From 01207c48e1f9979d440c2fa6f45d37847172efa3 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:49:28 -0400 Subject: [PATCH 1/2] feat: add street-lantern showcase piece Named well/barrel/campfire/stall list is exhausted; this is the ROADMAP hanging-lantern silhouette prop with budgets fitted to the generated mesh. 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 +- docs/gallery/assets/street-lantern-hero.webp | Bin 0 -> 11896 bytes docs/gallery/index.html | 13 +- docs/gallery/street-lantern/index.html | 1241 ++++++++++++++++++ showcase/gallery.json | 12 + showcase/street-lantern/README.md | 74 ++ showcase/street-lantern/preview.webp | Bin 0 -> 7924 bytes showcase/street-lantern/street_lantern.py | 917 +++++++++++++ tests/smoke/catalog.json | 3 +- 12 files changed, 2267 insertions(+), 7 deletions(-) create mode 100644 docs/gallery/assets/street-lantern-hero.webp create mode 100644 docs/gallery/street-lantern/index.html create mode 100644 showcase/street-lantern/README.md create mode 100644 showcase/street-lantern/preview.webp create mode 100644 showcase/street-lantern/street_lantern.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 778cd83..25f10f7 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -142,6 +142,7 @@ "showcase/stone-well", "showcase/wooden-barrel", "showcase/campfire", - "showcase/market-stall" + "showcase/market-stall", + "showcase/street-lantern" ] } diff --git a/AGENTS.md b/AGENTS.md index d837213..71978c1 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 5 showcase pieces (counts are CI-enforced against README.md +examples, and 6 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 d5f2662..e7a8429 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, 5 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 6 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 4da406b..e88f176 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  5 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  6 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 5 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 6 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. @@ -80,6 +80,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Wooden barrel: a staved barrel with iron hoops on a dark studio floor, warm wedge on the back wall Campfire: a two-course stone fire ring with a tripod of logs on a dark studio floor, warm wedge on the back wall Market stall: a timber frame with post feet, slatted counter, rafters, and a striped awning on a dark studio floor, warm wedge on the back wall +Street lantern: an iron post with brass collars, a braced arm, and a muntined amber cage 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. @@ -91,6 +92,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`market-stall`](showcase/market-stall/) — procedural timber stall with striped awning and counter through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`street-lantern`](showcase/street-lantern/) — procedural hanging lantern with iron post, brass fittings, and amber cage 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/docs/gallery/assets/street-lantern-hero.webp b/docs/gallery/assets/street-lantern-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..238836a67acfa7f287b29717255ceeb98c7f803e GIT binary patch literal 11896 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 92694c5..3f77cdc 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" /> - 56 examples + 57 examples
@@ -940,6 +940,17 @@

market-stall

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

street-lantern

+

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

+

witnesses Recomputed: 3520 tris, three materials with 24 glass and 94 brass faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.403×0.892×1.404 m, LOD ratios in band, convex collider 84 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

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

street-lantern

+

A procedural hanging street lantern 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: 3520 tris, three materials with 24 glass and 94 brass faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.403×0.892×1.404 m, LOD ratios in band, convex collider 84 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/street-lantern/street_lantern.py --
+ +
+
+

A showcase piece, not an example. Procedural hanging street lantern (stepped iron foot, tapered octagonal post, brass collars, braced arm, muntined cage with amber panes, pyramidal brass roof and finial) 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 | 3400–3650 | 3520 / 3520 / 3520 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2199 | | Materials | exactly 3 distinct, ≥8 glass faces, ≥8 brass faces | 3 slots, 24 glass, 94 brass | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.403, 0.892, 1.404) m ± 0.01 | (0.4032, 0.8916, 1.4041), zmin 0 | | Collider tris | ≤ 110 | 84 | | Export | written, size > 0 | 254552 / 254552 / 254536 bytes |

+

DECIMATE COLLAPSE triangle counts are not guaranteed identical across series — the gate is a ratio band, not an exact count. This mesh happened to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. glTF byte size differs by a few bytes across series.

+

