diff --git a/docs/gallery/assets/cart-hero.webp b/docs/gallery/assets/cart-hero.webp index 33e7fcfe..28b5a011 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 da501894..c4f7637d 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 ef77656c..9fcd5c90 100644 --- a/docs/gallery/cart/index.html +++ b/docs/gallery/cart/index.html @@ -253,7 +253,7 @@

cart

cart render

Rendered headless by the showcase piece itself — click to zoom.

-
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.
+
witnesses Recomputed: 2856 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 154 tris, hygiene zero (loose, non-manifold, doubles, n-gons), zero coplanar cross-shell face pairs, both tyres grounded, tyre seat depth in band over 48 angular stations, the two wheels mirrored within 5e-5 m, grounded zmin, non-empty glTF. --flush-tyre exits 15 on the z-fight budget; --sink-tyre exits 18 on the tyre seat band; --float-wheel exits 16 on the named-support budget; --skew-wheel exits 19 on the wheel mirror; --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,18 +264,25 @@

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–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 |

+

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 2760–2960 | 2856 / 2856 / 2856 | | 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 | | Named supports | each tyre's own zmin within 1e-4 of the floor | +Y 0.000000, −Y 0.000000 | | Hygiene | loose V/E, non-manifold, zero-area, doubles @1e-5, n-gons: all 0 | 0 / 0 / 0 on every axis | | Z-fighting | coplanar cross-shell face pairs = 0 | 0 / 0 / 0 | | Tyre seat | 0.0030–0.0055 m interference, per angular station | [0.00400, 0.00400] over 48 stations | | Wheel mirror | the two wheels match in X, Z and extent within 5e-5 m | 0.00000 mm | | 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. --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.

+

Falsifiers

+

Each flag breaks one stage so a named budget fails and the piece exits *that* code. Magnitudes are sized so no earlier gate can steal the failure: the two wheel moves stay inside BBOX_TOL, so the AABB gate (exit 8) still passes.

+

| Flag | Target budget | Exit | | --- | --- | --- | | --skip-decimate | LOD1 ratio band (ratio becomes 1.0) | 9 | | --lift-z | AABB grounded zmin (whole mesh up 0.05 m) | 16 | | --flush-tyre | Coplanar cross-shell pairs — sets the tyre's inner radius equal to the felloe's outer radius, which is the construction bug this piece was rebuilt to remove (0 → 48 pairs) | 15 | | --sink-tyre | Tyre seat band — buries the hoop 9 mm into the felloe so it reads as one body | 18 | | --float-wheel | Named supports — lifts one wheel 3 mm while the other still grounds the AABB | 16 | | --skew-wheel | Wheel mirror — pushes one wheel 6 mm out along the track | 19 |

+

--lift-z and --float-wheel share exit 16 and fail different budgets: --lift-z fails the AABB gate, which fires first; --float-wheel leaves the AABB grounded, so only the per-tyre support check can catch it.

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 -- --flush-tyre
+blender --background --python cart.py -- --sink-tyre
+blender --background --python cart.py -- --float-wheel
+blender --background --python cart.py -- --skew-wheel
 blender --background --python cart.py -- --output cart.png
-

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

+

Smoke passes none of the falsifier flags and no --output.

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 | | 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) |

+

| 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, n-gons, or coplanar cross-shell face pairs (--flush-tyre lands here) | | 16 | Bbox min Z not grounded (--lift-z), or a named support off the floor (--float-wheel) | | 17 | Material-island gap above tolerance (parts meant to touch) | | 18 | Tyre seat depth outside its band (--sink-tyre lands here) | | 19 | Wheel mirror deviation above epsilon (--skew-wheel lands here) |

Source

@@ -331,6 +338,16 @@

Source

RIM_W = 0.036 TYRE_T = 0.008 TYRE_W = 0.040 +# Felloe outer radius is the *host* surface the tyre is hooped onto. The +# tyre's inner radius is derived from it minus a named interference, never +# set equal to it: r_in == r_out puts the tyre's inner cylinder and the +# felloe's tread on one plane for every segment, which is a guaranteed +# z-fight (it measured 32 coplanar cross-shell pairs, 16 per wheel). +RIM_OUTER = RIM_MAJOR + RIM_RADIAL +TYRE_SEAT = 0.004 +TYRE_SEAT_MIN = 0.0030 +TYRE_SEAT_MAX = 0.0055 +WHEEL_SEGMENTS = 24 TRACK = 0.68 AXLE_X = -0.16 AXLE_R = 0.020 @@ -353,8 +370,11 @@

Source

BBOX_TOL = 0.01 OUTER_SIZE = (1.539, 0.749, 0.640) -BASE_TRIS_MIN = 2470 -BASE_TRIS_MAX = 2900 +# Re-fitted after the wheel went from 16 to 24 segments (2600 -> 2856). +# Narrower than the band it replaces (200 wide, was 430) and centred on +# the measured value, so this is a tightening, not a widening. +BASE_TRIS_MIN = 2760 +BASE_TRIS_MAX = 2960 ZMIN_EPS = 1e-4 DOUBLES_EPS = 1e-5 AREA_EPS = 1e-10 @@ -375,6 +395,24 @@

Source

METAL_FACES_MIN = 24 WOOD_FACES_MIN = 800 +# Z-fighting: two separate bodies landing on one plane. Cross-shell, with +# hay-bale's constants (copied, not imported). +COPLANAR_NORMAL_EPS = 1e-4 +COPLANAR_PLANE_EPS = 1e-4 +COPLANAR_CENTRE_MAX = 0.05 +ZFIGHT_PAIRS_MAX = 0 +# Named supports: a two-wheel cart's AABB zmin is grounded by whichever +# tyre happens to be lowest. Each tyre carries its own floor contact. +SUPPORT_ZMIN_EPS = 1e-4 +# Mirrored members: the two wheels are the same part reflected in Y. +WHEEL_MIRROR_EPS = 5e-5 +# Falsifier magnitudes, each sized to trip its own budget and nothing +# earlier: the wheel moves stay inside BBOX_TOL so the AABB gate cannot +# steal the failure. +SINK_TYRE_SEAT = 0.009 +FLOAT_WHEEL_Z = 0.003 +SKEW_WHEEL_Y = 0.006 + WOOD_IDX = 0 METAL_IDX = 1 @@ -534,14 +572,19 @@

Source

def add_wheel(bm, loc, wood, metal): wood.extend( add_ring( - bm, loc, RIM_MAJOR, RIM_RADIAL, RIM_W, 16, WOOD_IDX, + bm, loc, RIM_MAJOR, RIM_RADIAL, RIM_W, WHEEL_SEGMENTS, WOOD_IDX, euler=(math.pi / 2.0, 0.0, 0.0), ) ) + # Hooped, not pasted on: inner radius is RIM_OUTER - TYRE_SEAT so the + # band bites into the felloe, and the tread still lands at + # RIM_OUTER + TYRE_T so the tyre stays the ground contact. + tyre_in = RIM_OUTER - TYRE_SEAT + tyre_out = RIM_OUTER + TYRE_T metal.extend( add_ring( - bm, loc, RIM_MAJOR + RIM_RADIAL + TYRE_T / 2.0, TYRE_T / 2.0, - TYRE_W, 16, METAL_IDX, + bm, loc, 0.5 * (tyre_in + tyre_out), 0.5 * (tyre_out - tyre_in), + TYRE_W, WHEEL_SEGMENTS, METAL_IDX, euler=(math.pi / 2.0, 0.0, 0.0), ) ) @@ -882,6 +925,179 @@

