From 1a17418d795101d92d45f9de7cf4dca8c31a6f9c Mon Sep 17 00:00:00 2001
From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Date: Sun, 13 Sep 2026 20:13:55 -0400
Subject: [PATCH 1/2] feat: add park-bench showcase piece
Wrought-iron slatted bench with scrolled legs through the shipped budget pipeline. --skip-decimate exits 9 on the LOD1 ratio band.
Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Co-authored-by: Cursor
---
.cursor-plugin/plugin.json | 3 +-
AGENTS.md | 2 +-
CLAUDE.md | 2 +-
README.md | 7 +-
ROADMAP.md | 2 +-
docs/gallery/assets/park-bench-hero.webp | Bin 0 -> 25368 bytes
docs/gallery/index.html | 13 +-
docs/gallery/park-bench/index.html | 1080 ++++++++++++++++++++++
docs/gallery/watchtower/index.html | 2 +-
showcase/gallery.json | 12 +
showcase/park-bench/README.md | 74 ++
showcase/park-bench/park_bench.py | 756 +++++++++++++++
showcase/park-bench/preview.webp | Bin 0 -> 21340 bytes
tests/smoke/catalog.json | 3 +-
14 files changed, 1947 insertions(+), 9 deletions(-)
create mode 100644 docs/gallery/assets/park-bench-hero.webp
create mode 100644 docs/gallery/park-bench/index.html
create mode 100644 showcase/park-bench/README.md
create mode 100644 showcase/park-bench/park_bench.py
create mode 100644 showcase/park-bench/preview.webp
diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index c764713..c1d2462 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -148,6 +148,7 @@
"showcase/terrain-scatter",
"showcase/fence-kit",
"showcase/watchtower",
- "showcase/cart"
+ "showcase/cart",
+ "showcase/park-bench"
]
}
diff --git a/AGENTS.md b/AGENTS.md
index 17d2def..f308065 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,7 +21,7 @@ classifies it as a `cursor-plugin`. This is content the AI loads when the user
asks Blender questions or works on Blender add-ons in Cursor or Claude Code.
The content base is 16 skills, 9 rules, 3 templates, 27 snippets, 59
-examples, and 11 showcase pieces (counts are CI-enforced against README.md
+examples, and 12 showcase pieces (counts are CI-enforced against README.md)
and the manifest). The full inventory tables and per-item purposes live in
`CLAUDE.md`. Example anatomy and authoring rules: copy `examples/bmesh-gear/`;
showcase conventions: `showcase/README.md`. The render look is specified
diff --git a/CLAUDE.md b/CLAUDE.md
index 45a70d1..dbd6a1d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,7 +22,7 @@ rules/.mdc - Anti-pattern rules, 9 total
templates// - Starter projects, 3 total
snippets/.py - Standalone code patterns, 27 total
examples// - Runnable smoke-gated examples, 59 total (+ gallery.json)
-showcase// - Budget-conformance props, 11 pieces (sibling of examples/; see showcase/README.md)
+showcase// - Budget-conformance props, 12 pieces (sibling of examples/; see showcase/README.md)
scripts/build_gallery.py - Regenerates docs/gallery/ from examples/gallery.json + showcase/gallery.json
scripts/site/ - Vendored landing-page build (Jinja2)
docs/gallery/ - Committed generated gallery pages + hero renders
diff --git a/README.md b/README.md
index 2bb0a70..e5f4b11 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 11 showcase pieces
+ 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 12 showcase pieces
@@ -37,7 +37,7 @@
## Overview
-This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 11 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable.
+This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 12 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable.
The content is consumed by AI coding agents (Cursor, Claude Code, any MCP-capable client) when working on Blender add-ons, geometry nodes scripts, batch pipelines, or animation tooling. There is no build step. Edit the markdown and Python files directly.
@@ -86,6 +86,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.
@@ -109,6 +110,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show
[`cart`](showcase/cart/) — procedural two-wheel wooden cart with spoked wheels, slatted bed, and shafts through the same pipeline. Falsifier `--skip-decimate` exits 9.
+[`park-bench`](showcase/park-bench/) — procedural wrought-iron park bench with scrolled legs and slatted seat through the same pipeline. Falsifier `--skip-decimate` exits 9.
+
## Examples
Runnable, smoke-gated demos live in [`examples/`](examples/) — each is executed headless on
diff --git a/ROADMAP.md b/ROADMAP.md
index 05440a8..8179a94 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -115,7 +115,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~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 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
+- ~~Wrought-iron park bench (slatted seat, scrolled legs) as a game-prop showcase piece~~ **SHIPPED** as `showcase/park-bench/` — scrolled iron legs, slatted seat and back; `--skip-decimate` exits 9 on the LOD1 ratio band
- Wooden wheelbarrow (staved tray, single wheel, handles) as a game-prop showcase piece
- ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **SHIPPED** as `showcase/fence-kit/` — tiling post-and-rail section (AABB X is the tile width; does not re-witness the snap contract); `--skip-decimate` exits 9 on the LOD1 ratio band
diff --git a/docs/gallery/assets/park-bench-hero.webp b/docs/gallery/assets/park-bench-hero.webp
new file mode 100644
index 0000000000000000000000000000000000000000..9d4d4ebf1485926d8a4360595123fa62703b21bf
GIT binary patch
literal 25368
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
H00000-5^ES
literal 0
HcmV?d00001
diff --git a/docs/gallery/index.html b/docs/gallery/index.html
index ea429dd..ba1f9f5 100644
--- a/docs/gallery/index.html
+++ b/docs/gallery/index.html
@@ -272,7 +272,7 @@ Examples Gallery
autocomplete="off" spellcheck="false" aria-label="Search examples" />
×
- 62 examples
+ 63 examples
+
+
+
+
+
+
+
A procedural wrought-iron park bench through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
+
witnesses Recomputed: 2100 tris, two materials with 510 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.080×0.566×0.853 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 106 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
View example →
+
+
No examples match the current filters.
Clear search and tags
diff --git a/docs/gallery/park-bench/index.html b/docs/gallery/park-bench/index.html
new file mode 100644
index 0000000..a7246da
--- /dev/null
+++ b/docs/gallery/park-bench/index.html
@@ -0,0 +1,1080 @@
+
+
+
+
+
+ park-bench — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ park-bench
+ A procedural wrought-iron park bench through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
+
+
+
+
+
+ Rendered headless by the example itself — click to zoom.
+ witnesses Recomputed: 2100 tris, two materials with 510 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.080×0.566×0.853 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 106 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/park-bench/park_bench.py --
+
Copy
+
+
+A showcase piece, not an example. Procedural wrought-iron park bench (scrolled legs, slatted seat and back, armrests) 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 | 2000–2200 | 2100 / 2100 / 2100 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4952 | | LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.1400 | | Materials | exactly 2 distinct, ≥48 metal faces | 2 slots, 510 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.080, 0.566, 0.853) m ± 0.01 | (1.0800, 0.5657, 0.8531), zmin 0 | | Collider tris | ≤ 140 | 106 | | Export | written, size > 0 | 159868 / 159868 / 159852 bytes |
+DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a gated axis.
+--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates.
+Run
+blender --background --python park_bench.py --
+blender --background --python park_bench.py -- --skip-decimate
+blender --background --python park_bench.py -- --output bench.png
+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 wood/metal faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file |
+
+
+ Source
+
+ """Game-ready park bench — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural wrought-iron bench (scrolled
+legs, slatted seat and back) after composing shipped pipeline pieces:
+bmesh construction, UVs, two materials, high-to-low normal bake, LOD
+chain, convex collider, Unity glTF export.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
+DECIMATE stage so the LOD-ratio budget fails.
+
+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.
+
+ blender --background --python park_bench.py --
+ blender --background --python park_bench.py -- --skip-decimate
+ blender --background --python park_bench.py -- --output bench.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+_REPO = os.path.abspath(
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0 , os.path.join(_REPO, "examples" ))
+sys.dont_write_bytecode = True
+import gallery_framing # noqa: E402
+
+SEAT_W = 1.18
+SEAT_D = 0.46
+SEAT_Z = 0.44
+N_SEAT = 6
+N_BACK = 4
+SLAT_T = 0.032
+BACK_H = 0.40
+IRON_T = 0.026
+SCROLL_R = 0.10
+SCROLL_N = 12
+ARM_Z = 0.62
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (1.080 , 0.566 , 0.853 )
+BASE_TRIS_MIN = 2000
+BASE_TRIS_MAX = 2200
+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
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 140
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 48
+WOOD_FACES_MIN = 24
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+ return "BLENDER_EEVEE" if bpy.app.version >= (5 , 0 , 0 ) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+ print(f" ERROR: {msg}" , file=sys.stderr)
+ return code
+
+
+def triangle_count(mesh):
+ mesh.calc_loop_triangles()
+ return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+ depsgraph = bpy.context.evaluated_depsgraph_get()
+ eval_obj = obj.evaluated_get(depsgraph)
+ eval_mesh = eval_obj.to_mesh()
+ try :
+ eval_mesh.calc_loop_triangles()
+ return len(eval_mesh.loop_triangles)
+ finally :
+ eval_obj.to_mesh_clear()
+
+
+def deselect_all():
+ for ob in list(bpy.context.view_layer.objects):
+ if ob is None :
+ continue
+ 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)
+ 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_arc_yz(bm, x, cy, cz, radius, a0, a1, n, thick, mat_idx):
+ pts = []
+ for i in range(n + 1 ):
+ t = i / n
+ ang = a0 + (a1 - a0) * t
+ pts.append((x, cy + radius * math.cos(ang), cz + radius * math.sin(ang)))
+ verts = []
+ for p0, p1 in zip(pts, pts[1 :]):
+ verts.extend(add_oriented_box(bm, p0, p1, (thick, thick), mat_idx))
+ return verts
+
+
+def pack_uvs(bm, margin=0.08 ):
+ uv = bm.loops.layers.uv.new("UVMap" )
+ faces = list(bm.faces)
+ n = len(faces)
+ cols = max(1 , math.ceil(math.sqrt(n)))
+ rows = max(1 , math.ceil(n / cols))
+ cell_w = 1.0 / cols
+ cell_h = 1.0 / rows
+ pad_u = margin * cell_w * 0.5
+ pad_v = margin * cell_h * 0.5
+ usable_w = cell_w - 2.0 * pad_u
+ usable_h = cell_h - 2.0 * pad_v
+ for i, face in enumerate(faces):
+ col = i % cols
+ row = i // cols
+ nrm = face.normal
+ ax = abs(nrm.x)
+ ay = abs(nrm.y)
+ az = abs(nrm.z)
+ coords = []
+ for loop in face.loops:
+ co = loop.vert.co
+ if az >= ax and az >= ay:
+ coords.append((co.x, co.y))
+ elif ax >= ay:
+ coords.append((co.y, co.z))
+ else :
+ coords.append((co.x, co.z))
+ xs = [c[0 ] for c in coords]
+ ys = [c[1 ] for c in coords]
+ minx, maxx = min(xs), max(xs)
+ miny, maxy = min(ys), max(ys)
+ dx = max(maxx - minx, 1e-8 )
+ dy = max(maxy - miny, 1e-8 )
+ origin_u = col * cell_w + pad_u
+ origin_v = row * cell_h + pad_v
+ for loop, (x, y) in zip(face.loops, coords):
+ loop[uv].uv = (
+ origin_u + (x - minx) / dx * usable_w,
+ origin_v + (y - miny) / dy * usable_h,
+ )
+
+
+def build_bench_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ try :
+ wood = []
+ hx = SEAT_W / 2.0 - 0.08
+ hy = SEAT_D / 2.0 - 0.05
+ # Span past the post centers (±hx) so slats insert into the iron frame.
+ slat_span = 2.0 * hx + IRON_T
+ usable = SEAT_D - 0.08
+ slat_w = usable / N_SEAT
+ y0 = -hy + slat_w / 2.0 + 0.01
+ for i in range(N_SEAT):
+ y = y0 + i * slat_w
+ wood.extend(
+ add_box(
+ bm,
+ (0.0 , y, SEAT_Z + SLAT_T / 2.0 ),
+ (slat_span, slat_w * 0.78 , SLAT_T),
+ WOOD_IDX,
+ )
+ )
+ for i in range(N_BACK):
+ z = SEAT_Z + SLAT_T + 0.05 + i * (BACK_H / N_BACK)
+ wood.extend(
+ add_box(
+ bm,
+ (0.0 , hy, z),
+ (slat_span, SLAT_T, slat_w * 0.72 ),
+ WOOD_IDX,
+ )
+ )
+
+ if bevel_offset > 0.0 :
+ edges = list({e for v in wood for e in v.link_edges})
+ ret = bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5 ,
+ affect="EDGES" ,
+ clamp_overlap=True ,
+ )
+ for f in ret.get("faces" ) or []:
+ f.material_index = WOOD_IDX
+
+ t = IRON_T
+ add_box(bm, (0.0 , -hy, SEAT_Z - 0.018 ), (SEAT_W - 0.10 , t * 1.15 , t), METAL_IDX)
+ add_box(bm, (0.0 , hy, SEAT_Z - 0.018 ), (SEAT_W - 0.10 , t * 1.15 , t), METAL_IDX)
+ for xsign in (-1.0 , 1.0 ):
+ add_box(
+ bm,
+ (xsign * hx, 0.0 , SEAT_Z - 0.018 ),
+ (t * 1.15 , SEAT_D - 0.08 , t),
+ METAL_IDX,
+ )
+
+ back_top = SEAT_Z + SLAT_T + BACK_H + 0.02
+ for xsign in (-1.0 , 1.0 ):
+ x = xsign * hx
+ add_oriented_box(
+ bm, (x, hy, SEAT_Z), (x, hy, back_top), (t, t), METAL_IDX
+ )
+ add_oriented_box(
+ bm, (x, -hy, SEAT_Z), (x, -hy, ARM_Z), (t, t), METAL_IDX
+ )
+ add_oriented_box(
+ bm, (x, -hy, ARM_Z), (x, hy, ARM_Z), (t * 0.9 , t * 0.9 ), METAL_IDX
+ )
+ add_oriented_box(
+ bm,
+ (x, -hy, SCROLL_R + 0.02 ),
+ (x, -hy, SEAT_Z),
+ (t, t),
+ METAL_IDX,
+ )
+ add_oriented_box(
+ bm,
+ (x, hy, SCROLL_R + 0.02 ),
+ (x, hy, SEAT_Z),
+ (t, t),
+ METAL_IDX,
+ )
+ add_arc_yz(
+ bm,
+ x,
+ -hy + 0.01 ,
+ SCROLL_R,
+ SCROLL_R,
+ math.pi / 2.0 ,
+ math.pi + 0.45 ,
+ SCROLL_N,
+ t,
+ METAL_IDX,
+ )
+ add_arc_yz(
+ bm,
+ x,
+ hy - 0.01 ,
+ SCROLL_R,
+ SCROLL_R,
+ math.pi / 2.0 ,
+ -0.45 ,
+ SCROLL_N,
+ t,
+ METAL_IDX,
+ )
+ add_arc_yz(
+ bm,
+ x,
+ -hy - 0.02 ,
+ ARM_Z - 0.04 ,
+ 0.07 ,
+ math.pi * 0.15 ,
+ math.pi * 0.95 ,
+ 10 ,
+ t * 0.85 ,
+ METAL_IDX,
+ )
+
+ add_box(bm, (0.0 , hy, back_top), (SEAT_W - 0.12 , t, t), METAL_IDX)
+ add_box(bm, (0.0 , 0.0 , 0.18 ), (SEAT_W - 0.22 , t * 0.9 , t * 0.9 ), METAL_IDX)
+ add_box(bm, (0.0 , 0.0 , 0.18 ), (t * 0.9 , SEAT_D - 0.14 , t * 0.9 ), 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:
+ face.smooth = False
+ me = bpy.data.meshes.new(name)
+ bm.to_mesh(me)
+ me.update()
+ for poly in me.polygons:
+ poly.use_smooth = False
+ finally :
+ bm.free()
+ out = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(out)
+ return out
+
+
+def principled(name, color, metallic, roughness):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF" ]
+ bsdf.inputs["Base Color" ].default_value = color
+ bsdf.inputs["Metallic" ].default_value = metallic
+ bsdf.inputs["Roughness" ].default_value = roughness
+ return mat
+
+
+def assign_slots(obj, wood, metal):
+ mats = obj.data.materials
+ wanted = (wood, metal)
+ for i, mat in enumerate(wanted):
+ if i < len(mats):
+ mats[i] = mat
+ else :
+ mats.append(mat)
+
+
+def world_bbox(obj):
+ corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+ xs = [c.x for c in corners]
+ ys = [c.y for c in corners]
+ zs = [c.z for c in corners]
+ return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+ uv = mesh.uv_layers.active
+ if uv is None :
+ return 0.0 , 0.0 , 1.0 , 1.0 , 0 , 1.0
+ data = uv.data
+ us = [loop.uv[0 ] for loop in data]
+ vs = [loop.uv[1 ] for loop in data]
+ aabbs = []
+ for poly in mesh.polygons:
+ pu = [data[i].uv[0 ] for i in poly.loop_indices]
+ pv = [data[i].uv[1 ] for i in poly.loop_indices]
+ aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+ overlap = 0.0
+ for i in range(len(aabbs)):
+ a = aabbs[i]
+ for j in range(i + 1 , len(aabbs)):
+ b = aabbs[j]
+ x0 = max(a[0 ], b[0 ])
+ y0 = max(a[1 ], b[1 ])
+ x1 = min(a[2 ], b[2 ])
+ y1 = min(a[3 ], b[3 ])
+ overlap += max(0.0 , x1 - x0) * max(0.0 , y1 - y0)
+ return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+ mesh = obj.data.copy()
+ lod = bpy.data.objects.new(name, mesh)
+ lod.matrix_world = obj.matrix_world.copy()
+ bpy.context.scene.collection.objects.link(lod)
+ if not skip_decimate and 0.0 < ratio < 1.0 :
+ mod = lod.modifiers.new("DecimateBudget" , "DECIMATE" )
+ mod.decimate_type = "COLLAPSE"
+ mod.ratio = ratio
+ return lod
+
+
+def convex_hull_collider(obj, name):
+ mesh = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try :
+ bm.from_mesh(obj.data)
+ result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+ interior = result.get("geom_interior" ) or []
+ unused = result.get("geom_unused" ) or []
+ if interior:
+ bmesh.ops.delete(bm, geom=interior, context="VERTS" )
+ if unused:
+ bmesh.ops.delete(bm, geom=unused, context="VERTS" )
+ bm.to_mesh(mesh)
+ mesh.update()
+ finally :
+ bm.free()
+ collider = bpy.data.objects.new(name, mesh)
+ bpy.context.collection.objects.link(collider)
+ collider.matrix_world = obj.matrix_world.copy()
+ return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+ if not obj.data.uv_layers:
+ return None , None
+ img = bpy.data.images.new("BenchNrm" , size, size, alpha=True , float_buffer=False )
+ img.colorspace_settings.name = "Non-Color"
+ nodes = target_mat.node_tree.nodes
+ tex = nodes.new("ShaderNodeTexImage" )
+ tex.image = img
+ nodes.active = tex
+ tex.select = True
+ obj.active_material_index = WOOD_IDX
+ return img, tex
+
+
+def bake_normal(high, low):
+ scene = bpy.context.scene
+ scene.render.engine = "CYCLES"
+ scene.cycles.device = "CPU"
+ scene.cycles.samples = 1
+ scene.cycles.use_denoising = False
+ deselect_all()
+ high.select_set(True )
+ low.select_set(True )
+ bpy.context.view_layer.objects.active = low
+ return bpy.ops.object.bake(
+ type="NORMAL" ,
+ use_selected_to_active=True ,
+ cage_extrusion=CAGE_EXTRUSION,
+ use_cage=False ,
+ normal_space="TANGENT" ,
+ margin=4 ,
+ margin_type="ADJACENT_FACES" ,
+ use_clear=True ,
+ target="IMAGE_TEXTURES" ,
+ )
+
+
+def export_unity(path, objects):
+ deselect_all()
+ for ob in objects:
+ ob.select_set(True )
+ bpy.context.view_layer.objects.active = objects[0 ]
+ bpy.ops.export_scene.gltf(
+ filepath=path,
+ use_selection=True ,
+ export_yup=True ,
+ export_apply=True ,
+ export_draco_mesh_compression_enable=False ,
+ export_animations=False ,
+ )
+
+
+def check(skip_decimate):
+ bpy.ops.wm.read_factory_settings(use_empty=True )
+ low = build_bench_mesh("BenchLow" , bevel_offset=0.005 , bevel_segments=2 )
+ high = build_bench_mesh("BenchHigh" , bevel_offset=0.005 , bevel_segments=4 )
+ wood = principled("BenchWood" , (0.40 , 0.20 , 0.07 , 1.0 ), 0.0 , 0.58 )
+ metal = principled("BenchIron" , (0.09 , 0.095 , 0.11 , 1.0 ), 1.0 , 0.28 )
+ assign_slots(low, wood, metal)
+ assign_slots(high, wood, metal)
+
+ if low.data is None or len(low.data.polygons) < 6 :
+ return fail("bench mesh did not build" , 3 ), None , None , None , None , None
+
+ base_tris = triangle_count(low.data)
+ mats = [s for s in low.data.materials if s is not None ]
+ nmat = len(mats)
+ distinct_mats = len({id(s) for s in mats})
+ idx_counts = {}
+ for poly in low.data.polygons:
+ idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0 ) + 1
+ print(f" measured mat_index_counts= {idx_counts}" )
+ u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data)
+ bb = world_bbox(low)
+ size_x = bb[3 ] - bb[0 ]
+ size_y = bb[4 ] - bb[1 ]
+ size_z = bb[5 ] - bb[2 ]
+
+ img, tex = setup_bake_image(low, wood)
+ if img is None :
+ return fail("bench has no UV layer" , 3 ), None , None , None , None , None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "BenchLOD1" , LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "BenchLOD2" , LOD2_TARGET, skip_decimate)
+ bpy.context.view_layer.update()
+ lod1_tris = evaluated_triangle_count(lod1)
+ lod2_tris = evaluated_triangle_count(lod2)
+ r1 = lod1_tris / base_tris if base_tris else 0.0
+ r2 = lod2_tris / base_tris if base_tris else 0.0
+
+ collider_src = build_bench_mesh("BenchColSrc" , bevel_offset=0.0 , bevel_segments=1 )
+ collider = convex_hull_collider(collider_src, "BenchCollider" )
+ bpy.data.objects.remove(collider_src, do_unlink=True )
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f" bdt_park_bench_ {os.getpid()}.glb " ,
+ )
+ if os.path.exists(export_path):
+ os.remove(export_path)
+ 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" measured base_tris= {base_tris} lod1_tris= {lod1_tris} "
+ f" lod2_tris= {lod2_tris} r1= {r1:.4f } r2= {r2:.4f }"
+ )
+ print(
+ f" measured nmat= {nmat} uv=( {u0:.4f }, {v0:.4f })-( {u1:.4f }, {v1:.4f }) "
+ f" overlap= {overlap:.6f } nfaces= {nfaces}"
+ )
+ print(
+ f" measured bbox=( {size_x:.4f }, {size_y:.4f }, {size_z:.4f }) "
+ f" outer= {OUTER_SIZE} zmin= {bb[2 ]:.4f }"
+ )
+ print(
+ f" measured collider_tris= {col_tris} bake= {bake_result} "
+ f" bake_has_data= {img.has_data} export_bytes= {export_size}"
+ )
+
+ if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+ return fail(
+ f" base tris {base_tris} not in [ {BASE_TRIS_MIN}, {BASE_TRIS_MAX}] " ,
+ 4 ,
+ ), None , None , None , None , None
+ if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+ return fail(
+ f" material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}" ,
+ 5 ,
+ ), None , None , None , None , None
+ if idx_counts.get(METAL_IDX, 0 ) < METAL_FACES_MIN:
+ return fail(
+ f" metal faces {idx_counts.get(METAL_IDX, 0 )} < {METAL_FACES_MIN}" ,
+ 5 ,
+ ), None , None , None , None , None
+ if idx_counts.get(WOOD_IDX, 0 ) < WOOD_FACES_MIN:
+ return fail(
+ f" wood faces {idx_counts.get(WOOD_IDX, 0 )} < {WOOD_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 }) " ,
+ 6 ,
+ ), None , None , None , None , None
+ if overlap > UV_OVERLAP_MAX:
+ return fail(
+ f" UV AABB overlap {overlap:.6f } > {UV_OVERLAP_MAX}" ,
+ 7 ,
+ ), None , None , None , None , None
+ if (
+ abs(size_x - OUTER_SIZE[0 ]) > BBOX_TOL
+ or abs(size_y - OUTER_SIZE[1 ]) > BBOX_TOL
+ or abs(size_z - OUTER_SIZE[2 ]) > BBOX_TOL
+ ):
+ return fail(
+ f" bbox ( {size_x:.4f }, {size_y:.4f }, {size_z:.4f }) "
+ f" off outer {OUTER_SIZE}" ,
+ 8 ,
+ ), None , None , None , None , None
+ if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+ return fail(
+ f" LOD1 ratio {r1:.4f } not in [ {LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+ "(--skip-decimate is the designed fail)" ,
+ 9 ,
+ ), None , None , None , None , None
+ if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+ return fail(
+ f" LOD2 ratio {r2:.4f } not in [ {LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}] " ,
+ 9 ,
+ ), None , None , None , None , None
+ if col_tris > COLLIDER_TRIS_MAX:
+ return fail(
+ f" collider tris {col_tris} > {COLLIDER_TRIS_MAX}" ,
+ 11 ,
+ ), None , None , None , None , None
+ if bake_result != {"FINISHED" } or not img.has_data:
+ return fail(
+ f" bake failed result= {bake_result} has_data= {img.has_data}" ,
+ 12 ,
+ ), None , None , None , None , None
+ if export_size <= 0 :
+ return fail("export file missing or empty" , 13 ), None , None , None , None , None
+ return 0 , low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+ nt = mat.node_tree
+ bsdf = nt.nodes["Principled BSDF" ]
+ nrm = nt.nodes.new("ShaderNodeNormalMap" )
+ nrm.inputs["Strength" ].default_value = 1.0
+ nt.links.new(tex.outputs["Color" ], nrm.inputs["Color" ])
+ nt.links.new(nrm.outputs["Normal" ], bsdf.inputs["Normal" ])
+
+
+def render_still(low, wood, tex, path, engine):
+ scene = bpy.context.scene
+ wire_normal(wood, tex)
+ for ob in list(scene.objects):
+ if ob.type == "MESH" and ob != low:
+ ob.hide_render = True
+ ob.hide_viewport = True
+
+ low.rotation_euler.z = math.radians(-14.0 )
+ low.rotation_euler.x = math.radians(2.0 )
+
+ floor_me = bpy.data.meshes.new("Floor" )
+ bm = bmesh.new()
+ try :
+ bmesh.ops.create_grid(bm, x_segments=1 , y_segments=1 , size=14.0 )
+ bm.to_mesh(floor_me)
+ finally :
+ bm.free()
+ fmat = bpy.data.materials.new("Floor" )
+ fmat.use_nodes = True
+ fb = fmat.node_tree.nodes["Principled BSDF" ]
+ fb.inputs["Base Color" ].default_value = (0.03 , 0.032 , 0.037 , 1.0 )
+ fb.inputs["Roughness" ].default_value = 0.7
+ floor_me.materials.append(fmat)
+ floor = bpy.data.objects.new("Floor" , floor_me)
+ scene.collection.objects.link(floor)
+ wall = bpy.data.objects.new("Wall" , floor_me.copy())
+ wall.location = (0.0 , 8.5 , 0.0 )
+ wall.rotation_euler = (math.radians(90 ), 0.0 , 0.0 )
+ scene.collection.objects.link(wall)
+
+ world = bpy.data.worlds.new("World" )
+ world.use_nodes = True
+ world.node_tree.nodes["Background" ].inputs["Color" ].default_value = (
+ 0.02 , 0.021 , 0.025 , 1.0 ,
+ )
+ scene.world = world
+
+ def light(name, loc, energy, size, col, rot):
+ ld = bpy.data.lights.new(name, "AREA" )
+ ld.energy = energy
+ ld.size = size
+ ld.color = col
+ ob = bpy.data.objects.new(name, ld)
+ ob.location = loc
+ ob.rotation_euler = tuple(math.radians(a) for a in rot)
+ scene.collection.objects.link(ob)
+
+ light("Key" , (-3.6 , -5.0 , 5.4 ), 660.0 , 4.0 , (1.0 , 0.94 , 0.86 ), (50 , 0 , -36 ))
+ light("Fill" , (5.0 , -3.4 , 2.4 ), 46.0 , 8.0 , (0.72 , 0.82 , 1.0 ), (62 , 0 , 50 ))
+ light("Wedge" , (2.2 , 4.0 , 3.8 ), 600.0 , 5.5 , (1.0 , 0.70 , 0.40 ), (-70 , 0 , 198 ))
+
+ cam_data = bpy.data.cameras.new("Cam" )
+ cam_data.lens = 50.0
+ cam = bpy.data.objects.new("Cam" , cam_data)
+ cam.location = (1.95 , -2.55 , 1.22 )
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim" , None )
+ aim.location = (0.0 , 0.0 , 0.40 )
+ scene.collection.objects.link(aim)
+ con = cam.constraints.new("TRACK_TO" )
+ con.target = aim
+ con.track_axis = "TRACK_NEGATIVE_Z"
+ con.up_axis = "UP_Y"
+ scene.camera = cam
+
+ scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+ if engine == "cycles" :
+ scene.cycles.samples = 32
+ scene.cycles.device = "CPU"
+ else :
+ try :
+ scene.eevee.taa_render_samples = 64
+ except AttributeError:
+ pass
+ scene.render.resolution_x = 1280
+ scene.render.resolution_y = 720
+ scene.render.image_settings.file_format = (
+ "WEBP" if path.lower().endswith(".webp" ) else "PNG"
+ )
+ if path.lower().endswith(".webp" ):
+ scene.render.image_settings.quality = 90
+ scene.render.filepath = path
+ scene.view_settings.view_transform = "Standard"
+
+ fcode = gallery_framing.check_framing(
+ scene, cam, hero=[low], elements=[low], stage=[floor, wall],
+ )
+ if fcode:
+ return fcode
+ bpy.ops.render.render(write_still=True )
+ if not (os.path.exists(path) and os.path.getsize(path) > 0 ):
+ return fail("render produced no file" , 14 )
+ return 0
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--" ) + 1 :] if "--" in sys.argv else []
+ 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" ,
+ )
+ args = p.parse_args(argv)
+
+ code, low, _high, wood, tex, _col = check(args.skip_decimate)
+ if code:
+ return code
+ if args.output:
+ rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
+ print(f" rendered still {args.output}" )
+ print("park-bench OK" )
+ return 0
+
+
+if __name__ == "__main__" :
+ try :
+ sys.exit(main())
+ except Exception as e:
+ traceback.print_exc()
+ print(f" FATAL: {e}" , file=sys.stderr)
+ sys.exit(1 )
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/gallery/watchtower/index.html b/docs/gallery/watchtower/index.html
index 723449d..f036db6 100644
--- a/docs/gallery/watchtower/index.html
+++ b/docs/gallery/watchtower/index.html
@@ -285,7 +285,7 @@ Source
"""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
+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.
diff --git a/showcase/gallery.json b/showcase/gallery.json
index 89c2705..1397eda 100644
--- a/showcase/gallery.json
+++ b/showcase/gallery.json
@@ -136,6 +136,18 @@
"mesh",
"export"
]
+ },
+ {
+ "name": "park-bench",
+ "dir": "showcase/park-bench",
+ "teaches": "A procedural wrought-iron park bench through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
+ "witnessesFix": "Recomputed: 2100 tris, two materials with 510 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.080×0.566×0.853 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 106 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
+ "hero": "docs/gallery/assets/park-bench-hero.webp",
+ "preview": "showcase/park-bench/preview.webp",
+ "tags": [
+ "mesh",
+ "export"
+ ]
}
]
}
diff --git a/showcase/park-bench/README.md b/showcase/park-bench/README.md
new file mode 100644
index 0000000..8760682
--- /dev/null
+++ b/showcase/park-bench/README.md
@@ -0,0 +1,74 @@
+# Park bench
+
+A showcase piece, not an example. Procedural wrought-iron park bench
+(scrolled legs, slatted seat and back, armrests) 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 | 2000–2200 | 2100 / 2100 / 2100 |
+| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4952 |
+| LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.1400 |
+| Materials | exactly 2 distinct, ≥48 metal faces | 2 slots, 510 metal |
+| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
+| Outer AABB | (1.080, 0.566, 0.853) m ± 0.01 | (1.0800, 0.5657, 0.8531), zmin 0 |
+| Collider tris | ≤ 140 | 106 |
+| Export | written, size > 0 | 159868 / 159868 / 159852 bytes |
+
+DECIMATE COLLAPSE triangle counts are **not** identical across series —
+5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an
+exact count. Bake pixels are stochastic; the gate is `has_data` plus
+operator `FINISHED`, not byte-identity. Construction uses no RNG.
+Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a
+gated axis.
+
+`--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and
+exit 9 fires. That is the named budget the falsifier violates.
+
+## Run
+
+```bash
+blender --background --python park_bench.py --
+blender --background --python park_bench.py -- --skip-decimate
+blender --background --python park_bench.py -- --output bench.png
+```
+
+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 wood/metal faces missing |
+| 6 | UVs outside 0..1 |
+| 7 | UV AABB overlap above tolerance |
+| 8 | World AABB off declared outer size |
+| 9 | LOD ratio band (`--skip-decimate` lands here) |
+| 10 | Framing gate (render path only) |
+| 11 | Collider triangle count above ceiling |
+| 12 | Bake did not finish or image has no data |
+| 13 | Export file missing or empty |
+| 14 | `--output` produced no file |
diff --git a/showcase/park-bench/park_bench.py b/showcase/park-bench/park_bench.py
new file mode 100644
index 0000000..d74d12b
--- /dev/null
+++ b/showcase/park-bench/park_bench.py
@@ -0,0 +1,756 @@
+"""Game-ready park bench — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural wrought-iron bench (scrolled
+legs, slatted seat and back) after composing shipped pipeline pieces:
+bmesh construction, UVs, two materials, high-to-low normal bake, LOD
+chain, convex collider, Unity glTF export.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
+DECIMATE stage so the LOD-ratio budget fails.
+
+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.
+
+ blender --background --python park_bench.py --
+ blender --background --python park_bench.py -- --skip-decimate
+ blender --background --python park_bench.py -- --output bench.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+_REPO = os.path.abspath(
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing # noqa: E402
+
+SEAT_W = 1.18
+SEAT_D = 0.46
+SEAT_Z = 0.44
+N_SEAT = 6
+N_BACK = 4
+SLAT_T = 0.032
+BACK_H = 0.40
+IRON_T = 0.026
+SCROLL_R = 0.10
+SCROLL_N = 12
+ARM_Z = 0.62
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (1.080, 0.566, 0.853)
+BASE_TRIS_MIN = 2000
+BASE_TRIS_MAX = 2200
+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
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 140
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 48
+WOOD_FACES_MIN = 24
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+ return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+ print(f"ERROR: {msg}", file=sys.stderr)
+ return code
+
+
+def triangle_count(mesh):
+ mesh.calc_loop_triangles()
+ return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+ depsgraph = bpy.context.evaluated_depsgraph_get()
+ eval_obj = obj.evaluated_get(depsgraph)
+ eval_mesh = eval_obj.to_mesh()
+ try:
+ eval_mesh.calc_loop_triangles()
+ return len(eval_mesh.loop_triangles)
+ finally:
+ eval_obj.to_mesh_clear()
+
+
+def deselect_all():
+ for ob in list(bpy.context.view_layer.objects):
+ if ob is None:
+ continue
+ 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)
+ 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_arc_yz(bm, x, cy, cz, radius, a0, a1, n, thick, mat_idx):
+ pts = []
+ for i in range(n + 1):
+ t = i / n
+ ang = a0 + (a1 - a0) * t
+ pts.append((x, cy + radius * math.cos(ang), cz + radius * math.sin(ang)))
+ verts = []
+ for p0, p1 in zip(pts, pts[1:]):
+ verts.extend(add_oriented_box(bm, p0, p1, (thick, thick), mat_idx))
+ return verts
+
+
+def pack_uvs(bm, margin=0.08):
+ uv = bm.loops.layers.uv.new("UVMap")
+ faces = list(bm.faces)
+ n = len(faces)
+ cols = max(1, math.ceil(math.sqrt(n)))
+ rows = max(1, math.ceil(n / cols))
+ cell_w = 1.0 / cols
+ cell_h = 1.0 / rows
+ pad_u = margin * cell_w * 0.5
+ pad_v = margin * cell_h * 0.5
+ usable_w = cell_w - 2.0 * pad_u
+ usable_h = cell_h - 2.0 * pad_v
+ for i, face in enumerate(faces):
+ col = i % cols
+ row = i // cols
+ nrm = face.normal
+ ax = abs(nrm.x)
+ ay = abs(nrm.y)
+ az = abs(nrm.z)
+ coords = []
+ for loop in face.loops:
+ co = loop.vert.co
+ if az >= ax and az >= ay:
+ coords.append((co.x, co.y))
+ elif ax >= ay:
+ coords.append((co.y, co.z))
+ else:
+ coords.append((co.x, co.z))
+ xs = [c[0] for c in coords]
+ ys = [c[1] for c in coords]
+ minx, maxx = min(xs), max(xs)
+ miny, maxy = min(ys), max(ys)
+ dx = max(maxx - minx, 1e-8)
+ dy = max(maxy - miny, 1e-8)
+ origin_u = col * cell_w + pad_u
+ origin_v = row * cell_h + pad_v
+ for loop, (x, y) in zip(face.loops, coords):
+ loop[uv].uv = (
+ origin_u + (x - minx) / dx * usable_w,
+ origin_v + (y - miny) / dy * usable_h,
+ )
+
+
+def build_bench_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ try:
+ wood = []
+ hx = SEAT_W / 2.0 - 0.08
+ hy = SEAT_D / 2.0 - 0.05
+ # Span past the post centers (±hx) so slats insert into the iron frame.
+ slat_span = 2.0 * hx + IRON_T
+ usable = SEAT_D - 0.08
+ slat_w = usable / N_SEAT
+ y0 = -hy + slat_w / 2.0 + 0.01
+ for i in range(N_SEAT):
+ y = y0 + i * slat_w
+ wood.extend(
+ add_box(
+ bm,
+ (0.0, y, SEAT_Z + SLAT_T / 2.0),
+ (slat_span, slat_w * 0.78, SLAT_T),
+ WOOD_IDX,
+ )
+ )
+ for i in range(N_BACK):
+ z = SEAT_Z + SLAT_T + 0.05 + i * (BACK_H / N_BACK)
+ wood.extend(
+ add_box(
+ bm,
+ (0.0, hy, z),
+ (slat_span, SLAT_T, slat_w * 0.72),
+ WOOD_IDX,
+ )
+ )
+
+ if bevel_offset > 0.0:
+ edges = list({e for v in wood for e in v.link_edges})
+ ret = bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5,
+ affect="EDGES",
+ clamp_overlap=True,
+ )
+ for f in ret.get("faces") or []:
+ f.material_index = WOOD_IDX
+
+ t = IRON_T
+ add_box(bm, (0.0, -hy, SEAT_Z - 0.018), (SEAT_W - 0.10, t * 1.15, t), METAL_IDX)
+ add_box(bm, (0.0, hy, SEAT_Z - 0.018), (SEAT_W - 0.10, t * 1.15, t), METAL_IDX)
+ for xsign in (-1.0, 1.0):
+ add_box(
+ bm,
+ (xsign * hx, 0.0, SEAT_Z - 0.018),
+ (t * 1.15, SEAT_D - 0.08, t),
+ METAL_IDX,
+ )
+
+ back_top = SEAT_Z + SLAT_T + BACK_H + 0.02
+ for xsign in (-1.0, 1.0):
+ x = xsign * hx
+ add_oriented_box(
+ bm, (x, hy, SEAT_Z), (x, hy, back_top), (t, t), METAL_IDX
+ )
+ add_oriented_box(
+ bm, (x, -hy, SEAT_Z), (x, -hy, ARM_Z), (t, t), METAL_IDX
+ )
+ add_oriented_box(
+ bm, (x, -hy, ARM_Z), (x, hy, ARM_Z), (t * 0.9, t * 0.9), METAL_IDX
+ )
+ add_oriented_box(
+ bm,
+ (x, -hy, SCROLL_R + 0.02),
+ (x, -hy, SEAT_Z),
+ (t, t),
+ METAL_IDX,
+ )
+ add_oriented_box(
+ bm,
+ (x, hy, SCROLL_R + 0.02),
+ (x, hy, SEAT_Z),
+ (t, t),
+ METAL_IDX,
+ )
+ add_arc_yz(
+ bm,
+ x,
+ -hy + 0.01,
+ SCROLL_R,
+ SCROLL_R,
+ math.pi / 2.0,
+ math.pi + 0.45,
+ SCROLL_N,
+ t,
+ METAL_IDX,
+ )
+ add_arc_yz(
+ bm,
+ x,
+ hy - 0.01,
+ SCROLL_R,
+ SCROLL_R,
+ math.pi / 2.0,
+ -0.45,
+ SCROLL_N,
+ t,
+ METAL_IDX,
+ )
+ add_arc_yz(
+ bm,
+ x,
+ -hy - 0.02,
+ ARM_Z - 0.04,
+ 0.07,
+ math.pi * 0.15,
+ math.pi * 0.95,
+ 10,
+ t * 0.85,
+ METAL_IDX,
+ )
+
+ add_box(bm, (0.0, hy, back_top), (SEAT_W - 0.12, t, t), METAL_IDX)
+ add_box(bm, (0.0, 0.0, 0.18), (SEAT_W - 0.22, t * 0.9, t * 0.9), METAL_IDX)
+ add_box(bm, (0.0, 0.0, 0.18), (t * 0.9, SEAT_D - 0.14, t * 0.9), 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:
+ face.smooth = False
+ me = bpy.data.meshes.new(name)
+ bm.to_mesh(me)
+ me.update()
+ for poly in me.polygons:
+ poly.use_smooth = False
+ finally:
+ bm.free()
+ out = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(out)
+ return out
+
+
+def principled(name, color, metallic, roughness):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF"]
+ bsdf.inputs["Base Color"].default_value = color
+ bsdf.inputs["Metallic"].default_value = metallic
+ bsdf.inputs["Roughness"].default_value = roughness
+ return mat
+
+
+def assign_slots(obj, wood, metal):
+ mats = obj.data.materials
+ wanted = (wood, metal)
+ for i, mat in enumerate(wanted):
+ if i < len(mats):
+ mats[i] = mat
+ else:
+ mats.append(mat)
+
+
+def world_bbox(obj):
+ corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+ xs = [c.x for c in corners]
+ ys = [c.y for c in corners]
+ zs = [c.z for c in corners]
+ return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+ uv = mesh.uv_layers.active
+ if uv is None:
+ return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+ data = uv.data
+ us = [loop.uv[0] for loop in data]
+ vs = [loop.uv[1] for loop in data]
+ aabbs = []
+ for poly in mesh.polygons:
+ pu = [data[i].uv[0] for i in poly.loop_indices]
+ pv = [data[i].uv[1] for i in poly.loop_indices]
+ aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+ overlap = 0.0
+ for i in range(len(aabbs)):
+ a = aabbs[i]
+ for j in range(i + 1, len(aabbs)):
+ b = aabbs[j]
+ x0 = max(a[0], b[0])
+ y0 = max(a[1], b[1])
+ x1 = min(a[2], b[2])
+ y1 = min(a[3], b[3])
+ overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+ return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+ mesh = obj.data.copy()
+ lod = bpy.data.objects.new(name, mesh)
+ lod.matrix_world = obj.matrix_world.copy()
+ bpy.context.scene.collection.objects.link(lod)
+ if not skip_decimate and 0.0 < ratio < 1.0:
+ mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+ mod.decimate_type = "COLLAPSE"
+ mod.ratio = ratio
+ return lod
+
+
+def convex_hull_collider(obj, name):
+ mesh = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(obj.data)
+ result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+ interior = result.get("geom_interior") or []
+ unused = result.get("geom_unused") or []
+ if interior:
+ bmesh.ops.delete(bm, geom=interior, context="VERTS")
+ if unused:
+ bmesh.ops.delete(bm, geom=unused, context="VERTS")
+ bm.to_mesh(mesh)
+ mesh.update()
+ finally:
+ bm.free()
+ collider = bpy.data.objects.new(name, mesh)
+ bpy.context.collection.objects.link(collider)
+ collider.matrix_world = obj.matrix_world.copy()
+ return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+ if not obj.data.uv_layers:
+ return None, None
+ img = bpy.data.images.new("BenchNrm", size, size, alpha=True, float_buffer=False)
+ img.colorspace_settings.name = "Non-Color"
+ nodes = target_mat.node_tree.nodes
+ tex = nodes.new("ShaderNodeTexImage")
+ tex.image = img
+ nodes.active = tex
+ tex.select = True
+ obj.active_material_index = WOOD_IDX
+ return img, tex
+
+
+def bake_normal(high, low):
+ scene = bpy.context.scene
+ scene.render.engine = "CYCLES"
+ scene.cycles.device = "CPU"
+ scene.cycles.samples = 1
+ scene.cycles.use_denoising = False
+ deselect_all()
+ high.select_set(True)
+ low.select_set(True)
+ bpy.context.view_layer.objects.active = low
+ return bpy.ops.object.bake(
+ type="NORMAL",
+ use_selected_to_active=True,
+ cage_extrusion=CAGE_EXTRUSION,
+ use_cage=False,
+ normal_space="TANGENT",
+ margin=4,
+ margin_type="ADJACENT_FACES",
+ use_clear=True,
+ target="IMAGE_TEXTURES",
+ )
+
+
+def export_unity(path, objects):
+ deselect_all()
+ for ob in objects:
+ ob.select_set(True)
+ bpy.context.view_layer.objects.active = objects[0]
+ bpy.ops.export_scene.gltf(
+ filepath=path,
+ use_selection=True,
+ export_yup=True,
+ export_apply=True,
+ export_draco_mesh_compression_enable=False,
+ export_animations=False,
+ )
+
+
+def check(skip_decimate):
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ low = build_bench_mesh("BenchLow", bevel_offset=0.005, bevel_segments=2)
+ high = build_bench_mesh("BenchHigh", bevel_offset=0.005, bevel_segments=4)
+ wood = principled("BenchWood", (0.40, 0.20, 0.07, 1.0), 0.0, 0.58)
+ metal = principled("BenchIron", (0.09, 0.095, 0.11, 1.0), 1.0, 0.28)
+ assign_slots(low, wood, metal)
+ assign_slots(high, wood, metal)
+
+ if low.data is None or len(low.data.polygons) < 6:
+ return fail("bench mesh did not build", 3), None, None, None, None, None
+
+ base_tris = triangle_count(low.data)
+ mats = [s for s in low.data.materials if s is not None]
+ nmat = len(mats)
+ distinct_mats = len({id(s) for s in mats})
+ idx_counts = {}
+ for poly in low.data.polygons:
+ idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1
+ print(f"measured mat_index_counts={idx_counts}")
+ u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data)
+ bb = world_bbox(low)
+ size_x = bb[3] - bb[0]
+ size_y = bb[4] - bb[1]
+ size_z = bb[5] - bb[2]
+
+ img, tex = setup_bake_image(low, wood)
+ if img is None:
+ return fail("bench has no UV layer", 3), None, None, None, None, None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "BenchLOD1", LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "BenchLOD2", LOD2_TARGET, skip_decimate)
+ bpy.context.view_layer.update()
+ lod1_tris = evaluated_triangle_count(lod1)
+ lod2_tris = evaluated_triangle_count(lod2)
+ r1 = lod1_tris / base_tris if base_tris else 0.0
+ r2 = lod2_tris / base_tris if base_tris else 0.0
+
+ collider_src = build_bench_mesh("BenchColSrc", bevel_offset=0.0, bevel_segments=1)
+ collider = convex_hull_collider(collider_src, "BenchCollider")
+ bpy.data.objects.remove(collider_src, do_unlink=True)
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f"bdt_park_bench_{os.getpid()}.glb",
+ )
+ if os.path.exists(export_path):
+ os.remove(export_path)
+ 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"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+ f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+ )
+ print(
+ f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+ f"overlap={overlap:.6f} nfaces={nfaces}"
+ )
+ print(
+ f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+ f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
+ )
+ print(
+ f"measured collider_tris={col_tris} bake={bake_result} "
+ f"bake_has_data={img.has_data} export_bytes={export_size}"
+ )
+
+ if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+ return fail(
+ f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
+ 4,
+ ), None, None, None, None, None
+ if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+ return fail(
+ f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
+ 5,
+ ), None, None, None, None, None
+ if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+ return fail(
+ f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
+ 5,
+ ), None, None, None, None, None
+ if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN:
+ return fail(
+ f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_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})",
+ 6,
+ ), None, None, None, None, None
+ if overlap > UV_OVERLAP_MAX:
+ return fail(
+ f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+ 7,
+ ), None, None, None, None, None
+ if (
+ abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+ or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+ or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+ ):
+ return fail(
+ f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+ f"off outer {OUTER_SIZE}",
+ 8,
+ ), None, None, None, None, None
+ if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+ return fail(
+ f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+ "(--skip-decimate is the designed fail)",
+ 9,
+ ), None, None, None, None, None
+ if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+ return fail(
+ f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+ 9,
+ ), None, None, None, None, None
+ if col_tris > COLLIDER_TRIS_MAX:
+ return fail(
+ f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+ 11,
+ ), None, None, None, None, None
+ if bake_result != {"FINISHED"} or not img.has_data:
+ return fail(
+ f"bake failed result={bake_result} has_data={img.has_data}",
+ 12,
+ ), None, None, None, None, None
+ if export_size <= 0:
+ return fail("export file missing or empty", 13), None, None, None, None, None
+ return 0, low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+ nt = mat.node_tree
+ bsdf = nt.nodes["Principled BSDF"]
+ nrm = nt.nodes.new("ShaderNodeNormalMap")
+ nrm.inputs["Strength"].default_value = 1.0
+ nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+ nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, wood, tex, path, engine):
+ scene = bpy.context.scene
+ wire_normal(wood, tex)
+ for ob in list(scene.objects):
+ if ob.type == "MESH" and ob != low:
+ ob.hide_render = True
+ ob.hide_viewport = True
+
+ low.rotation_euler.z = math.radians(-14.0)
+ low.rotation_euler.x = math.radians(2.0)
+
+ floor_me = bpy.data.meshes.new("Floor")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
+ bm.to_mesh(floor_me)
+ finally:
+ bm.free()
+ fmat = bpy.data.materials.new("Floor")
+ fmat.use_nodes = True
+ fb = fmat.node_tree.nodes["Principled BSDF"]
+ fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+ fb.inputs["Roughness"].default_value = 0.7
+ floor_me.materials.append(fmat)
+ floor = bpy.data.objects.new("Floor", floor_me)
+ scene.collection.objects.link(floor)
+ wall = bpy.data.objects.new("Wall", floor_me.copy())
+ wall.location = (0.0, 8.5, 0.0)
+ wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+ scene.collection.objects.link(wall)
+
+ world = bpy.data.worlds.new("World")
+ world.use_nodes = True
+ world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+ 0.02, 0.021, 0.025, 1.0,
+ )
+ scene.world = world
+
+ def light(name, loc, energy, size, col, rot):
+ ld = bpy.data.lights.new(name, "AREA")
+ ld.energy = energy
+ ld.size = size
+ ld.color = col
+ ob = bpy.data.objects.new(name, ld)
+ ob.location = loc
+ ob.rotation_euler = tuple(math.radians(a) for a in rot)
+ scene.collection.objects.link(ob)
+
+ light("Key", (-3.6, -5.0, 5.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+ light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+ light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
+
+ cam_data = bpy.data.cameras.new("Cam")
+ cam_data.lens = 50.0
+ cam = bpy.data.objects.new("Cam", cam_data)
+ cam.location = (1.95, -2.55, 1.22)
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim", None)
+ aim.location = (0.0, 0.0, 0.40)
+ scene.collection.objects.link(aim)
+ con = cam.constraints.new("TRACK_TO")
+ con.target = aim
+ con.track_axis = "TRACK_NEGATIVE_Z"
+ con.up_axis = "UP_Y"
+ scene.camera = cam
+
+ scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+ if engine == "cycles":
+ scene.cycles.samples = 32
+ scene.cycles.device = "CPU"
+ else:
+ try:
+ scene.eevee.taa_render_samples = 64
+ except AttributeError:
+ pass
+ scene.render.resolution_x = 1280
+ scene.render.resolution_y = 720
+ scene.render.image_settings.file_format = (
+ "WEBP" if path.lower().endswith(".webp") else "PNG"
+ )
+ if path.lower().endswith(".webp"):
+ scene.render.image_settings.quality = 90
+ scene.render.filepath = path
+ scene.view_settings.view_transform = "Standard"
+
+ fcode = gallery_framing.check_framing(
+ scene, cam, hero=[low], elements=[low], stage=[floor, wall],
+ )
+ if fcode:
+ return fcode
+ bpy.ops.render.render(write_still=True)
+ if not (os.path.exists(path) and os.path.getsize(path) > 0):
+ return fail("render produced no file", 14)
+ return 0
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ 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",
+ )
+ args = p.parse_args(argv)
+
+ code, low, _high, wood, tex, _col = check(args.skip_decimate)
+ if code:
+ return code
+ if args.output:
+ rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
+ print(f"rendered still {args.output}")
+ print("park-bench OK")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(main())
+ except Exception as e:
+ traceback.print_exc()
+ print(f"FATAL: {e}", file=sys.stderr)
+ sys.exit(1)
diff --git a/showcase/park-bench/preview.webp b/showcase/park-bench/preview.webp
new file mode 100644
index 0000000000000000000000000000000000000000..e7523f042a95c85c2bb1b4f2e0a3786f4ade9138
GIT binary patch
literal 21340
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