From e48b9b8f4dc99cf2ce5a87a2710ee1dff4fafe50 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:47:21 -0400 Subject: [PATCH 1/2] feat: add anvil showcase piece London-pattern anvil on a hooped timber stump through the shipped budget pipeline. --skip-decimate exits 9 on the LOD1 ratio band. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 3 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 7 +- ROADMAP.md | 2 +- docs/gallery/anvil/index.html | 1118 +++++++++++++++++++++++++++ docs/gallery/assets/anvil-hero.webp | Bin 0 -> 9106 bytes docs/gallery/index.html | 13 +- showcase/anvil/README.md | 74 ++ showcase/anvil/anvil.py | 794 +++++++++++++++++++ showcase/anvil/preview.webp | Bin 0 -> 8332 bytes showcase/gallery.json | 12 + tests/smoke/catalog.json | 3 +- 13 files changed, 2022 insertions(+), 8 deletions(-) create mode 100644 docs/gallery/anvil/index.html create mode 100644 docs/gallery/assets/anvil-hero.webp create mode 100644 showcase/anvil/README.md create mode 100644 showcase/anvil/anvil.py create mode 100644 showcase/anvil/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 293beea..853716e 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -150,6 +150,7 @@ "showcase/watchtower", "showcase/cart", "showcase/park-bench", - "showcase/wheelbarrow" + "showcase/wheelbarrow", + "showcase/anvil" ] } diff --git a/AGENTS.md b/AGENTS.md index 372affb..af788ce 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 13 showcase pieces (counts are CI-enforced against README.md) +examples, and 14 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 f31806b..5689c15 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, 13 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 14 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 6913e3b..3dadd54 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

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

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 13 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. +This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 14 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. @@ -88,6 +88,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Cart: a two-wheel wooden cart with spoked wheels, slatted bed, and shafts on a dark studio floor Park bench: a wrought-iron bench with scrolled legs and wooden slats on a dark studio floor Wheelbarrow: a staved wooden tray with iron straps, a spoked wheel, rear legs, and handles on a dark studio floor +Anvil: a London-pattern anvil on a hooped timber stump 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. @@ -115,6 +116,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`wheelbarrow`](showcase/wheelbarrow/) — procedural staved wheelbarrow with a spoked wheel, iron straps, legs, and handles through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`anvil`](showcase/anvil/) — procedural London-pattern anvil on a timber stump 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 12fc8c3..edde7bd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -117,7 +117,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Two-wheel wooden cart showcase~~ **SHIPPED** as `showcase/cart/` — slatted bed, shafts, spoked wheels, iron hubs and axle; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Wrought-iron park bench (slatted seat, scrolled legs) as a game-prop showcase piece~~ **SHIPPED** as `showcase/park-bench/` — scrolled iron legs, slatted seat and back; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Wooden wheelbarrow (staved tray, single wheel, handles) as a game-prop showcase piece~~ **SHIPPED** as `showcase/wheelbarrow/` — staved U-tray, iron straps, spoked wheel, rear legs; `--skip-decimate` exits 9 on the LOD1 ratio band -- Blacksmith anvil on a timber stump as a game-prop showcase piece +- ~~Blacksmith anvil on a timber stump as a game-prop showcase piece~~ **SHIPPED** as `showcase/anvil/` — London-pattern horn, face, heel, waist, hooped stump; `--skip-decimate` exits 9 on the LOD1 ratio band - Staved wooden water trough on a stand as a game-prop showcase piece - ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **SHIPPED** as `showcase/fence-kit/` — tiling post-and-rail section (AABB X is the tile width; does not re-witness the snap contract); `--skip-decimate` exits 9 on the LOD1 ratio band diff --git a/docs/gallery/anvil/index.html b/docs/gallery/anvil/index.html new file mode 100644 index 0000000..c730f27 --- /dev/null +++ b/docs/gallery/anvil/index.html @@ -0,0 +1,1118 @@ + + + + + + anvil — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +

+
+

anvil

+

A procedural blacksmith anvil on a timber stump 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: 1052 tris, two materials with 102 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.750×0.406×0.690 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 134 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/anvil/anvil.py --
+ +
+
+

A showcase piece, not an example. Procedural London-pattern anvil on a timber stump (horn, face, heel, waist, iron hoops) 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 | 980–1140 | 1052 / 1052 / 1052 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2186 / 0.2186 / 0.2110 | | Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 102 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.750, 0.406, 0.690) m ± 0.01 | (0.7500, 0.4060, 0.6900), zmin 0 | | Collider tris | ≤ 180 | 134 | | Export | written, size > 0 | 84092 / 84092 / 84088 bytes |

+

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

+

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

+

Run

+
blender --background --python anvil.py --
+blender --background --python anvil.py -- --skip-decimate
+blender --background --python anvil.py -- --output anvil.png
+

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

+

Exit codes

+

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

+

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

+
+
+

Source

