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 = [ diff --git a/src/skidl/schematics/place.py b/src/skidl/schematics/place.py index 9502fe84..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.""" @@ -1144,6 +1144,593 @@ 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 _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() + 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 _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)). @@ -1166,8 +1753,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 +1771,168 @@ 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) + + # 分区摆放后再做几何对齐(主干共线、支路分层、左右对称、去重叠)。 + node._align_connected_geometry( + real_parts, adjacency, roles, main_part + ) + + 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 @@ -1287,6 +1992,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 @@ -1309,10 +2028,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 +2270,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: @@ -1584,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. @@ -1614,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(): @@ -1638,6 +2449,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 +2469,19 @@ 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)) - - # 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] + 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)) + + 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 +2494,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 +2536,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/place_small_group.py b/src/skidl/schematics/place_small_group.py new file mode 100644 index 00000000..e34954b9 --- /dev/null +++ b/src/skidl/schematics/place_small_group.py @@ -0,0 +1,242 @@ +# -*- 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 _pt_dist(a, b): + """两点欧氏距离(Point 无 distance 方法,用差向量 magnitude)。""" + return (a - b).magnitude + + +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_dist(pt, 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/src/skidl/schematics/route.py b/src/skidl/schematics/route.py index b44cb7b2..15a3f4cf 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. @@ -2039,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) @@ -2056,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.""" @@ -2188,6 +2260,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 +2425,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 +2534,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: @@ -2618,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.""" @@ -2919,8 +3083,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 +3117,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: @@ -2965,17 +3144,293 @@ 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, + visited_patterns=None, + ): + """压平 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 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: + 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) + + # 只处理明显小凸起,避免大范围改线 + 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) + + pt_segs = defaultdict(list) + for seg in segments: + 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): + 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: + 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) + 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: + 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) + 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] + 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. @@ -3008,19 +3463,55 @@ def get_jogs(segments): # 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, + node.wires, + net_bboxes, + part_obstacles, + net_internal_pins[net], + ) + # Split intersecting 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], + local_detour_visited[net], + ) + + 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] @@ -3051,6 +3542,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 +3673,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() @@ -3152,11 +3700,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() @@ -3165,7 +3727,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"): @@ -3174,7 +3736,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: @@ -3229,8 +3791,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/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/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/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/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/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/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/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/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/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: 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] == [] diff --git a/update.md b/update.md new file mode 100644 index 00000000..35500d6c --- /dev/null +++ b/update.md @@ -0,0 +1,326 @@ +# 更新记录(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/`**。 + +--- + +## 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`) + +### 为什么原先纯三段检测不够 + +- 第一版 `_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` 失败即跳过。