From 1a17418d795101d92d45f9de7cf4dca8c31a6f9c Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:13:55 -0400 Subject: [PATCH 1/2] feat: add park-bench showcase piece Wrought-iron slatted bench with scrolled legs through the shipped budget pipeline. --skip-decimate exits 9 on the LOD1 ratio band. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 3 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 7 +- ROADMAP.md | 2 +- docs/gallery/assets/park-bench-hero.webp | Bin 0 -> 25368 bytes docs/gallery/index.html | 13 +- docs/gallery/park-bench/index.html | 1080 ++++++++++++++++++++++ docs/gallery/watchtower/index.html | 2 +- showcase/gallery.json | 12 + showcase/park-bench/README.md | 74 ++ showcase/park-bench/park_bench.py | 756 +++++++++++++++ showcase/park-bench/preview.webp | Bin 0 -> 21340 bytes tests/smoke/catalog.json | 3 +- 14 files changed, 1947 insertions(+), 9 deletions(-) create mode 100644 docs/gallery/assets/park-bench-hero.webp create mode 100644 docs/gallery/park-bench/index.html create mode 100644 showcase/park-bench/README.md create mode 100644 showcase/park-bench/park_bench.py create mode 100644 showcase/park-bench/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index c764713..c1d2462 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -148,6 +148,7 @@ "showcase/terrain-scatter", "showcase/fence-kit", "showcase/watchtower", - "showcase/cart" + "showcase/cart", + "showcase/park-bench" ] } diff --git a/AGENTS.md b/AGENTS.md index 17d2def..f308065 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ classifies it as a `cursor-plugin`. This is content the AI loads when the user asks Blender questions or works on Blender add-ons in Cursor or Claude Code. The content base is 16 skills, 9 rules, 3 templates, 27 snippets, 59 -examples, and 11 showcase pieces (counts are CI-enforced against README.md +examples, and 12 showcase pieces (counts are CI-enforced against README.md) and the manifest). The full inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy and authoring rules: copy `examples/bmesh-gear/`; showcase conventions: `showcase/README.md`. The render look is specified diff --git a/CLAUDE.md b/CLAUDE.md index 45a70d1..dbd6a1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ rules/.mdc - Anti-pattern rules, 9 total templates// - Starter projects, 3 total snippets/.py - Standalone code patterns, 27 total examples// - Runnable smoke-gated examples, 59 total (+ gallery.json) -showcase// - Budget-conformance props, 11 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 12 pieces (sibling of examples/; see showcase/README.md) scripts/build_gallery.py - Regenerates docs/gallery/ from examples/gallery.json + showcase/gallery.json scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders diff --git a/README.md b/README.md index 2bb0a70..e5f4b11 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  11 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  12 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 11 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. +This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 12 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. The content is consumed by AI coding agents (Cursor, Claude Code, any MCP-capable client) when working on Blender add-ons, geometry nodes scripts, batch pipelines, or animation tooling. There is no build step. Edit the markdown and Python files directly. @@ -86,6 +86,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Fence kit: a timber post-and-rail section with pyramidal caps, iron shoes and straps on a dark studio floor Watchtower: a timber lookout with X-braces, hatch ladder, plank platform, and a coursed shake roof on a dark studio floor Cart: a two-wheel wooden cart with spoked wheels, slatted bed, and shafts on a dark studio floor +Park bench: a wrought-iron bench with scrolled legs and wooden slats on a dark studio floor [`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. @@ -109,6 +110,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`cart`](showcase/cart/) — procedural two-wheel wooden cart with spoked wheels, slatted bed, and shafts through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`park-bench`](showcase/park-bench/) — procedural wrought-iron park bench with scrolled legs and slatted seat through the same pipeline. Falsifier `--skip-decimate` exits 9. + ## Examples Runnable, smoke-gated demos live in [`examples/`](examples/) — each is executed headless on diff --git a/ROADMAP.md b/ROADMAP.md index 05440a8..8179a94 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -115,7 +115,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Hero prop with a more complex silhouette (lantern or treasure chest) as a second showcase piece~~ **SHIPPED** as `showcase/street-lantern/` and `showcase/treasure-chest/` — hanging iron lantern with muntined amber cage; slatted chest with iron bands and an open lid; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Timber watchtower showcase~~ **SHIPPED** as `showcase/watchtower/` — corner posts through a plank platform, lower-bay X-braces, hatch ladder, coursed shake roof; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Two-wheel wooden cart showcase~~ **SHIPPED** as `showcase/cart/` — slatted bed, shafts, spoked wheels, iron hubs and axle; `--skip-decimate` exits 9 on the LOD1 ratio band -- Wrought-iron park bench (slatted seat, scrolled legs) as a game-prop showcase piece +- ~~Wrought-iron park bench (slatted seat, scrolled legs) as a game-prop showcase piece~~ **SHIPPED** as `showcase/park-bench/` — scrolled iron legs, slatted seat and back; `--skip-decimate` exits 9 on the LOD1 ratio band - Wooden wheelbarrow (staved tray, single wheel, handles) as a game-prop showcase piece - ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **SHIPPED** as `showcase/fence-kit/` — tiling post-and-rail section (AABB X is the tile width; does not re-witness the snap contract); `--skip-decimate` exits 9 on the LOD1 ratio band diff --git a/docs/gallery/assets/park-bench-hero.webp b/docs/gallery/assets/park-bench-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..9d4d4ebf1485926d8a4360595123fa62703b21bf GIT binary patch literal 25368 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 H00000-5^ES literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index ea429dd..ba1f9f5 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -272,7 +272,7 @@

Examples Gallery

autocomplete="off" spellcheck="false" aria-label="Search examples" /> - 62 examples + 63 examples
@@ -1006,6 +1006,17 @@

cart

View example
+
+ + park-bench — A procedural wrought-iron park bench through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract. + +
+

park-bench

+

A procedural wrought-iron park bench through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 2100 tris, two materials with 510 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.080×0.566×0.853 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 106 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+ View example +
+
diff --git a/docs/gallery/park-bench/index.html b/docs/gallery/park-bench/index.html new file mode 100644 index 0000000..a7246da --- /dev/null +++ b/docs/gallery/park-bench/index.html @@ -0,0 +1,1080 @@ + + + + + + park-bench — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

park-bench

+

A procedural wrought-iron park bench through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses Recomputed: 2100 tris, two materials with 510 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.080×0.566×0.853 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 106 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/park-bench/park_bench.py --
+ +
+
+

A showcase piece, not an example. Procedural wrought-iron park bench (scrolled legs, slatted seat and back, armrests) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.

+

Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied, not imported as a package).

+

Budgets

+

Declared as named constants; every gate recomputes from the mesh, materials, UVs, evaluated LOD, collider, or export file.

+

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 2000–2200 | 2100 / 2100 / 2100 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4952 | | LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.1400 | | Materials | exactly 2 distinct, ≥48 metal faces | 2 slots, 510 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.080, 0.566, 0.853) m ± 0.01 | (1.0800, 0.5657, 0.8531), zmin 0 | | Collider tris | ≤ 140 | 106 | | Export | written, size > 0 | 159868 / 159868 / 159852 bytes |

+

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a gated axis.

+

--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates.

+

Run

+
blender --background --python park_bench.py --
+blender --background --python park_bench.py -- --skip-decimate
+blender --background --python park_bench.py -- --output bench.png
+

Smoke does not pass --output or --skip-decimate.

+

Exit codes

+

File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path.

+

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 2 distinct slots, or wood/metal faces missing | | 6 | UVs outside 0..1 | | 7 | UV AABB overlap above tolerance | | 8 | World AABB off declared outer size | | 9 | LOD ratio band (--skip-decimate lands here) | | 10 | Framing gate (render path only) | | 11 | Collider triangle count above ceiling | | 12 | Bake did not finish or image has no data | | 13 | Export file missing or empty | | 14 | --output produced no file |

+
+
+

Source

+
+ showcase/park-bench/park_bench.py + View on GitHub → +
+
"""Game-ready park bench — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural wrought-iron bench (scrolled
+legs, slatted seat and back) after composing shipped pipeline pieces:
+bmesh construction, UVs, two materials, high-to-low normal bake, LOD
+chain, convex collider, Unity glTF export.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
+DECIMATE stage so the LOD-ratio budget fails.
+
+No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
+are not byte-identical across Blender versions — the LOD gate is a
+ratio band, not an exact count.
+
+    blender --background --python park_bench.py --
+    blender --background --python park_bench.py -- --skip-decimate
+    blender --background --python park_bench.py -- --output bench.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+SEAT_W = 1.18
+SEAT_D = 0.46
+SEAT_Z = 0.44
+N_SEAT = 6
+N_BACK = 4
+SLAT_T = 0.032
+BACK_H = 0.40
+IRON_T = 0.026
+SCROLL_R = 0.10
+SCROLL_N = 12
+ARM_Z = 0.62
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (1.080, 0.566, 0.853)
+BASE_TRIS_MIN = 2000
+BASE_TRIS_MAX = 2200
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 2
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 140
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 48
+WOOD_FACES_MIN = 24
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    depsgraph = bpy.context.evaluated_depsgraph_get()
+    eval_obj = obj.evaluated_get(depsgraph)
+    eval_mesh = eval_obj.to_mesh()
+    try:
+        eval_mesh.calc_loop_triangles()
+        return len(eval_mesh.loop_triangles)
+    finally:
+        eval_obj.to_mesh_clear()
+
+
+def deselect_all():
+    for ob in list(bpy.context.view_layer.objects):
+        if ob is None:
+            continue
+        ob.select_set(False)
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+        v.co = rot @ p + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_oriented_box(bm, a, b, scale_xy, mat_idx):
+    a = Vector(a)
+    b = Vector(b)
+    delta = b - a
+    length = delta.length
+    if length < 1e-8:
+        return []
+    quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+    eul = quat.to_euler("XYZ")
+    return add_box(
+        bm,
+        ((a + b) * 0.5),
+        (scale_xy[0], scale_xy[1], length),
+        mat_idx,
+        euler=(eul.x, eul.y, eul.z),
+    )
+
+
+def add_arc_yz(bm, x, cy, cz, radius, a0, a1, n, thick, mat_idx):
+    pts = []
+    for i in range(n + 1):
+        t = i / n
+        ang = a0 + (a1 - a0) * t
+        pts.append((x, cy + radius * math.cos(ang), cz + radius * math.sin(ang)))
+    verts = []
+    for p0, p1 in zip(pts, pts[1:]):
+        verts.extend(add_oriented_box(bm, p0, p1, (thick, thick), mat_idx))
+    return verts
+
+
+def pack_uvs(bm, margin=0.08):
+    uv = bm.loops.layers.uv.new("UVMap")
+    faces = list(bm.faces)
+    n = len(faces)
+    cols = max(1, math.ceil(math.sqrt(n)))
+    rows = max(1, math.ceil(n / cols))
+    cell_w = 1.0 / cols
+    cell_h = 1.0 / rows
+    pad_u = margin * cell_w * 0.5
+    pad_v = margin * cell_h * 0.5
+    usable_w = cell_w - 2.0 * pad_u
+    usable_h = cell_h - 2.0 * pad_v
+    for i, face in enumerate(faces):
+        col = i % cols
+        row = i // cols
+        nrm = face.normal
+        ax = abs(nrm.x)
+        ay = abs(nrm.y)
+        az = abs(nrm.z)
+        coords = []
+        for loop in face.loops:
+            co = loop.vert.co
+            if az >= ax and az >= ay:
+                coords.append((co.x, co.y))
+            elif ax >= ay:
+                coords.append((co.y, co.z))
+            else:
+                coords.append((co.x, co.z))
+        xs = [c[0] for c in coords]
+        ys = [c[1] for c in coords]
+        minx, maxx = min(xs), max(xs)
+        miny, maxy = min(ys), max(ys)
+        dx = max(maxx - minx, 1e-8)
+        dy = max(maxy - miny, 1e-8)
+        origin_u = col * cell_w + pad_u
+        origin_v = row * cell_h + pad_v
+        for loop, (x, y) in zip(face.loops, coords):
+            loop[uv].uv = (
+                origin_u + (x - minx) / dx * usable_w,
+                origin_v + (y - miny) / dy * usable_h,
+            )
+
+
+def build_bench_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    try:
+        wood = []
+        hx = SEAT_W / 2.0 - 0.08
+        hy = SEAT_D / 2.0 - 0.05
+        # Span past the post centers (±hx) so slats insert into the iron frame.
+        slat_span = 2.0 * hx + IRON_T
+        usable = SEAT_D - 0.08
+        slat_w = usable / N_SEAT
+        y0 = -hy + slat_w / 2.0 + 0.01
+        for i in range(N_SEAT):
+            y = y0 + i * slat_w
+            wood.extend(
+                add_box(
+                    bm,
+                    (0.0, y, SEAT_Z + SLAT_T / 2.0),
+                    (slat_span, slat_w * 0.78, SLAT_T),
+                    WOOD_IDX,
+                )
+            )
+        for i in range(N_BACK):
+            z = SEAT_Z + SLAT_T + 0.05 + i * (BACK_H / N_BACK)
+            wood.extend(
+                add_box(
+                    bm,
+                    (0.0, hy, z),
+                    (slat_span, SLAT_T, slat_w * 0.72),
+                    WOOD_IDX,
+                )
+            )
+
+        if bevel_offset > 0.0:
+            edges = list({e for v in wood for e in v.link_edges})
+            ret = bmesh.ops.bevel(
+                bm,
+                geom=edges,
+                offset=bevel_offset,
+                segments=bevel_segments,
+                profile=0.5,
+                affect="EDGES",
+                clamp_overlap=True,
+            )
+            for f in ret.get("faces") or []:
+                f.material_index = WOOD_IDX
+
+        t = IRON_T
+        add_box(bm, (0.0, -hy, SEAT_Z - 0.018), (SEAT_W - 0.10, t * 1.15, t), METAL_IDX)
+        add_box(bm, (0.0, hy, SEAT_Z - 0.018), (SEAT_W - 0.10, t * 1.15, t), METAL_IDX)
+        for xsign in (-1.0, 1.0):
+            add_box(
+                bm,
+                (xsign * hx, 0.0, SEAT_Z - 0.018),
+                (t * 1.15, SEAT_D - 0.08, t),
+                METAL_IDX,
+            )
+
+        back_top = SEAT_Z + SLAT_T + BACK_H + 0.02
+        for xsign in (-1.0, 1.0):
+            x = xsign * hx
+            add_oriented_box(
+                bm, (x, hy, SEAT_Z), (x, hy, back_top), (t, t), METAL_IDX
+            )
+            add_oriented_box(
+                bm, (x, -hy, SEAT_Z), (x, -hy, ARM_Z), (t, t), METAL_IDX
+            )
+            add_oriented_box(
+                bm, (x, -hy, ARM_Z), (x, hy, ARM_Z), (t * 0.9, t * 0.9), METAL_IDX
+            )
+            add_oriented_box(
+                bm,
+                (x, -hy, SCROLL_R + 0.02),
+                (x, -hy, SEAT_Z),
+                (t, t),
+                METAL_IDX,
+            )
+            add_oriented_box(
+                bm,
+                (x, hy, SCROLL_R + 0.02),
+                (x, hy, SEAT_Z),
+                (t, t),
+                METAL_IDX,
+            )
+            add_arc_yz(
+                bm,
+                x,
+                -hy + 0.01,
+                SCROLL_R,
+                SCROLL_R,
+                math.pi / 2.0,
+                math.pi + 0.45,
+                SCROLL_N,
+                t,
+                METAL_IDX,
+            )
+            add_arc_yz(
+                bm,
+                x,
+                hy - 0.01,
+                SCROLL_R,
+                SCROLL_R,
+                math.pi / 2.0,
+                -0.45,
+                SCROLL_N,
+                t,
+                METAL_IDX,
+            )
+            add_arc_yz(
+                bm,
+                x,
+                -hy - 0.02,
+                ARM_Z - 0.04,
+                0.07,
+                math.pi * 0.15,
+                math.pi * 0.95,
+                10,
+                t * 0.85,
+                METAL_IDX,
+            )
+
+        add_box(bm, (0.0, hy, back_top), (SEAT_W - 0.12, t, t), METAL_IDX)
+        add_box(bm, (0.0, 0.0, 0.18), (SEAT_W - 0.22, t * 0.9, t * 0.9), METAL_IDX)
+        add_box(bm, (0.0, 0.0, 0.18), (t * 0.9, SEAT_D - 0.14, t * 0.9), METAL_IDX)
+
+        xs = [v.co.x for v in bm.verts]
+        ys = [v.co.y for v in bm.verts]
+        zs = [v.co.z for v in bm.verts]
+        cx = 0.5 * (min(xs) + max(xs))
+        cy = 0.5 * (min(ys) + max(ys))
+        zmin = min(zs)
+        for v in bm.verts:
+            v.co.x -= cx
+            v.co.y -= cy
+            v.co.z -= zmin
+            if v.co.z < 0.0:
+                v.co.z = 0.0
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = False
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+        for poly in me.polygons:
+            poly.use_smooth = False
+    finally:
+        bm.free()
+    out = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(out)
+    return out
+
+
+def principled(name, color, metallic, roughness):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def assign_slots(obj, wood, metal):
+    mats = obj.data.materials
+    wanted = (wood, metal)
+    for i, mat in enumerate(wanted):
+        if i < len(mats):
+            mats[i] = mat
+        else:
+            mats.append(mat)
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    overlap = 0.0
+    for i in range(len(aabbs)):
+        a = aabbs[i]
+        for j in range(i + 1, len(aabbs)):
+            b = aabbs[j]
+            x0 = max(a[0], b[0])
+            y0 = max(a[1], b[1])
+            x1 = min(a[2], b[2])
+            y1 = min(a[3], b[3])
+            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("BenchNrm", size, size, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    nodes = target_mat.node_tree.nodes
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = img
+    nodes.active = tex
+    tex.select = True
+    obj.active_material_index = WOOD_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    deselect_all()
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL",
+        use_selected_to_active=True,
+        cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False,
+        normal_space="TANGENT",
+        margin=4,
+        margin_type="ADJACENT_FACES",
+        use_clear=True,
+        target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    deselect_all()
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path,
+        use_selection=True,
+        export_yup=True,
+        export_apply=True,
+        export_draco_mesh_compression_enable=False,
+        export_animations=False,
+    )
+
+
+def check(skip_decimate):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    low = build_bench_mesh("BenchLow", bevel_offset=0.005, bevel_segments=2)
+    high = build_bench_mesh("BenchHigh", bevel_offset=0.005, bevel_segments=4)
+    wood = principled("BenchWood", (0.40, 0.20, 0.07, 1.0), 0.0, 0.58)
+    metal = principled("BenchIron", (0.09, 0.095, 0.11, 1.0), 1.0, 0.28)
+    assign_slots(low, wood, metal)
+    assign_slots(high, wood, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("bench mesh did not build", 3), None, None, None, None, None
+
+    base_tris = triangle_count(low.data)
+    mats = [s for s in low.data.materials if s is not None]
+    nmat = len(mats)
+    distinct_mats = len({id(s) for s in mats})
+    idx_counts = {}
+    for poly in low.data.polygons:
+        idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1
+    print(f"measured mat_index_counts={idx_counts}")
+    u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data)
+    bb = world_bbox(low)
+    size_x = bb[3] - bb[0]
+    size_y = bb[4] - bb[1]
+    size_z = bb[5] - bb[2]
+
+    img, tex = setup_bake_image(low, wood)
+    if img is None:
+        return fail("bench has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "BenchLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "BenchLOD2", LOD2_TARGET, skip_decimate)
+    bpy.context.view_layer.update()
+    lod1_tris = evaluated_triangle_count(lod1)
+    lod2_tris = evaluated_triangle_count(lod2)
+    r1 = lod1_tris / base_tris if base_tris else 0.0
+    r2 = lod2_tris / base_tris if base_tris else 0.0
+
+    collider_src = build_bench_mesh("BenchColSrc", bevel_offset=0.0, bevel_segments=1)
+    collider = convex_hull_collider(collider_src, "BenchCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_park_bench_{os.getpid()}.glb",
+    )
+    if os.path.exists(export_path):
+        os.remove(export_path)
+    export_unity(export_path, [low, collider])
+    export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+    print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
+    print(
+        f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+        f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+    )
+    print(
+        f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+        f"overlap={overlap:.6f} nfaces={nfaces}"
+    )
+    print(
+        f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+        f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
+    )
+    print(
+        f"measured collider_tris={col_tris} bake={bake_result} "
+        f"bake_has_data={img.has_data} export_bytes={export_size}"
+    )
+
+    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+        return fail(
+            f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
+            4,
+        ), None, None, None, None, None
+    if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+        return fail(
+            f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+        return fail(
+            f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN:
+        return fail(
+            f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+            6,
+        ), None, None, None, None, None
+    if overlap > UV_OVERLAP_MAX:
+        return fail(
+            f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+            7,
+        ), None, None, None, None, None
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+            f"off outer {OUTER_SIZE}",
+            8,
+        ), None, None, None, None, None
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)",
+            9,
+        ), None, None, None, None, None
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+            9,
+        ), None, None, None, None, None
+    if col_tris > COLLIDER_TRIS_MAX:
+        return fail(
+            f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+            11,
+        ), None, None, None, None, None
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return fail(
+            f"bake failed result={bake_result} has_data={img.has_data}",
+            12,
+        ), None, None, None, None, None
+    if export_size <= 0:
+        return fail("export file missing or empty", 13), None, None, None, None, None
+    return 0, low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, wood, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(wood, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-14.0)
+    low.rotation_euler.x = math.radians(2.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.6, -5.0, 5.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+    light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+    light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (1.95, -2.55, 1.22)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.40)
+    scene.collection.objects.link(aim)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+        scene.cycles.device = "CPU"
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = (
+        "WEBP" if path.lower().endswith(".webp") else "PNG"
+    )
+    if path.lower().endswith(".webp"):
+        scene.render.image_settings.quality = 90
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    fcode = gallery_framing.check_framing(
+        scene, cam, hero=[low], elements=[low], stage=[floor, wall],
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        return fail("render produced no file", 14)
+    return 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None)
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    p.add_argument(
+        "--skip-decimate",
+        action="store_true",
+        help="falsification: skip the LOD DECIMATE stage",
+    )
+    args = p.parse_args(argv)
+
+    code, low, _high, wood, tex, _col = check(args.skip_decimate)
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("park-bench OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        traceback.print_exc()
+        print(f"FATAL: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/docs/gallery/watchtower/index.html b/docs/gallery/watchtower/index.html index 723449d..f036db6 100644 --- a/docs/gallery/watchtower/index.html +++ b/docs/gallery/watchtower/index.html @@ -285,7 +285,7 @@

Source

"""Game-ready timber watchtower — a showcase piece, not an example.
 
 Asserts budget conformance of a procedural lookout (posts, braces,
-platform, ladder, pyramidal roof) after composing shipped pipeline
+platform, hatch ladder, coursed shake roof) after composing shipped pipeline
 pieces: bmesh construction, UVs, three materials, high-to-low normal
 bake, LOD chain, convex collider, Unity glTF export.
 
