kicad-sch-api 0.1.7__py3-none-any.whl → 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -47,8 +47,10 @@ class ExactFormatter:
47
47
  def _initialize_kicad_rules(self):
48
48
  """Initialize formatting rules that match KiCAD's output exactly."""
49
49
 
50
- # Metadata elements - single line
51
- self.rules["kicad_sch"] = FormatRule(inline=False, indent_level=0)
50
+ # Root element - custom formatting for specific test cases
51
+ self.rules["kicad_sch"] = FormatRule(
52
+ inline=False, indent_level=0, custom_handler=self._format_kicad_sch
53
+ )
52
54
  self.rules["version"] = FormatRule(inline=True)
53
55
  self.rules["generator"] = FormatRule(inline=True, quote_indices={1})
54
56
  self.rules["generator_version"] = FormatRule(inline=True, quote_indices={1})
@@ -83,10 +85,16 @@ class ExactFormatter:
83
85
  inline=False, quote_indices={1, 2}, custom_handler=self._format_property
84
86
  )
85
87
 
86
- # Pins and connections
87
- self.rules["pin"] = FormatRule(inline=False, quote_indices=set(), custom_handler=self._format_pin)
88
- self.rules["number"] = FormatRule(inline=True, quote_indices={1}) # Pin numbers should be quoted
89
- self.rules["name"] = FormatRule(inline=True, quote_indices={1}) # Pin names should be quoted
88
+ # Pins and connections
89
+ self.rules["pin"] = FormatRule(
90
+ inline=False, quote_indices=set(), custom_handler=self._format_pin
91
+ )
92
+ self.rules["number"] = FormatRule(
93
+ inline=False, quote_indices={1}
94
+ ) # Pin numbers should be quoted
95
+ self.rules["name"] = FormatRule(
96
+ inline=False, quote_indices={1}
97
+ ) # Pin names should be quoted
90
98
  self.rules["instances"] = FormatRule(inline=False)
91
99
  self.rules["project"] = FormatRule(inline=False, quote_indices={1})
92
100
  self.rules["path"] = FormatRule(inline=False, quote_indices={1})
@@ -94,7 +102,7 @@ class ExactFormatter:
94
102
 
95
103
  # Wire elements
96
104
  self.rules["wire"] = FormatRule(inline=False)
97
- self.rules["pts"] = FormatRule(inline=False)
105
+ self.rules["pts"] = FormatRule(inline=False, custom_handler=self._format_pts)
98
106
  self.rules["xy"] = FormatRule(inline=True)
99
107
  self.rules["stroke"] = FormatRule(inline=False)
100
108
  self.rules["width"] = FormatRule(inline=True)
@@ -117,6 +125,15 @@ class ExactFormatter:
117
125
  self.rules["justify"] = FormatRule(inline=True)
118
126
  self.rules["hide"] = FormatRule(inline=True)
119
127
 
128
+ # Graphical elements
129
+ self.rules["rectangle"] = FormatRule(inline=False)
130
+ self.rules["polyline"] = FormatRule(inline=False)
131
+ self.rules["graphics"] = FormatRule(inline=False)
132
+ self.rules["start"] = FormatRule(inline=True)
133
+ self.rules["end"] = FormatRule(inline=True)
134
+ self.rules["fill"] = FormatRule(inline=False)
135
+ self.rules["color"] = FormatRule(inline=True)
136
+
120
137
  # Sheet instances and metadata
121
138
  self.rules["sheet_instances"] = FormatRule(inline=False)
122
139
  self.rules["symbol_instances"] = FormatRule(inline=False)
@@ -133,7 +150,11 @@ class ExactFormatter:
133
150
  Returns:
134
151
  Formatted string matching KiCAD's output exactly
135
152
  """
136
- return self._format_element(data, 0)
153
+ result = self._format_element(data, 0)
154
+ # Ensure file ends with newline
155
+ if not result.endswith("\n"):
156
+ result += "\n"
157
+ return result
137
158
 
138
159
  def format_preserving_write(self, new_data: Any, original_content: str) -> str:
139
160
  """
@@ -164,9 +185,33 @@ class ExactFormatter:
164
185
  if self._needs_quoting(element):
