From 5bdea3c89d294a1f14aff04206f730b42499edd5 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:49:37 -0400 Subject: [PATCH 1/2] feat: add wooden-ladder showcase piece 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 | 9 +- ROADMAP.md | 2 +- docs/gallery/assets/wooden-ladder-hero.webp | Bin 0 -> 9624 bytes docs/gallery/index.html | 13 +- docs/gallery/wooden-ladder/index.html | 1039 +++++++++++++++++++ showcase/gallery.json | 12 + showcase/wooden-ladder/README.md | 74 ++ showcase/wooden-ladder/preview.webp | Bin 0 -> 8916 bytes showcase/wooden-ladder/wooden_ladder.py | 715 +++++++++++++ tests/smoke/catalog.json | 3 +- 13 files changed, 1865 insertions(+), 9 deletions(-) create mode 100644 docs/gallery/assets/wooden-ladder-hero.webp create mode 100644 docs/gallery/wooden-ladder/index.html create mode 100644 showcase/wooden-ladder/README.md create mode 100644 showcase/wooden-ladder/preview.webp create mode 100644 showcase/wooden-ladder/wooden_ladder.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index ba54d1b..65bab4b 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -161,6 +161,7 @@ "showcase/wooden-bucket", "showcase/wall-torch", "showcase/tavern-stool", - "showcase/iron-cauldron" + "showcase/iron-cauldron", + "showcase/wooden-ladder" ] } diff --git a/AGENTS.md b/AGENTS.md index 43fc201..a9b249e 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 24 showcase pieces (counts are CI-enforced against README.md) +examples, and 25 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 465185e..13c4e32 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, 24 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 25 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 ec3d200..4d823c9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  24 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  25 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 24 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 25 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. @@ -76,7 +76,7 @@ blender --background --python examples/bmesh-gear/bmesh_gear.py -- Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](showcase/README.md).

-24 showcase pieces — click to expand the preview grid +25 showcase pieces — click to expand the preview grid

Shipping crate: a wooden slat crate with iron corner brackets on a dark studio floor, warm wedge on the back wall @@ -103,6 +103,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Wall torch: a dressed stone plaque with an iron sconce, wooden haft, and flame on a dark studio floor Tavern stool: a round wooden seat on turned splayed legs with stretchers and iron ferrules on a dark studio floor Iron cauldron: a bulged iron pot hanging from a timber tripod on a dark studio floor + Wooden ladder: a raked timber ladder with six rungs and iron shoes 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. @@ -153,6 +154,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`iron-cauldron`](showcase/iron-cauldron/) — procedural hanging iron cauldron on a timber tripod through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`wooden-ladder`](showcase/wooden-ladder/) — procedural timber ladder with raked stiles, rungs, and iron shoes through the same pipeline. Falsifier `--skip-decimate` exits 9. +
## Examples diff --git a/ROADMAP.md b/ROADMAP.md index 72d3654..a8810b2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Wall-mounted torch sconce as a game-prop showcase piece~~ **SHIPPED** as `showcase/wall-torch/` — coursed stone plaque, iron bracket and cup, wooden haft, emissive flame; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Wooden tavern stool (turned legs, round seat, stretchers) as a game-prop showcase piece~~ **SHIPPED** as `showcase/tavern-stool/` — round seat, turned splayed legs, stretchers, iron ferrules; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Cast-iron cooking cauldron on a tripod as a game-prop showcase piece~~ **SHIPPED** as `showcase/iron-cauldron/` — bulged open pot, bail, timber tripod, hook; `--skip-decimate` exits 9 on the LOD1 ratio band -- Wooden ladder (rungs, stiles, iron shoes) as a game-prop showcase piece +- ~~Wooden ladder (rungs, stiles, iron shoes) as a game-prop showcase piece~~ **SHIPPED** as `showcase/wooden-ladder/` — raked stiles, six rungs, iron shoes and straps; `--skip-decimate` exits 9 on the LOD1 ratio band - Bound hay bale as a game-prop showcase piece - ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **SHIPPED** as `showcase/fence-kit/` — tiling post-and-rail section (AABB X is the tile width; does not re-witness the snap contract); `--skip-decimate` exits 9 on the LOD1 ratio band diff --git a/docs/gallery/assets/wooden-ladder-hero.webp b/docs/gallery/assets/wooden-ladder-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..26b04f459e1370099c91e74b38a599047f9ecd18 GIT binary patch literal 9624 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 Gallery autocomplete="off" spellcheck="false" aria-label="Search examples" /> - 75 examples + 76 examples
@@ -1149,6 +1149,17 @@

iron-cauldron

View example
+
+ + wooden-ladder — A procedural timber ladder with raked stiles, six rungs, and iron shoes through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather… + +
+

wooden-ladder

+

A procedural timber ladder with raked stiles, six rungs, and iron shoes through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 2184 tris, two materials with 1008 wood and 48 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.474×0.361×1.482 m, LOD ratios in band, convex collider 52 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

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

wooden-ladder

+

A procedural timber ladder with raked stiles, six rungs, and iron shoes 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: 2184 tris, two materials with 1008 wood and 48 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.474×0.361×1.482 m, LOD ratios in band, convex collider 52 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/wooden-ladder/wooden_ladder.py --
+ +
+
+

A showcase piece, not an example. Procedural timber ladder (raked stiles, six rungs, iron shoes and straps) 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 | 2100–2280 | 2184 / 2184 / 2184 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 | | Materials | exactly 2 distinct, ≥24 wood, ≥24 metal | 2 slots, 1008 wood, 48 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.474, 0.361, 1.482) m ± 0.01 | (0.4740, 0.3612, 1.4823), zmin 0 | | Collider tris | ≤ 120 | 52 | | Export | written, size > 0 | 158256 / 158256 / 158240 bytes |

+

