From 9b392619b001f0813a6297ee2f2a4c45c0b38ec7 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:26:35 -0400 Subject: [PATCH 1/4] docs(showcase): require named-support zmin on multi-foot props AABB zmin cannot catch a planted wheel hiding floating shoes. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- showcase/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/showcase/README.md b/showcase/README.md index 96e5368..c98a84b 100644 --- a/showcase/README.md +++ b/showcase/README.md @@ -53,6 +53,13 @@ entry in `showcase/gallery.json`, and a rendered still. 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). +- **Named supports (exit 16).** AABB `zmin` is necessary but not + sufficient on a multi-support prop: a wheel, a sled floor, or one + planted foot can ground the box while the other feet float. Split the + mesh into shells and assert each named support (shoe, tyre, foot) has + its own `zmin` within epsilon of 0. `--short-legs` (or the piece's + equivalent) is the falsifier: float the named supports while leaving + something else on the ground so the AABB gate would still pass. - **Joint-fit budgets.** Parts that interpenetrate on purpose — a tenon in a mortise, a peg in a hub — assert how deep the overlap goes, not just that it exists. Split the mesh into shells by edge connectivity, From fbcaab287c9d90159bbac9d4da8a8104f726edb4 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:26:51 -0400 Subject: [PATCH 2/4] fix(showcase): rebuild the grindstone so the A-frame and trough actually join The trestle was two sticks with a hole under the bearing and a floating tub. King, nested sill, manifold basin, and shoe tenons close the joints, locked as hygiene and seat budgets. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- docs/gallery/assets/grindstone-hero.webp | Bin 15024 -> 27022 bytes docs/gallery/grindstone/index.html | 910 +++++++++++++++++++---- showcase/grindstone/README.md | 95 ++- showcase/grindstone/grindstone.py | 879 ++++++++++++++++++---- showcase/grindstone/preview.webp | Bin 12192 -> 18166 bytes 5 files changed, 1559 insertions(+), 325 deletions(-) diff --git a/docs/gallery/assets/grindstone-hero.webp b/docs/gallery/assets/grindstone-hero.webp index ed1ef06a21e56fa75692116c4a38ce4d0bbedd3b..5e8245df88bdce439d505df7c5da7c76487813a8 100644 GIT binary patch literal 27022 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 grindstone render

Rendered headless by the example itself — click to zoom.

-
witnesses Recomputed: 1604 tris, three materials with 80 metal and 26 stone faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.630×0.615×0.575 m, LOD ratios in band, convex collider 130 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
witnesses Recomputed: 928 tris, three materials with 216 wood / 160 stone / 180 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.710×0.419×0.610 m, zmin 0, hygiene 0, four shoes planted, trough seated in the sills, stone dip 26 mm, LOD ratios in band (5.2 COLLAPSE leaner on LOD2), convex collider 198 tris, non-empty glTF. --skip-decimate exits 9 on LOD1; --stray-vert exits 15 on hygiene; --lift-z / --short-legs exit 16 on grounded; --float-crank / --float-legs exit 17 on joint fit; --no-dip / --narrow-trough exit 18 on seat.
blender --background --python showcase/grindstone/grindstone.py --
-

A showcase piece, not an example. Procedural grindstone (stone wheel, timber A-frame trestle, iron axle, hubs, crank, and tray) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

A showcase piece, not an example. Procedural grindstone (sandstone wheel, timber A-frame trestle, iron shoes/axle/hubs/crank, open water trough) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

The A-frame is a king post with diagonals tenoning into a fatter sill, not two sticks meeting at a point. The trough is one manifold basin seated into those sills. Iron shoes bury a short wood tenon so the angled leg end-cap cannot pierce Z=0.

It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.

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 | 1480–1720 | 1604 / 1604 / 1604 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2195 / 0.2195 / 0.2195 | | Materials | exactly 3 distinct, ≥24 metal and stone faces | 3 slots, 80 metal, 26 stone | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.630, 0.615, 0.575) m ± 0.01 | (0.6300, 0.6150, 0.5750), zmin 0 | | Collider tris | ≤ 180 | 130 | | Export | written, size > 0 | 123436 / 123436 / 123416 bytes |

-

DECIMATE COLLAPSE triangle counts are not identical across series — 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 20 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.

+

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 700–1000 | 928 / 928 / 928 | | 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.1897 | | Materials | exactly 3 distinct; ≥80 wood, stone, metal faces | 3 slots; 216 / 160 / 180 | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.710, 0.419, 0.610) m ± 0.015 | (0.7100, 0.4185, 0.6100), zmin 0 | | Collider tris | ≤ 240 | 198 | | Export | written, size > 0 | 89316 / 89316 / 89312 bytes |

+

Hygiene

+

Recomputed from the generated mesh, not asserted about the script.

+

| Axis | Declared | Measured (all three) | | --- | --- | --- | | Non-manifold edges | 0 | 0 | | Loose verts / edges | 0 / 0 | 0 / 0 | | Doubles merged at 1e-5 | 0 | 0 | | Zero-area faces | 0 | 0 | | N-gons | 0 | 0 | | Coplanar disjoint face pairs | 0 | 0 | | Grounded: zmin | within 1e-4 of 0 | 0.0000 | | Named supports: 4 shoes | each zmin ≤ 1e-3 | 4, shoe_z 0.00000 | | Stone size | 0.500 m dia × 0.095 m thick ± 0.02 / ± 0.015 | 0.5000 × 0.0950 |

+

Joint fit and seat

+

| Axis | Declared | Measured (all three) | | --- | --- | --- | | Crank-axle gap | ≤ 0.008 m | 0.00418 | | Shoe-wood BVH gap | ≤ 0.008 m (overlap is 0) | 0.00000 | | Trough-sill per-side gap | ≤ 0.008 m (overlap is 0) | 0.00000 | | Stone dip into trough | 0.015–0.045 m | 0.02570 | | Trough floor zmin | ≥ 0.040 m | 0.05800 |

+

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is 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 4 B on 5.2.1 (glTF serializer), not a gated axis.

+

Falsifiers

+

Each violates one named budget. All nine were run on 4.5.11, 5.1.2 and 5.2.1 and returned the same code on each.

+

| Flag | Budget violated | Exit | | --- | --- | --- | | --skip-decimate | LOD1 ratio band | 9 | | --stray-vert | loose vertex count is 0 | 15 | | --lift-z | bounding box zmin is 0 | 16 | | --short-legs | named shoe supports at Z=0 | 16 | | --float-crank | crank-axle gap | 17 | | --float-legs | shoe-wood join | 17 | | --no-dip | stone dip band | 18 | | --narrow-trough | trough-sill seat | 18 |

Run

blender --background --python grindstone.py --
 blender --background --python grindstone.py -- --skip-decimate
+blender --background --python grindstone.py -- --stray-vert
+blender --background --python grindstone.py -- --lift-z
+blender --background --python grindstone.py -- --short-legs
+blender --background --python grindstone.py -- --float-crank
+blender --background --python grindstone.py -- --float-legs
+blender --background --python grindstone.py -- --no-dip
+blender --background --python grindstone.py -- --narrow-trough
 blender --background --python grindstone.py -- --output grindstone.png
-

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

+

Smoke passes no flags.

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 wood/stone/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 |

+

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path. 1519 are the hygiene and joint-fit family.

+

| 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 face-count 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 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, z-fight | | 16 | Not grounded: bounding box zmin off 0, or a named shoe floats | | 17 | Joint fit: crank-axle gap, or shoe-wood join | | 18 | Seat: trough-sill gap, stone dip band, or trough sitting on the dirt | | 19 | Stone diameter or thickness off the stated real-world size |

Source

@@ -284,14 +299,23 @@

Source