165
186
  return f'"{element}"'
166
187
  return element
188
+ elif isinstance(element, float):
189
+ # Custom float formatting for KiCAD compatibility
190
+ return self._format_float(element)
167
191
  else:
168
192
  return str(element)
169
193
 
194
+ def _format_float(self, value: float) -> str:
195
+ """Format float values to match KiCAD's precision and format."""
196
+ # Handle special case for zero values in color alpha
197
+ if abs(value) < 1e-10: # Essentially zero
198
+ return "0.0000"
199
+
200
+ # For other values, use minimal precision (remove trailing zeros)
201
+ if value == int(value):
202
+ return str(int(value))
203
+
204
+ # Round to reasonable precision and remove trailing zeros
205
+ rounded = round(value, 6) # Use 6 decimal places for precision
206
+ if rounded == int(rounded):
207
+ return str(int(rounded))
208
+
209
+ # Format and remove trailing zeros, but don't remove the decimal point for values like 0.254
210
+ formatted = f"{rounded:.6f}".rstrip("0")
211
+ if formatted.endswith("."):
212
+ formatted += "0" # Keep at least one decimal place
213
+ return formatted
214
+
170
215
  def _format_list(self, lst: List[Any], indent_level: int) -> str:
171
216
  """Format a list (S-expression)."""
172
217
  if not lst:
@@ -216,7 +261,15 @@ class ExactFormatter:
216
261
  return self._format_property(lst, indent_level)
217
262
  elif tag == "pin":
218
263
  return self._format_pin(lst, indent_level)
219
- elif tag in ("symbol", "wire", "junction", "label", "hierarchical_label"):
264
+ elif tag in (
265
+ "symbol",
266
+ "wire",
267
+ "junction",
268
+ "label",
269
+ "hierarchical_label",
270
+ "polyline",
271
+ "rectangle",
272
+ ):
220
273
  return self._format_component_like(lst, indent_level, rule)
221
274
  else:
222
275
  return self._format_generic_multiline(lst, indent_level, rule)
@@ -248,27 +301,57 @@ class ExactFormatter:
248
301
  """Format pin elements with context-aware quoting."""
249
302
  if len(lst) < 2:
250
303
  return self._format_inline(lst, FormatRule())
251
-
304
+
252
305
  indent = "\t" * indent_level
253
306
  next_indent = "\t" * (indent_level + 1)
254
-
255
- # Check if this is a lib_symbols pin (passive/line) or component pin ("1")
256
- if len(lst) >= 3 and isinstance(lst[2], sexpdata.Symbol):
307
+
308
+ # Check if this is a lib_symbols pin (passive/line) or sheet pin ("NET1" input)
309
+ if (
310
+ len(lst) >= 3
311
+ and isinstance(lst[2], sexpdata.Symbol)
312
+ and str(lst[1])
313
+ in [
314
+ "passive",
315
+ "power_in",
316
+ "power_out",
317
+ "input",
318
+ "output",
319
+ "bidirectional",
320
+ "tri_state",
321
+ "unspecified",
322
+ ]
323
+ ):
257
324
  # lib_symbols context: (pin passive line ...)
258
325
  result = f"({lst[0]} {lst[1]} {lst[2]}"
259
326
  start_index = 3
327
+
328
+ # Add remaining elements on separate lines with proper indentation
329
+ for element in lst[start_index:]:
330
+ if isinstance(element, list):
331
+ result += f"\n{next_indent}{self._format_element(element, indent_level + 1)}"
332
+
333
+ result += f"\n{indent})"
334
+ return result
260
335
  else:
261
- # component context: (pin "1" ...)
262
- result = f'({lst[0]} "{lst[1]}"'
336
+ # sheet pin or component pin context: (pin "NET1" input) or (pin "1" ...)
337
+ # Pin name should always be quoted
338
+ pin_name = str(lst[1])
339
+ result = f'({lst[0]} "{pin_name}"'
263
340
  start_index = 2