DECIMATE COLLAPSE triangle counts are not identical across series — the gate is a ratio band, not an exact count. On this mesh the three binaries agreed on LOD ratios. 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 wooden_ladder.py --
+blender --background --python wooden_ladder.py -- --skip-decimate
+blender --background --python wooden_ladder.py -- --output ladder.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/wooden-ladder/wooden_ladder.py + View on GitHub → +
+
"""Game-ready wooden ladder — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural timber ladder 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 wooden_ladder.py --
+    blender --background --python wooden_ladder.py -- --skip-decimate
+    blender --background --python wooden_ladder.py -- --output ladder.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+# Showcase lives at repo-root/showcase/, not under examples/. The framing
+# helper is the repo's only shared import and lives next to the examples;
+# resolve the repo root so we do not move gallery_framing.py.
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+STILE_H = 1.48
+STILE_W = 0.058
+STILE_D = 0.034
+SPAN = 0.34
+N_RUNGS = 6
+RUNG_R = 0.017
+RUNG_SEGS = 10
+RAKE = math.radians(12.0)
+BBOX_TOL = 0.01
+# Fitted after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (0.474, 0.361, 1.482)
+
+BASE_TRIS_MIN = 2100
+BASE_TRIS_MAX = 2280
+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 = 120
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.06
+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):
+    # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
+    depsgraph = bpy.context.evaluated_depsgraph_get()
+    eval_obj = obj.evaluated_get(depsgraph)
+    eval_mesh = eval_obj.to_mesh()
+    try:
+        eval_mesh.calc_loop_triangles()
+        return len(eval_mesh.loop_triangles)
+    finally:
+        eval_obj.to_mesh_clear()
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+        v.co = rot @ p + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius1,
+        radius2=radius2,
+        depth=depth,
+    )
+    verts = list(geo["verts"])
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler)
+
+
+def 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_ladder_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    wood_verts = []
+    metal_faces = set()
+    try:
+        half = SPAN / 2.0 + STILE_W / 2.0
+        for sign in (-1.0, 1.0):
+            wood_verts.extend(
+                add_box(
+                    bm,
+                    (sign * half, 0.0, STILE_H / 2.0),
+                    (STILE_W, STILE_D, STILE_H),
+                    WOOD_IDX,
+                )
+            )
+            wood_verts.extend(
+                add_box(
+                    bm,
+                    (sign * half, 0.0, STILE_H + 0.012),
+                    (STILE_W + 0.008, STILE_D + 0.008, 0.028),
+                    WOOD_IDX,
+                )
+            )
+
+        z0 = 0.16
+        z1 = STILE_H - 0.14
+        for i in range(N_RUNGS):
+            t = i / (N_RUNGS - 1)
+            z = z0 + t * (z1 - z0)
+            wood_verts.extend(
+                add_cylinder(
+                    bm,
+                    (0.0, 0.0, z),
+                    RUNG_R,
+                    SPAN + STILE_W * 0.55,
+                    RUNG_SEGS,
+                    WOOD_IDX,
+                    euler=(0.0, math.pi / 2.0, 0.0),
+                )
+            )
+
+        if bevel_offset > 0.0:
+            edges = list({e for v in wood_verts for e in v.link_edges if v.is_valid})
+            if edges:
+                bmesh.ops.bevel(
+                    bm,
+                    geom=edges,
+                    offset=bevel_offset,
+                    segments=bevel_segments,
+                    profile=0.5,
+                    affect="EDGES",
+                    clamp_overlap=True,
+                )
+
+        before = set(bm.faces)
+        for sign in (-1.0, 1.0):
+            hx = sign * half
+            add_box(bm, (hx, 0.0, 0.028), (STILE_W + 0.018, STILE_D + 0.018, 0.056), METAL_IDX)
+            add_box(
+                bm,
+                (hx, STILE_D / 2.0 + 0.010, 0.070),
+                (STILE_W * 0.70, 0.014, 0.090),
+                METAL_IDX,
+            )
+            add_box(
+                bm,
+                (hx, -(STILE_D / 2.0 + 0.010), 0.070),
+                (STILE_W * 0.70, 0.014, 0.090),
+                METAL_IDX,
+            )
+            add_box(
+                bm,
+                (hx, 0.0, STILE_H - 0.055),
+                (STILE_W + 0.016, STILE_D + 0.016, 0.028),
+                METAL_IDX,
+            )
+        metal_faces.update(set(bm.faces) - before)
+
+        rake = Euler((RAKE, 0.0, 0.0)).to_matrix()
+        for v in bm.verts:
+            v.co = rake @ v.co
+        zs = [v.co.z for v in bm.verts]
+        zmin = min(zs)
+        xs = [v.co.x for v in bm.verts]
+        ys = [v.co.y for v in bm.verts]
+        cx = 0.5 * (min(xs) + max(xs))
+        cy = 0.5 * (min(ys) + max(ys))
+        for v in bm.verts:
+            v.co.x -= cx
+            v.co.y -= cy
+            v.co.z -= zmin
+            if v.co.z < 0.0:
+                v.co.z = 0.0
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = True
+        for edge in bm.edges:
+            edge.smooth = True
+            if edge.is_manifold and len(edge.link_faces) == 2:
+                if edge.calc_face_angle() > math.radians(40.0):
+                    edge.smooth = False
+        for f in metal_faces:
+            if f.is_valid:
+                f.material_index = METAL_IDX
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(obj)
+    return obj
+
+
+def principled(name, color, metallic, roughness):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def assign_slots(obj, wood, metal):
+    mats = obj.data.materials
+    wanted = (wood, metal)
+    for i, mat in enumerate(wanted):
+        if i < len(mats):
+            mats[i] = mat
+        else:
+            mats.append(mat)
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    overlap = 0.0
+    for i in range(len(aabbs)):
+        a = aabbs[i]
+        for j in range(i + 1, len(aabbs)):
+            b = aabbs[j]
+            x0 = max(a[0], b[0])
+            y0 = max(a[1], b[1])
+            x1 = min(a[2], b[2])
+            y1 = min(a[3], b[3])
+            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    # Duplicated from snippets/convex_hull_collider.py (not a package).
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    # Adapted from snippets/setup_bake_target_image.py — do not replace slots.
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("LadderNrm", size, size, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    nodes = target_mat.node_tree.nodes
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = img
+    nodes.active = tex
+    tex.select = True
+    obj.active_material_index = WOOD_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    # Duplicated from snippets/bake_normal_high_to_low.py (not a package).
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL",
+        use_selected_to_active=True,
+        cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False,
+        normal_space="TANGENT",
+        margin=4,
+        margin_type="ADJACENT_FACES",
+        use_clear=True,
+        target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    # Duplicated from snippets/export_preset_unity.py (not a package).
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path,
+        use_selection=True,
+        export_yup=True,
+        export_apply=True,
+        export_draco_mesh_compression_enable=False,
+        export_animations=False,
+    )
+
+
+def check(skip_decimate):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    low = build_ladder_mesh("LadderLow", bevel_offset=0.0025, bevel_segments=2)
+    high = build_ladder_mesh("LadderHigh", bevel_offset=0.0025, bevel_segments=4)
+    wood = principled("LadderWood", (0.42, 0.24, 0.10, 1.0), 0.0, 0.55)
+    metal = principled("LadderMetal", (0.48, 0.46, 0.42, 1.0), 1.0, 0.28)
+    assign_slots(low, wood, metal)
+    assign_slots(high, wood, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("ladder 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("ladder has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "LadderLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "LadderLOD2", 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_ladder_mesh("LadderColSrc", bevel_offset=0.0, bevel_segments=1)
+    collider = convex_hull_collider(collider_src, "LadderCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_wooden_ladder_{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(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 idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+        return fail(
+            f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+            6,
+        ), None, None, None, None, None
+    if overlap > UV_OVERLAP_MAX:
+        return fail(
+            f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+            7,
+        ), None, None, None, None, None
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+            f"off outer {OUTER_SIZE}",
+            8,
+        ), None, None, None, None, None
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)",
+            9,
+        ), None, None, None, None, None
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+            9,
+        ), None, None, None, None, None
+    if col_tris > COLLIDER_TRIS_MAX:
+        return fail(
+            f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+            11,
+        ), None, None, None, None, None
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return fail(
+            f"bake failed result={bake_result} has_data={img.has_data}",
+            12,
+        ), None, None, None, None, None
+    if export_size <= 0:
+        return fail("export file missing or empty", 13), None, None, None, None, None
+    return 0, low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, wood, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(wood, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-28.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.6, -5.0, 5.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+    light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+    light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (2.55, -3.55, 1.38)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.74)
+    scene.collection.objects.link(aim)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+        scene.cycles.device = "CPU"
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = (
+        "WEBP" if path.lower().endswith(".webp") else "PNG"
+    )
+    if path.lower().endswith(".webp"):
+        scene.render.image_settings.quality = 90
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    fcode = gallery_framing.check_framing(
+        scene, cam, hero=[low], elements=[low], stage=[floor, wall],
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        return fail("render produced no file", 14)
+    return 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None)
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    p.add_argument(
+        "--skip-decimate",
+        action="store_true",
+        help="falsification: skip the LOD DECIMATE stage",
+    )
+    args = p.parse_args(argv)
+
+    code, low, _high, wood, tex, _col = check(args.skip_decimate)
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("wooden-ladder 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 ad82420..3457b57 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -292,6 +292,18 @@ "mesh", "export" ] + }, + { + "name": "wooden-ladder", + "dir": "showcase/wooden-ladder", + "teaches": "A procedural timber ladder with raked stiles, six rungs, and iron shoes through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 2184 tris, two materials with 1008 wood and 48 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.474×0.361×1.482 m, LOD ratios in band, convex collider 52 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/wooden-ladder-hero.webp", + "preview": "showcase/wooden-ladder/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/wooden-ladder/README.md b/showcase/wooden-ladder/README.md new file mode 100644 index 0000000..3eaa4f0 --- /dev/null +++ b/showcase/wooden-ladder/README.md @@ -0,0 +1,74 @@ +# Wooden ladder + +A showcase piece, not an example. Procedural timber ladder (raked +stiles, six rungs, iron shoes and straps) 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 | 2100–2280 | 2184 / 2184 / 2184 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2198 / 0.2198 / 0.2198 | +| Materials | exactly 2 distinct, ≥24 wood, ≥24 metal | 2 slots, 1008 wood, 48 metal | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (0.474, 0.361, 1.482) m ± 0.01 | (0.4740, 0.3612, 1.4823), zmin 0 | +| Collider tris | ≤ 120 | 52 | +| Export | written, size > 0 | 158256 / 158256 / 158240 bytes | + +DECIMATE COLLAPSE triangle counts are **not** identical across series — +the gate is a ratio band, not an exact count. On this mesh the three +binaries agreed on LOD ratios. 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 wooden_ladder.py -- +blender --background --python wooden_ladder.py -- --skip-decimate +blender --background --python wooden_ladder.py -- --output ladder.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/wooden-ladder/preview.webp b/showcase/wooden-ladder/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..cab7c7994b54e9f53e256d9fdc041a9b226a4f61 GIT binary patch literal 8916 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 literal 0 HcmV?d00001 diff --git a/showcase/wooden-ladder/wooden_ladder.py b/showcase/wooden-ladder/wooden_ladder.py new file mode 100644 index 0000000..1a6ff2b --- /dev/null +++ b/showcase/wooden-ladder/wooden_ladder.py @@ -0,0 +1,715 @@ +"""Game-ready wooden ladder — a showcase piece, not an example. + +Asserts budget conformance of a procedural timber ladder 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 wooden_ladder.py -- + blender --background --python wooden_ladder.py -- --skip-decimate + blender --background --python wooden_ladder.py -- --output ladder.png +""" +import argparse +import math +import os +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Euler, Vector + +# Showcase lives at repo-root/showcase/, not under examples/. The framing +# helper is the repo's only shared import and lives next to the examples; +# resolve the repo root so we do not move gallery_framing.py. +_REPO = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) +) +sys.path.insert(0, os.path.join(_REPO, "examples")) +sys.dont_write_bytecode = True +import gallery_framing # noqa: E402 + +STILE_H = 1.48 +STILE_W = 0.058 +STILE_D = 0.034 +SPAN = 0.34 +N_RUNGS = 6 +RUNG_R = 0.017 +RUNG_SEGS = 10 +RAKE = math.radians(12.0) +BBOX_TOL = 0.01 +# Fitted after locking geometry. Recomputed from bound_box. +OUTER_SIZE = (0.474, 0.361, 1.482) + +BASE_TRIS_MIN = 2100 +BASE_TRIS_MAX = 2280 +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 = 120 +BAKE_RES = 256 +CAGE_EXTRUSION = 0.06 +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): + # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package). + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) + v.co = rot @ p + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius1, + radius2=radius2, + depth=depth, + ) + verts = list(geo["verts"]) + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler) + + +def 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_ladder_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + wood_verts = [] + metal_faces = set() + try: + half = SPAN / 2.0 + STILE_W / 2.0 + for sign in (-1.0, 1.0): + wood_verts.extend( + add_box( + bm, + (sign * half, 0.0, STILE_H / 2.0), + (STILE_W, STILE_D, STILE_H), + WOOD_IDX, + ) + ) + wood_verts.extend( + add_box( + bm, + (sign * half, 0.0, STILE_H + 0.012), + (STILE_W + 0.008, STILE_D + 0.008, 0.028), + WOOD_IDX, + ) + ) + + z0 = 0.16 + z1 = STILE_H - 0.14 + for i in range(N_RUNGS): + t = i / (N_RUNGS - 1) + z = z0 + t * (z1 - z0) + wood_verts.extend( + add_cylinder( + bm, + (0.0, 0.0, z), + RUNG_R, + SPAN + STILE_W * 0.55, + RUNG_SEGS, + WOOD_IDX, + euler=(0.0, math.pi / 2.0, 0.0), + ) + ) + + if bevel_offset > 0.0: + edges = list({e for v in wood_verts for e in v.link_edges if v.is_valid}) + if edges: + bmesh.ops.bevel( + bm, + geom=edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + + before = set(bm.faces) + for sign in (-1.0, 1.0): + hx = sign * half + add_box(bm, (hx, 0.0, 0.028), (STILE_W + 0.018, STILE_D + 0.018, 0.056), METAL_IDX) + add_box( + bm, + (hx, STILE_D / 2.0 + 0.010, 0.070), + (STILE_W * 0.70, 0.014, 0.090), + METAL_IDX, + ) + add_box( + bm, + (hx, -(STILE_D / 2.0 + 0.010), 0.070), + (STILE_W * 0.70, 0.014, 0.090), + METAL_IDX, + ) + add_box( + bm, + (hx, 0.0, STILE_H - 0.055), + (STILE_W + 0.016, STILE_D + 0.016, 0.028), + METAL_IDX, + ) + metal_faces.update(set(bm.faces) - before) + + rake = Euler((RAKE, 0.0, 0.0)).to_matrix() + for v in bm.verts: + v.co = rake @ v.co + zs = [v.co.z for v in bm.verts] + zmin = min(zs) + xs = [v.co.x for v in bm.verts] + ys = [v.co.y for v in bm.verts] + cx = 0.5 * (min(xs) + max(xs)) + cy = 0.5 * (min(ys) + max(ys)) + for v in bm.verts: + v.co.x -= cx + v.co.y -= cy + v.co.z -= zmin + if v.co.z < 0.0: + v.co.z = 0.0 + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = True + for edge in bm.edges: + edge.smooth = True + if edge.is_manifold and len(edge.link_faces) == 2: + if edge.calc_face_angle() > math.radians(40.0): + edge.smooth = False + for f in metal_faces: + if f.is_valid: + f.material_index = METAL_IDX + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + finally: + bm.free() + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj + + +def principled(name, color, metallic, roughness): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = color + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + return mat + + +def assign_slots(obj, wood, metal): + mats = obj.data.materials + wanted = (wood, metal) + for i, mat in enumerate(wanted): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0, 1.0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + overlap = 0.0 + for i in range(len(aabbs)): + a = aabbs[i] + for j in range(i + 1, len(aabbs)): + b = aabbs[j] + x0 = max(a[0], b[0]) + y0 = max(a[1], b[1]) + x1 = min(a[2], b[2]) + y1 = min(a[3], b[3]) + overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def convex_hull_collider(obj, name): + # Duplicated from snippets/convex_hull_collider.py (not a package). + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) + interior = result.get("geom_interior") or [] + unused = result.get("geom_unused") or [] + if interior: + bmesh.ops.delete(bm, geom=interior, context="VERTS") + if unused: + bmesh.ops.delete(bm, geom=unused, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + collider = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +def setup_bake_image(obj, target_mat, size=BAKE_RES): + # Adapted from snippets/setup_bake_target_image.py — do not replace slots. + if not obj.data.uv_layers: + return None, None + img = bpy.data.images.new("LadderNrm", size, size, alpha=True, float_buffer=False) + img.colorspace_settings.name = "Non-Color" + nodes = target_mat.node_tree.nodes + tex = nodes.new("ShaderNodeTexImage") + tex.image = img + nodes.active = tex + tex.select = True + obj.active_material_index = WOOD_IDX + return img, tex + + +def bake_normal(high, low): + # Duplicated from snippets/bake_normal_high_to_low.py (not a package). + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + return bpy.ops.object.bake( + type="NORMAL", + use_selected_to_active=True, + cage_extrusion=CAGE_EXTRUSION, + use_cage=False, + normal_space="TANGENT", + margin=4, + margin_type="ADJACENT_FACES", + use_clear=True, + target="IMAGE_TEXTURES", + ) + + +def export_unity(path, objects): + # Duplicated from snippets/export_preset_unity.py (not a package). + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + for ob in objects: + ob.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=False, + export_animations=False, + ) + + +def check(skip_decimate): + bpy.ops.wm.read_factory_settings(use_empty=True) + low = build_ladder_mesh("LadderLow", bevel_offset=0.0025, bevel_segments=2) + high = build_ladder_mesh("LadderHigh", bevel_offset=0.0025, bevel_segments=4) + wood = principled("LadderWood", (0.42, 0.24, 0.10, 1.0), 0.0, 0.55) + metal = principled("LadderMetal", (0.48, 0.46, 0.42, 1.0), 1.0, 0.28) + assign_slots(low, wood, metal) + assign_slots(high, wood, metal) + + if low.data is None or len(low.data.polygons) < 6: + return fail("ladder 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("ladder has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "LadderLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "LadderLOD2", 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_ladder_mesh("LadderColSrc", bevel_offset=0.0, bevel_segments=1) + collider = convex_hull_collider(collider_src, "LadderCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_wooden_ladder_{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(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 idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN: + return fail( + f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}", + 5, + ), None, None, None, None, None + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return fail( + f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", + 6, + ), None, None, None, None, None + if overlap > UV_OVERLAP_MAX: + return fail( + f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", + 7, + ), None, None, None, None, None + if ( + abs(size_x - OUTER_SIZE[0]) > BBOX_TOL + or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL + ): + return fail( + f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"off outer {OUTER_SIZE}", + 8, + ), None, None, None, None, None + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return fail( + f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", + 9, + ), None, None, None, None, None + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return fail( + f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", + 9, + ), None, None, None, None, None + if col_tris > COLLIDER_TRIS_MAX: + return fail( + f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", + 11, + ), None, None, None, None, None + if bake_result != {"FINISHED"} or not img.has_data: + return fail( + f"bake failed result={bake_result} has_data={img.has_data}", + 12, + ), None, None, None, None, None + if export_size <= 0: + return fail("export file missing or empty", 13), None, None, None, None, None + return 0, low, high, wood, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + nrm = nt.nodes.new("ShaderNodeNormalMap") + nrm.inputs["Strength"].default_value = 1.0 + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) + + +def render_still(low, wood, tex, path, engine): + scene = bpy.context.scene + wire_normal(wood, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(-28.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 8.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-3.6, -5.0, 5.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) + light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) + light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (2.55, -3.55, 1.38) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.74) + scene.collection.objects.link(aim) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 32 + scene.cycles.device = "CPU" + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = ( + "WEBP" if path.lower().endswith(".webp") else "PNG" + ) + if path.lower().endswith(".webp"): + scene.render.image_settings.quality = 90 + scene.render.filepath = path + scene.view_settings.view_transform = "Standard" + + fcode = gallery_framing.check_framing( + scene, cam, hero=[low], elements=[low], stage=[floor, wall], + ) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + return fail("render produced no file", 14) + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None) + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument( + "--skip-decimate", + action="store_true", + help="falsification: skip the LOD DECIMATE stage", + ) + args = p.parse_args(argv) + + code, low, _high, wood, tex, _col = check(args.skip_decimate) + if code: + return code + if args.output: + rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("wooden-ladder 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 1dc87c5..5d1f6b4 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -97,5 +97,6 @@ {"name": "wooden-bucket", "script": "showcase/wooden-bucket/wooden_bucket.py"}, {"name": "wall-torch", "script": "showcase/wall-torch/wall_torch.py"}, {"name": "tavern-stool", "script": "showcase/tavern-stool/tavern_stool.py"}, - {"name": "iron-cauldron", "script": "showcase/iron-cauldron/iron_cauldron.py"} + {"name": "iron-cauldron", "script": "showcase/iron-cauldron/iron_cauldron.py"}, + {"name": "wooden-ladder", "script": "showcase/wooden-ladder/wooden_ladder.py"} ] From 5b13a73bfbf1d4a9af1e78a4c305b03928d9a917 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:50:44 -0400 Subject: [PATCH 2/2] feat: add hay-bale showcase piece 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 | 9 +- ROADMAP.md | 4 +- docs/gallery/assets/hay-bale-hero.webp | Bin 0 -> 16162 bytes docs/gallery/hay-bale/index.html | 998 +++++++++++++++++++++++++ docs/gallery/index.html | 13 +- showcase/gallery.json | 12 + showcase/hay-bale/README.md | 74 ++ showcase/hay-bale/hay_bale.py | 674 +++++++++++++++++ showcase/hay-bale/preview.webp | Bin 0 -> 14156 bytes tests/smoke/catalog.json | 3 +- 13 files changed, 1785 insertions(+), 9 deletions(-) create mode 100644 docs/gallery/assets/hay-bale-hero.webp create mode 100644 docs/gallery/hay-bale/index.html create mode 100644 showcase/hay-bale/README.md create mode 100644 showcase/hay-bale/hay_bale.py create mode 100644 showcase/hay-bale/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 65bab4b..6f8f074 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -162,6 +162,7 @@ "showcase/wall-torch", "showcase/tavern-stool", "showcase/iron-cauldron", - "showcase/wooden-ladder" + "showcase/wooden-ladder", + "showcase/hay-bale" ] } diff --git a/AGENTS.md b/AGENTS.md index a9b249e..4dec798 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 25 showcase pieces (counts are CI-enforced against README.md) +examples, and 26 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 13c4e32..9c49df6 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, 25 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 26 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 4d823c9..e63f879 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  25 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  26 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 25 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 26 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. @@ -76,7 +76,7 @@ blender --background --python examples/bmesh-gear/bmesh_gear.py -- Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](showcase/README.md).

-25 showcase pieces — click to expand the preview grid +26 showcase pieces — click to expand the preview grid

Shipping crate: a wooden slat crate with iron corner brackets on a dark studio floor, warm wedge on the back wall @@ -104,6 +104,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Tavern stool: a round wooden seat on turned splayed legs with stretchers and iron ferrules on a dark studio floor Iron cauldron: a bulged iron pot hanging from a timber tripod on a dark studio floor Wooden ladder: a raked timber ladder with six rungs and iron shoes on a dark studio floor + Hay bale: a bound straw bale with two sisal twine belts 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. @@ -156,6 +157,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`wooden-ladder`](showcase/wooden-ladder/) — procedural timber ladder with raked stiles, rungs, and iron shoes through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`hay-bale`](showcase/hay-bale/) — procedural bound hay bale with sisal twine through the same pipeline. Falsifier `--skip-decimate` exits 9. +
## Examples diff --git a/ROADMAP.md b/ROADMAP.md index a8810b2..e5da783 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,7 +130,9 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Wooden tavern stool (turned legs, round seat, stretchers) as a game-prop showcase piece~~ **SHIPPED** as `showcase/tavern-stool/` — round seat, turned splayed legs, stretchers, iron ferrules; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Cast-iron cooking cauldron on a tripod as a game-prop showcase piece~~ **SHIPPED** as `showcase/iron-cauldron/` — bulged open pot, bail, timber tripod, hook; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Wooden ladder (rungs, stiles, iron shoes) as a game-prop showcase piece~~ **SHIPPED** as `showcase/wooden-ladder/` — raked stiles, six rungs, iron shoes and straps; `--skip-decimate` exits 9 on the LOD1 ratio band -- Bound hay bale as a game-prop showcase piece +- ~~Bound hay bale as a game-prop showcase piece~~ **SHIPPED** as `showcase/hay-bale/` — bevelled loaf, end nap, two sisal twine belts; `--skip-decimate` exits 9 on the LOD1 ratio band +- Wooden yoke (oxbow, iron rings) as a game-prop showcase piece +- Butter churn (staved tub, lid, dasher) 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/hay-bale-hero.webp b/docs/gallery/assets/hay-bale-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..0c6573c94047f15b6d2cb6521d1147ef2c459f1a GIT binary patch literal 16162 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 A0RR91 literal 0 HcmV?d00001 diff --git a/docs/gallery/hay-bale/index.html b/docs/gallery/hay-bale/index.html new file mode 100644 index 0000000..f2ceaa2 --- /dev/null +++ b/docs/gallery/hay-bale/index.html @@ -0,0 +1,998 @@ + + + + + + hay-bale — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

hay-bale

+

A procedural bound hay bale with a bevelled loaf, end nap, and two sisal twine belts 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: 252 tris, two materials with 66 hay and 60 twine faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.916×0.520×0.401 m, LOD ratios in band (5.2 COLLAPSE more aggressive), convex collider 68 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/hay-bale/hay_bale.py --
+ +
+
+

A showcase piece, not an example. Procedural bound straw bale (bevelled loaf, end nap, two sisal twine belts) 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 | 220–290 | 252 / 252 / 252 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4683 | | LOD2 ratio | 0.10–0.35 of base | 0.2143 / 0.2143 / 0.1429 | | Materials | exactly 2 distinct, ≥24 hay, ≥24 twine | 2 slots, 66 hay, 60 twine | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.916, 0.520, 0.401) m ± 0.01 | (0.9160, 0.5200, 0.4010), zmin 0 | | Collider tris | ≤ 120 | 68 | | Export | written, size > 0 | 25944 / 25944 / 25936 bytes |

+

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is more aggressive on both LOD ratios. 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 8 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 hay_bale.py --
+blender --background --python hay_bale.py -- --skip-decimate
+blender --background --python hay_bale.py -- --output bale.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 hay/twine 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/hay-bale/hay_bale.py + View on GitHub → +
+
"""Game-ready bound hay bale — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural straw bale 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 hay_bale.py --
+    blender --background --python hay_bale.py -- --skip-decimate
+    blender --background --python hay_bale.py -- --output bale.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+# Showcase lives at repo-root/showcase/, not under examples/. The framing
+# helper is the repo's only shared import and lives next to the examples;
+# resolve the repo root so we do not move gallery_framing.py.
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+BALE_X = 0.90
+BALE_Y = 0.48
+BALE_Z = 0.38
+TWINE_T = 0.014
+TWINE_W = 0.026
+TWINE_PAD = 0.006
+BBOX_TOL = 0.01
+# Fitted after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (0.916, 0.520, 0.401)
+
+BASE_TRIS_MIN = 220
+BASE_TRIS_MAX = 290
+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 = 120
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.04
+HAY_FACES_MIN = 24
+TWINE_FACES_MIN = 24
+
+HAY_IDX = 0
+TWINE_IDX = 1
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
+    depsgraph = bpy.context.evaluated_depsgraph_get()
+    eval_obj = obj.evaluated_get(depsgraph)
+    eval_mesh = eval_obj.to_mesh()
+    try:
+        eval_mesh.calc_loop_triangles()
+        return len(eval_mesh.loop_triangles)
+    finally:
+        eval_obj.to_mesh_clear()
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+        v.co = rot @ p + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def pack_uvs(bm, margin=0.08):
+    uv = bm.loops.layers.uv.new("UVMap")
+    faces = list(bm.faces)
+    n = len(faces)
+    cols = max(1, math.ceil(math.sqrt(n)))
+    rows = max(1, math.ceil(n / cols))
+    cell_w = 1.0 / cols
+    cell_h = 1.0 / rows
+    pad_u = margin * cell_w * 0.5
+    pad_v = margin * cell_h * 0.5
+    usable_w = cell_w - 2.0 * pad_u
+    usable_h = cell_h - 2.0 * pad_v
+    for i, face in enumerate(faces):
+        col = i % cols
+        row = i // cols
+        nrm = face.normal
+        ax = abs(nrm.x)
+        ay = abs(nrm.y)
+        az = abs(nrm.z)
+        coords = []
+        for loop in face.loops:
+            co = loop.vert.co
+            if az >= ax and az >= ay:
+                coords.append((co.x, co.y))
+            elif ax >= ay:
+                coords.append((co.y, co.z))
+            else:
+                coords.append((co.x, co.z))
+        xs = [c[0] for c in coords]
+        ys = [c[1] for c in coords]
+        minx, maxx = min(xs), max(xs)
+        miny, maxy = min(ys), max(ys)
+        dx = max(maxx - minx, 1e-8)
+        dy = max(maxy - miny, 1e-8)
+        origin_u = col * cell_w + pad_u
+        origin_v = row * cell_h + pad_v
+        for loop, (x, y) in zip(face.loops, coords):
+            loop[uv].uv = (
+                origin_u + (x - minx) / dx * usable_w,
+                origin_v + (y - miny) / dy * usable_h,
+            )
+
+
+def build_bale_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    hay_verts = []
+    twine_faces = set()
+    try:
+        hay_verts.extend(
+            add_box(
+                bm,
+                (0.0, 0.0, BALE_Z / 2.0),
+                (BALE_X, BALE_Y, BALE_Z),
+                HAY_IDX,
+            )
+        )
+        if bevel_offset > 0.0:
+            edges = list({e for v in hay_verts for e in v.link_edges if v.is_valid})
+            if edges:
+                bmesh.ops.bevel(
+                    bm,
+                    geom=edges,
+                    offset=bevel_offset,
+                    segments=bevel_segments,
+                    profile=0.5,
+                    affect="EDGES",
+                    clamp_overlap=True,
+                )
+
+        hx = BALE_X / 2.0
+        hy = BALE_Y / 2.0
+        for sign in (-1.0, 1.0):
+            add_box(
+                bm,
+                (sign * (hx + 0.002), 0.0, BALE_Z / 2.0),
+                (0.012, BALE_Y * 0.98, BALE_Z * 0.96),
+                HAY_IDX,
+            )
+
+        before = set(bm.faces)
+        for x in (-0.22, 0.22):
+            wrap_y = BALE_Y + 2.0 * TWINE_PAD + TWINE_T
+            wrap_z = BALE_Z + 2.0 * TWINE_PAD
+            add_box(bm, (x, 0.0, TWINE_T / 2.0), (TWINE_W, wrap_y, TWINE_T), TWINE_IDX)
+            add_box(
+                bm,
+                (x, 0.0, BALE_Z + TWINE_PAD - TWINE_T / 2.0),
+                (TWINE_W, wrap_y, TWINE_T),
+                TWINE_IDX,
+            )
+            add_box(
+                bm,
+                (x, hy + TWINE_PAD + TWINE_T / 2.0, BALE_Z / 2.0 + 0.004),
+                (TWINE_W, TWINE_T, wrap_z),
+                TWINE_IDX,
+            )
+            add_box(
+                bm,
+                (x, -(hy + TWINE_PAD + TWINE_T / 2.0), BALE_Z / 2.0 + 0.004),
+                (TWINE_W, TWINE_T, wrap_z),
+                TWINE_IDX,
+            )
+            add_box(
+                bm,
+                (x, 0.02, BALE_Z + TWINE_PAD + 0.004),
+                (TWINE_W + 0.008, 0.032, 0.018),
+                TWINE_IDX,
+            )
+        twine_faces.update(set(bm.faces) - before)
+
+        zs = [v.co.z for v in bm.verts]
+        zmin = min(zs)
+        xs = [v.co.x for v in bm.verts]
+        ys = [v.co.y for v in bm.verts]
+        cx = 0.5 * (min(xs) + max(xs))
+        cy = 0.5 * (min(ys) + max(ys))
+        for v in bm.verts:
+            v.co.x -= cx
+            v.co.y -= cy
+            v.co.z -= zmin
+            if v.co.z < 0.0:
+                v.co.z = 0.0
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = True
+        for edge in bm.edges:
+            edge.smooth = True
+            if edge.is_manifold and len(edge.link_faces) == 2:
+                if edge.calc_face_angle() > math.radians(38.0):
+                    edge.smooth = False
+        for f in twine_faces:
+            if f.is_valid:
+                f.material_index = TWINE_IDX
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+    finally:
+        bm.free()
+    obj = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(obj)
+    return obj
+
+
+def principled(name, color, metallic, roughness):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def assign_slots(obj, hay, twine):
+    mats = obj.data.materials
+    wanted = (hay, twine)
+    for i, mat in enumerate(wanted):
+        if i < len(mats):
+            mats[i] = mat
+        else:
+            mats.append(mat)
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    overlap = 0.0
+    for i in range(len(aabbs)):
+        a = aabbs[i]
+        for j in range(i + 1, len(aabbs)):
+            b = aabbs[j]
+            x0 = max(a[0], b[0])
+            y0 = max(a[1], b[1])
+            x1 = min(a[2], b[2])
+            y1 = min(a[3], b[3])
+            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    # Duplicated from snippets/convex_hull_collider.py (not a package).
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    # Adapted from snippets/setup_bake_target_image.py — do not replace slots.
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("HayBaleNrm", 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 = HAY_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    # Duplicated from snippets/bake_normal_high_to_low.py (not a package).
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL",
+        use_selected_to_active=True,
+        cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False,
+        normal_space="TANGENT",
+        margin=4,
+        margin_type="ADJACENT_FACES",
+        use_clear=True,
+        target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    # Duplicated from snippets/export_preset_unity.py (not a package).
+    for ob in bpy.context.view_layer.objects:
+        ob.select_set(False)
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path,
+        use_selection=True,
+        export_yup=True,
+        export_apply=True,
+        export_draco_mesh_compression_enable=False,
+        export_animations=False,
+    )
+
+
+def check(skip_decimate):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    low = build_bale_mesh("BaleLow", bevel_offset=0.012, bevel_segments=2)
+    high = build_bale_mesh("BaleHigh", bevel_offset=0.012, bevel_segments=4)
+    hay = principled("BaleHay", (0.72, 0.55, 0.18, 1.0), 0.0, 0.84)
+    twine = principled("BaleTwine", (0.20, 0.14, 0.07, 1.0), 0.0, 0.52)
+    assign_slots(low, hay, twine)
+    assign_slots(high, hay, twine)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("hay bale 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, hay)
+    if img is None:
+        return fail("hay bale has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "BaleLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "BaleLOD2", 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_bale_mesh("BaleColSrc", bevel_offset=0.0, bevel_segments=1)
+    collider = convex_hull_collider(collider_src, "BaleCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_hay_bale_{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(HAY_IDX, 0) < HAY_FACES_MIN:
+        return fail(
+            f"hay faces {idx_counts.get(HAY_IDX, 0)} < {HAY_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(TWINE_IDX, 0) < TWINE_FACES_MIN:
+        return fail(
+            f"twine faces {idx_counts.get(TWINE_IDX, 0)} < {TWINE_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, hay, 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, hay, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(hay, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-32.0)
+
+    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.26, -1.53, 0.88)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.15)
+    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, hay, tex, _col = check(args.skip_decimate)
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, hay, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("hay-bale 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/index.html b/docs/gallery/index.html index c2d4a62..32315c8 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" /> - 76 examples + 77 examples
@@ -1160,6 +1160,17 @@

wooden-ladder

View example
+
+ + hay-bale — A procedural bound hay bale with a bevelled loaf, end nap, and two sisal twine belts through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed… + +
+

hay-bale

+

A procedural bound hay bale with a bevelled loaf, end nap, and two sisal twine belts through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 252 tris, two materials with 66 hay and 60 twine faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.916×0.520×0.401 m, LOD ratios in band (5.2 COLLAPSE more aggressive), convex collider 68 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+ View example +
+
diff --git a/showcase/gallery.json b/showcase/gallery.json index 3457b57..da14762 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -304,6 +304,18 @@ "mesh", "export" ] + }, + { + "name": "hay-bale", + "dir": "showcase/hay-bale", + "teaches": "A procedural bound hay bale with a bevelled loaf, end nap, and two sisal twine belts through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 252 tris, two materials with 66 hay and 60 twine faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.916×0.520×0.401 m, LOD ratios in band (5.2 COLLAPSE more aggressive), convex collider 68 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/hay-bale-hero.webp", + "preview": "showcase/hay-bale/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/hay-bale/README.md b/showcase/hay-bale/README.md new file mode 100644 index 0000000..a4d534c --- /dev/null +++ b/showcase/hay-bale/README.md @@ -0,0 +1,74 @@ +# Hay bale + +A showcase piece, not an example. Procedural bound straw bale (bevelled +loaf, end nap, two sisal twine belts) 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 | 220–290 | 252 / 252 / 252 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.4683 | +| LOD2 ratio | 0.10–0.35 of base | 0.2143 / 0.2143 / 0.1429 | +| Materials | exactly 2 distinct, ≥24 hay, ≥24 twine | 2 slots, 66 hay, 60 twine | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (0.916, 0.520, 0.401) m ± 0.01 | (0.9160, 0.5200, 0.4010), zmin 0 | +| Collider tris | ≤ 120 | 68 | +| Export | written, size > 0 | 25944 / 25944 / 25936 bytes | + +DECIMATE COLLAPSE triangle counts are **not** identical across series — +5.2.1 is more aggressive on both LOD ratios. 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 8 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 hay_bale.py -- +blender --background --python hay_bale.py -- --skip-decimate +blender --background --python hay_bale.py -- --output bale.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 hay/twine 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/hay-bale/hay_bale.py b/showcase/hay-bale/hay_bale.py new file mode 100644 index 0000000..c72af21 --- /dev/null +++ b/showcase/hay-bale/hay_bale.py @@ -0,0 +1,674 @@ +"""Game-ready bound hay bale — a showcase piece, not an example. + +Asserts budget conformance of a procedural straw bale 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 hay_bale.py -- + blender --background --python hay_bale.py -- --skip-decimate + blender --background --python hay_bale.py -- --output bale.png +""" +import argparse +import math +import os +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Euler, Vector + +# Showcase lives at repo-root/showcase/, not under examples/. The framing +# helper is the repo's only shared import and lives next to the examples; +# resolve the repo root so we do not move gallery_framing.py. +_REPO = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) +) +sys.path.insert(0, os.path.join(_REPO, "examples")) +sys.dont_write_bytecode = True +import gallery_framing # noqa: E402 + +BALE_X = 0.90 +BALE_Y = 0.48 +BALE_Z = 0.38 +TWINE_T = 0.014 +TWINE_W = 0.026 +TWINE_PAD = 0.006 +BBOX_TOL = 0.01 +# Fitted after locking geometry. Recomputed from bound_box. +OUTER_SIZE = (0.916, 0.520, 0.401) + +BASE_TRIS_MIN = 220 +BASE_TRIS_MAX = 290 +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 = 120 +BAKE_RES = 256 +CAGE_EXTRUSION = 0.04 +HAY_FACES_MIN = 24 +TWINE_FACES_MIN = 24 + +HAY_IDX = 0 +TWINE_IDX = 1 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package). + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) + v.co = rot @ p + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def pack_uvs(bm, margin=0.08): + uv = bm.loops.layers.uv.new("UVMap") + faces = list(bm.faces) + n = len(faces) + cols = max(1, math.ceil(math.sqrt(n))) + rows = max(1, math.ceil(n / cols)) + cell_w = 1.0 / cols + cell_h = 1.0 / rows + pad_u = margin * cell_w * 0.5 + pad_v = margin * cell_h * 0.5 + usable_w = cell_w - 2.0 * pad_u + usable_h = cell_h - 2.0 * pad_v + for i, face in enumerate(faces): + col = i % cols + row = i // cols + nrm = face.normal + ax = abs(nrm.x) + ay = abs(nrm.y) + az = abs(nrm.z) + coords = [] + for loop in face.loops: + co = loop.vert.co + if az >= ax and az >= ay: + coords.append((co.x, co.y)) + elif ax >= ay: + coords.append((co.y, co.z)) + else: + coords.append((co.x, co.z)) + xs = [c[0] for c in coords] + ys = [c[1] for c in coords] + minx, maxx = min(xs), max(xs) + miny, maxy = min(ys), max(ys) + dx = max(maxx - minx, 1e-8) + dy = max(maxy - miny, 1e-8) + origin_u = col * cell_w + pad_u + origin_v = row * cell_h + pad_v + for loop, (x, y) in zip(face.loops, coords): + loop[uv].uv = ( + origin_u + (x - minx) / dx * usable_w, + origin_v + (y - miny) / dy * usable_h, + ) + + +def build_bale_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + hay_verts = [] + twine_faces = set() + try: + hay_verts.extend( + add_box( + bm, + (0.0, 0.0, BALE_Z / 2.0), + (BALE_X, BALE_Y, BALE_Z), + HAY_IDX, + ) + ) + if bevel_offset > 0.0: + edges = list({e for v in hay_verts for e in v.link_edges if v.is_valid}) + if edges: + bmesh.ops.bevel( + bm, + geom=edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + + hx = BALE_X / 2.0 + hy = BALE_Y / 2.0 + for sign in (-1.0, 1.0): + add_box( + bm, + (sign * (hx + 0.002), 0.0, BALE_Z / 2.0), + (0.012, BALE_Y * 0.98, BALE_Z * 0.96), + HAY_IDX, + ) + + before = set(bm.faces) + for x in (-0.22, 0.22): + wrap_y = BALE_Y + 2.0 * TWINE_PAD + TWINE_T + wrap_z = BALE_Z + 2.0 * TWINE_PAD + add_box(bm, (x, 0.0, TWINE_T / 2.0), (TWINE_W, wrap_y, TWINE_T), TWINE_IDX) + add_box( + bm, + (x, 0.0, BALE_Z + TWINE_PAD - TWINE_T / 2.0), + (TWINE_W, wrap_y, TWINE_T), + TWINE_IDX, + ) + add_box( + bm, + (x, hy + TWINE_PAD + TWINE_T / 2.0, BALE_Z / 2.0 + 0.004), + (TWINE_W, TWINE_T, wrap_z), + TWINE_IDX, + ) + add_box( + bm, + (x, -(hy + TWINE_PAD + TWINE_T / 2.0), BALE_Z / 2.0 + 0.004), + (TWINE_W, TWINE_T, wrap_z), + TWINE_IDX, + ) + add_box( + bm, + (x, 0.02, BALE_Z + TWINE_PAD + 0.004), + (TWINE_W + 0.008, 0.032, 0.018), + TWINE_IDX, + ) + twine_faces.update(set(bm.faces) - before) + + zs = [v.co.z for v in bm.verts] + zmin = min(zs) + xs = [v.co.x for v in bm.verts] + ys = [v.co.y for v in bm.verts] + cx = 0.5 * (min(xs) + max(xs)) + cy = 0.5 * (min(ys) + max(ys)) + for v in bm.verts: + v.co.x -= cx + v.co.y -= cy + v.co.z -= zmin + if v.co.z < 0.0: + v.co.z = 0.0 + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = True + for edge in bm.edges: + edge.smooth = True + if edge.is_manifold and len(edge.link_faces) == 2: + if edge.calc_face_angle() > math.radians(38.0): + edge.smooth = False + for f in twine_faces: + if f.is_valid: + f.material_index = TWINE_IDX + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + finally: + bm.free() + obj = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(obj) + return obj + + +def principled(name, color, metallic, roughness): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = color + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + return mat + + +def assign_slots(obj, hay, twine): + mats = obj.data.materials + wanted = (hay, twine) + for i, mat in enumerate(wanted): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0, 1.0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + overlap = 0.0 + for i in range(len(aabbs)): + a = aabbs[i] + for j in range(i + 1, len(aabbs)): + b = aabbs[j] + x0 = max(a[0], b[0]) + y0 = max(a[1], b[1]) + x1 = min(a[2], b[2]) + y1 = min(a[3], b[3]) + overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def convex_hull_collider(obj, name): + # Duplicated from snippets/convex_hull_collider.py (not a package). + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) + interior = result.get("geom_interior") or [] + unused = result.get("geom_unused") or [] + if interior: + bmesh.ops.delete(bm, geom=interior, context="VERTS") + if unused: + bmesh.ops.delete(bm, geom=unused, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + collider = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +def setup_bake_image(obj, target_mat, size=BAKE_RES): + # Adapted from snippets/setup_bake_target_image.py — do not replace slots. + if not obj.data.uv_layers: + return None, None + img = bpy.data.images.new("HayBaleNrm", 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 = HAY_IDX + return img, tex + + +def bake_normal(high, low): + # Duplicated from snippets/bake_normal_high_to_low.py (not a package). + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + return bpy.ops.object.bake( + type="NORMAL", + use_selected_to_active=True, + cage_extrusion=CAGE_EXTRUSION, + use_cage=False, + normal_space="TANGENT", + margin=4, + margin_type="ADJACENT_FACES", + use_clear=True, + target="IMAGE_TEXTURES", + ) + + +def export_unity(path, objects): + # Duplicated from snippets/export_preset_unity.py (not a package). + for ob in bpy.context.view_layer.objects: + ob.select_set(False) + for ob in objects: + ob.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=False, + export_animations=False, + ) + + +def check(skip_decimate): + bpy.ops.wm.read_factory_settings(use_empty=True) + low = build_bale_mesh("BaleLow", bevel_offset=0.012, bevel_segments=2) + high = build_bale_mesh("BaleHigh", bevel_offset=0.012, bevel_segments=4) + hay = principled("BaleHay", (0.72, 0.55, 0.18, 1.0), 0.0, 0.84) + twine = principled("BaleTwine", (0.20, 0.14, 0.07, 1.0), 0.0, 0.52) + assign_slots(low, hay, twine) + assign_slots(high, hay, twine) + + if low.data is None or len(low.data.polygons) < 6: + return fail("hay bale 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, hay) + if img is None: + return fail("hay bale has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "BaleLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "BaleLOD2", 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_bale_mesh("BaleColSrc", bevel_offset=0.0, bevel_segments=1) + collider = convex_hull_collider(collider_src, "BaleCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_hay_bale_{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(HAY_IDX, 0) < HAY_FACES_MIN: + return fail( + f"hay faces {idx_counts.get(HAY_IDX, 0)} < {HAY_FACES_MIN}", + 5, + ), None, None, None, None, None + if idx_counts.get(TWINE_IDX, 0) < TWINE_FACES_MIN: + return fail( + f"twine faces {idx_counts.get(TWINE_IDX, 0)} < {TWINE_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, hay, 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, hay, tex, path, engine): + scene = bpy.context.scene + wire_normal(hay, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(-32.0) + + 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.26, -1.53, 0.88) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.15) + 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, hay, tex, _col = check(args.skip_decimate) + if code: + return code + if args.output: + rcode = render_still(low, hay, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("hay-bale 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/hay-bale/preview.webp b/showcase/hay-bale/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..8c99ea0fd11df4892d5ef601bcef79b0c04805e4 GIT binary patch literal 14156 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 W-^E6^qr+hQ+Vkm9IBNn<3qSxgEN7zt literal 0 HcmV?d00001 diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index 5d1f6b4..d9c58f3 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -98,5 +98,6 @@ {"name": "wall-torch", "script": "showcase/wall-torch/wall_torch.py"}, {"name": "tavern-stool", "script": "showcase/tavern-stool/tavern_stool.py"}, {"name": "iron-cauldron", "script": "showcase/iron-cauldron/iron_cauldron.py"}, - {"name": "wooden-ladder", "script": "showcase/wooden-ladder/wooden_ladder.py"} + {"name": "wooden-ladder", "script": "showcase/wooden-ladder/wooden_ladder.py"}, + {"name": "hay-bale", "script": "showcase/hay-bale/hay_bale.py"} ]