Source

} +def shell_groups(me): + """Vertex-index shells by edge connectivity (union-find), biggest first.""" + parent = list(range(len(me.vertices))) + + def find(a): + while parent[a] != a: + parent[a] = parent[parent[a]] + a = parent[a] + return a + + for e in me.edges: + ra, rb = find(int(e.vertices[0])), find(int(e.vertices[1])) + if ra != rb: + parent[rb] = ra + groups = {} + for i in range(len(me.vertices)): + groups.setdefault(find(i), []).append(i) + return sorted(groups.values(), key=lambda g: -len(g)) + + +def shell_box(me, idxs): + co = [me.vertices[i].co for i in idxs] + return { + "xmin": min(c.x for c in co), "xmax": max(c.x for c in co), + "ymin": min(c.y for c in co), "ymax": max(c.y for c in co), + "zmin": min(c.z for c in co), "zmax": max(c.z for c in co), + } + + +def coplanar_zfight_pairs(me, groups): + """Coplanar face pairs from *different shells* — the z-fighting budget. + + Cross-shell, not merely share-no-vertex: two quads two steps apart on + one flat cap share no vertex and are coplanar by construction, and + counting those makes the budget unsatisfiable rather than meaningful. + Z-fighting is two separate bodies landing on one plane, which is + exactly a cross-shell pair. Combinatorics and constants copied from + showcase/hay-bale (do not import across pieces). + """ + owner = {} + for si, comp in enumerate(groups): + for vi in comp: + owner[vi] = si + faces = [(p.normal.copy(), p.center.copy(), owner.get(p.vertices[0], -1)) + for p in me.polygons] + hits = 0 + for i in range(len(faces)): + ni, ci, si = faces[i] + for j in range(i + 1, len(faces)): + nj, cj, sj = faces[j] + if si == sj: + continue + if (ci - cj).length > COPLANAR_CENTRE_MAX: + continue + if abs(abs(ni.dot(nj)) - 1.0) > COPLANAR_NORMAL_EPS: + continue + if abs(ni.dot(cj - ci)) > COPLANAR_PLANE_EPS: + continue + hits += 1 + return hits + + +def wheel_shells(me, groups): + """(rim, tyre) shell index pairs for each wheel, keyed by track side. + + Identified from the generated mesh by geometry — a shell centred on the + axle line whose YZ extent is a full disc — never from a construction + constant. The tyre is the metal-indexed member of the pair. + """ + metal = set() + for p in me.polygons: + if p.material_index == METAL_IDX: + metal.add(int(p.vertices[0])) + found = {} + for si, g in enumerate(groups): + b = shell_box(me, g) + dx = b["xmax"] - b["xmin"] + dz = b["zmax"] - b["zmin"] + if dx < 0.4 or abs(dx - dz) > 0.02: + continue + side = 1 if 0.5 * (b["ymin"] + b["ymax"]) > 0 else -1 + kind = "tyre" if any(i in metal for i in g) else "rim" + found.setdefault(side, {})[kind] = si + return found + + +def tyre_seat_depths(me, groups, rim_si, tyre_si, segments): + """Per-station interference between the tyre's inner ring and the felloe. + + Banded and per angular station, not one global figure: a single number + passes while one arc of the hoop visibly gaps. Both radii are recomputed + from the generated vertices, so nothing here restates a constant. + """ + axis_y = 0.5 * (shell_box(me, groups[tyre_si])["ymin"] + + shell_box(me, groups[tyre_si])["ymax"]) + rb = shell_box(me, groups[rim_si]) + cx = 0.5 * (rb["xmin"] + rb["xmax"]) + cz = 0.5 * (rb["zmin"] + rb["zmax"]) + + def bins(idxs): + out = {} + for i in idxs: + co = me.vertices[i].co + ang = math.atan2(co.z - cz, co.x - cx) + k = int(round(ang / (2.0 * math.pi / segments))) % segments + out.setdefault(k, []).append( + math.hypot(co.x - cx, co.z - cz)) + return out + + rim_bins = bins(groups[rim_si]) + tyre_bins = bins(groups[tyre_si]) + depths = [] + for k in sorted(set(rim_bins) & set(tyre_bins)): + rim_out = max(rim_bins[k]) + tyre_in = min(tyre_bins[k]) + depths.append(rim_out - tyre_in) + return depths, axis_y + + +def _wheel_centre(me, groups, rim_si): + b = shell_box(me, groups[rim_si]) + return 0.5 * (b["xmin"] + b["xmax"]), 0.5 * (b["zmin"] + b["zmax"]) + + +def break_tyre_seat(me, seat): + """Falsifier surgery: re-radius each tyre's inner ring to `seat` deep. + + seat=0.0 puts the tyre's inner cylinder on the felloe's tread plane, + which is the construction bug this piece was rebuilt to remove. + """ + groups = shell_groups(me) + for parts in wheel_shells(me, groups).values(): + cx, cz = _wheel_centre(me, groups, parts["rim"]) + idxs = groups[parts["tyre"]] + radii = [math.hypot(me.vertices[i].co.x - cx, + me.vertices[i].co.z - cz) for i in idxs] + split = 0.5 * (min(radii) + max(radii)) + for i, r in zip(idxs, radii): + if r >= split: + continue + co = me.vertices[i].co + scale = (RIM_OUTER - seat) / r + co.x = cx + (co.x - cx) * scale + co.z = cz + (co.z - cz) * scale + me.update() + + +def move_one_wheel(me, delta): + """Falsifier surgery: translate the +Y wheel only (rim, tyre, spokes). + + Everything whose vertices lie on the +Y side of the axle centreline and + within the wheel's radius, so the assembly moves as one body and the + opposite wheel still grounds the AABB. + """ + groups = shell_groups(me) + parts = wheel_shells(me, groups).get(1) + if parts is None: + return + b = shell_box(me, groups[parts["tyre"]]) + cx, cz = _wheel_centre(me, groups, parts["rim"]) + y_lo, y_hi = b["ymin"] - 0.02, b["ymax"] + 0.02 + r_max = 0.5 * (b["xmax"] - b["xmin"]) + 1e-4 + for v in me.vertices: + if not (y_lo <= v.co.y <= y_hi): + continue + if math.hypot(v.co.x - cx, v.co.z - cz) > r_max: + continue + v.co.x += delta[0] + v.co.y += delta[1] + v.co.z += delta[2] + me.update() + + def min_mat_distance(me, ia, ib): """Closest surface distance between two material islands via BVH. @@ -1003,7 +1219,8 @@

Source

) -def check(skip_decimate, lift_z=False): +def check(skip_decimate, lift_z=False, flush_tyre=False, sink_tyre=False, + float_wheel=False, skew_wheel=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) @@ -1011,9 +1228,14 @@

Source

"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), ) + # Wrought iron, not black plastic. At (0.13, 0.32) the tyre rendered as + # a flat black band with no sheen — darker and glossier than every + # sibling piece's iron (hitching-post 0.16/0.48, wooden-ladder + # 0.18/0.48). Raised to sit inside that calibration range, and the wear + # colour lifted off near-black so the noise actually varies the surface. 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), + "CartIron", (0.20, 0.195, 0.185, 1.0), 1.0, 0.50, + noise_scale=5.0, wear=(0.11, 0.105, 0.10, 1.0), ) assign_slots(low, wood, metal) assign_slots(high, wood, metal) @@ -1021,6 +1243,14 @@

Source

for v in low.data.vertices: v.co.z += LIFT_Z low.data.update() + if flush_tyre: + break_tyre_seat(low.data, 0.0) + if sink_tyre: + break_tyre_seat(low.data, SINK_TYRE_SEAT) + if float_wheel: + move_one_wheel(low.data, (0.0, 0.0, FLOAT_WHEEL_Z)) + if skew_wheel: + move_one_wheel(low.data, (0.0, SKEW_WHEEL_Y, 0.0)) if low.data is None or len(low.data.polygons) < 6: return fail("cart mesh did not build", 3), None, None, None, None, None @@ -1092,6 +1322,53 @@

Source

) print(f"measured gap_metal_wood={gap_mw:.5f}") + groups = shell_groups(low.data) + zfight = coplanar_zfight_pairs(low.data, groups) + wheels = wheel_shells(low.data, groups) + support_zmins = {} + seats = [] + for side, parts in sorted(wheels.items()): + tb = shell_box(low.data, groups[parts["tyre"]]) + support_zmins["+Y" if side > 0 else "-Y"] = round(tb["zmin"], 6) + depths, _ = tyre_seat_depths( + low.data, groups, parts["rim"], parts["tyre"], WHEEL_SEGMENTS + ) + seats.extend(depths) + support_worst = max((abs(z) for z in support_zmins.values()), default=1e9) + seat_min = min(seats) if seats else -1.0 + seat_max = max(seats) if seats else 1e9 + seat_n = len(seats) + # Mirror: the two wheels are one part reflected in Y, so their rim + # centres must match in X and Z and be opposite in Y. + wheel_mirror = 1e9 + if len(wheels) == 2: + boxes = {} + for side, parts in wheels.items(): + b = shell_box(low.data, groups[parts["rim"]]) + boxes[side] = ( + 0.5 * (b["xmin"] + b["xmax"]), + 0.5 * (b["ymin"] + b["ymax"]), + 0.5 * (b["zmin"] + b["zmax"]), + b["xmax"] - b["xmin"], + b["zmax"] - b["zmin"], + ) + a, b2 = boxes[1], boxes[-1] + wheel_mirror = max( + abs(a[0] - b2[0]), abs(a[1] + b2[1]), abs(a[2] - b2[2]), + abs(a[3] - b2[3]), abs(a[4] - b2[4]), + ) + print( + f"measured zfight_pairs={zfight} shells={len(groups)} " + f"support_zmin={support_zmins} " + f"tyre_seat=[{seat_min:.5f},{seat_max:.5f}] over {seat_n} stations " + f"wheel_mirror={wheel_mirror*1000:.5f}mm" + ) + + if len(wheels) != 2 or any(len(p) != 2 for p in wheels.values()): + return fail( + f"expected 2 wheels of (rim, tyre) shells, found {wheels}", + 3, + ), None, None, None, None, None if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", @@ -1169,18 +1446,49 @@

Source

f"doubles={hyg['doubles']} ngons={hyg['ngons']}", 15, ), None, None, None, None, None + if zfight > ZFIGHT_PAIRS_MAX: + return fail( + f"coplanar cross-shell face pairs {zfight} > {ZFIGHT_PAIRS_MAX} " + "(--flush-tyre is the designed fail: a tyre whose inner radius " + "equals the felloe's outer radius puts both on one plane)", + 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 support_worst > SUPPORT_ZMIN_EPS: + return fail( + f"named support zmin {support_worst:.6f} > {SUPPORT_ZMIN_EPS} " + f"per-tyre zmin={support_zmins} " + "(--float-wheel is the designed fail: one tyre off the floor " + "while the other still grounds the AABB)", + 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 + if seat_min < TYRE_SEAT_MIN or seat_max > TYRE_SEAT_MAX: + return fail( + f"tyre seat depth band [{seat_min:.5f}, {seat_max:.5f}] outside " + f"[{TYRE_SEAT_MIN}, {TYRE_SEAT_MAX}] over {seat_n} stations " + "(--sink-tyre is the designed fail: the hoop swallowed by the " + "felloe reads as one body, not a tyre)", + 18, + ), None, None, None, None, None + if wheel_mirror > WHEEL_MIRROR_EPS: + return fail( + f"wheel mirror deviation {wheel_mirror*1000:.4f} mm > " + f"{WHEEL_MIRROR_EPS*1000:.4f} mm " + "(--skew-wheel is the designed fail: the two wheels are one part " + "reflected in Y and must sit at matched X and Z)", + 19, + ), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -1304,10 +1612,38 @@

Source

action="store_true", help="falsification: lift the mesh so zmin fails the grounded budget", ) + p.add_argument( + "--flush-tyre", + action="store_true", + help="falsification: tyre inner radius == felloe outer radius, so " + "both land on one plane and the z-fight budget fails", + ) + p.add_argument( + "--sink-tyre", + action="store_true", + help="falsification: bury the tyre in the felloe so the seat band fails", + ) + p.add_argument( + "--float-wheel", + action="store_true", + help="falsification: lift one wheel off the floor while the other " + "still grounds the AABB, so the named-support budget fails", + ) + p.add_argument( + "--skew-wheel", + action="store_true", + help="falsification: push one wheel out along the track so the " + "wheel-mirror budget fails", + ) args = p.parse_args(argv) code, low, _high, wood, tex, _col = check( - args.skip_decimate, lift_z=args.lift_z + args.skip_decimate, + lift_z=args.lift_z, + flush_tyre=args.flush_tyre, + sink_tyre=args.sink_tyre, + float_wheel=args.float_wheel, + skew_wheel=args.skew_wheel, ) if code: return code diff --git a/docs/gallery/contact-sheets/cart-contact-sheet.webp b/docs/gallery/contact-sheets/cart-contact-sheet.webp new file mode 100644 index 00000000..c95d455d Binary files /dev/null and b/docs/gallery/contact-sheets/cart-contact-sheet.webp differ diff --git a/docs/gallery/contact-sheets/stone-well-contact-sheet.webp b/docs/gallery/contact-sheets/stone-well-contact-sheet.webp new file mode 100644 index 00000000..6030b2ba Binary files /dev/null and b/docs/gallery/contact-sheets/stone-well-contact-sheet.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 66297919..edf64310 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: 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.

+

witnesses Recomputed: 9380 tris, three materials with face floors, UVs in 0..1 with zero AABB overlap, outer AABB 1.312×1.312×1.761 m, LOD ratios in band, convex collider 346 tris, hygiene zero (loose, non-manifold, doubles, n-gons), zero coplanar cross-shell face pairs, all 12 bottom-course stones on the floor, each post tenoned 18 mm into the measured coping and standing under a roof hip corner, the bucket hanging 0.10 m clear of the coping, grounded zmin, non-empty glTF. --stand-posts exits 15 on the z-fight budget; --float-stone exits 16 on the named-support budget; --shallow-tenon and --drop-bucket exit 18; --turn-posts exits 19 on post placement; --skip-decimate exits 9 on the LOD1 ratio budget; --lift-z exits 16 on the grounded budget.

View showcase piece
@@ -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: 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.

+

witnesses Recomputed: 2856 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 154 tris, hygiene zero (loose, non-manifold, doubles, n-gons), zero coplanar cross-shell face pairs, both tyres grounded, tyre seat depth in band over 48 angular stations, the two wheels mirrored within 5e-5 m, grounded zmin, non-empty glTF. --flush-tyre exits 15 on the z-fight budget; --sink-tyre exits 18 on the tyre seat band; --float-wheel exits 16 on the named-support budget; --skew-wheel exits 19 on the wheel mirror; --skip-decimate exits 9 on the LOD1 ratio budget; --lift-z exits 16 on the grounded budget.

View showcase piece
diff --git a/docs/gallery/stone-well/index.html b/docs/gallery/stone-well/index.html index 578ee959..adac34e2 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 showcase piece itself — click to zoom.

-
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.
+
witnesses Recomputed: 9380 tris, three materials with face floors, UVs in 0..1 with zero AABB overlap, outer AABB 1.312×1.312×1.761 m, LOD ratios in band, convex collider 346 tris, hygiene zero (loose, non-manifold, doubles, n-gons), zero coplanar cross-shell face pairs, all 12 bottom-course stones on the floor, each post tenoned 18 mm into the measured coping and standing under a roof hip corner, the bucket hanging 0.10 m clear of the coping, grounded zmin, non-empty glTF. --stand-posts exits 15 on the z-fight budget; --float-stone exits 16 on the named-support budget; --shallow-tenon and --drop-bucket exit 18; --turn-posts exits 19 on post placement; --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,18 +264,27 @@

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–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 |

+

| 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.312, 1.312, 1.761) m ± 0.01 | (1.3120, 1.3120, 1.7606) | | Grounded | bbox min Z within 1e-4 of 0 | 0.0000 / 0.0000 / 0.0000 | | Named supports | all 12 bottom-course stones on the floor within 1e-4 | 12/12, worst 0.000000 | | Hygiene | loose V/E, non-manifold, zero-area, doubles @1e-5, n-gons: all 0 | 0 / 0 / 0 on every axis | | Z-fighting | coplanar cross-shell face pairs = 0 | 0 / 0 / 0 | | Post tenon | each post seats 0.015–0.022 m into the measured coping top | 0.01800 / 0.01800 / 0.01800 | | Post placement | each post within 0.02 rad, in plan, of a roof hip corner recomputed from the mesh | 0.00000 | | Bucket clearance | hangs 0.085–0.115 m clear of the measured coping top | 0.10000 | | Material-island gap | stone↔wood and metal↔wood min distance ≤ 0.008 m | 0.00000 / 0.00000 / 0.00000 | | Collider tris | ≤ 380 | 346 | | 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. --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.

+

Falsifiers

+

Each flag breaks one stage so a named budget fails and the piece exits *that* code.

+

| Flag | Target budget | Exit | | --- | --- | --- | | --skip-decimate | LOD1 ratio band (ratio becomes 1.0) | 9 | | --lift-z | AABB grounded zmin (whole mesh up 0.05 m) | 16 | | --stand-posts | Coplanar cross-shell pairs — puts the posts back on the axis *and* stands them on the coping, which is the pair of choices the shipped piece made (0 → 4 pairs) | 15 | | --float-stone | Named supports — lifts one bottom-course stone 4 mm while the other eleven still ground the AABB | 16 | | --shallow-tenon | Post tenon band — seats the posts only 4 mm, too shallow to be a joint but not flush, so no faces share a plane | 18 | | --drop-bucket | Bucket clearance — lowers the bucket 0.26 m back down the shaft | 18 | | --turn-posts | Post placement — rotates the post ring one curb facet off the roof's hip corners | 19 |

+

Two of these had to be re-aimed, which is the point of declaring the target:

+
  • --stand-posts seats the posts flush and returns them to the axis. Flush alone, at the corrected 45°, leaves no coping top face within COPLANAR_CENTRE_MAX of a foot, so it fell through to the tenon band (18) and witnessed nothing about z-fighting.
  • --turn-posts rotates by exactly one curb facet (30°). Any other angle sets the feet down on a different part of the 12-gon coping and the stone-wood gap gate (17) steals the failure; a full 45° also swings the crank grip past the eave and fails the AABB gate (8) — the same trap stone-archway's --flat-arch fell into. One facet is the only rotation whose local seat geometry is identical by symmetry.

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 -- --stand-posts
+blender --background --python stone_well.py -- --shallow-tenon
+blender --background --python stone_well.py -- --turn-posts
+blender --background --python stone_well.py -- --float-stone
+blender --background --python stone_well.py -- --drop-bucket
 blender --background --python stone_well.py -- --output well.png
-

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

+

Smoke passes none of the falsifier flags and no --output.

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, 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) |

+

| 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, n-gons, or coplanar cross-shell face pairs (--stand-posts lands here) | | 16 | Bbox min Z not grounded (--lift-z), or a named support off the floor (--float-stone) | | 17 | Material-island gap above tolerance (parts meant to touch) | | 18 | Post tenon depth outside its band (--shallow-tenon), or bucket clearance outside its band (--drop-bucket) | | 19 | A post not under a roof hip corner in plan (--turn-posts lands here) |

Source

@@ -290,9 +299,14 @@

Source

normal bake, LOD chain, convex collider, Unity glTF export. 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. ``--lift-z`` raises the -mesh so the grounded-zmin hygiene budget fails. +They are not API-contract witnesses. Each falsifier flag breaks one +stage so a named budget fails and the piece exits that budget's code: +``--skip-decimate`` (LOD ratio, 9), ``--lift-z`` (AABB grounded, 16), +``--float-stone`` (named supports, 16), ``--stand-posts`` (coplanar +cross-shell pairs, 15), ``--shallow-tenon`` (post tenon band, 18), +``--drop-bucket`` (bucket clearance, 18), ``--turn-posts`` (post under +hip corner, 19). See README.md for why two of them are aimed the way +they are. No RNG. Construction is closed-form (per-stone jitter is a deterministic hash). DECIMATE COLLAPSE triangle counts are not byte-identical across @@ -300,7 +314,7 @@

Source

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 -- --stand-posts blender --background --python stone_well.py -- --output well.png """ import argparse @@ -344,11 +358,29 @@

