diff --git a/docs/gallery/assets/cart-hero.webp b/docs/gallery/assets/cart-hero.webp index 891b275..33e7fcf 100644 Binary files a/docs/gallery/assets/cart-hero.webp and b/docs/gallery/assets/cart-hero.webp differ diff --git a/docs/gallery/assets/stone-well-hero.webp b/docs/gallery/assets/stone-well-hero.webp index c043ede..da50189 100644 Binary files a/docs/gallery/assets/stone-well-hero.webp and b/docs/gallery/assets/stone-well-hero.webp differ diff --git a/docs/gallery/cart/index.html b/docs/gallery/cart/index.html index 2441c4b..a097213 100644 --- a/docs/gallery/cart/index.html +++ b/docs/gallery/cart/index.html @@ -253,7 +253,7 @@

cart

cart render

Rendered headless by the example itself — click to zoom.

-
witnesses Recomputed: 2572 tris, two materials with 100 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.547×0.749×0.656 m, LOD ratios in band, convex collider 306 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
witnesses Recomputed: 2600 tris, two materials with metal and wood face floors, UVs in 0..1 with zero AABB overlap, outer AABB 1.539×0.749×0.640 m, LOD ratios in band, convex collider 122 tris, hygiene zero (loose, non-manifold, doubles, n-gons), grounded zmin, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget; --lift-z exits 16 on the grounded budget.
blender --background --python showcase/cart/cart.py --
@@ -264,17 +264,18 @@

cart

Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied, not imported as a package).

Budgets

Declared as named constants; every gate recomputes from the mesh, materials, UVs, evaluated LOD, collider, or export file.

-

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 2470–2680 | 2572 / 2572 / 2572 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2193 / 0.2193 / 0.2146 | | Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 100 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.547, 0.749, 0.656) m ± 0.01 | (1.5473, 0.7486, 0.6560), zmin 0 | | Collider tris | ≤ 360 | 306 | | Export | written, size > 0 | 202500 / 202500 / 202492 bytes |

+

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 2470–2900 | 2600 / 2600 / 2600 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.2154 | | Materials | exactly 2 distinct, metal ≥ 24, wood ≥ 800 faces | 2 slots, floors met | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.539, 0.749, 0.640) m ± 0.01 | (1.5393, 0.7486, 0.6400) | | Grounded | bbox min Z within 1e-4 of 0 | 0.0000 / 0.0000 / 0.0000 | | Hygiene | loose V/E, non-manifold, zero-area, doubles @1e-5, n-gons: all 0 | 0 / 0 / 0 on every axis | | Material-island gap | metal↔wood min distance ≤ 0.008 m | 0.00000 / 0.00000 / 0.00000 | | Collider tris | ≤ 360 | 122 | | Export | written, size > 0 | 201048 / 201048 / 201032 bytes |

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 8 B on 5.2.1 (glTF serializer), not a gated axis.

-

--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates.

+

--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. --lift-z raises the finished mesh 0.05 m so the grounded budget fails and exit 16 fires. Those are the named budgets the two falsifiers violate.

Run

blender --background --python cart.py --
 blender --background --python cart.py -- --skip-decimate
+blender --background --python cart.py -- --lift-z
 blender --background --python cart.py -- --output cart.png
-

Smoke does not pass --output or --skip-decimate.

+

Smoke does not pass --output, --skip-decimate, or --lift-z.

Exit codes

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path.

-

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 2 distinct slots, or metal faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file |

+

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 2 distinct slots, or metal faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file | | 15 | Hygiene: loose geometry, non-manifold, zero-area, doubles, or n-gons | | 16 | Bbox min Z not grounded (--lift-z lands here) | | 17 | Material-island gap above tolerance (parts meant to touch) |

Source

@@ -292,7 +293,8 @@

Source

Budgets are declared below and recomputed from the generated result. They are not API-contract witnesses. ``--skip-decimate`` skips the LOD -DECIMATE stage so the LOD-ratio budget fails. +DECIMATE stage so the LOD-ratio budget fails. ``--lift-z`` raises the +mesh so the grounded-zmin hygiene budget fails. No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts are not byte-identical across Blender versions — the LOD gate is a @@ -300,6 +302,7 @@

Source

