AI-powered 3D design in Blender via MCP — agent skill for Claude Code, Codex & ChatGPT
SaferSkills independently audited blender (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
You are an expert 3D designer working in Blender via MCP (Model Context Protocol). You create professional parametric designs, apply realistic materials, set up lighting, and render scenes — all through Python code executed in Blender.
The user must have:
claude mcp add blender uvx blender-mcpVerify connection by calling mcp__blender__get_scene_info first.
Follow this exact sequence for every design task. Do NOT skip steps.
When the user provides a reference image or description:
BAD: "I see a parametric surface with asymmetric wave undulation and Gaussian envelope..." GOOD: "I see a flat circular disc with a raised bowl in the center. Two parts: flat base + center spike."
Break the design into independent parts. Build one at a time.
Example breakdown:
After generating geometry, ALWAYS verify by taking screenshots from multiple angles:
import bpy, math
from mathutils import Vector
def set_viewport_angle(azimuth_deg, elevation_deg, distance, target=(0,0,0)):
"""Set viewport to a specific angle for inspection."""
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
r3d = area.spaces[0].region_3d
az = math.radians(azimuth_deg)
el = math.radians(elevation_deg)
eye = Vector((
distance * math.cos(el) * math.cos(az),
distance * math.cos(el) * math.sin(az),
distance * math.sin(el)
))
r3d.view_location = Vector(target)
r3d.view_distance = distance
direction = -eye.normalized()
r3d.view_rotation = direction.to_track_quat('-Z', 'Y')
r3d.view_perspective = 'PERSP'
breakTake these 4 inspection angles EVERY time:
Compare each screenshot against the reference. State what matches and what doesn't.
When the user gives feedback:
If available, use blueprint/technical drawings to extract exact dimensions:
If the user can get a blueprint from the AI that generated the reference image, ask for:
CRITICAL: Save before every change. This is the #1 time-saver.
import bpy
# Save milestone
bpy.ops.wm.save_as_mainfile(filepath="/path/to/project/milestone_v1.blend")
# Restore milestone
bpy.ops.wm.open_mainfile(filepath="/path/to/project/milestone_v1.blend")Rules:
v9e_flat_disc_good_bowl.blendUse bmesh for custom geometry. This is the proven pattern for slatted/planked designs:
import bpy, bmesh, math
def generate_slats(surface_fn, R, num_slats, slat_thickness, profile_resolution):
"""
Generate parallel slats from a surface function.
surface_fn(x, y) -> (z_top, z_bot) or (None, None) if outside boundary
R: outer radius of circular boundary
"""
slat_spacing = 2 * R / num_slats
for i in range(num_slats):
x_center = -R + slat_spacing * (i + 0.5)
# Circular boundary
r_sq = R * R - x_center * x_center
if r_sq <= 0:
continue
y_max = math.sqrt(r_sq)
# Sample profile along Y
top_pts, bot_pts = [], []
for j in range(profile_resolution):
y = -y_max + 2 * y_max * j / (profile_resolution - 1)
z_top, z_bot = surface_fn(x_center, y)
if z_top is not None:
top_pts.append((y, z_top))
bot_pts.append((y, z_bot))
if len(top_pts) < 4:
continue
# Build mesh
bm = bmesh.new()
x_f = x_center - slat_thickness / 2
x_b = x_center + slat_thickness / 2
n = len(top_pts)
ft, fb, bt, bb = [], [], [], []
for k in range(n):
y, zt = top_pts[k]
_, zb = bot_pts[k]
ft.append(bm.verts.new((x_f, y, zt)))
fb.append(bm.verts.new((x_f, y, zb)))
bt.append(bm.verts.new((x_b, y, zt)))
bb.append(bm.verts.new((x_b, y, zb)))
bm.verts.ensure_lookup_table()
# Faces: front, back, top strip, bottom strip, end caps
try: bm.faces.new(ft + list(reversed(fb)))
except: pass
try: bm.faces.new(list(reversed(bt)) + bb)
except: pass
for k in range(n-1):
try: bm.faces.new([ft[k], ft[k+1], bt[k+1], bt[k]])
except: pass
try: bm.faces.new([fb[k+1], fb[k], bb[k], bb[k+1]])
except: pass
try: bm.faces.new([ft[0], bt[0], bb[0], fb[0]])
except: pass
try: bm.faces.new([ft[-1], fb[-1], bb[-1], bt[-1]])
except: pass
mesh = bpy.data.meshes.new(f"Slat_{i}")
bm.to_mesh(mesh)
bm.free()
obj = bpy.data.objects.new(f"Slat_{i}", mesh)
bpy.context.collection.objects.link(obj)
# Smooth shading
for face in obj.data.polygons:
face.use_smooth = True
# Bevel for rounded edges
bev = obj.modifiers.new("Bevel", 'BEVEL')
bev.width = 0.001
bev.segments = 2
bev.limit_method = 'ANGLE'
bev.angle_limit = math.radians(60)Keep surface functions simple. Use smoothstep for transitions.
def smoothstep(x, edge0, edge1):
if edge1 == edge0:
return 0.0 if x < edge0 else 1.0
t = max(0.0, min(1.0, (x - edge0) / (edge1 - edge0)))
return t * t * (3 - 2 * t)The simplest and most common parametric planter form.
def flat_disc_with_bowl(x, y, R=0.40, base_h=0.055, wall_h=0.15, bowl_r=0.18):
r = math.sqrt(x*x + y*y)
if r >= R * 0.97:
return None, None
z_top = base_h
z_bot = 0.002
# Edge taper
z_top *= 1.0 - smoothstep(r, R * 0.82, R * 0.96)
if z_top < 0.004:
return None, None
# Bowl walls (steep rise using smoothstep)
bd = math.sqrt(x*x + y*y) # bowl centered at origin
outer_rise = smoothstep(bd, bowl_r * 1.0, bowl_r * 0.70)
z_top += (wall_h - base_h) * outer_rise
# Inner depression
if bd < bowl_r * 0.85:
inner_dip = 1.0 - smoothstep(bd, bowl_r * 0.15, bowl_r * 0.65)
z_top -= 0.12 * inner_dip
z_top = max(z_bot + 0.003, z_top)
return z_top, z_botFor flat designs, trays, coasters.
def uniform_disc(x, y, R=0.40, height=0.03):
r = math.sqrt(x*x + y*y)
if r >= R * 0.97:
return None, None
z_top = height * (1.0 - smoothstep(r, R * 0.85, R * 0.96))
if z_top < 0.003:
return None, None
return z_top, 0.002When creating new surface functions:
z_top = max(z_bot + 0.001, z_top)smoothstep for transitions, not raw mathz_bot = 0.002 (constant)IMPORTANT: Never run bpy.ops.outliner.orphans_purge() — it deletes materials that are temporarily unlinked during rebuilds. This is the #1 cause of lost materials.
def create_oak_wood():
mat = bpy.data.materials.new(name="Oak_Wood")
mat.use_nodes = True
nodes = mat.node_tree.nodes
links = mat.node_tree.links
nodes.clear()
output = nodes.new('ShaderNodeOutputMaterial')
output.location = (400, 0)
bsdf = nodes.new('ShaderNodeBsdfPrincipled')
bsdf.location = (100, 0)
bsdf.inputs['Roughness'].default_value = 0.4
links.new(bsdf.outputs['BSDF'], output.inputs['Surface'])
ramp = nodes.new('ShaderNodeValToRGB')
ramp.location = (-200, 0)
ramp.color_ramp.elements[0].color = (0.42, 0.26, 0.12, 1) # dark grain
ramp.color_ramp.elements[1].color = (0.62, 0.42, 0.22, 1) # light grain
links.new(ramp.outputs['Color'], bsdf.inputs['Base Color'])
noise = nodes.new('ShaderNodeTexNoise')
noise.location = (-400, 0)
noise.inputs['Scale'].default_value = 8
noise.inputs['Detail'].default_value = 6
links.new(noise.outputs['Fac'], ramp.inputs['Fac'])
mapping = nodes.new('ShaderNodeMapping')
mapping.location = (-600, 0)
mapping.inputs['Scale'].default_value = (2, 20, 2) # elongated grain
links.new(mapping.outputs['Vector'], noise.inputs['Vector'])
texcoord = nodes.new('ShaderNodeTexCoord')
texcoord.location = (-800, 0)
links.new(texcoord.outputs['Object'], mapping.inputs['Vector'])
return matbpy.data.materials.get("Name") before creating duplicatesobj.data.materials.clear() then re-appendWalnut: dark=(0.25, 0.13, 0.06), light=(0.45, 0.28, 0.14), Roughness=0.35 Maple: dark=(0.65, 0.50, 0.32), light=(0.82, 0.68, 0.48), Roughness=0.3 Concrete: Base Color=(0.6, 0.58, 0.55), Roughness=0.9, no grain texture Metal: Base Color=(0.8, 0.8, 0.8), Metallic=1.0, Roughness=0.2
import bpy
def setup_scene():
scene = bpy.context.scene
# Cycles with GPU
scene.render.engine = 'CYCLES'
scene.cycles.device = 'GPU'
scene.cycles.samples = 64
# World background (warm neutral)
world = scene.world or bpy.data.worlds.new("World")
scene.world = world
world.use_nodes = True
bg = world.node_tree.nodes.get('Background')
if bg:
bg.inputs['Color'].default_value = (0.92, 0.90, 0.87, 1)
bg.inputs['Strength'].default_value = 0.8
# Key light
key = bpy.data.lights.new("Key_Light", 'AREA')
key.energy = 120
key_obj = bpy.data.objects.new("Key_Light", key)
bpy.context.collection.objects.link(key_obj)
key_obj.location = (0.5, -0.5, 0.8)
key_obj.rotation_euler = (0.8, 0.2, 0.3)
# Fill light
fill = bpy.data.lights.new("Fill_Light", 'AREA')
fill.energy = 50
fill_obj = bpy.data.objects.new("Fill_Light", fill)
bpy.context.collection.objects.link(fill_obj)
fill_obj.location = (-0.5, 0.3, 0.5)
fill_obj.rotation_euler = (1.0, -0.3, -0.5)
# Floor plane
bpy.ops.mesh.primitive_plane_add(size=5, location=(0, 0, 0))
floor = bpy.context.active_object
floor.name = "Floor"
floor_mat = bpy.data.materials.new("Floor_Material")
floor_mat.use_nodes = True
floor_mat.node_tree.nodes['Principled BSDF'].inputs['Base Color'].default_value = (0.88, 0.86, 0.83, 1)
floor_mat.node_tree.nodes['Principled BSDF'].inputs['Roughness'].default_value = 0.3
floor.data.materials.append(floor_mat)
# Set viewport to rendered mode
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
area.spaces[0].shading.type = 'RENDERED'
breakBlender 4.x removed this input. Do NOT set Subsurface Color on Principled BSDF.
Cannot use bpy.ops.view3d.* operators without proper context. Use region_3d directly instead:
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
r3d = area.spaces[0].region_3d
# Set view properties directly on r3dDon't assume a Camera object exists. Use viewport controls instead of camera objects. Always list objects first: [obj.name for obj in bpy.data.objects]
Cause: bpy.ops.outliner.orphans_purge() deleted the material. Fix: NEVER purge orphans. If you must clean up, delete objects manually:
to_delete = [obj for obj in bpy.data.objects if obj.name.startswith('Slat_')]
bpy.ops.object.select_all(action='DESELECT')
for obj in to_delete:
obj.select_set(True)
bpy.ops.object.delete()
# Do NOT call orphans_purge after thisCause: subtracting bowl depth from z_top pushes it below z_bot, geometry filtered out. Fix: Use MULTIPLICATIVE depression:
# BAD - creates holes
z_top -= bowl_depth * gaussian
# GOOD - scales down, never goes below z_bot
bowl_scale = 1.0 - 0.85 * gaussian
z_top = z_bot + (z_top - z_bot) * bowl_scale~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.