draftwright 0.2.5__tar.gz → 0.2.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 (95) hide show
  1. {draftwright-0.2.5 → draftwright-0.2.7}/CHANGELOG.md +88 -7
  2. {draftwright-0.2.5 → draftwright-0.2.7}/PKG-INFO +1 -1
  3. {draftwright-0.2.5 → draftwright-0.2.7}/pyproject.toml +1 -1
  4. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/__init__.py +3 -0
  5. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/_core.py +25 -0
  6. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/analysis.py +21 -16
  7. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/annotations/from_model.py +98 -46
  8. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/annotations/holes.py +8 -5
  9. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/annotations/orchestrator.py +53 -4
  10. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/builder.py +84 -7
  11. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/cli.py +27 -11
  12. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/drawing.py +12 -1
  13. draftwright-0.2.7/src/draftwright/fits.py +154 -0
  14. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/model/__init__.py +7 -0
  15. draftwright-0.2.7/src/draftwright/model/declare.py +515 -0
  16. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/model/detect.py +16 -0
  17. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/model/ir.py +13 -1
  18. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/model/planner.py +8 -1
  19. draftwright-0.2.7/src/draftwright/sheet_dsl.py +328 -0
  20. draftwright-0.2.7/src/draftwright/sheet_emit.py +191 -0
  21. draftwright-0.2.7/tests/test_declare.py +566 -0
  22. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_e2e_standards.py +106 -0
  23. draftwright-0.2.7/tests/test_fits.py +225 -0
  24. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_make_drawing.py +48 -0
  25. draftwright-0.2.7/tests/test_object_aspects.py +89 -0
  26. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_render_seam.py +4 -3
  27. draftwright-0.2.7/tests/test_sheet_emit.py +199 -0
  28. draftwright-0.2.7/tests/test_sheet_of.py +81 -0
  29. draftwright-0.2.7/tests/test_tolerances.py +218 -0
  30. {draftwright-0.2.5 → draftwright-0.2.7}/.gitignore +0 -0
  31. {draftwright-0.2.5 → draftwright-0.2.7}/LICENSE +0 -0
  32. {draftwright-0.2.5 → draftwright-0.2.7}/README.md +0 -0
  33. {draftwright-0.2.5 → draftwright-0.2.7}/skills/SKILL.md +0 -0
  34. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/annotate.py +0 -0
  35. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/annotations/__init__.py +0 -0
  36. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/annotations/_common.py +0 -0
  37. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/annotations/sections.py +0 -0
  38. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/export.py +0 -0
  39. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/features.py +0 -0
  40. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  41. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  42. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  43. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  44. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/fonts/__init__.py +0 -0
  45. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/intents.py +0 -0
  46. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/layout.py +0 -0
  47. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/linting/__init__.py +0 -0
  48. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/linting/coverage.py +0 -0
  49. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/linting/issues.py +0 -0
  50. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/linting/structural.py +0 -0
  51. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/linting/suggest.py +0 -0
  52. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/make_drawing.py +0 -0
  53. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/pmi.py +0 -0
  54. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/projection.py +0 -0
  55. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/recognition/__init__.py +0 -0
  56. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/recognition/_features.py +0 -0
  57. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/recognition/levels.py +0 -0
  58. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/recognition/slots.py +0 -0
  59. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/recognition/turned.py +0 -0
  60. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/registry.py +0 -0
  61. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/repair.py +0 -0
  62. {draftwright-0.2.5 → draftwright-0.2.7}/src/draftwright/sheet.py +0 -0
  63. {draftwright-0.2.5 → draftwright-0.2.7}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  64. {draftwright-0.2.5 → draftwright-0.2.7}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  65. {draftwright-0.2.5 → draftwright-0.2.7}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  66. {draftwright-0.2.5 → draftwright-0.2.7}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  67. {draftwright-0.2.5 → draftwright-0.2.7}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  68. {draftwright-0.2.5 → draftwright-0.2.7}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  69. {draftwright-0.2.5 → draftwright-0.2.7}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  70. {draftwright-0.2.5 → draftwright-0.2.7}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  71. {draftwright-0.2.5 → draftwright-0.2.7}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  72. {draftwright-0.2.5 → draftwright-0.2.7}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  73. {draftwright-0.2.5 → draftwright-0.2.7}/tests/layout_snapshots/box.json +0 -0
  74. {draftwright-0.2.5 → draftwright-0.2.7}/tests/layout_snapshots/bracket.json +0 -0
  75. {draftwright-0.2.5 → draftwright-0.2.7}/tests/layout_snapshots/drive_screw_x.json +0 -0
  76. {draftwright-0.2.5 → draftwright-0.2.7}/tests/layout_snapshots/dshape.json +0 -0
  77. {draftwright-0.2.5 → draftwright-0.2.7}/tests/layout_snapshots/flange.json +0 -0
  78. {draftwright-0.2.5 → draftwright-0.2.7}/tests/layout_snapshots/holed_slot.json +0 -0
  79. {draftwright-0.2.5 → draftwright-0.2.7}/tests/layout_snapshots/plate_holes.json +0 -0
  80. {draftwright-0.2.5 → draftwright-0.2.7}/tests/layout_snapshots/side_drilled.json +0 -0
  81. {draftwright-0.2.5 → draftwright-0.2.7}/tests/layout_snapshots/slotted.json +0 -0
  82. {draftwright-0.2.5 → draftwright-0.2.7}/tests/layout_snapshots/turned_shaft.json +0 -0
  83. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_e2e_slice.py +0 -0
  84. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_layout.py +0 -0
  85. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_layout_cleanliness.py +0 -0
  86. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_layout_property.py +0 -0
  87. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_layout_snapshot.py +0 -0
  88. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_lint_structural.py +0 -0
  89. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_linting.py +0 -0
  90. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_part_model.py +0 -0
  91. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_pmi.py +0 -0
  92. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_recognition.py +0 -0
  93. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_registry.py +0 -0
  94. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_strip_layout.py +0 -0
  95. {draftwright-0.2.5 → draftwright-0.2.7}/tests/test_turned_steps.py +0 -0
