From 6775b8d3512912a10de33b0eeb67cb10aa031779 Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:33:29 -0400 Subject: [PATCH 1/2] feat: add terrain-scatter showcase piece Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 3 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 7 +- ROADMAP.md | 2 +- docs/gallery/assets/terrain-scatter-hero.webp | Bin 0 -> 33632 bytes docs/gallery/campfire/index.html | 2 +- docs/gallery/index.html | 13 +- docs/gallery/terrain-scatter/index.html | 1212 +++++++++++++++++ showcase/gallery.json | 12 + showcase/terrain-scatter/README.md | 75 + showcase/terrain-scatter/preview.webp | Bin 0 -> 15710 bytes showcase/terrain-scatter/terrain_scatter.py | 888 ++++++++++++ tests/smoke/catalog.json | 3 +- 14 files changed, 2212 insertions(+), 9 deletions(-) create mode 100644 docs/gallery/assets/terrain-scatter-hero.webp create mode 100644 docs/gallery/terrain-scatter/index.html create mode 100644 showcase/terrain-scatter/README.md create mode 100644 showcase/terrain-scatter/preview.webp create mode 100644 showcase/terrain-scatter/terrain_scatter.py diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index fff94ee..fa04126 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -144,6 +144,7 @@ "showcase/campfire", "showcase/market-stall", "showcase/street-lantern", - "showcase/treasure-chest" + "showcase/treasure-chest", + "showcase/terrain-scatter" ] } diff --git a/AGENTS.md b/AGENTS.md index d920173..4996f95 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 7 showcase pieces (counts are CI-enforced against README.md +examples, and 8 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 6e9cb0e..c2ce675 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, 7 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 8 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 60d9673..eb79570 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

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

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 7 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 8 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. @@ -82,6 +82,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Market stall: a timber frame with post feet, slatted counter, rafters, and a striped awning on a dark studio floor, warm wedge on the back wall Street lantern: an iron post with brass collars, a braced arm, and a muntined amber cage on a dark studio floor 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 [`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. @@ -97,6 +98,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`treasure-chest`](showcase/treasure-chest/) — procedural slatted chest with iron bands, corner brackets, and an open lid through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`terrain-scatter`](showcase/terrain-scatter/) — Geometry Nodes hill tile with bevelled masonry scatter 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 be5c2be..61bee38 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~Wooden barrel showcase~~ **SHIPPED** as `showcase/wooden-barrel/` — bulged staves, four iron hoops, lid heads; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Campfire showcase~~ **SHIPPED** as `showcase/campfire/` — two-course fire ring, log tripod; `--skip-decimate` exits 9 on the LOD1 ratio band - ~~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 +- ~~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 - 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 diff --git a/docs/gallery/assets/terrain-scatter-hero.webp b/docs/gallery/assets/terrain-scatter-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..a60c903fa4327ed767350aa66b6db4dffa01460e GIT binary patch literal 33632 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

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 | 3500–3750 | 3616 / 3616 / 3616 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2196 | | Materials | exactly 3 distinct, ≥24 wood faces, ≥8 ash faces | 3 slots, 100 wood, 48 ash | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.789, 0.789, 0.410) m ± 0.01 | (0.7891, 0.7891, 0.4099), zmin 0 | | Collider tris | ≤ 320 | 254 | | Export | written, size > 0 | 273604 / 273604 / 273596 bytes |

+

| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 3500–3750 | 3616 / 3616 / 3616 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2196 | | Materials | exactly 3 distinct, ≥24 wood faces, ≥8 ash faces | 3 slots, 100 wood, 48 ash | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.789, 0.789, 0.410) m ± 0.01 | (0.7891, 0.7891, 0.4094), zmin 0 | | Collider tris | ≤ 320 | 254 | | Export | written, size > 0 | 273604 / 273604 / 273596 bytes |

DECIMATE COLLAPSE triangle counts are not guaranteed identical across series — the gate is a ratio band, not an exact count. This mesh happened to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. glTF byte size differs by a few bytes across series.

--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

diff --git a/docs/gallery/index.html b/docs/gallery/index.html index 0133e33..559c8ad 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" /> - 58 examples + 59 examples
+
+ + terrain-scatter — A Geometry Nodes hill tile with masonry scatter through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract. + +
+

terrain-scatter

+

A Geometry Nodes hill tile with masonry scatter through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 1194 tris, two materials with 486 stone faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.800×1.800×0.655 m, LOD ratios in band, convex collider 52 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+ View example +
+
diff --git a/docs/gallery/terrain-scatter/index.html b/docs/gallery/terrain-scatter/index.html new file mode 100644 index 0000000..827bb59 --- /dev/null +++ b/docs/gallery/terrain-scatter/index.html @@ -0,0 +1,1212 @@ + + + + + + terrain-scatter — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + + +
+

terrain-scatter

+

A Geometry Nodes hill tile with masonry scatter 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: 1194 tris, two materials with 486 stone faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.800×1.800×0.655 m, LOD ratios in band, convex collider 52 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/terrain-scatter/terrain_scatter.py --
+ +
+
+

A showcase piece, not an example. Geometry Nodes sine-hill Mesh Grid with an Index-jittered Instance-on-Points scatter, realized cubes replaced by bevelled masonry, 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 geometry-nodes-python, 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 | 1150–1250 | 1194 / 1194 / 1194 | | LOD1 ratio | 0.32–0.62 of base | 0.4992 / 0.4992 / 0.4992 | | LOD2 ratio | 0.10–0.35 of base | 0.2194 / 0.2194 / 0.2194 | | Materials | exactly 2 distinct, ≥24 stone faces | 2 slots, 486 stone | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.800, 1.800, 0.655) m ± 0.01 | (1.8000, 1.8000, 0.6554), zmin 0 | | Collider tris | ≤ 80 | 52 | | Export | written, size > 0 | 90704 / 90704 / 90696 bytes |

+

DECIMATE COLLAPSE triangle counts are not guaranteed identical across series — the gate is a ratio band, not an exact count. This mesh happened to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG. glTF byte size may differ by a few bytes across series.

+

--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 terrain_scatter.py --
+blender --background --python terrain_scatter.py -- --skip-decimate
+blender --background --python terrain_scatter.py -- --output terrain.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 stone 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/terrain-scatter/terrain_scatter.py + View on GitHub → +
+
"""Game-ready GN terrain scatter — a showcase piece, not an example.
+
+Asserts budget conformance of a Geometry Nodes hill grid with instanced
+masonry rocks after composing shipped pipeline pieces: GN 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. Hills are a closed-form sine product; scatter is an Index-jittered
+instance grid. Realized cubes are replaced with bevelled lumped boxes
+(campfire/well masonry, not icospheres). 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 terrain_scatter.py --
+    blender --background --python terrain_scatter.py -- --skip-decimate
+    blender --background --python terrain_scatter.py -- --output terrain.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
+
+PATCH = 1.80
+FREQ = 2.2
+AMP = 0.16
+ROCK_SIZE = (0.22, 0.17, 0.13)
+ROCK_GRID = 3
+ROCK_SPAN = 1.15
+SLAB_LIFT = 0.10
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (1.800, 1.800, 0.655)
+BASE_TRIS_MIN = 1150
+BASE_TRIS_MAX = 1250
+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 = 80
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.08
+STONE_FACES_MIN = 24
+
+DIRT_IDX = 0
+STONE_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 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 hill_z_socket(tree, freq, amp):
+    pos = tree.nodes.new("GeometryNodeInputPosition")
+    sep = tree.nodes.new("ShaderNodeSeparateXYZ")
+    tree.links.new(pos.outputs["Position"], sep.inputs[0])
+    mx = tree.nodes.new("ShaderNodeMath")
+    mx.operation = "MULTIPLY"
+    mx.inputs[1].default_value = freq
+    my = tree.nodes.new("ShaderNodeMath")
+    my.operation = "MULTIPLY"
+    my.inputs[1].default_value = freq
+    tree.links.new(sep.outputs["X"], mx.inputs[0])
+    tree.links.new(sep.outputs["Y"], my.inputs[0])
+    sine = tree.nodes.new("ShaderNodeMath")
+    sine.operation = "SINE"
+    cose = tree.nodes.new("ShaderNodeMath")
+    cose.operation = "COSINE"
+    tree.links.new(mx.outputs[0], sine.inputs[0])
+    tree.links.new(my.outputs[0], cose.inputs[0])
+    prod = tree.nodes.new("ShaderNodeMath")
+    prod.operation = "MULTIPLY"
+    tree.links.new(sine.outputs[0], prod.inputs[0])
+    tree.links.new(cose.outputs[0], prod.inputs[1])
+    add1 = tree.nodes.new("ShaderNodeMath")
+    add1.operation = "ADD"
+    add1.inputs[1].default_value = 1.0
+    tree.links.new(prod.outputs[0], add1.inputs[0])
+    sc = tree.nodes.new("ShaderNodeMath")
+    sc.operation = "MULTIPLY"
+    sc.inputs[1].default_value = amp
+    tree.links.new(add1.outputs[0], sc.inputs[0])
+    return sc.outputs[0]
+
+
+def build_scatter_tree(verts, dirt, stone):
+    tree = bpy.data.node_groups.new("TerrainScatter", "GeometryNodeTree")
+    tree.interface.new_socket(
+        name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry",
+    )
+    go = tree.nodes.new("NodeGroupOutput")
+
+    hill = hill_z_socket(tree, FREQ, AMP)
+    off = tree.nodes.new("ShaderNodeCombineXYZ")
+    tree.links.new(hill, off.inputs["Z"])
+
+    grid = tree.nodes.new("GeometryNodeMeshGrid")
+    grid.inputs["Size X"].default_value = PATCH
+    grid.inputs["Size Y"].default_value = PATCH
+    grid.inputs["Vertices X"].default_value = verts
+    grid.inputs["Vertices Y"].default_value = verts
+    set_pos = tree.nodes.new("GeometryNodeSetPosition")
+    tree.links.new(grid.outputs["Mesh"], set_pos.inputs["Geometry"])
+    tree.links.new(off.outputs["Vector"], set_pos.inputs["Offset"])
+    lift = tree.nodes.new("GeometryNodeTransform")
+    lift.inputs["Translation"].default_value = (0.0, 0.0, SLAB_LIFT)
+    tree.links.new(set_pos.outputs["Geometry"], lift.inputs["Geometry"])
+    shade_t = tree.nodes.new("GeometryNodeSetShadeSmooth")
+    shade_t.inputs["Shade Smooth"].default_value = False
+    tree.links.new(lift.outputs["Geometry"], shade_t.inputs["Geometry"])
+    sm_d = tree.nodes.new("GeometryNodeSetMaterial")
+    sm_d.inputs["Material"].default_value = dirt
+    tree.links.new(shade_t.outputs["Geometry"], sm_d.inputs["Geometry"])
+
+    pts = tree.nodes.new("GeometryNodeMeshGrid")
+    pts.inputs["Size X"].default_value = ROCK_SPAN
+    pts.inputs["Size Y"].default_value = ROCK_SPAN
+    pts.inputs["Vertices X"].default_value = ROCK_GRID
+    pts.inputs["Vertices Y"].default_value = ROCK_GRID
+    idx = tree.nodes.new("GeometryNodeInputIndex")
+    jx = tree.nodes.new("ShaderNodeMath")
+    jx.operation = "MULTIPLY"
+    jx.inputs[1].default_value = 1.73
+    tree.links.new(idx.outputs["Index"], jx.inputs[0])
+    jxs = tree.nodes.new("ShaderNodeMath")
+    jxs.operation = "SINE"
+    tree.links.new(jx.outputs[0], jxs.inputs[0])
+    jxm = tree.nodes.new("ShaderNodeMath")
+    jxm.operation = "MULTIPLY"
+    jxm.inputs[1].default_value = 0.09
+    tree.links.new(jxs.outputs[0], jxm.inputs[0])
+    jy = tree.nodes.new("ShaderNodeMath")
+    jy.operation = "MULTIPLY"
+    jy.inputs[1].default_value = 2.19
+    tree.links.new(idx.outputs["Index"], jy.inputs[0])
+    jyc = tree.nodes.new("ShaderNodeMath")
+    jyc.operation = "COSINE"
+    tree.links.new(jy.outputs[0], jyc.inputs[0])
+    jym = tree.nodes.new("ShaderNodeMath")
+    jym.operation = "MULTIPLY"
+    jym.inputs[1].default_value = 0.09
+    tree.links.new(jyc.outputs[0], jym.inputs[0])
+    extra = tree.nodes.new("ShaderNodeMath")
+    extra.operation = "ADD"
+    extra.inputs[1].default_value = SLAB_LIFT
+    tree.links.new(hill, extra.inputs[0])
+    jitter = tree.nodes.new("ShaderNodeCombineXYZ")
+    tree.links.new(jxm.outputs[0], jitter.inputs["X"])
+    tree.links.new(jym.outputs[0], jitter.inputs["Y"])
+    tree.links.new(extra.outputs[0], jitter.inputs["Z"])
+    set_pts = tree.nodes.new("GeometryNodeSetPosition")
+    tree.links.new(pts.outputs["Mesh"], set_pts.inputs["Geometry"])
+    tree.links.new(jitter.outputs["Vector"], set_pts.inputs["Offset"])
+
+    cube = tree.nodes.new("GeometryNodeMeshCube")
+    cube.inputs["Size"].default_value = ROCK_SIZE
+    iop = tree.nodes.new("GeometryNodeInstanceOnPoints")
+    tree.links.new(set_pts.outputs["Geometry"], iop.inputs["Points"])
+    tree.links.new(cube.outputs["Mesh"], iop.inputs["Instance"])
+
+    ang = tree.nodes.new("ShaderNodeMath")
+    ang.operation = "MULTIPLY"
+    ang.inputs[1].default_value = 0.67
+    tree.links.new(idx.outputs["Index"], ang.inputs[0])
+    rot = tree.nodes.new("ShaderNodeCombineXYZ")
+    tree.links.new(ang.outputs[0], rot.inputs["Z"])
+    tree.links.new(rot.outputs["Vector"], iop.inputs["Rotation"])
+
+    mod = tree.nodes.new("ShaderNodeMath")
+    mod.operation = "MODULO"
+    mod.inputs[1].default_value = 3.0
+    tree.links.new(idx.outputs["Index"], mod.inputs[0])
+    div = tree.nodes.new("ShaderNodeMath")
+    div.operation = "DIVIDE"
+    div.inputs[1].default_value = 3.0
+    tree.links.new(mod.outputs[0], div.inputs[0])
+    mul_s = tree.nodes.new("ShaderNodeMath")
+    mul_s.operation = "MULTIPLY"
+    mul_s.inputs[1].default_value = 0.22
+    tree.links.new(div.outputs[0], mul_s.inputs[0])
+    add_s = tree.nodes.new("ShaderNodeMath")
+    add_s.operation = "ADD"
+    add_s.inputs[1].default_value = 0.84
+    tree.links.new(mul_s.outputs[0], add_s.inputs[0])
+    scl = tree.nodes.new("ShaderNodeCombineXYZ")
+    tree.links.new(add_s.outputs[0], scl.inputs["X"])
+    tree.links.new(add_s.outputs[0], scl.inputs["Y"])
+    tree.links.new(add_s.outputs[0], scl.inputs["Z"])
+    tree.links.new(scl.outputs["Vector"], iop.inputs["Scale"])
+
+    realize = tree.nodes.new("GeometryNodeRealizeInstances")
+    tree.links.new(iop.outputs["Instances"], realize.inputs["Geometry"])
+    sm_s = tree.nodes.new("GeometryNodeSetMaterial")
+    sm_s.inputs["Material"].default_value = stone
+    tree.links.new(realize.outputs["Geometry"], sm_s.inputs["Geometry"])
+
+    join = tree.nodes.new("GeometryNodeJoinGeometry")
+    tree.links.new(sm_d.outputs["Geometry"], join.inputs[0])
+    tree.links.new(sm_s.outputs["Geometry"], join.inputs[0])
+    shade = tree.nodes.new("GeometryNodeSetShadeSmooth")
+    shade.inputs["Shade Smooth"].default_value = False
+    tree.links.new(join.outputs["Geometry"], shade.inputs["Geometry"])
+    tree.links.new(shade.outputs["Geometry"], go.inputs["Geometry"])
+    return tree
+
+
+def eval_to_mesh(obj, name):
+    bpy.context.view_layer.update()
+    dg = bpy.context.evaluated_depsgraph_get()
+    ev = obj.evaluated_get(dg)
+    em = ev.to_mesh()
+    try:
+        me = bpy.data.meshes.new(name)
+        me.from_pydata(
+            [tuple(v.co) for v in em.vertices],
+            [],
+            [tuple(p.vertices) for p in em.polygons],
+        )
+        src_idx = [p.material_index for p in em.polygons]
+        me.update()
+        for poly, idx in zip(me.polygons, src_idx):
+            poly.material_index = idx
+    finally:
+        ev.to_mesh_clear()
+    return me
+
+
+def slabify(bm, floor_z=0.0):
+    bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+    boundary = [e for e in bm.edges if e.is_boundary]
+    if not boundary:
+        return
+    ret = bmesh.ops.extrude_edge_only(bm, edges=boundary)
+    verts = [g for g in ret["geom"] if isinstance(g, bmesh.types.BMVert)]
+    for v in verts:
+        v.co.z = floor_z
+    bottom = [
+        e for e in bm.edges
+        if e.is_boundary
+        and abs(e.verts[0].co.z - floor_z) < 1e-5
+        and abs(e.verts[1].co.z - floor_z) < 1e-5
+    ]
+    if bottom:
+        try:
+            bmesh.ops.edgeloop_fill(bm, edges=bottom)
+        except Exception:
+            bmesh.ops.contextual_create(bm, geom=bottom)
+    for f in bm.faces:
+        if f.material_index not in (DIRT_IDX, STONE_IDX):
+            f.material_index = DIRT_IDX
+
+
+def masonry_from_cubes(bm, lump, bevel_offset, bevel_segments):
+    stone_faces = [f for f in bm.faces if f.material_index == STONE_IDX]
+    visited = set()
+    islands = []
+    for face in stone_faces:
+        if face in visited:
+            continue
+        stack = [face]
+        island = []
+        while stack:
+            cur = stack.pop()
+            if cur in visited:
+                continue
+            visited.add(cur)
+            island.append(cur)
+            for edge in cur.edges:
+                for other in edge.link_faces:
+                    if other.material_index == STONE_IDX and other not in visited:
+                        stack.append(other)
+        islands.append(island)
+
+    specs = []
+    for island in islands:
+        verts = {v for f in island for v in f.verts}
+        xs = [v.co.x for v in verts]
+        ys = [v.co.y for v in verts]
+        zs = [v.co.z for v in verts]
+        cx = 0.5 * (min(xs) + max(xs))
+        cy = 0.5 * (min(ys) + max(ys))
+        cz = 0.5 * (min(zs) + max(zs))
+        z0 = min(zs)
+        sx = 0.17 + 0.04 * math.sin(cx * 8.1)
+        sy = 0.15 + 0.03 * math.cos(cy * 6.4)
+        sz = 0.20 + 0.05 * math.sin(cx * 4.2 + cy * 3.1)
+        ex = 0.38 * math.sin(cx * 5.1)
+        ey = 0.30 * math.cos(cy * 4.3)
+        ez = cx * 3.7 + cy * 2.1
+        cz = z0 + sz * 0.30
+        specs.append((cx, cy, cz, sx, sy, sz, ex, ey, ez))
+
+    if stone_faces:
+        bmesh.ops.delete(bm, geom=stone_faces, context="FACES")
+        loose = [v for v in bm.verts if not v.link_faces]
+        if loose:
+            bmesh.ops.delete(bm, geom=loose, context="VERTS")
+
+    for cx, cy, cz, sx, sy, sz, ex, ey, ez in specs:
+        verts = add_box(
+            bm,
+            (cx, cy, cz),
+            (sx, sy, sz),
+            STONE_IDX,
+            euler=(ex, ey, ez),
+        )
+        if bevel_offset > 0.0:
+            edges = list({e for v in verts for e in v.link_edges})
+            if 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 = STONE_IDX
+
+
+def build_terrain_mesh(name, grid_verts, lump, bevel_offset, bevel_segments, dirt, stone):
+    carrier = bpy.data.meshes.new(name + "Carrier")
+    carrier.vertices.add(1)
+    obj = bpy.data.objects.new(name + "GN", carrier)
+    bpy.context.collection.objects.link(obj)
+    tree = build_scatter_tree(grid_verts, dirt, stone)
+    mod = obj.modifiers.new("terrain_scatter", "NODES")
+    mod.node_group = tree
+    realized = eval_to_mesh(obj, name + "Eval")
+    bpy.data.objects.remove(obj, do_unlink=True)
+
+    bm = bmesh.new()
+    try:
+        bm.from_mesh(realized)
+        bm.verts.ensure_lookup_table()
+        bm.edges.ensure_lookup_table()
+        bm.faces.ensure_lookup_table()
+        slabify(bm, floor_z=0.0)
+        bm.verts.ensure_lookup_table()
+        bm.faces.ensure_lookup_table()
+        masonry_from_cubes(bm, lump, bevel_offset, bevel_segments)
+        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, dirt, stone):
+    # Do not materials.clear() — that resets polygon material_index to 0
+    # on this Blender, which would drop stone faces onto dirt.
+    mats = obj.data.materials
+    wanted = (dirt, stone)
+    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("TerrainNrm", 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 = DIRT_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)
+    dirt = principled("TerrainDirt", (0.30, 0.17, 0.07, 1.0), 0.0, 0.90)
+    stone = principled("TerrainStone", (0.56, 0.53, 0.48, 1.0), 0.0, 0.76)
+    low = build_terrain_mesh("TerrainLow", 9, 0.10, 0.022, 2, dirt, stone)
+    high = build_terrain_mesh("TerrainHigh", 13, 0.10, 0.022, 4, dirt, stone)
+    assign_slots(low, dirt, stone)
+    assign_slots(high, dirt, stone)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("terrain 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, dirt)
+    if img is None:
+        return fail("terrain has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "TerrainLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "TerrainLOD2", 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_terrain_mesh(
+        "TerrainColSrc", 9, 0.0, 0.0, 1, dirt, stone
+    )
+    collider = convex_hull_collider(collider_src, "TerrainCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_terrain_scatter_{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(STONE_IDX, 0) < STONE_FACES_MIN:
+        return fail(
+            f"stone faces {idx_counts.get(STONE_IDX, 0)} < {STONE_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, dirt, 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, dirt, tex, path, engine):
+    scene = bpy.context.scene
+    wire_normal(dirt, tex)
+    for ob in list(scene.objects):
+        if ob.type == "MESH" and ob != low:
+            ob.hide_render = True
+            ob.hide_viewport = True
+
+    low.rotation_euler.z = math.radians(-28.0)
+    low.rotation_euler.x = math.radians(0.0)
+
+    floor_me = bpy.data.meshes.new("Floor")
+    bm = bmesh.new()
+    try:
+        bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0)
+        bm.to_mesh(floor_me)
+    finally:
+        bm.free()
+    fmat = bpy.data.materials.new("Floor")
+    fmat.use_nodes = True
+    fb = fmat.node_tree.nodes["Principled BSDF"]
+    fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
+    fb.inputs["Roughness"].default_value = 0.7
+    floor_me.materials.append(fmat)
+    floor = bpy.data.objects.new("Floor", floor_me)
+    scene.collection.objects.link(floor)
+    wall = bpy.data.objects.new("Wall", floor_me.copy())
+    wall.location = (0.0, 8.5, 0.0)
+    wall.rotation_euler = (math.radians(90), 0.0, 0.0)
+    scene.collection.objects.link(wall)
+
+    world = bpy.data.worlds.new("World")
+    world.use_nodes = True
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (
+        0.02, 0.021, 0.025, 1.0,
+    )
+    scene.world = world
+
+    def light(name, loc, energy, size, col, rot):
+        ld = bpy.data.lights.new(name, "AREA")
+        ld.energy = energy
+        ld.size = size
+        ld.color = col
+        ob = bpy.data.objects.new(name, ld)
+        ob.location = loc
+        ob.rotation_euler = tuple(math.radians(a) for a in rot)
+        scene.collection.objects.link(ob)
+
+    light("Key", (-3.6, -5.0, 5.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 = (2.57, -3.51, 2.24)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.24)
+    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, dirt, tex, _col = check(args.skip_decimate)
+    if code:
+        return code
+    if args.output:
+        rcode = render_still(low, dirt, tex, os.path.abspath(args.output), args.engine)
+        if rcode:
+            return rcode
+        print(f"rendered still {args.output}")
+    print("terrain-scatter 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 981c2e6..9a26cd8 100644 --- a/showcase/gallery.json +++ b/showcase/gallery.json @@ -88,6 +88,18 @@ "mesh", "export" ] + }, + { + "name": "terrain-scatter", + "dir": "showcase/terrain-scatter", + "teaches": "A Geometry Nodes hill tile with masonry scatter through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.", + "witnessesFix": "Recomputed: 1194 tris, two materials with 486 stone faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.800×1.800×0.655 m, LOD ratios in band, convex collider 52 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.", + "hero": "docs/gallery/assets/terrain-scatter-hero.webp", + "preview": "showcase/terrain-scatter/preview.webp", + "tags": [ + "mesh", + "export" + ] } ] } diff --git a/showcase/terrain-scatter/README.md b/showcase/terrain-scatter/README.md new file mode 100644 index 0000000..499afe1 --- /dev/null +++ b/showcase/terrain-scatter/README.md @@ -0,0 +1,75 @@ +# Terrain scatter + +A showcase piece, not an example. Geometry Nodes sine-hill Mesh Grid +with an Index-jittered Instance-on-Points scatter, realized cubes +replaced by bevelled masonry, 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 `geometry-nodes-python`, `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 | 1150–1250 | 1194 / 1194 / 1194 | +| LOD1 ratio | 0.32–0.62 of base | 0.4992 / 0.4992 / 0.4992 | +| LOD2 ratio | 0.10–0.35 of base | 0.2194 / 0.2194 / 0.2194 | +| Materials | exactly 2 distinct, ≥24 stone faces | 2 slots, 486 stone | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (1.800, 1.800, 0.655) m ± 0.01 | (1.8000, 1.8000, 0.6554), zmin 0 | +| Collider tris | ≤ 80 | 52 | +| Export | written, size > 0 | 90704 / 90704 / 90696 bytes | + +DECIMATE COLLAPSE triangle counts are **not** guaranteed identical across +series — the gate is a ratio band, not an exact count. This mesh happened +to match on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate +is `has_data` plus operator `FINISHED`, not byte-identity. Construction +uses no RNG. glTF byte size may differ by a few bytes across series. + +`--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 terrain_scatter.py -- +blender --background --python terrain_scatter.py -- --skip-decimate +blender --background --python terrain_scatter.py -- --output terrain.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 stone 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/terrain-scatter/preview.webp b/showcase/terrain-scatter/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..0ff73df422eb25162df50fe33a6f27965b219231 GIT binary patch literal 15710 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 Q$9*4bz!VMu0000007yh&Z2$lO literal 0 HcmV?d00001 diff --git a/showcase/terrain-scatter/terrain_scatter.py b/showcase/terrain-scatter/terrain_scatter.py new file mode 100644 index 0000000..949e762 --- /dev/null +++ b/showcase/terrain-scatter/terrain_scatter.py @@ -0,0 +1,888 @@ +"""Game-ready GN terrain scatter — a showcase piece, not an example. + +Asserts budget conformance of a Geometry Nodes hill grid with instanced +masonry rocks after composing shipped pipeline pieces: GN 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. Hills are a closed-form sine product; scatter is an Index-jittered +instance grid. Realized cubes are replaced with bevelled lumped boxes +(campfire/well masonry, not icospheres). 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 terrain_scatter.py -- + blender --background --python terrain_scatter.py -- --skip-decimate + blender --background --python terrain_scatter.py -- --output terrain.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 + +PATCH = 1.80 +FREQ = 2.2 +AMP = 0.16 +ROCK_SIZE = (0.22, 0.17, 0.13) +ROCK_GRID = 3 +ROCK_SPAN = 1.15 +SLAB_LIFT = 0.10 + +BBOX_TOL = 0.01 +OUTER_SIZE = (1.800, 1.800, 0.655) +BASE_TRIS_MIN = 1150 +BASE_TRIS_MAX = 1250 +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 = 80 +BAKE_RES = 256 +CAGE_EXTRUSION = 0.08 +STONE_FACES_MIN = 24 + +DIRT_IDX = 0 +STONE_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 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 hill_z_socket(tree, freq, amp): + pos = tree.nodes.new("GeometryNodeInputPosition") + sep = tree.nodes.new("ShaderNodeSeparateXYZ") + tree.links.new(pos.outputs["Position"], sep.inputs[0]) + mx = tree.nodes.new("ShaderNodeMath") + mx.operation = "MULTIPLY" + mx.inputs[1].default_value = freq + my = tree.nodes.new("ShaderNodeMath") + my.operation = "MULTIPLY" + my.inputs[1].default_value = freq + tree.links.new(sep.outputs["X"], mx.inputs[0]) + tree.links.new(sep.outputs["Y"], my.inputs[0]) + sine = tree.nodes.new("ShaderNodeMath") + sine.operation = "SINE" + cose = tree.nodes.new("ShaderNodeMath") + cose.operation = "COSINE" + tree.links.new(mx.outputs[0], sine.inputs[0]) + tree.links.new(my.outputs[0], cose.inputs[0]) + prod = tree.nodes.new("ShaderNodeMath") + prod.operation = "MULTIPLY" + tree.links.new(sine.outputs[0], prod.inputs[0]) + tree.links.new(cose.outputs[0], prod.inputs[1]) + add1 = tree.nodes.new("ShaderNodeMath") + add1.operation = "ADD" + add1.inputs[1].default_value = 1.0 + tree.links.new(prod.outputs[0], add1.inputs[0]) + sc = tree.nodes.new("ShaderNodeMath") + sc.operation = "MULTIPLY" + sc.inputs[1].default_value = amp + tree.links.new(add1.outputs[0], sc.inputs[0]) + return sc.outputs[0] + + +def build_scatter_tree(verts, dirt, stone): + tree = bpy.data.node_groups.new("TerrainScatter", "GeometryNodeTree") + tree.interface.new_socket( + name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry", + ) + go = tree.nodes.new("NodeGroupOutput") + + hill = hill_z_socket(tree, FREQ, AMP) + off = tree.nodes.new("ShaderNodeCombineXYZ") + tree.links.new(hill, off.inputs["Z"]) + + grid = tree.nodes.new("GeometryNodeMeshGrid") + grid.inputs["Size X"].default_value = PATCH + grid.inputs["Size Y"].default_value = PATCH + grid.inputs["Vertices X"].default_value = verts + grid.inputs["Vertices Y"].default_value = verts + set_pos = tree.nodes.new("GeometryNodeSetPosition") + tree.links.new(grid.outputs["Mesh"], set_pos.inputs["Geometry"]) + tree.links.new(off.outputs["Vector"], set_pos.inputs["Offset"]) + lift = tree.nodes.new("GeometryNodeTransform") + lift.inputs["Translation"].default_value = (0.0, 0.0, SLAB_LIFT) + tree.links.new(set_pos.outputs["Geometry"], lift.inputs["Geometry"]) + shade_t = tree.nodes.new("GeometryNodeSetShadeSmooth") + shade_t.inputs["Shade Smooth"].default_value = False + tree.links.new(lift.outputs["Geometry"], shade_t.inputs["Geometry"]) + sm_d = tree.nodes.new("GeometryNodeSetMaterial") + sm_d.inputs["Material"].default_value = dirt + tree.links.new(shade_t.outputs["Geometry"], sm_d.inputs["Geometry"]) + + pts = tree.nodes.new("GeometryNodeMeshGrid") + pts.inputs["Size X"].default_value = ROCK_SPAN + pts.inputs["Size Y"].default_value = ROCK_SPAN + pts.inputs["Vertices X"].default_value = ROCK_GRID + pts.inputs["Vertices Y"].default_value = ROCK_GRID + idx = tree.nodes.new("GeometryNodeInputIndex") + jx = tree.nodes.new("ShaderNodeMath") + jx.operation = "MULTIPLY" + jx.inputs[1].default_value = 1.73 + tree.links.new(idx.outputs["Index"], jx.inputs[0]) + jxs = tree.nodes.new("ShaderNodeMath") + jxs.operation = "SINE" + tree.links.new(jx.outputs[0], jxs.inputs[0]) + jxm = tree.nodes.new("ShaderNodeMath") + jxm.operation = "MULTIPLY" + jxm.inputs[1].default_value = 0.09 + tree.links.new(jxs.outputs[0], jxm.inputs[0]) + jy = tree.nodes.new("ShaderNodeMath") + jy.operation = "MULTIPLY" + jy.inputs[1].default_value = 2.19 + tree.links.new(idx.outputs["Index"], jy.inputs[0]) + jyc = tree.nodes.new("ShaderNodeMath") + jyc.operation = "COSINE" + tree.links.new(jy.outputs[0], jyc.inputs[0]) + jym = tree.nodes.new("ShaderNodeMath") + jym.operation = "MULTIPLY" + jym.inputs[1].default_value = 0.09 + tree.links.new(jyc.outputs[0], jym.inputs[0]) + extra = tree.nodes.new("ShaderNodeMath") + extra.operation = "ADD" + extra.inputs[1].default_value = SLAB_LIFT + tree.links.new(hill, extra.inputs[0]) + jitter = tree.nodes.new("ShaderNodeCombineXYZ") + tree.links.new(jxm.outputs[0], jitter.inputs["X"]) + tree.links.new(jym.outputs[0], jitter.inputs["Y"]) + tree.links.new(extra.outputs[0], jitter.inputs["Z"]) + set_pts = tree.nodes.new("GeometryNodeSetPosition") + tree.links.new(pts.outputs["Mesh"], set_pts.inputs["Geometry"]) + tree.links.new(jitter.outputs["Vector"], set_pts.inputs["Offset"]) + + cube = tree.nodes.new("GeometryNodeMeshCube") + cube.inputs["Size"].default_value = ROCK_SIZE + iop = tree.nodes.new("GeometryNodeInstanceOnPoints") + tree.links.new(set_pts.outputs["Geometry"], iop.inputs["Points"]) + tree.links.new(cube.outputs["Mesh"], iop.inputs["Instance"]) + + ang = tree.nodes.new("ShaderNodeMath") + ang.operation = "MULTIPLY" + ang.inputs[1].default_value = 0.67 + tree.links.new(idx.outputs["Index"], ang.inputs[0]) + rot = tree.nodes.new("ShaderNodeCombineXYZ") + tree.links.new(ang.outputs[0], rot.inputs["Z"]) + tree.links.new(rot.outputs["Vector"], iop.inputs["Rotation"]) + + mod = tree.nodes.new("ShaderNodeMath") + mod.operation = "MODULO" + mod.inputs[1].default_value = 3.0 + tree.links.new(idx.outputs["Index"], mod.inputs[0]) + div = tree.nodes.new("ShaderNodeMath") + div.operation = "DIVIDE" + div.inputs[1].default_value = 3.0 + tree.links.new(mod.outputs[0], div.inputs[0]) + mul_s = tree.nodes.new("ShaderNodeMath") + mul_s.operation = "MULTIPLY" + mul_s.inputs[1].default_value = 0.22 + tree.links.new(div.outputs[0], mul_s.inputs[0]) + add_s = tree.nodes.new("ShaderNodeMath") + add_s.operation = "ADD" + add_s.inputs[1].default_value = 0.84 + tree.links.new(mul_s.outputs[0], add_s.inputs[0]) + scl = tree.nodes.new("ShaderNodeCombineXYZ") + tree.links.new(add_s.outputs[0], scl.inputs["X"]) + tree.links.new(add_s.outputs[0], scl.inputs["Y"]) + tree.links.new(add_s.outputs[0], scl.inputs["Z"]) + tree.links.new(scl.outputs["Vector"], iop.inputs["Scale"]) + + realize = tree.nodes.new("GeometryNodeRealizeInstances") + tree.links.new(iop.outputs["Instances"], realize.inputs["Geometry"]) + sm_s = tree.nodes.new("GeometryNodeSetMaterial") + sm_s.inputs["Material"].default_value = stone + tree.links.new(realize.outputs["Geometry"], sm_s.inputs["Geometry"]) + + join = tree.nodes.new("GeometryNodeJoinGeometry") + tree.links.new(sm_d.outputs["Geometry"], join.inputs[0]) + tree.links.new(sm_s.outputs["Geometry"], join.inputs[0]) + shade = tree.nodes.new("GeometryNodeSetShadeSmooth") + shade.inputs["Shade Smooth"].default_value = False + tree.links.new(join.outputs["Geometry"], shade.inputs["Geometry"]) + tree.links.new(shade.outputs["Geometry"], go.inputs["Geometry"]) + return tree + + +def eval_to_mesh(obj, name): + bpy.context.view_layer.update() + dg = bpy.context.evaluated_depsgraph_get() + ev = obj.evaluated_get(dg) + em = ev.to_mesh() + try: + me = bpy.data.meshes.new(name) + me.from_pydata( + [tuple(v.co) for v in em.vertices], + [], + [tuple(p.vertices) for p in em.polygons], + ) + src_idx = [p.material_index for p in em.polygons] + me.update() + for poly, idx in zip(me.polygons, src_idx): + poly.material_index = idx + finally: + ev.to_mesh_clear() + return me + + +def slabify(bm, floor_z=0.0): + bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) + boundary = [e for e in bm.edges if e.is_boundary] + if not boundary: + return + ret = bmesh.ops.extrude_edge_only(bm, edges=boundary) + verts = [g for g in ret["geom"] if isinstance(g, bmesh.types.BMVert)] + for v in verts: + v.co.z = floor_z + bottom = [ + e for e in bm.edges + if e.is_boundary + and abs(e.verts[0].co.z - floor_z) < 1e-5 + and abs(e.verts[1].co.z - floor_z) < 1e-5 + ] + if bottom: + try: + bmesh.ops.edgeloop_fill(bm, edges=bottom) + except Exception: + bmesh.ops.contextual_create(bm, geom=bottom) + for f in bm.faces: + if f.material_index not in (DIRT_IDX, STONE_IDX): + f.material_index = DIRT_IDX + + +def masonry_from_cubes(bm, lump, bevel_offset, bevel_segments): + stone_faces = [f for f in bm.faces if f.material_index == STONE_IDX] + visited = set() + islands = [] + for face in stone_faces: + if face in visited: + continue + stack = [face] + island = [] + while stack: + cur = stack.pop() + if cur in visited: + continue + visited.add(cur) + island.append(cur) + for edge in cur.edges: + for other in edge.link_faces: + if other.material_index == STONE_IDX and other not in visited: + stack.append(other) + islands.append(island) + + specs = [] + for island in islands: + verts = {v for f in island for v in f.verts} + xs = [v.co.x for v in verts] + ys = [v.co.y for v in verts] + zs = [v.co.z for v in verts] + cx = 0.5 * (min(xs) + max(xs)) + cy = 0.5 * (min(ys) + max(ys)) + cz = 0.5 * (min(zs) + max(zs)) + z0 = min(zs) + sx = 0.17 + 0.04 * math.sin(cx * 8.1) + sy = 0.15 + 0.03 * math.cos(cy * 6.4) + sz = 0.20 + 0.05 * math.sin(cx * 4.2 + cy * 3.1) + ex = 0.38 * math.sin(cx * 5.1) + ey = 0.30 * math.cos(cy * 4.3) + ez = cx * 3.7 + cy * 2.1 + cz = z0 + sz * 0.30 + specs.append((cx, cy, cz, sx, sy, sz, ex, ey, ez)) + + if stone_faces: + bmesh.ops.delete(bm, geom=stone_faces, context="FACES") + loose = [v for v in bm.verts if not v.link_faces] + if loose: + bmesh.ops.delete(bm, geom=loose, context="VERTS") + + for cx, cy, cz, sx, sy, sz, ex, ey, ez in specs: + verts = add_box( + bm, + (cx, cy, cz), + (sx, sy, sz), + STONE_IDX, + euler=(ex, ey, ez), + ) + if bevel_offset > 0.0: + edges = list({e for v in verts for e in v.link_edges}) + if 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 = STONE_IDX + + +def build_terrain_mesh(name, grid_verts, lump, bevel_offset, bevel_segments, dirt, stone): + carrier = bpy.data.meshes.new(name + "Carrier") + carrier.vertices.add(1) + obj = bpy.data.objects.new(name + "GN", carrier) + bpy.context.collection.objects.link(obj) + tree = build_scatter_tree(grid_verts, dirt, stone) + mod = obj.modifiers.new("terrain_scatter", "NODES") + mod.node_group = tree + realized = eval_to_mesh(obj, name + "Eval") + bpy.data.objects.remove(obj, do_unlink=True) + + bm = bmesh.new() + try: + bm.from_mesh(realized) + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + bm.faces.ensure_lookup_table() + slabify(bm, floor_z=0.0) + bm.verts.ensure_lookup_table() + bm.faces.ensure_lookup_table() + masonry_from_cubes(bm, lump, bevel_offset, bevel_segments) + 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, dirt, stone): + # Do not materials.clear() — that resets polygon material_index to 0 + # on this Blender, which would drop stone faces onto dirt. + mats = obj.data.materials + wanted = (dirt, stone) + 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("TerrainNrm", 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 = DIRT_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) + dirt = principled("TerrainDirt", (0.30, 0.17, 0.07, 1.0), 0.0, 0.90) + stone = principled("TerrainStone", (0.56, 0.53, 0.48, 1.0), 0.0, 0.76) + low = build_terrain_mesh("TerrainLow", 9, 0.10, 0.022, 2, dirt, stone) + high = build_terrain_mesh("TerrainHigh", 13, 0.10, 0.022, 4, dirt, stone) + assign_slots(low, dirt, stone) + assign_slots(high, dirt, stone) + + if low.data is None or len(low.data.polygons) < 6: + return fail("terrain 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, dirt) + if img is None: + return fail("terrain has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "TerrainLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "TerrainLOD2", 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_terrain_mesh( + "TerrainColSrc", 9, 0.0, 0.0, 1, dirt, stone + ) + collider = convex_hull_collider(collider_src, "TerrainCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_terrain_scatter_{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(STONE_IDX, 0) < STONE_FACES_MIN: + return fail( + f"stone faces {idx_counts.get(STONE_IDX, 0)} < {STONE_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, dirt, 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, dirt, tex, path, engine): + scene = bpy.context.scene + wire_normal(dirt, tex) + for ob in list(scene.objects): + if ob.type == "MESH" and ob != low: + ob.hide_render = True + ob.hide_viewport = True + + low.rotation_euler.z = math.radians(-28.0) + low.rotation_euler.x = math.radians(0.0) + + floor_me = bpy.data.meshes.new("Floor") + bm = bmesh.new() + try: + bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=14.0) + bm.to_mesh(floor_me) + finally: + bm.free() + fmat = bpy.data.materials.new("Floor") + fmat.use_nodes = True + fb = fmat.node_tree.nodes["Principled BSDF"] + fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) + fb.inputs["Roughness"].default_value = 0.7 + floor_me.materials.append(fmat) + floor = bpy.data.objects.new("Floor", floor_me) + scene.collection.objects.link(floor) + wall = bpy.data.objects.new("Wall", floor_me.copy()) + wall.location = (0.0, 8.5, 0.0) + wall.rotation_euler = (math.radians(90), 0.0, 0.0) + scene.collection.objects.link(wall) + + world = bpy.data.worlds.new("World") + world.use_nodes = True + world.node_tree.nodes["Background"].inputs["Color"].default_value = ( + 0.02, 0.021, 0.025, 1.0, + ) + scene.world = world + + def light(name, loc, energy, size, col, rot): + ld = bpy.data.lights.new(name, "AREA") + ld.energy = energy + ld.size = size + ld.color = col + ob = bpy.data.objects.new(name, ld) + ob.location = loc + ob.rotation_euler = tuple(math.radians(a) for a in rot) + scene.collection.objects.link(ob) + + light("Key", (-3.6, -5.0, 5.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 = (2.57, -3.51, 2.24) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.24) + 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, dirt, tex, _col = check(args.skip_decimate) + if code: + return code + if args.output: + rcode = render_still(low, dirt, tex, os.path.abspath(args.output), args.engine) + if rcode: + return rcode + print(f"rendered still {args.output}") + print("terrain-scatter 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 d73ce53..44cd01d 100644 --- a/tests/smoke/catalog.json +++ b/tests/smoke/catalog.json @@ -80,5 +80,6 @@ {"name": "campfire", "script": "showcase/campfire/campfire.py"}, {"name": "market-stall", "script": "showcase/market-stall/market_stall.py"}, {"name": "street-lantern", "script": "showcase/street-lantern/street_lantern.py"}, - {"name": "treasure-chest", "script": "showcase/treasure-chest/treasure_chest.py"} + {"name": "treasure-chest", "script": "showcase/treasure-chest/treasure_chest.py"}, + {"name": "terrain-scatter", "script": "showcase/terrain-scatter/terrain_scatter.py"} ] From f9532c4a46e4a69ececfb88881e19352aa6e986b Mon Sep 17 00:00:00 2001 From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:34:31 -0400 Subject: [PATCH 2/2] feat: add fence-kit showcase piece Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor --- .cursor-plugin/plugin.json | 3 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 7 +- ROADMAP.md | 2 +- docs/gallery/assets/fence-kit-hero.webp | Bin 0 -> 34934 bytes docs/gallery/fence-kit/index.html | 1036 +++++++++++++++++++++++ docs/gallery/index.html | 13 +- showcase/fence-kit/README.md | 75 ++ showcase/fence-kit/fence_kit.py | 712 ++++++++++++++++ showcase/fence-kit/preview.webp | Bin 0 -> 15316 bytes showcase/gallery.json | 12 + tests/smoke/catalog.json | 3 +- 13 files changed, 1859 insertions(+), 8 deletions(-) create mode 100644 docs/gallery/assets/fence-kit-hero.webp create mode 100644 docs/gallery/fence-kit/index.html create mode 100644 showcase/fence-kit/README.md create mode 100644 showcase/fence-kit/fence_kit.py create mode 100644 showcase/fence-kit/preview.webp diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index fa04126..cd30201 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -145,6 +145,7 @@ "showcase/market-stall", "showcase/street-lantern", "showcase/treasure-chest", - "showcase/terrain-scatter" + "showcase/terrain-scatter", + "showcase/fence-kit" ] } diff --git a/AGENTS.md b/AGENTS.md index 4996f95..5fef89b 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 8 showcase pieces (counts are CI-enforced against README.md +examples, and 9 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 c2ce675..dbb9054 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, 8 pieces (sibling of examples/; see showcase/README.md) +showcase// - Budget-conformance props, 9 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 eb79570..fa01acd 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

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

@@ -37,7 +37,7 @@ ## Overview -This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 8 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 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. 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. @@ -83,6 +83,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show Street lantern: an iron post with brass collars, a braced arm, and a muntined amber cage on a dark studio floor 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 [`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9. @@ -100,6 +101,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show [`terrain-scatter`](showcase/terrain-scatter/) — Geometry Nodes hill tile with bevelled masonry scatter through the same pipeline. Falsifier `--skip-decimate` exits 9. +[`fence-kit`](showcase/fence-kit/) — procedural post-and-rail fence section 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 61bee38..e056f05 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -115,7 +115,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - ~~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 - 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) +- ~~Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)~~ **SHIPPED** as `showcase/fence-kit/` — tiling post-and-rail section (AABB X is the tile width; does not re-witness the snap contract); `--skip-decimate` exits 9 on the LOD1 ratio band - ~~Custom ID-property delete witness~~ **SHIPPED** as `examples/cross-version-property-delete/` — IDs built via `bpy.data.objects.new` (not `active_object`); `property_unset` is TypeError and leaves the key; `del` removes it on 4.5 LTS and 5.x - ~~USD export evaluation_mode witness~~ **SHIPPED** as `examples/usd-export-evaluation-mode/` — probed `wm.usd_export` on CI Linux portables 5.2.1 (`9e2066aef7ef`) and 4.5.13 (`daeeeca98fb0`); TESSELLATE+VIEWPORT 26/24, TESSELLATE+RENDER 98/96; BEST_MATCH writes the 8-vert catmullClark cage so the mode is silent diff --git a/docs/gallery/assets/fence-kit-hero.webp b/docs/gallery/assets/fence-kit-hero.webp new file mode 100644 index 0000000000000000000000000000000000000000..590a5326e9e0aa334bb157b9d3216b084dc5c1c0 GIT binary patch literal 34934 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 literal 0 HcmV?d00001 diff --git a/docs/gallery/fence-kit/index.html b/docs/gallery/fence-kit/index.html new file mode 100644 index 0000000..a2d76d3 --- /dev/null +++ b/docs/gallery/fence-kit/index.html @@ -0,0 +1,1036 @@ + + + + + + fence-kit — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +

+
+

fence-kit

+

A procedural post-and-rail fence section 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: 1212 tris, two materials with 120 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.642×0.157×1.235 m, LOD ratios in band, convex collider 62 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/fence-kit/fence_kit.py --
+ +
+
+

A showcase piece, not an example. Procedural post-and-rail fence section (two posts, pyramidal caps, three rails, kickboard, brace, iron shoes and straps) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.

+

It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check. AABB X is the tile width so adjacent copies meet; this piece does not re-witness the modular-kit-snap snap contract.

+

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 | 1160–1280 | 1212 / 1212 / 1212 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2195 / 0.2195 / 0.2079 | | Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 120 metal | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (1.642, 0.157, 1.235) m ± 0.01 | (1.6418, 0.1574, 1.2350), zmin 0 | | Collider tris | ≤ 96 | 62 | | Export | written, size > 0 | 93232 / 93232 / 93224 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.

+

--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 fence_kit.py --
+blender --background --python fence_kit.py -- --skip-decimate
+blender --background --python fence_kit.py -- --output fence.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/fence-kit/fence_kit.py + View on GitHub → +
+
"""Game-ready post-and-rail fence kit — a showcase piece, not an example.
+
+Asserts budget conformance of a tiling timber fence section 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. AABB X is the tile width so
+adjacent copies meet; this piece does not re-witness the modular-kit
+snap contract. 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 fence_kit.py --
+    blender --background --python fence_kit.py -- --skip-decimate
+    blender --background --python fence_kit.py -- --output fence.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
+
+TILE = 1.60
+POST_W = 0.110
+POST_H = 1.14
+CAP_H = 0.095
+RAIL_Y = 0.055
+RAIL_Z = 0.064
+RAIL_ZS = (0.30, 0.58, 0.86)
+KICK_Z = 0.125
+KICK_Y = 0.040
+BRACE_T = 0.042
+IRON_T = 0.016
+SHOE_H = 0.034
+SHOE_SCALE = 1.38
+
+BBOX_TOL = 0.01
+OUTER_SIZE = (1.642, 0.157, 1.235)
+BASE_TRIS_MIN = 1160
+BASE_TRIS_MAX = 1280
+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.06
+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_fence_mesh(name, bevel_offset, bevel_segments):
+    bm = bmesh.new()
+    try:
+        wood_verts = []
+        post_xs = (-(TILE / 2.0 - POST_W / 2.0), TILE / 2.0 - POST_W / 2.0)
+        for px in post_xs:
+            wood_verts.extend(
+                add_box(bm, (px, 0.0, POST_H / 2.0), (POST_W, POST_W, POST_H), WOOD_IDX)
+            )
+            wood_verts.extend(
+                add_cone(
+                    bm,
+                    (px, 0.0, POST_H + CAP_H / 2.0),
+                    POST_W * 0.72,
+                    0.010,
+                    CAP_H,
+                    4,
+                    WOOD_IDX,
+                    euler=(0.0, 0.0, math.pi / 4.0),
+                )
+            )
+
+        rail_len = TILE - POST_W * 0.18
+        for z in RAIL_ZS:
+            wood_verts.extend(
+                add_box(bm, (0.0, 0.0, z), (rail_len, RAIL_Y, RAIL_Z), WOOD_IDX)
+            )
+
+        wood_verts.extend(
+            add_box(
+                bm,
+                (0.0, 0.0, KICK_Z / 2.0),
+                (TILE - POST_W * 0.22, KICK_Y, KICK_Z),
+                WOOD_IDX,
+            )
+        )
+
+        span = TILE - POST_W
+        rise = RAIL_ZS[-1] - RAIL_ZS[0]
+        brace_len = math.hypot(span, rise)
+        brace_ang = math.atan2(rise, span)
+        wood_verts.extend(
+            add_box(
+                bm,
+                (0.0, -POST_W * 0.55, (RAIL_ZS[0] + RAIL_ZS[-1]) / 2.0),
+                (brace_len, BRACE_T, BRACE_T),
+                WOOD_IDX,
+                euler=(0.0, -brace_ang, 0.0),
+            )
+        )
+
+        if bevel_offset > 0.0:
+            edges = list({e for v in wood_verts for e in v.link_edges})
+            bmesh.ops.bevel(
+                bm,
+                geom=edges,
+                offset=bevel_offset,
+                segments=bevel_segments,
+                profile=0.5,
+                affect="EDGES",
+                clamp_overlap=True,
+            )
+
+        for px in post_xs:
+            add_box(
+                bm,
+                (px, 0.0, SHOE_H / 2.0),
+                (POST_W * SHOE_SCALE, POST_W * SHOE_SCALE, SHOE_H),
+                METAL_IDX,
+            )
+            sign = 1.0 if px > 0.0 else -1.0
+            for z in RAIL_ZS:
+                add_box(
+                    bm,
+                    (px, POST_W / 2.0 + IRON_T / 2.0, z),
+                    (POST_W * 0.88, IRON_T, RAIL_Z * 1.40),
+                    METAL_IDX,
+                )
+                add_box(
+                    bm,
+                    (px, -(POST_W / 2.0 + IRON_T / 2.0), z),
+                    (POST_W * 0.88, IRON_T, RAIL_Z * 1.40),
+                    METAL_IDX,
+                )
+                add_box(
+                    bm,
+                    (px + sign * (POST_W / 2.0 + IRON_T / 2.0), 0.0, z),
+                    (IRON_T, POST_W * 0.88, RAIL_Z * 1.40),
+                    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]
+        cy = 0.5 * (min(ys) + max(ys))
+        zmin = min(zs)
+        for v in bm.verts:
+            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):
+    # Do not materials.clear() — that resets polygon material_index to 0
+    # on this Blender, which would drop metal faces onto wood.
+    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("FenceNrm", 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_fence_mesh("FenceLow", bevel_offset=0.006, bevel_segments=2)
+    high = build_fence_mesh("FenceHigh", bevel_offset=0.006, bevel_segments=4)
+    wood = principled("FenceWood", (0.46, 0.24, 0.08, 1.0), 0.0, 0.55)
+    metal = principled("FenceIron", (0.11, 0.115, 0.13, 1.0), 1.0, 0.28)
+    assign_slots(low, wood, metal)
+    assign_slots(high, wood, metal)
+
+    if low.data is None or len(low.data.polygons) < 6:
+        return fail("fence 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("fence has no UV layer", 3), None, None, None, None, None
+    bake_result = bake_normal(high, low)
+
+    lod1 = make_lod(low, "FenceLOD1", LOD1_TARGET, skip_decimate)
+    lod2 = make_lod(low, "FenceLOD2", 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_fence_mesh("FenceColSrc", bevel_offset=0.0, bevel_segments=1)
+    collider = convex_hull_collider(collider_src, "FenceCollider")
+    bpy.data.objects.remove(collider_src, do_unlink=True)
+    col_tris = triangle_count(collider.data)
+
+    export_path = os.path.join(
+        tempfile.gettempdir(),
+        f"bdt_fence_kit_{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(-16.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 = (2.50, -3.46, 1.95)
+    scene.collection.objects.link(cam)
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.58)
+    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("fence-kit 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 559c8ad..63152c6 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" /> - 59 examples + 60 examples
@@ -973,6 +973,17 @@

terrain-scatter

View example
+
+ + fence-kit — A procedural post-and-rail fence section through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract. + +
+

fence-kit

+

A procedural post-and-rail fence section through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.

+

witnesses Recomputed: 1212 tris, two materials with 120 metal faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.642×0.157×1.235 m, LOD ratios in band, convex collider 62 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.

+ View example +
+
diff --git a/showcase/fence-kit/README.md b/showcase/fence-kit/README.md new file mode 100644 index 0000000..4c3c6a1 --- /dev/null +++ b/showcase/fence-kit/README.md @@ -0,0 +1,75 @@ +# Fence kit + +A showcase piece, not an example. Procedural post-and-rail fence +section (two posts, pyramidal caps, three rails, kickboard, brace, +iron shoes and straps) then the shipped pipeline: unique-cell UVs, +Cycles high-to-low normal bake, LOD chain, convex collider, Unity +glTF export. + +It asserts **budget conformance** of the generated result. It does not +witness an API contract. "It rendered without error" is not a check. +AABB X is the tile width so adjacent copies meet; this piece does not +re-witness the `modular-kit-snap` snap contract. + +**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 | 1160–1280 | 1212 / 1212 / 1212 | +| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | +| LOD2 ratio | 0.10–0.35 of base | 0.2195 / 0.2195 / 0.2079 | +| Materials | exactly 2 distinct, ≥24 metal faces | 2 slots, 120 metal | +| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 | +| Outer AABB | (1.642, 0.157, 1.235) m ± 0.01 | (1.6418, 0.1574, 1.2350), zmin 0 | +| Collider tris | ≤ 96 | 62 | +| Export | written, size > 0 | 93232 / 93232 / 93224 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. + +`--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 fence_kit.py -- +blender --background --python fence_kit.py -- --skip-decimate +blender --background --python fence_kit.py -- --output fence.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/fence-kit/fence_kit.py b/showcase/fence-kit/fence_kit.py new file mode 100644 index 0000000..5ca5b7b --- /dev/null +++ b/showcase/fence-kit/fence_kit.py @@ -0,0 +1,712 @@ +"""Game-ready post-and-rail fence kit — a showcase piece, not an example. + +Asserts budget conformance of a tiling timber fence section 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. AABB X is the tile width so +adjacent copies meet; this piece does not re-witness the modular-kit +snap contract. 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 fence_kit.py -- + blender --background --python fence_kit.py -- --skip-decimate + blender --background --python fence_kit.py -- --output fence.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 + +TILE = 1.60 +POST_W = 0.110 +POST_H = 1.14 +CAP_H = 0.095 +RAIL_Y = 0.055 +RAIL_Z = 0.064 +RAIL_ZS = (0.30, 0.58, 0.86) +KICK_Z = 0.125 +KICK_Y = 0.040 +BRACE_T = 0.042 +IRON_T = 0.016 +SHOE_H = 0.034 +SHOE_SCALE = 1.38 + +BBOX_TOL = 0.01 +OUTER_SIZE = (1.642, 0.157, 1.235) +BASE_TRIS_MIN = 1160 +BASE_TRIS_MAX = 1280 +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.06 +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_fence_mesh(name, bevel_offset, bevel_segments): + bm = bmesh.new() + try: + wood_verts = [] + post_xs = (-(TILE / 2.0 - POST_W / 2.0), TILE / 2.0 - POST_W / 2.0) + for px in post_xs: + wood_verts.extend( + add_box(bm, (px, 0.0, POST_H / 2.0), (POST_W, POST_W, POST_H), WOOD_IDX) + ) + wood_verts.extend( + add_cone( + bm, + (px, 0.0, POST_H + CAP_H / 2.0), + POST_W * 0.72, + 0.010, + CAP_H, + 4, + WOOD_IDX, + euler=(0.0, 0.0, math.pi / 4.0), + ) + ) + + rail_len = TILE - POST_W * 0.18 + for z in RAIL_ZS: + wood_verts.extend( + add_box(bm, (0.0, 0.0, z), (rail_len, RAIL_Y, RAIL_Z), WOOD_IDX) + ) + + wood_verts.extend( + add_box( + bm, + (0.0, 0.0, KICK_Z / 2.0), + (TILE - POST_W * 0.22, KICK_Y, KICK_Z), + WOOD_IDX, + ) + ) + + span = TILE - POST_W + rise = RAIL_ZS[-1] - RAIL_ZS[0] + brace_len = math.hypot(span, rise) + brace_ang = math.atan2(rise, span) + wood_verts.extend( + add_box( + bm, + (0.0, -POST_W * 0.55, (RAIL_ZS[0] + RAIL_ZS[-1]) / 2.0), + (brace_len, BRACE_T, BRACE_T), + WOOD_IDX, + euler=(0.0, -brace_ang, 0.0), + ) + ) + + if bevel_offset > 0.0: + edges = list({e for v in wood_verts for e in v.link_edges}) + bmesh.ops.bevel( + bm, + geom=edges, + offset=bevel_offset, + segments=bevel_segments, + profile=0.5, + affect="EDGES", + clamp_overlap=True, + ) + + for px in post_xs: + add_box( + bm, + (px, 0.0, SHOE_H / 2.0), + (POST_W * SHOE_SCALE, POST_W * SHOE_SCALE, SHOE_H), + METAL_IDX, + ) + sign = 1.0 if px > 0.0 else -1.0 + for z in RAIL_ZS: + add_box( + bm, + (px, POST_W / 2.0 + IRON_T / 2.0, z), + (POST_W * 0.88, IRON_T, RAIL_Z * 1.40), + METAL_IDX, + ) + add_box( + bm, + (px, -(POST_W / 2.0 + IRON_T / 2.0), z), + (POST_W * 0.88, IRON_T, RAIL_Z * 1.40), + METAL_IDX, + ) + add_box( + bm, + (px + sign * (POST_W / 2.0 + IRON_T / 2.0), 0.0, z), + (IRON_T, POST_W * 0.88, RAIL_Z * 1.40), + 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] + cy = 0.5 * (min(ys) + max(ys)) + zmin = min(zs) + for v in bm.verts: + 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): + # Do not materials.clear() — that resets polygon material_index to 0 + # on this Blender, which would drop metal faces onto wood. + 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("FenceNrm", 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_fence_mesh("FenceLow", bevel_offset=0.006, bevel_segments=2) + high = build_fence_mesh("FenceHigh", bevel_offset=0.006, bevel_segments=4) + wood = principled("FenceWood", (0.46, 0.24, 0.08, 1.0), 0.0, 0.55) + metal = principled("FenceIron", (0.11, 0.115, 0.13, 1.0), 1.0, 0.28) + assign_slots(low, wood, metal) + assign_slots(high, wood, metal) + + if low.data is None or len(low.data.polygons) < 6: + return fail("fence 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("fence has no UV layer", 3), None, None, None, None, None + bake_result = bake_normal(high, low) + + lod1 = make_lod(low, "FenceLOD1", LOD1_TARGET, skip_decimate) + lod2 = make_lod(low, "FenceLOD2", 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_fence_mesh("FenceColSrc", bevel_offset=0.0, bevel_segments=1) + collider = convex_hull_collider(collider_src, "FenceCollider") + bpy.data.objects.remove(collider_src, do_unlink=True) + col_tris = triangle_count(collider.data) + + export_path = os.path.join( + tempfile.gettempdir(), + f"bdt_fence_kit_{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(-16.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 = (2.50, -3.46, 1.95) + scene.collection.objects.link(cam) + aim = bpy.data.objects.new("Aim", None) + aim.location = (0.0, 0.0, 0.58) + 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("fence-kit 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/fence-kit/preview.webp b/showcase/fence-kit/preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..3957412b3e8155cd1266755f91176fda2d5433dd GIT binary patch literal 15316 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