QUELLCODE / PY
build_print_model.py
design/v2/build_print_model.py
1# OpenAss: nur privater, experimenteller Gebrauch; auf eigene Verantwortung.2# Niemals Unternehmensrechner, Produktivsysteme, Industrieanlagen oder3# sicherheitskritische Systeme anschließen oder bedienen.4# Haftungshinweis und gesetzliche Grenzen: ../../DISCLAIMER.txt.5"""Run inside Blender via its Python Console. All modeled dimensions are in mm."""6import bpy7import math8import sys9from pathlib import Path10from mathutils import Vector1112ROOT = Path(__file__).resolve().parent13sys.path.insert(0, str(ROOT.parent))14import model_components15from public_export import protect_render_metadata, save_public_blend16import importlib17importlib.reload(model_components)18(ROOT / "renders").mkdir(exist_ok=True)1920for obj in list(bpy.data.objects):21 bpy.data.objects.remove(obj, do_unlink=True)22scene = bpy.context.scene23protect_render_metadata(scene)24scene.name = "LINK / Print Edition"25scene.unit_settings.system = "METRIC"26scene.unit_settings.length_unit = "MILLIMETERS"27scene.unit_settings.scale_length = 128model_collection = bpy.data.collections.new("01 • LINK PRINT / Product")29scene.collection.children.link(model_collection)30studio_collection = bpy.data.collections.new("02 • Studio / Cameras")31scene.collection.children.link(studio_collection)323334def material(name, color, metallic=0, roughness=0.4, transmission=0, emission=0):35 mat = bpy.data.materials.new(name)36 mat.use_nodes = True37 bsdf = mat.node_tree.nodes.get("Principled BSDF")38 bsdf.inputs["Base Color"].default_value = (*color, 1)39 bsdf.inputs["Metallic"].default_value = metallic40 bsdf.inputs["Roughness"].default_value = roughness41 bsdf.inputs["Transmission Weight"].default_value = transmission42 bsdf.inputs["IOR"].default_value = 1.4643 if emission:44 bsdf.inputs["Emission Color"].default_value = (*color, 1)45 bsdf.inputs["Emission Strength"].default_value = emission46 return mat474849mats = {50 "graphite": material("Graphite / bead-blasted anodised aluminium", (0.09,0.115,0.145),0.72,0.30),51 "black": material("Black anodised details",(0.018,0.025,0.034),0.45,0.28),52 "teal": material("Sea glass / status light",(0.028,0.64,0.56),0.25,0.24,emission=0.7),53 "pcb": material("Pi green solder mask",(0.018,0.23,0.12),0.12,0.46),54 "pcb_dark": material("Safety carrier / deep green",(0.025,0.095,0.075),0.10,0.40),55 "silver": material("Brushed nickel / connectors",(0.53,0.60,0.68),0.88,0.22),56 "gold": material("Gold contact plating",(0.83,0.54,0.13),0.85,0.23),57 "white": material("Warm white silkscreen",(0.78,0.85,0.89),0.0,0.45),58 "red": material("Safety red / satin polymer",(0.65,0.025,0.032),0.0,0.29),59 "rubber": material("Soft touch cable / feet",(0.008,0.012,0.017),0.0,0.58),60 "copper": material("Copper interconnect",(0.68,0.29,0.09),0.75,0.30),61 "glass": material("Smoked glass / service window",(0.58,0.73,0.77),0.04,0.095,transmission=0.93),62 "floor": material("Studio / warm pale grey",(0.56,0.60,0.63),0,0.7),63 "yellow": material("STOP identification ring",(0.90,0.57,0.07),0.2,0.33),64}656667class Modeling:68 def link(self,obj, parent=None, collection=None):69 for col in list(obj.users_collection):70 col.objects.unlink(obj)71 (collection or model_collection).objects.link(obj)72 if parent is not None:73 # Inputs are world coordinates; preserve them while grouping assemblies.74 bpy.context.view_layer.update()75 world = obj.matrix_world.copy()76 obj.parent = parent77 obj.matrix_world = world78 return obj7980 def empty(self,name,location_mm=(0,0,0)):81 obj = bpy.data.objects.new(name,None)82 obj.location = [v/1000 for v in location_mm]83 model_collection.objects.link(obj)84 return obj8586 def box(self,name,location_mm,size_mm,material,bevel_mm=0.5,parent=None):87 bpy.ops.mesh.primitive_cube_add(size=1, location=[v/1000 for v in location_mm])88 obj = bpy.context.object89 obj.name = name90 obj.dimensions = [v/1000 for v in size_mm]91 bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)92 obj.data.materials.append(material)93 if bevel_mm:94 bevel = obj.modifiers.new("Machined edge radius","BEVEL")95 bevel.width = bevel_mm/100096 bevel.segments = 497 bevel.harden_normals = True98 try:99 obj.modifiers.new("Balanced surface normals","WEIGHTED_NORMAL")100 except Exception:101 pass102 return self.link(obj,parent)103104 def cylinder(self,name,location_mm,radius_mm,depth_mm,material,rotation=(0,0,0),parent=None,vertices=48):105 bpy.ops.mesh.primitive_cylinder_add(vertices=vertices,radius=radius_mm/1000,depth=depth_mm/1000,106 location=[v/1000 for v in location_mm],rotation=rotation)107 obj = bpy.context.object108 obj.name = name109 obj.data.materials.append(material)110 bevel=obj.modifiers.new("CNC / soft edge","BEVEL")111 bevel.width=min(0.35,radius_mm/8,depth_mm/6)/1000112 bevel.segments=3113 bevel.harden_normals=True114 return self.link(obj,parent)115116 def text(self,name,text,location_mm,size_mm,material,rotation=(0,0,0),parent=None):117 curve=bpy.data.curves.new(name,"FONT")118 curve.body=text119 curve.size=size_mm/1000120 curve.extrude=0.015/1000121 curve.space_character=1.08122 obj=bpy.data.objects.new(name,curve)123 obj.location=[v/1000 for v in location_mm]124 obj.rotation_euler=rotation125 curve.materials.append(material)126 model_collection.objects.link(obj)127 if parent:128 bpy.context.view_layer.update()129 world=obj.matrix_world.copy();obj.parent=parent;obj.matrix_world=world130 return obj131132 def curve(self,name,points_mm,radius_mm,material,parent=None):133 curve=bpy.data.curves.new(name,"CURVE")134 curve.dimensions="3D"135 curve.resolution_u=20136 curve.bevel_depth=radius_mm/1000137 curve.bevel_resolution=5138 spline=curve.splines.new("BEZIER")139 spline.bezier_points.add(len(points_mm)-1)140 for point,xyz in zip(spline.bezier_points,points_mm):141 point.co=[v/1000 for v in xyz]142 point.handle_left_type="AUTO"143 point.handle_right_type="AUTO"144 obj=bpy.data.objects.new(name,curve)145 curve.materials.append(material)146 model_collection.objects.link(obj)147 if parent:148 bpy.context.view_layer.update()149 world=obj.matrix_world.copy();obj.parent=parent;obj.matrix_world=world150 return obj151152153154api = Modeling()155print_collection = bpy.data.collections.new('02 • Printable enclosure parts')156scene.collection.children.link(print_collection)157studio_collection.name = '03 • Studio / Cameras'158(ROOT/'prints').mkdir(exist_ok=True)159160161def polymer(name, color):162 mat=material(name,color,roughness=.53)163 nodes=mat.node_tree.nodes; links=mat.node_tree.links164 tex=nodes.new('ShaderNodeTexNoise');tex.inputs['Scale'].default_value=2400165 tex.inputs['Detail'].default_value=2.0;tex.inputs['Roughness'].default_value=.65166 coords=nodes.new('ShaderNodeTexCoord')167 links.new(coords.outputs['Object'],tex.inputs['Vector'])168 bump=nodes.new('ShaderNodeBump');bump.inputs['Strength'].default_value=.22169 bump.inputs['Distance'].default_value=.000025170 links.new(tex.outputs['Fac'],bump.inputs['Height'])171 links.new(bump.outputs['Normal'],nodes.get('Principled BSDF').inputs['Normal'])172 return mat173174175ivory=polymer('Warm chalk / fine matte printed polymer',(.72,.695,.64))176charcoal=polymer('Basalt / matte printed polymer',(.022,.028,.029))177quiet_ink=material('Warm graphite / subtle marking',(.21,.225,.215),roughness=.65)178warm_led=material('Warm white / status aperture',(.83,.91,.82),roughness=.38,emission=1.7)179red_cap=polymer('STOP / muted signal red',(.47,.032,.023))180181182def outline(w,d,r,n=18):183 points=[]184 for cx,cy,start in [(w/2-r,d/2-r,0),(-w/2+r,d/2-r,90),185 (-w/2+r,-d/2+r,180),(w/2-r,-d/2+r,270)]:186 for i in range(n+1):187 a=math.radians(start+i*90/n)188 points.append((cx+r*math.cos(a),cy+r*math.sin(a)))189 return points190191192def loft(name,outer,inner=None,mat=None,parent=None,printable=False):193 # Each ring is width, depth, XY corner radius, height. Closed solid manifold.194 loops=outer+(inner or [])195 count=len(outline(*outer[0][:3])); verts=[]196 for w,d,r,z in loops:197 verts += [(x/1000,y/1000,z/1000) for x,y in outline(w,d,r)]198 faces=[]199 for ring in range(len(outer)-1):200 for i in range(count):201 j=(i+1)%count; a=ring*count202 faces.append((a+i,a+j,a+count+j,a+count+i))203 faces.append(tuple((len(outer)-1)*count+i for i in range(count)))204 if inner:205 begin=len(outer)*count206 faces.append(tuple(begin+i for i in range(count-1,-1,-1)))207 for ring in range(len(inner)-1):208 a=begin+ring*count209 for i in range(count):210 j=(i+1)%count211 faces.append((a+i,a+j,a+count+j,a+count+i))212 bottom=(len(loops)-1)*count213 for i in range(count):214 j=(i+1)%count;faces.append((j,i,bottom+i,bottom+j))215 else:216 faces.append(tuple(range(count-1,-1,-1)))217 mesh=bpy.data.meshes.new(name);mesh.from_pydata(verts,[],faces);mesh.update()218 ob=bpy.data.objects.new(name,mesh)219 (print_collection if printable else model_collection).objects.link(ob)220 mesh.materials.append(mat or ivory)221 for poly in mesh.polygons:222 poly.use_smooth=len(poly.vertices)==4223 mod=ob.modifiers.new('Weighted satin normals','WEIGHTED_NORMAL');mod.weight=50224 if parent: ob.parent=parent225 return ob226227228def boolean(target,cutter,operation='DIFFERENCE'):229 bpy.context.view_layer.objects.active=cutter230 for modifier in list(cutter.modifiers):231 bpy.ops.object.modifier_apply(modifier=modifier.name)232 mod=target.modifiers.new('Printed geometry / '+operation,'BOOLEAN')233 mod.operation=operation;mod.solver='EXACT';mod.object=cutter234 bpy.context.view_layer.objects.active=target235 bpy.ops.object.modifier_apply(modifier=mod.name)236 bpy.data.objects.remove(cutter,do_unlink=True)237238239def cut_box(target,name,p,size,bevel=.5):240 boolean(target,api.box(name,p,size,charcoal,bevel))241242243def cut_cylinder(target,name,p,radius,depth,rotation=(0,0,0)):244 boolean(target,api.cylinder(name,p,radius,depth,charcoal,rotation,vertices=64))245246247upper_root=api.empty('A • Warm matte upper shell / lift to inspect')248base_root=api.empty('B • Hidden-fastener charcoal tray')249rear_root=api.empty('C • Recessed rear connector cartridge')250usb_root=api.empty('D • USB-A target lead')251252# Calm planar top, 22mm plan radii and a restrained 3.5mm upper edge.253outer=[(212,142,20.5,8.5),(214.5,144.5,21.75,9.4),(215,145,22,10)]254outer.append((215,145,22,43.5))255for i in range(1,9):256 t=(math.pi/2)*i/8; inset=3.5*(1-math.cos(t))257 outer.append((215-2*inset,145-2*inset,22-inset,43.5+3.5*math.sin(t)))258inner=[(207.5,137.5,18.25,43.5),(209,139,19,42.3),(209,139,19,8.5)]259upper=loft('PRINT 01 / upper shell — 3mm walls',outer,inner,ivory,upper_root,True)260# Rear opening reaches the open underside, avoiding a long unsupported bridge.261cut_box(upper,'Rear cartridge clearance',(0,73,15.5),(173,16,43),2)262# Controls fit a small pocket in the back-right of the top.263cut_cylinder(upper,'STOP aperture',(82,37,46),10.4,15)264cut_cylinder(upper,'ARM aperture',(56,37,46),5.6,15)265cut_box(upper,'Tiny status aperture',(72,-72.5,24),(11.2,12,1.45),.6)266267# Four substantial bosses connect to the side wall, with blind insert seats.268for x in (-96,96):269 for y in (-60,60):270 boss=api.cylinder('Upper screw boss',(x,y,16.7),5.8,15.4,ivory,vertices=64)271 # Keep the boss off the shell's bottom plane; exact coplanarity left272 # duplicate inner faces at the Boolean seam on curved corner walls.273 for modifier in list(boss.modifiers):boss.modifiers.remove(modifier)274 boolean(upper,boss,'UNION')275 cut_cylinder(upper,'M3 insert pocket',(x,y,12.3),2.2,8.4)276277tray=loft('PRINT 02 / dark underside tray',[(210,140,20,3),(213,143,21.5,4.1),278 (213,143,21.5,6.6),(211.5,141.5,20.75,7.3)],mat=charcoal,parent=base_root,printable=True)279for x in (-96,96):280 for y in (-60,60):281 cut_cylinder(tray,'M3 clearance',(x,y,5),1.75,12)282 cut_cylinder(tray,'Recessed screw head',(x,y,3.7),3.0,2.5)283 api.cylinder('Hidden tray screw',(x,y,3.35),2.6,.5,mats['black'],parent=base_root)284for x in (-85,85):285 for y in (-50,50):286 api.cylinder('Low silicone foot',(x,y,1.6),7.2,3.0,mats['rubber'],parent=base_root)287# Printed locating tabs sit clear of the USB harness lane along the left side.288for x in (-34,34):289 for y in (-65.8,65.8):290 boolean(tray,api.box('Locating tab',(x,y,7.7),(11,1.4,3),charcoal,.35),'UNION')291# Pi and Pico mounting bosses; positions remain subject to real-board fit check.292mounts=[(-84,-10.5,12.2),(-26,-10.5,12.2),(-84,38.5,12.2),(-26,38.5,12.2),293 (-69,-53,13.5),(-23,-53,13.5),(-69,-37,13.5),(-23,-37,13.5)]294for x,y,z in mounts:295 boolean(tray,api.cylinder('Board mounting boss',(x,y,(7+z)/2),3.5,z-6.6,charcoal),'UNION')296 cut_cylinder(tray,'M2.5 pilot pocket',(x,y,z-1.7),1.35,4.1)297# A shallow cradle supports the capture module without altering its metal case.298for x in (1,87):299 boolean(tray,api.box('Capture support rail',(x,14,10.1),(4,49,6.2),charcoal,.5),'UNION')300for x in (-59,-48,-37,-26):301 cut_box(tray,'Bottom air intake',(x,13,5),(4,27,10),1)302303panel=api.box('PRINT 03 / rear service cartridge',(0,71,22),(171,3,29),charcoal,1.4,parent=rear_root)304api.link(panel,parent=rear_root,collection=print_collection)305for x,size in [(-61,(17.1,9,8.0)),(-24,(17.1,9,8.0)),(23,(16.8,9,14.5)),(57,(11.8,9,6.1))]:306 cut_box(panel,'Rear connector aperture',(x,71,24),size,.8)307cut_cylinder(panel,'USB target lead aperture',(78,71,22),4.0,12,(math.pi/2,0,0))308309# Recessed connector faces stay within the common dark service panel.310for x,label in [(-61,'HDMI IN'),(-24,'HDMI OUT')]:311 api.box('HDMI rear metal shield',(x,69.7,24),(16.2,5,7.1),mats['silver'],.5,parent=rear_root)312 api.box('HDMI dark mouth',(x,72.3,24),(14.4,.5,5.3),mats['black'],.4,parent=rear_root)313 api.box('HDMI tongue',(x,72.65,24),(12.3,.3,1.0),mats['rubber'],.15,parent=rear_root)314 for pin in range(19):315 api.box('HDMI contact',(x-5.8+pin*.645,72.85,24.1),(.22,.12,.65),mats['gold'],.02,parent=rear_root)316 api.text('Rear discreet legend',label,(x+10,72.8,31),2.0,mats['white'],(math.pi/2,0,math.pi),parent=rear_root)317api.box('LAN shield',(23,69.5,24),(16.0,5,13.3),mats['silver'],.5,parent=rear_root)318api.box('LAN aperture',(23,72.2,24),(13.5,.5,10.4),mats['black'],.4,parent=rear_root)319for pin in range(8):320 api.box('LAN contact',(18.8+pin*1.2,72.6,26),(.4,.12,4.4),mats['gold'],.03,parent=rear_root)321api.box('USB-C PD shield',(57,69.6,24),(11,5,5.6),mats['silver'],1.4,parent=rear_root)322api.box('USB-C dark aperture',(57,72.3,24),(9.4,.5,3.9),mats['black'],1.2,parent=rear_root)323api.box('USB-C tongue',(57,72.65,24),(7.6,.3,.7),mats['black'],.2,parent=rear_root)324for x,label in [(31,'LAN'),(63,'POWER'),(84,'USB')]:325 api.text('Rear subtle legend',label,(x,72.8,31),2,mats['white'],(math.pi/2,0,math.pi),parent=rear_root)326327# Miniature classic mushroom STOP: 22.5mm head, short visible neck, yellow ring.328api.cylinder('STOP / dark mounting collar',(82,37,47.2),12.4,.65,charcoal,parent=upper_root)329yellow_stop=polymer('STOP / warm yellow mounting ring',(.72,.45,.035))330api.cylinder('STOP / slim yellow mounting ring',(82,37,47.65),12.0,.9,yellow_stop,parent=upper_root,vertices=96)331# Radius/height profile forms one closed rotational mesh, in millimetres.332profile=[(5.8,47.6),(6.0,47.85),(6.0,50.45),(6.35,50.8),333 (9.4,51.0),(10.5,51.2),(11.05,51.65),(11.25,52.25),334 (11.2,52.95),(10.8,53.65),(10.0,54.3),(8.8,54.85),335 (7.0,55.35),(6.5,55.5)]336segments=128337verts=[(0,0,profile[0][1]/1000)]338for radius,z in profile:339 verts += [(radius*math.cos(2*math.pi*i/segments)/1000,340 radius*math.sin(2*math.pi*i/segments)/1000,z/1000)341 for i in range(segments)]342verts.append((0,0,profile[-1][1]/1000))343faces=[(0,1+(i+1)%segments,1+i) for i in range(segments)]344for ring in range(len(profile)-1):345 a=1+ring*segments;b=a+segments346 faces += [(a+i,a+(i+1)%segments,b+(i+1)%segments,b+i) for i in range(segments)]347a=1+(len(profile)-1)*segments;pole=len(verts)-1348faces += [(a+i,a+(i+1)%segments,pole) for i in range(segments)]349mesh=bpy.data.meshes.new('Mini STOP / rounded mushroom profile')350mesh.from_pydata(verts,[],faces);mesh.update()351stop_cap=bpy.data.objects.new('STOP / miniature red mushroom',mesh)352model_collection.objects.link(stop_cap)353stop_cap.location=(.082,.037,0)354mesh.materials.append(red_cap)355for face in mesh.polygons:face.use_smooth=True356for face in list(mesh.polygons)[:segments]+list(mesh.polygons)[-segments:]:face.use_smooth=False357api.link(stop_cap,parent=upper_root)358stop_cap['Head diameter mm']=22.5359stop_cap['Height above deck mm']=8.5360api.text('STOP legend','STOP',(76.3,35.2,55.54),3,mats['white'],parent=upper_root)361api.cylinder('ARM recessed collar',(56,37,47.1),6.7,.5,charcoal,parent=upper_root)362api.cylinder('ARM cap',(56,37,47.0),5.1,.7,charcoal,parent=upper_root)363api.text('ARM legend','ARM',(52.2,26.5,47.02),2.3,quiet_ink,parent=upper_root)364api.text('Quiet front wordmark','link',(-80,-72.74,22),4.3,quiet_ink,(math.pi/2,0,0),parent=upper_root)365api.box('Front light diffuser',(72,-72.75,24),(9,.6,.8),warm_led,.32,parent=upper_root)366367api.cylinder('Rear cable grommet',(78,72,22),4.7,4.2,mats['rubber'],(math.pi/2,0,0),parent=usb_root)368api.curve('USB target lead',[(78,74,22),(97,87,15),(142,66,6),(156,14,5),(140,-40,5),(127,-60,5)],2.4,mats['rubber'],parent=usb_root)369api.box('USB-A / rounded soft grip',(127,-79,5.2),(15.6,27,9),mats['rubber'],2.6,parent=usb_root)370for i in range(5):371 api.box('USB cable strain relief',(127,-64+i*1.45,5.2),(8,.7,7.7),mats['rubber'],.3,parent=usb_root)372usb_metal=api.box('USB-A male metal',(127,-101,5.2),(12.1,17.2,4.5),mats['silver'],.4,parent=usb_root)373cut_box(usb_metal,'USB opening',(127,-109.4,5.2),(10.9,4,3.4),.2)374for x in (123.7,130.3):375 cut_box(usb_metal,'USB retention hole',(x,-99.5,7.4),(2.1,3.4,2),.2)376api.box('USB dielectric',(127,-105.2,4.55),(10.5,8.5,1.15),mats['white'],.12,parent=usb_root)377for pin in range(4):378 api.box('USB gold contact',(123.1+pin*2.6,-106.3,5.2),(1.3,5.7,.12),mats['gold'],.04,parent=usb_root)379380internals=model_components.build_internals(api,mats)381# Bring the visual reference's mounting metals clear of the printed mounting bosses.382for ob in list(internals['pi'].children_recursive)+list(internals['pico'].children_recursive):383 if '_standoff' in ob.name: ob.hide_render=True;ob.hide_viewport=True384wire_root=api.empty('E • Short rear interconnects')385for x,end in [(-61,(37.5,43.5,24)),(-24,(65.5,43.5,24))]:386 api.curve('HDMI rear extension',[(x,67,24),(x,55,24),(end[0]-5,52,24),end],1.8,mats['rubber'],parent=wire_root)387388scene.frame_start=1;scene.frame_end=100389for root,raise_mm in [(upper_root,86),*[(ob,18+i*11) for i,ob in enumerate(internals.values())]]:390 root.keyframe_insert(data_path='location',frame=1)391 root.location.z+=raise_mm/1000;root.keyframe_insert(data_path='location',frame=100)392 root.location.z-=raise_mm/1000393scene.frame_set(1)394scene.timeline_markers.new('CLOSED / Print Edition',frame=1)395scene.timeline_markers.new('OPEN / Inspect internals',frame=100)396397398def camera(name,p,target,lens=57):399 data=bpy.data.cameras.new(name);ob=bpy.data.objects.new(name,data);studio_collection.objects.link(ob)400 ob.location=Vector(p)/1000;ob.rotation_euler=(Vector(target)/1000-ob.location).to_track_quat('-Z','Y').to_euler()401 data.lens=lens;data.clip_start=.001;data.clip_end=100402 return ob403404405hero=camera('Camera / Warm white hero',(360,-420,265),(15,0,20),57)406rear_camera=camera('Camera / Recessed service panel',(-310,390,225),(0,10,18),59)407open_camera=camera('Camera / Open assembly',(310,-355,340),(0,0,72),53)408interior_camera=camera('Camera / Tray and electronics',(175,-205,450),(0,0,15),55)409scene.camera=hero410floor=api.box('Warm studio floor',(0,0,-3),(2000,2000,4),material('Warm limestone studio',(.49,.47,.43),roughness=.75),1)411api.link(floor,collection=studio_collection)412for name,p,power,size,color in [('Key',(50,-160,480),1.6,390,(1,.94,.84)),413 ('Soft rim',(-270,160,330),2,320,(.87,.92,1)),414 ('Warm fill',(300,130,250),1.2,280,(1,.88,.75))]:415 data=bpy.data.lights.new(name,'AREA');ob=bpy.data.objects.new(name,data);studio_collection.objects.link(ob)416 ob.location=Vector(p)/1000;ob.rotation_euler=(-ob.location).to_track_quat('-Z','Y').to_euler()417 data.energy=power;data.size=size/1000;data.shape='DISK';data.color=color418scene.world.use_nodes=True419scene.world.node_tree.nodes.get('Background').inputs['Color'].default_value=(.35,.37,.38,1)420scene.world.node_tree.nodes.get('Background').inputs['Strength'].default_value=.27421scene.render.engine='CYCLES';scene.cycles.samples=96;scene.cycles.use_denoising=True422scene.cycles.adaptive_threshold=.03423try:424 prefs=bpy.context.preferences.addons['cycles'].preferences;prefs.compute_device_type='METAL';prefs.get_devices()425 for device in prefs.devices: device.use=device.type=='METAL'426 scene.cycles.device='GPU' if any(d.use for d in prefs.devices) else 'CPU'427except Exception: scene.cycles.device='CPU'428scene.render.resolution_x=1900;scene.render.resolution_y=1400;scene.render.resolution_percentage=100429scene.render.image_settings.file_format='PNG';scene.render.image_settings.color_mode='RGBA'430scene.view_settings.view_transform='AgX';scene.view_settings.look='AgX - Medium High Contrast'431scene['Design']='215 × 145 × 47 mm; matte printed polymer; hidden fasteners; rear connector cartridge'432scene['Printing']='STL units are millimetres. Upper shell inverted, tray flat, cartridge back face down. Fit prototype required.'433scene['Inspect']='Frame 1 closed, frame 100 exploded. Separate printable solids and detailed electronic assemblies.'434435436def render_views():437 jobs=[('01-ivory-hero.png',1,hero,False),('02-rear.png',1,rear_camera,False),438 ('03-open.png',100,open_camera,False),('04-internals.png',1,interior_camera,True)]439 for name,frame,cam,hide in jobs:440 (ROOT/'render-status.txt').write_text('RENDERING '+name)441 scene.frame_set(frame);scene.camera=cam442 for ob in [upper_root,*upper_root.children_recursive]:ob.hide_render=hide443 scene.render.filepath=str(ROOT/'renders'/name);bpy.ops.render.render(write_still=True)444 for ob in [upper_root,*upper_root.children_recursive]:ob.hide_render=False445 scene.frame_set(1);scene.camera=hero446 ivory.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.035,.042,.044,1)447 scene.render.filepath=str(ROOT/'renders'/'05-anthracite-hero.png');bpy.ops.render.render(write_still=True)448 ivory.node_tree.nodes.get('Principled BSDF').inputs['Base Color'].default_value=(.72,.695,.64,1)449 scene.render.filepath=str(ROOT/'renders'/'01-ivory-hero.png')450 (ROOT/'render-status.txt').write_text('ALL_RENDERS_DONE')451452453def export_models():454 import struct455 import bmesh456 scene.frame_set(1);graph=bpy.context.evaluated_depsgraph_get();report=[]457 for name,ob in [('01-upper-shell.stl',upper),('02-bottom-tray.stl',tray),('03-rear-cartridge.stl',panel)]:458 mesh=bpy.data.meshes.new_from_object(ob.evaluated_get(graph),depsgraph=graph)459 bm=bmesh.new();bm.from_mesh(mesh)460 nonmanifold=sum(not e.is_manifold for e in bm.edges)461 report.append(f'{name}: non-manifold edges {nonmanifold}')462 bm.free();mesh.calc_loop_triangles()463 with open(ROOT/'prints'/name,'wb') as f:464 f.write(b'LINK Print Edition / millimetres'.ljust(80,b'\0'));f.write(struct.pack('<I',len(mesh.loop_triangles)))465 for tri in mesh.loop_triangles:466 v=[ob.matrix_world @ mesh.vertices[i].co for i in tri.vertices]467 normal=(v[1]-v[0]).cross(v[2]-v[0]).normalized()468 f.write(struct.pack('<12fH',*normal,*[a*1000 for xyz in v for a in xyz],0))469 bpy.data.meshes.remove(mesh)470 (ROOT/'print-check.txt').write_text('\n'.join(report))471 temporary=bpy.data.collections.new('Temporary export');scene.collection.children.link(temporary)472 duplicates=[]473 for col in (model_collection,print_collection):474 for ob in col.objects:475 if ob.type in {'MESH','CURVE','FONT'} and not ob.hide_render:476 mesh=bpy.data.meshes.new_from_object(ob.evaluated_get(graph),depsgraph=graph)477 copy=bpy.data.objects.new(ob.name,mesh);temporary.objects.link(copy);copy.matrix_world=ob.matrix_world.copy();duplicates.append(copy)478 bpy.ops.object.select_all(action='DESELECT')479 for ob in duplicates:ob.select_set(True)480 bpy.context.view_layer.objects.active=duplicates[0]481 bpy.ops.export_scene.gltf(filepath=str(ROOT/'LINK-print-edition.glb'),export_format='GLB',use_selection=True)482 for ob in duplicates:bpy.data.objects.remove(ob,do_unlink=True)483 bpy.data.collections.remove(temporary)484 bpy.ops.object.select_all(action='DESELECT')485 save_public_blend(ROOT/'LINK-print-edition.blend')486 (ROOT/'export-status.txt').write_text('EXPORTS_DONE')487488489# Remove old empty collections from earlier concept builds in the same session.490for col in list(bpy.data.collections):491 if len(col.objects)==0 and len(col.children)==0:bpy.data.collections.remove(col)492bpy.ops.object.select_all(action='DESELECT')493bpy.context.view_layer.objects.active=None494save_public_blend(ROOT/'LINK-print-edition.blend')495(ROOT/'model-status.txt').write_text('MODEL_READY')496print('LINK PRINT EDITION READY. Call render_views() and export_models().')