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 @@
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 @@
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
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 --
Copy
-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. 15–19 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
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 --
Copy
-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. 15–19 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: