319 lines
14 KiB
Python
319 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, MISSING_TOOL)
|
|
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)
|
|
if GS.ki5:
|
|
logger.error("`pcb2blender_tools` needs KiCad 6+")
|
|
exit(MISSING_TOOL)
|
|
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.
|
|
This output needs KiCad 6 or newer.
|
|
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 """
|