Skip to content

Instantly share code, notes, and snippets.

@yunho-c
Created May 22, 2026 02:42
Show Gist options
  • Select an option

  • Save yunho-c/10f4e8977fb61b1285bc5e610122307e to your computer and use it in GitHub Desktop.

Select an option

Save yunho-c/10f4e8977fb61b1285bc5e610122307e to your computer and use it in GitHub Desktop.
Blender: create toon outline
import bpy
import bmesh
import math
# ============================================================
# Toon / technical-diagram outline setup using Freestyle
# ============================================================
#
# What this does:
# - Enables Freestyle rendering.
# - Creates a bold dark outline style.
# - Creates a Freestyle line set using silhouettes, creases,
# borders, contours, and explicitly marked Freestyle edges.
# - Marks hard mesh edges as Freestyle edges using the operator
# bpy.ops.mesh.mark_freestyle_edge(), avoiding the removed /
# unavailable MeshEdge.use_freestyle_mark attribute.
#
# Good for:
# - Cubes
# - Cylinders
# - Simple robot-arm / technical-diagram geometry
# - Cel/toon-style renders
#
# ============================================================
# -----------------------------
# User settings
# -----------------------------
OUTLINE_COLOR = (0.00, 0.17, 0.22) # dark teal / blue-black
OUTLINE_THICKNESS = 3.0 # Freestyle line thickness in pixels-ish
HARD_EDGE_ANGLE_DEG = 30.0
# Edges sharper than this angle are marked as Freestyle edges.
# Cubes: all hard edges get marked.
# Smooth cylinders: mostly rim/boundary/silhouette behavior.
# Low-poly cylinders: lower/higher this depending on how many segment lines appear.
USE_SELECTED_ONLY = True
# True = apply only to selected mesh objects.
# False = apply to all visible mesh objects in the scene.
CLEAR_EXISTING_FREESTYLE_MARKS = True
# True = remove existing Freestyle edge marks before adding new ones.
# False = preserve existing marks and add more.
RESET_EXISTING_LINESETS = True
# True = remove existing Freestyle line sets and create a clean one.
# False = add this line set alongside existing ones.
ENABLE_MATERIAL_BOUNDARY_LINES = False
# True can be useful if you use different materials on one mesh and want
# outlines between material regions.
SET_CAMERA_TO_ORTHOGRAPHIC = False
ORTHO_SCALE = 6.0
SET_RENDER_RESOLUTION = False
RESOLUTION_X = 1600
RESOLUTION_Y = 1200
# -----------------------------
# Small compatibility helpers
# -----------------------------
def safe_set(obj, attr, value):
"""
Set a property if it exists.
Blender changes API details between versions, so this keeps the script
from failing on optional Freestyle properties.
"""
try:
if hasattr(obj, attr):
setattr(obj, attr, value)
except Exception:
pass
def ensure_object_mode():
"""
Many mesh operations are safest when starting from Object Mode.
"""
active = bpy.context.view_layer.objects.active
if active is not None and active.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
def get_target_mesh_objects():
"""
Return selected visible mesh objects, or all visible mesh objects.
"""
if USE_SELECTED_ONLY:
selected_meshes = [
obj for obj in bpy.context.selected_objects
if obj.type == "MESH" and obj.visible_get()
]
if selected_meshes:
return selected_meshes
return [
obj for obj in bpy.context.scene.objects
if obj.type == "MESH" and obj.visible_get()
]
# -----------------------------
# Edge detection / marking
# -----------------------------
def compute_hard_edge_indices(obj, angle_deg):
"""
Compute which mesh edges should be marked as Freestyle edges.
Criteria:
- Boundary/open edges
- Non-manifold edges
- Edges between two faces whose angle is sharper than angle_deg
"""
mesh = obj.data
mesh.update(calc_edges=True)
edge_key_to_index = {edge.key: edge.index for edge in mesh.edges}
edge_to_polys = {edge.index: [] for edge in mesh.edges}
for poly in mesh.polygons:
for edge_key in poly.edge_keys:
edge_index = edge_key_to_index.get(edge_key)
if edge_index is not None:
edge_to_polys[edge_index].append(poly.index)
cos_threshold = math.cos(math.radians(angle_deg))
edges_to_mark = set()
for edge in mesh.edges:
adjacent_polys = edge_to_polys.get(edge.index, [])
# Boundary / open edge
if len(adjacent_polys) < 2:
edges_to_mark.add(edge.index)
continue
# Non-manifold edge
if len(adjacent_polys) > 2:
edges_to_mark.add(edge.index)
continue
# Regular edge between two faces
p0 = mesh.polygons[adjacent_polys[0]]
p1 = mesh.polygons[adjacent_polys[1]]
# Dot product lower than threshold means the edge is sharper.
if p0.normal.dot(p1.normal) < cos_threshold:
edges_to_mark.add(edge.index)
return edges_to_mark
def clear_all_freestyle_edge_marks(obj):
"""
Clear Freestyle edge marks using Blender's mesh operator.
This avoids direct use of MeshEdge.use_freestyle_mark.
"""
ensure_object_mode()
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_mode(type="EDGE")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.mark_freestyle_edge(clear=True)
bpy.ops.mesh.select_all(action="DESELECT")
bpy.ops.object.mode_set(mode="OBJECT")
def mark_edges_as_freestyle(obj, edge_indices):
"""
Select a set of edges in Edit Mode and mark them as Freestyle edges.
Uses bmesh for reliable edit-mode selection.
"""
if not edge_indices:
print(f"No edges to mark on {obj.name}")
return
ensure_object_mode()
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_mode(type="EDGE")
bpy.ops.mesh.select_all(action="DESELECT")
bm = bmesh.from_edit_mesh(obj.data)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
bm.faces.ensure_lookup_table()
# Clear bmesh selection explicitly.
for v in bm.verts:
v.select_set(False)
for e in bm.edges:
e.select_set(False)
for f in bm.faces:
f.select_set(False)
# Select edges to mark.
for edge_index in edge_indices:
if edge_index < len(bm.edges):
edge = bm.edges[edge_index]
edge.select_set(True)
# Keep selection state valid.
for vert in edge.verts:
vert.select_set(True)
bmesh.update_edit_mesh(obj.data)
# This is the key call.
# It marks the currently selected edges as Freestyle edges.
bpy.ops.mesh.mark_freestyle_edge(clear=False)
bpy.ops.mesh.select_all(action="DESELECT")
bpy.ops.object.mode_set(mode="OBJECT")
def mark_hard_edges_for_freestyle(obj, angle_deg=30.0, clear_existing=True):
"""
Full per-object marking routine.
"""
if clear_existing:
clear_all_freestyle_edge_marks(obj)
edges_to_mark = compute_hard_edge_indices(obj, angle_deg)
mark_edges_as_freestyle(obj, edges_to_mark)
print(f"Marked {len(edges_to_mark)} Freestyle edge(s) on {obj.name}")
# -----------------------------
# Freestyle configuration
# -----------------------------
def configure_freestyle():
"""
Enable Freestyle and create a clean line set/style for toon outlines.
"""
scene = bpy.context.scene
view_layer = bpy.context.view_layer
# Enable Freestyle.
scene.render.use_freestyle = True
safe_set(view_layer, "use_freestyle", True)
# Absolute line thickness is easier to control for diagram renders.
safe_set(scene.render, "line_thickness_mode", "ABSOLUTE")
safe_set(scene.render, "line_thickness", 1.0)
fs = view_layer.freestyle_settings
# Use normal UI-style Freestyle settings, not Python style modules.
safe_set(fs, "mode", "EDITOR")
# Optional: clear existing line sets.
if RESET_EXISTING_LINESETS:
try:
while len(fs.linesets) > 0:
fs.linesets.remove(fs.linesets[0])
except Exception as e:
print("Could not remove existing Freestyle line sets:", e)
# Create or reuse a line style.
linestyle_name = "Toon_Dark_Teal_Outline"
linestyle = bpy.data.linestyles.get(linestyle_name)
if linestyle is None:
linestyle = bpy.data.linestyles.new(linestyle_name)
linestyle.color = OUTLINE_COLOR
linestyle.alpha = 1.0
linestyle.thickness = OUTLINE_THICKNESS
safe_set(linestyle, "thickness_position", "CENTER")
safe_set(linestyle, "use_chaining", True)
# Create line set.
lineset = fs.linesets.new("Toon Visible Outlines")
lineset.linestyle = linestyle
# Visibility.
lineset.select_by_visibility = True
safe_set(lineset, "visibility", "VISIBLE")
# Feature edge selection.
lineset.select_by_edge_types = True
safe_set(lineset, "edge_type_negation", "INCLUSIVE")
safe_set(lineset, "edge_type_combination", "OR")
# Important edge types for this style.
safe_set(lineset, "select_silhouette", True)
safe_set(lineset, "select_contour", True)
safe_set(lineset, "select_crease", True)
safe_set(lineset, "select_border", True)
safe_set(lineset, "select_edge_mark", True)
# Optional material-boundary outlines.
safe_set(lineset, "select_material_boundary", ENABLE_MATERIAL_BOUNDARY_LINES)
# Usually noisy for clean technical figures.
safe_set(lineset, "select_suggestive_contour", False)
safe_set(lineset, "select_ridge_valley", False)
return lineset
# -----------------------------
# Optional camera/render setup
# -----------------------------
def optionally_set_camera_orthographic():
if not SET_CAMERA_TO_ORTHOGRAPHIC:
return
scene = bpy.context.scene
cam = scene.camera
if cam is None:
bpy.ops.object.camera_add(
location=(5.0, -7.0, 5.0),
rotation=(math.radians(60.0), 0.0, math.radians(38.0)),
)
cam = bpy.context.object
scene.camera = cam
cam.data.type = "ORTHO"
cam.data.ortho_scale = ORTHO_SCALE
def optionally_set_render_resolution():
if not SET_RENDER_RESOLUTION:
return
scene = bpy.context.scene
scene.render.resolution_x = RESOLUTION_X
scene.render.resolution_y = RESOLUTION_Y
scene.render.film_transparent = False
# -----------------------------
# Main
# -----------------------------
def main():
original_active = bpy.context.view_layer.objects.active
original_selection = list(bpy.context.selected_objects)
ensure_object_mode()
target_objects = get_target_mesh_objects()
if not target_objects:
raise RuntimeError(
"No visible mesh objects found. "
"Select mesh objects, or set USE_SELECTED_ONLY = False."
)
for obj in target_objects:
mark_hard_edges_for_freestyle(
obj,
angle_deg=HARD_EDGE_ANGLE_DEG,
clear_existing=CLEAR_EXISTING_FREESTYLE_MARKS,
)
configure_freestyle()
optionally_set_camera_orthographic()
optionally_set_render_resolution()
# Restore original selection as a courtesy.
ensure_object_mode()
bpy.ops.object.select_all(action="DESELECT")
for obj in original_selection:
if obj.name in bpy.context.scene.objects:
obj.select_set(True)
if original_active is not None and original_active.name in bpy.context.scene.objects:
bpy.context.view_layer.objects.active = original_active
print("")
print("============================================================")
print(f"Freestyle toon outlines configured for {len(target_objects)} mesh object(s).")
print("Render the scene to see the outlines.")
print("If outlines do not show, check Render Properties > Freestyle is enabled.")
print("============================================================")
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment