From 1b6a0c78cf7aa2c8e9bc0df1af6c51e59d49fa4b Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:48:50 -0400 Subject: [PATCH 1/2] feat: add hand-pump showcase piece Cast-iron village pump on a wooden plinth with spout and handle through the shipped bake/LOD/collider/Unity glTF pipeline. --skip-decimate exits 9 on LOD1. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 3 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 7 +- ROADMAP.md | 2 +- docs/gallery/assets/hand-pump-hero.webp | Bin 0 -> 6120 bytes docs/gallery/hand-pump/index.html | 1153 +++++++++++++++++++++++ docs/gallery/index.html | 13 +- showcase/gallery.json | 12 + showcase/hand-pump/README.md | 74 ++ showcase/hand-pump/hand_pump.py | 829 ++++++++++++++++ showcase/hand-pump/preview.webp | Bin 0 -> 5806 bytes tests/smoke/catalog.json | 3 +- 13 files changed, 2092 insertions(+), 8 deletions(-) create mode 100644 docs/gallery/assets/hand-pump-hero.webp create mode 100644 docs/gallery/hand-pump/index.html create mode 100644 showcase/hand-pump/README.md create mode 100644 showcase/hand-pump/hand_pump.py create mode 100644 showcase/hand-pump/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 2263d4c..90555c1 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -154,6 +154,7 @@ "showcase/anvil", "showcase/water-trough", "showcase/hitching-post", - "showcase/grindstone" + "showcase/grindstone", + "showcase/hand-pump" ] } diff --git a/AGENTS.md b/AGENTS.md index 883678e..96ccdea 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 17 showcase pieces (counts are CI-enforced against README.md) +examples, and 18 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 4ee2219..7e1bcd6 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, 17 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 18 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 64d63ed..b9d5893 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  17 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  18 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 17 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 18 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. @@ -92,6 +92,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Water trough: a staved wooden trough on trestle legs with iron straps on a dark studio floor Hitching post: a square timber post with a pyramidal cap, cross-arm, iron bands, and two hitching rings on a dark studio floor 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 [`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. @@ -127,6 +128,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`grindstone`](showcase/grindstone/) — procedural grindstone on a timber trestle with iron axle and crank through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`hand-pump`](showcase/hand-pump/) — procedural cast-iron village hand pump 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 d467dff..476da61 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -121,7 +121,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Staved wooden water trough on a stand as a game-prop showcase piece~~ **SHIPPED** as `showcase/water-trough/` — U-staves, end boards, iron straps, trestle legs; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Hitching post with iron ring as a game-prop showcase piece~~ **SHIPPED** as `showcase/hitching-post/` — square post, pyramidal cap, cross-arm, iron shoe, bands, two rings; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~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 +- ~~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 - ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **SHIPPED** as `showcase/fence-kit/` — tiling post-and-rail section (AABB X is the tile width; does not re-witness the snap contract); `--skip-decimate` exits 9 on the LOD1 ratio band diff --git a/docs/gallery/assets/hand-pump-hero.webp b/docs/gallery/assets/hand-pump-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..4902358f5ebdeed646860beaad9be5ec6a7c71d4 GIT binary patch literal 6120 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/hand-pump/index.html b/docs/gallery/hand-pump/index.html new file mode 100644 index 0000000..3b395d2 --- /dev/null +++ b/docs/gallery/hand-pump/index.html @@ -0,0 +1,1153 @@ + + + + + + hand-pump — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +

+
+

hand-pump

+

A procedural village hand pump 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: 616 tris, two materials with 96 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.567×0.366×1.027 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 118 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/hand-pump/hand_pump.py --
+ +
+
+

A showcase piece, not an example. Procedural cast-iron village hand pump (wooden plinth, column, spout, handle, wooden grip) 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 | 540–700 | 616 / 616 / 616 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2175 / 0.2175 / 0.2143 | | Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 96 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.567, 0.366, 1.027) m ± 0.01 | (0.5668, 0.3660, 1.0271), zmin 0 | | Collider tris | ≤ 160 | 118 | | Export | written, size > 0 | 52764 / 52764 / 52756 bytes |

+

DECIMATE COLLAPSE triangle counts are not identical across series — 5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an exact count. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 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 hand_pump.py --
+blender --background --python hand_pump.py -- --skip-decimate
+blender --background --python hand_pump.py -- --output hand-pump.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/hand-pump/hand_pump.py + View on GitHub → +
+
"""Game-ready village hand pump — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural cast-iron hand pump (plinth,
+column, spout, handle, and wooden grip) 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 hand_pump.py --
+    blender --background --python hand_pump.py -- --skip-decimate
+    blender --background --python hand_pump.py -- --output hand-pump.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
+
+PLINTH_XY = 0.30
+PLINTH_H = 0.072
+COL_R = 0.050
+COL_H = 0.78
+COL_SEGS = 12
+SPOUT_Z = 0.42
+SPOUT_L = 0.16
+HANDLE_LIFT = 0.20
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (0.567, 0.366, 1.027)
+BASE_TRIS_MIN = 540
+BASE_TRIS_MAX = 700
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 2
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 160
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 24
+WOOD_FACES_MIN = 12
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    depsgraph = bpy.context.evaluated_depsgraph_get()
+    eval_obj = obj.evaluated_get(depsgraph)
+    eval_mesh = eval_obj.to_mesh()
+    try:
+        eval_mesh.calc_loop_triangles()
+        return len(eval_mesh.loop_triangles)
+    finally:
+        eval_obj.to_mesh_clear()
+
+
+def deselect_all():
+    for ob in list(bpy.context.view_layer.objects):
+        if ob is None:
+            continue
+        ob.select_set(False)
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cube(bm, size=1.0)
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+        v.co = rot @ p + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_oriented_box(bm, a, b, scale_xy, mat_idx):
+    a = Vector(a)
+    b = Vector(b)
+    delta = b - a
+    length = delta.length
+    if length < 1e-8:
+        return []
+    quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized())
+    eul = quat.to_euler("XYZ")
+    return add_box(
+        bm,
+        ((a + b) * 0.5),
+        (scale_xy[0], scale_xy[1], length),
+        mat_idx,
+        euler=(eul.x, eul.y, eul.z),
+    )
+
+
+def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius,
+        radius2=radius,
+        depth=depth,
+    )
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius1,
+        radius2=radius2,
+        depth=depth,
+    )
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def 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_hand_pump_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    try:
+        wood = []
+        metal = []
+        col_z = PLINTH_H + COL_H / 2.0
+        top_z = PLINTH_H + COL_H
+
+        wood.extend(
+            add_box(
+                bm,
+                (0.0, 0.0, PLINTH_H / 2.0),
+                (PLINTH_XY, PLINTH_XY, PLINTH_H),
+                WOOD_IDX,
+            )
+        )
+        grip_end = Vector((-0.36, 0.10, top_z - 0.04 + HANDLE_LIFT))
+        wood.extend(
+            add_cyl(
+                bm,
+                (grip_end.x, grip_end.y, grip_end.z),
+                0.016,
+                0.11,
+                8,
+                WOOD_IDX,
+                euler=(0.0, math.pi / 2.0, 0.25),
+            )
+        )
+
+        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_cyl(
+                bm,
+                (0.0, 0.0, PLINTH_H + 0.014),
+                0.092,
+                0.028,
+                COL_SEGS,
+                METAL_IDX,
+            )
+        )
+        metal.extend(
+            add_cyl(
+                bm,
+                (0.0, 0.0, col_z),
+                COL_R,
+                COL_H,
+                COL_SEGS,
+                METAL_IDX,
+            )
+        )
+        metal.extend(
+            add_cyl(
+                bm,
+                (0.0, 0.0, PLINTH_H + COL_H * 0.42),
+                COL_R + 0.010,
+                0.032,
+                COL_SEGS,
+                METAL_IDX,
+            )
+        )
+        metal.extend(
+            add_cone(
+                bm,
+                (0.0, 0.0, top_z + 0.028),
+                0.058,
+                0.018,
+                0.056,
+                COL_SEGS,
+                METAL_IDX,
+            )
+        )
+        spout_y = -(COL_R + SPOUT_L / 2.0)
+        metal.extend(
+            add_cyl(
+                bm,
+                (0.0, spout_y, SPOUT_Z),
+                0.022,
+                SPOUT_L,
+                10,
+                METAL_IDX,
+                euler=(math.pi / 2.0, 0.0, 0.0),
+            )
+        )
+        metal.extend(
+            add_cyl(
+                bm,
+                (0.0, spout_y - SPOUT_L / 2.0 + 0.012, SPOUT_Z - 0.045),
+                0.018,
+                0.090,
+                10,
+                METAL_IDX,
+            )
+        )
+        pivot = Vector((0.0, COL_R + 0.018, top_z - 0.055))
+        metal.extend(
+            add_cyl(
+                bm,
+                (pivot.x, pivot.y, pivot.z),
+                0.016,
+                0.055,
+                8,
+                METAL_IDX,
+                euler=(math.pi / 2.0, 0.0, 0.0),
+            )
+        )
+        metal.extend(
+            add_oriented_box(
+                bm,
+                (pivot.x, pivot.y, pivot.z),
+                (grip_end.x + 0.04, grip_end.y, grip_end.z),
+                (0.022, 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, 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("HandPumpNrm", 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_hand_pump_mesh("HandPumpLow", bevel_offset=0.004, bevel_segments=2)
+    high = build_hand_pump_mesh("HandPumpHigh", bevel_offset=0.004, bevel_segments=4)
+    wood = principled("HandPumpWood", (0.36, 0.19, 0.07, 1.0), 0.0, 0.60)
+    metal = principled("HandPumpIron", (0.16, 0.155, 0.15, 1.0), 1.0, 0.42)
+    assign_slots(low, wood, metal)
+    assign_slots(high, wood, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("hand pump 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("hand pump has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "HandPumpLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "HandPumpLOD2", 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_hand_pump_mesh(
+        "HandPumpColSrc", bevel_offset=0.0, bevel_segments=1
+    )
+    collider = convex_hull_collider(collider_src, "HandPumpCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_hand_pump_{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(-28.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 = (2.05, -2.54, 0.97)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.42)
+    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("hand-pump 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 bf147e1..00a637a 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" /> - 68 examples + 69 examples
@@ -1072,6 +1072,17 @@

grindstone

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

hand-pump

+

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

+

witnesses Recomputed: 616 tris, two materials with 96 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.567×0.366×1.027 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 118 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+ View example +
+
diff --git a/showcase/gallery.json b/showcase/gallery.json index ee6f359..18c2f87 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -208,6 +208,18 @@ "mesh", "export" ] + }, + { + "name": "hand-pump", + "dir": "showcase/hand-pump", + "teaches": "A procedural village hand pump through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 616 tris, two materials with 96 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.567×0.366×1.027 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 118 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/hand-pump-hero.webp", + "preview": "showcase/hand-pump/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/hand-pump/README.md b/showcase/hand-pump/README.md new file mode 100644 index 0000000..bde86e1 --- /dev/null +++ b/showcase/hand-pump/README.md @@ -0,0 +1,74 @@ +# Hand pump + +A showcase piece, not an example. Procedural cast-iron village hand +pump (wooden plinth, column, spout, handle, wooden grip) 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 | 540–700 | 616 / 616 / 616 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2175 / 0.2175 / 0.2143 | +| Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 96 metal | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (0.567, 0.366, 1.027) m ± 0.01 | (0.5668, 0.3660, 1.0271), zmin 0 | +| Collider tris | ≤ 160 | 118 | +| Export | written, size > 0 | 52764 / 52764 / 52756 bytes | + +DECIMATE COLLAPSE triangle counts are **not** identical across series — +5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an +exact count. Bake pixels are stochastic; the gate is `has_data` plus +operator `FINISHED`, not byte-identity. Construction uses no RNG. +Export byte counts differ by 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 hand_pump.py -- +blender --background --python hand_pump.py -- --skip-decimate +blender --background --python hand_pump.py -- --output hand-pump.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/hand-pump/hand_pump.py b/showcase/hand-pump/hand_pump.py new file mode 100644 index 0000000..e8fc8e4 --- /dev/null +++ b/showcase/hand-pump/hand_pump.py @@ -0,0 +1,829 @@ +"""Game-ready village hand pump — a showcase piece, not an example. + +Asserts budget conformance of a procedural cast-iron hand pump (plinth, +column, spout, handle, and wooden grip) 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 hand_pump.py -- + blender --background --python hand_pump.py -- --skip-decimate + blender --background --python hand_pump.py -- --output hand-pump.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 + +PLINTH_XY = 0.30 +PLINTH_H = 0.072 +COL_R = 0.050 +COL_H = 0.78 +COL_SEGS = 12 +SPOUT_Z = 0.42 +SPOUT_L = 0.16 +HANDLE_LIFT = 0.20 + +BBOX_TOL = 0.01 +OUTER_SIZE = (0.567, 0.366, 1.027) +BASE_TRIS_MIN = 540 +BASE_TRIS_MAX = 700 +LOD1_RATIO_MIN = 0.32 +LOD1_RATIO_MAX = 0.62 +LOD2_RATIO_MIN = 0.10 +LOD2_RATIO_MAX = 0.35 +LOD1_TARGET = 0.50 +LOD2_TARGET = 0.22 +MATERIAL_COUNT = 2 +UV_EPS = 1e-4 +UV_OVERLAP_MAX = 1e-5 +COLLIDER_TRIS_MAX = 160 +BAKE_RES = 256 +CAGE_EXTRUSION = 0.08 +METAL_FACES_MIN = 24 +WOOD_FACES_MIN = 12 + +WOOD_IDX = 0 +METAL_IDX = 1 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def deselect_all(): + for ob in list(bpy.context.view_layer.objects): + if ob is None: + continue + ob.select_set(False) + + +def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) + v.co = rot @ p + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_oriented_box(bm, a, b, scale_xy, mat_idx): + a = Vector(a) + b = Vector(b) + delta = b - a + length = delta.length + if length < 1e-8: + return [] + quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) + eul = quat.to_euler("XYZ") + return add_box( + bm, + ((a + b) * 0.5), + (scale_xy[0], scale_xy[1], length), + mat_idx, + euler=(eul.x, eul.y, eul.z), + ) + + +def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius, + radius2=radius, + depth=depth, + ) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius1, + radius2=radius2, + depth=depth, + ) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def 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_hand_pump_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + try: + wood = [] + metal = [] + col_z = PLINTH_H + COL_H / 2.0 + top_z = PLINTH_H + COL_H + + wood.extend( + add_box( + bm, + (0.0, 0.0, PLINTH_H / 2.0), + (PLINTH_XY, PLINTH_XY, PLINTH_H), + WOOD_IDX, + ) + ) + grip_end = Vector((-0.36, 0.10, top_z - 0.04 + HANDLE_LIFT)) + wood.extend( + add_cyl( + bm, + (grip_end.x, grip_end.y, grip_end.z), + 0.016, + 0.11, + 8, + WOOD_IDX, + euler=(0.0, math.pi / 2.0, 0.25), + ) + ) + + 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_cyl( + bm, + (0.0, 0.0, PLINTH_H + 0.014), + 0.092, + 0.028, + COL_SEGS, + METAL_IDX, + ) + ) + metal.extend( + add_cyl( + bm, + (0.0, 0.0, col_z), + COL_R, + COL_H, + COL_SEGS, + METAL_IDX, + ) + ) + metal.extend( + add_cyl( + bm, + (0.0, 0.0, PLINTH_H + COL_H * 0.42), + COL_R + 0.010, + 0.032, + COL_SEGS, + METAL_IDX, + ) + ) + metal.extend( + add_cone( + bm, + (0.0, 0.0, top_z + 0.028), + 0.058, + 0.018, + 0.056, + COL_SEGS, + METAL_IDX, + ) + ) + spout_y = -(COL_R + SPOUT_L / 2.0) + metal.extend( + add_cyl( + bm, + (0.0, spout_y, SPOUT_Z), + 0.022, + SPOUT_L, + 10, + METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + ) + metal.extend( + add_cyl( + bm, + (0.0, spout_y - SPOUT_L / 2.0 + 0.012, SPOUT_Z - 0.045), + 0.018, + 0.090, + 10, + METAL_IDX, + ) + ) + pivot = Vector((0.0, COL_R + 0.018, top_z - 0.055)) + metal.extend( + add_cyl( + bm, + (pivot.x, pivot.y, pivot.z), + 0.016, + 0.055, + 8, + METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + ) + metal.extend( + add_oriented_box( + bm, + (pivot.x, pivot.y, pivot.z), + (grip_end.x + 0.04, grip_end.y, grip_end.z), + (0.022, 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, 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("HandPumpNrm", 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_hand_pump_mesh("HandPumpLow", bevel_offset=0.004, bevel_segments=2) + high = build_hand_pump_mesh("HandPumpHigh", bevel_offset=0.004, bevel_segments=4) + wood = principled("HandPumpWood", (0.36, 0.19, 0.07, 1.0), 0.0, 0.60) + metal = principled("HandPumpIron", (0.16, 0.155, 0.15, 1.0), 1.0, 0.42) + assign_slots(low, wood, metal) + assign_slots(high, wood, metal) + + if low.data is None or len(low.data.polygons) < 6: + return fail("hand pump 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("hand pump has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "HandPumpLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "HandPumpLOD2", 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_hand_pump_mesh( + "HandPumpColSrc", bevel_offset=0.0, bevel_segments=1 + ) + collider = convex_hull_collider(collider_src, "HandPumpCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_hand_pump_{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(-28.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 = (2.05, -2.54, 0.97) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.42) + 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("hand-pump 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/hand-pump/preview.webp b/showcase/hand-pump/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..bef66e3b7af01b0eeda9683d4b8d864e27775710 GIT binary patch literal 5806 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 Date: Mon, 14 Sep 2026 18:49:35 -0400 Subject: [PATCH 2/2] feat: add signpost showcase piece Timber fingerboard signpost with two painted boards and iron straps through the shipped bake/LOD/collider/Unity glTF pipeline. --skip-decimate exits 9 on LOD1. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 3 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 7 +- ROADMAP.md | 4 +- docs/gallery/assets/signpost-hero.webp | Bin 0 -> 6706 bytes docs/gallery/index.html | 13 +- docs/gallery/signpost/index.html | 1135 ++++++++++++++++++++++++ showcase/gallery.json | 12 + showcase/signpost/README.md | 74 ++ showcase/signpost/preview.webp | Bin 0 -> 6302 bytes showcase/signpost/signpost.py | 811 +++++++++++++++++ tests/smoke/catalog.json | 3 +- 13 files changed, 2058 insertions(+), 8 deletions(-) create mode 100644 docs/gallery/assets/signpost-hero.webp create mode 100644 docs/gallery/signpost/index.html create mode 100644 showcase/signpost/README.md create mode 100644 showcase/signpost/preview.webp create mode 100644 showcase/signpost/signpost.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 90555c1..af82360 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -155,6 +155,7 @@ "showcase/water-trough", "showcase/hitching-post", "showcase/grindstone", - "showcase/hand-pump" + "showcase/hand-pump", + "showcase/signpost" ] } diff --git a/AGENTS.md b/AGENTS.md index 96ccdea..e133415 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 18 showcase pieces (counts are CI-enforced against README.md) +examples, and 19 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 7e1bcd6..2556569 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, 18 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 19 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 b9d5893..3e089be 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

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

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 18 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 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. 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. @@ -93,6 +93,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Hitching post: a square timber post with a pyramidal cap, cross-arm, iron bands, and two hitching rings on a dark studio floor 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 [`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. @@ -130,6 +131,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`hand-pump`](showcase/hand-pump/) — procedural cast-iron village hand pump through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`signpost`](showcase/signpost/) — procedural timber fingerboard signpost 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 476da61..f978f95 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,7 +122,9 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Hitching post with iron ring as a game-prop showcase piece~~ **SHIPPED** as `showcase/hitching-post/` — square post, pyramidal cap, cross-arm, iron shoe, bands, two rings; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~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 +- ~~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 - ~~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/signpost-hero.webp b/docs/gallery/assets/signpost-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..0d00ef7f06cb97603f37e3ba7461f6e337f8c55b GIT binary patch literal 6706 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 I7I**v0DGa$OaK4? literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 00a637a..5cc46c3 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" /> - 69 examples + 70 examples
@@ -1083,6 +1083,17 @@