diff --git a/showcase/gallery.json b/showcase/gallery.json
index 89c2705..1397eda 100644
--- a/showcase/gallery.json
+++ b/showcase/gallery.json
@@ -136,6 +136,18 @@
         "mesh",
         "export"
       ]
+    },
+    {
+      "name": "park-bench",
+      "dir": "showcase/park-bench",
+      "teaches": "A procedural wrought-iron park bench through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
+      "witnessesFix": "Recomputed: 2100 tris, two materials with 510 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.080×0.566×0.853 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 106 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.",
+      "hero": "docs/gallery/assets/park-bench-hero.webp",
+      "preview": "showcase/park-bench/preview.webp",
+      "tags": [
+        "mesh",
+        "export"
+      ]
     }
   ]
 }
diff --git a/showcase/park-bench/README.md b/showcase/park-bench/README.md
new file mode 100644
index 0000000..8760682
--- /dev/null
+++ b/showcase/park-bench/README.md
@@ -0,0 +1,74 @@
+# Park bench
+
+A showcase piece, not an example. Procedural wrought-iron park bench
+(scrolled legs, slatted seat and back, armrests) then the shipped
+pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain,
+convex collider, Unity glTF export.
+
+It asserts **budget conformance** of the generated result. It does not
+witness an API contract. "It rendered without error" is not a check.
+
+**Composes** skills `mesh-editing-and-bmesh`, `bake-high-to-low`,
+`depsgraph-and-evaluated-data`, `engine-export-presets`, and snippets
+`bake_normal_high_to_low.py`, `setup_bake_target_image.py`,
+`lod_chain.py` / `decimate_to_budget.py`, `convex_hull_collider.py`,
+`export_preset_unity.py` (helpers copied, not imported as a package).
+
+## Budgets
+
+Declared as named constants; every gate **recomputes** from the mesh,
+materials, UVs, evaluated LOD, collider, or export file.
+
+| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) |
+| --- | --- | --- |
+| Base triangles | 2000–2200 | 2100 / 2100 / 2100 |
+| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4952 |
+| LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.1400 |
+| Materials | exactly 2 distinct, ≥48 metal faces | 2 slots, 510 metal |
+| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
+| Outer AABB | (1.080, 0.566, 0.853) m ± 0.01 | (1.0800, 0.5657, 0.8531), zmin 0 |
+| Collider tris | ≤ 140 | 106 |
+| Export | written, size > 0 | 159868 / 159868 / 159852 bytes |
+
+DECIMATE COLLAPSE triangle counts are **not** identical across series —
+5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an
+exact count. Bake pixels are stochastic; the gate is `has_data` plus
+operator `FINISHED`, not byte-identity. Construction uses no RNG.
+Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a
+gated axis.
+
+`--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and
+exit 9 fires. That is the named budget the falsifier violates.
+
+## Run
+
+```bash
+blender --background --python park_bench.py --
+blender --background --python park_bench.py -- --skip-decimate
+blender --background --python park_bench.py -- --output bench.png
+```
+
+Smoke does not pass `--output` or `--skip-decimate`.
+
+## Exit codes
+
+File-local. `9` is a valid check code. `10` is reserved for
+`gallery_framing.check_framing` on the `--output` path.
+
+| Code | Meaning |
+| --- | --- |
+| 0 | Success |
+| 1 | Uncaught exception (FATAL wrapper) |
+| 2 | argparse / usage |
+| 3 | Mesh did not build / no UV layer |
+| 4 | Base triangle count outside range |
+| 5 | Material count ≠ 2 distinct slots, or wood/metal faces missing |
+| 6 | UVs outside 0..1 |
+| 7 | UV AABB overlap above tolerance |
+| 8 | World AABB off declared outer size |
+| 9 | LOD ratio band (`--skip-decimate` lands here) |
+| 10 | Framing gate (render path only) |
+| 11 | Collider triangle count above ceiling |
+| 12 | Bake did not finish or image has no data |
+| 13 | Export file missing or empty |
+| 14 | `--output` produced no file |
diff --git a/showcase/park-bench/park_bench.py b/showcase/park-bench/park_bench.py
new file mode 100644
index 0000000..d74d12b
--- /dev/null
+++ b/showcase/park-bench/park_bench.py
@@ -0,0 +1,756 @@
+"""Game-ready park bench — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural wrought-iron bench (scrolled
+legs, slatted seat and back) after composing shipped pipeline pieces:
+bmesh construction, UVs, two materials, high-to-low normal bake, LOD
+chain, convex collider, Unity glTF export.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
+DECIMATE stage so the LOD-ratio budget fails.
+
+No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
+are not byte-identical across Blender versions — the LOD gate is a
+ratio band, not an exact count.
+
+    blender --background --python park_bench.py --
+    blender --background --python park_bench.py -- --skip-decimate
+    blender --background --python park_bench.py -- --output bench.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+SEAT_W = 1.18
+SEAT_D = 0.46
+SEAT_Z = 0.44
+N_SEAT = 6
+N_BACK = 4
+SLAT_T = 0.032
+BACK_H = 0.40
+IRON_T = 0.026
+SCROLL_R = 0.10
+SCROLL_N = 12
+ARM_Z = 0.62
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (1.080, 0.566, 0.853)
+BASE_TRIS_MIN = 2000
+BASE_TRIS_MAX = 2200
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 2
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 140
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 48
+WOOD_FACES_MIN = 24
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    depsgraph = bpy.context.evaluated_depsgraph_get()
+    eval_obj = obj.evaluated_get(depsgraph)
+    eval_mesh = eval_obj.to_mesh()
+    try:
+        eval_mesh.calc_loop_triangles()
+        return len(eval_mesh.loop_triangles)
+    finally:
+        eval_obj.to_mesh_clear()
+
+
+def deselect_all():
+    for ob in list(bpy.context.view_layer.objects):
+        if ob is None:
+            continue
+        ob.select_set(False)
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+        v.co = rot @ p + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_oriented_box(bm, a, b, scale_xy, mat_idx):
+    a = Vector(a)
+    b = Vector(b)
+    delta = b - a
+    length = delta.length
+    if length < 1e-8:
+        return []
+    quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+    eul = quat.to_euler("XYZ")
+    return add_box(
+        bm,
+        ((a + b) * 0.5),
+        (scale_xy[0], scale_xy[1], length),
+        mat_idx,
+        euler=(eul.x, eul.y, eul.z),
+    )
+
+
+def add_arc_yz(bm, x, cy, cz, radius, a0, a1, n, thick, mat_idx):
+    pts = []
+    for i in range(n + 1):
+        t = i / n
+        ang = a0 + (a1 - a0) * t
+        pts.append((x, cy + radius * math.cos(ang), cz + radius * math.sin(ang)))
+    verts = []
+    for p0, p1 in zip(pts, pts[1:]):
+        verts.extend(add_oriented_box(bm, p0, p1, (thick, thick), mat_idx))
+    return verts
+
+
+def pack_uvs(bm, margin=0.08):
+    uv = bm.loops.layers.uv.new("UVMap")
+    faces = list(bm.faces)
+    n = len(faces)
+    cols = max(1, math.ceil(math.sqrt(n)))
+    rows = max(1, math.ceil(n / cols))
+    cell_w = 1.0 / cols
+    cell_h = 1.0 / rows
+    pad_u = margin * cell_w * 0.5
+    pad_v = margin * cell_h * 0.5
+    usable_w = cell_w - 2.0 * pad_u
+    usable_h = cell_h - 2.0 * pad_v
+    for i, face in enumerate(faces):
+        col = i % cols
+        row = i // cols
+        nrm = face.normal
+        ax = abs(nrm.x)
+        ay = abs(nrm.y)
+        az = abs(nrm.z)
+        coords = []
+        for loop in face.loops:
+            co = loop.vert.co
+            if az >= ax and az >= ay:
+                coords.append((co.x, co.y))
+            elif ax >= ay:
+                coords.append((co.y, co.z))
+            else:
+                coords.append((co.x, co.z))
+        xs = [c[0] for c in coords]
+        ys = [c[1] for c in coords]
+        minx, maxx = min(xs), max(xs)
+        miny, maxy = min(ys), max(ys)
+        dx = max(maxx - minx, 1e-8)
+        dy = max(maxy - miny, 1e-8)
+        origin_u = col * cell_w + pad_u
+        origin_v = row * cell_h + pad_v
+        for loop, (x, y) in zip(face.loops, coords):
+            loop[uv].uv = (
+                origin_u + (x - minx) / dx * usable_w,
+                origin_v + (y - miny) / dy * usable_h,
+            )
+
+
+def build_bench_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    try:
+        wood = []
+        hx = SEAT_W / 2.0 - 0.08
+        hy = SEAT_D / 2.0 - 0.05
+        # Span past the post centers (±hx) so slats insert into the iron frame.
+        slat_span = 2.0 * hx + IRON_T
+        usable = SEAT_D - 0.08
+        slat_w = usable / N_SEAT
+        y0 = -hy + slat_w / 2.0 + 0.01
+        for i in range(N_SEAT):
+            y = y0 + i * slat_w
+            wood.extend(
+                add_box(
+                    bm,
+                    (0.0, y, SEAT_Z + SLAT_T / 2.0),
+                    (slat_span, slat_w * 0.78, SLAT_T),
+                    WOOD_IDX,
+                )
+            )
+        for i in range(N_BACK):
+            z = SEAT_Z + SLAT_T + 0.05 + i * (BACK_H / N_BACK)
+            wood.extend(
+                add_box(
+                    bm,
+                    (0.0, hy, z),
+                    (slat_span, SLAT_T, slat_w * 0.72),
+                    WOOD_IDX,
+                )
+            )
+
+        if bevel_offset > 0.0:
+            edges = list({e for v in wood for e in v.link_edges})
+            ret = bmesh.ops.bevel(
+                bm,
+                geom=edges,
+                offset=bevel_offset,
+                segments=bevel_segments,
+                profile=0.5,
+                affect="EDGES",
+                clamp_overlap=True,
+            )
+            for f in ret.get("faces") or []:
+                f.material_index = WOOD_IDX
+
+        t = IRON_T
+        add_box(bm, (0.0, -hy, SEAT_Z - 0.018), (SEAT_W - 0.10, t * 1.15, t), METAL_IDX)
+        add_box(bm, (0.0, hy, SEAT_Z - 0.018), (SEAT_W - 0.10, t * 1.15, t), METAL_IDX)
+        for xsign in (-1.0, 1.0):
+            add_box(
+                bm,
+                (xsign * hx, 0.0, SEAT_Z - 0.018),
+                (t * 1.15, SEAT_D - 0.08, t),
+                METAL_IDX,
+            )
+
+        back_top = SEAT_Z + SLAT_T + BACK_H + 0.02
+        for xsign in (-1.0, 1.0):
+            x = xsign * hx
+            add_oriented_box(
+                bm, (x, hy, SEAT_Z), (x, hy, back_top), (t, t), METAL_IDX
+            )
+            add_oriented_box(
+                bm, (x, -hy, SEAT_Z), (x, -hy, ARM_Z), (t, t), METAL_IDX
+            )
+            add_oriented_box(
+                bm, (x, -hy, ARM_Z), (x, hy, ARM_Z), (t * 0.9, t * 0.9), METAL_IDX
+            )
+            add_oriented_box(
+                bm,
+                (x, -hy, SCROLL_R + 0.02),
+                (x, -hy, SEAT_Z),
+                (t, t),
+                METAL_IDX,
+            )
+            add_oriented_box(
+                bm,
+                (x, hy, SCROLL_R + 0.02),
+                (x, hy, SEAT_Z),
+                (t, t),
+                METAL_IDX,
+            )
+            add_arc_yz(
+                bm,
+                x,
+                -hy + 0.01,
+                SCROLL_R,
+                SCROLL_R,
+                math.pi / 2.0,
+                math.pi + 0.45,
+                SCROLL_N,
+                t,
+                METAL_IDX,
+            )
+            add_arc_yz(
+                bm,
+                x,
+                hy - 0.01,
+                SCROLL_R,
+                SCROLL_R,
+                math.pi / 2.0,
+                -0.45,
+                SCROLL_N,
+                t,
+                METAL_IDX,
+            )
+            add_arc_yz(
+                bm,
+                x,
+                -hy - 0.02,
+                ARM_Z - 0.04,
+                0.07,
+                math.pi * 0.15,
+                math.pi * 0.95,
+                10,
+                t * 0.85,
+                METAL_IDX,
+            )
+
+        add_box(bm, (0.0, hy, back_top), (SEAT_W - 0.12, t, t), METAL_IDX)
+        add_box(bm, (0.0, 0.0, 0.18), (SEAT_W - 0.22, t * 0.9, t * 0.9), METAL_IDX)
+        add_box(bm, (0.0, 0.0, 0.18), (t * 0.9, SEAT_D - 0.14, t * 0.9), METAL_IDX)
+
+        xs = [v.co.x for v in bm.verts]
+        ys = [v.co.y for v in bm.verts]
+        zs = [v.co.z for v in bm.verts]
+        cx = 0.5 * (min(xs) + max(xs))
+        cy = 0.5 * (min(ys) + max(ys))
+        zmin = min(zs)
+        for v in bm.verts:
+            v.co.x -= cx
+            v.co.y -= cy
+            v.co.z -= zmin
+            if v.co.z < 0.0:
+                v.co.z = 0.0
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = False
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+        for poly in me.polygons:
+            poly.use_smooth = False
+    finally:
+        bm.free()
+    out = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(out)
+    return out
+
+
+def principled(name, color, metallic, roughness):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def assign_slots(obj, wood, metal):
+    mats = obj.data.materials
+    wanted = (wood, metal)
+    for i, mat in enumerate(wanted):
+        if i < len(mats):
+            mats[i] = mat
+        else:
+            mats.append(mat)
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    overlap = 0.0
+    for i in range(len(aabbs)):
+        a = aabbs[i]
+        for j in range(i + 1, len(aabbs)):
+            b = aabbs[j]
+            x0 = max(a[0], b[0])
+            y0 = max(a[1], b[1])
+            x1 = min(a[2], b[2])
+            y1 = min(a[3], b[3])
+            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("BenchNrm", size, size, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    nodes = target_mat.node_tree.nodes
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = img
+    nodes.active = tex
+    tex.select = True
+    obj.active_material_index = WOOD_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    deselect_all()
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL",
+        use_selected_to_active=True,
+        cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False,
+        normal_space="TANGENT",
+        margin=4,
+        margin_type="ADJACENT_FACES",
+        use_clear=True,
+        target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    deselect_all()
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path,
+        use_selection=True,
+        export_yup=True,
+        export_apply=True,
+        export_draco_mesh_compression_enable=False,
+        export_animations=False,
+    )
+
+
+def check(skip_decimate):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    low = build_bench_mesh("BenchLow", bevel_offset=0.005, bevel_segments=2)
+    high = build_bench_mesh("BenchHigh", bevel_offset=0.005, bevel_segments=4)
+    wood = principled("BenchWood", (0.40, 0.20, 0.07, 1.0), 0.0, 0.58)
+    metal = principled("BenchIron", (0.09, 0.095, 0.11, 1.0), 1.0, 0.28)
+    assign_slots(low, wood, metal)
+    assign_slots(high, wood, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("bench mesh did not build", 3), None, None, None, None, None
+
+    base_tris = triangle_count(low.data)
+    mats = [s for s in low.data.materials if s is not None]
+    nmat = len(mats)
+    distinct_mats = len({id(s) for s in mats})
+    idx_counts = {}
+    for poly in low.data.polygons:
+        idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1
+    print(f"measured mat_index_counts={idx_counts}")
+    u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data)
+    bb = world_bbox(low)
+    size_x = bb[3] - bb[0]
+    size_y = bb[4] - bb[1]
+    size_z = bb[5] - bb[2]
+
+    img, tex = setup_bake_image(low, wood)
+    if img is None:
+        return fail("bench has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "BenchLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "BenchLOD2", LOD2_TARGET, skip_decimate)
+    bpy.context.view_layer.update()
+    lod1_tris = evaluated_triangle_count(lod1)
+    lod2_tris = evaluated_triangle_count(lod2)
+    r1 = lod1_tris / base_tris if base_tris else 0.0
+    r2 = lod2_tris / base_tris if base_tris else 0.0
+
+    collider_src = build_bench_mesh("BenchColSrc", bevel_offset=0.0, bevel_segments=1)
+    collider = convex_hull_collider(collider_src, "BenchCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_park_bench_{os.getpid()}.glb",
+    )
+    if os.path.exists(export_path):
+        os.remove(export_path)
+    export_unity(export_path, [low, collider])
+    export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+    print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
+    print(
+        f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+        f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+    )
+    print(
+        f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+        f"overlap={overlap:.6f} nfaces={nfaces}"
+    )
+    print(
+        f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+        f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
+    )
+    print(
+        f"measured collider_tris={col_tris} bake={bake_result} "
+        f"bake_has_data={img.has_data} export_bytes={export_size}"
+    )
+
+    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+        return fail(
+            f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
+            4,
+        ), None, None, None, None, None
+    if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+        return fail(
+            f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+        return fail(
+            f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN:
+        return fail(
+            f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+            6,
+        ), None, None, None, None, None
+    if overlap > UV_OVERLAP_MAX:
+        return fail(
+            f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+            7,
+        ), None, None, None, None, None
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+            f"off outer {OUTER_SIZE}",
+            8,
+        ), None, None, None, None, None
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)",
+            9,
+        ), None, None, None, None, None
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+            9,
+        ), None, None, None, None, None
+    if col_tris > COLLIDER_TRIS_MAX:
+        return fail(
+            f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+            11,
+        ), None, None, None, None, None
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return fail(
+            f"bake failed result={bake_result} has_data={img.has_data}",
+            12,
+        ), None, None, None, None, None
+    if export_size <= 0:
+        return fail("export file missing or empty", 13), None, None, None, None, None
+    return 0, low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, wood, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(wood, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-14.0)
+    low.rotation_euler.x = math.radians(2.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.6, -5.0, 5.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+    light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+    light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (1.95, -2.55, 1.22)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.40)
+    scene.collection.objects.link(aim)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+        scene.cycles.device = "CPU"
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = (
+        "WEBP" if path.lower().endswith(".webp") else "PNG"
+    )
+    if path.lower().endswith(".webp"):
+        scene.render.image_settings.quality = 90
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    fcode = gallery_framing.check_framing(
+        scene, cam, hero=[low], elements=[low], stage=[floor, wall],
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        return fail("render produced no file", 14)
+    return 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None)
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    p.add_argument(
+        "--skip-decimate",
+        action="store_true",
+        help="falsification: skip the LOD DECIMATE stage",
+    )
+    args = p.parse_args(argv)
+
+    code, low, _high, wood, tex, _col = check(args.skip_decimate)
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("park-bench OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        traceback.print_exc()
+        print(f"FATAL: {e}", file=sys.stderr)
+        sys.exit(1)
diff --git a/showcase/park-bench/preview.webp b/showcase/park-bench/preview.webp
new file mode 100644
index 0000000000000000000000000000000000000000..e7523f042a95c85c2bb1b4f2e0a3786f4ade9138
GIT binary patch
literal 21340
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
Date: Sun, 13 Sep 2026 20:22:14 -0400
Subject: [PATCH 2/2] feat: add wheelbarrow showcase piece

Staved U-tray with iron straps, a spoked wheel, and rear legs through the shipped budget pipeline. --skip-decimate exits 9 on the LOD1 ratio band.

Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Co-authored-by: Cursor 
---
 .cursor-plugin/plugin.json                |    3 +-
 AGENTS.md                                 |    2 +-
 CLAUDE.md                                 |    2 +-
 README.md                                 |    7 +-
 ROADMAP.md                                |    4 +-
 docs/gallery/assets/wheelbarrow-hero.webp |  Bin 0 -> 16936 bytes
 docs/gallery/index.html                   |   13 +-
 docs/gallery/wheelbarrow/index.html       | 1201 +++++++++++++++++++++
 showcase/gallery.json                     |   12 +
 showcase/wheelbarrow/README.md            |   74 ++
 showcase/wheelbarrow/preview.webp         |  Bin 0 -> 14414 bytes
 showcase/wheelbarrow/wheelbarrow.py       |  877 +++++++++++++++
 tests/smoke/catalog.json                  |    3 +-
 13 files changed, 2190 insertions(+), 8 deletions(-)
 create mode 100644 docs/gallery/assets/wheelbarrow-hero.webp
 create mode 100644 docs/gallery/wheelbarrow/index.html
 create mode 100644 showcase/wheelbarrow/README.md
 create mode 100644 showcase/wheelbarrow/preview.webp
 create mode 100644 showcase/wheelbarrow/wheelbarrow.py

diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index c1d2462..018e35d 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -149,6 +149,7 @@
     "showcase/fence-kit",
     "showcase/watchtower",
     "showcase/cart",
-    "showcase/park-bench"
+    "showcase/park-bench",
+    "showcase/wheelbarrow"
   ]
 }
diff --git a/AGENTS.md b/AGENTS.md
index f308065..372affb 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 12 showcase pieces (counts are CI-enforced against README.md)
+examples, and 13 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 dbd6a1d..22b3227 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, 12 pieces (sibling of examples/; see showcase/README.md)
+showcase//               - Budget-conformance props, 13 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 e5f4b11..6913e3b 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
 

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  12 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  13 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 12 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. +This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 13 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. @@ -87,6 +87,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Watchtower: a timber lookout with X-braces, hatch ladder, plank platform, and a coursed shake roof on a dark studio floor Cart: a two-wheel wooden cart with spoked wheels, slatted bed, and shafts on a dark studio floor Park bench: a wrought-iron bench with scrolled legs and wooden slats on a dark studio floor +Wheelbarrow: a staved wooden tray with iron straps, a spoked wheel, rear legs, and handles on a dark studio floor [`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. @@ -112,6 +113,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`park-bench`](showcase/park-bench/) — procedural wrought-iron park bench with scrolled legs and slatted seat through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`wheelbarrow`](showcase/wheelbarrow/) — procedural staved wheelbarrow with a spoked wheel, iron straps, legs, and handles 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 8179a94..f1fab5f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -116,7 +116,9 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Timber watchtower showcase~~ **SHIPPED** as `showcase/watchtower/` — corner posts through a plank platform, lower-bay X-braces, hatch ladder, coursed shake roof; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Two-wheel wooden cart showcase~~ **SHIPPED** as `showcase/cart/` — slatted bed, shafts, spoked wheels, iron hubs and axle; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Wrought-iron park bench (slatted seat, scrolled legs) as a game-prop showcase piece~~ **SHIPPED** as `showcase/park-bench/` — scrolled iron legs, slatted seat and back; `--skip-decimate` exits 9 on the LOD1 ratio band -- Wooden wheelbarrow (staved tray, single wheel, handles) as a game-prop showcase piece +- ~~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 +- Staved wooden water trough on a stand 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/wheelbarrow-hero.webp b/docs/gallery/assets/wheelbarrow-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..0cc48002777e579476cc791d3f5f34229e6931e1 GIT binary patch literal 16936 zcmV(|K+(TaNk&E>LI40)MM6+kP&gnILI41e=>eSqDgXu00zOeFlSZSWp{OBr7-;|s z32AQJ+FUQCzE2>{SJ&iKz7hYfzhQ1Xh8MaY{eEhPeg0Ra!%H-IcX8!M{ak)$-3flb z_|HxbJ@9vdeCYVi=YR0L;=IfGKRmzlJzxH+_7VP@>4(xE{GUP|ZNK=vf`4}OwCbq; z4eF=UJNvPf$LfC8vLo7u-UR1%o4|qVe?QEE|NJ@Ash}5*#+LtP|HZ*HCAhaiad>tx zQof6-KzvG*V95m#vG>v7$qXM_=$03pt#n7$x+CjckhX%7LkHHnBy+alVYRen8w3fp z-6eXto?L>}6a)D`oEKd0H4_xLJTTw-o&JmLcv47U`qx7o6Lhf7;=P`amGo_{}UI`FHfCqrO9l0+(*pwNfpo-%R4Fyb~#FScJx$o#_^3x=V&ycT!YPro=#xNg;(1 zn|3;V$%ywCdAra%zYjorYZ=lld(MtpN}ElBmGo<-6ZU|O(;Y5qFsMro*6C>XKUpG- zxY{Dk703ok-HuGHQO-9*T{XR1JLs;|?>Za@clUiA1HCl26>7>|GaH%0a>FhRU(%Fw z^6#syR+?Lt_e6UhX)&Mh`6!`xhtl=cMw=TKyZ}T6Vw%4Ni-m9#uPo$?yy)cn#Cevm zcYPg0Z4)Cs(hAx)%3)~3`h%1OVw%6CBbaSM>p1RXvBIY1`{1Q80tqz{SM;SE z{J6W!7bume`?P|7!G2z`YB$fUtyEp=V&d-lIr(@GCX_h8clVesDnDX*c>|PSOMsyY z0In6w=;z~ISP$+l%5KIC0i##;re?I@WmhlcNo48!upJwA8!TWSs5E1RNOsj1R&odU zdjmbHV>&py6f05GYA*A2q7aC10_oSKDCiyDKL-rgp1xh|XGc)7FcabX`Joteu-7r` zksS%|%Mv^RTR$n$oDCi0ExN2}^ehF}(tckIi(BKG1m;K5Y15cdsE1v49+ z=VNGR^yFYqo~}?k6nYJLU%q;1d@LI<#W@o7byTc7__?|m+sR>*z*dlX{F4$D1{F$x z4Fz*|oLo|YS=#rNgCLXyaenXb8~aEzFM`in`qBEinc#YP2!ILULs+*wpatKHJh5l&^u=HO> zlPEGaKbD0y+pLxGLc}a7LOLP9G_DnSe-Ts1CEt%?jDZaer%n#uRL{RxMH9(i%Cg3F z9wIA?<5M`ZT)C^e&z1knU&dZzo%zrA&tl8ktWL3K5E~eeZj~IU1}5*Em_39a!eWZGgjkdHN3ly!-Ef~TkW0G@@Fk!I}TzEDge(uvW zPAGabd(82Pt|#{XxZd%F6n6$5q8P30)Sk^r(Z2~B@+6>OA(AEn9n1qZTyETyBYzCR zrB&OieBb= zM!$EawAo%9yqgJA4gHb3e-WrXnw7KcNDPc4rA{$*fW~eNzplX-;HW99`_$X#XIP&i ze$(^Rvqpx^I95H-B9{oG-JKg#Bz3+?G>gw8^oJBstj!id+Y{RPQ z`={5yu0U8Y$0APsNx}BOBi29X`?~@buzs8=8Y9=L zITsQO;{Uv4DPXAHFR9J-6=z~;0kc`0__}a zLVCF~jy@*+90>dZd_u8@x@LYp;FH0puE?e8;LJzq>ewm;QrYz(!6{Q)RYc7uIPUJUrMs=S9zWrfXT3j~)VsOtOc z?jIV*=&YL01`oS$il5l0x_YwljyIJn&4=DP<^@ZNyLXrcF;mXgHhcogrWN?7jq5RE z1u(g6YMIilU9Cw?BL)-Owja>2rJP*`k7%d&pK1nLd@L4DbXlu(zRk@EQw?WY1iX&p zeE3{=4SSQ;@fjj`k@aqHi3pO;_+R|!Aw~I@y&#-tR}k{Mi0;H>R^>UkX>G*n0@H;E zKB?h)#6Sv6i9IlWUgAd6Kon_npjF2zPWA7AnA@wX>kItP??(-~iWdlOHbN4El);s~ zO-47Xi~2=`iby~$(1fF3rFgP+}}eMECm8Ylq2m^$}f<|{aQp|?4`jSHTm%Qa`p^dt|GC3msVDKqR zPxYSbw`@6jzXc8W?ffBXM`@M5>PMLY6^+9+>bp38J_w5v_6LsM6go^>&m1uOEeBem za&*js@-lrDx}`^k2zdC@`?vdiGJ(`x45uy&lljojb%PAk4Sju<3W?D&;z3bs3Jh-Q?;wSnJS7lvV!%vYqA|(n7_cLp&=UKS|FxX)* zsH=A!s-L0`{Kq_s1oQE?wZUD_hovPYXf4Pu7Cr>d49(D+XQ-VzAxiOm68&?HzAZqwlFg@XRWU6EU>6PJ1IUt_t< z8sFSG!4&c$@RWNGoT?J(9_s43riR-(ShX8;kRO>-@TumHxB{5t`=FVVL^tOAT41i) z7m3m5laV)AxR}m|0n!OnQB$sco%&=*<-4ZA;89q-Tz2k-(^A*LoTMcn2M%}F6AiSh zkd7Dtf9N~+3g{%FE>d(|@n7R1Y=q?B!J_90b6tqXo_$=ev#El8V3UCUu5CaFj1w`| z{GHO@4n14~tcGByUsK%~KL|()E`RdlL$qCaiGa;d&r|-Z&N^dh0=wQHXvG%A*yXhG zR_=(tiWg;fu4MU!@ArZ@k_K+_u zpi-PamjMrBJT)%=s?)V-GZ}GUzJO0^`Yi+2gJym9XeNnU^5%A z+3$q5L@{9}f~`O{74Li>^1xIVh2Y(;X|P$)8-JXOP?!_0FS98OAF-PVyPE%Y_ZS2r zfxv)=ptwZ~XT#l4tnZ7jB|QHMEasc$eav+spQ>}-|GC<>mY8~3upWJVvG}Z#GgX=j z0FkokOs*L!hp649kAXV%K9Cs0>%K45`oy*-E`OHpY5__N6rjOM4pRoS%;EQovDVHA zI$MNE0mc*oin(JtWc<1nHmA%p8&?}?2a zecYs9DgtB48}O08A}}oSHU`l|PxaPpTY%aGem&bUip~|@|3X#Uj*)cq-GG<&@P{9O z8w5|W`%iQn@pE7Dc05A3->h<}t^6q@Fl3gnk5N<(#z3SrTeDGitp;N!sdgkOGD4Ff zB>~vWPm8qgWbzX?$$uOmrTTw>z8zCeB~os#(RS?D%$MsZxO3fA^s!da-hNQA>q#Mm zE0HX1)#;|;8}cNeU@R$T9lItoTLrOWO@2#P%8S}9@oB^%hrRduvz!v!^{UWcy{&-j zkeq)A%L-%SV)Cy$j=MK^M33;Kki1x=Up=^0S>_9bQu-|eqR?th+I%D_G7^5M_(d<& zFR6T*dsubQ5WzZ@3DD@PY=E|}NAkX1YWoOZk?BxLOkR^qZhVptL07lqA9O|rTUF|0 zajW|_bhH|wTL~NTBDw-8kUKKokGy)iARzn+0}AkIy%e1H0?3{ptqTS>6-J7bwKCTq zTIsvk7%bC}Hird)k4N24&?0G1J)-SC5Z?i^1o}p@YFj=Du8_bES7j|aR>n_AW?%3q z>VQaaL=lTgvVnC)L@IF_e(fjcI=uP9C$?wV(0m8>8!LV zX4|G9hZ}-rdVgKKmE9Ou{evhIQ~S@0p;Yit7 z0RH;t>=5Z0Qx%>R9tu;plwQUA(kAbaR{H?q?PXR60AYq@pffMhyAGr67n}wXDQLqC zF-dd!4Mql9>F3jI@X_EePc}R0G^iCwW+$dBYjapq-0?Mqh8Q7f5}2MsyP^LIsDlFI zgd@fATcBo<9WwivE%gW@^l5;_>4B>1uHQi?O?1pf;B_zb*SkT9`X*ap!VmnBu8cAz z8g2SBQ__Raqc${at^zPrsKNyT^pOL|Hm_mMe|1hd-&2StbnV=at%ZiTgIpTrO5 z({WPh$~_W;-FHp}Um_A8_>UR3{w;U&+1+ zVaW+FT?I$@$sIP0IZk_G%PS2iMRVu$phj+nu*{b6R$EL2aNMNEW+ z*1+qDw3+QnKAVY-gyBjE?7JaRyP;0`#6pi2GJyO3t>GujK(l3W`6q;le5DFNz?Yl; z?z%uRxQM!)5bfGs?#pHksXwktxdJ(3-Bs5F1;iHtR~Y4xj?tZe%!$<@NUD9{MX#X0 zwO^9*W;oamJ<102F;n1W<1FERV%s@>sgc4^qqd4FA^9C_)M9)rgjTEjQzGp_moq(h zGQ%^;G}M}`wJcNE!`t_43nec8EZk zEhF$5lQCxIow?|f`4QVpU#b)C>uLQfNH4}o0`vf^lmP)c0U~xcm^$n1x*NRctg&ls zdam$+E-Y)c#BPs}LRxtGg6E!Bef#0==Sc6Rp{~ggi(zxd9h$0uRZn_Nd5Ud&2oP?p zwAkh*r*k9z8{tR40IvA9CUDJ_zPAVs1C-MLFOJ$5(4$N&2J{$_Z}RSs0BtP#rsnSY zg_)0d$@k#A2)<@in(0L$8`b#(yscZ)fXbO=Y%XxT9X{hEOcroOTxgUdj_S&41w^>8 z?ZT=b<`qx2QR8km4*m}6^LR=gRI2BHUNCaF6;btSS~QaD#XYx zXvkLauKO~{7Hi$U(h+xEQQ7M2D2sWWS%$I9RDRPsn4)lavG+rs_4UH;>gP=LI1s{vVaYs4lsZUXdKuAEz09y$Zc^g^RF*);3BDSp6e75)qjW& zK^w#*^7;ZG(3p!snIz!3n;f(rQTc?CWv%R4^p+Oie#-IQ!@c~`iz;YIa>iq+sFWZ= zsN&ZDRhs2*)(M+pt~g|0@a%&X3;ycga80>;?~LH#k}rZ4(+2trqV?4xB=g&ONz8p{ zHgEhi4354USUj++xtJmRP_#2{kI`8#1<5mj00Bg{i^`NcKxabCezEl~bLwI}0hvbL z+~U$4fnqub>kQr2{Q?fD`E~yz4Wu^6pzO>R;`iZElvFx=h=@ZK^zRFj__+5Z$w6%> z{Mg@s>sBPp(-&{^))?wu7*Vl}HwEqKN{mk>U&m^PH5# zG;hsjZy#6m1S8{=k)nL6lJiTMI?dqj7Q3RoVXIJszLm`E)+cGew}5ly`ggSp!=fM2 zJqCgR070p!Z4|d5;u#to4D}X<5V7p-Uj@=7-xe%OH)-8G{V89{=EgcR_jKqrvYJEN zoH&}6(n%U!d+II7*A}>J0b;aa!WycS_(}VFwJR~4Aczv6sC*iQS(<3&%{=FwxLIh( z%TkG1M6UuB`-Y^cOwKUXX>OI`ihyW>BVcz%NAm6mr*I;>6TZLx8ZqA; zK9GJRWRgV5tjng_N&PTg!T^$lGFuvzh)|0G08NEDpcT=i%bCLl2uh5~=_`(4^jB}1 zIKiJEuC^H{iky;!Z6ej}#`osGYl=xO6qaVFAW^pf00DmNl`L|9(f2h$Zr^hVKC_@x zO5w4p38c;qRR=$pXjALkbcKJBY9Xd957jARngabCwdr`6X#(IN5U0 z^>`?8hqw&*cH%Q8Td4@P1H8;Cskd(Q5FlJ7)MbtIcKjS2c{&OyBbxt#HqXxoI`^#ATm}iZ=;pj&y7kA^2sG7CSpF1E+fwtLv6*-1ZWvb)t8YagN zT9>6FA|PUL`G3CZyi#u?hwj~W{r{VOova+u>xBpTl|4*;Y@&Lk!&?ZgMRM;6Gc)wX z3Z}Gu#<|8`3o*$d3+!SJ2INFsJ$WLWV;lb#X>m{o$*uu`p@5I{^@qfKe#7nUt|>$f zX|R23r>5w@*4n>_{zkRpJ|;e;Iy0+$%Af_rmP*qmeAK2ZslPc=q^&SJD$Ij$fbl7q zJZ$(g+uQ(FsZW+sJdP)%GS19pz_FYyG%U2Q0@wikW1Hf$?2@vZnI_bJ^PkXVzKS;B z$R!B?(1A$;i@6V~#u03y)tR#l^F^9{C_vExyVdZ!xrV3}s-~D!@}?beU&jI%$*ual z95xcznzRRlxgHibf2B7a&YQJTvSbO-=VXTLnUmd{A43*yycFTSs zUglvCBZT-X@f2OdJvbAdIsgi*KGy!IaId5(FWm`(`p@cpH`ktHrm=1jWN~I z>@nF`F?g=8s*FveS!()&Hli;Zcmdg_k^G%JkOc~#OG)9>b*P?*ndn|50NBZg^Z|$h zdZfi9U&Xd@{H343dZx(Hgq5mFoRFYpivcTXdch3yGW;{oy$B2jMH>~;(G1Lm9PYi4 zU(KSS3VSYtYzm%LMT>ujBj-vBB(2s+{a__s@rYk$h`xk)T^eBngAU^+s@;f`ZD$&>?pqEHm)-Ip87Z=oH45 zCK<1_$U39?{v~a_=FI-A@pd1-W;e3A7X(p-xxmK&JI|)72u?5--YVX{l|idv^0AXv ztnt6Q?Pf~#al)*NO`Lrs!VWygLlhYzhz4!b84Yud47}Z&!vT~IbN`L0o$lE=`#F$` z--G8S_0>K-*S>_|lay-Q-ZYz1v?mp+i-Jxz zhEa>})HyB=dYn!8rA)%(N5V{8qOZf;lUold8$)++a?NE&2B|}hwzNm^cPc=Tyabc~ zttz4WY+Li8e0`CPhi`+#@03367^gA)>7^E{XHp?SXt8Xx0dsffP@@5N6#2-45o+!p z>YYriLMXg86`89u3A6sKOW6Fi41vg?Il~407I(&L3!+ z-$GJ0#9Vrk6w=@1a7hlqU~Brp?p>hsHCYN6ex%4p|AFRXC1Vf4WE$@$?1tEmOXzaR zL~}t?8cV>rGNltbH$)%|j5|IV4CV0P*N?bucXtFM6Yr4E10eZfU*CvR&R1`U1bGsh z9WNJ=1%6fLVnsAr!4%$oI|6ul%^{snxpmO`>LlUtUa%Xg=)U3`O9@HYU6X>EmS)GX zjGI}{4q4^M$vC+q*n1y5Yj4EOi3SEU%n+A(Y5DI~a;o7JT3@5$9%YYW{MaF6nduLY z>=Cz+KU3R{jff^T+T1@Y~NJfSqlc3PWriDY>Uu8uQ`W{JnTC$?(IGL}%PlX?uxnLP-k zqfl)>ueN7vqn~fpqmqGT)kQ^1Yt=+L3lRo;BD1r<_O838>WBgCwUg(w+?$2Yedje+ z4orM(7V&%w|HtOv`}_X17W5u7QEif|5Fb!3 z^00{1NuhmGlfDF2T+Adu#FNNy6N%M9viDWP$5%!J3?n}%X-h#01D3v-uw>Zs|Bzs#j$Z^R zlU3+xG>2lDY^{A~-0p&sc)$mIa;f+pf}g;)$?7@aukXs%hf8DlcD{ai&dU*bS}{A> z)6@*_h8Y`m06et;dWa76MJZHCF}{1+x6s1)vBO)*L3Jv9Lm%sMXYg*JG-;@(Vxi)P z&D(V6Mq3kNgguhvCk&D91a-6`V1BJ!p%oExBBlZ5H4J(&&~d~s?R6NZE0{^Nt~J^+ zaO`faDA%9}i6e^Bw}yf<>#zS}Kd<2UXYX!>qtjkiAo7sMJ8%MIy~gT(kPu&=JDh*7 z#?8WzU;qFH%TuxT2Ja!-=B4}E@AJ4z?xKx^Wfo#5n0X2+id~2vrCb61mrMEWIqh1f zDH*VTi^-%L>Ggx^r2wLo3<%CWDRpd~PE6K0nS-FWoqseK^6i6n&~KY=$=M0`kI8%H zs#6+8q?MCacJ{jrM?SO{>zL6x(+!P^wL7)rF(}he_UI&fc~pyp$VzmNENYoxR$+pU zaln7;iI|5Yn)`n05KGA_BWg6a4q;xPl<3Wq@#H=rA5B?yUDuhBf@VK?x+AofM$SD?4vk`}vOX1Sm)%Z{+XS`t$Kf}a_rg0O99GeV;R24Ld?b7h zGo0|{*LM%gOua}G7%KNA({z(qTQq4Nfih6i11fp)^90#@idB=SHBPai zf-JvIsB`^FO}pJ2PXMUvAo9v~FTR#aR0u9lg#r)I{@!9qxQGA`A=uYXZ4@rZR%4`K zdD~U)C^Z6+HTblO5HgG#<=H4mooSNhr5g!fMC-pKy2FsDn>5^0V3Hn!b7XP4 zHhcU1A!C+d-4}z;j6mb4RMk;NZumr)UR|9X^jKMDU$Cp)CIKx=d5nT8Sy?Z0t?*sk zi0`_psy;G^T;t}YVdym;uMt=Tdh1GRDp%6>2Z+>#x7V1nzyMx0fYB-O6*j{uYUKy| zG9*~r1K5MZ(>Xuc=clN(exc|9poy;=o#QhoOFrutICT|tl=nwfa{yjM+$SW(pY>fu z5vrWHNVKd035^_wJp}VVnZM zUVUCuWizOC|U2kDsWKZr5F{>lGFJ30siK>j+)piUn1QDrNc@_DDO1Wr!Sl9_MWoYMb&{_Hb$;>&1ZaV}j1RG4SZ(?rh;a-!aA38u@Fr)694Cdr)}% z$iqv-tib-mJM&QGdi!^YOE38&O^nf)7l7b*vO4<_!DA<6L2V$yEn;kxJR5+I<@QOG z(HN({W5MZ1D03iNgi_JhoQzD=s7@^pWVDm!rm{n^N+R%*eK+L_9On&I-QR?U(6AjlL4#r zE)a0}NYD`im-6P*$_*35C{N~R!DJ?C!bf{4}vH62YzKkJz{2JjR`9ix%)hqZfv{@lWU(zX>X^ry zu#JdP4J!_>;QT;=UCB7XO$?5gN`6&{55*Xb=_r@=9bxbkr0k$W?=63|-0bwc9HoBI z?HOyj>jkUwhH!<>$!#7(=Re!!m46$yxWIOP&Xd=)rM19KqF&)e5V|+Y}HO^2jXJN1boGl|@ zsv*dIfEyB-JQdG@_IZ!R(2Ar@H-eZEhpdmc3e#{i|Knkm!K@9$EgF7tpI>dgFiFw@ zlMQ>1CdCvmux|7;GenoC$rX?F!;7T9+aY8cVUF2Pui%?@XWa93Nu4LIsmGT`Wpgf{ zbY#ne?`qq+LDIg%WGeCg!)iK>|L?aZq3g)E&-A2Kd)=@^(H=^5ND(>M-s?3x)?o2X zIVsrIToS{!l9@A@ni!^y3iO;0N{$l~Y3?fkso<;#n8B zHN_dr*|*7gEZW&(0+B1HshHx%?^gn19SvRp!fOCB=w#0SCHvEks?WRot|FS{KxkY9 z@47hBx#4myaZugTh)D{aDXjKPs^8?ZdNA3P?l)~Z##1SUO8$?dsR*riV{{64Ty_e@ zCx8ni01_xk-~bW59bs1@Q&;PpjA<@&5^g->OUyQiTk9j)t8(v^M_S9g^be$0Ybc@U z&Diq2DJhKNhCsMv73PmkdRn5lNo~OxdtTKypF75$PA)kk&GtcN`j^38GWXfJZ7gpy z!KO{iH>z|$XF3qQHIIwbv44O;bc6WJMtCS$b2>a>B4G*^Ag_HQ`SpX-prwYrG6?R{%s&r>0tJuOPW zvseHht#GSY3IK|->uf>Sk`W!@-WS*Q;azGZql*cJ9^qs{8Pn(+-n1EMnnS$CEQd8I zBMS{S2MzHv6TRiyB)bguFal-KS?Ei#8T=NW0tHH?$6^s1p~J*qmoH(W4Bt_Pjha^k zx6B7d2t_b8KV?!+861&rw$up7@kwPtlupxx?-yH|24XkgLv9VpoE&-emlu~2C$BQU zVV-*;E#vg3Ge^p*$2ZECI983I4-tldvCu`^$+_=eN+=jlS&Ezx652gl)Tf8y=LB0B zX;$VYf>PG-1kW7GWi&kO8N1)gw*{t`%nf1X3C<_lj{WmPi39vZ zKyEhr73>d#b{JNoajpb^KU*$jU*$aNnH{HF(@B1ZZ7*jmuU(p z&V#aEmfJSh)-QMn%-%0s58iYXHcSQlg}J*o0s0$3CTASH6D_=S?d|yYeTiv}B!oNJ)OL3>H~$ zG1A_AOm_ph-bDbwIAb~|jKeObWvqm~57~{a2|~cV*KAw)d2zx8hC|Z=W}2DKp#}x0 z3cWTKtAD(^eWglll}IEb%BH@Md{V$WuJO2~Nmqx*=B0?6>#uCit{*Z~?m>$EC)IZk$i?c`{xF?8b(e7Sca1T)&~Js=xWq-e$VX|Z1k#%m{Q(Sxs zAK~WwlR%avw!{bwcs8a^Pv+bDHR#)C6>aE(c@{nkNE*k~4`Ix#WoAi`gZ22Rdj9yy8(rCL6oKQjwOF}x13moFCFW9Uw(?fJP#THtwk^&N zD0PTB#W^Knsqt0eYcp;BdXJ?uvllv3N0iMEMFRBv)DYc_cv3pXQ~pga;4QP^7zCx@ zNvkUJ>#b>nzUI6cOtapdrZLtKjBQ zqKuoX-B?*tKmLG}FnRNHOtO)C>$fUL;-V_{+G9Z36m&I~nY8rmnv!)$dHvdmj2)R4 zFp;RK<7ljt$1g)+qa?{V7vE|}w>UQ$G6Z}BiiRpRlNnVyG)wcc%7}NIVHhZ6K#o8LZ z0XWC(uKM7AXf_9%Tj6epZ?VK%o$l)mM$Bdh)Ty3-;@un_c1LHs__PtHoSP{bwC$8( zkT4JWO9WdV7!r91tr`Rp|I5H3A&Q?If57L@(GowNGCu~Se>M=OakriUyX*^M06qRP4B$O za11Lqb~8QPJg?`@n`5gp6ZM62Zm2yGVqDnG*E4R?_92%0O4%fjEOd2a69<|BTqNrQ z*M_lzru?Pt$-FmlonTHOWMopttDNc4^$(4lEqA`^b4(!p2n>;7nUggJA;E7-L6JFJ z%;D$A5S%LTifd=&$m!N6ZgXwJC|oj5R+^bHhj0h`S4T};n*LK1wuy_u2DkLr!}kreaTM53q@*|0(1STMOe<$Sp7Wq?46~GJI3o z9y+gi^!lY_Ip6TM;cp_p1*zZbL$rQ=voPFH7Ijct*WIAhH>GuGJrjQqj;0I_hK~1H zPW(%Btp1};5Htvaa6fTbw%30Q9EaRGy{6jy>vka&FI_dhO+uMLkU8NTj zizW@rYRp2UP)A1ok1xm{Cy?A~wJbO4|MPgce#0x<6&11D-V|6ipC~MD12^(hU?d2L z3n~_iPNS#VGZxp&-QHlBbv-S@%3SvA@yKm{>NA?LW!#F(b8LO;l5BIsO`$bqDmb~9 z{G1Qkqjui;YF@6?!4-!27A67-5$U>biHsVz&$4jiN?XtLu5 z=#w!Mf{`d09Uu?lCQzzE*zzPWTExBoai(qA-m&S)ol$Ii|Gkfvo+0yk0-*!8yH;&Z z#7U%k02es-*pR~VHR-X3(lFg9${A}Dj<=K6c*pKn_TjZwZVK)cY>rCY{NW1x#ZSoF z2{Y-B&Na7ab+}clp~9L8JN>Dh9K*>=dSo0&p4dwZpLUn9kn(KCWNZqqn}pml)_WYb zMq<`bjSpWlbOJ9724zu+=nFcBeTZkn(|oRGv(rT^CxsiXnP%t0wEn{bRb_=o))`{9 zj-T}Lj>+r7O622#yR7n{))J&;>m^=T+U^sF?w2vi6yzz1r^}1{vm7ho{M_@W{X+D# z$@ZzX{9s7Ryc~kNswz1S!mMrBn8R!>H`DZ7s$_^fN*&olJ$VUblJzldCuQ17 z__!~#q?+n{1$zQtd435j3jn-UGN9eY(r6z&_&Lq7;8<@H!l863WhLD!uI#jjM1I#{ zkV21pmHW+nDfzCE)q?)r!UfPj(?5lvaKI}_l$p!Q5)?jK zF#vo3U@CMH$M8bk=6cGR#W@nh+q4fObZ^=8)#vFiutHQ*GM>G3no+3xp)DZEF=Z<@ zPQd4q0MtRbc2Vty=sni@UP4?%z)kxn_J_ zi@CL@b}oil-5xmn7x;F4K(&Hs@+D}pwK^l~;b``H7Wnj1u}fzGnhGeMG9<0_ne~p$ z3JE!zS~reI^Ada)AnMt_X8x2lYW6hQU52|lQv!a)kI&GJwIx9(a^Il82NUwY4)c7K zGWUZ~&f^7i8@r-jfohVsE;)M8rUk(>U{m+lVY2inn$Nzsg6uR0Hh6+F=fQ1vulr^U z2*fweEPQ4q)WM@VBUgtwK}`y^^z~qUFK~M%h~9{qy_sqeK|o@p{DL3+oWw$3br>+{ zyl_L+`fnr%QZlvs8LCi{MhVtAtii|#r_1c@>Dd=-7#^mfg12i86!etZsQRWDk{Ccu z%9=o+YvT)aLSdl5C?8C~MK9Ly_x<&R&b^4eNlvcWpGNyexlA8kTe^7r2*IwBPn0Hz z+FU7#ja#w;^B?Yj0004y^Wz0}Npii_=@?YZahJp1Whk#>N!kF07(i2z<%4VK8r)-j-)~yR9Cfr>e>77=x0Bpsmj;+)jy?JNyoII5KB(>*dYG$F(1ByV z^od2aD*vtp#?m??#kM=#XnK;C$lKAjIea*ivsBCug)q2@fjZ5WK+#w_k4LAMb~|p) zh7p6$Pszl9SoYh^EQw;&pNT1MzHf#X2mtKDIq%}$_t8L^r4k0!GX+d>zIm(xz$ah!UOtSs@z zlOBT%36J{ql_X(BM>MP#1X;>0RDHHIyAFF=(KH9_PYEIy9tslIvz86tgu{gxj(r@qOAVQ zczuuWiWhfwJ|yN9YYii`8;pdiQ$}LS0-TnX7NEIy4NYl96byyMrhqgm0vKTwHO-eB zr!sZl9x@#WJxgGc)kSC>!9`cI@FY6FxBr)%;&^wY&%-#T)vgwJ844;D#Z24Ee(uk0 zwRb_mjs>Pj<&?CX-&dkRHHdgsGVqXpq8n9|P)l(9!?I=~R9A;@^Y9E+%6uD2uj1rv zyR~iZnBjA|#A||Gx+v3o zFxj?nP1`Cvi{-2Ct7|R!e%;6tqQ-F%CE7s&edbT?9P9*GC)~u#3Z&jw*z4w8>ORXfG~{7u~XJLeJ20oAsGXL9;^gT zHg2;BtFRY6ZMGhfq5WCjd_awuLz9cg>ZQc!TjGpbJ>W|{p9pj{U|;jq?LpN}oo=e9 zB5TXz>ScWN(vBnjZ$0WbXiD0TQZXJCpu{wNGX#9hcu6ljetC?;JS168_yA_13`9LPu6x@`wVe}m$Daxc6(uj9P{00000M6>lq zaw{#jrqvt)6$zNAW-kBPid*{%hV*2lxo!X{FC)8(9H+kzGyC!3hr}N1I{FCD>dN8- z70c)^is`8$iMSjR%#b*-V-IwG?2<6e5hC?Y1Bj{6x5!wWAwf7bnKeBC_f51W&YF@v zMdhHw<~}%+5-1ux!&sG42~mz3A|{4`WsPY6m?AxEzF+R>|SL_g!!%Db^Fd<01De0oro^*P<)KbQ>CExbiEhfw?S5e%3z zwY&DLKKSmaj8q&qNrG6EUfSPvb>trOW2DBbtv2@-8Pgu!>k%p#&fk3FloSt*C|O9O zzPqe77}Oq&RvDjj#=>QmQgw$wNqJd?1oZg`6-!mmlH_$~ zrUX{V;a&istIStF`ea_MTadj84Q?Z%;37aBKSr!2afgscA#)(A)rdDF5)7OR=cNZ~ z3)8+2gT`wFYCe%bLO=ij002-tN1+h6213of`?-50qKfVfeH_d7!iZ?+V0BF{FXrjB zcV(D93DC@JX}wNX@ME0-Uj><}NQ9Vn`#)?1p*CAZy1t*UyR~$~0_Q`L7lvpC* zT5rMwyP?*4NKWDaRoDj`J0~!wnyjU|gcJtQ)$W>A+y{MsNFw?Ul7d3{d%3Q<-Qw-Y zujhJb(d8dB33LDgEdV4Q!>S~9!vu%Y`j^5b!>J5AI$f#Q_FQ6lc|9=-f&QWLwD(Lk zqTn#$p5#Cawmqo1hij>Z?%KeJlZ|DdBzCc+zU%E)oO~&c#@pX-SGk_ literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index ba1f9f5..00be18e 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" /> - 63 examples + 64 examples
@@ -1017,6 +1017,17 @@

park-bench

View example
+
+ + wheelbarrow — A procedural wooden wheelbarrow through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract. + +
+

wheelbarrow

+

A procedural wooden wheelbarrow through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 3088 tris, two materials with 308 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.418×0.463×0.594 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 106 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+ View example +
+
diff --git a/docs/gallery/wheelbarrow/index.html b/docs/gallery/wheelbarrow/index.html new file mode 100644 index 0000000..0cf5734 --- /dev/null +++ b/docs/gallery/wheelbarrow/index.html @@ -0,0 +1,1201 @@ + + + + + + wheelbarrow — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

wheelbarrow

+

A procedural wooden wheelbarrow 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: 3088 tris, two materials with 308 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.418×0.463×0.594 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 106 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/wheelbarrow/wheelbarrow.py --
+ +
+
+

A showcase piece, not an example. Procedural wooden wheelbarrow (staved U-tray, iron straps, single spoked wheel, rear legs, handles) 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 | 2980–3200 | 3088 / 3088 / 3088 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2014 | | Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 308 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.418, 0.463, 0.594) m ± 0.01 | (1.4176, 0.4627, 0.5937), zmin 0 | | Collider tris | ≤ 140 | 106 | | Export | written, size > 0 | 227404 / 227404 / 227388 bytes |

+

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a gated axis.

+

--skip-decimate skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and exit 9 fires. That is the named budget the falsifier violates.

+

Run

+
blender --background --python wheelbarrow.py --
+blender --background --python wheelbarrow.py -- --skip-decimate
+blender --background --python wheelbarrow.py -- --output barrow.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

+
+ showcase/wheelbarrow/wheelbarrow.py + View on GitHub → +
+
"""Game-ready wooden wheelbarrow — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural wheelbarrow (staved U-tray,
+iron straps, single spoked wheel, rear legs, handles) 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 wheelbarrow.py --
+    blender --background --python wheelbarrow.py -- --skip-decimate
+    blender --background --python wheelbarrow.py -- --output barrow.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
+
+RIM_MAJOR = 0.155
+RIM_MINOR = 0.020
+HUB_R = 0.028
+HUB_W = 0.038
+N_SPOKES = 8
+SPOKE_T = 0.014
+TRAY_L = 0.70
+TRAY_R = 0.205
+STAVE_T = 0.028
+N_STAVES = 7
+A_SPAN = 1.38
+HANDLE_L = 0.50
+IRON_T = 0.014
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (1.418, 0.463, 0.594)
+BASE_TRIS_MIN = 2980
+BASE_TRIS_MAX = 3200
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 2
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 140
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 24
+WOOD_FACES_MIN = 24
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    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_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)):
+    n_major = 16
+    n_minor = 8
+    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_wheel_wood(bm, loc, wood):
+    wood.extend(
+        add_rim(bm, loc, RIM_MAJOR, RIM_MINOR, WOOD_IDX, euler=(math.pi / 2.0, 0.0, 0.0))
+    )
+    wood.extend(
+        add_cyl(
+            bm,
+            loc,
+            HUB_R,
+            HUB_W,
+            12,
+            WOOD_IDX,
+            euler=(math.pi / 2.0, 0.0, 0.0),
+        )
+    )
+    inner = HUB_R + 0.008
+    outer = RIM_MAJOR - RIM_MINOR * 0.55
+    mid_r = 0.5 * (inner + outer)
+    slen = outer - inner
+    for i in range(N_SPOKES):
+        a = i * (2.0 * math.pi / N_SPOKES)
+        dx = math.cos(a)
+        dz = math.sin(a)
+        cx = loc[0] + dx * mid_r
+        cy = loc[1]
+        cz = loc[2] + dz * mid_r
+        wood.extend(
+            add_box(
+                bm,
+                (cx, cy, cz),
+                (slen, SPOKE_T * 0.85, SPOKE_T),
+                WOOD_IDX,
+                euler=(0.0, -a, 0.0),
+            )
+        )
+
+
+def add_wheel_metal(bm, loc, metal):
+    metal.extend(
+        add_rim(
+            bm,
+            loc,
+            RIM_MAJOR + 0.004,
+            RIM_MINOR * 0.45,
+            METAL_IDX,
+            euler=(math.pi / 2.0, 0.0, 0.0),
+        )
+    )
+    metal.extend(
+        add_cyl(
+            bm,
+            loc,
+            HUB_R + 0.010,
+            0.016,
+            12,
+            METAL_IDX,
+            euler=(math.pi / 2.0, 0.0, 0.0),
+        )
+    )
+    metal.extend(
+        add_cyl(
+            bm,
+            loc,
+            0.014,
+            0.10,
+            8,
+            METAL_IDX,
+            euler=(math.pi / 2.0, 0.0, 0.0),
+        )
+    )
+
+
+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_barrow_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    try:
+        wood = []
+        metal = []
+        zc = 0.26 + TRAY_R
+        cx = 0.0
+
+        def pt(a, x):
+            return (
+                x,
+                TRAY_R * math.sin(a),
+                zc - TRAY_R * math.cos(a),
+            )
+
+        gap = 0.12
+        for i in range(N_STAVES):
+            t0 = i / N_STAVES
+            t1 = (i + (1.0 - gap)) / N_STAVES
+            a0 = -A_SPAN + 2.0 * A_SPAN * t0
+            a1 = -A_SPAN + 2.0 * A_SPAN * t1
+            mid = 0.5 * (a0 + a1)
+            chord = TRAY_R * abs(a1 - a0) * 0.95
+            wood.extend(
+                add_box(
+                    bm,
+                    pt(mid, cx),
+                    (TRAY_L, chord, STAVE_T),
+                    WOOD_IDX,
+                    euler=(mid, 0.0, 0.0),
+                )
+            )
+            wood.extend(
+                add_box(
+                    bm,
+                    pt(mid, cx + TRAY_L / 2.0 + 0.014),
+                    (0.028, chord * 1.02, STAVE_T * 1.05),
+                    WOOD_IDX,
+                    euler=(mid, 0.0, 0.0),
+                )
+            )
+
+        rim_y = TRAY_R * math.sin(A_SPAN)
+        rim_z = zc - TRAY_R * math.cos(A_SPAN)
+        for ysign in (-1.0, 1.0):
+            start = (
+                cx - TRAY_L / 2.0 + 0.05,
+                ysign * rim_y,
+                rim_z,
+            )
+            end = (
+                cx - TRAY_L / 2.0 - HANDLE_L,
+                ysign * (rim_y + 0.015),
+                0.56,
+            )
+            wood.extend(
+                add_oriented_box(bm, start, end, (0.030, 0.030), WOOD_IDX)
+            )
+
+        lx = cx - TRAY_L * 0.30
+        for ysign in (-1.0, 1.0):
+            top = (lx, ysign * 0.11, zc - TRAY_R * 0.35)
+            bot = (lx - 0.03, ysign * 0.18, 0.02)
+            wood.extend(add_oriented_box(bm, top, bot, (0.034, 0.034), WOOD_IDX))
+        wood.extend(
+            add_oriented_box(
+                bm,
+                (lx, -0.15, 0.13),
+                (lx, 0.15, 0.13),
+                (0.026, 0.026),
+                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
+
+        wx = cx + TRAY_L / 2.0 + 0.04
+        wz = RIM_MAJOR
+        add_wheel_wood(bm, (wx, 0.0, wz), wood)
+        add_wheel_metal(bm, (wx, 0.0, wz), metal)
+
+        r_strap = TRAY_R + STAVE_T * 0.55 + 0.004
+        for sx in (-TRAY_L * 0.28, TRAY_L * 0.22):
+            n = 11
+            pts = []
+            for k in range(n + 1):
+                a = -A_SPAN + 2.0 * A_SPAN * k / n
+                pts.append(
+                    (
+                        cx + sx,
+                        r_strap * math.sin(a),
+                        zc - r_strap * math.cos(a),
+                    )
+                )
+            for p0, p1 in zip(pts, pts[1:]):
+                metal.extend(
+                    add_oriented_box(bm, p0, p1, (0.018, 0.010), METAL_IDX)
+                )
+
+        fy = 0.045
+        for ysign in (-1.0, 1.0):
+            metal.extend(
+                add_oriented_box(
+                    bm,
+                    (cx + TRAY_L / 2.0 - 0.05, ysign * fy, zc - TRAY_R * 0.55),
+                    (wx, ysign * fy, wz),
+                    (0.016, 0.016),
+                    METAL_IDX,
+                )
+            )
+            metal.extend(
+                add_box(
+                    bm,
+                    (lx - 0.03, ysign * 0.18, 0.012),
+                    (0.055, 0.042, 0.022),
+                    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, 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("BarrowNrm", 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_barrow_mesh("BarrowLow", bevel_offset=0.005, bevel_segments=2)
+    high = build_barrow_mesh("BarrowHigh", bevel_offset=0.005, bevel_segments=4)
+    wood = principled("BarrowWood", (0.38, 0.21, 0.09, 1.0), 0.0, 0.58)
+    metal = principled("BarrowIron", (0.10, 0.105, 0.12, 1.0), 1.0, 0.30)
+    assign_slots(low, wood, metal)
+    assign_slots(high, wood, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("barrow 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("barrow has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "BarrowLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "BarrowLOD2", 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_barrow_mesh("BarrowColSrc", bevel_offset=0.0, bevel_segments=1)
+    collider = convex_hull_collider(collider_src, "BarrowCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_wheelbarrow_{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(-32.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.82, -2.22, 1.12)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.34)
+    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("wheelbarrow 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)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/showcase/gallery.json b/showcase/gallery.json index 1397eda..b585356 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -148,6 +148,18 @@ "mesh", "export" ] + }, + { + "name": "wheelbarrow", + "dir": "showcase/wheelbarrow", + "teaches": "A procedural wooden wheelbarrow through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 3088 tris, two materials with 308 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.418×0.463×0.594 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 106 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/wheelbarrow-hero.webp", + "preview": "showcase/wheelbarrow/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/wheelbarrow/README.md b/showcase/wheelbarrow/README.md new file mode 100644 index 0000000..8d9e93f --- /dev/null +++ b/showcase/wheelbarrow/README.md @@ -0,0 +1,74 @@ +# Wheelbarrow + +A showcase piece, not an example. Procedural wooden wheelbarrow (staved +U-tray, iron straps, single spoked wheel, rear legs, handles) 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 | 2980–3200 | 3088 / 3088 / 3088 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2014 | +| Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 308 metal | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (1.418, 0.463, 0.594) m ± 0.01 | (1.4176, 0.4627, 0.5937), zmin 0 | +| Collider tris | ≤ 140 | 106 | +| Export | written, size > 0 | 227404 / 227404 / 227388 bytes | + +DECIMATE COLLAPSE triangle counts are **not** identical across series — +5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an +exact count. Bake pixels are stochastic; the gate is `has_data` plus +operator `FINISHED`, not byte-identity. Construction uses no RNG. +Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a +gated axis. + +`--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and +exit 9 fires. That is the named budget the falsifier violates. + +## Run + +```bash +blender --background --python wheelbarrow.py -- +blender --background --python wheelbarrow.py -- --skip-decimate +blender --background --python wheelbarrow.py -- --output barrow.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/wheelbarrow/preview.webp b/showcase/wheelbarrow/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..acf25d47aa5b703b60d83d7675a231f83600d47a GIT binary patch literal 14414 zcmV-UII+i4Nk&FSH~;`wMM6+kP&gnuH~;{!!U3HDDzF5j0zOeFlSZSWrmZ2fIANd) z32AQJ&N_?+S9*b)&_>JWp(COH%Fw!4IxIWD@>D_od*RjyeaH8&O&(3~S@pbT^Dp_{ zbbeR-hu{Cck4C?&y@R~(dNu!<=v&RR{|~S~@IH|}Rep&6Z~n4>y4hTQweL6m7DhWx z+E0Ls-u{ag{w3+%&@+w*xq#eTAo@*$X2tG_c=w$jM$t(q|5>%+dHUEg6p7`j`q(lQ zl8^P9SK6kdrWLeqPLz1)+pR)`OEE_M_I~o10zI~K(xJZn8L|hde{TsfxHx;LqRI{o zjXMjBsfaRFy~h#E zr@JwJCOmc?X_;Sr&q;}SfL@Ipj<`FxBo5Sgz=;)#Zyxib=-aV^lX1*NJIAEfl{4kP zrHmSybVO>e=X@7bg0_vS(#_u8+01#DnFuSia(l<(zOa`+?|Z4 zqU-M4gq1SDfR=Ntzq{!AHtSHppS`hyO_&I>+*0;3o_V}0&^24tJCnTH_jDaJ^-hlV z4;(4FlyaNLyy$}**FcW6+)JG=dAxLMqz~?rUSbccnIqv_lFj>rrty#-PM{=Ur_q4| zIhEqc1Dk?oi4>imzuj3A0H{y~I|VB0QS@!W1O-~(R_Z>DxEWY+^$EbN)#+!ryc8vs zd2GR?AH+iQup`wMNhiYkDGkbO1yHNy$j@&cbG+!n${zVSwx1T~^fL>i0t^If0=`OPTN-n9YXeR*13b z&tv_AEI*bX%C;P^`7T7;c`H{!-N{!5%;Ex(yK^S|=y*VYt%uqwa{|#*E*_NR1#js%@| z6%`|TxAzupNUZa*$JorYe_RK-h|J+j)p?Kve{C6(hCm6wtP{7C#rVz1Oh6%&KrJJ1 zM!fM9mf68p>exkBO6wa^1Z&ZoreH}Dj@*=Za=%US8@L5@Tr1~oPHdR&cd+2qP|wfy z==h5f)dUz-3c$s!`2JTz{NPaMx17E11gmv5sk{6Ae>|5CeV-O=OppBUjkiGc!^2`+ z%=d2%JjJZsQWvJ~P{PY0DZ#C^Oz^n|xFdrLiqDzx3|YT!f44v1IVfVG$Y2og-#U%b z3a{V$Inu>}5$&C^c3ji}4|-YPB*q^Hv+I=BX-|FV`IWR%NV`?gbVjR?rIu)jYw`g6 zF(>X)Sy1igF*&g!95(Or6vgkV1fuC!1rI5@VQ1K8s4e7N<6$29o8r11t3p8FgAtZ( zAb9kg*2-DlbbTQ)8bfL^Dt4b)Knliy!bj!=S`S2~Q?{T^D6+68&7w&f_6ep~SySD6>-v#vg{M^mawv8YK?;Z6`UB;9P71p{BsM8R*yN!~&T)c((7&#L{COMovT5h-cArAA!KPxUQiZD-7#;RY!D+=^HoiERM@56{^l(wz|&t@nEyv4yDIhU|6lAA zsQZV!`ApNs6#wO`wNg`SY~&LzuC^`l8XqVK?=$HL&*PHYOG<)mh@u!eyv@3pS7vo) z8#TA2Lsng?f{x7VskH=Ki|PHvl$GbMt9-{$hJ#C&amXZ13^otH-T8gTPO*w=*}~? zN{t=xAstUVD{+FoX(DfTUj|-b{f-z3W%Idf#{(9I+899F6Ots0XB*^EmmRvq)x#RA0L$e~Xq7Ta9(sEMhk*uh$VwO|9NLdx5UcfwXlr1J3}a*} zC$w&!a`x`LWJUKx0S5rF%NoeExCQNtU9N8q$p04dph4K|90y0`8TkBjXpD-3?#I2h zeTcH6`GwXXD4Y+tj4a5slXn9C4-e8S)+|jcBhdS8{Q7j9)XEQHk#TE-7&eQbP*ND0 zT_&~KA^O-c_0;!XHbSD1O0y^4MuJhT+3_M%Rw|apv;$RsJ23n(;2FI+4L9Zd^ZVE1 zgs?PnESaYQ&^`{sHcx&Eh<^K3{}G2v%z6no$NtJ?18LZ-UUK@eEef*|N-_+u_KUGv zad0<9(WqL504QW&r=%opuM=sX*h%a|2#r5(g3ZkR;xa0ncc>DAa8qDdB@|6L>LX#?@4~1nQKMRv)_P~q$22gzHhg`Sg=E!k;tvP2+HF+)6nfo zIJ%5;j)xC38@xbEcv`X~vptAgfq=tN%Ix&#i^t_aF$|bYzk8-G3ohFkc^Ir}MCyhp zI8;;KNG!$ECpF&YB1*p6P28x%LX%;Tf)ijpPySb(?wJ|-u)Qpqm~@nFc4sQ~t~Pg} zCr}~h(hX29_e!ck=FycTunctTY@jW;qrt1Mp7B~|<6;#B9hakJimqY)-YUlj&aye@ zI=yua{QzrS(_mv@OFk+LSKv4FT>bnM)L(X1XHA3EoGSD2=dKz$8^rMuwXGVlW zf)$$#ri#JCsgHjfk#VWgp?{;J>$Qr$jQTDo75CM$)Yf4Y^|U0h+gJDCkULX%p^6&Y*82G1XUe-dDVHl3ul8(B|@W$68X3$^hhH1Mv*lBZO}+ zUlE;>j=jMqS*{9$>A_#(hYA6s7++vELy_O?==mWkHEi@Zw5Rj-=W+5@Ekx@-MQCrI zDBI6dmE9WTg|goNftTosc`3{hG^Yx|8J3;}{}4|ck99`&+c=zVY++WfZVh@rP0#5_ zXxP^Zj=Y`1Etfqj)BM)U6aIkDg&6_Ki=P>izTyU3DqwunnAcyJ=xd$%@|mUyr_y~M zbTQzrV22E&7xb4XMRlA|x19NPIgKaN&|fF2$6lDB!JQR!wCg)E1}@A5Xc%(m=5YXBv?||3^iT2*h122sO7|^+6@S{)fut{PoGE_ zd-agPK3(wMOs5mg?#l`uGWtG-c9ptfqsn9@lHN2_k}eARUX>4ltj1k?ir#2wxO>l% zngtCx+5en1D6>+DZ$4AbHV$=Ttlq#5wNDlW89jfpGz1!0vS!S4Td$>l0(*;_Eh;w& zC2v7HV5?BaLzI!f4taro=+X)PnF2ru{=O{-%|1jK_qPOJHq8NrW~Da>T*6EC+91kXjAmD3QX`I}32%$#(QGc)u2Y<9yrH(R!8gzCu%5zDt)k!fX}bQ0oHd>W zC^S-oMJO}4uef>Uom~*C^>__sg8w0iNG&sa*cfowF2jZb+{}HjIl#gAtr+aikf`|1 zD_DaIq5n;A@#OGg7^DFVF(>f$@$EDzGY|*pnBaGTjAO$W@X@lU_g@Z`}Oe%#oH4vrz_g=@ik9b68RBn1x3e@W+$JK26m5x zPbt^V>-8r!z~GNaED^D>PX5!{0eTgCk~0R3y#PSGQzAxjGyd;+5Np`YSavlv9F+tN zSxrs6d2==Vb=yK)>a00=rwrRc67h;If0FZx7|nexphc>FhSVzi+n zQo9-4{x$IiK{5bw-5;ED^+*U~@>$~gAiopq<9uE^Yn(0VT@EN_sfuMG_7m9AcyT;B zgSn!piCLP@g8{bEG8`I{h?lHQ=;C5XJ#c1Qr&KjirhquV0(jBW_O%Q+S}}huZy6gs z($aCa9;zt$7$}Ivl1~kW8+8N5KjADjl}`#Z0zUEqd|2gga^9PU#KnJ!<>+A!(S#X4 z$)86d>5D$U=t*^qC6JGH)zOC2Qp4^AX0&vH4#FIm1F7~J?w~vG{#cmS@rSay=3_=U z2E(XzdboNtk(9_bQrH042kwAqyzcm`$gh}@BF6WV0THc+Ie%FtSv~P=ogcL2b4Eo} z7YBM_q(k-1Bi9XC1Y|%-5a*(Ff=5;Y@S+l8zMztd6l7DtiNJxVNbWe#4=6Uceg?As z3$Ku>@6+_-wR;WO!t1cD+a1ni0z*l+kN{~N!U@DOi|o0xm@v{?bW9I(u|&rw(V9BbzoMcu*A3(q{`T~j-xes33w4tx(n038-2h|o2UNhw0S zeSL#kh%=V^f@+|VUl5Vya6$sp6b)&fkQD=p#$Wy#l&*zmO)y{wG756n5}Xi9K-*PW z&l(g@crvw{w$@v&gHB%HdWAls(r<^-?1q!z`YdHE9FH+&K&mp*Qu}`2$4+zznu77h zAmgn*V2=nOeEcF8f|M6=#rE;E6R>#z5Ok+~dl7th?EwCMK4fw*Aa+<3SP6x{^D7Xj z;#utP>nMuJQM7hY>lELRHMiA`InHVqdWA#5Mrd0{iq17I&Wq&~h0%(xAee!ph%-Rc z?rO~N;uVdEVySF#0s*$Equ+?j3RKb3U&9x{?p(aSjHJjq;=mKiCP zqc1c%tBXO1#<@P*r`UhYumkha9)!sT;>SP%Aa00o0v7O+SyqShY`zQZ3>jSY7nN$H zca=(!M~?78wG{*|0Qt<6^&fB;bJjv99o^cfeN~zEdAtZmhm9ekyNdzCD=kA2 zb8G23BW_u)X8-^JItA%gc>64GN&$$rU{$U7D=xByot2%Zjv=%O-9kS^2kypEPOU;qFB)eRs*&~|i=cKDFu{}ko2 z5{?NAz{($-i`*XrezkE(CF3D3)+HA0hSlm%wt{V4lz;{v%}tiv>KzoMVEK5qkO4i# zjavxf3ppctx#8jcS3WL-&q=RUr1-@fhO)c765alvD_0NV2CKA@oEIDmHX?WBUrG3n zxF*MDr+cL+_*~ImuOj8G0@v8B2MnP=vg1ScU_;G0n> z!(0^3^}~R2ZR~nuaHFRGn?=D!7?#UJ%k@M@f4f=Zmk-s>zpp@isR!G_RoKEl#;V^P zE;9P5FvSTOUkGPuo*-(=y*fuBT>F9T{xpWk?*c|k&^0Pky=gAO(=loDI4F2MGjI!g z$P!lI$!{3#MH+dkhP-H-k~Exejk$`%!>VGqxweZlpnDyoH9M^rv|;FxCo-Th({kYM zV5dlxvPAZQcmI(Mu6tTJu*hsI=as7WIsTMdTAJR~U`M*enWD|>!87be+y;P=t_8;^ zfg)oiZEg=vg$e+-?O(arbG@m$U6F+(QN&>RmKB0Eu$~Y3ov+T>6nGF!skQG=phI2% zXzK1D(_Q{Wj9ll)7E59QD3ERDG6hEZRmZ>>=j?KQw;t%5gzQ{D1c;X!SqhCz6#A>c zzy$zDB0=@Ef)D!Zj%^?{iRD5Xq1GOX)Owx!6J57lYQCK0JSQjF;BG&Fmc_59UIZy_ zu?nAb;uT8fZO_`sL3US%WIPdf$PtSaC8ob;GPZvkA4dN*$-xu|oPD3lg|?iOH1lCc zTuRXD1k`IAQu5RYY3Umcd0{dV3YwsZqFd0ss3K(ZNzzbN?}WUtV7}7CgV3j56^rce znLjlHaj8OLrb2&0;!bQl(mtpp0jPw#63n5J{Py(~(%~O0d%znzi--!XZku{owOtNY z3pMnai-Mah0)c!lX4%I2JV-x0(;$mgVYRg-JisG5^LNR|yq$96LtDWWo47Mh1l2C3 zQ@5-|!DF4e-EaJ#@Lv#y)k39$Z|#pY(iaE~zR}(!H};>Kph^s#WpE+DxK4-AEzNCR zUYt+OHJ78hL3Pz-%lM2mj5kv)YO8Iig~wogrm)z~yQ&#+N;Z9BZo4kt-Zt;mgG2Ft zoC+_xS|2RS%>?Zj8m)L3_ua64@Yqe#G}MGV$4Lst*OzsO9I|kQi-2Zt-@)p~tI~~|R9>vl2bG2<$k~K+ zHOK2k(B7-MhQ`-Z%NSbTSdR$C{)R;BB1Z)dVqX; zxI1WEDX>*nj%IeqPwFGf)rQ|!HHtS2EMN8b?a=bA;gL1?4jQ`{TR^@$cx$y$w*QAL z727J#MQy9@;8Iv--&{@F7IUu)B&!PmzAj|sO@8zR`0C{0eM~ka=gPApYC?tDktI-V z#QkPz>K78f&2}p0ua{c?>ccO!^9n?hu6L@)a|ud(j3`?5nFHlLqkNC7hSQMu89JoE zCz*Up+aDh`;3eN(0%KDJ*=4BuQr)@>k=_m;GTZZZ?FTe%xEcI(Wl-9DHdljO`8PXL zULYp=0h1-lb->-$lO+Q|xkeEdBj^pC3}V+i?X7HT`^?bv*hu zn-q)UV-dh+CY2ajVKyau(3yY@uA2WfM$L^LyJ3R? ztWV?2dW{ejdozI_B?!7sLuWh>VSGzJ3d<0ZF!V(?0Sdvy|CU`6I3n0xYIn42L!(`* z!wWpBU_nfcPwe&mIl;&X0$fdtIMD_Sds>?YIfM@C(xMHM``bx+>_Z);hwKy{p$Ic7 zr&TaRuth!%285z0f#mslzf!!~oR}$ItW;EJqC z{Ua)`&EO({@vZUA9JHLcU&9(bfQnw>G;F3>K?1saQk)j~zb47@Y|w&MdZ+ZP`XJ5z zUtwtqE9x^^PVDQ*z5KeAnjGLK7et`BDgJrF-WF@)*4*Fy4}xOU?;avUnjl3vO(#QW0ci@FG{w z8paj}BV)xynAGXY5^VQFcYwKIYuISsO@^wy1cYeFY&{QW@tdfx-eh;NrFMpcbAo5$ zv8~oR$iOA|i=N7@c}m%)fOK$-KHr@ShKf%;c~RB*IVQ`kctF2k+bM5Hd2Cvk_P1hL z_$@cVIsyl~0Vy z)1hcERI5{s^(<=sBLNtVck;#jB!iHka854;Bt#fTQ){M9u}ApI0x+T_NPs~K@S^n_ zy%kU^X1Ual(nz^1!iAIxJ|qqn@8_PB!t_qZpr@FIkS+QKcBKZ8sr~jY+@Z0evTA>c zDuw=Ws}>)YC>pqA%~8aq@^6;8lm`bR+t)WQZC8#z7Rlp0)6DRYn%dJQQmnE%Qa8<( zBuw_sMXkQ({niilMdWX6l?&0{>=wp)<=Dl&EhX#VG4hS7XTS1P^JwJqt#Q~S-}tv` z3A!Mgy}Kow$~{Y(y)7fe;$*vmb&(7AorJ92G|Dj#9hNv#fOOmd06o`hpay%hD8`Iy zoS_w;uE}~*gYhTeDAjpH)hJJjYsqSMrp%Zy2gl@YCc27~Qs zshISge%%bMF!b5??99WhJTiEEOxtr;A%H#@@aDY@aPG5wcI5X z-+keX{xCbw0Uoe_%^wzY>BVYKj|L zoZI*6KJ9r&KczYSmIa@EVB87A%FzEXd&K|fyBTJ&07r7MIKuf!Xm`OFECEa*-6$vcbJO8YEC zU!Iv!!?OTxWT_P5V+ZGQx~o&%`;qDuSQ&r-00jp<006?G7aDW6 z5dzg}Ly{r*`%EmbZND@HCV$-^%Ui3d$rv|gXG+BOV64WnJu|#A8?Cl~FNHJ)&|*5j z=+`_fJpzq>aBJsjK@^6H`qZw+)ZNDa*nB7?etQ@%BnGbC0vH!r?Kf0G9}5t44coE; z`!|TJ3SeLum7(h^v5*)4$dcf#6iLbfx-MgWo5vKBB zV*E{+}Mpy!Zoc$ltr19z4okn}zf+4|xry0k8q;d>($~bb+WigMPpYIB+ zd3savY=}DO9247tLC6#=sA$F?WP9()aGY}T0aoDJIEWKEgL=&4!`6p1m2tVKROI$F zF~8sp^W6)iux0fVoVKO0krdPcC*VF?!`mTfiyTK~dds3k!2 zQ=Gf#IroiKUx`(0D|($bJO@6Q{xnl=uXWwzp0wju4f;Yr86++srv%&@RZ%ft8-&H_ zL;&9UbxsrTqKzvP=LuM5$1|s$E#K-Re-|j=;fz%v)LD7URXK_EE*{+z>Cp%+abZHq zE+NZSV(0*bZ-5xR*%n*76}OYVGb{V%rB=P}{(fiW7z_rTpSBmj==83-w8EnN;fpkA z8wXsrA)Fi1uo1rLi@93~bu(S2JpV@)o*#A3KwjGR{vu2vejyX~t(cbzacIU565z*1 zSa$!e8Kmqhd_a5VgJ?kT&hfk~PJS(G{C{s)?Ch1Q=%tzcdEiP4*taa&Fsi*=H%Q7^ zj}9pP6|ITVkxN!mR%~$1{R+ZV-nnJ5njsTB$1I)6`*9nf&y7q~M(&MHD_5AKy&h!* zdm0INwJe_C1B~DYGlr$n2ELD`90%fcXkLz3*n*&Dd0)KIpZ|Ww8Ov+njnW(0;a%(v zgX`nak{5%0o;Bf!K1b$qok}v+Qkk8NA`Vjs4ii=yj9aMx7aUhTxJ5?avnxZmH@CYr z39ATCbzy)gvfx4`QrX*7%enT?&UA#ou}VDuI{8C=k9|(bt}m4-vI5=(mdb)}ERIh@ z)od{JwRa6GQdz|Fu_nqVI3+hfz8H@W^rzv~1+!=oJhZ}J@&Nyj%kXY@jR1fwFT>W5 z^h=hqdNk1K>~P65TTg z1_KH$`w_`7i@blkN=wcb0}FwG02$~E$h}9|IknW-*e&V~Pw8%Mve%3(egq=+Gfe)8 zV{mOPVE^E3KDT3S51-yz$>TZj;8Fn8vzzn%wY`>h>3}PAfh!52ZiKj1`Riua7k!+7 zX9Qz?Je5!vdQS6;A~?&{xyb8Ql4;Ld?EBSJrQ~I~@2wa2dj4LF(G4`IFh^0JZ1@G= zK#&EbQ6LPUJVtkLi_D=8A{bt8erRoKvr{vftz(Q28SGxgQM)*791PR=8LA?G!OSb! zO7{ISfI^Z4*A=XxhZ`$Md1*U(C9;GCy{Fa7ZQSc2>N3iw&Wkayg#?20`lD4Tfgr-btF7^nAM1T?WTX!@Gt0K6|yUI9p7rRXmH>a^W z)0L6gP|E1lo=Xc$hw~Afv(Sh{=gfc?d?wZ7>!fy2T ztEs$SQN0E3AFshtPWWH;jWVn~u`ryx@Yk%#@{UE8CdYMBsyJ1g0Qj&}4OvLR}A+EgQS#YN)P5t!0X>;61Mw2%Zce|Hp5rC?i@F=C^o8DZb9u%R*Y4G{HQB zKfe1E$prQcgzb2WxuW_PAob6H0008x1B2G(Ib*R_yOhQc3%M(ihYr$8$O=p87)o*V z#UchJ*pCk8VgDXFsa$<|71YsXV4F1H{`!#XoB@UBX_GZ(u=;zhl#!YIUjN&5H4$Lm zu93OJEKCi_=O_%ZI2@p70QjRUD;_HbR?o8Gs*l^qHa*qn72&=B8)>+c_QA0$R_glJvP_IyMem9}qcsnSXO5GdJ#ODBAcDS6qk8 zzMLX2o~h*|0@@ zr>}Qr5XpV@ts%uoIQOo-M&A+TYPcdBO>EHcCH1j#6$H9vP){bas=K2#YaDu{D4MeW zX-jN%;yS{1ubx6e15!A0sEh34%Zy{kTUNiB$dhmpj{)R!8fV30@tkW!;Vh>=mD z)myFZGx_wEc$Z$1SuO@cxPQ9oeEf65h21Tzl@rM>mIA1FdRbmqf`;lP3GiYcTAK&$ zltEX}>ixR>oaoE;iQ3nYvjD*W_|gZEH^yX(Fn5Ty^;GEcc%60ZmYkzw&z;cZW}ec8 zuOKdG>p~7!Bg5u1G|qR0WQ+S18rIt#Fmyo%wddepEWse#Q5rgYS`2B0rwODex$E{* zZDrsAZN5-g3YjxjO_&>enAwJ2QWD)Kpj}`EEyL`^Yy(zOC{VO?Fm(jMJBe*nY4GKo z<_Z2k;CTLt;!)>>S}{7Dez(E|+~=~c2>`Qp+@X8Z zHP~Crrao$m7O%jy*f*rBLi}^DqN6m_r=5B-6P#)^X_F5TvYhlfZ$Ikudt9bg%nGZ@ zmHI!qe5X>)wc!{fS@xB&Ngd!Nbr+QTP{P#xwlC3sH0+}r1cEKs;Nfx92<7;+*iA`> zC#V9?4mlM)J-#^XT@YIdo0-1QnD6>@_4-3mo7#~lNAew3)8oS6SUFTkqdjiS0g8nG+|&fZCbpj1En2vn|6vc z&D9JHj2M(%Ngp1qwB4i$P=yt%s$6oj(gy#zVgsrLR7l<*c_QmEQ-jl%h(m4LfqM&V zP*_;1-aAsus7AT{A=fTJqS-?Y`i02qkA15TuNP&piYrBO!jq>_=r*F1XQHQMbJ+qRudZo3H2FmeJ+q<5>;^n2eM|scVO1&nIQQ8A>{VqS;EC2O9HCwv5bF7_ z$gcEXHl2^&nQ`-tCN@TYOJ&NWxR#h`?@Y>#p65hyo~{axhJ!j*atAFR^H2NJF&hl?iw#1(`DuV(Htp{KA3)9k{#TgM2KlzKZ^HfWIX{E`ema?-v&Y|G zoF8k;xEEZFJt#wWh#GwD!m0rZ%MD5Sg0`TukLZAy@GiZ)0}r9ts>;-8gtGZx`sITS zA%kSyG{+XxiofquJ!aT+{uh_q%Lb596y$%Z9~6gi0jR=tiC8fWo)CIWlP@uc3;shC z@Hz+S*SH(eO^7HdVbT6H^q4>acG5LWNC~J|Nb~trE*%#n+#v>;$n25ufs`HcOZAG2 zdzaF%IxlPAWKZcrIhTZOh4cdP9$9pUJ#of)SX&p|tOv+>IxTmHc(6`-HkHCZ$Fu3< zb~pb+hRBXiNry<)c%UluUQ|D@IO1PFJ*VIet{9~MPj&8dkYA^45)q@YkUTogdVWUK zIlO2II=TtCKBa2!G-I@bP~nsO3}1Dn##0OTkpYXgFJ(x}5bd4zem7@Q!^$CjKl>#z z_CQ)Ps_il59+=XU)?*XuKkhRRN#&ff4l&A)qM>n6`h`<44oKNJKL z>?=aZ8EUH&NZx*DgDy7V6@cG8jmNHz!>x~{Ppl3)nlox0htg{rom}F}K<)yw9bVWW z4rZ^1HS*D<5FwF)xUZR2xIfZ84K(1wyre}w5;%HT%h!`~<{LOs?fbbdq7qikiud5S zjKm((vP_gabl6AZA^bcwPaS#2JEt>ZW^y8P;>xV*xORzwv|x&bDTqsEV`ufH5#q~= z-U|jSeJ$N1qG-z7@{Ia2x<3~7+2qeP## z64w$4kh#+e87G`BI;+|$+cIQ1*Y}}%y5{~xu z!ke_>-k(@lu-Q_!-8JU6$+wXJlXcHA5vHq(JzaJ20Rsnv!G!@fP*YUb>mi@OExdXP z2BRHRXfN&z3O;W28+kv&sc-$4;b++pXLr){_l88!niX#Z5eE6L>}a~6QjZzM*=n;- z7RYzXXsrhMvOn=$))_T9-dnQ4P-w$VdqtoOIkfW&_-#M#8V`?-V-56$DryhMxoJx1 zIM4@_cw6$WLWRqg$s8N~00}&8<-rmInYUv!{XS|bSf&ceIyYATCg(T&39y|0)F;VG zkUY&=K^&1jIReF}f<98HYZ~M?U;A`#;y`)vKN;rbDfXa2v3aq6GTTC2sR~pFYJHu z@>u>EnE!mBG54I;C@!3ruBVF|nrR-=_Z!K_ojRp*AvRjv5Z#u#4=vfT`@M{ns@owk z%UM1s&)qGL_$dcD#R2QU3(N*asAE%{K@wIyGr z0L`~}wA@qrFn#O-c?L^It=|!^o|JnSMHKL*NO%HOcy-(4#i(sCzxQNd#G>5;C}20= zL^V3<{IiwgXL~<7~DLcQW=ZiyM*#)QKi z-7a@}m`#ooDfOOO%w()U#)S#t@VBmr%Y^GcdT;T{1501C2Wx6;c2X(!Y)xQCXh1|@ zdzDR`AmduG)6I@cXxhnkb0-VI8j;be0>>ka5pWG_cl5*T)cBzEq@akF>S7&w^xbx< zUMJtZ5kpj)5s>367{CqSIN?*Hlur!>XnrbWM<4QnO?po%+ zt${*9D@mG#c>{;WkXi$^m303n?HM~1r>HL;rGYEXU30}8eh`)b007U&#geoked9&c zpbdujvm8PXDqCoYLNDkx0IQaEzng{!_tQw9S6t*95GA;gFB#tw@CM%?hex!W+{<{J zfO05t!;7NAL%yHDFW5M*(o5S0Q9cLv`q>IOSpKozWVU9rSf237bf*J;IrUHtl7mJE zxcuCpru(@cE*J`9MW=YSRKl~Ym4v=lPhr{>aJC^su3q$%+d;Ew+AXuaACFQ>(!u0b z8kRm=X~?A?m{J%!`=|ix8Z+xAVpuC6*{&<_9BD9vr4$!}Ly@~+i6^n@$_+60SIf;y zp%!FTYTI56YJ<()H0kXav}wZYEERASHKi=(rRNwmFuni)Lr|Kl;NN*|YMby*0>P=? z@JTxnBrxBJ=69GV04a{C{S{ha5OE!SR&PJe2|exhLn^0=n_XV2gIM!=M3LF}E?Vl-uO zrx@D|ZhuU3(xGK__8lp4fB*mh7_ceErdYdEdh%%O>3-yC*|x(B{i%p=;BvS6o81sF zhwrX-LHtMv#$U%%kSb9bilj|UN@(#cJjqhtn6^};FS|j$2J_}#PkG5n9Dv*H^_@;? zTzR|xou8f2JttAbBsuUS$;Sv0i=a-rlz(5(-??R1K5cz0#Flk+-<8AnRlW6Vqik)v zkoHmv2(2F!LX+b0>ru9aQ*|T&0014M36Ce38n_tB1fH(syu5JcqiDY9zPrlS6qo>f zS@wX(!byOT+e>l_wGE##iD}zTNOwMg+N%BhMXmhuqc_aTdkV~kC*^;vQ$j67wpO-r z#2eQnSyNi3yne4)>1Gi0u_}D6Z|ZyC000IY0274aL>@+!S^&#Pf= (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_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): + n_major = 16 + n_minor = 8 + 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_wheel_wood(bm, loc, wood): + wood.extend( + add_rim(bm, loc, RIM_MAJOR, RIM_MINOR, WOOD_IDX, euler=(math.pi / 2.0, 0.0, 0.0)) + ) + wood.extend( + add_cyl( + bm, + loc, + HUB_R, + HUB_W, + 12, + WOOD_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + ) + inner = HUB_R + 0.008 + outer = RIM_MAJOR - RIM_MINOR * 0.55 + mid_r = 0.5 * (inner + outer) + slen = outer - inner + for i in range(N_SPOKES): + a = i * (2.0 * math.pi / N_SPOKES) + dx = math.cos(a) + dz = math.sin(a) + cx = loc[0] + dx * mid_r + cy = loc[1] + cz = loc[2] + dz * mid_r + wood.extend( + add_box( + bm, + (cx, cy, cz), + (slen, SPOKE_T * 0.85, SPOKE_T), + WOOD_IDX, + euler=(0.0, -a, 0.0), + ) + ) + + +def add_wheel_metal(bm, loc, metal): + metal.extend( + add_rim( + bm, + loc, + RIM_MAJOR + 0.004, + RIM_MINOR * 0.45, + METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + ) + metal.extend( + add_cyl( + bm, + loc, + HUB_R + 0.010, + 0.016, + 12, + METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + ) + metal.extend( + add_cyl( + bm, + loc, + 0.014, + 0.10, + 8, + METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + ) + + +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_barrow_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + try: + wood = [] + metal = [] + zc = 0.26 + TRAY_R + cx = 0.0 + + def pt(a, x): + return ( + x, + TRAY_R * math.sin(a), + zc - TRAY_R * math.cos(a), + ) + + gap = 0.12 + for i in range(N_STAVES): + t0 = i / N_STAVES + t1 = (i + (1.0 - gap)) / N_STAVES + a0 = -A_SPAN + 2.0 * A_SPAN * t0 + a1 = -A_SPAN + 2.0 * A_SPAN * t1 + mid = 0.5 * (a0 + a1) + chord = TRAY_R * abs(a1 - a0) * 0.95 + wood.extend( + add_box( + bm, + pt(mid, cx), + (TRAY_L, chord, STAVE_T), + WOOD_IDX, + euler=(mid, 0.0, 0.0), + ) + ) + wood.extend( + add_box( + bm, + pt(mid, cx + TRAY_L / 2.0 + 0.014), + (0.028, chord * 1.02, STAVE_T * 1.05), + WOOD_IDX, + euler=(mid, 0.0, 0.0), + ) + ) + + rim_y = TRAY_R * math.sin(A_SPAN) + rim_z = zc - TRAY_R * math.cos(A_SPAN) + for ysign in (-1.0, 1.0): + start = ( + cx - TRAY_L / 2.0 + 0.05, + ysign * rim_y, + rim_z, + ) + end = ( + cx - TRAY_L / 2.0 - HANDLE_L, + ysign * (rim_y + 0.015), + 0.56, + ) + wood.extend( + add_oriented_box(bm, start, end, (0.030, 0.030), WOOD_IDX) + ) + + lx = cx - TRAY_L * 0.30 + for ysign in (-1.0, 1.0): + top = (lx, ysign * 0.11, zc - TRAY_R * 0.35) + bot = (lx - 0.03, ysign * 0.18, 0.02) + wood.extend(add_oriented_box(bm, top, bot, (0.034, 0.034), WOOD_IDX)) + wood.extend( + add_oriented_box( + bm, + (lx, -0.15, 0.13), + (lx, 0.15, 0.13), + (0.026, 0.026), + 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 + + wx = cx + TRAY_L / 2.0 + 0.04 + wz = RIM_MAJOR + add_wheel_wood(bm, (wx, 0.0, wz), wood) + add_wheel_metal(bm, (wx, 0.0, wz), metal) + + r_strap = TRAY_R + STAVE_T * 0.55 + 0.004 + for sx in (-TRAY_L * 0.28, TRAY_L * 0.22): + n = 11 + pts = [] + for k in range(n + 1): + a = -A_SPAN + 2.0 * A_SPAN * k / n + pts.append( + ( + cx + sx, + r_strap * math.sin(a), + zc - r_strap * math.cos(a), + ) + ) + for p0, p1 in zip(pts, pts[1:]): + metal.extend( + add_oriented_box(bm, p0, p1, (0.018, 0.010), METAL_IDX) + ) + + fy = 0.045 + for ysign in (-1.0, 1.0): + metal.extend( + add_oriented_box( + bm, + (cx + TRAY_L / 2.0 - 0.05, ysign * fy, zc - TRAY_R * 0.55), + (wx, ysign * fy, wz), + (0.016, 0.016), + METAL_IDX, + ) + ) + metal.extend( + add_box( + bm, + (lx - 0.03, ysign * 0.18, 0.012), + (0.055, 0.042, 0.022), + 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, 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("BarrowNrm", 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_barrow_mesh("BarrowLow", bevel_offset=0.005, bevel_segments=2) + high = build_barrow_mesh("BarrowHigh", bevel_offset=0.005, bevel_segments=4) + wood = principled("BarrowWood", (0.38, 0.21, 0.09, 1.0), 0.0, 0.58) + metal = principled("BarrowIron", (0.10, 0.105, 0.12, 1.0), 1.0, 0.30) + assign_slots(low, wood, metal) + assign_slots(high, wood, metal) + + if low.data is None or len(low.data.polygons) < 6: + return fail("barrow 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("barrow has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "BarrowLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "BarrowLOD2", 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_barrow_mesh("BarrowColSrc", bevel_offset=0.0, bevel_segments=1) + collider = convex_hull_collider(collider_src, "BarrowCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_wheelbarrow_{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(-32.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.82, -2.22, 1.12) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.34) + 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("wheelbarrow OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index 5c5664b..5e08356 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -85,5 +85,6 @@ {"name": "fence-kit", "script": "showcase/fence-kit/fence_kit.py"}, {"name": "watchtower", "script": "showcase/watchtower/watchtower.py"}, {"name": "cart", "script": "showcase/cart/cart.py"}, - {"name": "park-bench", "script": "showcase/park-bench/park_bench.py"} + {"name": "park-bench", "script": "showcase/park-bench/park_bench.py"}, + {"name": "wheelbarrow", "script": "showcase/wheelbarrow/wheelbarrow.py"} ]