diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index ead474a..91de3eb 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -156,6 +156,8 @@ "showcase/hitching-post", "showcase/grindstone", "showcase/hand-pump", - "showcase/signpost" + "showcase/signpost", + "showcase/bellows", + "showcase/chopping-block" ] } diff --git a/AGENTS.md b/AGENTS.md index e133415..c78af34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ classifies it as a `cursor-plugin`. This is content the AI loads when the user asks Blender questions or works on Blender add-ons in Cursor or Claude Code. The content base is 16 skills, 9 rules, 3 templates, 27 snippets, 59 -examples, and 19 showcase pieces (counts are CI-enforced against README.md) +examples, and 21 showcase pieces (counts are CI-enforced against README.md) and the manifest). The full inventory tables and per-item purposes live in `CLAUDE.md`. Example anatomy and authoring rules: copy `examples/bmesh-gear/`; showcase conventions: `showcase/README.md`. The render look is specified diff --git a/CLAUDE.md b/CLAUDE.md index 430a2a3..0080383 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, 19 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 21 pieces (sibling of examples/; see showcase/README.md) scripts/build_gallery.py - Regenerates docs/gallery/ from examples/gallery.json + showcase/gallery.json scripts/site/ - Vendored landing-page build (Jinja2) docs/gallery/ - Committed generated gallery pages + hero renders diff --git a/README.md b/README.md index 3e089be..758430e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

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

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 19 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. +This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 21 showcase pieces** for Blender Python development targeting Blender 5.2 LTS (current stable) with Blender 4.5 LTS fallback support. Blender 5.1 is prior stable. The content is consumed by AI coding agents (Cursor, Claude Code, any MCP-capable client) when working on Blender add-ons, geometry nodes scripts, batch pipelines, or animation tooling. There is no build step. Edit the markdown and Python files directly. @@ -94,6 +94,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Grindstone: a stone wheel on a timber A-frame with an iron axle, hubs, crank, and tray on a dark studio floor Hand pump: a cast-iron village pump on a wooden plinth with a spout and handle on a dark studio floor Signpost: a timber post with two painted fingerboards, iron straps, and a pyramidal cap on a dark studio floor +Bellows: hinged timber paddles with a leather bag and iron nozzle on a dark studio floor +Chopping block: a hooped timber stump with an embedded axe 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. @@ -133,6 +135,10 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`signpost`](showcase/signpost/) — procedural timber fingerboard signpost through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`bellows`](showcase/bellows/) — procedural blacksmith bellows with timber paddles, leather bag, and iron nozzle through the same pipeline. Falsifier `--skip-decimate` exits 9. + +[`chopping-block`](showcase/chopping-block/) — procedural hooped timber stump with an embedded axe through the same pipeline. Falsifier `--skip-decimate` exits 9. + ## Examples Runnable, smoke-gated demos live in [`examples/`](examples/) — each is executed headless on diff --git a/ROADMAP.md b/ROADMAP.md index 8d9b090..021bc49 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,8 +123,10 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Grindstone on a timber trestle as a game-prop showcase piece~~ **SHIPPED** as `showcase/grindstone/` — stone wheel, A-frame trestle, iron axle, hubs, crank; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Cast-iron village hand pump as a game-prop showcase piece~~ **SHIPPED** as `showcase/hand-pump/` — wooden plinth, column, spout, handle, wooden grip; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Timber fingerboard signpost as a game-prop showcase piece~~ **SHIPPED** as `showcase/signpost/` — square post, pyramidal cap, two painted boards, iron shoe and straps; `--skip-decimate` exits 9 on the LOD1 ratio band -- Blacksmith bellows as a game-prop showcase piece -- Chopping block with embedded axe as a game-prop showcase piece +- ~~Blacksmith bellows as a game-prop showcase piece~~ **SHIPPED** as `showcase/bellows/` — hinged timber paddles, leather bag, iron nozzle; `--skip-decimate` exits 9 on the LOD1 ratio band +- ~~Chopping block with embedded axe as a game-prop showcase piece~~ **SHIPPED** as `showcase/chopping-block/` — hooped timber stump, kerf, embedded axe; `--skip-decimate` exits 9 on the LOD1 ratio band +- Wooden bucket with iron bands and a rope bail as a game-prop showcase piece +- Wall-mounted torch sconce as a game-prop showcase piece - ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **SHIPPED** as `showcase/fence-kit/` — tiling post-and-rail section (AABB X is the tile width; does not re-witness the snap contract); `--skip-decimate` exits 9 on the LOD1 ratio band - ~~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/bellows-hero.webp b/docs/gallery/assets/bellows-hero.webp new file mode 100644 index 0000000..bcf9a98 Binary files /dev/null and b/docs/gallery/assets/bellows-hero.webp differ diff --git a/docs/gallery/assets/chopping-block-hero.webp b/docs/gallery/assets/chopping-block-hero.webp new file mode 100644 index 0000000..5362d1d Binary files /dev/null and b/docs/gallery/assets/chopping-block-hero.webp differ diff --git a/docs/gallery/bellows/index.html b/docs/gallery/bellows/index.html new file mode 100644 index 0000000..6b2735c --- /dev/null +++ b/docs/gallery/bellows/index.html @@ -0,0 +1,1229 @@ + + + + + + bellows — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +

+
+

bellows

+

A procedural blacksmith bellows 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: 1272 tris, three materials with 294 leather and 48 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.630×0.403×0.277 m, LOD ratios in band (5.2 COLLAPSE 2 tris leaner on LOD2), convex collider 148 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/bellows/bellows.py --
+ +
+
+

A showcase piece, not an example. Procedural blacksmith bellows (hinged timber paddles, leather bag, iron nozzle) 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 | 1180–1380 | 1272 / 1272 / 1272 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2186 / 0.2186 / 0.2170 | | Materials | exactly 3 distinct, ≥24 leather and metal faces | 3 slots, 294 leather, 48 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.630, 0.403, 0.277) m ± 0.01 | (0.6303, 0.4031, 0.2765), zmin 0 | | Collider tris | ≤ 180 | 148 | | Export | written, size > 0 | 105796 / 105796 / 105788 bytes |

+

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is 2 tris leaner on LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 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 bellows.py --
+blender --background --python bellows.py -- --skip-decimate
+blender --background --python bellows.py -- --output bellows.png
+

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

+

Exit codes

+

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

+

| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 3 distinct slots, or wood/leather/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/bellows/bellows.py + View on GitHub → +
+
"""Game-ready blacksmith bellows — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural bellows (hinged timber
+paddles, leather accordion, iron nozzle) after composing shipped
+pipeline pieces: bmesh construction, UVs, three materials, high-to-low
+normal bake, LOD chain, convex collider, Unity glTF export.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
+DECIMATE stage so the LOD-ratio budget fails.
+
+No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
+are not byte-identical across Blender versions — the LOD gate is a
+ratio band, not an exact count.
+
+    blender --background --python bellows.py --
+    blender --background --python bellows.py -- --skip-decimate
+    blender --background --python bellows.py -- --output bellows.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+BOARD_L = 0.52
+BOARD_W = 0.30
+BOARD_T = 0.034
+OPEN = 0.26
+N_FOLDS = 5
+NOZZLE_L = 0.10
+NOZZLE_R = 0.018
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (0.630, 0.403, 0.277)
+BASE_TRIS_MIN = 1180
+BASE_TRIS_MAX = 1380
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 3
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 180
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 24
+WOOD_FACES_MIN = 12
+LEATHER_FACES_MIN = 24
+
+WOOD_IDX = 0
+LEATHER_IDX = 1
+METAL_IDX = 2
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    depsgraph = bpy.context.evaluated_depsgraph_get()
+    eval_obj = obj.evaluated_get(depsgraph)
+    eval_mesh = eval_obj.to_mesh()
+    try:
+        eval_mesh.calc_loop_triangles()
+        return len(eval_mesh.loop_triangles)
+    finally:
+        eval_obj.to_mesh_clear()
+
+
+def deselect_all():
+    for ob in list(bpy.context.view_layer.objects):
+        if ob is None:
+            continue
+        ob.select_set(False)
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+        v.co = rot @ p + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_oriented_box(bm, a, b, scale_xy, mat_idx):
+    a = Vector(a)
+    b = Vector(b)
+    delta = b - a
+    length = delta.length
+    if length < 1e-8:
+        return []
+    quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+    eul = quat.to_euler("XYZ")
+    return add_box(
+        bm,
+        ((a + b) * 0.5),
+        (scale_xy[0], scale_xy[1], length),
+        mat_idx,
+        euler=(eul.x, eul.y, eul.z),
+    )
+
+
+def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius,
+        radius2=radius,
+        depth=depth,
+    )
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def _quad(bm, verts, mat_idx):
+    try:
+        face = bm.faces.new(verts)
+    except ValueError:
+        return None
+    face.material_index = mat_idx
+    return face
+
+
+def add_leather_panel(
+    bm, a0, a1, b0, b1, n_u, n_v, folds, bulge, thickness, outward, mat_idx
+):
+    a0 = Vector(a0)
+    a1 = Vector(a1)
+    b0 = Vector(b0)
+    b1 = Vector(b1)
+    outward = Vector(outward)
+    if outward.length < 1e-8:
+        return []
+    outward = outward.normalized()
+    grid_o = []
+    grid_i = []
+    collected = []
+    for iv in range(n_v + 1):
+        tv = iv / n_v
+        row_o = []
+        row_i = []
+        for iu in range(n_u + 1):
+            tu = iu / n_u
+            p = a0.lerp(a1, tu).lerp(b0.lerp(b1, tu), tv)
+            fold = abs(math.sin(tu * folds * math.pi))
+            puff = math.sin(tv * math.pi)
+            p = p + outward * (0.003 + bulge * fold * puff)
+            vo = bm.verts.new(p + outward * (thickness * 0.5))
+            vi = bm.verts.new(p - outward * (thickness * 0.5))
+            row_o.append(vo)
+            row_i.append(vi)
+            collected.extend((vo, vi))
+        grid_o.append(row_o)
+        grid_i.append(row_i)
+    bm.verts.ensure_lookup_table()
+    for iv in range(n_v):
+        for iu in range(n_u):
+            _quad(
+                bm,
+                (
+                    grid_o[iv][iu],
+                    grid_o[iv][iu + 1],
+                    grid_o[iv + 1][iu + 1],
+                    grid_o[iv + 1][iu],
+                ),
+                mat_idx,
+            )
+            _quad(
+                bm,
+                (
+                    grid_i[iv][iu + 1],
+                    grid_i[iv][iu],
+                    grid_i[iv + 1][iu],
+                    grid_i[iv + 1][iu + 1],
+                ),
+                mat_idx,
+            )
+    for iu in range(n_u):
+        _quad(
+            bm,
+            (grid_o[0][iu], grid_i[0][iu], grid_i[0][iu + 1], grid_o[0][iu + 1]),
+            mat_idx,
+        )
+        _quad(
+            bm,
+            (
+                grid_o[n_v][iu + 1],
+                grid_i[n_v][iu + 1],
+                grid_i[n_v][iu],
+                grid_o[n_v][iu],
+            ),
+            mat_idx,
+        )
+    for iv in range(n_v):
+        _quad(
+            bm,
+            (grid_o[iv][0], grid_o[iv + 1][0], grid_i[iv + 1][0], grid_i[iv][0]),
+            mat_idx,
+        )
+        _quad(
+            bm,
+            (
+                grid_o[iv][n_u],
+                grid_i[iv][n_u],
+                grid_i[iv + 1][n_u],
+                grid_o[iv + 1][n_u],
+            ),
+            mat_idx,
+        )
+    return collected
+
+
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius1,
+        radius2=radius2,
+        depth=depth,
+    )
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def pack_uvs(bm, margin=0.08):
+    uv = bm.loops.layers.uv.new("UVMap")
+    faces = list(bm.faces)
+    n = len(faces)
+    cols = max(1, math.ceil(math.sqrt(n)))
+    rows = max(1, math.ceil(n / cols))
+    cell_w = 1.0 / cols
+    cell_h = 1.0 / rows
+    pad_u = margin * cell_w * 0.5
+    pad_v = margin * cell_h * 0.5
+    usable_w = cell_w - 2.0 * pad_u
+    usable_h = cell_h - 2.0 * pad_v
+    for i, face in enumerate(faces):
+        col = i % cols
+        row = i // cols
+        nrm = face.normal
+        ax = abs(nrm.x)
+        ay = abs(nrm.y)
+        az = abs(nrm.z)
+        coords = []
+        for loop in face.loops:
+            co = loop.vert.co
+            if az >= ax and az >= ay:
+                coords.append((co.x, co.y))
+            elif ax >= ay:
+                coords.append((co.y, co.z))
+            else:
+                coords.append((co.x, co.z))
+        xs = [c[0] for c in coords]
+        ys = [c[1] for c in coords]
+        minx, maxx = min(xs), max(xs)
+        miny, maxy = min(ys), max(ys)
+        dx = max(maxx - minx, 1e-8)
+        dy = max(maxy - miny, 1e-8)
+        origin_u = col * cell_w + pad_u
+        origin_v = row * cell_h + pad_v
+        for loop, (x, y) in zip(face.loops, coords):
+            loop[uv].uv = (
+                origin_u + (x - minx) / dx * usable_w,
+                origin_v + (y - miny) / dy * usable_h,
+            )
+
+
+def build_bellows_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    try:
+        wood = []
+        leather = []
+        metal = []
+        z_bot = BOARD_T
+        open_ang = math.atan2(OPEN, BOARD_L)
+        hinge = Vector((BOARD_L / 2.0 - 0.018, 0.0, z_bot + 0.006))
+
+        wood.extend(
+            add_box(
+                bm,
+                (0.0, 0.0, BOARD_T / 2.0),
+                (BOARD_L, BOARD_W, BOARD_T),
+                WOOD_IDX,
+            )
+        )
+
+        rot = Euler((0.0, -open_ang, 0.0)).to_matrix()
+        local_center = Vector((-BOARD_L / 2.0 + 0.018, 0.0, BOARD_T / 2.0))
+        top_c = hinge + rot @ local_center
+        wood.extend(
+            add_box(
+                bm,
+                (top_c.x, top_c.y, top_c.z),
+                (BOARD_L, BOARD_W, BOARD_T),
+                WOOD_IDX,
+                euler=(0.0, -open_ang, 0.0),
+            )
+        )
+        for y in (-0.065, 0.065):
+            p0 = hinge + rot @ Vector((-BOARD_L / 2.0 + 0.055, y, BOARD_T))
+            p1 = hinge + rot @ Vector((-BOARD_L / 2.0 + 0.055, y, BOARD_T + 0.072))
+            wood.extend(add_oriented_box(bm, p0, p1, (0.022, 0.022), WOOD_IDX))
+        g0 = hinge + rot @ Vector((-BOARD_L / 2.0 + 0.055, -0.085, BOARD_T + 0.072))
+        g1 = hinge + rot @ Vector((-BOARD_L / 2.0 + 0.055, 0.085, BOARD_T + 0.072))
+        wood.extend(add_oriented_box(bm, g0, g1, (0.018, 0.018), WOOD_IDX))
+
+        if bevel_offset > 0.0:
+            edges = list({e for v in wood for e in v.link_edges})
+            ret = bmesh.ops.bevel(
+                bm,
+                geom=edges,
+                offset=bevel_offset,
+                segments=bevel_segments,
+                profile=0.5,
+                affect='EDGES',
+                clamp_overlap=True,
+            )
+            for f in ret.get('faces') or []:
+                f.material_index = WOOD_IDX
+
+        hw = BOARD_W / 2.0 - 0.003
+        hx0 = -BOARD_L / 2.0 + 0.010
+        hx1 = BOARD_L / 2.0 - 0.036
+
+        def top_under(lx, ly):
+            return hinge + rot @ (local_center + Vector((lx, ly, -BOARD_T / 2.0)))
+
+        for side in (-1.0, 1.0):
+            y = side * hw
+            leather.extend(
+                add_leather_panel(
+                    bm,
+                    Vector((hx0, y, z_bot)),
+                    Vector((hx1, y, z_bot)),
+                    top_under(-BOARD_L / 2.0, y),
+                    top_under(BOARD_L / 2.0 - 0.028, y),
+                    8,
+                    5,
+                    N_FOLDS,
+                    0.050,
+                    0.008,
+                    Vector((0.0, side, 0.0)),
+                    LEATHER_IDX,
+                )
+            )
+        leather.extend(
+            add_leather_panel(
+                bm,
+                Vector((hx0, -hw, z_bot)),
+                Vector((hx0, hw, z_bot)),
+                top_under(-BOARD_L / 2.0, -hw),
+                top_under(-BOARD_L / 2.0, hw),
+                6,
+                5,
+                N_FOLDS,
+                0.022,
+                0.008,
+                Vector((-1.0, 0.0, 0.0)),
+                LEATHER_IDX,
+            )
+        )
+
+        nz = z_bot + 0.028
+        metal.extend(
+            add_cyl(
+                bm,
+                (BOARD_L / 2.0 + NOZZLE_L / 2.0 - 0.01, 0.0, nz),
+                NOZZLE_R,
+                NOZZLE_L,
+                12,
+                METAL_IDX,
+                euler=(0.0, math.pi / 2.0, 0.0),
+            )
+        )
+        metal.extend(
+            add_cyl(
+                bm,
+                (BOARD_L / 2.0 + 0.006, 0.0, nz),
+                0.030,
+                0.018,
+                12,
+                METAL_IDX,
+                euler=(0.0, math.pi / 2.0, 0.0),
+            )
+        )
+        metal.extend(
+            add_cone(
+                bm,
+                (BOARD_L / 2.0 + NOZZLE_L - 0.012, 0.0, nz),
+                NOZZLE_R,
+                0.013,
+                0.042,
+                12,
+                METAL_IDX,
+                euler=(0.0, math.pi / 2.0, 0.0),
+            )
+        )
+        metal.extend(
+            add_box(
+                bm,
+                (BOARD_L / 2.0 - 0.04, 0.0, hinge.z),
+                (0.018, 0.070, 0.018),
+                METAL_IDX,
+            )
+        )
+
+        xs = [v.co.x for v in bm.verts]
+        ys = [v.co.y for v in bm.verts]
+        zs = [v.co.z for v in bm.verts]
+        rcx = 0.5 * (min(xs) + max(xs))
+        rcy = 0.5 * (min(ys) + max(ys))
+        zmin = min(zs)
+        for v in bm.verts:
+            v.co.x -= rcx
+            v.co.y -= rcy
+            v.co.z -= zmin
+            if v.co.z < 0.0:
+                v.co.z = 0.0
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = False
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+        for poly in me.polygons:
+            poly.use_smooth = False
+    finally:
+        bm.free()
+    out = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(out)
+    return out
+
+
+def principled(name, color, metallic, roughness):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def assign_slots(obj, wood, leather, metal):
+    mats = obj.data.materials
+    wanted = (wood, leather, metal)
+    for i, mat in enumerate(wanted):
+        if i < len(mats):
+            mats[i] = mat
+        else:
+            mats.append(mat)
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    overlap = 0.0
+    for i in range(len(aabbs)):
+        a = aabbs[i]
+        for j in range(i + 1, len(aabbs)):
+            b = aabbs[j]
+            x0 = max(a[0], b[0])
+            y0 = max(a[1], b[1])
+            x1 = min(a[2], b[2])
+            y1 = min(a[3], b[3])
+            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("BellowsNrm", size, size, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    nodes = target_mat.node_tree.nodes
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = img
+    nodes.active = tex
+    tex.select = True
+    obj.active_material_index = WOOD_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    deselect_all()
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL",
+        use_selected_to_active=True,
+        cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False,
+        normal_space="TANGENT",
+        margin=4,
+        margin_type="ADJACENT_FACES",
+        use_clear=True,
+        target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    deselect_all()
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path,
+        use_selection=True,
+        export_yup=True,
+        export_apply=True,
+        export_draco_mesh_compression_enable=False,
+        export_animations=False,
+    )
+
+
+def check(skip_decimate):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    low = build_bellows_mesh("BellowsLow", bevel_offset=0.004, bevel_segments=2)
+    high = build_bellows_mesh("BellowsHigh", bevel_offset=0.004, bevel_segments=4)
+    wood = principled("BellowsWood", (0.40, 0.22, 0.08, 1.0), 0.0, 0.58)
+    leather = principled("BellowsLeather", (0.45, 0.16, 0.08, 1.0), 0.0, 0.78)
+    metal = principled("BellowsIron", (0.16, 0.155, 0.15, 1.0), 1.0, 0.35)
+    assign_slots(low, wood, leather, metal)
+    assign_slots(high, wood, leather, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("bellows 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("bellows has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "BellowsLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "BellowsLOD2", 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_bellows_mesh(
+        "BellowsColSrc", bevel_offset=0.0, bevel_segments=1
+    )
+    collider = convex_hull_collider(collider_src, "BellowsCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_bellows_{os.getpid()}.glb",
+    )
+    if os.path.exists(export_path):
+        os.remove(export_path)
+    export_unity(export_path, [low, collider])
+    export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+    print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
+    print(
+        f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+        f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+    )
+    print(
+        f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+        f"overlap={overlap:.6f} nfaces={nfaces}"
+    )
+    print(
+        f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+        f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
+    )
+    print(
+        f"measured collider_tris={col_tris} bake={bake_result} "
+        f"bake_has_data={img.has_data} export_bytes={export_size}"
+    )
+
+    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+        return fail(
+            f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
+            4,
+        ), None, None, None, None, None
+    if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+        return fail(
+            f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+        return fail(
+            f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(LEATHER_IDX, 0) < LEATHER_FACES_MIN:
+        return fail(
+            f"leather faces {idx_counts.get(LEATHER_IDX, 0)} < {LEATHER_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN:
+        return fail(
+            f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+            6,
+        ), None, None, None, None, None
+    if overlap > UV_OVERLAP_MAX:
+        return fail(
+            f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+            7,
+        ), None, None, None, None, None
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+            f"off outer {OUTER_SIZE}",
+            8,
+        ), None, None, None, None, None
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)",
+            9,
+        ), None, None, None, None, None
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+            9,
+        ), None, None, None, None, None
+    if col_tris > COLLIDER_TRIS_MAX:
+        return fail(
+            f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+            11,
+        ), None, None, None, None, None
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return fail(
+            f"bake failed result={bake_result} has_data={img.has_data}",
+            12,
+        ), None, None, None, None, None
+    if export_size <= 0:
+        return fail("export file missing or empty", 13), None, None, None, None, None
+    return 0, low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, wood, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(wood, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(32.0)
+    low.rotation_euler.x = math.radians(0.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.6, -5.0, 5.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+    light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+    light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (-0.691, -0.861, 0.231)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (-0.04, 0.0, 0.11)
+    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, hero_mat, tex, _col = check(args.skip_decimate)
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, hero_mat, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("bellows 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/chopping-block/index.html b/docs/gallery/chopping-block/index.html new file mode 100644 index 0000000..d8ea858 --- /dev/null +++ b/docs/gallery/chopping-block/index.html @@ -0,0 +1,1127 @@ + + + + + + chopping-block — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

chopping-block

+

A procedural chopping block with an embedded axe 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: 2016 tris, two materials with 122 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.471×0.463×0.841 m, LOD ratios in band (5.2 COLLAPSE 2 tris leaner on LOD2), convex collider 188 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/chopping-block/chopping_block.py --
+ +
+
+

A showcase piece, not an example. Procedural chopping block (hooped timber stump with an embedded axe) 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 | 1880–2160 | 2016 / 2016 / 2016 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2192 / 0.2192 / 0.2183 | | Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 122 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.471, 0.463, 0.841) m ± 0.01 | (0.4713, 0.4626, 0.8413), zmin 0 | | Collider tris | ≤ 220 | 188 | | Export | written, size > 0 | 157000 / 157000 / 156992 bytes |

+

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is 2 tris leaner on LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 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 chopping_block.py --
+blender --background --python chopping_block.py -- --skip-decimate
+blender --background --python chopping_block.py -- --output chopping-block.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/chopping-block/chopping_block.py + View on GitHub → +
+
"""Game-ready chopping block — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural chopping block (hooped timber
+stump with an embedded axe) 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 chopping_block.py --
+    blender --background --python chopping_block.py -- --skip-decimate
+    blender --background --python chopping_block.py -- --output chopping-block.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Matrix, Vector
+
+_REPO = os.path.abspath(
+    os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing  # noqa: E402
+
+STUMP_R = 0.22
+STUMP_H = 0.36
+STUMP_SEGS = 20
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (0.471, 0.463, 0.841)
+BASE_TRIS_MIN = 1880
+BASE_TRIS_MAX = 2160
+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 = 220
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 24
+WOOD_FACES_MIN = 12
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    depsgraph = bpy.context.evaluated_depsgraph_get()
+    eval_obj = obj.evaluated_get(depsgraph)
+    eval_mesh = eval_obj.to_mesh()
+    try:
+        eval_mesh.calc_loop_triangles()
+        return len(eval_mesh.loop_triangles)
+    finally:
+        eval_obj.to_mesh_clear()
+
+
+def deselect_all():
+    for ob in list(bpy.context.view_layer.objects):
+        if ob is None:
+            continue
+        ob.select_set(False)
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+        v.co = rot @ p + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_oriented_box(bm, a, b, scale_xy, mat_idx):
+    a = Vector(a)
+    b = Vector(b)
+    delta = b - a
+    length = delta.length
+    if length < 1e-8:
+        return []
+    quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+    eul = quat.to_euler("XYZ")
+    return add_box(
+        bm,
+        ((a + b) * 0.5),
+        (scale_xy[0], scale_xy[1], length),
+        mat_idx,
+        euler=(eul.x, eul.y, eul.z),
+    )
+
+
+def add_cyl_between(bm, a, b, radius, segments, mat_idx):
+    a = Vector(a)
+    b = Vector(b)
+    delta = b - a
+    length = delta.length
+    if length < 1e-8:
+        return []
+    quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+    eul = quat.to_euler("XYZ")
+    return add_cyl(
+        bm,
+        ((a + b) * 0.5),
+        radius,
+        length,
+        segments,
+        mat_idx,
+        euler=(eul.x, eul.y, eul.z),
+    )
+
+
+def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius,
+        radius2=radius,
+        depth=depth,
+    )
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius1,
+        radius2=radius2,
+        depth=depth,
+    )
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)):
+    n_major = 14
+    n_minor = 7
+    rings = []
+    for i in range(n_major):
+        u = i * (2.0 * math.pi / n_major)
+        ring = []
+        for j in range(n_minor):
+            v = j * (2.0 * math.pi / n_minor)
+            x = (major + minor * math.cos(v)) * math.cos(u)
+            y = (major + minor * math.cos(v)) * math.sin(u)
+            z = minor * math.sin(v)
+            ring.append(bm.verts.new((x, y, z)))
+        rings.append(ring)
+    bm.verts.ensure_lookup_table()
+    for i in range(n_major):
+        i2 = (i + 1) % n_major
+        for j in range(n_minor):
+            j2 = (j + 1) % n_minor
+            face = bm.faces.new(
+                (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2])
+            )
+            face.material_index = mat_idx
+    verts = [v for ring in rings for v in ring]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    return verts
+
+
+def add_square_band(bm, z, half, t, h, mat_idx):
+    metal = []
+    metal.extend(add_box(bm, (0.0, half + t / 2.0, z), (2.0 * half + 2.0 * t, t, h), mat_idx))
+    metal.extend(add_box(bm, (0.0, -(half + t / 2.0), z), (2.0 * half + 2.0 * t, t, h), mat_idx))
+    metal.extend(add_box(bm, (half + t / 2.0, 0.0, z), (t, 2.0 * half, h), mat_idx))
+    metal.extend(add_box(bm, (-(half + t / 2.0), 0.0, z), (t, 2.0 * half, h), mat_idx))
+    return metal
+
+
+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_chopping_block_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    try:
+        wood = []
+        metal = []
+
+        wood.extend(
+            add_cone(
+                bm,
+                (0.0, 0.0, STUMP_H / 2.0),
+                STUMP_R + 0.012,
+                STUMP_R - 0.018,
+                STUMP_H,
+                STUMP_SEGS,
+                WOOD_IDX,
+            )
+        )
+        wood.extend(
+            add_cyl(
+                bm,
+                (0.0, 0.0, STUMP_H + 0.008),
+                STUMP_R - 0.020,
+                0.016,
+                STUMP_SEGS,
+                WOOD_IDX,
+            )
+        )
+        wood.extend(
+            add_box(
+                bm,
+                (0.04, 0.0, STUMP_H + 0.014),
+                (0.16, 0.012, 0.012),
+                WOOD_IDX,
+            )
+        )
+
+        h0 = Vector((-0.20, -0.14, STUMP_H + 0.44))
+        h1 = Vector((0.05, 0.03, STUMP_H + 0.02))
+        wood.extend(add_cyl_between(bm, h0, h1, 0.018, 10, WOOD_IDX))
+        pommel = h0 + (h0 - h1).normalized() * 0.035
+        wood.extend(add_cyl_between(bm, h0, pommel, 0.026, 10, WOOD_IDX))
+
+        if bevel_offset > 0.0:
+            edges = list({e for v in wood for e in v.link_edges})
+            ret = bmesh.ops.bevel(
+                bm,
+                geom=edges,
+                offset=bevel_offset,
+                segments=bevel_segments,
+                profile=0.5,
+                affect="EDGES",
+                clamp_overlap=True,
+            )
+            for f in ret.get("faces") or []:
+                f.material_index = WOOD_IDX
+
+        metal.extend(
+            add_rim(bm, (0.0, 0.0, 0.075), STUMP_R + 0.002, 0.012, METAL_IDX)
+        )
+
+        axis = (h1 - h0).normalized()
+        bit_up = Vector((0.0, 0.0, 1.0))
+        bit_right = axis.cross(bit_up)
+        if bit_right.length < 0.15:
+            bit_right = axis.cross(Vector((0.0, 1.0, 0.0)))
+        bit_right.normalize()
+        bit_up = bit_right.cross(axis).normalized()
+        rot_mat = Matrix((axis, bit_right, bit_up)).transposed()
+        eul = rot_mat.to_euler("XYZ")
+        head_e = (eul.x, eul.y, eul.z)
+        cheek = h1 - axis * 0.015
+        metal.extend(
+            add_box(bm, cheek, (0.10, 0.044, 0.078), METAL_IDX, euler=head_e)
+        )
+        poll = h1 - axis * 0.072
+        metal.extend(
+            add_box(bm, poll, (0.048, 0.058, 0.058), METAL_IDX, euler=head_e)
+        )
+        bit = h1 + axis * 0.045
+        metal.extend(
+            add_box(bm, bit, (0.055, 0.014, 0.175), METAL_IDX, euler=head_e)
+        )
+        tip = h1 + axis * 0.082
+        metal.extend(
+            add_box(bm, tip, (0.028, 0.008, 0.195), METAL_IDX, euler=head_e)
+        )
+
+        xs = [v.co.x for v in bm.verts]
+        ys = [v.co.y for v in bm.verts]
+        zs = [v.co.z for v in bm.verts]
+        rcx = 0.5 * (min(xs) + max(xs))
+        rcy = 0.5 * (min(ys) + max(ys))
+        zmin = min(zs)
+        for v in bm.verts:
+            v.co.x -= rcx
+            v.co.y -= rcy
+            v.co.z -= zmin
+            if v.co.z < 0.0:
+                v.co.z = 0.0
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = False
+        me = bpy.data.meshes.new(name)
+        bm.to_mesh(me)
+        me.update()
+        for poly in me.polygons:
+            poly.use_smooth = False
+    finally:
+        bm.free()
+    out = bpy.data.objects.new(name, me)
+    bpy.context.collection.objects.link(out)
+    return out
+
+
+def principled(name, color, metallic, roughness):
+    mat = bpy.data.materials.new(name)
+    mat.use_nodes = True
+    bsdf = mat.node_tree.nodes["Principled BSDF"]
+    bsdf.inputs["Base Color"].default_value = color
+    bsdf.inputs["Metallic"].default_value = metallic
+    bsdf.inputs["Roughness"].default_value = roughness
+    return mat
+
+
+def assign_slots(obj, wood, metal):
+    mats = obj.data.materials
+    wanted = (wood, metal)
+    for i, mat in enumerate(wanted):
+        if i < len(mats):
+            mats[i] = mat
+        else:
+            mats.append(mat)
+
+
+def world_bbox(obj):
+    corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+    xs = [c.x for c in corners]
+    ys = [c.y for c in corners]
+    zs = [c.z for c in corners]
+    return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+    uv = mesh.uv_layers.active
+    if uv is None:
+        return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+    data = uv.data
+    us = [loop.uv[0] for loop in data]
+    vs = [loop.uv[1] for loop in data]
+    aabbs = []
+    for poly in mesh.polygons:
+        pu = [data[i].uv[0] for i in poly.loop_indices]
+        pv = [data[i].uv[1] for i in poly.loop_indices]
+        aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+    overlap = 0.0
+    for i in range(len(aabbs)):
+        a = aabbs[i]
+        for j in range(i + 1, len(aabbs)):
+            b = aabbs[j]
+            x0 = max(a[0], b[0])
+            y0 = max(a[1], b[1])
+            x1 = min(a[2], b[2])
+            y1 = min(a[3], b[3])
+            overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+    return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+    mesh = obj.data.copy()
+    lod = bpy.data.objects.new(name, mesh)
+    lod.matrix_world = obj.matrix_world.copy()
+    bpy.context.scene.collection.objects.link(lod)
+    if not skip_decimate and 0.0 < ratio < 1.0:
+        mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+        mod.decimate_type = "COLLAPSE"
+        mod.ratio = ratio
+    return lod
+
+
+def convex_hull_collider(obj, name):
+    mesh = bpy.data.meshes.new(name)
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(obj.data)
+        result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+        interior = result.get("geom_interior") or []
+        unused = result.get("geom_unused") or []
+        if interior:
+            bmesh.ops.delete(bm, geom=interior, context="VERTS")
+        if unused:
+            bmesh.ops.delete(bm, geom=unused, context="VERTS")
+        bm.to_mesh(mesh)
+        mesh.update()
+    finally:
+        bm.free()
+    collider = bpy.data.objects.new(name, mesh)
+    bpy.context.collection.objects.link(collider)
+    collider.matrix_world = obj.matrix_world.copy()
+    return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+    if not obj.data.uv_layers:
+        return None, None
+    img = bpy.data.images.new("ChopBlockNrm", size, size, alpha=True, float_buffer=False)
+    img.colorspace_settings.name = "Non-Color"
+    nodes = target_mat.node_tree.nodes
+    tex = nodes.new("ShaderNodeTexImage")
+    tex.image = img
+    nodes.active = tex
+    tex.select = True
+    obj.active_material_index = WOOD_IDX
+    return img, tex
+
+
+def bake_normal(high, low):
+    scene = bpy.context.scene
+    scene.render.engine = "CYCLES"
+    scene.cycles.device = "CPU"
+    scene.cycles.samples = 1
+    scene.cycles.use_denoising = False
+    deselect_all()
+    high.select_set(True)
+    low.select_set(True)
+    bpy.context.view_layer.objects.active = low
+    return bpy.ops.object.bake(
+        type="NORMAL",
+        use_selected_to_active=True,
+        cage_extrusion=CAGE_EXTRUSION,
+        use_cage=False,
+        normal_space="TANGENT",
+        margin=4,
+        margin_type="ADJACENT_FACES",
+        use_clear=True,
+        target="IMAGE_TEXTURES",
+    )
+
+
+def export_unity(path, objects):
+    deselect_all()
+    for ob in objects:
+        ob.select_set(True)
+    bpy.context.view_layer.objects.active = objects[0]
+    bpy.ops.export_scene.gltf(
+        filepath=path,
+        use_selection=True,
+        export_yup=True,
+        export_apply=True,
+        export_draco_mesh_compression_enable=False,
+        export_animations=False,
+    )
+
+
+def check(skip_decimate):
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+    low = build_chopping_block_mesh("ChopBlockLow", bevel_offset=0.004, bevel_segments=2)
+    high = build_chopping_block_mesh("ChopBlockHigh", bevel_offset=0.004, bevel_segments=4)
+    wood = principled("ChopBlockWood", (0.38, 0.20, 0.08, 1.0), 0.0, 0.62)
+    metal = principled("ChopBlockIron", (0.22, 0.22, 0.23, 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("chopping block 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("chopping block has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "ChopBlockLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "ChopBlockLOD2", 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_chopping_block_mesh(
+        "ChopBlockColSrc", bevel_offset=0.0, bevel_segments=1
+    )
+    collider = convex_hull_collider(collider_src, "ChopBlockCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_chopping_block_{os.getpid()}.glb",
+    )
+    if os.path.exists(export_path):
+        os.remove(export_path)
+    export_unity(export_path, [low, collider])
+    export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+    print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
+    print(
+        f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+        f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+    )
+    print(
+        f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+        f"overlap={overlap:.6f} nfaces={nfaces}"
+    )
+    print(
+        f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+        f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
+    )
+    print(
+        f"measured collider_tris={col_tris} bake={bake_result} "
+        f"bake_has_data={img.has_data} export_bytes={export_size}"
+    )
+
+    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+        return fail(
+            f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
+            4,
+        ), None, None, None, None, None
+    if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+        return fail(
+            f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+        return fail(
+            f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN:
+        return fail(
+            f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+            6,
+        ), None, None, None, None, None
+    if overlap > UV_OVERLAP_MAX:
+        return fail(
+            f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+            7,
+        ), None, None, None, None, None
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+            f"off outer {OUTER_SIZE}",
+            8,
+        ), None, None, None, None, None
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)",
+            9,
+        ), None, None, None, None, None
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+            9,
+        ), None, None, None, None, None
+    if col_tris > COLLIDER_TRIS_MAX:
+        return fail(
+            f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+            11,
+        ), None, None, None, None, None
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return fail(
+            f"bake failed result={bake_result} has_data={img.has_data}",
+            12,
+        ), None, None, None, None, None
+    if export_size <= 0:
+        return fail("export file missing or empty", 13), None, None, None, None, None
+    return 0, low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, wood, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(wood, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-48.0)
+    low.rotation_euler.x = math.radians(0.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.6, -5.0, 5.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+    light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+    light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (1.572, -2.141, 1.630)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.40)
+    scene.collection.objects.link(aim)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+        scene.cycles.device = "CPU"
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = (
+        "WEBP" if path.lower().endswith(".webp") else "PNG"
+    )
+    if path.lower().endswith(".webp"):
+        scene.render.image_settings.quality = 90
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    fcode = gallery_framing.check_framing(
+        scene, cam, hero=[low], elements=[low], stage=[floor, wall],
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        return fail("render produced no file", 14)
+    return 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None)
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    p.add_argument(
+        "--skip-decimate",
+        action="store_true",
+        help="falsification: skip the LOD DECIMATE stage",
+    )
+    args = p.parse_args(argv)
+
+    code, low, _high, wood, tex, _col = check(args.skip_decimate)
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("chopping-block 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 e56f61b..846bcea 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" /> - 70 examples + 72 examples
@@ -1094,6 +1094,28 @@

signpost

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

bellows

+

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

+

witnesses Recomputed: 1272 tris, three materials with 294 leather and 48 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.630×0.403×0.277 m, LOD ratios in band (5.2 COLLAPSE 2 tris leaner on LOD2), convex collider 148 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+ View example +
+
+
+ + chopping-block — A procedural chopping block with an embedded axe through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract. + +
+

chopping-block

+

A procedural chopping block with an embedded axe through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 2016 tris, two materials with 122 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.471×0.463×0.841 m, LOD ratios in band (5.2 COLLAPSE 2 tris leaner on LOD2), convex collider 188 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+ View example +
+
diff --git a/showcase/bellows/README.md b/showcase/bellows/README.md new file mode 100644 index 0000000..7fd0d4f --- /dev/null +++ b/showcase/bellows/README.md @@ -0,0 +1,73 @@ +# Bellows + +A showcase piece, not an example. Procedural blacksmith bellows (hinged +timber paddles, leather bag, iron nozzle) 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 | 1180–1380 | 1272 / 1272 / 1272 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2186 / 0.2186 / 0.2170 | +| Materials | exactly 3 distinct, ≥24 leather and metal faces | 3 slots, 294 leather, 48 metal | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (0.630, 0.403, 0.277) m ± 0.01 | (0.6303, 0.4031, 0.2765), zmin 0 | +| Collider tris | ≤ 180 | 148 | +| Export | written, size > 0 | 105796 / 105796 / 105788 bytes | + +DECIMATE COLLAPSE triangle counts are **not** identical across series — +5.2.1 is 2 tris leaner on LOD2. The gate is a ratio band, not an exact +count. Bake pixels are stochastic; the gate is `has_data` plus operator +`FINISHED`, not byte-identity. Construction uses no RNG. Export byte +counts differ by 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 bellows.py -- +blender --background --python bellows.py -- --skip-decimate +blender --background --python bellows.py -- --output bellows.png +``` + +Smoke does not pass `--output` or `--skip-decimate`. + +## Exit codes + +File-local. `9` is a valid check code. `10` is reserved for +`gallery_framing.check_framing` on the `--output` path. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build / no UV layer | +| 4 | Base triangle count outside range | +| 5 | Material count ≠ 3 distinct slots, or wood/leather/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/bellows/bellows.py b/showcase/bellows/bellows.py new file mode 100644 index 0000000..f41ed87 --- /dev/null +++ b/showcase/bellows/bellows.py @@ -0,0 +1,905 @@ +"""Game-ready blacksmith bellows — a showcase piece, not an example. + +Asserts budget conformance of a procedural bellows (hinged timber +paddles, leather accordion, iron nozzle) after composing shipped +pipeline pieces: bmesh construction, UVs, three materials, high-to-low +normal bake, LOD chain, convex collider, Unity glTF export. + +Budgets are declared below and recomputed from the generated result. +They are not API-contract witnesses. ``--skip-decimate`` skips the LOD +DECIMATE stage so the LOD-ratio budget fails. + +No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts +are not byte-identical across Blender versions — the LOD gate is a +ratio band, not an exact count. + + blender --background --python bellows.py -- + blender --background --python bellows.py -- --skip-decimate + blender --background --python bellows.py -- --output bellows.png +""" +import argparse +import math +import os +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Euler, Vector + +_REPO = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) +) +sys.path.insert(0, os.path.join(_REPO, "examples")) +sys.dont_write_bytecode = True +import gallery_framing # noqa: E402 + +BOARD_L = 0.52 +BOARD_W = 0.30 +BOARD_T = 0.034 +OPEN = 0.26 +N_FOLDS = 5 +NOZZLE_L = 0.10 +NOZZLE_R = 0.018 + +BBOX_TOL = 0.01 +OUTER_SIZE = (0.630, 0.403, 0.277) +BASE_TRIS_MIN = 1180 +BASE_TRIS_MAX = 1380 +LOD1_RATIO_MIN = 0.32 +LOD1_RATIO_MAX = 0.62 +LOD2_RATIO_MIN = 0.10 +LOD2_RATIO_MAX = 0.35 +LOD1_TARGET = 0.50 +LOD2_TARGET = 0.22 +MATERIAL_COUNT = 3 +UV_EPS = 1e-4 +UV_OVERLAP_MAX = 1e-5 +COLLIDER_TRIS_MAX = 180 +BAKE_RES = 256 +CAGE_EXTRUSION = 0.08 +METAL_FACES_MIN = 24 +WOOD_FACES_MIN = 12 +LEATHER_FACES_MIN = 24 + +WOOD_IDX = 0 +LEATHER_IDX = 1 +METAL_IDX = 2 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def deselect_all(): + for ob in list(bpy.context.view_layer.objects): + if ob is None: + continue + ob.select_set(False) + + +def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) + v.co = rot @ p + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_oriented_box(bm, a, b, scale_xy, mat_idx): + a = Vector(a) + b = Vector(b) + delta = b - a + length = delta.length + if length < 1e-8: + return [] + quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) + eul = quat.to_euler("XYZ") + return add_box( + bm, + ((a + b) * 0.5), + (scale_xy[0], scale_xy[1], length), + mat_idx, + euler=(eul.x, eul.y, eul.z), + ) + + +def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius, + radius2=radius, + depth=depth, + ) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def _quad(bm, verts, mat_idx): + try: + face = bm.faces.new(verts) + except ValueError: + return None + face.material_index = mat_idx + return face + + +def add_leather_panel( + bm, a0, a1, b0, b1, n_u, n_v, folds, bulge, thickness, outward, mat_idx +): + a0 = Vector(a0) + a1 = Vector(a1) + b0 = Vector(b0) + b1 = Vector(b1) + outward = Vector(outward) + if outward.length < 1e-8: + return [] + outward = outward.normalized() + grid_o = [] + grid_i = [] + collected = [] + for iv in range(n_v + 1): + tv = iv / n_v + row_o = [] + row_i = [] + for iu in range(n_u + 1): + tu = iu / n_u + p = a0.lerp(a1, tu).lerp(b0.lerp(b1, tu), tv) + fold = abs(math.sin(tu * folds * math.pi)) + puff = math.sin(tv * math.pi) + p = p + outward * (0.003 + bulge * fold * puff) + vo = bm.verts.new(p + outward * (thickness * 0.5)) + vi = bm.verts.new(p - outward * (thickness * 0.5)) + row_o.append(vo) + row_i.append(vi) + collected.extend((vo, vi)) + grid_o.append(row_o) + grid_i.append(row_i) + bm.verts.ensure_lookup_table() + for iv in range(n_v): + for iu in range(n_u): + _quad( + bm, + ( + grid_o[iv][iu], + grid_o[iv][iu + 1], + grid_o[iv + 1][iu + 1], + grid_o[iv + 1][iu], + ), + mat_idx, + ) + _quad( + bm, + ( + grid_i[iv][iu + 1], + grid_i[iv][iu], + grid_i[iv + 1][iu], + grid_i[iv + 1][iu + 1], + ), + mat_idx, + ) + for iu in range(n_u): + _quad( + bm, + (grid_o[0][iu], grid_i[0][iu], grid_i[0][iu + 1], grid_o[0][iu + 1]), + mat_idx, + ) + _quad( + bm, + ( + grid_o[n_v][iu + 1], + grid_i[n_v][iu + 1], + grid_i[n_v][iu], + grid_o[n_v][iu], + ), + mat_idx, + ) + for iv in range(n_v): + _quad( + bm, + (grid_o[iv][0], grid_o[iv + 1][0], grid_i[iv + 1][0], grid_i[iv][0]), + mat_idx, + ) + _quad( + bm, + ( + grid_o[iv][n_u], + grid_i[iv][n_u], + grid_i[iv + 1][n_u], + grid_o[iv + 1][n_u], + ), + mat_idx, + ) + return collected + + +def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius1, + radius2=radius2, + depth=depth, + ) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def pack_uvs(bm, margin=0.08): + uv = bm.loops.layers.uv.new("UVMap") + faces = list(bm.faces) + n = len(faces) + cols = max(1, math.ceil(math.sqrt(n))) + rows = max(1, math.ceil(n / cols)) + cell_w = 1.0 / cols + cell_h = 1.0 / rows + pad_u = margin * cell_w * 0.5 + pad_v = margin * cell_h * 0.5 + usable_w = cell_w - 2.0 * pad_u + usable_h = cell_h - 2.0 * pad_v + for i, face in enumerate(faces): + col = i % cols + row = i // cols + nrm = face.normal + ax = abs(nrm.x) + ay = abs(nrm.y) + az = abs(nrm.z) + coords = [] + for loop in face.loops: + co = loop.vert.co + if az >= ax and az >= ay: + coords.append((co.x, co.y)) + elif ax >= ay: + coords.append((co.y, co.z)) + else: + coords.append((co.x, co.z)) + xs = [c[0] for c in coords] + ys = [c[1] for c in coords] + minx, maxx = min(xs), max(xs) + miny, maxy = min(ys), max(ys) + dx = max(maxx - minx, 1e-8) + dy = max(maxy - miny, 1e-8) + origin_u = col * cell_w + pad_u + origin_v = row * cell_h + pad_v + for loop, (x, y) in zip(face.loops, coords): + loop[uv].uv = ( + origin_u + (x - minx) / dx * usable_w, + origin_v + (y - miny) / dy * usable_h, + ) + + +def build_bellows_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + try: + wood = [] + leather = [] + metal = [] + z_bot = BOARD_T + open_ang = math.atan2(OPEN, BOARD_L) + hinge = Vector((BOARD_L / 2.0 - 0.018, 0.0, z_bot + 0.006)) + + wood.extend( + add_box( + bm, + (0.0, 0.0, BOARD_T / 2.0), + (BOARD_L, BOARD_W, BOARD_T), + WOOD_IDX, + ) + ) + + rot = Euler((0.0, -open_ang, 0.0)).to_matrix() + local_center = Vector((-BOARD_L / 2.0 + 0.018, 0.0, BOARD_T / 2.0)) + top_c = hinge + rot @ local_center + wood.extend( + add_box( + bm, + (top_c.x, top_c.y, top_c.z), + (BOARD_L, BOARD_W, BOARD_T), + WOOD_IDX, + euler=(0.0, -open_ang, 0.0), + ) + ) + for y in (-0.065, 0.065): + p0 = hinge + rot @ Vector((-BOARD_L / 2.0 + 0.055, y, BOARD_T)) + p1 = hinge + rot @ Vector((-BOARD_L / 2.0 + 0.055, y, BOARD_T + 0.072)) + wood.extend(add_oriented_box(bm, p0, p1, (0.022, 0.022), WOOD_IDX)) + g0 = hinge + rot @ Vector((-BOARD_L / 2.0 + 0.055, -0.085, BOARD_T + 0.072)) + g1 = hinge + rot @ Vector((-BOARD_L / 2.0 + 0.055, 0.085, BOARD_T + 0.072)) + wood.extend(add_oriented_box(bm, g0, g1, (0.018, 0.018), WOOD_IDX)) + + if bevel_offset > 0.0: + edges = list({e for v in wood for e in v.link_edges}) + ret = bmesh.ops.bevel( + bm, + geom=edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect='EDGES', + clamp_overlap=True, + ) + for f in ret.get('faces') or []: + f.material_index = WOOD_IDX + + hw = BOARD_W / 2.0 - 0.003 + hx0 = -BOARD_L / 2.0 + 0.010 + hx1 = BOARD_L / 2.0 - 0.036 + + def top_under(lx, ly): + return hinge + rot @ (local_center + Vector((lx, ly, -BOARD_T / 2.0))) + + for side in (-1.0, 1.0): + y = side * hw + leather.extend( + add_leather_panel( + bm, + Vector((hx0, y, z_bot)), + Vector((hx1, y, z_bot)), + top_under(-BOARD_L / 2.0, y), + top_under(BOARD_L / 2.0 - 0.028, y), + 8, + 5, + N_FOLDS, + 0.050, + 0.008, + Vector((0.0, side, 0.0)), + LEATHER_IDX, + ) + ) + leather.extend( + add_leather_panel( + bm, + Vector((hx0, -hw, z_bot)), + Vector((hx0, hw, z_bot)), + top_under(-BOARD_L / 2.0, -hw), + top_under(-BOARD_L / 2.0, hw), + 6, + 5, + N_FOLDS, + 0.022, + 0.008, + Vector((-1.0, 0.0, 0.0)), + LEATHER_IDX, + ) + ) + + nz = z_bot + 0.028 + metal.extend( + add_cyl( + bm, + (BOARD_L / 2.0 + NOZZLE_L / 2.0 - 0.01, 0.0, nz), + NOZZLE_R, + NOZZLE_L, + 12, + METAL_IDX, + euler=(0.0, math.pi / 2.0, 0.0), + ) + ) + metal.extend( + add_cyl( + bm, + (BOARD_L / 2.0 + 0.006, 0.0, nz), + 0.030, + 0.018, + 12, + METAL_IDX, + euler=(0.0, math.pi / 2.0, 0.0), + ) + ) + metal.extend( + add_cone( + bm, + (BOARD_L / 2.0 + NOZZLE_L - 0.012, 0.0, nz), + NOZZLE_R, + 0.013, + 0.042, + 12, + METAL_IDX, + euler=(0.0, math.pi / 2.0, 0.0), + ) + ) + metal.extend( + add_box( + bm, + (BOARD_L / 2.0 - 0.04, 0.0, hinge.z), + (0.018, 0.070, 0.018), + METAL_IDX, + ) + ) + + xs = [v.co.x for v in bm.verts] + ys = [v.co.y for v in bm.verts] + zs = [v.co.z for v in bm.verts] + rcx = 0.5 * (min(xs) + max(xs)) + rcy = 0.5 * (min(ys) + max(ys)) + zmin = min(zs) + for v in bm.verts: + v.co.x -= rcx + v.co.y -= rcy + v.co.z -= zmin + if v.co.z < 0.0: + v.co.z = 0.0 + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = False + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + for poly in me.polygons: + poly.use_smooth = False + finally: + bm.free() + out = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(out) + return out + + +def principled(name, color, metallic, roughness): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = color + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + return mat + + +def assign_slots(obj, wood, leather, metal): + mats = obj.data.materials + wanted = (wood, leather, metal) + for i, mat in enumerate(wanted): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0, 1.0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + overlap = 0.0 + for i in range(len(aabbs)): + a = aabbs[i] + for j in range(i + 1, len(aabbs)): + b = aabbs[j] + x0 = max(a[0], b[0]) + y0 = max(a[1], b[1]) + x1 = min(a[2], b[2]) + y1 = min(a[3], b[3]) + overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def convex_hull_collider(obj, name): + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) + interior = result.get("geom_interior") or [] + unused = result.get("geom_unused") or [] + if interior: + bmesh.ops.delete(bm, geom=interior, context="VERTS") + if unused: + bmesh.ops.delete(bm, geom=unused, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + collider = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +def setup_bake_image(obj, target_mat, size=BAKE_RES): + if not obj.data.uv_layers: + return None, None + img = bpy.data.images.new("BellowsNrm", size, size, alpha=True, float_buffer=False) + img.colorspace_settings.name = "Non-Color" + nodes = target_mat.node_tree.nodes + tex = nodes.new("ShaderNodeTexImage") + tex.image = img + nodes.active = tex + tex.select = True + obj.active_material_index = WOOD_IDX + return img, tex + + +def bake_normal(high, low): + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + deselect_all() + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + return bpy.ops.object.bake( + type="NORMAL", + use_selected_to_active=True, + cage_extrusion=CAGE_EXTRUSION, + use_cage=False, + normal_space="TANGENT", + margin=4, + margin_type="ADJACENT_FACES", + use_clear=True, + target="IMAGE_TEXTURES", + ) + + +def export_unity(path, objects): + deselect_all() + for ob in objects: + ob.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=False, + export_animations=False, + ) + + +def check(skip_decimate): + bpy.ops.wm.read_factory_settings(use_empty=True) + low = build_bellows_mesh("BellowsLow", bevel_offset=0.004, bevel_segments=2) + high = build_bellows_mesh("BellowsHigh", bevel_offset=0.004, bevel_segments=4) + wood = principled("BellowsWood", (0.40, 0.22, 0.08, 1.0), 0.0, 0.58) + leather = principled("BellowsLeather", (0.45, 0.16, 0.08, 1.0), 0.0, 0.78) + metal = principled("BellowsIron", (0.16, 0.155, 0.15, 1.0), 1.0, 0.35) + assign_slots(low, wood, leather, metal) + assign_slots(high, wood, leather, metal) + + if low.data is None or len(low.data.polygons) < 6: + return fail("bellows 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("bellows has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "BellowsLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "BellowsLOD2", 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_bellows_mesh( + "BellowsColSrc", bevel_offset=0.0, bevel_segments=1 + ) + collider = convex_hull_collider(collider_src, "BellowsCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_bellows_{os.getpid()}.glb", + ) + if os.path.exists(export_path): + os.remove(export_path) + export_unity(export_path, [low, collider]) + export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") + print( + f"measured base_tris={base_tris} lod1_tris={lod1_tris} " + f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" + ) + print( + f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " + f"overlap={overlap:.6f} nfaces={nfaces}" + ) + print( + f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"outer={OUTER_SIZE} zmin={bb[2]:.4f}" + ) + print( + f"measured collider_tris={col_tris} bake={bake_result} " + f"bake_has_data={img.has_data} export_bytes={export_size}" + ) + + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): + return fail( + f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", + 4, + ), None, None, None, None, None + if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT: + return fail( + f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", + 5, + ), None, None, None, None, None + if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN: + return fail( + f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}", + 5, + ), None, None, None, None, None + if idx_counts.get(LEATHER_IDX, 0) < LEATHER_FACES_MIN: + return fail( + f"leather faces {idx_counts.get(LEATHER_IDX, 0)} < {LEATHER_FACES_MIN}", + 5, + ), None, None, None, None, None + if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN: + return fail( + f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}", + 5, + ), None, None, None, None, None + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return fail( + f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", + 6, + ), None, None, None, None, None + if overlap > UV_OVERLAP_MAX: + return fail( + f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", + 7, + ), None, None, None, None, None + if ( + abs(size_x - OUTER_SIZE[0]) > BBOX_TOL + or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL + ): + return fail( + f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"off outer {OUTER_SIZE}", + 8, + ), None, None, None, None, None + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return fail( + f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", + 9, + ), None, None, None, None, None + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return fail( + f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", + 9, + ), None, None, None, None, None + if col_tris > COLLIDER_TRIS_MAX: + return fail( + f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", + 11, + ), None, None, None, None, None + if bake_result != {"FINISHED"} or not img.has_data: + return fail( + f"bake failed result={bake_result} has_data={img.has_data}", + 12, + ), None, None, None, None, None + if export_size <= 0: + return fail("export file missing or empty", 13), None, None, None, None, None + return 0, low, high, wood, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + nrm = nt.nodes.new("ShaderNodeNormalMap") + nrm.inputs["Strength"].default_value = 1.0 + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) + + +def render_still(low, wood, tex, path, engine): + scene = bpy.context.scene + wire_normal(wood, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(32.0) + low.rotation_euler.x = math.radians(0.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 8.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-3.6, -5.0, 5.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) + light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) + light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (-0.691, -0.861, 0.231) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (-0.04, 0.0, 0.11) + 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, hero_mat, tex, _col = check(args.skip_decimate) + if code: + return code + if args.output: + rcode = render_still(low, hero_mat, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("bellows 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/bellows/preview.webp b/showcase/bellows/preview.webp new file mode 100644 index 0000000..4b0deee Binary files /dev/null and b/showcase/bellows/preview.webp differ diff --git a/showcase/chopping-block/README.md b/showcase/chopping-block/README.md new file mode 100644 index 0000000..76d76c0 --- /dev/null +++ b/showcase/chopping-block/README.md @@ -0,0 +1,73 @@ +# Chopping block + +A showcase piece, not an example. Procedural chopping block (hooped +timber stump with an embedded axe) 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 | 1880–2160 | 2016 / 2016 / 2016 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2192 / 0.2192 / 0.2183 | +| Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 122 metal | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (0.471, 0.463, 0.841) m ± 0.01 | (0.4713, 0.4626, 0.8413), zmin 0 | +| Collider tris | ≤ 220 | 188 | +| Export | written, size > 0 | 157000 / 157000 / 156992 bytes | + +DECIMATE COLLAPSE triangle counts are **not** identical across series — +5.2.1 is 2 tris leaner on LOD2. The gate is a ratio band, not an exact +count. Bake pixels are stochastic; the gate is `has_data` plus operator +`FINISHED`, not byte-identity. Construction uses no RNG. Export byte +counts differ by 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 chopping_block.py -- +blender --background --python chopping_block.py -- --skip-decimate +blender --background --python chopping_block.py -- --output chopping-block.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/chopping-block/chopping_block.py b/showcase/chopping-block/chopping_block.py new file mode 100644 index 0000000..459ef49 --- /dev/null +++ b/showcase/chopping-block/chopping_block.py @@ -0,0 +1,803 @@ +"""Game-ready chopping block — a showcase piece, not an example. + +Asserts budget conformance of a procedural chopping block (hooped timber +stump with an embedded axe) 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 chopping_block.py -- + blender --background --python chopping_block.py -- --skip-decimate + blender --background --python chopping_block.py -- --output chopping-block.png +""" +import argparse +import math +import os +import sys +import tempfile +import traceback + +import bmesh +import bpy +from mathutils import Euler, Matrix, Vector + +_REPO = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) +) +sys.path.insert(0, os.path.join(_REPO, "examples")) +sys.dont_write_bytecode = True +import gallery_framing # noqa: E402 + +STUMP_R = 0.22 +STUMP_H = 0.36 +STUMP_SEGS = 20 + +BBOX_TOL = 0.01 +OUTER_SIZE = (0.471, 0.463, 0.841) +BASE_TRIS_MIN = 1880 +BASE_TRIS_MAX = 2160 +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 = 220 +BAKE_RES = 256 +CAGE_EXTRUSION = 0.08 +METAL_FACES_MIN = 24 +WOOD_FACES_MIN = 12 + +WOOD_IDX = 0 +METAL_IDX = 1 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def deselect_all(): + for ob in list(bpy.context.view_layer.objects): + if ob is None: + continue + ob.select_set(False) + + +def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) + v.co = rot @ p + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_oriented_box(bm, a, b, scale_xy, mat_idx): + a = Vector(a) + b = Vector(b) + delta = b - a + length = delta.length + if length < 1e-8: + return [] + quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) + eul = quat.to_euler("XYZ") + return add_box( + bm, + ((a + b) * 0.5), + (scale_xy[0], scale_xy[1], length), + mat_idx, + euler=(eul.x, eul.y, eul.z), + ) + + +def add_cyl_between(bm, a, b, radius, segments, mat_idx): + a = Vector(a) + b = Vector(b) + delta = b - a + length = delta.length + if length < 1e-8: + return [] + quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) + eul = quat.to_euler("XYZ") + return add_cyl( + bm, + ((a + b) * 0.5), + radius, + length, + segments, + mat_idx, + euler=(eul.x, eul.y, eul.z), + ) + + +def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius, + radius2=radius, + depth=depth, + ) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius1, + radius2=radius2, + depth=depth, + ) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): + n_major = 14 + n_minor = 7 + rings = [] + for i in range(n_major): + u = i * (2.0 * math.pi / n_major) + ring = [] + for j in range(n_minor): + v = j * (2.0 * math.pi / n_minor) + x = (major + minor * math.cos(v)) * math.cos(u) + y = (major + minor * math.cos(v)) * math.sin(u) + z = minor * math.sin(v) + ring.append(bm.verts.new((x, y, z))) + rings.append(ring) + bm.verts.ensure_lookup_table() + for i in range(n_major): + i2 = (i + 1) % n_major + for j in range(n_minor): + j2 = (j + 1) % n_minor + face = bm.faces.new( + (rings[i][j], rings[i2][j], rings[i2][j2], rings[i][j2]) + ) + face.material_index = mat_idx + verts = [v for ring in rings for v in ring] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + return verts + + +def add_square_band(bm, z, half, t, h, mat_idx): + metal = [] + metal.extend(add_box(bm, (0.0, half + t / 2.0, z), (2.0 * half + 2.0 * t, t, h), mat_idx)) + metal.extend(add_box(bm, (0.0, -(half + t / 2.0), z), (2.0 * half + 2.0 * t, t, h), mat_idx)) + metal.extend(add_box(bm, (half + t / 2.0, 0.0, z), (t, 2.0 * half, h), mat_idx)) + metal.extend(add_box(bm, (-(half + t / 2.0), 0.0, z), (t, 2.0 * half, h), mat_idx)) + return metal + + +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_chopping_block_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + try: + wood = [] + metal = [] + + wood.extend( + add_cone( + bm, + (0.0, 0.0, STUMP_H / 2.0), + STUMP_R + 0.012, + STUMP_R - 0.018, + STUMP_H, + STUMP_SEGS, + WOOD_IDX, + ) + ) + wood.extend( + add_cyl( + bm, + (0.0, 0.0, STUMP_H + 0.008), + STUMP_R - 0.020, + 0.016, + STUMP_SEGS, + WOOD_IDX, + ) + ) + wood.extend( + add_box( + bm, + (0.04, 0.0, STUMP_H + 0.014), + (0.16, 0.012, 0.012), + WOOD_IDX, + ) + ) + + h0 = Vector((-0.20, -0.14, STUMP_H + 0.44)) + h1 = Vector((0.05, 0.03, STUMP_H + 0.02)) + wood.extend(add_cyl_between(bm, h0, h1, 0.018, 10, WOOD_IDX)) + pommel = h0 + (h0 - h1).normalized() * 0.035 + wood.extend(add_cyl_between(bm, h0, pommel, 0.026, 10, WOOD_IDX)) + + if bevel_offset > 0.0: + edges = list({e for v in wood for e in v.link_edges}) + ret = bmesh.ops.bevel( + bm, + geom=edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + for f in ret.get("faces") or []: + f.material_index = WOOD_IDX + + metal.extend( + add_rim(bm, (0.0, 0.0, 0.075), STUMP_R + 0.002, 0.012, METAL_IDX) + ) + + axis = (h1 - h0).normalized() + bit_up = Vector((0.0, 0.0, 1.0)) + bit_right = axis.cross(bit_up) + if bit_right.length < 0.15: + bit_right = axis.cross(Vector((0.0, 1.0, 0.0))) + bit_right.normalize() + bit_up = bit_right.cross(axis).normalized() + rot_mat = Matrix((axis, bit_right, bit_up)).transposed() + eul = rot_mat.to_euler("XYZ") + head_e = (eul.x, eul.y, eul.z) + cheek = h1 - axis * 0.015 + metal.extend( + add_box(bm, cheek, (0.10, 0.044, 0.078), METAL_IDX, euler=head_e) + ) + poll = h1 - axis * 0.072 + metal.extend( + add_box(bm, poll, (0.048, 0.058, 0.058), METAL_IDX, euler=head_e) + ) + bit = h1 + axis * 0.045 + metal.extend( + add_box(bm, bit, (0.055, 0.014, 0.175), METAL_IDX, euler=head_e) + ) + tip = h1 + axis * 0.082 + metal.extend( + add_box(bm, tip, (0.028, 0.008, 0.195), METAL_IDX, euler=head_e) + ) + + xs = [v.co.x for v in bm.verts] + ys = [v.co.y for v in bm.verts] + zs = [v.co.z for v in bm.verts] + rcx = 0.5 * (min(xs) + max(xs)) + rcy = 0.5 * (min(ys) + max(ys)) + zmin = min(zs) + for v in bm.verts: + v.co.x -= rcx + v.co.y -= rcy + v.co.z -= zmin + if v.co.z < 0.0: + v.co.z = 0.0 + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = False + me = bpy.data.meshes.new(name) + bm.to_mesh(me) + me.update() + for poly in me.polygons: + poly.use_smooth = False + finally: + bm.free() + out = bpy.data.objects.new(name, me) + bpy.context.collection.objects.link(out) + return out + + +def principled(name, color, metallic, roughness): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + bsdf = mat.node_tree.nodes["Principled BSDF"] + bsdf.inputs["Base Color"].default_value = color + bsdf.inputs["Metallic"].default_value = metallic + bsdf.inputs["Roughness"].default_value = roughness + return mat + + +def assign_slots(obj, wood, metal): + mats = obj.data.materials + wanted = (wood, metal) + for i, mat in enumerate(wanted): + if i < len(mats): + mats[i] = mat + else: + mats.append(mat) + + +def world_bbox(obj): + corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + xs = [c.x for c in corners] + ys = [c.y for c in corners] + zs = [c.z for c in corners] + return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs)) + + +def uv_stats(mesh): + uv = mesh.uv_layers.active + if uv is None: + return 0.0, 0.0, 1.0, 1.0, 0, 1.0 + data = uv.data + us = [loop.uv[0] for loop in data] + vs = [loop.uv[1] for loop in data] + aabbs = [] + for poly in mesh.polygons: + pu = [data[i].uv[0] for i in poly.loop_indices] + pv = [data[i].uv[1] for i in poly.loop_indices] + aabbs.append((min(pu), min(pv), max(pu), max(pv))) + overlap = 0.0 + for i in range(len(aabbs)): + a = aabbs[i] + for j in range(i + 1, len(aabbs)): + b = aabbs[j] + x0 = max(a[0], b[0]) + y0 = max(a[1], b[1]) + x1 = min(a[2], b[2]) + y1 = min(a[3], b[3]) + overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) + return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) + + +def make_lod(obj, name, ratio, skip_decimate): + mesh = obj.data.copy() + lod = bpy.data.objects.new(name, mesh) + lod.matrix_world = obj.matrix_world.copy() + bpy.context.scene.collection.objects.link(lod) + if not skip_decimate and 0.0 < ratio < 1.0: + mod = lod.modifiers.new("DecimateBudget", "DECIMATE") + mod.decimate_type = "COLLAPSE" + mod.ratio = ratio + return lod + + +def convex_hull_collider(obj, name): + mesh = bpy.data.meshes.new(name) + bm = bmesh.new() + try: + bm.from_mesh(obj.data) + result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) + interior = result.get("geom_interior") or [] + unused = result.get("geom_unused") or [] + if interior: + bmesh.ops.delete(bm, geom=interior, context="VERTS") + if unused: + bmesh.ops.delete(bm, geom=unused, context="VERTS") + bm.to_mesh(mesh) + mesh.update() + finally: + bm.free() + collider = bpy.data.objects.new(name, mesh) + bpy.context.collection.objects.link(collider) + collider.matrix_world = obj.matrix_world.copy() + return collider + + +def setup_bake_image(obj, target_mat, size=BAKE_RES): + if not obj.data.uv_layers: + return None, None + img = bpy.data.images.new("ChopBlockNrm", size, size, alpha=True, float_buffer=False) + img.colorspace_settings.name = "Non-Color" + nodes = target_mat.node_tree.nodes + tex = nodes.new("ShaderNodeTexImage") + tex.image = img + nodes.active = tex + tex.select = True + obj.active_material_index = WOOD_IDX + return img, tex + + +def bake_normal(high, low): + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.device = "CPU" + scene.cycles.samples = 1 + scene.cycles.use_denoising = False + deselect_all() + high.select_set(True) + low.select_set(True) + bpy.context.view_layer.objects.active = low + return bpy.ops.object.bake( + type="NORMAL", + use_selected_to_active=True, + cage_extrusion=CAGE_EXTRUSION, + use_cage=False, + normal_space="TANGENT", + margin=4, + margin_type="ADJACENT_FACES", + use_clear=True, + target="IMAGE_TEXTURES", + ) + + +def export_unity(path, objects): + deselect_all() + for ob in objects: + ob.select_set(True) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.export_scene.gltf( + filepath=path, + use_selection=True, + export_yup=True, + export_apply=True, + export_draco_mesh_compression_enable=False, + export_animations=False, + ) + + +def check(skip_decimate): + bpy.ops.wm.read_factory_settings(use_empty=True) + low = build_chopping_block_mesh("ChopBlockLow", bevel_offset=0.004, bevel_segments=2) + high = build_chopping_block_mesh("ChopBlockHigh", bevel_offset=0.004, bevel_segments=4) + wood = principled("ChopBlockWood", (0.38, 0.20, 0.08, 1.0), 0.0, 0.62) + metal = principled("ChopBlockIron", (0.22, 0.22, 0.23, 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("chopping block 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("chopping block has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "ChopBlockLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "ChopBlockLOD2", 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_chopping_block_mesh( + "ChopBlockColSrc", bevel_offset=0.0, bevel_segments=1 + ) + collider = convex_hull_collider(collider_src, "ChopBlockCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_chopping_block_{os.getpid()}.glb", + ) + if os.path.exists(export_path): + os.remove(export_path) + export_unity(export_path, [low, collider]) + export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") + print( + f"measured base_tris={base_tris} lod1_tris={lod1_tris} " + f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" + ) + print( + f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " + f"overlap={overlap:.6f} nfaces={nfaces}" + ) + print( + f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"outer={OUTER_SIZE} zmin={bb[2]:.4f}" + ) + print( + f"measured collider_tris={col_tris} bake={bake_result} " + f"bake_has_data={img.has_data} export_bytes={export_size}" + ) + + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): + return fail( + f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", + 4, + ), None, None, None, None, None + if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT: + return fail( + f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", + 5, + ), None, None, None, None, None + if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN: + return fail( + f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}", + 5, + ), None, None, None, None, None + if idx_counts.get(WOOD_IDX, 0) < WOOD_FACES_MIN: + return fail( + f"wood faces {idx_counts.get(WOOD_IDX, 0)} < {WOOD_FACES_MIN}", + 5, + ), None, None, None, None, None + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return fail( + f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", + 6, + ), None, None, None, None, None + if overlap > UV_OVERLAP_MAX: + return fail( + f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", + 7, + ), None, None, None, None, None + if ( + abs(size_x - OUTER_SIZE[0]) > BBOX_TOL + or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL + ): + return fail( + f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"off outer {OUTER_SIZE}", + 8, + ), None, None, None, None, None + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return fail( + f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", + 9, + ), None, None, None, None, None + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return fail( + f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", + 9, + ), None, None, None, None, None + if col_tris > COLLIDER_TRIS_MAX: + return fail( + f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", + 11, + ), None, None, None, None, None + if bake_result != {"FINISHED"} or not img.has_data: + return fail( + f"bake failed result={bake_result} has_data={img.has_data}", + 12, + ), None, None, None, None, None + if export_size <= 0: + return fail("export file missing or empty", 13), None, None, None, None, None + return 0, low, high, wood, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + nrm = nt.nodes.new("ShaderNodeNormalMap") + nrm.inputs["Strength"].default_value = 1.0 + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) + + +def render_still(low, wood, tex, path, engine): + scene = bpy.context.scene + wire_normal(wood, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(-48.0) + low.rotation_euler.x = math.radians(0.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 8.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-3.6, -5.0, 5.4), 660.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) + light("Fill", (5.0, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) + light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (1.572, -2.141, 1.630) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.40) + scene.collection.objects.link(aim) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 32 + scene.cycles.device = "CPU" + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = ( + "WEBP" if path.lower().endswith(".webp") else "PNG" + ) + if path.lower().endswith(".webp"): + scene.render.image_settings.quality = 90 + scene.render.filepath = path + scene.view_settings.view_transform = "Standard" + + fcode = gallery_framing.check_framing( + scene, cam, hero=[low], elements=[low], stage=[floor, wall], + ) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + return fail("render produced no file", 14) + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None) + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument( + "--skip-decimate", + action="store_true", + help="falsification: skip the LOD DECIMATE stage", + ) + args = p.parse_args(argv) + + code, low, _high, wood, tex, _col = check(args.skip_decimate) + if code: + return code + if args.output: + rcode = render_still(low, wood, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("chopping-block 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/chopping-block/preview.webp b/showcase/chopping-block/preview.webp new file mode 100644 index 0000000..90c6b51 Binary files /dev/null and b/showcase/chopping-block/preview.webp differ diff --git a/showcase/gallery.json b/showcase/gallery.json index 3b0c5d7..7dd0ddf 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -232,6 +232,30 @@ "mesh", "export" ] + }, + { + "name": "bellows", + "dir": "showcase/bellows", + "teaches": "A procedural blacksmith bellows through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 1272 tris, three materials with 294 leather and 48 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.630×0.403×0.277 m, LOD ratios in band (5.2 COLLAPSE 2 tris leaner on LOD2), convex collider 148 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/bellows-hero.webp", + "preview": "showcase/bellows/preview.webp", + "tags": [ + "mesh", + "export" + ] + }, + { + "name": "chopping-block", + "dir": "showcase/chopping-block", + "teaches": "A procedural chopping block with an embedded axe through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 2016 tris, two materials with 122 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.471×0.463×0.841 m, LOD ratios in band (5.2 COLLAPSE 2 tris leaner on LOD2), convex collider 188 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/chopping-block-hero.webp", + "preview": "showcase/chopping-block/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index cba25ba..1730ee0 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -92,5 +92,7 @@ {"name": "hitching-post", "script": "showcase/hitching-post/hitching_post.py"}, {"name": "grindstone", "script": "showcase/grindstone/grindstone.py"}, {"name": "hand-pump", "script": "showcase/hand-pump/hand_pump.py"}, - {"name": "signpost", "script": "showcase/signpost/signpost.py"} + {"name": "signpost", "script": "showcase/signpost/signpost.py"}, + {"name": "bellows", "script": "showcase/bellows/bellows.py"}, + {"name": "chopping-block", "script": "showcase/chopping-block/chopping_block.py"} ]