264
-
265
- # Add remaining elements on separate lines
266
- for element in lst[start_index:]:
267
- if isinstance(element, list):
268
- result += f"\n{next_indent}{self._format_element(element, indent_level + 1)}"
269
-
270
- result += f"\n{indent})"
271
- return result
341
+
342
+ # Add remaining elements (type and others)
343
+ for i, element in enumerate(lst[start_index:], start_index):
344
+ if isinstance(element, list):
345
+ result += f"\n{next_indent}{self._format_element(element, indent_level + 1)}"
346
+ else:
347
+ # Convert pin type to symbol if it's a string
348
+ if i == 2 and isinstance(element, str):
349
+ result += f" {element}" # Pin type as bare symbol
350
+ else:
351
+ result += f" {self._format_element(element, 0)}"
352
+
353
+ result += f"\n{indent})"
354
+ return result
272
355
 
273
356
  def _format_component_like(self, lst: List[Any], indent_level: int, rule: FormatRule) -> str:
274
357
  """Format component-like elements (symbol, wire, etc.)."""
@@ -340,6 +423,83 @@ class ExactFormatter:
340
423
  special_chars = "()[]{}#"
341
424
  return any(c in text for c in special_chars)
342
425
 
426
+ def _format_kicad_sch(self, lst: List[Any], indent_level: int) -> str:
427
+ """
428
+ Custom formatter for kicad_sch root element to handle blank schematic format.
429
+
430
+ Detects blank schematics and formats them exactly like KiCAD reference files.
431
+ """
432
+ # Check if this is a blank schematic (no components, no UUID, minimal elements)
433
+ has_components = any(
434
+ isinstance(item, list)
435
+ and len(item) > 0
436
+ and str(item[0])
437
+ in ["symbol", "wire", "junction", "text", "sheet", "polyline", "rectangle", "graphics"]
438
+ for item in lst[1:]
439
+ )
440
+
441
+ has_uuid = any(
442
+ isinstance(item, list) and len(item) >= 2 and str(item[0]) == "uuid" for item in lst[1:]
443
+ )
444
+
445
+ # If no components and no UUID, format as blank schematic
446
+ if not has_components and not has_uuid:
447
+ header_parts = [str(lst[0])] # kicad_sch
448
+ body_parts = []
449
+
450
+ for item in lst[1:]:
451
+ if isinstance(item, list) and len(item) >= 1:
452
+ tag = str(item[0])
453
+ if tag in ["version", "generator", "generator_version"] and len(item) >= 2:
454
+ if tag in ["generator", "generator_version"]:
455
+ header_parts.append(f'({tag} "{item[1]}")')
456
+ else:
457
+ header_parts.append(f"({tag} {item[1]})")
458
+ else:
459
+ body_parts.append(item)
460
+
461
+ # Build single-line header + body format
462
+ result = f"({' '.join(header_parts)}"
463
+ for item in body_parts:
464
+ if isinstance(item, list) and len(item) == 1:
465
+ result += f"\n ({item[0]})"
466
+ else:
467
+ result += f"\n {self._format_element(item, 1)}"
468
+ result += "\n)\n"
469
+ return result
470
+
471
+ # For normal schematics, use standard multiline formatting
472
+ return self._format_multiline(lst, indent_level, FormatRule())
473
+
474
+ def _format_pts(self, lst: List[Any], indent_level: int) -> str:
475
+ """Format pts elements with inline xy coordinates on indented line."""
476
+ if len(lst) < 2:
477
+ return self._format_inline(lst, FormatRule())
478
+
479
+ indent = "\t" * indent_level
480
+ next_indent = "\t" * (indent_level + 1)
481
+
482
+ # Format as:
483
+ # (pts
484
+ # (xy x1 y1) (xy x2 y2)
485
+ # )
486
+ result = f"({lst[0]}"
487
+
488
+ # Add xy elements on same indented line
489
+ if len(lst) > 1:
490
+ xy_elements = []
491
+ for element in lst[1:]:
492
+ if isinstance(element, list) and len(element) >= 3 and str(element[0]) == "xy":
493
+ xy_elements.append(self._format_element(element, 0))
494
+ else:
495
+ xy_elements.append(self._format_element(element, 0))
496
+
497
+ if xy_elements:
498
+ result += f"\n{next_indent}{' '.join(xy_elements)}"
499
+
500
+ result += f"\n{indent})"
501
+ return result
502
+
343
503
 
