@@ -36,7 +36,7 @@
## Overview
-This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 30 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 31 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.
@@ -504,7 +504,7 @@ round-trips through the raw `POINT` buffer.
-Game asset pipeline — 5 examples
+Game asset pipeline — 6 examples
@@ -591,6 +591,25 @@ bit-exact weights, and identical deformation of the re-imported rig — plus
the parenting hazard: unparented skinned meshes let the exporter bind an
armature by name.
+
+
+
+
+
+
+
+
+### [collision-hull-proxy](examples/collision-hull-proxy/)
+
+The compound-collision contract prop pipelines (engines generally,
+FiveM/GTA-style prop workflows specifically) ingest: one convex hull piece
+per part group, built with `bmesh.ops.convex_hull` from a coarse
+`sec(π/n)`-inflated cage — never the dense render mesh, whose hull measures
+368 faces, over the 255-face per-piece engine budget. Closed-form plane
+tests prove containment (5.9e-08), convexity, watertightness, outward
+winding, and Euler characteristic 2 per piece. Proud details cost cage rows;
+concave grooves are free.
+
diff --git a/ROADMAP.md b/ROADMAP.md
index fe507be..fa5eda0 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -112,6 +112,10 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo
- Degenerate-bevel weld hazard (snippet or rule): bevel width ≥ half a box dimension creates zero-area faces whose loops weld on glTF export (found authoring `gltf-export-roundtrip`, where the count check caught a 36-vertex weld)
- ~~GAMMA_CROSS blend-curve witness~~ **SHIPPED** as `examples/vse-gamma-cross/` — the cross blends in a gamma-0.5 space: `((1-t)·√A + t·√B)²` with `t = (frame − start)/duration`, never 1 inside the effect; mid-cross dips 0.115 below the sRGB lerp from crimson/teal (closed form (0.341, 0.349, 0.463) confirmed per frame); AgX-default sampling poisons the fit (0.146 red-channel error, `view_transform='Standard'` mandatory); deleting a consumed input orphans-and-deletes the effect — 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`)
+- ~~Collision compound witness~~ **SHIPPED** as `examples/collision-hull-proxy/` — game-prop collision as a compound of convex pieces, each a `bmesh.ops.convex_hull` of a coarse `sec(π/n)`-inflated cage (containment 5.9e-08, watertight, positive signed volume, Euler 2, per-piece 255-face budget: body 70, caps 60×3, compound 250); a hull of the dense render mesh measures 374 faces — over budget — which is why pipelines hull cages; proud details cost cage rows, concave grooves are free; byte-identical on 4.5.11 and 5.1.2
+- Custom-normals / shade-by-angle witness (game prop shading) — `use_auto_smooth` removed in 4.1; assert what 4.5 vs 5.1 actually expose (`shade_smooth_by_angle` operator / Smooth-by-Angle node group), per-loop custom normals surviving depsgraph evaluation, unit-length basis, hard edges landing exactly where an independently recomputed dihedral angle crosses the threshold
+- prop-origin-transform witness — origin to base center, `transform_apply` through the data API, delta transforms, `matrix_parent_inverse` so parented children do not teleport; closed forms: post-apply scale exactly (1,1,1), local bbox min Z == 0, world bbox unchanged (builds on `parent-inverse-orrery`, does not duplicate it)
+- mesh-hygiene-audit witness — the engine-ingest checklist as executable contract: no ngons, no loose vertices, no non-manifold edges, no zero-area faces, consistent outward winding; closed forms via Euler characteristic and exact edge-face incidence counts
## Future (uncommitted)
diff --git a/docs/gallery/assets/collision-hull-proxy-hero.webp b/docs/gallery/assets/collision-hull-proxy-hero.webp
new file mode 100644
index 0000000..ed5dcc1
Binary files /dev/null and b/docs/gallery/assets/collision-hull-proxy-hero.webp differ
diff --git a/docs/gallery/collision-hull-proxy/index.html b/docs/gallery/collision-hull-proxy/index.html
new file mode 100644
index 0000000..8f468ec
--- /dev/null
+++ b/docs/gallery/collision-hull-proxy/index.html
@@ -0,0 +1,757 @@
+
+
+
+
+
+ collision-hull-proxy — Examples — Blender Developer Tools
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Skip to content
+
A fire hydrant street prop inside its compound collision shell: four convex pieces hulled by bmesh.ops.convex_hull from a coarse inflated cage. The dense render mesh is never hulled - its hull would measure 368 faces, over the 255-face per-piece engine budget. Closed-form plane tests prove containment, convexity, watertightness, outward winding, and Euler characteristic 2 per piece.
+
+
+
+
Rendered headless by the example itself — click to zoom.
+
witnesses A hull of the dense render mesh measures 368 faces, over the 255-face per-piece budget - the cage is a coarser lathe with rings inflated by sec(pi/n) so each cage ring circumscribes its render ring exactly (containment 5.9e-08). Proud details cost cage rows; concave grooves are free under the hull. Piece budgets: body 70, caps 60+60+60, compound 250.
A runnable example that builds a fire hydrant street prop — lathed barrel, bonnet dome, three outlet caps, operating nut — plus the collision a game prop pipeline ingests: a compound of four convex hull pieces, one per part group, each built with bmesh.ops.convex_hull from a coarse collision cage, following mesh-editing-and-bmesh.
+
Pipeline arc neighbor: LODs in lod-decimate-chain, tangent space in triangulate-tangents, export in gltf-export-roundtrip — collision bounds are the piece an engine's physics ingest checks first, and the piece whose failure rejects the whole prop.
+
Scope: this witnesses the bpy-level contract a prop pipeline relies on (watertight convex pieces that enclose the render mesh within budget). It is not an engine exporter and does not produce a shippable FiveM/engine asset — it proves the geometry properties such an asset must have.
+
What it witnesses: engines (GTA/FiveM-style prop workflows included) ingest collision as a *compound of convex pieces*, each piece under a per-piece face limit (255 is the common cap). The example builds that structure and derives every property from closed forms, per piece:
+
Containment. Every render-mesh vertex lies on the inner side of every face plane of its piece's hull — max excursion 5.9e-08 (tol 2e-4). This holds by construction, not luck: the cage is a coarse lathe whose rings are inflated by sec(π/n), so each n-gon cage ring *circumscribes* its render ring exactly. Proud details (a rim, a nut) must be paid for in cage rows; concave details (the grooves) are free — the hull bridges over them. That trade-off is the collision-authoring lesson.
Convexity. The same plane test restricted to the hull's own vertices (5.0e-08) — what a convex solver assumes when it uploads the piece.
Watertight + manifold. Every hull edge borders exactly two faces, and V − E + F = 2: a convex hull is a topological sphere.
Outward winding. Positive signed volume by the divergence theorem (body piece 0.968858) — inverted collision is a physics no-op.
Budget. Every piece ≤ 255 faces: body 70, pumper 60, side caps 60+60; the compound totals 250. The naive approach fails this — a hull of the *dense render mesh* measures 368 faces, over budget, which is why real pipelines hull a cage, never the render mesh.
+
What each check catches on failure: an artist editing the render mesh after the hull was generated (probe: pumper lug stretched 2×, exit 3, measured escape 0.730000); inverted hull winding (probe: polygons flipped, exit 5, signed volume −0.968858, the exact negation); a non-watertight piece (probe: one face deleted, exit 4, three border edges).
+
Version witness: output is byte-identical on Blender 4.5.11 LTS and 5.1.2 — same piece counts, same volumes, same excursions. bmesh.ops.convex_hull and foreach_get are stable across the pair.
+
The render shows the prop inside its four faceted translucent hull pieces: if any piece failed to enclose its geometry, painted metal would poke through the shell in frame.
+
Run
+
# Cheap correctness check (no render) — the CI check:
+blender --background --python collision_hull_proxy.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python collision_hull_proxy.py -- --output hydrant.png
+blender --background --python collision_hull_proxy.py -- --output hydrant.png --engine cycles
+
It exits non-zero on failure (render geometry escaping a hull, inverted winding, a non-watertight or non-convex piece, Euler drift, or a piece over the 255-face budget). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.
+
+
+
Source
+
+ examples/collision-hull-proxy/collision_hull_proxy.py
+ View on GitHub →
+
+
"""A fire hydrant prop plus its convex collision proxies — a runnable example.
+
+Witnesses the collision contract every game prop pipeline (engines generally,
+FiveM/GTA-style prop workflows specifically) depends on: a prop's collision is
+a COMPOUND of convex pieces, each piece a watertight, outward-wound hull that
+fully encloses its render geometry and stays inside the engine's per-piece
+face budget. Each piece is built with `bmesh.ops.convex_hull` from a coarse
+collision cage — never from the dense render mesh, whose hull would blow the
+budget — and the check derives every property from closed forms:
+
+ containment — every render-mesh vertex on the inner side of every face
+ plane of its piece's hull (max excursion printed, tol 2e-4)
+ convexity — the same plane test restricted to the hull's own vertices
+ watertight — every hull edge borders exactly two faces
+ winding — positive signed volume (divergence theorem)
+ manifold — Euler characteristic V - E + F == 2 (a convex hull is a
+ topological sphere)
+ budget — every piece <= 255 faces (the common per-piece convex limit
+ engines impose on collision meshes)
+
+The cage is a coarser lathe whose profile rings are inflated by sec(pi / n)
+so its n-gon rings circumscribe the render rings exactly — that is why
+containment holds with a measured margin of ~0 instead of by luck. Concave
+details (grooves) never touch the hull; proud details cost hull faces. That
+trade-off IS the collision-authoring lesson.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+ blender --background --python collision_hull_proxy.py -- # check only
+ blender --background --python collision_hull_proxy.py -- --output h.png # + render
+"""
+import bpy, bmesh, sys, os, math, argparse
+from mathutils import Vector
+
+SEGMENTS = 48# lathe resolution of the render mesh
+CAGE_SEG_BODY = 8# collision cage resolutions — the hull face budget
+CAGE_SEG_CAP = 8# is spent through these, not through SEGMENTS
+CAGE_SEG_NUT = 5
+HULL_BUDGET = 255# engine per-piece convex face limit
+TOL = 2e-4# plane-test tolerance: cage circumscribes render exactly
+
+# ---------------------------------------------------------------------------
+# Prop construction. The hydrant is lathed around Z; caps are lathed around Z
+# too and tipped onto their sides by the object rotation. All dimensions are
+# invented for this prop — no real-world design is referenced.
+# ---------------------------------------------------------------------------
+
+# (radius, height) render profile of body + bonnet, bottom pole to top pole.
+# Ribs are GROOVES: concave details are free under a convex hull, proud ones
+# would have to be paid for in cage rows.
+BODY_PROFILE = [
+ (0.00, 0.000),
+ (0.60, 0.000), # base flange edge
+ (0.60, 0.045),
+ (0.575, 0.055), # flange groove (concave)
+ (0.575, 0.070),
+ (0.60, 0.080),
+ (0.60, 0.095), # flange top edge
+ (0.48, 0.115), # taper (convex curve, lies inside the cage chord)
+ (0.40, 0.155),
+ (0.37, 0.205), # neck
+ (0.35, 0.320), # barrel
+ (0.346, 0.545),
+ (0.334, 0.555), # barrel groove (concave)
+ (0.334, 0.575),
+ (0.346, 0.590),
+ (0.345, 0.880), # barrel top
+ (0.348, 0.930),
+ (0.336, 0.950), # upper groove (concave)
+ (0.336, 1.000),
+ (0.348, 1.030),
+ (0.36, 1.090), # shoulder under bonnet
+ (0.35, 1.150), # bonnet dome (concave curve — cage keeps every row)
+ (0.30, 1.220),
+ (0.21, 1.280),
+ (0.10, 1.315),
+ (0.00, 1.325),
+]
+
+# Cage silhouette: every convex corner of the render profile, nothing else.
+# sec(pi/8) inflation makes each 8-gon cage ring circumscribe its render ring.
+CAGE_BODY_PROFILE = [
+ (0.00, 0.000),
+ (0.60, 0.000),
+ (0.60, 0.095),
+ (0.37, 0.205),
+ (0.345, 0.880),
+ (0.36, 1.090),
+ (0.35, 1.150),
+ (0.30, 1.220),
+ (0.21, 1.280),
+ (0.10, 1.315),
+ (0.00, 1.325),
+]
+
+NUT_PROFILE = [(0.09, 1.325), (0.072, 1.40), (0.0, 1.40)]
+
+# Side caps at z=0.76, pumper (bigger) at z=0.66. A cap's outlet direction
+# equals its azimuth (degrees from +X); the camera sits near azimuth 292, so
+# the pumper faces it and the side caps read on the flanks.
+SIDE_CAPS = [(0.66, 315.0), (0.76, 45.0), (0.76, 165.0)]
+PUMPER = 0# index of the large front outlet in SIDE_CAPS
+
+
+def cap_profiles(r_cap, reach):
+ """Render and cage (r, z) profiles for one outlet cap. The rim flare is
+ 1.10x the barrel radius so the dense rim rows lie inside the cage chord."""
+ render = [
+ (0.0, 0.0),
+ (r_cap, 0.0),
+ (r_cap, reach - 0.05),
+ (r_cap * 1.10, reach - 0.03), # rim
+ (r_cap * 1.10, reach),
+ (r_cap * 0.72, reach + 0.045), # cap dome
+ (0.0, reach + 0.06),
+ ]
+ cage = [
+ (0.0, 0.0),
+ (r_cap, 0.0),
+ (r_cap * 1.10, reach - 0.03),
+ (r_cap * 1.10, reach),
+ (r_cap * 0.72, reach + 0.045),
+ (0.0, reach + 0.06),
+ ]
+ return render, cage
+
+
+def signed_volume(me):
+ """Divergence-theorem volume; positive when face winding points outward."""
+ vol = 0.0
+ for p in me.polygons:
+ vs = [me.vertices[i].co for i in p.vertices]
+ v0 = vs[0]
+ for i in range(1, len(vs) - 1):
+ vol += v0.dot(vs[i].cross(vs[i + 1])) / 6.0
+ return vol
+
+
+def lathe_object(name, profile, segments, inflate=1.0):
+ """Spin an (r, z) profile around Z. r == 0 ends become pole vertices, so
+ the mesh closes with fans instead of degenerate rings. `inflate` scales
+ radii only — the collision cage uses sec(pi / segments) so its polygon
+ rings circumscribe the render rings exactly."""
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ bot = top = None
+ if profile[0][0] == 0.0:
+ bot = bm.verts.new((0.0, 0.0, profile[0][1]))
+ profile = profile[1:]
+ if profile[-1][0] == 0.0:
+ top = bm.verts.new((0.0, 0.0, profile[-1][1]))
+ profile = profile[:-1]
+ rings = []
+ for i in range(segments):
+ a = 2.0 * math.pi * i / segments
+ ca, sa = math.cos(a), math.sin(a)
+ rings.append([bm.verts.new((r * inflate * ca, r * inflate * sa, z))
+ for r, z in profile])
+ for i in range(segments):
+ j = (i + 1) % segments
+ for k in range(len(profile) - 1):
+ bm.faces.new((rings[i][k], rings[j][k], rings[j][k + 1], rings[i][k + 1]))
+ if bot isnotNone:
+ bm.faces.new((rings[j][0], rings[i][0], bot))
+ if top isnotNone:
+ bm.faces.new((rings[i][-1], rings[j][-1], top))
+ bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+ # recalc picks a consistent winding but not a direction; outward is
+ # pinned below by the same signed-volume form the check uses.
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ obj = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(obj)
+ if signed_volume(me) < 0.0:
+ for p in me.polygons:
+ p.flip()
+ return obj
+
+
+def make_material(name, color, metallic, roughness):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF"]
+ bsdf.inputs["Base Color"].default_value = (*color, 1.0)
+ bsdf.inputs["Metallic"].default_value = metallic
+ bsdf.inputs["Roughness"].default_value = roughness
+ return mat
+
+
+def build_hydrant():
+ """Builds the prop and its collision cages. Returns a list of collision
+ groups: {"name", "render": [objects], "cage": [objects]} — one convex
+ hull piece per group, the compound-collision structure engines ingest."""
+ bpy.ops.wm.read_factory_settings(use_empty=True)
+ red = make_material("EnamelRed", (0.48, 0.035, 0.022), 0.15, 0.30)
+ yellow = make_material("EnamelYellow", (0.82, 0.53, 0.05), 0.15, 0.32)
+ iron = make_material("CastIron", (0.05, 0.052, 0.06), 0.9, 0.48)
+
+ inflate_body = 1.0 / math.cos(math.pi / CAGE_SEG_BODY)
+ inflate_cap = 1.0 / math.cos(math.pi / CAGE_SEG_CAP)
+ inflate_nut = 1.0 / math.cos(math.pi / CAGE_SEG_NUT)
+
+ # --- body group: body + bonnet + operating nut ---
+ body = lathe_object("Body", BODY_PROFILE, SEGMENTS)
+ body.data.materials.append(red)
+ body.data.materials.append(yellow)
+ for p in body.data.polygons: # the bonnet dome is the yellow profile tail
+ if p.center.z > 1.09:
+ p.material_index = 1
+ nut = lathe_object("Nut", NUT_PROFILE, 5)
+ nut.data.materials.append(iron)
+ body_cage = lathe_object("BodyCage", CAGE_BODY_PROFILE, CAGE_SEG_BODY, inflate_body)
+ nut_cage = lathe_object("NutCage", NUT_PROFILE, CAGE_SEG_NUT, inflate_nut)
+ groups = [{"name": "body", "render": [body, nut], "cage": [body_cage, nut_cage]}]
+
+ # --- one group per outlet cap ---
+ for idx, (z, azim) in enumerate(SIDE_CAPS):
+ big = idx == PUMPER
+ r_cap = 0.19if big else0.145
+ reach = 0.62if big else0.56
+ prof_r, prof_c = cap_profiles(r_cap, reach)
+ cap = lathe_object("PumperCap"if big elsef"SideCap{idx}", prof_r, 24)
+ cap.data.materials.append(red)
+ lug_prof = [(0.075, reach + 0.05), (0.07, reach + 0.11), (0.0, reach + 0.11)]
+ lug = lathe_object(f"Lug{idx}", lug_prof, 6)
+ lug.data.materials.append(iron)
+ cap_cage = lathe_object(f"CapCage{idx}", prof_c, CAGE_SEG_CAP, inflate_cap)
+ lug_cage = lathe_object(f"LugCage{idx}", lug_prof, CAGE_SEG_CAP, inflate_cap)
+ rot = (math.radians(90), 0.0, math.radians(azim + 90.0))
+ for o in (cap, lug, cap_cage, lug_cage):
+ o.rotation_euler = rot
+ o.location = (0.0, 0.0, z)
+ groups.append({"name": "pumper"if big elsef"side{idx}",
+ "render": [cap, lug], "cage": [cap_cage, lug_cage]})
+ return groups
+
+
+# ---------------------------------------------------------------------------
+# Hull construction and the contract checks.
+# ---------------------------------------------------------------------------
+
+def collect_points(objects):
+ """World-space vertex cloud, read via foreach_get."""
+ bpy.context.view_layer.update() # matrix_world lags RNA writes until evaluated
+ pts = []
+ for o in objects:
+ me = o.data
+ buf = [0.0] * (len(me.vertices) * 3)
+ me.vertices.foreach_get("co", buf)
+ mw = o.matrix_world
+ for i in range(0, len(buf), 3):
+ pts.append(mw @ Vector((buf[i], buf[i + 1], buf[i + 2])))
+ return pts
+
+
+def build_hull(name, points):
+ """Convex hull of a point cloud via bmesh.ops.convex_hull."""
+ me = bpy.data.meshes.new(name)
+ bm = bmesh.new()
+ try:
+ verts = [bm.verts.new(p) for p in points]
+ bmesh.ops.convex_hull(bm, input=verts)
+ # the op leaves interior input verts behind; drop any vert not
+ # referenced by a face so V - E + F is meaningful
+ used = {v for f in bm.faces for v in f.verts}
+ for v in list(bm.verts):
+ if v notin used:
+ bm.verts.remove(v)
+ bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
+ bm.to_mesh(me)
+ finally:
+ bm.free()
+ obj = bpy.data.objects.new(name, me)
+ bpy.context.collection.objects.link(obj)
+ return obj
+
+
+def max_plane_excursion(me, points):
+ """Largest signed distance of any point to the OUTER side of any face
+ plane. <= 0 means every point is inside or on the hull."""
+ worst = -math.inf
+ where = None
+ for p in me.polygons:
+ n = p.normal
+ c = p.center
+ for q in points:
+ d = (q - c).dot(n)
+ if d > worst:
+ worst = d
+ where = (p.index, tuple(round(v, 4) for v in q))
+ return worst, where
+
+
+def check(pieces):
+ """pieces: [(group_name, hull_obj, render_points)]. Returns exit code."""
+ total_faces = 0
+ for name, hull, points in pieces:
+ me = hull.data
+ nv, ne, nf = len(me.vertices), len(me.edges), len(me.polygons)
+ total_faces += nf
+
+ bm = bmesh.new() # watertight: every edge borders exactly two faces
+ try:
+ bm.from_mesh(me)
+ bad_edges = sum(1for e in bm.edges if len(e.link_faces) != 2)
+ finally:
+ bm.free()
+ if bad_edges:
+ print(f"ERROR: piece {name}: {bad_edges} hull edge(s) do not border "
+ f"exactly two faces — proxy is not watertight", file=sys.stderr)
+ return4
+
+ vol = signed_volume(me) # winding: positive signed volume
+ if vol <= 0.0:
+ print(f"ERROR: piece {name}: signed volume {vol:.6f} <= 0 — hull "
+ f"winding is inverted", file=sys.stderr)
+ return5
+
+ contain_err, where = max_plane_excursion(me, points)
+ if contain_err > TOL:
+ print(f"ERROR: piece {name}: render vertex escapes its hull by "
+ f"{contain_err:.6f} (tol {TOL}) at face {where[0]}, point "
+ f"{where[1]} — proxy does not enclose the render mesh",
+ file=sys.stderr)
+ return3
+
+ hull_pts = [v.co.copy() for v in me.vertices]
+ hull_err, _ = max_plane_excursion(me, hull_pts)
+ if hull_err > TOL:
+ print(f"ERROR: piece {name}: hull vertex off its own face plane by "
+ f"{hull_err:.6f} — proxy is not convex", file=sys.stderr)
+ return6
+
+ euler = nv - ne + nf # a convex hull is a topological sphere
+ if euler != 2:
+ print(f"ERROR: piece {name}: Euler characteristic {euler} != 2 — "
+ f"hull is not a closed manifold sphere", file=sys.stderr)
+ return7
+
+ if nf > HULL_BUDGET:
+ print(f"ERROR: piece {name}: {nf} faces, over the {HULL_BUDGET}-face "
+ f"per-piece collision budget", file=sys.stderr)
+ return8
+
+ print(f"piece {name}: verts={nv} edges={ne} faces={nf} watertight=True "
+ f"euler=2 volume={vol:.6f} containment={contain_err:.3e}"
+ f"convexity={hull_err:.3e} budget={nf}<={HULL_BUDGET}")
+
+ print(f"compound: pieces={len(pieces)} total_faces={total_faces}"
+ f"(per-piece budget {HULL_BUDGET}; engine ingests the compound)")
+ return0
+
+
+# ---------------------------------------------------------------------------
+# Render: dark-studio staging per docs/VISUAL-STYLE.md. The hull pieces draw
+# as faceted translucent shells with a thin wire overlay; if a piece failed
+# to enclose its geometry, painted metal would poke through the shell.
+# ---------------------------------------------------------------------------
+
+def eevee_engine_id():
+ return'BLENDER_EEVEE'if bpy.app.version >= (5, 0, 0) else'BLENDER_EEVEE_NEXT'
+
+
+def render_still(groups, pieces, path, engine):
+ scene = bpy.context.scene
+ for g in groups:
+ for o in g["render"]:
+ for p in o.data.polygons:
+ p.use_smooth = True
+ for g in groups: # cages exist only to build hulls; never rendered
+ for o in g["cage"]:
+ o.hide_render = True
+
+ # translucent shell: Transparent mixed over Principled works identically
+ # in EEVEE and Cycles without touching blend-method RNA (renamed in 4.2)
+ shell = bpy.data.materials.new("HullShell")
+ shell.use_nodes = True
+ nt = shell.node_tree
+ nt.nodes.clear()
+ out = nt.nodes.new("ShaderNodeOutputMaterial")
+ mix = nt.nodes.new("ShaderNodeMixShader")
+ mix.inputs[0].default_value = 0.14# 86% transparent: output =
+ # (1-Fac)*Transparent + Fac*Principled
+ transp = nt.nodes.new("ShaderNodeBsdfTransparent")
+ bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
+ bsdf.inputs["Base Color"].default_value = (0.16, 0.55, 0.75, 1.0)
+ bsdf.inputs["Roughness"].default_value = 0.12
+ nt.links.new(transp.outputs[0], mix.inputs[1])
+ nt.links.new(bsdf.outputs[0], mix.inputs[2])
+ nt.links.new(mix.outputs[0], out.inputs[0])
+
+ wire_mat = make_material("HullWire", (0.35, 0.85, 1.0), 0.0, 0.4)
+ wbsdf = wire_mat.node_tree.nodes["Principled BSDF"]
+ wbsdf.inputs["Emission Color"].default_value = (0.2, 0.65, 0.85, 1.0)
+ wbsdf.inputs["Emission Strength"].default_value = 0.6
+
+ for name, hull, _ in pieces:
+ hull.data.materials.append(shell)
+ for p in hull.data.polygons:
+ p.use_smooth = False# facets must read as facets
+ hull.data.materials.append(wire_mat)
+ wire = bpy.data.objects.new(f"{name}Wire", hull.data)
+ mod = wire.modifiers.new("Wire", 'WIREFRAME')
+ mod.thickness = 0.010
+ mod.material_offset = 1
+ scene.collection.objects.link(wire)
+
+ 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 = make_material("Studio", (0.03, 0.032, 0.037), 0.0, 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, 11.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)
+
+ # key/fill/rim/wedge per docs/VISUAL-STYLE.md
+ light("Key", (-3.5, -4.5, 5.5), 500.0, 4.5, (1.0, 0.96, 0.9), (48, 0, -35))
+ light("Fill", (5.0, -3.5, 2.5), 110.0, 9.0, (0.75, 0.85, 1.0), (65, 0, 50))
+ light("Rim", (1.5, 4.5, 3.5), 300.0, 3.0, (0.6, 0.78, 1.0), (-55, 0, 170))
+ light("Wedge", (2.5, 5.5, 4.0), 380.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190))
+ # small warm glint off the camera-left shoulder: lifts the pumper cap face
+ light("Glint", (-1.5, -4.0, 3.5), 260.0, 2.0, (1.0, 0.9, 0.75), (55, 0, -15))
+
+ cam_data = bpy.data.cameras.new("Cam")
+ cam_data.lens = 52.0
+ cam = bpy.data.objects.new("Cam", cam_data)
+ cam.location = (2.05, -4.5, 1.85)
+ scene.collection.objects.link(cam)
+ target = bpy.data.objects.new("Aim", None)
+ target.location = (0.0, 0.0, 0.72)
+ 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 = 48
+ 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 flatten the enamel 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)
+
+ groups = build_hydrant()
+ pieces = []
+ for g in groups:
+ hull = build_hull(f"{g['name']}Hull", collect_points(g["cage"]))
+ pieces.append((g["name"], hull, collect_points(g["render"])))
+ code = check(pieces)
+ if code:
+ return code
+
+ if args.output:
+ ifnot render_still(groups, pieces, os.path.abspath(args.output), args.engine):
+ print("ERROR: render produced no file", file=sys.stderr)
+ return9
+ print(f"rendered still {args.output}")
+
+ print("collision-hull-proxy OK")
+ return0
+
+
+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/docs/gallery/contact-sheets/collision-hull-proxy-contact-sheet.webp b/docs/gallery/contact-sheets/collision-hull-proxy-contact-sheet.webp
new file mode 100644
index 0000000..52fff97
Binary files /dev/null and b/docs/gallery/contact-sheets/collision-hull-proxy-contact-sheet.webp differ
diff --git a/docs/gallery/index.html b/docs/gallery/index.html
index 1954e0a..f93e03d 100644
--- a/docs/gallery/index.html
+++ b/docs/gallery/index.html
@@ -535,6 +535,17 @@
A fire hydrant street prop inside its compound collision shell: four convex pieces hulled by bmesh.ops.convex_hull from a coarse inflated cage. The dense render mesh is never hulled - its hull would measure 368 faces, over the 255-face per-piece engine budget. Closed-form plane tests prove containment, convexity, watertightness, outward winding, and Euler characteristic 2 per piece.
+
witnesses A hull of the dense render mesh measures 368 faces, over the 255-face per-piece budget - the cage is a coarser lathe with rings inflated by sec(pi/n) so each cage ring circumscribes its render ring exactly (containment 5.9e-08). Proud details cost cage rows; concave grooves are free under the hull. Piece budgets: body 70, caps 60+60+60, compound 250.