"""Game-ready grindstone — a showcase piece, not an example.
 
-Asserts budget conformance of a procedural grindstone (stone wheel,
-timber trestle, iron axle, hubs, and crank) after composing shipped
-pipeline pieces: bmesh construction, UVs, three materials, high-to-low
-normal bake, LOD chain, convex collider, Unity glTF export.
+Asserts budget conformance of a procedural grindstone (thick sandstone
+wheel, timber A-frame, open water trough, iron axle/hubs/crank) after
+composing shipped pipeline pieces: bmesh construction, UVs, three
+materials, high-to-low 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.
+DECIMATE stage so the LOD-ratio budget fails. ``--stray-vert`` adds a
+loose vertex so the mesh-hygiene budget fails. ``--lift-z`` raises the
+mesh so the grounded-zmin budget fails. ``--float-crank`` offsets the
+crank from the axle so joint-fit fails. ``--no-dip`` raises the stone
+clear of the trough so the seat-conformance dip band fails.
+``--short-legs`` raises the iron shoes and plants a dummy so the named
+support budget fails while AABB zmin still passes. ``--float-legs``
+lifts the A-frame timber off the shoes so the shoe-wood join fails.
+``--narrow-trough`` shrinks the tub so it no longer seats in the sills.
 
 No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
 are not byte-identical across Blender versions — the LOD gate is a
@@ -311,6 +335,7 @@ 

Source

import bmesh import bpy from mathutils import Euler, Vector +from mathutils.bvhtree import BVHTree _REPO = os.path.abspath( os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) @@ -319,27 +344,44 @@

Source

sys.dont_write_bytecode = True import gallery_framing # noqa: E402 -STONE_R = 0.235 -STONE_T = 0.058 -STONE_SEGS = 24 -AXLE_Z = 0.34 -FRAME_Y = 0.175 -LEG_SPREAD = 0.28 +STONE_R = 0.250 +STONE_T = 0.095 +STONE_SEGS = 32 +CHAMFER = 0.012 +TROUGH_H = 0.080 +TROUGH_WALL = 0.018 +DIP = 0.028 +HUB_R = 0.048 +HUB_T = 0.022 +HUB_SEAT = 0.004 +FRAME_Y = STONE_T / 2.0 + HUB_T + 0.055 +LEG_SPREAD = 0.32 LEG_XY = (0.044, 0.044) -AXLE_R = 0.016 -AXLE_LEN = 0.50 -HUB_R = 0.040 -HUB_T = 0.026 -TRAY_L = 0.46 -TRAY_W = 0.26 -TRAY_H = 0.055 -CRANK_ARM = 0.14 -HANDLE_L = 0.10 - -BBOX_TOL = 0.01 -OUTER_SIZE = (0.630, 0.615, 0.575) -BASE_TRIS_MIN = 1480 -BASE_TRIS_MAX = 1720 +# Sill and king are fatter in Y than the diagonals so the braces +# tenon *into* the frame. Matching Y-thickness puts coplanar faces +# on the camera side and reads as a black hole in the timber. +SILL_Y = 0.070 +KING_Y = 0.054 +# Seat the tub into the sill inner faces. Deriving from LEG_XY left a +# daylight slot once the sill was fattened to nest the diagonals. +TROUGH_SEAT = 0.012 +TROUGH_W = 2.0 * (FRAME_Y - SILL_Y / 2.0 + TROUGH_SEAT) +TROUGH_L = 2.0 * (LEG_SPREAD - 0.010) +TROUGH_Z0 = 0.058 +AXLE_R = 0.014 +AXLE_OVER = 0.055 +CRANK_ARM = 0.13 +HANDLE_L = 0.11 +SHOE_H = 0.024 +SHOE_XY = (0.070, 0.070) + +BBOX_TOL = 0.015 +OUTER_SIZE = (0.710, 0.419, 0.610) +STONE_DIA = 2.0 * STONE_R +STONE_DIA_TOL = 0.02 +STONE_T_TOL = 0.015 +BASE_TRIS_MIN = 700 +BASE_TRIS_MAX = 1000 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -349,12 +391,25 @@

Source

MATERIAL_COUNT = 3 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 180 +COLLIDER_TRIS_MAX = 240 BAKE_RES = 256 CAGE_EXTRUSION = 0.08 -METAL_FACES_MIN = 24 -WOOD_FACES_MIN = 12 -STONE_FACES_MIN = 24 +METAL_FACES_MIN = 80 +WOOD_FACES_MIN = 80 +STONE_FACES_MIN = 80 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.999 +LIFT_Z = 0.05 +CRANK_JOIN = 0.008 +SHOE_JOIN = 0.008 +TROUGH_SILL_JOIN = 0.008 +DIP_MIN = 0.015 +DIP_MAX = 0.045 +SHOE_Z_MAX = 1e-3 +TROUGH_FLOOR_Z_MIN = 0.040 WOOD_IDX = 0 STONE_IDX = 1 @@ -429,7 +484,7 @@

Source

geo = bmesh.ops.create_cone( bm, cap_ends=True, - cap_tris=False, + cap_tris=True, segments=segments, radius1=radius, radius2=radius, @@ -446,25 +501,83 @@

Source

return verts -def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): - geo = bmesh.ops.create_cone( - bm, - cap_ends=True, - cap_tris=False, - segments=segments, - radius1=radius1, - radius2=radius2, - depth=depth, - ) - verts = geo["verts"] - rot = Euler(euler).to_matrix() +def add_basin(bm, loc, size, wall, mat_idx): + """One manifold open tub. Five overlapping boxes bevel into degenerates.""" + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = list(geo["verts"]) origin = Vector(loc) + sx, sy, sz = size for v in verts: - v.co = rot @ v.co + origin + v.co = Vector((v.co.x * sx, v.co.y * sy, v.co.z * sz)) + origin faces = {f for v in verts for f in v.link_faces} for f in faces: f.material_index = mat_idx - return verts + top = max(faces, key=lambda f: f.calc_center_median().z) + bmesh.ops.inset_region( + bm, + faces=[top], + thickness=wall, + depth=-(sz - wall), + use_boundary=True, + use_even_offset=True, + ) + for f in {f for v in verts for f in v.link_faces}: + f.material_index = mat_idx + seen = set(verts) + stack = list(verts) + while stack: + v = stack.pop() + for e in v.link_edges: + o = e.other_vert(v) + if o not in seen: + seen.add(o) + stack.append(o) + return list(seen) + + +def add_stone(bm, loc, axle_z): + """Ring-stack wheel: slight face inset so the rim reads thick, tris caps.""" + hw = STONE_T / 2.0 + rings_yz = [ + (-hw, STONE_R * 0.97), + (-hw + CHAMFER, STONE_R), + (hw - CHAMFER, STONE_R), + (hw, STONE_R * 0.97), + ] + rings = [] + for y_off, radius in rings_yz: + ring = [] + for i in range(STONE_SEGS): + a = 2.0 * math.pi * i / STONE_SEGS + ring.append( + bm.verts.new( + ( + loc[0] + radius * math.cos(a), + loc[1] + y_off, + axle_z + radius * math.sin(a), + ) + ) + ) + rings.append(ring) + for i in range(len(rings) - 1): + a, b = rings[i], rings[i + 1] + for k in range(STONE_SEGS): + kn = (k + 1) % STONE_SEGS + face = bm.faces.new((a[k], a[kn], b[kn], b[k])) + face.material_index = STONE_IDX + for ring, y_off, inward in ( + (rings[0], rings_yz[0][0], True), + (rings[-1], rings_yz[-1][0], False), + ): + c = bm.verts.new((loc[0], loc[1] + y_off, axle_z)) + for k in range(STONE_SEGS): + kn = (k + 1) % STONE_SEGS + if inward: + face = bm.faces.new((c, ring[kn], ring[k])) + else: + face = bm.faces.new((c, ring[k], ring[kn])) + face.material_index = STONE_IDX + return [v for ring in rings for v in ring] def pack_uvs(bm, margin=0.08): @@ -510,131 +623,167 @@

Source

) -def build_grindstone_mesh(name, bevel_offset, bevel_segments): +def build_grindstone_mesh( + name, bevel_offset, bevel_segments, + float_crank=False, no_dip=False, short_legs=False, float_legs=False, + narrow_trough=False, +): bm = bmesh.new() try: wood = [] - stone = [] metal = [] + dip = DIP + tub_z0 = TROUGH_Z0 - (0.040 if no_dip else 0.0) + trough_top = TROUGH_Z0 + TROUGH_H + stone_bottom = trough_top - dip + axle_z = STONE_R + stone_bottom + axle_len = 2.0 * FRAME_Y + 2.0 * AXLE_OVER + axle_end = axle_len / 2.0 + # Inward, not proud: an outward offset grows the Y AABB and + # trips exit 8 before the crank-join budget. + crank_y = axle_end - (0.022 if float_crank else 0.012) + shoe_z = 0.05 if short_legs else 0.0 + bearing_h = 0.090 + bearing_xz = (0.078, bearing_h) + king_xy = (0.052, KING_Y) + # Diagonals *are* the legs. A tall stump beside an angled timber + # paints a see-through triangle on the stump face. Bury a short + # tenon in the shoe (never proud of the iron) and start the + # diagonal on the shoe top so the angled end-cap cannot pierce + # Z=0. + tenon_z = shoe_z + SHOE_H * 0.50 + diag_z = shoe_z + SHOE_H + 0.008 + if float_legs: + tenon_z = shoe_z + SHOE_H + 0.070 + diag_z = shoe_z + SHOE_H + 0.070 + sill_h = 0.050 + sill_bottom = ( + shoe_z + SHOE_H + 0.055 if float_legs else shoe_z + SHOE_H * 0.40 + ) + sill_z = sill_bottom + sill_h / 2.0 for y in (-FRAME_Y, FRAME_Y): + # King post fills the A-crotch so the bearing is not a cube + # perched on two sticks with a triangular hole under it. wood.extend( add_oriented_box( bm, - (-LEG_SPREAD, y, 0.022), - (0.0, y, AXLE_Z), - LEG_XY, + (0.0, y, sill_z), + (0.0, y, axle_z + bearing_h * 0.35), + king_xy, WOOD_IDX, ) ) wood.extend( - add_oriented_box( + add_box( bm, - (LEG_SPREAD, y, 0.022), - (0.0, y, AXLE_Z), - LEG_XY, + (0.0, y, axle_z), + (bearing_xz[0], 0.058, bearing_xz[1]), WOOD_IDX, ) ) + # Tie beam sits on the shoes and occupies the A-foot. wood.extend( add_box( bm, - (0.0, y, 0.072), - (LEG_SPREAD * 1.55, 0.038, 0.038), + (0.0, y, sill_z), + (LEG_SPREAD * 2.0 + SHOE_XY[0], SILL_Y, sill_h), WOOD_IDX, ) ) + for x in (-LEG_SPREAD, LEG_SPREAD): + if not float_legs: + wood.extend( + add_box( + bm, + (x, y, tenon_z), + (LEG_XY[0] * 0.92, LEG_XY[1] * 0.92, SHOE_H * 0.90), + WOOD_IDX, + ) + ) + wood.extend( + add_oriented_box( + bm, + (x, y, diag_z), + (0.0, y, axle_z - 0.010), + LEG_XY, + WOOD_IDX, + ) + ) + metal.extend( + add_box( + bm, + (x, y, shoe_z + SHOE_H / 2.0), + (SHOE_XY[0], SHOE_XY[1], SHOE_H), + METAL_IDX, + ) + ) + + for x in (-LEG_SPREAD, LEG_SPREAD): + # End stretchers tenon through both sills at sill height. + # A higher independent Z left them floating as extra cubes. wood.extend( add_box( bm, - (0.0, y, AXLE_Z), - (0.090, 0.070, 0.070), + (x, 0.0, sill_z), + (0.038, FRAME_Y * 2.0, sill_h * 0.70), WOOD_IDX, ) ) - wood.extend( - add_box( - bm, - (0.0, 0.0, 0.078), - (0.070, FRAME_Y * 2.0 + 0.04, 0.040), - WOOD_IDX, - ) - ) - wood.extend( - add_box( - bm, - (0.0, 0.0, TRAY_H / 2.0 + 0.004), - (TRAY_L, TRAY_W, TRAY_H), - WOOD_IDX, - ) + tw = TROUGH_W * (0.58 if narrow_trough else 1.0) + chamfer = [] + basin_verts = add_basin( + bm, + (0.0, 0.0, tub_z0 + TROUGH_H / 2.0), + (TROUGH_L, tw, TROUGH_H), + TROUGH_WALL, + WOOD_IDX, ) - wood.extend( + chamfer.extend(basin_verts) + + chamfer.extend( add_cyl( bm, - (0.0, FRAME_Y + AXLE_LEN * 0.28, AXLE_Z + CRANK_ARM), - 0.014, + (0.0, crank_y + HANDLE_L * 0.15, axle_z + CRANK_ARM), + 0.016, HANDLE_L, - 8, + 10, WOOD_IDX, euler=(math.pi / 2.0, 0.0, 0.0), ) ) + wood.extend(chamfer) - if bevel_offset > 0.0: - edges = list({e for v in wood for e in v.link_edges}) - ret = bmesh.ops.bevel( - bm, - geom=edges, - offset=bevel_offset, - segments=bevel_segments, - profile=0.5, - affect="EDGES", - clamp_overlap=True, - ) - for f in ret.get("faces") or []: - f.material_index = WOOD_IDX - - stone.extend( - add_cyl( - bm, - (0.0, 0.0, AXLE_Z), - STONE_R, - STONE_T, - STONE_SEGS, - STONE_IDX, - euler=(math.pi / 2.0, 0.0, 0.0), - ) - ) + add_stone(bm, (0.0, 0.0), axle_z) metal.extend( add_cyl( bm, - (0.0, 0.0, AXLE_Z), + (0.0, 0.0, axle_z), AXLE_R, - AXLE_LEN, - 10, + axle_len, + 12, METAL_IDX, euler=(math.pi / 2.0, 0.0, 0.0), ) ) - for y in (-STONE_T / 2.0 - HUB_T / 2.0 + 0.004, STONE_T / 2.0 + HUB_T / 2.0 - 0.004): + for ysign in (-1.0, 1.0): metal.extend( add_cyl( bm, - (0.0, y, AXLE_Z), + (0.0, ysign * (STONE_T / 2.0 + HUB_T / 2.0 - HUB_SEAT), axle_z), HUB_R, HUB_T, - 12, + 14, METAL_IDX, euler=(math.pi / 2.0, 0.0, 0.0), ) ) - crank_y = FRAME_Y + AXLE_LEN * 0.22 metal.extend( add_box( bm, - (0.0, crank_y, AXLE_Z + CRANK_ARM / 2.0), + (0.0, crank_y, axle_z + CRANK_ARM / 2.0), (0.018, 0.018, CRANK_ARM + 0.02), METAL_IDX, ) @@ -642,37 +791,54 @@

Source

metal.extend( add_cyl( bm, - (0.0, crank_y, AXLE_Z + CRANK_ARM), + (0.0, crank_y, axle_z + CRANK_ARM), 0.010, HANDLE_L * 0.55, - 8, + 10, METAL_IDX, euler=(math.pi / 2.0, 0.0, 0.0), ) ) - for y in (-FRAME_Y, FRAME_Y): - for x in (-LEG_SPREAD, LEG_SPREAD): - metal.extend( - add_box( - bm, - (x, y, 0.018), - (0.070, 0.070, 0.036), - METAL_IDX, - ) + + if short_legs: + metal.extend( + add_box( + bm, + (0.0, 0.0, 0.005), + (0.020, 0.020, 0.010), + METAL_IDX, ) + ) xs = [v.co.x for v in bm.verts] ys = [v.co.y for v in bm.verts] - zs = [v.co.z for v in bm.verts] - rcx = 0.5 * (min(xs) + max(xs)) - rcy = 0.5 * (min(ys) + max(ys)) - zmin = min(zs) + cx = 0.5 * (min(xs) + max(xs)) + cy = 0.5 * (min(ys) + max(ys)) for v in bm.verts: - v.co.x -= rcx - v.co.y -= rcy - v.co.z -= zmin - if v.co.z < 0.0: - v.co.z = 0.0 + v.co.x -= cx + v.co.y -= cy + + if bevel_offset > 0.0: + edges = [] + trough_verts = set(basin_verts) + for e in {e for v in trough_verts for e in v.link_edges}: + if min(v.co.z for v in e.verts) < tub_z0 - 0.002: + continue + if any(f.material_index != WOOD_IDX for f in e.link_faces): + continue + edges.append(e) + if edges: + ret = bmesh.ops.bevel( + bm, + geom=edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + for f in ret.get("faces") or []: + f.material_index = WOOD_IDX pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) @@ -690,13 +856,33 @@

Source

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

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): + 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) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= 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 zfight_pairs(me): + data = [ + (p.center.copy(), p.normal.copy(), frozenset(p.vertices)) + for p in me.polygons + ] + eps2 = ZFIGHT_EPS * ZFIGHT_EPS + count = 0 + for i in range(len(data)): + ci, ni, vi = data[i] + for j in range(i + 1, len(data)): + cj, nj, vj = data[j] + if (cj - ci).length_squared > eps2: + continue + if abs(ni.dot(nj)) <= ZFIGHT_COS: + continue + if vi & vj: + continue + count += 1 + return count + + +def shells(me): + neighbors = [[] for _ in range(len(me.vertices))] + for edge in me.edges: + a, b = edge.vertices + neighbors[a].append(b) + neighbors[b].append(a) + seen = [False] * len(me.vertices) + groups = [] + for start in range(len(me.vertices)): + if seen[start]: + continue + seen[start] = True + stack = [start] + group = [] + while stack: + current = stack.pop() + group.append(current) + for nxt in neighbors[current]: + if not seen[nxt]: + seen[nxt] = True + stack.append(nxt) + groups.append(group) + return groups + + +def shell_aabb(me, group): + pts = [me.vertices[i].co for i in group] + return ( + min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts), + max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts), + ) + + +def mat_of(me, group): + member = set(group) + for p in me.polygons: + if all(i in member for i in p.vertices): + return p.material_index + return -1 + + +def support_audit(me): + groups = shells(me) + shoes = [] + for g in groups: + if mat_of(me, g) != METAL_IDX: + continue + a = shell_aabb(me, g) + if a[5] < 0.08 and (a[4] - a[1]) > 0.04 and (a[3] - a[0]) > 0.04: + shoes.append(a) + shoe_z = min((a[2] for a in shoes), default=99.0) + return {"shoes": len(shoes), "shoe_z": shoe_z} + + +def trough_floor_z(me): + """Z of the wide shallow wood shell (the trough floor).""" + groups = shells(me) + best = 99.0 + for g in groups: + if mat_of(me, g) != WOOD_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + if dx > TROUGH_L * 0.7 and dy > TROUGH_W * 0.55: + best = min(best, a[2]) + return best + + +def stone_audit(me): + stone_pts = [] + groups = shells(me) + for g in groups: + if mat_of(me, g) != STONE_IDX: + continue + stone_pts.extend(me.vertices[i].co for i in g) + if not stone_pts: + return { + "dia": 0.0, "thick": 0.0, "zmin": 99.0, "dip": -1.0, "trough_z": 99.0, + } + xs = [p.x for p in stone_pts] + ys = [p.y for p in stone_pts] + zs = [p.z for p in stone_pts] + dia = max(max(xs) - min(xs), max(zs) - min(zs)) + thick = max(ys) - min(ys) + zmin = min(zs) + tb = None + for g in groups: + if mat_of(me, g) != WOOD_IDX: + continue + a = shell_aabb(me, g) + dx, dy = a[3] - a[0], a[4] - a[1] + if abs(dx - TROUGH_L) < 0.08 and dy > 0.08 and a[2] < 0.15: + tb = a + break + trough_z = tb[2] if tb else trough_floor_z(me) + trough_top = tb[5] if tb else (TROUGH_Z0 + TROUGH_H) + return { + "dia": dia, + "thick": thick, + "zmin": zmin, + "dip": trough_top - zmin, + "trough_z": trough_z, + } + + +def crank_join(me): + """Gap from crank-arm verts to the axle shell.""" + groups = shells(me) + axle = None + crank = None + for g in groups: + if mat_of(me, g) != METAL_IDX: + continue + a = shell_aabb(me, g) + dy, dx, dz = a[4] - a[1], a[3] - a[0], a[5] - a[2] + if dy > 0.30 and dx < 0.06 and dz < 0.06: + axle = g + if dz > CRANK_ARM * 0.6 and dy < 0.05 and dx < 0.05: + crank = g + if axle is None or crank is None: + return 99.0 + axle_pts = [me.vertices[i].co for i in axle] + crank_pts = [me.vertices[i].co for i in crank] + best = 99.0 + for c in crank_pts: + d = min((c - a).length for a in axle_pts) + if d < best: + best = d + return best + + +def trough_sill_gap(me): + """Daylight in Y between the trough and each A-frame sill. + + Per-side, not a mixed max. The crank handle makes the mesh AABB + asymmetric, so a global |ymin|/ymax vs min(sill inner) compares the + trough's long side against the opposite sill and reports overlap + while the other side shows daylight. + Overlap on a side is 0. Missing shells return 99. + """ + groups = shells(me) + trough = None + plus_sill = None + minus_sill = None + for g in groups: + if mat_of(me, g) != WOOD_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + cy = 0.5 * (a[1] + a[4]) + if abs(dx - TROUGH_L) < 0.08 and dy > 0.08 and a[2] < 0.15: + trough = a + if ( + dx > LEG_SPREAD * 1.2 + and dy > SILL_Y * 0.55 + and abs(cy) > FRAME_Y * 0.40 + and a[5] < 0.14 + ): + if cy > 0.0: + plus_sill = a + else: + minus_sill = a + if trough is None or plus_sill is None or minus_sill is None: + return 99.0 + gap_plus = plus_sill[1] - trough[4] + gap_minus = trough[1] - minus_sill[4] + return max(0.0, gap_plus, gap_minus) + + +def shoe_wood_gap(me): + """Worst gap from an iron shoe to wood. + + Overlapping solids count as 0. Vert-vert and shoe-corner-to-surface + both lie about a shoe-width away even when a tenon is buried in the + iron; overlap is the join. + """ + groups = shells(me) + shoes = [] + for g in groups: + if mat_of(me, g) != METAL_IDX: + continue + a = shell_aabb(me, g) + if a[5] < 0.08 and (a[4] - a[1]) > 0.04 and (a[3] - a[0]) > 0.04: + shoes.append(g) + if not shoes: + return 99.0 + bm_wood = bmesh.new() + try: + bm_wood.from_mesh(me) + drop = [f for f in bm_wood.faces if f.material_index != WOOD_IDX] + if drop: + bmesh.ops.delete(bm_wood, geom=drop, context="FACES") + if not bm_wood.faces: + return 99.0 + tree_wood = BVHTree.FromBMesh(bm_wood) + worst = 0.0 + for g in shoes: + bm_s = bmesh.new() + try: + bm_s.from_mesh(me) + member = set(g) + drop_s = [ + f for f in bm_s.faces + if not all(v.index in member for v in f.verts) + ] + if drop_s: + bmesh.ops.delete(bm_s, geom=drop_s, context="FACES") + if not bm_s.faces: + worst = max(worst, 99.0) + continue + tree_s = BVHTree.FromBMesh(bm_s) + if tree_wood.overlap(tree_s): + continue + best = 99.0 + for i in g: + hit = tree_wood.find_nearest(me.vertices[i].co) + if hit[0] is None: + continue + best = min(best, hit[3]) + if best > worst: + worst = best + finally: + bm_s.free() + return worst + finally: + bm_wood.free() + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, STONE_R)) + bm.to_mesh(me) + me.update() + finally: + bm.free() + + def make_lod(obj, name, ratio, skip_decimate): mesh = obj.data.copy() lod = bpy.data.objects.new(name, mesh) @@ -829,15 +1309,40 @@

Source

) -def check(skip_decimate): +def check( + skip_decimate, lift_z=False, stray_vert=False, + float_crank=False, no_dip=False, short_legs=False, float_legs=False, + narrow_trough=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_grindstone_mesh("GrindstoneLow", bevel_offset=0.004, bevel_segments=2) - high = build_grindstone_mesh("GrindstoneHigh", bevel_offset=0.004, bevel_segments=4) - wood = principled("GrindstoneWood", (0.36, 0.19, 0.07, 1.0), 0.0, 0.60) - stone = principled("GrindstoneStone", (0.46, 0.44, 0.41, 1.0), 0.0, 0.86) - metal = principled("GrindstoneIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38) + flags = dict( + float_crank=float_crank, no_dip=no_dip, + short_legs=short_legs, float_legs=float_legs, + narrow_trough=narrow_trough, + ) + low = build_grindstone_mesh("GrindstoneLow", 0.004, 2, **flags) + high = build_grindstone_mesh("GrindstoneHigh", 0.004, 4, **flags) + wood = principled( + "GrindstoneWood", (0.38, 0.20, 0.08, 1.0), 0.0, 0.62, + noise_scale=6.5, wear=(0.22, 0.11, 0.04, 1.0), + ) + stone = principled( + "GrindstoneStone", (0.40, 0.36, 0.30, 1.0), 0.0, 0.92, + noise_scale=14.0, wear=(0.28, 0.24, 0.18, 1.0), + ) + metal = principled( + "GrindstoneIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38, + noise_scale=5.0, wear=(0.05, 0.05, 0.06, 1.0), + ) assign_slots(low, wood, stone, metal) assign_slots(high, wood, stone, metal) + if stray_vert: + add_stray_vert(low.data) + if lift_z: + for v in low.data.vertices: + v.co.z += LIFT_Z + low.data.update() + bpy.context.view_layer.update() if low.data is None or len(low.data.polygons) < 6: return fail("grindstone mesh did not build", 3), None, None, None, None, None @@ -869,9 +1374,7 @@

Source

r1 = lod1_tris / base_tris if base_tris else 0.0 r2 = lod2_tris / base_tris if base_tris else 0.0 - collider_src = build_grindstone_mesh( - "GrindstoneColSrc", bevel_offset=0.0, bevel_segments=1 - ) + collider_src = build_grindstone_mesh("GrindstoneColSrc", 0.0, 1, **flags) collider = convex_hull_collider(collider_src, "GrindstoneCollider") bpy.data.objects.remove(collider_src, do_unlink=True) col_tris = triangle_count(collider.data) @@ -885,6 +1388,14 @@

Source

export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + st = stone_audit(low.data) + cj = crank_join(low.data) + sw = shoe_wood_gap(low.data) + ts = trough_sill_gap(low.data) + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " @@ -902,6 +1413,17 @@

Source

f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}" ) + 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']} zfight={zf}" + ) + print( + f"measured supports shoes={sup['shoes']} shoe_z={sup['shoe_z']:.5f} " + f"stone_dia={st['dia']:.4f} stone_t={st['thick']:.4f} " + f"dip={st['dip']:.5f} trough_z={st['trough_z']:.5f} " + f"crank_join={cj:.5f} shoe_wood={sw:.5f} trough_sill={ts:.5f}" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -971,6 +1493,68 @@

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"] or zf + ): + 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']} zfight={zf}", + 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 sup["shoes"] < 4 or sup["shoe_z"] > SHOE_Z_MAX: + return fail( + f"shoe supports {sup['shoes']} shoe_z={sup['shoe_z']:.5f} " + "(--short-legs is the designed fail)", + 16, + ), None, None, None, None, None + if cj > CRANK_JOIN: + return fail( + f"crank-axle gap {cj:.5f} > {CRANK_JOIN} " + "(--float-crank is the designed fail)", + 17, + ), None, None, None, None, None + if sw > SHOE_JOIN: + return fail( + f"shoe-wood gap {sw:.5f} > {SHOE_JOIN} " + "(--float-legs is the designed fail)", + 17, + ), None, None, None, None, None + if ts > TROUGH_SILL_JOIN: + return fail( + f"trough-sill gap {ts:.5f} > {TROUGH_SILL_JOIN} " + "(--narrow-trough is the designed fail)", + 18, + ), None, None, None, None, None + if not (DIP_MIN <= st["dip"] <= DIP_MAX): + return fail( + f"stone dip {st['dip']:.5f} not in [{DIP_MIN}, {DIP_MAX}] " + "(--no-dip is the designed fail)", + 18, + ), None, None, None, None, None + if st["trough_z"] < TROUGH_FLOOR_Z_MIN: + return fail( + f"trough floor zmin {st['trough_z']:.5f} < {TROUGH_FLOOR_Z_MIN} " + "(tub must sit on the frame, not the dirt)", + 18, + ), None, None, None, None, None + if abs(st["dia"] - STONE_DIA) > STONE_DIA_TOL: + return fail( + f"stone diameter {st['dia']:.4f} off {STONE_DIA}", + 19, + ), None, None, None, None, None + if abs(st["thick"] - STONE_T) > STONE_T_TOL: + return fail( + f"stone thickness {st['thick']:.4f} off {STONE_T}", + 19, + ), None, None, None, None, None return 0, low, high, stone, tex, collider @@ -991,7 +1575,7 @@

Source

ob.hide_render = True ob.hide_viewport = True - low.rotation_euler.z = math.radians(-16.0) + low.rotation_euler.z = math.radians(-22.0) low.rotation_euler.x = math.radians(0.0) floor_me = bpy.data.meshes.new("Floor") @@ -1038,10 +1622,10 @@

Source

cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (1.48, -1.82, 0.86) + cam.location = (1.55, -1.95, 0.92) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.28) + aim.location = (0.0, 0.0, 0.32) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -1084,14 +1668,26 @@

