draftwright 0.1.2__tar.gz → 0.1.4__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.1.2 → draftwright-0.1.4}/.gitignore +1 -0
- {draftwright-0.1.2 → draftwright-0.1.4}/CHANGELOG.md +18 -0
- {draftwright-0.1.2 → draftwright-0.1.4}/PKG-INFO +1 -1
- {draftwright-0.1.2 → draftwright-0.1.4}/pyproject.toml +1 -1
- {draftwright-0.1.2 → draftwright-0.1.4}/src/draftwright/make_drawing.py +341 -83
- {draftwright-0.1.2 → draftwright-0.1.4}/tests/test_make_drawing.py +200 -27
- {draftwright-0.1.2 → draftwright-0.1.4}/LICENSE +0 -0
- {draftwright-0.1.2 → draftwright-0.1.4}/README.md +0 -0
- {draftwright-0.1.2 → draftwright-0.1.4}/skills/SKILL.md +0 -0
- {draftwright-0.1.2 → draftwright-0.1.4}/src/draftwright/__init__.py +0 -0
- {draftwright-0.1.2 → draftwright-0.1.4}/src/draftwright/pmi.py +0 -0
- {draftwright-0.1.2 → draftwright-0.1.4}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
- {draftwright-0.1.2 → draftwright-0.1.4}/tests/test_e2e_standards.py +0 -0
- {draftwright-0.1.2 → draftwright-0.1.4}/tests/test_pmi.py +0 -0
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## v0.1.4 — 2026-06-15
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Feature annotations (hole callouts, location dimensions, section view) now
|
|
10
|
+
fire on feature presence independent of the turned/prismatic classification,
|
|
11
|
+
so turned-and-drilled parts (e.g. flanges) get both the OD/centreline base
|
|
12
|
+
set and per-hole callouts plus bolt-circle furniture (#10).
|
|
13
|
+
- Isometric view placement now uses a general largest-empty-rectangle search in
|
|
14
|
+
place of the wide/flat-on-A3 special case (#11).
|
|
15
|
+
- Concentric bore-leader stacking is generalised beyond three, and the
|
|
16
|
+
step-height dimension gate is now a single derived constant (#10, #12).
|
|
17
|
+
|
|
18
|
+
### Internal
|
|
19
|
+
|
|
20
|
+
- Single-sourced duplicated geometry constants from the draft preset (#12).
|
|
21
|
+
- Minor comment and logging cleanups.
|
|
22
|
+
|
|
5
23
|
## v0.1.0 — 2026-06-14
|
|
6
24
|
|
|
7
25
|
Initial release — spun out of `build123d-drafting-helpers` v0.9.1.
|
|
@@ -77,6 +77,8 @@ _log = logging.getLogger(__name__)
|
|
|
77
77
|
|
|
78
78
|
_TB_W = 150.0
|
|
79
79
|
_MARGIN = 10.0
|
|
80
|
+
_TB_CLEAR = _MARGIN + 1.0 # title-block inset: one extra mm over _MARGIN for clearance
|
|
81
|
+
_FONT_SIZE = 3.0 # annotation text height (page-mm); the draft preset is built with this
|
|
80
82
|
_DIM_PAD = 18.0
|
|
81
83
|
_TB_H = 35.0
|
|
82
84
|
# Minimum acceptable projected view dimension (page-mm). Below this, annotation
|
|
@@ -167,6 +169,40 @@ def _is_rotational(x_size, y_size, od_diam, od_axis_offset) -> bool:
|
|
|
167
169
|
)
|
|
168
170
|
|
|
169
171
|
|
|
172
|
+
# A hole is "concentric" with a turned part's rotation axis when its drilling
|
|
173
|
+
# axis is the Z (OD) axis and its opening sits on the part centreline. Such
|
|
174
|
+
# bores are already dimensioned by the ldr_z bore leaders, so they must not
|
|
175
|
+
# also receive a hole callout / location dim (#10). Off-axis holes (a bolt
|
|
176
|
+
# circle, a cross-hole) fall through to the feature-presence path.
|
|
177
|
+
_CONCENTRIC_TOL_MM = 0.5
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _is_concentric_hole(h, a, axis_letter) -> bool:
|
|
181
|
+
"""True when *h* is an axial bore on the part centreline (turned base set)."""
|
|
182
|
+
if axis_letter(h) != "z":
|
|
183
|
+
return False
|
|
184
|
+
return math.hypot(h.location[0] - a.cx, h.location[1] - a.cy) <= _CONCENTRIC_TOL_MM
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _concentric_bore_diams(a) -> list:
|
|
188
|
+
"""Distinct bore diameters on the rotation axis, in z_diams order (#10).
|
|
189
|
+
|
|
190
|
+
``a.z_diams`` carries every Z cylinder diameter — including off-axis ones
|
|
191
|
+
such as a bolt circle's holes — so the bore-leader set is restricted to
|
|
192
|
+
diameters that actually have an *internal* Z cylinder whose axis sits on
|
|
193
|
+
the part centreline. The OD is excluded. Returned in z_diams order so
|
|
194
|
+
label ordering is stable.
|
|
195
|
+
"""
|
|
196
|
+
z_cyls, _ = a.cyls
|
|
197
|
+
concentric = {
|
|
198
|
+
c["diameter"]
|
|
199
|
+
for c in _full_cyls(z_cyls)
|
|
200
|
+
if not c["external"]
|
|
201
|
+
and math.hypot(c["axis_xyz"][0] - a.cx, c["axis_xyz"][1] - a.cy) <= _CONCENTRIC_TOL_MM
|
|
202
|
+
}
|
|
203
|
+
return [d for d in a.z_diams if d != a.od_diam and any(abs(d - c) <= 0.15 for c in concentric)]
|
|
204
|
+
|
|
205
|
+
|
|
170
206
|
def lint_feature_coverage(part, annotations, tol: float = 0.15, cyls=None) -> list:
|
|
171
207
|
"""Coarse completeness check: report part diameters with no callout (#80).
|
|
172
208
|
|
|
@@ -425,6 +461,18 @@ _SLOT_DIM_STEP = 14.0 # fv_zones.right: step-height dimension
|
|
|
425
461
|
_SLOT_DIM_WIDTH = 8.0 # pv_zones.below: overall width dimension
|
|
426
462
|
_SLOT_DIM_DEPTH = 8.0 # sv_zones.below: overall depth dimension
|
|
427
463
|
|
|
464
|
+
# Smallest projected step height (page-mm) that can still carry a *legible*
|
|
465
|
+
# stacked dimension between its two extension lines. Derived from what has to
|
|
466
|
+
# fit vertically: the label (font height) plus an arrowhead at each end plus
|
|
467
|
+
# the text clearance above and below — not an arbitrary page-mm cutoff (#13).
|
|
468
|
+
# Used as the single gate in BOTH _analyse (n_steps) and _auto_annotate
|
|
469
|
+
# (dim_step placement) so the two can never diverge.
|
|
470
|
+
_MIN_STEP_DIM_MM = (
|
|
471
|
+
_FONT_SIZE
|
|
472
|
+
+ 2 * draft_preset(font_size=_FONT_SIZE, decimal_precision=1).arrow_length
|
|
473
|
+
+ 2 * draft_preset(font_size=_FONT_SIZE, decimal_precision=1).pad_around_text
|
|
474
|
+
)
|
|
475
|
+
|
|
428
476
|
# ---------------------------------------------------------------------------
|
|
429
477
|
# Annotation depth estimators (Phase 2 of #118)
|
|
430
478
|
#
|
|
@@ -454,6 +502,17 @@ def _est_pv_below_depth() -> float:
|
|
|
454
502
|
return _STRIP_GAP + _SLOT_DIM_WIDTH
|
|
455
503
|
|
|
456
504
|
|
|
505
|
+
# Inter-constant invariant: the gap between the front view top edge and the
|
|
506
|
+
# plan view bottom edge equals _DIM_PAD. The pv_zones.below strip occupies
|
|
507
|
+
# that gap, so _DIM_PAD must be at least as wide as the depth pv_below needs.
|
|
508
|
+
# If _DIM_PAD is shrunk below _est_pv_below_depth(), dim_width would silently
|
|
509
|
+
# overlap the front view rather than failing an allocate().
|
|
510
|
+
assert _DIM_PAD >= _est_pv_below_depth(), (
|
|
511
|
+
f"_DIM_PAD ({_DIM_PAD}) is smaller than pv_below slot depth "
|
|
512
|
+
f"({_est_pv_below_depth()}); bump _DIM_PAD or shrink the slot constants."
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
457
516
|
# ---------------------------------------------------------------------------
|
|
458
517
|
# Two-pass layout — Pass 1: annotation strip depth measurement (#131)
|
|
459
518
|
#
|
|
@@ -462,7 +521,9 @@ def _est_pv_below_depth() -> float:
|
|
|
462
521
|
# ---------------------------------------------------------------------------
|
|
463
522
|
|
|
464
523
|
|
|
465
|
-
def _est_bore_callout_width(
|
|
524
|
+
def _est_bore_callout_width(
|
|
525
|
+
holes, font_size: float = _FONT_SIZE, patterns=None, pad_around_text: float = 2.0
|
|
526
|
+
) -> float:
|
|
466
527
|
"""Estimate the maximum bore callout label width (page-mm) across all holes.
|
|
467
528
|
|
|
468
529
|
Groups holes by machining spec (same as _annotate_holes), then estimates
|
|
@@ -471,6 +532,7 @@ def _est_bore_callout_width(holes, font_size: float = 3.0, patterns=None) -> flo
|
|
|
471
532
|
Returns the label width only — elbow_dx and gap clearance are NOT included;
|
|
472
533
|
callers that need the full strip depth should add those overheads separately.
|
|
473
534
|
Returns 0.0 when the hole list is empty.
|
|
535
|
+
*pad_around_text* should come from ``draft_preset(...).pad_around_text``.
|
|
474
536
|
"""
|
|
475
537
|
if not holes:
|
|
476
538
|
return 0.0
|
|
@@ -488,7 +550,7 @@ def _est_bore_callout_width(holes, font_size: float = 3.0, patterns=None) -> flo
|
|
|
488
550
|
h_fs = font_size
|
|
489
551
|
gap = 0.45 * h_fs
|
|
490
552
|
sym_w = h_fs
|
|
491
|
-
pad =
|
|
553
|
+
pad = pad_around_text
|
|
492
554
|
char_w = 0.6 * h_fs # avg character width
|
|
493
555
|
|
|
494
556
|
max_w = 0.0
|
|
@@ -539,18 +601,29 @@ class StripDepths:
|
|
|
539
601
|
left: float # horizontal corridor left of FV/PV
|
|
540
602
|
|
|
541
603
|
|
|
542
|
-
def _measure_strips(
|
|
604
|
+
def _measure_strips(
|
|
605
|
+
holes,
|
|
606
|
+
patterns,
|
|
607
|
+
n_steps: int,
|
|
608
|
+
bb,
|
|
609
|
+
font_size: float = _FONT_SIZE,
|
|
610
|
+
arrow_length: float = 2.7,
|
|
611
|
+
pad_around_text: float = 2.0,
|
|
612
|
+
) -> StripDepths:
|
|
543
613
|
"""Compute annotation strip depths from hole geometry (Pass 1 of #131).
|
|
544
614
|
|
|
545
615
|
All annotation sizes are scale-independent because font_size is a fixed
|
|
546
616
|
page-mm constant, so there is no circularity with choose_scale().
|
|
617
|
+
*arrow_length* and *pad_around_text* should come from ``draft_preset(...)``.
|
|
547
618
|
"""
|
|
548
|
-
bore_depth = _est_bore_callout_width(
|
|
619
|
+
bore_depth = _est_bore_callout_width(
|
|
620
|
+
holes, font_size, patterns=patterns, pad_around_text=pad_around_text
|
|
621
|
+
)
|
|
549
622
|
# Add elbow clearance and leader-to-label gap so gap_fv_sv fully contains
|
|
550
|
-
# the composed leader: elbow_dx (= arrow_length
|
|
551
|
-
#
|
|
623
|
+
# the composed leader: elbow_dx (= draft.arrow_length) + gap
|
|
624
|
+
# (= draft.pad_around_text), always present.
|
|
552
625
|
if bore_depth > 0:
|
|
553
|
-
bore_depth +=
|
|
626
|
+
bore_depth += pad_around_text + arrow_length
|
|
554
627
|
right = max(_est_right_strip_depth(n_steps), bore_depth)
|
|
555
628
|
left = max(_DIM_PAD, bore_depth)
|
|
556
629
|
return StripDepths(right=right, left=left)
|
|
@@ -663,6 +736,52 @@ def choose_scale(
|
|
|
663
736
|
# ---------------------------------------------------------------------------
|
|
664
737
|
|
|
665
738
|
|
|
739
|
+
def _largest_empty_rect(drawable, obstacles):
|
|
740
|
+
"""Largest axis-aligned empty rectangle in *drawable* avoiding *obstacles*.
|
|
741
|
+
|
|
742
|
+
*drawable* and each obstacle are ``(x0, y0, x1, y1)`` page-mm boxes. Returns
|
|
743
|
+
the empty sub-rectangle of *drawable* (overlapping no obstacle) that maximises
|
|
744
|
+
the side of the largest square it can hold — i.e. ``min(width, height)`` — so
|
|
745
|
+
the (near-square) iso view can be scaled up as far as possible.
|
|
746
|
+
|
|
747
|
+
The obstacle set is tiny (front/plan/side views + title block), so a
|
|
748
|
+
gap-based search over candidate edges is both exact enough and cheap: every
|
|
749
|
+
maximal empty rectangle has edges drawn from the drawable bounds and the
|
|
750
|
+
obstacle bounds, so enumerating those cut lines finds the optimum.
|
|
751
|
+
"""
|
|
752
|
+
dx0, dy0, dx1, dy1 = drawable
|
|
753
|
+
xs = sorted({dx0, dx1, *(c for o in obstacles for c in (o[0], o[2]) if dx0 < c < dx1)})
|
|
754
|
+
ys = sorted({dy0, dy1, *(c for o in obstacles for c in (o[1], o[3]) if dy0 < c < dy1)})
|
|
755
|
+
|
|
756
|
+
best = None
|
|
757
|
+
best_score = 0.0
|
|
758
|
+
for i in range(len(xs) - 1):
|
|
759
|
+
for j in range(i + 1, len(xs)):
|
|
760
|
+
rx0, rx1 = xs[i], xs[j]
|
|
761
|
+
for k in range(len(ys) - 1):
|
|
762
|
+
for m in range(k + 1, len(ys)):
|
|
763
|
+
ry0, ry1 = ys[k], ys[m]
|
|
764
|
+
if any(
|
|
765
|
+
rx0 < o[2] and o[0] < rx1 and ry0 < o[3] and o[1] < ry1 for o in obstacles
|
|
766
|
+
):
|
|
767
|
+
continue
|
|
768
|
+
score = min(rx1 - rx0, ry1 - ry0)
|
|
769
|
+
if score > best_score:
|
|
770
|
+
best_score = score
|
|
771
|
+
best = (rx0, ry0, rx1, ry1)
|
|
772
|
+
if best is None:
|
|
773
|
+
# No empty rectangle exists (obstacles cover the drawable area). This
|
|
774
|
+
# is unreachable in practice — choose_scale always leaves a gap — but
|
|
775
|
+
# if it ever happens the iso would render over the other views, so flag
|
|
776
|
+
# it rather than fail silently.
|
|
777
|
+
_log.warning(
|
|
778
|
+
"No empty rectangle found for the iso view; obstacles fill the "
|
|
779
|
+
"drawable area — iso may overlap other views"
|
|
780
|
+
)
|
|
781
|
+
return drawable
|
|
782
|
+
return best
|
|
783
|
+
|
|
784
|
+
|
|
666
785
|
def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, page=None, pmi="off"):
|
|
667
786
|
"""Load STEP or use a build123d Shape, analyse geometry, compute layout.
|
|
668
787
|
|
|
@@ -744,13 +863,26 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
|
|
|
744
863
|
# Pass 1 (two-pass layout, #131): measure annotation strip depths before
|
|
745
864
|
# view positions are fixed. font_size=3.0 is a fixed page-mm constant so
|
|
746
865
|
# all annotation sizes are scale-independent — no circularity.
|
|
866
|
+
# Construct the same draft preset used later in build_drawing() to read
|
|
867
|
+
# arrow_length and pad_around_text from their authoritative source rather
|
|
868
|
+
# than re-stating them as magic literals in the estimators.
|
|
869
|
+
_draft_est = draft_preset(font_size=_FONT_SIZE, decimal_precision=1)
|
|
870
|
+
_arrow_length = _draft_est.arrow_length
|
|
871
|
+
_pad_around_text = _draft_est.pad_around_text
|
|
747
872
|
holes = find_holes(part, cyls=(z_cyls, cross_cyls))
|
|
748
873
|
patterns = find_hole_patterns(holes)
|
|
749
874
|
|
|
750
875
|
# Conservative upper bound for page selection: count all candidate step
|
|
751
|
-
# faces without the SCALE-dependent
|
|
876
|
+
# faces without the SCALE-dependent _MIN_STEP_DIM_MM gate (SCALE not yet known).
|
|
752
877
|
n_steps_ub = len(step_zs[:3])
|
|
753
|
-
strips_ub = _measure_strips(
|
|
878
|
+
strips_ub = _measure_strips(
|
|
879
|
+
holes,
|
|
880
|
+
patterns,
|
|
881
|
+
n_steps_ub,
|
|
882
|
+
bb,
|
|
883
|
+
arrow_length=_arrow_length,
|
|
884
|
+
pad_around_text=_pad_around_text,
|
|
885
|
+
)
|
|
754
886
|
SCALE, PAGE_W, PAGE_H, TB_W = choose_scale(
|
|
755
887
|
x_size, y_size, z_size, n_steps=n_steps_ub, scale=scale, page=page, strips=strips_ub
|
|
756
888
|
)
|
|
@@ -772,9 +904,16 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
|
|
|
772
904
|
)
|
|
773
905
|
DIM_PAD = _DIM_PAD
|
|
774
906
|
margin = _MARGIN
|
|
775
|
-
# Refine: apply the same
|
|
776
|
-
n_steps = len([z for z in step_zs[:3] if (z - bb.min.Z) * SCALE >=
|
|
777
|
-
strips = _measure_strips(
|
|
907
|
+
# Refine: apply the same legibility gate _auto_annotate uses for dim_step.
|
|
908
|
+
n_steps = len([z for z in step_zs[:3] if (z - bb.min.Z) * SCALE >= _MIN_STEP_DIM_MM])
|
|
909
|
+
strips = _measure_strips(
|
|
910
|
+
holes,
|
|
911
|
+
patterns,
|
|
912
|
+
n_steps,
|
|
913
|
+
bb,
|
|
914
|
+
arrow_length=_arrow_length,
|
|
915
|
+
pad_around_text=_pad_around_text,
|
|
916
|
+
)
|
|
778
917
|
gap_fv_sv = max(DIM_PAD, strips.right)
|
|
779
918
|
gap_left = max(DIM_PAD, strips.left)
|
|
780
919
|
|
|
@@ -803,12 +942,44 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
|
|
|
803
942
|
SV_X = FV_X + fv_hw + gap_fv_sv + sv_hw
|
|
804
943
|
SV_Y = FV_Y
|
|
805
944
|
sv_right = SV_X + sv_hw + DIM_PAD
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
945
|
+
# Right wall for the side-view annotation strip: full page (minus margin)
|
|
946
|
+
# when the views clear the title block, otherwise stop left of it.
|
|
947
|
+
sv_right_wall = (
|
|
948
|
+
(PAGE_W - margin) if (PV_Y - pv_hh) > (margin + _TB_H) else (PAGE_W - TB_W - margin)
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
# Iso placement (#11): rather than special-casing wide/flat-on-A3, find the
|
|
952
|
+
# largest empty rectangle in the drawable area after the FV/PV/SV views and
|
|
953
|
+
# the title block are positioned, and centre the iso in it. _fit_iso_view
|
|
954
|
+
# then scales the iso to fill that rectangle. Each view box is padded by
|
|
955
|
+
# DIM_PAD to reserve its annotation strips; the title block is padded too.
|
|
956
|
+
drawable = (margin, margin, PAGE_W - margin, PAGE_H - margin)
|
|
957
|
+
obstacles = [
|
|
958
|
+
(
|
|
959
|
+
FV_X - fv_hw - DIM_PAD,
|
|
960
|
+
FV_Y - fv_hh - DIM_PAD,
|
|
961
|
+
FV_X + fv_hw + DIM_PAD,
|
|
962
|
+
FV_Y + fv_hh + DIM_PAD,
|
|
963
|
+
),
|
|
964
|
+
(
|
|
965
|
+
PV_X - fv_hw - DIM_PAD,
|
|
966
|
+
PV_Y - pv_hh - DIM_PAD,
|
|
967
|
+
PV_X + fv_hw + DIM_PAD,
|
|
968
|
+
PV_Y + pv_hh + DIM_PAD,
|
|
969
|
+
),
|
|
970
|
+
(
|
|
971
|
+
SV_X - sv_hw - DIM_PAD,
|
|
972
|
+
SV_Y - fv_hh - DIM_PAD,
|
|
973
|
+
SV_X + sv_hw + DIM_PAD,
|
|
974
|
+
SV_Y + fv_hh + DIM_PAD,
|
|
975
|
+
),
|
|
976
|
+
(PAGE_W - TB_W - 11 - DIM_PAD, margin, PAGE_W - 11 + DIM_PAD, 11 + _TB_H + DIM_PAD),
|
|
977
|
+
]
|
|
978
|
+
iso_left_limit, iso_bottom_limit, iso_right_limit, iso_top_limit = _largest_empty_rect(
|
|
979
|
+
drawable, obstacles
|
|
980
|
+
)
|
|
981
|
+
ISO_X = (iso_left_limit + iso_right_limit) / 2
|
|
982
|
+
ISO_Y = (iso_bottom_limit + iso_top_limit) / 2
|
|
812
983
|
|
|
813
984
|
# ------------------------------------------------------------------
|
|
814
985
|
# Strip / zone construction.
|
|
@@ -832,7 +1003,10 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
|
|
|
832
1003
|
fv_zones = ViewZones(
|
|
833
1004
|
right=Strip(fv_right_edge, sv_left_edge, direction=1),
|
|
834
1005
|
left=Strip(fv_left_edge, margin, direction=-1),
|
|
835
|
-
above
|
|
1006
|
+
# Stop the front-view 'above' strip short of pv_bottom_edge by the
|
|
1007
|
+
# slack the pv_below slot leaves in the gap, derived (not re-typed) so
|
|
1008
|
+
# it tracks _DIM_PAD and the slot constants.
|
|
1009
|
+
above=Strip(fv_top_edge, pv_bottom_edge - (_DIM_PAD - _est_pv_below_depth()), direction=1),
|
|
836
1010
|
below=Strip(fv_bottom_edge, margin, direction=-1),
|
|
837
1011
|
)
|
|
838
1012
|
pv_zones = ViewZones(
|
|
@@ -844,14 +1018,15 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
|
|
|
844
1018
|
right=Strip(pv_right_edge, sv_left_edge, direction=1),
|
|
845
1019
|
left=Strip(pv_left_edge, margin, direction=-1),
|
|
846
1020
|
above=Strip(pv_top_edge, PAGE_H - margin, direction=1),
|
|
847
|
-
# gap_fv_pv =
|
|
1021
|
+
# gap_fv_pv = _DIM_PAD; pv_below needs _est_pv_below_depth() mm,
|
|
1022
|
+
# leaving (_DIM_PAD - _est_pv_below_depth()) mm slack (assert above).
|
|
848
1023
|
below=Strip(pv_bottom_edge, fv_top_edge, direction=-1),
|
|
849
1024
|
)
|
|
850
1025
|
sv_bottom_edge = SV_Y - fv_hh # same as fv_bottom_edge; side and front share Z height
|
|
851
1026
|
sv_zones = ViewZones(
|
|
852
1027
|
# sv_right already includes DIM_PAD; anchor here so the strip never
|
|
853
1028
|
# places annotations inside that gap
|
|
854
|
-
right=Strip(sv_right,
|
|
1029
|
+
right=Strip(sv_right, sv_right_wall, direction=1),
|
|
855
1030
|
left=None, # immediately abuts the front view's right edge
|
|
856
1031
|
above=Strip(sv_top_edge, PAGE_H - margin, direction=1),
|
|
857
1032
|
below=Strip(sv_bottom_edge, margin, direction=-1),
|
|
@@ -909,6 +1084,9 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
|
|
|
909
1084
|
SV_Y=SV_Y,
|
|
910
1085
|
ISO_X=ISO_X,
|
|
911
1086
|
ISO_Y=ISO_Y,
|
|
1087
|
+
iso_left_limit=iso_left_limit,
|
|
1088
|
+
iso_bottom_limit=iso_bottom_limit,
|
|
1089
|
+
iso_top_limit=iso_top_limit,
|
|
912
1090
|
# View half-extents in page units (convenient for strip arithmetic)
|
|
913
1091
|
fv_hw=fv_hw,
|
|
914
1092
|
fv_hh=fv_hh,
|
|
@@ -1257,9 +1435,22 @@ def _auto_annotate(dwg, a):
|
|
|
1257
1435
|
# that the iso has been projected and fitted. Always apply so that any
|
|
1258
1436
|
# future allocations are bounded; warn when the cursor has already passed
|
|
1259
1437
|
# the limit (dims already placed may overlap the iso view).
|
|
1260
|
-
_iso_x0,
|
|
1438
|
+
_iso_x0, _iso_y0, _, _iso_y1 = _iso_bbox(dwg)
|
|
1261
1439
|
_iso_x_limit = _iso_x0 - 4
|
|
1262
|
-
|
|
1440
|
+
# Only tighten a right strip when the iso shares the strip's y-range: a strip
|
|
1441
|
+
# that abuts the iso horizontally would otherwise lose annotation space, while
|
|
1442
|
+
# one sitting entirely above/below the iso (e.g. the SV strip when the iso is
|
|
1443
|
+
# in an upper-right zone) must keep its full width — capping it could push the
|
|
1444
|
+
# outer_limit below the strip anchor and break all its allocations.
|
|
1445
|
+
_right_strips = []
|
|
1446
|
+
for _rs, _y0, _y1 in (
|
|
1447
|
+
(a.fv_zones.right, a.FV_Y - a.fv_hh, a.FV_Y + a.fv_hh),
|
|
1448
|
+
(a.pv_zones.right, a.PV_Y - a.pv_hh, a.PV_Y + a.pv_hh),
|
|
1449
|
+
(a.sv_zones.right, a.SV_Y - a.fv_hh, a.SV_Y + a.fv_hh),
|
|
1450
|
+
):
|
|
1451
|
+
if _y0 < _iso_y1 and _iso_y0 < _y1:
|
|
1452
|
+
_right_strips.append(_rs)
|
|
1453
|
+
for _rs in _right_strips:
|
|
1263
1454
|
_rs.outer_limit = min(_rs.outer_limit, _iso_x_limit)
|
|
1264
1455
|
if _rs._cursor >= _iso_x_limit:
|
|
1265
1456
|
_log.warning(
|
|
@@ -1324,16 +1515,25 @@ def _auto_annotate(dwg, a):
|
|
|
1324
1515
|
)
|
|
1325
1516
|
|
|
1326
1517
|
# Z-axis bore leaders to the left of the front view — these assume bores
|
|
1327
|
-
# concentric with the rotation axis, so rotational only (#81)
|
|
1328
|
-
|
|
1518
|
+
# concentric with the rotation axis, so rotational only (#81). z_diams
|
|
1519
|
+
# carries *every* Z cylinder diameter including off-axis ones (e.g. a bolt
|
|
1520
|
+
# circle's holes), so the bore set is restricted to diameters that actually
|
|
1521
|
+
# belong to an internal cylinder on the rotation axis (#10): an off-axis
|
|
1522
|
+
# ø8 bolt hole must not surface as a phantom concentric bore leader.
|
|
1523
|
+
bores = _concentric_bore_diams(a) if a.is_rotational else []
|
|
1329
1524
|
if a.is_rotational and bores:
|
|
1330
1525
|
left_edge = FX(a.bb.min.X)
|
|
1331
1526
|
left_space = left_edge - a.margin
|
|
1332
1527
|
if left_space >= a.DIM_PAD:
|
|
1333
1528
|
ldr_length = a.DIM_PAD * 0.6
|
|
1334
1529
|
elbow_x = left_edge - ldr_length
|
|
1335
|
-
|
|
1336
|
-
|
|
1530
|
+
# Stack all distinct bores, centred on the axis (generalised beyond
|
|
1531
|
+
# the old hard cap of 3 — #10); any not annotated would surface via
|
|
1532
|
+
# the coverage lint, but all are placed here.
|
|
1533
|
+
n = len(bores)
|
|
1534
|
+
pitch = max(10.0, draft.font_size * 3.0)
|
|
1535
|
+
for i, d in enumerate(bores):
|
|
1536
|
+
tip_z = FZ(a.cz) + (i - (n - 1) / 2) * pitch
|
|
1337
1537
|
dwg.add(
|
|
1338
1538
|
Leader(
|
|
1339
1539
|
tip=(FX(a.cx - d / 2), tip_z, 0),
|
|
@@ -1363,13 +1563,28 @@ def _auto_annotate(dwg, a):
|
|
|
1363
1563
|
size = max(2.5, h.diameter * a.SCALE + 2.0)
|
|
1364
1564
|
dwg.add(CenterMark(to_page(h), size, draft), f"cm_{view}{i}")
|
|
1365
1565
|
|
|
1366
|
-
# Hole callouts,
|
|
1367
|
-
#
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1566
|
+
# Hole callouts, location dims, and the section view fire on *feature
|
|
1567
|
+
# presence*, independent of the turned/prismatic class (#10): the
|
|
1568
|
+
# classification only selects the base set (OD+centreline+ldr_z vs envelope
|
|
1569
|
+
# dims). A turned flange (round OD + a bolt circle) must get BOTH.
|
|
1570
|
+
#
|
|
1571
|
+
# On a turned part the concentric, axis-aligned bores are already
|
|
1572
|
+
# dimensioned by the ldr_z leaders, so they are excluded here to avoid a
|
|
1573
|
+
# duplicate hole callout; only the off-axis features get callouts. On a
|
|
1574
|
+
# prismatic part every hole flows through unchanged.
|
|
1575
|
+
feature_holes = a.holes
|
|
1576
|
+
feature_patterns = a.patterns
|
|
1577
|
+
if a.is_rotational:
|
|
1578
|
+
feature_holes = [h for h in a.holes if not _is_concentric_hole(h, a, _axis_letter)]
|
|
1579
|
+
present = set(map(id, feature_holes))
|
|
1580
|
+
feature_patterns = [p for p in a.patterns if all(id(h) in present for h in p.holes)]
|
|
1581
|
+
if feature_holes:
|
|
1582
|
+
_annotate_holes(
|
|
1583
|
+
dwg, a, view_of_axis, _axis_letter, feature_patterns, holes_in=feature_holes
|
|
1584
|
+
)
|
|
1585
|
+
_add_location_dims(dwg, a, _axis_letter, feature_patterns, holes_in=feature_holes)
|
|
1371
1586
|
|
|
1372
|
-
if a.cross_diams and a.is_rotational:
|
|
1587
|
+
if a.cross_diams and a.is_rotational and not feature_holes:
|
|
1373
1588
|
_log.info(
|
|
1374
1589
|
"Cross-hole ø%s detected but not annotated (requires section view)",
|
|
1375
1590
|
_fmt(a.cross_diams[0]),
|
|
@@ -1378,7 +1593,9 @@ def _auto_annotate(dwg, a):
|
|
|
1378
1593
|
# Step heights — only where the step is tall enough to fit a label;
|
|
1379
1594
|
# each step witnesses from the previous dim's line (_right_ladder) so
|
|
1380
1595
|
# extension lines are adjacent rather than coincident
|
|
1381
|
-
for col, z in enumerate(
|
|
1596
|
+
for col, z in enumerate(
|
|
1597
|
+
[z for z in a.step_zs[:3] if (z - a.bb.min.Z) * a.SCALE >= _MIN_STEP_DIM_MM]
|
|
1598
|
+
):
|
|
1382
1599
|
_px = a.fv_zones.right.allocate(_SLOT_DIM_STEP)
|
|
1383
1600
|
if _px is None:
|
|
1384
1601
|
_log.warning("dim_step_%d skipped: fv_zones.right strip full", col)
|
|
@@ -1436,9 +1653,10 @@ def _auto_annotate(dwg, a):
|
|
|
1436
1653
|
|
|
1437
1654
|
# The section view goes last: its room check clears every annotation
|
|
1438
1655
|
# already placed right of the side view (callout labels, height/step
|
|
1439
|
-
# dim ladders)
|
|
1440
|
-
|
|
1441
|
-
|
|
1656
|
+
# dim ladders). Fires on feature presence, not class (#10); concentric
|
|
1657
|
+
# bores on a turned part are excluded (the ldr_z leaders cover them).
|
|
1658
|
+
if feature_holes:
|
|
1659
|
+
_add_section_view(dwg, a, _axis_letter, holes=feature_holes)
|
|
1442
1660
|
|
|
1443
1661
|
# Phase 7 — strip footprint debug logging + post-placement overflow check.
|
|
1444
1662
|
# Overflow can only occur when outer_limit was tightened after allocations
|
|
@@ -1847,7 +2065,7 @@ _MAX_CALLOUTS_PER_VIEW = 4
|
|
|
1847
2065
|
_MAX_LOCATION_REFS = 4
|
|
1848
2066
|
|
|
1849
2067
|
|
|
1850
|
-
def _add_location_dims(dwg, a, axis_letter, patterns):
|
|
2068
|
+
def _add_location_dims(dwg, a, axis_letter, patterns, holes_in=None):
|
|
1851
2069
|
"""Baseline X/Y location dimensions in the plan view (#93).
|
|
1852
2070
|
|
|
1853
2071
|
The datum corner is a *default* — the part's minimum-X/minimum-Y corner
|
|
@@ -1860,8 +2078,9 @@ def _add_location_dims(dwg, a, axis_letter, patterns):
|
|
|
1860
2078
|
never force-placed. Cross-axis holes are not located yet (logged).
|
|
1861
2079
|
"""
|
|
1862
2080
|
draft = dwg.draft
|
|
1863
|
-
|
|
1864
|
-
if
|
|
2081
|
+
all_holes = a.holes if holes_in is None else holes_in
|
|
2082
|
+
z_holes = [h for h in all_holes if axis_letter(h) == "z"]
|
|
2083
|
+
if len(z_holes) < len(all_holes):
|
|
1865
2084
|
_log.info("Cross-axis holes present; their locations are not auto-dimensioned")
|
|
1866
2085
|
patterned = {h for p in patterns for h in p.holes}
|
|
1867
2086
|
refs = [] # (world_x, world_y, sort_diameter)
|
|
@@ -2042,7 +2261,7 @@ def _section_hatch_edges(face, SX, SZ, spacing):
|
|
|
2042
2261
|
return result
|
|
2043
2262
|
|
|
2044
2263
|
|
|
2045
|
-
def _add_section_view(dwg, a, axis_letter):
|
|
2264
|
+
def _add_section_view(dwg, a, axis_letter, holes=None):
|
|
2046
2265
|
"""Full section A–A when blind or stepped holes hide their structure (#94).
|
|
2047
2266
|
|
|
2048
2267
|
Trigger: any Z-axis hole with a counterbore/spotface or a non-through
|
|
@@ -2057,7 +2276,7 @@ def _add_section_view(dwg, a, axis_letter):
|
|
|
2057
2276
|
"""
|
|
2058
2277
|
cands = [
|
|
2059
2278
|
h
|
|
2060
|
-
for h in a.holes
|
|
2279
|
+
for h in (a.holes if holes is None else holes)
|
|
2061
2280
|
if axis_letter(h) == "z" and (h.cbore or h.spotface or h.bottom != "through")
|
|
2062
2281
|
]
|
|
2063
2282
|
if not cands:
|
|
@@ -2069,8 +2288,9 @@ def _add_section_view(dwg, a, axis_letter):
|
|
|
2069
2288
|
)
|
|
2070
2289
|
|
|
2071
2290
|
# room check: same row as the front/side views, to the right — past any
|
|
2072
|
-
# side-view callout labels already placed there
|
|
2073
|
-
#
|
|
2291
|
+
# side-view callout labels already placed there.
|
|
2292
|
+
# 12.0 mm floor: conservative minimum half-width so very narrow sections
|
|
2293
|
+
# have enough room for the "SECTION A–A" caption and arrows.
|
|
2074
2294
|
half_w = max(a.x_size * a.SCALE / 2, 12.0)
|
|
2075
2295
|
half_h = a.z_size * a.SCALE / 2
|
|
2076
2296
|
side_vis, side_hid = dwg.views["side"]
|
|
@@ -2089,8 +2309,8 @@ def _add_section_view(dwg, a, axis_letter):
|
|
|
2089
2309
|
right_limit = a.PAGE_W - a.margin
|
|
2090
2310
|
if a.FV_Y + half_h + 6 > iso_y0 - 2:
|
|
2091
2311
|
right_limit = min(right_limit, iso_x0 - 4)
|
|
2092
|
-
tb_left = a.PAGE_W - a.TB_W -
|
|
2093
|
-
if a.FV_Y - half_h - 10 <
|
|
2312
|
+
tb_left = a.PAGE_W - a.TB_W - _TB_CLEAR
|
|
2313
|
+
if a.FV_Y - half_h - 10 < _TB_CLEAR + _TB_H and pos_x + half_w > tb_left - 4:
|
|
2094
2314
|
_log.info("Section A–A skipped (would collide with the title block)")
|
|
2095
2315
|
return
|
|
2096
2316
|
if pos_x + half_w > right_limit:
|
|
@@ -2324,7 +2544,7 @@ def _solve_strip_ys(natural_ys, min_gap, y_min, y_max):
|
|
|
2324
2544
|
return None
|
|
2325
2545
|
|
|
2326
2546
|
|
|
2327
|
-
def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns):
|
|
2547
|
+
def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=None):
|
|
2328
2548
|
"""Leader-attached HoleCallouts, one per distinct hole spec per view (#91).
|
|
2329
2549
|
|
|
2330
2550
|
Identical holes share one callout with an ``n×`` count prefix (#92's
|
|
@@ -2348,7 +2568,7 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns):
|
|
|
2348
2568
|
# different operations and get separate callouts, and a spec group's
|
|
2349
2569
|
# hole set therefore lines up exactly with find_hole_patterns' groups.
|
|
2350
2570
|
groups: dict = {}
|
|
2351
|
-
for h in a.holes:
|
|
2571
|
+
for h in a.holes if holes_in is None else holes_in:
|
|
2352
2572
|
groups.setdefault(_spec_key(h), []).append(h)
|
|
2353
2573
|
|
|
2354
2574
|
by_view: dict = {}
|
|
@@ -2360,8 +2580,8 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns):
|
|
|
2360
2580
|
plan_left = a.PV_X + (a.bb.min.X - a.cx) * a.SCALE
|
|
2361
2581
|
side_right = a.SV_X + (a.bb.max.Y - a.cy) * a.SCALE
|
|
2362
2582
|
front_bottom = a.FV_Y + (a.bb.min.Z - a.cz) * a.SCALE
|
|
2363
|
-
tb_left = a.PAGE_W - a.TB_W -
|
|
2364
|
-
tb_top =
|
|
2583
|
+
tb_left = a.PAGE_W - a.TB_W - _TB_CLEAR
|
|
2584
|
+
tb_top = _TB_CLEAR + _TB_H
|
|
2365
2585
|
|
|
2366
2586
|
# A section line will be placed when the part has z-axis holes with
|
|
2367
2587
|
# counterbores, spotfaces, or blind bottoms (_add_section_view trigger).
|
|
@@ -2662,25 +2882,37 @@ def _project_iso(dwg, a, scale, shape_s=None):
|
|
|
2662
2882
|
dwg._coords["iso"] = ViewCoordinates(axes, a.ISO_X, a.ISO_Y, a.cx, a.cy, a.cz, scale)
|
|
2663
2883
|
|
|
2664
2884
|
|
|
2665
|
-
def _fit_iso_view(dwg, a):
|
|
2666
|
-
"""
|
|
2885
|
+
def _fit_iso_view(dwg, a, annotate: bool = True):
|
|
2886
|
+
"""Scale the iso view to fill its page zone, captioning it NTS when the
|
|
2887
|
+
scale differs from sheet scale. Pass ``annotate=False`` to suppress the
|
|
2888
|
+
NTS note (used when ``auto_dims=False``).
|
|
2667
2889
|
|
|
2668
|
-
The
|
|
2669
|
-
|
|
2670
|
-
the
|
|
2671
|
-
|
|
2672
|
-
|
|
2890
|
+
The iso is always centred at (ISO_X, ISO_Y) which sits at the centre of
|
|
2891
|
+
the available zone. The projection is linear, so the factor needed to
|
|
2892
|
+
fill the zone can be computed from the measured extents without iteration.
|
|
2893
|
+
|
|
2894
|
+
- Overflow (needed < 1): shrink with 2 % safety margin.
|
|
2895
|
+
- Under-fill (needed > 1): grow to 90 % of zone, leaving breathing room.
|
|
2896
|
+
- Within 5 % of sheet scale: leave as-is (no NTS label).
|
|
2673
2897
|
"""
|
|
2674
|
-
|
|
2898
|
+
# Use the precomputed iso zone (largest empty rectangle). A section view
|
|
2899
|
+
# only constrains the iso region when it shares the iso's y-range; one that
|
|
2900
|
+
# sits entirely below the iso (e.g. when the iso is in an upper-right zone)
|
|
2901
|
+
# leaves the region's left edge untouched.
|
|
2902
|
+
region_left = a.iso_left_limit
|
|
2903
|
+
if "section_aa" in dwg.views:
|
|
2904
|
+
sec_vis, sec_hid = dwg.views["section_aa"]
|
|
2905
|
+
sec_bb = sec_vis.bounding_box()
|
|
2906
|
+
sec_right = sec_bb.max.X
|
|
2907
|
+
sec_y0, sec_y1 = sec_bb.min.Y, sec_bb.max.Y
|
|
2908
|
+
if sec_hid:
|
|
2909
|
+
shb = sec_hid.bounding_box()
|
|
2910
|
+
sec_right = max(sec_right, shb.max.X)
|
|
2911
|
+
sec_y0, sec_y1 = min(sec_y0, shb.min.Y), max(sec_y1, shb.max.Y)
|
|
2912
|
+
if sec_y0 < a.iso_top_limit and a.iso_bottom_limit < sec_y1:
|
|
2913
|
+
region_left = max(region_left, sec_right + 4)
|
|
2914
|
+
region = (region_left, a.iso_bottom_limit, a.iso_right_limit, a.iso_top_limit)
|
|
2675
2915
|
bb = _iso_bbox(dwg)
|
|
2676
|
-
# Exact check (no tolerance): the lint's view_out_of_bounds is exact, so
|
|
2677
|
-
# accepting a sub-tolerance overflow here would pass the fit yet fail lint.
|
|
2678
|
-
if _bbox_within(bb, region, tol=0.0):
|
|
2679
|
-
return
|
|
2680
|
-
# Orthographic projection is linear and the view centre maps to
|
|
2681
|
-
# (ISO_X, ISO_Y), so each bbox side's offset from the centre scales
|
|
2682
|
-
# exactly with the shape scale — the factor needed to fit can be computed
|
|
2683
|
-
# from the measured extents, costing a single re-projection.
|
|
2684
2916
|
ratios = [
|
|
2685
2917
|
avail / extent
|
|
2686
2918
|
for extent, avail in (
|
|
@@ -2692,24 +2924,32 @@ def _fit_iso_view(dwg, a):
|
|
|
2692
2924
|
if extent > 0
|
|
2693
2925
|
]
|
|
2694
2926
|
needed = min(ratios, default=1.0)
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2927
|
+
if needed >= 1.0:
|
|
2928
|
+
# Iso fits; grow to 90 % of zone — leaves comfortable breathing room.
|
|
2929
|
+
margin_pct = 0.90
|
|
2930
|
+
else:
|
|
2931
|
+
# Iso overflows; shrink to just fit with 2 % safety margin.
|
|
2932
|
+
margin_pct = 0.98
|
|
2933
|
+
factor = math.floor(needed * margin_pct * 10000) / 10000
|
|
2934
|
+
if needed >= 1.0:
|
|
2935
|
+
factor = max(factor, 1.0) # grow branch must never shrink
|
|
2936
|
+
if abs(factor - 1.0) < 0.05:
|
|
2937
|
+
return # within 5 % of sheet scale — no rescale, no NTS label
|
|
2699
2938
|
_project_iso(dwg, a, a.SCALE * factor)
|
|
2700
2939
|
bb = _iso_bbox(dwg)
|
|
2701
|
-
if not _bbox_within(bb, region):
|
|
2940
|
+
if factor < 1.0 and not _bbox_within(bb, region):
|
|
2702
2941
|
_log.warning("Iso view still overflows its page region at %g× sheet scale", factor)
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2942
|
+
if annotate:
|
|
2943
|
+
font = dwg.draft.font_size
|
|
2944
|
+
dwg.add(
|
|
2945
|
+
Note(
|
|
2946
|
+
"ISO VIEW (NTS)",
|
|
2947
|
+
(a.ISO_X, max(bb[1] - 2 * font, a.margin + font)),
|
|
2948
|
+
dwg.draft,
|
|
2949
|
+
),
|
|
2950
|
+
"note_iso_nts",
|
|
2951
|
+
)
|
|
2952
|
+
_log.info("Iso view scaled to %g× sheet scale%s", factor, " (NTS)" if annotate else "")
|
|
2713
2953
|
|
|
2714
2954
|
|
|
2715
2955
|
def build_drawing(
|
|
@@ -2764,7 +3004,7 @@ def build_drawing(
|
|
|
2764
3004
|
page_w=a.PAGE_W,
|
|
2765
3005
|
page_h=a.PAGE_H,
|
|
2766
3006
|
tb_w=a.TB_W,
|
|
2767
|
-
draft=draft_preset(font_size=
|
|
3007
|
+
draft=draft_preset(font_size=_FONT_SIZE, decimal_precision=1),
|
|
2768
3008
|
look_at=look_at,
|
|
2769
3009
|
dist=dist,
|
|
2770
3010
|
centroid=(a.cx, a.cy, a.cz),
|
|
@@ -2779,11 +3019,29 @@ def build_drawing(
|
|
|
2779
3019
|
dwg.add_view("plan", part_s, (cxs, cys, czs + dist), (0, 1, 0), (a.PV_X, a.PV_Y), scaled=True)
|
|
2780
3020
|
dwg.add_view("side", part_s, (cxs + dist, cys, czs), (0, 0, 1), (a.SV_X, a.SV_Y), scaled=True)
|
|
2781
3021
|
_project_iso(dwg, a, a.SCALE, shape_s=part_s)
|
|
2782
|
-
_fit_iso_view(dwg, a)
|
|
2783
3022
|
|
|
2784
3023
|
if auto_dims:
|
|
3024
|
+
# Snapshot outer_limits before _auto_annotate tightens them against the
|
|
3025
|
+
# initial (possibly overflowing) iso. After _fit_iso_view rescales the
|
|
3026
|
+
# iso we restore all three right strips to min(original, final_iso_x_limit)
|
|
3027
|
+
# so each strip reflects actual final geometry, not the transient state.
|
|
3028
|
+
_fv_ol = a.fv_zones.right.outer_limit
|
|
3029
|
+
_pv_ol = a.pv_zones.right.outer_limit
|
|
3030
|
+
_sv_ol = a.sv_zones.right.outer_limit
|
|
2785
3031
|
_auto_annotate(dwg, a)
|
|
3032
|
+
_fit_iso_view(dwg, a)
|
|
3033
|
+
_ix0, _iy0, _, _iy1 = _iso_bbox(dwg)
|
|
3034
|
+
_final_iso_x_lim = _ix0 - 4
|
|
3035
|
+
a.fv_zones.right.outer_limit = min(_fv_ol, _final_iso_x_lim)
|
|
3036
|
+
a.pv_zones.right.outer_limit = min(_pv_ol, _final_iso_x_lim)
|
|
3037
|
+
# Only re-cap the SV right strip when the iso shares its y-range (see the
|
|
3038
|
+
# matching guard in _auto_annotate); otherwise restore its full width.
|
|
3039
|
+
if (a.SV_Y - a.fv_hh) < _iy1 and _iy0 < (a.SV_Y + a.fv_hh):
|
|
3040
|
+
a.sv_zones.right.outer_limit = min(_sv_ol, _final_iso_x_lim)
|
|
3041
|
+
else:
|
|
3042
|
+
a.sv_zones.right.outer_limit = _sv_ol
|
|
2786
3043
|
else:
|
|
3044
|
+
_fit_iso_view(dwg, a, annotate=False)
|
|
2787
3045
|
_add_title_block(dwg, a)
|
|
2788
3046
|
return dwg
|
|
2789
3047
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Tests for draftwright.make_drawing."""
|
|
2
2
|
|
|
3
|
+
import math
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
@@ -439,8 +440,10 @@ class TestStripZones:
|
|
|
439
440
|
|
|
440
441
|
def test_right_strip_outer_limits_tightened_to_iso(self):
|
|
441
442
|
# fv.right and pv.right are both bounded by sv_left_edge so bore callout
|
|
442
|
-
# labels cannot cross into the side view. sv.right is
|
|
443
|
-
#
|
|
443
|
+
# labels cannot cross into the side view. The sv.right strip is only
|
|
444
|
+
# iso-tightened (to iso_x0 - 4) when the iso shares the side view's
|
|
445
|
+
# y-range; with the #11 free-rectangle placement the iso may instead sit
|
|
446
|
+
# above the side view, in which case sv.right keeps its full width.
|
|
444
447
|
# Use a plain box (no holes) so bore callout overhead doesn't push the
|
|
445
448
|
# iso view right and interfere with the sv tightening check.
|
|
446
449
|
from build123d import Box
|
|
@@ -452,15 +455,19 @@ class TestStripZones:
|
|
|
452
455
|
dwg = build_drawing(part)
|
|
453
456
|
a = dwg._analysis
|
|
454
457
|
sv_left = a.SV_X - a.sv_hw
|
|
455
|
-
iso_x0,
|
|
458
|
+
iso_x0, iso_y0, _, iso_y1 = _iso_bbox(dwg)
|
|
456
459
|
iso_limit = iso_x0 - 4
|
|
457
460
|
# fv right must not extend past the side view left edge
|
|
458
461
|
assert a.fv_zones.right.outer_limit == pytest.approx(sv_left, abs=0.1)
|
|
459
462
|
# pv right is also bounded by sv_left so bore callout labels cannot
|
|
460
463
|
# cross dim_locy extension lines in the side view corridor
|
|
461
464
|
assert a.pv_zones.right.outer_limit == pytest.approx(sv_left, abs=0.1)
|
|
462
|
-
# sv right strip is iso-tightened
|
|
463
|
-
|
|
465
|
+
# sv right strip is iso-tightened only when the iso overlaps its y-range.
|
|
466
|
+
sv_y0, sv_y1 = a.SV_Y - a.fv_hh, a.SV_Y + a.fv_hh
|
|
467
|
+
if sv_y0 < iso_y1 and iso_y0 < sv_y1:
|
|
468
|
+
assert a.sv_zones.right.outer_limit == pytest.approx(iso_limit, abs=0.1)
|
|
469
|
+
else:
|
|
470
|
+
assert a.sv_zones.right.outer_limit > iso_limit
|
|
464
471
|
|
|
465
472
|
def test_sv_zones_below_strip_is_active(self):
|
|
466
473
|
# sv_zones.below must be a Strip (not None) after _analyse().
|
|
@@ -1135,52 +1142,108 @@ def test_clear_annotations_keep_custom_and_unnamed_removed():
|
|
|
1135
1142
|
|
|
1136
1143
|
|
|
1137
1144
|
@pytest.fixture(scope="module")
|
|
1138
|
-
def
|
|
1139
|
-
#
|
|
1140
|
-
# sheet scale and is auto-shrunk. Module-scoped; tests must not mutate it.
|
|
1145
|
+
def ctc01_a3_drawing():
|
|
1146
|
+
# Fixture — NIST CTC-01-like plate at 1:5 on A3. Module-scoped; tests must not mutate it.
|
|
1141
1147
|
return build_drawing(Box(800, 450, 150), scale=0.2, page="A3")
|
|
1142
1148
|
|
|
1143
1149
|
|
|
1144
1150
|
@pytest.mark.timeout(120)
|
|
1145
|
-
def
|
|
1146
|
-
# #75 —
|
|
1147
|
-
#
|
|
1151
|
+
def test_ctc01_iso_uses_upper_right_zone(ctc01_a3_drawing):
|
|
1152
|
+
# #75 updated — wide/flat part on A3: the iso is repositioned into the upper-right
|
|
1153
|
+
# zone (above the SV, right of FV/PV) where it fits at sheet scale. No NTS label.
|
|
1148
1154
|
from draftwright.make_drawing import _iso_bbox
|
|
1149
1155
|
|
|
1150
|
-
dwg =
|
|
1156
|
+
dwg = ctc01_a3_drawing
|
|
1151
1157
|
labels = [getattr(a, "label", "") for a in dwg.annotations]
|
|
1152
|
-
assert "ISO VIEW (NTS)" in labels
|
|
1158
|
+
assert "ISO VIEW (NTS)" not in labels # iso now fits at sheet scale — no NTS
|
|
1153
1159
|
x0, y0, x1, y1 = _iso_bbox(dwg)
|
|
1154
1160
|
assert (
|
|
1155
1161
|
x1 <= dwg.page_w - 10 + 0.5 and x0 >= 0 and y0 >= 10 - 0.5 and y1 <= dwg.page_h - 10 + 0.5
|
|
1156
1162
|
)
|
|
1163
|
+
# iso must be significantly larger than the old 65 mm (shrunken) view
|
|
1164
|
+
assert (x1 - x0) > 100
|
|
1157
1165
|
|
|
1158
1166
|
|
|
1159
1167
|
@pytest.mark.timeout(120)
|
|
1160
|
-
def
|
|
1161
|
-
#
|
|
1162
|
-
#
|
|
1163
|
-
|
|
1164
|
-
dwg = shrunk_iso_drawing
|
|
1168
|
+
def test_ctc01_iso_world_to_page_mapping(ctc01_a3_drawing):
|
|
1169
|
+
# dwg.at("iso", ...) must map world points to page even after the iso is
|
|
1170
|
+
# repositioned to the upper-right zone (still projected at sheet scale).
|
|
1171
|
+
dwg = ctc01_a3_drawing
|
|
1165
1172
|
cx, cy, cz = dwg.centroid
|
|
1166
1173
|
centre = dwg.at("iso", cx, cy, cz)
|
|
1167
1174
|
vis, _hid = dwg.views["iso"]
|
|
1168
1175
|
bb = vis.bounding_box()
|
|
1169
1176
|
assert bb.min.X < centre[0] < bb.max.X and bb.min.Y < centre[1] < bb.max.Y
|
|
1170
|
-
|
|
1171
|
-
# the sheet scale). Derive the actual shrunk scale from _coords so the
|
|
1172
|
-
# test does not depend on a specific discretised shrink factor.
|
|
1173
|
-
shrunk_scale = dwg._coords["iso"]._scale
|
|
1174
|
-
assert shrunk_scale < dwg.scale, "iso should be shrunk below sheet scale"
|
|
1177
|
+
iso_scale = dwg._coords["iso"]._scale
|
|
1175
1178
|
raised = dwg.at("iso", cx, cy, cz + 100)
|
|
1176
|
-
assert raised[1] - centre[1] == pytest.approx(100 *
|
|
1179
|
+
assert raised[1] - centre[1] == pytest.approx(100 * iso_scale)
|
|
1177
1180
|
|
|
1178
1181
|
|
|
1179
1182
|
@pytest.mark.timeout(60)
|
|
1180
|
-
def
|
|
1183
|
+
def test_iso_stays_within_page_bounds():
|
|
1184
|
+
# Whether scaled up or not, the iso must always lie within the page margin.
|
|
1185
|
+
from draftwright.make_drawing import _iso_bbox
|
|
1186
|
+
|
|
1181
1187
|
dwg = build_drawing(Box(30, 20, 10))
|
|
1182
|
-
|
|
1183
|
-
|
|
1188
|
+
x0, y0, x1, y1 = _iso_bbox(dwg)
|
|
1189
|
+
margin = 10
|
|
1190
|
+
assert x0 >= margin - 0.5
|
|
1191
|
+
assert y0 >= margin - 0.5
|
|
1192
|
+
assert x1 <= dwg.page_w - margin + 0.5
|
|
1193
|
+
assert y1 <= dwg.page_h - margin + 0.5
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
@pytest.mark.timeout(120)
|
|
1197
|
+
def test_ctc01_iso_picks_upper_right_rectangle(ctc01_a3_drawing):
|
|
1198
|
+
# #11 — the general largest-empty-rectangle search must reproduce the #9
|
|
1199
|
+
# outcome for the wide/flat-on-A3 case: the chosen iso zone is the
|
|
1200
|
+
# upper-right region (right of the FV/PV column, above the SV row).
|
|
1201
|
+
dwg = ctc01_a3_drawing
|
|
1202
|
+
a = dwg._analysis
|
|
1203
|
+
# FV/PV occupy the left column; SV the lower-middle. The picked rectangle
|
|
1204
|
+
# must sit to the right of the FV/PV column and above the SV row.
|
|
1205
|
+
fv_right = a.FV_X + a.fv_hw
|
|
1206
|
+
sv_top = a.SV_Y + a.fv_hh
|
|
1207
|
+
assert a.iso_left_limit >= fv_right
|
|
1208
|
+
assert a.iso_bottom_limit >= sv_top
|
|
1209
|
+
# And it reaches into the upper-right corner of the drawable area.
|
|
1210
|
+
assert a.iso_right_limit >= a.PAGE_W - a.margin - 0.5
|
|
1211
|
+
assert a.iso_top_limit >= a.PAGE_H - a.margin - 0.5
|
|
1212
|
+
assert a.ISO_X > a.PAGE_W / 2 and a.ISO_Y > a.PAGE_H / 2
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
@pytest.mark.timeout(120)
|
|
1216
|
+
def test_tall_part_iso_in_largest_free_zone():
|
|
1217
|
+
# #11 — a tall/narrow part has no per-shape branch; the iso must land in the
|
|
1218
|
+
# largest empty rectangle, clear of every view bbox and the title block, and
|
|
1219
|
+
# stay within the page margins.
|
|
1220
|
+
from draftwright.make_drawing import _iso_bbox
|
|
1221
|
+
|
|
1222
|
+
dwg = build_drawing(Box(40, 40, 300))
|
|
1223
|
+
a = dwg._analysis
|
|
1224
|
+
x0, y0, x1, y1 = _iso_bbox(dwg)
|
|
1225
|
+
margin = a.margin
|
|
1226
|
+
# Within page margins.
|
|
1227
|
+
assert x0 >= margin - 0.5
|
|
1228
|
+
assert y0 >= margin - 0.5
|
|
1229
|
+
assert x1 <= a.PAGE_W - margin + 0.5
|
|
1230
|
+
assert y1 <= a.PAGE_H - margin + 0.5
|
|
1231
|
+
|
|
1232
|
+
iso_bb = (x0, y0, x1, y1)
|
|
1233
|
+
|
|
1234
|
+
def overlaps(b1, b2):
|
|
1235
|
+
return b1[0] < b2[2] and b2[0] < b1[2] and b1[1] < b2[3] and b2[1] < b1[3]
|
|
1236
|
+
|
|
1237
|
+
# No overlap with any orthographic view bounding box.
|
|
1238
|
+
for name in ("front", "plan", "side"):
|
|
1239
|
+
vis, hid = dwg.views[name]
|
|
1240
|
+
vb = vis.bounding_box()
|
|
1241
|
+
view_bb = (vb.min.X, vb.min.Y, vb.max.X, vb.max.Y)
|
|
1242
|
+
assert not overlaps(iso_bb, view_bb), f"iso overlaps {name} view"
|
|
1243
|
+
|
|
1244
|
+
# No overlap with the title-block region (bottom-right corner).
|
|
1245
|
+
tb_bb = (a.PAGE_W - a.TB_W - 11, 11, a.PAGE_W - 11, 11 + 35)
|
|
1246
|
+
assert not overlaps(iso_bb, tb_bb), "iso overlaps title block"
|
|
1184
1247
|
|
|
1185
1248
|
|
|
1186
1249
|
@pytest.mark.timeout(60)
|
|
@@ -2035,3 +2098,113 @@ class TestIsRotational:
|
|
|
2035
2098
|
dwg.lint()
|
|
2036
2099
|
dwg.lint()
|
|
2037
2100
|
assert calls["n"] == 0
|
|
2101
|
+
|
|
2102
|
+
|
|
2103
|
+
# ---------------------------------------------------------------------------
|
|
2104
|
+
# Layout-overfitting regression tests (issue #13)
|
|
2105
|
+
#
|
|
2106
|
+
# The fixtures above exercise the prismatic path well but leave the turned
|
|
2107
|
+
# path and several hard-coded thresholds under-tested — which is how the
|
|
2108
|
+
# overfitting in #10–#12 went unnoticed. These cases pin the *general*
|
|
2109
|
+
# behaviour the algorithm should have. Where current `main` does not yet
|
|
2110
|
+
# meet it, the test is marked xfail(strict=True) so it auto-flags (xpass)
|
|
2111
|
+
# the moment the corresponding fix lands.
|
|
2112
|
+
# ---------------------------------------------------------------------------
|
|
2113
|
+
|
|
2114
|
+
|
|
2115
|
+
class TestTurnedPlusDrilledFlange:
|
|
2116
|
+
"""A flange is turned (square envelope, dominant OD) yet carries discrete
|
|
2117
|
+
off-axis holes — the most common turned-and-drilled part. The binary
|
|
2118
|
+
turned/prismatic split (#10) classifies it rotational and then withholds
|
|
2119
|
+
every hole callout, location dim, and bolt-circle furniture, leaving the
|
|
2120
|
+
bolt holes with bare centre marks.
|
|
2121
|
+
"""
|
|
2122
|
+
|
|
2123
|
+
@staticmethod
|
|
2124
|
+
def _flange():
|
|
2125
|
+
# ø100 × 20 disc, ø30 central bore, 6 × ø8 holes on an ø80 bolt circle.
|
|
2126
|
+
flange = Cylinder(50, 20) - Cylinder(15, 20)
|
|
2127
|
+
for i in range(6):
|
|
2128
|
+
ang = 2 * math.pi * i / 6
|
|
2129
|
+
flange -= Pos(40 * math.cos(ang), 40 * math.sin(ang), 0) * Cylinder(4, 20)
|
|
2130
|
+
return flange
|
|
2131
|
+
|
|
2132
|
+
@pytest.mark.timeout(60)
|
|
2133
|
+
def test_flange_classifies_rotational_with_od(self):
|
|
2134
|
+
# The turned base set is correct today and must stay so.
|
|
2135
|
+
dwg = build_drawing(self._flange())
|
|
2136
|
+
assert dwg._analysis.is_rotational
|
|
2137
|
+
assert "dim_od" in dwg._named
|
|
2138
|
+
assert "centerline_front" in dwg._named
|
|
2139
|
+
|
|
2140
|
+
@pytest.mark.timeout(60)
|
|
2141
|
+
def test_flange_composes_od_with_bolt_circle_furniture(self):
|
|
2142
|
+
dwg = build_drawing(self._flange())
|
|
2143
|
+
# Turned base set — already works.
|
|
2144
|
+
assert "dim_od" in dwg._named
|
|
2145
|
+
# Feature-driven furniture for the bolt circle — withheld today.
|
|
2146
|
+
assert any(n.startswith("hc_") for n in dwg._named), "expected hole callouts"
|
|
2147
|
+
assert any(n.startswith("dim_loc") for n in dwg._named), "expected location dims"
|
|
2148
|
+
assert any(n.startswith("bc_") for n in dwg._named), "expected bolt-circle furniture"
|
|
2149
|
+
|
|
2150
|
+
|
|
2151
|
+
class TestTurnedMultiBoreOverflow:
|
|
2152
|
+
"""A turned part with 4+ distinct concentric bores. The leader stack caps
|
|
2153
|
+
at three (`bores[:3]`); the overflow must not vanish silently — it should
|
|
2154
|
+
be annotated or surfaced through the coverage lint (#10).
|
|
2155
|
+
"""
|
|
2156
|
+
|
|
2157
|
+
@staticmethod
|
|
2158
|
+
def _telescoping():
|
|
2159
|
+
# ø80 OD with four concentric counterbore steps: ø60 / ø44 / ø30 / ø16.
|
|
2160
|
+
part = Cylinder(40, 80)
|
|
2161
|
+
part -= Pos(0, 0, 30) * Cylinder(30, 20)
|
|
2162
|
+
part -= Pos(0, 0, 10) * Cylinder(22, 30)
|
|
2163
|
+
part -= Pos(0, 0, -10) * Cylinder(15, 30)
|
|
2164
|
+
part -= Pos(0, 0, -30) * Cylinder(8, 20)
|
|
2165
|
+
return part
|
|
2166
|
+
|
|
2167
|
+
@pytest.mark.timeout(60)
|
|
2168
|
+
def test_no_bore_silently_dropped(self):
|
|
2169
|
+
dwg = build_drawing(self._telescoping())
|
|
2170
|
+
a = dwg._analysis
|
|
2171
|
+
bores = {d for d in a.z_diams if d != a.od_diam}
|
|
2172
|
+
assert bores == {60.0, 44.0, 30.0, 16.0}
|
|
2173
|
+
annotated = {
|
|
2174
|
+
float(ann.label.lstrip("ø")) for n, ann in dwg._named.items() if n.startswith("ldr_z")
|
|
2175
|
+
}
|
|
2176
|
+
# Acceptance (#10): annotate all, or surface the overflow via lint —
|
|
2177
|
+
# never drop a bore with no trace.
|
|
2178
|
+
if annotated != bores:
|
|
2179
|
+
assert any(i.code == "feature_not_dimensioned" for i in dwg.lint()), (
|
|
2180
|
+
f"bores {bores - annotated} dropped with no lint coverage"
|
|
2181
|
+
)
|
|
2182
|
+
|
|
2183
|
+
|
|
2184
|
+
class TestStepHeightThreshold:
|
|
2185
|
+
"""The step-height gate dimensions a step only when it projects to ≥20 mm
|
|
2186
|
+
on the page (`(z - bb.min.Z) * SCALE >= 20`). That page-mm cutoff is
|
|
2187
|
+
incidental: a genuine, well-separated step should be dimensioned whatever
|
|
2188
|
+
its scaled height (#13).
|
|
2189
|
+
"""
|
|
2190
|
+
|
|
2191
|
+
@staticmethod
|
|
2192
|
+
def _stepped(base_h):
|
|
2193
|
+
# Prismatic two-level block: a base of height ``base_h`` (bottom at
|
|
2194
|
+
# z=0) with a smaller platform on top. The single interior step face
|
|
2195
|
+
# sits ``base_h`` above the part bottom, so at 1:1 it projects to
|
|
2196
|
+
# exactly ``base_h`` mm on the page.
|
|
2197
|
+
base = Pos(0, 0, base_h / 2) * Box(100, 100, base_h)
|
|
2198
|
+
platform = Pos(0, 0, base_h + 5) * Box(60, 60, 10)
|
|
2199
|
+
return base + platform
|
|
2200
|
+
|
|
2201
|
+
@pytest.mark.timeout(60)
|
|
2202
|
+
def test_step_above_page_gate_is_dimensioned(self):
|
|
2203
|
+
# 21 mm of page height — dimensioned. Guards the gate's upper side.
|
|
2204
|
+
dwg = build_drawing(self._stepped(21), scale=1.0, page="A2")
|
|
2205
|
+
assert any(n.startswith("dim_step") for n in dwg._named)
|
|
2206
|
+
|
|
2207
|
+
@pytest.mark.timeout(60)
|
|
2208
|
+
def test_real_step_just_below_page_gate_still_dimensioned(self):
|
|
2209
|
+
dwg = build_drawing(self._stepped(19), scale=1.0, page="A2")
|
|
2210
|
+
assert any(n.startswith("dim_step") for n in dwg._named)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|