--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 street_lantern.py --
+blender --background --python street_lantern.py -- --skip-decimate
+blender --background --python street_lantern.py -- --output lantern.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 glass/brass 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/street-lantern/street_lantern.py + View on GitHub → +
+
"""Game-ready street lantern — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural hanging lantern 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 street_lantern.py --
+    blender --background --python street_lantern.py -- --skip-decimate
+    blender --background --python street_lantern.py -- --output lantern.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
+
+FOOT_XY = 0.34
+FOOT_H = 0.085
+POST_R = 0.046
+POST_H = 1.18
+ARM_LEN = 0.52
+ARM_R = 0.028
+CAGE_W = 0.28
+CAGE_H = 0.34
+FRAME = 0.026
+PANE_T = 0.008
+ROOF_H = 0.12
+FINIAL_H = 0.07
+DROP_H = 0.07
+MUNTIN = 0.012
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (0.403, 0.892, 1.404)
+BASE_TRIS_MIN = 3400
+BASE_TRIS_MAX = 3650
+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 = 110
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.06
+GLASS_FACES_MIN = 8
+BRASS_FACES_MIN = 8
+
+METAL_IDX = 0
+GLASS_IDX = 1
+BRASS_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 add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+        v.co = rot @ p + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius1,
+        radius2=radius2,
+        depth=depth,
+    )
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def pack_uvs(bm, margin=0.08):
+    uv = bm.loops.layers.uv.new("UVMap")
+    faces = list(bm.faces)
+    n = len(faces)
+    cols = max(1, math.ceil(math.sqrt(n)))
+    rows = max(1, math.ceil(n / cols))
+    cell_w = 1.0 / cols
+    cell_h = 1.0 / rows
+    pad_u = margin * cell_w * 0.5
+    pad_v = margin * cell_h * 0.5
+    usable_w = cell_w - 2.0 * pad_u
+    usable_h = cell_h - 2.0 * pad_v
+    for i, face in enumerate(faces):
+        col = i % cols
+        row = i // cols
+        nrm = face.normal
+        ax = abs(nrm.x)
+        ay = abs(nrm.y)
+        az = abs(nrm.z)
+        coords = []
+        for loop in face.loops:
+            co = loop.vert.co
+            if az >= ax and az >= ay:
+                coords.append((co.x, co.y))
+            elif ax >= ay:
+                coords.append((co.y, co.z))
+            else:
+                coords.append((co.x, co.z))
+        xs = [c[0] for c in coords]
+        ys = [c[1] for c in coords]
+        minx, maxx = min(xs), max(xs)
+        miny, maxy = min(ys), max(ys)
+        dx = max(maxx - minx, 1e-8)
+        dy = max(maxy - miny, 1e-8)
+        origin_u = col * cell_w + pad_u
+        origin_v = row * cell_h + pad_v
+        for loop, (x, y) in zip(face.loops, coords):
+            loop[uv].uv = (
+                origin_u + (x - minx) / dx * usable_w,
+                origin_v + (y - miny) / dy * usable_h,
+            )
+
+
+def build_lantern_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    try:
+        bevel_verts = []
+        post_z0 = FOOT_H
+        post_top = post_z0 + POST_H
+        arm_z = post_top - 0.02
+        cage_y = ARM_LEN
+        cage_top = arm_z - DROP_H
+        cage_bot = cage_top - CAGE_H
+        cage_mid = 0.5 * (cage_top + cage_bot)
+
+        bevel_verts.extend(
+            add_box(
+                bm,
+                (0.0, 0.0, FOOT_H / 2.0),
+                (FOOT_XY, FOOT_XY, FOOT_H),
+                METAL_IDX,
+            )
+        )
+        pad = 0.07
+        pad_h = 0.022
+        hx = FOOT_XY / 2.0 - pad / 2.0 - 0.012
+        for sxn in (-1.0, 1.0):
+            for syn in (-1.0, 1.0):
+                bevel_verts.extend(
+                    add_box(
+                        bm,
+                        (sxn * hx, syn * hx, pad_h / 2.0),
+                        (pad, pad, pad_h),
+                        METAL_IDX,
+                    )
+                )
+        add_cone(
+            bm,
+            (0.0, 0.0, FOOT_H + 0.028),
+            0.12,
+            0.10,
+            0.056,
+            12,
+            BRASS_IDX,
+        )
+        add_cone(
+            bm,
+            (0.0, 0.0, post_z0 + POST_H / 2.0),
+            POST_R,
+            POST_R * 0.86,
+            POST_H,
+            8,
+            METAL_IDX,
+        )
+        add_cone(
+            bm,
+            (0.0, 0.0, post_z0 + POST_H * 0.36),
+            POST_R + 0.028,
+            POST_R + 0.028,
+            0.042,
+            12,
+            BRASS_IDX,
+        )
+        add_cone(
+            bm,
+            (0.0, 0.0, post_top),
+            0.078,
+            0.078,
+            0.052,
+            12,
+            BRASS_IDX,
+        )
+        add_cone(
+            bm,
+            (0.0, ARM_LEN / 2.0, arm_z),
+            ARM_R,
+            ARM_R,
+            ARM_LEN + 0.06,
+            8,
+            METAL_IDX,
+            euler=(math.radians(90.0), 0.0, 0.0),
+        )
+        bevel_verts.extend(
+            add_box(
+                bm,
+                (0.0, 0.18, arm_z - 0.10),
+                (0.032, 0.28, 0.032),
+                METAL_IDX,
+                euler=(math.radians(-36.0), 0.0, 0.0),
+            )
+        )
+        add_cone(
+            bm,
+            (0.0, 0.0, post_top + 0.055),
+            0.032,
+            0.006,
+            0.08,
+            8,
+            BRASS_IDX,
+        )
+        add_cone(
+            bm,
+            (0.0, cage_y, arm_z - DROP_H / 2.0),
+            0.016,
+            0.016,
+            DROP_H + 0.03,
+            8,
+            METAL_IDX,
+        )
+        eave = CAGE_W + 0.04
+        bevel_verts.extend(
+            add_box(
+                bm,
+                (0.0, cage_y, cage_bot - 0.010),
+                (eave, eave, 0.020),
+                METAL_IDX,
+            )
+        )
+        bevel_verts.extend(
+            add_box(
+                bm,
+                (0.0, cage_y, cage_top + 0.010),
+                (eave, eave, 0.020),
+                METAL_IDX,
+            )
+        )
+        hw = CAGE_W / 2.0 - FRAME / 2.0
+        for sxn in (-1.0, 1.0):
+            for syn in (-1.0, 1.0):
+                bevel_verts.extend(
+                    add_box(
+                        bm,
+                        (sxn * hw, cage_y + syn * hw, cage_mid),
+                        (FRAME, FRAME, CAGE_H),
+                        METAL_IDX,
+                    )
+                )
+        rail_z = (cage_bot + FRAME / 2.0, cage_mid, cage_top - FRAME / 2.0)
+        for z in rail_z:
+            for sign in (-1.0, 1.0):
+                bevel_verts.extend(
+                    add_box(
+                        bm,
+                        (0.0, cage_y + sign * (CAGE_W / 2.0 - FRAME / 2.0), z),
+                        (CAGE_W - 2.0 * FRAME, FRAME, FRAME),
+                        METAL_IDX,
+                    )
+                )
+                bevel_verts.extend(
+                    add_box(
+                        bm,
+                        (sign * (CAGE_W / 2.0 - FRAME / 2.0), cage_y, z),
+                        (FRAME, CAGE_W - 2.0 * FRAME, FRAME),
+                        METAL_IDX,
+                    )
+                )
+        for sign in (-1.0, 1.0):
+            bevel_verts.extend(
+                add_box(
+                    bm,
+                    (0.0, cage_y + sign * (CAGE_W / 2.0 - FRAME / 2.0), cage_mid),
+                    (MUNTIN, FRAME * 0.85, CAGE_H - 2.0 * FRAME),
+                    METAL_IDX,
+                )
+            )
+            bevel_verts.extend(
+                add_box(
+                    bm,
+                    (sign * (CAGE_W / 2.0 - FRAME / 2.0), cage_y, cage_mid),
+                    (FRAME * 0.85, MUNTIN, CAGE_H - 2.0 * FRAME),
+                    METAL_IDX,
+                )
+            )
+
+        inset = FRAME + 0.001
+        open_w = CAGE_W - 2.0 * inset
+        open_h = CAGE_H - 2.0 * inset
+        add_box(
+            bm,
+            (0.0, cage_y + CAGE_W / 2.0 - PANE_T / 2.0 - 0.001, cage_mid),
+            (open_w, PANE_T, open_h),
+            GLASS_IDX,
+        )
+        add_box(
+            bm,
+            (0.0, cage_y - CAGE_W / 2.0 + PANE_T / 2.0 + 0.001, cage_mid),
+            (open_w, PANE_T, open_h),
+            GLASS_IDX,
+        )
+        add_box(
+            bm,
+            (CAGE_W / 2.0 - PANE_T / 2.0 - 0.001, cage_y, cage_mid),
+            (PANE_T, open_w, open_h),
+            GLASS_IDX,
+        )
+        add_box(
+            bm,
+            (-CAGE_W / 2.0 + PANE_T / 2.0 + 0.001, cage_y, cage_mid),
+            (PANE_T, open_w, open_h),
+            GLASS_IDX,
+        )
+        add_cone(
+            bm,
+            (0.0, cage_y, cage_bot + 0.045),
+            0.028,
+            0.022,
+            0.055,
+            8,
+            BRASS_IDX,
+        )
+
+        bevel_verts.extend(
+            add_box(
+                bm,
+                (0.0, cage_y, cage_top + 0.022),
+                (CAGE_W + 0.08, CAGE_W + 0.08, 0.016),
+                BRASS_IDX,
+            )
+        )
+        roof_z = cage_top + 0.030 + ROOF_H / 2.0
+        add_cone(
+            bm,
+            (0.0, cage_y, roof_z),
+            CAGE_W * 0.72,
+            0.014,
+            ROOF_H,
+            4,
+            BRASS_IDX,
+        )
+        add_cone(
+            bm,
+            (0.0, cage_y, cage_top + 0.030 + ROOF_H + FINIAL_H * 0.40),
+            0.016,
+            0.016,
+            FINIAL_H * 0.55,
+            8,
+            BRASS_IDX,
+        )
+        add_cone(
+            bm,
+            (0.0, cage_y, cage_top + 0.030 + ROOF_H + FINIAL_H * 0.88),
+            0.024,
+            0.004,
+            FINIAL_H * 0.50,
+            8,
+            BRASS_IDX,
+        )
+
+        if bevel_offset > 0.0:
+            edges = list({e for v in bevel_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,
+            )
+
+        xs = [v.co.x for v in bm.verts]
+        ys = [v.co.y for v in bm.verts]
+        zs = [v.co.z for v in bm.verts]
+        cx = 0.5 * (min(xs) + max(xs))
+        cy = 0.5 * (min(ys) + max(ys))
+        zmin = min(zs)
+        for v in bm.verts:
+            v.co.x -= cx
+            v.co.y -= cy
+            v.co.z -= zmin
+            if v.co.z < 0.0:
+                v.co.z = 0.0
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = face.material_index != GLASS_IDX
+        for edge in bm.edges:
+            mats = {f.material_index for f in edge.link_faces}
+            if GLASS_IDX in mats:
+                edge.smooth = False
+            else:
+                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
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+        for poly in me.polygons:
+            poly.use_smooth = poly.material_index != GLASS_IDX
+    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=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 is not None:
+        ecol, strength = emission
+        if "Emission Color" in bsdf.inputs:
+            bsdf.inputs["Emission Color"].default_value = ecol
+            bsdf.inputs["Emission Strength"].default_value = strength
+        elif "Emission" in bsdf.inputs:
+            bsdf.inputs["Emission"].default_value = ecol
+            if "Emission Strength" in bsdf.inputs:
+                bsdf.inputs["Emission Strength"].default_value = strength
+    return mat
+
+
+def assign_slots(obj, metal, glass, brass):
+    # Do not materials.clear() — that resets polygon material_index to 0
+    # on this Blender, which would drop glass/brass faces onto metal.
+    mats = obj.data.materials
+    wanted = (metal, glass, brass)
+    for i, mat in enumerate(wanted):
+        if i < len(mats):
+            mats[i] = mat
+        else:
+            mats.append(mat)
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    overlap = 0.0
+    for i in range(len(aabbs)):
+        a = aabbs[i]
+        for j in range(i + 1, len(aabbs)):
+            b = aabbs[j]
+            x0 = max(a[0], b[0])
+            y0 = max(a[1], b[1])
+            x1 = min(a[2], b[2])
+            y1 = min(a[3], b[3])
+            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    # Duplicated from snippets/convex_hull_collider.py (not a package).
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    # Adapted from snippets/setup_bake_target_image.py — do not replace slots.
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("LanternNrm", 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 = METAL_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_lantern_mesh("LanternLow", bevel_offset=0.004, bevel_segments=2)
+    high = build_lantern_mesh("LanternHigh", bevel_offset=0.004, bevel_segments=4)
+    metal = principled("LanternMetal", (0.045, 0.048, 0.055, 1.0), 0.88, 0.34)
+    glass = principled(
+        "LanternGlass",
+        (0.62, 0.32, 0.08, 1.0),
+        0.0,
+        0.22,
+        emission=((0.85, 0.42, 0.10, 1.0), 0.35),
+    )
+    brass = principled("LanternBrass", (0.72, 0.46, 0.14, 1.0), 1.0, 0.28)
+    assign_slots(low, metal, glass, brass)
+    assign_slots(high, metal, glass, brass)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("lantern 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, metal)
+    if img is None:
+        return fail("lantern has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "LanternLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "LanternLOD2", 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_lantern_mesh(
+        "LanternColSrc", bevel_offset=0.0, bevel_segments=1
+    )
+    collider = convex_hull_collider(collider_src, "LanternCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_street_lantern_{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(GLASS_IDX, 0) < GLASS_FACES_MIN:
+        return fail(
+            f"glass faces {idx_counts.get(GLASS_IDX, 0)} < {GLASS_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(BRASS_IDX, 0) < BRASS_FACES_MIN:
+        return fail(
+            f"brass faces {idx_counts.get(BRASS_IDX, 0)} < {BRASS_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, metal, 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, metal, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(metal, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-28.0)
+    low.rotation_euler.x = math.radians(2.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.6, -5.0, 5.8), 680.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+    light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+    light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (2.55, -3.70, 1.85)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.78)
+    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, metal, tex, _col = check(args.skip_decimate)
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, metal, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("street-lantern 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 98fdc4a..c01cb52 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -64,6 +64,18 @@ "mesh", "export" ] + }, + { + "name": "street-lantern", + "dir": "showcase/street-lantern", + "teaches": "A procedural hanging street lantern through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 3520 tris, three materials with 24 glass and 94 brass faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.403×0.892×1.404 m, LOD ratios in band, convex collider 84 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/street-lantern-hero.webp", + "preview": "showcase/street-lantern/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/street-lantern/README.md b/showcase/street-lantern/README.md new file mode 100644 index 0000000..58957a1 --- /dev/null +++ b/showcase/street-lantern/README.md @@ -0,0 +1,74 @@ +# Street lantern + +A showcase piece, not an example. Procedural hanging street lantern +(stepped iron foot, tapered octagonal post, brass collars, braced arm, +muntined cage with amber panes, pyramidal brass roof and finial) 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 | 3400–3650 | 3520 / 3520 / 3520 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2199 | +| Materials | exactly 3 distinct, ≥8 glass faces, ≥8 brass faces | 3 slots, 24 glass, 94 brass | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (0.403, 0.892, 1.404) m ± 0.01 | (0.4032, 0.8916, 1.4041), zmin 0 | +| Collider tris | ≤ 110 | 84 | +| Export | written, size > 0 | 254552 / 254552 / 254536 bytes | + +DECIMATE COLLAPSE triangle counts are **not** guaranteed identical across +series — the gate is a ratio band, not an exact count. This mesh happened +to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate +is `has_data` plus operator `FINISHED`, not byte-identity. Construction +uses no RNG. glTF byte size differs by a few bytes across series. + +`--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 street_lantern.py -- +blender --background --python street_lantern.py -- --skip-decimate +blender --background --python street_lantern.py -- --output lantern.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 glass/brass 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/street-lantern/preview.webp b/showcase/street-lantern/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..c7ba133811ce991e849e55a902e3b23f6586f000 GIT binary patch literal 7924 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 (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package). + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) + v.co = rot @ p + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius1, + radius2=radius2, + depth=depth, + ) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def pack_uvs(bm, margin=0.08): + uv = bm.loops.layers.uv.new("UVMap") + faces = list(bm.faces) + n = len(faces) + cols = max(1, math.ceil(math.sqrt(n))) + rows = max(1, math.ceil(n / cols)) + cell_w = 1.0 / cols + cell_h = 1.0 / rows + pad_u = margin * cell_w * 0.5 + pad_v = margin * cell_h * 0.5 + usable_w = cell_w - 2.0 * pad_u + usable_h = cell_h - 2.0 * pad_v + for i, face in enumerate(faces): + col = i % cols + row = i // cols + nrm = face.normal + ax = abs(nrm.x) + ay = abs(nrm.y) + az = abs(nrm.z) + coords = [] + for loop in face.loops: + co = loop.vert.co + if az >= ax and az >= ay: + coords.append((co.x, co.y)) + elif ax >= ay: + coords.append((co.y, co.z)) + else: + coords.append((co.x, co.z)) + xs = [c[0] for c in coords] + ys = [c[1] for c in coords] + minx, maxx = min(xs), max(xs) + miny, maxy = min(ys), max(ys) + dx = max(maxx - minx, 1e-8) + dy = max(maxy - miny, 1e-8) + origin_u = col * cell_w + pad_u + origin_v = row * cell_h + pad_v + for loop, (x, y) in zip(face.loops, coords): + loop[uv].uv = ( + origin_u + (x - minx) / dx * usable_w, + origin_v + (y - miny) / dy * usable_h, + ) + + +def build_lantern_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + try: + bevel_verts = [] + post_z0 = FOOT_H + post_top = post_z0 + POST_H + arm_z = post_top - 0.02 + cage_y = ARM_LEN + cage_top = arm_z - DROP_H + cage_bot = cage_top - CAGE_H + cage_mid = 0.5 * (cage_top + cage_bot) + + bevel_verts.extend( + add_box( + bm, + (0.0, 0.0, FOOT_H / 2.0), + (FOOT_XY, FOOT_XY, FOOT_H), + METAL_IDX, + ) + ) + pad = 0.07 + pad_h = 0.022 + hx = FOOT_XY / 2.0 - pad / 2.0 - 0.012 + for sxn in (-1.0, 1.0): + for syn in (-1.0, 1.0): + bevel_verts.extend( + add_box( + bm, + (sxn * hx, syn * hx, pad_h / 2.0), + (pad, pad, pad_h), + METAL_IDX, + ) + ) + add_cone( + bm, + (0.0, 0.0, FOOT_H + 0.028), + 0.12, + 0.10, + 0.056, + 12, + BRASS_IDX, + ) + add_cone( + bm, + (0.0, 0.0, post_z0 + POST_H / 2.0), + POST_R, + POST_R * 0.86, + POST_H, + 8, + METAL_IDX, + ) + add_cone( + bm, + (0.0, 0.0, post_z0 + POST_H * 0.36), + POST_R + 0.028, + POST_R + 0.028, + 0.042, + 12, + BRASS_IDX, + ) + add_cone( + bm, + (0.0, 0.0, post_top), + 0.078, + 0.078, + 0.052, + 12, + BRASS_IDX, + ) + add_cone( + bm, + (0.0, ARM_LEN / 2.0, arm_z), + ARM_R, + ARM_R, + ARM_LEN + 0.06, + 8, + METAL_IDX, + euler=(math.radians(90.0), 0.0, 0.0), + ) + bevel_verts.extend( + add_box( + bm, + (0.0, 0.18, arm_z - 0.10), + (0.032, 0.28, 0.032), + METAL_IDX, + euler=(math.radians(-36.0), 0.0, 0.0), + ) + ) + add_cone( + bm, + (0.0, 0.0, post_top + 0.055), + 0.032, + 0.006, + 0.08, + 8, + BRASS_IDX, + ) + add_cone( + bm, + (0.0, cage_y, arm_z - DROP_H / 2.0), + 0.016, + 0.016, + DROP_H + 0.03, + 8, + METAL_IDX, + ) + eave = CAGE_W + 0.04 + bevel_verts.extend( + add_box( + bm, + (0.0, cage_y, cage_bot - 0.010), + (eave, eave, 0.020), + METAL_IDX, + ) + ) + bevel_verts.extend( + add_box( + bm, + (0.0, cage_y, cage_top + 0.010), + (eave, eave, 0.020), + METAL_IDX, + ) + ) + hw = CAGE_W / 2.0 - FRAME / 2.0 + for sxn in (-1.0, 1.0): + for syn in (-1.0, 1.0): + bevel_verts.extend( + add_box( + bm, + (sxn * hw, cage_y + syn * hw, cage_mid), + (FRAME, FRAME, CAGE_H), + METAL_IDX, + ) + ) + rail_z = (cage_bot + FRAME / 2.0, cage_mid, cage_top - FRAME / 2.0) + for z in rail_z: + for sign in (-1.0, 1.0): + bevel_verts.extend( + add_box( + bm, + (0.0, cage_y + sign * (CAGE_W / 2.0 - FRAME / 2.0), z), + (CAGE_W - 2.0 * FRAME, FRAME, FRAME), + METAL_IDX, + ) + ) + bevel_verts.extend( + add_box( + bm, + (sign * (CAGE_W / 2.0 - FRAME / 2.0), cage_y, z), + (FRAME, CAGE_W - 2.0 * FRAME, FRAME), + METAL_IDX, + ) + ) + for sign in (-1.0, 1.0): + bevel_verts.extend( + add_box( + bm, + (0.0, cage_y + sign * (CAGE_W / 2.0 - FRAME / 2.0), cage_mid), + (MUNTIN, FRAME * 0.85, CAGE_H - 2.0 * FRAME), + METAL_IDX, + ) + ) + bevel_verts.extend( + add_box( + bm, + (sign * (CAGE_W / 2.0 - FRAME / 2.0), cage_y, cage_mid), + (FRAME * 0.85, MUNTIN, CAGE_H - 2.0 * FRAME), + METAL_IDX, + ) + ) + + inset = FRAME + 0.001 + open_w = CAGE_W - 2.0 * inset + open_h = CAGE_H - 2.0 * inset + add_box( + bm, + (0.0, cage_y + CAGE_W / 2.0 - PANE_T / 2.0 - 0.001, cage_mid), + (open_w, PANE_T, open_h), + GLASS_IDX, + ) + add_box( + bm, + (0.0, cage_y - CAGE_W / 2.0 + PANE_T / 2.0 + 0.001, cage_mid), + (open_w, PANE_T, open_h), + GLASS_IDX, + ) + add_box( + bm, + (CAGE_W / 2.0 - PANE_T / 2.0 - 0.001, cage_y, cage_mid), + (PANE_T, open_w, open_h), + GLASS_IDX, + ) + add_box( + bm, + (-CAGE_W / 2.0 + PANE_T / 2.0 + 0.001, cage_y, cage_mid), + (PANE_T, open_w, open_h), + GLASS_IDX, + ) + add_cone( + bm, + (0.0, cage_y, cage_bot + 0.045), + 0.028, + 0.022, + 0.055, + 8, + BRASS_IDX, + ) + + bevel_verts.extend( + add_box( + bm, + (0.0, cage_y, cage_top + 0.022), + (CAGE_W + 0.08, CAGE_W + 0.08, 0.016), + BRASS_IDX, + ) + ) + roof_z = cage_top + 0.030 + ROOF_H / 2.0 + add_cone( + bm, + (0.0, cage_y, roof_z), + CAGE_W * 0.72, + 0.014, + ROOF_H, + 4, + BRASS_IDX, + ) + add_cone( + bm, + (0.0, cage_y, cage_top + 0.030 + ROOF_H + FINIAL_H * 0.40), + 0.016, + 0.016, + FINIAL_H * 0.55, + 8, + BRASS_IDX, + ) + add_cone( + bm, + (0.0, cage_y, cage_top + 0.030 + ROOF_H + FINIAL_H * 0.88), + 0.024, + 0.004, + FINIAL_H * 0.50, + 8, + BRASS_IDX, + ) + + if bevel_offset > 0.0: + edges = list({e for v in bevel_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, + ) + + xs = [v.co.x for v in bm.verts] + ys = [v.co.y for v in bm.verts] + zs = [v.co.z for v in bm.verts] + cx = 0.5 * (min(xs) + max(xs)) + cy = 0.5 * (min(ys) + max(ys)) + zmin = min(zs) + for v in bm.verts: + v.co.x -= cx + v.co.y -= cy + v.co.z -= zmin + if v.co.z < 0.0: + v.co.z = 0.0 + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = face.material_index != GLASS_IDX + for edge in bm.edges: + mats = {f.material_index for f in edge.link_faces} + if GLASS_IDX in mats: + edge.smooth = False + else: + 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 + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + for poly in me.polygons: + poly.use_smooth = poly.material_index != GLASS_IDX + 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=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 is not None: + ecol, strength = emission + if "Emission Color" in bsdf.inputs: + bsdf.inputs["Emission Color"].default_value = ecol + bsdf.inputs["Emission Strength"].default_value = strength + elif "Emission" in bsdf.inputs: + bsdf.inputs["Emission"].default_value = ecol + if "Emission Strength" in bsdf.inputs: + bsdf.inputs["Emission Strength"].default_value = strength + return mat + + +def assign_slots(obj, metal, glass, brass): + # Do not materials.clear() — that resets polygon material_index to 0 + # on this Blender, which would drop glass/brass faces onto metal. + mats = obj.data.materials + wanted = (metal, glass, brass) + for i, mat in enumerate(wanted): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0, 1.0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + overlap = 0.0 + for i in range(len(aabbs)): + a = aabbs[i] + for j in range(i + 1, len(aabbs)): + b = aabbs[j] + x0 = max(a[0], b[0]) + y0 = max(a[1], b[1]) + x1 = min(a[2], b[2]) + y1 = min(a[3], b[3]) + overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def convex_hull_collider(obj, name): + # Duplicated from snippets/convex_hull_collider.py (not a package). + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) + interior = result.get("geom_interior") or [] + unused = result.get("geom_unused") or [] + if interior: + bmesh.ops.delete(bm, geom=interior, context="VERTS") + if unused: + bmesh.ops.delete(bm, geom=unused, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + collider = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +def setup_bake_image(obj, target_mat, size=BAKE_RES): + # Adapted from snippets/setup_bake_target_image.py — do not replace slots. + if not obj.data.uv_layers: + return None, None + img = bpy.data.images.new("LanternNrm", 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 = METAL_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_lantern_mesh("LanternLow", bevel_offset=0.004, bevel_segments=2) + high = build_lantern_mesh("LanternHigh", bevel_offset=0.004, bevel_segments=4) + metal = principled("LanternMetal", (0.045, 0.048, 0.055, 1.0), 0.88, 0.34) + glass = principled( + "LanternGlass", + (0.62, 0.32, 0.08, 1.0), + 0.0, + 0.22, + emission=((0.85, 0.42, 0.10, 1.0), 0.35), + ) + brass = principled("LanternBrass", (0.72, 0.46, 0.14, 1.0), 1.0, 0.28) + assign_slots(low, metal, glass, brass) + assign_slots(high, metal, glass, brass) + + if low.data is None or len(low.data.polygons) < 6: + return fail("lantern 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, metal) + if img is None: + return fail("lantern has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "LanternLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "LanternLOD2", 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_lantern_mesh( + "LanternColSrc", bevel_offset=0.0, bevel_segments=1 + ) + collider = convex_hull_collider(collider_src, "LanternCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_street_lantern_{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(GLASS_IDX, 0) < GLASS_FACES_MIN: + return fail( + f"glass faces {idx_counts.get(GLASS_IDX, 0)} < {GLASS_FACES_MIN}", + 5, + ), None, None, None, None, None + if idx_counts.get(BRASS_IDX, 0) < BRASS_FACES_MIN: + return fail( + f"brass faces {idx_counts.get(BRASS_IDX, 0)} < {BRASS_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, metal, 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, metal, tex, path, engine): + scene = bpy.context.scene + wire_normal(metal, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(-28.0) + low.rotation_euler.x = math.radians(2.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 8.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-3.6, -5.0, 5.8), 680.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) + light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) + light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (2.55, -3.70, 1.85) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.78) + 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, metal, tex, _col = check(args.skip_decimate) + if code: + return code + if args.output: + rcode = render_still(low, metal, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("street-lantern 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 34bbe5b..5fceae7 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -78,5 +78,6 @@ {"name": "stone-well", "script": "showcase/stone-well/stone_well.py"}, {"name": "wooden-barrel", "script": "showcase/wooden-barrel/wooden_barrel.py"}, {"name": "campfire", "script": "showcase/campfire/campfire.py"}, - {"name": "market-stall", "script": "showcase/market-stall/market_stall.py"} + {"name": "market-stall", "script": "showcase/market-stall/market_stall.py"}, + {"name": "street-lantern", "script": "showcase/street-lantern/street_lantern.py"} ] From 346f6d302a8ff2bac6d781fdeb06ce80e7a180f7 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:50:24 -0400 Subject: [PATCH 2/2] feat: add treasure-chest showcase piece Companion ROADMAP silhouette prop: slatted open chest with iron bands; budgets fitted to the generated mesh. 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/treasure-chest-hero.webp | Bin 0 -> 26812 bytes docs/gallery/index.html | 13 +- docs/gallery/treasure-chest/index.html | 1182 ++++++++++++++++++ showcase/gallery.json | 12 + showcase/treasure-chest/README.md | 73 ++ showcase/treasure-chest/preview.webp | Bin 0 -> 15844 bytes showcase/treasure-chest/treasure_chest.py | 858 +++++++++++++ tests/smoke/catalog.json | 3 +- 13 files changed, 2151 insertions(+), 8 deletions(-) create mode 100644 docs/gallery/assets/treasure-chest-hero.webp create mode 100644 docs/gallery/treasure-chest/index.html create mode 100644 showcase/treasure-chest/README.md create mode 100644 showcase/treasure-chest/preview.webp create mode 100644 showcase/treasure-chest/treasure_chest.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 25f10f7..47b052b 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -143,6 +143,7 @@ "showcase/wooden-barrel", "showcase/campfire", "showcase/market-stall", - "showcase/street-lantern" + "showcase/street-lantern", + "showcase/treasure-chest" ] } diff --git a/AGENTS.md b/AGENTS.md index 71978c1..d920173 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 6 showcase pieces (counts are CI-enforced against README.md +examples, and 7 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 e7a8429..f41d1c3 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, 6 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 7 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 e88f176..60d9673 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  6 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  7 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 6 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 7 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. @@ -81,6 +81,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Campfire: a two-course stone fire ring with a tripod of logs on a dark studio floor, warm wedge on the back wall Market stall: a timber frame with post feet, slatted counter, rafters, and a striped awning on a dark studio floor, warm wedge on the back wall Street lantern: an iron post with brass collars, a braced arm, and a muntined amber cage on a dark studio floor +Treasure chest: a slatted wooden chest with iron bands, corner brackets, and an open lid 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. @@ -94,6 +95,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`street-lantern`](showcase/street-lantern/) — procedural hanging lantern with iron post, brass fittings, and amber cage through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`treasure-chest`](showcase/treasure-chest/) — procedural slatted chest with iron bands, corner brackets, and an open lid 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 06325e9..35211d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -112,7 +112,9 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Campfire showcase~~ **SHIPPED** as `showcase/campfire/` — two-course fire ring, log tripod; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Market stall showcase~~ **SHIPPED** as `showcase/market-stall/` — timber frame, slatted counter, striped awning; `--skip-decimate` exits 9 on the LOD1 ratio band - Procedural terrain or landscape showcase using Geometry Nodes scatter -- Hero prop with a more complex silhouette (lantern or treasure chest) as a second showcase piece +- ~~Hero prop with a more complex silhouette (lantern or treasure chest) as a second showcase piece~~ **SHIPPED** as `showcase/street-lantern/` and `showcase/treasure-chest/` — hanging iron lantern with muntined amber cage; slatted chest with iron bands and an open lid; `--skip-decimate` exits 9 on the LOD1 ratio band +- Wrought-iron park bench (slatted seat, scrolled legs) as a game-prop showcase piece +- Wooden wheelbarrow (staved tray, single wheel, handles) as a game-prop showcase piece - Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract) - ~~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/treasure-chest-hero.webp b/docs/gallery/assets/treasure-chest-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..8bbeffc2e6da3d259ca28bc5cf6f57dc6ed94aa9 GIT binary patch literal 26812 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

}4_wy;PbwlYzASCGMk23ip+N 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 3f77cdc..0133e33 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" /> - 57 examples + 58 examples
@@ -951,6 +951,17 @@

street-lantern

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

treasure-chest

+

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

+

witnesses Recomputed: 5724 tris, two materials with 180 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.776×0.549×0.631 m, LOD ratios in band, convex collider 138 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

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

treasure-chest

+

A procedural slatted treasure chest 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: 5724 tris, two materials with 180 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.776×0.549×0.631 m, LOD ratios in band, convex collider 138 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/treasure-chest/treasure_chest.py --
+ +
+
+

A showcase piece, not an example. Procedural slatted treasure chest (hollow body, open lid, iron bands, corner brackets, lock plate) 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 | 5600–5850 | 5724 / 5724 / 5724 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 | | Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 180 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.776, 0.549, 0.631) m ± 0.01 | (0.7760, 0.5486, 0.6312), zmin 0 | | Collider tris | ≤ 160 | 138 | | Export | written, size > 0 | 416656 / 416656 / 416644 bytes |

+

DECIMATE COLLAPSE triangle counts are not guaranteed identical across series — the gate is a ratio band, not an exact count. This mesh happened to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. glTF byte size differs by a few bytes across series.

+

--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 treasure_chest.py --
+blender --background --python treasure_chest.py -- --skip-decimate
+blender --background --python treasure_chest.py -- --output chest.png
+

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

+

Exit codes

+

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

+

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

+
+
+

Source

+
+ showcase/treasure-chest/treasure_chest.py + View on GitHub → +
+
"""Game-ready treasure chest — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural slatted chest after composing
+shipped pipeline pieces: bmesh construction, UVs, two materials, high-to-low
+normal bake, LOD chain, convex collider, Unity glTF export.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
+DECIMATE stage so the LOD-ratio budget fails.
+
+No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
+are not byte-identical across Blender versions — the LOD gate is a
+ratio band, not an exact count.
+
+    blender --background --python treasure_chest.py --
+    blender --background --python treasure_chest.py -- --skip-decimate
+    blender --background --python treasure_chest.py -- --output chest.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
+
+OUTER_W = 0.74
+OUTER_D = 0.46
+CAVITY_H = 0.30
+WALL = 0.030
+BOTTOM = 0.028
+FOOT_H = 0.038
+FOOT_S = 0.072
+LID_T = 0.032
+LID_LIP = 0.042
+SLAT_T = 0.018
+N_FRONT = 5
+N_LID = 5
+BAND_W = 0.048
+BAND_T = 0.016
+LID_ANGLE = math.radians(-26.0)
+BBOX_TOL = 0.01
+OUTER_SIZE = (0.776, 0.549, 0.631)
+BASE_TRIS_MIN = 5600
+BASE_TRIS_MAX = 5850
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 2
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 160
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.06
+METAL_FACES_MIN = 24
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
+    depsgraph = bpy.context.evaluated_depsgraph_get()
+    eval_obj = obj.evaluated_get(depsgraph)
+    eval_mesh = eval_obj.to_mesh()
+    try:
+        eval_mesh.calc_loop_triangles()
+        return len(eval_mesh.loop_triangles)
+    finally:
+        eval_obj.to_mesh_clear()
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+        v.co = rot @ p + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def 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_chest_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    try:
+        bevel_verts = []
+        lid_verts = []
+        body_top = FOOT_H + BOTTOM + CAVITY_H
+        hx = OUTER_W / 2.0
+        hy = OUTER_D / 2.0
+
+        for sxn in (-1.0, 1.0):
+            for syn in (-1.0, 1.0):
+                bevel_verts.extend(
+                    add_box(
+                        bm,
+                        (
+                            sxn * (hx - FOOT_S / 2.0 - 0.01),
+                            syn * (hy - FOOT_S / 2.0 - 0.01),
+                            FOOT_H / 2.0,
+                        ),
+                        (FOOT_S, FOOT_S, FOOT_H),
+                        METAL_IDX,
+                    )
+                )
+
+        bevel_verts.extend(
+            add_box(
+                bm,
+                (0.0, 0.0, FOOT_H + BOTTOM / 2.0),
+                (OUTER_W, OUTER_D, BOTTOM),
+                WOOD_IDX,
+            )
+        )
+        wall_h = CAVITY_H
+        wall_z = FOOT_H + BOTTOM + wall_h / 2.0
+        bevel_verts.extend(
+            add_box(
+                bm,
+                (0.0, -hy + WALL / 2.0, wall_z),
+                (OUTER_W, WALL, wall_h),
+                WOOD_IDX,
+            )
+        )
+        bevel_verts.extend(
+            add_box(
+                bm,
+                (0.0, hy - WALL / 2.0, wall_z),
+                (OUTER_W, WALL, wall_h),
+                WOOD_IDX,
+            )
+        )
+        inner_d = OUTER_D - 2.0 * WALL
+        bevel_verts.extend(
+            add_box(
+                bm,
+                (-hx + WALL / 2.0, 0.0, wall_z),
+                (WALL, inner_d, wall_h),
+                WOOD_IDX,
+            )
+        )
+        bevel_verts.extend(
+            add_box(
+                bm,
+                (hx - WALL / 2.0, 0.0, wall_z),
+                (WALL, inner_d, wall_h),
+                WOOD_IDX,
+            )
+        )
+
+        slat_w = (OUTER_W - 0.06) / N_FRONT
+        gap = 0.006
+        usable = slat_w - gap
+        x0 = -hx + 0.03 + usable / 2.0
+        for i in range(N_FRONT):
+            x = x0 + i * slat_w
+            bevel_verts.extend(
+                add_box(
+                    bm,
+                    (x, -hy - SLAT_T / 2.0, wall_z),
+                    (usable, SLAT_T, wall_h - 0.02),
+                    WOOD_IDX,
+                )
+            )
+
+        n_side = 3
+        side_h = (wall_h - 0.04) / n_side
+        for sign in (-1.0, 1.0):
+            for i in range(n_side):
+                z = FOOT_H + BOTTOM + 0.02 + side_h / 2.0 + i * (side_h + 0.006)
+                bevel_verts.extend(
+                    add_box(
+                        bm,
+                        (sign * (hx + SLAT_T / 2.0), 0.0, z),
+                        (SLAT_T, OUTER_D - 0.08, side_h - 0.008),
+                        WOOD_IDX,
+                    )
+                )
+
+        band_xs = (-OUTER_W * 0.32, 0.0, OUTER_W * 0.32)
+        for bx in band_xs:
+            bevel_verts.extend(
+                add_box(
+                    bm,
+                    (bx, -hy - BAND_T / 2.0, wall_z),
+                    (BAND_W, BAND_T, wall_h + 0.01),
+                    METAL_IDX,
+                )
+            )
+            bevel_verts.extend(
+                add_box(
+                    bm,
+                    (bx, hy + BAND_T / 2.0, wall_z),
+                    (BAND_W, BAND_T, wall_h + 0.01),
+                    METAL_IDX,
+                )
+            )
+            bevel_verts.extend(
+                add_box(
+                    bm,
+                    (bx, 0.0, FOOT_H + BAND_T / 2.0),
+                    (BAND_W, OUTER_D + 2.0 * BAND_T, BAND_T),
+                    METAL_IDX,
+                )
+            )
+
+        plate_w = 0.12
+        plate_h = 0.13
+        bevel_verts.extend(
+            add_box(
+                bm,
+                (0.0, -hy - BAND_T - 0.006, FOOT_H + BOTTOM + CAVITY_H * 0.55),
+                (plate_w, 0.014, plate_h),
+                METAL_IDX,
+            )
+        )
+        bevel_verts.extend(
+            add_box(
+                bm,
+                (0.0, -hy - BAND_T - 0.018, FOOT_H + BOTTOM + CAVITY_H * 0.70),
+                (0.036, 0.028, 0.022),
+                METAL_IDX,
+            )
+        )
+
+        bracket = 0.055
+        thick = 0.016
+        for sxn in (-1.0, 1.0):
+            for syn in (-1.0, 1.0):
+                bevel_verts.extend(
+                    add_box(
+                        bm,
+                        (
+                            sxn * (hx + thick / 2.0),
+                            syn * (hy - bracket / 2.0),
+                            wall_z,
+                        ),
+                        (thick, bracket, wall_h - 0.02),
+                        METAL_IDX,
+                    )
+                )
+                bevel_verts.extend(
+                    add_box(
+                        bm,
+                        (
+                            sxn * (hx - bracket / 2.0),
+                            syn * (hy + thick / 2.0),
+                            wall_z,
+                        ),
+                        (bracket, thick, wall_h - 0.02),
+                        METAL_IDX,
+                    )
+                )
+
+        # Closed lid, then rotate around the back hinge so the front lifts.
+        lid_z = body_top + LID_T / 2.0
+        lid_verts.extend(
+            add_box(
+                bm,
+                (0.0, 0.0, lid_z),
+                (OUTER_W, OUTER_D, LID_T),
+                WOOD_IDX,
+            )
+        )
+        lid_verts.extend(
+            add_box(
+                bm,
+                (0.0, -hy + LID_LIP / 2.0, body_top - LID_LIP / 2.0 + 0.004),
+                (OUTER_W - 0.02, LID_LIP, LID_LIP),
+                WOOD_IDX,
+            )
+        )
+        lid_slat_w = (OUTER_W - 0.05) / N_LID
+        usable_l = lid_slat_w - 0.006
+        x0l = -hx + 0.025 + usable_l / 2.0
+        for i in range(N_LID):
+            x = x0l + i * lid_slat_w
+            lid_verts.extend(
+                add_box(
+                    bm,
+                    (x, 0.0, body_top + LID_T + SLAT_T / 2.0),
+                    (usable_l, OUTER_D - 0.04, SLAT_T),
+                    WOOD_IDX,
+                )
+            )
+        for bx in band_xs:
+            lid_verts.extend(
+                add_box(
+                    bm,
+                    (bx, 0.0, body_top + LID_T + SLAT_T + BAND_T / 2.0),
+                    (BAND_W, OUTER_D + 2.0 * BAND_T, BAND_T),
+                    METAL_IDX,
+                )
+            )
+            lid_verts.extend(
+                add_box(
+                    bm,
+                    (bx, -hy - BAND_T / 2.0, body_top + LID_T / 2.0),
+                    (BAND_W, BAND_T, LID_T + SLAT_T + 0.01),
+                    METAL_IDX,
+                )
+            )
+        lid_verts.extend(
+            add_box(
+                bm,
+                (0.0, -hy - BAND_T - 0.006, body_top + 0.012),
+                (0.055, 0.016, 0.028),
+                METAL_IDX,
+            )
+        )
+
+        hinge = Vector((0.0, hy, body_top))
+        rot = Euler((LID_ANGLE, 0.0, 0.0)).to_matrix()
+        for v in lid_verts:
+            v.co = rot @ (v.co - hinge) + hinge
+
+        if bevel_offset > 0.0:
+            edges = list(
+                {e for v in (bevel_verts + lid_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,
+            )
+
+        xs = [v.co.x for v in bm.verts]
+        ys = [v.co.y for v in bm.verts]
+        zs = [v.co.z for v in bm.verts]
+        cx = 0.5 * (min(xs) + max(xs))
+        cy = 0.5 * (min(ys) + max(ys))
+        zmin = min(zs)
+        for v in bm.verts:
+            v.co.x -= cx
+            v.co.y -= cy
+            v.co.z -= zmin
+            if v.co.z < 0.0:
+                v.co.z = 0.0
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = 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
+        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):
+    # Do not materials.clear() — that resets polygon material_index to 0
+    # on this Blender, which would drop metal faces onto wood.
+    mats = obj.data.materials
+    wanted = (wood, metal)
+    for i, mat in enumerate(wanted):
+        if i < len(mats):
+            mats[i] = mat
+        else:
+            mats.append(mat)
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    overlap = 0.0
+    for i in range(len(aabbs)):
+        a = aabbs[i]
+        for j in range(i + 1, len(aabbs)):
+            b = aabbs[j]
+            x0 = max(a[0], b[0])
+            y0 = max(a[1], b[1])
+            x1 = min(a[2], b[2])
+            y1 = min(a[3], b[3])
+            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    # Duplicated from snippets/convex_hull_collider.py (not a package).
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    # Adapted from snippets/setup_bake_target_image.py — do not replace slots.
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("ChestNrm", 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_chest_mesh("ChestLow", bevel_offset=0.008, bevel_segments=2)
+    high = build_chest_mesh("ChestHigh", bevel_offset=0.008, bevel_segments=4)
+    wood = principled("ChestWood", (0.42, 0.20, 0.07, 1.0), 0.0, 0.52)
+    metal = principled("ChestMetal", (0.22, 0.23, 0.26, 1.0), 1.0, 0.30)
+    assign_slots(low, wood, metal)
+    assign_slots(high, wood, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("chest 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("chest has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "ChestLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "ChestLOD2", 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_chest_mesh(
+        "ChestColSrc", bevel_offset=0.0, bevel_segments=1
+    )
+    collider = convex_hull_collider(collider_src, "ChestCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_treasure_chest_{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 u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+            6,
+        ), None, None, None, None, None
+    if overlap > UV_OVERLAP_MAX:
+        return fail(
+            f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+            7,
+        ), None, None, None, None, None
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+            f"off outer {OUTER_SIZE}",
+            8,
+        ), None, None, None, None, None
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)",
+            9,
+        ), None, None, None, None, None
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+            9,
+        ), None, None, None, None, None
+    if col_tris > COLLIDER_TRIS_MAX:
+        return fail(
+            f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+            11,
+        ), None, None, None, None, None
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return fail(
+            f"bake failed result={bake_result} has_data={img.has_data}",
+            12,
+        ), None, None, None, None, None
+    if export_size <= 0:
+        return fail("export file missing or empty", 13), None, None, None, None, None
+    return 0, low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, wood, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(wood, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-28.0)
+    low.rotation_euler.x = math.radians(2.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.6, -5.0, 5.8), 680.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+    light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+    light("Wedge", (2.4, 4.2, 4.1), 640.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.54, -2.16, 1.37)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.34)
+    scene.collection.objects.link(aim)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+        scene.cycles.device = "CPU"
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = (
+        "WEBP" if path.lower().endswith(".webp") else "PNG"
+    )
+    if path.lower().endswith(".webp"):
+        scene.render.image_settings.quality = 90
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    fcode = gallery_framing.check_framing(
+        scene, cam, hero=[low], elements=[low], stage=[floor, wall],
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        return fail("render produced no file", 14)
+    return 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None)
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    p.add_argument(
+        "--skip-decimate",
+        action="store_true",
+        help="falsification: skip the LOD DECIMATE stage",
+    )
+    args = p.parse_args(argv)
+
+    code, low, _high, wood, tex, _col = check(args.skip_decimate)
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("treasure-chest 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 c01cb52..981c2e6 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -76,6 +76,18 @@ "mesh", "export" ] + }, + { + "name": "treasure-chest", + "dir": "showcase/treasure-chest", + "teaches": "A procedural slatted treasure chest through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 5724 tris, two materials with 180 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.776×0.549×0.631 m, LOD ratios in band, convex collider 138 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/treasure-chest-hero.webp", + "preview": "showcase/treasure-chest/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/treasure-chest/README.md b/showcase/treasure-chest/README.md new file mode 100644 index 0000000..d3488b9 --- /dev/null +++ b/showcase/treasure-chest/README.md @@ -0,0 +1,73 @@ +# Treasure chest + +A showcase piece, not an example. Procedural slatted treasure chest +(hollow body, open lid, iron bands, corner brackets, lock plate) 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 | 5600–5850 | 5724 / 5724 / 5724 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 | +| Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 180 metal | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (0.776, 0.549, 0.631) m ± 0.01 | (0.7760, 0.5486, 0.6312), zmin 0 | +| Collider tris | ≤ 160 | 138 | +| Export | written, size > 0 | 416656 / 416656 / 416644 bytes | + +DECIMATE COLLAPSE triangle counts are **not** guaranteed identical across +series — the gate is a ratio band, not an exact count. This mesh happened +to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate +is `has_data` plus operator `FINISHED`, not byte-identity. Construction +uses no RNG. glTF byte size differs by a few bytes across series. + +`--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 treasure_chest.py -- +blender --background --python treasure_chest.py -- --skip-decimate +blender --background --python treasure_chest.py -- --output chest.png +``` + +Smoke does not pass `--output` or `--skip-decimate`. + +## Exit codes + +File-local. `9` is a valid check code. `10` is reserved for +`gallery_framing.check_framing` on the `--output` path. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build / no UV layer | +| 4 | Base triangle count outside range | +| 5 | Material count ≠ 2 distinct slots, or metal faces missing | +| 6 | UVs outside 0..1 | +| 7 | UV AABB overlap above tolerance | +| 8 | World AABB off declared outer size | +| 9 | LOD ratio band (`--skip-decimate` lands here) | +| 10 | Framing gate (render path only) | +| 11 | Collider triangle count above ceiling | +| 12 | Bake did not finish or image has no data | +| 13 | Export file missing or empty | +| 14 | `--output` produced no file | diff --git a/showcase/treasure-chest/preview.webp b/showcase/treasure-chest/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..25cef6444404a7b66bdc256fb3b2bbde2473f6c9 GIT binary patch literal 15844 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 uiufq= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package). + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) + v.co = rot @ p + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def pack_uvs(bm, margin=0.08): + uv = bm.loops.layers.uv.new("UVMap") + faces = list(bm.faces) + n = len(faces) + cols = max(1, math.ceil(math.sqrt(n))) + rows = max(1, math.ceil(n / cols)) + cell_w = 1.0 / cols + cell_h = 1.0 / rows + pad_u = margin * cell_w * 0.5 + pad_v = margin * cell_h * 0.5 + usable_w = cell_w - 2.0 * pad_u + usable_h = cell_h - 2.0 * pad_v + for i, face in enumerate(faces): + col = i % cols + row = i // cols + nrm = face.normal + ax = abs(nrm.x) + ay = abs(nrm.y) + az = abs(nrm.z) + coords = [] + for loop in face.loops: + co = loop.vert.co + if az >= ax and az >= ay: + coords.append((co.x, co.y)) + elif ax >= ay: + coords.append((co.y, co.z)) + else: + coords.append((co.x, co.z)) + xs = [c[0] for c in coords] + ys = [c[1] for c in coords] + minx, maxx = min(xs), max(xs) + miny, maxy = min(ys), max(ys) + dx = max(maxx - minx, 1e-8) + dy = max(maxy - miny, 1e-8) + origin_u = col * cell_w + pad_u + origin_v = row * cell_h + pad_v + for loop, (x, y) in zip(face.loops, coords): + loop[uv].uv = ( + origin_u + (x - minx) / dx * usable_w, + origin_v + (y - miny) / dy * usable_h, + ) + + +def build_chest_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + try: + bevel_verts = [] + lid_verts = [] + body_top = FOOT_H + BOTTOM + CAVITY_H + hx = OUTER_W / 2.0 + hy = OUTER_D / 2.0 + + for sxn in (-1.0, 1.0): + for syn in (-1.0, 1.0): + bevel_verts.extend( + add_box( + bm, + ( + sxn * (hx - FOOT_S / 2.0 - 0.01), + syn * (hy - FOOT_S / 2.0 - 0.01), + FOOT_H / 2.0, + ), + (FOOT_S, FOOT_S, FOOT_H), + METAL_IDX, + ) + ) + + bevel_verts.extend( + add_box( + bm, + (0.0, 0.0, FOOT_H + BOTTOM / 2.0), + (OUTER_W, OUTER_D, BOTTOM), + WOOD_IDX, + ) + ) + wall_h = CAVITY_H + wall_z = FOOT_H + BOTTOM + wall_h / 2.0 + bevel_verts.extend( + add_box( + bm, + (0.0, -hy + WALL / 2.0, wall_z), + (OUTER_W, WALL, wall_h), + WOOD_IDX, + ) + ) + bevel_verts.extend( + add_box( + bm, + (0.0, hy - WALL / 2.0, wall_z), + (OUTER_W, WALL, wall_h), + WOOD_IDX, + ) + ) + inner_d = OUTER_D - 2.0 * WALL + bevel_verts.extend( + add_box( + bm, + (-hx + WALL / 2.0, 0.0, wall_z), + (WALL, inner_d, wall_h), + WOOD_IDX, + ) + ) + bevel_verts.extend( + add_box( + bm, + (hx - WALL / 2.0, 0.0, wall_z), + (WALL, inner_d, wall_h), + WOOD_IDX, + ) + ) + + slat_w = (OUTER_W - 0.06) / N_FRONT + gap = 0.006 + usable = slat_w - gap + x0 = -hx + 0.03 + usable / 2.0 + for i in range(N_FRONT): + x = x0 + i * slat_w + bevel_verts.extend( + add_box( + bm, + (x, -hy - SLAT_T / 2.0, wall_z), + (usable, SLAT_T, wall_h - 0.02), + WOOD_IDX, + ) + ) + + n_side = 3 + side_h = (wall_h - 0.04) / n_side + for sign in (-1.0, 1.0): + for i in range(n_side): + z = FOOT_H + BOTTOM + 0.02 + side_h / 2.0 + i * (side_h + 0.006) + bevel_verts.extend( + add_box( + bm, + (sign * (hx + SLAT_T / 2.0), 0.0, z), + (SLAT_T, OUTER_D - 0.08, side_h - 0.008), + WOOD_IDX, + ) + ) + + band_xs = (-OUTER_W * 0.32, 0.0, OUTER_W * 0.32) + for bx in band_xs: + bevel_verts.extend( + add_box( + bm, + (bx, -hy - BAND_T / 2.0, wall_z), + (BAND_W, BAND_T, wall_h + 0.01), + METAL_IDX, + ) + ) + bevel_verts.extend( + add_box( + bm, + (bx, hy + BAND_T / 2.0, wall_z), + (BAND_W, BAND_T, wall_h + 0.01), + METAL_IDX, + ) + ) + bevel_verts.extend( + add_box( + bm, + (bx, 0.0, FOOT_H + BAND_T / 2.0), + (BAND_W, OUTER_D + 2.0 * BAND_T, BAND_T), + METAL_IDX, + ) + ) + + plate_w = 0.12 + plate_h = 0.13 + bevel_verts.extend( + add_box( + bm, + (0.0, -hy - BAND_T - 0.006, FOOT_H + BOTTOM + CAVITY_H * 0.55), + (plate_w, 0.014, plate_h), + METAL_IDX, + ) + ) + bevel_verts.extend( + add_box( + bm, + (0.0, -hy - BAND_T - 0.018, FOOT_H + BOTTOM + CAVITY_H * 0.70), + (0.036, 0.028, 0.022), + METAL_IDX, + ) + ) + + bracket = 0.055 + thick = 0.016 + for sxn in (-1.0, 1.0): + for syn in (-1.0, 1.0): + bevel_verts.extend( + add_box( + bm, + ( + sxn * (hx + thick / 2.0), + syn * (hy - bracket / 2.0), + wall_z, + ), + (thick, bracket, wall_h - 0.02), + METAL_IDX, + ) + ) + bevel_verts.extend( + add_box( + bm, + ( + sxn * (hx - bracket / 2.0), + syn * (hy + thick / 2.0), + wall_z, + ), + (bracket, thick, wall_h - 0.02), + METAL_IDX, + ) + ) + + # Closed lid, then rotate around the back hinge so the front lifts. + lid_z = body_top + LID_T / 2.0 + lid_verts.extend( + add_box( + bm, + (0.0, 0.0, lid_z), + (OUTER_W, OUTER_D, LID_T), + WOOD_IDX, + ) + ) + lid_verts.extend( + add_box( + bm, + (0.0, -hy + LID_LIP / 2.0, body_top - LID_LIP / 2.0 + 0.004), + (OUTER_W - 0.02, LID_LIP, LID_LIP), + WOOD_IDX, + ) + ) + lid_slat_w = (OUTER_W - 0.05) / N_LID + usable_l = lid_slat_w - 0.006 + x0l = -hx + 0.025 + usable_l / 2.0 + for i in range(N_LID): + x = x0l + i * lid_slat_w + lid_verts.extend( + add_box( + bm, + (x, 0.0, body_top + LID_T + SLAT_T / 2.0), + (usable_l, OUTER_D - 0.04, SLAT_T), + WOOD_IDX, + ) + ) + for bx in band_xs: + lid_verts.extend( + add_box( + bm, + (bx, 0.0, body_top + LID_T + SLAT_T + BAND_T / 2.0), + (BAND_W, OUTER_D + 2.0 * BAND_T, BAND_T), + METAL_IDX, + ) + ) + lid_verts.extend( + add_box( + bm, + (bx, -hy - BAND_T / 2.0, body_top + LID_T / 2.0), + (BAND_W, BAND_T, LID_T + SLAT_T + 0.01), + METAL_IDX, + ) + ) + lid_verts.extend( + add_box( + bm, + (0.0, -hy - BAND_T - 0.006, body_top + 0.012), + (0.055, 0.016, 0.028), + METAL_IDX, + ) + ) + + hinge = Vector((0.0, hy, body_top)) + rot = Euler((LID_ANGLE, 0.0, 0.0)).to_matrix() + for v in lid_verts: + v.co = rot @ (v.co - hinge) + hinge + + if bevel_offset > 0.0: + edges = list( + {e for v in (bevel_verts + lid_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, + ) + + xs = [v.co.x for v in bm.verts] + ys = [v.co.y for v in bm.verts] + zs = [v.co.z for v in bm.verts] + cx = 0.5 * (min(xs) + max(xs)) + cy = 0.5 * (min(ys) + max(ys)) + zmin = min(zs) + for v in bm.verts: + v.co.x -= cx + v.co.y -= cy + v.co.z -= zmin + if v.co.z < 0.0: + v.co.z = 0.0 + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = 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 + 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): + # Do not materials.clear() — that resets polygon material_index to 0 + # on this Blender, which would drop metal faces onto wood. + mats = obj.data.materials + wanted = (wood, metal) + for i, mat in enumerate(wanted): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0, 1.0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + overlap = 0.0 + for i in range(len(aabbs)): + a = aabbs[i] + for j in range(i + 1, len(aabbs)): + b = aabbs[j] + x0 = max(a[0], b[0]) + y0 = max(a[1], b[1]) + x1 = min(a[2], b[2]) + y1 = min(a[3], b[3]) + overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def convex_hull_collider(obj, name): + # Duplicated from snippets/convex_hull_collider.py (not a package). + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) + interior = result.get("geom_interior") or [] + unused = result.get("geom_unused") or [] + if interior: + bmesh.ops.delete(bm, geom=interior, context="VERTS") + if unused: + bmesh.ops.delete(bm, geom=unused, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + collider = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +def setup_bake_image(obj, target_mat, size=BAKE_RES): + # Adapted from snippets/setup_bake_target_image.py — do not replace slots. + if not obj.data.uv_layers: + return None, None + img = bpy.data.images.new("ChestNrm", 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_chest_mesh("ChestLow", bevel_offset=0.008, bevel_segments=2) + high = build_chest_mesh("ChestHigh", bevel_offset=0.008, bevel_segments=4) + wood = principled("ChestWood", (0.42, 0.20, 0.07, 1.0), 0.0, 0.52) + metal = principled("ChestMetal", (0.22, 0.23, 0.26, 1.0), 1.0, 0.30) + assign_slots(low, wood, metal) + assign_slots(high, wood, metal) + + if low.data is None or len(low.data.polygons) < 6: + return fail("chest 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("chest has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "ChestLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "ChestLOD2", 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_chest_mesh( + "ChestColSrc", bevel_offset=0.0, bevel_segments=1 + ) + collider = convex_hull_collider(collider_src, "ChestCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_treasure_chest_{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 u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return fail( + f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", + 6, + ), None, None, None, None, None + if overlap > UV_OVERLAP_MAX: + return fail( + f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", + 7, + ), None, None, None, None, None + if ( + abs(size_x - OUTER_SIZE[0]) > BBOX_TOL + or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL + ): + return fail( + f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"off outer {OUTER_SIZE}", + 8, + ), None, None, None, None, None + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return fail( + f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", + 9, + ), None, None, None, None, None + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return fail( + f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", + 9, + ), None, None, None, None, None + if col_tris > COLLIDER_TRIS_MAX: + return fail( + f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", + 11, + ), None, None, None, None, None + if bake_result != {"FINISHED"} or not img.has_data: + return fail( + f"bake failed result={bake_result} has_data={img.has_data}", + 12, + ), None, None, None, None, None + if export_size <= 0: + return fail("export file missing or empty", 13), None, None, None, None, None + return 0, low, high, wood, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + nrm = nt.nodes.new("ShaderNodeNormalMap") + nrm.inputs["Strength"].default_value = 1.0 + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) + + +def render_still(low, wood, tex, path, engine): + scene = bpy.context.scene + wire_normal(wood, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(-28.0) + low.rotation_euler.x = math.radians(2.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 8.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-3.6, -5.0, 5.8), 680.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) + light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) + light("Wedge", (2.4, 4.2, 4.1), 640.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.54, -2.16, 1.37) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.34) + scene.collection.objects.link(aim) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 32 + scene.cycles.device = "CPU" + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = ( + "WEBP" if path.lower().endswith(".webp") else "PNG" + ) + if path.lower().endswith(".webp"): + scene.render.image_settings.quality = 90 + scene.render.filepath = path + scene.view_settings.view_transform = "Standard" + + fcode = gallery_framing.check_framing( + scene, cam, hero=[low], elements=[low], stage=[floor, wall], + ) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + return fail("render produced no file", 14) + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None) + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument( + "--skip-decimate", + action="store_true", + help="falsification: skip the LOD DECIMATE stage", + ) + args = p.parse_args(argv) + + code, low, _high, wood, tex, _col = check(args.skip_decimate) + if code: + return code + if args.output: + rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("treasure-chest 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 5fceae7..d73ce53 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -79,5 +79,6 @@ {"name": "wooden-barrel", "script": "showcase/wooden-barrel/wooden_barrel.py"}, {"name": "campfire", "script": "showcase/campfire/campfire.py"}, {"name": "market-stall", "script": "showcase/market-stall/market_stall.py"}, - {"name": "street-lantern", "script": "showcase/street-lantern/street_lantern.py"} + {"name": "street-lantern", "script": "showcase/street-lantern/street_lantern.py"}, + {"name": "treasure-chest", "script": "showcase/treasure-chest/treasure_chest.py"} ]