From fd9ac4d2b0c240d3d2aee4dc5e178c4b99a61e7e Mon Sep 17 00:00:00 2001
From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Date: Mon, 14 Sep 2026 18:26:21 -0400
Subject: [PATCH 1/2] feat: add hitching-post showcase piece
Square timber post with a pyramidal cap, cross-arm, and iron rings through the shipped bake/LOD/collider/Unity glTF pipeline. --skip-decimate exits 9 on LOD1.
Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Co-authored-by: Cursor
---
.cursor-plugin/plugin.json | 3 +-
AGENTS.md | 2 +-
CLAUDE.md | 2 +-
README.md | 7 +-
ROADMAP.md | 2 +-
docs/gallery/assets/hitching-post-hero.webp | Bin 0 -> 7030 bytes
docs/gallery/hitching-post/index.html | 1114 +++++++++++++++++++
docs/gallery/index.html | 13 +-
showcase/gallery.json | 12 +
showcase/hitching-post/README.md | 74 ++
showcase/hitching-post/hitching_post.py | 790 +++++++++++++
showcase/hitching-post/preview.webp | Bin 0 -> 6410 bytes
tests/smoke/catalog.json | 3 +-
13 files changed, 2014 insertions(+), 8 deletions(-)
create mode 100644 docs/gallery/assets/hitching-post-hero.webp
create mode 100644 docs/gallery/hitching-post/index.html
create mode 100644 showcase/hitching-post/README.md
create mode 100644 showcase/hitching-post/hitching_post.py
create mode 100644 showcase/hitching-post/preview.webp
diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index c45afbe..94936d5 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -152,6 +152,7 @@
"showcase/park-bench",
"showcase/wheelbarrow",
"showcase/anvil",
- "showcase/water-trough"
+ "showcase/water-trough",
+ "showcase/hitching-post"
]
}
diff --git a/AGENTS.md b/AGENTS.md
index 6ce0c0e..fb82a56 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 15 showcase pieces (counts are CI-enforced against README.md)
+examples, and 16 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 752008f..c83965b 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, 15 pieces (sibling of examples/; see showcase/README.md)
+showcase// - Budget-conformance props, 16 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 fdceebd..4b8c8e6 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 15 showcase pieces
+ 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 16 showcase pieces
@@ -37,7 +37,7 @@
## Overview
-This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 15 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 16 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.
@@ -90,6 +90,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.
@@ -121,6 +122,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show
[`water-trough`](showcase/water-trough/) — procedural staved water trough on a timber stand through the same pipeline. Falsifier `--skip-decimate` exits 9.
+[`hitching-post`](showcase/hitching-post/) — procedural timber hitching post with a cross-arm and iron rings through the same pipeline. Falsifier `--skip-decimate` exits 9.
+
## Examples
Runnable, smoke-gated demos live in [`examples/`](examples/) — each is executed headless on
diff --git a/ROADMAP.md b/ROADMAP.md
index c57cd3c..94ac3a6 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -119,7 +119,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~Wooden wheelbarrow (staved tray, single wheel, handles) as a game-prop showcase piece~~ **SHIPPED** as `showcase/wheelbarrow/` — staved U-tray, iron straps, spoked wheel, rear legs; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Blacksmith anvil on a timber stump as a game-prop showcase piece~~ **SHIPPED** as `showcase/anvil/` — London-pattern horn, face, heel, waist, hooped stump; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Staved wooden water trough on a stand as a game-prop showcase piece~~ **SHIPPED** as `showcase/water-trough/` — U-staves, end boards, iron straps, trestle legs; `--skip-decimate` exits 9 on the LOD1 ratio band
-- Hitching post with iron ring as a game-prop showcase piece
+- ~~Hitching post with iron ring as a game-prop showcase piece~~ **SHIPPED** as `showcase/hitching-post/` — square post, pyramidal cap, cross-arm, iron shoe, bands, two rings; `--skip-decimate` exits 9 on the LOD1 ratio band
- Grindstone on a timber trestle 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/hitching-post-hero.webp b/docs/gallery/assets/hitching-post-hero.webp
new file mode 100644
index 0000000000000000000000000000000000000000..2c65ebe646d2d82243997d38bc744a5ccbb6f37d
GIT binary patch
literal 7030
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
+
+
+
+
+ hitching-post — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ hitching-post
+ A procedural hitching post 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: 848 tris, two materials with 262 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.460×0.194×1.260 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 144 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/hitching-post/hitching_post.py --
+
Copy
+
+
+A showcase piece, not an example. Procedural timber hitching post (square post, pyramidal cap, cross-arm, iron shoe, bands, and two hitching rings) 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 | 780–920 | 848 / 848 / 848 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2193 / 0.2193 / 0.1958 | | Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 262 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.460, 0.194, 1.260) m ± 0.01 | (0.4600, 0.1943, 1.2600), zmin 0 | | Collider tris | ≤ 180 | 144 | | Export | written, size > 0 | 73164 / 73164 / 73160 bytes |
+DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 4 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 hitching_post.py --
+blender --background --python hitching_post.py -- --skip-decimate
+blender --background --python hitching_post.py -- --output hitching-post.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 hitching post — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural timber hitching post (square
+post, pyramidal cap, cross-arm, iron shoe, bands, and hitching rings)
+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 hitching_post.py --
+ blender --background --python hitching_post.py -- --skip-decimate
+ blender --background --python hitching_post.py -- --output hitching-post.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+_REPO = os.path.abspath(
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0 , os.path.join(_REPO, "examples" ))
+sys.dont_write_bytecode = True
+import gallery_framing # noqa: E402
+
+POST_W = 0.125
+POST_H = 1.16
+CAP_H = 0.10
+SHOE_H = 0.050
+SHOE_SCALE = 1.34
+ARM_Z = 0.90
+ARM_L = 0.46
+ARM_Y = 0.056
+ARM_ZTH = 0.056
+RING_MAJOR = 0.058
+RING_MINOR = 0.011
+BAND_H = 0.030
+BAND_T = 0.012
+BAND_ZS = (0.20 , 0.52 )
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (0.460 , 0.194 , 1.260 )
+BASE_TRIS_MIN = 780
+BASE_TRIS_MAX = 920
+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 = 180
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 24
+WOOD_FACES_MIN = 12
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+ return "BLENDER_EEVEE" if bpy.app.version >= (5 , 0 , 0 ) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+ print(f" ERROR: {msg}" , file=sys.stderr)
+ return code
+
+
+def triangle_count(mesh):
+ mesh.calc_loop_triangles()
+ return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+ depsgraph = bpy.context.evaluated_depsgraph_get()
+ eval_obj = obj.evaluated_get(depsgraph)
+ eval_mesh = eval_obj.to_mesh()
+ try :
+ eval_mesh.calc_loop_triangles()
+ return len(eval_mesh.loop_triangles)
+ finally :
+ eval_obj.to_mesh_clear()
+
+
+def deselect_all():
+ for ob in list(bpy.context.view_layer.objects):
+ if ob is None :
+ continue
+ ob.select_set(False )
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ geo = bmesh.ops.create_cube(bm, size=1.0 )
+ verts = geo["verts" ]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ p = Vector((v.co.x * scale[0 ], v.co.y * scale[1 ], v.co.z * scale[2 ]))
+ v.co = rot @ p + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_oriented_box(bm, a, b, scale_xy, mat_idx):
+ a = Vector(a)
+ b = Vector(b)
+ delta = b - a
+ length = delta.length
+ if length < 1e-8 :
+ return []
+ quat = Vector((0.0 , 0.0 , 1.0 )).rotation_difference(delta.normalized())
+ eul = quat.to_euler("XYZ" )
+ return add_box(
+ bm,
+ ((a + b) * 0.5 ),
+ (scale_xy[0 ], scale_xy[1 ], length),
+ mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
+
+
+def add_cyl(bm, loc, radius, 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=radius,
+ radius2=radius,
+ depth=depth,
+ )
+ verts = 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_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 = 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_rim(bm, loc, major, minor, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ n_major = 14
+ n_minor = 7
+ rings = []
+ for i in range(n_major):
+ u = i * (2.0 * math.pi / n_major)
+ ring = []
+ for j in range(n_minor):
+ v = j * (2.0 * math.pi / n_minor)
+ x = (major + minor * math.cos(v)) * math.cos(u)
+ y = (major + minor * math.cos(v)) * math.sin(u)
+ z = minor * math.sin(v)
+ ring.append(bm.verts.new((x, y, z)))
+ rings.append(ring)
+ bm.verts.ensure_lookup_table()
+ for i in range(n_major):
+ i2 = (i + 1 ) % n_major
+ for j in range(n_minor):
+ j2 = (j + 1 ) % n_minor
+ face = bm.faces.new(
+ (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2])
+ )
+ face.material_index = mat_idx
+ verts = [v for ring in rings for v in ring]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ v.co = rot @ v.co + origin
+ return verts
+
+
+def add_square_band(bm, z, half, t, h, mat_idx):
+ metal = []
+ metal.extend(add_box(bm, (0.0 , half + t / 2.0 , z), (2.0 * half + 2.0 * t, t, h), mat_idx))
+ metal.extend(add_box(bm, (0.0 , -(half + t / 2.0 ), z), (2.0 * half + 2.0 * t, t, h), mat_idx))
+ metal.extend(add_box(bm, (half + t / 2.0 , 0.0 , z), (t, 2.0 * half, h), mat_idx))
+ metal.extend(add_box(bm, (-(half + t / 2.0 ), 0.0 , z), (t, 2.0 * half, h), mat_idx))
+ return metal
+
+
+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_hitching_post_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ try :
+ wood = []
+ metal = []
+ half = POST_W / 2.0
+
+ wood.extend(
+ add_box(
+ bm,
+ (0.0 , 0.0 , POST_H / 2.0 ),
+ (POST_W, POST_W, POST_H),
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_cone(
+ bm,
+ (0.0 , 0.0 , POST_H + CAP_H / 2.0 ),
+ POST_W * 0.78 ,
+ 0.012 ,
+ CAP_H,
+ 4 ,
+ WOOD_IDX,
+ euler=(0.0 , 0.0 , math.pi / 4.0 ),
+ )
+ )
+ wood.extend(
+ add_box(
+ bm,
+ (0.0 , 0.0 , ARM_Z),
+ (ARM_L, ARM_Y, ARM_ZTH),
+ WOOD_IDX,
+ )
+ )
+
+ if bevel_offset > 0.0 :
+ edges = list({e for v in wood for e in v.link_edges})
+ ret = bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5 ,
+ affect="EDGES" ,
+ clamp_overlap=True ,
+ )
+ for f in ret.get("faces" ) or []:
+ f.material_index = WOOD_IDX
+
+ metal.extend(
+ add_box(
+ bm,
+ (0.0 , 0.0 , SHOE_H / 2.0 ),
+ (POST_W * SHOE_SCALE, POST_W * SHOE_SCALE, SHOE_H),
+ METAL_IDX,
+ )
+ )
+ for z in BAND_ZS:
+ metal.extend(add_square_band(bm, z, half, BAND_T, BAND_H, METAL_IDX))
+
+ ring_y = -(max(POST_W, ARM_Y) / 2.0 + RING_MINOR + 0.006 )
+ for sx in (-ARM_L * 0.32 , ARM_L * 0.32 ):
+ metal.extend(
+ add_box(
+ bm,
+ (sx, ring_y + RING_MINOR + 0.010 , ARM_Z),
+ (0.018 , 0.022 , 0.018 ),
+ METAL_IDX,
+ )
+ )
+ metal.extend(
+ add_rim(
+ bm,
+ (sx, ring_y - RING_MAJOR * 0.35 , ARM_Z - RING_MAJOR * 0.15 ),
+ RING_MAJOR,
+ RING_MINOR,
+ METAL_IDX,
+ euler=(math.pi / 2.0 , 0.0 , 0.0 ),
+ )
+ )
+
+ xs = [v.co.x for v in bm.verts]
+ ys = [v.co.y for v in bm.verts]
+ zs = [v.co.z for v in bm.verts]
+ rcx = 0.5 * (min(xs) + max(xs))
+ rcy = 0.5 * (min(ys) + max(ys))
+ zmin = min(zs)
+ for v in bm.verts:
+ v.co.x -= rcx
+ v.co.y -= rcy
+ v.co.z -= zmin
+ if v.co.z < 0.0 :
+ v.co.z = 0.0
+
+ pack_uvs(bm)
+ bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+ for face in bm.faces:
+ face.smooth = False
+ me = bpy.data.meshes.new(name)
+ bm.to_mesh(me)
+ me.update()
+ for poly in me.polygons:
+ poly.use_smooth = False
+ finally :
+ bm.free()
+ out = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(out)
+ return out
+
+
+def principled(name, color, metallic, roughness):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF" ]
+ bsdf.inputs["Base Color" ].default_value = color
+ bsdf.inputs["Metallic" ].default_value = metallic
+ bsdf.inputs["Roughness" ].default_value = roughness
+ return mat
+
+
+def assign_slots(obj, wood, metal):
+ mats = obj.data.materials
+ wanted = (wood, metal)
+ for i, mat in enumerate(wanted):
+ if i < len(mats):
+ mats[i] = mat
+ else :
+ mats.append(mat)
+
+
+def world_bbox(obj):
+ corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+ xs = [c.x for c in corners]
+ ys = [c.y for c in corners]
+ zs = [c.z for c in corners]
+ return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+ uv = mesh.uv_layers.active
+ if uv is None :
+ return 0.0 , 0.0 , 1.0 , 1.0 , 0 , 1.0
+ data = uv.data
+ us = [loop.uv[0 ] for loop in data]
+ vs = [loop.uv[1 ] for loop in data]
+ aabbs = []
+ for poly in mesh.polygons:
+ pu = [data[i].uv[0 ] for i in poly.loop_indices]
+ pv = [data[i].uv[1 ] for i in poly.loop_indices]
+ aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+ overlap = 0.0
+ for i in range(len(aabbs)):
+ a = aabbs[i]
+ for j in range(i + 1 , len(aabbs)):
+ b = aabbs[j]
+ x0 = max(a[0 ], b[0 ])
+ y0 = max(a[1 ], b[1 ])
+ x1 = min(a[2 ], b[2 ])
+ y1 = min(a[3 ], b[3 ])
+ overlap += max(0.0 , x1 - x0) * max(0.0 , y1 - y0)
+ return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+ mesh = obj.data.copy()
+ lod = bpy.data.objects.new(name, mesh)
+ lod.matrix_world = obj.matrix_world.copy()
+ bpy.context.scene.collection.objects.link(lod)
+ if not skip_decimate and 0.0 < ratio < 1.0 :
+ mod = lod.modifiers.new("DecimateBudget" , "DECIMATE" )
+ mod.decimate_type = "COLLAPSE"
+ mod.ratio = ratio
+ return lod
+
+
+def convex_hull_collider(obj, name):
+ mesh = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try :
+ bm.from_mesh(obj.data)
+ result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+ interior = result.get("geom_interior" ) or []
+ unused = result.get("geom_unused" ) or []
+ if interior:
+ bmesh.ops.delete(bm, geom=interior, context="VERTS" )
+ if unused:
+ bmesh.ops.delete(bm, geom=unused, context="VERTS" )
+ bm.to_mesh(mesh)
+ mesh.update()
+ finally :
+ bm.free()
+ collider = bpy.data.objects.new(name, mesh)
+ bpy.context.collection.objects.link(collider)
+ collider.matrix_world = obj.matrix_world.copy()
+ return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+ if not obj.data.uv_layers:
+ return None , None
+ img = bpy.data.images.new("HitchPostNrm" , size, size, alpha=True , float_buffer=False )
+ img.colorspace_settings.name = "Non-Color"
+ nodes = target_mat.node_tree.nodes
+ tex = nodes.new("ShaderNodeTexImage" )
+ tex.image = img
+ nodes.active = tex
+ tex.select = True
+ obj.active_material_index = WOOD_IDX
+ return img, tex
+
+
+def bake_normal(high, low):
+ scene = bpy.context.scene
+ scene.render.engine = "CYCLES"
+ scene.cycles.device = "CPU"
+ scene.cycles.samples = 1
+ scene.cycles.use_denoising = False
+ deselect_all()
+ high.select_set(True )
+ low.select_set(True )
+ bpy.context.view_layer.objects.active = low
+ return bpy.ops.object.bake(
+ type="NORMAL" ,
+ use_selected_to_active=True ,
+ cage_extrusion=CAGE_EXTRUSION,
+ use_cage=False ,
+ normal_space="TANGENT" ,
+ margin=4 ,
+ margin_type="ADJACENT_FACES" ,
+ use_clear=True ,
+ target="IMAGE_TEXTURES" ,
+ )
+
+
+def export_unity(path, objects):
+ deselect_all()
+ for ob in objects:
+ ob.select_set(True )
+ bpy.context.view_layer.objects.active = objects[0 ]
+ bpy.ops.export_scene.gltf(
+ filepath=path,
+ use_selection=True ,
+ export_yup=True ,
+ export_apply=True ,
+ export_draco_mesh_compression_enable=False ,
+ export_animations=False ,
+ )
+
+
+def check(skip_decimate):
+ bpy.ops.wm.read_factory_settings(use_empty=True )
+ low = build_hitching_post_mesh("HitchPostLow" , bevel_offset=0.004 , bevel_segments=2 )
+ high = build_hitching_post_mesh("HitchPostHigh" , bevel_offset=0.004 , bevel_segments=4 )
+ wood = principled("HitchPostWood" , (0.36 , 0.19 , 0.07 , 1.0 ), 0.0 , 0.60 )
+ metal = principled("HitchPostIron" , (0.14 , 0.145 , 0.155 , 1.0 ), 1.0 , 0.38 )
+ assign_slots(low, wood, metal)
+ assign_slots(high, wood, metal)
+
+ if low.data is None or len(low.data.polygons) < 6 :
+ return fail("hitching post 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("hitching post has no UV layer" , 3 ), None , None , None , None , None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "HitchPostLOD1" , LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "HitchPostLOD2" , 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_hitching_post_mesh(
+ "HitchPostColSrc" , bevel_offset=0.0 , bevel_segments=1
+ )
+ collider = convex_hull_collider(collider_src, "HitchPostCollider" )
+ bpy.data.objects.remove(collider_src, do_unlink=True )
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f" bdt_hitching_post_ {os.getpid()}.glb " ,
+ )
+ if os.path.exists(export_path):
+ os.remove(export_path)
+ export_unity(export_path, [low, collider])
+ export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+ print(f" blender= {tuple(bpy.app.version)} skip_decimate= {skip_decimate}" )
+ print(
+ f" measured base_tris= {base_tris} lod1_tris= {lod1_tris} "
+ f" lod2_tris= {lod2_tris} r1= {r1:.4f } r2= {r2:.4f }"
+ )
+ print(
+ f" measured nmat= {nmat} uv=( {u0:.4f }, {v0:.4f })-( {u1:.4f }, {v1:.4f }) "
+ f" overlap= {overlap:.6f } nfaces= {nfaces}"
+ )
+ print(
+ f" measured bbox=( {size_x:.4f }, {size_y:.4f }, {size_z:.4f }) "
+ f" outer= {OUTER_SIZE} zmin= {bb[2 ]:.4f }"
+ )
+ print(
+ f" measured collider_tris= {col_tris} bake= {bake_result} "
+ f" bake_has_data= {img.has_data} export_bytes= {export_size}"
+ )
+
+ if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+ return fail(
+ f" base tris {base_tris} not in [ {BASE_TRIS_MIN}, {BASE_TRIS_MAX}] " ,
+ 4 ,
+ ), None , None , None , None , None
+ if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+ return fail(
+ f" material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}" ,
+ 5 ,
+ ), None , None , None , None , None
+ if idx_counts.get(METAL_IDX, 0 ) < METAL_FACES_MIN:
+ return fail(
+ f" metal faces {idx_counts.get(METAL_IDX, 0 )} < {METAL_FACES_MIN}" ,
+ 5 ,
+ ), None , None , None , None , None
+ if idx_counts.get(WOOD_IDX, 0 ) < WOOD_FACES_MIN:
+ return fail(
+ f" wood faces {idx_counts.get(WOOD_IDX, 0 )} < {WOOD_FACES_MIN}" ,
+ 5 ,
+ ), None , None , None , None , None
+ if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+ return fail(
+ f" UVs outside 0..1: ( {u0:.4f }, {v0:.4f })-( {u1:.4f }, {v1:.4f }) " ,
+ 6 ,
+ ), None , None , None , None , None
+ if overlap > UV_OVERLAP_MAX:
+ return fail(
+ f" UV AABB overlap {overlap:.6f } > {UV_OVERLAP_MAX}" ,
+ 7 ,
+ ), None , None , None , None , None
+ if (
+ abs(size_x - OUTER_SIZE[0 ]) > BBOX_TOL
+ or abs(size_y - OUTER_SIZE[1 ]) > BBOX_TOL
+ or abs(size_z - OUTER_SIZE[2 ]) > BBOX_TOL
+ ):
+ return fail(
+ f" bbox ( {size_x:.4f }, {size_y:.4f }, {size_z:.4f }) "
+ f" off outer {OUTER_SIZE}" ,
+ 8 ,
+ ), None , None , None , None , None
+ if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+ return fail(
+ f" LOD1 ratio {r1:.4f } not in [ {LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+ "(--skip-decimate is the designed fail)" ,
+ 9 ,
+ ), None , None , None , None , None
+ if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+ return fail(
+ f" LOD2 ratio {r2:.4f } not in [ {LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}] " ,
+ 9 ,
+ ), None , None , None , None , None
+ if col_tris > COLLIDER_TRIS_MAX:
+ return fail(
+ f" collider tris {col_tris} > {COLLIDER_TRIS_MAX}" ,
+ 11 ,
+ ), None , None , None , None , None
+ if bake_result != {"FINISHED" } or not img.has_data:
+ return fail(
+ f" bake failed result= {bake_result} has_data= {img.has_data}" ,
+ 12 ,
+ ), None , None , None , None , None
+ if export_size <= 0 :
+ return fail("export file missing or empty" , 13 ), None , None , None , None , None
+ return 0 , low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+ nt = mat.node_tree
+ bsdf = nt.nodes["Principled BSDF" ]
+ nrm = nt.nodes.new("ShaderNodeNormalMap" )
+ nrm.inputs["Strength" ].default_value = 1.0
+ nt.links.new(tex.outputs["Color" ], nrm.inputs["Color" ])
+ nt.links.new(nrm.outputs["Normal" ], bsdf.inputs["Normal" ])
+
+
+def render_still(low, wood, tex, path, engine):
+ scene = bpy.context.scene
+ wire_normal(wood, tex)
+ for ob in list(scene.objects):
+ if ob.type == "MESH" and ob != low:
+ ob.hide_render = True
+ ob.hide_viewport = True
+
+ low.rotation_euler.z = math.radians(-18.0 )
+ low.rotation_euler.x = math.radians(0.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.25 , -3.12 , 1.22 )
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim" , None )
+ aim.location = (0.0 , 0.0 , 0.63 )
+ 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("hitching-post 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 db1eb35..d6f91d6 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" />
×
- 66 examples
+ 67 examples
+
+
+
+
+
+
+
A procedural hitching post through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
+
witnesses Recomputed: 848 tris, two materials with 262 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.460×0.194×1.260 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 144 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 4049d69..d3afae2 100644
--- a/showcase/gallery.json
+++ b/showcase/gallery.json
@@ -184,6 +184,18 @@
"mesh",
"export"
]
+ },
+ {
+ "name": "hitching-post",
+ "dir": "showcase/hitching-post",
+ "teaches": "A procedural hitching post through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
+ "witnessesFix": "Recomputed: 848 tris, two materials with 262 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.460×0.194×1.260 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 144 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
+ "hero": "docs/gallery/assets/hitching-post-hero.webp",
+ "preview": "showcase/hitching-post/preview.webp",
+ "tags": [
+ "mesh",
+ "export"
+ ]
}
]
}
diff --git a/showcase/hitching-post/README.md b/showcase/hitching-post/README.md
new file mode 100644
index 0000000..08866a9
--- /dev/null
+++ b/showcase/hitching-post/README.md
@@ -0,0 +1,74 @@
+# Hitching post
+
+A showcase piece, not an example. Procedural timber hitching post
+(square post, pyramidal cap, cross-arm, iron shoe, bands, and two
+hitching rings) 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 | 780–920 | 848 / 848 / 848 |
+| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 |
+| LOD2 ratio | 0.10–0.35 of base | 0.2193 / 0.2193 / 0.1958 |
+| Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 262 metal |
+| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
+| Outer AABB | (0.460, 0.194, 1.260) m ± 0.01 | (0.4600, 0.1943, 1.2600), zmin 0 |
+| Collider tris | ≤ 180 | 144 |
+| Export | written, size > 0 | 73164 / 73164 / 73160 bytes |
+
+DECIMATE COLLAPSE triangle counts are **not** identical across series —
+5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an
+exact count. Bake pixels are stochastic; the gate is `has_data` plus
+operator `FINISHED`, not byte-identity. Construction uses no RNG.
+Export byte counts differ by 4 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 hitching_post.py --
+blender --background --python hitching_post.py -- --skip-decimate
+blender --background --python hitching_post.py -- --output hitching-post.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/hitching-post/hitching_post.py b/showcase/hitching-post/hitching_post.py
new file mode 100644
index 0000000..b9edd1e
--- /dev/null
+++ b/showcase/hitching-post/hitching_post.py
@@ -0,0 +1,790 @@
+"""Game-ready hitching post — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural timber hitching post (square
+post, pyramidal cap, cross-arm, iron shoe, bands, and hitching rings)
+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 hitching_post.py --
+ blender --background --python hitching_post.py -- --skip-decimate
+ blender --background --python hitching_post.py -- --output hitching-post.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+_REPO = os.path.abspath(
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing # noqa: E402
+
+POST_W = 0.125
+POST_H = 1.16
+CAP_H = 0.10
+SHOE_H = 0.050
+SHOE_SCALE = 1.34
+ARM_Z = 0.90
+ARM_L = 0.46
+ARM_Y = 0.056
+ARM_ZTH = 0.056
+RING_MAJOR = 0.058
+RING_MINOR = 0.011
+BAND_H = 0.030
+BAND_T = 0.012
+BAND_ZS = (0.20, 0.52)
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (0.460, 0.194, 1.260)
+BASE_TRIS_MIN = 780
+BASE_TRIS_MAX = 920
+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 = 180
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 24
+WOOD_FACES_MIN = 12
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+ return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+ print(f"ERROR: {msg}", file=sys.stderr)
+ return code
+
+
+def triangle_count(mesh):
+ mesh.calc_loop_triangles()
+ return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+ depsgraph = bpy.context.evaluated_depsgraph_get()
+ eval_obj = obj.evaluated_get(depsgraph)
+ eval_mesh = eval_obj.to_mesh()
+ try:
+ eval_mesh.calc_loop_triangles()
+ return len(eval_mesh.loop_triangles)
+ finally:
+ eval_obj.to_mesh_clear()
+
+
+def deselect_all():
+ for ob in list(bpy.context.view_layer.objects):
+ if ob is None:
+ continue
+ ob.select_set(False)
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+ geo = bmesh.ops.create_cube(bm, size=1.0)
+ verts = geo["verts"]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+ v.co = rot @ p + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_oriented_box(bm, a, b, scale_xy, mat_idx):
+ a = Vector(a)
+ b = Vector(b)
+ delta = b - a
+ length = delta.length
+ if length < 1e-8:
+ return []
+ quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+ eul = quat.to_euler("XYZ")
+ return add_box(
+ bm,
+ ((a + b) * 0.5),
+ (scale_xy[0], scale_xy[1], length),
+ mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
+
+
+def add_cyl(bm, loc, radius, 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=radius,
+ radius2=radius,
+ depth=depth,
+ )
+ verts = 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_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 = 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_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)):
+ n_major = 14
+ n_minor = 7
+ rings = []
+ for i in range(n_major):
+ u = i * (2.0 * math.pi / n_major)
+ ring = []
+ for j in range(n_minor):
+ v = j * (2.0 * math.pi / n_minor)
+ x = (major + minor * math.cos(v)) * math.cos(u)
+ y = (major + minor * math.cos(v)) * math.sin(u)
+ z = minor * math.sin(v)
+ ring.append(bm.verts.new((x, y, z)))
+ rings.append(ring)
+ bm.verts.ensure_lookup_table()
+ for i in range(n_major):
+ i2 = (i + 1) % n_major
+ for j in range(n_minor):
+ j2 = (j + 1) % n_minor
+ face = bm.faces.new(
+ (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2])
+ )
+ face.material_index = mat_idx
+ verts = [v for ring in rings for v in ring]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ v.co = rot @ v.co + origin
+ return verts
+
+
+def add_square_band(bm, z, half, t, h, mat_idx):
+ metal = []
+ metal.extend(add_box(bm, (0.0, half + t / 2.0, z), (2.0 * half + 2.0 * t, t, h), mat_idx))
+ metal.extend(add_box(bm, (0.0, -(half + t / 2.0), z), (2.0 * half + 2.0 * t, t, h), mat_idx))
+ metal.extend(add_box(bm, (half + t / 2.0, 0.0, z), (t, 2.0 * half, h), mat_idx))
+ metal.extend(add_box(bm, (-(half + t / 2.0), 0.0, z), (t, 2.0 * half, h), mat_idx))
+ return metal
+
+
+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_hitching_post_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ try:
+ wood = []
+ metal = []
+ half = POST_W / 2.0
+
+ wood.extend(
+ add_box(
+ bm,
+ (0.0, 0.0, POST_H / 2.0),
+ (POST_W, POST_W, POST_H),
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_cone(
+ bm,
+ (0.0, 0.0, POST_H + CAP_H / 2.0),
+ POST_W * 0.78,
+ 0.012,
+ CAP_H,
+ 4,
+ WOOD_IDX,
+ euler=(0.0, 0.0, math.pi / 4.0),
+ )
+ )
+ wood.extend(
+ add_box(
+ bm,
+ (0.0, 0.0, ARM_Z),
+ (ARM_L, ARM_Y, ARM_ZTH),
+ WOOD_IDX,
+ )
+ )
+
+ if bevel_offset > 0.0:
+ edges = list({e for v in wood for e in v.link_edges})
+ ret = bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5,
+ affect="EDGES",
+ clamp_overlap=True,
+ )
+ for f in ret.get("faces") or []:
+ f.material_index = WOOD_IDX
+
+ metal.extend(
+ add_box(
+ bm,
+ (0.0, 0.0, SHOE_H / 2.0),
+ (POST_W * SHOE_SCALE, POST_W * SHOE_SCALE, SHOE_H),
+ METAL_IDX,
+ )
+ )
+ for z in BAND_ZS:
+ metal.extend(add_square_band(bm, z, half, BAND_T, BAND_H, METAL_IDX))
+
+ ring_y = -(max(POST_W, ARM_Y) / 2.0 + RING_MINOR + 0.006)
+ for sx in (-ARM_L * 0.32, ARM_L * 0.32):
+ metal.extend(
+ add_box(
+ bm,
+ (sx, ring_y + RING_MINOR + 0.010, ARM_Z),
+ (0.018, 0.022, 0.018),
+ METAL_IDX,
+ )
+ )
+ metal.extend(
+ add_rim(
+ bm,
+ (sx, ring_y - RING_MAJOR * 0.35, ARM_Z - RING_MAJOR * 0.15),
+ RING_MAJOR,
+ RING_MINOR,
+ METAL_IDX,
+ euler=(math.pi / 2.0, 0.0, 0.0),
+ )
+ )
+
+ xs = [v.co.x for v in bm.verts]
+ ys = [v.co.y for v in bm.verts]
+ zs = [v.co.z for v in bm.verts]
+ rcx = 0.5 * (min(xs) + max(xs))
+ rcy = 0.5 * (min(ys) + max(ys))
+ zmin = min(zs)
+ for v in bm.verts:
+ v.co.x -= rcx
+ v.co.y -= rcy
+ v.co.z -= zmin
+ if v.co.z < 0.0:
+ v.co.z = 0.0
+
+ pack_uvs(bm)
+ bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+ for face in bm.faces:
+ face.smooth = False
+ me = bpy.data.meshes.new(name)
+ bm.to_mesh(me)
+ me.update()
+ for poly in me.polygons:
+ poly.use_smooth = False
+ finally:
+ bm.free()
+ out = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(out)
+ return out
+
+
+def principled(name, color, metallic, roughness):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF"]
+ bsdf.inputs["Base Color"].default_value = color
+ bsdf.inputs["Metallic"].default_value = metallic
+ bsdf.inputs["Roughness"].default_value = roughness
+ return mat
+
+
+def assign_slots(obj, wood, metal):
+ mats = obj.data.materials
+ wanted = (wood, metal)
+ for i, mat in enumerate(wanted):
+ if i < len(mats):
+ mats[i] = mat
+ else:
+ mats.append(mat)
+
+
+def world_bbox(obj):
+ corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+ xs = [c.x for c in corners]
+ ys = [c.y for c in corners]
+ zs = [c.z for c in corners]
+ return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+ uv = mesh.uv_layers.active
+ if uv is None:
+ return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+ data = uv.data
+ us = [loop.uv[0] for loop in data]
+ vs = [loop.uv[1] for loop in data]
+ aabbs = []
+ for poly in mesh.polygons:
+ pu = [data[i].uv[0] for i in poly.loop_indices]
+ pv = [data[i].uv[1] for i in poly.loop_indices]
+ aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+ overlap = 0.0
+ for i in range(len(aabbs)):
+ a = aabbs[i]
+ for j in range(i + 1, len(aabbs)):
+ b = aabbs[j]
+ x0 = max(a[0], b[0])
+ y0 = max(a[1], b[1])
+ x1 = min(a[2], b[2])
+ y1 = min(a[3], b[3])
+ overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+ return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+ mesh = obj.data.copy()
+ lod = bpy.data.objects.new(name, mesh)
+ lod.matrix_world = obj.matrix_world.copy()
+ bpy.context.scene.collection.objects.link(lod)
+ if not skip_decimate and 0.0 < ratio < 1.0:
+ mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+ mod.decimate_type = "COLLAPSE"
+ mod.ratio = ratio
+ return lod
+
+
+def convex_hull_collider(obj, name):
+ mesh = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(obj.data)
+ result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+ interior = result.get("geom_interior") or []
+ unused = result.get("geom_unused") or []
+ if interior:
+ bmesh.ops.delete(bm, geom=interior, context="VERTS")
+ if unused:
+ bmesh.ops.delete(bm, geom=unused, context="VERTS")
+ bm.to_mesh(mesh)
+ mesh.update()
+ finally:
+ bm.free()
+ collider = bpy.data.objects.new(name, mesh)
+ bpy.context.collection.objects.link(collider)
+ collider.matrix_world = obj.matrix_world.copy()
+ return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+ if not obj.data.uv_layers:
+ return None, None
+ img = bpy.data.images.new("HitchPostNrm", size, size, alpha=True, float_buffer=False)
+ img.colorspace_settings.name = "Non-Color"
+ nodes = target_mat.node_tree.nodes
+ tex = nodes.new("ShaderNodeTexImage")
+ tex.image = img
+ nodes.active = tex
+ tex.select = True
+ obj.active_material_index = WOOD_IDX
+ return img, tex
+
+
+def bake_normal(high, low):
+ scene = bpy.context.scene
+ scene.render.engine = "CYCLES"
+ scene.cycles.device = "CPU"
+ scene.cycles.samples = 1
+ scene.cycles.use_denoising = False
+ deselect_all()
+ high.select_set(True)
+ low.select_set(True)
+ bpy.context.view_layer.objects.active = low
+ return bpy.ops.object.bake(
+ type="NORMAL",
+ use_selected_to_active=True,
+ cage_extrusion=CAGE_EXTRUSION,
+ use_cage=False,
+ normal_space="TANGENT",
+ margin=4,
+ margin_type="ADJACENT_FACES",
+ use_clear=True,
+ target="IMAGE_TEXTURES",
+ )
+
+
+def export_unity(path, objects):
+ deselect_all()
+ for ob in objects:
+ ob.select_set(True)
+ bpy.context.view_layer.objects.active = objects[0]
+ bpy.ops.export_scene.gltf(
+ filepath=path,
+ use_selection=True,
+ export_yup=True,
+ export_apply=True,
+ export_draco_mesh_compression_enable=False,
+ export_animations=False,
+ )
+
+
+def check(skip_decimate):
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ low = build_hitching_post_mesh("HitchPostLow", bevel_offset=0.004, bevel_segments=2)
+ high = build_hitching_post_mesh("HitchPostHigh", bevel_offset=0.004, bevel_segments=4)
+ wood = principled("HitchPostWood", (0.36, 0.19, 0.07, 1.0), 0.0, 0.60)
+ metal = principled("HitchPostIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38)
+ assign_slots(low, wood, metal)
+ assign_slots(high, wood, metal)
+
+ if low.data is None or len(low.data.polygons) < 6:
+ return fail("hitching post 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("hitching post has no UV layer", 3), None, None, None, None, None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "HitchPostLOD1", LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "HitchPostLOD2", 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_hitching_post_mesh(
+ "HitchPostColSrc", bevel_offset=0.0, bevel_segments=1
+ )
+ collider = convex_hull_collider(collider_src, "HitchPostCollider")
+ bpy.data.objects.remove(collider_src, do_unlink=True)
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f"bdt_hitching_post_{os.getpid()}.glb",
+ )
+ if os.path.exists(export_path):
+ os.remove(export_path)
+ export_unity(export_path, [low, collider])
+ export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+ print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
+ print(
+ f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+ f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+ )
+ print(
+ f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+ f"overlap={overlap:.6f} nfaces={nfaces}"
+ )
+ print(
+ f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+ f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
+ )
+ print(
+ f"measured collider_tris={col_tris} bake={bake_result} "
+ f"bake_has_data={img.has_data} export_bytes={export_size}"
+ )
+
+ if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+ return fail(
+ f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
+ 4,
+ ), None, None, None, None, None
+ if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+ return fail(
+ f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
+ 5,
+ ), None, None, None, None, None
+ if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+ return fail(
+ f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
+ 5,
+ ), None, None, None, None, None
+ if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN:
+ return fail(
+ f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}",
+ 5,
+ ), None, None, None, None, None
+ if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+ return fail(
+ f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+ 6,
+ ), None, None, None, None, None
+ if overlap > UV_OVERLAP_MAX:
+ return fail(
+ f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+ 7,
+ ), None, None, None, None, None
+ if (
+ abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+ or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+ or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+ ):
+ return fail(
+ f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+ f"off outer {OUTER_SIZE}",
+ 8,
+ ), None, None, None, None, None
+ if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+ return fail(
+ f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+ "(--skip-decimate is the designed fail)",
+ 9,
+ ), None, None, None, None, None
+ if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+ return fail(
+ f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+ 9,
+ ), None, None, None, None, None
+ if col_tris > COLLIDER_TRIS_MAX:
+ return fail(
+ f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+ 11,
+ ), None, None, None, None, None
+ if bake_result != {"FINISHED"} or not img.has_data:
+ return fail(
+ f"bake failed result={bake_result} has_data={img.has_data}",
+ 12,
+ ), None, None, None, None, None
+ if export_size <= 0:
+ return fail("export file missing or empty", 13), None, None, None, None, None
+ return 0, low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+ nt = mat.node_tree
+ bsdf = nt.nodes["Principled BSDF"]
+ nrm = nt.nodes.new("ShaderNodeNormalMap")
+ nrm.inputs["Strength"].default_value = 1.0
+ nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+ nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, wood, tex, path, engine):
+ scene = bpy.context.scene
+ wire_normal(wood, tex)
+ for ob in list(scene.objects):
+ if ob.type == "MESH" and ob != low:
+ ob.hide_render = True
+ ob.hide_viewport = True
+
+ low.rotation_euler.z = math.radians(-18.0)
+ low.rotation_euler.x = math.radians(0.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.25, -3.12, 1.22)
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim", None)
+ aim.location = (0.0, 0.0, 0.63)
+ 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("hitching-post 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/hitching-post/preview.webp b/showcase/hitching-post/preview.webp
new file mode 100644
index 0000000000000000000000000000000000000000..12234113da012f870b62bbbde98d8c2907516805
GIT binary patch
literal 6410
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
Date: Mon, 14 Sep 2026 18:27:04 -0400
Subject: [PATCH 2/2] feat: add grindstone showcase piece
Stone wheel on a timber A-frame with iron axle, hubs, and crank through the shipped bake/LOD/collider/Unity glTF pipeline. --skip-decimate exits 9 on LOD1.
Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Co-authored-by: Cursor
---
.cursor-plugin/plugin.json | 3 +-
AGENTS.md | 2 +-
CLAUDE.md | 2 +-
README.md | 7 +-
ROADMAP.md | 4 +-
docs/gallery/assets/grindstone-hero.webp | Bin 0 -> 15024 bytes
docs/gallery/grindstone/index.html | 1152 ++++++++++++++++++++++
docs/gallery/index.html | 13 +-
showcase/gallery.json | 12 +
showcase/grindstone/README.md | 73 ++
showcase/grindstone/grindstone.py | 828 ++++++++++++++++
showcase/grindstone/preview.webp | Bin 0 -> 12192 bytes
tests/smoke/catalog.json | 3 +-
13 files changed, 2091 insertions(+), 8 deletions(-)
create mode 100644 docs/gallery/assets/grindstone-hero.webp
create mode 100644 docs/gallery/grindstone/index.html
create mode 100644 showcase/grindstone/README.md
create mode 100644 showcase/grindstone/grindstone.py
create mode 100644 showcase/grindstone/preview.webp
diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index 94936d5..090f795 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -153,6 +153,7 @@
"showcase/wheelbarrow",
"showcase/anvil",
"showcase/water-trough",
- "showcase/hitching-post"
+ "showcase/hitching-post",
+ "showcase/grindstone"
]
}
diff --git a/AGENTS.md b/AGENTS.md
index fb82a56..883678e 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 16 showcase pieces (counts are CI-enforced against README.md)
+examples, and 17 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 c83965b..759fb8a 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, 16 pieces (sibling of examples/; see showcase/README.md)
+showcase// - Budget-conformance props, 17 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 4b8c8e6..64d63ed 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 16 showcase pieces
+ 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 17 showcase pieces
@@ -37,7 +37,7 @@
## Overview
-This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 16 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 17 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.
@@ -91,6 +91,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.
@@ -124,6 +125,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show
[`hitching-post`](showcase/hitching-post/) — procedural timber hitching post with a cross-arm and iron rings through the same pipeline. Falsifier `--skip-decimate` exits 9.
+[`grindstone`](showcase/grindstone/) — procedural grindstone on a timber trestle with iron axle and crank through the same pipeline. Falsifier `--skip-decimate` exits 9.
+
## Examples
Runnable, smoke-gated demos live in [`examples/`](examples/) — each is executed headless on
diff --git a/ROADMAP.md b/ROADMAP.md
index 94ac3a6..ef8207a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -120,7 +120,9 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~Blacksmith anvil on a timber stump as a game-prop showcase piece~~ **SHIPPED** as `showcase/anvil/` — London-pattern horn, face, heel, waist, hooped stump; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Staved wooden water trough on a stand as a game-prop showcase piece~~ **SHIPPED** as `showcase/water-trough/` — U-staves, end boards, iron straps, trestle legs; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~Hitching post with iron ring as a game-prop showcase piece~~ **SHIPPED** as `showcase/hitching-post/` — square post, pyramidal cap, cross-arm, iron shoe, bands, two rings; `--skip-decimate` exits 9 on the LOD1 ratio band
-- Grindstone on a timber trestle as a game-prop showcase piece
+- ~~Grindstone on a timber trestle as a game-prop showcase piece~~ **SHIPPED** as `showcase/grindstone/` — stone wheel, A-frame trestle, iron axle, hubs, crank; `--skip-decimate` exits 9 on the LOD1 ratio band
+- Cast-iron village hand pump as a game-prop showcase piece
+- Timber fingerboard signpost 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/grindstone-hero.webp b/docs/gallery/assets/grindstone-hero.webp
new file mode 100644
index 0000000000000000000000000000000000000000..ed1ef06a21e56fa75692116c4a38ce4d0bbedd3b
GIT binary patch
literal 15024
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
+
+
+
+
+ grindstone — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ grindstone
+ A procedural grindstone on a timber trestle 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: 1604 tris, three materials with 80 metal and 26 stone faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.630×0.615×0.575 m, LOD ratios in band, convex collider 130 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/grindstone/grindstone.py --
+
Copy
+
+
+A showcase piece, not an example. Procedural grindstone (stone wheel, timber A-frame trestle, iron axle, hubs, crank, and tray) 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 | 1480–1720 | 1604 / 1604 / 1604 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2195 / 0.2195 / 0.2195 | | Materials | exactly 3 distinct, ≥24 metal and stone faces | 3 slots, 80 metal, 26 stone | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.630, 0.615, 0.575) m ± 0.01 | (0.6300, 0.6150, 0.5750), zmin 0 | | Collider tris | ≤ 180 | 130 | | Export | written, size > 0 | 123436 / 123436 / 123416 bytes |
+DECIMATE COLLAPSE triangle counts are not identical across series — the gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 20 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 grindstone.py --
+blender --background --python grindstone.py -- --skip-decimate
+blender --background --python grindstone.py -- --output grindstone.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 ≠ 3 distinct slots, or wood/stone/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 grindstone — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural grindstone (stone wheel,
+timber trestle, iron axle, hubs, and crank) after composing shipped
+pipeline pieces: bmesh construction, UVs, three materials, high-to-low
+normal bake, LOD chain, convex collider, Unity glTF export.
+
+Budgets are declared below and recomputed from the generated result.
+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 grindstone.py --
+ blender --background --python grindstone.py -- --skip-decimate
+ blender --background --python grindstone.py -- --output grindstone.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+_REPO = os.path.abspath(
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0 , os.path.join(_REPO, "examples" ))
+sys.dont_write_bytecode = True
+import gallery_framing # noqa: E402
+
+STONE_R = 0.235
+STONE_T = 0.058
+STONE_SEGS = 24
+AXLE_Z = 0.34
+FRAME_Y = 0.175
+LEG_SPREAD = 0.28
+LEG_XY = (0.044 , 0.044 )
+AXLE_R = 0.016
+AXLE_LEN = 0.50
+HUB_R = 0.040
+HUB_T = 0.026
+TRAY_L = 0.46
+TRAY_W = 0.26
+TRAY_H = 0.055
+CRANK_ARM = 0.14
+HANDLE_L = 0.10
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (0.630 , 0.615 , 0.575 )
+BASE_TRIS_MIN = 1480
+BASE_TRIS_MAX = 1720
+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 = 3
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 180
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 24
+WOOD_FACES_MIN = 12
+STONE_FACES_MIN = 24
+
+WOOD_IDX = 0
+STONE_IDX = 1
+METAL_IDX = 2
+
+
+def eevee_engine_id():
+ return "BLENDER_EEVEE" if bpy.app.version >= (5 , 0 , 0 ) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+ print(f" ERROR: {msg}" , file=sys.stderr)
+ return code
+
+
+def triangle_count(mesh):
+ mesh.calc_loop_triangles()
+ return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+ depsgraph = bpy.context.evaluated_depsgraph_get()
+ eval_obj = obj.evaluated_get(depsgraph)
+ eval_mesh = eval_obj.to_mesh()
+ try :
+ eval_mesh.calc_loop_triangles()
+ return len(eval_mesh.loop_triangles)
+ finally :
+ eval_obj.to_mesh_clear()
+
+
+def deselect_all():
+ for ob in list(bpy.context.view_layer.objects):
+ if ob is None :
+ continue
+ ob.select_set(False )
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ geo = bmesh.ops.create_cube(bm, size=1.0 )
+ verts = geo["verts" ]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ p = Vector((v.co.x * scale[0 ], v.co.y * scale[1 ], v.co.z * scale[2 ]))
+ v.co = rot @ p + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_oriented_box(bm, a, b, scale_xy, mat_idx):
+ a = Vector(a)
+ b = Vector(b)
+ delta = b - a
+ length = delta.length
+ if length < 1e-8 :
+ return []
+ quat = Vector((0.0 , 0.0 , 1.0 )).rotation_difference(delta.normalized())
+ eul = quat.to_euler("XYZ" )
+ return add_box(
+ bm,
+ ((a + b) * 0.5 ),
+ (scale_xy[0 ], scale_xy[1 ], length),
+ mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
+
+
+def add_cyl(bm, loc, radius, 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=radius,
+ radius2=radius,
+ depth=depth,
+ )
+ verts = 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_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 = 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 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_grindstone_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ try :
+ wood = []
+ stone = []
+ metal = []
+
+ for y in (-FRAME_Y, FRAME_Y):
+ wood.extend(
+ add_oriented_box(
+ bm,
+ (-LEG_SPREAD, y, 0.022 ),
+ (0.0 , y, AXLE_Z),
+ LEG_XY,
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_oriented_box(
+ bm,
+ (LEG_SPREAD, y, 0.022 ),
+ (0.0 , y, AXLE_Z),
+ LEG_XY,
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_box(
+ bm,
+ (0.0 , y, 0.072 ),
+ (LEG_SPREAD * 1.55 , 0.038 , 0.038 ),
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_box(
+ bm,
+ (0.0 , y, AXLE_Z),
+ (0.090 , 0.070 , 0.070 ),
+ WOOD_IDX,
+ )
+ )
+
+ wood.extend(
+ add_box(
+ bm,
+ (0.0 , 0.0 , 0.078 ),
+ (0.070 , FRAME_Y * 2.0 + 0.04 , 0.040 ),
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_box(
+ bm,
+ (0.0 , 0.0 , TRAY_H / 2.0 + 0.004 ),
+ (TRAY_L, TRAY_W, TRAY_H),
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_cyl(
+ bm,
+ (0.0 , FRAME_Y + AXLE_LEN * 0.28 , AXLE_Z + CRANK_ARM),
+ 0.014 ,
+ HANDLE_L,
+ 8 ,
+ WOOD_IDX,
+ euler=(math.pi / 2.0 , 0.0 , 0.0 ),
+ )
+ )
+
+ if bevel_offset > 0.0 :
+ edges = list({e for v in wood for e in v.link_edges})
+ ret = bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5 ,
+ affect="EDGES" ,
+ clamp_overlap=True ,
+ )
+ for f in ret.get("faces" ) or []:
+ f.material_index = WOOD_IDX
+
+ stone.extend(
+ add_cyl(
+ bm,
+ (0.0 , 0.0 , AXLE_Z),
+ STONE_R,
+ STONE_T,
+ STONE_SEGS,
+ STONE_IDX,
+ euler=(math.pi / 2.0 , 0.0 , 0.0 ),
+ )
+ )
+
+ metal.extend(
+ add_cyl(
+ bm,
+ (0.0 , 0.0 , AXLE_Z),
+ AXLE_R,
+ AXLE_LEN,
+ 10 ,
+ METAL_IDX,
+ euler=(math.pi / 2.0 , 0.0 , 0.0 ),
+ )
+ )
+ for y in (-STONE_T / 2.0 - HUB_T / 2.0 + 0.004 , STONE_T / 2.0 + HUB_T / 2.0 - 0.004 ):
+ metal.extend(
+ add_cyl(
+ bm,
+ (0.0 , y, AXLE_Z),
+ HUB_R,
+ HUB_T,
+ 12 ,
+ METAL_IDX,
+ euler=(math.pi / 2.0 , 0.0 , 0.0 ),
+ )
+ )
+ crank_y = FRAME_Y + AXLE_LEN * 0.22
+ metal.extend(
+ add_box(
+ bm,
+ (0.0 , crank_y, AXLE_Z + CRANK_ARM / 2.0 ),
+ (0.018 , 0.018 , CRANK_ARM + 0.02 ),
+ METAL_IDX,
+ )
+ )
+ metal.extend(
+ add_cyl(
+ bm,
+ (0.0 , crank_y, AXLE_Z + CRANK_ARM),
+ 0.010 ,
+ HANDLE_L * 0.55 ,
+ 8 ,
+ METAL_IDX,
+ euler=(math.pi / 2.0 , 0.0 , 0.0 ),
+ )
+ )
+ for y in (-FRAME_Y, FRAME_Y):
+ for x in (-LEG_SPREAD, LEG_SPREAD):
+ metal.extend(
+ add_box(
+ bm,
+ (x, y, 0.018 ),
+ (0.070 , 0.070 , 0.036 ),
+ METAL_IDX,
+ )
+ )
+
+ xs = [v.co.x for v in bm.verts]
+ ys = [v.co.y for v in bm.verts]
+ zs = [v.co.z for v in bm.verts]
+ rcx = 0.5 * (min(xs) + max(xs))
+ rcy = 0.5 * (min(ys) + max(ys))
+ zmin = min(zs)
+ for v in bm.verts:
+ v.co.x -= rcx
+ v.co.y -= rcy
+ v.co.z -= zmin
+ if v.co.z < 0.0 :
+ v.co.z = 0.0
+
+ pack_uvs(bm)
+ bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+ for face in bm.faces:
+ face.smooth = False
+ me = bpy.data.meshes.new(name)
+ bm.to_mesh(me)
+ me.update()
+ for poly in me.polygons:
+ poly.use_smooth = False
+ finally :
+ bm.free()
+ out = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(out)
+ return out
+
+
+def principled(name, color, metallic, roughness):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF" ]
+ bsdf.inputs["Base Color" ].default_value = color
+ bsdf.inputs["Metallic" ].default_value = metallic
+ bsdf.inputs["Roughness" ].default_value = roughness
+ return mat
+
+
+def assign_slots(obj, wood, stone, metal):
+ mats = obj.data.materials
+ wanted = (wood, stone, metal)
+ for i, mat in enumerate(wanted):
+ if i < len(mats):
+ mats[i] = mat
+ else :
+ mats.append(mat)
+
+
+def world_bbox(obj):
+ corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+ xs = [c.x for c in corners]
+ ys = [c.y for c in corners]
+ zs = [c.z for c in corners]
+ return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+ uv = mesh.uv_layers.active
+ if uv is None :
+ return 0.0 , 0.0 , 1.0 , 1.0 , 0 , 1.0
+ data = uv.data
+ us = [loop.uv[0 ] for loop in data]
+ vs = [loop.uv[1 ] for loop in data]
+ aabbs = []
+ for poly in mesh.polygons:
+ pu = [data[i].uv[0 ] for i in poly.loop_indices]
+ pv = [data[i].uv[1 ] for i in poly.loop_indices]
+ aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+ overlap = 0.0
+ for i in range(len(aabbs)):
+ a = aabbs[i]
+ for j in range(i + 1 , len(aabbs)):
+ b = aabbs[j]
+ x0 = max(a[0 ], b[0 ])
+ y0 = max(a[1 ], b[1 ])
+ x1 = min(a[2 ], b[2 ])
+ y1 = min(a[3 ], b[3 ])
+ overlap += max(0.0 , x1 - x0) * max(0.0 , y1 - y0)
+ return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+ mesh = obj.data.copy()
+ lod = bpy.data.objects.new(name, mesh)
+ lod.matrix_world = obj.matrix_world.copy()
+ bpy.context.scene.collection.objects.link(lod)
+ if not skip_decimate and 0.0 < ratio < 1.0 :
+ mod = lod.modifiers.new("DecimateBudget" , "DECIMATE" )
+ mod.decimate_type = "COLLAPSE"
+ mod.ratio = ratio
+ return lod
+
+
+def convex_hull_collider(obj, name):
+ mesh = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try :
+ bm.from_mesh(obj.data)
+ result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+ interior = result.get("geom_interior" ) or []
+ unused = result.get("geom_unused" ) or []
+ if interior:
+ bmesh.ops.delete(bm, geom=interior, context="VERTS" )
+ if unused:
+ bmesh.ops.delete(bm, geom=unused, context="VERTS" )
+ bm.to_mesh(mesh)
+ mesh.update()
+ finally :
+ bm.free()
+ collider = bpy.data.objects.new(name, mesh)
+ bpy.context.collection.objects.link(collider)
+ collider.matrix_world = obj.matrix_world.copy()
+ return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+ if not obj.data.uv_layers:
+ return None , None
+ img = bpy.data.images.new("GrindstoneNrm" , 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 = STONE_IDX
+ return img, tex
+
+
+def bake_normal(high, low):
+ scene = bpy.context.scene
+ scene.render.engine = "CYCLES"
+ scene.cycles.device = "CPU"
+ scene.cycles.samples = 1
+ scene.cycles.use_denoising = False
+ deselect_all()
+ high.select_set(True )
+ low.select_set(True )
+ bpy.context.view_layer.objects.active = low
+ return bpy.ops.object.bake(
+ type="NORMAL" ,
+ use_selected_to_active=True ,
+ cage_extrusion=CAGE_EXTRUSION,
+ use_cage=False ,
+ normal_space="TANGENT" ,
+ margin=4 ,
+ margin_type="ADJACENT_FACES" ,
+ use_clear=True ,
+ target="IMAGE_TEXTURES" ,
+ )
+
+
+def export_unity(path, objects):
+ deselect_all()
+ for ob in objects:
+ ob.select_set(True )
+ bpy.context.view_layer.objects.active = objects[0 ]
+ bpy.ops.export_scene.gltf(
+ filepath=path,
+ use_selection=True ,
+ export_yup=True ,
+ export_apply=True ,
+ export_draco_mesh_compression_enable=False ,
+ export_animations=False ,
+ )
+
+
+def check(skip_decimate):
+ bpy.ops.wm.read_factory_settings(use_empty=True )
+ low = build_grindstone_mesh("GrindstoneLow" , bevel_offset=0.004 , bevel_segments=2 )
+ high = build_grindstone_mesh("GrindstoneHigh" , bevel_offset=0.004 , bevel_segments=4 )
+ wood = principled("GrindstoneWood" , (0.36 , 0.19 , 0.07 , 1.0 ), 0.0 , 0.60 )
+ stone = principled("GrindstoneStone" , (0.46 , 0.44 , 0.41 , 1.0 ), 0.0 , 0.86 )
+ metal = principled("GrindstoneIron" , (0.14 , 0.145 , 0.155 , 1.0 ), 1.0 , 0.38 )
+ assign_slots(low, wood, stone, metal)
+ assign_slots(high, wood, stone, metal)
+
+ if low.data is None or len(low.data.polygons) < 6 :
+ return fail("grindstone 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, stone)
+ if img is None :
+ return fail("grindstone has no UV layer" , 3 ), None , None , None , None , None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "GrindstoneLOD1" , LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "GrindstoneLOD2" , 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_grindstone_mesh(
+ "GrindstoneColSrc" , bevel_offset=0.0 , bevel_segments=1
+ )
+ collider = convex_hull_collider(collider_src, "GrindstoneCollider" )
+ bpy.data.objects.remove(collider_src, do_unlink=True )
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f" bdt_grindstone_ {os.getpid()}.glb " ,
+ )
+ if os.path.exists(export_path):
+ os.remove(export_path)
+ export_unity(export_path, [low, collider])
+ export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+ print(f" blender= {tuple(bpy.app.version)} skip_decimate= {skip_decimate}" )
+ print(
+ f" measured base_tris= {base_tris} lod1_tris= {lod1_tris} "
+ f" lod2_tris= {lod2_tris} r1= {r1:.4f } r2= {r2:.4f }"
+ )
+ print(
+ f" measured nmat= {nmat} uv=( {u0:.4f }, {v0:.4f })-( {u1:.4f }, {v1:.4f }) "
+ f" overlap= {overlap:.6f } nfaces= {nfaces}"
+ )
+ print(
+ f" measured bbox=( {size_x:.4f }, {size_y:.4f }, {size_z:.4f }) "
+ f" outer= {OUTER_SIZE} zmin= {bb[2 ]:.4f }"
+ )
+ print(
+ f" measured collider_tris= {col_tris} bake= {bake_result} "
+ f" bake_has_data= {img.has_data} export_bytes= {export_size}"
+ )
+
+ if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+ return fail(
+ f" base tris {base_tris} not in [ {BASE_TRIS_MIN}, {BASE_TRIS_MAX}] " ,
+ 4 ,
+ ), None , None , None , None , None
+ if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+ return fail(
+ f" material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}" ,
+ 5 ,
+ ), None , None , None , None , None
+ if idx_counts.get(METAL_IDX, 0 ) < METAL_FACES_MIN:
+ return fail(
+ f" metal faces {idx_counts.get(METAL_IDX, 0 )} < {METAL_FACES_MIN}" ,
+ 5 ,
+ ), None , None , None , None , None
+ if idx_counts.get(STONE_IDX, 0 ) < STONE_FACES_MIN:
+ return fail(
+ f" stone faces {idx_counts.get(STONE_IDX, 0 )} < {STONE_FACES_MIN}" ,
+ 5 ,
+ ), None , None , None , None , None
+ if idx_counts.get(WOOD_IDX, 0 ) < WOOD_FACES_MIN:
+ return fail(
+ f" wood faces {idx_counts.get(WOOD_IDX, 0 )} < {WOOD_FACES_MIN}" ,
+ 5 ,
+ ), None , None , None , None , None
+ if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+ return fail(
+ f" UVs outside 0..1: ( {u0:.4f }, {v0:.4f })-( {u1:.4f }, {v1:.4f }) " ,
+ 6 ,
+ ), None , None , None , None , None
+ if overlap > UV_OVERLAP_MAX:
+ return fail(
+ f" UV AABB overlap {overlap:.6f } > {UV_OVERLAP_MAX}" ,
+ 7 ,
+ ), None , None , None , None , None
+ if (
+ abs(size_x - OUTER_SIZE[0 ]) > BBOX_TOL
+ or abs(size_y - OUTER_SIZE[1 ]) > BBOX_TOL
+ or abs(size_z - OUTER_SIZE[2 ]) > BBOX_TOL
+ ):
+ return fail(
+ f" bbox ( {size_x:.4f }, {size_y:.4f }, {size_z:.4f }) "
+ f" off outer {OUTER_SIZE}" ,
+ 8 ,
+ ), None , None , None , None , None
+ if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+ return fail(
+ f" LOD1 ratio {r1:.4f } not in [ {LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+ "(--skip-decimate is the designed fail)" ,
+ 9 ,
+ ), None , None , None , None , None
+ if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+ return fail(
+ f" LOD2 ratio {r2:.4f } not in [ {LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}] " ,
+ 9 ,
+ ), None , None , None , None , None
+ if col_tris > COLLIDER_TRIS_MAX:
+ return fail(
+ f" collider tris {col_tris} > {COLLIDER_TRIS_MAX}" ,
+ 11 ,
+ ), None , None , None , None , None
+ if bake_result != {"FINISHED" } or not img.has_data:
+ return fail(
+ f" bake failed result= {bake_result} has_data= {img.has_data}" ,
+ 12 ,
+ ), None , None , None , None , None
+ if export_size <= 0 :
+ return fail("export file missing or empty" , 13 ), None , None , None , None , None
+ return 0 , low, high, stone, 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(-16.0 )
+ low.rotation_euler.x = math.radians(0.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.48 , -1.82 , 0.86 )
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim" , None )
+ aim.location = (0.0 , 0.0 , 0.28 )
+ 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, hero_mat, tex, _col = check(args.skip_decimate)
+ if code:
+ return code
+ if args.output:
+ rcode = render_still(low, hero_mat, tex, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
+ print(f" rendered still {args.output}" )
+ print("grindstone 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 d6f91d6..bf147e1 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" />
×
- 67 examples
+ 68 examples
+
+
+
+
+
+
+
A procedural grindstone on a timber trestle through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
+
witnesses Recomputed: 1604 tris, three materials with 80 metal and 26 stone faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.630×0.615×0.575 m, LOD ratios in band, convex collider 130 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 d3afae2..ee6f359 100644
--- a/showcase/gallery.json
+++ b/showcase/gallery.json
@@ -196,6 +196,18 @@
"mesh",
"export"
]
+ },
+ {
+ "name": "grindstone",
+ "dir": "showcase/grindstone",
+ "teaches": "A procedural grindstone on a timber trestle through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
+ "witnessesFix": "Recomputed: 1604 tris, three materials with 80 metal and 26 stone faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.630×0.615×0.575 m, LOD ratios in band, convex collider 130 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
+ "hero": "docs/gallery/assets/grindstone-hero.webp",
+ "preview": "showcase/grindstone/preview.webp",
+ "tags": [
+ "mesh",
+ "export"
+ ]
}
]
}
diff --git a/showcase/grindstone/README.md b/showcase/grindstone/README.md
new file mode 100644
index 0000000..31519a0
--- /dev/null
+++ b/showcase/grindstone/README.md
@@ -0,0 +1,73 @@
+# Grindstone
+
+A showcase piece, not an example. Procedural grindstone (stone wheel,
+timber A-frame trestle, iron axle, hubs, crank, and tray) 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 | 1480–1720 | 1604 / 1604 / 1604 |
+| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 |
+| LOD2 ratio | 0.10–0.35 of base | 0.2195 / 0.2195 / 0.2195 |
+| Materials | exactly 3 distinct, ≥24 metal and stone faces | 3 slots, 80 metal, 26 stone |
+| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
+| Outer AABB | (0.630, 0.615, 0.575) m ± 0.01 | (0.6300, 0.6150, 0.5750), zmin 0 |
+| Collider tris | ≤ 180 | 130 |
+| Export | written, size > 0 | 123436 / 123436 / 123416 bytes |
+
+DECIMATE COLLAPSE triangle counts are **not** identical across series —
+the gate is a ratio band, not an exact count. Bake pixels are
+stochastic; the gate is `has_data` plus operator `FINISHED`, not
+byte-identity. Construction uses no RNG. Export byte counts differ by
+20 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 grindstone.py --
+blender --background --python grindstone.py -- --skip-decimate
+blender --background --python grindstone.py -- --output grindstone.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 ≠ 3 distinct slots, or wood/stone/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/grindstone/grindstone.py b/showcase/grindstone/grindstone.py
new file mode 100644
index 0000000..d6741e0
--- /dev/null
+++ b/showcase/grindstone/grindstone.py
@@ -0,0 +1,828 @@
+"""Game-ready grindstone — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural grindstone (stone wheel,
+timber trestle, iron axle, hubs, and crank) after composing shipped
+pipeline pieces: bmesh construction, UVs, three materials, high-to-low
+normal bake, LOD chain, convex collider, Unity glTF export.
+
+Budgets are declared below and recomputed from the generated result.
+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 grindstone.py --
+ blender --background --python grindstone.py -- --skip-decimate
+ blender --background --python grindstone.py -- --output grindstone.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+_REPO = os.path.abspath(
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing # noqa: E402
+
+STONE_R = 0.235
+STONE_T = 0.058
+STONE_SEGS = 24
+AXLE_Z = 0.34
+FRAME_Y = 0.175
+LEG_SPREAD = 0.28
+LEG_XY = (0.044, 0.044)
+AXLE_R = 0.016
+AXLE_LEN = 0.50
+HUB_R = 0.040
+HUB_T = 0.026
+TRAY_L = 0.46
+TRAY_W = 0.26
+TRAY_H = 0.055
+CRANK_ARM = 0.14
+HANDLE_L = 0.10
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (0.630, 0.615, 0.575)
+BASE_TRIS_MIN = 1480
+BASE_TRIS_MAX = 1720
+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 = 3
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 180
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 24
+WOOD_FACES_MIN = 12
+STONE_FACES_MIN = 24
+
+WOOD_IDX = 0
+STONE_IDX = 1
+METAL_IDX = 2
+
+
+def eevee_engine_id():
+ return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+ print(f"ERROR: {msg}", file=sys.stderr)
+ return code
+
+
+def triangle_count(mesh):
+ mesh.calc_loop_triangles()
+ return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+ depsgraph = bpy.context.evaluated_depsgraph_get()
+ eval_obj = obj.evaluated_get(depsgraph)
+ eval_mesh = eval_obj.to_mesh()
+ try:
+ eval_mesh.calc_loop_triangles()
+ return len(eval_mesh.loop_triangles)
+ finally:
+ eval_obj.to_mesh_clear()
+
+
+def deselect_all():
+ for ob in list(bpy.context.view_layer.objects):
+ if ob is None:
+ continue
+ ob.select_set(False)
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+ geo = bmesh.ops.create_cube(bm, size=1.0)
+ verts = geo["verts"]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+ v.co = rot @ p + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_oriented_box(bm, a, b, scale_xy, mat_idx):
+ a = Vector(a)
+ b = Vector(b)
+ delta = b - a
+ length = delta.length
+ if length < 1e-8:
+ return []
+ quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+ eul = quat.to_euler("XYZ")
+ return add_box(
+ bm,
+ ((a + b) * 0.5),
+ (scale_xy[0], scale_xy[1], length),
+ mat_idx,
+ euler=(eul.x, eul.y, eul.z),
+ )
+
+
+def add_cyl(bm, loc, radius, 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=radius,
+ radius2=radius,
+ depth=depth,
+ )
+ verts = 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_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 = 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 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_grindstone_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ try:
+ wood = []
+ stone = []
+ metal = []
+
+ for y in (-FRAME_Y, FRAME_Y):
+ wood.extend(
+ add_oriented_box(
+ bm,
+ (-LEG_SPREAD, y, 0.022),
+ (0.0, y, AXLE_Z),
+ LEG_XY,
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_oriented_box(
+ bm,
+ (LEG_SPREAD, y, 0.022),
+ (0.0, y, AXLE_Z),
+ LEG_XY,
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_box(
+ bm,
+ (0.0, y, 0.072),
+ (LEG_SPREAD * 1.55, 0.038, 0.038),
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_box(
+ bm,
+ (0.0, y, AXLE_Z),
+ (0.090, 0.070, 0.070),
+ WOOD_IDX,
+ )
+ )
+
+ wood.extend(
+ add_box(
+ bm,
+ (0.0, 0.0, 0.078),
+ (0.070, FRAME_Y * 2.0 + 0.04, 0.040),
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_box(
+ bm,
+ (0.0, 0.0, TRAY_H / 2.0 + 0.004),
+ (TRAY_L, TRAY_W, TRAY_H),
+ WOOD_IDX,
+ )
+ )
+ wood.extend(
+ add_cyl(
+ bm,
+ (0.0, FRAME_Y + AXLE_LEN * 0.28, AXLE_Z + CRANK_ARM),
+ 0.014,
+ HANDLE_L,
+ 8,
+ WOOD_IDX,
+ euler=(math.pi / 2.0, 0.0, 0.0),
+ )
+ )
+
+ if bevel_offset > 0.0:
+ edges = list({e for v in wood for e in v.link_edges})
+ ret = bmesh.ops.bevel(
+ bm,
+ geom=edges,
+ offset=bevel_offset,
+ segments=bevel_segments,
+ profile=0.5,
+ affect="EDGES",
+ clamp_overlap=True,
+ )
+ for f in ret.get("faces") or []:
+ f.material_index = WOOD_IDX
+
+ stone.extend(
+ add_cyl(
+ bm,
+ (0.0, 0.0, AXLE_Z),
+ STONE_R,
+ STONE_T,
+ STONE_SEGS,
+ STONE_IDX,
+ euler=(math.pi / 2.0, 0.0, 0.0),
+ )
+ )
+
+ metal.extend(
+ add_cyl(
+ bm,
+ (0.0, 0.0, AXLE_Z),
+ AXLE_R,
+ AXLE_LEN,
+ 10,
+ METAL_IDX,
+ euler=(math.pi / 2.0, 0.0, 0.0),
+ )
+ )
+ for y in (-STONE_T / 2.0 - HUB_T / 2.0 + 0.004, STONE_T / 2.0 + HUB_T / 2.0 - 0.004):
+ metal.extend(
+ add_cyl(
+ bm,
+ (0.0, y, AXLE_Z),
+ HUB_R,
+ HUB_T,
+ 12,
+ METAL_IDX,
+ euler=(math.pi / 2.0, 0.0, 0.0),
+ )
+ )
+ crank_y = FRAME_Y + AXLE_LEN * 0.22
+ metal.extend(
+ add_box(
+ bm,
+ (0.0, crank_y, AXLE_Z + CRANK_ARM / 2.0),
+ (0.018, 0.018, CRANK_ARM + 0.02),
+ METAL_IDX,
+ )
+ )
+ metal.extend(
+ add_cyl(
+ bm,
+ (0.0, crank_y, AXLE_Z + CRANK_ARM),
+ 0.010,
+ HANDLE_L * 0.55,
+ 8,
+ METAL_IDX,
+ euler=(math.pi / 2.0, 0.0, 0.0),
+ )
+ )
+ for y in (-FRAME_Y, FRAME_Y):
+ for x in (-LEG_SPREAD, LEG_SPREAD):
+ metal.extend(
+ add_box(
+ bm,
+ (x, y, 0.018),
+ (0.070, 0.070, 0.036),
+ METAL_IDX,
+ )
+ )
+
+ xs = [v.co.x for v in bm.verts]
+ ys = [v.co.y for v in bm.verts]
+ zs = [v.co.z for v in bm.verts]
+ rcx = 0.5 * (min(xs) + max(xs))
+ rcy = 0.5 * (min(ys) + max(ys))
+ zmin = min(zs)
+ for v in bm.verts:
+ v.co.x -= rcx
+ v.co.y -= rcy
+ v.co.z -= zmin
+ if v.co.z < 0.0:
+ v.co.z = 0.0
+
+ pack_uvs(bm)
+ bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+ for face in bm.faces:
+ face.smooth = False
+ me = bpy.data.meshes.new(name)
+ bm.to_mesh(me)
+ me.update()
+ for poly in me.polygons:
+ poly.use_smooth = False
+ finally:
+ bm.free()
+ out = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(out)
+ return out
+
+
+def principled(name, color, metallic, roughness):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF"]
+ bsdf.inputs["Base Color"].default_value = color
+ bsdf.inputs["Metallic"].default_value = metallic
+ bsdf.inputs["Roughness"].default_value = roughness
+ return mat
+
+
+def assign_slots(obj, wood, stone, metal):
+ mats = obj.data.materials
+ wanted = (wood, stone, metal)
+ for i, mat in enumerate(wanted):
+ if i < len(mats):
+ mats[i] = mat
+ else:
+ mats.append(mat)
+
+
+def world_bbox(obj):
+ corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+ xs = [c.x for c in corners]
+ ys = [c.y for c in corners]
+ zs = [c.z for c in corners]
+ return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+ uv = mesh.uv_layers.active
+ if uv is None:
+ return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+ data = uv.data
+ us = [loop.uv[0] for loop in data]
+ vs = [loop.uv[1] for loop in data]
+ aabbs = []
+ for poly in mesh.polygons:
+ pu = [data[i].uv[0] for i in poly.loop_indices]
+ pv = [data[i].uv[1] for i in poly.loop_indices]
+ aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+ overlap = 0.0
+ for i in range(len(aabbs)):
+ a = aabbs[i]
+ for j in range(i + 1, len(aabbs)):
+ b = aabbs[j]
+ x0 = max(a[0], b[0])
+ y0 = max(a[1], b[1])
+ x1 = min(a[2], b[2])
+ y1 = min(a[3], b[3])
+ overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+ return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+ mesh = obj.data.copy()
+ lod = bpy.data.objects.new(name, mesh)
+ lod.matrix_world = obj.matrix_world.copy()
+ bpy.context.scene.collection.objects.link(lod)
+ if not skip_decimate and 0.0 < ratio < 1.0:
+ mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+ mod.decimate_type = "COLLAPSE"
+ mod.ratio = ratio
+ return lod
+
+
+def convex_hull_collider(obj, name):
+ mesh = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(obj.data)
+ result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+ interior = result.get("geom_interior") or []
+ unused = result.get("geom_unused") or []
+ if interior:
+ bmesh.ops.delete(bm, geom=interior, context="VERTS")
+ if unused:
+ bmesh.ops.delete(bm, geom=unused, context="VERTS")
+ bm.to_mesh(mesh)
+ mesh.update()
+ finally:
+ bm.free()
+ collider = bpy.data.objects.new(name, mesh)
+ bpy.context.collection.objects.link(collider)
+ collider.matrix_world = obj.matrix_world.copy()
+ return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+ if not obj.data.uv_layers:
+ return None, None
+ img = bpy.data.images.new("GrindstoneNrm", 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 = STONE_IDX
+ return img, tex
+
+
+def bake_normal(high, low):
+ scene = bpy.context.scene
+ scene.render.engine = "CYCLES"
+ scene.cycles.device = "CPU"
+ scene.cycles.samples = 1
+ scene.cycles.use_denoising = False
+ deselect_all()
+ high.select_set(True)
+ low.select_set(True)
+ bpy.context.view_layer.objects.active = low
+ return bpy.ops.object.bake(
+ type="NORMAL",
+ use_selected_to_active=True,
+ cage_extrusion=CAGE_EXTRUSION,
+ use_cage=False,
+ normal_space="TANGENT",
+ margin=4,
+ margin_type="ADJACENT_FACES",
+ use_clear=True,
+ target="IMAGE_TEXTURES",
+ )
+
+
+def export_unity(path, objects):
+ deselect_all()
+ for ob in objects:
+ ob.select_set(True)
+ bpy.context.view_layer.objects.active = objects[0]
+ bpy.ops.export_scene.gltf(
+ filepath=path,
+ use_selection=True,
+ export_yup=True,
+ export_apply=True,
+ export_draco_mesh_compression_enable=False,
+ export_animations=False,
+ )
+
+
+def check(skip_decimate):
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ low = build_grindstone_mesh("GrindstoneLow", bevel_offset=0.004, bevel_segments=2)
+ high = build_grindstone_mesh("GrindstoneHigh", bevel_offset=0.004, bevel_segments=4)
+ wood = principled("GrindstoneWood", (0.36, 0.19, 0.07, 1.0), 0.0, 0.60)
+ stone = principled("GrindstoneStone", (0.46, 0.44, 0.41, 1.0), 0.0, 0.86)
+ metal = principled("GrindstoneIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38)
+ assign_slots(low, wood, stone, metal)
+ assign_slots(high, wood, stone, metal)
+
+ if low.data is None or len(low.data.polygons) < 6:
+ return fail("grindstone 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, stone)
+ if img is None:
+ return fail("grindstone has no UV layer", 3), None, None, None, None, None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "GrindstoneLOD1", LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "GrindstoneLOD2", 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_grindstone_mesh(
+ "GrindstoneColSrc", bevel_offset=0.0, bevel_segments=1
+ )
+ collider = convex_hull_collider(collider_src, "GrindstoneCollider")
+ bpy.data.objects.remove(collider_src, do_unlink=True)
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f"bdt_grindstone_{os.getpid()}.glb",
+ )
+ if os.path.exists(export_path):
+ os.remove(export_path)
+ export_unity(export_path, [low, collider])
+ export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+ print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
+ print(
+ f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+ f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+ )
+ print(
+ f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+ f"overlap={overlap:.6f} nfaces={nfaces}"
+ )
+ print(
+ f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+ f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
+ )
+ print(
+ f"measured collider_tris={col_tris} bake={bake_result} "
+ f"bake_has_data={img.has_data} export_bytes={export_size}"
+ )
+
+ if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+ return fail(
+ f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
+ 4,
+ ), None, None, None, None, None
+ if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+ return fail(
+ f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
+ 5,
+ ), None, None, None, None, None
+ if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+ return fail(
+ f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
+ 5,
+ ), None, None, None, None, None
+ if idx_counts.get(STONE_IDX, 0) < STONE_FACES_MIN:
+ return fail(
+ f"stone faces {idx_counts.get(STONE_IDX, 0)} < {STONE_FACES_MIN}",
+ 5,
+ ), None, None, None, None, None
+ if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN:
+ return fail(
+ f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}",
+ 5,
+ ), None, None, None, None, None
+ if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+ return fail(
+ f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+ 6,
+ ), None, None, None, None, None
+ if overlap > UV_OVERLAP_MAX:
+ return fail(
+ f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+ 7,
+ ), None, None, None, None, None
+ if (
+ abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+ or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+ or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+ ):
+ return fail(
+ f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+ f"off outer {OUTER_SIZE}",
+ 8,
+ ), None, None, None, None, None
+ if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+ return fail(
+ f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+ "(--skip-decimate is the designed fail)",
+ 9,
+ ), None, None, None, None, None
+ if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+ return fail(
+ f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+ 9,
+ ), None, None, None, None, None
+ if col_tris > COLLIDER_TRIS_MAX:
+ return fail(
+ f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+ 11,
+ ), None, None, None, None, None
+ if bake_result != {"FINISHED"} or not img.has_data:
+ return fail(
+ f"bake failed result={bake_result} has_data={img.has_data}",
+ 12,
+ ), None, None, None, None, None
+ if export_size <= 0:
+ return fail("export file missing or empty", 13), None, None, None, None, None
+ return 0, low, high, stone, 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(-16.0)
+ low.rotation_euler.x = math.radians(0.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.48, -1.82, 0.86)
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim", None)
+ aim.location = (0.0, 0.0, 0.28)
+ 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, hero_mat, tex, _col = check(args.skip_decimate)
+ if code:
+ return code
+ if args.output:
+ rcode = render_still(low, hero_mat, tex, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
+ print(f"rendered still {args.output}")
+ print("grindstone 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/grindstone/preview.webp b/showcase/grindstone/preview.webp
new file mode 100644
index 0000000000000000000000000000000000000000..3a1bcf8342dbaf26fb6cf1f52eb7713cb7a251e7
GIT binary patch
literal 12192
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
literal 0
HcmV?d00001
diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json
index 8568b1e..05cf588 100644
--- a/tests/smoke/catalog.json
+++ b/tests/smoke/catalog.json
@@ -89,5 +89,6 @@
{"name": "wheelbarrow", "script": "showcase/wheelbarrow/wheelbarrow.py"},
{"name": "anvil", "script": "showcase/anvil/anvil.py"},
{"name": "water-trough", "script": "showcase/water-trough/water_trough.py"},
- {"name": "hitching-post", "script": "showcase/hitching-post/hitching_post.py"}
+ {"name": "hitching-post", "script": "showcase/hitching-post/hitching_post.py"},
+ {"name": "grindstone", "script": "showcase/grindstone/grindstone.py"}
]