kcaa 0.2.0__tar.gz → 0.2.2__tar.gz

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.
Files changed (89) hide show
  1. {kcaa-0.2.0 → kcaa-0.2.2}/PKG-INFO +3 -2
  2. {kcaa-0.2.0 → kcaa-0.2.2}/README.md +1 -1
  3. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/resources/netlist_resources.py +9 -5
  4. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/router/grid_a_star.py +85 -45
  5. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/router/router.py +535 -121
  6. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/router/world_model.py +319 -32
  7. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/netlist_tools.py +51 -36
  8. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/pcb_edit_tools.py +14 -13
  9. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/pcb_group_tools.py +29 -29
  10. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/pcb_placement_helpers.py +10 -10
  11. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/pcb_placement_tools.py +5 -5
  12. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/pcb_query_tools.py +23 -15
  13. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/pcb_routing_tools.py +20 -15
  14. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/pcb_zone_tools.py +1 -1
  15. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/placement_helpers.py +5 -2
  16. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/schematic_group_tools.py +21 -11
  17. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/sheet_tools.py +2 -2
  18. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/symbol_edit_tools.py +126 -47
  19. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/wire_edit_tools.py +14 -12
  20. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/netlist_parser.py +266 -65
  21. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/pcb_board_utils.py +133 -28
  22. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/pcb_footprint_utils.py +4 -2
  23. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/pcb_library_utils.py +4 -0
  24. kcaa-0.2.2/kcaa/utils/skip_helpers.py +245 -0
  25. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/symbol_geometry.py +3 -3
  26. {kcaa-0.2.0 → kcaa-0.2.2}/kicad_plugin/README.md +3 -3
  27. {kcaa-0.2.0 → kcaa-0.2.2}/pyproject.toml +2 -1
  28. kcaa-0.2.0/kcaa/utils/skip_helpers.py +0 -196
  29. {kcaa-0.2.0 → kcaa-0.2.2}/.gitignore +0 -0
  30. {kcaa-0.2.0 → kcaa-0.2.2}/LICENSE +0 -0
  31. {kcaa-0.2.0 → kcaa-0.2.2}/README_CN.md +0 -0
  32. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/__init__.py +0 -0
  33. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/context.py +0 -0
  34. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/prompts/__init__.py +0 -0
  35. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/prompts/bom_prompts.py +0 -0
  36. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/prompts/drc_prompt.py +0 -0
  37. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/prompts/pattern_prompts.py +0 -0
  38. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/prompts/templates.py +0 -0
  39. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/resources/__init__.py +0 -0
  40. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/resources/bom_resources.py +0 -0
  41. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/resources/drc_resources.py +0 -0
  42. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/resources/files.py +0 -0
  43. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/resources/pattern_resources.py +0 -0
  44. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/resources/projects.py +0 -0
  45. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/router/__init__.py +0 -0
  46. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/router/a_star.py +0 -0
  47. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/router/path_postprocess.py +0 -0
  48. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/router/via_check.py +0 -0
  49. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/router/visibility_graph.py +0 -0
  50. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/server.py +0 -0
  51. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/__init__.py +0 -0
  52. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/analysis_tools.py +0 -0
  53. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/bom_tools.py +0 -0
  54. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/drc_tools.py +0 -0
  55. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/export_tools.py +0 -0
  56. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/kipy_tools.py +0 -0
  57. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/pattern_tools.py +0 -0
  58. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/pcb_library_tools.py +0 -0
  59. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/project_tools.py +0 -0
  60. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/skill_tools.py +0 -0
  61. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/symbol_tools.py +0 -0
  62. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/validation_tools.py +0 -0
  63. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/tools/version_tools.py +0 -0
  64. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/__init__.py +0 -0
  65. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/boundary_validator.py +0 -0
  66. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/component_utils.py +0 -0
  67. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/config.py +0 -0
  68. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/file_utils.py +0 -0
  69. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/footprint_database.py +0 -0
  70. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/footprint_index_manager.py +0 -0
  71. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/ipc_drc.py +0 -0
  72. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/kicad_api_detection.py +0 -0
  73. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/kicad_cli.py +0 -0
  74. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/kicad_utils.py +0 -0
  75. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/kipy_reload.py +0 -0
  76. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/net_settings.py +0 -0
  77. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/path_validator.py +0 -0
  78. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/pattern_recognition.py +0 -0
  79. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/pcb_design_rules.py +0 -0
  80. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/pcb_sexp_utils.py +0 -0
  81. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/schematic_sexp_utils.py +0 -0
  82. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/secure_subprocess.py +0 -0
  83. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/skip_compat.py +0 -0
  84. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/symbol_database.py +0 -0
  85. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/symbol_extractor.py +0 -0
  86. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/symbol_index_manager.py +0 -0
  87. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/symbol_index_reader.py +0 -0
  88. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/temp_dir_manager.py +0 -0
  89. {kcaa-0.2.0 → kcaa-0.2.2}/kcaa/utils/version_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kcaa
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Model Context Protocol (MCP) server for KiCad electronic design automation (EDA) files
5
5
  Project-URL: Homepage, https://github.com/paul356/KiCad-AI-Assistant