@@ -1,15 +1,96 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## v0.2.7 — 2026-07-05
4
+
5
+ **The three authoring modes (ADR 0011 Amendment 1).** Naming how you drive draftwright —
6
+ *just do it* / *auto then tweak* / *generate an editable script* — and filling in the third:
7
+ a declarative `Sheet`-DSL generator, and number-free authoring against the build123d objects
8
+ you built.
9
+
10
+ ### Added
11
+
12
+ - **`draftwright part.step --script --style sheet`** — generate an **editable declarative
13
+ `Sheet` script** from a detected part: one commentable line per feature (hole / diameter /
14
+ step / slot / pattern / envelope), views noted, the auto-section flagged. Comment a line out
15
+ to drop that feature; the script re-runs to the drawing. The existing `--script` (imperative
16
+ edit-verb reconstruction) stays the default (#461).
17
+ - **Object-reading `.cbore(tool)` / `.spotface(tool)`** on a declared hole — read the
18
+ counterbore/spotface's ⌀ **and** depth off the tool object + part (depth measured from the
19
+ local open face, so a rib/wall elsewhere doesn't skew it). A `Sheet`-declared drawing can now
20
+ carry **zero magic numbers** and track the geometry parametrically (#462).
21
+ - **`Sheet.of(feature)`** — a fluent handle onto an **existing / detection-seeded** feature (by
22
+ the build123d object, an index, or the `Feature` itself), so you can `.fit(...)` /
23
+ `.tolerance(...)` a feature from `Sheet.from_part()` without re-declaring it (#463).
24
+
25
+ ### Docs
26
+
27
+ - **ADR 0011 Amendment 1** — the three authoring modes and the mode-3 generation surface;
28
+ records the decision to emit **honest detected numbers** for a detected part rather than
29
+ fabricate a build123d reconstruction that misrepresents the geometry (#464).
30
+
31
+ ## v0.2.6 — 2026-07-05
32
+
33
+ **ADR 0011 — the IR as a public input: declare features, don't only detect them.**
34
+ Detection stays the default, but the caller can now *supply* the feature model, so a
35
+ part you built parametrically reads as its own drawing and misdetection becomes
36
+ recoverable by construction. Plus the first Phase-2 aspect layer (tolerances + fits).
37
+
38
+ ### Added
39
+
40
+ - **`build_drawing(part, model=…)`** accepts a caller-supplied `PartModel` (or a
41
+ `Sequence[Feature]`); when given, **detection is skipped** and the auto-pass dimensions
42
+ the declared features. Detection and declaration are two producers of the same IR (#447,
43
+ ADR 0011 Phase 0).
44
+ - **Object → feature constructors** `draftwright.model.hole` / `boss` / `step` / `slot` /
45
+ `pattern` / `envelope` — read a feature's geometry off the build123d object you built (⌀
46
+ from the cylindrical face, axis/location from the bbox), or supply explicit values (#447).
47
+ - **The fluent `Sheet` façade** (`draftwright.Sheet`) — reference the objects you built,
48
+ declare their drawing aspects, `.build()` / `.export()`. `Sheet.from_part()` seeds the
49
+ hybrid override mode from detection (#447, Phase 1).
50
+ - **Toleranced dimensions** — a `±` / limit tolerance on a diameter, step, or hole bore,
51
+ rendered on both the linear and ⌀-callout paths (`Sheet.tolerance(...)`, or a
52
+ `decorations=` side-layer) (#28, Phase 2 P2a).
53
+ - **Fit-class deviation** — `Sheet.fit("H7")` resolves an ISO 286 fit class to its limit
54
+ deviation for the feature's nominal ⌀, rendered as the class code (`ø20 H7`, default) or
55
+ the signed deviations (`show="deviation"` → `ø20 +0.021/0`). Common classes; fails loud
56
+ outside its table (#29, Phase 2 P2a.2).
57
+
58
+ ### Changed
59
+
60
+ - **Object constructors honour explicit overrides** — a passed object supplies *defaults*;
61
+ each explicit keyword overrides that field independently, and invalid public input fails
62
+ at declaration with a clear `ValueError` (#451, #452).
63
+ - **A declared hole/pattern renders at its declared position** even where detection missed
64
+ it — the callout membership is sourced from the declared model, not only detection (#448).
65
+ - **`Sheet.model()` / `Sheet.from_part()`** no longer build a full drawing just to return
66
+ the IR — feature inspection/seeding is now a cheap, no-render path (#453).
67
+
68
+ ### Fixed
69
+
70
+ - A narrow diameter band hidden under a larger OD silhouette is no longer silently
71
+ undimensioned (the two feature inventories agreed) (#298).
72
+
73
+ ## v0.2.5 — 2026-07-04
74
+
75
+ **The editable write API and record-then-finalize.** A detected drawing became an editable
76
+ object, and a generated `--script` became a runnable reconstruction that reaches auto-pass
77
+ quality.
78
+
79
+ ### Added
80
+
81
+ - **`dwg.model()`** exposes the detected `PartModel` as a read surface, plus feature-
82
+ referenced add verbs `dimension()` / `callout()` / `locate()` / `furniture()` /
83
+ `section()` and `drop(feature)`, with a machine-checked completeness audit (#400).
84
+ - **Record-then-finalize** (#426): the verbs record intents in deferred mode and
85
+ `dwg.finalize()` (auto-run by export) drains them through the auto-pass's own solvers, so
86
+ a reconstruction reaches auto-pass quality; `--script` now emits a runnable detect-only
87
+ reconstruction.
4
88
 
5
89
  ### Changed
6
90
 
7
- - **Dimension-line spacing now follows ISO 129-1 / ASME Y14.5 convention** (#347).
8
- The first dimension line sits further from the view outline (first-line gap
9
- 810 mm) and successive parallel lines stack tighter and uniform (between-line
10
- clear gap 4 → 2.5 mm). The inter-view corridor widens in step (`_DIM_PAD`
11
- 18 → 20 mm) so the wider first-line gap does not crowd the between-view
12
- dimensions. Re-drifts the layout of every drawing.
91
+ - **Dimension-line spacing now follows ISO 129-1 / ASME Y14.5 convention** (#347): a wider
92
+ first-line gap (8 10 mm) and tighter, uniform parallel stacking (between-line clear gap
93
+ 42.5 mm), with the inter-view corridor widened in step. Re-drifts every drawing.
13
94
 
14
95
  ## v0.2.4 — 2026-07-03
15
96
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.2.5
3
+ Version: 0.2.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.2.5"
7
+ version = "0.2.7"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -30,6 +30,7 @@ _LAZY = {
30
30
  "make_drawing": "draftwright.builder",
31
31
  "Drawing": "draftwright.drawing",
32
32
  "FeatureInfo": "draftwright.drawing",
33
+ "Sheet": "draftwright.sheet_dsl",
33
34
  "fix_svg_page_size": "draftwright.export",
34
35
  "lint_feature_coverage": "draftwright.linting",
35
36
  "PmiRecord": "draftwright.pmi",
@@ -78,6 +79,7 @@ if TYPE_CHECKING: # static analysers / IDEs — no runtime import, no kernel co
78
79
  from draftwright.linting import lint_feature_coverage
79
80
  from draftwright.pmi import PmiRecord, extract_pmi
80
81
  from draftwright.sheet import choose_scale
82
+ from draftwright.sheet_dsl import Sheet
81
83
 
82
84
 
83
85
  def __dir__():
@@ -91,6 +93,7 @@ __all__ = [
91
93
  "Drawing",
92
94
  "FeatureInfo",
93
95
  "PmiRecord",
96
+ "Sheet",
94
97
  "analyse_face_levels",
95
98
  "build_drawing",
96
99
  "choose_scale",
@@ -30,6 +30,7 @@ from build123d_drafting.helpers import (
30
30
  format_drawing_scale,
31
31
  )
32
32
 
33
+ from draftwright.fits import FitClass
33
34
  from draftwright.fonts import PLEX_MONO, PLEX_SANS_CONDENSED
34
35
  from draftwright.layout import _greedy_strip_1d, _solve_strip_1d
35
36
 
@@ -86,6 +87,30 @@ def _fmt(v: float) -> str:
86
87
  return str(r) if abs(v - r) < 1e-6 else f"{v:.1f}"
87
88
 
88
89
 
90
+ def _tol_suffix(tolerance, draft) -> str:
91
+ """The ``±`` / limit tolerance suffix to append to a **callout** label (a ø leader
92
+ or a hole callout), matching byte-for-byte what ``Dimension(tolerance=…)`` renders
93
+ on a linear dim (helpers ``_format_label``): a symmetric ``float`` → ``" ±t"``; an
94
+ ``(lower, upper)`` pair → ``" +upper -lower"`` — all rounded to the draft precision.
95
+
96
+ draftwright owns this suffix ONLY because the pinned helpers' ``Leader`` /
97
+ ``HoleCallout`` take no ``tolerance=`` yet, so we bake it into the label string
98
+ ourselves. Delete this once helpers grows a first-class tolerance parameter for
99
+ those two (extraction tracked as #449) — ``Dimension`` formats its own (#28 / P2a).
100
+
101
+ A resolved fit (:class:`~draftwright.fits.FitClass`, P2a.2) renders its own class-code
102
+ or deviation suffix — it rides the same ``tolerance`` field as an aspect marker."""
103
+ if tolerance is None:
104
+ return ""
105
+ if isinstance(tolerance, FitClass):
106
+ return tolerance.suffix()
107
+ prec = draft.decimal_precision
108
+ if isinstance(tolerance, (int, float)):
109
+ return f" ±{round(tolerance, prec):.{prec}f}"
110
+ lo, hi = tolerance
111
+ return f" +{round(hi, prec):.{prec}f} -{round(lo, prec):.{prec}f}"
112
+
113
+
89
114
  def _tag_sequence(n):
90
115
  """``A, B, …, Z, AA, AB, …`` — deterministic hole-table tags for *n* rows."""
91
116
  tags = []
@@ -179,6 +179,26 @@ _STEP_MIN_AREA_FRAC = 0.01
179
179
  # ---------------------------------------------------------------------------
180
180
 
181
181
 
182
+ def _solids_body(part, src: str = "part"):
183
+ """The part reduced to just its solids — the geometry the drawing is *of*.
184
+
185
+ AP242 STEP files (and hand-built Compounds) can carry non-solid geometry beside
186
+ the solid — PMI presentation wires, leader curves, construction edges/sketches —
187
+ which, left in, draw as phantom rectangles in every view and inflate the bounding
188
+ box, corrupting the scale choice and the envelope dimensions. Shared by
189
+ :func:`_analyse` and :meth:`draftwright.Sheet.model` (#453) so the model a caller
190
+ *inspects* is wrapped from the exact same body the engine *draws*."""
191
+ solids = part.solids()
192
+ if not solids:
193
+ return part
194
+ body = solids[0] if len(solids) == 1 else Compound(children=list(solids))
195
+ if body.bounding_box().size != part.bounding_box().size or len(part.edges()) != len(
196
+ body.edges()
197
+ ):
198
+ _log.info("Dropping non-solid geometry from %s (PMI presentation data)", src)
199
+ return body
200
+
201
+
182
202
  def _analyse(
183
203
  step_file, title, number, tolerance, drawn_by, out, scale=None, page=None, pmi="off"
184
204
  ) -> Analysis:
@@ -192,22 +212,7 @@ def _analyse(
192
212
  else:
193
213
  part = _import_step(step_file)
194
214
  src = str(step_file)
195
- # AP242 STEP files carry PMI presentation geometry (annotation-plane
196
- # border wires, leader curves) beside the solid; left in, it draws as
197
- # phantom rectangles in every view and inflates the bounding box —
198
- # corrupting the scale choice and the envelope dimensions. The drawing
199
- # is of the solids.
200
- solids = part.solids()
201
- if solids:
202
- body = solids[0] if len(solids) == 1 else Compound(children=list(solids))
203
- if body.bounding_box().size != part.bounding_box().size or len(part.edges()) != len(
204
- body.edges()
205
- ):
206
- _log.info(
207
- "Dropping non-solid geometry from %s (PMI presentation data)",
208
- src,
209
- )
210
- part = body
215
+ part = _solids_body(part, src)
211
216
 
212
217
  # Semantic PMI extraction (AP242 only; separate read-only pass).
213
218
  pmi_records: list = []
@@ -41,6 +41,7 @@ from draftwright._core import (
41
41
  _legible_steps,
42
42
  _log,
43
43
  _solve_strip_ys,
44
+ _tol_suffix,
44
45
  )
45
46
  from draftwright.annotations._common import (
46
47
  CROSSABLE_TYPES,
@@ -82,6 +83,14 @@ def hole_callout_spec(group: DimensionGroup) -> dict | None:
82
83
  bore = _first(group, "diameter", "bore")
83
84
  if bore is None:
84
85
  return None
86
+ bore_tol = next(
87
+ (
88
+ pd.param.tolerance
89
+ for pd in group.dims
90
+ if pd.param.kind == "diameter" and pd.param.role == "bore"
91
+ ),
92
+ None,
93
+ )
85
94
  depth = _first(group, "depth", "bore")
86
95
  count = feat.count
87
96
  suffix = None
@@ -99,6 +108,7 @@ def hole_callout_spec(group: DimensionGroup) -> dict | None:
99
108
  "cbore_dia": _first(group, "diameter", "counterbore", "spotface"),
100
109
  "cbore_depth": _first(group, "depth", "counterbore", "spotface"),
101
110
  "suffix": suffix,
111
+ "tolerance": bore_tol, # P2a: ± on the bore ⌀, baked into the callout string below
102
112
  }
103
113
 
104
114
 
@@ -120,8 +130,14 @@ def callout_from_spec(spec, draft, count) -> HoleCallout | None:
120
130
  def f(v): # see the #261 note above — every value crosses as a formatted string
121
131
  return _fmt(v) if v is not None else None
122
132
 
133
+ dia = f(spec["diameter"])
134
+ if dia is not None:
135
+ # P2a: bake the ± tolerance into the bore string (helpers' HoleCallout accepts a
136
+ # diameter carrying tolerance/fit text, "8 ±0.05"); no tolerance → empty suffix.
137
+ dia += _tol_suffix(spec.get("tolerance"), draft)
138
+
123
139
  return HoleCallout(
124
- f(spec["diameter"]),
140
+ dia,
125
141
  count=count,
126
142
  through=spec["through"],
127
143
  depth=f(spec["depth"]),
@@ -578,6 +594,28 @@ def _mentioned_diameters(dwg) -> set[float]:
578
594
  return diams
579
595
 
580
596
 
597
+ def _place_what_fits(specs, axis: int, min_gap: float, lo: float, hi: float):
598
+ """Fit as many ø specs as the strip ``[lo, hi]`` holds at ``min_gap`` spacing,
599
+ dropping the SMALLEST-diameter spec first when the full set overflows — so the
600
+ significant ODs survive and only the finest bands fall to ``feature_not_dimensioned``,
601
+ never the whole row/column (#298). ``specs`` = ``[(tip, dia, label, feat), ...]``;
602
+ ``axis`` selects the strip coordinate of ``tip`` (0 = page-x for the row-below, 1 =
603
+ page-y for the column-left). Returns ``(survivors_in_strip_order, positions)`` —
604
+ ``([], [])`` if not even one fits. A part whose full row already fits keeps every
605
+ spec in strip order, so existing output is unchanged."""
606
+ survivors = sorted(specs, key=lambda s: s[0][axis])
607
+ while survivors:
608
+ naturals = [s[0][axis] for s in survivors]
609
+ pos = _solve_strip_ys(naturals, min_gap, lo, hi) or _greedy_strip_ys(
610
+ naturals, min_gap, lo, hi
611
+ )
612
+ if pos is not None:
613
+ return survivors, pos
614
+ drop = min(range(len(survivors)), key=lambda i: survivors[i][1])
615
+ survivors.pop(drop)
616
+ return [], []
617
+
618
+
581
619
  def _diameter_row_below(dwg, items, start: int = 0) -> int:
582
620
  """ø-callout row BELOW the front view for X-turned step/boss diameters (#77).
583
621
  *items* is ``[(anchor, diameter), ...]``. The row is dropped clear of anything
@@ -599,28 +637,23 @@ def _diameter_row_below(dwg, items, start: int = 0) -> int:
599
637
  label_y = obstacle_bottom - (draft.font_size + 4 * draft.pad_around_text)
600
638
  if label_y < _MARGIN + draft.font_size:
601
639
  return 0
602
- specs = [] # (tip_page, label, feature), tip on the step's bottom silhouette
603
- for anchor, dia, feat in items:
640
+ specs = [] # (tip_page, dia, label, feature), tip on the step's bottom silhouette
641
+ for anchor, dia, feat, dtol in items:
604
642
  ax, ay, az = anchor
605
643
  tip = dwg.at("front", ax, ay, az - dia / 2)
606
- specs.append((tip, f"ø{_fmt(dia)}", feat))
607
- specs.sort(key=lambda s: s[0][0])
608
- half_w = max(len(label) for _, label, _ in specs) * draft.font_size * 0.62 / 2
644
+ specs.append((tip, dia, f"ø{_fmt(dia)}{_tol_suffix(dtol, draft)}", feat))
645
+ half_w = max(len(label) for _, _, label, _ in specs) * draft.font_size * 0.62 / 2
609
646
  min_gap = 2 * half_w + 2 * draft.pad_around_text
610
- naturals = [tip[0] for tip, _, _ in specs]
611
- xs = _solve_strip_ys(naturals, min_gap, fx0 + half_w, fx1 - half_w) or _greedy_strip_ys(
612
- naturals, min_gap, fx0 + half_w, fx1 - half_w
613
- )
614
- if xs is None:
615
- return 0
616
- for i, ((tip, label, feat), lx) in enumerate(zip(specs, xs, strict=True)):
647
+ # Place what fits; drop the smallest ø first, never the whole row (#298).
648
+ survivors, xs = _place_what_fits(specs, 0, min_gap, fx0 + half_w, fx1 - half_w)
649
+ for i, ((tip, dia, label, feat), lx) in enumerate(zip(survivors, xs, strict=True)):
617
650
  dwg.add(
618
651
  Leader(tip=(tip[0], tip[1], 0), elbow=(lx, label_y, 0), label=label, draft=draft),
619
652
  f"m_dia_x{start + i}",
620
653
  view="front",
621
654
  feature=feat,
622
655
  )
623
- return len(specs)
656
+ return len(survivors)
624
657
 
625
658
 
626
659
  def _diameter_column_left(dwg, items, start: int = 0) -> int:
@@ -632,31 +665,30 @@ def _diameter_column_left(dwg, items, start: int = 0) -> int:
632
665
  return 0
633
666
  draft = dwg.draft
634
667
  fx0, fy0, _, fy1 = dwg.view_bounds("front")
635
- label_w = max(len(f"ø{_fmt(dia)}") for _, dia, _ in items) * draft.font_size * 0.62
668
+ label_w = (
669
+ max(len(f"ø{_fmt(dia)}{_tol_suffix(dtol, draft)}") for _, dia, _, dtol in items)
670
+ * draft.font_size
671
+ * 0.62
672
+ )
636
673
  elbow_x = fx0 - (draft.font_size + 2 * draft.pad_around_text)
637
674
  if elbow_x - label_w < _MARGIN:
638
675
  return 0
639
- specs = [] # (tip_page, label, feature), tip on the step's left silhouette
640
- for anchor, dia, feat in items:
676
+ specs = [] # (tip_page, dia, label, feature), tip on the step's left silhouette
677
+ for anchor, dia, feat, dtol in items:
641
678
  ax, ay, az = anchor
642
679
  tip = dwg.at("front", ax - dia / 2, ay, az)
643
- specs.append((tip, f"ø{_fmt(dia)}", feat))
644
- specs.sort(key=lambda s: s[0][1])
680
+ specs.append((tip, dia, f"ø{_fmt(dia)}{_tol_suffix(dtol, draft)}", feat))
645
681
  half_h = draft.font_size / 2 + draft.pad_around_text
646
682
  min_gap = 2 * half_h
647
- naturals = [tip[1] for tip, _, _ in specs]
648
- ys = _solve_strip_ys(naturals, min_gap, fy0 + half_h, fy1 - half_h) or _greedy_strip_ys(
649
- naturals, min_gap, fy0 + half_h, fy1 - half_h
650
- )
651
- if ys is None:
652
- return 0
683
+ # Place what fits; drop the smallest ø first, never the whole column (#298).
684
+ survivors, ys = _place_what_fits(specs, 1, min_gap, fy0 + half_h, fy1 - half_h)
653
685
  # Full-footprint occupancy (leader shafts, witness/extension lines, hatch) — NOT
654
686
  # the label-box-only `_occupied_boxes`, which is blind to a bore callout's leader
655
687
  # SHAFT, so a ø label could silently overprint it (the #133/#225/#305 invisible-
656
688
  # occupant class, #358). Centre lines stay crossable (a diameter dim may cross one).
657
689
  occupied = strip_obstacles(dwg, view="front", crossable=CROSSABLE_TYPES)
658
690
  placed = 0
659
- for i, ((tip, label, feat), ly) in enumerate(zip(specs, ys, strict=True)):
691
+ for i, ((tip, dia, label, feat), ly) in enumerate(zip(survivors, ys, strict=True)):
660
692
  ldr = Leader(tip=(tip[0], tip[1], 0), elbow=(elbow_x, ly, 0), label=label, draft=draft)
661
693
  if _box_hits(_anno_box(ldr), occupied):
662
694
  continue # would overprint a bore leader / existing callout — drop just this one
@@ -682,24 +714,35 @@ def render_diameters(dwg, groups, tol: float = 0.15, *, only=None) -> int:
682
714
  # diameter (insertion-ordered), so provenance (#412) can tag the callout with its
683
715
  # single owner — or leave it unowned when two distinct features share the diameter
684
716
  # (the #398c/#406 shared-value rule, so drop can't over-strip a sibling).
685
- row_buckets: dict = {} # round(dia,2) -> [anchor, dia, {features}] (X-turned)
717
+ row_buckets: dict = {} # round(dia,2) -> [anchor, dia, {features}, tolerance] (X-turned)
686
718
  col_buckets: dict = {} # Z-turned
687
719
  for g in groups:
688
720
  if g.feature_kind not in ("step", "boss"):
689
721
  continue
690
722
  if only is not None and g.feature not in only: # #426 finalize: recorded subset
691
723
  continue
692
- dia = next((pd.param.value for pd in g.dims if pd.param.kind == "diameter"), None)
693
- if dia is None or any(abs(dia - m) <= tol for m in mentioned):
724
+ dpd = next((pd for pd in g.dims if pd.param.kind == "diameter"), None)
725
+ if dpd is None:
726
+ continue
727
+ dia = dpd.param.value
728
+ if any(abs(dia - m) <= tol for m in mentioned):
694
729
  continue
695
730
  bucket = {"x": row_buckets, "z": col_buckets}.get(g.feature.frame.axis)
696
731
  if bucket is None:
697
732
  continue
698
733
  dkey = round(dia, 2)
699
- bucket.setdefault(dkey, [g.anchor, dia, set()])[2].add(g.feature)
734
+ dtol = dpd.param.tolerance
735
+ # entry = [anchor, dia, {features}, ± tolerance]. A callout is per (axis, ⌀); the
736
+ # first authored tolerance on a shared ⌀ wins (P2a — a single callout, one label).
737
+ entry = bucket.setdefault(dkey, [g.anchor, dia, set(), dtol])
738
+ entry[2].add(g.feature)
739
+ if entry[3] is None:
740
+ entry[3] = dtol
700
741
 
701
742
  def _items(buckets):
702
- return [(a, d, next(iter(fs)) if len(fs) == 1 else None) for a, d, fs in buckets.values()]
743
+ return [
744
+ (a, d, next(iter(fs)) if len(fs) == 1 else None, t) for a, d, fs, t in buckets.values()
745
+ ]
703
746
 
704
747
  # The placers name leaders m_dia_{x,z}{start+i} CONTIGUOUSLY from one start. The auto-pass
705
748
  # (only None) uses start=0 — byte-identical. The finalize path (only set) may run after
@@ -867,12 +910,14 @@ def _draw_step_chain(
867
910
  draft = dwg.draft
868
911
  gap = draft.font_size + 4 * draft.pad_around_text
869
912
  horizontal = abs(segs[0][1][0] - segs[0][0][0]) >= abs(segs[0][1][1] - segs[0][0][1])
870
- vals = [v for *_, v in segs]
913
+ vals = [s[2] for s in segs] # value is index 2 (segs are 4-tuples: pa, pb, value, tol)
871
914
  mean_v = sum(vals) / len(vals)
872
915
  if allow_collapse and len(segs) >= 3 and (max(vals) - min(vals)) <= 0.10 * mean_v:
916
+ # A uniform run collapses to one "N× v" dim; a per-step ± would be a false claim on
917
+ # N equal steps, so the collapse carries NO tolerance (#28 / P2a).
873
918
  label = f"{len(segs)}× {_fmt(mean_v)}"
874
- xs = [p[0] for pa, pb, _ in segs for p in (pa, pb)]
875
- ys = [p[1] for pa, pb, _ in segs for p in (pa, pb)]
919
+ xs = [p[0] for pa, pb, *_ in segs for p in (pa, pb)]
920
+ ys = [p[1] for pa, pb, *_ in segs for p in (pa, pb)]
876
921
  if horizontal:
877
922
  dim = _dim((min(xs), y1, 0), (max(xs), y1, 0), "above", gap, draft, label=label)
878
923
  else:
@@ -884,7 +929,8 @@ def _draw_step_chain(
884
929
  tiers = [0] * len(segs)
885
930
  if horizontal:
886
931
  cw = [
887
- ((pa[0] + pb[0]) / 2, len(_fmt(v)) * draft.font_size * 0.62) for pa, pb, v in segs
932
+ ((pa[0] + pb[0]) / 2, len(_fmt(v)) * draft.font_size * 0.62)
933
+ for pa, pb, v, *_ in segs
888
934
  ]
889
935
 
890
936
  def _clear(items): # (center, width) pairs in x order — labels don't overlap
@@ -904,14 +950,14 @@ def _draw_step_chain(
904
950
  )
905
951
  return 0
906
952
  else:
907
- shoulder_ys = sorted({c for pa, pb, _ in segs for c in (pa[1], pb[1])})
953
+ shoulder_ys = sorted({c for pa, pb, *_ in segs for c in (pa[1], pb[1])})
908
954
  if any(b - a < tier_step for a, b in zip(shoulder_ys, shoulder_ys[1:])):
909
955
  _log.info("step-length chain skipped: shoulders too close to dimension")
910
956
  _record_step_chain_drop(dwg, "turned shoulders too closely spaced to dimension")
911
957
  return 0
912
958
 
913
959
  candidates = []
914
- for i, (pa, pb, value) in enumerate(segs):
960
+ for i, (pa, pb, value, seg_tol) in enumerate(segs):
915
961
  if horizontal:
916
962
  p1, p2, side = (pa[0], y1, 0), (pb[0], y1, 0), "above"
917
963
  dist = gap + tiers[i] * tier_step
@@ -919,7 +965,10 @@ def _draw_step_chain(
919
965
  p1, p2, side = (x1, pa[1], 0), (x1, pb[1], 0), "right"
920
966
  dist = gap
921
967
  candidates.append(
922
- (f"{name_prefix}{start + i}", _dim(p1, p2, side, dist, draft, label=_fmt(value)))
968
+ (
969
+ f"{name_prefix}{start + i}",
970
+ _dim(p1, p2, side, dist, draft, label=_fmt(value), tolerance=seg_tol),
971
+ )
923
972
  )
924
973
 
925
974
  # Room guard: if any dim would fall off the drawable page, place NONE.
@@ -980,14 +1029,14 @@ def render_step_lengths(dwg, groups, *, only=None) -> int:
980
1029
  )
981
1030
  if length is None or length.span is None:
982
1031
  continue
983
- rows.append((length.span[0], length.span[1], length.value))
1032
+ rows.append((length.span[0], length.span[1], length.value, length.tolerance))
984
1033
  if not rows:
985
1034
  return 0
986
1035
  draft = dwg.draft
987
1036
  # only=None (auto-pass) → start=0, historical m_steplen naming, byte-identical. The
988
1037
  # finalize path (only set) starts past existing m_steplen names (#426 naming seam).
989
1038
  start = _next_steplen_start(dwg) if only is not None else 0
990
- fsegs = [(dwg.at("front", *a), dwg.at("front", *b), v) for a, b, v in rows]
1039
+ fsegs = [(dwg.at("front", *a), dwg.at("front", *b), v, t) for a, b, v, t in rows]
991
1040
  horizontal = abs(fsegs[0][1][0] - fsegs[0][0][0]) >= abs(fsegs[0][1][1] - fsegs[0][0][1])
992
1041
 
993
1042
  # X-turned crowded-head detour (#307): split off each contiguous *run of ≥2*
@@ -997,7 +1046,7 @@ def render_step_lengths(dwg, groups, *, only=None) -> int:
997
1046
  # (#307 review). The legible steps + blocks stay as the main chain.
998
1047
  if horizontal:
999
1048
  floor_pg = 2 * draft.arrow_length
1000
- sub = [i for i, (pa, pb, _) in enumerate(fsegs) if abs(pb[0] - pa[0]) < floor_pg]
1049
+ sub = [i for i, (pa, pb, *_) in enumerate(fsegs) if abs(pb[0] - pa[0]) < floor_pg]
1001
1050
  runs: list[list[int]] = []
1002
1051
  for j in sub:
1003
1052
  (runs[-1].append(j) if runs and j == runs[-1][-1] + 1 else runs.append([j]))
@@ -1006,17 +1055,20 @@ def render_step_lengths(dwg, groups, *, only=None) -> int:
1006
1055
  blocks = []
1007
1056
  for run in heads:
1008
1057
  ra = [rows[i] for i in run]
1009
- hlo = min(min(a[0], b[0]) for a, b, _ in ra)
1010
- hhi = max(max(a[0], b[0]) for a, b, _ in ra)
1011
- minlen = min(v for *_, v in ra)
1058
+ hlo = min(min(a[0], b[0]) for a, b, *_ in ra)
1059
+ hhi = max(max(a[0], b[0]) for a, b, *_ in ra)
1060
+ minlen = min(r[2] for r in ra) # value is index 2 (rows are 4-tuples: a,b,v,tol)
1012
1061
  # World→page scale for the detail (no sheet factor — detail_scale is an
1013
1062
  # absolute world→page scale). (#307 review)
1014
1063
  scale_needed = _MIN_STEP_SEP_MM / minlen if minlen > 0 else float("inf")
1015
- blocks.append((dwg.at("front", hlo, 0, 0), dwg.at("front", hhi, 0, 0), hhi - hlo))
1064
+ # A head *block* is a synthetic span, not one toleranced step — carry no ± (None).
1065
+ blocks.append(
1066
+ (dwg.at("front", hlo, 0, 0), dwg.at("front", hhi, 0, 0), hhi - hlo, None)
1067
+ )
1016
1068
 
1017
1069
  def _redraw(dwg, view, detail_scale, _hw=ra):
1018
1070
  # View-scoped name prefix so two detail views never collide (#307 review).
1019
- hsegs = [(dwg.at(view, *a), dwg.at(view, *b), v) for a, b, v in _hw]
1071
+ hsegs = [(dwg.at(view, *a), dwg.at(view, *b), v, t) for a, b, v, t in _hw]
1020
1072
  return _draw_step_chain(dwg, view, hsegs, f"dim_{view}_steplen", detail_scale)
1021
1073
 
1022
1074
  dwg._detail_requests.append(
@@ -388,12 +388,13 @@ def add_feature_diameter(dwg, feature) -> str:
388
388
  "pass one from dwg.model().features"
389
389
  )
390
390
  group = next((g for g in plan_dimensions(model) if g.feature is feature), None)
391
- dia = (
392
- next((pd.param.value for pd in group.dims if pd.param.kind == "diameter"), None)
391
+ dpd = (
392
+ next((pd for pd in group.dims if pd.param.kind == "diameter"), None)
393
393
  if group is not None
394
394
  else None
395
395
  )
396
- if group is None or dia is None:
396
+ dia = dpd.param.value if dpd is not None else None
397
+ if group is None or dpd is None or dia is None:
397
398
  raise ValueError(
398
399
  f"callout(): {type(feature).__name__} exposes no step/boss diameter callout"
399
400
  )
@@ -403,7 +404,9 @@ def add_feature_diameter(dwg, feature) -> str:
403
404
  f"callout(): a {axis!r}-turned step/boss diameter is not placeable "
404
405
  "(only X- and Z-turned parts)"
405
406
  )
406
- items = [(group.anchor, dia, feature)]
407
+ # 4-tuple (anchor, dia, feature, tolerance): a manual callout honours a declared ±
408
+ # tolerance too, like the auto-pass (P2a, #28).
409
+ items = [(group.anchor, dia, feature, dpd.param.tolerance)]
407
410
  # The row/column placers name leaders m_dia_{x,z}{start+i} — pass the first FREE
408
411
  # index so a second callout() (or a call on an already-annotated turned part) never
409
412
  # collides on m_dia_x0/z0 and clobbers an existing leader (#419 review F1).
@@ -698,7 +701,7 @@ def _add_furniture(dwg, a: Analysis, view, j, feat: PatternFeature | None, to_pa
698
701
  here) so it survives finalize's ``place_furniture=False`` (#426 Ph4c)."""
699
702
  if feat is None:
700
703
  return
701
- members = feat.members
704
+ members = feat.members or (feat.frame.origin,) # guard a declared pattern's empty members
702
705
  # Remember the bore-callout name AND the holes it documents (by position), so a
703
706
  # later hole-table escalation leaves the grouped pattern callout standing and
704
707
  # tabulates only the holes no *placed* pattern callout covers (#92).