draftwright 0.3.6__tar.gz → 0.3.7__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 (136) hide show
  1. {draftwright-0.3.6 → draftwright-0.3.7}/CHANGELOG.md +22 -0
  2. {draftwright-0.3.6 → draftwright-0.3.7}/PKG-INFO +1 -1
  3. {draftwright-0.3.6 → draftwright-0.3.7}/pyproject.toml +1 -1
  4. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotations/from_model.py +166 -6
  5. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/linting/structural.py +128 -0
  6. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_lint_structural.py +79 -0
  7. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_make_drawing.py +268 -0
  8. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_private_test_imports.py +8 -0
  9. {draftwright-0.3.6 → draftwright-0.3.7}/.gitignore +0 -0
  10. {draftwright-0.3.6 → draftwright-0.3.7}/LICENSE +0 -0
  11. {draftwright-0.3.6 → draftwright-0.3.7}/README.md +0 -0
  12. {draftwright-0.3.6 → draftwright-0.3.7}/docs/adr/README.md +0 -0
  13. {draftwright-0.3.6 → draftwright-0.3.7}/skills/SKILL.md +0 -0
  14. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/__init__.py +0 -0
  15. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/_core.py +0 -0
  16. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/_geometry.py +0 -0
  17. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/analysis.py +0 -0
  18. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotate.py +0 -0
  19. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotations/__init__.py +0 -0
  20. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotations/_common.py +0 -0
  21. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotations/balloons.py +0 -0
  22. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotations/holes.py +0 -0
  23. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotations/orchestrator.py +0 -0
  24. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotations/sections.py +0 -0
  25. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/builder.py +0 -0
  26. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/cli.py +0 -0
  27. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/compose.py +0 -0
  28. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/drawing.py +0 -0
  29. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/export.py +0 -0
  30. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/fits.py +0 -0
  31. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  32. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  33. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  34. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  35. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/fonts/__init__.py +0 -0
  36. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/intents.py +0 -0
  37. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/layout.py +0 -0
  38. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/linting/__init__.py +0 -0
  39. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/linting/coverage.py +0 -0
  40. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/linting/issues.py +0 -0
  41. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/linting/suggest.py +0 -0
  42. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/make_drawing.py +0 -0
  43. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/model/__init__.py +0 -0
  44. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/model/declare.py +0 -0
  45. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/model/detect.py +0 -0
  46. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/model/ir.py +0 -0
  47. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/model/planner.py +0 -0
  48. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/pmi.py +0 -0
  49. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/projection.py +0 -0
  50. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/__init__.py +0 -0
  51. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/_features.py +0 -0
  52. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/_record.py +0 -0
  53. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/chamfers.py +0 -0
  54. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/countersinks.py +0 -0
  55. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/fillets.py +0 -0
  56. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/flats.py +0 -0
  57. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/grooves.py +0 -0
  58. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/levels.py +0 -0
  59. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/plates.py +0 -0
  60. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/slots.py +0 -0
  61. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/turned.py +0 -0
  62. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/registry.py +0 -0
  63. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/repair.py +0 -0
  64. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/score.py +0 -0
  65. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/sheet.py +0 -0
  66. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/sheet_dsl.py +0 -0
  67. {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/sheet_emit.py +0 -0
  68. {draftwright-0.3.6 → draftwright-0.3.7}/tests/_kernel.py +0 -0
  69. {draftwright-0.3.6 → draftwright-0.3.7}/tests/_layout_sig.py +0 -0
  70. {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/grm03_thumbwheel_drive_screw.step +0 -0
  71. {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  72. {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  73. {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  74. {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  75. {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  76. {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  77. {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  78. {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  79. {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  80. {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  81. {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/bracket_section.json +0 -0
  82. {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/centered_rebate.json +0 -0
  83. {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/chamfered.json +0 -0
  84. {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/filleted.json +0 -0
  85. {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/flange_dense.json +0 -0
  86. {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/grid_plate.json +0 -0
  87. {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/grooved_shaft.json +0 -0
  88. {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/hex_bar.json +0 -0
  89. {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/holed_slot.json +0 -0
  90. {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/pocketed.json +0 -0
  91. {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/prismatic_ladder.json +0 -0
  92. {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/scattered_plate.json +0 -0
  93. {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/side_drilled.json +0 -0
  94. {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/turned_stepped.json +0 -0
  95. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_architecture_docs.py +0 -0
  96. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_carve_free_position_callers.py +0 -0
  97. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_declare.py +0 -0
  98. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_dense_sheet_canary.py +0 -0
  99. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_detect_once.py +0 -0
  100. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_drawing_encapsulation.py +0 -0
  101. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_e2e_slice.py +0 -0
  102. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_e2e_standards.py +0 -0
  103. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_export_dxf_zoom.py +0 -0
  104. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_fillets_adjacency.py +0 -0
  105. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_fits.py +0 -0
  106. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_gdt_placement.py +0 -0
  107. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_import_boundaries.py +0 -0
  108. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_layout.py +0 -0
  109. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_layout_cleanliness.py +0 -0
  110. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_layout_hypothesis.py +0 -0
  111. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_layout_property.py +0 -0
  112. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_lint_box_cache.py +0 -0
  113. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_lint_reconciliation.py +0 -0
  114. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_linting.py +0 -0
  115. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_object_aspects.py +0 -0
  116. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_occupancy_boxes.py +0 -0
  117. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_part_model.py +0 -0
  118. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_pitch_dim_footprint.py +0 -0
  119. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_pmi.py +0 -0
  120. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_recogniser_contract.py +0 -0
  121. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_recognition.py +0 -0
  122. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_refactor_golden.py +0 -0
  123. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_registry.py +0 -0
  124. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_render_seam.py +0 -0
  125. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_score.py +0 -0
  126. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_script_detail_parity.py +0 -0
  127. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_sheet_emit.py +0 -0
  128. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_sheet_gdt.py +0 -0
  129. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_sheet_notes.py +0 -0
  130. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_sheet_of.py +0 -0
  131. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_sheet_tables.py +0 -0
  132. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_solve_trace.py +0 -0
  133. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_strip_layout.py +0 -0
  134. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_tolerances.py +0 -0
  135. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_turned_steps.py +0 -0
  136. {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_witness_label_reconciliation.py +0 -0
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.7 — 2026-07-21
4
+
5
+ ### Fixed
6
+
7
+ - **Turned ⌀ leaders point at the feature, not a corner or across the body**
8
+ (#794/#798): the diameter callouts on a turned part now centre on the step's
9
+ mid-length (a boss keeps its frame origin; a ⌀ shared by disjoint steps centres
10
+ on the longest run), so the arrow lands on the middle of the feature's edge
11
+ rather than a step corner. A leader that would otherwise cut diagonally through
12
+ the body — a nested or end feature such as a ⌀6 boss stub — is re-routed out to
13
+ the nearest clear margin (axis-aware: X-turned → left/right, Z-turned → top/
14
+ bottom), pointing straight at the feature instead of clipping the silhouette. A
15
+ re-route that can't be placed clear-and-safe is restored and flagged, and a
16
+ pinned leader is never moved.
17
+
18
+ ### Added
19
+
20
+ - **`leader_crosses_silhouette` lint notice** (#796): an info-level structural
21
+ notice when a leader shaft passes *through* the projected part body (as opposed
22
+ to a hole callout legitimately exiting an internal hole), so the gap is
23
+ observable even where routing cannot clear it.
24
+
3
25
  ## v0.3.6 — 2026-07-21
4
26
 
5
27
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.3.6
3
+ Version: 0.3.7
4
4
  Summary: Automated technical-drawing generation for build123d
5
5
  Project-URL: Homepage, https://github.com/pzfreo/draftwright
6
6
  Project-URL: Repository, https://github.com/pzfreo/draftwright
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.3.6"
7
+ version = "0.3.7"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -52,6 +52,7 @@ from draftwright._core import (
52
52
  _legible_locations,
53
53
  _legible_steps,
54
54
  _log,
55
+ _shape_box2d,
55
56
  _solve_strip_ys,
56
57
  _text_size,
57
58
  _title_block_box,
@@ -776,8 +777,8 @@ def _diameter_row_below(dwg, items, start: int = 0, trace=None) -> int:
776
777
  for _, d, _, _ in items
777
778
  )
778
779
  return 0
779
- specs = [] # (tip_page, dia, label, feature), tip on the step's bottom silhouette
780
- for anchor, dia, feat, dtol in items:
780
+ specs = [] # (tip_page, dia, label, feature), tip on the step's bottom silhouette,
781
+ for anchor, dia, feat, dtol in items: # centred along the feature's length (not a corner)
781
782
  ax, ay, az = anchor
782
783
  tip = dwg.at("front", ax, ay, az - dia / 2)
783
784
  specs.append((tip, dia, f"ø{_fmt(dia)}{_tol_suffix(dtol, draft)}", feat))
@@ -890,8 +891,8 @@ def _diameter_column_left(dwg, items, start: int = 0, trace=None) -> int:
890
891
  for _, d, _, _ in items
891
892
  )
892
893
  return 0
893
- specs = [] # (tip_page, dia, label, feature), tip on the step's left silhouette
894
- for anchor, dia, feat, dtol in items:
894
+ specs = [] # (tip_page, dia, label, feature), tip on the step's left silhouette,
895
+ for anchor, dia, feat, dtol in items: # centred along the feature's length (not a corner)
895
896
  ax, ay, az = anchor
896
897
  tip = dwg.at("front", ax - dia / 2, ay, az)
897
898
  specs.append((tip, dia, f"ø{_fmt(dia)}{_tol_suffix(dtol, draft)}", feat))
@@ -935,6 +936,45 @@ def _diameter_column_left(dwg, items, start: int = 0, trace=None) -> int:
935
936
  return placed
936
937
 
937
938
 
939
+ def _diameter_step_anchor(anchor, features):
940
+ """Anchor point for a turned ⌀ leader tip.
941
+
942
+ Centre the leader at the MID-LENGTH of the STEP carrying this diameter, rather
943
+ than the frame origin passed in (which for a boss or a shared ⌀'s first-bucketed
944
+ step can be an end corner). Only STEP spans are used: a step round-trips its
945
+ length + centre through the emitted Sheet script (declared ``length=``/``at=``),
946
+ so direct and scripted builds agree (#707 parity); a boss is re-synthesised
947
+ without a declared span and keeps its frame origin in both paths (``anchor``).
948
+
949
+ When a ⌀ is shared by several steps, select ONE — the longest run, leftmost on
950
+ ties — and use THAT step's OWN origin (radial + axial). Never the convex-hull
951
+ midpoint (which for disjoint ⌀A–⌀B–⌀A steps falls in the ⌀B gap, off any
952
+ silhouette) and never the bucket ``anchor``'s radial (which for non-coaxial
953
+ same-⌀ steps belongs to a different feature). The selection quantises both the
954
+ length and the lower end to the emitter's 3-dp values, so direct and scripted
955
+ pick the same run. In the #426 finalize/``only=`` path this centres over the
956
+ recorded subset, as the pre-#794 first-recorded-origin anchor also did.
957
+ """
958
+ steps = [
959
+ f for f in features if getattr(f, "kind", None) == "step" and getattr(f, "span", None)
960
+ ]
961
+ if not steps:
962
+ return anchor
963
+ idx = {"x": 0, "y": 1, "z": 2}[steps[0].frame.axis]
964
+
965
+ def _key(f):
966
+ ends = sorted(end[idx] for end in f.span)
967
+ length = round(ends[1] - ends[0], 3) # emitter rounds step length and centre (`at`) to
968
+ at = round((ends[0] + ends[1]) / 2, 3) # 3dp — quantise both so the selection round-trips
969
+ return (length, -(at - length / 2))
970
+
971
+ step = max(steps, key=_key)
972
+ ends = sorted(end[idx] for end in step.span)
973
+ centred = list(step.frame.origin) # the SELECTED step's own origin (radial + axial)
974
+ centred[idx] = (ends[0] + ends[1]) / 2
975
+ return tuple(centred)
976
+
977
+
938
978
  def render_diameters(dwg, groups, tol: float = 0.15, *, ctx, only=None) -> int:
939
979
  """ø leaders for a turned part's external step/boss diameters, from the IR —
940
980
  one distinct callout per diameter, in a tidy row below the front view
@@ -978,7 +1018,8 @@ def render_diameters(dwg, groups, tol: float = 0.15, *, ctx, only=None) -> int:
978
1018
 
979
1019
  def _items(buckets):
980
1020
  return [
981
- (a, d, next(iter(fs)) if len(fs) == 1 else None, t) for a, d, fs, t in buckets.values()
1021
+ (_diameter_step_anchor(a, fs), d, next(iter(fs)) if len(fs) == 1 else None, t)
1022
+ for a, d, fs, t in buckets.values()
982
1023
  ]
983
1024
 
984
1025
  # The placers name leaders m_dia_{x,z}{start+i} CONTIGUOUSLY from one start. The auto-pass
@@ -998,9 +1039,128 @@ def render_diameters(dwg, groups, tol: float = 0.15, *, ctx, only=None) -> int:
998
1039
  start_x = _next_start("m_dia_x") if only is not None else 0
999
1040
  start_z = _next_start("m_dia_z") if only is not None else 0
1000
1041
  trace = getattr(ctx, "trace", None) # the immediate placers report to the trace too (#736)
1001
- return _diameter_row_below(
1042
+ placed = _diameter_row_below(
1002
1043
  dwg, _items(row_buckets), start=start_x, trace=trace
1003
1044
  ) + _diameter_column_left(dwg, _items(col_buckets), start=start_z, trace=trace)
1045
+ # #798: a ⌀ leader the row/column solve sent DIAGONALLY into the body — cutting
1046
+ # the silhouette, or an end feature whose diagonal merely grazes it — is re-routed
1047
+ # to the clear side (the margin the feature sits at). Auto-pass only: the finalize
1048
+ # (only=) path replays recorded verbs and must not disturb pinned user dims.
1049
+ if only is None:
1050
+ _reroute_crossing_diameters(dwg)
1051
+ return placed
1052
+
1053
+
1054
+ _REROUTE_EDGE_TOL = 2.0 # page mm: a tip this close to an axial end sits AT that end
1055
+ _REROUTE_SLACK = 1.0 # page mm: an elbow displaced this far toward the interior is "into the body"
1056
+
1057
+
1058
+ def _reroute_crossing_diameters(dwg) -> int:
1059
+ """Route a turned ⌀ leader that heads INTO the part body (#798) out to the
1060
+ nearest CLEAR margin, rather than diagonally into the body.
1061
+
1062
+ Two triggers, both re-routed to the clear side:
1063
+
1064
+ * The shaft CUTS THROUGH the body (the Phase-1 ``_leader_shaft_hits_edges``
1065
+ discriminator) — a nested feature clipping a neighbour.
1066
+ * An END feature (tip at the part's axial extreme, e.g. a ⌀6 boss stub) whose
1067
+ row/column-solved elbow diagonals back INTO the body — a near-miss that reads
1068
+ as clipping the outline even where it does not strictly cross.
1069
+
1070
+ Axis-aware: an ``m_dia_x`` (X-turned) leader's axial run is page-X and its clear
1071
+ margins are left/right; an ``m_dia_z`` (Z-turned) leader's axial run is page-Y
1072
+ and its margins are bottom/top. Candidates (near margin → straight out either way
1073
+ along the radial axis) are kept only when the new shaft clears the outline AND the
1074
+ rebuilt leader's LABEL stays inside the page and off every other view/annotation
1075
+ box — so a re-route never trades an info crossing for an out-of-bounds or overlap
1076
+ error (the shaft itself, like the row/column placers, is gated only on the
1077
+ silhouette). If nothing is both clear and safe the leader is restored unchanged
1078
+ (Phase-1 then flags it). A PINNED leader (ADR 0012) is never moved. Returns the
1079
+ number re-routed."""
1080
+ from draftwright.linting.structural import _leader_shaft_hits_edges
1081
+
1082
+ vs = dwg.views.get("front")
1083
+ if not vs or vs[0] is None:
1084
+ return 0
1085
+ try:
1086
+ edges = [(e, _shape_box2d(e)) for e in vs[0].edges()]
1087
+ except Exception: # noqa: BLE001 — an unanalysable projected shape just skips re-routing
1088
+ return 0
1089
+ fb = dwg.view_bounds("front")
1090
+ if not fb:
1091
+ return 0
1092
+ draft = dwg.draft
1093
+ gap = draft.font_size + 2 * draft.pad_around_text
1094
+ page = (_MARGIN, _MARGIN, dwg.page_w - _MARGIN, dwg.page_h - _MARGIN)
1095
+
1096
+ def _within_page(box):
1097
+ return box[0] >= page[0] and box[1] >= page[1] and box[2] <= page[2] and box[3] <= page[3]
1098
+
1099
+ rerouted = 0
1100
+ for name in [n for n in dwg.annotations() if n.startswith(("m_dia_x", "m_dia_z"))]:
1101
+ ldr = dwg.get_annotation(name)
1102
+ if ldr is None or getattr(ldr, "elbow", None) is None:
1103
+ continue
1104
+ if dwg.registry.is_pinned(name): # a pin is the user's "stays put" (ADR 0012)
1105
+ continue
1106
+ tip, elbow = ldr.tip, ldr.elbow
1107
+ crosses = _leader_shaft_hits_edges(tip, elbow, edges)
1108
+ ax = 0 if name.startswith("m_dia_x") else 1 # axial page axis (X row / Y column)
1109
+ rad = 1 - ax
1110
+ lo_b, hi_b = fb[ax], fb[ax + 2]
1111
+ at_lo = tip[ax] - lo_b <= _REROUTE_EDGE_TOL
1112
+ at_hi = hi_b - tip[ax] <= _REROUTE_EDGE_TOL
1113
+ into_body = (at_lo and elbow[ax] > tip[ax] + _REROUTE_SLACK) or (
1114
+ at_hi and elbow[ax] < tip[ax] - _REROUTE_SLACK
1115
+ )
1116
+ if not (crosses or into_body):
1117
+ continue
1118
+ # Clear-side elbows toward the CLEAR end only: the near axial margin (the end
1119
+ # the feature sits at) first, then straight out either way along the radial
1120
+ # axis. The far axial margin is deliberately NOT a candidate — a leader run
1121
+ # the whole length of the part to the opposite end reads worse than the
1122
+ # near-miss it replaces; restore-and-flag is the honest fallback instead.
1123
+ near_a = lo_b - gap if tip[ax] - lo_b <= hi_b - tip[ax] else hi_b + gap
1124
+
1125
+ def _pt(a_val, r_val, _ax=ax):
1126
+ p = [0.0, 0.0]
1127
+ p[_ax], p[1 - _ax] = a_val, r_val
1128
+ return (p[0], p[1])
1129
+
1130
+ candidates = (
1131
+ _pt(near_a, tip[rad]),
1132
+ _pt(tip[ax], fb[rad] - gap),
1133
+ _pt(tip[ax], fb[rad + 2] + gap),
1134
+ )
1135
+ feat = dwg.registry.feature_of(name)
1136
+ old = dwg.remove(name) # remove first so obstacles exclude the leader being replaced
1137
+ placed_it = False
1138
+ try:
1139
+ obstacles = strip_obstacles(dwg, crossable=CROSSABLE_TYPES)
1140
+ for vis, hid in dwg.views.values():
1141
+ for shp in (vis, hid):
1142
+ if shp is None:
1143
+ continue
1144
+ b = shp.bounding_box()
1145
+ obstacles.append((b.min.X, b.min.Y, b.max.X, b.max.Y))
1146
+ for ex, ey in candidates:
1147
+ if _leader_shaft_hits_edges(tip, (ex, ey), edges):
1148
+ continue
1149
+ cand = Leader(
1150
+ tip=(tip[0], tip[1], 0), elbow=(ex, ey, 0), label=ldr.label, draft=draft
1151
+ )
1152
+ box = _anno_box(cand)
1153
+ if box is None or not _within_page(box) or _box_hits(box, obstacles):
1154
+ continue
1155
+ dwg.add(cand, name, view="front", feature=feat)
1156
+ rerouted += 1
1157
+ placed_it = True
1158
+ break
1159
+ except Exception: # noqa: BLE001 — a re-route error must never lose the leader
1160
+ placed_it = False
1161
+ if not placed_it and dwg.get_annotation(name) is None:
1162
+ dwg.add(old, name, view="front", feature=feat) # restore (Phase-1 flags it)
1163
+ return rerouted
1004
1164
 
1005
1165
 
1006
1166
  def _env_pd(group, role):
@@ -454,6 +454,89 @@ def _edges_intersect_rect(edge_entries, rect) -> bool:
454
454
  return False
455
455
 
456
456
 
457
+ def _seg_seg_point(a, b, c, d):
458
+ """Intersection point of segments *a*-*b* and *c*-*d*, or ``None`` (parallel,
459
+ collinear, or no overlap). Collinear returns ``None`` on purpose — a shaft
460
+ running ALONG an edge does not cut across it."""
461
+ rx, ry = b[0] - a[0], b[1] - a[1]
462
+ sx, sy = d[0] - c[0], d[1] - c[1]
463
+ denom = rx * sy - ry * sx
464
+ if abs(denom) < 1e-12:
465
+ return None
466
+ qpx, qpy = c[0] - a[0], c[1] - a[1]
467
+ t = (qpx * sy - qpy * sx) / denom
468
+ u = (qpx * ry - qpy * rx) / denom
469
+ if 0.0 <= t <= 1.0 and 0.0 <= u <= 1.0:
470
+ return (a[0] + t * rx, a[1] + t * ry)
471
+ return None
472
+
473
+
474
+ def _leader_shaft_hits_edges(tip, elbow, edge_entries, skip_mm=0.5) -> bool:
475
+ """True if the leader shaft *tip*→*elbow* cuts THROUGH the projected view
476
+ silhouette *edge_entries* (``(edge, bbox2d)`` pairs, #796).
477
+
478
+ The discriminator is the count of DISTINCT crossing points, past a *skip_mm*
479
+ step off the tip:
480
+
481
+ * A leader that legitimately EXITS the outline — a hole/feature callout whose
482
+ tip is at an internal point, or a ⌀ callout whose tip is on the surface and
483
+ whose shaft runs outward — crosses the outline **once** (or not at all).
484
+ * A shaft that cuts THROUGH a body — a ⌀ leader clipping a neighbouring step,
485
+ a groove leader crossing a flange — **enters and exits**, crossing **twice**.
486
+
487
+ Genuine crossing POINTS are computed (not edge-bbox hits) and deduped within
488
+ 0.5 mm, so a shaft merely grazing a shared vertex, or a doubled/coincident
489
+ projected edge, counts once — not two (review of #799). Straight edges use an
490
+ exact segment intersection; a curved edge is sampled ~1 mm; an edge whose
491
+ geometry THROWS during analysis counts as one crossing (conservative). A cut
492
+ is **two or more** distinct crossings beyond the tip's on-outline touch, which
493
+ the skip removes (the tip's own edge crosses at t<0 of the skipped shaft).
494
+
495
+ The 0.5 mm dedup radius is a page-space resolution floor: a shaft cutting a
496
+ feature narrower than ~0.5 mm ON THE PAGE merges to one crossing and is not
497
+ flagged — acceptable for a Phase-1 observability notice (#798 owns the robust
498
+ filled-region test)."""
499
+ tx, ty = tip[0], tip[1]
500
+ ex, ey = elbow[0], elbow[1]
501
+ length = ((ex - tx) ** 2 + (ey - ty) ** 2) ** 0.5
502
+ if length < 1e-9:
503
+ return False
504
+ t = min(0.5, skip_mm / length) # step off the tip; never past the shaft midpoint
505
+ a = (tx + t * (ex - tx), ty + t * (ey - ty))
506
+ b = (ex, ey)
507
+ points: list[tuple[float, float]] = []
508
+ opaque = 0
509
+ for e, eb in edge_entries:
510
+ if eb is None:
511
+ continue
512
+ if not _segment_clips_box(a, b, eb): # cheap bbox prefilter
513
+ continue
514
+ try:
515
+ if e.geom_type == GeomType.LINE:
516
+ s, d = e.start_point(), e.end_point()
517
+ p = _seg_seg_point(a, b, (s.X, s.Y), (d.X, d.Y))
518
+ if p is not None:
519
+ points.append(p)
520
+ else:
521
+ n = min(200, max(8, int(e.length) + 1))
522
+ prev = e.position_at(0)
523
+ for i in range(1, n + 1):
524
+ cur = e.position_at(i / n)
525
+ p = _seg_seg_point(a, b, (prev.X, prev.Y), (cur.X, cur.Y))
526
+ if p is not None:
527
+ points.append(p)
528
+ prev = cur
529
+ except Exception:
530
+ opaque += 1
531
+ distinct = 0
532
+ seen: list[tuple[float, float]] = []
533
+ for p in points:
534
+ if not any(abs(p[0] - q[0]) < 0.5 and abs(p[1] - q[1]) < 0.5 for q in seen):
535
+ seen.append(p)
536
+ distinct += 1
537
+ return (distinct + opaque) >= 2
538
+
539
+
457
540
  def _view_edge_entries(vs, cache):
458
541
  """Per-edge ``(edge, bbox2d)`` list for view shape *vs*, memoised in *cache*.
459
542
 
@@ -548,6 +631,51 @@ def _lint_view_shapes(
548
631
  )
549
632
  )
550
633
 
634
+ # #796 — leader shaft crosses the view silhouette. A leader that routes its
635
+ # shaft THROUGH the part body to reach its label reads as clipping the
636
+ # outline; the tip-on-silhouette touch is excluded (see _leader_shaft_hits_edges).
637
+ # Only tested where the shaft's own bbox overlaps the view (a leader far from a
638
+ # view can't cross it), reusing the shared edge cache.
639
+ for vname, vbb, vs in named_views:
640
+ for ann in ann_items:
641
+ if id(ann) in view_shape_ids:
642
+ continue
643
+ if getattr(ann, "elbow", None) is None:
644
+ continue
645
+ if getattr(ann, "is_centerline", False) or getattr(ann, "is_section_hatch", False):
646
+ continue
647
+ if getattr(ann, "covers_diameters", None):
648
+ continue # a hole/bore callout legitimately exits the part from an internal hole
649
+ try:
650
+ tip, elbow = ann.tip, ann.elbow
651
+ shaft_bb = (
652
+ min(tip[0], elbow[0]),
653
+ min(tip[1], elbow[1]),
654
+ max(tip[0], elbow[0]),
655
+ max(tip[1], elbow[1]),
656
+ )
657
+ except Exception:
658
+ continue
659
+ if not _boxes_overlap(vbb, shaft_bb):
660
+ continue
661
+ edges = _view_edge_entries(vs, cache)
662
+ if edges is None:
663
+ continue
664
+ if _leader_shaft_hits_edges(tip, elbow, edges):
665
+ albl = getattr(ann, "label", None) or getattr(ann, "name", None) or "leader"
666
+ issues.append(
667
+ LintIssue(
668
+ severity="info",
669
+ message=(
670
+ f"leader '{albl}' shaft crosses view '{vname}' silhouette "
671
+ f"— route it clear of the outline, or dimension the feature "
672
+ f"in a detail view"
673
+ ),
674
+ location=(elbow[0], elbow[1]),
675
+ code="leader_crosses_silhouette",
676
+ )
677
+ )
678
+
551
679
  # #160 — view shape vs view shape bounding box overlaps
552
680
  for i, (aname, abb, _) in enumerate(named_views):
553
681
  ax0, ay0, ax1, ay1 = abb
@@ -481,3 +481,82 @@ class TestLintSelfSilencing:
481
481
  b = SimpleNamespace(label_bbox=(0.0, 0.0, 4.0, 4.0), label="B")
482
482
  with pytest.raises(IndexError):
483
483
  lint_drawing([a, b])
484
+
485
+
486
+ class TestLeaderCrossesSilhouette:
487
+ """#796: leader_crosses_silhouette — a leader shaft that cuts THROUGH the part
488
+ body. Discriminator unit-tested here on controlled real edges (no projection);
489
+ the real projected-view behaviour is integration-tested in test_make_drawing."""
490
+
491
+ @staticmethod
492
+ def _edges(*segs):
493
+ from build123d import Edge
494
+
495
+ from draftwright.linting.structural import _shape_box2d
496
+
497
+ out = []
498
+ for (x0, y0), (x1, y1) in segs:
499
+ e = Edge.make_line((x0, y0, 0), (x1, y1, 0))
500
+ out.append((e, _shape_box2d(e)))
501
+ return out
502
+
503
+ def test_two_crossings_is_a_cut(self):
504
+ from draftwright.linting.structural import _leader_shaft_hits_edges
505
+
506
+ # shaft x=5, y 0→20; two horizontal edges at y=8 and y=12 → enters+exits.
507
+ edges = self._edges(((0, 8), (10, 8)), ((0, 12), (10, 12)))
508
+ assert _leader_shaft_hits_edges((5.0, 0.0), (5.0, 20.0), edges)
509
+
510
+ def test_single_crossing_is_a_legitimate_exit(self):
511
+ from draftwright.linting.structural import _leader_shaft_hits_edges
512
+
513
+ # only one edge on the way out (a hole callout exiting the outline once).
514
+ edges = self._edges(((0, 12), (10, 12)))
515
+ assert not _leader_shaft_hits_edges((5.0, 0.0), (5.0, 20.0), edges)
516
+
517
+ def test_tip_edge_is_skipped(self):
518
+ from draftwright.linting.structural import _leader_shaft_hits_edges
519
+
520
+ # the tip sits ON an edge (y=0) and the shaft runs away: the tip's own edge
521
+ # crosses at t<0 of the skipped shaft, so a lone outward ⌀ leader is not a cut.
522
+ edges = self._edges(((0, 0), (10, 0)))
523
+ assert not _leader_shaft_hits_edges((5.0, 0.0), (5.0, 20.0), edges)
524
+
525
+ def test_shared_vertex_counts_once(self):
526
+ from draftwright.linting.structural import _leader_shaft_hits_edges
527
+
528
+ # Two edges meet at (5, 5) forming a V; a shaft grazing that vertex crosses
529
+ # BOTH edges at the same point — deduped to one, so it is not a cut (the
530
+ # bbox-count version over-counted this to two — #799 review).
531
+ edges = self._edges(((0, 0), (5, 5)), ((5, 5), (10, 0)))
532
+ assert not _leader_shaft_hits_edges((5.0, -5.0), (5.0, 10.0), edges)
533
+
534
+ def test_doubled_edge_counts_once(self):
535
+ from draftwright.linting.structural import _leader_shaft_hits_edges
536
+
537
+ # A coincident/doubled projected edge (front+back face project alike) is one
538
+ # crossing, not two.
539
+ edges = self._edges(((0, 10), (10, 10)), ((0, 10), (10, 10)))
540
+ assert not _leader_shaft_hits_edges((5.0, 0.0), (5.0, 20.0), edges)
541
+
542
+ def test_tip_skip_prevents_near_edge_false_positive(self):
543
+ from draftwright.linting.structural import _leader_shaft_hits_edges
544
+
545
+ # Tip ON a near edge (y=0), with a far edge (y=12); the shaft exits through
546
+ # the far edge only. The tip's own edge must be stepped over — WITHOUT the
547
+ # skip this counts 2 (near tip-touch + far exit) and false-fires. This is the
548
+ # case that actually pins the load-bearing tip-skip (#799 review).
549
+ edges = self._edges(((0, 0), (10, 0)), ((0, 12), (10, 12)))
550
+ assert not _leader_shaft_hits_edges((5.0, 0.0), (5.0, 20.0), edges)
551
+
552
+ def test_shaft_through_a_curved_edge_twice_is_a_cut(self):
553
+ from build123d import Edge
554
+
555
+ from draftwright.linting.structural import _leader_shaft_hits_edges, _shape_box2d
556
+
557
+ # A single CURVED edge crossed twice (enter+exit) is a cut — exercises the
558
+ # curve-sampling + cross-subsegment dedup path. Circle r5 at origin; the
559
+ # shaft along y=0 passes through it at x=-5 and x=+5.
560
+ arc = Edge.make_circle(5)
561
+ edges = [(arc, _shape_box2d(arc))]
562
+ assert _leader_shaft_hits_edges((-10.0, 0.0), (10.0, 0.0), edges)