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)