Skip to content

Commit 9c510dc

Browse files
authored
Initial placement generation for svg-pcb backend (#413)
Changes to the experimental svg-pcb backend. Adds a better initial placement, instead of all components stacked, tries to pack components with a fixed aspect ratio, recursively by group bottom-up. Also now generates the entire svg-pcb code block instead of just snippets. Also adds svgpcb backend to unit test outputs. Changes the footprint data json to store area and bounding box. Moves the FootprintDataTable into electronics_model, where the svg-pcb backend and the footprint data generation is now.
1 parent 2ac5c97 commit 9c510dc

File tree

62 files changed

+134668
-13180
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+134668
-13180
lines changed

edg/abstract_parts/SelectorArea.py

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,8 @@
1-
from typing import Optional
2-
3-
from pydantic import RootModel
4-
import os
5-
61
from ..electronics_model import *
72
from .PartsTable import PartsTableRow
83
from .PartsTablePart import PartsTableFootprintFilter, PartsTablePart
94

105

11-
class FootprintJson(RootModel): # script relpath imports are weird so this is duplicated here
12-
root: dict[str, float] # footprint name -> area
13-
14-
15-
class FootprintAreaTable:
16-
_table: Optional[FootprintJson] = None
17-
18-
@classmethod
19-
def area_of(cls, footprint: str) -> float:
20-
"""Returns the area of a footprint, returning infinity if unavailable"""
21-
if cls._table is None:
22-
with open(os.path.join(os.path.dirname(__file__), "resources", "kicad_footprints.json"), 'r') as f:
23-
cls._table = FootprintJson.model_validate_json(f.read())
24-
return cls._table.root.get(footprint) or float('inf')
25-
26-
276
@abstract_block
287
class SelectorArea(PartsTablePart):
298
"""A base mixin that defines a footprint_area range specification for blocks that automatically select parts.
@@ -44,6 +23,10 @@ def __init__(self, *args, footprint_area: RangeLike = RangeExpr.ALL, **kwargs):
4423
super().__init__(*args, **kwargs)
4524
self.footprint_area = self.ArgParameter(footprint_area)
4625

26+
@classmethod
27+
def _footprint_area(cls, footprint_name: str) -> float:
28+
return FootprintDataTable.area_of(footprint_name)
29+
4730

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

5538
def _row_filter(self, row: PartsTableRow) -> bool:
5639
return super()._row_filter(row) and \
57-
(Range.exact(FootprintAreaTable.area_of(row[self.KICAD_FOOTPRINT])).fuzzy_in(self.get(self.footprint_area)))
40+
(Range.exact(FootprintDataTable.area_of(row[self.KICAD_FOOTPRINT])).fuzzy_in(self.get(self.footprint_area)))
41+
42+
@classmethod
43+
def _row_area(cls, row: PartsTableRow) -> float:
44+
"""Returns the area of the part in the row, for use in sorting."""
45+
return FootprintDataTable.area_of(row[cls.KICAD_FOOTPRINT])

edg/abstract_parts/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from .Categories import MultipackDevice
2323

2424
from .ESeriesUtil import ESeriesUtil
25-
from .SelectorArea import SelectorArea, PartsTableAreaSelector, FootprintAreaTable
25+
from .SelectorArea import SelectorArea, PartsTableAreaSelector
2626

2727
from .AbstractDevices import Battery
2828
from .AbstractConnector import BananaJack, BananaSafetyJack, RfConnector, RfConnectorTestPoint, RfConnectorAntenna,\

edg/abstract_parts/resources/kicad_footprints.json

Lines changed: 0 additions & 12540 deletions
This file was deleted.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from typing import Optional, List, Tuple
2+
3+
from pydantic import RootModel, BaseModel
4+
import os
5+
6+
7+
class FootprintData(BaseModel):
8+
area: float
9+
bbox: Tuple[float, float, float, float] # [x_min, y_min, x_max, y_max]
10+
11+
12+
class FootprintJson(RootModel): # script relpath imports are weird so this is duplicated here
13+
root: dict[str, FootprintData] # footprint name -> data
14+
15+
16+
class FootprintDataTable:
17+
_table: Optional[FootprintJson] = None
18+
19+
@classmethod
20+
def _get_table(cls) -> FootprintJson:
21+
if cls._table is None:
22+
with open(os.path.join(os.path.dirname(__file__), "resources", "kicad_footprints.json"), 'r') as f:
23+
cls._table = FootprintJson.model_validate_json(f.read())
24+
return cls._table
25+
26+
@classmethod
27+
def area_of(cls, footprint: str) -> float:
28+
"""Returns the area of a footprint, returning infinity if unavailable"""
29+
elt = cls._get_table().root.get(footprint)
30+
if elt is None:
31+
return float('inf')
32+
else:
33+
return elt.area
34+
35+
@classmethod
36+
def bbox_of(cls, footprint: str) -> Optional[Tuple[float, float, float, float]]:
37+
"""Returns the bounding box of a footprint, returning None if unavailable"""
38+
elt = cls._get_table().root.get(footprint)
39+
if elt is None:
40+
return None
41+
else:
42+
return elt.bbox

edg/electronics_model/SvgPcbBackend.py

Lines changed: 167 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,116 @@
11
import importlib
22
import inspect
3-
from typing import List, Tuple, NamedTuple, Dict
3+
import math
4+
from typing import List, Tuple, NamedTuple, Dict, Union, Set
45

56
from .. import edgir
7+
from .KicadFootprintData import FootprintDataTable
68
from ..core import *
79
from .NetlistGenerator import NetlistTransform, NetBlock, Netlist
810
from .SvgPcbTemplateBlock import SvgPcbTemplateBlock
911

1012

13+
class PlacedBlock(NamedTuple):
14+
"""A placement of a hierarchical block, including the coordinates of its immediate elements.
15+
Elements are placed in local space, with (0, 0) as the origin and elements moved as a group.
16+
Elements are indexed by name."""
17+
elts: Dict[str, Tuple[Union['PlacedBlock', TransformUtil.Path], Tuple[float, float]]] # name -> elt, (x, y)
18+
height: float
19+
width: float
20+
21+
22+
def arrange_netlist(netlist: Netlist) -> PlacedBlock:
23+
FOOTPRINT_BORDER = 1 # mm
24+
BLOCK_BORDER = 2 # mm
25+
26+
# create list of blocks by path
27+
block_subblocks: Dict[Tuple[str, ...], Set[str]] = {}
28+
block_footprints: Dict[Tuple[str, ...], List[NetBlock]] = {}
29+
30+
# for here, we only group one level deep
31+
for block in netlist.blocks:
32+
containing_path = block.full_path.blocks[0:min(len(block.full_path.blocks) - 1, 1)]
33+
block_footprints.setdefault(containing_path, []).append(block)
34+
for i in range(len(containing_path)):
35+
block_subblocks.setdefault(tuple(containing_path[:i]), set()).add(containing_path[i])
36+
37+
def arrange_hierarchy(root: Tuple[str, ...]) -> PlacedBlock:
38+
"""Recursively arranges the immediate components of a hierarchy, treating each element
39+
as a bounding box rectangle, and trying to maintain some aspect ratio."""
40+
# TODO don't count borders as part of a block's width / height
41+
ASPECT_RATIO = 16 / 9
42+
43+
sub_placed: List[Tuple[str, float, float, Union[PlacedBlock, NetBlock]]] = [] # (name, width, height, PlacedBlock or footprint)
44+
for subblock in block_subblocks.get(root, set()):
45+
subplaced = arrange_hierarchy(root + (subblock,))
46+
sub_placed.append((subblock, subplaced.width + BLOCK_BORDER, subplaced.height + BLOCK_BORDER, subplaced))
47+
48+
for footprint in block_footprints.get(root, []):
49+
bbox = FootprintDataTable.bbox_of(footprint.footprint) or (1, 1, 1, 1)
50+
width = bbox[2] - bbox[0] + FOOTPRINT_BORDER
51+
height = bbox[3] - bbox[1] + FOOTPRINT_BORDER
52+
# use refdes as key so it's globally unique, for when this is run with blocks grouped together
53+
sub_placed.append((footprint.refdes, width, height, footprint))
54+
55+
total_area = sum(width * height for _, width, height, _ in sub_placed)
56+
max_width = math.sqrt(total_area * ASPECT_RATIO)
57+
58+
x_max = 0.0
59+
y_max = 0.0
60+
# track the y limits and y position of the prior elements
61+
x_stack: List[Tuple[float, float, float]] = [] # [(x pos of next, y pos, y limit)]
62+
elts: Dict[str, Tuple[Union[PlacedBlock, TransformUtil.Path], Tuple[float, float]]] = {}
63+
for name, width, height, entry in sorted(sub_placed, key=lambda x: -x[2]): # by height
64+
if not x_stack: # only on first component
65+
next_y = 0.0
66+
else:
67+
next_y = x_stack[-1][1] # y position of the next element
68+
69+
while True: # advance rows as needed
70+
if not x_stack:
71+
break
72+
if x_stack[-1][0] + width > max_width: # out of X space, advance a row
73+
_, _, next_y = x_stack.pop()
74+
continue
75+
if next_y + height > x_stack[-1][2]: # out of Y space, advance a row
76+
_, _, next_y = x_stack.pop()
77+
continue
78+
break
79+
80+
if not x_stack:
81+
next_x = 0.0
82+
else:
83+
next_x = x_stack[-1][0]
84+
85+
if isinstance(entry, PlacedBlock): # assumed (0, 0) at top left
86+
elts[name] = (entry, (next_x, next_y))
87+
elif isinstance(entry, NetBlock): # account for footprint origin, flipping y-axis
88+
bbox = FootprintDataTable.bbox_of(entry.footprint) or (0, 0, 0, 0)
89+
elts[name] = (entry.full_path, (next_x - bbox[0], next_y + bbox[3]))
90+
x_stack.append((next_x + width, next_y, next_y + height))
91+
x_max = max(x_max, next_x + width)
92+
y_max = max(y_max, next_y + height)
93+
return PlacedBlock(
94+
elts=elts, width=x_max, height=y_max
95+
)
96+
return arrange_hierarchy(())
97+
98+
99+
def flatten_packed_block(block: PlacedBlock) -> Dict[TransformUtil.Path, Tuple[float, float]]:
100+
"""Flatten a packed_block to a dict of individual components."""
101+
flattened: Dict[TransformUtil.Path, Tuple[float, float]] = {}
102+
def walk_group(block: PlacedBlock, x_pos: float, y_pos: float) -> None:
103+
for _, (elt, (elt_x, elt_y)) in block.elts.items():
104+
if isinstance(elt, PlacedBlock):
105+
walk_group(elt, x_pos + elt_x, y_pos + elt_y)
106+
elif isinstance(elt, TransformUtil.Path):
107+
flattened[elt] = (x_pos + elt_x, y_pos + elt_y)
108+
else:
109+
raise TypeError
110+
walk_group(block, 0, 0)
111+
return flattened
112+
113+
11114
class SvgPcbGeneratedBlock(NamedTuple):
12115
path: TransformUtil.Path
13116
fn_name: str
@@ -45,26 +148,15 @@ def run(self) -> List[SvgPcbGeneratedBlock]:
45148
return self._svgpcb_blocks
46149

47150

48-
class SvgPcbCompilerResult(NamedTuple):
49-
functions: list[str]
50-
instantiations: list[str]
51-
52-
53151
class SvgPcbBackend(BaseBackend):
54152
def run(self, design: CompiledDesign, args: Dict[str, str] = {}) -> List[Tuple[edgir.LocalPath, str]]:
55153
netlist = NetlistTransform(design).run()
56154
result = self._generate(design, netlist)
57-
if result.functions: # pack layout templates into a file
58-
svgpcb_str = ""
59-
svgpcb_str += "\n".join(result.functions)
60-
svgpcb_str += "\n" + "\n".join(result.instantiations)
61-
return [
62-
(edgir.LocalPath(), svgpcb_str)
63-
]
64-
else:
65-
return [] # no layout templates, ignore
155+
return [
156+
(edgir.LocalPath(), result)
157+
]
66158

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

84178
svgpcb_block_instantiations = [
85179
f"const {SvgPcbTemplateBlock._svgpcb_pathname_to_svgpcb(block.path)} = {block.fn_name}(pt(0, 0))"
86180
for block in svgpcb_blocks
87181
]
88-
other_block_instantiations = [
89-
f"""\
182+
183+
# note, dimensions in inches
184+
other_block_instantiations = []
185+
for block in other_blocks:
186+
x_pos, y_pos = pos_dict.get(block.full_path, (0, 0)) # in mm, need to convert to in below
187+
block_code = f"""\
90188
const {SvgPcbTemplateBlock._svgpcb_pathname_to_svgpcb(block.full_path)} = board.add({SvgPcbTemplateBlock._svgpcb_footprint_to_svgpcb(block.footprint)}, {{
91-
translate: pt(0, 0), rotate: 0,
189+
translate: pt({x_pos/25.4:.3f}, {y_pos/25.4:.3f}), rotate: 0,
92190
id: '{SvgPcbTemplateBlock._svgpcb_pathname_to_svgpcb(block.full_path)}'
93191
}})"""
94-
for block in other_blocks
95-
]
96-
97-
return SvgPcbCompilerResult(
98-
[block.svgpcb_code for block in svgpcb_blocks],
99-
svgpcb_block_instantiations + other_block_instantiations
100-
)
192+
other_block_instantiations.append(block_code)
193+
194+
NEWLINE = '\n'
195+
full_code = f"""\
196+
const board = new PCB();
197+
198+
{NEWLINE.join(svgpcb_block_instantiations + other_block_instantiations)}
199+
200+
const limit0 = pt(-{2/25.4}, -{2/25.4});
201+
const limit1 = pt({arranged_blocks.width/25.4}, {arranged_blocks.height/25.4});
202+
const xMin = Math.min(limit0[0], limit1[0]);
203+
const xMax = Math.max(limit0[0], limit1[0]);
204+
const yMin = Math.min(limit0[1], limit1[1]);
205+
const yMax = Math.max(limit0[1], limit1[1]);
206+
207+
const filletRadius = 0.1;
208+
const outline = path(
209+
[(xMin+xMax/2), yMax],
210+
["fillet", filletRadius, [xMax, yMax]],
211+
["fillet", filletRadius, [xMax, yMin]],
212+
["fillet", filletRadius, [xMin, yMin]],
213+
["fillet", filletRadius, [xMin, yMax]],
214+
[(xMin+xMax/2), yMax],
215+
);
216+
board.addShape("outline", outline);
217+
218+
renderPCB({{
219+
pcb: board,
220+
layerColors: {{
221+
"F.Paste": "#000000ff",
222+
"F.Mask": "#000000ff",
223+
"B.Mask": "#000000ff",
224+
"componentLabels": "#00e5e5e5",
225+
"outline": "#002d00ff",
226+
"padLabels": "#ffff99e5",
227+
"B.Cu": "#ef4e4eff",
228+
"F.Cu": "#ff8c00cc",
229+
}},
230+
limits: {{
231+
x: [xMin, xMax],
232+
y: [yMin, yMax]
233+
}},
234+
background: "#00000000",
235+
mmPerUnit: 25.4
236+
}})
237+
238+
{NEWLINE.join([block.svgpcb_code for block in svgpcb_blocks])}
239+
"""
240+
241+
return full_code

edg/electronics_model/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
# for power users to build custom blackbox handlers
4242
from .KiCadSchematicParser import KiCadSymbol, KiCadLibSymbol
4343
from .KiCadSchematicBlock import KiCadBlackbox, KiCadBlackboxBase
44+
from .KicadFootprintData import FootprintDataTable
4445

4546
from .RefdesRefinementPass import RefdesRefinementPass
4647
from .NetlistBackend import NetlistBackend

0 commit comments

Comments
 (0)