draftwright 0.1.10__tar.gz → 0.1.12__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 (27) hide show
  1. {draftwright-0.1.10 → draftwright-0.1.12}/CHANGELOG.md +86 -0
  2. {draftwright-0.1.10 → draftwright-0.1.12}/PKG-INFO +7 -22
  3. {draftwright-0.1.10 → draftwright-0.1.12}/README.md +4 -19
  4. {draftwright-0.1.10 → draftwright-0.1.12}/pyproject.toml +3 -3
  5. draftwright-0.1.12/src/draftwright/_core.py +461 -0
  6. draftwright-0.1.12/src/draftwright/annotate.py +2193 -0
  7. {draftwright-0.1.10 → draftwright-0.1.12}/src/draftwright/make_drawing.py +1227 -2448
  8. {draftwright-0.1.10 → draftwright-0.1.12}/tests/test_e2e_standards.py +40 -0
  9. {draftwright-0.1.10 → draftwright-0.1.12}/tests/test_layout.py +2 -1
  10. {draftwright-0.1.10 → draftwright-0.1.12}/tests/test_make_drawing.py +686 -40
  11. {draftwright-0.1.10 → draftwright-0.1.12}/.gitignore +0 -0
  12. {draftwright-0.1.10 → draftwright-0.1.12}/LICENSE +0 -0
  13. {draftwright-0.1.10 → draftwright-0.1.12}/skills/SKILL.md +0 -0
  14. {draftwright-0.1.10 → draftwright-0.1.12}/src/draftwright/__init__.py +0 -0
  15. {draftwright-0.1.10 → draftwright-0.1.12}/src/draftwright/layout.py +0 -0
  16. {draftwright-0.1.10 → draftwright-0.1.12}/src/draftwright/pmi.py +0 -0
  17. {draftwright-0.1.10 → draftwright-0.1.12}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  18. {draftwright-0.1.10 → draftwright-0.1.12}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  19. {draftwright-0.1.10 → draftwright-0.1.12}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  20. {draftwright-0.1.10 → draftwright-0.1.12}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  21. {draftwright-0.1.10 → draftwright-0.1.12}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  22. {draftwright-0.1.10 → draftwright-0.1.12}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  23. {draftwright-0.1.10 → draftwright-0.1.12}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  24. {draftwright-0.1.10 → draftwright-0.1.12}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  25. {draftwright-0.1.10 → draftwright-0.1.12}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  26. {draftwright-0.1.10 → draftwright-0.1.12}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  27. {draftwright-0.1.10 → draftwright-0.1.12}/tests/test_pmi.py +0 -0
