From 141631459b40af053a146a6894aa83b653cd52f4 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:14:20 -0400 Subject: [PATCH 1/2] feat: add watchtower showcase piece Timber lookout with budgets fitted to measured geometry on 4.5.11, 5.1.2, and 5.2.1. 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 | 1 + docs/gallery/assets/watchtower-hero.webp | Bin 0 -> 37470 bytes docs/gallery/index.html | 13 +- docs/gallery/watchtower/index.html | 1129 ++++++++++++++++++++++ showcase/gallery.json | 12 + showcase/watchtower/README.md | 74 ++ showcase/watchtower/preview.webp | Bin 0 -> 17846 bytes showcase/watchtower/watchtower.py | 805 +++++++++++++++ tests/smoke/catalog.json | 3 +- 13 files changed, 2044 insertions(+), 7 deletions(-) create mode 100644 docs/gallery/assets/watchtower-hero.webp create mode 100644 docs/gallery/watchtower/index.html create mode 100644 showcase/watchtower/README.md create mode 100644 showcase/watchtower/preview.webp create mode 100644 showcase/watchtower/watchtower.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index c60b645..65ca1f6 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -146,6 +146,7 @@ "showcase/street-lantern", "showcase/treasure-chest", "showcase/terrain-scatter", - "showcase/fence-kit" + "showcase/fence-kit", + "showcase/watchtower" ] } diff --git a/AGENTS.md b/AGENTS.md index 5fef89b..cdf772c 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 9 showcase pieces (counts are CI-enforced against README.md +examples, and 10 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 89925b4..aabf58c 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, 9 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 10 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 fa01acd..d47fdb3 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

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

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 9 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 10 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. @@ -84,6 +84,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Treasure chest: a slatted wooden chest with iron bands, corner brackets, and an open lid on a dark studio floor Terrain scatter: a Geometry Nodes dirt hill tile with bevelled masonry rocks on a dark studio floor Fence kit: a timber post-and-rail section with pyramidal caps, iron shoes and straps on a dark studio floor +Watchtower: a timber lookout with X-braces, ladder, plank platform, and pyramidal roof 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. @@ -103,6 +104,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`fence-kit`](showcase/fence-kit/) — procedural post-and-rail fence section through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`watchtower`](showcase/watchtower/) — procedural timber lookout with X-braces, ladder, platform, and pyramidal roof 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 9c46be3..bbc242b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -113,6 +113,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Market stall showcase~~ **SHIPPED** as `showcase/market-stall/` — timber frame, slatted counter, striped awning; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Procedural terrain or landscape showcase using Geometry Nodes scatter~~ **SHIPPED** as `showcase/terrain-scatter/` — GN sine-hill Mesh Grid, Index-jittered Instance-on-Points scatter replaced with bevelled masonry; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Hero prop with a more complex silhouette (lantern or treasure chest) as a second showcase piece~~ **SHIPPED** as `showcase/street-lantern/` and `showcase/treasure-chest/` — hanging iron lantern with muntined amber cage; slatted chest with iron bands and an open lid; `--skip-decimate` exits 9 on the LOD1 ratio band +- ~~Timber watchtower showcase~~ **SHIPPED** as `showcase/watchtower/` — corner posts, X-braces, plank platform, ladder, pyramidal roof; `--skip-decimate` exits 9 on the LOD1 ratio band - Wrought-iron park bench (slatted seat, scrolled legs) as a game-prop showcase piece - Wooden wheelbarrow (staved tray, single wheel, handles) as a game-prop showcase piece - ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **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/watchtower-hero.webp b/docs/gallery/assets/watchtower-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..236138954f9645cedc0006100c951539118f1999 GIT binary patch literal 37470 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 63152c6..7d544ef 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" /> - 60 examples + 61 examples +
+ + watchtower — A procedural timber watchtower through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract. + +
+

watchtower

+

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

+

witnesses Recomputed: 4656 tris, two materials with 60 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.910×0.927×1.822 m, LOD ratios in band, convex collider 64 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

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

watchtower

+

A procedural timber watchtower 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: 4656 tris, two materials with 60 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.910×0.927×1.822 m, LOD ratios in band, convex collider 64 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/watchtower/watchtower.py --
+ +
+
+

A showcase piece, not an example. Procedural timber lookout (corner posts, X-braces, plank platform, three-sided rail, ladder, pyramidal roof, iron shoes) 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 | 4550–4760 | 4656 / 4656 / 4656 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2199 | | Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 60 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.910, 0.927, 1.822) m ± 0.01 | (0.9104, 0.9267, 1.8220), zmin 0 | | Collider tris | ≤ 96 | 64 | | Export | written, size > 0 | 334288 / 334288 / 334272 bytes |

+

DECIMATE COLLAPSE triangle counts are not required to match across series — the gate is a ratio band, not an exact count. This mesh's LOD2 ratio happened to match on all three binaries. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not a gated axis.

+

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

+

Run

+
blender --background --python watchtower.py --
+blender --background --python watchtower.py -- --skip-decimate
+blender --background --python watchtower.py -- --output tower.png
+

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

+

Exit codes

+

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

+

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

+
+
+

Source

+
+ showcase/watchtower/watchtower.py + View on GitHub → +
+
"""Game-ready timber watchtower — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural lookout (posts, braces,
+platform, ladder, pyramidal roof) 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 watchtower.py --
+    blender --background --python watchtower.py -- --skip-decimate
+    blender --background --python watchtower.py -- --output tower.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
+
+HALF = 0.40
+POST = 0.080
+POST_H = 1.48
+PLAT_Z = 1.18
+RAIL_H = 0.36
+ROOF_H = 0.52
+BRACE_T = 0.038
+PLANK_T = 0.042
+IRON_T = 0.014
+SHOE_H = 0.032
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (0.910, 0.927, 1.822)
+BASE_TRIS_MIN = 4550
+BASE_TRIS_MAX = 4760
+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 = 96
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 24
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    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_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+    geo = bmesh.ops.create_cone(
+        bm,
+        cap_ends=True,
+        cap_tris=False,
+        segments=segments,
+        radius1=radius1,
+        radius2=radius2,
+        depth=depth,
+    )
+    verts = geo["verts"]
+    rot = Euler(euler).to_matrix()
+    origin = Vector(loc)
+    for v in verts:
+        v.co = rot @ v.co + origin
+    faces = {f for v in verts for f in v.link_faces}
+    for f in faces:
+        f.material_index = mat_idx
+    return verts
+
+
+def pack_uvs(bm, margin=0.08):
+    uv = bm.loops.layers.uv.new("UVMap")
+    faces = list(bm.faces)
+    n = len(faces)
+    cols = max(1, math.ceil(math.sqrt(n)))
+    rows = max(1, math.ceil(n / cols))
+    cell_w = 1.0 / cols
+    cell_h = 1.0 / rows
+    pad_u = margin * cell_w * 0.5
+    pad_v = margin * cell_h * 0.5
+    usable_w = cell_w - 2.0 * pad_u
+    usable_h = cell_h - 2.0 * pad_v
+    for i, face in enumerate(faces):
+        col = i % cols
+        row = i // cols
+        nrm = face.normal
+        ax = abs(nrm.x)
+        ay = abs(nrm.y)
+        az = abs(nrm.z)
+        coords = []
+        for loop in face.loops:
+            co = loop.vert.co
+            if az >= ax and az >= ay:
+                coords.append((co.x, co.y))
+            elif ax >= ay:
+                coords.append((co.y, co.z))
+            else:
+                coords.append((co.x, co.z))
+        xs = [c[0] for c in coords]
+        ys = [c[1] for c in coords]
+        minx, maxx = min(xs), max(xs)
+        miny, maxy = min(ys), max(ys)
+        dx = max(maxx - minx, 1e-8)
+        dy = max(maxy - miny, 1e-8)
+        origin_u = col * cell_w + pad_u
+        origin_v = row * cell_h + pad_v
+        for loop, (x, y) in zip(face.loops, coords):
+            loop[uv].uv = (
+                origin_u + (x - minx) / dx * usable_w,
+                origin_v + (y - miny) / dy * usable_h,
+            )
+
+
+def build_tower_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    try:
+        wood = []
+        posts = (
+            (-HALF, -HALF),
+            (HALF, -HALF),
+            (-HALF, HALF),
+            (HALF, HALF),
+        )
+        for px, py in posts:
+            wood.extend(
+                add_box(bm, (px, py, POST_H / 2.0), (POST, POST, POST_H), WOOD_IDX)
+            )
+
+        span = 2.0 * HALF + POST * 0.15
+        for sign in (-1.0, 1.0):
+            wood.extend(
+                add_box(bm, (0.0, sign * HALF, 0.04), (span, POST * 0.92, 0.08), WOOD_IDX)
+            )
+            wood.extend(
+                add_box(bm, (sign * HALF, 0.0, 0.04), (POST * 0.92, span, 0.08), WOOD_IDX)
+            )
+            wood.extend(
+                add_box(
+                    bm,
+                    (0.0, sign * HALF, POST_H - 0.04),
+                    (span, POST * 0.85, 0.08),
+                    WOOD_IDX,
+                )
+            )
+            wood.extend(
+                add_box(
+                    bm,
+                    (sign * HALF, 0.0, POST_H - 0.04),
+                    (POST * 0.85, span, 0.08),
+                    WOOD_IDX,
+                )
+            )
+
+        rise = 0.72
+        run = 2.0 * HALF - POST
+        blen = math.hypot(run, rise)
+        bang = math.atan2(rise, run)
+        for ysign in (-1.0, 1.0):
+            for bang_sign in (-1.0, 1.0):
+                wood.extend(
+                    add_box(
+                        bm,
+                        (0.0, ysign * (HALF + POST * 0.15), 0.52),
+                        (blen, BRACE_T, BRACE_T),
+                        WOOD_IDX,
+                        euler=(0.0, bang_sign * bang, 0.0),
+                    )
+                )
+        for xsign in (-1.0, 1.0):
+            for bang_sign in (-1.0, 1.0):
+                wood.extend(
+                    add_box(
+                        bm,
+                        (xsign * (HALF + POST * 0.15), 0.0, 0.52),
+                        (BRACE_T, blen, BRACE_T),
+                        WOOD_IDX,
+                        euler=(bang_sign * bang, 0.0, 0.0),
+                    )
+                )
+
+        n_plank = 5
+        plank_w = (2.0 * HALF - 0.04) / n_plank
+        y0 = -HALF + 0.02 + plank_w / 2.0
+        for i in range(n_plank):
+            y = y0 + i * plank_w
+            wood.extend(
+                add_box(
+                    bm,
+                    (0.0, y, PLAT_Z + PLANK_T / 2.0),
+                    (2.0 * HALF + POST * 0.4, plank_w * 0.88, PLANK_T),
+                    WOOD_IDX,
+                )
+            )
+
+        rail_z = PLAT_Z + PLANK_T + RAIL_H * 0.55
+        for px, py in ((-HALF, HALF), (HALF, HALF), (HALF, -HALF), (-HALF, -HALF)):
+            wood.extend(
+                add_box(
+                    bm,
+                    (px, py, PLAT_Z + PLANK_T + RAIL_H / 2.0),
+                    (POST * 0.62, POST * 0.62, RAIL_H),
+                    WOOD_IDX,
+                )
+            )
+        wood.extend(
+            add_box(
+                bm,
+                (0.0, HALF, rail_z),
+                (2.0 * HALF, POST * 0.42, 0.045),
+                WOOD_IDX,
+            )
+        )
+        wood.extend(
+            add_box(
+                bm,
+                (HALF, 0.12, rail_z),
+                (POST * 0.42, 2.0 * HALF * 0.72, 0.045),
+                WOOD_IDX,
+            )
+        )
+        wood.extend(
+            add_box(
+                bm,
+                (-HALF, 0.12, rail_z),
+                (POST * 0.42, 2.0 * HALF * 0.72, 0.045),
+                WOOD_IDX,
+            )
+        )
+
+        stile_y = -HALF - POST * 0.55
+        for sx in (-0.13, 0.13):
+            wood.extend(
+                add_box(
+                    bm,
+                    (sx, stile_y, PLAT_Z / 2.0),
+                    (0.038, 0.042, PLAT_Z + 0.06),
+                    WOOD_IDX,
+                )
+            )
+        n_rung = 6
+        for i in range(n_rung):
+            z = 0.16 + i * (PLAT_Z - 0.22) / (n_rung - 1)
+            wood.extend(
+                add_box(bm, (0.0, stile_y, z), (0.28, 0.032, 0.032), WOOD_IDX)
+            )
+
+        wood.extend(
+            add_box(
+                bm,
+                (0.0, 0.0, PLAT_Z + PLANK_T + 0.03),
+                (2.0 * HALF + POST * 1.15, 2.0 * HALF + POST * 1.15, 0.05),
+                WOOD_IDX,
+            )
+        )
+        wood.extend(
+            add_cone(
+                bm,
+                (0.0, 0.0, PLAT_Z + PLANK_T + 0.05 + ROOF_H / 2.0),
+                HALF + POST * 0.95,
+                0.018,
+                ROOF_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 px, py in posts:
+            add_box(
+                bm,
+                (px, py, SHOE_H / 2.0),
+                (POST * 1.38, POST * 1.38, SHOE_H),
+                METAL_IDX,
+            )
+            add_box(
+                bm,
+                (px, py, PLAT_Z + PLANK_T + IRON_T / 2.0),
+                (POST * 1.15, POST * 1.15, IRON_T),
+                METAL_IDX,
+            )
+        for sx in (-0.13, 0.13):
+            add_box(
+                bm,
+                (sx, stile_y, PLAT_Z + 0.02),
+                (0.05, 0.055, IRON_T),
+                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]
+        cx = 0.5 * (min(xs) + max(xs))
+        cy = 0.5 * (min(ys) + max(ys))
+        zmin = min(zs)
+        for v in bm.verts:
+            v.co.x -= cx
+            v.co.y -= cy
+            v.co.z -= zmin
+            if v.co.z < 0.0:
+                v.co.z = 0.0
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = 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("TowerNrm", 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_tower_mesh("TowerLow", bevel_offset=0.006, bevel_segments=2)
+    high = build_tower_mesh("TowerHigh", bevel_offset=0.006, bevel_segments=4)
+    wood = principled("TowerWood", (0.44, 0.23, 0.08, 1.0), 0.0, 0.56)
+    metal = principled("TowerIron", (0.12, 0.125, 0.14, 1.0), 1.0, 0.30)
+    assign_slots(low, wood, metal)
+    assign_slots(high, wood, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("tower 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("tower has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "TowerLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "TowerLOD2", 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_tower_mesh("TowerColSrc", bevel_offset=0.0, bevel_segments=1)
+    collider = convex_hull_collider(collider_src, "TowerCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_watchtower_{os.getpid()}.glb",
+    )
+    if os.path.exists(export_path):
+        os.remove(export_path)
+    export_unity(export_path, [low, collider])
+    export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+    print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
+    print(
+        f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+        f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+    )
+    print(
+        f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+        f"overlap={overlap:.6f} nfaces={nfaces}"
+    )
+    print(
+        f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+        f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
+    )
+    print(
+        f"measured collider_tris={col_tris} bake={bake_result} "
+        f"bake_has_data={img.has_data} export_bytes={export_size}"
+    )
+
+    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+        return fail(
+            f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
+            4,
+        ), None, None, None, None, None
+    if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+        return fail(
+            f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+        return fail(
+            f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+            6,
+        ), None, None, None, None, None
+    if overlap > UV_OVERLAP_MAX:
+        return fail(
+            f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+            7,
+        ), None, None, None, None, None
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+            f"off outer {OUTER_SIZE}",
+            8,
+        ), None, None, None, None, None
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)",
+            9,
+        ), None, None, None, None, None
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+            9,
+        ), None, None, None, None, None
+    if col_tris > COLLIDER_TRIS_MAX:
+        return fail(
+            f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+            11,
+        ), None, None, None, None, None
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return fail(
+            f"bake failed result={bake_result} has_data={img.has_data}",
+            12,
+        ), None, None, None, None, None
+    if export_size <= 0:
+        return fail("export file missing or empty", 13), None, None, None, None, None
+    return 0, low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, wood, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(wood, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-22.0)
+    low.rotation_euler.x = math.radians(2.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.6, -5.0, 5.8), 680.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+    light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+    light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (3.85, -5.15, 2.55)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.88)
+    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("watchtower 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 5eb3818..7360dbc 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -112,6 +112,18 @@ "mesh", "export" ] + }, + { + "name": "watchtower", + "dir": "showcase/watchtower", + "teaches": "A procedural timber watchtower through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 4656 tris, two materials with 60 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.910×0.927×1.822 m, LOD ratios in band, convex collider 64 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/watchtower-hero.webp", + "preview": "showcase/watchtower/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/watchtower/README.md b/showcase/watchtower/README.md new file mode 100644 index 0000000..d11249a --- /dev/null +++ b/showcase/watchtower/README.md @@ -0,0 +1,74 @@ +# Watchtower + +A showcase piece, not an example. Procedural timber lookout (corner +posts, X-braces, plank platform, three-sided rail, ladder, pyramidal +roof, iron shoes) 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 | 4550–4760 | 4656 / 4656 / 4656 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2199 / 0.2199 / 0.2199 | +| Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 60 metal | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (0.910, 0.927, 1.822) m ± 0.01 | (0.9104, 0.9267, 1.8220), zmin 0 | +| Collider tris | ≤ 96 | 64 | +| Export | written, size > 0 | 334288 / 334288 / 334272 bytes | + +DECIMATE COLLAPSE triangle counts are **not** required to match across +series — the gate is a ratio band, not an exact count. This mesh's LOD2 +ratio happened to match on all three binaries. Bake pixels are +stochastic; the gate is `has_data` plus operator `FINISHED`, not +byte-identity. Construction uses no RNG. Export byte counts differ by +16 B on 5.2.1 (glTF serializer), not a gated axis. + +`--skip-decimate` skips the LOD DECIMATE stage so LOD1 ratio is 1.0 and +exit 9 fires. That is the named budget the falsifier violates. + +## Run + +```bash +blender --background --python watchtower.py -- +blender --background --python watchtower.py -- --skip-decimate +blender --background --python watchtower.py -- --output tower.png +``` + +Smoke does not pass `--output` or `--skip-decimate`. + +## Exit codes + +File-local. `9` is a valid check code. `10` is reserved for +`gallery_framing.check_framing` on the `--output` path. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build / no UV layer | +| 4 | Base triangle count outside range | +| 5 | Material count ≠ 2 distinct slots, or metal faces missing | +| 6 | UVs outside 0..1 | +| 7 | UV AABB overlap above tolerance | +| 8 | World AABB off declared outer size | +| 9 | LOD ratio band (`--skip-decimate` lands here) | +| 10 | Framing gate (render path only) | +| 11 | Collider triangle count above ceiling | +| 12 | Bake did not finish or image has no data | +| 13 | Export file missing or empty | +| 14 | `--output` produced no file | diff --git a/showcase/watchtower/preview.webp b/showcase/watchtower/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..a268a34c79529fdfc00afc03bbe0a6a096e2acf7 GIT binary patch literal 17846 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 (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + 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_cone(bm, loc, radius1, radius2, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)): + geo = bmesh.ops.create_cone( + bm, + cap_ends=True, + cap_tris=False, + segments=segments, + radius1=radius1, + radius2=radius2, + depth=depth, + ) + verts = geo["verts"] + rot = Euler(euler).to_matrix() + origin = Vector(loc) + for v in verts: + v.co = rot @ v.co + origin + faces = {f for v in verts for f in v.link_faces} + for f in faces: + f.material_index = mat_idx + return verts + + +def pack_uvs(bm, margin=0.08): + uv = bm.loops.layers.uv.new("UVMap") + faces = list(bm.faces) + n = len(faces) + cols = max(1, math.ceil(math.sqrt(n))) + rows = max(1, math.ceil(n / cols)) + cell_w = 1.0 / cols + cell_h = 1.0 / rows + pad_u = margin * cell_w * 0.5 + pad_v = margin * cell_h * 0.5 + usable_w = cell_w - 2.0 * pad_u + usable_h = cell_h - 2.0 * pad_v + for i, face in enumerate(faces): + col = i % cols + row = i // cols + nrm = face.normal + ax = abs(nrm.x) + ay = abs(nrm.y) + az = abs(nrm.z) + coords = [] + for loop in face.loops: + co = loop.vert.co + if az >= ax and az >= ay: + coords.append((co.x, co.y)) + elif ax >= ay: + coords.append((co.y, co.z)) + else: + coords.append((co.x, co.z)) + xs = [c[0] for c in coords] + ys = [c[1] for c in coords] + minx, maxx = min(xs), max(xs) + miny, maxy = min(ys), max(ys) + dx = max(maxx - minx, 1e-8) + dy = max(maxy - miny, 1e-8) + origin_u = col * cell_w + pad_u + origin_v = row * cell_h + pad_v + for loop, (x, y) in zip(face.loops, coords): + loop[uv].uv = ( + origin_u + (x - minx) / dx * usable_w, + origin_v + (y - miny) / dy * usable_h, + ) + + +def build_tower_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + try: + wood = [] + posts = ( + (-HALF, -HALF), + (HALF, -HALF), + (-HALF, HALF), + (HALF, HALF), + ) + for px, py in posts: + wood.extend( + add_box(bm, (px, py, POST_H / 2.0), (POST, POST, POST_H), WOOD_IDX) + ) + + span = 2.0 * HALF + POST * 0.15 + for sign in (-1.0, 1.0): + wood.extend( + add_box(bm, (0.0, sign * HALF, 0.04), (span, POST * 0.92, 0.08), WOOD_IDX) + ) + wood.extend( + add_box(bm, (sign * HALF, 0.0, 0.04), (POST * 0.92, span, 0.08), WOOD_IDX) + ) + wood.extend( + add_box( + bm, + (0.0, sign * HALF, POST_H - 0.04), + (span, POST * 0.85, 0.08), + WOOD_IDX, + ) + ) + wood.extend( + add_box( + bm, + (sign * HALF, 0.0, POST_H - 0.04), + (POST * 0.85, span, 0.08), + WOOD_IDX, + ) + ) + + rise = 0.72 + run = 2.0 * HALF - POST + blen = math.hypot(run, rise) + bang = math.atan2(rise, run) + for ysign in (-1.0, 1.0): + for bang_sign in (-1.0, 1.0): + wood.extend( + add_box( + bm, + (0.0, ysign * (HALF + POST * 0.15), 0.52), + (blen, BRACE_T, BRACE_T), + WOOD_IDX, + euler=(0.0, bang_sign * bang, 0.0), + ) + ) + for xsign in (-1.0, 1.0): + for bang_sign in (-1.0, 1.0): + wood.extend( + add_box( + bm, + (xsign * (HALF + POST * 0.15), 0.0, 0.52), + (BRACE_T, blen, BRACE_T), + WOOD_IDX, + euler=(bang_sign * bang, 0.0, 0.0), + ) + ) + + n_plank = 5 + plank_w = (2.0 * HALF - 0.04) / n_plank + y0 = -HALF + 0.02 + plank_w / 2.0 + for i in range(n_plank): + y = y0 + i * plank_w + wood.extend( + add_box( + bm, + (0.0, y, PLAT_Z + PLANK_T / 2.0), + (2.0 * HALF + POST * 0.4, plank_w * 0.88, PLANK_T), + WOOD_IDX, + ) + ) + + rail_z = PLAT_Z + PLANK_T + RAIL_H * 0.55 + for px, py in ((-HALF, HALF), (HALF, HALF), (HALF, -HALF), (-HALF, -HALF)): + wood.extend( + add_box( + bm, + (px, py, PLAT_Z + PLANK_T + RAIL_H / 2.0), + (POST * 0.62, POST * 0.62, RAIL_H), + WOOD_IDX, + ) + ) + wood.extend( + add_box( + bm, + (0.0, HALF, rail_z), + (2.0 * HALF, POST * 0.42, 0.045), + WOOD_IDX, + ) + ) + wood.extend( + add_box( + bm, + (HALF, 0.12, rail_z), + (POST * 0.42, 2.0 * HALF * 0.72, 0.045), + WOOD_IDX, + ) + ) + wood.extend( + add_box( + bm, + (-HALF, 0.12, rail_z), + (POST * 0.42, 2.0 * HALF * 0.72, 0.045), + WOOD_IDX, + ) + ) + + stile_y = -HALF - POST * 0.55 + for sx in (-0.13, 0.13): + wood.extend( + add_box( + bm, + (sx, stile_y, PLAT_Z / 2.0), + (0.038, 0.042, PLAT_Z + 0.06), + WOOD_IDX, + ) + ) + n_rung = 6 + for i in range(n_rung): + z = 0.16 + i * (PLAT_Z - 0.22) / (n_rung - 1) + wood.extend( + add_box(bm, (0.0, stile_y, z), (0.28, 0.032, 0.032), WOOD_IDX) + ) + + wood.extend( + add_box( + bm, + (0.0, 0.0, PLAT_Z + PLANK_T + 0.03), + (2.0 * HALF + POST * 1.15, 2.0 * HALF + POST * 1.15, 0.05), + WOOD_IDX, + ) + ) + wood.extend( + add_cone( + bm, + (0.0, 0.0, PLAT_Z + PLANK_T + 0.05 + ROOF_H / 2.0), + HALF + POST * 0.95, + 0.018, + ROOF_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 px, py in posts: + add_box( + bm, + (px, py, SHOE_H / 2.0), + (POST * 1.38, POST * 1.38, SHOE_H), + METAL_IDX, + ) + add_box( + bm, + (px, py, PLAT_Z + PLANK_T + IRON_T / 2.0), + (POST * 1.15, POST * 1.15, IRON_T), + METAL_IDX, + ) + for sx in (-0.13, 0.13): + add_box( + bm, + (sx, stile_y, PLAT_Z + 0.02), + (0.05, 0.055, IRON_T), + 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] + cx = 0.5 * (min(xs) + max(xs)) + cy = 0.5 * (min(ys) + max(ys)) + zmin = min(zs) + for v in bm.verts: + v.co.x -= cx + v.co.y -= cy + v.co.z -= zmin + if v.co.z < 0.0: + v.co.z = 0.0 + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = 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("TowerNrm", 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_tower_mesh("TowerLow", bevel_offset=0.006, bevel_segments=2) + high = build_tower_mesh("TowerHigh", bevel_offset=0.006, bevel_segments=4) + wood = principled("TowerWood", (0.44, 0.23, 0.08, 1.0), 0.0, 0.56) + metal = principled("TowerIron", (0.12, 0.125, 0.14, 1.0), 1.0, 0.30) + assign_slots(low, wood, metal) + assign_slots(high, wood, metal) + + if low.data is None or len(low.data.polygons) < 6: + return fail("tower 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("tower has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "TowerLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "TowerLOD2", 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_tower_mesh("TowerColSrc", bevel_offset=0.0, bevel_segments=1) + collider = convex_hull_collider(collider_src, "TowerCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_watchtower_{os.getpid()}.glb", + ) + if os.path.exists(export_path): + os.remove(export_path) + export_unity(export_path, [low, collider]) + export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") + print( + f"measured base_tris={base_tris} lod1_tris={lod1_tris} " + f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" + ) + print( + f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " + f"overlap={overlap:.6f} nfaces={nfaces}" + ) + print( + f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"outer={OUTER_SIZE} zmin={bb[2]:.4f}" + ) + print( + f"measured collider_tris={col_tris} bake={bake_result} " + f"bake_has_data={img.has_data} export_bytes={export_size}" + ) + + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): + return fail( + f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", + 4, + ), None, None, None, None, None + if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT: + return fail( + f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", + 5, + ), None, None, None, None, None + if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN: + return fail( + f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}", + 5, + ), None, None, None, None, None + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return fail( + f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", + 6, + ), None, None, None, None, None + if overlap > UV_OVERLAP_MAX: + return fail( + f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", + 7, + ), None, None, None, None, None + if ( + abs(size_x - OUTER_SIZE[0]) > BBOX_TOL + or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL + ): + return fail( + f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"off outer {OUTER_SIZE}", + 8, + ), None, None, None, None, None + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return fail( + f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", + 9, + ), None, None, None, None, None + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return fail( + f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", + 9, + ), None, None, None, None, None + if col_tris > COLLIDER_TRIS_MAX: + return fail( + f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", + 11, + ), None, None, None, None, None + if bake_result != {"FINISHED"} or not img.has_data: + return fail( + f"bake failed result={bake_result} has_data={img.has_data}", + 12, + ), None, None, None, None, None + if export_size <= 0: + return fail("export file missing or empty", 13), None, None, None, None, None + return 0, low, high, wood, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + nrm = nt.nodes.new("ShaderNodeNormalMap") + nrm.inputs["Strength"].default_value = 1.0 + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) + + +def render_still(low, wood, tex, path, engine): + scene = bpy.context.scene + wire_normal(wood, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(-22.0) + low.rotation_euler.x = math.radians(2.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 8.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-3.6, -5.0, 5.8), 680.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) + light("Fill", (5.0, -3.6, 2.6), 48.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) + light("Wedge", (2.4, 4.2, 4.1), 640.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (3.85, -5.15, 2.55) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.88) + 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("watchtower 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 4abc0ab..34c2501 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -82,5 +82,6 @@ {"name": "street-lantern", "script": "showcase/street-lantern/street_lantern.py"}, {"name": "treasure-chest", "script": "showcase/treasure-chest/treasure_chest.py"}, {"name": "terrain-scatter", "script": "showcase/terrain-scatter/terrain_scatter.py"}, - {"name": "fence-kit", "script": "showcase/fence-kit/fence_kit.py"} + {"name": "fence-kit", "script": "showcase/fence-kit/fence_kit.py"}, + {"name": "watchtower", "script": "showcase/watchtower/watchtower.py"} ] From d2467d4853d576b8f8f2f4ed710e1d06033aae4f Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:15:06 -0400 Subject: [PATCH 2/2] feat: add cart showcase piece Two-wheel wooden cart with budgets fitted to measured geometry on 4.5.11, 5.1.2, and 5.2.1. 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 | 1 + docs/gallery/assets/cart-hero.webp | Bin 0 -> 47524 bytes docs/gallery/cart/index.html | 1161 ++++++++++++++++++++++++++++ docs/gallery/index.html | 13 +- showcase/cart/README.md | 74 ++ showcase/cart/cart.py | 837 ++++++++++++++++++++ showcase/cart/preview.webp | Bin 0 -> 19774 bytes showcase/gallery.json | 12 + tests/smoke/catalog.json | 3 +- 13 files changed, 2108 insertions(+), 7 deletions(-) create mode 100644 docs/gallery/assets/cart-hero.webp create mode 100644 docs/gallery/cart/index.html create mode 100644 showcase/cart/README.md create mode 100644 showcase/cart/cart.py create mode 100644 showcase/cart/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 65ca1f6..0c76b83 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -147,6 +147,7 @@ "showcase/treasure-chest", "showcase/terrain-scatter", "showcase/fence-kit", - "showcase/watchtower" + "showcase/watchtower", + "showcase/cart" ] } diff --git a/AGENTS.md b/AGENTS.md index cdf772c..17d2def 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 10 showcase pieces (counts are CI-enforced against README.md +examples, and 11 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 aabf58c..13c3df0 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, 10 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 11 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 d47fdb3..10fb0c6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  10 showcase pieces + 16 skills  •  9 rules  •  3 templates  •  27 snippets  •  59 examples  •  11 showcase pieces

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 10 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 11 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. @@ -85,6 +85,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Terrain scatter: a Geometry Nodes dirt hill tile with bevelled masonry rocks on a dark studio floor Fence kit: a timber post-and-rail section with pyramidal caps, iron shoes and straps on a dark studio floor Watchtower: a timber lookout with X-braces, ladder, plank platform, and pyramidal roof on a dark studio floor +Cart: a two-wheel wooden cart with spoked wheels, slatted bed, and shafts 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. @@ -106,6 +107,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`watchtower`](showcase/watchtower/) — procedural timber lookout with X-braces, ladder, platform, and pyramidal roof through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`cart`](showcase/cart/) — procedural two-wheel wooden cart with spoked wheels, slatted bed, and shafts 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 bbc242b..b76ced6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -114,6 +114,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Procedural terrain or landscape showcase using Geometry Nodes scatter~~ **SHIPPED** as `showcase/terrain-scatter/` — GN sine-hill Mesh Grid, Index-jittered Instance-on-Points scatter replaced with bevelled masonry; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Hero prop with a more complex silhouette (lantern or treasure chest) as a second showcase piece~~ **SHIPPED** as `showcase/street-lantern/` and `showcase/treasure-chest/` — hanging iron lantern with muntined amber cage; slatted chest with iron bands and an open lid; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Timber watchtower showcase~~ **SHIPPED** as `showcase/watchtower/` — corner posts, X-braces, plank platform, ladder, pyramidal roof; `--skip-decimate` exits 9 on the LOD1 ratio band +- ~~Two-wheel wooden cart showcase~~ **SHIPPED** as `showcase/cart/` — slatted bed, shafts, spoked wheels, iron hubs and axle; `--skip-decimate` exits 9 on the LOD1 ratio band - Wrought-iron park bench (slatted seat, scrolled legs) as a game-prop showcase piece - Wooden wheelbarrow (staved tray, single wheel, handles) 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/cart-hero.webp b/docs/gallery/assets/cart-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..891b2756d5ea41dbb61357f19611587dbccbca0b GIT binary patch literal 47524 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 QqFAP0TTi_a6<7cO07AtzLjV8( literal 0 HcmV?d00001 diff --git a/docs/gallery/cart/index.html b/docs/gallery/cart/index.html new file mode 100644 index 0000000..2441c4b --- /dev/null +++ b/docs/gallery/cart/index.html @@ -0,0 +1,1161 @@ + + + + + + cart — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +

+
+

cart

+

A procedural two-wheel wooden cart 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: 2572 tris, two materials with 100 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.547×0.749×0.656 m, LOD ratios in band, convex collider 306 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/cart/cart.py --
+ +
+
+

A showcase piece, not an example. Procedural two-wheel wooden cart (slatted bed, side walls, shafts, spoked wheels, iron hubs and axle) 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 | 2470–2680 | 2572 / 2572 / 2572 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2193 / 0.2193 / 0.2146 | | Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 100 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.547, 0.749, 0.656) m ± 0.01 | (1.5473, 0.7486, 0.6560), zmin 0 | | Collider tris | ≤ 360 | 306 | | Export | written, size > 0 | 202500 / 202500 / 202492 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 cart.py --
+blender --background --python cart.py -- --skip-decimate
+blender --background --python cart.py -- --output cart.png
+

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

+

Exit codes

+

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

+

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

+
+
+

Source

+
+ showcase/cart/cart.py + View on GitHub → +
+
"""Game-ready two-wheel wooden cart — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural cart (staved bed, side
+walls, shafts, spoked wheels, iron hubs and axle) 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 cart.py --
+    blender --background --python cart.py -- --skip-decimate
+    blender --background --python cart.py -- --output cart.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
+
+RIM_MAJOR = 0.30
+RIM_MINOR = 0.028
+TRACK = 0.68
+AXLE_X = -0.16
+HUB_R = 0.052
+HUB_W = 0.046
+N_SPOKES = 8
+SPOKE_T = 0.018
+BED_L = 0.92
+BED_W = 0.50
+BED_T = 0.038
+WALL_H = 0.16
+WALL_T = 0.032
+SHAFT_L = 0.58
+SHAFT_T = 0.034
+N_SLATS = 6
+IRON_T = 0.014
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (1.547, 0.749, 0.656)
+BASE_TRIS_MIN = 2470
+BASE_TRIS_MAX = 2680
+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 = 360
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+METAL_FACES_MIN = 24
+
+WOOD_IDX = 0
+METAL_IDX = 1
+
+
+def eevee_engine_id():
+    return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+    print(f"ERROR: {msg}", file=sys.stderr)
+    return code
+
+
+def triangle_count(mesh):
+    mesh.calc_loop_triangles()
+    return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+    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_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_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)):
+    n_major = 16
+    n_minor = 8
+    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 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 add_wheel(bm, loc, wood, metal):
+    wood.extend(
+        add_rim(bm, loc, RIM_MAJOR, RIM_MINOR, WOOD_IDX, euler=(math.pi / 2.0, 0.0, 0.0))
+    )
+    wood.extend(
+        add_cyl(
+            bm,
+            loc,
+            HUB_R,
+            HUB_W,
+            12,
+            WOOD_IDX,
+            euler=(math.pi / 2.0, 0.0, 0.0),
+        )
+    )
+    inner = HUB_R + 0.008
+    outer = RIM_MAJOR - RIM_MINOR * 0.55
+    mid_r = 0.5 * (inner + outer)
+    slen = outer - inner
+    for i in range(N_SPOKES):
+        a = i * (2.0 * math.pi / N_SPOKES)
+        dx = math.cos(a)
+        dz = math.sin(a)
+        cx = loc[0] + dx * mid_r
+        cy = loc[1]
+        cz = loc[2] + dz * mid_r
+        wood.extend(
+            add_box(
+                bm,
+                (cx, cy, cz),
+                (slen, SPOKE_T * 0.85, SPOKE_T),
+                WOOD_IDX,
+                euler=(0.0, -a, 0.0),
+            )
+        )
+    metal.extend(
+        add_cyl(
+            bm,
+            loc,
+            HUB_R + 0.010,
+            0.016,
+            12,
+            METAL_IDX,
+            euler=(math.pi / 2.0, 0.0, 0.0),
+        )
+    )
+    metal.extend(
+        add_cyl(
+            bm,
+            (loc[0], loc[1] + (HUB_W * 0.55 if loc[1] > 0 else -HUB_W * 0.55), loc[2]),
+            0.022,
+            0.018,
+            10,
+            METAL_IDX,
+            euler=(math.pi / 2.0, 0.0, 0.0),
+        )
+    )
+
+
+def build_cart_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    try:
+        body = []
+        wood_wheels = []
+        metal = []
+        axle_z = RIM_MAJOR
+        bed_z = axle_z + 0.10
+        bed_cx = 0.06
+
+        add_wheel(bm, (AXLE_X, TRACK / 2.0, axle_z), wood_wheels, metal)
+        add_wheel(bm, (AXLE_X, -TRACK / 2.0, axle_z), wood_wheels, metal)
+
+        metal.extend(
+            add_cyl(
+                bm,
+                (AXLE_X, 0.0, axle_z),
+                0.020,
+                TRACK + 0.06,
+                10,
+                METAL_IDX,
+                euler=(math.pi / 2.0, 0.0, 0.0),
+            )
+        )
+
+        slat_w = BED_W / N_SLATS
+        y0 = -BED_W / 2.0 + slat_w / 2.0
+        for i in range(N_SLATS):
+            y = y0 + i * slat_w
+            body.extend(
+                add_box(
+                    bm,
+                    (bed_cx, y, bed_z),
+                    (BED_L, slat_w * 0.88, BED_T),
+                    WOOD_IDX,
+                )
+            )
+        for ysign in (-1.0, 1.0):
+            body.extend(
+                add_box(
+                    bm,
+                    (bed_cx, ysign * (BED_W / 2.0 + WALL_T / 2.0), bed_z + WALL_H / 2.0),
+                    (BED_L * 0.98, WALL_T, WALL_H),
+                    WOOD_IDX,
+                )
+            )
+        body.extend(
+            add_box(
+                bm,
+                (bed_cx + BED_L / 2.0 - WALL_T / 2.0, 0.0, bed_z + WALL_H / 2.0 + 0.02),
+                (WALL_T, BED_W + WALL_T * 2.0, WALL_H + 0.04),
+                WOOD_IDX,
+            )
+        )
+        body.extend(
+            add_box(
+                bm,
+                (bed_cx - BED_L / 2.0 + WALL_T / 2.0, 0.0, bed_z + 0.06),
+                (WALL_T, BED_W + WALL_T, 0.12),
+                WOOD_IDX,
+            )
+        )
+        for xj in (bed_cx - BED_L * 0.32, bed_cx + BED_L * 0.28):
+            body.extend(
+                add_box(
+                    bm,
+                    (xj, 0.0, bed_z - BED_T / 2.0 - 0.022),
+                    (0.055, BED_W * 0.92, 0.044),
+                    WOOD_IDX,
+                )
+            )
+
+        shaft_x = bed_cx + BED_L / 2.0 + SHAFT_L / 2.0 - 0.04
+        shaft_z = bed_z - 0.02
+        pitch = math.radians(7.0)
+        for ysign in (-1.0, 1.0):
+            body.extend(
+                add_box(
+                    bm,
+                    (shaft_x, ysign * 0.12, shaft_z),
+                    (SHAFT_L, SHAFT_T, SHAFT_T),
+                    WOOD_IDX,
+                    euler=(0.0, pitch, 0.0),
+                )
+            )
+            metal.extend(
+                add_box(
+                    bm,
+                    (bed_cx + BED_L / 2.0 - 0.02, ysign * 0.12, bed_z - 0.01),
+                    (0.05, 0.042, IRON_T),
+                    METAL_IDX,
+                )
+            )
+
+        for sx, sy in (
+            (bed_cx - BED_L / 2.0 + 0.04, -BED_W / 2.0),
+            (bed_cx - BED_L / 2.0 + 0.04, BED_W / 2.0),
+            (bed_cx + BED_L / 2.0 - 0.04, -BED_W / 2.0),
+            (bed_cx + BED_L / 2.0 - 0.04, BED_W / 2.0),
+        ):
+            metal.extend(
+                add_box(
+                    bm,
+                    (sx, sy, bed_z + 0.01),
+                    (0.055, 0.055, IRON_T),
+                    METAL_IDX,
+                )
+            )
+
+        if bevel_offset > 0.0:
+            edges = list({e for v in body 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
+
+        xs = [v.co.x for v in bm.verts]
+        ys = [v.co.y for v in bm.verts]
+        zs = [v.co.z for v in bm.verts]
+        cx = 0.5 * (min(xs) + max(xs))
+        cy = 0.5 * (min(ys) + max(ys))
+        zmin = min(zs)
+        for v in bm.verts:
+            v.co.x -= cx
+            v.co.y -= cy
+            v.co.z -= zmin
+            if v.co.z < 0.0:
+                v.co.z = 0.0
+
+        pack_uvs(bm)
+        bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+        for face in bm.faces:
+            face.smooth = 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("CartNrm", 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_cart_mesh("CartLow", bevel_offset=0.005, bevel_segments=2)
+    high = build_cart_mesh("CartHigh", bevel_offset=0.005, bevel_segments=4)
+    wood = principled("CartWood", (0.42, 0.22, 0.08, 1.0), 0.0, 0.58)
+    metal = principled("CartIron", (0.13, 0.135, 0.15, 1.0), 1.0, 0.32)
+    assign_slots(low, wood, metal)
+    assign_slots(high, wood, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("cart 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("cart has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "CartLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "CartLOD2", 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_cart_mesh("CartColSrc", bevel_offset=0.0, bevel_segments=1)
+    collider = convex_hull_collider(collider_src, "CartCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_cart_{os.getpid()}.glb",
+    )
+    if os.path.exists(export_path):
+        os.remove(export_path)
+    export_unity(export_path, [low, collider])
+    export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+    print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}")
+    print(
+        f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+        f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+    )
+    print(
+        f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+        f"overlap={overlap:.6f} nfaces={nfaces}"
+    )
+    print(
+        f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+        f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
+    )
+    print(
+        f"measured collider_tris={col_tris} bake={bake_result} "
+        f"bake_has_data={img.has_data} export_bytes={export_size}"
+    )
+
+    if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+        return fail(
+            f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
+            4,
+        ), None, None, None, None, None
+    if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+        return fail(
+            f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
+            5,
+        ), None, None, None, None, None
+    if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN:
+        return fail(
+            f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}",
+            5,
+        ), None, None, None, None, None
+    if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS:
+        return fail(
+            f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})",
+            6,
+        ), None, None, None, None, None
+    if overlap > UV_OVERLAP_MAX:
+        return fail(
+            f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}",
+            7,
+        ), None, None, None, None, None
+    if (
+        abs(size_x - OUTER_SIZE[0]) > BBOX_TOL
+        or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL
+        or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL
+    ):
+        return fail(
+            f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+            f"off outer {OUTER_SIZE}",
+            8,
+        ), None, None, None, None, None
+    if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX):
+        return fail(
+            f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] "
+            "(--skip-decimate is the designed fail)",
+            9,
+        ), None, None, None, None, None
+    if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX):
+        return fail(
+            f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]",
+            9,
+        ), None, None, None, None, None
+    if col_tris > COLLIDER_TRIS_MAX:
+        return fail(
+            f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}",
+            11,
+        ), None, None, None, None, None
+    if bake_result != {"FINISHED"} or not img.has_data:
+        return fail(
+            f"bake failed result={bake_result} has_data={img.has_data}",
+            12,
+        ), None, None, None, None, None
+    if export_size <= 0:
+        return fail("export file missing or empty", 13), None, None, None, None, None
+    return 0, low, high, wood, tex, collider
+
+
+def wire_normal(mat, tex):
+    nt = mat.node_tree
+    bsdf = nt.nodes["Principled BSDF"]
+    nrm = nt.nodes.new("ShaderNodeNormalMap")
+    nrm.inputs["Strength"].default_value = 1.0
+    nt.links.new(tex.outputs["Color"], nrm.inputs["Color"])
+    nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"])
+
+
+def render_still(low, wood, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(wood, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-28.0)
+    low.rotation_euler.x = math.radians(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.4, -4.8, 5.4), 640.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36))
+    light("Fill", (4.8, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50))
+    light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198))
+
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 50.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (1.34, -1.62, 0.88)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.05, 0.0, 0.30)
+    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("cart 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 7d544ef..aec7c6a 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" /> - 61 examples + 62 examples
@@ -995,6 +995,17 @@

watchtower

View example
+
+ + cart — A procedural two-wheel wooden cart through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract. + +
+

cart

+

A procedural two-wheel wooden cart through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 2572 tris, two materials with 100 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.547×0.749×0.656 m, LOD ratios in band, convex collider 306 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+ View example +
+
diff --git a/showcase/cart/README.md b/showcase/cart/README.md new file mode 100644 index 0000000..424a48a --- /dev/null +++ b/showcase/cart/README.md @@ -0,0 +1,74 @@ +# Cart + +A showcase piece, not an example. Procedural two-wheel wooden cart +(slatted bed, side walls, shafts, spoked wheels, iron hubs and axle) +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 | 2470–2680 | 2572 / 2572 / 2572 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2193 / 0.2193 / 0.2146 | +| Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 100 metal | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (1.547, 0.749, 0.656) m ± 0.01 | (1.5473, 0.7486, 0.6560), zmin 0 | +| Collider tris | ≤ 360 | 306 | +| Export | written, size > 0 | 202500 / 202500 / 202492 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 cart.py -- +blender --background --python cart.py -- --skip-decimate +blender --background --python cart.py -- --output cart.png +``` + +Smoke does not pass `--output` or `--skip-decimate`. + +## Exit codes + +File-local. `9` is a valid check code. `10` is reserved for +`gallery_framing.check_framing` on the `--output` path. + +| Code | Meaning | +| --- | --- | +| 0 | Success | +| 1 | Uncaught exception (FATAL wrapper) | +| 2 | argparse / usage | +| 3 | Mesh did not build / no UV layer | +| 4 | Base triangle count outside range | +| 5 | Material count ≠ 2 distinct slots, or metal faces missing | +| 6 | UVs outside 0..1 | +| 7 | UV AABB overlap above tolerance | +| 8 | World AABB off declared outer size | +| 9 | LOD ratio band (`--skip-decimate` lands here) | +| 10 | Framing gate (render path only) | +| 11 | Collider triangle count above ceiling | +| 12 | Bake did not finish or image has no data | +| 13 | Export file missing or empty | +| 14 | `--output` produced no file | diff --git a/showcase/cart/cart.py b/showcase/cart/cart.py new file mode 100644 index 0000000..cb7f36e --- /dev/null +++ b/showcase/cart/cart.py @@ -0,0 +1,837 @@ +"""Game-ready two-wheel wooden cart — a showcase piece, not an example. + +Asserts budget conformance of a procedural cart (staved bed, side +walls, shafts, spoked wheels, iron hubs and axle) 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 cart.py -- + blender --background --python cart.py -- --skip-decimate + blender --background --python cart.py -- --output cart.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 + +RIM_MAJOR = 0.30 +RIM_MINOR = 0.028 +TRACK = 0.68 +AXLE_X = -0.16 +HUB_R = 0.052 +HUB_W = 0.046 +N_SPOKES = 8 +SPOKE_T = 0.018 +BED_L = 0.92 +BED_W = 0.50 +BED_T = 0.038 +WALL_H = 0.16 +WALL_T = 0.032 +SHAFT_L = 0.58 +SHAFT_T = 0.034 +N_SLATS = 6 +IRON_T = 0.014 + +BBOX_TOL = 0.01 +OUTER_SIZE = (1.547, 0.749, 0.656) +BASE_TRIS_MIN = 2470 +BASE_TRIS_MAX = 2680 +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 = 360 +BAKE_RES = 256 +CAGE_EXTRUSION = 0.08 +METAL_FACES_MIN = 24 + +WOOD_IDX = 0 +METAL_IDX = 1 + + +def eevee_engine_id(): + return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" + + +def fail(msg, code): + print(f"ERROR: {msg}", file=sys.stderr) + return code + + +def triangle_count(mesh): + mesh.calc_loop_triangles() + return len(mesh.loop_triangles) + + +def evaluated_triangle_count(obj): + 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_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_rim(bm, loc, major, minor, mat_idx, euler=(0.0, 0.0, 0.0)): + n_major = 16 + n_minor = 8 + 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 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 add_wheel(bm, loc, wood, metal): + wood.extend( + add_rim(bm, loc, RIM_MAJOR, RIM_MINOR, WOOD_IDX, euler=(math.pi / 2.0, 0.0, 0.0)) + ) + wood.extend( + add_cyl( + bm, + loc, + HUB_R, + HUB_W, + 12, + WOOD_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + ) + inner = HUB_R + 0.008 + outer = RIM_MAJOR - RIM_MINOR * 0.55 + mid_r = 0.5 * (inner + outer) + slen = outer - inner + for i in range(N_SPOKES): + a = i * (2.0 * math.pi / N_SPOKES) + dx = math.cos(a) + dz = math.sin(a) + cx = loc[0] + dx * mid_r + cy = loc[1] + cz = loc[2] + dz * mid_r + wood.extend( + add_box( + bm, + (cx, cy, cz), + (slen, SPOKE_T * 0.85, SPOKE_T), + WOOD_IDX, + euler=(0.0, -a, 0.0), + ) + ) + metal.extend( + add_cyl( + bm, + loc, + HUB_R + 0.010, + 0.016, + 12, + METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + ) + metal.extend( + add_cyl( + bm, + (loc[0], loc[1] + (HUB_W * 0.55 if loc[1] > 0 else -HUB_W * 0.55), loc[2]), + 0.022, + 0.018, + 10, + METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + ) + + +def build_cart_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + try: + body = [] + wood_wheels = [] + metal = [] + axle_z = RIM_MAJOR + bed_z = axle_z + 0.10 + bed_cx = 0.06 + + add_wheel(bm, (AXLE_X, TRACK / 2.0, axle_z), wood_wheels, metal) + add_wheel(bm, (AXLE_X, -TRACK / 2.0, axle_z), wood_wheels, metal) + + metal.extend( + add_cyl( + bm, + (AXLE_X, 0.0, axle_z), + 0.020, + TRACK + 0.06, + 10, + METAL_IDX, + euler=(math.pi / 2.0, 0.0, 0.0), + ) + ) + + slat_w = BED_W / N_SLATS + y0 = -BED_W / 2.0 + slat_w / 2.0 + for i in range(N_SLATS): + y = y0 + i * slat_w + body.extend( + add_box( + bm, + (bed_cx, y, bed_z), + (BED_L, slat_w * 0.88, BED_T), + WOOD_IDX, + ) + ) + for ysign in (-1.0, 1.0): + body.extend( + add_box( + bm, + (bed_cx, ysign * (BED_W / 2.0 + WALL_T / 2.0), bed_z + WALL_H / 2.0), + (BED_L * 0.98, WALL_T, WALL_H), + WOOD_IDX, + ) + ) + body.extend( + add_box( + bm, + (bed_cx + BED_L / 2.0 - WALL_T / 2.0, 0.0, bed_z + WALL_H / 2.0 + 0.02), + (WALL_T, BED_W + WALL_T * 2.0, WALL_H + 0.04), + WOOD_IDX, + ) + ) + body.extend( + add_box( + bm, + (bed_cx - BED_L / 2.0 + WALL_T / 2.0, 0.0, bed_z + 0.06), + (WALL_T, BED_W + WALL_T, 0.12), + WOOD_IDX, + ) + ) + for xj in (bed_cx - BED_L * 0.32, bed_cx + BED_L * 0.28): + body.extend( + add_box( + bm, + (xj, 0.0, bed_z - BED_T / 2.0 - 0.022), + (0.055, BED_W * 0.92, 0.044), + WOOD_IDX, + ) + ) + + shaft_x = bed_cx + BED_L / 2.0 + SHAFT_L / 2.0 - 0.04 + shaft_z = bed_z - 0.02 + pitch = math.radians(7.0) + for ysign in (-1.0, 1.0): + body.extend( + add_box( + bm, + (shaft_x, ysign * 0.12, shaft_z), + (SHAFT_L, SHAFT_T, SHAFT_T), + WOOD_IDX, + euler=(0.0, pitch, 0.0), + ) + ) + metal.extend( + add_box( + bm, + (bed_cx + BED_L / 2.0 - 0.02, ysign * 0.12, bed_z - 0.01), + (0.05, 0.042, IRON_T), + METAL_IDX, + ) + ) + + for sx, sy in ( + (bed_cx - BED_L / 2.0 + 0.04, -BED_W / 2.0), + (bed_cx - BED_L / 2.0 + 0.04, BED_W / 2.0), + (bed_cx + BED_L / 2.0 - 0.04, -BED_W / 2.0), + (bed_cx + BED_L / 2.0 - 0.04, BED_W / 2.0), + ): + metal.extend( + add_box( + bm, + (sx, sy, bed_z + 0.01), + (0.055, 0.055, IRON_T), + METAL_IDX, + ) + ) + + if bevel_offset > 0.0: + edges = list({e for v in body 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 + + xs = [v.co.x for v in bm.verts] + ys = [v.co.y for v in bm.verts] + zs = [v.co.z for v in bm.verts] + cx = 0.5 * (min(xs) + max(xs)) + cy = 0.5 * (min(ys) + max(ys)) + zmin = min(zs) + for v in bm.verts: + v.co.x -= cx + v.co.y -= cy + v.co.z -= zmin + if v.co.z < 0.0: + v.co.z = 0.0 + + pack_uvs(bm) + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + for face in bm.faces: + face.smooth = 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("CartNrm", 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_cart_mesh("CartLow", bevel_offset=0.005, bevel_segments=2) + high = build_cart_mesh("CartHigh", bevel_offset=0.005, bevel_segments=4) + wood = principled("CartWood", (0.42, 0.22, 0.08, 1.0), 0.0, 0.58) + metal = principled("CartIron", (0.13, 0.135, 0.15, 1.0), 1.0, 0.32) + assign_slots(low, wood, metal) + assign_slots(high, wood, metal) + + if low.data is None or len(low.data.polygons) < 6: + return fail("cart 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("cart has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "CartLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "CartLOD2", 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_cart_mesh("CartColSrc", bevel_offset=0.0, bevel_segments=1) + collider = convex_hull_collider(collider_src, "CartCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_cart_{os.getpid()}.glb", + ) + if os.path.exists(export_path): + os.remove(export_path) + export_unity(export_path, [low, collider]) + export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 + + print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") + print( + f"measured base_tris={base_tris} lod1_tris={lod1_tris} " + f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}" + ) + print( + f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " + f"overlap={overlap:.6f} nfaces={nfaces}" + ) + print( + f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"outer={OUTER_SIZE} zmin={bb[2]:.4f}" + ) + print( + f"measured collider_tris={col_tris} bake={bake_result} " + f"bake_has_data={img.has_data} export_bytes={export_size}" + ) + + if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): + return fail( + f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", + 4, + ), None, None, None, None, None + if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT: + return fail( + f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", + 5, + ), None, None, None, None, None + if idx_counts.get(METAL_IDX, 0) < METAL_FACES_MIN: + return fail( + f"metal faces {idx_counts.get(METAL_IDX, 0)} < {METAL_FACES_MIN}", + 5, + ), None, None, None, None, None + if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: + return fail( + f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", + 6, + ), None, None, None, None, None + if overlap > UV_OVERLAP_MAX: + return fail( + f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", + 7, + ), None, None, None, None, None + if ( + abs(size_x - OUTER_SIZE[0]) > BBOX_TOL + or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL + or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL + ): + return fail( + f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) " + f"off outer {OUTER_SIZE}", + 8, + ), None, None, None, None, None + if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): + return fail( + f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " + "(--skip-decimate is the designed fail)", + 9, + ), None, None, None, None, None + if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): + return fail( + f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", + 9, + ), None, None, None, None, None + if col_tris > COLLIDER_TRIS_MAX: + return fail( + f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", + 11, + ), None, None, None, None, None + if bake_result != {"FINISHED"} or not img.has_data: + return fail( + f"bake failed result={bake_result} has_data={img.has_data}", + 12, + ), None, None, None, None, None + if export_size <= 0: + return fail("export file missing or empty", 13), None, None, None, None, None + return 0, low, high, wood, tex, collider + + +def wire_normal(mat, tex): + nt = mat.node_tree + bsdf = nt.nodes["Principled BSDF"] + nrm = nt.nodes.new("ShaderNodeNormalMap") + nrm.inputs["Strength"].default_value = 1.0 + nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) + nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) + + +def render_still(low, wood, tex, path, engine): + scene = bpy.context.scene + wire_normal(wood, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(-28.0) + low.rotation_euler.x = math.radians(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.4, -4.8, 5.4), 640.0, 4.0, (1.0, 0.94, 0.86), (50, 0, -36)) + light("Fill", (4.8, -3.4, 2.4), 46.0, 8.0, (0.72, 0.82, 1.0), (62, 0, 50)) + light("Wedge", (2.2, 4.0, 3.8), 600.0, 5.5, (1.0, 0.70, 0.40), (-70, 0, 198)) + + cam_data = bpy.data.cameras.new("Cam") + cam_data.lens = 50.0 + cam = bpy.data.objects.new("Cam", cam_data) + cam.location = (1.34, -1.62, 0.88) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.05, 0.0, 0.30) + 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("cart 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/cart/preview.webp b/showcase/cart/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..c7127ceffdc2c417a18c907604cb4fdff22bbb57 GIT binary patch literal 19774 zcmV(nK=Qv*Nk&FCO#lE_MM6+kP&gneO#lFJC<2`UDzF5j0zOeFkwzn;q$H!Yc#x0^ z32AO~q&Tb#;Db)X!~U12cS`&&$Gi9azc?QUkl!KKr9G!aA06N0V^Gll%Kr=C`y~9s z_dn-<9(=TTt~dOD?VmXRG5(A9Kehi$AJl)-dk%k9{*&u-(_fgsHP8J2y1xehyn1T& zRR3A^GyW~TleOATc7jb54oF>l z`3{&V9l@YOWqpdhh(1OW*~AvKws~UjKKkAhiUXNp-2Y*qKY>n2vUE5PVVG#>AEkO0Ikm~T>7*LsuH9Of`H!76^Z-H(eorT zZ?heJ(_``?CBhf2&gDX71PS3PjYux`LOjqH+||aW{!)&?Qp24*fRrI0y)G6 zC~7!8A8F<}x}=vLTYgV_$svU9pv9 z`U>RHvlZWb1ZNTg)o7>A#~taDui_?gUeT6Ptq0mc1M(^X4FiWCzLyMc0SG{R7^(OI zaaY;b^1qDdxTdc~*btramETKtXqK}Ok zy}WL3|t4uk%h+=zHtdYqP>&0Dh=N;pl} z12i@3zXPC>1&PVK;bk`mfl3Ffo%#xcHkA^Lvg+%p`OhT&!FN|7U}-Y>pJ(6l|3PO= zUei^SjR4KBIPT<&xdNPMsm6Fmo<^cLOb5Gv;N*eNM*}}(*_4y=e!Qlzs*@sHX_Bi> zbwc!f!9j8O_qD_q787iNvM%z5v+}=80(@AV&nJH~uElI#$^b$bopFZjk1ImL>TnJ4 z41F96pKke*qjpws*a>YenfL6Q2S4NcH)kJtp-g@7&4-!??H1T6urzJSF{h({n_jUN z)QF34gn3q)0p7^uX%sOmzNFIFXBDJc90Ii?Khj~srs4RYn$f^u-%^!);xWz(KH)&S ziGT=N`YL)pGeedf-_7U>+i4+Y9)jP(RayHEVn6k}d3whR3pj0AOV?QnW1=a$RjYJ( zr_^0HZ7ujLR*5L?(}$kS%Ut@V%reBi>NqdB@}-)2afHIQk9|z5NrbC7f>WbkS;he=6)?bC4~n?iIbH=SUhksW z%%O6Nl(&xx2)*kW6CgWarNBW9qVsnK zu|()*aJT1-!(S69m1xYFG!-I<3*hr={(&q5Lg0tC<+%N3PZ>b7?J# znGt+x^u{*wxR^negsXZ2Fl#IQ5$)ct*KB+(4c4jo&?}#CB*7Ky9vLiy>O&zF%=dw! z0cZMr)XVJEaVW1Du&d$thH3BL<4-P!f>O&E0 zs-k4r_sEV&AfdjxSfX$d3zF)*;P6u1%_!b0bHY0bV8v`7I5E3K%8cM?=@=1D_tC7V z-gVcfwMUW>R0RoTta7jI;$)zP=DABw^)HaVf~l8Bi|)Ed=OQy}OO>T7+koG-fe)-9 z*?4r*!_E0i)<}Qb6b>nYz{))6;nXG(?tdfNX80m_>ZAksATnH^pze=K+m(_<8$nM$ z;YMTf?8%sK6=+)62Y1`4u z)>kBTE%bRO4858{#L(lIda=y!D#kY6u1~t+HF0ZPCgUFV%?C?%%8}i30O7nRnF0)t zYKw9{{3%^*FpB6dt%2v`3lB+V5pbJwKUGO?-&i!G2^Wz_I30!EMr;452jOuI*{2iK za{jh2YFb{!@RUoJIjB8o6j#pc{#`BJB`=|Fp+gNzOltj6eUxz$CMlJ$cazgfO`SOT zAY|;LKh0!^+YhlCg#ICC!%?dFxNx9zB9y%l#!8N?RvGd?y{x0sA=Id&)GzQ^-<-ST z`}oC)o%?T84|m17qH9daVbAbIO+--Mh?@)`=xa$cXLRHLrh} z6qWlQbz}8f%Ba-OEWw!Z#*FEGOCGw%p)CBbTfWC~N;0@EL2%96{8E|>{SH36K;A=1qZw>j=dv+mj5aUxa`eYk|?r5L_ZF7wXX zkKBA6+_{cMS1CejsfRCp3`XlnXe;mAP<8=*qqEiTrhz3YDR4(EFHi`7@H7q;(j6&i54%A*vLFvYff9aYL9)P>--5GHWPp$zmB=z#qp9ms)+b(rM35 zh>(!{WWry6_DvbLhEgei+ZKdZOXnMzgZ3IH9O-03oZG&@(XV<$3h2-!O3Pd*Cx5l~E z5uD8TnnlxLcX34Y=b_txF+p zUs^#+(xYBWpLHGf_RJx>Hv}@P&MOH`+!_Qa=Et@Lk+d-@>CrwKy}0Eu!#c`)oo)$i zd^KAm+3gZ<@-~E0xcUuUA_+3J)5Os9<6r*Rb9s)vbAyD#1GH!carkYhGrm1LT~X$a z+`sRQBLsY8(cpOTg6bI44F3^E(pnDUseLz>C?OuiAl#Nj`Ozv)xab!-0tO`4p@!6|+yN;ivH2Fl`u9#5u* z(EO;ap%xt!Zu^YsWA|J0Fjm+(XeHDXPJr*Jx(D}Xjr`QGut-;ZbSZHIsm(;N9(dlB z#Jf30=}~kEJb9MJFB^g3!P&+UN2h{5`w_5;W4r`rl13Xb&3hb?^LDBhG}DDM=3llg zMqOvUvjuEMlk~euVi)}A(yqKSlJaoO(fiuaRovdj9xgikjkGUCg(_@^Mh9}2;j_yXVmT{bOdzURfBZGO^l7G$rf=M+Gzb>6Yi^pRM6^7# zV4HT-Q21rVot;~~wXzhlg^!RhpkH`}BO{*+*a#XsT}khRym3>xOsqPdY-4g*8k-{y zB@kPd%V;IpwsSm`hzCGh!Tnf8@pM$i!FcW|*_g2QvSs%6aEH5ltY%f@}i*ThXk?JqYN3?ZTl6hWu$Hh5jljvi%? z_sJd1;PG;j#nROnwFA_`?HB<3_TQ{!u>zLk>E#|en@Em4hj5UeEw+l3gX|)$taAX? z%(hL8aJQho|AE@zH~FFEWnBU^Nkevfp!pH;n7!*h{l6dTTga2eT;HIK$n`uDfr4k>zEH7g z-XKv@nYhr!|HXx7rK1F71UFHQ7fgsIIbX`S!vF1tfM1IU?LS%pq4{E#mdQo!#Su|2 z>c~f?)w*wIVYf8ezw@Z{_=2VPyp0z3h~ARl5rcEM_v#wNqWLBhs4PDrn-t<+xTG9s zq~7aF7(9(ZfGpHE1tuzz)f-CG1BjiLqRtLKY)^=Gn-Zb&Crw3L%rQr2PoaRt?TgYv z<7kyMIk+{m*5yZAfS8H?<_UFSmg_W0Y|_9=OEND1Wg(}8Y&4mo46Zy47w`^9OGh9HAab=C@V_7QsEwO^69_M*C!wR zPKORai={GJ@jE&tl}?tuAA9_*8*G5cTh%KEH;G;0u^56t^ErD7rcT^`Z&w~*d+ncn z^|IcIn9L-5H`seBWRkA-DSQpTR_xd_hrsm~weCg2pyWxrf9Sw{YQ+p{W%wwmVL(zM zFL4M}l(MiH!g-=Zt{+Zhys_e1CAe|}I9&~>^%7Ub4`4kp4|e|M1sQ&H2uYpESn(KC zXuqHB3Ox9Ll)J-hkr9N z$TVH=qh0Hy2r1w}r1Ax)@pjpZVOjs@_<&8e=jhT*I z-k?nPs3c#hm!tTW7Z+w8*U*SDe1?K7Ht|c|vnHTGQUX?aW2AkD3*4I`5ITm!H_#e6 z%T^BNde5=zBBsD&++MGG*n0W`qE`0y3t&x-{G7Ys|u3xK- z(f9g8xMd%@ThkG6^|1Bx>h`Ul68kb2vTinKrZ~0c+Sz_rRezv%Oc~syax}l zkaHAiN4N~|2=0`dNwuZ77Zr-q9^p3x2rY{OsLiXCHcS8j{`?N}!s@`SRbY$g6`=V# z|Jr)aG{l_AKYcoa>WAC*<5)D!(YP~kFORNDCAoc-z%Wd)kmcA;Capmy1h}Woimu_s zwd8d^(p~E2bGTy~V=brC$tRg>CUBSd4$D^ukeOux*UfEY#vo#W@AKTE!)JqzKA_zKEJqZ+s)7a*d>#CG_Obze z^ftwfRA8Tu6xQLi#+Q+a$)&ZQvRcQUrBEN?9! zW(+y`2qZXAOPXG4-ACwZ^$7lZQ!n= zfbzpky0ZnMX1rwaCI&&hr4uRCTCqIozeDyUGe8CUYPmGTW&ICw6z8xC9}u@l`ZN>E zn+nJqul6aUL?dFMmT2Kb76t3l1ict<8s+`)7wr;yTK)A#reCpcf@e|q|_a@ ztVpb1S_HI`h8+XHpK3-i!;y+#_@5ay-bM`-v!IoO5BxV|_RL&OsScJ{0BIaEJ#m+A z)E^9?Ru;RqfK1b`@yd5VYh9GT&TP(>%-_%r`j&UXD7qRdbr zS~esnZ$i1DkI@Ni)G#Kd)Cu?krglwF#GveYW zz&1aUF+*x>TErcnd3?9JUi!}gv|C!I#7#*~FXzO(i1@i70x6ZVk}KhEiO12!nMac8 zFT>;apUPnJAiPB8Rgn?T)1He=L~deK_Hb#D38&lBMNOntm!)j!^{hPRnQp}P{lSil zH`|6EC^SbmNRRbi$sSH>biyAG-0bqWW_oJrArlQzBN{q_5DoJ&1x~K;sp~bk@bL1~ z=sG;zS%Jp~p@;X%c&jWUie#YPEXN0l2403prbIOnwJD(`1%hLr~TR^O*Z z-P1h+*n0zOpWX4oFTv}0A-~<5Aj9i92NW+-;O=+}KEHYxWVXAFJ9IS+}Pj$5qu9ffN{dJ@2dI zr-dFlY#B8%*vH3PqSG#RR_;%~%`VJF_V>hunrchX;~>cfCgv0XqGGGq-)la4=MnI! zX@Rgb$O6!MxN8c$?<-zpb`$n;>)-D_jNTg3FV~I{9_~M@#E*02p=HUoUTaN-Qbh%7 z3p)ivWP9E@$GX~mGw3Pehkqva1NE^*eBbMJA@{gyCR#bt8Qp#Ra_oM9+;HNzp;k`+ zO31XmNRy-~=WBs(;zusiSc+eSieDvHrE&+jy#!upGCUInzYrnbV-~B6aOJ^rLw|{X zGKVaoXOAfC95e%pFVPh4{<_r45guXFmAZ0)x?M6Zyzb-+{F0@+ghq;ZU*5kwJN0*; z%u{pWSln7|ep<)GJmv1)rqN&5Tw{HZQbO)?LYST!UF8~B2MHRmy?#aZ^I!f;*VO8_ z981NTjaeNF2{)nb)*HOGT?29rzqsXb{8@Aq2nZxG>?c>sc8^yD2NW+{Qj<9b8|!pM z3hO4niF7%b(xHB97bA!YliH>Uq+l$OSE!D&0zA&M2G1`wr?NHh7_g~#g(gHk8^N<@ zcz}n0i`?Q67)=SUbYcTje;Nf~?vFnZHK~*ox1u+V?5;*5fAZU%fW4CJ%_ZlTkWE{I z^3TZ8GcAukbT-*=iIYMBrbAhumMh#+*OE6iff%~=?_T$+=IIO`68}Y+`EO2 zJ7bj(pHEL(COg-F7)XZ+xYhrfC@W7BGeoDjK9?!ymY`U1VJ?y zX9N~sgd4qCy2l#-nSzA-)wZSd^LOyU@PF8sMFH{BYWySh7?cyt_~teVweFAMVpbwDcoF?b7iVsKn; zCIUVcwP;GVL*vlaX}J?pMN2G=%mGXSV2{lhO(6R+gE0^-qpJg?&?q7@uSXJErXeds zV~O_);ktW+9y!Du_pe%OcLnwA+OEj0 zzU&MNza5w2w=rs*o{QJ++<+~yZw(XLEqIEd4WR&8?O(9%E)#t$mN<!TQ3h7SAYdQD8Mp;|y#xoXv|O zHRf(|lPWaOl4M4yH(Q#p1+srFoqx^J3l!)7!jNQ#%ggJsJ!2Vjtop(72HoC-)HxW$+CsQrKtm7nGv@nOf!iIkyg{q9p%)^9mtBF4{9_DaLGmlujwI_ z-Qz$pcJwprNnsW+7>bChxs8|*XHgen=)KXhk(by?p~*#v{TqK+P^$$rihY4=GAOVL5*Yio*SRTg()>fO2KyN(Q-NV8=U3$3y|h(HJ2c*h9t?5 z7luj+%2ue$3K&sJ031!10C{SXTn{<|`WZFkeM{jjBwj0r78iD}0F4894A*8=bngH< zzOHtYg9_2@CM($*k@scf3Co24?0?xr!x<-tMDHQ(R-<5Ku;Hlb0A92!38zd8o%vm6 z7!0NF0oXcNLUXAp=1uU=syk*C%&k zn)M>xEVmo3|+ABpFkCkLQk<&if-O}_P4aar9V0uZg}^Dgaw)9Ebw zw3rUqXEis#R#e$}ayZ#Bl1h>!QSt*n%V4qzDe(eY5OMccqflALxrgGUvN|)~AB-iu$kJ{qq{_8dJ=PQdcwep5JgB|z zwaTBqqU~oay{vv4RjptNFrr`ZLRPy=dJ|6Ovu*!HHL0?MR`)>$8yC*MFgwvwPB-Zv ztKZpDqdj7dqr>W;5!Ta4q**P&2gqFoLMIm5fNlA_yckqs3i|;$<;3kHBvj795-OZ0 zbJ5HHRBicwX!T=d5F$NSZQpfsoOiwL0tPIu{3RFtf4%!4LXIlq_QVH`9RB+>-*W27 z`^*$5x#-d|*BY77vIMlb@8vsGO{IT`vvj?l%#K8yCF@FixsC}uXrNZPf<1V!SBnQd z`rxDzM1ec7m_;yyG7NZ4;$A7p+i$ubt|DGu>0;#Q+zqSg0rds{5O2uGyy^pTk(qg= zM^TsOEtn#>WfP#@OGiIx?q6!(ao;bIoAN&PVmEEsuSE1Zm$!!LRah9OA_;Y4nG_U&s zoCHlKAXLF8QI+5OmEwSpXIbQCa81k67ethqG)Q7So+KH&VKZPFdG{2~N{~rPVQ{r%F08-S)qHkf#6#|>KDm9T<+%=d3BbTtsPY{r> z<_wua;OqKu^jb37_wL&?@Lb%kBZ;}`E-=__0rpJ^9O*K3UulK>?y@s%7?g);H1gviFkYRxs&)Q-!x((oT|>qLb=dUn z8Ze#^hf5{uqDY`T%1On$1#OYHrog7A*ieLk1aCesq*dO>?!j6)^`=-Jzxs5shLGIcI^~e~6SN{rk)4sIT&22iG=m-r>v*Ub8>bK= zzq>e0bEhOJNa!2|F^K;(_S+U83Qt|&BYaiv zJ(U!;Si}gVe?K$>bstYTaE?+yxI{AE~~1XWQH@{d}klmOX`OAcFq@ z03xf$R--;Dk$?Ps0{!uDfo67~wWklLuFIWE%;4eOI`IB^tA@qEyNl}+r3rZ-hCYUx@Lk7R92p^f6d8+9-xvnFCF)?)r#ZmH zMXNgcA!vzuFjaF~YqYUcllXO>F4g%^U4fWP|8PiQ9sT>MR#SE|E!YYFPMX6Ku{s!F z4_j@%(C(69qs8Wv!9zoInMgeJPPNS}Zp|2Vg@{iQpIBZ!H%#1G0Dc5$j4!=tnJk(U zA{)PyfpnsF0<%U(6Y#6<^%z3NuzcL&F!z5;GQ`=Cx9vIR4*~sN>w7o)j#u_Y>$dbK zY0hZ&$OQg@N`S<^=%T`g4{_+XJP+sl(?ghdbr26ae}lYz(*^))wL267*Z=?lLkRd&0jGgu^k_;TjS9HqB>7Xf@yg zO`xc(EA&buja=sXj@ei*GP*10LO#YuFvKC?+#9d!eB+&Zf@k4QEWjt4s2sDZdAslO zTD3$?Mb{Mtztlz>43<|GrNbGb;rZ*8m1Cs1&EdVm`7iV+W>1}a8Gg;Ah1#1_rNDq96Qy+q#FMon z2?{m+3h63^Ekp3jEuNf`Ch|pFa5d@cVo|G_BY(h(0jew@0EEv+*K`GeviUM*a>Jtq z67$-k1>gsJh;#if&%QWY1q-XjL>gUI&x6~I&D{g5Wx#|@rO?9WTwZo>l}&grC^(}R zsoKhvZL{w1lgwcS8=UiqF%q!1*oI&Xt1jiS?3Cc2PN4Ovc7;!4lnuyBm2dW@t_B=| zQTP*LzLa#5_ZuDRGgomb-q<@yF*BM-Oy7H%WUVJEA!a-#K4j_Pw)MJ=v22L6EbBR) zXfhdI{P8i^G2(^l0BXnSC$ZawXLj1aPo@s3XU?djITu7j`@S09R3J8i z0L2YVvI)x6NMLtBBXvF}*^aedx(TGB?O9%OUePYWPtcthVsQHno*_aSbw6jpL!ivb zH^GWx>XyZ8Y|+j#@T8_~T0i8Gd_?C7PAtCwA_#CzZR|$h+JslAMP8u;QM=FVIQu7j z)1Y@#>2j&ACr%sJ;$aE?9k_Rhe~}x_pzm?RNYk-q}4)M{8``bSj5~b~j`e z6>4{98&zuvq{{p?%nO$2A7xv|GOJwau(1s@R*l>lXuY6H{F>#~$cd)cC|+F1Qsu&( z?p(ZLl{L*8@o*^(QAkl@pq3vj5F)9mV(Oe6khGD*s7k*)A-oPoO;Aaa*VT$s=;2~| zvWfmT9K1t-4Q0+4cvW4q5q71IV`x!$H`7GK&5?snyTJV1)CX+e8ydBX4J5&J`!n(I zUS3P1{&a=WjdTA$$JQ%NQzFTuXy&wQ628=9>|^fSg?#Cb724(^qOWTC^{u?ZUpRPQ zfn?*>@F+#=&7Jt-GMNzu>t-J*o{Jhv@Z8suTncL#DfqMc58$i0VMPC52v6M8aCRPP zJ=SXl*qEN>m3RZaQ*+a($8+48-Q)wGFliQ*pSV^oT3f`v10tL_gcDW7cFwK1**cFL zYNXR{0Cu_lGLc`aRx|wJVTaqHZ@OpcybVpkbwfU^zyJ-Jl9%#48&#vkEUy*5EhKn* z3S7ZTeD6e~ZFrt(J!oOyKC{uc(UrC(FcLkpd z5hc}p6df$>u5xMF z@JHA92?6?TuK!o^)c}9?4aY51i`Vp9M_rEbn{aFs&eLPmCJ`#JK9f>;LTp0ZjbVc8 z>+dMY)(MNFm`Yp#7CDO6HdJ_6F&^=Mn9Ix*1jK7OQ)7$G``f^#JoZcU*X)CtlEO8$ z6sp%6K@7||nSdhI0ew3bQ=$gYdkkxm&dXY|xx#WcfpDy2WP$5i0HqR}v>8qVZZzh- z?MI*Ga-|7o17hSVD5zf?NMsm(lRi|4q#!IyYn|&C&noB%NPHzS1Qu*PoG$FBUEhM# z$*hG86nask6mbR-f(Owd*M;-F?^<-ti0NUl7dsnl+KE3*CIA&(pZZHglf2MBND63r zehUyA5z=8O_`5P$=^c4-kInRV0xS9!nr7JZ4`xUv`J?X$4@%*I+&@_i?$kHMbDiYe zRwjdr{7ciLd5}iD)9+8*G2u3j!5v~Tn6BHR76p7R9;&+cu)m!an4ai37p7q2J?2lQ z4>CJ|+ealEqLZ?%qfQ+=6fv=oB*%%}FX#s8?#Jbda{zb0P0+T3LUNKG&*#y+M@5Y9 zkGs^VJYwW#0`@&IW#5`yvY=rq_IaiA9a&LfVSbkn0qdN*@ z0-(UZmWt-&v4BnTwg}CDChTJoEcV&LiFGgaFKWnq7IEUFbEnYI8?~>FG+r1iY3G2Z~{b?7PHBY6>9;*4NL{H?>C~w}+7G(NTwD;n)@E;XwVvP*vl6%g^(r6XK#Ve%;0OnD}^)!L`K6(|WcN))-ZE7P=^q$* zozykvj*1m3AgalhtG%~#0*4=cvE|egUzeRqe{o^Ec1g)iktuD_BZmLqDk0*&GJBIUDg+9fV=Q)tj)X^nZ0T_^sQQ8Z(T=4c zr?uAnkFq^?GD|*st^gPlQAnH>P{gR*A+lXLxt!spaj#}@u_ z%espVY32V4^Q&b-toMr4soe$Z`ONJSa5fvOYpPs?itonR%?eHQ<~JN?A{8*dO{9b+ zjcbqjfIN}j94DW5kn2*4`7+8IJ^57VK0c%_ztfX#4EVCrY@ulvBbX@)n46v_c3S`8 zn>VGXb>{aG;B;9Bm7<}QARWFVV5Pv)y3ME~3EA5a(~?h~(tff%ZtI*UM}^FCJ+RL6RlxobpXbi7 zyi)14mw_sTm|`=ZV~k4CVogRnq=)rPwuYBZ#+1O6;y4cEWstBtaVw`rwoGud_4#+L zn+Qan|J~hpN<@O*C3bD4(Dew#k`ayoePj7v1Q($@ZKGam=o2szP2sC2fxI}pCTO+H zS0O6Yo)pRJe#cpi3GeoImV?*_(m0wzB`Z3}P`><+fU?j}#F}UVj@f~wV({SDy*>{} zD4WlmsJ`)sN%?9vBL>c0@Z;z-y^tyX9qM_xHHaXQ?9#Sc3ZWn}n z+!IgXo_N)PR#x4LoSDQcbf1R8+YC8=F0qX(T|iemA3sJR>qGI|c>IdgXOoP>b`5*? zY7P8*UHGNCP_-t19)MI!HabME;k^Y#S33)=RP!_i+qch8BXyb^uXz&bmtdtM##uZnlWe)j|2;DuS&wtqikWrAPSi#BH;W zX6pKFC<&aGkO22_zF%KsmVzicd-6@xnFG#Z8~Y^#G_g2%MZx6q=A6g1E*AzuxtfBB z^!7X^-bN6ZkRGz_NU%3P?Er9H{k>etgo|--$k2Gh1+;1o%+P%QnJ9yo+OE+Z5L-`P zl~eH_0T5n*uuoRS-L-MbrrfYfrux{i+c-gLuFNWD$nUwR-=xx;<3h{E7QC?F?pam8 zy?RnII|lJOx~^dx)V;eZ;e${Y~O;5aXn#*SKIVswN&!+rE5zCpPA7yk*(&kcnIzxrA!6Kcbr3C z8k8smU}aAi!3*F)s$tjTo3uGp*Vm`dnUG?%7i2a=daa?q-;-o{T#W?3pt0VZsJ-{j zQUQF7t6nO2?@3_Z!YqPVp9-k@8XXa`kl*VY&^M0J000T}D!t}L=q)a$ zFQVqm$Wv)ET$yPW9!nV7)gKSugu!o1CT`?0BEX|=%w|Z>dW52VjodOqYNInMA;|M1 z5B!?bV49hpi4ocu9Fex1pyV&5!IH#!v<6Z_YZ=8Z8Q&y*G=JmCiXrq`JC5yMZRN2d zt&$oVf0z3rI6v-2`ahuC1p|}{8OgnWF9SDf2)|eoPM4F8AFW4~r(Lg+IrgaQRIto*sInoxiUH(ZADrz>?Hit5RJhqhG!!Gr!k7RJ|6+fQQclWfl#k*g zOlR%f^mmG_&wMrkm*X5tO`DYr{@{w4>4Me(k*aV~FWTa`xiZ_vwcnYSySrV(-3TGN zhDz^uww4mQ?U7KE)D#B*aVz2IfhlZKtV%uxP)0YXyd#K-VsF zqj-+Ll2uIE4UFt{^yCIpOpWwQhd>DUv$VJX0Mf2mvEnh~aIR;ouFXFwxw>5pqYr5| zX~pW{PVOaRUb@h5(zN@REOv8TMP+8J!l7InfSGyt3_kgLOp+WB47$TPtPD=^atpb$&6 zDDYI)_;d5q|EQuq43TI`3F(5FZM+XKMtvZp6U*gQyOMSS?+Sx?8ViHk{dc?vnohO! z>xi_!S(BCpA@(+DaUu@d2_lE8e#(i9o7S*bsN+iEgCho+@s%Q}m2_Sk2=828I9T=; zAKa1woAI1hn^X37B};OoqAYVu@Zm>=0~~`OWRx*{6Vp^E17y+8)|5qYqYKzogqHid z%Nj1jn+BKwc+im-7(E9*m*3Q<{8`#iQ1f-9Y-|K+QyaS1-#Vecz0$|oD$vPC5f+9s zOy)m>WTSYhru^k7435P0`S%x`SgJ)12@dhhPiID!q{z*y3Kdq z1uv5F_qX%JMGqW^$=CtkJ$jYa`DI$qIC3Y8_0J0tyH+E-uC^4rGBf4-!i*~{~Dm#BL_5I;iMiCkJ ztl{SX#+<6fTu8WB-FMxW^Z)=;82}G@$mq>6qY%rdC_GM*xk~@pJi@Nzy>@rI4!O)- z+WQuI>v zv;0y?G=sbmr?MZKBVcXy=KLUAfnZ`~)5$&bd(qeRzWi*l_yfVg`Z1xikc$dMDAi?O z7o5dVnTL%wIL`XW3@g!~9JRq$DX7o9w1&C_;qFHO^MX^4?Y>ffA!jRHT5S>0j0-zb zRq06H;8|BNhxl{q_}NUFW!azreDDE}Um1{?hVcI1Vj|4WQ~@&zgi;IPo6*=bbQ#MIyk zaXjLl0C=4XkCpw?z%&GUYS2wK$(%^Jid<#>2@0?0Ne*ZHFH;WXqohk#U|& zq6Eo}BN+vsgDWWlFl8}(DZ0uK?18;7)gwK}%hI%F4_HthdoT-~uU@XMyIU$P#))c; z5S<+GeE0Gj8}}D+`Ze^x00|ds?!{m*WX<2H4i17E9iFIRWVLs!nx844Q-W547z&)B z+Z{GeOsy%#e6zTB(`9!*r+VgjNczEB~k zpxevK$_5xXYIM5!!;dsj?3DYIz>)k0e&UPeSNal957W6QH1QIpe;~(5Cr1@XGz6Vjma*mb~-ivraU%T^cRq*!`(81=|(HfQF!m$ zxa@gk{qu-cH^ka_~sqV#ub6zzF) z#i6pbs(C>q#1wV=qjUp(nm(%O{sx2Y*&v^ne>^_O>C-8eAR}Ah0X8u76l=j($QK(S zO56jS`koaYVlmT+!sH#v$f0sM_+nIfeM_h@r`*!rQ3V$-k^{C9!!w}}zGz4#z(}87 zZjN*tWbRJK-@O54l@M0U0z4A&()zOW+)?~g zSan&edQ7;;eK&j9^0*h>epWb~71TP3wahJ<+*HiDXHl>#!L{B>9yJ`$h-mj*f<17M ztl~K72A;H(MeUxRHI>|c3xN$knoY0|rzVsSln{$zNN(*67o0PJB5*jIl_;N?E%^Z2 zbBh(*P0DT3%%kZ@@Wf*!O^UClT@hN%5nbb+M!7g%%ZasRPy|UZ*5y5jwq^Bk^ z?>WO?c`gx3%n~>~tkX#?P&;a=O9oZRn;PJwy6n&moN>Js|N%s$93;A%lu*w66S#I7}hS8`!`$*dq_7UY}Nt#1#zsQ zhDn^o3>`8o2Ei6`I?JJ#l^rnNG6$vSPXb<6<*T1*3Q*}@Ew(0 z*diX&`*{Ap`dtGLd`l(lg#uriWRKlZ31iKT z0DSyxjvOnHC|p7RaMbB_^M@JEy&i=hY013lGt;B2`HRQsF26@Hl%^jD22ZqA1< zxNk0Vu6?1un(-Tva0SU_7z-5QLO9{AT^L6WM2eB;us6%Ky1mvJDx;nj0U_mfu$=v+ z&Y!6-*xIy;O)TZU3<{kiedgu}z47lq?t|H=1!g8BgLAFClr$`L4$u_rwj!Uz{Ze*y zkBY}U?&ng~@?!Jh&LgLy6Q6!1-e=~mNniRT+m??rAY=b{|brZ=r5}9_NyrId<`0(J! zju&}L=u9+Tgk)TIZGoUyX6?aDp94qlJqDZPg$zVy6wqI8iZoWbe77XI;o z)e=zw^$K{L?_`Zj8foBJDW%GDmVaKLXX*td0r~M3D8`n03UKG;=^ATDao#?M@x-6% z!?jY?@Zr4t#huEeAXN{;=<~jXuOEw z1gk!1z%Mj+Wt!HRR@YT7zv6(x(;<(347pi>0`Vkv2@hE25>mJY*VXts7+l&xu$dtg8r;dZgvlm|`YsAv13J1z z2kVmax&NYWFqyvVqAZO{Aps?BZiE5;TEQiH=|}Oq!;whgiLIL7&$ZpUQ0ylK zm~Xn`4a8ynq3PH_#PUHpY7z#Rd)|lsqJPIuiPxg&w8>Avr>g0JD;PQ6&F}ZPl7AGA z#$8;vU#a>eq|z&fo>@St+u#M#i|^L@B>p)M~a=_3!pRD^l)8zoNjtDmT} z>p{fe-Mp?8F(+ijS~^XWIXdk#jq1uo{$VJt?f&$^o$u$fR$CTm^dtb(kkUAx%>Ii_ z-#8*md_A9*v^eEKA|M^YzDI}>^^{%j@72#&j@WvN={Bw(vm^woz*bK~xYhY~0biR3 zlQIg`z8C10<{y8g+Gz@&0BM|Qo+q@;o{o$z7Ehj^Bx4QhRV7=`GMA^?qI{s;k9X=6 zv6I^+Hhx6S9|AV&d{{N5Nys7M81VS}V~~l*><% z{#LU=c<^nM+241$#w|2NV8{5x3Rxz&%H@eVES`{{KD|a;yAIJub99=-V)xYH92C52 z>%QqE?d&ryteigh=Ru7SHq#d*8HJzBBDtV9FlEUK3bp0WRg61@;|&n=Vrq#U#n6B zDgWn!@5588rBE@%FDJHrbNjJ|_;I$W_(X}CHt16#A(x~o>yQGDAGOJi7O~@{;PVQU zJo__(gvQ&c95Ya*M&*^toeD)EAEiQL-2v)yBxOhcxQpX zwSUGuHh|+(meueP6B67f&Q>3VzK`?GV)`{`IYRj|u8+fTeea$kMWRai?|i?%nQI!ICIyKbH~LJ_KNA|HP}I9qf10}< zlC8DhIZ$mawTm0YhX#^yb$B7LJ2^c>Czb2`S_)+`PRcv#Pjj5Nu{ow)T!|*&=%7EQ zu^|Pu!kd%)O=Ial)qDuhd!eZ*aLd@(%SqSCIu@-ayp|?oW_+F9p@4nj2`%d~2{CqUuyWvL52vODe^uT&_IQTj@Tuar@-{X>3O9H~U<0 z@4a*El_h{Opon}Mr3CK(k#!uSjCI=D-HIg9PA6twAPLB72t%;magn$67&SgOht)eF zRkPCjsVYbq)}zNtd>s-z%6iqu-QyMGQkXqNJ+pJSQ0HG6P*B zA_!YHD%B4Ib28ZUB}M|#tOK_Eb%H)7;aPh{io#Wwrlxg6ESd@eHgK4xq-Q=JRcJ69 z6|Ft{n!TI4C&GBp*V!?Z0$z zU$grIUa+i{sBEs==^zecV9zh9EnWQbZ|8H_egGIsN*W+WLYKXR(2%t78{2m;|3^dL zvZH2_cISpbixhVCcUfJ0&DQGz&HPY808V@jsv@A-ovLN}XGLY_oO0PP z)4Oq*@Gr7d=y}F*d09BSY0DdZQv;>$!s);(u)LvkM6m*__-sn$n0P{txo^I~x@wlY zdx5wzYcN-u3v6Bhb2p=Xr`CigEX6slYq_o>phTktiZ2&*35oryw)ehWr_T<9Pk}_> z3+_CsuK2K$AUt~z12OV~B4&%9ltl=t0~whZi{3k|d$6|@`YHVI@_FkSzI)j^&wki| z8x%uUm+eZA7#Yd|L}4U1)WTDp?CN4)TW!M$x!I|vnB`&LX&AS8?) z8B1vlX47wZYD;YEvgz+`ypv3i#5{YCUa)c&7%fohvjjLzHs zfRt}yn|WF+96sq&PHezFhS2mxlZw5R&6OKAVcKV3tMLFW;BieJ4dVPs4N?6JR)RUyFk{iU4pErpYE^C547Zfa*$6&eC#`el?8AZ8{=+UyUb-aIk)UBfv0;Bp6k z8wO=w(mf@IJj)o4Ye3RKA`q%nq@}{`isGvwmn@+JLxRESF&9bB0NcKMa-~hH#>R;m zQ`;fIkI;xoi}v3zM`%!W(Bl?Mg*a863DJZVAXe6Db!6Rgw+J7EUrBQJb)=C3yURHL zJZ=A$CuwYxE(BzPM}E0Cd%nAaG!V*|uUx$xU+5s1Rd(|Hc<6`2VG!8i=JGD|axD>q zToU3V02k51nxOjfi=@}L5;PxJ_KAu$85SOoKuABWeDCs+BG0w7v#u!Yx3*DqB+UKW*w`=bNgq;| zhsm|c&JFN{QLX<&kmHg03UezL7+JZ_HBqu#W{vB`z%9t zi56dm%pCIP>2DQVijx=|i2ORhHBprKgbJW!&#?v?JcIK~upIy99X}`l0i`?W_INqN zD-|21<#)ij+U?y-FL0uu?qlF1tpBMy9UN8rLpzM_t000003D@<{ z`v`z0{^*jc*K`U#+&x|*fX@s<&pA9_op*|q4V*0SK(GWB005E!$RSVm72J;w*Fd@+ zdNivfAZ-sLL51O)WcrF24PSe4wcrg$RFj*Aa}BnWV-!Pyf=D=#y@_YNP~!o}Oi6da zGxqY@0gbpQFFLRD-c1t3bkJ&L8l}+Et+uJSKXfri9f)be6AbrWgR0wb>s(5Y9&PUP dUpfx0S&7vfs*J(;%$+mB_b}fa8Zr_F003`