hand-pump

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

signpost

+

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

+

witnesses Recomputed: 388 tris, three materials with 66 metal and 22 board faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.162×0.147×1.575 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 92 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

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

signpost

+

A procedural timber fingerboard signpost 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: 388 tris, three materials with 66 metal and 22 board faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.162×0.147×1.575 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 92 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/signpost/signpost.py --
+ +
+
+

A showcase piece, not an example. Procedural timber fingerboard signpost (square post, pyramidal cap, two painted boards, iron shoe and straps) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.

+

Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied, not imported as a package).

+

Budgets

+

Declared as named constants; every gate recomputes from the mesh, materials, UVs, evaluated LOD, collider, or export file.

+

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 320–460 | 388 / 388 / 388 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2165 / 0.2165 / 0.1804 | | Materials | exactly 3 distinct, ≥24 metal and ≥12 board faces | 3 slots, 66 metal, 22 board | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.162, 0.147, 1.575) m ± 0.01 | (1.1620, 0.1474, 1.5750), zmin 0 | | Collider tris | ≤ 140 | 92 | | Export | written, size > 0 | 40124 / 40124 / 40112 bytes |

+

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

+

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

+

Run

+
blender --background --python signpost.py --
+blender --background --python signpost.py -- --skip-decimate
+blender --background --python signpost.py -- --output signpost.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/board/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/signpost/signpost.py + View on GitHub → +
+
"""Game-ready timber signpost — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural fingerboard signpost (post,
+two painted boards, iron shoe and straps) 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 signpost.py --
+    blender --background --python signpost.py -- --skip-decimate
+    blender --background --python signpost.py -- --output signpost.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
+
+POST_W = 0.110
+POST_H = 1.48
+CAP_H = 0.095
+SHOE_H = 0.048
+SHOE_SCALE = 1.34
+BOARD_Y = 0.072
+BOARD_ZTH = 0.046
+BOARD_A = (1.24, 0.52, 1.0)
+BOARD_B = (1.02, 0.44, -1.0)
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (1.162, 0.147, 1.575)
+BASE_TRIS_MIN = 320
+BASE_TRIS_MAX = 460
+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 = 140
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 24
+WOOD_FACES_MIN = 12
+BOARD_FACES_MIN = 12
+
+WOOD_IDX = 0
+BOARD_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 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_signpost_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    try:
+        wood = []
+        boards = []
+        metal = []
+        half = POST_W / 2.0
+
+        wood.extend(
+            add_box(
+                bm,
+                (0.0, 0.0, POST_H / 2.0),
+                (POST_W, POST_W, POST_H),
+                WOOD_IDX,
+            )
+        )
+        wood.extend(
+            add_cone(
+                bm,
+                (0.0, 0.0, POST_H + CAP_H / 2.0),
+                POST_W * 0.78,
+                0.012,
+                CAP_H,
+                4,
+                WOOD_IDX,
+                euler=(0.0, 0.0, math.pi / 4.0),
+            )
+        )
+
+        if bevel_offset > 0.0:
+            edges = list({e for v in wood for e in v.link_edges})
+            ret = bmesh.ops.bevel(
+                bm,
+                geom=edges,
+                offset=bevel_offset,
+                segments=bevel_segments,
+                profile=0.5,
+                affect="EDGES",
+                clamp_overlap=True,
+            )
+            for f in ret.get("faces") or []:
+                f.material_index = WOOD_IDX
+
+        for z, length, sign in (BOARD_A, BOARD_B):
+            cx = sign * (half + length / 2.0 - 0.018)
+            boards.extend(
+                add_box(
+                    bm,
+                    (cx, 0.0, z),
+                    (length, BOARD_Y, BOARD_ZTH),
+                    BOARD_IDX,
+                )
+            )
+            tip_x = cx + sign * (length / 2.0 + 0.028)
+            boards.extend(
+                add_cone(
+                    bm,
+                    (tip_x, 0.0, z),
+                    0.052,
+                    0.004,
+                    0.072,
+                    3,
+                    BOARD_IDX,
+                    euler=(0.0, sign * math.pi / 2.0, 0.0),
+                )
+            )
+            metal.extend(
+                add_box(
+                    bm,
+                    (sign * (half + 0.010), 0.0, z),
+                    (0.020, BOARD_Y + 0.016, BOARD_ZTH + 0.018),
+                    METAL_IDX,
+                )
+            )
+            metal.extend(
+                add_box(
+                    bm,
+                    (0.0, half + 0.010, z),
+                    (POST_W * 0.92, 0.014, BOARD_ZTH * 1.15),
+                    METAL_IDX,
+                )
+            )
+            metal.extend(
+                add_box(
+                    bm,
+                    (0.0, -(half + 0.010), z),
+                    (POST_W * 0.92, 0.014, BOARD_ZTH * 1.15),
+                    METAL_IDX,
+                )
+            )
+
+        metal.extend(
+            add_box(
+                bm,
+                (0.0, 0.0, SHOE_H / 2.0),
+                (POST_W * SHOE_SCALE, POST_W * SHOE_SCALE, SHOE_H),
+                METAL_IDX,
+            )
+        )
+        metal.extend(add_square_band(bm, 0.22, half, 0.012, 0.028, 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, board, metal):
+    mats = obj.data.materials
+    wanted = (wood, board, 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("SignpostNrm", 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_signpost_mesh("SignpostLow", bevel_offset=0.004, bevel_segments=2)
+    high = build_signpost_mesh("SignpostHigh", bevel_offset=0.004, bevel_segments=4)
+    wood = principled("SignpostWood", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62)
+    board = principled("SignpostBoard", (0.72, 0.58, 0.28, 1.0), 0.0, 0.55)
+    metal = principled("SignpostIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38)
+    assign_slots(low, wood, board, metal)
+    assign_slots(high, wood, board, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("signpost 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("signpost has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "SignpostLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "SignpostLOD2", 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_signpost_mesh(
+        "SignpostColSrc", bevel_offset=0.0, bevel_segments=1
+    )
+    collider = convex_hull_collider(collider_src, "SignpostCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_signpost_{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(BOARD_IDX, 0) < BOARD_FACES_MIN:
+        return fail(
+            f"board faces {idx_counts.get(BOARD_IDX, 0)} < {BOARD_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(-18.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 = (3.06, -4.02, 1.38)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.78)
+    scene.collection.objects.link(aim)
+    con = cam.constraints.new("TRACK_TO")
+    con.target = aim
+    con.track_axis = "TRACK_NEGATIVE_Z"
+    con.up_axis = "UP_Y"
+    scene.camera = cam
+
+    scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id()
+    if engine == "cycles":
+        scene.cycles.samples = 32
+        scene.cycles.device = "CPU"
+    else:
+        try:
+            scene.eevee.taa_render_samples = 64
+        except AttributeError:
+            pass
+    scene.render.resolution_x = 1280
+    scene.render.resolution_y = 720
+    scene.render.image_settings.file_format = (
+        "WEBP" if path.lower().endswith(".webp") else "PNG"
+    )
+    if path.lower().endswith(".webp"):
+        scene.render.image_settings.quality = 90
+    scene.render.filepath = path
+    scene.view_settings.view_transform = "Standard"
+
+    fcode = gallery_framing.check_framing(
+        scene, cam, hero=[low], elements=[low], stage=[floor, wall],
+    )
+    if fcode:
+        return fcode
+    bpy.ops.render.render(write_still=True)
+    if not (os.path.exists(path) and os.path.getsize(path) > 0):
+        return fail("render produced no file", 14)
+    return 0
+
+
+def main():
+    argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+    p = argparse.ArgumentParser()
+    p.add_argument("--output", default=None)
+    p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"))
+    p.add_argument(
+        "--skip-decimate",
+        action="store_true",
+        help="falsification: skip the LOD DECIMATE stage",
+    )
+    args = p.parse_args(argv)
+
+    code, low, _high, 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("signpost OK")
+    return 0
+
+
+if __name__ == "__main__":
+    try:
+        sys.exit(main())
+    except Exception as e:
+        traceback.print_exc()
+        print(f"FATAL: {e}", file=sys.stderr)
+        sys.exit(1)
+
+
+
+ +
+
+ generated from examples/gallery.json + CC-BY-NC-ND-4.0 + exit 0 +
+
+ + + diff --git a/showcase/gallery.json b/showcase/gallery.json index 18c2f87..923a9c9 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -220,6 +220,18 @@ "mesh", "export" ] + }, + { + "name": "signpost", + "dir": "showcase/signpost", + "teaches": "A procedural timber fingerboard signpost through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 388 tris, three materials with 66 metal and 22 board faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.162×0.147×1.575 m, LOD ratios in band (5.2 COLLAPSE more aggressive on LOD2), convex collider 92 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/signpost-hero.webp", + "preview": "showcase/signpost/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/signpost/README.md b/showcase/signpost/README.md new file mode 100644 index 0000000..98ff19e --- /dev/null +++ b/showcase/signpost/README.md @@ -0,0 +1,74 @@ +# Signpost + +A showcase piece, not an example. Procedural timber fingerboard +signpost (square post, pyramidal cap, two painted boards, iron shoe +and straps) then the shipped pipeline: unique-cell UVs, Cycles +high-to-low normal bake, LOD chain, convex collider, Unity glTF export. + +It asserts **budget conformance** of the generated result. It does not +witness an API contract. "It rendered without error" is not a check. + +**Composes** skills `mesh-editing-and-bmesh`, `bake-high-to-low`, +`depsgraph-and-evaluated-data`, `engine-export-presets`, and snippets +`bake_normal_high_to_low.py`, `setup_bake_target_image.py`, +`lod_chain.py` / `decimate_to_budget.py`, `convex_hull_collider.py`, +`export_preset_unity.py` (helpers copied, not imported as a package). + +## Budgets + +Declared as named constants; every gate **recomputes** from the mesh, +materials, UVs, evaluated LOD, collider, or export file. + +| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | +| --- | --- | --- | +| Base triangles | 320–460 | 388 / 388 / 388 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2165 / 0.2165 / 0.1804 | +| Materials | exactly 3 distinct, ≥24 metal and ≥12 board faces | 3 slots, 66 metal, 22 board | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (1.162, 0.147, 1.575) m ± 0.01 | (1.1620, 0.1474, 1.5750), zmin 0 | +| Collider tris | ≤ 140 | 92 | +| Export | written, size > 0 | 40124 / 40124 / 40112 bytes | + +DECIMATE COLLAPSE triangle counts are **not** identical across series — +5.2.1 is more aggressive on LOD2. The gate is a ratio band, not an +exact count. Bake pixels are stochastic; the gate is `has_data` plus +operator `FINISHED`, not byte-identity. Construction uses no RNG. +Export byte counts differ by 12 B on 5.2.1 (glTF serializer), not a +gated axis. + +`--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and +exit 9 fires. That is the named budget the falsifier violates. + +## Run + +```bash +blender --background --python signpost.py -- +blender --background --python signpost.py -- --skip-decimate +blender --background --python signpost.py -- --output signpost.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/board/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/signpost/preview.webp b/showcase/signpost/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..24ccfceb82df94123cec7edf8f26b38ccf6b63f7 GIT binary patch literal 6302 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 U5iCDYy0= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + depsgraph = bpy.context.evaluated_depsgraph_get() + eval_obj = obj.evaluated_get(depsgraph) + eval_mesh = eval_obj.to_mesh() + try: + eval_mesh.calc_loop_triangles() + return len(eval_mesh.loop_triangles) + finally: + eval_obj.to_mesh_clear() + + +def deselect_all(): + for ob in list(bpy.context.view_layer.objects): + if ob is None: + continue + ob.select_set(False) + + +def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cube(bm, size=1.0) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2])) + v.co = rot @ p + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_oriented_box(bm, a, b, scale_xy, mat_idx): + a = Vector(a) + b = Vector(b) + delta = b - a + length = delta.length + if length < 1e-8: + return [] + quat = Vector((0.0, 0.0, 1.0)).rotation_difference(delta.normalized()) + eul = quat.to_euler("XYZ") + return add_box( + bm, + ((a + b) * 0.5), + (scale_xy[0], scale_xy[1], length), + mat_idx, + euler=(eul.x, eul.y, eul.z), + ) + + +def add_cyl(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius, + radius2=radius, + depth=depth, + ) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def add_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius1, + radius2=radius2, + depth=depth, + ) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def 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_signpost_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + try: + wood = [] + boards = [] + metal = [] + half = POST_W / 2.0 + + wood.extend( + add_box( + bm, + (0.0, 0.0, POST_H / 2.0), + (POST_W, POST_W, POST_H), + WOOD_IDX, + ) + ) + wood.extend( + add_cone( + bm, + (0.0, 0.0, POST_H + CAP_H / 2.0), + POST_W * 0.78, + 0.012, + CAP_H, + 4, + WOOD_IDX, + euler=(0.0, 0.0, math.pi / 4.0), + ) + ) + + if bevel_offset > 0.0: + edges = list({e for v in wood for e in v.link_edges}) + ret = bmesh.ops.bevel( + bm, + geom=edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + for f in ret.get("faces") or []: + f.material_index = WOOD_IDX + + for z, length, sign in (BOARD_A, BOARD_B): + cx = sign * (half + length / 2.0 - 0.018) + boards.extend( + add_box( + bm, + (cx, 0.0, z), + (length, BOARD_Y, BOARD_ZTH), + BOARD_IDX, + ) + ) + tip_x = cx + sign * (length / 2.0 + 0.028) + boards.extend( + add_cone( + bm, + (tip_x, 0.0, z), + 0.052, + 0.004, + 0.072, + 3, + BOARD_IDX, + euler=(0.0, sign * math.pi / 2.0, 0.0), + ) + ) + metal.extend( + add_box( + bm, + (sign * (half + 0.010), 0.0, z), + (0.020, BOARD_Y + 0.016, BOARD_ZTH + 0.018), + METAL_IDX, + ) + ) + metal.extend( + add_box( + bm, + (0.0, half + 0.010, z), + (POST_W * 0.92, 0.014, BOARD_ZTH * 1.15), + METAL_IDX, + ) + ) + metal.extend( + add_box( + bm, + (0.0, -(half + 0.010), z), + (POST_W * 0.92, 0.014, BOARD_ZTH * 1.15), + METAL_IDX, + ) + ) + + metal.extend( + add_box( + bm, + (0.0, 0.0, SHOE_H / 2.0), + (POST_W * SHOE_SCALE, POST_W * SHOE_SCALE, SHOE_H), + METAL_IDX, + ) + ) + metal.extend(add_square_band(bm, 0.22, half, 0.012, 0.028, 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, board, metal): + mats = obj.data.materials + wanted = (wood, board, 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("SignpostNrm", 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_signpost_mesh("SignpostLow", bevel_offset=0.004, bevel_segments=2) + high = build_signpost_mesh("SignpostHigh", bevel_offset=0.004, bevel_segments=4) + wood = principled("SignpostWood", (0.34, 0.18, 0.07, 1.0), 0.0, 0.62) + board = principled("SignpostBoard", (0.72, 0.58, 0.28, 1.0), 0.0, 0.55) + metal = principled("SignpostIron", (0.14, 0.145, 0.155, 1.0), 1.0, 0.38) + assign_slots(low, wood, board, metal) + assign_slots(high, wood, board, metal) + + if low.data is None or len(low.data.polygons) < 6: + return fail("signpost 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("signpost has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "SignpostLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "SignpostLOD2", 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_signpost_mesh( + "SignpostColSrc", bevel_offset=0.0, bevel_segments=1 + ) + collider = convex_hull_collider(collider_src, "SignpostCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_signpost_{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(BOARD_IDX, 0) < BOARD_FACES_MIN: + return fail( + f"board faces {idx_counts.get(BOARD_IDX, 0)} < {BOARD_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(-18.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 = (3.06, -4.02, 1.38) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.78) + scene.collection.objects.link(aim) + con = cam.constraints.new("TRACK_TO") + con.target = aim + con.track_axis = "TRACK_NEGATIVE_Z" + con.up_axis = "UP_Y" + scene.camera = cam + + scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() + if engine == "cycles": + scene.cycles.samples = 32 + scene.cycles.device = "CPU" + else: + try: + scene.eevee.taa_render_samples = 64 + except AttributeError: + pass + scene.render.resolution_x = 1280 + scene.render.resolution_y = 720 + scene.render.image_settings.file_format = ( + "WEBP" if path.lower().endswith(".webp") else "PNG" + ) + if path.lower().endswith(".webp"): + scene.render.image_settings.quality = 90 + scene.render.filepath = path + scene.view_settings.view_transform = "Standard" + + fcode = gallery_framing.check_framing( + scene, cam, hero=[low], elements=[low], stage=[floor, wall], + ) + if fcode: + return fcode + bpy.ops.render.render(write_still=True) + if not (os.path.exists(path) and os.path.getsize(path) > 0): + return fail("render produced no file", 14) + return 0 + + +def main(): + argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] + p = argparse.ArgumentParser() + p.add_argument("--output", default=None) + p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) + p.add_argument( + "--skip-decimate", + action="store_true", + help="falsification: skip the LOD DECIMATE stage", + ) + args = p.parse_args(argv) + + code, low, _high, 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("signpost OK") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + traceback.print_exc() + print(f"FATAL: {e}", file=sys.stderr) + sys.exit(1) diff --git a/tests/smoke/catalog.json b/tests/smoke/catalog.json index 444e249..cba25ba 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -91,5 +91,6 @@ {"name": "water-trough", "script": "showcase/water-trough/water_trough.py"}, {"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": "hand-pump", "script": "showcase/hand-pump/hand_pump.py"}, + {"name": "signpost", "script": "showcase/signpost/signpost.py"} ]