Source

CURB_Z = MASONRY_TOP + CURB_H / 2.0 POST_S = 0.068 POST_R = 0.55 -POST_H = 0.58 -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 +# Posts land under the roof's four hip corners, not at the midpoint of each +# eave. A cone with 4 base verts rotated by pi/4 puts its corners on the +# diagonals, so the posts go on the diagonals too. At the axis-aligned +# angles the roof corners cantilevered 0.80 m unsupported and one post +# stood dead centre in the well mouth from every orthogonal view. +POST_ANGLES = tuple(math.pi / 4.0 + i * math.pi / 2.0 for i in range(4)) +# Tenoned into the curb, not stood on it: a foot whose bottom face lands +# exactly on the curb's top face puts both on one plane (it measured 4 +# coplanar cross-shell pairs, one per post). POST_TOP is held fixed so +# nothing above the posts moves. +POST_SEAT = 0.018 +POST_SEAT_MIN = 0.015 +POST_SEAT_MAX = 0.022 +POST_CLEAR = 0.58 +POST_BOTTOM = MASONRY_TOP + CURB_H - POST_SEAT +POST_TOP = MASONRY_TOP + CURB_H + POST_CLEAR +POST_H = POST_TOP - POST_BOTTOM +# The roof is sized to oversail the curb by a named clearance, measured on +# the flat of the eave. Deriving it from the post ring instead made the +# roof 1.61 m across a 1.08 m drum — an umbrella, not a well house. +EAVE_CLEAR = 0.055 +EAVE_HALF = R_OUTER + CURB_OUT + EAVE_CLEAR +EAVE_OVERHANG = EAVE_HALF - (POST_R + POST_S / 2.0) EAVE_Z = POST_TOP - 0.02 ROOF_RISE = 0.34 PEAK_Z = EAVE_Z + ROOF_RISE @@ -358,17 +390,26 @@

Source

# 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 +# The drum spans the first opposed pair of posts, so it shares their angle. +WINDLASS_AXIS = POST_ANGLES[0] BUCKET_R_TOP = 0.105 BUCKET_R_BOT = 0.088 BUCKET_WALL_T = 0.008 BUCKET_H = 0.14 -BUCKET_Z = 0.68 +# The bucket is the piece's whole story, so it hangs in the open above the +# curb. At 0.68 it sat down the shaft with only its rim level with the +# coping: invisible in the hero and in every orthographic view. +CURB_TOP = MASONRY_TOP + CURB_H +BUCKET_CLEAR = 0.10 +BUCKET_CLEAR_MIN = 0.085 +BUCKET_CLEAR_MAX = 0.115 +BUCKET_Z = CURB_TOP + BUCKET_CLEAR + BUCKET_H / 2.0 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.761) +OUTER_SIZE = (1.312, 1.312, 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. @@ -391,7 +432,48 @@

Source

METAL_FACES_MIN = 100 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 320 +# Grew with the roof resize: the convex hull of a 1.31 m roof over the +# drum measures 346 where the 1.64 m roof measured 318. Budget raised to +# match a deliberate geometry change, with headroom for the falsifiers. +COLLIDER_TRIS_MAX = 380 +# Z-fighting: two separate bodies landing on one plane. Cross-shell, with +# hay-bale's constants (copied, not imported). +COPLANAR_NORMAL_EPS = 1e-4 +COPLANAR_PLANE_EPS = 1e-4 +COPLANAR_CENTRE_MAX = 0.05 +ZFIGHT_PAIRS_MAX = 0 +# Named supports: the AABB zmin is grounded by whichever bottom-course +# stone happens to be lowest, so every bottom stone carries its own floor +# contact. N_AROUND of them, one per bay. +SUPPORT_ZMIN_EPS = 1e-4 +SUPPORT_COUNT = N_AROUND +# Posts stand under the roof's hip corners, measured in plan against the +# corners recomputed from the generated roof. +# Angular, in radians, wrapped into [-pi, pi]. 0.02 rad is 1.15 degrees. +POST_CORNER_EPS = 0.02 +POST_COUNT = 4 +# Falsifier magnitudes, each sized to trip its own budget and nothing +# earlier: the moves stay inside BBOX_TOL so the AABB gate cannot steal +# the failure. +STAND_POST_SEAT = 0.0 +# --stand-posts must put the posts back where the shipped bug had them: +# on the axis, where a curb top face sits within COPLANAR_CENTRE_MAX of +# each foot. One facet over (15 degrees) is flush but too far from any +# coping top face to register, and falls through to the seat band. +# The crank no longer follows the post ring, so this rotation cannot +# swing it past the eave. +STAND_POST_TURN = math.pi / 4.0 +SHALLOW_TENON_SEAT = 0.004 +FLOAT_STONE_Z = 0.004 +# Exactly one curb facet. Any other angle sets the post feet down on a +# different part of the 12-gon coping and the stone-wood gap gate (17) +# steals the failure; a full 45 degrees also swings the crank grip past +# the eave and fails the AABB gate (8). One facet is the only rotation +# whose local seat geometry is identical by symmetry, so nothing but the +# hip-alignment budget can see it. Same mis-aimed-falsifier trap as +# stone-archway's --flat-arch. +TURN_POSTS_ANGLE = 2.0 * math.pi / N_AROUND +DROP_BUCKET_Z = 0.26 BAKE_RES = 256 CAGE_EXTRUSION = 0.06 @@ -561,7 +643,8 @@

Source

f.material_index = mat_idx -def build_well_mesh(name, bevel_offset, bevel_segments): +def build_well_mesh(name, bevel_offset, bevel_segments, + post_seat=POST_SEAT, turn_posts=0.0): bm = bmesh.new() stone_verts = [] wood_bevel_verts = [] @@ -618,24 +701,33 @@

Source

clamp_overlap=True, ) - post_angles = (0.0, math.pi / 2.0, math.pi, 3.0 * math.pi / 2.0) - for ang in post_angles: + angles = tuple(a - turn_posts for a in POST_ANGLES) + post_bottom = CURB_TOP - post_seat + post_h = POST_TOP - post_bottom + for ang in angles: loc = ( POST_R * math.cos(ang), POST_R * math.sin(ang), - POST_BOTTOM + POST_H / 2.0, + post_bottom + post_h / 2.0, ) wood_bevel_verts.extend( - add_box(bm, loc, (POST_S, POST_S, POST_H), WOOD_IDX) + add_box(bm, loc, (POST_S, POST_S, post_h), WOOD_IDX, + euler=(0.0, 0.0, ang)) ) + # The windlass is borne by two opposite posts, so it runs along the + # post diagonal, not along X. Everything hung on it — beam, drum, + # crank arm, grip — turns with it as one assembly. beam_z = POST_TOP - 0.05 + wax = WINDLASS_AXIS + ca, sa = math.cos(wax), math.sin(wax) 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, + euler=(0.0, 0.0, wax), ) ) add_cylinder( @@ -645,24 +737,27 @@

Source

WINDLASS_LEN, 12, WOOD_IDX, - euler=(0.0, math.pi / 2.0, 0.0), + euler=(0.0, math.pi / 2.0, wax), ) # Crank: arm pinned to the protruding windlass end, grip parallel to # the windlass axis at the arm's lower end. + arm_r = WINDLASS_END + 0.004 add_box( bm, - (WINDLASS_END + 0.004, 0.0, beam_z - 0.055), + (arm_r * ca, arm_r * sa, beam_z - 0.055), (0.020, 0.022, 0.13), METAL_IDX, + euler=(0.0, 0.0, wax), ) + grip_r = WINDLASS_END + 0.055 add_cylinder( bm, - (WINDLASS_END + 0.055, 0.0, beam_z - 0.12), + (grip_r * ca, grip_r * sa, beam_z - 0.12), 0.014, 0.10, 10, METAL_IDX, - euler=(0.0, math.pi / 2.0, 0.0), + euler=(0.0, math.pi / 2.0, wax), ) pitch = math.atan(ROOF_RISE / EAVE_HALF) @@ -963,6 +1058,162 @@

Source

} +def shell_groups(me): + """Vertex-index shells by edge connectivity (union-find), biggest first.""" + parent = list(range(len(me.vertices))) + + def find(a): + while parent[a] != a: + parent[a] = parent[parent[a]] + a = parent[a] + return a + + for e in me.edges: + ra, rb = find(int(e.vertices[0])), find(int(e.vertices[1])) + if ra != rb: + parent[rb] = ra + groups = {} + for i in range(len(me.vertices)): + groups.setdefault(find(i), []).append(i) + return sorted(groups.values(), key=lambda g: -len(g)) + + +def shell_box(me, idxs): + co = [me.vertices[i].co for i in idxs] + return { + "xmin": min(c.x for c in co), "xmax": max(c.x for c in co), + "ymin": min(c.y for c in co), "ymax": max(c.y for c in co), + "zmin": min(c.z for c in co), "zmax": max(c.z for c in co), + } + + +def coplanar_zfight_pairs(me, groups): + """Coplanar face pairs from *different shells* — the z-fighting budget. + + Cross-shell, not merely share-no-vertex: two quads two steps apart on + one flat cap share no vertex and are coplanar by construction, and + counting those makes the budget unsatisfiable rather than meaningful. + Z-fighting is two separate bodies landing on one plane, which is + exactly a cross-shell pair. Combinatorics and constants copied from + showcase/hay-bale (do not import across pieces). + """ + owner = {} + for si, comp in enumerate(groups): + for vi in comp: + owner[vi] = si + faces = [(p.normal.copy(), p.center.copy(), owner.get(p.vertices[0], -1)) + for p in me.polygons] + hits = 0 + for i in range(len(faces)): + ni, ci, si = faces[i] + for j in range(i + 1, len(faces)): + nj, cj, sj = faces[j] + if si == sj: + continue + if (ci - cj).length > COPLANAR_CENTRE_MAX: + continue + if abs(abs(ni.dot(nj)) - 1.0) > COPLANAR_NORMAL_EPS: + continue + if abs(ni.dot(cj - ci)) > COPLANAR_PLANE_EPS: + continue + hits += 1 + return hits + + +def post_shells(me, groups): + """The four roof posts, found in the generated mesh by their geometry. + + A post is a wood shell that spans the curb line vertically and is + square and slender in plan — never looked up by a construction index. + """ + wood = set() + for p in me.polygons: + if p.material_index == WOOD_IDX: + wood.add(int(p.vertices[0])) + out = [] + for si, g in enumerate(groups): + if not any(i in wood for i in g): + continue + b = shell_box(me, g) + dz = b["zmax"] - b["zmin"] + dx = b["xmax"] - b["xmin"] + dy = b["ymax"] - b["ymin"] + if not (0.4 <= dz <= 0.8) or max(dx, dy) > 0.25: + continue + out.append((si, b)) + return out + + +def measured_curb_top(me): + """Top of the masonry, read off the generated mesh, not from CURB_TOP. + + An assertion that restates the constant the builder used witnesses + nothing, so the post tenon is measured against the stone the post is + actually tenoned into. + """ + stone = {int(p.vertices[0]) for p in me.polygons + if p.material_index == STONE_IDX} + return max(me.vertices[i].co.z for i in stone) + + +def roof_corners(me, groups): + """The roof's four hip corners, recomputed from the generated mesh. + + The corner verts of the widest wood shell above the eave: its four + extreme XY points. Nothing here restates EAVE_HALF. + """ + pts = [v.co for v in me.vertices if v.co.z >= EAVE_Z - 0.12] + if not pts: + return [] + zlo = min(p.z for p in pts) + eave = [p for p in pts if p.z <= zlo + 0.05] + if not eave: + return [] + # The four extreme points of the pooled eave ring are the hip corners. + # Taking them from a single shell picks one fascia board instead, whose + # own extremes sit 90 degrees off the corners they are nailed to. + corners = [] + for qx, qy in ((1, 1), (-1, 1), (-1, -1), (1, -1)): + corners.append(max(eave, key=lambda p: qx * p.x + qy * p.y)) + return corners + + +def drop_bucket(me, dz): + """Falsifier surgery: lower the hung bucket assembly back down the shaft. + + Everything inside the mouth above the curb and under the windlass — + which is the bucket, its hoops and its bail — moves as one body. + """ + groups = shell_groups(me) + for g in groups: + b = shell_box(me, g) + if b["zmin"] < CURB_TOP or b["zmax"] > POST_TOP - 0.10: + continue + if max(b["xmax"] - b["xmin"], b["ymax"] - b["ymin"]) > 2.5 * BUCKET_R_TOP: + continue + for i in g: + me.vertices[i].co.z -= dz + me.update() + + +def float_one_stone(me, dz): + """Falsifier surgery: lift one bottom-course stone off the floor. + + The other bays stay down, so the AABB grounded gate still passes and + only the named-support budget can catch it. + """ + groups = shell_groups(me) + floor_z = min(v.co.z for v in me.vertices) + for g in groups: + b = shell_box(me, g) + if b["zmin"] > floor_z + 1e-5 or b["zmax"] > floor_z + STONE_H * 1.6: + continue + for i in g: + me.vertices[i].co.z += dz + break + me.update() + + def min_mat_distance(me, ia, ib): """Closest surface distance between two material islands via BVH. @@ -1090,10 +1341,28 @@

