KiBot/kibot/out_pcb2blender_tools.py

315 lines
14 KiB
Python

# -*- 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)
# Some code is adapted from: https://github.com/30350n/pcb2blender
from dataclasses import dataclass, field
import json
import os
import re
import struct
from typing import List
from pcbnew import B_Paste, F_Paste, PCB_TEXT_T, ToMM
from .gs import GS
from .misc import (MOD_THROUGH_HOLE, MOD_SMD, UI_VIRTUAL, W_UNKPCB3DTXT, W_NOPCB3DBR, W_NOPCB3DTL, W_BADPCB3DTXT,
W_UNKPCB3DNAME, W_BADPCB3DSTK)
from .optionable import Optionable
from .out_base import VariantOptions
from .macros import macros, document, output_class # noqa: F401
from . import log
logger = log.get_logger()
@dataclass
class StackedBoard:
""" Name and position of a stacked board """
name: str
offset: List[float]
@dataclass
class BoardDef:
""" A sub-PCBs, its bounds and stacked boards """
name: str
bounds: List[float]
stacked_boards: List[StackedBoard] = field(default_factory=list)
def sanitized(name):
""" Replace character that aren't alphabetic by _ """
return re.sub(r"[\W]+", "_", name)
class PCB2Blender_ToolsOptions(VariantOptions):
def __init__(self):
with document:
self.output = GS.def_global_output
""" *Filename for the output (%i=pcb2blender, %x=pcb3d) """
self.board_bounds_create = True
""" Create the file that informs the size of the used PCB area.
This is the bounding box reported by KiCad for the PCB edge with 1 mm of margin """
self.board_bounds_dir = 'layers'
""" Sub-directory where the bounds file is stored """
self.board_bounds_file = 'bounds'
""" Name of the bounds file """
self.pads_info_create = True
""" Create the files containing the PCB pads information """
self.pads_info_dir = 'pads'
""" Sub-directory where the pads info files are stored """
self.stackup_create = False
""" Create a JSON file containing the board stackup """
self.stackup_file = 'board.yaml'
""" Name for the stackup file """
self.stackup_dir = '.'
""" Directory for the stackup file """
self.sub_boards_create = True
""" Extract sub-PCBs and their Z axis position """
self.sub_boards_dir = 'boards'
""" Directory for the boards definitions """
self.sub_boards_bounds_file = 'bounds'
""" File name for the sub-PCBs bounds """
self.sub_boards_stacked_prefix = 'stacked_'
""" Prefix used for the stack files """
self.show_components = Optionable
""" *[list(string)|string=all] [none,all] List of components to include in the pads list,
can be also a string for `none` or `all`. The default is `all` """
super().__init__()
self._expand_id = 'pcb2blender'
self._expand_ext = 'pcb3d'
def config(self, parent):
super().config(parent)
# List of components
self._show_all_components = False
if isinstance(self.show_components, str):
if self.show_components == 'all':
self._show_all_components = True
self.show_components = []
elif isinstance(self.show_components, type):
# Default is all
self._show_all_components = True
else: # a list
self.show_components = self.solve_kf_filters(self.show_components)
def do_board_bounds(self, dir_name):
if not self.board_bounds_create:
return
dir_name = os.path.join(dir_name, self.board_bounds_dir)
os.makedirs(dir_name, exist_ok=True)
fname = os.path.join(dir_name, self.board_bounds_file)
# PCB bounding box using the PCB edge, converted to mm
bounds = tuple(map(GS.to_mm, GS.get_rect_for(GS.board.ComputeBoundingBox(aBoardEdgesOnly=True))))
# Apply 1 mm margin (x, y, w, h)
bounds = (bounds[0]-1, bounds[1]-1, bounds[2]+2, bounds[3]+2)
with open(fname, 'wb') as f:
# Four big endian float numbers
f.write(struct.pack("!ffff", *bounds))
@staticmethod
def is_not_virtual_ki6(m):
return bool(m.GetAttributes() & (MOD_THROUGH_HOLE | MOD_SMD))
@staticmethod
def is_not_virtual_ki5(m):
return bool(m.GetAttributes() != UI_VIRTUAL)
def do_pads_info(self, dir_name):
if not self.pads_info_create:
return
dir_name = os.path.join(dir_name, self.pads_info_dir)
os.makedirs(dir_name, exist_ok=True)
is_not_virtual = self.is_not_virtual_ki5 if GS.ki5 else self.is_not_virtual_ki6
for i, footprint in enumerate(GS.get_modules()):
has_model = len(footprint.Models()) > 0
is_tht_or_smd = is_not_virtual(footprint)
value = footprint.GetValue()
value = value.replace('/', '_')
reference = footprint.GetReference()
for j, pad in enumerate(footprint.Pads()):
name = os.path.join(dir_name, sanitized("{}_{}_{}_{}".format(value, reference, i, j)))
is_flipped = pad.IsFlipped()
has_paste = pad.IsOnLayer(B_Paste if is_flipped else F_Paste)
with open(name, 'wb') as f:
f.write(struct.pack("!ff????BBffffBff",
*map(GS.to_mm, pad.GetPosition()),
is_flipped,
has_model,
is_tht_or_smd,
has_paste,
pad.GetAttribute(),
pad.GetShape(),
*map(GS.to_mm, pad.GetSize()),
GS.get_pad_orientation_in_radians(pad),
pad.GetRoundRectRadiusRatio(),
pad.GetDrillShape(),
*map(GS.to_mm, pad.GetDrillSize())))
def do_stackup(self, dir_name):
if not self.stackup_create or (not GS.global_pcb_finish and not GS.stackup):
return
dir_name = os.path.join(dir_name, self.stackup_dir)
os.makedirs(dir_name, exist_ok=True)
fname = os.path.join(dir_name, self.stackup_file)
# Create the board_info
board_info = {}
if GS.global_pcb_finish:
board_info['copper_finish'] = GS.global_pcb_finish
if GS.stackup:
layers_parsed = []
for la in GS.stackup:
parsed_layer = {'name': la.name, 'type': la.type}
if la.color is not None:
parsed_layer['color'] = la.color
if la.thickness is not None:
parsed_layer['thickness'] = la.thickness/1000
layers_parsed.append(parsed_layer)
board_info['stackup'] = layers_parsed
data = json.dumps(board_info, indent=3)
logger.debug('Stackup: '+str(data))
with open(fname, 'wt') as f:
f.write(data)
def get_boarddefs(self):
""" Extract the sub-PCBs and their positions using texts.
This is the original mechanism and the code is from the plug-in. """
boarddefs = {}
tls = {} # Top Left coordinates
brs = {} # Bottom right coordinates
stacks = {} # PCB stack relations
# Collect the information from the texts
for drawing in GS.board.GetDrawings():
if drawing.Type() != PCB_TEXT_T:
continue
text = drawing.GetText()
# Only process text starting with PCB3D_
if not text.startswith("PCB3D_"):
continue
# Store the position of the text according to the declared type
pos = tuple(map(ToMM, drawing.GetPosition()))
if text.startswith("PCB3D_TL_"):
tls.setdefault(text[9:], pos)
elif text.startswith("PCB3D_BR_"):
brs.setdefault(text[9:], pos)
elif text.startswith("PCB3D_STACK_"):
stacks.setdefault(text, pos)
else:
logger.warning(W_UNKPCB3DTXT+'Unknown PCB3D mark: `{}`'.format(text))
# Separate the PCBs
for name in tls.copy():
# Look for the Bottom Right corner
if name in brs:
# Remove both
tl_pos = tls.pop(name)
br_pos = brs.pop(name)
# Add a definition with the bbox (x, y, w, h)
boarddef = BoardDef(sanitized(name), (tl_pos[0], tl_pos[1], br_pos[0]-tl_pos[0], br_pos[1]-tl_pos[1]))
boarddefs[boarddef.name] = boarddef
else:
logger.warning(W_NOPCB3DBR+'PCB3D_TL_{} without corresponding PCB3D_BR_{}'.format(name, name))
for name in brs.keys():
logger.warning(W_NOPCB3DTL+'PCB3D_BR_{} without corresponding PCB3D_TL_{}'.format(name, name))
# Solve the stack (relative positions)
for stack_str in stacks.copy():
# Extract the parameters
try:
other, onto, target, z_offset = stack_str[12:].split("_")
z_offset = float(z_offset)
except ValueError:
onto = ''
if onto != "ONTO":
logger.warning(W_BADPCB3DTXT+'Malformed stack marker `{}` must be PCB3D_STACK_other_ONTO_target_zoffset'.
format(stack_str))
continue
# Check the names and sanity check
other_name = sanitized(other) # The name of the current board
if other_name not in boarddefs and other_name != 'FPNL':
logger.warning(W_UNKPCB3DNAME+'Unknown `{}` in `{}` valid names are: {}'.
format(other_name, stack_str, list(boarddefs)))
continue
target_name = sanitized(target) # The name of the board below
if target_name not in boarddefs:
logger.warning(W_UNKPCB3DNAME+'Unknown `{}` in `{}` valid names are: {}'.
format(target_name, stack_str, list(boarddefs)))
continue
if target_name == other_name:
logger.warning(W_BADPCB3DSTK+"Can't stack a board onto itself ({})".format(stack_str))
continue
# Add this board to the target
stack_pos = stacks.pop(stack_str)
target_pos = boarddefs[target_name].bounds[:2]
stacked = StackedBoard(other_name, (stack_pos[0]-target_pos[0], stack_pos[1]-target_pos[1], z_offset))
boarddefs[target_name].stacked_boards.append(stacked)
return boarddefs
def do_sub_boards(self, dir_name):
if not self.sub_boards_create:
return
dir_name = os.path.join(dir_name, self.sub_boards_dir)
os.makedirs(dir_name, exist_ok=True)
boarddefs = self.get_boarddefs()
logger.debug('Collected board definitions: '+str(boarddefs))
for boarddef in boarddefs.values():
subdir = os.path.join(dir_name, boarddef.name)
os.makedirs(subdir, exist_ok=True)
with open(os.path.join(subdir, self.sub_boards_bounds_file), 'wb') as f:
f.write(struct.pack("!ffff", *boarddef.bounds))
for stacked in boarddef.stacked_boards:
with open(os.path.join(subdir, self.sub_boards_stacked_prefix+stacked.name), 'wb') as f:
f.write(struct.pack("!fff", *stacked.offset))
def run(self, output):
super().run(output)
dir_name = os.path.dirname(output)
self.apply_show_components()
self.filter_pcb_components(do_3D=True)
self.do_board_bounds(dir_name)
self.do_pads_info(dir_name)
self.do_stackup(dir_name)
self.do_sub_boards(dir_name)
self.unfilter_pcb_components(do_3D=True)
self.undo_show_components()
def get_targets(self, out_dir):
files = []
if self.board_bounds_create:
files.append(os.path.join(out_dir, self.board_bounds_dir, self.board_bounds_file))
if self.pads_info_create:
dir_name = os.path.join(out_dir, self.pads_info_dir)
for i, footprint in enumerate(GS.get_modules()):
value = footprint.GetValue()
reference = footprint.GetReference()
for j in range(len(footprint.Pads())):
files.append(os.path.join(dir_name, sanitized("{}_{}_{}_{}".format(value, reference, i, j))))
if self.stackup_create and (GS.global_pcb_finish or GS.stackup):
files.append(os.path.join(out_dir, self.stackup_dir, self.stackup_file))
if self.sub_boards_create:
dir_name = os.path.join(out_dir, self.sub_boards_dir)
boarddefs = self.get_boarddefs()
for boarddef in boarddefs.values():
subdir = os.path.join(dir_name, boarddef.name)
files.append(os.path.join(subdir, self.sub_boards_bounds_file))
for stacked in boarddef.stacked_boards:
files.append(os.path.join(subdir, self.sub_boards_stacked_prefix+stacked.name))
else:
files.append(dir_name)
return files
@output_class
class PCB2Blender_Tools(BaseOutput): # noqa: F821
""" PCB2Blender Tools
A bunch of tools used to generate PCB3D files used to export PCBs to Blender.
Blender is the most important free software 3D render package.
The PCB3D file format is used by the PCB2Blender project (https://github.com/30350n/pcb2blender)
to import KiCad PCBs in Blender.
You need to install a Blender plug-in to load PCB3D files.
The tools in this output are used by internal templates used to generate PCB3D files. """
def __init__(self):
super().__init__()
self._category = 'PCB/3D/Auxiliar'
with document:
self.options = PCB2Blender_ToolsOptions
""" *[dict] Options for the `pcb2blender_tools` output """