Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 10 additions & 22 deletions edg/abstract_parts/SelectorArea.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,8 @@
from typing import Optional

from pydantic import RootModel
import os

from ..electronics_model import *
from .PartsTable import PartsTableRow
from .PartsTablePart import PartsTableFootprintFilter, PartsTablePart


class FootprintJson(RootModel): # script relpath imports are weird so this is duplicated here
root: dict[str, float] # footprint name -> area


class FootprintAreaTable:
_table: Optional[FootprintJson] = None

@classmethod
def area_of(cls, footprint: str) -> float:
"""Returns the area of a footprint, returning infinity if unavailable"""
if cls._table is None:
with open(os.path.join(os.path.dirname(__file__), "resources", "kicad_footprints.json"), 'r') as f:
cls._table = FootprintJson.model_validate_json(f.read())
return cls._table.root.get(footprint) or float('inf')


@abstract_block
class SelectorArea(PartsTablePart):
"""A base mixin that defines a footprint_area range specification for blocks that automatically select parts.
Expand All @@ -44,6 +23,10 @@ def __init__(self, *args, footprint_area: RangeLike = RangeExpr.ALL, **kwargs):
super().__init__(*args, **kwargs)
self.footprint_area = self.ArgParameter(footprint_area)

@classmethod
def _footprint_area(cls, footprint_name: str) -> float:
return FootprintDataTable.area_of(footprint_name)


@non_library
class PartsTableAreaSelector(PartsTableFootprintFilter, SelectorArea):
Expand All @@ -54,4 +37,9 @@ def __init__(self, *args, **kwargs):

def _row_filter(self, row: PartsTableRow) -> bool:
return super()._row_filter(row) and \
(Range.exact(FootprintAreaTable.area_of(row[self.KICAD_FOOTPRINT])).fuzzy_in(self.get(self.footprint_area)))
(Range.exact(FootprintDataTable.area_of(row[self.KICAD_FOOTPRINT])).fuzzy_in(self.get(self.footprint_area)))

@classmethod
def _row_area(cls, row: PartsTableRow) -> float:
"""Returns the area of the part in the row, for use in sorting."""
return FootprintDataTable.area_of(row[cls.KICAD_FOOTPRINT])
2 changes: 1 addition & 1 deletion edg/abstract_parts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from .Categories import MultipackDevice

from .ESeriesUtil import ESeriesUtil
from .SelectorArea import SelectorArea, PartsTableAreaSelector, FootprintAreaTable
from .SelectorArea import SelectorArea, PartsTableAreaSelector

from .AbstractDevices import Battery
from .AbstractConnector import BananaJack, BananaSafetyJack, RfConnector, RfConnectorTestPoint, RfConnectorAntenna,\
Expand Down
12,540 changes: 0 additions & 12,540 deletions edg/abstract_parts/resources/kicad_footprints.json

This file was deleted.

42 changes: 42 additions & 0 deletions edg/electronics_model/KicadFootprintData.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from typing import Optional, List, Tuple

from pydantic import RootModel, BaseModel
import os


class FootprintData(BaseModel):
area: float
bbox: Tuple[float, float, float, float] # [x_min, y_min, x_max, y_max]


class FootprintJson(RootModel): # script relpath imports are weird so this is duplicated here
root: dict[str, FootprintData] # footprint name -> data


class FootprintDataTable:
_table: Optional[FootprintJson] = None

@classmethod
def _get_table(cls) -> FootprintJson:
if cls._table is None:
with open(os.path.join(os.path.dirname(__file__), "resources", "kicad_footprints.json"), 'r') as f:
cls._table = FootprintJson.model_validate_json(f.read())
return cls._table

@classmethod
def area_of(cls, footprint: str) -> float:
"""Returns the area of a footprint, returning infinity if unavailable"""
elt = cls._get_table().root.get(footprint)
if elt is None:
return float('inf')
else:
return elt.area

@classmethod
def bbox_of(cls, footprint: str) -> Optional[Tuple[float, float, float, float]]:
"""Returns the bounding box of a footprint, returning None if unavailable"""
elt = cls._get_table().root.get(footprint)
if elt is None:
return None
else:
return elt.bbox
193 changes: 167 additions & 26 deletions edg/electronics_model/SvgPcbBackend.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,116 @@
import importlib
import inspect
from typing import List, Tuple, NamedTuple, Dict
import math
from typing import List, Tuple, NamedTuple, Dict, Union, Set

from .. import edgir
from .KicadFootprintData import FootprintDataTable
from ..core import *
from .NetlistGenerator import NetlistTransform, NetBlock, Netlist
from .SvgPcbTemplateBlock import SvgPcbTemplateBlock


class PlacedBlock(NamedTuple):
"""A placement of a hierarchical block, including the coordinates of its immediate elements.
Elements are placed in local space, with (0, 0) as the origin and elements moved as a group.
Elements are indexed by name."""
elts: Dict[str, Tuple[Union['PlacedBlock', TransformUtil.Path], Tuple[float, float]]] # name -> elt, (x, y)
height: float
width: float


def arrange_netlist(netlist: Netlist) -> PlacedBlock:
FOOTPRINT_BORDER = 1 # mm
BLOCK_BORDER = 2 # mm

# create list of blocks by path
block_subblocks: Dict[Tuple[str, ...], Set[str]] = {}
block_footprints: Dict[Tuple[str, ...], List[NetBlock]] = {}

# for here, we only group one level deep
for block in netlist.blocks:
containing_path = block.full_path.blocks[0:min(len(block.full_path.blocks) - 1, 1)]
block_footprints.setdefault(containing_path, []).append(block)
for i in range(len(containing_path)):
block_subblocks.setdefault(tuple(containing_path[:i]), set()).add(containing_path[i])

def arrange_hierarchy(root: Tuple[str, ...]) -> PlacedBlock:
"""Recursively arranges the immediate components of a hierarchy, treating each element
as a bounding box rectangle, and trying to maintain some aspect ratio."""
# TODO don't count borders as part of a block's width / height
ASPECT_RATIO = 16 / 9

sub_placed: List[Tuple[str, float, float, Union[PlacedBlock, NetBlock]]] = [] # (name, width, height, PlacedBlock or footprint)
for subblock in block_subblocks.get(root, set()):
subplaced = arrange_hierarchy(root + (subblock,))
sub_placed.append((subblock, subplaced.width + BLOCK_BORDER, subplaced.height + BLOCK_BORDER, subplaced))

for footprint in block_footprints.get(root, []):
bbox = FootprintDataTable.bbox_of(footprint.footprint) or (1, 1, 1, 1)
width = bbox[2] - bbox[0] + FOOTPRINT_BORDER
height = bbox[3] - bbox[1] + FOOTPRINT_BORDER
# use refdes as key so it's globally unique, for when this is run with blocks grouped together
sub_placed.append((footprint.refdes, width, height, footprint))

total_area = sum(width * height for _, width, height, _ in sub_placed)
max_width = math.sqrt(total_area * ASPECT_RATIO)

x_max = 0.0
y_max = 0.0
# track the y limits and y position of the prior elements
x_stack: List[Tuple[float, float, float]] = [] # [(x pos of next, y pos, y limit)]
elts: Dict[str, Tuple[Union[PlacedBlock, TransformUtil.Path], Tuple[float, float]]] = {}
for name, width, height, entry in sorted(sub_placed, key=lambda x: -x[2]): # by height
if not x_stack: # only on first component
next_y = 0.0
else:
next_y = x_stack[-1][1] # y position of the next element

while True: # advance rows as needed
if not x_stack:
break
if x_stack[-1][0] + width > max_width: # out of X space, advance a row
_, _, next_y = x_stack.pop()
continue
if next_y + height > x_stack[-1][2]: # out of Y space, advance a row
_, _, next_y = x_stack.pop()
continue
break

if not x_stack:
next_x = 0.0
else:
next_x = x_stack[-1][0]

if isinstance(entry, PlacedBlock): # assumed (0, 0) at top left
elts[name] = (entry, (next_x, next_y))
elif isinstance(entry, NetBlock): # account for footprint origin, flipping y-axis
bbox = FootprintDataTable.bbox_of(entry.footprint) or (0, 0, 0, 0)
elts[name] = (entry.full_path, (next_x - bbox[0], next_y + bbox[3]))
x_stack.append((next_x + width, next_y, next_y + height))
x_max = max(x_max, next_x + width)
y_max = max(y_max, next_y + height)
return PlacedBlock(
elts=elts, width=x_max, height=y_max
)
return arrange_hierarchy(())


def flatten_packed_block(block: PlacedBlock) -> Dict[TransformUtil.Path, Tuple[float, float]]:
"""Flatten a packed_block to a dict of individual components."""
flattened: Dict[TransformUtil.Path, Tuple[float, float]] = {}
def walk_group(block: PlacedBlock, x_pos: float, y_pos: float) -> None:
for _, (elt, (elt_x, elt_y)) in block.elts.items():
if isinstance(elt, PlacedBlock):
walk_group(elt, x_pos + elt_x, y_pos + elt_y)
elif isinstance(elt, TransformUtil.Path):
flattened[elt] = (x_pos + elt_x, y_pos + elt_y)
else:
raise TypeError
walk_group(block, 0, 0)
return flattened


class SvgPcbGeneratedBlock(NamedTuple):
path: TransformUtil.Path
fn_name: str
Expand Down Expand Up @@ -45,26 +148,15 @@ def run(self) -> List[SvgPcbGeneratedBlock]:
return self._svgpcb_blocks


class SvgPcbCompilerResult(NamedTuple):
functions: list[str]
instantiations: list[str]


class SvgPcbBackend(BaseBackend):
def run(self, design: CompiledDesign, args: Dict[str, str] = {}) -> List[Tuple[edgir.LocalPath, str]]:
netlist = NetlistTransform(design).run()
result = self._generate(design, netlist)
if result.functions: # pack layout templates into a file
svgpcb_str = ""
svgpcb_str += "\n".join(result.functions)
svgpcb_str += "\n" + "\n".join(result.instantiations)
return [
(edgir.LocalPath(), svgpcb_str)
]
else:
return [] # no layout templates, ignore
return [
(edgir.LocalPath(), result)
]

def _generate(self, design: CompiledDesign, netlist: Netlist) -> SvgPcbCompilerResult:
def _generate(self, design: CompiledDesign, netlist: Netlist) -> str:
"""Generates SVBPCB fragments as a structured result"""
def block_matches_prefixes(block: NetBlock, prefixes: List[Tuple[str, ...]]):
for prefix in prefixes:
Expand All @@ -80,21 +172,70 @@ def filter_blocks_by_pathname(blocks: List[NetBlock], exclude_prefixes: List[Tup
svgpcb_block_prefixes = [block.path.to_tuple() for block in svgpcb_blocks]
netlist = NetlistTransform(design).run()
other_blocks = filter_blocks_by_pathname(netlist.blocks, svgpcb_block_prefixes)
arranged_blocks = arrange_netlist(netlist)
pos_dict = flatten_packed_block(arranged_blocks)

svgpcb_block_instantiations = [
f"const {SvgPcbTemplateBlock._svgpcb_pathname_to_svgpcb(block.path)} = {block.fn_name}(pt(0, 0))"
for block in svgpcb_blocks
]
other_block_instantiations = [
f"""\