Source

) -def check(skip_decimate, lift_z=False): +def check(skip_decimate, lift_z=False, stand_posts=False, turn_posts=False, + float_stone=False, drop_bucket_flag=False, shallow_tenon=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) + # --stand-posts reproduces the shipped bug exactly: posts on the axis + # AND standing on the coping, which is the pair of choices that put a + # foot and a curb top on one plane. Seating alone at 45 degrees leaves + # no curb top face within COPLANAR_CENTRE_MAX of a foot, so it would + # fall through to the seat band and witness the wrong budget. + seat = POST_SEAT + if stand_posts: + seat = STAND_POST_SEAT + elif shallow_tenon: + seat = SHALLOW_TENON_SEAT + turn = 0.0 + if stand_posts: + turn = STAND_POST_TURN + elif turn_posts: + turn = TURN_POSTS_ANGLE + low = build_well_mesh("WellLow", bevel_offset=0.010, bevel_segments=2, + post_seat=seat, turn_posts=turn) + high = build_well_mesh("WellHigh", bevel_offset=0.010, bevel_segments=4, + post_seat=seat, turn_posts=turn) 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), @@ -1112,6 +1381,10 @@

Source

for v in low.data.vertices: v.co.z += LIFT_Z low.data.update() + if float_stone: + float_one_stone(low.data, FLOAT_STONE_Z) + if drop_bucket_flag: + drop_bucket(low.data, DROP_BUCKET_Z) if low.data is None or len(low.data.polygons) < 6: return fail("well mesh did not build", 3), None, None, None, None, None @@ -1184,6 +1457,62 @@

Source

) print(f"measured gap_stone_wood={gap_sw:.5f} gap_metal_wood={gap_mw:.5f}") + groups = shell_groups(low.data) + zfight = coplanar_zfight_pairs(low.data, groups) + # Named supports: every bottom-course stone, not just the lowest one. + floor_z = bb[2] + supports = [] + for g in groups: + b = shell_box(low.data, g) + if b["zmin"] < floor_z + 0.02 and b["zmax"] < floor_z + STONE_H * 1.6: + supports.append(b["zmin"] - floor_z) + n_support = len(supports) + support_worst = max((abs(z) for z in supports), default=1e9) + # Post tenon depth, recomputed per post from the generated curb top. + posts = post_shells(low.data, groups) + n_posts = len(posts) + curb_top = measured_curb_top(low.data) + seats = [curb_top - b["zmin"] for _si, b in posts] + seat_min = min(seats) if seats else -1.0 + seat_max = max(seats) if seats else 1e9 + # Each post stands under a hip corner of the roof, in plan. + corners = roof_corners(low.data, groups) + post_corner = 1e9 + if posts and corners: + worst = 0.0 + for _si, b in posts: + px = 0.5 * (b["xmin"] + b["xmax"]) + py = 0.5 * (b["ymin"] + b["ymax"]) + pa = math.atan2(py, px) + best = min( + abs((math.atan2(c.y, c.x) - pa + math.pi) + % (2.0 * math.pi) - math.pi) + for c in corners + ) + worst = max(worst, best) + post_corner = worst + # Bucket clearance above the curb. + bucket_clear = -1.0 + metal_v = {int(p.vertices[0]) for p in low.data.polygons + if p.material_index == METAL_IDX} + for g in groups: + b = shell_box(low.data, g) + if b["zmin"] < curb_top or any(i in metal_v for i in g): + continue + if (b["xmax"] - b["xmin"]) > 2.5 * BUCKET_R_TOP: + continue + if b["zmax"] - b["zmin"] > BUCKET_H * 1.4: + continue + cand = b["zmin"] - curb_top + if bucket_clear < 0 or cand < bucket_clear: + bucket_clear = cand + print( + f"measured zfight_pairs={zfight} shells={len(groups)} " + f"grounded_stones={n_support} support_worst={support_worst:.6f} " + f"posts={n_posts} post_seat=[{seat_min:.5f},{seat_max:.5f}] " + f"post_hip_offset={post_corner:.5f} bucket_clear={bucket_clear:.5f}" + ) + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", @@ -1266,12 +1595,27 @@

Source

