shipping-crate
A procedural shipping crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
A procedural iron brazier — spun bowl with a rolled rim on three forged legs, a bed of ash and a heap of broken glowing charcoal — carried through UVs, bake, LOD, compound collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
Rendered headless by the showcase piece itself — click to zoom.
category Village
blender --background --python showcase/brazier/brazier.py --
A fire brazier: a spun-iron bowl with a rolled rim, three forged legs swept in one piece from the bowl wall to curled feet, a bed of ash in the bowl and a heap of thirty-six broken, glowing charcoal lumps on it. A showcase piece, not an example — it witnesses no API contract. It asserts that generated geometry meets declared asset budgets, recomputed from the finished mesh.
| Shipped content | Used for |
|---|---|
skills/mesh-editing-and-bmesh | lathed bowl and ash, swept legs, cleaved coal lumps in one bmesh |
skills/procedural-materials-and-shaders | rusted rough iron, pale ash, charcoal with emissive cracks masked by noise |
skills/bake-high-to-low | Cycles tangent-space normal bake, high onto low |
skills/engine-export-presets | Unity glTF (export_yup=True) |
skills/depsgraph-and-evaluated-data | evaluated triangle counts for the LOD ratios |
snippets/decimate_to_budget.py | LOD1 / LOD2 COLLAPSE chain |
snippets/convex_hull_collider.py | one hull for the bowl, one per leg, merged into a compound |
snippets/lod_chain.py | LOD naming and ratio pattern |
examples/mesh-hygiene-audit | hygiene combinatorics (copied, not imported) |
A brazier holds burning coals, so the thing it must not do is tip when someone knocks it. That is invisible to every envelope check: legs tucked in under the bowl still reach the floor, still ground the piece, and still fit inside the bowl's own bounding box.
The piece recomputes stability from the finished mesh:
atan(d / z), where d is the distance from the mass centre's plan position to the nearest support edge and z is its height. This is the lean the brazier survives before it goes over. Measured 18.30° against a 13° floor.--tuck-legs is the falsifier built for exactly this. It brings the feet in from 0.29 m to 0.15 m of the axis. They still ground (zmin and the three-feet gate pass), the rim still sets the envelope (the AABB is unchanged), and every other budget passes. Only the tip angle sees it: 10.23°.
Declared in the script as named constants, recomputed from the generated mesh. Measured values are from Blender 5.2.1; every one is byte-identical on 4.5.11 and 5.1.2 (only the glTF file size differs, by 4 bytes, which is exporter metadata and not a budget).
| Budget | Band | Measured | ||
|---|---|---|---|---|
| Base triangles | 6600–8200 | 6960 | ||
| LOD1 ratio | 0.32–0.62 | 0.5000 | ||
| LOD2 ratio | 0.10–0.35 | 0.2198 | ||
| Material slots | exactly 3, distinct | 3 | ||
| Iron faces | ≥ 1400 | 1680 | ||
| Coal faces | ≥ 2800 | 2880 | ||
| Ash faces | ≥ 380 | 448 | ||
| UV bounds | inside 0..1 | (0.0006, 0.0006)–(0.9994, 0.9994) | ||
| UV AABB overlap | ≤ 1e-5 | 0.000000 | ||
| Outer AABB | 0.696 × 0.696 × 0.612 m ± 0.020 | 0.6960 × 0.6960 × 0.6118 | ||
| Collider triangles | ≤ 400 | 240 (four hulls) | ||
| Normal bake | {'FINISHED'} with image data | {'FINISHED'}, has_data=True | ||
| glTF export | file written, non-empty | ~450 kB | ||
| Hygiene | all zero | loose 0/0, non-manifold 0, zero-area 0, doubles 0, n-gons 0, coplanar cross-shell pairs 0 | ||
| Grounded AABB | \ | zmin\ | ≤ 1e-4 | 0.00000 |
| Named feet | 3 legs, each zmin ≤ 1e-3 | 3 at 0.00000 | ||
| Legs bite the bowl | deepest leg vertex inside the bowl ≥ 1.5 mm | 2.58 mm | ||
| Coal seat | 36 coals, deepest vertex inside the ash 4–30 mm | 6.40–15.69 mm | ||
| Freeboard | rim above the highest coal or ash ≥ 30 mm | 53.95 mm | ||
| Tip angle | ≥ 13° | 18.30° | ||
| Coal interpenetration | 0 pairs | 0 | ||
| Coal size spread | largest footprint over smallest ≥ 1.35 | 5.64 |
Real-world size: a 0.70 m bowl, rim 0.61 m off the floor — a courtyard or hall brazier.
WALL (8 mm), so the wall keeps its thickness where the bowl runs flat.LEG_BITE inside the iron.ASH_BITE outside it. The bed is held by the bowl, not resting beside it.COAL_GAP, and stays inside the inner wall. Each is then sunk into the ash by a fraction that varies with its index.--overfill, --pile-coals and --uniform-coals all exited 4 or 5 instead of their targets. The heap is now a fixed N_COALS = 36.BMFace returned the wrong face after create_icosphere(subdivisions=2) freed and reallocated face memory. UV islands now live in a face int layer, and the provisional strip UVs are written straight into the loop layer.Every convention in showcase/README.md, and whether it applies here.
| Convention | Applies | How |
|---|---|---|
| Deterministic, budgets declared, assertions recompute | yes | no RNG; every value above is read off the mesh |
| Falsifier fails the budget it targets | yes | table below, proven on all three binaries |
| Hygiene incl. cross-shell coplanar | yes | exit 15 |
| Named supports | yes | three feet (--float-foot) |
| A joint bites | yes | each leg's deepest vertex inside the bowl by signed distance (--short-legs) |
| A bent bar is one sweep | yes | each leg is one sweep from bowl to toe |
| Iron is not chrome | yes | near-black and rust, roughness 0.55–0.85, metallic 0.65 |
| A vessel has a base and a belly | yes | the bowl's wall is offset along its normal |
| A vessel holds its contents | yes | the ash bites the inner wall; freeboard is asserted (--overfill) |
| Carried parts bite their bearers | yes | coals seated in the ash, banded (--float-coals) |
| Rock is broken, not smooth | yes | cleaved, flat-shaded lumps |
| Scattered parts do not interpenetrate | yes | exit 20 (--pile-coals) |
| Scatter varies in size | yes | exit 21 (--uniform-coals) |
| Shading is part of the model | yes | bowl and ash smooth (spun, powdery); legs flat (forged bar); coals flat (broken) |
| One substance, one slot | yes | iron, coal, ash |
| Edge treatment: no right angles | yes, by construction | chamfered-square legs (45° steps), lathed bowl; not a separate budget |
| Sort bmesh operator inputs | n/a | no set-fed operator is run |
| Level on the stage; stage 60 m | yes | turned about Z only; 60 m floor and wall |
| Keep a falsifier's envelope still | yes | every falsifier leaves the AABB unchanged |
| Timber, masonry, rope, fixtures, roofs, rings, paint | no | the piece has none of these |
Each breaks one pipeline stage so a named budget fails. All ten were run on 4.5.11, 5.1.2 and 5.2.1 and exited the same declared code on all three.
| Flag | Target budget | Breaks | Exit |
|---|---|---|---|
--skip-decimate | LOD1 ratio | drops the DECIMATE modifiers, LOD1 ratio goes to 1.0000 | 9 |
--stray-vert | mesh hygiene | adds one loose vertex under the bowl | 15 |
--lift-z | grounded zmin | lifts the whole mesh 50 mm | 16 |
--float-foot | named feet | lifts one foot 12 mm; the other two still ground the AABB | 16 |
--short-legs | leg-to-bowl bite | starts every leg 12 mm off the wall; −10.0 mm | 17 |
--float-coals | coal seat | lifts every coal 30 mm off the ash; −13.9 to −23.3 mm | 18 |
--overfill | freeboard | raises the ash and heap 45 mm, still below the rim; 8.3 mm | 18 |
--tuck-legs | tip angle | brings the feet in to 0.15 m of the axis; 10.23° | 19 |
--pile-coals | coal interpenetration | packs coals at 0.6 of their spacing; 40 pairs | 20 |
--uniform-coals | coal size spread | one size for every coal; 1.198 | 21 |
File-local and sequential. 9 is a valid check code. 1 is the FATAL wrapper — a crash, never a named check.
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Uncaught exception (FATAL wrapper) |
| 2 | argparse / usage |
| 3 | Mesh did not build, or has no UV layer |
| 4 | Base triangle count outside band |
| 5 | Material slots, or a material's face floor |
| 6 | UVs outside 0..1 |
| 7 | UV AABB overlap above tolerance |
| 8 | Outer AABB off declared size |
| 9 | LOD1 or LOD2 ratio outside band (--skip-decimate) |
| 10 | Framing gate (examples/gallery_framing.py, render path only) |
| 11 | Collider triangles above ceiling |
| 12 | Normal bake failed or produced no image data |
| 13 | glTF export missing or empty |
| 14 | --output produced no file |
| 15 | Mesh hygiene (--stray-vert) |
| 16 | Grounded zmin, or a foot floating (--lift-z, --float-foot) |
| 17 | A leg not biting the bowl (--short-legs) |
| 18 | Coal seat or freeboard (--float-coals, --overfill) |
| 19 | Tip angle below floor (--tuck-legs) |
| 20 | Coals interpenetrate (--pile-coals) |
| 21 | Coal size spread below floor (--uniform-coals) |
# Budget check, no render. ~2.2 s on 4.5, ~2.0 s on 5.1, ~2.4 s on 5.2.
blender --background --python brazier.py --
# Falsifier: the legs tuck in under the bowl. Must exit 19.
blender --background --python brazier.py -- --tuck-legs
# Falsifier: the bowl is filled to the brim. Must exit 18.
blender --background --python brazier.py -- --overfill
# Render the gallery still (EEVEE; --engine cycles on a GPU-less host).
blender --background --python brazier.py -- --output brazier.webp
Smoke runs the check-only path. It does not pass --output or any falsifier.
The render adds two render-only lights of its own: a point light above the coals for the fire, and a spot for the warm floor pool. The camera is low and close, so the house area wedge landed on the back wall, and an area lamp brought near lit the whole floor.
| Value | 4.5.11 | 5.1.2 | 5.2.1 |
|---|---|---|---|
| Base triangles | 6960 | 6960 | 6960 |
| LOD1 / LOD2 tris | 3480 / 1530 | same | same |
| Face counts (iron / coal / ash) | 1680 / 2880 / 448 | same | same |
| Outer AABB | 0.6960 × 0.6960 × 0.6118 | same | same |
| Collider tris | 240 | 240 | 240 |
| Mass / tip angle | 51.87 kg / 18.30° | same | same |
| glTF bytes | 450940 | 450940 | 450936 |
| Check wall-clock | ~2.2 s | ~2.0 s | ~2.4 s |
"""Game-ready brazier — a showcase piece, not an example. Asserts budget conformance of a procedural iron brazier: a spun bowl with a rolled rim on three forged legs, a bed of ash in the bowl and a heap of broken, glowing coals on it. Carried through UVs, three materials (iron, coal, ash), a high-to-low normal bake, an LOD chain, a compound convex collider, and a Unity glTF export. The budget that matters here is the one a brazier can fail invisibly: it holds burning coals, so it must not tip when it is knocked. The piece recomputes the centre of mass from the closed shells, weighted by the density of each material, and the support polygon from the feet that touch the floor, and asserts the angle it can lean before it tips. Legs tucked in under the bowl still ground the piece and still fit its bounding box; only the tip angle knows the difference. Budgets are declared below and recomputed from the generated result. They are not API-contract witnesses. Each falsifier violates one named budget: ``--skip-decimate`` the LOD-ratio band, ``--stray-vert`` mesh hygiene, ``--lift-z`` grounded zmin, ``--float-foot`` the named feet, ``--short-legs`` the leg-to-bowl bite, ``--float-coals`` the coal seat, ``--overfill`` the freeboard, ``--tuck-legs`` the tip angle, ``--pile-coals`` coal interpenetration, ``--uniform-coals`` coal size variation. No randomness: every coal's size, shape and cleave is a closed-form term of its index. DECIMATE COLLAPSE triangle counts are not byte-identical across Blender versions — the LOD gate is a ratio band. blender --background --python brazier.py -- blender --background --python brazier.py -- --tuck-legs blender --background --python brazier.py -- --output brazier.png """ import argparse import math import os import sys import tempfile import traceback import bmesh import bpy from mathutils import Matrix, Vector from mathutils.bvhtree import BVHTree from mathutils.kdtree import KDTree _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 # The bowl: a half-ellipsoid spun from sheet iron, rim at RIM_Z, with a # rolled bead at the lip. The inner wall is the outer wall offset along # its own normal by WALL. RIM_R = 0.340 RIM_Z = 0.600 DEPTH = 0.200 WALL = 0.008 BEAD_R = 0.010 BOWL_SEG = 32 BOWL_STEPS = 14 # Three forged legs: a chamfered-square bar swept in one piece from inside # the bowl wall, out and down to a flat foot, ending in a small curl. N_LEGS = 3 LEG_A = 0.011 LEG_CHAMFER = 0.30 ATTACH_PHI = math.radians(40.0) LEG_BITE = 0.003 LEG_CONE = 0.25 FOOT_R = 0.290 TUCK_FOOT_R = 0.150 SHORT_LEG_GAP = 0.012 FLOAT_FOOT = 0.012 # Ash: a shallow dome whose rim bites into the bowl's inner wall. ASH_Z = 0.490 ASH_DOME = 0.035 ASH_BITE = 0.003 ASH_SEG = 32 OVERFILL = 0.045 # Coals: broken lumps (a cleaved icosphere, flat-shaded) sunk into the # ash. Sizes vary by a closed-form draw on the index. COAL_BASE = 0.026 COAL_VAR = 0.40 COAL_SINK = 0.30 COAL_GAP = 0.002 COAL_TRIES = 90 # A heap is a fixed number of coals: packing as many as fit made the # count, and so the triangle budget, move with every falsifier that # changes the space or the spacing. N_COALS = 36 PILE_SCALE = 0.60 FLOAT_COALS = 0.030 DENSITY = {0: 7850.0, 1: 500.0, 2: 600.0} BBOX_TOL = 0.020 OUTER_SIZE = (0.696, 0.696, 0.612) BASE_TRIS_MIN = 6600 BASE_TRIS_MAX = 8200 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 IRON_FACES_MIN = 1400 COAL_FACES_MIN = 2800 ASH_FACES_MIN = 380 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 COLLIDER_TRIS_MAX = 400 BAKE_RES = 512 CAGE_EXTRUSION = 0.01 ZMIN_EPS = 1e-4 DOUBLES_EPS = 1e-5 AREA_EPS = 1e-10 COPLANAR_NORMAL_EPS = 1e-4 COPLANAR_PLANE_EPS = 1e-4 COPLANAR_CENTRE_MAX = 0.05 LIFT_Z = 0.05 FOOT_Z_MAX = 1e-3 LEG_BITE_MIN = 0.0015 COAL_SEAT_MIN = 0.004 COAL_SEAT_MAX = 0.030 FREEBOARD_MIN = 0.030 TIP_MIN = math.radians(13.0) COAL_SPREAD_MIN = 1.35 IRON_IDX = 0 COAL_IDX = 1 ASH_IDX = 2 def eevee_engine_id(): """EEVEE id: 'BLENDER_EEVEE' on 5.0+, 'BLENDER_EEVEE_NEXT' on 4.2-4.5.""" return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" def fail(msg, code): print(f"FAIL[{code}]: {msg}", file=sys.stderr) return code def triangle_count(mesh): mesh.calc_loop_triangles() return len(mesh.loop_triangles) def evaluated_triangle_count(obj): deps = bpy.context.evaluated_depsgraph_get() ev = obj.evaluated_get(deps) mesh = ev.to_mesh() try: mesh.calc_loop_triangles() return len(mesh.loop_triangles) finally: ev.to_mesh_clear() # --- profiles --------------------------------------------------------------- def outer_point(phi): """Outer bowl wall at polar angle phi (0 at the bottom pole, pi/2 at the rim).""" return Vector((RIM_R * math.sin(phi), RIM_Z - DEPTH * math.cos(phi))) def outer_normal(phi): p = outer_point(phi) n = Vector((p.x / RIM_R ** 2, (p.y - RIM_Z) / DEPTH ** 2)) return n.normalized() def bowl_profile(): """(r, z) loop from the outer pole, over the rolled bead, to the inner pole. The inner wall is the outer one offset along its own normal, so the wall is WALL thick everywhere; a sideways offset thins it to nothing where the bowl runs flat. """ phi_top = math.acos((BEAD_R + 0.002) / DEPTH) phis = [phi_top * k / BOWL_STEPS for k in range(BOWL_STEPS + 1)] outer = [outer_point(p) for p in phis] inner = [outer_point(p) - outer_normal(p) * WALL for p in phis] centre = Vector((RIM_R - 0.002, RIM_Z + 0.002)) bead = [] lo = math.atan2(outer[-1].y - centre.y, outer[-1].x - centre.x) hi = math.atan2(inner[-1].y - centre.y, inner[-1].x - centre.x) + 2.0 * math.pi for k in range(1, 8): a = lo + (hi - lo) * k / 8 bead.append(centre + Vector((math.cos(a), math.sin(a))) * BEAD_R) loop = [Vector((0.0, outer[0].y))] + outer[1:] + bead + list(reversed(inner[1:])) loop.append(Vector((0.0, inner[0].y))) return loop, inner def inner_radius(inner, z): """Inner wall radius at height z, linear between the offset samples.""" pts = sorted(inner, key=lambda p: p.y) if z <= pts[0].y: return 0.0 for a, b in zip(pts, pts[1:]): if a.y <= z <= b.y: f = (z - a.y) / (b.y - a.y) if b.y > a.y else 0.0 return a.x + (b.x - a.x) * f return pts[-1].x # --- construction ----------------------------------------------------------- def stamp(ctx, face, island, uvmap): """Tag ``face`` with its UV island and write its provisional strip UVs. Kept in bmesh layers, not a dict keyed by BMFace: an operator that frees and reallocates faces (create_icosphere with subdivisions=2) hands a later face a dead face's identity, and the dict then answers for the wrong face. """ face[ctx["isl"]] = island for loop in face.loops: loop[ctx["uv"]].uv = uvmap[loop.vert] def new_island(ctx): ctx["next"] += 1 return ctx["next"] def lathe(bm, profile, n, mat_idx, ctx, smooth=True): """Revolve an (r, z) profile about Z; r == 0 at the ends makes a pole. One strip island (profile length by angle); pole fans put their pole half a column over so no two faces overlap in UV. """ island = new_island(ctx) s = [0.0] for a, b in zip(profile, profile[1:]): s.append(s[-1] + (b - a).length) rings = [] for p in profile: if p.x <= 0.0: rings.append(bm.verts.new((0.0, 0.0, p.y))) continue rings.append([bm.verts.new((p.x * math.cos(2 * math.pi * k / n), p.x * math.sin(2 * math.pi * k / n), p.y)) for k in range(n)]) faces = [] for k in range(len(rings) - 1): a, b = rings[k], rings[k + 1] for i in range(n): j = (i + 1) % n if isinstance(a, list) and isinstance(b, list): vs = (a[i], a[j], b[j], b[i]) uv = {a[i]: (s[k], i / n), a[j]: (s[k], (i + 1) / n), b[j]: (s[k + 1], (i + 1) / n), b[i]: (s[k + 1], i / n)} elif isinstance(b, list): vs = (a, b[j], b[i]) uv = {a: (s[k], (i + 0.5) / n), b[j]: (s[k + 1], (i + 1) / n), b[i]: (s[k + 1], i / n)} else: vs = (a[i], a[j], b) uv = {a[i]: (s[k], i / n), a[j]: (s[k], (i + 1) / n), b: (s[k + 1], (i + 0.5) / n)} f = bm.faces.new(vs) f.material_index = mat_idx f.smooth = smooth stamp(ctx, f, island, uv) faces.append(f) return faces def leg_section(): """A chamfered square bar, flat side down: (side, up) offsets.""" a, c = LEG_A, LEG_A * LEG_CHAMFER pts = [(a, -(a - c)), (a, a - c), (a - c, a), (-(a - c), a), (-a, a - c), (-a, -(a - c)), (-(a - c), -a), (a - c, -a)] return pts def bezier(p0, p1, p2, p3, n): out = [] for k in range(n + 1): t = k / n u = 1.0 - t out.append(p0 * u ** 3 + p1 * 3 * u * u * t + p2 * 3 * u * t * t + p3 * t ** 3) return out def leg_path(foot_r, short=False, float_foot=False): """(rho, z) centreline, from inside the bowl wall to the curled foot. One sweep: the bar leaves the bowl along the wall's normal, bends down and out, runs level on the floor with a flat face down, and curls up. """ base = outer_point(ATTACH_PHI) n = outer_normal(ATTACH_PHI) start = base + n * (SHORT_LEG_GAP if short else -LEG_BITE) p1 = base + n * 0.030 lift = FLOAT_FOOT if float_foot else 0.0 foot0 = Vector((foot_r - 0.015, LEG_A + lift)) foot1 = Vector((foot_r + 0.015, LEG_A + lift)) pts = [start] # The handle into the foot is level, so the bar arrives flat on the floor. pts += bezier(p1, p1 + n * 0.10, foot0 - Vector((0.10, 0.0)), foot0, 12) pts += [foot0 + (foot1 - foot0) * (k / 3) for k in range(1, 4)] curl = bezier(foot1, foot1 + Vector((0.012, 0.0)), foot1 + Vector((0.024, 0.006)), foot1 + Vector((0.026, 0.020)), 4) pts += curl[1:] return pts def sweep_bar(bm, pts, section, side, mat_idx, ctx, cone=LEG_CONE): """Sweep ``section`` along ``pts`` (3D), frame fixed to the path's plane. Ends close in a blunt cone so there is no n-gon cap. Returns vertices. """ island = new_island(ctx) m = len(pts) tans = [] for i in range(m): d = pts[min(i + 1, m - 1)] - pts[max(i - 1, 0)] tans.append(d.normalized()) n = len(section) rings = [] for p, t in zip(pts, tans): s = side - t * side.dot(t) s.normalize() up = t.cross(s) rings.append([bm.verts.new(p + s * x + up * y) for x, y in section]) arc = [0.0] for i in range(1, m): arc.append(arc[-1] + (pts[i] - pts[i - 1]).length) for k in range(m - 1): a, b = rings[k], rings[k + 1] for i in range(n): j = (i + 1) % n f = bm.faces.new((a[i], a[j], b[j], b[i])) f.material_index = mat_idx f.smooth = False stamp(ctx, f, island, {a[i]: (arc[k], i / n), a[j]: (arc[k], (i + 1) / n), b[j]: (arc[k + 1], (i + 1) / n), b[i]: (arc[k + 1], i / n)}) reach = cone * LEG_A for ring, p, t, sign, s_end in ((rings[0], pts[0], tans[0], -1.0, arc[0]), (rings[-1], pts[-1], tans[-1], 1.0, arc[-1])): pole = bm.verts.new(p + t * (sign * reach)) for i in range(n): j = (i + 1) % n f = bm.faces.new((pole, ring[i], ring[j])) f.material_index = mat_idx f.smooth = False stamp(ctx, f, island, {pole: (s_end + sign * reach, (i + 0.5) / n), ring[i]: (s_end, i / n), ring[j]: (s_end, (i + 1) / n)}) return [v for r in rings for v in r] def coal_size(i, uniform=False): """Closed-form size draw per coal: golden-ratio spacing, no RNG.""" if uniform: # One draw for every coal, small enough that the full heap still # fits: the coals do not set the envelope, so nothing else moves. return COAL_BASE * (1.0 - COAL_VAR * 0.5) f = (i * 0.6180339887 + 0.37) % 1.0 return COAL_BASE * (1.0 + COAL_VAR * (f - 0.5) * 2.0) def coal_axes(i, size): """Ellipsoid half-axes: a charcoal lump is longer than it is tall.""" e = (i * 0.4142135 + 0.2) % 1.0 return (size * (1.15 + 0.25 * e), size * (0.95 - 0.15 * e), size * 0.72) _ICO = {} def ico_unit(): """Unit icosphere (subdivisions 2): vertex positions and face index lists.""" if not _ICO: tmp = bmesh.new() try: bmesh.ops.create_icosphere(tmp, subdivisions=2, radius=1.0) tmp.verts.index_update() _ICO["verts"] = [v.co.copy() for v in tmp.verts] _ICO["faces"] = [[v.index for v in f.verts] for f in tmp.faces] finally: tmp.free() return _ICO["verts"], _ICO["faces"] def coal_shape(axes, i): """A broken lump, centred on the origin: a scaled icosphere cleaved by three planes. Every vertex beyond a plane is projected onto it, so the lump reads as split charcoal rather than a pebble. """ unit, _faces = ico_unit() yaw = i * 2.39996 c, s = math.cos(yaw), math.sin(yaw) pts = [] for u in unit: x, y, z = u.x * axes[0], u.y * axes[1], u.z * axes[2] pts.append(Vector((c * x - s * y, s * x + c * y, z))) # Tumbled, not set upright: each lump leans by a closed-form few # degrees. Stood level, two similar lumps once carried a pair of # near-parallel buried faces in one plane (a coplanar pair). tilt = Matrix.Rotation(0.18 * (((i * 0.6180340) % 1.0) - 0.5), 3, "X") @ Matrix.Rotation(0.18 * (((i * 0.4142136) % 1.0) - 0.5), 3, "Y") pts = [tilt @ p for p in pts] for k in range(3): a = yaw + k * 2.1 + 0.4 el = 0.35 + 0.25 * ((i + k) % 3) nrm = Vector((math.cos(a) * math.cos(el), math.sin(a) * math.cos(el), math.sin(el))) d = 0.70 * min(axes) + 0.18 * max(axes) * (((i * 7 + k * 3) % 5) / 5.0) for p in pts: h = p.dot(nrm) if h > d: p -= nrm * (h - d) return pts def add_coal(bm, centre, shape, mat_idx): """Emit a lump built by ``coal_shape`` at ``centre``. Flat-shaded.""" _unit, faces = ico_unit() vs = [bm.verts.new(p + centre) for p in shape] for idx in faces: f = bm.faces.new([vs[k] for k in idx]) f.material_index = mat_idx f.smooth = False return vs def coal_layout(inner, ash_z, r_edge, pile=False, uniform=False): """Greedy sunflower packing: each coal at the smallest radius that clears the rest. Coal ``i`` tries the golden angle times ``i`` and walks outward until its centre is at least the sum of its own and each placed coal's plan radius bound (plus COAL_GAP) from every one of them, and its bound stays inside the inner wall at the height of its base. Bounds come from the same constants that size each coal, so a bigger coal takes more room. ``pile`` shrinks the spacing so neighbours interpenetrate. """ shapes = {} def shape(i): if i not in shapes: shapes[i] = coal_shape(coal_axes(i, coal_size(i, uniform)), i) return shapes[i] def bound(i): # The lump's own plan radius after cleaving, read off its vertices. return max(math.hypot(p.x, p.y) for p in shape(i)) def ash_at(rho): return ash_z + ASH_DOME * (1.0 - min(1.0, (rho / r_edge) ** 2)) scale = PILE_SCALE if pile else 1.0 placed = [] for i in range(COAL_TRIES): if len(placed) == N_COALS: break b = bound(i) theta = i * 2.39996323 rho = 0.0 while True: x, y = rho * math.cos(theta), rho * math.sin(theta) limit = inner_radius(inner, ash_at(rho) - 0.02) - COAL_GAP if rho + b > limit: break if all(math.hypot(x - px, y - py) >= (b + pb) * scale + COAL_GAP for px, py, pb, _i in placed): placed.append((x, y, b, i)) break rho += 0.002 coals = [] for x, y, _b, i in placed: size = coal_size(i, uniform) axes = coal_axes(i, size) # Each lump settles a little differently. Sunk to one fraction, two # buried undersides once landed in one plane (a coplanar pair). sink = COAL_SINK + 0.06 * (((i * 0.3819660) % 1.0) - 0.5) z = ash_at(math.hypot(x, y)) + axes[2] * (1.0 - 2.0 * sink) coals.append((Vector((x, y, z)), shape(i), i)) return coals def pack_uvs(bm, ctx, margin=0.06): """One grid cell per UV island: strip islands by their layer tag, else a face each.""" uv, isl = ctx["uv"], ctx["isl"] bm.faces.index_update() islands, order = {}, [] for face in bm.faces: key = ("s", face[isl]) if face[isl] else ("f", face.index) if key not in islands: islands[key] = [] order.append(key) islands[key].append(face) cols = max(1, math.ceil(math.sqrt(len(order)))) rows = max(1, math.ceil(len(order) / cols)) cw, ch = 1.0 / cols, 1.0 / rows pu, pv = margin * cw * 0.5, margin * ch * 0.5 for idx, key in enumerate(order): faces = islands[key] coords = {} for face in faces: if face[isl]: coords[face.index] = [tuple(loop[uv].uv) for loop in face.loops] continue nrm = face.normal ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z) pts = [] for loop in face.loops: co = loop.vert.co if az >= ax and az >= ay: pts.append((co.x, co.y)) elif ax >= ay: pts.append((co.y, co.z)) else: pts.append((co.x, co.z)) coords[face.index] = pts allc = [c for cs in coords.values() for c in cs] minx, maxx = min(c[0] for c in allc), max(c[0] for c in allc) miny, maxy = min(c[1] for c in allc), max(c[1] for c in allc) dx, dy = max(maxx - minx, 1e-8), max(maxy - miny, 1e-8) ou, ov = (idx % cols) * cw + pu, (idx // cols) * ch + pv for face in faces: for loop, (x, y) in zip(face.loops, coords[face.index]): loop[uv].uv = (ou + (x - minx) / dx * (cw - 2 * pu), ov + (y - miny) / dy * (ch - 2 * pv)) def build_brazier_mesh( name, stray_vert=False, float_foot=False, short_legs=False, float_coals=False, overfill=False, tuck_legs=False, pile_coals=False, uniform_coals=False, ): profile, inner = bowl_profile() ash_z = ASH_Z + (OVERFILL if overfill else 0.0) r_edge = inner_radius(inner, ash_z) + ASH_BITE bm = bmesh.new() try: ctx = {"uv": bm.loops.layers.uv.new("UVMap"), "isl": bm.faces.layers.int.new("UVIsland"), "next": 0} lathe(bm, profile, BOWL_SEG, IRON_IDX, ctx) foot_r = TUCK_FOOT_R if tuck_legs else FOOT_R for k in range(N_LEGS): az = 2.0 * math.pi * k / N_LEGS + math.pi / 2.0 radial = Vector((math.cos(az), math.sin(az), 0.0)) side = Vector((-math.sin(az), math.cos(az), 0.0)) path = leg_path(foot_r, short=short_legs, float_foot=(float_foot and k == 0)) pts = [radial * p.x + Vector((0.0, 0.0, p.y)) for p in path] sweep_bar(bm, pts, leg_section(), side, IRON_IDX, ctx) # Ash: the dome, then down the inner wall (biting it) to the bottom. ash = [Vector((0.0, inner[0].y - ASH_BITE))] lows = sorted(inner, key=lambda p: p.y) for p in lows: if inner[0].y + 0.004 < p.y < ash_z - 0.004: ash.append(Vector((inner_radius(inner, p.y) + ASH_BITE, p.y))) ash.append(Vector((r_edge, ash_z))) for k in range(1, 6): rho = r_edge * (1.0 - k / 6.0) ash.append(Vector((rho, ash_z + ASH_DOME * (1.0 - (rho / r_edge) ** 2)))) ash.append(Vector((0.0, ash_z + ASH_DOME))) lathe(bm, ash, ASH_SEG, ASH_IDX, ctx) for centre, shape, _i in coal_layout(inner, ash_z, r_edge, pile=pile_coals, uniform=uniform_coals): if float_coals: centre = centre + Vector((0.0, 0.0, FLOAT_COALS)) add_coal(bm, centre, shape, COAL_IDX) if stray_vert: bm.verts.new((0.0, 0.0, 0.25)) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) pack_uvs(bm, ctx) bm.faces.layers.int.remove(ctx["isl"]) 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 # --- surface ---------------------------------------------------------------- def _sock(sockets, identifier): return next(sk for sk in sockets if sk.identifier == identifier) def iron_material(name): """Forged iron: near-black, rough, rusted in patches. Not chrome.""" mat = bpy.data.materials.new(name) mat.use_nodes = True nt = mat.node_tree bsdf = nt.nodes["Principled BSDF"] tc = nt.nodes.new("ShaderNodeTexCoord") noise = nt.nodes.new("ShaderNodeTexNoise") noise.inputs["Scale"].default_value = 14.0 noise.inputs["Detail"].default_value = 8.0 nt.links.new(tc.outputs["Object"], noise.inputs["Vector"]) ramp = nt.nodes.new("ShaderNodeValToRGB") ramp.color_ramp.elements[0].position = 0.45 ramp.color_ramp.elements[0].color = (0.035, 0.033, 0.031, 1.0) ramp.color_ramp.elements[1].position = 0.78 ramp.color_ramp.elements[1].color = (0.20, 0.085, 0.035, 1.0) nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"]) bsdf.inputs["Metallic"].default_value = 0.65 rough = nt.nodes.new("ShaderNodeMapRange") rough.inputs["To Min"].default_value = 0.55 rough.inputs["To Max"].default_value = 0.85 nt.links.new(noise.outputs["Fac"], rough.inputs["Value"]) nt.links.new(rough.outputs["Result"], bsdf.inputs["Roughness"]) return mat def coal_material(name): """Charcoal: black and matte, glowing orange in the cracks.""" mat = bpy.data.materials.new(name) mat.use_nodes = True nt = mat.node_tree bsdf = nt.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = (0.022, 0.020, 0.019, 1.0) bsdf.inputs["Roughness"].default_value = 0.9 tc = nt.nodes.new("ShaderNodeTexCoord") vor = nt.nodes.new("ShaderNodeTexVoronoi") vor.feature = "DISTANCE_TO_EDGE" vor.inputs["Scale"].default_value = 60.0 nt.links.new(tc.outputs["Object"], vor.inputs["Vector"]) crack = nt.nodes.new("ShaderNodeMapRange") crack.inputs["From Min"].default_value = 0.0 crack.inputs["From Max"].default_value = 0.04 crack.inputs["To Min"].default_value = 1.0 crack.inputs["To Max"].default_value = 0.0 nt.links.new(vor.outputs["Distance"], crack.inputs["Value"]) # Hotter lower down: the heap burns from the ash up. sep = nt.nodes.new("ShaderNodeSeparateXYZ") nt.links.new(tc.outputs["Object"], sep.inputs["Vector"]) heat = nt.nodes.new("ShaderNodeMapRange") heat.inputs["From Min"].default_value = 0.60 heat.inputs["From Max"].default_value = 0.47 heat.inputs["To Min"].default_value = 0.35 heat.inputs["To Max"].default_value = 1.0 nt.links.new(sep.outputs["Z"], heat.inputs["Value"]) # Not every crack burns: a noise mask breaks the network up, so a lump # glows in patches instead of wearing an even honeycomb. mask_n = nt.nodes.new("ShaderNodeTexNoise") mask_n.inputs["Scale"].default_value = 22.0 mask_n.inputs["Detail"].default_value = 3.0 nt.links.new(tc.outputs["Object"], mask_n.inputs["Vector"]) mask = nt.nodes.new("ShaderNodeMapRange") mask.inputs["From Min"].default_value = 0.42 mask.inputs["From Max"].default_value = 0.62 nt.links.new(mask_n.outputs["Fac"], mask.inputs["Value"]) burn = nt.nodes.new("ShaderNodeMath") burn.operation = "MULTIPLY" nt.links.new(crack.outputs["Result"], burn.inputs[0]) nt.links.new(mask.outputs["Result"], burn.inputs[1]) glow = nt.nodes.new("ShaderNodeMath") glow.operation = "MULTIPLY" nt.links.new(burn.outputs["Value"], glow.inputs[0]) nt.links.new(heat.outputs["Result"], glow.inputs[1]) strength = nt.nodes.new("ShaderNodeMath") strength.operation = "MULTIPLY" strength.inputs[1].default_value = 7.0 nt.links.new(glow.outputs["Value"], strength.inputs[0]) bsdf.inputs["Emission Color"].default_value = (1.0, 0.32, 0.06, 1.0) nt.links.new(strength.outputs["Value"], bsdf.inputs["Emission Strength"]) return mat def ash_material(name): """Ash: pale, matte, with darker cinders mottled through it.""" mat = bpy.data.materials.new(name) mat.use_nodes = True nt = mat.node_tree bsdf = nt.nodes["Principled BSDF"] tc = nt.nodes.new("ShaderNodeTexCoord") noise = nt.nodes.new("ShaderNodeTexNoise") noise.inputs["Scale"].default_value = 40.0 noise.inputs["Detail"].default_value = 6.0 nt.links.new(tc.outputs["Object"], noise.inputs["Vector"]) ramp = nt.nodes.new("ShaderNodeValToRGB") ramp.color_ramp.elements[0].position = 0.35 ramp.color_ramp.elements[0].color = (0.06, 0.056, 0.053, 1.0) ramp.color_ramp.elements[1].position = 0.70 ramp.color_ramp.elements[1].color = (0.26, 0.245, 0.225, 1.0) nt.links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"]) bsdf.inputs["Roughness"].default_value = 0.97 return mat def brazier_materials(): return iron_material("BrazierIron"), coal_material("BrazierCoal"), ash_material("BrazierAsh") def assign_slots(obj, mats): slots = obj.data.materials for i, mat in enumerate(mats): if i < len(slots): slots[i] = mat else: slots.append(mat) # --- measurement ------------------------------------------------------------ def world_bbox(obj): corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box] xs, ys, zs = [c.x for c in corners], [c.y for c in corners], [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.0, 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))) span = max(1e-6, max(a[2] - a[0] for a in aabbs), max(a[3] - a[1] for a in aabbs)) buckets = {} for i, a in enumerate(aabbs): for c in range(int(a[0] // span), int(a[2] // span) + 1): for r in range(int(a[1] // span), int(a[3] // span) + 1): buckets.setdefault((c, r), []).append(i) overlap = 0.0 seen = set() for members in buckets.values(): for ii in range(len(members)): for jj in range(ii + 1, len(members)): i, j = members[ii], members[jj] key = (i, j) if i < j else (j, i) if key in seen: continue seen.add(key) a, b = aabbs[i], aabbs[j] overlap += max(0.0, min(a[2], b[2]) - max(a[0], b[0])) * max( 0.0, min(a[3], b[3]) - max(a[1], b[1])) return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) def face_area(me, poly): idxs = poly.vertices v0 = me.vertices[idxs[0]].co area = 0.0 for i in range(1, len(idxs) - 1): area += (me.vertices[idxs[i]].co - v0).cross(me.vertices[idxs[i + 1]].co - v0).length * 0.5 return area def hygiene_audit(me): ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) bm = bmesh.new() try: bm.from_mesh(me) loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) nonman = sum(1 for e in bm.edges if not e.is_manifold) ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) doubles = len(ret.get("targetmap") or {}) finally: bm.free() return {"ngons": ngons, "loose_v": loose_v, "loose_e": loose_e, "nonman": nonman, "zero_area": zero_area, "doubles": doubles} def shells(me): neighbors = [[] for _ in range(len(me.vertices))] for edge in me.edges: a, b = edge.vertices neighbors[a].append(b) neighbors[b].append(a) seen = [False] * len(me.vertices) groups = [] for start in range(len(me.vertices)): if seen[start]: continue seen[start] = True stack = [start] group = [] while stack: cur = stack.pop() group.append(cur) for nxt in neighbors[cur]: if not seen[nxt]: seen[nxt] = True stack.append(nxt) groups.append(group) return groups def zfight_pairs(me): """Coplanar face pairs from *different shells* (copied from showcase/grindstone).""" owner = {} for si, g in enumerate(shells(me)): for vi in g: owner[vi] = si faces = [(p.normal.copy(), p.center.copy(), owner.get(p.vertices[0], -1)) for p in me.polygons] kd = KDTree(len(faces)) for i, (_n, c, _s) in enumerate(faces): kd.insert(c, i) kd.balance() hits = 0 for i, (ni, ci, si) in enumerate(faces): for _co, j, _d in kd.find_range(ci, COPLANAR_CENTRE_MAX): if j <= i: continue nj, cj, sj = faces[j] if si == sj: continue if abs(abs(ni.dot(nj)) - 1.0) > COPLANAR_NORMAL_EPS: continue if abs(ni.dot(cj - ci)) > COPLANAR_PLANE_EPS: continue hits += 1 return hits def shell_tree(me, group): member = set(group) remap = {v: k for k, v in enumerate(group)} polys = [[remap[i] for i in p.vertices] for p in me.polygons if p.vertices[0] in member] return BVHTree.FromPolygons([me.vertices[i].co.copy() for i in group], polys) def inside_depth(tree, pts): """Deepest point of ``pts`` inside ``tree``'s surface; negative is a gap.""" best = -99.0 for p in pts: loc, nrm, _i, dist = tree.find_nearest(p) if loc is None: continue d = dist if (p - loc).dot(nrm) < 0.0 else -dist best = max(best, d) return best def classify(me): mats = {} for p in me.polygons: for i in p.vertices: mats.setdefault(i, p.material_index) out = {"bowl": [], "leg": [], "ash": [], "coal": [], "other": []} for g in shells(me): pts = [me.vertices[i].co.copy() for i in g] rec = {"g": g, "pts": pts, "c": sum(pts, Vector()) / len(pts)} m = mats.get(g[0], -1) if m == IRON_IDX: out["bowl" if len(g) > 400 else "leg"].append(rec) elif m == ASH_IDX: out["ash"].append(rec) elif m == COAL_IDX: out["coal"].append(rec) else: out["other"].append(rec) return out def mass_centre(me): """Centre of mass of the closed shells, each triangle weighted by density. Signed tetrahedra from the origin: every shell is closed and wound outward, so interior volumes cancel and each material contributes its volume times its density. """ me.calc_loop_triangles() m_tot = 0.0 acc = Vector((0.0, 0.0, 0.0)) for tri in me.loop_triangles: a, b, c = (me.vertices[i].co for i in tri.vertices) v = a.dot(b.cross(c)) / 6.0 rho = DENSITY.get(me.polygons[tri.polygon_index].material_index, 0.0) m_tot += v * rho acc += (a + b + c) * (v * rho / 4.0) return acc / m_tot if m_tot else Vector(), m_tot def convex_hull_2d(pts): pts = sorted(set((round(p[0], 9), round(p[1], 9)) for p in pts)) if len(pts) < 3: return pts def cross(o, a, b): return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) lower, upper = [], [] for p in pts: while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0: lower.pop() lower.append(p) for p in reversed(pts): while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0: upper.pop() upper.append(p) return lower[:-1] + upper[:-1] def brazier_audit(me): parts = classify(me) bowl = parts["bowl"][0] if parts["bowl"] else None ash = parts["ash"][0] if parts["ash"] else None out = {k: len(v) for k, v in parts.items()} out["foot_worst"] = max((min(p.z for p in r["pts"]) for r in parts["leg"]), default=99.0) # Legs bite the bowl wall: deepest leg vertex inside the bowl shell. bowl_tree = shell_tree(me, bowl["g"]) if bowl else None bites = [inside_depth(bowl_tree, r["pts"]) for r in parts["leg"]] if bowl_tree else [] out["leg_bite"] = min(bites, default=-99.0) # Coals sit in the ash: deepest coal vertex inside the ash shell. ash_tree = shell_tree(me, ash["g"]) if ash else None seats = [inside_depth(ash_tree, r["pts"]) for r in parts["coal"]] if ash_tree else [] out["seat_min"] = min(seats, default=-99.0) out["seat_max"] = max(seats, default=99.0) # Freeboard: the rim above the highest coal or ash. rim = max(p.z for p in bowl["pts"]) if bowl else 0.0 top = max((p.z for r in parts["coal"] + parts["ash"] for p in r["pts"]), default=99.0) out["freeboard"] = rim - top # Coals do not interpenetrate one another. trees = [shell_tree(me, r["g"]) for r in parts["coal"]] hits = 0 for i in range(len(trees)): for j in range(i + 1, len(trees)): if trees[i].overlap(trees[j]): hits += 1 out["coal_hits"] = hits # Coal sizes vary: largest footprint over smallest. foot = [] for r in parts["coal"]: xs = [p.x for p in r["pts"]] ys = [p.y for p in r["pts"]] foot.append((max(xs) - min(xs)) * (max(ys) - min(ys))) out["coal_spread"] = (max(foot) / min(foot)) if foot else 0.0 # Tip angle: distance from the mass centre to the nearest edge of the # support polygon, over its height. com, mass = mass_centre(me) contacts = [(p.x, p.y) for r in parts["leg"] for p in r["pts"] if p.z <= 1e-6] hull = convex_hull_2d(contacts) d_edge = -99.0 if len(hull) >= 3: d_edge = 99.0 for a, b in zip(hull, hull[1:] + hull[:1]): ex, ey = b[0] - a[0], b[1] - a[1] ln = math.hypot(ex, ey) d = (ex * (com.y - a[1]) - ey * (com.x - a[0])) / ln d_edge = min(d_edge, d) out["com"] = com out["mass"] = mass out["d_edge"] = d_edge out["tip"] = math.atan2(d_edge, com.z) if com.z > 0 else -1.0 return out 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 hull_collider(obj, name): """Compound collider: one convex hull for the bowl and its contents, one per leg. A single hull over the whole brazier fills the space between the legs, which a character should be able to kick a foot through. """ me = obj.data parts = classify(me) # Sampled in build order: the bowl is rings of BOWL_SEG, a leg rings # of eight. The ash and coals sit below the rim, inside the bowl's hull. bowl = sorted(parts["bowl"][0]["g"]) groups = [[me.vertices[v].co.copy() for k, v in enumerate(bowl) if (k // BOWL_SEG) % 2 == 0 and (k % BOWL_SEG) % 4 == 0] + [me.vertices[bowl[-1]].co.copy()]] for r in parts["leg"]: leg = sorted(r["g"]) groups.append([me.vertices[v].co.copy() for k, v in enumerate(leg) if (k // 8) % 4 == 0 and k % 2 == 0] + [me.vertices[leg[-1]].co.copy()]) mesh = bpy.data.meshes.new(name) bm = bmesh.new() try: for pts in groups: tmp = bmesh.new() try: vs = [tmp.verts.new(p) for p in pts] bmesh.ops.convex_hull(tmp, input=vs) remap = {} for f in tmp.faces: for v in f.verts: if v not in remap: remap[v] = bm.verts.new(v.co) bm.faces.new([remap[v] for v in f.verts]) finally: tmp.free() bm.to_mesh(mesh) mesh.update() finally: bm.free() col = bpy.data.objects.new(name, mesh) bpy.context.collection.objects.link(col) return col def setup_bake_image(obj, target_mat, size): img = bpy.data.images.new("BrazierNrm", 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 = IRON_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 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): 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, lift_z=False, **flags): bpy.ops.wm.read_factory_settings(use_empty=True) nothing = (None,) * 5 low = build_brazier_mesh("BrazierLow", **flags) hi_flags = {k: v for k, v in flags.items() if k != "stray_vert"} high = build_brazier_mesh("BrazierHigh", **hi_flags) mats = brazier_materials() assign_slots(low, mats) assign_slots(high, mats) if lift_z: for v in low.data.vertices: v.co.z += LIFT_Z low.data.update() bpy.context.view_layer.update() if len(low.data.polygons) < 6 or not low.data.uv_layers: return (fail("brazier mesh did not build, or has no UV layer", 3),) + nothing base_tris = triangle_count(low.data) slots = [s for s in low.data.materials if s is not None] nmat, distinct = len(slots), len({id(s) for s in slots}) idx_counts = {} for poly in low.data.polygons: idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1 u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data) bb = world_bbox(low) size_x, size_y, size_z = bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2] img, tex = setup_bake_image(low, mats[IRON_IDX], BAKE_RES) bake_result = bake_normal(high, low) lod1 = make_lod(low, "BrazierLOD1", LOD1_TARGET, skip_decimate) lod2 = make_lod(low, "BrazierLOD2", 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 = hull_collider(high, "BrazierCollider") col_tris = triangle_count(collider.data) export_path = os.path.join(tempfile.gettempdir(), f"bdt_brazier_{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 # Blender points TMPDIR at its own temp preference, which on a portable # build is the working directory, so the export must not outlive this. if os.path.isfile(export_path): os.remove(export_path) hyg = hygiene_audit(low.data) zf = zfight_pairs(low.data) ba = brazier_audit(low.data) print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print(f"measured mat_index_counts={dict(sorted(idx_counts.items()))}") 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]:.5f}") print(f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}") print(f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}") print(f"measured parts bowl={ba['bowl']} legs={ba['leg']} ash={ba['ash']} " f"coals={ba['coal']} other={ba['other']} foot_worst={ba['foot_worst']:.5f} " f"leg_bite={ba['leg_bite']:.5f} seat=({ba['seat_min']:.5f},{ba['seat_max']:.5f}) " f"freeboard={ba['freeboard']:.5f} coal_hits={ba['coal_hits']} " f"coal_spread={ba['coal_spread']:.3f}") print(f"measured stability mass={ba['mass']:.2f}kg com=({ba['com'].x:.5f}," f"{ba['com'].y:.5f},{ba['com'].z:.5f}) d_edge={ba['d_edge']:.5f} " f"tip={math.degrees(ba['tip']):.2f}deg") 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),) + nothing if nmat != MATERIAL_COUNT or distinct != MATERIAL_COUNT: return (fail(f"material slots {nmat} distinct {distinct} != {MATERIAL_COUNT}", 5),) + nothing for idx, floor, label in ((IRON_IDX, IRON_FACES_MIN, "iron"), (COAL_IDX, COAL_FACES_MIN, "coal"), (ASH_IDX, ASH_FACES_MIN, "ash")): if idx_counts.get(idx, 0) < floor: return (fail(f"{label} faces {idx_counts.get(idx, 0)} < {floor}", 5),) + nothing 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),) + nothing if overlap > UV_OVERLAP_MAX: return (fail(f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7),) + nothing 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}) off outer {OUTER_SIZE}", 8),) + nothing 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),) + nothing 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),) + nothing if col_tris > COLLIDER_TRIS_MAX: return (fail(f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11),) + nothing if bake_result != {"FINISHED"} or not img.has_data: return (fail(f"bake failed result={bake_result} has_data={img.has_data}", 12),) + nothing if export_size <= 0: return (fail("export file missing or empty", 13),) + nothing if (hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"] or hyg["doubles"] or hyg["ngons"] or zf): return (fail(f"hygiene {hyg} zfight={zf} (--stray-vert is the designed fail)", 15),) + nothing if abs(bb[2]) > ZMIN_EPS: return (fail(f"zmin {bb[2]:.6f} not within {ZMIN_EPS} of 0 " "(--lift-z is the designed fail)", 16),) + nothing if ba["leg"] != N_LEGS or ba["foot_worst"] > FOOT_Z_MAX: return (fail(f"feet: {ba['leg']} of {N_LEGS} legs, worst foot z={ba['foot_worst']:.5f} " f"> {FOOT_Z_MAX} (--float-foot is the designed fail)", 16),) + nothing if ba["leg_bite"] < LEG_BITE_MIN: return (fail(f"legs bite the bowl {ba['leg_bite']:.5f} < {LEG_BITE_MIN} " "(--short-legs is the designed fail)", 17),) + nothing if ba["seat_min"] < COAL_SEAT_MIN or ba["seat_max"] > COAL_SEAT_MAX: return (fail(f"coal seat ({ba['seat_min']:.5f}, {ba['seat_max']:.5f}) outside " f"[{COAL_SEAT_MIN}, {COAL_SEAT_MAX}] (--float-coals is the designed fail)", 18),) + nothing if ba["freeboard"] < FREEBOARD_MIN: return (fail(f"freeboard {ba['freeboard']:.5f} < {FREEBOARD_MIN} " "(--overfill is the designed fail)", 18),) + nothing if ba["tip"] < TIP_MIN: return (fail(f"tip angle {math.degrees(ba['tip']):.2f} deg < " f"{math.degrees(TIP_MIN):.1f} (--tuck-legs is the designed fail)", 19),) + nothing if ba["coal_hits"]: return (fail(f"{ba['coal_hits']} coal pairs interpenetrate, need 0 " "(--pile-coals is the designed fail)", 20),) + nothing if ba["coal_spread"] < COAL_SPREAD_MIN: return (fail(f"coal size spread {ba['coal_spread']:.3f} < {COAL_SPREAD_MIN} " "(--uniform-coals is the designed fail)", 21),) + nothing return 0, low, high, mats, tex, collider def wire_normal(mat, tex): nt = mat.node_tree nrm = nt.nodes.new("ShaderNodeNormalMap") nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) nt.links.new(nrm.outputs["Normal"], nt.nodes["Principled BSDF"].inputs["Normal"]) def render_still(low, mats, tex, path, engine): scene = bpy.context.scene wire_normal(mats[IRON_IDX], tex) for ob in list(scene.objects): if ob.type == "MESH" and ob != low: ob.hide_render = True ob.hide_viewport = True # Level on the floor: turned about Z only. low.rotation_euler.z = math.radians(8.0) floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() try: bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=60.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, kind, loc, energy, size, col, rot=(0, 0, 0)): ld = bpy.data.lights.new(name, kind) ld.energy = energy if kind == "AREA": ld.size = size else: ld.shadow_soft_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", "AREA", (-2.6, -3.4, 3.8), 300.0, 3.5, (1.0, 0.95, 0.88), (46, 0, -38)) light("Fill", "AREA", (3.4, -2.6, 1.8), 40.0, 6.0, (0.74, 0.84, 1.0), (66, 0, 52)) light("Rim", "AREA", (-1.6, 2.8, 2.4), 200.0, 3.0, (0.62, 0.78, 1.0), (-60, 0, 200)) # The warm floor pool behind the piece: a spot aimed at a floor point. # This camera is low and close; the house area wedge put the pool on # the back wall, and an area lamp moved nearer lit the whole floor. ld = bpy.data.lights.new("Wedge", "SPOT") ld.energy, ld.color = 170.0, (1.0, 0.66, 0.34) ld.spot_size, ld.spot_blend, ld.shadow_soft_size = math.radians(44.0), 1.0, 0.3 wedge = bpy.data.objects.new("Wedge", ld) wedge.location = (0.45, 1.55, 1.70) wedge.rotation_euler = (Vector((0.10, 0.30, 0.0)) - wedge.location).to_track_quat( "-Z", "Y").to_euler() scene.collection.objects.link(wedge) # Render-only: the fire's own light, well above the coals so the ash # is lit by it rather than burnt out. light("Fire", "POINT", (0.0, 0.0, 0.78), 9.0, 0.25, (1.0, 0.45, 0.14)) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = 50.0 cam = bpy.data.objects.new("Cam", cam_data) cam.location = (1.10, -1.95, 1.30) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) aim.location = (0.0, 0.0, 0.31) 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") p.add_argument("--stray-vert", action="store_true") p.add_argument("--lift-z", action="store_true") p.add_argument("--float-foot", action="store_true") p.add_argument("--short-legs", action="store_true") p.add_argument("--float-coals", action="store_true") p.add_argument("--overfill", action="store_true") p.add_argument("--tuck-legs", action="store_true") p.add_argument("--pile-coals", action="store_true") p.add_argument("--uniform-coals", action="store_true") args = p.parse_args(argv) code, low, _high, mats, tex, _col = check( args.skip_decimate, lift_z=args.lift_z, stray_vert=args.stray_vert, float_foot=args.float_foot, short_legs=args.short_legs, float_coals=args.float_coals, overfill=args.overfill, tuck_legs=args.tuck_legs, pile_coals=args.pile_coals, uniform_coals=args.uniform_coals, ) if code: return code if args.output: rcode = render_still(low, mats, tex, os.path.abspath(args.output), args.engine) if rcode: return rcode print(f"rendered still {args.output}") print("brazier 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)