diff --git a/docs/gallery/assets/market-stall-hero.webp b/docs/gallery/assets/market-stall-hero.webp index 4ab4f3d..a223a5a 100644 Binary files a/docs/gallery/assets/market-stall-hero.webp and b/docs/gallery/assets/market-stall-hero.webp differ diff --git a/docs/gallery/assets/watchtower-hero.webp b/docs/gallery/assets/watchtower-hero.webp index 326f4ab..9f5cdc0 100644 Binary files a/docs/gallery/assets/watchtower-hero.webp and b/docs/gallery/assets/watchtower-hero.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 989e70b..03a357f 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -936,7 +936,7 @@

campfire

market-stall

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

-

witnesses Recomputed: 4404 tris, three materials with 48 faces per awning stripe slot, UVs in 0..1 with zero AABB overlap, outer AABB 1.392×0.966×1.743 m, LOD ratios in band, convex collider 46 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+

witnesses Recomputed: 4324 tris, three materials with 34 faces per awning stripe slot, UVs in 0..1 with zero AABB overlap, outer AABB 1.389×1.011×1.740 m, grounded zmin, 4 wrap feet, brace-counter overlap 0, awning seat 4 mm, post plumb 0, LOD ratios in band, convex collider 58 tris, non-empty glTF. Hygiene all zero. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-feet 16; --low-brace 17; --float-awning 18; --rake-posts 19.

View example
@@ -991,7 +991,7 @@

fence-kit

watchtower

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

-

witnesses Recomputed: 5220 tris, three materials with 84 metal and 126 roof faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.434×1.434×1.982 m, LOD ratios in band, convex collider 56 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+

witnesses Recomputed: 6408 tris, three materials with 300 metal and 126 roof faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.594×1.594×2.973 m, grounded zmin, 4 iron shoes, rail engage 18 mm, collar standoff 2 mm, post plumb 0, LOD ratios in band (5.2 COLLAPSE leaner on LOD2), convex collider 150 tris, non-empty glTF. Hygiene all zero. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-shoes 16; --short-rails 17; --float-band 18; --rake-posts 19.

View example
diff --git a/docs/gallery/market-stall/index.html b/docs/gallery/market-stall/index.html index b7bcce6..e0189a9 100644 --- a/docs/gallery/market-stall/index.html +++ b/docs/gallery/market-stall/index.html @@ -253,28 +253,42 @@

market-stall

market-stall render

Rendered headless by the example itself — click to zoom.

-
witnesses Recomputed: 4404 tris, three materials with 48 faces per awning stripe slot, UVs in 0..1 with zero AABB overlap, outer AABB 1.392×0.966×1.743 m, LOD ratios in band, convex collider 46 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
witnesses Recomputed: 4324 tris, three materials with 34 faces per awning stripe slot, UVs in 0..1 with zero AABB overlap, outer AABB 1.389×1.011×1.740 m, grounded zmin, 4 wrap feet, brace-counter overlap 0, awning seat 4 mm, post plumb 0, LOD ratios in band, convex collider 58 tris, non-empty glTF. Hygiene all zero. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-feet 16; --low-brace 17; --float-awning 18; --rake-posts 19.
blender --background --python showcase/market-stall/market_stall.py --
-

A showcase piece, not an example. Procedural timber stall (posts with feet, slanted rafters, slatted counter and shelf, back-wall planks, eave fascia, striped awning and valance) 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 timber stall (corner posts with wrap plinths, slatted counter and shelf, back-wall planks, side braces that sit in the post bay, striped awning with a front roller and hanging valance) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

The old piece used capped foot cubes coplanar with the post bottoms and counter-leg pads coplanar with the legs, which z-fought at Z=0. Wrap plates stand off the post; counter legs go to Z=0 without a second bottom face.

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 | 4280–4550 | 4404 / 4404 / 4404 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 | | Materials | exactly 3 distinct, ≥16 faces per stripe slot | 3 slots, 48 / 48 stripe faces | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.392, 0.966, 1.743) m ± 0.01 | (1.3915, 0.9659, 1.7430), zmin 0 | | Collider tris | ≤ 80 | 46 | | Export | written, size > 0 | 316748 / 316748 / 316724 bytes |

-

DECIMATE COLLAPSE triangle counts are not guaranteed identical across series — the gate is a ratio band, not an exact count. This mesh happened to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. glTF byte size differs by a few bytes across series.

-

--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates.

+

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 3900–5200 | 4324 / 4324 / 4324 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2197 / 0.2197 / 0.2197 | | Materials | exactly 3 distinct; ≥16 faces per stripe slot | 3 slots; 34 / 34 stripe faces | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.389, 1.011, 1.740) m ± 0.01 | (1.3892, 1.0107, 1.7402), zmin 0 | | Collider tris | ≤ 80 | 58 | | Export | written, size > 0 | 312752 / 312752 / 312728 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 wrap feet | each zmin ≤ 1e-3 | 4, foot_z 0.00000 | | Frame plan | 1.36 × 0.92 m ± 0.04 | 1.3600 × 0.9200 |

+

Joint fit and seat

+

| Axis | Declared | Measured (all three) | | --- | --- | --- | | Brace-vs-counter overlap | ≤ 1e-6 m³ | 0.000000 | | Header-post tenon engage | ≥ 0.4 × tenon | 0.0595 | | Awning-on-header seat | −0.002–0.010 m | 0.00378 | | Post plumb (XY drift) | ≤ 0.008 m | 0.00000 |

+

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 24 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-feet | named wrap-foot supports at Z=0 | 16 | | --low-brace | brace-vs-counter overlap | 17 | | --float-awning | awning-on-header seat | 18 | | --rake-posts | post plumb | 19 |

Run

blender --background --python market_stall.py --
 blender --background --python market_stall.py -- --skip-decimate
+blender --background --python market_stall.py -- --stray-vert
+blender --background --python market_stall.py -- --lift-z
+blender --background --python market_stall.py -- --short-feet
+blender --background --python market_stall.py -- --low-brace
+blender --background --python market_stall.py -- --float-awning
+blender --background --python market_stall.py -- --rake-posts
 blender --background --python market_stall.py -- --output stall.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 stripe 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 stripe faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file | | 15 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, z-fight | | 16 | Not grounded: bounding box zmin off 0, or a named wrap foot floats | | 17 | Joint fit: brace occupying the counter volume | | 18 | Seat: awning-on-header gap | | 19 | Post plumb or frame plan off the stated real-world size |

Source

@@ -289,12 +303,15 @@

Source

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. +They are not API-contract witnesses. Each falsifier violates one named +budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh +hygiene, ``--lift-z`` grounded zmin, ``--short-feet`` named post-foot +supports, ``--low-brace`` brace-vs-counter joint fit, ``--float-awning`` +awning-on-header seat, ``--rake-posts`` post plumb. -No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts -are not byte-identical across Blender versions — the LOD gate is a -ratio band, not an exact count. +No RNG. Slat jitter is closed-form ``sin(i)``. DECIMATE COLLAPSE +triangle counts are not byte-identical across Blender versions — the LOD +gate is a ratio band, not an exact count. blender --background --python market_stall.py -- blender --background --python market_stall.py -- --skip-decimate @@ -328,16 +345,20 @@

Source

BACK_H = 1.72 N_STRIPES = 8 AWNING_T = 0.018 +OVERHANG_F = 0.040 +OVERHANG_B = 0.030 COUNTER_Z = 0.82 COUNTER_D = 0.34 COUNTER_T = 0.070 BRACE = 0.036 +FOOT_H = 0.036 +TENON = POST * 0.35 BBOX_TOL = 0.01 # Fitted to the generated AABB after locking geometry. Recomputed from bound_box. -OUTER_SIZE = (1.392, 0.966, 1.743) +OUTER_SIZE = (1.389, 1.011, 1.740) -BASE_TRIS_MIN = 4280 -BASE_TRIS_MAX = 4550 +BASE_TRIS_MIN = 3900 +BASE_TRIS_MAX = 5200 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -350,6 +371,24 @@

Source

COLLIDER_TRIS_MAX = 80 BAKE_RES = 256 CAGE_EXTRUSION = 0.06 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.998 +FOOT_ZMIN_MAX = 0.001 +FOOT_COUNT = 4 +AWNING_SEAT_MAX = 0.010 +AWNING_SEAT_MIN = -0.002 +BRACE_COUNTER_OVERLAP_MAX = 1e-6 +POST_PLUMB_MAX = 0.008 +FRAME_XY_TOL = 0.04 +LIFT_Z = 0.05 +# Small enough that the AABB still sits in BBOX_TOL; large enough that +# the front-eave seat drops below AWNING_SEAT_MIN. +FLOAT_AWNING = 0.008 +RAKE = math.radians(2.0) +SHORT_FOOT_Z = 0.048 WOOD_IDX = 0 STRIPE_A_IDX = 1 @@ -396,25 +435,75 @@

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( +def add_oriented_box(bm, a, b, scale_xy, mat_idx): + a = Vector(a) + b = Vector(b) + delta = b - a + length = delta.length + if length < 1e-8: + return [] + quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) + eul = quat.to_euler("XYZ") + return add_box( bm, - cap_ends=True, - cap_tris=False, - segments=segments, - radius1=radius1, - radius2=radius2, - depth=depth, + ((a + b) * 0.5), + (scale_xy[0], scale_xy[1], length), + mat_idx, + euler=(eul.x, eul.y, eul.z), ) - verts = geo["verts"] + + +def add_wrap(bm, px, py, z, height, host, t, mat_idx): + """Four plates around a square post. Y-facing plates cover the corners.""" + half = host / 2.0 + off = half + t / 2.0 + 0.002 + add_box(bm, (px + off, py, z), (t, host, height), mat_idx) + add_box(bm, (px - off, py, z), (t, host, height), mat_idx) + add_box(bm, (px, py + off, z), (host + 2.0 * t, t, height), mat_idx) + add_box(bm, (px, py - off, z), (host + 2.0 * t, t, height), mat_idx) + return [] + + +def add_striped_slab(bm, loc, scale, euler, n_stripes, even_idx, odd_idx): + """One slab split into n stripes that share vertices — no daylight gaps.""" rot = Euler(euler).to_matrix() origin = Vector(loc) - for v in verts: - v.co = rot @ v.co + origin - faces = {f for v in verts for f in v.link_faces} - for f in faces: - f.material_index = mat_idx - return verts + sx, sy, sz = scale + stations = [] + for i in range(n_stripes + 1): + x = -0.5 * sx + sx * i / n_stripes + row = [] + for y, z in ( + (-0.5 * sy, -0.5 * sz), + (-0.5 * sy, 0.5 * sz), + (0.5 * sy, -0.5 * sz), + (0.5 * sy, 0.5 * sz), + ): + row.append(bm.verts.new(rot @ Vector((x, y, z)) + origin)) + stations.append(row) + kept = [] + for i in range(n_stripes): + a = stations[i] + b = stations[i + 1] + idx = even_idx if (i % 2 == 0) else odd_idx + quads = ( + (a[0], b[0], b[2], a[2]), + (a[1], a[3], b[3], b[1]), + (a[0], a[1], b[1], b[0]), + (a[2], b[2], b[3], a[3]), + ) + for q in quads: + face = bm.faces.new(q) + face.material_index = idx + kept.append(face) + first = stations[0] + last = stations[-1] + cap_a = bm.faces.new((first[0], first[2], first[3], first[1])) + cap_a.material_index = even_idx + cap_b = bm.faces.new((last[0], last[1], last[3], last[2])) + cap_b.material_index = even_idx if ((n_stripes - 1) % 2 == 0) else odd_idx + kept.extend((cap_a, cap_b)) + return kept def pack_uvs(bm, margin=0.08): @@ -460,205 +549,228 @@

