# -*- coding: utf-8 -*- # Copyright (c) 2023 Salvador E. Tropea # Copyright (c) 2023 Instituto Nacional de TecnologĂ­a Industrial # License: GPL-3.0 # Project: KiBot (formerly KiPlot) """ Dependencies: - from: Blender role: mandatory version: 3.4.0 """ from copy import copy import json import os from tempfile import NamedTemporaryFile, TemporaryDirectory from .error import KiPlotConfigurationError from .kiplot import get_output_targets, run_output, run_command, register_xmp_import, config_output from .gs import GS from .optionable import Optionable from .out_base_3d import Base3D, Base3DOptionsWithHL from .registrable import RegOutput from .macros import macros, document, output_class # noqa: F401 from . import log logger = log.get_logger() bb = None def get_board_size(): global bb if bb is None: bb = GS.board.ComputeBoundingBox(True) width = GS.to_mm(bb.GetWidth())/1000.0 height = GS.to_mm(bb.GetHeight())/1000.0 size = max(width, height) return width, height, size class PCB2BlenderOptions(Optionable): """ How the PCB3D is imported """ def __init__(self, field=None): super().__init__() with document: self.components = True """ Import the components """ self.cut_boards = True """ Separate the sub-PCBs in separated 3D models """ self.texture_dpi = 1016.0 """ [508-2032] Texture density in dots per inch """ self.center = True """ Center the PCB at the coordinates origin """ self.enhance_materials = True """ Create good looking materials """ self.merge_materials = True """ Reuse materials """ self.solder_joints = "SMART" """ [NONE,SMART,ALL] The plug-in can add nice looking solder joints. This option controls if we add it for none, all or only for THT/SMD pads with solder paste """ self.stack_boards = True """ Move the sub-PCBs to their relative position """ self._unkown_is_error = True class BlenderOutputOptions(Optionable): """ What is generated """ def __init__(self, field=None): super().__init__() with document: self.type = 'render' """ *[fbx,obj,x3d,gltf,stl,ply,blender,render] The format for the output. The `render` type will generate a PNG image of the render result. `fbx` is Kaydara's Filmbox, 'obj' is the Wavefront, 'x3d' is the new ISO/IEC standard that replaced VRML, `gltf` is the standardized GL format, `stl` is the 3D printing format, 'ply' is Polygon File Format (Stanford). Note that some formats includes the light and camera and others are just the 3D model (i.e. STL and PLY) """ self.output = GS.def_global_output """ Name for the generated file (%i='3D_blender_$VIEW' %x=VARIABLE). The extension is selected from the type """ self._unkown_is_error = True class BlenderLightOptions(Optionable): """ A light in the scene. Currently also for the camera """ def __init__(self, field=None): super().__init__() with document: self.name = "" """ Name for the light """ self.pos_x = 0 """ [number|string] X position [m]. You can use `width`, `height` and `size` for PCB dimensions """ self.pos_y = 0 """ [number|string] Y position [m]. You can use `width`, `height` and `size` for PCB dimensions """ self.pos_z = 0 """ [number|string] Z position [m]. You can use `width`, `height` and `size` for PCB dimensions """ self._unkown_is_error = True def solve(self, member): val = getattr(self, member) if not isinstance(val, str): return float(val) try: res = eval(val, {}, {'width': self._width, 'height': self._width, 'size': self._size}) except Exception as e: raise KiPlotConfigurationError('wrong `{}`: `{}`\nPython says: `{}`'.format(member, val, str(e))) return res def config(self, parent): super().config(parent) self._width, self._height, self._size = get_board_size() self.pos_x = self.solve('pos_x') self.pos_y = self.solve('pos_y') self.pos_z = self.solve('pos_z') class BlenderRenderOptions(Optionable): """ Render parameters """ def __init__(self, field=None): super().__init__() with document: self.samples = 10 """ *How many samples we create. Each sample is a raytracing render. Use 1 for a raw preview, 10 for a draft and 100 or more for the final render """ self.resolution_x = 1280 """ Width of the image """ self.resolution_y = 720 """ Height of the image """ self.transparent_background = False """ *Make the background transparent """ self.background1 = "#66667F" """ First color for the background gradient """ self.background2 = "#CCCCE5" """ Second color for the background gradient """ self._unkown_is_error = True class PCB3DExportOptions(Base3DOptionsWithHL): """ Options to generate the PCB3D file """ def __init__(self, field=None): super().__init__() with document: self.output = GS.def_global_output """ Name for the generated PCB3D file (%i='blender_export' %x='pcb3d') """ self.version = '2.1' """ [2.1,2.1_haschtl] Variant of the format used """ self._expand_id = 'blender_export' self._expand_ext = 'pcb3d' self._unkown_is_error = True class Blender_ExportOptions(Optionable): _views = {'top': 'z', 'bottom': 'Z', 'front': 'y', 'rear': 'Y', 'right': 'x', 'left': 'X'} _rviews = {v: k for k, v in _views.items()} def __init__(self): with document: self.pcb3d = PCB3DExportOptions """ *[string|dict] Options to export the PCB to Blender. You can also specify the name of the output that generates the PCB3D file. See the `PCB2Blender_2_1` and `PCB2Blender_2_1_haschtl` templates """ self.pcb_import = PCB2BlenderOptions """ Options to configure how Blender imports the PCB. The default values are good for most cases """ self.outputs = BlenderOutputOptions """ [dict|list(dict)] Outputs to generate in the same run """ self.light = BlenderLightOptions """ [dict|list(dict)] Options for the light/s """ self.add_default_light = True """ Add a default light when none specified. The default light is located at (-size*3.33, size*3.33, size*5) where size is max(width, height) of the PCB """ self.camera = BlenderLightOptions """ [dict] Options for the camera. If none specified KiBot will create a suitable camera """ self.render_options = BlenderRenderOptions """ *[dict] How the render is done for the `render` output type """ self.rotate_x = 0 """ Angle to rotate the board in the X axis, positive is clockwise [degrees] """ self.rotate_y = 0 """ Angle to rotate the board in the Y axis, positive is clockwise [degrees] """ self.rotate_z = 0 """ Angle to rotate the board in the Z axis, positive is clockwise [degrees] """ self.view = 'top' """ *[top,bottom,front,rear,right,left,z,Z,y,Y,x,X] Point of view. Compatible with `render_3d` """ super().__init__() self._expand_id += '_blender' self._unkown_is_error = True def config(self, parent): super().config(parent) # Check we at least have a name for the source output if isinstance(self.pcb3d, type) or (isinstance(self.pcb3d, str) and not self.pcb3d): raise KiPlotConfigurationError('You must specify the name of the output that' ' generates the PCB3D file or its options') # Do we have outputs? if isinstance(self.outputs, type): self.outputs = [] elif isinstance(self.outputs, BlenderOutputOptions): # One, make a list self.outputs = [self.outputs] # Ensure we have import options if isinstance(self.pcb_import, type): self.pcb_import = PCB2BlenderOptions() # Ensure we have a light if isinstance(self.light, type): # None if self.add_default_light: # Create one light = BlenderLightOptions() light.name = 'kibot_light' _, _, size = get_board_size() light.pos_x = -size*3.33 light.pos_y = size*3.33 light.pos_z = size*5.0 self.light = [light] else: # The dark ... self.light = [] elif isinstance(self.light, BlenderLightOptions): # Ensure a list self.light = [self.light] # Check light names light_names = set() for li in self.light: name = li.name if li.name else 'kibot_light' if name in light_names: id = 2 while name+'_'+str(id) in light_names: id += 1 name = name+'_'+str(id) li.name = name # If no camera let the script create one if isinstance(self.camera, type): self.camera = None elif not self.camera.name: self.camera.name = 'kibot_camera' # Ensure we have some render options if isinstance(self.render_options, type): self.render_options = BlenderRenderOptions() # View point view = self._views.get(self.view, None) if view is not None: self.view = view self._expand_id += '_'+self._rviews.get(self.view) def get_output_filename(self, o, output_dir): if o.type == 'render': self._expand_ext = 'png' elif o.type == 'blender': self._expand_ext = 'blend' else: self._expand_ext = o.type return self._parent.expand_filename(output_dir, o.output) def get_targets(self, out_dir): return [self.get_output_filename(o, out_dir) for o in self.outputs] def create_vrml(self, dest_dir): tree = {'name': '_temporal_vrml_for_pcb3d', 'type': 'vrml', 'comment': 'Internally created for the PCB3D', 'dir': dest_dir, 'options': {'output': 'pcb.wrl', 'dir_models': 'components', 'use_pcb_center_as_ref': False, 'model_units': 'meters'}} out = RegOutput.get_class_for('vrml')() out.set_tree(tree) config_output(out) out.options.copy_options(self.pcb3d) logger.debug(' - Creating VRML ...') out.options.run(os.path.join(dest_dir, 'pcb.wrl')) def create_layers(self, dest_dir): out_dir = os.path.join(dest_dir, 'layers') tree = {'name': '_temporal_svgs_layers', 'type': 'svg', 'comment': 'Internally created for the PCB3D', 'dir': out_dir, 'options': {'output': '%i.%x', 'margin': 1, 'limit_viewbox': True, 'svg_precision': 6, 'drill_marks': 'none'}, 'layers': ['F.Cu', 'B.Cu', 'F.Paste', 'B.Paste', 'F.Mask', 'B.Mask', {'layer': 'F.SilkS', 'suffix': 'F_SilkS'}, {'layer': 'B.SilkS', 'suffix': 'B_SilkS'}]} out = RegOutput.get_class_for(tree['type'])() out.set_tree(tree) config_output(out) logger.debug(' - Creating SVG for layers ...') out.run(out_dir) def create_pads(self, dest_dir): tree = {'name': '_temporal_pcb3d_tools', 'type': 'pcb2blender_tools', 'comment': 'Internally created for the PCB3D', 'dir': dest_dir, 'options': {'stackup_create': self.pcb3d.version == '2.1_haschtl'}} out = RegOutput.get_class_for(tree['type'])() out.set_tree(tree) config_output(out) logger.debug(' - Creating Pads and boundary ...') out.run(dest_dir) def create_pcb3d(self, data_dir): out_dir = self._parent.output_dir # Compute the name for the PCB3D cur_id = self._expand_id cur_ext = self._expand_ext self._expand_id = self.pcb3d._expand_id self._expand_ext = self.pcb3d._expand_ext out_name = self._parent.expand_filename(out_dir, self.pcb3d.output) self._expand_id = cur_id self._expand_ext = cur_ext tree = {'name': '_temporal_compress_pcb3d', 'type': 'compress', 'comment': 'Internally created for the PCB3D', 'dir': out_dir, 'options': {'output': out_name, 'format': 'ZIP', 'files': [{'source': os.path.join(data_dir, 'boards'), 'dest': '/'}, {'source': os.path.join(data_dir, 'boards/*'), 'dest': 'boards'}, {'source': os.path.join(data_dir, 'components'), 'dest': '/'}, {'source': os.path.join(data_dir, 'components/*'), 'dest': 'components'}, {'source': os.path.join(data_dir, 'layers'), 'dest': '/'}, {'source': os.path.join(data_dir, 'layers/*'), 'dest': 'layers'}, {'source': os.path.join(data_dir, 'pads'), 'dest': '/'}, {'source': os.path.join(data_dir, 'pads/*'), 'dest': 'pads'}, {'source': os.path.join(data_dir, 'pcb.wrl'), 'dest': '/'}, ]}} out = RegOutput.get_class_for(tree['type'])() out.set_tree(tree) config_output(out) logger.debug(' - Creating the PCB3D ...') out.run(out_dir) return out_name def solve_pcb3d(self): if isinstance(self.pcb3d, str): # An output creates it pcb3d_targets, _, pcb3d_out = get_output_targets(self.pcb3d, self._parent) pcb3d_file = pcb3d_targets[0] logger.debug('- From file '+pcb3d_file) if not pcb3d_out._done: logger.debug('- Running '+self.pcb3d) run_output(pcb3d_out) self._pcb3d = PCB3DExportOptions() self._pcb3d.output = pcb3d_file # Needed by ensure tool self._pcb3d._parent = self._parent else: # We create it with TemporaryDirectory() as tmp_dir: # VRML self.create_vrml(tmp_dir) # SVG layers self.create_layers(tmp_dir) # Pads and bounds self.create_pads(tmp_dir) # Compress the files pcb3d_file = self.create_pcb3d(tmp_dir) self._pcb3d = self.pcb3d # Needed by ensure tool self.type = self._parent.type if not os.path.isfile(pcb3d_file): raise KiPlotConfigurationError('Missing '+pcb3d_file) return pcb3d_file def run(self, output): pcb3d_file = self.solve_pcb3d() # If no outputs specified just finish # Can be used to export the PCB to Blender if not self.outputs: return # Make sure Blender is available command = self._pcb3d.ensure_tool('Blender') # Create a JSON with the scene information with NamedTemporaryFile(mode='w', suffix='.json') as f: scene = {} if self.light: lights = [{'name': light.name, 'position': (light.pos_x, light.pos_y, light.pos_z)} for light in self.light] scene['lights'] = lights if self.camera: scene['camera'] = {'name': self.camera.name, 'position': (self.camera.pos_x, self.camera.pos_y, self.camera.pos_z)} ro = self.render_options scene['render'] = {'samples': ro.samples, 'resolution_x': ro.resolution_x, 'resolution_y': ro.resolution_y, 'transparent_background': ro.transparent_background, 'background1': ro.background1, 'background2': ro.background2} if self.rotate_x: scene['rotate_x'] = -self.rotate_x if self.rotate_y: scene['rotate_y'] = -self.rotate_y if self.rotate_z: scene['rotate_z'] = -self.rotate_z if self.view: scene['view'] = self.view text = json.dumps(scene, sort_keys=True, indent=2) logger.debug('Scene:\n'+text) f.write(text) f.flush() # Create the command line script = os.path.join(os.path.dirname(__file__), 'blender_scripts', 'blender_export.py') cmd = [command, '-b', '--factory-startup', '-P', script, '--'] pi = self.pcb_import if not pi.components: cmd.append('--no_components') if not pi.cut_boards: cmd.append('--dont_cut_boards') if pi.texture_dpi != 1016.0: cmd.extend(['--texture_dpi', str(pi.texture_dpi)]) if not pi.center: cmd.append('--dont_center') if not pi.enhance_materials: cmd.append('--dont_enhance_materials') if not pi.merge_materials: cmd.append('--dont_merge_materials') if pi.solder_joints != "SMART": cmd.extend(['--solder_joints', pi.solder_joints]) if not pi.stack_boards: cmd.append('--dont_stack_boards') cmd.append('--format') cmd.extend([o.type for o in self.outputs]) cmd.append('--output') for o in self.outputs: cmd.append(self.get_output_filename(o, self._parent.output_dir)) cmd.extend(['--scene', f.name]) cmd.append(pcb3d_file) # Execute the command run_command(cmd) @output_class class Blender_Export(Base3D): """ Blender Export **Experimental** Exports the PCB in various 3D file formats. Also renders the PCB with high-quality. This output is complex to setup and needs very big dependencies. Please be patient when using it. You need Blender with the pcb2blender plug-in installed. Visit: [pcb2blender](https://github.com/30350n/pcb2blender). You can just generate the exported PCB if no output is specified. You can also export the PCB and render it at the same time """ def __init__(self): super().__init__() with document: self.options = Blender_ExportOptions """ *[dict] Options for the `blender_export` output """ self._category = 'PCB/3D' @staticmethod def get_conf_examples(name, layers, templates): if not GS.check_tool(name, 'Blender'): return None outs = [] has_top = False has_bottom = False for la in layers: if la.is_top() or la.layer.startswith('F.'): has_top = True elif la.is_bottom() or la.layer.startswith('B.'): has_bottom = True if not has_top and not has_bottom: return None register_xmp_import('PCB2Blender_2_1') out_ops = {'pcb3d': '_PCB2Blender_2_1', 'outputs': [{'type': 'render'}, {'type': 'blender'}]} if has_top: gb = {} gb['name'] = 'basic_{}_top'.format(name) gb['comment'] = '3D view from top (Blender)' gb['type'] = name gb['dir'] = '3D' gb['options'] = copy(out_ops) outs.append(gb) gb = {} gb['name'] = 'basic_{}_30deg'.format(name) gb['comment'] = '3D view from 30 degrees (Blender)' gb['type'] = name gb['dir'] = '3D' gb['output_id'] = '_30deg' gb['options'] = copy(out_ops) gb['options'].update({'rotate_x': 30, 'rotate_z': -20}) outs.append(gb) if has_bottom: gb = {} gb['name'] = 'basic_{}_bottom'.format(name) gb['comment'] = '3D view from bottom (Blender)' gb['type'] = name gb['dir'] = '3D' gb['options'] = copy(out_ops) gb['options'].update({'view': 'bottom'}) outs.append(gb) return outs