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
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.
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.
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.4or abs(dx - dz) > 0.02:
+ continue
+ side = 1if0.5 * (b["ymin"] + b["ymax"]) > 0else -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])
+ return0.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 isNone:
+ 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:
+ ifnot (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 @@
"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 isNoneor 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 > 0else"-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 else1e9
+ 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) != 2or any(len(p) != 2for p in wheels.values()):
+ return fail(
+ f"expected 2 wheels of (rim, tyre) shells, found {wheels}",
+ 3,
+ ), None, None, None, None, Noneifnot (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, Noneif 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, Noneif 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, Nonereturn0, 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 @@
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.
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.
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.
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.
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 @@
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.0for 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 @@
}
+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):
+ ifnot 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"]
+ ifnot (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]
+ ifnot pts:
+ return []
+ zlo = min(p.z for p in pts)
+ eave = [p for p in pts if p.z <= zlo + 0.05]
+ ifnot 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-5or 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 isNoneor 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.02and 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 else1e9
+ # 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 < 0or 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}"
+ )
+
ifnot (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, Noneif 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, Noneif 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, Nonereturn0, 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