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.
- {draftwright-0.1.10 → draftwright-0.1.11}/CHANGELOG.md +44 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/PKG-INFO +5 -20
- {draftwright-0.1.10 → draftwright-0.1.11}/README.md +4 -19
- {draftwright-0.1.10 → draftwright-0.1.11}/pyproject.toml +1 -1
- draftwright-0.1.11/src/draftwright/_core.py +432 -0
- draftwright-0.1.11/src/draftwright/annotate.py +2039 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/src/draftwright/make_drawing.py +90 -2284
- {draftwright-0.1.10 → draftwright-0.1.11}/tests/test_layout.py +2 -1
- {draftwright-0.1.10 → draftwright-0.1.11}/tests/test_make_drawing.py +90 -17
- {draftwright-0.1.10 → draftwright-0.1.11}/.gitignore +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/LICENSE +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/skills/SKILL.md +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/src/draftwright/__init__.py +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/src/draftwright/layout.py +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/src/draftwright/pmi.py +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
- {draftwright-0.1.10 → draftwright-0.1.11}/tests/test_e2e_standards.py +0 -0
- {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.
|
|
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
|
|
812
|
+
draftwright is a single module (`make_drawing.py`) on top of two libraries:
|
|
813
813
|
|
|
814
814
|
```
|
|
815
|
-
draftwright
|
|
816
|
-
└── build123d-drafting-helpers
|
|
817
|
-
└── build123d
|
|
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
|
|
122
|
+
draftwright is a single module (`make_drawing.py`) on top of two libraries:
|
|
123
123
|
|
|
124
124
|
```
|
|
125
|
-
draftwright
|
|
126
|
-
└── build123d-drafting-helpers
|
|
127
|
-
└── build123d
|
|
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.
|
|
@@ -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")
|