Source

) -def build_stall_mesh(name, bevel_offset, bevel_segments): +def build_stall_mesh( + name, + bevel_offset, + bevel_segments, + low_brace=False, + float_awning=False, + rake_posts=False, + short_feet=False, +): bm = bmesh.new() wood_verts = [] - stripe_a = set() - stripe_b = set() + stripe_faces = [] try: hx = WIDTH / 2.0 hy = DEPTH / 2.0 - inset = POST / 2.0 + px = hx - POST / 2.0 + py_f = -(hy - POST / 2.0) + py_b = hy - POST / 2.0 + post_h_f = FRONT_H - POST + post_h_b = BACK_H - POST + rake = (RAKE, 0.0, 0.0) if rake_posts else (0.0, 0.0, 0.0) posts = ( - (-hx + inset, -hy + inset, FRONT_H / 2.0, FRONT_H), - (hx - inset, -hy + inset, FRONT_H / 2.0, FRONT_H), - (-hx + inset, hy - inset, BACK_H / 2.0, BACK_H), - (hx - inset, hy - inset, BACK_H / 2.0, BACK_H), + (-px, py_f, post_h_f), + (px, py_f, post_h_f), + (-px, py_b, post_h_b), + (px, py_b, post_h_b), ) - for x, y, z, h in posts: - wood_verts.extend(add_box(bm, (x, y, z), (POST, POST, h), WOOD_IDX)) + foot_z = SHORT_FOOT_Z if short_feet else FOOT_H / 2.0 + foot_h = 0.020 if short_feet else FOOT_H + wrap_t = POST * 0.18 + for x, y, h in posts: wood_verts.extend( - add_box(bm, (x, y, 0.018), (POST * 1.45, POST * 1.45, 0.036), WOOD_IDX) + add_box(bm, (x, y, h / 2.0), (POST, POST, h), WOOD_IDX, euler=rake) ) + add_wrap(bm, x, y, foot_z, foot_h, POST, wrap_t, WOOD_IDX) - # Front and back header beams (along X). + header_len = WIDTH - POST + 2.0 * TENON wood_verts.extend( add_box( bm, - (0.0, -hy + inset, FRONT_H - POST / 2.0), - (WIDTH - POST, POST, POST), + (0.0, py_f, FRONT_H - POST / 2.0), + (header_len, POST * 0.78, POST), WOOD_IDX, ) ) wood_verts.extend( add_box( bm, - (0.0, hy - inset, BACK_H - POST / 2.0), - (WIDTH - POST, POST, POST), + (0.0, py_b, BACK_H - POST / 2.0), + (header_len, POST * 0.78, POST), WOOD_IDX, ) ) - # Side plates, slanted to match the awning. - dz = BACK_H - FRONT_H - slant_len = math.hypot(DEPTH - POST, dz) - pitch = math.atan2(dz, DEPTH - POST) - for sx in (-hx + inset, hx - inset): + # Side plates sit on the header centres, spanning post to post. + for sx in (-px, px): wood_verts.extend( - add_box( + add_oriented_box( bm, - (sx, 0.0, (FRONT_H + BACK_H) / 2.0 - POST / 2.0), - (POST * 0.85, slant_len, POST * 0.85), + (sx, py_f, FRONT_H - POST / 2.0), + (sx, py_b, BACK_H - POST / 2.0), + (POST * 0.80, POST * 0.80), WOOD_IDX, - euler=(pitch, 0.0, 0.0), ) ) - # Inner rafters under the awning — the empty underside was a slab of air. for i in range(3): rx = -hx + POST * 2.0 + (i + 1) * (WIDTH - 4.0 * POST) / 4.0 wood_verts.extend( - add_box( + add_oriented_box( bm, - (rx, 0.0, (FRONT_H + BACK_H) / 2.0 - POST * 0.42), - (POST * 0.42, slant_len * 0.96, POST * 0.42), + (rx, py_f, FRONT_H - POST / 2.0), + (rx, py_b, BACK_H - POST / 2.0), + (POST * 0.42, POST * 0.42), WOOD_IDX, - euler=(pitch, 0.0, 0.0), ) ) - # Mid rail on the back wall and two diagonal braces at the sides. wood_verts.extend( add_box( bm, - (0.0, hy - inset, BACK_H * 0.55), - (WIDTH - 2.0 * POST, POST * 0.7, POST * 0.7), + (0.0, py_b, BACK_H * 0.48), + (WIDTH - POST, POST * 0.65, POST * 0.65), WOOD_IDX, ) ) - for sx in (-hx + inset, hx - inset): - p0 = Vector((sx, -hy + inset, 0.30)) - p1 = Vector((sx, hy - inset, BACK_H * 0.58)) - mid = (p0 + p1) * 0.5 - delta = p1 - p0 - brace_len = delta.length - brace_pitch = math.atan2(delta.z, delta.y) - wood_verts.extend( - add_box( - bm, - (mid.x, mid.y, mid.z), - (BRACE, brace_len, BRACE), - WOOD_IDX, - euler=(brace_pitch, 0.0, 0.0), - ) + wood_verts.extend( + add_box( + bm, + (0.0, py_f, 0.16), + (WIDTH - 2.0 * POST, POST * 0.70, POST * 0.55), + WOOD_IDX, ) + ) - # Slatted counter — crate-lid language, not one fat slab. - cy = -hy + COUNTER_D / 2.0 + 0.04 + for sign in (-1.0, 1.0): + sx = sign * px + if low_brace: + a = Vector((sx, py_f, 0.28)) + b = Vector((sx, py_b, BACK_H * 0.58)) + else: + # On the post centreline so the brace tenons the post instead + # of reading as a wing in the front elevation. + a = Vector((sx, 0.04, COUNTER_Z + 0.14)) + b = Vector((sx, py_b, BACK_H * 0.56)) + wood_verts.extend(add_oriented_box(bm, a, b, (BRACE, BRACE), WOOD_IDX)) + + inner_w = WIDTH - 2.0 * POST - 0.024 + y0 = py_f + POST / 2.0 + 0.016 + y1 = y0 + COUNTER_D n_slats = 6 - slat_gap = 0.010 - slat_d = (COUNTER_D - 0.02 - (n_slats - 1) * slat_gap) / n_slats - y0 = cy - COUNTER_D / 2.0 + 0.01 + slat_d / 2.0 + slat_gap = 0.008 + slat_d = (COUNTER_D - (n_slats - 1) * slat_gap) / n_slats for i in range(n_slats): + jw = 0.018 * math.sin(i * 2.31 + 0.5) + jt = 0.006 * math.sin(i * 1.87) + y = y0 + slat_d / 2.0 + i * (slat_d + slat_gap) wood_verts.extend( add_box( bm, - (0.0, y0 + i * (slat_d + slat_gap), COUNTER_Z), - (WIDTH - 0.06, slat_d, COUNTER_T * 0.72), + (0.0, y, COUNTER_Z), + (inner_w + jw, slat_d * 0.94, COUNTER_T * 0.62 + jt), WOOD_IDX, ) ) wood_verts.extend( add_box( bm, - (0.0, cy - COUNTER_D / 2.0 + 0.018, COUNTER_Z - 0.09), - (WIDTH - 0.08, 0.036, 0.16), + (0.0, y0 + 0.016, COUNTER_Z - 0.08), + (inner_w * 0.96, 0.032, 0.14), WOOD_IDX, ) ) - under_shelf_z = 0.38 + shelf_z = 0.38 n_shelf = 4 shelf_d = COUNTER_D * 0.78 - shelf_gap = 0.010 + shelf_gap = 0.008 shelf_slat = (shelf_d - (n_shelf - 1) * shelf_gap) / n_shelf - sy0 = cy - shelf_d / 2.0 + shelf_slat / 2.0 + sy0 = y0 + 0.04 + shelf_slat / 2.0 for i in range(n_shelf): + jw = 0.014 * math.sin(i * 1.63 + 0.9) wood_verts.extend( add_box( bm, - (0.0, sy0 + i * (shelf_slat + shelf_gap), under_shelf_z), - (WIDTH - 0.16, shelf_slat, 0.024), + (0.0, sy0 + i * (shelf_slat + shelf_gap), shelf_z), + (inner_w - 0.08 + jw, shelf_slat * 0.94, 0.024), WOOD_IDX, ) ) - for lx in (-hx + 0.16, hx - 0.16): - for ly in (cy - COUNTER_D * 0.32, cy + COUNTER_D * 0.32): + leg_h = COUNTER_Z - COUNTER_T * 0.32 + for lx in (-inner_w / 2.0 + 0.04, inner_w / 2.0 - 0.04): + for ly in (y0 + 0.05, y1 - 0.05): wood_verts.extend( - add_box( - bm, - (lx, ly, COUNTER_Z / 2.0), - (0.045, 0.045, COUNTER_Z), - WOOD_IDX, - ) + add_box(bm, (lx, ly, leg_h / 2.0), (0.042, 0.042, leg_h), WOOD_IDX) ) - # Back wall: horizontal planks with gaps, not a felt slab. + plank_y = py_b - POST / 2.0 - 0.014 + plank_len = WIDTH - POST for i in range(5): - z = 0.36 + i * 0.155 + z = 0.30 + i * 0.148 + jw = 0.004 * math.sin(i * 2.11) wood_verts.extend( add_box( bm, - (0.0, hy - inset - 0.014, z), - (WIDTH - 2.35 * POST, 0.028, 0.122), + (0.0, plank_y, z), + (plank_len + jw, 0.028, 0.118), WOOD_IDX, ) ) - # Front eave fascia under the valance. + fascia_y = -hy - OVERHANG_F wood_verts.extend( add_box( bm, - (0.0, -hy - 0.008, FRONT_H - 0.028), - (WIDTH - POST * 0.4, 0.032, 0.048), + (0.0, fascia_y, FRONT_H - 0.022), + (WIDTH - POST * 0.2, 0.032, 0.044), WOOD_IDX, ) ) - - stripe_w = WIDTH / N_STRIPES - awning_len = math.hypot(DEPTH, dz) - awning_pitch = math.atan2(dz, DEPTH) - cz = (FRONT_H + BACK_H) / 2.0 + AWNING_T * 0.35 - for i in range(N_STRIPES): - cx = -hx + (i + 0.5) * stripe_w - idx = STRIPE_A_IDX if (i % 2 == 0) else STRIPE_B_IDX - before = set(bm.faces) + wood_verts.extend( add_box( bm, - (cx, 0.0, cz), - (stripe_w * 0.98, awning_len + 0.04, AWNING_T), - idx, - euler=(awning_pitch, 0.0, 0.0), + (0.0, fascia_y - 0.006, FRONT_H - 0.004), + (WIDTH - POST * 0.05, 0.022, 0.022), + WOOD_IDX, ) - new_faces = set(bm.faces) - before - if idx == STRIPE_A_IDX: - stripe_a.update(new_faces) - else: - stripe_b.update(new_faces) + ) - # Front valance hanging from the eave. - for i in range(N_STRIPES): - cx = -hx + (i + 0.5) * stripe_w - idx = STRIPE_B_IDX if (i % 2 == 0) else STRIPE_A_IDX - before = set(bm.faces) - add_box( + # Centreline sits above the header; thickness then hangs along the + # roof normal so the front underside clears the fascia top. + seat = 0.012 + front = Vector((0.0, -hy - OVERHANG_F, FRONT_H + seat)) + back = Vector((0.0, hy + OVERHANG_B, BACK_H + seat)) + dy = back.y - front.y + dz = back.z - front.z + awning_len = math.hypot(dy, dz) + pitch = math.atan2(dz, dy) + nrm = Vector((0.0, -math.sin(pitch), math.cos(pitch))) + mid = (front + back) * 0.5 + if float_awning: + mid = Vector((mid.x, mid.y, mid.z + FLOAT_AWNING)) + stripe_faces.extend( + add_striped_slab( bm, - (cx, -hy - 0.01, FRONT_H - 0.08), - (stripe_w * 0.96, 0.016, 0.14), - idx, + mid, + (WIDTH, awning_len, AWNING_T), + (pitch, 0.0, 0.0), + N_STRIPES, + STRIPE_A_IDX, + STRIPE_B_IDX, ) - new_faces = set(bm.faces) - before - if idx == STRIPE_A_IDX: - stripe_a.update(new_faces) - else: - stripe_b.update(new_faces) + ) + val_z = FRONT_H - 0.09 + (FLOAT_AWNING if float_awning else 0.0) + stripe_faces.extend( + add_striped_slab( + bm, + (0.0, fascia_y - 0.002, val_z), + (WIDTH, 0.014, 0.15), + (0.0, 0.0, 0.0), + N_STRIPES, + STRIPE_B_IDX, + STRIPE_A_IDX, + ) + ) if bevel_offset > 0.0: edges = list({e for v in wood_verts for e in v.link_edges}) @@ -673,9 +785,10 @@

Source

) zmin = min(v.co.z for v in bm.verts) - if zmin != 0.0: - for v in bm.verts: - v.co.z -= zmin + for v in bm.verts: + v.co.z -= zmin + if v.co.z < 0.002: + v.co.z = 0.0 pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) @@ -686,12 +799,9 @@

Source

if edge.is_manifold and len(edge.link_faces) == 2: if edge.calc_face_angle() < math.radians(25.0): edge.smooth = True - for f in stripe_a: - if f.is_valid: - f.material_index = STRIPE_A_IDX - for f in stripe_b: - if f.is_valid: - f.material_index = STRIPE_B_IDX + for face in stripe_faces: + if face.is_valid: + pass me = bpy.data.meshes.new(name) bm.to_mesh(me) me.update() @@ -757,6 +867,271 @@

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): + 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 aabb_overlap(a, b): + x = min(a[3], b[3]) - max(a[0], b[0]) + y = min(a[4], b[4]) - max(a[1], b[1]) + z = min(a[5], b[5]) - max(a[2], b[2]) + if x <= 0.0 or y <= 0.0 or z <= 0.0: + return 0.0 + return x * y * z + + +def mat_of(me, group): + member = set(group) + for poly in me.polygons: + if all(i in member for i in poly.vertices): + return poly.material_index + return None + + +def support_audit(me): + """Post feet: wrap plates clustered at the four frame corners.""" + plates = [] + for group in shells(me): + if mat_of(me, group) != WOOD_IDX: + continue + a = shell_aabb(me, group) + dz = a[5] - a[2] + dx = a[3] - a[0] + dy = a[4] - a[1] + cz = 0.5 * (a[2] + a[5]) + if cz > 0.10 or dz > 0.10: + continue + if max(dx, dy) < POST * 0.8: + continue + plates.append(a) + corners = {} + for a in plates: + cx = 0.5 * (a[0] + a[3]) + cy = 0.5 * (a[1] + a[4]) + key = (1 if cx > 0.0 else -1, 1 if cy > 0.0 else -1) + corners.setdefault(key, []).append(a) + zmin = min((a[2] for a in plates), default=99.0) + return {"feet": len(corners), "foot_z": zmin} + + +def joint_audit(me): + """Braces must not occupy the counter volume. Headers bite the posts.""" + hx = WIDTH / 2.0 + groups = shells(me) + boxes = [(g, shell_aabb(me, g), mat_of(me, g)) for g in groups] + posts = [] + headers = [] + braces = [] + slats = [] + for _g, a, mat in boxes: + if mat != WOOD_IDX: + continue + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + cx = 0.5 * (a[0] + a[3]) + cy = 0.5 * (a[1] + a[4]) + cz = 0.5 * (a[2] + a[5]) + if dz > FRONT_H * 0.55 and dx < POST * 2.4 and dy < POST * 2.4: + posts.append(a) + continue + if dx > WIDTH * 0.6 and dz < POST * 2.2 and dy < POST * 2.2 and cz > FRONT_H * 0.7: + headers.append(a) + continue + if abs(cx) > hx - POST * 1.8 and dz > 0.35 and dy > 0.25 and dx < 0.20: + braces.append(a) + continue + if abs(cz - COUNTER_Z) < 0.08 and dx > WIDTH * 0.4: + slats.append(a) + overlap = 0.0 + for brace in braces: + for slat in slats: + overlap = max(overlap, aabb_overlap(brace, slat)) + pairs = [ + min(h[3] - p[0], p[3] - h[0]) + for h in headers + for p in posts + if abs(0.5 * (h[1] + h[4]) - 0.5 * (p[1] + p[4])) < POST * 2.0 + ] + engage = min(pairs) if pairs else 1.0 + return { + "posts": len(posts), + "headers": len(headers), + "braces": len(braces), + "slats": len(slats), + "overlap": overlap, + "engage": engage, + } + + +def awning_seat(me): + """Gap from front-header top to awning underside, at the front eave.""" + hy = DEPTH / 2.0 + wood_z = [] + stripe_z = [] + for poly in me.polygons: + zs = [me.vertices[i].co.z for i in poly.vertices] + ys = [me.vertices[i].co.y for i in poly.vertices] + cy = sum(ys) / len(ys) + if cy > -hy + 0.15: + continue + zmax = max(zs) + zmin = min(zs) + if poly.material_index == WOOD_IDX and zmax > FRONT_H - 0.12: + if max(ys) - min(ys) > 0.028: + wood_z.append(zmax) + if poly.material_index in (STRIPE_A_IDX, STRIPE_B_IDX) and zmax > FRONT_H: + stripe_z.append(zmin) + if not wood_z or not stripe_z: + return 99.0 + return min(stripe_z) - max(wood_z) + + +def plumb_audit(me): + """XY centroid of each post's bottom slab vs top slab.""" + drifts = [] + for group in shells(me): + if mat_of(me, group) != WOOD_IDX: + continue + a = shell_aabb(me, group) + dz = a[5] - a[2] + dx = a[3] - a[0] + dy = a[4] - a[1] + if dz < FRONT_H * 0.55 or dx > POST * 2.4 or dy > POST * 2.4: + continue + pts = [me.vertices[i].co for i in group] + zcut_lo = a[2] + 0.08 * dz + zcut_hi = a[5] - 0.08 * dz + lo = [p for p in pts if p.z <= zcut_lo] + hi = [p for p in pts if p.z >= zcut_hi] + if len(lo) < 3 or len(hi) < 3: + continue + c_lo = Vector((sum(p.x for p in lo) / len(lo), sum(p.y for p in lo) / len(lo))) + c_hi = Vector((sum(p.x for p in hi) / len(hi), sum(p.y for p in hi) / len(hi))) + drifts.append((c_hi - c_lo).length) + return max(drifts) if drifts else 99.0 + + +def frame_size(me): + """Post-foot envelope vs declared stall plan, not the awning AABB.""" + hx = WIDTH / 2.0 + hy = DEPTH / 2.0 + xs, ys, zs = [], [], [] + for group in shells(me): + if mat_of(me, group) != WOOD_IDX: + continue + a = shell_aabb(me, group) + dz = a[5] - a[2] + dx = a[3] - a[0] + dy = a[4] - a[1] + cx = 0.5 * (a[0] + a[3]) + cy = 0.5 * (a[1] + a[4]) + if dz > FRONT_H * 0.55 and dx < POST * 2.4 and dy < POST * 2.4: + xs.extend((a[0], a[3])) + ys.extend((a[1], a[4])) + zs.append(a[5]) + continue + if dz < 0.08 and abs(cx) > hx - POST * 1.6 and abs(cy) > hy - POST * 1.6: + zs.append(a[2]) + if not xs: + return 0.0, 0.0, 0.0 + return max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs) + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, COUNTER_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) @@ -849,16 +1224,47 @@

Source

) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + short_feet=False, + low_brace=False, + float_awning=False, + rake_posts=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_stall_mesh("StallLow", bevel_offset=0.006, bevel_segments=2) - high = build_stall_mesh("StallHigh", bevel_offset=0.006, bevel_segments=4) + low = build_stall_mesh( + "StallLow", + bevel_offset=0.006, + bevel_segments=2, + low_brace=low_brace, + float_awning=float_awning, + rake_posts=rake_posts, + short_feet=short_feet, + ) + high = build_stall_mesh( + "StallHigh", + bevel_offset=0.006, + bevel_segments=4, + low_brace=low_brace, + float_awning=float_awning, + rake_posts=rake_posts, + short_feet=short_feet, + ) wood = principled("StallWood", (0.42, 0.24, 0.10, 1.0), 0.0, 0.55) stripe_a = principled("StallStripeA", (0.72, 0.12, 0.10, 1.0), 0.0, 0.62) stripe_b = principled("StallStripeB", (0.86, 0.80, 0.62, 1.0), 0.0, 0.58) assign_slots(low, wood, stripe_a, stripe_b) assign_slots(high, wood, stripe_a, stripe_b) + 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() + if low.data is None or len(low.data.polygons) < 6: return fail("stall mesh did not build", 3), None, None, None, None, None @@ -875,6 +1281,13 @@

Source

size_x = bb[3] - bb[0] size_y = bb[4] - bb[1] size_z = bb[5] - bb[2] + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + jnt = joint_audit(low.data) + seat = awning_seat(low.data) + plumb = plumb_audit(low.data) + fx, fy, fz = frame_size(low.data) img, tex = setup_bake_image(low, wood) if img is None: @@ -903,9 +1316,7 @@

Source

export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 - print( - f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}" - ) + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" @@ -922,6 +1333,16 @@

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 feet={sup['feet']} foot_z={sup['foot_z']:.5f} " + f"brace_overlap={jnt['overlap']:.6f} engage={jnt['engage']:.4f} " + f"seat={seat:.5f} plumb={plumb:.5f} frame=({fx:.4f},{fy:.4f},{fz:.4f})" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -986,6 +1407,40 @@

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 bb[2] > ZMIN_EPS or sup["feet"] != FOOT_COUNT or sup["foot_z"] > FOOT_ZMIN_MAX: + return fail( + f"grounded zmin={bb[2]:.5f} feet={sup['feet']} " + f"foot_z={sup['foot_z']:.5f}", + 16, + ), None, None, None, None, None + if jnt["overlap"] > BRACE_COUNTER_OVERLAP_MAX or jnt["engage"] < TENON * 0.4: + return fail( + f"joint overlap={jnt['overlap']:.6f} engage={jnt['engage']:.4f} " + f"posts={jnt['posts']} braces={jnt['braces']} slats={jnt['slats']}", + 17, + ), None, None, None, None, None + if seat < AWNING_SEAT_MIN or seat > AWNING_SEAT_MAX: + return fail(f"awning seat gap {seat:.5f} > {AWNING_SEAT_MAX}", 18), None, None, None, None, None + if ( + plumb > POST_PLUMB_MAX + or abs(fx - WIDTH) > FRAME_XY_TOL + or abs(fy - DEPTH) > FRAME_XY_TOL + ): + return fail( + f"plumb={plumb:.5f} frame=({fx:.4f},{fy:.4f},{fz:.4f}) " + f"off {WIDTH}x{DEPTH}", + 19, + ), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -1050,13 +1505,15 @@