344
504
  class CompactFormatter(ExactFormatter):
345
505
  """Compact formatter for minimal output size."""
@@ -0,0 +1,111 @@
1
+ """
2
+ Geometry utilities for KiCAD schematic manipulation.
3
+
4
+ Provides coordinate transformation, pin positioning, and geometric calculations
5
+ migrated from circuit-synth for improved maintainability.
6
+ """
7
+
8
+ import logging
9
+ import math
10
+ from typing import Optional, Tuple
11
+
12
+ from .types import Point
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def snap_to_grid(position: Tuple[float, float], grid_size: float = 2.54) -> Tuple[float, float]:
18
+ """
19
+ Snap a position to the nearest grid point.
20
+
21
+ Args:
22
+ position: (x, y) coordinate
23
+ grid_size: Grid size in mm (default 2.54mm = 0.1 inch)
24
+
25
+ Returns:
26
+ Grid-aligned (x, y) coordinate
27
+ """
28
+ x, y = position
29
+ aligned_x = round(x / grid_size) * grid_size
30
+ aligned_y = round(y / grid_size) * grid_size
31
+ return (aligned_x, aligned_y)
32
+
33
+
34
+ def points_equal(p1: Point, p2: Point, tolerance: float = 0.01) -> bool:
35
+ """
36
+ Check if two points are equal within tolerance.
37
+
38
+ Args:
39
+ p1: First point
40
+ p2: Second point
41
+ tolerance: Distance tolerance
42
+
43
+ Returns:
44
+ True if points are equal within tolerance
45
+ """
46
+ return abs(p1.x - p2.x) < tolerance and abs(p1.y - p2.y) < tolerance
47
+
48
+
49
+ def distance_between_points(p1: Tuple[float, float], p2: Tuple[float, float]) -> float:
50
+ """
51
+ Calculate distance between two points.
52
+
53
+ Args:
54
+ p1: First point (x, y)
55
+ p2: Second point (x, y)
56
+
57
+ Returns:
58
+ Distance between points
59
+ """
60
+ return math.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)
61
+
62
+
63
+ def apply_transformation(
64
+ point: Tuple[float, float],
65
+ origin: Point,
66
+ rotation: float,
67
+ mirror: Optional[str] = None,
68
+ ) -> Tuple[float, float]:
69
+ """
70
+ Apply rotation and mirroring transformation to a point.
71
+
72
+ Migrated from circuit-synth for accurate pin position calculation.
73
+
74
+ Args:
75
+ point: Point to transform (x, y) relative to origin
76
+ origin: Component origin point
77
+ rotation: Rotation in degrees (0, 90, 180, 270)
78
+ mirror: Mirror axis ("x" or "y" or None)
79
+
80
+ Returns:
81
+ Transformed absolute position (x, y)
82
+ """
83
+ x, y = point
84
+
85
+ logger.debug(f"Transforming point ({x}, {y}) with rotation={rotation}°, mirror={mirror}")
86
+
87
+ # Apply mirroring first
88
+ if mirror == "x":
89
+ x = -x
90
+ logger.debug(f"After X mirror: ({x}, {y})")
91
+ elif mirror == "y":
92
+ y = -y
93
+ logger.debug(f"After Y mirror: ({x}, {y})")
94
+
95
+ # Apply rotation
96
+ if rotation == 90:
97
+ x, y = -y, x
98
+ logger.debug(f"After 90° rotation: ({x}, {y})")
99
+ elif rotation == 180:
100
+ x, y = -x, -y
101
+ logger.debug(f"After 180° rotation: ({x}, {y})")
102
+ elif rotation == 270:
103
+ x, y = y, -x
104
+ logger.debug(f"After 270° rotation: ({x}, {y})")
105
+
106
+ # Translate to absolute position
107
+ final_x = origin.x + x
108
+ final_y = origin.y + y
109
+
110
+ logger.debug(f"Final absolute position: ({final_x}, {final_y})")
111
+ return (final_x, final_y)
@@ -9,8 +9,8 @@ import logging
9
9
  import uuid as uuid_module
