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.
@@ -9,3 +9,4 @@ build/
9
9
  testenv/
10
10
  .coverage
11
11
  coverage.xml
12
+ .DS_Store
@@ -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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Automated technical-drawing generation for build123d
5
5
  Project-URL: Homepage, https://github.com/pzfreo/draftwright
6
6
  Project-URL: Repository, https://github.com/pzfreo/draftwright
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -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(holes, font_size: float = 3.0, patterns=None) -> float:
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 = 2.0 # pad_around_text (fixed in Draft for standard drawings)
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(holes, patterns, n_steps: int, bb, font_size: float = 3.0) -> StripDepths:
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(holes, font_size, patterns=patterns)
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 0.9×fs when a section
551
- # line is present) + gap (= pad_around_text = 2.0 mm, always present).
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 += 2.0 + 0.9 * font_size
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 20 mm gate (SCALE is not yet known).
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(holes, patterns, n_steps_ub, bb)
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 20 mm height gate _auto_annotate uses for dim_step.
776
- n_steps = len([z for z in step_zs[:3] if (z - bb.min.Z) * SCALE >= 20])
777
- strips = _measure_strips(holes, patterns, n_steps, bb)
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
- tb_top_y = margin + _TB_H
807
- iso_above_tb = (PV_Y - pv_hh) > tb_top_y
808
- iso_right_limit = (PAGE_W - margin) if iso_above_tb else (PAGE_W - TB_W - margin)
809
- right_avail = max(0.0, iso_right_limit - sv_right)
810
- ISO_X = sv_right + right_avail / 2
811
- ISO_Y = PV_Y
812
-
813
- # When the standard iso zone (right of SV) is narrower than the natural iso
814
- # extent, check whether the upper-right zone right of FV/PV and above the SV
815
- # y-range offers more room. This zone shares no y-range with the SV, so the
816
- # iso can sit there without conflicting with SV annotations.
817
- _iso_natural = bbox_max * SCALE * 0.7
818
- _ur_left = FV_X + fv_hw + gap_fv_sv # = sv_left_edge; clears FV/PV right strips
819
- _sv_top = FV_Y + fv_hh # SV_Y == FV_Y; sv_top == FV_Y + fv_hh
820
- _ur_bottom = _sv_top + DIM_PAD
821
- _ur_w = max(0.0, iso_right_limit - _ur_left)
822
- _ur_h = max(0.0, (PAGE_H - margin) - _ur_bottom)
823
- _std_min = min(right_avail, PAGE_H - 2 * margin)
824
- _ur_min = min(_ur_w, _ur_h)
825
- if _std_min < _iso_natural and _ur_min > _std_min:
826
- ISO_X = (_ur_left + iso_right_limit) / 2
827
- ISO_Y = (_ur_bottom + PAGE_H - margin) / 2
828
- iso_left_limit = _ur_left
829
- iso_bottom_limit = _ur_bottom
830
- iso_in_upper_right = True
831
- else:
832
- iso_left_limit = sv_right
833
- iso_bottom_limit = margin
834
- iso_in_upper_right = False
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=Strip(fv_top_edge, pv_bottom_edge - 2, direction=1),
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 = DIM_PAD = 18 mm; pv_below needs 16 mm, leaving 2 mm slack.
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, iso_right_limit, direction=1),
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
- iso_in_upper_right=iso_in_upper_right,
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, _, _, _ = _iso_bbox(dwg)
1438
+ _iso_x0, _iso_y0, _, _iso_y1 = _iso_bbox(dwg)
1287
1439
  _iso_x_limit = _iso_x0 - 4
1288
- # When the iso sits above the SV (upper-right zone), the SV right strip shares
1289
- # no y-range with the iso, so tightening sv_zones.right by iso_x would set
1290
- # outer_limit below the strip anchor and break all SV annotation allocations.
1291
- _right_strips = (
1292
- (a.fv_zones.right, a.pv_zones.right)
1293
- if a.iso_in_upper_right
1294
- else (a.fv_zones.right, a.pv_zones.right, a.sv_zones.right)
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
- bores = [d for d in a.z_diams if d != a.od_diam]
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
- for i, d in enumerate(bores[:3]):
1370
- tip_z = FZ(a.cz) + (i - 1) * 10
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, locations, and sections prismatic parts only; turned
1401
- # parts keep dim_od/ldr_z
1402
- if not a.is_rotational and a.holes:
1403
- _annotate_holes(dwg, a, view_of_axis, _axis_letter, a.patterns)
1404
- _add_location_dims(dwg, a, _axis_letter, a.patterns)
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([z for z in a.step_zs[:3] if (z - a.bb.min.Z) * a.SCALE >= 20]):
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
- if not a.is_rotational and a.holes:
1475
- _add_section_view(dwg, a, _axis_letter)
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
- z_holes = [h for h in a.holes if axis_letter(h) == "z"]
1898
- if len(z_holes) < len(a.holes):
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
- # the caption is ~19mm wide narrow sections are bounded by it
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 - 11
2127
- if a.FV_Y - half_h - 10 < 11 + _TB_H and pos_x + half_w > tb_left - 4:
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 - 11
2398
- tb_top = 11 + _TB_H
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 limits. When iso is in the upper-right zone,
2713
- # any section view sits below the iso's y-range so its x-extent doesn't
2714
- # constrain the iso region.
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 not a.iso_in_upper_right and "section_aa" in dwg.views:
2903
+ if "section_aa" in dwg.views:
2717
2904
  sec_vis, sec_hid = dwg.views["section_aa"]
2718
- sec_right = sec_vis.bounding_box().max.X
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
- sec_right = max(sec_right, sec_hid.bounding_box().max.X)
2721
- region_left = max(region_left, sec_right + 4)
2722
- region = (region_left, a.iso_bottom_limit, a.iso_right_limit, a.PAGE_H - a.margin)
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=3.0, decimal_precision=1),
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
- _final_iso_x_lim = _iso_bbox(dwg)[0] - 4
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
- if not a.iso_in_upper_right:
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 tightened to the
443
- # actual iso view left edge (iso_x0 - 4) by _auto_annotate().
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, _, _, _ = _iso_bbox(dwg)
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
- assert a.sv_zones.right.outer_limit == pytest.approx(iso_limit, abs=0.1)
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