f"doubles={hyg['doubles']} ngons={hyg['ngons']}", 15, ), None, None, None, None, None + if zfight > ZFIGHT_PAIRS_MAX: + return fail( + f"coplanar cross-shell face pairs {zfight} > {ZFIGHT_PAIRS_MAX} " + "(--stand-posts is the designed fail: a post foot standing on " + "the coping's top face puts both on one plane)", + 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 n_support != SUPPORT_COUNT or support_worst > SUPPORT_ZMIN_EPS: + return fail( + f"grounded bottom-course stones {n_support}/{SUPPORT_COUNT}, " + f"worst zmin {support_worst:.6f} > {SUPPORT_ZMIN_EPS} " + "(--float-stone is the designed fail: one bay lifted off the " + "floor while the rest still ground the AABB)", + 16, + ), None, None, None, None, None if gap_sw > GAP_MAX: return fail( f"stone-wood gap {gap_sw:.5f} > {GAP_MAX} " @@ -1284,6 +1628,30 @@

Source

"(crank, hoops, and bail must touch the wood they mount to)", 17, ), None, None, None, None, None + if seat_min < POST_SEAT_MIN or seat_max > POST_SEAT_MAX: + return fail( + f"post tenon depth band [{seat_min:.5f}, {seat_max:.5f}] outside " + f"[{POST_SEAT_MIN}, {POST_SEAT_MAX}] over {n_posts} posts " + "(--shallow-tenon is the designed fail)", + 18, + ), None, None, None, None, None + if bucket_clear < BUCKET_CLEAR_MIN or bucket_clear > BUCKET_CLEAR_MAX: + return fail( + f"bucket clears the curb by {bucket_clear:.5f} outside " + f"[{BUCKET_CLEAR_MIN}, {BUCKET_CLEAR_MAX}] " + "(--drop-bucket is the designed fail: a bucket down the shaft " + "shows only its rim and the piece loses its subject)", + 18, + ), None, None, None, None, None + if n_posts != POST_COUNT or post_corner > POST_CORNER_EPS: + return fail( + f"posts {n_posts}/{POST_COUNT}, worst post-to-hip-corner plan " + f"offset {post_corner:.5f} > {POST_CORNER_EPS} " + "(--turn-posts is the designed fail: posts at the midpoint of " + "each eave leave the roof's corners cantilevered and stand one " + "post in the well mouth)", + 19, + ), None, None, None, None, None return 0, low, high, stone, tex, collider @@ -1407,10 +1775,45 @@

Source

action="store_true", help="falsification: lift the mesh so zmin fails the grounded budget", ) + p.add_argument( + "--stand-posts", + action="store_true", + help="falsification: stand the posts on the curb instead of tenoning " + "them in, so foot and coping land on one plane", + ) + p.add_argument( + "--shallow-tenon", + action="store_true", + help="falsification: tenon the posts only part way into the curb so " + "the seat band fails without putting faces on one plane", + ) + p.add_argument( + "--turn-posts", + action="store_true", + help="falsification: rotate the post ring off the roof's hip corners " + "to the midpoint of each eave", + ) + p.add_argument( + "--float-stone", + action="store_true", + help="falsification: lift one bottom-course stone while the rest " + "still ground the AABB", + ) + p.add_argument( + "--drop-bucket", + action="store_true", + help="falsification: lower the bucket back down the shaft", + ) args = p.parse_args(argv) code, low, _high, stone, tex, _col = check( - args.skip_decimate, lift_z=args.lift_z + args.skip_decimate, + lift_z=args.lift_z, + stand_posts=args.stand_posts, + turn_posts=args.turn_posts, + float_stone=args.float_stone, + drop_bucket_flag=args.drop_bucket, + shallow_tenon=args.shallow_tenon, ) if code: return code diff --git a/examples/ngon-triangulate/README.md b/examples/ngon-triangulate/README.md index 320e8bf3..316368e8 100644 --- a/examples/ngon-triangulate/README.md +++ b/examples/ngon-triangulate/README.md @@ -22,6 +22,38 @@ glTF tris = 12 for a cube *or* this mesh. `--skip-triangulate` exits 4. No gallery still. A hexagon on a cube does not read at thumbnail without fake annotation. +## Verified behaviour + +`Mesh.calc_tangents` raises `RuntimeError` on any face with more than +four loops. The message is byte-identical on 4.5.11 LTS, 5.1.2, and +5.2.1 LTS: + +``` +Error: Tangent space can only be computed for tris/quads, aborting +``` + +`TANGENT_ABORT` matches on the `tris/quads` substring, so the check +survives a reword of the surrounding sentence but still fails if the +abort stops happening at all. + +## Falsifiers + +Each flag breaks one stage and lands on the assertion that stage feeds. +Neither announces a failure; both let a real check catch the mesh. + +| Flag | What it breaks | Exit | +| --- | --- | --- | +| `--no-dissolve` | never makes the n-gon, so the pre-assertion finds no pathology (`ngon count 0 != 1`) | 3 | +| `--skip-triangulate` | skips `bmesh.ops.triangulate`, so the handling assertion sees the n-gon survive (`ngons=1 tris=0 quads=4`) | 4 | + +## API reference + +| Name | 4.5 LTS | 5.2 LTS | +| --- | --- | --- | +| `Mesh.calc_tangents` | [4.5](https://docs.blender.org/api/4.5/bpy.types.Mesh.html#bpy.types.Mesh.calc_tangents) | [5.2](https://docs.blender.org/api/current/bpy.types.Mesh.html#bpy.types.Mesh.calc_tangents) | +| `bmesh.ops.dissolve_edges` | [4.5](https://docs.blender.org/api/4.5/bmesh.ops.html#bmesh.ops.dissolve_edges) | [5.2](https://docs.blender.org/api/current/bmesh.ops.html#bmesh.ops.dissolve_edges) | +| `bmesh.ops.triangulate` | [4.5](https://docs.blender.org/api/4.5/bmesh.ops.html#bmesh.ops.triangulate) | [5.2](https://docs.blender.org/api/current/bmesh.ops.html#bmesh.ops.triangulate) | + ## Run ```bash @@ -41,7 +73,7 @@ against it. | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Pathology missing: n-gon count, loops, or face count (`--no-dissolve` lands here) | -| 4 | `calc_tangents` / triangulate handling (`--skip-triangulate` lands here) | +| 4 | `calc_tangents` / triangulate handling (`--skip-triangulate` lands here, via the real handling assertion) | The `blender-smoke` workflow runs the check on Blender 5.2 LTS and 4.5 LTS (5.1 on the weekly cron, the `needs-5.1` PR label, or manual dispatch). diff --git a/examples/ngon-triangulate/ngon_triangulate.py b/examples/ngon-triangulate/ngon_triangulate.py index 394d7ba5..a437cb0e 100644 --- a/examples/ngon-triangulate/ngon_triangulate.py +++ b/examples/ngon-triangulate/ngon_triangulate.py @@ -9,7 +9,9 @@ * 5 faces, 1 n-gon, that face has 6 loops * ``Mesh.calc_tangents`` aborts until triangulated - (same abort ``triangulate-tangents`` documents) + (same abort ``triangulate-tangents`` documents). The message is + byte-identical on 4.5.11 LTS, 5.1.2 and 5.2.1 LTS: + "Error: Tangent space can only be computed for tris/quads, aborting" * triangulate the n-gon → 4 tris + 4 quads, 28 loops, tangents succeed glTF tri count is 12 either way (hexagon+quads or a cube) — not a @@ -117,13 +119,12 @@ def check(ob, skip_triangulate): file=sys.stderr, ) return 4 - if skip_triangulate: - print( - "ERROR: skip-triangulate left the n-gon; handling unrepaired", - file=sys.stderr, - ) - return 4 - triangulate_ngons(me) + # --skip-triangulate skips the repair and lets the handling assertions + # below catch the unrepaired mesh. Returning 4 from here instead would + # make the flag announce a failure rather than cause one, and the + # assertions it is supposed to falsify would never run. + if not skip_triangulate: + triangulate_ngons(me) leftover = ngons(me) tris = sum(1 for p in me.polygons if len(p.vertices) == 3) quads = sum(1 for p in me.polygons if len(p.vertices) == 4) diff --git a/showcase/README.md b/showcase/README.md index 225ebcb1..4808638f 100644 --- a/showcase/README.md +++ b/showcase/README.md @@ -42,6 +42,13 @@ entry in `showcase/gallery.json`, and a rendered still. coplanar-face budget fails (exit 15). `--short-stile` / `--short-post` lift a member out of its cup (exit 18 or 19). `--clip-ring` pulls a hung ring off the eye centerline (exit 18). + `--flush-tyre` / `--stand-posts` restore a flush surface so the + coplanar budget fails (exit 15). `--float-wheel` / `--float-stone` + lift one named support while the rest still ground the AABB (exit 16). + `--sink-tyre` / `--shallow-tenon` put a seat outside its band (exit + 18). `--drop-bucket` hides the hung subject (exit 18). + `--skew-wheel` / `--turn-posts` break a mirror or placement budget + (exit 19). A budget with no falsifier witnesses nothing: prove each one fails once, and check the exit code, not just non-zero. @@ -187,6 +194,63 @@ entry in `showcase/gallery.json`, and a rendered still. The outer AABB does not cover this: on a prop with an appendage the AABB is the appendage, and the body can drift to any size underneath it. +- **A band is hooped onto its host, never set flush against it (exit + 15/18).** A tyre, ferrule, hoop or collar derives its **inner** radius + from the host's **outer** radius minus a named interference, and its + outer radius from the host plus its own thickness. Writing + `r_mid = host_out + t/2, radial_t = t/2` — the obvious spelling — + makes the band's inner cylinder and the host's tread the *same + surface*, so every segment is a coplanar cross-shell pair around the + whole circumference. `cart` shipped that way and measured 32 pairs + (16 per wheel); the speckle was visible on the committed hero and in + a clay pass, and no budget could see it. `--flush-tyre` restores the + equality and is the falsifier. +- **A member is tenoned into its seat, never stood on it (exit + 15/18).** A post, leg or stile whose bottom face lands exactly on its + host's top face puts both on one plane. Derive the member's bottom + from the host's top minus a named seat depth, and hold the member's + *top* fixed so nothing above it moves. `stone-well` stood its four + roof posts on the coping and measured 4 pairs, one per post. + Assert the seat as a **band** recomputed against the host surface read + off the generated mesh — `max z` over the host's material — not + against the constant the builder used, which witnesses nothing. +- **Posts go under the roof's corners, not the middle of its eaves + (exit 19).** Where a hip or pyramid roof is carried on four posts, + assert each post's plan bearing against a hip corner **recomputed from + the generated mesh**, as a wrapped angular difference. Take the + corners from the *pooled* vertices at the eave line: pulling them from + a single shell picks one fascia board, whose own extremes sit 90° off + the corners it is nailed to, and the budget then fails on a correct + model. `stone-well` placed its posts on the axes, which cantilevered + the roof's corners 0.80 m and stood one post dead centre in the well + mouth in every orthographic view. +- **A roof is sized from what it must cover, not from what carries it.** + Deriving the eave reach from the post ring plus an overhang gave + `stone-well` a 1.64 m roof over a 1.08 m drum — an umbrella. Derive it + from the covered body's own radius plus a named clearance + (`EAVE_HALF = R_OUTER + CURB_OUT + EAVE_CLEAR`), and let the overhang + fall out of that. Expect the convex-hull collider budget to move when + the roof does; re-fit it and say so. +- **The subject hangs where it can be seen (exit 18).** A prop whose + story is one small part — a bucket on a rope, a lantern on a hook — + asserts that part's clearance above the body it hangs over, as a band, + measured against the body read off the mesh. `stone-well`'s bucket sat + down the shaft with only its rim level with the coping: invisible in + the hero and in all six orthographic views, and no budget noticed. + `--drop-bucket` is the falsifier. +- **Mirrored assemblies (exit 19).** Where a prop has a left and a + right of the same part — two wheels, two brackets — pair the shells + and assert they match in the two axes they share and in their extents, + within a named epsilon, and are opposite in the mirrored axis. Size + the falsifier's displacement to stay **inside** `BBOX_TOL` so the AABB + gate cannot steal the failure: `cart`'s `--skew-wheel` moves one wheel + 6 mm along a track whose tolerance is 10 mm. +- **Segment counts are a silhouette budget, not a triangle budget.** A + 16-gon felloe reads as a polygon at hero size, and the flat facet + facing the key light renders as a hard white plate. `cart` went to 24 + and the chords disappeared. Re-fit the triangle band around the new + measured count rather than leaving the old one — and prefer a band + *narrower* than the one it replaces, centred on the measurement. - **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: diff --git a/showcase/cart/README.md b/showcase/cart/README.md index b6c013f7..a4e7db31 100644 --- a/showcase/cart/README.md +++ b/showcase/cart/README.md @@ -21,29 +21,49 @@ materials, UVs, evaluated LOD, collider, or export file. | Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | -| Base triangles | 2470–2900 | 2600 / 2600 / 2600 | +| Base triangles | 2760–2960 | 2856 / 2856 / 2856 | | 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 | +| LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2157 | | 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 | +| Named supports | each tyre's own zmin within 1e-4 of the floor | +Y 0.000000, −Y 0.000000 | | Hygiene | loose V/E, non-manifold, zero-area, doubles @1e-5, n-gons: all 0 | 0 / 0 / 0 on every axis | +| Z-fighting | coplanar cross-shell face pairs = 0 | 0 / 0 / 0 | +| Tyre seat | 0.0030–0.0055 m interference, per angular station | [0.00400, 0.00400] over 48 stations | +| Wheel mirror | the two wheels match in X, Z and extent within 5e-5 m | 0.00000 mm | | 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 | +| Collider tris | ≤ 360 | 154 | +| Export | written, size > 0, removed after measuring | 221960 bytes on 5.2.1 | 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. +Export byte counts differ by a few bytes across series (glTF +serializer); the gate is "written and non-empty", not a byte count, and +the file is removed once measured. -`--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. +## Falsifiers + +Each flag breaks one stage so a **named** budget fails and the piece +exits *that* code. Magnitudes are sized so no earlier gate can steal the +failure: the two wheel moves stay inside `BBOX_TOL`, so the AABB gate +(exit 8) still passes. + +| Flag | Target budget | Exit | +| --- | --- | --- | +| `--skip-decimate` | LOD1 ratio band (ratio becomes 1.0) | 9 | +| `--lift-z` | AABB grounded zmin (whole mesh up 0.05 m) | 16 | +| `--flush-tyre` | Coplanar cross-shell pairs — sets the tyre's inner radius equal to the felloe's outer radius, which is the construction bug this piece was rebuilt to remove (0 → 48 pairs) | 15 | +| `--sink-tyre` | Tyre seat band — buries the hoop 9 mm into the felloe so it reads as one body | 18 | +| `--float-wheel` | Named supports — lifts one wheel 3 mm while the other still grounds the AABB | 16 | +| `--skew-wheel` | Wheel mirror — pushes one wheel 6 mm out along the track | 19 | + +`--lift-z` and `--float-wheel` share exit 16 and fail different budgets: +`--lift-z` fails the AABB gate, which fires first; `--float-wheel` leaves +the AABB grounded, so only the per-tyre support check can catch it. ## Run @@ -51,10 +71,14 @@ falsifiers violate. blender --background --python cart.py -- blender --background --python cart.py -- --skip-decimate blender --background --python cart.py -- --lift-z +blender --background --python cart.py -- --flush-tyre +blender --background --python cart.py -- --sink-tyre +blender --background --python cart.py -- --float-wheel +blender --background --python cart.py -- --skew-wheel blender --background --python cart.py -- --output cart.png ``` -Smoke does not pass `--output`, `--skip-decimate`, or `--lift-z`. +Smoke passes none of the falsifier flags and no `--output`. ## Exit codes @@ -78,6 +102,8 @@ 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) | +| 15 | Hygiene: loose geometry, non-manifold, zero-area, doubles, n-gons, or coplanar cross-shell face pairs (`--flush-tyre` lands here) | +| 16 | Bbox min Z not grounded (`--lift-z`), or a named support off the floor (`--float-wheel`) | | 17 | Material-island gap above tolerance (parts meant to touch) | +| 18 | Tyre seat depth outside its band (`--sink-tyre` lands here) | +| 19 | Wheel mirror deviation above epsilon (`--skew-wheel` lands here) | diff --git a/showcase/cart/cart.py b/showcase/cart/cart.py index e7e06df2..7cc134a8 100644 --- a/showcase/cart/cart.py +++ b/showcase/cart/cart.py @@ -7,9 +7,13 @@ export. 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. ``--lift-z`` raises the -mesh so the grounded-zmin hygiene budget fails. +They are not API-contract witnesses. Each falsifier flag breaks one +stage so a named budget fails and the piece exits that budget's code: +``--skip-decimate`` (LOD ratio, 9), ``--lift-z`` (AABB grounded, 16), +``--float-wheel`` (named supports, 16), ``--flush-tyre`` (coplanar +cross-shell pairs, 15), ``--sink-tyre`` (tyre seat band, 18), +``--skew-wheel`` (wheel mirror, 19). The two wheel moves stay inside +``BBOX_TOL`` so the AABB gate cannot steal the failure. No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts are not byte-identical across Blender versions — the LOD gate is a @@ -17,7 +21,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 -- --flush-tyre blender --background --python cart.py -- --output cart.png """ import argparse @@ -46,6 +50,16 @@ RIM_W = 0.036 TYRE_T = 0.008 TYRE_W = 0.040 +# Felloe outer radius is the *host* surface the tyre is hooped onto. The +# tyre's inner radius is derived from it minus a named interference, never +# set equal to it: r_in == r_out puts the tyre's inner cylinder and the +# felloe's tread on one plane for every segment, which is a guaranteed +# z-fight (it measured 32 coplanar cross-shell pairs, 16 per wheel). +RIM_OUTER = RIM_MAJOR + RIM_RADIAL +TYRE_SEAT = 0.004 +TYRE_SEAT_MIN = 0.0030 +TYRE_SEAT_MAX = 0.0055 +WHEEL_SEGMENTS = 24 TRACK = 0.68 AXLE_X = -0.16 AXLE_R = 0.020 @@ -68,8 +82,11 @@ BBOX_TOL = 0.01 OUTER_SIZE = (1.539, 0.749, 0.640) -BASE_TRIS_MIN = 2470 -BASE_TRIS_MAX = 2900 +# Re-fitted after the wheel went from 16 to 24 segments (2600 -> 2856). +# Narrower than the band it replaces (200 wide, was 430) and centred on +# the measured value, so this is a tightening, not a widening. +BASE_TRIS_MIN = 2760 +BASE_TRIS_MAX = 2960 ZMIN_EPS = 1e-4 DOUBLES_EPS = 1e-5 AREA_EPS = 1e-10 @@ -90,6 +107,24 @@ METAL_FACES_MIN = 24 WOOD_FACES_MIN = 800 +# Z-fighting: two separate bodies landing on one plane. Cross-shell, with +# hay-bale's constants (copied, not imported). +COPLANAR_NORMAL_EPS = 1e-4 +COPLANAR_PLANE_EPS = 1e-4 +COPLANAR_CENTRE_MAX = 0.05 +ZFIGHT_PAIRS_MAX = 0 +# Named supports: a two-wheel cart's AABB zmin is grounded by whichever +# tyre happens to be lowest. Each tyre carries its own floor contact. +SUPPORT_ZMIN_EPS = 1e-4 +# Mirrored members: the two wheels are the same part reflected in Y. +WHEEL_MIRROR_EPS = 5e-5 +# Falsifier magnitudes, each sized to trip its own budget and nothing +# earlier: the wheel moves stay inside BBOX_TOL so the AABB gate cannot +# steal the failure. +SINK_TYRE_SEAT = 0.009 +FLOAT_WHEEL_Z = 0.003 +SKEW_WHEEL_Y = 0.006 + WOOD_IDX = 0 METAL_IDX = 1 @@ -249,14 +284,19 @@ def pack_uvs(bm, margin=0.08): def add_wheel(bm, loc, wood, metal): wood.extend( add_ring( - bm, loc, RIM_MAJOR, RIM_RADIAL, RIM_W, 16, WOOD_IDX, + bm, loc, RIM_MAJOR, RIM_RADIAL, RIM_W, WHEEL_SEGMENTS, WOOD_IDX, euler=(math.pi / 2.0, 0.0, 0.0), ) ) + # Hooped, not pasted on: inner radius is RIM_OUTER - TYRE_SEAT so the + # band bites into the felloe, and the tread still lands at + # RIM_OUTER + TYRE_T so the tyre stays the ground contact. + tyre_in = RIM_OUTER - TYRE_SEAT + tyre_out = RIM_OUTER + TYRE_T metal.extend( add_ring( - bm, loc, RIM_MAJOR + RIM_RADIAL + TYRE_T / 2.0, TYRE_T / 2.0, - TYRE_W, 16, METAL_IDX, + bm, loc, 0.5 * (tyre_in + tyre_out), 0.5 * (tyre_out - tyre_in), + TYRE_W, WHEEL_SEGMENTS, METAL_IDX, euler=(math.pi / 2.0, 0.0, 0.0), ) ) @@ -597,6 +637,179 @@ def hygiene_audit(me): } +def shell_groups(me): + """Vertex-index shells by edge connectivity (union-find), biggest first.""" + parent = list(range(len(me.vertices))) + + def find(a): + while parent[a] != a: + parent[a] = parent[parent[a]] + a = parent[a] + return a + + for e in me.edges: + ra, rb = find(int(e.vertices[0])), find(int(e.vertices[1])) + if ra != rb: + parent[rb] = ra + groups = {} + for i in range(len(me.vertices)): + groups.setdefault(find(i), []).append(i) + return sorted(groups.values(), key=lambda g: -len(g)) + + +def shell_box(me, idxs): + co = [me.vertices[i].co for i in idxs] + return { + "xmin": min(c.x for c in co), "xmax": max(c.x for c in co), + "ymin": min(c.y for c in co), "ymax": max(c.y for c in co), + "zmin": min(c.z for c in co), "zmax": max(c.z for c in co), + } + + +def coplanar_zfight_pairs(me, groups): + """Coplanar face pairs from *different shells* — the z-fighting budget. + + Cross-shell, not merely share-no-vertex: two quads two steps apart on + one flat cap share no vertex and are coplanar by construction, and + counting those makes the budget unsatisfiable rather than meaningful. + Z-fighting is two separate bodies landing on one plane, which is + exactly a cross-shell pair. Combinatorics and constants copied from + showcase/hay-bale (do not import across pieces). + """ + owner = {} + for si, comp in enumerate(groups): + for vi in comp: + owner[vi] = si + faces = [(p.normal.copy(), p.center.copy(), owner.get(p.vertices[0], -1)) + for p in me.polygons] + hits = 0 + for i in range(len(faces)): + ni, ci, si = faces[i] + for j in range(i + 1, len(faces)): + nj, cj, sj = faces[j] + if si == sj: + continue + if (ci - cj).length > COPLANAR_CENTRE_MAX: + continue + if abs(abs(ni.dot(nj)) - 1.0) > COPLANAR_NORMAL_EPS: + continue + if abs(ni.dot(cj - ci)) > COPLANAR_PLANE_EPS: + continue + hits += 1 + return hits + + +def wheel_shells(me, groups): + """(rim, tyre) shell index pairs for each wheel, keyed by track side. + + Identified from the generated mesh by geometry — a shell centred on the + axle line whose YZ extent is a full disc — never from a construction + constant. The tyre is the metal-indexed member of the pair. + """ + metal = set() + for p in me.polygons: + if p.material_index == METAL_IDX: + metal.add(int(p.vertices[0])) + found = {} + for si, g in enumerate(groups): + b = shell_box(me, g) + dx = b["xmax"] - b["xmin"] + dz = b["zmax"] - b["zmin"] + if dx < 0.4 or abs(dx - dz) > 0.02: + continue + side = 1 if 0.5 * (b["ymin"] + b["ymax"]) > 0 else -1 + kind = "tyre" if any(i in metal for i in g) else "rim" + found.setdefault(side, {})[kind] = si + return found + + +def tyre_seat_depths(me, groups, rim_si, tyre_si, segments): + """Per-station interference between the tyre's inner ring and the felloe. + + Banded and per angular station, not one global figure: a single number + passes while one arc of the hoop visibly gaps. Both radii are recomputed + from the generated vertices, so nothing here restates a constant. + """ + axis_y = 0.5 * (shell_box(me, groups[tyre_si])["ymin"] + + shell_box(me, groups[tyre_si])["ymax"]) + rb = shell_box(me, groups[rim_si]) + cx = 0.5 * (rb["xmin"] + rb["xmax"]) + cz = 0.5 * (rb["zmin"] + rb["zmax"]) + + def bins(idxs): + out = {} + for i in idxs: + co = me.vertices[i].co + ang = math.atan2(co.z - cz, co.x - cx) + k = int(round(ang / (2.0 * math.pi / segments))) % segments + out.setdefault(k, []).append( + math.hypot(co.x - cx, co.z - cz)) + return out + + rim_bins = bins(groups[rim_si]) + tyre_bins = bins(groups[tyre_si]) + depths = [] + for k in sorted(set(rim_bins) & set(tyre_bins)): + rim_out = max(rim_bins[k]) + tyre_in = min(tyre_bins[k]) + depths.append(rim_out - tyre_in) + return depths, axis_y + + +def _wheel_centre(me, groups, rim_si): + b = shell_box(me, groups[rim_si]) + return 0.5 * (b["xmin"] + b["xmax"]), 0.5 * (b["zmin"] + b["zmax"]) + + +def break_tyre_seat(me, seat): + """Falsifier surgery: re-radius each tyre's inner ring to `seat` deep. + + seat=0.0 puts the tyre's inner cylinder on the felloe's tread plane, + which is the construction bug this piece was rebuilt to remove. + """ + groups = shell_groups(me) + for parts in wheel_shells(me, groups).values(): + cx, cz = _wheel_centre(me, groups, parts["rim"]) + idxs = groups[parts["tyre"]] + radii = [math.hypot(me.vertices[i].co.x - cx, + me.vertices[i].co.z - cz) for i in idxs] + split = 0.5 * (min(radii) + max(radii)) + for i, r in zip(idxs, radii): + if r >= split: + continue + co = me.vertices[i].co + scale = (RIM_OUTER - seat) / r + co.x = cx + (co.x - cx) * scale + co.z = cz + (co.z - cz) * scale + me.update() + + +def move_one_wheel(me, delta): + """Falsifier surgery: translate the +Y wheel only (rim, tyre, spokes). + + Everything whose vertices lie on the +Y side of the axle centreline and + within the wheel's radius, so the assembly moves as one body and the + opposite wheel still grounds the AABB. + """ + groups = shell_groups(me) + parts = wheel_shells(me, groups).get(1) + if parts is None: + return + b = shell_box(me, groups[parts["tyre"]]) + cx, cz = _wheel_centre(me, groups, parts["rim"]) + y_lo, y_hi = b["ymin"] - 0.02, b["ymax"] + 0.02 + r_max = 0.5 * (b["xmax"] - b["xmin"]) + 1e-4 + for v in me.vertices: + if not (y_lo <= v.co.y <= y_hi): + continue + if math.hypot(v.co.x - cx, v.co.z - cz) > r_max: + continue + v.co.x += delta[0] + v.co.y += delta[1] + v.co.z += delta[2] + me.update() + + def min_mat_distance(me, ia, ib): """Closest surface distance between two material islands via BVH. @@ -718,7 +931,8 @@ def export_unity(path, objects): ) -def check(skip_decimate, lift_z=False): +def check(skip_decimate, lift_z=False, flush_tyre=False, sink_tyre=False, + float_wheel=False, skew_wheel=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) @@ -726,9 +940,14 @@ def check(skip_decimate, lift_z=False): "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), ) + # Wrought iron, not black plastic. At (0.13, 0.32) the tyre rendered as + # a flat black band with no sheen — darker and glossier than every + # sibling piece's iron (hitching-post 0.16/0.48, wooden-ladder + # 0.18/0.48). Raised to sit inside that calibration range, and the wear + # colour lifted off near-black so the noise actually varies the surface. 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), + "CartIron", (0.20, 0.195, 0.185, 1.0), 1.0, 0.50, + noise_scale=5.0, wear=(0.11, 0.105, 0.10, 1.0), ) assign_slots(low, wood, metal) assign_slots(high, wood, metal) @@ -736,6 +955,14 @@ def check(skip_decimate, lift_z=False): for v in low.data.vertices: v.co.z += LIFT_Z low.data.update() + if flush_tyre: + break_tyre_seat(low.data, 0.0) + if sink_tyre: + break_tyre_seat(low.data, SINK_TYRE_SEAT) + if float_wheel: + move_one_wheel(low.data, (0.0, 0.0, FLOAT_WHEEL_Z)) + if skew_wheel: + move_one_wheel(low.data, (0.0, SKEW_WHEEL_Y, 0.0)) if low.data is None or len(low.data.polygons) < 6: return fail("cart mesh did not build", 3), None, None, None, None, None @@ -780,6 +1007,15 @@ def check(skip_decimate, lift_z=False): os.remove(export_path) export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + # Blender sets TMPDIR from its own preference, which resolves to the + # working directory on a stock portable build — so gettempdir() is the + # repo root under CI and every run left a .glb behind. The budget only + # needs the byte count, so drop the file once it is measured. + if os.path.isfile(export_path): + try: + os.remove(export_path) + except OSError: + pass print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( @@ -807,6 +1043,53 @@ def check(skip_decimate, lift_z=False): ) print(f"measured gap_metal_wood={gap_mw:.5f}") + groups = shell_groups(low.data) + zfight = coplanar_zfight_pairs(low.data, groups) + wheels = wheel_shells(low.data, groups) + support_zmins = {} + seats = [] + for side, parts in sorted(wheels.items()): + tb = shell_box(low.data, groups[parts["tyre"]]) + support_zmins["+Y" if side > 0 else "-Y"] = round(tb["zmin"], 6) + depths, _ = tyre_seat_depths( + low.data, groups, parts["rim"], parts["tyre"], WHEEL_SEGMENTS + ) + seats.extend(depths) + support_worst = max((abs(z) for z in support_zmins.values()), default=1e9) + seat_min = min(seats) if seats else -1.0 + seat_max = max(seats) if seats else 1e9 + seat_n = len(seats) + # Mirror: the two wheels are one part reflected in Y, so their rim + # centres must match in X and Z and be opposite in Y. + wheel_mirror = 1e9 + if len(wheels) == 2: + boxes = {} + for side, parts in wheels.items(): + b = shell_box(low.data, groups[parts["rim"]]) + boxes[side] = ( + 0.5 * (b["xmin"] + b["xmax"]), + 0.5 * (b["ymin"] + b["ymax"]), + 0.5 * (b["zmin"] + b["zmax"]), + b["xmax"] - b["xmin"], + b["zmax"] - b["zmin"], + ) + a, b2 = boxes[1], boxes[-1] + wheel_mirror = max( + abs(a[0] - b2[0]), abs(a[1] + b2[1]), abs(a[2] - b2[2]), + abs(a[3] - b2[3]), abs(a[4] - b2[4]), + ) + print( + f"measured zfight_pairs={zfight} shells={len(groups)} " + f"support_zmin={support_zmins} " + f"tyre_seat=[{seat_min:.5f},{seat_max:.5f}] over {seat_n} stations " + f"wheel_mirror={wheel_mirror*1000:.5f}mm" + ) + + if len(wheels) != 2 or any(len(p) != 2 for p in wheels.values()): + return fail( + f"expected 2 wheels of (rim, tyre) shells, found {wheels}", + 3, + ), None, None, None, None, None if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", @@ -884,18 +1167,49 @@ def check(skip_decimate, lift_z=False): f"doubles={hyg['doubles']} ngons={hyg['ngons']}", 15, ), None, None, None, None, None + if zfight > ZFIGHT_PAIRS_MAX: + return fail( + f"coplanar cross-shell face pairs {zfight} > {ZFIGHT_PAIRS_MAX} " + "(--flush-tyre is the designed fail: a tyre whose inner radius " + "equals the felloe's outer radius puts both on one plane)", + 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 support_worst > SUPPORT_ZMIN_EPS: + return fail( + f"named support zmin {support_worst:.6f} > {SUPPORT_ZMIN_EPS} " + f"per-tyre zmin={support_zmins} " + "(--float-wheel is the designed fail: one tyre off the floor " + "while the other still grounds the AABB)", + 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 + if seat_min < TYRE_SEAT_MIN or seat_max > TYRE_SEAT_MAX: + return fail( + f"tyre seat depth band [{seat_min:.5f}, {seat_max:.5f}] outside " + f"[{TYRE_SEAT_MIN}, {TYRE_SEAT_MAX}] over {seat_n} stations " + "(--sink-tyre is the designed fail: the hoop swallowed by the " + "felloe reads as one body, not a tyre)", + 18, + ), None, None, None, None, None + if wheel_mirror > WHEEL_MIRROR_EPS: + return fail( + f"wheel mirror deviation {wheel_mirror*1000:.4f} mm > " + f"{WHEEL_MIRROR_EPS*1000:.4f} mm " + "(--skew-wheel is the designed fail: the two wheels are one part " + "reflected in Y and must sit at matched X and Z)", + 19, + ), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -1019,10 +1333,38 @@ def main(): action="store_true", help="falsification: lift the mesh so zmin fails the grounded budget", ) + p.add_argument( + "--flush-tyre", + action="store_true", + help="falsification: tyre inner radius == felloe outer radius, so " + "both land on one plane and the z-fight budget fails", + ) + p.add_argument( + "--sink-tyre", + action="store_true", + help="falsification: bury the tyre in the felloe so the seat band fails", + ) + p.add_argument( + "--float-wheel", + action="store_true", + help="falsification: lift one wheel off the floor while the other " + "still grounds the AABB, so the named-support budget fails", + ) + p.add_argument( + "--skew-wheel", + action="store_true", + help="falsification: push one wheel out along the track so the " + "wheel-mirror budget fails", + ) args = p.parse_args(argv) code, low, _high, wood, tex, _col = check( - args.skip_decimate, lift_z=args.lift_z + args.skip_decimate, + lift_z=args.lift_z, + flush_tyre=args.flush_tyre, + sink_tyre=args.sink_tyre, + float_wheel=args.float_wheel, + skew_wheel=args.skew_wheel, ) if code: return code diff --git a/showcase/cart/preview.webp b/showcase/cart/preview.webp index 33e7fcfe..28b5a011 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 3914d102..38e1d7f1 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: 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.", + "witnessesFix": "Recomputed: 9380 tris, three materials with face floors, UVs in 0..1 with zero AABB overlap, outer AABB 1.312×1.312×1.761 m, LOD ratios in band, convex collider 346 tris, hygiene zero (loose, non-manifold, doubles, n-gons), zero coplanar cross-shell face pairs, all 12 bottom-course stones on the floor, each post tenoned 18 mm into the measured coping and standing under a roof hip corner, the bucket hanging 0.10 m clear of the coping, grounded zmin, non-empty glTF. --stand-posts exits 15 on the z-fight budget; --float-stone exits 16 on the named-support budget; --shallow-tenon and --drop-bucket exit 18; --turn-posts exits 19 on post placement; --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: 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.", + "witnessesFix": "Recomputed: 2856 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 154 tris, hygiene zero (loose, non-manifold, doubles, n-gons), zero coplanar cross-shell face pairs, both tyres grounded, tyre seat depth in band over 48 angular stations, the two wheels mirrored within 5e-5 m, grounded zmin, non-empty glTF. --flush-tyre exits 15 on the z-fight budget; --sink-tyre exits 18 on the tyre seat band; --float-wheel exits 16 on the named-support budget; --skew-wheel exits 19 on the wheel mirror; --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 c951bc2e..ddc65805 100644 --- a/showcase/stone-well/README.md +++ b/showcase/stone-well/README.md @@ -27,23 +27,54 @@ materials, UVs, evaluated LOD, collider, or export file. | 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) | +| Outer AABB | (1.312, 1.312, 1.761) m ± 0.01 | (1.3120, 1.3120, 1.7606) | | Grounded | bbox min Z within 1e-4 of 0 | 0.0000 / 0.0000 / 0.0000 | +| Named supports | all 12 bottom-course stones on the floor within 1e-4 | 12/12, worst 0.000000 | | Hygiene | loose V/E, non-manifold, zero-area, doubles @1e-5, n-gons: all 0 | 0 / 0 / 0 on every axis | +| Z-fighting | coplanar cross-shell face pairs = 0 | 0 / 0 / 0 | +| Post tenon | each post seats 0.015–0.022 m into the measured coping top | 0.01800 / 0.01800 / 0.01800 | +| Post placement | each post within 0.02 rad, in plan, of a roof hip corner recomputed from the mesh | 0.00000 | +| Bucket clearance | hangs 0.085–0.115 m clear of the measured coping top | 0.10000 | | 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 | +| Collider tris | ≤ 380 | 346 | +| Export | written, size > 0, removed after measuring | 690092 bytes on 5.2.1 | 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. +uses no RNG. glTF byte size differs by a few hundred bytes across +series; the gate is "written and non-empty", not a byte count, and the +file is removed once measured. -`--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. +## Falsifiers + +Each flag breaks one stage so a **named** budget fails and the piece +exits *that* code. + +| Flag | Target budget | Exit | +| --- | --- | --- | +| `--skip-decimate` | LOD1 ratio band (ratio becomes 1.0) | 9 | +| `--lift-z` | AABB grounded zmin (whole mesh up 0.05 m) | 16 | +| `--stand-posts` | Coplanar cross-shell pairs — puts the posts back on the axis *and* stands them on the coping, which is the pair of choices the shipped piece made (0 → 4 pairs) | 15 | +| `--float-stone` | Named supports — lifts one bottom-course stone 4 mm while the other eleven still ground the AABB | 16 | +| `--shallow-tenon` | Post tenon band — seats the posts only 4 mm, too shallow to be a joint but not flush, so no faces share a plane | 18 | +| `--drop-bucket` | Bucket clearance — lowers the bucket 0.26 m back down the shaft | 18 | +| `--turn-posts` | Post placement — rotates the post ring one curb facet off the roof's hip corners | 19 | + +Two of these had to be re-aimed, which is the point of declaring the +target: + +- `--stand-posts` seats the posts flush **and** returns them to the + axis. Flush alone, at the corrected 45°, leaves no coping top face + within `COPLANAR_CENTRE_MAX` of a foot, so it fell through to the + tenon band (18) and witnessed nothing about z-fighting. +- `--turn-posts` rotates by exactly one curb facet (30°). Any other + angle sets the feet down on a different part of the 12-gon coping and + the stone-wood gap gate (17) steals the failure; a full 45° also + swings the crank grip past the eave and fails the AABB gate (8) — + the same trap `stone-archway`'s `--flat-arch` fell into. One facet is + the only rotation whose local seat geometry is identical by symmetry. ## Run @@ -51,10 +82,15 @@ falsifiers violate. 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 -- --stand-posts +blender --background --python stone_well.py -- --shallow-tenon +blender --background --python stone_well.py -- --turn-posts +blender --background --python stone_well.py -- --float-stone +blender --background --python stone_well.py -- --drop-bucket blender --background --python stone_well.py -- --output well.png ``` -Smoke does not pass `--output`, `--skip-decimate`, or `--lift-z`. +Smoke passes none of the falsifier flags and no `--output`. ## Exit codes @@ -78,6 +114,8 @@ 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) | +| 15 | Hygiene: loose geometry, non-manifold, zero-area, doubles, n-gons, or coplanar cross-shell face pairs (`--stand-posts` lands here) | +| 16 | Bbox min Z not grounded (`--lift-z`), or a named support off the floor (`--float-stone`) | | 17 | Material-island gap above tolerance (parts meant to touch) | +| 18 | Post tenon depth outside its band (`--shallow-tenon`), or bucket clearance outside its band (`--drop-bucket`) | +| 19 | A post not under a roof hip corner in plan (`--turn-posts` lands here) | diff --git a/showcase/stone-well/preview.webp b/showcase/stone-well/preview.webp index da501894..c4f7637d 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 cf284d9f..91cff387 100644 --- a/showcase/stone-well/stone_well.py +++ b/showcase/stone-well/stone_well.py @@ -5,9 +5,14 @@ normal bake, LOD chain, convex collider, Unity glTF export. 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. ``--lift-z`` raises the -mesh so the grounded-zmin hygiene budget fails. +They are not API-contract witnesses. Each falsifier flag breaks one +stage so a named budget fails and the piece exits that budget's code: +``--skip-decimate`` (LOD ratio, 9), ``--lift-z`` (AABB grounded, 16), +``--float-stone`` (named supports, 16), ``--stand-posts`` (coplanar +cross-shell pairs, 15), ``--shallow-tenon`` (post tenon band, 18), +``--drop-bucket`` (bucket clearance, 18), ``--turn-posts`` (post under +hip corner, 19). See README.md for why two of them are aimed the way +they are. No RNG. Construction is closed-form (per-stone jitter is a deterministic hash). DECIMATE COLLAPSE triangle counts are not byte-identical across @@ -15,7 +20,7 @@ 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 -- --stand-posts blender --background --python stone_well.py -- --output well.png """ import argparse @@ -59,11 +64,29 @@ CURB_Z = MASONRY_TOP + CURB_H / 2.0 POST_S = 0.068 POST_R = 0.55 -POST_H = 0.58 -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 +# Posts land under the roof's four hip corners, not at the midpoint of each +# eave. A cone with 4 base verts rotated by pi/4 puts its corners on the +# diagonals, so the posts go on the diagonals too. At the axis-aligned +# angles the roof corners cantilevered 0.80 m unsupported and one post +# stood dead centre in the well mouth from every orthogonal view. +POST_ANGLES = tuple(math.pi / 4.0 + i * math.pi / 2.0 for i in range(4)) +# Tenoned into the curb, not stood on it: a foot whose bottom face lands +# exactly on the curb's top face puts both on one plane (it measured 4 +# coplanar cross-shell pairs, one per post). POST_TOP is held fixed so +# nothing above the posts moves. +POST_SEAT = 0.018 +POST_SEAT_MIN = 0.015 +POST_SEAT_MAX = 0.022 +POST_CLEAR = 0.58 +POST_BOTTOM = MASONRY_TOP + CURB_H - POST_SEAT +POST_TOP = MASONRY_TOP + CURB_H + POST_CLEAR +POST_H = POST_TOP - POST_BOTTOM +# The roof is sized to oversail the curb by a named clearance, measured on +# the flat of the eave. Deriving it from the post ring instead made the +# roof 1.61 m across a 1.08 m drum — an umbrella, not a well house. +EAVE_CLEAR = 0.055 +EAVE_HALF = R_OUTER + CURB_OUT + EAVE_CLEAR +EAVE_OVERHANG = EAVE_HALF - (POST_R + POST_S / 2.0) EAVE_Z = POST_TOP - 0.02 ROOF_RISE = 0.34 PEAK_Z = EAVE_Z + ROOF_RISE @@ -73,17 +96,26 @@ # 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 +# The drum spans the first opposed pair of posts, so it shares their angle. +WINDLASS_AXIS = POST_ANGLES[0] BUCKET_R_TOP = 0.105 BUCKET_R_BOT = 0.088 BUCKET_WALL_T = 0.008 BUCKET_H = 0.14 -BUCKET_Z = 0.68 +# The bucket is the piece's whole story, so it hangs in the open above the +# curb. At 0.68 it sat down the shaft with only its rim level with the +# coping: invisible in the hero and in every orthographic view. +CURB_TOP = MASONRY_TOP + CURB_H +BUCKET_CLEAR = 0.10 +BUCKET_CLEAR_MIN = 0.085 +BUCKET_CLEAR_MAX = 0.115 +BUCKET_Z = CURB_TOP + BUCKET_CLEAR + BUCKET_H / 2.0 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.761) +OUTER_SIZE = (1.312, 1.312, 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. @@ -106,7 +138,48 @@ METAL_FACES_MIN = 100 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 320 +# Grew with the roof resize: the convex hull of a 1.31 m roof over the +# drum measures 346 where the 1.64 m roof measured 318. Budget raised to +# match a deliberate geometry change, with headroom for the falsifiers. +COLLIDER_TRIS_MAX = 380 +# Z-fighting: two separate bodies landing on one plane. Cross-shell, with +# hay-bale's constants (copied, not imported). +COPLANAR_NORMAL_EPS = 1e-4 +COPLANAR_PLANE_EPS = 1e-4 +COPLANAR_CENTRE_MAX = 0.05 +ZFIGHT_PAIRS_MAX = 0 +# Named supports: the AABB zmin is grounded by whichever bottom-course +# stone happens to be lowest, so every bottom stone carries its own floor +# contact. N_AROUND of them, one per bay. +SUPPORT_ZMIN_EPS = 1e-4 +SUPPORT_COUNT = N_AROUND +# Posts stand under the roof's hip corners, measured in plan against the +# corners recomputed from the generated roof. +# Angular, in radians, wrapped into [-pi, pi]. 0.02 rad is 1.15 degrees. +POST_CORNER_EPS = 0.02 +POST_COUNT = 4 +# Falsifier magnitudes, each sized to trip its own budget and nothing +# earlier: the moves stay inside BBOX_TOL so the AABB gate cannot steal +# the failure. +STAND_POST_SEAT = 0.0 +# --stand-posts must put the posts back where the shipped bug had them: +# on the axis, where a curb top face sits within COPLANAR_CENTRE_MAX of +# each foot. One facet over (15 degrees) is flush but too far from any +# coping top face to register, and falls through to the seat band. +# The crank no longer follows the post ring, so this rotation cannot +# swing it past the eave. +STAND_POST_TURN = math.pi / 4.0 +SHALLOW_TENON_SEAT = 0.004 +FLOAT_STONE_Z = 0.004 +# Exactly one curb facet. Any other angle sets the post feet down on a +# different part of the 12-gon coping and the stone-wood gap gate (17) +# steals the failure; a full 45 degrees also swings the crank grip past +# the eave and fails the AABB gate (8). One facet is the only rotation +# whose local seat geometry is identical by symmetry, so nothing but the +# hip-alignment budget can see it. Same mis-aimed-falsifier trap as +# stone-archway's --flat-arch. +TURN_POSTS_ANGLE = 2.0 * math.pi / N_AROUND +DROP_BUCKET_Z = 0.26 BAKE_RES = 256 CAGE_EXTRUSION = 0.06 @@ -276,7 +349,8 @@ def add_open_bucket(bm, loc, r_bot, r_top, wall_t, depth, segments, mat_idx): f.material_index = mat_idx -def build_well_mesh(name, bevel_offset, bevel_segments): +def build_well_mesh(name, bevel_offset, bevel_segments, + post_seat=POST_SEAT, turn_posts=0.0): bm = bmesh.new() stone_verts = [] wood_bevel_verts = [] @@ -333,24 +407,33 @@ def build_well_mesh(name, bevel_offset, bevel_segments): clamp_overlap=True, ) - post_angles = (0.0, math.pi / 2.0, math.pi, 3.0 * math.pi / 2.0) - for ang in post_angles: + angles = tuple(a - turn_posts for a in POST_ANGLES) + post_bottom = CURB_TOP - post_seat + post_h = POST_TOP - post_bottom + for ang in angles: loc = ( POST_R * math.cos(ang), POST_R * math.sin(ang), - POST_BOTTOM + POST_H / 2.0, + post_bottom + post_h / 2.0, ) wood_bevel_verts.extend( - add_box(bm, loc, (POST_S, POST_S, POST_H), WOOD_IDX) + add_box(bm, loc, (POST_S, POST_S, post_h), WOOD_IDX, + euler=(0.0, 0.0, ang)) ) + # The windlass is borne by two opposite posts, so it runs along the + # post diagonal, not along X. Everything hung on it — beam, drum, + # crank arm, grip — turns with it as one assembly. beam_z = POST_TOP - 0.05 + wax = WINDLASS_AXIS + ca, sa = math.cos(wax), math.sin(wax) 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, + euler=(0.0, 0.0, wax), ) ) add_cylinder( @@ -360,24 +443,27 @@ def build_well_mesh(name, bevel_offset, bevel_segments): WINDLASS_LEN, 12, WOOD_IDX, - euler=(0.0, math.pi / 2.0, 0.0), + euler=(0.0, math.pi / 2.0, wax), ) # Crank: arm pinned to the protruding windlass end, grip parallel to # the windlass axis at the arm's lower end. + arm_r = WINDLASS_END + 0.004 add_box( bm, - (WINDLASS_END + 0.004, 0.0, beam_z - 0.055), + (arm_r * ca, arm_r * sa, beam_z - 0.055), (0.020, 0.022, 0.13), METAL_IDX, + euler=(0.0, 0.0, wax), ) + grip_r = WINDLASS_END + 0.055 add_cylinder( bm, - (WINDLASS_END + 0.055, 0.0, beam_z - 0.12), + (grip_r * ca, grip_r * sa, beam_z - 0.12), 0.014, 0.10, 10, METAL_IDX, - euler=(0.0, math.pi / 2.0, 0.0), + euler=(0.0, math.pi / 2.0, wax), ) pitch = math.atan(ROOF_RISE / EAVE_HALF) @@ -678,6 +764,162 @@ def hygiene_audit(me): } +def shell_groups(me): + """Vertex-index shells by edge connectivity (union-find), biggest first.""" + parent = list(range(len(me.vertices))) + + def find(a): + while parent[a] != a: + parent[a] = parent[parent[a]] + a = parent[a] + return a + + for e in me.edges: + ra, rb = find(int(e.vertices[0])), find(int(e.vertices[1])) + if ra != rb: + parent[rb] = ra + groups = {} + for i in range(len(me.vertices)): + groups.setdefault(find(i), []).append(i) + return sorted(groups.values(), key=lambda g: -len(g)) + + +def shell_box(me, idxs): + co = [me.vertices[i].co for i in idxs] + return { + "xmin": min(c.x for c in co), "xmax": max(c.x for c in co), + "ymin": min(c.y for c in co), "ymax": max(c.y for c in co), + "zmin": min(c.z for c in co), "zmax": max(c.z for c in co), + } + + +def coplanar_zfight_pairs(me, groups): + """Coplanar face pairs from *different shells* — the z-fighting budget. + + Cross-shell, not merely share-no-vertex: two quads two steps apart on + one flat cap share no vertex and are coplanar by construction, and + counting those makes the budget unsatisfiable rather than meaningful. + Z-fighting is two separate bodies landing on one plane, which is + exactly a cross-shell pair. Combinatorics and constants copied from + showcase/hay-bale (do not import across pieces). + """ + owner = {} + for si, comp in enumerate(groups): + for vi in comp: + owner[vi] = si + faces = [(p.normal.copy(), p.center.copy(), owner.get(p.vertices[0], -1)) + for p in me.polygons] + hits = 0 + for i in range(len(faces)): + ni, ci, si = faces[i] + for j in range(i + 1, len(faces)): + nj, cj, sj = faces[j] + if si == sj: + continue + if (ci - cj).length > COPLANAR_CENTRE_MAX: + continue + if abs(abs(ni.dot(nj)) - 1.0) > COPLANAR_NORMAL_EPS: + continue + if abs(ni.dot(cj - ci)) > COPLANAR_PLANE_EPS: + continue + hits += 1 + return hits + + +def post_shells(me, groups): + """The four roof posts, found in the generated mesh by their geometry. + + A post is a wood shell that spans the curb line vertically and is + square and slender in plan — never looked up by a construction index. + """ + wood = set() + for p in me.polygons: + if p.material_index == WOOD_IDX: + wood.add(int(p.vertices[0])) + out = [] + for si, g in enumerate(groups): + if not any(i in wood for i in g): + continue + b = shell_box(me, g) + dz = b["zmax"] - b["zmin"] + dx = b["xmax"] - b["xmin"] + dy = b["ymax"] - b["ymin"] + if not (0.4 <= dz <= 0.8) or max(dx, dy) > 0.25: + continue + out.append((si, b)) + return out + + +def measured_curb_top(me): + """Top of the masonry, read off the generated mesh, not from CURB_TOP. + + An assertion that restates the constant the builder used witnesses + nothing, so the post tenon is measured against the stone the post is + actually tenoned into. + """ + stone = {int(p.vertices[0]) for p in me.polygons + if p.material_index == STONE_IDX} + return max(me.vertices[i].co.z for i in stone) + + +def roof_corners(me, groups): + """The roof's four hip corners, recomputed from the generated mesh. + + The corner verts of the widest wood shell above the eave: its four + extreme XY points. Nothing here restates EAVE_HALF. + """ + pts = [v.co for v in me.vertices if v.co.z >= EAVE_Z - 0.12] + if not pts: + return [] + zlo = min(p.z for p in pts) + eave = [p for p in pts if p.z <= zlo + 0.05] + if not eave: + return [] + # The four extreme points of the pooled eave ring are the hip corners. + # Taking them from a single shell picks one fascia board instead, whose + # own extremes sit 90 degrees off the corners they are nailed to. + corners = [] + for qx, qy in ((1, 1), (-1, 1), (-1, -1), (1, -1)): + corners.append(max(eave, key=lambda p: qx * p.x + qy * p.y)) + return corners + + +def drop_bucket(me, dz): + """Falsifier surgery: lower the hung bucket assembly back down the shaft. + + Everything inside the mouth above the curb and under the windlass — + which is the bucket, its hoops and its bail — moves as one body. + """ + groups = shell_groups(me) + for g in groups: + b = shell_box(me, g) + if b["zmin"] < CURB_TOP or b["zmax"] > POST_TOP - 0.10: + continue + if max(b["xmax"] - b["xmin"], b["ymax"] - b["ymin"]) > 2.5 * BUCKET_R_TOP: + continue + for i in g: + me.vertices[i].co.z -= dz + me.update() + + +def float_one_stone(me, dz): + """Falsifier surgery: lift one bottom-course stone off the floor. + + The other bays stay down, so the AABB grounded gate still passes and + only the named-support budget can catch it. + """ + groups = shell_groups(me) + floor_z = min(v.co.z for v in me.vertices) + for g in groups: + b = shell_box(me, g) + if b["zmin"] > floor_z + 1e-5 or b["zmax"] > floor_z + STONE_H * 1.6: + continue + for i in g: + me.vertices[i].co.z += dz + break + me.update() + + def min_mat_distance(me, ia, ib): """Closest surface distance between two material islands via BVH. @@ -805,10 +1047,28 @@ def export_unity(path, objects): ) -def check(skip_decimate, lift_z=False): +def check(skip_decimate, lift_z=False, stand_posts=False, turn_posts=False, + float_stone=False, drop_bucket_flag=False, shallow_tenon=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) + # --stand-posts reproduces the shipped bug exactly: posts on the axis + # AND standing on the coping, which is the pair of choices that put a + # foot and a curb top on one plane. Seating alone at 45 degrees leaves + # no curb top face within COPLANAR_CENTRE_MAX of a foot, so it would + # fall through to the seat band and witness the wrong budget. + seat = POST_SEAT + if stand_posts: + seat = STAND_POST_SEAT + elif shallow_tenon: + seat = SHALLOW_TENON_SEAT + turn = 0.0 + if stand_posts: + turn = STAND_POST_TURN + elif turn_posts: + turn = TURN_POSTS_ANGLE + low = build_well_mesh("WellLow", bevel_offset=0.010, bevel_segments=2, + post_seat=seat, turn_posts=turn) + high = build_well_mesh("WellHigh", bevel_offset=0.010, bevel_segments=4, + post_seat=seat, turn_posts=turn) 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), @@ -827,6 +1087,10 @@ def check(skip_decimate, lift_z=False): for v in low.data.vertices: v.co.z += LIFT_Z low.data.update() + if float_stone: + float_one_stone(low.data, FLOAT_STONE_Z) + if drop_bucket_flag: + drop_bucket(low.data, DROP_BUCKET_Z) if low.data is None or len(low.data.polygons) < 6: return fail("well mesh did not build", 3), None, None, None, None, None @@ -869,6 +1133,15 @@ def check(skip_decimate, lift_z=False): os.remove(export_path) export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + # Blender sets TMPDIR from its own preference, which resolves to the + # working directory on a stock portable build — so gettempdir() is the + # repo root under CI and every run left a .glb behind. The budget only + # needs the byte count, so drop the file once it is measured. + if os.path.isfile(export_path): + try: + os.remove(export_path) + except OSError: + pass print( f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}" @@ -899,6 +1172,62 @@ def check(skip_decimate, lift_z=False): ) print(f"measured gap_stone_wood={gap_sw:.5f} gap_metal_wood={gap_mw:.5f}") + groups = shell_groups(low.data) + zfight = coplanar_zfight_pairs(low.data, groups) + # Named supports: every bottom-course stone, not just the lowest one. + floor_z = bb[2] + supports = [] + for g in groups: + b = shell_box(low.data, g) + if b["zmin"] < floor_z + 0.02 and b["zmax"] < floor_z + STONE_H * 1.6: + supports.append(b["zmin"] - floor_z) + n_support = len(supports) + support_worst = max((abs(z) for z in supports), default=1e9) + # Post tenon depth, recomputed per post from the generated curb top. + posts = post_shells(low.data, groups) + n_posts = len(posts) + curb_top = measured_curb_top(low.data) + seats = [curb_top - b["zmin"] for _si, b in posts] + seat_min = min(seats) if seats else -1.0 + seat_max = max(seats) if seats else 1e9 + # Each post stands under a hip corner of the roof, in plan. + corners = roof_corners(low.data, groups) + post_corner = 1e9 + if posts and corners: + worst = 0.0 + for _si, b in posts: + px = 0.5 * (b["xmin"] + b["xmax"]) + py = 0.5 * (b["ymin"] + b["ymax"]) + pa = math.atan2(py, px) + best = min( + abs((math.atan2(c.y, c.x) - pa + math.pi) + % (2.0 * math.pi) - math.pi) + for c in corners + ) + worst = max(worst, best) + post_corner = worst + # Bucket clearance above the curb. + bucket_clear = -1.0 + metal_v = {int(p.vertices[0]) for p in low.data.polygons + if p.material_index == METAL_IDX} + for g in groups: + b = shell_box(low.data, g) + if b["zmin"] < curb_top or any(i in metal_v for i in g): + continue + if (b["xmax"] - b["xmin"]) > 2.5 * BUCKET_R_TOP: + continue + if b["zmax"] - b["zmin"] > BUCKET_H * 1.4: + continue + cand = b["zmin"] - curb_top + if bucket_clear < 0 or cand < bucket_clear: + bucket_clear = cand + print( + f"measured zfight_pairs={zfight} shells={len(groups)} " + f"grounded_stones={n_support} support_worst={support_worst:.6f} " + f"posts={n_posts} post_seat=[{seat_min:.5f},{seat_max:.5f}] " + f"post_hip_offset={post_corner:.5f} bucket_clear={bucket_clear:.5f}" + ) + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", @@ -981,12 +1310,27 @@ def check(skip_decimate, lift_z=False): f"doubles={hyg['doubles']} ngons={hyg['ngons']}", 15, ), None, None, None, None, None + if zfight > ZFIGHT_PAIRS_MAX: + return fail( + f"coplanar cross-shell face pairs {zfight} > {ZFIGHT_PAIRS_MAX} " + "(--stand-posts is the designed fail: a post foot standing on " + "the coping's top face puts both on one plane)", + 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 n_support != SUPPORT_COUNT or support_worst > SUPPORT_ZMIN_EPS: + return fail( + f"grounded bottom-course stones {n_support}/{SUPPORT_COUNT}, " + f"worst zmin {support_worst:.6f} > {SUPPORT_ZMIN_EPS} " + "(--float-stone is the designed fail: one bay lifted off the " + "floor while the rest still ground the AABB)", + 16, + ), None, None, None, None, None if gap_sw > GAP_MAX: return fail( f"stone-wood gap {gap_sw:.5f} > {GAP_MAX} " @@ -999,6 +1343,30 @@ def check(skip_decimate, lift_z=False): "(crank, hoops, and bail must touch the wood they mount to)", 17, ), None, None, None, None, None + if seat_min < POST_SEAT_MIN or seat_max > POST_SEAT_MAX: + return fail( + f"post tenon depth band [{seat_min:.5f}, {seat_max:.5f}] outside " + f"[{POST_SEAT_MIN}, {POST_SEAT_MAX}] over {n_posts} posts " + "(--shallow-tenon is the designed fail)", + 18, + ), None, None, None, None, None + if bucket_clear < BUCKET_CLEAR_MIN or bucket_clear > BUCKET_CLEAR_MAX: + return fail( + f"bucket clears the curb by {bucket_clear:.5f} outside " + f"[{BUCKET_CLEAR_MIN}, {BUCKET_CLEAR_MAX}] " + "(--drop-bucket is the designed fail: a bucket down the shaft " + "shows only its rim and the piece loses its subject)", + 18, + ), None, None, None, None, None + if n_posts != POST_COUNT or post_corner > POST_CORNER_EPS: + return fail( + f"posts {n_posts}/{POST_COUNT}, worst post-to-hip-corner plan " + f"offset {post_corner:.5f} > {POST_CORNER_EPS} " + "(--turn-posts is the designed fail: posts at the midpoint of " + "each eave leave the roof's corners cantilevered and stand one " + "post in the well mouth)", + 19, + ), None, None, None, None, None return 0, low, high, stone, tex, collider @@ -1122,10 +1490,45 @@ def main(): action="store_true", help="falsification: lift the mesh so zmin fails the grounded budget", ) + p.add_argument( + "--stand-posts", + action="store_true", + help="falsification: stand the posts on the curb instead of tenoning " + "them in, so foot and coping land on one plane", + ) + p.add_argument( + "--shallow-tenon", + action="store_true", + help="falsification: tenon the posts only part way into the curb so " + "the seat band fails without putting faces on one plane", + ) + p.add_argument( + "--turn-posts", + action="store_true", + help="falsification: rotate the post ring off the roof's hip corners " + "to the midpoint of each eave", + ) + p.add_argument( + "--float-stone", + action="store_true", + help="falsification: lift one bottom-course stone while the rest " + "still ground the AABB", + ) + p.add_argument( + "--drop-bucket", + action="store_true", + help="falsification: lower the bucket back down the shaft", + ) args = p.parse_args(argv) code, low, _high, stone, tex, _col = check( - args.skip_decimate, lift_z=args.lift_z + args.skip_decimate, + lift_z=args.lift_z, + stand_posts=args.stand_posts, + turn_posts=args.turn_posts, + float_stone=args.float_stone, + drop_bucket_flag=args.drop_bucket, + shallow_tenon=args.shallow_tenon, ) if code: return code