draftwright 0.1.3__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.3 → draftwright-0.1.4}/.gitignore +1 -0
- {draftwright-0.1.3 → draftwright-0.1.4}/CHANGELOG.md +18 -0
- {draftwright-0.1.3 → draftwright-0.1.4}/PKG-INFO +1 -1
- {draftwright-0.1.3 → draftwright-0.1.4}/pyproject.toml +1 -1
- {draftwright-0.1.3 → draftwright-0.1.4}/src/draftwright/make_drawing.py +289 -92
- {draftwright-0.1.3 → draftwright-0.1.4}/tests/test_make_drawing.py +175 -5
- {draftwright-0.1.3 → draftwright-0.1.4}/LICENSE +0 -0
- {draftwright-0.1.3 → draftwright-0.1.4}/README.md +0 -0
- {draftwright-0.1.3 → draftwright-0.1.4}/skills/SKILL.md +0 -0
- {draftwright-0.1.3 → draftwright-0.1.4}/src/draftwright/__init__.py +0 -0
- {draftwright-0.1.3 → draftwright-0.1.4}/src/draftwright/pmi.py +0 -0
- {draftwright-0.1.3 → draftwright-0.1.4}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
- {draftwright-0.1.3 → draftwright-0.1.4}/tests/test_e2e_standards.py +0 -0
- {draftwright-0.1.3 → 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,35 +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
|
-
|
|
812
|
-
|
|
813
|
-
#
|
|
814
|
-
#
|
|
815
|
-
#
|
|
816
|
-
#
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
|
835
983
|
|
|
836
984
|
# ------------------------------------------------------------------
|
|
837
985
|
# Strip / zone construction.
|
|
@@ -855,7 +1003,10 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
|
|
|
855
1003
|
fv_zones = ViewZones(
|
|
856
1004
|
right=Strip(fv_right_edge, sv_left_edge, direction=1),
|
|
857
1005
|
left=Strip(fv_left_edge, margin, direction=-1),
|
|
858
|
-
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),
|
|
859
1010
|
below=Strip(fv_bottom_edge, margin, direction=-1),
|
|
860
1011
|
)
|
|
861
1012
|
pv_zones = ViewZones(
|
|
@@ -867,14 +1018,15 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
|
|
|
867
1018
|
right=Strip(pv_right_edge, sv_left_edge, direction=1),
|
|
868
1019
|
left=Strip(pv_left_edge, margin, direction=-1),
|
|
869
1020
|
above=Strip(pv_top_edge, PAGE_H - margin, direction=1),
|
|
870
|
-
# 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).
|
|
871
1023
|
below=Strip(pv_bottom_edge, fv_top_edge, direction=-1),
|
|
872
1024
|
)
|
|
873
1025
|
sv_bottom_edge = SV_Y - fv_hh # same as fv_bottom_edge; side and front share Z height
|
|
874
1026
|
sv_zones = ViewZones(
|
|
875
1027
|
# sv_right already includes DIM_PAD; anchor here so the strip never
|
|
876
1028
|
# places annotations inside that gap
|
|
877
|
-
right=Strip(sv_right,
|
|
1029
|
+
right=Strip(sv_right, sv_right_wall, direction=1),
|
|
878
1030
|
left=None, # immediately abuts the front view's right edge
|
|
879
1031
|
above=Strip(sv_top_edge, PAGE_H - margin, direction=1),
|
|
880
1032
|
below=Strip(sv_bottom_edge, margin, direction=-1),
|
|
@@ -934,7 +1086,7 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
|
|
|
934
1086
|
ISO_Y=ISO_Y,
|
|
935
1087
|
iso_left_limit=iso_left_limit,
|
|
936
1088
|
iso_bottom_limit=iso_bottom_limit,
|
|
937
|
-
|
|
1089
|
+
iso_top_limit=iso_top_limit,
|
|
938
1090
|
# View half-extents in page units (convenient for strip arithmetic)
|
|
939
1091
|
fv_hw=fv_hw,
|
|
940
1092
|
fv_hh=fv_hh,
|
|
@@ -1283,16 +1435,21 @@ def _auto_annotate(dwg, a):
|
|
|
1283
1435
|
# that the iso has been projected and fitted. Always apply so that any
|
|
1284
1436
|
# future allocations are bounded; warn when the cursor has already passed
|
|
1285
1437
|
# the limit (dims already placed may overlap the iso view).
|
|
1286
|
-
_iso_x0,
|
|
1438
|
+
_iso_x0, _iso_y0, _, _iso_y1 = _iso_bbox(dwg)
|
|
1287
1439
|
_iso_x_limit = _iso_x0 - 4
|
|
1288
|
-
#
|
|
1289
|
-
#
|
|
1290
|
-
#
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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)
|
|
1296
1453
|
for _rs in _right_strips:
|
|
1297
1454
|
_rs.outer_limit = min(_rs.outer_limit, _iso_x_limit)
|
|
1298
1455
|
if _rs._cursor >= _iso_x_limit:
|
|
@@ -1358,16 +1515,25 @@ def _auto_annotate(dwg, a):
|
|
|
1358
1515
|
)
|
|
1359
1516
|
|
|
1360
1517
|
# Z-axis bore leaders to the left of the front view — these assume bores
|
|
1361
|
-
# concentric with the rotation axis, so rotational only (#81)
|
|
1362
|
-
|
|
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 []
|
|
1363
1524
|
if a.is_rotational and bores:
|
|
1364
1525
|
left_edge = FX(a.bb.min.X)
|
|
1365
1526
|
left_space = left_edge - a.margin
|
|
1366
1527
|
if left_space >= a.DIM_PAD:
|
|
1367
1528
|
ldr_length = a.DIM_PAD * 0.6
|
|
1368
1529
|
elbow_x = left_edge - ldr_length
|
|
1369
|
-
|
|
1370
|
-
|
|
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
|
|
1371
1537
|
dwg.add(
|
|
1372
1538
|
Leader(
|
|
1373
1539
|
tip=(FX(a.cx - d / 2), tip_z, 0),
|
|
@@ -1397,13 +1563,28 @@ def _auto_annotate(dwg, a):
|
|
|
1397
1563
|
size = max(2.5, h.diameter * a.SCALE + 2.0)
|
|
1398
1564
|
dwg.add(CenterMark(to_page(h), size, draft), f"cm_{view}{i}")
|
|
1399
1565
|
|
|
1400
|
-
# Hole callouts,
|
|
1401
|
-
#
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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)
|
|
1405
1586
|
|
|
1406
|
-
if a.cross_diams and a.is_rotational:
|
|
1587
|
+
if a.cross_diams and a.is_rotational and not feature_holes:
|
|
1407
1588
|
_log.info(
|
|
1408
1589
|
"Cross-hole ø%s detected but not annotated (requires section view)",
|
|
1409
1590
|
_fmt(a.cross_diams[0]),
|
|
@@ -1412,7 +1593,9 @@ def _auto_annotate(dwg, a):
|
|
|
1412
1593
|
# Step heights — only where the step is tall enough to fit a label;
|
|
1413
1594
|
# each step witnesses from the previous dim's line (_right_ladder) so
|
|
1414
1595
|
# extension lines are adjacent rather than coincident
|
|
1415
|
-
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
|
+
):
|
|
1416
1599
|
_px = a.fv_zones.right.allocate(_SLOT_DIM_STEP)
|
|
1417
1600
|
if _px is None:
|
|
1418
1601
|
_log.warning("dim_step_%d skipped: fv_zones.right strip full", col)
|
|
@@ -1470,9 +1653,10 @@ def _auto_annotate(dwg, a):
|
|
|
1470
1653
|
|
|
1471
1654
|
# The section view goes last: its room check clears every annotation
|
|
1472
1655
|
# already placed right of the side view (callout labels, height/step
|
|
1473
|
-
# dim ladders)
|
|
1474
|
-
|
|
1475
|
-
|
|
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)
|
|
1476
1660
|
|
|
1477
1661
|
# Phase 7 — strip footprint debug logging + post-placement overflow check.
|
|
1478
1662
|
# Overflow can only occur when outer_limit was tightened after allocations
|
|
@@ -1881,7 +2065,7 @@ _MAX_CALLOUTS_PER_VIEW = 4
|
|
|
1881
2065
|
_MAX_LOCATION_REFS = 4
|
|
1882
2066
|
|
|
1883
2067
|
|
|
1884
|
-
def _add_location_dims(dwg, a, axis_letter, patterns):
|
|
2068
|
+
def _add_location_dims(dwg, a, axis_letter, patterns, holes_in=None):
|
|
1885
2069
|
"""Baseline X/Y location dimensions in the plan view (#93).
|
|
1886
2070
|
|
|
1887
2071
|
The datum corner is a *default* — the part's minimum-X/minimum-Y corner
|
|
@@ -1894,8 +2078,9 @@ def _add_location_dims(dwg, a, axis_letter, patterns):
|
|
|
1894
2078
|
never force-placed. Cross-axis holes are not located yet (logged).
|
|
1895
2079
|
"""
|
|
1896
2080
|
draft = dwg.draft
|
|
1897
|
-
|
|
1898
|
-
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):
|
|
1899
2084
|
_log.info("Cross-axis holes present; their locations are not auto-dimensioned")
|
|
1900
2085
|
patterned = {h for p in patterns for h in p.holes}
|
|
1901
2086
|
refs = [] # (world_x, world_y, sort_diameter)
|
|
@@ -2076,7 +2261,7 @@ def _section_hatch_edges(face, SX, SZ, spacing):
|
|
|
2076
2261
|
return result
|
|
2077
2262
|
|
|
2078
2263
|
|
|
2079
|
-
def _add_section_view(dwg, a, axis_letter):
|
|
2264
|
+
def _add_section_view(dwg, a, axis_letter, holes=None):
|
|
2080
2265
|
"""Full section A–A when blind or stepped holes hide their structure (#94).
|
|
2081
2266
|
|
|
2082
2267
|
Trigger: any Z-axis hole with a counterbore/spotface or a non-through
|
|
@@ -2091,7 +2276,7 @@ def _add_section_view(dwg, a, axis_letter):
|
|
|
2091
2276
|
"""
|
|
2092
2277
|
cands = [
|
|
2093
2278
|
h
|
|
2094
|
-
for h in a.holes
|
|
2279
|
+
for h in (a.holes if holes is None else holes)
|
|
2095
2280
|
if axis_letter(h) == "z" and (h.cbore or h.spotface or h.bottom != "through")
|
|
2096
2281
|
]
|
|
2097
2282
|
if not cands:
|
|
@@ -2103,8 +2288,9 @@ def _add_section_view(dwg, a, axis_letter):
|
|
|
2103
2288
|
)
|
|
2104
2289
|
|
|
2105
2290
|
# room check: same row as the front/side views, to the right — past any
|
|
2106
|
-
# side-view callout labels already placed there
|
|
2107
|
-
#
|
|
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.
|
|
2108
2294
|
half_w = max(a.x_size * a.SCALE / 2, 12.0)
|
|
2109
2295
|
half_h = a.z_size * a.SCALE / 2
|
|
2110
2296
|
side_vis, side_hid = dwg.views["side"]
|
|
@@ -2123,8 +2309,8 @@ def _add_section_view(dwg, a, axis_letter):
|
|
|
2123
2309
|
right_limit = a.PAGE_W - a.margin
|
|
2124
2310
|
if a.FV_Y + half_h + 6 > iso_y0 - 2:
|
|
2125
2311
|
right_limit = min(right_limit, iso_x0 - 4)
|
|
2126
|
-
tb_left = a.PAGE_W - a.TB_W -
|
|
2127
|
-
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:
|
|
2128
2314
|
_log.info("Section A–A skipped (would collide with the title block)")
|
|
2129
2315
|
return
|
|
2130
2316
|
if pos_x + half_w > right_limit:
|
|
@@ -2358,7 +2544,7 @@ def _solve_strip_ys(natural_ys, min_gap, y_min, y_max):
|
|
|
2358
2544
|
return None
|
|
2359
2545
|
|
|
2360
2546
|
|
|
2361
|
-
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):
|
|
2362
2548
|
"""Leader-attached HoleCallouts, one per distinct hole spec per view (#91).
|
|
2363
2549
|
|
|
2364
2550
|
Identical holes share one callout with an ``n×`` count prefix (#92's
|
|
@@ -2382,7 +2568,7 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns):
|
|
|
2382
2568
|
# different operations and get separate callouts, and a spec group's
|
|
2383
2569
|
# hole set therefore lines up exactly with find_hole_patterns' groups.
|
|
2384
2570
|
groups: dict = {}
|
|
2385
|
-
for h in a.holes:
|
|
2571
|
+
for h in a.holes if holes_in is None else holes_in:
|
|
2386
2572
|
groups.setdefault(_spec_key(h), []).append(h)
|
|
2387
2573
|
|
|
2388
2574
|
by_view: dict = {}
|
|
@@ -2394,8 +2580,8 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns):
|
|
|
2394
2580
|
plan_left = a.PV_X + (a.bb.min.X - a.cx) * a.SCALE
|
|
2395
2581
|
side_right = a.SV_X + (a.bb.max.Y - a.cy) * a.SCALE
|
|
2396
2582
|
front_bottom = a.FV_Y + (a.bb.min.Z - a.cz) * a.SCALE
|
|
2397
|
-
tb_left = a.PAGE_W - a.TB_W -
|
|
2398
|
-
tb_top =
|
|
2583
|
+
tb_left = a.PAGE_W - a.TB_W - _TB_CLEAR
|
|
2584
|
+
tb_top = _TB_CLEAR + _TB_H
|
|
2399
2585
|
|
|
2400
2586
|
# A section line will be placed when the part has z-axis holes with
|
|
2401
2587
|
# counterbores, spotfaces, or blind bottoms (_add_section_view trigger).
|
|
@@ -2709,17 +2895,23 @@ def _fit_iso_view(dwg, a, annotate: bool = True):
|
|
|
2709
2895
|
- Under-fill (needed > 1): grow to 90 % of zone, leaving breathing room.
|
|
2710
2896
|
- Within 5 % of sheet scale: leave as-is (no NTS label).
|
|
2711
2897
|
"""
|
|
2712
|
-
# Use the precomputed iso zone
|
|
2713
|
-
#
|
|
2714
|
-
#
|
|
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.
|
|
2715
2902
|
region_left = a.iso_left_limit
|
|
2716
|
-
if
|
|
2903
|
+
if "section_aa" in dwg.views:
|
|
2717
2904
|
sec_vis, sec_hid = dwg.views["section_aa"]
|
|
2718
|
-
|
|
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
|
|
2719
2908
|
if sec_hid:
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
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)
|
|
2723
2915
|
bb = _iso_bbox(dwg)
|
|
2724
2916
|
ratios = [
|
|
2725
2917
|
avail / extent
|
|
@@ -2812,7 +3004,7 @@ def build_drawing(
|
|
|
2812
3004
|
page_w=a.PAGE_W,
|
|
2813
3005
|
page_h=a.PAGE_H,
|
|
2814
3006
|
tb_w=a.TB_W,
|
|
2815
|
-
draft=draft_preset(font_size=
|
|
3007
|
+
draft=draft_preset(font_size=_FONT_SIZE, decimal_precision=1),
|
|
2816
3008
|
look_at=look_at,
|
|
2817
3009
|
dist=dist,
|
|
2818
3010
|
centroid=(a.cx, a.cy, a.cz),
|
|
@@ -2838,11 +3030,16 @@ def build_drawing(
|
|
|
2838
3030
|
_sv_ol = a.sv_zones.right.outer_limit
|
|
2839
3031
|
_auto_annotate(dwg, a)
|
|
2840
3032
|
_fit_iso_view(dwg, a)
|
|
2841
|
-
|
|
3033
|
+
_ix0, _iy0, _, _iy1 = _iso_bbox(dwg)
|
|
3034
|
+
_final_iso_x_lim = _ix0 - 4
|
|
2842
3035
|
a.fv_zones.right.outer_limit = min(_fv_ol, _final_iso_x_lim)
|
|
2843
3036
|
a.pv_zones.right.outer_limit = min(_pv_ol, _final_iso_x_lim)
|
|
2844
|
-
|
|
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):
|
|
2845
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
|
|
2846
3043
|
else:
|
|
2847
3044
|
_fit_iso_view(dwg, a, annotate=False)
|
|
2848
3045
|
_add_title_block(dwg, a)
|
|
@@ -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().
|
|
@@ -1186,6 +1193,59 @@ def test_iso_stays_within_page_bounds():
|
|
|
1186
1193
|
assert y1 <= dwg.page_h - margin + 0.5
|
|
1187
1194
|
|
|
1188
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"
|
|
1247
|
+
|
|
1248
|
+
|
|
1189
1249
|
@pytest.mark.timeout(60)
|
|
1190
1250
|
def test_drawing_add_and_remove():
|
|
1191
1251
|
dwg = build_drawing(Box(30, 20, 10))
|
|
@@ -2038,3 +2098,113 @@ class TestIsRotational:
|
|
|
2038
2098
|
dwg.lint()
|
|
2039
2099
|
dwg.lint()
|
|
2040
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
|