blender --background --python cart.py -- blender --background --python cart.py -- --skip-decimate + blender --background --python cart.py -- --lift-z blender --background --python cart.py -- --output cart.png """ import argparse @@ -312,6 +315,7 @@

Source

import bmesh import bpy from mathutils import Euler, Vector +from mathutils.bvhtree import BVHTree _REPO = os.path.abspath( os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) @@ -321,9 +325,15 @@

Source

import gallery_framing # noqa: E402 RIM_MAJOR = 0.30 -RIM_MINOR = 0.028 +# Felloe ring: flat-tread box section, not a round tube — round torus rims +# read as bicycle wheels. The iron tyre wraps the tread. +RIM_RADIAL = 0.012 +RIM_W = 0.036 +TYRE_T = 0.008 +TYRE_W = 0.040 TRACK = 0.68 AXLE_X = -0.16 +AXLE_R = 0.020 HUB_R = 0.052 HUB_W = 0.046 N_SPOKES = 8 @@ -333,15 +343,23 @@

Source

BED_T = 0.038 WALL_H = 0.16 WALL_T = 0.032 +TAILGATE_H = 0.10 SHAFT_L = 0.58 SHAFT_T = 0.034 +SHAFT_PITCH = math.radians(7.0) N_SLATS = 6 IRON_T = 0.014 +BOLSTER_H = 0.044 BBOX_TOL = 0.01 -OUTER_SIZE = (1.547, 0.749, 0.656) +OUTER_SIZE = (1.539, 0.749, 0.640) BASE_TRIS_MIN = 2470 -BASE_TRIS_MAX = 2680 +BASE_TRIS_MAX = 2900 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +GAP_MAX = 0.008 +LIFT_Z = 0.05 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -355,6 +373,7 @@

Source

BAKE_RES = 256 CAGE_EXTRUSION = 0.08 METAL_FACES_MIN = 24 +WOOD_FACES_MIN = 800 WOOD_IDX = 0 METAL_IDX = 1 @@ -410,7 +429,7 @@

Source

geo = bmesh.ops.create_cone( bm, cap_ends=True, - cap_tris=False, + cap_tris=True, segments=segments, radius1=radius, radius2=radius, @@ -427,28 +446,39 @@

Source

return verts -def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): - n_major = 16 - n_minor = 8 +def add_ring(bm, loc, r_mid, radial_t, width, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + """Flat-sided ring with a box cross-section (felloe / tyre profile). + + Manifold: outer tread, inner surface, and two side annuli, all quads. + Built in the XY plane, width along Z, then rotated/translated. + """ + r_in = r_mid - radial_t + r_out = r_mid + radial_t + hw = width / 2.0 rings = [] - for i in range(n_major): - u = i * (2.0 * math.pi / n_major) - ring = [] - for j in range(n_minor): - v = j * (2.0 * math.pi / n_minor) - x = (major + minor * math.cos(v)) * math.cos(u) - y = (major + minor * math.cos(v)) * math.sin(u) - z = minor * math.sin(v) - ring.append(bm.verts.new((x, y, z))) - rings.append(ring) - bm.verts.ensure_lookup_table() - for i in range(n_major): - i2 = (i + 1) % n_major - for j in range(n_minor): - j2 = (j + 1) % n_minor - face = bm.faces.new( - (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2]) - ) + for i in range(segments): + u = i * (2.0 * math.pi / segments) + cu = math.cos(u) + su = math.sin(u) + rings.append( + [ + bm.verts.new((r_in * cu, r_in * su, -hw)), + bm.verts.new((r_out * cu, r_out * su, -hw)), + bm.verts.new((r_out * cu, r_out * su, hw)), + bm.verts.new((r_in * cu, r_in * su, hw)), + ] + ) + for i in range(segments): + i2 = (i + 1) % segments + a = rings[i] + b = rings[i2] + for quad in ( + (a[1], b[1], b[2], a[2]), + (a[3], b[3], b[0], a[0]), + (a[2], b[2], b[3], a[3]), + (a[0], b[0], b[1], a[1]), + ): + face = bm.faces.new(quad) face.material_index = mat_idx verts = [v for ring in rings for v in ring] rot = Euler(euler).to_matrix() @@ -503,7 +533,17 @@

Source

def add_wheel(bm, loc, wood, metal): wood.extend( - add_rim(bm, loc, RIM_MAJOR, RIM_MINOR, WOOD_IDX, euler=(math.pi / 2.0, 0.0, 0.0)) + add_ring( + bm, loc, RIM_MAJOR, RIM_RADIAL, RIM_W, 16, WOOD_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + ) + metal.extend( + add_ring( + bm, loc, RIM_MAJOR + RIM_RADIAL + TYRE_T / 2.0, TYRE_T / 2.0, + TYRE_W, 16, METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) ) wood.extend( add_cyl( @@ -516,8 +556,10 @@

Source

euler=(math.pi / 2.0, 0.0, 0.0), ) ) - inner = HUB_R + 0.008 - outer = RIM_MAJOR - RIM_MINOR * 0.55 + # Spokes root inside the hub and embed into the felloe ring — no + # floating ends hidden by the hub band. + inner = HUB_R * 0.4 + outer = RIM_MAJOR - RIM_RADIAL + 0.008 mid_r = 0.5 * (inner + outer) slen = outer - inner for i in range(N_SPOKES): @@ -567,7 +609,10 @@

Source

wood_wheels = [] metal = [] axle_z = RIM_MAJOR - bed_z = axle_z + 0.10 + # The bed rides on the axle through a bolster: axle top -> bolster -> + # bed bottom. Never let the axle interpenetrate the slats. + bed_z = axle_z + AXLE_R + BOLSTER_H + BED_T / 2.0 + bed_bottom = bed_z - BED_T / 2.0 bed_cx = 0.06 add_wheel(bm, (AXLE_X, TRACK / 2.0, axle_z), wood_wheels, metal) @@ -577,7 +622,7 @@

Source

add_cyl( bm, (AXLE_X, 0.0, axle_z), - 0.020, + AXLE_R, TRACK + 0.06, 10, METAL_IDX, @@ -597,6 +642,8 @@

Source

WOOD_IDX, ) ) + # Side walls and the front board finish flush at WALL_H; the back + # board is a deliberately lower tailgate. for ysign in (-1.0, 1.0): body.extend( add_box( @@ -609,32 +656,37 @@

Source

body.extend( add_box( bm, - (bed_cx + BED_L / 2.0 - WALL_T / 2.0, 0.0, bed_z + WALL_H / 2.0 + 0.02), - (WALL_T, BED_W + WALL_T * 2.0, WALL_H + 0.04), + (bed_cx + BED_L / 2.0 - WALL_T / 2.0, 0.0, bed_z + WALL_H / 2.0), + (WALL_T, BED_W + WALL_T * 2.0, WALL_H), WOOD_IDX, ) ) body.extend( add_box( bm, - (bed_cx - BED_L / 2.0 + WALL_T / 2.0, 0.0, bed_z + 0.06), - (WALL_T, BED_W + WALL_T, 0.12), + (bed_cx - BED_L / 2.0 + WALL_T / 2.0, 0.0, bed_z + TAILGATE_H / 2.0), + (WALL_T, BED_W + WALL_T * 2.0, TAILGATE_H), WOOD_IDX, ) ) - for xj in (bed_cx - BED_L * 0.32, bed_cx + BED_L * 0.28): + # Bolsters: one sits on the axle, one forward; both carry the bed. + for xj in (AXLE_X, bed_cx + BED_L * 0.28): body.extend( add_box( bm, - (xj, 0.0, bed_z - BED_T / 2.0 - 0.022), - (0.055, BED_W * 0.92, 0.044), + (xj, 0.0, axle_z + AXLE_R + BOLSTER_H / 2.0), + (0.055, BED_W * 0.92, BOLSTER_H), WOOD_IDX, ) ) + # Shafts hang under the bed front: the back end embeds 6 mm into the + # slat bottom and never pokes through the bed floor. shaft_x = bed_cx + BED_L / 2.0 + SHAFT_L / 2.0 - 0.04 - shaft_z = bed_z - 0.02 - pitch = math.radians(7.0) + shaft_z = ( + bed_bottom + 0.006 - SHAFT_T / 2.0 + - math.sin(SHAFT_PITCH) * (SHAFT_L / 2.0) + ) for ysign in (-1.0, 1.0): body.extend( add_box( @@ -642,28 +694,30 @@

Source

(shaft_x, ysign * 0.12, shaft_z), (SHAFT_L, SHAFT_T, SHAFT_T), WOOD_IDX, - euler=(0.0, pitch, 0.0), + euler=(0.0, SHAFT_PITCH, 0.0), ) ) metal.extend( add_box( bm, - (bed_cx + BED_L / 2.0 - 0.02, ysign * 0.12, bed_z - 0.01), + (bed_cx + BED_L / 2.0 - 0.02, ysign * 0.12, bed_bottom - IRON_T / 2.0), (0.05, 0.042, IRON_T), METAL_IDX, ) ) + # Tie-down plates sit on top of the bed floor, fully inboard of the + # walls — visible, not buried inside the slats. for sx, sy in ( - (bed_cx - BED_L / 2.0 + 0.04, -BED_W / 2.0), - (bed_cx - BED_L / 2.0 + 0.04, BED_W / 2.0), - (bed_cx + BED_L / 2.0 - 0.04, -BED_W / 2.0), - (bed_cx + BED_L / 2.0 - 0.04, BED_W / 2.0), + (bed_cx - BED_L / 2.0 + 0.055, -BED_W / 2.0 + 0.055), + (bed_cx - BED_L / 2.0 + 0.055, BED_W / 2.0 - 0.055), + (bed_cx + BED_L / 2.0 - 0.055, -BED_W / 2.0 + 0.055), + (bed_cx + BED_L / 2.0 - 0.055, BED_W / 2.0 - 0.055), ): metal.extend( add_box( bm, - (sx, sy, bed_z + 0.01), + (sx, sy, bed_z + BED_T / 2.0 + IRON_T / 2.0), (0.055, 0.055, IRON_T), METAL_IDX, ) @@ -712,13 +766,33 @@

Source

return out -def principled(name, color, metallic, roughness): +def principled(name, color, metallic, roughness, noise_scale=0.0, wear=None): mat = bpy.data.materials.new(name) mat.use_nodes = True - bsdf = mat.node_tree.nodes["Principled BSDF"] + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = color bsdf.inputs["Metallic"].default_value = metallic bsdf.inputs["Roughness"].default_value = roughness + if noise_scale > 0.0 and wear is not None: + tex = nt.nodes.new("ShaderNodeTexNoise") + tex.inputs["Scale"].default_value = noise_scale + tex.inputs["Detail"].default_value = 8.0 + tex.inputs["Roughness"].default_value = 0.55 + mix = nt.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + mix.inputs["A"].default_value = color + mix.inputs["B"].default_value = wear + fac = mix.inputs.get("Factor") or mix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], fac) + nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"]) + rmix = nt.nodes.new("ShaderNodeMix") + rmix.data_type = "FLOAT" + rmix.inputs["A"].default_value = roughness + rmix.inputs["B"].default_value = min(1.0, roughness + 0.18) + rfac = rmix.inputs.get("Factor") or rmix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], rfac) + nt.links.new(rmix.outputs["Result"], bsdf.inputs["Roughness"]) return mat @@ -765,6 +839,84 @@

Source

return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) +def face_area(me, poly): + vs = [me.vertices[i].co for i in poly.vertices] + if len(vs) < 3: + return 0.0 + v0 = vs[0] + area = 0.0 + for i in range(1, len(vs) - 1): + area += (vs[i] - v0).cross(vs[i + 1] - v0).length * 0.5 + return area + + +def hygiene_audit(me): + # Combinatorics match examples/mesh-hygiene-audit.audit (copied, not imported). + nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + areas = [face_area(me, p) for p in me.polygons] + zero_area = sum(1 for a in areas if a <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) + loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) + nonman = sum(1 for e in bm.edges if not e.is_manifold) + ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) + doubles = len(ret.get("targetmap") or {}) + finally: + bm.free() + return { + "nv": nv, + "ne": ne, + "nf": nf, + "ngons": ngons, + "loose_v": loose_v, + "loose_e": loose_e, + "nonman": nonman, + "zero_area": zero_area, + "doubles": doubles, + "euler": nv - ne + nf, + } + + +def min_mat_distance(me, ia, ib): + """Closest surface distance between two material islands via BVH. + + Vert-vert distance is the wrong metric for thin parts: a face interior + can touch while its corner verts sit a radius apart. + """ + bm_a = bmesh.new() + bm_b = bmesh.new() + try: + bm_a.from_mesh(me) + bm_b.from_mesh(me) + bm_a.faces.ensure_lookup_table() + bm_b.faces.ensure_lookup_table() + drop_a = [f for f in bm_a.faces if f.material_index != ia] + drop_b = [f for f in bm_b.faces if f.material_index != ib] + if drop_a: + bmesh.ops.delete(bm_a, geom=drop_a, context="FACES") + if drop_b: + bmesh.ops.delete(bm_b, geom=drop_b, context="FACES") + if not bm_a.faces or not bm_b.faces: + return 1e9 + tree = BVHTree.FromBMesh(bm_b) + best = 1e9 + for src in list(bm_a.verts) + list(bm_a.faces): + co = src.co if hasattr(src, "co") else src.calc_center_median() + hit = tree.find_nearest(co) + if hit[0] is None: + continue + best = min(best, hit[3]) + return best + finally: + bm_a.free() + bm_b.free() + + def make_lod(obj, name, ratio, skip_decimate): mesh = obj.data.copy() lod = bpy.data.objects.new(name, mesh) @@ -851,14 +1003,24 @@

Source

) -def check(skip_decimate): +def check(skip_decimate, lift_z=False): bpy.ops.wm.read_factory_settings(use_empty=True) low = build_cart_mesh("CartLow", bevel_offset=0.005, bevel_segments=2) high = build_cart_mesh("CartHigh", bevel_offset=0.005, bevel_segments=4) - wood = principled("CartWood", (0.42, 0.22, 0.08, 1.0), 0.0, 0.58) - metal = principled("CartIron", (0.13, 0.135, 0.15, 1.0), 1.0, 0.32) + wood = principled( + "CartWood", (0.42, 0.22, 0.08, 1.0), 0.0, 0.58, + noise_scale=7.0, wear=(0.26, 0.12, 0.04, 1.0), + ) + metal = principled( + "CartIron", (0.13, 0.135, 0.15, 1.0), 1.0, 0.32, + noise_scale=5.0, wear=(0.05, 0.05, 0.06, 1.0), + ) assign_slots(low, wood, metal) assign_slots(high, wood, metal) + if lift_z: + for v in low.data.vertices: + v.co.z += LIFT_Z + low.data.update() if low.data is None or len(low.data.polygons) < 6: return fail("cart mesh did not build", 3), None, None, None, None, None @@ -921,6 +1083,14 @@

Source

f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}" ) + hyg = hygiene_audit(low.data) + gap_mw = min_mat_distance(low.data, METAL_IDX, WOOD_IDX) + print( + f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} euler={hyg['euler']}" + ) + print(f"measured gap_metal_wood={gap_mw:.5f}") if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -937,6 +1107,11 @@

Source

f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}", 5, ), None, None, None, None, None + if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN: + return fail( + f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}", + 5, + ), None, None, None, None, None if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: return fail( f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", @@ -980,6 +1155,32 @@

Source

), None, None, None, None, None if export_size <= 0: return fail("export file missing or empty", 13), None, None, None, None, None + if ( + hyg["loose_v"] + or hyg["loose_e"] + or hyg["nonman"] + or hyg["zero_area"] + or hyg["doubles"] + or hyg["ngons"] + ): + return fail( + f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']}", + 15, + ), None, None, None, None, None + if abs(bb[2]) > ZMIN_EPS: + return fail( + f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 " + "(--lift-z is the designed fail)", + 16, + ), None, None, None, None, None + if gap_mw > GAP_MAX: + return fail( + f"metal-wood gap {gap_mw:.5f} > {GAP_MAX} " + "(tyres, hubs, straps, and plates must touch the wood they mount to)", + 17, + ), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -1098,9 +1299,16 @@

Source

action="store_true", help="falsification: skip the LOD DECIMATE stage", ) + p.add_argument( + "--lift-z", + action="store_true", + help="falsification: lift the mesh so zmin fails the grounded budget", + ) args = p.parse_args(argv) - code, low, _high, wood, tex, _col = check(args.skip_decimate) + code, low, _high, wood, tex, _col = check( + args.skip_decimate, lift_z=args.lift_z + ) if code: return code if args.output: diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 8915f9c..8e07123 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -903,7 +903,7 @@

shipping-crate

stone-well

A procedural stone well through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

-

witnesses Recomputed: 8388 tris, three materials, UVs in 0..1 with zero AABB overlap, outer AABB 1.640×1.640×1.688 m, LOD ratios in band, convex collider 230 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+

witnesses Recomputed: 9380 tris, three materials with face floors, UVs in 0..1 with zero AABB overlap, outer AABB 1.640×1.640×1.761 m, LOD ratios in band, convex collider 306 tris, hygiene zero (loose, non-manifold, doubles, n-gons), grounded zmin, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget; --lift-z exits 16 on the grounded budget.

View example
@@ -1002,7 +1002,7 @@

watchtower

cart

A procedural two-wheel wooden cart through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

-

witnesses Recomputed: 2572 tris, two materials with 100 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.547×0.749×0.656 m, LOD ratios in band, convex collider 306 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+

witnesses Recomputed: 2600 tris, two materials with metal and wood face floors, UVs in 0..1 with zero AABB overlap, outer AABB 1.539×0.749×0.640 m, LOD ratios in band, convex collider 122 tris, hygiene zero (loose, non-manifold, doubles, n-gons), grounded zmin, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget; --lift-z exits 16 on the grounded budget.

View example
diff --git a/docs/gallery/iron-cauldron/index.html b/docs/gallery/iron-cauldron/index.html index 239ed7f..ea55dc7 100644 --- a/docs/gallery/iron-cauldron/index.html +++ b/docs/gallery/iron-cauldron/index.html @@ -265,7 +265,7 @@

iron-cauldron

Budgets

Declared as named constants; every gate recomputes from the mesh, materials, UVs, evaluated LOD, collider, or export file.

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 3280–3520 | 3388 / 3388 / 3388 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2172 | | Materials | exactly 2 distinct, ≥12 wood, ≥24 metal | 2 slots, 450 wood, 1198 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.013, 0.986, 0.827) m ± 0.01 | (1.0128, 0.9865, 0.8271), zmin 0.002 | | Pot–leg clearance | ≥ 0.04 m (recomputed vs pole axes) | 0.0513 / 0.0513 / 0.0513 | | Collider tris | ≤ 200 | 98 | | Export | written, size > 0 | 245060 / 245060 / 245044 bytes |

-

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is 4 tris leaner on LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 12 B on 5.2.1 (glTF serializer), not a gated axis.

+

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is 8 tris leaner on LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a gated axis. The pot–leg clearance is recomputed from shell verts vs the three pole axes; clipping exits 3.

--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates.

Run

blender --background --python iron_cauldron.py --
diff --git a/docs/gallery/stone-well/index.html b/docs/gallery/stone-well/index.html
index 72ece19..352f183 100644
--- a/docs/gallery/stone-well/index.html
+++ b/docs/gallery/stone-well/index.html
@@ -253,7 +253,7 @@ 

stone-well

stone-well render

Rendered headless by the example itself — click to zoom.

-
witnesses Recomputed: 8388 tris, three materials, UVs in 0..1 with zero AABB overlap, outer AABB 1.640×1.640×1.688 m, LOD ratios in band, convex collider 230 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
witnesses Recomputed: 9380 tris, three materials with face floors, UVs in 0..1 with zero AABB overlap, outer AABB 1.640×1.640×1.761 m, LOD ratios in band, convex collider 306 tris, hygiene zero (loose, non-manifold, doubles, n-gons), grounded zmin, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget; --lift-z exits 16 on the grounded budget.
blender --background --python showcase/stone-well/stone_well.py --
@@ -264,17 +264,18 @@

stone-well

Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied, not imported as a package).

Budgets

Declared as named constants; every gate recomputes from the mesh, materials, UVs, evaluated LOD, collider, or export file.

-

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 8280–8500 | 8388 / 8388 / 8388 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 | | Materials | exactly 3 distinct | 3 | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.640, 1.640, 1.688) m ± 0.01 | (1.6400, 1.6400, 1.6882), zmin 0 | | Collider tris | ≤ 260 | 230 | | Export | written, size > 0 | 601028 / 601092 / 601372 bytes |

+

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 8280–9500 | 9380 / 9380 / 9380 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 | | Materials | exactly 3 distinct | 3 | | Material faces | stone ≥ 3000, wood ≥ 600, metal ≥ 100 | all above | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.640, 1.640, 1.761) m ± 0.01 | (1.6400, 1.6400, 1.7606) | | Grounded | bbox min Z within 1e-4 of 0 | 0.0000 / 0.0000 / 0.0000 | | Hygiene | loose V/E, non-manifold, zero-area, doubles @1e-5, n-gons: all 0 | 0 / 0 / 0 on every axis | | Material-island gap | stone↔wood and metal↔wood min distance ≤ 0.008 m | 0.00000 / 0.00000 / 0.00000 | | Collider tris | ≤ 320 | 306 | | Export | written, size > 0 | 686672 / 686736 / 686724 bytes |

DECIMATE COLLAPSE triangle counts are not guaranteed identical across series — the gate is a ratio band, not an exact count. This mesh happened to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. glTF byte size differs by a few hundred bytes across series.

-

--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates.

+

--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. --lift-z raises the finished mesh 0.05 m so the grounded budget fails and exit 16 fires. Those are the named budgets the two falsifiers violate.

Run

blender --background --python stone_well.py --
 blender --background --python stone_well.py -- --skip-decimate
+blender --background --python stone_well.py -- --lift-z
 blender --background --python stone_well.py -- --output well.png
-

Smoke does not pass --output or --skip-decimate.

+

Smoke does not pass --output, --skip-decimate, or --lift-z.

Exit codes

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path.

-

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 3 distinct slots | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file |

+

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 3 distinct slots, or a material face floor missed | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file | | 15 | Hygiene: loose geometry, non-manifold, zero-area, doubles, or n-gons | | 16 | Bbox min Z not grounded (--lift-z lands here) | | 17 | Material-island gap above tolerance (parts meant to touch) |

Source

@@ -290,14 +291,16 @@

Source

Budgets are declared below and recomputed from the generated result. They are not API-contract witnesses. ``--skip-decimate`` skips the LOD -DECIMATE stage so the LOD-ratio budget fails. +DECIMATE stage so the LOD-ratio budget fails. ``--lift-z`` raises the +mesh so the grounded-zmin hygiene budget fails. -No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts -are not byte-identical across Blender versions — the LOD gate is a -ratio band, not an exact count. +No RNG. Construction is closed-form (per-stone jitter is a deterministic +hash). DECIMATE COLLAPSE triangle counts are not byte-identical across +Blender versions — the LOD gate is a ratio band, not an exact count. blender --background --python stone_well.py -- blender --background --python stone_well.py -- --skip-decimate + blender --background --python stone_well.py -- --lift-z blender --background --python stone_well.py -- --output well.png """ import argparse @@ -310,6 +313,7 @@

