Created
May 22, 2026 02:42
-
-
Save yunho-c/10f4e8977fb61b1285bc5e610122307e to your computer and use it in GitHub Desktop.
Blender: create toon outline
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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