@@ -1,5 +1,77 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## v0.1.12 — 2026-06-21
6
+
7
+ ### Changed
8
+
9
+ - **Requires `build123d-drafting-helpers>=0.12.0`** (#92, #122). draftwright now
10
+ consumes the new sub-clustered hole-pattern recognition, the
11
+ `feature_diameters()` coverage inventory, the persistent `view_edge_cache`,
12
+ and the `ViewCoordinates.from_viewport()` ISO projection basis.
13
+ - **Grouped hole-pattern callouts** (#92, #111, #114). A recognised perimeter,
14
+ grid, or bolt circle collapses to a single `n× ⌀ …` callout plus its pattern
15
+ dimensions instead of a balloon on every hole. A spec group now sub-clusters
16
+ into multiple patterns (a perimeter → its edge `LinearArray` rows, a filled
17
+ lattice → one `RectGrid` with a `(rows×cols)` callout and both pitch
18
+ dimensions); only genuinely unpatterned holes fall back to the per-hole table.
19
+ On NIST CTC-02 the table shrinks from 61 rows to the unpatterned remainder.
20
+ - **Layout overhaul — compose-then-pack** (#121, #112, ADR 0004). Each view owns
21
+ the annotations created against it, and the resulting view blocks are packed
22
+ disjoint with automatic page/scale escalation. This eliminates cross-view
23
+ overlap — most visibly, plan-view balloons landing on front-view dimensions.
24
+ - **Drawing attribution** (#120). The title block records the author, the SVG
25
+ and PDF carry a clickable draftwright hyperlink, and a "generated by
26
+ draftwright" note is written to the SVG/DXF/PDF file metadata.
27
+ - **Gap between wrapped hole-table column blocks** (#123) so a chart that wraps
28
+ into several blocks reads as distinct columns.
29
+
30
+ ### Fixed
31
+
32
+ - **Plan-view top balloon ring no longer floats over a phantom corridor** (#125).
33
+ The hole-table escalation deletes the X-location dimensions but left their
34
+ stale depth in the strip cursor, so the top balloons were parked far above the
35
+ view. The ring is now sized to the real dimension stack, so the top-side
36
+ leaders are short like the other three sides.
37
+ - **No more phantom `feature_not_dimensioned` warnings** on slot-ends and shallow
38
+ recesses, via the helpers 0.12.0 `feature_diameters()` coverage inventory
39
+ (#92).
40
+
41
+ ### Internal
42
+
43
+ - `AnnoBox` box-model footprint foundation and the four-side balloon ring placed
44
+ in a reserved view halo (#111, #112); the title block is pinned as a
45
+ first-class layout block (#112).
46
+
47
+ ## v0.1.11 — 2026-06-19
48
+
49
+ ### Changed
50
+
51
+ - **Feature-coverage lint is assembly-aware.** A general-arrangement drawing of
52
+ a multi-solid part deliberately omits each part's bores (they belong on detail
53
+ sheets), so `feature_not_dimensioned` / `feature_count_mismatch` are now
54
+ emitted at `info` rather than `warning` when the part is multi-solid — out of
55
+ the warning count and quality score, but still queryable. Auto-detected;
56
+ override with `build_drawing(..., assembly=True/False)` or
57
+ `lint_feature_coverage(..., assembly=...)` (#69).
58
+
59
+ ### Fixed
60
+
61
+ - **`place_dim` now labels the real-world length, not the page distance**, at
62
+ non-1:1 scale. Previously a dimension placed at a scale other than 1:1 showed
63
+ the on-page millimetre span instead of the true model dimension (#104).
64
+
65
+ ### Internal
66
+
67
+ - **`make_drawing.py` decomposed (#98).** The per-view projection math and the
68
+ analysis namespace were deduplicated and typed (the namespace is now a frozen
69
+ `Analysis` dataclass), and the annotation passes were extracted into a new
70
+ `draftwright.annotate` module on top of a shared `draftwright._core`. The
71
+ module graph is a DAG (`layout → _core → {make_drawing, annotate}`) and
72
+ `make_drawing.py` shrank from ~5,270 to ~2,930 lines. No public API or
73
+ behaviour change.
74
+
3
75
  ## v0.1.10 — 2026-06-18
4
76
 
5
77
  ### Added
@@ -31,6 +103,20 @@
31
103
  - Hole-callout and turned-diameter placement is deconflicted through the shared
32
104
  `LayoutSolver` instead of ad-hoc per-pass logic (no output change).
33
105
 
106
+ ### Fixed
107
+
108
+ - **Exact circles recovered for revolution silhouettes.** `project_to_viewport`'s
109
+ HLR returns the on-axis silhouette of a turned feature (or a concentric
110
+ gear-tooth-tip arc) as an approximating spline, not a true circle — splines in
111
+ the DXF where CAM expects `CIRCLE`/`ARC`, and fitted rather than exact radii.
112
+ `add_view` now refits any silhouette whose samples are equidistant from a
113
+ recognised revolution axis back to an exact circle/arc (#67).
114
+ - **Blind-hole depth no longer measured across solid boundaries.** On a
115
+ multi-solid assembly, coaxial bores in different bodies were merged into one
116
+ hole, reporting a depth spanning the inter-body gap (the ⌀9.8 ↓111.4 symptom).
117
+ Fixed upstream in `build123d-drafting-helpers` 0.10.1; the dependency pin is
118
+ bumped to `>=0.10.1` to pick it up (#68).
119
+
34
120
  ### Docs
35
121
 
36
122
  - The skill and generated-script header now lead with the domain API
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.1.10
3
+ Version: 0.1.12
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
@@ -679,8 +679,8 @@ Classifier: Programming Language :: Python :: 3.10
679
679
  Classifier: Programming Language :: Python :: 3.11
680
680
  Classifier: Programming Language :: Python :: 3.12
681
681
  Classifier: Topic :: Scientific/Engineering
682
- Requires-Python: >=3.10
683
- Requires-Dist: build123d-drafting-helpers>=0.10.1
682
+ Requires-Python: <3.15,>=3.10
683
+ Requires-Dist: build123d-drafting-helpers>=0.12.0
684
684
  Requires-Dist: build123d>=0.9.0
685
685
  Requires-Dist: kiwisolver<2,>=1.4
686
686
  Requires-Dist: numpy>=1.24
@@ -809,30 +809,15 @@ generation, the lint→repair loop, and the constraint-based layout engine).
809
809
 
810
810
  ## Architecture
811
811
 
812
- draftwright is a single module (`make_drawing.py`) on top of two Apache 2.0 libraries:
812
+ draftwright is a single module (`make_drawing.py`) on top of two libraries:
813
813
 
814
814
  ```
815
- draftwright (AGPL-3.0)
816
- └── build123d-drafting-helpers (Apache 2.0) — Dimension, Leader, HoleCallout, …
817
- └── build123d (Apache 2.0) — CAD kernel
815
+ draftwright
816
+ └── build123d-drafting-helpers — Dimension, Leader, HoleCallout, …
817
+ └── build123d — CAD kernel
818
818
  ```
819
819
 
820
820
  The engine handles view layout (strip/zone model), scale selection, feature recognition
821
821
  (holes, bosses, bolt circles), annotation placement, and section view generation.
822
822
  Annotation primitives (`Dimension`, `Leader`, `lint_drawing`, etc.) live in
823
823
  `build123d-drafting-helpers` and can be used independently.
824
-
825
- ## License
826
-
827
- draftwright is licensed under the **GNU Affero General Public License v3 (AGPL-3.0)**.
828
-
829
- This means:
830
- - You can use, modify, and distribute it freely in open-source projects
831
- - If you run draftwright as part of a **network service** (e.g. an API that generates
832
- drawings), you must make your application's source code available to users
833
-
834
- For commercial use without these obligations, contact pzfreo@gmail.com.
835
-
836
- The annotation primitives in
837
- [`build123d-drafting-helpers`](https://github.com/pzfreo/build123d-drafting-helpers)
838
- are Apache 2.0 and may be embedded in commercial applications without restriction.
@@ -119,30 +119,15 @@ generation, the lint→repair loop, and the constraint-based layout engine).
119
119
 
120
120
  ## Architecture
121
121
 
122
- draftwright is a single module (`make_drawing.py`) on top of two Apache 2.0 libraries:
122
+ draftwright is a single module (`make_drawing.py`) on top of two libraries:
123
123
 
124
124
  ```
125
- draftwright (AGPL-3.0)
126
- └── build123d-drafting-helpers (Apache 2.0) — Dimension, Leader, HoleCallout, …
127
- └── build123d (Apache 2.0) — CAD kernel
125
+ draftwright
126
+ └── build123d-drafting-helpers — Dimension, Leader, HoleCallout, …
127
+ └── build123d — CAD kernel
128
128
  ```
129
129
 
130
130
  The engine handles view layout (strip/zone model), scale selection, feature recognition
131
131
  (holes, bosses, bolt circles), annotation placement, and section view generation.
132
132
  Annotation primitives (`Dimension`, `Leader`, `lint_drawing`, etc.) live in
133
133
  `build123d-drafting-helpers` and can be used independently.
134
-
135
- ## License
136
-
137
- draftwright is licensed under the **GNU Affero General Public License v3 (AGPL-3.0)**.
138
-
139
- This means:
140
- - You can use, modify, and distribute it freely in open-source projects
141
- - If you run draftwright as part of a **network service** (e.g. an API that generates
142
- drawings), you must make your application's source code available to users
143
-
144
- For commercial use without these obligations, contact pzfreo@gmail.com.
145
-
146
- The annotation primitives in
147
- [`build123d-drafting-helpers`](https://github.com/pzfreo/build123d-drafting-helpers)
148
- are Apache 2.0 and may be embedded in commercial applications without restriction.
@@ -4,15 +4,15 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.1.10"
7
+ version = "0.1.12"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
11
11
  authors = [{ name = "Paul Fremantle", email = "pzfreo@gmail.com" }]
12
- requires-python = ">=3.10"
12
+ requires-python = ">=3.10,<3.15"
13
13
  dependencies = [
14
14
  "build123d>=0.9.0",
15
- "build123d-drafting-helpers>=0.10.1",
15
+ "build123d-drafting-helpers>=0.12.0",
16
16
  "kiwisolver>=1.4,<2",
17
17
  "numpy>=1.24",
18
18
  ]
@@ -0,0 +1,461 @@
1
+ """Shared low-level primitives for the draftwright drawing engine.
2
+
3
+ This module sits below :mod:`draftwright.make_drawing` and
4
+ :mod:`draftwright.annotate`: it holds the data structures and small helpers
5
+ both layers depend on (the :class:`Analysis` namespace and its field types,
6
+ the dimension/format helpers, and the layout constants). It imports only from
7
+ :mod:`draftwright.layout` and third-party libraries -- never from
8
+ ``make_drawing`` -- so the module graph stays a DAG (#98 Phase C).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from types import SimpleNamespace
18
+
19
+ from build123d import BoundBox, Location, Shape
20
+ from build123d_drafting.helpers import (
21
+ _TB_COL_FRACTIONS, # private: title-block column widths — tight coupling, see CLAUDE.md
22
+ Dimension,
23
+ TitleBlock,
24
+ draft_preset,
25
+ format_drawing_scale,
26
+ )
27
+
28
+ from draftwright.layout import _greedy_strip_1d, _solve_strip_1d
29
+
30
+ _log = logging.getLogger(__name__)
31
+
32
+
33
+ _MARGIN = 10.0
34
+
35
+
36
+ _TB_CLEAR = _MARGIN + 1.0 # title-block inset: one extra mm over _MARGIN for clearance
37
+
38
+
39
+ _FONT_SIZE = 3.0 # annotation text height (page-mm); the draft preset is built with this
40
+
41
+
42
+ _TB_H = 35.0
43
+
44
+
45
+ def _fmt(v: float) -> str:
46
+ """Format a float as integer string if whole, otherwise 1 dp."""
47
+ r = round(v)
48
+ return str(r) if abs(v - r) < 1e-6 else f"{v:.1f}"
49
+
50
+
51
+ def _tag_sequence(n):
52
+ """``A, B, …, Z, AA, AB, …`` — deterministic hole-table tags for *n* rows."""
53
+ tags = []
54
+ for i in range(n):
55
+ s, k = "", i
56
+ while True:
57
+ s = chr(ord("A") + k % 26) + s
58
+ k = k // 26 - 1
59
+ if k < 0:
60
+ break
61
+ tags.append(s)
62
+ return tags
63
+
64
+
65
+ _DIAM_RE = re.compile(r"[øØ⌀]\s*(\d+(?:\.\d+)?)")
66
+
67
+
68
+ def _axis_letter(obj) -> str:
69
+ """Letter (``"x"``/``"y"``/``"z"``) of ``obj.axis``'s dominant component.
70
+
71
+ ``obj`` is anything carrying an ``.axis`` 3-vector (a hole or a boss).
72
+ """
73
+ return max(zip("xyz", obj.axis, strict=True), key=lambda t: abs(t[1]))[0]
74
+
75
+
76
+ _CONCENTRIC_TOL_MM = 0.5
77
+
78
+
79
+ def _dim(p1, p2, side, distance, draft, **kwargs):
80
+ """Build a :class:`Dimension`, tagged with its placement spec.
81
+
82
+ Identical to constructing ``Dimension`` directly, but records ``p1``,
83
+ ``p2``, ``side``, ``distance`` and the label kwargs on the result as
84
+ ``_dw_spec`` so the #30 repair loop can re-place the dimension (flip the
85
+ side, widen the offset) without re-deriving any geometry. Only dimensions
86
+ built this way are re-placeable by :meth:`Drawing.repair`.
87
+ """
88
+ d = Dimension(p1, p2, side, distance, draft, **kwargs)
89
+ d._dw_spec = SimpleNamespace(
90
+ p1=p1, p2=p2, side=side, distance=abs(distance), draft=draft, kwargs=kwargs
91
+ )
92
+ return d
93
+
94
+
95
+ @dataclass
96
+ class Strip:
97
+ """A one-dimensional annotation band adjacent to an orthographic view.
98
+
99
+ Annotations are stacked outward from the view edge by calling
100
+ :meth:`allocate`. The cursor starts at ``anchor + direction * gap`` and
101
+ advances after each successful allocation.
102
+
103
+ Attributes:
104
+ anchor: Page coordinate of the view edge this strip starts from.
105
+ outer_limit: Page coordinate at which the strip ends (page margin,
106
+ neighbouring view, or title-block boundary).
107
+ direction: ``+1`` — cursor moves away from anchor (right/above);
108
+ ``-1`` — cursor retreats from anchor (left/below).
109
+ gap: Clearance between the view edge and the first annotation.
110
+ spacing: Clearance between successive annotations.
111
+ """
112
+
113
+ anchor: float
114
+ outer_limit: float
115
+ direction: float = 1.0
116
+ gap: float = 8.0
117
+ spacing: float = 4.0
118
+ _cursor: float = field(init=False, compare=False, repr=False)
119
+
120
+ def __post_init__(self):
121
+ self._cursor = self.anchor + self.direction * self.gap
122
+
123
+ # ------------------------------------------------------------------
124
+ # Public API
125
+
126
+ @property
127
+ def available(self) -> float:
128
+ """Total space available in this strip (mm)."""
129
+ return abs(self.outer_limit - self.anchor)
130
+
131
+ @property
132
+ def depth_used(self) -> float:
133
+ """How far the cursor has advanced from the anchor (mm)."""
134
+ return abs(self._cursor - self.anchor)
135
+
136
+ def peek(self, size: float) -> float | None:
137
+ """Return what ``allocate(size)`` would return without advancing the cursor."""
138
+ if self.direction == 1:
139
+ start = self._cursor
140
+ return start if (start + size) <= self.outer_limit else None
141
+ else:
142
+ end = self._cursor
143
+ return end if (end - size) >= self.outer_limit else None
144
+
145
+ def allocate(self, size: float) -> float | None:
146
+ """Reserve *size* mm; return the near-edge page coordinate, or ``None`` if full.
147
+
148
+ The returned value is the page coordinate of the annotation's
149
+ dimension line (or leader elbow). Convert to a relative offset with::
150
+
151
+ distance = abs(page_coord - strip.anchor)
152
+ """
153
+ if self.direction == 1:
154
+ start = self._cursor
155
+ end = start + size
156
+ if end > self.outer_limit:
157
+ return None
158
+ self._cursor = end + self.spacing
159
+ return start
160
+ else:
161
+ end = self._cursor
162
+ start = end - size
163
+ if start < self.outer_limit:
164
+ return None
165
+ self._cursor = start - self.spacing
166
+ return end
167
+
168
+
169
+ @dataclass
170
+ class ViewZones:
171
+ """The four annotation strips surrounding one orthographic view.
172
+
173
+ The ``right``/``above``/``below`` strips are always present for the three
174
+ orthographic views; only ``left`` can be ``None`` (a side view's left strip
175
+ abuts the front view, so it has no usable space).
176
+ """
177
+
178
+ right: Strip
179
+ above: Strip
180
+ below: Strip
181
+ left: Strip | None = None
182
+
183
+
184
+ _PAD = draft_preset(font_size=_FONT_SIZE, decimal_precision=1).pad_around_text
185
+
186
+
187
+ _SLOT_DIM_WIDTH = 2 * _FONT_SIZE + _PAD # pv_zones.below: overall width dimension
188
+
189
+
190
+ _SLOT_DIM_DEPTH = 2 * _FONT_SIZE + _PAD # sv_zones.below: overall depth dimension
191
+
192
+
193
+ _SLOT_DIM_HEIGHT = 2 * _FONT_SIZE + 2 * _PAD # fv_zones.right: overall height dim
194
+
195
+
196
+ _SLOT_DIM_STEP = 4 * _FONT_SIZE + _PAD # fv_zones.right: step-height dimension
197
+
198
+
199
+ _TABULATE_MIN_HOLES = 16
200
+
201
+
202
+ _MIN_STEP_DIM_MM = (
203
+ _FONT_SIZE
204
+ + 2 * draft_preset(font_size=_FONT_SIZE, decimal_precision=1).arrow_length
205
+ + 2 * draft_preset(font_size=_FONT_SIZE, decimal_precision=1).pad_around_text
206
+ )
207
+
208
+
209
+ _MIN_STEP_SEP_MM = _FONT_SIZE + 2 * _PAD
210
+
211
+
212
+ _MIN_LOC_SEP_MM = draft_preset(font_size=_FONT_SIZE, decimal_precision=1).arrow_length + _PAD
213
+
214
+
215
+ def _legible_steps(step_zs, bb_min_z, scale):
216
+ """Step heights worth dimensioning at *scale*, and how many were too close.
217
+
218
+ A step is dimensioned only if it is tall enough from the base to carry a
219
+ label *and* at least ``_MIN_STEP_SEP_MM`` (page-mm) above the previously
220
+ kept step — consecutive shoulders closer than that are page-coincident and
221
+ cannot be told apart (#41). Returns ``(kept_zs, n_too_close)``: the heights
222
+ to dimension, and the count of tall-enough steps dropped for spacing (the
223
+ caller surfaces these via lint; the full-fidelity answer is a detail view,
224
+ #42). Steps too short to carry a label at all are silently omitted — they
225
+ are simply not dimensionable, not dropped.
226
+ """
227
+ kept: list[float] = []
228
+ n_too_close = 0
229
+ last = None
230
+ for z in sorted(step_zs):
231
+ if (z - bb_min_z) * scale < _MIN_STEP_DIM_MM:
232
+ continue
233
+ if last is not None and (z - last) * scale < _MIN_STEP_SEP_MM:
234
+ n_too_close += 1
235
+ continue
236
+ kept.append(z)
237
+ last = z
238
+ return kept, n_too_close
239
+
240
+
241
+ def _largest_empty_rect(drawable, obstacles):
242
+ """Largest axis-aligned empty rectangle in *drawable* avoiding *obstacles*.
243
+
244
+ *drawable* and each obstacle are ``(x0, y0, x1, y1)`` page-mm boxes. Returns
245
+ the empty sub-rectangle of *drawable* (overlapping no obstacle) that maximises
246
+ the side of the largest square it can hold — i.e. ``min(width, height)`` — so
247
+ the (near-square) iso view can be scaled up as far as possible.
248
+
249
+ The obstacle set is tiny (front/plan/side views + title block), so a
250
+ gap-based search over candidate edges is both exact enough and cheap: every
251
+ maximal empty rectangle has edges drawn from the drawable bounds and the
252
+ obstacle bounds, so enumerating those cut lines finds the optimum.
253
+ """
254
+ dx0, dy0, dx1, dy1 = drawable
255
+ xs = sorted({dx0, dx1, *(c for o in obstacles for c in (o[0], o[2]) if dx0 < c < dx1)})
256
+ ys = sorted({dy0, dy1, *(c for o in obstacles for c in (o[1], o[3]) if dy0 < c < dy1)})
257
+
258
+ best = None
259
+ best_score = 0.0
260
+ for i in range(len(xs) - 1):
261
+ for j in range(i + 1, len(xs)):
262
+ rx0, rx1 = xs[i], xs[j]
263
+ for k in range(len(ys) - 1):
264
+ for m in range(k + 1, len(ys)):
265
+ ry0, ry1 = ys[k], ys[m]
266
+ if any(
267
+ rx0 < o[2] and o[0] < rx1 and ry0 < o[3] and o[1] < ry1 for o in obstacles
268
+ ):
269
+ continue
270
+ score = min(rx1 - rx0, ry1 - ry0)
271
+ if score > best_score:
272
+ best_score = score
273
+ best = (rx0, ry0, rx1, ry1)
274
+ if best is None:
275
+ # No empty rectangle exists (obstacles cover the drawable area). This
276
+ # is unreachable in practice — choose_scale always leaves a gap — but
277
+ # if it ever happens the iso would render over the other views, so flag
278
+ # it rather than fail silently.
279
+ _log.warning(
280
+ "No empty rectangle found for the iso view; obstacles fill the "
281
+ "drawable area — iso may overlap other views"
282
+ )
283
+ return drawable
284
+ return best
285
+
286
+
287
+ @dataclass(frozen=True)
288
+ class _Projector:
289
+ """Model → page coordinate projection for the orthographic views.
290
+
291
+ Each in-plane view axis projects as ``origin + (value - centroid) * scale``.
292
+ Built once in :func:`_analyse` and hung off the analysis namespace as
293
+ ``a.proj`` so the annotation passes share one projector instead of each
294
+ re-deriving the ``FX``/``FZ``/``SX``/``SZ``/``PX``/``PY`` closures.
295
+
296
+ This deliberately mirrors those analysis-phase closures byte-for-byte (an
297
+ unsigned ``+1`` projection), so the consolidation is provably
298
+ behaviour-preserving. The helpers library's ``ViewCoordinates.px``/``.py``
299
+ (already built per view as ``dwg._coords``) computes a *signed* projection
300
+ from ``view_axes()``; routing through it would couple the annotation passes
301
+ to render-order ``_coords`` population and could change output where a view
302
+ axis projects with a negative sign. Unifying onto ``ViewCoordinates`` is
303
+ therefore tracked as separate follow-up work, not part of this dedup.
304
+
305
+ Convention at call sites: bind a short local alias (``FX = a.proj.front_x``)
306
+ when a function projects repeatedly through its body; call ``a.proj.*()``
307
+ directly for one-off projections.
308
+ """
309
+
310
+ fv_x: float
311
+ fv_y: float
312
+ sv_x: float
313
+ sv_y: float
314
+ pv_x: float
315
+ pv_y: float
316
+ cx: float
317
+ cy: float
318
+ cz: float
319
+ scale: float
320
+
321
+ def front_x(self, x: float) -> float:
322
+ return self.fv_x + (x - self.cx) * self.scale
323
+
324
+ def front_z(self, z: float) -> float:
325
+ return self.fv_y + (z - self.cz) * self.scale
326
+
327
+ def side_x(self, y: float) -> float:
328
+ return self.sv_x + (y - self.cy) * self.scale
329
+
330
+ def side_z(self, z: float) -> float:
331
+ return self.sv_y + (z - self.cz) * self.scale
332
+
333
+ def plan_x(self, x: float) -> float:
334
+ return self.pv_x + (x - self.cx) * self.scale
335
+
336
+ def plan_y(self, y: float) -> float:
337
+ return self.pv_y + (y - self.cy) * self.scale
338
+
339
+
340
+ @dataclass(frozen=True)
341
+ class Analysis:
342
+ """Typed geometry+layout analysis produced by :func:`_analyse`.
343
+
344
+ The single data structure threaded through the whole annotation layer
345
+ (exposed as ``dwg._analysis`` and passed to the passes as ``a``). It was a
346
+ ``SimpleNamespace`` — invisible to mypy; making it a frozen dataclass type-
347
+ checks every ``a.<field>`` access and documents the contract (#98).
348
+
349
+ Page-coordinate fields (``FV_X`` … ``SV_Y``, ``ISO_X``/``ISO_Y``, the
350
+ ``*_limit`` and half-extent fields) are in page mm; ``cx``/``cy``/``cz`` and
351
+ the size fields are world mm; ``SCALE`` is the page-per-world factor.
352
+ """
353
+
354
+ part: Shape
355
+ bb: BoundBox
356
+ x_size: float
357
+ y_size: float
358
+ z_size: float
359
+ cx: float
360
+ cy: float
361
+ cz: float
362
+ bbox_max: float
363
+ holes: list
364
+ patterns: list
365
+ z_diams: list[float]
366
+ cross_diams: list[float]
367
+ cyls: tuple[list, list]
368
+ od_diam: float | None
369
+ is_rotational: bool
370
+ step_zs: list[float]
371
+ sv_right: float
372
+ iso_right_limit: float
373
+ SCALE: float
374
+ PAGE_W: float
375
+ PAGE_H: float
376
+ TB_W: float
377
+ DIM_PAD: float
378
+ margin: float
379
+ x_offset: float
380
+ FV_X: float
381
+ FV_Y: float
382
+ PV_X: float
383
+ PV_Y: float
384
+ SV_X: float
385
+ SV_Y: float
386
+ proj: _Projector
387
+ ISO_X: float
388
+ ISO_Y: float
389
+ iso_left_limit: float
390
+ iso_bottom_limit: float
391
+ iso_top_limit: float
392
+ fv_hw: float
393
+ fv_hh: float
394
+ pv_hh: float
395
+ sv_hw: float
396
+ fv_zones: ViewZones
397
+ pv_zones: ViewZones
398
+ sv_zones: ViewZones
399
+ step_file: str | Path | Shape
400
+ title: str
401
+ number: str
402
+ tolerance: str
403
+ drawn_by: str
404
+ out: str
405
+ pmi: list
406
+ pmi_mode: str
407
+
408
+
409
+ _greedy_strip_ys = _greedy_strip_1d
410
+
411
+
412
+ _solve_strip_ys = _solve_strip_1d
413
+
414
+
415
+ _DRAFTWRIGHT_URL = "https://github.com/pzfreo/draftwright"
416
+
417
+
418
+ def _attribution_author(drawn_by: str | None) -> str:
419
+ """ISO 7200 "drawn by" value: the human author and draftwright, or just
420
+ draftwright when no author was supplied."""
421
+ author = (drawn_by or "").strip()
422
+ return f"{author} / draftwright" if author else "draftwright"
423
+
424
+
425
+ def _add_title_block(dwg, a: Analysis):
426
+ """Add the title block annotation."""
427
+ tb = TitleBlock(
428
+ a.title,
429
+ a.number,
430
+ scale=format_drawing_scale(a.SCALE),
431
+ general_tolerance=a.tolerance,
432
+ designed_by=_attribution_author(a.drawn_by),
433
+ revision="A",
434
+ legal_owner="",
435
+ width=a.TB_W,
436
+ draft=dwg.draft,
437
+ )
438
+ # Drawn-by cell geometry, taken from the block itself rather than hardcoded,
439
+ # so the hyperlink rect tracks any upstream TitleBlock layout change: the
440
+ # two-row block's bottom row is half the block height, and the cell spans
441
+ # from the first column divider (_TB_COL_FRACTIONS[0]) to the right edge.
442
+ cell_h = tb.block_bbox["height"] / 2.0
443
+ tb = tb.locate(Location((a.PAGE_W - a.TB_W - _TB_CLEAR, _TB_CLEAR, 0)))
444
+ dwg.add(tb, "title_block")
445
+
446
+ # Record that cell's page-space rectangle so export() can place a clickable
447
+ # draftwright hyperlink over the "… / draftwright" author text. Keeps the
448
+ # block at its standard two rows — no extra height, no layout impact (a
449
+ # third row would squeeze the dense-part hole table).
450
+ bx = a.PAGE_W - a.TB_W - _TB_CLEAR
451
+ dwg._draftwright_link_rect = (
452
+ bx + _TB_COL_FRACTIONS[0] * a.TB_W,
453
+ _TB_CLEAR,
454
+ bx + a.TB_W,
455
+ _TB_CLEAR + cell_h,
456
+ )
457
+
458
+
459
+ def _iso_bbox(dwg):
460
+ """(min_x, min_y, max_x, max_y) of the placed iso view, hidden lines included."""
461
+ return dwg.view_bounds("iso")