Source

p = argparse.ArgumentParser() p.add_argument("--output", default=None) p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) - p.add_argument( - "--skip-decimate", - action="store_true", - help="falsification: skip the LOD DECIMATE stage", - ) + p.add_argument("--skip-decimate", action="store_true") + p.add_argument("--lift-z", action="store_true") + p.add_argument("--stray-vert", action="store_true") + p.add_argument("--float-crank", action="store_true") + p.add_argument("--no-dip", action="store_true") + p.add_argument("--short-legs", action="store_true") + p.add_argument("--float-legs", action="store_true") + p.add_argument("--narrow-trough", action="store_true") args = p.parse_args(argv) - code, low, _high, hero_mat, tex, _col = check(args.skip_decimate) + code, low, _high, hero_mat, tex, _col = check( + args.skip_decimate, + lift_z=args.lift_z, + stray_vert=args.stray_vert, + float_crank=args.float_crank, + no_dip=args.no_dip, + short_legs=args.short_legs, + float_legs=args.float_legs, + narrow_trough=args.narrow_trough, + ) if code: return code if args.output: diff --git a/showcase/grindstone/README.md b/showcase/grindstone/README.md index 31519a0..80961f3 100644 --- a/showcase/grindstone/README.md +++ b/showcase/grindstone/README.md @@ -1,9 +1,14 @@ # Grindstone -A showcase piece, not an example. Procedural grindstone (stone wheel, -timber A-frame trestle, iron axle, hubs, crank, and tray) then the -shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD -chain, convex collider, Unity glTF export. +A showcase piece, not an example. Procedural grindstone (sandstone +wheel, timber A-frame trestle, iron shoes/axle/hubs/crank, open water +trough) then the shipped pipeline: unique-cell UVs, Cycles high-to-low +normal bake, LOD chain, convex collider, Unity glTF export. + +The A-frame is a king post with diagonals tenoning into a fatter sill, +not two sticks meeting at a point. The trough is one manifold basin +seated into those sills. Iron shoes bury a short wood tenon so the +angled leg end-cap cannot pierce Z=0. It asserts **budget conformance** of the generated result. It does not witness an API contract. "It rendered without error" is not a check. @@ -21,38 +26,85 @@ materials, UVs, evaluated LOD, collider, or export file. | Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | -| Base triangles | 1480–1720 | 1604 / 1604 / 1604 | +| Base triangles | 700–1000 | 928 / 928 / 928 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | -| LOD2 ratio | 0.10–0.35 of base | 0.2195 / 0.2195 / 0.2195 | -| Materials | exactly 3 distinct, ≥24 metal and stone faces | 3 slots, 80 metal, 26 stone | +| LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.1897 | +| Materials | exactly 3 distinct; ≥80 wood, stone, metal faces | 3 slots; 216 / 160 / 180 | | UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | -| Outer AABB | (0.630, 0.615, 0.575) m ± 0.01 | (0.6300, 0.6150, 0.5750), zmin 0 | -| Collider tris | ≤ 180 | 130 | -| Export | written, size > 0 | 123436 / 123436 / 123416 bytes | +| Outer AABB | (0.710, 0.419, 0.610) m ± 0.015 | (0.7100, 0.4185, 0.6100), zmin 0 | +| Collider tris | ≤ 240 | 198 | +| Export | written, size > 0 | 89316 / 89316 / 89312 bytes | + +### Hygiene + +Recomputed from the generated mesh, not asserted about the script. + +| Axis | Declared | Measured (all three) | +| --- | --- | --- | +| Non-manifold edges | 0 | 0 | +| Loose verts / edges | 0 / 0 | 0 / 0 | +| Doubles merged at 1e-5 | 0 | 0 | +| Zero-area faces | 0 | 0 | +| N-gons | 0 | 0 | +| Coplanar disjoint face pairs | 0 | 0 | +| Grounded: `zmin` | within 1e-4 of 0 | 0.0000 | +| Named supports: 4 shoes | each `zmin` ≤ 1e-3 | 4, shoe_z 0.00000 | +| Stone size | 0.500 m dia × 0.095 m thick ± 0.02 / ± 0.015 | 0.5000 × 0.0950 | + +### Joint fit and seat + +| Axis | Declared | Measured (all three) | +| --- | --- | --- | +| Crank-axle gap | ≤ 0.008 m | 0.00418 | +| Shoe-wood BVH gap | ≤ 0.008 m (overlap is 0) | 0.00000 | +| Trough-sill per-side gap | ≤ 0.008 m (overlap is 0) | 0.00000 | +| Stone dip into trough | 0.015–0.045 m | 0.02570 | +| Trough floor zmin | ≥ 0.040 m | 0.05800 | DECIMATE COLLAPSE triangle counts are **not** identical across series — -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 -20 B on 5.2.1 (glTF serializer), not a gated axis. +5.2.1 is 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 4 B on 5.2.1 (glTF serializer), not a gated axis. + +### Falsifiers -`--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. +Each violates one named budget. All nine were run on 4.5.11, 5.1.2 and +5.2.1 and returned the same code on each. + +| Flag | Budget violated | Exit | +| --- | --- | --- | +| `--skip-decimate` | LOD1 ratio band | 9 | +| `--stray-vert` | loose vertex count is 0 | 15 | +| `--lift-z` | bounding box `zmin` is 0 | 16 | +| `--short-legs` | named shoe supports at Z=0 | 16 | +| `--float-crank` | crank-axle gap | 17 | +| `--float-legs` | shoe-wood join | 17 | +| `--no-dip` | stone dip band | 18 | +| `--narrow-trough` | trough-sill seat | 18 | ## Run ```bash blender --background --python grindstone.py -- blender --background --python grindstone.py -- --skip-decimate +blender --background --python grindstone.py -- --stray-vert +blender --background --python grindstone.py -- --lift-z +blender --background --python grindstone.py -- --short-legs +blender --background --python grindstone.py -- --float-crank +blender --background --python grindstone.py -- --float-legs +blender --background --python grindstone.py -- --no-dip +blender --background --python grindstone.py -- --narrow-trough blender --background --python grindstone.py -- --output grindstone.png ``` -Smoke does not pass `--output` or `--skip-decimate`. +Smoke passes no flags. ## Exit codes File-local. `9` is a valid check code. `10` is reserved for -`gallery_framing.check_framing` on the `--output` path. +`gallery_framing.check_framing` on the `--output` path. `15`–`19` are the +hygiene and joint-fit family. | Code | Meaning | | --- | --- | @@ -61,7 +113,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, or wood/stone/metal faces missing | +| 5 | Material count ≠ 3 distinct slots, or a face-count floor missed | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | @@ -71,3 +123,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 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, z-fight | +| 16 | Not grounded: bounding box `zmin` off 0, or a named shoe floats | +| 17 | Joint fit: crank-axle gap, or shoe-wood join | +| 18 | Seat: trough-sill gap, stone dip band, or trough sitting on the dirt | +| 19 | Stone diameter or thickness off the stated real-world size | diff --git a/showcase/grindstone/grindstone.py b/showcase/grindstone/grindstone.py index d6741e0..cba9c03 100644 --- a/showcase/grindstone/grindstone.py +++ b/showcase/grindstone/grindstone.py @@ -1,13 +1,22 @@ """Game-ready grindstone — a showcase piece, not an example. -Asserts budget conformance of a procedural grindstone (stone wheel, -timber trestle, iron axle, hubs, and crank) after composing shipped -pipeline pieces: bmesh construction, UVs, three materials, high-to-low -normal bake, LOD chain, convex collider, Unity glTF export. +Asserts budget conformance of a procedural grindstone (thick sandstone +wheel, timber A-frame, open water trough, iron axle/hubs/crank) after +composing shipped pipeline pieces: bmesh construction, UVs, three +materials, high-to-low 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. +DECIMATE stage so the LOD-ratio budget fails. ``--stray-vert`` adds a +loose vertex so the mesh-hygiene budget fails. ``--lift-z`` raises the +mesh so the grounded-zmin budget fails. ``--float-crank`` offsets the +crank from the axle so joint-fit fails. ``--no-dip`` raises the stone +clear of the trough so the seat-conformance dip band fails. +``--short-legs`` raises the iron shoes and plants a dummy so the named +support budget fails while AABB zmin still passes. ``--float-legs`` +lifts the A-frame timber off the shoes so the shoe-wood join fails. +``--narrow-trough`` shrinks the tub so it no longer seats in the sills. No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts are not byte-identical across Blender versions — the LOD gate is a @@ -27,6 +36,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) @@ -35,27 +45,44 @@ sys.dont_write_bytecode = True import gallery_framing # noqa: E402 -STONE_R = 0.235 -STONE_T = 0.058 -STONE_SEGS = 24 -AXLE_Z = 0.34 -FRAME_Y = 0.175 -LEG_SPREAD = 0.28 +STONE_R = 0.250 +STONE_T = 0.095 +STONE_SEGS = 32 +CHAMFER = 0.012 +TROUGH_H = 0.080 +TROUGH_WALL = 0.018 +DIP = 0.028 +HUB_R = 0.048 +HUB_T = 0.022 +HUB_SEAT = 0.004 +FRAME_Y = STONE_T / 2.0 + HUB_T + 0.055 +LEG_SPREAD = 0.32 LEG_XY = (0.044, 0.044) -AXLE_R = 0.016 -AXLE_LEN = 0.50 -HUB_R = 0.040 -HUB_T = 0.026 -TRAY_L = 0.46 -TRAY_W = 0.26 -TRAY_H = 0.055 -CRANK_ARM = 0.14 -HANDLE_L = 0.10 - -BBOX_TOL = 0.01 -OUTER_SIZE = (0.630, 0.615, 0.575) -BASE_TRIS_MIN = 1480 -BASE_TRIS_MAX = 1720 +# Sill and king are fatter in Y than the diagonals so the braces +# tenon *into* the frame. Matching Y-thickness puts coplanar faces +# on the camera side and reads as a black hole in the timber. +SILL_Y = 0.070 +KING_Y = 0.054 +# Seat the tub into the sill inner faces. Deriving from LEG_XY left a +# daylight slot once the sill was fattened to nest the diagonals. +TROUGH_SEAT = 0.012 +TROUGH_W = 2.0 * (FRAME_Y - SILL_Y / 2.0 + TROUGH_SEAT) +TROUGH_L = 2.0 * (LEG_SPREAD - 0.010) +TROUGH_Z0 = 0.058 +AXLE_R = 0.014 +AXLE_OVER = 0.055 +CRANK_ARM = 0.13 +HANDLE_L = 0.11 +SHOE_H = 0.024 +SHOE_XY = (0.070, 0.070) + +BBOX_TOL = 0.015 +OUTER_SIZE = (0.710, 0.419, 0.610) +STONE_DIA = 2.0 * STONE_R +STONE_DIA_TOL = 0.02 +STONE_T_TOL = 0.015 +BASE_TRIS_MIN = 700 +BASE_TRIS_MAX = 1000 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -65,12 +92,25 @@ MATERIAL_COUNT = 3 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 180 +COLLIDER_TRIS_MAX = 240 BAKE_RES = 256 CAGE_EXTRUSION = 0.08 -METAL_FACES_MIN = 24 -WOOD_FACES_MIN = 12 -STONE_FACES_MIN = 24 +METAL_FACES_MIN = 80 +WOOD_FACES_MIN = 80 +STONE_FACES_MIN = 80 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.999 +LIFT_Z = 0.05 +CRANK_JOIN = 0.008 +SHOE_JOIN = 0.008 +TROUGH_SILL_JOIN = 0.008 +DIP_MIN = 0.015 +DIP_MAX = 0.045 +SHOE_Z_MAX = 1e-3 +TROUGH_FLOOR_Z_MIN = 0.040 WOOD_IDX = 0 STONE_IDX = 1 @@ -145,7 +185,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, @@ -162,25 +202,83 @@ def add_cyl(bm, loc, radius, depth, segments, 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)): - geo = bmesh.ops.create_cone( - bm, - cap_ends=True, - cap_tris=False, - segments=segments, - radius1=radius1, - radius2=radius2, - depth=depth, - ) - verts = geo["verts"] - rot = Euler(euler).to_matrix() +def add_basin(bm, loc, size, wall, mat_idx): + """One manifold open tub. Five overlapping boxes bevel into degenerates.""" + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = list(geo["verts"]) origin = Vector(loc) + sx, sy, sz = size for v in verts: - v.co = rot @ v.co + origin + v.co = Vector((v.co.x * sx, v.co.y * sy, v.co.z * sz)) + origin faces = {f for v in verts for f in v.link_faces} for f in faces: f.material_index = mat_idx - return verts + top = max(faces, key=lambda f: f.calc_center_median().z) + bmesh.ops.inset_region( + bm, + faces=[top], + thickness=wall, + depth=-(sz - wall), + use_boundary=True, + use_even_offset=True, + ) + for f in {f for v in verts for f in v.link_faces}: + f.material_index = mat_idx + seen = set(verts) + stack = list(verts) + while stack: + v = stack.pop() + for e in v.link_edges: + o = e.other_vert(v) + if o not in seen: + seen.add(o) + stack.append(o) + return list(seen) + + +def add_stone(bm, loc, axle_z): + """Ring-stack wheel: slight face inset so the rim reads thick, tris caps.""" + hw = STONE_T / 2.0 + rings_yz = [ + (-hw, STONE_R * 0.97), + (-hw + CHAMFER, STONE_R), + (hw - CHAMFER, STONE_R), + (hw, STONE_R * 0.97), + ] + rings = [] + for y_off, radius in rings_yz: + ring = [] + for i in range(STONE_SEGS): + a = 2.0 * math.pi * i / STONE_SEGS + ring.append( + bm.verts.new( + ( + loc[0] + radius * math.cos(a), + loc[1] + y_off, + axle_z + radius * math.sin(a), + ) + ) + ) + rings.append(ring) + for i in range(len(rings) - 1): + a, b = rings[i], rings[i + 1] + for k in range(STONE_SEGS): + kn = (k + 1) % STONE_SEGS + face = bm.faces.new((a[k], a[kn], b[kn], b[k])) + face.material_index = STONE_IDX + for ring, y_off, inward in ( + (rings[0], rings_yz[0][0], True), + (rings[-1], rings_yz[-1][0], False), + ): + c = bm.verts.new((loc[0], loc[1] + y_off, axle_z)) + for k in range(STONE_SEGS): + kn = (k + 1) % STONE_SEGS + if inward: + face = bm.faces.new((c, ring[kn], ring[k])) + else: + face = bm.faces.new((c, ring[k], ring[kn])) + face.material_index = STONE_IDX + return [v for ring in rings for v in ring] def pack_uvs(bm, margin=0.08): @@ -226,131 +324,167 @@ def pack_uvs(bm, margin=0.08): ) -def build_grindstone_mesh(name, bevel_offset, bevel_segments): +def build_grindstone_mesh( + name, bevel_offset, bevel_segments, + float_crank=False, no_dip=False, short_legs=False, float_legs=False, + narrow_trough=False, +): bm = bmesh.new() try: wood = [] - stone = [] metal = [] + dip = DIP + tub_z0 = TROUGH_Z0 - (0.040 if no_dip else 0.0) + trough_top = TROUGH_Z0 + TROUGH_H + stone_bottom = trough_top - dip + axle_z = STONE_R + stone_bottom + axle_len = 2.0 * FRAME_Y + 2.0 * AXLE_OVER + axle_end = axle_len / 2.0 + # Inward, not proud: an outward offset grows the Y AABB and + # trips exit 8 before the crank-join budget. + crank_y = axle_end - (0.022 if float_crank else 0.012) + shoe_z = 0.05 if short_legs else 0.0 + bearing_h = 0.090 + bearing_xz = (0.078, bearing_h) + king_xy = (0.052, KING_Y) + # Diagonals *are* the legs. A tall stump beside an angled timber + # paints a see-through triangle on the stump face. Bury a short + # tenon in the shoe (never proud of the iron) and start the + # diagonal on the shoe top so the angled end-cap cannot pierce + # Z=0. + tenon_z = shoe_z + SHOE_H * 0.50 + diag_z = shoe_z + SHOE_H + 0.008 + if float_legs: + tenon_z = shoe_z + SHOE_H + 0.070 + diag_z = shoe_z + SHOE_H + 0.070 + sill_h = 0.050 + sill_bottom = ( + shoe_z + SHOE_H + 0.055 if float_legs else shoe_z + SHOE_H * 0.40 + ) + sill_z = sill_bottom + sill_h / 2.0 for y in (-FRAME_Y, FRAME_Y): + # King post fills the A-crotch so the bearing is not a cube + # perched on two sticks with a triangular hole under it. wood.extend( add_oriented_box( bm, - (-LEG_SPREAD, y, 0.022), - (0.0, y, AXLE_Z), - LEG_XY, + (0.0, y, sill_z), + (0.0, y, axle_z + bearing_h * 0.35), + king_xy, WOOD_IDX, ) ) wood.extend( - add_oriented_box( + add_box( bm, - (LEG_SPREAD, y, 0.022), - (0.0, y, AXLE_Z), - LEG_XY, + (0.0, y, axle_z), + (bearing_xz[0], 0.058, bearing_xz[1]), WOOD_IDX, ) ) + # Tie beam sits on the shoes and occupies the A-foot. wood.extend( add_box( bm, - (0.0, y, 0.072), - (LEG_SPREAD * 1.55, 0.038, 0.038), + (0.0, y, sill_z), + (LEG_SPREAD * 2.0 + SHOE_XY[0], SILL_Y, sill_h), WOOD_IDX, ) ) + for x in (-LEG_SPREAD, LEG_SPREAD): + if not float_legs: + wood.extend( + add_box( + bm, + (x, y, tenon_z), + (LEG_XY[0] * 0.92, LEG_XY[1] * 0.92, SHOE_H * 0.90), + WOOD_IDX, + ) + ) + wood.extend( + add_oriented_box( + bm, + (x, y, diag_z), + (0.0, y, axle_z - 0.010), + LEG_XY, + WOOD_IDX, + ) + ) + metal.extend( + add_box( + bm, + (x, y, shoe_z + SHOE_H / 2.0), + (SHOE_XY[0], SHOE_XY[1], SHOE_H), + METAL_IDX, + ) + ) + + for x in (-LEG_SPREAD, LEG_SPREAD): + # End stretchers tenon through both sills at sill height. + # A higher independent Z left them floating as extra cubes. wood.extend( add_box( bm, - (0.0, y, AXLE_Z), - (0.090, 0.070, 0.070), + (x, 0.0, sill_z), + (0.038, FRAME_Y * 2.0, sill_h * 0.70), WOOD_IDX, ) ) - wood.extend( - add_box( - bm, - (0.0, 0.0, 0.078), - (0.070, FRAME_Y * 2.0 + 0.04, 0.040), - WOOD_IDX, - ) + tw = TROUGH_W * (0.58 if narrow_trough else 1.0) + chamfer = [] + basin_verts = add_basin( + bm, + (0.0, 0.0, tub_z0 + TROUGH_H / 2.0), + (TROUGH_L, tw, TROUGH_H), + TROUGH_WALL, + WOOD_IDX, ) - wood.extend( - add_box( - bm, - (0.0, 0.0, TRAY_H / 2.0 + 0.004), - (TRAY_L, TRAY_W, TRAY_H), - WOOD_IDX, - ) - ) - wood.extend( + chamfer.extend(basin_verts) + + chamfer.extend( add_cyl( bm, - (0.0, FRAME_Y + AXLE_LEN * 0.28, AXLE_Z + CRANK_ARM), - 0.014, + (0.0, crank_y + HANDLE_L * 0.15, axle_z + CRANK_ARM), + 0.016, HANDLE_L, - 8, + 10, WOOD_IDX, euler=(math.pi / 2.0, 0.0, 0.0), ) ) + wood.extend(chamfer) - if bevel_offset > 0.0: - edges = list({e for v in wood for e in v.link_edges}) - ret = bmesh.ops.bevel( - bm, - geom=edges, - offset=bevel_offset, - segments=bevel_segments, - profile=0.5, - affect="EDGES", - clamp_overlap=True, - ) - for f in ret.get("faces") or []: - f.material_index = WOOD_IDX - - stone.extend( - add_cyl( - bm, - (0.0, 0.0, AXLE_Z), - STONE_R, - STONE_T, - STONE_SEGS, - STONE_IDX, - euler=(math.pi / 2.0, 0.0, 0.0), - ) - ) + add_stone(bm, (0.0, 0.0), axle_z) metal.extend( add_cyl( bm, - (0.0, 0.0, AXLE_Z), + (0.0, 0.0, axle_z), AXLE_R, - AXLE_LEN, - 10, + axle_len, + 12, METAL_IDX, euler=(math.pi / 2.0, 0.0, 0.0), ) ) - for y in (-STONE_T / 2.0 - HUB_T / 2.0 + 0.004, STONE_T / 2.0 + HUB_T / 2.0 - 0.004): + for ysign in (-1.0, 1.0): metal.extend( add_cyl( bm, - (0.0, y, AXLE_Z), + (0.0, ysign * (STONE_T / 2.0 + HUB_T / 2.0 - HUB_SEAT), axle_z), HUB_R, HUB_T, - 12, + 14, METAL_IDX, euler=(math.pi / 2.0, 0.0, 0.0), ) ) - crank_y = FRAME_Y + AXLE_LEN * 0.22 metal.extend( add_box( bm, - (0.0, crank_y, AXLE_Z + CRANK_ARM / 2.0), + (0.0, crank_y, axle_z + CRANK_ARM / 2.0), (0.018, 0.018, CRANK_ARM + 0.02), METAL_IDX, ) @@ -358,37 +492,54 @@ def build_grindstone_mesh(name, bevel_offset, bevel_segments): metal.extend( add_cyl( bm, - (0.0, crank_y, AXLE_Z + CRANK_ARM), + (0.0, crank_y, axle_z + CRANK_ARM), 0.010, HANDLE_L * 0.55, - 8, + 10, METAL_IDX, euler=(math.pi / 2.0, 0.0, 0.0), ) ) - for y in (-FRAME_Y, FRAME_Y): - for x in (-LEG_SPREAD, LEG_SPREAD): - metal.extend( - add_box( - bm, - (x, y, 0.018), - (0.070, 0.070, 0.036), - METAL_IDX, - ) + + if short_legs: + metal.extend( + add_box( + bm, + (0.0, 0.0, 0.005), + (0.020, 0.020, 0.010), + METAL_IDX, ) + ) xs = [v.co.x for v in bm.verts] ys = [v.co.y for v in bm.verts] - zs = [v.co.z for v in bm.verts] - rcx = 0.5 * (min(xs) + max(xs)) - rcy = 0.5 * (min(ys) + max(ys)) - zmin = min(zs) + cx = 0.5 * (min(xs) + max(xs)) + cy = 0.5 * (min(ys) + max(ys)) for v in bm.verts: - v.co.x -= rcx - v.co.y -= rcy - v.co.z -= zmin - if v.co.z < 0.0: - v.co.z = 0.0 + v.co.x -= cx + v.co.y -= cy + + if bevel_offset > 0.0: + edges = [] + trough_verts = set(basin_verts) + for e in {e for v in trough_verts for e in v.link_edges}: + if min(v.co.z for v in e.verts) < tub_z0 - 0.002: + continue + if any(f.material_index != WOOD_IDX for f in e.link_faces): + continue + edges.append(e) + if edges: + ret = bmesh.ops.bevel( + bm, + geom=edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + for f in ret.get("faces") or []: + f.material_index = WOOD_IDX pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) @@ -406,13 +557,33 @@ def build_grindstone_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 @@ -459,6 +630,300 @@ 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): + 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) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= 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 zfight_pairs(me): + data = [ + (p.center.copy(), p.normal.copy(), frozenset(p.vertices)) + for p in me.polygons + ] + eps2 = ZFIGHT_EPS * ZFIGHT_EPS + count = 0 + for i in range(len(data)): + ci, ni, vi = data[i] + for j in range(i + 1, len(data)): + cj, nj, vj = data[j] + if (cj - ci).length_squared > eps2: + continue + if abs(ni.dot(nj)) <= ZFIGHT_COS: + continue + if vi & vj: + continue + count += 1 + return count + + +def shells(me): + neighbors = [[] for _ in range(len(me.vertices))] + for edge in me.edges: + a, b = edge.vertices + neighbors[a].append(b) + neighbors[b].append(a) + seen = [False] * len(me.vertices) + groups = [] + for start in range(len(me.vertices)): + if seen[start]: + continue + seen[start] = True + stack = [start] + group = [] + while stack: + current = stack.pop() + group.append(current) + for nxt in neighbors[current]: + if not seen[nxt]: + seen[nxt] = True + stack.append(nxt) + groups.append(group) + return groups + + +def shell_aabb(me, group): + pts = [me.vertices[i].co for i in group] + return ( + min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts), + max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts), + ) + + +def mat_of(me, group): + member = set(group) + for p in me.polygons: + if all(i in member for i in p.vertices): + return p.material_index + return -1 + + +def support_audit(me): + groups = shells(me) + shoes = [] + for g in groups: + if mat_of(me, g) != METAL_IDX: + continue + a = shell_aabb(me, g) + if a[5] < 0.08 and (a[4] - a[1]) > 0.04 and (a[3] - a[0]) > 0.04: + shoes.append(a) + shoe_z = min((a[2] for a in shoes), default=99.0) + return {"shoes": len(shoes), "shoe_z": shoe_z} + + +def trough_floor_z(me): + """Z of the wide shallow wood shell (the trough floor).""" + groups = shells(me) + best = 99.0 + for g in groups: + if mat_of(me, g) != WOOD_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + if dx > TROUGH_L * 0.7 and dy > TROUGH_W * 0.55: + best = min(best, a[2]) + return best + + +def stone_audit(me): + stone_pts = [] + groups = shells(me) + for g in groups: + if mat_of(me, g) != STONE_IDX: + continue + stone_pts.extend(me.vertices[i].co for i in g) + if not stone_pts: + return { + "dia": 0.0, "thick": 0.0, "zmin": 99.0, "dip": -1.0, "trough_z": 99.0, + } + xs = [p.x for p in stone_pts] + ys = [p.y for p in stone_pts] + zs = [p.z for p in stone_pts] + dia = max(max(xs) - min(xs), max(zs) - min(zs)) + thick = max(ys) - min(ys) + zmin = min(zs) + tb = None + for g in groups: + if mat_of(me, g) != WOOD_IDX: + continue + a = shell_aabb(me, g) + dx, dy = a[3] - a[0], a[4] - a[1] + if abs(dx - TROUGH_L) < 0.08 and dy > 0.08 and a[2] < 0.15: + tb = a + break + trough_z = tb[2] if tb else trough_floor_z(me) + trough_top = tb[5] if tb else (TROUGH_Z0 + TROUGH_H) + return { + "dia": dia, + "thick": thick, + "zmin": zmin, + "dip": trough_top - zmin, + "trough_z": trough_z, + } + + +def crank_join(me): + """Gap from crank-arm verts to the axle shell.""" + groups = shells(me) + axle = None + crank = None + for g in groups: + if mat_of(me, g) != METAL_IDX: + continue + a = shell_aabb(me, g) + dy, dx, dz = a[4] - a[1], a[3] - a[0], a[5] - a[2] + if dy > 0.30 and dx < 0.06 and dz < 0.06: + axle = g + if dz > CRANK_ARM * 0.6 and dy < 0.05 and dx < 0.05: + crank = g + if axle is None or crank is None: + return 99.0 + axle_pts = [me.vertices[i].co for i in axle] + crank_pts = [me.vertices[i].co for i in crank] + best = 99.0 + for c in crank_pts: + d = min((c - a).length for a in axle_pts) + if d < best: + best = d + return best + + +def trough_sill_gap(me): + """Daylight in Y between the trough and each A-frame sill. + + Per-side, not a mixed max. The crank handle makes the mesh AABB + asymmetric, so a global |ymin|/ymax vs min(sill inner) compares the + trough's long side against the opposite sill and reports overlap + while the other side shows daylight. + Overlap on a side is 0. Missing shells return 99. + """ + groups = shells(me) + trough = None + plus_sill = None + minus_sill = None + for g in groups: + if mat_of(me, g) != WOOD_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + cy = 0.5 * (a[1] + a[4]) + if abs(dx - TROUGH_L) < 0.08 and dy > 0.08 and a[2] < 0.15: + trough = a + if ( + dx > LEG_SPREAD * 1.2 + and dy > SILL_Y * 0.55 + and abs(cy) > FRAME_Y * 0.40 + and a[5] < 0.14 + ): + if cy > 0.0: + plus_sill = a + else: + minus_sill = a + if trough is None or plus_sill is None or minus_sill is None: + return 99.0 + gap_plus = plus_sill[1] - trough[4] + gap_minus = trough[1] - minus_sill[4] + return max(0.0, gap_plus, gap_minus) + + +def shoe_wood_gap(me): + """Worst gap from an iron shoe to wood. + + Overlapping solids count as 0. Vert-vert and shoe-corner-to-surface + both lie about a shoe-width away even when a tenon is buried in the + iron; overlap is the join. + """ + groups = shells(me) + shoes = [] + for g in groups: + if mat_of(me, g) != METAL_IDX: + continue + a = shell_aabb(me, g) + if a[5] < 0.08 and (a[4] - a[1]) > 0.04 and (a[3] - a[0]) > 0.04: + shoes.append(g) + if not shoes: + return 99.0 + bm_wood = bmesh.new() + try: + bm_wood.from_mesh(me) + drop = [f for f in bm_wood.faces if f.material_index != WOOD_IDX] + if drop: + bmesh.ops.delete(bm_wood, geom=drop, context="FACES") + if not bm_wood.faces: + return 99.0 + tree_wood = BVHTree.FromBMesh(bm_wood) + worst = 0.0 + for g in shoes: + bm_s = bmesh.new() + try: + bm_s.from_mesh(me) + member = set(g) + drop_s = [ + f for f in bm_s.faces + if not all(v.index in member for v in f.verts) + ] + if drop_s: + bmesh.ops.delete(bm_s, geom=drop_s, context="FACES") + if not bm_s.faces: + worst = max(worst, 99.0) + continue + tree_s = BVHTree.FromBMesh(bm_s) + if tree_wood.overlap(tree_s): + continue + best = 99.0 + for i in g: + hit = tree_wood.find_nearest(me.vertices[i].co) + if hit[0] is None: + continue + best = min(best, hit[3]) + if best > worst: + worst = best + finally: + bm_s.free() + return worst + finally: + bm_wood.free() + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, STONE_R)) + bm.to_mesh(me) + me.update() + finally: + bm.free() + + def make_lod(obj, name, ratio, skip_decimate): mesh = obj.data.copy() lod = bpy.data.objects.new(name, mesh) @@ -545,15 +1010,40 @@ def export_unity(path, objects): ) -def check(skip_decimate): +def check( + skip_decimate, lift_z=False, stray_vert=False, + float_crank=False, no_dip=False, short_legs=False, float_legs=False, + narrow_trough=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_grindstone_mesh("GrindstoneLow", bevel_offset=0.004, bevel_segments=2) - high = build_grindstone_mesh("GrindstoneHigh", bevel_offset=0.004, bevel_segments=4) - wood = principled("GrindstoneWood", (0.36, 0.19, 0.07, 1.0), 0.0, 0.60) - stone = principled("GrindstoneStone", (0.46, 0.44, 0.41, 1.0), 0.0, 0.86) - metal = principled("GrindstoneIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38) + flags = dict( + float_crank=float_crank, no_dip=no_dip, + short_legs=short_legs, float_legs=float_legs, + narrow_trough=narrow_trough, + ) + low = build_grindstone_mesh("GrindstoneLow", 0.004, 2, **flags) + high = build_grindstone_mesh("GrindstoneHigh", 0.004, 4, **flags) + wood = principled( + "GrindstoneWood", (0.38, 0.20, 0.08, 1.0), 0.0, 0.62, + noise_scale=6.5, wear=(0.22, 0.11, 0.04, 1.0), + ) + stone = principled( + "GrindstoneStone", (0.40, 0.36, 0.30, 1.0), 0.0, 0.92, + noise_scale=14.0, wear=(0.28, 0.24, 0.18, 1.0), + ) + metal = principled( + "GrindstoneIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38, + noise_scale=5.0, wear=(0.05, 0.05, 0.06, 1.0), + ) assign_slots(low, wood, stone, metal) assign_slots(high, wood, stone, metal) + if stray_vert: + add_stray_vert(low.data) + if lift_z: + for v in low.data.vertices: + v.co.z += LIFT_Z + low.data.update() + bpy.context.view_layer.update() if low.data is None or len(low.data.polygons) < 6: return fail("grindstone mesh did not build", 3), None, None, None, None, None @@ -585,9 +1075,7 @@ def check(skip_decimate): r1 = lod1_tris / base_tris if base_tris else 0.0 r2 = lod2_tris / base_tris if base_tris else 0.0 - collider_src = build_grindstone_mesh( - "GrindstoneColSrc", bevel_offset=0.0, bevel_segments=1 - ) + collider_src = build_grindstone_mesh("GrindstoneColSrc", 0.0, 1, **flags) collider = convex_hull_collider(collider_src, "GrindstoneCollider") bpy.data.objects.remove(collider_src, do_unlink=True) col_tris = triangle_count(collider.data) @@ -601,6 +1089,14 @@ def check(skip_decimate): export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + st = stone_audit(low.data) + cj = crank_join(low.data) + sw = shoe_wood_gap(low.data) + ts = trough_sill_gap(low.data) + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " @@ -618,6 +1114,17 @@ def check(skip_decimate): f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}" ) + 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']} zfight={zf}" + ) + print( + f"measured supports shoes={sup['shoes']} shoe_z={sup['shoe_z']:.5f} " + f"stone_dia={st['dia']:.4f} stone_t={st['thick']:.4f} " + f"dip={st['dip']:.5f} trough_z={st['trough_z']:.5f} " + f"crank_join={cj:.5f} shoe_wood={sw:.5f} trough_sill={ts:.5f}" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -687,6 +1194,68 @@ 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"] or zf + ): + 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']} zfight={zf}", + 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 sup["shoes"] < 4 or sup["shoe_z"] > SHOE_Z_MAX: + return fail( + f"shoe supports {sup['shoes']} shoe_z={sup['shoe_z']:.5f} " + "(--short-legs is the designed fail)", + 16, + ), None, None, None, None, None + if cj > CRANK_JOIN: + return fail( + f"crank-axle gap {cj:.5f} > {CRANK_JOIN} " + "(--float-crank is the designed fail)", + 17, + ), None, None, None, None, None + if sw > SHOE_JOIN: + return fail( + f"shoe-wood gap {sw:.5f} > {SHOE_JOIN} " + "(--float-legs is the designed fail)", + 17, + ), None, None, None, None, None + if ts > TROUGH_SILL_JOIN: + return fail( + f"trough-sill gap {ts:.5f} > {TROUGH_SILL_JOIN} " + "(--narrow-trough is the designed fail)", + 18, + ), None, None, None, None, None + if not (DIP_MIN <= st["dip"] <= DIP_MAX): + return fail( + f"stone dip {st['dip']:.5f} not in [{DIP_MIN}, {DIP_MAX}] " + "(--no-dip is the designed fail)", + 18, + ), None, None, None, None, None + if st["trough_z"] < TROUGH_FLOOR_Z_MIN: + return fail( + f"trough floor zmin {st['trough_z']:.5f} < {TROUGH_FLOOR_Z_MIN} " + "(tub must sit on the frame, not the dirt)", + 18, + ), None, None, None, None, None + if abs(st["dia"] - STONE_DIA) > STONE_DIA_TOL: + return fail( + f"stone diameter {st['dia']:.4f} off {STONE_DIA}", + 19, + ), None, None, None, None, None + if abs(st["thick"] - STONE_T) > STONE_T_TOL: + return fail( + f"stone thickness {st['thick']:.4f} off {STONE_T}", + 19, + ), None, None, None, None, None return 0, low, high, stone, tex, collider @@ -707,7 +1276,7 @@ def render_still(low, wood, tex, path, engine): ob.hide_render = True ob.hide_viewport = True - low.rotation_euler.z = math.radians(-16.0) + low.rotation_euler.z = math.radians(-22.0) low.rotation_euler.x = math.radians(0.0) floor_me = bpy.data.meshes.new("Floor") @@ -754,10 +1323,10 @@ def light(name, loc, energy, size, col, rot): cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (1.48, -1.82, 0.86) + cam.location = (1.55, -1.95, 0.92) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.28) + aim.location = (0.0, 0.0, 0.32) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -800,14 +1369,26 @@ def main(): p = argparse.ArgumentParser() p.add_argument("--output", default=None) p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) - p.add_argument( - "--skip-decimate", - action="store_true", - help="falsification: skip the LOD DECIMATE stage", - ) + p.add_argument("--skip-decimate", action="store_true") + p.add_argument("--lift-z", action="store_true") + p.add_argument("--stray-vert", action="store_true") + p.add_argument("--float-crank", action="store_true") + p.add_argument("--no-dip", action="store_true") + p.add_argument("--short-legs", action="store_true") + p.add_argument("--float-legs", action="store_true") + p.add_argument("--narrow-trough", action="store_true") args = p.parse_args(argv) - code, low, _high, hero_mat, tex, _col = check(args.skip_decimate) + code, low, _high, hero_mat, tex, _col = check( + args.skip_decimate, + lift_z=args.lift_z, + stray_vert=args.stray_vert, + float_crank=args.float_crank, + no_dip=args.no_dip, + short_legs=args.short_legs, + float_legs=args.float_legs, + narrow_trough=args.narrow_trough, + ) if code: return code if args.output: diff --git a/showcase/grindstone/preview.webp b/showcase/grindstone/preview.webp index 3a1bcf8342dbaf26fb6cf1f52eb7713cb7a251e7..90a7ba37ee93cfb9101abfcacbac177a57efe04d 100644 GIT binary patch literal 18166 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 literal 12192 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 From 558d16ca878b28828184de0abafca24fdcf289e5 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:27:09 -0400 Subject: [PATCH 3/4] fix(showcase): rebuild the wheelbarrow so the tray and wheel joints actually meet Walls sat on the floor and spokes were audited against floor slats. Seated walls, nested handles, and a hub-local spoke metric lock the joints. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- docs/gallery/assets/wheelbarrow-hero.webp | Bin 16936 -> 32834 bytes docs/gallery/index.html | 4 +- docs/gallery/wheelbarrow/index.html | 1025 +++++++++++++++++---- showcase/gallery.json | 4 +- showcase/wheelbarrow/README.md | 91 +- showcase/wheelbarrow/preview.webp | Bin 14414 -> 21432 bytes showcase/wheelbarrow/wheelbarrow.py | 995 ++++++++++++++++---- 7 files changed, 1727 insertions(+), 392 deletions(-) diff --git a/docs/gallery/assets/wheelbarrow-hero.webp b/docs/gallery/assets/wheelbarrow-hero.webp index 0cc48002777e579476cc791d3f5f34229e6931e1..44cfcb7b291edfb25edc2152f8fff23420189a54 100644 GIT binary patch literal 32834 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

