├── README.md ├── Template.blend ├── blender.py ├── enhanced.png ├── generator.py ├── inkscape.py ├── kicad.py ├── kicad2vrml.7z ├── raw.png └── setup.py /README.md: -------------------------------------------------------------------------------- 1 | # KiBlenderCad 2 | Based on the work of @PCB-Arts: https://github.com/PCB-Arts/stylized-blender-setup 3 | Automatically create a blender file of a kicad_pcb based on a template: 4 | 5 | * Export layers in SVG files 6 | * Export VRML file 7 | * Convert SVG files to PNG textures thanks to inkscape 8 | * Integrate PNG layers and maps textures in Blender files 9 | 10 | ``` 11 | generator.py -vvvvv "D:\MyPcb\MyPcb.kicad_pcb" 12 | ``` 13 | 14 | 15 | ### Important 16 | 17 | * You need to apply the VRML color import patch on your Blender installation: 18 | https://developer.blender.org/T71232 19 | 20 | * You need to install kicad2vrml inside *bin* directory of your Kicad installation: 21 | * Compile kicad2vrml from https://github.com/diorcety/kicad/tree/kicad2vrml 22 | * Use Windows executable available for Kicad 5.1.x in [kicad2vrml.7z](./kicad2vrml.7z) 23 | 24 | ### Examples 25 | ## [Icezum](https://github.com/FPGAwars/icezum) 26 | 27 | - Blender output just after the script 28 | 29 | ![raw](./raw.png) 30 | 31 | 32 | - After few minutes and some material replacements 33 | 34 | ![enhanced](./enhanced.png) -------------------------------------------------------------------------------- /Template.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diorcety/KiBlenderCad/a86e6cdcf48caace22f6454818b32c7162e93670/Template.blend -------------------------------------------------------------------------------- /blender.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | # cx_freeze issue like this one https://github.com/pyinstaller/pyinstaller/issues/3795 6 | if sys.platform == "win32": 7 | import ctypes 8 | ctypes.windll.kernel32.SetDllDirectoryA(None) 9 | 10 | 11 | import bpy 12 | import bmesh 13 | 14 | import logging 15 | import argparse 16 | import pathlib 17 | 18 | from mathutils.bvhtree import BVHTree 19 | from collections import defaultdict 20 | from mathutils import Vector, Quaternion, Matrix 21 | from functools import reduce 22 | from itertools import product 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | ### 28 | ### FROM https://gist.github.com/SURYHPEZ/9502819 29 | ### 30 | 31 | def merge_boxes(objects): 32 | return reduce(lambda x, y: x + y, [Box(obj) for obj in objects if obj.type == 'MESH']) 33 | 34 | 35 | class Box: 36 | def __init__(self, bl_object=None, max_min=None): 37 | if bl_object and bl_object.type == 'MESH': 38 | self.__bound_box = self.__get_bound_box_from_object(bl_object) 39 | elif max_min: 40 | self.__bound_box = self.__get_bound_box_from_max_min(max_min) 41 | else: 42 | raise TypeError() 43 | 44 | def __add__(self, bound_box): 45 | return self.merge(bound_box) 46 | 47 | def __getitem__(self, index): 48 | return self.__bound_box[index] 49 | 50 | @property 51 | def max(self): 52 | return Vector(max((v.x, v.y, v.z) for v in self.__bound_box)) 53 | 54 | @property 55 | def min(self): 56 | return Vector(min((v.x, v.y, v.z) for v in self.__bound_box)) 57 | 58 | @property 59 | def center(self): 60 | return sum((v for v in self.__bound_box), Vector()) / 8 61 | 62 | def merge(self, box): 63 | if not box: 64 | return self 65 | 66 | if not isinstance(box, Box): 67 | raise TypeError('Require a Box object') 68 | 69 | max_new = Vector(map(max, zip(self.max, box.max))) 70 | min_new = Vector(map(min, zip(self.min, box.min))) 71 | 72 | return Box(max_min=(max_new, min_new)) 73 | 74 | def __get_bound_box_from_object(self, bl_object): 75 | return [bl_object.matrix_world @ Vector(v) for v in bl_object.bound_box] 76 | 77 | def __get_bound_box_from_max_min(self, max_min): 78 | max_point, min_point = max_min 79 | 80 | return [Vector(v) for v in product((max_point.x, min_point.x), 81 | (max_point.y, min_point.y), 82 | (max_point.z, min_point.z))] 83 | 84 | 85 | ### 86 | ### 87 | ### 88 | 89 | texture_map = { 90 | 'Top Copper': 'F_Cu', 91 | 'Bottom Copper': 'B_Cu', 92 | 'Top Silkscreen': 'F_SilkS', 93 | 'Bottom Silkscreen': 'B_SilkS', 94 | 'Top Soldermask': 'F_Mask', 95 | 'Bottom Soldermask': 'B_Mask', 96 | } 97 | 98 | 99 | def get_by_label(nodes, label): 100 | for node in nodes: 101 | if node.label == label: 102 | return node 103 | raise Exception("Node %s not found" % label) 104 | 105 | 106 | def bvh_from_bmesh(obj): 107 | bm = bmesh.new() 108 | bm.from_mesh(obj.data) 109 | bm.transform(obj.matrix_world) 110 | result = BVHTree.FromBMesh(bm) 111 | return result, bm 112 | 113 | 114 | def get_closest_distance(bmesh1, bmesh2, max_distance=0.01): 115 | min_d = float("inf") 116 | _, bm1 = bmesh1 117 | bvh2, _ = bmesh2 118 | for v in bm1.verts: 119 | r = bvh2.find_nearest_range(v.co, max_distance) 120 | if len(r) > 0: 121 | min_d = min(min([a[3] for a in r]), min_d) 122 | return min_d 123 | 124 | 125 | def is_overlapped(bmesh1, bmesh2): 126 | bvh1, _ = bmesh1 127 | bvh2, _ = bmesh2 128 | return len(bvh1.overlap(bvh2, )) > 0 129 | 130 | 131 | def join_tree_objects_with_tree(tree, tree_objects): 132 | name = tree.name 133 | tree_objects = list(tree_objects) 134 | tree_objects.insert(0, tree) 135 | ctx = bpy.context.copy() 136 | ctx["active_object"] = tree_objects[0] 137 | ctx["selected_editable_objects"] = tree_objects 138 | bpy.ops.object.join(ctx) 139 | return bpy.data.objects[name] 140 | 141 | 142 | def factorize_mats(objects): 143 | dct = defaultdict(set) 144 | 145 | # Get All object mats 146 | for obj in objects: 147 | for mat_s in obj.material_slots: 148 | mat = mat_s.material 149 | if mat.use_nodes: 150 | continue 151 | identifier = (tuple(mat.diffuse_color), mat.specular_color.copy().freeze()) 152 | dct[identifier].add(mat) 153 | 154 | for mat_set in dct.values(): 155 | mat_list = list(mat_set) 156 | for mat in mat_list[1:]: 157 | mat.user_remap(mat_list[0]) 158 | bpy.data.materials.remove(mat) 159 | 160 | 161 | def regroup_meshes(objects, max_distance=0.05): 162 | initial_len = len(objects) 163 | 164 | objects = set(objects) 165 | world_v = {obj: bvh_from_bmesh(obj) for obj in objects} 166 | 167 | def remove_from_world_vertices(a): 168 | bvh, bm = world_v.pop(a) 169 | bm.free() 170 | 171 | while len(objects) > 0: 172 | logger.info("Regroup meshes progression: %d%%" % ((initial_len - len(objects)) / initial_len * 100)) 173 | to_merge = set() 174 | 175 | obj_a = objects.pop() 176 | for obj_b in objects: 177 | if is_overlapped(world_v[obj_a], world_v[obj_b]): 178 | to_merge.add(obj_b) 179 | else: 180 | d = get_closest_distance(world_v[obj_a], world_v[obj_b], max_distance) 181 | if d <= max_distance: 182 | to_merge.add(obj_b) 183 | 184 | remove_from_world_vertices(obj_a) 185 | 186 | for obj in to_merge: 187 | # Find object with same data (have to be really removed) 188 | for o in bpy.data.objects: 189 | if o.data == obj.data and o != obj: 190 | if o in objects: 191 | objects.remove(o) 192 | remove_from_world_vertices(o) 193 | bpy.data.objects.remove(o, do_unlink=True) 194 | objects.remove(obj) 195 | remove_from_world_vertices(obj) 196 | 197 | if len(to_merge) > 0: 198 | ret = join_tree_objects_with_tree(obj_a, to_merge) 199 | 200 | # Update data 201 | objects.add(ret) 202 | world_v[ret] = bvh_from_bmesh(ret) 203 | 204 | 205 | def get_mass_center(obj): 206 | local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector()) 207 | global_bbox_center = obj.matrix_world @ local_bbox_center 208 | return global_bbox_center 209 | 210 | 211 | def cleanup(objects, distance=0.000001): 212 | meshes = set(o.data for o in objects if o.type == 'MESH') 213 | 214 | bm = bmesh.new() 215 | 216 | for m in meshes: 217 | bm.from_mesh(m) 218 | bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=distance) 219 | bm.to_mesh(m) 220 | m.update() 221 | bm.clear() 222 | 223 | bm.free() 224 | 225 | 226 | def get_pcb(objects, dimensions, approximation=0.03): 227 | result = [] 228 | for o in objects: 229 | if sum([1 if len(list(filter(lambda x: abs(x - y) / y <= approximation, o.dimensions))) >= 1 else 0 for y in 230 | dimensions]) == len(dimensions): 231 | result.append(o) 232 | return result 233 | 234 | 235 | def fancy_positioning(camera, focus, all_objects): 236 | origin = Vector((0, 0, 0)) 237 | objects_box = merge_boxes(all_objects) 238 | dimensions = objects_box.max - objects_box.min 239 | direction = (camera.location - focus.location).normalized() 240 | axis_align = Vector(([1.0 if dimensions[i] == min(dimensions) else 0.0 for i in range(3)])) 241 | angle = axis_align.angle(direction) 242 | axis = axis_align.cross(direction) 243 | 244 | r = Quaternion(axis, angle).to_matrix().to_4x4() 245 | m1 = Matrix.Translation(origin - objects_box.center) 246 | m2 = Matrix.Translation(focus.location - origin) 247 | 248 | # Move to origin, rotate and move to focus point 249 | for o in all_objects: 250 | o.matrix_world = (m2 @ r @ m1) @ o.matrix_world 251 | 252 | 253 | def instantiate_template(output_file, template_file, pcb_dimensions, wrl, texture_directory): 254 | logger.info("Open template file: %s" % template_file) 255 | bpy.ops.wm.open_mainfile(filepath=template_file) 256 | 257 | bpy.ops.object.select_all(action='DESELECT') 258 | 259 | logger.info("Create PCB collection") 260 | pcb_collection = bpy.data.collections.new("PCB") 261 | bpy.context.scene.collection.children.link(pcb_collection) 262 | 263 | logger.info("Import WRL file: %s" % wrl) 264 | if not os.path.exists(wrl): 265 | raise Exception("WRL file doesn't exist") 266 | bpy.ops.import_scene.x3d(filepath=wrl) 267 | 268 | for obj in bpy.context.selected_objects: 269 | for coll in obj.users_collection: 270 | # Unlink the object 271 | coll.objects.unlink(obj) 272 | pcb_collection.objects.link(obj) 273 | obj.select_set(False) 274 | 275 | pcb_collection = bpy.data.collections.get("PCB") 276 | 277 | logger.info("Clean up duplicated vertices") 278 | cleanup(pcb_collection.all_objects) 279 | 280 | logger.info("Clean up duplicated materials") 281 | factorize_mats(pcb_collection.all_objects) 282 | 283 | logger.info("Merge PCB objects (PCB edges and holes, with surface ones)") 284 | pcb_objects = None 285 | if pcb_dimensions is not None: 286 | pcb_objects = get_pcb(pcb_collection.all_objects, pcb_dimensions) 287 | if len(pcb_objects) == 0: 288 | dimensions_str = (', '.join(map(str, pcb_dimensions))) 289 | logger.warning("Can't find objects with same dimensions as specified: %s" % dimensions_str) 290 | if pcb_objects is None: 291 | logger.warning("Use two last objects of imported VRML as PCB objects (default VRML Kicad exporter behaviour)") 292 | pcb_objects = pcb_collection.all_objects[-2:] 293 | if len(pcb_objects) > 1: 294 | pcb_object = join_tree_objects_with_tree(pcb_objects[0], pcb_objects[1:]) 295 | else: 296 | pcb_object = pcb_objects[0] 297 | pcb_object.name = "Board" 298 | 299 | logger.info("Merge meshes from same components") 300 | component_mesh = set(pcb_collection.all_objects) 301 | component_mesh.remove(pcb_object) 302 | regroup_meshes(component_mesh) 303 | 304 | logger.info("Clean up duplicated vertices again") 305 | cleanup(pcb_collection.all_objects) 306 | 307 | logger.info("Link PCB material with textures") 308 | pcb_mat = bpy.data.materials.get("PCB") 309 | for node_label, file_pattern in texture_map.items(): 310 | svg_files = list(pathlib.Path(texture_directory).glob('*' + file_pattern + '.png')) 311 | if len(svg_files) != 1: 312 | logger.warning("File for pattern \"%s\" not found" % file_pattern) 313 | continue 314 | get_by_label(pcb_mat.node_tree.nodes, node_label).image = bpy.data.images.load(filepath=str(svg_files[0])) 315 | 316 | logger.info("Create UV Maps") 317 | top_map = pcb_object.data.uv_layers.new(name='Top UV Map') 318 | bottom_map = pcb_object.data.uv_layers.new(name='Bottom UV Map') 319 | get_by_label(pcb_mat.node_tree.nodes, "UV Map Top").uv_map = top_map.name 320 | get_by_label(pcb_mat.node_tree.nodes, "UV Map Bottom").uv_map = bottom_map.name 321 | 322 | logger.info("Add PCB material to PCB Mesh") 323 | pcb_object.data.materials[0] = pcb_mat 324 | 325 | logger.info("Positioning the board") 326 | fancy_positioning(bpy.data.objects['Camera'], bpy.data.objects['Camera_Focus'], pcb_collection.all_objects) 327 | 328 | logger.info("Save final file: %s" % output_file) 329 | bpy.ops.wm.save_as_mainfile(filepath=output_file) 330 | bpy.ops.file.make_paths_relative() 331 | bpy.ops.wm.save_mainfile() 332 | 333 | 334 | def dim_type(strings): 335 | return tuple(map(float, strings.split(":"))) 336 | 337 | 338 | def _main(argv=sys.argv): 339 | logging.basicConfig(level=logging.DEBUG, 340 | format='%(asctime)s - %(threadName)s - %(name)s - %(levelname)s - %(message)s') 341 | parser = argparse.ArgumentParser(prog=argv[0], description='KicadBlender', 342 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 343 | parser.add_argument('-v', '--verbose', dest='verbose_count', action='count', default=0, 344 | help="increases log verbosity for each occurrence.") 345 | parser.add_argument('-d', '--dimensions', type=dim_type, 346 | help="pcb dimensions: width:height:tickness") 347 | parser.add_argument('template', 348 | help="template file") 349 | parser.add_argument('wrl', 350 | help="wrl input file") 351 | parser.add_argument('textures', 352 | help="textures input directory") 353 | parser.add_argument('output', 354 | help="input blender file") 355 | 356 | # Parse 357 | args, unknown_args = parser.parse_known_args(argv[1:]) 358 | 359 | # Set logging level 360 | logger.setLevel(max(3 - args.verbose_count, 0) * 10) 361 | 362 | instantiate_template(args.output, args.template, args.dimensions, args.wrl, args.textures) 363 | 364 | 365 | def main(): 366 | try: 367 | sys.exit(_main(sys.argv[sys.argv.index("--"):])) 368 | except Exception as e: 369 | logger.exception(e) 370 | sys.exit(-1) 371 | finally: 372 | logging.shutdown() 373 | 374 | 375 | if __name__ == "__main__": 376 | main() 377 | -------------------------------------------------------------------------------- /enhanced.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diorcety/KiBlenderCad/a86e6cdcf48caace22f6454818b32c7162e93670/enhanced.png -------------------------------------------------------------------------------- /generator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | 5 | import logging 6 | import sys 7 | import argparse 8 | import subprocess 9 | import json 10 | import pathlib 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | if os.name == 'nt': 15 | import winreg 16 | 17 | 18 | def read_install_location(software_name): 19 | # Need to traverse the two registry 20 | sub_key = [r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', 21 | r'SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall'] 22 | 23 | for i in sub_key: 24 | key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, i, 0, winreg.KEY_READ) 25 | for j in range(0, winreg.QueryInfoKey(key)[0] - 1): 26 | try: 27 | key_name = winreg.EnumKey(key, j) 28 | key_path = i + '\\' + key_name 29 | each_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path, 0, winreg.KEY_READ) 30 | displayName, _ = winreg.QueryValueEx(each_key, 'DisplayName') 31 | if software_name.lower() in displayName.lower() or software_name.lower() in key_name.lower(): 32 | installLocation, _ = winreg.QueryValueEx(each_key, 'InstallLocation') 33 | return installLocation 34 | except WindowsError: 35 | pass 36 | raise Exception("Install location not found for %s" % software_name) 37 | 38 | 39 | kicad_python_program = os.path.join(read_install_location('KiCad 5.1'), 'bin', 'python.exe') 40 | blender_program = os.path.join(read_install_location('Blender'), 'blender.exe') 41 | else: 42 | kicad_python_program = 'python' 43 | blender_program = 'blender' 44 | 45 | 46 | def mkdir_p(path): 47 | if not os.path.exists(path): 48 | os.makedirs(path) 49 | return path 50 | 51 | 52 | def call_program(*args, **kwargs): 53 | exec_env = os.environ.copy() 54 | for v in ['VIRTUAL_ENV', 'PYTHONPATH', 'PYTHONUNBUFFERED']: 55 | exec_env.pop(v, None) 56 | exec_env['PATH'] = os.pathsep.join([a for a in exec_env['PATH'].split(os.pathsep) if a not in sys.path]) 57 | kwargs = kwargs.copy() 58 | kwargs.update(env=exec_env) 59 | ret = subprocess.call(*args, **kwargs) 60 | if ret != 0: 61 | raise Exception("Error occurs in a subprogram") 62 | 63 | 64 | def _main(argv=sys.argv): 65 | logging.basicConfig(stream=sys.stderr, level=logging.DEBUG, 66 | format='%(asctime)s - %(threadName)s - %(name)s - %(levelname)s - %(message)s') 67 | parser = argparse.ArgumentParser(prog=argv[0], description='KicadBlender', 68 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 69 | parser.add_argument('-v', '--verbose', dest='verbose_count', action='count', default=1, 70 | help="increases log verbosity for each occurrence.") 71 | parser.add_argument('-q', '--quality', default=100, type=int, 72 | help="texture quality") 73 | parser.add_argument('-o', '--output', default=None, 74 | help="output directory") 75 | parser.add_argument('input', 76 | help="input kicad file") 77 | 78 | # Parse 79 | args, unknown_args = parser.parse_known_args(argv[1:]) 80 | 81 | # Set logging level 82 | logging.getLogger().setLevel(max(3 - args.verbose_count, 0) * 10) 83 | 84 | verbose_args = 'v' * args.verbose_count 85 | if len(verbose_args): 86 | verbose_args = ['-' + verbose_args] 87 | else: 88 | verbose_args = [] 89 | 90 | if args.output is None: 91 | args.output = os.path.join(os.path.dirname(args.input), "blender") 92 | 93 | tmp_path = mkdir_p(os.path.join(args.output, "tmp")) 94 | textures_path = mkdir_p(os.path.join(args.output, "textures")) 95 | if getattr(sys, 'frozen', False): 96 | script_path = os.path.dirname(sys.executable) 97 | else: 98 | script_path = pathlib.Path(__file__).parent.resolve() 99 | blender_file = os.path.join(args.output, os.path.basename(os.path.splitext(args.input)[0] + '.blend')) 100 | 101 | logger.info("Export boards SVGs and VRML") 102 | # Call kicad script with kicad python executable 103 | sub_args = [kicad_python_program, os.path.join(script_path, "kicad.py")] 104 | sub_args += verbose_args 105 | sub_args += [args.input, tmp_path] 106 | logger.debug("Call " + " ".join(sub_args)) 107 | call_program(sub_args) 108 | 109 | logger.debug("Opening SVG data output") 110 | with open(os.path.join(tmp_path, "data.json")) as json_file: 111 | data = json.load(json_file) 112 | 113 | logger.debug("Data: %s" % data) 114 | logger.info("SVGs to PNGs") 115 | svg_files = pathlib.Path(tmp_path).glob('*.svg') 116 | for path in svg_files: 117 | svg_file = str(path) 118 | png_file = os.path.join(textures_path, path.with_suffix(".png").name) 119 | 120 | # Call inkscape script with current python env 121 | if getattr(sys, 'frozen', False): 122 | sub_args = [os.path.join(script_path, "inkscape.exe")] 123 | else: 124 | sub_args = [sys.executable, os.path.join(script_path, "inkscape.py")] 125 | sub_args += verbose_args 126 | sub_args += [svg_file, "-o", png_file] 127 | sub_args += ["-s", str(args.quality)] 128 | sub_args += ["-u", data['units']] 129 | sub_args += ["--", "%f:%f:%f:%f" % (data['x'], data['y'], data['width'], data['height'])] 130 | logger.debug("Call " + " ".join(sub_args)) 131 | call_program(sub_args) 132 | 133 | logger.info("Create Blender file") 134 | # Call blender script with blender executable 135 | sub_args = [blender_program, "--background", "--python", os.path.join(script_path, "blender.py"), '--'] 136 | sub_args += verbose_args 137 | sub_args += ["-d", "%f:%f:%f" % (data['width'], data['height'], data['thickness'])] 138 | sub_args += [os.path.join(script_path, "Template.blend"), data['vrml'], textures_path, blender_file] 139 | logger.debug("Call " + " ".join(sub_args)) 140 | call_program(sub_args) 141 | 142 | 143 | def main(): 144 | try: 145 | sys.exit(_main(sys.argv)) 146 | except Exception as e: 147 | logger.exception(e) 148 | sys.exit(-1) 149 | finally: 150 | logging.shutdown() 151 | 152 | 153 | if __name__ == "__main__": 154 | main() 155 | -------------------------------------------------------------------------------- /inkscape.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import xml.dom.minidom as minidom 4 | import re 5 | import sys 6 | import subprocess 7 | import argparse 8 | import logging 9 | import locale 10 | import os 11 | 12 | from enum import Enum 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | number = r'([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)' 17 | number_unit = number + '(\w+)?' 18 | 19 | 20 | class LengthUnit(Enum): 21 | mm = ('mm', 3.779528) 22 | cm = ('cm', 37.79528) 23 | 24 | 25 | str_to_enum_unit = {a.value[0]: a.value for a in LengthUnit} 26 | 27 | if os.name == 'nt': 28 | import winreg 29 | 30 | 31 | def read_install_location(software_name): 32 | # Need to traverse the two registry 33 | sub_key = [r'SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', 34 | r'SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall'] 35 | 36 | for i in sub_key: 37 | key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, i, 0, winreg.KEY_READ) 38 | for j in range(0, winreg.QueryInfoKey(key)[0] - 1): 39 | try: 40 | key_name = winreg.EnumKey(key, j) 41 | key_path = i + '\\' + key_name 42 | each_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path, 0, winreg.KEY_READ) 43 | displayName, _ = winreg.QueryValueEx(each_key, 'DisplayName') 44 | if software_name.lower() in displayName.lower(): 45 | installLocation, _ = winreg.QueryValueEx(each_key, 'InstallLocation') 46 | return installLocation 47 | except WindowsError: 48 | pass 49 | raise Exception("Install location not found for %s" % software_name) 50 | 51 | 52 | ink_program = os.path.join(read_install_location('Inkscape'), 'bin', 'inkscape.com') 53 | else: 54 | ink_program = 'inkscape' 55 | 56 | # Use current locale (used by inkscape) 57 | if os.name == 'nt': 58 | curr = locale.getdefaultlocale() 59 | locale.setlocale(locale.LC_ALL, curr[0]) 60 | 61 | 62 | def number_format(number): 63 | str = '{0:n}'.format(number) 64 | return re.sub(r"\s+", '', str) # Remove space separators 65 | 66 | 67 | class Transform: 68 | def __init__(self, **entries): 69 | self.__dict__.update(entries) 70 | 71 | 72 | def svg_to_png(ifile, ofile, options): 73 | doc = minidom.parse(ifile) 74 | svg_elem = doc.getElementsByTagName('svg')[0] 75 | height = svg_elem.getAttribute('height') 76 | match = re.search(number_unit, height, re.IGNORECASE) 77 | if not match: 78 | raise Exception("Invalid height attribute") 79 | user_to_dpi_r = str_to_enum_unit[options.unit][1] 80 | ink_x1 = options.x * user_to_dpi_r 81 | if os.name != 'nt': 82 | height_value = float(match.group(1)) 83 | height_unit = str_to_enum_unit[match.group(2)] 84 | svg_to_dpi_r = height_unit[1] 85 | ink_y1 = height_value * svg_to_dpi_r - (options.y + options.height) * user_to_dpi_r 86 | else: 87 | ink_y1 = options.y * user_to_dpi_r 88 | ink_width = options.width * user_to_dpi_r 89 | ink_height = options.height * user_to_dpi_r 90 | ink_x2 = ink_x1 + ink_width 91 | ink_y2 = ink_y1 + ink_height 92 | ink_scale = options.scale / user_to_dpi_r * 96 93 | args = [ink_program] 94 | args += ['--export-dpi=%s' % number_format(ink_scale)] 95 | args += ['--export-area=%s:%s:%s:%s' % tuple([number_format(a) for a in (ink_x1, ink_y1, ink_x2, ink_y2)])] 96 | args += ['--export-background=white'] 97 | args += ['-o' if os.name == 'nt' else '-e', ofile, ifile] 98 | logger.debug("Call " + " ".join(args)) 99 | subprocess.call(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 100 | 101 | 102 | def coord_type(strings): 103 | return tuple(map(float, strings.split(":"))) 104 | 105 | 106 | def _main(argv=sys.argv): 107 | logging.basicConfig(stream=sys.stderr, level=logging.DEBUG, 108 | format='%(asctime)s - %(threadName)s - %(name)s - %(levelname)s - %(message)s') 109 | parser = argparse.ArgumentParser(prog=argv[0], description='KicadBlender', 110 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 111 | parser.add_argument('-v', '--verbose', dest='verbose_count', action='count', default=0, 112 | help="increases log verbosity for each occurrence.") 113 | parser.add_argument('-o', '--output', default=None, 114 | help="output file") 115 | parser.add_argument('-s', '--scale', default=100.0, type=float, 116 | help="scale to apply (pixel by user unit)") 117 | parser.add_argument('-u', '--unit', default="cm", 118 | help="user unit (taken from SVG if not defined)") 119 | parser.add_argument('input', 120 | help="input file") 121 | parser.add_argument('area', type=coord_type, 122 | help="area to export (user unit) format: x:y:w:h") 123 | 124 | # Parse 125 | args, unknown_args = parser.parse_known_args(argv[1:]) 126 | 127 | # Set logging level 128 | logging.getLogger().setLevel(max(3 - args.verbose_count, 0) * 10) 129 | 130 | if args.output is None: 131 | args.output = os.path.splitext(args.input)[0] + '.png' 132 | 133 | options = Transform(**{ 134 | 'x': args.area[0], 135 | 'y': args.area[1], 136 | 'width': args.area[2], 137 | 'height': args.area[3], 138 | 'scale': args.scale, 139 | 'unit': args.unit, 140 | }) 141 | 142 | logger.info('Convert SVG %s to PNG %s' % (args.input, args.output)) 143 | svg_to_png(args.input, args.output, options) 144 | 145 | 146 | def main(): 147 | try: 148 | sys.exit(_main(sys.argv)) 149 | except Exception as e: 150 | logger.exception(e) 151 | sys.exit(-1) 152 | finally: 153 | logging.shutdown() 154 | 155 | 156 | if __name__ == "__main__": 157 | main() 158 | -------------------------------------------------------------------------------- /kicad.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | # cx_freeze issue like this one https://github.com/pyinstaller/pyinstaller/issues/3795 6 | if sys.platform == "win32": 7 | import ctypes 8 | ctypes.windll.kernel32.SetDllDirectoryA(None) 9 | 10 | import pcbnew 11 | import logging 12 | import argparse 13 | import json 14 | import subprocess 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | def call_program(*args, **kwargs): 20 | exec_env = os.environ.copy() 21 | for v in ['VIRTUAL_ENV', 'PYTHONPATH', 'PYTHONUNBUFFERED']: 22 | exec_env.pop(v, None) 23 | kwargs = kwargs.copy() 24 | kwargs.update(env=exec_env) 25 | ret = subprocess.call(*args, **kwargs) 26 | if ret != 0: 27 | raise Exception("Error occurs in a subprogram") 28 | 29 | 30 | def set_default_settings(board): 31 | plot_controller = pcbnew.PLOT_CONTROLLER(board) 32 | plot_options = plot_controller.GetPlotOptions() 33 | 34 | plot_options.SetPlotFrameRef(False) 35 | plot_options.SetLineWidth(pcbnew.FromMM(0.35)) 36 | plot_options.SetScale(1) 37 | plot_options.SetUseAuxOrigin(True) 38 | plot_options.SetMirror(False) 39 | plot_options.SetExcludeEdgeLayer(False) 40 | plot_controller.SetColorMode(True) 41 | 42 | 43 | def plot(board, layer, file, name, output_directory): 44 | plot_controller = pcbnew.PLOT_CONTROLLER(board) 45 | plot_options = plot_controller.GetPlotOptions() 46 | plot_options.SetOutputDirectory(output_directory) 47 | plot_controller.SetLayer(layer) 48 | plot_controller.OpenPlotfile(file, pcbnew.PLOT_FORMAT_SVG, name) 49 | output_filename = plot_controller.GetPlotFileName() 50 | plot_controller.PlotLayer() 51 | plot_controller.ClosePlot() 52 | return output_filename 53 | 54 | 55 | def normalize(point): 56 | return [point[0] / pcbnew.IU_PER_MM, point[1] / pcbnew.IU_PER_MM] 57 | 58 | 59 | def parse_poly_set(self, polygon_set): 60 | result = [] 61 | for polygon_index in range(polygon_set.OutlineCount()): 62 | outline = polygon_set.Outline(polygon_index) 63 | if not hasattr(outline, "PointCount"): 64 | self.logger.warn("No PointCount method on outline object. " 65 | "Unpatched kicad version?") 66 | return result 67 | parsed_outline = [] 68 | for point_index in range(outline.PointCount()): 69 | point = outline.CPoint(point_index) 70 | parsed_outline.append(self.normalize([point.x, point.y])) 71 | result.append(parsed_outline) 72 | 73 | return result 74 | 75 | 76 | def parse_shape(d): 77 | # type: (pcbnew.PCB_SHAPE) -> dict or None 78 | shape = { 79 | pcbnew.S_SEGMENT: "segment", 80 | pcbnew.S_CIRCLE: "circle", 81 | pcbnew.S_ARC: "arc", 82 | pcbnew.S_POLYGON: "polygon", 83 | pcbnew.S_CURVE: "curve", 84 | pcbnew.S_RECT: "rect", 85 | }.get(d.GetShape(), "") 86 | if shape == "": 87 | logger.info("Unsupported shape %s, skipping", d.GetShape()) 88 | return None 89 | start = normalize(d.GetStart()) 90 | end = normalize(d.GetEnd()) 91 | if shape in ["segment", "rect"]: 92 | return { 93 | "type": shape, 94 | "start": start, 95 | "end": end, 96 | "width": d.GetWidth() * 1e-6 97 | } 98 | if shape == "circle": 99 | return { 100 | "type": shape, 101 | "start": start, 102 | "radius": d.GetRadius() * 1e-6, 103 | "width": d.GetWidth() * 1e-6 104 | } 105 | if shape == "arc": 106 | a1 = round(d.GetArcAngleStart() * 0.1, 2) 107 | a2 = round((d.GetArcAngleStart() + d.GetAngle()) * 0.1, 2) 108 | if d.GetAngle() < 0: 109 | (a1, a2) = (a2, a1) 110 | return { 111 | "type": shape, 112 | "start": start, 113 | "radius": d.GetRadius() * 1e-6, 114 | "startangle": a1, 115 | "endangle": a2, 116 | "width": d.GetWidth() * 1e-6 117 | } 118 | if shape == "polygon": 119 | if hasattr(d, "GetPolyShape"): 120 | polygons = parse_poly_set(d.GetPolyShape()) 121 | else: 122 | logger.info("Polygons not supported for KiCad 4, skipping") 123 | return None 124 | angle = 0 125 | if hasattr(d, 'GetParentModule'): 126 | parent_footprint = d.GetParentModule() 127 | else: 128 | parent_footprint = d.GetParentFootprint() 129 | if parent_footprint is not None: 130 | angle = parent_footprint.GetOrientation() * 0.1, 131 | return { 132 | "type": shape, 133 | "pos": start, 134 | "angle": angle, 135 | "polygons": polygons 136 | } 137 | if shape == "curve": 138 | return { 139 | "type": shape, 140 | "start": start, 141 | "cpa": normalize(d.GetBezControl1()), 142 | "cpb": normalize(d.GetBezControl2()), 143 | "end": end, 144 | "width": d.GetWidth() * 1e-6 145 | } 146 | 147 | 148 | def parse_drawing(d): 149 | if d.GetClass() in ["DRAWSEGMENT", "MGRAPHIC", "PCB_SHAPE"]: 150 | return parse_shape(d) 151 | else: 152 | return None 153 | 154 | 155 | def parse_edges(pcb): 156 | edges = [] 157 | drawings = list(pcb.GetDrawings()) 158 | bbox = None 159 | for d in drawings: 160 | if d.GetLayer() == pcbnew.Edge_Cuts: 161 | parsed_drawing = parse_drawing(d) 162 | if parsed_drawing: 163 | edges.append(parsed_drawing) 164 | if bbox is None: 165 | bbox = d.GetBoundingBox() 166 | else: 167 | bbox.Merge(d.GetBoundingBox()) 168 | if bbox: 169 | bbox.Normalize() 170 | return edges, bbox 171 | 172 | 173 | def get_bounding_box(pcb): 174 | edges, _ = parse_edges(pcb) 175 | x_min = y_min = float('+inf') 176 | x_max = y_max = float('-inf') 177 | for edge in edges: 178 | if 'start' in edge and 'end' in edge: 179 | xs, ys = zip(edge['start'], edge['end']) 180 | for x in xs: 181 | x_min = min(x_min, x) 182 | x_max = max(x_max, x) 183 | for y in ys: 184 | y_min = min(y_min, y) 185 | y_max = max(y_max, y) 186 | return x_min, y_min, x_max, y_max 187 | 188 | 189 | def export_layers(board, output_directory): 190 | set_default_settings(board) 191 | 192 | for layer in board.GetEnabledLayers().UIOrder(): 193 | file = name = board.GetLayerName(layer) 194 | logger.debug('plotting layer {} ({}) to SVG'.format(name, layer)) 195 | output_filename = plot(board, layer, file, name, output_directory) 196 | logger.info('Layer %s SVG: %s' % (name, output_filename)) 197 | 198 | 199 | def export_vrml(board_file, vrml_file, origin): 200 | if os.name == 'nt': 201 | kicad2vrml = os.path.join(os.path.dirname(sys.executable), "kicad2vrml.exe") 202 | else: 203 | kicad2vrml = "kicad2vrml" 204 | sub_args = [kicad2vrml] 205 | sub_args += [board_file] 206 | sub_args += ["-f", "-o", vrml_file] 207 | sub_args += ["--user-origin", "%fx%f" % (origin[0], origin[1])] 208 | logger.debug("Call " + " ".join(sub_args)) 209 | call_program(sub_args) 210 | 211 | 212 | def _main(argv=sys.argv): 213 | logging.basicConfig(stream=sys.stderr, level=logging.DEBUG, 214 | format='%(asctime)s - %(threadName)s - %(name)s - %(levelname)s - %(message)s') 215 | parser = argparse.ArgumentParser(prog=argv[0], description='KicadBlender', 216 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 217 | parser.add_argument('-v', '--verbose', dest='verbose_count', action='count', default=0, 218 | help="increases log verbosity for each occurrence.") 219 | parser.add_argument('-o', '--output', default=None, 220 | help="output file") 221 | parser.add_argument('input', 222 | help="input kicad file") 223 | parser.add_argument('output', 224 | help="output directory") 225 | 226 | # Parse 227 | args, unknown_args = parser.parse_known_args(argv[1:]) 228 | 229 | # Set logging level 230 | logging.getLogger().setLevel(max(3 - args.verbose_count, 0) * 10) 231 | 232 | logger.debug("Loading board") 233 | board = pcbnew.LoadBoard(args.input) 234 | if not os.path.exists(args.output): 235 | os.makedirs(args.output) 236 | 237 | logger.info("Export Layers to SVG") 238 | export_layers(board, args.output) 239 | 240 | x, y = board.GetGridOrigin() 241 | x, y = (x / pcbnew.IU_PER_MM, y / pcbnew.IU_PER_MM) 242 | vrml_file = os.path.join(args.output, os.path.basename(os.path.splitext(args.input)[0] + '.wrl')) 243 | logger.info("Export VRML") 244 | export_vrml(args.input, vrml_file, (x, y)) 245 | 246 | with open(os.path.join(args.output, "data.json"), 'wb') as fp: 247 | x1, y1, x2, y2 = get_bounding_box(board) 248 | values = { 249 | 'x': x1, 250 | "y": y1, 251 | 'width': x2 - x1, 252 | 'height': y2 - y1, 253 | 'thickness': board.GetDesignSettings().GetBoardThickness() / pcbnew.IU_PER_MM, 254 | 'units': 'mm', 255 | 'vrml': vrml_file 256 | } 257 | json.dump(values, fp) 258 | 259 | 260 | def main(): 261 | try: 262 | sys.exit(_main(sys.argv)) 263 | except Exception as e: 264 | logger.exception(e) 265 | sys.exit(-1) 266 | finally: 267 | logging.shutdown() 268 | 269 | 270 | if __name__ == "__main__": 271 | main() 272 | -------------------------------------------------------------------------------- /kicad2vrml.7z: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diorcety/KiBlenderCad/a86e6cdcf48caace22f6454818b32c7162e93670/kicad2vrml.7z -------------------------------------------------------------------------------- /raw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diorcety/KiBlenderCad/a86e6cdcf48caace22f6454818b32c7162e93670/raw.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from cx_Freeze import setup, Executable 2 | 3 | setup( 4 | executables = [Executable("generator.py"), Executable("inkscape.py")] 5 | ) --------------------------------------------------------------------------------