Source

light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + bb = world_bbox(low) + span = max(bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2], 0.2) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (3.11, -5.10, 1.60) + cam.location = (span * 2.17, -span * 2.95, span * 1.12) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, -0.10, 0.92) + aim.location = (0.0, -0.04 * span, 0.52 * (bb[2] + bb[5])) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -1099,14 +1556,24 @@

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("--stray-vert", action="store_true") + p.add_argument("--lift-z", action="store_true") + p.add_argument("--short-feet", action="store_true") + p.add_argument("--low-brace", action="store_true") + p.add_argument("--float-awning", action="store_true") + p.add_argument("--rake-posts", action="store_true") 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, + short_feet=args.short_feet, + low_brace=args.low_brace, + float_awning=args.float_awning, + rake_posts=args.rake_posts, + ) if code: return code if args.output: diff --git a/docs/gallery/watchtower/index.html b/docs/gallery/watchtower/index.html index f036db6..67d3407 100644 --- a/docs/gallery/watchtower/index.html +++ b/docs/gallery/watchtower/index.html @@ -253,28 +253,43 @@

watchtower

watchtower render

Rendered headless by the example itself — click to zoom.

-
witnesses Recomputed: 5220 tris, three materials with 84 metal and 126 roof faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.434×1.434×1.982 m, LOD ratios in band, convex collider 56 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
witnesses Recomputed: 6408 tris, three materials with 300 metal and 126 roof faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.594×1.594×2.973 m, grounded zmin, 4 iron shoes, rail engage 18 mm, collar standoff 2 mm, post plumb 0, LOD ratios in band (5.2 COLLAPSE leaner on LOD2), convex collider 150 tris, non-empty glTF. Hygiene all zero. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-shoes 16; --short-rails 17; --float-band 18; --rake-posts 19.
blender --background --python showcase/watchtower/watchtower.py --
-

A showcase piece, not an example. Procedural timber lookout (corner posts through a plank platform, lower-bay X-braces, hatch and ladder, coursed shake roof with eaves) 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 timber lookout (corner posts through a plank platform, lower-bay X-braces, hatch and ladder whose stiles plant at Z=0, coursed shake roof over a solid cone, mitered iron collars and shoes) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

The old roof was a wood U with incomplete shake coverage; rails showed through the eave. The cone plus non-overlapping courses close the pyramid. Shoes are added after the zmin snap so a bevel undershoot cannot lift them.

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 | 5110–5330 | 5220 / 5220 / 5220 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2199 | | Materials | exactly 3 distinct, ≥24 metal, ≥40 roof | 3 slots, 84 metal, 126 roof | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.434, 1.434, 1.982) m ± 0.01 | (1.4340, 1.4340, 1.9818), zmin 0 | | Collider tris | ≤ 80 | 56 | | Export | written, size > 0 | 374376 / 374376 / 374360 bytes |

-

DECIMATE COLLAPSE triangle counts are not required to match 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.

-

--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 | 6200–8200 | 6408 / 6408 / 6408 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2197 / 0.2197 / 0.2185 | | Materials | exactly 3 distinct; ≥80 metal, ≥40 roof faces | 3 slots; 300 metal, 126 roof | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.594, 1.594, 2.973) m ± 0.01 | (1.5940, 1.5940, 2.9731), zmin 0 | | Collider tris | ≤ 180 | 150 | | Export | written, size > 0 | 464572 / 464572 / 464560 bytes |

+

Base triangles rose from 5220 to 6408 in the quality pass: a taller eave-true roof and coursed shakes, not hidden interior faces.

+

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 iron shoes | each zmin ≤ 1e-3 | 4, shoe_z 0.00000 | | Plan | 1.56 m eave × 2.97 m height ± 0.08 / ± 0.28 | 1.5940 × 2.9731 |

+

Joint fit and seat

+

| Axis | Declared | Measured (all three) | | --- | --- | --- | | Rail-to-post engage | ≥ 0.012 m | 0.0180 | | Girt-collar standoff | ≤ 0.010 m | 0.00200 | | Post plumb (XY drift) | ≤ 0.010 m | 0.00000 |

+

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 12 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-shoes | named shoe supports at Z=0 | 16 | | --short-rails | rail-to-post joint fit | 17 | | --float-band | iron-collar seat | 18 | | --rake-posts | post plumb | 19 |

Run

blender --background --python watchtower.py --
 blender --background --python watchtower.py -- --skip-decimate
+blender --background --python watchtower.py -- --stray-vert
+blender --background --python watchtower.py -- --lift-z
+blender --background --python watchtower.py -- --short-shoes
+blender --background --python watchtower.py -- --short-rails
+blender --background --python watchtower.py -- --float-band
+blender --background --python watchtower.py -- --rake-posts
 blender --background --python watchtower.py -- --output tower.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 metal/roof 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 metal/roof faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file | | 15 | 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: rail-to-post engage | | 18 | Seat: girt-collar standoff | | 19 | Post plumb or plan off the stated real-world size |

Source

@@ -290,8 +305,11 @@

Source

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. +They are not API-contract witnesses. Each falsifier violates one named +budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh +hygiene, ``--lift-z`` grounded zmin, ``--short-shoes`` named shoe +supports, ``--short-rails`` rail-to-post joint fit, ``--float-band`` +iron-collar seat, ``--rake-posts`` post plumb. No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts are not byte-identical across Blender versions — the LOD gate is a @@ -319,28 +337,37 @@

Source

sys.dont_write_bytecode = True import gallery_framing # noqa: E402 -HALF = 0.46 +HALF = 0.52 POST = 0.090 -POST_H = 1.58 -PLAT_Z = 1.04 -RAIL_H = 0.40 -GIRT_Z = 0.52 -KICK_H = 0.18 -BRACE_T = 0.042 +POST_H = 2.42 +PLAT_Z = 1.72 PLANK_T = 0.038 -IRON_T = 0.014 -SHOE_H = 0.034 -EAVE_HALF = 0.70 -ROOF_RISE = 0.40 +# Guardrails sit in the bay between the deck and the eave — a 0.90 m +# code rail would punch through the roof. +RAIL_CLEAR = 0.10 +RAIL_H = POST_H - PLAT_Z - PLANK_T - RAIL_CLEAR +GIRT_Z = 0.86 +GIRT_H = 0.075 +SILL_H = 0.085 +KICK_H = 0.20 +BRACE_T = 0.042 +IRON_T = 0.012 +IRON_H = 0.028 +IRON_STANDOFF = 0.002 +SHOE_H = 0.040 +EAVE_HALF = 0.78 +ROOF_RISE = 0.55 SHINGLE_T = 0.016 N_SHINGLE = 5 N_PLANK = 6 -HATCH_PLANKS = 1 +HATCH_PLANKS = 2 +LEAN = 0.18 +RAIL_TENON = POST * 0.40 BBOX_TOL = 0.01 -OUTER_SIZE = (1.434, 1.434, 1.982) -BASE_TRIS_MIN = 5110 -BASE_TRIS_MAX = 5330 +OUTER_SIZE = (1.594, 1.594, 2.973) +BASE_TRIS_MIN = 6200 +BASE_TRIS_MAX = 8200 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -350,11 +377,28 @@

Source

MATERIAL_COUNT = 3 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 80 +COLLIDER_TRIS_MAX = 180 BAKE_RES = 256 CAGE_EXTRUSION = 0.10 -METAL_FACES_MIN = 24 +METAL_FACES_MIN = 80 ROOF_FACES_MIN = 40 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.998 +SHOE_COUNT = 4 +SHOE_ZMIN_MAX = 0.001 +RAIL_ENGAGE_MIN = 0.012 +BAND_SEAT_MAX = 0.010 +POST_PLUMB_MAX = 0.010 +PLAN_XY = 2.0 * EAVE_HALF +PLAN_Z = POST_H + ROOF_RISE +PLAN_TOL = 0.08 +LIFT_Z = 0.05 +RAKE = math.radians(2.0) +SHORT_SHOE_Z = 0.055 +BAND_FLOAT = 0.040 WOOD_IDX = 0 ROOF_IDX = 1 @@ -393,6 +437,20 @@

Source

ob.select_set(False) +def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) + v.co = rot @ p + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + def add_oriented_box(bm, a, b, scale_xy, mat_idx): a = Vector(a) b = Vector(b) @@ -411,20 +469,6 @@

Source

) -def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): - geo = bmesh.ops.create_cube(bm, size=1.0) - verts = geo["verts"] - rot = Euler(euler).to_matrix() - origin = Vector(loc) - for v in verts: - p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) - v.co = rot @ p + origin - faces = {f for v in verts for f in v.link_faces} - for f in faces: - f.material_index = mat_idx - 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, @@ -446,6 +490,21 @@

Source

return verts +def add_collar(bm, px, py, z, height, mat_idx, standoff=0.0): + """Four plates on the post faces — a wrap, not a cube through the timber. + + X-facing plates cover the post face only. Y-facing plates cover the + corners so the wrap meets at a butt instead of a coplanar overlap. + """ + half = POST / 2.0 + t = IRON_T + off = half + t / 2.0 + standoff + add_box(bm, (px + off, py, z), (t, POST, height), mat_idx) + add_box(bm, (px - off, py, z), (t, POST, height), mat_idx) + add_box(bm, (px, py + off, z), (POST + 2.0 * t, t, height), mat_idx) + add_box(bm, (px, py - off, z), (POST + 2.0 * t, t, height), mat_idx) + + def pack_uvs(bm, margin=0.08): uv = bm.loops.layers.uv.new("UVMap") faces = list(bm.faces) @@ -490,7 +549,9 @@

Source

def add_shingle_roof(bm, eave_z, peak_z): - """Well-style coursed shakes on a square pyramid, plus a cone underside.""" + """Coursed shakes on a solid square pyramid. Each course is offset along + the face normal so overlapping shakes do not share vertex positions. + """ add_cone( bm, (0.0, 0.0, (eave_z + peak_z) / 2.0), @@ -503,7 +564,7 @@

Source

) nrm_local = Vector((0.0, ROOF_RISE, EAVE_HALF)).normalized() - def add_course(yaw, t0, t1): + def add_course(yaw, t0, t1, row): rot = Euler((0.0, 0.0, yaw)).to_matrix() nrm = rot @ nrm_local @@ -513,8 +574,8 @@

Source

z = peak_z - t * ROOF_RISE return rot @ Vector((s * w, y, z)) - inner = SHINGLE_T * 0.12 - outer = SHINGLE_T * 1.05 + inner = SHINGLE_T * (0.20 + 0.22 * row) + outer = inner + SHINGLE_T * 0.90 corners = ( pt(t0, -1.0), pt(t0, 1.0), @@ -538,12 +599,20 @@

Source

for side in range(4): yaw = side * (math.pi / 2.0) for row in range(N_SHINGLE): - t0 = (row + 0.16) / N_SHINGLE - t1 = min(1.0, (row + 1.08) / N_SHINGLE) - add_course(yaw, t0, t1) - - -def build_tower_mesh(name, bevel_offset, bevel_segments): + t0 = (row + 0.14) / N_SHINGLE + t1 = min(0.96, (row + 1.0) / N_SHINGLE) + add_course(yaw, t0, t1, row) + + +def build_tower_mesh( + name, + bevel_offset, + bevel_segments, + short_shoes=False, + short_rails=False, + float_band=False, + rake_posts=False, +): bm = bmesh.new() try: wood = [] @@ -553,28 +622,38 @@

Source

(-HALF, HALF), (HALF, HALF), ) + rake = (RAKE, 0.0, 0.0) if rake_posts else (0.0, 0.0, 0.0) for px, py in posts: wood.extend( - add_box(bm, (px, py, POST_H / 2.0), (POST, POST, POST_H), WOOD_IDX) + add_box( + bm, + (px, py, POST_H / 2.0), + (POST, POST, POST_H), + WOOD_IDX, + euler=rake, + ) ) bay = 2.0 * HALF - POST - sill_h = 0.085 - girt_h = 0.075 + # Tenon into the posts; thickness under POST so side faces are not + # coplanar with the post faces. + member = bay + POST * 0.40 + section = POST * 0.70 + sill_z = SHOE_H + SILL_H / 2.0 for sign in (-1.0, 1.0): wood.extend( add_box( bm, - (0.0, sign * HALF, sill_h / 2.0), - (bay, POST * 0.82, sill_h), + (0.0, sign * HALF, sill_z), + (member, section, SILL_H), WOOD_IDX, ) ) wood.extend( add_box( bm, - (sign * HALF, 0.0, sill_h / 2.0), - (POST * 0.82, bay, sill_h), + (sign * HALF, 0.0, sill_z), + (section, member, SILL_H), WOOD_IDX, ) ) @@ -582,7 +661,7 @@

Source

add_box( bm, (0.0, sign * HALF, GIRT_Z), - (bay, POST * 0.78, girt_h), + (member, section, GIRT_H), WOOD_IDX, ) ) @@ -590,73 +669,96 @@

Source

add_box( bm, (sign * HALF, 0.0, GIRT_Z), - (POST * 0.78, bay, girt_h), + (section, member, GIRT_H), WOOD_IDX, ) ) - for ysign in (-1.0, 1.0): - if ysign < 0.0: - continue + # Kick sits on the sill, not on the same plane as the sill bottom. + kick_z = SHOE_H + SILL_H + KICK_H / 2.0 + kick_span = bay * 0.88 + wood.extend( + add_box( + bm, + (0.0, HALF - 0.020, kick_z), + (kick_span, POST * 0.50, KICK_H), + WOOD_IDX, + ) + ) + for xsign in (-1.0, 1.0): wood.extend( add_box( bm, - (0.0, ysign * HALF, KICK_H / 2.0 + 0.01), - (bay * 0.92, POST * 0.55, KICK_H), + (xsign * (HALF - 0.020), 0.0, kick_z), + (POST * 0.50, kick_span, KICK_H), WOOD_IDX, ) ) - for xsign in (-1.0, 1.0): + hatch_gap = 0.32 + kick_half = (bay * 0.92 - hatch_gap) / 2.0 + for xoff in (-(hatch_gap / 2.0 + kick_half / 2.0), hatch_gap / 2.0 + kick_half / 2.0): wood.extend( add_box( bm, - (xsign * HALF, 0.0, KICK_H / 2.0 + 0.01), - (POST * 0.55, bay * 0.92, KICK_H), + (xoff, -HALF + 0.020, kick_z), + (kick_half, POST * 0.55, KICK_H), WOOD_IDX, ) ) - rise = GIRT_Z - sill_h + rise = GIRT_Z - (SHOE_H + SILL_H) run = bay blen = math.hypot(run, rise) bang = math.atan2(rise, run) - brace_z = (sill_h + GIRT_Z) / 2.0 - for ysign in (-1.0, 1.0): - if ysign < 0.0: - continue - for bang_sign in (-1.0, 1.0): + brace_z = (SHOE_H + SILL_H + GIRT_Z) / 2.0 + # Lap the two diagonals by a brace thickness so they do not occupy + # one plane (the black diamond in the old crossing). + for ysign in (1.0,): + for k, bang_sign in enumerate((-1.0, 1.0)): wood.extend( add_box( bm, - (0.0, ysign * HALF, brace_z), + (0.0, ysign * HALF + ysign * k * (BRACE_T + 0.008), brace_z), (blen, BRACE_T, BRACE_T), WOOD_IDX, euler=(0.0, bang_sign * bang, 0.0), ) ) for xsign in (-1.0, 1.0): - for bang_sign in (-1.0, 1.0): + for k, bang_sign in enumerate((-1.0, 1.0)): wood.extend( add_box( bm, - (xsign * HALF, 0.0, brace_z), + (xsign * HALF + xsign * k * (BRACE_T + 0.008), 0.0, brace_z), (BRACE_T, blen, BRACE_T), WOOD_IDX, euler=(bang_sign * bang, 0.0, 0.0), ) ) + for xsign in (-1.0, 1.0): + wood.extend( + add_oriented_box( + bm, + (xsign * (HALF - POST * 0.2), -HALF, SHOE_H + SILL_H), + (xsign * 0.22, -HALF, GIRT_Z), + (BRACE_T, BRACE_T), + WOOD_IDX, + ) + ) plank_span_x = 2.0 * HALF + POST * 0.15 usable_y = 2.0 * HALF - 0.06 plank_w = usable_y / N_PLANK y0 = -HALF + 0.03 + plank_w / 2.0 + hatch_y1 = y0 + (HATCH_PLANKS - 0.5) * plank_w for i in range(HATCH_PLANKS, N_PLANK): y = y0 + i * plank_w + jw = 0.006 * math.sin(i * 1.7) wood.extend( add_box( bm, (0.0, y, PLAT_Z + PLANK_T / 2.0), - (plank_span_x, plank_w * 0.86, PLANK_T), + (plank_span_x + jw, plank_w * 0.90, PLANK_T), WOOD_IDX, ) ) @@ -664,61 +766,85 @@

