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.
@@ -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.2
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.2"
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,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
- 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
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=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),
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 = 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).
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, iso_right_limit, direction=1),
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, _, _, _ = _iso_bbox(dwg)
1438
+ _iso_x0, _iso_y0, _, _iso_y1 = _iso_bbox(dwg)
1261
1439
  _iso_x_limit = _iso_x0 - 4
1262
- for _rs in (a.fv_zones.right, a.pv_zones.right, a.sv_zones.right):
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
- 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 []
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
- for i, d in enumerate(bores[:3]):
1336
- 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
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, locations, and sections prismatic parts only; turned
1367
- # parts keep dim_od/ldr_z
1368
- if not a.is_rotational and a.holes:
1369
- _annotate_holes(dwg, a, view_of_axis, _axis_letter, a.patterns)
1370
- _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)
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([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
+ ):
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
- if not a.is_rotational and a.holes:
1441
- _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)
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
- z_holes = [h for h in a.holes if axis_letter(h) == "z"]
1864
- 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):
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
- # 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.
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 - 11
2093
- 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:
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 - 11
2364
- tb_top = 11 + _TB_H
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
- """Shrink the iso view to fit its page region, captioning it NTS (#75).
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 layout reserves ~0.7 × bbox_max for the iso column, but the true
2669
- projected extent can be wider (long prismatic parts), pushing the iso past
2670
- the page edge or into the side view's dimension space. When the projected
2671
- iso bbox overflows the region, re-project at a clean fraction of sheet
2672
- scale and add an "ISO VIEW (NTS)" caption below it.
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
- region = (a.sv_right, a.margin, a.iso_right_limit, a.PAGE_H - a.margin)
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
- # Apply a 2 % safety margin and floor to 4 decimal places to avoid
2696
- # floating-point creep past the region boundary. The iso is NTS so
2697
- # there is no need to constrain to "clean" fractions.
2698
- factor = math.floor(needed * 0.98 * 10000) / 10000
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
- font = dwg.draft.font_size
2704
- dwg.add(
2705
- Note(
2706
- "ISO VIEW (NTS)",
2707
- (a.ISO_X, max(bb[1] - 2 * font, a.margin + font)),
2708
- dwg.draft,
2709
- ),
2710
- "note_iso_nts",
2711
- )
2712
- _log.info("Iso view shrunk to %g× sheet scale (NTS)", factor)
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=3.0, decimal_precision=1),
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 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().
@@ -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 shrunk_iso_drawing():
1139
- # #75 fixture — NIST CTC-01-like plate at 1:5 on A3: the iso overflows at
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 test_iso_overflow_shrinks_with_nts_note(shrunk_iso_drawing):
1146
- # #75 — at sheet scale the iso would run past the A3 page edge; it must be
1147
- # re-projected smaller and captioned NTS.
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 = shrunk_iso_drawing
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 test_shrunk_iso_keeps_world_to_page_mapping(shrunk_iso_drawing):
1161
- # After the NTS shrink, dwg.at("iso", ...) must still map world points to
1162
- # the page: the centroid lands on the view centre and offsets scale by the
1163
- # shrunk view scale, not the sheet scale.
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
- # World +Z maps to page +Y; the offset must use the shrunk view scale (not
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 * shrunk_scale)
1179
+ assert raised[1] - centre[1] == pytest.approx(100 * iso_scale)
1177
1180
 
1178
1181
 
1179
1182
  @pytest.mark.timeout(60)
1180
- def test_iso_that_fits_is_not_shrunk():
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
- labels = [getattr(a, "label", "") for a in dwg.annotations]
1183
- assert "ISO VIEW (NTS)" not in labels
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