draftwright 0.3.5__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.5 → draftwright-0.3.7}/CHANGELOG.md +66 -1
  2. {draftwright-0.3.5 → draftwright-0.3.7}/PKG-INFO +1 -1
  3. {draftwright-0.3.5 → draftwright-0.3.7}/pyproject.toml +1 -1
  4. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/annotations/from_model.py +166 -6
  5. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/annotations/sections.py +17 -0
  6. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/builder.py +76 -2
  7. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/cli.py +7 -0
  8. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/linting/structural.py +128 -0
  9. draftwright-0.3.7/tests/test_dense_sheet_canary.py +99 -0
  10. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_layout_hypothesis.py +30 -1
  11. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_lint_structural.py +79 -0
  12. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_make_drawing.py +376 -0
  13. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_private_test_imports.py +8 -0
  14. {draftwright-0.3.5 → draftwright-0.3.7}/.gitignore +0 -0
  15. {draftwright-0.3.5 → draftwright-0.3.7}/LICENSE +0 -0
  16. {draftwright-0.3.5 → draftwright-0.3.7}/README.md +0 -0
  17. {draftwright-0.3.5 → draftwright-0.3.7}/docs/adr/README.md +0 -0
  18. {draftwright-0.3.5 → draftwright-0.3.7}/skills/SKILL.md +0 -0
  19. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/__init__.py +0 -0
  20. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/_core.py +0 -0
  21. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/_geometry.py +0 -0
  22. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/analysis.py +0 -0
  23. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/annotate.py +0 -0
  24. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/annotations/__init__.py +0 -0
  25. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/annotations/_common.py +0 -0
  26. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/annotations/balloons.py +0 -0
  27. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/annotations/holes.py +0 -0
  28. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/annotations/orchestrator.py +0 -0
  29. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/compose.py +0 -0
  30. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/drawing.py +0 -0
  31. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/export.py +0 -0
  32. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/fits.py +0 -0
  33. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  34. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  35. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  36. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  37. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/fonts/__init__.py +0 -0
  38. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/intents.py +0 -0
  39. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/layout.py +0 -0
  40. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/linting/__init__.py +0 -0
  41. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/linting/coverage.py +0 -0
  42. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/linting/issues.py +0 -0
  43. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/linting/suggest.py +0 -0
  44. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/make_drawing.py +0 -0
  45. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/model/__init__.py +0 -0
  46. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/model/declare.py +0 -0
  47. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/model/detect.py +0 -0
  48. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/model/ir.py +0 -0
  49. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/model/planner.py +0 -0
  50. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/pmi.py +0 -0
  51. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/projection.py +0 -0
  52. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/recognition/__init__.py +0 -0
  53. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/recognition/_features.py +0 -0
  54. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/recognition/_record.py +0 -0
  55. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/recognition/chamfers.py +0 -0
  56. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/recognition/countersinks.py +0 -0
  57. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/recognition/fillets.py +0 -0
  58. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/recognition/flats.py +0 -0
  59. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/recognition/grooves.py +0 -0
  60. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/recognition/levels.py +0 -0
  61. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/recognition/plates.py +0 -0
  62. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/recognition/slots.py +0 -0
  63. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/recognition/turned.py +0 -0
  64. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/registry.py +0 -0
  65. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/repair.py +0 -0
  66. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/score.py +0 -0
  67. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/sheet.py +0 -0
  68. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/sheet_dsl.py +0 -0
  69. {draftwright-0.3.5 → draftwright-0.3.7}/src/draftwright/sheet_emit.py +0 -0
  70. {draftwright-0.3.5 → draftwright-0.3.7}/tests/_kernel.py +0 -0
  71. {draftwright-0.3.5 → draftwright-0.3.7}/tests/_layout_sig.py +0 -0
  72. {draftwright-0.3.5 → draftwright-0.3.7}/tests/fixtures/grm03_thumbwheel_drive_screw.step +0 -0
  73. {draftwright-0.3.5 → draftwright-0.3.7}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  74. {draftwright-0.3.5 → draftwright-0.3.7}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  75. {draftwright-0.3.5 → draftwright-0.3.7}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  76. {draftwright-0.3.5 → draftwright-0.3.7}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  77. {draftwright-0.3.5 → draftwright-0.3.7}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  78. {draftwright-0.3.5 → draftwright-0.3.7}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  79. {draftwright-0.3.5 → draftwright-0.3.7}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  80. {draftwright-0.3.5 → draftwright-0.3.7}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  81. {draftwright-0.3.5 → draftwright-0.3.7}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  82. {draftwright-0.3.5 → draftwright-0.3.7}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  83. {draftwright-0.3.5 → draftwright-0.3.7}/tests/refactor_golden/bracket_section.json +0 -0
  84. {draftwright-0.3.5 → draftwright-0.3.7}/tests/refactor_golden/centered_rebate.json +0 -0
  85. {draftwright-0.3.5 → draftwright-0.3.7}/tests/refactor_golden/chamfered.json +0 -0
  86. {draftwright-0.3.5 → draftwright-0.3.7}/tests/refactor_golden/filleted.json +0 -0
  87. {draftwright-0.3.5 → draftwright-0.3.7}/tests/refactor_golden/flange_dense.json +0 -0
  88. {draftwright-0.3.5 → draftwright-0.3.7}/tests/refactor_golden/grid_plate.json +0 -0
  89. {draftwright-0.3.5 → draftwright-0.3.7}/tests/refactor_golden/grooved_shaft.json +0 -0
  90. {draftwright-0.3.5 → draftwright-0.3.7}/tests/refactor_golden/hex_bar.json +0 -0
  91. {draftwright-0.3.5 → draftwright-0.3.7}/tests/refactor_golden/holed_slot.json +0 -0
  92. {draftwright-0.3.5 → draftwright-0.3.7}/tests/refactor_golden/pocketed.json +0 -0
  93. {draftwright-0.3.5 → draftwright-0.3.7}/tests/refactor_golden/prismatic_ladder.json +0 -0
  94. {draftwright-0.3.5 → draftwright-0.3.7}/tests/refactor_golden/scattered_plate.json +0 -0
  95. {draftwright-0.3.5 → draftwright-0.3.7}/tests/refactor_golden/side_drilled.json +0 -0
  96. {draftwright-0.3.5 → draftwright-0.3.7}/tests/refactor_golden/turned_stepped.json +0 -0
  97. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_architecture_docs.py +0 -0
  98. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_carve_free_position_callers.py +0 -0
  99. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_declare.py +0 -0
  100. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_detect_once.py +0 -0
  101. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_drawing_encapsulation.py +0 -0
  102. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_e2e_slice.py +0 -0
  103. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_e2e_standards.py +0 -0
  104. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_export_dxf_zoom.py +0 -0
  105. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_fillets_adjacency.py +0 -0
  106. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_fits.py +0 -0
  107. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_gdt_placement.py +0 -0
  108. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_import_boundaries.py +0 -0
  109. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_layout.py +0 -0
  110. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_layout_cleanliness.py +0 -0
  111. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_layout_property.py +0 -0
  112. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_lint_box_cache.py +0 -0
  113. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_lint_reconciliation.py +0 -0
  114. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_linting.py +0 -0
  115. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_object_aspects.py +0 -0
  116. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_occupancy_boxes.py +0 -0
  117. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_part_model.py +0 -0
  118. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_pitch_dim_footprint.py +0 -0
  119. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_pmi.py +0 -0
  120. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_recogniser_contract.py +0 -0
  121. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_recognition.py +0 -0
  122. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_refactor_golden.py +0 -0
  123. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_registry.py +0 -0
  124. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_render_seam.py +0 -0
  125. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_score.py +0 -0
  126. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_script_detail_parity.py +0 -0
  127. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_sheet_emit.py +0 -0
  128. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_sheet_gdt.py +0 -0
  129. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_sheet_notes.py +0 -0
  130. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_sheet_of.py +0 -0
  131. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_sheet_tables.py +0 -0
  132. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_solve_trace.py +0 -0
  133. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_strip_layout.py +0 -0
  134. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_tolerances.py +0 -0
  135. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_turned_steps.py +0 -0
  136. {draftwright-0.3.5 → draftwright-0.3.7}/tests/test_witness_label_reconciliation.py +0 -0
