diff --git a/README.md b/README.md
index 10fb0c6..2bb0a70 100644
--- a/README.md
+++ b/README.md
@@ -84,7 +84,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show
-
+
[`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9.
@@ -105,7 +105,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show
[`fence-kit`](showcase/fence-kit/) — procedural post-and-rail fence section through the same pipeline. Falsifier `--skip-decimate` exits 9.
-[`watchtower`](showcase/watchtower/) — procedural timber lookout with X-braces, ladder, platform, and pyramidal roof through the same pipeline. Falsifier `--skip-decimate` exits 9.
+[`watchtower`](showcase/watchtower/) — procedural timber lookout with X-braces, hatch ladder, platform, and coursed shake roof through the same pipeline. Falsifier `--skip-decimate` exits 9.
[`cart`](showcase/cart/) — procedural two-wheel wooden cart with spoked wheels, slatted bed, and shafts through the same pipeline. Falsifier `--skip-decimate` exits 9.
diff --git a/ROADMAP.md b/ROADMAP.md
index e9c3bea..09c79af 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -113,7 +113,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~Market stall showcase~~ **SHIPPED** as `showcase/market-stall/` — timber frame, slatted counter, striped awning; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Procedural terrain or landscape showcase using Geometry Nodes scatter~~ **SHIPPED** as `showcase/terrain-scatter/` — GN sine-hill Mesh Grid, Index-jittered Instance-on-Points scatter replaced with bevelled masonry; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Hero prop with a more complex silhouette (lantern or treasure chest) as a second showcase piece~~ **SHIPPED** as `showcase/street-lantern/` and `showcase/treasure-chest/` — hanging iron lantern with muntined amber cage; slatted chest with iron bands and an open lid; `--skip-decimate` exits 9 on the LOD1 ratio band
-- ~~Timber watchtower showcase~~ **SHIPPED** as `showcase/watchtower/` — corner posts, X-braces, plank platform, ladder, pyramidal roof; `--skip-decimate` exits 9 on the LOD1 ratio band
+- ~~Timber watchtower showcase~~ **SHIPPED** as `showcase/watchtower/` — corner posts through a plank platform, lower-bay X-braces, hatch ladder, coursed shake roof; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Two-wheel wooden cart showcase~~ **SHIPPED** as `showcase/cart/` — slatted bed, shafts, spoked wheels, iron hubs and axle; `--skip-decimate` exits 9 on the LOD1 ratio band
- Wrought-iron park bench (slatted seat, scrolled legs) as a game-prop showcase piece
- Wooden wheelbarrow (staved tray, single wheel, handles) as a game-prop showcase piece
diff --git a/docs/gallery/assets/watchtower-hero.webp b/docs/gallery/assets/watchtower-hero.webp
index 2361389..326f4ab 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 aec7c6a..ea429dd 100644
--- a/docs/gallery/index.html
+++ b/docs/gallery/index.html
@@ -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: 4656 tris, two materials with 60 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.910×0.927×1.822 m, LOD ratios in band, convex collider 64 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
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.
View example →
diff --git a/docs/gallery/watchtower/index.html b/docs/gallery/watchtower/index.html
index bc3611f..723449d 100644
--- a/docs/gallery/watchtower/index.html
+++ b/docs/gallery/watchtower/index.html
@@ -253,19 +253,19 @@ watchtower
Rendered headless by the example itself — click to zoom.
- witnesses Recomputed: 4656 tris, two materials with 60 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.910×0.927×1.822 m, LOD ratios in band, convex collider 64 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+ 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.
blender --background --python showcase/watchtower/watchtower.py --
Copy
-A showcase piece, not an example. Procedural timber lookout (corner posts, X-braces, plank platform, three-sided rail, ladder, pyramidal roof, iron shoes) 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, 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.
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 | 4550–4760 | 4656 / 4656 / 4656 | | 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 2 distinct, ≥24 metal faces | 2 slots, 60 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.910, 0.927, 1.822) m ± 0.01 | (0.9104, 0.9267, 1.8220), zmin 0 | | Collider tris | ≤ 96 | 64 | | Export | written, size > 0 | 334288 / 334288 / 334272 bytes |
-DECIMATE COLLAPSE triangle counts are not required to match across series — the gate is a ratio band, not an exact count. This mesh's LOD2 ratio happened to match on all three binaries. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a gated axis.
+| 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.
Run
blender --background --python watchtower.py --
@@ -274,7 +274,7 @@ Run
Smoke does not pass --output or --skip-decimate.
Exit codes
File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path.
-| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 2 distinct slots, or metal faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file |
+| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 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 |
Source
@@ -286,7 +286,7 @@ Source
Asserts budget conformance of a procedural lookout (posts, braces,
platform, ladder, pyramidal roof) after composing shipped pipeline
-pieces: bmesh construction, UVs, two materials, high-to-low normal
+pieces: bmesh construction, UVs, three materials, high-to-low normal
bake, LOD chain, convex collider, Unity glTF export.
Budgets are declared below and recomputed from the generated result.
@@ -319,37 +319,46 @@ Source
sys.dont_write_bytecode = True
import gallery_framing # noqa: E402
-HALF = 0.40
-POST = 0.080
-POST_H = 1.48
-PLAT_Z = 1.18
-RAIL_H = 0.36
-ROOF_H = 0.52
-BRACE_T = 0.038
-PLANK_T = 0.042
+HALF = 0.46
+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
+PLANK_T = 0.038
IRON_T = 0.014
-SHOE_H = 0.032
+SHOE_H = 0.034
+EAVE_HALF = 0.70
+ROOF_RISE = 0.40
+SHINGLE_T = 0.016
+N_SHINGLE = 5
+N_PLANK = 6
+HATCH_PLANKS = 1
BBOX_TOL = 0.01
-OUTER_SIZE = (0.910 , 0.927 , 1.822 )
-BASE_TRIS_MIN = 4550
-BASE_TRIS_MAX = 4760
+OUTER_SIZE = (1.434 , 1.434 , 1.982 )
+BASE_TRIS_MIN = 5110
+BASE_TRIS_MAX = 5330
LOD1_RATIO_MIN = 0.32
LOD1_RATIO_MAX = 0.62
LOD2_RATIO_MIN = 0.10
LOD2_RATIO_MAX = 0.35
LOD1_TARGET = 0.50
LOD2_TARGET = 0.22
-MATERIAL_COUNT = 2
+MATERIAL_COUNT = 3
UV_EPS = 1e-4
UV_OVERLAP_MAX = 1e-5
-COLLIDER_TRIS_MAX = 96
+COLLIDER_TRIS_MAX = 80
BAKE_RES = 256
-CAGE_EXTRUSION = 0.08
+CAGE_EXTRUSION = 0.10
METAL_FACES_MIN = 24
+ROOF_FACES_MIN = 40
WOOD_IDX = 0
-METAL_IDX = 1
+ROOF_IDX = 1
+METAL_IDX = 2
def eevee_engine_id():
@@ -384,6 +393,24 @@ Source
ob.select_set(False )
+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,
+ ((a + b) * 0.5 ),
+ (scale_xy[0 ], scale_xy[1 ], length),
+ mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
+
+
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" ]
@@ -462,6 +489,60 @@ Source
)
+def add_shingle_roof(bm, eave_z, peak_z):
+ """Well-style coursed shakes on a square pyramid, plus a cone underside."""
+ add_cone(
+ bm,
+ (0.0 , 0.0 , (eave_z + peak_z) / 2.0 ),
+ EAVE_HALF * math.sqrt(2.0 ),
+ 0.03 ,
+ ROOF_RISE,
+ 4 ,
+ ROOF_IDX,
+ euler=(0.0 , 0.0 , math.pi / 4.0 ),
+ )
+ nrm_local = Vector((0.0 , ROOF_RISE, EAVE_HALF)).normalized()
+
+ def add_course(yaw, t0, t1):
+ rot = Euler((0.0 , 0.0 , yaw)).to_matrix()
+ nrm = rot @ nrm_local
+
+ def pt(t, s):
+ w = EAVE_HALF * t
+ y = t * EAVE_HALF
+ z = peak_z - t * ROOF_RISE
+ return rot @ Vector((s * w, y, z))
+
+ inner = SHINGLE_T * 0.12
+ outer = SHINGLE_T * 1.05
+ corners = (
+ pt(t0, -1.0 ),
+ pt(t0, 1.0 ),
+ pt(t1, 1.0 ),
+ pt(t1, -1.0 ),
+ )
+ vs = [bm.verts.new(c + nrm * inner) for c in corners]
+ vs.extend(bm.verts.new(c + nrm * outer) for c in corners)
+ idx = (
+ (0 , 1 , 2 , 3 ),
+ (4 , 7 , 6 , 5 ),
+ (0 , 4 , 5 , 1 ),
+ (1 , 5 , 6 , 2 ),
+ (2 , 6 , 7 , 3 ),
+ (3 , 7 , 4 , 0 ),
+ )
+ for a, b, c, d in idx:
+ face = bm.faces.new((vs[a], vs[b], vs[c], vs[d]))
+ face.material_index = ROOF_IDX
+
+ 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):
bm = bmesh.new()
try :
@@ -477,41 +558,77 @@ Source
add_box(bm, (px, py, POST_H / 2.0 ), (POST, POST, POST_H), WOOD_IDX)
)
- span = 2.0 * HALF + POST * 0.15
+ bay = 2.0 * HALF - POST
+ sill_h = 0.085
+ girt_h = 0.075
for sign in (-1.0 , 1.0 ):
wood.extend(
- add_box(bm, (0.0 , sign * HALF, 0.04 ), (span, POST * 0.92 , 0.08 ), WOOD_IDX)
+ add_box(
+ bm,
+ (0.0 , sign * HALF, sill_h / 2.0 ),
+ (bay, POST * 0.82 , sill_h),
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_box(
+ bm,
+ (sign * HALF, 0.0 , sill_h / 2.0 ),
+ (POST * 0.82 , bay, sill_h),
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_box(
+ bm,
+ (0.0 , sign * HALF, GIRT_Z),
+ (bay, POST * 0.78 , girt_h),
+ WOOD_IDX,
+ )
)
wood.extend(
- add_box(bm, (sign * HALF, 0.0 , 0.04 ), (POST * 0.92 , span, 0.08 ), WOOD_IDX)
+ add_box(
+ bm,
+ (sign * HALF, 0.0 , GIRT_Z),
+ (POST * 0.78 , bay, girt_h),
+ WOOD_IDX,
+ )
)
+
+ for ysign in (-1.0 , 1.0 ):
+ if ysign < 0.0 :
+ continue
wood.extend(
add_box(
bm,
- (0.0 , sign * HALF, POST_H - 0.04 ),
- (span, POST * 0.85 , 0.08 ),
+ (0.0 , ysign * HALF, KICK_H / 2.0 + 0.01 ),
+ (bay * 0.92 , POST * 0.55 , KICK_H),
WOOD_IDX,
)
)
+ for xsign in (-1.0 , 1.0 ):
wood.extend(
add_box(
bm,
- (sign * HALF, 0.0 , POST_H - 0.04 ),
- (POST * 0.85 , span, 0.08 ),
+ (xsign * HALF, 0.0 , KICK_H / 2.0 + 0.01 ),
+ (POST * 0.55 , bay * 0.92 , KICK_H),
WOOD_IDX,
)
)
- rise = 0.72
- run = 2.0 * HALF - POST
+ rise = GIRT_Z - 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 ):
wood.extend(
add_box(
bm,
- (0.0 , ysign * (HALF + POST * 0.15 ), 0.52 ),
+ (0.0 , ysign * HALF, brace_z),
(blen, BRACE_T, BRACE_T),
WOOD_IDX,
euler=(0.0 , bang_sign * bang, 0.0 ),
@@ -522,99 +639,86 @@ Source
wood.extend(
add_box(
bm,
- (xsign * (HALF + POST * 0.15 ), 0.0 , 0.52 ),
+ (xsign * HALF, 0.0 , brace_z),
(BRACE_T, blen, BRACE_T),
WOOD_IDX,
euler=(bang_sign * bang, 0.0 , 0.0 ),
)
)
- n_plank = 5
- plank_w = (2.0 * HALF - 0.04 ) / n_plank
- y0 = -HALF + 0.02 + plank_w / 2.0
- for i in range(n_plank):
+ 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
+ for i in range(HATCH_PLANKS, N_PLANK):
y = y0 + i * plank_w
wood.extend(
add_box(
bm,
(0.0 , y, PLAT_Z + PLANK_T / 2.0 ),
- (2.0 * HALF + POST * 0.4 , plank_w * 0.88 , PLANK_T),
+ (plank_span_x, plank_w * 0.86 , PLANK_T),
WOOD_IDX,
)
)
-
- rail_z = PLAT_Z + PLANK_T + RAIL_H * 0.55
- for px, py in ((-HALF, HALF), (HALF, HALF), (HALF, -HALF), (-HALF, -HALF)):
+ for xj in (-HALF * 0.42 , HALF * 0.42 ):
wood.extend(
add_box(
bm,
- (px, py, PLAT_Z + PLANK_T + RAIL_H / 2.0 ),
- (POST * 0.62 , POST * 0.62 , RAIL_H),
+ (xj, 0.06 , PLAT_Z - 0.022 ),
+ (POST * 0.62 , 2.0 * HALF * 0.88 , 0.044 ),
WOOD_IDX,
)
)
+
+ rail_z_lo = PLAT_Z + PLANK_T + 0.12
+ rail_z_hi = PLAT_Z + PLANK_T + RAIL_H - 0.04
+ rail_t = POST * 0.42
wood.extend(
- add_box(
- bm,
- (0.0 , HALF, rail_z),
- (2.0 * HALF, POST * 0.42 , 0.045 ),
- WOOD_IDX,
- )
- )
- wood.extend(
- add_box(
- bm,
- (HALF, 0.12 , rail_z),
- (POST * 0.42 , 2.0 * HALF * 0.72 , 0.045 ),
- WOOD_IDX,
- )
+ add_box(bm, (0.0 , HALF, rail_z_lo), (bay, rail_t, 0.048 ), WOOD_IDX)
)
wood.extend(
- add_box(
- bm,
- (-HALF, 0.12 , rail_z),
- (POST * 0.42 , 2.0 * HALF * 0.72 , 0.045 ),
- WOOD_IDX,
- )
+ add_box(bm, (0.0 , HALF, rail_z_hi), (bay, rail_t, 0.048 ), WOOD_IDX)
)
-
- stile_y = -HALF - POST * 0.55
- for sx in (-0.13 , 0.13 ):
+ for xsign in (-1.0 , 1.0 ):
wood.extend(
add_box(
bm,
- (sx, stile_y, PLAT_Z / 2.0 ),
- (0.038 , 0.042 , PLAT_Z + 0.06 ),
+ (xsign * HALF, 0.08 , rail_z_lo),
+ (rail_t, bay * 0.78 , 0.048 ),
WOOD_IDX,
)
)
- n_rung = 6
- for i in range(n_rung):
- z = 0.16 + i * (PLAT_Z - 0.22 ) / (n_rung - 1 )
wood.extend(
- add_box(bm, (0.0 , stile_y, z), (0.28 , 0.032 , 0.032 ), WOOD_IDX)
+ add_box(
+ bm,
+ (xsign * HALF, 0.08 , rail_z_hi),
+ (rail_t, bay * 0.78 , 0.048 ),
+ WOOD_IDX,
+ )
)
- wood.extend(
- add_box(
- bm,
- (0.0 , 0.0 , PLAT_Z + PLANK_T + 0.03 ),
- (2.0 * HALF + POST * 1.15 , 2.0 * HALF + POST * 1.15 , 0.05 ),
- 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 ):
+ wood.extend(
+ add_oriented_box(
+ bm,
+ (sx, bot_y, 0.03 ),
+ (sx, top_y, top_z),
+ (0.040 , 0.044 ),
+ WOOD_IDX,
+ )
)
- )
- wood.extend(
- add_cone(
- bm,
- (0.0 , 0.0 , PLAT_Z + PLANK_T + 0.05 + ROOF_H / 2.0 ),
- HALF + POST * 0.95 ,
- 0.018 ,
- ROOF_H,
- 4 ,
- WOOD_IDX,
- euler=(0.0 , 0.0 , math.pi / 4.0 ),
+ n_rung = 8
+ 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)
)
- )
if bevel_offset > 0.0 :
edges = list({e for v in wood for e in v.link_edges})
@@ -630,6 +734,30 @@ Source
for f in ret.get("faces" ) or []:
f.material_index = WOOD_IDX
+ eave_z = POST_H
+ peak_z = POST_H + ROOF_RISE
+ add_shingle_roof(bm, eave_z, peak_z)
+ fascia_h = 0.048
+ fascia_t = 0.034
+ for side in range(4 ):
+ yaw = side * (math.pi / 2.0 )
+ fx = EAVE_HALF * math.sin(yaw)
+ fy = EAVE_HALF * math.cos(yaw)
+ if side % 2 == 0 :
+ add_box(
+ bm,
+ (0.0 , fy, eave_z - fascia_h / 2.0 ),
+ (2.0 * EAVE_HALF + fascia_t, fascia_t, fascia_h),
+ WOOD_IDX,
+ )
+ else :
+ add_box(
+ bm,
+ (fx, 0.0 , eave_z - fascia_h / 2.0 ),
+ (fascia_t, 2.0 * EAVE_HALF + fascia_t, fascia_h),
+ WOOD_IDX,
+ )
+
for px, py in posts:
add_box(
bm,
@@ -637,17 +765,23 @@ Source
(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_box(
bm,
(px, py, PLAT_Z + PLANK_T + IRON_T / 2.0 ),
- (POST * 1.15 , POST * 1.15 , IRON_T),
+ (POST * 1.18 , POST * 1.18 , IRON_T),
METAL_IDX,
)
- for sx in (-0.13 , 0.13 ):
+ for sx in (-0.12 , 0.12 ):
add_box(
bm,
- (sx, stile_y, PLAT_Z + 0.02 ),
- (0.05 , 0.055 , IRON_T),
+ (sx, -HALF - 0.02 , PLAT_Z + 0.02 ),
+ (0.055 , 0.06 , IRON_T),
METAL_IDX,
)
@@ -690,9 +824,9 @@ Source
return mat
-def assign_slots(obj, wood, metal):
+def assign_slots(obj, wood, roof, metal):
mats = obj.data.materials
- wanted = (wood, metal)
+ wanted = (wood, roof, metal)
for i, mat in enumerate(wanted):
if i < len(mats):
mats[i] = mat
@@ -823,10 +957,11 @@ Source
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 )
- wood = principled("TowerWood" , (0.44 , 0.23 , 0.08 , 1.0 ), 0.0 , 0.56 )
+ 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, metal)
- assign_slots(high, wood, metal)
+ assign_slots(low, wood, roof, metal)
+ assign_slots(high, wood, roof, metal)
if low.data is None or len(low.data.polygons) < 6 :
return fail("tower mesh did not build" , 3 ), None , None , None , None , None
@@ -905,6 +1040,11 @@ Source
f" metal faces {idx_counts.get(METAL_IDX, 0 )} < {METAL_FACES_MIN}" ,
5 ,
), None , None , None , None , None
+ if idx_counts.get(ROOF_IDX, 0 ) < ROOF_FACES_MIN:
+ return fail(
+ f" roof faces {idx_counts.get(ROOF_IDX, 0 )} < {ROOF_FACES_MIN}" ,
+ 5 ,
+ ), None , None , None , None , None
if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
return fail(
f" UVs outside 0..1: ( {u0:.4f }, {v0:.4f })-( {u1:.4f }, {v1:.4f }) " ,
@@ -968,7 +1108,7 @@ Source
ob.hide_render = True
ob.hide_viewport = True
- low.rotation_euler.z = math.radians(-22.0 )
+ low.rotation_euler.z = math.radians(-28.0 )
low.rotation_euler.x = math.radians(2.0 )
floor_me = bpy.data.meshes.new("Floor" )
@@ -1015,10 +1155,10 @@ Source
cam_data = bpy.data.cameras.new("Cam" )
cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam" , cam_data)
- cam.location = (3.85 , -5.15 , 2.55 )
+ cam.location = (4.25 , -5.80 , 2.55 )
scene.collection.objects.link(cam)
aim = bpy.data.objects.new("Aim" , None )
- aim.location = (0.0 , 0.0 , 0.88 )
+ aim.location = (0.0 , 0.0 , 0.98 )
scene.collection.objects.link(aim)
con = cam.constraints.new("TRACK_TO" )
con.target = aim
diff --git a/showcase/gallery.json b/showcase/gallery.json
index 87b5241..89c2705 100644
--- a/showcase/gallery.json
+++ b/showcase/gallery.json
@@ -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: 4656 tris, two materials with 60 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.910×0.927×1.822 m, LOD ratios in band, convex collider 64 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
+ "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.",
"hero": "docs/gallery/assets/watchtower-hero.webp",
"preview": "showcase/watchtower/preview.webp",
"tags": [
diff --git a/showcase/watchtower/README.md b/showcase/watchtower/README.md
index d11249a..1735941 100644
--- a/showcase/watchtower/README.md
+++ b/showcase/watchtower/README.md
@@ -1,9 +1,10 @@
# Watchtower
A showcase piece, not an example. Procedural timber lookout (corner
-posts, X-braces, plank platform, three-sided rail, ladder, pyramidal
-roof, iron shoes) then the shipped pipeline: unique-cell UVs, Cycles
-high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
+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.
It asserts **budget conformance** of the generated result. It does not
witness an API contract. "It rendered without error" is not a check.
@@ -21,21 +22,19 @@ materials, UVs, evaluated LOD, collider, or export file.
| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) |
| --- | --- | --- |
-| Base triangles | 4550–4760 | 4656 / 4656 / 4656 |
+| 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 2 distinct, ≥24 metal faces | 2 slots, 60 metal |
+| 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 | (0.910, 0.927, 1.822) m ± 0.01 | (0.9104, 0.9267, 1.8220), zmin 0 |
-| Collider tris | ≤ 96 | 64 |
-| Export | written, size > 0 | 334288 / 334288 / 334272 bytes |
+| 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. This mesh's LOD2
-ratio happened to match on all three binaries. Bake pixels are
+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
-16 B on 5.2.1 (glTF serializer), not a gated axis.
+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.
@@ -62,7 +61,7 @@ File-local. `9` is a valid check code. `10` is reserved for
| 2 | argparse / usage |
| 3 | Mesh did not build / no UV layer |
| 4 | Base triangle count outside range |
-| 5 | Material count ≠ 2 distinct slots, or metal faces missing |
+| 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 |
diff --git a/showcase/watchtower/preview.webp b/showcase/watchtower/preview.webp
index a268a34..7a8c86a 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 576fbfa..9373d47 100644
--- a/showcase/watchtower/watchtower.py
+++ b/showcase/watchtower/watchtower.py
@@ -1,8 +1,8 @@
"""Game-ready timber watchtower — a showcase piece, not an example.
Asserts budget conformance of a procedural lookout (posts, braces,
-platform, ladder, pyramidal roof) after composing shipped pipeline
-pieces: bmesh construction, UVs, two materials, high-to-low normal
+platform, hatch ladder, coursed shake roof) after composing shipped pipeline
+pieces: bmesh construction, UVs, three materials, high-to-low normal
bake, LOD chain, convex collider, Unity glTF export.
Budgets are declared below and recomputed from the generated result.
@@ -35,37 +35,46 @@
sys.dont_write_bytecode = True
import gallery_framing # noqa: E402
-HALF = 0.40
-POST = 0.080
-POST_H = 1.48
-PLAT_Z = 1.18
-RAIL_H = 0.36
-ROOF_H = 0.52
-BRACE_T = 0.038
-PLANK_T = 0.042
+HALF = 0.46
+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
+PLANK_T = 0.038
IRON_T = 0.014
-SHOE_H = 0.032
+SHOE_H = 0.034
+EAVE_HALF = 0.70
+ROOF_RISE = 0.40
+SHINGLE_T = 0.016
+N_SHINGLE = 5
+N_PLANK = 6
+HATCH_PLANKS = 1
BBOX_TOL = 0.01
-OUTER_SIZE = (0.910, 0.927, 1.822)
-BASE_TRIS_MIN = 4550
-BASE_TRIS_MAX = 4760
+OUTER_SIZE = (1.434, 1.434, 1.982)
+BASE_TRIS_MIN = 5110
+BASE_TRIS_MAX = 5330
LOD1_RATIO_MIN = 0.32
LOD1_RATIO_MAX = 0.62
LOD2_RATIO_MIN = 0.10
LOD2_RATIO_MAX = 0.35
LOD1_TARGET = 0.50
LOD2_TARGET = 0.22
-MATERIAL_COUNT = 2
+MATERIAL_COUNT = 3
UV_EPS = 1e-4
UV_OVERLAP_MAX = 1e-5
-COLLIDER_TRIS_MAX = 96
+COLLIDER_TRIS_MAX = 80
BAKE_RES = 256
-CAGE_EXTRUSION = 0.08
+CAGE_EXTRUSION = 0.10
METAL_FACES_MIN = 24
+ROOF_FACES_MIN = 40
WOOD_IDX = 0
-METAL_IDX = 1
+ROOF_IDX = 1
+METAL_IDX = 2
def eevee_engine_id():
@@ -100,6 +109,24 @@ def deselect_all():
ob.select_set(False)
+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,
+ ((a + b) * 0.5),
+ (scale_xy[0], scale_xy[1], length),
+ mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
+
+
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"]
@@ -178,6 +205,60 @@ 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."""
+ add_cone(
+ bm,
+ (0.0, 0.0, (eave_z + peak_z) / 2.0),
+ EAVE_HALF * math.sqrt(2.0),
+ 0.03,
+ ROOF_RISE,
+ 4,
+ ROOF_IDX,
+ euler=(0.0, 0.0, math.pi / 4.0),
+ )
+ nrm_local = Vector((0.0, ROOF_RISE, EAVE_HALF)).normalized()
+
+ def add_course(yaw, t0, t1):
+ rot = Euler((0.0, 0.0, yaw)).to_matrix()
+ nrm = rot @ nrm_local
+
+ def pt(t, s):
+ w = EAVE_HALF * t
+ y = t * EAVE_HALF
+ z = peak_z - t * ROOF_RISE
+ return rot @ Vector((s * w, y, z))
+
+ inner = SHINGLE_T * 0.12
+ outer = SHINGLE_T * 1.05
+ corners = (
+ pt(t0, -1.0),
+ pt(t0, 1.0),
+ pt(t1, 1.0),
+ pt(t1, -1.0),
+ )
+ vs = [bm.verts.new(c + nrm * inner) for c in corners]
+ vs.extend(bm.verts.new(c + nrm * outer) for c in corners)
+ idx = (
+ (0, 1, 2, 3),
+ (4, 7, 6, 5),
+ (0, 4, 5, 1),
+ (1, 5, 6, 2),
+ (2, 6, 7, 3),
+ (3, 7, 4, 0),
+ )
+ for a, b, c, d in idx:
+ face = bm.faces.new((vs[a], vs[b], vs[c], vs[d]))
+ face.material_index = ROOF_IDX
+
+ 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):
bm = bmesh.new()
try:
@@ -193,41 +274,77 @@ def build_tower_mesh(name, bevel_offset, bevel_segments):
add_box(bm, (px, py, POST_H / 2.0), (POST, POST, POST_H), WOOD_IDX)
)
- span = 2.0 * HALF + POST * 0.15
+ bay = 2.0 * HALF - POST
+ sill_h = 0.085
+ girt_h = 0.075
for sign in (-1.0, 1.0):
wood.extend(
- add_box(bm, (0.0, sign * HALF, 0.04), (span, POST * 0.92, 0.08), WOOD_IDX)
+ add_box(
+ bm,
+ (0.0, sign * HALF, sill_h / 2.0),
+ (bay, POST * 0.82, sill_h),
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_box(
+ bm,
+ (sign * HALF, 0.0, sill_h / 2.0),
+ (POST * 0.82, bay, sill_h),
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_box(
+ bm,
+ (0.0, sign * HALF, GIRT_Z),
+ (bay, POST * 0.78, girt_h),
+ WOOD_IDX,
+ )
)
wood.extend(
- add_box(bm, (sign * HALF, 0.0, 0.04), (POST * 0.92, span, 0.08), WOOD_IDX)
+ add_box(
+ bm,
+ (sign * HALF, 0.0, GIRT_Z),
+ (POST * 0.78, bay, girt_h),
+ WOOD_IDX,
+ )
)
+
+ for ysign in (-1.0, 1.0):
+ if ysign < 0.0:
+ continue
wood.extend(
add_box(
bm,
- (0.0, sign * HALF, POST_H - 0.04),
- (span, POST * 0.85, 0.08),
+ (0.0, ysign * HALF, KICK_H / 2.0 + 0.01),
+ (bay * 0.92, POST * 0.55, KICK_H),
WOOD_IDX,
)
)
+ for xsign in (-1.0, 1.0):
wood.extend(
add_box(
bm,
- (sign * HALF, 0.0, POST_H - 0.04),
- (POST * 0.85, span, 0.08),
+ (xsign * HALF, 0.0, KICK_H / 2.0 + 0.01),
+ (POST * 0.55, bay * 0.92, KICK_H),
WOOD_IDX,
)
)
- rise = 0.72
- run = 2.0 * HALF - POST
+ rise = GIRT_Z - 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):
wood.extend(
add_box(
bm,
- (0.0, ysign * (HALF + POST * 0.15), 0.52),
+ (0.0, ysign * HALF, brace_z),
(blen, BRACE_T, BRACE_T),
WOOD_IDX,
euler=(0.0, bang_sign * bang, 0.0),
@@ -238,99 +355,86 @@ def build_tower_mesh(name, bevel_offset, bevel_segments):
wood.extend(
add_box(
bm,
- (xsign * (HALF + POST * 0.15), 0.0, 0.52),
+ (xsign * HALF, 0.0, brace_z),
(BRACE_T, blen, BRACE_T),
WOOD_IDX,
euler=(bang_sign * bang, 0.0, 0.0),
)
)
- n_plank = 5
- plank_w = (2.0 * HALF - 0.04) / n_plank
- y0 = -HALF + 0.02 + plank_w / 2.0
- for i in range(n_plank):
+ 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
+ for i in range(HATCH_PLANKS, N_PLANK):
y = y0 + i * plank_w
wood.extend(
add_box(
bm,
(0.0, y, PLAT_Z + PLANK_T / 2.0),
- (2.0 * HALF + POST * 0.4, plank_w * 0.88, PLANK_T),
+ (plank_span_x, plank_w * 0.86, PLANK_T),
WOOD_IDX,
)
)
-
- rail_z = PLAT_Z + PLANK_T + RAIL_H * 0.55
- for px, py in ((-HALF, HALF), (HALF, HALF), (HALF, -HALF), (-HALF, -HALF)):
+ for xj in (-HALF * 0.42, HALF * 0.42):
wood.extend(
add_box(
bm,
- (px, py, PLAT_Z + PLANK_T + RAIL_H / 2.0),
- (POST * 0.62, POST * 0.62, RAIL_H),
+ (xj, 0.06, PLAT_Z - 0.022),
+ (POST * 0.62, 2.0 * HALF * 0.88, 0.044),
WOOD_IDX,
)
)
+
+ rail_z_lo = PLAT_Z + PLANK_T + 0.12
+ rail_z_hi = PLAT_Z + PLANK_T + RAIL_H - 0.04
+ rail_t = POST * 0.42
wood.extend(
- add_box(
- bm,
- (0.0, HALF, rail_z),
- (2.0 * HALF, POST * 0.42, 0.045),
- WOOD_IDX,
- )
- )
- wood.extend(
- add_box(
- bm,
- (HALF, 0.12, rail_z),
- (POST * 0.42, 2.0 * HALF * 0.72, 0.045),
- WOOD_IDX,
- )
+ add_box(bm, (0.0, HALF, rail_z_lo), (bay, rail_t, 0.048), WOOD_IDX)
)
wood.extend(
- add_box(
- bm,
- (-HALF, 0.12, rail_z),
- (POST * 0.42, 2.0 * HALF * 0.72, 0.045),
- WOOD_IDX,
- )
+ add_box(bm, (0.0, HALF, rail_z_hi), (bay, rail_t, 0.048), WOOD_IDX)
)
-
- stile_y = -HALF - POST * 0.55
- for sx in (-0.13, 0.13):
+ for xsign in (-1.0, 1.0):
wood.extend(
add_box(
bm,
- (sx, stile_y, PLAT_Z / 2.0),
- (0.038, 0.042, PLAT_Z + 0.06),
+ (xsign * HALF, 0.08, rail_z_lo),
+ (rail_t, bay * 0.78, 0.048),
WOOD_IDX,
)
)
- n_rung = 6
- for i in range(n_rung):
- z = 0.16 + i * (PLAT_Z - 0.22) / (n_rung - 1)
wood.extend(
- add_box(bm, (0.0, stile_y, z), (0.28, 0.032, 0.032), WOOD_IDX)
+ add_box(
+ bm,
+ (xsign * HALF, 0.08, rail_z_hi),
+ (rail_t, bay * 0.78, 0.048),
+ WOOD_IDX,
+ )
)
- wood.extend(
- add_box(
- bm,
- (0.0, 0.0, PLAT_Z + PLANK_T + 0.03),
- (2.0 * HALF + POST * 1.15, 2.0 * HALF + POST * 1.15, 0.05),
- 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):
+ wood.extend(
+ add_oriented_box(
+ bm,
+ (sx, bot_y, 0.03),
+ (sx, top_y, top_z),
+ (0.040, 0.044),
+ WOOD_IDX,
+ )
)
- )
- wood.extend(
- add_cone(
- bm,
- (0.0, 0.0, PLAT_Z + PLANK_T + 0.05 + ROOF_H / 2.0),
- HALF + POST * 0.95,
- 0.018,
- ROOF_H,
- 4,
- WOOD_IDX,
- euler=(0.0, 0.0, math.pi / 4.0),
+ n_rung = 8
+ 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)
)
- )
if bevel_offset > 0.0:
edges = list({e for v in wood for e in v.link_edges})
@@ -346,6 +450,30 @@ def build_tower_mesh(name, bevel_offset, bevel_segments):
for f in ret.get("faces") or []:
f.material_index = WOOD_IDX
+ eave_z = POST_H
+ peak_z = POST_H + ROOF_RISE
+ add_shingle_roof(bm, eave_z, peak_z)
+ fascia_h = 0.048
+ fascia_t = 0.034
+ for side in range(4):
+ yaw = side * (math.pi / 2.0)
+ fx = EAVE_HALF * math.sin(yaw)
+ fy = EAVE_HALF * math.cos(yaw)
+ if side % 2 == 0:
+ add_box(
+ bm,
+ (0.0, fy, eave_z - fascia_h / 2.0),
+ (2.0 * EAVE_HALF + fascia_t, fascia_t, fascia_h),
+ WOOD_IDX,
+ )
+ else:
+ add_box(
+ bm,
+ (fx, 0.0, eave_z - fascia_h / 2.0),
+ (fascia_t, 2.0 * EAVE_HALF + fascia_t, fascia_h),
+ WOOD_IDX,
+ )
+
for px, py in posts:
add_box(
bm,
@@ -353,17 +481,23 @@ def build_tower_mesh(name, bevel_offset, bevel_segments):
(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_box(
bm,
(px, py, PLAT_Z + PLANK_T + IRON_T / 2.0),
- (POST * 1.15, POST * 1.15, IRON_T),
+ (POST * 1.18, POST * 1.18, IRON_T),
METAL_IDX,
)
- for sx in (-0.13, 0.13):
+ for sx in (-0.12, 0.12):
add_box(
bm,
- (sx, stile_y, PLAT_Z + 0.02),
- (0.05, 0.055, IRON_T),
+ (sx, -HALF - 0.02, PLAT_Z + 0.02),
+ (0.055, 0.06, IRON_T),
METAL_IDX,
)
@@ -406,9 +540,9 @@ def principled(name, color, metallic, roughness):
return mat
-def assign_slots(obj, wood, metal):
+def assign_slots(obj, wood, roof, metal):
mats = obj.data.materials
- wanted = (wood, metal)
+ wanted = (wood, roof, metal)
for i, mat in enumerate(wanted):
if i < len(mats):
mats[i] = mat
@@ -539,10 +673,11 @@ def check(skip_decimate):
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)
- wood = principled("TowerWood", (0.44, 0.23, 0.08, 1.0), 0.0, 0.56)
+ 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, metal)
- assign_slots(high, wood, metal)
+ assign_slots(low, wood, roof, metal)
+ assign_slots(high, wood, roof, metal)
if low.data is None or len(low.data.polygons) < 6:
return fail("tower mesh did not build", 3), None, None, None, None, None
@@ -621,6 +756,11 @@ def check(skip_decimate):
f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
5,
), None, None, None, None, None
+ if idx_counts.get(ROOF_IDX, 0) < ROOF_FACES_MIN:
+ return fail(
+ f"roof faces {idx_counts.get(ROOF_IDX, 0)} < {ROOF_FACES_MIN}",
+ 5,
+ ), None, None, None, None, None
if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
return fail(
f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
@@ -684,7 +824,7 @@ def render_still(low, wood, tex, path, engine):
ob.hide_render = True
ob.hide_viewport = True
- low.rotation_euler.z = math.radians(-22.0)
+ low.rotation_euler.z = math.radians(-28.0)
low.rotation_euler.x = math.radians(2.0)
floor_me = bpy.data.meshes.new("Floor")
@@ -731,10 +871,10 @@ def light(name, loc, energy, size, col, rot):
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (3.85, -5.15, 2.55)
+ cam.location = (4.25, -5.80, 2.55)
scene.collection.objects.link(cam)
aim = bpy.data.objects.new("Aim", None)
- aim.location = (0.0, 0.0, 0.88)
+ aim.location = (0.0, 0.0, 0.98)
scene.collection.objects.link(aim)
con = cam.constraints.new("TRACK_TO")
con.target = aim