From 5bdea3c89d294a1f14aff04206f730b42499edd5 Mon Sep 17 00:00:00 2001
From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Date: Tue, 15 Sep 2026 18:49:37 -0400
Subject: [PATCH 1/2] feat: add wooden-ladder showcase piece
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 | 9 +-
ROADMAP.md | 2 +-
docs/gallery/assets/wooden-ladder-hero.webp | Bin 0 -> 9624 bytes
docs/gallery/index.html | 13 +-
docs/gallery/wooden-ladder/index.html | 1039 +++++++++++++++++++
showcase/gallery.json | 12 +
showcase/wooden-ladder/README.md | 74 ++
showcase/wooden-ladder/preview.webp | Bin 0 -> 8916 bytes
showcase/wooden-ladder/wooden_ladder.py | 715 +++++++++++++
tests/smoke/catalog.json | 3 +-
13 files changed, 1865 insertions(+), 9 deletions(-)
create mode 100644 docs/gallery/assets/wooden-ladder-hero.webp
create mode 100644 docs/gallery/wooden-ladder/index.html
create mode 100644 showcase/wooden-ladder/README.md
create mode 100644 showcase/wooden-ladder/preview.webp
create mode 100644 showcase/wooden-ladder/wooden_ladder.py
diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index ba54d1b..65bab4b 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -161,6 +161,7 @@
"showcase/wooden-bucket",
"showcase/wall-torch",
"showcase/tavern-stool",
- "showcase/iron-cauldron"
+ "showcase/iron-cauldron",
+ "showcase/wooden-ladder"
]
}
diff --git a/AGENTS.md b/AGENTS.md
index 43fc201..a9b249e 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 24 showcase pieces (counts are CI-enforced against README.md)
+examples, and 25 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 465185e..13c4e32 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, 24 pieces (sibling of examples/; see showcase/README.md)
+showcase// - Budget-conformance props, 25 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 ec3d200..4d823c9 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 24 showcase pieces
+ 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 25 showcase pieces
@@ -37,7 +37,7 @@
## Overview
-This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 24 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 25 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.
@@ -76,7 +76,7 @@ blender --background --python examples/bmesh-gear/bmesh_gear.py --
Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](showcase/README.md).
-24 showcase pieces — click to expand the preview grid
+25 showcase pieces — click to expand the preview grid
@@ -103,6 +103,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.
@@ -153,6 +154,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show
[`iron-cauldron`](showcase/iron-cauldron/) — procedural hanging iron cauldron on a timber tripod through the same pipeline. Falsifier `--skip-decimate` exits 9.
+[`wooden-ladder`](showcase/wooden-ladder/) — procedural timber ladder with raked stiles, rungs, and iron shoes through the same pipeline. Falsifier `--skip-decimate` exits 9.
+
## Examples
diff --git a/ROADMAP.md b/ROADMAP.md
index 72d3654..a8810b2 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -129,7 +129,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~Wall-mounted torch sconce as a game-prop showcase piece~~ **SHIPPED** as `showcase/wall-torch/` — coursed stone plaque, iron bracket and cup, wooden haft, emissive flame; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Wooden tavern stool (turned legs, round seat, stretchers) as a game-prop showcase piece~~ **SHIPPED** as `showcase/tavern-stool/` — round seat, turned splayed legs, stretchers, iron ferrules; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Cast-iron cooking cauldron on a tripod as a game-prop showcase piece~~ **SHIPPED** as `showcase/iron-cauldron/` — bulged open pot, bail, timber tripod, hook; `--skip-decimate` exits 9 on the LOD1 ratio band
-- Wooden ladder (rungs, stiles, iron shoes) as a game-prop showcase piece
+- ~~Wooden ladder (rungs, stiles, iron shoes) as a game-prop showcase piece~~ **SHIPPED** as `showcase/wooden-ladder/` — raked stiles, six rungs, iron shoes and straps; `--skip-decimate` exits 9 on the LOD1 ratio band
- Bound hay bale 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/wooden-ladder-hero.webp b/docs/gallery/assets/wooden-ladder-hero.webp
new file mode 100644
index 0000000000000000000000000000000000000000..26b04f459e1370099c91e74b38a599047f9ecd18
GIT binary patch
literal 9624
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 Gallery
autocomplete="off" spellcheck="false" aria-label="Search examples" />
×
- 75 examples
+ 76 examples
+
+
+
+
+
+
+
A procedural timber ladder with raked stiles, six rungs, and iron shoes through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
+
witnesses Recomputed: 2184 tris, two materials with 1008 wood and 48 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.474×0.361×1.482 m, LOD ratios in band, convex collider 52 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/wooden-ladder/index.html b/docs/gallery/wooden-ladder/index.html
new file mode 100644
index 0000000..1f5d4a3
--- /dev/null
+++ b/docs/gallery/wooden-ladder/index.html
@@ -0,0 +1,1039 @@
+
+
+
+
+
+ wooden-ladder — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ wooden-ladder
+ A procedural timber ladder with raked stiles, six rungs, and iron shoes 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: 2184 tris, two materials with 1008 wood and 48 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.474×0.361×1.482 m, LOD ratios in band, convex collider 52 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/wooden-ladder/wooden_ladder.py --
+
Copy
+
+
+A showcase piece, not an example. Procedural timber ladder (raked stiles, six rungs, iron shoes and straps) 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 | 2100–2280 | 2184 / 2184 / 2184 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 | | Materials | exactly 2 distinct, ≥24 wood, ≥24 metal | 2 slots, 1008 wood, 48 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.474, 0.361, 1.482) m ± 0.01 | (0.4740, 0.3612, 1.4823), zmin 0 | | Collider tris | ≤ 120 | 52 | | Export | written, size > 0 | 158256 / 158256 / 158240 bytes |
+DECIMATE COLLAPSE triangle counts are not identical across series — the gate is a ratio band, not an exact count. On this mesh the three binaries agreed on LOD ratios. 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 wooden_ladder.py --
+blender --background --python wooden_ladder.py -- --skip-decimate
+blender --background --python wooden_ladder.py -- --output ladder.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 wooden ladder — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural timber ladder 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 wooden_ladder.py --
+ blender --background --python wooden_ladder.py -- --skip-decimate
+ blender --background --python wooden_ladder.py -- --output ladder.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+# Showcase lives at repo-root/showcase/, not under examples/. The framing
+# helper is the repo's only shared import and lives next to the examples;
+# resolve the repo root so we do not move gallery_framing.py.
+_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
+
+STILE_H = 1.48
+STILE_W = 0.058
+STILE_D = 0.034
+SPAN = 0.34
+N_RUNGS = 6
+RUNG_R = 0.017
+RUNG_SEGS = 10
+RAKE = math.radians(12.0 )
+BBOX_TOL = 0.01
+# Fitted after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (0.474 , 0.361 , 1.482 )
+
+BASE_TRIS_MIN = 2100
+BASE_TRIS_MAX = 2280
+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 = 120
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.06
+METAL_FACES_MIN = 24
+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):
+ # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
+ 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 add_box(bm, loc, scale, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ geo = bmesh.ops.create_cube(bm, size=1.0 )
+ verts = geo["verts" ]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ p = Vector((v.co.x * scale[0 ], v.co.y * scale[1 ], v.co.z * scale[2 ]))
+ v.co = rot @ p + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ geo = bmesh.ops.create_cone(
+ bm,
+ cap_ends=True ,
+ cap_tris=False ,
+ segments=segments,
+ radius1=radius1,
+ radius2=radius2,
+ depth=depth,
+ )
+ verts = list(geo["verts" ])
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ v.co = rot @ v.co + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler)
+
+
+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_ladder_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ wood_verts = []
+ metal_faces = set()
+ try :
+ half = SPAN / 2.0 + STILE_W / 2.0
+ for sign in (-1.0 , 1.0 ):
+ wood_verts.extend(
+ add_box(
+ bm,
+ (sign * half, 0.0 , STILE_H / 2.0 ),
+ (STILE_W, STILE_D, STILE_H),
+ WOOD_IDX,
+ )
+ )
+ wood_verts.extend(
+ add_box(
+ bm,
+ (sign * half, 0.0 , STILE_H + 0.012 ),
+ (STILE_W + 0.008 , STILE_D + 0.008 , 0.028 ),
+ WOOD_IDX,
+ )
+ )
+
+ z0 = 0.16
+ z1 = STILE_H - 0.14
+ for i in range(N_RUNGS):
+ t = i / (N_RUNGS - 1 )
+ z = z0 + t * (z1 - z0)
+ wood_verts.extend(
+ add_cylinder(
+ bm,
+ (0.0 , 0.0 , z),
+ RUNG_R,
+ SPAN + STILE_W * 0.55 ,
+ RUNG_SEGS,
+ WOOD_IDX,
+ euler=(0.0 , math.pi / 2.0 , 0.0 ),
+ )
+ )
+
+ if bevel_offset > 0.0 :
+ edges = list({e for v in wood_verts for e in v.link_edges if v.is_valid})
+ if edges:
+ bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5 ,
+ affect="EDGES" ,
+ clamp_overlap=True ,
+ )
+
+ before = set(bm.faces)
+ for sign in (-1.0 , 1.0 ):
+ hx = sign * half
+ add_box(bm, (hx, 0.0 , 0.028 ), (STILE_W + 0.018 , STILE_D + 0.018 , 0.056 ), METAL_IDX)
+ add_box(
+ bm,
+ (hx, STILE_D / 2.0 + 0.010 , 0.070 ),
+ (STILE_W * 0.70 , 0.014 , 0.090 ),
+ METAL_IDX,
+ )
+ add_box(
+ bm,
+ (hx, -(STILE_D / 2.0 + 0.010 ), 0.070 ),
+ (STILE_W * 0.70 , 0.014 , 0.090 ),
+ METAL_IDX,
+ )
+ add_box(
+ bm,
+ (hx, 0.0 , STILE_H - 0.055 ),
+ (STILE_W + 0.016 , STILE_D + 0.016 , 0.028 ),
+ METAL_IDX,
+ )
+ metal_faces.update(set(bm.faces) - before)
+
+ rake = Euler((RAKE, 0.0 , 0.0 )).to_matrix()
+ for v in bm.verts:
+ v.co = rake @ v.co
+ zs = [v.co.z for v in bm.verts]
+ zmin = min(zs)
+ xs = [v.co.x for v in bm.verts]
+ ys = [v.co.y for v in bm.verts]
+ cx = 0.5 * (min(xs) + max(xs))
+ cy = 0.5 * (min(ys) + max(ys))
+ 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 = True
+ for edge in bm.edges:
+ edge.smooth = True
+ if edge.is_manifold and len(edge.link_faces) == 2 :
+ if edge.calc_face_angle() > math.radians(40.0 ):
+ edge.smooth = False
+ for f in metal_faces:
+ if f.is_valid:
+ f.material_index = METAL_IDX
+ me = bpy.data.meshes.new(name)
+ bm.to_mesh(me)
+ me.update()
+ finally :
+ bm.free()
+ obj = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(obj)
+ return obj
+
+
+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):
+ # Duplicated from snippets/convex_hull_collider.py (not a package).
+ 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):
+ # Adapted from snippets/setup_bake_target_image.py — do not replace slots.
+ if not obj.data.uv_layers:
+ return None , None
+ img = bpy.data.images.new("LadderNrm" , 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):
+ # Duplicated from snippets/bake_normal_high_to_low.py (not a package).
+ scene = bpy.context.scene
+ scene.render.engine = "CYCLES"
+ scene.cycles.device = "CPU"
+ scene.cycles.samples = 1
+ scene.cycles.use_denoising = False
+ for ob in bpy.context.view_layer.objects:
+ ob.select_set(False )
+ 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):
+ # Duplicated from snippets/export_preset_unity.py (not a package).
+ for ob in bpy.context.view_layer.objects:
+ ob.select_set(False )
+ 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_ladder_mesh("LadderLow" , bevel_offset=0.0025 , bevel_segments=2 )
+ high = build_ladder_mesh("LadderHigh" , bevel_offset=0.0025 , bevel_segments=4 )
+ wood = principled("LadderWood" , (0.42 , 0.24 , 0.10 , 1.0 ), 0.0 , 0.55 )
+ metal = principled("LadderMetal" , (0.48 , 0.46 , 0.42 , 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("ladder 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("ladder has no UV layer" , 3 ), None , None , None , None , None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "LadderLOD1" , LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "LadderLOD2" , 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_ladder_mesh("LadderColSrc" , bevel_offset=0.0 , bevel_segments=1 )
+ collider = convex_hull_collider(collider_src, "LadderCollider" )
+ bpy.data.objects.remove(collider_src, do_unlink=True )
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f" bdt_wooden_ladder_ {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(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 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 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(-28.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 = (2.55 , -3.55 , 1.38 )
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim" , None )
+ aim.location = (0.0 , 0.0 , 0.74 )
+ 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("wooden-ladder 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/gallery.json b/showcase/gallery.json
index ad82420..3457b57 100644
--- a/showcase/gallery.json
+++ b/showcase/gallery.json
@@ -292,6 +292,18 @@
"mesh",
"export"
]
+ },
+ {
+ "name": "wooden-ladder",
+ "dir": "showcase/wooden-ladder",
+ "teaches": "A procedural timber ladder with raked stiles, six rungs, and iron shoes through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
+ "witnessesFix": "Recomputed: 2184 tris, two materials with 1008 wood and 48 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.474×0.361×1.482 m, LOD ratios in band, convex collider 52 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
+ "hero": "docs/gallery/assets/wooden-ladder-hero.webp",
+ "preview": "showcase/wooden-ladder/preview.webp",
+ "tags": [
+ "mesh",
+ "export"
+ ]
}
]
}
diff --git a/showcase/wooden-ladder/README.md b/showcase/wooden-ladder/README.md
new file mode 100644
index 0000000..3eaa4f0
--- /dev/null
+++ b/showcase/wooden-ladder/README.md
@@ -0,0 +1,74 @@
+# Wooden ladder
+
+A showcase piece, not an example. Procedural timber ladder (raked
+stiles, six rungs, iron shoes and straps) 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 | 2100–2280 | 2184 / 2184 / 2184 |
+| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 |
+| LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 |
+| Materials | exactly 2 distinct, ≥24 wood, ≥24 metal | 2 slots, 1008 wood, 48 metal |
+| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
+| Outer AABB | (0.474, 0.361, 1.482) m ± 0.01 | (0.4740, 0.3612, 1.4823), zmin 0 |
+| Collider tris | ≤ 120 | 52 |
+| Export | written, size > 0 | 158256 / 158256 / 158240 bytes |
+
+DECIMATE COLLAPSE triangle counts are **not** identical across series —
+the gate is a ratio band, not an exact count. On this mesh the three
+binaries agreed on LOD ratios. 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 wooden_ladder.py --
+blender --background --python wooden_ladder.py -- --skip-decimate
+blender --background --python wooden_ladder.py -- --output ladder.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/wooden-ladder/preview.webp b/showcase/wooden-ladder/preview.webp
new file mode 100644
index 0000000000000000000000000000000000000000..cab7c7994b54e9f53e256d9fdc041a9b226a4f61
GIT binary patch
literal 8916
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
literal 0
HcmV?d00001
diff --git a/showcase/wooden-ladder/wooden_ladder.py b/showcase/wooden-ladder/wooden_ladder.py
new file mode 100644
index 0000000..1a6ff2b
--- /dev/null
+++ b/showcase/wooden-ladder/wooden_ladder.py
@@ -0,0 +1,715 @@
+"""Game-ready wooden ladder — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural timber ladder 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 wooden_ladder.py --
+ blender --background --python wooden_ladder.py -- --skip-decimate
+ blender --background --python wooden_ladder.py -- --output ladder.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+# Showcase lives at repo-root/showcase/, not under examples/. The framing
+# helper is the repo's only shared import and lives next to the examples;
+# resolve the repo root so we do not move gallery_framing.py.
+_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
+
+STILE_H = 1.48
+STILE_W = 0.058
+STILE_D = 0.034
+SPAN = 0.34
+N_RUNGS = 6
+RUNG_R = 0.017
+RUNG_SEGS = 10
+RAKE = math.radians(12.0)
+BBOX_TOL = 0.01
+# Fitted after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (0.474, 0.361, 1.482)
+
+BASE_TRIS_MIN = 2100
+BASE_TRIS_MAX = 2280
+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 = 120
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.06
+METAL_FACES_MIN = 24
+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):
+ # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
+ 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 add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+ geo = bmesh.ops.create_cube(bm, size=1.0)
+ verts = geo["verts"]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+ v.co = rot @ p + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+ geo = bmesh.ops.create_cone(
+ bm,
+ cap_ends=True,
+ cap_tris=False,
+ segments=segments,
+ radius1=radius1,
+ radius2=radius2,
+ depth=depth,
+ )
+ verts = list(geo["verts"])
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ v.co = rot @ v.co + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+ return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler)
+
+
+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_ladder_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ wood_verts = []
+ metal_faces = set()
+ try:
+ half = SPAN / 2.0 + STILE_W / 2.0
+ for sign in (-1.0, 1.0):
+ wood_verts.extend(
+ add_box(
+ bm,
+ (sign * half, 0.0, STILE_H / 2.0),
+ (STILE_W, STILE_D, STILE_H),
+ WOOD_IDX,
+ )
+ )
+ wood_verts.extend(
+ add_box(
+ bm,
+ (sign * half, 0.0, STILE_H + 0.012),
+ (STILE_W + 0.008, STILE_D + 0.008, 0.028),
+ WOOD_IDX,
+ )
+ )
+
+ z0 = 0.16
+ z1 = STILE_H - 0.14
+ for i in range(N_RUNGS):
+ t = i / (N_RUNGS - 1)
+ z = z0 + t * (z1 - z0)
+ wood_verts.extend(
+ add_cylinder(
+ bm,
+ (0.0, 0.0, z),
+ RUNG_R,
+ SPAN + STILE_W * 0.55,
+ RUNG_SEGS,
+ WOOD_IDX,
+ euler=(0.0, math.pi / 2.0, 0.0),
+ )
+ )
+
+ if bevel_offset > 0.0:
+ edges = list({e for v in wood_verts for e in v.link_edges if v.is_valid})
+ if edges:
+ bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5,
+ affect="EDGES",
+ clamp_overlap=True,
+ )
+
+ before = set(bm.faces)
+ for sign in (-1.0, 1.0):
+ hx = sign * half
+ add_box(bm, (hx, 0.0, 0.028), (STILE_W + 0.018, STILE_D + 0.018, 0.056), METAL_IDX)
+ add_box(
+ bm,
+ (hx, STILE_D / 2.0 + 0.010, 0.070),
+ (STILE_W * 0.70, 0.014, 0.090),
+ METAL_IDX,
+ )
+ add_box(
+ bm,
+ (hx, -(STILE_D / 2.0 + 0.010), 0.070),
+ (STILE_W * 0.70, 0.014, 0.090),
+ METAL_IDX,
+ )
+ add_box(
+ bm,
+ (hx, 0.0, STILE_H - 0.055),
+ (STILE_W + 0.016, STILE_D + 0.016, 0.028),
+ METAL_IDX,
+ )
+ metal_faces.update(set(bm.faces) - before)
+
+ rake = Euler((RAKE, 0.0, 0.0)).to_matrix()
+ for v in bm.verts:
+ v.co = rake @ v.co
+ zs = [v.co.z for v in bm.verts]
+ zmin = min(zs)
+ xs = [v.co.x for v in bm.verts]
+ ys = [v.co.y for v in bm.verts]
+ cx = 0.5 * (min(xs) + max(xs))
+ cy = 0.5 * (min(ys) + max(ys))
+ 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 = True
+ for edge in bm.edges:
+ edge.smooth = True
+ if edge.is_manifold and len(edge.link_faces) == 2:
+ if edge.calc_face_angle() > math.radians(40.0):
+ edge.smooth = False
+ for f in metal_faces:
+ if f.is_valid:
+ f.material_index = METAL_IDX
+ me = bpy.data.meshes.new(name)
+ bm.to_mesh(me)
+ me.update()
+ finally:
+ bm.free()
+ obj = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(obj)
+ return obj
+
+
+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):
+ # Duplicated from snippets/convex_hull_collider.py (not a package).
+ 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):
+ # Adapted from snippets/setup_bake_target_image.py — do not replace slots.
+ if not obj.data.uv_layers:
+ return None, None
+ img = bpy.data.images.new("LadderNrm", 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):
+ # Duplicated from snippets/bake_normal_high_to_low.py (not a package).
+ scene = bpy.context.scene
+ scene.render.engine = "CYCLES"
+ scene.cycles.device = "CPU"
+ scene.cycles.samples = 1
+ scene.cycles.use_denoising = False
+ for ob in bpy.context.view_layer.objects:
+ ob.select_set(False)
+ 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):
+ # Duplicated from snippets/export_preset_unity.py (not a package).
+ for ob in bpy.context.view_layer.objects:
+ ob.select_set(False)
+ 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_ladder_mesh("LadderLow", bevel_offset=0.0025, bevel_segments=2)
+ high = build_ladder_mesh("LadderHigh", bevel_offset=0.0025, bevel_segments=4)
+ wood = principled("LadderWood", (0.42, 0.24, 0.10, 1.0), 0.0, 0.55)
+ metal = principled("LadderMetal", (0.48, 0.46, 0.42, 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("ladder 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("ladder has no UV layer", 3), None, None, None, None, None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "LadderLOD1", LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "LadderLOD2", 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_ladder_mesh("LadderColSrc", bevel_offset=0.0, bevel_segments=1)
+ collider = convex_hull_collider(collider_src, "LadderCollider")
+ bpy.data.objects.remove(collider_src, do_unlink=True)
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f"bdt_wooden_ladder_{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(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 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 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(-28.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 = (2.55, -3.55, 1.38)
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim", None)
+ aim.location = (0.0, 0.0, 0.74)
+ 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("wooden-ladder 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/tests/smoke/catalog.json b/tests/smoke/catalog.json
index 1dc87c5..5d1f6b4 100644
--- a/tests/smoke/catalog.json
+++ b/tests/smoke/catalog.json
@@ -97,5 +97,6 @@
{"name": "wooden-bucket", "script": "showcase/wooden-bucket/wooden_bucket.py"},
{"name": "wall-torch", "script": "showcase/wall-torch/wall_torch.py"},
{"name": "tavern-stool", "script": "showcase/tavern-stool/tavern_stool.py"},
- {"name": "iron-cauldron", "script": "showcase/iron-cauldron/iron_cauldron.py"}
+ {"name": "iron-cauldron", "script": "showcase/iron-cauldron/iron_cauldron.py"},
+ {"name": "wooden-ladder", "script": "showcase/wooden-ladder/wooden_ladder.py"}
]
From 5b13a73bfbf1d4a9af1e78a4c305b03928d9a917 Mon Sep 17 00:00:00 2001
From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Date: Tue, 15 Sep 2026 18:50:44 -0400
Subject: [PATCH 2/2] feat: add hay-bale showcase piece
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 | 9 +-
ROADMAP.md | 4 +-
docs/gallery/assets/hay-bale-hero.webp | Bin 0 -> 16162 bytes
docs/gallery/hay-bale/index.html | 998 +++++++++++++++++++++++++
docs/gallery/index.html | 13 +-
showcase/gallery.json | 12 +
showcase/hay-bale/README.md | 74 ++
showcase/hay-bale/hay_bale.py | 674 +++++++++++++++++
showcase/hay-bale/preview.webp | Bin 0 -> 14156 bytes
tests/smoke/catalog.json | 3 +-
13 files changed, 1785 insertions(+), 9 deletions(-)
create mode 100644 docs/gallery/assets/hay-bale-hero.webp
create mode 100644 docs/gallery/hay-bale/index.html
create mode 100644 showcase/hay-bale/README.md
create mode 100644 showcase/hay-bale/hay_bale.py
create mode 100644 showcase/hay-bale/preview.webp
diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index 65bab4b..6f8f074 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -162,6 +162,7 @@
"showcase/wall-torch",
"showcase/tavern-stool",
"showcase/iron-cauldron",
- "showcase/wooden-ladder"
+ "showcase/wooden-ladder",
+ "showcase/hay-bale"
]
}
diff --git a/AGENTS.md b/AGENTS.md
index a9b249e..4dec798 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 25 showcase pieces (counts are CI-enforced against README.md)
+examples, and 26 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 13c4e32..9c49df6 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, 25 pieces (sibling of examples/; see showcase/README.md)
+showcase// - Budget-conformance props, 26 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 4d823c9..e63f879 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 25 showcase pieces
+ 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 26 showcase pieces
@@ -37,7 +37,7 @@
## Overview
-This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 25 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 26 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.
@@ -76,7 +76,7 @@ blender --background --python examples/bmesh-gear/bmesh_gear.py --
Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](showcase/README.md).
-25 showcase pieces — click to expand the preview grid
+26 showcase pieces — click to expand the preview grid
@@ -104,6 +104,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.
@@ -156,6 +157,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show
[`wooden-ladder`](showcase/wooden-ladder/) — procedural timber ladder with raked stiles, rungs, and iron shoes through the same pipeline. Falsifier `--skip-decimate` exits 9.
+[`hay-bale`](showcase/hay-bale/) — procedural bound hay bale with sisal twine through the same pipeline. Falsifier `--skip-decimate` exits 9.
+
## Examples
diff --git a/ROADMAP.md b/ROADMAP.md
index a8810b2..e5da783 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -130,7 +130,9 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~Wooden tavern stool (turned legs, round seat, stretchers) as a game-prop showcase piece~~ **SHIPPED** as `showcase/tavern-stool/` — round seat, turned splayed legs, stretchers, iron ferrules; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Cast-iron cooking cauldron on a tripod as a game-prop showcase piece~~ **SHIPPED** as `showcase/iron-cauldron/` — bulged open pot, bail, timber tripod, hook; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Wooden ladder (rungs, stiles, iron shoes) as a game-prop showcase piece~~ **SHIPPED** as `showcase/wooden-ladder/` — raked stiles, six rungs, iron shoes and straps; `--skip-decimate` exits 9 on the LOD1 ratio band
-- Bound hay bale as a game-prop showcase piece
+- ~~Bound hay bale as a game-prop showcase piece~~ **SHIPPED** as `showcase/hay-bale/` — bevelled loaf, end nap, two sisal twine belts; `--skip-decimate` exits 9 on the LOD1 ratio band
+- Wooden yoke (oxbow, iron rings) as a game-prop showcase piece
+- Butter churn (staved tub, lid, dasher) 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
- ~~Custom ID-property delete witness~~ **SHIPPED** as `examples/cross-version-property-delete/` — IDs built via `bpy.data.objects.new` (not `active_object`); `property_unset` is TypeError and leaves the key; `del` removes it on 4.5 LTS and 5.x
diff --git a/docs/gallery/assets/hay-bale-hero.webp b/docs/gallery/assets/hay-bale-hero.webp
new file mode 100644
index 0000000000000000000000000000000000000000..0c6573c94047f15b6d2cb6521d1147ef2c459f1a
GIT binary patch
literal 16162
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
A0RR91
literal 0
HcmV?d00001
diff --git a/docs/gallery/hay-bale/index.html b/docs/gallery/hay-bale/index.html
new file mode 100644
index 0000000..f2ceaa2
--- /dev/null
+++ b/docs/gallery/hay-bale/index.html
@@ -0,0 +1,998 @@
+
+
+
+
+
+ hay-bale — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ hay-bale
+ A procedural bound hay bale with a bevelled loaf, end nap, and two sisal twine belts 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: 252 tris, two materials with 66 hay and 60 twine faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.916×0.520×0.401 m, LOD ratios in band (5.2 COLLAPSE more aggressive), convex collider 68 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/hay-bale/hay_bale.py --
+
Copy
+
+
+A showcase piece, not an example. Procedural bound straw bale (bevelled loaf, end nap, two sisal twine belts) 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 | 220–290 | 252 / 252 / 252 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4683 | | LOD2 ratio | 0.10–0.35 of base | 0.2143 / 0.2143 / 0.1429 | | Materials | exactly 2 distinct, ≥24 hay, ≥24 twine | 2 slots, 66 hay, 60 twine | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.916, 0.520, 0.401) m ± 0.01 | (0.9160, 0.5200, 0.4010), zmin 0 | | Collider tris | ≤ 120 | 68 | | Export | written, size > 0 | 25944 / 25944 / 25936 bytes |
+DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is more aggressive on both LOD ratios. 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 8 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 hay_bale.py --
+blender --background --python hay_bale.py -- --skip-decimate
+blender --background --python hay_bale.py -- --output bale.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 hay/twine 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 bound hay bale — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural straw bale 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 hay_bale.py --
+ blender --background --python hay_bale.py -- --skip-decimate
+ blender --background --python hay_bale.py -- --output bale.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+# Showcase lives at repo-root/showcase/, not under examples/. The framing
+# helper is the repo's only shared import and lives next to the examples;
+# resolve the repo root so we do not move gallery_framing.py.
+_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
+
+BALE_X = 0.90
+BALE_Y = 0.48
+BALE_Z = 0.38
+TWINE_T = 0.014
+TWINE_W = 0.026
+TWINE_PAD = 0.006
+BBOX_TOL = 0.01
+# Fitted after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (0.916 , 0.520 , 0.401 )
+
+BASE_TRIS_MIN = 220
+BASE_TRIS_MAX = 290
+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 = 120
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.04
+HAY_FACES_MIN = 24
+TWINE_FACES_MIN = 24
+
+HAY_IDX = 0
+TWINE_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):
+ # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
+ 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 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 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_bale_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ hay_verts = []
+ twine_faces = set()
+ try :
+ hay_verts.extend(
+ add_box(
+ bm,
+ (0.0 , 0.0 , BALE_Z / 2.0 ),
+ (BALE_X, BALE_Y, BALE_Z),
+ HAY_IDX,
+ )
+ )
+ if bevel_offset > 0.0 :
+ edges = list({e for v in hay_verts for e in v.link_edges if v.is_valid})
+ if edges:
+ bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5 ,
+ affect="EDGES" ,
+ clamp_overlap=True ,
+ )
+
+ hx = BALE_X / 2.0
+ hy = BALE_Y / 2.0
+ for sign in (-1.0 , 1.0 ):
+ add_box(
+ bm,
+ (sign * (hx + 0.002 ), 0.0 , BALE_Z / 2.0 ),
+ (0.012 , BALE_Y * 0.98 , BALE_Z * 0.96 ),
+ HAY_IDX,
+ )
+
+ before = set(bm.faces)
+ for x in (-0.22 , 0.22 ):
+ wrap_y = BALE_Y + 2.0 * TWINE_PAD + TWINE_T
+ wrap_z = BALE_Z + 2.0 * TWINE_PAD
+ add_box(bm, (x, 0.0 , TWINE_T / 2.0 ), (TWINE_W, wrap_y, TWINE_T), TWINE_IDX)
+ add_box(
+ bm,
+ (x, 0.0 , BALE_Z + TWINE_PAD - TWINE_T / 2.0 ),
+ (TWINE_W, wrap_y, TWINE_T),
+ TWINE_IDX,
+ )
+ add_box(
+ bm,
+ (x, hy + TWINE_PAD + TWINE_T / 2.0 , BALE_Z / 2.0 + 0.004 ),
+ (TWINE_W, TWINE_T, wrap_z),
+ TWINE_IDX,
+ )
+ add_box(
+ bm,
+ (x, -(hy + TWINE_PAD + TWINE_T / 2.0 ), BALE_Z / 2.0 + 0.004 ),
+ (TWINE_W, TWINE_T, wrap_z),
+ TWINE_IDX,
+ )
+ add_box(
+ bm,
+ (x, 0.02 , BALE_Z + TWINE_PAD + 0.004 ),
+ (TWINE_W + 0.008 , 0.032 , 0.018 ),
+ TWINE_IDX,
+ )
+ twine_faces.update(set(bm.faces) - before)
+
+ zs = [v.co.z for v in bm.verts]
+ zmin = min(zs)
+ xs = [v.co.x for v in bm.verts]
+ ys = [v.co.y for v in bm.verts]
+ cx = 0.5 * (min(xs) + max(xs))
+ cy = 0.5 * (min(ys) + max(ys))
+ 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 = True
+ for edge in bm.edges:
+ edge.smooth = True
+ if edge.is_manifold and len(edge.link_faces) == 2 :
+ if edge.calc_face_angle() > math.radians(38.0 ):
+ edge.smooth = False
+ for f in twine_faces:
+ if f.is_valid:
+ f.material_index = TWINE_IDX
+ me = bpy.data.meshes.new(name)
+ bm.to_mesh(me)
+ me.update()
+ finally :
+ bm.free()
+ obj = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(obj)
+ return obj
+
+
+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, hay, twine):
+ mats = obj.data.materials
+ wanted = (hay, twine)
+ 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):
+ # Duplicated from snippets/convex_hull_collider.py (not a package).
+ 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):
+ # Adapted from snippets/setup_bake_target_image.py — do not replace slots.
+ if not obj.data.uv_layers:
+ return None , None
+ img = bpy.data.images.new("HayBaleNrm" , 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 = HAY_IDX
+ return img, tex
+
+
+def bake_normal(high, low):
+ # Duplicated from snippets/bake_normal_high_to_low.py (not a package).
+ scene = bpy.context.scene
+ scene.render.engine = "CYCLES"
+ scene.cycles.device = "CPU"
+ scene.cycles.samples = 1
+ scene.cycles.use_denoising = False
+ for ob in bpy.context.view_layer.objects:
+ ob.select_set(False )
+ 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):
+ # Duplicated from snippets/export_preset_unity.py (not a package).
+ for ob in bpy.context.view_layer.objects:
+ ob.select_set(False )
+ 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_bale_mesh("BaleLow" , bevel_offset=0.012 , bevel_segments=2 )
+ high = build_bale_mesh("BaleHigh" , bevel_offset=0.012 , bevel_segments=4 )
+ hay = principled("BaleHay" , (0.72 , 0.55 , 0.18 , 1.0 ), 0.0 , 0.84 )
+ twine = principled("BaleTwine" , (0.20 , 0.14 , 0.07 , 1.0 ), 0.0 , 0.52 )
+ assign_slots(low, hay, twine)
+ assign_slots(high, hay, twine)
+
+ if low.data is None or len(low.data.polygons) < 6 :
+ return fail("hay bale 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, hay)
+ if img is None :
+ return fail("hay bale has no UV layer" , 3 ), None , None , None , None , None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "BaleLOD1" , LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "BaleLOD2" , 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_bale_mesh("BaleColSrc" , bevel_offset=0.0 , bevel_segments=1 )
+ collider = convex_hull_collider(collider_src, "BaleCollider" )
+ bpy.data.objects.remove(collider_src, do_unlink=True )
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f" bdt_hay_bale_ {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(HAY_IDX, 0 ) < HAY_FACES_MIN:
+ return fail(
+ f" hay faces {idx_counts.get(HAY_IDX, 0 )} < {HAY_FACES_MIN}" ,
+ 5 ,
+ ), None , None , None , None , None
+ if idx_counts.get(TWINE_IDX, 0 ) < TWINE_FACES_MIN:
+ return fail(
+ f" twine faces {idx_counts.get(TWINE_IDX, 0 )} < {TWINE_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, hay, 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, hay, tex, path, engine):
+ scene = bpy.context.scene
+ wire_normal(hay, 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(-32.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.26 , -1.53 , 0.88 )
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim" , None )
+ aim.location = (0.0 , 0.0 , 0.15 )
+ 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, hay, tex, _col = check(args.skip_decimate)
+ if code:
+ return code
+ if args.output:
+ rcode = render_still(low, hay, tex, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
+ print(f" rendered still {args.output}" )
+ print("hay-bale 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/index.html b/docs/gallery/index.html
index c2d4a62..32315c8 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" />
×
- 76 examples
+ 77 examples
+
+
+
+
+
+
+
A procedural bound hay bale with a bevelled loaf, end nap, and two sisal twine belts through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
+
witnesses Recomputed: 252 tris, two materials with 66 hay and 60 twine faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.916×0.520×0.401 m, LOD ratios in band (5.2 COLLAPSE more aggressive), convex collider 68 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/showcase/gallery.json b/showcase/gallery.json
index 3457b57..da14762 100644
--- a/showcase/gallery.json
+++ b/showcase/gallery.json
@@ -304,6 +304,18 @@
"mesh",
"export"
]
+ },
+ {
+ "name": "hay-bale",
+ "dir": "showcase/hay-bale",
+ "teaches": "A procedural bound hay bale with a bevelled loaf, end nap, and two sisal twine belts through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
+ "witnessesFix": "Recomputed: 252 tris, two materials with 66 hay and 60 twine faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.916×0.520×0.401 m, LOD ratios in band (5.2 COLLAPSE more aggressive), convex collider 68 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
+ "hero": "docs/gallery/assets/hay-bale-hero.webp",
+ "preview": "showcase/hay-bale/preview.webp",
+ "tags": [
+ "mesh",
+ "export"
+ ]
}
]
}
diff --git a/showcase/hay-bale/README.md b/showcase/hay-bale/README.md
new file mode 100644
index 0000000..a4d534c
--- /dev/null
+++ b/showcase/hay-bale/README.md
@@ -0,0 +1,74 @@
+# Hay bale
+
+A showcase piece, not an example. Procedural bound straw bale (bevelled
+loaf, end nap, two sisal twine belts) 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 | 220–290 | 252 / 252 / 252 |
+| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4683 |
+| LOD2 ratio | 0.10–0.35 of base | 0.2143 / 0.2143 / 0.1429 |
+| Materials | exactly 2 distinct, ≥24 hay, ≥24 twine | 2 slots, 66 hay, 60 twine |
+| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
+| Outer AABB | (0.916, 0.520, 0.401) m ± 0.01 | (0.9160, 0.5200, 0.4010), zmin 0 |
+| Collider tris | ≤ 120 | 68 |
+| Export | written, size > 0 | 25944 / 25944 / 25936 bytes |
+
+DECIMATE COLLAPSE triangle counts are **not** identical across series —
+5.2.1 is more aggressive on both LOD ratios. 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 8 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 hay_bale.py --
+blender --background --python hay_bale.py -- --skip-decimate
+blender --background --python hay_bale.py -- --output bale.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 hay/twine 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/hay-bale/hay_bale.py b/showcase/hay-bale/hay_bale.py
new file mode 100644
index 0000000..c72af21
--- /dev/null
+++ b/showcase/hay-bale/hay_bale.py
@@ -0,0 +1,674 @@
+"""Game-ready bound hay bale — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural straw bale 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 hay_bale.py --
+ blender --background --python hay_bale.py -- --skip-decimate
+ blender --background --python hay_bale.py -- --output bale.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+# Showcase lives at repo-root/showcase/, not under examples/. The framing
+# helper is the repo's only shared import and lives next to the examples;
+# resolve the repo root so we do not move gallery_framing.py.
+_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
+
+BALE_X = 0.90
+BALE_Y = 0.48
+BALE_Z = 0.38
+TWINE_T = 0.014
+TWINE_W = 0.026
+TWINE_PAD = 0.006
+BBOX_TOL = 0.01
+# Fitted after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (0.916, 0.520, 0.401)
+
+BASE_TRIS_MIN = 220
+BASE_TRIS_MAX = 290
+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 = 120
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.04
+HAY_FACES_MIN = 24
+TWINE_FACES_MIN = 24
+
+HAY_IDX = 0
+TWINE_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):
+ # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
+ 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 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 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_bale_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ hay_verts = []
+ twine_faces = set()
+ try:
+ hay_verts.extend(
+ add_box(
+ bm,
+ (0.0, 0.0, BALE_Z / 2.0),
+ (BALE_X, BALE_Y, BALE_Z),
+ HAY_IDX,
+ )
+ )
+ if bevel_offset > 0.0:
+ edges = list({e for v in hay_verts for e in v.link_edges if v.is_valid})
+ if edges:
+ bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5,
+ affect="EDGES",
+ clamp_overlap=True,
+ )
+
+ hx = BALE_X / 2.0
+ hy = BALE_Y / 2.0
+ for sign in (-1.0, 1.0):
+ add_box(
+ bm,
+ (sign * (hx + 0.002), 0.0, BALE_Z / 2.0),
+ (0.012, BALE_Y * 0.98, BALE_Z * 0.96),
+ HAY_IDX,
+ )
+
+ before = set(bm.faces)
+ for x in (-0.22, 0.22):
+ wrap_y = BALE_Y + 2.0 * TWINE_PAD + TWINE_T
+ wrap_z = BALE_Z + 2.0 * TWINE_PAD
+ add_box(bm, (x, 0.0, TWINE_T / 2.0), (TWINE_W, wrap_y, TWINE_T), TWINE_IDX)
+ add_box(
+ bm,
+ (x, 0.0, BALE_Z + TWINE_PAD - TWINE_T / 2.0),
+ (TWINE_W, wrap_y, TWINE_T),
+ TWINE_IDX,
+ )
+ add_box(
+ bm,
+ (x, hy + TWINE_PAD + TWINE_T / 2.0, BALE_Z / 2.0 + 0.004),
+ (TWINE_W, TWINE_T, wrap_z),
+ TWINE_IDX,
+ )
+ add_box(
+ bm,
+ (x, -(hy + TWINE_PAD + TWINE_T / 2.0), BALE_Z / 2.0 + 0.004),
+ (TWINE_W, TWINE_T, wrap_z),
+ TWINE_IDX,
+ )
+ add_box(
+ bm,
+ (x, 0.02, BALE_Z + TWINE_PAD + 0.004),
+ (TWINE_W + 0.008, 0.032, 0.018),
+ TWINE_IDX,
+ )
+ twine_faces.update(set(bm.faces) - before)
+
+ zs = [v.co.z for v in bm.verts]
+ zmin = min(zs)
+ xs = [v.co.x for v in bm.verts]
+ ys = [v.co.y for v in bm.verts]
+ cx = 0.5 * (min(xs) + max(xs))
+ cy = 0.5 * (min(ys) + max(ys))
+ 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 = True
+ for edge in bm.edges:
+ edge.smooth = True
+ if edge.is_manifold and len(edge.link_faces) == 2:
+ if edge.calc_face_angle() > math.radians(38.0):
+ edge.smooth = False
+ for f in twine_faces:
+ if f.is_valid:
+ f.material_index = TWINE_IDX
+ me = bpy.data.meshes.new(name)
+ bm.to_mesh(me)
+ me.update()
+ finally:
+ bm.free()
+ obj = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(obj)
+ return obj
+
+
+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, hay, twine):
+ mats = obj.data.materials
+ wanted = (hay, twine)
+ 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):
+ # Duplicated from snippets/convex_hull_collider.py (not a package).
+ 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):
+ # Adapted from snippets/setup_bake_target_image.py — do not replace slots.
+ if not obj.data.uv_layers:
+ return None, None
+ img = bpy.data.images.new("HayBaleNrm", 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 = HAY_IDX
+ return img, tex
+
+
+def bake_normal(high, low):
+ # Duplicated from snippets/bake_normal_high_to_low.py (not a package).
+ scene = bpy.context.scene
+ scene.render.engine = "CYCLES"
+ scene.cycles.device = "CPU"
+ scene.cycles.samples = 1
+ scene.cycles.use_denoising = False
+ for ob in bpy.context.view_layer.objects:
+ ob.select_set(False)
+ 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):
+ # Duplicated from snippets/export_preset_unity.py (not a package).
+ for ob in bpy.context.view_layer.objects:
+ ob.select_set(False)
+ 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_bale_mesh("BaleLow", bevel_offset=0.012, bevel_segments=2)
+ high = build_bale_mesh("BaleHigh", bevel_offset=0.012, bevel_segments=4)
+ hay = principled("BaleHay", (0.72, 0.55, 0.18, 1.0), 0.0, 0.84)
+ twine = principled("BaleTwine", (0.20, 0.14, 0.07, 1.0), 0.0, 0.52)
+ assign_slots(low, hay, twine)
+ assign_slots(high, hay, twine)
+
+ if low.data is None or len(low.data.polygons) < 6:
+ return fail("hay bale 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, hay)
+ if img is None:
+ return fail("hay bale has no UV layer", 3), None, None, None, None, None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "BaleLOD1", LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "BaleLOD2", 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_bale_mesh("BaleColSrc", bevel_offset=0.0, bevel_segments=1)
+ collider = convex_hull_collider(collider_src, "BaleCollider")
+ bpy.data.objects.remove(collider_src, do_unlink=True)
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f"bdt_hay_bale_{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(HAY_IDX, 0) < HAY_FACES_MIN:
+ return fail(
+ f"hay faces {idx_counts.get(HAY_IDX, 0)} < {HAY_FACES_MIN}",
+ 5,
+ ), None, None, None, None, None
+ if idx_counts.get(TWINE_IDX, 0) < TWINE_FACES_MIN:
+ return fail(
+ f"twine faces {idx_counts.get(TWINE_IDX, 0)} < {TWINE_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, hay, 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, hay, tex, path, engine):
+ scene = bpy.context.scene
+ wire_normal(hay, 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(-32.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.26, -1.53, 0.88)
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim", None)
+ aim.location = (0.0, 0.0, 0.15)
+ 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, hay, tex, _col = check(args.skip_decimate)
+ if code:
+ return code
+ if args.output:
+ rcode = render_still(low, hay, tex, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
+ print(f"rendered still {args.output}")
+ print("hay-bale 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/hay-bale/preview.webp b/showcase/hay-bale/preview.webp
new file mode 100644
index 0000000000000000000000000000000000000000..8c99ea0fd11df4892d5ef601bcef79b0c04805e4
GIT binary patch
literal 14156
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
W-^E6^qr+hQ+Vkm9IBNn<3qSxgEN7zt
literal 0
HcmV?d00001
diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json
index 5d1f6b4..d9c58f3 100644
--- a/tests/smoke/catalog.json
+++ b/tests/smoke/catalog.json
@@ -98,5 +98,6 @@
{"name": "wall-torch", "script": "showcase/wall-torch/wall_torch.py"},
{"name": "tavern-stool", "script": "showcase/tavern-stool/tavern_stool.py"},
{"name": "iron-cauldron", "script": "showcase/iron-cauldron/iron_cauldron.py"},
- {"name": "wooden-ladder", "script": "showcase/wooden-ladder/wooden_ladder.py"}
+ {"name": "wooden-ladder", "script": "showcase/wooden-ladder/wooden_ladder.py"},
+ {"name": "hay-bale", "script": "showcase/hay-bale/hay_bale.py"}
]