222 lines
7.1 KiB
Python
222 lines
7.1 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
# 20260228 ChatGPT
|
||
|
|
# $Header$
|
||
|
|
# $Id$
|
||
|
|
#
|
||
|
|
# Usage:
|
||
|
|
# blender -b -P render_stl_variants.py -- /path/to/part.stl /path/to/out_prefix
|
||
|
|
#
|
||
|
|
# Output:
|
||
|
|
# <out_prefix>_solid.png
|
||
|
|
# <out_prefix>_solid_edges.png
|
||
|
|
# <out_prefix>_wire.png
|
||
|
|
# <out_prefix>_matcap.png
|
||
|
|
# <out_prefix>_freestyle.png
|
||
|
|
#
|
||
|
|
# Notes:
|
||
|
|
# - Uses orthographic camera + fit-to-bounds framing (consistent prints).
|
||
|
|
# - Workbench engine for fast technical-looking renders.
|
||
|
|
# - Freestyle enabled only for the freestyle variant.
|
||
|
|
|
||
|
|
import bpy
|
||
|
|
import sys
|
||
|
|
import math
|
||
|
|
from mathutils import Vector
|
||
|
|
|
||
|
|
def die(msg):
|
||
|
|
print(msg, file=sys.stderr)
|
||
|
|
raise SystemExit(1)
|
||
|
|
|
||
|
|
def argv_after_double_dash():
|
||
|
|
if "--" not in sys.argv:
|
||
|
|
return []
|
||
|
|
return sys.argv[sys.argv.index("--") + 1:]
|
||
|
|
|
||
|
|
def reset_scene():
|
||
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||
|
|
scene = bpy.context.scene
|
||
|
|
scene.unit_settings.system = 'NONE'
|
||
|
|
scene.render.resolution_x = 1200
|
||
|
|
scene.render.resolution_y = 1200
|
||
|
|
scene.render.film_transparent = False
|
||
|
|
scene.render.image_settings.file_format = 'PNG'
|
||
|
|
return scene
|
||
|
|
|
||
|
|
def import_stl(path):
|
||
|
|
bpy.ops.import_mesh.stl(filepath=path)
|
||
|
|
objs = [o for o in bpy.context.selected_objects if o.type == 'MESH']
|
||
|
|
if not objs:
|
||
|
|
die(f"Failed to import STL: {path}")
|
||
|
|
# join into one object for consistent bounds
|
||
|
|
bpy.context.view_layer.objects.active = objs[0]
|
||
|
|
for o in objs:
|
||
|
|
o.select_set(True)
|
||
|
|
bpy.ops.object.join()
|
||
|
|
obj = bpy.context.view_layer.objects.active
|
||
|
|
obj.name = "PART"
|
||
|
|
return obj
|
||
|
|
|
||
|
|
def center_origin_and_location(obj):
|
||
|
|
bpy.context.view_layer.objects.active = obj
|
||
|
|
obj.select_set(True)
|
||
|
|
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
|
||
|
|
obj.location = (0, 0, 0)
|
||
|
|
|
||
|
|
def bounds_world(obj):
|
||
|
|
# bounding box corners in world coordinates
|
||
|
|
mw = obj.matrix_world
|
||
|
|
corners = [mw @ Vector(c) for c in obj.bound_box]
|
||
|
|
xs = [c.x for c in corners]; ys = [c.y for c in corners]; zs = [c.z for c in corners]
|
||
|
|
min_v = Vector((min(xs), min(ys), min(zs)))
|
||
|
|
max_v = Vector((max(xs), max(ys), max(zs)))
|
||
|
|
size = max_v - min_v
|
||
|
|
center = (min_v + max_v) / 2.0
|
||
|
|
return center, size
|
||
|
|
|
||
|
|
def ensure_camera(scene):
|
||
|
|
cam_data = bpy.data.cameras.new("OrthoCam")
|
||
|
|
cam_data.type = 'ORTHO'
|
||
|
|
cam = bpy.data.objects.new("OrthoCam", cam_data)
|
||
|
|
scene.collection.objects.link(cam)
|
||
|
|
scene.camera = cam
|
||
|
|
return cam
|
||
|
|
|
||
|
|
def ensure_light_rig(scene):
|
||
|
|
# For workbench it barely matters, but keep a light for non-workbench fallback
|
||
|
|
ld = bpy.data.lights.new("Key", type='AREA')
|
||
|
|
lo = bpy.data.objects.new("Key", ld)
|
||
|
|
lo.location = (3, -3, 3)
|
||
|
|
scene.collection.objects.link(lo)
|
||
|
|
|
||
|
|
def set_isometric_camera(cam, target=(0,0,0), ortho_scale=2.0):
|
||
|
|
cam.data.ortho_scale = ortho_scale
|
||
|
|
cam.location = (3.0, -3.0, 3.0)
|
||
|
|
cam.rotation_euler = (math.radians(55), 0, math.radians(45))
|
||
|
|
|
||
|
|
def fit_camera_to_object(cam, obj, margin=1.15):
|
||
|
|
center, size = bounds_world(obj)
|
||
|
|
max_dim = max(size.x, size.y, size.z)
|
||
|
|
# ortho_scale roughly maps to view width; margin gives padding
|
||
|
|
cam.data.ortho_scale = max_dim * margin
|
||
|
|
|
||
|
|
def set_world_background(scene, gray=1.0):
|
||
|
|
# Simple white/gray background via World color
|
||
|
|
if scene.world is None:
|
||
|
|
scene.world = bpy.data.worlds.new("World")
|
||
|
|
scene.world.use_nodes = False
|
||
|
|
scene.world.color = (gray, gray, gray)
|
||
|
|
|
||
|
|
def workbench_common(scene):
|
||
|
|
scene.render.engine = 'BLENDER_WORKBENCH'
|
||
|
|
scene.display.shading.light = 'STUDIO'
|
||
|
|
scene.display.shading.color_type = 'SINGLE'
|
||
|
|
scene.display.shading.single_color = (0.82, 0.82, 0.82) # light gray “clay”
|
||
|
|
scene.display.shading.show_backface_culling = False
|
||
|
|
scene.display.shading.show_xray = False
|
||
|
|
|
||
|
|
def variant_solid(scene):
|
||
|
|
workbench_common(scene)
|
||
|
|
scene.display.shading.show_wireframes = False
|
||
|
|
|
||
|
|
def variant_wire(scene):
|
||
|
|
workbench_common(scene)
|
||
|
|
scene.display.shading.show_wireframes = True
|
||
|
|
scene.display.shading.wireframe_color_type = 'SINGLE'
|
||
|
|
scene.display.shading.wireframe_color = (0.0, 0.0, 0.0)
|
||
|
|
scene.display.shading.wireframe_opacity = 1.0
|
||
|
|
scene.display.shading.wireframe_thickness = 1
|
||
|
|
|
||
|
|
def variant_solid_edges(scene):
|
||
|
|
# Workbench wire overlay on top of solid
|
||
|
|
workbench_common(scene)
|
||
|
|
scene.display.shading.show_wireframes = True
|
||
|
|
scene.display.shading.wireframe_color_type = 'SINGLE'
|
||
|
|
scene.display.shading.wireframe_color = (0.0, 0.0, 0.0)
|
||
|
|
scene.display.shading.wireframe_opacity = 1.0
|
||
|
|
scene.display.shading.wireframe_thickness = 1
|
||
|
|
|
||
|
|
def variant_matcap(scene):
|
||
|
|
# Workbench “Matcap-like” studio + cavity (helps valleys/holes read on paper)
|
||
|
|
workbench_common(scene)
|
||
|
|
scene.display.shading.show_wireframes = False
|
||
|
|
scene.display.shading.show_cavity = True
|
||
|
|
scene.display.shading.cavity_type = 'WORLD'
|
||
|
|
scene.display.shading.cavity_ridge_factor = 1.0
|
||
|
|
scene.display.shading.cavity_valley_factor = 1.0
|
||
|
|
|
||
|
|
def variant_freestyle(scene):
|
||
|
|
# Freestyle line drawing + light solid base
|
||
|
|
# Works with Workbench in many cases, but Freestyle is historically tied to Eevee/Cycles.
|
||
|
|
# Here we switch to Eevee for reliability.
|
||
|
|
scene.render.engine = 'BLENDER_EEVEE'
|
||
|
|
scene.eevee.use_gtao = True
|
||
|
|
scene.render.film_transparent = False
|
||
|
|
|
||
|
|
# Setup simple material
|
||
|
|
mat = bpy.data.materials.new("Clay")
|
||
|
|
mat.use_nodes = True
|
||
|
|
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
||
|
|
bsdf.inputs["Base Color"].default_value = (0.85, 0.85, 0.85, 1.0)
|
||
|
|
bsdf.inputs["Roughness"].default_value = 1.0
|
||
|
|
|
||
|
|
obj = bpy.data.objects.get("PART")
|
||
|
|
if obj and obj.data.materials:
|
||
|
|
obj.data.materials[0] = mat
|
||
|
|
elif obj:
|
||
|
|
obj.data.materials.append(mat)
|
||
|
|
|
||
|
|
# Enable Freestyle
|
||
|
|
scene.render.use_freestyle = True
|
||
|
|
# Default freestyle line set is OK; keep lines black and modest thickness
|
||
|
|
if scene.view_layers:
|
||
|
|
vl = scene.view_layers[0]
|
||
|
|
vl.freestyle_settings.linesets.new("LineSet") if not vl.freestyle_settings.linesets else None
|
||
|
|
# Render Properties -> Freestyle settings are somewhat verbose to tune; default is acceptable for v1.
|
||
|
|
|
||
|
|
def render(scene, outpath):
|
||
|
|
scene.render.filepath = outpath
|
||
|
|
bpy.ops.render.render(write_still=True)
|
||
|
|
|
||
|
|
def main():
|
||
|
|
args = argv_after_double_dash()
|
||
|
|
if len(args) != 2:
|
||
|
|
die("Expected: <input.stl> <out_prefix>")
|
||
|
|
|
||
|
|
in_stl, out_prefix = args
|
||
|
|
scene = reset_scene()
|
||
|
|
obj = import_stl(in_stl)
|
||
|
|
center_origin_and_location(obj)
|
||
|
|
ensure_light_rig(scene)
|
||
|
|
cam = ensure_camera(scene)
|
||
|
|
set_isometric_camera(cam)
|
||
|
|
fit_camera_to_object(cam, obj, margin=1.20)
|
||
|
|
set_world_background(scene, gray=1.0)
|
||
|
|
|
||
|
|
# SOLID
|
||
|
|
variant_solid(scene)
|
||
|
|
scene.render.use_freestyle = False
|
||
|
|
render(scene, f"{out_prefix}_solid.png")
|
||
|
|
|
||
|
|
# SOLID + EDGES
|
||
|
|
variant_solid_edges(scene)
|
||
|
|
scene.render.use_freestyle = False
|
||
|
|
render(scene, f"{out_prefix}_solid_edges.png")
|
||
|
|
|
||
|
|
# WIRE
|
||
|
|
variant_wire(scene)
|
||
|
|
scene.render.use_freestyle = False
|
||
|
|
render(scene, f"{out_prefix}_wire.png")
|
||
|
|
|
||
|
|
# MATCAP-ish (cavity)
|
||
|
|
variant_matcap(scene)
|
||
|
|
scene.render.use_freestyle = False
|
||
|
|
render(scene, f"{out_prefix}_matcap.png")
|
||
|
|
|
||
|
|
# FREESTYLE
|
||
|
|
variant_freestyle(scene)
|
||
|
|
render(scene, f"{out_prefix}_freestyle.png")
|
||
|
|
|
||
|
|
print("Done.")
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|