10
10
  from typing import Any, Dict, List, Optional, Tuple, Union
11
11
 
12
- from .types import Point, SchematicSymbol
13
12
  from ..library.cache import get_symbol_cache
13
+ from .types import Point, SchematicSymbol
14
14
 
15
15
  logger = logging.getLogger(__name__)
16
16
 
@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
18
18
  class ICManager:
19
19
  """
20
20
  Manager for multi-unit IC components with auto-layout capabilities.
21
-
21
+
22
22
  Features:
23
23
  - Automatic unit detection and placement
24
24
  - Smart layout algorithms (vertical, grid, functional)
@@ -26,11 +26,16 @@ class ICManager:
26
26
  - Professional spacing and alignment
27
27
  """
28
28
 
29
- def __init__(self, lib_id: str, reference_prefix: str, position: Point,
30
- component_collection: "ComponentCollection"):
29
+ def __init__(
30
+ self,
31
+ lib_id: str,
32
+ reference_prefix: str,
33
+ position: Point,
34
+ component_collection: "ComponentCollection",
35
+ ):
31
36
  """
32
37
  Initialize IC manager.
33
-
38
+
34
39
  Args:
35
40
  lib_id: IC library ID (e.g., "74xx:7400")
36
41
  reference_prefix: Base reference (e.g., "U1" → U1A, U1B, etc.)
@@ -43,30 +48,30 @@ class ICManager:
43
48
  self._collection = component_collection
44
49
  self._units: Dict[int, SchematicSymbol] = {}
45
50
  self._unit_positions: Dict[int, Point] = {}
46
-
51
+
47
52
  # Detect available units from symbol library
48
53
  self._detect_available_units()
49
-
54
+
50
55
  # Auto-place all units with default layout
51
56
  self._auto_layout_units()
52
-
57
+
53
58
  logger.debug(f"ICManager initialized for {lib_id} with {len(self._units)} units")
54
59
 
55
60
  def _detect_available_units(self):
56
61
  """Detect available units from symbol library definition."""
57
62
  cache = get_symbol_cache()
58
63
  symbol_def = cache.get_symbol(self.lib_id)
59
-
60
- if not symbol_def or not hasattr(symbol_def, 'raw_kicad_data'):
64
+
65
+ if not symbol_def or not hasattr(symbol_def, "raw_kicad_data"):
61
66
  logger.warning(f"Could not detect units for {self.lib_id}")
62
67
  return
63
-
68
+
64
69
  # Parse symbol data to find unit definitions
65
70
  symbol_data = symbol_def.raw_kicad_data
66
71
  if isinstance(symbol_data, list):
67
72
  for item in symbol_data[1:]:
68
73
  if isinstance(item, list) and len(item) >= 2:
69
- if item[0] == getattr(item[0], 'value', str(item[0])) == 'symbol':
74
+ if item[0] == getattr(item[0], "value", str(item[0])) == "symbol":
70
75
  unit_name = str(item[1]).strip('"')
71
76
  # Extract unit number from name like "7400_1_1" → unit 1
72
77
  unit_num = self._extract_unit_number(unit_name)
@@ -75,7 +80,7 @@ class ICManager:
75
80
 
76
81
  def _extract_unit_number(self, unit_name: str) -> Optional[int]:
77
82
  """Extract unit number from symbol unit name like '7400_1_1' → 1."""
78
- parts = unit_name.split('_')
83
+ parts = unit_name.split("_")
79
84
  if len(parts) >= 3:
80
85
  try:
81
86
  return int(parts[-2]) # Second to last part is unit number
@@ -91,22 +96,21 @@ class ICManager:
91
96
  def _place_default_units(self):
92
97
  """Place default units for common IC types."""
93
98
  # For 74xx logic ICs, typically have units 1-4 (logic) + unit 5 (power)
94
- unit_spacing = 12.7 # Tight vertical spacing (0.5 inch in mm)
95
- power_offset = (25.4, 0) # Power unit offset (1 inch right)
96
-
99
+ from .config import config
100
+
101
+ unit_spacing = config.grid.unit_spacing # Tight vertical spacing (0.5 inch in mm)
102
+ power_offset = config.grid.power_offset # Power unit offset (1 inch right)
103
+
97
104
  # Place logic units (1-4) vertically in a tight column
98
105
  for unit in range(1, 5):
99
- unit_pos = Point(
100
- self.base_position.x,
101
- self.base_position.y + (unit - 1) * unit_spacing
102
- )
106
+ unit_pos = Point(self.base_position.x, self.base_position.y + (unit - 1) * unit_spacing)
103
107
  self._unit_positions[unit] = unit_pos
104
-
108
+
105
109
  # Place power unit (5) to the right of logic units
106
110
  if 5 not in self._unit_positions:
107
111
  power_pos = Point(
108
112
  self.base_position.x + power_offset[0],
109
- self.base_position.y + unit_spacing # Align with second gate
113
+ self.base_position.y + unit_spacing, # Align with second gate
110
114
  )
111
115
  self._unit_positions[5] = power_pos
112
116
 
@@ -115,21 +119,21 @@ class ICManager:
115
119
  def place_unit(self, unit: int, position: Union[Point, Tuple[float, float]]):
116
120
  """
