Source code for jitxlib.symbols.box

"""
Box symbol module for JITX Standard Library

This module provides box symbol definitions for creating rectangular symbols
with configurable pin layouts and spacing.

Quick Start:
    >>> # Auto-generated symbol
    >>> class MyComponent(Component):
    ...     GND = Port()
    ...     VCC = Port()
    ...     IN = [Port() for _ in range(3)]
    ...     OUT = [Port() for _ in range(5)]
    ...     symbol = BoxSymbol()

    >>> # Custom layout
    >>> class MyComponent(Component):
    ...     GND = Port()
    ...     VCC = Port()
    ...     IN = [Port() for _ in range(3)]
    ...     OUT = [Port() for _ in range(5)]
    ...     symbol = BoxSymbol(
    ...         rows=[
    ...             Row(left=PinGroup(IN), right=PinGroup(OUT)),
    ...         ],
    ...         columns=[
    ...             Column(up=PinGroup(VCC), down=PinGroup(GND)),
    ...         ],
    ...     )
"""

from __future__ import annotations
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, replace
from enum import Enum
from functools import reduce
import math
import re

from jitx._structural import Container, Structurable, pathstring
from jitx.component import CurrentComponent
from jitx.shapes import Shape
from jitx.shapes.composites import rectangle
from jitx.shapes.primitive import Polyline
from jitx.symbol import Direction, Pin, SymbolMapping
from jitx.transform import Transform
from jitx.net import Port
from jitx.inspect import extract, visit

from .common import DEF_PAD_NAME_SIZE, DEF_PIN_LENGTH, DEF_PIN_NAME_SIZE
from .decorators import PinDecorator, DecoratorPlacement, draw, placement
from .label import LabelConfigurable, LabelledSymbol