@@ -1,6 +1,71 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
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
+
25
+ ## v0.3.6 — 2026-07-21
26
+
27
+ ### Fixed
28
+
29
+ - **`Sheet.step(boss)` on a prismatic part fails loudly instead of silently
30
+ dropping the height** (#631): declaring a turned `.step()` at a cylinder that is
31
+ really an external boss set `orientation="z"`, which made `render_height_ladder`
32
+ suppress the overall-height dim on the assumption the OD/step chain conveyed it —
33
+ but on a prismatic box that chain never renders, so the height vanished and
34
+ nothing replaced it (11 → 10 annotations). The build now detects a `StepFeature`
35
+ coincident with a `BossFeature` (same axis, origin, diameter) — a combination
36
+ that can never be legitimate — and raises a clear `ValueError` pointing to
37
+ `.boss()` (which renders its own ø and height per #632). Legitimate turned shafts
38
+ have steps but no coincident boss, so they are unaffected.
39
+ - **`detail_view=True` no longer a silent no-op when the detail can't be placed**
40
+ (#630): a part whose lint recommends a detail view (`step_dim_dropped`) but whose
41
+ crowded band is full-width (e.g. a shelled cover's stacked face levels) can't be
42
+ enlarged legibly *and* still fit alongside the main views — so `detail_view=True`
43
+ produced a drawing byte-identical to `False`, silently ignoring the opt-in. It
44
+ now records a `detail_unplaceable` warning naming the reason and an actionable
45
+ remedy (dimension manually / move onto its own sheet), so the opt-in is
46
+ observable. Parts whose detail *does* fit are unchanged.
47
+ - **Imperative `--script` round-trips the title-block fields + sheet furniture**
48
+ (#775): the imperative flavour reproduced only title/number/tolerance/drawn_by/
49
+ scale/page, dropping the `material`/`date`/`revision`/`company` fields (#766) and
50
+ the `frame`/`zones`/`projection` furniture (#767/#768/#769) — so a regenerated
51
+ imperative script didn't match the direct CLI. They now ride the emitted script's
52
+ cog config block and its `build_drawing(...)` call (the Sheet flavour already
53
+ round-tripped them), and the CLI forwards them to `generate_script`.
54
+
55
+ ### Internal
56
+
57
+ - **Fast-tier dense-sheet canary for the #733/#734 strip-pressure regression**
58
+ (#737): a synthetic contended-front-strip fixture that drops a step-height
59
+ principal dim on the pre-#734 commit and stays clean on `main` — closing the
60
+ fast-tier gap the #733 regression fell through (only the CI-only slow CTC
61
+ fixtures exercised real strip pressure before).
62
+ - **Layout-hypothesis fuzz tier is now a reproducible PR gate** (#692):
63
+ `derandomize=True` makes every run generate the same fixed example set (with
64
+ `print_blob` for paste-able reproduction), so a failure recurs deterministically
65
+ instead of surfacing on a seed-dependent minority of runs; exploratory fuzzing
66
+ moves behind `DRAFTWRIGHT_FUZZ_EXPLORE=1`. The two `test_make_drawing.py` xdist
67
+ observations (#669) were investigated and found non-reproducible — both tests are
68
+ non-flaky by construction/measurement on current `main`.
4
69
 
5
70
  ## v0.3.5 — 2026-07-20
6
71
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.3.5
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.5"
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):
@@ -597,6 +597,23 @@ def _resolve_details(dwg, a: Analysis, *, ctx) -> None:
597
597
  dwg.pin(hname)
598
598
  if placed:
599
599
  n_placed += 1
600
+ elif req.kind == "prismatic-steps":
601
+ # (#630) The prismatic-steps request is the one queued only under the
602
+ # build_drawing(detail_view=True) opt-in (_request_prismatic_detail's gate),
603
+ # so a bail-out here means that opt-in produced nothing. Say so — with an
604
+ # actionable remedy — rather than returning a drawing byte-identical to
605
+ # detail_view=False (a full-width crowded band, e.g. a shelled cover's stacked
606
+ # face levels, can't be enlarged legibly and still fit alongside the main views).
607
+ # The automatic turned-head requests (queued unconditionally by
608
+ # render_step_lengths) are NOT opt-in — their bail-out is the normal path (the
609
+ # main view locates the head/block inline), so they stay silent.
610
+ ctx.record_issue(
611
+ "warning",
612
+ "detail_unplaceable",
613
+ f"{req.kind} detail view requested but could not be placed legibly on this "
614
+ "sheet (the crowded band is too wide to enlarge and still fit); dimension the "
615
+ "feature manually or move it onto its own sheet",
616
+ )
600
617
 
601
618
 
602
619
  def _overall_height_name(dwg, a: Analysis) -> str | None:
@@ -287,6 +287,33 @@ def _assemble(
287
287
  rot = build_rotational_feature(a)
288
288
  if rot is not None:
289
289
  pm = replace(pm, features=[*pm.features, rot])
290
+ # A z step declares a segment of a z-turned profile; the height ladder then suppresses
291
+ # the overall height on the premise the step-length chain conveys it (from_model
292
+ # render_height_ladder). That premise holds only if the declared steps span the part's
293
+ # FULL z-extent — a boss, or even a stepped boss on a plate, declared via .step()
294
+ # leaves the bulk unspanned, so suppression silently drops the overall height (#631).
295
+ # Guard on that exact condition rather than a classifier proxy (is_rotational / prof
296
+ # both have blind spots): raise when the z-steps don't reach both ends of the part.
297
+ z_steps = [f for f in pm.features if isinstance(f, StepFeature) and f.frame.axis == "z"]
298
+ if pm.orientation == "z" and z_steps:
299
+ tol = 1e-3 * max(a.z_size, 1.0) # small absolute float epsilon, floored
300
+ # The step lengths convey the overall height only if the steps TILE the z-extent
301
+ # contiguously — a single reach to each end isn't enough (an interior gap would
302
+ # still leave height unconveyed). Walk the spans low→high, extending coverage.
303
+ spans = sorted(
304
+ (min(p0[2], p1[2]), max(p0[2], p1[2])) for f in z_steps for (p0, p1) in [f.span]
305
+ )
306
+ covered = a.bb.min.Z
307
+ for lo, hi in spans:
308
+ if lo <= covered + tol:
309
+ covered = max(covered, hi)
310
+ if spans[0][0] > a.bb.min.Z + tol or covered < a.bb.max.Z - tol:
311
+ raise ValueError(
312
+ "step() declares a segment of a turned profile, but the declared steps "
313
+ "don't span this part's full height — it is not a turned (rotational) "
314
+ "body. For a boss (an external cylinder on a prismatic part) use .boss() "
315
+ "— it renders its own ø and height."
316
+ )
290
317
  # PMI (STEP AP242) is likewise detection-sourced, so a declared / emitted-script model
291
318
  # carries none. When PMI annotation is on, synthesise the same imported drafting
292
319
  # annotations detection would (render_pmi reads them off the model, gated on a.pmi_mode)
@@ -975,6 +1002,13 @@ def _write_script(
975
1002
  f"NUMBER = {a.number!r}",
976
1003
  f"TOLERANCE = {a.tolerance!r}",
977
1004
  f"DRAWN_BY = {a.drawn_by!r}",
1005
+ f"MATERIAL = {a.material!r}",
1006
+ f"DATE = {a.date!r}",
1007
+ f"REVISION = {a.revision!r}",
1008
+ f"COMPANY = {a.company!r}",
1009
+ f"FRAME = {a.frame!r}",
1010
+ f"ZONES = {a.zones!r}",
1011
+ f"PROJECTION = {a.projection!r}",
978
1012
  f"PMI = {a.pmi_mode!r}",
979
1013
  f"SCALE = {scale!r}",
980
1014
  f"PAGE = {page!r}",
@@ -989,6 +1023,13 @@ def _write_script(
989
1023
  f"_NUMBER = {a.number!r}\n"
990
1024
  f"_TOLERANCE = {a.tolerance!r}\n"
991
1025
  f"_DRAWN_BY = {a.drawn_by!r}\n"
1026
+ f"_MATERIAL = {a.material!r}\n"
1027
+ f"_DATE = {a.date!r}\n"
1028
+ f"_REVISION = {a.revision!r}\n"
1029
+ f"_COMPANY = {a.company!r}\n"
1030
+ f"_FRAME = {a.frame!r} # draw a sheet border (#767)\n"
1031
+ f"_ZONES = {a.zones!r} # ISO 5457 zone ruler (implies frame, #768)\n"
1032
+ f"_PROJECTION = {a.projection!r} # 'third' | 'first' | None (#769)\n"
992
1033
  f"_PMI = {a.pmi_mode!r} # 'off' | 'report' | 'annotate'\n"
993
1034
  f"_SCALE = {scale!r} # None = auto; e.g. 5 for 5:1, 0.5 for 1:2\n"
994
1035
  f"_PAGE = {page!r} # None = auto; e.g. 'A3' or (297, 210)\n"
@@ -997,7 +1038,11 @@ def _write_script(
997
1038
  " for _k, _v in [\n"
998
1039
  " ('STEP_FILE', repr(_STEP_FILE)), ('TITLE', repr(_TITLE)),\n"
999
1040
  " ('NUMBER', repr(_NUMBER)), ('TOLERANCE', repr(_TOLERANCE)),\n"
1000
- " ('DRAWN_BY', repr(_DRAWN_BY)), ('PMI', repr(_PMI)),\n"
1041
+ " ('DRAWN_BY', repr(_DRAWN_BY)), ('MATERIAL', repr(_MATERIAL)),\n"
1042
+ " ('DATE', repr(_DATE)), ('REVISION', repr(_REVISION)),\n"
1043
+ " ('COMPANY', repr(_COMPANY)), ('FRAME', repr(_FRAME)),\n"
1044
+ " ('ZONES', repr(_ZONES)), ('PROJECTION', repr(_PROJECTION)),\n"
1045
+ " ('PMI', repr(_PMI)),\n"
1001
1046
  " ('SCALE', repr(_SCALE)), ('PAGE', repr(_PAGE)),\n"
1002
1047
  " ]:\n"
1003
1048
  " cog.outl(f'{_k} = {_v}')\n"
@@ -1042,6 +1087,13 @@ def _write_script(
1042
1087
  " number=NUMBER,\n"
1043
1088
  " tolerance=TOLERANCE,\n"
1044
1089
  " drawn_by=DRAWN_BY,\n"
1090
+ " material=MATERIAL,\n"
1091
+ " date=DATE,\n"
1092
+ " revision=REVISION,\n"
1093
+ " company=COMPANY,\n"
1094
+ " frame=FRAME,\n"
1095
+ " zones=ZONES,\n"
1096
+ " projection=PROJECTION,\n"
1045
1097
  " pmi=PMI,\n"
1046
1098
  " scale=SCALE,\n"
1047
1099
  " page=PAGE,\n"
@@ -1103,6 +1155,13 @@ def generate_script(
1103
1155
  pmi: Literal["off", "report", "annotate"] = "off",
1104
1156
  scale: float | None = None,
1105
1157
  page: str | None = None,
1158
+ material: str = "",
1159
+ date: str = "",
1160
+ revision: str = "A",
1161
+ company: str = "",
1162
+ frame: bool = False,
1163
+ zones: bool = False,
1164
+ projection: str | None = None,
1106
1165
  formats: Sequence[str] = ("pdf",),
1107
1166
  ) -> str:
1108
1167
  """Generate an editable Cog-enabled drawing script from a STEP file.
@@ -1131,7 +1190,22 @@ def generate_script(
1131
1190
  # here too would crash generation on an out-of-range value (e.g. --script --scale
1132
1191
  # 0.001 / --page A9) instead of writing the script and deferring — inconsistent with
1133
1192
  # a large unfittable scale, which already defers (review #401).
1134
- a = _analyse(step_file, title, number, tolerance, drawn_by, out, pmi=pmi)
1193
+ a = _analyse(
1194
+ step_file,
1195
+ title,
1196
+ number,
1197
+ tolerance,
1198
+ drawn_by,
1199
+ out,
1200
+ pmi=pmi,
1201
+ material=material,
1202
+ date=date,
1203
+ revision=revision,
1204
+ company=company,
1205
+ frame=frame,
1206
+ zones=zones,
1207
+ projection=projection,
1208
+ )
1135
1209
  return _write_script(a, scale=scale, page=page, formats=formats)
1136
1210
 
1137
1211
 
@@ -235,6 +235,13 @@ def main(
235
235
  number=number,
236
236
  tolerance=tolerance,
237
237
  drawn_by=drawn_by,
238
+ material=material,
239
+ date=date,
240
+ revision=revision,
241
+ company=company,
242
+ frame=frame,
243
+ zones=zones,
244
+ projection=projection or None,
238
245
  pmi=pmi.value,
239
246
  scale=scale,
240
247
  page=page,
@@ -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