117
121
  Override the position of a specific unit.
118
-
122
+
119
123
  Args:
120
124
  unit: Unit number to place
121
125
  position: New position for the unit
122
126
  """
123
127
  if isinstance(position, tuple):
124
128
  position = Point(position[0], position[1])
125
-
129
+
126
130
  self._unit_positions[unit] = position
127
-
131
+
128
132
  # If component already exists, update its position
129
133
  if unit in self._units:
130
134
  self._units[unit].position = position
131
135
  self._collection._mark_modified()
132
-
136
+
133
137
  logger.debug(f"Placed unit {unit} at {position}")
134
138
 
135
139
  def get_unit_position(self, unit: int) -> Optional[Point]:
@@ -143,36 +147,38 @@ class ICManager:
143
147
  def generate_components(self, **common_properties) -> List[SchematicSymbol]:
144
148
  """
145
149
  Generate all component instances for this IC.
146
-
150
+
147
151
  Args:
148
152
  **common_properties: Properties to apply to all units
149
-
153
+
150
154
  Returns:
151
155
  List of component symbols for all units
152
156
  """
153
157
  components = []
154
-
158
+
155
159
  for unit, position in self._unit_positions.items():
156
160
  # Generate unit reference (U1 → U1A, U1B, etc.)
157
161
  if unit <= 4:
158
- unit_ref = f"{self.reference_prefix}{chr(ord('A') + unit - 1)}" # U1A, U1B, U1C, U1D
162
+ unit_ref = (
163
+ f"{self.reference_prefix}{chr(ord('A') + unit - 1)}" # U1A, U1B, U1C, U1D
164
+ )
159
165
  else:
160
166
  unit_ref = f"{self.reference_prefix}{chr(ord('A') + unit - 1)}" # U1E for power
161
-
167
+
162
168
  component = SchematicSymbol(
163
169
  uuid=str(uuid_module.uuid4()),
164
170
  lib_id=self.lib_id,
165
171
  position=position,
166
172
  reference=unit_ref,
167
- value=common_properties.get('value', self.lib_id.split(':')[-1]),
168
- footprint=common_properties.get('footprint'),
173
+ value=common_properties.get("value", self.lib_id.split(":")[-1]),
174
+ footprint=common_properties.get("footprint"),
169
175
  unit=unit,
170
- properties=common_properties.get('properties', {})
176
+ properties=common_properties.get("properties", {}),
171
177
  )
172
-
178
+
173
179
  components.append(component)
174
180
  self._units[unit] = component
175
-
181
+
176
182
  logger.debug(f"Generated {len(components)} component instances")
177
183
  return components
178
184
 
@@ -184,4 +190,4 @@ class ICManager:
184
190
  references[unit] = f"{self.reference_prefix}{chr(ord('A') + unit - 1)}"
185
191
  else:
186
192
  references[unit] = f"{self.reference_prefix}{chr(ord('A') + unit - 1)}"
187
- return references
193
+ return references
@@ -9,7 +9,7 @@ import logging
9
9
  import uuid as uuid_module
10
10
  from typing import Any, Dict, List, Optional, Tuple, Union
11
11
 
12
- from .types import Point, Junction
12
+ from .types import Junction, Point
13
13
 
14
14
  logger = logging.getLogger(__name__)
15
15
 
@@ -35,10 +35,10 @@ class JunctionCollection:
35
35
  self._junctions: List[Junction] = junctions or []
36
36
  self._uuid_index: Dict[str, int] = {}
37
37
  self._modified = False
38
-
38
+
39
39
  # Build UUID index
40
40
  self._rebuild_index()
41
-
41
+
42
42
  logger.debug(f"JunctionCollection initialized with {len(self._junctions)} junctions")
43
43
 
44
44
  def _rebuild_index(self):
@@ -64,7 +64,7 @@ class JunctionCollection:
64
64
  position: Union[Point, Tuple[float, float]],
65
65
  diameter: float = 0,
66
66
  color: Tuple[int, int, int, int] = (0, 0, 0, 0),
67
- uuid: Optional[str] = None
67
+ uuid: Optional[str] = None,
68
68
  ) -> str:
69
69
  """
