diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 3bb4cca..21616c1 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -61,6 +61,7 @@ "examples": [ "examples/armature-bend", "examples/bmesh-gear", + "examples/car-mirror-symmetry", "examples/color-attribute-wheel", "examples/collision-hull-proxy", "examples/compositor-glare", diff --git a/.github/workflows/blender-smoke.yml b/.github/workflows/blender-smoke.yml index 3b85ec4..d232c3a 100644 --- a/.github/workflows/blender-smoke.yml +++ b/.github/workflows/blender-smoke.yml @@ -529,3 +529,16 @@ jobs: # failure. xvfb-run -a "$BLENDER" --background \ --python examples/soccer-ball-goldberg/soccer_ball_goldberg.py -- + + - name: Shipped example - car mirror symmetry (Mirror modifier + depsgraph) + run: | + set -euo pipefail + # Check only (no render): a hatchback lofted as one half and + # completed by the Mirror modifier; asserts the datablock keeps the + # half (126/231/106, 28 centerline verts), the evaluated mesh is + # exactly 2n-c (224/434/212), watertight with Euler 2, every + # evaluated vertex has an exact negated-X partner, and wheels/lamps + # mirror about object origins parked on the symmetry plane. Exits + # non-zero on failure. + xvfb-run -a "$BLENDER" --background \ + --python examples/car-mirror-symmetry/car_mirror_symmetry.py -- diff --git a/README.md b/README.md index 5526a24..55d8a34 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  37 examples + 12 skills  •  6 rules  •  2 templates  •  17 snippets  •  38 examples

@@ -36,7 +36,7 @@ ## Overview -This repository ships **12 skills, 6 rules, 2 templates, 17 snippets, and 37 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 38 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. @@ -720,6 +720,25 @@ bound by face vertex count, never enumeration order. Sibling to witnesses polyhedral invariants plus per-face-class material binding. The smoothed render inverts on sight if the panel binding does. + + + + +Car mirror symmetry: a red low-poly hatchback on a dark studio floor, light windshield band and white headlamp pair — lofted as one half and completed by the Mirror modifier evaluated through the depsgraph, proving 2n-c counts, exact negated-X partners, a welded watertight centerline, and wheels mirrored about origins on the plane + + + +### [car-mirror-symmetry](examples/car-mirror-symmetry/) + +The Mirror + depsgraph contract: the datablock holds only the authored half +(126/231/106, 28 centerline verts) while the evaluated mesh is the welded +whole — exactly `2n − c` verts, watertight with Euler 2, every vertex paired +at negated X (deviation 0.0). Wheels and lamps mirror about object origins +parked **on** the symmetry plane — offset the data, never the object. +Companion to [`depsgraph-export`](examples/depsgraph-export/) (evaluated-vs- +original) and [`shape-key-blend`](examples/shape-key-blend/) (non-destructive +evaluation). Break the mirror and the render is literally half a car. + diff --git a/ROADMAP.md b/ROADMAP.md index 6a7f5ab..eb8fe4b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -121,7 +121,7 @@ Not committed; target list for the next content version. (v0.3.0 shipped the smo - Freestyle SVG / line-set witness: Freestyle line set on a silhouette (full render pass; prefer Line Art first) - ~~prop-origin-transform witness~~ **SHIPPED** as `examples/prop-origin-transform/` — origin to base center + data-API scale apply + `matrix_parent_inverse` for a bolted accessory; scale `(1,1,1)`, local `min.z==0`, world AABB delta 0, bare jump ~0.43 m, MPI err ~3e-8; stale `matrix_world` until `view_layer.update()`; byte-identical on 4.5.11 and 5.1.2 - ~~Goldberg polyhedron topology witness~~ **SHIPPED** as `examples/soccer-ball-goldberg/` — soccer ball as a truncated icosahedron: bmesh icosphere cut at 1/3 per edge, faces ordered by link-topology walks; closed forms 60/90/32 + Euler 2, census 12×5+20×6, uniform degree 3, edge dev 6.5e-06, planarity 1.9e-06 (independent Newell normal), circumsphere dev 9.0e-06, panels bound by face vertex count (enumeration-order probes exit 13); float32 noise floor ~9e-6 → 3e-5 gates; byte-identical on 4.5.11 and 5.1.2 -- Mirror-modifier symmetry witness: half-model completed by Mirror evaluated through the depsgraph — evaluated-vs-original counts (2n − welded centerline), exact ±X partner for every evaluated vertex, merge-threshold weld proof, failure dramatically visible (half a subject / split seam). Strongest subject: a generic vehicle body +- ~~Mirror-modifier symmetry witness: half-model completed by Mirror evaluated through the depsgraph~~ **SHIPPED** as `examples/car-mirror-symmetry/` — generic hatchback loft (14 stations × 9-point half-rings); datablock keeps the half (126/231/106, 28 centerline), evaluated is exactly 2n−c (224/434/212), watertight Euler 2, ±X partner dev 0.0; wheels/lamps mirror about origins parked on the plane; probes: merge off → 252 (exit 5), centerline off-plane → 27≠28 (exit 4), axis off → 126 half-car (exit 5), applied → exit 3; byte-identical on 4.5.11 and 5.1.2 - Chess-piece lathe witness: a turned piece (rook/pawn) via Screw modifier or bmesh spin from an authored profile — evaluated verts == profile × steps (minus pole merges), ring radii match the profile closed form, axis of revolution exact - Dice-pair per-face witness: material/attribute assignment driven by face normal direction, opposite-faces-sum-to-7 as an independently computed invariant, pip placement from a closed-form lattice - Bicycle-wheel radial witness: spokes via radial Array with object offset — exact angular spacing, spoke count, hub/rim concentricity, every evaluated spoke endpoint on its computed rim coordinate diff --git a/docs/gallery/assets/car-mirror-symmetry-hero.webp b/docs/gallery/assets/car-mirror-symmetry-hero.webp new file mode 100644 index 0000000..ac3430e Binary files /dev/null and b/docs/gallery/assets/car-mirror-symmetry-hero.webp differ diff --git a/docs/gallery/car-mirror-symmetry/index.html b/docs/gallery/car-mirror-symmetry/index.html new file mode 100644 index 0000000..d41ceea --- /dev/null +++ b/docs/gallery/car-mirror-symmetry/index.html @@ -0,0 +1,772 @@ + + + + + + car-mirror-symmetry — Examples — Blender Developer Tools + + + + + + + + + + + + + + + + + +

+ Examples Gallery +
+ GitHub +
+
+
+

car-mirror-symmetry

+

A generic hatchback lofted as one half (14 stations, 9-point rings) and completed by the Mirror modifier, evaluated through the depsgraph. Wheels and lamps mirror about object origins parked on the symmetry plane.

+
+
+ +

Rendered headless by the example itself — click to zoom.

+
witnesses Mirror is a modifier, not applied data: the original keeps the authored half while the evaluated mesh is the welded, watertight, exactly symmetric whole.
+
+
blender --background --python examples/car-mirror-symmetry/car_mirror_symmetry.py --
+ +
+
+

A runnable example that builds a generic hatchback as one half — a loft of 14 stations, each a 9-point half-ring from the bottom centerline out to the roof centerline — and completes it with a Mirror modifier, evaluated through the depsgraph, following depsgraph-and-evaluated-data. Wheels and lamps are separate objects mirrored the idiomatic way: the object origin sits on the symmetry plane and the mesh data is offset — mirror mirrors about the object's own origin, so you offset the data, never the object.

+

What it witnesses: the original datablock keeps only the authored half while the depsgraph carries the mirrored whole, and both directions are closed forms:

+ +

Why the checks target the modifier, not the base: mutating a non-plane base vertex cannot break the ±X pairing — the evaluated set is always half ∪ mirror(half), symmetric by construction. Realistic failures live in the modifier (merge off, axis off, modifier applied into data, origin off the plane), which is what the probes break.

+

Version witness: check output is byte-identical on Blender 4.5.11 LTS and 5.1.2. Mirror, evaluated_get / to_mesh / to_mesh_clear (no argument — passing the mesh raises TypeError on both), and TRACK_TO constraint behavior are stable across the pair; only the EEVEE engine id is version-gated.

+

The render is the proof: hide the mirror and the still is literally half a car — halved windshield and hood at the centerline, one headlamp. Render notes: the loft is faceted by design (flat shading, no bevel modifier — the modifier stack is Mirror only, so counts stay closed-form); the window band is glass by construction class (steepest roof-rise slope is the windshield, steepest drop the rear window, ring segment 5 the side windows), and the glass is dielectric — metallic glass mirrors the key light and renders the windshield as a hot salmon slab.

+

Run

+
# Cheap correctness check (no render) — the CI check:
+blender --background --python car_mirror_symmetry.py --
+
+# Also render a still (EEVEE on a GPU host; use --engine cycles on GPU-less hosts):
+blender --background --python car_mirror_symmetry.py -- --output car.png
+blender --background --python car_mirror_symmetry.py -- --output car.png --engine cycles
+

It exits non-zero on failure (applied mirror, doubled centerline, unwelded seam, broken symmetry, or a mirrored part off its plane origin). The blender-smoke workflow runs the check on Blender 4.5 LTS and 5.1.

+
+
+

Source

+
+ examples/car-mirror-symmetry/car_mirror_symmetry.py + View on GitHub → +
+
"""A generic hatchback built as one half and completed by the Mirror modifier
+— a runnable example.
+
+Witnesses the Mirror + depsgraph contract from depsgraph-and-evaluated-data:
+the original datablock keeps only the authored half, while the depsgraph
+carries the mirrored whole. Closed forms: evaluated vertex count is exactly
+2n - c (c = welded centerline verts), every evaluated vertex has an exact
+partner at negated X, the merge threshold actually welds (no doubled
+centerline), the evaluated shell is watertight with Euler characteristic 2,
+and the wheels mirror about their object origins sitting ON the symmetry
+plane. Failure is dramatically visible: a car with one side missing.
+
+By default it runs only the correctness check (no render) — the CI smoke
+check. Pass --output to also render a still:
+
+    blender --background --python car_mirror_symmetry.py --                 # check only
+    blender --background --python car_mirror_symmetry.py -- --output c.png  # + render
+"""
+import bpy, bmesh, sys, os, math, argparse
+
+# --- closed-form construction parameters -----------------------------------
+# 14 loft stations along Y (front at -Y), each a 9-point half-ring from the
+# bottom centerline out and up to the roof centerline. Fields:
+# (y, w_side, z_floor, z_sill, z_side, w_glass, z_glass_base, w_roof, z_roof)
+STATIONS = [
+    (-1.95, 0.74, 0.34, 0.36, 0.52, 0.05, 0.62, 0.04, 0.64),  # nose tip
+    (-1.80, 0.83, 0.30, 0.32, 0.55, 0.08, 0.70, 0.07, 0.73),  # front bumper
+    (-1.65, 0.86, 0.28, 0.28, 0.54, 0.10, 0.72, 0.09, 0.74),
+    (-1.30, 0.87, 0.28, 0.62, 0.66, 0.11, 0.74, 0.10, 0.76),  # front arch peak
+    (-0.95, 0.86, 0.28, 0.28, 0.54, 0.12, 0.78, 0.11, 0.80),
+    (-0.60, 0.85, 0.28, 0.28, 0.55, 0.38, 0.90, 0.34, 0.95),  # hood -> cowl
+    (-0.30, 0.84, 0.28, 0.28, 0.56, 0.54, 1.02, 0.50, 1.30),  # windshield
+    ( 0.20, 0.83, 0.28, 0.28, 0.57, 0.60, 1.05, 0.56, 1.45),  # roof front
+    ( 0.70, 0.83, 0.28, 0.28, 0.57, 0.60, 1.05, 0.55, 1.44),  # roof rear
+    ( 0.95, 0.84, 0.28, 0.28, 0.56, 0.58, 1.04, 0.52, 1.38),
+    ( 1.30, 0.85, 0.28, 0.62, 0.68, 0.50, 1.00, 0.44, 1.24),  # rear arch peak
+    ( 1.65, 0.84, 0.28, 0.28, 0.55, 0.34, 0.95, 0.28, 1.10),  # hatch
+    ( 1.90, 0.80, 0.30, 0.32, 0.53, 0.12, 0.86, 0.11, 0.92),  # tail
+    ( 2.00, 0.72, 0.34, 0.36, 0.50, 0.05, 0.78, 0.04, 0.78),  # rear bumper
+]
+RING = 9                       # points per half-ring, p0 and p8 on the centerline
+WHEEL_SEG = 16
+WHEEL_RINGS = [                # (x, radius) profile: bead, tread, sidewall, rim, cap
+    (0.63, 0.30), (0.67, 0.33), (0.83, 0.33), (0.87, 0.30), (0.89, 0.19), (0.90, 0.15),
+]
+WHEEL_Y = (-1.3, 1.3)          # front / rear axle positions
+WHEEL_Z = 0.33                 # axle height == tire radius: wheels rest on z=0
+
+N_BODY = len(STATIONS) * RING                    # 126
+F_BODY = (len(STATIONS) - 1) * (RING - 1) + 2    # 104 quads + 2 caps
+E_BODY = (len(STATIONS) * (RING - 1)             # ring edges
+          + (len(STATIONS) - 1) * RING           # longitudinal edges
+          + 2)                                   # cap closing edges
+CENTERLINE = len(STATIONS) * 2                   # p0 + p8 per station: 28
+N_WHEEL = len(WHEEL_RINGS) * WHEEL_SEG           # 96
+F_WHEEL = (len(WHEEL_RINGS) - 1) * WHEEL_SEG + 1 # strips + hubcap ngon
+
+MERGE_THRESHOLD = 1.0e-3
+TOL_SYMM = 2.0e-5    # evaluated partner deviation (float32 storage; mirror copies exact)
+TOL_BBOX = 1.0e-5    # |min.x + max.x| on the evaluated body
+TOL_PLANE = 1.0e-6   # |x| this small counts as on the symmetry plane
+
+
+def half_ring(st):
+    """One 9-point half cross-section, bottom centerline -> roof centerline."""
+    y, w, zf, zsill, zside, wg, zgb, wr, zroof = st
+    return [
+        (0.0, y, zf),                 # p0 bottom centerline
+        (0.55 * w, y, zf - 0.02),     # p1 underbody
+        (0.95 * w, y, zsill),         # p2 sill (rises over wheel arches)
+        (w, y, zside),                # p3 lower door (widest)
+        (0.99 * w, y, zside + 0.10),  # p4 shoulder
+        (wg, y, zgb),                 # p5 greenhouse base
+        (wr, y, zroof),               # p6 roof edge
+        (0.55 * wr, y, zroof + 0.015),# p7 roof crown
+        (0.0, y, zroof),              # p8 top centerline
+    ]
+
+
+def _newell(pts):
+    nx = ny = nz = 0.0
+    for i, p in enumerate(pts):
+        q = pts[(i + 1) % len(pts)]
+        nx += (p[1] - q[1]) * (p[2] + q[2])
+        ny += (p[2] - q[2]) * (p[0] + q[0])
+        nz += (p[0] - q[0]) * (p[1] + q[1])
+    n = math.sqrt(nx * nx + ny * ny + nz * nz)
+    return (nx / n, ny / n, nz / n)
+
+
+def build_car():
+    bpy.ops.wm.read_factory_settings(use_empty=True)
+
+    # -- half body loft ------------------------------------------------------
+    rings = [half_ring(st) for st in STATIONS]
+    me = bpy.data.meshes.new("CarBodyHalf")
+    bm = bmesh.new()
+    try:
+        bv = [[bm.verts.new(co) for co in ring] for ring in rings]
+        faces = []
+        for i in range(len(STATIONS) - 1):
+            for k in range(RING - 1):
+                faces.append(bm.faces.new(
+                    (bv[i][k], bv[i][k + 1], bv[i + 1][k + 1], bv[i + 1][k])))
+        caps = [bm.faces.new(bv[0]), bm.faces.new(bv[-1])]
+        # winding: the probe side quad must face +X; flip everything if the
+        # loft convention came out inward (mirror does not fix winding)
+        probe = [v.co for v in (bv[8][3], bv[8][4], bv[9][4], bv[9][3])]
+        if _newell(probe)[0] < 0:
+            for f in faces + caps:
+                f.normal_flip()
+        # caps must point away from the body (front -Y, rear +Y)
+        if _newell([v.co for v in bv[0]])[1] > 0:
+            caps[0].normal_flip()
+        if _newell([v.co for v in bv[-1]])[1] < 0:
+            caps[1].normal_flip()
+        bm.to_mesh(me)
+    finally:
+        bm.free()  # the ownership contract from always-free-bmesh
+
+    body = bpy.data.objects.new("CarBody", me)
+    bpy.context.collection.objects.link(body)
+    mirror = body.modifiers.new("MirrorHalf", 'MIRROR')
+    mirror.use_axis[0] = True
+    mirror.use_clip = True            # centerline verts cannot leave x=0
+    mirror.use_mirror_merge = True    # weld the two halves shut
+    mirror.merge_threshold = MERGE_THRESHOLD
+
+    # -- wheels: own Mirror each, object origins ON the symmetry plane -------
+    wheels = []
+    for y in WHEEL_Y:
+        wme = bpy.data.meshes.new("WheelHalf")
+        bm = bmesh.new()
+        try:
+            wr = []
+            for x, r in WHEEL_RINGS:
+                wr.append([bm.verts.new(
+                    (x, r * math.cos(2.0 * math.pi * s / WHEEL_SEG),
+                     r * math.sin(2.0 * math.pi * s / WHEEL_SEG)))
+                    for s in range(WHEEL_SEG)])
+            for j in range(len(WHEEL_RINGS) - 1):
+                for s in range(WHEEL_SEG):
+                    bm.faces.new((wr[j][s], wr[j][(s + 1) % WHEEL_SEG],
+                                  wr[j + 1][(s + 1) % WHEEL_SEG], wr[j + 1][s]))
+            bm.faces.new(wr[-1])  # hubcap ngon
+            bm.to_mesh(wme)
+        finally:
+            bm.free()
+        wheel = bpy.data.objects.new("WheelFront" if y < 0 else "WheelRear", wme)
+        wheel.location = (0.0, y, WHEEL_Z)  # origin on the plane: mirror mirrors DATA
+        bpy.context.collection.objects.link(wheel)
+        wm = wheel.modifiers.new("MirrorHalf", 'MIRROR')
+        wm.use_axis[0] = True
+        wm.use_mirror_merge = True
+        wm.merge_threshold = MERGE_THRESHOLD
+        wheels.append(wheel)
+
+    # materials exist in both modes (slot layout is part of the scene contract)
+    for name in ("Paint", "Glass", "Trim", "Tire", "Hubcap", "Headlamp", "Taillamp"):
+        bpy.data.materials.new(name)
+    for mat_name in ("Paint", "Glass", "Trim"):
+        body.data.materials.append(bpy.data.materials[mat_name])
+    for w in wheels:
+        for mat_name in ("Tire", "Hubcap"):
+            w.data.materials.append(bpy.data.materials[mat_name])
+
+    # -- lamps: small mirrored boxes, same origin-on-plane idiom as wheels ---
+    def lamp(name, x0, x1, y0, y1, z0, z1):
+        lme = bpy.data.meshes.new(name + "Half")
+        bm = bmesh.new()
+        try:
+            bmesh.ops.create_cube(bm, size=1.0)
+            bmesh.ops.scale(bm, vec=(x1 - x0, y1 - y0, z1 - z0), verts=bm.verts)
+            bmesh.ops.translate(bm, vec=((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2),
+                                verts=bm.verts)
+            bm.to_mesh(lme)
+        finally:
+            bm.free()
+        ob = bpy.data.objects.new(name, lme)
+        bpy.context.collection.objects.link(ob)  # origin at world origin: on the plane
+        lm = ob.modifiers.new("MirrorHalf", 'MIRROR')
+        lm.use_axis[0] = True
+        lm.use_mirror_merge = True
+        lm.merge_threshold = MERGE_THRESHOLD
+        ob.data.materials.append(bpy.data.materials[name])
+        return ob
+
+    headlamp = lamp("Headlamp", 0.38, 0.78, -1.97, -1.88, 0.40, 0.50)
+    taillamp = lamp("Taillamp", 0.30, 0.70, 1.97, 2.01, 0.50, 0.62)
+
+    _assign_body_materials(body)
+    _assign_wheel_materials(wheels)
+    return {"body": body,
+            "mirrored": [(w, N_WHEEL, F_WHEEL) for w in wheels]
+                        + [(headlamp, 8, 6), (taillamp, 8, 6)]}
+
+
+def _assign_body_materials(body):
+    """Deterministic panel classes by construction position (not hand-picked):
+    side windows are ring segment 5 at cabin stations; the windshield and
+    rear window are the full slopes (segments 5..7) at the steepest roof-rise
+    and roof-drop station pairs; pillars/roof sides stay paint, underbody/
+    sill/caps/bumpers are trim, everything else paint."""
+    PAINT, GLASS, TRIM = 0, 1, 2
+    n_st = len(STATIONS)
+    dz = [STATIONS[i + 1][8] - STATIONS[i][8] for i in range(n_st - 1)]
+    shield_pair = max(range(n_st - 1), key=lambda i: dz[i])      # windshield slope
+    cabin = [i for i in range(n_st - 1)
+             if STATIONS[i][5] >= 0.30 and STATIONS[i + 1][5] >= 0.30]
+    rear_pair = min(cabin, key=lambda i: dz[i])                  # rear-window slope
+    for poly in body.data.polygons:
+        i, k = divmod(poly.index, RING - 1)
+        if i >= n_st - 1:            # cap ngons (front/rear)
+            poly.material_index = TRIM
+            continue
+        both_cabin = STATIONS[i][5] >= 0.30 and STATIONS[i + 1][5] >= 0.30
+        if both_cabin and (k == 5 or (i in (shield_pair, rear_pair) and 5 <= k <= 7)):
+            poly.material_index = GLASS
+        elif k <= 1 or (i in (0, n_st - 2) and k <= 2):
+            poly.material_index = TRIM       # underbody/sill + bumper bands
+        else:
+            poly.material_index = PAINT
+
+
+def _assign_wheel_materials(wheels):
+    for w in wheels:
+        for poly in w.data.polygons:
+            j = poly.index // WHEEL_SEG
+            poly.material_index = 1 if j >= len(WHEEL_RINGS) - 2 else 0
+
+
+def _eval_mesh(obj, dg):
+    ev = obj.evaluated_get(dg)
+    me = ev.to_mesh()
+    try:
+        verts = [(ev.matrix_world @ v.co) for v in me.vertices]
+        edges = len(me.edges)
+        faces = len(me.polygons)
+        yield_v = (verts, edges, faces)
+    finally:
+        ev.to_mesh_clear()  # no argument: clears this object's evaluated mesh
+    return yield_v
+
+
+def _symmetry_dev(verts, tol_plane):
+    """Max deviation between every vertex and its negated-X partner.
+
+    Buckets by rounded (y, z, |x|): on-plane verts must be alone in their
+    bucket; off-plane buckets must pair exactly one +X with one -X, and the
+    pair's coordinate deltas are the measured error."""
+    buckets = {}
+    for v in verts:
+        key = (round(v.y, 5), round(v.z, 5), round(abs(v.x), 5))
+        buckets.setdefault(key, []).append(v)
+    dev = 0.0
+    lone = 0
+    for key, members in buckets.items():
+        if key[2] <= tol_plane:
+            if len(members) != 1:
+                lone += 1
+            continue
+        pos = [m for m in members if m.x > 0]
+        neg = [m for m in members if m.x < 0]
+        if len(pos) != 1 or len(neg) != 1:
+            lone += 1
+            continue
+        p, n = pos[0], neg[0]
+        dev = max(dev, abs(p.x + n.x), abs(p.y - n.y), abs(p.z - n.z))
+    return dev, lone
+
+
+def check(objs):
+    body = objs["body"]
+    me = body.data
+
+    # 1. the original datablock holds ONLY the authored half
+    got = (len(me.vertices), len(me.edges), len(me.polygons))
+    if got != (N_BODY, E_BODY, F_BODY):
+        print(f"ERROR: body datablock {got} != half-model closed form "
+              f"{(N_BODY, E_BODY, F_BODY)} — the mirror must live in the "
+              f"modifier stack, not in applied data", file=sys.stderr)
+        return 3
+    c = sum(1 for v in me.vertices if abs(v.co.x) <= TOL_PLANE)
+    if c != CENTERLINE:
+        print(f"ERROR: {c} authored centerline verts != {CENTERLINE}",
+              file=sys.stderr)
+        return 4
+
+    bpy.context.view_layer.update()
+    dg = bpy.context.evaluated_depsgraph_get()
+    verts, e_eval, f_eval = _eval_mesh(body, dg)
+
+    # 2. evaluated counts: exactly 2n - c, and watertight Euler 2
+    want_v = 2 * N_BODY - CENTERLINE
+    if len(verts) != want_v:
+        print(f"ERROR: evaluated body has {len(verts)} verts != 2n-c = {want_v} "
+              f"— merge is not welding the centerline (doubled seam)", file=sys.stderr)
+        return 5
+    on_plane = sum(1 for v in verts if abs(v.x) <= TOL_PLANE)
+    if on_plane != CENTERLINE:
+        print(f"ERROR: {on_plane} evaluated on-plane verts != {CENTERLINE} "
+              f"(merge threshold must weld, not duplicate)", file=sys.stderr)
+        return 6
+    euler = len(verts) - e_eval + f_eval
+    if euler != 2:
+        print(f"ERROR: evaluated Euler {euler} != 2 — mirrored shell is not a "
+              f"closed solid", file=sys.stderr)
+        return 7
+    bm = bmesh.new()
+    try:
+        ev = body.evaluated_get(dg)
+        ev_me = ev.to_mesh()
+        try:
+            bm.from_mesh(ev_me)
+        finally:
+            ev.to_mesh_clear()
+        bad = sum(1 for e in bm.edges if len(e.link_faces) != 2)
+    finally:
+        bm.free()
+    if bad:
+        print(f"ERROR: {bad} non-manifold edge(s) in the evaluated shell",
+              file=sys.stderr)
+        return 8
+
+    # 3. every evaluated vertex has an exact negated-X partner
+    dev, lone = _symmetry_dev(verts, TOL_PLANE)
+    if lone:
+        print(f"ERROR: {lone} evaluated vert(s) lack a mirrored partner",
+              file=sys.stderr)
+        return 9
+    if dev > TOL_SYMM:
+        print(f"ERROR: mirror partner deviation {dev:.3e} > tol {TOL_SYMM:.1e}",
+              file=sys.stderr)
+        return 10
+    xmin = min(v.x for v in verts)
+    xmax = max(v.x for v in verts)
+    bbox_asym = abs(xmin + xmax)
+    if bbox_asym > TOL_BBOX:
+        print(f"ERROR: evaluated bbox asymmetric by {bbox_asym:.3e} "
+              f"(tol {TOL_BBOX:.1e})", file=sys.stderr)
+        return 11
+
+    # 4. mirrored parts (wheels, lamps): each mirrored about an object origin
+    # that sits ON the plane — the data is offset, the object is not
+    part_lines = []
+    for w, n_half, f_half in objs["mirrored"]:
+        if abs(w.location.x) > TOL_PLANE:
+            print(f"ERROR: {w.name} origin x={w.location.x} — mirror mirrors "
+                  f"about the object origin; it must sit on the plane",
+                  file=sys.stderr)
+            return 12
+        if len(w.data.vertices) != n_half or len(w.data.polygons) != f_half:
+            print(f"ERROR: {w.name} datablock "
+                  f"{(len(w.data.vertices), len(w.data.polygons))} != "
+                  f"{(n_half, f_half)}", file=sys.stderr)
+            return 13
+        wv, _, wf = _eval_mesh(w, dg)
+        if len(wv) != 2 * n_half or wf != 2 * f_half:
+            print(f"ERROR: {w.name} evaluated {(len(wv), wf)} != "
+                  f"{(2 * n_half, 2 * f_half)}", file=sys.stderr)
+            return 14
+        wdev, wlone = _symmetry_dev(wv, TOL_PLANE)
+        if wlone or wdev > TOL_SYMM:
+            print(f"ERROR: {w.name} partner check: {wlone} lone, dev {wdev:.3e}",
+                  file=sys.stderr)
+            return 15
+        if min(v.x for v in wv) >= 0.0:
+            print(f"ERROR: {w.name} evaluated mesh stayed on one side — "
+                  f"mirror produced no mirrored half", file=sys.stderr)
+            return 16
+        part_lines.append(f"{w.name} sym_dev={wdev:.3e}")
+
+    print(f"body half={got[0]}/{got[1]}/{got[2]} centerline={c} | "
+          f"eval={len(verts)}/{e_eval}/{f_eval} euler=2 manifold=True | "
+          f"sym_dev={dev:.3e} (tol {TOL_SYMM:.1e}) bbox_asym={bbox_asym:.3e}")
+    print("mirrored parts | " + " | ".join(part_lines)
+          + " | origins on plane, evaluated spans both sides")
+    return 0
+
+
+def eevee_engine_id():
+    return 'BLENDER_EEVEE' if bpy.app.version >= (5, 0, 0) else 'BLENDER_EEVEE_NEXT'
+
+
+def _finish_materials():
+    def principled(name):
+        m = bpy.data.materials[name]
+        m.use_nodes = True
+        return m.node_tree.nodes["Principled BSDF"]
+    p = principled("Paint")
+    p.inputs["Base Color"].default_value = (0.48, 0.015, 0.022, 1.0)
+    p.inputs["Metallic"].default_value = 0.5
+    p.inputs["Roughness"].default_value = 0.32
+    g = principled("Glass")
+    # dielectric, not metal: metallic glass mirrors the key light across the
+    # whole windshield and it renders as a hot salmon slab
+    g.inputs["Base Color"].default_value = (0.02, 0.026, 0.036, 1.0)
+    g.inputs["Metallic"].default_value = 0.0
+    g.inputs["Roughness"].default_value = 0.3
+    t = principled("Trim")
+    t.inputs["Base Color"].default_value = (0.02, 0.021, 0.026, 1.0)
+    t.inputs["Roughness"].default_value = 0.6
+    tire = principled("Tire")
+    tire.inputs["Base Color"].default_value = (0.012, 0.013, 0.016, 1.0)
+    tire.inputs["Roughness"].default_value = 0.85
+    hub = principled("Hubcap")
+    hub.inputs["Base Color"].default_value = (0.62, 0.64, 0.68, 1.0)
+    hub.inputs["Metallic"].default_value = 1.0
+    hub.inputs["Roughness"].default_value = 0.28
+    head = principled("Headlamp")
+    head.inputs["Base Color"].default_value = (0.85, 0.9, 0.95, 1.0)
+    head.inputs["Emission Color"].default_value = (0.9, 0.95, 1.0, 1.0)
+    head.inputs["Emission Strength"].default_value = 1.2
+    tail = principled("Taillamp")
+    tail.inputs["Base Color"].default_value = (0.3, 0.008, 0.01, 1.0)
+    tail.inputs["Emission Color"].default_value = (0.8, 0.02, 0.02, 1.0)
+    tail.inputs["Emission Strength"].default_value = 0.9
+
+
+def render_still(objs, path, engine):
+    scene = bpy.context.scene
+    _finish_materials()
+    body = objs["body"]
+    for poly in body.data.polygons:
+        poly.use_smooth = False  # crisp loft panels
+
+    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
+    # metallic paint needs a faint ambient or the flanks die to black
+    world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.024, 0.026, 0.032, 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)
+
+    # default-stage rig per docs/VISUAL-STYLE.md
+    light("Key", (-4.0, -5.0, 6.0), 550.0, 5.0, (1.0, 0.96, 0.9), (48, 0, -35))
+    light("Fill", (5.0, -3.5, 2.5), 120.0, 9.0, (0.75, 0.85, 1.0), (65, 0, 50))
+    light("Rim", (3.0, 4.5, 5.0), 320.0, 4.0, (0.6, 0.78, 1.0), (-55, 0, 155))
+    light("Wedge", (2.5, 5.5, 4.0), 400.0, 6.0, (1.0, 0.76, 0.5), (-68, 0, 190))
+
+    aim = bpy.data.objects.new("Aim", None)
+    aim.location = (0.0, 0.0, 0.55)
+    scene.collection.objects.link(aim)
+    cam_data = bpy.data.cameras.new("Cam")
+    cam_data.lens = 52.0
+    cam = bpy.data.objects.new("Cam", cam_data)
+    cam.location = (4.4, -5.3, 2.1)
+    scene.collection.objects.link(cam)
+    track = cam.constraints.new('TRACK_TO')  # data API, not bpy.ops (damped-track-aim)
+    track.target = aim
+    track.track_axis = 'TRACK_NEGATIVE_Z'
+    track.up_axis = 'UP_Y'
+    scene.camera = cam
+
+    scene.render.engine = 'CYCLES' if engine == 'cycles' else eevee_engine_id()
+    if engine == 'cycles':
+        scene.cycles.samples = 32
+    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 candy paint toward chalk (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)
+
+    objs = build_car()
+    code = check(objs)
+    if code:
+        return code
+
+    if args.output:
+        if not render_still(objs, os.path.abspath(args.output), args.engine):
+            print("ERROR: render produced no file", file=sys.stderr)
+            return 6
+        print(f"rendered still {args.output}")
+
+    print("car-mirror-symmetry 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/car-mirror-symmetry-contact-sheet.webp b/docs/gallery/contact-sheets/car-mirror-symmetry-contact-sheet.webp new file mode 100644 index 0000000..c398c59 Binary files /dev/null and b/docs/gallery/contact-sheets/car-mirror-symmetry-contact-sheet.webp differ diff --git a/docs/gallery/index.html b/docs/gallery/index.html index feada58..c96b510 100644 --- a/docs/gallery/index.html +++ b/docs/gallery/index.html @@ -620,6 +620,17 @@

soccer-ball-goldberg

View example +
+ + car-mirror-symmetry — A generic hatchback lofted as one half (14 stations, 9-point rings) and completed by the Mirror modifier, evaluated through the depsgraph. + +
+

car-mirror-symmetry

+

A generic hatchback lofted as one half (14 stations, 9-point rings) and completed by the Mirror modifier, evaluated through the depsgraph. Wheels and lamps mirror about object origins parked on the symmetry plane.

+

witnesses Mirror is a modifier, not applied data: the original keeps the authored half while the evaluated mesh is the welded, watertight, exactly symmetric whole.

+ View example +
+