diff --git a/src/skidl/tools/kicad9/constants.py b/src/skidl/tools/kicad9/constants.py index a7de8044..5dc344bd 100644 --- a/src/skidl/tools/kicad9/constants.py +++ b/src/skidl/tools/kicad9/constants.py @@ -14,3 +14,6 @@ BLK_EXT_PAD = 2 * GRID DRAWING_BOX_RESIZE = 100 HIER_TERM_SIZE = 50 + +# Minimum clearance (mils) when offsetting labels to avoid component overlap. +LABEL_DECONFLICT_MARGIN = 50 diff --git a/src/skidl/tools/kicad9/gen_schematic.py b/src/skidl/tools/kicad9/gen_schematic.py index 4b822a08..e1f7e76a 100644 --- a/src/skidl/tools/kicad9/gen_schematic.py +++ b/src/skidl/tools/kicad9/gen_schematic.py @@ -13,7 +13,7 @@ import re import shutil import subprocess -from collections import Counter +from collections import Counter, defaultdict from skidl.geometry import BBox, Point, Tx, Vector from skidl.schematics.net_terminal import NetTerminal @@ -318,16 +318,45 @@ def _handle_fallback(circuit, tool_module, filepath, top_name, title, flatness, "labels-only output instead of crashing." ) - # Produce labels-only output. + from skidl.schematics.place import PlacementFailure + from skidl.schematics.route import RoutingFailure + + # Place with real connectivity so connected parts group together, + # then stub for routing. This gives connectivity-aware placement + # with labels-only routing (which always succeeds). + placed = False + for expansion in [1.5, 2.25, 3.0]: + try: + preprocess_circuit(circuit, **options) + node = SchNode(circuit, tool_module, filepath, top_name, title, flatness) + node.place(expansion_factor=expansion, **options) + placed = True + break + except PlacementFailure: + finalize_parts_and_nets(circuit, **options) + logger.info( + f" [graceful_fallback] Connectivity-aware placement failed " + f"at {expansion}x, trying wider" + ) + + if not placed: + # Last resort: stub everything, place without connectivity. + _stub_all_non_explicit(circuit) + preprocess_circuit(circuit, **options) + node = SchNode(circuit, tool_module, filepath, top_name, title, flatness) + node.place(expansion_factor=1.5, **options) + + _snap_two_pin_parts(node) + + # Stub all remaining nets so routing is trivial (labels only). stubbed_nets = [] for net in circuit.nets: if not getattr(net, "_stub_explicit", False) and not net._stub: stubbed_nets.append(net.name) - _stub_all_non_explicit(circuit) + net._stub = True + for pin in net.get_pins(): + pin.stub = True - preprocess_circuit(circuit, **options) - node = SchNode(circuit, tool_module, filepath, top_name, title, flatness) - node.place(expansion_factor=1.0, **options) node.route(**options) output_file = write_top_schematic( circuit, node, filepath, top_name, title, version=20230409 @@ -335,19 +364,477 @@ def _handle_fallback(circuit, tool_module, filepath, top_name, title, flatness, finalize_parts_and_nets(circuit, **options) msg = ( - f"{reason}. Produced labels-only schematic at {output_file}. " - f"Nets converted to labels: {', '.join(stubbed_nets[:10])}" - f"{'...' if len(stubbed_nets) > 10 else ''}. " - "This may mask routing issues that could be fixed by improving " - "the circuit layout. Set auto_stub_fallback='raise' to get the " - "original error instead." + f"{reason}. Produced schematic at {output_file} with " + f"connectivity-aware placement. " + f"{len(stubbed_nets)} nets as labels, close 2-pin nets wired directly." ) - logger.warning(msg) + logger.info(msg) if fallback == "warn": warnings.warn(msg, LabelsOnlyWarning, stacklevel=4) +def _is_two_pin_part(part): + """Return True if part is a simple 2-pin component (LED, R, C, etc.).""" + return not isinstance(part, NetTerminal) and len(part.pins) == 2 + + +def _is_power_net(net): + """Return True if net is a power rail (GND, VCC, +3.3V, etc.).""" + name = getattr(net, 'name', '') + return name.startswith("+") or bool(_POWER_NET_RE.match(name)) + + +def _pin_world_orient(pin, part): + """Get the world-space outward direction from a pin after part rotation. + + Transforms the pin's stub direction vector through the part's full + transform (including mirrors/flips), then returns the opposite direction. + """ + orient_to_vec = {"R": (1, 0), "L": (-1, 0), "U": (0, -1), "D": (0, 1)} + outward = {"L": "R", "R": "L", "U": "D", "D": "U"} + + raw_orient = getattr(pin, "orientation", "R") + vx, vy = orient_to_vec.get(raw_orient, (1, 0)) + tx = part.tx + wx = tx.a * vx + tx.b * vy + wy = tx.c * vx + tx.d * vy + if abs(wx) >= abs(wy): + world_orient = "R" if wx > 0 else "L" + else: + world_orient = "D" if wy > 0 else "U" + return outward.get(world_orient, "R") + + +def _compute_snap_tx(my_pin, other_pin, target_world, extend_dir): + """Compute the transform to snap a 2-pin part onto a target pin position. + + Orients the part so `other_pin` extends in `extend_dir` from the target, + and places `my_pin` exactly at `target_world`. + + Returns: + Tx: The new transform for the 2-pin part. + """ + dx_local = other_pin.pt.x - my_pin.pt.x + dy_local = other_pin.pt.y - my_pin.pt.y + + if extend_dir == "R": + if abs(dx_local) >= abs(dy_local): + symtx = "" if dx_local > 0 else "H" + else: + symtx = "R" if dy_local > 0 else "L" + elif extend_dir == "L": + if abs(dx_local) >= abs(dy_local): + symtx = "" if dx_local < 0 else "H" + else: + symtx = "L" if dy_local > 0 else "R" + elif extend_dir == "U": + if abs(dy_local) >= abs(dx_local): + symtx = "" if dy_local > 0 else "V" + else: + symtx = "L" if dx_local > 0 else "R" + elif extend_dir == "D": + if abs(dy_local) >= abs(dx_local): + symtx = "" if dy_local < 0 else "V" + else: + symtx = "R" if dx_local > 0 else "L" + else: + symtx = "" + + new_tx = Tx.from_symtx(symtx) + my_pin_placed = my_pin.pt * new_tx + offset = Point( + target_world.x - my_pin_placed.x, + target_world.y - my_pin_placed.y, + ) + return new_tx.move(offset) + + +def _snap_two_pin_parts(node): + """Snap 2-pin parts onto their connected IC or already-snapped part pins. + + Pass 1: Snap onto IC pins (parts with >2 pins). Each IC pin only accepts + one snapped part; extras keep their labels. + + Pass 2+: Iteratively snap remaining 2-pin parts onto the free pins of + already-snapped 2-pin parts, building chains (e.g. IC ← R ← LED). + + Pass 3: Stack remaining 2-pin parts onto already-occupied IC pins, + extending perpendicular to the first snapped part. Handles nets shared + between multiple 2-pin parts (e.g. switch + pull-down on the same IC input). + + Recurses into child nodes first. + """ + for child in node.children.values(): + _snap_two_pin_parts(child) + + node_part_ids = {id(p) for p in node.parts} + snapped = set() + occupied_pins = set() + + for part in list(node.parts): + if not _is_two_pin_part(part): + continue + + p1, p2 = part.pins[0], part.pins[1] + net1 = getattr(p1, "net", None) + net2 = getattr(p2, "net", None) + if not net1 or not net2: + continue + + target_pin = None + target_part = None + my_pin = None + + both_power = _is_power_net(net1) and _is_power_net(net2) + min_target_pins = 8 if both_power else 2 + + for my_p, other_net in [(p1, net1), (p2, net2)]: + if _is_power_net(other_net) and not both_power: + continue + for net_pin in other_net.pins: + other_part = net_pin.part + if ( + other_part is not part + and id(other_part) in node_part_ids + and not isinstance(other_part, NetTerminal) + and len(other_part.pins) > min_target_pins + and id(net_pin) not in occupied_pins + ): + target_pin = net_pin + target_part = other_part + my_pin = my_p + break + if target_pin: + break + + if not target_pin: + continue + + target_world = target_pin.pt * target_part.tx + extend_dir = _pin_world_orient(target_pin, target_part) + other_pin = p2 if my_pin is p1 else p1 + + part.tx = _compute_snap_tx(my_pin, other_pin, target_world, extend_dir) + if both_power: + _offset_dir = {"R": (200, 0), "L": (-200, 0), "U": (0, 200), "D": (0, -200)} + dx, dy = _offset_dir.get(extend_dir, (200, 0)) + part.tx = part.tx.move(Point(dx, dy)) + snapped.add(id(part)) + occupied_pins.add(id(target_pin)) + + for _iteration in range(5): + newly_snapped = set() + + for part in list(node.parts): + if id(part) in snapped or not _is_two_pin_part(part): + continue + + p1, p2 = part.pins[0], part.pins[1] + net1 = getattr(p1, "net", None) + net2 = getattr(p2, "net", None) + if not net1 or not net2: + continue + + target_pin = None + target_part = None + my_pin = None + + both_power = _is_power_net(net1) and _is_power_net(net2) + + for my_p, other_net in [(p1, net1), (p2, net2)]: + if _is_power_net(other_net) and not both_power: + continue + for net_pin in other_net.pins: + other_part = net_pin.part + if ( + other_part is not part + and id(other_part) in snapped + and id(net_pin) not in occupied_pins + ): + target_pin = net_pin + target_part = other_part + my_pin = my_p + break + if target_pin: + break + + if not target_pin: + continue + + target_world = target_pin.pt * target_part.tx + extend_dir = _pin_world_orient(target_pin, target_part) + other_pin = p2 if my_pin is p1 else p1 + + part.tx = _compute_snap_tx(my_pin, other_pin, target_world, extend_dir) + newly_snapped.add(id(part)) + occupied_pins.add(id(target_pin)) + + if not newly_snapped: + break + snapped |= newly_snapped + + perp_map = {"R": "D", "L": "U", "U": "R", "D": "L"} + for part in list(node.parts): + if id(part) in snapped or not _is_two_pin_part(part): + continue + + p1, p2 = part.pins[0], part.pins[1] + net1 = getattr(p1, "net", None) + net2 = getattr(p2, "net", None) + if not net1 or not net2: + continue + + target_pin = None + target_part = None + my_pin = None + + for my_p, other_net in [(p1, net1), (p2, net2)]: + for net_pin in other_net.pins: + other_part = net_pin.part + if ( + other_part is not part + and id(other_part) in node_part_ids + and not isinstance(other_part, NetTerminal) + and len(other_part.pins) > 2 + and id(net_pin) in occupied_pins + ): + target_pin = net_pin + target_part = other_part + my_pin = my_p + break + if target_pin: + break + + if not target_pin: + continue + + target_world = target_pin.pt * target_part.tx + ic_dir = _pin_world_orient(target_pin, target_part) + extend_dir = perp_map.get(ic_dir, ic_dir) + other_pin = p2 if my_pin is p1 else p1 + + part.tx = _compute_snap_tx(my_pin, other_pin, target_world, extend_dir) + snapped.add(id(part)) + + _stagger_tjunctions(node, node_part_ids, snapped, occupied_pins) + + +def _stagger_tjunctions(node, node_part_ids, snapped, occupied_pins, min_group=2): + """Detect repeating T-junction patterns and stagger parts outward from IC. + + Phase 1: identify stagger groups, compute how much space each needs, + and shift ICs apart vertically so fans won't overlap. + Phase 2: place the staggered parts at the (now separated) IC positions. + """ + perp_map = {"R": "D", "L": "U", "U": "R", "D": "L"} + anti_perp = {"U": "D", "D": "U", "L": "R", "R": "L"} + _dir_vec = {"R": (1, 0), "L": (-1, 0), "U": (0, -1), "D": (0, 1)} + + ic_pin_to_parts = defaultdict(list) + + for part in node.parts: + if not _is_two_pin_part(part): + continue + + p1, p2 = part.pins[0], part.pins[1] + net1 = getattr(p1, "net", None) + net2 = getattr(p2, "net", None) + if not net1 or not net2: + continue + + for my_p, other_net in [(p1, net1), (p2, net2)]: + if _is_power_net(other_net): + continue + for net_pin in other_net.pins: + ic = net_pin.part + if ( + ic is not part + and id(ic) in node_part_ids + and not isinstance(ic, NetTerminal) + and len(ic.pins) > 2 + and id(net_pin) in occupied_pins + ): + other_pin = p2 if my_p is p1 else p1 + ic_pin_to_parts[id(net_pin)].append( + (part, my_p, other_pin, net_pin, ic) + ) + break + else: + continue + break + + ic_groups = defaultdict(list) + for ic_pin_id, parts_list in ic_pin_to_parts.items(): + if not parts_list: + continue + ic = parts_list[0][4] + ic_groups[id(ic)].append((parts_list[0][3], parts_list)) + + # ── Phase 1: identify qualifying groups and pre-shift ICs ───────── + MM_TO_MILS = 1 / 0.0254 + stagger_plans = [] + + for ic_id, pin_entries in ic_groups.items(): + fanout_counts = [len(pl) for _, pl in pin_entries] + dominant = max(set(fanout_counts), key=fanout_counts.count) + if dominant < 2: + continue + matching = [(ip, pl) for ip, pl in pin_entries if len(pl) == dominant] + + if len(matching) < min_group: + continue + + ic_part = matching[0][1][0][4] + ic_dir = _pin_world_orient(matching[0][0], ic_part) + step_dx, step_dy = _dir_vec.get(ic_dir, (1, 0)) + + max_span = 0 + for _, parts_list_scan in matching: + for (scan_part, _, _, _, _) in parts_list_scan: + pts = [getattr(p, "pt", Point(p.x * MM_TO_MILS, p.y * MM_TO_MILS)) for p in scan_part.pins] + if pts: + span = max( + max(p.x for p in pts) - min(p.x for p in pts), + max(p.y for p in pts) - min(p.y for p in pts), + ) + max_span = max(max_span, span) + step_size = max(100, int(max_span) + 50) + + n_pins = len(matching) + stagger_extent = step_size * n_pins + max_span + + stagger_plans.append({ + "ic_part": ic_part, + "matching": matching, + "ic_dir": ic_dir, + "step_dx": step_dx, + "step_dy": step_dy, + "step_size": step_size, + "stagger_extent": stagger_extent, + "dominant": dominant, + }) + + if len(stagger_plans) > 1: + _pre_shift_ics(stagger_plans, node, snapped) + + # ── Phase 2: place staggered parts at final IC positions ────────── + junction_wires = getattr(node, "_tjunction_wires", []) + suppressed_pins = set() + + for plan in stagger_plans: + ic_part = plan["ic_part"] + matching = plan["matching"] + ic_dir = plan["ic_dir"] + step_dx = plan["step_dx"] + step_dy = plan["step_dy"] + step_size = plan["step_size"] + perp_dir = perp_map.get(ic_dir, ic_dir) + + def _pin_sort_key(entry, _ic_part=ic_part, _ic_dir=ic_dir): + ic_pin = entry[0] + w = ic_pin.pt * _ic_part.tx + if _ic_dir in ("L", "R"): + return w.y + return w.x + + matching.sort(key=_pin_sort_key) + + parts_per_pin = plan["dominant"] + anti = anti_perp.get(perp_dir, perp_dir) + extend_dirs = [perp_dir, anti] if parts_per_pin >= 2 else [perp_dir] + + for pin_idx, (ic_pin, parts_list) in enumerate(matching): + ic_pin_world = ic_pin.pt * ic_part.tx + + parts_list.sort(key=lambda t: getattr(t[0], "ref", "")) + + offset_n = pin_idx + 1 + ox = ic_pin_world.x + step_dx * step_size * offset_n + oy = ic_pin_world.y + step_dy * step_size * offset_n + junction_pt = Point(ox, oy) + + for part_idx, (part, my_pin, other_pin, _, _) in enumerate(parts_list): + ext_dir = extend_dirs[part_idx % len(extend_dirs)] + part.tx = _compute_snap_tx( + my_pin, other_pin, junction_pt, ext_dir + ) + snapped.add(id(part)) + suppressed_pins.add(id(my_pin)) + junction_wires.append( + (ic_pin_world.x, ic_pin_world.y, ox, oy) + ) + + node._tjunction_wires = junction_wires + node._tjunction_suppressed_pins = suppressed_pins + + +def _pre_shift_ics(plans, node, snapped): + """Shift ICs vertically BEFORE stagger placement so fans won't overlap. + + Collects all parts already snapped to each IC and moves them together. + The stagger parts haven't been placed yet, so they'll naturally land + at the shifted IC positions in phase 2. + """ + for plan in plans: + ic = plan["ic_part"] + ic_deps = set() + ic_id = id(ic) + + for part in node.parts: + if id(part) == ic_id or id(part) not in snapped: + continue + if not _is_two_pin_part(part): + continue + for pin in part.pins: + net = getattr(pin, "net", None) + if not net: + continue + for net_pin in net.pins: + if net_pin.part is ic: + ic_deps.add(id(part)) + break + if id(part) in ic_deps: + break + + plan["_deps"] = [p for p in node.parts if id(p) in ic_deps] + + def _ic_bbox(plan): + ic = plan["ic_part"] + all_parts = [ic] + plan["_deps"] + min_y = float("inf") + max_y = float("-inf") + for part in all_parts: + for pin in part.pins: + w = pin.pt * part.tx + min_y = min(min_y, w.y) + max_y = max(max_y, w.y) + return min_y, max_y + + plans.sort(key=lambda p: _ic_bbox(p)[0]) + + margin = 200 + prev_max_y = None + + for plan in plans: + ic_min_y, ic_max_y = _ic_bbox(plan) + needed_height = plan["stagger_extent"] + group_max_y = max(ic_max_y, ic_min_y + needed_height) + + if prev_max_y is not None and ic_min_y < prev_max_y + margin: + shift = (prev_max_y + margin) - ic_min_y + vec = Point(0, shift) + shifted = set() + for part in [plan["ic_part"]] + plan["_deps"]: + if id(part) not in shifted: + part.tx = part.tx.move(vec) + shifted.add(id(part)) + ic_min_y += shift + group_max_y += shift + + prev_max_y = group_max_y + + def _stub_all_non_explicit(circuit): """Stub all nets that weren't explicitly set by the user (labels-only fallback). diff --git a/src/skidl/tools/kicad9/sexp_schematic.py b/src/skidl/tools/kicad9/sexp_schematic.py index c12cd98a..163da33a 100644 --- a/src/skidl/tools/kicad9/sexp_schematic.py +++ b/src/skidl/tools/kicad9/sexp_schematic.py @@ -26,6 +26,7 @@ from skidl.geometry import Point, Tx from skidl.pckg_info import __version__ +from skidl.net import NCNet from skidl.schematics.net_terminal import NetTerminal from skidl.utilities import export_to_all @@ -256,12 +257,16 @@ def _power_symbol_to_sexp(pin, net_name, tx): x = _round_mm(pt.x) y = _round_mm(pt.y) - # Power symbol angle: the symbol's pin orientation determines how - # it should be rotated. For most power symbols, the connection pin - # is at (0, 0) and the graphical part extends in one direction. - # We don't rotate — KiCad power symbols are designed to display correctly - # at angle 0 (voltage symbols point up, GND symbols point down). - angle = 0 + # Rotate the power symbol so its graphical part points AWAY from the + # component. calc_pin_dir returns directions in SKiDL's internal coords + # (Y-up), but KiCad schematics use Y-down, so U and D are swapped. + # At angle=0: GND bars point down, supply arrows point up (KiCad convention). + pin_dir = calc_pin_dir(pin) + _is_gnd = any(g in net_name.upper() for g in ("GND", "VSS", "EARTH")) + if _is_gnd: + angle = {"U": 0, "D": 180, "R": 270, "L": 90}.get(pin_dir, 0) + else: + angle = {"D": 0, "U": 180, "R": 90, "L": 270}.get(pin_dir, 0) lib_id = f"power:{net_name}" inst_uuid = _gen_uuid(f"pwr:{net_name}:{x}:{y}:{_pwr_counter[0]}") @@ -855,7 +860,10 @@ def net_label_to_sexp(pin, tx=Tx(), force=False): """ if not force and (not pin.stub or not pin.is_connected()): return None - + + if isinstance(getattr(pin, "net", None), NCNet): + return None + # Check if this net matches a known KiCad power symbol. # If so, emit a power symbol instance instead of a global_label. # This eliminates power_pin_not_driven ERC errors. @@ -897,6 +905,33 @@ def net_label_to_sexp(pin, tx=Tx(), force=False): return label +def _gen_no_connect_flags(node, tx): + """Generate no_connect flag S-expressions for pins on NCNet. + + Returns a list of Sexp objects, one per NC pin. + """ + flags = [] + for part in node.parts: + if isinstance(part, NetTerminal): + continue + for pin in part: + if not isinstance(getattr(pin, "net", None), NCNet): + continue + pin_pt = getattr(pin, "pt", Point(pin.x, pin.y)) + part_tx = getattr(pin.part, "tx", Tx()) + pt = pin_pt * part_tx * tx + flags.append( + Sexp( + [ + "no_connect", + ["at", _round_mm(pt.x), _round_mm(pt.y)], + ["uuid", _gen_uuid(f"nc:{part.ref}:{pin.num}:{pt.x}:{pt.y}")], + ] + ) + ) + return flags + + # --------------------------------------------------------------------------- # Title block # --------------------------------------------------------------------------- @@ -1089,6 +1124,246 @@ def _fix_sheet_filename(node): node.sheet_filename = node.sheet_filename[:-4] + ".kicad_sch" +def _kicad_pin_pos(pin, part_tx, sheet_tx): + """Compute pin position as KiCad renders it from symbol placement. + + KiCad's pin transform order: Y-flip, rotate(-angle), then mirror. + The angle from analyze_transform() is the visual angle in SKiDL's Y-up + space; KiCad uses its negative because the sheet Y-flip reverses rotation. + """ + import math + + angle_deg, mx, my = part_tx.analyze_transform() + composed = part_tx * sheet_tx + ox = _round_mm(composed.origin.x) + oy = _round_mm(composed.origin.y) + + px, py = pin.x, -pin.y + + theta = math.radians(-angle_deg) + cos_t, sin_t = math.cos(theta), math.sin(theta) + rx = px * cos_t - py * sin_t + ry = px * sin_t + py * cos_t + + if mx: + ry = -ry + if my: + rx = -rx + + return _round_mm(ox + rx), _round_mm(oy + ry) + + +def _find_wireable_nets(node, tx, max_dist_mm=80.0): + """Suppress labels for pins connected by snap (overlapping positions). + + Builds connected clusters of overlapping pins per net. Within each + cluster, all pins are physically connected so only one label is needed + (for cross-sheet connectivity). All other pins in the cluster are + suppressed. + + Also tracks nets that have real (non-NetTerminal) pins on this sheet, + so NetTerminal labels can be suppressed for those nets. + + Returns: + tuple: (wired_pin_ids, wire_sexps) where wired_pin_ids is a set of + id(pin) for pins that should NOT get labels, and wire_sexps is + an empty list (no wire elements needed for overlapping pins). + """ + node_part_ids = {id(p) for p in node.parts} + wired_pin_ids = set() + + seen_nets = set() + for part in node.parts: + if isinstance(part, NetTerminal): + continue + for pin in part: + if not pin.stub or not pin.is_connected(): + continue + net = pin.net + if id(net) in seen_nets: + continue + seen_nets.add(id(net)) + + if net.name in _get_power_symbol_names(): + continue + + pins_in_node = [ + p for p in net.pins + if id(p.part) in node_part_ids + and not isinstance(p.part, NetTerminal) + and p.stub + ] + if len(pins_in_node) < 2: + continue + + positions = [] + for p in pins_in_node: + x, y = _kicad_pin_pos(p, getattr(p.part, "tx", Tx()), tx) + positions.append((x, y, p)) + + # Union-find to cluster overlapping pins (dist < 0.01mm). + parent = list(range(len(positions))) + + def find(i): + while parent[i] != i: + parent[i] = parent[parent[i]] + i = parent[i] + return i + + for i in range(len(positions)): + for j in range(i + 1, len(positions)): + dist = ((positions[i][0] - positions[j][0]) ** 2 + + (positions[i][1] - positions[j][1]) ** 2) ** 0.5 + if dist < 0.01: + ri, rj = find(i), find(j) + if ri != rj: + parent[ri] = rj + + # Group pins by cluster. + clusters = {} + for i in range(len(positions)): + r = find(i) + clusters.setdefault(r, []).append(i) + + # Check if the net has pins outside this sheet. + all_real_pins = [ + p for p in net.pins + if not isinstance(p.part, NetTerminal) + ] + has_pins_outside = len(all_real_pins) > len(pins_in_node) + + for members in clusters.values(): + if len(members) < 2: + continue + + pins_outside_cluster = len(pins_in_node) - len(members) + + if not has_pins_outside and pins_outside_cluster == 0: + # All net pins are in this cluster — fully connected by + # overlap, no labels needed at all. + for idx in members: + wired_pin_ids.add(id(positions[idx][2])) + else: + # Net has pins elsewhere — keep one label for cross-sheet + # connectivity, suppress the rest. + for idx in members[1:]: + wired_pin_ids.add(id(positions[idx][2])) + + return wired_pin_ids, [] + + +def _gen_power_bus_wires(node, tx, max_gap_mm=10.0): + """Generate wire segments connecting co-linear power net pins. + + Finds subgroups of 3+ pins on the same power net that share an X or Y + coordinate (within 0.1mm) AND are spaced within max_gap_mm of each + neighbour. Returns (wire_sexps, bus_pin_ids) where bus_pin_ids are + pins that should NOT get individual power symbols. + """ + from collections import defaultdict + + node_part_ids = {id(p) for p in node.parts} + power_names = _get_power_symbol_names() + bus_pin_ids = set() + wires = [] + + seen_nets = set() + for part in node.parts: + if isinstance(part, NetTerminal): + continue + for pin in part: + if not pin.is_connected(): + continue + net = pin.net + if id(net) in seen_nets: + continue + seen_nets.add(id(net)) + + if net.name not in power_names: + continue + + pins_in_node = [ + p for p in net.pins + if id(p.part) in node_part_ids + and not isinstance(p.part, NetTerminal) + and len(p.part.pins) == 2 + and not all( + pp.is_connected() and pp.net.name in power_names + for pp in p.part.pins + ) + ] + if len(pins_in_node) < 3: + continue + + positions = [] + for p in pins_in_node: + x, y = _kicad_pin_pos(p, getattr(p.part, "tx", Tx()), tx) + positions.append((_round_mm(x), _round_mm(y), p)) + + # Group pins by shared X coordinate (vertical columns). + x_groups = defaultdict(list) + for pos in positions: + x_key = round(pos[0], 1) + x_groups[x_key].append(pos) + + # Group pins by shared Y coordinate (horizontal rows). + y_groups = defaultdict(list) + for pos in positions: + y_key = round(pos[1], 1) + y_groups[y_key].append(pos) + + def _split_runs(sorted_group, axis): + """Split sorted positions into contiguous runs by max_gap_mm.""" + runs = [[sorted_group[0]]] + for j in range(1, len(sorted_group)): + gap = abs(sorted_group[j][axis] - sorted_group[j - 1][axis]) + if gap <= max_gap_mm: + runs[-1].append(sorted_group[j]) + else: + runs.append([sorted_group[j]]) + return [r for r in runs if len(r) >= 3] + + def _emit_run(run, net_name): + for j in range(len(run) - 1): + x1, y1, _ = run[j] + x2, y2, _ = run[j + 1] + wires.append( + Sexp( + [ + "wire", + ["pts", ["xy", x1, y1], ["xy", x2, y2]], + ["stroke", ["width", 0], ["type", "default"]], + [ + "uuid", + _gen_uuid( + f"pbus:{net_name}:{x1}:{y1}:{x2}:{y2}" + ), + ], + ] + ) + ) + for _, _, p in run[:-1]: + bus_pin_ids.add(id(p)) + + # Process vertical groups (same X, split by Y gap). + for x_key, group in x_groups.items(): + if len(group) < 3: + continue + group.sort(key=lambda p: p[1]) + for run in _split_runs(group, axis=1): + _emit_run(run, net.name) + + # Process horizontal groups (same Y, split by X gap). + for y_key, group in y_groups.items(): + if len(group) < 3: + continue + group.sort(key=lambda p: p[0]) + for run in _split_runs(group, axis=0): + _emit_run(run, net.name) + + return wires, bus_pin_ids + + @export_to_all def node_to_sexp_schematic(node, sheet_tx=Tx(), version=20230409): """Convert a SchNode tree to S-expression schematic(s). @@ -1130,11 +1405,24 @@ def node_to_sexp_schematic(node, sheet_tx=Tx(), version=20230409): if lib_id not in lib_symbols: lib_symbols[lib_id] = part + # Collect net names that have real (non-NetTerminal) stubbed pins on this + # sheet. NetTerminal labels are redundant for these nets since the pins + # will generate their own labels. + nets_with_real_pins = set() + for part in node.parts: + if isinstance(part, NetTerminal): + continue + for pin in part: + if pin.stub and pin.is_connected(): + nets_with_real_pins.add(pin.net.name) + # Generate part S-expressions. for part in node.parts: if isinstance(part, NetTerminal): - # NetTerminals become net labels. - label = net_label_to_sexp(part.pins[0], tx=tx, force=True) + pin = part.pins[0] + if pin.is_connected() and pin.net.name in nets_with_real_pins: + continue + label = net_label_to_sexp(pin, tx=tx, force=True) if label: elements.append(label) else: @@ -1149,14 +1437,58 @@ def node_to_sexp_schematic(node, sheet_tx=Tx(), version=20230409): for net, junctions in node.junctions.items(): elements.extend(junction_to_sexp(net, junctions, tx=tx)) - # Generate net labels for stubbed pins. + # Replace close 2-pin stubbed nets with direct wires instead of labels. + wired_pin_ids, direct_wires = _find_wireable_nets(node, tx) + elements.extend(direct_wires) + + # Connect co-linear power net pins with bus wires. + power_wires, bus_pin_ids = _gen_power_bus_wires(node, tx) + elements.extend(power_wires) + wired_pin_ids.update(bus_pin_ids) + + # Generate T-junction wires from staggered snap placement. + for tjw in getattr(node, "_tjunction_wires", []): + x1_mil, y1_mil, x2_mil, y2_mil = tjw + p1 = Point(x1_mil, y1_mil) * tx + p2 = Point(x2_mil, y2_mil) * tx + x1, y1 = _round_mm(p1.x), _round_mm(p1.y) + x2, y2 = _round_mm(p2.x), _round_mm(p2.y) + elements.append( + Sexp( + [ + "wire", + ["pts", ["xy", x1, y1], ["xy", x2, y2]], + ["stroke", ["width", 0], ["type", "default"]], + ["uuid", _gen_uuid(f"tjwire:{x1}:{y1}:{x2}:{y2}")], + ] + ) + ) + + # Suppress labels for staggered T-junction signal pins (connected by wire). + wired_pin_ids.update(getattr(node, "_tjunction_suppressed_pins", set())) + + # Generate net labels for stubbed pins (skip pins that got direct wires). + power_names = _get_power_symbol_names() for part in node.parts: if isinstance(part, NetTerminal): continue for pin in part: + if id(pin) in wired_pin_ids: + continue label = net_label_to_sexp(pin, tx=tx) if label: elements.append(label) + elif ( + len(part.pins) == 2 + and not pin.stub + and pin.is_connected() + and pin.net.name in power_names + ): + label = net_label_to_sexp(pin, tx=tx, force=True) + if label: + elements.append(label) + + elements.extend(_gen_no_connect_flags(node, tx)) if node.flattened: # Return elements for inclusion in the parent sheet. @@ -1216,6 +1548,108 @@ def node_to_sexp_schematic(node, sheet_tx=Tx(), version=20230409): return [create_hierarchical_sheet_sexp(node, sheet_tx)] +# --------------------------------------------------------------------------- +# Label deconfliction +# --------------------------------------------------------------------------- + + +def _deconflict_labels(elements, node, sheet_tx): + """Offset labels that overlap component bodies. + + After labels are generated with correct rotation, some may still overlap + neighbouring components (not their own). This pass detects label-to- + component bbox intersections and nudges the label along its direction + axis to clear the overlap. + """ + from skidl.geometry import BBox + from skidl.tools.kicad9.constants import LABEL_DECONFLICT_MARGIN + + MARGIN_MM = LABEL_DECONFLICT_MARGIN * MILS_TO_MM + + # Build component bboxes in sheet (mm) coordinates. + comp_bboxes = [] + for part in node.parts: + if isinstance(part, NetTerminal): + continue + bbox = getattr(part, "place_bbox", None) or getattr(part, "lbl_bbox", None) + if bbox is None: + continue + part_tx = getattr(part, "tx", Tx()) + tx = part_tx * sheet_tx + tb = bbox * tx + # Normalise so min < max after transforms. + comp_bboxes.append( + BBox( + Point(min(tb.min.x, tb.max.x), min(tb.min.y, tb.max.y)), + Point(max(tb.min.x, tb.max.x), max(tb.min.y, tb.max.y)), + ) + ) + + if not comp_bboxes: + return + + # Direction unit vectors for each KiCad label angle. + from math import cos, sin, radians + + def _label_dir(angle_deg): + r = radians(angle_deg) + return (cos(r), sin(r)) + + # Approximate label bbox: 10mm wide × 2mm tall, extending in the label + # direction from the anchor point. This is conservative; exact size + # depends on net name length. + LABEL_W = 10.0 # mm + LABEL_H = 2.0 # mm (half-height each side of anchor) + + for elem in elements: + if not hasattr(elem, "__getitem__") or len(elem) < 1: + continue + if elem[0] != "global_label": + continue + + # Find the (at x y angle) sub-expression. + at_sexp = None + for sub in elem: + if hasattr(sub, "__getitem__") and len(sub) > 0 and sub[0] == "at": + at_sexp = sub + break + if at_sexp is None or len(at_sexp) < 4: + continue + + lx, ly, langle = float(at_sexp[1]), float(at_sexp[2]), int(at_sexp[3]) + dx, dy = _label_dir(langle) + + # Build label bbox: extends LABEL_W in the label direction. + x_end = lx + dx * LABEL_W + y_end = ly + dy * LABEL_W + lbl_min_x = min(lx, x_end) - LABEL_H / 2 + lbl_max_x = max(lx, x_end) + LABEL_H / 2 + lbl_min_y = min(ly, y_end) - LABEL_H / 2 + lbl_max_y = max(ly, y_end) + LABEL_H / 2 + lbl_bbox = BBox(Point(lbl_min_x, lbl_min_y), Point(lbl_max_x, lbl_max_y)) + + for cb in comp_bboxes: + if not lbl_bbox.intersects(cb): + continue + + # Offset label along its direction to clear the component bbox. + if abs(dx) > abs(dy): + # Horizontal label — offset in x. + if dx > 0: + offset = cb.max.x - lx + MARGIN_MM + else: + offset = cb.min.x - lx - MARGIN_MM + at_sexp[1] = _round_mm(lx + offset) + else: + # Vertical label — offset in y. + if dy > 0: + offset = cb.max.y - ly + MARGIN_MM + else: + offset = cb.min.y - ly - MARGIN_MM + at_sexp[2] = _round_mm(ly + offset) + break # Only resolve the first overlap per label. + + # --------------------------------------------------------------------------- # Top-level schematic assembly + write # --------------------------------------------------------------------------- @@ -1257,10 +1691,22 @@ def write_top_schematic(circuit, node, filepath, top_name, title, version=202304 if lib_id not in lib_symbols: lib_symbols[lib_id] = part + # Collect net names with real stubbed pins on the root sheet. + nets_with_real_pins = set() + for part in node.parts: + if isinstance(part, NetTerminal): + continue + for pin in part: + if pin.stub and pin.is_connected(): + nets_with_real_pins.add(pin.net.name) + # Generate part S-expressions for root-level parts. for part in node.parts: if isinstance(part, NetTerminal): - label = net_label_to_sexp(part.pins[0], tx=sheet_tx, force=True) + pin = part.pins[0] + if pin.is_connected() and pin.net.name in nets_with_real_pins: + continue + label = net_label_to_sexp(pin, tx=sheet_tx, force=True) if label: elements.append(label) else: @@ -1275,14 +1721,35 @@ def write_top_schematic(circuit, node, filepath, top_name, title, version=202304 for net, junctions in node.junctions.items(): elements.extend(junction_to_sexp(net, junctions, tx=sheet_tx)) - # Generate net labels for stubbed pins. + # Replace close 2-pin stubbed nets with direct wires instead of labels. + wired_pin_ids, direct_wires = _find_wireable_nets(node, sheet_tx) + elements.extend(direct_wires) + + # Generate net labels for stubbed pins (skip pins that got direct wires). + power_names = _get_power_symbol_names() for part in node.parts: if isinstance(part, NetTerminal): continue for pin in part: + if id(pin) in wired_pin_ids: + continue label = net_label_to_sexp(pin, tx=sheet_tx) if label: elements.append(label) + elif ( + len(part.pins) == 2 + and not pin.stub + and pin.is_connected() + and pin.net.name in power_names + ): + label = net_label_to_sexp(pin, tx=sheet_tx, force=True) + if label: + elements.append(label) + + elements.extend(_gen_no_connect_flags(node, sheet_tx)) + + # Post-process: offset any labels that overlap component bodies. + _deconflict_labels(elements, node, sheet_tx) # Build lib_symbols section. lib_symbols_sexp = Sexp(["lib_symbols"])