Rendered headless by the example itself — click to zoom.
+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:
+- Original holds the half. Body datablock is exactly 126 verts / 231 edges / 106 faces (14 × 9, loft closed forms), with exactly 28 centerline verts (probe: modifier applied into data → exit 3, datablock reads
(224, 434, 212)). - Evaluated is the welded whole. Exactly
2n − c = 224verts (probe:use_mirror_merge = False→ exit 5, 252 verts — the doubled seam; probe: one centerline vert pulled off the plane → exit 4, 27 ≠ 28 welded; probe: mirror axis off → exit 5, 126 — the half-car). The evaluated shell is watertight (every edge borders 2 faces) with Euler characteristic 2 — the two halves weld into a topological sphere. - Exact ±X partners. Every evaluated vertex has a partner at negated X: measured deviation 0.000e+00 (mirror copies exactly; tol 2e-5 guards float32), evaluated bbox symmetric (
min.x == −max.x). - Mirrored parts. Wheels (96/81 half → 192/162 evaluated) and lamps (8/6 → 16/12) each double across the plane with partner deviation 0.0 — and each object origin reads
x == 0.
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
+ +"""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) ++
+
+