qbkv~_Y4lfQ!! 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 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 5b045fe..93257e4 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -1024,7 +1024,7 @@

park-bench

wheelbarrow

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

-

witnesses Recomputed: 3088 tris, two materials with 308 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.418×0.463×0.594 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 106 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+

witnesses Recomputed: 2500 tris, two materials with 1094 wood and 190 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.556×0.630×0.572 m, zmin 0, hygiene 0, wall-floor seat 12 mm, spoke clearance 40 mm, LOD ratios in band (5.2 COLLAPSE leaner on LOD2), convex collider 90 tris, non-empty glTF. --skip-decimate exits 9 on LOD1; --stray-vert exits 15 on hygiene; --lift-z / --short-legs exit 16 on grounded; --fat-spokes / --float-walls exit 17 on joint fit; --pipe-rim exits 18 on tread aspect.

View example
@@ -1068,7 +1068,7 @@

hitching-post

grindstone

A procedural grindstone on a timber trestle through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

-

witnesses Recomputed: 1604 tris, three materials with 80 metal and 26 stone faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.630×0.615×0.575 m, LOD ratios in band, convex collider 130 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+

witnesses Recomputed: 928 tris, three materials with 216 wood / 160 stone / 180 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.710×0.419×0.610 m, zmin 0, hygiene 0, four shoes planted, trough seated in the sills, stone dip 26 mm, LOD ratios in band (5.2 COLLAPSE leaner on LOD2), convex collider 198 tris, non-empty glTF. --skip-decimate exits 9 on LOD1; --stray-vert exits 15 on hygiene; --lift-z / --short-legs exit 16 on grounded; --float-crank / --float-legs exit 17 on joint fit; --no-dip / --narrow-trough exit 18 on seat.