Source

wood.extend( add_box( bm, - (xj, 0.06, PLAT_Z - 0.022), - (POST * 0.62, 2.0 * HALF * 0.88, 0.044), + (xj, 0.10, PLAT_Z - 0.022), + (POST * 0.62, 2.0 * HALF * 0.82, 0.044), + WOOD_IDX, + ) + ) + # Hatch trim: three boards around the missing planks. + wood.extend( + add_box( + bm, + (0.0, hatch_y1, PLAT_Z + PLANK_T / 2.0), + (0.46, 0.040, PLANK_T), + WOOD_IDX, + ) + ) + for xsign in (-1.0, 1.0): + wood.extend( + add_box( + bm, + (xsign * 0.23, -HALF + 0.12, PLAT_Z + PLANK_T / 2.0), + (0.040, hatch_y1 + HALF - 0.02, PLANK_T), WOOD_IDX, ) ) - rail_z_lo = PLAT_Z + PLANK_T + 0.12 - rail_z_hi = PLAT_Z + PLANK_T + RAIL_H - 0.04 + rail_z_lo = PLAT_Z + PLANK_T + 0.14 + rail_z_hi = PLAT_Z + PLANK_T + RAIL_H - 0.05 rail_t = POST * 0.42 + rail_len = bay * 0.70 if short_rails else bay + RAIL_TENON wood.extend( - add_box(bm, (0.0, HALF, rail_z_lo), (bay, rail_t, 0.048), WOOD_IDX) + add_box(bm, (0.0, HALF, rail_z_lo), (rail_len, rail_t, 0.048), WOOD_IDX) ) wood.extend( - add_box(bm, (0.0, HALF, rail_z_hi), (bay, rail_t, 0.048), WOOD_IDX) + add_box(bm, (0.0, HALF, rail_z_hi), (rail_len, rail_t, 0.048), WOOD_IDX) ) + hatch_clear = 0.30 + side_y0 = -HALF + hatch_clear + side_span = HALF - side_y0 + side_cy = (side_y0 + HALF) / 2.0 + side_len = side_span + (0.0 if short_rails else RAIL_TENON * 0.5) for xsign in (-1.0, 1.0): wood.extend( add_box( bm, - (xsign * HALF, 0.08, rail_z_lo), - (rail_t, bay * 0.78, 0.048), + (xsign * HALF, side_cy, rail_z_lo), + (rail_t, side_len, 0.048), WOOD_IDX, ) ) wood.extend( add_box( bm, - (xsign * HALF, 0.08, rail_z_hi), - (rail_t, bay * 0.78, 0.048), + (xsign * HALF, side_cy, rail_z_hi), + (rail_t, side_len, 0.048), WOOD_IDX, ) ) - lean = 0.20 - bot_y = -HALF - lean - top_y = -HALF + 0.02 - top_z = PLAT_Z + 0.18 - for sx in (-0.12, 0.12): + bot_y = -HALF - LEAN + top_y = -HALF + 0.10 + top_z = PLAT_Z + # Stiles plant at z=0. Separate foot cubes on the same plane as the + # stile bottoms were coplanar z-fights; a chamfered stile end is the + # foot. + for sx in (-0.13, 0.13): wood.extend( add_oriented_box( bm, - (sx, bot_y, 0.03), + (sx, bot_y, 0.0), (sx, top_y, top_z), - (0.040, 0.044), + (0.044, 0.050), WOOD_IDX, ) ) - n_rung = 8 + n_rung = 9 for i in range(n_rung): - t = (i + 1) / (n_rung + 1) - z = 0.08 + t * (PLAT_Z - 0.14) - y = bot_y + (top_y - bot_y) * ((z - 0.03) / (top_z - 0.03)) - wood.extend( - add_box(bm, (0.0, y, z), (0.26, 0.034, 0.032), WOOD_IDX) - ) + t = (i + 0.6) / (n_rung + 0.2) + z = 0.06 + t * (PLAT_Z - 0.14) + y = bot_y + (top_y - bot_y) * (z / max(top_z, 1e-6)) + wood.extend(add_box(bm, (0.0, y, z), (0.28, 0.034, 0.032), WOOD_IDX)) if bevel_offset > 0.0: edges = list({e for v in wood for e in v.link_edges}) @@ -739,6 +865,7 @@

Source

add_shingle_roof(bm, eave_z, peak_z) fascia_h = 0.048 fascia_t = 0.034 + fascia_span = 2.0 * EAVE_HALF - 0.10 for side in range(4): yaw = side * (math.pi / 2.0) fx = EAVE_HALF * math.sin(yaw) @@ -746,58 +873,44 @@

Source

if side % 2 == 0: add_box( bm, - (0.0, fy, eave_z - fascia_h / 2.0), - (2.0 * EAVE_HALF + fascia_t, fascia_t, fascia_h), + (0.0, fy, eave_z - fascia_h / 2.0 - 0.008), + (fascia_span, fascia_t, fascia_h), WOOD_IDX, ) else: add_box( bm, - (fx, 0.0, eave_z - fascia_h / 2.0), - (fascia_t, 2.0 * EAVE_HALF + fascia_t, fascia_h), + (fx, 0.0, eave_z - fascia_h / 2.0 - 0.008), + (fascia_t, fascia_span, fascia_h), WOOD_IDX, ) + zmin = min(v.co.z for v in bm.verts) + for v in bm.verts: + v.co.z -= zmin + if v.co.z < 0.005: + v.co.z = 0.0 + + shoe_z = SHORT_SHOE_Z if short_shoes else SHOE_H / 2.0 + shoe_h = 0.018 if short_shoes else SHOE_H + band_off = BAND_FLOAT if float_band else IRON_STANDOFF + girt_band_z = GIRT_Z + GIRT_H / 2.0 + IRON_H / 2.0 + deck_band_z = PLAT_Z + PLANK_T + IRON_H / 2.0 + 0.006 for px, py in posts: - add_box( - bm, - (px, py, SHOE_H / 2.0), - (POST * 1.38, POST * 1.38, SHOE_H), - METAL_IDX, + add_collar(bm, px, py, shoe_z, shoe_h, METAL_IDX, standoff=IRON_STANDOFF) + add_collar(bm, px, py, girt_band_z, IRON_H, METAL_IDX, standoff=band_off) + add_collar( + bm, px, py, deck_band_z, IRON_H, METAL_IDX, + standoff=band_off, ) + for sx in (-0.13, 0.13): add_box( bm, - (px, py, GIRT_Z), - (POST * 1.12, POST * 1.12, IRON_T), - METAL_IDX, - ) - add_box( - bm, - (px, py, PLAT_Z + PLANK_T + IRON_T / 2.0), - (POST * 1.18, POST * 1.18, IRON_T), - METAL_IDX, - ) - for sx in (-0.12, 0.12): - add_box( - bm, - (sx, -HALF - 0.02, PLAT_Z + 0.02), - (0.055, 0.06, IRON_T), + (sx, -HALF + 0.02, PLAT_Z + 0.02), + (0.050, 0.055, IRON_T), 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] - cx = 0.5 * (min(xs) + max(xs)) - cy = 0.5 * (min(ys) + max(ys)) - zmin = min(zs) - for v in bm.verts: - v.co.x -= cx - v.co.y -= cy - v.co.z -= zmin - if v.co.z < 0.0: - v.co.z = 0.0 - pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) for face in bm.faces: @@ -867,6 +980,232 @@

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 poly in me.polygons: + if all(i in member for i in poly.vertices): + return poly.material_index + return None + + +def support_audit(me): + plates = [] + for group in shells(me): + if mat_of(me, group) != METAL_IDX: + continue + a = shell_aabb(me, group) + cz = 0.5 * (a[2] + a[5]) + if cz > 0.12: + continue + plates.append(a) + corners = {} + for a in plates: + cx = 0.5 * (a[0] + a[3]) + cy = 0.5 * (a[1] + a[4]) + key = (round(cx * 4.0) / 4.0, round(cy * 4.0) / 4.0) + corners.setdefault(key, []).append(a) + zmin = min((a[2] for a in plates), default=99.0) + return {"shoes": len(corners), "shoe_z": zmin} + + +def joint_audit(me): + """Guardrail bite into the +Y posts.""" + posts = [] + rails = [] + for group in shells(me): + if mat_of(me, group) != WOOD_IDX: + continue + a = shell_aabb(me, group) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + cy = 0.5 * (a[1] + a[4]) + cz = 0.5 * (a[2] + a[5]) + if dz > POST_H * 0.55 and dx < POST * 2.2 and dy < POST * 2.2: + posts.append(a) + continue + if ( + cz > PLAT_Z + and dx > HALF + and dy < POST + and abs(cy - HALF) < POST + ): + rails.append(a) + back_posts = [p for p in posts if 0.5 * (p[1] + p[4]) > 0.0] + pairs = [ + min(r[3] - p[0], p[3] - r[0]) + for r in rails + for p in back_posts + ] + engage = min(pairs) if pairs else 1.0 + return {"posts": len(posts), "rails": len(rails), "engage": engage} + + +def band_seat(me): + """Standoff from each girt collar's inner face to the post outer face.""" + posts = [] + for group in shells(me): + if mat_of(me, group) != WOOD_IDX: + continue + a = shell_aabb(me, group) + dz = a[5] - a[2] + dx = a[3] - a[0] + dy = a[4] - a[1] + if dz > POST_H * 0.55 and dx < POST * 2.2 and dy < POST * 2.2: + posts.append(a) + if not posts: + return 99.0 + gaps = [] + for group in shells(me): + if mat_of(me, group) != METAL_IDX: + continue + a = shell_aabb(me, group) + cz = 0.5 * (a[2] + a[5]) + girt_band_z = GIRT_Z + GIRT_H / 2.0 + IRON_H / 2.0 + if abs(cz - girt_band_z) > IRON_H: + continue + cx = 0.5 * (a[0] + a[3]) + cy = 0.5 * (a[1] + a[4]) + dx = a[3] - a[0] + dy = a[4] - a[1] + nearest = min( + posts, + key=lambda p: abs(cx - 0.5 * (p[0] + p[3])) + abs(cy - 0.5 * (p[1] + p[4])), + ) + px = 0.5 * (nearest[0] + nearest[3]) + py = 0.5 * (nearest[1] + nearest[4]) + if dx <= dy: + inner = a[0] if cx > px else a[3] + gaps.append(abs(inner - (px + (POST / 2.0 if cx > px else -POST / 2.0)))) + else: + inner = a[1] if cy > py else a[4] + gaps.append(abs(inner - (py + (POST / 2.0 if cy > py else -POST / 2.0)))) + if not gaps: + return 99.0 + return max(gaps) + + +def plumb_audit(me): + drifts = [] + for group in shells(me): + if mat_of(me, group) != WOOD_IDX: + continue + a = shell_aabb(me, group) + dz = a[5] - a[2] + dx = a[3] - a[0] + dy = a[4] - a[1] + if dz < POST_H * 0.55 or dx > POST * 2.2 or dy > POST * 2.2: + continue + pts = [me.vertices[i].co for i in group] + zcut_lo = a[2] + 0.08 * dz + zcut_hi = a[5] - 0.08 * dz + lo = [p for p in pts if p.z <= zcut_lo] + hi = [p for p in pts if p.z >= zcut_hi] + if len(lo) < 3 or len(hi) < 3: + continue + c_lo = Vector((sum(p.x for p in lo) / len(lo), sum(p.y for p in lo) / len(lo))) + c_hi = Vector((sum(p.x for p in hi) / len(hi), sum(p.y for p in hi) / len(hi))) + drifts.append((c_hi - c_lo).length) + return max(drifts) if drifts else 99.0 + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, PLAT_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) @@ -953,16 +1292,47 @@

