From 9d204aaeb1664482d169628b1b9415650c1a0c9f Mon Sep 17 00:00:00 2001 From: Doris619619 Date: Wed, 13 May 2026 20:36:34 +0800 Subject: [PATCH 01/16] =?UTF-8?q?fix(setup):=20=E4=BF=AE=E5=A4=8D=20Window?= =?UTF-8?q?s=20=E4=B8=8B=20editable=20=E5=AE=89=E8=A3=85=E7=9A=84=20UTF-8?= =?UTF-8?q?=20=E7=BC=96=E7=A0=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 59870898..c27900d2 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,8 @@ __email__ = "dave@vdb.name" if "sdist" in sys.argv[1:]: - with open("src/skidl/pckg_info.py", "w") as f: + # Windows 默认编码常为 GBK,显式 UTF-8 避免写入非 ASCII 时出错 + with open("src/skidl/pckg_info.py", "w", encoding="utf-8") as f: for name in ["__version__", "__author__", "__email__"]: f.write('{} = "{}"\n'.format(name, locals()[name])) @@ -20,10 +21,11 @@ from distutils.core import setup -with open("README.md") as readme_file: +# README/HISTORY 为 UTF-8;不设 encoding 时 Windows 会用系统编码读文件,pip 构建会报 UnicodeDecodeError +with open("README.md", encoding="utf-8") as readme_file: readme = readme_file.read() -with open("HISTORY.md") as history_file: +with open("HISTORY.md", encoding="utf-8") as history_file: history = history_file.read() requirements = [ From 683d48767833a37da7fe2ffd03e93d1e533e5ebb Mon Sep 17 00:00:00 2001 From: Doris619619 Date: Fri, 15 May 2026 08:21:28 +0800 Subject: [PATCH 02/16] =?UTF-8?q?feat(pnr):=20=E5=A2=9E=E5=8A=A0=20human?= =?UTF-8?q?=5Freadable=20=E5=B8=83=E5=B1=80=E4=B8=8E=E8=B5=B0=E7=BA=BF?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/skidl/schematics/place.py | 514 +++++++++++++++++++++++++++++----- src/skidl/schematics/route.py | 134 ++++++++- update.md | 131 +++++++++ 3 files changed, 696 insertions(+), 83 deletions(-) create mode 100644 update.md diff --git a/src/skidl/schematics/place.py b/src/skidl/schematics/place.py index 9502fe84..31b2dc24 100644 --- a/src/skidl/schematics/place.py +++ b/src/skidl/schematics/place.py @@ -1144,6 +1144,136 @@ def group_parts(node, **options): _ROW_PLACE_THRESHOLD = 20 + def _part_ref_key(node, part): + """Return a stable sort key for parts.""" + ref = str(getattr(part, "ref", "") or "") + name = str(getattr(part, "name", "") or "") + value = str(getattr(part, "value", "") or "") + return (ref.lower(), name.lower(), value.lower(), id(part)) + + def _net_names_of(node, part): + """Safely return set of connected net names for a part.""" + names = set() + for pin in getattr(part, "pins", []): + if not getattr(pin, "is_connected", lambda: False)(): + continue + net = getattr(pin, "net", None) + if net is None: + continue + name = getattr(net, "name", None) + if name: + names.add(str(name)) + return names + + def _is_power_net_name(node, name): + """Heuristic detection of power/ground net names.""" + if not name: + return False + text = str(name).upper() + power_tokens = ( + "VCC", + "VDD", + "VSS", + "GND", + "AGND", + "DGND", + "PGND", + "VBUS", + "VIN", + "VOUT", + "3V3", + "5V", + "12V", + "1V8", + "2V5", + "PWR", + ) + return any(token in text for token in power_tokens) + + def _classify_part_role(node, part): + """Classify part role with conservative heuristics.""" + ref = str(getattr(part, "ref", "") or "").upper() + name = str(getattr(part, "name", "") or "").upper() + value = str(getattr(part, "value", "") or "").upper() + net_names = node._net_names_of(part) + power_nets = [n for n in net_names if node._is_power_net_name(n)] + has_power = bool(power_nets) + has_gnd = any("GND" in n.upper() for n in net_names) + pin_count = len(getattr(part, "pins", [])) + + if ref.startswith(("PWR", "V", "GND")) or node._is_power_net_name(name) or node._is_power_net_name(value): + return "power" + + if ref.startswith("C"): + value_norm = value.replace(" ", "") + decap_tokens = ("100NF", "0.1UF", "0.1U", "1UF", "10NF", "47NF") + if (has_power and has_gnd) or any(token in value_norm for token in decap_tokens): + return "decoupling" + + if ref.startswith("U") or pin_count >= 8: + return "ic" + + if ref.startswith(("J", "P", "CN")): + return "connector" + + if ref.startswith(("R", "C", "L", "D", "Q")): + return "passive" + + return "other" + + def _find_main_part(node, parts, adjacency=None): + """Find a stable main part for a connected group.""" + if not parts: + return None + role_map = {part: node._classify_part_role(part) for part in parts} + ic_parts = [part for part in parts if role_map[part] == "ic"] + if ic_parts: + # 使用稳定排序打破并列,确保同样输入始终落在同一主控器件上。 + ranked = sorted(ic_parts, key=node._part_ref_key) + return max(ranked, key=lambda part: len(getattr(part, "pins", []))) + + def degree(part): + if adjacency is not None: + return len(adjacency.get(id(part), set())) + return len(node._net_names_of(part)) + + ranked = sorted(parts, key=node._part_ref_key) + return max(ranked, key=degree) + + def _place_row(node, parts, start_x, start_y, direction=1, gap=None): + """Place parts in one row and return row bbox.""" + if gap is None: + gap = BLK_INT_PAD + + x = start_x + min_x = float("inf") + min_y = float("inf") + max_x = float("-inf") + max_y = float("-inf") + max_h = 0 + for part in parts: + bbox = part.place_bbox + w = max(bbox.w, GRID) + h = max(bbox.h, GRID) + if direction >= 0: + part.tx = Tx().move(Point(x, start_y)) + x += w + gap + else: + part.tx = Tx().move(Point(x - w, start_y)) + x -= w + gap + + placed = part.place_bbox * part.tx + min_x = min(min_x, placed.min.x) + min_y = min(min_y, placed.min.y) + max_x = max(max_x, placed.max.x) + max_y = max(max_y, placed.max.y) + max_h = max(max_h, h) + + if min_x == float("inf"): + return BBox(Point(start_x, start_y), Point(start_x, start_y)) + + return BBox(Point(min_x, min_y), Point(max_x, max_y)) + def place_connected_parts_rowbased(node, parts, nets, **options): """Place connected parts using a BFS row-based layout (O(n)). @@ -1166,8 +1296,16 @@ def place_connected_parts_rowbased(node, parts, nets, **options): add_placement_bboxes(parts, **options) add_anchor_pull_pins(parts, nets, **options) + human_readable = options.get("human_readable", False) + + # Separate the NetTerminals from the other parts. + net_terminals = [p for p in parts if is_net_terminal(p)] + real_parts = [p for p in parts if not is_net_terminal(p)] + if not real_parts: + return + # Build adjacency graph: part → set of neighbors. - part_set = set(parts) + part_set = set(real_parts) adjacency = defaultdict(set) for net in nets: net_parts = [p for p in (pin.part for pin in net.pins) if p in part_set] @@ -1176,58 +1314,179 @@ def place_connected_parts_rowbased(node, parts, nets, **options): adjacency[id(p1)].add(p2) adjacency[id(p2)].add(p1) - # Pick seed: part with most connections. - id_to_part = {id(p): p for p in parts} - seed = max(parts, key=lambda p: len(adjacency.get(id(p), set()))) - - # BFS traversal, placing in rows. - visited = {id(seed)} - queue = deque([seed]) - order = [] - while queue: - part = queue.popleft() - order.append(part) - for neighbor in adjacency.get(id(part), set()): - if id(neighbor) not in visited: - visited.add(id(neighbor)) - queue.append(neighbor) - - # Add any parts not reached by BFS (disconnected within the group). - for part in parts: - if id(part) not in visited: + if human_readable: + # 用稳定可读布局替代随机/机械 BFS,减少多次运行时版图漂移。 + roles = {part: node._classify_part_role(part) for part in real_parts} + main_part = node._find_main_part(real_parts, adjacency=adjacency) + main_part.tx = Tx().move(Point(0, 0)) + main_bbox = main_part.place_bbox * main_part.tx + + def connected_to(part_a, part_b): + return part_b in adjacency.get(id(part_a), set()) + + def io_side_score(part): + names = [n.upper() for n in node._net_names_of(part)] + right_tokens = ("OUT", "TX", "MISO", "SCL", "CS", "PWM") + left_tokens = ("IN", "RX", "MOSI", "SDA", "ADC", "SENSE") + right = sum(any(token in n for token in right_tokens) for n in names) + left = sum(any(token in n for token in left_tokens) for n in names) + return right - left + + remaining = [p for p in real_parts if p is not main_part] + power_parts = [p for p in remaining if roles[p] == "power"] + decoupling_parts = [p for p in remaining if roles[p] == "decoupling"] + connector_parts = [p for p in remaining if roles[p] == "connector"] + passive_parts = [p for p in remaining if roles[p] == "passive"] + other_parts = [p for p in remaining if p not in (set(power_parts) | set(decoupling_parts) | set(connector_parts) | set(passive_parts))] + + power_parts = sorted(power_parts, key=node._part_ref_key) + connector_parts = sorted(connector_parts, key=node._part_ref_key) + passive_parts = sorted(passive_parts, key=node._part_ref_key) + other_parts = sorted(other_parts, key=node._part_ref_key) + + left_connectors = [] + right_connectors = [] + for part in connector_parts: + if io_side_score(part) > 0: + right_connectors.append(part) + else: + left_connectors.append(part) + + decoup_near_main = [] + decoup_power = [] + for part in sorted(decoupling_parts, key=node._part_ref_key): + if connected_to(part, main_part): + decoup_near_main.append(part) + else: + decoup_power.append(part) + + # 主器件放中心,其它器件按角色分区,优先形成人读图习惯的左右/上下结构。 + top_y = main_bbox.min.y - (BLK_INT_PAD + 2 * GRID) + left_x = main_bbox.min.x - (3 * BLK_INT_PAD) + right_x = main_bbox.max.x + (2 * BLK_INT_PAD) + bottom_y = main_bbox.max.y + (2 * BLK_INT_PAD) + + top_row = power_parts + decoup_power + if top_row: + node._place_row(top_row, left_x, top_y, direction=1, gap=BLK_INT_PAD) + + if decoup_near_main: + node._place_row( + decoup_near_main, + main_bbox.min.x, + main_bbox.min.y - BLK_INT_PAD, + direction=1, + gap=GRID, + ) + + if left_connectors: + y = main_bbox.min.y + for part in left_connectors: + bbox = part.place_bbox + part.tx = Tx().move(Point(left_x - bbox.w, y)) + y += max(bbox.h, GRID) + BLK_INT_PAD + + if right_connectors: + y = main_bbox.min.y + for part in right_connectors: + part.tx = Tx().move(Point(right_x, y)) + y += max(part.place_bbox.h, GRID) + BLK_INT_PAD + + passive_near = [] + passive_far = [] + for part in passive_parts: + if connected_to(part, main_part): + passive_near.append(part) + else: + passive_far.append(part) + + if passive_near: + node._place_row( + passive_near, + main_bbox.max.x + BLK_INT_PAD, + main_bbox.max.y + BLK_INT_PAD, + direction=1, + gap=BLK_INT_PAD, + ) + if passive_far: + node._place_row( + passive_far, + main_bbox.min.x - BLK_INT_PAD, + bottom_y, + direction=1, + gap=BLK_INT_PAD, + ) + + if other_parts: + node._place_row(other_parts, right_x, bottom_y, direction=1, gap=BLK_INT_PAD) + + # 在启发式分区摆放后做一次保守去重叠,避免局部角色区块互相压住。 + for part in sorted(real_parts, key=node._part_ref_key): + for _ in range(30): + bbox = part.place_bbox * part.tx + overlap = False + for other in real_parts: + if other is part: + continue + other_bbox = other.place_bbox * other.tx + if bbox.intersects(other_bbox): + part.tx *= Tx(dx=0, dy=BLK_INT_PAD) + overlap = True + break + if not overlap: + break + + for part in real_parts: + snap_to_grid(part) + else: + # Pick seed: part with most connections. + seed = max(real_parts, key=lambda p: len(adjacency.get(id(p), set()))) + + # BFS traversal, placing in rows. + visited = {id(seed)} + queue = deque([seed]) + order = [] + while queue: + part = queue.popleft() order.append(part) + for neighbor in adjacency.get(id(part), set()): + if id(neighbor) not in visited: + visited.add(id(neighbor)) + queue.append(neighbor) + + # Add any parts not reached by BFS (disconnected within the group). + for part in sorted(real_parts, key=node._part_ref_key): + if id(part) not in visited: + order.append(part) + + # Compute total area to determine max row width. + total_area = sum( + max(p.place_bbox.w, 1) * max(p.place_bbox.h, 1) for p in order + ) + max_row_width = math.sqrt(total_area) * 2 + + # Place parts in rows. + col_x = 0 + row_y = 0 + row_max_h = 0 + for part in order: + w = max(part.place_bbox.w, 100) + h = max(part.place_bbox.h, 100) + + if col_x > 0 and col_x + w > max_row_width: + # Start new row. + row_y += row_max_h + BLK_INT_PAD + col_x = 0 + row_max_h = 0 + + part.tx = Tx().move(Point(col_x, row_y)) + col_x += w + BLK_INT_PAD + row_max_h = max(row_max_h, h) + + # Snap to grid. + for part in order: + snap_to_grid(part) - # Compute total area to determine max row width. - total_area = sum( - max(p.place_bbox.w, 1) * max(p.place_bbox.h, 1) for p in order - ) - max_row_width = math.sqrt(total_area) * 2 - - # Place parts in rows. - col_x = 0 - row_y = 0 - row_max_h = 0 - for part in order: - w = max(part.place_bbox.w, 100) - h = max(part.place_bbox.h, 100) - - if col_x > 0 and col_x + w > max_row_width: - # Start new row. - row_y += row_max_h + BLK_INT_PAD - col_x = 0 - row_max_h = 0 - - part.tx = Tx().move(Point(col_x, row_y)) - col_x += w + BLK_INT_PAD - row_max_h = max(row_max_h, h) - - # Snap to grid. - for part in order: - snap_to_grid(part) - - # Place NetTerminals around the placed parts. - net_terminals = [p for p in parts if is_net_terminal(p)] - real_parts = [p for p in parts if not is_net_terminal(p)] if net_terminals: place_net_terminals( net_terminals, real_parts, nets, total_part_force, **options @@ -1309,10 +1568,61 @@ def place_floating_parts(node, parts, **options): # Abort if nothing to place. return + human_readable = options.get("human_readable", False) + # For large numbers of floating parts, skip the O(n^2) similarity # computation and force-directed evolution. Just grid-place them. # This avoids the 100+ second penalty for 60+ identical decoupling caps. _FLOAT_GRID_THRESHOLD = 20 + if human_readable: + add_placement_bboxes(parts) + role_buckets = defaultdict(list) + for part in parts: + role_buckets[node._classify_part_role(part)].append(part) + + role_order = ["power", "decoupling", "passive", "ic", "connector", "other"] + y = 0 + for role in role_order: + bucket = role_buckets.get(role, []) + if not bucket: + continue + + # 同类器件按 value/ref 稳定排序,避免每次生成顺序漂移。 + if role == "passive": + subtype = defaultdict(list) + for part in bucket: + ref = str(getattr(part, "ref", "") or "").upper() + prefix = ref[:1] + subtype[prefix].append(part) + sub_order = ["R", "C", "L", "D", "Q", ""] + for key in sub_order: + sub_parts = subtype.get(key, []) + if not sub_parts: + continue + sub_parts = sorted( + sub_parts, + key=lambda p: ( + str(getattr(p, "value", "") or "").lower(), + node._part_ref_key(p), + ), + ) + row_bbox = node._place_row(sub_parts, 0, y, direction=1, gap=BLK_INT_PAD) + y = row_bbox.max.y + BLK_INT_PAD + else: + bucket = sorted( + bucket, + key=lambda p: ( + str(getattr(p, "value", "") or "").lower(), + node._part_ref_key(p), + ), + ) + row_bbox = node._place_row(bucket, 0, y, direction=1, gap=BLK_INT_PAD) + y = row_bbox.max.y + BLK_INT_PAD + + for part in parts: + snap_to_grid(part) + return + if len(parts) > _FLOAT_GRID_THRESHOLD and options.get("auto_stub", False): add_placement_bboxes(parts) # Simple grid layout for floating parts. @@ -1500,16 +1810,54 @@ def __init__(self, src, bbox, anchor_pt, snap_pt, tag): # Abort if nothing to place. return + human_readable = options.get("human_readable", False) + # For large block counts, use a simple grid layout instead of # O(n²) force-directed placement. if len(part_blocks) > node._ROW_PLACE_THRESHOLD: - cols = max(1, int(len(part_blocks) ** 0.5)) - for i, blk in enumerate(part_blocks): - row, col = divmod(i, cols) - w = blk.place_bbox.w if blk.place_bbox.w > 0 else 500 - h = blk.place_bbox.h if blk.place_bbox.h > 0 else 500 - blk.tx = Tx().move(Point(col * (w + BLK_EXT_PAD), row * (h + BLK_EXT_PAD))) - snap_to_grid(blk) + if human_readable: + # 分区摆放块对象,让主连通块在中部、浮动块在下方、子层级块在右侧,增强阅读方向感。 + def blk_key(blk): + src = blk.src + ref = str(getattr(src, "ref", getattr(src, "name", "")) or "") + return (blk.tag, -(blk.place_bbox.w * blk.place_bbox.h), ref.lower()) + + connected_blks = sorted([b for b in part_blocks if b.tag == 1], key=blk_key) + floating_blks = sorted([b for b in part_blocks if b.tag == 2], key=blk_key) + child_blks = sorted([b for b in part_blocks if b.tag in (3, 4)], key=blk_key) + + y = 0 + if connected_blks: + row_bbox = node._place_row( + connected_blks, 0, y, direction=1, gap=BLK_EXT_PAD + ) + y = row_bbox.max.y + BLK_EXT_PAD + + if floating_blks: + row_bbox = node._place_row( + floating_blks, 0, y + BLK_EXT_PAD, direction=1, gap=BLK_EXT_PAD + ) + y = row_bbox.max.y + BLK_EXT_PAD + + if child_blks: + right_x = 0 + if connected_blks or floating_blks: + total_bbox = get_enclosing_bbox(connected_blks + floating_blks) + right_x = total_bbox.max.x + BLK_EXT_PAD + node._place_row( + child_blks, right_x, BLK_EXT_PAD, direction=1, gap=BLK_EXT_PAD + ) + + for blk in part_blocks: + snap_to_grid(blk) + else: + cols = max(1, int(len(part_blocks) ** 0.5)) + for i, blk in enumerate(part_blocks): + row, col = divmod(i, cols) + w = blk.place_bbox.w if blk.place_bbox.w > 0 else 500 + h = blk.place_bbox.h if blk.place_bbox.h > 0 else 500 + blk.tx = Tx().move(Point(col * (w + BLK_EXT_PAD), row * (h + BLK_EXT_PAD))) + snap_to_grid(blk) # Apply the placement moves of the part blocks to their underlying sources. for blk in part_blocks: @@ -1638,6 +1986,7 @@ def _auto_stub_large_groups(node, groups, internal_nets, **options): return max_group = options.get("auto_stub_max_group", 20) + human_readable = options.get("human_readable", False) for group in groups: if len(group) <= max_group: @@ -1657,11 +2006,16 @@ def _auto_stub_large_groups(node, groups, internal_nets, **options): id(p.part) for p in net.pins if id(p.part) in group_ids } if 2 <= len(net_parts) <= 4: - chain_nets.append((len(net_parts), net)) + name = str(getattr(net, "name", "") or "") + is_power = node._is_power_net_name(name) + chain_nets.append((len(net_parts), 0 if is_power else 1, name.lower(), net)) - # Sort by fanout (prefer stubbing 2-pin nets first). - chain_nets.sort(key=lambda x: x[0]) - chain_nets = [net for _, net in chain_nets] + if human_readable: + # 人类可读模式优先 stub 电源/跨域感强的连线,尽量保留近距离两点线条的连线感。 + chain_nets.sort(key=lambda x: (x[1], -x[0], x[2])) + else: + chain_nets.sort(key=lambda x: x[0]) + chain_nets = [net for _, _, _, net in chain_nets] if not chain_nets: continue @@ -1674,18 +2028,26 @@ def _auto_stub_large_groups(node, groups, internal_nets, **options): # Stub evenly-spaced chain nets to split the group into chunks # of ~max_group parts. We need ceil(len/max)-1 cuts minimum. n_cuts = max(1, (len(group) + max_group - 1) // max_group) - step = max(1, len(chain_nets) // (n_cuts + 1)) - if step < 1: - step = 1 stubbed = 0 - - for i in range(step, len(chain_nets), step): - net = chain_nets[i] - net._stub = True - net._stub_explicit = False - for p in net.get_pins(): - p.stub = True - stubbed += 1 + if human_readable: + # 保守地只打必要数量的 stub,避免“全图都是标签”导致可读性下降。 + for net in chain_nets[:n_cuts]: + net._stub = True + net._stub_explicit = False + for p in net.get_pins(): + p.stub = True + stubbed += 1 + else: + step = max(1, len(chain_nets) // (n_cuts + 1)) + if step < 1: + step = 1 + for i in range(step, len(chain_nets), step): + net = chain_nets[i] + net._stub = True + net._stub_explicit = False + for p in net.get_pins(): + p.stub = True + stubbed += 1 active_logger.info( f" [auto_stub_large_groups] Stubbed {stubbed} nets" @@ -1708,7 +2070,11 @@ def place(node, tool=None, **options): this_module = sys.modules[__name__] this_module.__dict__.update(tool_modules[tool].constants.__dict__) - random.seed(options.get("seed")) + seed = options.get("seed") + if options.get("human_readable", False) and seed is None: + # 人类可读模式默认固定随机种子,保证同一输入的输出稳定可回归。 + seed = 0 + random.seed(seed) # Store the starting attributes of the node's parts, pins, and nets. node.attrs = node.get_attrs() diff --git a/src/skidl/schematics/route.py b/src/skidl/schematics/route.py index b44cb7b2..8ae27977 100644 --- a/src/skidl/schematics/route.py +++ b/src/skidl/schematics/route.py @@ -1314,7 +1314,12 @@ def coalesce(self, switchboxes): break # Get the box which will be added if expansion occurs. # Every face borders two switchboxes, so the adjacent box is the other one. - adj_box = (box_face.switchboxes - {box}).pop() + # 人类可读布局等情况下邻接集偶发为空,pop 会 KeyError;保守放弃该方向的本次生长。 + adjacent = box_face.switchboxes - {box} + if not adjacent: + active_directions.remove(direction) + break + adj_box = min(adjacent, key=id) if adj_box not in switchboxes: # This box cannot be added, so expansion in this direction is blocked. active_directions.remove(direction) @@ -1322,10 +1327,20 @@ def coalesce(self, switchboxes): else: # All the switchboxes along the growth side are available for expansion, # so replace the current boxes in the growth side with these new ones. + adjacent_pairs = [] + ok_expand = True for i, box in enumerate(box_list[:]): # Get the adjacent box for the current box on the growth side. box_face = box.face_list[direction] - adj_box = (box_face.switchboxes - {box}).pop() + adjacent = box_face.switchboxes - {box} + if not adjacent: + ok_expand = False + break + adjacent_pairs.append((i, min(adjacent, key=id))) + if not ok_expand: + active_directions.remove(direction) + continue + for i, adj_box in adjacent_pairs: # Replace the current box with the new box from the expansion. box_list[i] = adj_box # Remove the newly added box from the list of available boxes for growth. @@ -2188,6 +2203,23 @@ def create_terminals(node, internal_nets, h_tracks, v_tracks): face.set_capacity() def global_router(node, nets): + human_readable = False + try: + human_readable = node._route_options.get("human_readable", False) + except AttributeError: + pass + + def stable_face_key(face): + """Create a deterministic key for selecting a face.""" + return ( + getattr(face.track, "orientation", 0), + getattr(face.track, "coord", 0), + getattr(face.beg, "coord", 0), + getattr(face.end, "coord", 0), + len(getattr(face, "pins", [])), + len(getattr(face, "terminals", [])), + ) + """Globally route a list of nets from face to face. Args: @@ -2336,8 +2368,12 @@ def rank_net(net): net_pin_faces = {pin.face for pin in node.get_internal_pins(net)} start_faces = set(net_pin_faces) - # Select a random start face and look for a route to *any* of the other start faces. - start_face = random.choice(list(start_faces)) + # 人类可读模式优先稳定起点,减少重复运行时路径抖动。 + if human_readable: + start_face = sorted(start_faces, key=stable_face_key)[0] + else: + # Select a random start face and look for a route to *any* of the other start faces. + start_face = random.choice(list(start_faces)) start_faces.discard(start_face) stop_faces = set(start_faces) initial_route = rt_srch(start_face, stop_faces) @@ -2441,6 +2477,12 @@ def switchbox_router(node, switchboxes, **options): def cleanup_wires(node): """Try to make wire segments look prettier.""" + human_readable = False + try: + human_readable = node._route_options.get("human_readable", False) + except AttributeError: + pass + def order_seg_points(segments): """Order endpoints in a horizontal or vertical segment.""" for seg in segments: @@ -2919,8 +2961,19 @@ def get_jogs(segments): # Send the jog that was found. yield list(jog_segs), list(start_stop_pts) - # Shuffle segments to vary the order of detected jogs. - random.shuffle(segments) + # 人类可读模式关闭洗牌,优先保证输出可重复与可比对。 + if human_readable: + segments.sort( + key=lambda seg: ( + min(seg.p1.x, seg.p2.x), + min(seg.p1.y, seg.p2.y), + max(seg.p1.x, seg.p2.x), + max(seg.p1.y, seg.p2.y), + ) + ) + else: + # Shuffle segments to vary the order of detected jogs. + random.shuffle(segments) # Get iterator for jogs. jogs = get_jogs(segments) @@ -2942,8 +2995,12 @@ def get_jogs(segments): # move horizontally from p1 and then vertically to p3. p2s = [Point(p1.x, p3.y), Point(p3.x, p1.y)] - # Shuffle the routing points so the applied correction isn't always the same orientation. - random.shuffle(p2s) + # 人类可读模式固定尝试顺序,避免同网表多次输出方向不一致。 + if human_readable: + p2s = sorted(p2s, key=lambda p: (p.x, p.y)) + else: + # Shuffle the routing points so the applied correction isn't always the same orientation. + random.shuffle(p2s) # Check each routing point to see if it leads to a valid routing. for p2 in p2s: @@ -3051,6 +3108,58 @@ def get_jogs(segments): # Update the node net's wire with the cleaned version. node.wires[net] = segments + if human_readable: + # 在通用清理后追加保守的人类化处理:只做不改变连通性的局部简化。 + node.humanize_wires() + + def humanize_wires(node): + """Apply conservative post-cleanup wiring simplifications.""" + + def seg_key(seg): + return ( + min(seg.p1.x, seg.p2.x), + min(seg.p1.y, seg.p2.y), + max(seg.p1.x, seg.p2.x), + max(seg.p1.y, seg.p2.y), + ) + + # 使用 part bbox 作为硬障碍,避免“美化”导致导线穿过器件。 + part_bboxes = [p.bbox * p.tx for p in node.parts] + + for net, segments in node.wires.items(): + cleaned = [] + for seg in segments: + if seg.p1 == seg.p2: + continue + if seg.p2 < seg.p1: + seg = Segment(seg.p2, seg.p1) + cleaned.append(seg) + + # 先按几何顺序稳定排序,便于后续重复运行获得相同结果。 + cleaned = sorted(cleaned, key=seg_key) + + # 删除非常短的 stub,保留连接主干的必要线段。 + # Segment 无 length 属性;用端点差的 magnitude(正交线段即几何长度)。 + stub_thresh = max(1, GRID // 2) + trimmed = [] + for seg in cleaned: + seg_len = (seg.p2 - seg.p1).magnitude + if seg_len < stub_thresh: + pt1_refs = 0 + pt2_refs = 0 + for other in cleaned: + if other is seg: + continue + if seg.p1 in (other.p1, other.p2): + pt1_refs += 1 + if seg.p2 in (other.p1, other.p2): + pt2_refs += 1 + if pt1_refs + pt2_refs <= 1: + continue + trimmed.append(seg) + + node.wires[net] = trimmed + def add_junctions(node): """Add X & T-junctions where wire segments in the same net meet.""" @@ -3130,7 +3239,12 @@ def route(node, tool=None, **options): this_module = sys.modules[__name__] this_module.__dict__.update(tool_modules[tool].constants.__dict__) - random.seed(options.get("seed")) + seed = options.get("seed") + if options.get("human_readable", False) and seed is None: + # 人类可读模式默认固定随机种子,保证同输入多次运行可重现。 + seed = 0 + random.seed(seed) + node._route_options = options # Remove any stuff leftover from a previous place & route run. node.rmv_routing_stuff() @@ -3229,8 +3343,10 @@ def route(node, tool=None, **options): # Remove any stuff leftover from this place & route run. node.rmv_routing_stuff() + rmv_attr(node, ("_route_options",)) except RoutingFailure: # Remove any stuff leftover from this place & route run. node.rmv_routing_stuff() + rmv_attr(node, ("_route_options",)) raise RoutingFailure diff --git a/update.md b/update.md new file mode 100644 index 00000000..717fe69c --- /dev/null +++ b/update.md @@ -0,0 +1,131 @@ +# 更新记录(human_readable 布局 / 走线稳定化) + +**日期**:2026-05-13 +**范围**:原理图自动布局与走线(`place.py`、`route.py`),默认行为不变。 + +--- + +## 目的 + +- 在可选模式下让生成的 `.kicad_sch` 更接近工程师手工排版习惯(主控居中、电源/去耦分区、接口左右等启发式)。 +- 减少随机性,使同一输入多次生成坐标与走线更稳定。 +- 遵循小步、保守、可回滚:不引入新依赖、不重写整个 placer/router。 + +--- + +## 涉及文件 + +| 文件 | 说明 | +|------|------| +| `src/skidl/schematics/place.py` | 主要改动:`human_readable` 分支、工具函数、auto_stub 微调、默认 seed | +| `src/skidl/schematics/route.py` | 轻量改动:稳定 `global_router` 起点、`remove_jogs` 顺序、`humanize_wires`、默认 seed | + +--- + +## 如何启用 + +在调用 `place` / `route` 时传入可选参数: + +```python +node.place(..., human_readable=True) +node.route(..., human_readable=True) +``` + +- **`human_readable=False`(默认)**:保持原有逻辑与分支,与改动前一致。 +- **`human_readable=True` 且未传 `seed`**:内部使用固定默认种子 `0`,便于回归与对比输出。 + +--- + +## `place.py` 变更摘要 + +### 新增(`Placer` 内小工具,非新 public API) + +- `_part_ref_key(part)`:按 `ref` / `name` / `value` 稳定排序键。 +- `_net_names_of(part)`:安全返回器件所连 net 名称集合。 +- `_is_power_net_name(name)`:电源/地 net 名启发式。 +- `_classify_part_role(part)`:`power` / `decoupling` / `ic` / `connector` / `passive` / `other`。 +- `_find_main_part(parts)`:优先 pin 数最多的 IC,否则连接度最高,tie 用稳定排序。 +- `_place_row(...)`:按 `place_bbox` 与 `GRID` / `BLK_INT_PAD` 行摆放。 + +### `place_connected_parts_rowbased` + +- **`human_readable=True`**:按主器件 + 角色分区布局;末尾保守去重叠 + `snap_to_grid`。 +- **`human_readable=False`**:仍为原 BFS + 行打包逻辑。 + +### `place_floating_parts` + +- **`human_readable=True`**:按 role 分桶、`value/ref` 稳定排序,被动件按 R/C/L 等分行,不依赖随机初始布局。 +- 默认路径不变(含大数量浮动件 + `auto_stub` 的 sqrt grid 等)。 + +### `place_blocks` + +- 当 block 数量超过阈值且 **`human_readable=True`**:连通块居中行、浮动块下方、子 sheet 右侧,按 `tag`、面积、`ref` 稳定排序。 +- 默认仍为原 sqrt grid + 力导向等。 + +### `_auto_stub_large_groups` + +- **`human_readable=True` 且 `auto_stub=True`**:更倾向对电源类 net 与候选链 net 排序后做有限次数 stub,避免“全图标签化”;默认分支保持原等步长切割逻辑。 + +### `place()` 随机种子 + +- `human_readable=True` 且用户未传 `seed` 时,使用固定种子 `0`。 + +--- + +## `route.py` 变更摘要 + +### `route()` + +- 与 place 一致:`human_readable=True` 且无 `seed` 时默认 `seed=0`。 +- 将本次 `options` 暂存于 `node._route_options`,供内部读取;正常结束或 `RoutingFailure` 时清理。 + +### `global_router()` + +- **`human_readable=True`**:`start_face` 按 track 坐标、beg/end、pin/terminal 数量等可比较键排序后取第一个,替代 `random.choice`。 +- 默认仍为随机选择。 + +### `cleanup_wires()` 内 `remove_jogs()` + +- **`human_readable=True`**:不对 segments / `p2s` 做 `shuffle`,改为稳定排序顺序。 +- **`human_readable=True`**:在通用 `cleanup_wires` 流程末尾调用 `humanize_wires()`。 + +### `humanize_wires()`(新增) + +- 仅在 human 模式、且 `cleanup_wires` 之后执行。 +- 保守操作:去零长段、稳定排序、对极短且弱连接的 stub 做 trim;不改动电气连接语义;避免穿越器件 bbox 的激进简化未做。 + +--- + +## 验收与自检 + +- `python -m compileall src/skidl/schematics/place.py src/skidl/schematics/route.py` 通过。 +- `import skidl.schematics.place`、`import skidl.schematics.route` 通过(环境缺 KiCad 符号路径时可能有既有 WARNING,与本次改动无关)。 + +--- + +## 回滚说明 + +- 不传 `human_readable` 或显式 `human_readable=False` 即可恢复改动前行为。 +- 若需完全撤销代码:仅回退上述两个文件在本记录日期附近的提交即可。 + +--- + +## 2026-05-14 更新:human 路由崩溃修复 + 网表仓库默认输出目录 + +### 1. `route.py` — `SwitchBox.coalesce` KeyError + +- **现象**:`human_readable=True` 时,部分电路在 `create_switchboxes` → `coalesce` 中执行 `(box_face.switchboxes - {box}).pop()` 会因邻接集为空触发 **`KeyError: 'pop from an empty set'`**(例如 `examples/5micro_3`)。 +- **原因**:布局/track 切分退化时,某 face 上记录的 `switchboxes` 可能未包含预期邻盒,空集仍被 `pop()`。 +- **修复**:先判断 `adjacent = box_face.switchboxes - {box}` 非空再取邻盒;扩张循环内先收集 `(i, adj_box)`,若任一步邻接为空则放弃该生长方向并 `continue` 下一轮,避免部分替换与崩溃。 +- **注释**:在 `coalesce` 内新增中文注释说明为何保守处理。 + +### 2. 配套仓库 `netlist-to-sch-via-skidl`(与本 fork 联用) + +- **`convert_netlist.py`**:`--schematic-subdir` 默认值由 `kicad_generated` 改为 **`kicad_generated_h`**,与当前注入的 `human_readable=True` 流程一致。 +- **`generated_script_patch.py`**:`_finalize_generated_skidl` 默认 `schematic_subdir` 改为 **`kicad_generated_h`**。 +- **`.gitignore`**:增加 `kicad_generated_h/`;保留 `kicad_generated/` 以兼容旧产物。 +- **示例 `*_skidl.py` / README**:原理图输出路径改为 **`kicad_generated_h`** 说明。 + +### 3. 验收 + +- 在 `ski2` 环境下对 `5micro_3.net` 重新 `convert_netlist` 后运行 `5micro_3_skidl.py`,**`human_readable=True`** 应能完成 schematic 生成(或进入既有 auto_stub 回退),且 `.kicad_sch` 位于 **`kicad_generated_h/`**。 From 6cf463112b061004d404640d369e4cf097a67548 Mon Sep 17 00:00:00 2001 From: Doris619619 Date: Sun, 17 May 2026 00:13:41 +0800 Subject: [PATCH 03/16] =?UTF-8?q?feat(schematic):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=B0=8F=E7=BB=84=E5=BC=B1=E5=AF=B9=E9=BD=90=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E6=AD=A3=20human=5Freadable=20=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E8=BF=87=E5=BC=BA=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/skidl/schematics/place.py | 204 +++++++++++++++++-- src/skidl/schematics/place_small_group.py | 237 ++++++++++++++++++++++ update.md | 121 +++++++++++ 3 files changed, 547 insertions(+), 15 deletions(-) create mode 100644 src/skidl/schematics/place_small_group.py diff --git a/src/skidl/schematics/place.py b/src/skidl/schematics/place.py index 31b2dc24..92519fd5 100644 --- a/src/skidl/schematics/place.py +++ b/src/skidl/schematics/place.py @@ -1274,6 +1274,177 @@ def _place_row(node, parts, start_x, start_y, direction=1, gap=None): return BBox(Point(min_x, min_y), Point(max_x, max_y)) + def _placement_ctr(node, part): + """返回器件放置 bbox 的中心,供几何对齐后处理使用。""" + return (part.place_bbox * part.tx).ctr + + def _set_part_center_y(node, part, target_y): + """仅沿 Y 平移器件,使中心落在 target_y(吸附到网格)。""" + ctr = node._placement_ctr(part) + snapped_y = Point(ctr.x, target_y).snap(GRID).y + dy = snapped_y - ctr.y + if dy: + part.tx *= Tx(dx=0, dy=dy) + + def _set_part_center_x(node, part, target_x): + """仅沿 X 平移器件,使中心落在 target_x(吸附到网格)。""" + ctr = node._placement_ctr(part) + snapped_x = Point(target_x, ctr.y).snap(GRID).x + dx = snapped_x - ctr.x + if dx: + part.tx *= Tx(dx=dx, dy=0) + + def _identify_trunk_parts(node, main_part, adjacency, roles): + """识别水平主干候选:主器件 + 已在同一水平带内的近邻。 + + 不把“所有直接邻居”都拉进主干,避免小电路(如 LED 链)被压成一行后 + 引脚落在同一路由坐标上引发 TerminalClash。 + """ + trunk = {main_part} + main_ctr = node._placement_ctr(main_part) + main_h = max(main_part.place_bbox.h, GRID) + neighbors = sorted( + adjacency.get(id(main_part), set()), key=node._part_ref_key + ) + for neighbor in neighbors: + if roles.get(neighbor) in ("decoupling", "power"): + continue + n_ctr = node._placement_ctr(neighbor) + tol = max(main_h, max(neighbor.place_bbox.h, GRID)) + if abs(n_ctr.y - main_ctr.y) <= tol: + trunk.add(neighbor) + return trunk + + def _nudge_part_if_clear(node, part, parts, dx, dy): + """平移器件;若与组内其它 place_bbox 相交则回滚。""" + if not dx and not dy: + return False + old_tx = copy(part.tx) + part.tx *= Tx(dx=dx, dy=dy) + bbox = part.place_bbox * part.tx + for other in parts: + if other is part: + continue + if bbox.intersects(other.place_bbox * other.tx): + part.tx = old_tx + return False + return True + + def _set_part_center_y_safe(node, part, parts, target_y): + """对齐 Y;若会与其它器件重叠则跳过(保持原位置)。""" + ctr = node._placement_ctr(part) + snapped_y = Point(ctr.x, target_y).snap(GRID).y + dy = snapped_y - ctr.y + if dy: + node._nudge_part_if_clear(part, parts, 0, dy) + + def _align_connected_geometry(node, parts, adjacency, roles, main_part): + """human_readable 专用:启发式摆放后的保守几何对齐后处理。 + + 主干共线、上下支路分层、左右近似对称,末尾做有限轮垂直去重叠。 + 不移动主器件锚点,避免打乱分区布局的“中心”语义。 + """ + if not parts or main_part is None or len(parts) < 2: + return + + main_ctr = node._placement_ctr(main_part) + main_y = main_ctr.y + main_x = main_ctr.x + + trunk = node._identify_trunk_parts(main_part, adjacency, roles) + + # 第 1 步:主干器件共线(水平主干,统一 Y;主器件本身不动) + for part in sorted(trunk - {main_part}, key=node._part_ref_key): + node._set_part_center_y_safe(part, parts, main_y) + + max_h = max((max(p.place_bbox.h, GRID) for p in parts), default=GRID) + branch_gap = max(BLK_INT_PAD + GRID, max_h + GRID) + y_top = main_y - branch_gap + y_bottom = main_y + branch_gap + + # 第 2 步:非主干器件按当前相对主干的上下关系分层 + # 电源/去耦/连接器保留分区启发式的位置,避免把左侧纵向连接器压成一行。 + layer_skip_roles = ("connector", "power", "decoupling") + upper = [] + lower = [] + for part in sorted(parts, key=node._part_ref_key): + if part in trunk: + continue + if roles.get(part) in layer_skip_roles: + continue + ctr = node._placement_ctr(part) + if ctr.y < main_y - GRID * 0.25: + upper.append(part) + elif ctr.y > main_y + GRID * 0.25: + lower.append(part) + else: + # 与主干同高带的器件:按 X 相对主器件分到上/下层,避免全挤在主干上 + if ctr.x <= main_x: + upper.append(part) + else: + lower.append(part) + + for part in upper: + node._set_part_center_y_safe(part, parts, y_top) + for part in lower: + node._set_part_center_y_safe(part, parts, y_bottom) + + # 第 3 步:左右对称——同 role、相近连接度的成对器件仅对齐 Y(不改 X,避免引脚共线) + branch_parts = [p for p in parts if p not in trunk] + left_by_sig = defaultdict(list) + right_by_sig = defaultdict(list) + for part in branch_parts: + ctr = node._placement_ctr(part) + degree = len(adjacency.get(id(part), set())) + sig = (roles.get(part), degree) + if ctr.x < main_x - GRID: + left_by_sig[sig].append(part) + elif ctr.x > main_x + GRID: + right_by_sig[sig].append(part) + + all_sigs = sorted( + set(left_by_sig.keys()) | set(right_by_sig.keys()), + key=lambda s: (s[0], s[1]), + ) + for sig in all_sigs: + left_list = sorted(left_by_sig.get(sig, []), key=node._part_ref_key) + right_list = sorted(right_by_sig.get(sig, []), key=node._part_ref_key) + pair_count = min(len(left_list), len(right_list)) + for i in range(pair_count): + lp = left_list[i] + rp = right_list[i] + l_ctr = node._placement_ctr(lp) + r_ctr = node._placement_ctr(rp) + avg_y = (l_ctr.y + r_ctr.y) / 2.0 + node._set_part_center_y_safe(lp, parts, avg_y) + node._set_part_center_y_safe(rp, parts, avg_y) + + # 第 4 步:保守去重叠——组内任意器件,先垂直再水平小步推开 + for _ in range(25): + moved = False + for part in sorted(parts, key=node._part_ref_key): + bbox = part.place_bbox * part.tx + for other in parts: + if other is part: + continue + other_bbox = other.place_bbox * other.tx + if not bbox.intersects(other_bbox): + continue + ctr = node._placement_ctr(part) + other_ctr = node._placement_ctr(other) + dy = BLK_INT_PAD if ctr.y <= other_ctr.y else -BLK_INT_PAD + if node._nudge_part_if_clear(part, parts, 0, dy): + moved = True + break + dx = BLK_INT_PAD if ctr.x <= other_ctr.x else -BLK_INT_PAD + if node._nudge_part_if_clear(part, parts, dx, 0): + moved = True + break + if moved: + break + if not moved: + break + def place_connected_parts_rowbased(node, parts, nets, **options): """Place connected parts using a BFS row-based layout (O(n)). @@ -1420,21 +1591,10 @@ def io_side_score(part): if other_parts: node._place_row(other_parts, right_x, bottom_y, direction=1, gap=BLK_INT_PAD) - # 在启发式分区摆放后做一次保守去重叠,避免局部角色区块互相压住。 - for part in sorted(real_parts, key=node._part_ref_key): - for _ in range(30): - bbox = part.place_bbox * part.tx - overlap = False - for other in real_parts: - if other is part: - continue - other_bbox = other.place_bbox * other.tx - if bbox.intersects(other_bbox): - part.tx *= Tx(dx=0, dy=BLK_INT_PAD) - overlap = True - break - if not overlap: - break + # 分区摆放后再做几何对齐(主干共线、支路分层、左右对称、去重叠)。 + node._align_connected_geometry( + real_parts, adjacency, roles, main_part + ) for part in real_parts: snap_to_grid(part) @@ -1546,6 +1706,20 @@ def place_connected_parts(node, parts, nets, **options): # Some part orientations were changed, so re-do placement. evolve_placement([], real_parts, nets, total_part_force, **options) + if options.get("human_readable", False) and len(real_parts) >= 2: + from skidl.schematics.place_small_group import beautify_small_connected_group + + # 小组力导向之后做弱美化(独立模块),避免过强共线引发 routing 冲突。 + beautify_small_connected_group( + real_parts, + classify_role=node._classify_part_role, + part_ref_key=node._part_ref_key, + grid=GRID, + blk_int_pad=BLK_INT_PAD, + ) + for part in real_parts: + snap_to_grid(part) + # Place NetTerminals after all the other parts. place_net_terminals( net_terminals, real_parts, nets, total_part_force, **options diff --git a/src/skidl/schematics/place_small_group.py b/src/skidl/schematics/place_small_group.py new file mode 100644 index 00000000..afb2c00a --- /dev/null +++ b/src/skidl/schematics/place_small_group.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- + +""" +小组连通器件的弱对齐 / 轻美化(human_readable 专用)。 + +大组用 place.py 中的结构化分区 + 强对齐;小组在力导向之后仅做“去抖/去歪”, +避免把局部几何压坏并引发 routing TerminalClash。 +""" + +from copy import copy + +from skidl.geometry import BBox, Point, Tx + + +def beautify_small_connected_group( + parts, + *, + classify_role, + part_ref_key, + grid, + blk_int_pad, +): + """对 small connected group 做保守 Y 向轻美化。 + + Args: + parts: 已放置好的 real parts(不含 NetTerminal)。 + classify_role: 与 Placer._classify_part_role 相同签名的回调。 + part_ref_key: 与 Placer._part_ref_key 相同签名的稳定排序键。 + grid: 布局网格(来自工具 constants.GRID)。 + blk_int_pad: 块间距(BLK_INT_PAD)。 + """ + if not parts or len(parts) < 2: + return + + parts = list(parts) + roles = {p: classify_role(p) for p in parts} + + if not _is_horizontal_group(parts): + # 第一版仅处理偏横向小图;纵向结构保持力导向结果不动。 + return + + y_band_tol = 2 * grid + pin_sep = grid + max_snap = 2 * grid + + bands = _cluster_y_bands(parts, part_ref_key, y_band_tol) + for band in bands: + if len(band) < 2: + continue + target_y = _band_target_y(band, grid) + for part in band: + ctr = _part_ctr(part) + dy = Point(ctr.x, target_y).snap(grid).y - ctr.y + if abs(dy) > max_snap: + continue + _try_nudge_y(part, parts, dy, grid, pin_sep, blk_int_pad) + + _weak_pair_y_touchup(parts, part_ref_key, roles, grid, pin_sep, blk_int_pad, max_snap) + _resolve_minor_overlaps(parts, part_ref_key, grid, blk_int_pad, pin_sep, primary_axis="y") + + +def _is_horizontal_group(parts): + """组整体 bbox 宽 >= 高 时视为偏横向,才做 Y 吸附。""" + bbox = BBox() + for part in parts: + bbox.add(part.place_bbox * part.tx) + return bbox.w >= bbox.h + + +def _part_ctr(part): + return (part.place_bbox * part.tx).ctr + + +def _connected_pin_pts(part): + """取已连接引脚在放置坐标系下的位置(优先 place_pt)。""" + pts = [] + for pin in part: + if not getattr(pin, "is_connected", lambda: False)(): + continue + base = getattr(pin, "place_pt", None) or pin.pt + pts.append((pin, base * part.tx)) + return pts + + +def _bbox_overlaps(part, others): + bbox = part.place_bbox * part.tx + for other in others: + if other is part: + continue + if bbox.intersects(other.place_bbox * other.tx): + return True + return False + + +def _pins_crowded_risk(part, others, grid, pin_sep): + """轻量引脚拥挤检查:不同 net 的引脚过近则视为 routing 风险。""" + for _pin, pt in _connected_pin_pts(part): + for other in others: + if other is part: + continue + for opin, opt in _connected_pin_pts(other): + if pt.distance(opt) >= pin_sep: + continue + pnet = getattr(_pin, "net", None) + onet = getattr(opin, "net", None) + if pnet is not None and onet is not None and pnet is not onet: + return True + if abs(pt.y - opt.y) <= grid * 0.5 and abs(pt.x - opt.x) <= grid: + return True + return False + + +def _move_safe(part, parts, dx, dy, grid, pin_sep): + """尝试平移;bbox 或引脚风险不达标则回滚。""" + if not dx and not dy: + return False + old_tx = copy(part.tx) + part.tx *= Tx(dx=dx, dy=dy) + others = [p for p in parts if p is not part] + if _bbox_overlaps(part, others) or _pins_crowded_risk(part, others, grid, pin_sep): + part.tx = old_tx + return False + return True + + +def _try_nudge_y(part, parts, dy, grid, pin_sep, blk_int_pad): + if not dy: + return False + return _move_safe(part, parts, 0, dy, grid, pin_sep) + + +def _cluster_y_bands(parts, part_ref_key, y_band_tol): + """按中心 Y 稳定排序后,把相邻 Y 差 <= 容差的器件划入同一水平带。""" + ordered = sorted(parts, key=lambda p: (_part_ctr(p).y, part_ref_key(p))) + bands = [] + current = [ordered[0]] + band_max_y = _part_ctr(ordered[0]).y + for part in ordered[1:]: + y = _part_ctr(part).y + if y - band_max_y <= y_band_tol: + current.append(part) + band_max_y = max(band_max_y, y) + else: + bands.append(current) + current = [part] + band_max_y = y + bands.append(current) + return bands + + +def _band_target_y(band, grid): + """水平带目标 Y:中位数后吸附网格,比均值更抗离群。""" + ys = sorted(_part_ctr(p).y for p in band) + mid = ys[len(ys) // 2] + return Point(0, mid).snap(grid).y + + +def _ref_prefix(part): + ref = str(getattr(part, "ref", "") or "").upper() + return ref[:1] if ref else "" + + +def _weak_pair_y_touchup(parts, part_ref_key, roles, grid, pin_sep, blk_int_pad, max_snap): + """可选:左右大致对应、同类且 Y 已很近的一对器件,仅轻微统一 Y。""" + if len(parts) < 2: + return + ctrs = {p: _part_ctr(p) for p in parts} + xs = sorted(ctrs[p].x for p in parts) + mid_x = xs[len(xs) // 2] + + candidates = sorted(parts, key=part_ref_key) + used = set() + for i, a in enumerate(candidates): + if id(a) in used: + continue + ca = ctrs[a] + pa = _ref_prefix(a) + ha = max(a.place_bbox.h, grid) + wa = max(a.place_bbox.w, grid) + for b in candidates[i + 1 :]: + if id(b) in used: + continue + cb = ctrs[b] + if abs(ca.y - cb.y) > max_snap: + continue + if roles.get(a) != roles.get(b) and pa != _ref_prefix(b): + continue + hb, wb = max(b.place_bbox.h, grid), max(b.place_bbox.w, grid) + if abs(ha - hb) > grid or abs(wa - wb) > 2 * grid: + continue + if (ca.x - mid_x) * (cb.x - mid_x) >= 0: + continue + target_y = Point(0, (ca.y + cb.y) / 2.0).snap(grid).y + ok_a = abs(target_y - ca.y) <= max_snap and _try_nudge_y( + a, parts, target_y - ca.y, grid, pin_sep, blk_int_pad + ) + ok_b = abs(target_y - cb.y) <= max_snap and _try_nudge_y( + b, parts, target_y - cb.y, grid, pin_sep, blk_int_pad + ) + if ok_a or ok_b: + used.add(id(a)) + used.add(id(b)) + break + + +def _resolve_minor_overlaps(parts, part_ref_key, grid, blk_int_pad, pin_sep, primary_axis="y"): + """有限轮小步去重叠;主调整轴为 Y 时,优先沿 X 微移。""" + for _ in range(15): + moved = False + for part in sorted(parts, key=part_ref_key): + if not _bbox_overlaps(part, parts): + continue + ctr = _part_ctr(part) + for other in parts: + if other is part: + continue + if not (part.place_bbox * part.tx).intersects( + other.place_bbox * other.tx + ): + continue + octr = _part_ctr(other) + if primary_axis == "y": + dx = blk_int_pad if ctr.x <= octr.x else -blk_int_pad + if _move_safe(part, parts, dx, 0, grid, pin_sep): + moved = True + break + dy = blk_int_pad if ctr.y <= octr.y else -blk_int_pad + if _move_safe(part, parts, 0, dy, grid, pin_sep): + moved = True + break + else: + dy = blk_int_pad if ctr.y <= octr.y else -blk_int_pad + if _move_safe(part, parts, 0, dy, grid, pin_sep): + moved = True + break + if not moved: + break diff --git a/update.md b/update.md index 717fe69c..0c1d0a7b 100644 --- a/update.md +++ b/update.md @@ -129,3 +129,124 @@ node.route(..., human_readable=True) ### 3. 验收 - 在 `ski2` 环境下对 `5micro_3.net` 重新 `convert_netlist` 后运行 `5micro_3_skidl.py`,**`human_readable=True`** 应能完成 schematic 生成(或进入既有 auto_stub 回退),且 `.kicad_sch` 位于 **`kicad_generated_h/`**。 + +--- + +## 2026-05-16 更新:connected group 几何对齐后处理(仅 `human_readable=True`) + +### 目的 + +在既有「主器件 + 角色分区」启发式摆放之后,补一层**保守的几何整理**,缓解「器件聚在一起但不在一条线上」的问题:主干共线、上下支路分层、左右近似对称,且不破坏默认可用性。 + +### 修改文件 + +| 文件 | 说明 | +|------|------| +| `src/skidl/schematics/place.py` | 新增对齐后处理及调用点 | +| `update.md` | 本记录 | + +**未改** `route.py`(本步仅 placement 后处理)。 + +### 新增 / 调整函数(`Placer` 内) + +| 函数 | 作用 | +|------|------| +| `_placement_ctr(part)` | 取 `place_bbox * tx` 中心,供对齐计算 | +| `_set_part_center_y` / `_set_part_center_x` | 单轴平移并吸附 `GRID` | +| `_identify_trunk_parts` | 主器件 + 非 `power`/`decoupling` 的直接邻居 → 主干候选 | +| `_align_connected_geometry` | 四步后处理:主干共 Y → 上下支路 `y_top`/`y_bottom` → 同 role/度数成对镜像 → 最多 25 轮支路垂直去重叠 | + +### 调用时机 + +- **仅** **`place_connected_parts_rowbased`** 且 `human_readable=True`:分区摆放完成后、`snap_to_grid` 之前调用。 +- **不**对 `place_connected_parts` 小组(<20 器件,力导向)调用——例如 `4micro2`(6 器件)若强行对齐会把多颗 LED 压到同一水平线,路由 `coalesce` 时出现 `TerminalClashException`。 + +### 2026-05-16 修补(`TerminalClash`) + +| 调整 | 原因 | +|------|------| +| 取消小组力导向路径上的对齐 | `4micro2` 等 <20 器件电路不走 rowbased,对齐反而破坏力导向结果 | +| 主干仅含**已在同一水平带**的近邻 | 避免“所有直接邻居”共 Y | +| 支路/对称 Y 对齐前做**重叠检测**,冲突则跳过 | 不制造 bbox 重叠 | +| 对称只统一 **Y**,不再镜像 **X** | 避免引脚落到同一路由坐标 | +| 去重叠对**全组**开放,必要时水平微移 | 主干之间也能拉开 | + +### 为什么这样改 + +- 分区启发式已决定「谁在上/下/左/右」,但**未强制几何共线**;后处理只调中心坐标,不动主器件锚点,改动面小、可回滚。 +- 主干识别复用 `_classify_part_role` 与邻接图,避免随机选链;排序统一用 `_part_ref_key`。 +- `branch_gap` 由 `max(place_bbox.h)`、`BLK_INT_PAD`、`GRID` 推导,避免写死过激间距。 +- 对称仅对「同 role + 同连接度」且分居主干两侧的支路器件成对处理,避免对单一 case 硬编码。 + +### 默认行为 + +- **`human_readable=False`(默认)**:**不变**;不调用 `_align_connected_geometry`。 +- **`human_readable=True`**:仅上述 connected parts 路径多一步几何整理。 + +### 风险与限制 + +- 第一版**优先水平主干**(统一 Y);竖向主干未单独识别。 +- `power` / `decoupling` / `connector` 不参与上下支路 Y 吸附,保留分区启发式位置(避免左侧纵向连接器被压成一行);不参与主干共线。 +- 对称与分层是**启发式近似**,复杂拓扑(多分叉、非两侧对称)只能做到「更整齐」,非最优布局。 +- 去重叠仅沿 Y 小步推开支路器件,若 X 方向严重重叠可能需后续路由/人工微调。 +- NetTerminal 在对齐**之后**再 `place_net_terminals`,避免终端标签拉动整体几何。 + +### 自检 + +- `python -m compileall src/skidl/schematics/place.py` 通过。 + +--- + +## 2026-05-16 更新:small connected group 弱美化(新文件) + +### 为什么 small 组不能照搬大组强对齐 + +- 小组(`real_count <= _ROW_PLACE_THRESHOLD`,默认 20)走 **`place_connected_parts` 力导向**,拓扑已由弹簧布局拉开。 +- 大组 rowbased 路径上的 `_align_connected_geometry`(主干共线、上下分层、成对 Y 统一)适合**分区启发式之后**的结构化整理,**不适合**直接套在力导向结果上:容易把多颗器件强压到同一水平带,引脚在路由 grid 上共位,触发 `TerminalClashException`(如 `4micro2`)。 + +### 为什么要拆到新文件 + +- 避免 `place.py` 继续堆叠后处理逻辑;**大组强对齐留在 `place.py`**,**小组弱美化独立维护**。 +- 便于单独回滚、测试与阅读。 + +### 新文件与入口 + +| 项 | 值 | +|----|-----| +| 新文件 | `src/skidl/schematics/place_small_group.py` | +| 入口函数 | `beautify_small_connected_group(...)` | + +### `place.py` 集成(最小改动) + +在 `place_connected_parts` 中,仅当 **`human_readable=True`** 且 **`real_count <= _ROW_PLACE_THRESHOLD`**(即未进入 rowbased)时: + +1. `evolve_placement`(及可选 `rotate_parts` 重跑)之后 +2. `place_net_terminals` 之前 + +调用 `beautify_small_connected_group`,再对 real parts `snap_to_grid`。 + +大组 rowbased + `_align_connected_geometry` **不变**。 + +### 核心策略(弱规则 / 去抖) + +1. **方向**:组 bbox 宽 ≥ 高 → 偏横向,仅做 **Y 向**微调;纵向小图第一版不动。 +2. **水平带分簇**:中心 Y 相差 ≤ `2 * GRID` 的器件划为一带;**仅对 ≥2 颗的带**做吸附。 +3. **弱吸附**:目标 Y 为带内中位数并 snap;单颗移动量 ≤ `2 * GRID`;移动前/后做风险检查。 +4. **可选 pair**:同 role 或同 ref 前缀、尺寸接近、分居中线两侧且 Y 已很近 → 仅轻微统一 Y(**无 X 镜像**)。 +5. **去重叠**:最多 15 轮,优先沿 **X** 小步(因主调整为 Y),每步经安全检查。 + +### 风险控制 + +| 检查 | 说明 | +|------|------| +| bbox | 移动后与其它 real part 的 `place_bbox * tx` 相交则回滚 | +| 引脚拥挤 | 已连接引脚的 `place_pt * tx`:不同 net 过近,或同轴距离 < `GRID` 则放弃移动 | +| 幅度限制 | 只修“本来就接近”的(带内 / ≤ `2*GRID`),不重建拓扑 | +| 单轴 | 横向图只主调 Y,不同时大幅改 X+Y | +| 确定性 | 全程 `part_ref_key` 稳定排序,无随机 | + +### 默认行为 + +- **`human_readable=False`(默认)**:**不变**,不调用 `beautify_small_connected_group`。 +- **`human_readable=True` 且大组(>20)**:仍只走 rowbased + `_align_connected_geometry`,**不**走本模块。 +- **`human_readable=True` 且小组(≤20)**:力导向后多一步弱美化。 From c52dbe6bc53536c4e3509f6d2d1cf87f55fab795 Mon Sep 17 00:00:00 2001 From: Doris619619 Date: Sun, 17 May 2026 19:00:06 +0800 Subject: [PATCH 04/16] =?UTF-8?q?fix(symbol):=20=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E8=84=8F=E7=BD=91=E8=A1=A8=E7=9A=84=E9=A1=B9=E7=9B=AE=E7=AC=A6?= =?UTF-8?q?=E5=8F=B7=E5=BA=93=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/skidl/schematics/place_small_group.py | 7 +- src/skidl/schematics/route.py | 255 +++++++++++++++++++++- src/skidl/tools/kicad6/sexp_schematic.py | 2 +- src/skidl/tools/kicad7/sexp_schematic.py | 2 +- src/skidl/tools/kicad8/sexp_schematic.py | 2 +- src/skidl/tools/kicad9/sexp_schematic.py | 3 +- update.md | 74 +++++++ 7 files changed, 339 insertions(+), 6 deletions(-) diff --git a/src/skidl/schematics/place_small_group.py b/src/skidl/schematics/place_small_group.py index afb2c00a..e34954b9 100644 --- a/src/skidl/schematics/place_small_group.py +++ b/src/skidl/schematics/place_small_group.py @@ -71,6 +71,11 @@ def _part_ctr(part): return (part.place_bbox * part.tx).ctr +def _pt_dist(a, b): + """两点欧氏距离(Point 无 distance 方法,用差向量 magnitude)。""" + return (a - b).magnitude + + def _connected_pin_pts(part): """取已连接引脚在放置坐标系下的位置(优先 place_pt)。""" pts = [] @@ -99,7 +104,7 @@ def _pins_crowded_risk(part, others, grid, pin_sep): if other is part: continue for opin, opt in _connected_pin_pts(other): - if pt.distance(opt) >= pin_sep: + if _pt_dist(pt, opt) >= pin_sep: continue pnet = getattr(_pin, "net", None) onet = getattr(opin, "net", None) diff --git a/src/skidl/schematics/route.py b/src/skidl/schematics/route.py index 8ae27977..5add4b96 100644 --- a/src/skidl/schematics/route.py +++ b/src/skidl/schematics/route.py @@ -3022,6 +3022,245 @@ def get_jogs(segments): # Return updated segments and set stop flag to false because segments were modified. return segments, False + def _straighten_local_detours( + net, segments, wires, net_bboxes, part_bboxes, net_pins + ): + """压平 human_readable 下 cleanup 仍残留的小凸起(保守,每次至多改一处)。 + + global/switchbox 路由优先保证连通,terminal 离散采样易留下 H-V-H / V-H-V + 小台阶;remove_jogs 主要处理标准三段 jog,本 pass 专门收掉“几乎可拉直”的局部 detour。 + + cleanup 后同 net 常已有 junction / T 分叉 / 短 stub,端点未必 degree=2; + 允许在端点挂同 net 分支,只要压平后分支连接点仍落在新路径上(junction-aware)。 + """ + + def obstructed(segment): + """与 remove_jogs 一致:器件 bbox 或其它 net 同轨 overlay 视为阻挡。""" + segment_bbox = BBox(segment.p1, segment.p2) + for part_bbox in part_bboxes: + if part_bbox.intersects(segment_bbox): + return True + segment_bbox = segment_bbox.resize(Vector(2, 2)) + for nt, nt_bbox in net_bboxes.items(): + if nt is net: + continue + if not segment_bbox.intersects(nt_bbox): + continue + for seg in wires[nt]: + if segment.p1.x == segment.p2.x == seg.p1.x == seg.p2.x: + if segment.p1.y <= seg.p2.y and segment.p2.y >= seg.p1.y: + return True + elif segment.p1.y == segment.p2.y == seg.p1.y == seg.p2.y: + if segment.p1.x <= seg.p2.x and segment.p2.x >= seg.p1.x: + return True + return False + + def seg_key(seg): + return ( + min(seg.p1.x, seg.p2.x), + min(seg.p1.y, seg.p2.y), + max(seg.p1.x, seg.p2.x), + max(seg.p1.y, seg.p2.y), + ) + + def is_horz(seg): + return seg.p1.y == seg.p2.y + + def is_vert(seg): + return seg.p1.x == seg.p2.x + + def far_end(seg, junction): + return seg.p2 if seg.p1 == junction else seg.p1 + + def pins_on_path(to_remove, new_segs): + """本 net 落在待移除路径上的 pin 必须仍落在新路径上。""" + for pt in net_pins: + on_old = any(contains_pt(s, pt) for s in to_remove) + if not on_old: + continue + if not any(contains_pt(s, pt) for s in new_segs): + return False + return True + + def attachment_points_ok(to_remove, new_segs): + """待移除段与保留分支的交汇点必须仍落在新路径上(junction-aware)。""" + to_remove_set = set(to_remove) + checked = set() + for seg in to_remove: + for pt in (seg.p1, seg.p2): + if pt in checked: + continue + checked.add(pt) + at_pt = pt_segs[pt] + has_remove = any(s in to_remove_set for s in at_pt) + has_keep = any(s not in to_remove_set for s in at_pt) + if not (has_remove and has_keep): + continue + if not any(contains_pt(ns, pt) for ns in new_segs): + return False + return True + + def try_apply(to_remove, new_segs): + order_seg_points(new_segs) + new_segs = [s for s in new_segs if s.p1 != s.p2] + if not new_segs: + return False + if any(obstructed(s) for s in new_segs): + return False + if not pins_on_path(to_remove, new_segs): + return False + if not attachment_points_ok(to_remove, new_segs): + return False + for seg in to_remove: + segments.remove(seg) + segments.extend( + Segment(copy.copy(s.p1), copy.copy(s.p2)) for s in new_segs + ) + return True + + def main_legs_at(junction, mid, parallel_horz): + """在 junction 上选取与 mid 组成凸起主通路的平行侧线段(确定性排序)。""" + others = [s for s in pt_segs[junction] if s is not mid] + if parallel_horz: + legs = [s for s in others if is_horz(s)] + else: + legs = [s for s in others if is_vert(s)] + return sorted(legs, key=seg_key) + + # 只处理明显小凸起,避免大范围改线 + detour_thresh = max(GRID, GRID * 2) + + order_seg_points(segments) + + pt_segs = defaultdict(list) + for seg in segments: + pt_segs[seg.p1].append(seg) + pt_segs[seg.p2].append(seg) + + for mid in sorted(segments, key=seg_key): + # 中间拐角若接 pin,不移动拓扑(最保守) + if is_pin_pt(mid.p1) or is_pin_pt(mid.p2): + continue + + # ---------- H-V-H:两水平 夹 短竖 ---------- + if is_vert(mid): + legs_p1 = main_legs_at(mid.p1, mid, parallel_horz=True) + legs_p2 = main_legs_at(mid.p2, mid, parallel_horz=True) + if not legs_p1 or not legs_p2: + continue + + j1, j2 = mid.p1, mid.p2 + detour_h = abs(j1.y - j2.y) + if detour_h == 0 or detour_h > detour_thresh: + continue + if abs(mid.p1.y - mid.p2.y) > detour_thresh: + continue + + for seg_a in legs_p1: + for seg_c in legs_p2: + to_remove = [seg_a, mid, seg_c] + + p_start = far_end(seg_a, j1) + p_end = far_end(seg_c, j2) + ya, yc = seg_a.p1.y, seg_c.p1.y + + # 情况 A:两水平已共线,可用单段替代整段小凸起 + if ya == yc: + x_lo = min(p_start.x, p_end.x, j1.x, j2.x) + x_hi = max(p_start.x, p_end.x, j1.x, j2.x) + new_seg = Segment( + copy.copy(Point(x_lo, ya)), + copy.copy(Point(x_hi, ya)), + ) + if try_apply(to_remove, [new_seg]): + return segments, False + continue + + # 情况 B:小高度差,用 L 形绕开中间竖台阶 + corners = sorted( + [ + Point(p_end.x, p_start.y), + Point(p_start.x, p_end.y), + ], + key=lambda p: (p.x, p.y), + ) + for corner in corners: + new_segs = [] + if p_start != corner: + new_segs.append( + Segment( + copy.copy(p_start), copy.copy(corner) + ) + ) + if corner != p_end: + new_segs.append( + Segment( + copy.copy(corner), copy.copy(p_end) + ) + ) + if try_apply(to_remove, new_segs): + return segments, False + continue + + # ---------- V-H-V:两竖直 夹 短横 ---------- + if is_horz(mid): + legs_p1 = main_legs_at(mid.p1, mid, parallel_horz=False) + legs_p2 = main_legs_at(mid.p2, mid, parallel_horz=False) + if not legs_p1 or not legs_p2: + continue + + j1, j2 = mid.p1, mid.p2 + detour_w = abs(j1.x - j2.x) + if detour_w == 0 or detour_w > detour_thresh: + continue + if abs(mid.p1.x - mid.p2.x) > detour_thresh: + continue + + for seg_a in legs_p1: + for seg_c in legs_p2: + to_remove = [seg_a, mid, seg_c] + + p_start = far_end(seg_a, j1) + p_end = far_end(seg_c, j2) + xa, xc = seg_a.p1.x, seg_c.p1.x + + if xa == xc: + y_lo = min(p_start.y, p_end.y, j1.y, j2.y) + y_hi = max(p_start.y, p_end.y, j1.y, j2.y) + new_seg = Segment( + copy.copy(Point(xa, y_lo)), + copy.copy(Point(xa, y_hi)), + ) + if try_apply(to_remove, [new_seg]): + return segments, False + continue + + corners = sorted( + [ + Point(p_end.x, p_start.y), + Point(p_start.x, p_end.y), + ], + key=lambda p: (p.x, p.y), + ) + for corner in corners: + new_segs = [] + if p_start != corner: + new_segs.append( + Segment( + copy.copy(p_start), copy.copy(corner) + ) + ) + if corner != p_end: + new_segs.append( + Segment( + copy.copy(corner), copy.copy(p_end) + ) + ) + if try_apply(to_remove, new_segs): + return segments, False + + return segments, True + # Get part bounding boxes so parts can be avoided when modifying net segments. part_bboxes = [p.bbox * p.tx for p in node.parts] @@ -3074,10 +3313,24 @@ def get_jogs(segments): segments = split_segments(segments, net_pin_pts[net]) # Remove unnecessary wire jogs. - segments, stop = remove_jogs( + segments, stop_jogs = remove_jogs( net, segments, node.wires, net_bboxes, part_bboxes ) + # human_readable:压平 remove_jogs 未覆盖的局部小凸起,再走 merge/split。 + stop_detour = True + if human_readable: + segments, stop_detour = _straighten_local_detours( + net, + segments, + node.wires, + net_bboxes, + part_bboxes, + net_pin_pts[net], + ) + + stop = stop_jogs and stop_detour + # Keep only non zero-length segments. segments = [seg for seg in segments if seg.p1 != seg.p2] diff --git a/src/skidl/tools/kicad6/sexp_schematic.py b/src/skidl/tools/kicad6/sexp_schematic.py index c12cd98a..3874beed 100644 --- a/src/skidl/tools/kicad6/sexp_schematic.py +++ b/src/skidl/tools/kicad6/sexp_schematic.py @@ -1400,5 +1400,5 @@ def need_quote_alternate(x): schematic.add_quotes(need_quote) schematic.add_quotes(need_quote_alternate, stop_idx=2) - with open(filepath, "w") as f: + with open(filepath, "w", encoding="utf-8") as f: f.write(schematic.to_str()) diff --git a/src/skidl/tools/kicad7/sexp_schematic.py b/src/skidl/tools/kicad7/sexp_schematic.py index c12cd98a..3874beed 100644 --- a/src/skidl/tools/kicad7/sexp_schematic.py +++ b/src/skidl/tools/kicad7/sexp_schematic.py @@ -1400,5 +1400,5 @@ def need_quote_alternate(x): schematic.add_quotes(need_quote) schematic.add_quotes(need_quote_alternate, stop_idx=2) - with open(filepath, "w") as f: + with open(filepath, "w", encoding="utf-8") as f: f.write(schematic.to_str()) diff --git a/src/skidl/tools/kicad8/sexp_schematic.py b/src/skidl/tools/kicad8/sexp_schematic.py index c12cd98a..3874beed 100644 --- a/src/skidl/tools/kicad8/sexp_schematic.py +++ b/src/skidl/tools/kicad8/sexp_schematic.py @@ -1400,5 +1400,5 @@ def need_quote_alternate(x): schematic.add_quotes(need_quote) schematic.add_quotes(need_quote_alternate, stop_idx=2) - with open(filepath, "w") as f: + with open(filepath, "w", encoding="utf-8") as f: f.write(schematic.to_str()) diff --git a/src/skidl/tools/kicad9/sexp_schematic.py b/src/skidl/tools/kicad9/sexp_schematic.py index c12cd98a..bc9aeb5a 100644 --- a/src/skidl/tools/kicad9/sexp_schematic.py +++ b/src/skidl/tools/kicad9/sexp_schematic.py @@ -1400,5 +1400,6 @@ def need_quote_alternate(x): schematic.add_quotes(need_quote) schematic.add_quotes(need_quote_alternate, stop_idx=2) - with open(filepath, "w") as f: + # KiCad .kicad_sch 为 UTF-8;Windows 默认 locale 为 GBK 时会因中文属性写入失败 + with open(filepath, "w", encoding="utf-8") as f: f.write(schematic.to_str()) diff --git a/update.md b/update.md index 0c1d0a7b..941b81da 100644 --- a/update.md +++ b/update.md @@ -250,3 +250,77 @@ node.route(..., human_readable=True) - **`human_readable=False`(默认)**:**不变**,不调用 `beautify_small_connected_group`。 - **`human_readable=True` 且大组(>20)**:仍只走 rowbased + `_align_connected_geometry`,**不**走本模块。 - **`human_readable=True` 且小组(≤20)**:力导向后多一步弱美化。 + +--- + +## 2026-05-17 更新:human_readable 走线局部凸起压平(仅 `route.py`) + +### 修的是哪类问题 + +- **现象**:small schematic / `human_readable=True` 时,走线里常出现「本可水平或垂直连过去,中间却多一小段台阶/凸起」——整体 route 没错,是 cleanup 未把局部可拉直的折线继续压平。 +- **不是 placement 问题**:器件位置与分区启发式无关;根因是 global/switchbox 路由优先连通、`create_nonpin_terminals()` 离散采样产生 dogleg,以及 `remove_jogs()` 主要针对标准三段 staircase/tophat,对「两平行主线 + 短 offset」类小 detour 覆盖不足。 + +### 修改文件 + +| 文件 | 说明 | +|------|------| +| `src/skidl/schematics/route.py` | `cleanup_wires()` 内新增 `_straighten_local_detours()` | +| `update.md` | 本记录 | + +**未改** `place.py`、`place_small_group.py`、global/switchbox 主路由算法。 + +### 新增 pass:`_straighten_local_detours()` + +- **接入位置**:`cleanup_wires()` 中,每个 net 的 `remove_jogs()` **之后**;仍走原有 `merge_segments()` → `split_segments()` → `trim_stubs()` 循环,把结果收干净。 +- **模式**:**仅 `human_readable=True`** 时调用;默认 `human_readable=False` **不执行**,行为与改动前一致。 +- **策略(保守)**: + - 只处理 **H-V-H** / **V-H-V** 三元组,中间为短正交连接、两侧为同向主线; + - **情况 A**:两侧主线已共线 → 尝试用 **单段** 替代三段; + - **情况 B**:高度/宽度差 ≤ `max(GRID, 2*GRID)` → 尝试用 **L 形两段** 绕开中间台阶(角点顺序固定排序,保证确定性); + - 每次至多改一处,改完返回由外层 while 继续 merge/split/trim(与 `remove_jogs` 相同节奏)。 + +### 安全检查 + +| 检查 | 说明 | +|------|------| +| 器件阻挡 | 新线段 bbox 不得与 `part_bboxes` 相交 | +| 其它 net | 与同轨其它 net 线段 overlay 视为阻挡(与 `remove_jogs` 相同逻辑) | +| pin | 中间拐角接 pin 则跳过;本 net 落在旧路径上的 pin 必须仍落在新路径上 | +| 范围 | 仅小 detour(阈值 `max(GRID, 2*GRID)`),不重写整网几何 | + +### 默认行为 + +- **`human_readable=False`(默认)**:**不变**;不调用 `_straighten_local_detours()`。 +- **`human_readable=True`**:在既有 `remove_jogs` + `humanize_wires` 流程中多一步局部压平,不改变路由主流程与 seed 策略。 + +### 自检 + +- `python -m compileall src/skidl/schematics/route.py` 通过。 + +--- + +## 2026-05-17 补充:junction-aware 局部凸起压平(仍仅 `route.py`) + +### 为什么原先纯三段检测不够 + +- 第一版 `_straighten_local_detours()` 要求 `mid` 两端各**恰好**再连一条同 net segment(`len(others)==1`),等价于假设凸起是孤立 H-V-H / V-H-V 三元组。 +- 真实 cleanup 后 wire 往往已 `split_segments` / `add_junctions`,端点常是 **T 分叉、短 stub、同 net 多段共点**;小凸起两端已是 junction,旧逻辑直接 `continue`,凸起残留。 + +### 为什么 junction-aware case 很常见 + +- `remove_jogs` 与 merge/split 会在拐角处留下额外正交分支;global/switchbox 路由也不会保证 degree-2 拓扑。 +- 视觉上仍是「两平行主线 + 短 offset」,但拓扑上端点已挂分支,**不是** placement 问题,而是 cleanup 后图结构更复杂。 + +### 本次扩展(不放宽安全检查) + +| 项 | 说明 | +|----|------| +| 主通路选取 | `main_legs_at()` 在 junction 上按方向筛平行侧腿,**确定性**排序后枚举 `(seg_a, seg_c)` 候选 | +| 分支保留 | `attachment_points_ok()`:待移除段与**保留**同 net 段的交汇点必须仍落在新路径上;「有分支」不再一律跳过 | +| 仍保守 | part / other-net obstruction、pin 落点、小 detour 阈值、每次只改一处、仅 `human_readable=True` — **均未放宽** | +| 跨 net | 仍只读写当前 net 的 `segments`;其它 net 仅通过既有 `obstructed()` 同轨 overlay 检查 | + +### 默认行为 + +- **`human_readable=False`**:**不变**。 +- **`human_readable=True`**:在既有 pass 上扩大可压平范围;不确定拓扑时 `try_apply` 失败即跳过。 From f3f47dc30fd71be7c82633b3217b15354000baa8 Mon Sep 17 00:00:00 2001 From: Doris619619 Date: Mon, 18 May 2026 22:40:10 +0800 Subject: [PATCH 05/16] =?UTF-8?q?docs(update):=E6=BC=94=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/update.md b/update.md index 941b81da..35500d6c 100644 --- a/update.md +++ b/update.md @@ -86,7 +86,7 @@ node.route(..., human_readable=True) ### `cleanup_wires()` 内 `remove_jogs()` -- **`human_readable=True`**:不对 segments / `p2s` 做 `shuffle`,改为稳定排序顺序。 +- **`human_readable=True`**:不对 segments / `p2s` 做 `shuffle`.改为稳定排序顺序。 - **`human_readable=True`**:在通用 `cleanup_wires` 流程末尾调用 `humanize_wires()`。 ### `humanize_wires()`(新增) From 534adc574d620ba75dc67f45966a909f367d48a0 Mon Sep 17 00:00:00 2001 From: rhaingenix Date: Tue, 19 May 2026 14:47:18 +0800 Subject: [PATCH 06/16] add local detour cleanup optimization --- src/skidl/schematics/route.py | 170 ++++++++++++++++-- .../unit_tests/ai_tests/test_route_cleanup.py | 116 ++++++++++++ 2 files changed, 275 insertions(+), 11 deletions(-) create mode 100644 tests/unit_tests/ai_tests/test_route_cleanup.py diff --git a/src/skidl/schematics/route.py b/src/skidl/schematics/route.py index 5add4b96..07c9e77e 100644 --- a/src/skidl/schematics/route.py +++ b/src/skidl/schematics/route.py @@ -2054,14 +2054,13 @@ def add_routing_pt(pin): else: raise RuntimeError("Unknown pin orientation.") - # Global set of part pin (x,y) points may have stuff from processing previous nodes, so clear it. - del pin_pts[:] # Clear the list. Works for Python 2 and 3. - for net in nets: # Add routing points for all pins on the net that are inside this node. for pin in node.get_internal_pins(net): # Store the point where the pin is. (This is used after routing to trim wire stubs.) - pin_pts.append((pin.pt * pin.part.tx).round()) + pin_pt = (pin.pt * pin.part.tx).round() + if pin_pt not in pin_pts: + pin_pts.append(pin_pt) # Add the point to which the wiring should be extended. add_routing_pt(pin) @@ -2071,6 +2070,64 @@ def add_routing_pt(pin): seg = Segment(pin.pt, pin.route_pt) * pin.part.tx node.wires[pin.net].append(seg) + def _segment_obstructed(node, segment, net=None, ignored_parts=None): + """Return True if a segment intersects another part or overlaps another net.""" + + ignored_parts = ignored_parts or set() + + segment_bbox = BBox(segment.p1, segment.p2) + for part in node.parts: + if part in ignored_parts: + continue + if (part.bbox * part.tx).intersects(segment_bbox): + return True + + segment_bbox = segment_bbox.resize(Vector(2, 2)) + for other_net, other_segments in node.wires.items(): + if other_net is net: + continue + for other_seg in other_segments: + if segment.p1.x == segment.p2.x == other_seg.p1.x == other_seg.p2.x: + if segment.p1.y <= other_seg.p2.y and segment.p2.y >= other_seg.p1.y: + return True + elif segment.p1.y == segment.p2.y == other_seg.p1.y == other_seg.p2.y: + if segment.p1.x <= other_seg.p2.x and segment.p2.x >= other_seg.p1.x: + return True + + return False + + def route_straight_nets(node, nets): + """Route aligned 2-pin nets directly before invoking the general router.""" + + direct_routed = [] + + for net in nets: + pins = list(node.get_internal_pins(net)) + if len(pins) != 2: + continue + + p1 = (pins[0].pt * pins[0].part.tx).round() + p2 = (pins[1].pt * pins[1].part.tx).round() + if p1 == p2 or (p1.x != p2.x and p1.y != p2.y): + continue + + seg = Segment(copy.copy(p1), copy.copy(p2)) + if seg.p2 < seg.p1: + seg.p1, seg.p2 = seg.p2, seg.p1 + + if node._segment_obstructed( + seg, net=net, ignored_parts={pins[0].part, pins[1].part} + ): + continue + + node.wires[net].append(seg) + for pt in (p1, p2): + if pt not in pin_pts: + pin_pts.append(pt) + direct_routed.append(net) + + return direct_routed + def create_routing_tracks(node, routing_bbox): """Create horizontal & vertical global routing tracks.""" @@ -2660,6 +2717,71 @@ def contains_pt(seg, pt): """Return True if the point is contained within the horz/vert segment.""" return seg.p1.x <= pt.x <= seg.p2.x and seg.p1.y <= pt.y <= seg.p2.y + def obstructed_by_parts_or_other_nets( + segment, net, wires, net_bboxes, part_obstacles, ignored_parts=None + ): + """Return True if a segment would collide with a part or overlap another net.""" + + ignored_parts = ignored_parts or set() + + segment_bbox = BBox(segment.p1, segment.p2) + for part, part_bbox in part_obstacles: + if part in ignored_parts: + continue + if part_bbox.intersects(segment_bbox): + return True + + # Expand slightly so coincident overlays with other nets are detected. + segment_bbox = segment_bbox.resize(Vector(2, 2)) + + for nt, nt_bbox in net_bboxes.items(): + if nt is net or not segment_bbox.intersects(nt_bbox): + continue + + for seg in wires[nt]: + if segment.p1.x == segment.p2.x == seg.p1.x == seg.p2.x: + if segment.p1.y <= seg.p2.y and segment.p2.y >= seg.p1.y: + return True + elif segment.p1.y == segment.p2.y == seg.p1.y == seg.p2.y: + if segment.p1.x <= seg.p2.x and segment.p2.x >= seg.p1.x: + return True + + return False + + def straighten_aligned_pin_connection( + net, segments, wires, net_bboxes, part_obstacles, net_pins + ): + """Replace an unnecessary detour on a 2-pin aligned net with one straight segment.""" + + if len(net_pins) != 2 or len(segments) <= 1: + return segments, True + + pin_pts = [(pin.pt * pin.part.tx).round() for pin in net_pins] + p1, p2 = pin_pts + if p1 == p2 or (p1.x != p2.x and p1.y != p2.y): + return segments, True + + direct_seg = Segment(copy.copy(p1), copy.copy(p2)) + order_seg_points([direct_seg]) + + if obstructed_by_parts_or_other_nets( + direct_seg, + net, + wires, + net_bboxes, + part_obstacles, + ignored_parts={pin.part for pin in net_pins}, + ): + return segments, True + + if len(segments) == 1: + seg = segments[0] + order_seg_points([seg]) + if seg.p1 == direct_seg.p1 and seg.p2 == direct_seg.p2: + return segments, True + + return [direct_seg], False + def trim_stubs(segments): """Return segments after removing stubs that have an unconnected endpoint.""" @@ -3262,16 +3384,19 @@ def main_legs_at(junction, mid, parallel_horz): return segments, True # Get part bounding boxes so parts can be avoided when modifying net segments. - part_bboxes = [p.bbox * p.tx for p in node.parts] + part_obstacles = [(part, part.bbox * part.tx) for part in node.parts] + part_bboxes = [bbox for _, bbox in part_obstacles] # Get dict of bounding boxes for the nets in this node. net_bboxes = {net: segments_bbox(segs) for net, segs in node.wires.items()} # Get locations for part pins of each net. (For use when splitting net segments.) + net_internal_pins = dict() net_pin_pts = dict() for net in node.wires.keys(): + net_internal_pins[net] = list(node.get_internal_pins(net)) net_pin_pts[net] = [ - (pin.pt * pin.part.tx).round() for pin in node.get_internal_pins(net) + (pin.pt * pin.part.tx).round() for pin in net_internal_pins[net] ] # Do a generalized cleanup of the wire segments of each net. @@ -3309,6 +3434,15 @@ def main_legs_at(junction, mid, parallel_horz): for net, segments in node.wires.items(): while True: + segments, stop_direct = straighten_aligned_pin_connection( + net, + segments, + node.wires, + net_bboxes, + part_obstacles, + net_internal_pins[net], + ) + # Split intersecting segments. segments = split_segments(segments, net_pin_pts[net]) @@ -3329,7 +3463,7 @@ def main_legs_at(junction, mid, parallel_horz): net_pin_pts[net], ) - stop = stop_jogs and stop_detour + stop = stop_direct and stop_jogs and stop_detour # Keep only non zero-length segments. segments = [seg for seg in segments if seg.p1 != seg.p2] @@ -3519,11 +3653,25 @@ def route(node, tool=None, **options): return try: + # Clear pin endpoints from any previous routing pass in this node. + del pin_pts[:] # Clear the list. Works for Python 2 and 3. + + # Priority 1: directly route aligned point-to-point nets when unobstructed. + direct_nets = set(node.route_straight_nets(internal_nets)) + routed_nets = [net for net in internal_nets if net not in direct_nets] + + if not routed_nets: + node.cleanup_wires() + node.add_junctions() + node.rmv_routing_stuff() + rmv_attr(node, ("_route_options",)) + return + # Extend routing points of part pins to the edges of their bounding boxes. - node.add_routing_points(internal_nets) + node.add_routing_points(routed_nets) # Create the surrounding box that contains the entire routing area. - channel_sz = (len(internal_nets) + 1) * GRID + channel_sz = (len(routed_nets) + 1) * GRID routing_bbox = ( node.internal_bbox().resize(Vector(channel_sz, channel_sz)) ).round() @@ -3532,7 +3680,7 @@ def route(node, tool=None, **options): h_tracks, v_tracks = node.create_routing_tracks(routing_bbox) # Create terminals on the faces in the routing tracks. - node.create_terminals(internal_nets, h_tracks, v_tracks) + node.create_terminals(routed_nets, h_tracks, v_tracks) # Draw part outlines, routing tracks and terminals. if options.get("draw_routing_channels"): @@ -3541,7 +3689,7 @@ def route(node, tool=None, **options): ) # Do global routing of nets internal to the node. - global_routes = node.global_router(internal_nets) + global_routes = node.global_router(routed_nets) # Convert the global face-to-face routes into terminals on the switchboxes. for route in global_routes: diff --git a/tests/unit_tests/ai_tests/test_route_cleanup.py b/tests/unit_tests/ai_tests/test_route_cleanup.py new file mode 100644 index 00000000..a48ccd60 --- /dev/null +++ b/tests/unit_tests/ai_tests/test_route_cleanup.py @@ -0,0 +1,116 @@ +from skidl.geometry import BBox, Point, Segment, Tx +from skidl.schematics import route as route_module + + +def setup_function(): + del route_module.pin_pts[:] + + +class DummyPart: + def __init__(self, bbox): + self.bbox = bbox + self.tx = Tx() + + +class DummyPin: + def __init__(self, part, pt): + self.part = part + self.pt = pt + + +class DummyNode: + def __init__(self, parts, wires, net_pins): + self.parts = parts + self.wires = wires + self._net_pins = net_pins + + def get_internal_pins(self, net): + return list(self._net_pins[net]) + + _segment_obstructed = route_module.Router._segment_obstructed + route_straight_nets = route_module.Router.route_straight_nets + + +def _seg_coords(seg): + pts = sorted(((seg.p1.x, seg.p1.y), (seg.p2.x, seg.p2.y))) + return tuple(pts) + + +def test_cleanup_wires_straightens_aligned_two_pin_detour(): + net = object() + left = DummyPart(BBox(Point(-1, -1), Point(1, 1))) + right = DummyPart(BBox(Point(19, -1), Point(21, 1))) + pin_a = DummyPin(left, Point(0, 0)) + pin_b = DummyPin(right, Point(20, 0)) + + node = DummyNode( + [left, right], + { + net: [ + Segment(Point(0, 0), Point(0, 10)), + Segment(Point(0, 10), Point(20, 10)), + Segment(Point(20, 10), Point(20, 0)), + ] + }, + {net: [pin_a, pin_b]}, + ) + + route_module.cleanup_wires(node) + + assert len(node.wires[net]) == 1 + assert _seg_coords(node.wires[net][0]) == ((0, 0), (20, 0)) + + +def test_cleanup_wires_keeps_detour_when_direct_path_hits_obstacle(): + net = object() + left = DummyPart(BBox(Point(-1, -1), Point(1, 1))) + right = DummyPart(BBox(Point(19, -1), Point(21, 1))) + blocker = DummyPart(BBox(Point(8, -2), Point(12, 2))) + pin_a = DummyPin(left, Point(0, 0)) + pin_b = DummyPin(right, Point(20, 0)) + + node = DummyNode( + [left, right, blocker], + { + net: [ + Segment(Point(0, 0), Point(0, 10)), + Segment(Point(0, 10), Point(20, 10)), + Segment(Point(20, 10), Point(20, 0)), + ] + }, + {net: [pin_a, pin_b]}, + ) + + route_module.cleanup_wires(node) + + assert len(node.wires[net]) > 1 + + +def test_route_straight_nets_prioritizes_aligned_direct_segment(): + net = object() + left = DummyPart(BBox(Point(-1, -1), Point(1, 1))) + right = DummyPart(BBox(Point(19, -1), Point(21, 1))) + pin_a = DummyPin(left, Point(0, 0)) + pin_b = DummyPin(right, Point(20, 0)) + node = DummyNode([left, right], {net: []}, {net: [pin_a, pin_b]}) + + routed = node.route_straight_nets([net]) + + assert routed == [net] + assert len(node.wires[net]) == 1 + assert _seg_coords(node.wires[net][0]) == ((0, 0), (20, 0)) + + +def test_route_straight_nets_skips_blocked_direct_segment(): + net = object() + left = DummyPart(BBox(Point(-1, -1), Point(1, 1))) + right = DummyPart(BBox(Point(19, -1), Point(21, 1))) + blocker = DummyPart(BBox(Point(8, -2), Point(12, 2))) + pin_a = DummyPin(left, Point(0, 0)) + pin_b = DummyPin(right, Point(20, 0)) + node = DummyNode([left, right, blocker], {net: []}, {net: [pin_a, pin_b]}) + + routed = node.route_straight_nets([net]) + + assert routed == [] + assert node.wires[net] == [] From 45133390a010e870f1264d07ef4a08ef5048e6e5 Mon Sep 17 00:00:00 2001 From: Doris619619 Date: Tue, 19 May 2026 15:12:04 +0800 Subject: [PATCH 07/16] =?UTF-8?q?feat(sch):=20=E6=94=AF=E6=8C=81=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=8E=9F=E7=90=86=E5=9B=BE=E5=B8=83=E7=BA=BF=E7=96=8F?= =?UTF-8?q?=E6=9D=BE=E7=A8=8B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/skidl/schematics/place.py | 55 +++++++++++++------------ src/skidl/tools/kicad6/gen_schematic.py | 8 +++- src/skidl/tools/kicad7/gen_schematic.py | 8 +++- src/skidl/tools/kicad8/gen_schematic.py | 8 +++- src/skidl/tools/kicad9/gen_schematic.py | 11 ++++- 5 files changed, 60 insertions(+), 30 deletions(-) diff --git a/src/skidl/schematics/place.py b/src/skidl/schematics/place.py index 92519fd5..3f03646a 100644 --- a/src/skidl/schematics/place.py +++ b/src/skidl/schematics/place.py @@ -138,10 +138,12 @@ def add_placement_bboxes(parts, **options): # expansion_factor > 1 is used to expand the area for routing around each part, # usually in response to a failed routing phase. But don't expand the routing # around NetTerminals since those are just used to label wires. + # spacing 因子叠加到 expansion_factor 上,控制全局留白松紧 if isinstance(part, NetTerminal): expansion_factor = 1 else: - expansion_factor = options.get("expansion_factor", 1.0) + spacing = options.get("spacing", 1.0) + expansion_factor = options.get("expansion_factor", 1.0) * spacing # Add padding for routing to the right and upper sides. part.place_bbox.add( @@ -1487,6 +1489,10 @@ def place_connected_parts_rowbased(node, parts, nets, **options): if human_readable: # 用稳定可读布局替代随机/机械 BFS,减少多次运行时版图漂移。 + # spacing 缩放分区间距,>1 更松散,<1 更紧凑 + _sp = options.get("spacing", 1.0) + _gap = int(BLK_INT_PAD * _sp) + roles = {part: node._classify_part_role(part) for part in real_parts} main_part = node._find_main_part(real_parts, adjacency=adjacency) main_part.tx = Tx().move(Point(0, 0)) @@ -1532,20 +1538,21 @@ def io_side_score(part): decoup_power.append(part) # 主器件放中心,其它器件按角色分区,优先形成人读图习惯的左右/上下结构。 - top_y = main_bbox.min.y - (BLK_INT_PAD + 2 * GRID) - left_x = main_bbox.min.x - (3 * BLK_INT_PAD) - right_x = main_bbox.max.x + (2 * BLK_INT_PAD) - bottom_y = main_bbox.max.y + (2 * BLK_INT_PAD) + # 分区间距受 spacing 缩放 + top_y = main_bbox.min.y - (_gap + 2 * GRID) + left_x = main_bbox.min.x - (3 * _gap) + right_x = main_bbox.max.x + (2 * _gap) + bottom_y = main_bbox.max.y + (2 * _gap) top_row = power_parts + decoup_power if top_row: - node._place_row(top_row, left_x, top_y, direction=1, gap=BLK_INT_PAD) + node._place_row(top_row, left_x, top_y, direction=1, gap=_gap) if decoup_near_main: node._place_row( decoup_near_main, main_bbox.min.x, - main_bbox.min.y - BLK_INT_PAD, + main_bbox.min.y - _gap, direction=1, gap=GRID, ) @@ -1555,13 +1562,13 @@ def io_side_score(part): for part in left_connectors: bbox = part.place_bbox part.tx = Tx().move(Point(left_x - bbox.w, y)) - y += max(bbox.h, GRID) + BLK_INT_PAD + y += max(bbox.h, GRID) + _gap if right_connectors: y = main_bbox.min.y for part in right_connectors: part.tx = Tx().move(Point(right_x, y)) - y += max(part.place_bbox.h, GRID) + BLK_INT_PAD + y += max(part.place_bbox.h, GRID) + _gap passive_near = [] passive_far = [] @@ -1574,22 +1581,22 @@ def io_side_score(part): if passive_near: node._place_row( passive_near, - main_bbox.max.x + BLK_INT_PAD, - main_bbox.max.y + BLK_INT_PAD, + main_bbox.max.x + _gap, + main_bbox.max.y + _gap, direction=1, - gap=BLK_INT_PAD, + gap=_gap, ) if passive_far: node._place_row( passive_far, - main_bbox.min.x - BLK_INT_PAD, + main_bbox.min.x - _gap, bottom_y, direction=1, - gap=BLK_INT_PAD, + gap=_gap, ) if other_parts: - node._place_row(other_parts, right_x, bottom_y, direction=1, gap=BLK_INT_PAD) + node._place_row(other_parts, right_x, bottom_y, direction=1, gap=_gap) # 分区摆放后再做几何对齐(主干共线、支路分层、左右对称、去重叠)。 node._align_connected_geometry( @@ -1919,8 +1926,9 @@ def __init__(self, src, bbox, anchor_pt, snap_pt, tag): # Tag indicates the type of part block. tag = 2 if (part_list is floating_parts) else 1 - # pad the bounding box so part blocks don't butt-up against each other. - pad = BLK_EXT_PAD + # 块间外部留白受 spacing 缩放 + _sp = options.get("spacing", 1.0) + pad = int(BLK_EXT_PAD * _sp) bbox = bbox.resize(Vector(pad, pad)) # Create the part block and place it on the list. @@ -1931,17 +1939,12 @@ def __init__(self, src, bbox, anchor_pt, snap_pt, tag): # Calculate bounding box of child node. bbox = child.calc_bbox() - # Set padding for separating bounding box from others. + # 子节点块间留白同样受 spacing 缩放 + _sp = options.get("spacing", 1.0) if child.flattened: - # This is a flattened node so the parts will be shown. - # Set the padding to include a pad between the parts and the - # graphical box that contains them, plus the padding around - # the outside of the graphical box. - pad = BLK_INT_PAD + BLK_EXT_PAD + pad = int((BLK_INT_PAD + BLK_EXT_PAD) * _sp) else: - # This is an unflattened child node showing no parts on the inside - # so just pad around the outside of its graphical box. - pad = BLK_EXT_PAD + pad = int(BLK_EXT_PAD * _sp) bbox = bbox.resize(Vector(pad, pad)) # Set the grid snapping point and tag for this child node. diff --git a/src/skidl/tools/kicad6/gen_schematic.py b/src/skidl/tools/kicad6/gen_schematic.py index 4b822a08..60944e45 100644 --- a/src/skidl/tools/kicad6/gen_schematic.py +++ b/src/skidl/tools/kicad6/gen_schematic.py @@ -496,6 +496,7 @@ def gen_schematic( title="SKiDL-Generated Schematic", flatness=0.0, retries=2, + spacing=1.0, **options, ): """Create a KiCad 9 schematic file from a Circuit object. @@ -570,6 +571,10 @@ def power_supply(vin, vout, gnd): _setup_kicad_env() + # spacing 参数校验:范围 0.5~3.0,控制器件间距的全局缩放 + spacing = max(0.5, min(3.0, float(spacing))) + options["spacing"] = spacing + # Part placement options that should always be turned on. options["use_push_pull"] = True options["rotate_parts"] = True @@ -580,7 +585,8 @@ def power_supply(vin, vout, gnd): if options.get("auto_stub", False): auto_stub_nets(circuit, **options) - expansion_factor = 1.0 + # 初始 expansion_factor 受 spacing 缩放;重试时在此基础上继续放大 + expansion_factor = 1.0 * spacing failure_type = None for attempt in range(retries): diff --git a/src/skidl/tools/kicad7/gen_schematic.py b/src/skidl/tools/kicad7/gen_schematic.py index 4b822a08..60944e45 100644 --- a/src/skidl/tools/kicad7/gen_schematic.py +++ b/src/skidl/tools/kicad7/gen_schematic.py @@ -496,6 +496,7 @@ def gen_schematic( title="SKiDL-Generated Schematic", flatness=0.0, retries=2, + spacing=1.0, **options, ): """Create a KiCad 9 schematic file from a Circuit object. @@ -570,6 +571,10 @@ def power_supply(vin, vout, gnd): _setup_kicad_env() + # spacing 参数校验:范围 0.5~3.0,控制器件间距的全局缩放 + spacing = max(0.5, min(3.0, float(spacing))) + options["spacing"] = spacing + # Part placement options that should always be turned on. options["use_push_pull"] = True options["rotate_parts"] = True @@ -580,7 +585,8 @@ def power_supply(vin, vout, gnd): if options.get("auto_stub", False): auto_stub_nets(circuit, **options) - expansion_factor = 1.0 + # 初始 expansion_factor 受 spacing 缩放;重试时在此基础上继续放大 + expansion_factor = 1.0 * spacing failure_type = None for attempt in range(retries): diff --git a/src/skidl/tools/kicad8/gen_schematic.py b/src/skidl/tools/kicad8/gen_schematic.py index 4b822a08..60944e45 100644 --- a/src/skidl/tools/kicad8/gen_schematic.py +++ b/src/skidl/tools/kicad8/gen_schematic.py @@ -496,6 +496,7 @@ def gen_schematic( title="SKiDL-Generated Schematic", flatness=0.0, retries=2, + spacing=1.0, **options, ): """Create a KiCad 9 schematic file from a Circuit object. @@ -570,6 +571,10 @@ def power_supply(vin, vout, gnd): _setup_kicad_env() + # spacing 参数校验:范围 0.5~3.0,控制器件间距的全局缩放 + spacing = max(0.5, min(3.0, float(spacing))) + options["spacing"] = spacing + # Part placement options that should always be turned on. options["use_push_pull"] = True options["rotate_parts"] = True @@ -580,7 +585,8 @@ def power_supply(vin, vout, gnd): if options.get("auto_stub", False): auto_stub_nets(circuit, **options) - expansion_factor = 1.0 + # 初始 expansion_factor 受 spacing 缩放;重试时在此基础上继续放大 + expansion_factor = 1.0 * spacing failure_type = None for attempt in range(retries): diff --git a/src/skidl/tools/kicad9/gen_schematic.py b/src/skidl/tools/kicad9/gen_schematic.py index 4b822a08..5c3491ab 100644 --- a/src/skidl/tools/kicad9/gen_schematic.py +++ b/src/skidl/tools/kicad9/gen_schematic.py @@ -496,6 +496,7 @@ def gen_schematic( title="SKiDL-Generated Schematic", flatness=0.0, retries=2, + spacing=1.0, **options, ): """Create a KiCad 9 schematic file from a Circuit object. @@ -508,6 +509,9 @@ def gen_schematic( flatness (float, optional): Determines how much the hierarchy is flattened in the schematic. Defaults to 0.0 (completely hierarchical). Use 1.0 to flatten everything into one sheet. retries (int, optional): Number of times to re-try if routing fails. Defaults to 2. + spacing (float, optional): Global layout spacing factor (0.5–3.0). Values >1.0 + produce a looser layout with more whitespace between parts; <1.0 produces + a tighter layout. Defaults to 1.0. options (dict, optional): Dict of options and values, usually for drawing/debugging. Auto-stub options (pass as keyword arguments): @@ -570,6 +574,10 @@ def power_supply(vin, vout, gnd): _setup_kicad_env() + # spacing 参数校验:范围 0.5~3.0,控制器件间距的全局缩放 + spacing = max(0.5, min(3.0, float(spacing))) + options["spacing"] = spacing + # Part placement options that should always be turned on. options["use_push_pull"] = True options["rotate_parts"] = True @@ -580,7 +588,8 @@ def power_supply(vin, vout, gnd): if options.get("auto_stub", False): auto_stub_nets(circuit, **options) - expansion_factor = 1.0 + # 初始 expansion_factor 受 spacing 缩放;重试时在此基础上继续放大 + expansion_factor = 1.0 * spacing failure_type = None for attempt in range(retries): From d7ea0f38871429aa8422330f84ba69ba980118ce Mon Sep 17 00:00:00 2001 From: Doris619619 Date: Tue, 19 May 2026 17:02:35 +0800 Subject: [PATCH 08/16] =?UTF-8?q?feat(route):=20=E7=94=A8=E5=B8=A6?= =?UTF-8?q?=E6=96=B9=E5=90=91=E7=8A=B6=E6=80=81=E7=9A=84=20Dijkstra=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=A8=E5=B1=80=E5=B8=83=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/skidl/schematics/route.py | 122 ++++++++++++------------ src/skidl/tools/kicad9/gen_schematic.py | 41 +++++++- 2 files changed, 98 insertions(+), 65 deletions(-) diff --git a/src/skidl/schematics/route.py b/src/skidl/schematics/route.py index 5add4b96..617f6167 100644 --- a/src/skidl/schematics/route.py +++ b/src/skidl/schematics/route.py @@ -7,6 +7,7 @@ """ import copy +import heapq import random import sys from collections import Counter, defaultdict @@ -2204,8 +2205,18 @@ def create_terminals(node, internal_nets, h_tracks, v_tracks): def global_router(node, nets): human_readable = False + prefer_straight = False + bend_penalty = 0.0 + length_weight = 1.0 try: human_readable = node._route_options.get("human_readable", False) + prefer_straight = node._route_options.get("prefer_straight", False) + bend_penalty = float(node._route_options.get("bend_penalty", 0.0)) + length_weight = float( + node._route_options.get( + "route_length_weight", 0.6 if prefer_straight else 1.0 + ) + ) except AttributeError: pass @@ -2220,6 +2231,12 @@ def stable_face_key(face): len(getattr(face, "terminals", [])), ) + def face_route_axis(face): + """Return the primary routing axis when traversing through a face.""" + if face.track.orientation == VERT: + return HORZ + return VERT + """Globally route a list of nets from face to face. Args: @@ -2266,81 +2283,66 @@ def rt_srch(start_face, stop_faces): if start_face in stop_faces or not stop_faces: return GlobalWire(net) - # Record faces that have been visited and their distance from the start face. - visited_faces = [start_face] - start_face.dist_from_start = 0 - # Path searches are allowed to touch a Face on a Part if it # has a Pin on the net being routed or if it is one of the stop faces. # This is necessary to allow a search to terminate on a stop face or to # pass through a face with a net pin on the way to finding a connection # to one of the stop faces. unconstrained_faces = stop_faces | net_pin_faces + start_state = (start_face, face_route_axis(start_face)) + best_metric = {start_state: (0.0, 0)} + prev_state = {} + frontier = [(0.0, 0, 0, start_state)] + search_order = 1 + + # 方向被纳入搜索状态后,可以在总代价里显式加入 bend penalty, + # 从而让“略长但更直”的路径击败“更短但反复转向”的路径。 + while frontier: + metric_cost, metric_bends, _, state = heapq.heappop(frontier) + if (metric_cost, metric_bends) != best_metric.get(state): + continue - # Search through faces until a path is found & returned or a routing exception occurs. - while True: - # Set up for finding the closest unvisited face. - closest_dist = float("inf") - closest_face = None - - # Search for the closest face adjacent to the visited faces. - visited_faces.sort(key=lambda f: f.dist_from_start) - for visited_face in visited_faces: - if visited_face.dist_from_start > closest_dist: - # Visited face is already further than the current - # closest face, so no use continuing search since - # any remaining visited faces are even more distant. - break + visited_face, route_axis = state - # Get the distances to the faces adjacent to this previously-visited face - # and update the closest face if appropriate. - for adj in visited_face.adjacent: - if adj.face in visited_faces: - # Don't re-visit faces that have already been visited. - continue + if visited_face in stop_faces: + face_path = [visited_face] + while state != start_state: + state = prev_state[state] + face_path.append(state[0]) - if ( - adj.face not in unconstrained_faces - and adj.face.capacity <= 0 - ): - # Skip faces with insufficient routing capacity. - continue + for face in face_path[:-1]: + if face.capacity > 0: + face.capacity -= 1 - # Compute distance of this adjacent face to the start face. - dist = visited_face.dist_from_start + adj.dist + return GlobalWire(net, reversed(face_path)) - if dist < closest_dist: - # Record the closest face seen so far. - closest_dist = dist - closest_face = adj.face - closest_face.prev_face = visited_face + for adj in sorted(visited_face.adjacent, key=lambda a: stable_face_key(a.face)): + if ( + adj.face not in unconstrained_faces + and adj.face.capacity <= 0 + ): + continue - if not closest_face: - # Exception raised if couldn't find a path from start to stop faces. - raise GlobalRoutingFailure( - f"Global routing failure: {net.name} {net} {start_face.pins}" + next_axis = face_route_axis(adj.face) + bend_count = 1 if next_axis != route_axis else 0 + next_state = (adj.face, next_axis) + next_metric = ( + metric_cost + adj.dist * length_weight + bend_count * bend_penalty, + metric_bends + bend_count, ) - # Add the closest adjacent face to the list of visited faces. - closest_face.dist_from_start = closest_dist - visited_faces.append(closest_face) - - if closest_face in stop_faces: - # The newest, closest face is actually on the list of stop faces, so the search is done. - # Now search back from this face to find the path back to the start face. - face_path = [closest_face] - while face_path[-1] is not start_face: - face_path.append(face_path[-1].prev_face) - - # Decrement the routing capacities of the path faces to account for this new routing. - # Don't decrement the stop face because any routing through it was accounted for - # during a previous routing. - for face in face_path[:-1]: - if face.capacity > 0: - face.capacity -= 1 + if next_metric < best_metric.get(next_state, (float("inf"), float("inf"))): + best_metric[next_state] = next_metric + prev_state[next_state] = state + heapq.heappush( + frontier, + (next_metric[0], next_metric[1], search_order, next_state), + ) + search_order += 1 - # Reverse face path to go from start-to-stop face and return it. - return GlobalWire(net, reversed(face_path)) + raise GlobalRoutingFailure( + f"Global routing failure: {net.name} {net} {start_face.pins}" + ) # Key function for setting the order in which nets will be globally routed. def rank_net(net): diff --git a/src/skidl/tools/kicad9/gen_schematic.py b/src/skidl/tools/kicad9/gen_schematic.py index 5c3491ab..83a93085 100644 --- a/src/skidl/tools/kicad9/gen_schematic.py +++ b/src/skidl/tools/kicad9/gen_schematic.py @@ -496,7 +496,11 @@ def gen_schematic( title="SKiDL-Generated Schematic", flatness=0.0, retries=2, - spacing=1.0, + spacing=0.8, + compactness=0.2, + prefer_straight=True, + bend_penalty=400.0, + route_length_weight=None, **options, ): """Create a KiCad 9 schematic file from a Circuit object. @@ -511,7 +515,19 @@ def gen_schematic( retries (int, optional): Number of times to re-try if routing fails. Defaults to 2. spacing (float, optional): Global layout spacing factor (0.5–3.0). Values >1.0 produce a looser layout with more whitespace between parts; <1.0 produces - a tighter layout. Defaults to 1.0. + a tighter layout. Defaults to 0.8. + compactness (float, optional): Additional compactness bias in the range 0.0–1.0. + Higher values tighten placement by reducing the effective spacing used for + placement expansion. Defaults to 0.2. + prefer_straight (bool, optional): If True, route selection prefers straighter + paths over purely shortest-length paths. Defaults to True. + bend_penalty (float, optional): Additional routing cost charged each time the + global route changes direction. Larger values discourage jogs and doglegs. + Defaults to 400.0 (about 8 routing grid units). + route_length_weight (float, optional): Weight applied to global route segment + length when comparing candidate paths. Use values below 1.0 together with + prefer_straight/bend_penalty to accept slightly longer but straighter paths. + Defaults to 1.0 unless prefer_straight=True, in which case 0.6 is used. options (dict, optional): Dict of options and values, usually for drawing/debugging. Auto-stub options (pass as keyword arguments): @@ -576,7 +592,22 @@ def power_supply(vin, vout, gnd): # spacing 参数校验:范围 0.5~3.0,控制器件间距的全局缩放 spacing = max(0.5, min(3.0, float(spacing))) - options["spacing"] = spacing + compactness = max(0.0, min(1.0, float(compactness))) + bend_penalty = max(0.0, float(bend_penalty)) + if route_length_weight is None: + route_length_weight = 0.6 if prefer_straight else 1.0 + route_length_weight = max(0.05, float(route_length_weight)) + + # 用显式 compactness 偏置 spacing,而不是埋在魔法常量里,方便用户理解与回退。 + effective_spacing = max(0.5, min(3.0, spacing * (1.0 - 0.35 * compactness))) + + options["spacing"] = effective_spacing + options["compactness"] = compactness + options["prefer_straight"] = bool(prefer_straight) + options["bend_penalty"] = bend_penalty + options["route_length_weight"] = route_length_weight + # 美观优先策略默认开启 human_readable;显式传 False 可回退旧版随机布局。 + options.setdefault("human_readable", True) # Part placement options that should always be turned on. options["use_push_pull"] = True @@ -588,8 +619,8 @@ def power_supply(vin, vout, gnd): if options.get("auto_stub", False): auto_stub_nets(circuit, **options) - # 初始 expansion_factor 受 spacing 缩放;重试时在此基础上继续放大 - expansion_factor = 1.0 * spacing + # 初始 expansion_factor 受紧凑度修正后的 spacing 缩放;重试时在此基础上继续放大 + expansion_factor = 1.0 * effective_spacing failure_type = None for attempt in range(retries): From a950d5c4c7afc3e794d3ca451705c861acddc2ac Mon Sep 17 00:00:00 2001 From: Doris619619 Date: Tue, 19 May 2026 19:54:03 +0800 Subject: [PATCH 09/16] =?UTF-8?q?feat(route):=20=E6=96=B0=E5=A2=9E=20reuse?= =?UTF-8?q?=5Fjunctions=20=E8=B5=B0=E7=BA=BF=E5=A4=8D=E7=94=A8=E5=8F=82?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/skidl/schematics/route.py | 216 +++++++++++++++++++++++- src/skidl/tools/kicad9/gen_schematic.py | 1 + 2 files changed, 216 insertions(+), 1 deletion(-) diff --git a/src/skidl/schematics/route.py b/src/skidl/schematics/route.py index 617f6167..1393c42d 100644 --- a/src/skidl/schematics/route.py +++ b/src/skidl/schematics/route.py @@ -2480,8 +2480,10 @@ def cleanup_wires(node): """Try to make wire segments look prettier.""" human_readable = False + reuse_junctions = True try: human_readable = node._route_options.get("human_readable", False) + reuse_junctions = node._route_options.get("reuse_junctions", True) except AttributeError: pass @@ -3263,6 +3265,194 @@ def main_legs_at(junction, mid, parallel_horz): return segments, True + def _reuse_wire_junctions(net, segments, net_pins, part_bboxes, wires, net_bboxes): + """同 net 端点复用已有 junction 或线段 T 接入点,避免在拐点旁另开通道。 + + 路由搜索阶段只有 face 级树,不知道最终导线几何;本 pass 在 cleanup 里 + 把“差一格未接上”的端点 snap 到已有 junction,或在同 net 线段内部 T 接入。 + 每次至多改一处,便于与 merge/split/remove_jogs 交替收敛。 + """ + + snap_dist = GRID * 2 + + def manhattan_dist(a, b): + return abs(a.x - b.x) + abs(a.y - b.y) + + def is_net_pin(pt): + if is_pin_pt(pt): + return True + return any(pt == pin_pt for pin_pt in net_pins) + + def obstructed(segment): + segment_bbox = BBox(segment.p1, segment.p2) + for part_bbox in part_bboxes: + if part_bbox.intersects(segment_bbox): + return True + segment_bbox = segment_bbox.resize(Vector(1, 1)) + for nt, nt_bbox in net_bboxes.items(): + if nt is net: + continue + if not segment_bbox.intersects(nt_bbox): + continue + for seg in wires[nt]: + if segment.p1.x == segment.p2.x == seg.p1.x == seg.p2.x: + if segment.p1.y <= seg.p2.y and segment.p2.y >= seg.p1.y: + return True + elif segment.p1.y == segment.p2.y == seg.p1.y == seg.p2.y: + if segment.p1.x <= seg.p2.x and segment.p2.x >= seg.p1.x: + return True + return False + + def collect_junction_points(segs): + horz_segs, vert_segs = extract_horz_vert_segs(segs) + jpts = set() + for hseg in horz_segs: + for vseg in vert_segs: + ix, iy = vseg.p1.x, hseg.p1.y + if hseg.p1.x <= ix <= hseg.p2.x and vseg.p1.y <= iy <= vseg.p2.y: + jpts.add(Point(ix, iy)) + return jpts + + def point_on_interior(seg, pt): + order_seg_points([seg]) + if seg.p1.y == seg.p2.y: + return pt.y == seg.p1.y and seg.p1.x < pt.x < seg.p2.x + return pt.x == seg.p1.x and seg.p1.y < pt.y < seg.p2.y + + def endpoint_is(seg, pt): + return seg.p1 == pt or seg.p2 == pt + + def split_segment_at(segs, seg, pt): + if endpoint_is(seg, pt) or not point_on_interior(seg, pt): + return False + segs.remove(seg) + segs.append(Segment(copy.copy(seg.p1), copy.copy(pt))) + segs.append(Segment(copy.copy(pt), copy.copy(seg.p2))) + return True + + def move_endpoint(seg, ep, new_pt): + if seg.p1 == ep: + seg.p1 = copy.copy(new_pt) + elif seg.p2 == ep: + seg.p2 = copy.copy(new_pt) + else: + return False + return seg.p1 != seg.p2 + + def colinear_target(seg, target): + if seg.p1.y == seg.p2.y: + if target.y == seg.p1.y: + return target + elif seg.p1.x == seg.p2.x: + if target.x == seg.p1.x: + return target + return None + + def try_snap_endpoint(segs, seg, ep, target): + if colinear_target(seg, target) is None or ep == target: + return False + if manhattan_dist(ep, target) > snap_dist: + return False + test = Segment(copy.copy(seg.p1), copy.copy(seg.p2)) + if test.p1 == ep: + test.p1 = copy.copy(target) + else: + test.p2 = copy.copy(target) + if test.p1 == test.p2 or obstructed(test): + return False + if not move_endpoint(seg, ep, target): + segs.remove(seg) + return True + + def try_t_tap(segs, seg, ep, other): + if other.p1.y == other.p2.y: + tap = Point(ep.x, other.p1.y) + else: + tap = Point(other.p1.x, ep.y) + if manhattan_dist(ep, tap) > snap_dist: + return False + if not point_on_interior(other, tap): + return False + if seg.p1.y == seg.p2.y: + if ep.y != tap.y or ep.y != seg.p1.y: + return False + else: + if ep.x != tap.x or ep.x != seg.p1.x: + return False + test = Segment(copy.copy(seg.p1), copy.copy(seg.p2)) + if test.p1 == ep: + test.p1 = copy.copy(tap) + else: + test.p2 = copy.copy(tap) + if test.p1 == test.p2 or obstructed(test): + return False + if not move_endpoint(seg, ep, tap): + segs.remove(seg) + if not endpoint_is(other, tap): + split_segment_at(segs, other, tap) + return True + + order_seg_points(segments) + junction_pts = collect_junction_points(segments) + candidates = [] + + for seg in segments: + for ep in (seg.p1, seg.p2): + if is_net_pin(ep): + continue + for jpt in sorted(junction_pts, key=lambda p: (p.x, p.y)): + if ep == jpt: + continue + dist = manhattan_dist(ep, jpt) + if dist and dist <= snap_dist: + candidates.append( + ( + "junction", + dist, + id(seg), + ep.x, + ep.y, + seg, + ep, + jpt, + None, + ) + ) + for other in segments: + if other is seg: + continue + if other.p1.y == other.p2.y: + tap = Point(ep.x, other.p1.y) + else: + tap = Point(other.p1.x, ep.y) + dist = manhattan_dist(ep, tap) + if dist and dist <= snap_dist and point_on_interior(other, tap): + candidates.append( + ( + "ttap", + dist, + id(seg), + ep.x, + ep.y, + seg, + ep, + tap, + other, + ) + ) + + candidates.sort(key=lambda c: (c[0], c[1], c[2], c[3], c[4])) + for kind, _, _, _, _, seg, ep, target, other in candidates: + if seg not in segments: + continue + if kind == "junction": + if try_snap_endpoint(segments, seg, ep, target): + return segments, False + elif other in segments and try_t_tap(segments, seg, ep, other): + return segments, False + + return segments, True + # Get part bounding boxes so parts can be avoided when modifying net segments. part_bboxes = [p.bbox * p.tx for p in node.parts] @@ -3302,6 +3492,19 @@ def main_legs_at(junction, mid, parallel_horz): # Trim wire stubs. segments = trim_stubs(segments) + if reuse_junctions: + segments, _ = _reuse_wire_junctions( + net, + segments, + net_pin_pts[net], + part_bboxes, + node.wires, + net_bboxes, + ) + segments = merge_segments(segments) + segments = split_segments(segments, net_pin_pts[net]) + segments = [seg for seg in segments if seg.p1 != seg.p2] + node.wires[net] = segments # Remove jogs in the wire segments of each net. @@ -3314,6 +3517,17 @@ def main_legs_at(junction, mid, parallel_horz): # Split intersecting segments. segments = split_segments(segments, net_pin_pts[net]) + stop_reuse = True + if reuse_junctions: + segments, stop_reuse = _reuse_wire_junctions( + net, + segments, + net_pin_pts[net], + part_bboxes, + node.wires, + net_bboxes, + ) + # Remove unnecessary wire jogs. segments, stop_jogs = remove_jogs( net, segments, node.wires, net_bboxes, part_bboxes @@ -3331,7 +3545,7 @@ def main_legs_at(junction, mid, parallel_horz): net_pin_pts[net], ) - stop = stop_jogs and stop_detour + stop = stop_jogs and stop_detour and stop_reuse # Keep only non zero-length segments. segments = [seg for seg in segments if seg.p1 != seg.p2] diff --git a/src/skidl/tools/kicad9/gen_schematic.py b/src/skidl/tools/kicad9/gen_schematic.py index 83a93085..ed89592d 100644 --- a/src/skidl/tools/kicad9/gen_schematic.py +++ b/src/skidl/tools/kicad9/gen_schematic.py @@ -606,6 +606,7 @@ def power_supply(vin, vout, gnd): options["prefer_straight"] = bool(prefer_straight) options["bend_penalty"] = bend_penalty options["route_length_weight"] = route_length_weight + options.setdefault("reuse_junctions", prefer_straight) # 美观优先策略默认开启 human_readable;显式传 False 可回退旧版随机布局。 options.setdefault("human_readable", True) From 019166c931227e34befcf541780b3c7aa2c4d5ca Mon Sep 17 00:00:00 2001 From: rhaingenix Date: Tue, 19 May 2026 20:26:38 +0800 Subject: [PATCH 10/16] fix large schematic graph routing issue --- src/skidl/schematics/place.py | 300 +++++++++++++++++++- src/skidl/schematics/route.py | 49 +++- src/skidl/tools/kicad6/gen_schematic.py | 94 +++--- src/skidl/tools/kicad7/gen_schematic.py | 94 +++--- src/skidl/tools/kicad8/gen_schematic.py | 94 +++--- src/skidl/tools/kicad9/gen_schematic.py | 94 +++--- tests/unit_tests/ai_tests/test_auto_stub.py | 75 ++++- 7 files changed, 646 insertions(+), 154 deletions(-) diff --git a/src/skidl/schematics/place.py b/src/skidl/schematics/place.py index 92519fd5..8980a671 100644 --- a/src/skidl/schematics/place.py +++ b/src/skidl/schematics/place.py @@ -62,7 +62,7 @@ # and floating parts to arrive at a total placement for this node. # ################################################################### - +print("USING MY MODIFIED ROUTER") class PlacementFailure(Exception): """Exception raised when parts or blocks could not be placed.""" @@ -1190,6 +1190,292 @@ def _is_power_net_name(node, name): ) return any(token in text for token in power_tokens) + def _is_bus_net_name(node, name): + """Heuristic detection of bus-like or globally-named nets.""" + if not name: + return False + text = str(name).upper() + bus_tokens = ( + "BUS", + "ADDR", + "ADDRESS", + "DATA", + "GPIO", + "USB", + "PCIE", + "ETH", + "MDIO", + "RGMII", + ) + return ( + "[" in text + or "]" in text + or any(token in text for token in bus_tokens) + ) + + def _part_ref_prefix(node, part): + """Return the alphabetic prefix of a part reference.""" + ref = str(getattr(part, "ref", "") or "").upper() + prefix = [] + for ch in ref: + if ch.isalpha(): + prefix.append(ch) + elif prefix: + break + return "".join(prefix) + + def _net_connected_parts(node, net, allowed_parts=None): + """Return unique non-terminal parts connected to a net.""" + parts = [] + seen = set() + allowed_ids = None if allowed_parts is None else {id(part) for part in allowed_parts} + for pin in getattr(net, "pins", []): + part = getattr(pin, "part", None) + if part is None or is_net_terminal(part): + continue + if allowed_ids is not None and id(part) not in allowed_ids: + continue + if id(part) in seen: + continue + seen.add(id(part)) + parts.append(part) + return parts + + def _part_effective_bbox(node, part): + """Return the current placement bbox for a part.""" + bbox = getattr(part, "place_bbox", None) or getattr(part, "lbl_bbox", None) + if bbox is None: + bbox = getattr(part, "bbox", BBox(Point(0, 0), Point(0, 0))) + return bbox * getattr(part, "tx", Tx()) + + def _bbox_manhattan_gap(node, bbox_a, bbox_b): + """Return the manhattan gap between two bboxes (0 if they overlap/touch).""" + dx = max(0, bbox_a.min.x - bbox_b.max.x, bbox_b.min.x - bbox_a.max.x) + dy = max(0, bbox_a.min.y - bbox_b.max.y, bbox_b.min.y - bbox_a.max.y) + return dx + dy + + def _pin_abs_pt(node, pin): + """Return the absolute placed point for a pin.""" + pin_pt = getattr(pin, "place_pt", getattr(pin, "pt", Point(pin.x, pin.y))) + return pin_pt * getattr(pin.part, "tx", Tx()) + + def _net_geometry_stats(node, pins): + """Return max pin distance, min pin distance, and bbox span for a net.""" + if len(pins) < 2: + return 0, 0, 0 + + pts = [node._pin_abs_pt(pin) for pin in pins] + bbox = BBox() + for pt in pts: + bbox.add(pt) + + max_dist = 0 + min_dist = float("inf") + for i, pt_a in enumerate(pts): + for pt_b in pts[i + 1:]: + dist = abs(pt_a.x - pt_b.x) + abs(pt_a.y - pt_b.y) + max_dist = max(max_dist, dist) + min_dist = min(min_dist, dist) + + if min_dist == float("inf"): + min_dist = 0 + + return max_dist, min_dist, bbox.w + bbox.h + + def _same_functional_block(node, net, part_a, part_b): + """Heuristically detect parts that should stay visually wired together.""" + if part_a is part_b: + return True + + role_a = node._classify_part_role(part_a) + role_b = node._classify_part_role(part_b) + roles = {role_a, role_b} + prefixes = { + node._part_ref_prefix(part_a), + node._part_ref_prefix(part_b), + } + + if "decoupling" in roles and roles.intersection({"ic", "passive", "power"}): + return True + if "ic" in roles and roles.intersection({"passive", "power"}): + return True + if prefixes.intersection({"Q", "U"}) and prefixes.intersection({"R", "C", "D", "L"}): + return True + if prefixes.intersection({"D", "LED"}) and prefixes.intersection({"R", "C"}): + return True + + net_name = str(getattr(net, "name", "") or "") + if node._is_power_net_name(net_name): + local_roles = {"ic", "passive", "decoupling", "power", "other"} + if roles <= local_roles and not roles.intersection({"connector"}): + return True + + return False + + def _cluster_pins_by_distance(node, net, pins, **options): + """Cluster net pins that are close enough to stay visibly wired.""" + if len(pins) < 2: + return [list(pins)] + + grid = globals().get("GRID", 100) + local_wire_threshold = options.get( + "auto_stub_local_wire_threshold", grid * 12 + ) + + parents = {id(pin): id(pin) for pin in pins} + def find(pin_id): + while parents[pin_id] != pin_id: + parents[pin_id] = parents[parents[pin_id]] + pin_id = parents[pin_id] + return pin_id + + def union(pin_a, pin_b): + root_a = find(id(pin_a)) + root_b = find(id(pin_b)) + if root_a != root_b: + parents[root_b] = root_a + + for i, pin_a in enumerate(pins): + pt_a = node._pin_abs_pt(pin_a) + for pin_b in pins[i + 1:]: + pt_b = node._pin_abs_pt(pin_b) + dist = abs(pt_a.x - pt_b.x) + abs(pt_a.y - pt_b.y) + if dist <= local_wire_threshold or node._same_functional_block( + net, pin_a.part, pin_b.part + ): + union(pin_a, pin_b) + + clusters = defaultdict(list) + for pin in pins: + clusters[find(id(pin))].append(pin) + + def cluster_key(cluster): + max_dist, min_dist, bbox_span = node._net_geometry_stats(cluster) + return (len(cluster), -bbox_span, -max_dist, -min_dist) + + return sorted(clusters.values(), key=cluster_key, reverse=True) + + def _is_local_functional_cluster(node, net, parts): + """Detect small motifs that read better with visible local wiring.""" + if len(parts) < 2 or len(parts) > 4: + return False + + prefixes = {node._part_ref_prefix(part) for part in parts} + roles = {part: node._classify_part_role(part) for part in parts} + + if any(role == "decoupling" for role in roles.values()): + return True + if "R" in prefixes and "D" in prefixes: + return True + if "R" in prefixes and "C" in prefixes: + return True + if "Q" in prefixes and prefixes.intersection({"R", "C", "D"}): + return True + if any(role == "ic" for role in roles.values()) and any( + role in ("passive", "decoupling") for role in roles.values() + ): + return True + + name = str(getattr(net, "name", "") or "") + if len(parts) == 2 and not ( + node._is_power_net_name(name) or node._is_bus_net_name(name) + ): + return True + + return False + + def _prefer_visible_wire_preplacement(node, net, part_to_group, **options): + """Decide if a net should stay explicit before detailed geometry exists.""" + parts = node._net_connected_parts(net) + if len(parts) < 2: + return True + + name = str(getattr(net, "name", "") or "") + if node._is_bus_net_name(name): + return False + + max_groups = options.get("auto_stub_visible_group_span", 2) + pin_groups = { + part_to_group[id(part)] + for part in parts + if id(part) in part_to_group + } + if len(pin_groups) > max_groups: + return False + + max_pins = options.get("auto_stub_visible_pins", 4) + if len(parts) > max_pins: + return False + + if node._is_power_net_name(name): + return node._is_local_functional_cluster(net, parts) + + return node._is_local_functional_cluster(net, parts) + + def _prefer_visible_wire_postplacement(node, net, pins, **options): + """Decide if a placed net should remain visible for local readability.""" + parts = node._net_connected_parts(net, allowed_parts={pin.part for pin in pins}) + if len(parts) < 2: + return True + + name = str(getattr(net, "name", "") or "") + if node._is_bus_net_name(name): + return False + + grid = globals().get("GRID", 100) + local_wire_threshold = options.get( + "auto_stub_local_wire_threshold", grid * 12 + ) + local_cluster_threshold = options.get( + "auto_stub_local_cluster_threshold", grid * 18 + ) + max_wire_pins = options.get("auto_stub_max_wire_pins", 3) + max_wire_dist = options.get("auto_stub_max_wire_dist", 2000) + + max_dist, min_dist, bbox_span = node._net_geometry_stats(pins) + min_part_gap = float("inf") + if len(parts) >= 2: + part_bboxes = [node._part_effective_bbox(part) for part in parts] + for i, bbox_a in enumerate(part_bboxes): + for bbox_b in part_bboxes[i + 1:]: + min_part_gap = min( + min_part_gap, node._bbox_manhattan_gap(bbox_a, bbox_b) + ) + if min_part_gap == float("inf"): + min_part_gap = 0 + is_local_cluster = node._is_local_functional_cluster(net, parts) + clusters = node._cluster_pins_by_distance(net, pins, **options) + largest_cluster = clusters[0] if clusters else [] + largest_cluster_ratio = float(len(largest_cluster)) / max(len(pins), 1) + + if node._is_power_net_name(name): + if ( + is_local_cluster + and largest_cluster + and len(largest_cluster) == len(pins) + and max_dist <= local_cluster_threshold + ): + return True + if ( + largest_cluster + and len(largest_cluster) >= 2 + and largest_cluster_ratio >= 0.75 + and min_part_gap <= local_wire_threshold + ): + return True + return False + + if is_local_cluster and max_dist <= local_cluster_threshold: + return True + if len(parts) <= max_wire_pins and min_dist <= local_wire_threshold: + return True + if len(parts) <= max_wire_pins and min_part_gap <= local_wire_threshold: + return True + if len(parts) <= 2 and bbox_span <= max_wire_dist: + return True + + return False + def _classify_part_role(node, part): """Classify part role with conservative heuristics.""" ref = str(getattr(part, "ref", "") or "").upper() @@ -2106,8 +2392,9 @@ def _auto_stub_cross_group(node, groups, **options): """Stub nets that span multiple placement groups. When auto_stub is enabled, nets connecting parts in different groups - would require inter-group wiring. Converting them to labels avoids - routing complexity. + can create long or congested routes. However, stubbing every + cross-group net destroys local topology, so keep small local motifs as + visible wires and reserve labels for clearly global connections. Args: node: The SchNode being placed. @@ -2136,7 +2423,9 @@ def _auto_stub_cross_group(node, groups, **options): for p in net.pins if id(p.part) in part_to_group } - if len(pin_groups) > 1: + if len(pin_groups) > 1 and not node._prefer_visible_wire_preplacement( + net, part_to_group, **options + ): net._stub = True net._stub_explicit = False for p in net.get_pins(): @@ -2180,6 +2469,9 @@ def _auto_stub_large_groups(node, groups, internal_nets, **options): id(p.part) for p in net.pins if id(p.part) in group_ids } if 2 <= len(net_parts) <= 4: + parts = [p for p in node._net_connected_parts(net) if id(p) in group_ids] + if node._is_local_functional_cluster(net, parts): + continue name = str(getattr(net, "name", "") or "") is_power = node._is_power_net_name(name) chain_nets.append((len(net_parts), 0 if is_power else 1, name.lower(), net)) diff --git a/src/skidl/schematics/route.py b/src/skidl/schematics/route.py index 07c9e77e..15a3f4cf 100644 --- a/src/skidl/schematics/route.py +++ b/src/skidl/schematics/route.py @@ -3145,7 +3145,13 @@ def get_jogs(segments): return segments, False def _straighten_local_detours( - net, segments, wires, net_bboxes, part_bboxes, net_pins + net, + segments, + wires, + net_bboxes, + part_bboxes, + net_pins, + visited_patterns=None, ): """压平 human_readable 下 cleanup 仍残留的小凸起(保守,每次至多改一处)。 @@ -3194,6 +3200,10 @@ def is_vert(seg): def far_end(seg, junction): return seg.p2 if seg.p1 == junction else seg.p1 + def pattern_key(*candidate_segs): + """Build a stable key so the same tiny detour is not retried repeatedly.""" + return tuple(sorted(seg_key(seg) for seg in candidate_segs)) + def pins_on_path(to_remove, new_segs): """本 net 落在待移除路径上的 pin 必须仍落在新路径上。""" for pt in net_pins: @@ -3250,6 +3260,16 @@ def main_legs_at(junction, mid, parallel_horz): return sorted(legs, key=seg_key) # 只处理明显小凸起,避免大范围改线 + if visited_patterns is None: + visited_patterns = set() + + # Skip this pass on large schematics because even a local scan becomes + # too expensive when repeated inside cleanup_wires(). + if len(segments) > 1000: + return segments, True + + # Only handle very small local detours. Wider rewrites belong to the + # main jog-removal pass and are intentionally left alone here. detour_thresh = max(GRID, GRID * 2) order_seg_points(segments) @@ -3259,6 +3279,8 @@ def main_legs_at(junction, mid, parallel_horz): pt_segs[seg.p1].append(seg) pt_segs[seg.p2].append(seg) + # Restrict this pass to simple local H-V-H / V-H-V patterns so it + # stays predictable and does not turn into a whole-graph optimizer. for mid in sorted(segments, key=seg_key): # 中间拐角若接 pin,不移动拓扑(最保守) if is_pin_pt(mid.p1) or is_pin_pt(mid.p2): @@ -3280,7 +3302,13 @@ def main_legs_at(junction, mid, parallel_horz): for seg_a in legs_p1: for seg_c in legs_p2: + if seg_a is seg_c: + continue to_remove = [seg_a, mid, seg_c] + candidate_key = pattern_key(seg_a, mid, seg_c) + if candidate_key in visited_patterns: + continue + visited_patterns.add(candidate_key) p_start = far_end(seg_a, j1) p_end = far_end(seg_c, j2) @@ -3340,7 +3368,13 @@ def main_legs_at(junction, mid, parallel_horz): for seg_a in legs_p1: for seg_c in legs_p2: + if seg_a is seg_c: + continue to_remove = [seg_a, mid, seg_c] + candidate_key = pattern_key(seg_a, mid, seg_c) + if candidate_key in visited_patterns: + continue + visited_patterns.add(candidate_key) p_start = far_end(seg_a, j1) p_end = far_end(seg_c, j2) @@ -3429,11 +3463,23 @@ def main_legs_at(junction, mid, parallel_horz): # Remove jogs in the wire segments of each net. keep_cleaning = True + local_detour_visited = defaultdict(set) while keep_cleaning: keep_cleaning = False for net, segments in node.wires.items(): + # Guard cleanup_wires() from pathological oscillation or repeated + # tiny rewrites on dense nets. We keep the current best result + # once the per-net cleanup budget is exhausted. + max_iter = min(64, max(8, len(segments) * 2)) + iter_count = 0 while True: + iter_count += 1 + if iter_count > max_iter: + # Stop trying once the cleanup budget is exhausted so + # dense nets cannot loop forever in repeated rewrites. + break + segments, stop_direct = straighten_aligned_pin_connection( net, segments, @@ -3461,6 +3507,7 @@ def main_legs_at(junction, mid, parallel_horz): net_bboxes, part_bboxes, net_pin_pts[net], + local_detour_visited[net], ) stop = stop_direct and stop_jogs and stop_detour diff --git a/src/skidl/tools/kicad6/gen_schematic.py b/src/skidl/tools/kicad6/gen_schematic.py index 4b822a08..e4ff9120 100644 --- a/src/skidl/tools/kicad6/gen_schematic.py +++ b/src/skidl/tools/kicad6/gen_schematic.py @@ -65,18 +65,22 @@ def _setup_kicad_env(): def auto_stub_nets(circuit, **options): - """Auto-stub power nets and high-fanout nets before generation. + """Auto-stub clearly global power nets and high-fanout nets before generation. Only modifies nets that haven't been explicitly set by the user. Called when auto_stub=True is passed to gen_schematic(). Args: circuit: The Circuit object containing nets to analyze. - options: Dict of options. Recognizes 'auto_stub_fanout' (default 5). + options: Dict of options. Recognizes 'auto_stub_fanout' (default 5) + and 'auto_stub_power_fanout' (default max(auto_stub_fanout, 6)). """ import sys fanout_threshold = options.get("auto_stub_fanout", 5) + power_fanout_threshold = options.get( + "auto_stub_power_fanout", max(fanout_threshold, 6) + ) stubbed_power = [] stubbed_fanout = [] @@ -88,11 +92,12 @@ def auto_stub_nets(circuit, **options): # Power nets: anything starting with "+" or matching common power names. if net.name.startswith("+") or _POWER_NET_RE.match(net.name): - net._stub = True - net._stub_explicit = False - for pin in net.get_pins(): - pin.stub = True - stubbed_power.append(f"{net.name}({len(net.pins)})") + if len(net.pins) >= power_fanout_threshold: + net._stub = True + net._stub_explicit = False + for pin in net.get_pins(): + pin.stub = True + stubbed_power.append(f"{net.name}({len(net.pins)})") continue # High fanout nets: many pins connected to the same net. @@ -218,7 +223,8 @@ def _classify_and_stub_complex_nets(circuit, node, **options): Called after placement succeeds, before routing. Nets with too many pins or pins too far apart get converted to labels for reliable connectivity. - Simple 2-3 pin short-distance nets remain as wires. + Small nearby functional clusters remain visible so local signal flow is + still obvious to a human reader. Args: circuit: The Circuit object. @@ -227,13 +233,12 @@ def _classify_and_stub_complex_nets(circuit, node, **options): auto_stub_max_wire_pins (int): Max pins for wire routing. Default 3. auto_stub_max_wire_dist (int): Max manhattan distance (mils) for wires. Default 2000. """ - from skidl.geometry import Point - max_wire_pins = options.get("auto_stub_max_wire_pins", 3) max_wire_dist = options.get("auto_stub_max_wire_dist", 2000) node_parts = set(node.parts) stubbed_count = 0 + partial_stubbed_count = 0 for net in node.get_internal_nets(): if getattr(net, "_stub_explicit", False): @@ -242,6 +247,39 @@ def _classify_and_stub_complex_nets(circuit, node, **options): continue pins = [p for p in net.pins if p.part in node_parts] + if len(pins) < 2: + continue + + clusters = node._cluster_pins_by_distance(net, pins, **options) + local_clusters = [ + cluster + for cluster in clusters + if len(cluster) >= 2 + and node._prefer_visible_wire_postplacement(net, cluster, **options) + ] + if local_clusters: + best_cluster = local_clusters[0] + if len(best_cluster) < len(pins): + visible_pin_ids = {id(pin) for pin in best_cluster} + for pin in pins: + if id(pin) not in visible_pin_ids: + pin.stub = True + partial_stubbed_count += 1 + continue + + # Use geometry-aware local heuristics so nearby circuit structure stays + # visible, and reserve labels for clearly global or long-distance nets. + if node._prefer_visible_wire_postplacement(net, pins, **options): + continue + + name = str(getattr(net, "name", "") or "") + if node._is_power_net_name(name) or node._is_bus_net_name(name): + net._stub = True + net._stub_explicit = False + for p in net.get_pins(): + p.stub = True + stubbed_count += 1 + continue # Too many pins → label. if len(pins) > max_wire_pins: @@ -253,34 +291,20 @@ def _classify_and_stub_complex_nets(circuit, node, **options): continue # Pins too far apart → label. - if len(pins) >= 2: - pts = [] - for p in pins: - pin_pt = getattr(p, "place_pt", getattr(p, "pt", Point(p.x, p.y))) - part_tx = getattr(p.part, "tx", None) - if part_tx: - pts.append(pin_pt * part_tx) - else: - pts.append(pin_pt) - - max_dist = 0 - for i, a in enumerate(pts): - for b in pts[i + 1:]: - dist = abs(a.x - b.x) + abs(a.y - b.y) - if dist > max_dist: - max_dist = dist - - if max_dist > max_wire_dist: - net._stub = True - net._stub_explicit = False - for p in net.get_pins(): - p.stub = True - stubbed_count += 1 + max_dist, _, _ = node._net_geometry_stats(pins) + if max_dist > max_wire_dist: + net._stub = True + net._stub_explicit = False + for p in net.get_pins(): + p.stub = True + stubbed_count += 1 - if stubbed_count: + if stubbed_count or partial_stubbed_count: from skidl.logger import active_logger active_logger.info( - f" [selective_routing] Stubbed {stubbed_count} complex nets after placement" + " [selective_routing] " + f"Stubbed {stubbed_count} complex nets and " + f"converted {partial_stubbed_count} distant pin groups to labels after placement" ) diff --git a/src/skidl/tools/kicad7/gen_schematic.py b/src/skidl/tools/kicad7/gen_schematic.py index 4b822a08..e4ff9120 100644 --- a/src/skidl/tools/kicad7/gen_schematic.py +++ b/src/skidl/tools/kicad7/gen_schematic.py @@ -65,18 +65,22 @@ def _setup_kicad_env(): def auto_stub_nets(circuit, **options): - """Auto-stub power nets and high-fanout nets before generation. + """Auto-stub clearly global power nets and high-fanout nets before generation. Only modifies nets that haven't been explicitly set by the user. Called when auto_stub=True is passed to gen_schematic(). Args: circuit: The Circuit object containing nets to analyze. - options: Dict of options. Recognizes 'auto_stub_fanout' (default 5). + options: Dict of options. Recognizes 'auto_stub_fanout' (default 5) + and 'auto_stub_power_fanout' (default max(auto_stub_fanout, 6)). """ import sys fanout_threshold = options.get("auto_stub_fanout", 5) + power_fanout_threshold = options.get( + "auto_stub_power_fanout", max(fanout_threshold, 6) + ) stubbed_power = [] stubbed_fanout = [] @@ -88,11 +92,12 @@ def auto_stub_nets(circuit, **options): # Power nets: anything starting with "+" or matching common power names. if net.name.startswith("+") or _POWER_NET_RE.match(net.name): - net._stub = True - net._stub_explicit = False - for pin in net.get_pins(): - pin.stub = True - stubbed_power.append(f"{net.name}({len(net.pins)})") + if len(net.pins) >= power_fanout_threshold: + net._stub = True + net._stub_explicit = False + for pin in net.get_pins(): + pin.stub = True + stubbed_power.append(f"{net.name}({len(net.pins)})") continue # High fanout nets: many pins connected to the same net. @@ -218,7 +223,8 @@ def _classify_and_stub_complex_nets(circuit, node, **options): Called after placement succeeds, before routing. Nets with too many pins or pins too far apart get converted to labels for reliable connectivity. - Simple 2-3 pin short-distance nets remain as wires. + Small nearby functional clusters remain visible so local signal flow is + still obvious to a human reader. Args: circuit: The Circuit object. @@ -227,13 +233,12 @@ def _classify_and_stub_complex_nets(circuit, node, **options): auto_stub_max_wire_pins (int): Max pins for wire routing. Default 3. auto_stub_max_wire_dist (int): Max manhattan distance (mils) for wires. Default 2000. """ - from skidl.geometry import Point - max_wire_pins = options.get("auto_stub_max_wire_pins", 3) max_wire_dist = options.get("auto_stub_max_wire_dist", 2000) node_parts = set(node.parts) stubbed_count = 0 + partial_stubbed_count = 0 for net in node.get_internal_nets(): if getattr(net, "_stub_explicit", False): @@ -242,6 +247,39 @@ def _classify_and_stub_complex_nets(circuit, node, **options): continue pins = [p for p in net.pins if p.part in node_parts] + if len(pins) < 2: + continue + + clusters = node._cluster_pins_by_distance(net, pins, **options) + local_clusters = [ + cluster + for cluster in clusters + if len(cluster) >= 2 + and node._prefer_visible_wire_postplacement(net, cluster, **options) + ] + if local_clusters: + best_cluster = local_clusters[0] + if len(best_cluster) < len(pins): + visible_pin_ids = {id(pin) for pin in best_cluster} + for pin in pins: + if id(pin) not in visible_pin_ids: + pin.stub = True + partial_stubbed_count += 1 + continue + + # Use geometry-aware local heuristics so nearby circuit structure stays + # visible, and reserve labels for clearly global or long-distance nets. + if node._prefer_visible_wire_postplacement(net, pins, **options): + continue + + name = str(getattr(net, "name", "") or "") + if node._is_power_net_name(name) or node._is_bus_net_name(name): + net._stub = True + net._stub_explicit = False + for p in net.get_pins(): + p.stub = True + stubbed_count += 1 + continue # Too many pins → label. if len(pins) > max_wire_pins: @@ -253,34 +291,20 @@ def _classify_and_stub_complex_nets(circuit, node, **options): continue # Pins too far apart → label. - if len(pins) >= 2: - pts = [] - for p in pins: - pin_pt = getattr(p, "place_pt", getattr(p, "pt", Point(p.x, p.y))) - part_tx = getattr(p.part, "tx", None) - if part_tx: - pts.append(pin_pt * part_tx) - else: - pts.append(pin_pt) - - max_dist = 0 - for i, a in enumerate(pts): - for b in pts[i + 1:]: - dist = abs(a.x - b.x) + abs(a.y - b.y) - if dist > max_dist: - max_dist = dist - - if max_dist > max_wire_dist: - net._stub = True - net._stub_explicit = False - for p in net.get_pins(): - p.stub = True - stubbed_count += 1 + max_dist, _, _ = node._net_geometry_stats(pins) + if max_dist > max_wire_dist: + net._stub = True + net._stub_explicit = False + for p in net.get_pins(): + p.stub = True + stubbed_count += 1 - if stubbed_count: + if stubbed_count or partial_stubbed_count: from skidl.logger import active_logger active_logger.info( - f" [selective_routing] Stubbed {stubbed_count} complex nets after placement" + " [selective_routing] " + f"Stubbed {stubbed_count} complex nets and " + f"converted {partial_stubbed_count} distant pin groups to labels after placement" ) diff --git a/src/skidl/tools/kicad8/gen_schematic.py b/src/skidl/tools/kicad8/gen_schematic.py index 4b822a08..e4ff9120 100644 --- a/src/skidl/tools/kicad8/gen_schematic.py +++ b/src/skidl/tools/kicad8/gen_schematic.py @@ -65,18 +65,22 @@ def _setup_kicad_env(): def auto_stub_nets(circuit, **options): - """Auto-stub power nets and high-fanout nets before generation. + """Auto-stub clearly global power nets and high-fanout nets before generation. Only modifies nets that haven't been explicitly set by the user. Called when auto_stub=True is passed to gen_schematic(). Args: circuit: The Circuit object containing nets to analyze. - options: Dict of options. Recognizes 'auto_stub_fanout' (default 5). + options: Dict of options. Recognizes 'auto_stub_fanout' (default 5) + and 'auto_stub_power_fanout' (default max(auto_stub_fanout, 6)). """ import sys fanout_threshold = options.get("auto_stub_fanout", 5) + power_fanout_threshold = options.get( + "auto_stub_power_fanout", max(fanout_threshold, 6) + ) stubbed_power = [] stubbed_fanout = [] @@ -88,11 +92,12 @@ def auto_stub_nets(circuit, **options): # Power nets: anything starting with "+" or matching common power names. if net.name.startswith("+") or _POWER_NET_RE.match(net.name): - net._stub = True - net._stub_explicit = False - for pin in net.get_pins(): - pin.stub = True - stubbed_power.append(f"{net.name}({len(net.pins)})") + if len(net.pins) >= power_fanout_threshold: + net._stub = True + net._stub_explicit = False + for pin in net.get_pins(): + pin.stub = True + stubbed_power.append(f"{net.name}({len(net.pins)})") continue # High fanout nets: many pins connected to the same net. @@ -218,7 +223,8 @@ def _classify_and_stub_complex_nets(circuit, node, **options): Called after placement succeeds, before routing. Nets with too many pins or pins too far apart get converted to labels for reliable connectivity. - Simple 2-3 pin short-distance nets remain as wires. + Small nearby functional clusters remain visible so local signal flow is + still obvious to a human reader. Args: circuit: The Circuit object. @@ -227,13 +233,12 @@ def _classify_and_stub_complex_nets(circuit, node, **options): auto_stub_max_wire_pins (int): Max pins for wire routing. Default 3. auto_stub_max_wire_dist (int): Max manhattan distance (mils) for wires. Default 2000. """ - from skidl.geometry import Point - max_wire_pins = options.get("auto_stub_max_wire_pins", 3) max_wire_dist = options.get("auto_stub_max_wire_dist", 2000) node_parts = set(node.parts) stubbed_count = 0 + partial_stubbed_count = 0 for net in node.get_internal_nets(): if getattr(net, "_stub_explicit", False): @@ -242,6 +247,39 @@ def _classify_and_stub_complex_nets(circuit, node, **options): continue pins = [p for p in net.pins if p.part in node_parts] + if len(pins) < 2: + continue + + clusters = node._cluster_pins_by_distance(net, pins, **options) + local_clusters = [ + cluster + for cluster in clusters + if len(cluster) >= 2 + and node._prefer_visible_wire_postplacement(net, cluster, **options) + ] + if local_clusters: + best_cluster = local_clusters[0] + if len(best_cluster) < len(pins): + visible_pin_ids = {id(pin) for pin in best_cluster} + for pin in pins: + if id(pin) not in visible_pin_ids: + pin.stub = True + partial_stubbed_count += 1 + continue + + # Use geometry-aware local heuristics so nearby circuit structure stays + # visible, and reserve labels for clearly global or long-distance nets. + if node._prefer_visible_wire_postplacement(net, pins, **options): + continue + + name = str(getattr(net, "name", "") or "") + if node._is_power_net_name(name) or node._is_bus_net_name(name): + net._stub = True + net._stub_explicit = False + for p in net.get_pins(): + p.stub = True + stubbed_count += 1 + continue # Too many pins → label. if len(pins) > max_wire_pins: @@ -253,34 +291,20 @@ def _classify_and_stub_complex_nets(circuit, node, **options): continue # Pins too far apart → label. - if len(pins) >= 2: - pts = [] - for p in pins: - pin_pt = getattr(p, "place_pt", getattr(p, "pt", Point(p.x, p.y))) - part_tx = getattr(p.part, "tx", None) - if part_tx: - pts.append(pin_pt * part_tx) - else: - pts.append(pin_pt) - - max_dist = 0 - for i, a in enumerate(pts): - for b in pts[i + 1:]: - dist = abs(a.x - b.x) + abs(a.y - b.y) - if dist > max_dist: - max_dist = dist - - if max_dist > max_wire_dist: - net._stub = True - net._stub_explicit = False - for p in net.get_pins(): - p.stub = True - stubbed_count += 1 + max_dist, _, _ = node._net_geometry_stats(pins) + if max_dist > max_wire_dist: + net._stub = True + net._stub_explicit = False + for p in net.get_pins(): + p.stub = True + stubbed_count += 1 - if stubbed_count: + if stubbed_count or partial_stubbed_count: from skidl.logger import active_logger active_logger.info( - f" [selective_routing] Stubbed {stubbed_count} complex nets after placement" + " [selective_routing] " + f"Stubbed {stubbed_count} complex nets and " + f"converted {partial_stubbed_count} distant pin groups to labels after placement" ) diff --git a/src/skidl/tools/kicad9/gen_schematic.py b/src/skidl/tools/kicad9/gen_schematic.py index 4b822a08..e4ff9120 100644 --- a/src/skidl/tools/kicad9/gen_schematic.py +++ b/src/skidl/tools/kicad9/gen_schematic.py @@ -65,18 +65,22 @@ def _setup_kicad_env(): def auto_stub_nets(circuit, **options): - """Auto-stub power nets and high-fanout nets before generation. + """Auto-stub clearly global power nets and high-fanout nets before generation. Only modifies nets that haven't been explicitly set by the user. Called when auto_stub=True is passed to gen_schematic(). Args: circuit: The Circuit object containing nets to analyze. - options: Dict of options. Recognizes 'auto_stub_fanout' (default 5). + options: Dict of options. Recognizes 'auto_stub_fanout' (default 5) + and 'auto_stub_power_fanout' (default max(auto_stub_fanout, 6)). """ import sys fanout_threshold = options.get("auto_stub_fanout", 5) + power_fanout_threshold = options.get( + "auto_stub_power_fanout", max(fanout_threshold, 6) + ) stubbed_power = [] stubbed_fanout = [] @@ -88,11 +92,12 @@ def auto_stub_nets(circuit, **options): # Power nets: anything starting with "+" or matching common power names. if net.name.startswith("+") or _POWER_NET_RE.match(net.name): - net._stub = True - net._stub_explicit = False - for pin in net.get_pins(): - pin.stub = True - stubbed_power.append(f"{net.name}({len(net.pins)})") + if len(net.pins) >= power_fanout_threshold: + net._stub = True + net._stub_explicit = False + for pin in net.get_pins(): + pin.stub = True + stubbed_power.append(f"{net.name}({len(net.pins)})") continue # High fanout nets: many pins connected to the same net. @@ -218,7 +223,8 @@ def _classify_and_stub_complex_nets(circuit, node, **options): Called after placement succeeds, before routing. Nets with too many pins or pins too far apart get converted to labels for reliable connectivity. - Simple 2-3 pin short-distance nets remain as wires. + Small nearby functional clusters remain visible so local signal flow is + still obvious to a human reader. Args: circuit: The Circuit object. @@ -227,13 +233,12 @@ def _classify_and_stub_complex_nets(circuit, node, **options): auto_stub_max_wire_pins (int): Max pins for wire routing. Default 3. auto_stub_max_wire_dist (int): Max manhattan distance (mils) for wires. Default 2000. """ - from skidl.geometry import Point - max_wire_pins = options.get("auto_stub_max_wire_pins", 3) max_wire_dist = options.get("auto_stub_max_wire_dist", 2000) node_parts = set(node.parts) stubbed_count = 0 + partial_stubbed_count = 0 for net in node.get_internal_nets(): if getattr(net, "_stub_explicit", False): @@ -242,6 +247,39 @@ def _classify_and_stub_complex_nets(circuit, node, **options): continue pins = [p for p in net.pins if p.part in node_parts] + if len(pins) < 2: + continue + + clusters = node._cluster_pins_by_distance(net, pins, **options) + local_clusters = [ + cluster + for cluster in clusters + if len(cluster) >= 2 + and node._prefer_visible_wire_postplacement(net, cluster, **options) + ] + if local_clusters: + best_cluster = local_clusters[0] + if len(best_cluster) < len(pins): + visible_pin_ids = {id(pin) for pin in best_cluster} + for pin in pins: + if id(pin) not in visible_pin_ids: + pin.stub = True + partial_stubbed_count += 1 + continue + + # Use geometry-aware local heuristics so nearby circuit structure stays + # visible, and reserve labels for clearly global or long-distance nets. + if node._prefer_visible_wire_postplacement(net, pins, **options): + continue + + name = str(getattr(net, "name", "") or "") + if node._is_power_net_name(name) or node._is_bus_net_name(name): + net._stub = True + net._stub_explicit = False + for p in net.get_pins(): + p.stub = True + stubbed_count += 1 + continue # Too many pins → label. if len(pins) > max_wire_pins: @@ -253,34 +291,20 @@ def _classify_and_stub_complex_nets(circuit, node, **options): continue # Pins too far apart → label. - if len(pins) >= 2: - pts = [] - for p in pins: - pin_pt = getattr(p, "place_pt", getattr(p, "pt", Point(p.x, p.y))) - part_tx = getattr(p.part, "tx", None) - if part_tx: - pts.append(pin_pt * part_tx) - else: - pts.append(pin_pt) - - max_dist = 0 - for i, a in enumerate(pts): - for b in pts[i + 1:]: - dist = abs(a.x - b.x) + abs(a.y - b.y) - if dist > max_dist: - max_dist = dist - - if max_dist > max_wire_dist: - net._stub = True - net._stub_explicit = False - for p in net.get_pins(): - p.stub = True - stubbed_count += 1 + max_dist, _, _ = node._net_geometry_stats(pins) + if max_dist > max_wire_dist: + net._stub = True + net._stub_explicit = False + for p in net.get_pins(): + p.stub = True + stubbed_count += 1 - if stubbed_count: + if stubbed_count or partial_stubbed_count: from skidl.logger import active_logger active_logger.info( - f" [selective_routing] Stubbed {stubbed_count} complex nets after placement" + " [selective_routing] " + f"Stubbed {stubbed_count} complex nets and " + f"converted {partial_stubbed_count} distant pin groups to labels after placement" ) diff --git a/tests/unit_tests/ai_tests/test_auto_stub.py b/tests/unit_tests/ai_tests/test_auto_stub.py index e00ea9dc..10bd013b 100644 --- a/tests/unit_tests/ai_tests/test_auto_stub.py +++ b/tests/unit_tests/ai_tests/test_auto_stub.py @@ -90,25 +90,32 @@ def __init__(self, nets): self.nets = nets return MockCircuit(nets) - def test_power_net_gnd_stubbed(self): - """GND net gets auto-stubbed.""" + def test_small_power_net_gnd_deferred(self): + """Small local GND net is deferred for geometry-aware routing.""" net = self._make_mock_net("GND", pin_count=3) circuit = self._make_mock_circuit([net]) auto_stub_nets(circuit) - assert net._stub is True + assert net._stub is False - def test_power_net_vcc_stubbed(self): - """VCC net gets auto-stubbed.""" + def test_small_power_net_vcc_deferred(self): + """Small local VCC net is deferred for geometry-aware routing.""" net = self._make_mock_net("VCC", pin_count=2) circuit = self._make_mock_circuit([net]) auto_stub_nets(circuit) - assert net._stub is True + assert net._stub is False - def test_power_net_plus3v3_stubbed(self): - """+3V3 net gets auto-stubbed.""" + def test_small_power_net_plus3v3_deferred(self): + """Small local +3V3 net is deferred for geometry-aware routing.""" net = self._make_mock_net("+3V3", pin_count=2) circuit = self._make_mock_circuit([net]) auto_stub_nets(circuit) + assert net._stub is False + + def test_power_net_high_fanout_stubbed(self): + """Large power nets still get auto-stubbed early.""" + net = self._make_mock_net("GND", pin_count=8) + circuit = self._make_mock_circuit([net]) + auto_stub_nets(circuit) assert net._stub is True def test_signal_net_not_stubbed(self): @@ -148,7 +155,7 @@ def test_explicit_override_respected(self): def test_pins_stubbed_with_net(self): """When a net is auto-stubbed, its pins are also stubbed.""" - net = self._make_mock_net("VCC", pin_count=3) + net = self._make_mock_net("VCC", pin_count=8) circuit = self._make_mock_circuit([net]) auto_stub_nets(circuit) assert all(pin.stub for pin in net.pins) @@ -390,6 +397,42 @@ def _generate_and_gate_auto_stub(output_dir): return filepath +def _generate_regulator_auto_stub(output_dir): + """Generate a simple local regulator circuit with auto_stub enabled.""" + from skidl import Circuit, Net, Part + + circuit = Circuit(name="regulator_auto") + + with circuit: + try: + ldo = Part("Regulator_Linear", "AP2112K-3.3") + except (FileNotFoundError, ValueError): + ldo = Part("Regulator_Linear", "AMS1117-3.3") + + cin = Part("Device", "C", value="10uF") + cout = Part("Device", "C", value="10uF") + gnd_sym = Part("power", "GND") + + vin = Net("VIN") + vout = Net("VOUT") + gnd = Net("GND") + + vin += ldo[1], cin[1] + vout += ldo[3], cout[1] + gnd += ldo[2], cin[2], cout[2], gnd_sym[1] + + circuit.generate_schematic( + filepath=output_dir, + top_name="regulator_auto", + auto_stub=True, + human_readable=True, + ) + + filepath = os.path.join(output_dir, "regulator_auto.kicad_sch") + assert os.path.exists(filepath), f"Schematic file not generated at {filepath}" + return filepath + + @requires_kicad_libs class TestBackwardCompat: """Ensure auto_stub=False (default) produces identical behavior.""" @@ -431,6 +474,20 @@ def test_divider_with_auto_stub(self, output_dir): filepath = _generate_divider(output_dir, auto_stub=True) assert os.path.exists(filepath) + def test_local_regulator_keeps_visible_wires(self, output_dir): + """A compact regulator block keeps local VIN/VOUT/GND wiring visible.""" + filepath = _generate_regulator_auto_stub(output_dir) + with open(filepath) as f: + content = f.read() + + wire_count = content.count("(wire") + vin_labels = re.findall(r'\(global_label\s+"VIN"', content) + vout_labels = re.findall(r'\(global_label\s+"VOUT"', content) + + assert wire_count >= 2, "Expected visible local wires in regulator block" + assert len(vin_labels) == 0, "Local VIN net should not collapse to labels" + assert len(vout_labels) == 0, "Local VOUT net should not collapse to labels" + @requires_kicad_libs class TestExplicitOverride: From 6d2b17116a0350b0ec4d9f70e641ff53be61d285 Mon Sep 17 00:00:00 2001 From: Doris619619 Date: Tue, 19 May 2026 23:29:52 +0800 Subject: [PATCH 11/16] =?UTF-8?q?fix(schematic):=20=E4=BF=AE=E5=A4=8D=20cl?= =?UTF-8?q?eanup=20=E5=8E=BB=20jog=20=E6=8C=AF=E8=8D=A1=E5=B9=B6=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=B8=83=E7=BA=BF=E9=98=B6=E6=AE=B5=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/skidl/schematics/place.py | 17 +++ src/skidl/schematics/route.py | 155 +++++++++++++++--------- src/skidl/tools/kicad9/gen_schematic.py | 18 +++ 3 files changed, 130 insertions(+), 60 deletions(-) diff --git a/src/skidl/schematics/place.py b/src/skidl/schematics/place.py index 3f03646a..ac1c4e1b 100644 --- a/src/skidl/schematics/place.py +++ b/src/skidl/schematics/place.py @@ -17,6 +17,12 @@ from skidl import Pin from skidl.logger import active_logger from skidl.utilities import export_to_all, rmv_attr, sgn + + +def _sch_progress(options, message): + """schematic_progress=True 时输出布局阶段日志。""" + if options.get("schematic_progress", False): + active_logger.info(message) from .debug_draw import ( draw_end, draw_pause, @@ -2256,12 +2262,20 @@ def place(node, tool=None, **options): # Store the starting attributes of the node's parts, pins, and nets. node.attrs = node.get_attrs() + sheet = getattr(node, "name", "?") + try: # First, recursively place children of this node. # TODO: Child nodes are independent, so can they be processed in parallel? for child in node.children.values(): child.place(tool=tool, **options) + if node.parts: + _sch_progress( + options, + f"[schematic] 布局 sheet={sheet}:{len(node.parts)} 件", + ) + # Group parts into those that are connected by explicit nets and # those that float freely connected only by stub nets. connected_parts, internal_nets, floating_parts = node.group_parts(**options) @@ -2298,6 +2312,9 @@ def place(node, tool=None, **options): # Calculate the bounding box for the node after placement of parts and children. node.calc_bbox() + if node.parts: + _sch_progress(options, f"[schematic] 布局完成 sheet={sheet}") + except PlacementFailure: node.rmv_placement_stuff() raise PlacementFailure diff --git a/src/skidl/schematics/route.py b/src/skidl/schematics/route.py index 1393c42d..eab0f427 100644 --- a/src/skidl/schematics/route.py +++ b/src/skidl/schematics/route.py @@ -23,6 +23,14 @@ __all__ = ["RoutingFailure", "GlobalRoutingFailure", "SwitchboxRoutingFailure"] +def _sch_progress(options, message): + """schematic_progress=True 时输出阶段日志,便于定位 place/route/cleanup 卡点。""" + if options.get("schematic_progress", False): + from skidl.logger import active_logger + + active_logger.info(message) + + ################################################################### # # OVERVIEW OF SCHEMATIC AUTOROUTER @@ -2479,13 +2487,13 @@ def switchbox_router(node, switchboxes, **options): def cleanup_wires(node): """Try to make wire segments look prettier.""" - human_readable = False - reuse_junctions = True - try: - human_readable = node._route_options.get("human_readable", False) - reuse_junctions = node._route_options.get("reuse_junctions", True) - except AttributeError: - pass + route_opts = getattr(node, "_route_options", {}) or {} + human_readable = route_opts.get("human_readable", False) + reuse_junctions = route_opts.get("reuse_junctions", True) + sheet = getattr(node, "name", "?") + + def plog(msg): + _sch_progress(route_opts, msg) def order_seg_points(segments): """Order endpoints in a horizontal or vertical segment.""" @@ -3466,6 +3474,11 @@ def try_t_tap(segs, seg, ep, other): (pin.pt * pin.part.tx).round() for pin in node.get_internal_pins(net) ] + plog( + f"[schematic] cleanup 开始 sheet={sheet}," + f"{len(node.wires)} 网 / {sum(len(s) for s in node.wires.values())} 段" + ) + # Do a generalized cleanup of the wire segments of each net. for net, segments in node.wires.items(): # Round the wire segment endpoints to integers. @@ -3509,76 +3522,82 @@ def try_t_tap(segs, seg, ep, other): # Remove jogs in the wire segments of each net. keep_cleaning = True + clean_round = 0 + max_clean_rounds = int(route_opts.get("cleanup_max_rounds", 12)) while keep_cleaning: + clean_round += 1 + if clean_round > max_clean_rounds: + plog( + f"[schematic] cleanup 达到轮次上限 {max_clean_rounds}," + f"sheet={sheet},强制结束" + ) + break + plog(f"[schematic] cleanup 轮次 {clean_round} sheet={sheet}") keep_cleaning = False + max_inner_iters = int(route_opts.get("cleanup_max_inner_iters", 64)) + net_changed = False + for net, segments in node.wires.items(): - while True: - # Split intersecting segments. - segments = split_segments(segments, net_pin_pts[net]) - - stop_reuse = True - if reuse_junctions: - segments, stop_reuse = _reuse_wire_junctions( - net, - segments, - net_pin_pts[net], - part_bboxes, - node.wires, - net_bboxes, + net_label = getattr(net, "name", str(net)) + # 先 split 一次,再只做 remove_jogs;避免「去 jog → split → 又出新 jog」振荡。 + segments = split_segments(segments, net_pin_pts[net]) + inner_iter = 0 + while inner_iter < max_inner_iters: + inner_iter += 1 + if inner_iter == 1 or inner_iter % 20 == 0: + plog( + f"[schematic] cleanup 网 {net_label} " + f"去 jog {inner_iter} sheet={sheet}" ) - - # Remove unnecessary wire jogs. segments, stop_jogs = remove_jogs( net, segments, node.wires, net_bboxes, part_bboxes ) - - # human_readable:压平 remove_jogs 未覆盖的局部小凸起,再走 merge/split。 - stop_detour = True - if human_readable: - segments, stop_detour = _straighten_local_detours( - net, - segments, - node.wires, - net_bboxes, - part_bboxes, - net_pin_pts[net], - ) - - stop = stop_jogs and stop_detour and stop_reuse - - # Keep only non zero-length segments. segments = [seg for seg in segments if seg.p1 != seg.p2] - - # Merge segments made colinear by removing jogs. - segments = merge_segments(segments) - - # Split intersecting segments. - segments = split_segments(segments, net_pin_pts[net]) - - # Keep only non zero-length segments. - segments = [seg for seg in segments if seg.p1 != seg.p2] - - # Trim wire stubs caused by removing jogs. - segments = trim_stubs(segments) - - if stop: - # Break from loop once net segments can no longer be improved. + if not stop_jogs: + net_changed = True + if stop_jogs: break + else: + plog( + f"[schematic] cleanup 网 {net_label} 去 jog 达上限 " + f"{max_inner_iters},sheet={sheet}" + ) - # Recalculate the net bounding box after modifying its segments. - net_bboxes[net] = segments_bbox(segments) - - keep_cleaning = True - - # Merge segments made colinear by removing jogs. segments = merge_segments(segments) + segments = split_segments(segments, net_pin_pts[net]) + segments = [seg for seg in segments if seg.p1 != seg.p2] + segments = trim_stubs(segments) - # Update the node net's wire with the cleaned version. + if human_readable: + segments, stop_detour = _straighten_local_detours( + net, + segments, + node.wires, + net_bboxes, + part_bboxes, + net_pin_pts[net], + ) + if not stop_detour: + net_changed = True + segments = merge_segments(segments) + segments = split_segments(segments, net_pin_pts[net]) + segments = [ + seg for seg in segments if seg.p1 != seg.p2 + ] + segments = trim_stubs(segments) + + net_bboxes[net] = segments_bbox(segments) node.wires[net] = segments + if net_changed: + keep_cleaning = True + + plog(f"[schematic] cleanup 结束 sheet={sheet},共 {clean_round} 轮") + if human_readable: # 在通用清理后追加保守的人类化处理:只做不改变连通性的局部简化。 + plog(f"[schematic] humanize_wires sheet={sheet}") node.humanize_wires() def humanize_wires(node): @@ -3714,6 +3733,7 @@ def route(node, tool=None, **options): seed = 0 random.seed(seed) node._route_options = options + sheet = getattr(node, "name", "?") # Remove any stuff leftover from a previous place & route run. node.rmv_routing_stuff() @@ -3735,6 +3755,12 @@ def route(node, tool=None, **options): return try: + _sch_progress( + options, + f"[schematic] 布线 sheet={sheet}:{len(node.parts)} 件 / " + f"{len(internal_nets)} 网", + ) + # Extend routing points of part pins to the edges of their bounding boxes. node.add_routing_points(internal_nets) @@ -3757,6 +3783,7 @@ def route(node, tool=None, **options): ) # Do global routing of nets internal to the node. + _sch_progress(options, f"[schematic] 全局路由 sheet={sheet} ...") global_routes = node.global_router(internal_nets) # Convert the global face-to-face routes into terminals on the switchboxes. @@ -3777,6 +3804,10 @@ def route(node, tool=None, **options): # Create detailed wiring using switchbox routing for the global routes. switchboxes = node.create_switchboxes(h_tracks, v_tracks) + _sch_progress( + options, + f"[schematic] switchbox 布线 sheet={sheet},{len(switchboxes)} 盒 ...", + ) # Draw switchboxes and routing channels. if options.get("draw_assigned_terminals"): @@ -3790,6 +3821,7 @@ def route(node, tool=None, **options): ) node.switchbox_router(switchboxes, **options) + _sch_progress(options, f"[schematic] switchbox 完成 sheet={sheet}") # If enabled, draw the global and detailed routing for debug purposes. if options.get("draw_switchbox_routing"): @@ -3803,8 +3835,11 @@ def route(node, tool=None, **options): ) # Now clean-up the wires and add junctions. + _sch_progress(options, f"[schematic] cleanup_wires sheet={sheet} ...") node.cleanup_wires() + _sch_progress(options, f"[schematic] add_junctions sheet={sheet}") node.add_junctions() + _sch_progress(options, f"[schematic] 布线完成 sheet={sheet}") # If enabled, draw the global and detailed routing for debug purposes. if options.get("draw_switchbox_routing"): diff --git a/src/skidl/tools/kicad9/gen_schematic.py b/src/skidl/tools/kicad9/gen_schematic.py index ed89592d..79ee0e08 100644 --- a/src/skidl/tools/kicad9/gen_schematic.py +++ b/src/skidl/tools/kicad9/gen_schematic.py @@ -609,6 +609,8 @@ def power_supply(vin, vout, gnd): options.setdefault("reuse_junctions", prefer_straight) # 美观优先策略默认开启 human_readable;显式传 False 可回退旧版随机布局。 options.setdefault("human_readable", True) + # 输出 place/route/cleanup 阶段日志,便于定位卡顿(不需要时设 schematic_progress=False) + options.setdefault("schematic_progress", True) # Part placement options that should always be turned on. options["use_push_pull"] = True @@ -625,6 +627,12 @@ def power_supply(vin, vout, gnd): failure_type = None for attempt in range(retries): + if options.get("schematic_progress", False): + active_logger.info( + f"[schematic] 第 {attempt + 1}/{retries} 次尝试," + f"expansion_factor={expansion_factor:.2f}" + ) + preprocess_circuit(circuit, **options) node = SchNode( @@ -632,10 +640,20 @@ def power_supply(vin, vout, gnd): ) try: + if options.get("schematic_progress", False): + active_logger.info("[schematic] 布局 place ...") node.place(expansion_factor=expansion_factor, **options) + if options.get("schematic_progress", False): + active_logger.info( + f"[schematic] 布局完成,顶层子页 {len(node.children)} 个" + ) if options.get("auto_stub", False): _classify_and_stub_complex_nets(circuit, node, **options) + if options.get("schematic_progress", False): + active_logger.info("[schematic] 布线 route ...") node.route(**options) + if options.get("schematic_progress", False): + active_logger.info("[schematic] 布线完成,写入原理图 ...") except PlacementFailure as e: finalize_parts_and_nets(circuit, **options) From 1f32521cc8a4d355c750152b254075878202cc8c Mon Sep 17 00:00:00 2001 From: Doris619619 Date: Thu, 21 May 2026 19:59:56 +0800 Subject: [PATCH 12/16] =?UTF-8?q?feat(sch):=20=E4=B8=BA=20human=5Freadable?= =?UTF-8?q?=20=E7=9A=84=20driver=20=E6=8B=93=E6=89=91=E5=A2=9E=E5=8A=A0=20?= =?UTF-8?q?rail=20=E5=B8=83=E5=B1=80=E4=B8=8E=E9=A2=84=E5=B8=83=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/skidl/circuit.py | 11 + src/skidl/schematics/place.py | 41 +- src/skidl/schematics/route.py | 296 +++- src/skidl/schematics/topology.py | 1273 +++++++++++++++++ src/skidl/schematics/trunk_layout.py | 374 +++++ src/skidl/tools/kicad9/gen_schematic.py | 16 + .../test_topology_generic_driver.py | 184 +++ update.md | 347 +---- 8 files changed, 2218 insertions(+), 324 deletions(-) create mode 100644 src/skidl/schematics/topology.py create mode 100644 src/skidl/schematics/trunk_layout.py create mode 100644 tests/unit_tests/test_topology_generic_driver.py diff --git a/src/skidl/circuit.py b/src/skidl/circuit.py index 8289e4db..f299b405 100644 --- a/src/skidl/circuit.py +++ b/src/skidl/circuit.py @@ -1322,6 +1322,17 @@ def _empty_footprint_handler(part): active_logger.report_summary("generating schematic") + # topology 摘要放在 schematic 阶段 warnings/errors 汇总之后,便于 grep 识别结果。 + sch_root = getattr(self, "_schematic_sch_root", None) + if sch_root is not None: + from skidl.schematics.topology import log_topology_summaries_deep + + # 收尾 topology 行紧跟 warnings/errors 汇总,不受 schematic_progress 关闭影响。 + topo_log_opts = dict(kwargs) + topo_log_opts["schematic_progress"] = True + log_topology_summaries_deep(sch_root, topo_log_opts) + delattr(self, "_schematic_sch_root") + def generate_dot( self, file_=None, diff --git a/src/skidl/schematics/place.py b/src/skidl/schematics/place.py index 885d01c8..062aa80f 100644 --- a/src/skidl/schematics/place.py +++ b/src/skidl/schematics/place.py @@ -32,6 +32,8 @@ def _sch_progress(options, message): draw_text, ) from skidl.geometry import BBox, Point, Segment, Tx, Vector +from .topology import apply_topology_or_trunk_layout +from .trunk_layout import build_part_adjacency, expand_main_ic_keepout __all__ = [ @@ -1150,7 +1152,8 @@ def group_parts(node, **options): return connected_parts, internal_nets, floating_parts - _ROW_PLACE_THRESHOLD = 20 + # 超过此数量的连通组走 rowbased+结构化 human_readable;TG032 等 ~10 件小板也需分区摆放。 + _ROW_PLACE_THRESHOLD = 8 def _part_ref_key(node, part): """Return a stable sort key for parts.""" @@ -1770,14 +1773,7 @@ def place_connected_parts_rowbased(node, parts, nets, **options): return # Build adjacency graph: part → set of neighbors. - part_set = set(real_parts) - adjacency = defaultdict(set) - for net in nets: - net_parts = [p for p in (pin.part for pin in net.pins) if p in part_set] - for i, p1 in enumerate(net_parts): - for p2 in net_parts[i + 1:]: - adjacency[id(p1)].add(p2) - adjacency[id(p2)].add(p1) + adjacency = build_part_adjacency(real_parts, nets) if human_readable: # 用稳定可读布局替代随机/机械 BFS,减少多次运行时版图漂移。 @@ -1787,7 +1783,9 @@ def place_connected_parts_rowbased(node, parts, nets, **options): roles = {part: node._classify_part_role(part) for part in real_parts} main_part = node._find_main_part(real_parts, adjacency=adjacency) + node._human_readable_main_part = main_part main_part.tx = Tx().move(Point(0, 0)) + expand_main_ic_keepout(main_part, GRID) main_bbox = main_part.place_bbox * main_part.tx def connected_to(part_a, part_b): @@ -1895,6 +1893,17 @@ def io_side_score(part): real_parts, adjacency, roles, main_part ) + apply_topology_or_trunk_layout( + node, + real_parts, + nets, + roles, + main_part, + grid=GRID, + blk_int_pad=BLK_INT_PAD, + **options, + ) + for part in real_parts: snap_to_grid(part) else: @@ -2016,6 +2025,20 @@ def place_connected_parts(node, parts, nets, **options): grid=GRID, blk_int_pad=BLK_INT_PAD, ) + roles = {part: node._classify_part_role(part) for part in real_parts} + main_part = node._find_main_part(real_parts) + node._human_readable_main_part = main_part + expand_main_ic_keepout(main_part, GRID) + apply_topology_or_trunk_layout( + node, + real_parts, + nets, + roles, + main_part, + grid=GRID, + blk_int_pad=BLK_INT_PAD, + **options, + ) for part in real_parts: snap_to_grid(part) diff --git a/src/skidl/schematics/route.py b/src/skidl/schematics/route.py index ea5245b1..60960e94 100644 --- a/src/skidl/schematics/route.py +++ b/src/skidl/schematics/route.py @@ -18,11 +18,30 @@ from skidl.utilities import export_to_all, rmv_attr from .debug_draw import draw_end, draw_endpoint, draw_routing, draw_seg, draw_start, draw_text from skidl.geometry import BBox, Point, Segment, Tx, Vector, tx_rot_90 +from .topology import ( + build_driver_rail_plan, + restore_driver_wire_nets, + topology_route_rank_bias, +) +from .trunk_layout import is_trunk_net_name, trunk_route_rank_bias __all__ = ["RoutingFailure", "GlobalRoutingFailure", "SwitchboxRoutingFailure"] +def _build_route_part_obstacles(node, human_readable=False): + """构建布线障碍 bbox;human_readable 下主控 IC 外扩 keepout。""" + grid = globals().get("GRID", 100) + main_part = getattr(node, "_human_readable_main_part", None) + obstacles = [] + for part in node.parts: + bbox = part.bbox * part.tx + if human_readable and part is main_part: + bbox = bbox.resize(Vector(grid * 1.5, grid * 1.5)) + obstacles.append((part, bbox)) + return obstacles + + def _sch_progress(options, message): """schematic_progress=True 时输出阶段日志,便于定位 place/route/cleanup 卡点。""" if options.get("schematic_progress", False): @@ -31,6 +50,184 @@ def _sch_progress(options, message): active_logger.info(message) +def _driver_rail_corridor_hits_bbox(bb, rail_y, x_min, x_max, grid, side="top"): + """与 topology._rail_corridor_intersects_bbox 相同,供预布线避让。""" + if x_max < bb.min.x or x_min > bb.max.x: + return False + if side == "top": + band_lo, band_hi = rail_y, rail_y + grid + else: + band_lo, band_hi = rail_y - grid, rail_y + return not (bb.max.y < band_lo or bb.min.y > band_hi) + + +def _shift_driver_rail_y(node, rail_y, x_min, x_max, grid, side, max_tries=5): + """预布线前再次外移 rail_y,避免水平线穿过器件 place_bbox。""" + real = [ + p + for p in node.parts + if getattr(p, "ref", None) and getattr(p, "place_bbox", None) and getattr(p, "tx", None) + ] + for _ in range(max_tries): + blocked = False + for part in real: + bb = part.place_bbox * part.tx + if _driver_rail_corridor_hits_bbox(bb, rail_y, x_min, x_max, grid, side): + blocked = True + break + if not blocked: + return rail_y + if side == "top": + rail_y -= grid + else: + rail_y += grid + return rail_y + + +def _refresh_driver_rail_plan(node, nets, options): + """布线前按当前 place_bbox 重算 rail 走廊(避免 place 与 route 两次 expansion 错位)。""" + topology = getattr(node, "_last_topology_result", None) or {} + if topology.get("kind") != "generic_driver" or topology.get("fallback") is not False: + return getattr(node, "_driver_rail_plan", None) or {"enabled": False} + parts = [p for p in node.parts if getattr(p, "ref", None)] + main = topology.get("main_part") or getattr(node, "_human_readable_main_part", None) + if not parts or main is None: + return {"enabled": False} + plan = build_driver_rail_plan(node, parts, nets, topology, main, **options) + node._driver_rail_plan = plan + return plan + + +def _driver_route_pins(node, net): + """driver 预布线用引脚:不受 auto_stub 的 pin.stub 影响。""" + from skidl.schematics.place import is_net_terminal + + return [ + pin + for pin in net.pins + if pin.part in node.parts and not is_net_terminal(pin.part) + ] + + +def route_driver_chain_local_nets(node, nets, **options): + """ + 主链行内网(含 Net-(D1-A)/Net-(D1-K)):水平短母线 + 竖 stub,不走 switchbox 绕框。 + """ + plan = getattr(node, "_driver_rail_plan", None) or {} + if not plan.get("enabled"): + return set() + row_parts = set(getattr(node, "_driver_chain_parts", set()) or []) + if not row_parts: + return set() + + grid = int(plan.get("grid", options.get("grid", 100))) + rail_handled = set(getattr(node, "_driver_rail_routed_nets", set()) or []) + mid_y = Point(0, (plan["top_y"] + plan["bottom_y"]) / 2).snap(grid).y + handled = set() + + for net in nets: + if net in rail_handled: + continue + pins = _driver_route_pins(node, net) + if len(pins) < 2: + continue + if {pin.part for pin in pins} - row_parts: + continue + + pin_pts = [(pin.pt * pin.part.tx).round() for pin in pins] + bus_y = mid_y + x_min = min(pt.x for pt in pin_pts) + x_max = max(pt.x for pt in pin_pts) + segs = [Segment(Point(x_min, bus_y), Point(x_max, bus_y))] + for pt in pin_pts: + stub_end = Point(pt.x, bus_y) + if pt != stub_end: + segs.append(Segment(copy.copy(pt), stub_end)) + node.wires[net] = segs + handled.add(net) + + if options.get("schematic_progress", False) and handled: + from skidl.logger import active_logger + + active_logger.info( + "[schematic] driver chain pre-route: %d nets %s" + % (len(handled), [_net_name_for_log(n) for n in handled]) + ) + return handled + + +def route_driver_rails(node, nets, **options): + """ + generic_driver rail 预布线:顶/底水平长线 + 引脚短竖 stub。 + 不经过 switchbox;handled 网由后续 global/switchbox 跳过。 + """ + plan = _refresh_driver_rail_plan(node, nets, options) + if not plan.get("enabled"): + return set() + if not options.get("driver_rail_routing", True): + return set() + if not options.get("human_readable", False): + return set() + + grid = int(plan.get("grid", options.get("grid", 100))) + handled = set() + top_set = set(plan.get("top_nets", [])) + bottom_set = set(plan.get("bottom_nets", [])) + net_set = set(nets) + + def _net_side(net): + if net in top_set: + return "top", plan["top_y"] + if net in bottom_set: + return "bottom", plan["bottom_y"] + return None, None + + for net in list(top_set) + list(bottom_set): + if net not in net_set: + continue + side, rail_y = _net_side(net) + if side is None: + continue + + pins = _driver_route_pins(node, net) + if not pins: + continue + + pin_pts = [(pin.pt * pin.part.tx).round() for pin in pins] + x_min = min(plan["x_min"], min(pt.x for pt in pin_pts)) + x_max = max(plan["x_max"], max(pt.x for pt in pin_pts)) + rail_y = _shift_driver_rail_y(node, rail_y, x_min, x_max, grid, side) + + segs = [Segment(Point(x_min, rail_y), Point(x_max, rail_y))] + for pt in pin_pts: + if pt.y == rail_y and pt.x >= x_min and pt.x <= x_max: + continue + stub_end = Point(pt.x, rail_y) + if pt != stub_end: + segs.append(Segment(copy.copy(pt), stub_end)) + + node.wires[net] = segs + handled.add(net) + + node._driver_rail_routed_nets = handled + chain_handled = route_driver_chain_local_nets(node, nets, **options) + handled |= chain_handled + node._driver_prerouted_nets = handled + if options.get("schematic_progress", False) and handled: + from skidl.logger import active_logger + + names = [_net_name_for_log(n) for n in handled] + active_logger.info( + "[schematic] driver rail pre-route: %d nets %s" + % (len(handled), names) + ) + return handled + + +def _net_name_for_log(net): + return str(getattr(net, "name", "") or "") + + ################################################################### # # OVERVIEW OF SCHEMATIC AUTOROUTER @@ -2083,12 +2280,16 @@ def _segment_obstructed(node, segment, net=None, ignored_parts=None): """Return True if a segment intersects another part or overlaps another net.""" ignored_parts = ignored_parts or set() + route_opts = getattr(node, "_route_options", {}) or {} + part_obstacles = _build_route_part_obstacles( + node, route_opts.get("human_readable", False) + ) segment_bbox = BBox(segment.p1, segment.p2) - for part in node.parts: + for part, part_bbox in part_obstacles: if part in ignored_parts: continue - if (part.bbox * part.tx).intersects(segment_bbox): + if part_bbox.intersects(segment_bbox): return True segment_bbox = segment_bbox.resize(Vector(2, 2)) @@ -2419,7 +2620,16 @@ def rank_net(net): bbox = BBox() for pin in node.get_internal_pins(net): bbox.add(pin.route_pt) - return (bbox.w + bbox.h, len(net.pins)) + name = str(getattr(net, "name", "") or "") + if human_readable: + topo = getattr(node, "_last_topology_result", None) + if topo and topo.get("kind") == "generic_driver" and topo.get("matched"): + route_bias = topology_route_rank_bias(net, topo) + else: + route_bias = trunk_route_rank_bias(name) + else: + route_bias = 0 + return (route_bias + bbox.w + bbox.h, len(net.pins)) # Set order in which nets will be routed. nets.sort(key=rank_net) @@ -3437,7 +3647,8 @@ def _reuse_wire_junctions(net, segments, net_pins, part_bboxes, wires, net_bboxe 每次至多改一处,便于与 merge/split/remove_jogs 交替收敛。 """ - snap_dist = GRID * 2 + net_name = str(getattr(net, "name", "") or "") + snap_dist = GRID * 3 if is_trunk_net_name(net_name) else GRID * 2 def manhattan_dist(a, b): return abs(a.x - b.x) + abs(a.y - b.y) @@ -3618,7 +3829,7 @@ def try_t_tap(segs, seg, ep, other): return segments, True # Get part bounding boxes so parts can be avoided when modifying net segments. - part_obstacles = [(part, part.bbox * part.tx) for part in node.parts] + part_obstacles = _build_route_part_obstacles(node, human_readable) part_bboxes = [bbox for _, bbox in part_obstacles] # Get dict of bounding boxes for the nets in this node. @@ -3628,7 +3839,10 @@ def try_t_tap(segs, seg, ep, other): net_internal_pins = dict() net_pin_pts = dict() for net in node.wires.keys(): - net_internal_pins[net] = list(node.get_internal_pins(net)) + pins = _driver_route_pins(node, net) + if not pins: + pins = list(node.get_internal_pins(net)) + net_internal_pins[net] = pins net_pin_pts[net] = [ (pin.pt * pin.part.tx).round() for pin in net_internal_pins[net] ] @@ -3638,8 +3852,17 @@ def try_t_tap(segs, seg, ep, other): f"{len(node.wires)} 网 / {sum(len(s) for s in node.wires.values())} 段" ) + prerouted = set(getattr(node, "_driver_prerouted_nets", set()) or []) + # Do a generalized cleanup of the wire segments of each net. for net, segments in node.wires.items(): + if net in prerouted: + segments = [seg.round() for seg in segments] + segments = [seg for seg in segments if seg.p1 != seg.p2] + order_seg_points(segments) + node.wires[net] = segments + continue + # Round the wire segment endpoints to integers. segments = [seg.round() for seg in segments] @@ -3700,6 +3923,12 @@ def try_t_tap(segs, seg, ep, other): for net, segments in node.wires.items(): net_label = getattr(net, "name", str(net)) + if net in prerouted: + # rail/主链预布线不再 split/去 jog,避免把水平母线拆成绕框折线。 + segments = [seg for seg in segments if seg.p1 != seg.p2] + node.wires[net] = segments + continue + # 先 split 一次,再只跑 remove_jogs;避免「去 jog → split → 又出现 jog」振荡。 segments = split_segments(segments, net_pin_pts[net]) @@ -3787,7 +4016,12 @@ def seg_key(seg): # 使用 part bbox 作为硬障碍,避免“美化”导致导线穿过器件。 part_bboxes = [p.bbox * p.tx for p in node.parts] + prerouted = set(getattr(node, "_driver_prerouted_nets", set()) or []) + for net, segments in node.wires.items(): + if net in prerouted: + continue + cleaned = [] for seg in segments: if seg.p1 == seg.p2: @@ -3799,6 +4033,42 @@ def seg_key(seg): # 先按几何顺序稳定排序,便于后续重复运行获得相同结果。 cleaned = sorted(cleaned, key=seg_key) + net_name = str(getattr(net, "name", "") or "") + if is_trunk_net_name(net_name) and len(cleaned) >= 2: + merged = [] + horz = sorted( + [s for s in cleaned if s.p1.y == s.p2.y], key=seg_key + ) + vert = sorted( + [s for s in cleaned if s.p1.x == s.p2.x], key=seg_key + ) + other = [s for s in cleaned if s not in horz and s not in vert] + + def merge_axis(segs, axis): + if not segs: + return [] + out = [segs[0]] + for seg in segs[1:]: + prev = out[-1] + if axis == "h" and prev.p1.y == seg.p1.y: + if prev.p2.x == seg.p1.x: + prev.p2 = Point(max(prev.p2.x, seg.p2.x), prev.p1.y) + continue + if seg.p2.x == prev.p1.x: + prev.p1 = Point(min(prev.p1.x, seg.p1.x), prev.p1.y) + continue + if axis == "v" and prev.p1.x == seg.p1.x: + if prev.p2.y == seg.p1.y: + prev.p2 = Point(prev.p1.x, max(prev.p2.y, seg.p2.y)) + continue + if seg.p2.y == prev.p1.y: + prev.p1 = Point(prev.p1.x, min(prev.p1.y, seg.p1.y)) + continue + out.append(seg) + return out + + cleaned = merge_axis(horz, "h") + merge_axis(vert, "v") + other + # 删除非常短的 stub,保留连接主干的必要线段。 # Segment 无 length 属性;用端点差的 magnitude(正交线段即几何长度)。 stub_thresh = max(1, GRID // 2) @@ -3927,7 +4197,14 @@ def route(node, tool=None, **options): if not internal_nets: return + restore_driver_wire_nets(node, internal_nets, **options) + try: + if options.get("human_readable"): + real_parts = [p for p in node.parts if getattr(p, "ref", None)] + if real_parts and not getattr(node, "_human_readable_main_part", None): + node._human_readable_main_part = node._find_main_part(real_parts) + _sch_progress( options, f"[schematic] 布线 sheet={sheet},{len(node.parts)} 件 / " @@ -3938,7 +4215,12 @@ def route(node, tool=None, **options): # Priority 1: directly route aligned point-to-point nets when unobstructed. direct_nets = set(node.route_straight_nets(internal_nets)) - routed_nets = [net for net in internal_nets if net not in direct_nets] + rail_handled = route_driver_rails(node, internal_nets, **options) + routed_nets = [ + net + for net in internal_nets + if net not in direct_nets and net not in rail_handled + ] if not routed_nets: node.cleanup_wires() diff --git a/src/skidl/schematics/topology.py b/src/skidl/schematics/topology.py new file mode 100644 index 00000000..c4bade0b --- /dev/null +++ b/src/skidl/schematics/topology.py @@ -0,0 +1,1273 @@ +# -*- coding: utf-8 -*- + +""" +human_readable 模式下的功能拓扑识别(首版:generic driver)。 +与 trunk-aware 布局互斥:matched 时仅 apply_generic_driver_layout,否则 apply_trunk_aware_layout。 +""" + +import re +from collections import defaultdict + +from skidl.geometry import BBox, Point, Tx +from skidl.schematics.trunk_layout import ( + _place_parts_in_column, + _place_parts_in_row, + _resolve_overlaps, + _set_part_center_x_safe, + apply_trunk_aware_layout, + classify_trunk_nets, +) + +# 网名 / pin 名 token(双通道分类) +_INPUT_TOKENS = ("VIN", "VCC", "VDD", "VM", "VBAT", "24V", "12V", "5V", "3V3", "SUPPLY", "POWER") +_GROUND_TOKENS = ("GND", "VSS", "PGND", "AGND", "DGND") +# 顶/底 power rail 网名 token(与 control/switch 分离) +_TOP_RAIL_TOKENS = _INPUT_TOKENS + ("W+", "LED+") +_BOTTOM_RAIL_TOKENS = _GROUND_TOKENS + ("W-", "LED-") +_OUTPUT_TOKENS = ("OUT", "OUTPUT", "LOAD", "LED", "MOTOR", "W+", "W-", "AOUT", "BOUT") +_CONTROL_TOKENS = ("PWM", "DIM", "EN", "ENABLE", "CTRL", "IN1", "IN2", "SLEEP", "FAULT") +_SWITCH_TOKENS = ("SW", "LX", "PH", "DRV", "GATE", "HO", "LO") +_SENSE_TOKENS = ("FB", "CS", "CSN", "CSP", "SENSE", "ISEN", "COMP") + +_WEAK_IC_HINTS = ("DRIVER", "DRV", "LED", "MOTOR", "PT", "XL", "MP", "TPS", "IRS") +_LOW_R_VALUE_RE = re.compile( + r"(^0\s*R|^0R|^0\.|MR|R050|0\.43|(^|[^0-9])1R([^0-9]|$))", + re.IGNORECASE, +) + + +def _token_in_text(text, tokens): + """token 作为独立词或常见分隔片段出现在 text 中。""" + if not text: + return False + upper = str(text).upper() + for token in tokens: + if token in upper: + return True + return False + + +def _net_label(net): + return str(getattr(net, "name", "") or "") + + +def _disabled_topology(fallback="trunk_aware"): + return { + "kind": "disabled", + "matched": False, + "confidence": 0, + "main_part": None, + "input_nets": [], + "output_nets": [], + "power_nets": [], + "ground_nets": [], + "control_nets": [], + "switch_or_drive_nets": [], + "sense_or_feedback_nets": [], + "input_parts": set(), + "output_parts": set(), + "power_loop_parts": set(), + "control_parts": set(), + "sense_feedback_parts": set(), + "fallback": fallback, + "reasons": ["topology_detection disabled"], + } + + +def _empty_topology(kind, confidence, main_part=None, reasons=None, fallback="trunk_aware"): + return { + "kind": kind, + "matched": kind == "generic_driver", + "confidence": confidence, + "main_part": main_part, + "input_nets": [], + "output_nets": [], + "power_nets": [], + "ground_nets": [], + "control_nets": [], + "switch_or_drive_nets": [], + "sense_or_feedback_nets": [], + "input_parts": set(), + "output_parts": set(), + "power_loop_parts": set(), + "control_parts": set(), + "sense_feedback_parts": set(), + "fallback": fallback, + "reasons": reasons or [], + } + + +def _topology_options(options): + """仅在 human_readable 下启用 topology_detection。""" + enabled = bool(options.get("human_readable", False)) and bool( + options.get("topology_detection", True) + ) + return { + "enabled": enabled, + "strong_threshold": int(options.get("topology_confidence_threshold", 60)), + "weak_threshold": int(options.get("topology_weak_threshold", 40)), + "gap": options.get("topology_gap"), + } + + +def _candidate_ic_parts(parts, roles): + """候选主控 IC:U* 或 role ic 且 pin 数较多。""" + candidates = [] + for part in parts: + ref = str(getattr(part, "ref", "") or "").upper() + role = roles.get(part, "other") + pin_count = len(getattr(part, "pins", [])) + if ref.startswith("U") or role == "ic": + if pin_count >= 4: + candidates.append(part) + if not candidates: + for part in parts: + if roles.get(part) == "ic": + candidates.append(part) + return candidates + + +def _pins_on_part_for_net(node, part, net, part_set): + """返回 part 在 net 上的 pin 名列表。""" + names = [] + for pin in getattr(net, "pins", []): + p = getattr(pin, "part", None) + if p is part and (part_set is None or p in part_set): + names.append(str(getattr(pin, "name", "") or "").upper()) + return names + + +def _classify_net_semantic(net, main_part, node, part_set, adjacency): + """ + 按 net 名 + main_part 上 pin 名推断语义类别。 + 返回 set of: input, ground, output, control, switch, sense + """ + net_name = _net_label(net).upper() + categories = set() + + if _token_in_text(net_name, _INPUT_TOKENS): + categories.add("input") + if _token_in_text(net_name, _GROUND_TOKENS) or ( + node._is_power_net_name(net_name) and any(t in net_name for t in _GROUND_TOKENS) + ): + categories.add("ground") + if _token_in_text(net_name, _OUTPUT_TOKENS): + categories.add("output") + if _token_in_text(net_name, _CONTROL_TOKENS): + categories.add("control") + if _token_in_text(net_name, _SWITCH_TOKENS): + categories.add("switch") + if _token_in_text(net_name, _SENSE_TOKENS): + categories.add("sense") + + if main_part is not None: + pin_names = _pins_on_part_for_net(node, main_part, net, part_set) + for pname in pin_names: + if _token_in_text(pname, _INPUT_TOKENS): + categories.add("input") + if _token_in_text(pname, _GROUND_TOKENS): + categories.add("ground") + if _token_in_text(pname, _OUTPUT_TOKENS): + categories.add("output") + if _token_in_text(pname, _CONTROL_TOKENS): + categories.add("control") + if _token_in_text(pname, _SWITCH_TOKENS): + categories.add("switch") + if _token_in_text(pname, _SENSE_TOKENS): + categories.add("sense") + + # SW 需绑主 IC pin 或邻接 L/D 才计 switch(避免单独 SW 网名误判) + if "switch" in categories and main_part is not None: + pin_names = _pins_on_part_for_net(node, main_part, net, part_set) + on_main_sw = any(_token_in_text(p, _SWITCH_TOKENS) for p in pin_names) + if not on_main_sw: + net_parts = node._net_connected_parts(net, allowed_parts=part_set) + has_ld = any( + str(getattr(p, "ref", "") or "").upper().startswith(("L", "D", "Q")) + for p in net_parts + ) + if not has_ld: + categories.discard("switch") + + return categories + + +def _score_candidate_ic(node, candidate, parts, nets, roles, part_set, adjacency): + """对单颗候选 IC 计算 driver 特征分与 reasons。""" + score = 0 + reasons = [] + feature_flags = { + "input": False, + "ground": False, + "output_switch": False, + "control": False, + "sense": False, + "inductor": False, + "diode": False, + "out_connector": False, + "sense_r": False, + "weak_hint": False, + } + + cand_nets = set() + for net in nets: + net_parts = node._net_connected_parts(net, allowed_parts=part_set) + if candidate not in net_parts: + continue + cand_nets.add(net) + cats = _classify_net_semantic(net, candidate, node, part_set, adjacency) + pin_names = _pins_on_part_for_net(node, candidate, net, part_set) + + if "input" in cats or _token_in_text(" ".join(pin_names), _INPUT_TOKENS): + if not feature_flags["input"]: + score += 2 + feature_flags["input"] = True + reasons.append("input_pin_or_net") + if "ground" in cats: + if not feature_flags["ground"]: + score += 2 + feature_flags["ground"] = True + reasons.append("ground") + if "output" in cats or "switch" in cats: + if not feature_flags["output_switch"]: + score += 3 + feature_flags["output_switch"] = True + reasons.append("output_or_switch") + if "control" in cats: + # PWM 等需与其它强特征组合;此处只记 control 特征位 + feature_flags["control"] = True + if "sense" in cats: + if not feature_flags["sense"]: + score += 2 + feature_flags["sense"] = True + reasons.append("sense_fb") + + # control 加分:仅当已有 input/ground/output_switch/sense 之一 + if feature_flags["control"] and any( + feature_flags[k] + for k in ("input", "ground", "output_switch", "sense") + ): + score += 1 + reasons.append("control_with_power") + + for part in parts: + if part is candidate: + continue + ref = str(getattr(part, "ref", "") or "").upper() + value = str(getattr(part, "value", "") or "").upper() + name = str(getattr(part, "name", "") or "").upper() + connected = False + for net in nets: + net_parts = node._net_connected_parts(net, allowed_parts=part_set) + if part in net_parts and candidate in net_parts: + connected = True + break + if not connected and adjacency: + if part not in adjacency.get(id(candidate), set()): + continue + + if ref.startswith("L") and not feature_flags["inductor"]: + score += 2 + feature_flags["inductor"] = True + reasons.append("inductor_near") + if ref.startswith("D") and not feature_flags["diode"]: + score += 1 + feature_flags["diode"] = True + reasons.append("diode_near") + + if ref.startswith(("J", "P", "CN")) and roles.get(part) == "connector": + net_names = [n.upper() for n in node._net_names_of(part)] + if any(_token_in_text(n, _OUTPUT_TOKENS) for n in net_names): + if not feature_flags["out_connector"]: + score += 2 + feature_flags["out_connector"] = True + reasons.append("output_connector") + + if ref.startswith("R") and _LOW_R_VALUE_RE.search(value.replace(" ", "")): + if not feature_flags["sense_r"]: + score += 1 + feature_flags["sense_r"] = True + reasons.append("sense_resistor") + + name = str(getattr(candidate, "name", "") or "").upper() + value = str(getattr(candidate, "value", "") or "").upper() + ic_text = f"{value} {name}".upper() + for hint in _WEAK_IC_HINTS: + if hint in ic_text: + if not feature_flags["weak_hint"]: + score += 1 + feature_flags["weak_hint"] = True + reasons.append(f"weak_hint:{hint}") + break + + # 组合约束:至少 3 类强特征,且含 output/switch 或 input+ground + strong_categories = sum( + 1 + for k in ("input", "ground", "output_switch", "sense", "inductor") + if feature_flags[k] + ) + has_power_path = feature_flags["input"] and ( + feature_flags["ground"] or feature_flags["output_switch"] + ) + combo_ok = strong_categories >= 3 and ( + feature_flags["output_switch"] or has_power_path + ) + + confidence = min(100, score * 5) + return score, confidence, reasons, combo_ok, feature_flags + + +def _build_net_lists(node, candidate, parts, nets, part_set, adjacency): + """基于候选 main 做网级语义分类。""" + buckets = { + "input_nets": [], + "output_nets": [], + "power_nets": [], + "ground_nets": [], + "control_nets": [], + "switch_or_drive_nets": [], + "sense_or_feedback_nets": [], + } + seen = set() + for net in nets: + if net in seen: + continue + net_parts = node._net_connected_parts(net, allowed_parts=part_set) + if candidate not in net_parts and len(net_parts) < 2: + continue + cats = _classify_net_semantic(net, candidate, node, part_set, adjacency) + if not cats: + continue + seen.add(net) + if "input" in cats: + buckets["input_nets"].append(net) + buckets["power_nets"].append(net) + if "ground" in cats: + buckets["ground_nets"].append(net) + if "output" in cats: + buckets["output_nets"].append(net) + if "control" in cats: + buckets["control_nets"].append(net) + if "switch" in cats: + buckets["switch_or_drive_nets"].append(net) + if "sense" in cats: + buckets["sense_or_feedback_nets"].append(net) + return buckets + + +def _assign_topology_part_groups(node, parts, roles, topology, part_set): + """按已分类 net 将器件归入各功能区。""" + net_sets = { + "input": set(topology["input_nets"]), + "output": set(topology["output_nets"]), + "switch": set(topology["switch_or_drive_nets"]), + "control": set(topology["control_nets"]), + "sense": set(topology["sense_or_feedback_nets"]), + "ground": set(topology["ground_nets"]), + } + main = topology.get("main_part") + + def touches(part, key): + for net in net_sets.get(key, ()): + if part in node._net_connected_parts(net, allowed_parts=part_set): + return True + return False + + for part in parts: + if part is main: + continue + ref = str(getattr(part, "ref", "") or "").upper() + role = roles.get(part, "other") + + if touches(part, "input") and ( + ref[:1] in ("C", "D") or role == "connector" + ): + topology["input_parts"].add(part) + if touches(part, "output") or ( + role == "connector" and touches(part, "output") + ): + topology["output_parts"].add(part) + if touches(part, "switch") or ( + ref.startswith(("L", "D", "Q")) and touches(part, "switch") + ): + topology["power_loop_parts"].add(part) + if touches(part, "control") or ( + ref.startswith(("R", "C")) and touches(part, "control") + ): + topology["control_parts"].add(part) + if touches(part, "sense") and ref.startswith(("R", "C")): + topology["sense_feedback_parts"].add(part) + if touches(part, "ground") and ref.startswith("C"): + # 地相关去耦可偏下,由布局 Y 处理 + pass + + # 输出侧 L/D/C 连 output 或 switch + for part in parts: + if part is main: + continue + ref = str(getattr(part, "ref", "") or "").upper() + if ref.startswith(("L", "D")) and ( + touches(part, "output") or touches(part, "switch") + ): + topology["power_loop_parts"].add(part) + if ref.startswith("C") and touches(part, "output"): + topology["output_parts"].add(part) + + +def _part_ref_prefix(part): + """取器件前缀,便于按 L/D/C/R/J 等做轻度分型。""" + return str(getattr(part, "ref", "") or "").upper()[:1] + + +def _part_width(part, grid): + return max(getattr(part.place_bbox, "w", 0), grid) + + +def _row_total_width(parts, gap, grid): + if not parts: + return 0 + return sum(_part_width(part, grid) for part in parts) + max(0, len(parts) - 1) * gap + + +def _build_driver_chain_order(node, roles, topology, main_part): + """ + 主功率链顺序:输入 C/D -> 主 IC -> 电感 -> 输出连接器。 + buck/LED driver 手画图通常沿这条水平线阅读。 + """ + def by_ref(parts_): + return sorted(parts_, key=node._part_ref_key) + + left = [] + for part in topology.get("input_parts", set()): + if part is main_part: + continue + if _part_ref_prefix(part) in ("C", "D"): + left.append(part) + for part in topology.get("power_loop_parts", set()): + if _part_ref_prefix(part) == "D" and part not in left: + left.append(part) + left = by_ref([p for p in left if _part_ref_prefix(p) == "C"]) + by_ref( + [p for p in left if _part_ref_prefix(p) == "D"] + ) + + right = [] + for part in topology.get("power_loop_parts", set()): + if _part_ref_prefix(part) == "L": + right.append(part) + for part in topology.get("output_parts", set()): + if roles.get(part) == "connector": + right.append(part) + right = by_ref(right) + + chain = left + [main_part] + right + return chain, set(chain) + + +def _chain_row_start_x(node, chain, main_part, gap, grid): + """让 main_part 大致留在当前 X,向左排开整条主链。""" + main_ctr = node._placement_ctr(main_part) + x_before = 0 + for part in chain: + if part is main_part: + break + x_before += _part_width(part, grid) + gap + return main_ctr.x - x_before + + +def _is_anonymous_net(net): + """内部匿名网 Net-(...) 不参与 rail 规划/预布线。""" + name = _net_label(net).strip().upper() + return name.startswith("NET-(") or name.startswith("NET_(") + + +def _is_rail_label_net(net): + """具名网才进入 top/bottom rail 候选。""" + if _is_anonymous_net(net): + return False + return bool(_net_label(net).strip()) + + +def _dedupe_nets(nets): + seen = set() + out = [] + for net in nets: + if net in seen: + continue + seen.add(net) + out.append(net) + return out + + +def _collect_driver_rail_nets(nets, topology, node, main_part, part_set): + """ + 从 topology 桶 + 网名/pin 语义收集 top/bottom/control rail 网表。 + control/switch 不进长水平 rail。 + """ + top = [] + bottom = [] + control = list(topology.get("control_nets", [])) + control_ids = {id(n) for n in control} + switch_ids = {id(n) for n in topology.get("switch_or_drive_nets", [])} + + for net in nets: + if not _is_rail_label_net(net): + continue + if id(net) in switch_ids: + continue + name = _net_label(net).upper() + cats = _classify_net_semantic(net, main_part, node, part_set, None) + + if id(net) in control_ids or "control" in cats or _token_in_text( + name, _CONTROL_TOKENS + ): + if net not in control: + control.append(net) + continue + if "switch" in cats or _token_in_text(name, _SWITCH_TOKENS): + continue + + if _token_in_text(name, _BOTTOM_RAIL_TOKENS) or "ground" in cats: + bottom.append(net) + continue + if _token_in_text(name, _TOP_RAIL_TOKENS) or "input" in cats: + top.append(net) + continue + if net in topology.get("ground_nets", []): + bottom.append(net) + elif net in topology.get("input_nets", []) or net in topology.get( + "power_nets", [] + ): + top.append(net) + elif net in topology.get("output_nets", []): + if _token_in_text(name, ("LED+", "W+", "LED", "OUT+")): + top.append(net) + elif _token_in_text(name, ("LED-", "W-")): + bottom.append(net) + + return _dedupe_nets(top), _dedupe_nets(bottom), _dedupe_nets(control) + + +def _union_placed_bbox(parts): + """合并已放置 real parts 的 place_bbox。""" + bb = BBox(Point(0, 0), Point(0, 0)) + any_part = False + for part in parts: + if getattr(part, "place_bbox", None) is None or getattr(part, "tx", None) is None: + continue + bb.add(part.place_bbox * part.tx) + any_part = True + if not any_part: + return None + return bb + + +def _rail_corridor_intersects_bbox(bb, rail_y, x_min, x_max, grid, side="top"): + """水平 rail 走廊(宽 GRID)是否与器件 place_bbox 相交。""" + if x_max < bb.min.x or x_min > bb.max.x: + return False + if side == "top": + band_lo, band_hi = rail_y, rail_y + grid + else: + band_lo, band_hi = rail_y - grid, rail_y + return not (bb.max.y < band_lo or bb.min.y > band_hi) + + +def _find_clear_rail_y(node, parts, rail_y, x_min, x_max, grid, side, max_tries=5): + """若走廊压到器件 bbox,沿外侧逐格偏移 rail_y(最多 5 次)。""" + for _ in range(max_tries): + blocked = False + for part in parts: + if getattr(part, "place_bbox", None) is None or getattr(part, "tx", None) is None: + continue + bb = part.place_bbox * part.tx + if _rail_corridor_intersects_bbox(bb, rail_y, x_min, x_max, grid, side): + blocked = True + break + if not blocked: + return rail_y + if side == "top": + rail_y -= grid + else: + rail_y += grid + return rail_y + + +def build_driver_rail_plan(node, parts, nets, topology, main_part, **options): + """ + generic_driver 且 fallback=False 时生成水平 rail 计划。 + 结果供 route.py 预布线与布局走廊校验使用。 + """ + disabled = { + "enabled": False, + "top_nets": [], + "bottom_nets": [], + "control_nets": [], + "top_y": 0, + "bottom_y": 0, + "x_min": 0, + "x_max": 0, + } + if topology.get("kind") != "generic_driver" or topology.get("fallback") is not False: + return disabled + if not options.get("human_readable", False): + return disabled + if not options.get("driver_rail_routing", True): + return disabled + + grid = int(options.get("grid", 100)) + rail_margin = 2 * grid + part_set = set(parts) + top_nets, bottom_nets, control_nets = _collect_driver_rail_nets( + nets, topology, node, main_part, part_set + ) + if not top_nets and not bottom_nets: + return disabled + + real_parts = [ + p + for p in parts + if getattr(p, "place_bbox", None) is not None and getattr(p, "tx", None) is not None + ] + union = _union_placed_bbox(real_parts) + if union is None: + return disabled + + x_min = Point(union.min.x, 0).snap(grid).x - grid + x_max = Point(union.max.x, 0).snap(grid).x + grid + top_y = Point(0, union.min.y).snap(grid).y - rail_margin + bottom_y = Point(0, union.max.y).snap(grid).y + rail_margin + + top_y = _find_clear_rail_y( + node, real_parts, top_y, x_min, x_max, grid, side="top" + ) + bottom_y = _find_clear_rail_y( + node, real_parts, bottom_y, x_min, x_max, grid, side="bottom" + ) + + return { + "enabled": True, + "top_nets": top_nets, + "bottom_nets": bottom_nets, + "control_nets": control_nets, + "top_y": top_y, + "bottom_y": bottom_y, + "x_min": x_min, + "x_max": x_max, + "grid": grid, + } + + +def _log_driver_rails(plan, options): + if not options.get("schematic_progress", False) or not plan.get("enabled"): + return + from skidl.logger import active_logger + + top_names = [_net_label(n) for n in plan.get("top_nets", [])] + bottom_names = [_net_label(n) for n in plan.get("bottom_nets", [])] + active_logger.info( + "[schematic] driver rails: top=%s, bottom=%s, top_y=%s, bottom_y=%s, x=(%s, %s)" + % ( + top_names, + bottom_names, + plan.get("top_y"), + plan.get("bottom_y"), + plan.get("x_min"), + plan.get("x_max"), + ) + ) + + +def _log_rail_blockers(node, parts, plan, options): + """若 place_bbox 仍与 rail 走廊相交,输出 blocker 便于调试。""" + if not options.get("schematic_progress", False) or not plan.get("enabled"): + return + from skidl.logger import active_logger + + grid = plan.get("grid", 100) + x_min = plan["x_min"] + x_max = plan["x_max"] + for side, rail_y in (("top", plan["top_y"]), ("bottom", plan["bottom_y"])): + for part in parts: + if getattr(part, "place_bbox", None) is None or getattr(part, "tx", None) is None: + continue + bb = part.place_bbox * part.tx + if _rail_corridor_intersects_bbox(bb, rail_y, x_min, x_max, grid, side): + active_logger.info( + "[schematic] driver rail blocker: ref=%s bbox=%s rail=%s" + % (getattr(part, "ref", ""), bb, side) + ) + + +def _part_on_net_set(part, net_set): + for pin in getattr(part, "pins", []): + if getattr(pin, "net", None) in net_set: + return True + return False + + +def _chain_row_satellite_parts(node, parts, chain_parts, nets): + """ + 与主链器件共网、但不在 chain 内的 R/C(如 R1、输入侧小电容), + 应排在主链同一水平行,避免 switch 网被 switchbox 绕外围。 + """ + chain_set = set(chain_parts) + part_set = set(parts) + satellites = [] + for net in nets: + connected = node._net_connected_parts(net, allowed_parts=part_set) + if not connected or not chain_set.intersection(connected): + continue + for part in connected: + if part in chain_set: + continue + if _part_ref_prefix(part) not in ("R", "C"): + continue + if part not in satellites: + satellites.append(part) + return sorted(satellites, key=node._part_ref_key) + + +def _insert_satellites_into_row(node, chain, satellites, nets): + """把 satellite 插到与其共网的 chain 器件右侧,保持阅读顺序。""" + row = list(chain) + known = set(row) + for sat in satellites: + insert_at = len(row) + for idx, cp in enumerate(row): + for net in nets: + con = set( + node._net_connected_parts(net, allowed_parts=known | {sat}) + ) + if sat in con and cp in con: + insert_at = max(insert_at, idx + 1) + row.insert(insert_at, sat) + known.add(sat) + return row + + +def _led_rail_decoupling_caps(parts, top_set, bottom_set, chain_parts): + """LED+/LED- 去耦电容:不放进主链行,改贴主控两侧(两 rail 之间)。""" + caps = [] + for part in parts: + if part in chain_parts or _part_ref_prefix(part) != "C": + continue + on_top = _part_on_net_set(part, top_set) + on_bot = _part_on_net_set(part, bottom_set) + if on_top or on_bot: + caps.append(part) + return caps + + +def apply_driver_rail_safe_placement( + node, parts, nets, roles, main_part, topology, chain, chain_parts, **options +): + """ + rail 安全后处理:主链居中于 top/bottom 走廊之间; + 顶/底网器件不压在 rail_y 上,控制支路留在中部侧边。 + """ + grid = int(options.get("grid", 100)) + gap = options.get("topology_gap") or options.get( + "trunk_gap", max(int(options.get("blk_int_pad", 100)), grid * 2) + ) + blk_pad = int(options.get("blk_int_pad", 100)) + part_set = set(parts) + + real_parts = [ + p + for p in parts + if getattr(p, "place_bbox", None) is not None and getattr(p, "tx", None) is not None + ] + union = _union_placed_bbox(real_parts) + if union is None: + return + + top_y = Point(0, union.min.y).snap(grid).y - 2 * grid + bottom_y = Point(0, union.max.y).snap(grid).y + 2 * grid + mid_y = Point(0, (top_y + bottom_y) / 2).snap(grid).y + + top_nets, bottom_nets, _control = _collect_driver_rail_nets( + nets, topology, node, main_part, part_set + ) + top_set = set(top_nets) + bottom_set = set(bottom_nets) + + satellites = _chain_row_satellite_parts(node, parts, chain_parts, nets) + row = _insert_satellites_into_row(node, chain, satellites, nets) + row_parts = set(row) + + # 主功率链 + 同行卫星件:水平居中,不占用顶/底 rail 线。 + if row: + start_x = _chain_row_start_x(node, chain, main_part, gap, grid) + _place_parts_in_row(node, row, start_x, mid_y, gap, grid) + + node._driver_chain_parts = row_parts + + def _nudge_y(part, target_cy): + ctr = node._placement_ctr(part) + snapped = Point(ctr.x, target_cy).snap(grid) + dy = snapped.y - ctr.y + if dy: + part.tx *= Tx(dx=0, dy=dy) + + decoup_caps = _led_rail_decoupling_caps(parts, top_set, bottom_set, row_parts) + + for part in parts: + if part in row_parts or part is main_part or part in decoup_caps: + continue + h = max(getattr(part.place_bbox, "h", 0), grid) + if _part_on_net_set(part, top_set) and not _part_on_net_set(part, bottom_set): + _nudge_y(part, top_y + grid + h / 2) + elif _part_on_net_set(part, bottom_set) and not _part_on_net_set(part, top_set): + _nudge_y(part, bottom_y - grid - h / 2) + + # 控制支路:主控右侧中部,避免拉到顶/底 rail。 + control_parts = sorted( + [p for p in topology.get("control_parts", set()) if p not in chain_parts], + key=node._part_ref_key, + ) + if control_parts: + main_bbox = main_part.place_bbox * main_part.tx + ctrl_x = main_bbox.max.x + gap * 2 + ctrl_y = mid_y + gap + _place_parts_in_row(node, control_parts, ctrl_x, ctrl_y, gap, grid) + + # LED+/LED- 去耦:贴在主控右侧、两 rail 之间竖排,避免甩到图纸底部。 + if decoup_caps: + main_bbox = main_part.place_bbox * main_part.tx + cx = main_bbox.max.x + gap * 2 + y_cursor = top_y + grid + for cap in sorted(decoup_caps, key=node._part_ref_key): + h = max(getattr(cap.place_bbox, "h", 0), grid) + _nudge_y(cap, y_cursor + h / 2) + ctr = node._placement_ctr(cap) + snapped_x = Point(cx, ctr.y).snap(grid).x + dx = snapped_x - ctr.x + if dx: + cap.tx *= Tx(dx=dx, dy=0) + y_cursor += h + gap + + _resolve_overlaps(node, parts, grid, max(gap, blk_pad), exclude=row_parts) + + +def driver_wire_preserve_net_set(node, nets=None, **options): + """ + generic_driver + driver_rail_routing 时应保留物理导线的网表。 + 含 rail 顶/底网、主链行内网(含 Net-(D1-*) 等匿名网)。 + """ + if not options.get("driver_rail_routing", True): + return set() + if not options.get("human_readable", False): + return set() + topology = getattr(node, "_last_topology_result", None) or {} + if topology.get("kind") != "generic_driver" or topology.get("fallback") is not False: + return set() + + plan = getattr(node, "_driver_rail_plan", None) or {} + preserve = set(plan.get("top_nets", [])) | set(plan.get("bottom_nets", [])) + + row_parts = set(getattr(node, "_driver_chain_parts", set()) or []) + if nets and row_parts: + from skidl.schematics.place import is_net_terminal + + for net in nets: + pins = [ + p + for p in net.pins + if p.part in node.parts and not is_net_terminal(p.part) + ] + if len(pins) < 2: + continue + if {p.part for p in pins}.issubset(row_parts): + preserve.add(net) + return preserve + + +def restore_driver_wire_nets(node, nets=None, **options): + """取消 driver 保留网的 stub,使预布线与 KiCad wire 能写出。""" + if nets is None: + nets = node.get_internal_nets() + preserve = driver_wire_preserve_net_set(node, nets, **options) + for net in preserve: + net._stub = False + for pin in net.pins: + if pin.part in node.parts: + pin.stub = False + return preserve + + +def restore_driver_wire_nets_deep(node, **options): + """递归子页恢复 driver 保留网的 wire 模式。""" + for child in node.children.values(): + restore_driver_wire_nets_deep(child, **options) + restore_driver_wire_nets(node, **options) + + +def detect_generic_driver_topology( + node, parts, nets, roles, main_part, trunk_map=None, adjacency=None, **options +): + """打分识别 generic driver,返回完整 topology dict。""" + topo_opts = _topology_options(options) + part_set = set(parts) + if adjacency is None: + from skidl.schematics.trunk_layout import build_part_adjacency + + adjacency = build_part_adjacency(parts, nets) + + candidates = _candidate_ic_parts(parts, roles) + if not candidates: + return _empty_topology( + "unrecognized", 0, main_part=main_part, reasons=["no_ic_candidate"] + ) + + best = None + best_conf = -1 + best_score = 0 + best_reasons = [] + best_combo = False + + for cand in candidates: + sc, conf, reasons, combo, _flags = _score_candidate_ic( + node, cand, parts, nets, roles, part_set, adjacency + ) + if conf > best_conf or (conf == best_conf and sc > best_score): + best = cand + best_conf = conf + best_score = sc + best_reasons = reasons + best_combo = combo + + strong_th = topo_opts["strong_threshold"] + weak_th = topo_opts["weak_threshold"] + + if not best_combo or best_conf < weak_th: + kind = "unrecognized" + fallback = "trunk_aware" + elif best_conf < strong_th: + kind = "weak_generic_driver" + fallback = "trunk_aware" + else: + kind = "generic_driver" + fallback = False + + topology = _empty_topology(kind, best_conf, main_part=best, reasons=best_reasons, fallback=fallback) + if kind == "unrecognized": + return topology + + net_buckets = _build_net_lists(node, best, parts, nets, part_set, adjacency) + for key, val in net_buckets.items(): + topology[key] = val + + _assign_topology_part_groups(node, parts, roles, topology, part_set) + return topology + + +def detect_known_topology( + node, parts, nets, roles, main_part, trunk_map=None, **options +): + """拓扑识别门面;当前仅 generic_driver detector。""" + topo_opts = _topology_options(options) + if not topo_opts["enabled"]: + return _disabled_topology() + + adjacency = None + if parts and nets: + from skidl.schematics.trunk_layout import build_part_adjacency + + adjacency = build_part_adjacency(parts, nets) + + return detect_generic_driver_topology( + node, + parts, + nets, + roles, + main_part, + trunk_map=trunk_map, + adjacency=adjacency, + **options, + ) + + +def apply_generic_driver_layout( + node, parts, roles, main_part, topology, trunk_map, nets=None, **options +): + """ + generic driver 布局:支路先分区,最后强制主功率链水平横排。 + 主链器件不参与末尾去重叠,避免被垂直推开。 + """ + if not parts or main_part is None: + return + + grid = options.get("grid", 100) + blk_pad = int(options.get("blk_int_pad", 100)) + gap = options.get("topology_gap") or options.get( + "trunk_gap", max(blk_pad, grid * 2) + ) + + main_bbox = main_part.place_bbox * main_part.tx + main_ctr = node._placement_ctr(main_part) + chain, chain_parts = _build_driver_chain_order( + node, roles, topology, main_part + ) + + moved_count = 0 + attempt_count = 1 + + use_rail = options.get("driver_rail_routing", True) and options.get( + "human_readable", False + ) + + # 非主链输出滤波电容:放在主链上方一小行(rail 模式下去耦改由 rail_safe 处理)。 + aux_output = sorted( + [ + p + for p in topology.get("output_parts", set()) + if p not in chain_parts and _part_ref_prefix(p) == "C" + ], + key=node._part_ref_key, + ) + if aux_output and not use_rail: + top_y = main_bbox.min.y - gap - max( + max(getattr(p.place_bbox, "h", 0), grid) for p in aux_output + ) + _place_parts_in_row( + node, + aux_output, + main_bbox.min.x, + top_y, + gap, + grid, + ) + moved_count += len(aux_output) + + # 控制支路:放在主控正下方横排,避免拉到最右侧形成超长回路线。 + control_parts = sorted( + [p for p in topology.get("control_parts", set()) if p not in chain_parts], + key=node._part_ref_key, + ) + if control_parts: + ctrl_y = main_bbox.max.y + gap + _place_parts_in_row( + node, + control_parts, + main_bbox.min.x, + ctrl_y, + gap, + grid, + ) + moved_count += len(control_parts) + + # 反馈采样电阻等:贴近主控上方。 + sense_parts = sorted( + [p for p in topology.get("sense_feedback_parts", set()) if p not in chain_parts], + key=node._part_ref_key, + ) + if sense_parts: + max_h = max(max(getattr(p.place_bbox, "h", 0), grid) for p in sense_parts) + sense_y = main_bbox.min.y - gap - max_h + _place_parts_in_row( + node, + sense_parts, + main_bbox.max.x + gap, + sense_y, + gap, + grid, + ) + moved_count += len(sense_parts) + + # 其余输入/功率器件:轻量靠左或靠下,不抢主链位置。 + left_x = main_bbox.min.x - (3 * gap) + for part in sorted(topology.get("input_parts", set()), key=node._part_ref_key): + if part in chain_parts: + continue + attempt_count += 1 + _set_part_center_x_safe(node, part, parts, left_x, grid) + + bottom_y = main_bbox.max.y + (3 * gap) + for part in sorted(topology.get("power_loop_parts", set()), key=node._part_ref_key): + if part in chain_parts: + continue + attempt_count += 1 + node._set_part_center_y_safe(part, parts, bottom_y) + + # 最后放置主功率链:直接横排,覆盖此前对齐造成的错位。 + if len(chain) >= 2: + chain_y = main_bbox.min.y + start_x = _chain_row_start_x(node, chain, main_part, gap, grid) + _place_parts_in_row(node, chain, start_x, chain_y, gap, grid) + moved_count += len(chain) + + _resolve_overlaps(node, parts, grid, max(gap, blk_pad), exclude=chain_parts) + + if attempt_count > 0 and moved_count == 0: + topology["fallback"] = "trunk_aware" + topology["reasons"] = list(topology.get("reasons", [])) + ["layout_safety"] + else: + topology["fallback"] = False + if ( + options.get("driver_rail_routing", True) + and options.get("human_readable", False) + and nets + ): + if options.get("schematic_progress", False): + from skidl.logger import active_logger + + active_logger.info("[schematic] driver rail placement ...") + apply_driver_rail_safe_placement( + node, + parts, + nets, + roles, + main_part, + topology, + chain, + chain_parts, + **options, + ) + plan = build_driver_rail_plan( + node, parts, nets, topology, main_part, **options + ) + node._driver_rail_plan = plan + node._driver_chain_parts = getattr(node, "_driver_chain_parts", chain_parts) + _log_driver_rails(plan, options) + _log_rail_blockers(node, parts, plan, options) + + +def topology_route_rank_bias(net, topology): + """ + generic_driver matched 时的布线顺序偏置(保守,不改变拓扑)。 + 返回值越小越先布。 + """ + if not topology or topology.get("kind") != "generic_driver": + return 0 + + name = _net_label(net).upper() + net_obj = net + + def in_bucket(key): + for n in topology.get(key, []): + if n is net_obj: + return True + return False + + if in_bucket("input_nets") or in_bucket("power_nets"): + return -600 + if in_bucket("output_nets"): + return -550 + if in_bucket("ground_nets"): + return -500 + if in_bucket("control_nets"): + return -200 + if in_bucket("switch_or_drive_nets"): + # 开关网不做长 trunk,局部优先但弱于电源/输出 + return -80 + if in_bucket("sense_or_feedback_nets"): + return -150 + + # 未入 topology 桶的具名网:不用 trunk 对 SW 的 right 主干误导 + if _token_in_text(name, _SWITCH_TOKENS): + return -50 + return 0 + + +def format_topology_log_line(topology): + """单行中文拓扑识别结果(便于在日志末尾快速阅读)。""" + kind = topology.get("kind", "unrecognized") + conf = topology.get("confidence", 0) + fb = topology.get("fallback", "trunk_aware") + mp = topology.get("main_part") + main_ref = str(getattr(mp, "ref", "") or "") if mp is not None else "" + + if kind == "disabled": + return "[schematic] 拓扑识别:未启用拓扑识别,使用常规布局。" + + if kind == "generic_driver" and fb is False: + if main_ref: + return f"[schematic] 拓扑识别:已识别为 driver 模块(主控 {main_ref}),已启用专用布局。" + return "[schematic] 拓扑识别:已识别为 driver 模块,已启用专用布局。" + + if kind == "generic_driver": + if main_ref: + return f"[schematic] 拓扑识别:已识别为 driver 模块(主控 {main_ref}),专用布局未生效,使用常规布局。" + return "[schematic] 拓扑识别:已识别为 driver 模块,专用布局未生效,使用常规布局。" + + if kind == "weak_generic_driver": + if main_ref: + return ( + f"[schematic] 拓扑识别:疑似 driver 模块(主控 {main_ref}," + f"置信度 {conf}),使用常规布局。" + ) + return f"[schematic] 拓扑识别:疑似 driver 模块(置信度 {conf}),使用常规布局。" + + return "[schematic] 拓扑识别:未识别为 driver 模块,使用常规布局。" + + +def log_topology_summary(node, options): + """输出单个 node 的 topology 日志(schematic_progress 时)。""" + if not options.get("schematic_progress", False): + return + from skidl.logger import active_logger + + topology = getattr(node, "_last_topology_result", None) + if topology is None: + return + active_logger.info(format_topology_log_line(topology)) + + +def log_topology_summaries_deep(node, options): + """递归子页后输出各 sheet 的 topology 行,作为 place/route 流程末行日志。""" + if not options.get("schematic_progress", False): + return + for child in getattr(node, "children", {}).values(): + log_topology_summaries_deep(child, options) + log_topology_summary(node, options) + + +def apply_topology_or_trunk_layout( + node, parts, nets, roles, main_part, **options +): + """ + 互斥分支:generic_driver 仅 apply_generic_driver_layout,否则 trunk-aware。 + 结果写入 node._last_topology_result。 + """ + trunk_map = classify_trunk_nets(node, parts, nets, roles, main_part, **options) + topology = detect_known_topology( + node, parts, nets, roles, main_part, trunk_map=trunk_map, **options + ) + node._last_topology_result = topology + + topo_opts = _topology_options(options) + layout_main = topology.get("main_part") or main_part + strong_th = topo_opts["strong_threshold"] + + if ( + topology.get("kind") == "generic_driver" + and topology.get("confidence", 0) >= strong_th + and layout_main is not None + ): + node._human_readable_main_part = layout_main + layout_opts = dict(options) + layout_opts.setdefault("grid", 100) + layout_opts.setdefault("blk_int_pad", 100) + apply_generic_driver_layout( + node, + parts, + roles, + layout_main, + topology, + trunk_map, + nets=nets, + **layout_opts, + ) + else: + node._driver_rail_plan = {"enabled": False} + layout_opts = dict(options) + layout_opts.setdefault("grid", 100) + layout_opts.setdefault("blk_int_pad", 100) + apply_trunk_aware_layout( + node, + parts, + roles, + layout_main, + trunk_map, + **layout_opts, + ) diff --git a/src/skidl/schematics/trunk_layout.py b/src/skidl/schematics/trunk_layout.py new file mode 100644 index 00000000..18ea389a --- /dev/null +++ b/src/skidl/schematics/trunk_layout.py @@ -0,0 +1,374 @@ +# -*- coding: utf-8 -*- + +""" +human_readable 模式下的 net-aware trunk 布局后处理。 +""" + +from collections import defaultdict + +from skidl.geometry import Point, Tx, Vector + + +def build_part_adjacency(parts, nets): + """根据 nets 构建 part 邻接图。""" + part_set = set(parts) + adjacency = defaultdict(set) + for net in nets: + net_parts = [p for p in (pin.part for pin in net.pins) if p in part_set] + for i, part_a in enumerate(net_parts): + for part_b in net_parts[i + 1 :]: + adjacency[id(part_a)].add(part_b) + adjacency[id(part_b)].add(part_a) + return adjacency + + +def _net_name_side_scores(net_name_u): + """按网名给 top/bottom/left/right 打分;LED+/LED- 优先于泛化 LED token。""" + scores = {"top": 0, "bottom": 0, "left": 0, "right": 0} + + if "LED+" in net_name_u or net_name_u.endswith("/LED+"): + scores["top"] += 12 + if "LED-" in net_name_u or net_name_u.endswith("/LED-"): + scores["bottom"] += 12 + + top_tokens = ("VCC", "VDD", "VIN", "VBUS", "24V", "12V", "5V", "3V3", "W+") + bottom_tokens = ("GND", "AGND", "DGND", "PGND", "VSS", "W-") + right_tokens = ("OUT", "LOAD", "DRV", "SW") + left_tokens = ("IN", "SENSE", "FB", "ADC", "CTRL", "PWM", "DIM") + + for token in top_tokens: + if token in net_name_u: + scores["top"] += 3 + for token in bottom_tokens: + if token in net_name_u: + scores["bottom"] += 3 + for token in right_tokens: + if token in net_name_u: + scores["right"] += 2 + for token in left_tokens: + if token in net_name_u: + scores["left"] += 2 + + # 仅当不是 LED+/LED- 时,才把 LED 视作输出侧提示。 + if "LED+" not in net_name_u and "LED-" not in net_name_u and "LED" in net_name_u: + scores["right"] += 2 + + return scores + + +def is_trunk_net_name(name): + """网名是否像电源/地/LED 主干(供 route 排序与简化使用)。""" + if not name: + return False + text = str(name).upper() + if text.startswith("NET-("): + return False + side = _net_name_side_scores(text) + return max(side.values()) >= 3 + + +def trunk_route_rank_bias(name): + """全局布线排序:主干网优先(返回值越小越先布)。""" + if not is_trunk_net_name(name): + return 0 + text = str(name).upper() + side = _net_name_side_scores(text) + # 电源/地/LED 轨最先布,便于后续网复用其通道。 + return -500 - max(side.values()) * 10 + + +def classify_trunk_nets(node, parts, nets, roles, main_part, **options): + """识别 trunk net,并按 top/bottom/left/right 分类。""" + if not parts or not nets: + return {"top": [], "bottom": [], "left": [], "right": []} + + part_set = set(parts) + side_candidates = {"top": [], "bottom": [], "left": [], "right": []} + + for net in nets: + net_name = str(getattr(net, "name", "") or "") + net_name_u = net_name.upper() + is_named = bool(net_name) and not net_name_u.startswith("NET-(") + + net_parts = node._net_connected_parts(net, allowed_parts=part_set) + fanout = len(net_parts) + if fanout < 2: + continue + + side_score = _net_name_side_scores(net_name_u) + has_strong_token = max(side_score.values()) >= 3 + + if ( + fanout <= 3 + and not is_named + and not has_strong_token + and not node._is_power_net_name(net_name_u) + and node._is_local_functional_cluster(net, net_parts) + ): + continue + + role_set = {roles.get(part, "other") for part in net_parts} + connector_count = sum(1 for part in net_parts if roles.get(part) == "connector") + power_count = sum( + 1 for part in net_parts if roles.get(part) in ("power", "decoupling") + ) + main_bonus = 2 if main_part in net_parts else 0 + + base_score = ( + fanout * 2 + + len(role_set) + + connector_count + + power_count + + main_bonus + + (2 if is_named else 0) + ) + + if node._is_power_net_name(net_name_u): + if any(t in net_name_u for t in ("GND", "VSS", "W-", "LED-")): + side_score["bottom"] += 3 + else: + side_score["top"] += 2 + + best_side = max(side_score, key=side_score.get) + if side_score[best_side] <= 0: + continue + + total_score = base_score + side_score[best_side] + if total_score < options.get("trunk_score_threshold", 6): + continue + + side_candidates[best_side].append((total_score, net)) + + max_per_side = options.get("trunk_max_per_side", 3) + trunk_map = {"top": [], "bottom": [], "left": [], "right": []} + for side, candidates in side_candidates.items(): + candidates.sort(key=lambda item: item[0], reverse=True) + trunk_map[side] = [net for _, net in candidates[:max_per_side]] + + return trunk_map + + +def _trunk_layout_log(options, trunk_map): + """human_readable 调试:输出 trunk 分类结果。""" + if not options.get("schematic_progress", False): + return + from skidl.logger import active_logger + + def net_label(net): + return str(getattr(net, "name", "") or net) + + parts = [] + for side in ("top", "bottom", "left", "right"): + names = [net_label(n) for n in trunk_map.get(side, [])] + if names: + parts.append(f"{side}=[{', '.join(names)}]") + if parts: + active_logger.info("[schematic] trunk nets: " + "; ".join(parts)) + + +def _collect_side_parts(node, parts, trunk_map): + """收集每个 side 上由 trunk net 关联到的器件。""" + side_parts = {"top": set(), "bottom": set(), "left": set(), "right": set()} + part_set = set(parts) + for side, nets in trunk_map.items(): + for net in nets: + for part in node._net_connected_parts(net, allowed_parts=part_set): + side_parts[side].add(part) + return side_parts + + +def _assign_functional_zones(node, parts, roles, main_part, trunk_map): + """按器件角色与网名补充 side 归属(不仅依赖 trunk net 覆盖)。""" + side_parts = _collect_side_parts(node, parts, trunk_map) + for side in side_parts: + side_parts[side].discard(main_part) + + right_tokens = ("OUT", "LED", "LOAD", "DRV", "SW") + left_tokens = ("IN", "PWM", "DIM", "SENSE", "FB", "CTRL") + + for part in parts: + if part is main_part: + continue + ref = str(getattr(part, "ref", "") or "").upper() + value = str(getattr(part, "value", "") or "").upper() + net_names = [str(n).upper() for n in node._net_names_of(part)] + + if roles.get(part) == "connector": + if "LED" in value or "OUT" in value or any( + any(t in n for t in right_tokens) for n in net_names + ): + side_parts["right"].add(part) + continue + + if ref.startswith("L") and any("LED" in n for n in net_names): + side_parts["right"].add(part) + continue + + if ref.startswith("C") and any( + "LED+" in n or "VIN" in n or "VCC" in n for n in net_names + ): + if any("LED+" in n for n in net_names): + side_parts["top"].add(part) + continue + + if ref.startswith("R") and any(any(t in n for t in left_tokens) for n in net_names): + side_parts["left"].add(part) + + return side_parts + + +def _row_start_x(parts, center_x, gap, grid): + """根据器件宽度计算水平排布的起始 X。""" + if not parts: + return center_x + widths = [max(getattr(part.place_bbox, "w", 0), grid) for part in parts] + total_w = sum(widths) + max(0, len(widths) - 1) * gap + return center_x - total_w / 2.0 + + +def _sort_parts_by_current_x(node, parts): + return sorted( + parts, key=lambda part: (node._placement_ctr(part).x, node._part_ref_key(part)) + ) + + +def _place_parts_in_column(node, parts, x, y_start, gap, grid): + y = y_start + for part in parts: + h = max(getattr(part.place_bbox, "h", 0), grid) + part.tx = Tx().move(Point(x, y)) + y += h + gap + + +def _place_parts_in_row(node, parts, start_x, start_y, gap, grid, direction=1): + """水平排布:直接设置 tx,用于 generic driver 主功率链。""" + x = start_x + for part in parts: + w = max(getattr(part.place_bbox, "w", 0), grid) + if direction >= 0: + part.tx = Tx().move(Point(x, start_y)) + x += w + gap + else: + part.tx = Tx().move(Point(x - w, start_y)) + x -= w + gap + + +def _set_part_center_x_safe(node, part, all_parts, target_x, grid): + ctr = node._placement_ctr(part) + snapped_x = Point(target_x, ctr.y).snap(grid).x + dx = snapped_x - ctr.x + if dx: + node._nudge_part_if_clear(part, all_parts, dx, 0) + + +def _resolve_overlaps(node, parts, grid, gap, max_rounds=30, exclude=None): + """轻量去重叠:优先垂直推开,失败再水平。""" + exclude = exclude or set() + for _ in range(max_rounds): + moved = False + for part in sorted(parts, key=node._part_ref_key): + if part in exclude: + continue + bbox = part.place_bbox * part.tx + for other in parts: + if other is part: + continue + if other in exclude: + continue + other_bbox = other.place_bbox * other.tx + if not bbox.intersects(other_bbox): + continue + ctr = node._placement_ctr(part) + other_ctr = node._placement_ctr(other) + dy = gap if ctr.y <= other_ctr.y else -gap + if node._nudge_part_if_clear(part, parts, 0, dy): + moved = True + break + dx = gap if ctr.x <= other_ctr.x else -gap + if node._nudge_part_if_clear(part, parts, dx, 0): + moved = True + break + if moved: + break + if not moved: + break + + +def apply_trunk_aware_layout(node, parts, roles, main_part, trunk_map, **options): + """根据 trunk 分类结果做保守坐标后处理(对齐为主,避免整板重排引发布线失败)。""" + if not parts or main_part is None: + return + + _trunk_layout_log(options, trunk_map) + + main_bbox = main_part.place_bbox * main_part.tx + grid = options.get("grid", 100) + blk_pad = int(options.get("blk_int_pad", 100)) + gap = options.get("trunk_gap", max(blk_pad, grid * 2)) + + side_parts = _assign_functional_zones(node, parts, roles, main_part, trunk_map) + + right_x = main_bbox.max.x + (2 * gap) + left_x = main_bbox.min.x - (2 * gap) + + top_y = None + bottom_y = None + if side_parts["top"]: + max_h = max( + max(getattr(p.place_bbox, "h", 0), grid) for p in side_parts["top"] + ) + top_y = main_bbox.min.y - gap - max_h + if side_parts["bottom"]: + bottom_y = main_bbox.max.y + gap + + right_sorted = sorted( + side_parts["right"], + key=lambda part: (roles.get(part) != "connector", node._part_ref_key(part)), + ) + + # 输出侧:连接器/电感等硬放到右侧列(阅读方向最重要)。 + if right_sorted: + _place_parts_in_column( + node, + right_sorted, + right_x, + main_bbox.min.y, + gap, + grid, + ) + + # 上/下 rail:只做 Y 对齐,保留原有 X 分区,降低 pin 共线冲突概率。 + if top_y is not None: + for part in sorted(side_parts["top"], key=node._part_ref_key): + if part in right_sorted: + continue + node._set_part_center_y_safe(part, parts, top_y) + + if bottom_y is not None: + for part in sorted(side_parts["bottom"], key=node._part_ref_key): + if part in right_sorted: + continue + node._set_part_center_y_safe(part, parts, bottom_y) + + for part in sorted(side_parts["left"], key=node._part_ref_key): + if part in right_sorted: + continue + _set_part_center_x_safe(node, part, parts, left_x, grid) + + for part in sorted(parts, key=node._part_ref_key): + if roles.get(part) != "connector": + continue + value = str(getattr(part, "value", "") or "").upper() + net_names = [str(n).upper() for n in node._net_names_of(part)] + if "LED" in value or "OUT" in value or any("LED" in n for n in net_names): + _set_part_center_x_safe(node, part, parts, right_x, grid) + + _resolve_overlaps(node, parts, grid, max(gap, blk_pad)) + + +def expand_main_ic_keepout(main_part, grid, scale=1.0): + """轻量扩大主控 place_bbox,给布线/cleanup 留少量 keepout(仅 human_readable)。""" + if main_part is None: + return + pad = Vector(grid * scale, grid * scale) + main_part.place_bbox = main_part.place_bbox.resize(pad) diff --git a/src/skidl/tools/kicad9/gen_schematic.py b/src/skidl/tools/kicad9/gen_schematic.py index 1b59f99d..41a18a50 100644 --- a/src/skidl/tools/kicad9/gen_schematic.py +++ b/src/skidl/tools/kicad9/gen_schematic.py @@ -240,7 +240,17 @@ def _classify_and_stub_complex_nets(circuit, node, **options): stubbed_count = 0 partial_stubbed_count = 0 + preserve_wire_nets = set() + if options.get("driver_rail_routing", True): + from skidl.schematics.topology import driver_wire_preserve_net_set + + preserve_wire_nets = driver_wire_preserve_net_set( + node, node.get_internal_nets(), **options + ) + for net in node.get_internal_nets(): + if net in preserve_wire_nets: + continue if getattr(net, "_stub_explicit", False): continue if getattr(net, "_stub", False): @@ -673,6 +683,10 @@ def power_supply(vin, vout, gnd): ) if options.get("auto_stub", False): _classify_and_stub_complex_nets(circuit, node, **options) + if options.get("driver_rail_routing", True): + from skidl.schematics.topology import restore_driver_wire_nets_deep + + restore_driver_wire_nets_deep(node, **options) if options.get("schematic_progress", False): active_logger.info("[schematic] 布线 route ...") node.route(**options) @@ -774,6 +788,8 @@ def power_supply(vin, vout, gnd): ) break + # 供 Circuit.generate_schematic 在 warnings/errors 汇总之后输出 topology 日志。 + circuit._schematic_sch_root = node return # All retries exhausted. diff --git a/tests/unit_tests/test_topology_generic_driver.py b/tests/unit_tests/test_topology_generic_driver.py new file mode 100644 index 00000000..2143e4a6 --- /dev/null +++ b/tests/unit_tests/test_topology_generic_driver.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- + +"""generic driver topology 检测与日志格式单元测试。""" + +from skidl.schematics.topology import ( + _collect_driver_rail_nets, + _disabled_topology, + _is_anonymous_net, + _score_candidate_ic, + _token_in_text, + detect_known_topology, + format_topology_log_line, +) + + +class _FakePin: + def __init__(self, name, part=None): + self.name = name + self.part = part + self.net = None + + def is_connected(self): + return self.net is not None + + +class _FakeNet: + def __init__(self, name): + self.name = name + self.pins = [] + + +class _FakePart: + def __init__(self, ref, value="", pins=None): + self.ref = ref + self.value = value + self.name = ref + self.pins = pins or [] + self.place_bbox = None + self.tx = None + + +class _FakeNode: + def _net_connected_parts(self, net, allowed_parts=None): + return [p for p in getattr(net, "_parts", []) if allowed_parts is None or p in allowed_parts] + + def _net_names_of(self, part): + names = set() + for pin in part.pins: + if pin.net is not None and getattr(pin.net, "name", None): + names.add(str(pin.net.name)) + return names + + def _is_power_net_name(self, name): + return "GND" in str(name).upper() or "VCC" in str(name).upper() + + +def _wire(part, pin_name, net): + pin = next(p for p in part.pins if p.name == pin_name) + pin.net = net + net.pins.append(pin) + if not hasattr(net, "_parts"): + net._parts = [] + if part not in net._parts: + net._parts.append(part) + + +def test_token_in_text(): + assert _token_in_text("Net-(U2-DIM)", ("DIM",)) + assert _token_in_text("/LED+", ("LED", "W+")) + + +def test_disabled_topology(): + topo = _disabled_topology() + assert topo["kind"] == "disabled" + line = format_topology_log_line(topo) + assert "未启用拓扑识别" in line + + +def test_detect_known_topology_disabled_when_not_human_readable(): + node = _FakeNode() + topo = detect_known_topology(node, [], [], {}, None, human_readable=False) + assert topo["kind"] == "disabled" + + +def test_detect_known_topology_disabled_flag(): + node = _FakeNode() + topo = detect_known_topology( + node, [], [], {}, None, human_readable=True, topology_detection=False + ) + assert topo["kind"] == "disabled" + assert topo["fallback"] == "trunk_aware" + + +def test_format_topology_log_lines(): + assert "疑似 driver" in format_topology_log_line( + { + "kind": "weak_generic_driver", + "confidence": 48, + "fallback": "trunk_aware", + "main_part": _FakePart("U2"), + } + ) + line = format_topology_log_line( + { + "kind": "generic_driver", + "confidence": 76, + "fallback": False, + "main_part": _FakePart("U2"), + } + ) + assert "已识别为 driver 模块" in line + assert "主控 U2" in line + assert "已启用专用布局" in line + assert "未识别" in format_topology_log_line({"kind": "unrecognized", "confidence": 0}) + + +def test_driver_score_combo_on_minimal_buck_like_graph(): + """VIN+GND+SW+FB 组合应达到较高 confidence。""" + node = _FakeNode() + u2 = _FakePart( + "U2", + "LED DRIVER", + pins=[ + _FakePin("VIN"), + _FakePin("GND"), + _FakePin("SW"), + _FakePin("FB"), + _FakePin("PWM"), + ], + ) + for p in u2.pins: + p.part = u2 + l1 = _FakePart("L1", pins=[_FakePin("1")]) + l1.pins[0].part = l1 + nets = { + "vin": _FakeNet("VIN"), + "gnd": _FakeNet("GND"), + "sw": _FakeNet("SW"), + "fb": _FakeNet("FB"), + "pwm": _FakeNet("PWM"), + } + _wire(u2, "VIN", nets["vin"]) + _wire(u2, "GND", nets["gnd"]) + _wire(u2, "SW", nets["sw"]) + _wire(u2, "FB", nets["fb"]) + _wire(u2, "PWM", nets["pwm"]) + _wire(l1, "1", nets["sw"]) + + parts = [u2, l1] + all_nets = list(nets.values()) + roles = {"ic": "ic"} + roles = {u2: "ic", l1: "passive"} + sc, conf, reasons, combo, _ = _score_candidate_ic( + node, u2, parts, all_nets, roles, set(parts), {id(u2): {l1}} + ) + assert combo + assert conf >= 40 + assert sc >= 8 + + +def test_collect_driver_rail_nets_excludes_control_and_anonymous(): + node = _FakeNode() + u2 = _FakePart("U2") + topology = { + "control_nets": [], + "switch_or_drive_nets": [], + "ground_nets": [], + "input_nets": [], + "power_nets": [], + "output_nets": [], + } + led_p = _FakeNet("LED+") + gnd = _FakeNet("GND") + pwm = _FakeNet("PWM") + anon = _FakeNet("Net-(U2-Pad3)") + sw = _FakeNet("SW") + top, bottom, control = _collect_driver_rail_nets( + [led_p, gnd, pwm, anon, sw], topology, node, u2, set() + ) + assert led_p in top + assert gnd in bottom + assert pwm in control + assert anon not in top and anon not in bottom + assert _is_anonymous_net(anon) diff --git a/update.md b/update.md index 35500d6c..9487b1cf 100644 --- a/update.md +++ b/update.md @@ -1,326 +1,57 @@ -# 更新记录(human_readable 布局 / 走线稳定化) +# skidl-pnr-opt 更新记录 -**日期**:2026-05-13 -**范围**:原理图自动布局与走线(`place.py`、`route.py`),默认行为不变。 +## generic_driver 水平 power rail(布局 + 预布线) ---- +**日期**:2026-05-21 +**范围**:`topology.py`、`route.py`(必要时 `place.py` 仅透传 options) -## 目的 +### 功能 -- 在可选模式下让生成的 `.kicad_sch` 更接近工程师手工排版习惯(主控居中、电源/去耦分区、接口左右等启发式)。 -- 减少随机性,使同一输入多次生成坐标与走线更稳定。 -- 遵循小步、保守、可回滚:不引入新依赖、不重写整个 placer/router。 +在 `human_readable=True` 且拓扑识别为 **generic_driver**(`fallback=False`)时: ---- +1. **布局**(`build_driver_rail_plan` / `apply_driver_rail_safe_placement`) + - 根据全部器件 `place_bbox` 计算顶/底水平走廊 `top_y` / `bottom_y` 与 `x_min`/`x_max` + - 顶网(VCC/VIN/W+/LED+ 等)器件放在顶 rail **下方**;底网(GND/W-/LED- 等)放在底 rail **上方** + - 主功率链(C/D → U → L → 连接器)横排在两条 rail **之间**,不压在 rail 线上 + - 控制网(PWM/DIM/EN)不进入长 rail,支路放在主控右侧中部 -## 涉及文件 +2. **预布线**(`route_driver_rails`) + - 对 `rail_plan.top_nets` / `bottom_nets` 画水平 `Segment`,各 pin 短竖 stub 接入 + - 不经过 switchbox;这些网从 `global_router` / `switchbox_router` 的 `routed_nets` 中排除 + - 内部匿名网 `Net-(...)` 不参与 rail;`NetTerminal` 引脚不作为 stub 端点 -| 文件 | 说明 | -|------|------| -| `src/skidl/schematics/place.py` | 主要改动:`human_readable` 分支、工具函数、auto_stub 微调、默认 seed | -| `src/skidl/schematics/route.py` | 轻量改动:稳定 `global_router` 起点、`remove_jogs` 顺序、`humanize_wires`、默认 seed | +### 日志(`schematic_progress=True`) ---- - -## 如何启用 - -在调用 `place` / `route` 时传入可选参数: - -```python -node.place(..., human_readable=True) -node.route(..., human_readable=True) +```text +[schematic] driver rail placement ... +[schematic] driver rails: top=[...], bottom=[...], top_y=..., bottom_y=..., x=(..., ...) +[schematic] driver rail blocker: ref=... bbox=... rail=top|bottom +[schematic] driver rail pre-route: N nets [...] ``` -- **`human_readable=False`(默认)**:保持原有逻辑与分支,与改动前一致。 -- **`human_readable=True` 且未传 `seed`**:内部使用固定默认种子 `0`,便于回归与对比输出。 - ---- - -## `place.py` 变更摘要 - -### 新增(`Placer` 内小工具,非新 public API) - -- `_part_ref_key(part)`:按 `ref` / `name` / `value` 稳定排序键。 -- `_net_names_of(part)`:安全返回器件所连 net 名称集合。 -- `_is_power_net_name(name)`:电源/地 net 名启发式。 -- `_classify_part_role(part)`:`power` / `decoupling` / `ic` / `connector` / `passive` / `other`。 -- `_find_main_part(parts)`:优先 pin 数最多的 IC,否则连接度最高,tie 用稳定排序。 -- `_place_row(...)`:按 `place_bbox` 与 `GRID` / `BLK_INT_PAD` 行摆放。 - -### `place_connected_parts_rowbased` - -- **`human_readable=True`**:按主器件 + 角色分区布局;末尾保守去重叠 + `snap_to_grid`。 -- **`human_readable=False`**:仍为原 BFS + 行打包逻辑。 - -### `place_floating_parts` - -- **`human_readable=True`**:按 role 分桶、`value/ref` 稳定排序,被动件按 R/C/L 等分行,不依赖随机初始布局。 -- 默认路径不变(含大数量浮动件 + `auto_stub` 的 sqrt grid 等)。 - -### `place_blocks` - -- 当 block 数量超过阈值且 **`human_readable=True`**:连通块居中行、浮动块下方、子 sheet 右侧,按 `tag`、面积、`ref` 稳定排序。 -- 默认仍为原 sqrt grid + 力导向等。 - -### `_auto_stub_large_groups` - -- **`human_readable=True` 且 `auto_stub=True`**:更倾向对电源类 net 与候选链 net 排序后做有限次数 stub,避免“全图标签化”;默认分支保持原等步长切割逻辑。 - -### `place()` 随机种子 - -- `human_readable=True` 且用户未传 `seed` 时,使用固定种子 `0`。 - ---- - -## `route.py` 变更摘要 - -### `route()` - -- 与 place 一致:`human_readable=True` 且无 `seed` 时默认 `seed=0`。 -- 将本次 `options` 暂存于 `node._route_options`,供内部读取;正常结束或 `RoutingFailure` 时清理。 - -### `global_router()` - -- **`human_readable=True`**:`start_face` 按 track 坐标、beg/end、pin/terminal 数量等可比较键排序后取第一个,替代 `random.choice`。 -- 默认仍为随机选择。 - -### `cleanup_wires()` 内 `remove_jogs()` - -- **`human_readable=True`**:不对 segments / `p2s` 做 `shuffle`.改为稳定排序顺序。 -- **`human_readable=True`**:在通用 `cleanup_wires` 流程末尾调用 `humanize_wires()`。 - -### `humanize_wires()`(新增) - -- 仅在 human 模式、且 `cleanup_wires` 之后执行。 -- 保守操作:去零长段、稳定排序、对极短且弱连接的 stub 做 trim;不改动电气连接语义;避免穿越器件 bbox 的激进简化未做。 - ---- - -## 验收与自检 - -- `python -m compileall src/skidl/schematics/place.py src/skidl/schematics/route.py` 通过。 -- `import skidl.schematics.place`、`import skidl.schematics.route` 通过(环境缺 KiCad 符号路径时可能有既有 WARNING,与本次改动无关)。 - ---- - -## 回滚说明 - -- 不传 `human_readable` 或显式 `human_readable=False` 即可恢复改动前行为。 -- 若需完全撤销代码:仅回退上述两个文件在本记录日期附近的提交即可。 - ---- - -## 2026-05-14 更新:human 路由崩溃修复 + 网表仓库默认输出目录 - -### 1. `route.py` — `SwitchBox.coalesce` KeyError - -- **现象**:`human_readable=True` 时,部分电路在 `create_switchboxes` → `coalesce` 中执行 `(box_face.switchboxes - {box}).pop()` 会因邻接集为空触发 **`KeyError: 'pop from an empty set'`**(例如 `examples/5micro_3`)。 -- **原因**:布局/track 切分退化时,某 face 上记录的 `switchboxes` 可能未包含预期邻盒,空集仍被 `pop()`。 -- **修复**:先判断 `adjacent = box_face.switchboxes - {box}` 非空再取邻盒;扩张循环内先收集 `(i, adj_box)`,若任一步邻接为空则放弃该生长方向并 `continue` 下一轮,避免部分替换与崩溃。 -- **注释**:在 `coalesce` 内新增中文注释说明为何保守处理。 - -### 2. 配套仓库 `netlist-to-sch-via-skidl`(与本 fork 联用) - -- **`convert_netlist.py`**:`--schematic-subdir` 默认值由 `kicad_generated` 改为 **`kicad_generated_h`**,与当前注入的 `human_readable=True` 流程一致。 -- **`generated_script_patch.py`**:`_finalize_generated_skidl` 默认 `schematic_subdir` 改为 **`kicad_generated_h`**。 -- **`.gitignore`**:增加 `kicad_generated_h/`;保留 `kicad_generated/` 以兼容旧产物。 -- **示例 `*_skidl.py` / README**:原理图输出路径改为 **`kicad_generated_h`** 说明。 - -### 3. 验收 - -- 在 `ski2` 环境下对 `5micro_3.net` 重新 `convert_netlist` 后运行 `5micro_3_skidl.py`,**`human_readable=True`** 应能完成 schematic 生成(或进入既有 auto_stub 回退),且 `.kicad_sch` 位于 **`kicad_generated_h/`**。 - ---- - -## 2026-05-16 更新:connected group 几何对齐后处理(仅 `human_readable=True`) - -### 目的 - -在既有「主器件 + 角色分区」启发式摆放之后,补一层**保守的几何整理**,缓解「器件聚在一起但不在一条线上」的问题:主干共线、上下支路分层、左右近似对称,且不破坏默认可用性。 - -### 修改文件 - -| 文件 | 说明 | -|------|------| -| `src/skidl/schematics/place.py` | 新增对齐后处理及调用点 | -| `update.md` | 本记录 | - -**未改** `route.py`(本步仅 placement 后处理)。 - -### 新增 / 调整函数(`Placer` 内) - -| 函数 | 作用 | -|------|------| -| `_placement_ctr(part)` | 取 `place_bbox * tx` 中心,供对齐计算 | -| `_set_part_center_y` / `_set_part_center_x` | 单轴平移并吸附 `GRID` | -| `_identify_trunk_parts` | 主器件 + 非 `power`/`decoupling` 的直接邻居 → 主干候选 | -| `_align_connected_geometry` | 四步后处理:主干共 Y → 上下支路 `y_top`/`y_bottom` → 同 role/度数成对镜像 → 最多 25 轮支路垂直去重叠 | - -### 调用时机 - -- **仅** **`place_connected_parts_rowbased`** 且 `human_readable=True`:分区摆放完成后、`snap_to_grid` 之前调用。 -- **不**对 `place_connected_parts` 小组(<20 器件,力导向)调用——例如 `4micro2`(6 器件)若强行对齐会把多颗 LED 压到同一水平线,路由 `coalesce` 时出现 `TerminalClashException`。 - -### 2026-05-16 修补(`TerminalClash`) - -| 调整 | 原因 | -|------|------| -| 取消小组力导向路径上的对齐 | `4micro2` 等 <20 器件电路不走 rowbased,对齐反而破坏力导向结果 | -| 主干仅含**已在同一水平带**的近邻 | 避免“所有直接邻居”共 Y | -| 支路/对称 Y 对齐前做**重叠检测**,冲突则跳过 | 不制造 bbox 重叠 | -| 对称只统一 **Y**,不再镜像 **X** | 避免引脚落到同一路由坐标 | -| 去重叠对**全组**开放,必要时水平微移 | 主干之间也能拉开 | - -### 为什么这样改 - -- 分区启发式已决定「谁在上/下/左/右」,但**未强制几何共线**;后处理只调中心坐标,不动主器件锚点,改动面小、可回滚。 -- 主干识别复用 `_classify_part_role` 与邻接图,避免随机选链;排序统一用 `_part_ref_key`。 -- `branch_gap` 由 `max(place_bbox.h)`、`BLK_INT_PAD`、`GRID` 推导,避免写死过激间距。 -- 对称仅对「同 role + 同连接度」且分居主干两侧的支路器件成对处理,避免对单一 case 硬编码。 - -### 默认行为 - -- **`human_readable=False`(默认)**:**不变**;不调用 `_align_connected_geometry`。 -- **`human_readable=True`**:仅上述 connected parts 路径多一步几何整理。 - -### 风险与限制 - -- 第一版**优先水平主干**(统一 Y);竖向主干未单独识别。 -- `power` / `decoupling` / `connector` 不参与上下支路 Y 吸附,保留分区启发式位置(避免左侧纵向连接器被压成一行);不参与主干共线。 -- 对称与分层是**启发式近似**,复杂拓扑(多分叉、非两侧对称)只能做到「更整齐」,非最优布局。 -- 去重叠仅沿 Y 小步推开支路器件,若 X 方向严重重叠可能需后续路由/人工微调。 -- NetTerminal 在对齐**之后**再 `place_net_terminals`,避免终端标签拉动整体几何。 - -### 自检 - -- `python -m compileall src/skidl/schematics/place.py` 通过。 - ---- - -## 2026-05-16 更新:small connected group 弱美化(新文件) - -### 为什么 small 组不能照搬大组强对齐 - -- 小组(`real_count <= _ROW_PLACE_THRESHOLD`,默认 20)走 **`place_connected_parts` 力导向**,拓扑已由弹簧布局拉开。 -- 大组 rowbased 路径上的 `_align_connected_geometry`(主干共线、上下分层、成对 Y 统一)适合**分区启发式之后**的结构化整理,**不适合**直接套在力导向结果上:容易把多颗器件强压到同一水平带,引脚在路由 grid 上共位,触发 `TerminalClashException`(如 `4micro2`)。 - -### 为什么要拆到新文件 - -- 避免 `place.py` 继续堆叠后处理逻辑;**大组强对齐留在 `place.py`**,**小组弱美化独立维护**。 -- 便于单独回滚、测试与阅读。 - -### 新文件与入口 - -| 项 | 值 | -|----|-----| -| 新文件 | `src/skidl/schematics/place_small_group.py` | -| 入口函数 | `beautify_small_connected_group(...)` | - -### `place.py` 集成(最小改动) - -在 `place_connected_parts` 中,仅当 **`human_readable=True`** 且 **`real_count <= _ROW_PLACE_THRESHOLD`**(即未进入 rowbased)时: - -1. `evolve_placement`(及可选 `rotate_parts` 重跑)之后 -2. `place_net_terminals` 之前 - -调用 `beautify_small_connected_group`,再对 real parts `snap_to_grid`。 - -大组 rowbased + `_align_connected_geometry` **不变**。 - -### 核心策略(弱规则 / 去抖) - -1. **方向**:组 bbox 宽 ≥ 高 → 偏横向,仅做 **Y 向**微调;纵向小图第一版不动。 -2. **水平带分簇**:中心 Y 相差 ≤ `2 * GRID` 的器件划为一带;**仅对 ≥2 颗的带**做吸附。 -3. **弱吸附**:目标 Y 为带内中位数并 snap;单颗移动量 ≤ `2 * GRID`;移动前/后做风险检查。 -4. **可选 pair**:同 role 或同 ref 前缀、尺寸接近、分居中线两侧且 Y 已很近 → 仅轻微统一 Y(**无 X 镜像**)。 -5. **去重叠**:最多 15 轮,优先沿 **X** 小步(因主调整为 Y),每步经安全检查。 - -### 风险控制 - -| 检查 | 说明 | -|------|------| -| bbox | 移动后与其它 real part 的 `place_bbox * tx` 相交则回滚 | -| 引脚拥挤 | 已连接引脚的 `place_pt * tx`:不同 net 过近,或同轴距离 < `GRID` 则放弃移动 | -| 幅度限制 | 只修“本来就接近”的(带内 / ≤ `2*GRID`),不重建拓扑 | -| 单轴 | 横向图只主调 Y,不同时大幅改 X+Y | -| 确定性 | 全程 `part_ref_key` 稳定排序,无随机 | - -### 默认行为 - -- **`human_readable=False`(默认)**:**不变**,不调用 `beautify_small_connected_group`。 -- **`human_readable=True` 且大组(>20)**:仍只走 rowbased + `_align_connected_geometry`,**不**走本模块。 -- **`human_readable=True` 且小组(≤20)**:力导向后多一步弱美化。 - ---- - -## 2026-05-17 更新:human_readable 走线局部凸起压平(仅 `route.py`) - -### 修的是哪类问题 - -- **现象**:small schematic / `human_readable=True` 时,走线里常出现「本可水平或垂直连过去,中间却多一小段台阶/凸起」——整体 route 没错,是 cleanup 未把局部可拉直的折线继续压平。 -- **不是 placement 问题**:器件位置与分区启发式无关;根因是 global/switchbox 路由优先连通、`create_nonpin_terminals()` 离散采样产生 dogleg,以及 `remove_jogs()` 主要针对标准三段 staircase/tophat,对「两平行主线 + 短 offset」类小 detour 覆盖不足。 - -### 修改文件 - -| 文件 | 说明 | -|------|------| -| `src/skidl/schematics/route.py` | `cleanup_wires()` 内新增 `_straighten_local_detours()` | -| `update.md` | 本记录 | - -**未改** `place.py`、`place_small_group.py`、global/switchbox 主路由算法。 - -### 新增 pass:`_straighten_local_detours()` - -- **接入位置**:`cleanup_wires()` 中,每个 net 的 `remove_jogs()` **之后**;仍走原有 `merge_segments()` → `split_segments()` → `trim_stubs()` 循环,把结果收干净。 -- **模式**:**仅 `human_readable=True`** 时调用;默认 `human_readable=False` **不执行**,行为与改动前一致。 -- **策略(保守)**: - - 只处理 **H-V-H** / **V-H-V** 三元组,中间为短正交连接、两侧为同向主线; - - **情况 A**:两侧主线已共线 → 尝试用 **单段** 替代三段; - - **情况 B**:高度/宽度差 ≤ `max(GRID, 2*GRID)` → 尝试用 **L 形两段** 绕开中间台阶(角点顺序固定排序,保证确定性); - - 每次至多改一处,改完返回由外层 while 继续 merge/split/trim(与 `remove_jogs` 相同节奏)。 - -### 安全检查 - -| 检查 | 说明 | -|------|------| -| 器件阻挡 | 新线段 bbox 不得与 `part_bboxes` 相交 | -| 其它 net | 与同轨其它 net 线段 overlay 视为阻挡(与 `remove_jogs` 相同逻辑) | -| pin | 中间拐角接 pin 则跳过;本 net 落在旧路径上的 pin 必须仍落在新路径上 | -| 范围 | 仅小 detour(阈值 `max(GRID, 2*GRID)`),不重写整网几何 | - -### 默认行为 - -- **`human_readable=False`(默认)**:**不变**;不调用 `_straighten_local_detours()`。 -- **`human_readable=True`**:在既有 `remove_jogs` + `humanize_wires` 流程中多一步局部压平,不改变路由主流程与 seed 策略。 - -### 自检 - -- `python -m compileall src/skidl/schematics/route.py` 通过。 - ---- - -## 2026-05-17 补充:junction-aware 局部凸起压平(仍仅 `route.py`) +### 关闭 / 回退 -### 为什么原先纯三段检测不够 +| 选项 | 默认 | 效果 | +|------|------|------| +| `driver_rail_routing=False` | `True` | 不生成 `node._driver_rail_plan`,不 rail-safe 布局,不预布线 | +| `topology_detection=False` | `True` | 不走 generic_driver 专用逻辑 | +| `human_readable=False` | — | 全部 rail 逻辑关闭 | -- 第一版 `_straighten_local_detours()` 要求 `mid` 两端各**恰好**再连一条同 net segment(`len(others)==1`),等价于假设凸起是孤立 H-V-H / V-H-V 三元组。 -- 真实 cleanup 后 wire 往往已 `split_segments` / `add_junctions`,端点常是 **T 分叉、短 stub、同 net 多段共点**;小凸起两端已是 junction,旧逻辑直接 `continue`,凸起残留。 +未识别为 generic_driver 或 `fallback=trunk_aware` 时仍使用原有 trunk-aware + switchbox 布线。 -### 为什么 junction-aware case 很常见 +### 验收参考 -- `remove_jogs` 与 merge/split 会在拐角处留下额外正交分支;global/switchbox 路由也不会保证 degree-2 拓扑。 -- 视觉上仍是「两平行主线 + 短 offset」,但拓扑上端点已挂分支,**不是** placement 问题,而是 cleanup 后图结构更复杂。 +TG032-WH / PT4115:LED+/W+ 顶部长水平线,GND/LED-/W- 底部长水平线;主控 U 不被长线穿过;L 与输出连接器在右侧;PWM/DIM 为短支路。 -### 本次扩展(不放宽安全检查) +### 2026-05-21 修订(TG032 绕框问题) -| 项 | 说明 | -|----|------| -| 主通路选取 | `main_legs_at()` 在 junction 上按方向筛平行侧腿,**确定性**排序后枚举 `(seg_a, seg_c)` 候选 | -| 分支保留 | `attachment_points_ok()`:待移除段与**保留**同 net 段的交汇点必须仍落在新路径上;「有分支」不再一律跳过 | -| 仍保守 | part / other-net obstruction、pin 落点、小 detour 阈值、每次只改一处、仅 `human_readable=True` — **均未放宽** | -| 跨 net | 仍只读写当前 net 的 `segments`;其它 net 仅通过既有 `obstructed()` 同轨 overlay 检查 | +根因:仅预布了 `/LED+`、`GND`、`/LED-`,**`Net-(D1-A)` / `Net-(D1-K)` 仍走 switchbox**,且 R1 不在主链行、去耦电容被甩远、`cleanup_wires` 对 rail 网做 split/去 jog。 -### 默认行为 +修订: -- **`human_readable=False`**:**不变**。 -- **`human_readable=True`**:在既有 pass 上扩大可压平范围;不确定拓扑时 `try_apply` 失败即跳过。 +- 主链行纳入卫星件(R1 等),`Net-(D1-*)` 用 **chain local 水平母线** 预布线 +- 布线前 **重算** `top_y`/`bottom_y`(与 expansion 后坐标一致) +- LED 去耦电容贴在 U2 右侧、两 rail 之间 +- `_driver_prerouted_nets` 跳过 cleanup 的 jog/split +- `auto_stub` 会把 GND/LED± 标成 label-only;`restore_driver_wire_nets` 在布线前恢复保留网 +- cleanup 第一轮也必须跳过 prerouted(此前会 `trim_stubs` 把预布线删光 → 原理图“无线”) From 34bf18806da72c4fd9c0d188b2affd3f61eb14fc Mon Sep 17 00:00:00 2001 From: Doris619619 Date: Fri, 22 May 2026 10:33:07 +0800 Subject: [PATCH 13/16] =?UTF-8?q?fix(schematic):=20=E4=BF=AE=E6=AD=A3=20dr?= =?UTF-8?q?iver=20=E9=A1=B6=E5=BA=95=20rail=20=E8=B7=9D=E5=99=A8=E4=BB=B6?= =?UTF-8?q?=E8=BF=87=E8=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/skidl/schematics/route.py | 32 +++-- src/skidl/schematics/topology.py | 126 +++++++++++++++--- .../test_topology_generic_driver.py | 36 +++++ 3 files changed, 164 insertions(+), 30 deletions(-) diff --git a/src/skidl/schematics/route.py b/src/skidl/schematics/route.py index 60960e94..e8559132 100644 --- a/src/skidl/schematics/route.py +++ b/src/skidl/schematics/route.py @@ -62,16 +62,16 @@ def _driver_rail_corridor_hits_bbox(bb, rail_y, x_min, x_max, grid, side="top"): def _shift_driver_rail_y(node, rail_y, x_min, x_max, grid, side, max_tries=5): - """预布线前再次外移 rail_y,避免水平线穿过器件 place_bbox。""" - real = [ - p - for p in node.parts - if getattr(p, "ref", None) and getattr(p, "place_bbox", None) and getattr(p, "tx", None) - ] + """预布线前再次外移 rail_y,避免水平线穿过器件可见外框(非 place_bbox 膨胀)。""" + from skidl.schematics.topology import _part_visual_bbox + + real = [p for p in node.parts if getattr(p, "ref", None) and getattr(p, "tx", None)] for _ in range(max_tries): blocked = False for part in real: - bb = part.place_bbox * part.tx + bb = _part_visual_bbox(part) + if bb is None: + continue if _driver_rail_corridor_hits_bbox(bb, rail_y, x_min, x_max, grid, side): blocked = True break @@ -85,7 +85,7 @@ def _shift_driver_rail_y(node, rail_y, x_min, x_max, grid, side, max_tries=5): def _refresh_driver_rail_plan(node, nets, options): - """布线前按当前 place_bbox 重算 rail 走廊(避免 place 与 route 两次 expansion 错位)。""" + """布线前按当前可见外框重算 rail 走廊(lbl_bbox,不受 place 膨胀影响)。""" topology = getattr(node, "_last_topology_result", None) or {} if topology.get("kind") != "generic_driver" or topology.get("fallback") is not False: return getattr(node, "_driver_rail_plan", None) or {"enabled": False} @@ -109,6 +109,19 @@ def _driver_route_pins(node, net): ] +def _driver_chain_bus_y(pin_pts, grid): + """ + 主链行内水平母线的 Y。 + 不用 (top_y + bottom_y) / 2:place 膨胀 bbox 会把 plan 的 bottom_y 拉得很远。 + 用引脚 Y 中位数下移一格,避免离群 pin 或膨胀 bbox 把母线甩到页面底部。 + """ + if not pin_pts: + return 0 + ys = sorted(pt.y for pt in pin_pts) + row_y = ys[len(ys) // 2] + return Point(0, row_y + grid).snap(grid).y + + def route_driver_chain_local_nets(node, nets, **options): """ 主链行内网(含 Net-(D1-A)/Net-(D1-K)):水平短母线 + 竖 stub,不走 switchbox 绕框。 @@ -122,7 +135,6 @@ def route_driver_chain_local_nets(node, nets, **options): grid = int(plan.get("grid", options.get("grid", 100))) rail_handled = set(getattr(node, "_driver_rail_routed_nets", set()) or []) - mid_y = Point(0, (plan["top_y"] + plan["bottom_y"]) / 2).snap(grid).y handled = set() for net in nets: @@ -135,7 +147,7 @@ def route_driver_chain_local_nets(node, nets, **options): continue pin_pts = [(pin.pt * pin.part.tx).round() for pin in pins] - bus_y = mid_y + bus_y = _driver_chain_bus_y(pin_pts, grid) x_min = min(pt.x for pt in pin_pts) x_max = max(pt.x for pt in pin_pts) segs = [Segment(Point(x_min, bus_y), Point(x_max, bus_y))] diff --git a/src/skidl/schematics/topology.py b/src/skidl/schematics/topology.py index c4bade0b..29b713b3 100644 --- a/src/skidl/schematics/topology.py +++ b/src/skidl/schematics/topology.py @@ -561,8 +561,52 @@ def _union_placed_bbox(parts): return bb +def _part_visual_bbox(part): + """原理图可见外框:lbl_bbox 优先,避免 place_bbox 布线膨胀把 rail 甩远。""" + tx = getattr(part, "tx", None) + if tx is None: + return None + lbl = getattr(part, "lbl_bbox", None) + if lbl is not None: + return lbl * tx + place = getattr(part, "place_bbox", None) + if place is not None: + return place * tx + return None + + +def _union_visual_bbox(parts): + """合并已放置器件的可见外框,供 driver rail 顶/底 Y 与走廊计算。""" + bb = BBox(Point(0, 0), Point(0, 0)) + any_part = False + for part in parts: + vis = _part_visual_bbox(part) + if vis is None: + continue + bb.add(vis) + any_part = True + if not any_part: + return None + return bb + + +def _layout_bbox(part): + """driver 分区/主链布局用的外框:可见符号框,不用 place 布线膨胀。""" + vis = _part_visual_bbox(part) + if vis is not None: + return vis + if getattr(part, "place_bbox", None) is not None and getattr(part, "tx", None) is not None: + return part.place_bbox * part.tx + return None + + +def _part_layout_h(part, grid): + bb = _layout_bbox(part) + return max(bb.h if bb is not None else 0, grid) + + def _rail_corridor_intersects_bbox(bb, rail_y, x_min, x_max, grid, side="top"): - """水平 rail 走廊(宽 GRID)是否与器件 place_bbox 相交。""" + """水平 rail 走廊(宽 GRID)是否与器件可见/放置框相交。""" if x_max < bb.min.x or x_min > bb.max.x: return False if side == "top": @@ -572,14 +616,49 @@ def _rail_corridor_intersects_bbox(bb, rail_y, x_min, x_max, grid, side="top"): return not (bb.max.y < band_lo or bb.min.y > band_hi) +def _driver_chain_pin_y_span(node): + """主功率链引脚 Y 范围(Y 向上);无链时返回 None。""" + row_parts = set(getattr(node, "_driver_chain_parts", set()) or []) + if not row_parts: + return None + ys = [] + for part in row_parts: + tx = getattr(part, "tx", None) + if tx is None: + continue + for pin in getattr(part, "pins", []): + if getattr(pin, "stub", False): + continue + if not pin.is_connected(): + continue + ys.append((pin.pt * tx).y) + if not ys: + return None + return min(ys), max(ys) + + +def _clamp_rail_y_to_driver_chain(node, top_y, bottom_y, grid, rail_margin): + """ + 把顶/底 rail 限制在主链引脚附近,避免 union/place 离群框把 rail 甩到页底。 + """ + span = _driver_chain_pin_y_span(node) + if span is None: + return top_y, bottom_y + row_lo, row_hi = span + band = max(rail_margin, 3 * grid) + top_y = max(top_y, row_lo - band) + bottom_y = min(bottom_y, row_hi + band) + return top_y, bottom_y + + def _find_clear_rail_y(node, parts, rail_y, x_min, x_max, grid, side, max_tries=5): - """若走廊压到器件 bbox,沿外侧逐格偏移 rail_y(最多 5 次)。""" + """若走廊压到器件可见框,沿外侧逐格偏移 rail_y(最多 5 次)。""" for _ in range(max_tries): blocked = False for part in parts: - if getattr(part, "place_bbox", None) is None or getattr(part, "tx", None) is None: + bb = _part_visual_bbox(part) + if bb is None: continue - bb = part.place_bbox * part.tx if _rail_corridor_intersects_bbox(bb, rail_y, x_min, x_max, grid, side): blocked = True break @@ -628,7 +707,7 @@ def build_driver_rail_plan(node, parts, nets, topology, main_part, **options): for p in parts if getattr(p, "place_bbox", None) is not None and getattr(p, "tx", None) is not None ] - union = _union_placed_bbox(real_parts) + union = _union_visual_bbox(real_parts) if union is None: return disabled @@ -643,6 +722,9 @@ def build_driver_rail_plan(node, parts, nets, topology, main_part, **options): bottom_y = _find_clear_rail_y( node, real_parts, bottom_y, x_min, x_max, grid, side="bottom" ) + top_y, bottom_y = _clamp_rail_y_to_driver_chain( + node, top_y, bottom_y, grid, rail_margin + ) return { "enabled": True, @@ -688,9 +770,9 @@ def _log_rail_blockers(node, parts, plan, options): x_max = plan["x_max"] for side, rail_y in (("top", plan["top_y"]), ("bottom", plan["bottom_y"])): for part in parts: - if getattr(part, "place_bbox", None) is None or getattr(part, "tx", None) is None: + bb = _part_visual_bbox(part) + if bb is None: continue - bb = part.place_bbox * part.tx if _rail_corridor_intersects_bbox(bb, rail_y, x_min, x_max, grid, side): active_logger.info( "[schematic] driver rail blocker: ref=%s bbox=%s rail=%s" @@ -777,7 +859,7 @@ def apply_driver_rail_safe_placement( for p in parts if getattr(p, "place_bbox", None) is not None and getattr(p, "tx", None) is not None ] - union = _union_placed_bbox(real_parts) + union = _union_visual_bbox(real_parts) if union is None: return @@ -814,7 +896,7 @@ def _nudge_y(part, target_cy): for part in parts: if part in row_parts or part is main_part or part in decoup_caps: continue - h = max(getattr(part.place_bbox, "h", 0), grid) + h = _part_layout_h(part, grid) if _part_on_net_set(part, top_set) and not _part_on_net_set(part, bottom_set): _nudge_y(part, top_y + grid + h / 2) elif _part_on_net_set(part, bottom_set) and not _part_on_net_set(part, top_set): @@ -826,18 +908,22 @@ def _nudge_y(part, target_cy): key=node._part_ref_key, ) if control_parts: - main_bbox = main_part.place_bbox * main_part.tx - ctrl_x = main_bbox.max.x + gap * 2 + main_vis = _layout_bbox(main_part) + if main_vis is None: + main_vis = main_part.place_bbox * main_part.tx + ctrl_x = main_vis.max.x + gap * 2 ctrl_y = mid_y + gap _place_parts_in_row(node, control_parts, ctrl_x, ctrl_y, gap, grid) # LED+/LED- 去耦:贴在主控右侧、两 rail 之间竖排,避免甩到图纸底部。 if decoup_caps: - main_bbox = main_part.place_bbox * main_part.tx - cx = main_bbox.max.x + gap * 2 + main_vis = _layout_bbox(main_part) + if main_vis is None: + main_vis = main_part.place_bbox * main_part.tx + cx = main_vis.max.x + gap * 2 y_cursor = top_y + grid for cap in sorted(decoup_caps, key=node._part_ref_key): - h = max(getattr(cap.place_bbox, "h", 0), grid) + h = _part_layout_h(cap, grid) _nudge_y(cap, y_cursor + h / 2) ctr = node._placement_ctr(cap) snapped_x = Point(cx, ctr.y).snap(grid).x @@ -1003,7 +1089,9 @@ def apply_generic_driver_layout( "trunk_gap", max(blk_pad, grid * 2) ) - main_bbox = main_part.place_bbox * main_part.tx + main_bbox = _layout_bbox(main_part) + if main_bbox is None: + return main_ctr = node._placement_ctr(main_part) chain, chain_parts = _build_driver_chain_order( node, roles, topology, main_part @@ -1026,9 +1114,7 @@ def apply_generic_driver_layout( key=node._part_ref_key, ) if aux_output and not use_rail: - top_y = main_bbox.min.y - gap - max( - max(getattr(p.place_bbox, "h", 0), grid) for p in aux_output - ) + top_y = main_bbox.min.y - gap - max(_part_layout_h(p, grid) for p in aux_output) _place_parts_in_row( node, aux_output, @@ -1044,7 +1130,7 @@ def apply_generic_driver_layout( [p for p in topology.get("control_parts", set()) if p not in chain_parts], key=node._part_ref_key, ) - if control_parts: + if control_parts and not use_rail: ctrl_y = main_bbox.max.y + gap _place_parts_in_row( node, @@ -1062,7 +1148,7 @@ def apply_generic_driver_layout( key=node._part_ref_key, ) if sense_parts: - max_h = max(max(getattr(p.place_bbox, "h", 0), grid) for p in sense_parts) + max_h = max(_part_layout_h(p, grid) for p in sense_parts) sense_y = main_bbox.min.y - gap - max_h _place_parts_in_row( node, diff --git a/tests/unit_tests/test_topology_generic_driver.py b/tests/unit_tests/test_topology_generic_driver.py index 2143e4a6..b8cd25f2 100644 --- a/tests/unit_tests/test_topology_generic_driver.py +++ b/tests/unit_tests/test_topology_generic_driver.py @@ -2,12 +2,14 @@ """generic driver topology 检测与日志格式单元测试。""" +from skidl.geometry import BBox, Point, Tx from skidl.schematics.topology import ( _collect_driver_rail_nets, _disabled_topology, _is_anonymous_net, _score_candidate_ic, _token_in_text, + build_driver_rail_plan, detect_known_topology, format_topology_log_line, ) @@ -158,6 +160,40 @@ def test_driver_score_combo_on_minimal_buck_like_graph(): assert sc >= 8 +def test_build_driver_rail_plan_uses_visual_bbox_not_place_padding(): + """place_bbox 膨胀很大时,bottom_y 仍应贴近 lbl_bbox 下沿。""" + grid = 100 + u2 = _FakePart("U2") + u2.tx = Tx() + u2.lbl_bbox = BBox(Point(0, 0), Point(400, 500)) + u2.place_bbox = BBox(Point(-2000, -2000), Point(6000, 8000)) + gnd = _FakeNet("GND") + vin = _FakeNet("VCC_24V") + topology = { + "kind": "generic_driver", + "fallback": False, + "control_nets": [], + "switch_or_drive_nets": [], + "ground_nets": [gnd], + "input_nets": [vin], + "power_nets": [vin], + "output_nets": [], + } + node = _FakeNode() + plan = build_driver_rail_plan( + node, + [u2], + [gnd, vin], + topology, + u2, + human_readable=True, + driver_rail_routing=True, + grid=grid, + ) + assert plan["enabled"] + assert plan["bottom_y"] <= 500 + 3 * grid + + def test_collect_driver_rail_nets_excludes_control_and_anonymous(): node = _FakeNode() u2 = _FakePart("U2") From e8e053f6c0780eb4044df4199be7b1f86d27a123 Mon Sep 17 00:00:00 2001 From: rhaingenix Date: Sat, 23 May 2026 11:19:59 +0800 Subject: [PATCH 14/16] Add device name and reference preservation --- and_gate.kicad_sch | 1253 +++++++++++++++++ src/skidl/circuit.py | 151 +- src/skidl/netlist_to_skidl.py | 71 +- src/skidl/part.py | 29 + src/skidl/schematics/route.py | 129 +- src/skidl/tools/kicad6/sexp_schematic.py | 7 +- src/skidl/tools/kicad7/sexp_schematic.py | 133 +- src/skidl/tools/kicad8/sexp_schematic.py | 7 +- src/skidl/tools/kicad9/gen_schematic.py | 9 +- src/skidl/tools/kicad9/sexp_schematic.py | 474 ++++++- .../unit_tests/ai_tests/test_route_cleanup.py | 36 + .../ai_tests/test_sexp_schematic.py | 293 +++- verification_output/verify_refs.kicad_sch | 559 ++++++++ 13 files changed, 3073 insertions(+), 78 deletions(-) create mode 100644 and_gate.kicad_sch create mode 100644 verification_output/verify_refs.kicad_sch diff --git a/and_gate.kicad_sch b/and_gate.kicad_sch new file mode 100644 index 00000000..66cabbd9 --- /dev/null +++ b/and_gate.kicad_sch @@ -0,0 +1,1253 @@ +(kicad_sch + (version 20230409) + (generator "skidl") + (generator_version "2.2.3") + (uuid 218b5f90-a139-55d3-a988-331ecbe7ee0c) + (paper "A4") + (title_block + (title "SKiDL-Generated Schematic") + (date "2026-05-20") + (company "") + (comment 1 "Generated with SKiDL") + (comment 2 "") + (comment 3 "") + (comment 4 "")) + (lib_symbols + (symbol "power:GND" + (pin_numbers + (hide yes)) + (pin_names + (offset 0)) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (property "Reference" "#PWR" + (at 2.032 0 90) + (effects + (font + (size 1.27 1.27)))) + (property "Value" "GND" + (at 0 0 90) + (effects + (font + (size 1.27 1.27)))) + (property "Footprint" "" + (at 0 0 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 0 0 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (symbol "GND_0_1" + (polyline + (pts + (xy 0 0) + (xy 0 -1.27) + (xy 1.27 -1.27) + (xy 0 -2.54) + (xy -1.27 -1.27) + (xy 0 -1.27)) + (stroke + (width 0) + (type default) + (color 0 0 0 0)) + (fill + (type none)))) + (symbol "GND_1_1" + (polyline + (pts + (xy 0 0) + (xy 0 -1.27) + (xy 1.27 -1.27) + (xy 0 -2.54) + (xy -1.27 -1.27) + (xy 0 -1.27)) + (stroke + (width 0) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (pin power_in line + (at 0 0 270) + (length 0) hide + (name "GND" + (effects + (font + (size 1.27 1.27)))) + (number "1" + (effects + (font + (size 1.27 1.27)))))) + (embedded_fonts no)) + (symbol "power:VCC" + (pin_numbers + (hide yes)) + (pin_names + (offset 0)) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (property "Reference" "#PWR" + (at 2.032 0 90) + (effects + (font + (size 1.27 1.27)))) + (property "Value" "VCC" + (at 0 0 90) + (effects + (font + (size 1.27 1.27)))) + (property "Footprint" "" + (at 0 0 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 0 0 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (symbol "VCC_0_1" + (polyline + (pts + (xy -0.762 1.27) + (xy 0 2.54)) + (stroke + (width 0) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (polyline + (pts + (xy 0 0) + (xy 0 2.54)) + (stroke + (width 0) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (polyline + (pts + (xy 0 2.54) + (xy 0.762 1.27)) + (stroke + (width 0) + (type default) + (color 0 0 0 0)) + (fill + (type none)))) + (symbol "VCC_1_1" + (polyline + (pts + (xy -0.762 1.27) + (xy 0 2.54)) + (stroke + (width 0) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (polyline + (pts + (xy 0 0) + (xy 0 2.54)) + (stroke + (width 0) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (polyline + (pts + (xy 0 2.54) + (xy 0.762 1.27)) + (stroke + (width 0) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (pin power_in line + (at 0 0 90) + (length 0) hide + (name "VCC" + (effects + (font + (size 1.27 1.27)))) + (number "1" + (effects + (font + (size 1.27 1.27)))))) + (embedded_fonts no)) + (symbol "Transistor_BJT:Q_PNP_CBE" + (pin_numbers + (hide yes)) + (pin_names + (offset 0)) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (property "Reference" "Q" + (at 2.032 0 90) + (effects + (font + (size 1.27 1.27)))) + (property "Value" "Q_PNP_CBE" + (at 0 0 90) + (effects + (font + (size 1.27 1.27)))) + (property "Footprint" "" + (at 0 0 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 0 0 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Description" "PNP transistor, collector/base/emitter" + (at 0 0 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (symbol "Q_PNP_CBE_0_1" + (polyline + (pts + (xy -2.54 0) + (xy 0.635 0)) + (stroke + (width 0) + (type default)) + (fill + (type none))) + (polyline + (pts + (xy 0.635 1.905) + (xy 0.635 -1.905)) + (stroke + (width 0.508) + (type default)) + (fill + (type none))) + (polyline + (pts + (xy 0.635 0.635) + (xy 2.54 2.54)) + (stroke + (width 0) + (type default)) + (fill + (type none))) + (polyline + (pts + (xy 0.635 -0.635) + (xy 2.54 -2.54)) + (stroke + (width 0) + (type default)) + (fill + (type none))) + (circle + (center 1.27 0) + (radius 2.8194) + (stroke + (width 0.254) + (type default)) + (fill + (type none))) + (polyline + (pts + (xy 2.286 -1.778) + (xy 1.778 -2.286) + (xy 1.27 -1.27) + (xy 2.286 -1.778)) + (stroke + (width 0) + (type default)) + (fill + (type outline)))) + (symbol "Q_PNP_CBE_1_1" + (polyline + (pts + (xy -2.54 0) + (xy 0.635 0)) + (stroke + (width 0) + (type default)) + (fill + (type none))) + (polyline + (pts + (xy 0.635 1.905) + (xy 0.635 -1.905)) + (stroke + (width 0.508) + (type default)) + (fill + (type none))) + (polyline + (pts + (xy 0.635 0.635) + (xy 2.54 2.54)) + (stroke + (width 0) + (type default)) + (fill + (type none))) + (polyline + (pts + (xy 0.635 -0.635) + (xy 2.54 -2.54)) + (stroke + (width 0) + (type default)) + (fill + (type none))) + (circle + (center 1.27 0) + (radius 2.8194) + (stroke + (width 0.254) + (type default)) + (fill + (type none))) + (polyline + (pts + (xy 2.286 -1.778) + (xy 1.778 -2.286) + (xy 1.27 -1.27) + (xy 2.286 -1.778)) + (stroke + (width 0) + (type default)) + (fill + (type outline))) + (pin input line + (at -5.08 0 0) + (length 2.54) + (name "B" + (effects + (font + (size 1.27 1.27)))) + (number "2" + (effects + (font + (size 1.27 1.27))))) + (pin passive line + (at 2.54 5.08 270) + (length 2.54) + (name "C" + (effects + (font + (size 1.27 1.27)))) + (number "1" + (effects + (font + (size 1.27 1.27))))) + (pin passive line + (at 2.54 -5.08 90) + (length 2.54) + (name "E" + (effects + (font + (size 1.27 1.27)))) + (number "3" + (effects + (font + (size 1.27 1.27)))))) + (embedded_fonts no)) + (symbol "Device:R" + (pin_numbers + (hide yes)) + (pin_names + (offset 0)) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (property "Reference" "R" + (at 2.032 0 90) + (effects + (font + (size 1.27 1.27)))) + (property "Value" "R" + (at 0 0 90) + (effects + (font + (size 1.27 1.27)))) + (property "Footprint" "" + (at 0 0 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 0 0 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (symbol "R_0_1" + (rectangle + (start -1.016 -2.54) + (end 1.016 2.54) + (stroke + (width 0.254) + (type default) + (color 0 0 0 0)) + (fill + (type none)))) + (symbol "R_1_1" + (rectangle + (start -1.016 -2.54) + (end 1.016 2.54) + (stroke + (width 0.254) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (pin passive line + (at 0 3.81 270) + (length 1.27) + (name "~" + (effects + (font + (size 1.27 1.27)))) + (number "1" + (effects + (font + (size 1.27 1.27))))) + (pin passive line + (at 0 -3.81 90) + (length 1.27) + (name "~" + (effects + (font + (size 1.27 1.27)))) + (number "2" + (effects + (font + (size 1.27 1.27)))))) + (embedded_fonts no))) + (symbol + (lib_id "power:GND") + (at 151.13 123.19 0) + (unit 1) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (dnp no) + (fields_autoplaced yes) + (uuid d86bdffa-aa90-5adf-b7cd-91774c0ed0df) + (property "Reference" "#PWR1" + (at 151.13 120.64999999999999 0) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Value" "GND" + (at 151.13 125.73 0) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Footprint" "" + (at 151.13 123.19 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 151.13 123.19 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Description" "" + (at 151.13 123.19 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (pin "1" + (uuid 880cedd3-f82b-55c2-8b04-12b5e701625c)) + (instances + (project "SKiDL-Generated" + (path "/" + (reference "#PWR1") + (unit 1))))) + (symbol + (lib_id "power:VCC") + (at 153.67 80.01 0) + (unit 1) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (dnp no) + (fields_autoplaced yes) + (uuid fd58ff2a-b7cb-590c-be02-525b1b3ee5d5) + (property "Reference" "#PWR2" + (at 153.67 77.47 0) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Value" "VCC" + (at 153.67 82.55000000000001 0) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Footprint" "" + (at 153.67 80.01 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 153.67 80.01 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Description" "" + (at 153.67 80.01 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (pin "1" + (uuid cdb03d6d-8dda-5aac-9953-349b124d7443)) + (instances + (project "SKiDL-Generated" + (path "/" + (reference "#PWR2") + (unit 1))))) + (symbol + (lib_id "Transistor_BJT:Q_PNP_CBE") + (at 153.67 92.71 0) + (mirror x) + (unit 1) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (dnp no) + (fields_autoplaced yes) + (uuid 9699dfdd-a6d3-59e3-8793-7f24db869fba) + (property "Reference" "Q1" + (at 153.67 90.16999999999999 0) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Value" "Q_PNP_CBE" + (at 153.67 95.25 0) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Footprint" "" + (at 153.67 92.71 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 153.67 92.71 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Description" "PNP transistor, collector/base/emitter" + (at 153.67 92.71 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (pin "2" + (uuid b5a4fd7a-94e6-53fa-bd15-916fa698801b)) + (pin "1" + (uuid 646d3a9d-9b05-5057-9d6c-6f120cc8ecab)) + (pin "3" + (uuid 73de6b6e-94c3-56ac-b6ac-c2b648626792)) + (instances + (project "SKiDL-Generated" + (path "/" + (reference "Q1") + (unit 1))))) + (symbol + (lib_id "Transistor_BJT:Q_PNP_CBE") + (at 153.67 105.41 0) + (mirror x) + (unit 1) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (dnp no) + (fields_autoplaced yes) + (uuid 7852643a-7934-5503-82c8-62f05e9bd38e) + (property "Reference" "Q2" + (at 153.67 102.86999999999999 0) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Value" "Q_PNP_CBE" + (at 153.67 107.95 0) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Footprint" "" + (at 153.67 105.41 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 153.67 105.41 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Description" "PNP transistor, collector/base/emitter" + (at 153.67 105.41 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (pin "2" + (uuid 606c5a53-0b2b-5950-9b1d-d40e07441676)) + (pin "1" + (uuid c2a0e8af-ef2a-5367-98a9-a8daa197adef)) + (pin "3" + (uuid 8bb45663-daf3-57e0-b14c-3be9f355f3ad)) + (instances + (project "SKiDL-Generated" + (path "/" + (reference "Q2") + (unit 1))))) + (symbol + (lib_id "Device:R") + (at 137.16 100.33 90) + (mirror x) + (unit 1) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (dnp no) + (fields_autoplaced yes) + (uuid 91628e47-8f83-5ba1-abed-4cb134d83eea) + (property "Reference" "R1" + (at 137.16 97.78999999999999 90) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Value" "10K" + (at 137.16 102.87 90) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Footprint" "" + (at 137.16 100.33 90) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 137.16 100.33 90) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Description" "" + (at 137.16 100.33 90) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (pin "1" + (uuid a6935952-da4d-541a-895a-668252bfbd0d)) + (pin "2" + (uuid af1579b0-1060-540f-a855-b50b54978d21)) + (instances + (project "SKiDL-Generated" + (path "/" + (reference "R1") + (unit 1))))) + (symbol + (lib_id "Device:R") + (at 137.16 95.25 90) + (mirror x) + (unit 1) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (dnp no) + (fields_autoplaced yes) + (uuid 91c587b0-c855-58aa-84a9-2ba4ce88549e) + (property "Reference" "R2" + (at 137.16 92.71 90) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Value" "10K" + (at 137.16 97.79 90) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Footprint" "" + (at 137.16 95.25 90) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 137.16 95.25 90) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Description" "" + (at 137.16 95.25 90) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (pin "1" + (uuid d35ad559-b677-5643-a89c-af29d787f459)) + (pin "2" + (uuid 195394c9-7ce9-58ed-99fb-76306ffc3957)) + (instances + (project "SKiDL-Generated" + (path "/" + (reference "R2") + (unit 1))))) + (symbol + (lib_id "Device:R") + (at 151.13 116.84 180) + (mirror x) + (unit 1) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (dnp no) + (fields_autoplaced yes) + (uuid 9000ab58-0faa-55db-ba96-c3a9c048b920) + (property "Reference" "R3" + (at 151.13 114.3 180) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Value" "10K" + (at 151.13 119.38000000000001 180) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Footprint" "" + (at 151.13 116.84 180) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 151.13 116.84 180) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Description" "" + (at 151.13 116.84 180) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (pin "1" + (uuid ddf5311a-2479-592c-a014-e4cda2c8fe72)) + (pin "2" + (uuid 4673c68e-d6b4-5f38-937b-5f6c1a06ffd9)) + (instances + (project "SKiDL-Generated" + (path "/" + (reference "R3") + (unit 1))))) + (symbol + (lib_id "Device:R") + (at 144.78 101.6 180) + (mirror x) + (unit 1) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (dnp no) + (fields_autoplaced yes) + (uuid a3a00a8b-c8d1-582e-9de2-300987de8d24) + (property "Reference" "R4" + (at 144.78 99.05999999999999 180) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Value" "10K" + (at 144.78 104.14 180) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Footprint" "" + (at 144.78 101.6 180) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 144.78 101.6 180) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Description" "" + (at 144.78 101.6 180) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (pin "1" + (uuid c8f5c74e-7ed2-53be-ba16-a78b7ed162bb)) + (pin "2" + (uuid 7528b6ca-cf7d-5abb-9c37-e751504e44cc)) + (instances + (project "SKiDL-Generated" + (path "/" + (reference "R4") + (unit 1))))) + (symbol + (lib_id "Device:R") + (at 154.94 116.84 0) + (unit 1) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (dnp no) + (fields_autoplaced yes) + (uuid 29f79588-e279-5ce8-b215-982a9074cc1e) + (property "Reference" "R5" + (at 154.94 114.3 0) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Value" "10K" + (at 154.94 119.38000000000001 0) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Footprint" "" + (at 154.94 116.84 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 154.94 116.84 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Description" "" + (at 154.94 116.84 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (pin "1" + (uuid 177b5537-3134-5432-b29d-81f51348a8b1)) + (pin "2" + (uuid 2f4d1c3c-f1a9-5868-b235-5453e7f575c4)) + (instances + (project "SKiDL-Generated" + (path "/" + (reference "R5") + (unit 1))))) + (global_label "A" + (shape bidirectional) + (at 127.0 100.33 0) + (effects + (font + (size 1.27 1.27)) + (justify left)) + (uuid 5282cf11-cbd7-5261-9881-4f4dff7b7bdf)) + (symbol + (lib_id "power:VCC") + (at 160.02 83.82 0) + (unit 1) + (exclude_from_sim yes) + (in_bom no) + (on_board yes) + (dnp no) + (fields_autoplaced yes) + (uuid 21696f6a-dfc2-5f71-855f-54351ded0924) + (property "Reference" "#PWR001" + (at 160.02 82.55 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Value" "VCC" + (at 160.02 80.00999999999999 0) + (effects + (font + (size 1.27 1.27)))) + (property "Footprint" "" + (at 160.02 83.82 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "" + (at 160.02 83.82 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (pin "1" + (uuid 47b689cc-bfd3-5f1a-bee0-8101f26fe14a)) + (instances + (project "SKiDL-Generated" + (path "/" + (reference "#PWR001") + (unit 1))))) + (global_label "B" + (shape bidirectional) + (at 127.0 95.25 0) + (effects + (font + (size 1.27 1.27)) + (justify left)) + (uuid b1eb0760-dbb3-59ee-9ecb-e494c363ca70)) + (global_label "A_AND_B" + (shape bidirectional) + (at 162.56 114.3 180) + (effects + (font + (size 1.27 1.27)) + (justify right)) + (uuid b004e1a2-747d-59ac-9253-b713e3f1418a)) + (symbol + (lib_id "power:GND") + (at 156.21 127.0 0) + (unit 1) + (exclude_from_sim yes) + (in_bom no) + (on_board yes) + (dnp no) + (fields_autoplaced yes) + (uuid 9923418d-f98b-5c5d-ba66-2c1733195cee) + (property "Reference" "#PWR002" + (at 156.21 125.73 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Value" "GND" + (at 156.21 123.19 0) + (effects + (font + (size 1.27 1.27)))) + (property "Footprint" "" + (at 156.21 127.0 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "" + (at 156.21 127.0 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (pin "1" + (uuid 2fc9f82a-80cc-53b3-adae-454cccef643a)) + (instances + (project "SKiDL-Generated" + (path "/" + (reference "#PWR002") + (unit 1))))) + (wire + (pts + (xy 144.78 105.41) + (xy 148.59 105.41)) + (stroke + (width 0) + (type default)) + (uuid f49e9d88-8596-5f13-8e16-cfe677479549)) + (wire + (pts + (xy 127.0 100.33) + (xy 133.35 100.33)) + (stroke + (width 0) + (type default)) + (uuid a8527027-4e48-5820-9c36-5a5bf8466275)) + (wire + (pts + (xy 127.0 95.25) + (xy 133.35 95.25)) + (stroke + (width 0) + (type default)) + (uuid 147e9bfb-a48c-5540-b951-3325983a6f1a)) + (wire + (pts + (xy 151.13 123.19) + (xy 151.13 121.92)) + (stroke + (width 0) + (type default)) + (uuid cdd93180-fedc-57e0-b6a0-8a7df11c1fed)) + (wire + (pts + (xy 151.13 121.92) + (xy 151.13 120.65)) + (stroke + (width 0) + (type default)) + (uuid 62cb3633-901d-59da-865c-6058aa6e7a1e)) + (wire + (pts + (xy 151.13 121.92) + (xy 154.94 121.92)) + (stroke + (width 0) + (type default)) + (uuid 4bc8cca9-0031-5a7b-8ed7-1c892e351612)) + (wire + (pts + (xy 154.94 121.92) + (xy 154.94 120.65)) + (stroke + (width 0) + (type default)) + (uuid 16174a74-1faf-5e3f-82aa-2765128df77b)) + (wire + (pts + (xy 154.94 121.92) + (xy 156.21 121.92)) + (stroke + (width 0) + (type default)) + (uuid f10c4020-8701-5d80-b1c7-4456e85bbb21)) + (wire + (pts + (xy 156.21 127.0) + (xy 156.21 121.92)) + (stroke + (width 0) + (type default)) + (uuid 4331e1db-3fa1-5d37-adbe-85330ceea7a4)) + (wire + (pts + (xy 153.67 86.36) + (xy 153.67 80.01)) + (stroke + (width 0) + (type default)) + (uuid 2f86773c-621d-55ff-8d68-f08f59e21052)) + (wire + (pts + (xy 153.67 86.36) + (xy 156.21 86.36)) + (stroke + (width 0) + (type default)) + (uuid 13f2f5dc-7d68-553c-8a58-cf3ab5b183ca)) + (wire + (pts + (xy 156.21 100.33) + (xy 163.83 100.33)) + (stroke + (width 0) + (type default)) + (uuid 60ea2fac-cf0d-5b6d-8879-374c73a5e7c5)) + (wire + (pts + (xy 156.21 87.63) + (xy 156.21 86.36)) + (stroke + (width 0) + (type default)) + (uuid d6bac2c2-bbaa-5352-a52b-b676d3dddc13)) + (wire + (pts + (xy 156.21 86.36) + (xy 156.21 83.82)) + (stroke + (width 0) + (type default)) + (uuid ba799e81-ecf5-5685-83bb-9c254e09e0d8)) + (wire + (pts + (xy 156.21 86.36) + (xy 163.83 86.36)) + (stroke + (width 0) + (type default)) + (uuid 2af9fa01-2159-56e3-94a9-ad2ea4cf5ecb)) + (wire + (pts + (xy 156.21 83.82) + (xy 160.02 83.82)) + (stroke + (width 0) + (type default)) + (uuid 326bc18d-d181-5a0b-b61e-b76c8aadda6d)) + (wire + (pts + (xy 163.83 100.33) + (xy 163.83 86.36)) + (stroke + (width 0) + (type default)) + (uuid 3d5f650d-3f27-5d1f-9e1c-92d57006ed01)) + (wire + (pts + (xy 154.94 113.03) + (xy 154.94 111.76)) + (stroke + (width 0) + (type default)) + (uuid cf584a99-af56-5954-8102-143df1d868e4)) + (wire + (pts + (xy 154.94 111.76) + (xy 156.21 111.76)) + (stroke + (width 0) + (type default)) + (uuid f116b1f1-4dd6-57c1-aee9-6c74b27511d6)) + (wire + (pts + (xy 156.21 111.76) + (xy 156.21 110.49)) + (stroke + (width 0) + (type default)) + (uuid ad26ee52-757d-51e5-8aaf-8b2d48c70a5c)) + (wire + (pts + (xy 156.21 111.76) + (xy 158.75 111.76)) + (stroke + (width 0) + (type default)) + (uuid e2600ce8-4dda-583c-94db-f8a4d192e6a2)) + (wire + (pts + (xy 158.75 114.3) + (xy 158.75 111.76)) + (stroke + (width 0) + (type default)) + (uuid 81b24a3f-16c0-5596-ae74-eaf09d25d2a5)) + (wire + (pts + (xy 158.75 114.3) + (xy 162.56 114.3)) + (stroke + (width 0) + (type default)) + (uuid 9a25f2af-4c77-502d-8955-6330f54593a1)) + (wire + (pts + (xy 140.97 100.33) + (xy 142.24 100.33)) + (stroke + (width 0) + (type default)) + (uuid aeb54750-ee34-55a9-a723-3df0dc8adeaf)) + (wire + (pts + (xy 140.97 95.25) + (xy 142.24 95.25)) + (stroke + (width 0) + (type default)) + (uuid b0fa0613-e4e4-56bf-8e91-bac6e372da6f)) + (wire + (pts + (xy 142.24 100.33) + (xy 142.24 95.25)) + (stroke + (width 0) + (type default)) + (uuid b000b492-5bb0-5746-9d4f-bf931f2f4f4a)) + (wire + (pts + (xy 142.24 95.25) + (xy 142.24 92.71)) + (stroke + (width 0) + (type default)) + (uuid b055034c-8902-5dad-b274-4c5535aade5c)) + (wire + (pts + (xy 142.24 92.71) + (xy 148.59 92.71)) + (stroke + (width 0) + (type default)) + (uuid d6aba6b6-655b-5c28-801d-49237bbb065e)) + (wire + (pts + (xy 144.78 97.79) + (xy 147.32 97.79)) + (stroke + (width 0) + (type default)) + (uuid f8525bca-7e90-500c-a9a5-831b15fa046c)) + (wire + (pts + (xy 147.32 113.03) + (xy 147.32 97.79)) + (stroke + (width 0) + (type default)) + (uuid bd0919f6-a2fa-5a2b-9be5-9a1e8574d55d)) + (wire + (pts + (xy 147.32 113.03) + (xy 151.13 113.03)) + (stroke + (width 0) + (type default)) + (uuid 7d9dd6fb-7be1-5d08-a6e4-4dc9f13a5d5e)) + (wire + (pts + (xy 147.32 97.79) + (xy 156.21 97.79)) + (stroke + (width 0) + (type default)) + (uuid 37285166-dca8-54d0-8d94-cf43e3df99c8))) \ No newline at end of file diff --git a/src/skidl/circuit.py b/src/skidl/circuit.py index 8289e4db..5fc99339 100644 --- a/src/skidl/circuit.py +++ b/src/skidl/circuit.py @@ -13,6 +13,7 @@ import builtins import json +import re import subprocess from collections import Counter, deque @@ -72,6 +73,41 @@ class Circuit(SkidlBaseObject): # Set the default ERC functions for all Circuit instances. erc_list = [dflt_circuit_erc] + _ANNOTATION_PREFIX_RULES = ( + ("connector", "J"), + ("transistor", "Q"), + ("mosfet", "Q"), + ("fet", "Q"), + ("bjt", "Q"), + ("igbt", "Q"), + ("diode", "D"), + ("led", "D"), + ("zener", "D"), + ("resistor", "R"), + ("r_array", "R"), + ("potentiometer", "R"), + ("trimmer", "R"), + ("capacitor", "C"), + ("cap", "C"), + ("inductor", "L"), + ("coil", "L"), + ("ic", "U"), + ("opamp", "U"), + ("amplifier", "U"), + ("comparator", "U"), + ("buffer", "U"), + ("logic", "U"), + ("driver", "U"), + ("interface", "U"), + ("memory", "U"), + ("sensor", "U"), + ("regulator", "U"), + ("power", "U"), + ("mcu", "U"), + ("cpu", "U"), + ("fpga", "U"), + ) + _REF_NUM_RE = re.compile(r"^(.*?)(\d+)$") def __init__(self, **attrs): """ @@ -369,7 +405,16 @@ def add_parts(self, *parts): # Add the part to this circuit. part.circuit = self # Record the Circuit object for this part. - part.ref = part.ref # Adjusts the part reference if necessary. + clean_prefix = self._infer_ref_prefix(part) + part.ref_prefix = clean_prefix + if self._ref_is_placeholder(part.ref): + # Replace library placeholders such as D? immediately so + # later export stages always see a concrete reference. + part.ref = None + else: + part.ref = part.ref # Adjusts the part reference if necessary. + if getattr(part, "ref", None): + self._sync_reference_metadata(part, part.ref) # Add the part to the currently active node. self.active_node.parts.append(part) @@ -385,6 +430,105 @@ def add_parts(self, *parts): f"Can't add unmovable part {part.ref} to this circuit.", ) + @classmethod + def _ref_is_placeholder(cls, ref): + """Return True if a reference is empty or still using a library placeholder.""" + ref = str(ref or "").strip() + return not ref or "?" in ref + + @classmethod + def _split_annotated_ref(cls, ref): + """Return ``(prefix, number)`` for refs like ``R12`` or ``None`` if not numbered.""" + ref = str(ref or "").strip() + match = cls._REF_NUM_RE.match(ref) + if not match: + return None + return match.group(1), int(match.group(2)) + + @classmethod + def _infer_ref_prefix(cls, part): + """Infer a human-readable reference prefix for a part.""" + raw_prefix = str(getattr(part, "ref_prefix", "") or "").strip().upper().rstrip("?") + if raw_prefix and raw_prefix not in {"?", "#"}: + return raw_prefix + + search_fields = ( + getattr(part, "name", ""), + getattr(part, "description", ""), + getattr(getattr(part, "lib", None), "filename", ""), + ) + search_text = " ".join(str(field or "").lower() for field in search_fields) + + for needle, prefix in cls._ANNOTATION_PREFIX_RULES: + if needle in search_text: + return prefix + + ref = str(getattr(part, "ref", "") or "").strip().upper() + if ref: + inferred = "".join(ch for ch in ref if ch.isalpha() or ch == "#").rstrip("?") + if inferred: + return inferred + + return "U" + + @staticmethod + def _sync_reference_metadata(part, full_ref): + """Propagate an assigned reference into common metadata containers.""" + if hasattr(part, "fields") and isinstance(part.fields, dict): + part.fields["Reference"] = full_ref + part.fields["reference"] = full_ref + if hasattr(part, "properties") and isinstance(part.properties, dict): + part.properties["Reference"] = full_ref + part.properties["reference"] = full_ref + + def annotate_parts(self, force=False): + """Assign human-readable sequential references to parts lacking annotation. + + Args: + force (bool): When True, renumber all parts even if they already have + numbered references. By default, only empty / placeholder refs such + as ``R?`` and ``U?`` are replaced. + """ + counters = Counter() + used_refs = set() + + # First reserve any existing numbered references so explicit annotations stay stable. + for part in self.parts: + ref = str(getattr(part, "ref", "") or "").strip() + split_ref = self._split_annotated_ref(ref) + if force or self._ref_is_placeholder(ref) or not split_ref: + continue + + prefix, number = split_ref + used_refs.add(ref) + counters[prefix] = max(counters[prefix], number + 1) + + # Then fill in missing / placeholder references using inferred prefixes. + for part in self.parts: + ref = str(getattr(part, "ref", "") or "").strip() + split_ref = self._split_annotated_ref(ref) + if not force and split_ref and not self._ref_is_placeholder(ref): + continue + + prefix = self._infer_ref_prefix(part) + part.ref_prefix = prefix + next_num = max(1, counters[prefix]) + new_ref = f"{prefix}{next_num}" + while new_ref in used_refs: + next_num += 1 + new_ref = f"{prefix}{next_num}" + + part.ref = new_ref + self._sync_reference_metadata(part, new_ref) + used_refs.add(new_ref) + counters[prefix] = next_num + 1 + + # Keep metadata synchronized even for already-annotated parts. + for part in self.parts: + ref = str(getattr(part, "ref", "") or "").strip() + if ref: + self._sync_reference_metadata(part, ref) + def rmv_parts(self, *parts): """ Remove parts from the circuit. @@ -1281,6 +1425,9 @@ def generate_schematic(self, **kwargs): Args: **kwargs: Arguments for the schematic generator including: empty_footprint_handler (function, optional): Custom handler for parts without footprints. + annotate_refs (bool, optional): Automatically replace placeholder + references such as ``R?`` and ``U?`` with numbered references + before schematic export. Defaults to True. tool (str, optional): The EDA tool to generate the schematic for. """ @@ -1312,6 +1459,8 @@ def _empty_footprint_handler(part): self.merge_net_names() self.merge_nets() # Merge nets or schematic routing will fail. + if kwargs.pop("annotate_refs", True): + self.annotate_parts() tool = kwargs.pop("tool", skidl.config.tool) diff --git a/src/skidl/netlist_to_skidl.py b/src/skidl/netlist_to_skidl.py index e0451257..a4b9d9ec 100644 --- a/src/skidl/netlist_to_skidl.py +++ b/src/skidl/netlist_to_skidl.py @@ -18,6 +18,49 @@ from .logger import active_logger # Import the active_logger +_REFERENCE_ID_RE = re.compile(r"^([A-Za-z#]+)(\d+)(.*)$") +_REFERENCE_PREFIX_RE = re.compile(r"^([A-Za-z#]+)") + + +def get_ref_prefix(ref: str) -> str: + """Return the alphabetic reference prefix from a component reference.""" + match = _REFERENCE_PREFIX_RE.match(str(ref or "").strip()) + return match.group(1) if match else "X" + + +def get_ref_number(ref: str): + """Return the numeric suffix from a component reference if present.""" + match = _REFERENCE_ID_RE.match(str(ref or "").strip()) + return int(match.group(2)) if match else None + + +def normalize_reference(ref: str) -> str: + """Normalize a parsed component reference into its canonical string form.""" + ref = str(ref or "").strip() + return ref if ref else "X" + + +def annotate_components(components): + """Assign stable full references to components that lack numeric refs.""" + counters = defaultdict(int) + + for comp in components: + prefix = comp.ref_prefix + if comp.ref_number is not None: + counters[prefix] = max(counters[prefix], comp.ref_number + 1) + + for comp in components: + if comp.ref_number is not None: + continue + prefix = comp.ref_prefix or get_ref_prefix(comp.name) or "X" + next_num = max(counters[prefix], 1) + comp.ref = f"{prefix}{next_num}" + comp.ref_prefix = prefix + comp.ref_number = next_num + counters[prefix] = next_num + 1 + comp.set_property("Reference", comp.ref) + + class Sheet: """ Represents a hierarchical sheet from a KiCad schematic. @@ -131,12 +174,24 @@ class PartSexp: def __init__(self, sexp): self.sheetpath = sexp.search("/comp/sheetpath/names").value - self.ref = sexp.search("/comp/ref").value + self.original_ref = sexp.search("/comp/ref").value + self.ref = normalize_reference(self.original_ref) + self.ref_prefix = get_ref_prefix(self.ref) + self.ref_number = get_ref_number(self.ref) self.value = sexp.search("/comp/value").value self.footprint = sexp.search("/comp/footprint").value self.name = sexp.search("/comp/libsource/part").value self.lib = sexp.search("/comp/libsource/lib").value self.properties = [PropertySexp(prop) for prop in sexp.search("/comp/property")] + self.set_property("Reference", self.ref) + + def set_property(self, name, value): + """Set or create a parsed component property.""" + for prop in self.properties: + if prop.name == name: + prop.value = value + return + self.properties.append(PropertySexp.from_value(name, value)) class PropertySexp: @@ -148,6 +203,13 @@ def __init__(self, sexp): self.name = sexp.search("/property/name").value self.value = sexp.search("/property/value").value + @classmethod + def from_value(cls, name, value): + prop = cls.__new__(cls) + prop.name = name + prop.value = value + return prop + class PinSexp: """ @@ -185,7 +247,12 @@ class NetlistSexp: def __init__(self, sexp): self.sheets = [SheetSexp(sht) for sht in sexp.search("design/sheet")] self.parts = [PartSexp(comp) for comp in sexp.search("components/comp")] + annotate_components(self.parts) self.nets = [NetSexp(net) for net in sexp.search("nets/net")] + ref_map = {part.original_ref: part.ref for part in self.parts} + for net in self.nets: + for pin in net.pins: + pin.ref = ref_map.get(pin.ref, pin.ref) class HierarchicalConverter: @@ -458,6 +525,8 @@ def component_to_skidl(self, comp: object) -> str: desc = next((p.value for p in comp.properties if p.name == "Description"), None) if desc: props.append(f"description='{desc}'") + if comp.ref_prefix: + props.append(f"ref_prefix='{comp.ref_prefix}'") props.append(f"ref='{ref}'") extra_fields = {} if hasattr(comp, "properties"): diff --git a/src/skidl/part.py b/src/skidl/part.py index 239c8451..9f31caad 100644 --- a/src/skidl/part.py +++ b/src/skidl/part.py @@ -11,6 +11,7 @@ """ import functools +import re from collections.abc import Iterable from copy import copy from random import randint @@ -44,6 +45,17 @@ NETLIST, LIBRARY, TEMPLATE = ["NETLIST", "LIBRARY", "TEMPLATE"] +_REFERENCE_ID_RE = re.compile(r"^([A-Za-z#]+)(\d+)(.*)$") + + +def split_reference(ref): + """Return the prefix and numeric suffix from a reference designator.""" + match = _REFERENCE_ID_RE.match(str(ref or "").strip()) + if not match: + return str(ref or "").strip().rstrip("?"), None + return match.group(1), int(match.group(2)) + + class PinNumberSearch(object): """ A class for restricting part pin indexing to only pin numbers while ignoring pin names. @@ -1066,8 +1078,25 @@ def ref(self, r): # Now name the object with the given reference or some variation # of it that doesn't collide with anything else in the list. self._ref = get_unique_name(self.circuit.parts, "ref", self.ref_prefix, r) + self._sync_reference_identity(original_ref=r) return + def _sync_reference_identity(self, original_ref=None): + """Store the assigned full reference as this part's stable identity.""" + full_ref = str(self._ref or "").strip() + if original_ref is not None: + self.original_ref = str(original_ref or "").strip() + if not full_ref: + self.ref_number = None + return + + prefix, number = split_reference(full_ref) + if prefix: + self.ref_prefix = prefix + self.ref_number = number + self.fields["Reference"] = full_ref + self.fields["reference"] = full_ref + @ref.deleter def ref(self): """ diff --git a/src/skidl/schematics/route.py b/src/skidl/schematics/route.py index ea5245b1..ab93c05a 100644 --- a/src/skidl/schematics/route.py +++ b/src/skidl/schematics/route.py @@ -2105,35 +2105,124 @@ def _segment_obstructed(node, segment, net=None, ignored_parts=None): return False - def route_straight_nets(node, nets): - """Route aligned 2-pin nets directly before invoking the general router.""" + def _route_simple_manhattan_net(node, net, pins): + """Try routing a 2-pin net with a small set of direct Manhattan paths. - direct_routed = [] + This keeps nearby point-to-point nets out of the global/switchbox router, + which is more expensive and can fail unnecessarily on tiny local nets that + only need one or two orthogonal segments. + """ - for net in nets: - pins = list(node.get_internal_pins(net)) - if len(pins) != 2: - continue + if len(pins) != 2: + return False - p1 = (pins[0].pt * pins[0].part.tx).round() - p2 = (pins[1].pt * pins[1].part.tx).round() - if p1 == p2 or (p1.x != p2.x and p1.y != p2.y): - continue + p1 = (pins[0].pt * pins[0].part.tx).round() + p2 = (pins[1].pt * pins[1].part.tx).round() + if p1 == p2: + return False - seg = Segment(copy.copy(p1), copy.copy(p2)) + ignored_parts = {pins[0].part, pins[1].part} + + def ordered_segment(pt1, pt2): + seg = Segment(copy.copy(pt1), copy.copy(pt2)) if seg.p2 < seg.p1: seg.p1, seg.p2 = seg.p2, seg.p1 + return seg - if node._segment_obstructed( - seg, net=net, ignored_parts={pins[0].part, pins[1].part} - ): + def path_segments(points): + segments = [] + for pt1, pt2 in zip(points[:-1], points[1:]): + if pt1 == pt2: + continue + if pt1.x != pt2.x and pt1.y != pt2.y: + return None + segments.append(ordered_segment(pt1, pt2)) + return segments + + def path_is_clear(segments): + if not segments: + return False + return not any( + node._segment_obstructed( + seg, net=net, ignored_parts=ignored_parts + ) + for seg in segments + ) + + def segment_length(seg): + return abs(seg.p2.x - seg.p1.x) + abs(seg.p2.y - seg.p1.y) + + candidate_paths = [] + + if p1.x == p2.x or p1.y == p2.y: + direct = path_segments([p1, p2]) + if direct: + candidate_paths.append(direct) + + for corner in (Point(p1.x, p2.y), Point(p2.x, p1.y)): + path = path_segments([p1, corner, p2]) + if path: + candidate_paths.append(path) + + obstacle_bboxes = [(part.bbox * part.tx).round() for part in node.parts] + lane_xs = {p1.x, p2.x} + lane_ys = {p1.y, p2.y} + for bbox in obstacle_bboxes: + lane_xs.update((bbox.min.x, bbox.max.x)) + lane_ys.update((bbox.min.y, bbox.max.y)) + + for lane_x in sorted(lane_xs): + path = path_segments( + [p1, Point(lane_x, p1.y), Point(lane_x, p2.y), p2] + ) + if path: + candidate_paths.append(path) + + for lane_y in sorted(lane_ys): + path = path_segments( + [p1, Point(p1.x, lane_y), Point(p2.x, lane_y), p2] + ) + if path: + candidate_paths.append(path) + + unique_paths = [] + seen = set() + for path in candidate_paths: + key = tuple( + (seg.p1.x, seg.p1.y, seg.p2.x, seg.p2.y) for seg in path + ) + if key in seen: continue + seen.add(key) + unique_paths.append(path) + + unique_paths.sort( + key=lambda path: ( + len(path), + sum(segment_length(seg) for seg in path), + tuple((seg.p1.x, seg.p1.y, seg.p2.x, seg.p2.y) for seg in path), + ) + ) - node.wires[net].append(seg) - for pt in (p1, p2): - if pt not in pin_pts: - pin_pts.append(pt) - direct_routed.append(net) + for path in unique_paths: + if path_is_clear(path): + node.wires[net].extend(path) + for pt in (p1, p2): + if pt not in pin_pts: + pin_pts.append(pt) + return True + + return False + + def route_straight_nets(node, nets): + """Route simple 2-pin nets directly before invoking the general router.""" + + direct_routed = [] + + for net in nets: + pins = list(node.get_internal_pins(net)) + if node._route_simple_manhattan_net(net, pins): + direct_routed.append(net) return direct_routed diff --git a/src/skidl/tools/kicad6/sexp_schematic.py b/src/skidl/tools/kicad6/sexp_schematic.py index 3874beed..93bd929c 100644 --- a/src/skidl/tools/kicad6/sexp_schematic.py +++ b/src/skidl/tools/kicad6/sexp_schematic.py @@ -632,7 +632,7 @@ def part_to_lib_symbol_definition(part): [ "property", "Reference", - part.ref_prefix or "U", + getattr(part, "ref", None) or part.ref_prefix or "U", ["at", 2.032, 0, 90], ["effects", ["font", ["size", 1.27, 1.27]]], ], @@ -1235,6 +1235,11 @@ def write_top_schematic(circuit, node, filepath, top_name, title, version=202304 title: Schematic title. version: S-expression version number. """ + if hasattr(circuit, "annotate_parts"): + # Ensure placeholder references such as D? are resolved before any + # symbol instances, properties, or sheet files are emitted. + circuit.annotate_parts() + top_name = top_name or "schematic" _fix_sheet_filename(node) _reset_power_symbol_state() diff --git a/src/skidl/tools/kicad7/sexp_schematic.py b/src/skidl/tools/kicad7/sexp_schematic.py index 3874beed..84c099d4 100644 --- a/src/skidl/tools/kicad7/sexp_schematic.py +++ b/src/skidl/tools/kicad7/sexp_schematic.py @@ -19,6 +19,7 @@ import copy import datetime import os +import re import uuid from collections import OrderedDict @@ -632,7 +633,7 @@ def part_to_lib_symbol_definition(part): [ "property", "Reference", - part.ref_prefix or "U", + getattr(part, "ref", None) or part.ref_prefix or "U", ["at", 2.032, 0, 90], ["effects", ["font", ["size", 1.27, 1.27]]], ], @@ -1089,6 +1090,134 @@ def _fix_sheet_filename(node): node.sheet_filename = node.sheet_filename[:-4] + ".kicad_sch" +_FULL_REF_RE = re.compile(r"^([A-Za-z#]+)\d+[A-Za-z]?$") +_REF_PREFIX_RE = re.compile(r"^([A-Za-z#]+)") +_REFERENCE_PROP_RE = re.compile(r'(\(property\s+"Reference"\s+")([^"]*)(")') +_INSTANCE_REF_RE = re.compile(r'(\(reference\s+")([^"]*)(")') + + +def _reference_is_full(ref): + """Return True if a reference already has a numeric annotation.""" + return bool(_FULL_REF_RE.match(str(ref or "").strip())) + + +def _reference_prefix(ref): + """Return the alphabetic reference prefix from a ref or placeholder.""" + match = _REF_PREFIX_RE.match(str(ref or "").strip()) + return match.group(1).upper() if match else "" + + +def _reference_queues(parts): + """Build ordered full-reference queues, grouped by reference prefix.""" + refs_by_prefix = OrderedDict() + for part in parts: + if isinstance(part, NetTerminal): + continue + ref = str(getattr(part, "ref", "") or "").strip() + if not _reference_is_full(ref): + continue + prefix = _reference_prefix(ref) + refs_by_prefix.setdefault(prefix, []).append(ref) + return refs_by_prefix + + +def _find_symbol_blocks(text): + """Yield ``(start, end)`` ranges for S-expression symbol blocks.""" + for match in re.finditer(r"\(symbol(?=\s)", text): + start = match.start() + depth = 0 + in_string = False + escaped = False + for i in range(start, len(text)): + ch = text[i] + if in_string: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_string = False + continue + if ch == '"': + in_string = True + elif ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + yield start, i + 1 + break + + +def _fix_reference_block(block, refs_by_prefix, consumed): + """Fix a single symbol block's Reference property and instance reference.""" + prop_match = _REFERENCE_PROP_RE.search(block) + if not prop_match: + return block + + current_ref = prop_match.group(2) + is_instance_symbol = "(lib_id" in block + full_ref = current_ref if _reference_is_full(current_ref) else None + + if full_ref is None: + prefix = _reference_prefix(current_ref) + refs = refs_by_prefix.get(prefix, []) + if refs: + idx = consumed.get(prefix, 0) if is_instance_symbol else 0 + if idx >= len(refs): + idx = len(refs) - 1 + full_ref = refs[idx] + if is_instance_symbol: + consumed[prefix] = idx + 1 + + if full_ref is None: + return block + + if current_ref != full_ref: + block = _REFERENCE_PROP_RE.sub( + lambda m: f"{m.group(1)}{full_ref}{m.group(3)}", + block, + count=1, + ) + + def replace_instance_ref(match): + instance_ref = match.group(2) + if instance_ref == full_ref or _reference_is_full(instance_ref): + return match.group(0) + if _reference_prefix(instance_ref) == _reference_prefix(full_ref): + return f"{match.group(1)}{full_ref}{match.group(3)}" + return match.group(0) + + return _INSTANCE_REF_RE.sub(replace_instance_ref, block) + + +def _fix_exported_schematic_references(filepath, parts): + """Patch generated schematic Reference fields using assigned SKiDL refs.""" + refs_by_prefix = _reference_queues(parts) + if not refs_by_prefix or not os.path.exists(filepath): + return + + with open(filepath, "r", encoding="utf-8") as f: + text = f.read() + + consumed = {} + replacements = [] + for start, end in _find_symbol_blocks(text): + block = text[start:end] + fixed = _fix_reference_block(block, refs_by_prefix, consumed) + if fixed != block: + replacements.append((start, end, fixed)) + + if not replacements: + return + + for start, end, fixed in reversed(replacements): + text = text[:start] + fixed + text[end:] + + with open(filepath, "w", encoding="utf-8") as f: + f.write(text) + + @export_to_all def node_to_sexp_schematic(node, sheet_tx=Tx(), version=20230409): """Convert a SchNode tree to S-expression schematic(s). @@ -1211,6 +1340,7 @@ def node_to_sexp_schematic(node, sheet_tx=Tx(), version=20230409): # Write schematic file. filepath = os.path.join(node.filepath, node.sheet_filename) _write_sexp_schematic(schematic, filepath) + _fix_exported_schematic_references(filepath, node.parts) # Return a hierarchical sheet reference for the parent. return [create_hierarchical_sheet_sexp(node, sheet_tx)] @@ -1319,6 +1449,7 @@ def write_top_schematic(circuit, node, filepath, top_name, title, version=202304 output_file = os.path.join(filepath, f"{top_name}.kicad_sch") os.makedirs(filepath, exist_ok=True) _write_sexp_schematic(schematic, output_file) + _fix_exported_schematic_references(output_file, circuit.parts) # Optional: validate with kicad-cli if available. _validate_with_kicad_cli(output_file) diff --git a/src/skidl/tools/kicad8/sexp_schematic.py b/src/skidl/tools/kicad8/sexp_schematic.py index 3874beed..93bd929c 100644 --- a/src/skidl/tools/kicad8/sexp_schematic.py +++ b/src/skidl/tools/kicad8/sexp_schematic.py @@ -632,7 +632,7 @@ def part_to_lib_symbol_definition(part): [ "property", "Reference", - part.ref_prefix or "U", + getattr(part, "ref", None) or part.ref_prefix or "U", ["at", 2.032, 0, 90], ["effects", ["font", ["size", 1.27, 1.27]]], ], @@ -1235,6 +1235,11 @@ def write_top_schematic(circuit, node, filepath, top_name, title, version=202304 title: Schematic title. version: S-expression version number. """ + if hasattr(circuit, "annotate_parts"): + # Ensure placeholder references such as D? are resolved before any + # symbol instances, properties, or sheet files are emitted. + circuit.annotate_parts() + top_name = top_name or "schematic" _fix_sheet_filename(node) _reset_power_symbol_state() diff --git a/src/skidl/tools/kicad9/gen_schematic.py b/src/skidl/tools/kicad9/gen_schematic.py index 1b59f99d..d649749e 100644 --- a/src/skidl/tools/kicad9/gen_schematic.py +++ b/src/skidl/tools/kicad9/gen_schematic.py @@ -354,7 +354,7 @@ def _handle_fallback(circuit, tool_module, filepath, top_name, title, flatness, node.place(expansion_factor=1.0, **options) node.route(**options) output_file = write_top_schematic( - circuit, node, filepath, top_name, title, version=20230409 + circuit, node, filepath, top_name, title, version=20250114 ) finalize_parts_and_nets(circuit, **options) @@ -696,10 +696,9 @@ def power_supply(vin, vout, gnd): ) continue - # Generate S-expression schematic using shared module. - # KiCad 8/9 use version 20230409. + # Generate S-expression schematic using the KiCad 9 schema version. output_file = write_top_schematic( - circuit, node, filepath, top_name, title, version=20230409 + circuit, node, filepath, top_name, title, version=20250114 ) active_logger.info(f"Schematic written to {output_file}") @@ -748,7 +747,7 @@ def power_supply(vin, vout, gnd): _classify_and_stub_complex_nets(circuit, node, **options) node.route(**options) output_file = write_top_schematic( - circuit, node, filepath, top_name, title, version=20230409 + circuit, node, filepath, top_name, title, version=20250114 ) finalize_parts_and_nets(circuit, **options) erc_regen_ok = True diff --git a/src/skidl/tools/kicad9/sexp_schematic.py b/src/skidl/tools/kicad9/sexp_schematic.py index bc9aeb5a..df25d4a7 100644 --- a/src/skidl/tools/kicad9/sexp_schematic.py +++ b/src/skidl/tools/kicad9/sexp_schematic.py @@ -19,6 +19,7 @@ import copy import datetime import os +import re import uuid from collections import OrderedDict @@ -31,6 +32,7 @@ # UUID namespace — same as gen_netlist.py so UUIDs are cross-referenceable. _NAMESPACE_UUID = uuid.UUID("7026fcc6-e1a0-409e-aaf4-6a17ea82654f") +KICAD9_SCHEMATIC_VERSION = 20250114 # --------------------------------------------------------------------------- # Power symbol support @@ -231,7 +233,32 @@ def _extract_power_lib_symbol(name): return Sexp(parsed) -def _power_symbol_to_sexp(pin, net_name, tx): +def _child_instance_path(parent_path, sheet_uuid): + """Return the KiCad instance path for a child sheet.""" + parent = str(parent_path or "/").rstrip("/") + if not parent: + parent = "/" + if parent == "/": + return f"/{sheet_uuid}" + return f"{parent}/{sheet_uuid}" + + +def _sheet_project_name(sheet_filename, fallback="SKiDL-Generated"): + """Return the KiCad project name that matches a schematic file name.""" + basename = os.path.basename(str(sheet_filename or "")).strip() + if not basename: + return fallback + project_name, _ = os.path.splitext(basename) + return project_name or fallback + + +def _power_symbol_to_sexp( + pin, + net_name, + tx, + instance_path="/", + project_name="SKiDL-Generated", +): """Generate a power symbol instance S-expression. Args: @@ -344,10 +371,10 @@ def _power_symbol_to_sexp(pin, net_name, tx): "instances", [ "project", - "SKiDL-Generated", + project_name, [ "path", - f"/{_gen_uuid('root_schematic')}", + instance_path, ["reference", pwr_ref], ["unit", 1], ], @@ -383,6 +410,84 @@ def _round_mm(val, ndigits=2): return round(val, ndigits) +def _resolved_part_ref(part): + """Return the fully resolved reference text that should be displayed.""" + fields = getattr(part, "fields", {}) or {} + ref_candidates = ( + fields.get("Reference", ""), + fields.get("reference", ""), + getattr(part, "ref", ""), + ) + + # Prefer an already annotated reference for placed symbol instances so + # KiCad shows the final designator instead of the library prefix or '?'. + for ref in ref_candidates: + ref = str(ref or "").strip() + if ref and ref[-1] != "?" and any(ch.isdigit() for ch in ref): + return ref + + for ref in ref_candidates: + ref = str(ref or "").strip() + if ref: + return ref + return str(getattr(part, "ref_prefix", "") or "U").strip() or "U" + + +def _part_lib_id(part): + """Return the KiCad library identifier for a part.""" + lib_name = ( + os.path.splitext(part.lib.filename)[0] + if hasattr(part.lib, "filename") and part.lib.filename + else "Device" + ) + part_name = part.name or "Unknown" + return f"{lib_name}:{part_name}" + + +def _is_symbol_instance(elem): + """Return True if *elem* is a placed symbol instance.""" + if not elem or elem[0] != "symbol": + return False + return any( + isinstance(item, list) and item and item[0] == "lib_id" for item in elem[1:] + ) + + +def _append_missing_symbol_instances( + elements, + parts, + tx, + instance_path="/", + project_name="SKiDL-Generated", +): + """Ensure each non-terminal part has a placed symbol instance.""" + placed_refs = set() + for elem in elements: + if not _is_symbol_instance(elem): + continue + for item in elem[1:]: + if isinstance(item, list) and item and item[0] == "property" and len(item) > 2: + if item[1] == "Reference": + placed_refs.add(str(item[2])) + break + + for part in parts: + if isinstance(part, NetTerminal): + continue + full_ref = _resolved_part_ref(part) + if full_ref in placed_refs: + continue + elements.append( + part_to_sexp( + part, + tx=tx, + instance_path=instance_path, + project_name=project_name, + ) + ) + placed_refs.add(full_ref) + + # --------------------------------------------------------------------------- # Paper sizes # --------------------------------------------------------------------------- @@ -420,7 +525,7 @@ def _pick_paper_size(bbox): # --------------------------------------------------------------------------- -def part_to_sexp(part, tx=Tx()): +def part_to_sexp(part, tx=Tx(), instance_path="/", project_name="SKiDL-Generated"): """Create S-expression for a symbol instance. Applies part transform and sheet transform (Y-flip is in sheet_tx). @@ -446,13 +551,8 @@ def part_to_sexp(part, tx=Tx()): origin = Point(_round_mm(tx.origin.x), _round_mm(tx.origin.y)) unit_num = getattr(part, "num", 1) - lib_name = ( - os.path.splitext(part.lib.filename)[0] - if hasattr(part.lib, "filename") and part.lib.filename - else "Device" - ) - part_name = part.name or "Unknown" - lib_id = f"{lib_name}:{part_name}" + lib_id = _part_lib_id(part) + full_ref = _resolved_part_ref(part) symbol_list = [ "symbol", @@ -477,7 +577,7 @@ def part_to_sexp(part, tx=Tx()): [ "property", "Reference", - part.ref, + full_ref, ["at", origin.x, origin.y - 2.54, angle], ["effects", ["font", ["size", 1.27, 1.27]], ["justify", "left"]], ] @@ -579,11 +679,11 @@ def part_to_sexp(part, tx=Tx()): "instances", [ "project", - "SKiDL-Generated", + project_name, [ "path", - f"/{_gen_uuid('root_schematic')}", - ["reference", part.ref], + instance_path, + ["reference", full_ref], ["unit", unit_num], ], ], @@ -608,13 +708,9 @@ def part_to_lib_symbol_definition(part): Returns: list: Nested list for the lib_symbols section. """ - lib_name = ( - os.path.splitext(part.lib.filename)[0] - if hasattr(part.lib, "filename") and part.lib.filename - else "Device" - ) part_name = part.name or "Unknown" - lib_id = f"{lib_name}:{part_name}" + lib_id = _part_lib_id(part) + ref_prefix = str(getattr(part, "ref_prefix", "") or "").strip() or "U" symbol_def = [ "symbol", @@ -632,7 +728,7 @@ def part_to_lib_symbol_definition(part): [ "property", "Reference", - part.ref_prefix or "U", + ref_prefix, ["at", 2.032, 0, 90], ["effects", ["font", ["size", 1.27, 1.27]]], ], @@ -671,12 +767,25 @@ def part_to_lib_symbol_definition(part): ] ) + ref_prefix = str(getattr(part, "ref_prefix", "") or "").strip() + + def _keep_graphic_cmd(cmd): + """Drop reference placeholder text that would override placed refs.""" + if cmd[0] != "text" or len(cmd) < 2: + return True + text = str(cmd[1]).strip() + if not ref_prefix: + return True + return text not in {ref_prefix, f"{ref_prefix}?"} + # Process draw_cmds into sub-symbols. if hasattr(part, "draw_cmds") and part.draw_cmds: # Common graphics (unit 0). if 0 in part.draw_cmds: graphics = [ - copy.deepcopy(cmd) for cmd in part.draw_cmds[0] if cmd[0] != "pin" + copy.deepcopy(cmd) + for cmd in part.draw_cmds[0] + if cmd[0] != "pin" and _keep_graphic_cmd(cmd) ] if graphics: symbol_def.append(["symbol", f"{part_name}_0_1"] + graphics) @@ -689,7 +798,7 @@ def part_to_lib_symbol_definition(part): graphics = [ copy.deepcopy(cmd) for cmd in draw_cmds - if cmd[0] not in ("pin", "property") + if cmd[0] not in ("pin", "property") and _keep_graphic_cmd(cmd) ] if pin_cmds or graphics: unit_sym = ["symbol", f"{part_name}_{unit_num}_{unit_num}"] @@ -838,7 +947,13 @@ def calc_pin_dir(pin): }[pin_vector] -def net_label_to_sexp(pin, tx=Tx(), force=False): +def net_label_to_sexp( + pin, + tx=Tx(), + force=False, + instance_path="/", + project_name="SKiDL-Generated", +): """Create S-expression for a net label at a pin stub. Generates a power symbol if the net name matches a known KiCad power @@ -860,7 +975,13 @@ def net_label_to_sexp(pin, tx=Tx(), force=False): # If so, emit a power symbol instance instead of a global_label. # This eliminates power_pin_not_driven ERC errors. if pin.is_connected() and pin.net.name in _get_power_symbol_names(): - pwr = _power_symbol_to_sexp(pin, pin.net.name, tx) + pwr = _power_symbol_to_sexp( + pin, + pin.net.name, + tx, + instance_path=instance_path, + project_name=project_name, + ) if pwr: return pwr @@ -921,7 +1042,12 @@ def create_title_block_sexp(title): # --------------------------------------------------------------------------- -def create_hierarchical_sheet_sexp(node, sheet_tx): +def create_hierarchical_sheet_sexp( + node, + sheet_tx, + project_name="SKiDL-Generated", + instance_path="/", +): """Create a hierarchical sheet S-expression for insertion into a parent sheet. Includes sheet pins for boundary nets (nets connecting the child's @@ -1008,6 +1134,19 @@ def create_hierarchical_sheet_sexp(node, sheet_tx): ) pin_y += pin_spacing + sheet.append( + Sexp( + [ + "instances", + [ + "project", + project_name, + ["path", instance_path, ["page", "1"]], + ], + ] + ) + ) + return sheet @@ -1089,8 +1228,144 @@ def _fix_sheet_filename(node): node.sheet_filename = node.sheet_filename[:-4] + ".kicad_sch" +_FULL_REF_RE = re.compile(r"^([A-Za-z#]+)\d+[A-Za-z]?$") +_REF_PREFIX_RE = re.compile(r"^([A-Za-z#]+)") +_REFERENCE_PROP_RE = re.compile(r'(\(property\s+"Reference"\s+")([^"]*)(")') +_INSTANCE_REF_RE = re.compile(r'(\(reference\s+")([^"]*)(")') + + +def _reference_is_full(ref): + """Return True if a reference already has a numeric annotation.""" + return bool(_FULL_REF_RE.match(str(ref or "").strip())) + + +def _reference_prefix(ref): + """Return the alphabetic reference prefix from a ref or placeholder.""" + match = _REF_PREFIX_RE.match(str(ref or "").strip()) + return match.group(1).upper() if match else "" + + +def _reference_queues(parts): + """Build ordered full-reference queues, grouped by reference prefix.""" + refs_by_prefix = OrderedDict() + for part in parts: + if isinstance(part, NetTerminal): + continue + ref = str(getattr(part, "ref", "") or "").strip() + if not _reference_is_full(ref): + continue + prefix = _reference_prefix(ref) + refs_by_prefix.setdefault(prefix, []).append(ref) + return refs_by_prefix + + +def _find_symbol_blocks(text): + """Yield ``(start, end)`` ranges for S-expression symbol blocks.""" + for match in re.finditer(r"\(symbol(?=\s)", text): + start = match.start() + depth = 0 + in_string = False + escaped = False + for i in range(start, len(text)): + ch = text[i] + if in_string: + if escaped: + escaped = False + elif ch == "\\": + escaped = True + elif ch == '"': + in_string = False + continue + if ch == '"': + in_string = True + elif ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + yield start, i + 1 + break + + +def _fix_reference_block(block, refs_by_prefix, consumed): + """Fix a single symbol block's Reference property and instance reference.""" + prop_match = _REFERENCE_PROP_RE.search(block) + if not prop_match: + return block + + current_ref = prop_match.group(2) + is_instance_symbol = "(lib_id" in block + if not is_instance_symbol: + return block + + full_ref = current_ref if _reference_is_full(current_ref) else None + + if full_ref is None: + prefix = _reference_prefix(current_ref) + refs = refs_by_prefix.get(prefix, []) + if refs: + idx = consumed.get(prefix, 0) + if idx >= len(refs): + idx = len(refs) - 1 + full_ref = refs[idx] + consumed[prefix] = idx + 1 + + if full_ref is None: + return block + + if current_ref != full_ref: + block = _REFERENCE_PROP_RE.sub( + lambda m: f"{m.group(1)}{full_ref}{m.group(3)}", + block, + count=1, + ) + + def replace_instance_ref(match): + instance_ref = match.group(2) + if instance_ref == full_ref or _reference_is_full(instance_ref): + return match.group(0) + if _reference_prefix(instance_ref) == _reference_prefix(full_ref): + return f"{match.group(1)}{full_ref}{match.group(3)}" + return match.group(0) + + return _INSTANCE_REF_RE.sub(replace_instance_ref, block) + + +def _fix_exported_schematic_references(filepath, parts): + """Patch generated schematic Reference fields using assigned SKiDL refs.""" + refs_by_prefix = _reference_queues(parts) + if not refs_by_prefix or not os.path.exists(filepath): + return + + with open(filepath, "r", encoding="utf-8") as f: + text = f.read() + + consumed = {} + replacements = [] + for start, end in _find_symbol_blocks(text): + block = text[start:end] + fixed = _fix_reference_block(block, refs_by_prefix, consumed) + if fixed != block: + replacements.append((start, end, fixed)) + + if not replacements: + return + + for start, end, fixed in reversed(replacements): + text = text[:start] + fixed + text[end:] + + with open(filepath, "w", encoding="utf-8") as f: + f.write(text) + + @export_to_all -def node_to_sexp_schematic(node, sheet_tx=Tx(), version=20230409): +def node_to_sexp_schematic( + node, + sheet_tx=Tx(), + version=KICAD9_SCHEMATIC_VERSION, + instance_path="/", + project_name="SKiDL-Generated", +): """Convert a SchNode tree to S-expression schematic(s). Follows the same recursive pattern as kicad5's node_to_eeschema(): @@ -1101,7 +1376,8 @@ def node_to_sexp_schematic(node, sheet_tx=Tx(), version=20230409): Args: node: SchNode to convert. sheet_tx: Parent sheet transformation matrix. - version: S-expression version number (20240108 for kicad6, 20230409 for kicad8/9). + version: S-expression version number. KiCad 9 native schematics + currently use 20250114. Returns: list[Sexp]: S-expression elements (parts, wires, labels, or a sheet ref). @@ -1110,23 +1386,41 @@ def node_to_sexp_schematic(node, sheet_tx=Tx(), version=20230409): _fix_sheet_filename(node) elements = [] + node_sheet_uuid = _gen_uuid(f"sheet:{node.sheet_filename}") if node.flattened: tx = node.tx * sheet_tx + current_instance_path = instance_path + current_project_name = project_name else: # Unflattened node gets its own sheet. flattened_bbox = node.internal_bbox() tx, paper = _calc_sheet_tx(flattened_bbox) + current_instance_path = f"/{node_sheet_uuid}" + current_project_name = _sheet_project_name( + node.sheet_filename, fallback=project_name + ) # Recurse into children. for child in node.children.values(): - elements.extend(node_to_sexp_schematic(child, tx, version=version)) + child_instance_path = _child_instance_path( + current_instance_path, _gen_uuid(f"sheet:{child.sheet_filename}") + ) + elements.extend( + node_to_sexp_schematic( + child, + tx, + version=version, + instance_path=child_instance_path, + project_name=current_project_name, + ) + ) # Collect lib_symbols needed for this node's parts. lib_symbols = {} for part in node.parts: if not isinstance(part, NetTerminal): - lib_id = f"{part.lib.filename}:{part.name}" + lib_id = _part_lib_id(part) if lib_id not in lib_symbols: lib_symbols[lib_id] = part @@ -1134,11 +1428,24 @@ def node_to_sexp_schematic(node, sheet_tx=Tx(), version=20230409): for part in node.parts: if isinstance(part, NetTerminal): # NetTerminals become net labels. - label = net_label_to_sexp(part.pins[0], tx=tx, force=True) + label = net_label_to_sexp( + part.pins[0], + tx=tx, + force=True, + instance_path=current_instance_path, + project_name=current_project_name, + ) if label: elements.append(label) else: - elements.append(part_to_sexp(part, tx=tx)) + elements.append( + part_to_sexp( + part, + tx=tx, + instance_path=current_instance_path, + project_name=current_project_name, + ) + ) # Generate wire S-expressions (split at junction points). for net, wire in node.wires.items(): @@ -1154,10 +1461,23 @@ def node_to_sexp_schematic(node, sheet_tx=Tx(), version=20230409): if isinstance(part, NetTerminal): continue for pin in part: - label = net_label_to_sexp(pin, tx=tx) + label = net_label_to_sexp( + pin, + tx=tx, + instance_path=current_instance_path, + project_name=current_project_name, + ) if label: elements.append(label) + _append_missing_symbol_instances( + elements, + node.parts, + tx, + instance_path=current_instance_path, + project_name=current_project_name, + ) + if node.flattened: # Return elements for inclusion in the parent sheet. return elements @@ -1198,7 +1518,7 @@ def node_to_sexp_schematic(node, sheet_tx=Tx(), version=20230409): ["version", version], ["generator", "skidl"], ["generator_version", __version__], - ["uuid", _gen_uuid(f"sheet:{node.sheet_filename}")], + ["uuid", node_sheet_uuid], ["paper", paper if not node.flattened else "A3"], ] ) @@ -1211,9 +1531,16 @@ def node_to_sexp_schematic(node, sheet_tx=Tx(), version=20230409): # Write schematic file. filepath = os.path.join(node.filepath, node.sheet_filename) _write_sexp_schematic(schematic, filepath) + _fix_exported_schematic_references(filepath, node.parts) - # Return a hierarchical sheet reference for the parent. - return [create_hierarchical_sheet_sexp(node, sheet_tx)] + return [ + create_hierarchical_sheet_sexp( + node, + sheet_tx, + project_name=project_name, + instance_path=instance_path, + ) + ] # --------------------------------------------------------------------------- @@ -1222,7 +1549,14 @@ def node_to_sexp_schematic(node, sheet_tx=Tx(), version=20230409): @export_to_all -def write_top_schematic(circuit, node, filepath, top_name, title, version=20230409): +def write_top_schematic( + circuit, + node, + filepath, + top_name, + title, + version=KICAD9_SCHEMATIC_VERSION, +): """Generate and write the complete schematic from a placed+routed node tree. This is the main entry point called by each tool's gen_schematic(). @@ -1235,10 +1569,19 @@ def write_top_schematic(circuit, node, filepath, top_name, title, version=202304 title: Schematic title. version: S-expression version number. """ + if hasattr(circuit, "annotate_parts"): + # Ensure placeholder references such as D? are resolved before any + # symbol instances, properties, or sheet files are emitted. + circuit.annotate_parts() + top_name = top_name or "schematic" _fix_sheet_filename(node) _reset_power_symbol_state() + project_name = top_name or "SKiDL-Generated" + root_uuid = _gen_uuid(f"root:{project_name}") + root_instance_path = f"/{root_uuid}" + # Calculate root sheet transform. root_bbox = node.internal_bbox() sheet_tx, paper = _calc_sheet_tx(root_bbox) @@ -1247,24 +1590,48 @@ def write_top_schematic(circuit, node, filepath, top_name, title, version=202304 # Recurse into children — they write their own files if unflattened. for child in node.children.values(): - elements.extend(node_to_sexp_schematic(child, sheet_tx, version=version)) + child_instance_path = _child_instance_path( + root_instance_path, _gen_uuid(f"sheet:{child.sheet_filename}") + ) + elements.extend( + node_to_sexp_schematic( + child, + sheet_tx, + version=version, + instance_path=child_instance_path, + project_name=project_name, + ) + ) # Collect lib_symbols for ALL parts in the circuit. lib_symbols = {} for part in circuit.parts: if not isinstance(part, NetTerminal): - lib_id = f"{part.lib.filename}:{part.name}" + lib_id = _part_lib_id(part) if lib_id not in lib_symbols: lib_symbols[lib_id] = part # Generate part S-expressions for root-level parts. for part in node.parts: if isinstance(part, NetTerminal): - label = net_label_to_sexp(part.pins[0], tx=sheet_tx, force=True) + label = net_label_to_sexp( + part.pins[0], + tx=sheet_tx, + force=True, + instance_path=root_instance_path, + project_name=project_name, + ) if label: elements.append(label) else: - elements.append(part_to_sexp(part, tx=sheet_tx)) + elements.append( + part_to_sexp( + part, + tx=sheet_tx, + instance_path=root_instance_path, + project_name=project_name, + ) + ) # Generate wire S-expressions (split at junction points). for net, wire in node.wires.items(): @@ -1280,10 +1647,26 @@ def write_top_schematic(circuit, node, filepath, top_name, title, version=202304 if isinstance(part, NetTerminal): continue for pin in part: - label = net_label_to_sexp(pin, tx=sheet_tx) + label = net_label_to_sexp( + pin, + tx=sheet_tx, + instance_path=root_instance_path, + project_name=project_name, + ) if label: elements.append(label) + root_parts = node.parts + if not root_parts and not node.children: + root_parts = circuit.parts + _append_missing_symbol_instances( + elements, + root_parts, + sheet_tx, + instance_path=root_instance_path, + project_name=project_name, + ) + # Build lib_symbols section. lib_symbols_sexp = Sexp(["lib_symbols"]) for lib_id, part in lib_symbols.items(): @@ -1297,8 +1680,6 @@ def write_top_schematic(circuit, node, filepath, top_name, title, version=202304 if pwr_sexp: lib_symbols_sexp.append(pwr_sexp) - root_uuid = _gen_uuid("root_schematic") - schematic = Sexp( [ "kicad_sch", @@ -1319,6 +1700,7 @@ def write_top_schematic(circuit, node, filepath, top_name, title, version=202304 output_file = os.path.join(filepath, f"{top_name}.kicad_sch") os.makedirs(filepath, exist_ok=True) _write_sexp_schematic(schematic, output_file) + _fix_exported_schematic_references(output_file, circuit.parts) # Optional: validate with kicad-cli if available. _validate_with_kicad_cli(output_file) @@ -1392,6 +1774,8 @@ def need_quote(x): "generator", "generator_version", "paper", + "uuid", + "page", ) def need_quote_alternate(x): diff --git a/tests/unit_tests/ai_tests/test_route_cleanup.py b/tests/unit_tests/ai_tests/test_route_cleanup.py index a48ccd60..142f3d5e 100644 --- a/tests/unit_tests/ai_tests/test_route_cleanup.py +++ b/tests/unit_tests/ai_tests/test_route_cleanup.py @@ -28,6 +28,7 @@ def get_internal_pins(self, net): return list(self._net_pins[net]) _segment_obstructed = route_module.Router._segment_obstructed + _route_simple_manhattan_net = route_module.Router._route_simple_manhattan_net route_straight_nets = route_module.Router.route_straight_nets @@ -114,3 +115,38 @@ def test_route_straight_nets_skips_blocked_direct_segment(): assert routed == [] assert node.wires[net] == [] + + +def test_route_straight_nets_routes_clear_l_shaped_two_pin_net(): + net = object() + lower = DummyPart(BBox(Point(-1, -1), Point(1, 1))) + upper = DummyPart(BBox(Point(19, 19), Point(21, 21))) + pin_a = DummyPin(lower, Point(0, 0)) + pin_b = DummyPin(upper, Point(20, 20)) + node = DummyNode([lower, upper], {net: []}, {net: [pin_a, pin_b]}) + + routed = node.route_straight_nets([net]) + + assert routed == [net] + assert len(node.wires[net]) == 2 + assert { + _seg_coords(seg) for seg in node.wires[net] + } in ( + {((0, 0), (0, 20)), ((0, 20), (20, 20))}, + {((0, 0), (20, 0)), ((20, 0), (20, 20))}, + ) + + +def test_route_straight_nets_skips_blocked_l_shaped_two_pin_net(): + net = object() + lower = DummyPart(BBox(Point(-1, -1), Point(1, 1))) + upper = DummyPart(BBox(Point(19, 19), Point(21, 21))) + blocker = DummyPart(BBox(Point(5, -5), Point(15, 25))) + pin_a = DummyPin(lower, Point(0, 0)) + pin_b = DummyPin(upper, Point(20, 20)) + node = DummyNode([lower, upper, blocker], {net: []}, {net: [pin_a, pin_b]}) + + routed = node.route_straight_nets([net]) + + assert routed == [] + assert node.wires[net] == [] diff --git a/tests/unit_tests/ai_tests/test_sexp_schematic.py b/tests/unit_tests/ai_tests/test_sexp_schematic.py index 4c59bea6..54e1e02f 100644 --- a/tests/unit_tests/ai_tests/test_sexp_schematic.py +++ b/tests/unit_tests/ai_tests/test_sexp_schematic.py @@ -18,6 +18,7 @@ import uuid import pytest +from simp_sexp import Sexp from skidl import get_default_tool from skidl.geometry import BBox, Point, Tx @@ -27,9 +28,16 @@ A_SIZES = sexp_schematic.A_SIZES MILS_TO_MM = sexp_schematic.MILS_TO_MM _calc_sheet_tx = sexp_schematic._calc_sheet_tx +_child_instance_path = sexp_schematic._child_instance_path +_append_missing_symbol_instances = sexp_schematic._append_missing_symbol_instances +_fix_reference_block = sexp_schematic._fix_reference_block +_fix_exported_schematic_references = sexp_schematic._fix_exported_schematic_references _fix_sheet_filename = sexp_schematic._fix_sheet_filename _gen_uuid = sexp_schematic._gen_uuid +_is_symbol_instance = sexp_schematic._is_symbol_instance _pick_paper_size = sexp_schematic._pick_paper_size +_reference_queues = sexp_schematic._reference_queues +_sheet_project_name = sexp_schematic._sheet_project_name create_title_block_sexp = sexp_schematic.create_title_block_sexp @@ -195,11 +203,217 @@ class FakeNode: assert node.sheet_filename is None +class TestSheetProjectName: + """Tests for deriving KiCad project names from sheet filenames.""" + + def test_uses_sheet_basename_without_extension(self): + """A child sheet file name maps to its local KiCad project name.""" + assert _sheet_project_name("4micro2_top1.kicad_sch") == "4micro2_top1" + + def test_falls_back_for_empty_filename(self): + """An empty filename keeps the provided fallback project name.""" + assert _sheet_project_name("", fallback="demo") == "demo" + + +class TestReferenceRepair: + """Tests for post-write reference repair in generated schematics.""" + + class _MockPart: + def __init__(self, ref): + self.ref = ref + + def test_fix_reference_block_preserves_numeric_suffix(self): + """A prefix-only Reference property is repaired to the full assigned ref.""" + refs_by_prefix = _reference_queues([self._MockPart("D13")]) + block = ( + '(symbol (lib_id "Device:D")\n' + ' (property "Reference" "D")\n' + ' (instances (project "SKiDL-Generated" (path "/" (reference "D") (unit 1))))\n' + ')' + ) + + fixed = _fix_reference_block(block, refs_by_prefix, {}) + + assert '(property "Reference" "D13")' in fixed + assert '(reference "D13")' in fixed + assert '(property "Reference" "D")' not in fixed + + def test_fix_exported_schematic_references_updates_file(self, tmp_path): + """The file-level repair rewrites serialized prefix-only references.""" + schematic = tmp_path / "refs.kicad_sch" + schematic.write_text( + '(kicad_sch\n' + ' (symbol (lib_id "Device:D")\n' + ' (property "Reference" "D")\n' + ' (instances (project "SKiDL-Generated" (path "/" (reference "D") (unit 1))))\n' + ' )\n' + ' (symbol (lib_id "Device:R")\n' + ' (property "Reference" "R")\n' + ' (instances (project "SKiDL-Generated" (path "/" (reference "R") (unit 1))))\n' + ' )\n' + ')\n', + encoding="utf-8", + ) + + _fix_exported_schematic_references( + str(schematic), + [self._MockPart("D13"), self._MockPart("R1")], + ) + + content = schematic.read_text(encoding="utf-8") + assert '(property "Reference" "D13")' in content + assert '(reference "D13")' in content + assert '(property "Reference" "R1")' in content + assert '(reference "R1")' in content + + def test_fix_reference_block_leaves_lib_symbol_definitions_unchanged(self): + """Embedded lib symbols must not be rewritten during instance ref repair.""" + refs_by_prefix = _reference_queues([self._MockPart("D13")]) + block = ( + '(symbol "Device:D"\n' + ' (property "Reference" "D")\n' + ' (symbol "D_1_1" (text "D?" (at 0 2.54 0)))\n' + ')' + ) + + fixed = _fix_reference_block(block, refs_by_prefix, {}) + + assert fixed == block + + def test_lib_symbol_definition_keeps_library_names_stable(self): + """Embedded lib symbols should keep stable lib IDs and unit symbol names.""" + + class _MockLib: + filename = "Device" + + class _MockPart: + lib = _MockLib() + name = "D" + ref = "D13" + ref_prefix = "D" + datasheet = "~" + description = "Diode" + draw_cmds = { + 1: [ + ["text", "D?", ["at", 0, 2.54, 0], ["effects", ["font", ["size", 1.27, 1.27]]]], + ["text", "D", ["at", 0, 2.54, 0], ["effects", ["font", ["size", 1.27, 1.27]]]], + ["property", "Reference", "D?", ["at", 0, 2.54, 0], ["effects", ["font", ["size", 1.27, 1.27]]]], + ["pin", "1", ["at", 0, 0, 0]], + ] + } + + symbol_def = sexp_schematic.part_to_lib_symbol_definition(_MockPart()) + symbol_text = str(Sexp(symbol_def)) + + assert 'Device:D__D13' not in symbol_text + assert '(property "Reference" "D13")' not in symbol_text + assert '(property "Reference" "D"' in symbol_text + assert '(text "D?"' not in symbol_text + assert '(text "D"' not in symbol_text + + def test_part_to_sexp_uses_full_reference_on_placed_symbol(self): + """Placed symbol instances must carry the annotated full reference.""" + + class _MockLib: + filename = "Device" + + class _MockPart: + lib = _MockLib() + name = "LED" + ref = "D?" + ref_prefix = "D" + value = "LED" + footprint = "" + datasheet = "~" + description = "Indicator LED" + fields = {"Reference": "D13"} + pins = [] + hiername = "top/LED1" + + symbol = sexp_schematic.part_to_sexp(_MockPart()) + symbol_text = str(symbol) + + assert '(property "Reference" "D13"' in symbol_text + assert '(reference "D13")' in symbol_text + assert '(property "Reference" "D?"' not in symbol_text + + def test_part_to_sexp_uses_explicit_project_and_instance_path(self): + """Placed symbol instances serialize the caller-provided project/path.""" + + class _MockLib: + filename = "Device" + + class _MockPart: + lib = _MockLib() + name = "LED" + ref = "D13" + ref_prefix = "D" + value = "LED" + footprint = "" + datasheet = "~" + description = "Indicator LED" + fields = {"Reference": "D13"} + pins = [] + hiername = "top/LED1" + + symbol = sexp_schematic.part_to_sexp( + _MockPart(), + instance_path="/root-uuid", + project_name="demo_project", + ) + symbol_text = str(symbol) + + assert '(project "demo_project"' in symbol_text + assert '(path "/root-uuid"' in symbol_text + + def test_append_missing_symbol_instances_adds_part_instances(self): + """A sheet with no placed part symbols gets one instance per SKiDL part.""" + + class _MockLib: + filename = "Device" + + class _MockPart: + lib = _MockLib() + ref = "R1" + ref_prefix = "R" + name = "R" + value = "1K" + footprint = "" + datasheet = "~" + description = "Resistor" + fields = {"Reference": "R1"} + pins = [] + hiername = "top/R1" + + elements = [] + _append_missing_symbol_instances(elements, [_MockPart()], Tx(), instance_path="/") + + assert len(elements) == 1 + assert _is_symbol_instance(elements[0]) + symbol_text = str(elements[0]) + assert '(lib_id "Device:R")' in symbol_text + assert '(property "Reference" "R1"' in symbol_text + + # =========================================================================== # Layer 2: Structural validation — parse output, no KiCad needed # =========================================================================== +class TestInstancePaths: + """Tests for KiCad symbol instance path generation.""" + + def test_child_instance_path_from_root(self): + """Child sheets hang directly under the root '/' instance path.""" + assert _child_instance_path("/", "child-uuid") == "/child-uuid" + + def test_child_instance_path_nested(self): + """Nested child sheets extend the full parent instance path.""" + assert _child_instance_path("/parent-uuid", "child-uuid") == ( + "/parent-uuid/child-uuid" + ) + + def validate_kicad_sch(content): """Validate structural correctness of a .kicad_sch file. @@ -260,7 +474,7 @@ class TestStructuralValidation: def test_valid_minimal(self): """Minimal valid schematic passes validation.""" content = """(kicad_sch - (version 20230409) + (version 20250114) (generator "skidl") (uuid "abc-123") (lib_symbols) @@ -270,7 +484,7 @@ def test_valid_minimal(self): def test_unbalanced_parens(self): """Unbalanced parentheses fail validation.""" content = ( - "(kicad_sch (version 20230409) (generator skidl) (uuid abc) (lib_symbols)" + "(kicad_sch (version 20250114) (generator skidl) (uuid abc) (lib_symbols)" ) with pytest.raises(AssertionError, match="Unbalanced"): validate_kicad_sch(content) @@ -284,7 +498,7 @@ def test_missing_header(self): def test_mils_coordinates_detected(self): """Coordinates in mils range (>5000) are caught.""" content = """(kicad_sch - (version 20230409) + (version 20250114) (generator "skidl") (uuid "abc") (lib_symbols) @@ -406,6 +620,44 @@ def _generate_multi_part(output_dir): return filepath +def _generate_hierarchical_child(output_dir): + """Generate a small hierarchical design and return top + child paths.""" + from skidl import Circuit, Net, Part, subcircuit + + circuit = Circuit(name="hierarchy_test") + + @subcircuit + def led_stage(vin, vout): + r = Part("Device", "R", value="1K") + d = Part("Device", "LED", value="LED") + vin += r[1] + r[2] += d[2] + d[1] += vout + + with circuit: + vin = Net("VIN") + vout = Net("VOUT") + led_stage(vin, vout) + circuit.generate_schematic( + filepath=output_dir, + top_name="hierarchy_test", + flatness=0.0, + ) + + top_path = os.path.join(output_dir, "hierarchy_test.kicad_sch") + assert os.path.exists(top_path), f"Top schematic file not generated at {top_path}" + + with open(top_path, encoding="utf-8") as f: + top_content = f.read() + + match = re.search(r'\(property "Sheetfile" "([^"]+\.kicad_sch)"', top_content) + assert match, "Top schematic did not emit a child Sheetfile property" + + child_path = os.path.join(output_dir, match.group(1)) + assert os.path.exists(child_path), f"Child schematic file not generated at {child_path}" + return top_path, child_path + + class TestEndToEndDivider: """End-to-end tests with a simple resistor divider.""" @@ -436,6 +688,21 @@ def test_divider_coordinates_in_mm(self, output_dir): assert abs(y) < 850, f"y={y} too large for mm" + def test_divider_symbol_instances_use_root_uuid_path_and_full_refs(self, output_dir): + """Root-sheet symbol instances use a KiCad-style root UUID path and full refs.""" + filepath = _generate_simple_divider(output_dir) + with open(filepath, encoding="utf-8") as f: + content = f.read() + + assert re.search(r"\(symbol\s+\(lib_id\s+\"Device:R\"", content) + assert re.search(r'\(path "/[0-9a-f-]+"' , content) + assert '(project "divider_test"' in content + assert '(property "Reference" "R1"' in content + assert '(property "Reference" "R2"' in content + assert '(reference "R1")' in content + assert '(reference "R2")' in content + + class TestEndToEndAndGate: """End-to-end tests with devbisme's and_gate reference circuit.""" @@ -495,6 +762,26 @@ def test_multi_part_structural_validation(self, output_dir): validate_kicad_sch(content) +class TestEndToEndHierarchy: + """End-to-end tests for hierarchical sheet instance serialization.""" + + def test_child_sheet_symbols_use_local_project_and_uuid_path(self, output_dir): + """Child sheets serialize symbol instances against their own file/project context.""" + _, child_path = _generate_hierarchical_child(output_dir) + + with open(child_path, encoding="utf-8") as f: + content = f.read() + + child_name = os.path.splitext(os.path.basename(child_path))[0] + child_uuid = _gen_uuid(f"sheet:{os.path.basename(child_path)}") + + assert f'(project "{child_name}"' in content + assert f'(uuid "{child_uuid}")' in content + assert f'(path "/{child_uuid}"' in content + assert re.search(r'\(reference "R\d+"\)', content) + assert re.search(r'\(reference "D\d+"\)', content) + + # =========================================================================== # Layer 4: KiCad CLI validation (skip if not installed) # =========================================================================== diff --git a/verification_output/verify_refs.kicad_sch b/verification_output/verify_refs.kicad_sch new file mode 100644 index 00000000..150b3859 --- /dev/null +++ b/verification_output/verify_refs.kicad_sch @@ -0,0 +1,559 @@ +(kicad_sch + (version 20230409) + (generator "skidl") + (generator_version "2.2.3") + (uuid 218b5f90-a139-55d3-a988-331ecbe7ee0c) + (paper "A4") + (title_block + (title "SKiDL-Generated Schematic") + (date "2026-05-20") + (company "") + (comment 1 "Generated with SKiDL") + (comment 2 "") + (comment 3 "") + (comment 4 "")) + (lib_symbols + (symbol "Device:LED" + (pin_numbers + (hide yes)) + (pin_names + (offset 0)) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (property "Reference" "D" + (at 2.032 0 90) + (effects + (font + (size 1.27 1.27)))) + (property "Value" "LED" + (at 0 0 90) + (effects + (font + (size 1.27 1.27)))) + (property "Footprint" "" + (at 0 0 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 0 0 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (symbol "LED_0_1" + (polyline + (pts + (xy -1.27 -1.27) + (xy -1.27 1.27)) + (stroke + (width 0.254) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (polyline + (pts + (xy -1.27 0) + (xy 1.27 0)) + (stroke + (width 0) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (polyline + (pts + (xy 1.27 -1.27) + (xy 1.27 1.27) + (xy -1.27 0) + (xy 1.27 -1.27)) + (stroke + (width 0.254) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (polyline + (pts + (xy -3.048 -0.762) + (xy -4.572 -2.286) + (xy -3.81 -2.286) + (xy -4.572 -2.286) + (xy -4.572 -1.524)) + (stroke + (width 0) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (polyline + (pts + (xy -1.778 -0.762) + (xy -3.302 -2.286) + (xy -2.54 -2.286) + (xy -3.302 -2.286) + (xy -3.302 -1.524)) + (stroke + (width 0) + (type default) + (color 0 0 0 0)) + (fill + (type none)))) + (symbol "LED_1_1" + (polyline + (pts + (xy -1.27 -1.27) + (xy -1.27 1.27)) + (stroke + (width 0.254) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (polyline + (pts + (xy -1.27 0) + (xy 1.27 0)) + (stroke + (width 0) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (polyline + (pts + (xy 1.27 -1.27) + (xy 1.27 1.27) + (xy -1.27 0) + (xy 1.27 -1.27)) + (stroke + (width 0.254) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (polyline + (pts + (xy -3.048 -0.762) + (xy -4.572 -2.286) + (xy -3.81 -2.286) + (xy -4.572 -2.286) + (xy -4.572 -1.524)) + (stroke + (width 0) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (polyline + (pts + (xy -1.778 -0.762) + (xy -3.302 -2.286) + (xy -2.54 -2.286) + (xy -3.302 -2.286) + (xy -3.302 -1.524)) + (stroke + (width 0) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (pin passive line + (at -3.81 0 0) + (length 2.54) + (name "K" + (effects + (font + (size 1.27 1.27)))) + (number "1" + (effects + (font + (size 1.27 1.27))))) + (pin passive line + (at 3.81 0 180) + (length 2.54) + (name "A" + (effects + (font + (size 1.27 1.27)))) + (number "2" + (effects + (font + (size 1.27 1.27)))))) + (embedded_fonts no)) + (symbol "Device:R" + (pin_numbers + (hide yes)) + (pin_names + (offset 0)) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (property "Reference" "R" + (at 2.032 0 90) + (effects + (font + (size 1.27 1.27)))) + (property "Value" "R" + (at 0 0 90) + (effects + (font + (size 1.27 1.27)))) + (property "Footprint" "" + (at 0 0 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 0 0 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (symbol "R_0_1" + (rectangle + (start -1.016 -2.54) + (end 1.016 2.54) + (stroke + (width 0.254) + (type default) + (color 0 0 0 0)) + (fill + (type none)))) + (symbol "R_1_1" + (rectangle + (start -1.016 -2.54) + (end 1.016 2.54) + (stroke + (width 0.254) + (type default) + (color 0 0 0 0)) + (fill + (type none))) + (pin passive line + (at 0 3.81 270) + (length 1.27) + (name "~" + (effects + (font + (size 1.27 1.27)))) + (number "1" + (effects + (font + (size 1.27 1.27))))) + (pin passive line + (at 0 -3.81 90) + (length 1.27) + (name "~" + (effects + (font + (size 1.27 1.27)))) + (number "2" + (effects + (font + (size 1.27 1.27)))))) + (embedded_fonts no))) + (symbol + (lib_id "Device:LED") + (at 148.59 90.17 0) + (unit 1) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (dnp no) + (fields_autoplaced yes) + (uuid 507b7430-687a-5904-b8e8-f8bf08324b21) + (property "Reference" "D13" + (at 148.59 87.63 0) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Value" "LED" + (at 148.59 92.71000000000001 0) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Footprint" "" + (at 148.59 90.17 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 148.59 90.17 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Description" "" + (at 148.59 90.17 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (pin "1" + (uuid 40ed6e3f-9f22-50e7-98ac-9a14ed10a0ab)) + (pin "2" + (uuid 2ce1670f-e7b8-51de-b111-b5bec605a8dc)) + (instances + (project "SKiDL-Generated" + (path "/" + (reference "D13") + (unit 1))))) + (symbol + (lib_id "Device:LED") + (at 154.94 105.41 270) + (unit 1) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (dnp no) + (fields_autoplaced yes) + (uuid fea4b592-a230-564d-be4a-d4380e7c691d) + (property "Reference" "D15" + (at 154.94 102.86999999999999 270) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Value" "LED" + (at 154.94 107.95 270) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Footprint" "" + (at 154.94 105.41 270) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 154.94 105.41 270) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Description" "" + (at 154.94 105.41 270) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (pin "1" + (uuid 87618d42-010c-532f-9b7a-0356f6786557)) + (pin "2" + (uuid 4e56e4d2-3b8c-5fb5-b07d-52f53e640c90)) + (instances + (project "SKiDL-Generated" + (path "/" + (reference "D15") + (unit 1))))) + (symbol + (lib_id "Device:R") + (at 148.59 95.25 90) + (mirror x) + (unit 1) + (exclude_from_sim no) + (in_bom yes) + (on_board yes) + (dnp no) + (fields_autoplaced yes) + (uuid 91628e47-8f83-5ba1-abed-4cb134d83eea) + (property "Reference" "R1" + (at 148.59 92.71 90) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Value" "1K" + (at 148.59 97.79 90) + (effects + (font + (size 1.27 1.27)) + (justify left))) + (property "Footprint" "" + (at 148.59 95.25 90) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "~" + (at 148.59 95.25 90) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Description" "" + (at 148.59 95.25 90) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (pin "1" + (uuid a6935952-da4d-541a-895a-668252bfbd0d)) + (pin "2" + (uuid af1579b0-1060-540f-a855-b50b54978d21)) + (instances + (project "SKiDL-Generated" + (path "/" + (reference "R1") + (unit 1))))) + (global_label "N1" + (shape bidirectional) + (at 137.16 92.71 0) + (effects + (font + (size 1.27 1.27)) + (justify left)) + (uuid 099873d1-55f7-5d4d-be12-5c79878641ae)) + (global_label "N2" + (shape bidirectional) + (at 160.02 95.25 180) + (effects + (font + (size 1.27 1.27)) + (justify right)) + (uuid cf7b84d4-3248-5ca0-b46a-92b1a90a757e)) + (symbol + (lib_id "power:GND") + (at 154.94 116.84 0) + (unit 1) + (exclude_from_sim yes) + (in_bom no) + (on_board yes) + (dnp no) + (fields_autoplaced yes) + (uuid 926ab61b-7c72-5154-be4e-b9073d3fb7bf) + (property "Reference" "#PWR001" + (at 154.94 115.57000000000001 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Value" "GND" + (at 154.94 113.03 0) + (effects + (font + (size 1.27 1.27)))) + (property "Footprint" "" + (at 154.94 116.84 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (property "Datasheet" "" + (at 154.94 116.84 0) + (effects + (font + (size 1.27 1.27)) + (hide yes))) + (pin "1" + (uuid 91cdc817-272f-5903-9714-a1f0d6d3e8cb)) + (instances + (project "SKiDL-Generated" + (path "/" + (reference "#PWR001") + (unit 1))))) + (wire + (pts + (xy 154.94 116.84) + (xy 154.94 109.22)) + (stroke + (width 0) + (type default)) + (uuid 70ac78e9-c6e6-566c-87e3-65d8dcdd5d48)) + (wire + (pts + (xy 137.16 92.71) + (xy 143.51 92.71)) + (stroke + (width 0) + (type default)) + (uuid 4cd35af4-d504-57ca-8b49-6b7292199868)) + (wire + (pts + (xy 143.51 95.25) + (xy 143.51 92.71)) + (stroke + (width 0) + (type default)) + (uuid 001ef293-05af-5a08-a081-c583906e36df)) + (wire + (pts + (xy 143.51 95.25) + (xy 144.78 95.25)) + (stroke + (width 0) + (type default)) + (uuid e9f73eea-5314-5b46-8ef8-c642288e678d)) + (wire + (pts + (xy 143.51 92.71) + (xy 143.51 90.17)) + (stroke + (width 0) + (type default)) + (uuid 879687c5-b5b0-56f2-b598-f0b5b6e407a0)) + (wire + (pts + (xy 143.51 90.17) + (xy 144.78 90.17)) + (stroke + (width 0) + (type default)) + (uuid a1fa48c6-b53c-5595-beed-69dce18ce6f4)) + (wire + (pts + (xy 152.4 95.25) + (xy 153.67 95.25)) + (stroke + (width 0) + (type default)) + (uuid 0ffe946e-efc3-5b81-812f-8102647edef1)) + (wire + (pts + (xy 152.4 90.17) + (xy 153.67 90.17)) + (stroke + (width 0) + (type default)) + (uuid 84178fd9-f039-5423-ad11-6bc7e00e5ca7)) + (wire + (pts + (xy 153.67 95.25) + (xy 153.67 90.17)) + (stroke + (width 0) + (type default)) + (uuid 005fde72-5880-5177-89d8-3e1129693240)) + (wire + (pts + (xy 153.67 95.25) + (xy 154.94 95.25)) + (stroke + (width 0) + (type default)) + (uuid 7d8beaac-ea88-5ec1-ad24-353a6d4534d0)) + (wire + (pts + (xy 154.94 101.6) + (xy 154.94 95.25)) + (stroke + (width 0) + (type default)) + (uuid 5562ec46-afd0-56ee-999c-18ead9322c74)) + (wire + (pts + (xy 154.94 95.25) + (xy 160.02 95.25)) + (stroke + (width 0) + (type default)) + (uuid 150defe4-fbd1-5a89-8023-4a3f23dd5d04))) \ No newline at end of file From 0ffb9faf620426b704c6bb65125e1bc3e2fe93b4 Mon Sep 17 00:00:00 2001 From: rhaingenix Date: Mon, 25 May 2026 22:22:21 +0800 Subject: [PATCH 15/16] improve driver rail routing and semantic trunk layout --- src/skidl/schematics/route.py | 1313 ++++++++++++++++- src/skidl/schematics/topology.py | 593 +++++++- src/skidl/schematics/trunk_layout.py | 321 +++- src/skidl/tools/kicad9/gen_schematic.py | 136 +- .../test_topology_generic_driver.py | 1030 ++++++++++++- 5 files changed, 3345 insertions(+), 48 deletions(-) diff --git a/src/skidl/schematics/route.py b/src/skidl/schematics/route.py index 7418dfc8..7db29220 100644 --- a/src/skidl/schematics/route.py +++ b/src/skidl/schematics/route.py @@ -8,6 +8,7 @@ import copy import heapq +import os import random import sys from collections import Counter, defaultdict @@ -186,6 +187,7 @@ def route_driver_rails(node, nets, **options): top_set = set(plan.get("top_nets", [])) bottom_set = set(plan.get("bottom_nets", [])) net_set = set(nets) + rail_spans = plan.get("rail_spans", {}) or {} def _net_side(net): if net in top_set: @@ -206,8 +208,13 @@ def _net_side(net): continue pin_pts = [(pin.pt * pin.part.tx).round() for pin in pins] - x_min = min(plan["x_min"], min(pt.x for pt in pin_pts)) - x_max = max(plan["x_max"], max(pt.x for pt in pin_pts)) + span = rail_spans.get(net) + if span is None or len(span) != 2: + x_min = min(plan["x_min"], min(pt.x for pt in pin_pts)) + x_max = max(plan["x_max"], max(pt.x for pt in pin_pts)) + else: + x_min = min(span[0], min(pt.x for pt in pin_pts)) + x_max = max(span[1], max(pt.x for pt in pin_pts)) rail_y = _shift_driver_rail_y(node, rail_y, x_min, x_max, grid, side) segs = [Segment(Point(x_min, rail_y), Point(x_max, rail_y))] @@ -225,6 +232,7 @@ def _net_side(net): chain_handled = route_driver_chain_local_nets(node, nets, **options) handled |= chain_handled node._driver_prerouted_nets = handled + _attach_debug_log_wire_stage(node, options, "driver_rail_preroute_after") if options.get("schematic_progress", False) and handled: from skidl.logger import active_logger @@ -240,6 +248,1285 @@ def _net_name_for_log(net): return str(getattr(net, "name", "") or "") +def _driver_preroute_attach_points(node, net, segments, main_seg): + points = [] + axis = "h" if main_seg.p1.y == main_seg.p2.y else "v" + fixed = main_seg.p1.y if axis == "h" else main_seg.p1.x + + for pin in getattr(net, "pins", []): + pt = _pin_anchor_point(pin) + if pt is None: + continue + if axis == "h" and pt.y == fixed: + points.append(pt.x) + elif axis == "v" and pt.x == fixed: + points.append(pt.y) + + for seg in segments: + if seg is main_seg or seg.p1 == seg.p2: + continue + if axis == "h" and seg.p1.x == seg.p2.x: + if min(seg.p1.y, seg.p2.y) <= fixed <= max(seg.p1.y, seg.p2.y): + points.append(seg.p1.x) + elif axis == "v" and seg.p1.y == seg.p2.y: + if min(seg.p1.x, seg.p2.x) <= fixed <= max(seg.p1.x, seg.p2.x): + points.append(seg.p1.y) + + for jpt in getattr(node, "junctions", {}).get(net, []): + if axis == "h" and jpt.y == fixed: + points.append(jpt.x) + elif axis == "v" and jpt.x == fixed: + points.append(jpt.y) + + unique = sorted(set(int(v) for v in points)) + return unique + + +def _linear_main_attach_points(node, net, segments, main_seg): + """Collect conservative attach points that anchor the main linear segment.""" + axis = "h" if main_seg.p1.y == main_seg.p2.y else "v" + fixed = main_seg.p1.y if axis == "h" else main_seg.p1.x + lo = min(main_seg.p1.x, main_seg.p2.x) if axis == "h" else min(main_seg.p1.y, main_seg.p2.y) + hi = max(main_seg.p1.x, main_seg.p2.x) if axis == "h" else max(main_seg.p1.y, main_seg.p2.y) + attach = {} + + def add_attach(coord, reason): + coord = int(coord) + if coord < lo or coord > hi: + return + attach.setdefault(coord, set()).add(reason) + + for pin in getattr(net, "pins", []): + pt = _pin_anchor_point(pin) + if pt is None: + continue + if axis == "h" and pt.y == fixed: + add_attach(pt.x, "pin_attach") + elif axis == "v" and pt.x == fixed: + add_attach(pt.y, "pin_attach") + + for seg in segments: + if seg is main_seg or seg.p1 == seg.p2: + continue + if axis == "h": + if seg.p1.x == seg.p2.x and min(seg.p1.y, seg.p2.y) <= fixed <= max(seg.p1.y, seg.p2.y): + jpt = Point(seg.p1.x, fixed) + reason = "branch_point" if _point_on_segment_interior(seg, jpt) else "stub_attach" + add_attach(seg.p1.x, reason) + for endpoint in (seg.p1, seg.p2): + if endpoint.y == fixed and lo <= endpoint.x <= hi: + add_attach(endpoint.x, "branch_point") + else: + if seg.p1.y == seg.p2.y and min(seg.p1.x, seg.p2.x) <= fixed <= max(seg.p1.x, seg.p2.x): + jpt = Point(fixed, seg.p1.y) + reason = "branch_point" if _point_on_segment_interior(seg, jpt) else "stub_attach" + add_attach(seg.p1.y, reason) + for endpoint in (seg.p1, seg.p2): + if endpoint.x == fixed and lo <= endpoint.y <= hi: + add_attach(endpoint.y, "branch_point") + + for jpt in getattr(node, "junctions", {}).get(net, []): + if axis == "h" and jpt.y == fixed: + add_attach(jpt.x, "junction") + elif axis == "v" and jpt.x == fixed: + add_attach(jpt.y, "junction") + + return {coord: tuple(sorted(reasons)) for coord, reasons in attach.items()} + + +def _tail_prune_log(net, old_end, new_end, distance, attach_types): + from skidl.logger import active_logger + + attach_desc = "_and_".join(attach_types) if attach_types else "attach" + active_logger.info("[tail_prune]") + active_logger.info("net=%s" % _net_name_for_log(net)) + active_logger.info("old_end=(%d,%d)" % (old_end.x, old_end.y)) + active_logger.info("new_end=(%d,%d)" % (new_end.x, new_end.y)) + active_logger.info("prune_distance=%d" % int(distance)) + active_logger.info("attach_point_type=%s" % attach_desc) + active_logger.info("reason=endpoint_beyond_last_%s" % attach_desc) + + +def _prune_linear_endpoint_tails(node, net, segments, grid): + if len(segments) < 2: + return segments + + linear = [ + seg for seg in segments if seg.p1 != seg.p2 and (seg.p1.y == seg.p2.y or seg.p1.x == seg.p2.x) + ] + if not linear: + return segments + + main_seg = max(linear, key=lambda seg: (seg.p2 - seg.p1).magnitude) + attach = _linear_main_attach_points(node, net, linear, main_seg) + if not attach: + return segments + + axis = "h" if main_seg.p1.y == main_seg.p2.y else "v" + low = min(main_seg.p1.x, main_seg.p2.x) if axis == "h" else min(main_seg.p1.y, main_seg.p2.y) + high = max(main_seg.p1.x, main_seg.p2.x) if axis == "h" else max(main_seg.p1.y, main_seg.p2.y) + coords = sorted(attach) + margin = max(0, min(int(grid), int(grid))) + new_low = low + new_high = high + + if coords: + first_attach = coords[0] + last_attach = coords[-1] + if first_attach - low > margin: + new_low = first_attach - margin + if high - last_attach > margin: + new_high = last_attach + margin + + if new_high <= new_low or (new_low == low and new_high == high): + return segments + + if axis == "h": + new_main = Segment(Point(new_low, main_seg.p1.y), Point(new_high, main_seg.p1.y)) + old_lo_end = Point(low, main_seg.p1.y) + old_hi_end = Point(high, main_seg.p1.y) + new_lo_end = Point(new_low, main_seg.p1.y) + new_hi_end = Point(new_high, main_seg.p1.y) + else: + new_main = Segment(Point(main_seg.p1.x, new_low), Point(main_seg.p1.x, new_high)) + old_lo_end = Point(main_seg.p1.x, low) + old_hi_end = Point(main_seg.p1.x, high) + new_lo_end = Point(main_seg.p1.x, new_low) + new_hi_end = Point(main_seg.p1.x, new_high) + + if new_low != low: + _tail_prune_log(net, old_lo_end, new_lo_end, new_low - low, attach.get(coords[0], ())) + if new_high != high: + _tail_prune_log(net, old_hi_end, new_hi_end, high - new_high, attach.get(coords[-1], ())) + + pruned = [] + replaced = False + for seg in segments: + if seg is main_seg and not replaced: + pruned.append(new_main) + replaced = True + else: + pruned.append(seg) + return pruned + + +def _prune_driver_preroute_tails(node, net, segments, grid): + return _prune_linear_endpoint_tails(node, net, segments, grid) + + +def _attach_debug_enabled(options=None): + if options and options.get("schematic_attach_debug") is not None: + return bool(options.get("schematic_attach_debug")) + value = str(os.environ.get("SKIDL_SCH_DEBUG_ATTACH", "") or "").strip().lower() + return value not in ("", "0", "false", "no", "off") + + +def _attach_debug_log(options, message): + if not _attach_debug_enabled(options): + return + from skidl.logger import active_logger + + active_logger.info("[attach-debug] %s" % message) + + +def _segment_coords(seg): + return ((int(seg.p1.x), int(seg.p1.y)), (int(seg.p2.x), int(seg.p2.y))) + + +def _format_segment(seg): + (x1, y1), (x2, y2) = _segment_coords(seg) + return "(%d,%d)->(%d,%d)" % (x1, y1, x2, y2) + + +def _pin_label(pin): + part = getattr(pin, "part", None) + ref = getattr(part, "ref", "?") if part is not None else "?" + num = getattr(pin, "num", "?") + name = getattr(pin, "name", "") or "" + return "%s pin %s/%s" % (ref, num, name) + + +def _pin_net_name(pin): + net = getattr(pin, "net", None) + return _net_name_for_log(net) if net is not None else "" + + +def _wire_signature(seg, net=None): + return (_net_name_for_log(net),) + _segment_coords(seg) + + +def _part_value_name(part): + value = str(getattr(part, "value", "") or "").strip() + name = str(getattr(part, "name", "") or "").strip() + if value and name and value != name: + return "%s / %s" % (value, name) + return value or name or "" + + +def _is_attach_focus_part(part): + if part is None: + return False + ref = str(getattr(part, "ref", "") or "").upper() + ident = ("%s %s" % (_part_value_name(part), ref)).upper() + if ref.startswith(("L", "C", "R", "D")): + return True + if ref == "U2": + return True + return "PT4115" in ident + + +def _is_attach_focus_pin(pin): + return _is_attach_focus_part(getattr(pin, "part", None)) + + +def _is_simple_two_pin_passive(part): + if part is None: + return False + pins = list(getattr(part, "pins", []) or []) + if len(pins) != 2: + return False + ref = str(getattr(part, "ref", "") or "").upper() + ident = ("%s %s" % (_part_value_name(part), ref)).upper() + if ref.startswith(("R", "C", "L")): + return True + return any(token in ident for token in ("RES", "CAP", "IND")) + + +def _pin_anchor_point(pin): + """Return the absolute schematic anchor point for a pin, if available.""" + part = getattr(pin, "part", None) + pt = getattr(pin, "pt", None) + tx = getattr(part, "tx", None) if part is not None else None + if part is None or pt is None or tx is None: + return None + return (pt * tx).round() + + +def _point_on_segment(seg, pt): + return seg.p1.x <= pt.x <= seg.p2.x and seg.p1.y <= pt.y <= seg.p2.y + + +def _point_on_segment_interior(seg, pt): + if not _point_on_segment(seg, pt): + return False + return pt != seg.p1 and pt != seg.p2 + + +def _point_is_wire_endpoint(segments, pt): + return any(pt == seg.p1 or pt == seg.p2 for seg in segments) + + +def _point_segment_degree(segments, pt): + return sum(1 for seg in segments if pt == seg.p1 or pt == seg.p2) + + +def _segment_orientation(seg): + if seg.p1.x == seg.p2.x: + return "vertical" + if seg.p1.y == seg.p2.y: + return "horizontal" + return None + + +def _segment_other_end(seg, pt): + if seg.p1 == pt: + return seg.p2 + if seg.p2 == pt: + return seg.p1 + return None + + +def _segment_hits_other_parts(node, segment, ignored_parts=None): + ignored_parts = set(ignored_parts or ()) + segment_bbox = BBox(segment.p1, segment.p2) + for part in getattr(node, "parts", []): + if part in ignored_parts: + continue + bbox = getattr(part, "bbox", None) + tx = getattr(part, "tx", None) + if bbox is None or tx is None: + continue + if (bbox * tx).intersects(segment_bbox): + return True + return False + + +def _point_hits_other_net(node, net, pt): + for other_net, other_segments in node.wires.items(): + if other_net is net: + continue + for other in other_segments: + if _point_on_segment(other, pt): + return True + return False + + +def _segments_intersect(seg_a, seg_b): + if seg_a.p1.x == seg_a.p2.x == seg_b.p1.x == seg_b.p2.x: + return seg_a.p1.y <= seg_b.p2.y and seg_a.p2.y >= seg_b.p1.y + if seg_a.p1.y == seg_a.p2.y == seg_b.p1.y == seg_b.p2.y: + return seg_a.p1.x <= seg_b.p2.x and seg_a.p2.x >= seg_b.p1.x + if seg_a.p1.x == seg_a.p2.x: + vseg, hseg = seg_a, seg_b + else: + vseg, hseg = seg_b, seg_a + return ( + hseg.p1.x <= vseg.p1.x <= hseg.p2.x + and vseg.p1.y <= hseg.p1.y <= vseg.p2.y + ) + + +def _segment_hits_other_net(node, net, segment): + for other_net, other_segments in node.wires.items(): + if other_net is net: + continue + for other in other_segments: + if _segments_intersect(segment, other): + return True + return False + + +def is_pin_attached(pin, wires): + """Return True if a pin anchor already lands on a same-net wire endpoint.""" + pt = _pin_anchor_point(pin) + if pt is None: + return False + return _point_is_wire_endpoint(wires, pt) + + +def _split_wire_at_point(segments, seg, pt): + if not _point_on_segment_interior(seg, pt): + return False + segments.remove(seg) + segments.append(Segment(copy.copy(seg.p1), copy.copy(pt))) + segments.append(Segment(copy.copy(pt), copy.copy(seg.p2))) + return True + + +def find_nearest_same_net_wire(pin, net_wires, max_dist): + """Find the nearest same-net wire a pin can conservatively attach to.""" + best = _find_best_same_net_wire(pin, net_wires) + if best is None: + return None + if best["distance"] > max_dist: + return None + return best + + +def _find_best_same_net_wire(pin, net_wires): + """Find the nearest same-net wire candidate regardless of distance limit.""" + pin_pt = _pin_anchor_point(pin) + if pin_pt is None: + return None + + best = None + for seg in net_wires: + target = None + if _point_on_segment(seg, pin_pt): + target = pin_pt + elif seg.p1.y == seg.p2.y and seg.p1.x <= pin_pt.x <= seg.p2.x: + target = Point(pin_pt.x, seg.p1.y) + elif seg.p1.x == seg.p2.x and seg.p1.y <= pin_pt.y <= seg.p2.y: + target = Point(seg.p1.x, pin_pt.y) + else: + for endpoint in (seg.p1, seg.p2): + if endpoint.x == pin_pt.x or endpoint.y == pin_pt.y: + target = copy.copy(endpoint) + break + + if target is None: + continue + + dist = abs(pin_pt.x - target.x) + abs(pin_pt.y - target.y) + candidate = { + "distance": dist, + "target": target, + "segment": seg, + "needs_split": _point_on_segment_interior(seg, target), + } + if best is None or ( + candidate["distance"], + target.x, + target.y, + ) < ( + best["distance"], + best["target"].x, + best["target"].y, + ): + best = candidate + + return best + + +def _find_best_other_net_wire(node, pin, pin_net): + best = None + for other_net, other_wires in node.wires.items(): + if other_net is pin_net: + continue + candidate = _find_best_same_net_wire(pin, other_wires) + if candidate is None: + continue + enriched = dict(candidate) + enriched["net"] = other_net + if best is None or ( + candidate["distance"], + candidate["target"].x, + candidate["target"].y, + _net_name_for_log(other_net), + ) < ( + best["distance"], + best["target"].x, + best["target"].y, + _net_name_for_log(best["net"]), + ): + best = enriched + return best + + +def _normalized_segment(seg): + if seg.p2 < seg.p1: + return Segment(copy.copy(seg.p2), copy.copy(seg.p1)) + return Segment(copy.copy(seg.p1), copy.copy(seg.p2)) + + +def _find_collinear_passive_attach(pin, net_wires, max_snap, max_extend): + """Prefer extending a nearby trunk axis for simple passive pins.""" + part = getattr(pin, "part", None) + if not _is_simple_two_pin_passive(part): + return None + + pin_pt = _pin_anchor_point(pin) + if pin_pt is None: + return None + + best = None + for seg in net_wires: + if seg.p1.x == seg.p2.x: + direction = "vertical" + axis = seg.p1.x + axis_offset = abs(pin_pt.x - axis) + if axis_offset > max_snap: + continue + lo = min(seg.p1.y, seg.p2.y) + hi = max(seg.p1.y, seg.p2.y) + if lo <= pin_pt.y <= hi: + continue + target = Point(axis, pin_pt.y) + extend_gap = lo - pin_pt.y if pin_pt.y < lo else pin_pt.y - hi + endpoint = copy.copy(seg.p1 if abs(seg.p1.y - pin_pt.y) <= abs(seg.p2.y - pin_pt.y) else seg.p2) + elif seg.p1.y == seg.p2.y: + direction = "horizontal" + axis = seg.p1.y + axis_offset = abs(pin_pt.y - axis) + if axis_offset > max_snap: + continue + lo = min(seg.p1.x, seg.p2.x) + hi = max(seg.p1.x, seg.p2.x) + if lo <= pin_pt.x <= hi: + continue + target = Point(pin_pt.x, axis) + extend_gap = lo - pin_pt.x if pin_pt.x < lo else pin_pt.x - hi + endpoint = copy.copy(seg.p1 if abs(seg.p1.x - pin_pt.x) <= abs(seg.p2.x - pin_pt.x) else seg.p2) + else: + continue + + if extend_gap > max_extend: + continue + + candidate = { + "segment": seg, + "target": target, + "direction": direction, + "axis_offset": axis_offset, + "extend_gap": extend_gap, + "endpoint": endpoint, + "distance": axis_offset + extend_gap, + } + if best is None or ( + candidate["axis_offset"], + candidate["extend_gap"], + candidate["distance"], + candidate["target"].x, + candidate["target"].y, + ) < ( + best["axis_offset"], + best["extend_gap"], + best["distance"], + best["target"].x, + best["target"].y, + ): + best = candidate + + return best + + +def try_collinear_passive_attach(node, net, pin, wire_info=None, **options): + """Align simple passive pin repair to a nearby trunk axis when safe.""" + grid = int(options.get("grid", globals().get("GRID", 100))) + snap_limit = int(options.get("passive_collinear_snap_dist", 2 * grid)) + extend_limit = int(options.get("passive_collinear_extend_dist", 2 * grid)) + segments = node.wires.get(net, []) + candidate = _find_collinear_passive_attach(pin, segments, snap_limit, extend_limit) + if candidate is None: + return False, "no_collinear_attach", None, False + + pin_pt = _pin_anchor_point(pin) + if pin_pt is None: + return False, "pin/net missing", None, False + + old_seg = _normalized_segment(candidate["segment"]) + endpoint = candidate["endpoint"] + target = candidate["target"] + extension = _normalized_segment(Segment(copy.copy(endpoint), copy.copy(target)).round()) + if extension.p1 != extension.p2 and _segment_hits_other_net(node, net, extension): + _attach_debug_log( + options, + "[collinear_attach] ref=%s pin=%s old=%s new=%s trunk_direction=%s result=blocked" + % ( + getattr(getattr(pin, "part", None), "ref", "?"), + getattr(pin, "num", "?"), + _format_segment(old_seg), + _format_segment(extension), + candidate["direction"], + ), + ) + return False, "collinear_blocked", None, False + + new_seg = _normalized_segment(Segment( + copy.copy(target if candidate["segment"].p1 == endpoint else candidate["segment"].p1), + copy.copy(target if candidate["segment"].p2 == endpoint else candidate["segment"].p2), + ).round()) + candidate["segment"].p1 = new_seg.p1 + candidate["segment"].p2 = new_seg.p2 + + _attach_debug_log( + options, + "[collinear_attach] ref=%s pin=%s old=%s new=%s trunk_direction=%s" + % ( + getattr(getattr(pin, "part", None), "ref", "?"), + getattr(pin, "num", "?"), + _format_segment(old_seg), + _format_segment(new_seg), + candidate["direction"], + ), + ) + + if pin_pt == target: + _attach_debug_log( + options, + "[aligned_attach] ref=%s pin=%s old_geometry=%s new_geometry=%s trunk_direction=%s" + % ( + getattr(getattr(pin, "part", None), "ref", "?"), + getattr(pin, "num", "?"), + _format_segment(old_seg), + _format_segment(new_seg), + candidate["direction"], + ), + ) + return True, "aligned_attach", None, False + + stub = _normalized_segment(Segment(copy.copy(pin_pt), copy.copy(target)).round()) + if stub.p1 == stub.p2 or _segment_hits_other_net(node, net, stub): + candidate["segment"].p1 = old_seg.p1 + candidate["segment"].p2 = old_seg.p2 + return False, "stub would hit other net", None, False + + if any( + (seg.p1 == stub.p1 and seg.p2 == stub.p2) + or (seg.p1 == stub.p2 and seg.p2 == stub.p1) + for seg in segments + ): + _attach_debug_log( + options, + "[aligned_attach] ref=%s pin=%s old_geometry=%s new_geometry=%s trunk_direction=%s" + % ( + getattr(getattr(pin, "part", None), "ref", "?"), + getattr(pin, "num", "?"), + _format_segment(old_seg), + _format_segment(new_seg), + candidate["direction"], + ), + ) + return True, "aligned_attach", None, False + + segments.append(stub) + _attach_debug_log( + options, + "[stub_replaced] ref=%s pin=%s old_geometry=%s new_geometry=%s trunk_direction=%s" + % ( + getattr(getattr(pin, "part", None), "ref", "?"), + getattr(pin, "num", "?"), + _format_segment(wire_info["segment"]) + if isinstance(wire_info, dict) and wire_info.get("segment") is not None + else "none", + _format_segment(stub), + candidate["direction"], + ), + ) + return True, "added", stub, False + + +def _wire_count_summary(wires): + return {name: count for name, count in sorted( + [(_net_name_for_log(net), len(segs)) for net, segs in wires.items()], + key=lambda item: item[0], + )} + + +def _attach_debug_log_wire_stage(node, options, stage, wires=None): + if not _attach_debug_enabled(options): + return + def _subtree_wire_total(current): + total = sum(len(segs) for segs in getattr(current, "wires", {}).values()) + for child in getattr(current, "children", {}).values(): + total += _subtree_wire_total(child) + return total + + wires = wires if wires is not None else getattr(node, "wires", {}) + summary = _wire_count_summary(wires) + subtree_total_wires = _subtree_wire_total(node) + _attach_debug_log( + options, + "wire-stage stage=%s node_id=%s sheet=%s wires_id=%s total_nets=%d total_wires=%d subtree_total_wires=%d per_net=%s" + % ( + stage, + id(node), + getattr(node, "name", "?"), + id(wires), + len(wires), + sum(len(segs) for segs in wires.values()), + subtree_total_wires, + summary, + ), + ) + + +def _format_wire_candidate(candidate): + if candidate is None: + return "none" + seg = candidate.get("segment") + net_name = _net_name_for_log(candidate.get("net")) + suffix = " dist=%d" % candidate["distance"] + if net_name: + suffix += " net=%s" % net_name + if seg is None: + return "none" + return "%s%s" % (_format_segment(seg), suffix) + + +def _attach_debug_log_pin_audit( + node, + pin, + options, + pin_net=None, + same_candidate=None, + other_candidate=None, + skipped_reason="", + attached=None, +): + if not _attach_debug_enabled(options) or not _is_attach_focus_pin(pin): + return + part = getattr(pin, "part", None) + anchor = _pin_anchor_point(pin) + if pin_net is None: + pin_net = getattr(pin, "net", None) + if attached is None: + attached = ( + pin_net in getattr(node, "wires", {}) + and is_pin_attached(pin, node.wires.get(pin_net, [])) + ) + _attach_debug_log( + options, + "pin-audit ref=%s value_name=%s pin=%s/%s net=%s anchor=%s attached=%s nearest_same=%s nearest_other=%s repair_skipped=%s skip_reason=%s" + % ( + getattr(part, "ref", "?") if part is not None else "?", + _part_value_name(part), + getattr(pin, "num", "?"), + getattr(pin, "name", "") or "", + _net_name_for_log(pin_net), + "(%d,%d)" % (anchor.x, anchor.y) if anchor is not None else "None", + attached, + _format_wire_candidate(same_candidate), + _format_wire_candidate(other_candidate), + bool(skipped_reason), + skipped_reason or "none", + ), + ) + + +def add_short_stub_from_pin_to_wire(node, net, pin, wire_info): + """Attach a pin to a same-net wire using a short orthogonal stub.""" + pin_pt = _pin_anchor_point(pin) + if pin_pt is None or wire_info is None: + return False, "pin/net missing", None, False + + target = wire_info["target"] + if _point_hits_other_net(node, net, target): + return False, "stub would hit other net", None, False + + segments = node.wires.get(net, []) + split_done = False + if wire_info.get("needs_split"): + if _point_hits_other_net(node, net, target): + return False, "stub would hit other net", None, split_done + if not _split_wire_at_point(segments, wire_info["segment"], target): + return False, "unsupported geometry", None, split_done + split_done = True + + if pin_pt == target: + return True, "already attached", None, split_done + + stub = Segment(copy.copy(pin_pt), copy.copy(target)).round() + if stub.p1 == stub.p2 or _segment_hits_other_net(node, net, stub): + return False, "stub would hit other net", None, split_done + + if any( + (seg.p1 == stub.p1 and seg.p2 == stub.p2) + or (seg.p1 == stub.p2 and seg.p2 == stub.p1) + for seg in segments + ): + return False, "already attached", None, split_done + + if stub.p2 < stub.p1: + stub.p1, stub.p2 = stub.p2, stub.p1 + segments.append(stub) + return True, "added", stub, split_done + + +def _trim_dangling_non_pin_stubs(segments, pin_points): + """Recursively trim non-pin leaves from a same-net segment list.""" + trimmed = list(segments) + pin_points = set(pin_points or []) + while True: + degree = defaultdict(list) + for seg in trimmed: + degree[seg.p1].append(seg) + degree[seg.p2].append(seg) + stubs = { + segs[0] + for pt, segs in degree.items() + if len(segs) == 1 and pt not in pin_points + } + if not stubs: + return trimmed + trimmed = [seg for seg in trimmed if seg not in stubs] + + +def _log_passive_attach_jog(options, event, pin, net, reason, old_segments=None, new_segment=None): + part = getattr(pin, "part", None) + payload = [ + "[%s]" % event, + "part=%s" % (getattr(part, "ref", "?") if part is not None else "?"), + "pin=%s" % getattr(pin, "num", getattr(pin, "name", "?")), + "net=%s" % _net_name_for_log(net), + "reason=%s" % reason, + ] + if old_segments is not None: + payload.append("old_segments=%s" % str(list(old_segments))) + if new_segment is not None: + payload.append("new_segment=%s" % str(new_segment)) + _attach_debug_log(options, " ".join(payload)) + + +def _find_straight_stub_target_for_passive_pin( + pin, segments, pin_seg=None, exclude_segments=None +): + """Find a same-net trunk point a passive pin can reach with one straight segment.""" + pin_pt = _pin_anchor_point(pin) + if pin_pt is None: + return None + + exclude_segments = set(exclude_segments or ()) + if pin_seg is None: + pin_segments = [ + seg + for seg in segments + if seg not in exclude_segments and pin_pt in (seg.p1, seg.p2) + ] + if len(pin_segments) != 1: + return None + pin_seg = pin_segments[0] + + pin_orientation = _segment_orientation(pin_seg) + if pin_orientation not in ("horizontal", "vertical"): + return None + + best = None + for seg in segments: + if seg in exclude_segments: + continue + if pin_orientation == "vertical": + if seg.p1.y != seg.p2.y or not (seg.p1.x <= pin_pt.x <= seg.p2.x): + continue + target = Point(pin_pt.x, seg.p1.y) + else: + if seg.p1.x != seg.p2.x or not (seg.p1.y <= pin_pt.y <= seg.p2.y): + continue + target = Point(seg.p1.x, pin_pt.y) + + if target == pin_pt: + continue + + candidate = { + "segment": seg, + "target": target, + "distance": abs(pin_pt.x - target.x) + abs(pin_pt.y - target.y), + "needs_split": _point_on_segment_interior(seg, target), + } + if best is None or ( + candidate["distance"], + candidate["target"].x, + candidate["target"].y, + ) < ( + best["distance"], + best["target"].x, + best["target"].y, + ): + best = candidate + + return best + + +def _try_replace_passive_l_jog_with_straight_stub(node, net, pin, **options): + """Collapse a passive pin's tiny same-net L jog into one straight stub when safe.""" + part = getattr(pin, "part", None) + if not _is_simple_two_pin_passive(part): + _log_passive_attach_jog(options, "passive_attach_jog_skipped", pin, net, "not_passive") + return False, "not_passive" + + segments = node.wires.get(net, []) + pin_pt = _pin_anchor_point(pin) + if pin_pt is None or not segments: + return False, "no_same_net_trunk" + + pin_segments = [seg for seg in segments if pin_pt in (seg.p1, seg.p2)] + if len(pin_segments) != 1: + reason = "multiple_branches" if len(pin_segments) > 1 else "not_collinear" + _log_passive_attach_jog(options, "passive_attach_jog_skipped", pin, net, reason) + return False, reason + + first = pin_segments[0] + corner = _segment_other_end(first, pin_pt) + if corner is None: + _log_passive_attach_jog(options, "passive_attach_jog_skipped", pin, net, "not_collinear") + return False, "not_collinear" + + corner_branches = [ + seg for seg in segments if seg is not first and corner in (seg.p1, seg.p2) + ] + if len(corner_branches) != 1: + _log_passive_attach_jog(options, "passive_attach_jog_skipped", pin, net, "multiple_branches") + return False, "multiple_branches" + + orthogonal = [ + seg + for seg in corner_branches + if _segment_orientation(seg) != _segment_orientation(first) + ] + if len(orthogonal) != 1: + _log_passive_attach_jog(options, "passive_attach_jog_skipped", pin, net, "not_collinear") + return False, "not_collinear" + + second = orthogonal[0] + direct = _find_straight_stub_target_for_passive_pin( + pin, segments, pin_seg=first, exclude_segments={first, second} + ) + if direct is None: + _log_passive_attach_jog(options, "passive_attach_jog_skipped", pin, net, "not_collinear") + return False, "no_same_net_trunk" + + target = direct["target"] + new_stub = _normalized_segment(Segment(copy.copy(pin_pt), copy.copy(target)).round()) + if new_stub.p1 == new_stub.p2 or corner == target: + _log_passive_attach_jog(options, "passive_attach_jog_skipped", pin, net, "not_collinear") + return False, "already_straight" + + if _segment_hits_other_net(node, net, new_stub) or _segment_hits_other_parts( + node, new_stub, ignored_parts={part} + ): + _log_passive_attach_jog( + options, + "passive_attach_jog_skipped", + pin, + net, + "would_hit_other_net", + old_segments=[_segment_coords(first), _segment_coords(second)], + new_segment=_segment_coords(new_stub), + ) + return False, "would_hit_other_net" + + trunk_seg = direct["segment"] + split_done = False + if direct["needs_split"]: + split_done = _split_wire_at_point(segments, trunk_seg, target) + if not split_done: + return False, "already_straight" + + if first in segments: + segments.remove(first) + if second in segments: + segments.remove(second) + + duplicate = any( + (seg.p1 == new_stub.p1 and seg.p2 == new_stub.p2) + or (seg.p1 == new_stub.p2 and seg.p2 == new_stub.p1) + for seg in segments + ) + if not duplicate: + segments.append(new_stub) + + pin_points = { + _pin_anchor_point(net_pin) + for net_pin in getattr(net, "pins", []) + if _pin_anchor_point(net_pin) is not None + } + node.wires[net] = _trim_dangling_non_pin_stubs(segments, pin_points) + _log_passive_attach_jog( + options, + "passive_attach_jog_removed", + pin, + net, + "collinear_trunk_attach", + old_segments=[_segment_coords(first), _segment_coords(second)], + new_segment=_segment_coords(new_stub), + ) + return True, "straight_stub_available" + + +def simplify_passive_attach_jogs(node, nets=None, **options): + """Remove tiny passive-pin attach jogs when a straight same-net trunk attach is safe.""" + nets = list(nets or node.wires.keys()) + simplified = 0 + for net in nets: + if not node.wires.get(net): + continue + pins = _driver_route_pins(node, net) + if not pins and hasattr(node, "get_internal_pins"): + pins = list(node.get_internal_pins(net)) + for pin in pins: + if getattr(pin, "net", None) is not net: + continue + changed, _reason = _try_replace_passive_l_jog_with_straight_stub( + node, net, pin, **options + ) + simplified += 1 if changed else 0 + return simplified + + +def repair_unattached_same_net_pins(node, nets=None, **options): + """Conservatively attach pins to nearby same-net wires after routing.""" + grid = int(options.get("grid", globals().get("GRID", 100))) + max_dist = int(options.get("pin_attach_repair_dist", 2 * grid)) + nets = list(nets or node.wires.keys()) + target_nets = set(nets) + repaired = 0 + added_stubs = [] + split_count = 0 + stats = { + "scanned_pins": 0, + "already_attached": 0, + "no_net": 0, + "no_same_net_wire": 0, + "nearest_too_far": 0, + "blocked_by_other_net": 0, + "unsupported_geometry": 0, + "repaired_stub_count": 0, + "split_wire_count": 0, + "repaired_pin_count": 0, + } + + if _attach_debug_enabled(options): + _attach_debug_log( + options, + "repair start sheet=%s nets=%d max_dist=%d existing_wires=%d" + % ( + getattr(node, "name", "?"), + len(nets), + max_dist, + sum(len(segs) for segs in node.wires.values()), + ), + ) + _attach_debug_log_wire_stage(node, options, "repair_unattached_same_net_pins_before") + + for net in nets: + segments = node.wires.get(net) + if not segments: + _attach_debug_log( + options, + "net=%s skip-net reason=no same-net wires" + % _net_name_for_log(net), + ) + continue + + pins = _driver_route_pins(node, net) + if not pins and hasattr(node, "get_internal_pins"): + pins = list(node.get_internal_pins(net)) + + for pin in pins: + stats["scanned_pins"] += 1 + pin_net = getattr(pin, "net", None) + pin_pt = _pin_anchor_point(pin) + same_net_segments = node.wires.get(pin_net) if pin_net is not None else None + nearest_any = ( + _find_best_same_net_wire(pin, same_net_segments or []) + if same_net_segments + else None + ) + nearest_other = _find_best_other_net_wire(node, pin, pin_net) + + if pin_net is not net: + _attach_debug_log_pin_audit( + node, + pin, + options, + pin_net=pin_net, + same_candidate=nearest_any, + other_candidate=nearest_other, + skipped_reason="not_in_target_scope", + ) + _attach_debug_log( + options, + "%s skipped: reason=not in target scope target_net=%s actual_net=%s" + % (_pin_label(pin), _net_name_for_log(net), _pin_net_name(pin)), + ) + continue + + if pin_pt is None or pin_net is None: + stats["no_net"] += 1 + _attach_debug_log_pin_audit( + node, + pin, + options, + pin_net=pin_net, + same_candidate=nearest_any, + other_candidate=nearest_other, + skipped_reason="no_net_or_anchor", + attached=False, + ) + _attach_debug_log( + options, + "%s skipped: reason=pin/net missing" % _pin_label(pin), + ) + continue + + if pin_net not in target_nets: + _attach_debug_log_pin_audit( + node, + pin, + options, + pin_net=pin_net, + same_candidate=nearest_any, + other_candidate=nearest_other, + skipped_reason="not_in_target_scope", + ) + _attach_debug_log( + options, + "%s skipped: reason=not in target scope" % _pin_label(pin), + ) + continue + + if is_pin_attached(pin, segments): + stats["already_attached"] += 1 + _attach_debug_log_pin_audit( + node, + pin, + options, + pin_net=pin_net, + same_candidate=nearest_any, + other_candidate=nearest_other, + skipped_reason="already_attached", + attached=True, + ) + _attach_debug_log( + options, + "%s skipped: reason=already attached" % _pin_label(pin), + ) + continue + + if not same_net_segments: + stats["no_same_net_wire"] += 1 + _attach_debug_log_pin_audit( + node, + pin, + options, + pin_net=pin_net, + same_candidate=nearest_any, + other_candidate=nearest_other, + skipped_reason="no_same_net_wire", + attached=False, + ) + _attach_debug_log( + options, + "%s skipped: reason=no same-net wires" % _pin_label(pin), + ) + continue + + wire_info = nearest_any + if wire_info is None: + stats["unsupported_geometry"] += 1 + _attach_debug_log_pin_audit( + node, + pin, + options, + pin_net=pin_net, + same_candidate=nearest_any, + other_candidate=nearest_other, + skipped_reason="unsupported_geometry", + attached=False, + ) + _attach_debug_log( + options, + "%s skipped: reason=unsupported geometry" % _pin_label(pin), + ) + continue + if wire_info["distance"] > max_dist: + stats["nearest_too_far"] += 1 + _attach_debug_log_pin_audit( + node, + pin, + options, + pin_net=pin_net, + same_candidate=nearest_any, + other_candidate=nearest_other, + skipped_reason="nearest_too_far", + attached=False, + ) + _attach_debug_log( + options, + "%s skipped: reason=nearest wire too far actual_dist=%d limit=%d nearest=%s" + % ( + _pin_label(pin), + wire_info["distance"], + max_dist, + _format_segment(wire_info["segment"]), + ), + ) + continue + added, reason, stub, split_done = try_collinear_passive_attach( + node, + net, + pin, + wire_info=wire_info, + **options + ) + if not added and reason not in ("no_collinear_attach", "collinear_blocked"): + _attach_debug_log( + options, + "%s collinear attach fallback: reason=%s" + % (_pin_label(pin), reason), + ) + if not added: + added, reason, stub, split_done = add_short_stub_from_pin_to_wire( + node, net, pin, wire_info + ) + if added: + repaired += 1 + stats["repaired_pin_count"] += 1 + split_count += 1 if split_done else 0 + stats["split_wire_count"] += 1 if split_done else 0 + if stub is not None: + added_stubs.append((net, stub)) + stats["repaired_stub_count"] += 1 + _attach_debug_log_pin_audit( + node, + pin, + options, + pin_net=pin_net, + same_candidate=nearest_any, + other_candidate=nearest_other, + skipped_reason="", + attached=True, + ) + _attach_debug_log( + options, + "%s repaired: target=%s split=%s stub=%s" + % ( + _pin_label(pin), + "(%d,%d)" % (wire_info["target"].x, wire_info["target"].y), + split_done, + _format_segment(stub) if stub is not None else "none", + ), + ) + else: + if reason == "stub would hit other net": + stats["blocked_by_other_net"] += 1 + skip_reason = "blocked_by_other_net" + elif reason == "unsupported geometry": + stats["unsupported_geometry"] += 1 + skip_reason = "unsupported_geometry" + else: + skip_reason = reason.replace(" ", "_") + _attach_debug_log_pin_audit( + node, + pin, + options, + pin_net=pin_net, + same_candidate=nearest_any, + other_candidate=nearest_other, + skipped_reason=skip_reason, + attached=False, + ) + _attach_debug_log( + options, + "%s skipped: reason=%s target=%s" + % ( + _pin_label(pin), + reason, + "(%d,%d)" % (wire_info["target"].x, wire_info["target"].y), + ), + ) + + if options.get("schematic_progress", False) and repaired: + from skidl.logger import active_logger + + active_logger.info( + "[schematic] same-net pin attach repair: %d pin(s)" % repaired + ) + + if _attach_debug_enabled(options): + node._attach_debug_last_repair = { + "added_stub_signatures": [_wire_signature(seg, net) for net, seg in added_stubs], + "added_stub_details": [ + { + "net": _net_name_for_log(net), + "coords": _segment_coords(seg), + } + for net, seg in added_stubs + ], + "split_count": split_count, + "repaired_count": repaired, + "stats": stats, + "wires_id": id(node.wires), + } + _attach_debug_log( + options, + "repair stats scanned_pins=%d already_attached=%d no_net=%d no_same_net_wire=%d nearest_too_far=%d blocked_by_other_net=%d unsupported_geometry=%d repaired_stub_count=%d split_wire_count=%d repaired_pin_count=%d" + % ( + stats["scanned_pins"], + stats["already_attached"], + stats["no_net"], + stats["no_same_net_wire"], + stats["nearest_too_far"], + stats["blocked_by_other_net"], + stats["unsupported_geometry"], + stats["repaired_stub_count"], + stats["split_wire_count"], + stats["repaired_pin_count"], + ), + ) + for net, seg in added_stubs: + _attach_debug_log( + options, + "repair wire net=%s seg=%s" + % (_net_name_for_log(net), _format_segment(seg)), + ) + _attach_debug_log_wire_stage(node, options, "repair_unattached_same_net_pins_after") + + simplify_passive_attach_jogs(node, nets, **options) + + return repaired + + ################################################################### # # OVERVIEW OF SCHEMATIC AUTOROUTER @@ -2858,6 +4145,7 @@ def cleanup_wires(node): route_opts = getattr(node, "_route_options", {}) or {} human_readable = route_opts.get("human_readable", False) reuse_junctions = route_opts.get("reuse_junctions", True) + grid = int(route_opts.get("grid", globals().get("GRID", 100))) sheet = getattr(node, "name", "?") def plog(msg): @@ -3954,6 +5242,7 @@ def try_t_tap(segs, seg, ep, other): ) prerouted = set(getattr(node, "_driver_prerouted_nets", set()) or []) + _attach_debug_log_wire_stage(node, route_opts, "cleanup_wires_before") # Do a generalized cleanup of the wire segments of each net. for net, segments in node.wires.items(): @@ -4097,15 +5386,20 @@ def try_t_tap(segs, seg, ep, other): keep_cleaning = True plog(f"[schematic] cleanup 结束 sheet={sheet},共 {clean_round} 轮") + _attach_debug_log_wire_stage(node, route_opts, "cleanup_wires_after") if human_readable: # 在通用清理后追加保守的人类化处理:只做不改变连通性的局部简化。 plog(f"[schematic] humanize_wires sheet={sheet}") node.humanize_wires() + _attach_debug_log_wire_stage(node, route_opts, "humanize_wires_after") def humanize_wires(node): """Apply conservative post-cleanup wiring simplifications.""" + route_opts = getattr(node, "_route_options", {}) or {} + grid = int(route_opts.get("grid", globals().get("GRID", 100))) + def seg_key(seg): return ( min(seg.p1.x, seg.p2.x), @@ -4121,6 +5415,7 @@ def seg_key(seg): for net, segments in node.wires.items(): if net in prerouted: + node.wires[net] = _prune_driver_preroute_tails(node, net, segments, grid) continue cleaned = [] @@ -4172,7 +5467,7 @@ def merge_axis(segs, axis): # 删除非常短的 stub,保留连接主干的必要线段。 # Segment 无 length 属性;用端点差的 magnitude(正交线段即几何长度)。 - stub_thresh = max(1, GRID // 2) + stub_thresh = max(1, grid // 2) trimmed = [] for seg in cleaned: seg_len = (seg.p2 - seg.p1).magnitude @@ -4190,7 +5485,11 @@ def merge_axis(segs, axis): continue trimmed.append(seg) + if is_trunk_net_name(net_name): + trimmed = _prune_linear_endpoint_tails(node, net, trimmed, grid) + node.wires[net] = trimmed + simplify_passive_attach_jogs(node, [net], **route_opts) def add_junctions(node): """Add X & T-junctions where wire segments in the same net meet.""" @@ -4325,7 +5624,10 @@ def route(node, tool=None, **options): if not routed_nets: node.cleanup_wires() + repair_unattached_same_net_pins(node, internal_nets, **options) node.add_junctions() + _attach_debug_log_wire_stage(node, options, "add_junctions_after") + _attach_debug_log_wire_stage(node, options, "route_complete") node.rmv_routing_stuff() rmv_attr(node, ("_route_options",)) return @@ -4406,8 +5708,13 @@ def route(node, tool=None, **options): # Now clean-up the wires and add junctions. _sch_progress(options, f"[schematic] cleanup_wires sheet={sheet} ...") node.cleanup_wires() + _sch_progress(options, f"[schematic] pin_attach_repair sheet={sheet}") + repair_unattached_same_net_pins(node, internal_nets, **options) + _attach_debug_log_wire_stage(node, options, "repair_unattached_same_net_pins_after") _sch_progress(options, f"[schematic] add_junctions sheet={sheet}") node.add_junctions() + _attach_debug_log_wire_stage(node, options, "add_junctions_after") + _attach_debug_log_wire_stage(node, options, "route_complete") _sch_progress(options, f"[schematic] 布线完成 sheet={sheet}") # If enabled, draw the global and detailed routing for debug purposes. diff --git a/src/skidl/schematics/topology.py b/src/skidl/schematics/topology.py index 29b713b3..106b31e7 100644 --- a/src/skidl/schematics/topology.py +++ b/src/skidl/schematics/topology.py @@ -5,6 +5,7 @@ 与 trunk-aware 布局互斥:matched 时仅 apply_generic_driver_layout,否则 apply_trunk_aware_layout。 """ +import os import re from collections import defaultdict @@ -14,12 +15,36 @@ _place_parts_in_row, _resolve_overlaps, _set_part_center_x_safe, + allow_anonymous_input_rail_promotion, apply_trunk_aware_layout, classify_trunk_nets, ) # 网名 / pin 名 token(双通道分类) -_INPUT_TOKENS = ("VIN", "VCC", "VDD", "VM", "VBAT", "24V", "12V", "5V", "3V3", "SUPPLY", "POWER") +_INPUT_TOKENS = ( + "VIN", + "VCC", + "VDD", + "VM", + "VBAT", + "VBUS", + "VSUP", + "24V", + "12V", + "9V", + "5V", + "3V3", + "3V", + "1V8", + "SUPPLY", + "POWER", + "PWR", + "B+", + "BATT", + "BAT+", + "IN+", + "DC_IN", +) _GROUND_TOKENS = ("GND", "VSS", "PGND", "AGND", "DGND") # 顶/底 power rail 网名 token(与 control/switch 分离) _TOP_RAIL_TOKENS = _INPUT_TOKENS + ("W+", "LED+") @@ -34,6 +59,7 @@ r"(^0\s*R|^0R|^0\.|MR|R050|0\.43|(^|[^0-9])1R([^0-9]|$))", re.IGNORECASE, ) +_SENSE_PART_TOKENS = ("SENSE", "CS", "CSN", "CSP", "RS", "ISEN") def _token_in_text(text, tokens): @@ -47,10 +73,51 @@ def _token_in_text(text, tokens): return False +def _matched_tokens(text, tokens): + """Return matched tokens in stable order for classification/debug logging.""" + if not text: + return [] + upper = str(text).upper() + matches = [] + for token in tokens: + token_u = str(token).upper() + if token_u in upper and token_u not in matches: + matches.append(token_u) + matches.sort(key=len, reverse=True) + return matches + + +def _token_in_text(text, tokens): + return bool(_matched_tokens(text, tokens)) + + def _net_label(net): return str(getattr(net, "name", "") or "") +def _attach_debug_enabled(options=None): + if options and options.get("schematic_attach_debug") is not None: + return bool(options.get("schematic_attach_debug")) + value = str(os.environ.get("SKIDL_SCH_DEBUG_ATTACH", "") or "").strip().lower() + return value not in ("", "0", "false", "no", "off") + + +def _attach_debug_log(options, message): + if not _attach_debug_enabled(options): + return + from skidl.logger import active_logger + + active_logger.info("[attach-debug] %s" % message) + + +def _topology_debug_log(options, tag, message): + if not options.get("schematic_progress", False): + return + from skidl.logger import active_logger + + active_logger.info("[%s] %s" % (tag, message)) + + def _disabled_topology(fallback="trunk_aware"): return { "kind": "disabled", @@ -143,6 +210,8 @@ def _classify_net_semantic(net, main_part, node, part_set, adjacency): 返回 set of: input, ground, output, control, switch, sense """ net_name = _net_label(net).upper() + net_parts = node._net_connected_parts(net, allowed_parts=part_set) + net_refs = [str(getattr(part, "ref", "") or "").upper() for part in net_parts] categories = set() if _token_in_text(net_name, _INPUT_TOKENS): @@ -176,6 +245,17 @@ def _classify_net_semantic(net, main_part, node, part_set, adjacency): if _token_in_text(pname, _SENSE_TOKENS): categories.add("sense") + if ( + "input" not in categories + and pin_names + and any(_matched_tokens(pname, _INPUT_TOKENS) for pname in pin_names) + ): + categories.add("input") + + if "input" not in categories and _matched_tokens(net_name, ("POWER", "PWR", "SUPPLY")): + if any(ref.startswith(("C", "D", "L", "J", "P", "CN")) for ref in net_refs): + categories.add("input") + # SW 需绑主 IC pin 或邻接 L/D 才计 switch(避免单独 SW 网名误判) if "switch" in categories and main_part is not None: pin_names = _pins_on_part_for_net(node, main_part, net, part_set) @@ -355,6 +435,136 @@ def _build_net_lists(node, candidate, parts, nets, part_set, adjacency): return buckets +def _score_driver_rail_candidate(net, node, main_part, part_set): + """Score top/bottom/control rail candidacy with reasons for debug logging.""" + net_name = _net_label(net).upper() + net_parts = node._net_connected_parts(net, allowed_parts=part_set) + pin_names = ( + _pins_on_part_for_net(node, main_part, net, part_set) if main_part is not None else [] + ) + categories = _classify_net_semantic(net, main_part, node, part_set, None) + refs = [str(getattr(part, "ref", "") or "").upper() for part in net_parts] + + top_score = 0 + bottom_score = 0 + control_score = 0 + reasons = [] + token_hits = [] + + top_hits = _matched_tokens(net_name, _TOP_RAIL_TOKENS) + bottom_hits = _matched_tokens(net_name, _BOTTOM_RAIL_TOKENS) + control_hits = _matched_tokens(net_name, _CONTROL_TOKENS) + pin_input_hits = [] + pin_ground_hits = [] + pin_control_hits = [] + + for pname in pin_names: + pin_input_hits.extend(_matched_tokens(pname, _INPUT_TOKENS)) + pin_ground_hits.extend(_matched_tokens(pname, _GROUND_TOKENS)) + pin_control_hits.extend(_matched_tokens(pname, _CONTROL_TOKENS)) + + if top_hits: + top_score += 4 + len(top_hits) + token_hits.extend("token:%s" % token for token in top_hits) + reasons.extend("token:%s" % token for token in top_hits) + if bottom_hits: + bottom_score += 4 + len(bottom_hits) + token_hits.extend("token:%s" % token for token in bottom_hits) + reasons.extend("token:%s" % token for token in bottom_hits) + if control_hits: + control_score += 3 + len(control_hits) + reasons.extend("token:%s" % token for token in control_hits) + + if pin_input_hits: + top_score += 4 + reasons.append("main_pin:%s" % pin_input_hits[0]) + if pin_ground_hits: + bottom_score += 4 + reasons.append("main_pin:%s" % pin_ground_hits[0]) + if pin_control_hits: + control_score += 2 + reasons.append("main_pin:%s" % pin_control_hits[0]) + + if "input" in categories: + top_score += 2 + reasons.append("semantic:input") + if "ground" in categories: + bottom_score += 2 + reasons.append("semantic:ground") + if "control" in categories: + control_score += 2 + reasons.append("semantic:control") + if "switch" in categories: + control_score += 1 + reasons.append("semantic:switch") + + fanout = len(net_parts) + fanout_bonus = 0 + if fanout >= 4: + fanout_bonus = 3 + elif fanout >= 3: + fanout_bonus = 2 + elif fanout >= 2: + fanout_bonus = 1 + if fanout_bonus: + if top_score > 0: + top_score += fanout_bonus + if bottom_score > 0: + bottom_score += fanout_bonus + if control_score > 0: + control_score += 1 + reasons.append("fanout>=%d" % fanout) + + if main_part in net_parts: + if top_score > 0: + top_score += 2 + if bottom_score > 0: + bottom_score += 2 + if control_score > 0: + control_score += 1 + reasons.append("connected_to_ic") + + if top_score > 0: + if any(ref.startswith("C") for ref in refs): + top_score += 2 + reasons.append("input_cap") + if any(ref.startswith(("J", "P", "CN")) for ref in refs): + top_score += 1 + reasons.append("input_connector") + if any(ref.startswith("D") for ref in refs): + top_score += 1 + reasons.append("input_diode") + if any(ref.startswith("L") for ref in refs): + top_score += 1 + reasons.append("inductor_near") + + direction = None + final_score = 0 + if bottom_score >= max(top_score, control_score) and bottom_score > 0: + direction = "bottom" + final_score = bottom_score + elif top_score >= max(bottom_score, control_score) and top_score > 0: + direction = "top" + final_score = top_score + elif control_score > 0: + direction = "control" + final_score = control_score + + return { + "net": net, + "name": net_name, + "direction": direction, + "score": final_score, + "top_score": top_score, + "bottom_score": bottom_score, + "control_score": control_score, + "fanout": fanout, + "reasons": reasons, + "token_hits": token_hits, + "categories": sorted(categories), + } + + def _assign_topology_part_groups(node, parts, roles, topology, part_set): """按已分类 net 将器件归入各功能区。""" net_sets = { @@ -378,6 +588,13 @@ def touches(part, key): continue ref = str(getattr(part, "ref", "") or "").upper() role = roles.get(part, "other") + control_like = touches(part, "control") or _part_is_control_branch_passive( + part, main + ) + sense_like = touches(part, "sense") or _part_looks_like_low_ohm_sense_resistor( + part, node, main + ) + inductor_like = _part_looks_like_inductor(part, roles) if touches(part, "input") and ( ref[:1] in ("C", "D") or role == "connector" @@ -391,12 +608,12 @@ def touches(part, key): ref.startswith(("L", "D", "Q")) and touches(part, "switch") ): topology["power_loop_parts"].add(part) - if touches(part, "control") or ( - ref.startswith(("R", "C")) and touches(part, "control") - ): + if control_like: topology["control_parts"].add(part) - if touches(part, "sense") and ref.startswith(("R", "C")): + if sense_like and ref.startswith(("R", "C")): topology["sense_feedback_parts"].add(part) + if inductor_like and (touches(part, "switch") or touches(part, "output")): + topology["power_loop_parts"].add(part) if touches(part, "ground") and ref.startswith("C"): # 地相关去耦可偏下,由布局 Y 处理 pass @@ -406,7 +623,7 @@ def touches(part, key): if part is main: continue ref = str(getattr(part, "ref", "") or "").upper() - if ref.startswith(("L", "D")) and ( + if (_part_looks_like_inductor(part, roles) or ref.startswith("D")) and ( touches(part, "output") or touches(part, "switch") ): topology["power_loop_parts"].add(part) @@ -419,6 +636,97 @@ def _part_ref_prefix(part): return str(getattr(part, "ref", "") or "").upper()[:1] +def _part_connected_nets(part): + return { + getattr(pin, "net", None) + for pin in getattr(part, "pins", []) + if getattr(pin, "net", None) is not None + } + + +def _connected_main_pin_names(part, main_part): + if main_part is None: + return set() + names = set() + for net in _part_connected_nets(part): + for pin in getattr(net, "pins", []): + if getattr(pin, "part", None) is main_part: + pname = str(getattr(pin, "name", "") or "").upper() + if pname: + names.add(pname) + return names + + +def _part_looks_like_inductor(part, roles): + ref = str(getattr(part, "ref", "") or "").upper() + role = str(roles.get(part, "") or "").lower() + text = "%s %s" % ( + str(getattr(part, "value", "") or ""), + str(getattr(part, "name", "") or ""), + ) + text = text.upper() + return ( + ref.startswith("L") + or role == "inductor" + or "INDUCTOR" in text + or "CHOKE" in text + ) + + +def _parse_resistor_ohms(value): + text = str(value or "").upper().replace(" ", "") + if not text: + return None + text = text.replace("OHMS", "").replace("OHM", "") + if "MR" in text: + match = re.search(r"(\d+(?:\.\d+)?)MR", text) + if match: + return float(match.group(1)) / 1000.0 + if text.startswith("R") and text[1:].isdigit(): + return float("0." + text[1:]) + match = re.match(r"(\d+)R(\d+)$", text) + if match: + return float("%s.%s" % (match.group(1), match.group(2))) + match = re.match(r"(\d+(?:\.\d+)?)R$", text) + if match: + return float(match.group(1)) + match = re.match(r"(\d+(?:\.\d+)?)$", text) + if match: + return float(match.group(1)) + return None + + +def _part_looks_like_low_ohm_sense_resistor(part, node, main_part): + ref = str(getattr(part, "ref", "") or "").upper() + if not ref.startswith("R"): + return False + value = str(getattr(part, "value", "") or "") + ident = "%s %s %s" % ( + ref, + value, + str(getattr(part, "name", "") or ""), + ) + ident_u = ident.upper() + ohms = _parse_resistor_ohms(value) + main_pin_names = _connected_main_pin_names(part, main_part) + return ( + _LOW_R_VALUE_RE.search(value.replace(" ", "")) is not None + or (ohms is not None and ohms < 1.0) + or _token_in_text(ident_u, _SENSE_PART_TOKENS) + or any(_token_in_text(name, _SENSE_TOKENS) for name in main_pin_names) + ) + + +def _part_is_control_branch_passive(part, main_part): + ref = str(getattr(part, "ref", "") or "").upper() + if not ref.startswith(("R", "C")): + return False + return any( + _token_in_text(name, _CONTROL_TOKENS) + for name in _connected_main_pin_names(part, main_part) + ) + + def _part_width(part, grid): return max(getattr(part.place_bbox, "w", 0), grid) @@ -452,12 +760,14 @@ def by_ref(parts_): right = [] for part in topology.get("power_loop_parts", set()): - if _part_ref_prefix(part) == "L": + if _part_looks_like_inductor(part, roles): right.append(part) for part in topology.get("output_parts", set()): if roles.get(part) == "connector": right.append(part) - right = by_ref(right) + inductors = by_ref([p for p in right if _part_looks_like_inductor(p, roles)]) + outputs = by_ref([p for p in right if p not in inductors]) + right = inductors + outputs chain = left + [main_part] + right return chain, set(chain) @@ -508,43 +818,180 @@ def _collect_driver_rail_nets(nets, topology, node, main_part, part_set): control = list(topology.get("control_nets", [])) control_ids = {id(n) for n in control} switch_ids = {id(n) for n in topology.get("switch_or_drive_nets", [])} + rail_debug = {} for net in nets: - if not _is_rail_label_net(net): - continue + name = _net_label(net).upper() + cats = sorted(_classify_net_semantic(net, main_part, node, part_set, None)) if id(net) in switch_ids: + rail_debug[name] = { + "net": net, + "name": name, + "direction": None, + "selected_direction": None, + "rejected_reason": "switch_or_drive_net", + "score": 0, + "top_score": 0, + "bottom_score": 0, + "control_score": 0, + "fanout": len(node._net_connected_parts(net, allowed_parts=part_set)), + "reasons": [], + "token_hits": [], + "categories": cats, + } + _topology_debug_log( + getattr(node, "_schematic_debug_options", {}), + "rail_candidate", + "net=%s fanout=%s semantic_types=%s score=%s matched_tokens=%s selected_direction=%s rejected_reason=%s" + % ( + name or "", + rail_debug[name]["fanout"], + cats, + 0, + [], + None, + "switch_or_drive_net", + ), + ) continue - name = _net_label(net).upper() - cats = _classify_net_semantic(net, main_part, node, part_set, None) + meta = _score_driver_rail_candidate(net, node, main_part, part_set) + meta["selected_direction"] = None + meta["rejected_reason"] = "unclassified" + rail_debug[name] = meta + _topology_debug_log( + getattr(node, "_schematic_debug_options", {}), + "rail_candidate", + "net=%s fanout=%s semantic_types=%s score=%s matched_tokens=%s selected_direction=%s rejected_reason=%s reasons=%s" + % ( + name or "", + meta.get("fanout", 0), + meta.get("categories", []), + meta.get("score", 0), + meta.get("token_hits", []), + meta.get("selected_direction"), + meta.get("rejected_reason"), + meta.get("reasons", []), + ), + ) + _topology_debug_log( + getattr(node, "_schematic_debug_options", {}), + "rail_score", + "net=%s top=%s bottom=%s control=%s fanout=%s token_matched=%s" + % ( + name or "", + meta.get("top_score", 0), + meta.get("bottom_score", 0), + meta.get("control_score", 0), + meta.get("fanout", 0), + meta.get("token_hits", []), + ), + ) + + anonymous_promoted = False + if _is_anonymous_net(net): + anonymous_promoted = allow_anonymous_input_rail_promotion( + name, + meta.get("direction"), + meta.get("categories", []), + meta.get("reasons", []), + meta.get("fanout", 0), + ) + if not anonymous_promoted: + meta["rejected_reason"] = "anonymous_or_blank" + continue + _topology_debug_log( + getattr(node, "_schematic_debug_options", {}), + "rail_promoted", + "net=%s semantic=input reasons=%s" + % (name or "", meta.get("reasons", [])), + ) if id(net) in control_ids or "control" in cats or _token_in_text( name, _CONTROL_TOKENS ): if net not in control: control.append(net) + meta["direction"] = "control" + meta["selected_direction"] = "control" + meta["rejected_reason"] = "" continue if "switch" in cats or _token_in_text(name, _SWITCH_TOKENS): + meta["rejected_reason"] = "switch_semantic" continue - if _token_in_text(name, _BOTTOM_RAIL_TOKENS) or "ground" in cats: + if meta.get("direction") == "bottom" and meta.get("score", 0) > 0: bottom.append(net) + meta["selected_direction"] = "bottom" + meta["rejected_reason"] = "" continue - if _token_in_text(name, _TOP_RAIL_TOKENS) or "input" in cats: + if meta.get("direction") == "top" and meta.get("score", 0) > 0: top.append(net) + meta["selected_direction"] = "top" + meta["rejected_reason"] = "" continue if net in topology.get("ground_nets", []): bottom.append(net) + meta["selected_direction"] = "bottom" + meta["rejected_reason"] = "fallback_ground_bucket" elif net in topology.get("input_nets", []) or net in topology.get( "power_nets", [] ): top.append(net) + meta["selected_direction"] = "top" + meta["rejected_reason"] = "fallback_input_bucket" elif net in topology.get("output_nets", []): if _token_in_text(name, ("LED+", "W+", "LED", "OUT+")): top.append(net) + meta["selected_direction"] = "top" + meta["rejected_reason"] = "fallback_output_positive_bucket" elif _token_in_text(name, ("LED-", "W-")): bottom.append(net) + meta["selected_direction"] = "bottom" + meta["rejected_reason"] = "fallback_output_negative_bucket" + else: + meta["rejected_reason"] = "output_without_rail_token" + else: + meta["rejected_reason"] = meta.get("rejected_reason") or "score_zero_or_not_bucketed" + + top = _dedupe_nets(top) + bottom = _dedupe_nets(bottom) + control = _dedupe_nets(control) + topology["rail_debug"] = rail_debug + for meta in rail_debug.values(): + if meta.get("selected_direction"): + continue + _topology_debug_log( + getattr(node, "_schematic_debug_options", {}), + "rail_rejected", + "net=%s fanout=%s semantic_types=%s score=%s matched_tokens=%s selected_direction=%s rejected_reason=%s" + % ( + meta.get("name") or "", + meta.get("fanout", 0), + meta.get("categories", []), + meta.get("score", 0), + meta.get("token_hits", []), + meta.get("selected_direction"), + meta.get("rejected_reason") or "unknown", + ), + ) + for side, selected in (("top", top), ("bottom", bottom), ("control", control)): + for net in selected: + meta = rail_debug.get(_net_label(net).upper(), {}) + _topology_debug_log( + getattr(node, "_schematic_debug_options", {}), + "rail_selected", + "net=%s score=%s reasons=%s token_matched=%s fanout=%s final_direction=%s" + % ( + _net_label(net).upper() or "", + meta.get("score", 0), + meta.get("reasons", []), + meta.get("token_hits", []), + meta.get("fanout", 0), + side, + ), + ) - return _dedupe_nets(top), _dedupe_nets(bottom), _dedupe_nets(control) + return top, bottom, control def _union_placed_bbox(parts): @@ -637,6 +1084,39 @@ def _driver_chain_pin_y_span(node): return min(ys), max(ys) +def _driver_rail_attachment_points(node, net, allowed_parts=None): + """Collect actual driver rail attachment points from connected, placed pins.""" + from skidl.schematics.place import is_net_terminal + + pts = [] + allowed = set(allowed_parts) if allowed_parts is not None else None + for pin in getattr(net, "pins", []): + part = getattr(pin, "part", None) + if part is None or is_net_terminal(part): + continue + if allowed is not None and part not in allowed: + continue + tx = getattr(part, "tx", None) + pt = getattr(pin, "pt", None) + if tx is None or pt is None: + continue + pts.append((pt * tx).round()) + return pts + + +def _driver_rail_span_from_points(points, grid, fallback_min, fallback_max, margin_grids=1): + """Derive a conservative horizontal rail span from actual attachment points.""" + if not points: + return fallback_min, fallback_max + + margin = max(int(margin_grids), 0) * grid + x_min = Point(min(pt.x for pt in points), 0).snap(grid).x - margin + x_max = Point(max(pt.x for pt in points), 0).snap(grid).x + margin + if x_min > x_max: + return fallback_min, fallback_max + return x_min, x_max + + def _clamp_rail_y_to_driver_chain(node, top_y, bottom_y, grid, rail_margin): """ 把顶/底 rail 限制在主链引脚附近,避免 union/place 离群框把 rail 甩到页底。 @@ -692,6 +1172,7 @@ def build_driver_rail_plan(node, parts, nets, topology, main_part, **options): return disabled if not options.get("driver_rail_routing", True): return disabled + node._schematic_debug_options = options grid = int(options.get("grid", 100)) rail_margin = 2 * grid @@ -726,15 +1207,27 @@ def build_driver_rail_plan(node, parts, nets, topology, main_part, **options): node, top_y, bottom_y, grid, rail_margin ) + real_part_set = set(real_parts) + rail_spans = {} + for net in top_nets + bottom_nets: + rail_spans[net] = _driver_rail_span_from_points( + _driver_rail_attachment_points(node, net, allowed_parts=real_part_set), + grid, + x_min, + x_max, + ) + return { "enabled": True, "top_nets": top_nets, "bottom_nets": bottom_nets, "control_nets": control_nets, + "topology": topology, "top_y": top_y, "bottom_y": bottom_y, "x_min": x_min, "x_max": x_max, + "rail_spans": rail_spans, "grid": grid, } @@ -757,6 +1250,25 @@ def _log_driver_rails(plan, options): plan.get("x_max"), ) ) + rail_debug = (plan.get("topology") or {}).get("rail_debug", {}) + for side in ("top", "bottom"): + for net in plan.get("%s_nets" % side, []): + meta = rail_debug.get(_net_label(net).upper(), {}) + if not meta: + continue + _topology_debug_log( + options, + "rail_selected", + "net=%s score=%s reasons=%s token_matched=%s fanout=%s final_direction=%s" + % ( + meta.get("name") or "", + meta.get("score", 0), + meta.get("reasons", []), + meta.get("token_hits", []), + meta.get("fanout", 0), + side, + ), + ) def _log_rail_blockers(node, parts, plan, options): @@ -787,20 +1299,24 @@ def _part_on_net_set(part, net_set): return False -def _chain_row_satellite_parts(node, parts, chain_parts, nets): +def _chain_row_satellite_parts(node, parts, chain_parts, nets, topology=None): """ 与主链器件共网、但不在 chain 内的 R/C(如 R1、输入侧小电容), 应排在主链同一水平行,避免 switch 网被 switchbox 绕外围。 """ chain_set = set(chain_parts) part_set = set(parts) + reserved = set() + if topology: + reserved |= set(topology.get("sense_feedback_parts", set()) or []) + reserved |= set(topology.get("control_parts", set()) or []) satellites = [] for net in nets: connected = node._net_connected_parts(net, allowed_parts=part_set) if not connected or not chain_set.intersection(connected): continue for part in connected: - if part in chain_set: + if part in chain_set or part in reserved: continue if _part_ref_prefix(part) not in ("R", "C"): continue @@ -814,14 +1330,19 @@ def _insert_satellites_into_row(node, chain, satellites, nets): row = list(chain) known = set(row) for sat in satellites: - insert_at = len(row) + insert_at = None for idx, cp in enumerate(row): for net in nets: con = set( node._net_connected_parts(net, allowed_parts=known | {sat}) ) if sat in con and cp in con: - insert_at = max(insert_at, idx + 1) + if insert_at is None: + insert_at = idx + 1 + else: + insert_at = min(insert_at, idx + 1) + if insert_at is None: + insert_at = len(row) row.insert(insert_at, sat) known.add(sat) return row @@ -873,7 +1394,7 @@ def apply_driver_rail_safe_placement( top_set = set(top_nets) bottom_set = set(bottom_nets) - satellites = _chain_row_satellite_parts(node, parts, chain_parts, nets) + satellites = _chain_row_satellite_parts(node, parts, chain_parts, nets, topology) row = _insert_satellites_into_row(node, chain, satellites, nets) row_parts = set(row) @@ -894,7 +1415,13 @@ def _nudge_y(part, target_cy): decoup_caps = _led_rail_decoupling_caps(parts, top_set, bottom_set, row_parts) for part in parts: - if part in row_parts or part is main_part or part in decoup_caps: + if ( + part in row_parts + or part is main_part + or part in decoup_caps + or part in topology.get("sense_feedback_parts", set()) + or part in topology.get("control_parts", set()) + ): continue h = _part_layout_h(part, grid) if _part_on_net_set(part, top_set) and not _part_on_net_set(part, bottom_set): @@ -902,6 +1429,18 @@ def _nudge_y(part, target_cy): elif _part_on_net_set(part, bottom_set) and not _part_on_net_set(part, top_set): _nudge_y(part, bottom_y - grid - h / 2) + sense_parts = sorted( + [p for p in topology.get("sense_feedback_parts", set()) if p not in chain_parts], + key=node._part_ref_key, + ) + if sense_parts: + main_vis = _layout_bbox(main_part) + if main_vis is None: + main_vis = main_part.place_bbox * main_part.tx + sense_x = main_vis.max.x + gap + sense_y = mid_y + gap + _place_parts_in_row(node, sense_parts, sense_x, sense_y, gap, grid) + # 控制支路:主控右侧中部,避免拉到顶/底 rail。 control_parts = sorted( [p for p in topology.get("control_parts", set()) if p not in chain_parts], @@ -911,8 +1450,9 @@ def _nudge_y(part, target_cy): main_vis = _layout_bbox(main_part) if main_vis is None: main_vis = main_part.place_bbox * main_part.tx - ctrl_x = main_vis.max.x + gap * 2 - ctrl_y = mid_y + gap + ctrl_width = _row_total_width(control_parts, gap, grid) + ctrl_x = main_vis.min.x - ctrl_width + ctrl_y = main_vis.max.y + gap * 2 _place_parts_in_row(node, control_parts, ctrl_x, ctrl_y, gap, grid) # LED+/LED- 去耦:贴在主控右侧、两 rail 之间竖排,避免甩到图纸底部。 @@ -973,6 +1513,12 @@ def restore_driver_wire_nets(node, nets=None, **options): if nets is None: nets = node.get_internal_nets() preserve = driver_wire_preserve_net_set(node, nets, **options) + if preserve: + _attach_debug_log( + options, + "restore_driver_wire_nets sheet=%s preserve=%s" + % (getattr(node, "name", "?"), [_net_label(net) for net in preserve]), + ) for net in preserve: net._stub = False for pin in net.pins: @@ -1315,6 +1861,7 @@ def apply_topology_or_trunk_layout( 互斥分支:generic_driver 仅 apply_generic_driver_layout,否则 trunk-aware。 结果写入 node._last_topology_result。 """ + node._schematic_debug_options = options trunk_map = classify_trunk_nets(node, parts, nets, roles, main_part, **options) topology = detect_known_topology( node, parts, nets, roles, main_part, trunk_map=trunk_map, **options diff --git a/src/skidl/schematics/trunk_layout.py b/src/skidl/schematics/trunk_layout.py index 18ea389a..b2aec1f5 100644 --- a/src/skidl/schematics/trunk_layout.py +++ b/src/skidl/schematics/trunk_layout.py @@ -8,6 +8,34 @@ from skidl.geometry import Point, Tx, Vector +_TOP_TRUNK_TOKENS = ( + "LED+", + "VIN", + "VCC", + "VDD", + "VBUS", + "VBAT", + "VSUP", + "24V", + "12V", + "9V", + "5V", + "3V3", + "3V", + "1V8", + "POWER", + "PWR", + "SUPPLY", + "B+", + "BAT+", + "IN+", + "DC_IN", + "W+", +) +_BOTTOM_TRUNK_TOKENS = ("LED-", "GND", "AGND", "DGND", "PGND", "VSS", "W-") +_RIGHT_TRUNK_TOKENS = ("OUT", "LOAD", "DRV", "SW") +_LEFT_TRUNK_TOKENS = ("IN", "SENSE", "FB", "ADC", "CTRL", "PWM", "DIM") + def build_part_adjacency(parts, nets): """根据 nets 构建 part 邻接图。""" @@ -22,6 +50,27 @@ def build_part_adjacency(parts, nets): return adjacency +def _matched_tokens(text, tokens): + if not text: + return [] + upper = str(text).upper() + matches = [] + for token in tokens: + token_u = str(token).upper() + if token_u in upper and token_u not in matches: + matches.append(token_u) + matches.sort(key=len, reverse=True) + return matches + + +def _trunk_debug_log(options, tag, message): + if not options.get("schematic_progress", False): + return + from skidl.logger import active_logger + + active_logger.info("[%s] %s" % (tag, message)) + + def _net_name_side_scores(net_name_u): """按网名给 top/bottom/left/right 打分;LED+/LED- 优先于泛化 LED token。""" scores = {"top": 0, "bottom": 0, "left": 0, "right": 0} @@ -31,21 +80,16 @@ def _net_name_side_scores(net_name_u): if "LED-" in net_name_u or net_name_u.endswith("/LED-"): scores["bottom"] += 12 - top_tokens = ("VCC", "VDD", "VIN", "VBUS", "24V", "12V", "5V", "3V3", "W+") - bottom_tokens = ("GND", "AGND", "DGND", "PGND", "VSS", "W-") - right_tokens = ("OUT", "LOAD", "DRV", "SW") - left_tokens = ("IN", "SENSE", "FB", "ADC", "CTRL", "PWM", "DIM") - - for token in top_tokens: + for token in _TOP_TRUNK_TOKENS: if token in net_name_u: scores["top"] += 3 - for token in bottom_tokens: + for token in _BOTTOM_TRUNK_TOKENS: if token in net_name_u: scores["bottom"] += 3 - for token in right_tokens: + for token in _RIGHT_TRUNK_TOKENS: if token in net_name_u: scores["right"] += 2 - for token in left_tokens: + for token in _LEFT_TRUNK_TOKENS: if token in net_name_u: scores["left"] += 2 @@ -56,6 +100,168 @@ def _net_name_side_scores(net_name_u): return scores +def _pin_names_on_part_for_net(main_part, net): + if main_part is None: + return [] + names = [] + for pin in getattr(net, "pins", []): + if getattr(pin, "part", None) is main_part: + names.append(str(getattr(pin, "name", "") or "").upper()) + return names + + +def _is_anonymous_net_name(net_name): + text = str(net_name or "").strip().upper() + return text.startswith("NET-(") or text.startswith("NET_(") + + +def allow_anonymous_input_rail_promotion(net_name, direction, categories, reasons, fanout): + """ + Allow only strongly evidenced anonymous input nets to participate as top rails. + + This stays intentionally conservative: + - anonymous control/switch/sense nets are not promoted + - only top/input-like candidates are considered + - promotion requires multiple topology hints, not just one token + """ + if not _is_anonymous_net_name(net_name): + return False + categories = {str(cat).lower() for cat in (categories or [])} + reasons = list(reasons or []) + if direction != "top" or "input" not in categories: + return False + if categories.intersection({"control", "switch", "sense"}): + return False + + evidence_score = 0 + has_main_input_pin = any(reason.startswith("main_pin:") for reason in reasons) + if has_main_input_pin: + evidence_score += 3 + if "semantic:input" in reasons: + evidence_score += 2 + if "connected_to_ic" in reasons: + evidence_score += 1 + if "input_cap" in reasons: + evidence_score += 1 + if "input_connector" in reasons: + evidence_score += 1 + if "input_diode" in reasons: + evidence_score += 1 + if fanout >= 4 or "fanout>=4" in reasons: + evidence_score += 2 + elif fanout >= 3 or "fanout>=3" in reasons: + evidence_score += 1 + + strong_context = sum( + 1 + for reason in ("input_cap", "input_connector", "input_diode", "connected_to_ic") + if reason in reasons + ) + return has_main_input_pin and evidence_score >= 6 and strong_context >= 2 + + +def _score_trunk_candidate(node, net, net_parts, roles, main_part): + net_name = str(getattr(net, "name", "") or "") + net_name_u = net_name.upper() + side_score = _net_name_side_scores(net_name_u) + reasons = [] + token_hits = [] + categories = set() + + for side, tokens in ( + ("top", _TOP_TRUNK_TOKENS), + ("bottom", _BOTTOM_TRUNK_TOKENS), + ("right", _RIGHT_TRUNK_TOKENS), + ("left", _LEFT_TRUNK_TOKENS), + ): + hits = _matched_tokens(net_name_u, tokens) + if hits: + token_hits.extend("%s:%s" % (side, token) for token in hits) + reasons.extend("token:%s" % token for token in hits) + + pin_names = _pin_names_on_part_for_net(main_part, net) + pin_top_hits = [] + pin_bottom_hits = [] + pin_left_hits = [] + pin_right_hits = [] + for pname in pin_names: + pin_top_hits.extend(_matched_tokens(pname, _TOP_TRUNK_TOKENS)) + pin_bottom_hits.extend(_matched_tokens(pname, _BOTTOM_TRUNK_TOKENS)) + pin_left_hits.extend(_matched_tokens(pname, _LEFT_TRUNK_TOKENS)) + pin_right_hits.extend(_matched_tokens(pname, _RIGHT_TRUNK_TOKENS)) + + if pin_top_hits: + side_score["top"] += 4 + reasons.append("main_pin:%s" % pin_top_hits[0]) + categories.add("input") + if pin_bottom_hits: + side_score["bottom"] += 4 + reasons.append("main_pin:%s" % pin_bottom_hits[0]) + categories.add("ground") + if pin_left_hits: + side_score["left"] += 2 + reasons.append("main_pin:%s" % pin_left_hits[0]) + categories.add("control") + if pin_right_hits: + side_score["right"] += 2 + reasons.append("main_pin:%s" % pin_right_hits[0]) + categories.add("switch") + + fanout = len(net_parts) + if fanout >= 4: + reasons.append("fanout>=4") + elif fanout >= 3: + reasons.append("fanout>=3") + elif fanout >= 2: + reasons.append("fanout>=2") + + refs = [str(getattr(part, "ref", "") or "").upper() for part in net_parts] + if side_score["top"] > 0: + categories.add("input") + if any(ref.startswith("C") for ref in refs): + side_score["top"] += 2 + reasons.append("input_cap") + if any(ref.startswith(("J", "P", "CN")) for ref in refs): + side_score["top"] += 1 + reasons.append("input_connector") + if any(ref.startswith("D") for ref in refs): + side_score["top"] += 1 + reasons.append("input_diode") + if any(ref.startswith("L") for ref in refs): + side_score["top"] += 1 + reasons.append("inductor_near") + if side_score["bottom"] > 0 and main_part in net_parts: + categories.add("ground") + side_score["bottom"] += 1 + reasons.append("connected_to_ic") + + if main_part in net_parts: + for side in side_score: + if side_score[side] > 0: + side_score[side] += 2 if side in ("top", "bottom") else 1 + reasons.append("connected_to_ic") + + if node._is_power_net_name(net_name_u): + if any(t in net_name_u for t in ("GND", "VSS", "W-", "LED-")): + side_score["bottom"] += 3 + reasons.append("power_net_name:bottom") + categories.add("ground") + else: + side_score["top"] += 3 + reasons.append("power_net_name:top") + categories.add("input") + + return { + "net": net, + "name": net_name_u, + "side_score": side_score, + "fanout": fanout, + "reasons": reasons, + "token_hits": token_hits, + "categories": sorted(categories), + } + + def is_trunk_net_name(name): """网名是否像电源/地/LED 主干(供 route 排序与简化使用)。""" if not name: @@ -84,19 +290,22 @@ def classify_trunk_nets(node, parts, nets, roles, main_part, **options): part_set = set(parts) side_candidates = {"top": [], "bottom": [], "left": [], "right": []} + debug_meta = {} for net in nets: net_name = str(getattr(net, "name", "") or "") net_name_u = net_name.upper() - is_named = bool(net_name) and not net_name_u.startswith("NET-(") + is_named = bool(net_name) and not _is_anonymous_net_name(net_name_u) net_parts = node._net_connected_parts(net, allowed_parts=part_set) fanout = len(net_parts) if fanout < 2: continue - side_score = _net_name_side_scores(net_name_u) + meta = _score_trunk_candidate(node, net, net_parts, roles, main_part) + side_score = meta["side_score"] has_strong_token = max(side_score.values()) >= 3 + debug_meta[net_name_u] = meta if ( fanout <= 3 @@ -123,18 +332,63 @@ def classify_trunk_nets(node, parts, nets, roles, main_part, **options): + (2 if is_named else 0) ) - if node._is_power_net_name(net_name_u): - if any(t in net_name_u for t in ("GND", "VSS", "W-", "LED-")): - side_score["bottom"] += 3 - else: - side_score["top"] += 2 - best_side = max(side_score, key=side_score.get) if side_score[best_side] <= 0: + meta["rejected_reason"] = "non_positive_side_score" + continue + + anonymous_promoted = allow_anonymous_input_rail_promotion( + net_name_u, + best_side, + meta.get("categories", []), + meta.get("reasons", []), + fanout, + ) + if not is_named and not anonymous_promoted: + meta["rejected_reason"] = "anonymous_or_blank" continue total_score = base_score + side_score[best_side] + meta["score"] = total_score + meta["best_side"] = best_side + meta["rejected_reason"] = "" + _trunk_debug_log( + options, + "rail_candidate", + "net=%s fanout=%s score=%s matched_tokens=%s selected_direction=%s rejected_reason=%s reasons=%s" + % ( + net_name_u or "", + meta.get("fanout", 0), + total_score, + meta.get("token_hits", []), + best_side, + meta.get("rejected_reason"), + meta.get("reasons", []), + ), + ) + if anonymous_promoted: + _trunk_debug_log( + options, + "rail_promoted", + "net=%s semantic=input reasons=%s" + % (net_name_u or "", meta.get("reasons", [])), + ) + _trunk_debug_log( + options, + "rail_score", + "net=%s top=%s bottom=%s left=%s right=%s fanout=%s token_matched=%s" + % ( + net_name_u or "", + side_score["top"], + side_score["bottom"], + side_score["left"], + side_score["right"], + fanout, + meta.get("token_hits", []), + ), + ) if total_score < options.get("trunk_score_threshold", 6): + meta["rejected_reason"] = "below_trunk_score_threshold" continue side_candidates[best_side].append((total_score, net)) @@ -145,6 +399,39 @@ def classify_trunk_nets(node, parts, nets, roles, main_part, **options): candidates.sort(key=lambda item: item[0], reverse=True) trunk_map[side] = [net for _, net in candidates[:max_per_side]] + node._trunk_candidate_debug = debug_meta + for net_name, meta in debug_meta.items(): + if meta.get("rejected_reason"): + _trunk_debug_log( + options, + "rail_rejected", + "net=%s fanout=%s score=%s matched_tokens=%s selected_direction=%s rejected_reason=%s" + % ( + net_name or "", + meta.get("fanout", 0), + meta.get("score", 0), + meta.get("token_hits", []), + meta.get("best_side"), + meta.get("rejected_reason"), + ), + ) + for side, selected in trunk_map.items(): + for net in selected: + meta = debug_meta.get(str(getattr(net, "name", "") or "").upper(), {}) + _trunk_debug_log( + options, + "rail_selected", + "net=%s score=%s reasons=%s token_matched=%s fanout=%s final_direction=%s" + % ( + str(getattr(net, "name", "") or "").upper() or "", + meta.get("score", 0), + meta.get("reasons", []), + meta.get("token_hits", []), + meta.get("fanout", 0), + side, + ), + ) + return trunk_map diff --git a/src/skidl/tools/kicad9/gen_schematic.py b/src/skidl/tools/kicad9/gen_schematic.py index 2708ef61..55ac0876 100644 --- a/src/skidl/tools/kicad9/gen_schematic.py +++ b/src/skidl/tools/kicad9/gen_schematic.py @@ -27,6 +27,111 @@ __all__ = [] +def _attach_debug_enabled(options=None): + if options and options.get("schematic_attach_debug") is not None: + return bool(options.get("schematic_attach_debug")) + value = str(os.environ.get("SKIDL_SCH_DEBUG_ATTACH", "") or "").strip().lower() + return value not in ("", "0", "false", "no", "off") + + +def _attach_debug_log(options, message): + if not _attach_debug_enabled(options): + return + from skidl.logger import active_logger + + active_logger.info("[attach-debug] %s" % message) + + +def _segment_coords(seg): + return ((int(seg.p1.x), int(seg.p1.y)), (int(seg.p2.x), int(seg.p2.y))) + + +def _wire_signature(net, seg): + name = str(getattr(net, "name", "") or "") + return (name,) + _segment_coords(seg) + + +def _wire_count_summary(wires): + return {str(getattr(net, "name", "") or ""): len(segs) for net, segs in sorted( + wires.items(), key=lambda item: str(getattr(item[0], "name", "") or "") + )} + + +def _log_attach_export_state(node, options): + if not _attach_debug_enabled(options): + return + + def _subtree_wire_total(current): + total = sum(len(wire) for wire in getattr(current, "wires", {}).values()) + for child in getattr(current, "children", {}).values(): + total += _subtree_wire_total(child) + return total + + def _walk(current, stage): + total_wires = sum(len(wire) for wire in current.wires.values()) + _attach_debug_log( + options, + "%s node_id=%s sheet=%s node_wires_id=%s export_wires_id=%s total_nets=%d total_wires=%d subtree_total_wires=%d child_count=%d flattened=%s per_net=%s" + % ( + stage, + id(current), + getattr(current, "name", "?"), + id(current.wires), + id(current.wires), + len(current.wires), + total_wires, + _subtree_wire_total(current), + len(getattr(current, "children", {})), + getattr(current, "flattened", False), + _wire_count_summary(current.wires), + ), + ) + for child_name, child in sorted( + getattr(current, "children", {}).items(), + key=lambda item: str(item[0]), + ): + _attach_debug_log( + options, + "%s child-link parent_id=%s parent_sheet=%s child_name=%s child_id=%s child_sheet=%s child_wires_id=%s" + % ( + stage, + id(current), + getattr(current, "name", "?"), + child_name, + id(child), + getattr(child, "name", "?"), + id(getattr(child, "wires", {})), + ), + ) + _walk(child, stage) + + _walk(node, "export preflight") + + repair_info = getattr(node, "_attach_debug_last_repair", None) or {} + current_signatures = { + _wire_signature(net, seg) + for net, wire in node.wires.items() + for seg in wire + } + expected = set(repair_info.get("added_stub_signatures", [])) + if expected: + present = expected & current_signatures + missing = expected - current_signatures + _attach_debug_log( + options, + "export stub_presence present=%d missing=%d" + % (len(present), len(missing)), + ) + for item in repair_info.get("added_stub_details", []): + _attach_debug_log( + options, + "export stub_expected net=%s seg=%s" + % (item["net"], item["coords"]), + ) + for signature in sorted(missing): + _attach_debug_log(options, "export stub_missing %s" % (signature,)) + + def _setup_kicad_env(): """Set KiCad footprint directory if not already set. @@ -363,8 +468,15 @@ def _handle_fallback(circuit, tool_module, filepath, top_name, title, flatness, node = SchNode(circuit, tool_module, filepath, top_name, title, flatness) node.place(expansion_factor=1.0, **options) node.route(**options) + _log_attach_export_state(node, options) output_file = write_top_schematic( - circuit, node, filepath, top_name, title, version=20250114 + circuit, + node, + filepath, + top_name, + title, + version=20250114, + flatten=bool(options.get("flatten", False) or options.get("export_flat", False)), ) finalize_parts_and_nets(circuit, **options) @@ -529,6 +641,7 @@ def gen_schematic( top_name=get_script_name(), title="SKiDL-Generated Schematic", flatness=0.0, + flatten=False, retries=2, spacing=0.8, compactness=0.2, @@ -546,6 +659,8 @@ def gen_schematic( title (str, optional): The title of the schematic. Defaults to "SKiDL-Generated Schematic". flatness (float, optional): Determines how much the hierarchy is flattened in the schematic. Defaults to 0.0 (completely hierarchical). Use 1.0 to flatten everything into one sheet. + flatten (bool, optional): If True, export one standalone schematic page + instead of a hierarchy wrapper with child sheet files. Defaults to False. retries (int, optional): Number of times to re-try if routing fails. Defaults to 2. spacing (float, optional): Global layout spacing factor (0.5–3.0). Values >1.0 produce a looser layout with more whitespace between parts; <1.0 produces @@ -640,6 +755,8 @@ def power_supply(vin, vout, gnd): options["prefer_straight"] = bool(prefer_straight) options["bend_penalty"] = bend_penalty options["route_length_weight"] = route_length_weight + options["flatten"] = bool(flatten) + options["export_flat"] = bool(options.get("export_flat", False) or flatten) options.setdefault("reuse_junctions", prefer_straight) # 美观优先策略默认开启 human_readable;显式传 False 可回退旧版随机布局。 options.setdefault("human_readable", True) @@ -711,8 +828,15 @@ def power_supply(vin, vout, gnd): continue # Generate S-expression schematic using the KiCad 9 schema version. + _log_attach_export_state(node, options) output_file = write_top_schematic( - circuit, node, filepath, top_name, title, version=20250114 + circuit, + node, + filepath, + top_name, + title, + version=20250114, + flatten=options["export_flat"], ) active_logger.info(f"Schematic written to {output_file}") @@ -761,7 +885,13 @@ def power_supply(vin, vout, gnd): _classify_and_stub_complex_nets(circuit, node, **options) node.route(**options) output_file = write_top_schematic( - circuit, node, filepath, top_name, title, version=20250114 + circuit, + node, + filepath, + top_name, + title, + version=20250114, + flatten=options["export_flat"], ) finalize_parts_and_nets(circuit, **options) erc_regen_ok = True diff --git a/tests/unit_tests/test_topology_generic_driver.py b/tests/unit_tests/test_topology_generic_driver.py index b8cd25f2..4e1dd568 100644 --- a/tests/unit_tests/test_topology_generic_driver.py +++ b/tests/unit_tests/test_topology_generic_driver.py @@ -2,8 +2,20 @@ """generic driver topology 检测与日志格式单元测试。""" -from skidl.geometry import BBox, Point, Tx +from collections import defaultdict + +from skidl.geometry import BBox, Point, Segment, Tx +from skidl.schematics.route import ( + Router, + _prune_linear_endpoint_tails, + _prune_driver_preroute_tails, + is_pin_attached, + repair_unattached_same_net_pins, + route_driver_rails, +) +from skidl.schematics.trunk_layout import classify_trunk_nets from skidl.schematics.topology import ( + _build_driver_chain_order, _collect_driver_rail_nets, _disabled_topology, _is_anonymous_net, @@ -11,15 +23,18 @@ _token_in_text, build_driver_rail_plan, detect_known_topology, + driver_wire_preserve_net_set, format_topology_log_line, ) class _FakePin: - def __init__(self, name, part=None): + def __init__(self, name, part=None, pt=None): self.name = name self.part = part self.net = None + self.pt = pt or Point(0, 0) + self.stub = False def is_connected(self): return self.net is not None @@ -42,6 +57,13 @@ def __init__(self, ref, value="", pins=None): class _FakeNode: + def __init__(self): + self.parts = [] + self.wires = {} + self.junctions = defaultdict(list) + self.children = {} + self._driver_chain_parts = set() + def _net_connected_parts(self, net, allowed_parts=None): return [p for p in getattr(net, "_parts", []) if allowed_parts is None or p in allowed_parts] @@ -55,6 +77,15 @@ def _net_names_of(self, part): def _is_power_net_name(self, name): return "GND" in str(name).upper() or "VCC" in str(name).upper() + def _is_local_functional_cluster(self, net, net_parts): + return False + + def get_internal_pins(self, net): + return [pin for pin in net.pins if pin.part in self.parts and not pin.stub] + + def _part_ref_key(self, part): + return str(getattr(part, "ref", "") or "") + def _wire(part, pin_name, net): pin = next(p for p in part.pins if p.name == pin_name) @@ -218,3 +249,998 @@ def test_collect_driver_rail_nets_excludes_control_and_anonymous(): assert pwm in control assert anon not in top and anon not in bottom assert _is_anonymous_net(anon) + + +def test_collect_driver_rail_nets_promotes_input_power_tokens_to_top(): + node = _FakeNode() + u2 = _FakePart("U2", pins=[_FakePin("VIN"), _FakePin("GND")]) + c1 = _FakePart("C1", pins=[_FakePin("1")]) + j1 = _FakePart("J1", pins=[_FakePin("1")]) + for part in (u2, c1, j1): + for pin in part.pins: + pin.part = part + + topology = { + "control_nets": [], + "switch_or_drive_nets": [], + "ground_nets": [], + "input_nets": [], + "power_nets": [], + "output_nets": [], + } + vin = _FakeNet("DC_IN_24V") + gnd = _FakeNet("GND") + _wire(u2, "VIN", vin) + _wire(c1, "1", vin) + _wire(j1, "1", vin) + _wire(u2, "GND", gnd) + + top, bottom, control = _collect_driver_rail_nets( + [vin, gnd], topology, node, u2, {u2, c1, j1} + ) + + assert vin in top + assert gnd in bottom + assert control == [] + assert topology["rail_debug"]["DC_IN_24V"]["direction"] == "top" + assert "token:DC_IN" in topology["rail_debug"]["DC_IN_24V"]["reasons"] + + +def test_collect_driver_rail_nets_promotes_anonymous_input_with_strong_topology_evidence(): + node = _FakeNode() + u2 = _FakePart("U2", pins=[_FakePin("VIN"), _FakePin("IN"), _FakePin("DIM")]) + c1 = _FakePart("C1", pins=[_FakePin("1")]) + d1 = _FakePart("D1", pins=[_FakePin("K"), _FakePin("A")]) + l1 = _FakePart("L1", pins=[_FakePin("1")]) + for part in (u2, c1, d1, l1): + for pin in part.pins: + pin.part = part + + topology = { + "control_nets": [], + "switch_or_drive_nets": [], + "ground_nets": [], + "input_nets": [], + "power_nets": [], + "output_nets": [], + } + anon_input = _FakeNet("NET-(D1-K)") + anon_control = _FakeNet("NET-(U2-DIM)") + _wire(u2, "VIN", anon_input) + _wire(u2, "IN", anon_input) + _wire(c1, "1", anon_input) + _wire(d1, "K", anon_input) + _wire(l1, "1", anon_input) + _wire(u2, "DIM", anon_control) + + top, bottom, control = _collect_driver_rail_nets( + [anon_input, anon_control], topology, node, u2, {u2, c1, d1, l1} + ) + + assert anon_input in top + assert anon_control not in top + assert anon_control not in bottom + assert anon_control not in control + assert bottom == [] + assert topology["rail_debug"]["NET-(D1-K)"]["selected_direction"] == "top" + assert topology["rail_debug"]["NET-(D1-K)"]["rejected_reason"] == "" + assert "main_pin:VIN" in topology["rail_debug"]["NET-(D1-K)"]["reasons"] + assert "input_cap" in topology["rail_debug"]["NET-(D1-K)"]["reasons"] + assert "input_diode" in topology["rail_debug"]["NET-(D1-K)"]["reasons"] + assert topology["rail_debug"]["NET-(U2-DIM)"]["rejected_reason"] == "anonymous_or_blank" + + +def _build_tg032_like_driver_graph(): + node = _FakeNode() + u2 = _FakePart( + "U2", + "PT4115", + pins=[ + _FakePin("SW"), + _FakePin("GND"), + _FakePin("DIM"), + _FakePin("LED"), + _FakePin("CSN"), + _FakePin("VIN"), + ], + ) + d1 = _FakePart("D1", pins=[_FakePin("A"), _FakePin("K")]) + l1 = _FakePart("L1", "68uH", pins=[_FakePin("1"), _FakePin("2")]) + p3 = _FakePart("P3", "LED Output", pins=[_FakePin("1"), _FakePin("2")]) + r1 = _FakePart("R1", "0.43R", pins=[_FakePin("1"), _FakePin("2")]) + r3 = _FakePart("R3", "3.3K", pins=[_FakePin("1"), _FakePin("2")]) + r4 = _FakePart("R4", "4.7K", pins=[_FakePin("1"), _FakePin("2")]) + c1 = _FakePart("C1", "47uF", pins=[_FakePin("1")]) + for part in (u2, d1, l1, p3, r1, r3, r4, c1): + for pin in part.pins: + pin.part = part + + led_p = _FakeNet("/LED+") + led_n = _FakeNet("/LED-") + pwm = _FakeNet("/PWM") + gnd = _FakeNet("GND") + d1_a = _FakeNet("Net-(D1-A)") + d1_k = _FakeNet("Net-(D1-K)") + u2_dim = _FakeNet("Net-(U2-DIM)") + + _wire(u2, "LED", led_p) + _wire(p3, "1", led_p) + _wire(r1, "2", led_p) + _wire(l1, "1", led_n) + _wire(p3, "2", led_n) + _wire(r3, "1", pwm) + _wire(r4, "2", gnd) + _wire(u2, "GND", gnd) + _wire(u2, "SW", d1_a) + _wire(d1, "A", d1_a) + _wire(l1, "2", d1_a) + _wire(u2, "CSN", d1_k) + _wire(u2, "VIN", d1_k) + _wire(d1, "K", d1_k) + _wire(r1, "1", d1_k) + _wire(c1, "1", d1_k) + _wire(r3, "2", u2_dim) + _wire(r4, "1", u2_dim) + _wire(u2, "DIM", u2_dim) + + parts = [u2, d1, l1, p3, r1, r3, r4, c1] + nets = [led_p, led_n, pwm, gnd, d1_a, d1_k, u2_dim] + roles = { + u2: "ic", + d1: "passive", + l1: "passive", + p3: "connector", + r1: "passive", + r3: "passive", + r4: "passive", + c1: "decoupling", + } + return node, parts, nets, roles, u2, l1, p3, r1, r3, r4, d1_k + + +def test_detect_known_topology_groups_tg032_parts_conservatively(): + node, parts, nets, roles, u2, l1, p3, r1, r3, r4, d1_k = _build_tg032_like_driver_graph() + + topo = detect_known_topology( + node, + parts, + nets, + roles, + u2, + human_readable=True, + ) + + assert topo["kind"] == "generic_driver" + assert d1_k in topo["input_nets"] + assert l1 in topo["power_loop_parts"] + assert r1 in topo["sense_feedback_parts"] + assert r3 in topo["control_parts"] + assert r4 in topo["control_parts"] + + +def test_build_driver_chain_order_keeps_inductor_between_ic_and_output(): + node, parts, nets, roles, u2, l1, p3, r1, r3, r4, _d1_k = _build_tg032_like_driver_graph() + topo = detect_known_topology( + node, + parts, + nets, + roles, + u2, + human_readable=True, + ) + + chain, chain_parts = _build_driver_chain_order(node, roles, topo, u2) + + assert l1 in chain_parts + assert chain.index(u2) < chain.index(l1) < chain.index(p3) + + +def test_classify_trunk_nets_keeps_led_rails_and_adds_input_power_top(): + node = _FakeNode() + u2 = _FakePart( + "U2", + "PT4115", + pins=[_FakePin("VIN"), _FakePin("GND"), _FakePin("OUT"), _FakePin("LEDN")], + ) + c1 = _FakePart("C1", pins=[_FakePin("1")]) + j1 = _FakePart("J1", pins=[_FakePin("1")]) + d1 = _FakePart("D1", pins=[_FakePin("1")]) + led_conn = _FakePart("J2", pins=[_FakePin("1"), _FakePin("2")]) + for part in (u2, c1, j1, d1, led_conn): + for pin in part.pins: + pin.part = part + + vin = _FakeNet("DC_IN_24V") + gnd = _FakeNet("GND") + led_p = _FakeNet("/LED+") + led_n = _FakeNet("/LED-") + _wire(u2, "VIN", vin) + _wire(c1, "1", vin) + _wire(j1, "1", vin) + _wire(d1, "1", vin) + _wire(u2, "GND", gnd) + _wire(led_conn, "2", gnd) + _wire(u2, "OUT", led_p) + _wire(led_conn, "1", led_p) + _wire(u2, "LEDN", led_n) + + roles = { + u2: "ic", + c1: "decoupling", + j1: "connector", + d1: "passive", + led_conn: "connector", + } + trunk = classify_trunk_nets( + node, + [u2, c1, j1, d1, led_conn], + [vin, gnd, led_p, led_n], + roles, + u2, + ) + + assert vin in trunk["top"] + assert led_p in trunk["top"] + assert gnd in trunk["bottom"] + assert node._trunk_candidate_debug["DC_IN_24V"]["best_side"] == "top" + assert "main_pin:VIN" in node._trunk_candidate_debug["DC_IN_24V"]["reasons"] + + +def test_classify_trunk_nets_promotes_only_strong_anonymous_input_rails(): + node = _FakeNode() + u2 = _FakePart( + "U2", + "PT4115", + pins=[_FakePin("VIN"), _FakePin("IN"), _FakePin("SW"), _FakePin("DIM")], + ) + c1 = _FakePart("C1", pins=[_FakePin("1")]) + d1 = _FakePart("D1", pins=[_FakePin("K"), _FakePin("A")]) + j1 = _FakePart("J1", pins=[_FakePin("1")]) + l1 = _FakePart("L1", pins=[_FakePin("1")]) + for part in (u2, c1, d1, j1, l1): + for pin in part.pins: + pin.part = part + + anon_input = _FakeNet("NET-(D1-K)") + anon_switch = _FakeNet("NET-(D1-A)") + anon_control = _FakeNet("NET-(U2-DIM)") + _wire(u2, "VIN", anon_input) + _wire(u2, "IN", anon_input) + _wire(c1, "1", anon_input) + _wire(d1, "K", anon_input) + _wire(j1, "1", anon_input) + _wire(l1, "1", anon_input) + _wire(u2, "SW", anon_switch) + _wire(d1, "A", anon_switch) + _wire(u2, "DIM", anon_control) + + roles = { + u2: "ic", + c1: "decoupling", + d1: "passive", + j1: "connector", + l1: "passive", + } + trunk = classify_trunk_nets( + node, + [u2, c1, d1, j1, l1], + [anon_input, anon_switch, anon_control], + roles, + u2, + ) + + if anon_input in trunk["top"]: + assert node._trunk_candidate_debug["NET-(D1-K)"]["best_side"] == "top" + else: + assert node._trunk_candidate_debug["NET-(D1-K)"]["rejected_reason"] == "anonymous_or_blank" + assert anon_switch not in trunk["top"] + assert anon_switch not in trunk["bottom"] + assert anon_control not in trunk["top"] + assert node._trunk_candidate_debug["NET-(D1-A)"]["rejected_reason"] == "anonymous_or_blank" + assert node._trunk_candidate_debug.get("NET-(U2-DIM)", {}).get("rejected_reason", "anonymous_or_blank") == "anonymous_or_blank" + + +def test_build_driver_rail_plan_adds_per_net_span_from_connected_pins(): + grid = 100 + node = _FakeNode() + u2 = _FakePart("U2", pins=[_FakePin("VIN", pt=Point(0, 0))]) + c1 = _FakePart("C1", pins=[_FakePin("1", pt=Point(0, 0))]) + far = _FakePart("J9") + for part, center_x in ((u2, 0), (c1, 800), (far, 5000)): + part.tx = Tx(dx=center_x, dy=0) + part.lbl_bbox = BBox(Point(-100, -100), Point(100, 100)) + part.place_bbox = BBox(Point(-200, -200), Point(200, 200)) + for pin in u2.pins: + pin.part = u2 + for pin in c1.pins: + pin.part = c1 + node.parts = [u2, c1, far] + + vin = _FakeNet("VCC") + _wire(u2, "VIN", vin) + _wire(c1, "1", vin) + + topology = { + "kind": "generic_driver", + "fallback": False, + "control_nets": [], + "switch_or_drive_nets": [], + "ground_nets": [], + "input_nets": [vin], + "power_nets": [vin], + "output_nets": [], + } + + plan = build_driver_rail_plan( + node, + node.parts, + [vin], + topology, + u2, + human_readable=True, + driver_rail_routing=True, + grid=grid, + ) + + assert plan["enabled"] + assert plan["x_max"] >= 5000 + assert plan["rail_spans"][vin] == (-100, 900) + + +def test_route_driver_rails_trims_span_but_still_covers_all_connected_pins(): + grid = 100 + node = _FakeNode() + u2 = _FakePart("U2", pins=[_FakePin("VIN", pt=Point(0, 100))]) + c1 = _FakePart("C1", pins=[_FakePin("1", pt=Point(0, 0))]) + c2 = _FakePart("C2", pins=[_FakePin("1", pt=Point(0, 0))]) + far = _FakePart("J9") + for part, dx, dy in ((u2, 0, 0), (c1, 600, 0), (c2, 1200, 0), (far, 4800, 0)): + part.tx = Tx(dx=dx, dy=dy) + part.lbl_bbox = BBox(Point(-100, -100), Point(100, 100)) + part.place_bbox = BBox(Point(-300, -300), Point(300, 300)) + for pin in u2.pins: + pin.part = u2 + for pin in c1.pins: + pin.part = c1 + for pin in c2.pins: + pin.part = c2 + node.parts = [u2, c1, c2, far] + + vin = _FakeNet("VCC") + _wire(u2, "VIN", vin) + _wire(c1, "1", vin) + _wire(c2, "1", vin) + + node._last_topology_result = { + "kind": "generic_driver", + "fallback": False, + "main_part": u2, + "control_nets": [], + "switch_or_drive_nets": [], + "ground_nets": [], + "input_nets": [vin], + "power_nets": [vin], + "output_nets": [], + } + + handled = route_driver_rails( + node, + [vin], + human_readable=True, + driver_rail_routing=True, + grid=grid, + ) + + assert vin in handled + assert vin in node.wires + horiz = [seg for seg in node.wires[vin] if seg.p1.y == seg.p2.y] + vert = [seg for seg in node.wires[vin] if seg.p1.x == seg.p2.x] + assert len(horiz) == 1 + assert len(vert) == 3 + assert horiz[0].p1.x == -100 + assert horiz[0].p2.x == 1300 + assert horiz[0].p2.x < 4800 + assert {seg.p1.x for seg in vert} == {0, 600, 1200} + + +def test_prune_driver_preroute_tails_keeps_connections_and_one_grid_margin(): + grid = 100 + node = _FakeNode() + u2 = _FakePart("U2", pins=[_FakePin("VIN", pt=Point(0, 100))]) + l1 = _FakePart("L1", pins=[_FakePin("1", pt=Point(0, 100))]) + for part, dx in ((u2, 0), (l1, 600)): + part.tx = Tx(dx=dx, dy=0) + for pin in part.pins: + pin.part = part + node.parts = [u2, l1] + + vin = _FakeNet("VCC") + _wire(u2, "VIN", vin) + _wire(l1, "1", vin) + segs = [ + Segment(Point(-600, 0), Point(1600, 0)), + Segment(Point(0, 100), Point(0, 0)), + Segment(Point(600, 100), Point(600, 0)), + ] + + pruned = _prune_driver_preroute_tails(node, vin, segs, grid) + horiz = [seg for seg in pruned if seg.p1.y == seg.p2.y] + + assert len(horiz) == 1 + assert horiz[0].p1.x == -100 + assert horiz[0].p2.x == 700 + assert is_pin_attached(u2.pins[0], pruned) + assert is_pin_attached(l1.pins[0], pruned) + + +def test_prune_driver_preroute_tails_removes_dangling_endpoint_beyond_last_stub(): + grid = 100 + node = _FakeNode() + u2 = _FakePart("U2", pins=[_FakePin("VIN", pt=Point(0, 100))]) + c1 = _FakePart("C1", pins=[_FakePin("1", pt=Point(0, 100))]) + for part, dx in ((u2, 0), (c1, 800)): + part.tx = Tx(dx=dx, dy=0) + for pin in part.pins: + pin.part = part + node.parts = [u2, c1] + + vin = _FakeNet("VCC") + _wire(u2, "VIN", vin) + _wire(c1, "1", vin) + segs = [ + Segment(Point(0, 0), Point(1200, 0)), + Segment(Point(0, 100), Point(0, 0)), + Segment(Point(800, 100), Point(800, 0)), + ] + + pruned = _prune_driver_preroute_tails(node, vin, segs, grid) + + assert Segment(Point(0, 0), Point(900, 0)) in pruned + assert Segment(Point(800, 100), Point(800, 0)) in pruned + assert Segment(Point(0, 100), Point(0, 0)) in pruned + assert not any(seg == Segment(Point(0, 0), Point(1200, 0)) for seg in pruned) + + +def test_prune_driver_preroute_tails_keeps_real_junction_endpoint(): + grid = 100 + node = _FakeNode() + vin = _FakeNet("VCC") + segs = [ + Segment(Point(0, 0), Point(1000, 0)), + Segment(Point(1000, 0), Point(1000, 300)), + Segment(Point(400, -100), Point(400, 0)), + ] + node.junctions[vin].append(Point(1000, 0)) + + pruned = _prune_driver_preroute_tails(node, vin, segs, grid) + horiz = [seg for seg in pruned if seg.p1.y == seg.p2.y] + + assert len(horiz) == 1 + assert horiz[0].p1 == Point(300, 0) + assert horiz[0].p2 == Point(1000, 0) + assert Segment(Point(1000, 0), Point(1000, 300)) in pruned + + +def test_prune_linear_endpoint_tails_handles_trunk_net_without_touching_branch(): + grid = 100 + node = _FakeNode() + trunk = _FakeNet("/LED+") + segs = [ + Segment(Point(-200, 0), Point(1200, 0)), + Segment(Point(0, 100), Point(0, 0)), + Segment(Point(900, 0), Point(900, 300)), + ] + + pruned = _prune_linear_endpoint_tails(node, trunk, segs, grid) + horiz = [seg for seg in pruned if seg.p1.y == seg.p2.y] + + assert len(horiz) == 1 + assert horiz[0].p1 == Point(-100, 0) + assert horiz[0].p2 == Point(900, 0) + assert Segment(Point(900, 0), Point(900, 300)) in pruned + + +def test_driver_wire_preserve_net_set_keeps_prerouted_rails(): + grid = 100 + node = _FakeNode() + u2 = _FakePart("U2", pins=[_FakePin("VIN", pt=Point(0, 0))]) + c1 = _FakePart("C1", pins=[_FakePin("1", pt=Point(0, 0))]) + for part, dx in ((u2, 0), (c1, 500)): + part.tx = Tx(dx=dx, dy=0) + part.lbl_bbox = BBox(Point(-100, -100), Point(100, 100)) + part.place_bbox = BBox(Point(-200, -200), Point(200, 200)) + for pin in u2.pins: + pin.part = u2 + for pin in c1.pins: + pin.part = c1 + node.parts = [u2, c1] + + vin = _FakeNet("VCC") + _wire(u2, "VIN", vin) + _wire(c1, "1", vin) + + node._last_topology_result = { + "kind": "generic_driver", + "fallback": False, + "main_part": u2, + "control_nets": [], + "switch_or_drive_nets": [], + "ground_nets": [], + "input_nets": [vin], + "power_nets": [vin], + "output_nets": [], + } + node._driver_rail_plan = build_driver_rail_plan( + node, + node.parts, + [vin], + node._last_topology_result, + u2, + human_readable=True, + driver_rail_routing=True, + grid=grid, + ) + + preserve = driver_wire_preserve_net_set( + node, + [vin], + human_readable=True, + driver_rail_routing=True, + grid=grid, + ) + + assert vin in preserve + + +def test_repair_unattached_pin_adds_short_stub_to_same_net_rail(): + grid = 100 + node = _FakeNode() + r1 = _FakePart("R1", pins=[_FakePin("1", pt=Point(0, 0))]) + r1.tx = Tx(dx=200, dy=100) + r1.pins[0].part = r1 + node.parts = [r1] + + vin = _FakeNet("VCC") + _wire(r1, "1", vin) + node.wires[vin] = [Segment(Point(0, 0), Point(500, 0))] + + repaired = repair_unattached_same_net_pins(node, [vin], grid=grid) + + assert repaired == 1 + assert is_pin_attached(r1.pins[0], node.wires[vin]) + assert any( + seg.p1 == Point(200, 0) and seg.p2 == Point(200, 100) + for seg in node.wires[vin] + ) + + +def test_repair_unattached_pin_does_not_cross_attach_other_net(): + grid = 100 + node = _FakeNode() + c10 = _FakePart("C10", pins=[_FakePin("1", pt=Point(0, 0))]) + c10.tx = Tx(dx=200, dy=100) + c10.pins[0].part = c10 + node.parts = [c10] + + gnd = _FakeNet("GND") + other = _FakeNet("SW") + _wire(c10, "1", gnd) + node.wires[gnd] = [Segment(Point(0, 0), Point(500, 0))] + node.wires[other] = [Segment(Point(200, -100), Point(200, 100))] + + repaired = repair_unattached_same_net_pins(node, [gnd], grid=grid) + + assert repaired == 0 + assert not is_pin_attached(c10.pins[0], node.wires[gnd]) + assert len(node.wires[gnd]) == 1 + + +def test_repair_unattached_pin_does_not_duplicate_existing_attach(): + grid = 100 + node = _FakeNode() + l1 = _FakePart("L1", pins=[_FakePin("1", pt=Point(0, 0))]) + l1.tx = Tx(dx=300, dy=100) + l1.pins[0].part = l1 + node.parts = [l1] + + sw = _FakeNet("SW") + _wire(l1, "1", sw) + node.wires[sw] = [Segment(Point(300, 0), Point(300, 100))] + + repaired = repair_unattached_same_net_pins(node, [sw], grid=grid) + + assert repaired == 0 + assert len(node.wires[sw]) == 1 + + +def test_repair_unattached_pin_splits_same_net_wire_under_pin(): + grid = 100 + node = _FakeNode() + c1 = _FakePart("C1", pins=[_FakePin("1", pt=Point(0, 0))]) + c1.tx = Tx(dx=200, dy=0) + c1.pins[0].part = c1 + node.parts = [c1] + + led = _FakeNet("LED+") + _wire(c1, "1", led) + node.wires[led] = [Segment(Point(0, 0), Point(400, 0))] + + repaired = repair_unattached_same_net_pins(node, [led], grid=grid) + + assert repaired == 1 + assert is_pin_attached(c1.pins[0], node.wires[led]) + assert len(node.wires[led]) == 2 + assert any( + seg.p1 == Point(0, 0) and seg.p2 == Point(200, 0) for seg in node.wires[led] + ) + assert any( + seg.p1 == Point(200, 0) and seg.p2 == Point(400, 0) + for seg in node.wires[led] + ) + + +def test_passive_attach_prefers_collinear_trunk_axis(): + grid = 100 + node = _FakeNode() + r4 = _FakePart("R4", pins=[_FakePin("1", pt=Point(0, 0)), _FakePin("2", pt=Point(200, 0))]) + r4.tx = Tx(dx=200, dy=200) + for pin in r4.pins: + pin.part = r4 + node.parts = [r4] + + vin = _FakeNet("VCC") + _wire(r4, "1", vin) + node.wires[vin] = [ + Segment(Point(100, 0), Point(100, 100)), + Segment(Point(100, 100), Point(200, 100)), + ] + + repaired = repair_unattached_same_net_pins(node, [vin], grid=grid) + + assert repaired == 1 + assert is_pin_attached(r4.pins[0], node.wires[vin]) + assert any( + seg.p1 == Point(100, 0) and seg.p2 == Point(100, 200) + for seg in node.wires[vin] + ) + assert any( + seg.p1 == Point(100, 200) and seg.p2 == Point(200, 200) + for seg in node.wires[vin] + ) + assert not any( + seg.p1 == Point(200, 100) and seg.p2 == Point(200, 200) + for seg in node.wires[vin] + ) + + +def test_passive_collinear_attach_falls_back_when_extension_blocked(): + grid = 100 + node = _FakeNode() + r4 = _FakePart("R4", pins=[_FakePin("1", pt=Point(0, 0)), _FakePin("2", pt=Point(200, 0))]) + r4.tx = Tx(dx=200, dy=200) + for pin in r4.pins: + pin.part = r4 + node.parts = [r4] + + vin = _FakeNet("VCC") + other = _FakeNet("SW") + _wire(r4, "1", vin) + node.wires[vin] = [ + Segment(Point(100, 0), Point(100, 100)), + Segment(Point(100, 100), Point(200, 100)), + ] + node.wires[other] = [Segment(Point(0, 150), Point(150, 150))] + + repaired = repair_unattached_same_net_pins(node, [vin], grid=grid) + + assert repaired == 1 + assert is_pin_attached(r4.pins[0], node.wires[vin]) + assert any( + seg.p1 == Point(200, 100) and seg.p2 == Point(200, 200) + for seg in node.wires[vin] + ) + assert not any( + seg.p1 == Point(100, 0) and seg.p2 == Point(100, 200) + for seg in node.wires[vin] + ) + + +def test_passive_collinear_attach_does_not_break_capacitor_stub_repair(): + grid = 100 + node = _FakeNode() + c1 = _FakePart("C1", pins=[_FakePin("1", pt=Point(0, 0)), _FakePin("2", pt=Point(100, 0))]) + c1.tx = Tx(dx=200, dy=100) + for pin in c1.pins: + pin.part = c1 + node.parts = [c1] + + gnd = _FakeNet("GND") + _wire(c1, "1", gnd) + node.wires[gnd] = [Segment(Point(0, 0), Point(400, 0))] + + repaired = repair_unattached_same_net_pins(node, [gnd], grid=grid) + + assert repaired == 1 + assert is_pin_attached(c1.pins[0], node.wires[gnd]) + assert any( + seg.p1 == Point(200, 0) and seg.p2 == Point(200, 100) + for seg in node.wires[gnd] + ) + + +def test_non_passive_attach_keeps_existing_stub_strategy(): + grid = 100 + node = _FakeNode() + u3 = _FakePart("U3", pins=[_FakePin("IO", pt=Point(0, 0)), _FakePin("NC", pt=Point(200, 0))]) + u3.tx = Tx(dx=200, dy=200) + for pin in u3.pins: + pin.part = u3 + node.parts = [u3] + + sig = _FakeNet("SIG") + _wire(u3, "IO", sig) + node.wires[sig] = [ + Segment(Point(100, 0), Point(100, 100)), + Segment(Point(100, 100), Point(200, 100)), + ] + + repaired = repair_unattached_same_net_pins(node, [sig], grid=grid) + + assert repaired == 1 + assert is_pin_attached(u3.pins[0], node.wires[sig]) + assert any( + seg.p1 == Point(200, 100) and seg.p2 == Point(200, 200) + for seg in node.wires[sig] + ) + assert not any( + seg.p1 == Point(100, 0) and seg.p2 == Point(100, 200) + for seg in node.wires[sig] + ) + + +def test_passive_jog_cleanup_replaces_r3_style_l_stub_with_straight_stub(): + grid = 100 + node = _FakeNode() + r3 = _FakePart("R3", pins=[_FakePin("1", pt=Point(0, 0)), _FakePin("2", pt=Point(0, 200))]) + src = _FakePart("J1", pins=[_FakePin("1", pt=Point(0, 0))]) + for part, dx, dy in ((r3, 100, 100), (src, 0, 0)): + part.tx = Tx(dx=dx, dy=dy) + part.bbox = BBox(Point(-50, -50), Point(50, 250)) + for pin in part.pins: + pin.part = part + node.parts = [r3, src] + + pwm = _FakeNet("PWM") + _wire(r3, "1", pwm) + _wire(src, "1", pwm) + node.wires[pwm] = [ + Segment(Point(0, 0), Point(400, 0)), + Segment(Point(80, 0), Point(80, 50)), + Segment(Point(80, 50), Point(100, 50)), + Segment(Point(100, 50), Point(100, 100)), + ] + + repaired = repair_unattached_same_net_pins(node, [pwm], grid=grid) + + assert repaired == 0 + assert any( + seg.p1 == Point(100, 0) and seg.p2 == Point(100, 100) + for seg in node.wires[pwm] + ) + assert not any( + seg.p1 == Point(80, 50) and seg.p2 == Point(100, 50) + for seg in node.wires[pwm] + ) + assert not any( + seg.p1 == Point(80, 0) and seg.p2 == Point(80, 50) + for seg in node.wires[pwm] + ) + + +def test_passive_jog_cleanup_skips_when_straight_stub_would_hit_other_net(): + grid = 100 + node = _FakeNode() + r3 = _FakePart("R3", pins=[_FakePin("1", pt=Point(0, 0)), _FakePin("2", pt=Point(0, 200))]) + src = _FakePart("J1", pins=[_FakePin("1", pt=Point(0, 0))]) + for part, dx, dy in ((r3, 100, 100), (src, 0, 0)): + part.tx = Tx(dx=dx, dy=dy) + part.bbox = BBox(Point(-50, -50), Point(50, 250)) + for pin in part.pins: + pin.part = part + node.parts = [r3, src] + + pwm = _FakeNet("PWM") + sw = _FakeNet("SW") + _wire(r3, "1", pwm) + _wire(src, "1", pwm) + node.wires[pwm] = [ + Segment(Point(0, 0), Point(400, 0)), + Segment(Point(80, 0), Point(80, 50)), + Segment(Point(80, 50), Point(100, 50)), + Segment(Point(100, 50), Point(100, 100)), + ] + node.wires[sw] = [Segment(Point(100, 20), Point(100, 80))] + + repaired = repair_unattached_same_net_pins(node, [pwm], grid=grid) + + assert repaired == 0 + assert any( + seg.p1 == Point(80, 50) and seg.p2 == Point(100, 50) + for seg in node.wires[pwm] + ) + assert any( + seg.p1 == Point(100, 50) and seg.p2 == Point(100, 100) + for seg in node.wires[pwm] + ) + assert not any( + seg.p1 == Point(100, 0) and seg.p2 == Point(100, 100) + for seg in node.wires[pwm] + ) + + +def test_passive_jog_cleanup_keeps_existing_straight_capacitor_attach(): + grid = 100 + node = _FakeNode() + c1 = _FakePart("C1", pins=[_FakePin("1", pt=Point(0, 0)), _FakePin("2", pt=Point(100, 0))]) + src = _FakePart("J1", pins=[_FakePin("1", pt=Point(0, 0))]) + for part, dx, dy in ((c1, 200, 100), (src, 0, 0)): + part.tx = Tx(dx=dx, dy=dy) + part.bbox = BBox(Point(-50, -50), Point(150, 50)) + for pin in part.pins: + pin.part = part + node.parts = [c1, src] + + gnd = _FakeNet("GND") + _wire(c1, "1", gnd) + _wire(src, "1", gnd) + node.wires[gnd] = [ + Segment(Point(0, 0), Point(400, 0)), + Segment(Point(200, 0), Point(200, 100)), + ] + + repaired = repair_unattached_same_net_pins(node, [gnd], grid=grid) + + assert repaired == 0 + assert len(node.wires[gnd]) == 2 + assert any( + seg.p1 == Point(200, 0) and seg.p2 == Point(200, 100) + for seg in node.wires[gnd] + ) + + +def test_humanize_wires_removes_passive_attach_jog_for_attached_pin(): + node = _FakeNode() + node._route_options = {"human_readable": True} + r3 = _FakePart("R3", pins=[_FakePin("1", pt=Point(0, 0)), _FakePin("2", pt=Point(0, 200))]) + src = _FakePart("J1", pins=[_FakePin("1", pt=Point(0, 0))]) + for part, dx, dy in ((r3, 100, 100), (src, 0, 0)): + part.tx = Tx(dx=dx, dy=dy) + part.bbox = BBox(Point(-50, -50), Point(50, 250)) + for pin in part.pins: + pin.part = part + node.parts = [r3, src] + + pwm = _FakeNet("PWM") + _wire(r3, "1", pwm) + _wire(src, "1", pwm) + node.wires[pwm] = [ + Segment(Point(0, 0), Point(400, 0)), + Segment(Point(80, 0), Point(80, 50)), + Segment(Point(80, 50), Point(100, 50)), + Segment(Point(100, 50), Point(100, 100)), + ] + + Router.humanize_wires(node) + + assert any( + seg.p1 == Point(100, 0) and seg.p2 == Point(100, 100) + for seg in node.wires[pwm] + ) + assert not any( + seg.p1 == Point(80, 50) and seg.p2 == Point(100, 50) + for seg in node.wires[pwm] + ) + + +def test_humanize_wires_keeps_passive_attach_corner_when_it_is_a_real_branch(): + node = _FakeNode() + node._route_options = {"human_readable": True} + r3 = _FakePart("R3", pins=[_FakePin("1", pt=Point(0, 0)), _FakePin("2", pt=Point(0, 200))]) + src = _FakePart("J1", pins=[_FakePin("1", pt=Point(0, 0))]) + load = _FakePart("J2", pins=[_FakePin("1", pt=Point(0, 0))]) + for part, dx, dy in ((r3, 100, 100), (src, 0, 0), (load, 160, 50)): + part.tx = Tx(dx=dx, dy=dy) + part.bbox = BBox(Point(-50, -50), Point(50, 250)) + for pin in part.pins: + pin.part = part + node.parts = [r3, src, load] + + pwm = _FakeNet("PWM") + _wire(r3, "1", pwm) + _wire(src, "1", pwm) + _wire(load, "1", pwm) + node.wires[pwm] = [ + Segment(Point(0, 0), Point(400, 0)), + Segment(Point(80, 0), Point(80, 50)), + Segment(Point(80, 50), Point(100, 50)), + Segment(Point(100, 50), Point(100, 100)), + Segment(Point(100, 50), Point(160, 50)), + ] + + Router.humanize_wires(node) + + assert any( + seg.p1 == Point(80, 50) and seg.p2 == Point(100, 50) + for seg in node.wires[pwm] + ) + assert any( + seg.p1 == Point(100, 50) and seg.p2 == Point(160, 50) + for seg in node.wires[pwm] + ) + assert not any( + seg.p1 == Point(100, 0) and seg.p2 == Point(100, 100) + for seg in node.wires[pwm] + ) + + +def test_generic_driver_passive_pin_attach_repair_after_rail_preroute(): + grid = 100 + node = _FakeNode() + u2 = _FakePart("U2", pins=[_FakePin("VIN", pt=Point(0, 100))]) + l1 = _FakePart("L1", pins=[_FakePin("1", pt=Point(0, 0))]) + c10 = _FakePart("C10", pins=[_FakePin("1", pt=Point(0, 0))]) + for part, dx, dy in ((u2, 0, 0), (l1, 600, -100), (c10, 1200, -100)): + part.tx = Tx(dx=dx, dy=dy) + if part is u2: + part.lbl_bbox = BBox(Point(-100, -100), Point(100, 100)) + part.place_bbox = BBox(Point(-300, -300), Point(300, 300)) + else: + part.lbl_bbox = BBox(Point(-100, 0), Point(100, 0)) + part.place_bbox = BBox(Point(-150, 0), Point(150, 0)) + for pin in part.pins: + pin.part = part + node.parts = [u2, l1, c10] + + vin = _FakeNet("VCC") + _wire(u2, "VIN", vin) + _wire(l1, "1", vin) + _wire(c10, "1", vin) + node._last_topology_result = { + "kind": "generic_driver", + "fallback": False, + "main_part": u2, + "control_nets": [], + "switch_or_drive_nets": [], + "ground_nets": [], + "input_nets": [vin], + "power_nets": [vin], + "output_nets": [], + } + + handled = route_driver_rails( + node, + [vin], + human_readable=True, + driver_rail_routing=True, + grid=grid, + ) + assert vin in handled + + rail_y = min(seg.p1.y for seg in node.wires[vin] if seg.p1.y == seg.p2.y) + node.wires[vin] = [seg for seg in node.wires[vin] if seg.p1.y == seg.p2.y] + assert not is_pin_attached(l1.pins[0], node.wires[vin]) + assert not is_pin_attached(c10.pins[0], node.wires[vin]) + + repaired = repair_unattached_same_net_pins(node, [vin], grid=grid) + + assert repaired == 2 + assert is_pin_attached(l1.pins[0], node.wires[vin]) + assert is_pin_attached(c10.pins[0], node.wires[vin]) + assert any( + seg.p1 == Point(600, rail_y) and seg.p2 == Point(600, -100) + for seg in node.wires[vin] + ) + assert any( + seg.p1 == Point(1200, rail_y) and seg.p2 == Point(1200, -100) + for seg in node.wires[vin] + ) From 482a8ea8f012958452eed501f8abd2c07e10eed5 Mon Sep 17 00:00:00 2001 From: rhaingenix Date: Tue, 26 May 2026 22:25:39 +0800 Subject: [PATCH 16/16] Guard trunk routing for local nets --- src/skidl/schematics/route.py | 108 +++++++ src/skidl/tools/kicad9/sexp_schematic.py | 278 +++++++++++++++++- .../test_topology_generic_driver.py | 73 +++++ 3 files changed, 457 insertions(+), 2 deletions(-) diff --git a/src/skidl/schematics/route.py b/src/skidl/schematics/route.py index 7db29220..91e037ae 100644 --- a/src/skidl/schematics/route.py +++ b/src/skidl/schematics/route.py @@ -30,6 +30,108 @@ __all__ = ["RoutingFailure", "GlobalRoutingFailure", "SwitchboxRoutingFailure"] +_RAIL_LIKE_EXACT_NET_NAMES = { + "GND", + "VCC", + "VDD", + "VSS", + "VBAT", + "VIN", + "3V3", + "5V", + "+3V3", + "+5V", + "PWR", + "POWER", + "RAIL", +} + +_RAIL_LIKE_NAME_TOKENS = ( + "GND", + "VCC", + "VDD", + "VSS", + "VBAT", + "VIN", + "3V3", + "5V", + "PWR", + "POWER", + "SUPPLY", + "RAIL", +) + + +def _net_bbox_from_pins(pins): + bbox = BBox() + for pin in pins: + part = getattr(pin, "part", None) + pt = getattr(pin, "pt", None) + tx = getattr(part, "tx", None) + if part is None or pt is None or tx is None: + continue + bbox.add((pt * tx).round()) + return bbox + + +def _is_power_like_net(node, net, pins=None): + """Return True for nets that are explicitly named like rails/power.""" + name = str(getattr(net, "name", "") or "").strip().upper() + compact = name.replace(" ", "") + + if compact in _RAIL_LIKE_EXACT_NET_NAMES: + return True + if compact and any(token in compact for token in _RAIL_LIKE_NAME_TOKENS): + return True + + is_power_name = getattr(node, "_is_power_net_name", None) + if callable(is_power_name) and compact and is_power_name(compact): + return True + + return False + + +def _has_supply_symbol_participation(pins): + from skidl.schematics.place import is_net_terminal + + return any(is_net_terminal(getattr(pin, "part", None)) for pin in pins) + + +def _should_use_trunk_routing(node, net, pins, bbox=None, grid=None): + """Conservatively allow trunk routing only for clear rails/backbones.""" + pin_count = len(pins) + if pin_count < 2: + return False + + grid = int(grid or globals().get("GRID", 100)) + bbox = bbox or _net_bbox_from_pins(pins) + named_power = _is_power_like_net(node, net, pins) + has_supply_symbol = _has_supply_symbol_participation(getattr(net, "pins", pins)) + + if pin_count == 2: + return named_power + + if named_power: + return True + + connected_parts = { + getattr(pin, "part", None) + for pin in pins + if getattr(pin, "part", None) in getattr(node, "parts", []) + } + fanout = len(connected_parts) or pin_count + dominant_span = max(getattr(bbox, "w", 0), getattr(bbox, "h", 0)) + broad_span = getattr(bbox, "w", 0) >= grid * 8 or getattr(bbox, "h", 0) >= grid * 8 + + if pin_count <= 3: + return has_supply_symbol and dominant_span >= grid * 12 + + if has_supply_symbol and dominant_span >= grid * 8: + return True + + return fanout >= 4 and broad_span and dominant_span >= grid * 10 + + def _build_route_part_obstacles(node, human_readable=False): """构建布线障碍 bbox;human_readable 下主控 IC 外扩 keepout。""" grid = globals().get("GRID", 100) @@ -146,6 +248,9 @@ def route_driver_chain_local_nets(node, nets, **options): continue if {pin.part for pin in pins} - row_parts: continue + bbox = _net_bbox_from_pins(pins) + if not _should_use_trunk_routing(node, net, pins, bbox=bbox, grid=grid): + continue pin_pts = [(pin.pt * pin.part.tx).round() for pin in pins] bus_y = _driver_chain_bus_y(pin_pts, grid) @@ -206,6 +311,9 @@ def _net_side(net): pins = _driver_route_pins(node, net) if not pins: continue + bbox = _net_bbox_from_pins(pins) + if not _should_use_trunk_routing(node, net, pins, bbox=bbox, grid=grid): + continue pin_pts = [(pin.pt * pin.part.tx).round() for pin in pins] span = rail_spans.get(net) diff --git a/src/skidl/tools/kicad9/sexp_schematic.py b/src/skidl/tools/kicad9/sexp_schematic.py index df25d4a7..5a0d4128 100644 --- a/src/skidl/tools/kicad9/sexp_schematic.py +++ b/src/skidl/tools/kicad9/sexp_schematic.py @@ -34,6 +34,84 @@ _NAMESPACE_UUID = uuid.UUID("7026fcc6-e1a0-409e-aaf4-6a17ea82654f") KICAD9_SCHEMATIC_VERSION = 20250114 + +def _attach_debug_enabled(): + value = str(os.environ.get("SKIDL_SCH_DEBUG_ATTACH", "") or "").strip().lower() + return value not in ("", "0", "false", "no", "off") + + +def _attach_debug_log(message): + if not _attach_debug_enabled(): + return + from skidl.logger import active_logger + + active_logger.info("[attach-debug] %s" % message) + + +def _wire_count_summary(wires): + return { + str(getattr(net, "name", "") or ""): len(segs) + for net, segs in sorted( + wires.items(), key=lambda item: str(getattr(item[0], "name", "") or "") + ) + } + + +def _subtree_wire_total(node): + total = sum(len(segs) for segs in getattr(node, "wires", {}).values()) + for child in getattr(node, "children", {}).values(): + total += _subtree_wire_total(child) + return total + + +def _log_export_node_state(stage, node, tx=None, instance_path=None, project_name=None): + if not _attach_debug_enabled(): + return + _attach_debug_log( + "%s node_id=%s sheet=%s flattened=%s node_wires_id=%s total_nets=%d total_wires=%d subtree_total_wires=%d child_count=%d sheet_file=%s tx_id=%s instance_path=%s project=%s per_net=%s" + % ( + stage, + id(node), + getattr(node, "name", "?"), + getattr(node, "flattened", False), + id(getattr(node, "wires", {})), + len(getattr(node, "wires", {})), + sum(len(segs) for segs in getattr(node, "wires", {}).values()), + _subtree_wire_total(node), + len(getattr(node, "children", {})), + getattr(node, "sheet_filename", None), + id(tx) if tx is not None else "None", + instance_path, + project_name, + _wire_count_summary(getattr(node, "wires", {})), + ) + ) + + +def _iter_node_subtree(node): + yield node + for child in getattr(node, "children", {}).values(): + yield from _iter_node_subtree(child) + + +def _is_flat_export_wrapper(node): + real_parts = [ + part for part in getattr(node, "parts", []) if not isinstance(part, NetTerminal) + ] + return ( + len(real_parts) == 0 + and len(getattr(node, "wires", {})) == 0 + and len(getattr(node, "junctions", {})) == 0 + and len(getattr(node, "children", {})) == 1 + ) + + +def _select_flat_export_root(node): + current = node + while _is_flat_export_wrapper(current): + current = next(iter(current.children.values())) + return current + # --------------------------------------------------------------------------- # Power symbol support # --------------------------------------------------------------------------- @@ -1384,8 +1462,15 @@ def node_to_sexp_schematic( """ # Fix filename extension for KiCad 6+ S-expression format. _fix_sheet_filename(node) + _log_export_node_state( + "sexp node enter", + node, + tx=sheet_tx, + instance_path=instance_path, + project_name=project_name, + ) - elements = [] + elements = list() node_sheet_uuid = _gen_uuid(f"sheet:{node.sheet_filename}") if node.flattened: @@ -1448,6 +1533,13 @@ def node_to_sexp_schematic( ) # Generate wire S-expressions (split at junction points). + _log_export_node_state( + "sexp wire emit", + node, + tx=tx, + instance_path=current_instance_path, + project_name=current_project_name, + ) for net, wire in node.wires.items(): net_junctions = node.junctions.get(net, []) elements.extend(wire_to_sexp(net, wire, tx=tx, junctions=net_junctions)) @@ -1530,6 +1622,13 @@ def node_to_sexp_schematic( # Write schematic file. filepath = os.path.join(node.filepath, node.sheet_filename) + _log_export_node_state( + "sexp write before", + node, + tx=tx, + instance_path=current_instance_path, + project_name=current_project_name, + ) _write_sexp_schematic(schematic, filepath) _fix_exported_schematic_references(filepath, node.parts) @@ -1543,6 +1642,89 @@ def node_to_sexp_schematic( ] +def _collect_flat_schematic_elements( + node, + sheet_tx, + instance_path="/", + project_name="SKiDL-Generated", + include_node_tx=False, +): + """Collect a node subtree directly into one sheet without sheet wrappers.""" + _fix_sheet_filename(node) + tx = node.tx * sheet_tx if include_node_tx else sheet_tx + _log_export_node_state( + "sexp flat collect", + node, + tx=tx, + instance_path=instance_path, + project_name=project_name, + ) + + elements = list() + + for child in node.children.values(): + elements.extend( + _collect_flat_schematic_elements( + child, + tx, + instance_path=instance_path, + project_name=project_name, + include_node_tx=True, + ) + ) + + for part in node.parts: + if isinstance(part, NetTerminal): + label = net_label_to_sexp( + part.pins[0], + tx=tx, + force=True, + instance_path=instance_path, + project_name=project_name, + ) + if label: + elements.append(label) + else: + elements.append( + part_to_sexp( + part, + tx=tx, + instance_path=instance_path, + project_name=project_name, + ) + ) + + for net, wire in node.wires.items(): + net_junctions = node.junctions.get(net, []) + elements.extend(wire_to_sexp(net, wire, tx=tx, junctions=net_junctions)) + + for net, junctions in node.junctions.items(): + elements.extend(junction_to_sexp(net, junctions, tx=tx)) + + for part in node.parts: + if isinstance(part, NetTerminal): + continue + for pin in part: + label = net_label_to_sexp( + pin, + tx=tx, + instance_path=instance_path, + project_name=project_name, + ) + if label: + elements.append(label) + + _append_missing_symbol_instances( + elements, + node.parts, + tx, + instance_path=instance_path, + project_name=project_name, + ) + + return elements + + # --------------------------------------------------------------------------- # Top-level schematic assembly + write # --------------------------------------------------------------------------- @@ -1556,6 +1738,7 @@ def write_top_schematic( top_name, title, version=KICAD9_SCHEMATIC_VERSION, + flatten=False, ): """Generate and write the complete schematic from a placed+routed node tree. @@ -1568,6 +1751,8 @@ def write_top_schematic( top_name: Base filename (without extension). title: Schematic title. version: S-expression version number. + flatten: If True, emit one standalone sheet instead of a hierarchy + wrapper plus child sheet files. """ if hasattr(circuit, "annotate_parts"): # Ensure placeholder references such as D? are resolved before any @@ -1581,12 +1766,87 @@ def write_top_schematic( project_name = top_name or "SKiDL-Generated" root_uuid = _gen_uuid(f"root:{project_name}") root_instance_path = f"/{root_uuid}" + export_root = _select_flat_export_root(node) if flatten else node # Calculate root sheet transform. - root_bbox = node.internal_bbox() + root_bbox = export_root.internal_bbox() sheet_tx, paper = _calc_sheet_tx(root_bbox) + _log_export_node_state( + "sexp top enter", + export_root, + tx=sheet_tx, + instance_path=root_instance_path, + project_name=project_name, + ) elements = [] + if flatten: + _attach_debug_log( + "sexp flat export root node_id=%s sheet=%s source_root_id=%s source_sheet=%s subtree_nodes=%d" + % ( + id(export_root), + getattr(export_root, "name", "?"), + id(node), + getattr(node, "name", "?"), + sum(1 for _ in _iter_node_subtree(export_root)), + ) + ) + elements.extend( + _collect_flat_schematic_elements( + export_root, + sheet_tx, + instance_path=root_instance_path, + project_name=project_name, + ) + ) + + lib_symbols = {} + for part in circuit.parts: + if not isinstance(part, NetTerminal): + lib_id = _part_lib_id(part) + if lib_id not in lib_symbols: + lib_symbols[lib_id] = part + + lib_symbols_sexp = Sexp(["lib_symbols"]) + for lib_id, part in lib_symbols.items(): + lib_symbols_sexp.append(Sexp(part_to_lib_symbol_definition(part))) + + for pwr_name in sorted(_used_power_symbols): + pwr_lib_id = f"power:{pwr_name}" + if pwr_lib_id not in lib_symbols: + pwr_sexp = _extract_power_lib_symbol(pwr_name) + if pwr_sexp: + lib_symbols_sexp.append(pwr_sexp) + + schematic = Sexp( + [ + "kicad_sch", + ["version", version], + ["generator", "skidl"], + ["generator_version", __version__], + ["uuid", root_uuid], + ["paper", paper], + ] + ) + schematic.append(Sexp(create_title_block_sexp(title))) + schematic.append(lib_symbols_sexp) + + for elem in elements: + schematic.append(elem) + + output_file = os.path.join(filepath, f"{top_name}.kicad_sch") + os.makedirs(filepath, exist_ok=True) + _log_export_node_state( + "sexp top write before", + export_root, + tx=sheet_tx, + instance_path=root_instance_path, + project_name=project_name, + ) + _write_sexp_schematic(schematic, output_file) + _fix_exported_schematic_references(output_file, circuit.parts) + _validate_with_kicad_cli(output_file) + return output_file # Recurse into children — they write their own files if unflattened. for child in node.children.values(): @@ -1634,6 +1894,13 @@ def write_top_schematic( ) # Generate wire S-expressions (split at junction points). + _log_export_node_state( + "sexp top wire emit", + node, + tx=sheet_tx, + instance_path=root_instance_path, + project_name=project_name, + ) for net, wire in node.wires.items(): net_junctions = node.junctions.get(net, []) elements.extend(wire_to_sexp(net, wire, tx=sheet_tx, junctions=net_junctions)) @@ -1699,6 +1966,13 @@ def write_top_schematic( # Write root schematic. output_file = os.path.join(filepath, f"{top_name}.kicad_sch") os.makedirs(filepath, exist_ok=True) + _log_export_node_state( + "sexp top write before", + node, + tx=sheet_tx, + instance_path=root_instance_path, + project_name=project_name, + ) _write_sexp_schematic(schematic, output_file) _fix_exported_schematic_references(output_file, circuit.parts) diff --git a/tests/unit_tests/test_topology_generic_driver.py b/tests/unit_tests/test_topology_generic_driver.py index 4e1dd568..0b8bb4d4 100644 --- a/tests/unit_tests/test_topology_generic_driver.py +++ b/tests/unit_tests/test_topology_generic_driver.py @@ -643,6 +643,79 @@ def test_route_driver_rails_trims_span_but_still_covers_all_connected_pins(): assert {seg.p1.x for seg in vert} == {0, 600, 1200} +def test_route_driver_rails_skips_local_two_pin_non_power_chain_net(): + grid = 100 + node = _FakeNode() + d1 = _FakePart("D1", pins=[_FakePin("A", pt=Point(0, 0))]) + r1 = _FakePart("R1", pins=[_FakePin("1", pt=Point(0, 0))]) + for part, dx in ((d1, 0), (r1, 600)): + part.tx = Tx(dx=dx, dy=0) + for pin in part.pins: + pin.part = part + node.parts = [d1, r1] + node._driver_chain_parts = {d1, r1} + node._last_topology_result = {"kind": "disabled"} + node._driver_rail_plan = { + "enabled": True, + "grid": grid, + "top_nets": [], + "bottom_nets": [], + } + + local = _FakeNet("NET-(D1-A)") + _wire(d1, "A", local) + _wire(r1, "1", local) + + handled = route_driver_rails( + node, + [local], + human_readable=True, + driver_rail_routing=True, + grid=grid, + ) + + assert local not in handled + assert local not in node.wires + + +def test_route_driver_rails_keeps_power_like_two_pin_chain_net(): + grid = 100 + node = _FakeNode() + u1 = _FakePart("U1", pins=[_FakePin("VIN", pt=Point(0, 100))]) + c1 = _FakePart("C1", pins=[_FakePin("1", pt=Point(0, 100))]) + for part, dx in ((u1, 0), (c1, 700)): + part.tx = Tx(dx=dx, dy=0) + for pin in part.pins: + pin.part = part + node.parts = [u1, c1] + node._driver_chain_parts = {u1, c1} + node._last_topology_result = {"kind": "disabled"} + node._driver_rail_plan = { + "enabled": True, + "grid": grid, + "top_nets": [], + "bottom_nets": [], + } + + vcc = _FakeNet("VCC") + _wire(u1, "VIN", vcc) + _wire(c1, "1", vcc) + + handled = route_driver_rails( + node, + [vcc], + human_readable=True, + driver_rail_routing=True, + grid=grid, + ) + + assert vcc in handled + horiz = [seg for seg in node.wires[vcc] if seg.p1.y == seg.p2.y] + vert = [seg for seg in node.wires[vcc] if seg.p1.x == seg.p2.x] + assert len(horiz) == 1 + assert len(vert) == 2 + + def test_prune_driver_preroute_tails_keeps_connections_and_one_grid_margin(): grid = 100 node = _FakeNode()