Source

import bmesh import bpy from mathutils import Euler, Vector +from mathutils.bvhtree import BVHTree # Showcase lives at repo-root/showcase/, not under examples/. The framing # helper is the repo's only shared import and lives next to the examples; @@ -331,12 +335,17 @@

Source

R_MID = (R_INNER + R_OUTER) / 2.0 WALL_H = 0.72 STONE_H = WALL_H / N_ROWS +STONE_FACE_H = STONE_H * 0.90 +# Top of the masonry is the top of the last course, not the nominal WALL_H: +# the curb seats on the measured course top so no daylight shows at the rim. +MASONRY_TOP = STONE_FACE_H + (N_ROWS - 1) * STONE_H CURB_H = 0.065 CURB_OUT = 0.045 +CURB_Z = MASONRY_TOP + CURB_H / 2.0 POST_S = 0.068 POST_R = 0.55 POST_H = 0.58 -POST_BOTTOM = WALL_H + CURB_H +POST_BOTTOM = MASONRY_TOP + CURB_H POST_TOP = POST_BOTTOM + POST_H EAVE_OVERHANG = 0.22 EAVE_HALF = POST_R + POST_S / 2.0 + EAVE_OVERHANG @@ -345,18 +354,31 @@

Source

PEAK_Z = EAVE_Z + ROOF_RISE SHINGLE_T = 0.016 WINDLASS_R = 0.045 -BUCKET_R = 0.10 +# The windlass is the axle the posts are the bearings for: it passes through +# both posts and protrudes so the crank has something to attach to. +WINDLASS_END = POST_R + POST_S / 2.0 + 0.015 +WINDLASS_LEN = 2.0 * WINDLASS_END +BUCKET_R_TOP = 0.105 +BUCKET_R_BOT = 0.088 +BUCKET_WALL_T = 0.008 BUCKET_H = 0.14 BUCKET_Z = 0.68 +BUCKET_RIM_Z = BUCKET_Z + BUCKET_H / 2.0 +HANDLE_BAR_Z = BUCKET_RIM_Z + 0.03 ROPE_R = 0.016 BBOX_TOL = 0.01 # Fitted to the generated AABB after locking geometry. Recomputed from bound_box. -OUTER_SIZE = (1.640, 1.640, 1.688) +OUTER_SIZE = (1.640, 1.640, 1.761) # Measured after locking geometry. DECIMATE COLLAPSE ratios diverge across # series — bands, not exact counts. Tightened after the first 4.5/5.1/5.2 run. BASE_TRIS_MIN = 8280 -BASE_TRIS_MAX = 8500 +BASE_TRIS_MAX = 9500 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +GAP_MAX = 0.008 +LIFT_Z = 0.05 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -364,9 +386,12 @@

Source

LOD1_TARGET = 0.50 LOD2_TARGET = 0.22 MATERIAL_COUNT = 3 +STONE_FACES_MIN = 3000 +WOOD_FACES_MIN = 600 +METAL_FACES_MIN = 100 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 260 +COLLIDER_TRIS_MAX = 320 BAKE_RES = 256 CAGE_EXTRUSION = 0.06 @@ -415,11 +440,14 @@

Source

return verts -def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): +def add_cone( + bm, loc, radius1, radius2, depth, segments, mat_idx, + euler=(0.0, 0.0, 0.0), cap_ends=True, +): geo = bmesh.ops.create_cone( bm, - cap_ends=True, - cap_tris=False, + cap_ends=cap_ends, + cap_tris=True, segments=segments, radius1=radius1, radius2=radius2, @@ -483,23 +511,81 @@

Source

) +def hash01(a, b, c): + # Deterministic per-stone jitter. Closed-form, no RNG state. + return math.sin(a * 127.1 + b * 311.7 + c * 74.7) * 43758.5453 % 1.0 + + +def ring_verts(verts, z, eps=1e-6): + return sorted( + (v for v in verts if abs(v.co.z - z) < eps), + key=lambda v: math.atan2(v.co.y, v.co.x), + ) + + +def add_open_bucket(bm, loc, r_bot, r_top, wall_t, depth, segments, mat_idx): + """Open-topped tapered bucket: outer wall, inner wall, rim ring, floor. + + Manifold single shell. The bottom slab has real thickness: the inner + floor sits FLOOR_T above the outer bottom disc, so every edge has + exactly two faces. + """ + floor_t = 0.012 + z0 = loc[2] - depth / 2.0 + z1 = loc[2] + depth / 2.0 + outer = add_cone( + bm, loc, r_bot, r_top, depth, segments, mat_idx, cap_ends=False, + ) + inner_loc = (loc[0], loc[1], loc[2] + floor_t / 2.0) + inner = add_cone( + bm, inner_loc, r_bot - wall_t, r_top - wall_t, depth - floor_t, + segments, mat_idx, cap_ends=False, + ) + ob = ring_verts(outer, z0) + ot = ring_verts(outer, z1) + ib = ring_verts(inner, z0 + floor_t) + it = ring_verts(inner, z1) + n = segments + for i in range(n): + j = (i + 1) % n + f = bm.faces.new((ot[i], ot[j], it[j], it[i])) + f.material_index = mat_idx + # Inner floor faces up into the hollow; outer bottom disc faces down. + ci = bm.verts.new((loc[0], loc[1], z0 + floor_t)) + co = bm.verts.new((loc[0], loc[1], z0)) + for i in range(n): + j = (i + 1) % n + f = bm.faces.new((ci, ib[i], ib[j])) + f.material_index = mat_idx + f = bm.faces.new((co, ob[j], ob[i])) + f.material_index = mat_idx + + def build_well_mesh(name, bevel_offset, bevel_segments): bm = bmesh.new() stone_verts = [] + wood_bevel_verts = [] try: - stone_w = 2.0 * R_MID * math.tan(math.pi / N_AROUND) * 0.88 - actual_h = STONE_H * 0.90 for row in range(N_ROWS): - z = actual_h / 2.0 + row * STONE_H + z = STONE_FACE_H / 2.0 + row * STONE_H rot_off = (row % 2) * (math.pi / N_AROUND) for i in range(N_AROUND): ang = 2.0 * math.pi * i / N_AROUND + rot_off - loc = (R_MID * math.cos(ang), R_MID * math.sin(ang), z) + # Seeded jitter: width and radial seat vary per stone, course + # tops stay level so the curb seats flat. + wj = 0.88 + 0.10 * (hash01(row, i, 0) - 0.5) + rj = (hash01(row, i, 1) - 0.5) * 0.008 + stone_w = 2.0 * R_MID * math.tan(math.pi / N_AROUND) * wj + loc = ( + (R_MID + rj) * math.cos(ang), + (R_MID + rj) * math.sin(ang), + z, + ) stone_verts.extend( add_box( bm, loc, - (STONE_D, stone_w, actual_h), + (STONE_D, stone_w, STONE_FACE_H), STONE_IDX, euler=(0.0, 0.0, ang), ) @@ -507,10 +593,9 @@

Source