[docs] class SpaceType(Enum): """Types of spaces in box symbol layout""" CORNER = "corner" # Corner margin space ROW_COL_SPACING = "row_col_spacing" # Space between rows or cols ROW_COL_SPACING_ALIGN = "row_col_spacing_align" # Space between rows or cols, aligned to the larger of the two GROUP_SPACING = "group_spacing" # Space between pin groups within a row or col PIN_SPACING = "pin_spacing" # Space between pins within a group PRE_PIN_SPACING = "pre_pin_spacing" # Space before pins enlarged by decorators
[docs] @dataclass class SpaceEntry: """Entry describing a space in the box symbol layout""" value: float type: SpaceType
# Box specific constants DEF_MIN_WIDTH = 2.0 DEF_MIN_HEIGHT = 2.0 DEF_PIN_SPACING = 2.0 DEF_CORNER_MARGIN = 2.0 DEF_GROUP_SPACING = 2.0 DEF_ROW_SPACING = 2.0 DEF_COLUMN_SPACING = 2.0
[docs] class PinGroup(Structurable): """A group of pins with optional positioning and margins. >>> # Group of input pins >>> IN = [Port() for _ in range(3)] >>> input_group = PinGroup(IN) """ pins: Sequence[Port] def __init__( self, pins: Iterable[Port] | Port, *args: Port, pre_margin: float | None = None, post_margin: float | None = None, ): """ Create a PinGroup with pins and optional margins. Args: pins: Individual Port or an iterable of Ports *args: Additional individual Port arguments pre_margin: Margin above this group for groups in a row, or to the left of this group for groups in a column (uses group_spacing if None) post_margin: Margin below this group for groups in a row, or to the right of this group for groups in a column (uses group_spacing if None) """ if not isinstance(pins, Iterable): pins = (pins,) else: pins = tuple(pins) self.pins = pins + args self.pre_margin = pre_margin self.post_margin = post_margin spread_pins = [] for pin in self.pins: if pin.is_single_pin(): spread_pins.append(pin) else: for port in extract(pin, Port): if port.is_single_pin(): spread_pins.append(port) self.pins = tuple(spread_pins) # Validation if not self.pins: raise ValueError("PinGroup must have at least one pin") if self.pre_margin and self.pre_margin < 0: raise ValueError("PinGroup pre_margin must be non-negative if provided") if self.post_margin and self.post_margin < 0: raise ValueError("PinGroup post_margin must be non-negative if provided")
[docs] class Row(Structurable): """A row in the box symbol grid, containing Left and Right pin groups. Rows define horizontal arrangements of pins. Pins on the left extend outward to the left, and pins on the right extend outward to the right. Pins are arranged in the order they are given, from top to bottom. >>> # Single row with inputs on left, outputs on right >>> row = Row( ... left=PinGroup(IN), ... right=PinGroup(OUT) ... ) """ left: Sequence[PinGroup] right: Sequence[PinGroup] top_margin: float | None bottom_margin: float | None def __init__( self, left: Iterable[PinGroup] | PinGroup = (), right: Iterable[PinGroup] | PinGroup = (), top_margin: float | None = None, bottom_margin: float | None = None, ): """ Create a Row with pin groups and optional margins. Args: left: Individual PinGroup or an iterable of PinGroups for the Left direction right: Individual PinGroup or an iterable of PinGroups for the Right direction top_margin: Margin above this row (uses row_spacing if None) bottom_margin: Margin below this row (uses row_spacing if None) """ if isinstance(left, PinGroup): left = (left,) else: left = tuple(left) if isinstance(right, PinGroup): right = (right,) else: right = tuple(right) self.left = left self.right = right self.top_margin = top_margin self.bottom_margin = bottom_margin # Validation if self.top_margin is not None and self.top_margin < 0: raise ValueError("Row top_margin must be non-negative if provided") if self.bottom_margin is not None and self.bottom_margin < 0: raise ValueError("Row bottom_margin must be non-negative if provided")
[docs] class Column(Structurable): """A column in the box symbol grid, containing Up and Down pin groups. Columns define vertical arrangements of pins. Pins on top extend upward, and pins on bottom extend downward. Pins are arranged in the order they are given, from left to right. >>> # Column with power pins on top, ground on bottom >>> col = Column( ... up=PinGroup(VCC, VREF), ... down=PinGroup(GND) ... ) """ up: Sequence[PinGroup] down: Sequence[PinGroup] left_margin: float | None right_margin: float | None def __init__( self, up: Iterable[PinGroup] | PinGroup = (), down: Iterable[PinGroup] | PinGroup = (), left_margin: float | None = None, right_margin: float | None = None, ): """ Create a Column with pin groups and optional margins. Args: up: Individual PinGroup or an iterable of PinGroups for the Up direction down: Individual PinGroup or an iterable of PinGroups for the Down direction left_margin: Margin to the left of this column (uses col_spacing if None) right_margin: Margin to the right of this column (uses col_spacing if None) """ if isinstance(up, PinGroup): up = (up,) else: up = tuple(up) if isinstance(down, PinGroup): down = (down,) else: down = tuple(down) self.up = up self.down = down self.left_margin = left_margin self.right_margin = right_margin # Validation if self.left_margin is not None and self.left_margin < 0: raise ValueError("Column left_margin must be non-negative if provided") if self.right_margin is not None and self.right_margin < 0: raise ValueError("Column right_margin must be non-negative if provided")
[docs] @dataclass class BoxConfig(LabelConfigurable): """Configuration for box symbols. Controls spacing, sizing, and text appearance for box-style schematic symbols. Can be set on a box symbol or globally via SymbolStyleContext. >>> # Custom configuration for a compact symbol >>> config = BoxConfig( ... pin_spacing=1.0, ... corner_margin=1.0, ... group_spacing=1.0, ... ) >>> symbol = BoxSymbol(rows=rows, columns=columns, config=config) """ min_width: float = DEF_MIN_WIDTH """Minimum width of the box, required to be at least 2""" min_height: float = DEF_MIN_HEIGHT """Minimum height of the box, required to be at least 2""" pin_spacing: float = DEF_PIN_SPACING """Spacing between pins within the same group, required to be at least 1""" corner_margin: float = DEF_CORNER_MARGIN """Margin from box corners to first/last pins, required to be at least 1""" group_spacing: float = DEF_GROUP_SPACING """Spacing between pin groups on the same side, required to be at least 1""" row_spacing: float = DEF_ROW_SPACING """Spacing between rows in grid layout""" col_spacing: float = DEF_COLUMN_SPACING """Spacing between columns in grid layout""" pin_length: int = DEF_PIN_LENGTH """Length of the pin""" pin_name_size: float | None = DEF_PIN_NAME_SIZE """Size of the pin name text""" pad_name_size: float | None = DEF_PAD_NAME_SIZE """Size of the pad name text""" def __post_init__(self): if self.min_width < 2: raise ValueError("Box symbol config min_width must be at least 2") if self.min_height < 2: raise ValueError("Box symbol config min_height must be at least 2") if self.pin_spacing < 1: raise ValueError("Box symbol config pin_spacing must be at least 1") if self.corner_margin < 1: raise ValueError("Box symbol config corner_margin must be at least 1") if self.group_spacing < 1: raise ValueError("Box symbol config group_spacing must be at least 1") if self.row_spacing < 0: raise ValueError("Box symbol config row_spacing must be non-negative") if self.col_spacing < 0: raise ValueError("Box symbol config col_spacing must be non-negative") if self.pin_length < 0: raise ValueError("Box symbol config pin_length must be non-negative") if self.pin_name_size is not None and self.pin_name_size < 0: raise ValueError("Box symbol config pin_name_size must be non-negative") if self.pad_name_size is not None and self.pad_name_size < 0: raise ValueError("Box symbol config pad_name_size must be non-negative")
[docs] class SymbolBox(LabelledSymbol): """Box-shaped symbol""" _config: BoxConfig box: Shape p: tuple[Pin, ...] decorators: tuple[tuple[Shape, ...], ...] _debug_grid: tuple[Polyline, ...] def __init__(self, config: BoxConfig): self._config = config self.pin_name_size = config.pin_name_size self.pad_name_size = config.pad_name_size @property def config(self) -> BoxConfig: """Configuration object that provides label configuration""" return self._config @property def label_config(self) -> BoxConfig: return self.config
[docs] def set_pins(self, pins: Sequence[Pin]): # Pin name doesn't particularly matter here, because a component port will be assigned to this symbol pin. # The visualizer will display the port name, not the pin name. # `p` is chosen here as a base name. self.p = tuple(pins)
[docs] class BoxSymbol(Container): """Container for a box-shaped symbol with pins, artwork, labels, and a symbol mapping. BoxSymbol creates rectangular schematic symbols with pins on all four sides. Pins are organized into Rows of left and right pins and Columns of up and down pins. If no rows/columns are provided, the symbol auto-generates from component ports. Examples: >>> # Auto-generated symbol from component ports >>> class MyComponent(Component): ... IN = [Port() for _ in range(4)] ... OUT = [Port() for _ in range(4)] ... VCC = Port() ... GND = Port() ... ... def __init__(self): ... self.symbol = BoxSymbol() # Auto-arranges all ports >>> # Manual layout with rows and columns >>> class MyComponent(Component): ... IN = [Port() for _ in range(8)] ... OUT = [Port() for _ in range(8)] ... CLK = Port() ... RST = Port() ... VCC = Port() ... GND = Port() ... ... def __init__(self): ... self.symbol = BoxSymbol( ... rows=Row( ... left=PinGroup(self.IN), ... right=PinGroup(self.OUT) ... ), ... columns=Column( ... up=PinGroup([self.CLK, self.RST, self.VCC]), ... down=PinGroup(self.GND) ... ) ... ) >>> # Multiple rows for complex layouts >>> symbol = BoxSymbol( ... rows=[ ... Row(left=PinGroup(data_pins), right=PinGroup(status_pins)), ... Row(left=PinGroup(addr_pins), right=PinGroup(control_pins)) ... ], ... columns=Column(up=PinGroup(power_pins), down=PinGroup(gnd_pins)), ... config=BoxConfig(pin_spacing=1.0) ... ) """ config: BoxConfig width: float height: float symbol: SymbolBox mapping: SymbolMapping _rows: tuple[Row, ...] _columns: tuple[Column, ...] _left_row_spaces: tuple[SpaceEntry, ...] _right_row_spaces: tuple[SpaceEntry, ...] _up_col_spaces: tuple[SpaceEntry, ...] _down_col_spaces: tuple[SpaceEntry, ...] _x_offset: float _y_offset: float def __init__( self, rows: Row | Sequence[Row] = (), columns: Column | Sequence[Column] = (), config: BoxConfig | None = None, debug: bool = False, **kwargs, ): """ Create a box symbol with the new row/column API Args: rows: Row object or sequence of Row objects defining the grid rows columns: Column object or sequence of Column objects defining the grid columns config: Box configuration object (optional) **kwargs: Additional configuration parameters that override the config object """ from .context import SymbolStyleContext context = SymbolStyleContext.get() if config is None: if context is None: config = BoxConfig() else: config = context.box_config config = replace(config, **kwargs) # Create box configuration self.config = config # Convert single Row/Column objects to sequences if isinstance(rows, Row): rows = (rows,) if isinstance(columns, Column): columns = (columns,) # Auto-generate from component context if no rows/columns provided if not rows and not columns: try: rows, columns = self._auto_generate_from_component() except ValueError: # No component context available, keep empty rows/columns pass self._rows = tuple(rows or [Row()]) self._columns = tuple(columns or [Column()]) # Precalculate row and column spaces early for use in dimension calculation self._calculate_row_and_column_spaces() # Calculate box dimensions based on pin requirements to set width and height self._calculate_box_dimensions() # Initialize the symbol box self.symbol = SymbolBox(config) # Create the box shape, then adjust it to be on grid points when width and height are odd self._build_box_shape() # Build pins and symbol mapping self._build_pins() # Build labels self.symbol._build_labels(ref=Direction.Up, value=Direction.Down, margin=1) # Debug grid if debug: self._debug_grid() def _calculate_box_dimensions(self) -> None: """ Calculate box dimensions based on precalculated row and column spaces. Sets 'width' and 'height' attributes. """ # Use precalculated spaces to determine required dimensions # Width is determined by the largest column space (Up/Down directions) # Height is determined by the largest row space (Left/Right directions) # Set dimensions based on precalculated spaces required_width = sum(space.value for space in self._up_col_spaces) required_height = sum(space.value for space in self._left_row_spaces) self.width = max(self.config.min_width, required_width) self.height = max(self.config.min_height, required_height) def _build_box_shape(self) -> None: """ Calculate the box shape based on the precalculated box width and height. Sets 'box', '_x_offset', and '_y_offset' attributes. """ self.symbol.box = rectangle(self.width, self.height) # Track offset for grid lines self._x_offset = 0.0 self._y_offset = 0.0 if self.width % 2 != 0: self._x_offset = 0.5 self.symbol.box = Transform((self._x_offset, 0.0)) * self.symbol.box if self.height % 2 != 0: self._y_offset = 0.5 self.symbol.box = Transform((0.0, self._y_offset)) * self.symbol.box def _build_pins(self) -> None: """ Calculate pin positions based on box dimensions and row/column layout, and add pins to the symbol. """ def advance_space( current_pos: float, space_idx: int, spaces: Sequence[SpaceEntry] ) -> tuple[float, int]: """Helper to advance position and space index.""" return current_pos + spaces[space_idx].value, space_idx + 1 pin_positions = {} mappings = {} pins = [] pin_decorators: Sequence[tuple[Shape, ...]] = [] # Define direction configurations direction_configs = ( ( Direction.Left, [row.left for row in self._rows], self._left_row_spaces, ), ( Direction.Right, [row.right for row in self._rows], self._right_row_spaces, ), (Direction.Up, [col.up for col in self._columns], self._up_col_spaces), ( Direction.Down, [col.down for col in self._columns], self._down_col_spaces, ), ) # Process each direction for direction, many_pin_groups, spaces in direction_configs: positions = [] # Simply iterate through all spaces and calculate cumulative positions current_position = 0.0 space_idx = 0 def position_point(value: float, dir=direction) -> tuple[float, float]: if dir == Direction.Left: pt = (-self.width / 2, self.height / 2 - value) elif dir == Direction.Right: pt = (self.width / 2, self.height / 2 - value) elif dir == Direction.Up: pt = (value - self.width / 2, self.height / 2) else: pt = (value - self.width / 2, -self.height / 2) return math.ceil(pt[0]), math.ceil(pt[1]) for pin_groups in many_pin_groups: # Skip spaces until we get to the content for this container while space_idx < len(spaces) and spaces[space_idx].type in ( SpaceType.CORNER, SpaceType.ROW_COL_SPACING, SpaceType.ROW_COL_SPACING_ALIGN, ): current_position, space_idx = advance_space( current_position, space_idx, spaces ) # Process pin groups for this container for group in pin_groups: # Skip GROUP_SPACING before this group if ( space_idx < len(spaces) and spaces[space_idx].type == SpaceType.GROUP_SPACING ): current_position, space_idx = advance_space( current_position, space_idx, spaces ) # Skip PRE_PIN_SPACING before this group if ( space_idx < len(spaces) and spaces[space_idx].type == SpaceType.PRE_PIN_SPACING ): current_position, space_idx = advance_space( current_position, space_idx, spaces ) # Position pins in this group for pin in group.pins: at = position_point(current_position) positions.append((pin, at)) if decorator := PinDecorator.get(pin): pin_decorators.append(draw(decorator.spec, direction, at)) # Advance space to next pin position (if not at the last pin in this group) go_next = False while not go_next: current_position, space_idx = advance_space( current_position, space_idx, spaces ) if space_idx >= len(spaces): go_next = True elif spaces[space_idx].type != SpaceType.PRE_PIN_SPACING: go_next = True pin_positions[direction] = positions # Create pins for each direction (in order) for direction in [ Direction.Up, Direction.Right, Direction.Down, Direction.Left, ]: for port, pos in pin_positions[direction]: # Create pin with appropriate settings based on decorator placement decorator = PinDecorator.get(port) pin_name_size = ( 0 if ( decorator and placement(decorator.spec) == DecoratorPlacement.INSIDE ) else None ) pin = Pin( at=pos, direction=direction, length=self.config.pin_length, pin_name_size=pin_name_size, ) mappings[port] = pin pins.append(pin) self.symbol.set_pins(pins) self.symbol.decorators = tuple(pin_decorators) self.mapping = SymbolMapping(mappings) def _debug_grid(self): debug_grid = [] line_width = 0.05 thick_width = line_width * 4 diagonal_width = line_width * 0.5 margin = self.config.pin_length + 1 # Box boundaries x0, x1 = -self.width / 2 + self._x_offset, self.width / 2 + self._x_offset y0, y1 = -self.height / 2 + self._y_offset, self.height / 2 + self._y_offset content_spaces = ( SpaceType.PIN_SPACING, SpaceType.PRE_PIN_SPACING, SpaceType.GROUP_SPACING, SpaceType.ROW_COL_SPACING_ALIGN, ) def add_grid_lines(spaces: Sequence[SpaceEntry], is_horizontal=True): """Add grid lines for either rows (horizontal) or columns (vertical).""" space_tally = 0 prev_tally = 0 diagonal_spacing = 0.4 space_index = 0 while space_index < len(spaces): space = spaces[space_index] space_tally += space.value if space.type in content_spaces: if is_horizontal: y = y1 - prev_tally debug_grid.append( Polyline(line_width, [(x0 - margin, y), (x1 + margin, y)]) ) else: x = x0 + prev_tally debug_grid.append( Polyline(line_width, [(x, y0 - margin), (x, y1 + margin)]) ) # Content areas (PIN_SPACING and GROUP_SPACING) - thick lines + diagonal fill while space.type in content_spaces: if ( space_index < len(spaces) - 1 and spaces[space_index + 1].type in content_spaces ): space_tally += spaces[space_index + 1].value space_index += 1 else: break if is_horizontal: y_bottom, y_top = y1 - space_tally, y1 - prev_tally # Thick border lines for x in [x0 - margin, x1 + margin]: debug_grid.append( Polyline(thick_width, [(x, y_top), (x, y_bottom)]) ) # 45-degree diagonal fill (bottom-left to top-right for rows) width = (x1 + margin) - (x0 - margin) height = y_top - y_bottom # Draw diagonals from bottom edge x_pos = x0 - margin while x_pos < x1 + margin: # Calculate how far this diagonal can go max_length = min((x1 + margin) - x_pos, height) debug_grid.append( Polyline( diagonal_width, [ (x_pos, y_bottom), (x_pos + max_length, y_bottom + max_length), ], ) ) x_pos += diagonal_spacing # Draw diagonals from left edge to fill gaps # Start from just above the bottom edge y_pos = y_bottom + diagonal_spacing while y_pos < y_top: # Calculate how far this diagonal can go max_length = min(width, y_top - y_pos) debug_grid.append( Polyline( diagonal_width, [ (x0 - margin, y_pos), (x0 - margin + max_length, y_pos + max_length), ], ) ) y_pos += diagonal_spacing else: x_start, x_end = x0 + prev_tally, x0 + space_tally # Thick border lines for y in [y0 - margin, y1 + margin]: debug_grid.append( Polyline(thick_width, [(x_start, y), (x_end, y)]) ) # 45-degree diagonal fill (top-left to bottom-right for columns) width = x_end - x_start height = (y1 + margin) - (y0 - margin) # Draw diagonals from top edge x_pos = x_start while x_pos < x_end: # Calculate how far this diagonal can go max_length = min(x_end - x_pos, height) debug_grid.append( Polyline( diagonal_width, [ (x_pos, y1 + margin), (x_pos + max_length, y1 + margin - max_length), ], ) ) x_pos += diagonal_spacing # Draw diagonals from left edge to fill gaps y_pos = y1 + margin - diagonal_spacing while y_pos > y0 - margin: # Calculate how far this diagonal can go max_length = min(width, y_pos - (y0 - margin)) debug_grid.append( Polyline( diagonal_width, [ (x_start, y_pos), (x_start + max_length, y_pos - max_length), ], ) ) y_pos -= diagonal_spacing if is_horizontal: y = y1 - space_tally debug_grid.append( Polyline(line_width, [(x0 - margin, y), (x1 + margin, y)]) ) else: x = x0 + space_tally debug_grid.append( Polyline(line_width, [(x, y0 - margin), (x, y1 + margin)]) ) prev_tally = space_tally space_index += 1 add_grid_lines(self._left_row_spaces, is_horizontal=True) add_grid_lines(self._up_col_spaces, is_horizontal=False) self.symbol._debug_grid = tuple(debug_grid) def _calculate_row_and_column_spaces(self) -> None: """Precalculate the space allocated for each row (Left/Right directions) and column (Up/Down directions). Each row must be wide enough to accommodate the widest slot from both Left and Right directions for that row index. Each column must be wide enough to accommodate the widest slot from both Up and Down directions for that column index. Sets '_row_spaces' and '_col_spaces' attributes. """ # Calculate space needed for groups - returns sequence of SpaceEntry objects def pin_groups_space(pin_groups: Sequence[PinGroup]) -> Sequence[SpaceEntry]: if not pin_groups: return [] spaces = [] # Add pre margin for first group first_group = pin_groups[0] if first_group.pre_margin is not None: spaces.append( SpaceEntry(first_group.pre_margin, SpaceType.GROUP_SPACING) ) # Process each group for group_idx, group in enumerate(pin_groups): # Calculate pin spacing within the group, considering decorators # Create individual PIN_SPACING entries for each pin gap for pin_idx, pin in enumerate(group.pins): # Calculate required spacing based on decorators pin_space = self.config.pin_spacing # Check current pin's decorator pre_pin_space = False decorator = PinDecorator.get(pin) if decorator: decorator_shapes = decorator.shapes() bounds = reduce( lambda a, b: a.union(b), [s.to_shapely() for s in decorator_shapes], ).bounds decorator_size = bounds[3] - bounds[1] if len(group.pins) == 1 and decorator_size <= pin_space: pin_space = 0 elif pin_space < decorator_size: pin_space = decorator_size / 2 pre_pin_space = True # Create a PIN_SPACING entry for this specific pin gap if pre_pin_space: spaces.append( SpaceEntry(math.ceil(pin_space), SpaceType.PRE_PIN_SPACING) ) spaces.append( SpaceEntry( 0 if (pin_idx == len(group.pins) - 1 and not pre_pin_space) else math.ceil(pin_space), SpaceType.PIN_SPACING, ) ) # Add spacing between groups (except for last group) if group_idx < len(pin_groups) - 1: current_group = pin_groups[group_idx] next_group = pin_groups[group_idx + 1] post_margin = ( current_group.post_margin if current_group.post_margin is not None else 0 ) pre_margin = ( next_group.pre_margin if next_group.pre_margin is not None else 0 ) # If neither is defined, use group_spacing if ( current_group.post_margin is None and next_group.pre_margin is None ): spaces.append( SpaceEntry( self.config.group_spacing, SpaceType.GROUP_SPACING ) ) else: # Otherwise use the sum of the two margins spaces.append( SpaceEntry( post_margin + pre_margin, SpaceType.GROUP_SPACING ) ) # Add post margin for last group last_group = pin_groups[-1] if last_group.post_margin is not None: spaces.append( SpaceEntry(last_group.post_margin, SpaceType.GROUP_SPACING) ) return spaces # Values alternate between margin and group, always starting and ending with a margin. left_row_spaces: Sequence[SpaceEntry] = [] right_row_spaces: Sequence[SpaceEntry] = [] up_col_spaces: Sequence[SpaceEntry] = [] down_col_spaces: Sequence[SpaceEntry] = [] corner_margin = self.config.corner_margin # Account for port names when managing corner margins to avoid overlap. ports = self._component_ports() ports_dict = dict(ports) def pair_ports_with_names(all_groups_in_dir: Sequence[Sequence[PinGroup]]): """Pair ports in the given pin groups with their path strings.""" result = [] for groups in all_groups_in_dir: for group in groups: for port in group.pins: if port in ports_dict: result.append((port, ports_dict[port])) return result left_ports = pair_ports_with_names([row.left for row in self._rows]) right_ports = pair_ports_with_names([row.right for row in self._rows]) up_ports = pair_ports_with_names([col.up for col in self._columns]) down_ports = pair_ports_with_names([col.down for col in self._columns]) def max_name_length(port_list): """Get the maximum name length from a list of (port, name) tuples.""" return max((len(name) for _, name in port_list), default=0) longest_left_port_name = max_name_length(left_ports) longest_right_port_name = max_name_length(right_ports) longest_up_port_name = max_name_length(up_ports) longest_down_port_name = max_name_length(down_ports) coeff = 0.5 * max(self.config.pin_name_size or 0.5, 0.5) row_corner_margin = max( corner_margin, math.ceil(coeff * (longest_up_port_name + longest_down_port_name)), ) col_corner_margin = max( corner_margin, math.ceil(coeff * (longest_left_port_name + longest_right_port_name)), ) if self._rows: corner = SpaceEntry(row_corner_margin, SpaceType.CORNER) left_row_spaces.append(corner) right_row_spaces.append(corner) # Add top margin for first row if self._rows[0].top_margin is not None: top_margin = SpaceEntry( self._rows[0].top_margin, SpaceType.ROW_COL_SPACING ) left_row_spaces.append(top_margin) right_row_spaces.append(top_margin) # Iterate pairwise through rows for i in range(len(self._rows)): current_row = self._rows[i] # Pin group spacing calculations left_spaces = pin_groups_space(current_row.left) right_spaces = pin_groups_space(current_row.right) left_row_spaces.extend(left_spaces) right_row_spaces.extend(right_spaces) left_space = sum(space.value for space in left_spaces) right_space = sum(space.value for space in right_spaces) if left_space < right_space: left_row_spaces.append( SpaceEntry( right_space - left_space, SpaceType.ROW_COL_SPACING_ALIGN ) ) elif right_space < left_space: right_row_spaces.append( SpaceEntry( left_space - right_space, SpaceType.ROW_COL_SPACING_ALIGN ) ) if i < len(self._rows) - 1: next_row = self._rows[i + 1] # Row margin calculations top_margin = ( current_row.bottom_margin if current_row.bottom_margin is not None else 0 ) bottom_margin = ( next_row.top_margin if next_row.top_margin is not None else 0 ) # If neither row has margins defined, use row_spacing if ( current_row.bottom_margin is None and next_row.top_margin is None ): row_end = self.config.row_spacing else: # Otherwise use the sum of the two margins row_end = bottom_margin + top_margin row_end_entry = SpaceEntry(row_end, SpaceType.ROW_COL_SPACING) left_row_spaces.append(row_end_entry) right_row_spaces.append(row_end_entry) # Add bottom margin for last row last_row = self._rows[-1] if last_row.bottom_margin is not None: last_row_entry = SpaceEntry( last_row.bottom_margin, SpaceType.ROW_COL_SPACING ) left_row_spaces.append(last_row_entry) right_row_spaces.append(last_row_entry) left_row_spaces.append(corner) right_row_spaces.append(corner) if self._columns: corner = SpaceEntry(col_corner_margin, SpaceType.CORNER) up_col_spaces.append(corner) down_col_spaces.append(corner) # Add left margin for first column first_column = self._columns[0] if first_column.right_margin is not None: left_margin = SpaceEntry( first_column.right_margin, SpaceType.ROW_COL_SPACING ) up_col_spaces.append(left_margin) down_col_spaces.append(left_margin) # Iterate pairwise through columns for i in range(len(self._columns)): current_column = self._columns[i] # Pin group spacing calculations up_spaces = pin_groups_space(current_column.up) down_spaces = pin_groups_space(current_column.down) up_col_spaces.extend(up_spaces) down_col_spaces.extend(down_spaces) up_space = sum(space.value for space in up_spaces) down_space = sum(space.value for space in down_spaces) if up_space < down_space: up_col_spaces.append( SpaceEntry( down_space - up_space, SpaceType.ROW_COL_SPACING_ALIGN ) ) elif down_space < up_space: down_col_spaces.append( SpaceEntry( up_space - down_space, SpaceType.ROW_COL_SPACING_ALIGN ) ) if i < len(self._columns) - 1: next_column = self._columns[i + 1] # Column margin calculations left_margin = ( current_column.right_margin if current_column.right_margin is not None else 0 ) right_margin = ( next_column.left_margin if next_column.left_margin is not None else 0 ) # If neither column has margins defined, use col_spacing if ( current_column.right_margin is None and next_column.left_margin is None ): col_end = self.config.col_spacing else: # Otherwise use the sum of the two margins col_end = right_margin + left_margin col_end_entry = SpaceEntry(col_end, SpaceType.ROW_COL_SPACING) up_col_spaces.append(col_end_entry) down_col_spaces.append(col_end_entry) # Add right margin for last column last_column = self._columns[-1] if last_column.right_margin is not None: last_column_entry = SpaceEntry( last_column.right_margin, SpaceType.ROW_COL_SPACING ) up_col_spaces.append(last_column_entry) down_col_spaces.append(last_column_entry) up_col_spaces.append(corner) down_col_spaces.append(corner) self._left_row_spaces = tuple(left_row_spaces) self._right_row_spaces = tuple(right_row_spaces) self._up_col_spaces = tuple(up_col_spaces) self._down_col_spaces = tuple(down_col_spaces) def _component_ports(self) -> Sequence[tuple[Port, str]]: """ Get the ordered sequence of ports from the current component. """ context = CurrentComponent.get() if context is None: raise ValueError("No component context available for auto-generation") component = context.component ports: Sequence[tuple[Port, str]] = [] for trace, port in visit(component, Port): if port.is_single_pin(): ports.append((port, pathstring(trace.path))) return ports def _auto_generate_from_component(self) -> tuple[Sequence[Row], Sequence[Column]]: """ Auto-generate symbol layout from the current component context. Takes the ordered sequence of ports from the component and makes up to one cut to split them into Left and Right buckets while preserving their original order. Never cuts between ports with the same prefix. Returns: tuple of (rows, columns) for the symbol layout """ ports = self._component_ports() # Extract prefixes to identify groups rgx = r"^([A-Za-z_]+(?:\[[^\]]*\])*?)(?:\.[A-Za-z_]+)*?(?=\[[^\]]*\]$|$)" prefixes = [] for _, name in ports: match = re.match(rgx, name) prefix = match.group(1) if match else name prefixes.append(prefix) # Find valid cut points (where prefix changes) valid_cuts = [] for i in range(1, len(prefixes)): if prefixes[i] != prefixes[i - 1]: valid_cuts.append(i) # Calculate desired cut points for even distribution total_ports = len(ports) desired_cut = total_ports // 2 # Find nearest valid cuts def find_nearest_valid_cut(desired_cut: int) -> int: closest_cut = total_ports min_distance = float("inf") # Find the closest valid cut point for cut in valid_cuts: distance = abs(desired_cut - cut) if distance < min_distance: min_distance = distance closest_cut = cut return closest_cut cut = find_nearest_valid_cut(desired_cut) # Helper function to create PinGroups for each prefix section def create_pin_groups(ports: Sequence[tuple[Port, str]]) -> Sequence[PinGroup]: groups = [] current_group = [] current_prefix = None for port, name in ports: # Extract prefix from port name match = re.match(rgx, name) prefix = match.group(1) if match else name if current_prefix is None: current_prefix = prefix current_group = [port] elif prefix == current_prefix: current_group.append(port) else: # Prefix changed, create a group and start a new one groups.append(PinGroup(current_group)) current_prefix = prefix current_group = [port] # Don't forget the last group if current_group: groups.append(PinGroup(current_group)) return groups # Create PinGroups for each direction left_groups = create_pin_groups(ports[:cut]) right_groups = create_pin_groups(ports[cut:]) # Create rows and columns rows = [] columns = [] # Left/Right go in rows if left_groups or right_groups: rows.append(Row(left=left_groups, right=right_groups)) return rows, columns