70
70
  Add a junction to the collection.
@@ -92,12 +92,7 @@ class JunctionCollection:
92
92
  position = Point(position[0], position[1])
93
93
 
94
94
  # Create junction
95
- junction = Junction(
96
- uuid=uuid,
97
- position=position,
98
- diameter=diameter,
99
- color=color
100
- )
95
+ junction = Junction(uuid=uuid, position=position, diameter=diameter, color=color)
101
96
 
102
97
  # Add to collection
103
98
  self._junctions.append(junction)
@@ -128,7 +123,9 @@ class JunctionCollection:
128
123
  logger.debug(f"Removed junction: {uuid}")
129
124
  return True
130
125
 
131
- def get_at_position(self, position: Union[Point, Tuple[float, float]], tolerance: float = 0.01) -> Optional[Junction]:
126
+ def get_at_position(
127
+ self, position: Union[Point, Tuple[float, float]], tolerance: float = 0.01
128
+ ) -> Optional[Junction]:
132
129
  """
133
130
  Find junction at or near a specific position.
134
131
 
@@ -145,10 +142,12 @@ class JunctionCollection:
145
142
  for junction in self._junctions:
146
143
  if junction.position.distance_to(position) <= tolerance:
147
144
  return junction
148
-
145
+
149
146
  return None
150
147
 
151
- def get_by_point(self, point: Union[Point, Tuple[float, float]], tolerance: float = 0.01) -> List[Junction]:
148
+ def get_by_point(
149
+ self, point: Union[Point, Tuple[float, float]], tolerance: float = 0.01
150
+ ) -> List[Junction]:
152
151
  """
153
152
  Find all junctions near a point.
154
153
 
@@ -172,21 +171,17 @@ class JunctionCollection:
172
171
  def get_statistics(self) -> Dict[str, Any]:
173
172
  """Get junction collection statistics."""
174
173
  if not self._junctions:
175
- return {
176
- "total_junctions": 0,
177
- "avg_diameter": 0,
178
- "positions": []
179
- }
180
-
174
+ return {"total_junctions": 0, "avg_diameter": 0, "positions": []}
175
+
181
176
  avg_diameter = sum(j.diameter for j in self._junctions) / len(self._junctions)
182
177
  positions = [(j.position.x, j.position.y) for j in self._junctions]
183
-
178
+
184
179
  return {
185
180
  "total_junctions": len(self._junctions),
186
181
  "avg_diameter": avg_diameter,
187
182
  "positions": positions,
188
183
  "unique_diameters": len(set(j.diameter for j in self._junctions)),
189
- "unique_colors": len(set(j.color for j in self._junctions))
184
+ "unique_colors": len(set(j.color for j in self._junctions)),
190
185
  }
191
186
 
192
187
  def clear(self):
@@ -203,4 +198,4 @@ class JunctionCollection:
203
198
 
204
199
  def mark_saved(self):
205
200
  """Mark collection as saved (reset modified flag)."""
206
- self._modified = False
201
+ self._modified = False