#!/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: ") 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()