# note, dimensions in inches
other_block_instantiations = []
for block in other_blocks:
x_pos, y_pos = pos_dict.get(block.full_path, (0, 0)) # in mm, need to convert to in below
block_code = f"""\
const {SvgPcbTemplateBlock._svgpcb_pathname_to_svgpcb(block.full_path)} = board.add({SvgPcbTemplateBlock._svgpcb_footprint_to_svgpcb(block.footprint)}, {{
translate: pt(0, 0), rotate: 0,
translate: pt({x_pos/25.4:.3f}, {y_pos/25.4:.3f}), rotate: 0,
id: '{SvgPcbTemplateBlock._svgpcb_pathname_to_svgpcb(block.full_path)}'
}})"""
for block in other_blocks
]

return SvgPcbCompilerResult(
[block.svgpcb_code for block in svgpcb_blocks],
svgpcb_block_instantiations + other_block_instantiations
)
other_block_instantiations.append(block_code)

NEWLINE = '\n'
full_code = f"""\
const board = new PCB();

{NEWLINE.join(svgpcb_block_instantiations + other_block_instantiations)}

const limit0 = pt(-{2/25.4}, -{2/25.4});
const limit1 = pt({arranged_blocks.width/25.4}, {arranged_blocks.height/25.4});
const xMin = Math.min(limit0[0], limit1[0]);
const xMax = Math.max(limit0[0], limit1[0]);
const yMin = Math.min(limit0[1], limit1[1]);
const yMax = Math.max(limit0[1], limit1[1]);

const filletRadius = 0.1;
const outline = path(
[(xMin+xMax/2), yMax],
["fillet", filletRadius, [xMax, yMax]],
["fillet", filletRadius, [xMax, yMin]],
["fillet", filletRadius, [xMin, yMin]],
["fillet", filletRadius, [xMin, yMax]],
[(xMin+xMax/2), yMax],
);
board.addShape("outline", outline);

renderPCB({{
pcb: board,
layerColors: {{
"F.Paste": "#000000ff",
"F.Mask": "#000000ff",
"B.Mask": "#000000ff",
"componentLabels": "#00e5e5e5",
"outline": "#002d00ff",
"padLabels": "#ffff99e5",
"B.Cu": "#ef4e4eff",
"F.Cu": "#ff8c00cc",
}},
limits: {{
x: [xMin, xMax],
y: [yMin, yMax]
}},
background: "#00000000",
mmPerUnit: 25.4
}})

{NEWLINE.join([block.svgpcb_code for block in svgpcb_blocks])}
"""

return full_code
1 change: 1 addition & 0 deletions edg/electronics_model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
# for power users to build custom blackbox handlers
from .KiCadSchematicParser import KiCadSymbol, KiCadLibSymbol
from .KiCadSchematicBlock import KiCadBlackbox, KiCadBlackboxBase
from .KicadFootprintData import FootprintDataTable

from .RefdesRefinementPass import RefdesRefinementPass
from .NetlistBackend import NetlistBackend
Expand Down
Loading