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