"""Sheep footstool for a friend as one light mesh (the prim version is 17 LI). Run headless: "C:/Program Files/Blender Foundation/Blender 5.2/blender.exe" -b -P stool_mesh.py -- [--glb out.glb] [--render out.png] [--phys phys.glb] [--wool-tris 1450] [--cap-tris 240] [--cam AZ,ELEV] [--seed 3] Proportions follow gen_stool.py (body ~0.66 x 0.5 m, +x is where it looks), centred 0.24 m up rather than 0.26 for stubbier legs. The fleece is metaballs (core ellipsoids plus lumps) converted to mesh and decimated, so it reads as puffy wool rather than an egg; face details are placed by ray-casting onto the head. Four material slots (wool, face, eye, pink; the eye glints use face). Origin at the floor under the body centre. Claude Opus 5.5 (subagent for Tessel), 2026-10-11.""" import math import random import sys import bpy import bmesh from mathutils import Vector args = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] opt = {args[i]: args[i + 1] for i in range(0, len(args) - 1, 2)} rng = random.Random(int(opt.get("--seed", 3))) bpy.ops.wm.read_factory_settings(use_empty=True) scene = bpy.context.scene def srgb(h): """'#rrggbb' -> linear rgb (Blender colours and glTF factors are linear).""" 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): m = 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 return m WOOL = material("wool", "#ffffff", 0.95) FACE = material("face", "#f4e9e4", 0.7) PINK = material("pink", "#fcc0d7", 0.6) EYE = material("eye", "#2a1d1d", 0.08) # metaball field: s * (1 - d^2/R^2)^3 per ball, surface where the sum reaches the threshold THRESH = 0.6 def mb_radius(surface_r, stiff): """Element radius whose lone ball has the given visible radius.""" return surface_r / math.sqrt(1 - (THRESH / stiff) ** (1 / 3)) def select_only(ob): for o in scene.objects: o.select_set(False) bpy.context.view_layer.objects.active = ob ob.select_set(True) def tris(ob): return sum(len(p.vertices) - 2 for p in ob.data.polygons) def metaballs(name, balls, mat, res=0.012, target=None): """balls: [(centre, surface radius, stiffness, (sx, sy, sz) or None)] -> decimated mesh object.""" mb = bpy.data.metaballs.new(name) mb.resolution = mb.render_resolution = res mb.threshold = THRESH for co, r, s, size in balls: el = mb.elements.new(type="ELLIPSOID" if size else "BALL") el.co = co el.stiffness = s el.radius = mb_radius(r, s) if size: el.size_x, el.size_y, el.size_z = size ob = bpy.data.objects.new(name, mb) scene.collection.objects.link(ob) select_only(ob) bpy.ops.object.convert(target="MESH") ob = bpy.context.view_layer.objects.active ob.data.materials.clear() ob.data.materials.append(mat) if target and tris(ob) > target: d = ob.modifiers.new("dec", "DECIMATE") d.ratio = target / tris(ob) d.use_collapse_triangulate = True bpy.ops.object.modifier_apply(modifier=d.name) bpy.ops.object.shade_smooth() return ob def ellipsoid(name, centre, radii, mat, segs=10, rings=6, rot=(0, 0, 0)): bpy.ops.mesh.primitive_uv_sphere_add(segments=segs, ring_count=rings, radius=1, location=centre, rotation=[math.radians(a) for a in rot]) ob = bpy.context.active_object ob.name = name ob.scale = radii bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) ob.data.materials.append(mat) bpy.ops.object.shade_smooth() return ob def lathe(name, profile, centre, mat, segs=10): """profile [(r, z)] bottom to top around a vertical axis at centre (x, y); open where r > 0 at an end.""" bm = bmesh.new() cx, cy = centre rings = [] for r, z in profile: if r == 0: rings.append([bm.verts.new((cx, cy, z))]) else: rings.append([bm.verts.new((cx + r * math.cos(2 * math.pi * k / segs), cy + r * math.sin(2 * math.pi * k / segs), z)) for k in range(segs)]) for a, b in zip(rings, rings[1:]): for k in range(segs): k1 = (k + 1) % segs if len(a) == 1: bm.faces.new((a[0], b[k1], b[k])) elif len(b) == 1: bm.faces.new((a[k], a[k1], b[0])) else: bm.faces.new((a[k], a[k1], b[k1], b[k])) me = bpy.data.meshes.new(name) bm.to_mesh(me) bm.free() ob = bpy.data.objects.new(name, me) scene.collection.objects.link(ob) ob.data.materials.append(mat) select_only(ob) bpy.ops.object.shade_smooth() return ob # --- fleece: core ellipsoid + lumps spread over it (fibonacci points, jittered), fewer under the belly --- BODY_C = Vector((0, 0, float(opt.get("--body-z", 0.24)))) A, B, C = 0.255, 0.19, 0.14 # core semi-axes; lumps add ~4-5 cm all round balls = [(BODY_C, 1.0, 2.0, (A, B, C)), (BODY_C + Vector((0, 0, -0.07)), 1.0, 2.0, (A * 0.92, B * 0.9, 0.1))] N = int(opt.get("--lumps", 36)) LS = float(opt.get("--lump-scale", 1)) LO = float(opt.get("--lump-out", 1.0)) golden = math.pi * (3 - math.sqrt(5)) for i in range(N): z = 1 - 2 * (i + 0.5) / N if z < -0.9: continue rr = math.sqrt(1 - z * z) th = golden * i + rng.uniform(-0.3, 0.3) p = Vector((rr * math.cos(th), rr * math.sin(th), z)) on = BODY_C + Vector((p.x * A, p.y * B, p.z * C)) * LO size = (0.062 + 0.02 * max(z, 0)) * LS * rng.uniform(0.88, 1.12) balls.append((on, size, float(opt.get("--stiff", 3.0)), None)) balls.append((BODY_C + Vector((-0.30, 0, 0.07)), 0.065, 2.6, None)) # tail wool = metaballs("Wool", balls, WOOL, target=int(opt.get("--wool-tris", 1450))) # --- head: cream, a round skull with a softer muzzle, tipped down a little --- H = Vector((0.335, 0, BODY_C.z + 0.11)) HR = 0.112 head = metaballs("Face", [(H, HR, 2.0, None), (H + Vector((0.05, 0, -0.045)), 0.075, 2.0, None)], FACE, res=0.008, target=280) def sph(az, el): az, el = math.radians(az), math.radians(el) return Vector((math.cos(el) * math.cos(az), math.cos(el) * math.sin(az), math.sin(el))) def on_head(az, el): """Point and outward normal where a ray from outside toward the skull centre meets the face.""" d = sph(az, el) hit, loc, nrm, _ = head.ray_cast(H + d * 0.5, -d) assert hit, (az, el) return loc, nrm.normalized() def placed(name, at, nrm, radii, mat, segs=8, rings=5, up=Vector((0, 0, 1))): """Ellipsoid whose local x lies along nrm (radii[0] is its depth), z as close to `up` as allowed.""" ob = ellipsoid(name, (0, 0, 0), radii, mat, segs, rings) ob.rotation_mode = "QUATERNION" ob.rotation_quaternion = nrm.to_track_quat("X", "Z") if abs(nrm.dot(up)) > 0.99 else _frame(nrm, up) ob.location = at select_only(ob) bpy.ops.object.transform_apply(location=True, rotation=True, scale=False) return ob def _frame(x, up): from mathutils import Matrix y = up.cross(x).normalized() z = x.cross(y) return Matrix((x, y, z)).transposed().to_quaternion() parts = [wool, head] cap_balls = [] for az, el, r in ((180, 70, 0.05), (0, 62, 0.042), (40, 50, 0.04), (-40, 50, 0.04), (110, 52, 0.042), (-110, 52, 0.042), (0, 84, 0.048), (25, 30, 0.032), (-25, 30, 0.032)): loc, _ = on_head(az, el) cap_balls.append((H + (loc - H) * 0.92, r, 2.6, None)) cap = metaballs("Cap", cap_balls, WOOL, res=0.008, target=int(opt.get("--cap-tris", 240))) parts.append(cap) for s in (1, -1): loc, n = on_head(s * 30, -2) eye = placed("eye", loc - n * 0.009, n, (0.014, 0.019, 0.027), EYE, 10, 6) up = Vector((0, 0, 1)) side = n.cross(up).normalized() * s parts += [eye, placed("glint", loc + n * 0.004 + up * 0.012 + side * 0.004, n, (0.004, 0.006, 0.006), FACE, 6, 4)] loc, n = on_head(s * 52, -22) parts.append(placed("blush", loc - n * 0.002, n, (0.004, 0.02, 0.013), PINK, 8, 4)) # ears: leaves sticking out sideways from under the cap, drooping, flat side to the front loc, n = on_head(s * 88, 18) out = Vector((0.12, s * 1, -0.42)).normalized() parts.append(placed("ear", loc + out * 0.055, Vector((1, -s * 0.12, 0.2)).normalized(), (0.013, 0.033, 0.07), PINK, 8, 5, up=out)) loc, n = on_head(0, -24) parts.append(placed("nose", loc - n * 0.003, n, (0.008, 0.022, 0.012), PINK, 8, 4)) # --- legs: stubby pink pegs, rounded at the foot, open tops hidden in the wool --- leg = [(0, 0), (0.045, 0), (0.057, 0.012), (0.06, 0.035), (0.056, 0.16)] for x, y in ((0.165, 0.135), (0.165, -0.135), (-0.165, 0.135), (-0.165, -0.135)): parts.append(lathe("leg", leg, (x, y), PINK)) # UVs for a tiling wool texture in-world (cube projection keeps the seams few) for ob in parts: select_only(ob) bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.select_all(action="SELECT") bpy.ops.uv.cube_project(cube_size=0.25) bpy.ops.object.mode_set(mode="OBJECT") for ob in parts: ob.select_set(True) bpy.context.view_layer.objects.active = wool bpy.ops.object.join() sheep = bpy.context.view_layer.objects.active sheep.name = "Sheep footstool" # keep slot order wool, face, pink, eye lo = Vector([min(v.co[i] for v in sheep.data.vertices) for i in range(3)]) hi = Vector([max(v.co[i] for v in sheep.data.vertices) for i in range(3)]) print("tris:", tris(sheep), "slots:", [m.name for m in sheep.data.materials]) print("bbox min", tuple(round(v, 3) for v in lo), "max", tuple(round(v, 3) for v in hi), "centre", tuple(round(v, 3) for v in (lo + hi) / 2)) if opt.get("--glb"): select_only(sheep) bpy.ops.export_scene.gltf(filepath=opt["--glb"], export_format="GLB", use_selection=True, export_apply=True) print("wrote", opt["--glb"]) if opt.get("--phys"): # an octagonal prism with exactly the visual bbox (SL fits the physics model to the high LOD's bounds) bm = bmesh.new() cx, cy = (lo.x + hi.x) / 2, (lo.y + hi.y) / 2 rx, ry = (hi.x - lo.x) / 2, (hi.y - lo.y) / 2 ring = [(math.cos(math.pi / 8 + k * math.pi / 4), math.sin(math.pi / 8 + k * math.pi / 4)) for k in range(8)] m = max(abs(c) for c, _ in ring) bot = [bm.verts.new((cx + rx * c / m, cy + ry * s / m, lo.z)) for c, s in ring] top = [bm.verts.new((cx + rx * c / m, cy + ry * s / m, hi.z)) for c, s in ring] bm.faces.new(bot[::-1]) bm.faces.new(top) for k in range(8): bm.faces.new((bot[k], bot[(k + 1) % 8], top[(k + 1) % 8], top[k])) bmesh.ops.triangulate(bm, faces=bm.faces[:]) me = bpy.data.meshes.new("phys") bm.to_mesh(me) bm.free() phys = bpy.data.objects.new("Sheep footstool phys", me) scene.collection.objects.link(phys) select_only(phys) print("phys tris:", tris(phys)) bpy.ops.export_scene.gltf(filepath=opt["--phys"], export_format="GLB", use_selection=True) bpy.data.objects.remove(phys) print("wrote", opt["--phys"]) if opt.get("--render"): az, elev = (float(v) for v in opt.get("--cam", "-35,22").split(",")) target = Vector((0.06, 0, 0.22)) d = float(opt.get("--dist", 1.75)) cam = bpy.data.objects.new("cam", bpy.data.cameras.new("cam")) scene.collection.objects.link(cam) cam.location = target + d * Vector((math.cos(math.radians(elev)) * math.cos(math.radians(az)), math.cos(math.radians(elev)) * math.sin(math.radians(az)), math.sin(math.radians(elev)))) cam.rotation_euler = (target - cam.location).to_track_quat("-Z", "Y").to_euler() cam.data.lens = 50 scene.camera = cam sun = bpy.data.objects.new("sun", bpy.data.lights.new("sun", "SUN")) sun.data.energy = 3.0 sun.data.angle = math.radians(8) sun.rotation_euler = (-sph(-75, 50)).to_track_quat("-Z", "Y").to_euler() scene.collection.objects.link(sun) world = bpy.data.worlds.new("w") world.use_nodes = True world.node_tree.nodes["Background"].inputs["Color"].default_value = (*srgb("#b9c7d6"), 1) world.node_tree.nodes["Background"].inputs["Strength"].default_value = 0.9 scene.world = world bpy.ops.mesh.primitive_plane_add(size=6) bpy.context.active_object.data.materials.append(material("floor", "#9c8270", 0.8)) scene.render.engine = "BLENDER_EEVEE" scene.view_settings.view_transform = "Standard" scene.render.resolution_x = scene.render.resolution_y = 560 scene.render.filepath = opt["--render"] bpy.ops.render.render(write_still=True) print("rendered", opt["--render"])