410 lines
13 KiB
Python
410 lines
13 KiB
Python
import re
|
|
import struct
|
|
import tomllib
|
|
from dataclasses import dataclass, field, fields
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from types import GenericAlias
|
|
from typing import Any, Callable
|
|
from zipfile import Path as ZipPath, ZipFile
|
|
|
|
if "bpy" in locals():
|
|
from error_helper import warning
|
|
else:
|
|
warning = print
|
|
|
|
|
|
@dataclass
|
|
class TOMLSerializable:
|
|
@classmethod
|
|
def from_toml(cls, data: str):
|
|
toml = tomllib.loads(data)
|
|
values = {}
|
|
for f in fields(cls):
|
|
value = toml[f.name]
|
|
if isinstance(f.type, type) and issubclass(f.type, Enum):
|
|
if value in f.type._member_names_:
|
|
values[f.name] = f.type[value]
|
|
elif -1 in f.type._value2member_map_:
|
|
f.type._missing_(value)
|
|
values[f.name] = f.type(-1)
|
|
elif isinstance(f.type, (type, GenericAlias)):
|
|
values[f.name] = f.type(value) # pyright: ignore[reportCallIssue]
|
|
else:
|
|
values[f.name] = value
|
|
return cls(**values)
|
|
|
|
def to_toml(self):
|
|
data = ""
|
|
for f in fields(self):
|
|
value = getattr(self, f.name)
|
|
data += f"{f.name} = {self._toml_value(value)}\n"
|
|
return data
|
|
|
|
@classmethod
|
|
def _toml_value(cls, value: Any) -> str:
|
|
if isinstance(value, (tuple, list)):
|
|
return f"[ {', '.join(cls._toml_value(subvalue) for subvalue in value)} ]"
|
|
elif isinstance(value, Enum):
|
|
return f'"{value.name}"'
|
|
elif isinstance(value, str):
|
|
return f'"{value}"'
|
|
elif isinstance(value, bool):
|
|
return str(value).lower()
|
|
else:
|
|
return str(value)
|
|
|
|
|
|
class PadType(Enum):
|
|
UNKNOWN = -1
|
|
THT = 0
|
|
SMD = 1
|
|
CONN = 2
|
|
NPTH = 3
|
|
|
|
@classmethod
|
|
def _missing_(cls, value: Any):
|
|
warning(f"unknown pad type '{value}'")
|
|
return cls.UNKNOWN
|
|
|
|
|
|
class PadShape(Enum):
|
|
UNKNOWN = -1
|
|
CIRCLE = 0
|
|
RECT = 1
|
|
OVAL = 2
|
|
TRAPEZOID = 3
|
|
ROUNDRECT = 4
|
|
CHAMFERED_RECT = 5
|
|
CUSTOM = 6
|
|
|
|
@classmethod
|
|
def _missing_(cls, value: Any):
|
|
warning(f"unknown pad shape '{value}'")
|
|
return cls.UNKNOWN
|
|
|
|
|
|
class DrillShape(Enum):
|
|
UNKNOWN = -1
|
|
CIRCULAR = 0
|
|
OVAL = 1
|
|
|
|
@classmethod
|
|
def _missing_(cls, value: Any):
|
|
warning(f"unknown drill shape '{value}'")
|
|
return cls.UNKNOWN
|
|
|
|
|
|
class PadFabType(Enum):
|
|
NONE = 0
|
|
BGA = 1
|
|
FIDUCIAL = (2, 3)
|
|
TESTPOINT = 4
|
|
HEATSINK = 5
|
|
CASTELLATED = 6
|
|
MECHANICAL = 7
|
|
|
|
@classmethod
|
|
def _missing_(cls, value: Any):
|
|
warning(f"unknown pad fabrication attribute '{value}'")
|
|
return cls.NONE
|
|
|
|
|
|
@dataclass
|
|
class Pad(TOMLSerializable):
|
|
position: tuple[float, float]
|
|
is_flipped: bool
|
|
has_model: bool
|
|
is_tht_or_smd: bool
|
|
has_paste: bool
|
|
pad_type: PadType
|
|
shape: PadShape
|
|
size: tuple[float, float]
|
|
rotation: float
|
|
roundness: float
|
|
drill_shape: DrillShape
|
|
drill_size: tuple[float, float]
|
|
fab_type: PadFabType = PadFabType.NONE
|
|
|
|
FORMAT = "!ff????BBffffBff"
|
|
FORMAT_SIZE = struct.calcsize(FORMAT)
|
|
|
|
@classmethod
|
|
def from_bytes(cls, data: bytes):
|
|
if len(data) != cls.FORMAT_SIZE:
|
|
data = data[: cls.FORMAT_SIZE].ljust(cls.FORMAT_SIZE, b"\x00")
|
|
warning(f"unexpected pad data size '{len(data)}' (expected {cls.FORMAT_SIZE})")
|
|
|
|
unpacked = struct.unpack(cls.FORMAT, data)
|
|
return Pad(
|
|
(unpacked[0], -unpacked[1]),
|
|
unpacked[2],
|
|
unpacked[3],
|
|
unpacked[4],
|
|
unpacked[5],
|
|
PadType(unpacked[6]),
|
|
PadShape(unpacked[7]),
|
|
(unpacked[8], unpacked[9]),
|
|
unpacked[10],
|
|
unpacked[11],
|
|
DrillShape(unpacked[12]),
|
|
(unpacked[13], unpacked[14]),
|
|
)
|
|
|
|
|
|
class KiCadColor(Enum):
|
|
CUSTOM = 0
|
|
GREEN = 1
|
|
RED = 2
|
|
BLUE = 3
|
|
PURPLE = 4
|
|
BLACK = 5
|
|
WHITE = 6
|
|
YELLOW = 7
|
|
|
|
|
|
class SurfaceFinish(Enum):
|
|
HASL = 0
|
|
ENIG = 1
|
|
NONE = 2
|
|
|
|
|
|
@dataclass
|
|
class Stackup(TOMLSerializable):
|
|
thickness_mm: float = 1.6
|
|
mask_color: KiCadColor = KiCadColor.GREEN
|
|
mask_color_custom: tuple[float, ...] = (0.0, 0.0, 0.0)
|
|
silks_color: KiCadColor = KiCadColor.WHITE
|
|
silks_color_custom: tuple[float, ...] = (0.0, 0.0, 0.0)
|
|
surface_finish: SurfaceFinish = SurfaceFinish.HASL
|
|
|
|
OLD_FORMAT = "!fbBBBbBBBb"
|
|
OLD_FORMAT_SIZE = struct.calcsize(OLD_FORMAT)
|
|
|
|
@classmethod
|
|
def from_bytes(cls, data: bytes):
|
|
if len(data) != cls.OLD_FORMAT_SIZE:
|
|
data = data[: cls.OLD_FORMAT_SIZE].ljust(cls.OLD_FORMAT_SIZE, b"\x00")
|
|
warning(f"unexpected stackup data size '{len(data)}' (expected {cls.OLD_FORMAT_SIZE})")
|
|
|
|
unpacked = struct.unpack(cls.OLD_FORMAT, data)
|
|
return Stackup(
|
|
unpacked[0],
|
|
KiCadColor(unpacked[1]),
|
|
(unpacked[2] / 255, unpacked[3] / 255, unpacked[4] / 255),
|
|
KiCadColor(unpacked[5]),
|
|
(unpacked[6] / 255, unpacked[7] / 255, unpacked[8] / 255),
|
|
SurfaceFinish(unpacked[9]),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Bounds(TOMLSerializable):
|
|
top_left: tuple[float, float]
|
|
size: tuple[float, float]
|
|
|
|
OLD_FORMAT = "!ffff"
|
|
|
|
@classmethod
|
|
def from_bytes(cls, data: bytes):
|
|
unpacked = struct.unpack(cls.OLD_FORMAT, data)
|
|
return Bounds(unpacked[0:2], unpacked[2:4])
|
|
|
|
@property
|
|
def bottom_right(self):
|
|
return (self.top_left[0] + self.size[0], self.top_left[1] + self.size[1])
|
|
|
|
@property
|
|
def center(self):
|
|
return self.top_left[0] + self.size[0] * 0.5, self.top_left[1] + self.size[1] * 0.5
|
|
|
|
|
|
class StackedBoard(tuple[float, float, float]):
|
|
OLD_FORMAT = "!fff"
|
|
|
|
@classmethod
|
|
def from_toml(cls, data: str):
|
|
return StackedBoard(tomllib.loads(data)["offset"])
|
|
|
|
def to_toml(self):
|
|
return f"offset = [ {self[0]}, {self[1]}, {self[2]} ]"
|
|
|
|
@classmethod
|
|
def from_bytes(cls, data: bytes):
|
|
return StackedBoard(struct.unpack(cls.OLD_FORMAT, data))
|
|
|
|
|
|
@dataclass
|
|
class Board:
|
|
bounds: Bounds
|
|
stacked_boards: dict[str, StackedBoard] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class PCB3D:
|
|
layers_bounds: Bounds
|
|
stackup: Stackup
|
|
boards: dict[str, Board]
|
|
pads: dict[str, Pad]
|
|
content: str = ""
|
|
|
|
@classmethod
|
|
def from_file(
|
|
cls,
|
|
file: ZipFile,
|
|
extract_dir: Path,
|
|
on_error: Callable[[str], Any] = print,
|
|
on_warning: Callable[[str], None] = print,
|
|
):
|
|
members = {path.name for path in ZipPath(file).iterdir()}
|
|
if missing := cls.REQUIRED_MEMBERS.difference(members):
|
|
return on_error(f"not a valid .pcb3d file: missing {str(missing)[1:-1]}")
|
|
zip_path = ZipPath(file)
|
|
|
|
with file.open(cls.PCB) as pcb_file:
|
|
pcb_file_content = pcb_file.read().decode("utf-8")
|
|
with open(extract_dir / cls.PCB, "wb") as filtered_file:
|
|
filtered = cls.REGEX_FILTER_COMPONENTS.sub("\\g<prefix>", pcb_file_content)
|
|
filtered_file.write(filtered.encode("utf-8"))
|
|
|
|
components = {
|
|
name
|
|
for name in file.namelist()
|
|
if name.startswith(f"{cls.COMPONENTS}/") and name.endswith(".wrl")
|
|
}
|
|
|
|
file.extractall(extract_dir, components)
|
|
|
|
layers = (f"{cls.LAYERS}/{layer}.svg" for layer in cls.INCLUDED_LAYERS)
|
|
file.extractall(extract_dir, layers)
|
|
|
|
if (layers_bounds_path := zip_path / cls.LAYERS / cls.LAYERS_BOUNDS).exists():
|
|
layers_bounds = Bounds.from_toml(layers_bounds_path.read_text())
|
|
else:
|
|
old_layers_bounds_path = zip_path / cls.LAYERS / cls.LAYERS_BOUNDS[:-5]
|
|
layers_bounds = Bounds.from_bytes(old_layers_bounds_path.read_bytes())
|
|
|
|
if (layers_stackup_path := zip_path / cls.LAYERS / cls.LAYERS_STACKUP).exists():
|
|
stackup = Stackup.from_toml(layers_stackup_path.read_text())
|
|
elif (old_layers_stackup_path := zip_path / cls.LAYERS / cls.LAYERS_STACKUP[:-5]).exists():
|
|
stackup = Stackup.from_bytes(old_layers_stackup_path.read_bytes())
|
|
else:
|
|
stackup = Stackup()
|
|
on_warning("old file format: cls file doesn't contain stackup")
|
|
|
|
boards = {}
|
|
if not (boards_path := (zip_path / cls.BOARDS)).exists():
|
|
on_warning(f'old file format: cls file doesn\'t contain "{cls.BOARDS}" dir')
|
|
else:
|
|
for board_dir in boards_path.iterdir():
|
|
try:
|
|
if (bounds_path := board_dir / cls.BOUNDS).exists():
|
|
bounds = Bounds.from_toml(bounds_path.read_text())
|
|
elif (old_bounds_path := board_dir / cls.BOUNDS[:-5]).exists():
|
|
bounds = Bounds.from_bytes(old_bounds_path.read_bytes())
|
|
else:
|
|
continue
|
|
except (struct.error, tomllib.TOMLDecodeError, ValueError):
|
|
on_warning(f'ignoring board "{board_dir}" (corrupted)')
|
|
continue
|
|
|
|
stacked_boards: dict[str, StackedBoard] = {}
|
|
for path in board_dir.iterdir():
|
|
if not path.name.startswith(cls.STACKED):
|
|
continue
|
|
|
|
try:
|
|
if path.suffix == ".toml":
|
|
stacked_board = StackedBoard.from_toml(path.read_text())
|
|
elif path.suffix == "":
|
|
stacked_board = StackedBoard.from_bytes(path.read_bytes())
|
|
else:
|
|
continue
|
|
except (struct.error, tomllib.TOMLDecodeError, ValueError):
|
|
on_warning("ignoring stacked board (corrupted)")
|
|
continue
|
|
|
|
stacked_board_name = path.stem.split(cls.STACKED, 1)[-1]
|
|
stacked_boards[stacked_board_name] = stacked_board
|
|
|
|
boards[board_dir.name] = Board(bounds, stacked_boards)
|
|
|
|
pads = {}
|
|
if not (pads_path := (zip_path / cls.PADS)).exists():
|
|
on_warning(f'old file format: cls file doesn\'t contain "{cls.PADS}" dir')
|
|
else:
|
|
for path in pads_path.iterdir():
|
|
try:
|
|
if path.suffix == ".toml":
|
|
pads[path.stem] = Pad.from_toml(path.read_text())
|
|
elif path.suffix == "":
|
|
pads[path.stem] = Pad.from_bytes(path.read_bytes())
|
|
else:
|
|
continue
|
|
except struct.error:
|
|
on_warning("old file format: failed to parse pads")
|
|
break
|
|
|
|
return PCB3D(layers_bounds, stackup, boards, pads, pcb_file_content)
|
|
|
|
def write(self, file: ZipFile, wrl_file: Path, components_dir: Path, layers_dir: Path):
|
|
# always ensure the COMPONENTS, LAYERS and BOARDS directories are created
|
|
file.writestr(f"{self.COMPONENTS}/", "")
|
|
file.writestr(f"{self.LAYERS}/", "")
|
|
file.writestr(f"{self.BOARDS}/", "")
|
|
|
|
file.write(wrl_file, self.PCB)
|
|
for path in components_dir.glob("**/*.wrl"):
|
|
file.write(path, f"{self.COMPONENTS}/{path.name}")
|
|
|
|
for path in layers_dir.glob("**/*.svg"):
|
|
file.write(path, f"{self.LAYERS}/{path.name}")
|
|
file.writestr(f"{self.LAYERS}/{self.LAYERS_BOUNDS}", self.layers_bounds.to_toml())
|
|
file.writestr(f"{self.LAYERS}/{self.LAYERS_STACKUP}", self.stackup.to_toml())
|
|
|
|
for board_name, board in self.boards.items():
|
|
subdir = f"{self.BOARDS}/{board_name}"
|
|
file.writestr(f"{subdir}/{self.BOUNDS}", board.bounds.to_toml())
|
|
|
|
for stacked_name, stacked in board.stacked_boards.items():
|
|
file.writestr(f"{subdir}/{self.STACKED}{stacked_name}.toml", stacked.to_toml())
|
|
|
|
for pad_name, pad in self.pads.items():
|
|
file.writestr(f"{self.PADS}/{pad_name}.toml", pad.to_toml())
|
|
|
|
PCB = "pcb.wrl"
|
|
COMPONENTS = "components"
|
|
LAYERS = "layers"
|
|
LAYERS_BOUNDS = "bounds.toml"
|
|
LAYERS_STACKUP = "stackup.toml"
|
|
BOARDS = "boards"
|
|
BOUNDS = "bounds.toml"
|
|
STACKED = "stacked_"
|
|
PADS = "pads"
|
|
|
|
REQUIRED_MEMBERS = {PCB, LAYERS}
|
|
|
|
_INCLUDED_LAYERS = ["Cu", "Paste", "SilkS", "Mask"]
|
|
INCLUDED_LAYERS_FRONT = [f"F_{layer}" for layer in _INCLUDED_LAYERS]
|
|
INCLUDED_LAYERS_BACK = [f"B_{layer}" for layer in _INCLUDED_LAYERS]
|
|
INCLUDED_LAYERS = list(sum(zip(INCLUDED_LAYERS_FRONT, INCLUDED_LAYERS_BACK), ()))
|
|
|
|
REGEX_FILTER_COMPONENTS = re.compile(
|
|
r"(?P<prefix>Transform\s*{\s*"
|
|
r"(?:rotation (?P<r>[^\n]*)\n)?\s*"
|
|
r"(?:translation (?P<t>[^\n]*)\n)?\s*"
|
|
r"(?:scale (?P<s>[^\n]*)\n)?\s*"
|
|
r"children\s*\[\s*)"
|
|
r"(?P<instances>(?:Transform\s*{\s*"
|
|
r"(?:rotation [^\n]*\n)?\s*(?:translation [^\n]*\n)?\s*(?:scale [^\n]*\n)?\s*"
|
|
r"children\s*\[\s*Inline\s*{\s*url\s*\"[^\"]*\"\s*}\s*]\s*}\s*)+)"
|
|
)
|
|
|
|
REGEX_COMPONENT = re.compile(
|
|
r"Transform\s*{\s*"
|
|
r"(?:rotation (?P<r>[^\n]*)\n)?\s*"
|
|
r"(?:translation (?P<t>[^\n]*)\n)?\s*"
|
|
r"(?:scale (?P<s>[^\n]*)\n)?\s*"
|
|
r"children\s*\[\s*Inline\s*{\s*url\s*\"(?P<url>[^\"]*)\"\s*}\s*]\s*}\s*"
|
|
)
|