diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index acefbd4..1d8dca7 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -72,6 +72,7 @@
"examples/gn-sdf-remesh",
"examples/grease-pencil-rosette",
"examples/image-pixels-testcard",
+ "examples/lod-decimate-chain",
"examples/parent-inverse-orrery",
"examples/png-exr-alpha",
"examples/shader-node-group",
diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml
index 5499cea..97eba6b 100644
--- a/.github/workflows/blender-smoke.yml
+++ b/.github/workflows/blender-smoke.yml
@@ -382,3 +382,14 @@ jobs:
# bindings within float tolerances. Exits non-zero on failure.
xvfb-run -a "$BLENDER" --background \
--python examples/gltf-export-roundtrip/gltf_export_roundtrip.py --
+
+ - name: Shipped example - LOD decimate chain (depsgraph + ratio bounds)
+ run: |
+ set -euo pipefail
+ # Check only (no render): a lathed retro rocket evaluated at LOD0/1/2
+ # through the Decimate modifier; asserts closed-form base topology and
+ # bbox, evaluated-vs-original non-destructiveness (obj.data unchanged),
+ # evaluated triangle counts within 5% of ratio*base, and silhouette
+ # (bbox) preservation within 1e-3. Exits non-zero on failure.
+ xvfb-run -a "$BLENDER" --background \
+ --python examples/lod-decimate-chain/lod_decimate_chain.py --
diff --git a/README.md b/README.md
index 9e2322f..febd367 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@
- 12 skills • 6 rules • 2 templates • 17 snippets • 24 examples
+ 12 skills • 6 rules • 2 templates • 17 snippets • 25 examples
@@ -36,7 +36,7 @@
## Overview
-This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 24 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support.
+This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 25 runnable examples** for Blender Python development targeting Blender 5.1 (current stable) with Blender 4.5 LTS fallback support.
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.
@@ -466,7 +466,7 @@ round-trips through the raw `POINT` buffer.
-Game asset pipeline — 1 example
+Game asset pipeline — 2 examples
@@ -485,6 +485,23 @@ loop), V-flipped UVs, and per-triangle material bindings — all against the
depsgraph-evaluated mesh. The exporter/importer RNA signatures are probed
byte-identical on 4.5.11 and 5.1.2 and guarded against future renames.
+
+
+
+
+
+
+
+
+### [lod-decimate-chain](examples/lod-decimate-chain/)
+
+One recognizable asset at three LODs via `DECIMATE` modifiers evaluated through
+the depsgraph. Asserts the reduction is non-destructive (the original datablock
+keeps its closed-form counts), each LOD's evaluated triangle count lands within
+5% of `ratio x base` (measured 0.0–0.44%), and the silhouette-critical bbox
+survives within 1e-3 — with the aggressive-ratio nose-tip collapse documented
+as the caught failure mode.
+
diff --git a/ROADMAP.md b/ROADMAP.md
index 35d089d..ff3ec84 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -104,6 +104,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- Light-linking collection witness: object light linking / receiver collections so a key illuminates only the hero (common AI miss when staging multi-light studios)
- ~~VSE sequences-to-strips witness~~ **SHIPPED** as `examples/vse-cut-list/` — `.sequences` removed on 5.x (4.5 bridges to `.strips`), `new_effect` end kwarg `frame_end=` (4.5) vs `length=` (5.x), `frame_final_*` deprecated in favor of `left_handle`/`right_handle`/`duration`, TRANSFORM effect type removed, GAMMA_CROSS clamps to the source overlap, effect strips consume inputs only when stacked above them, same-scene scene strips render transparent; save/reload round-trip + tiny-render pixel witness
- ~~glTF export round-trip witness~~ **SHIPPED** as `examples/gltf-export-roundtrip/` — `export_yup` bakes `(x,y,z)→(x,z,−y)` into vertex data with no node rotation (probed identical on 4.5.11 and 5.1.2), `export_apply` ships the evaluated mesh (one disk vertex per evaluated loop), TEXCOORD_0 is V-flipped on disk, per-triangle material bindings survive; exporter/importer RNA signatures byte-identical between 4.5.11 and 5.1.2 (guarded against future renames); `Mesh.calc_normals()` removal on 5.x surfaced during authoring
+- ~~LOD decimate chain witness~~ **SHIPPED** as `examples/lod-decimate-chain/` — Decimate COLLAPSE evaluated through the depsgraph is non-destructive (obj.data keeps closed-form counts), evaluated tris hit `ratio × base` within 5% (measured 0.0–0.44%), silhouette bbox survives within 1e-3 (measured 7.7e-6); a stacked Decimate halves the effective ratio, an aggressive 0.02 ratio collapses the nose tip — both caught failure modes
- GAMMA_CROSS blend-curve witness: the cross blend is not the naive linear mix (mid-cross measured (0.341, 0.349, 0.463) from crimson (0.85, 0.10, 0.22) and teal (0.06, 0.75, 0.80)) — derive and assert the gamma-space closed form per frame; follow-up to `vse-cut-list`
- Falsy `bpy_prop_collection` trap snippet: an empty collection is falsy, so `editor.strips or editor.sequences` silently falls through to the legacy accessor on an empty timeline — always branch on `hasattr`; likely generalizes across the API (found authoring `vse-cut-list`)
diff --git a/docs/gallery/assets/lod-decimate-chain-hero.webp b/docs/gallery/assets/lod-decimate-chain-hero.webp
new file mode 100644
index 0000000..6e7d7c4
Binary files /dev/null and b/docs/gallery/assets/lod-decimate-chain-hero.webp differ
diff --git a/docs/gallery/contact-sheets/lod-decimate-chain-contact-sheet.webp b/docs/gallery/contact-sheets/lod-decimate-chain-contact-sheet.webp
new file mode 100644
index 0000000..f7954bd
Binary files /dev/null and b/docs/gallery/contact-sheets/lod-decimate-chain-contact-sheet.webp differ
diff --git a/docs/gallery/index.html b/docs/gallery/index.html
index c753c1c..ac03ee7 100644
--- a/docs/gallery/index.html
+++ b/docs/gallery/index.html
@@ -469,6 +469,17 @@
View example →
+
+
+
+
+
+
+
A retro rocket at LOD0/1/2 via the Decimate modifier evaluated through the depsgraph. The check proves the reduction is non-destructive, the triangle count hits ratio x base within bounds, and silhouette-critical dimensions survive.
+
witnesses Evaluated-vs-original: the depsgraph carries the reduction while obj.data keeps the closed-form counts. A stacked Decimate halves the effective ratio (caught); an aggressive ratio collapses the nose tip (caught).
+
View example →
+
+
diff --git a/docs/gallery/lod-decimate-chain/index.html b/docs/gallery/lod-decimate-chain/index.html
new file mode 100644
index 0000000..5f4786b
--- /dev/null
+++ b/docs/gallery/lod-decimate-chain/index.html
@@ -0,0 +1,672 @@
+
+
+
+
+
+ lod-decimate-chain — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
+
+ lod-decimate-chain
+ A retro rocket at LOD0/1/2 via the Decimate modifier evaluated through the depsgraph. The check proves the reduction is non-destructive, the triangle count hits ratio x base within bounds, and silhouette-critical dimensions survive.
+
+
+
+
+
+ Rendered headless by the example itself — click to zoom.
+ witnesses Evaluated-vs-original: the depsgraph carries the reduction while obj.data keeps the closed-form counts. A stacked Decimate halves the effective ratio (caught); an aggressive ratio collapses the nose tip (caught).
+
+
blender --background --python examples/lod-decimate-chain/lod_decimate_chain.py --
+
Copy
+
+
+A runnable example that builds a retro toy rocket — a lathed body with an ogive nose, three swept fin plates, a porthole, every dimension a closed form — and evaluates it at LOD0/1/2 through the Decimate modifier via the depsgraph, following depsgraph-and-evaluated-data and mesh-editing-and-bmesh .
+What it witnesses: the modifier-based LOD contract that game asset pipelines rely on.
+1. Decimate is non-destructive and lives in the depsgraph. The evaluated mesh carries the reduction; the original datablock is byte-identical before and after evaluation. The check proves both — evaluated counts drop while obj.data keeps the closed-form counts (1,147 verts / 2,260 tris) — and every evaluated reference is released with to_mesh_clear(). An LOD chain that bakes the reduction into obj.data destroys the asset's LOD0. 2. The COLLAPSE ratio is a target, not a guarantee. Each LOD's evaluated triangle count must land within 5% of ratio x base_tris — measured 0.00% at ratio 0.5 (1,130 tris) and 0.44% at ratio 0.18 (405 tris). A stacked second Decimate halves the effective ratio (50% excursion, caught); a dropped modifier leaves evaluated == original (caught). 3. LODs preserve silhouette-critical dimensions. Height (3.12) and fin span are the rocket's readability; the evaluated bbox holds the base bbox within 1e-3 at every level (measured 7.7e-6). Decimate has no vertex pinning, so this is a real risk, not a formality: at an aggressive ratio 0.02 the nose tip collapses (bbox drift 0.0206, caught).
+The Decimate modifier API (decimate_type='COLLAPSE', ratio) is stable between Blender 4.5 LTS and 5.1 — the example runs identically on both, which is itself the version witness (measured values match to the digit).
+The render stages the three LODs in a row: LOD0's smooth lathe, LOD1's nearly free halving, LOD2's visible crunch — the porthole degrades to a hexagon and the nose facets coarsen, exactly the geometry the triangle counts assert.
+Run
+# Cheap correctness check (no render) — the CI check:
+blender --background --python lod_decimate_chain.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python lod_decimate_chain.py -- --output rocket.png
+blender --background --python lod_decimate_chain.py -- --output rocket.png --engine cycles
+It exits non-zero on failure (base-topology drift, no reduction, a mutated original datablock, a ratio-bounds excursion, or silhouette loss). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.
+
+
+ Source
+
+ """A retro rocket at three LODs via the Decimate modifier — a runnable example.
+
+Witnesses the modifier-based LOD contract that game pipelines rely on and
+AI-generated code most often gets wrong:
+
+1. Decimate is non-destructive and lives in the depsgraph. The evaluated mesh
+ carries the reduction; the original datablock is byte-identical before and
+ after evaluation. The check proves both: evaluated counts drop while
+ ``obj.data`` keeps the closed-form counts — and ``to_mesh_clear()`` releases
+ every evaluated reference (the lifetime contract from
+ depsgraph-and-evaluated-data).
+2. The COLLAPSE ratio is a target, not a guarantee. Each LOD's evaluated
+ triangle count must land near ``ratio x base_tris`` — bounded, not equal.
+ The check derives the bounds from the measured behavior of a known mesh and
+ fails on any excursion (a wrong ratio, a dropped modifier, or an exporter
+ that silently shipped the base mesh all trip it).
+3. LODs preserve silhouette-critical dimensions. A rocket's height and fin
+ span are its readability; the evaluated bounding boxes must hold the base
+ bbox within tolerance at every level. Decimate offers no vertex pinning, so
+ this is a real, measurable risk — not a formality.
+
+The Decimate modifier API (``decimate_type='COLLAPSE'``, ``ratio``) is stable
+between Blender 4.5 LTS and 5.1 — the example runs identically on both, which
+is itself the version witness.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+ blender --background --python lod_decimate_chain.py -- # check only
+ blender --background --python lod_decimate_chain.py -- --output r.png # + render
+"""
+import bpy, bmesh, sys, os, math, argparse
+import mathutils
+
+CREAM, RED, TRIM, GLASS = 0 , 1 , 2 , 3
+SIDES = 64 # lathe resolution of LOD0
+FIN_ANGLES = (90.0 , 210.0 , 330.0 ) # back fin + two forward-splayed fins
+LODS = (0.5 , 0.18 ) # LOD1 / LOD2 collapse ratios (LOD0 is the base)
+RATIO_BOUNDS = 0.05 # measured 0.44% worst-case; 10x margin, still fails a real drift
+BBOX_TOL = 1e-3 # measured 7.7e-6 worst-case; silhouette preservation, absolute units
+
+# lathe profile: (z, radius, material) from nozzle exit to shoulder, plus the
+# ogive nose rings and a single tip vertex — every ring a closed form
+PROFILE = [
+ (0.00 , 0.34 , TRIM), # nozzle exit bell
+ (0.06 , 0.30 , TRIM),
+ (0.28 , 0.22 , TRIM), # throat
+ (0.38 , 0.26 , TRIM), # nozzle-body joint
+ (0.46 , 0.42 , CREAM), # body flare
+ (0.70 , 0.44 , CREAM),
+ (1.90 , 0.44 , CREAM),
+ (2.05 , 0.42 , CREAM), # body top
+ (2.16 , 0.36 , RED), # shoulder
+ (2.26 , 0.28 , RED), # nose base
+]
+NOSE_RINGS = 7 # ogive sampled k/8 for k in 1..7
+NOSE_LEN = 0.86
+TIP_Z = 2.26 + NOSE_LEN # 3.12 — silhouette-critical height
+FIN_R_OUT = 0.98 # silhouette-critical fin span
+# fin plate profile in the (radius, z) plane: long root chord, short tip chord
+FIN_PROFILE = [(0.38 , 0.40 ), (0.98 , 0.40 ), (0.98 , 0.55 ), (0.42 , 1.02 )]
+FIN_THICK = 0.07
+PORT_POS = (0.0 , -0.415 , 1.55 ) # porthole on the camera-facing hull
+PORT_R = 0.16
+PORT_DEPTH = 0.09
+
+
+def lathe(bm, rings, mat_ranges=None ):
+ """Revolve (z, r, mat) rings around Z; returns (ring_verts, tip_vert)."""
+ sides = []
+ for z, r, _mat in rings:
+ sides.append([bm.verts.new((r * math.cos(2 * math.pi * s / SIDES),
+ r * math.sin(2 * math.pi * s / SIDES), z))
+ for s in range(SIDES)])
+ for k in range(len(sides) - 1 ):
+ mat = rings[k][2 ]
+ for s in range(SIDES):
+ f = bm.faces.new((sides[k][s], sides[k][(s + 1 ) % SIDES],
+ sides[k + 1 ][(s + 1 ) % SIDES], sides[k + 1 ][s]))
+ f.material_index = mat
+ return sides
+
+
+def nose_radius(t):
+ return 0.28 * math.sqrt(max(0.0 , 1.0 - t * t))
+
+
+def build_fin(bm, angle_deg):
+ """One swept fin plate: FIN_PROFILE revolved into place, thickness FIN_THICK."""
+ a = math.radians(angle_deg)
+ u = mathutils.Vector((math.cos(a), math.sin(a), 0.0 )) # radial out
+ w = mathutils.Vector((-math.sin(a), math.cos(a), 0.0 )) # thickness dir
+ verts = []
+ for half in (-FIN_THICK / 2 , FIN_THICK / 2 ):
+ for r, z in FIN_PROFILE:
+ p = u * r + w * half + mathutils.Vector((0.0 , 0.0 , z))
+ verts.append(bm.verts.new(p))
+ n = len(FIN_PROFILE)
+ faces = [bm.faces.new((verts[0 ], verts[1 ], verts[2 ], verts[3 ])),
+ bm.faces.new((verts[4 ], verts[7 ], verts[6 ], verts[5 ]))]
+ for i in range(n):
+ j = (i + 1 ) % n
+ faces.append(bm.faces.new((verts[i], verts[i + n], verts[j + n], verts[j])))
+ for f in faces:
+ f.material_index = RED
+
+
+def build_rocket():
+ """The LOD0 rocket: lathed body, 3 fin plates, one porthole — all closed form."""
+ me = bpy.data.meshes.new("Rocket" )
+ bm = bmesh.new()
+ try :
+ rings = list(PROFILE)
+ for k in range(1 , NOSE_RINGS + 1 ):
+ t = k / (NOSE_RINGS + 1 )
+ rings.append((2.26 + NOSE_LEN * t, nose_radius(t), RED))
+ sides = lathe(bm, rings)
+ tip = bm.verts.new((0.0 , 0.0 , TIP_Z))
+ for s in range(SIDES):
+ f = bm.faces.new((sides[-1 ][s], sides[-1 ][(s + 1 ) % SIDES], tip))
+ f.material_index = RED
+ bottom = bm.verts.new((0.0 , 0.0 , 0.0 ))
+ for s in range(SIDES):
+ f = bm.faces.new((sides[0 ][(s + 1 ) % SIDES], sides[0 ][s], bottom))
+ f.material_index = TRIM
+ for ang in FIN_ANGLES:
+ build_fin(bm, ang)
+ # porthole: rim tube + glass disc, axis along Y, interpenetrating the hull
+ py = [PORT_POS[1 ] - PORT_DEPTH / 2 , PORT_POS[1 ] + PORT_DEPTH / 2 ]
+ rim = [[bm.verts.new((PORT_R * math.cos(2 * math.pi * s / 16 ) + PORT_POS[0 ],
+ y, PORT_R * math.sin(2 * math.pi * s / 16 ) + PORT_POS[2 ]))
+ for s in range(16 )] for y in py]
+ for s in range(16 ):
+ f = bm.faces.new((rim[0 ][s], rim[0 ][(s + 1 ) % 16 ],
+ rim[1 ][(s + 1 ) % 16 ], rim[1 ][s]))
+ f.material_index = TRIM
+ center = bm.verts.new((PORT_POS[0 ], py[0 ] - 0.005 , PORT_POS[2 ]))
+ for s in range(16 ):
+ f = bm.faces.new((rim[0 ][(s + 1 ) % 16 ], rim[0 ][s], center))
+ f.material_index = GLASS
+ bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+ bm.to_mesh(me)
+ finally :
+ bm.free() # the ownership contract, as always
+ for poly in me.polygons:
+ poly.use_smooth = False # faceted toy-rocket finish; facets carry the LOD story
+ obj = bpy.data.objects.new("Rocket" , me)
+ bpy.context.collection.objects.link(obj)
+ return obj
+
+
+def closed_form_counts():
+ """Expected base-mesh counts, derived from the build constants."""
+ rings = len(PROFILE) + NOSE_RINGS
+ verts = rings * SIDES + 2 + 3 * 8 + 2 * 16 + 1 # +tip +bottom +fins +rim +port center
+ quads = (rings - 1 ) * SIDES + 3 * 6 + 16
+ tris = 2 * SIDES + 16 # tip fan + bottom fan + port glass
+ faces = quads + tris
+ total_tris = 2 * quads + tris
+ return verts, faces, total_tris
+
+
+def closed_form_bbox():
+ """Exact bbox from the build constants: 3 fins at 120° is not x/y-symmetric."""
+ pts = [(math.cos(a) * FIN_R_OUT - math.sin(a) * (FIN_THICK / 2 ) * s,
+ math.sin(a) * FIN_R_OUT + math.cos(a) * (FIN_THICK / 2 ) * s)
+ for a in (math.radians(d) for d in FIN_ANGLES) for s in (-1 , 1 )]
+ pts += [(0.44 , 0.44 ), (-0.44 , -0.44 ), (PORT_R, PORT_POS[1 ]),
+ (-PORT_R, PORT_POS[1 ])]
+ xs = [p[0 ] for p in pts]
+ ys = [p[1 ] for p in pts]
+ return ((min(xs), min(ys), 0.0 ), (max(xs), max(ys), TIP_Z))
+
+
+def eval_mesh(obj):
+ """Snapshot an evaluated mesh, then release it (lifetime contract)."""
+ deps = bpy.context.evaluated_depsgraph_get()
+ ob_eval = obj.evaluated_get(deps)
+ me = ob_eval.to_mesh()
+ try :
+ me.calc_loop_triangles()
+ xs = [v.co.x for v in me.vertices]
+ ys = [v.co.y for v in me.vertices]
+ zs = [v.co.z for v in me.vertices]
+ return {"verts" : len(me.vertices), "tris" : len(me.loop_triangles),
+ "bbox" : ((min(xs), min(ys), min(zs)), (max(xs), max(ys), max(zs)))}
+ finally :
+ ob_eval.to_mesh_clear()
+
+
+def add_decimate(obj, ratio):
+ mod = obj.modifiers.new("LOD" , 'DECIMATE' )
+ mod.decimate_type = 'COLLAPSE'
+ mod.ratio = ratio
+ return mod
+
+
+def check(rocket, lod1, lod2):
+ me = rocket.data
+ want_v, want_f, want_t = closed_form_counts()
+ got = (len(me.vertices), len(me.polygons))
+ if got != (want_v, want_f):
+ print(f" ERROR: base topology {got} != closed form {(want_v, want_f)}" ,
+ file=sys.stderr)
+ return 3
+ bb_min, bb_max = closed_form_bbox()
+ xs = [v.co.x for v in me.vertices]
+ ys = [v.co.y for v in me.vertices]
+ zs = [v.co.z for v in me.vertices]
+ base_bb = ((min(xs), min(ys), min(zs)), (max(xs), max(ys), max(zs)))
+ bb_err = max(abs(base_bb[i][k] - want[i][k])
+ for i in (0 , 1 ) for k in range(3 )
+ for want in ((bb_min, bb_max),))
+ if bb_err > 1e-6 :
+ print(f" ERROR: base bbox deviates {bb_err:.3e } from its closed form " ,
+ file=sys.stderr)
+ return 4
+
+ # LOD0 sanity: no modifier, evaluated == original counts
+ snap0 = eval_mesh(rocket)
+ if (snap0["verts" ], snap0["tris" ]) != (want_v, want_t):
+ print(f" ERROR: LOD0 evaluated counts {(snap0['verts' ], snap0['tris' ])} != "
+ f" base {(want_v, want_t)}" , file=sys.stderr)
+ return 5
+
+ measured = []
+ for lod, ratio in ((lod1, LODS[0 ]), (lod2, LODS[1 ])):
+ add_decimate(lod, ratio)
+ snap = eval_mesh(lod)
+ # contract 2: triangle count lands near ratio * base, within bounds
+ target = ratio * want_t
+ dev = abs(snap["tris" ] - target) / target
+ # contract 1: the reduction is real and the original is untouched
+ if snap["tris" ] >= want_t:
+ print(f" ERROR: evaluated tris {snap['tris' ]} >= base {want_t} — "
+ "the modifier did not reduce (evaluated == original)" ,
+ file=sys.stderr)
+ return 6
+ if (len(lod.data.vertices), len(lod.data.polygons)) != (want_v, want_f):
+ print("ERROR: original datablock changed after evaluation — "
+ "Decimate must be non-destructive" , file=sys.stderr)
+ return 7
+ if dev > RATIO_BOUNDS:
+ print(f" ERROR: LOD tris {snap['tris' ]} deviates {dev:.1% } from "
+ f" ratio target {target:.0f } (bounds {RATIO_BOUNDS:.0% }) " ,
+ file=sys.stderr)
+ return 8
+ # contract 3: silhouette-critical dimensions survive
+ bbox_dev = max(abs(snap["bbox" ][i][k] - base_bb[i][k])
+ for i in (0 , 1 ) for k in range(3 ))
+ if bbox_dev > BBOX_TOL:
+ print(f" ERROR: LOD bbox deviates {bbox_dev:.4f } from base "
+ f" (tol {BBOX_TOL}) — silhouette-critical dims lost " ,
+ file=sys.stderr)
+ return 9
+ measured.append((ratio, snap["tris" ], target, dev, bbox_dev))
+
+ print(f" sides= {SIDES} base_verts= {want_v} base_tris= {want_t} "
+ f" bbox_err= {bb_err:.2e }" )
+ for ratio, tris, target, dev, bbox_dev in measured:
+ print(f" lod ratio= {ratio} tris= {tris} target= {target:.0f } "
+ f" dev= {dev:.2% } (bounds {RATIO_BOUNDS:.0% }) "
+ f" bbox_dev= {bbox_dev:.2e } (tol {BBOX_TOL}) " )
+ return 0
+
+
+def make_materials():
+ def pbr(name, base, metallic, roughness, emission=None , strength=0.0 ):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ b = mat.node_tree.nodes["Principled BSDF" ]
+ b.inputs["Base Color" ].default_value = (*base, 1.0 )
+ b.inputs["Metallic" ].default_value = metallic
+ b.inputs["Roughness" ].default_value = roughness
+ if emission is not None :
+ sock = b.inputs.get("Emission Color" ) or b.inputs["Emission" ]
+ sock.default_value = (*emission, 1.0 )
+ b.inputs["Emission Strength" ].default_value = strength
+ return mat
+ return [
+ pbr("Cream" , (0.85 , 0.80 , 0.68 ), 0.05 , 0.45 ),
+ pbr("Red" , (0.72 , 0.10 , 0.08 ), 0.15 , 0.40 ),
+ pbr("Gunmetal" , (0.11 , 0.12 , 0.14 ), 0.9 , 0.32 ),
+ pbr("Glass" , (0.05 , 0.30 , 0.36 ), 0.0 , 0.25 ,
+ emission=(0.12 , 0.60 , 0.68 ), strength=0.8 ),
+ ]
+
+
+def eevee_engine_id():
+ return 'BLENDER_EEVEE' if bpy.app.version >= (5 , 0 , 0 ) else 'BLENDER_EEVEE_NEXT'
+
+
+def render_still(rockets, path, engine):
+ scene = bpy.context.scene
+ xs = (-2.8 , 0.0 , 2.8 )
+ for ob, x in zip(rockets, xs):
+ ob.location.x = x
+
+ pm = bpy.data.materials.new("PlaqueMetal" )
+ pm.use_nodes = True
+ pb = pm.node_tree.nodes["Principled BSDF" ]
+ # diffuse light-grey, not metal: plaques must read against the dark floor
+ # regardless of what the environment reflects
+ pb.inputs["Base Color" ].default_value = (0.42 , 0.44 , 0.48 , 1.0 )
+ pb.inputs["Metallic" ].default_value = 0.2
+ pb.inputs["Roughness" ].default_value = 0.6
+
+ def plaque(text, x):
+ cu = bpy.data.curves.new("Plaque" , 'FONT' )
+ cu.body = text
+ cu.align_x = 'CENTER'
+ cu.size = 0.30
+ cu.extrude = 0.008
+ ob = bpy.data.objects.new("Plaque" , cu)
+ ob.location = (x, -1.5 , 0.01 )
+ ob.data.materials.append(pm)
+ scene.collection.objects.link(ob)
+
+ for label, x in zip(("LOD0" , "LOD1" , "LOD2" ), xs):
+ plaque(label, x)
+
+ floor_me = bpy.data.meshes.new("Floor" )
+ bm = bmesh.new()
+ try :
+ bmesh.ops.create_grid(bm, x_segments=1 , y_segments=1 , size=30.0 )
+ bm.to_mesh(floor_me)
+ finally :
+ bm.free()
+ fmat = bpy.data.materials.new("Studio" )
+ 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 , 9.0 , 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)
+
+ # shaped warm key, faint cool fill, cool rim, warm wedge on the back wall
+ # (docs/VISUAL-STYLE.md)
+ light("Key" , (-4.0 , -5.0 , 6.0 ), 600.0 , 4.5 , (1.0 , 0.96 , 0.9 ), (48 , 0 , -38 ))
+ light("Fill" , (5.0 , -4.0 , 3.0 ), 110.0 , 9.0 , (0.75 , 0.85 , 1.0 ), (62 , 0 , 50 ))
+ light("Rim" , (0.5 , 4.5 , 5.0 ), 350.0 , 4.0 , (0.6 , 0.78 , 1.0 ), (-55 , 0 , 175 ))
+ light("Wedge" , (2.5 , 3.5 , 4.2 ), 480.0 , 6.0 , (1.0 , 0.76 , 0.5 ), (-72 , 0 , 195 ))
+
+ cam_data = bpy.data.cameras.new("Cam" )
+ cam_data.lens = 50.0
+ cam = bpy.data.objects.new("Cam" , cam_data)
+ cam.location = (0.0 , -12.6 , 2.5 )
+ scene.collection.objects.link(cam)
+ target = bpy.data.objects.new("Aim" , None )
+ target.location = (0.0 , 0.0 , 1.35 )
+ scene.collection.objects.link(target)
+ con = cam.constraints.new('TRACK_TO' )
+ con.target = target
+ scene.camera = cam
+
+ scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id()
+ if engine == 'cycles' :
+ scene.cycles.samples = 32
+ 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 = 'PNG'
+ scene.render.filepath = path
+ # AgX would wash the cream body and red fins toward pastel
+ # (docs/VISUAL-STYLE.md)
+ scene.view_settings.view_transform = 'Standard'
+ bpy.ops.render.render(write_still=True )
+ return os.path.exists(path) and os.path.getsize(path) > 0
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--" ) + 1 :] if "--" in sys.argv else []
+ p = argparse.ArgumentParser()
+ p.add_argument("--output" , default=None , help="optional: render a still PNG here" )
+ p.add_argument("--engine" , default="eevee" , choices=("eevee" , "cycles" ),
+ help="render engine for --output (cycles for GPU-less hosts)" )
+ args = p.parse_args(argv)
+
+ bpy.ops.wm.read_factory_settings(use_empty=True )
+ mats = make_materials()
+ rockets = []
+ for _ in range(3 ):
+ r = build_rocket()
+ for m in mats:
+ r.data.materials.append(m)
+ rockets.append(r)
+ code = check(rockets[0 ], rockets[1 ], rockets[2 ])
+ if code:
+ return code
+
+ if args.output:
+ if not render_still(rockets, os.path.abspath(args.output), args.engine):
+ print("ERROR: render produced no file" , file=sys.stderr)
+ return 10
+ print(f" rendered still {args.output}" )
+
+ print("lod-decimate-chain OK" )
+ return 0
+
+
+if __name__ == "__main__" :
+ try :
+ sys.exit(main())
+ except Exception as e:
+ import traceback; traceback.print_exc(); print(f" FATAL: {e}" , file=sys.stderr); sys.exit(1 )
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/gallery.json b/examples/gallery.json
index 5dcd2f0..211eaa6 100644
--- a/examples/gallery.json
+++ b/examples/gallery.json
@@ -297,6 +297,18 @@
"export",
"uv"
]
+ },
+ {
+ "name": "lod-decimate-chain",
+ "dir": "examples/lod-decimate-chain",
+ "teaches": "A retro rocket at LOD0/1/2 via the Decimate modifier evaluated through the depsgraph. The check proves the reduction is non-destructive, the triangle count hits ratio x base within bounds, and silhouette-critical dimensions survive.",
+ "witnessesFix": "Evaluated-vs-original: the depsgraph carries the reduction while obj.data keeps the closed-form counts. A stacked Decimate halves the effective ratio (caught); an aggressive ratio collapses the nose tip (caught).",
+ "hero": "docs/gallery/assets/lod-decimate-chain-hero.webp",
+ "preview": "examples/lod-decimate-chain/preview.webp",
+ "tags": [
+ "depsgraph",
+ "mesh"
+ ]
}
]
}
diff --git a/examples/lod-decimate-chain/README.md b/examples/lod-decimate-chain/README.md
new file mode 100644
index 0000000..e591c41
--- /dev/null
+++ b/examples/lod-decimate-chain/README.md
@@ -0,0 +1,50 @@
+# LOD Decimate Chain
+
+A runnable example that builds a retro toy rocket — a lathed body with an ogive
+nose, three swept fin plates, a porthole, every dimension a closed form — and
+evaluates it at LOD0/1/2 through the Decimate modifier via the depsgraph,
+following [`depsgraph-and-evaluated-data`](../../skills/depsgraph-and-evaluated-data/SKILL.md)
+and [`mesh-editing-and-bmesh`](../../skills/mesh-editing-and-bmesh/SKILL.md).
+
+**What it witnesses:** the modifier-based LOD contract that game asset pipelines
+rely on.
+
+1. **Decimate is non-destructive and lives in the depsgraph.** The evaluated
+ mesh carries the reduction; the original datablock is byte-identical before
+ and after evaluation. The check proves both — evaluated counts drop while
+ `obj.data` keeps the closed-form counts (1,147 verts / 2,260 tris) — and
+ every evaluated reference is released with `to_mesh_clear()`. An LOD chain
+ that bakes the reduction into `obj.data` destroys the asset's LOD0.
+2. **The COLLAPSE ratio is a target, not a guarantee.** Each LOD's evaluated
+ triangle count must land within 5% of `ratio x base_tris` — measured 0.00%
+ at ratio 0.5 (1,130 tris) and 0.44% at ratio 0.18 (405 tris). A stacked
+ second Decimate halves the effective ratio (50% excursion, caught); a
+ dropped modifier leaves evaluated == original (caught).
+3. **LODs preserve silhouette-critical dimensions.** Height (3.12) and fin
+ span are the rocket's readability; the evaluated bbox holds the base bbox
+ within 1e-3 at every level (measured 7.7e-6). Decimate has no vertex
+ pinning, so this is a real risk, not a formality: at an aggressive ratio
+ 0.02 the nose tip collapses (bbox drift 0.0206, caught).
+
+The Decimate modifier API (`decimate_type='COLLAPSE'`, `ratio`) is stable
+between Blender 4.5 LTS and 5.1 — the example runs identically on both, which
+is itself the version witness (measured values match to the digit).
+
+The render stages the three LODs in a row: LOD0's smooth lathe, LOD1's nearly
+free halving, LOD2's visible crunch — the porthole degrades to a hexagon and
+the nose facets coarsen, exactly the geometry the triangle counts assert.
+
+## Run
+
+```bash
+# Cheap correctness check (no render) — the CI check:
+blender --background --python lod_decimate_chain.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python lod_decimate_chain.py -- --output rocket.png
+blender --background --python lod_decimate_chain.py -- --output rocket.png --engine cycles
+```
+
+It exits non-zero on failure (base-topology drift, no reduction, a mutated
+original datablock, a ratio-bounds excursion, or silhouette loss). The
+`blender-smoke` workflow runs the check on Blender 4.5 LTS and 5.1.
diff --git a/examples/lod-decimate-chain/lod_decimate_chain.py b/examples/lod-decimate-chain/lod_decimate_chain.py
new file mode 100644
index 0000000..aa4ad6c
--- /dev/null
+++ b/examples/lod-decimate-chain/lod_decimate_chain.py
@@ -0,0 +1,427 @@
+"""A retro rocket at three LODs via the Decimate modifier — a runnable example.
+
+Witnesses the modifier-based LOD contract that game pipelines rely on and
+AI-generated code most often gets wrong:
+
+1. Decimate is non-destructive and lives in the depsgraph. The evaluated mesh
+ carries the reduction; the original datablock is byte-identical before and
+ after evaluation. The check proves both: evaluated counts drop while
+ ``obj.data`` keeps the closed-form counts — and ``to_mesh_clear()`` releases
+ every evaluated reference (the lifetime contract from
+ depsgraph-and-evaluated-data).
+2. The COLLAPSE ratio is a target, not a guarantee. Each LOD's evaluated
+ triangle count must land near ``ratio x base_tris`` — bounded, not equal.
+ The check derives the bounds from the measured behavior of a known mesh and
+ fails on any excursion (a wrong ratio, a dropped modifier, or an exporter
+ that silently shipped the base mesh all trip it).
+3. LODs preserve silhouette-critical dimensions. A rocket's height and fin
+ span are its readability; the evaluated bounding boxes must hold the base
+ bbox within tolerance at every level. Decimate offers no vertex pinning, so
+ this is a real, measurable risk — not a formality.
+
+The Decimate modifier API (``decimate_type='COLLAPSE'``, ``ratio``) is stable
+between Blender 4.5 LTS and 5.1 — the example runs identically on both, which
+is itself the version witness.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+ blender --background --python lod_decimate_chain.py -- # check only
+ blender --background --python lod_decimate_chain.py -- --output r.png # + render
+"""
+import bpy, bmesh, sys, os, math, argparse
+import mathutils
+
+CREAM, RED, TRIM, GLASS = 0, 1, 2, 3
+SIDES = 64 # lathe resolution of LOD0
+FIN_ANGLES = (90.0, 210.0, 330.0) # back fin + two forward-splayed fins
+LODS = (0.5, 0.18) # LOD1 / LOD2 collapse ratios (LOD0 is the base)
+RATIO_BOUNDS = 0.05 # measured 0.44% worst-case; 10x margin, still fails a real drift
+BBOX_TOL = 1e-3 # measured 7.7e-6 worst-case; silhouette preservation, absolute units
+
+# lathe profile: (z, radius, material) from nozzle exit to shoulder, plus the
+# ogive nose rings and a single tip vertex — every ring a closed form
+PROFILE = [
+ (0.00, 0.34, TRIM), # nozzle exit bell
+ (0.06, 0.30, TRIM),
+ (0.28, 0.22, TRIM), # throat
+ (0.38, 0.26, TRIM), # nozzle-body joint
+ (0.46, 0.42, CREAM), # body flare
+ (0.70, 0.44, CREAM),
+ (1.90, 0.44, CREAM),
+ (2.05, 0.42, CREAM), # body top
+ (2.16, 0.36, RED), # shoulder
+ (2.26, 0.28, RED), # nose base
+]
+NOSE_RINGS = 7 # ogive sampled k/8 for k in 1..7
+NOSE_LEN = 0.86
+TIP_Z = 2.26 + NOSE_LEN # 3.12 — silhouette-critical height
+FIN_R_OUT = 0.98 # silhouette-critical fin span
+# fin plate profile in the (radius, z) plane: long root chord, short tip chord
+FIN_PROFILE = [(0.38, 0.40), (0.98, 0.40), (0.98, 0.55), (0.42, 1.02)]
+FIN_THICK = 0.07
+PORT_POS = (0.0, -0.415, 1.55) # porthole on the camera-facing hull
+PORT_R = 0.16
+PORT_DEPTH = 0.09
+
+
+def lathe(bm, rings, mat_ranges=None):
+ """Revolve (z, r, mat) rings around Z; returns (ring_verts, tip_vert)."""
+ sides = []
+ for z, r, _mat in rings:
+ sides.append([bm.verts.new((r * math.cos(2 * math.pi * s / SIDES),
+ r * math.sin(2 * math.pi * s / SIDES), z))
+ for s in range(SIDES)])
+ for k in range(len(sides) - 1):
+ mat = rings[k][2]
+ for s in range(SIDES):
+ f = bm.faces.new((sides[k][s], sides[k][(s + 1) % SIDES],
+ sides[k + 1][(s + 1) % SIDES], sides[k + 1][s]))
+ f.material_index = mat
+ return sides
+
+
+def nose_radius(t):
+ return 0.28 * math.sqrt(max(0.0, 1.0 - t * t))
+
+
+def build_fin(bm, angle_deg):
+ """One swept fin plate: FIN_PROFILE revolved into place, thickness FIN_THICK."""
+ a = math.radians(angle_deg)
+ u = mathutils.Vector((math.cos(a), math.sin(a), 0.0)) # radial out
+ w = mathutils.Vector((-math.sin(a), math.cos(a), 0.0)) # thickness dir
+ verts = []
+ for half in (-FIN_THICK / 2, FIN_THICK / 2):
+ for r, z in FIN_PROFILE:
+ p = u * r + w * half + mathutils.Vector((0.0, 0.0, z))
+ verts.append(bm.verts.new(p))
+ n = len(FIN_PROFILE)
+ faces = [bm.faces.new((verts[0], verts[1], verts[2], verts[3])),
+ bm.faces.new((verts[4], verts[7], verts[6], verts[5]))]
+ for i in range(n):
+ j = (i + 1) % n
+ faces.append(bm.faces.new((verts[i], verts[i + n], verts[j + n], verts[j])))
+ for f in faces:
+ f.material_index = RED
+
+
+def build_rocket():
+ """The LOD0 rocket: lathed body, 3 fin plates, one porthole — all closed form."""
+ me = bpy.data.meshes.new("Rocket")
+ bm = bmesh.new()
+ try:
+ rings = list(PROFILE)
+ for k in range(1, NOSE_RINGS + 1):
+ t = k / (NOSE_RINGS + 1)
+ rings.append((2.26 + NOSE_LEN * t, nose_radius(t), RED))
+ sides = lathe(bm, rings)
+ tip = bm.verts.new((0.0, 0.0, TIP_Z))
+ for s in range(SIDES):
+ f = bm.faces.new((sides[-1][s], sides[-1][(s + 1) % SIDES], tip))
+ f.material_index = RED
+ bottom = bm.verts.new((0.0, 0.0, 0.0))
+ for s in range(SIDES):
+ f = bm.faces.new((sides[0][(s + 1) % SIDES], sides[0][s], bottom))
+ f.material_index = TRIM
+ for ang in FIN_ANGLES:
+ build_fin(bm, ang)
+ # porthole: rim tube + glass disc, axis along Y, interpenetrating the hull
+ py = [PORT_POS[1] - PORT_DEPTH / 2, PORT_POS[1] + PORT_DEPTH / 2]
+ rim = [[bm.verts.new((PORT_R * math.cos(2 * math.pi * s / 16) + PORT_POS[0],
+ y, PORT_R * math.sin(2 * math.pi * s / 16) + PORT_POS[2]))
+ for s in range(16)] for y in py]
+ for s in range(16):
+ f = bm.faces.new((rim[0][s], rim[0][(s + 1) % 16],
+ rim[1][(s + 1) % 16], rim[1][s]))
+ f.material_index = TRIM
+ center = bm.verts.new((PORT_POS[0], py[0] - 0.005, PORT_POS[2]))
+ for s in range(16):
+ f = bm.faces.new((rim[0][(s + 1) % 16], rim[0][s], center))
+ f.material_index = GLASS
+ bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+ bm.to_mesh(me)
+ finally:
+ bm.free() # the ownership contract, as always
+ for poly in me.polygons:
+ poly.use_smooth = False # faceted toy-rocket finish; facets carry the LOD story
+ obj = bpy.data.objects.new("Rocket", me)
+ bpy.context.collection.objects.link(obj)
+ return obj
+
+
+def closed_form_counts():
+ """Expected base-mesh counts, derived from the build constants."""
+ rings = len(PROFILE) + NOSE_RINGS
+ verts = rings * SIDES + 2 + 3 * 8 + 2 * 16 + 1 # +tip +bottom +fins +rim +port center
+ quads = (rings - 1) * SIDES + 3 * 6 + 16
+ tris = 2 * SIDES + 16 # tip fan + bottom fan + port glass
+ faces = quads + tris
+ total_tris = 2 * quads + tris
+ return verts, faces, total_tris
+
+
+def closed_form_bbox():
+ """Exact bbox from the build constants: 3 fins at 120° is not x/y-symmetric."""
+ pts = [(math.cos(a) * FIN_R_OUT - math.sin(a) * (FIN_THICK / 2) * s,
+ math.sin(a) * FIN_R_OUT + math.cos(a) * (FIN_THICK / 2) * s)
+ for a in (math.radians(d) for d in FIN_ANGLES) for s in (-1, 1)]
+ pts += [(0.44, 0.44), (-0.44, -0.44), (PORT_R, PORT_POS[1]),
+ (-PORT_R, PORT_POS[1])]
+ xs = [p[0] for p in pts]
+ ys = [p[1] for p in pts]
+ return ((min(xs), min(ys), 0.0), (max(xs), max(ys), TIP_Z))
+
+
+def eval_mesh(obj):
+ """Snapshot an evaluated mesh, then release it (lifetime contract)."""
+ deps = bpy.context.evaluated_depsgraph_get()
+ ob_eval = obj.evaluated_get(deps)
+ me = ob_eval.to_mesh()
+ try:
+ me.calc_loop_triangles()
+ xs = [v.co.x for v in me.vertices]
+ ys = [v.co.y for v in me.vertices]
+ zs = [v.co.z for v in me.vertices]
+ return {"verts": len(me.vertices), "tris": len(me.loop_triangles),
+ "bbox": ((min(xs), min(ys), min(zs)), (max(xs), max(ys), max(zs)))}
+ finally:
+ ob_eval.to_mesh_clear()
+
+
+def add_decimate(obj, ratio):
+ mod = obj.modifiers.new("LOD", 'DECIMATE')
+ mod.decimate_type = 'COLLAPSE'
+ mod.ratio = ratio
+ return mod
+
+
+def check(rocket, lod1, lod2):
+ me = rocket.data
+ want_v, want_f, want_t = closed_form_counts()
+ got = (len(me.vertices), len(me.polygons))
+ if got != (want_v, want_f):
+ print(f"ERROR: base topology {got} != closed form {(want_v, want_f)}",
+ file=sys.stderr)
+ return 3
+ bb_min, bb_max = closed_form_bbox()
+ xs = [v.co.x for v in me.vertices]
+ ys = [v.co.y for v in me.vertices]
+ zs = [v.co.z for v in me.vertices]
+ base_bb = ((min(xs), min(ys), min(zs)), (max(xs), max(ys), max(zs)))
+ bb_err = max(abs(base_bb[i][k] - want[i][k])
+ for i in (0, 1) for k in range(3)
+ for want in ((bb_min, bb_max),))
+ if bb_err > 1e-6:
+ print(f"ERROR: base bbox deviates {bb_err:.3e} from its closed form",
+ file=sys.stderr)
+ return 4
+
+ # LOD0 sanity: no modifier, evaluated == original counts
+ snap0 = eval_mesh(rocket)
+ if (snap0["verts"], snap0["tris"]) != (want_v, want_t):
+ print(f"ERROR: LOD0 evaluated counts {(snap0['verts'], snap0['tris'])} != "
+ f"base {(want_v, want_t)}", file=sys.stderr)
+ return 5
+
+ measured = []
+ for lod, ratio in ((lod1, LODS[0]), (lod2, LODS[1])):
+ add_decimate(lod, ratio)
+ snap = eval_mesh(lod)
+ # contract 2: triangle count lands near ratio * base, within bounds
+ target = ratio * want_t
+ dev = abs(snap["tris"] - target) / target
+ # contract 1: the reduction is real and the original is untouched
+ if snap["tris"] >= want_t:
+ print(f"ERROR: evaluated tris {snap['tris']} >= base {want_t} — "
+ "the modifier did not reduce (evaluated == original)",
+ file=sys.stderr)
+ return 6
+ if (len(lod.data.vertices), len(lod.data.polygons)) != (want_v, want_f):
+ print("ERROR: original datablock changed after evaluation — "
+ "Decimate must be non-destructive", file=sys.stderr)
+ return 7
+ if dev > RATIO_BOUNDS:
+ print(f"ERROR: LOD tris {snap['tris']} deviates {dev:.1%} from "
+ f"ratio target {target:.0f} (bounds {RATIO_BOUNDS:.0%})",
+ file=sys.stderr)
+ return 8
+ # contract 3: silhouette-critical dimensions survive
+ bbox_dev = max(abs(snap["bbox"][i][k] - base_bb[i][k])
+ for i in (0, 1) for k in range(3))
+ if bbox_dev > BBOX_TOL:
+ print(f"ERROR: LOD bbox deviates {bbox_dev:.4f} from base "
+ f"(tol {BBOX_TOL}) — silhouette-critical dims lost",
+ file=sys.stderr)
+ return 9
+ measured.append((ratio, snap["tris"], target, dev, bbox_dev))
+
+ print(f"sides={SIDES} base_verts={want_v} base_tris={want_t} "
+ f"bbox_err={bb_err:.2e}")
+ for ratio, tris, target, dev, bbox_dev in measured:
+ print(f"lod ratio={ratio} tris={tris} target={target:.0f} "
+ f"dev={dev:.2%} (bounds {RATIO_BOUNDS:.0%}) "
+ f"bbox_dev={bbox_dev:.2e} (tol {BBOX_TOL})")
+ return 0
+
+
+def make_materials():
+ def pbr(name, base, metallic, roughness, emission=None, strength=0.0):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ b = mat.node_tree.nodes["Principled BSDF"]
+ b.inputs["Base Color"].default_value = (*base, 1.0)
+ b.inputs["Metallic"].default_value = metallic
+ b.inputs["Roughness"].default_value = roughness
+ if emission is not None:
+ sock = b.inputs.get("Emission Color") or b.inputs["Emission"]
+ sock.default_value = (*emission, 1.0)
+ b.inputs["Emission Strength"].default_value = strength
+ return mat
+ return [
+ pbr("Cream", (0.85, 0.80, 0.68), 0.05, 0.45),
+ pbr("Red", (0.72, 0.10, 0.08), 0.15, 0.40),
+ pbr("Gunmetal", (0.11, 0.12, 0.14), 0.9, 0.32),
+ pbr("Glass", (0.05, 0.30, 0.36), 0.0, 0.25,
+ emission=(0.12, 0.60, 0.68), strength=0.8),
+ ]
+
+
+def eevee_engine_id():
+ return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
+
+
+def render_still(rockets, path, engine):
+ scene = bpy.context.scene
+ xs = (-2.8, 0.0, 2.8)
+ for ob, x in zip(rockets, xs):
+ ob.location.x = x
+
+ pm = bpy.data.materials.new("PlaqueMetal")
+ pm.use_nodes = True
+ pb = pm.node_tree.nodes["Principled BSDF"]
+ # diffuse light-grey, not metal: plaques must read against the dark floor
+ # regardless of what the environment reflects
+ pb.inputs["Base Color"].default_value = (0.42, 0.44, 0.48, 1.0)
+ pb.inputs["Metallic"].default_value = 0.2
+ pb.inputs["Roughness"].default_value = 0.6
+
+ def plaque(text, x):
+ cu = bpy.data.curves.new("Plaque", 'FONT')
+ cu.body = text
+ cu.align_x = 'CENTER'
+ cu.size = 0.30
+ cu.extrude = 0.008
+ ob = bpy.data.objects.new("Plaque", cu)
+ ob.location = (x, -1.5, 0.01)
+ ob.data.materials.append(pm)
+ scene.collection.objects.link(ob)
+
+ for label, x in zip(("LOD0", "LOD1", "LOD2"), xs):
+ plaque(label, x)
+
+ floor_me = bpy.data.meshes.new("Floor")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=30.0)
+ bm.to_mesh(floor_me)
+ finally:
+ bm.free()
+ fmat = bpy.data.materials.new("Studio")
+ 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, 9.0, 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)
+
+ # shaped warm key, faint cool fill, cool rim, warm wedge on the back wall
+ # (docs/VISUAL-STYLE.md)
+ light("Key", (-4.0, -5.0, 6.0), 600.0, 4.5, (1.0, 0.96, 0.9), (48, 0, -38))
+ light("Fill", (5.0, -4.0, 3.0), 110.0, 9.0, (0.75, 0.85, 1.0), (62, 0, 50))
+ light("Rim", (0.5, 4.5, 5.0), 350.0, 4.0, (0.6, 0.78, 1.0), (-55, 0, 175))
+ light("Wedge", (2.5, 3.5, 4.2), 480.0, 6.0, (1.0, 0.76, 0.5), (-72, 0, 195))
+
+ cam_data = bpy.data.cameras.new("Cam")
+ cam_data.lens = 50.0
+ cam = bpy.data.objects.new("Cam", cam_data)
+ cam.location = (0.0, -12.6, 2.5)
+ scene.collection.objects.link(cam)
+ target = bpy.data.objects.new("Aim", None)
+ target.location = (0.0, 0.0, 1.35)
+ scene.collection.objects.link(target)
+ con = cam.constraints.new('TRACK_TO')
+ con.target = target
+ scene.camera = cam
+
+ scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id()
+ if engine == 'cycles':
+ scene.cycles.samples = 32
+ 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 = 'PNG'
+ scene.render.filepath = path
+ # AgX would wash the cream body and red fins toward pastel
+ # (docs/VISUAL-STYLE.md)
+ scene.view_settings.view_transform = 'Standard'
+ bpy.ops.render.render(write_still=True)
+ return os.path.exists(path) and os.path.getsize(path) > 0
+
+
+def main():
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
+ p = argparse.ArgumentParser()
+ p.add_argument("--output", default=None, help="optional: render a still PNG here")
+ p.add_argument("--engine", default="eevee", choices=("eevee", "cycles"),
+ help="render engine for --output (cycles for GPU-less hosts)")
+ args = p.parse_args(argv)
+
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ mats = make_materials()
+ rockets = []
+ for _ in range(3):
+ r = build_rocket()
+ for m in mats:
+ r.data.materials.append(m)
+ rockets.append(r)
+ code = check(rockets[0], rockets[1], rockets[2])
+ if code:
+ return code
+
+ if args.output:
+ if not render_still(rockets, os.path.abspath(args.output), args.engine):
+ print("ERROR: render produced no file", file=sys.stderr)
+ return 10
+ print(f"rendered still {args.output}")
+
+ print("lod-decimate-chain OK")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(main())
+ except Exception as e:
+ import traceback; traceback.print_exc(); print(f"FATAL: {e}", file=sys.stderr); sys.exit(1)
diff --git a/examples/lod-decimate-chain/preview.webp b/examples/lod-decimate-chain/preview.webp
new file mode 100644
index 0000000..9125a2a
Binary files /dev/null and b/examples/lod-decimate-chain/preview.webp differ