QUELLCODE / PY
build_model.py
design/build_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))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 | Hardware Box"25scene.unit_settings.system = "METRIC"26scene.unit_settings.length_unit = "MILLIMETERS"27scene.unit_settings.scale_length = 128model_collection = bpy.data.collections.new("01 • LINK / 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 obj151152153api=Modeling()154chassis=api.empty("A • Lower enclosure / 215 × 145 × 64 mm")155base=api.box("Bottom tray / aluminium",(0,0,5.5),(215,145,7),mats["graphite"],5,parent=chassis)156shell=api.box("Continuous enclosure / rounded aluminium shell",(0,0,32),(215,145,48),mats["graphite"],5,parent=chassis)157front=rear=left=right=shell158159160def cut(obj, name, location, size, bevel=0.5):161 cutter=api.box(name,location,size,mats["black"],bevel)162 bpy.context.view_layer.objects.active=cutter163 for modifier in list(cutter.modifiers):164 bpy.ops.object.modifier_apply(modifier=modifier.name)165 mod=obj.modifiers.new(name,"BOOLEAN")166 mod.operation="DIFFERENCE";mod.solver="EXACT";mod.object=cutter167 bpy.context.view_layer.objects.active=obj168 bpy.ops.object.modifier_apply(modifier=mod.name)169 bpy.data.objects.remove(cutter,do_unlink=True)170171172# True panel apertures; model female connector cavities, not printed rectangles.173cut(shell,"Enclosure interior clearance",(0,0,32),(205,135,56),3)174for x in (-65,-25):175 cut(front,"HDMI chassis aperture",(x,-70.5,26),(17.4,9,10.6),0.8)176cut(rear,"Ethernet panel aperture",(56,70.5,27),(18,9,16),1)177cut(rear,"USB-C PD panel aperture",(-38,70.5,24),(12.4,9,7),2)178cut(right,"USB cable gland aperture",(105.5,-27,26),(9,12,10),3)179for index in range(11):180 y=-36+index*7181 cut(left,f"Cooling slot {index+1:02d}",(-105.5,y,33),(10,3.2,21),1.1)182for x in (-86,86):183 for y in (-52,52):184 api.cylinder("Recessed silicone foot",(x,y,0.5),9,3,mats["rubber"],parent=chassis)185 api.cylinder("Foot retaining screw",(x,y,-1.05),2.6,0.4,mats["black"],parent=chassis)186for x in (-96,96):187 for y in (-60,60):188 api.cylinder("M3 chassis standoff",(x,y,28),3.4,39,mats["black"],parent=chassis)189 api.cylinder("Brass threaded insert",(x,y,48),2.0,3,mats["gold"],parent=chassis)190191192def hdmi_port(x,label):193 holder=api.empty("HDMI • "+label)194 # Type-A HDMI profile: wide top, tapered lower corners.195 def profile(name,y,width,height,material):196 outline=[(-width/2,height/2),(width/2,height/2),(width/2,-height/2+1.5),197 (width/2-2,-height/2),(-width/2+2,-height/2),(-width/2,-height/2+1.5)]198 vertices=[((x+px)/1000,(y+dy)/1000,(26+pz)/1000) for dy in (-0.22,0.22) for px,pz in outline]199 faces=[tuple(range(5,-1,-1)),tuple(range(6,12))]+[(i,(i+1)%6,(i+1)%6+6,i+6) for i in range(6)]200 mesh=bpy.data.meshes.new(name);mesh.from_pydata(vertices,[],faces);mesh.materials.append(material)201 obj=bpy.data.objects.new(name,mesh);model_collection.objects.link(obj);obj.parent=holder202 bevel=obj.modifiers.new("Connector profile edge","BEVEL");bevel.width=0.15/1000;bevel.segments=3203 return obj204 api.box("HDMI rear mount",(x,-71.8,26),(16.3,5,7.8),mats["silver"],0.6,parent=holder)205 profile("HDMI nickel trapezoid",-75.1,16.3,7.8,mats["silver"])206 profile("HDMI recessed cavity",-75.38,14.6,6.1,mats["black"])207 api.box("HDMI insulating tongue",(x,-75.65,25.8),(12.5,0.45,1.3),mats["rubber"],0.2,parent=holder)208 for pin in range(19):209 api.box(f"HDMI pin {pin+1:02d}",(x-5.8+pin*0.645,-75.94,26.0),(0.25,0.18,0.8),mats["gold"],0.02,parent=holder)210 api.text("Laser port label",label,(x-9,-73.01,38),2.5,mats["white"],(math.pi/2,0,0),parent=chassis)211 return holder212213214hdmi_port(-65,"HDMI IN")215hdmi_port(-25,"HDMI OUT")216api.text("Passthrough descriptor","4K60 PASSTHROUGH",(-75,-73.01,12),1.8,mats["white"],(math.pi/2,0,0),parent=chassis)217api.text("Front brand","LINK",(20,-73.02,34),8.5,mats["white"],(math.pi/2,0,0),parent=chassis)218api.text("Front product descriptor","HDMI + HID",(20,-73.02,25),2.5,mats["white"],(math.pi/2,0,0),parent=chassis)219api.box("Front status light recess",(38,-72.83,15),(37,0.8,2.8),mats["black"],0.7,parent=chassis)220api.box("Front status light / teal",(36,-73.30,15),(32,0.4,1.15),mats["teal"],0.35,parent=chassis)221222# Rear service interfaces.223api.box("RJ45 metal shield",(56,71,27),(17,7,15),mats["silver"],0.6,parent=chassis)224api.box("RJ45 cavity",(56,75,27),(14.5,0.5,12),mats["black"],0.6,parent=chassis)225for index in range(8):226 api.box("RJ45 gold contact",(51.8+index*1.2,75.4,29),(0.4,0.2,6),mats["gold"],0.02,parent=chassis)227api.box("USB-C PD nickel surround",(-38,71.4,24),(11.5,6.3,6.1),mats["silver"],1.8,parent=chassis)228api.box("USB-C PD cavity",(-38,74.8,24),(9.8,0.5,4.5),mats["black"],1.3,parent=chassis)229api.box("USB-C PD tongue",(-38,75.0,24),(8,0.3,0.9),mats["rubber"],0.3,parent=chassis)230api.text("Rear power label","POWER / USB-C PD",(-12,73.1,38),2.4,mats["white"],(math.pi/2,0,math.pi),parent=chassis)231api.text("Rear ethernet label","GIGABIT LAN",(75,73.1,41),2.4,mats["white"],(math.pi/2,0,math.pi),parent=chassis)232api.text("Rear serial label","LINK-01 / DESIGNED IN AUSTRIA",(83,73.1,11),1.8,mats["white"],(math.pi/2,0,math.pi),parent=chassis)233234# A lid assembly animated closed/open; transparent window exposes Pi and capture.235lid=api.empty("B • Removable top / smoked-glass window")236top=api.box("Top aluminium frame",(0,0,58.5),(215,145,5),mats["graphite"],5,parent=lid)237cut(top,"Smoked glass window opening",(-10,10,58.5),(176,95,12),6)238api.box("Window black gasket",(-10,10,58.5),(178,97,1.5),mats["rubber"],5,parent=lid)239window=api.box("Smoked glass panel",(-10,10,60.05),(175,94,2.3),mats["glass"],4.6,parent=lid)240# Thin perimeter mask, no opaque full plate underneath the transparent panel.241gasket=bpy.data.objects.get("Window black gasket")242cut(gasket,"Gasket central clearance",(-10,10,58.5),(172,91,6),4)243for x in (-97,97):244 for y in (-61,61):245 api.cylinder("Flush lid screw",(x,y,61.15),2.7,0.7,mats["black"],parent=lid)246 api.box("Screw slot",(x,y,61.6),(2.8,0.55,0.17),mats["silver"],0.1,parent=lid)247api.text("Lid product name","L I N K",(-86,-56,61.15),7,mats["white"],parent=lid)248api.text("Lid specification","COMPUTER USE / HDMI + USB",(-86,-63,61.15),1.85,mats["white"],parent=lid)249250# Compact concept controls: actual industrial 82mm-deep switch needs separate redesign.251api.cylinder("STOP yellow ID ring",(80,-48,61.8),11.6,1.8,mats["yellow"],parent=lid)252api.cylinder("STOP black collar",(80,-48,63.3),9.3,2.2,mats["black"],parent=lid)253api.cylinder("STOP mushroom / compact concept",(80,-48,68.0),10.7,7.1,mats["red"],parent=lid)254api.text("STOP cap legend","STOP",(73.5,-50.3,71.8),3.4,mats["white"],parent=lid)255api.cylinder("ARM recessed surround",(47,-48,61.9),6.2,1.8,mats["black"],parent=lid)256api.cylinder("ARM tactile button",(47,-48,62.5),4.9,1.3,mats["teal"],parent=lid)257api.text("ARM legend","ARM",(42,-61,61.2),2.6,mats["white"],parent=lid)258259# Captive USB lead and true USB-A male connector with contacts and retention holes.260usb=api.empty("C • Captive target USB-A plug / HID")261api.cylinder("USB cable gland",(107,-27,26),6.3,7,mats["rubber"],(0,math.pi/2,0),parent=usb)262api.curve("Target USB / flexible cable",[(110,-27,26),(140,-35,18),(154,-58,6),(150,-95,5),(132,-124,5)],2.8,mats["rubber"],parent=usb)263api.box("USB-A plug / soft grip",(132,-142,5.4),(17,29,10),mats["rubber"],2.6,parent=usb)264for i in range(5):265 api.box("USB strain relief rib",(132,-127.5+i*1.6,5.5),(9.2,0.8,8.7),mats["rubber"],0.3,parent=usb)266metal=api.box("USB-A male nickel shell",(132,-165,5.4),(12.1,17.2,4.5),mats["silver"],0.45,parent=usb)267cut(metal,"USB-A mouth",(132,-173.4,5.35),(10.9,4,3.4),0.2)268for x in (128.7,135.3):269 cut(metal,"USB retention opening",(x,-163.5,7.6),(2.1,3.4,2),0.25)270api.box("USB-A white dielectric tongue",(132,-169.2,4.75),(10.5,8.5,1.15),mats["white"],0.12,parent=usb)271for pin in range(4):272 api.box("USB-A gold contact",(128.1+pin*2.6,-170.3,5.4),(1.3,5.7,0.12),mats["gold"],0.05,parent=usb)273api.text("USB plug marking","HID",(128,-147,10.55),3.7,mats["white"],parent=usb)274275internals=model_components.build_internals(api,mats)276277# Internal short harnesses follow function and remain separate from the target USB power.278wire_root=api.empty("H • Routed internal interconnects")279for index,material_key in enumerate(("teal","copper","white")):280 api.curve("Isolated UART harness "+str(index+1),[(-57,-20,17),(-58,-31,12),(-23,-34,12),(12,-45+index*1.2,17)],0.45,mats[material_key],parent=wire_root)281api.curve("HDMI IN short extension",[(-65,-66,26),(-64,-55,18),(-8,-44,18),(24,-16,24)],2.3,mats["rubber"],parent=wire_root)282api.curve("HDMI LOOP short extension",[(-25,-66,26),(-22,-54,17),(38,-41,17),(55,-16,24)],2.3,mats["rubber"],parent=wire_root)283284# Timeline: 1 = assembled, 100 = exploded inspection. Components remain editable.285scene.frame_start=1;scene.frame_end=100286for root,raise_mm in [(lid,94), *[(obj,20+idx*12) for idx,obj in enumerate(internals.values())]]:287 if not isinstance(root,bpy.types.Object):288 continue289 root.keyframe_insert(data_path="location",frame=1)290 root.location.z+=raise_mm/1000291 root.keyframe_insert(data_path="location",frame=100)292 root.location.z-=raise_mm/1000293scene.timeline_markers.new("ASSEMBLED / Product",frame=1)294scene.timeline_markers.new("EXPLODED / Inspect internals",frame=100)295scene.frame_set(1)296297298def camera(name,position_mm,target_mm,lens=55):299 data=bpy.data.cameras.new(name)300 obj=bpy.data.objects.new(name,data)301 studio_collection.objects.link(obj)302 obj.location=Vector(position_mm)/1000303 obj.rotation_euler=(Vector(target_mm)/1000-obj.location).to_track_quat("-Z","Y").to_euler()304 data.lens=lens305 data.clip_start=0.001;data.clip_end=100306 return obj307308309hero=camera("Camera • Hero / HDMI and USB",(350,-460,330),(20,-34,25),57)310exploded=camera("Camera • Exploded / all assemblies",(365,-440,430),(0,0,80),53)311rear_camera=camera("Camera • Rear / power and network",(-310,390,280),(0,0,25),57)312top_camera=camera("Camera • Interior / lid removed",(170,-200,510),(0,0,17),57)313scene.camera=hero314ground=api.box("Studio plinth",(0,0,-4),(2000,2000,4),mats["floor"],2)315api.link(ground,collection=studio_collection)316317318def area(name,location_mm,power,size_mm,color):319 data=bpy.data.lights.new(name,"AREA")320 obj=bpy.data.objects.new(name,data)321 studio_collection.objects.link(obj)322 obj.location=Vector(location_mm)/1000323 obj.rotation_euler=(-obj.location).to_track_quat("-Z","Y").to_euler()324 data.energy=power;data.shape="DISK";data.size=size_mm/1000;data.color=color325326327area("Key • softbox",(110,-180,500),1.7,410,(0.86,0.93,1.0))328area("Rim • long highlight",(-270,140,340),2.0,330,(0.75,0.90,1.0))329area("Fill • warm edge",(340,180,210),1.4,260,(1.0,0.83,0.65))330scene.world.color=(0.28,0.28,0.28)331scene.world.use_nodes=True332scene.world.node_tree.nodes.get("Background").inputs["Color"].default_value=(0.28,0.32,0.36,1)333scene.world.node_tree.nodes.get("Background").inputs["Strength"].default_value=0.3334scene.render.engine="CYCLES"335scene.cycles.samples=96336scene.cycles.use_denoising=True337scene.cycles.adaptive_threshold=0.035338scene.cycles.max_bounces=8339scene.cycles.transmission_bounces=6340try:341 prefs=bpy.context.preferences.addons["cycles"].preferences342 prefs.compute_device_type="METAL"343 prefs.get_devices()344 found=False345 for device in prefs.devices:346 device.use=device.type=="METAL"347 found=found or device.use348 if found:349 scene.cycles.device="GPU"350except Exception:351 scene.cycles.device="CPU"352scene.render.resolution_x=1800353scene.render.resolution_y=1400354scene.render.resolution_percentage=100355scene.render.image_settings.file_format="PNG"356scene.render.image_settings.color_mode="RGBA"357scene.render.film_transparent=False358scene.view_settings.view_transform="AgX"359scene.view_settings.look="AgX - Medium High Contrast"360scene.render.filepath=str(ROOT/"renders"/"01-hero.png")361362# Useful starting view when opening the editable .blend.363bpy.ops.object.select_all(action="DESELECT")364bpy.context.view_layer.objects.active=None365for screen in bpy.data.screens:366 for region in screen.areas:367 if region.type=="VIEW_3D":368 space=region.spaces.active369 space.shading.type="MATERIAL"370 space.overlay.show_overlays=False371 space.region_3d.view_perspective="CAMERA"372scene["Concept dimensions"]="215 × 145 × 64 mm enclosure; compact controls increase overall height"373scene["Reference units"]="Millimetres modeled in metres, 1 Blender unit = 1 metre"374scene["Hardware intent"]="Pi5 + Pico2 + HDMI IN/LOOP capture + isolated HID + integrated hub/power carrier"375scene["Manufacturing status"]="Industrial design concept; connector clearances, cooling and safety hardware require engineering verification"376scene["Inspect"]="Frame 1: assembled. Frame 100: exploded. Dedicated cameras in studio collection."377save_public_blend(ROOT/"LINK-hardware-box.blend")378(ROOT/"model-status.txt").write_text(f"MODEL_READY\n{len(model_collection.objects)} product objects\n",encoding="utf-8")379380381def render_views():382 jobs=[("01-hero.png",1,hero,False), ("02-internals.png",1,top_camera,True),383 ("03-exploded.png",100,exploded,False), ("04-rear.png",1,rear_camera,False)]384 for filename,frame,cam,hide_lid in jobs:385 (ROOT/"render-status.txt").write_text("RENDERING "+filename,encoding="utf-8")386 scene.frame_set(frame);scene.camera=cam387 for obj in [lid,*lid.children_recursive]:388 obj.hide_render=hide_lid389 scene.render.filepath=str(ROOT/"renders"/filename)390 bpy.ops.render.render(write_still=True)391 for obj in [lid,*lid.children_recursive]:392 obj.hide_render=False393 scene.frame_set(1);scene.camera=hero394 scene.render.filepath=str(ROOT/"renders"/"01-hero.png")395 save_public_blend(ROOT/"LINK-hardware-box.blend")396 (ROOT/"render-status.txt").write_text("ALL_RENDERS_DONE",encoding="utf-8")397398399def export_glb():400 scene.frame_set(1)401 export_collection=bpy.data.collections.new("Temporary GLB meshes")402 scene.collection.children.link(export_collection)403 graph=bpy.context.evaluated_depsgraph_get()404 duplicates=[]405 for obj in model_collection.objects:406 if obj.type in {"MESH","CURVE","FONT"}:407 mesh=bpy.data.meshes.new_from_object(obj.evaluated_get(graph),depsgraph=graph)408 copied=bpy.data.objects.new(obj.name,mesh)409 export_collection.objects.link(copied)410 copied.matrix_world=obj.matrix_world.copy()411 duplicates.append(copied)412 bpy.ops.object.select_all(action="DESELECT")413 for obj in duplicates:414 obj.select_set(True)415 if duplicates:416 bpy.context.view_layer.objects.active=duplicates[0]417 bpy.ops.export_scene.gltf(filepath=str(ROOT/"LINK-hardware-box.glb"),export_format="GLB",use_selection=True)418 for obj in duplicates:419 bpy.data.objects.remove(obj,do_unlink=True)420 bpy.data.collections.remove(export_collection)421 save_public_blend(ROOT/"LINK-hardware-box.blend")422423print("LINK MODEL READY. Call render_views() and export_glb().")