View example
diff --git a/docs/gallery/wheelbarrow/index.html b/docs/gallery/wheelbarrow/index.html index 0cf5734..0e89664 100644 --- a/docs/gallery/wheelbarrow/index.html +++ b/docs/gallery/wheelbarrow/index.html @@ -253,28 +253,42 @@

wheelbarrow

wheelbarrow render

Rendered headless by the example itself — click to zoom.

-
witnesses Recomputed: 3088 tris, two materials with 308 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.418×0.463×0.594 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 106 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
witnesses Recomputed: 2500 tris, two materials with 1094 wood and 190 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.556×0.630×0.572 m, zmin 0, hygiene 0, wall-floor seat 12 mm, spoke clearance 40 mm, LOD ratios in band (5.2 COLLAPSE leaner on LOD2), convex collider 90 tris, non-empty glTF. --skip-decimate exits 9 on LOD1; --stray-vert exits 15 on hygiene; --lift-z / --short-legs exit 16 on grounded; --fat-spokes / --float-walls exit 17 on joint fit; --pipe-rim exits 18 on tread aspect.
blender --background --python showcase/wheelbarrow/wheelbarrow.py --
-

A showcase piece, not an example. Procedural wooden wheelbarrow (staved U-tray, iron straps, single spoked wheel, rear legs, handles) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

A showcase piece, not an example. Procedural wooden wheelbarrow (two chassis shafts that are the handles, box tray with overlapping floor slats, iron straps, single flat-tread spoked wheel, rear legs) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

The tray walls seat into the floor rather than sitting on top of it, so a wood bevel cannot open daylight at the joint. Handles are thinner in Y than the chassis they tenon into. The tyre is a flat felloe wrap, not a torus.

It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.

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 | 2980–3200 | 3088 / 3088 / 3088 | | 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.2014 | | Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 308 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.418, 0.463, 0.594) m ± 0.01 | (1.4176, 0.4627, 0.5937), zmin 0 | | Collider tris | ≤ 140 | 106 | | Export | written, size > 0 | 227404 / 227404 / 227388 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 16 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.

+

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 2300–2800 | 2500 / 2500 / 2500 | | 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.2104 | | Materials | exactly 2 distinct; ≥700 wood, ≥180 metal faces | 2 slots; 1094 / 190 | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.558, 0.630, 0.574) m ± 0.015 | (1.5556, 0.6300, 0.5720), zmin 0 | | Collider tris | ≤ 220 | 90 | | Export | written, size > 0 | 187576 / 187576 / 187560 bytes |

+

Hygiene

+

Recomputed from the generated mesh, not asserted about the script.

+

| Axis | Declared | Measured (all three) | | --- | --- | --- | | Non-manifold edges | 0 | 0 | | Loose verts / edges | 0 / 0 | 0 / 0 | | Doubles merged at 1e-5 | 0 | 0 | | Zero-area faces | 0 | 0 | | N-gons | 0 | 0 | | Coplanar disjoint face pairs | 0 | 0 | | Grounded: zmin | within 1e-4 of 0 | 0.0000 | | Named supports: 2 shoes + tyre | each zmin ≤ 1e-3 | shoe_z 0.00000, tyre_z 0.00000 | | Tray size | (0.720, 0.532, 0.220) m ± 0.02 | (0.7200, 0.5320, 0.2200) |

+

Joint fit and seat

+

| Axis | Declared | Measured (all three) | | --- | --- | --- | | Spoke clearance (hub − spoke) | ≥ 0.010 m | 0.04000 | | Handle-tray overlap | gap ≤ 0.020 m | −0.07062 | | Wall-floor seat | ≥ 0.005 m | 0.01200 | | Metal-wood BVH gap | ≤ 0.010 m | 0.00000 | | Tread aspect (tyre width / radial) | ≥ 2.5 | 5.111 |

+

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is 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.

+

Falsifiers

+

Each violates one named budget. All eight were run on 4.5.11, 5.1.2 and 5.2.1 and returned the same code on each.

+

| Flag | Budget violated | Exit | | --- | --- | --- | | --skip-decimate | LOD1 ratio band | 9 | | --stray-vert | loose vertex count is 0 | 15 | | --lift-z | bounding box zmin is 0 | 16 | | --short-legs | named shoe supports at Z=0 | 16 | | --fat-spokes | spoke clearance inside the hub | 17 | | --float-walls | wall-floor seat | 17 | | --pipe-rim | tread aspect (torus on a flat felloe) | 18 |

Run

blender --background --python wheelbarrow.py --
 blender --background --python wheelbarrow.py -- --skip-decimate
+blender --background --python wheelbarrow.py -- --stray-vert
+blender --background --python wheelbarrow.py -- --lift-z
+blender --background --python wheelbarrow.py -- --short-legs
+blender --background --python wheelbarrow.py -- --fat-spokes
+blender --background --python wheelbarrow.py -- --float-walls
+blender --background --python wheelbarrow.py -- --pipe-rim
 blender --background --python wheelbarrow.py -- --output barrow.png
-

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

+

Smoke passes no flags.

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 wood/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 |

+

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path. 1519 are the hygiene and joint-fit family.

+

| 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 a face-count 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 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, z-fight | | 16 | Not grounded: bounding box zmin off 0, or a named shoe/tyre floats | | 17 | Joint fit: spoke clearance, handle join, wall-floor seat, or metal-wood gap | | 18 | Seat: tread aspect of the tyre | | 19 | Tray size off the stated real-world dimensions |

Source

@@ -284,14 +298,21 @@

Source

