initial migration, not vetted

This commit is contained in:
John Poole 2026-02-28 16:33:16 -08:00
commit 09bc6a5304
29 changed files with 122147 additions and 0 deletions

View file

@ -0,0 +1,309 @@
#!/usr/bin/env python3
# 20260228 ChatGPT
# $Header$
# $Id$
#
# SCRIPT_ID: render_glb_variants.py
# SCRIPT_VERSION: 6
#
# Goal:
# Headless-safe PNG rendering for GLB models in environments where EGL/OpenGL may crash.
# Uses Cycles CPU (no Eevee/Workbench).
#
# Usage:
# blender -b -P blender/render_glb_variants.py -- /path/to/part.glb /path/to/out_prefix
import bpy
import sys
import math
from mathutils import Vector
SCRIPT_ID = "render_glb_variants.py"
SCRIPT_VERSION = 6
def log(msg):
print(f"{SCRIPT_ID} v{SCRIPT_VERSION}: {msg}")
def die(msg):
print(f"{SCRIPT_ID} v{SCRIPT_VERSION} ERROR: {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.render.engine = 'CYCLES'
# Force CPU
scene.cycles.device = 'CPU'
# Fast-ish defaults for documentation renders
scene.cycles.samples = 32
scene.cycles.preview_samples = 16
scene.cycles.use_denoising = True
scene.render.resolution_x = 1200
scene.render.resolution_y = 1200
scene.render.image_settings.file_format = 'PNG'
scene.render.film_transparent = False
scene.render.use_freestyle = False
return scene
def set_world_background_white(scene, strength=1.0):
if scene.world is None:
scene.world = bpy.data.worlds.new("World")
world = scene.world
world.use_nodes = True
nt = world.node_tree
nt.nodes.clear()
out = nt.nodes.new("ShaderNodeOutputWorld")
bg = nt.nodes.new("ShaderNodeBackground")
bg.inputs["Color"].default_value = (1.0, 1.0, 1.0, 1.0)
bg.inputs["Strength"].default_value = strength
nt.links.new(bg.outputs["Background"], out.inputs["Surface"])
def import_glb(path):
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=path)
after = set(bpy.data.objects)
new_objs = [o for o in (after - before)]
meshes = [o for o in new_objs if o.type == 'MESH']
if not meshes:
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH']
if not meshes:
die(f"Failed to import GLB (no mesh objects found): {path}")
if len(meshes) > 1:
bpy.ops.object.select_all(action='DESELECT')
for o in meshes:
o.select_set(True)
bpy.context.view_layer.objects.active = meshes[0]
bpy.ops.object.join()
obj = bpy.context.view_layer.objects.active
else:
obj = meshes[0]
obj.name = "PART"
return obj
def center_origin_and_location(obj):
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
obj.location = (0, 0, 0)
def bounds_world(obj):
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):
# Cycles likes real lights
def add_area(name, loc, power):
ld = bpy.data.lights.new(name, type='AREA')
ld.energy = power
lo = bpy.data.objects.new(name, ld)
lo.location = loc
scene.collection.objects.link(lo)
add_area("Key", (3, -3, 3), 800)
add_area("Fill", (-3, 3, 2), 400)
add_area("Top", (0, 0, 4), 300)
def set_isometric_camera(cam):
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.20):
_, size = bounds_world(obj)
max_dim = max(size.x, size.y, size.z)
cam.data.ortho_scale = max_dim * margin
def set_input_if_exists(node, socket_name, value):
if socket_name in node.inputs:
node.inputs[socket_name].default_value = value
return True
return False
def make_material_clay(name="Clay", color=(0.83, 0.83, 0.83, 1.0)):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
nt = mat.node_tree
nt.nodes.clear()
out = nt.nodes.new("ShaderNodeOutputMaterial")
bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
set_input_if_exists(bsdf, "Base Color", color)
set_input_if_exists(bsdf, "Roughness", 1.0)
if not set_input_if_exists(bsdf, "Specular", 0.0):
set_input_if_exists(bsdf, "Specular IOR Level", 0.0)
nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
return mat
def make_material_black_emission(name="WireBlack"):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
nt = mat.node_tree
nt.nodes.clear()
out = nt.nodes.new("ShaderNodeOutputMaterial")
em = nt.nodes.new("ShaderNodeEmission")
em.inputs["Color"].default_value = (0, 0, 0, 1)
em.inputs["Strength"].default_value = 1.0
nt.links.new(em.outputs["Emission"], out.inputs["Surface"])
return mat
def make_material_feature_ao(name="FeatureAO"):
"""
Cavity-like look using Ambient Occlusion node when available.
Falls back to normal-vector shading if AO node isn't available.
"""
mat = bpy.data.materials.new(name)
mat.use_nodes = True
nt = mat.node_tree
nt.nodes.clear()
out = nt.nodes.new("ShaderNodeOutputMaterial")
bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
set_input_if_exists(bsdf, "Roughness", 1.0)
if not set_input_if_exists(bsdf, "Specular", 0.0):
set_input_if_exists(bsdf, "Specular IOR Level", 0.0)
# Try AO node
try:
ao = nt.nodes.new("ShaderNodeAmbientOcclusion")
ao.inputs["Distance"].default_value = 0.2
ramp = nt.nodes.new("ShaderNodeValToRGB")
# Slight contrast boost
ramp.color_ramp.elements[0].position = 0.35
ramp.color_ramp.elements[1].position = 0.85
nt.links.new(ao.outputs["AO"], ramp.inputs["Fac"])
nt.links.new(ramp.outputs["Color"], bsdf.inputs["Base Color"])
except Exception:
# Fallback: normals shading
geo = nt.nodes.new("ShaderNodeNewGeometry")
add = nt.nodes.new("ShaderNodeVectorMath"); add.operation = 'ADD'
add.inputs[1].default_value = (1.0, 1.0, 1.0)
mul = nt.nodes.new("ShaderNodeVectorMath"); mul.operation = 'MULTIPLY'
mul.inputs[1].default_value = (0.5, 0.5, 0.5)
nt.links.new(geo.outputs["Normal"], add.inputs[0])
nt.links.new(add.outputs["Vector"], mul.inputs[0])
nt.links.new(mul.outputs["Vector"], bsdf.inputs["Base Color"])
nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
return mat
def assign_single_material(obj, mat):
obj.data.materials.clear()
obj.data.materials.append(mat)
def duplicate_object(obj, name):
dup = obj.copy()
dup.data = obj.data.copy()
dup.name = name
bpy.context.scene.collection.objects.link(dup)
return dup
def delete_object(obj):
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.ops.object.delete()
def enable_freestyle(scene, thickness=1.0):
scene.render.use_freestyle = True
vl = scene.view_layers[0]
fs = vl.freestyle_settings
if not fs.linesets:
fs.linesets.new("LineSet")
ls = fs.linesets[0]
try:
ls.linestyle.thickness = thickness
except Exception:
pass
def render(scene, outpath):
scene.render.filepath = outpath
bpy.ops.render.render(write_still=True)
def main():
log("starting")
args = argv_after_double_dash()
if len(args) != 2:
die("Expected: <input.glb> <out_prefix>")
in_glb, out_prefix = args
scene = reset_scene()
set_world_background_white(scene)
obj = import_glb(in_glb)
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)
clay = make_material_clay()
wire_black = make_material_black_emission()
feature = make_material_feature_ao()
# 1) SOLID
scene.render.use_freestyle = False
assign_single_material(obj, clay)
render(scene, f"{out_prefix}_solid.png")
# 2) SOLID + EDGES
scene.render.use_freestyle = False
assign_single_material(obj, clay)
enable_freestyle(scene, thickness=1.0)
render(scene, f"{out_prefix}_solid_edges.png")
# 3) PURE WIRE
scene.render.use_freestyle = False
wire_obj = duplicate_object(obj, "PART_WIRE")
obj.hide_render = True
wire_obj.hide_render = False
assign_single_material(wire_obj, wire_black)
mod = wire_obj.modifiers.new("Wireframe", type='WIREFRAME')
mod.thickness = 0.0015
mod.use_replace = True
mod.use_boundary = True
render(scene, f"{out_prefix}_wire.png")
obj.hide_render = False
delete_object(wire_obj)
# 4) FEATURE POP (AO/cavity-ish)
scene.render.use_freestyle = False
assign_single_material(obj, feature)
render(scene, f"{out_prefix}_feature.png")
# 5) FREESTYLE ONLY
scene.render.use_freestyle = False
white = make_material_clay(name="WhiteClay", color=(1.0, 1.0, 1.0, 1.0))
assign_single_material(obj, white)
enable_freestyle(scene, thickness=1.2)
render(scene, f"{out_prefix}_freestyle.png")
log("done")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,222 @@
#!/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()