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