From 9366c9a3a5f9f78623d2a5acbce0f334df30274e Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:16:11 -0400 Subject: [PATCH 1/2] feat: add wooden-bucket showcase piece The prompt list is exhausted; ROADMAP's next prop is a coopered bucket with a sealed hull and rope bail, not another gapped stave miss. 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/wooden-bucket-hero.webp | Bin 0 -> 15036 bytes docs/gallery/index.html | 13 +- docs/gallery/wooden-bucket/index.html | 1135 +++++++++++++++++++ showcase/gallery.json | 12 + showcase/wooden-bucket/README.md | 73 ++ showcase/wooden-bucket/preview.webp | Bin 0 -> 12330 bytes showcase/wooden-bucket/wooden_bucket.py | 811 +++++++++++++ tests/smoke/catalog.json | 3 +- 13 files changed, 2055 insertions(+), 8 deletions(-) create mode 100644 docs/gallery/assets/wooden-bucket-hero.webp create mode 100644 docs/gallery/wooden-bucket/index.html create mode 100644 showcase/wooden-bucket/README.md create mode 100644 showcase/wooden-bucket/preview.webp create mode 100644 showcase/wooden-bucket/wooden_bucket.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 839f1c0..9749bd2 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -157,6 +157,7 @@ "showcase/grindstone", "showcase/hand-pump", "showcase/signpost", - "showcase/chopping-block" + "showcase/chopping-block", + "showcase/wooden-bucket" ] } diff --git a/AGENTS.md b/AGENTS.md index e7f61cb..c78af34 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 20 showcase pieces (counts are CI-enforced against README.md) +examples, and 21 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 c59ef3e..22faf42 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, 20 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 21 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 0114e5f..c58619e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  20 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  21 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 20 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 21 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. @@ -95,6 +95,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Hand pump: a cast-iron village pump on a wooden plinth with a spout and handle on a dark studio floor Signpost: a timber post with two painted fingerboards, iron straps, and a pyramidal cap on a dark studio floor Chopping block: a hooped timber stump with an embedded axe on a dark studio floor +Wooden bucket: a coopered bucket with overlapping staves, iron hoops, and a rope bail 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. @@ -136,6 +137,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`chopping-block`](showcase/chopping-block/) — procedural hooped timber stump with an embedded axe through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`wooden-bucket`](showcase/wooden-bucket/) — procedural coopered wooden bucket with iron hoops and a rope bail 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 d66b147..576dd1f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -125,7 +125,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Timber fingerboard signpost as a game-prop showcase piece~~ **SHIPPED** as `showcase/signpost/` — square post, pyramidal cap, two painted boards, iron shoe and straps; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Blacksmith bellows as a game-prop showcase piece~~ **DROPPED** — leather bag never read; removed from the showcase tree rather than shipping a crate-shaped miss - ~~Chopping block with embedded axe as a game-prop showcase piece~~ **SHIPPED** as `showcase/chopping-block/` — hooped timber stump, kerf, embedded axe; `--skip-decimate` exits 9 on the LOD1 ratio band -- Wooden bucket with iron bands and a rope bail as a game-prop showcase piece +- ~~Wooden bucket with iron bands and a rope bail as a game-prop showcase piece~~ **SHIPPED** as `showcase/wooden-bucket/` — overlapping staves, sealed bottom, iron hoops, ears, rope bail; `--skip-decimate` exits 9 on the LOD1 ratio band - Wall-mounted torch sconce as a game-prop showcase piece - ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **SHIPPED** as `showcase/fence-kit/` — tiling post-and-rail section (AABB X is the tile width; does not re-witness the snap contract); `--skip-decimate` exits 9 on the LOD1 ratio band diff --git a/docs/gallery/assets/wooden-bucket-hero.webp b/docs/gallery/assets/wooden-bucket-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..9c1133df27065cf8f3edfe568d56186bbaf88c30 GIT binary patch literal 15036 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 S%Z;(f|Me0000ncn+Wd literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index f813ed5..93233d9 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" /> - 71 examples + 72 examples
@@ -1105,6 +1105,17 @@

chopping-block

View example
+
+ + wooden-bucket — A procedural coopered wooden bucket with overlapping staves, a sealed bottom, iron hoops, and a rope bail through UVs, bake, LOD, collider, and Unity glTF… + +
+

wooden-bucket

+

A procedural coopered wooden bucket with overlapping staves, a sealed bottom, iron hoops, and a rope bail through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 4272 tris, three materials with 222 metal and 72 rope faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.402×0.345×0.544 m, LOD ratios in band, convex collider 390 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

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

wooden-bucket

+

A procedural coopered wooden bucket with overlapping staves, a sealed bottom, iron hoops, and a rope bail 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: 4272 tris, three materials with 222 metal and 72 rope faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.402×0.345×0.544 m, LOD ratios in band, convex collider 390 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/wooden-bucket/wooden_bucket.py --
+ +
+
+

A showcase piece, not an example. Procedural coopered bucket (overlapping staves, sealed bottom, iron hoops, ears, rope bail) 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 | 4150–4400 | 4272 / 4272 / 4272 | | 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.2196 | | Materials | exactly 3 distinct, ≥24 metal, ≥12 rope | 3 slots, 222 metal, 72 rope | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.402, 0.345, 0.544) m ± 0.01 | (0.4020, 0.3449, 0.5439), zmin 0 | | Collider tris | ≤ 420 | 390 | | Export | written, size > 0 | 330476 / 330476 / 330464 bytes |

+

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

+

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

+

Run

+
blender --background --python wooden_bucket.py --
+blender --background --python wooden_bucket.py -- --skip-decimate
+blender --background --python wooden_bucket.py -- --output bucket.png
+

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

+

Exit codes

+

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