curb_r = R_OUTER + CURB_OUT / 2.0 curb_w = 2.0 * curb_r * math.tan(math.pi / N_AROUND) * 0.90 - curb_z = WALL_H + CURB_H / 2.0 for i in range(N_AROUND): ang = 2.0 * math.pi * i / N_AROUND - loc = (curb_r * math.cos(ang), curb_r * math.sin(ang), curb_z) + loc = (curb_r * math.cos(ang), curb_r * math.sin(ang), CURB_Z) stone_verts.extend( add_box( bm, @@ -540,30 +625,44 @@

Source

POST_R * math.sin(ang), POST_BOTTOM + POST_H / 2.0, ) - add_box(bm, loc, (POST_S, POST_S, POST_H), WOOD_IDX) + wood_bevel_verts.extend( + add_box(bm, loc, (POST_S, POST_S, POST_H), WOOD_IDX) + ) beam_z = POST_TOP - 0.05 - add_box( - bm, - (0.0, 0.0, beam_z), - (POST_R * 2.0 - POST_S, POST_S * 0.85, POST_S * 0.85), - WOOD_IDX, + wood_bevel_verts.extend( + add_box( + bm, + (0.0, 0.0, beam_z), + (POST_R * 2.0 - POST_S, POST_S * 0.85, POST_S * 0.85), + WOOD_IDX, + ) ) add_cylinder( bm, (0.0, 0.0, beam_z), WINDLASS_R, - POST_R * 2.0 - POST_S * 1.4, + WINDLASS_LEN, 12, WOOD_IDX, euler=(0.0, math.pi / 2.0, 0.0), ) + # Crank: arm pinned to the protruding windlass end, grip parallel to + # the windlass axis at the arm's lower end. add_box( bm, - (POST_R - POST_S * 0.2, 0.0, beam_z), - (0.018, 0.12, 0.018), + (WINDLASS_END + 0.004, 0.0, beam_z - 0.055), + (0.020, 0.022, 0.13), METAL_IDX, - euler=(0.0, 0.0, math.radians(25.0)), + ) + add_cylinder( + bm, + (WINDLASS_END + 0.055, 0.0, beam_z - 0.12), + 0.014, + 0.10, + 10, + METAL_IDX, + euler=(0.0, math.pi / 2.0, 0.0), ) pitch = math.atan(ROOF_RISE / EAVE_HALF) @@ -578,6 +677,17 @@

Source

WOOD_IDX, euler=(0.0, 0.0, math.pi / 4.0), ) + # Cap the open pit where the four shingle courses meet at the peak. + add_cone( + bm, + (0.0, 0.0, PEAK_Z + 0.035), + 0.075, + 0.015, + 0.11, + 4, + WOOD_IDX, + euler=(0.0, 0.0, math.pi / 4.0), + ) nrm_local = Vector((0.0, ROOF_RISE, EAVE_HALF)).normalized() def add_course(yaw, t0, t1): @@ -623,27 +733,49 @@

Source

add_course(yaw, t0, t1) fascia_h = 0.045 fascia_t = 0.032 + # Butt joints: the X-running boards span the full eave; the Y-running + # boards embed 2 mm into them. An exact flush butt lands board end + # verts on the other board's corner verts (doubles at 1e-5). for side in range(4): yaw = side * (math.pi / 2.0) fx = EAVE_HALF * math.sin(yaw) fy = EAVE_HALF * math.cos(yaw) if side % 2 == 0: - add_box( - bm, - (0.0, fy, EAVE_Z - fascia_h / 2.0), - (2.0 * EAVE_HALF + fascia_t, fascia_t, fascia_h), - WOOD_IDX, + wood_bevel_verts.extend( + add_box( + bm, + (0.0, fy, EAVE_Z - fascia_h / 2.0), + (2.0 * EAVE_HALF + fascia_t, fascia_t, fascia_h), + WOOD_IDX, + ) ) else: - add_box( - bm, - (fx, 0.0, EAVE_Z - fascia_h / 2.0), - (fascia_t, 2.0 * EAVE_HALF + fascia_t, fascia_h), - WOOD_IDX, + wood_bevel_verts.extend( + add_box( + bm, + (fx, 0.0, EAVE_Z - fascia_h / 2.0), + (fascia_t, 2.0 * EAVE_HALF - fascia_t + 0.004, fascia_h), + WOOD_IDX, + ) ) + if bevel_offset > 0.0: + edges = list({e for v in wood_bevel_verts for e in v.link_edges}) + ret = bmesh.ops.bevel( + bm, + geom=edges, + offset=min(bevel_offset, 0.006), + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + for f in ret.get("faces") or []: + f.material_index = WOOD_IDX + + # Rope ties off at the bail handle bar, not mid-air above the bucket. rope_top = beam_z - WINDLASS_R - rope_bot = BUCKET_Z + BUCKET_H / 2.0 + rope_bot = HANDLE_BAR_Z rope_h = rope_top - rope_bot add_cylinder( bm, @@ -653,32 +785,42 @@

Source

8, WOOD_IDX, ) - add_cylinder(bm, (0.0, 0.0, BUCKET_Z), BUCKET_R, BUCKET_H, 12, WOOD_IDX) + add_open_bucket( + bm, + (0.0, 0.0, BUCKET_Z), + BUCKET_R_BOT, + BUCKET_R_TOP, + BUCKET_WALL_T, + BUCKET_H, + 12, + WOOD_IDX, + ) + + def bucket_r_at(z): + t = (z - (BUCKET_Z - BUCKET_H / 2.0)) / BUCKET_H + return BUCKET_R_BOT + t * (BUCKET_R_TOP - BUCKET_R_BOT) + for hz in (-BUCKET_H * 0.28, BUCKET_H * 0.28): add_cylinder( bm, (0.0, 0.0, BUCKET_Z + hz), - BUCKET_R + 0.008, + bucket_r_at(BUCKET_Z + hz) + 0.004, 0.018, 12, METAL_IDX, ) + # Bail handle: legs pinned to the outside of the rim, bar across. + for xs in (-1.0, 1.0): + add_box( + bm, + (xs * (BUCKET_R_TOP + 0.006), 0.0, BUCKET_RIM_Z - 0.015), + (0.014, 0.014, 0.09), + METAL_IDX, + ) add_box( bm, - (0.0, 0.0, BUCKET_Z + BUCKET_H / 2.0 + 0.04), - (BUCKET_R * 1.6, 0.014, 0.014), - METAL_IDX, - ) - add_box( - bm, - (-BUCKET_R * 0.72, 0.0, BUCKET_Z + BUCKET_H / 2.0 + 0.02), - (0.014, 0.014, 0.05), - METAL_IDX, - ) - add_box( - bm, - (BUCKET_R * 0.72, 0.0, BUCKET_Z + BUCKET_H / 2.0 + 0.02), - (0.014, 0.014, 0.05), + (0.0, 0.0, HANDLE_BAR_Z), + (2.0 * (BUCKET_R_TOP + 0.020), 0.014, 0.014), METAL_IDX, ) @@ -701,21 +843,48 @@

Source

return obj -def principled(name, color, metallic, roughness): +def principled(name, color, metallic, roughness, noise_scale=0.0, wear=None): mat = bpy.data.materials.new(name) mat.use_nodes = True - bsdf = mat.node_tree.nodes["Principled BSDF"] + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = color bsdf.inputs["Metallic"].default_value = metallic bsdf.inputs["Roughness"].default_value = roughness + if noise_scale > 0.0 and wear is not None: + tex = nt.nodes.new("ShaderNodeTexNoise") + tex.inputs["Scale"].default_value = noise_scale + tex.inputs["Detail"].default_value = 8.0 + tex.inputs["Roughness"].default_value = 0.55 + mix = nt.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + mix.inputs["A"].default_value = color + mix.inputs["B"].default_value = wear + fac = mix.inputs.get("Factor") or mix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], fac) + nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"]) + rmix = nt.nodes.new("ShaderNodeMix") + rmix.data_type = "FLOAT" + rmix.inputs["A"].default_value = roughness + rmix.inputs["B"].default_value = min(1.0, roughness + 0.18) + rfac = rmix.inputs.get("Factor") or rmix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], rfac) + nt.links.new(rmix.outputs["Result"], bsdf.inputs["Roughness"]) return mat def assign_slots(obj, stone, wood, metal): - obj.data.materials.clear() - obj.data.materials.append(stone) - obj.data.materials.append(wood) - obj.data.materials.append(metal) + # Index-preserving: materials.clear() resets every polygon's + # material_index to 0 (the piece would render all-stone). Assign by + # slot position instead; the per-material face-count budgets in check() + # prove the indices survive. + mats = obj.data.materials + wanted = (stone, wood, metal) + for i, mat in enumerate(wanted): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) def world_bbox(obj): @@ -751,6 +920,84 @@

Source

return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) +def face_area(me, poly): + vs = [me.vertices[i].co for i in poly.vertices] + if len(vs) < 3: + return 0.0 + v0 = vs[0] + area = 0.0 + for i in range(1, len(vs) - 1): + area += (vs[i] - v0).cross(vs[i + 1] - v0).length * 0.5 + return area + + +def hygiene_audit(me): + # Combinatorics match examples/mesh-hygiene-audit.audit (copied, not imported). + nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + areas = [face_area(me, p) for p in me.polygons] + zero_area = sum(1 for a in areas if a <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) + loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) + nonman = sum(1 for e in bm.edges if not e.is_manifold) + ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) + doubles = len(ret.get("targetmap") or {}) + finally: + bm.free() + return { + "nv": nv, + "ne": ne, + "nf": nf, + "ngons": ngons, + "loose_v": loose_v, + "loose_e": loose_e, + "nonman": nonman, + "zero_area": zero_area, + "doubles": doubles, + "euler": nv - ne + nf, + } + + +def min_mat_distance(me, ia, ib): + """Closest surface distance between two material islands via BVH. + + Vert-vert distance is the wrong metric for thin parts: a face interior + can touch while its corner verts sit a radius apart. + """ + bm_a = bmesh.new() + bm_b = bmesh.new() + try: + bm_a.from_mesh(me) + bm_b.from_mesh(me) + bm_a.faces.ensure_lookup_table() + bm_b.faces.ensure_lookup_table() + drop_a = [f for f in bm_a.faces if f.material_index != ia] + drop_b = [f for f in bm_b.faces if f.material_index != ib] + if drop_a: + bmesh.ops.delete(bm_a, geom=drop_a, context="FACES") + if drop_b: + bmesh.ops.delete(bm_b, geom=drop_b, context="FACES") + if not bm_a.faces or not bm_b.faces: + return 1e9 + tree = BVHTree.FromBMesh(bm_b) + best = 1e9 + for src in list(bm_a.verts) + list(bm_a.faces): + co = src.co if hasattr(src, "co") else src.calc_center_median() + hit = tree.find_nearest(co) + if hit[0] is None: + continue + best = min(best, hit[3]) + return best + finally: + bm_a.free() + bm_b.free() + + def make_lod(obj, name, ratio, skip_decimate): mesh = obj.data.copy() lod = bpy.data.objects.new(name, mesh) @@ -843,15 +1090,28 @@

Source

) -def check(skip_decimate): +def check(skip_decimate, lift_z=False): bpy.ops.wm.read_factory_settings(use_empty=True) low = build_well_mesh("WellLow", bevel_offset=0.010, bevel_segments=2) high = build_well_mesh("WellHigh", bevel_offset=0.010, bevel_segments=4) - stone = principled("WellStone", (0.40, 0.42, 0.46, 1.0), 0.0, 0.84) - wood = principled("WellWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50) - metal = principled("WellMetal", (0.62, 0.58, 0.48, 1.0), 1.0, 0.25) + stone = principled( + "WellStone", (0.40, 0.42, 0.46, 1.0), 0.0, 0.84, + noise_scale=9.0, wear=(0.29, 0.30, 0.33, 1.0), + ) + wood = principled( + "WellWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50, + noise_scale=7.0, wear=(0.30, 0.13, 0.04, 1.0), + ) + metal = principled( + "WellMetal", (0.62, 0.58, 0.48, 1.0), 1.0, 0.30, + noise_scale=5.0, wear=(0.34, 0.32, 0.27, 1.0), + ) assign_slots(low, stone, wood, metal) assign_slots(high, stone, wood, metal) + if lift_z: + for v in low.data.vertices: + v.co.z += LIFT_Z + low.data.update() if low.data is None or len(low.data.polygons) < 6: return fail("well mesh did not build", 3), None, None, None, None, None @@ -860,6 +1120,10 @@

Source

mats = [s for s in low.data.materials if s is not None] nmat = len(mats) distinct_mats = len({id(s) for s in mats}) + idx_counts = {} + for poly in low.data.polygons: + idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1 + print(f"measured mat_index_counts={idx_counts}") u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data) bb = world_bbox(low) size_x = bb[3] - bb[0] @@ -910,6 +1174,15 @@

Source

f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}" ) + hyg = hygiene_audit(low.data) + gap_sw = min_mat_distance(low.data, STONE_IDX, WOOD_IDX) + gap_mw = min_mat_distance(low.data, METAL_IDX, WOOD_IDX) + print( + f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} euler={hyg['euler']}" + ) + print(f"measured gap_stone_wood={gap_sw:.5f} gap_metal_wood={gap_mw:.5f}") if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -921,6 +1194,21 @@

Source

f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", 5, ), None, None, None, None, None + if idx_counts.get(STONE_IDX, 0) < STONE_FACES_MIN: + return fail( + f"stone faces {idx_counts.get(STONE_IDX, 0)} < {STONE_FACES_MIN}", + 5, + ), None, None, None, None, None + if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN: + return fail( + f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}", + 5, + ), None, None, None, None, None + if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN: + return fail( + f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}", + 5, + ), None, None, None, None, None if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: return fail( f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", @@ -964,6 +1252,38 @@

Source

), None, None, None, None, None if export_size <= 0: return fail("export file missing or empty", 13), None, None, None, None, None + if ( + hyg["loose_v"] + or hyg["loose_e"] + or hyg["nonman"] + or hyg["zero_area"] + or hyg["doubles"] + or hyg["ngons"] + ): + return fail( + f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']}", + 15, + ), None, None, None, None, None + if abs(bb[2]) > ZMIN_EPS: + return fail( + f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 " + "(--lift-z is the designed fail)", + 16, + ), None, None, None, None, None + if gap_sw > GAP_MAX: + return fail( + f"stone-wood gap {gap_sw:.5f} > {GAP_MAX} " + "(posts must seat on the curb)", + 17, + ), None, None, None, None, None + if gap_mw > GAP_MAX: + return fail( + f"metal-wood gap {gap_mw:.5f} > {GAP_MAX} " + "(crank, hoops, and bail must touch the wood they mount to)", + 17, + ), None, None, None, None, None return 0, low, high, stone, tex, collider @@ -1034,7 +1354,7 @@

Source

cam.location = (3.10, -4.45, 2.12) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, OUTER_SIZE[2] / 2.0 + 0.04) + aim.location = (0.0, 0.0, OUTER_SIZE[2] / 2.0) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -1082,9 +1402,16 @@

Source