6
6
  Project-URL: Bug Tracker, https://github.com/paul356/KiCad-AI-Assistant/issues
@@ -24,6 +24,7 @@ Classifier: Topic :: Scientific/Engineering :: Electronic Design Automation (EDA
24
24
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
25
  Classifier: Typing :: Typed
26
26
  Requires-Python: >=3.10
27
+ Requires-Dist: certifi>=2026.2.25
27
28
  Requires-Dist: defusedxml>=0.7.0
28
29
  Requires-Dist: fastmcp>=2.0.0
29
30
  Requires-Dist: kicad-python>=0.7.1
@@ -298,7 +299,7 @@ kcaa
298
299
  |------|-------------|
299
300
  | `get_board_info` | Get basic PCB board information |
300
301
  | `list_footprints` | List all footprints placed on the board |
301
- | `get_footprint` | Get details of a single placed footprint |
302
+ | `get_footprint` | Get details of a placed footprint (pads, properties, Edge.Cuts geometry) |
302
303
  | `get_footprint_bbox` | Get the courtyard bounding box of a footprint |
303
304
  | `get_board_bounding_box` | Get the union bounding box of all footprints |
304
305
  | `list_nets` | List all nets on the board |
@@ -260,7 +260,7 @@ kcaa
260
260
  |------|-------------|
261
261
  | `get_board_info` | Get basic PCB board information |
262
262
  | `list_footprints` | List all footprints placed on the board |
263
- | `get_footprint` | Get details of a single placed footprint |
263
+ | `get_footprint` | Get details of a placed footprint (pads, properties, Edge.Cuts geometry) |
264
264
  | `get_footprint_bbox` | Get the courtyard bounding box of a footprint |
265
265
  | `get_board_bounding_box` | Get the union bounding box of all footprints |
266
266
  | `list_nets` | List all nets on the board |
@@ -7,7 +7,7 @@ import os
7
7
  from fastmcp import FastMCP
8
8
 
9
9
  from kcaa.utils.file_utils import get_project_files
10
- from kcaa.utils.netlist_parser import analyze_netlist, extract_netlist
10
+ from kcaa.utils.netlist_parser import analyze_netlist, extract_netlist, iter_component_pins
11
11
 
12
12
 
13
13
  def register_netlist_resources(mcp: FastMCP) -> None:
@@ -210,12 +210,16 @@ def register_netlist_resources(mcp: FastMCP) -> None:
210
210
 
211
211
  report += "\n"
212
212
 
213
- # Pins section
214
- if "pins" in component_info:
213
+ # Pins section — pins are nested per unit (issue #89); list them flat,
214
+ # tagging the unit only for multi-unit components.
215
+ units = component_info.get("units") or {}
216
+ if units:
217
+ multi_unit = len(units) > 1
215
218
  report += "## Pins\n\n"
216
219
 
217
- for pin in component_info["pins"]:
218
- report += f"- **Pin {pin['num']}**: {pin['name']}\n"
220
+ for unit, pin in iter_component_pins(component_info):
221
+ suffix = f" (unit {unit})" if multi_unit else ""
222
+ report += f"- **Pin {pin['num']}{suffix}**: {pin['name']}\n"
219
223
 
220
224
  report += "\n"
221
225
 
@@ -56,6 +56,14 @@ _DIRECTIONS = [
56
56
  _CARD_COST = 1.0
57
57
  _DIAG_COST = math.sqrt(2.0)
58
58
 
59
+ # Turn penalty (mm-equivalent) added when the path changes direction.
60
+ # 0.3 mm ≈ 3 cells at 0.1 mm resolution — discourages zigzag without
61
+ # blocking legitimate detours. Set to 0 to disable (pure shortest path).
62
+ _TURN_PENALTY = 0.3
63
+
64
+ # Number of movement directions (8-directional grid).
65
+ _N_DIRS = 8
66
+
59
67
  # Default via cost (mm added to the path for each via transition).
60
68
  _VIA_COST = 2.0
61
69
 
@@ -231,7 +239,9 @@ def _octile_dist(gx: int, gy: int, ex: int, ey: int) -> float:
231
239
  """Octile distance heuristic (admissible for 8-direction movement)."""
232
240
  dx = abs(gx - ex)
233
241
  dy = abs(gy - ey)
234
- return _CARD_COST * abs(dx - dy) + (_DIAG_COST - _CARD_COST) * min(dx, dy)
242
+ if dx > dy:
243
+ return _DIAG_COST * dy + (dx - dy)
244
+ return _DIAG_COST * dx + (dy - dx)
235
245
 
236
246
 
237
247
  def grid_a_star(
@@ -243,6 +253,7 @@ def grid_a_star(
243
253
  via_from: dict[int, set[int]] | None = None,
244
254
  via_cost: float = _VIA_COST,
245
255
  via_forbidden_zones: list | None = None,
256
+ turn_penalty: float = _TURN_PENALTY,
246
257
  ) -> AStarResult:
247
258
  """Run A* on one or more walkability grids.
248
259
 
@@ -265,6 +276,8 @@ def grid_a_star(
265
276
  via_cost: Distance-equivalent cost per via transition (mm).
266
277
  via_forbidden_zones: Optional list of ``shapely`` Polygon objects
267
278
  where vias are forbidden (e.g. start/end pad AABBs).
279
+ turn_penalty: Distance-equivalent cost added when the path
280
+ changes direction. 0 disables (pure shortest path).
268
281
 
269
282
  Returns:
270
283
  :class:`AStarResult` with ``path`` as ``list[(x, y, layer_idx)]``,
@@ -288,13 +301,21 @@ def grid_a_star(
288
301
  if not grids[end_layer_idx].is_free(ex, ey):
289
302
  return AStarResult(path=None)
290
303
 
291
- def _encode(gx: int, gy: int, li: int) -> int:
292
- return (gy * W + gx) * n_layers + li
304
+ # State encoding: (cell, layer, arrival_direction).
305
+ # arrival_direction is 0-7 (index into _DIRECTIONS) or _N_DIRS for
306
+ # the start node (no previous move). This lets A* distinguish
307
+ # reaching a cell from different directions and apply a turn penalty
308
+ # when the direction changes.
309
+ _NO_DIR = _N_DIRS # sentinel for "no previous direction"
310
+
311
+ def _encode(gx: int, gy: int, li: int, di: int) -> int:
312
+ return ((gy * W + gx) * n_layers + li) * (_N_DIRS + 1) + di
293
313
 
294
- start_id = _encode(sx, sy, start_layer_idx)
295
- end_id = _encode(ex, ey, end_layer_idx)
314
+ # The goal is any state at (ex, ey, end_layer) regardless of direction.
315
+ # We check for the cell-level match inside the loop.
316
+ start_id = _encode(sx, sy, start_layer_idx, _NO_DIR)
296
317
 
297
- g_score = {start_id: 0.0}
318
+ g_score: dict[int, float] = {start_id: 0.0}
298
319
  parent: dict[int, int | None] = {start_id: None}
299
320
  open_heap = [(_octile_dist(sx, sy, ex, ey), start_id)]
300
321
  closed: set[int] = set()
@@ -307,45 +328,55 @@ def grid_a_star(
307
328
  closed.add(cur_id)
308
329
  visited += 1
309
330
 
310
- if cur_id == end_id:
331
+ # Decode state.
332
+ di = cur_id % (_N_DIRS + 1)
333
+ rest = cur_id // (_N_DIRS + 1)
334
+ li = rest % n_layers
335
+ rest2 = rest // n_layers
336
+ cy = rest2 // W
337
+ cx = rest2 % W
338
+ cur_g = g_score[cur_id]
339
+ cur_grid = grids[li]
340
+
341
+ # Goal check: any direction at the target cell on the target layer.
342
+ if cx == ex and cy == ey and li == end_layer_idx:
311
343
  path: list[tuple[float, float, int]] = []
312
- nid = cur_id
344
+ nid: int | None = cur_id
313
345
  while nid is not None:
314
- li = nid % n_layers
315
- rest = nid // n_layers
316
- gy_p = rest // W
317
- gx_p = rest % W
346
+ rest_n = nid // (_N_DIRS + 1)
347
+ li_n = rest_n % n_layers
348
+ rest2_n = rest_n // n_layers
349
+ gy_p = rest2_n // W
350
+ gx_p = rest2_n % W
318
351
  wx, wy = ref.to_world(gx_p, gy_p)
319
- path.append((wx, wy, li))
352
+ path.append((wx, wy, li_n))
320
353
  nid = parent[nid]
321
354
  path.reverse()
322
355
 
323
- # Compute length from world-coordinate distance (ignore
324
- # layer — via cost is already baked into g_score).
325
356
  total_len = 0.0
326
357
  for i in range(1, len(path)):
327
358
  px, py, _ = path[i - 1]
328
- cx, cy, _ = path[i]
329
- total_len += math.hypot(cx - px, cy - py)
359
+ cx_p, cy_p, _ = path[i]
360
+ total_len += math.hypot(cx_p - px, cy_p - py)
330
361
  return AStarResult(path=path, cells_visited=visited, path_length_mm=total_len)
331
362
 
332
- li = cur_id % n_layers
333
- rest = cur_id // n_layers
334
- cy = rest // W
335
- cx = rest % W
336
- cur_g = g_score[cur_id]
337
- cur_grid = grids[li]
338
-
339
363
  # ---- Same-layer 8-dir moves ----
340
- for dx, dy, is_diag in _DIRECTIONS:
364
+ for dir_idx, (dx, dy, is_diag) in enumerate(_DIRECTIONS):
341
365
  nx, ny = cx + dx, cy + dy
342
366
  if not cur_grid.is_free(nx, ny):
343
367
  continue
344
- nid = _encode(nx, ny, li)
368
+ nid = _encode(nx, ny, li, dir_idx)
345
369
  if nid in closed:
346
370
  continue
347
371
  step = _DIAG_COST if is_diag else _CARD_COST
348
- tentative = cur_g + step
372
+ # Turn penalty: if this move's direction differs from the
373
+ # arrival direction (and we have a previous direction), add
374
+ # the penalty. 45° changes (cardinal→diagonal) cost the
375
+ # same as 90° changes — both are one turn.
376
+ tp = 0.0
377
+ if turn_penalty > 0 and di != _NO_DIR and di != dir_idx:
378
+ tp = turn_penalty
379
+ tentative = cur_g + step + tp
349
380
  if tentative < g_score.get(nid, float("inf")):
350
381
  g_score[nid] = tentative
351
382
  heapq.heappush(
@@ -364,7 +395,8 @@ def grid_a_star(
364
395
  wx, wy = ref.to_world(cx, cy)
365
396
  if any(z.contains(Point(wx, wy)) for z in via_forbidden_zones):
366
397
  continue
367
- nid = _encode(cx, cy, tgt_li)
398
+ # Via resets direction (layer change is not a "turn").
399
+ nid = _encode(cx, cy, tgt_li, _NO_DIR)
368
400
  if nid in closed:
369
401
  continue
370
402
  tentative = cur_g + via_cost
@@ -422,6 +454,7 @@ def hierarchical_a_star(
422
454
  end_world: tuple[float, float],
423
455
  fine_resolution: float = GRID_RESOLUTION,
424
456
  route_bbox: tuple[float, float, float, float] | None = None,
457
+ turn_penalty: float = _TURN_PENALTY,
425
458
  ) -> AStarResult:
426
459
  """Run hierarchical A* with auto-detection of single-pass vs two-pass.
427
460
 
@@ -457,11 +490,11 @@ def hierarchical_a_star(
457
490
 
458
491
  if fine_cells < _SINGLE_PASS_THRESHOLD:
459
492
  grid = build_grid_map(obstacles, bbox, resolution=fine_resolution)
460
- return grid_a_star([grid], start_world, end_world)
493
+ return grid_a_star([grid], start_world, end_world, turn_penalty=turn_penalty)
461
494
 
462
495
  coarse_res = fine_resolution * COARSE_FACTOR
463
496
  coarse_grid = build_grid_map(obstacles, bbox, resolution=coarse_res)
464
- coarse_result = grid_a_star([coarse_grid], start_world, end_world)
497
+ coarse_result = grid_a_star([coarse_grid], start_world, end_world, turn_penalty=turn_penalty)
465
498
  if coarse_result.path is None:
466
499
  return AStarResult(path=None, cells_visited=coarse_result.cells_visited)
467
500
 
@@ -477,7 +510,7 @@ def hierarchical_a_star(
477
510
  )
478
511
 
479
512
  fine_grid = build_grid_map(obstacles, band, resolution=fine_resolution)
480
- result = grid_a_star([fine_grid], start_world, end_world)
513
+ result = grid_a_star([fine_grid], start_world, end_world, turn_penalty=turn_penalty)
481
514
  if result.path is not None:
482
515
  result.cells_visited += coarse_result.cells_visited
483
516
  else:
@@ -535,6 +568,7 @@ def multi_layer_a_star(
535
568
  fine_resolution: float = GRID_RESOLUTION,
536
569
  via_cost: float = _VIA_COST,
537
570
  via_forbidden_zones: list | None = None,
571
+ turn_penalty: float = _TURN_PENALTY,
538
572
  ) -> MultiLayerAStarResult:
539
573
  """Run A* across multiple copper layers.
540
574
 
@@ -583,6 +617,7 @@ def multi_layer_a_star(
583
617
  via_from=via_from or None,
584
618
  via_cost=via_cost,
585
619
  via_forbidden_zones=via_forbidden_zones,
620
+ turn_penalty=turn_penalty,
586
621
  )
587
622
  if result.path is None:
588
623
  return MultiLayerAStarResult(path=None, cells_visited=result.cells_visited)
@@ -683,6 +718,13 @@ def _grid_line_clear(
683
718
  return True
684
719
 
685
720
 
721
+ def _is_45_family(x0: float, y0: float, x1: float, y1: float) -> bool:
722
+ """True when the segment (x0,y0)-(x1,y1) is axis-aligned or a 45° diagonal."""
723
+ dx = abs(x1 - x0)
724
+ dy = abs(y1 - y0)
725
+ return abs(dx) < 1e-6 or abs(dy) < 1e-6 or abs(dx - dy) < 1e-6
726
+
727
+
686
728
  def shortcut_path(
687
729
  pts: list[tuple[float, float]],
688
730
  obstacles: list,
@@ -693,16 +735,11 @@ def shortcut_path(
693
735
 
694
736
  Builds a walkability grid once, then for each point tries to skip
695
737
  as many subsequent points as possible while keeping the line clear.
696
- Only 0°/45°/90°/135° shortcuts are accepted.
697
-
698
- Args:
699
- pts: Ordered ``(x, y)`` points (after collinear simplification).
700
- obstacles: Inflated obstacle list (Polygon objects).
701
- bbox: ``(min_x, min_y, max_x, max_y)`` of the route area.
702
- resolution: Grid cell size in mm.
703
-
704
- Returns:
705
- Simplified polyline with redundant points removed.
738
+ Only 0°/45°/90°/135° shortcuts are accepted. A shortcut that would
739
+ skip a direction change all the way through (producing a near-45°
740
+ long diagonal) is REJECTED: snapping that back to the 45° family
741
+ later would have to force a Manhattan corner (often a 90° turn), so
742
+ it is better to keep the original 45°+axis-aligned waypoints.
706
743
  """
707
744
  if len(pts) <= 2:
708
745
  return list(pts)
@@ -713,10 +750,13 @@ def shortcut_path(
713
750
  while i < len(pts) - 1:
714
751
  best_next = i + 1
715
752
  for k in range(len(pts) - 1, i, -1):
716
- dx = pts[k][0] - result[-1][0]
717
- dy = pts[k][1] - result[-1][1]
718
- if abs(dx) > 1e-9 and abs(dy) > 1e-9 and abs(abs(dx) - abs(dy)) > 1e-9:
719
- continue # not 0/45/90/135°
753
+ if not _is_45_family(
754
+ result[-1][0],
755
+ result[-1][1],
756
+ pts[k][0],
757
+ pts[k][1],
758
+ ):
759
+ continue
720
760
  if _grid_line_clear(
721
761
  grid,
722
762
  result[-1][0],