draftwright 0.1.6__tar.gz → 0.1.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. draftwright-0.1.7/CHANGELOG.md +148 -0
  2. {draftwright-0.1.6 → draftwright-0.1.7}/PKG-INFO +1 -1
  3. {draftwright-0.1.6 → draftwright-0.1.7}/pyproject.toml +1 -1
  4. {draftwright-0.1.6 → draftwright-0.1.7}/src/draftwright/make_drawing.py +261 -62
  5. {draftwright-0.1.6 → draftwright-0.1.7}/tests/test_e2e_standards.py +6 -6
  6. {draftwright-0.1.6 → draftwright-0.1.7}/tests/test_make_drawing.py +312 -9
  7. draftwright-0.1.6/CHANGELOG.md +0 -77
  8. {draftwright-0.1.6 → draftwright-0.1.7}/.gitignore +0 -0
  9. {draftwright-0.1.6 → draftwright-0.1.7}/LICENSE +0 -0
  10. {draftwright-0.1.6 → draftwright-0.1.7}/README.md +0 -0
  11. {draftwright-0.1.6 → draftwright-0.1.7}/skills/SKILL.md +0 -0
  12. {draftwright-0.1.6 → draftwright-0.1.7}/src/draftwright/__init__.py +0 -0
  13. {draftwright-0.1.6 → draftwright-0.1.7}/src/draftwright/pmi.py +0 -0
  14. {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  15. {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  16. {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  17. {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  18. {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  19. {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  20. {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  21. {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  22. {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  23. {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  24. {draftwright-0.1.6 → draftwright-0.1.7}/tests/test_pmi.py +0 -0
@@ -0,0 +1,148 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ ### Changed
6
+
7
+ - **Hole callouts are no longer capped at four per view.** Every distinct bore
8
+ is attempted; the per-view placement bounds (front-view shaft rows, plan/side
9
+ strip Y-solver) are the real limit, and a callout that genuinely doesn't fit
10
+ surfaces as `callout_dropped` (a warning, with its diameter, excluded from
11
+ `feature_not_dimensioned`). Three previously-silent front-view skip paths now
12
+ surface too. The bore-callout "no room"/"strip full" drops are reclassified
13
+ from error (`placement_unsatisfiable`) to this warning, since under the
14
+ adaptive model an unplaceable callout is an incomplete — not invalid —
15
+ drawing. Completes the adaptive-caps work (#36); NIST CTC parts now place
16
+ 5–9 callouts (vs a capped 4) with no error-severity lint.
17
+ - **Step-height dimensions are no longer capped at three.** The `fv_zones.right`
18
+ corridor is now sized for every legible step (`_est_right_strip_depth` no
19
+ longer caps the count), and a step dim is placed for each legible level. A
20
+ part with several shoulders gets them all dimensioned instead of an arbitrary
21
+ three; the strip allocator remains the real bound (an unplaceable step
22
+ surfaces as `placement_unsatisfiable`). Verified the NIST CTC parts (8–16
23
+ step faces each) build with no error-severity lint. Second step of the
24
+ adaptive-caps work (#36); the per-view callout cap follows. The
25
+ `step_dim_dropped` lint code is removed (the cap that produced it is gone).
26
+ - Hole **location dimensions are no longer capped at four** per part: they are
27
+ placed nearest-datum-first (baseline practice) until the above-view tier
28
+ strips fill, so a part with room gets all its holes located instead of an
29
+ arbitrary four. Refs that genuinely don't fit are skipped (never
30
+ force-placed) and surface as `location_ref_dropped` (#36). First step of the
31
+ adaptive-caps work; step-height and per-view callout caps follow.
32
+
33
+ ### Added
34
+
35
+ - `Drawing.lint_summary()` — a JSON-friendly aggregate of `lint()` for
36
+ non-interactive callers (scripts, or an LLM via the API): severity counts,
37
+ per-code counts, a `geometry_issues` tally (standards/geometry checks vs pure
38
+ layout), a `passed` flag, a coarse 0–1 `score`, and the full issue list. Gives
39
+ a single signal to gate and optimise on without rendering the SVG (#32).
40
+
41
+ ### Fixed
42
+
43
+ - Annotations the layout had to drop (hole callouts past the per-view cap,
44
+ location references past the per-part cap, step-height dimensions past the
45
+ first three, bore callouts with no room or an unsatisfiable strip) are no
46
+ longer silent: each is recorded during the build and surfaced by `lint()`
47
+ under a dedicated code (`callout_dropped`, `location_ref_dropped`,
48
+ `step_dim_dropped`, `placement_unsatisfiable`), so a short drawing always
49
+ carries a machine-readable reason (#32).
50
+ - `placement_unsatisfiable` (the engine could not place an annotation it wanted
51
+ to, as opposed to a deliberate cap) is **error** severity, so it fails the
52
+ `lint_summary()` `passed` gate; the deliberate-cap drops stay warnings (#32).
53
+ - A callout dropped by the per-view cap is no longer double-reported: the
54
+ dropped diameters are named in the `callout_dropped` message and excluded from
55
+ `feature_not_dimensioned` (#32).
56
+ - `_auto_annotate` is idempotent for build-time lint records — re-annotating a
57
+ drawing no longer accumulates duplicate drop reports (#32).
58
+
59
+ ## v0.1.6 — 2026-06-15
60
+
61
+ ### Fixed
62
+
63
+ - Section-view boolean cut on cast geometry: the exact `body - Box(...)` boolean
64
+ raised an uncatchable `Standard_DomainError` (C++ abort, SIGABRT) on some parts
65
+ (NIST CTC-04), crashing the whole drawing. `_fuzzy_cut()` now runs
66
+ `BRepAlgoAPI_Cut` with a small fuzzy tolerance and keeps solids-only, making
67
+ the section cut robust (#20, #22).
68
+
69
+ ### Tests
70
+
71
+ - NIST CTC-04 (both AP203 and AP242) now build with a clean section view and are
72
+ covered by the CTC build tests.
73
+ - Known: CTC-02 AP242 still segfaults inside OCCT's AP242/PMI STEP read (#20),
74
+ excluded from build tests.
75
+
76
+ ## v0.1.5 — 2026-06-15
77
+
78
+ ### Fixed
79
+
80
+ - CTC-02 spurious full-page line: build123d's `ExportSVG` projected
81
+ circle-edge-on edges (hole/fillet rims seen edge-on) as elliptical arcs with
82
+ a near-zero minor radius, which renderers blow up into full-page lines.
83
+ `sanitize_svg_arcs()` rewrites any arc with a sub-1e-3 mm radius into the
84
+ straight line it actually is, leaving real arcs untouched (#19). Not a PMI
85
+ issue — the file is AP203 geometry-only.
86
+
87
+ ### Tests
88
+
89
+ - Added the full NIST CTC set (01–05) as fixtures, both AP203 geometry-only and
90
+ AP242 (with-PMI) variants.
91
+ - Heavy end-to-end CTC fixture builds are marked `slow` and deselected from the
92
+ default `pytest` run (fast normal run, ~4.5 min); CI runs the fast tier across
93
+ the OS/Python matrix and the slow tier once.
94
+ - Known: AP242 CTC-02 and both CTC-04 variants crash OCCT on import (#20); their
95
+ fixtures are excluded from build tests.
96
+
97
+ ## v0.1.4 — 2026-06-15
98
+
99
+ ### Changed
100
+
101
+ - Feature annotations (hole callouts, location dimensions, section view) now
102
+ fire on feature presence independent of the turned/prismatic classification,
103
+ so turned-and-drilled parts (e.g. flanges) get both the OD/centreline base
104
+ set and per-hole callouts plus bolt-circle furniture (#10).
105
+ - Isometric view placement now uses a general largest-empty-rectangle search in
106
+ place of the wide/flat-on-A3 special case (#11).
107
+ - Concentric bore-leader stacking is generalised beyond three, and the
108
+ step-height dimension gate is now a single derived constant (#10, #12).
109
+
110
+ ### Internal
111
+
112
+ - Single-sourced duplicated geometry constants from the draft preset (#12).
113
+ - Minor comment and logging cleanups.
114
+
115
+ ## v0.1.0 — 2026-06-14
116
+
117
+ Initial release — spun out of `build123d-drafting-helpers` v0.9.1.
118
+
119
+ The automated drawing engine (`make_drawing`, `build_drawing`, `Drawing`)
120
+ was previously part of `build123d-drafting-helpers`. It is now a separate
121
+ AGPL-licensed package that depends on `build123d-drafting-helpers>=0.9.1`
122
+ for annotation primitives.
123
+
124
+ ### Migration from build123d-drafting-helpers
125
+
126
+ ```python
127
+ # Before
128
+ from build123d_drafting import make_drawing, Drawing, build_drawing
129
+
130
+ # After
131
+ from draftwright import make_drawing, Drawing, build_drawing
132
+ ```
133
+
134
+ ### Features (carried over from build123d-drafting-helpers)
135
+
136
+ - **`make_drawing`** / **`build_drawing`** — automatic multi-view technical
137
+ drawing from a build123d solid: view layout, scale selection, orthographic
138
+ projection, dimension placement, title block.
139
+ - **`Drawing`** — composable drawing object with `.lint()`, `.add()`,
140
+ `.export_svg()`, `.export_dxf()`.
141
+ - **`choose_scale`** — ISO/ASME standard scale selection.
142
+ - **`lint_feature_coverage`** — checks annotation coverage against detected
143
+ part features (holes, bosses, bolt circles).
144
+ - **Section A–A views** — automatic section view for blind/stepped holes,
145
+ with ISO 128-44 solid filled cutting-plane arrows and ISO 128-50 45°
146
+ hatching on the cut face.
147
+ - **`generate_script`** — generates a standalone drawing script from a STEP
148
+ file.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.1.6
3
+ Version: 0.1.7
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.6"
7
+ version = "0.1.7"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -17,6 +17,7 @@ CLI (registered as ``make-drawing``)::
17
17
  """
18
18
 
19
19
  import argparse
20
+ import functools
20
21
  import logging
21
22
  import math
22
23
  import re
@@ -26,6 +27,7 @@ from types import SimpleNamespace
26
27
  from typing import Literal
27
28
 
28
29
  from build123d import (
30
+ Align,
29
31
  Arrow,
30
32
  Box,
31
33
  Color,
@@ -40,8 +42,8 @@ from build123d import (
40
42
  Mode,
41
43
  Pos,
42
44
  Shape,
45
+ Text,
43
46
  Vector,
44
- import_step,
45
47
  )
46
48
  from build123d_drafting.features import (
47
49
  BoltCircle,
@@ -73,6 +75,8 @@ from build123d_drafting.helpers import (
73
75
  from OCP.BRepAdaptor import BRepAdaptor_Surface
74
76
  from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
75
77
  from OCP.GeomAbs import GeomAbs_Plane
78
+ from OCP.IFSelect import IFSelect_ReturnStatus
79
+ from OCP.STEPControl import STEPControl_Reader
76
80
  from OCP.TopTools import TopTools_ListOfShape
77
81
 
78
82
  _log = logging.getLogger(__name__)
@@ -167,6 +171,23 @@ def sanitize_svg_arcs(svg_path: str) -> int:
167
171
  return n
168
172
 
169
173
 
174
+ def _import_step(path) -> Compound:
175
+ """Read solid geometry from a STEP file via OCCT's ``STEPControl_Reader``.
176
+
177
+ build123d's ``import_step`` uses the XCAF reader (colours, names, PMI), which
178
+ **segfaults** on some AP242 files carrying semantic PMI — e.g. NIST CTC-02
179
+ AP242 (#20) — before any Python code can intervene. draftwright needs only
180
+ the solid geometry (it drops PMI presentation data anyway), so we read the
181
+ geometry directly. Verified to produce identical shapes (solids, edges, bbox)
182
+ to ``import_step`` on the files that read in both, minus the unused metadata.
183
+ """
184
+ reader = STEPControl_Reader()
185
+ if reader.ReadFile(str(path)) != IFSelect_ReturnStatus.IFSelect_RetDone:
186
+ raise ValueError(f"could not read STEP file {path!r}")
187
+ reader.TransferRoots()
188
+ return Compound(reader.OneShape())
189
+
190
+
170
191
  # ---------------------------------------------------------------------------
171
192
  # Geometry analysis
172
193
  # ---------------------------------------------------------------------------
@@ -188,6 +209,30 @@ def _fmt(v: float) -> str:
188
209
  return str(r) if abs(v - r) < 1e-6 else f"{v:.1f}"
189
210
 
190
211
 
212
+ @functools.lru_cache(maxsize=512)
213
+ def _text_width(text: str, font_size: float, font: str = "Arial") -> float:
214
+ """Measured rendered width (page-mm) of *text* in *font* at *font_size*.
215
+
216
+ Uses build123d's ``Text`` — the same primitive ``Dimension``/``HoleCallout``
217
+ stroke their labels with — so callout-width estimates use real glyph metrics
218
+ instead of a character-count fudge (#31). Cached because the same numeric
219
+ labels recur across holes and the rasterisation is the costly part.
220
+ """
221
+ if not text:
222
+ return 0.0
223
+ return (
224
+ Text(
225
+ txt=text,
226
+ font_size=font_size,
227
+ font=font,
228
+ align=(Align.CENTER, Align.CENTER),
229
+ mode=Mode.PRIVATE,
230
+ )
231
+ .bounding_box()
232
+ .size.X
233
+ )
234
+
235
+
191
236
  _DIAM_RE = re.compile(r"[øØ⌀]\s*(\d+(?:\.\d+)?)")
192
237
 
193
238
  # Turned-part classification (#81): a rotational part's bounding box is
@@ -252,9 +297,14 @@ def _concentric_bore_diams(a) -> list:
252
297
  return [d for d in a.z_diams if d != a.od_diam and any(abs(d - c) <= 0.15 for c in concentric)]
253
298
 
254
299
 
255
- def lint_feature_coverage(part, annotations, tol: float = 0.15, cyls=None) -> list:
300
+ def lint_feature_coverage(part, annotations, tol: float = 0.15, cyls=None, exclude=None) -> list:
256
301
  """Coarse completeness check: report part diameters with no callout (#80).
257
302
 
303
+ ``exclude`` is an optional iterable of diameters already accounted for by a
304
+ more specific build-time lint (e.g. the per-view callout cap's
305
+ ``callout_dropped``); these are skipped here so a dropped callout is not
306
+ double-reported as ``feature_not_dimensioned``.
307
+
258
308
  Builds a feature inventory from *part*'s hole/boss diameters (cylinder
259
309
  patches spanning at least ~half a turn around their axis in total, so
260
310
  fillets are ignored) and diffs it against every ø value mentioned in the
@@ -294,6 +344,7 @@ def lint_feature_coverage(part, annotations, tol: float = 0.15, cyls=None) -> li
294
344
  mentioned.add(float(v))
295
345
  provided[float(v)] = provided.get(float(v), 0) + count
296
346
 
347
+ exclude = exclude or ()
297
348
  issues = [
298
349
  LintIssue(
299
350
  severity="warning",
@@ -302,6 +353,7 @@ def lint_feature_coverage(part, annotations, tol: float = 0.15, cyls=None) -> li
302
353
  )
303
354
  for d in inventory
304
355
  if not any(abs(d - v) <= tol for v in mentioned)
356
+ and not any(abs(d - e) <= tol for e in exclude)
305
357
  ]
306
358
 
307
359
  required: dict[float, int] = {}
@@ -326,6 +378,30 @@ def lint_feature_coverage(part, annotations, tol: float = 0.15, cyls=None) -> li
326
378
  return issues
327
379
 
328
380
 
381
+ # --- lint scoring (see Drawing.lint_summary) -------------------------------
382
+ # Codes that check standards/geometry correctness rather than pure page
383
+ # layout. Grouped so a caller (and the #30 repair loop) can tell a wrong
384
+ # drawing from a merely tight one.
385
+ _GEOMETRY_AWARE_CODES = frozenset(
386
+ {
387
+ "feature_not_dimensioned",
388
+ "feature_count_mismatch",
389
+ "missing_principal_dimension",
390
+ "label_vs_measured",
391
+ "dim_inside_part",
392
+ "callout_dropped",
393
+ "location_ref_dropped",
394
+ "placement_unsatisfiable",
395
+ }
396
+ )
397
+
398
+ # Coarse 0–1 quality heuristic: a clean sheet scores 1.0; each issue subtracts
399
+ # a flat per-severity penalty (clamped at 0). A convenience signal only — the
400
+ # severity/code counts in the summary are the authoritative output.
401
+ _SCORE_ERROR_PENALTY = 0.2
402
+ _SCORE_WARNING_PENALTY = 0.05
403
+
404
+
329
405
  def analyse_face_levels(part, tol: float = 0.5) -> list:
330
406
  """Return sorted unique Z-coords of horizontal (normal≈±Z) planar faces.
331
407
 
@@ -501,14 +577,37 @@ def _parse_page(page) -> tuple:
501
577
  _STRIP_GAP = 8.0
502
578
  _STRIP_SPACING = 4.0
503
579
 
580
+ # Horizontal page budget to reserve for the isometric view during scale
581
+ # selection and view placement, as a fraction of bbox_max * scale. This is a
582
+ # deliberate *under-estimate*, not the true projected size (a cube's iso
583
+ # projection is ~1.63*bbox_max wide): the iso is the last column and is fitted
584
+ # to the actual largest-empty-rect afterwards by _fit_iso_view(), which shrinks
585
+ # it to whatever space is genuinely left. A true fit test here is circular —
586
+ # the empty rect depends on the very view positions this estimate feeds — so
587
+ # the budget stays a single, named factor rather than a recomputed fit (#31).
588
+ _ISO_WIDTH_BUDGET = 0.7
589
+
504
590
  # Slot sizes for the annotations that allocate from fv/pv/sv strips.
505
591
  # Shared between the depth estimators below and the allocate() call-sites in
506
592
  # _auto_annotate() so that a slot-size change is automatically reflected in
507
593
  # the estimator-driven corridor widths.
508
- _SLOT_DIM_HEIGHT = 10.0 # fv_zones.right: overall height dimension
509
- _SLOT_DIM_STEP = 14.0 # fv_zones.right: step-height dimension
510
- _SLOT_DIM_WIDTH = 8.0 # pv_zones.below: overall width dimension
511
- _SLOT_DIM_DEPTH = 8.0 # sv_zones.below: overall depth dimension
594
+ #
595
+ # A slot is the perpendicular depth (page-mm) reserved for one Dimension: its
596
+ # dim-line offset from the view edge plus the label, which sits exactly
597
+ # pad_around_text beyond the line (measured: a "right"/"below" Dimension's
598
+ # perpendicular span equals offset + pad_around_text - extension_gap, and
599
+ # pad == extension_gap in the draft preset). Each slot is therefore derived
600
+ # from text metrics (font_size + pad_around_text), like _MIN_STEP_DIM_MM, so it
601
+ # rescales with _FONT_SIZE instead of being a bare mm guess (#31).
602
+ _PAD = draft_preset(font_size=_FONT_SIZE, decimal_precision=1).pad_around_text
603
+ # Single overall dim: two glyph-heights of line offset + the outboard label pad.
604
+ _SLOT_DIM_WIDTH = 2 * _FONT_SIZE + _PAD # pv_zones.below: overall width dimension
605
+ _SLOT_DIM_DEPTH = 2 * _FONT_SIZE + _PAD # sv_zones.below: overall depth dimension
606
+ # The overall height dim leads the right ladder, so it carries an extra pad of
607
+ # clearance from the view above the first step dim's witness.
608
+ _SLOT_DIM_HEIGHT = 2 * _FONT_SIZE + 2 * _PAD # fv_zones.right: overall height dim
609
+ # Stacked step dims sit deeper so each ladder rung's label clears the rung below.
610
+ _SLOT_DIM_STEP = 4 * _FONT_SIZE + _PAD # fv_zones.right: step-height dimension
512
611
 
513
612
  # Smallest projected step height (page-mm) that can still carry a *legible*
514
613
  # stacked dimension between its two extension lines. Derived from what has to
@@ -536,12 +635,11 @@ _MIN_STEP_DIM_MM = (
536
635
  def _est_right_strip_depth(n_steps: int) -> float:
537
636
  """Depth needed to the right of the front view.
538
637
 
539
- Always includes dim_height (1 slot). Up to *n_steps* dim_step slots
540
- (capped at 3) follow if any step levels are present. Returns the minimum
541
- corridor width (from view edge to outer_limit) that makes all those
542
- allocations succeed.
638
+ Always includes dim_height (1 slot). *n_steps* dim_step slots follow if
639
+ any step levels are present. Returns the minimum corridor width (from view
640
+ edge to outer_limit) that makes all those allocations succeed.
543
641
  """
544
- n = 1 + min(max(n_steps, 0), 3) # dim_height + up to 3 step dims
642
+ n = 1 + max(n_steps, 0) # dim_height + one slot per step dim
545
643
  # gap + dim_height + (n-1) step slots each preceded by one spacing
546
644
  return _STRIP_GAP + _SLOT_DIM_HEIGHT + (n - 1) * (_STRIP_SPACING + _SLOT_DIM_STEP)
547
645
 
@@ -597,10 +695,13 @@ def _est_bore_callout_width(
597
695
  bc_by_spec[_spec_key(p.holes[0])] = p
598
696
 
599
697
  h_fs = font_size
698
+ # gap (inter-token spacing) and sym_w (geometry-symbol cell width) mirror
699
+ # HoleCallout's own internal layout constants so the estimate matches what
700
+ # the primitive actually strokes. Variable text tokens are measured with
701
+ # real glyph metrics (_text_width) rather than a character-count fudge (#31).
600
702
  gap = 0.45 * h_fs
601
703
  sym_w = h_fs
602
704
  pad = pad_around_text
603
- char_w = 0.6 * h_fs # avg character width
604
705
 
605
706
  max_w = 0.0
606
707
  for spec_key, group in groups.items():
@@ -611,26 +712,26 @@ def _est_bore_callout_width(
611
712
 
612
713
  token_w: list[float] = []
613
714
  if count:
614
- token_w.append(len(f"{count}×") * char_w)
715
+ token_w.append(_text_width(f"{count}×", h_fs))
615
716
  token_w.append(sym_w) # ⌀ symbol
616
- token_w.append(len(_fmt(rep.diameter)) * char_w)
717
+ token_w.append(_text_width(_fmt(rep.diameter), h_fs))
617
718
  if through:
618
- token_w.append(len("THRU") * char_w)
719
+ token_w.append(_text_width("THRU", h_fs))
619
720
  elif rep.depth:
620
721
  token_w.append(sym_w) # depth symbol
621
- token_w.append(len(_fmt(rep.depth)) * char_w)
722
+ token_w.append(_text_width(_fmt(rep.depth), h_fs))
622
723
  if step:
623
724
  token_w.append(sym_w) # counterbore/spotface symbol
624
725
  token_w.append(sym_w) # ⌀
625
- token_w.append(len(_fmt(step.diameter)) * char_w)
726
+ token_w.append(_text_width(_fmt(step.diameter), h_fs))
626
727
  if step.depth:
627
728
  token_w.append(sym_w) # depth symbol
628
- token_w.append(len(_fmt(step.depth)) * char_w)
729
+ token_w.append(_text_width(_fmt(step.depth), h_fs))
629
730
 
630
731
  # BoltCircle suffix: "EQ SP ON ø{bc_dia} BC"
631
732
  bc = bc_by_spec.get(spec_key)
632
733
  if bc is not None:
633
- token_w.append(len(f"EQ SP ON ø{_fmt(bc.diameter)} BC") * char_w)
734
+ token_w.append(_text_width(f"EQ SP ON ø{_fmt(bc.diameter)} BC", h_fs))
634
735
 
635
736
  n = len(token_w)
636
737
  w = sum(token_w) + max(n - 1, 0) * gap + pad
@@ -705,7 +806,7 @@ def _fits(
705
806
  + gap_fv_sv
706
807
  + y_size * scale
707
808
  + _DIM_PAD
708
- + bbox_max * scale * 0.7
809
+ + bbox_max * scale * _ISO_WIDTH_BUDGET
709
810
  + _DIM_PAD
710
811
  + tb_w
711
812
  + _MARGIN
@@ -840,7 +941,7 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
840
941
  part = step_file
841
942
  src = "build123d object"
842
943
  else:
843
- part = import_step(step_file)
944
+ part = _import_step(step_file)
844
945
  src = str(step_file)
845
946
  # AP242 STEP files carry PMI presentation geometry (annotation-plane
846
947
  # border wires, leader curves) beside the solid; left in, it draws as
@@ -923,7 +1024,7 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
923
1024
 
924
1025
  # Conservative upper bound for page selection: count all candidate step
925
1026
  # faces without the SCALE-dependent _MIN_STEP_DIM_MM gate (SCALE not yet known).
926
- n_steps_ub = len(step_zs[:3])
1027
+ n_steps_ub = len(step_zs)
927
1028
  strips_ub = _measure_strips(
928
1029
  holes,
929
1030
  patterns,
@@ -954,7 +1055,7 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
954
1055
  DIM_PAD = _DIM_PAD
955
1056
  margin = _MARGIN
956
1057
  # Refine: apply the same legibility gate _auto_annotate uses for dim_step.
957
- n_steps = len([z for z in step_zs[:3] if (z - bb.min.Z) * SCALE >= _MIN_STEP_DIM_MM])
1058
+ n_steps = len([z for z in step_zs if (z - bb.min.Z) * SCALE >= _MIN_STEP_DIM_MM])
958
1059
  strips = _measure_strips(
959
1060
  holes,
960
1061
  patterns,
@@ -980,7 +1081,7 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
980
1081
  + x_size * SCALE
981
1082
  + y_size * SCALE
982
1083
  + 2 * DIM_PAD
983
- + bbox_max * SCALE * 0.7
1084
+ + bbox_max * SCALE * _ISO_WIDTH_BUDGET
984
1085
  )
985
1086
  x_offset = max(0.0, (PAGE_W - 2 * margin - TB_W - total_content_w) / 2)
986
1087
 
@@ -1220,6 +1321,14 @@ class Drawing:
1220
1321
  self.svg_path: str | None = None
1221
1322
  self.dxf_path: str | None = None
1222
1323
  self._analysis: SimpleNamespace | None = None
1324
+ # Lint issues found while building (e.g. annotations the layout had to
1325
+ # drop). Recorded here so :meth:`lint` can surface them — a dropped
1326
+ # feature must never be silent. Diameters dropped by the per-view
1327
+ # callout cap are tracked separately so :meth:`lint` can suppress the
1328
+ # redundant feature_not_dimensioned for them. Both are reset at the
1329
+ # top of :func:`_auto_annotate` so re-annotation does not accumulate.
1330
+ self._build_issues: list = []
1331
+ self._dropped_callout_diams: list = []
1223
1332
 
1224
1333
  # -- views ----------------------------------------------------------------
1225
1334
  def add_view(self, name, shape, camera, up, position, *, look_at=None, scaled=False):
@@ -1311,11 +1420,18 @@ class Drawing:
1311
1420
  self._named = kept_named
1312
1421
  return removed
1313
1422
 
1423
+ def _record_build_issue(self, severity, code, message):
1424
+ """Record a lint issue discovered during construction (e.g. an
1425
+ annotation the layout had to drop). Surfaced by :meth:`lint` so a
1426
+ dropped feature is never silent."""
1427
+ self._build_issues.append(LintIssue(severity=severity, code=code, message=message))
1428
+
1314
1429
  # -- output ---------------------------------------------------------------
1315
1430
  def lint(self):
1316
1431
  """Lint all annotations against all views; returns the list of issues.
1317
1432
 
1318
1433
  When :attr:`part` is set, also runs :func:`lint_feature_coverage`.
1434
+ Build-time drops recorded via :meth:`_record_build_issue` are included.
1319
1435
  """
1320
1436
  set_page(self.page_w, self.page_h, margin=10)
1321
1437
  view_shapes = [vis for vis, _ in self.views.values()]
@@ -1323,9 +1439,59 @@ class Drawing:
1323
1439
  if self.part is not None:
1324
1440
  if self._cyl_cache is None:
1325
1441
  self._cyl_cache = analyse_cylinders(self.part)
1326
- issues += lint_feature_coverage(self.part, self.annotations, cyls=self._cyl_cache)
1442
+ issues += lint_feature_coverage(
1443
+ self.part,
1444
+ self.annotations,
1445
+ cyls=self._cyl_cache,
1446
+ exclude=self._dropped_callout_diams,
1447
+ )
1448
+ issues += list(self._build_issues)
1327
1449
  return issues
1328
1450
 
1451
+ def lint_summary(self) -> dict:
1452
+ """Aggregate :meth:`lint` into a JSON-friendly quality summary.
1453
+
1454
+ Gives a non-interactive caller (a script, or an LLM via the API) a
1455
+ single signal to gate and optimise on without rendering the SVG:
1456
+
1457
+ - ``passed`` — no error-severity issues;
1458
+ - ``score`` — coarse 0–1 quality heuristic (see ``_SCORE_*``);
1459
+ - ``errors`` / ``warnings`` / ``infos`` — counts by severity;
1460
+ - ``by_code`` — per-check counts;
1461
+ - ``geometry_issues`` — count of standards/geometry-correctness issues
1462
+ as opposed to pure layout (see ``_GEOMETRY_AWARE_CODES``);
1463
+ - ``issues`` — the full list, each as a plain dict.
1464
+ """
1465
+ issues = self.lint()
1466
+ errors = sum(1 for i in issues if i.severity == "error")
1467
+ warnings = sum(1 for i in issues if i.severity == "warning")
1468
+ infos = sum(1 for i in issues if i.severity == "info")
1469
+ by_code: dict[str, int] = {}
1470
+ for i in issues:
1471
+ by_code[i.code] = by_code.get(i.code, 0) + 1
1472
+ score = max(
1473
+ 0.0,
1474
+ 1.0 - errors * _SCORE_ERROR_PENALTY - warnings * _SCORE_WARNING_PENALTY,
1475
+ )
1476
+ return {
1477
+ "passed": errors == 0,
1478
+ "score": score,
1479
+ "errors": errors,
1480
+ "warnings": warnings,
1481
+ "infos": infos,
1482
+ "by_code": by_code,
1483
+ "geometry_issues": sum(1 for i in issues if i.code in _GEOMETRY_AWARE_CODES),
1484
+ "issues": [
1485
+ {
1486
+ "severity": i.severity,
1487
+ "code": i.code,
1488
+ "message": i.message,
1489
+ "location": i.location,
1490
+ }
1491
+ for i in issues
1492
+ ],
1493
+ }
1494
+
1329
1495
  def export(self, out=None):
1330
1496
  """Lint, then write SVG and DXF. Returns ``(svg_path, dxf_path)``."""
1331
1497
  out = out if out is not None else self.out
@@ -1464,6 +1630,10 @@ def _export_shape(exporter, shape, layer, ctx):
1464
1630
  def _auto_annotate(dwg, a):
1465
1631
  """Add the standard automatic dimensions, centrelines, and title block."""
1466
1632
  draft = dwg.draft
1633
+ # Idempotent: clear build-time lint state so a second annotation pass does
1634
+ # not accumulate duplicate drop records.
1635
+ dwg._build_issues = []
1636
+ dwg._dropped_callout_diams = []
1467
1637
 
1468
1638
  def FX(x):
1469
1639
  return a.FV_X + (x - a.cx) * a.SCALE
@@ -1644,13 +1814,24 @@ def _auto_annotate(dwg, a):
1644
1814
 
1645
1815
  # Step heights — only where the step is tall enough to fit a label;
1646
1816
  # each step witnesses from the previous dim's line (_right_ladder) so
1647
- # extension lines are adjacent rather than coincident
1648
- for col, z in enumerate(
1649
- [z for z in a.step_zs[:3] if (z - a.bb.min.Z) * a.SCALE >= _MIN_STEP_DIM_MM]
1650
- ):
1817
+ # extension lines are adjacent rather than coincident. No fixed cap: the
1818
+ # fv_zones.right corridor is sized for every legible step (#36), and the
1819
+ # strip allocator is the real bound a step that doesn't fit surfaces via
1820
+ # lint rather than being silently dropped.
1821
+ def _step_legible(z):
1822
+ return (z - a.bb.min.Z) * a.SCALE >= _MIN_STEP_DIM_MM
1823
+
1824
+ _step_zs = [z for z in a.step_zs if _step_legible(z)]
1825
+ for col, z in enumerate(_step_zs):
1651
1826
  _px = a.fv_zones.right.allocate(_SLOT_DIM_STEP)
1652
1827
  if _px is None:
1653
1828
  _log.warning("dim_step_%d skipped: fv_zones.right strip full", col)
1829
+ dwg._record_build_issue(
1830
+ "error",
1831
+ "placement_unsatisfiable",
1832
+ f"{len(_step_zs) - col} step-height dimension(s) dropped "
1833
+ "(front-view right strip full)",
1834
+ )
1654
1835
  break
1655
1836
  dwg.add(
1656
1837
  Dimension(
@@ -2111,10 +2292,20 @@ def _annotate_pmi(dwg, a, draft) -> None:
2111
2292
  _log.info("PMI annotate: %d/%d dims placed", emitted, len(usable))
2112
2293
 
2113
2294
 
2114
- _MAX_CALLOUTS_PER_VIEW = 4
2295
+ def _record_callout_drop(dwg, view, diam, reason):
2296
+ """Record a hole callout the layout could not place (#36).
2115
2297
 
2116
-
2117
- _MAX_LOCATION_REFS = 4
2298
+ A warning (the drawing is incomplete, not invalid), whose diameter is
2299
+ excluded from ``feature_not_dimensioned`` like the old per-view cap drop —
2300
+ so a callout that genuinely doesn't fit is surfaced once, with a reason,
2301
+ and not double-reported.
2302
+ """
2303
+ dwg._dropped_callout_diams.append(diam)
2304
+ dwg._record_build_issue(
2305
+ "warning",
2306
+ "callout_dropped",
2307
+ f"hole callout ø{_fmt(diam)} dropped from the {view} view ({reason})",
2308
+ )
2118
2309
 
2119
2310
 
2120
2311
  def _add_location_dims(dwg, a, axis_letter, patterns, holes_in=None):
@@ -2123,11 +2314,13 @@ def _add_location_dims(dwg, a, axis_letter, patterns, holes_in=None):
2123
2314
  The datum corner is a *default* — the part's minimum-X/minimum-Y corner
2124
2315
  (lower-left in the plan view), per inspection practice; a human/LLM pass
2125
2316
  can re-anchor it. One reference per pattern (bolt-circle centre, array
2126
- first hole) plus each unpatterned hole, capped at
2127
- ``_MAX_LOCATION_REFS`` (largest holes first, the rest logged). X dims
2128
- tier above the plan view (below sit dim_width and the front view),
2129
- Y dims tier to its left; a tier that would leave the page is skipped,
2130
- never force-placed. Cross-axis holes are not located yet (logged).
2317
+ first hole) plus each unpatterned hole. There is no fixed cap: dims are
2318
+ placed nearest-datum-first (baseline practice) until the above-view tier
2319
+ strips fill a tier that would leave the page is skipped, never
2320
+ force-placed, and the unplaced ref surfaces as ``location_ref_dropped``
2321
+ (#36). X dims tier above the plan view (below sit dim_width and the front
2322
+ view), Y dims tier above the side view. Cross-axis holes are not located
2323
+ yet (logged).
2131
2324
  """
2132
2325
  draft = dwg.draft
2133
2326
  all_holes = a.holes if holes_in is None else holes_in
@@ -2160,14 +2353,6 @@ def _add_location_dims(dwg, a, axis_letter, patterns, holes_in=None):
2160
2353
  refs = unique
2161
2354
  if not refs:
2162
2355
  return
2163
- if len(refs) > _MAX_LOCATION_REFS:
2164
- refs.sort(key=lambda r: r[2], reverse=True)
2165
- _log.info(
2166
- "%d location references; dimensioning the %d largest",
2167
- len(refs),
2168
- _MAX_LOCATION_REFS,
2169
- )
2170
- refs = refs[:_MAX_LOCATION_REFS]
2171
2356
 
2172
2357
  def PX(x):
2173
2358
  return a.PV_X + (x - a.cx) * a.SCALE
@@ -2194,6 +2379,11 @@ def _add_location_dims(dwg, a, axis_letter, patterns, holes_in=None):
2194
2379
  _py = a.pv_zones.above.allocate(tier)
2195
2380
  if _py is None:
2196
2381
  _log.info("X location dim for x=%s skipped (no room above plan view)", _fmt(rx))
2382
+ dwg._record_build_issue(
2383
+ "warning",
2384
+ "location_ref_dropped",
2385
+ f"X location dim for x={_fmt(rx)} not placed (no room above the plan view)",
2386
+ )
2197
2387
  continue
2198
2388
  dwg.add(
2199
2389
  Dimension(
@@ -2249,6 +2439,11 @@ def _add_location_dims(dwg, a, axis_letter, patterns, holes_in=None):
2249
2439
  _py = a.sv_zones.above.allocate(tier)
2250
2440
  if _py is None:
2251
2441
  _log.info("Y location dim for y=%s skipped (no room above the side view)", _fmt(ry))
2442
+ dwg._record_build_issue(
2443
+ "warning",
2444
+ "location_ref_dropped",
2445
+ f"Y location dim for y={_fmt(ry)} not placed (no room above the side view)",
2446
+ )
2252
2447
  continue
2253
2448
  dwg.add(
2254
2449
  Dimension(
@@ -2652,7 +2847,11 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
2652
2847
  """
2653
2848
  draft = dwg.draft
2654
2849
  gap = draft.pad_around_text
2655
- min_gap = draft.font_size * 2.2
2850
+ # Minimum vertical separation between stacked bore-callout labels: one label
2851
+ # height (font_size) plus pad_around_text clearance above and below, so
2852
+ # adjacent labels never touch. Derived from text metrics rather than a bare
2853
+ # font-size ratio (#31).
2854
+ min_gap = draft.font_size + 2 * gap
2656
2855
  # Group on the same machining-spec key pattern detection uses (snapped
2657
2856
  # axis vector included): blind holes drilled from opposite faces are
2658
2857
  # different operations and get separate callouts, and a spec group's
@@ -2739,19 +2938,12 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
2739
2938
  for holes in view_groups:
2740
2939
  pattern = patterns.get(frozenset(holes))
2741
2940
  specs.append((holes, _build_callout(holes, pattern), pattern))
2742
- if len(specs) > _MAX_CALLOUTS_PER_VIEW:
2743
- # annotate the largest features; the rest surface via the lint
2744
- # (their pattern furniture is withheld too a bare pitch circle
2745
- # with no callout referencing it explains nothing)
2746
- specs.sort(key=lambda s: s[0][0].diameter, reverse=True)
2747
- _log.info(
2748
- "%d hole specs in %s view; annotating the %d largest "
2749
- "(the rest surface as feature_not_dimensioned)",
2750
- len(specs),
2751
- view,
2752
- _MAX_CALLOUTS_PER_VIEW,
2753
- )
2754
- specs = specs[:_MAX_CALLOUTS_PER_VIEW]
2941
+ # No fixed cap (#36): every spec is attempted; the per-view placement
2942
+ # bounds below (front-view shaft rows, plan/side strip Y-solver) are the
2943
+ # real limit, and any callout that genuinely doesn't fit surfaces as
2944
+ # callout_dropped. Largest diameters first so the most significant
2945
+ # features win the available room.
2946
+ specs.sort(key=lambda s: s[0][0].diameter, reverse=True)
2755
2947
 
2756
2948
  if view == "front":
2757
2949
  # Below the view, vertical shafts. Rows are assigned right-to-
@@ -2769,6 +2961,7 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
2769
2961
  side, x0, x1 = "left", centre[0] - gap - w, centre[0] - gap
2770
2962
  else:
2771
2963
  _log.info("Hole callout ø%s skipped (no room)", _fmt(holes[0].diameter))
2964
+ _record_callout_drop(dwg, view, holes[0].diameter, "no room beside the view")
2772
2965
  continue
2773
2966
  # the title block only constrains rows that reach its x-range
2774
2967
  floor = (tb_top + 4) if x1 > tb_left - 4 else a.margin + 4
@@ -2776,6 +2969,7 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
2776
2969
  _log.info(
2777
2970
  "Hole callout ø%s skipped (front strip full)", _fmt(holes[0].diameter)
2778
2971
  )
2972
+ _record_callout_drop(dwg, view, holes[0].diameter, "front strip full")
2779
2973
  continue
2780
2974
  if any(
2781
2975
  ox0 <= centre[0] <= ox1 and row_y > elbow_y for ox0, ox1, row_y in occupied
@@ -2784,6 +2978,9 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
2784
2978
  "Hole callout ø%s skipped (shaft would cross another callout)",
2785
2979
  _fmt(holes[0].diameter),
2786
2980
  )
2981
+ _record_callout_drop(
2982
+ dwg, view, holes[0].diameter, "shaft would cross another callout"
2983
+ )
2787
2984
  continue
2788
2985
  elbow = (centre[0], elbow_y)
2789
2986
  occupied.append((x0, x1, elbow_y))
@@ -2843,6 +3040,7 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
2843
3040
 
2844
3041
  if not can_right and not can_left:
2845
3042
  _log.info("Hole callout ø%s skipped (no room)", _fmt(holes[0].diameter))
3043
+ _record_callout_drop(dwg, view, holes[0].diameter, "no room beside the view")
2846
3044
  continue
2847
3045
 
2848
3046
  if can_right and (not can_left or d_right <= d_left):
@@ -2869,6 +3067,8 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
2869
3067
  n_drop,
2870
3068
  len(right_queue),
2871
3069
  )
3070
+ for holes, *_ in right_queue[len(right_ys) :]:
3071
+ _record_callout_drop(dwg, view, holes[0].diameter, "right strip full")
2872
3072
  right_queue = right_queue[: len(right_ys)]
2873
3073
  if left_ys is None and left_queue:
2874
3074
  left_ys = _greedy_strip_ys(
@@ -2881,6 +3081,8 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
2881
3081
  n_drop,
2882
3082
  len(left_queue),
2883
3083
  )
3084
+ for holes, *_ in left_queue[len(left_ys) :]:
3085
+ _record_callout_drop(dwg, view, holes[0].diameter, "left strip full")
2884
3086
  left_queue = left_queue[: len(left_ys)]
2885
3087
 
2886
3088
  for i, ((holes, callout, pattern, _, rep), elbow_y) in enumerate(
@@ -2922,9 +3124,6 @@ def _add_title_block(dwg, a):
2922
3124
  dwg.add(tb, "title_block")
2923
3125
 
2924
3126
 
2925
- _ISO_SHRINK_FACTORS = (0.5, 0.2, 0.1)
2926
-
2927
-
2928
3127
  def _iso_bbox(dwg):
2929
3128
  """(min_x, min_y, max_x, max_y) of the placed iso view, hidden lines included."""
2930
3129
  vis, hid = dwg.views["iso"]
@@ -91,15 +91,15 @@ def test_e2e_from_step_meets_standards(tmp_path):
91
91
 
92
92
  # ---------------------------------------------------------------------------
93
93
  # NIST MBE PMI Combined Test Cases (CTC), public-domain models.
94
- # All 10 variants ship as fixtures. CTC-04 used to abort in the section-view
95
- # boolean cut; the fuzzy cut (_fuzzy_cut, #20) fixed that, so it now builds.
96
- # CTC-02 AP242 still segfaults inside OCCT's AP242/PMI STEP read (#20) — a
97
- # segfault would kill the whole test run, so it stays excluded.
94
+ # All 10 variants ship as fixtures and now build. Two #20 fixes got them there:
95
+ # the fuzzy section cut (_fuzzy_cut) unblocked CTC-04, and the direct
96
+ # STEPControl_Reader importer (_import_step) avoids the XCAF/PMI segfault that
97
+ # build123d's import_step hit on CTC-02 AP242.
98
98
  # ---------------------------------------------------------------------------
99
99
 
100
100
  FIXTURES = Path(__file__).parent / "fixtures"
101
- _CTC_AP203_OK = ["01", "02", "03", "04", "05"] # all build (#20 section-cut fix)
102
- _CTC_AP242_OK = ["01", "03", "04", "05"] # 02 segfaults in OCCT STEP+PMI read (#20)
101
+ _CTC_AP203_OK = ["01", "02", "03", "04", "05"]
102
+ _CTC_AP242_OK = ["01", "02", "03", "04", "05"]
103
103
 
104
104
 
105
105
  @pytest.mark.slow
@@ -533,11 +533,18 @@ class TestDepthEstimators:
533
533
  # dim_height (10) + 3×dim_step (14 each) + 3×spacing (4 each) = 8+10+4+14+4+14+4+14 = 72
534
534
  assert _est_right_strip_depth(3) == pytest.approx(72.0, abs=0.01)
535
535
 
536
- def test_right_depth_capped_at_three_steps(self):
537
- from draftwright.make_drawing import _est_right_strip_depth
536
+ def test_right_depth_grows_per_step_uncapped(self):
537
+ from draftwright.make_drawing import (
538
+ _SLOT_DIM_STEP,
539
+ _STRIP_SPACING,
540
+ _est_right_strip_depth,
541
+ )
538
542
 
539
- # n_steps is capped at 3 in the estimator
540
- assert _est_right_strip_depth(3) == _est_right_strip_depth(10)
543
+ # #36: no cap each further step adds one slot + one spacing.
544
+ assert _est_right_strip_depth(10) > _est_right_strip_depth(3)
545
+ assert _est_right_strip_depth(10) - _est_right_strip_depth(3) == pytest.approx(
546
+ 7 * (_STRIP_SPACING + _SLOT_DIM_STEP), abs=0.01
547
+ )
541
548
 
542
549
  def test_right_depth_increases_with_steps(self):
543
550
  from draftwright.make_drawing import _est_right_strip_depth
@@ -586,6 +593,54 @@ class TestDepthEstimators:
586
593
  assert s.allocate(_SLOT_DIM_WIDTH) is not None, "dim_width must fit in pv_below corridor"
587
594
 
588
595
 
596
+ # ---------------------------------------------------------------------------
597
+ # #31: layout constants derived from text metrics
598
+ # ---------------------------------------------------------------------------
599
+
600
+
601
+ class TestDerivedLayoutConstants:
602
+ """Slots / callout widths / iso budget derive from text metrics, not bare mm."""
603
+
604
+ def test_slots_derive_from_font_metrics(self):
605
+ from draftwright.make_drawing import (
606
+ _FONT_SIZE,
607
+ _PAD,
608
+ _SLOT_DIM_DEPTH,
609
+ _SLOT_DIM_HEIGHT,
610
+ _SLOT_DIM_STEP,
611
+ _SLOT_DIM_WIDTH,
612
+ )
613
+
614
+ assert _SLOT_DIM_WIDTH == pytest.approx(2 * _FONT_SIZE + _PAD)
615
+ assert _SLOT_DIM_DEPTH == pytest.approx(2 * _FONT_SIZE + _PAD)
616
+ assert _SLOT_DIM_HEIGHT == pytest.approx(2 * _FONT_SIZE + 2 * _PAD)
617
+ assert _SLOT_DIM_STEP == pytest.approx(4 * _FONT_SIZE + _PAD)
618
+ # The slots are linear in font metrics — a hypothetical larger font
619
+ # would yield larger slots — so they are not frozen mm constants.
620
+ assert (2 * (2 * _FONT_SIZE) + 2 * _PAD) > _SLOT_DIM_HEIGHT
621
+
622
+ def test_text_width_returns_real_glyph_metrics(self):
623
+ from draftwright.make_drawing import _text_width
624
+
625
+ assert _text_width("", 3.0) == 0.0
626
+ # A real measurement is positive and grows with the string.
627
+ w1 = _text_width("8", 3.0)
628
+ w3 = _text_width("888", 3.0)
629
+ assert 0.0 < w1 < w3
630
+ # Wider glyphs (uppercase) measure wider than the old 0.6*font fudge
631
+ # would have estimated — the whole point of using real metrics (#31).
632
+ assert _text_width("THRU", 3.0) > 4 * 0.6 * 3.0
633
+
634
+ def test_bore_callout_width_scales_with_font_size(self):
635
+ from draftwright.make_drawing import _est_bore_callout_width, find_holes
636
+
637
+ part = Box(60, 40, 12) - Pos(0, 0, 6) * Cylinder(3, 12)
638
+ holes = find_holes(part)
639
+ small = _est_bore_callout_width(holes, font_size=3.0)
640
+ large = _est_bore_callout_width(holes, font_size=6.0)
641
+ assert large > small
642
+
643
+
589
644
  # ---------------------------------------------------------------------------
590
645
  # Phase 3 (#118): dynamic FV→SV corridor
591
646
  # ---------------------------------------------------------------------------
@@ -1633,7 +1688,10 @@ class TestAutoHoleAnnotations:
1633
1688
  assert [i for i in dwg.lint() if i.severity != "info"] == []
1634
1689
 
1635
1690
  @pytest.mark.timeout(60)
1636
- def test_callout_cap_keeps_the_largest_holes(self):
1691
+ def test_all_distinct_bores_get_callouts(self):
1692
+ # #36: no per-view callout cap — six distinct-diameter holes in a row
1693
+ # all get callouts (previously capped at the four largest), and nothing
1694
+ # is dropped because they fit.
1637
1695
  part = Box(120, 80, 10)
1638
1696
  for i, r in enumerate([1, 1.5, 2, 2.5, 3, 4]):
1639
1697
  part = part - Pos(-50 + i * 20, 0, 0) * Cylinder(r, 10)
@@ -1642,10 +1700,8 @@ class TestAutoHoleAnnotations:
1642
1700
  for name, ann in dwg._named.items():
1643
1701
  if name.startswith("hc_"):
1644
1702
  covered.update(ann.covers_diameters)
1645
- assert covered == {4.0, 5.0, 6.0, 8.0}
1646
- # the dropped specs surface through the coverage lint by design
1647
- flagged = {i.message for i in dwg.lint() if i.code == "feature_not_dimensioned"}
1648
- assert len(flagged) == 2
1703
+ assert covered == {2.0, 3.0, 4.0, 5.0, 6.0, 8.0}
1704
+ assert "callout_dropped" not in {i.code for i in dwg.lint()}
1649
1705
 
1650
1706
  @pytest.mark.timeout(60)
1651
1707
  def test_rotational_part_keeps_leader_annotations(self):
@@ -2254,3 +2310,250 @@ class TestSanitizeSvgArcs:
2254
2310
  n = sanitize_svg_arcs(f)
2255
2311
  assert n == 0
2256
2312
  assert "A 5.0 5.0 0 0 1 20 20" in Path(f).read_text(encoding="utf-8")
2313
+
2314
+
2315
+ # ---------------------------------------------------------------------------
2316
+ # Lint summary + surfacing of build-time annotation drops (#32)
2317
+ # ---------------------------------------------------------------------------
2318
+
2319
+
2320
+ class TestLintSummaryAndDrops:
2321
+ def test_summary_shape_is_consistent_with_lint(self):
2322
+ from build123d import Box, Cylinder
2323
+
2324
+ from draftwright import build_drawing
2325
+
2326
+ dwg = build_drawing(Box(80, 60, 20) - Cylinder(5, 20))
2327
+ issues = dwg.lint()
2328
+ s = dwg.lint_summary()
2329
+
2330
+ assert set(s) == {
2331
+ "passed",
2332
+ "score",
2333
+ "errors",
2334
+ "warnings",
2335
+ "infos",
2336
+ "by_code",
2337
+ "geometry_issues",
2338
+ "issues",
2339
+ }
2340
+ assert s["errors"] + s["warnings"] + s["infos"] == len(issues)
2341
+ assert s["passed"] is (s["errors"] == 0)
2342
+ assert 0.0 <= s["score"] <= 1.0
2343
+ assert sum(s["by_code"].values()) == len(issues)
2344
+ assert len(s["issues"]) == len(issues)
2345
+ # A single-hole plate doesn't overflow the per-view callout cap.
2346
+ assert "callout_dropped" not in s["by_code"]
2347
+
2348
+ def test_recorded_build_issue_surfaces_and_counts(self):
2349
+ from build123d import Box
2350
+
2351
+ from draftwright import build_drawing
2352
+
2353
+ dwg = build_drawing(Box(60, 40, 30))
2354
+ before = dwg.lint_summary()
2355
+ dwg._record_build_issue("warning", "callout_dropped", "synthetic drop")
2356
+
2357
+ codes = {i.code for i in dwg.lint()}
2358
+ assert "callout_dropped" in codes
2359
+
2360
+ after = dwg.lint_summary()
2361
+ assert after["warnings"] == before["warnings"] + 1
2362
+ assert after["by_code"]["callout_dropped"] == 1
2363
+ # callout_dropped is a geometry-aware code, so it lifts that count too.
2364
+ assert after["geometry_issues"] == before["geometry_issues"] + 1
2365
+
2366
+ def test_dropped_callout_diameter_excluded_from_feature_lint(self):
2367
+ # The de-dup contract: a diameter recorded as a dropped callout is
2368
+ # excluded from feature_not_dimensioned, so a callout the layout could
2369
+ # not place (#36) is surfaced once (as callout_dropped) and not
2370
+ # double-reported.
2371
+ from build123d import Box, Cylinder
2372
+
2373
+ from draftwright.make_drawing import lint_feature_coverage
2374
+
2375
+ part = Box(60, 40, 20) - Cylinder(5, 20) # one undimensioned ø10 bore
2376
+ base = lint_feature_coverage(part, [])
2377
+ assert any(i.code == "feature_not_dimensioned" for i in base)
2378
+ excluded = lint_feature_coverage(part, [], exclude=[10.0])
2379
+ assert not any(i.code == "feature_not_dimensioned" for i in excluded)
2380
+
2381
+ @pytest.mark.timeout(120)
2382
+ def test_step_dims_are_adaptive_not_capped(self):
2383
+ # #36: no fixed 3-step cap. Five stacked ledges → a step dim for each
2384
+ # legible ledge (well over the old cap of three), corridor sized to fit
2385
+ # them all, no error-severity lint.
2386
+ from build123d import Box, Pos
2387
+
2388
+ from draftwright import build_drawing
2389
+
2390
+ tower = Box(120, 120, 15)
2391
+ for i in range(1, 6):
2392
+ side = 120 - i * 18
2393
+ tower += Pos(0, 0, i * 15) * Box(side, side, 15)
2394
+ dwg = build_drawing(tower)
2395
+ n_steps = len([n for n in dwg._named if n.startswith("dim_step")])
2396
+ assert n_steps > 3, f"expected adaptive >3 step dims, got {n_steps}"
2397
+ assert [i for i in dwg.lint() if i.severity == "error"] == []
2398
+
2399
+ @pytest.mark.timeout(120)
2400
+ def test_location_dims_are_adaptive_not_capped(self):
2401
+ # #36: location dims have no fixed cap. Six scattered holes (distinct X
2402
+ # and Y, varied diameters so no array collapses them) get far more than
2403
+ # the old cap of four location dims, with nothing dropped — they fit.
2404
+ from build123d import Box, Cylinder, Pos
2405
+
2406
+ from draftwright import build_drawing
2407
+
2408
+ plate = Box(140, 90, 8)
2409
+ for x, y, r in [
2410
+ (-55, -35, 2.0),
2411
+ (-33, -12, 2.5),
2412
+ (-11, 15, 3.0),
2413
+ (12, -20, 3.5),
2414
+ (34, 28, 2.0),
2415
+ (55, 5, 2.5),
2416
+ ]:
2417
+ plate -= Pos(x, y, 0) * Cylinder(r, 8)
2418
+ dwg = build_drawing(plate)
2419
+ n_loc = len([n for n in dwg._named if n.startswith(("dim_locx", "dim_locy"))])
2420
+ assert n_loc > 4, f"expected adaptive >4 location dims, got {n_loc}"
2421
+ assert "location_ref_dropped" not in {i.code for i in dwg.lint()}
2422
+
2423
+ @pytest.mark.timeout(120)
2424
+ def test_auto_annotate_clears_stale_build_issues(self):
2425
+ # Re-annotating starts build-time lint tracking from a clean slate:
2426
+ # stale drop records from a prior pass are cleared, not accumulated.
2427
+ # (A full second pass is not idempotent — strip cursors advance — but
2428
+ # the records always reflect only the latest pass.)
2429
+ from build123d import Box
2430
+
2431
+ from draftwright import build_drawing
2432
+ from draftwright.make_drawing import _auto_annotate
2433
+
2434
+ dwg = build_drawing(Box(60, 40, 30))
2435
+ dwg._record_build_issue("warning", "callout_dropped", "stale")
2436
+ assert any(i.message == "stale" for i in dwg._build_issues)
2437
+ _auto_annotate(dwg, dwg._analysis)
2438
+ assert not any(i.message == "stale" for i in dwg._build_issues)
2439
+ assert dwg._dropped_callout_diams == []
2440
+
2441
+ def test_repeated_lint_is_stable(self):
2442
+ # lint()/lint_summary() are idempotent — repeated calls return the same
2443
+ # issues and never accumulate the build-time drop records.
2444
+ from build123d import Box, Cylinder, Pos
2445
+
2446
+ from draftwright import build_drawing
2447
+
2448
+ plate = Box(120, 60, 8)
2449
+ for x, r in zip((-48, -24, 0, 24, 48), (2.0, 2.5, 3.0, 3.5, 4.0)):
2450
+ plate -= Pos(x, 0, 0) * Cylinder(r, 8)
2451
+ dwg = build_drawing(plate)
2452
+ first, second = dwg.lint(), dwg.lint()
2453
+ assert len(first) == len(second)
2454
+ assert dwg.lint_summary()["by_code"] == dwg.lint_summary()["by_code"]
2455
+
2456
+ def test_placement_unsatisfiable_is_error_severity(self):
2457
+ # placement_unsatisfiable (engine could not place a wanted annotation)
2458
+ # is error-severity, so it fails the `passed` gate.
2459
+ from build123d import Box
2460
+
2461
+ from draftwright import build_drawing
2462
+
2463
+ dwg = build_drawing(Box(60, 40, 30))
2464
+ assert dwg.lint_summary()["passed"] is True
2465
+ dwg._record_build_issue("error", "placement_unsatisfiable", "synthetic")
2466
+ s = dwg.lint_summary()
2467
+ assert s["passed"] is False
2468
+ assert s["errors"] >= 1
2469
+ assert s["by_code"]["placement_unsatisfiable"] == 1
2470
+
2471
+
2472
+ # ---------------------------------------------------------------------------
2473
+ # Layout generalisation guards (#13) — pin the *general* behaviour the
2474
+ # algorithm should have on turned/hybrid parts and at the step-legibility
2475
+ # boundary, so the overfitting that #10–#12/#31 removed cannot creep back.
2476
+ # ---------------------------------------------------------------------------
2477
+
2478
+
2479
+ class TestLayoutGeneralisation:
2480
+ @pytest.mark.timeout(120)
2481
+ def test_turned_flange_gets_both_od_and_hole_furniture(self):
2482
+ # A turned-and-drilled flange (cylinder OD + centre bore + bolt circle)
2483
+ # must get the turned base set (OD dim + centrelines) AND the drilled
2484
+ # furniture (hole callout + pitch circle) — not one or the other. This
2485
+ # is the feature-presence composition from #10, on a genuinely
2486
+ # rotational part rather than a prismatic plate.
2487
+ import math
2488
+
2489
+ from build123d import Cylinder, Pos
2490
+
2491
+ from draftwright import build_drawing
2492
+
2493
+ flange = Cylinder(radius=40, height=10) - Cylinder(radius=8, height=10)
2494
+ for i in range(6):
2495
+ ang = math.radians(60 * i)
2496
+ flange -= Pos(28 * math.cos(ang), 28 * math.sin(ang), 0) * Cylinder(2.5, 10)
2497
+ dwg = build_drawing(flange)
2498
+
2499
+ assert dwg._analysis.is_rotational, "flange should classify as rotational"
2500
+ # Turned base set.
2501
+ assert "dim_od" in dwg._named
2502
+ assert "centerline_front" in dwg._named
2503
+ assert "centerline_side" in dwg._named
2504
+ # Drilled furniture.
2505
+ assert any(n.startswith("hc_") for n in dwg._named), "expected a hole callout"
2506
+ assert any(n.startswith("bc_") for n in dwg._named), "expected a pitch circle"
2507
+ # No error-severity lint (warnings tolerated).
2508
+ assert [i for i in dwg.lint() if i.severity == "error"] == []
2509
+
2510
+ @pytest.mark.timeout(120)
2511
+ def test_turned_flange_dimensions_all_its_bores(self):
2512
+ # #36: no per-view callout cap — a turned part with five distinct bores
2513
+ # gets a callout for every one (was capped at four largest), more than
2514
+ # the old cap, with nothing dropped because they fit.
2515
+ import math
2516
+
2517
+ from build123d import Cylinder, Pos
2518
+
2519
+ from draftwright import build_drawing
2520
+
2521
+ flange = Cylinder(radius=45, height=10) - Cylinder(radius=8, height=10)
2522
+ for i, r in enumerate((2.0, 2.5, 3.0, 3.5, 4.0)):
2523
+ ang = math.radians(72 * i)
2524
+ flange -= Pos(30 * math.cos(ang), 30 * math.sin(ang), 0) * Cylinder(r, 10)
2525
+ dwg = build_drawing(flange)
2526
+
2527
+ n_callouts = len([n for n in dwg._named if n.startswith("hc_")])
2528
+ assert n_callouts > 4, f"expected adaptive >4 callouts, got {n_callouts}"
2529
+ assert "callout_dropped" not in {i.code for i in dwg.lint()}
2530
+
2531
+ @pytest.mark.timeout(120)
2532
+ def test_step_height_legibility_threshold(self):
2533
+ # The step-height dimension gate is the legibility constant
2534
+ # (_MIN_STEP_DIM_MM), not an incidental cutoff: a shoulder whose
2535
+ # page-projected height falls just below the gate gets no step dim;
2536
+ # just above, it does. Pin the gate, not a magic millimetre value.
2537
+ from build123d import Cylinder, Pos
2538
+
2539
+ from draftwright import build_drawing
2540
+ from draftwright.make_drawing import _MIN_STEP_DIM_MM
2541
+
2542
+ def shaft_with_shoulder_at(length):
2543
+ # Lower segment height == `length`; shoulder sits `length` above the
2544
+ # base (bb.min.Z), so legibility = length * SCALE.
2545
+ return Pos(0, 0, length / 2) * Cylinder(22, length) + Pos(
2546
+ 0, 0, length + 12.5
2547
+ ) * Cylinder(11, 25)
2548
+
2549
+ for length, expect in ((12.0, False), (13.0, True)):
2550
+ dwg = build_drawing(shaft_with_shoulder_at(length))
2551
+ a = dwg._analysis
2552
+ legible = length * a.SCALE >= _MIN_STEP_DIM_MM
2553
+ assert legible is expect, (
2554
+ f"length={length} scale={a.SCALE}: legibility expectation wrong"
2555
+ )
2556
+ has_step = "dim_step_0" in dwg._named
2557
+ assert has_step is expect, (
2558
+ f"length={length}: step dim present={has_step}, expected {expect}"
2559
+ )
@@ -1,77 +0,0 @@
1
- # Changelog
2
-
3
- ## Unreleased
4
-
5
- ## v0.1.5 — 2026-06-15
6
-
7
- ### Fixed
8
-
9
- - CTC-02 spurious full-page line: build123d's `ExportSVG` projected
10
- circle-edge-on edges (hole/fillet rims seen edge-on) as elliptical arcs with
11
- a near-zero minor radius, which renderers blow up into full-page lines.
12
- `sanitize_svg_arcs()` rewrites any arc with a sub-1e-3 mm radius into the
13
- straight line it actually is, leaving real arcs untouched (#19). Not a PMI
14
- issue — the file is AP203 geometry-only.
15
-
16
- ### Tests
17
-
18
- - Added the full NIST CTC set (01–05) as fixtures, both AP203 geometry-only and
19
- AP242 (with-PMI) variants.
20
- - Heavy end-to-end CTC fixture builds are marked `slow` and deselected from the
21
- default `pytest` run (fast normal run, ~4.5 min); CI runs the fast tier across
22
- the OS/Python matrix and the slow tier once.
23
- - Known: AP242 CTC-02 and both CTC-04 variants crash OCCT on import (#20); their
24
- fixtures are excluded from build tests.
25
-
26
- ## v0.1.4 — 2026-06-15
27
-
28
- ### Changed
29
-
30
- - Feature annotations (hole callouts, location dimensions, section view) now
31
- fire on feature presence independent of the turned/prismatic classification,
32
- so turned-and-drilled parts (e.g. flanges) get both the OD/centreline base
33
- set and per-hole callouts plus bolt-circle furniture (#10).
34
- - Isometric view placement now uses a general largest-empty-rectangle search in
35
- place of the wide/flat-on-A3 special case (#11).
36
- - Concentric bore-leader stacking is generalised beyond three, and the
37
- step-height dimension gate is now a single derived constant (#10, #12).
38
-
39
- ### Internal
40
-
41
- - Single-sourced duplicated geometry constants from the draft preset (#12).
42
- - Minor comment and logging cleanups.
43
-
44
- ## v0.1.0 — 2026-06-14
45
-
46
- Initial release — spun out of `build123d-drafting-helpers` v0.9.1.
47
-
48
- The automated drawing engine (`make_drawing`, `build_drawing`, `Drawing`)
49
- was previously part of `build123d-drafting-helpers`. It is now a separate
50
- AGPL-licensed package that depends on `build123d-drafting-helpers>=0.9.1`
51
- for annotation primitives.
52
-
53
- ### Migration from build123d-drafting-helpers
54
-
55
- ```python
56
- # Before
57
- from build123d_drafting import make_drawing, Drawing, build_drawing
58
-
59
- # After
60
- from draftwright import make_drawing, Drawing, build_drawing
61
- ```
62
-
63
- ### Features (carried over from build123d-drafting-helpers)
64
-
65
- - **`make_drawing`** / **`build_drawing`** — automatic multi-view technical
66
- drawing from a build123d solid: view layout, scale selection, orthographic
67
- projection, dimension placement, title block.
68
- - **`Drawing`** — composable drawing object with `.lint()`, `.add()`,
69
- `.export_svg()`, `.export_dxf()`.
70
- - **`choose_scale`** — ISO/ASME standard scale selection.
71
- - **`lint_feature_coverage`** — checks annotation coverage against detected
72
- part features (holes, bosses, bolt circles).
73
- - **Section A–A views** — automatic section view for blind/stepped holes,
74
- with ISO 128-44 solid filled cutting-plane arrows and ISO 128-50 45°
75
- hatching on the cut face.
76
- - **`generate_script`** — generates a standalone drawing script from a STEP
77
- file.
File without changes
File without changes
File without changes
File without changes