"""Mesh body and head for the Walker Cat (replacing the navy box root and sphere head). Run headless: "C:/Program Files/Blender Foundation/Blender 5.2/blender.exe" -b -P walker_mesh.py -- [--out DIR] [--render] [--probe] [--tris SCALE --suffix _lite] Shapes are signed distance fields (ellipsoids and round cones, smooth-unioned), meshed with surface nets, decimated with Y symmetry, snapped back onto the field, and given normals from the field gradient. Each part is centred on its bounding box (SL centres mesh there) and exported Z-up / X-forward (`export_yup=False`): the viewer's glTF loader keeps vertices verbatim and puts the Y-up conversion into the object rotation, and walker.lsl overwrites the root and head rotations anyway, so the local frame must already be the script's frame. Writes body.glb, head.glb (+ PNG renders with --render, legs/eyes/ears/tail/cannon mocked up from walker.lsl's standing pose). --probe prints sizes and fit checks only (signed distance: + = outside the surface). Claude Opus 5.5 (subagent for Tessel), 2026-10-11.""" import math import os import sys import bmesh import bpy import numpy as np from mathutils import Euler, Quaternion, Vector argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] OUT = argv[argv.index("--out") + 1] if "--out" in argv else os.path.dirname(os.path.abspath(__file__)) RENDER = "--render" in argv PROBE = "--probe" in argv TRIS = float(argv[argv.index("--tris") + 1]) if "--tris" in argv else 1.0 # scales every triangle budget SUFFIX = argv[argv.index("--suffix") + 1] if "--suffix" in argv else "" # e.g. _lite -> body_lite.glb NAVY = "#2E3D6B" MUZZLE = "#6474A6" CORAL = "#F28073" # ---------------------------------------------------------------- distance fields (P: (N, 3) array) def ell(P, c, r): """Ellipsoid (iq's bound; exact zero set).""" q = (P - c) / r k0 = np.linalg.norm(q, axis=1) k1 = np.linalg.norm(q / r, axis=1) return k0 * (k0 - 1.0) / np.maximum(k1, 1e-9) def rcone(P, a, b, r1, r2): """Round cone from sphere (a, r1) to sphere (b, r2).""" a = np.asarray(a, float) ba = np.asarray(b, float) - a l2 = ba @ ba rr = r1 - r2 a2 = l2 - rr * rr il2 = 1.0 / l2 pa = P - a y = pa @ ba z = y - l2 w = pa * l2 - np.outer(y, ba) x2 = np.einsum("ij,ij->i", w, w) y2 = y * y * l2 z2 = z * z * l2 k = np.sign(rr) * rr * rr * x2 d3 = (np.sqrt(np.maximum(x2 * a2 * il2, 0)) + y * rr) * il2 - r1 d1 = np.sqrt(x2 + z2) * il2 - r2 d2 = np.sqrt(x2 + y2) * il2 - r1 return np.where(np.sign(z) * a2 * z2 > k, d1, np.where(np.sign(y) * a2 * y2 < k, d2, d3)) def smin(a, b, k): h = np.clip(0.5 + 0.5 * (b - a) / k, 0, 1) return b * (1 - h) + a * h - k * h * (1 - h) def smax(a, b, k): return -smin(-a, -b, k) def mirror(f): """Apply a +y shape on both sides.""" def g(P): Q = P.copy() Q[:, 1] = np.abs(Q[:, 1]) return f(Q) return g # cannon in root-local coords (walker.lsl): centre (-0.34, 0, 0.26), local z tilted 0.55 rad about y CANNON_C = np.array([-0.34, 0, 0.26]) CANNON_A = np.array([math.sin(0.55), 0, math.cos(0.55)]) def body_sdf(P): d = ell(P, (0.26, 0, -0.01), (0.24, 0.162, 0.14)) # ribcage / chest d = smin(d, mirror(lambda Q: ell(Q, (0.36, 0.13, -0.065), (0.14, 0.095, 0.085)))(P), 0.07) # shoulders d = smin(d, ell(P, (0.0, 0, 0.035), (0.34, 0.172, 0.105)), 0.08) # waist, tucked belly d = smin(d, ell(P, (-0.31, 0, 0.025), (0.19, 0.15, 0.125)), 0.06) # rump d = smin(d, mirror(lambda Q: ell(Q, (-0.33, 0.125, -0.04), (0.17, 0.115, 0.108)))(P), 0.07) # haunches d = smin(d, ell(P, (0.43, 0, 0.06), (0.085, 0.1, 0.1)), 0.05) # neck stub (hidden in the head) return smax(d, P[:, 2] - 0.142, 0.04) # flat-ish back for the rider def head_fur(P): d = ell(P, (-0.015, 0, 0.025), (0.152, 0.145, 0.118)) # cranium d = smin(d, ell(P, (0.104, 0, 0.008), (0.075, 0.155, 0.12)), 0.05) # flat broad face d = smin(d, mirror(lambda Q: ell(Q, (0.04, 0.075, -0.045), (0.1, 0.085, 0.072)))(P), 0.04) # jowls tufts = mirror(lambda Q: np.minimum( rcone(Q, (0.03, 0.12, -0.04), (-0.005, 0.212, -0.07), 0.036, 0.005), rcone(Q, (0.0, 0.11, -0.07), (-0.025, 0.188, -0.125), 0.034, 0.005))) d = smin(d, tufts(P), 0.016) return d def head_muzzle(P): d = mirror(lambda Q: ell(Q, (0.158, 0.031, -0.045), (0.043, 0.043, 0.034)))(P) d = smin(d, ell(P, (0.125, 0, -0.083), (0.04, 0.032, 0.022)), 0.02) # chin return d def head_nose(P): # soft inverted triangle: wide top, narrowing down return smin(ell(P, (0.18, 0, -0.004), (0.018, 0.03, 0.013)), ell(P, (0.176, 0, -0.019), (0.015, 0.011, 0.012)), 0.01) def head_all(P): return np.minimum(np.minimum(head_fur(P), head_muzzle(P)), head_nose(P)) # ---------------------------------------------------------------- meshing def grad(f, P, e=1e-4): g = np.empty_like(P) for i in range(3): o = np.zeros(3) o[i] = e g[:, i] = (f(P + o) - f(P - o)) / (2 * e) return g def surface_nets(f, lo, hi, h): xs, ys, zs = (np.arange(lo[i] - 2 * h, hi[i] + 2 * h, h) for i in range(3)) G = np.stack(np.meshgrid(xs, ys, zs, indexing="ij"), -1) F = f(G.reshape(-1, 3)).reshape(G.shape[:3]) for ax in range(3): assert F.take([0, -1], axis=ax).min() > 0, "shape reaches the grid edge" n = np.array(F.shape) cdims = n - 1 cid = lambda i, j, k: (i * cdims[1] + j) * cdims[2] + k acc = np.zeros((cdims.prod(), 3)) cnt = np.zeros(cdims.prod()) quads = [] for ax in range(3): s0 = [slice(None)] * 3 s1 = [slice(None)] * 3 s0[ax] = slice(0, -1) s1[ax] = slice(1, None) f0, f1 = F[tuple(s0)], F[tuple(s1)] cross = (f0 < 0) != (f1 < 0) idx = np.argwhere(cross) a, b = f0[cross], f1[cross] t = a / (a - b) pts = np.stack([xs[0] + idx[:, 0] * h, ys[0] + idx[:, 1] * h, zs[0] + idx[:, 2] * h], 1) pts[:, ax] += t * h # the four cells around this edge: offsets in the two other axes u, v = [x for x in range(3) if x != ax] cells = [] for du, dv in ((-1, -1), (0, -1), (0, 0), (-1, 0)): c = idx.copy() c[:, u] += du c[:, v] += dv cells.append(c) for c in cells: ok = np.all((c >= 0) & (c < cdims), axis=1) ci = cid(c[ok, 0], c[ok, 1], c[ok, 2]) np.add.at(acc, ci, pts[ok]) np.add.at(cnt, ci, 1) q = np.stack([cid(c[:, 0], c[:, 1], c[:, 2]) for c in cells], 1) # (u, v) is right-handed with ax for ax = 0 (y, z) and 2 (x, y), but (x, z) for ax = 1 is not flip = (a < 0) if ax != 1 else (a >= 0) q[~flip] = q[~flip][:, ::-1] quads.append(q) quads = np.concatenate(quads) used = np.nonzero(cnt)[0] remap = -np.ones(cdims.prod(), int) remap[used] = np.arange(len(used)) V = acc[used] / cnt[used, None] return V, remap[quads] def project(f, V, iters=4): for _ in range(iters): g = grad(f, V) V = V - (f(V) / np.maximum(np.einsum("ij,ij->i", g, g), 1e-9))[:, None] * g return V def decimated(f, lo, hi, h, tris, name): """Surface nets, decimate, keep the +y half and mirror it (Decimate's own symmetry option folds faces on the mid-plane; decimating a half mesh wrecked it), snap onto the field.""" V, Q = surface_nets(f, lo, hi, h) me = bpy.data.meshes.new(name + "_raw") me.from_pydata(V.tolist(), [], Q.tolist()) ob = bpy.data.objects.new(name + "_raw", me) bpy.context.scene.collection.objects.link(ob) m = ob.modifiers.new("dec", "DECIMATE") m.ratio = min(1.0, tris / (2 * len(Q))) m.use_collapse_triangulate = True bm = bmesh.new() bm.from_object(ob, bpy.context.evaluated_depsgraph_get()) bpy.data.objects.remove(ob) bpy.data.meshes.remove(me) bmesh.ops.bisect_plane(bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:], plane_co=(0, 0, 0), plane_no=(0, 1, 0), clear_inner=True) bmesh.ops.triangulate(bm, faces=bm.faces[:]) V = np.array([v.co[:] for v in bm.verts]) bm.verts.index_update() F = np.array([[v.index for v in fc.verts] for fc in bm.faces]) bm.free() area2 = np.linalg.norm(np.cross(V[F[:, 1]] - V[F[:, 0]], V[F[:, 2]] - V[F[:, 0]]), axis=1) F = F[area2 > 1e-9] # collapse leaves a few zero-area slivers assert V[:, 1].min() > -1e-6 rim = np.nonzero(np.abs(V[:, 1]) < 1e-6)[0] V[rim, 1] = 0 n = len(V) other = np.arange(n) + n other[rim] = rim V = np.concatenate([V, V * (1, -1, 1)]) F = np.concatenate([F, other[F[:, ::-1]]]) V = project(f, V) area2 = np.linalg.norm(np.cross(V[F[:, 1]] - V[F[:, 0]], V[F[:, 2]] - V[F[:, 0]]), axis=1) F = F[area2 > 1e-8] # slivers from the cut, after snapping used = np.unique(F) remap = -np.ones(len(V), int) remap[used] = np.arange(len(used)) return V[used], remap[F] def srgb(h): c = [int(h[i:i + 2], 16) / 255 for i in (1, 3, 5)] return tuple(v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in c) def material(name, hexcol, rough=0.55, metal=0.0, emit=0.0): m = bpy.data.materials.get(name) or bpy.data.materials.new(name) m.use_nodes = True b = m.node_tree.nodes["Principled BSDF"] b.inputs["Base Color"].default_value = (*srgb(hexcol), 1) b.inputs["Roughness"].default_value = rough b.inputs["Metallic"].default_value = metal if emit: b.inputs["Emission Color"].default_value = (*srgb(hexcol), 1) b.inputs["Emission Strength"].default_value = emit return m def assemble(name, parts): """parts: [(V, F, sdf, material)] -> one bbox-centred object, normals from each part's field, box UVs.""" Vs, Fs, Ns, Ms, off = [], [], [], [], 0 for i, (V, F, f, mat) in enumerate(parts): g = grad(f, V) Ns.append(g / np.linalg.norm(g, axis=1, keepdims=True)) Vs.append(V) Fs.append(F + off) Ms += [i] * len(F) off += len(V) V, F, N = np.concatenate(Vs), np.concatenate(Fs), np.concatenate(Ns) fn = np.cross(V[F[:, 1]] - V[F[:, 0]], V[F[:, 2]] - V[F[:, 0]]) gc = np.concatenate([grad(f, V_[F_].mean(1)) for (V_, F_, f, _), o_ in zip(parts, np.cumsum([0] + [len(p[0]) for p in parts])[:-1])]) print(f"{name}: {(np.einsum('ij,ij->i', fn, gc) < 0).sum()} faces against the field, " f"{(np.linalg.norm(fn, axis=1) < 1e-8).sum()} degenerate") centre = (V.min(0) + V.max(0)) / 2 V = V - centre me = bpy.data.meshes.new(name) me.from_pydata(V.tolist(), [], F.tolist()) for _, _, _, mat in parts: me.materials.append(mat) me.polygons.foreach_set("material_index", Ms) me.polygons.foreach_set("use_smooth", [True] * len(F)) uv = me.uv_layers.new(name="UVMap") fn = np.cross(V[F[:, 1]] - V[F[:, 0]], V[F[:, 2]] - V[F[:, 0]]) dom = np.abs(fn).argmax(1) lv = np.empty(len(me.loops), int) me.loops.foreach_get("vertex_index", lv) keep = np.array([[1, 2], [0, 2], [0, 1]])[np.repeat(dom, 3)] co = V[lv] uvs = np.take_along_axis(co, keep, 1) + 0.5 uv.data.foreach_set("uv", uvs.ravel()) me.normals_split_custom_set_from_vertices(N.tolist()) me.update() ob = bpy.data.objects.new(name, me) bpy.context.scene.collection.objects.link(ob) return ob, centre, V def export(ob, path): for o in bpy.context.scene.objects: o.select_set(o == ob) bpy.context.view_layer.objects.active = ob bpy.ops.export_scene.gltf(filepath=path, export_format="GLB", use_selection=True, export_yup=False, export_apply=True) print("wrote", path) def report(name, ob, V, centre): lo, hi = V.min(0), V.max(0) print(f"{name}: {len(ob.data.polygons)} tris, {len(ob.data.vertices)} verts, size " f"{tuple(round(float(v), 3) for v in hi - lo)}, bbox centre was at {tuple(round(float(v), 4) for v in centre)} " f"in design coords (geometry shifted by its negative)") # ---------------------------------------------------------------- build for o in list(bpy.data.objects): bpy.data.objects.remove(o) M_NAVY, M_MUZ, M_NOSE = material("navy", NAVY), material("muzzle", MUZZLE), material("nose", CORAL, 0.45) H = 0.006 bV, bF = decimated(body_sdf, (-0.55, -0.3, -0.2), (0.55, 0.3, 0.2), H, 980 * TRIS, "body") hV, hF = decimated(head_fur, (-0.2, -0.25, -0.18), (0.2, 0.25, 0.18), 0.004, 640 * TRIS, "fur") mV, mF = decimated(head_muzzle, (0.05, -0.1, -0.13), (0.22, 0.1, 0.0), 0.003, 150 * TRIS, "muzzle") nV, nF = decimated(head_nose, (0.13, -0.05, -0.05), (0.21, 0.05, 0.02), 0.002, 48 * TRIS, "nose") body, bc, bVf = assemble("walker_body", [(bV, bF, body_sdf, M_NAVY)]) head, hc, hVf = assemble("walker_head", [(hV, hF, head_fur, M_NAVY), (mV, mF, head_muzzle, M_MUZ), (nV, nF, head_nose, M_NOSE)]) report("body", body, bVf, bc) report("head", head, hVf, hc) # fit checks in final (bbox-centred) coords: signed distance, + = outside the surface fb = lambda P: body_sdf(np.atleast_2d(P) + bc) fh = lambda P: head_all(np.atleast_2d(P) + hc) for nm, p in [("hip fl", (0.38, 0.2, -0.08)), ("hip bl", (-0.38, 0.2, -0.08)), ("tail root", (-0.48, 0, 0.06)), ("cannon base", tuple(np.round(CANNON_C - 0.18 * CANNON_A, 3).tolist())), ("seat xy top", (0.05, 0, 0.3))]: print(f" body {nm} {p}: sdf {fb(p)[0]:+.3f}") z = np.linspace(0.3, -0.3, 6001) top = z[np.argmax(fb(np.stack([np.full_like(z, 0.05), 0 * z, z], 1)) < 0)] print(f" body back surface under the seat (x 0.05): z {top:+.3f}") def surface_x(f, y, z): x = np.linspace(0.3, -0.3, 6001) return x[np.argmax(f(np.stack([x, np.full_like(x, y), np.full_like(x, z)], 1)) < 0)] p = (0.15, 0.08, 0.05) print(f" head eye centre {p}: sdf {fh(p)[0]:+.3f}; face surface at that y,z is x {surface_x(fh, p[1], p[2]):+.3f}") p = (-0.05, 0.1, 0.18) print(f" head ear centre {p}: sdf {fh(p)[0]:+.3f}") # ear cone 0.11 x 0.05 x 0.2, rotation euler (-0.35, -0.2, 0) for the left ear: base ring must be buried q = Euler((-0.35, -0.2, 0), "XYZ").to_quaternion() ax = np.array(q @ Vector((0, 0, 1))) ex, ey = np.array(q @ Vector((1, 0, 0))), np.array(q @ Vector((0, 1, 0))) base = np.array([-0.05, 0.1, 0.18]) - 0.1 * ax ring = np.array([base + 0.055 * math.cos(a) * ex + 0.025 * math.sin(a) * ey for a in np.linspace(0, 2 * math.pi, 16)]) print(f" head ear base centre {tuple(np.round(base, 3).tolist())}: sdf {fh(base)[0]:+.3f}; base rim sdf max {fh(ring).max():+.3f}") for s in np.linspace(0, 0.2, 21): if fh(base + s * ax)[0] > 0: print(f" ear emerges {s:.2f} m up its 0.2 m length") break if PROBE: sys.exit(0) os.makedirs(OUT, exist_ok=True) export(body, os.path.join(OUT, f"body{SUFFIX}.glb")) export(head, os.path.join(OUT, f"head{SUFFIX}.glb")) if not RENDER: sys.exit(0) # ---------------------------------------------------------------- preview renders, roughly as assembled in SL scene = bpy.context.scene scene.render.engine = "BLENDER_EEVEE" scene.eevee.taa_render_samples = 24 scene.render.resolution_x, scene.render.resolution_y = 720, 540 scene.view_settings.view_transform = "Standard" scene.world = bpy.data.worlds.new("w") scene.world.use_nodes = True scene.world.node_tree.nodes["Background"].inputs["Color"].default_value = (*srgb("#C9D3DE"), 1) scene.world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.9 M_BRASS = material("brass", "#D9A84D", 0.35, 0.8) M_SHIN = material("shin", "#4D4D57", 0.5) M_EYE = material("eye", "#99F2FF", 0.3, 0, 2.0) M_GROUND = material("ground", "#8E9A86", 0.9) def prim(kind, size, loc, rot, mat, parent=None, taper=False, hollow=False): if kind == "sphere": bpy.ops.mesh.primitive_uv_sphere_add(segments=24, ring_count=12, radius=0.5) elif taper: bpy.ops.mesh.primitive_cone_add(vertices=24, radius1=0.5, radius2=0, depth=1) else: bpy.ops.mesh.primitive_cylinder_add(vertices=24, radius=0.5, depth=1) o = bpy.context.active_object o.scale = size o.rotation_mode = "QUATERNION" o.rotation_quaternion = rot o.location = loc o.data.materials.append(mat) if hollow: b = o.modifiers.new("hollow", "SOLIDIFY") b.thickness = 0.08 for p in o.data.polygons: p.use_smooth = True if parent: o.parent = parent return o def rot_between(a, b): return Vector(a).rotation_difference(Vector(b)) def cat(root_loc, yaw=0.0, head_yaw=0.0): root = bpy.data.objects.new("root", None) scene.collection.objects.link(root) root.location = root_loc root.rotation_euler = (0, 0, yaw) made = [root] b = body.copy() b.hide_render = False b.parent = root scene.collection.objects.link(b) hc_ = Vector((0.6, 0, 0.14)) hr = Euler((0, 0, head_yaw)).to_quaternion() h = head.copy() h.hide_render = False h.parent = root h.location = hc_ h.rotation_mode = "QUATERNION" h.rotation_quaternion = hr scene.collection.objects.link(h) for s in (1, -1): prim("sphere", (0.075,) * 3, hc_ + hr @ Vector((0.15, 0.08 * s, 0.05)), Quaternion(), M_EYE, root) prim("cyl", (0.11, 0.05, 0.2), hc_ + hr @ Vector((-0.05, 0.1 * s, 0.18)), hr @ Euler((-0.35 * s, -0.2, 0)).to_quaternion(), M_NAVY, root, taper=True) d = Vector((-0.75, 0.0, 0.6)).normalized() prim("cyl", (0.09, 0.09, 0.55), Vector((-0.48, 0, 0.06)) + d * 0.27, rot_between((0, 0, 1), d), M_NAVY, root, taper=True) prim("cyl", (0.13, 0.13, 0.36), Vector(CANNON_C), Euler((0, 0.55, 0)).to_quaternion(), M_BRASS, root, hollow=True) hips = [(0.38, 0.2, -0.08), (0.38, -0.2, -0.08), (-0.38, 0.2, -0.08), (-0.38, -0.2, -0.08)] homes = [(0.5, 0.3, -0.95), (0.5, -0.3, -0.95), (-0.3, 0.3, -0.95), (-0.3, -0.3, -0.95)] L1, L2 = 0.55, 0.6 for hip, foot in zip(hips, homes): hip, foot = Vector(hip), Vector(foot) dvec = foot - hip dist = min(dvec.length, L1 + L2 - 0.01) dr = dvec.normalized() a = math.acos(max(-1, min(1, (L1 * L1 + dist * dist - L2 * L2) / (2 * L1 * dist)))) pole = Vector((-1, 0, 0)) p = (pole - dr * pole.dot(dr)).normalized() knee = hip + dr * (L1 * math.cos(a)) + p * (L1 * math.sin(a)) end = hip + dr * dist prim("cyl", (0.09, 0.09, L1), (hip + knee) / 2, rot_between((0, 0, 1), knee - hip), M_BRASS, root) prim("cyl", (0.07, 0.07, L2), (knee + end) / 2, rot_between((0, 0, 1), end - knee), M_SHIN, root) prim("sphere", (0.18, 0.14, 0.08), end + Vector((0.04, 0, 0.03)), Quaternion(), M_COR, root) return made + list(root.children) M_COR = material("coral", "#F28073", 0.5) def camera(target, az, el, dist, lens=50): cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")) scene.collection.objects.link(cam) cam.data.lens = lens t = Vector(target) d = Vector((math.cos(math.radians(el)) * math.cos(math.radians(az)), math.cos(math.radians(el)) * math.sin(math.radians(az)), math.sin(math.radians(el)))) cam.location = t + d * dist cam.rotation_euler = (-d).to_track_quat("-Z", "Y").to_euler() scene.camera = cam return cam sun = bpy.data.objects.new("sun", bpy.data.lights.new("sun", "SUN")) sun.data.energy = 3.2 sun.data.angle = math.radians(8) sun.rotation_euler = Euler((math.radians(50), 0, math.radians(-40))) scene.collection.objects.link(sun) fill = bpy.data.objects.new("fill", bpy.data.lights.new("fill", "SUN")) fill.data.energy = 0.8 fill.rotation_euler = Euler((math.radians(65), 0, math.radians(150))) scene.collection.objects.link(fill) def shot(fname, target, az, el, dist, lens=50): cam = camera(target, az, el, dist, lens) scene.render.filepath = os.path.join(OUT, fname.replace(".png", SUFFIX + ".png")) bpy.ops.render.render(write_still=True) bpy.data.objects.remove(cam) print("rendered", fname) # parts on their own (origin = SL prim centre) for o in (body, head): o.hide_render = True body.hide_render = False shot("body.png", (0, 0, 0), 35, 25, 2.4) shot("body_side.png", (0, 0, 0), 90, 5, 2.4) body.hide_render = True head.hide_render = False shot("head_mesh.png", (0, 0, 0), 30, 12, 1.0) head.hide_render = True # assembled, standing (body centre 0.95 m above the ground), from a few metres bpy.ops.mesh.primitive_plane_add(size=20) bpy.context.active_object.data.materials.append(M_GROUND) parts = cat((0, 0, 0.95)) shot("head.png", (0.6, 0, 1.08), 25, 8, 1.4) shot("head_side.png", (0.5, 0, 1.05), 90, 3, 1.6) shot("assembled.png", (0.1, 0, 0.7), 35, 18, 4.5) shot("assembled_side.png", (0.05, 0, 0.7), 90, 8, 4.5) shot("assembled_front.png", (0.3, 0, 0.8), 0, 10, 3.5) for o in parts: bpy.data.objects.remove(o) cat((0, 0, 0.95), head_yaw=0.9) shot("assembled_lookleft.png", (0.2, 0, 0.9), 50, 30, 3.0)