+

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 3 distinct slots, or metal/rope 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/wooden-bucket/wooden_bucket.py + View on GitHub → +
+
"""Game-ready wooden bucket — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural coopered bucket after composing
+shipped pipeline pieces: bmesh construction, UVs, three materials,
+high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
+DECIMATE stage so the LOD-ratio budget fails.
+
+No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
+are not byte-identical across Blender versions — the LOD gate is a
+ratio band, not an exact count.
+
+    blender --background --python wooden_bucket.py --
+    blender --background --python wooden_bucket.py -- --skip-decimate
+    blender --background --python wooden_bucket.py -- --output bucket.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+# Showcase lives at repo-root/showcase/, not under examples/. The framing
+# helper is the repo's only shared import and lives next to the examples;
+# resolve the repo root so we do not move gallery_framing.py.
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+HEIGHT = 0.36
+R_BOT = 0.125
+R_TOP = 0.168
+N_STAVES = 14
+STAVE_THICK = 0.022
+N_RINGS = 4
+# Negative gap = overlapping staves so the hull is sealed.
+STAVE_GAP = -0.08
+HOOP_ZS = (0.055, 0.155, 0.305)
+HOOP_H = 0.026
+HOOP_PAD = 0.011
+BOTTOM_T = 0.024
+BAIL_R = 0.012
+BAIL_SEGS = 12
+BBOX_TOL = 0.01
+# Fitted after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (0.402, 0.345, 0.544)
+
+BASE_TRIS_MIN = 4150
+BASE_TRIS_MAX = 4400
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 3
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 420
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.06
+METAL_FACES_MIN = 24
+ROPE_FACES_MIN = 12
+
+WOOD_IDX = 0
+METAL_IDX = 1
+ROPE_IDX = 2
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
+    depsgraph = bpy.context.evaluated_depsgraph_get()
+    eval_obj = obj.evaluated_get(depsgraph)
+    eval_mesh = eval_obj.to_mesh()
+    try:
+        eval_mesh.calc_loop_triangles()
+        return len(eval_mesh.loop_triangles)
+    finally:
+        eval_obj.to_mesh_clear()
+
+
+def radius_at(z):
+    t = max(0.0, min(1.0, z / HEIGHT))
+    return R_BOT + (R_TOP - R_BOT) * t
+
+
+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_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius1,
+        radius2=radius2,
+        depth=depth,
+    )
+    verts = list(geo["verts"])
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler)
+
+
+def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)):
+    n_major = 12
+    n_minor = 6
+    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)
+    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 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_bucket_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    stave_verts = []
+    hoop_faces = set()
+    rope_faces = set()
+    try:
+        zs = [HEIGHT * i / (N_RINGS - 1) for i in range(N_RINGS)]
+        for i in range(N_STAVES):
+            a0 = 2.0 * math.pi * i / N_STAVES
+            a1 = 2.0 * math.pi * (i + 1.0 - STAVE_GAP) / N_STAVES
+            outer = []
+            inner = []
+            for z in zs:
+                r = radius_at(z)
+                ov = (
+                    bm.verts.new((r * math.cos(a0), r * math.sin(a0), z)),
+                    bm.verts.new((r * math.cos(a1), r * math.sin(a1), z)),
+                )
+                ri = r - STAVE_THICK
+                iv = (
+                    bm.verts.new((ri * math.cos(a0), ri * math.sin(a0), z)),
+                    bm.verts.new((ri * math.cos(a1), ri * math.sin(a1), z)),
+                )
+                outer.append(ov)
+                inner.append(iv)
+                stave_verts.extend(ov)
+                stave_verts.extend(iv)
+            for k in range(N_RINGS - 1):
+                o0a, o0b = outer[k]
+                o1a, o1b = outer[k + 1]
+                i0a, i0b = inner[k]
+                i1a, i1b = inner[k + 1]
+                for vs in (
+                    (o0a, o1a, o1b, o0b),
+                    (i0b, i1b, i1a, i0a),
+                    (o0a, i0a, i1a, o1a),
+                    (o0b, o1b, i1b, i0b),
+                ):
+                    face = bm.faces.new(vs)
+                    face.material_index = WOOD_IDX
+            # Rim only — no top lid. Interior stays open.
+            top = bm.faces.new((outer[-1][0], outer[-1][1], inner[-1][1], inner[-1][0]))
+            top.material_index = WOOD_IDX
+            bot = bm.faces.new((outer[0][1], outer[0][0], inner[0][0], inner[0][1]))
+            bot.material_index = WOOD_IDX
+
+        if bevel_offset > 0.0:
+            edges = list({e for v in stave_verts for e in v.link_edges})
+            bmesh.ops.bevel(
+                bm,
+                geom=edges,
+                offset=bevel_offset,
+                segments=bevel_segments,
+                profile=0.5,
+                affect="EDGES",
+                clamp_overlap=True,
+            )
+
+        # Bottom seated inside the hull so the basin actually holds.
+        add_cylinder(
+            bm,
+            (0.0, 0.0, BOTTOM_T / 2.0),
+            R_BOT - STAVE_THICK * 0.35,
+            BOTTOM_T,
+            16,
+            WOOD_IDX,
+        )
+
+        for z in HOOP_ZS:
+            before = set(bm.faces)
+            add_cylinder(
+                bm,
+                (0.0, 0.0, z),
+                radius_at(z) + HOOP_PAD,
+                HOOP_H,
+                20,
+                METAL_IDX,
+            )
+            hoop_faces.update(set(bm.faces) - before)
+
+        ear_x = R_TOP + 0.006
+        ear_z = HEIGHT - 0.018
+        for sign in (-1.0, 1.0):
+            before = set(bm.faces)
+            add_box(
+                bm,
+                (sign * ear_x, 0.0, ear_z),
+                (0.028, 0.036, 0.048),
+                METAL_IDX,
+            )
+            add_rim(
+                bm,
+                (sign * (ear_x + 0.006), 0.0, ear_z + 0.012),
+                0.016,
+                0.005,
+                METAL_IDX,
+                euler=(math.radians(90.0), 0.0, 0.0),
+            )
+            hoop_faces.update(set(bm.faces) - before)
+
+        bail_major = ear_x + 0.004
+        bail_pts = []
+        for i in range(BAIL_SEGS + 1):
+            t = math.pi * i / BAIL_SEGS
+            bail_pts.append(
+                (
+                    bail_major * math.cos(t),
+                    0.0,
+                    ear_z + 0.012 + bail_major * math.sin(t),
+                )
+            )
+        before = set(bm.faces)
+        for a, b in zip(bail_pts, bail_pts[1:]):
+            add_oriented_box(bm, a, b, (BAIL_R * 2.0, BAIL_R * 2.0), ROPE_IDX)
+        rope_faces.update(set(bm.faces) - before)
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = True
+        for edge in bm.edges:
+            edge.smooth = True
+            if edge.is_manifold and len(edge.link_faces) == 2:
+                if edge.calc_face_angle() > math.radians(35.0):
+                    edge.smooth = False
+        for f in hoop_faces:
+            if f.is_valid:
+                f.material_index = METAL_IDX
+        for f in rope_faces:
+            if f.is_valid:
+                f.material_index = ROPE_IDX
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(obj)
+    return obj
+
+
+def principled(name, color, metallic, roughness):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def assign_slots(obj, wood, metal, rope):
+    mats = obj.data.materials
+    if len(mats) == 0:
+        mats.append(wood)
+        mats.append(metal)
+        mats.append(rope)
+        return
+    mats[0] = wood
+    if len(mats) == 1:
+        mats.append(metal)
+    else:
+        mats[1] = metal
+    if len(mats) == 2:
+        mats.append(rope)
+    else:
+        mats[2] = rope
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    overlap = 0.0
+    for i in range(len(aabbs)):
+        a = aabbs[i]
+        for j in range(i + 1, len(aabbs)):
+            b = aabbs[j]
+            x0 = max(a[0], b[0])
+            y0 = max(a[1], b[1])
+            x1 = min(a[2], b[2])
+            y1 = min(a[3], b[3])
+            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    # Duplicated from snippets/convex_hull_collider.py (not a package).
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    # Adapted from snippets/setup_bake_target_image.py — do not replace slots.
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("BucketNrm", size, size, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    nodes = target_mat.node_tree.nodes
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = img
+    nodes.active = tex
+    tex.select = True
+    obj.active_material_index = WOOD_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    # Duplicated from snippets/bake_normal_high_to_low.py (not a package).
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL",
+        use_selected_to_active=True,
+        cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False,
+        normal_space="TANGENT",
+        margin=4,
+        margin_type="ADJACENT_FACES",
+        use_clear=True,
+        target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    # Duplicated from snippets/export_preset_unity.py (not a package).
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path,
+        use_selection=True,
+        export_yup=True,
+        export_apply=True,
+        export_draco_mesh_compression_enable=False,
+        export_animations=False,
+    )
+
+
+def check(skip_decimate):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    low = build_bucket_mesh("BucketLow", bevel_offset=0.003, bevel_segments=2)
+    high = build_bucket_mesh("BucketHigh", bevel_offset=0.003, bevel_segments=4)
+    wood = principled("BucketWood", (0.46, 0.24, 0.09, 1.0), 0.0, 0.52)
+    metal = principled("BucketMetal", (0.58, 0.56, 0.52, 1.0), 1.0, 0.24)
+    rope = principled("BucketRope", (0.42, 0.32, 0.18, 1.0), 0.0, 0.78)
+    assign_slots(low, wood, metal, rope)
+    assign_slots(high, wood, metal, rope)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("bucket 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("bucket has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "BucketLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "BucketLOD2", 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_bucket_mesh("BucketColSrc", bevel_offset=0.0, bevel_segments=1)
+    collider = convex_hull_collider(collider_src, "BucketCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_wooden_bucket_{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(ROPE_IDX, 0) < ROPE_FACES_MIN:
+        return fail(
+            f"rope faces {idx_counts.get(ROPE_IDX, 0)} < {ROPE_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
+
+    # Three-quarter from above so the open basin and bottom read.
+    low.rotation_euler.z = math.radians(-38.0)
+    low.rotation_euler.x = math.radians(8.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.05, -1.42, 1.12)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.30)
+    scene.collection.objects.link(aim)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+        scene.cycles.device = "CPU"
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = (
+        "WEBP" if path.lower().endswith(".webp") else "PNG"
+    )
+    if path.lower().endswith(".webp"):
+        scene.render.image_settings.quality = 90
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    fcode = gallery_framing.check_framing(
+        scene, cam, hero=[low], elements=[low], stage=[floor, wall],
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        return fail("render produced no file", 14)
+    return 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None)
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    p.add_argument(
+        "--skip-decimate",
+        action="store_true",
+        help="falsification: skip the LOD DECIMATE stage",
+    )
+    args = p.parse_args(argv)
+
+    code, low, _high, wood, tex, _col = check(args.skip_decimate)
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("wooden-bucket 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 51bf8a2..6640e91 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -244,6 +244,18 @@ "mesh", "export" ] + }, + { + "name": "wooden-bucket", + "dir": "showcase/wooden-bucket", + "teaches": "A procedural coopered wooden bucket with overlapping staves, a sealed bottom, iron hoops, and a rope bail through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 4272 tris, three materials with 222 metal and 72 rope faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.402×0.345×0.544 m, LOD ratios in band, convex collider 390 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/wooden-bucket-hero.webp", + "preview": "showcase/wooden-bucket/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/wooden-bucket/README.md b/showcase/wooden-bucket/README.md new file mode 100644 index 0000000..d60ab42 --- /dev/null +++ b/showcase/wooden-bucket/README.md @@ -0,0 +1,73 @@ +# Wooden bucket + +A showcase piece, not an example. Procedural coopered bucket (overlapping +staves, sealed bottom, iron hoops, ears, rope bail) 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 | 4150–4400 | 4272 / 4272 / 4272 | +| 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.2196 | +| Materials | exactly 3 distinct, ≥24 metal, ≥12 rope | 3 slots, 222 metal, 72 rope | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (0.402, 0.345, 0.544) m ± 0.01 | (0.4020, 0.3449, 0.5439), zmin 0 | +| Collider tris | ≤ 420 | 390 | +| Export | written, size > 0 | 330476 / 330476 / 330464 bytes | + +DECIMATE COLLAPSE triangle counts are **not** identical across series — +the gate is a ratio band, not an exact count. Bake pixels are stochastic; +the gate is `has_data` plus operator `FINISHED`, not byte-identity. +Construction uses no RNG. Export byte counts differ by 12 B on 5.2.1 +(glTF serializer), not a gated axis. + +`--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and +exit 9 fires. That is the named budget the falsifier violates. + +## Run + +```bash +blender --background --python wooden_bucket.py -- +blender --background --python wooden_bucket.py -- --skip-decimate +blender --background --python wooden_bucket.py -- --output bucket.png +``` + +Smoke does not pass `--output` or `--skip-decimate`. + +## Exit codes + +File-local. `9` is a valid check code. `10` is reserved for +`gallery_framing.check_framing` on the `--output` path. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build / no UV layer | +| 4 | Base triangle count outside range | +| 5 | Material count ≠ 3 distinct slots, or metal/rope faces missing | +| 6 | UVs outside 0..1 | +| 7 | UV AABB overlap above tolerance | +| 8 | World AABB off declared outer size | +| 9 | LOD ratio band (`--skip-decimate` lands here) | +| 10 | Framing gate (render path only) | +| 11 | Collider triangle count above ceiling | +| 12 | Bake did not finish or image has no data | +| 13 | Export file missing or empty | +| 14 | `--output` produced no file | diff --git a/showcase/wooden-bucket/preview.webp b/showcase/wooden-bucket/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..289a57b645c406f6924f4562b6b99ecb6010569a GIT binary patch literal 12330 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 Q{0Xoo*C1cg4V7d70MKvwjQ{`u literal 0 HcmV?d00001 diff --git a/showcase/wooden-bucket/wooden_bucket.py b/showcase/wooden-bucket/wooden_bucket.py new file mode 100644 index 0000000..15bc595 --- /dev/null +++ b/showcase/wooden-bucket/wooden_bucket.py @@ -0,0 +1,811 @@ +"""Game-ready wooden bucket — a showcase piece, not an example. + +Asserts budget conformance of a procedural coopered bucket after composing +shipped pipeline pieces: bmesh construction, UVs, three materials, +high-to-low normal bake, LOD chain, convex collider, Unity glTF export. + +Budgets are declared below and recomputed from the generated result. +They are not API-contract witnesses. ``--skip-decimate`` skips the LOD +DECIMATE stage so the LOD-ratio budget fails. + +No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts +are not byte-identical across Blender versions — the LOD gate is a +ratio band, not an exact count. + + blender --background --python wooden_bucket.py -- + blender --background --python wooden_bucket.py -- --skip-decimate + blender --background --python wooden_bucket.py -- --output bucket.png +""" +import argparse +import math +import os +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Euler, Vector + +# Showcase lives at repo-root/showcase/, not under examples/. The framing +# helper is the repo's only shared import and lives next to the examples; +# resolve the repo root so we do not move gallery_framing.py. +_REPO = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) +) +sys.path.insert(0, os.path.join(_REPO, "examples")) +sys.dont_write_bytecode = True +import gallery_framing # noqa: E402 + +HEIGHT = 0.36 +R_BOT = 0.125 +R_TOP = 0.168 +N_STAVES = 14 +STAVE_THICK = 0.022 +N_RINGS = 4 +# Negative gap = overlapping staves so the hull is sealed. +STAVE_GAP = -0.08 +HOOP_ZS = (0.055, 0.155, 0.305) +HOOP_H = 0.026 +HOOP_PAD = 0.011 +BOTTOM_T = 0.024 +BAIL_R = 0.012 +BAIL_SEGS = 12 +BBOX_TOL = 0.01 +# Fitted after locking geometry. Recomputed from bound_box. +OUTER_SIZE = (0.402, 0.345, 0.544) + +BASE_TRIS_MIN = 4150 +BASE_TRIS_MAX = 4400 +LOD1_RATIO_MIN = 0.32 +LOD1_RATIO_MAX = 0.62 +LOD2_RATIO_MIN = 0.10 +LOD2_RATIO_MAX = 0.35 +LOD1_TARGET = 0.50 +LOD2_TARGET = 0.22 +MATERIAL_COUNT = 3 +UV_EPS = 1e-4 +UV_OVERLAP_MAX = 1e-5 +COLLIDER_TRIS_MAX = 420 +BAKE_RES = 256 +CAGE_EXTRUSION = 0.06 +METAL_FACES_MIN = 24 +ROPE_FACES_MIN = 12 + +WOOD_IDX = 0 +METAL_IDX = 1 +ROPE_IDX = 2 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package). + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def radius_at(z): + t = max(0.0, min(1.0, z / HEIGHT)) + return R_BOT + (R_TOP - R_BOT) * t + + +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_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius1, + radius2=radius2, + depth=depth, + ) + verts = list(geo["verts"]) + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler) + + +def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): + n_major = 12 + n_minor = 6 + 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) + 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 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_bucket_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + stave_verts = [] + hoop_faces = set() + rope_faces = set() + try: + zs = [HEIGHT * i / (N_RINGS - 1) for i in range(N_RINGS)] + for i in range(N_STAVES): + a0 = 2.0 * math.pi * i / N_STAVES + a1 = 2.0 * math.pi * (i + 1.0 - STAVE_GAP) / N_STAVES + outer = [] + inner = [] + for z in zs: + r = radius_at(z) + ov = ( + bm.verts.new((r * math.cos(a0), r * math.sin(a0), z)), + bm.verts.new((r * math.cos(a1), r * math.sin(a1), z)), + ) + ri = r - STAVE_THICK + iv = ( + bm.verts.new((ri * math.cos(a0), ri * math.sin(a0), z)), + bm.verts.new((ri * math.cos(a1), ri * math.sin(a1), z)), + ) + outer.append(ov) + inner.append(iv) + stave_verts.extend(ov) + stave_verts.extend(iv) + for k in range(N_RINGS - 1): + o0a, o0b = outer[k] + o1a, o1b = outer[k + 1] + i0a, i0b = inner[k] + i1a, i1b = inner[k + 1] + for vs in ( + (o0a, o1a, o1b, o0b), + (i0b, i1b, i1a, i0a), + (o0a, i0a, i1a, o1a), + (o0b, o1b, i1b, i0b), + ): + face = bm.faces.new(vs) + face.material_index = WOOD_IDX + # Rim only — no top lid. Interior stays open. + top = bm.faces.new((outer[-1][0], outer[-1][1], inner[-1][1], inner[-1][0])) + top.material_index = WOOD_IDX + bot = bm.faces.new((outer[0][1], outer[0][0], inner[0][0], inner[0][1])) + bot.material_index = WOOD_IDX + + if bevel_offset > 0.0: + edges = list({e for v in stave_verts for e in v.link_edges}) + bmesh.ops.bevel( + bm, + geom=edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + + # Bottom seated inside the hull so the basin actually holds. + add_cylinder( + bm, + (0.0, 0.0, BOTTOM_T / 2.0), + R_BOT - STAVE_THICK * 0.35, + BOTTOM_T, + 16, + WOOD_IDX, + ) + + for z in HOOP_ZS: + before = set(bm.faces) + add_cylinder( + bm, + (0.0, 0.0, z), + radius_at(z) + HOOP_PAD, + HOOP_H, + 20, + METAL_IDX, + ) + hoop_faces.update(set(bm.faces) - before) + + ear_x = R_TOP + 0.006 + ear_z = HEIGHT - 0.018 + for sign in (-1.0, 1.0): + before = set(bm.faces) + add_box( + bm, + (sign * ear_x, 0.0, ear_z), + (0.028, 0.036, 0.048), + METAL_IDX, + ) + add_rim( + bm, + (sign * (ear_x + 0.006), 0.0, ear_z + 0.012), + 0.016, + 0.005, + METAL_IDX, + euler=(math.radians(90.0), 0.0, 0.0), + ) + hoop_faces.update(set(bm.faces) - before) + + bail_major = ear_x + 0.004 + bail_pts = [] + for i in range(BAIL_SEGS + 1): + t = math.pi * i / BAIL_SEGS + bail_pts.append( + ( + bail_major * math.cos(t), + 0.0, + ear_z + 0.012 + bail_major * math.sin(t), + ) + ) + before = set(bm.faces) + for a, b in zip(bail_pts, bail_pts[1:]): + add_oriented_box(bm, a, b, (BAIL_R * 2.0, BAIL_R * 2.0), ROPE_IDX) + rope_faces.update(set(bm.faces) - before) + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = True + for edge in bm.edges: + edge.smooth = True + if edge.is_manifold and len(edge.link_faces) == 2: + if edge.calc_face_angle() > math.radians(35.0): + edge.smooth = False + for f in hoop_faces: + if f.is_valid: + f.material_index = METAL_IDX + for f in rope_faces: + if f.is_valid: + f.material_index = ROPE_IDX + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + finally: + bm.free() + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj + + +def principled(name, color, metallic, roughness): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = color + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + return mat + + +def assign_slots(obj, wood, metal, rope): + mats = obj.data.materials + if len(mats) == 0: + mats.append(wood) + mats.append(metal) + mats.append(rope) + return + mats[0] = wood + if len(mats) == 1: + mats.append(metal) + else: + mats[1] = metal + if len(mats) == 2: + mats.append(rope) + else: + mats[2] = rope + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0, 1.0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + overlap = 0.0 + for i in range(len(aabbs)): + a = aabbs[i] + for j in range(i + 1, len(aabbs)): + b = aabbs[j] + x0 = max(a[0], b[0]) + y0 = max(a[1], b[1]) + x1 = min(a[2], b[2]) + y1 = min(a[3], b[3]) + overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def convex_hull_collider(obj, name): + # Duplicated from snippets/convex_hull_collider.py (not a package). + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) + interior = result.get("geom_interior") or [] + unused = result.get("geom_unused") or [] + if interior: + bmesh.ops.delete(bm, geom=interior, context="VERTS") + if unused: + bmesh.ops.delete(bm, geom=unused, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + collider = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +def setup_bake_image(obj, target_mat, size=BAKE_RES): + # Adapted from snippets/setup_bake_target_image.py — do not replace slots. + if not obj.data.uv_layers: + return None, None + img = bpy.data.images.new("BucketNrm", size, size, alpha=True, float_buffer=False) + img.colorspace_settings.name = "Non-Color" + nodes = target_mat.node_tree.nodes + tex = nodes.new("ShaderNodeTexImage") + tex.image = img + nodes.active = tex + tex.select = True + obj.active_material_index = WOOD_IDX + return img, tex + + +def bake_normal(high, low): + # Duplicated from snippets/bake_normal_high_to_low.py (not a package). + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + return bpy.ops.object.bake( + type="NORMAL", + use_selected_to_active=True, + cage_extrusion=CAGE_EXTRUSION, + use_cage=False, + normal_space="TANGENT", + margin=4, + margin_type="ADJACENT_FACES", + use_clear=True, + target="IMAGE_TEXTURES", + ) + + +def export_unity(path, objects): + # Duplicated from snippets/export_preset_unity.py (not a package). + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + for ob in objects: + ob.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=False, + export_animations=False, + ) + + +def check(skip_decimate): + bpy.ops.wm.read_factory_settings(use_empty=True) + low = build_bucket_mesh("BucketLow", bevel_offset=0.003, bevel_segments=2) + high = build_bucket_mesh("BucketHigh", bevel_offset=0.003, bevel_segments=4) + wood = principled("BucketWood", (0.46, 0.24, 0.09, 1.0), 0.0, 0.52) + metal = principled("BucketMetal", (0.58, 0.56, 0.52, 1.0), 1.0, 0.24) + rope = principled("BucketRope", (0.42, 0.32, 0.18, 1.0), 0.0, 0.78) + assign_slots(low, wood, metal, rope) + assign_slots(high, wood, metal, rope) + + if low.data is None or len(low.data.polygons) < 6: + return fail("bucket 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("bucket has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "BucketLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "BucketLOD2", 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_bucket_mesh("BucketColSrc", bevel_offset=0.0, bevel_segments=1) + collider = convex_hull_collider(collider_src, "BucketCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_wooden_bucket_{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(ROPE_IDX, 0) < ROPE_FACES_MIN: + return fail( + f"rope faces {idx_counts.get(ROPE_IDX, 0)} < {ROPE_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 + + # Three-quarter from above so the open basin and bottom read. + low.rotation_euler.z = math.radians(-38.0) + low.rotation_euler.x = math.radians(8.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.05, -1.42, 1.12) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.30) + scene.collection.objects.link(aim) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 32 + scene.cycles.device = "CPU" + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = ( + "WEBP" if path.lower().endswith(".webp") else "PNG" + ) + if path.lower().endswith(".webp"): + scene.render.image_settings.quality = 90 + scene.render.filepath = path + scene.view_settings.view_transform = "Standard" + + fcode = gallery_framing.check_framing( + scene, cam, hero=[low], elements=[low], stage=[floor, wall], + ) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + return fail("render produced no file", 14) + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None) + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument( + "--skip-decimate", + action="store_true", + help="falsification: skip the LOD DECIMATE stage", + ) + args = p.parse_args(argv) + + code, low, _high, wood, tex, _col = check(args.skip_decimate) + if code: + return code + if args.output: + rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("wooden-bucket 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 61672a1..0ad279a 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -93,5 +93,6 @@ {"name": "grindstone", "script": "showcase/grindstone/grindstone.py"}, {"name": "hand-pump", "script": "showcase/hand-pump/hand_pump.py"}, {"name": "signpost", "script": "showcase/signpost/signpost.py"}, - {"name": "chopping-block", "script": "showcase/chopping-block/chopping_block.py"} + {"name": "chopping-block", "script": "showcase/chopping-block/chopping_block.py"}, + {"name": "wooden-bucket", "script": "showcase/wooden-bucket/wooden_bucket.py"} ] From e12ba000a9c7934b623c7821cdad48859b92c919 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:17:07 -0400 Subject: [PATCH 2/2] feat: add wall-torch showcase piece A wall sconce has to read as fire on iron, not a blank slab. Mount the torch on a coursed plaque and keep the flame facing the camera. 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/wall-torch-hero.webp | Bin 0 -> 23882 bytes docs/gallery/index.html | 13 +- docs/gallery/wall-torch/index.html | 1171 ++++++++++++++++++++++ showcase/gallery.json | 12 + showcase/wall-torch/README.md | 74 ++ showcase/wall-torch/preview.webp | Bin 0 -> 19552 bytes showcase/wall-torch/wall_torch.py | 847 ++++++++++++++++ tests/smoke/catalog.json | 3 +- 13 files changed, 2130 insertions(+), 8 deletions(-) create mode 100644 docs/gallery/assets/wall-torch-hero.webp create mode 100644 docs/gallery/wall-torch/index.html create mode 100644 showcase/wall-torch/README.md create mode 100644 showcase/wall-torch/preview.webp create mode 100644 showcase/wall-torch/wall_torch.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 9749bd2..f460be9 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -158,6 +158,7 @@ "showcase/hand-pump", "showcase/signpost", "showcase/chopping-block", - "showcase/wooden-bucket" + "showcase/wooden-bucket", + "showcase/wall-torch" ] } diff --git a/AGENTS.md b/AGENTS.md index c78af34..4e9fd61 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 21 showcase pieces (counts are CI-enforced against README.md) +examples, and 22 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 22faf42..8b0c2e2 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, 21 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 22 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 c58619e..a1374e9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  21 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  22 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 21 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 22 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. @@ -96,6 +96,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Signpost: a timber post with two painted fingerboards, iron straps, and a pyramidal cap on a dark studio floor Chopping block: a hooped timber stump with an embedded axe on a dark studio floor Wooden bucket: a coopered bucket with overlapping staves, iron hoops, and a rope bail on a dark studio floor +Wall torch: a coursed stone plaque with an iron sconce, wooden haft, and flame 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. @@ -139,6 +140,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`wooden-bucket`](showcase/wooden-bucket/) — procedural coopered wooden bucket with iron hoops and a rope bail through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`wall-torch`](showcase/wall-torch/) — procedural wall-mounted torch sconce with a stone plaque, iron bracket, and flame 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 576dd1f..306f731 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,7 +126,9 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Blacksmith bellows as a game-prop showcase piece~~ **DROPPED** — leather bag never read; removed from the showcase tree rather than shipping a crate-shaped miss - ~~Chopping block with embedded axe as a game-prop showcase piece~~ **SHIPPED** as `showcase/chopping-block/` — hooped timber stump, kerf, embedded axe; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Wooden bucket with iron bands and a rope bail as a game-prop showcase piece~~ **SHIPPED** as `showcase/wooden-bucket/` — overlapping staves, sealed bottom, iron hoops, ears, rope bail; `--skip-decimate` exits 9 on the LOD1 ratio band -- Wall-mounted torch sconce as a game-prop showcase piece +- ~~Wall-mounted torch sconce as a game-prop showcase piece~~ **SHIPPED** as `showcase/wall-torch/` — coursed stone plaque, iron bracket and cup, wooden haft, emissive flame; `--skip-decimate` exits 9 on the LOD1 ratio band +- Wooden tavern stool (turned legs, round seat, stretchers) as a game-prop showcase piece +- Cast-iron cooking cauldron on a tripod 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/wall-torch-hero.webp b/docs/gallery/assets/wall-torch-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..21046e4c5a68140cc68712a82ef3956403076106 GIT binary patch literal 23882 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 93233d9..620c668 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" /> - 72 examples + 73 examples
@@ -1116,6 +1116,17 @@

wooden-bucket

View example
+
+ + wall-torch — A procedural wall-mounted torch sconce with a coursed stone plaque, iron bracket, wooden haft, and emissive flame through UVs, bake, LOD, collider, and Unity… + +
+

wall-torch

+

A procedural wall-mounted torch sconce with a coursed stone plaque, iron bracket, wooden haft, and emissive flame through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 1520 tris, four materials with 540 stone, 130 metal, and 28 flame faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.440×0.343×0.740 m, LOD ratios in band (5.2 COLLAPSE 4 tris leaner on LOD2), convex collider 94 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+ View example +
+
diff --git a/docs/gallery/wall-torch/index.html b/docs/gallery/wall-torch/index.html new file mode 100644 index 0000000..489dfc7 --- /dev/null +++ b/docs/gallery/wall-torch/index.html @@ -0,0 +1,1171 @@ + + + + + + wall-torch — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

wall-torch

+

A procedural wall-mounted torch sconce with a coursed stone plaque, iron bracket, wooden haft, and emissive flame 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: 1520 tris, four materials with 540 stone, 130 metal, and 28 flame faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.440×0.343×0.740 m, LOD ratios in band (5.2 COLLAPSE 4 tris leaner on LOD2), convex collider 94 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/wall-torch/wall_torch.py --
+ +
+
+

A showcase piece, not an example. Procedural wall-mounted torch sconce (coursed stone plaque, iron bracket and cup, wooden haft, emissive flame) 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, procedural-materials-and-shaders, 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 | 1400–1650 | 1520 / 1520 / 1520 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2197 / 0.2197 / 0.2171 | | Materials | exactly 4 distinct, ≥24 stone, ≥24 metal, ≥8 flame | 4 slots, 540 stone, 130 metal, 28 flame | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.440, 0.343, 0.740) m ± 0.01 | (0.4400, 0.3430, 0.7400), zmin 0 | | Collider tris | ≤ 120 | 94 | | Export | written, size > 0 | 117260 / 117260 / 117244 bytes |

+

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is 4 tris leaner 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 wall_torch.py --
+blender --background --python wall_torch.py -- --skip-decimate
+blender --background --python wall_torch.py -- --output torch.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 ≠ 4 distinct slots, or stone/metal/flame 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/wall-torch/wall_torch.py + View on GitHub → +
+
"""Game-ready wall torch sconce — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural wall-mounted torch after composing
+shipped pipeline pieces: bmesh construction, UVs, four 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 wall_torch.py --
+    blender --background --python wall_torch.py -- --skip-decimate
+    blender --background --python wall_torch.py -- --output torch.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+# Showcase lives at repo-root/showcase/, not under examples/. The framing
+# helper is the repo's only shared import and lives next to the examples;
+# resolve the repo root so we do not move gallery_framing.py.
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+WALL_W = 0.42
+WALL_D = 0.11
+WALL_H = 0.68
+N_ROWS = 4
+N_COLS = 2
+MORTAR = 0.010
+BBOX_TOL = 0.01
+# Fitted after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (0.440, 0.343, 0.740)
+
+BASE_TRIS_MIN = 1400
+BASE_TRIS_MAX = 1650
+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 = 4
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 120
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.06
+STONE_FACES_MIN = 24
+METAL_FACES_MIN = 24
+FLAME_FACES_MIN = 8
+
+STONE_IDX = 0
+METAL_IDX = 1
+WOOD_IDX = 2
+FLAME_IDX = 3
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
+    depsgraph = bpy.context.evaluated_depsgraph_get()
+    eval_obj = obj.evaluated_get(depsgraph)
+    eval_mesh = eval_obj.to_mesh()
+    try:
+        eval_mesh.calc_loop_triangles()
+        return len(eval_mesh.loop_triangles)
+    finally:
+        eval_obj.to_mesh_clear()
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+        v.co = rot @ p + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_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_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius1,
+        radius2=radius2,
+        depth=depth,
+    )
+    verts = list(geo["verts"])
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler)
+
+
+def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)):
+    n_major = 12
+    n_minor = 6
+    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)
+    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 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_torch_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    metal_faces = set()
+    flame_faces = set()
+    try:
+        # Solid core so mortar gaps cannot punch through the plaque.
+        add_box(
+            bm,
+            (0.0, 0.0, WALL_H / 2.0),
+            (WALL_W - 0.03, WALL_D - 0.03, WALL_H),
+            STONE_IDX,
+        )
+        usable_w = WALL_W - 2.0 * MORTAR
+        usable_h = WALL_H - 2.0 * MORTAR
+        stone_w = (usable_w - (N_COLS - 1) * MORTAR) / N_COLS
+        stone_h = (usable_h - (N_ROWS - 1) * MORTAR) / N_ROWS
+        face_y = WALL_D / 2.0 + 0.006
+        for row in range(N_ROWS):
+            z0 = MORTAR + row * (stone_h + MORTAR) + stone_h / 2.0
+            x_shift = (stone_w + MORTAR) * 0.5 if row % 2 else 0.0
+            for col in range(N_COLS):
+                x = -WALL_W / 2.0 + MORTAR + col * (stone_w + MORTAR) + stone_w / 2.0
+                x += x_shift * 0.35
+                w = stone_w if col < N_COLS - 1 or row % 2 == 0 else stone_w * 0.78
+                add_box(
+                    bm,
+                    (x, face_y, z0),
+                    (w, 0.028, stone_h),
+                    STONE_IDX,
+                )
+        # Top cap course
+        add_box(
+            bm,
+            (0.0, 0.0, WALL_H + 0.012),
+            (WALL_W + 0.02, WALL_D + 0.02, 0.028),
+            STONE_IDX,
+        )
+
+        plate_y = WALL_D / 2.0 + 0.028
+        plate_z = 0.46
+        before = set(bm.faces)
+        add_box(bm, (0.0, plate_y, plate_z), (0.16, 0.016, 0.22), METAL_IDX)
+        add_box(bm, (0.0, plate_y, plate_z + 0.08), (0.18, 0.012, 0.028), METAL_IDX)
+        add_box(bm, (0.0, plate_y, plate_z - 0.08), (0.18, 0.012, 0.028), METAL_IDX)
+        add_rim(
+            bm,
+            (0.0, plate_y + 0.018, plate_z + 0.02),
+            0.042,
+            0.008,
+            METAL_IDX,
+            euler=(math.radians(90.0), 0.0, 0.0),
+        )
+        arm_y0 = plate_y + 0.010
+        arm_y1 = plate_y + 0.13
+        cup_z = 0.40
+        add_oriented_box(
+            bm,
+            (0.028, arm_y0, plate_z - 0.02),
+            (0.018, arm_y1, cup_z + 0.04),
+            (0.016, 0.016),
+            METAL_IDX,
+        )
+        add_oriented_box(
+            bm,
+            (-0.028, arm_y0, plate_z - 0.02),
+            (-0.018, arm_y1, cup_z + 0.04),
+            (0.016, 0.016),
+            METAL_IDX,
+        )
+        add_cone(
+            bm,
+            (0.0, arm_y1 + 0.01, cup_z),
+            0.055,
+            0.032,
+            0.055,
+            12,
+            METAL_IDX,
+        )
+        add_cylinder(
+            bm,
+            (0.0, arm_y1 + 0.01, cup_z - 0.038),
+            0.048,
+            0.012,
+            12,
+            METAL_IDX,
+        )
+        metal_faces.update(set(bm.faces) - before)
+
+        stick_z0 = cup_z - 0.02
+        stick_z1 = 0.60
+        add_cylinder(
+            bm,
+            (0.0, arm_y1 + 0.01, (stick_z0 + stick_z1) / 2.0),
+            0.022,
+            stick_z1 - stick_z0,
+            10,
+            WOOD_IDX,
+        )
+        add_cone(
+            bm,
+            (0.0, arm_y1 + 0.01, 0.56),
+            0.042,
+            0.026,
+            0.10,
+            10,
+            WOOD_IDX,
+        )
+
+        before = set(bm.faces)
+        flame_y = arm_y1 + 0.01
+        add_cone(bm, (0.0, flame_y, 0.66), 0.038, 0.006, 0.16, 8, FLAME_IDX)
+        add_cone(
+            bm,
+            (0.016, flame_y - 0.008, 0.65),
+            0.022,
+            0.004,
+            0.12,
+            7,
+            FLAME_IDX,
+            euler=(0.0, math.radians(14.0), 0.0),
+        )
+        add_cone(
+            bm,
+            (-0.014, flame_y + 0.010, 0.645),
+            0.020,
+            0.004,
+            0.11,
+            7,
+            FLAME_IDX,
+            euler=(math.radians(-10.0), math.radians(-12.0), 0.0),
+        )
+        flame_faces.update(set(bm.faces) - before)
+
+        if bevel_offset > 0.0:
+            stone_edges = [
+                e for e in bm.edges
+                if any(f.material_index == STONE_IDX for f in e.link_faces)
+            ]
+            if stone_edges:
+                bmesh.ops.bevel(
+                    bm,
+                    geom=stone_edges,
+                    offset=bevel_offset,
+                    segments=bevel_segments,
+                    profile=0.5,
+                    affect="EDGES",
+                    clamp_overlap=True,
+                )
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = face.material_index != STONE_IDX
+        for edge in bm.edges:
+            edge.smooth = True
+            if edge.is_manifold and len(edge.link_faces) == 2:
+                if edge.calc_face_angle() > math.radians(35.0):
+                    edge.smooth = False
+        for f in metal_faces:
+            if f.is_valid:
+                f.material_index = METAL_IDX
+        for f in flame_faces:
+            if f.is_valid:
+                f.material_index = FLAME_IDX
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(obj)
+    return obj
+
+
+def principled(name, color, metallic, roughness, emission=0.0, emission_color=None):
+    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
+    if emission > 0.0:
+        ecol = emission_color or color
+        if "Emission Color" in bsdf.inputs:
+            bsdf.inputs["Emission Color"].default_value = ecol
+            bsdf.inputs["Emission Strength"].default_value = emission
+        elif "Emission" in bsdf.inputs:
+            bsdf.inputs["Emission"].default_value = ecol
+    return mat
+
+
+def assign_slots(obj, stone, metal, wood, flame):
+    mats = obj.data.materials
+    slots = (stone, metal, wood, flame)
+    if len(mats) == 0:
+        for s in slots:
+            mats.append(s)
+        return
+    for i, s in enumerate(slots):
+        if i < len(mats):
+            mats[i] = s
+        else:
+            mats.append(s)
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    overlap = 0.0
+    for i in range(len(aabbs)):
+        a = aabbs[i]
+        for j in range(i + 1, len(aabbs)):
+            b = aabbs[j]
+            x0 = max(a[0], b[0])
+            y0 = max(a[1], b[1])
+            x1 = min(a[2], b[2])
+            y1 = min(a[3], b[3])
+            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    # Duplicated from snippets/convex_hull_collider.py (not a package).
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("TorchNrm", size, size, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    nodes = target_mat.node_tree.nodes
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = img
+    nodes.active = tex
+    tex.select = True
+    obj.active_material_index = STONE_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL",
+        use_selected_to_active=True,
+        cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False,
+        normal_space="TANGENT",
+        margin=4,
+        margin_type="ADJACENT_FACES",
+        use_clear=True,
+        target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path,
+        use_selection=True,
+        export_yup=True,
+        export_apply=True,
+        export_draco_mesh_compression_enable=False,
+        export_animations=False,
+    )
+
+
+def check(skip_decimate):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    low = build_torch_mesh("TorchLow", bevel_offset=0.004, bevel_segments=2)
+    high = build_torch_mesh("TorchHigh", bevel_offset=0.004, bevel_segments=4)
+    stone = principled("TorchStone", (0.50, 0.42, 0.32, 1.0), 0.0, 0.68)
+    metal = principled("TorchMetal", (0.50, 0.48, 0.44, 1.0), 1.0, 0.28)
+    wood = principled("TorchWood", (0.28, 0.14, 0.06, 1.0), 0.0, 0.62)
+    flame = principled(
+        "TorchFlame",
+        (1.0, 0.32, 0.05, 1.0),
+        0.0,
+        0.42,
+        emission=4.5,
+        emission_color=(1.0, 0.38, 0.06, 1.0),
+    )
+    assign_slots(low, stone, metal, wood, flame)
+    assign_slots(high, stone, metal, wood, flame)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("torch mesh did not build", 3), None, None, None, None, None
+
+    base_tris = triangle_count(low.data)
+    mats = [s for s in low.data.materials if s is not None]
+    nmat = len(mats)
+    distinct_mats = len({id(s) for s in mats})
+    idx_counts = {}
+    for poly in low.data.polygons:
+        idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1
+    print(f"measured mat_index_counts={idx_counts}")
+    u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data)
+    bb = world_bbox(low)
+    size_x = bb[3] - bb[0]
+    size_y = bb[4] - bb[1]
+    size_z = bb[5] - bb[2]
+
+    img, tex = setup_bake_image(low, stone)
+    if img is None:
+        return fail("torch has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "TorchLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "TorchLOD2", 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_torch_mesh("TorchColSrc", bevel_offset=0.0, bevel_segments=1)
+    collider = convex_hull_collider(collider_src, "TorchCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_wall_torch_{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(STONE_IDX, 0) < STONE_FACES_MIN:
+        return fail(
+            f"stone faces {idx_counts.get(STONE_IDX, 0)} < {STONE_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(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(FLAME_IDX, 0) < FLAME_FACES_MIN:
+        return fail(
+            f"flame faces {idx_counts.get(FLAME_IDX, 0)} < {FLAME_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+            6,
+        ), None, None, None, None, None
+    if overlap > UV_OVERLAP_MAX:
+        return fail(
+            f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+            7,
+        ), None, None, None, None, None
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+            f"off outer {OUTER_SIZE}",
+            8,
+        ), None, None, None, None, None
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)",
+            9,
+        ), None, None, None, None, None
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+            9,
+        ), None, None, None, None, None
+    if col_tris > COLLIDER_TRIS_MAX:
+        return fail(
+            f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+            11,
+        ), None, None, None, None, None
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return fail(
+            f"bake failed result={bake_result} has_data={img.has_data}",
+            12,
+        ), None, None, None, None, None
+    if export_size <= 0:
+        return fail("export file missing or empty", 13), None, None, None, None, None
+    return 0, low, high, stone, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, stone, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(stone, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    # Sconce is built on +Y; camera sits in -Y, so yaw 180 so the flame faces us.
+    low.rotation_euler.z = math.radians(152.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), 640.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+    light("Fill", (5.0, -3.4, 2.4), 42.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+    light("Wedge", (2.2, 4.0, 3.8), 560.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
+    # Small warm key on the flame so emission reads in EEVEE stills.
+    light("FlameKick", (0.4, 1.2, 1.1), 90.0, 0.6, (1.0, 0.55, 0.22), (40, 0, 160))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (1.32, -1.78, 1.08)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.08, 0.38)
+    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, stone, tex, _col = check(args.skip_decimate)
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, stone, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("wall-torch 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 6640e91..1c59681 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -256,6 +256,18 @@ "mesh", "export" ] + }, + { + "name": "wall-torch", + "dir": "showcase/wall-torch", + "teaches": "A procedural wall-mounted torch sconce with a coursed stone plaque, iron bracket, wooden haft, and emissive flame through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 1520 tris, four materials with 540 stone, 130 metal, and 28 flame faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.440×0.343×0.740 m, LOD ratios in band (5.2 COLLAPSE 4 tris leaner on LOD2), convex collider 94 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/wall-torch-hero.webp", + "preview": "showcase/wall-torch/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/wall-torch/README.md b/showcase/wall-torch/README.md new file mode 100644 index 0000000..1fe07ad --- /dev/null +++ b/showcase/wall-torch/README.md @@ -0,0 +1,74 @@ +# Wall torch + +A showcase piece, not an example. Procedural wall-mounted torch sconce +(coursed stone plaque, iron bracket and cup, wooden haft, emissive flame) +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`, +`procedural-materials-and-shaders`, 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 | 1400–1650 | 1520 / 1520 / 1520 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2197 / 0.2197 / 0.2171 | +| Materials | exactly 4 distinct, ≥24 stone, ≥24 metal, ≥8 flame | 4 slots, 540 stone, 130 metal, 28 flame | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (0.440, 0.343, 0.740) m ± 0.01 | (0.4400, 0.3430, 0.7400), zmin 0 | +| Collider tris | ≤ 120 | 94 | +| Export | written, size > 0 | 117260 / 117260 / 117244 bytes | + +DECIMATE COLLAPSE triangle counts are **not** identical across series — +5.2.1 is 4 tris leaner 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 wall_torch.py -- +blender --background --python wall_torch.py -- --skip-decimate +blender --background --python wall_torch.py -- --output torch.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 ≠ 4 distinct slots, or stone/metal/flame 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/wall-torch/preview.webp b/showcase/wall-torch/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..556b35330c12eecf4f791957da5a04482d860e1a GIT binary patch literal 19552 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 PRf%wO8nhpEMvwpiYq2|| literal 0 HcmV?d00001 diff --git a/showcase/wall-torch/wall_torch.py b/showcase/wall-torch/wall_torch.py new file mode 100644 index 0000000..ace3aa2 --- /dev/null +++ b/showcase/wall-torch/wall_torch.py @@ -0,0 +1,847 @@ +"""Game-ready wall torch sconce — a showcase piece, not an example. + +Asserts budget conformance of a procedural wall-mounted torch after composing +shipped pipeline pieces: bmesh construction, UVs, four 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 wall_torch.py -- + blender --background --python wall_torch.py -- --skip-decimate + blender --background --python wall_torch.py -- --output torch.png +""" +import argparse +import math +import os +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Euler, Vector + +# Showcase lives at repo-root/showcase/, not under examples/. The framing +# helper is the repo's only shared import and lives next to the examples; +# resolve the repo root so we do not move gallery_framing.py. +_REPO = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) +) +sys.path.insert(0, os.path.join(_REPO, "examples")) +sys.dont_write_bytecode = True +import gallery_framing # noqa: E402 + +WALL_W = 0.42 +WALL_D = 0.11 +WALL_H = 0.68 +N_ROWS = 4 +N_COLS = 2 +MORTAR = 0.010 +BBOX_TOL = 0.01 +# Fitted after locking geometry. Recomputed from bound_box. +OUTER_SIZE = (0.440, 0.343, 0.740) + +BASE_TRIS_MIN = 1400 +BASE_TRIS_MAX = 1650 +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 = 4 +UV_EPS = 1e-4 +UV_OVERLAP_MAX = 1e-5 +COLLIDER_TRIS_MAX = 120 +BAKE_RES = 256 +CAGE_EXTRUSION = 0.06 +STONE_FACES_MIN = 24 +METAL_FACES_MIN = 24 +FLAME_FACES_MIN = 8 + +STONE_IDX = 0 +METAL_IDX = 1 +WOOD_IDX = 2 +FLAME_IDX = 3 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package). + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) + v.co = rot @ p + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_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_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius1, + radius2=radius2, + depth=depth, + ) + verts = list(geo["verts"]) + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler) + + +def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): + n_major = 12 + n_minor = 6 + 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) + 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 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_torch_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + metal_faces = set() + flame_faces = set() + try: + # Solid core so mortar gaps cannot punch through the plaque. + add_box( + bm, + (0.0, 0.0, WALL_H / 2.0), + (WALL_W - 0.03, WALL_D - 0.03, WALL_H), + STONE_IDX, + ) + usable_w = WALL_W - 2.0 * MORTAR + usable_h = WALL_H - 2.0 * MORTAR + stone_w = (usable_w - (N_COLS - 1) * MORTAR) / N_COLS + stone_h = (usable_h - (N_ROWS - 1) * MORTAR) / N_ROWS + face_y = WALL_D / 2.0 + 0.006 + for row in range(N_ROWS): + z0 = MORTAR + row * (stone_h + MORTAR) + stone_h / 2.0 + x_shift = (stone_w + MORTAR) * 0.5 if row % 2 else 0.0 + for col in range(N_COLS): + x = -WALL_W / 2.0 + MORTAR + col * (stone_w + MORTAR) + stone_w / 2.0 + x += x_shift * 0.35 + w = stone_w if col < N_COLS - 1 or row % 2 == 0 else stone_w * 0.78 + add_box( + bm, + (x, face_y, z0), + (w, 0.028, stone_h), + STONE_IDX, + ) + # Top cap course + add_box( + bm, + (0.0, 0.0, WALL_H + 0.012), + (WALL_W + 0.02, WALL_D + 0.02, 0.028), + STONE_IDX, + ) + + plate_y = WALL_D / 2.0 + 0.028 + plate_z = 0.46 + before = set(bm.faces) + add_box(bm, (0.0, plate_y, plate_z), (0.16, 0.016, 0.22), METAL_IDX) + add_box(bm, (0.0, plate_y, plate_z + 0.08), (0.18, 0.012, 0.028), METAL_IDX) + add_box(bm, (0.0, plate_y, plate_z - 0.08), (0.18, 0.012, 0.028), METAL_IDX) + add_rim( + bm, + (0.0, plate_y + 0.018, plate_z + 0.02), + 0.042, + 0.008, + METAL_IDX, + euler=(math.radians(90.0), 0.0, 0.0), + ) + arm_y0 = plate_y + 0.010 + arm_y1 = plate_y + 0.13 + cup_z = 0.40 + add_oriented_box( + bm, + (0.028, arm_y0, plate_z - 0.02), + (0.018, arm_y1, cup_z + 0.04), + (0.016, 0.016), + METAL_IDX, + ) + add_oriented_box( + bm, + (-0.028, arm_y0, plate_z - 0.02), + (-0.018, arm_y1, cup_z + 0.04), + (0.016, 0.016), + METAL_IDX, + ) + add_cone( + bm, + (0.0, arm_y1 + 0.01, cup_z), + 0.055, + 0.032, + 0.055, + 12, + METAL_IDX, + ) + add_cylinder( + bm, + (0.0, arm_y1 + 0.01, cup_z - 0.038), + 0.048, + 0.012, + 12, + METAL_IDX, + ) + metal_faces.update(set(bm.faces) - before) + + stick_z0 = cup_z - 0.02 + stick_z1 = 0.60 + add_cylinder( + bm, + (0.0, arm_y1 + 0.01, (stick_z0 + stick_z1) / 2.0), + 0.022, + stick_z1 - stick_z0, + 10, + WOOD_IDX, + ) + add_cone( + bm, + (0.0, arm_y1 + 0.01, 0.56), + 0.042, + 0.026, + 0.10, + 10, + WOOD_IDX, + ) + + before = set(bm.faces) + flame_y = arm_y1 + 0.01 + add_cone(bm, (0.0, flame_y, 0.66), 0.038, 0.006, 0.16, 8, FLAME_IDX) + add_cone( + bm, + (0.016, flame_y - 0.008, 0.65), + 0.022, + 0.004, + 0.12, + 7, + FLAME_IDX, + euler=(0.0, math.radians(14.0), 0.0), + ) + add_cone( + bm, + (-0.014, flame_y + 0.010, 0.645), + 0.020, + 0.004, + 0.11, + 7, + FLAME_IDX, + euler=(math.radians(-10.0), math.radians(-12.0), 0.0), + ) + flame_faces.update(set(bm.faces) - before) + + if bevel_offset > 0.0: + stone_edges = [ + e for e in bm.edges + if any(f.material_index == STONE_IDX for f in e.link_faces) + ] + if stone_edges: + bmesh.ops.bevel( + bm, + geom=stone_edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = face.material_index != STONE_IDX + for edge in bm.edges: + edge.smooth = True + if edge.is_manifold and len(edge.link_faces) == 2: + if edge.calc_face_angle() > math.radians(35.0): + edge.smooth = False + for f in metal_faces: + if f.is_valid: + f.material_index = METAL_IDX + for f in flame_faces: + if f.is_valid: + f.material_index = FLAME_IDX + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + finally: + bm.free() + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj + + +def principled(name, color, metallic, roughness, emission=0.0, emission_color=None): + 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 + if emission > 0.0: + ecol = emission_color or color + if "Emission Color" in bsdf.inputs: + bsdf.inputs["Emission Color"].default_value = ecol + bsdf.inputs["Emission Strength"].default_value = emission + elif "Emission" in bsdf.inputs: + bsdf.inputs["Emission"].default_value = ecol + return mat + + +def assign_slots(obj, stone, metal, wood, flame): + mats = obj.data.materials + slots = (stone, metal, wood, flame) + if len(mats) == 0: + for s in slots: + mats.append(s) + return + for i, s in enumerate(slots): + if i < len(mats): + mats[i] = s + else: + mats.append(s) + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0, 1.0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + overlap = 0.0 + for i in range(len(aabbs)): + a = aabbs[i] + for j in range(i + 1, len(aabbs)): + b = aabbs[j] + x0 = max(a[0], b[0]) + y0 = max(a[1], b[1]) + x1 = min(a[2], b[2]) + y1 = min(a[3], b[3]) + overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def convex_hull_collider(obj, name): + # Duplicated from snippets/convex_hull_collider.py (not a package). + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) + interior = result.get("geom_interior") or [] + unused = result.get("geom_unused") or [] + if interior: + bmesh.ops.delete(bm, geom=interior, context="VERTS") + if unused: + bmesh.ops.delete(bm, geom=unused, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + collider = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +def setup_bake_image(obj, target_mat, size=BAKE_RES): + if not obj.data.uv_layers: + return None, None + img = bpy.data.images.new("TorchNrm", size, size, alpha=True, float_buffer=False) + img.colorspace_settings.name = "Non-Color" + nodes = target_mat.node_tree.nodes + tex = nodes.new("ShaderNodeTexImage") + tex.image = img + nodes.active = tex + tex.select = True + obj.active_material_index = STONE_IDX + return img, tex + + +def bake_normal(high, low): + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + return bpy.ops.object.bake( + type="NORMAL", + use_selected_to_active=True, + cage_extrusion=CAGE_EXTRUSION, + use_cage=False, + normal_space="TANGENT", + margin=4, + margin_type="ADJACENT_FACES", + use_clear=True, + target="IMAGE_TEXTURES", + ) + + +def export_unity(path, objects): + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + for ob in objects: + ob.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=False, + export_animations=False, + ) + + +def check(skip_decimate): + bpy.ops.wm.read_factory_settings(use_empty=True) + low = build_torch_mesh("TorchLow", bevel_offset=0.004, bevel_segments=2) + high = build_torch_mesh("TorchHigh", bevel_offset=0.004, bevel_segments=4) + stone = principled("TorchStone", (0.50, 0.42, 0.32, 1.0), 0.0, 0.68) + metal = principled("TorchMetal", (0.50, 0.48, 0.44, 1.0), 1.0, 0.28) + wood = principled("TorchWood", (0.28, 0.14, 0.06, 1.0), 0.0, 0.62) + flame = principled( + "TorchFlame", + (1.0, 0.32, 0.05, 1.0), + 0.0, + 0.42, + emission=4.5, + emission_color=(1.0, 0.38, 0.06, 1.0), + ) + assign_slots(low, stone, metal, wood, flame) + assign_slots(high, stone, metal, wood, flame) + + if low.data is None or len(low.data.polygons) < 6: + return fail("torch mesh did not build", 3), None, None, None, None, None + + base_tris = triangle_count(low.data) + mats = [s for s in low.data.materials if s is not None] + nmat = len(mats) + distinct_mats = len({id(s) for s in mats}) + idx_counts = {} + for poly in low.data.polygons: + idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1 + print(f"measured mat_index_counts={idx_counts}") + u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data) + bb = world_bbox(low) + size_x = bb[3] - bb[0] + size_y = bb[4] - bb[1] + size_z = bb[5] - bb[2] + + img, tex = setup_bake_image(low, stone) + if img is None: + return fail("torch has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "TorchLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "TorchLOD2", 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_torch_mesh("TorchColSrc", bevel_offset=0.0, bevel_segments=1) + collider = convex_hull_collider(collider_src, "TorchCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_wall_torch_{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(STONE_IDX, 0) < STONE_FACES_MIN: + return fail( + f"stone faces {idx_counts.get(STONE_IDX, 0)} < {STONE_FACES_MIN}", + 5, + ), None, None, None, None, None + if idx_counts.get(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(FLAME_IDX, 0) < FLAME_FACES_MIN: + return fail( + f"flame faces {idx_counts.get(FLAME_IDX, 0)} < {FLAME_FACES_MIN}", + 5, + ), None, None, None, None, None + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return fail( + f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", + 6, + ), None, None, None, None, None + if overlap > UV_OVERLAP_MAX: + return fail( + f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", + 7, + ), None, None, None, None, None + if ( + abs(size_x - OUTER_SIZE[0]) > BBOX_TOL + or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL + ): + return fail( + f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"off outer {OUTER_SIZE}", + 8, + ), None, None, None, None, None + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return fail( + f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", + 9, + ), None, None, None, None, None + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return fail( + f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", + 9, + ), None, None, None, None, None + if col_tris > COLLIDER_TRIS_MAX: + return fail( + f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", + 11, + ), None, None, None, None, None + if bake_result != {"FINISHED"} or not img.has_data: + return fail( + f"bake failed result={bake_result} has_data={img.has_data}", + 12, + ), None, None, None, None, None + if export_size <= 0: + return fail("export file missing or empty", 13), None, None, None, None, None + return 0, low, high, stone, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + nrm = nt.nodes.new("ShaderNodeNormalMap") + nrm.inputs["Strength"].default_value = 1.0 + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) + + +def render_still(low, stone, tex, path, engine): + scene = bpy.context.scene + wire_normal(stone, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + # Sconce is built on +Y; camera sits in -Y, so yaw 180 so the flame faces us. + low.rotation_euler.z = math.radians(152.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), 640.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) + light("Fill", (5.0, -3.4, 2.4), 42.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) + light("Wedge", (2.2, 4.0, 3.8), 560.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + # Small warm key on the flame so emission reads in EEVEE stills. + light("FlameKick", (0.4, 1.2, 1.1), 90.0, 0.6, (1.0, 0.55, 0.22), (40, 0, 160)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (1.32, -1.78, 1.08) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.08, 0.38) + 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, stone, tex, _col = check(args.skip_decimate) + if code: + return code + if args.output: + rcode = render_still(low, stone, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("wall-torch 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 0ad279a..602401f 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -94,5 +94,6 @@ {"name": "hand-pump", "script": "showcase/hand-pump/hand_pump.py"}, {"name": "signpost", "script": "showcase/signpost/signpost.py"}, {"name": "chopping-block", "script": "showcase/chopping-block/chopping_block.py"}, - {"name": "wooden-bucket", "script": "showcase/wooden-bucket/wooden_bucket.py"} + {"name": "wooden-bucket", "script": "showcase/wooden-bucket/wooden_bucket.py"}, + {"name": "wall-torch", "script": "showcase/wall-torch/wall_torch.py"} ]