diff --git a/docs/gallery/assets/grindstone-hero.webp b/docs/gallery/assets/grindstone-hero.webp index ed1ef06..5e8245d 100644 Binary files a/docs/gallery/assets/grindstone-hero.webp and b/docs/gallery/assets/grindstone-hero.webp differ diff --git a/docs/gallery/assets/wheelbarrow-hero.webp b/docs/gallery/assets/wheelbarrow-hero.webp index 0cc4800..44cfcb7 100644 Binary files a/docs/gallery/assets/wheelbarrow-hero.webp and b/docs/gallery/assets/wheelbarrow-hero.webp differ diff --git a/docs/gallery/grindstone/index.html b/docs/gallery/grindstone/index.html index 3590e62..ace8172 100644 --- a/docs/gallery/grindstone/index.html +++ b/docs/gallery/grindstone/index.html @@ -253,28 +253,43 @@

grindstone

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/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/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, 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/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..c6fe471 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 None + + +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 3a1bcf8..90a7ba3 100644 Binary files a/showcase/grindstone/preview.webp and b/showcase/grindstone/preview.webp differ 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 acf25d4..89e4a3d 100644 Binary files a/showcase/wheelbarrow/preview.webp and b/showcase/wheelbarrow/preview.webp differ diff --git a/showcase/wheelbarrow/wheelbarrow.py b/showcase/wheelbarrow/wheelbarrow.py index c9e12a3..74aafde 100644 --- a/showcase/wheelbarrow/wheelbarrow.py +++ b/showcase/wheelbarrow/wheelbarrow.py @@ -1,13 +1,20 @@ """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 @@ -27,6 +34,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,24 +43,56 @@ 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 @@ -62,11 +102,23 @@ 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 @@ -140,7 +192,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, @@ -157,7 +209,46 @@ def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): 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 = [] @@ -171,7 +262,6 @@ def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): 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): @@ -188,23 +278,47 @@ def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): 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): @@ -216,45 +330,19 @@ def add_wheel_wood(bm, loc, wood): 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), ) ) @@ -303,149 +391,198 @@ def pack_uvs(bm, margin=0.08): ) -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, + ) ) - end = ( - cx - TRAY_L / 2.0 - HANDLE_L, - ysign * (rim_y + 0.015), - 0.56, + 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, + ) ) - wood.extend( - add_oriented_box(bm, start, end, (0.030, 0.030), WOOD_IDX) + 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, + ) + ) + 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)) @@ -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: