draftwright 0.1.10__tar.gz → 0.1.11__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.11}/CHANGELOG.md +44 -0
  2. {draftwright-0.1.10 → draftwright-0.1.11}/PKG-INFO +5 -20
  3. {draftwright-0.1.10 → draftwright-0.1.11}/README.md +4 -19
  4. {draftwright-0.1.10 → draftwright-0.1.11}/pyproject.toml +1 -1
  5. draftwright-0.1.11/src/draftwright/_core.py +432 -0
  6. draftwright-0.1.11/src/draftwright/annotate.py +2039 -0
  7. {draftwright-0.1.10 → draftwright-0.1.11}/src/draftwright/make_drawing.py +90 -2284
  8. {draftwright-0.1.10 → draftwright-0.1.11}/tests/test_layout.py +2 -1
  9. {draftwright-0.1.10 → draftwright-0.1.11}/tests/test_make_drawing.py +90 -17
  10. {draftwright-0.1.10 → draftwright-0.1.11}/.gitignore +0 -0
  11. {draftwright-0.1.10 → draftwright-0.1.11}/LICENSE +0 -0
  12. {draftwright-0.1.10 → draftwright-0.1.11}/skills/SKILL.md +0 -0
  13. {draftwright-0.1.10 → draftwright-0.1.11}/src/draftwright/__init__.py +0 -0
  14. {draftwright-0.1.10 → draftwright-0.1.11}/src/draftwright/layout.py +0 -0
  15. {draftwright-0.1.10 → draftwright-0.1.11}/src/draftwright/pmi.py +0 -0
  16. {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  17. {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  18. {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  19. {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  20. {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  21. {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  22. {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  23. {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  24. {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  25. {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  26. {draftwright-0.1.10 → draftwright-0.1.11}/tests/test_e2e_standards.py +0 -0
  27. {draftwright-0.1.10 → draftwright-0.1.11}/tests/test_pmi.py +0 -0
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## v0.1.11 — 2026-06-19
6
+
7
+ ### Changed
8
+
9
+ - **Feature-coverage lint is assembly-aware.** A general-arrangement drawing of
10
+ a multi-solid part deliberately omits each part's bores (they belong on detail
11
+ sheets), so `feature_not_dimensioned` / `feature_count_mismatch` are now
12
+ emitted at `info` rather than `warning` when the part is multi-solid — out of
13
+ the warning count and quality score, but still queryable. Auto-detected;
14
+ override with `build_drawing(..., assembly=True/False)` or
15
+ `lint_feature_coverage(..., assembly=...)` (#69).
16
+
17
+ ### Fixed
18
+
19
+ - **`place_dim` now labels the real-world length, not the page distance**, at
20
+ non-1:1 scale. Previously a dimension placed at a scale other than 1:1 showed
21
+ the on-page millimetre span instead of the true model dimension (#104).
22
+
23
+ ### Internal
24
+
25
+ - **`make_drawing.py` decomposed (#98).** The per-view projection math and the
26
+ analysis namespace were deduplicated and typed (the namespace is now a frozen
27
+ `Analysis` dataclass), and the annotation passes were extracted into a new
28
+ `draftwright.annotate` module on top of a shared `draftwright._core`. The
29
+ module graph is a DAG (`layout → _core → {make_drawing, annotate}`) and
30
+ `make_drawing.py` shrank from ~5,270 to ~2,930 lines. No public API or
31
+ behaviour change.
32
+
3
33
  ## v0.1.10 — 2026-06-18
4
34
 
5
35
  ### Added
@@ -31,6 +61,20 @@
31
61
  - Hole-callout and turned-diameter placement is deconflicted through the shared
32
62
  `LayoutSolver` instead of ad-hoc per-pass logic (no output change).
33
63
 
64
+ ### Fixed
65
+
66
+ - **Exact circles recovered for revolution silhouettes.** `project_to_viewport`'s
67
+ HLR returns the on-axis silhouette of a turned feature (or a concentric
68
+ gear-tooth-tip arc) as an approximating spline, not a true circle — splines in
69
+ the DXF where CAM expects `CIRCLE`/`ARC`, and fitted rather than exact radii.
70
+ `add_view` now refits any silhouette whose samples are equidistant from a
71
+ recognised revolution axis back to an exact circle/arc (#67).
72
+ - **Blind-hole depth no longer measured across solid boundaries.** On a
73
+ multi-solid assembly, coaxial bores in different bodies were merged into one
74
+ hole, reporting a depth spanning the inter-body gap (the ⌀9.8 ↓111.4 symptom).
75
+ Fixed upstream in `build123d-drafting-helpers` 0.10.1; the dependency pin is
76
+ bumped to `>=0.10.1` to pick it up (#68).
77
+
34
78
  ### Docs
35
79
 
36
80
  - 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.11
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
@@ -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,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.1.10"
7
+ version = "0.1.11"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -0,0 +1,432 @@
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
+ Dimension,
22
+ TitleBlock,
23
+ draft_preset,
24
+ format_drawing_scale,
25
+ )
26
+
27
+ from draftwright.layout import _greedy_strip_1d, _solve_strip_1d
28
+
29
+ _log = logging.getLogger(__name__)
30
+
31
+
32
+ _MARGIN = 10.0
33
+
34
+
35
+ _TB_CLEAR = _MARGIN + 1.0 # title-block inset: one extra mm over _MARGIN for clearance
36
+
37
+
38
+ _FONT_SIZE = 3.0 # annotation text height (page-mm); the draft preset is built with this
39
+
40
+
41
+ _TB_H = 35.0
42
+
43
+
44
+ def _fmt(v: float) -> str:
45
+ """Format a float as integer string if whole, otherwise 1 dp."""
46
+ r = round(v)
47
+ return str(r) if abs(v - r) < 1e-6 else f"{v:.1f}"
48
+
49
+
50
+ def _tag_sequence(n):
51
+ """``A, B, …, Z, AA, AB, …`` — deterministic hole-table tags for *n* rows."""
52
+ tags = []
53
+ for i in range(n):
54
+ s, k = "", i
55
+ while True:
56
+ s = chr(ord("A") + k % 26) + s
57
+ k = k // 26 - 1
58
+ if k < 0:
59
+ break
60
+ tags.append(s)
61
+ return tags
62
+
63
+
64
+ _DIAM_RE = re.compile(r"[øØ⌀]\s*(\d+(?:\.\d+)?)")
65
+
66
+
67
+ def _axis_letter(obj) -> str:
68
+ """Letter (``"x"``/``"y"``/``"z"``) of ``obj.axis``'s dominant component.
69
+
70
+ ``obj`` is anything carrying an ``.axis`` 3-vector (a hole or a boss).
71
+ """
72
+ return max(zip("xyz", obj.axis, strict=True), key=lambda t: abs(t[1]))[0]
73
+
74
+
75
+ _CONCENTRIC_TOL_MM = 0.5
76
+
77
+
78
+ def _dim(p1, p2, side, distance, draft, **kwargs):
79
+ """Build a :class:`Dimension`, tagged with its placement spec.
80
+
81
+ Identical to constructing ``Dimension`` directly, but records ``p1``,
82
+ ``p2``, ``side``, ``distance`` and the label kwargs on the result as
83
+ ``_dw_spec`` so the #30 repair loop can re-place the dimension (flip the
84
+ side, widen the offset) without re-deriving any geometry. Only dimensions
85
+ built this way are re-placeable by :meth:`Drawing.repair`.
86
+ """
87
+ d = Dimension(p1, p2, side, distance, draft, **kwargs)
88
+ d._dw_spec = SimpleNamespace(
89
+ p1=p1, p2=p2, side=side, distance=abs(distance), draft=draft, kwargs=kwargs
90
+ )
91
+ return d
92
+
93
+
94
+ @dataclass
95
+ class Strip:
96
+ """A one-dimensional annotation band adjacent to an orthographic view.
97
+
98
+ Annotations are stacked outward from the view edge by calling
99
+ :meth:`allocate`. The cursor starts at ``anchor + direction * gap`` and
100
+ advances after each successful allocation.
101
+
102
+ Attributes:
103
+ anchor: Page coordinate of the view edge this strip starts from.
104
+ outer_limit: Page coordinate at which the strip ends (page margin,
105
+ neighbouring view, or title-block boundary).
106
+ direction: ``+1`` — cursor moves away from anchor (right/above);
107
+ ``-1`` — cursor retreats from anchor (left/below).
108
+ gap: Clearance between the view edge and the first annotation.
109
+ spacing: Clearance between successive annotations.
110
+ """
111
+
112
+ anchor: float
113
+ outer_limit: float
114
+ direction: float = 1.0
115
+ gap: float = 8.0
116
+ spacing: float = 4.0
117
+ _cursor: float = field(init=False, compare=False, repr=False)
118
+
119
+ def __post_init__(self):
120
+ self._cursor = self.anchor + self.direction * self.gap
121
+
122
+ # ------------------------------------------------------------------
123
+ # Public API
124
+
125
+ @property
126
+ def available(self) -> float:
127
+ """Total space available in this strip (mm)."""
128
+ return abs(self.outer_limit - self.anchor)
129
+
130
+ @property
131
+ def depth_used(self) -> float:
132
+ """How far the cursor has advanced from the anchor (mm)."""
133
+ return abs(self._cursor - self.anchor)
134
+
135
+ def peek(self, size: float) -> float | None:
136
+ """Return what ``allocate(size)`` would return without advancing the cursor."""
137
+ if self.direction == 1:
138
+ start = self._cursor
139
+ return start if (start + size) <= self.outer_limit else None
140
+ else:
141
+ end = self._cursor
142
+ return end if (end - size) >= self.outer_limit else None
143
+
144
+ def allocate(self, size: float) -> float | None:
145
+ """Reserve *size* mm; return the near-edge page coordinate, or ``None`` if full.
146
+
147
+ The returned value is the page coordinate of the annotation's
148
+ dimension line (or leader elbow). Convert to a relative offset with::
149
+
150
+ distance = abs(page_coord - strip.anchor)
151
+ """
152
+ if self.direction == 1:
153
+ start = self._cursor
154
+ end = start + size
155
+ if end > self.outer_limit:
156
+ return None
157
+ self._cursor = end + self.spacing
158
+ return start
159
+ else:
160
+ end = self._cursor
161
+ start = end - size
162
+ if start < self.outer_limit:
163
+ return None
164
+ self._cursor = start - self.spacing
165
+ return end
166
+
167
+
168
+ @dataclass
169
+ class ViewZones:
170
+ """The four annotation strips surrounding one orthographic view.
171
+
172
+ The ``right``/``above``/``below`` strips are always present for the three
173
+ orthographic views; only ``left`` can be ``None`` (a side view's left strip
174
+ abuts the front view, so it has no usable space).
175
+ """
176
+
177
+ right: Strip
178
+ above: Strip
179
+ below: Strip
180
+ left: Strip | None = None
181
+
182
+
183
+ _PAD = draft_preset(font_size=_FONT_SIZE, decimal_precision=1).pad_around_text
184
+
185
+
186
+ _SLOT_DIM_WIDTH = 2 * _FONT_SIZE + _PAD # pv_zones.below: overall width dimension
187
+
188
+
189
+ _SLOT_DIM_DEPTH = 2 * _FONT_SIZE + _PAD # sv_zones.below: overall depth dimension
190
+
191
+
192
+ _SLOT_DIM_HEIGHT = 2 * _FONT_SIZE + 2 * _PAD # fv_zones.right: overall height dim
193
+
194
+
195
+ _SLOT_DIM_STEP = 4 * _FONT_SIZE + _PAD # fv_zones.right: step-height dimension
196
+
197
+
198
+ _TABULATE_MIN_HOLES = 16
199
+
200
+
201
+ _MIN_STEP_DIM_MM = (
202
+ _FONT_SIZE
203
+ + 2 * draft_preset(font_size=_FONT_SIZE, decimal_precision=1).arrow_length
204
+ + 2 * draft_preset(font_size=_FONT_SIZE, decimal_precision=1).pad_around_text
205
+ )
206
+
207
+
208
+ _MIN_STEP_SEP_MM = _FONT_SIZE + 2 * _PAD
209
+
210
+
211
+ _MIN_LOC_SEP_MM = draft_preset(font_size=_FONT_SIZE, decimal_precision=1).arrow_length + _PAD
212
+
213
+
214
+ def _legible_steps(step_zs, bb_min_z, scale):
215
+ """Step heights worth dimensioning at *scale*, and how many were too close.
216
+
217
+ A step is dimensioned only if it is tall enough from the base to carry a
218
+ label *and* at least ``_MIN_STEP_SEP_MM`` (page-mm) above the previously
219
+ kept step — consecutive shoulders closer than that are page-coincident and
220
+ cannot be told apart (#41). Returns ``(kept_zs, n_too_close)``: the heights
221
+ to dimension, and the count of tall-enough steps dropped for spacing (the
222
+ caller surfaces these via lint; the full-fidelity answer is a detail view,
223
+ #42). Steps too short to carry a label at all are silently omitted — they
224
+ are simply not dimensionable, not dropped.
225
+ """
226
+ kept: list[float] = []
227
+ n_too_close = 0
228
+ last = None
229
+ for z in sorted(step_zs):
230
+ if (z - bb_min_z) * scale < _MIN_STEP_DIM_MM:
231
+ continue
232
+ if last is not None and (z - last) * scale < _MIN_STEP_SEP_MM:
233
+ n_too_close += 1
234
+ continue
235
+ kept.append(z)
236
+ last = z
237
+ return kept, n_too_close
238
+
239
+
240
+ def _largest_empty_rect(drawable, obstacles):
241
+ """Largest axis-aligned empty rectangle in *drawable* avoiding *obstacles*.
242
+
243
+ *drawable* and each obstacle are ``(x0, y0, x1, y1)`` page-mm boxes. Returns
244
+ the empty sub-rectangle of *drawable* (overlapping no obstacle) that maximises
245
+ the side of the largest square it can hold — i.e. ``min(width, height)`` — so
246
+ the (near-square) iso view can be scaled up as far as possible.
247
+
248
+ The obstacle set is tiny (front/plan/side views + title block), so a
249
+ gap-based search over candidate edges is both exact enough and cheap: every
250
+ maximal empty rectangle has edges drawn from the drawable bounds and the
251
+ obstacle bounds, so enumerating those cut lines finds the optimum.
252
+ """
253
+ dx0, dy0, dx1, dy1 = drawable
254
+ xs = sorted({dx0, dx1, *(c for o in obstacles for c in (o[0], o[2]) if dx0 < c < dx1)})
255
+ ys = sorted({dy0, dy1, *(c for o in obstacles for c in (o[1], o[3]) if dy0 < c < dy1)})
256
+
257
+ best = None
258
+ best_score = 0.0
259
+ for i in range(len(xs) - 1):
260
+ for j in range(i + 1, len(xs)):
261
+ rx0, rx1 = xs[i], xs[j]
262
+ for k in range(len(ys) - 1):
263
+ for m in range(k + 1, len(ys)):
264
+ ry0, ry1 = ys[k], ys[m]
265
+ if any(
266
+ rx0 < o[2] and o[0] < rx1 and ry0 < o[3] and o[1] < ry1 for o in obstacles
267
+ ):
268
+ continue
269
+ score = min(rx1 - rx0, ry1 - ry0)
270
+ if score > best_score:
271
+ best_score = score
272
+ best = (rx0, ry0, rx1, ry1)
273
+ if best is None:
274
+ # No empty rectangle exists (obstacles cover the drawable area). This
275
+ # is unreachable in practice — choose_scale always leaves a gap — but
276
+ # if it ever happens the iso would render over the other views, so flag
277
+ # it rather than fail silently.
278
+ _log.warning(
279
+ "No empty rectangle found for the iso view; obstacles fill the "
280
+ "drawable area — iso may overlap other views"
281
+ )
282
+ return drawable
283
+ return best
284
+
285
+
286
+ @dataclass(frozen=True)
287
+ class _Projector:
288
+ """Model → page coordinate projection for the orthographic views.
289
+
290
+ Each in-plane view axis projects as ``origin + (value - centroid) * scale``.
291
+ Built once in :func:`_analyse` and hung off the analysis namespace as
292
+ ``a.proj`` so the annotation passes share one projector instead of each
293
+ re-deriving the ``FX``/``FZ``/``SX``/``SZ``/``PX``/``PY`` closures.
294
+
295
+ This deliberately mirrors those analysis-phase closures byte-for-byte (an
296
+ unsigned ``+1`` projection), so the consolidation is provably
297
+ behaviour-preserving. The helpers library's ``ViewCoordinates.px``/``.py``
298
+ (already built per view as ``dwg._coords``) computes a *signed* projection
299
+ from ``view_axes()``; routing through it would couple the annotation passes
300
+ to render-order ``_coords`` population and could change output where a view
301
+ axis projects with a negative sign. Unifying onto ``ViewCoordinates`` is
302
+ therefore tracked as separate follow-up work, not part of this dedup.
303
+
304
+ Convention at call sites: bind a short local alias (``FX = a.proj.front_x``)
305
+ when a function projects repeatedly through its body; call ``a.proj.*()``
306
+ directly for one-off projections.
307
+ """
308
+
309
+ fv_x: float
310
+ fv_y: float
311
+ sv_x: float
312
+ sv_y: float
313
+ pv_x: float
314
+ pv_y: float
315
+ cx: float
316
+ cy: float
317
+ cz: float
318
+ scale: float
319
+
320
+ def front_x(self, x: float) -> float:
321
+ return self.fv_x + (x - self.cx) * self.scale
322
+
323
+ def front_z(self, z: float) -> float:
324
+ return self.fv_y + (z - self.cz) * self.scale
325
+
326
+ def side_x(self, y: float) -> float:
327
+ return self.sv_x + (y - self.cy) * self.scale
328
+
329
+ def side_z(self, z: float) -> float:
330
+ return self.sv_y + (z - self.cz) * self.scale
331
+
332
+ def plan_x(self, x: float) -> float:
333
+ return self.pv_x + (x - self.cx) * self.scale
334
+
335
+ def plan_y(self, y: float) -> float:
336
+ return self.pv_y + (y - self.cy) * self.scale
337
+
338
+
339
+ @dataclass(frozen=True)
340
+ class Analysis:
341
+ """Typed geometry+layout analysis produced by :func:`_analyse`.
342
+
343
+ The single data structure threaded through the whole annotation layer
344
+ (exposed as ``dwg._analysis`` and passed to the passes as ``a``). It was a
345
+ ``SimpleNamespace`` — invisible to mypy; making it a frozen dataclass type-
346
+ checks every ``a.<field>`` access and documents the contract (#98).
347
+
348
+ Page-coordinate fields (``FV_X`` … ``SV_Y``, ``ISO_X``/``ISO_Y``, the
349
+ ``*_limit`` and half-extent fields) are in page mm; ``cx``/``cy``/``cz`` and
350
+ the size fields are world mm; ``SCALE`` is the page-per-world factor.
351
+ """
352
+
353
+ part: Shape
354
+ bb: BoundBox
355
+ x_size: float
356
+ y_size: float
357
+ z_size: float
358
+ cx: float
359
+ cy: float
360
+ cz: float
361
+ bbox_max: float
362
+ holes: list
363
+ patterns: list
364
+ z_diams: list[float]
365
+ cross_diams: list[float]
366
+ cyls: tuple[list, list]
367
+ od_diam: float | None
368
+ is_rotational: bool
369
+ step_zs: list[float]
370
+ sv_right: float
371
+ iso_right_limit: float
372
+ SCALE: float
373
+ PAGE_W: float
374
+ PAGE_H: float
375
+ TB_W: float
376
+ DIM_PAD: float
377
+ margin: float
378
+ x_offset: float
379
+ FV_X: float
380
+ FV_Y: float
381
+ PV_X: float
382
+ PV_Y: float
383
+ SV_X: float
384
+ SV_Y: float
385
+ proj: _Projector
386
+ ISO_X: float
387
+ ISO_Y: float
388
+ iso_left_limit: float
389
+ iso_bottom_limit: float
390
+ iso_top_limit: float
391
+ fv_hw: float
392
+ fv_hh: float
393
+ pv_hh: float
394
+ sv_hw: float
395
+ fv_zones: ViewZones
396
+ pv_zones: ViewZones
397
+ sv_zones: ViewZones
398
+ step_file: str | Path | Shape
399
+ title: str
400
+ number: str
401
+ tolerance: str
402
+ drawn_by: str
403
+ out: str
404
+ pmi: list
405
+ pmi_mode: str
406
+
407
+
408
+ _greedy_strip_ys = _greedy_strip_1d
409
+
410
+
411
+ _solve_strip_ys = _solve_strip_1d
412
+
413
+
414
+ def _add_title_block(dwg, a: Analysis):
415
+ """Add the title block annotation."""
416
+ tb = TitleBlock(
417
+ a.title,
418
+ a.number,
419
+ scale=format_drawing_scale(a.SCALE),
420
+ general_tolerance=a.tolerance,
421
+ designed_by=a.drawn_by,
422
+ revision="A",
423
+ legal_owner="",
424
+ width=a.TB_W,
425
+ draft=dwg.draft,
426
+ ).locate(Location((a.PAGE_W - a.TB_W - 11, 11, 0)))
427
+ dwg.add(tb, "title_block")
428
+
429
+
430
+ def _iso_bbox(dwg):
431
+ """(min_x, min_y, max_x, max_y) of the placed iso view, hidden lines included."""
432
+ return dwg.view_bounds("iso")