"""Game-ready wooden wheelbarrow — a showcase piece, not an example.
 
-Asserts budget conformance of a procedural wheelbarrow (staved U-tray,
-iron straps, single spoked wheel, rear legs, handles) after composing
+Asserts budget conformance of a procedural wheelbarrow (two chassis
+shafts, box tray, flat-tread spoked wheel, rear legs) after composing
 shipped pipeline pieces: bmesh construction, UVs, two materials,
 high-to-low 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.
+DECIMATE stage so the LOD-ratio budget fails. ``--stray-vert`` adds a
+loose vertex so the mesh-hygiene budget fails. ``--lift-z`` raises the
+mesh so the grounded-zmin budget fails. ``--short-legs`` lifts only the
+shoes so the named-support budget fails while the wheel still grounds
+the AABB. ``--fat-spokes`` thickens the spokes to the hub diameter so
+joint-fit fails. ``--pipe-rim`` swaps the flat felloe/tyre for a torus
+so the tread-aspect seat budget fails. ``--float-walls`` lifts the
+tray walls off the floor so the wall-floor seat budget fails.
 
 No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
 are not byte-identical across Blender versions — the LOD gate is a
@@ -311,6 +332,7 @@ 

Source

import bmesh import bpy from mathutils import Euler, Vector +from mathutils.bvhtree import BVHTree _REPO = os.path.abspath( os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) @@ -319,24 +341,56 @@

Source

sys.dont_write_bytecode = True import gallery_framing # noqa: E402 -RIM_MAJOR = 0.155 -RIM_MINOR = 0.020 -HUB_R = 0.028 -HUB_W = 0.038 +# Wheel: flat-tread wooden felloe + iron tyre. Torus rims read as bicycle +# tyres; the cart pass already killed that class. +RIM_MAJOR = 0.170 +RIM_RADIAL = 0.014 +RIM_W = 0.042 +TYRE_T = 0.007 +TYRE_BITE = 0.002 +TYRE_W = 0.046 +HUB_R = 0.032 +HUB_W = 0.044 N_SPOKES = 8 SPOKE_T = 0.014 -TRAY_L = 0.70 -TRAY_R = 0.205 -STAVE_T = 0.028 -N_STAVES = 7 -A_SPAN = 1.38 -HANDLE_L = 0.50 -IRON_T = 0.014 - -BBOX_TOL = 0.01 -OUTER_SIZE = (1.418, 0.463, 0.594) -BASE_TRIS_MIN = 2980 -BASE_TRIS_MAX = 3200 +WHEEL_X = 0.58 +WHEEL_Z = RIM_MAJOR + RIM_RADIAL + TYRE_T +RIM_MINOR_PIPE = 0.020 + +# Chassis shafts are the handles. They run under the tray, then converge +# into forks at the axle. Tray, legs, and wheel all hang off this frame. +SHAFT_Y = 0.255 +SHAFT_W = 0.036 +SHAFT_H = 0.044 +SHAFT_Z = 0.280 +HANDLE_X = -0.78 +HANDLE_Z = 0.56 +TRAY_X0 = -0.32 +TRAY_X1 = 0.40 +TRAY_L = TRAY_X1 - TRAY_X0 +TRAY_W = 2.0 * SHAFT_Y +FLOOR_T = 0.022 +N_FLOOR = 5 +# Negative: slats overlap so the wood bevel cannot open daylight +# through the tray floor. +SLAT_GAP = -0.003 +WALL_H = 0.22 +WALL_T = 0.022 +WALL_SEAT = 0.012 +HANDLE_SEAT = 0.055 +FRONT_H = 0.26 +REAR_H = 0.10 +FORK_Y = HUB_W / 2.0 + 0.016 +LEG_X = -0.06 +SHOE_H = 0.024 +SHOE_XY = (0.058, 0.050) + +BBOX_TOL = 0.015 +OUTER_SIZE = (1.558, 0.630, 0.574) +TRAY_SIZE = (0.720, 0.532, 0.220) +TRAY_SIZE_TOL = (0.02, 0.02, 0.02) +BASE_TRIS_MIN = 2300 +BASE_TRIS_MAX = 2800 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -346,11 +400,23 @@

Source

MATERIAL_COUNT = 2 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 140 +COLLIDER_TRIS_MAX = 220 BAKE_RES = 256 CAGE_EXTRUSION = 0.08 -METAL_FACES_MIN = 24 -WOOD_FACES_MIN = 24 +METAL_FACES_MIN = 180 +WOOD_FACES_MIN = 700 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.999 +LIFT_Z = 0.05 +GAP_MAX = 0.010 +SPOKE_CLEAR_MIN = 0.010 +TREAD_ASPECT_MIN = 2.5 +HANDLE_JOIN = 0.020 +WALL_SEAT_MIN = 0.005 +SHOE_Z_MAX = 1e-3 WOOD_IDX = 0 METAL_IDX = 1 @@ -424,7 +490,7 @@

Source

geo = bmesh.ops.create_cone( bm, cap_ends=True, - cap_tris=False, + cap_tris=True, segments=segments, radius1=radius, radius2=radius, @@ -441,7 +507,46 @@

Source

return verts +def add_ring(bm, loc, r_mid, radial_t, width, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + """Flat-sided ring (felloe / tyre). Quads only; box cross-section.""" + r_in = r_mid - radial_t + r_out = r_mid + radial_t + hw = width / 2.0 + rings = [] + 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() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + return verts + + def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): + """Torus rim — used only by the --pipe-rim falsifier.""" n_major = 16 n_minor = 8 rings = [] @@ -455,7 +560,6 @@

Source

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): @@ -472,23 +576,47 @@

Source

return verts -def add_wheel_wood(bm, loc, wood): - wood.extend( - add_rim(bm, loc, RIM_MAJOR, RIM_MINOR, WOOD_IDX, euler=(math.pi / 2.0, 0.0, 0.0)) - ) +def add_wheel(bm, loc, wood, metal, pipe_rim, spoke_t): + if pipe_rim: + # Metal is the outer tyre so named-support zmin stays 0 and the + # falsifier can reach the tread-aspect gate (exit 18). An inner + # metal torus floats and trips exit 16 first. + wood.extend( + add_rim( + bm, loc, RIM_MAJOR, RIM_MINOR_PIPE * 0.45, WOOD_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + ) + metal.extend( + add_rim( + bm, loc, RIM_MAJOR + 0.004, RIM_MINOR_PIPE, METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + ) + else: + wood.extend( + 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_BITE / 2.0, + TYRE_T / 2.0 + TYRE_BITE / 2.0, + TYRE_W, 16, METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + ) wood.extend( add_cyl( - bm, - loc, - HUB_R, - HUB_W, - 12, - WOOD_IDX, + bm, loc, HUB_R, HUB_W, 12, WOOD_IDX, euler=(math.pi / 2.0, 0.0, 0.0), ) ) - inner = HUB_R + 0.008 - outer = RIM_MAJOR - RIM_MINOR * 0.55 + 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): @@ -500,45 +628,19 @@

Source

cz = loc[2] + dz * mid_r wood.extend( add_box( - bm, - (cx, cy, cz), - (slen, SPOKE_T * 0.85, SPOKE_T), - WOOD_IDX, + bm, (cx, cy, cz), (slen, spoke_t * 0.85, spoke_t), WOOD_IDX, euler=(0.0, -a, 0.0), ) ) - - -def add_wheel_metal(bm, loc, metal): - metal.extend( - add_rim( - bm, - loc, - RIM_MAJOR + 0.004, - RIM_MINOR * 0.45, - METAL_IDX, - euler=(math.pi / 2.0, 0.0, 0.0), - ) - ) metal.extend( add_cyl( - bm, - loc, - HUB_R + 0.010, - 0.016, - 12, - METAL_IDX, + bm, loc, HUB_R + 0.010, 0.016, 12, METAL_IDX, euler=(math.pi / 2.0, 0.0, 0.0), ) ) metal.extend( add_cyl( - bm, - loc, - 0.014, - 0.10, - 8, - METAL_IDX, + bm, loc, 0.014, FORK_Y * 2.0 + 0.04, 10, METAL_IDX, euler=(math.pi / 2.0, 0.0, 0.0), ) ) @@ -587,149 +689,198 @@

Source

) -def build_barrow_mesh(name, bevel_offset, bevel_segments): +def build_barrow_mesh( + name, bevel_offset, bevel_segments, + pipe_rim=False, fat_spokes=False, short_legs=False, float_walls=False, +): bm = bmesh.new() try: - wood = [] + body = [] metal = [] - zc = 0.26 + TRAY_R - cx = 0.0 - - def pt(a, x): - return ( - x, - TRAY_R * math.sin(a), - zc - TRAY_R * math.cos(a), - ) + spoke_t = HUB_R * 2.2 if fat_spokes else SPOKE_T + shoe_z = 0.05 if short_legs else 0.0 + shoe_top = shoe_z + SHOE_H + floor_z = SHAFT_Z + SHAFT_H / 2.0 + FLOOR_T / 2.0 + floor_top = SHAFT_Z + SHAFT_H / 2.0 + FLOOR_T + seat = -0.003 if float_walls else WALL_SEAT + wall_z = floor_top - seat + WALL_H / 2.0 + front_z = floor_top - seat + FRONT_H / 2.0 + rear_z = floor_top - seat + REAR_H / 2.0 - gap = 0.12 - for i in range(N_STAVES): - t0 = i / N_STAVES - t1 = (i + (1.0 - gap)) / N_STAVES - a0 = -A_SPAN + 2.0 * A_SPAN * t0 - a1 = -A_SPAN + 2.0 * A_SPAN * t1 - mid = 0.5 * (a0 + a1) - chord = TRAY_R * abs(a1 - a0) * 0.95 - wood.extend( - add_box( + for ysign in (-1.0, 1.0): + y = ysign * SHAFT_Y + fy = ysign * FORK_Y + body.extend( + add_oriented_box( bm, - pt(mid, cx), - (TRAY_L, chord, STAVE_T), + (HANDLE_X, y, HANDLE_Z), + (TRAY_X0 + HANDLE_SEAT, y, SHAFT_Z), + (SHAFT_W - 0.010, SHAFT_H - 0.010), WOOD_IDX, - euler=(mid, 0.0, 0.0), ) ) - wood.extend( - add_box( + body.extend( + add_oriented_box( bm, - pt(mid, cx + TRAY_L / 2.0 + 0.014), - (0.028, chord * 1.02, STAVE_T * 1.05), + (TRAY_X0, y, SHAFT_Z), + (TRAY_X1, y, SHAFT_Z), + (SHAFT_W, SHAFT_H), WOOD_IDX, - euler=(mid, 0.0, 0.0), ) ) - - rim_y = TRAY_R * math.sin(A_SPAN) - rim_z = zc - TRAY_R * math.cos(A_SPAN) - for ysign in (-1.0, 1.0): - start = ( - cx - TRAY_L / 2.0 + 0.05, - ysign * rim_y, - rim_z, + body.extend( + add_oriented_box( + bm, + (TRAY_X1, y, SHAFT_Z), + (WHEEL_X, fy, WHEEL_Z), + (SHAFT_W, SHAFT_H), + WOOD_IDX, + ) + ) + body.extend( + add_oriented_box( + bm, + (LEG_X, y, SHAFT_Z), + (LEG_X + 0.02, ysign * (SHAFT_Y + 0.035), shoe_top), + (0.034, 0.034), + WOOD_IDX, + ) ) - end = ( - cx - TRAY_L / 2.0 - HANDLE_L, - ysign * (rim_y + 0.015), - 0.56, + metal.extend( + add_box( + bm, + (LEG_X + 0.02, ysign * (SHAFT_Y + 0.035), shoe_z + SHOE_H / 2.0), + (SHOE_XY[0], SHOE_XY[1], SHOE_H), + METAL_IDX, + ) ) - wood.extend( - add_oriented_box(bm, start, end, (0.030, 0.030), WOOD_IDX) + metal.extend( + add_box( + bm, + (WHEEL_X, ysign * (FORK_Y - SHAFT_W / 2.0 - 0.005), WHEEL_Z), + (0.040, 0.008, 0.040), + METAL_IDX, + ) ) - lx = cx - TRAY_L * 0.30 - for ysign in (-1.0, 1.0): - top = (lx, ysign * 0.11, zc - TRAY_R * 0.35) - bot = (lx - 0.03, ysign * 0.18, 0.02) - wood.extend(add_oriented_box(bm, top, bot, (0.034, 0.034), WOOD_IDX)) - wood.extend( + body.extend( add_oriented_box( bm, - (lx, -0.15, 0.13), - (lx, 0.15, 0.13), - (0.026, 0.026), + (LEG_X, -SHAFT_Y, 0.12), + (LEG_X, SHAFT_Y, 0.12), + (0.028, 0.028), WOOD_IDX, ) ) - - if bevel_offset > 0.0: - edges = list({e for v in wood for e in v.link_edges}) - ret = bmesh.ops.bevel( + body.extend( + add_oriented_box( bm, - geom=edges, - offset=bevel_offset, - segments=bevel_segments, - profile=0.5, - affect="EDGES", - clamp_overlap=True, + (TRAY_X1 + 0.04, -FORK_Y, WHEEL_Z + 0.02), + (TRAY_X1 + 0.04, FORK_Y, WHEEL_Z + 0.02), + (0.024, 0.024), + WOOD_IDX, ) - for f in ret.get("faces") or []: - f.material_index = WOOD_IDX - - wx = cx + TRAY_L / 2.0 + 0.04 - wz = RIM_MAJOR - add_wheel_wood(bm, (wx, 0.0, wz), wood) - add_wheel_metal(bm, (wx, 0.0, wz), metal) - - r_strap = TRAY_R + STAVE_T * 0.55 + 0.004 - for sx in (-TRAY_L * 0.28, TRAY_L * 0.22): - n = 11 - pts = [] - for k in range(n + 1): - a = -A_SPAN + 2.0 * A_SPAN * k / n - pts.append( - ( - cx + sx, - r_strap * math.sin(a), - zc - r_strap * math.cos(a), - ) + ) + + slat_span = TRAY_L + slat_w = (slat_span - (N_FLOOR - 1) * SLAT_GAP) / N_FLOOR + # Slats run under the side walls; walls sit on the floor, not + # beside a through-gap at the inner arris. + slat_y = TRAY_W + WALL_T + for i in range(N_FLOOR): + x = TRAY_X0 + slat_w / 2.0 + i * (slat_w + SLAT_GAP) + body.extend( + add_box( + bm, + (x, 0.0, floor_z), + (slat_w, slat_y, FLOOR_T), + WOOD_IDX, ) - for p0, p1 in zip(pts, pts[1:]): - metal.extend( - add_oriented_box(bm, p0, p1, (0.018, 0.010), METAL_IDX) + ) + for ysign in (-1.0, 1.0): + body.extend( + add_box( + bm, + (0.5 * (TRAY_X0 + TRAY_X1), ysign * (TRAY_W / 2.0), wall_z), + (TRAY_L, WALL_T, WALL_H), + WOOD_IDX, ) + ) + body.extend( + add_box( + bm, + (TRAY_X1, 0.0, front_z), + (WALL_T, TRAY_W + WALL_T, FRONT_H), + WOOD_IDX, + ) + ) + body.extend( + add_box( + bm, + (TRAY_X0, 0.0, rear_z), + (WALL_T, TRAY_W + WALL_T, REAR_H), + WOOD_IDX, + ) + ) - fy = 0.045 - for ysign in (-1.0, 1.0): + strap_r_y = TRAY_W / 2.0 + WALL_T / 2.0 + 0.004 + for sx in (TRAY_X0 + TRAY_L * 0.28, TRAY_X0 + TRAY_L * 0.72): + z0 = SHAFT_Z + SHAFT_H / 2.0 + z1 = z0 + FLOOR_T + WALL_H + for ysign in (-1.0, 1.0): + metal.extend( + add_oriented_box( + bm, + (sx, ysign * strap_r_y, z0), + (sx, ysign * strap_r_y, z1), + (0.018, 0.008), + METAL_IDX, + ) + ) metal.extend( add_oriented_box( bm, - (cx + TRAY_L / 2.0 - 0.05, ysign * fy, zc - TRAY_R * 0.55), - (wx, ysign * fy, wz), - (0.016, 0.016), + (sx, -strap_r_y, z0), + (sx, strap_r_y, z0), + (0.018, 0.008), METAL_IDX, ) ) - metal.extend( - add_box( + + if bevel_offset > 0.0: + edges = [] + for e in {e for v in body for e in v.link_edges}: + if min(v.co.z for v in e.verts) < shoe_top + 0.008: + continue + edges.append(e) + if edges: + ret = bmesh.ops.bevel( bm, - (lx - 0.03, ysign * 0.18, 0.012), - (0.055, 0.042, 0.022), - METAL_IDX, + geom=edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, ) - ) + for f in ret.get("faces") or []: + f.material_index = WOOD_IDX + + wood_wheel = [] + wheel_z = ( + RIM_MAJOR + 0.004 + RIM_MINOR_PIPE if pipe_rim else WHEEL_Z + ) + add_wheel( + bm, (WHEEL_X, 0.0, wheel_z), wood_wheel, metal, pipe_rim, spoke_t + ) xs = [v.co.x for v in bm.verts] ys = [v.co.y for v in bm.verts] - zs = [v.co.z for v in bm.verts] - rcx = 0.5 * (min(xs) + max(xs)) - rcy = 0.5 * (min(ys) + max(ys)) - zmin = min(zs) + cx = 0.5 * (min(xs) + max(xs)) + cy = 0.5 * (min(ys) + max(ys)) for v in bm.verts: - v.co.x -= rcx - v.co.y -= rcy - v.co.z -= zmin - if v.co.z < 0.0: - v.co.z = 0.0 + v.co.x -= cx + v.co.y -= cy pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) @@ -747,13 +898,33 @@

Source

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

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) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= 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 zfight_pairs(me): + """Disjoint faces sharing a plane and a position, which z-fight.""" + data = [ + (p.center.copy(), p.normal.copy(), frozenset(p.vertices)) + for p in me.polygons + ] + eps2 = ZFIGHT_EPS * ZFIGHT_EPS + count = 0 + for i in range(len(data)): + ci, ni, vi = data[i] + for j in range(i + 1, len(data)): + cj, nj, vj = data[j] + if (cj - ci).length_squared > eps2: + continue + if abs(ni.dot(nj)) <= ZFIGHT_COS: + continue + if vi & vj: + continue + count += 1 + return count + + +def shells(me): + neighbors = [[] for _ in range(len(me.vertices))] + for edge in me.edges: + a, b = edge.vertices + neighbors[a].append(b) + neighbors[b].append(a) + seen = [False] * len(me.vertices) + groups = [] + for start in range(len(me.vertices)): + if seen[start]: + continue + seen[start] = True + stack = [start] + group = [] + while stack: + current = stack.pop() + group.append(current) + for nxt in neighbors[current]: + if not seen[nxt]: + seen[nxt] = True + stack.append(nxt) + groups.append(group) + return groups + + +def shell_aabb(me, group): + pts = [me.vertices[i].co for i in group] + return ( + min(p.x for p in pts), + min(p.y for p in pts), + min(p.z for p in pts), + max(p.x for p in pts), + max(p.y for p in pts), + max(p.z for p in pts), + ) + + +def support_audit(me): + """Named supports must each sit on Z=0. AABB zmin is the lowest one.""" + groups = shells(me) + metal_groups = [] + for g in groups: + faces = [ + p for p in me.polygons if all(i in set(g) for i in p.vertices) + ] + if not faces: + continue + if faces[0].material_index != METAL_IDX: + continue + metal_groups.append(shell_aabb(me, g)) + shoes = [a for a in metal_groups if a[5] < 0.08 and (a[4] - a[1]) > 0.03] + tyres = [ + a for a in metal_groups + if (a[3] - a[0]) > 0.25 and (a[5] - a[2]) > 0.25 + ] + shoe_z = min((a[2] for a in shoes), default=99.0) + tyre_z = min((a[2] for a in tyres), default=99.0) + return { + "shoes": len(shoes), + "tyres": len(tyres), + "shoe_z": shoe_z, + "tyre_z": tyre_z, + } + + +def tray_size(me): + """Side-wall pair: length, track, and height of the box they bound.""" + groups = shells(me) + walls = [] + for g in groups: + faces = [ + p for p in me.polygons if all(i in set(g) for i in p.vertices) + ] + if not faces or faces[0].material_index != WOOD_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + if abs(dx - TRAY_L) < 0.10 and dy < 0.06 and abs(dz - WALL_H) < 0.08: + walls.append(a) + if len(walls) < 2: + return (0.0, 0.0, 0.0) + left = min(walls, key=lambda a: a[1]) + right = max(walls, key=lambda a: a[1]) + return ( + 0.5 * ((left[3] - left[0]) + (right[3] - right[0])), + right[4] - left[1], + 0.5 * ((left[5] - left[2]) + (right[5] - right[2])), + ) + + +def wall_floor_seat(me): + """How far the side walls drop into the floor shells, metres. + + Recomputed from AABBs. A wall that only kisses the floor top after + a bevel reports ~0 and fails the seat floor. + """ + groups = shells(me) + floors = [] + walls = [] + for g in groups: + faces = [ + p for p in me.polygons if all(i in set(g) for i in p.vertices) + ] + if not faces or faces[0].material_index != WOOD_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + if dz < FLOOR_T * 2.2 and dy > TRAY_W * 0.5: + floors.append(a) + if abs(dx - TRAY_L) < 0.10 and dy < WALL_T * 4.0 and abs(dz - WALL_H) < 0.08: + walls.append(a) + if not floors or not walls: + return -1.0 + floor_top = max(a[5] for a in floors) + wall_bot = min(a[2] for a in walls) + return floor_top - wall_bot + + +def handle_join_gap(me): + """Daylight between the handle sticks and the tray rear. + + Handles are the wood shells whose xmax is behind the tray; the tray + rear is the wood shell with the smallest xmax among tall-wide parts. + """ + groups = shells(me) + wood = [] + for g in groups: + faces = [ + p for p in me.polygons if all(i in set(g) for i in p.vertices) + ] + if not faces or faces[0].material_index != WOOD_IDX: + continue + wood.append(shell_aabb(me, g)) + if not wood: + return 99.0 + xmin = min(a[0] for a in wood) + handles = [a for a in wood if a[0] < xmin + 0.08] + trayish = [a for a in wood if (a[4] - a[1]) > TRAY_W * 0.6] + if not handles or not trayish: + return 99.0 + tray_x0 = min(a[0] for a in trayish) + handle_x1 = max(a[3] for a in handles) + return tray_x0 - handle_x1 + + +def spoke_clearance(me): + """Hub diameter minus fattest spoke thickness. + + Spokes are the wood shells around the wheel centroid. Floor slats + also have a thin Z and a long X; matching on those dimensions + reports a false clearance that ``--fat-spokes`` cannot violate. + """ + groups = shells(me) + hubs = [] + spoke_thick = [] + for g in groups: + faces = [ + p for p in me.polygons if all(i in set(g) for i in p.vertices) + ] + if not faces or faces[0].material_index != WOOD_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + cx = 0.5 * (a[0] + a[3]) + cz = 0.5 * (a[2] + a[5]) + if abs(cx - WHEEL_X) > RIM_MAJOR or abs(cz - WHEEL_Z) > RIM_MAJOR: + continue + if abs(dy - HUB_W) < 0.012 and max(dx, dz) < HUB_R * 2.4: + hubs.append(a) + continue + if dx > RIM_MAJOR and dz > RIM_MAJOR: + continue + thick = min(dx, dy, dz) + longest = max(dx, dy, dz) + if longest > HUB_R * 2.0 and thick < HUB_R * 2.6: + spoke_thick.append(thick) + if not hubs: + return -1.0 + hub = hubs[0] + hub_d = max(hub[3] - hub[0], hub[5] - hub[2]) + if not spoke_thick: + return hub_d + return hub_d - max(spoke_thick) + + +def tread_aspect(me): + """Tyre Y-width over radial thickness. Flat treads are wide; torii are not. + + Measured on the tyre shell's own verts, not on every vert in its AABB — + the felloe and hub sit inside that box and would inflate the radial span. + """ + groups = shells(me) + best = None + for g in groups: + member = set(g) + faces = [ + p for p in me.polygons if all(i in member for i in p.vertices) + ] + if not faces or faces[0].material_index != METAL_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + if dx < 0.25 or dz < 0.25: + continue + cx = 0.5 * (a[0] + a[3]) + cz = 0.5 * (a[2] + a[5]) + rs = [ + math.hypot(me.vertices[i].co.x - cx, me.vertices[i].co.z - cz) + for i in g + ] + radial = max(rs) - min(rs) + if radial < 1e-6: + continue + aspect = dy / radial + score = dx * dz + if best is None or score > best[0]: + best = (score, aspect) + return best[1] if best else 0.0 + + +def min_mat_distance(me, ia, ib): + bm_a = bmesh.new() + bm_b = bmesh.new() + try: + bm_a.from_mesh(me) + bm_b.from_mesh(me) + 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 add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, SHAFT_Z)) + bm.to_mesh(me) + me.update() + finally: + bm.free() + + def make_lod(obj, name, ratio, skip_decimate): mesh = obj.data.copy() lod = bpy.data.objects.new(name, mesh) @@ -886,14 +1380,34 @@