+
+ showcase/anvil/anvil.py + View on GitHub → +
+
"""Game-ready blacksmith anvil — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural London-pattern anvil on a
+timber stump (horn, face, heel, waist, iron hoops) 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 anvil.py --
+    blender --background --python anvil.py -- --skip-decimate
+    blender --background --python anvil.py -- --output anvil.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+STUMP_R = 0.195
+STUMP_H = 0.34
+FACE_L = 0.40
+FACE_W = 0.125
+FACE_T = 0.048
+BODY_H = 0.18
+WAIST_L = 0.22
+WAIST_W = 0.088
+FOOT_L = 0.32
+FOOT_W = 0.15
+HORN_L = 0.28
+HEEL_L = 0.11
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (0.750, 0.406, 0.690)
+BASE_TRIS_MIN = 980
+BASE_TRIS_MAX = 1140
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 2
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 180
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 24
+WOOD_FACES_MIN = 12
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    depsgraph = bpy.context.evaluated_depsgraph_get()
+    eval_obj = obj.evaluated_get(depsgraph)
+    eval_mesh = eval_obj.to_mesh()
+    try:
+        eval_mesh.calc_loop_triangles()
+        return len(eval_mesh.loop_triangles)
+    finally:
+        eval_obj.to_mesh_clear()
+
+
+def deselect_all():
+    for ob in list(bpy.context.view_layer.objects):
+        if ob is None:
+            continue
+        ob.select_set(False)
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+        v.co = rot @ p + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_oriented_box(bm, a, b, scale_xy, mat_idx):
+    a = Vector(a)
+    b = Vector(b)
+    delta = b - a
+    length = delta.length
+    if length < 1e-8:
+        return []
+    quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+    eul = quat.to_euler("XYZ")
+    return add_box(
+        bm,
+        ((a + b) * 0.5),
+        (scale_xy[0], scale_xy[1], length),
+        mat_idx,
+        euler=(eul.x, eul.y, eul.z),
+    )
+
+
+def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius,
+        radius2=radius,
+        depth=depth,
+    )
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius1,
+        radius2=radius2,
+        depth=depth,
+    )
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def 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_anvil_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    try:
+        wood = []
+        metal = []
+
+        wood.extend(
+            add_cyl(
+                bm, (0.0, 0.0, STUMP_H / 2.0), STUMP_R, STUMP_H, 14, WOOD_IDX,
+            )
+        )
+        wood.extend(
+            add_cyl(
+                bm,
+                (0.0, 0.0, STUMP_H + 0.012),
+                STUMP_R - 0.012,
+                0.024,
+                14,
+                WOOD_IDX,
+            )
+        )
+
+        if bevel_offset > 0.0:
+            edges = list({e for v in wood for e in v.link_edges})
+            ret = bmesh.ops.bevel(
+                bm,
+                geom=edges,
+                offset=bevel_offset,
+                segments=bevel_segments,
+                profile=0.5,
+                affect="EDGES",
+                clamp_overlap=True,
+            )
+            for f in ret.get("faces") or []:
+                f.material_index = WOOD_IDX
+
+        for z in (0.055, STUMP_H - 0.055):
+            metal.extend(
+                add_cyl(
+                    bm,
+                    (0.0, 0.0, z),
+                    STUMP_R + 0.008,
+                    0.028,
+                    16,
+                    METAL_IDX,
+                )
+            )
+
+        z0 = STUMP_H
+        metal.extend(
+            add_box(
+                bm,
+                (0.0, 0.0, z0 + 0.028),
+                (FOOT_L, FOOT_W, 0.056),
+                METAL_IDX,
+            )
+        )
+        metal.extend(
+            add_box(
+                bm,
+                (0.0, 0.0, z0 + 0.056 + BODY_H / 2.0),
+                (WAIST_L, WAIST_W, BODY_H),
+                METAL_IDX,
+            )
+        )
+        metal.extend(
+            add_box(
+                bm,
+                (0.0, 0.0, z0 + 0.056 + BODY_H + 0.028),
+                (FACE_L * 0.90, FACE_W * 0.88, 0.056),
+                METAL_IDX,
+            )
+        )
+        face_z = z0 + 0.056 + BODY_H + 0.056 + FACE_T / 2.0
+        metal.extend(
+            add_box(
+                bm,
+                (0.0, 0.0, face_z),
+                (FACE_L, FACE_W, FACE_T),
+                METAL_IDX,
+            )
+        )
+
+        horn_z = face_z - 0.018
+        metal.extend(
+            add_cone(
+                bm,
+                (FACE_L / 2.0 + HORN_L / 2.0 - 0.02, 0.0, horn_z),
+                0.052,
+                0.016,
+                HORN_L,
+                12,
+                METAL_IDX,
+                euler=(0.0, math.pi / 2.0, 0.0),
+            )
+        )
+        metal.extend(
+            add_box(
+                bm,
+                (FACE_L / 2.0 - 0.01, 0.0, horn_z - 0.028),
+                (0.09, FACE_W * 0.92, 0.040),
+                METAL_IDX,
+            )
+        )
+        metal.extend(
+            add_box(
+                bm,
+                (-FACE_L / 2.0 - HEEL_L / 2.0 + 0.02, 0.0, face_z - 0.008),
+                (HEEL_L, FACE_W * 0.92, FACE_T * 0.82),
+                METAL_IDX,
+            )
+        )
+        metal.extend(
+            add_box(
+                bm,
+                (FACE_L * 0.22, 0.0, face_z + FACE_T / 2.0 - 0.006),
+                (0.034, 0.034, 0.014),
+                METAL_IDX,
+            )
+        )
+        metal.extend(
+            add_cyl(
+                bm,
+                (FACE_L * 0.32, 0.0, face_z + FACE_T / 2.0 - 0.004),
+                0.009,
+                0.012,
+                8,
+                METAL_IDX,
+            )
+        )
+
+        xs = [v.co.x for v in bm.verts]
+        ys = [v.co.y for v in bm.verts]
+        zs = [v.co.z for v in bm.verts]
+        rcx = 0.5 * (min(xs) + max(xs))
+        rcy = 0.5 * (min(ys) + max(ys))
+        zmin = min(zs)
+        for v in bm.verts:
+            v.co.x -= rcx
+            v.co.y -= rcy
+            v.co.z -= zmin
+            if v.co.z < 0.0:
+                v.co.z = 0.0
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = False
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+        for poly in me.polygons:
+            poly.use_smooth = False
+    finally:
+        bm.free()
+    out = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(out)
+    return out
+
+
+def principled(name, color, metallic, roughness):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def assign_slots(obj, wood, metal):
+    mats = obj.data.materials
+    wanted = (wood, metal)
+    for i, mat in enumerate(wanted):
+        if i < len(mats):
+            mats[i] = mat
+        else:
+            mats.append(mat)
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    overlap = 0.0
+    for i in range(len(aabbs)):
+        a = aabbs[i]
+        for j in range(i + 1, len(aabbs)):
+            b = aabbs[j]
+            x0 = max(a[0], b[0])
+            y0 = max(a[1], b[1])
+            x1 = min(a[2], b[2])
+            y1 = min(a[3], b[3])
+            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("AnvilNrm", size, size, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    nodes = target_mat.node_tree.nodes
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = img
+    nodes.active = tex
+    tex.select = True
+    obj.active_material_index = WOOD_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    deselect_all()
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL",
+        use_selected_to_active=True,
+        cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False,
+        normal_space="TANGENT",
+        margin=4,
+        margin_type="ADJACENT_FACES",
+        use_clear=True,
+        target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    deselect_all()
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path,
+        use_selection=True,
+        export_yup=True,
+        export_apply=True,
+        export_draco_mesh_compression_enable=False,
+        export_animations=False,
+    )
+
+
+def check(skip_decimate):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    low = build_anvil_mesh("AnvilLow", bevel_offset=0.005, bevel_segments=2)
+    high = build_anvil_mesh("AnvilHigh", bevel_offset=0.005, bevel_segments=4)
+    wood = principled("AnvilStump", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62)
+    metal = principled("AnvilSteel", (0.18, 0.185, 0.195, 1.0), 1.0, 0.24)
+    assign_slots(low, wood, metal)
+    assign_slots(high, wood, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("anvil 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("anvil has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "AnvilLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "AnvilLOD2", 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_anvil_mesh("AnvilColSrc", bevel_offset=0.0, bevel_segments=1)
+    collider = convex_hull_collider(collider_src, "AnvilCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_anvil_{os.getpid()}.glb",
+    )
+    if os.path.exists(export_path):
+        os.remove(export_path)
+    export_unity(export_path, [low, collider])
+    export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+    print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
+    print(
+        f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+        f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+    )
+    print(
+        f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+        f"overlap={overlap:.6f} nfaces={nfaces}"
+    )
+    print(
+        f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+        f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
+    )
+    print(
+        f"measured collider_tris={col_tris} bake={bake_result} "
+        f"bake_has_data={img.has_data} export_bytes={export_size}"
+    )
+
+    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+        return fail(
+            f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
+            4,
+        ), None, None, None, None, None
+    if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+        return fail(
+            f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+        return fail(
+            f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN:
+        return fail(
+            f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+            6,
+        ), None, None, None, None, None
+    if overlap > UV_OVERLAP_MAX:
+        return fail(
+            f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+            7,
+        ), None, None, None, None, None
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+            f"off outer {OUTER_SIZE}",
+            8,
+        ), None, None, None, None, None
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)",
+            9,
+        ), None, None, None, None, None
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+            9,
+        ), None, None, None, None, None
+    if col_tris > COLLIDER_TRIS_MAX:
+        return fail(
+            f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+            11,
+        ), None, None, None, None, None
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return fail(
+            f"bake failed result={bake_result} has_data={img.has_data}",
+            12,
+        ), None, None, None, None, None
+    if export_size <= 0:
+        return fail("export file missing or empty", 13), None, None, None, None, None
+    return 0, low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, wood, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(wood, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-22.0)
+    low.rotation_euler.x = math.radians(0.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.6, -5.0, 5.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+    light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+    light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (1.36, -1.68, 0.93)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.36)
+    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("anvil OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        traceback.print_exc()
+        print(f"FATAL: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/docs/gallery/assets/anvil-hero.webp b/docs/gallery/assets/anvil-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..0a706f6494f8ba2a08690f378e88bc24a557ebe7 GIT binary patch literal 9106 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 QThRajY-LzP=s*Af0E#ZP;{X5v literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 00be18e..b79b342 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" /> - 64 examples + 65 examples
@@ -1028,6 +1028,17 @@

wheelbarrow

View example
+
+ + anvil — A procedural blacksmith anvil on a timber stump through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract. + +
+

anvil

+

A procedural blacksmith anvil on a timber stump through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 1052 tris, two materials with 102 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.750×0.406×0.690 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 134 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+ View example +
+
diff --git a/showcase/anvil/README.md b/showcase/anvil/README.md new file mode 100644 index 0000000..2672e89 --- /dev/null +++ b/showcase/anvil/README.md @@ -0,0 +1,74 @@ +# Anvil + +A showcase piece, not an example. Procedural London-pattern anvil on a +timber stump (horn, face, heel, waist, iron hoops) 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 | 980–1140 | 1052 / 1052 / 1052 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2186 / 0.2186 / 0.2110 | +| Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 102 metal | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (0.750, 0.406, 0.690) m ± 0.01 | (0.7500, 0.4060, 0.6900), zmin 0 | +| Collider tris | ≤ 180 | 134 | +| Export | written, size > 0 | 84092 / 84092 / 84088 bytes | + +DECIMATE COLLAPSE triangle counts are **not** identical across series — +5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an +exact count. Bake pixels are stochastic; the gate is `has_data` plus +operator `FINISHED`, not byte-identity. Construction uses no RNG. +Export byte counts differ by 4 B on 5.2.1 (glTF serializer), not a +gated axis. + +`--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and +exit 9 fires. That is the named budget the falsifier violates. + +## Run + +```bash +blender --background --python anvil.py -- +blender --background --python anvil.py -- --skip-decimate +blender --background --python anvil.py -- --output anvil.png +``` + +Smoke does not pass `--output` or `--skip-decimate`. + +## Exit codes + +File-local. `9` is a valid check code. `10` is reserved for +`gallery_framing.check_framing` on the `--output` path. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build / no UV layer | +| 4 | Base triangle count outside range | +| 5 | Material count ≠ 2 distinct slots, or wood/metal faces missing | +| 6 | UVs outside 0..1 | +| 7 | UV AABB overlap above tolerance | +| 8 | World AABB off declared outer size | +| 9 | LOD ratio band (`--skip-decimate` lands here) | +| 10 | Framing gate (render path only) | +| 11 | Collider triangle count above ceiling | +| 12 | Bake did not finish or image has no data | +| 13 | Export file missing or empty | +| 14 | `--output` produced no file | diff --git a/showcase/anvil/anvil.py b/showcase/anvil/anvil.py new file mode 100644 index 0000000..f7a3deb --- /dev/null +++ b/showcase/anvil/anvil.py @@ -0,0 +1,794 @@ +"""Game-ready blacksmith anvil — a showcase piece, not an example. + +Asserts budget conformance of a procedural London-pattern anvil on a +timber stump (horn, face, heel, waist, iron hoops) 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 anvil.py -- + blender --background --python anvil.py -- --skip-decimate + blender --background --python anvil.py -- --output anvil.png +""" +import argparse +import math +import os +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Euler, Vector + +_REPO = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) +) +sys.path.insert(0, os.path.join(_REPO, "examples")) +sys.dont_write_bytecode = True +import gallery_framing # noqa: E402 + +STUMP_R = 0.195 +STUMP_H = 0.34 +FACE_L = 0.40 +FACE_W = 0.125 +FACE_T = 0.048 +BODY_H = 0.18 +WAIST_L = 0.22 +WAIST_W = 0.088 +FOOT_L = 0.32 +FOOT_W = 0.15 +HORN_L = 0.28 +HEEL_L = 0.11 + +BBOX_TOL = 0.01 +OUTER_SIZE = (0.750, 0.406, 0.690) +BASE_TRIS_MIN = 980 +BASE_TRIS_MAX = 1140 +LOD1_RATIO_MIN = 0.32 +LOD1_RATIO_MAX = 0.62 +LOD2_RATIO_MIN = 0.10 +LOD2_RATIO_MAX = 0.35 +LOD1_TARGET = 0.50 +LOD2_TARGET = 0.22 +MATERIAL_COUNT = 2 +UV_EPS = 1e-4 +UV_OVERLAP_MAX = 1e-5 +COLLIDER_TRIS_MAX = 180 +BAKE_RES = 256 +CAGE_EXTRUSION = 0.08 +METAL_FACES_MIN = 24 +WOOD_FACES_MIN = 12 + +WOOD_IDX = 0 +METAL_IDX = 1 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def deselect_all(): + for ob in list(bpy.context.view_layer.objects): + if ob is None: + continue + ob.select_set(False) + + +def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) + v.co = rot @ p + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_oriented_box(bm, a, b, scale_xy, mat_idx): + a = Vector(a) + b = Vector(b) + delta = b - a + length = delta.length + if length < 1e-8: + return [] + quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) + eul = quat.to_euler("XYZ") + return add_box( + bm, + ((a + b) * 0.5), + (scale_xy[0], scale_xy[1], length), + mat_idx, + euler=(eul.x, eul.y, eul.z), + ) + + +def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius, + radius2=radius, + depth=depth, + ) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius1, + radius2=radius2, + depth=depth, + ) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def 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_anvil_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + try: + wood = [] + metal = [] + + wood.extend( + add_cyl( + bm, (0.0, 0.0, STUMP_H / 2.0), STUMP_R, STUMP_H, 14, WOOD_IDX, + ) + ) + wood.extend( + add_cyl( + bm, + (0.0, 0.0, STUMP_H + 0.012), + STUMP_R - 0.012, + 0.024, + 14, + WOOD_IDX, + ) + ) + + if bevel_offset > 0.0: + edges = list({e for v in wood for e in v.link_edges}) + ret = bmesh.ops.bevel( + bm, + geom=edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + for f in ret.get("faces") or []: + f.material_index = WOOD_IDX + + for z in (0.055, STUMP_H - 0.055): + metal.extend( + add_cyl( + bm, + (0.0, 0.0, z), + STUMP_R + 0.008, + 0.028, + 16, + METAL_IDX, + ) + ) + + z0 = STUMP_H + metal.extend( + add_box( + bm, + (0.0, 0.0, z0 + 0.028), + (FOOT_L, FOOT_W, 0.056), + METAL_IDX, + ) + ) + metal.extend( + add_box( + bm, + (0.0, 0.0, z0 + 0.056 + BODY_H / 2.0), + (WAIST_L, WAIST_W, BODY_H), + METAL_IDX, + ) + ) + metal.extend( + add_box( + bm, + (0.0, 0.0, z0 + 0.056 + BODY_H + 0.028), + (FACE_L * 0.90, FACE_W * 0.88, 0.056), + METAL_IDX, + ) + ) + face_z = z0 + 0.056 + BODY_H + 0.056 + FACE_T / 2.0 + metal.extend( + add_box( + bm, + (0.0, 0.0, face_z), + (FACE_L, FACE_W, FACE_T), + METAL_IDX, + ) + ) + + horn_z = face_z - 0.018 + metal.extend( + add_cone( + bm, + (FACE_L / 2.0 + HORN_L / 2.0 - 0.02, 0.0, horn_z), + 0.052, + 0.016, + HORN_L, + 12, + METAL_IDX, + euler=(0.0, math.pi / 2.0, 0.0), + ) + ) + metal.extend( + add_box( + bm, + (FACE_L / 2.0 - 0.01, 0.0, horn_z - 0.028), + (0.09, FACE_W * 0.92, 0.040), + METAL_IDX, + ) + ) + metal.extend( + add_box( + bm, + (-FACE_L / 2.0 - HEEL_L / 2.0 + 0.02, 0.0, face_z - 0.008), + (HEEL_L, FACE_W * 0.92, FACE_T * 0.82), + METAL_IDX, + ) + ) + metal.extend( + add_box( + bm, + (FACE_L * 0.22, 0.0, face_z + FACE_T / 2.0 - 0.006), + (0.034, 0.034, 0.014), + METAL_IDX, + ) + ) + metal.extend( + add_cyl( + bm, + (FACE_L * 0.32, 0.0, face_z + FACE_T / 2.0 - 0.004), + 0.009, + 0.012, + 8, + METAL_IDX, + ) + ) + + xs = [v.co.x for v in bm.verts] + ys = [v.co.y for v in bm.verts] + zs = [v.co.z for v in bm.verts] + rcx = 0.5 * (min(xs) + max(xs)) + rcy = 0.5 * (min(ys) + max(ys)) + zmin = min(zs) + for v in bm.verts: + v.co.x -= rcx + v.co.y -= rcy + v.co.z -= zmin + if v.co.z < 0.0: + v.co.z = 0.0 + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = False + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + for poly in me.polygons: + poly.use_smooth = False + finally: + bm.free() + out = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(out) + return out + + +def principled(name, color, metallic, roughness): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = color + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + return mat + + +def assign_slots(obj, wood, metal): + mats = obj.data.materials + wanted = (wood, metal) + for i, mat in enumerate(wanted): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0, 1.0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + overlap = 0.0 + for i in range(len(aabbs)): + a = aabbs[i] + for j in range(i + 1, len(aabbs)): + b = aabbs[j] + x0 = max(a[0], b[0]) + y0 = max(a[1], b[1]) + x1 = min(a[2], b[2]) + y1 = min(a[3], b[3]) + overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def convex_hull_collider(obj, name): + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) + interior = result.get("geom_interior") or [] + unused = result.get("geom_unused") or [] + if interior: + bmesh.ops.delete(bm, geom=interior, context="VERTS") + if unused: + bmesh.ops.delete(bm, geom=unused, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + collider = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +def setup_bake_image(obj, target_mat, size=BAKE_RES): + if not obj.data.uv_layers: + return None, None + img = bpy.data.images.new("AnvilNrm", size, size, alpha=True, float_buffer=False) + img.colorspace_settings.name = "Non-Color" + nodes = target_mat.node_tree.nodes + tex = nodes.new("ShaderNodeTexImage") + tex.image = img + nodes.active = tex + tex.select = True + obj.active_material_index = WOOD_IDX + return img, tex + + +def bake_normal(high, low): + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + deselect_all() + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + return bpy.ops.object.bake( + type="NORMAL", + use_selected_to_active=True, + cage_extrusion=CAGE_EXTRUSION, + use_cage=False, + normal_space="TANGENT", + margin=4, + margin_type="ADJACENT_FACES", + use_clear=True, + target="IMAGE_TEXTURES", + ) + + +def export_unity(path, objects): + deselect_all() + for ob in objects: + ob.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=False, + export_animations=False, + ) + + +def check(skip_decimate): + bpy.ops.wm.read_factory_settings(use_empty=True) + low = build_anvil_mesh("AnvilLow", bevel_offset=0.005, bevel_segments=2) + high = build_anvil_mesh("AnvilHigh", bevel_offset=0.005, bevel_segments=4) + wood = principled("AnvilStump", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62) + metal = principled("AnvilSteel", (0.18, 0.185, 0.195, 1.0), 1.0, 0.24) + assign_slots(low, wood, metal) + assign_slots(high, wood, metal) + + if low.data is None or len(low.data.polygons) < 6: + return fail("anvil 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("anvil has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "AnvilLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "AnvilLOD2", 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_anvil_mesh("AnvilColSrc", bevel_offset=0.0, bevel_segments=1) + collider = convex_hull_collider(collider_src, "AnvilCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_anvil_{os.getpid()}.glb", + ) + if os.path.exists(export_path): + os.remove(export_path) + export_unity(export_path, [low, collider]) + export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") + print( + f"measured base_tris={base_tris} lod1_tris={lod1_tris} " + f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" + ) + print( + f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " + f"overlap={overlap:.6f} nfaces={nfaces}" + ) + print( + f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"outer={OUTER_SIZE} zmin={bb[2]:.4f}" + ) + print( + f"measured collider_tris={col_tris} bake={bake_result} " + f"bake_has_data={img.has_data} export_bytes={export_size}" + ) + + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): + return fail( + f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", + 4, + ), None, None, None, None, None + if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT: + return fail( + f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", + 5, + ), None, None, None, None, None + if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN: + return fail( + f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}", + 5, + ), None, None, None, None, None + if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN: + return fail( + f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}", + 5, + ), None, None, None, None, None + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return fail( + f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", + 6, + ), None, None, None, None, None + if overlap > UV_OVERLAP_MAX: + return fail( + f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", + 7, + ), None, None, None, None, None + if ( + abs(size_x - OUTER_SIZE[0]) > BBOX_TOL + or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL + ): + return fail( + f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"off outer {OUTER_SIZE}", + 8, + ), None, None, None, None, None + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return fail( + f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", + 9, + ), None, None, None, None, None + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return fail( + f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", + 9, + ), None, None, None, None, None + if col_tris > COLLIDER_TRIS_MAX: + return fail( + f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", + 11, + ), None, None, None, None, None + if bake_result != {"FINISHED"} or not img.has_data: + return fail( + f"bake failed result={bake_result} has_data={img.has_data}", + 12, + ), None, None, None, None, None + if export_size <= 0: + return fail("export file missing or empty", 13), None, None, None, None, None + return 0, low, high, wood, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + nrm = nt.nodes.new("ShaderNodeNormalMap") + nrm.inputs["Strength"].default_value = 1.0 + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) + + +def render_still(low, wood, tex, path, engine): + scene = bpy.context.scene + wire_normal(wood, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(-22.0) + low.rotation_euler.x = math.radians(0.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 8.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-3.6, -5.0, 5.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) + light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) + light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (1.36, -1.68, 0.93) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.36) + 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("anvil OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) diff --git a/showcase/anvil/preview.webp b/showcase/anvil/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..883d6b7ab68ed34a34fdb43760611b03fccc5ace GIT binary patch literal 8332 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 WF4A7eq^!OCo9Ggne4FS+zyJU`5Exhh literal 0 HcmV?d00001 diff --git a/showcase/gallery.json b/showcase/gallery.json index b585356..9a13cbf 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -160,6 +160,18 @@ "mesh", "export" ] + }, + { + "name": "anvil", + "dir": "showcase/anvil", + "teaches": "A procedural blacksmith anvil on a timber stump through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 1052 tris, two materials with 102 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.750×0.406×0.690 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 134 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/anvil-hero.webp", + "preview": "showcase/anvil/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index 5e08356..f1c5387 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -86,5 +86,6 @@ {"name": "watchtower", "script": "showcase/watchtower/watchtower.py"}, {"name": "cart", "script": "showcase/cart/cart.py"}, {"name": "park-bench", "script": "showcase/park-bench/park_bench.py"}, - {"name": "wheelbarrow", "script": "showcase/wheelbarrow/wheelbarrow.py"} + {"name": "wheelbarrow", "script": "showcase/wheelbarrow/wheelbarrow.py"}, + {"name": "anvil", "script": "showcase/anvil/anvil.py"} ] From d44f406bdda213e09493275f59251d5afd958daa Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:54:23 -0400 Subject: [PATCH 2/2] feat: add water-trough showcase piece Staved U-trough on trestle legs with iron straps through the shipped budget pipeline. --skip-decimate exits 9 on the LOD1 ratio band. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 3 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 7 +- ROADMAP.md | 4 +- docs/gallery/assets/water-trough-hero.webp | Bin 0 -> 26592 bytes docs/gallery/index.html | 13 +- docs/gallery/water-trough/index.html | 1091 ++++++++++++++++++++ showcase/gallery.json | 12 + showcase/water-trough/README.md | 74 ++ showcase/water-trough/preview.webp | Bin 0 -> 22166 bytes showcase/water-trough/water_trough.py | 767 ++++++++++++++ tests/smoke/catalog.json | 3 +- 13 files changed, 1970 insertions(+), 8 deletions(-) create mode 100644 docs/gallery/assets/water-trough-hero.webp create mode 100644 docs/gallery/water-trough/index.html create mode 100644 showcase/water-trough/README.md create mode 100644 showcase/water-trough/preview.webp create mode 100644 showcase/water-trough/water_trough.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 853716e..95710fd 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -151,6 +151,7 @@ "showcase/cart", "showcase/park-bench", "showcase/wheelbarrow", - "showcase/anvil" + "showcase/anvil", + "showcase/water-trough" ] } diff --git a/AGENTS.md b/AGENTS.md index af788ce..6ce0c0e 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 14 showcase pieces (counts are CI-enforced against README.md) +examples, and 15 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 5689c15..02cc13c 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, 14 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 15 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 3dadd54..fdceebd 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  14 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  15 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 14 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 15 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. 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. @@ -89,6 +89,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Park bench: a wrought-iron bench with scrolled legs and wooden slats on a dark studio floor Wheelbarrow: a staved wooden tray with iron straps, a spoked wheel, rear legs, and handles on a dark studio floor Anvil: a London-pattern anvil on a hooped timber stump on a dark studio floor +Water trough: a staved wooden trough on trestle legs with iron straps 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. @@ -118,6 +119,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`anvil`](showcase/anvil/) — procedural London-pattern anvil on a timber stump through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`water-trough`](showcase/water-trough/) — procedural staved water trough on a timber stand 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 edde7bd..3c9a41f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -118,7 +118,9 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Wrought-iron park bench (slatted seat, scrolled legs) as a game-prop showcase piece~~ **SHIPPED** as `showcase/park-bench/` — scrolled iron legs, slatted seat and back; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Wooden wheelbarrow (staved tray, single wheel, handles) as a game-prop showcase piece~~ **SHIPPED** as `showcase/wheelbarrow/` — staved U-tray, iron straps, spoked wheel, rear legs; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Blacksmith anvil on a timber stump as a game-prop showcase piece~~ **SHIPPED** as `showcase/anvil/` — London-pattern horn, face, heel, waist, hooped stump; `--skip-decimate` exits 9 on the LOD1 ratio band -- Staved wooden water trough on a stand as a game-prop showcase piece +- ~~Staved wooden water trough on a stand as a game-prop showcase piece~~ **SHIPPED** as `showcase/water-trough/` — U-staves, end boards, iron straps, trestle legs; `--skip-decimate` exits 9 on the LOD1 ratio band +- Hitching post with iron ring as a game-prop showcase piece +- Grindstone on a timber trestle as a game-prop showcase piece - ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **SHIPPED** as `showcase/fence-kit/` — tiling post-and-rail section (AABB X is the tile width; does not re-witness the snap contract); `--skip-decimate` exits 9 on the LOD1 ratio band - ~~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/water-trough-hero.webp b/docs/gallery/assets/water-trough-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..58a2d57d1e0de31fcf842cca93211250ebd1e7f0 GIT binary patch literal 26592 zcmV(oH;NqXu8H4+O zdN21swVv#MF#frFc>0+CqwCB1_x=A;ztMl}`rCe3{1yLS_5tz#>GkVl=_}_k>j~wh z|NsB~zUQm_`+SS|pV$v5{~`O&+yA((68=NL&*uL0y-e@F^FQN#0zW7Iqv%r{ClFyD zh~Lrr8uX*W9)R9FI&yov*dRh;tM1T8>0&kyEM5KD2>mQZ!R3p;yFnkNh}bW6V$Qzj zF8pyRSmf;z;|xk$XlLR)hjuORC97|O)n9YII`U1cb3)Z2kXI=wHE-_Cr|DufX)Qrf zp0(!EtGrL$(c@rJLYm5E#Q~FL_VW#Azm2yZf{d9?jSc z^MKw{&FNw`4=h{F+6f7fwg7BhRjYl-1?B-JkPszaDoKhl*|E5^Vq&AkKI$|I(jwOK zcWT^cx1tuNo3m;9KrIm}BhVqULeDX(IHWItpn@asu^P2o3wuvnE-g|cKS%kncWFiB z^BSudV6{zJMLS*DF2I(e_D-m#MZULd-_q%4n(l?7&Tco^`=gXRRtjLcfJ@A*K z6z~{SfBi$j?o5Hdu;*7~qr}lR|GZU1{Oq(7n5wB>*Wp=S+nd zviC|AI@&vpcZE^OFG6Td_uCJUI!x2eh=Nu?lNowLF|ukCyCKj8Rjx{I)M1VW?9yG7 z-BYB%g~dnrCM|N%u-cQE9bW-5k9nLAm&b6csyfh zFri<>$&S=i5t$Mpgi>9gJK7+?^BRZz$ENEg59atcv(DYrvSVbJq{ULg2xg?wmOD%n zWE&yW^1kLQg;r*|&Lht*xcazl$arX=pZ7VYA?*z~>@Yir;(jbBGpiFYmMWU@@ zF4T-qr&2l{H*Bfq`+cH{v~E~sB%e@elJ7Y|MAyGIx(4`5vRfP|(;oA-U>&uAxq+co zbVFW!O-Zm&4C;Px2`$pP?vE4C8whggR0Tc74G8X1P1c;1Z#4}oYzP7CfaaS0^-lei*flcvqxV!muKgT zXyzIDa}FvQAAHIF!O!O3cYZ=8qm36d!QCYFqdtEZQJwQS42XD5z4dq9)$jhfH4@iX z>L2LxmaxReYB6#BIA0p{7olTP;p(DDs@7>-wJ1QZN|9Y*=Pnxs(Q}@NnRo*7;gAQ+{jw_8>)Kl7c;^^5fyr@|k{0sb>r9-f>`Y!Ur1YGGW(LiL;Sr@+ zDdW{c&mSE$yyV)FPnM0L`3#||On@9C=C-#S-x5dKW&t*A{(Y)APjKaC6nNgb?cX)O z0-D@XsQ|jT3J$059u{H9UmyqcaU*mIqgP5p*|bs7y4**b6!;3EM-U9%SM!#`!@i3C zf$Ru>dhM**%NZ$4|5EbhW=ja!ucRWUv@PmjhisByDyu*&sts6XN=RFkVWY-Yg~!3! z%6^g^S>`}MzCoK83XG*ri2(H^rk85c8xb0!AOR&^2yF@&at+6qBqj;!4X|#r6j;Rk zs3T}AIdD@^<_*9pv`cSj&muWHsVUJKI2L!tIU_OGtr3#gneG+`tPt!~VZ*j1ih8eb zN?2>wrzJ43(oknrH_v47HTnNrZTUK#??ffL%FBpOs)Y_skFh8>{kR-w4x|b%uvteK zQ#xfybpaffXLEQBsvgU%hwC&fD$`#Cu>7|2jJ;NbMqQPLDnQy-nP6T9|A60EaJc3M zhMH(o0klXKM2JLAr}YZ9%0^Km2z{8Sa$SVc4wY}U4}KAYU7 zM_!GndAo;DKqpn^4Nu%=c=Y#Jf^+sh_TJk)v?F?!wv!`SY(+M-L+gi<@@8}00+iX- z;?aaT3W{vMKX`3qxbERSAds@&rvq9>?10LyRi+A~_`BCL$=hD(<5TU?DCeU#a?h ziX#Z$#m3F?ItLQ1SkHajGS?vhg!_3Z-go}{6odBeNrl|jI>pQxZ)+%NlOxLa%lJSp z;f{QSG9!KPI>H`dvt~ude7G0s@3FG;PZj8Xev4zx?BV5Y!r3N~$FF(FcZ3%bJQ+Xk z-zSTulYs^fz-k53=AGsBusU%yiIrBSbmys}0fm@y+THrsiKhD(8dlpYD4C!<<^7*Q zB9x(X13I4-1Bz(v$s#kzB)PNqb;)nJZWZW3eoT)}7T%j6n`o>67VMcwIspp`p+k04 z!KxYPcTh!oTE(O@C;W^MUJEaOZ{HD`jHh6-ryCNxiXaI36x#(F}$^4@yn(jg#GRZ<>Q`&HJ zXjRWSpgeWD+rda-z)BWnKOZoM>h!c#AKhA*M{o3fiUw{dB*2BxTISWq*Z4qwNbR`+ zCu^yQ!|#b$aljC%wo@^T8?p&yMQzHmP5hr_H&5temMpKQ&$(AGHe%0DI@C^ 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index b79b342..db1eb35 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" /> - 65 examples + 66 examples
@@ -1039,6 +1039,17 @@

anvil

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

water-trough

+

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

+

witnesses Recomputed: 4008 tris, two materials with 168 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.136×0.482×0.470 m, LOD ratios in band, convex collider 108 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

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

water-trough

+

A procedural staved water trough 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: 4008 tris, two materials with 168 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.136×0.482×0.470 m, LOD ratios in band, convex collider 108 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/water-trough/water_trough.py --
+ +
+
+

A showcase piece, not an example. Procedural staved water trough on a timber stand (U-staves, end boards, iron straps, trestle legs) 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 | 3880–4140 | 4008 / 4008 / 4008 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2196 | | Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 168 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.136, 0.482, 0.470) m ± 0.01 | (1.1360, 0.4820, 0.4701), zmin 0 | | Collider tris | ≤ 150 | 108 | | Export | written, size > 0 | 292484 / 292484 / 292468 bytes |

+

DECIMATE COLLAPSE triangle counts are not identical across series in general — the gate is a ratio band, not an exact count. On this piece LOD2 happened to match on 4.5.11, 5.1.2, and 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a gated axis.

+

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

+

Run

+
blender --background --python water_trough.py --
+blender --background --python water_trough.py -- --skip-decimate
+blender --background --python water_trough.py -- --output trough.png
+

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

+

Exit codes

+

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

+

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

+
+
+

Source

+
+ showcase/water-trough/water_trough.py + View on GitHub → +
+
"""Game-ready wooden water trough — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural staved trough on a timber
+stand (U-staves, end boards, iron straps, trestle legs) 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 water_trough.py --
+    blender --background --python water_trough.py -- --skip-decimate
+    blender --background --python water_trough.py -- --output trough.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+TRAY_L = 1.08
+TRAY_R = 0.20
+STAVE_T = 0.026
+N_STAVES = 9
+A_SPAN = 1.42
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (1.136, 0.482, 0.470)
+BASE_TRIS_MIN = 3880
+BASE_TRIS_MAX = 4140
+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 = 150
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 24
+WOOD_FACES_MIN = 24
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    depsgraph = bpy.context.evaluated_depsgraph_get()
+    eval_obj = obj.evaluated_get(depsgraph)
+    eval_mesh = eval_obj.to_mesh()
+    try:
+        eval_mesh.calc_loop_triangles()
+        return len(eval_mesh.loop_triangles)
+    finally:
+        eval_obj.to_mesh_clear()
+
+
+def deselect_all():
+    for ob in list(bpy.context.view_layer.objects):
+        if ob is None:
+            continue
+        ob.select_set(False)
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+        v.co = rot @ p + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_oriented_box(bm, a, b, scale_xy, mat_idx):
+    a = Vector(a)
+    b = Vector(b)
+    delta = b - a
+    length = delta.length
+    if length < 1e-8:
+        return []
+    quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+    eul = quat.to_euler("XYZ")
+    return add_box(
+        bm,
+        ((a + b) * 0.5),
+        (scale_xy[0], scale_xy[1], length),
+        mat_idx,
+        euler=(eul.x, eul.y, eul.z),
+    )
+
+
+def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius,
+        radius2=radius,
+        depth=depth,
+    )
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_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_trough_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    try:
+        wood = []
+        metal = []
+        zc = 0.30 + TRAY_R
+        cx = 0.0
+
+        def pt(a, x):
+            return (
+                x,
+                TRAY_R * math.sin(a),
+                zc - TRAY_R * math.cos(a),
+            )
+
+        gap = 0.10
+        for i in range(N_STAVES):
+            t0 = i / N_STAVES
+            t1 = (i + (1.0 - gap)) / N_STAVES
+            a0 = -A_SPAN + 2.0 * A_SPAN * t0
+            a1 = -A_SPAN + 2.0 * A_SPAN * t1
+            mid = 0.5 * (a0 + a1)
+            chord = TRAY_R * abs(a1 - a0) * 0.95
+            wood.extend(
+                add_box(
+                    bm,
+                    pt(mid, cx),
+                    (TRAY_L, chord, STAVE_T),
+                    WOOD_IDX,
+                    euler=(mid, 0.0, 0.0),
+                )
+            )
+            for xend in (cx + TRAY_L / 2.0 + 0.014, cx - TRAY_L / 2.0 - 0.014):
+                wood.extend(
+                    add_box(
+                        bm,
+                        pt(mid, xend),
+                        (0.028, chord * 1.02, STAVE_T * 1.05),
+                        WOOD_IDX,
+                        euler=(mid, 0.0, 0.0),
+                    )
+                )
+
+        for sx in (-TRAY_L * 0.32, TRAY_L * 0.32):
+            for ysign in (-1.0, 1.0):
+                top = (sx, ysign * 0.12, zc - TRAY_R - 0.02)
+                bot = (sx, ysign * 0.22, 0.02)
+                wood.extend(
+                    add_oriented_box(bm, top, bot, (0.036, 0.036), WOOD_IDX)
+                )
+            wood.extend(
+                add_oriented_box(
+                    bm,
+                    (sx, -0.16, 0.13),
+                    (sx, 0.16, 0.13),
+                    (0.028, 0.028),
+                    WOOD_IDX,
+                )
+            )
+        wood.extend(
+            add_oriented_box(
+                bm,
+                (-TRAY_L * 0.32, 0.0, 0.13),
+                (TRAY_L * 0.32, 0.0, 0.13),
+                (0.028, 0.028),
+                WOOD_IDX,
+            )
+        )
+
+        if bevel_offset > 0.0:
+            edges = list({e for v in wood for e in v.link_edges})
+            ret = bmesh.ops.bevel(
+                bm,
+                geom=edges,
+                offset=bevel_offset,
+                segments=bevel_segments,
+                profile=0.5,
+                affect="EDGES",
+                clamp_overlap=True,
+            )
+            for f in ret.get("faces") or []:
+                f.material_index = WOOD_IDX
+
+        r_strap = TRAY_R + STAVE_T * 0.55 + 0.004
+        for sx in (-TRAY_L * 0.28, TRAY_L * 0.28):
+            n = 12
+            pts = []
+            for k in range(n + 1):
+                a = -A_SPAN + 2.0 * A_SPAN * k / n
+                pts.append(
+                    (
+                        cx + sx,
+                        r_strap * math.sin(a),
+                        zc - r_strap * math.cos(a),
+                    )
+                )
+            for p0, p1 in zip(pts, pts[1:]):
+                metal.extend(
+                    add_oriented_box(bm, p0, p1, (0.020, 0.010), METAL_IDX)
+                )
+        for sx in (-TRAY_L * 0.32, TRAY_L * 0.32):
+            for ysign in (-1.0, 1.0):
+                metal.extend(
+                    add_box(
+                        bm,
+                        (sx, ysign * 0.22, 0.012),
+                        (0.050, 0.042, 0.022),
+                        METAL_IDX,
+                    )
+                )
+
+        xs = [v.co.x for v in bm.verts]
+        ys = [v.co.y for v in bm.verts]
+        zs = [v.co.z for v in bm.verts]
+        rcx = 0.5 * (min(xs) + max(xs))
+        rcy = 0.5 * (min(ys) + max(ys))
+        zmin = min(zs)
+        for v in bm.verts:
+            v.co.x -= rcx
+            v.co.y -= rcy
+            v.co.z -= zmin
+            if v.co.z < 0.0:
+                v.co.z = 0.0
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = False
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+        for poly in me.polygons:
+            poly.use_smooth = False
+    finally:
+        bm.free()
+    out = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(out)
+    return out
+
+
+def principled(name, color, metallic, roughness):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def assign_slots(obj, wood, metal):
+    mats = obj.data.materials
+    wanted = (wood, metal)
+    for i, mat in enumerate(wanted):
+        if i < len(mats):
+            mats[i] = mat
+        else:
+            mats.append(mat)
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    overlap = 0.0
+    for i in range(len(aabbs)):
+        a = aabbs[i]
+        for j in range(i + 1, len(aabbs)):
+            b = aabbs[j]
+            x0 = max(a[0], b[0])
+            y0 = max(a[1], b[1])
+            x1 = min(a[2], b[2])
+            y1 = min(a[3], b[3])
+            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("TroughNrm", size, size, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    nodes = target_mat.node_tree.nodes
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = img
+    nodes.active = tex
+    tex.select = True
+    obj.active_material_index = WOOD_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    deselect_all()
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL",
+        use_selected_to_active=True,
+        cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False,
+        normal_space="TANGENT",
+        margin=4,
+        margin_type="ADJACENT_FACES",
+        use_clear=True,
+        target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    deselect_all()
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path,
+        use_selection=True,
+        export_yup=True,
+        export_apply=True,
+        export_draco_mesh_compression_enable=False,
+        export_animations=False,
+    )
+
+
+def check(skip_decimate):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    low = build_trough_mesh("TroughLow", bevel_offset=0.005, bevel_segments=2)
+    high = build_trough_mesh("TroughHigh", bevel_offset=0.005, bevel_segments=4)
+    wood = principled("TroughWood", (0.40, 0.22, 0.08, 1.0), 0.0, 0.58)
+    metal = principled("TroughIron", (0.10, 0.105, 0.12, 1.0), 1.0, 0.30)
+    assign_slots(low, wood, metal)
+    assign_slots(high, wood, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("trough 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("trough has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "TroughLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "TroughLOD2", 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_trough_mesh("TroughColSrc", bevel_offset=0.0, bevel_segments=1)
+    collider = convex_hull_collider(collider_src, "TroughCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_water_trough_{os.getpid()}.glb",
+    )
+    if os.path.exists(export_path):
+        os.remove(export_path)
+    export_unity(export_path, [low, collider])
+    export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+    print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
+    print(
+        f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+        f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+    )
+    print(
+        f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+        f"overlap={overlap:.6f} nfaces={nfaces}"
+    )
+    print(
+        f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+        f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
+    )
+    print(
+        f"measured collider_tris={col_tris} bake={bake_result} "
+        f"bake_has_data={img.has_data} export_bytes={export_size}"
+    )
+
+    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+        return fail(
+            f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
+            4,
+        ), None, None, None, None, None
+    if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+        return fail(
+            f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+        return fail(
+            f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN:
+        return fail(
+            f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+            6,
+        ), None, None, None, None, None
+    if overlap > UV_OVERLAP_MAX:
+        return fail(
+            f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+            7,
+        ), None, None, None, None, None
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+            f"off outer {OUTER_SIZE}",
+            8,
+        ), None, None, None, None, None
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)",
+            9,
+        ), None, None, None, None, None
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+            9,
+        ), None, None, None, None, None
+    if col_tris > COLLIDER_TRIS_MAX:
+        return fail(
+            f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+            11,
+        ), None, None, None, None, None
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return fail(
+            f"bake failed result={bake_result} has_data={img.has_data}",
+            12,
+        ), None, None, None, None, None
+    if export_size <= 0:
+        return fail("export file missing or empty", 13), None, None, None, None, None
+    return 0, low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, wood, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(wood, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-16.0)
+    low.rotation_euler.x = math.radians(0.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.6, -5.0, 5.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+    light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+    light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (1.43, -1.73, 0.88)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.26)
+    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("water-trough 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 9a13cbf..4049d69 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -172,6 +172,18 @@ "mesh", "export" ] + }, + { + "name": "water-trough", + "dir": "showcase/water-trough", + "teaches": "A procedural staved water trough through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 4008 tris, two materials with 168 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.136×0.482×0.470 m, LOD ratios in band, convex collider 108 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/water-trough-hero.webp", + "preview": "showcase/water-trough/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/water-trough/README.md b/showcase/water-trough/README.md new file mode 100644 index 0000000..9611149 --- /dev/null +++ b/showcase/water-trough/README.md @@ -0,0 +1,74 @@ +# Water trough + +A showcase piece, not an example. Procedural staved water trough on a +timber stand (U-staves, end boards, iron straps, trestle legs) 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 | 3880–4140 | 4008 / 4008 / 4008 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2196 | +| Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 168 metal | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (1.136, 0.482, 0.470) m ± 0.01 | (1.1360, 0.4820, 0.4701), zmin 0 | +| Collider tris | ≤ 150 | 108 | +| Export | written, size > 0 | 292484 / 292484 / 292468 bytes | + +DECIMATE COLLAPSE triangle counts are **not** identical across series +in general — the gate is a ratio band, not an exact count. On this +piece LOD2 happened to match on 4.5.11, 5.1.2, and 5.2.1. Bake pixels +are stochastic; the gate is `has_data` plus operator `FINISHED`, not +byte-identity. Construction uses no RNG. Export byte counts differ by +16 B on 5.2.1 (glTF serializer), not a gated axis. + +`--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and +exit 9 fires. That is the named budget the falsifier violates. + +## Run + +```bash +blender --background --python water_trough.py -- +blender --background --python water_trough.py -- --skip-decimate +blender --background --python water_trough.py -- --output trough.png +``` + +Smoke does not pass `--output` or `--skip-decimate`. + +## Exit codes + +File-local. `9` is a valid check code. `10` is reserved for +`gallery_framing.check_framing` on the `--output` path. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build / no UV layer | +| 4 | Base triangle count outside range | +| 5 | Material count ≠ 2 distinct slots, or wood/metal faces missing | +| 6 | UVs outside 0..1 | +| 7 | UV AABB overlap above tolerance | +| 8 | World AABB off declared outer size | +| 9 | LOD ratio band (`--skip-decimate` lands here) | +| 10 | Framing gate (render path only) | +| 11 | Collider triangle count above ceiling | +| 12 | Bake did not finish or image has no data | +| 13 | Export file missing or empty | +| 14 | `--output` produced no file | diff --git a/showcase/water-trough/preview.webp b/showcase/water-trough/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..00ba9f164b26a200a05fa7be6f0d1a856670c2a3 GIT binary patch literal 22166 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 literal 0 HcmV?d00001 diff --git a/showcase/water-trough/water_trough.py b/showcase/water-trough/water_trough.py new file mode 100644 index 0000000..e1bdb8a --- /dev/null +++ b/showcase/water-trough/water_trough.py @@ -0,0 +1,767 @@ +"""Game-ready wooden water trough — a showcase piece, not an example. + +Asserts budget conformance of a procedural staved trough on a timber +stand (U-staves, end boards, iron straps, trestle legs) 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 water_trough.py -- + blender --background --python water_trough.py -- --skip-decimate + blender --background --python water_trough.py -- --output trough.png +""" +import argparse +import math +import os +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Euler, Vector + +_REPO = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) +) +sys.path.insert(0, os.path.join(_REPO, "examples")) +sys.dont_write_bytecode = True +import gallery_framing # noqa: E402 + +TRAY_L = 1.08 +TRAY_R = 0.20 +STAVE_T = 0.026 +N_STAVES = 9 +A_SPAN = 1.42 + +BBOX_TOL = 0.01 +OUTER_SIZE = (1.136, 0.482, 0.470) +BASE_TRIS_MIN = 3880 +BASE_TRIS_MAX = 4140 +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 = 150 +BAKE_RES = 256 +CAGE_EXTRUSION = 0.08 +METAL_FACES_MIN = 24 +WOOD_FACES_MIN = 24 + +WOOD_IDX = 0 +METAL_IDX = 1 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def deselect_all(): + for ob in list(bpy.context.view_layer.objects): + if ob is None: + continue + ob.select_set(False) + + +def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) + v.co = rot @ p + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_oriented_box(bm, a, b, scale_xy, mat_idx): + a = Vector(a) + b = Vector(b) + delta = b - a + length = delta.length + if length < 1e-8: + return [] + quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) + eul = quat.to_euler("XYZ") + return add_box( + bm, + ((a + b) * 0.5), + (scale_xy[0], scale_xy[1], length), + mat_idx, + euler=(eul.x, eul.y, eul.z), + ) + + +def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius, + radius2=radius, + depth=depth, + ) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_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_trough_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + try: + wood = [] + metal = [] + zc = 0.30 + TRAY_R + cx = 0.0 + + def pt(a, x): + return ( + x, + TRAY_R * math.sin(a), + zc - TRAY_R * math.cos(a), + ) + + gap = 0.10 + for i in range(N_STAVES): + t0 = i / N_STAVES + t1 = (i + (1.0 - gap)) / N_STAVES + a0 = -A_SPAN + 2.0 * A_SPAN * t0 + a1 = -A_SPAN + 2.0 * A_SPAN * t1 + mid = 0.5 * (a0 + a1) + chord = TRAY_R * abs(a1 - a0) * 0.95 + wood.extend( + add_box( + bm, + pt(mid, cx), + (TRAY_L, chord, STAVE_T), + WOOD_IDX, + euler=(mid, 0.0, 0.0), + ) + ) + for xend in (cx + TRAY_L / 2.0 + 0.014, cx - TRAY_L / 2.0 - 0.014): + wood.extend( + add_box( + bm, + pt(mid, xend), + (0.028, chord * 1.02, STAVE_T * 1.05), + WOOD_IDX, + euler=(mid, 0.0, 0.0), + ) + ) + + for sx in (-TRAY_L * 0.32, TRAY_L * 0.32): + for ysign in (-1.0, 1.0): + top = (sx, ysign * 0.12, zc - TRAY_R - 0.02) + bot = (sx, ysign * 0.22, 0.02) + wood.extend( + add_oriented_box(bm, top, bot, (0.036, 0.036), WOOD_IDX) + ) + wood.extend( + add_oriented_box( + bm, + (sx, -0.16, 0.13), + (sx, 0.16, 0.13), + (0.028, 0.028), + WOOD_IDX, + ) + ) + wood.extend( + add_oriented_box( + bm, + (-TRAY_L * 0.32, 0.0, 0.13), + (TRAY_L * 0.32, 0.0, 0.13), + (0.028, 0.028), + WOOD_IDX, + ) + ) + + if bevel_offset > 0.0: + edges = list({e for v in wood for e in v.link_edges}) + ret = bmesh.ops.bevel( + bm, + geom=edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + for f in ret.get("faces") or []: + f.material_index = WOOD_IDX + + r_strap = TRAY_R + STAVE_T * 0.55 + 0.004 + for sx in (-TRAY_L * 0.28, TRAY_L * 0.28): + n = 12 + pts = [] + for k in range(n + 1): + a = -A_SPAN + 2.0 * A_SPAN * k / n + pts.append( + ( + cx + sx, + r_strap * math.sin(a), + zc - r_strap * math.cos(a), + ) + ) + for p0, p1 in zip(pts, pts[1:]): + metal.extend( + add_oriented_box(bm, p0, p1, (0.020, 0.010), METAL_IDX) + ) + for sx in (-TRAY_L * 0.32, TRAY_L * 0.32): + for ysign in (-1.0, 1.0): + metal.extend( + add_box( + bm, + (sx, ysign * 0.22, 0.012), + (0.050, 0.042, 0.022), + METAL_IDX, + ) + ) + + xs = [v.co.x for v in bm.verts] + ys = [v.co.y for v in bm.verts] + zs = [v.co.z for v in bm.verts] + rcx = 0.5 * (min(xs) + max(xs)) + rcy = 0.5 * (min(ys) + max(ys)) + zmin = min(zs) + for v in bm.verts: + v.co.x -= rcx + v.co.y -= rcy + v.co.z -= zmin + if v.co.z < 0.0: + v.co.z = 0.0 + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = False + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + for poly in me.polygons: + poly.use_smooth = False + finally: + bm.free() + out = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(out) + return out + + +def principled(name, color, metallic, roughness): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = color + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + return mat + + +def assign_slots(obj, wood, metal): + mats = obj.data.materials + wanted = (wood, metal) + for i, mat in enumerate(wanted): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0, 1.0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + overlap = 0.0 + for i in range(len(aabbs)): + a = aabbs[i] + for j in range(i + 1, len(aabbs)): + b = aabbs[j] + x0 = max(a[0], b[0]) + y0 = max(a[1], b[1]) + x1 = min(a[2], b[2]) + y1 = min(a[3], b[3]) + overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def convex_hull_collider(obj, name): + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) + interior = result.get("geom_interior") or [] + unused = result.get("geom_unused") or [] + if interior: + bmesh.ops.delete(bm, geom=interior, context="VERTS") + if unused: + bmesh.ops.delete(bm, geom=unused, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + collider = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +def setup_bake_image(obj, target_mat, size=BAKE_RES): + if not obj.data.uv_layers: + return None, None + img = bpy.data.images.new("TroughNrm", size, size, alpha=True, float_buffer=False) + img.colorspace_settings.name = "Non-Color" + nodes = target_mat.node_tree.nodes + tex = nodes.new("ShaderNodeTexImage") + tex.image = img + nodes.active = tex + tex.select = True + obj.active_material_index = WOOD_IDX + return img, tex + + +def bake_normal(high, low): + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + deselect_all() + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + return bpy.ops.object.bake( + type="NORMAL", + use_selected_to_active=True, + cage_extrusion=CAGE_EXTRUSION, + use_cage=False, + normal_space="TANGENT", + margin=4, + margin_type="ADJACENT_FACES", + use_clear=True, + target="IMAGE_TEXTURES", + ) + + +def export_unity(path, objects): + deselect_all() + for ob in objects: + ob.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=False, + export_animations=False, + ) + + +def check(skip_decimate): + bpy.ops.wm.read_factory_settings(use_empty=True) + low = build_trough_mesh("TroughLow", bevel_offset=0.005, bevel_segments=2) + high = build_trough_mesh("TroughHigh", bevel_offset=0.005, bevel_segments=4) + wood = principled("TroughWood", (0.40, 0.22, 0.08, 1.0), 0.0, 0.58) + metal = principled("TroughIron", (0.10, 0.105, 0.12, 1.0), 1.0, 0.30) + assign_slots(low, wood, metal) + assign_slots(high, wood, metal) + + if low.data is None or len(low.data.polygons) < 6: + return fail("trough 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("trough has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "TroughLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "TroughLOD2", 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_trough_mesh("TroughColSrc", bevel_offset=0.0, bevel_segments=1) + collider = convex_hull_collider(collider_src, "TroughCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_water_trough_{os.getpid()}.glb", + ) + if os.path.exists(export_path): + os.remove(export_path) + export_unity(export_path, [low, collider]) + export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") + print( + f"measured base_tris={base_tris} lod1_tris={lod1_tris} " + f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" + ) + print( + f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " + f"overlap={overlap:.6f} nfaces={nfaces}" + ) + print( + f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"outer={OUTER_SIZE} zmin={bb[2]:.4f}" + ) + print( + f"measured collider_tris={col_tris} bake={bake_result} " + f"bake_has_data={img.has_data} export_bytes={export_size}" + ) + + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): + return fail( + f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", + 4, + ), None, None, None, None, None + if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT: + return fail( + f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", + 5, + ), None, None, None, None, None + if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN: + return fail( + f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}", + 5, + ), None, None, None, None, None + if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN: + return fail( + f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}", + 5, + ), None, None, None, None, None + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return fail( + f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", + 6, + ), None, None, None, None, None + if overlap > UV_OVERLAP_MAX: + return fail( + f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", + 7, + ), None, None, None, None, None + if ( + abs(size_x - OUTER_SIZE[0]) > BBOX_TOL + or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL + ): + return fail( + f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"off outer {OUTER_SIZE}", + 8, + ), None, None, None, None, None + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return fail( + f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", + 9, + ), None, None, None, None, None + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return fail( + f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", + 9, + ), None, None, None, None, None + if col_tris > COLLIDER_TRIS_MAX: + return fail( + f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", + 11, + ), None, None, None, None, None + if bake_result != {"FINISHED"} or not img.has_data: + return fail( + f"bake failed result={bake_result} has_data={img.has_data}", + 12, + ), None, None, None, None, None + if export_size <= 0: + return fail("export file missing or empty", 13), None, None, None, None, None + return 0, low, high, wood, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + nrm = nt.nodes.new("ShaderNodeNormalMap") + nrm.inputs["Strength"].default_value = 1.0 + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) + + +def render_still(low, wood, tex, path, engine): + scene = bpy.context.scene + wire_normal(wood, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(-16.0) + low.rotation_euler.x = math.radians(0.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 8.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-3.6, -5.0, 5.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) + light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) + light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (1.43, -1.73, 0.88) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.26) + 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("water-trough 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 f1c5387..a396505 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -87,5 +87,6 @@ {"name": "cart", "script": "showcase/cart/cart.py"}, {"name": "park-bench", "script": "showcase/park-bench/park_bench.py"}, {"name": "wheelbarrow", "script": "showcase/wheelbarrow/wheelbarrow.py"}, - {"name": "anvil", "script": "showcase/anvil/anvil.py"} + {"name": "anvil", "script": "showcase/anvil/anvil.py"}, + {"name": "water-trough", "script": "showcase/water-trough/water_trough.py"} ]