Source

) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + short_shoes=False, + short_rails=False, + float_band=False, + rake_posts=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_tower_mesh("TowerLow", bevel_offset=0.006, bevel_segments=2) - high = build_tower_mesh("TowerHigh", bevel_offset=0.006, bevel_segments=4) + low = build_tower_mesh( + "TowerLow", + bevel_offset=0.006, + bevel_segments=2, + short_shoes=short_shoes, + short_rails=short_rails, + float_band=float_band, + rake_posts=rake_posts, + ) + high = build_tower_mesh( + "TowerHigh", + bevel_offset=0.006, + bevel_segments=4, + short_shoes=short_shoes, + short_rails=short_rails, + float_band=float_band, + rake_posts=rake_posts, + ) wood = principled("TowerWood", (0.42, 0.22, 0.08, 1.0), 0.0, 0.56) roof = principled("TowerShake", (0.30, 0.28, 0.26, 1.0), 0.0, 0.74) metal = principled("TowerIron", (0.12, 0.125, 0.14, 1.0), 1.0, 0.30) assign_slots(low, wood, roof, metal) assign_slots(high, wood, roof, 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() + if low.data is None or len(low.data.polygons) < 6: return fail("tower mesh did not build", 3), None, None, None, None, None @@ -979,6 +1349,12 @@

Source

size_x = bb[3] - bb[0] size_y = bb[4] - bb[1] size_z = bb[5] - bb[2] + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + jnt = joint_audit(low.data) + seat = band_seat(low.data) + plumb = plumb_audit(low.data) img, tex = setup_bake_image(low, wood) if img is None: @@ -1024,6 +1400,16 @@

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 shoes={sup['shoes']} shoe_z={sup['shoe_z']:.5f} " + f"rail_engage={jnt['engage']:.4f} band_seat={seat:.5f} " + f"plumb={plumb:.5f}" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -1088,6 +1474,40 @@

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 bb[2] > ZMIN_EPS or sup["shoes"] != SHOE_COUNT or sup["shoe_z"] > SHOE_ZMIN_MAX: + return fail( + f"grounded zmin={bb[2]:.5f} shoes={sup['shoes']} " + f"shoe_z={sup['shoe_z']:.5f}", + 16, + ), None, None, None, None, None + if jnt["engage"] < RAIL_ENGAGE_MIN: + return fail( + f"rail engage {jnt['engage']:.4f} < {RAIL_ENGAGE_MIN} " + f"posts={jnt['posts']} rails={jnt['rails']}", + 17, + ), None, None, None, None, None + if seat > BAND_SEAT_MAX: + return fail(f"band seat {seat:.5f} > {BAND_SEAT_MAX}", 18), None, None, None, None, None + if ( + plumb > POST_PLUMB_MAX + or abs(size_z - PLAN_Z) > PLAN_TOL + or abs(size_x - PLAN_XY) > PLAN_TOL + 0.20 + ): + return fail( + f"plumb={plumb:.5f} size=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"plan={PLAN_XY:.3f}x{PLAN_Z:.3f}", + 19, + ), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -1152,13 +1572,19 @@

Source

light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + bb = world_bbox(low) + span = max(bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2], 0.2) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (4.25, -5.80, 2.55) + cam.location = (span * 1.98, -span * 2.75, span * 1.13) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.98) + aim.location = ( + 0.5 * (bb[0] + bb[3]), + 0.5 * (bb[1] + bb[4]), + 0.42 * (bb[2] + bb[5]), + ) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -1201,14 +1627,24 @@

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("--stray-vert", action="store_true") + p.add_argument("--lift-z", action="store_true") + p.add_argument("--short-shoes", action="store_true") + p.add_argument("--short-rails", action="store_true") + p.add_argument("--float-band", action="store_true") + p.add_argument("--rake-posts", action="store_true") 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, + short_shoes=args.short_shoes, + short_rails=args.short_rails, + float_band=args.float_band, + rake_posts=args.rake_posts, + ) if code: return code if args.output: diff --git a/showcase/gallery.json b/showcase/gallery.json index 4de7eb5..bdaac1f 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -57,7 +57,7 @@ "name": "market-stall", "dir": "showcase/market-stall", "teaches": "A procedural timber market stall through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", - "witnessesFix": "Recomputed: 4404 tris, three materials with 48 faces per awning stripe slot, UVs in 0..1 with zero AABB overlap, outer AABB 1.392×0.966×1.743 m, LOD ratios in band, convex collider 46 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "witnessesFix": "Recomputed: 4324 tris, three materials with 34 faces per awning stripe slot, UVs in 0..1 with zero AABB overlap, outer AABB 1.389×1.011×1.740 m, grounded zmin, 4 wrap feet, brace-counter overlap 0, awning seat 4 mm, post plumb 0, LOD ratios in band, convex collider 58 tris, non-empty glTF. Hygiene all zero. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-feet 16; --low-brace 17; --float-awning 18; --rake-posts 19.", "hero": "docs/gallery/assets/market-stall-hero.webp", "preview": "showcase/market-stall/preview.webp", "tags": [ @@ -117,7 +117,7 @@ "name": "watchtower", "dir": "showcase/watchtower", "teaches": "A procedural timber watchtower through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", - "witnessesFix": "Recomputed: 5220 tris, three materials with 84 metal and 126 roof faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.434×1.434×1.982 m, LOD ratios in band, convex collider 56 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "witnessesFix": "Recomputed: 6408 tris, three materials with 300 metal and 126 roof faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.594×1.594×2.973 m, grounded zmin, 4 iron shoes, rail engage 18 mm, collar standoff 2 mm, post plumb 0, LOD ratios in band (5.2 COLLAPSE leaner on LOD2), convex collider 150 tris, non-empty glTF. Hygiene all zero. --skip-decimate exits 9; --stray-vert 15; --lift-z/--short-shoes 16; --short-rails 17; --float-band 18; --rake-posts 19.", "hero": "docs/gallery/assets/watchtower-hero.webp", "preview": "showcase/watchtower/preview.webp", "tags": [ diff --git a/showcase/market-stall/README.md b/showcase/market-stall/README.md index 7288aec..4e26e54 100644 --- a/showcase/market-stall/README.md +++ b/showcase/market-stall/README.md @@ -1,10 +1,15 @@ # Market stall -A showcase piece, not an example. Procedural timber stall (posts with feet, -slanted rafters, slatted counter and shelf, back-wall planks, eave fascia, -striped awning and valance) 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 timber stall (corner posts +with wrap plinths, slatted counter and shelf, back-wall planks, side +braces that sit in the post bay, striped awning with a front roller and +hanging valance) then the shipped pipeline: unique-cell UVs, Cycles +high-to-low normal bake, LOD chain, convex collider, Unity glTF export. + +The old piece used capped foot cubes coplanar with the post bottoms and +counter-leg pads coplanar with the legs, which z-fought at Z=0. Wrap +plates stand off the post; counter legs go to Z=0 without a second +bottom face. It asserts **budget conformance** of the generated result. It does not witness an API contract. "It rendered without error" is not a check. @@ -22,38 +27,82 @@ materials, UVs, evaluated LOD, collider, or export file. | Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | -| Base triangles | 4280–4550 | 4404 / 4404 / 4404 | +| Base triangles | 3900–5200 | 4324 / 4324 / 4324 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | -| LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 | -| Materials | exactly 3 distinct, ≥16 faces per stripe slot | 3 slots, 48 / 48 stripe faces | +| LOD2 ratio | 0.10–0.35 of base | 0.2197 / 0.2197 / 0.2197 | +| Materials | exactly 3 distinct; ≥16 faces per stripe slot | 3 slots; 34 / 34 stripe faces | | UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | -| Outer AABB | (1.392, 0.966, 1.743) m ± 0.01 | (1.3915, 0.9659, 1.7430), zmin 0 | -| Collider tris | ≤ 80 | 46 | -| Export | written, size > 0 | 316748 / 316748 / 316724 bytes | +| Outer AABB | (1.389, 1.011, 1.740) m ± 0.01 | (1.3892, 1.0107, 1.7402), zmin 0 | +| Collider tris | ≤ 80 | 58 | +| Export | written, size > 0 | 312752 / 312752 / 312728 bytes | + +### Hygiene + +Recomputed from the generated mesh, not asserted about the script. -DECIMATE COLLAPSE triangle counts are **not** guaranteed identical across -series — the gate is a ratio band, not an exact count. This mesh happened -to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate -is `has_data` plus operator `FINISHED`, not byte-identity. Construction -uses no RNG. glTF byte size differs by a few bytes across series. +| 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 wrap feet | each `zmin` ≤ 1e-3 | 4, foot_z 0.00000 | +| Frame plan | 1.36 × 0.92 m ± 0.04 | 1.3600 × 0.9200 | + +### Joint fit and seat -`--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 (all three) | +| --- | --- | --- | +| Brace-vs-counter overlap | ≤ 1e-6 m³ | 0.000000 | +| Header-post tenon engage | ≥ 0.4 × tenon | 0.0595 | +| Awning-on-header seat | −0.002–0.010 m | 0.00378 | +| Post plumb (XY drift) | ≤ 0.008 m | 0.00000 | + +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 +24 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-feet` | named wrap-foot supports at Z=0 | 16 | +| `--low-brace` | brace-vs-counter overlap | 17 | +| `--float-awning` | awning-on-header seat | 18 | +| `--rake-posts` | post plumb | 19 | ## Run ```bash blender --background --python market_stall.py -- blender --background --python market_stall.py -- --skip-decimate +blender --background --python market_stall.py -- --stray-vert +blender --background --python market_stall.py -- --lift-z +blender --background --python market_stall.py -- --short-feet +blender --background --python market_stall.py -- --low-brace +blender --background --python market_stall.py -- --float-awning +blender --background --python market_stall.py -- --rake-posts blender --background --python market_stall.py -- --output stall.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 | | --- | --- | @@ -72,3 +121,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 wrap foot floats | +| 17 | Joint fit: brace occupying the counter volume | +| 18 | Seat: awning-on-header gap | +| 19 | Post plumb or frame plan off the stated real-world size | diff --git a/showcase/market-stall/market_stall.py b/showcase/market-stall/market_stall.py index 01ed396..9263c8a 100644 --- a/showcase/market-stall/market_stall.py +++ b/showcase/market-stall/market_stall.py @@ -5,12 +5,15 @@ 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. +They are not API-contract witnesses. Each falsifier violates one named +budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh +hygiene, ``--lift-z`` grounded zmin, ``--short-feet`` named post-foot +supports, ``--low-brace`` brace-vs-counter joint fit, ``--float-awning`` +awning-on-header seat, ``--rake-posts`` post plumb. -No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts -are not byte-identical across Blender versions — the LOD gate is a -ratio band, not an exact count. +No RNG. Slat jitter is closed-form ``sin(i)``. DECIMATE COLLAPSE +triangle counts are not byte-identical across Blender versions — the LOD +gate is a ratio band, not an exact count. blender --background --python market_stall.py -- blender --background --python market_stall.py -- --skip-decimate @@ -44,16 +47,20 @@ BACK_H = 1.72 N_STRIPES = 8 AWNING_T = 0.018 +OVERHANG_F = 0.040 +OVERHANG_B = 0.030 COUNTER_Z = 0.82 COUNTER_D = 0.34 COUNTER_T = 0.070 BRACE = 0.036 +FOOT_H = 0.036 +TENON = POST * 0.35 BBOX_TOL = 0.01 # Fitted to the generated AABB after locking geometry. Recomputed from bound_box. -OUTER_SIZE = (1.392, 0.966, 1.743) +OUTER_SIZE = (1.389, 1.011, 1.740) -BASE_TRIS_MIN = 4280 -BASE_TRIS_MAX = 4550 +BASE_TRIS_MIN = 3900 +BASE_TRIS_MAX = 5200 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -66,6 +73,24 @@ COLLIDER_TRIS_MAX = 80 BAKE_RES = 256 CAGE_EXTRUSION = 0.06 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.998 +FOOT_ZMIN_MAX = 0.001 +FOOT_COUNT = 4 +AWNING_SEAT_MAX = 0.010 +AWNING_SEAT_MIN = -0.002 +BRACE_COUNTER_OVERLAP_MAX = 1e-6 +POST_PLUMB_MAX = 0.008 +FRAME_XY_TOL = 0.04 +LIFT_Z = 0.05 +# Small enough that the AABB still sits in BBOX_TOL; large enough that +# the front-eave seat drops below AWNING_SEAT_MIN. +FLOAT_AWNING = 0.008 +RAKE = math.radians(2.0) +SHORT_FOOT_Z = 0.048 WOOD_IDX = 0 STRIPE_A_IDX = 1 @@ -112,25 +137,75 @@ def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): return verts -def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): - geo = bmesh.ops.create_cone( +def add_oriented_box(bm, a, b, scale_xy, mat_idx): + a = Vector(a) + b = Vector(b) + delta = b - a + length = delta.length + if length < 1e-8: + return [] + quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) + eul = quat.to_euler("XYZ") + return add_box( bm, - cap_ends=True, - cap_tris=False, - segments=segments, - radius1=radius1, - radius2=radius2, - depth=depth, + ((a + b) * 0.5), + (scale_xy[0], scale_xy[1], length), + mat_idx, + euler=(eul.x, eul.y, eul.z), ) - verts = geo["verts"] + + +def add_wrap(bm, px, py, z, height, host, t, mat_idx): + """Four plates around a square post. Y-facing plates cover the corners.""" + half = host / 2.0 + off = half + t / 2.0 + 0.002 + add_box(bm, (px + off, py, z), (t, host, height), mat_idx) + add_box(bm, (px - off, py, z), (t, host, height), mat_idx) + add_box(bm, (px, py + off, z), (host + 2.0 * t, t, height), mat_idx) + add_box(bm, (px, py - off, z), (host + 2.0 * t, t, height), mat_idx) + return [] + + +def add_striped_slab(bm, loc, scale, euler, n_stripes, even_idx, odd_idx): + """One slab split into n stripes that share vertices — no daylight gaps.""" rot = Euler(euler).to_matrix() origin = Vector(loc) - for v in verts: - v.co = rot @ v.co + origin - faces = {f for v in verts for f in v.link_faces} - for f in faces: - f.material_index = mat_idx - return verts + sx, sy, sz = scale + stations = [] + for i in range(n_stripes + 1): + x = -0.5 * sx + sx * i / n_stripes + row = [] + for y, z in ( + (-0.5 * sy, -0.5 * sz), + (-0.5 * sy, 0.5 * sz), + (0.5 * sy, -0.5 * sz), + (0.5 * sy, 0.5 * sz), + ): + row.append(bm.verts.new(rot @ Vector((x, y, z)) + origin)) + stations.append(row) + kept = [] + for i in range(n_stripes): + a = stations[i] + b = stations[i + 1] + idx = even_idx if (i % 2 == 0) else odd_idx + quads = ( + (a[0], b[0], b[2], a[2]), + (a[1], a[3], b[3], b[1]), + (a[0], a[1], b[1], b[0]), + (a[2], b[2], b[3], a[3]), + ) + for q in quads: + face = bm.faces.new(q) + face.material_index = idx + kept.append(face) + first = stations[0] + last = stations[-1] + cap_a = bm.faces.new((first[0], first[2], first[3], first[1])) + cap_a.material_index = even_idx + cap_b = bm.faces.new((last[0], last[1], last[3], last[2])) + cap_b.material_index = even_idx if ((n_stripes - 1) % 2 == 0) else odd_idx + kept.extend((cap_a, cap_b)) + return kept def pack_uvs(bm, margin=0.08): @@ -176,205 +251,228 @@ def pack_uvs(bm, margin=0.08): ) -def build_stall_mesh(name, bevel_offset, bevel_segments): +def build_stall_mesh( + name, + bevel_offset, + bevel_segments, + low_brace=False, + float_awning=False, + rake_posts=False, + short_feet=False, +): bm = bmesh.new() wood_verts = [] - stripe_a = set() - stripe_b = set() + stripe_faces = [] try: hx = WIDTH / 2.0 hy = DEPTH / 2.0 - inset = POST / 2.0 + px = hx - POST / 2.0 + py_f = -(hy - POST / 2.0) + py_b = hy - POST / 2.0 + post_h_f = FRONT_H - POST + post_h_b = BACK_H - POST + rake = (RAKE, 0.0, 0.0) if rake_posts else (0.0, 0.0, 0.0) posts = ( - (-hx + inset, -hy + inset, FRONT_H / 2.0, FRONT_H), - (hx - inset, -hy + inset, FRONT_H / 2.0, FRONT_H), - (-hx + inset, hy - inset, BACK_H / 2.0, BACK_H), - (hx - inset, hy - inset, BACK_H / 2.0, BACK_H), + (-px, py_f, post_h_f), + (px, py_f, post_h_f), + (-px, py_b, post_h_b), + (px, py_b, post_h_b), ) - for x, y, z, h in posts: - wood_verts.extend(add_box(bm, (x, y, z), (POST, POST, h), WOOD_IDX)) + foot_z = SHORT_FOOT_Z if short_feet else FOOT_H / 2.0 + foot_h = 0.020 if short_feet else FOOT_H + wrap_t = POST * 0.18 + for x, y, h in posts: wood_verts.extend( - add_box(bm, (x, y, 0.018), (POST * 1.45, POST * 1.45, 0.036), WOOD_IDX) + add_box(bm, (x, y, h / 2.0), (POST, POST, h), WOOD_IDX, euler=rake) ) + add_wrap(bm, x, y, foot_z, foot_h, POST, wrap_t, WOOD_IDX) - # Front and back header beams (along X). + header_len = WIDTH - POST + 2.0 * TENON wood_verts.extend( add_box( bm, - (0.0, -hy + inset, FRONT_H - POST / 2.0), - (WIDTH - POST, POST, POST), + (0.0, py_f, FRONT_H - POST / 2.0), + (header_len, POST * 0.78, POST), WOOD_IDX, ) ) wood_verts.extend( add_box( bm, - (0.0, hy - inset, BACK_H - POST / 2.0), - (WIDTH - POST, POST, POST), + (0.0, py_b, BACK_H - POST / 2.0), + (header_len, POST * 0.78, POST), WOOD_IDX, ) ) - # Side plates, slanted to match the awning. - dz = BACK_H - FRONT_H - slant_len = math.hypot(DEPTH - POST, dz) - pitch = math.atan2(dz, DEPTH - POST) - for sx in (-hx + inset, hx - inset): + # Side plates sit on the header centres, spanning post to post. + for sx in (-px, px): wood_verts.extend( - add_box( + add_oriented_box( bm, - (sx, 0.0, (FRONT_H + BACK_H) / 2.0 - POST / 2.0), - (POST * 0.85, slant_len, POST * 0.85), + (sx, py_f, FRONT_H - POST / 2.0), + (sx, py_b, BACK_H - POST / 2.0), + (POST * 0.80, POST * 0.80), WOOD_IDX, - euler=(pitch, 0.0, 0.0), ) ) - # Inner rafters under the awning — the empty underside was a slab of air. for i in range(3): rx = -hx + POST * 2.0 + (i + 1) * (WIDTH - 4.0 * POST) / 4.0 wood_verts.extend( - add_box( + add_oriented_box( bm, - (rx, 0.0, (FRONT_H + BACK_H) / 2.0 - POST * 0.42), - (POST * 0.42, slant_len * 0.96, POST * 0.42), + (rx, py_f, FRONT_H - POST / 2.0), + (rx, py_b, BACK_H - POST / 2.0), + (POST * 0.42, POST * 0.42), WOOD_IDX, - euler=(pitch, 0.0, 0.0), ) ) - # Mid rail on the back wall and two diagonal braces at the sides. wood_verts.extend( add_box( bm, - (0.0, hy - inset, BACK_H * 0.55), - (WIDTH - 2.0 * POST, POST * 0.7, POST * 0.7), + (0.0, py_b, BACK_H * 0.48), + (WIDTH - POST, POST * 0.65, POST * 0.65), WOOD_IDX, ) ) - for sx in (-hx + inset, hx - inset): - p0 = Vector((sx, -hy + inset, 0.30)) - p1 = Vector((sx, hy - inset, BACK_H * 0.58)) - mid = (p0 + p1) * 0.5 - delta = p1 - p0 - brace_len = delta.length - brace_pitch = math.atan2(delta.z, delta.y) - wood_verts.extend( - add_box( - bm, - (mid.x, mid.y, mid.z), - (BRACE, brace_len, BRACE), - WOOD_IDX, - euler=(brace_pitch, 0.0, 0.0), - ) + wood_verts.extend( + add_box( + bm, + (0.0, py_f, 0.16), + (WIDTH - 2.0 * POST, POST * 0.70, POST * 0.55), + WOOD_IDX, ) + ) - # Slatted counter — crate-lid language, not one fat slab. - cy = -hy + COUNTER_D / 2.0 + 0.04 + for sign in (-1.0, 1.0): + sx = sign * px + if low_brace: + a = Vector((sx, py_f, 0.28)) + b = Vector((sx, py_b, BACK_H * 0.58)) + else: + # On the post centreline so the brace tenons the post instead + # of reading as a wing in the front elevation. + a = Vector((sx, 0.04, COUNTER_Z + 0.14)) + b = Vector((sx, py_b, BACK_H * 0.56)) + wood_verts.extend(add_oriented_box(bm, a, b, (BRACE, BRACE), WOOD_IDX)) + + inner_w = WIDTH - 2.0 * POST - 0.024 + y0 = py_f + POST / 2.0 + 0.016 + y1 = y0 + COUNTER_D n_slats = 6 - slat_gap = 0.010 - slat_d = (COUNTER_D - 0.02 - (n_slats - 1) * slat_gap) / n_slats - y0 = cy - COUNTER_D / 2.0 + 0.01 + slat_d / 2.0 + slat_gap = 0.008 + slat_d = (COUNTER_D - (n_slats - 1) * slat_gap) / n_slats for i in range(n_slats): + jw = 0.018 * math.sin(i * 2.31 + 0.5) + jt = 0.006 * math.sin(i * 1.87) + y = y0 + slat_d / 2.0 + i * (slat_d + slat_gap) wood_verts.extend( add_box( bm, - (0.0, y0 + i * (slat_d + slat_gap), COUNTER_Z), - (WIDTH - 0.06, slat_d, COUNTER_T * 0.72), + (0.0, y, COUNTER_Z), + (inner_w + jw, slat_d * 0.94, COUNTER_T * 0.62 + jt), WOOD_IDX, ) ) wood_verts.extend( add_box( bm, - (0.0, cy - COUNTER_D / 2.0 + 0.018, COUNTER_Z - 0.09), - (WIDTH - 0.08, 0.036, 0.16), + (0.0, y0 + 0.016, COUNTER_Z - 0.08), + (inner_w * 0.96, 0.032, 0.14), WOOD_IDX, ) ) - under_shelf_z = 0.38 + shelf_z = 0.38 n_shelf = 4 shelf_d = COUNTER_D * 0.78 - shelf_gap = 0.010 + shelf_gap = 0.008 shelf_slat = (shelf_d - (n_shelf - 1) * shelf_gap) / n_shelf - sy0 = cy - shelf_d / 2.0 + shelf_slat / 2.0 + sy0 = y0 + 0.04 + shelf_slat / 2.0 for i in range(n_shelf): + jw = 0.014 * math.sin(i * 1.63 + 0.9) wood_verts.extend( add_box( bm, - (0.0, sy0 + i * (shelf_slat + shelf_gap), under_shelf_z), - (WIDTH - 0.16, shelf_slat, 0.024), + (0.0, sy0 + i * (shelf_slat + shelf_gap), shelf_z), + (inner_w - 0.08 + jw, shelf_slat * 0.94, 0.024), WOOD_IDX, ) ) - for lx in (-hx + 0.16, hx - 0.16): - for ly in (cy - COUNTER_D * 0.32, cy + COUNTER_D * 0.32): + leg_h = COUNTER_Z - COUNTER_T * 0.32 + for lx in (-inner_w / 2.0 + 0.04, inner_w / 2.0 - 0.04): + for ly in (y0 + 0.05, y1 - 0.05): wood_verts.extend( - add_box( - bm, - (lx, ly, COUNTER_Z / 2.0), - (0.045, 0.045, COUNTER_Z), - WOOD_IDX, - ) + add_box(bm, (lx, ly, leg_h / 2.0), (0.042, 0.042, leg_h), WOOD_IDX) ) - # Back wall: horizontal planks with gaps, not a felt slab. + plank_y = py_b - POST / 2.0 - 0.014 + plank_len = WIDTH - POST for i in range(5): - z = 0.36 + i * 0.155 + z = 0.30 + i * 0.148 + jw = 0.004 * math.sin(i * 2.11) wood_verts.extend( add_box( bm, - (0.0, hy - inset - 0.014, z), - (WIDTH - 2.35 * POST, 0.028, 0.122), + (0.0, plank_y, z), + (plank_len + jw, 0.028, 0.118), WOOD_IDX, ) ) - # Front eave fascia under the valance. + fascia_y = -hy - OVERHANG_F wood_verts.extend( add_box( bm, - (0.0, -hy - 0.008, FRONT_H - 0.028), - (WIDTH - POST * 0.4, 0.032, 0.048), + (0.0, fascia_y, FRONT_H - 0.022), + (WIDTH - POST * 0.2, 0.032, 0.044), WOOD_IDX, ) ) - - stripe_w = WIDTH / N_STRIPES - awning_len = math.hypot(DEPTH, dz) - awning_pitch = math.atan2(dz, DEPTH) - cz = (FRONT_H + BACK_H) / 2.0 + AWNING_T * 0.35 - for i in range(N_STRIPES): - cx = -hx + (i + 0.5) * stripe_w - idx = STRIPE_A_IDX if (i % 2 == 0) else STRIPE_B_IDX - before = set(bm.faces) + wood_verts.extend( add_box( bm, - (cx, 0.0, cz), - (stripe_w * 0.98, awning_len + 0.04, AWNING_T), - idx, - euler=(awning_pitch, 0.0, 0.0), + (0.0, fascia_y - 0.006, FRONT_H - 0.004), + (WIDTH - POST * 0.05, 0.022, 0.022), + WOOD_IDX, ) - new_faces = set(bm.faces) - before - if idx == STRIPE_A_IDX: - stripe_a.update(new_faces) - else: - stripe_b.update(new_faces) + ) - # Front valance hanging from the eave. - for i in range(N_STRIPES): - cx = -hx + (i + 0.5) * stripe_w - idx = STRIPE_B_IDX if (i % 2 == 0) else STRIPE_A_IDX - before = set(bm.faces) - add_box( + # Centreline sits above the header; thickness then hangs along the + # roof normal so the front underside clears the fascia top. + seat = 0.012 + front = Vector((0.0, -hy - OVERHANG_F, FRONT_H + seat)) + back = Vector((0.0, hy + OVERHANG_B, BACK_H + seat)) + dy = back.y - front.y + dz = back.z - front.z + awning_len = math.hypot(dy, dz) + pitch = math.atan2(dz, dy) + nrm = Vector((0.0, -math.sin(pitch), math.cos(pitch))) + mid = (front + back) * 0.5 + if float_awning: + mid = Vector((mid.x, mid.y, mid.z + FLOAT_AWNING)) + stripe_faces.extend( + add_striped_slab( bm, - (cx, -hy - 0.01, FRONT_H - 0.08), - (stripe_w * 0.96, 0.016, 0.14), - idx, + mid, + (WIDTH, awning_len, AWNING_T), + (pitch, 0.0, 0.0), + N_STRIPES, + STRIPE_A_IDX, + STRIPE_B_IDX, ) - new_faces = set(bm.faces) - before - if idx == STRIPE_A_IDX: - stripe_a.update(new_faces) - else: - stripe_b.update(new_faces) + ) + val_z = FRONT_H - 0.09 + (FLOAT_AWNING if float_awning else 0.0) + stripe_faces.extend( + add_striped_slab( + bm, + (0.0, fascia_y - 0.002, val_z), + (WIDTH, 0.014, 0.15), + (0.0, 0.0, 0.0), + N_STRIPES, + STRIPE_B_IDX, + STRIPE_A_IDX, + ) + ) if bevel_offset > 0.0: edges = list({e for v in wood_verts for e in v.link_edges}) @@ -389,9 +487,10 @@ def build_stall_mesh(name, bevel_offset, bevel_segments): ) zmin = min(v.co.z for v in bm.verts) - if zmin != 0.0: - for v in bm.verts: - v.co.z -= zmin + for v in bm.verts: + v.co.z -= zmin + if v.co.z < 0.002: + v.co.z = 0.0 pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) @@ -402,12 +501,9 @@ def build_stall_mesh(name, bevel_offset, bevel_segments): if edge.is_manifold and len(edge.link_faces) == 2: if edge.calc_face_angle() < math.radians(25.0): edge.smooth = True - for f in stripe_a: - if f.is_valid: - f.material_index = STRIPE_A_IDX - for f in stripe_b: - if f.is_valid: - f.material_index = STRIPE_B_IDX + for face in stripe_faces: + if face.is_valid: + pass me = bpy.data.meshes.new(name) bm.to_mesh(me) me.update() @@ -473,6 +569,271 @@ 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): + 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 aabb_overlap(a, b): + x = min(a[3], b[3]) - max(a[0], b[0]) + y = min(a[4], b[4]) - max(a[1], b[1]) + z = min(a[5], b[5]) - max(a[2], b[2]) + if x <= 0.0 or y <= 0.0 or z <= 0.0: + return 0.0 + return x * y * z + + +def mat_of(me, group): + member = set(group) + for poly in me.polygons: + if all(i in member for i in poly.vertices): + return poly.material_index + return None + + +def support_audit(me): + """Post feet: wrap plates clustered at the four frame corners.""" + plates = [] + for group in shells(me): + if mat_of(me, group) != WOOD_IDX: + continue + a = shell_aabb(me, group) + dz = a[5] - a[2] + dx = a[3] - a[0] + dy = a[4] - a[1] + cz = 0.5 * (a[2] + a[5]) + if cz > 0.10 or dz > 0.10: + continue + if max(dx, dy) < POST * 0.8: + continue + plates.append(a) + corners = {} + for a in plates: + cx = 0.5 * (a[0] + a[3]) + cy = 0.5 * (a[1] + a[4]) + key = (1 if cx > 0.0 else -1, 1 if cy > 0.0 else -1) + corners.setdefault(key, []).append(a) + zmin = min((a[2] for a in plates), default=99.0) + return {"feet": len(corners), "foot_z": zmin} + + +def joint_audit(me): + """Braces must not occupy the counter volume. Headers bite the posts.""" + hx = WIDTH / 2.0 + groups = shells(me) + boxes = [(g, shell_aabb(me, g), mat_of(me, g)) for g in groups] + posts = [] + headers = [] + braces = [] + slats = [] + for _g, a, mat in boxes: + if mat != WOOD_IDX: + continue + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + cx = 0.5 * (a[0] + a[3]) + cy = 0.5 * (a[1] + a[4]) + cz = 0.5 * (a[2] + a[5]) + if dz > FRONT_H * 0.55 and dx < POST * 2.4 and dy < POST * 2.4: + posts.append(a) + continue + if dx > WIDTH * 0.6 and dz < POST * 2.2 and dy < POST * 2.2 and cz > FRONT_H * 0.7: + headers.append(a) + continue + if abs(cx) > hx - POST * 1.8 and dz > 0.35 and dy > 0.25 and dx < 0.20: + braces.append(a) + continue + if abs(cz - COUNTER_Z) < 0.08 and dx > WIDTH * 0.4: + slats.append(a) + overlap = 0.0 + for brace in braces: + for slat in slats: + overlap = max(overlap, aabb_overlap(brace, slat)) + pairs = [ + min(h[3] - p[0], p[3] - h[0]) + for h in headers + for p in posts + if abs(0.5 * (h[1] + h[4]) - 0.5 * (p[1] + p[4])) < POST * 2.0 + ] + engage = min(pairs) if pairs else 1.0 + return { + "posts": len(posts), + "headers": len(headers), + "braces": len(braces), + "slats": len(slats), + "overlap": overlap, + "engage": engage, + } + + +def awning_seat(me): + """Gap from front-header top to awning underside, at the front eave.""" + hy = DEPTH / 2.0 + wood_z = [] + stripe_z = [] + for poly in me.polygons: + zs = [me.vertices[i].co.z for i in poly.vertices] + ys = [me.vertices[i].co.y for i in poly.vertices] + cy = sum(ys) / len(ys) + if cy > -hy + 0.15: + continue + zmax = max(zs) + zmin = min(zs) + if poly.material_index == WOOD_IDX and zmax > FRONT_H - 0.12: + if max(ys) - min(ys) > 0.028: + wood_z.append(zmax) + if poly.material_index in (STRIPE_A_IDX, STRIPE_B_IDX) and zmax > FRONT_H: + stripe_z.append(zmin) + if not wood_z or not stripe_z: + return 99.0 + return min(stripe_z) - max(wood_z) + + +def plumb_audit(me): + """XY centroid of each post's bottom slab vs top slab.""" + drifts = [] + for group in shells(me): + if mat_of(me, group) != WOOD_IDX: + continue + a = shell_aabb(me, group) + dz = a[5] - a[2] + dx = a[3] - a[0] + dy = a[4] - a[1] + if dz < FRONT_H * 0.55 or dx > POST * 2.4 or dy > POST * 2.4: + continue + pts = [me.vertices[i].co for i in group] + zcut_lo = a[2] + 0.08 * dz + zcut_hi = a[5] - 0.08 * dz + lo = [p for p in pts if p.z <= zcut_lo] + hi = [p for p in pts if p.z >= zcut_hi] + if len(lo) < 3 or len(hi) < 3: + continue + c_lo = Vector((sum(p.x for p in lo) / len(lo), sum(p.y for p in lo) / len(lo))) + c_hi = Vector((sum(p.x for p in hi) / len(hi), sum(p.y for p in hi) / len(hi))) + drifts.append((c_hi - c_lo).length) + return max(drifts) if drifts else 99.0 + + +def frame_size(me): + """Post-foot envelope vs declared stall plan, not the awning AABB.""" + hx = WIDTH / 2.0 + hy = DEPTH / 2.0 + xs, ys, zs = [], [], [] + for group in shells(me): + if mat_of(me, group) != WOOD_IDX: + continue + a = shell_aabb(me, group) + dz = a[5] - a[2] + dx = a[3] - a[0] + dy = a[4] - a[1] + cx = 0.5 * (a[0] + a[3]) + cy = 0.5 * (a[1] + a[4]) + if dz > FRONT_H * 0.55 and dx < POST * 2.4 and dy < POST * 2.4: + xs.extend((a[0], a[3])) + ys.extend((a[1], a[4])) + zs.append(a[5]) + continue + if dz < 0.08 and abs(cx) > hx - POST * 1.6 and abs(cy) > hy - POST * 1.6: + zs.append(a[2]) + if not xs: + return 0.0, 0.0, 0.0 + return max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs) + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, COUNTER_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) @@ -565,16 +926,47 @@ def export_unity(path, objects): ) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + short_feet=False, + low_brace=False, + float_awning=False, + rake_posts=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_stall_mesh("StallLow", bevel_offset=0.006, bevel_segments=2) - high = build_stall_mesh("StallHigh", bevel_offset=0.006, bevel_segments=4) + low = build_stall_mesh( + "StallLow", + bevel_offset=0.006, + bevel_segments=2, + low_brace=low_brace, + float_awning=float_awning, + rake_posts=rake_posts, + short_feet=short_feet, + ) + high = build_stall_mesh( + "StallHigh", + bevel_offset=0.006, + bevel_segments=4, + low_brace=low_brace, + float_awning=float_awning, + rake_posts=rake_posts, + short_feet=short_feet, + ) wood = principled("StallWood", (0.42, 0.24, 0.10, 1.0), 0.0, 0.55) stripe_a = principled("StallStripeA", (0.72, 0.12, 0.10, 1.0), 0.0, 0.62) stripe_b = principled("StallStripeB", (0.86, 0.80, 0.62, 1.0), 0.0, 0.58) assign_slots(low, wood, stripe_a, stripe_b) assign_slots(high, wood, stripe_a, stripe_b) + 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() + if low.data is None or len(low.data.polygons) < 6: return fail("stall mesh did not build", 3), None, None, None, None, None @@ -591,6 +983,13 @@ def check(skip_decimate): size_x = bb[3] - bb[0] size_y = bb[4] - bb[1] size_z = bb[5] - bb[2] + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + jnt = joint_audit(low.data) + seat = awning_seat(low.data) + plumb = plumb_audit(low.data) + fx, fy, fz = frame_size(low.data) img, tex = setup_bake_image(low, wood) if img is None: @@ -619,9 +1018,7 @@ 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 - print( - f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}" - ) + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print( f"measured base_tris={base_tris} lod1_tris={lod1_tris} " f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" @@ -638,6 +1035,16 @@ 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 feet={sup['feet']} foot_z={sup['foot_z']:.5f} " + f"brace_overlap={jnt['overlap']:.6f} engage={jnt['engage']:.4f} " + f"seat={seat:.5f} plumb={plumb:.5f} frame=({fx:.4f},{fy:.4f},{fz:.4f})" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -702,6 +1109,40 @@ 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 bb[2] > ZMIN_EPS or sup["feet"] != FOOT_COUNT or sup["foot_z"] > FOOT_ZMIN_MAX: + return fail( + f"grounded zmin={bb[2]:.5f} feet={sup['feet']} " + f"foot_z={sup['foot_z']:.5f}", + 16, + ), None, None, None, None, None + if jnt["overlap"] > BRACE_COUNTER_OVERLAP_MAX or jnt["engage"] < TENON * 0.4: + return fail( + f"joint overlap={jnt['overlap']:.6f} engage={jnt['engage']:.4f} " + f"posts={jnt['posts']} braces={jnt['braces']} slats={jnt['slats']}", + 17, + ), None, None, None, None, None + if seat < AWNING_SEAT_MIN or seat > AWNING_SEAT_MAX: + return fail(f"awning seat gap {seat:.5f} > {AWNING_SEAT_MAX}", 18), None, None, None, None, None + if ( + plumb > POST_PLUMB_MAX + or abs(fx - WIDTH) > FRAME_XY_TOL + or abs(fy - DEPTH) > FRAME_XY_TOL + ): + return fail( + f"plumb={plumb:.5f} frame=({fx:.4f},{fy:.4f},{fz:.4f}) " + f"off {WIDTH}x{DEPTH}", + 19, + ), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -766,13 +1207,15 @@ def light(name, loc, energy, size, col, rot): light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + bb = world_bbox(low) + span = max(bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2], 0.2) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (3.11, -5.10, 1.60) + cam.location = (span * 2.17, -span * 2.95, span * 1.12) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, -0.10, 0.92) + aim.location = (0.0, -0.04 * span, 0.52 * (bb[2] + bb[5])) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -815,14 +1258,24 @@ 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("--stray-vert", action="store_true") + p.add_argument("--lift-z", action="store_true") + p.add_argument("--short-feet", action="store_true") + p.add_argument("--low-brace", action="store_true") + p.add_argument("--float-awning", action="store_true") + p.add_argument("--rake-posts", action="store_true") 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, + short_feet=args.short_feet, + low_brace=args.low_brace, + float_awning=args.float_awning, + rake_posts=args.rake_posts, + ) if code: return code if args.output: diff --git a/showcase/market-stall/preview.webp b/showcase/market-stall/preview.webp index c507a27..a223a5a 100644 Binary files a/showcase/market-stall/preview.webp and b/showcase/market-stall/preview.webp differ diff --git a/showcase/watchtower/README.md b/showcase/watchtower/README.md index 1735941..f0c99b5 100644 --- a/showcase/watchtower/README.md +++ b/showcase/watchtower/README.md @@ -1,11 +1,17 @@ # Watchtower A showcase piece, not an example. Procedural timber lookout (corner -posts through a plank platform, lower-bay X-braces, hatch and ladder, -coursed shake roof with eaves) then the shipped pipeline: unique-cell +posts through a plank platform, lower-bay X-braces, hatch and ladder +whose stiles plant at Z=0, coursed shake roof over a solid cone, +mitered iron collars and shoes) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export. +The old roof was a wood U with incomplete shake coverage; rails showed +through the eave. The cone plus non-overlapping courses close the +pyramid. Shoes are added after the zmin snap so a bevel undershoot +cannot lift them. + It asserts **budget conformance** of the generated result. It does not witness an API contract. "It rendered without error" is not a check. @@ -22,37 +28,84 @@ materials, UVs, evaluated LOD, collider, or export file. | Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | -| Base triangles | 5110–5330 | 5220 / 5220 / 5220 | +| Base triangles | 6200–8200 | 6408 / 6408 / 6408 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | -| LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2199 | -| Materials | exactly 3 distinct, ≥24 metal, ≥40 roof | 3 slots, 84 metal, 126 roof | +| LOD2 ratio | 0.10–0.35 of base | 0.2197 / 0.2197 / 0.2185 | +| Materials | exactly 3 distinct; ≥80 metal, ≥40 roof faces | 3 slots; 300 metal, 126 roof | | UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | -| Outer AABB | (1.434, 1.434, 1.982) m ± 0.01 | (1.4340, 1.4340, 1.9818), zmin 0 | -| Collider tris | ≤ 80 | 56 | -| Export | written, size > 0 | 374376 / 374376 / 374360 bytes | +| Outer AABB | (1.594, 1.594, 2.973) m ± 0.01 | (1.5940, 1.5940, 2.9731), zmin 0 | +| Collider tris | ≤ 180 | 150 | +| Export | written, size > 0 | 464572 / 464572 / 464560 bytes | + +Base triangles rose from **5220 to 6408** in the quality pass: a taller +eave-true roof and coursed shakes, not hidden interior faces. + +### 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 iron shoes | each `zmin` ≤ 1e-3 | 4, shoe_z 0.00000 | +| Plan | 1.56 m eave × 2.97 m height ± 0.08 / ± 0.28 | 1.5940 × 2.9731 | -DECIMATE COLLAPSE triangle counts are **not** required to match 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. +### Joint fit and seat -`--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 (all three) | +| --- | --- | --- | +| Rail-to-post engage | ≥ 0.012 m | 0.0180 | +| Girt-collar standoff | ≤ 0.010 m | 0.00200 | +| Post plumb (XY drift) | ≤ 0.010 m | 0.00000 | + +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 12 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-shoes` | named shoe supports at Z=0 | 16 | +| `--short-rails` | rail-to-post joint fit | 17 | +| `--float-band` | iron-collar seat | 18 | +| `--rake-posts` | post plumb | 19 | ## Run ```bash blender --background --python watchtower.py -- blender --background --python watchtower.py -- --skip-decimate +blender --background --python watchtower.py -- --stray-vert +blender --background --python watchtower.py -- --lift-z +blender --background --python watchtower.py -- --short-shoes +blender --background --python watchtower.py -- --short-rails +blender --background --python watchtower.py -- --float-band +blender --background --python watchtower.py -- --rake-posts blender --background --python watchtower.py -- --output tower.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 | | --- | --- | @@ -71,3 +124,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: rail-to-post engage | +| 18 | Seat: girt-collar standoff | +| 19 | Post plumb or plan off the stated real-world size | diff --git a/showcase/watchtower/preview.webp b/showcase/watchtower/preview.webp index 7a8c86a..9f5cdc0 100644 Binary files a/showcase/watchtower/preview.webp and b/showcase/watchtower/preview.webp differ diff --git a/showcase/watchtower/watchtower.py b/showcase/watchtower/watchtower.py index 9373d47..68aea62 100644 --- a/showcase/watchtower/watchtower.py +++ b/showcase/watchtower/watchtower.py @@ -6,8 +6,11 @@ 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. +They are not API-contract witnesses. Each falsifier violates one named +budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh +hygiene, ``--lift-z`` grounded zmin, ``--short-shoes`` named shoe +supports, ``--short-rails`` rail-to-post joint fit, ``--float-band`` +iron-collar seat, ``--rake-posts`` post plumb. No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts are not byte-identical across Blender versions — the LOD gate is a @@ -35,28 +38,37 @@ sys.dont_write_bytecode = True import gallery_framing # noqa: E402 -HALF = 0.46 +HALF = 0.52 POST = 0.090 -POST_H = 1.58 -PLAT_Z = 1.04 -RAIL_H = 0.40 -GIRT_Z = 0.52 -KICK_H = 0.18 -BRACE_T = 0.042 +POST_H = 2.42 +PLAT_Z = 1.72 PLANK_T = 0.038 -IRON_T = 0.014 -SHOE_H = 0.034 -EAVE_HALF = 0.70 -ROOF_RISE = 0.40 +# Guardrails sit in the bay between the deck and the eave — a 0.90 m +# code rail would punch through the roof. +RAIL_CLEAR = 0.10 +RAIL_H = POST_H - PLAT_Z - PLANK_T - RAIL_CLEAR +GIRT_Z = 0.86 +GIRT_H = 0.075 +SILL_H = 0.085 +KICK_H = 0.20 +BRACE_T = 0.042 +IRON_T = 0.012 +IRON_H = 0.028 +IRON_STANDOFF = 0.002 +SHOE_H = 0.040 +EAVE_HALF = 0.78 +ROOF_RISE = 0.55 SHINGLE_T = 0.016 N_SHINGLE = 5 N_PLANK = 6 -HATCH_PLANKS = 1 +HATCH_PLANKS = 2 +LEAN = 0.18 +RAIL_TENON = POST * 0.40 BBOX_TOL = 0.01 -OUTER_SIZE = (1.434, 1.434, 1.982) -BASE_TRIS_MIN = 5110 -BASE_TRIS_MAX = 5330 +OUTER_SIZE = (1.594, 1.594, 2.973) +BASE_TRIS_MIN = 6200 +BASE_TRIS_MAX = 8200 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 @@ -66,11 +78,28 @@ MATERIAL_COUNT = 3 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 -COLLIDER_TRIS_MAX = 80 +COLLIDER_TRIS_MAX = 180 BAKE_RES = 256 CAGE_EXTRUSION = 0.10 -METAL_FACES_MIN = 24 +METAL_FACES_MIN = 80 ROOF_FACES_MIN = 40 +ZMIN_EPS = 1e-4 +DOUBLES_EPS = 1e-5 +AREA_EPS = 1e-10 +ZFIGHT_EPS = 1e-4 +ZFIGHT_COS = 0.998 +SHOE_COUNT = 4 +SHOE_ZMIN_MAX = 0.001 +RAIL_ENGAGE_MIN = 0.012 +BAND_SEAT_MAX = 0.010 +POST_PLUMB_MAX = 0.010 +PLAN_XY = 2.0 * EAVE_HALF +PLAN_Z = POST_H + ROOF_RISE +PLAN_TOL = 0.08 +LIFT_Z = 0.05 +RAKE = math.radians(2.0) +SHORT_SHOE_Z = 0.055 +BAND_FLOAT = 0.040 WOOD_IDX = 0 ROOF_IDX = 1 @@ -109,6 +138,20 @@ def deselect_all(): ob.select_set(False) +def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) + v.co = rot @ p + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + def add_oriented_box(bm, a, b, scale_xy, mat_idx): a = Vector(a) b = Vector(b) @@ -127,20 +170,6 @@ def add_oriented_box(bm, a, b, scale_xy, mat_idx): ) -def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): - geo = bmesh.ops.create_cube(bm, size=1.0) - verts = geo["verts"] - rot = Euler(euler).to_matrix() - origin = Vector(loc) - for v in verts: - p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) - v.co = rot @ p + origin - faces = {f for v in verts for f in v.link_faces} - for f in faces: - f.material_index = mat_idx - 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, @@ -162,6 +191,21 @@ def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0. return verts +def add_collar(bm, px, py, z, height, mat_idx, standoff=0.0): + """Four plates on the post faces — a wrap, not a cube through the timber. + + X-facing plates cover the post face only. Y-facing plates cover the + corners so the wrap meets at a butt instead of a coplanar overlap. + """ + half = POST / 2.0 + t = IRON_T + off = half + t / 2.0 + standoff + add_box(bm, (px + off, py, z), (t, POST, height), mat_idx) + add_box(bm, (px - off, py, z), (t, POST, height), mat_idx) + add_box(bm, (px, py + off, z), (POST + 2.0 * t, t, height), mat_idx) + add_box(bm, (px, py - off, z), (POST + 2.0 * t, t, height), mat_idx) + + def pack_uvs(bm, margin=0.08): uv = bm.loops.layers.uv.new("UVMap") faces = list(bm.faces) @@ -206,7 +250,9 @@ def pack_uvs(bm, margin=0.08): def add_shingle_roof(bm, eave_z, peak_z): - """Well-style coursed shakes on a square pyramid, plus a cone underside.""" + """Coursed shakes on a solid square pyramid. Each course is offset along + the face normal so overlapping shakes do not share vertex positions. + """ add_cone( bm, (0.0, 0.0, (eave_z + peak_z) / 2.0), @@ -219,7 +265,7 @@ def add_shingle_roof(bm, eave_z, peak_z): ) nrm_local = Vector((0.0, ROOF_RISE, EAVE_HALF)).normalized() - def add_course(yaw, t0, t1): + def add_course(yaw, t0, t1, row): rot = Euler((0.0, 0.0, yaw)).to_matrix() nrm = rot @ nrm_local @@ -229,8 +275,8 @@ def pt(t, s): z = peak_z - t * ROOF_RISE return rot @ Vector((s * w, y, z)) - inner = SHINGLE_T * 0.12 - outer = SHINGLE_T * 1.05 + inner = SHINGLE_T * (0.20 + 0.22 * row) + outer = inner + SHINGLE_T * 0.90 corners = ( pt(t0, -1.0), pt(t0, 1.0), @@ -254,12 +300,20 @@ def pt(t, s): for side in range(4): yaw = side * (math.pi / 2.0) for row in range(N_SHINGLE): - t0 = (row + 0.16) / N_SHINGLE - t1 = min(1.0, (row + 1.08) / N_SHINGLE) - add_course(yaw, t0, t1) - - -def build_tower_mesh(name, bevel_offset, bevel_segments): + t0 = (row + 0.14) / N_SHINGLE + t1 = min(0.96, (row + 1.0) / N_SHINGLE) + add_course(yaw, t0, t1, row) + + +def build_tower_mesh( + name, + bevel_offset, + bevel_segments, + short_shoes=False, + short_rails=False, + float_band=False, + rake_posts=False, +): bm = bmesh.new() try: wood = [] @@ -269,28 +323,38 @@ def build_tower_mesh(name, bevel_offset, bevel_segments): (-HALF, HALF), (HALF, HALF), ) + rake = (RAKE, 0.0, 0.0) if rake_posts else (0.0, 0.0, 0.0) for px, py in posts: wood.extend( - add_box(bm, (px, py, POST_H / 2.0), (POST, POST, POST_H), WOOD_IDX) + add_box( + bm, + (px, py, POST_H / 2.0), + (POST, POST, POST_H), + WOOD_IDX, + euler=rake, + ) ) bay = 2.0 * HALF - POST - sill_h = 0.085 - girt_h = 0.075 + # Tenon into the posts; thickness under POST so side faces are not + # coplanar with the post faces. + member = bay + POST * 0.40 + section = POST * 0.70 + sill_z = SHOE_H + SILL_H / 2.0 for sign in (-1.0, 1.0): wood.extend( add_box( bm, - (0.0, sign * HALF, sill_h / 2.0), - (bay, POST * 0.82, sill_h), + (0.0, sign * HALF, sill_z), + (member, section, SILL_H), WOOD_IDX, ) ) wood.extend( add_box( bm, - (sign * HALF, 0.0, sill_h / 2.0), - (POST * 0.82, bay, sill_h), + (sign * HALF, 0.0, sill_z), + (section, member, SILL_H), WOOD_IDX, ) ) @@ -298,7 +362,7 @@ def build_tower_mesh(name, bevel_offset, bevel_segments): add_box( bm, (0.0, sign * HALF, GIRT_Z), - (bay, POST * 0.78, girt_h), + (member, section, GIRT_H), WOOD_IDX, ) ) @@ -306,73 +370,96 @@ def build_tower_mesh(name, bevel_offset, bevel_segments): add_box( bm, (sign * HALF, 0.0, GIRT_Z), - (POST * 0.78, bay, girt_h), + (section, member, GIRT_H), WOOD_IDX, ) ) - for ysign in (-1.0, 1.0): - if ysign < 0.0: - continue + # Kick sits on the sill, not on the same plane as the sill bottom. + kick_z = SHOE_H + SILL_H + KICK_H / 2.0 + kick_span = bay * 0.88 + wood.extend( + add_box( + bm, + (0.0, HALF - 0.020, kick_z), + (kick_span, POST * 0.50, KICK_H), + WOOD_IDX, + ) + ) + for xsign in (-1.0, 1.0): wood.extend( add_box( bm, - (0.0, ysign * HALF, KICK_H / 2.0 + 0.01), - (bay * 0.92, POST * 0.55, KICK_H), + (xsign * (HALF - 0.020), 0.0, kick_z), + (POST * 0.50, kick_span, KICK_H), WOOD_IDX, ) ) - for xsign in (-1.0, 1.0): + hatch_gap = 0.32 + kick_half = (bay * 0.92 - hatch_gap) / 2.0 + for xoff in (-(hatch_gap / 2.0 + kick_half / 2.0), hatch_gap / 2.0 + kick_half / 2.0): wood.extend( add_box( bm, - (xsign * HALF, 0.0, KICK_H / 2.0 + 0.01), - (POST * 0.55, bay * 0.92, KICK_H), + (xoff, -HALF + 0.020, kick_z), + (kick_half, POST * 0.55, KICK_H), WOOD_IDX, ) ) - rise = GIRT_Z - sill_h + rise = GIRT_Z - (SHOE_H + SILL_H) run = bay blen = math.hypot(run, rise) bang = math.atan2(rise, run) - brace_z = (sill_h + GIRT_Z) / 2.0 - for ysign in (-1.0, 1.0): - if ysign < 0.0: - continue - for bang_sign in (-1.0, 1.0): + brace_z = (SHOE_H + SILL_H + GIRT_Z) / 2.0 + # Lap the two diagonals by a brace thickness so they do not occupy + # one plane (the black diamond in the old crossing). + for ysign in (1.0,): + for k, bang_sign in enumerate((-1.0, 1.0)): wood.extend( add_box( bm, - (0.0, ysign * HALF, brace_z), + (0.0, ysign * HALF + ysign * k * (BRACE_T + 0.008), brace_z), (blen, BRACE_T, BRACE_T), WOOD_IDX, euler=(0.0, bang_sign * bang, 0.0), ) ) for xsign in (-1.0, 1.0): - for bang_sign in (-1.0, 1.0): + for k, bang_sign in enumerate((-1.0, 1.0)): wood.extend( add_box( bm, - (xsign * HALF, 0.0, brace_z), + (xsign * HALF + xsign * k * (BRACE_T + 0.008), 0.0, brace_z), (BRACE_T, blen, BRACE_T), WOOD_IDX, euler=(bang_sign * bang, 0.0, 0.0), ) ) + for xsign in (-1.0, 1.0): + wood.extend( + add_oriented_box( + bm, + (xsign * (HALF - POST * 0.2), -HALF, SHOE_H + SILL_H), + (xsign * 0.22, -HALF, GIRT_Z), + (BRACE_T, BRACE_T), + WOOD_IDX, + ) + ) plank_span_x = 2.0 * HALF + POST * 0.15 usable_y = 2.0 * HALF - 0.06 plank_w = usable_y / N_PLANK y0 = -HALF + 0.03 + plank_w / 2.0 + hatch_y1 = y0 + (HATCH_PLANKS - 0.5) * plank_w for i in range(HATCH_PLANKS, N_PLANK): y = y0 + i * plank_w + jw = 0.006 * math.sin(i * 1.7) wood.extend( add_box( bm, (0.0, y, PLAT_Z + PLANK_T / 2.0), - (plank_span_x, plank_w * 0.86, PLANK_T), + (plank_span_x + jw, plank_w * 0.90, PLANK_T), WOOD_IDX, ) ) @@ -380,61 +467,85 @@ def build_tower_mesh(name, bevel_offset, bevel_segments): wood.extend( add_box( bm, - (xj, 0.06, PLAT_Z - 0.022), - (POST * 0.62, 2.0 * HALF * 0.88, 0.044), + (xj, 0.10, PLAT_Z - 0.022), + (POST * 0.62, 2.0 * HALF * 0.82, 0.044), + WOOD_IDX, + ) + ) + # Hatch trim: three boards around the missing planks. + wood.extend( + add_box( + bm, + (0.0, hatch_y1, PLAT_Z + PLANK_T / 2.0), + (0.46, 0.040, PLANK_T), + WOOD_IDX, + ) + ) + for xsign in (-1.0, 1.0): + wood.extend( + add_box( + bm, + (xsign * 0.23, -HALF + 0.12, PLAT_Z + PLANK_T / 2.0), + (0.040, hatch_y1 + HALF - 0.02, PLANK_T), WOOD_IDX, ) ) - rail_z_lo = PLAT_Z + PLANK_T + 0.12 - rail_z_hi = PLAT_Z + PLANK_T + RAIL_H - 0.04 + rail_z_lo = PLAT_Z + PLANK_T + 0.14 + rail_z_hi = PLAT_Z + PLANK_T + RAIL_H - 0.05 rail_t = POST * 0.42 + rail_len = bay * 0.70 if short_rails else bay + RAIL_TENON wood.extend( - add_box(bm, (0.0, HALF, rail_z_lo), (bay, rail_t, 0.048), WOOD_IDX) + add_box(bm, (0.0, HALF, rail_z_lo), (rail_len, rail_t, 0.048), WOOD_IDX) ) wood.extend( - add_box(bm, (0.0, HALF, rail_z_hi), (bay, rail_t, 0.048), WOOD_IDX) + add_box(bm, (0.0, HALF, rail_z_hi), (rail_len, rail_t, 0.048), WOOD_IDX) ) + hatch_clear = 0.30 + side_y0 = -HALF + hatch_clear + side_span = HALF - side_y0 + side_cy = (side_y0 + HALF) / 2.0 + side_len = side_span + (0.0 if short_rails else RAIL_TENON * 0.5) for xsign in (-1.0, 1.0): wood.extend( add_box( bm, - (xsign * HALF, 0.08, rail_z_lo), - (rail_t, bay * 0.78, 0.048), + (xsign * HALF, side_cy, rail_z_lo), + (rail_t, side_len, 0.048), WOOD_IDX, ) ) wood.extend( add_box( bm, - (xsign * HALF, 0.08, rail_z_hi), - (rail_t, bay * 0.78, 0.048), + (xsign * HALF, side_cy, rail_z_hi), + (rail_t, side_len, 0.048), WOOD_IDX, ) ) - lean = 0.20 - bot_y = -HALF - lean - top_y = -HALF + 0.02 - top_z = PLAT_Z + 0.18 - for sx in (-0.12, 0.12): + bot_y = -HALF - LEAN + top_y = -HALF + 0.10 + top_z = PLAT_Z + # Stiles plant at z=0. Separate foot cubes on the same plane as the + # stile bottoms were coplanar z-fights; a chamfered stile end is the + # foot. + for sx in (-0.13, 0.13): wood.extend( add_oriented_box( bm, - (sx, bot_y, 0.03), + (sx, bot_y, 0.0), (sx, top_y, top_z), - (0.040, 0.044), + (0.044, 0.050), WOOD_IDX, ) ) - n_rung = 8 + n_rung = 9 for i in range(n_rung): - t = (i + 1) / (n_rung + 1) - z = 0.08 + t * (PLAT_Z - 0.14) - y = bot_y + (top_y - bot_y) * ((z - 0.03) / (top_z - 0.03)) - wood.extend( - add_box(bm, (0.0, y, z), (0.26, 0.034, 0.032), WOOD_IDX) - ) + t = (i + 0.6) / (n_rung + 0.2) + z = 0.06 + t * (PLAT_Z - 0.14) + y = bot_y + (top_y - bot_y) * (z / max(top_z, 1e-6)) + wood.extend(add_box(bm, (0.0, y, z), (0.28, 0.034, 0.032), WOOD_IDX)) if bevel_offset > 0.0: edges = list({e for v in wood for e in v.link_edges}) @@ -455,6 +566,7 @@ def build_tower_mesh(name, bevel_offset, bevel_segments): add_shingle_roof(bm, eave_z, peak_z) fascia_h = 0.048 fascia_t = 0.034 + fascia_span = 2.0 * EAVE_HALF - 0.10 for side in range(4): yaw = side * (math.pi / 2.0) fx = EAVE_HALF * math.sin(yaw) @@ -462,58 +574,44 @@ def build_tower_mesh(name, bevel_offset, bevel_segments): if side % 2 == 0: add_box( bm, - (0.0, fy, eave_z - fascia_h / 2.0), - (2.0 * EAVE_HALF + fascia_t, fascia_t, fascia_h), + (0.0, fy, eave_z - fascia_h / 2.0 - 0.008), + (fascia_span, fascia_t, fascia_h), WOOD_IDX, ) else: add_box( bm, - (fx, 0.0, eave_z - fascia_h / 2.0), - (fascia_t, 2.0 * EAVE_HALF + fascia_t, fascia_h), + (fx, 0.0, eave_z - fascia_h / 2.0 - 0.008), + (fascia_t, fascia_span, fascia_h), WOOD_IDX, ) + zmin = min(v.co.z for v in bm.verts) + for v in bm.verts: + v.co.z -= zmin + if v.co.z < 0.005: + v.co.z = 0.0 + + shoe_z = SHORT_SHOE_Z if short_shoes else SHOE_H / 2.0 + shoe_h = 0.018 if short_shoes else SHOE_H + band_off = BAND_FLOAT if float_band else IRON_STANDOFF + girt_band_z = GIRT_Z + GIRT_H / 2.0 + IRON_H / 2.0 + deck_band_z = PLAT_Z + PLANK_T + IRON_H / 2.0 + 0.006 for px, py in posts: - add_box( - bm, - (px, py, SHOE_H / 2.0), - (POST * 1.38, POST * 1.38, SHOE_H), - METAL_IDX, - ) - add_box( - bm, - (px, py, GIRT_Z), - (POST * 1.12, POST * 1.12, IRON_T), - METAL_IDX, + add_collar(bm, px, py, shoe_z, shoe_h, METAL_IDX, standoff=IRON_STANDOFF) + add_collar(bm, px, py, girt_band_z, IRON_H, METAL_IDX, standoff=band_off) + add_collar( + bm, px, py, deck_band_z, IRON_H, METAL_IDX, + standoff=band_off, ) + for sx in (-0.13, 0.13): add_box( bm, - (px, py, PLAT_Z + PLANK_T + IRON_T / 2.0), - (POST * 1.18, POST * 1.18, IRON_T), - METAL_IDX, - ) - for sx in (-0.12, 0.12): - add_box( - bm, - (sx, -HALF - 0.02, PLAT_Z + 0.02), - (0.055, 0.06, IRON_T), + (sx, -HALF + 0.02, PLAT_Z + 0.02), + (0.050, 0.055, IRON_T), 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] - cx = 0.5 * (min(xs) + max(xs)) - cy = 0.5 * (min(ys) + max(ys)) - zmin = min(zs) - for v in bm.verts: - v.co.x -= cx - v.co.y -= cy - v.co.z -= zmin - if v.co.z < 0.0: - v.co.z = 0.0 - pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) for face in bm.faces: @@ -583,6 +681,232 @@ 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 poly in me.polygons: + if all(i in member for i in poly.vertices): + return poly.material_index + return None + + +def support_audit(me): + plates = [] + for group in shells(me): + if mat_of(me, group) != METAL_IDX: + continue + a = shell_aabb(me, group) + cz = 0.5 * (a[2] + a[5]) + if cz > 0.12: + continue + plates.append(a) + corners = {} + for a in plates: + cx = 0.5 * (a[0] + a[3]) + cy = 0.5 * (a[1] + a[4]) + key = (round(cx * 4.0) / 4.0, round(cy * 4.0) / 4.0) + corners.setdefault(key, []).append(a) + zmin = min((a[2] for a in plates), default=99.0) + return {"shoes": len(corners), "shoe_z": zmin} + + +def joint_audit(me): + """Guardrail bite into the +Y posts.""" + posts = [] + rails = [] + for group in shells(me): + if mat_of(me, group) != WOOD_IDX: + continue + a = shell_aabb(me, group) + dx, dy, dz = a[3] - a[0], a[4] - a[1], a[5] - a[2] + cy = 0.5 * (a[1] + a[4]) + cz = 0.5 * (a[2] + a[5]) + if dz > POST_H * 0.55 and dx < POST * 2.2 and dy < POST * 2.2: + posts.append(a) + continue + if ( + cz > PLAT_Z + and dx > HALF + and dy < POST + and abs(cy - HALF) < POST + ): + rails.append(a) + back_posts = [p for p in posts if 0.5 * (p[1] + p[4]) > 0.0] + pairs = [ + min(r[3] - p[0], p[3] - r[0]) + for r in rails + for p in back_posts + ] + engage = min(pairs) if pairs else 1.0 + return {"posts": len(posts), "rails": len(rails), "engage": engage} + + +def band_seat(me): + """Standoff from each girt collar's inner face to the post outer face.""" + posts = [] + for group in shells(me): + if mat_of(me, group) != WOOD_IDX: + continue + a = shell_aabb(me, group) + dz = a[5] - a[2] + dx = a[3] - a[0] + dy = a[4] - a[1] + if dz > POST_H * 0.55 and dx < POST * 2.2 and dy < POST * 2.2: + posts.append(a) + if not posts: + return 99.0 + gaps = [] + for group in shells(me): + if mat_of(me, group) != METAL_IDX: + continue + a = shell_aabb(me, group) + cz = 0.5 * (a[2] + a[5]) + girt_band_z = GIRT_Z + GIRT_H / 2.0 + IRON_H / 2.0 + if abs(cz - girt_band_z) > IRON_H: + continue + cx = 0.5 * (a[0] + a[3]) + cy = 0.5 * (a[1] + a[4]) + dx = a[3] - a[0] + dy = a[4] - a[1] + nearest = min( + posts, + key=lambda p: abs(cx - 0.5 * (p[0] + p[3])) + abs(cy - 0.5 * (p[1] + p[4])), + ) + px = 0.5 * (nearest[0] + nearest[3]) + py = 0.5 * (nearest[1] + nearest[4]) + if dx <= dy: + inner = a[0] if cx > px else a[3] + gaps.append(abs(inner - (px + (POST / 2.0 if cx > px else -POST / 2.0)))) + else: + inner = a[1] if cy > py else a[4] + gaps.append(abs(inner - (py + (POST / 2.0 if cy > py else -POST / 2.0)))) + if not gaps: + return 99.0 + return max(gaps) + + +def plumb_audit(me): + drifts = [] + for group in shells(me): + if mat_of(me, group) != WOOD_IDX: + continue + a = shell_aabb(me, group) + dz = a[5] - a[2] + dx = a[3] - a[0] + dy = a[4] - a[1] + if dz < POST_H * 0.55 or dx > POST * 2.2 or dy > POST * 2.2: + continue + pts = [me.vertices[i].co for i in group] + zcut_lo = a[2] + 0.08 * dz + zcut_hi = a[5] - 0.08 * dz + lo = [p for p in pts if p.z <= zcut_lo] + hi = [p for p in pts if p.z >= zcut_hi] + if len(lo) < 3 or len(hi) < 3: + continue + c_lo = Vector((sum(p.x for p in lo) / len(lo), sum(p.y for p in lo) / len(lo))) + c_hi = Vector((sum(p.x for p in hi) / len(hi), sum(p.y for p in hi) / len(hi))) + drifts.append((c_hi - c_lo).length) + return max(drifts) if drifts else 99.0 + + +def add_stray_vert(me): + bm = bmesh.new() + try: + bm.from_mesh(me) + bm.verts.new((0.0, 0.0, PLAT_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) @@ -669,16 +993,47 @@ def export_unity(path, objects): ) -def check(skip_decimate): +def check( + skip_decimate, + lift_z=False, + stray_vert=False, + short_shoes=False, + short_rails=False, + float_band=False, + rake_posts=False, +): bpy.ops.wm.read_factory_settings(use_empty=True) - low = build_tower_mesh("TowerLow", bevel_offset=0.006, bevel_segments=2) - high = build_tower_mesh("TowerHigh", bevel_offset=0.006, bevel_segments=4) + low = build_tower_mesh( + "TowerLow", + bevel_offset=0.006, + bevel_segments=2, + short_shoes=short_shoes, + short_rails=short_rails, + float_band=float_band, + rake_posts=rake_posts, + ) + high = build_tower_mesh( + "TowerHigh", + bevel_offset=0.006, + bevel_segments=4, + short_shoes=short_shoes, + short_rails=short_rails, + float_band=float_band, + rake_posts=rake_posts, + ) wood = principled("TowerWood", (0.42, 0.22, 0.08, 1.0), 0.0, 0.56) roof = principled("TowerShake", (0.30, 0.28, 0.26, 1.0), 0.0, 0.74) metal = principled("TowerIron", (0.12, 0.125, 0.14, 1.0), 1.0, 0.30) assign_slots(low, wood, roof, metal) assign_slots(high, wood, roof, 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() + if low.data is None or len(low.data.polygons) < 6: return fail("tower mesh did not build", 3), None, None, None, None, None @@ -695,6 +1050,12 @@ def check(skip_decimate): size_x = bb[3] - bb[0] size_y = bb[4] - bb[1] size_z = bb[5] - bb[2] + hyg = hygiene_audit(low.data) + zf = zfight_pairs(low.data) + sup = support_audit(low.data) + jnt = joint_audit(low.data) + seat = band_seat(low.data) + plumb = plumb_audit(low.data) img, tex = setup_bake_image(low, wood) if img is None: @@ -740,6 +1101,16 @@ 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 shoes={sup['shoes']} shoe_z={sup['shoe_z']:.5f} " + f"rail_engage={jnt['engage']:.4f} band_seat={seat:.5f} " + f"plumb={plumb:.5f}" + ) if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return fail( @@ -804,6 +1175,40 @@ 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 bb[2] > ZMIN_EPS or sup["shoes"] != SHOE_COUNT or sup["shoe_z"] > SHOE_ZMIN_MAX: + return fail( + f"grounded zmin={bb[2]:.5f} shoes={sup['shoes']} " + f"shoe_z={sup['shoe_z']:.5f}", + 16, + ), None, None, None, None, None + if jnt["engage"] < RAIL_ENGAGE_MIN: + return fail( + f"rail engage {jnt['engage']:.4f} < {RAIL_ENGAGE_MIN} " + f"posts={jnt['posts']} rails={jnt['rails']}", + 17, + ), None, None, None, None, None + if seat > BAND_SEAT_MAX: + return fail(f"band seat {seat:.5f} > {BAND_SEAT_MAX}", 18), None, None, None, None, None + if ( + plumb > POST_PLUMB_MAX + or abs(size_z - PLAN_Z) > PLAN_TOL + or abs(size_x - PLAN_XY) > PLAN_TOL + 0.20 + ): + return fail( + f"plumb={plumb:.5f} size=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"plan={PLAN_XY:.3f}x{PLAN_Z:.3f}", + 19, + ), None, None, None, None, None return 0, low, high, wood, tex, collider @@ -868,13 +1273,19 @@ def light(name, loc, energy, size, col, rot): light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + bb = world_bbox(low) + span = max(bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2], 0.2) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) - cam.location = (4.25, -5.80, 2.55) + cam.location = (span * 1.98, -span * 2.75, span * 1.13) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) - aim.location = (0.0, 0.0, 0.98) + aim.location = ( + 0.5 * (bb[0] + bb[3]), + 0.5 * (bb[1] + bb[4]), + 0.42 * (bb[2] + bb[5]), + ) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim @@ -917,14 +1328,24 @@ 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("--stray-vert", action="store_true") + p.add_argument("--lift-z", action="store_true") + p.add_argument("--short-shoes", action="store_true") + p.add_argument("--short-rails", action="store_true") + p.add_argument("--float-band", action="store_true") + p.add_argument("--rake-posts", action="store_true") 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, + short_shoes=args.short_shoes, + short_rails=args.short_rails, + float_band=args.float_band, + rake_posts=args.rake_posts, + ) if code: return code if args.output: