From 42b562dd0a6c31b6e5cec241d8204b797418e069 Mon Sep 17 00:00:00 2001
From: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
Date: Sun, 13 Sep 2026 15:12:38 -0400
Subject: [PATCH 1/2] feat: add campfire as a fourth 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 | 1 +
docs/gallery/assets/campfire-hero.webp | Bin 0 -> 33862 bytes
docs/gallery/campfire/index.html | 1103 ++++++++++++++++++++++++
docs/gallery/index.html | 13 +-
showcase/campfire/README.md | 73 ++
showcase/campfire/campfire.py | 779 +++++++++++++++++
showcase/campfire/preview.webp | Bin 0 -> 31906 bytes
showcase/gallery.json | 12 +
tests/smoke/catalog.json | 3 +-
13 files changed, 1991 insertions(+), 7 deletions(-)
create mode 100644 docs/gallery/assets/campfire-hero.webp
create mode 100644 docs/gallery/campfire/index.html
create mode 100644 showcase/campfire/README.md
create mode 100644 showcase/campfire/campfire.py
create mode 100644 showcase/campfire/preview.webp
diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index d75ddde..4bea0e7 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -140,6 +140,7 @@
"showcase": [
"showcase/shipping-crate",
"showcase/stone-well",
- "showcase/wooden-barrel"
+ "showcase/wooden-barrel",
+ "showcase/campfire"
]
}
diff --git a/AGENTS.md b/AGENTS.md
index dec8a32..f33c126 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 3 showcase pieces (counts are CI-enforced against README.md
+examples, and 4 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 3cb6a95..c740f45 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, 3 pieces (sibling of examples/; see showcase/README.md)
+showcase// - Budget-conformance props, 4 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 4909166..86c661d 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 3 showcase pieces
+ 16 skills • 9 rules • 3 templates • 27 snippets • 59 examples • 4 showcase pieces
@@ -37,7 +37,7 @@
## Overview
-This repository ships **16 skills, 9 rules, 3 templates, 27 snippets, 59 examples, and 3 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 4 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.
@@ -78,6 +78,7 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show
+
[`shipping-crate`](showcase/shipping-crate/) — procedural crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets. Falsifier `--skip-decimate` exits 9.
@@ -85,6 +86,8 @@ Budget-conformance props. Not examples. Conventions: [`showcase/README.md`](show
[`wooden-barrel`](showcase/wooden-barrel/) — procedural staved barrel with iron hoops through the same pipeline. Falsifier `--skip-decimate` exits 9.
+[`campfire`](showcase/campfire/) — procedural cobble ring, logs, and ash 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 4788ab8..a45ede9 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -109,6 +109,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- ~~Showcase sibling tree + shipping-crate pilot~~ **SHIPPED** as `showcase/shipping-crate/` — budget-conformance crate composing bake, LOD, collider, Unity glTF; `--skip-decimate` exits 9 on the LOD1 ratio band; DECIMATE COLLAPSE ratios diverge on 5.2 vs 4.5/5.1
- ~~Round stone well showcase~~ **SHIPPED** as `showcase/stone-well/` — running-bond brick well, shingled pyramid roof, windlass/rope/bucket; `--skip-decimate` exits 9 on the LOD1 ratio band
- ~~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/` — cobble ring, ash mound, crossed logs; `--skip-decimate` exits 9 on the LOD1 ratio band
- Procedural terrain or landscape showcase using Geometry Nodes scatter
- Hero prop with a more complex silhouette (lantern or treasure chest) as a second showcase piece
- Small modular kit showcase on recognizable geometry (`modular-kit-snap` contract)
diff --git a/docs/gallery/assets/campfire-hero.webp b/docs/gallery/assets/campfire-hero.webp
new file mode 100644
index 0000000000000000000000000000000000000000..b25c3b91a97195a638083b1e63092da71cf59535
GIT binary patch
literal 33862
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
literal 0
HcmV?d00001
diff --git a/docs/gallery/campfire/index.html b/docs/gallery/campfire/index.html
new file mode 100644
index 0000000..3e729e5
--- /dev/null
+++ b/docs/gallery/campfire/index.html
@@ -0,0 +1,1103 @@
+
+
+
+
+
+ campfire — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ campfire
+ A procedural campfire 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: 7528 tris, three materials with 116 wood and 18 ash faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.964×0.950×0.252 m, LOD ratios in band, convex collider 105 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
+
blender --background --python showcase/campfire/campfire.py --
+
Copy
+
+
+A showcase piece, not an example. Procedural campfire (flattened cobble ring, inner stones, ash mound, crossed logs, sticks, charcoal) then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
+It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.
+Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied, not imported as a package).
+Budgets
+Declared as named constants; every gate recomputes from the mesh, materials, UVs, evaluated LOD, collider, or export file.
+| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) | | --- | --- | --- | | Base triangles | 7400–7650 | 7528 / 7528 / 7528 | | LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 | | LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.2200 | | Materials | exactly 3 distinct, ≥24 wood faces, ≥8 ash faces | 3 slots, 116 wood, 18 ash | | UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 | | Outer AABB | (0.964, 0.950, 0.252) m ± 0.01 | (0.9641, 0.9495, 0.2521), zmin 0 | | Collider tris | ≤ 160 | 105 | | Export | written, size > 0 | 548944 / 548944 / 548936 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
+blender --background --python campfire.py --
+blender --background --python campfire.py -- --skip-decimate
+blender --background --python campfire.py -- --output campfire.png
+Smoke does not pass --output or --skip-decimate.
+Exit codes
+File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path.
+| Code | Meaning | | --- | --- | | 0 | Success | | 1 | Uncaught exception (FATAL wrapper) | | 2 | argparse / usage | | 3 | Mesh did not build / no UV layer | | 4 | Base triangle count outside range | | 5 | Material count ≠ 3 distinct slots, or wood/ash 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
+
+ """Game-ready campfire — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural campfire after composing
+shipped pipeline pieces: bmesh construction, UVs, three materials,
+high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
+DECIMATE stage so the LOD-ratio budget fails.
+
+No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
+are not byte-identical across Blender versions — the LOD gate is a
+ratio band, not an exact count.
+
+ blender --background --python campfire.py --
+ blender --background --python campfire.py -- --skip-decimate
+ blender --background --python campfire.py -- --output campfire.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+# Showcase lives at repo-root/showcase/, not under examples/. The framing
+# helper is the repo's only shared import and lives next to the examples;
+# resolve the repo root so we do not move gallery_framing.py.
+_REPO = os.path.abspath(
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0 , os.path.join(_REPO, "examples" ))
+sys.dont_write_bytecode = True
+import gallery_framing # noqa: E402
+
+N_RING = 12
+RING_R = 0.40
+N_INNER = 7
+INNER_R = 0.22
+ASH_R = 0.26
+ASH_H = 0.055
+N_LOGS = 4
+LOG_LEN = 0.50
+LOG_R = 0.040
+N_STICKS = 3
+STICK_LEN = 0.38
+STICK_R = 0.016
+N_COALS = 5
+BBOX_TOL = 0.01
+# Fitted to the generated AABB after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (0.964 , 0.950 , 0.252 )
+
+BASE_TRIS_MIN = 7400
+BASE_TRIS_MAX = 7650
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 3
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 160
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.06
+
+STONE_IDX = 0
+WOOD_IDX = 1
+ASH_IDX = 2
+
+
+def eevee_engine_id():
+ return "BLENDER_EEVEE" if bpy.app.version >= (5 , 0 , 0 ) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+ print(f" ERROR: {msg}" , file=sys.stderr)
+ return code
+
+
+def triangle_count(mesh):
+ mesh.calc_loop_triangles()
+ return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+ # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
+ depsgraph = bpy.context.evaluated_depsgraph_get()
+ eval_obj = obj.evaluated_get(depsgraph)
+ eval_mesh = eval_obj.to_mesh()
+ try :
+ eval_mesh.calc_loop_triangles()
+ return len(eval_mesh.loop_triangles)
+ finally :
+ eval_obj.to_mesh_clear()
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ geo = bmesh.ops.create_cube(bm, size=1.0 )
+ verts = geo["verts" ]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ p = Vector((v.co.x * scale[0 ], v.co.y * scale[1 ], v.co.z * scale[2 ]))
+ v.co = rot @ p + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_cobble(bm, loc, scale, mat_idx, euler=(0.0 , 0.0 , 0.0 ), lump=0.22 ):
+ geo = bmesh.ops.create_icosphere(bm, subdivisions=1 , radius=0.5 )
+ verts = list(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 ]))
+ h = math.sin(p.x * 19.7 + p.y * 13.3 ) * math.cos(p.z * 17.1 + p.x * 7.9 )
+ h += 0.40 * math.sin(p.y * 31.0 + p.z * 23.0 )
+ h = max(-0.80 , min(0.80 , h))
+ p = p * (1.0 + lump * h)
+ 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 add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0 , 0.0 , 0.0 )):
+ return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler)
+
+
+def pack_uvs(bm, margin=0.08 ):
+ uv = bm.loops.layers.uv.new("UVMap" )
+ faces = list(bm.faces)
+ n = len(faces)
+ cols = max(1 , math.ceil(math.sqrt(n)))
+ rows = max(1 , math.ceil(n / cols))
+ cell_w = 1.0 / cols
+ cell_h = 1.0 / rows
+ pad_u = margin * cell_w * 0.5
+ pad_v = margin * cell_h * 0.5
+ usable_w = cell_w - 2.0 * pad_u
+ usable_h = cell_h - 2.0 * pad_v
+ for i, face in enumerate(faces):
+ col = i % cols
+ row = i // cols
+ nrm = face.normal
+ ax = abs(nrm.x)
+ ay = abs(nrm.y)
+ az = abs(nrm.z)
+ coords = []
+ for loop in face.loops:
+ co = loop.vert.co
+ if az >= ax and az >= ay:
+ coords.append((co.x, co.y))
+ elif ax >= ay:
+ coords.append((co.y, co.z))
+ else :
+ coords.append((co.x, co.z))
+ xs = [c[0 ] for c in coords]
+ ys = [c[1 ] for c in coords]
+ minx, maxx = min(xs), max(xs)
+ miny, maxy = min(ys), max(ys)
+ dx = max(maxx - minx, 1e-8 )
+ dy = max(maxy - miny, 1e-8 )
+ origin_u = col * cell_w + pad_u
+ origin_v = row * cell_h + pad_v
+ for loop, (x, y) in zip(face.loops, coords):
+ loop[uv].uv = (
+ origin_u + (x - minx) / dx * usable_w,
+ origin_v + (y - miny) / dy * usable_h,
+ )
+
+
+def _stone_size(i, base_xy, base_z, k):
+ sx = base_xy[0 ] + 0.035 * abs(math.sin(i * 2.17 + k))
+ sy = base_xy[1 ] + 0.028 * abs(math.cos(i * 1.73 + k * 0.7 ))
+ sz = base_z + 0.038 * abs(math.sin(i * 1.11 + k * 1.3 ))
+ return sx, sy, sz
+
+
+def build_campfire_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ bevel_verts = []
+ ash_faces = set()
+ wood_faces = set()
+ try :
+ for i in range(N_RING):
+ ang = 2.0 * math.pi * i / N_RING
+ sx, sy, sz = _stone_size(i, (0.175 , 0.138 ), 0.070 , 0.2 )
+ r = RING_R + 0.028 * math.sin(i * 1.9 )
+ loc = (r * math.cos(ang), r * math.sin(ang), sz / 2.0 )
+ tilt = (
+ math.radians(18.0 * math.sin(i * 0.7 )),
+ math.radians(14.0 * math.cos(i * 1.3 )),
+ ang + math.radians(22.0 * math.sin(i * 1.1 )),
+ )
+ bevel_verts.extend(
+ add_cobble(bm, loc, (sx, sy, sz), STONE_IDX, euler=tilt, lump=0.24 )
+ )
+
+ for i in range(N_INNER):
+ ang = 2.0 * math.pi * i / N_INNER + 0.31
+ sx, sy, sz = _stone_size(i, (0.100 , 0.082 ), 0.048 , 1.4 )
+ r = INNER_R + 0.018 * math.cos(i * 1.4 )
+ loc = (r * math.cos(ang), r * math.sin(ang), sz / 2.0 )
+ tilt = (
+ math.radians(12.0 * math.cos(i * 0.9 )),
+ math.radians(10.0 * math.sin(i * 1.5 )),
+ ang + 0.4 ,
+ )
+ bevel_verts.extend(
+ add_cobble(bm, loc, (sx, sy, sz), STONE_IDX, euler=tilt, lump=0.20 )
+ )
+
+ before = set(bm.faces)
+ add_cone(
+ bm,
+ (0.0 , 0.0 , ASH_H / 2.0 ),
+ ASH_R,
+ 0.055 ,
+ ASH_H,
+ 16 ,
+ ASH_IDX,
+ )
+ ash_faces.update(set(bm.faces) - before)
+
+ for i in range(N_LOGS):
+ yaw = i * (math.pi / 2.0 ) + math.radians(18.0 )
+ loc = (
+ 0.045 * math.cos(yaw + math.pi / 4.0 ),
+ 0.045 * math.sin(yaw + math.pi / 4.0 ),
+ LOG_R + 0.020 + 0.012 * (i % 2 ),
+ )
+ before = set(bm.faces)
+ verts = add_cylinder(
+ bm,
+ loc,
+ LOG_R * (1.0 - 0.08 * (i % 2 )),
+ LOG_LEN,
+ 12 ,
+ WOOD_IDX,
+ euler=(math.pi / 2.0 , 0.0 , yaw),
+ )
+ bevel_verts.extend(verts)
+ wood_faces.update(set(bm.faces) - before)
+
+ for i in range(N_STICKS):
+ yaw = i * (2.0 * math.pi / N_STICKS) + 0.55
+ pitch = math.radians(32.0 + 6.0 * math.sin(i * 1.7 ))
+ loc = (
+ 0.07 * math.cos(yaw),
+ 0.07 * math.sin(yaw),
+ 0.055 + STICK_R,
+ )
+ before = set(bm.faces)
+ verts = add_cylinder(
+ bm,
+ loc,
+ STICK_R,
+ STICK_LEN,
+ 8 ,
+ WOOD_IDX,
+ euler=(math.pi / 2.0 - pitch, 0.0 , yaw),
+ )
+ bevel_verts.extend(verts)
+ wood_faces.update(set(bm.faces) - before)
+
+ for i in range(N_COALS):
+ ang = 2.0 * math.pi * i / N_COALS + 0.2
+ r = 0.07 + 0.025 * (i % 2 )
+ sz = 0.028 + 0.010 * abs(math.sin(i * 2.2 ))
+ loc = (r * math.cos(ang), r * math.sin(ang), ASH_H * 0.55 + sz / 2.0 )
+ before = set(bm.faces)
+ verts = add_box(
+ bm,
+ loc,
+ (0.040 , 0.028 , sz),
+ WOOD_IDX,
+ euler=(0.0 , 0.0 , ang),
+ )
+ bevel_verts.extend(verts)
+ wood_faces.update(set(bm.faces) - before)
+
+ if bevel_offset > 0.0 :
+ edges = list({e for v in bevel_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 ,
+ )
+
+ zmin = min(v.co.z for v in bm.verts)
+ if zmin != 0.0 :
+ for v in bm.verts:
+ v.co.z -= zmin
+
+ pack_uvs(bm)
+ bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+ for face in bm.faces:
+ face.smooth = True
+ for edge in bm.edges:
+ edge.smooth = True
+ if edge.is_manifold and len(edge.link_faces) == 2 :
+ if edge.calc_face_angle() > math.radians(35.0 ):
+ edge.smooth = False
+ for f in ash_faces:
+ if f.is_valid:
+ f.material_index = ASH_IDX
+ for f in wood_faces:
+ if f.is_valid:
+ f.material_index = WOOD_IDX
+ me = bpy.data.meshes.new(name)
+ bm.to_mesh(me)
+ me.update()
+ finally :
+ bm.free()
+ obj = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(obj)
+ return obj
+
+
+def principled(name, color, metallic, roughness):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF" ]
+ bsdf.inputs["Base Color" ].default_value = color
+ bsdf.inputs["Metallic" ].default_value = metallic
+ bsdf.inputs["Roughness" ].default_value = roughness
+ return mat
+
+
+def assign_slots(obj, stone, wood, ash):
+ # Do not materials.clear() — that resets polygon material_index to 0
+ # on this Blender, which would drop wood/ash faces onto stone.
+ mats = obj.data.materials
+ wanted = (stone, wood, ash)
+ for i, mat in enumerate(wanted):
+ if i < len(mats):
+ mats[i] = mat
+ else :
+ mats.append(mat)
+
+
+def world_bbox(obj):
+ corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+ xs = [c.x for c in corners]
+ ys = [c.y for c in corners]
+ zs = [c.z for c in corners]
+ return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+ uv = mesh.uv_layers.active
+ if uv is None :
+ return 0.0 , 0.0 , 1.0 , 1.0 , 0 , 1.0
+ data = uv.data
+ us = [loop.uv[0 ] for loop in data]
+ vs = [loop.uv[1 ] for loop in data]
+ aabbs = []
+ for poly in mesh.polygons:
+ pu = [data[i].uv[0 ] for i in poly.loop_indices]
+ pv = [data[i].uv[1 ] for i in poly.loop_indices]
+ aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+ overlap = 0.0
+ for i in range(len(aabbs)):
+ a = aabbs[i]
+ for j in range(i + 1 , len(aabbs)):
+ b = aabbs[j]
+ x0 = max(a[0 ], b[0 ])
+ y0 = max(a[1 ], b[1 ])
+ x1 = min(a[2 ], b[2 ])
+ y1 = min(a[3 ], b[3 ])
+ overlap += max(0.0 , x1 - x0) * max(0.0 , y1 - y0)
+ return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+ mesh = obj.data.copy()
+ lod = bpy.data.objects.new(name, mesh)
+ lod.matrix_world = obj.matrix_world.copy()
+ bpy.context.scene.collection.objects.link(lod)
+ if not skip_decimate and 0.0 < ratio < 1.0 :
+ mod = lod.modifiers.new("DecimateBudget" , "DECIMATE" )
+ mod.decimate_type = "COLLAPSE"
+ mod.ratio = ratio
+ return lod
+
+
+def convex_hull_collider(obj, name):
+ # Duplicated from snippets/convex_hull_collider.py (not a package).
+ mesh = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try :
+ bm.from_mesh(obj.data)
+ result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+ interior = result.get("geom_interior" ) or []
+ unused = result.get("geom_unused" ) or []
+ if interior:
+ bmesh.ops.delete(bm, geom=interior, context="VERTS" )
+ if unused:
+ bmesh.ops.delete(bm, geom=unused, context="VERTS" )
+ bm.to_mesh(mesh)
+ mesh.update()
+ finally :
+ bm.free()
+ collider = bpy.data.objects.new(name, mesh)
+ bpy.context.collection.objects.link(collider)
+ collider.matrix_world = obj.matrix_world.copy()
+ return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+ # Adapted from snippets/setup_bake_target_image.py — do not replace slots.
+ if not obj.data.uv_layers:
+ return None , None
+ img = bpy.data.images.new("CampfireNrm" , 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 = STONE_IDX
+ return img, tex
+
+
+def bake_normal(high, low):
+ # Duplicated from snippets/bake_normal_high_to_low.py (not a package).
+ scene = bpy.context.scene
+ scene.render.engine = "CYCLES"
+ scene.cycles.device = "CPU"
+ scene.cycles.samples = 1
+ scene.cycles.use_denoising = False
+ for ob in bpy.context.view_layer.objects:
+ ob.select_set(False )
+ high.select_set(True )
+ low.select_set(True )
+ bpy.context.view_layer.objects.active = low
+ return bpy.ops.object.bake(
+ type="NORMAL" ,
+ use_selected_to_active=True ,
+ cage_extrusion=CAGE_EXTRUSION,
+ use_cage=False ,
+ normal_space="TANGENT" ,
+ margin=4 ,
+ margin_type="ADJACENT_FACES" ,
+ use_clear=True ,
+ target="IMAGE_TEXTURES" ,
+ )
+
+
+def export_unity(path, objects):
+ # Duplicated from snippets/export_preset_unity.py (not a package).
+ for ob in bpy.context.view_layer.objects:
+ ob.select_set(False )
+ for ob in objects:
+ ob.select_set(True )
+ bpy.context.view_layer.objects.active = objects[0 ]
+ bpy.ops.export_scene.gltf(
+ filepath=path,
+ use_selection=True ,
+ export_yup=True ,
+ export_apply=True ,
+ export_draco_mesh_compression_enable=False ,
+ export_animations=False ,
+ )
+
+
+def check(skip_decimate):
+ bpy.ops.wm.read_factory_settings(use_empty=True )
+ low = build_campfire_mesh("CampfireLow" , bevel_offset=0.006 , bevel_segments=2 )
+ high = build_campfire_mesh("CampfireHigh" , bevel_offset=0.006 , bevel_segments=4 )
+ stone = principled("CampfireStone" , (0.46 , 0.40 , 0.34 , 1.0 ), 0.0 , 0.72 )
+ wood = principled("CampfireWood" , (0.14 , 0.075 , 0.040 , 1.0 ), 0.0 , 0.78 )
+ ash = principled("CampfireAsh" , (0.28 , 0.26 , 0.24 , 1.0 ), 0.0 , 0.90 )
+ assign_slots(low, stone, wood, ash)
+ assign_slots(high, stone, wood, ash)
+
+ if low.data is None or len(low.data.polygons) < 6 :
+ return fail("campfire 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, stone)
+ if img is None :
+ return fail("campfire has no UV layer" , 3 ), None , None , None , None , None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "CampfireLOD1" , LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "CampfireLOD2" , 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_campfire_mesh(
+ "CampfireColSrc" , bevel_offset=0.0 , bevel_segments=1
+ )
+ collider = convex_hull_collider(collider_src, "CampfireCollider" )
+ bpy.data.objects.remove(collider_src, do_unlink=True )
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f" bdt_campfire_ {os.getpid()}.glb " ,
+ )
+ if os.path.exists(export_path):
+ os.remove(export_path)
+ export_unity(export_path, [low, collider])
+ export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+ print(
+ f" blender= {tuple(bpy.app.version)} skip_decimate= {skip_decimate}"
+ )
+ print(
+ f" measured base_tris= {base_tris} lod1_tris= {lod1_tris} "
+ f" lod2_tris= {lod2_tris} r1= {r1:.4f } r2= {r2:.4f }"
+ )
+ print(
+ f" measured nmat= {nmat} uv=( {u0:.4f }, {v0:.4f })-( {u1:.4f }, {v1:.4f }) "
+ f" overlap= {overlap:.6f } nfaces= {nfaces}"
+ )
+ print(
+ f" measured bbox=( {size_x:.4f }, {size_y:.4f }, {size_z:.4f }) "
+ f" outer= {OUTER_SIZE} zmin= {bb[2 ]:.4f }"
+ )
+ print(
+ f" measured collider_tris= {col_tris} bake= {bake_result} "
+ f" bake_has_data= {img.has_data} export_bytes= {export_size}"
+ )
+
+ if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+ return fail(
+ f" base tris {base_tris} not in [ {BASE_TRIS_MIN}, {BASE_TRIS_MAX}] " ,
+ 4 ,
+ ), None , None , None , None , None
+ if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+ return fail(
+ f" material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}" ,
+ 5 ,
+ ), None , None , None , None , None
+ if idx_counts.get(WOOD_IDX, 0 ) < 24 :
+ return fail(
+ f" wood log faces {idx_counts.get(WOOD_IDX, 0 )} < 24 " ,
+ 5 ,
+ ), None , None , None , None , None
+ if idx_counts.get(ASH_IDX, 0 ) < 8 :
+ return fail(
+ f" ash faces {idx_counts.get(ASH_IDX, 0 )} < 8 " ,
+ 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, stone, 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, stone, tex, path, engine):
+ scene = bpy.context.scene
+ wire_normal(stone, 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(-36.0 )
+ low.rotation_euler.x = math.radians(4.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 = (0.96 , -1.33 , 0.69 )
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim" , None )
+ aim.location = (0.0 , 0.0 , 0.10 )
+ 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, stone, tex, _col = check(args.skip_decimate)
+ if code:
+ return code
+ if args.output:
+ rcode = render_still(low, stone, tex, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
+ print(f" rendered still {args.output}" )
+ print("campfire 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/docs/gallery/index.html b/docs/gallery/index.html
index 98d5568..0a59254 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" />
×
- 54 examples
+ 55 examples
+
+
+
+
+
+
+
A procedural campfire through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
+
witnesses Recomputed: 7528 tris, three materials with 116 wood and 18 ash faces, UVs in 0..1 with zero AABB overlap, outer AABB 0.964×0.950×0.252 m, LOD ratios in band, convex collider 105 tris, non-empty glTF. --skip-decimate exits 9 on the LOD1 ratio budget.
+
View example →
+
+
No examples match the current filters.
Clear search and tags
diff --git a/showcase/campfire/README.md b/showcase/campfire/README.md
new file mode 100644
index 0000000..a92161a
--- /dev/null
+++ b/showcase/campfire/README.md
@@ -0,0 +1,73 @@
+# Campfire
+
+A showcase piece, not an example. Procedural campfire (flattened cobble
+ring, inner stones, ash mound, crossed logs, sticks, charcoal) then the
+shipped pipeline: unique-cell UVs, Cycles high-to-low normal bake, LOD
+chain, convex collider, Unity glTF export.
+
+It asserts **budget conformance** of the generated result. It does not
+witness an API contract. "It rendered without error" is not a check.
+
+**Composes** skills `mesh-editing-and-bmesh`, `bake-high-to-low`,
+`depsgraph-and-evaluated-data`, `engine-export-presets`, and snippets
+`bake_normal_high_to_low.py`, `setup_bake_target_image.py`,
+`lod_chain.py` / `decimate_to_budget.py`, `convex_hull_collider.py`,
+`export_preset_unity.py` (helpers copied, not imported as a package).
+
+## Budgets
+
+Declared as named constants; every gate **recomputes** from the mesh,
+materials, UVs, evaluated LOD, collider, or export file.
+
+| Axis | Declared | Measured (4.5.11 / 5.1.2 / 5.2.1) |
+| --- | --- | --- |
+| Base triangles | 7400–7650 | 7528 / 7528 / 7528 |
+| LOD1 ratio | 0.32–0.62 of base | 0.5000 / 0.5000 / 0.5000 |
+| LOD2 ratio | 0.10–0.35 of base | 0.2200 / 0.2200 / 0.2200 |
+| Materials | exactly 3 distinct, ≥24 wood faces, ≥8 ash faces | 3 slots, 116 wood, 18 ash |
+| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
+| Outer AABB | (0.964, 0.950, 0.252) m ± 0.01 | (0.9641, 0.9495, 0.2521), zmin 0 |
+| Collider tris | ≤ 160 | 105 |
+| Export | written, size > 0 | 548944 / 548944 / 548936 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
+
+```bash
+blender --background --python campfire.py --
+blender --background --python campfire.py -- --skip-decimate
+blender --background --python campfire.py -- --output campfire.png
+```
+
+Smoke does not pass `--output` or `--skip-decimate`.
+
+## Exit codes
+
+File-local. `9` is a valid check code. `10` is reserved for
+`gallery_framing.check_framing` on the `--output` path.
+
+| Code | Meaning |
+| --- | --- |
+| 0 | Success |
+| 1 | Uncaught exception (FATAL wrapper) |
+| 2 | argparse / usage |
+| 3 | Mesh did not build / no UV layer |
+| 4 | Base triangle count outside range |
+| 5 | Material count ≠ 3 distinct slots, or wood/ash 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/campfire/campfire.py b/showcase/campfire/campfire.py
new file mode 100644
index 0000000..d8f2a82
--- /dev/null
+++ b/showcase/campfire/campfire.py
@@ -0,0 +1,779 @@
+"""Game-ready campfire — a showcase piece, not an example.
+
+Asserts budget conformance of a procedural campfire after composing
+shipped pipeline pieces: bmesh construction, UVs, three materials,
+high-to-low normal bake, LOD chain, convex collider, Unity glTF export.
+
+Budgets are declared below and recomputed from the generated result.
+They are not API-contract witnesses. ``--skip-decimate`` skips the LOD
+DECIMATE stage so the LOD-ratio budget fails.
+
+No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts
+are not byte-identical across Blender versions — the LOD gate is a
+ratio band, not an exact count.
+
+ blender --background --python campfire.py --
+ blender --background --python campfire.py -- --skip-decimate
+ blender --background --python campfire.py -- --output campfire.png
+"""
+import argparse
+import math
+import os
+import sys
+import tempfile
+import traceback
+
+import bmesh
+import bpy
+from mathutils import Euler, Vector
+
+# Showcase lives at repo-root/showcase/, not under examples/. The framing
+# helper is the repo's only shared import and lives next to the examples;
+# resolve the repo root so we do not move gallery_framing.py.
+_REPO = os.path.abspath(
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir)
+)
+sys.path.insert(0, os.path.join(_REPO, "examples"))
+sys.dont_write_bytecode = True
+import gallery_framing # noqa: E402
+
+N_RING = 12
+RING_R = 0.40
+N_INNER = 7
+INNER_R = 0.22
+ASH_R = 0.26
+ASH_H = 0.055
+N_LOGS = 4
+LOG_LEN = 0.50
+LOG_R = 0.040
+N_STICKS = 3
+STICK_LEN = 0.38
+STICK_R = 0.016
+N_COALS = 5
+BBOX_TOL = 0.01
+# Fitted to the generated AABB after locking geometry. Recomputed from bound_box.
+OUTER_SIZE = (0.964, 0.950, 0.252)
+
+BASE_TRIS_MIN = 7400
+BASE_TRIS_MAX = 7650
+LOD1_RATIO_MIN = 0.32
+LOD1_RATIO_MAX = 0.62
+LOD2_RATIO_MIN = 0.10
+LOD2_RATIO_MAX = 0.35
+LOD1_TARGET = 0.50
+LOD2_TARGET = 0.22
+MATERIAL_COUNT = 3
+UV_EPS = 1e-4
+UV_OVERLAP_MAX = 1e-5
+COLLIDER_TRIS_MAX = 160
+BAKE_RES = 256
+CAGE_EXTRUSION = 0.06
+
+STONE_IDX = 0
+WOOD_IDX = 1
+ASH_IDX = 2
+
+
+def eevee_engine_id():
+ return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT"
+
+
+def fail(msg, code):
+ print(f"ERROR: {msg}", file=sys.stderr)
+ return code
+
+
+def triangle_count(mesh):
+ mesh.calc_loop_triangles()
+ return len(mesh.loop_triangles)
+
+
+def evaluated_triangle_count(obj):
+ # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package).
+ depsgraph = bpy.context.evaluated_depsgraph_get()
+ eval_obj = obj.evaluated_get(depsgraph)
+ eval_mesh = eval_obj.to_mesh()
+ try:
+ eval_mesh.calc_loop_triangles()
+ return len(eval_mesh.loop_triangles)
+ finally:
+ eval_obj.to_mesh_clear()
+
+
+def add_box(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0)):
+ geo = bmesh.ops.create_cube(bm, size=1.0)
+ verts = geo["verts"]
+ rot = Euler(euler).to_matrix()
+ origin = Vector(loc)
+ for v in verts:
+ p = Vector((v.co.x * scale[0], v.co.y * scale[1], v.co.z * scale[2]))
+ v.co = rot @ p + origin
+ faces = {f for v in verts for f in v.link_faces}
+ for f in faces:
+ f.material_index = mat_idx
+ return verts
+
+
+def add_cobble(bm, loc, scale, mat_idx, euler=(0.0, 0.0, 0.0), lump=0.22):
+ geo = bmesh.ops.create_icosphere(bm, subdivisions=1, radius=0.5)
+ verts = list(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]))
+ h = math.sin(p.x * 19.7 + p.y * 13.3) * math.cos(p.z * 17.1 + p.x * 7.9)
+ h += 0.40 * math.sin(p.y * 31.0 + p.z * 23.0)
+ h = max(-0.80, min(0.80, h))
+ p = p * (1.0 + lump * h)
+ 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 add_cylinder(bm, loc, radius, depth, segments, mat_idx, euler=(0.0, 0.0, 0.0)):
+ return add_cone(bm, loc, radius, radius, depth, segments, mat_idx, euler=euler)
+
+
+def pack_uvs(bm, margin=0.08):
+ uv = bm.loops.layers.uv.new("UVMap")
+ faces = list(bm.faces)
+ n = len(faces)
+ cols = max(1, math.ceil(math.sqrt(n)))
+ rows = max(1, math.ceil(n / cols))
+ cell_w = 1.0 / cols
+ cell_h = 1.0 / rows
+ pad_u = margin * cell_w * 0.5
+ pad_v = margin * cell_h * 0.5
+ usable_w = cell_w - 2.0 * pad_u
+ usable_h = cell_h - 2.0 * pad_v
+ for i, face in enumerate(faces):
+ col = i % cols
+ row = i // cols
+ nrm = face.normal
+ ax = abs(nrm.x)
+ ay = abs(nrm.y)
+ az = abs(nrm.z)
+ coords = []
+ for loop in face.loops:
+ co = loop.vert.co
+ if az >= ax and az >= ay:
+ coords.append((co.x, co.y))
+ elif ax >= ay:
+ coords.append((co.y, co.z))
+ else:
+ coords.append((co.x, co.z))
+ xs = [c[0] for c in coords]
+ ys = [c[1] for c in coords]
+ minx, maxx = min(xs), max(xs)
+ miny, maxy = min(ys), max(ys)
+ dx = max(maxx - minx, 1e-8)
+ dy = max(maxy - miny, 1e-8)
+ origin_u = col * cell_w + pad_u
+ origin_v = row * cell_h + pad_v
+ for loop, (x, y) in zip(face.loops, coords):
+ loop[uv].uv = (
+ origin_u + (x - minx) / dx * usable_w,
+ origin_v + (y - miny) / dy * usable_h,
+ )
+
+
+def _stone_size(i, base_xy, base_z, k):
+ sx = base_xy[0] + 0.035 * abs(math.sin(i * 2.17 + k))
+ sy = base_xy[1] + 0.028 * abs(math.cos(i * 1.73 + k * 0.7))
+ sz = base_z + 0.038 * abs(math.sin(i * 1.11 + k * 1.3))
+ return sx, sy, sz
+
+
+def build_campfire_mesh(name, bevel_offset, bevel_segments):
+ bm = bmesh.new()
+ bevel_verts = []
+ ash_faces = set()
+ wood_faces = set()
+ try:
+ for i in range(N_RING):
+ ang = 2.0 * math.pi * i / N_RING
+ sx, sy, sz = _stone_size(i, (0.175, 0.138), 0.070, 0.2)
+ r = RING_R + 0.028 * math.sin(i * 1.9)
+ loc = (r * math.cos(ang), r * math.sin(ang), sz / 2.0)
+ tilt = (
+ math.radians(18.0 * math.sin(i * 0.7)),
+ math.radians(14.0 * math.cos(i * 1.3)),
+ ang + math.radians(22.0 * math.sin(i * 1.1)),
+ )
+ bevel_verts.extend(
+ add_cobble(bm, loc, (sx, sy, sz), STONE_IDX, euler=tilt, lump=0.24)
+ )
+
+ for i in range(N_INNER):
+ ang = 2.0 * math.pi * i / N_INNER + 0.31
+ sx, sy, sz = _stone_size(i, (0.100, 0.082), 0.048, 1.4)
+ r = INNER_R + 0.018 * math.cos(i * 1.4)
+ loc = (r * math.cos(ang), r * math.sin(ang), sz / 2.0)
+ tilt = (
+ math.radians(12.0 * math.cos(i * 0.9)),
+ math.radians(10.0 * math.sin(i * 1.5)),
+ ang + 0.4,
+ )
+ bevel_verts.extend(
+ add_cobble(bm, loc, (sx, sy, sz), STONE_IDX, euler=tilt, lump=0.20)
+ )
+
+ before = set(bm.faces)
+ add_cone(
+ bm,
+ (0.0, 0.0, ASH_H / 2.0),
+ ASH_R,
+ 0.055,
+ ASH_H,
+ 16,
+ ASH_IDX,
+ )
+ ash_faces.update(set(bm.faces) - before)
+
+ for i in range(N_LOGS):
+ yaw = i * (math.pi / 2.0) + math.radians(18.0)
+ loc = (
+ 0.045 * math.cos(yaw + math.pi / 4.0),
+ 0.045 * math.sin(yaw + math.pi / 4.0),
+ LOG_R + 0.020 + 0.012 * (i % 2),
+ )
+ before = set(bm.faces)
+ verts = add_cylinder(
+ bm,
+ loc,
+ LOG_R * (1.0 - 0.08 * (i % 2)),
+ LOG_LEN,
+ 12,
+ WOOD_IDX,
+ euler=(math.pi / 2.0, 0.0, yaw),
+ )
+ bevel_verts.extend(verts)
+ wood_faces.update(set(bm.faces) - before)
+
+ for i in range(N_STICKS):
+ yaw = i * (2.0 * math.pi / N_STICKS) + 0.55
+ pitch = math.radians(32.0 + 6.0 * math.sin(i * 1.7))
+ loc = (
+ 0.07 * math.cos(yaw),
+ 0.07 * math.sin(yaw),
+ 0.055 + STICK_R,
+ )
+ before = set(bm.faces)
+ verts = add_cylinder(
+ bm,
+ loc,
+ STICK_R,
+ STICK_LEN,
+ 8,
+ WOOD_IDX,
+ euler=(math.pi / 2.0 - pitch, 0.0, yaw),
+ )
+ bevel_verts.extend(verts)
+ wood_faces.update(set(bm.faces) - before)
+
+ for i in range(N_COALS):
+ ang = 2.0 * math.pi * i / N_COALS + 0.2
+ r = 0.07 + 0.025 * (i % 2)
+ sz = 0.028 + 0.010 * abs(math.sin(i * 2.2))
+ loc = (r * math.cos(ang), r * math.sin(ang), ASH_H * 0.55 + sz / 2.0)
+ before = set(bm.faces)
+ verts = add_box(
+ bm,
+ loc,
+ (0.040, 0.028, sz),
+ WOOD_IDX,
+ euler=(0.0, 0.0, ang),
+ )
+ bevel_verts.extend(verts)
+ wood_faces.update(set(bm.faces) - before)
+
+ if bevel_offset > 0.0:
+ edges = list({e for v in bevel_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,
+ )
+
+ zmin = min(v.co.z for v in bm.verts)
+ if zmin != 0.0:
+ for v in bm.verts:
+ v.co.z -= zmin
+
+ pack_uvs(bm)
+ bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
+ for face in bm.faces:
+ face.smooth = True
+ for edge in bm.edges:
+ edge.smooth = True
+ if edge.is_manifold and len(edge.link_faces) == 2:
+ if edge.calc_face_angle() > math.radians(35.0):
+ edge.smooth = False
+ for f in ash_faces:
+ if f.is_valid:
+ f.material_index = ASH_IDX
+ for f in wood_faces:
+ if f.is_valid:
+ f.material_index = WOOD_IDX
+ me = bpy.data.meshes.new(name)
+ bm.to_mesh(me)
+ me.update()
+ finally:
+ bm.free()
+ obj = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(obj)
+ return obj
+
+
+def principled(name, color, metallic, roughness):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF"]
+ bsdf.inputs["Base Color"].default_value = color
+ bsdf.inputs["Metallic"].default_value = metallic
+ bsdf.inputs["Roughness"].default_value = roughness
+ return mat
+
+
+def assign_slots(obj, stone, wood, ash):
+ # Do not materials.clear() — that resets polygon material_index to 0
+ # on this Blender, which would drop wood/ash faces onto stone.
+ mats = obj.data.materials
+ wanted = (stone, wood, ash)
+ for i, mat in enumerate(wanted):
+ if i < len(mats):
+ mats[i] = mat
+ else:
+ mats.append(mat)
+
+
+def world_bbox(obj):
+ corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
+ xs = [c.x for c in corners]
+ ys = [c.y for c in corners]
+ zs = [c.z for c in corners]
+ return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
+
+
+def uv_stats(mesh):
+ uv = mesh.uv_layers.active
+ if uv is None:
+ return 0.0, 0.0, 1.0, 1.0, 0, 1.0
+ data = uv.data
+ us = [loop.uv[0] for loop in data]
+ vs = [loop.uv[1] for loop in data]
+ aabbs = []
+ for poly in mesh.polygons:
+ pu = [data[i].uv[0] for i in poly.loop_indices]
+ pv = [data[i].uv[1] for i in poly.loop_indices]
+ aabbs.append((min(pu), min(pv), max(pu), max(pv)))
+ overlap = 0.0
+ for i in range(len(aabbs)):
+ a = aabbs[i]
+ for j in range(i + 1, len(aabbs)):
+ b = aabbs[j]
+ x0 = max(a[0], b[0])
+ y0 = max(a[1], b[1])
+ x1 = min(a[2], b[2])
+ y1 = min(a[3], b[3])
+ overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0)
+ return min(us), min(vs), max(us), max(vs), overlap, len(aabbs)
+
+
+def make_lod(obj, name, ratio, skip_decimate):
+ mesh = obj.data.copy()
+ lod = bpy.data.objects.new(name, mesh)
+ lod.matrix_world = obj.matrix_world.copy()
+ bpy.context.scene.collection.objects.link(lod)
+ if not skip_decimate and 0.0 < ratio < 1.0:
+ mod = lod.modifiers.new("DecimateBudget", "DECIMATE")
+ mod.decimate_type = "COLLAPSE"
+ mod.ratio = ratio
+ return lod
+
+
+def convex_hull_collider(obj, name):
+ # Duplicated from snippets/convex_hull_collider.py (not a package).
+ mesh = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ bm.from_mesh(obj.data)
+ result = bmesh.ops.convex_hull(bm, input=list(bm.verts))
+ interior = result.get("geom_interior") or []
+ unused = result.get("geom_unused") or []
+ if interior:
+ bmesh.ops.delete(bm, geom=interior, context="VERTS")
+ if unused:
+ bmesh.ops.delete(bm, geom=unused, context="VERTS")
+ bm.to_mesh(mesh)
+ mesh.update()
+ finally:
+ bm.free()
+ collider = bpy.data.objects.new(name, mesh)
+ bpy.context.collection.objects.link(collider)
+ collider.matrix_world = obj.matrix_world.copy()
+ return collider
+
+
+def setup_bake_image(obj, target_mat, size=BAKE_RES):
+ # Adapted from snippets/setup_bake_target_image.py — do not replace slots.
+ if not obj.data.uv_layers:
+ return None, None
+ img = bpy.data.images.new("CampfireNrm", 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 = STONE_IDX
+ return img, tex
+
+
+def bake_normal(high, low):
+ # Duplicated from snippets/bake_normal_high_to_low.py (not a package).
+ scene = bpy.context.scene
+ scene.render.engine = "CYCLES"
+ scene.cycles.device = "CPU"
+ scene.cycles.samples = 1
+ scene.cycles.use_denoising = False
+ for ob in bpy.context.view_layer.objects:
+ ob.select_set(False)
+ high.select_set(True)
+ low.select_set(True)
+ bpy.context.view_layer.objects.active = low
+ return bpy.ops.object.bake(
+ type="NORMAL",
+ use_selected_to_active=True,
+ cage_extrusion=CAGE_EXTRUSION,
+ use_cage=False,
+ normal_space="TANGENT",
+ margin=4,
+ margin_type="ADJACENT_FACES",
+ use_clear=True,
+ target="IMAGE_TEXTURES",
+ )
+
+
+def export_unity(path, objects):
+ # Duplicated from snippets/export_preset_unity.py (not a package).
+ for ob in bpy.context.view_layer.objects:
+ ob.select_set(False)
+ for ob in objects:
+ ob.select_set(True)
+ bpy.context.view_layer.objects.active = objects[0]
+ bpy.ops.export_scene.gltf(
+ filepath=path,
+ use_selection=True,
+ export_yup=True,
+ export_apply=True,
+ export_draco_mesh_compression_enable=False,
+ export_animations=False,
+ )
+
+
+def check(skip_decimate):
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ low = build_campfire_mesh("CampfireLow", bevel_offset=0.006, bevel_segments=2)
+ high = build_campfire_mesh("CampfireHigh", bevel_offset=0.006, bevel_segments=4)
+ stone = principled("CampfireStone", (0.46, 0.40, 0.34, 1.0), 0.0, 0.72)
+ wood = principled("CampfireWood", (0.14, 0.075, 0.040, 1.0), 0.0, 0.78)
+ ash = principled("CampfireAsh", (0.28, 0.26, 0.24, 1.0), 0.0, 0.90)
+ assign_slots(low, stone, wood, ash)
+ assign_slots(high, stone, wood, ash)
+
+ if low.data is None or len(low.data.polygons) < 6:
+ return fail("campfire 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, stone)
+ if img is None:
+ return fail("campfire has no UV layer", 3), None, None, None, None, None
+ bake_result = bake_normal(high, low)
+
+ lod1 = make_lod(low, "CampfireLOD1", LOD1_TARGET, skip_decimate)
+ lod2 = make_lod(low, "CampfireLOD2", 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_campfire_mesh(
+ "CampfireColSrc", bevel_offset=0.0, bevel_segments=1
+ )
+ collider = convex_hull_collider(collider_src, "CampfireCollider")
+ bpy.data.objects.remove(collider_src, do_unlink=True)
+ col_tris = triangle_count(collider.data)
+
+ export_path = os.path.join(
+ tempfile.gettempdir(),
+ f"bdt_campfire_{os.getpid()}.glb",
+ )
+ if os.path.exists(export_path):
+ os.remove(export_path)
+ export_unity(export_path, [low, collider])
+ export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0
+
+ print(
+ f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}"
+ )
+ print(
+ f"measured base_tris={base_tris} lod1_tris={lod1_tris} "
+ f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}"
+ )
+ print(
+ f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) "
+ f"overlap={overlap:.6f} nfaces={nfaces}"
+ )
+ print(
+ f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) "
+ f"outer={OUTER_SIZE} zmin={bb[2]:.4f}"
+ )
+ print(
+ f"measured collider_tris={col_tris} bake={bake_result} "
+ f"bake_has_data={img.has_data} export_bytes={export_size}"
+ )
+
+ if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX):
+ return fail(
+ f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]",
+ 4,
+ ), None, None, None, None, None
+ if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT:
+ return fail(
+ f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}",
+ 5,
+ ), None, None, None, None, None
+ if idx_counts.get(WOOD_IDX, 0) < 24:
+ return fail(
+ f"wood log faces {idx_counts.get(WOOD_IDX, 0)} < 24",
+ 5,
+ ), None, None, None, None, None
+ if idx_counts.get(ASH_IDX, 0) < 8:
+ return fail(
+ f"ash faces {idx_counts.get(ASH_IDX, 0)} < 8",
+ 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, stone, 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, stone, tex, path, engine):
+ scene = bpy.context.scene
+ wire_normal(stone, 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(-36.0)
+ low.rotation_euler.x = math.radians(4.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 = (0.96, -1.33, 0.69)
+ scene.collection.objects.link(cam)
+ aim = bpy.data.objects.new("Aim", None)
+ aim.location = (0.0, 0.0, 0.10)
+ 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, stone, tex, _col = check(args.skip_decimate)
+ if code:
+ return code
+ if args.output:
+ rcode = render_still(low, stone, tex, os.path.abspath(args.output), args.engine)
+ if rcode:
+ return rcode
+ print(f"rendered still {args.output}")
+ print("campfire 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/campfire/preview.webp b/showcase/campfire/preview.webp
new file mode 100644
index 0000000000000000000000000000000000000000..22249ea6bc2af4b98e8263cbd65ad7ea1ca17d92
GIT binary patch
literal 31906
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