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().")