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.
- {draftwright-0.3.6 → draftwright-0.3.7}/CHANGELOG.md +22 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/PKG-INFO +1 -1
- {draftwright-0.3.6 → draftwright-0.3.7}/pyproject.toml +1 -1
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotations/from_model.py +166 -6
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/linting/structural.py +128 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_lint_structural.py +79 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_make_drawing.py +268 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_private_test_imports.py +8 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/.gitignore +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/LICENSE +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/README.md +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/docs/adr/README.md +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/skills/SKILL.md +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/__init__.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/_core.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/_geometry.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/analysis.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotate.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotations/__init__.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotations/_common.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotations/balloons.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotations/holes.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotations/orchestrator.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/annotations/sections.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/builder.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/cli.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/compose.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/drawing.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/export.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/fits.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/fonts/__init__.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/intents.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/layout.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/linting/__init__.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/linting/coverage.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/linting/issues.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/linting/suggest.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/make_drawing.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/model/__init__.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/model/declare.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/model/detect.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/model/ir.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/model/planner.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/pmi.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/projection.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/__init__.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/_features.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/_record.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/chamfers.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/countersinks.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/fillets.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/flats.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/grooves.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/levels.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/plates.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/slots.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/recognition/turned.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/registry.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/repair.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/score.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/sheet.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/sheet_dsl.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/src/draftwright/sheet_emit.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/_kernel.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/_layout_sig.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/grm03_thumbwheel_drive_screw.step +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/bracket_section.json +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/centered_rebate.json +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/chamfered.json +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/filleted.json +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/flange_dense.json +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/grid_plate.json +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/grooved_shaft.json +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/hex_bar.json +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/holed_slot.json +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/pocketed.json +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/prismatic_ladder.json +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/scattered_plate.json +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/side_drilled.json +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/refactor_golden/turned_stepped.json +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_architecture_docs.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_carve_free_position_callers.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_declare.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_dense_sheet_canary.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_detect_once.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_drawing_encapsulation.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_e2e_slice.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_e2e_standards.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_export_dxf_zoom.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_fillets_adjacency.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_fits.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_gdt_placement.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_import_boundaries.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_layout.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_layout_cleanliness.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_layout_hypothesis.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_layout_property.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_lint_box_cache.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_lint_reconciliation.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_linting.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_object_aspects.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_occupancy_boxes.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_part_model.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_pitch_dim_footprint.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_pmi.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_recogniser_contract.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_recognition.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_refactor_golden.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_registry.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_render_seam.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_score.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_script_detail_parity.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_sheet_emit.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_sheet_gdt.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_sheet_notes.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_sheet_of.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_sheet_tables.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_solve_trace.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_strip_layout.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_tolerances.py +0 -0
- {draftwright-0.3.6 → draftwright-0.3.7}/tests/test_turned_steps.py +0 -0
- {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
|
|
@@ -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)
|
|
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
|
-
|
|
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)
|