feat: multi-sheet net connectivity + sexp-based parsing reliability

Adds robust multi-sheet (hierarchical) net connectivity for KiCad
schematics and switches the wire/label parsing to a direct sexpdata
pipeline that bypasses kicad-skip's collection iteration, which was
silently dropping wires, labels, and symbol instances on real-world
schematics.

python/commands/wire_connectivity.py
  - New sexpdata helpers: _load_sexp, _parse_wires_sexp,
    _parse_labels_sexp, _parse_symbol_instances_sexp,
    _parse_hierarchical_labels_sexp, _discover_sub_sheets.
  - _build_adjacency now detects T-junctions (endpoint landing on
    another wire's interior segment) so adjacency captures connections
    KiCad doesn't represent as separate wire segments.
  - _find_connected_wires gains an interior-segment fallback so labels
    placed mid-wire still seed BFS correctly.
  - _parse_virtual_connections gathers label / global_label /
    hierarchical_label and power-symbol pin positions, with a
    kicad-skip fallback for unit tests that mock the schematic.
  - _find_pins_on_net rebuilds pin positions from sexpdata symbol
    instances (with mirror_x/mirror_y/rotation handling) and uses a
    plus/minus 1 IU tolerance for floating-point edge cases.
  - get_connections_for_net walks the top sheet plus every recursively
    discovered sub-sheet, deduping pins across sheets.

python/commands/pin_locator.py
  - lib_id matching now falls back to a bare-name + unit-suffix match
    so instances like "stat-tis-custom:BAT_18650" resolve to
    lib_symbols entries like "BAT_18650_3".
  - Pin position math now y-negates lib_symbols coords, applies
    mirror_x/mirror_y in local coords before rotation, and propagates
    the same transform into get_pin_orientation so downstream callers
    get a correct outward angle for mirrored symbols.

python/commands/connection_schematic.py
  - generate_netlist now collects nets from both label and
    global_label and routes them through get_connections_for_net so
    netlists reflect cross-sheet connectivity instead of single-sheet
    label-only nets.

python/kicad_interface.py
  - list_schematic_nets aggregates net names across the top sheet and
    all sub-sheets via the sexp helpers, then resolves connections
    using get_connections_for_net.
  - get_net_connections delegates to get_connections_for_net for
    consistent multi-sheet results.
This commit is contained in:
William Viana
2026-04-19 23:42:15 -07:00
parent 5b7434fdef
commit 046f33d876
4 changed files with 599 additions and 82 deletions

View File

@@ -126,16 +126,42 @@ class PinLocator:
logger.error("No lib_symbols section found in schematic")
return {}
# Find the specific symbol definition
for item in lib_symbols[1:]: # Skip 'lib_symbols' itself
if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"):
symbol_name = str(item[1]).strip('"')
if symbol_name == lib_id:
# Found the symbol, parse pins
pins = self.parse_symbol_definition(item)
self.pin_definition_cache[cache_key] = pins
logger.info(f"Extracted {len(pins)} pins from {lib_id}")
return pins
# Find the specific symbol definition.
# KiCad lib_symbols may use a different name than the instance lib_id:
# instance lib_id: "stat-tis-custom:BAT_18650"
# lib_symbols name: "BAT_18650_3" (prefix stripped, unit suffix added)
# Strategy: exact match first, then bare-name prefix match.
bare_name = lib_id.split(":")[-1] if ":" in lib_id else lib_id
best_match = None
for item in lib_symbols[1:]:
if not (isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol")):
continue
symbol_name = str(item[1]).strip('"')
if symbol_name == lib_id:
best_match = item
break
if best_match is None:
sn_bare = symbol_name.split(":")[-1] if ":" in symbol_name else symbol_name
if sn_bare == bare_name or (
sn_bare.startswith(bare_name)
and len(sn_bare) > len(bare_name)
and sn_bare[len(bare_name)] == "_"
and sn_bare[len(bare_name) + 1 :].isdigit()
):
best_match = item
if best_match is not None:
matched_name = str(best_match[1]).strip('"')
pins = self.parse_symbol_definition(best_match)
self.pin_definition_cache[cache_key] = pins
if matched_name != lib_id:
logger.info(
f"Matched {lib_id} → lib_symbols '{matched_name}' ({len(pins)} pins)"
)
else:
logger.info(f"Extracted {len(pins)} pins from {lib_id}")
return pins
logger.warning(f"Symbol {lib_id} not found in lib_symbols")
return {}
@@ -228,8 +254,26 @@ class PinLocator:
else:
return None
# Pin definition angle + symbol rotation = absolute outward direction
mirror_x = False
mirror_y = False
if hasattr(target_symbol, "mirror"):
mirror_val = (
str(target_symbol.mirror.value)
if hasattr(target_symbol.mirror, "value")
else ""
)
if mirror_val == "x":
mirror_x = True
elif mirror_val == "y":
mirror_y = True
pin_def_angle = pins[pin_number].get("angle", 0)
# Y-negate flips the angle across the x-axis
pin_def_angle = (360 - pin_def_angle) % 360
if mirror_x:
pin_def_angle = (360 - pin_def_angle) % 360
if mirror_y:
pin_def_angle = (180 - pin_def_angle) % 360
absolute_angle = (pin_def_angle + symbol_rotation) % 360
return absolute_angle
@@ -270,12 +314,25 @@ class PinLocator:
logger.error(f"Symbol {symbol_reference} not found in schematic")
return None
# Get symbol position and rotation
# Get symbol position, rotation, and mirror state
symbol_at = target_symbol.at.value
symbol_x = float(symbol_at[0])
symbol_y = float(symbol_at[1])
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
mirror_x = False
mirror_y = False
if hasattr(target_symbol, "mirror"):
mirror_val = (
str(target_symbol.mirror.value)
if hasattr(target_symbol.mirror, "value")
else ""
)
if mirror_val == "x":
mirror_x = True
elif mirror_val == "y":
mirror_y = True
# Get symbol lib_id
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
if not lib_id:
@@ -283,7 +340,8 @@ class PinLocator:
return None
logger.debug(
f"Symbol {symbol_reference}: pos=({symbol_x}, {symbol_y}), rot={symbol_rotation}, lib_id={lib_id}"
f"Symbol {symbol_reference}: pos=({symbol_x}, {symbol_y}), rot={symbol_rotation}, "
f"mirror_x={mirror_x}, mirror_y={mirror_y}, lib_id={lib_id}"
)
# Get pin definitions for this symbol
@@ -319,10 +377,21 @@ class PinLocator:
logger.debug(f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})")
# lib_symbols uses y-up; schematic uses y-down
pin_rel_y = -pin_rel_y
# Mirror in local coords after y-negate (KiCad transform order)
# mirror_x = flip across X axis → negate y
# mirror_y = flip across Y axis → negate x
if mirror_x:
pin_rel_y = -pin_rel_y
if mirror_y:
pin_rel_x = -pin_rel_x
# Apply symbol rotation to pin position
if symbol_rotation != 0:
pin_rel_x, pin_rel_y = self.rotate_point(pin_rel_x, pin_rel_y, symbol_rotation)
logger.debug(f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})")
logger.debug(f"After transform (y-neg/mirror/rot): ({pin_rel_x}, {pin_rel_y})")
# Calculate absolute position
abs_x = symbol_x + pin_rel_x