Source

) -def check(skip_decimate): +def check( + skip_decimate, lift_z=False, stray_vert=False, + fat_spokes=False, pipe_rim=False, short_legs=False, float_walls=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_barrow_mesh("BarrowLow", bevel_offset=0.005, bevel_segments=2) - high = build_barrow_mesh("BarrowHigh", bevel_offset=0.005, bevel_segments=4) - wood = principled("BarrowWood", (0.38, 0.21, 0.09, 1.0), 0.0, 0.58) - metal = principled("BarrowIron", (0.10, 0.105, 0.12, 1.0), 1.0, 0.30) + flags = dict( + pipe_rim=pipe_rim, fat_spokes=fat_spokes, + short_legs=short_legs, float_walls=float_walls, + ) + low = build_barrow_mesh("BarrowLow", 0.004, 2, **flags) + high = build_barrow_mesh("BarrowHigh", 0.004, 4, **flags) + wood = principled( + "BarrowWood", (0.40, 0.22, 0.09, 1.0), 0.0, 0.58, + noise_scale=7.0, wear=(0.24, 0.12, 0.04, 1.0), + ) + metal = principled( + "BarrowIron", (0.11, 0.115, 0.13, 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 stray_vert: + add_stray_vert(low.data) + if lift_z: + for v in low.data.vertices: + v.co.z += LIFT_Z + low.data.update() + bpy.context.view_layer.update() if low.data is None or len(low.data.polygons) < 6: return fail("barrow mesh did not build", 3), None, None, None, None, None @@ -925,7 +1439,7 @@

Source

r1 = lod1_tris / base_tris if base_tris else 0.0 r2 = lod2_tris / base_tris if base_tris else 0.0 - collider_src = build_barrow_mesh("BarrowColSrc", bevel_offset=0.0, bevel_segments=1) + collider_src = build_barrow_mesh("BarrowColSrc", 0.0, 1, **flags) collider = convex_hull_collider(collider_src, "BarrowCollider") bpy.data.objects.remove(collider_src, do_unlink=True) col_tris = triangle_count(collider.data) @@ -939,6 +1453,16 @@

Source

export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + tsz = tray_size(low.data) + hgap = handle_join_gap(low.data) + sclear = spoke_clearance(low.data) + aspect = tread_aspect(low.data) + gap_mw = min_mat_distance(low.data, METAL_IDX, WOOD_IDX) + seat = wall_floor_seat(low.data) + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " @@ -956,6 +1480,21 @@

Source

f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}" ) + 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']} zfight={zf}" + ) + print( + f"measured supports shoes={sup['shoes']} shoe_z={sup['shoe_z']:.5f} " + f"tyres={sup['tyres']} tyre_z={sup['tyre_z']:.5f}" + ) + print( + f"measured tray=({tsz[0]:.4f},{tsz[1]:.4f},{tsz[2]:.4f}) " + f"handle_gap={hgap:.5f} spoke_clear={sclear:.5f} " + f"tread_aspect={aspect:.3f} gap_metal_wood={gap_mw:.5f} " + f"wall_floor_seat={seat:.5f}" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -1020,6 +1559,76 @@

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"] + or zf + ): + 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']} zfight={zf}", + 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 sup["shoes"] < 2 or sup["shoe_z"] > SHOE_Z_MAX: + return fail( + f"shoe supports {sup['shoes']} shoe_z={sup['shoe_z']:.5f} " + "(--short-legs is the designed fail)", + 16, + ), None, None, None, None, None + if sup["tyres"] < 1 or sup["tyre_z"] > SHOE_Z_MAX: + return fail( + f"tyre supports {sup['tyres']} tyre_z={sup['tyre_z']:.5f}", + 16, + ), None, None, None, None, None + if sclear < SPOKE_CLEAR_MIN: + return fail( + f"spoke clearance {sclear:.5f} < {SPOKE_CLEAR_MIN} " + "(--fat-spokes is the designed fail)", + 17, + ), None, None, None, None, None + if hgap > HANDLE_JOIN: + return fail( + f"handle-tray gap {hgap:.5f} > {HANDLE_JOIN}", + 17, + ), None, None, None, None, None + if seat < WALL_SEAT_MIN: + return fail( + f"wall-floor seat {seat:.5f} < {WALL_SEAT_MIN} " + "(--float-walls is the designed fail)", + 17, + ), None, None, None, None, None + if gap_mw > GAP_MAX: + return fail( + f"metal-wood gap {gap_mw:.5f} > {GAP_MAX}", + 17, + ), None, None, None, None, None + if aspect < TREAD_ASPECT_MIN: + return fail( + f"tread aspect {aspect:.3f} < {TREAD_ASPECT_MIN} " + "(--pipe-rim is the designed fail)", + 18, + ), None, None, None, None, None + if ( + abs(tsz[0] - TRAY_SIZE[0]) > TRAY_SIZE_TOL[0] + or abs(tsz[1] - TRAY_SIZE[1]) > TRAY_SIZE_TOL[1] + or abs(tsz[2] - TRAY_SIZE[2]) > TRAY_SIZE_TOL[2] + ): + return fail( + f"tray size ({tsz[0]:.4f},{tsz[1]:.4f},{tsz[2]:.4f}) " + f"off declared {TRAY_SIZE}", + 19, + ), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -1087,10 +1696,10 @@

Source

cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (1.82, -2.22, 1.12) + cam.location = (1.95, -2.35, 1.18) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.34) + aim.location = (0.0, 0.0, 0.32) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -1138,9 +1747,47 @@

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", + ) + p.add_argument( + "--stray-vert", + action="store_true", + help="falsification: add a loose vertex so the hygiene budget fails", + ) + p.add_argument( + "--fat-spokes", + action="store_true", + help="falsification: spokes as thick as the hub, failing joint fit", + ) + p.add_argument( + "--pipe-rim", + action="store_true", + help="falsification: torus tyre on a flat felloe, failing tread aspect", + ) + p.add_argument( + "--short-legs", + action="store_true", + help="falsification: shoes float while the wheel still grounds the AABB", + ) + p.add_argument( + "--float-walls", + action="store_true", + help="falsification: walls kiss the floor so the seat budget fails", + ) 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, + stray_vert=args.stray_vert, + fat_spokes=args.fat_spokes, + pipe_rim=args.pipe_rim, + short_legs=args.short_legs, + float_walls=args.float_walls, + ) if code: return code if args.output: diff --git a/showcase/gallery.json b/showcase/gallery.json index 38d45b9..2310696 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -153,7 +153,7 @@ "name": "wheelbarrow", "dir": "showcase/wheelbarrow", "teaches": "A procedural wooden wheelbarrow through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", - "witnessesFix": "Recomputed: 3088 tris, two materials with 308 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.418×0.463×0.594 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 106 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "witnessesFix": "Recomputed: 2500 tris, two materials with 1094 wood and 190 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.556×0.630×0.572 m, zmin 0, hygiene 0, wall-floor seat 12 mm, spoke clearance 40 mm, LOD ratios in band (5.2 COLLAPSE leaner on LOD2), convex collider 90 tris, non-empty glTF. --skip-decimate exits 9 on LOD1; --stray-vert exits 15 on hygiene; --lift-z / --short-legs exit 16 on grounded; --fat-spokes / --float-walls exit 17 on joint fit; --pipe-rim exits 18 on tread aspect.", "hero": "docs/gallery/assets/wheelbarrow-hero.webp", "preview": "showcase/wheelbarrow/preview.webp", "tags": [ @@ -201,7 +201,7 @@ "name": "grindstone", "dir": "showcase/grindstone", "teaches": "A procedural grindstone on a timber trestle through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", - "witnessesFix": "Recomputed: 1604 tris, three materials with 80 metal and 26 stone faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.630×0.615×0.575 m, LOD ratios in band, convex collider 130 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "witnessesFix": "Recomputed: 928 tris, three materials with 216 wood / 160 stone / 180 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.710×0.419×0.610 m, zmin 0, hygiene 0, four shoes planted, trough seated in the sills, stone dip 26 mm, LOD ratios in band (5.2 COLLAPSE leaner on LOD2), convex collider 198 tris, non-empty glTF. --skip-decimate exits 9 on LOD1; --stray-vert exits 15 on hygiene; --lift-z / --short-legs exit 16 on grounded; --float-crank / --float-legs exit 17 on joint fit; --no-dip / --narrow-trough exit 18 on seat.", "hero": "docs/gallery/assets/grindstone-hero.webp", "preview": "showcase/grindstone/preview.webp", "tags": [ diff --git a/showcase/wheelbarrow/README.md b/showcase/wheelbarrow/README.md index 8d9e93f..1b8cb9e 100644 --- a/showcase/wheelbarrow/README.md +++ b/showcase/wheelbarrow/README.md @@ -1,10 +1,16 @@ # Wheelbarrow -A showcase piece, not an example. Procedural wooden wheelbarrow (staved -U-tray, iron straps, single spoked wheel, rear legs, handles) then the +A showcase piece, not an example. Procedural wooden wheelbarrow (two +chassis shafts that are the handles, box tray with overlapping floor +slats, iron straps, single flat-tread spoked wheel, rear legs) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export. +The tray walls seat into the floor rather than sitting on top of it, so +a wood bevel cannot open daylight at the joint. Handles are thinner in +Y than the chassis they tenon into. The tyre is a flat felloe wrap, not +a torus. + It asserts **budget conformance** of the generated result. It does not witness an API contract. "It rendered without error" is not a check. @@ -21,39 +27,83 @@ materials, UVs, evaluated LOD, collider, or export file. | Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | -| Base triangles | 2980–3200 | 3088 / 3088 / 3088 | +| Base triangles | 2300–2800 | 2500 / 2500 / 2500 | | 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.2014 | -| Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 308 metal | +| LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.2104 | +| Materials | exactly 2 distinct; ≥700 wood, ≥180 metal faces | 2 slots; 1094 / 190 | | UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | -| Outer AABB | (1.418, 0.463, 0.594) m ± 0.01 | (1.4176, 0.4627, 0.5937), zmin 0 | -| Collider tris | ≤ 140 | 106 | -| Export | written, size > 0 | 227404 / 227404 / 227388 bytes | +| Outer AABB | (1.558, 0.630, 0.574) m ± 0.015 | (1.5556, 0.6300, 0.5720), zmin 0 | +| Collider tris | ≤ 220 | 90 | +| Export | written, size > 0 | 187576 / 187576 / 187560 bytes | + +### Hygiene + +Recomputed from the generated mesh, not asserted about the script. + +| Axis | Declared | Measured (all three) | +| --- | --- | --- | +| Non-manifold edges | 0 | 0 | +| Loose verts / edges | 0 / 0 | 0 / 0 | +| Doubles merged at 1e-5 | 0 | 0 | +| Zero-area faces | 0 | 0 | +| N-gons | 0 | 0 | +| Coplanar disjoint face pairs | 0 | 0 | +| Grounded: `zmin` | within 1e-4 of 0 | 0.0000 | +| Named supports: 2 shoes + tyre | each `zmin` ≤ 1e-3 | shoe_z 0.00000, tyre_z 0.00000 | +| Tray size | (0.720, 0.532, 0.220) m ± 0.02 | (0.7200, 0.5320, 0.2200) | + +### Joint fit and seat + +| Axis | Declared | Measured (all three) | +| --- | --- | --- | +| Spoke clearance (hub − spoke) | ≥ 0.010 m | 0.04000 | +| Handle-tray overlap | gap ≤ 0.020 m | −0.07062 | +| Wall-floor seat | ≥ 0.005 m | 0.01200 | +| Metal-wood BVH gap | ≤ 0.010 m | 0.00000 | +| Tread aspect (tyre width / radial) | ≥ 2.5 | 5.111 | 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 16 B on 5.2.1 (glTF serializer), not a -gated axis. +5.2.1 is 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. + +### Falsifiers -`--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. +Each violates one named budget. All eight were run on 4.5.11, 5.1.2 and +5.2.1 and returned the same code on each. + +| Flag | Budget violated | Exit | +| --- | --- | --- | +| `--skip-decimate` | LOD1 ratio band | 9 | +| `--stray-vert` | loose vertex count is 0 | 15 | +| `--lift-z` | bounding box `zmin` is 0 | 16 | +| `--short-legs` | named shoe supports at Z=0 | 16 | +| `--fat-spokes` | spoke clearance inside the hub | 17 | +| `--float-walls` | wall-floor seat | 17 | +| `--pipe-rim` | tread aspect (torus on a flat felloe) | 18 | ## Run ```bash blender --background --python wheelbarrow.py -- blender --background --python wheelbarrow.py -- --skip-decimate +blender --background --python wheelbarrow.py -- --stray-vert +blender --background --python wheelbarrow.py -- --lift-z +blender --background --python wheelbarrow.py -- --short-legs +blender --background --python wheelbarrow.py -- --fat-spokes +blender --background --python wheelbarrow.py -- --float-walls +blender --background --python wheelbarrow.py -- --pipe-rim blender --background --python wheelbarrow.py -- --output barrow.png ``` -Smoke does not pass `--output` or `--skip-decimate`. +Smoke passes no flags. ## Exit codes File-local. `9` is a valid check code. `10` is reserved for -`gallery_framing.check_framing` on the `--output` path. +`gallery_framing.check_framing` on the `--output` path. `15`–`19` are the +hygiene and joint-fit family. | Code | Meaning | | --- | --- | @@ -62,7 +112,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 ≠ 2 distinct slots, or wood/metal faces missing | +| 5 | Material count ≠ 2 distinct slots, or a face-count floor missed | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | @@ -72,3 +122,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 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, z-fight | +| 16 | Not grounded: bounding box `zmin` off 0, or a named shoe/tyre floats | +| 17 | Joint fit: spoke clearance, handle join, wall-floor seat, or metal-wood gap | +| 18 | Seat: tread aspect of the tyre | +| 19 | Tray size off the stated real-world dimensions | diff --git a/showcase/wheelbarrow/preview.webp b/showcase/wheelbarrow/preview.webp index acf25d47aa5b703b60d83d7675a231f83600d47a..89e4a3df721336664e051abe1e3f3b408a243dbf 100644 GIT binary patch literal 21432 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 X!@<*l?!L=qJ7uimtVlC1kAMIGMf0$+ literal 14414 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 0.0: - edges = list({e for v in wood for e in v.link_edges}) - ret = bmesh.ops.bevel( + body.extend( + add_oriented_box( bm, - geom=edges, - offset=bevel_offset, - segments=bevel_segments, - profile=0.5, - affect="EDGES", - clamp_overlap=True, + (TRAY_X1 + 0.04, -FORK_Y, WHEEL_Z + 0.02), + (TRAY_X1 + 0.04, FORK_Y, WHEEL_Z + 0.02), + (0.024, 0.024), + WOOD_IDX, ) - for f in ret.get("faces") or []: - f.material_index = WOOD_IDX - - wx = cx + TRAY_L / 2.0 + 0.04 - wz = RIM_MAJOR - add_wheel_wood(bm, (wx, 0.0, wz), wood) - add_wheel_metal(bm, (wx, 0.0, wz), metal) - - r_strap = TRAY_R + STAVE_T * 0.55 + 0.004 - for sx in (-TRAY_L * 0.28, TRAY_L * 0.22): - n = 11 - pts = [] - for k in range(n + 1): - a = -A_SPAN + 2.0 * A_SPAN * k / n - pts.append( - ( - cx + sx, - r_strap * math.sin(a), - zc - r_strap * math.cos(a), - ) + ) + + slat_span = TRAY_L + slat_w = (slat_span - (N_FLOOR - 1) * SLAT_GAP) / N_FLOOR + # Slats run under the side walls; walls sit on the floor, not + # beside a through-gap at the inner arris. + slat_y = TRAY_W + WALL_T + for i in range(N_FLOOR): + x = TRAY_X0 + slat_w / 2.0 + i * (slat_w + SLAT_GAP) + body.extend( + add_box( + bm, + (x, 0.0, floor_z), + (slat_w, slat_y, FLOOR_T), + WOOD_IDX, ) - for p0, p1 in zip(pts, pts[1:]): - metal.extend( - add_oriented_box(bm, p0, p1, (0.018, 0.010), METAL_IDX) + ) + for ysign in (-1.0, 1.0): + body.extend( + add_box( + bm, + (0.5 * (TRAY_X0 + TRAY_X1), ysign * (TRAY_W / 2.0), wall_z), + (TRAY_L, WALL_T, WALL_H), + WOOD_IDX, ) + ) + body.extend( + add_box( + bm, + (TRAY_X1, 0.0, front_z), + (WALL_T, TRAY_W + WALL_T, FRONT_H), + WOOD_IDX, + ) + ) + body.extend( + add_box( + bm, + (TRAY_X0, 0.0, rear_z), + (WALL_T, TRAY_W + WALL_T, REAR_H), + WOOD_IDX, + ) + ) - fy = 0.045 - for ysign in (-1.0, 1.0): + strap_r_y = TRAY_W / 2.0 + WALL_T / 2.0 + 0.004 + for sx in (TRAY_X0 + TRAY_L * 0.28, TRAY_X0 + TRAY_L * 0.72): + z0 = SHAFT_Z + SHAFT_H / 2.0 + z1 = z0 + FLOOR_T + WALL_H + for ysign in (-1.0, 1.0): + metal.extend( + add_oriented_box( + bm, + (sx, ysign * strap_r_y, z0), + (sx, ysign * strap_r_y, z1), + (0.018, 0.008), + METAL_IDX, + ) + ) metal.extend( add_oriented_box( bm, - (cx + TRAY_L / 2.0 - 0.05, ysign * fy, zc - TRAY_R * 0.55), - (wx, ysign * fy, wz), - (0.016, 0.016), + (sx, -strap_r_y, z0), + (sx, strap_r_y, z0), + (0.018, 0.008), METAL_IDX, ) ) - metal.extend( - add_box( + + if bevel_offset > 0.0: + edges = [] + for e in {e for v in body for e in v.link_edges}: + if min(v.co.z for v in e.verts) < shoe_top + 0.008: + continue + edges.append(e) + if edges: + ret = bmesh.ops.bevel( bm, - (lx - 0.03, ysign * 0.18, 0.012), - (0.055, 0.042, 0.022), - METAL_IDX, + geom=edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, ) - ) + for f in ret.get("faces") or []: + f.material_index = WOOD_IDX + + wood_wheel = [] + wheel_z = ( + RIM_MAJOR + 0.004 + RIM_MINOR_PIPE if pipe_rim else WHEEL_Z + ) + add_wheel( + bm, (WHEEL_X, 0.0, wheel_z), wood_wheel, metal, pipe_rim, spoke_t + ) xs = [v.co.x for v in bm.verts] ys = [v.co.y for v in bm.verts] - zs = [v.co.z for v in bm.verts] - rcx = 0.5 * (min(xs) + max(xs)) - rcy = 0.5 * (min(ys) + max(ys)) - zmin = min(zs) + cx = 0.5 * (min(xs) + max(xs)) + cy = 0.5 * (min(ys) + max(ys)) for v in bm.verts: - v.co.x -= rcx - v.co.y -= rcy - v.co.z -= zmin - if v.co.z < 0.0: - v.co.z = 0.0 + v.co.x -= cx + v.co.y -= cy pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) @@ -463,13 +600,33 @@ def pt(a, x): 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 @@ -516,6 +673,329 @@ 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) + zero_area = sum(1 for p in me.polygons if face_area(me, p) <= 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 zfight_pairs(me): + """Disjoint faces sharing a plane and a position, which z-fight.""" + data = [ + (p.center.copy(), p.normal.copy(), frozenset(p.vertices)) + for p in me.polygons + ] + eps2 = ZFIGHT_EPS * ZFIGHT_EPS + count = 0 + for i in range(len(data)): + ci, ni, vi = data[i] + for j in range(i + 1, len(data)): + cj, nj, vj = data[j] + if (cj - ci).length_squared > eps2: + continue + if abs(ni.dot(nj)) <= ZFIGHT_COS: + continue + if vi & vj: + continue + count += 1 + return count + + +def shells(me): + neighbors = [[] for _ in range(len(me.vertices))] + for edge in me.edges: + a, b = edge.vertices + neighbors[a].append(b) + neighbors[b].append(a) + seen = [False] * len(me.vertices) + groups = [] + for start in range(len(me.vertices)): + if seen[start]: + continue + seen[start] = True + stack = [start] + group = [] + while stack: + current = stack.pop() + group.append(current) + for nxt in neighbors[current]: + if not seen[nxt]: + seen[nxt] = True + stack.append(nxt) + groups.append(group) + return groups + + +def shell_aabb(me, group): + pts = [me.vertices[i].co for i in group] + return ( + min(p.x for p in pts), + min(p.y for p in pts), + min(p.z for p in pts), + max(p.x for p in pts), + max(p.y for p in pts), + max(p.z for p in pts), + ) + + +def support_audit(me): + """Named supports must each sit on Z=0. AABB zmin is the lowest one.""" + groups = shells(me) + metal_groups = [] + for g in groups: + faces = [ + p for p in me.polygons if all(i in set(g) for i in p.vertices) + ] + if not faces: + continue + if faces[0].material_index != METAL_IDX: + continue + metal_groups.append(shell_aabb(me, g)) + shoes = [a for a in metal_groups if a[5] < 0.08 and (a[4] - a[1]) > 0.03] + tyres = [ + a for a in metal_groups + if (a[3] - a[0]) > 0.25 and (a[5] - a[2]) > 0.25 + ] + shoe_z = min((a[2] for a in shoes), default=99.0) + tyre_z = min((a[2] for a in tyres), default=99.0) + return { + "shoes": len(shoes), + "tyres": len(tyres), + "shoe_z": shoe_z, + "tyre_z": tyre_z, + } + + +def tray_size(me): + """Side-wall pair: length, track, and height of the box they bound.""" + groups = shells(me) + walls = [] + for g in groups: + faces = [ + p for p in me.polygons if all(i in set(g) for i in p.vertices) + ] + if not faces or faces[0].material_index != WOOD_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + if abs(dx - TRAY_L) < 0.10 and dy < 0.06 and abs(dz - WALL_H) < 0.08: + walls.append(a) + if len(walls) < 2: + return (0.0, 0.0, 0.0) + left = min(walls, key=lambda a: a[1]) + right = max(walls, key=lambda a: a[1]) + return ( + 0.5 * ((left[3] - left[0]) + (right[3] - right[0])), + right[4] - left[1], + 0.5 * ((left[5] - left[2]) + (right[5] - right[2])), + ) + + +def wall_floor_seat(me): + """How far the side walls drop into the floor shells, metres. + + Recomputed from AABBs. A wall that only kisses the floor top after + a bevel reports ~0 and fails the seat floor. + """ + groups = shells(me) + floors = [] + walls = [] + for g in groups: + faces = [ + p for p in me.polygons if all(i in set(g) for i in p.vertices) + ] + if not faces or faces[0].material_index != WOOD_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + if dz < FLOOR_T * 2.2 and dy > TRAY_W * 0.5: + floors.append(a) + if abs(dx - TRAY_L) < 0.10 and dy < WALL_T * 4.0 and abs(dz - WALL_H) < 0.08: + walls.append(a) + if not floors or not walls: + return -1.0 + floor_top = max(a[5] for a in floors) + wall_bot = min(a[2] for a in walls) + return floor_top - wall_bot + + +def handle_join_gap(me): + """Daylight between the handle sticks and the tray rear. + + Handles are the wood shells whose xmax is behind the tray; the tray + rear is the wood shell with the smallest xmax among tall-wide parts. + """ + groups = shells(me) + wood = [] + for g in groups: + faces = [ + p for p in me.polygons if all(i in set(g) for i in p.vertices) + ] + if not faces or faces[0].material_index != WOOD_IDX: + continue + wood.append(shell_aabb(me, g)) + if not wood: + return 99.0 + xmin = min(a[0] for a in wood) + handles = [a for a in wood if a[0] < xmin + 0.08] + trayish = [a for a in wood if (a[4] - a[1]) > TRAY_W * 0.6] + if not handles or not trayish: + return 99.0 + tray_x0 = min(a[0] for a in trayish) + handle_x1 = max(a[3] for a in handles) + return tray_x0 - handle_x1 + + +def spoke_clearance(me): + """Hub diameter minus fattest spoke thickness. + + Spokes are the wood shells around the wheel centroid. Floor slats + also have a thin Z and a long X; matching on those dimensions + reports a false clearance that ``--fat-spokes`` cannot violate. + """ + groups = shells(me) + hubs = [] + spoke_thick = [] + for g in groups: + faces = [ + p for p in me.polygons if all(i in set(g) for i in p.vertices) + ] + if not faces or faces[0].material_index != WOOD_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + cx = 0.5 * (a[0] + a[3]) + cz = 0.5 * (a[2] + a[5]) + if abs(cx - WHEEL_X) > RIM_MAJOR or abs(cz - WHEEL_Z) > RIM_MAJOR: + continue + if abs(dy - HUB_W) < 0.012 and max(dx, dz) < HUB_R * 2.4: + hubs.append(a) + continue + if dx > RIM_MAJOR and dz > RIM_MAJOR: + continue + thick = min(dx, dy, dz) + longest = max(dx, dy, dz) + if longest > HUB_R * 2.0 and thick < HUB_R * 2.6: + spoke_thick.append(thick) + if not hubs: + return -1.0 + hub = hubs[0] + hub_d = max(hub[3] - hub[0], hub[5] - hub[2]) + if not spoke_thick: + return hub_d + return hub_d - max(spoke_thick) + + +def tread_aspect(me): + """Tyre Y-width over radial thickness. Flat treads are wide; torii are not. + + Measured on the tyre shell's own verts, not on every vert in its AABB — + the felloe and hub sit inside that box and would inflate the radial span. + """ + groups = shells(me) + best = None + for g in groups: + member = set(g) + faces = [ + p for p in me.polygons if all(i in member for i in p.vertices) + ] + if not faces or faces[0].material_index != METAL_IDX: + continue + a = shell_aabb(me, g) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + if dx < 0.25 or dz < 0.25: + continue + cx = 0.5 * (a[0] + a[3]) + cz = 0.5 * (a[2] + a[5]) + rs = [ + math.hypot(me.vertices[i].co.x - cx, me.vertices[i].co.z - cz) + for i in g + ] + radial = max(rs) - min(rs) + if radial < 1e-6: + continue + aspect = dy / radial + score = dx * dz + if best is None or score > best[0]: + best = (score, aspect) + return best[1] if best else 0.0 + + +def min_mat_distance(me, ia, ib): + bm_a = bmesh.new() + bm_b = bmesh.new() + try: + bm_a.from_mesh(me) + bm_b.from_mesh(me) + 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 add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, SHAFT_Z)) + bm.to_mesh(me) + me.update() + finally: + bm.free() + + def make_lod(obj, name, ratio, skip_decimate): mesh = obj.data.copy() lod = bpy.data.objects.new(name, mesh) @@ -602,14 +1082,34 @@ def export_unity(path, objects): ) -def check(skip_decimate): +def check( + skip_decimate, lift_z=False, stray_vert=False, + fat_spokes=False, pipe_rim=False, short_legs=False, float_walls=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_barrow_mesh("BarrowLow", bevel_offset=0.005, bevel_segments=2) - high = build_barrow_mesh("BarrowHigh", bevel_offset=0.005, bevel_segments=4) - wood = principled("BarrowWood", (0.38, 0.21, 0.09, 1.0), 0.0, 0.58) - metal = principled("BarrowIron", (0.10, 0.105, 0.12, 1.0), 1.0, 0.30) + flags = dict( + pipe_rim=pipe_rim, fat_spokes=fat_spokes, + short_legs=short_legs, float_walls=float_walls, + ) + low = build_barrow_mesh("BarrowLow", 0.004, 2, **flags) + high = build_barrow_mesh("BarrowHigh", 0.004, 4, **flags) + wood = principled( + "BarrowWood", (0.40, 0.22, 0.09, 1.0), 0.0, 0.58, + noise_scale=7.0, wear=(0.24, 0.12, 0.04, 1.0), + ) + metal = principled( + "BarrowIron", (0.11, 0.115, 0.13, 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 stray_vert: + add_stray_vert(low.data) + if lift_z: + for v in low.data.vertices: + v.co.z += LIFT_Z + low.data.update() + bpy.context.view_layer.update() if low.data is None or len(low.data.polygons) < 6: return fail("barrow mesh did not build", 3), None, None, None, None, None @@ -641,7 +1141,7 @@ def check(skip_decimate): r1 = lod1_tris / base_tris if base_tris else 0.0 r2 = lod2_tris / base_tris if base_tris else 0.0 - collider_src = build_barrow_mesh("BarrowColSrc", bevel_offset=0.0, bevel_segments=1) + collider_src = build_barrow_mesh("BarrowColSrc", 0.0, 1, **flags) collider = convex_hull_collider(collider_src, "BarrowCollider") bpy.data.objects.remove(collider_src, do_unlink=True) col_tris = triangle_count(collider.data) @@ -655,6 +1155,16 @@ def check(skip_decimate): export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + tsz = tray_size(low.data) + hgap = handle_join_gap(low.data) + sclear = spoke_clearance(low.data) + aspect = tread_aspect(low.data) + gap_mw = min_mat_distance(low.data, METAL_IDX, WOOD_IDX) + seat = wall_floor_seat(low.data) + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " @@ -672,6 +1182,21 @@ def check(skip_decimate): f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}" ) + 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']} zfight={zf}" + ) + print( + f"measured supports shoes={sup['shoes']} shoe_z={sup['shoe_z']:.5f} " + f"tyres={sup['tyres']} tyre_z={sup['tyre_z']:.5f}" + ) + print( + f"measured tray=({tsz[0]:.4f},{tsz[1]:.4f},{tsz[2]:.4f}) " + f"handle_gap={hgap:.5f} spoke_clear={sclear:.5f} " + f"tread_aspect={aspect:.3f} gap_metal_wood={gap_mw:.5f} " + f"wall_floor_seat={seat:.5f}" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -736,6 +1261,76 @@ 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"] + or zf + ): + 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']} zfight={zf}", + 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 sup["shoes"] < 2 or sup["shoe_z"] > SHOE_Z_MAX: + return fail( + f"shoe supports {sup['shoes']} shoe_z={sup['shoe_z']:.5f} " + "(--short-legs is the designed fail)", + 16, + ), None, None, None, None, None + if sup["tyres"] < 1 or sup["tyre_z"] > SHOE_Z_MAX: + return fail( + f"tyre supports {sup['tyres']} tyre_z={sup['tyre_z']:.5f}", + 16, + ), None, None, None, None, None + if sclear < SPOKE_CLEAR_MIN: + return fail( + f"spoke clearance {sclear:.5f} < {SPOKE_CLEAR_MIN} " + "(--fat-spokes is the designed fail)", + 17, + ), None, None, None, None, None + if hgap > HANDLE_JOIN: + return fail( + f"handle-tray gap {hgap:.5f} > {HANDLE_JOIN}", + 17, + ), None, None, None, None, None + if seat < WALL_SEAT_MIN: + return fail( + f"wall-floor seat {seat:.5f} < {WALL_SEAT_MIN} " + "(--float-walls is the designed fail)", + 17, + ), None, None, None, None, None + if gap_mw > GAP_MAX: + return fail( + f"metal-wood gap {gap_mw:.5f} > {GAP_MAX}", + 17, + ), None, None, None, None, None + if aspect < TREAD_ASPECT_MIN: + return fail( + f"tread aspect {aspect:.3f} < {TREAD_ASPECT_MIN} " + "(--pipe-rim is the designed fail)", + 18, + ), None, None, None, None, None + if ( + abs(tsz[0] - TRAY_SIZE[0]) > TRAY_SIZE_TOL[0] + or abs(tsz[1] - TRAY_SIZE[1]) > TRAY_SIZE_TOL[1] + or abs(tsz[2] - TRAY_SIZE[2]) > TRAY_SIZE_TOL[2] + ): + return fail( + f"tray size ({tsz[0]:.4f},{tsz[1]:.4f},{tsz[2]:.4f}) " + f"off declared {TRAY_SIZE}", + 19, + ), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -803,10 +1398,10 @@ def light(name, loc, energy, size, col, rot): cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (1.82, -2.22, 1.12) + cam.location = (1.95, -2.35, 1.18) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.34) + aim.location = (0.0, 0.0, 0.32) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -854,9 +1449,47 @@ 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", + ) + p.add_argument( + "--stray-vert", + action="store_true", + help="falsification: add a loose vertex so the hygiene budget fails", + ) + p.add_argument( + "--fat-spokes", + action="store_true", + help="falsification: spokes as thick as the hub, failing joint fit", + ) + p.add_argument( + "--pipe-rim", + action="store_true", + help="falsification: torus tyre on a flat felloe, failing tread aspect", + ) + p.add_argument( + "--short-legs", + action="store_true", + help="falsification: shoes float while the wheel still grounds the AABB", + ) + p.add_argument( + "--float-walls", + action="store_true", + help="falsification: walls kiss the floor so the seat budget fails", + ) 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, + stray_vert=args.stray_vert, + fat_spokes=args.fat_spokes, + pipe_rim=args.pipe_rim, + short_legs=args.short_legs, + float_walls=args.float_walls, + ) if code: return code if args.output: From 3f26ac26402ebac82a91041061da6febceb29828 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:31:13 -0400 Subject: [PATCH 4/4] fix(showcase): stop grindstone mat_of from looking like a product exit The README exit-table check treats return -1 as a process code. Missing material is None. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- showcase/grindstone/grindstone.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/showcase/grindstone/grindstone.py b/showcase/grindstone/grindstone.py index cba9c03..c6fe471 100644 --- a/showcase/grindstone/grindstone.py +++ b/showcase/grindstone/grindstone.py @@ -723,7 +723,7 @@ def mat_of(me, group): for p in me.polygons: if all(i in member for i in p.vertices): return p.material_index - return -1 + return None def support_audit(me):