action="store_true", help="falsification: skip the LOD DECIMATE stage", ) + p.add_argument( + "--lift-z", + action="store_true", + help="falsification: lift the mesh so zmin fails the grounded budget", + ) args = p.parse_args(argv) - code, low, _high, stone, tex, _col = check(args.skip_decimate) + code, low, _high, stone, tex, _col = check( + args.skip_decimate, lift_z=args.lift_z + ) if code: return code if args.output: diff --git a/showcase/README.md b/showcase/README.md index 5896fe7..c46a12f 100644 --- a/showcase/README.md +++ b/showcase/README.md @@ -43,6 +43,13 @@ entry in `showcase/gallery.json`, and a rendered still. characteristic 2 — that is a single-shell contract. Pairs of parts meant to touch assert a BVH surface gap below a named epsilon (vert-vert is the wrong metric for thin straps and collars). +- **Material face floors.** Every declared material asserts a named + minimum face count on the finished mesh, recomputed from + `polygon.material_index`. This catches the slot-assignment wipe class: + `obj.data.materials.clear()` resets every polygon's `material_index` + to 0 on some versions, which renders the piece single-material while + the slot count still passes. Assign slots by index-preserving + append/replace, never clear-and-rebuild. - **Exit codes** are file-local: `0` success, argparse `2`, `3` and above in check order. `9` is legal. FATAL `sys.exit(1)` is a crash, never a named check. diff --git a/showcase/cart/README.md b/showcase/cart/README.md index 424a48a..b6c013f 100644 --- a/showcase/cart/README.md +++ b/showcase/cart/README.md @@ -21,14 +21,17 @@ materials, UVs, evaluated LOD, collider, or export file. | Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | -| Base triangles | 2470–2680 | 2572 / 2572 / 2572 | +| Base triangles | 2470–2900 | 2600 / 2600 / 2600 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | -| LOD2 ratio | 0.10–0.35 of base | 0.2193 / 0.2193 / 0.2146 | -| Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 100 metal | +| LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.2154 | +| Materials | exactly 2 distinct, metal ≥ 24, wood ≥ 800 faces | 2 slots, floors met | | UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | -| Outer AABB | (1.547, 0.749, 0.656) m ± 0.01 | (1.5473, 0.7486, 0.6560), zmin 0 | -| Collider tris | ≤ 360 | 306 | -| Export | written, size > 0 | 202500 / 202500 / 202492 bytes | +| Outer AABB | (1.539, 0.749, 0.640) m ± 0.01 | (1.5393, 0.7486, 0.6400) | +| Grounded | bbox min Z within 1e-4 of 0 | 0.0000 / 0.0000 / 0.0000 | +| Hygiene | loose V/E, non-manifold, zero-area, doubles @1e-5, n-gons: all 0 | 0 / 0 / 0 on every axis | +| Material-island gap | metal↔wood min distance ≤ 0.008 m | 0.00000 / 0.00000 / 0.00000 | +| Collider tris | ≤ 360 | 122 | +| Export | written, size > 0 | 201048 / 201048 / 201032 bytes | DECIMATE COLLAPSE triangle counts are **not** identical across series — 5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an @@ -38,17 +41,20 @@ Export byte counts differ by 8 B on 5.2.1 (glTF serializer), not a gated axis. `--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and -exit 9 fires. That is the named budget the falsifier violates. +exit 9 fires. `--lift-z` raises the finished mesh 0.05 m so the grounded +budget fails and exit 16 fires. Those are the named budgets the two +falsifiers violate. ## Run ```bash blender --background --python cart.py -- blender --background --python cart.py -- --skip-decimate +blender --background --python cart.py -- --lift-z blender --background --python cart.py -- --output cart.png ``` -Smoke does not pass `--output` or `--skip-decimate`. +Smoke does not pass `--output`, `--skip-decimate`, or `--lift-z`. ## Exit codes @@ -72,3 +78,6 @@ File-local. `9` is a valid check code. `10` is reserved for | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | `--output` produced no file | +| 15 | Hygiene: loose geometry, non-manifold, zero-area, doubles, or n-gons | +| 16 | Bbox min Z not grounded (`--lift-z` lands here) | +| 17 | Material-island gap above tolerance (parts meant to touch) | diff --git a/showcase/cart/cart.py b/showcase/cart/cart.py index cb7f36e..e7e06df 100644 --- a/showcase/cart/cart.py +++ b/showcase/cart/cart.py @@ -8,7 +8,8 @@ Budgets are declared below and recomputed from the generated result. They are not API-contract witnesses. ``--skip-decimate`` skips the LOD -DECIMATE stage so the LOD-ratio budget fails. +DECIMATE stage so the LOD-ratio budget fails. ``--lift-z`` raises the +mesh so the grounded-zmin hygiene budget fails. No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts are not byte-identical across Blender versions — the LOD gate is a @@ -16,6 +17,7 @@ blender --background --python cart.py -- blender --background --python cart.py -- --skip-decimate + blender --background --python cart.py -- --lift-z blender --background --python cart.py -- --output cart.png """ import argparse @@ -28,6 +30,7 @@ import bmesh import bpy from mathutils import Euler, Vector +from mathutils.bvhtree import BVHTree _REPO = os.path.abspath( os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) @@ -37,9 +40,15 @@ import gallery_framing # noqa: E402 RIM_MAJOR = 0.30 -RIM_MINOR = 0.028 +# Felloe ring: flat-tread box section, not a round tube — round torus rims +# read as bicycle wheels. The iron tyre wraps the tread. +RIM_RADIAL = 0.012 +RIM_W = 0.036 +TYRE_T = 0.008 +TYRE_W = 0.040 TRACK = 0.68 AXLE_X = -0.16 +AXLE_R = 0.020 HUB_R = 0.052 HUB_W = 0.046 N_SPOKES = 8 @@ -49,15 +58,23 @@ BED_T = 0.038 WALL_H = 0.16 WALL_T = 0.032 +TAILGATE_H = 0.10 SHAFT_L = 0.58 SHAFT_T = 0.034 +SHAFT_PITCH = math.radians(7.0) N_SLATS = 6 IRON_T = 0.014 +BOLSTER_H = 0.044 BBOX_TOL = 0.01 -OUTER_SIZE = (1.547, 0.749, 0.656) +OUTER_SIZE = (1.539, 0.749, 0.640) BASE_TRIS_MIN = 2470 -BASE_TRIS_MAX = 2680 +BASE_TRIS_MAX = 2900 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +GAP_MAX = 0.008 +LIFT_Z = 0.05 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -71,6 +88,7 @@ BAKE_RES = 256 CAGE_EXTRUSION = 0.08 METAL_FACES_MIN = 24 +WOOD_FACES_MIN = 800 WOOD_IDX = 0 METAL_IDX = 1 @@ -126,7 +144,7 @@ def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): geo = bmesh.ops.create_cone( bm, cap_ends=True, - cap_tris=False, + cap_tris=True, segments=segments, radius1=radius, radius2=radius, @@ -143,28 +161,39 @@ def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): return verts -def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): - n_major = 16 - n_minor = 8 +def add_ring(bm, loc, r_mid, radial_t, width, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + """Flat-sided ring with a box cross-section (felloe / tyre profile). + + Manifold: outer tread, inner surface, and two side annuli, all quads. + Built in the XY plane, width along Z, then rotated/translated. + """ + r_in = r_mid - radial_t + r_out = r_mid + radial_t + hw = width / 2.0 rings = [] - for i in range(n_major): - u = i * (2.0 * math.pi / n_major) - ring = [] - for j in range(n_minor): - v = j * (2.0 * math.pi / n_minor) - x = (major + minor * math.cos(v)) * math.cos(u) - y = (major + minor * math.cos(v)) * math.sin(u) - z = minor * math.sin(v) - ring.append(bm.verts.new((x, y, z))) - rings.append(ring) - bm.verts.ensure_lookup_table() - for i in range(n_major): - i2 = (i + 1) % n_major - for j in range(n_minor): - j2 = (j + 1) % n_minor - face = bm.faces.new( - (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2]) - ) + for i in range(segments): + u = i * (2.0 * math.pi / segments) + cu = math.cos(u) + su = math.sin(u) + rings.append( + [ + bm.verts.new((r_in * cu, r_in * su, -hw)), + bm.verts.new((r_out * cu, r_out * su, -hw)), + bm.verts.new((r_out * cu, r_out * su, hw)), + bm.verts.new((r_in * cu, r_in * su, hw)), + ] + ) + for i in range(segments): + i2 = (i + 1) % segments + a = rings[i] + b = rings[i2] + for quad in ( + (a[1], b[1], b[2], a[2]), + (a[3], b[3], b[0], a[0]), + (a[2], b[2], b[3], a[3]), + (a[0], b[0], b[1], a[1]), + ): + face = bm.faces.new(quad) face.material_index = mat_idx verts = [v for ring in rings for v in ring] rot = Euler(euler).to_matrix() @@ -219,7 +248,17 @@ def pack_uvs(bm, margin=0.08): def add_wheel(bm, loc, wood, metal): wood.extend( - add_rim(bm, loc, RIM_MAJOR, RIM_MINOR, WOOD_IDX, euler=(math.pi / 2.0, 0.0, 0.0)) + add_ring( + bm, loc, RIM_MAJOR, RIM_RADIAL, RIM_W, 16, WOOD_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + ) + metal.extend( + add_ring( + bm, loc, RIM_MAJOR + RIM_RADIAL + TYRE_T / 2.0, TYRE_T / 2.0, + TYRE_W, 16, METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) ) wood.extend( add_cyl( @@ -232,8 +271,10 @@ def add_wheel(bm, loc, wood, metal): euler=(math.pi / 2.0, 0.0, 0.0), ) ) - inner = HUB_R + 0.008 - outer = RIM_MAJOR - RIM_MINOR * 0.55 + # Spokes root inside the hub and embed into the felloe ring — no + # floating ends hidden by the hub band. + inner = HUB_R * 0.4 + outer = RIM_MAJOR - RIM_RADIAL + 0.008 mid_r = 0.5 * (inner + outer) slen = outer - inner for i in range(N_SPOKES): @@ -283,7 +324,10 @@ def build_cart_mesh(name, bevel_offset, bevel_segments): wood_wheels = [] metal = [] axle_z = RIM_MAJOR - bed_z = axle_z + 0.10 + # The bed rides on the axle through a bolster: axle top -> bolster -> + # bed bottom. Never let the axle interpenetrate the slats. + bed_z = axle_z + AXLE_R + BOLSTER_H + BED_T / 2.0 + bed_bottom = bed_z - BED_T / 2.0 bed_cx = 0.06 add_wheel(bm, (AXLE_X, TRACK / 2.0, axle_z), wood_wheels, metal) @@ -293,7 +337,7 @@ def build_cart_mesh(name, bevel_offset, bevel_segments): add_cyl( bm, (AXLE_X, 0.0, axle_z), - 0.020, + AXLE_R, TRACK + 0.06, 10, METAL_IDX, @@ -313,6 +357,8 @@ def build_cart_mesh(name, bevel_offset, bevel_segments): WOOD_IDX, ) ) + # Side walls and the front board finish flush at WALL_H; the back + # board is a deliberately lower tailgate. for ysign in (-1.0, 1.0): body.extend( add_box( @@ -325,32 +371,37 @@ def build_cart_mesh(name, bevel_offset, bevel_segments): body.extend( add_box( bm, - (bed_cx + BED_L / 2.0 - WALL_T / 2.0, 0.0, bed_z + WALL_H / 2.0 + 0.02), - (WALL_T, BED_W + WALL_T * 2.0, WALL_H + 0.04), + (bed_cx + BED_L / 2.0 - WALL_T / 2.0, 0.0, bed_z + WALL_H / 2.0), + (WALL_T, BED_W + WALL_T * 2.0, WALL_H), WOOD_IDX, ) ) body.extend( add_box( bm, - (bed_cx - BED_L / 2.0 + WALL_T / 2.0, 0.0, bed_z + 0.06), - (WALL_T, BED_W + WALL_T, 0.12), + (bed_cx - BED_L / 2.0 + WALL_T / 2.0, 0.0, bed_z + TAILGATE_H / 2.0), + (WALL_T, BED_W + WALL_T * 2.0, TAILGATE_H), WOOD_IDX, ) ) - for xj in (bed_cx - BED_L * 0.32, bed_cx + BED_L * 0.28): + # Bolsters: one sits on the axle, one forward; both carry the bed. + for xj in (AXLE_X, bed_cx + BED_L * 0.28): body.extend( add_box( bm, - (xj, 0.0, bed_z - BED_T / 2.0 - 0.022), - (0.055, BED_W * 0.92, 0.044), + (xj, 0.0, axle_z + AXLE_R + BOLSTER_H / 2.0), + (0.055, BED_W * 0.92, BOLSTER_H), WOOD_IDX, ) ) + # Shafts hang under the bed front: the back end embeds 6 mm into the + # slat bottom and never pokes through the bed floor. shaft_x = bed_cx + BED_L / 2.0 + SHAFT_L / 2.0 - 0.04 - shaft_z = bed_z - 0.02 - pitch = math.radians(7.0) + shaft_z = ( + bed_bottom + 0.006 - SHAFT_T / 2.0 + - math.sin(SHAFT_PITCH) * (SHAFT_L / 2.0) + ) for ysign in (-1.0, 1.0): body.extend( add_box( @@ -358,28 +409,30 @@ def build_cart_mesh(name, bevel_offset, bevel_segments): (shaft_x, ysign * 0.12, shaft_z), (SHAFT_L, SHAFT_T, SHAFT_T), WOOD_IDX, - euler=(0.0, pitch, 0.0), + euler=(0.0, SHAFT_PITCH, 0.0), ) ) metal.extend( add_box( bm, - (bed_cx + BED_L / 2.0 - 0.02, ysign * 0.12, bed_z - 0.01), + (bed_cx + BED_L / 2.0 - 0.02, ysign * 0.12, bed_bottom - IRON_T / 2.0), (0.05, 0.042, IRON_T), METAL_IDX, ) ) + # Tie-down plates sit on top of the bed floor, fully inboard of the + # walls — visible, not buried inside the slats. for sx, sy in ( - (bed_cx - BED_L / 2.0 + 0.04, -BED_W / 2.0), - (bed_cx - BED_L / 2.0 + 0.04, BED_W / 2.0), - (bed_cx + BED_L / 2.0 - 0.04, -BED_W / 2.0), - (bed_cx + BED_L / 2.0 - 0.04, BED_W / 2.0), + (bed_cx - BED_L / 2.0 + 0.055, -BED_W / 2.0 + 0.055), + (bed_cx - BED_L / 2.0 + 0.055, BED_W / 2.0 - 0.055), + (bed_cx + BED_L / 2.0 - 0.055, -BED_W / 2.0 + 0.055), + (bed_cx + BED_L / 2.0 - 0.055, BED_W / 2.0 - 0.055), ): metal.extend( add_box( bm, - (sx, sy, bed_z + 0.01), + (sx, sy, bed_z + BED_T / 2.0 + IRON_T / 2.0), (0.055, 0.055, IRON_T), METAL_IDX, ) @@ -428,13 +481,33 @@ def build_cart_mesh(name, bevel_offset, bevel_segments): return out -def principled(name, color, metallic, roughness): +def principled(name, color, metallic, roughness, noise_scale=0.0, wear=None): mat = bpy.data.materials.new(name) mat.use_nodes = True - bsdf = mat.node_tree.nodes["Principled BSDF"] + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = color bsdf.inputs["Metallic"].default_value = metallic bsdf.inputs["Roughness"].default_value = roughness + if noise_scale > 0.0 and wear is not None: + tex = nt.nodes.new("ShaderNodeTexNoise") + tex.inputs["Scale"].default_value = noise_scale + tex.inputs["Detail"].default_value = 8.0 + tex.inputs["Roughness"].default_value = 0.55 + mix = nt.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + mix.inputs["A"].default_value = color + mix.inputs["B"].default_value = wear + fac = mix.inputs.get("Factor") or mix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], fac) + nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"]) + rmix = nt.nodes.new("ShaderNodeMix") + rmix.data_type = "FLOAT" + rmix.inputs["A"].default_value = roughness + rmix.inputs["B"].default_value = min(1.0, roughness + 0.18) + rfac = rmix.inputs.get("Factor") or rmix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], rfac) + nt.links.new(rmix.outputs["Result"], bsdf.inputs["Roughness"]) return mat @@ -481,6 +554,84 @@ def uv_stats(mesh): return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) +def face_area(me, poly): + vs = [me.vertices[i].co for i in poly.vertices] + if len(vs) < 3: + return 0.0 + v0 = vs[0] + area = 0.0 + for i in range(1, len(vs) - 1): + area += (vs[i] - v0).cross(vs[i + 1] - v0).length * 0.5 + return area + + +def hygiene_audit(me): + # Combinatorics match examples/mesh-hygiene-audit.audit (copied, not imported). + nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + areas = [face_area(me, p) for p in me.polygons] + zero_area = sum(1 for a in areas if a <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) + loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) + nonman = sum(1 for e in bm.edges if not e.is_manifold) + ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) + doubles = len(ret.get("targetmap") or {}) + finally: + bm.free() + return { + "nv": nv, + "ne": ne, + "nf": nf, + "ngons": ngons, + "loose_v": loose_v, + "loose_e": loose_e, + "nonman": nonman, + "zero_area": zero_area, + "doubles": doubles, + "euler": nv - ne + nf, + } + + +def min_mat_distance(me, ia, ib): + """Closest surface distance between two material islands via BVH. + + Vert-vert distance is the wrong metric for thin parts: a face interior + can touch while its corner verts sit a radius apart. + """ + bm_a = bmesh.new() + bm_b = bmesh.new() + try: + bm_a.from_mesh(me) + bm_b.from_mesh(me) + bm_a.faces.ensure_lookup_table() + bm_b.faces.ensure_lookup_table() + drop_a = [f for f in bm_a.faces if f.material_index != ia] + drop_b = [f for f in bm_b.faces if f.material_index != ib] + if drop_a: + bmesh.ops.delete(bm_a, geom=drop_a, context="FACES") + if drop_b: + bmesh.ops.delete(bm_b, geom=drop_b, context="FACES") + if not bm_a.faces or not bm_b.faces: + return 1e9 + tree = BVHTree.FromBMesh(bm_b) + best = 1e9 + for src in list(bm_a.verts) + list(bm_a.faces): + co = src.co if hasattr(src, "co") else src.calc_center_median() + hit = tree.find_nearest(co) + if hit[0] is None: + continue + best = min(best, hit[3]) + return best + finally: + bm_a.free() + bm_b.free() + + def make_lod(obj, name, ratio, skip_decimate): mesh = obj.data.copy() lod = bpy.data.objects.new(name, mesh) @@ -567,14 +718,24 @@ def export_unity(path, objects): ) -def check(skip_decimate): +def check(skip_decimate, lift_z=False): bpy.ops.wm.read_factory_settings(use_empty=True) low = build_cart_mesh("CartLow", bevel_offset=0.005, bevel_segments=2) high = build_cart_mesh("CartHigh", bevel_offset=0.005, bevel_segments=4) - wood = principled("CartWood", (0.42, 0.22, 0.08, 1.0), 0.0, 0.58) - metal = principled("CartIron", (0.13, 0.135, 0.15, 1.0), 1.0, 0.32) + wood = principled( + "CartWood", (0.42, 0.22, 0.08, 1.0), 0.0, 0.58, + noise_scale=7.0, wear=(0.26, 0.12, 0.04, 1.0), + ) + metal = principled( + "CartIron", (0.13, 0.135, 0.15, 1.0), 1.0, 0.32, + noise_scale=5.0, wear=(0.05, 0.05, 0.06, 1.0), + ) assign_slots(low, wood, metal) assign_slots(high, wood, metal) + if lift_z: + for v in low.data.vertices: + v.co.z += LIFT_Z + low.data.update() if low.data is None or len(low.data.polygons) < 6: return fail("cart mesh did not build", 3), None, None, None, None, None @@ -637,6 +798,14 @@ def check(skip_decimate): f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}" ) + hyg = hygiene_audit(low.data) + gap_mw = min_mat_distance(low.data, METAL_IDX, WOOD_IDX) + print( + f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} euler={hyg['euler']}" + ) + print(f"measured gap_metal_wood={gap_mw:.5f}") if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -653,6 +822,11 @@ def check(skip_decimate): f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}", 5, ), None, None, None, None, None + if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN: + return fail( + f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}", + 5, + ), None, None, None, None, None if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: return fail( f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", @@ -696,6 +870,32 @@ def check(skip_decimate): ), None, None, None, None, None if export_size <= 0: return fail("export file missing or empty", 13), None, None, None, None, None + if ( + hyg["loose_v"] + or hyg["loose_e"] + or hyg["nonman"] + or hyg["zero_area"] + or hyg["doubles"] + or hyg["ngons"] + ): + return fail( + f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']}", + 15, + ), None, None, None, None, None + if abs(bb[2]) > ZMIN_EPS: + return fail( + f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 " + "(--lift-z is the designed fail)", + 16, + ), None, None, None, None, None + if gap_mw > GAP_MAX: + return fail( + f"metal-wood gap {gap_mw:.5f} > {GAP_MAX} " + "(tyres, hubs, straps, and plates must touch the wood they mount to)", + 17, + ), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -814,9 +1014,16 @@ def main(): action="store_true", help="falsification: skip the LOD DECIMATE stage", ) + p.add_argument( + "--lift-z", + action="store_true", + help="falsification: lift the mesh so zmin fails the grounded budget", + ) args = p.parse_args(argv) - code, low, _high, wood, tex, _col = check(args.skip_decimate) + code, low, _high, wood, tex, _col = check( + args.skip_decimate, lift_z=args.lift_z + ) if code: return code if args.output: diff --git a/showcase/cart/preview.webp b/showcase/cart/preview.webp index c7127ce..33e7fcf 100644 Binary files a/showcase/cart/preview.webp and b/showcase/cart/preview.webp differ diff --git a/showcase/gallery.json b/showcase/gallery.json index 09b5d48..ef09b9c 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -21,7 +21,7 @@ "name": "stone-well", "dir": "showcase/stone-well", "teaches": "A procedural stone well through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", - "witnessesFix": "Recomputed: 8388 tris, three materials, UVs in 0..1 with zero AABB overlap, outer AABB 1.640×1.640×1.688 m, LOD ratios in band, convex collider 230 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "witnessesFix": "Recomputed: 9380 tris, three materials with face floors, UVs in 0..1 with zero AABB overlap, outer AABB 1.640×1.640×1.761 m, LOD ratios in band, convex collider 306 tris, hygiene zero (loose, non-manifold, doubles, n-gons), grounded zmin, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget; --lift-z exits 16 on the grounded budget.", "hero": "docs/gallery/assets/stone-well-hero.webp", "preview": "showcase/stone-well/preview.webp", "tags": [ @@ -129,7 +129,7 @@ "name": "cart", "dir": "showcase/cart", "teaches": "A procedural two-wheel wooden cart through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", - "witnessesFix": "Recomputed: 2572 tris, two materials with 100 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.547×0.749×0.656 m, LOD ratios in band, convex collider 306 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "witnessesFix": "Recomputed: 2600 tris, two materials with metal and wood face floors, UVs in 0..1 with zero AABB overlap, outer AABB 1.539×0.749×0.640 m, LOD ratios in band, convex collider 122 tris, hygiene zero (loose, non-manifold, doubles, n-gons), grounded zmin, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget; --lift-z exits 16 on the grounded budget.", "hero": "docs/gallery/assets/cart-hero.webp", "preview": "showcase/cart/preview.webp", "tags": [ diff --git a/showcase/stone-well/README.md b/showcase/stone-well/README.md index 189421d..c951bc2 100644 --- a/showcase/stone-well/README.md +++ b/showcase/stone-well/README.md @@ -21,14 +21,18 @@ materials, UVs, evaluated LOD, collider, or export file. | Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | -| Base triangles | 8280–8500 | 8388 / 8388 / 8388 | +| Base triangles | 8280–9500 | 9380 / 9380 / 9380 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 | | Materials | exactly 3 distinct | 3 | +| Material faces | stone ≥ 3000, wood ≥ 600, metal ≥ 100 | all above | | UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | -| Outer AABB | (1.640, 1.640, 1.688) m ± 0.01 | (1.6400, 1.6400, 1.6882), zmin 0 | -| Collider tris | ≤ 260 | 230 | -| Export | written, size > 0 | 601028 / 601092 / 601372 bytes | +| Outer AABB | (1.640, 1.640, 1.761) m ± 0.01 | (1.6400, 1.6400, 1.7606) | +| Grounded | bbox min Z within 1e-4 of 0 | 0.0000 / 0.0000 / 0.0000 | +| Hygiene | loose V/E, non-manifold, zero-area, doubles @1e-5, n-gons: all 0 | 0 / 0 / 0 on every axis | +| Material-island gap | stone↔wood and metal↔wood min distance ≤ 0.008 m | 0.00000 / 0.00000 / 0.00000 | +| Collider tris | ≤ 320 | 306 | +| Export | written, size > 0 | 686672 / 686736 / 686724 bytes | DECIMATE COLLAPSE triangle counts are **not** guaranteed identical across series — the gate is a ratio band, not an exact count. This mesh happened @@ -37,17 +41,20 @@ is `has_data` plus operator `FINISHED`, not byte-identity. Construction uses no RNG. glTF byte size differs by a few hundred bytes across series. `--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and -exit 9 fires. That is the named budget the falsifier violates. +exit 9 fires. `--lift-z` raises the finished mesh 0.05 m so the grounded +budget fails and exit 16 fires. Those are the named budgets the two +falsifiers violate. ## Run ```bash blender --background --python stone_well.py -- blender --background --python stone_well.py -- --skip-decimate +blender --background --python stone_well.py -- --lift-z blender --background --python stone_well.py -- --output well.png ``` -Smoke does not pass `--output` or `--skip-decimate`. +Smoke does not pass `--output`, `--skip-decimate`, or `--lift-z`. ## Exit codes @@ -61,7 +68,7 @@ File-local. `9` is a valid check code. `10` is reserved for | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | -| 5 | Material count ≠ 3 distinct slots | +| 5 | Material count ≠ 3 distinct slots, or a material face floor missed | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | @@ -71,3 +78,6 @@ File-local. `9` is a valid check code. `10` is reserved for | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | `--output` produced no file | +| 15 | Hygiene: loose geometry, non-manifold, zero-area, doubles, or n-gons | +| 16 | Bbox min Z not grounded (`--lift-z` lands here) | +| 17 | Material-island gap above tolerance (parts meant to touch) | diff --git a/showcase/stone-well/preview.webp b/showcase/stone-well/preview.webp index 62ae041..da50189 100644 Binary files a/showcase/stone-well/preview.webp and b/showcase/stone-well/preview.webp differ diff --git a/showcase/stone-well/stone_well.py b/showcase/stone-well/stone_well.py index e363bab..cf284d9 100644 --- a/showcase/stone-well/stone_well.py +++ b/showcase/stone-well/stone_well.py @@ -6,14 +6,16 @@ Budgets are declared below and recomputed from the generated result. They are not API-contract witnesses. ``--skip-decimate`` skips the LOD -DECIMATE stage so the LOD-ratio budget fails. +DECIMATE stage so the LOD-ratio budget fails. ``--lift-z`` raises the +mesh so the grounded-zmin hygiene budget fails. -No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts -are not byte-identical across Blender versions — the LOD gate is a -ratio band, not an exact count. +No RNG. Construction is closed-form (per-stone jitter is a deterministic +hash). DECIMATE COLLAPSE triangle counts are not byte-identical across +Blender versions — the LOD gate is a ratio band, not an exact count. blender --background --python stone_well.py -- blender --background --python stone_well.py -- --skip-decimate + blender --background --python stone_well.py -- --lift-z blender --background --python stone_well.py -- --output well.png """ import argparse @@ -26,6 +28,7 @@ import bmesh import bpy from mathutils import Euler, Vector +from mathutils.bvhtree import BVHTree # Showcase lives at repo-root/showcase/, not under examples/. The framing # helper is the repo's only shared import and lives next to the examples; @@ -47,12 +50,17 @@ R_MID = (R_INNER + R_OUTER) / 2.0 WALL_H = 0.72 STONE_H = WALL_H / N_ROWS +STONE_FACE_H = STONE_H * 0.90 +# Top of the masonry is the top of the last course, not the nominal WALL_H: +# the curb seats on the measured course top so no daylight shows at the rim. +MASONRY_TOP = STONE_FACE_H + (N_ROWS - 1) * STONE_H CURB_H = 0.065 CURB_OUT = 0.045 +CURB_Z = MASONRY_TOP + CURB_H / 2.0 POST_S = 0.068 POST_R = 0.55 POST_H = 0.58 -POST_BOTTOM = WALL_H + CURB_H +POST_BOTTOM = MASONRY_TOP + CURB_H POST_TOP = POST_BOTTOM + POST_H EAVE_OVERHANG = 0.22 EAVE_HALF = POST_R + POST_S / 2.0 + EAVE_OVERHANG @@ -61,18 +69,31 @@ PEAK_Z = EAVE_Z + ROOF_RISE SHINGLE_T = 0.016 WINDLASS_R = 0.045 -BUCKET_R = 0.10 +# The windlass is the axle the posts are the bearings for: it passes through +# both posts and protrudes so the crank has something to attach to. +WINDLASS_END = POST_R + POST_S / 2.0 + 0.015 +WINDLASS_LEN = 2.0 * WINDLASS_END +BUCKET_R_TOP = 0.105 +BUCKET_R_BOT = 0.088 +BUCKET_WALL_T = 0.008 BUCKET_H = 0.14 BUCKET_Z = 0.68 +BUCKET_RIM_Z = BUCKET_Z + BUCKET_H / 2.0 +HANDLE_BAR_Z = BUCKET_RIM_Z + 0.03 ROPE_R = 0.016 BBOX_TOL = 0.01 # Fitted to the generated AABB after locking geometry. Recomputed from bound_box. -OUTER_SIZE = (1.640, 1.640, 1.688) +OUTER_SIZE = (1.640, 1.640, 1.761) # Measured after locking geometry. DECIMATE COLLAPSE ratios diverge across # series — bands, not exact counts. Tightened after the first 4.5/5.1/5.2 run. BASE_TRIS_MIN = 8280 -BASE_TRIS_MAX = 8500 +BASE_TRIS_MAX = 9500 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +GAP_MAX = 0.008 +LIFT_Z = 0.05 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -80,9 +101,12 @@ LOD1_TARGET = 0.50 LOD2_TARGET = 0.22 MATERIAL_COUNT = 3 +STONE_FACES_MIN = 3000 +WOOD_FACES_MIN = 600 +METAL_FACES_MIN = 100 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 260 +COLLIDER_TRIS_MAX = 320 BAKE_RES = 256 CAGE_EXTRUSION = 0.06 @@ -131,11 +155,14 @@ def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): return verts -def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): +def add_cone( + bm, loc, radius1, radius2, depth, segments, mat_idx, + euler=(0.0, 0.0, 0.0), cap_ends=True, +): geo = bmesh.ops.create_cone( bm, - cap_ends=True, - cap_tris=False, + cap_ends=cap_ends, + cap_tris=True, segments=segments, radius1=radius1, radius2=radius2, @@ -199,23 +226,81 @@ def pack_uvs(bm, margin=0.08): ) +def hash01(a, b, c): + # Deterministic per-stone jitter. Closed-form, no RNG state. + return math.sin(a * 127.1 + b * 311.7 + c * 74.7) * 43758.5453 % 1.0 + + +def ring_verts(verts, z, eps=1e-6): + return sorted( + (v for v in verts if abs(v.co.z - z) < eps), + key=lambda v: math.atan2(v.co.y, v.co.x), + ) + + +def add_open_bucket(bm, loc, r_bot, r_top, wall_t, depth, segments, mat_idx): + """Open-topped tapered bucket: outer wall, inner wall, rim ring, floor. + + Manifold single shell. The bottom slab has real thickness: the inner + floor sits FLOOR_T above the outer bottom disc, so every edge has + exactly two faces. + """ + floor_t = 0.012 + z0 = loc[2] - depth / 2.0 + z1 = loc[2] + depth / 2.0 + outer = add_cone( + bm, loc, r_bot, r_top, depth, segments, mat_idx, cap_ends=False, + ) + inner_loc = (loc[0], loc[1], loc[2] + floor_t / 2.0) + inner = add_cone( + bm, inner_loc, r_bot - wall_t, r_top - wall_t, depth - floor_t, + segments, mat_idx, cap_ends=False, + ) + ob = ring_verts(outer, z0) + ot = ring_verts(outer, z1) + ib = ring_verts(inner, z0 + floor_t) + it = ring_verts(inner, z1) + n = segments + for i in range(n): + j = (i + 1) % n + f = bm.faces.new((ot[i], ot[j], it[j], it[i])) + f.material_index = mat_idx + # Inner floor faces up into the hollow; outer bottom disc faces down. + ci = bm.verts.new((loc[0], loc[1], z0 + floor_t)) + co = bm.verts.new((loc[0], loc[1], z0)) + for i in range(n): + j = (i + 1) % n + f = bm.faces.new((ci, ib[i], ib[j])) + f.material_index = mat_idx + f = bm.faces.new((co, ob[j], ob[i])) + f.material_index = mat_idx + + def build_well_mesh(name, bevel_offset, bevel_segments): bm = bmesh.new() stone_verts = [] + wood_bevel_verts = [] try: - stone_w = 2.0 * R_MID * math.tan(math.pi / N_AROUND) * 0.88 - actual_h = STONE_H * 0.90 for row in range(N_ROWS): - z = actual_h / 2.0 + row * STONE_H + z = STONE_FACE_H / 2.0 + row * STONE_H rot_off = (row % 2) * (math.pi / N_AROUND) for i in range(N_AROUND): ang = 2.0 * math.pi * i / N_AROUND + rot_off - loc = (R_MID * math.cos(ang), R_MID * math.sin(ang), z) + # Seeded jitter: width and radial seat vary per stone, course + # tops stay level so the curb seats flat. + wj = 0.88 + 0.10 * (hash01(row, i, 0) - 0.5) + rj = (hash01(row, i, 1) - 0.5) * 0.008 + stone_w = 2.0 * R_MID * math.tan(math.pi / N_AROUND) * wj + loc = ( + (R_MID + rj) * math.cos(ang), + (R_MID + rj) * math.sin(ang), + z, + ) stone_verts.extend( add_box( bm, loc, - (STONE_D, stone_w, actual_h), + (STONE_D, stone_w, STONE_FACE_H), STONE_IDX, euler=(0.0, 0.0, ang), ) @@ -223,10 +308,9 @@ def build_well_mesh(name, bevel_offset, bevel_segments): curb_r = R_OUTER + CURB_OUT / 2.0 curb_w = 2.0 * curb_r * math.tan(math.pi / N_AROUND) * 0.90 - curb_z = WALL_H + CURB_H / 2.0 for i in range(N_AROUND): ang = 2.0 * math.pi * i / N_AROUND - loc = (curb_r * math.cos(ang), curb_r * math.sin(ang), curb_z) + loc = (curb_r * math.cos(ang), curb_r * math.sin(ang), CURB_Z) stone_verts.extend( add_box( bm, @@ -256,30 +340,44 @@ def build_well_mesh(name, bevel_offset, bevel_segments): POST_R * math.sin(ang), POST_BOTTOM + POST_H / 2.0, ) - add_box(bm, loc, (POST_S, POST_S, POST_H), WOOD_IDX) + wood_bevel_verts.extend( + add_box(bm, loc, (POST_S, POST_S, POST_H), WOOD_IDX) + ) beam_z = POST_TOP - 0.05 - add_box( - bm, - (0.0, 0.0, beam_z), - (POST_R * 2.0 - POST_S, POST_S * 0.85, POST_S * 0.85), - WOOD_IDX, + wood_bevel_verts.extend( + add_box( + bm, + (0.0, 0.0, beam_z), + (POST_R * 2.0 - POST_S, POST_S * 0.85, POST_S * 0.85), + WOOD_IDX, + ) ) add_cylinder( bm, (0.0, 0.0, beam_z), WINDLASS_R, - POST_R * 2.0 - POST_S * 1.4, + WINDLASS_LEN, 12, WOOD_IDX, euler=(0.0, math.pi / 2.0, 0.0), ) + # Crank: arm pinned to the protruding windlass end, grip parallel to + # the windlass axis at the arm's lower end. add_box( bm, - (POST_R - POST_S * 0.2, 0.0, beam_z), - (0.018, 0.12, 0.018), + (WINDLASS_END + 0.004, 0.0, beam_z - 0.055), + (0.020, 0.022, 0.13), METAL_IDX, - euler=(0.0, 0.0, math.radians(25.0)), + ) + add_cylinder( + bm, + (WINDLASS_END + 0.055, 0.0, beam_z - 0.12), + 0.014, + 0.10, + 10, + METAL_IDX, + euler=(0.0, math.pi / 2.0, 0.0), ) pitch = math.atan(ROOF_RISE / EAVE_HALF) @@ -294,6 +392,17 @@ def build_well_mesh(name, bevel_offset, bevel_segments): WOOD_IDX, euler=(0.0, 0.0, math.pi / 4.0), ) + # Cap the open pit where the four shingle courses meet at the peak. + add_cone( + bm, + (0.0, 0.0, PEAK_Z + 0.035), + 0.075, + 0.015, + 0.11, + 4, + WOOD_IDX, + euler=(0.0, 0.0, math.pi / 4.0), + ) nrm_local = Vector((0.0, ROOF_RISE, EAVE_HALF)).normalized() def add_course(yaw, t0, t1): @@ -339,27 +448,49 @@ def pt(t, s): add_course(yaw, t0, t1) fascia_h = 0.045 fascia_t = 0.032 + # Butt joints: the X-running boards span the full eave; the Y-running + # boards embed 2 mm into them. An exact flush butt lands board end + # verts on the other board's corner verts (doubles at 1e-5). for side in range(4): yaw = side * (math.pi / 2.0) fx = EAVE_HALF * math.sin(yaw) fy = EAVE_HALF * math.cos(yaw) if side % 2 == 0: - add_box( - bm, - (0.0, fy, EAVE_Z - fascia_h / 2.0), - (2.0 * EAVE_HALF + fascia_t, fascia_t, fascia_h), - WOOD_IDX, + wood_bevel_verts.extend( + add_box( + bm, + (0.0, fy, EAVE_Z - fascia_h / 2.0), + (2.0 * EAVE_HALF + fascia_t, fascia_t, fascia_h), + WOOD_IDX, + ) ) else: - add_box( - bm, - (fx, 0.0, EAVE_Z - fascia_h / 2.0), - (fascia_t, 2.0 * EAVE_HALF + fascia_t, fascia_h), - WOOD_IDX, + wood_bevel_verts.extend( + add_box( + bm, + (fx, 0.0, EAVE_Z - fascia_h / 2.0), + (fascia_t, 2.0 * EAVE_HALF - fascia_t + 0.004, fascia_h), + WOOD_IDX, + ) ) + if bevel_offset > 0.0: + edges = list({e for v in wood_bevel_verts for e in v.link_edges}) + ret = bmesh.ops.bevel( + bm, + geom=edges, + offset=min(bevel_offset, 0.006), + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + for f in ret.get("faces") or []: + f.material_index = WOOD_IDX + + # Rope ties off at the bail handle bar, not mid-air above the bucket. rope_top = beam_z - WINDLASS_R - rope_bot = BUCKET_Z + BUCKET_H / 2.0 + rope_bot = HANDLE_BAR_Z rope_h = rope_top - rope_bot add_cylinder( bm, @@ -369,32 +500,42 @@ def pt(t, s): 8, WOOD_IDX, ) - add_cylinder(bm, (0.0, 0.0, BUCKET_Z), BUCKET_R, BUCKET_H, 12, WOOD_IDX) + add_open_bucket( + bm, + (0.0, 0.0, BUCKET_Z), + BUCKET_R_BOT, + BUCKET_R_TOP, + BUCKET_WALL_T, + BUCKET_H, + 12, + WOOD_IDX, + ) + + def bucket_r_at(z): + t = (z - (BUCKET_Z - BUCKET_H / 2.0)) / BUCKET_H + return BUCKET_R_BOT + t * (BUCKET_R_TOP - BUCKET_R_BOT) + for hz in (-BUCKET_H * 0.28, BUCKET_H * 0.28): add_cylinder( bm, (0.0, 0.0, BUCKET_Z + hz), - BUCKET_R + 0.008, + bucket_r_at(BUCKET_Z + hz) + 0.004, 0.018, 12, METAL_IDX, ) + # Bail handle: legs pinned to the outside of the rim, bar across. + for xs in (-1.0, 1.0): + add_box( + bm, + (xs * (BUCKET_R_TOP + 0.006), 0.0, BUCKET_RIM_Z - 0.015), + (0.014, 0.014, 0.09), + METAL_IDX, + ) add_box( bm, - (0.0, 0.0, BUCKET_Z + BUCKET_H / 2.0 + 0.04), - (BUCKET_R * 1.6, 0.014, 0.014), - METAL_IDX, - ) - add_box( - bm, - (-BUCKET_R * 0.72, 0.0, BUCKET_Z + BUCKET_H / 2.0 + 0.02), - (0.014, 0.014, 0.05), - METAL_IDX, - ) - add_box( - bm, - (BUCKET_R * 0.72, 0.0, BUCKET_Z + BUCKET_H / 2.0 + 0.02), - (0.014, 0.014, 0.05), + (0.0, 0.0, HANDLE_BAR_Z), + (2.0 * (BUCKET_R_TOP + 0.020), 0.014, 0.014), METAL_IDX, ) @@ -417,21 +558,48 @@ def pt(t, s): return obj -def principled(name, color, metallic, roughness): +def principled(name, color, metallic, roughness, noise_scale=0.0, wear=None): mat = bpy.data.materials.new(name) mat.use_nodes = True - bsdf = mat.node_tree.nodes["Principled BSDF"] + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = color bsdf.inputs["Metallic"].default_value = metallic bsdf.inputs["Roughness"].default_value = roughness + if noise_scale > 0.0 and wear is not None: + tex = nt.nodes.new("ShaderNodeTexNoise") + tex.inputs["Scale"].default_value = noise_scale + tex.inputs["Detail"].default_value = 8.0 + tex.inputs["Roughness"].default_value = 0.55 + mix = nt.nodes.new("ShaderNodeMix") + mix.data_type = "RGBA" + mix.inputs["A"].default_value = color + mix.inputs["B"].default_value = wear + fac = mix.inputs.get("Factor") or mix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], fac) + nt.links.new(mix.outputs["Result"], bsdf.inputs["Base Color"]) + rmix = nt.nodes.new("ShaderNodeMix") + rmix.data_type = "FLOAT" + rmix.inputs["A"].default_value = roughness + rmix.inputs["B"].default_value = min(1.0, roughness + 0.18) + rfac = rmix.inputs.get("Factor") or rmix.inputs.get("Fac") + nt.links.new(tex.outputs["Fac"], rfac) + nt.links.new(rmix.outputs["Result"], bsdf.inputs["Roughness"]) return mat def assign_slots(obj, stone, wood, metal): - obj.data.materials.clear() - obj.data.materials.append(stone) - obj.data.materials.append(wood) - obj.data.materials.append(metal) + # Index-preserving: materials.clear() resets every polygon's + # material_index to 0 (the piece would render all-stone). Assign by + # slot position instead; the per-material face-count budgets in check() + # prove the indices survive. + mats = obj.data.materials + wanted = (stone, wood, metal) + for i, mat in enumerate(wanted): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) def world_bbox(obj): @@ -467,6 +635,84 @@ def uv_stats(mesh): return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) +def face_area(me, poly): + vs = [me.vertices[i].co for i in poly.vertices] + if len(vs) < 3: + return 0.0 + v0 = vs[0] + area = 0.0 + for i in range(1, len(vs) - 1): + area += (vs[i] - v0).cross(vs[i + 1] - v0).length * 0.5 + return area + + +def hygiene_audit(me): + # Combinatorics match examples/mesh-hygiene-audit.audit (copied, not imported). + nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons) + ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) + areas = [face_area(me, p) for p in me.polygons] + zero_area = sum(1 for a in areas if a <= AREA_EPS) + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) + loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) + nonman = sum(1 for e in bm.edges if not e.is_manifold) + ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) + doubles = len(ret.get("targetmap") or {}) + finally: + bm.free() + return { + "nv": nv, + "ne": ne, + "nf": nf, + "ngons": ngons, + "loose_v": loose_v, + "loose_e": loose_e, + "nonman": nonman, + "zero_area": zero_area, + "doubles": doubles, + "euler": nv - ne + nf, + } + + +def min_mat_distance(me, ia, ib): + """Closest surface distance between two material islands via BVH. + + Vert-vert distance is the wrong metric for thin parts: a face interior + can touch while its corner verts sit a radius apart. + """ + bm_a = bmesh.new() + bm_b = bmesh.new() + try: + bm_a.from_mesh(me) + bm_b.from_mesh(me) + bm_a.faces.ensure_lookup_table() + bm_b.faces.ensure_lookup_table() + drop_a = [f for f in bm_a.faces if f.material_index != ia] + drop_b = [f for f in bm_b.faces if f.material_index != ib] + if drop_a: + bmesh.ops.delete(bm_a, geom=drop_a, context="FACES") + if drop_b: + bmesh.ops.delete(bm_b, geom=drop_b, context="FACES") + if not bm_a.faces or not bm_b.faces: + return 1e9 + tree = BVHTree.FromBMesh(bm_b) + best = 1e9 + for src in list(bm_a.verts) + list(bm_a.faces): + co = src.co if hasattr(src, "co") else src.calc_center_median() + hit = tree.find_nearest(co) + if hit[0] is None: + continue + best = min(best, hit[3]) + return best + finally: + bm_a.free() + bm_b.free() + + def make_lod(obj, name, ratio, skip_decimate): mesh = obj.data.copy() lod = bpy.data.objects.new(name, mesh) @@ -559,15 +805,28 @@ def export_unity(path, objects): ) -def check(skip_decimate): +def check(skip_decimate, lift_z=False): bpy.ops.wm.read_factory_settings(use_empty=True) low = build_well_mesh("WellLow", bevel_offset=0.010, bevel_segments=2) high = build_well_mesh("WellHigh", bevel_offset=0.010, bevel_segments=4) - stone = principled("WellStone", (0.40, 0.42, 0.46, 1.0), 0.0, 0.84) - wood = principled("WellWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50) - metal = principled("WellMetal", (0.62, 0.58, 0.48, 1.0), 1.0, 0.25) + stone = principled( + "WellStone", (0.40, 0.42, 0.46, 1.0), 0.0, 0.84, + noise_scale=9.0, wear=(0.29, 0.30, 0.33, 1.0), + ) + wood = principled( + "WellWood", (0.48, 0.22, 0.07, 1.0), 0.0, 0.50, + noise_scale=7.0, wear=(0.30, 0.13, 0.04, 1.0), + ) + metal = principled( + "WellMetal", (0.62, 0.58, 0.48, 1.0), 1.0, 0.30, + noise_scale=5.0, wear=(0.34, 0.32, 0.27, 1.0), + ) assign_slots(low, stone, wood, metal) assign_slots(high, stone, wood, metal) + if lift_z: + for v in low.data.vertices: + v.co.z += LIFT_Z + low.data.update() if low.data is None or len(low.data.polygons) < 6: return fail("well mesh did not build", 3), None, None, None, None, None @@ -576,6 +835,10 @@ def check(skip_decimate): mats = [s for s in low.data.materials if s is not None] nmat = len(mats) distinct_mats = len({id(s) for s in mats}) + idx_counts = {} + for poly in low.data.polygons: + idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1 + print(f"measured mat_index_counts={idx_counts}") u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data) bb = world_bbox(low) size_x = bb[3] - bb[0] @@ -626,6 +889,15 @@ def check(skip_decimate): f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}" ) + hyg = hygiene_audit(low.data) + gap_sw = min_mat_distance(low.data, STONE_IDX, WOOD_IDX) + gap_mw = min_mat_distance(low.data, METAL_IDX, WOOD_IDX) + print( + f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']} euler={hyg['euler']}" + ) + print(f"measured gap_stone_wood={gap_sw:.5f} gap_metal_wood={gap_mw:.5f}") if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -637,6 +909,21 @@ def check(skip_decimate): f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", 5, ), None, None, None, None, None + if idx_counts.get(STONE_IDX, 0) < STONE_FACES_MIN: + return fail( + f"stone faces {idx_counts.get(STONE_IDX, 0)} < {STONE_FACES_MIN}", + 5, + ), None, None, None, None, None + if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN: + return fail( + f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}", + 5, + ), None, None, None, None, None + if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN: + return fail( + f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}", + 5, + ), None, None, None, None, None if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: return fail( f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", @@ -680,6 +967,38 @@ def check(skip_decimate): ), None, None, None, None, None if export_size <= 0: return fail("export file missing or empty", 13), None, None, None, None, None + if ( + hyg["loose_v"] + or hyg["loose_e"] + or hyg["nonman"] + or hyg["zero_area"] + or hyg["doubles"] + or hyg["ngons"] + ): + return fail( + f"hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " + f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " + f"doubles={hyg['doubles']} ngons={hyg['ngons']}", + 15, + ), None, None, None, None, None + if abs(bb[2]) > ZMIN_EPS: + return fail( + f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 " + "(--lift-z is the designed fail)", + 16, + ), None, None, None, None, None + if gap_sw > GAP_MAX: + return fail( + f"stone-wood gap {gap_sw:.5f} > {GAP_MAX} " + "(posts must seat on the curb)", + 17, + ), None, None, None, None, None + if gap_mw > GAP_MAX: + return fail( + f"metal-wood gap {gap_mw:.5f} > {GAP_MAX} " + "(crank, hoops, and bail must touch the wood they mount to)", + 17, + ), None, None, None, None, None return 0, low, high, stone, tex, collider @@ -750,7 +1069,7 @@ def light(name, loc, energy, size, col, rot): cam.location = (3.10, -4.45, 2.12) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, OUTER_SIZE[2] / 2.0 + 0.04) + aim.location = (0.0, 0.0, OUTER_SIZE[2] / 2.0) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -798,9 +1117,16 @@ def main(): action="store_true", help="falsification: skip the LOD DECIMATE stage", ) + p.add_argument( + "--lift-z", + action="store_true", + help="falsification: lift the mesh so zmin fails the grounded budget", + ) args = p.parse_args(argv) - code, low, _high, stone, tex, _col = check(args.skip_decimate) + code, low, _high, stone, tex, _col = check( + args.skip_decimate, lift_z=args.lift_z + ) if code: return code if args.output: