diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index ef18564..bfb0dda 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -62,6 +62,7 @@ "examples/armature-bend", "examples/bmesh-gear", "examples/color-attribute-wheel", + "examples/collision-hull-proxy", "examples/compositor-glare", "examples/curve-bevel-arc", "examples/damped-track-aim", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index e4b6a20..c3d41b5 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -448,3 +448,15 @@ jobs: # (ld.light_linking is an AttributeError). Exits non-zero on failure. xvfb-run -a "$BLENDER" --background \ --python examples/light-link-studio/light_link_studio.py -- + + - name: Shipped example - collision hull proxy (compound convex pieces) + run: | + set -euo pipefail + # Check only (no render): a lathed fire hydrant prop plus its + # four-piece compound collision, each piece a bmesh convex_hull of + # a coarse sec(pi/n)-inflated cage; asserts per-piece watertightness, + # positive signed volume, render-vertex containment and hull + # convexity by closed-form plane tests, Euler characteristic 2, and + # the 255-face per-piece engine budget. Exits non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/collision-hull-proxy/collision_hull_proxy.py -- diff --git a/README.md b/README.md index 3da8beb..713bf3b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  30 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  31 examples

@@ -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: a red fire hydrant street prop with a yellow bonnet on a dark studio floor, enclosed in four faceted translucent cyan collision shells - the compound convex hull set a game engine ingests + + +### [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 + + + + + + + + + + + + + + + + + +
+ Examples Gallery +
+ GitHub +
+
+
+

collision-hull-proxy

+

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.
+
+
blender --background --python examples/collision-hull-proxy/collision_hull_proxy.py --
+ +
+
+

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 is not None:
+                bm.faces.new((rings[j][0], rings[i][0], bot))
+            if top is not None:
+                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.19 if big else 0.145
+        reach = 0.62 if big else 0.56
+        prof_r, prof_c = cap_profiles(r_cap, reach)
+        cap = lathe_object("PumperCap" if big else f"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 else f"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 not in 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(1 for 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)
+            return 4
+
+        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)
+            return 5
+
+        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)
+            return 3
+
+        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)
+            return 6
+
+        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)
+            return 7
+
+        if nf > HULL_BUDGET:
+            print(f"ERROR: piece {name}: {nf} faces, over the {HULL_BUDGET}-face "
+                  f"per-piece collision budget", file=sys.stderr)
+            return 8
+
+        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)")
+    return 0
+
+
+# ---------------------------------------------------------------------------
+# 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:
+        if not render_still(groups, pieces, os.path.abspath(args.output), args.engine):
+            print("ERROR: render produced no file", file=sys.stderr)
+            return 9
+        print(f"rendered still {args.output}")
+
+    print("collision-hull-proxy 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/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 @@

light-link-studio

View example +
+ + collision-hull-proxy — A fire hydrant street prop inside its compound collision shell: four convex pieces hulled by bmesh.ops.convex_hull from a coarse inflated cage. + +
+

collision-hull-proxy

+

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.

+ View example +
+