draftwright 0.1.0__py3-none-any.whl
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/__init__.py +39 -0
- draftwright/make_drawing.py +3030 -0
- draftwright/pmi.py +362 -0
- draftwright-0.1.0.dist-info/METADATA +824 -0
- draftwright-0.1.0.dist-info/RECORD +8 -0
- draftwright-0.1.0.dist-info/WHEEL +4 -0
- draftwright-0.1.0.dist-info/entry_points.txt +2 -0
- draftwright-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,3030 @@
|
|
|
1
|
+
"""make_drawing — Zero-AI STEP-to-technical-drawing pipeline.
|
|
2
|
+
|
|
3
|
+
Produces a 4-view third-angle technical drawing (front, plan, side, isometric)
|
|
4
|
+
with automatic dimension selection from face-geometry analysis.
|
|
5
|
+
|
|
6
|
+
Typical usage::
|
|
7
|
+
|
|
8
|
+
from build123d_drafting.make_drawing import make_drawing
|
|
9
|
+
svg_path, dxf_path = make_drawing("part.step", title="BRACKET", number="DWG-042")
|
|
10
|
+
|
|
11
|
+
CLI (registered as ``make-drawing``)::
|
|
12
|
+
|
|
13
|
+
make-drawing part.step
|
|
14
|
+
make-drawing part.step --title "BRACKET" --number DWG-042
|
|
15
|
+
make-drawing part.step --script # write editable .py instead
|
|
16
|
+
make-drawing part.step --out /tmp/output
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import logging
|
|
21
|
+
import math
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from types import SimpleNamespace
|
|
26
|
+
from typing import Literal
|
|
27
|
+
|
|
28
|
+
from build123d import (
|
|
29
|
+
Arrow,
|
|
30
|
+
Box,
|
|
31
|
+
Color,
|
|
32
|
+
Compound,
|
|
33
|
+
Edge,
|
|
34
|
+
ExportDXF,
|
|
35
|
+
ExportSVG,
|
|
36
|
+
GeomType,
|
|
37
|
+
HeadType,
|
|
38
|
+
LineType,
|
|
39
|
+
Location,
|
|
40
|
+
Mode,
|
|
41
|
+
Pos,
|
|
42
|
+
Shape,
|
|
43
|
+
Vector,
|
|
44
|
+
import_step,
|
|
45
|
+
)
|
|
46
|
+
from build123d_drafting.features import (
|
|
47
|
+
BoltCircle,
|
|
48
|
+
LinearArray,
|
|
49
|
+
_full_cyls,
|
|
50
|
+
_spec_key,
|
|
51
|
+
analyse_cylinders,
|
|
52
|
+
find_hole_patterns,
|
|
53
|
+
find_holes,
|
|
54
|
+
)
|
|
55
|
+
from build123d_drafting.helpers import (
|
|
56
|
+
Centerline,
|
|
57
|
+
CenterlineCircle,
|
|
58
|
+
CenterMark,
|
|
59
|
+
Dimension,
|
|
60
|
+
HoleCallout,
|
|
61
|
+
Leader,
|
|
62
|
+
LintIssue,
|
|
63
|
+
Note,
|
|
64
|
+
TitleBlock,
|
|
65
|
+
ViewCoordinates,
|
|
66
|
+
annotate,
|
|
67
|
+
draft_preset,
|
|
68
|
+
format_drawing_scale,
|
|
69
|
+
lint_drawing,
|
|
70
|
+
set_page,
|
|
71
|
+
view_axes,
|
|
72
|
+
)
|
|
73
|
+
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
|
74
|
+
from OCP.GeomAbs import GeomAbs_Plane
|
|
75
|
+
|
|
76
|
+
_log = logging.getLogger(__name__)
|
|
77
|
+
|
|
78
|
+
_TB_W = 150.0
|
|
79
|
+
_MARGIN = 10.0
|
|
80
|
+
_DIM_PAD = 18.0
|
|
81
|
+
_TB_H = 35.0
|
|
82
|
+
# Minimum acceptable projected view dimension (page-mm). Below this, annotation
|
|
83
|
+
# geometry (leader wires, centre marks, bore callout elbows) can degenerate and
|
|
84
|
+
# cause OCCT Standard_DomainError / SIGABRT (#129).
|
|
85
|
+
_MIN_VIEW_MM = 10.0
|
|
86
|
+
|
|
87
|
+
_PAGE_SIZES = {
|
|
88
|
+
"A4": (297.0, 210.0),
|
|
89
|
+
"A3": (420.0, 297.0),
|
|
90
|
+
"A2": (594.0, 420.0),
|
|
91
|
+
"A1": (841.0, 594.0),
|
|
92
|
+
"A0": (1189.0, 841.0),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# SVG post-processing
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def fix_svg_page_size(svg_path: str, page_w: float, page_h: float) -> None:
|
|
102
|
+
"""Rewrite the SVG width/height/viewBox to match the full ISO page size.
|
|
103
|
+
|
|
104
|
+
ExportSVG crops to content bounding box; this expands it to the declared
|
|
105
|
+
page so the rendering fills the correct A-series sheet.
|
|
106
|
+
"""
|
|
107
|
+
data = Path(svg_path).read_text(encoding="utf-8")
|
|
108
|
+
data = re.sub(r'width="[^"]*"', f'width="{page_w:.3f}mm"', data, count=1)
|
|
109
|
+
data = re.sub(r'height="[^"]*"', f'height="{page_h:.3f}mm"', data, count=1)
|
|
110
|
+
data = re.sub(
|
|
111
|
+
r'viewBox="[^"]*"',
|
|
112
|
+
f'viewBox="0 -{page_h:.3f} {page_w:.3f} {page_h:.3f}"',
|
|
113
|
+
data,
|
|
114
|
+
count=1,
|
|
115
|
+
)
|
|
116
|
+
Path(svg_path).write_text(data, encoding="utf-8")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Geometry analysis
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def dedup_diams(cyls, tol: float = 0.15) -> list:
|
|
125
|
+
"""Return sorted-descending deduplicated diameter list from cylinder records."""
|
|
126
|
+
raw = sorted({c["diameter"] for c in cyls}, reverse=True)
|
|
127
|
+
merged: list[float] = []
|
|
128
|
+
for d in raw:
|
|
129
|
+
if not merged or abs(d - merged[-1]) > tol:
|
|
130
|
+
merged.append(round(d, 2))
|
|
131
|
+
return merged
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _fmt(v: float) -> str:
|
|
135
|
+
"""Format a float as integer string if whole, otherwise 1 dp."""
|
|
136
|
+
r = round(v)
|
|
137
|
+
return str(r) if abs(v - r) < 1e-6 else f"{v:.1f}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_DIAM_RE = re.compile(r"[øØ⌀]\s*(\d+(?:\.\d+)?)")
|
|
141
|
+
|
|
142
|
+
# Turned-part classification (#81): a rotational part's bounding box is
|
|
143
|
+
# square in XY to within _SQUARENESS_TOL, and its OD — the largest full
|
|
144
|
+
# *external* Z cylinder — fills at least _OD_FILL_MIN of that envelope, with
|
|
145
|
+
# its axis within _OD_AXIS_TOL of the envelope centre. Anything else is
|
|
146
|
+
# prismatic — its Z cylinders are holes or local bosses, not an OD.
|
|
147
|
+
_SQUARENESS_TOL = 0.05
|
|
148
|
+
_OD_FILL_MIN = 0.8
|
|
149
|
+
_OD_AXIS_TOL = 0.05
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _is_rotational(x_size, y_size, od_diam, od_axis_offset) -> bool:
|
|
153
|
+
"""True for turned parts: an outward-facing Z cylinder, concentric with
|
|
154
|
+
the bounding box, filling a square envelope.
|
|
155
|
+
|
|
156
|
+
``od_diam`` is the largest full *external* Z-cylinder diameter (``None``
|
|
157
|
+
when there is none — bores never qualify as an OD) and
|
|
158
|
+
``od_axis_offset`` that cylinder's axis distance from the bbox centre.
|
|
159
|
+
"""
|
|
160
|
+
if od_diam is None:
|
|
161
|
+
return False
|
|
162
|
+
envelope = max(x_size, y_size)
|
|
163
|
+
return (
|
|
164
|
+
abs(x_size - y_size) <= _SQUARENESS_TOL * envelope
|
|
165
|
+
and od_diam >= _OD_FILL_MIN * envelope
|
|
166
|
+
and od_axis_offset <= _OD_AXIS_TOL * envelope
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def lint_feature_coverage(part, annotations, tol: float = 0.15, cyls=None) -> list:
|
|
171
|
+
"""Coarse completeness check: report part diameters with no callout (#80).
|
|
172
|
+
|
|
173
|
+
Builds a feature inventory from *part*'s hole/boss diameters (cylinder
|
|
174
|
+
patches spanning at least ~half a turn around their axis in total, so
|
|
175
|
+
fillets are ignored) and diffs it against every ø value mentioned in the
|
|
176
|
+
annotations' labels, plus the structured ``covers_diameters`` metadata on
|
|
177
|
+
annotations that draw their values geometrically (e.g. ``HoleCallout``).
|
|
178
|
+
Radius callouts are *not* counted — "R5 TYP" fillet notes would otherwise
|
|
179
|
+
mask an undimensioned ø10 bore. Title blocks are skipped — part numbers
|
|
180
|
+
like "BRACKET R8" are not callouts. Each uncovered diameter yields one
|
|
181
|
+
``feature_not_dimensioned`` warning.
|
|
182
|
+
|
|
183
|
+
``cyls`` accepts a precomputed ``analyse_cylinders(part)`` result so
|
|
184
|
+
repeated lint runs need not re-scan the solid.
|
|
185
|
+
|
|
186
|
+
Counts are checked too (#92): the part's holes (via ``find_holes``) give
|
|
187
|
+
a required count per diameter (each bore, counterbore, and spotface
|
|
188
|
+
occurrence counts one), and structured callouts declare how many holes
|
|
189
|
+
they dimension (``covers_count`` — the ``n×`` prefix). A shortfall
|
|
190
|
+
yields a ``feature_count_mismatch`` warning. A diameter covered by any
|
|
191
|
+
free-text ø-label is exempt from the count check — text labels carry no
|
|
192
|
+
count semantics. Location coverage remains out of scope (#93).
|
|
193
|
+
"""
|
|
194
|
+
z_cyls, cross_cyls = cyls if cyls is not None else analyse_cylinders(part)
|
|
195
|
+
inventory = dedup_diams(_full_cyls(z_cyls + cross_cyls), tol=tol)
|
|
196
|
+
|
|
197
|
+
mentioned: set[float] = set()
|
|
198
|
+
text_mentioned: set[float] = set()
|
|
199
|
+
provided: dict[float, int] = {}
|
|
200
|
+
for ann in annotations:
|
|
201
|
+
if isinstance(ann, TitleBlock):
|
|
202
|
+
continue
|
|
203
|
+
label = getattr(ann, "label", None) or ""
|
|
204
|
+
for m in _DIAM_RE.finditer(label):
|
|
205
|
+
mentioned.add(float(m.group(1)))
|
|
206
|
+
text_mentioned.add(float(m.group(1)))
|
|
207
|
+
count = getattr(ann, "covers_count", 1)
|
|
208
|
+
for v in getattr(ann, "covers_diameters", ()):
|
|
209
|
+
mentioned.add(float(v))
|
|
210
|
+
provided[float(v)] = provided.get(float(v), 0) + count
|
|
211
|
+
|
|
212
|
+
issues = [
|
|
213
|
+
LintIssue(
|
|
214
|
+
severity="warning",
|
|
215
|
+
code="feature_not_dimensioned",
|
|
216
|
+
message=f"cylindrical feature ø{_fmt(d)} has no diameter callout on the sheet",
|
|
217
|
+
)
|
|
218
|
+
for d in inventory
|
|
219
|
+
if not any(abs(d - v) <= tol for v in mentioned)
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
required: dict[float, int] = {}
|
|
223
|
+
for h in find_holes(part, cyls=(z_cyls, cross_cyls)):
|
|
224
|
+
for d in (h.diameter, *(s.diameter for s in (h.cbore, h.spotface) if s)):
|
|
225
|
+
key = next((k for k in required if abs(k - d) <= tol), d)
|
|
226
|
+
required[key] = required.get(key, 0) + 1
|
|
227
|
+
for d, need in sorted(required.items(), reverse=True):
|
|
228
|
+
if any(abs(d - v) <= tol for v in text_mentioned):
|
|
229
|
+
continue # free-text coverage carries no count to check against
|
|
230
|
+
have = sum(c for v, c in provided.items() if abs(d - v) <= tol)
|
|
231
|
+
if 0 < have < need:
|
|
232
|
+
issues.append(
|
|
233
|
+
LintIssue(
|
|
234
|
+
severity="warning",
|
|
235
|
+
code="feature_count_mismatch",
|
|
236
|
+
message=(
|
|
237
|
+
f"{need} ø{_fmt(d)} features on the part but callouts account for {have}"
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
return issues
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def analyse_face_levels(part, tol: float = 0.5) -> list:
|
|
245
|
+
"""Return sorted unique Z-coords of horizontal (normal≈±Z) planar faces.
|
|
246
|
+
|
|
247
|
+
Uses tol-bucket deduplication but returns the actual face Z, not the rounded
|
|
248
|
+
bucket centre, so dimension labels match the true geometry.
|
|
249
|
+
"""
|
|
250
|
+
buckets = {}
|
|
251
|
+
for face in part.faces():
|
|
252
|
+
surf = BRepAdaptor_Surface(face.wrapped)
|
|
253
|
+
if surf.GetType() == GeomAbs_Plane:
|
|
254
|
+
ax = surf.Plane().Axis().Direction()
|
|
255
|
+
if abs(ax.Z()) > 0.99:
|
|
256
|
+
z = surf.Plane().Location().Z()
|
|
257
|
+
key = round(z / tol) * tol
|
|
258
|
+
if key not in buckets:
|
|
259
|
+
buckets[key] = z
|
|
260
|
+
return sorted(buckets.values())
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
_LADDER = [
|
|
264
|
+
(10.0, 297.0, 210.0, 120.0), # A4 10:1
|
|
265
|
+
(5.0, 297.0, 210.0, 120.0), # A4 5:1
|
|
266
|
+
(5.0, 420.0, 297.0, 150.0), # A3 5:1
|
|
267
|
+
(2.0, 297.0, 210.0, 120.0), # A4 2:1
|
|
268
|
+
(1.0, 297.0, 210.0, 120.0), # A4 1:1
|
|
269
|
+
(2.0, 420.0, 297.0, 150.0), # A3 2:1
|
|
270
|
+
(1.0, 420.0, 297.0, 150.0), # A3 1:1
|
|
271
|
+
(2.0, 594.0, 420.0, 150.0), # A2 2:1
|
|
272
|
+
(1.0, 594.0, 420.0, 150.0), # A2 1:1
|
|
273
|
+
(0.5, 594.0, 420.0, 150.0), # A2 1:2
|
|
274
|
+
(0.2, 420.0, 297.0, 150.0), # A3 1:5
|
|
275
|
+
(0.2, 594.0, 420.0, 150.0), # A2 1:5
|
|
276
|
+
(1.0, 841.0, 594.0, 150.0), # A1 1:1
|
|
277
|
+
(0.5, 841.0, 594.0, 150.0), # A1 1:2
|
|
278
|
+
(0.2, 841.0, 594.0, 150.0), # A1 1:5
|
|
279
|
+
(0.5, 1189.0, 841.0, 150.0), # A0 1:2
|
|
280
|
+
(0.2, 1189.0, 841.0, 150.0), # A0 1:5
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
_SCALES = [10.0, 5.0, 2.0, 1.0, 0.5, 0.2]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# Strip / zone layout model
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass
|
|
292
|
+
class Strip:
|
|
293
|
+
"""A one-dimensional annotation band adjacent to an orthographic view.
|
|
294
|
+
|
|
295
|
+
Annotations are stacked outward from the view edge by calling
|
|
296
|
+
:meth:`allocate`. The cursor starts at ``anchor + direction * gap`` and
|
|
297
|
+
advances after each successful allocation.
|
|
298
|
+
|
|
299
|
+
Attributes:
|
|
300
|
+
anchor: Page coordinate of the view edge this strip starts from.
|
|
301
|
+
outer_limit: Page coordinate at which the strip ends (page margin,
|
|
302
|
+
neighbouring view, or title-block boundary).
|
|
303
|
+
direction: ``+1`` — cursor moves away from anchor (right/above);
|
|
304
|
+
``-1`` — cursor retreats from anchor (left/below).
|
|
305
|
+
gap: Clearance between the view edge and the first annotation.
|
|
306
|
+
spacing: Clearance between successive annotations.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
anchor: float
|
|
310
|
+
outer_limit: float
|
|
311
|
+
direction: float = 1.0
|
|
312
|
+
gap: float = 8.0
|
|
313
|
+
spacing: float = 4.0
|
|
314
|
+
_cursor: float = field(init=False, compare=False, repr=False)
|
|
315
|
+
|
|
316
|
+
def __post_init__(self):
|
|
317
|
+
self._cursor = self.anchor + self.direction * self.gap
|
|
318
|
+
|
|
319
|
+
# ------------------------------------------------------------------
|
|
320
|
+
# Public API
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def available(self) -> float:
|
|
324
|
+
"""Total space available in this strip (mm)."""
|
|
325
|
+
return abs(self.outer_limit - self.anchor)
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def depth_used(self) -> float:
|
|
329
|
+
"""How far the cursor has advanced from the anchor (mm)."""
|
|
330
|
+
return abs(self._cursor - self.anchor)
|
|
331
|
+
|
|
332
|
+
def peek(self, size: float) -> float | None:
|
|
333
|
+
"""Return what ``allocate(size)`` would return without advancing the cursor."""
|
|
334
|
+
if self.direction == 1:
|
|
335
|
+
start = self._cursor
|
|
336
|
+
return start if (start + size) <= self.outer_limit else None
|
|
337
|
+
else:
|
|
338
|
+
end = self._cursor
|
|
339
|
+
return end if (end - size) >= self.outer_limit else None
|
|
340
|
+
|
|
341
|
+
def allocate(self, size: float) -> float | None:
|
|
342
|
+
"""Reserve *size* mm; return the near-edge page coordinate, or ``None`` if full.
|
|
343
|
+
|
|
344
|
+
The returned value is the page coordinate of the annotation's
|
|
345
|
+
dimension line (or leader elbow). Convert to a relative offset with::
|
|
346
|
+
|
|
347
|
+
distance = abs(page_coord - strip.anchor)
|
|
348
|
+
"""
|
|
349
|
+
if self.direction == 1:
|
|
350
|
+
start = self._cursor
|
|
351
|
+
end = start + size
|
|
352
|
+
if end > self.outer_limit:
|
|
353
|
+
return None
|
|
354
|
+
self._cursor = end + self.spacing
|
|
355
|
+
return start
|
|
356
|
+
else:
|
|
357
|
+
end = self._cursor
|
|
358
|
+
start = end - size
|
|
359
|
+
if start < self.outer_limit:
|
|
360
|
+
return None
|
|
361
|
+
self._cursor = start - self.spacing
|
|
362
|
+
return end
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@dataclass
|
|
366
|
+
class ViewZones:
|
|
367
|
+
"""The four annotation strips surrounding one orthographic view.
|
|
368
|
+
|
|
369
|
+
Any strip that has no usable space (e.g. a side view's left strip, which
|
|
370
|
+
abuts the front view) is ``None``.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
right: Strip | None = None
|
|
374
|
+
left: Strip | None = None
|
|
375
|
+
above: Strip | None = None
|
|
376
|
+
below: Strip | None = None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _tb_width(page_w: float) -> float:
|
|
380
|
+
"""Title-block width for a page: 120 mm on A4, 150 mm on A3 and larger."""
|
|
381
|
+
return 120.0 if page_w <= 297.0 else 150.0
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _parse_page(page) -> tuple:
|
|
385
|
+
"""Resolve a page spec to ``(PAGE_W, PAGE_H, TB_W)``.
|
|
386
|
+
|
|
387
|
+
Accepts an ISO name (``"A4"``…``"A0"``, case-insensitive), a
|
|
388
|
+
``"WIDTHxHEIGHT"`` string in mm (e.g. ``"420x297"``), or a
|
|
389
|
+
``(width, height)`` tuple in mm.
|
|
390
|
+
"""
|
|
391
|
+
if isinstance(page, str):
|
|
392
|
+
name = page.strip().upper()
|
|
393
|
+
if name in _PAGE_SIZES:
|
|
394
|
+
pw, ph = _PAGE_SIZES[name]
|
|
395
|
+
else:
|
|
396
|
+
m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*[xX×]\s*(\d+(?:\.\d+)?)", page.strip())
|
|
397
|
+
if not m:
|
|
398
|
+
raise ValueError(
|
|
399
|
+
f"unknown page size {page!r} — expected one of "
|
|
400
|
+
f"{', '.join(_PAGE_SIZES)} or WIDTHxHEIGHT in mm (e.g. '420x297')"
|
|
401
|
+
)
|
|
402
|
+
pw, ph = float(m.group(1)), float(m.group(2))
|
|
403
|
+
else:
|
|
404
|
+
try:
|
|
405
|
+
pw, ph = float(page[0]), float(page[1])
|
|
406
|
+
except (TypeError, ValueError, IndexError):
|
|
407
|
+
raise ValueError(
|
|
408
|
+
f"invalid page size {page!r} — expected an ISO name, "
|
|
409
|
+
f"'WIDTHxHEIGHT', or a (width, height) tuple in mm"
|
|
410
|
+
) from None
|
|
411
|
+
if pw <= 0 or ph <= 0:
|
|
412
|
+
raise ValueError(f"page dimensions must be positive, got {page!r}")
|
|
413
|
+
return pw, ph, _tb_width(pw)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
_STRIP_GAP = 8.0
|
|
417
|
+
_STRIP_SPACING = 4.0
|
|
418
|
+
|
|
419
|
+
# Slot sizes for the annotations that allocate from fv/pv/sv strips.
|
|
420
|
+
# Shared between the depth estimators below and the allocate() call-sites in
|
|
421
|
+
# _auto_annotate() so that a slot-size change is automatically reflected in
|
|
422
|
+
# the estimator-driven corridor widths.
|
|
423
|
+
_SLOT_DIM_HEIGHT = 10.0 # fv_zones.right: overall height dimension
|
|
424
|
+
_SLOT_DIM_STEP = 14.0 # fv_zones.right: step-height dimension
|
|
425
|
+
_SLOT_DIM_WIDTH = 8.0 # pv_zones.below: overall width dimension
|
|
426
|
+
_SLOT_DIM_DEPTH = 8.0 # sv_zones.below: overall depth dimension
|
|
427
|
+
|
|
428
|
+
# ---------------------------------------------------------------------------
|
|
429
|
+
# Annotation depth estimators (Phase 2 of #118)
|
|
430
|
+
#
|
|
431
|
+
# These pure functions estimate the strip depth (mm) required for each
|
|
432
|
+
# inter-view boundary BEFORE view positions are fixed. They are intentionally
|
|
433
|
+
# conservative (may over-estimate slightly). Used by _analyse() (Phase 3) to
|
|
434
|
+
# set minimum corridor widths, and by _fits() (Phase 3) for consistent sheet
|
|
435
|
+
# selection.
|
|
436
|
+
# ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _est_right_strip_depth(n_steps: int) -> float:
|
|
440
|
+
"""Depth needed to the right of the front view.
|
|
441
|
+
|
|
442
|
+
Always includes dim_height (1 slot). Up to *n_steps* dim_step slots
|
|
443
|
+
(capped at 3) follow if any step levels are present. Returns the minimum
|
|
444
|
+
corridor width (from view edge to outer_limit) that makes all those
|
|
445
|
+
allocations succeed.
|
|
446
|
+
"""
|
|
447
|
+
n = 1 + min(max(n_steps, 0), 3) # dim_height + up to 3 step dims
|
|
448
|
+
# gap + dim_height + (n-1) step slots each preceded by one spacing
|
|
449
|
+
return _STRIP_GAP + _SLOT_DIM_HEIGHT + (n - 1) * (_STRIP_SPACING + _SLOT_DIM_STEP)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _est_pv_below_depth() -> float:
|
|
453
|
+
"""Depth needed below the plan view: dim_width (always one slot)."""
|
|
454
|
+
return _STRIP_GAP + _SLOT_DIM_WIDTH
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
# Two-pass layout — Pass 1: annotation strip depth measurement (#131)
|
|
459
|
+
#
|
|
460
|
+
# font_size = 3.0 mm is a fixed page-mm constant, so all annotation depths
|
|
461
|
+
# are scale-independent and can be computed before choose_scale() is called.
|
|
462
|
+
# ---------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _est_bore_callout_width(holes, font_size: float = 3.0, patterns=None) -> float:
|
|
466
|
+
"""Estimate the maximum bore callout label width (page-mm) across all holes.
|
|
467
|
+
|
|
468
|
+
Groups holes by machining spec (same as _annotate_holes), then estimates
|
|
469
|
+
the HoleCallout token sequence width using a character-based formula.
|
|
470
|
+
Includes the BoltCircle suffix ("EQ SP ON ø… BC") when patterns are supplied.
|
|
471
|
+
Returns the label width only — elbow_dx and gap clearance are NOT included;
|
|
472
|
+
callers that need the full strip depth should add those overheads separately.
|
|
473
|
+
Returns 0.0 when the hole list is empty.
|
|
474
|
+
"""
|
|
475
|
+
if not holes:
|
|
476
|
+
return 0.0
|
|
477
|
+
groups: dict = {}
|
|
478
|
+
for h in holes:
|
|
479
|
+
groups.setdefault(_spec_key(h), []).append(h)
|
|
480
|
+
|
|
481
|
+
# Map spec_key → BoltCircle so BoltCircle groups get their suffix estimated.
|
|
482
|
+
bc_by_spec: dict = {}
|
|
483
|
+
if patterns:
|
|
484
|
+
for p in patterns:
|
|
485
|
+
if isinstance(p, BoltCircle):
|
|
486
|
+
bc_by_spec[_spec_key(p.holes[0])] = p
|
|
487
|
+
|
|
488
|
+
h_fs = font_size
|
|
489
|
+
gap = 0.45 * h_fs
|
|
490
|
+
sym_w = h_fs
|
|
491
|
+
pad = 2.0 # pad_around_text (fixed in Draft for standard drawings)
|
|
492
|
+
char_w = 0.6 * h_fs # avg character width
|
|
493
|
+
|
|
494
|
+
max_w = 0.0
|
|
495
|
+
for spec_key, group in groups.items():
|
|
496
|
+
rep = group[0]
|
|
497
|
+
count = len(group) if len(group) > 1 else None
|
|
498
|
+
through = rep.bottom == "through"
|
|
499
|
+
step = rep.cbore or rep.spotface
|
|
500
|
+
|
|
501
|
+
token_w: list[float] = []
|
|
502
|
+
if count:
|
|
503
|
+
token_w.append(len(f"{count}×") * char_w)
|
|
504
|
+
token_w.append(sym_w) # ⌀ symbol
|
|
505
|
+
token_w.append(len(_fmt(rep.diameter)) * char_w)
|
|
506
|
+
if through:
|
|
507
|
+
token_w.append(len("THRU") * char_w)
|
|
508
|
+
elif rep.depth:
|
|
509
|
+
token_w.append(sym_w) # depth symbol
|
|
510
|
+
token_w.append(len(_fmt(rep.depth)) * char_w)
|
|
511
|
+
if step:
|
|
512
|
+
token_w.append(sym_w) # counterbore/spotface symbol
|
|
513
|
+
token_w.append(sym_w) # ⌀
|
|
514
|
+
token_w.append(len(_fmt(step.diameter)) * char_w)
|
|
515
|
+
if step.depth:
|
|
516
|
+
token_w.append(sym_w) # depth symbol
|
|
517
|
+
token_w.append(len(_fmt(step.depth)) * char_w)
|
|
518
|
+
|
|
519
|
+
# BoltCircle suffix: "EQ SP ON ø{bc_dia} BC"
|
|
520
|
+
bc = bc_by_spec.get(spec_key)
|
|
521
|
+
if bc is not None:
|
|
522
|
+
token_w.append(len(f"EQ SP ON ø{_fmt(bc.diameter)} BC") * char_w)
|
|
523
|
+
|
|
524
|
+
n = len(token_w)
|
|
525
|
+
w = sum(token_w) + max(n - 1, 0) * gap + pad
|
|
526
|
+
max_w = max(max_w, w)
|
|
527
|
+
|
|
528
|
+
return max_w
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@dataclass
|
|
532
|
+
class StripDepths:
|
|
533
|
+
"""Annotation strip depths (page-mm) computed before view positions are fixed.
|
|
534
|
+
|
|
535
|
+
Drives the inter-view corridor widths in the two-pass layout (#131).
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
right: float # horizontal corridor right of FV/PV → gap_fv_sv
|
|
539
|
+
left: float # horizontal corridor left of FV/PV
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _measure_strips(holes, patterns, n_steps: int, bb, font_size: float = 3.0) -> StripDepths:
|
|
543
|
+
"""Compute annotation strip depths from hole geometry (Pass 1 of #131).
|
|
544
|
+
|
|
545
|
+
All annotation sizes are scale-independent because font_size is a fixed
|
|
546
|
+
page-mm constant, so there is no circularity with choose_scale().
|
|
547
|
+
"""
|
|
548
|
+
bore_depth = _est_bore_callout_width(holes, font_size, patterns=patterns)
|
|
549
|
+
# Add elbow clearance and leader-to-label gap so gap_fv_sv fully contains
|
|
550
|
+
# the composed leader: elbow_dx (= arrow_length ≈ 0.9×fs when a section
|
|
551
|
+
# line is present) + gap (= pad_around_text = 2.0 mm, always present).
|
|
552
|
+
if bore_depth > 0:
|
|
553
|
+
bore_depth += 2.0 + 0.9 * font_size
|
|
554
|
+
right = max(_est_right_strip_depth(n_steps), bore_depth)
|
|
555
|
+
left = max(_DIM_PAD, bore_depth)
|
|
556
|
+
return StripDepths(right=right, left=left)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _fits(
|
|
560
|
+
x_size,
|
|
561
|
+
y_size,
|
|
562
|
+
z_size,
|
|
563
|
+
scale,
|
|
564
|
+
page_w,
|
|
565
|
+
page_h,
|
|
566
|
+
tb_w,
|
|
567
|
+
n_steps: int = 0,
|
|
568
|
+
strips: StripDepths | None = None,
|
|
569
|
+
) -> bool:
|
|
570
|
+
"""True if the 4-view layout fits the page at this scale.
|
|
571
|
+
|
|
572
|
+
The title block occupies only the bottom ``_TB_H`` mm of the sheet, so when
|
|
573
|
+
the vertically-centred view rows clear its top edge, the row width does not
|
|
574
|
+
need to reserve title-block space (the iso view may extend over it).
|
|
575
|
+
"""
|
|
576
|
+
bbox_max = max(x_size, y_size, z_size)
|
|
577
|
+
gap_fv_sv = max(_DIM_PAD, strips.right if strips else _est_right_strip_depth(n_steps))
|
|
578
|
+
gap_left = max(_DIM_PAD, strips.left if strips else _DIM_PAD)
|
|
579
|
+
w = (
|
|
580
|
+
_MARGIN
|
|
581
|
+
+ gap_left
|
|
582
|
+
+ x_size * scale
|
|
583
|
+
+ gap_fv_sv
|
|
584
|
+
+ y_size * scale
|
|
585
|
+
+ _DIM_PAD
|
|
586
|
+
+ bbox_max * scale * 0.7
|
|
587
|
+
+ _DIM_PAD
|
|
588
|
+
+ tb_w
|
|
589
|
+
+ _MARGIN
|
|
590
|
+
)
|
|
591
|
+
h = _MARGIN + _DIM_PAD + y_size * scale + _DIM_PAD + z_size * scale + _DIM_PAD + _MARGIN
|
|
592
|
+
if h > page_h:
|
|
593
|
+
return False
|
|
594
|
+
if w <= page_w:
|
|
595
|
+
return True
|
|
596
|
+
views_bottom = max(0.0, (page_h - h) / 2) + _MARGIN + _DIM_PAD
|
|
597
|
+
# When views clear the title block row, the iso sits above it and the
|
|
598
|
+
# title block no longer constrains horizontal space — drop tb_w from w.
|
|
599
|
+
return w - tb_w <= page_w and views_bottom >= _MARGIN + _TB_H
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def choose_scale(
|
|
603
|
+
x_size: float,
|
|
604
|
+
y_size: float,
|
|
605
|
+
z_size: float,
|
|
606
|
+
n_steps: int = 0,
|
|
607
|
+
scale=None,
|
|
608
|
+
page=None,
|
|
609
|
+
strips: StripDepths | None = None,
|
|
610
|
+
) -> tuple:
|
|
611
|
+
"""Return (SCALE, PAGE_W, PAGE_H, TB_W) for a 4-view layout.
|
|
612
|
+
|
|
613
|
+
Layout columns: [front(x×z)] [side(y×z)] [iso(~0.7*max)] [title block].
|
|
614
|
+
Rows: [plan(x×y)] above [front/side].
|
|
615
|
+
Tries ISO A-series pages (A4→A3→A2→A1→A0) at preferred scales, including
|
|
616
|
+
ISO 5455 enlargement scales (10:1, 5:1) so small parts get legible views.
|
|
617
|
+
A4 uses a 120 mm title block; A3+ use 150 mm. The title block only
|
|
618
|
+
constrains row width when the view rows would overlap it vertically.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
scale: optional fixed scale factor (e.g. ``5`` for 5:1, ``0.5`` for
|
|
622
|
+
1:2); the page is then chosen as the smallest A-series sheet that
|
|
623
|
+
fits.
|
|
624
|
+
page: optional fixed page — an ISO name (``"A3"``), ``"WIDTHxHEIGHT"``
|
|
625
|
+
in mm, or a ``(width, height)`` tuple; the scale is then chosen as
|
|
626
|
+
the largest standard scale that fits. When both ``scale`` and
|
|
627
|
+
``page`` are given they are used as-is (a warning is logged if the
|
|
628
|
+
layout does not fit).
|
|
629
|
+
"""
|
|
630
|
+
if scale is not None and float(scale) <= 0:
|
|
631
|
+
raise ValueError(f"scale must be positive, got {scale!r}")
|
|
632
|
+
if scale is not None and page is not None:
|
|
633
|
+
pw, ph, tb = _parse_page(page)
|
|
634
|
+
if not _fits(
|
|
635
|
+
x_size, y_size, z_size, float(scale), pw, ph, tb, n_steps=n_steps, strips=strips
|
|
636
|
+
):
|
|
637
|
+
_log.warning(
|
|
638
|
+
"Requested scale %s on %s page may not fit the 4-view layout", scale, page
|
|
639
|
+
)
|
|
640
|
+
return float(scale), pw, ph, tb
|
|
641
|
+
if page is not None:
|
|
642
|
+
pw, ph, tb = _parse_page(page)
|
|
643
|
+
candidates = [(s, pw, ph, tb) for s in _SCALES]
|
|
644
|
+
elif scale is not None:
|
|
645
|
+
candidates = [(float(scale), pw, ph, _tb_width(pw)) for pw, ph in _PAGE_SIZES.values()]
|
|
646
|
+
else:
|
|
647
|
+
candidates = _LADDER
|
|
648
|
+
for cand in candidates:
|
|
649
|
+
if _fits(x_size, y_size, z_size, *cand, n_steps=n_steps, strips=strips):
|
|
650
|
+
return cand
|
|
651
|
+
_log.warning(
|
|
652
|
+
"No layout fits %.0f × %.0f × %.0f mm; falling back to %s",
|
|
653
|
+
x_size,
|
|
654
|
+
y_size,
|
|
655
|
+
z_size,
|
|
656
|
+
candidates[-1],
|
|
657
|
+
)
|
|
658
|
+
return candidates[-1]
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
# ---------------------------------------------------------------------------
|
|
662
|
+
# Shared analysis step
|
|
663
|
+
# ---------------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, page=None, pmi="off"):
|
|
667
|
+
"""Load STEP or use a build123d Shape, analyse geometry, compute layout.
|
|
668
|
+
|
|
669
|
+
Returns SimpleNamespace.
|
|
670
|
+
"""
|
|
671
|
+
if isinstance(step_file, Shape):
|
|
672
|
+
part = step_file
|
|
673
|
+
src = "build123d object"
|
|
674
|
+
else:
|
|
675
|
+
part = import_step(step_file)
|
|
676
|
+
src = str(step_file)
|
|
677
|
+
# AP242 STEP files carry PMI presentation geometry (annotation-plane
|
|
678
|
+
# border wires, leader curves) beside the solid; left in, it draws as
|
|
679
|
+
# phantom rectangles in every view and inflates the bounding box —
|
|
680
|
+
# corrupting the scale choice and the envelope dimensions. The drawing
|
|
681
|
+
# is of the solids.
|
|
682
|
+
solids = part.solids()
|
|
683
|
+
if solids:
|
|
684
|
+
body = solids[0] if len(solids) == 1 else Compound(children=list(solids))
|
|
685
|
+
if body.bounding_box().size != part.bounding_box().size or len(part.edges()) != len(
|
|
686
|
+
body.edges()
|
|
687
|
+
):
|
|
688
|
+
_log.info(
|
|
689
|
+
"Dropping non-solid geometry from %s (PMI presentation data)",
|
|
690
|
+
src,
|
|
691
|
+
)
|
|
692
|
+
part = body
|
|
693
|
+
|
|
694
|
+
# Semantic PMI extraction (AP242 only; separate read-only pass).
|
|
695
|
+
pmi_records: list = []
|
|
696
|
+
if pmi != "off" and not isinstance(step_file, Shape):
|
|
697
|
+
try:
|
|
698
|
+
from draftwright.pmi import extract_pmi
|
|
699
|
+
|
|
700
|
+
pmi_records = extract_pmi(step_file)
|
|
701
|
+
except Exception as exc:
|
|
702
|
+
_log.warning("PMI extraction failed: %s", exc)
|
|
703
|
+
|
|
704
|
+
bb = part.bounding_box()
|
|
705
|
+
x_size = bb.max.X - bb.min.X
|
|
706
|
+
y_size = bb.max.Y - bb.min.Y
|
|
707
|
+
z_size = bb.max.Z - bb.min.Z
|
|
708
|
+
cx = (bb.min.X + bb.max.X) / 2
|
|
709
|
+
cy = (bb.min.Y + bb.max.Y) / 2
|
|
710
|
+
cz = (bb.min.Z + bb.max.Z) / 2
|
|
711
|
+
bbox_max = max(x_size, y_size, z_size)
|
|
712
|
+
|
|
713
|
+
_log.info("Loaded %s bbox: %.2f × %.2f × %.2f mm", src, x_size, y_size, z_size)
|
|
714
|
+
|
|
715
|
+
z_cyls, cross_cyls = analyse_cylinders(part)
|
|
716
|
+
# Partial (fillet) faces are not features: they would pollute the OD,
|
|
717
|
+
# the bore leaders, and the rotational classification alike (#81)
|
|
718
|
+
full_z = _full_cyls(z_cyls)
|
|
719
|
+
z_diams = dedup_diams(full_z)
|
|
720
|
+
cross_diams = dedup_diams(_full_cyls(cross_cyls))
|
|
721
|
+
|
|
722
|
+
_log.info("Z-axis diameters: %s", z_diams)
|
|
723
|
+
if cross_diams:
|
|
724
|
+
_log.info("Cross-hole diams: %s", cross_diams)
|
|
725
|
+
|
|
726
|
+
od_cyl = max((c for c in full_z if c["external"]), key=lambda c: c["diameter"], default=None)
|
|
727
|
+
od_diam = None
|
|
728
|
+
if od_cyl:
|
|
729
|
+
# Snap to the dedup_diams representative so comparisons against
|
|
730
|
+
# z_diams entries (bore-leader exclusion, labels) are exact even if
|
|
731
|
+
# the cylinder records ever carry unrounded OCCT diameters (#86)
|
|
732
|
+
raw_od = od_cyl["diameter"]
|
|
733
|
+
od_diam = min(z_diams, key=lambda d: abs(d - raw_od))
|
|
734
|
+
od_axis_offset = (
|
|
735
|
+
math.hypot(od_cyl["axis_xyz"][0] - cx, od_cyl["axis_xyz"][1] - cy) if od_cyl else 0.0
|
|
736
|
+
)
|
|
737
|
+
is_rotational = _is_rotational(x_size, y_size, od_diam, od_axis_offset)
|
|
738
|
+
if z_diams and not is_rotational:
|
|
739
|
+
_log.info("Part classified prismatic; skipping OD/centreline/bore annotations")
|
|
740
|
+
|
|
741
|
+
face_zs = analyse_face_levels(part)
|
|
742
|
+
step_zs = [z for z in face_zs if z > bb.min.Z + 0.6 and z < bb.max.Z - 0.6]
|
|
743
|
+
|
|
744
|
+
# Pass 1 (two-pass layout, #131): measure annotation strip depths before
|
|
745
|
+
# view positions are fixed. font_size=3.0 is a fixed page-mm constant so
|
|
746
|
+
# all annotation sizes are scale-independent — no circularity.
|
|
747
|
+
holes = find_holes(part, cyls=(z_cyls, cross_cyls))
|
|
748
|
+
patterns = find_hole_patterns(holes)
|
|
749
|
+
|
|
750
|
+
# Conservative upper bound for page selection: count all candidate step
|
|
751
|
+
# faces without the SCALE-dependent 20 mm gate (SCALE is not yet known).
|
|
752
|
+
n_steps_ub = len(step_zs[:3])
|
|
753
|
+
strips_ub = _measure_strips(holes, patterns, n_steps_ub, bb)
|
|
754
|
+
SCALE, PAGE_W, PAGE_H, TB_W = choose_scale(
|
|
755
|
+
x_size, y_size, z_size, n_steps=n_steps_ub, scale=scale, page=page, strips=strips_ub
|
|
756
|
+
)
|
|
757
|
+
if scale is not None:
|
|
758
|
+
auto_scale, _, _, _ = choose_scale(
|
|
759
|
+
x_size, y_size, z_size, n_steps=n_steps_ub, scale=None, page=page, strips=strips_ub
|
|
760
|
+
)
|
|
761
|
+
if SCALE < auto_scale:
|
|
762
|
+
min_dim = min(x_size, y_size, z_size)
|
|
763
|
+
min_view = min_dim * SCALE
|
|
764
|
+
if min_view < _MIN_VIEW_MM:
|
|
765
|
+
safe = _MIN_VIEW_MM / min_dim
|
|
766
|
+
raise ValueError(
|
|
767
|
+
f"scale {SCALE!r} projects the smallest part dimension "
|
|
768
|
+
f"({min_dim:.0f} mm) to {min_view:.1f} mm — "
|
|
769
|
+
f"annotation geometry degenerates below {_MIN_VIEW_MM:.0f} mm "
|
|
770
|
+
f"(OCCT Standard_DomainError / SIGABRT). "
|
|
771
|
+
f"Use scale ≥ {safe:.3g} or omit --scale for automatic selection."
|
|
772
|
+
)
|
|
773
|
+
DIM_PAD = _DIM_PAD
|
|
774
|
+
margin = _MARGIN
|
|
775
|
+
# Refine: apply the same 20 mm height gate _auto_annotate uses for dim_step.
|
|
776
|
+
n_steps = len([z for z in step_zs[:3] if (z - bb.min.Z) * SCALE >= 20])
|
|
777
|
+
strips = _measure_strips(holes, patterns, n_steps, bb)
|
|
778
|
+
gap_fv_sv = max(DIM_PAD, strips.right)
|
|
779
|
+
gap_left = max(DIM_PAD, strips.left)
|
|
780
|
+
|
|
781
|
+
fv_hw = x_size * SCALE / 2
|
|
782
|
+
fv_hh = z_size * SCALE / 2
|
|
783
|
+
pv_hh = y_size * SCALE / 2
|
|
784
|
+
sv_hw = y_size * SCALE / 2
|
|
785
|
+
|
|
786
|
+
total_h = 2 * margin + 3 * DIM_PAD + z_size * SCALE + y_size * SCALE
|
|
787
|
+
y_offset = max(0.0, (PAGE_H - total_h) / 2)
|
|
788
|
+
|
|
789
|
+
total_content_w = (
|
|
790
|
+
gap_left
|
|
791
|
+
+ gap_fv_sv
|
|
792
|
+
+ x_size * SCALE
|
|
793
|
+
+ y_size * SCALE
|
|
794
|
+
+ 2 * DIM_PAD
|
|
795
|
+
+ bbox_max * SCALE * 0.7
|
|
796
|
+
)
|
|
797
|
+
x_offset = max(0.0, (PAGE_W - 2 * margin - TB_W - total_content_w) / 2)
|
|
798
|
+
|
|
799
|
+
FV_X = margin + x_offset + gap_left + fv_hw
|
|
800
|
+
FV_Y = y_offset + margin + DIM_PAD + fv_hh
|
|
801
|
+
PV_X = FV_X
|
|
802
|
+
PV_Y = FV_Y + fv_hh + DIM_PAD + pv_hh
|
|
803
|
+
SV_X = FV_X + fv_hw + gap_fv_sv + sv_hw
|
|
804
|
+
SV_Y = FV_Y
|
|
805
|
+
sv_right = SV_X + sv_hw + DIM_PAD
|
|
806
|
+
tb_top_y = margin + _TB_H
|
|
807
|
+
iso_above_tb = (PV_Y - pv_hh) > tb_top_y
|
|
808
|
+
iso_right_limit = (PAGE_W - margin) if iso_above_tb else (PAGE_W - TB_W - margin)
|
|
809
|
+
right_avail = max(0.0, iso_right_limit - sv_right)
|
|
810
|
+
ISO_X = sv_right + right_avail / 2
|
|
811
|
+
ISO_Y = PV_Y
|
|
812
|
+
|
|
813
|
+
# ------------------------------------------------------------------
|
|
814
|
+
# Strip / zone construction.
|
|
815
|
+
# Phase 1: defines regions only — annotation functions still use their
|
|
816
|
+
# own hard-coded offsets. Later phases will route each annotation
|
|
817
|
+
# through strip.allocate(). The iso view's outer limits are conservative
|
|
818
|
+
# here (PAGE_H - margin / iso_right_limit); _auto_annotate() tightens
|
|
819
|
+
# them once the iso has been projected.
|
|
820
|
+
fv_right_edge = FV_X + fv_hw
|
|
821
|
+
fv_left_edge = FV_X - fv_hw
|
|
822
|
+
fv_top_edge = FV_Y + fv_hh
|
|
823
|
+
fv_bottom_edge = FV_Y - fv_hh
|
|
824
|
+
pv_right_edge = PV_X + fv_hw # plan has the same X half-width as front
|
|
825
|
+
pv_left_edge = PV_X - fv_hw
|
|
826
|
+
pv_top_edge = PV_Y + pv_hh
|
|
827
|
+
pv_bottom_edge = PV_Y - pv_hh # = fv_top_edge + DIM_PAD
|
|
828
|
+
sv_top_edge = SV_Y + fv_hh # side view has the same Z height as front
|
|
829
|
+
# Outer limit for fv/pv right strips: must not enter the side view.
|
|
830
|
+
sv_left_edge = SV_X - sv_hw # = fv_right_edge + gap_fv_sv
|
|
831
|
+
|
|
832
|
+
fv_zones = ViewZones(
|
|
833
|
+
right=Strip(fv_right_edge, sv_left_edge, direction=1),
|
|
834
|
+
left=Strip(fv_left_edge, margin, direction=-1),
|
|
835
|
+
above=Strip(fv_top_edge, pv_bottom_edge - 2, direction=1),
|
|
836
|
+
below=Strip(fv_bottom_edge, margin, direction=-1),
|
|
837
|
+
)
|
|
838
|
+
pv_zones = ViewZones(
|
|
839
|
+
# Outer limit = sv_left_edge (not iso_right_limit) so bore callouts in
|
|
840
|
+
# the plan view are bounded by the same hard wall as the FV right strip,
|
|
841
|
+
# preventing labels from crossing dim_locy extension lines in the side
|
|
842
|
+
# view. gap_fv_sv is sized by _measure_strips to accommodate the widest
|
|
843
|
+
# callout, so well-estimated labels will always fit within this bound.
|
|
844
|
+
right=Strip(pv_right_edge, sv_left_edge, direction=1),
|
|
845
|
+
left=Strip(pv_left_edge, margin, direction=-1),
|
|
846
|
+
above=Strip(pv_top_edge, PAGE_H - margin, direction=1),
|
|
847
|
+
# gap_fv_pv = DIM_PAD = 18 mm; pv_below needs 16 mm, leaving 2 mm slack.
|
|
848
|
+
below=Strip(pv_bottom_edge, fv_top_edge, direction=-1),
|
|
849
|
+
)
|
|
850
|
+
sv_bottom_edge = SV_Y - fv_hh # same as fv_bottom_edge; side and front share Z height
|
|
851
|
+
sv_zones = ViewZones(
|
|
852
|
+
# sv_right already includes DIM_PAD; anchor here so the strip never
|
|
853
|
+
# places annotations inside that gap
|
|
854
|
+
right=Strip(sv_right, iso_right_limit, direction=1),
|
|
855
|
+
left=None, # immediately abuts the front view's right edge
|
|
856
|
+
above=Strip(sv_top_edge, PAGE_H - margin, direction=1),
|
|
857
|
+
below=Strip(sv_bottom_edge, margin, direction=-1),
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
page_label = {297: "A4", 420: "A3", 594: "A2", 841: "A1", 1189: "A0"}.get(
|
|
861
|
+
int(PAGE_W), f"{PAGE_W:.0f}mm"
|
|
862
|
+
)
|
|
863
|
+
_log.info(
|
|
864
|
+
"Scale %s:1 page %s FV(%.0f,%.0f) PV(%.0f,%.0f) SV(%.0f,%.0f) ISO(%.0f,%.0f)",
|
|
865
|
+
SCALE,
|
|
866
|
+
page_label,
|
|
867
|
+
FV_X,
|
|
868
|
+
FV_Y,
|
|
869
|
+
PV_X,
|
|
870
|
+
PV_Y,
|
|
871
|
+
SV_X,
|
|
872
|
+
SV_Y,
|
|
873
|
+
ISO_X,
|
|
874
|
+
ISO_Y,
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
return SimpleNamespace(
|
|
878
|
+
part=part,
|
|
879
|
+
bb=bb,
|
|
880
|
+
x_size=x_size,
|
|
881
|
+
y_size=y_size,
|
|
882
|
+
z_size=z_size,
|
|
883
|
+
cx=cx,
|
|
884
|
+
cy=cy,
|
|
885
|
+
cz=cz,
|
|
886
|
+
bbox_max=bbox_max,
|
|
887
|
+
holes=holes,
|
|
888
|
+
patterns=patterns,
|
|
889
|
+
z_diams=z_diams,
|
|
890
|
+
cross_diams=cross_diams,
|
|
891
|
+
cyls=(z_cyls, cross_cyls),
|
|
892
|
+
od_diam=od_diam,
|
|
893
|
+
is_rotational=is_rotational,
|
|
894
|
+
step_zs=step_zs,
|
|
895
|
+
sv_right=sv_right,
|
|
896
|
+
iso_right_limit=iso_right_limit,
|
|
897
|
+
SCALE=SCALE,
|
|
898
|
+
PAGE_W=PAGE_W,
|
|
899
|
+
PAGE_H=PAGE_H,
|
|
900
|
+
TB_W=TB_W,
|
|
901
|
+
DIM_PAD=DIM_PAD,
|
|
902
|
+
margin=margin,
|
|
903
|
+
x_offset=x_offset,
|
|
904
|
+
FV_X=FV_X,
|
|
905
|
+
FV_Y=FV_Y,
|
|
906
|
+
PV_X=PV_X,
|
|
907
|
+
PV_Y=PV_Y,
|
|
908
|
+
SV_X=SV_X,
|
|
909
|
+
SV_Y=SV_Y,
|
|
910
|
+
ISO_X=ISO_X,
|
|
911
|
+
ISO_Y=ISO_Y,
|
|
912
|
+
# View half-extents in page units (convenient for strip arithmetic)
|
|
913
|
+
fv_hw=fv_hw,
|
|
914
|
+
fv_hh=fv_hh,
|
|
915
|
+
pv_hh=pv_hh,
|
|
916
|
+
sv_hw=sv_hw,
|
|
917
|
+
# Strip / zone layout model (Phase 1 — regions defined, not yet used)
|
|
918
|
+
fv_zones=fv_zones,
|
|
919
|
+
pv_zones=pv_zones,
|
|
920
|
+
sv_zones=sv_zones,
|
|
921
|
+
step_file=step_file,
|
|
922
|
+
title=title,
|
|
923
|
+
number=number,
|
|
924
|
+
tolerance=tolerance,
|
|
925
|
+
drawn_by=drawn_by,
|
|
926
|
+
out=out,
|
|
927
|
+
pmi=pmi_records,
|
|
928
|
+
pmi_mode=pmi,
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
# ---------------------------------------------------------------------------
|
|
933
|
+
# Drawing builder (composable; make_drawing == build_drawing + export)
|
|
934
|
+
# ---------------------------------------------------------------------------
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
class Drawing:
|
|
938
|
+
"""A composable technical drawing — the editable form of :func:`make_drawing`.
|
|
939
|
+
|
|
940
|
+
A ``Drawing`` holds the projected views, the annotation list, and per-view
|
|
941
|
+
coordinate helpers. :func:`build_drawing` returns one pre-populated with the
|
|
942
|
+
standard 4-view layout and automatic dimensions; you then add or remove
|
|
943
|
+
annotations, add section/auxiliary views, and finally :meth:`export`.
|
|
944
|
+
|
|
945
|
+
Attributes:
|
|
946
|
+
scale: drawing scale factor (e.g. ``2.0`` for 2:1).
|
|
947
|
+
page_w, page_h: sheet size in mm.
|
|
948
|
+
tb_w: title-block width in mm.
|
|
949
|
+
draft: the shared ``Draft`` preset used by the automatic annotations.
|
|
950
|
+
look_at: scaled centroid ``(x, y, z)`` — the default ``look_at`` and a
|
|
951
|
+
building block for custom view cameras (see :meth:`add_view`).
|
|
952
|
+
dist: orthographic camera distance in scaled space.
|
|
953
|
+
centroid: unscaled centroid ``(x, y, z)``.
|
|
954
|
+
views: ``{name: (visible_compound, hidden_compound_or_None)}``.
|
|
955
|
+
annotations: ordered list of annotation objects (mutable).
|
|
956
|
+
part: the source solid, when known — enables the feature-coverage lint.
|
|
957
|
+
|
|
958
|
+
The constructor also accepts ``cyls``, a precomputed
|
|
959
|
+
``analyse_cylinders(part)`` result (cached privately; computed lazily on
|
|
960
|
+
first :meth:`lint` otherwise).
|
|
961
|
+
"""
|
|
962
|
+
|
|
963
|
+
def __init__(
|
|
964
|
+
self,
|
|
965
|
+
*,
|
|
966
|
+
scale,
|
|
967
|
+
page_w,
|
|
968
|
+
page_h,
|
|
969
|
+
tb_w,
|
|
970
|
+
draft,
|
|
971
|
+
look_at,
|
|
972
|
+
dist,
|
|
973
|
+
centroid,
|
|
974
|
+
out,
|
|
975
|
+
part=None,
|
|
976
|
+
cyls=None,
|
|
977
|
+
):
|
|
978
|
+
self.scale = scale
|
|
979
|
+
self.part = part
|
|
980
|
+
self._cyl_cache = cyls
|
|
981
|
+
self.page_w = page_w
|
|
982
|
+
self.page_h = page_h
|
|
983
|
+
self.tb_w = tb_w
|
|
984
|
+
self.draft = draft
|
|
985
|
+
self.look_at = look_at
|
|
986
|
+
self.dist = dist
|
|
987
|
+
self.centroid = centroid
|
|
988
|
+
self.out = out
|
|
989
|
+
self.views: dict = {}
|
|
990
|
+
self.annotations: list = []
|
|
991
|
+
self._coords: dict = {}
|
|
992
|
+
self._named: dict = {}
|
|
993
|
+
self.svg_path: str | None = None
|
|
994
|
+
self.dxf_path: str | None = None
|
|
995
|
+
self._analysis: SimpleNamespace | None = None
|
|
996
|
+
|
|
997
|
+
# -- views ----------------------------------------------------------------
|
|
998
|
+
def add_view(self, name, shape, camera, up, position, *, look_at=None, scaled=False):
|
|
999
|
+
"""Project ``shape`` from ``camera`` and place it at ``position``.
|
|
1000
|
+
|
|
1001
|
+
Args:
|
|
1002
|
+
name: view name (key in :attr:`views`); also used for coordinate lookups.
|
|
1003
|
+
shape: a build123d ``Shape`` to project. Given in world (unscaled)
|
|
1004
|
+
coordinates and scaled internally unless ``scaled=True``.
|
|
1005
|
+
camera, up, look_at: viewport parameters in **scaled** space (the same
|
|
1006
|
+
convention the standard views use). ``look_at`` defaults to
|
|
1007
|
+
:attr:`look_at` (the scaled centroid). Compose custom cameras from
|
|
1008
|
+
:attr:`look_at` and :attr:`dist`.
|
|
1009
|
+
position: ``(x, y)`` page position for the view centre, in mm.
|
|
1010
|
+
scaled: set ``True`` if ``shape`` is already scaled by :attr:`scale`.
|
|
1011
|
+
|
|
1012
|
+
Returns:
|
|
1013
|
+
The :class:`ViewCoordinates` for this view (also via :meth:`coords`),
|
|
1014
|
+
for mapping world points to page coordinates.
|
|
1015
|
+
"""
|
|
1016
|
+
la = self.look_at if look_at is None else look_at
|
|
1017
|
+
shape_s = shape if scaled else shape.scale(self.scale)
|
|
1018
|
+
vis, hid = shape_s.project_to_viewport(camera, up, la)
|
|
1019
|
+
vl, hl = list(vis), list(hid)
|
|
1020
|
+
if not vl and not hl:
|
|
1021
|
+
raise ValueError(
|
|
1022
|
+
f"project_to_viewport returned empty geometry for view {name!r} "
|
|
1023
|
+
f"(camera {camera}) — check the camera position and look_at."
|
|
1024
|
+
)
|
|
1025
|
+
loc = Location((position[0], position[1], 0))
|
|
1026
|
+
placed = Compound(children=vl).locate(loc)
|
|
1027
|
+
placed_hid = Compound(children=hl).locate(loc) if hl else None
|
|
1028
|
+
self.views[name] = (placed, placed_hid)
|
|
1029
|
+
axes = view_axes(camera, up, la)
|
|
1030
|
+
cx, cy, cz = la[0] / self.scale, la[1] / self.scale, la[2] / self.scale
|
|
1031
|
+
self._coords[name] = ViewCoordinates(
|
|
1032
|
+
axes, position[0], position[1], cx, cy, cz, self.scale
|
|
1033
|
+
)
|
|
1034
|
+
_log.info(" %s: %d visible / %d hidden", name, len(vl), len(hl))
|
|
1035
|
+
return self._coords[name]
|
|
1036
|
+
|
|
1037
|
+
def coords(self, view):
|
|
1038
|
+
"""Return the :class:`ViewCoordinates` for a named view."""
|
|
1039
|
+
return self._coords[view]
|
|
1040
|
+
|
|
1041
|
+
def at(self, view, x, y, z):
|
|
1042
|
+
"""Map a world point to a page point ``(px, py, 0)`` in ``view``."""
|
|
1043
|
+
px, py = self._coords[view].pp(x, y, z)
|
|
1044
|
+
return (px, py, 0.0)
|
|
1045
|
+
|
|
1046
|
+
# -- annotations ----------------------------------------------------------
|
|
1047
|
+
def add(self, obj, name=None):
|
|
1048
|
+
"""Register an annotation so lint and export include it; returns ``obj``.
|
|
1049
|
+
|
|
1050
|
+
Re-using an existing ``name`` replaces the previously added object (it is
|
|
1051
|
+
dropped from :attr:`annotations`), so a name always maps to one object.
|
|
1052
|
+
"""
|
|
1053
|
+
if name is not None and name in self._named:
|
|
1054
|
+
self.annotations.remove(self._named[name])
|
|
1055
|
+
annotate(obj, name)
|
|
1056
|
+
self.annotations.append(obj)
|
|
1057
|
+
if name is not None:
|
|
1058
|
+
self._named[name] = obj
|
|
1059
|
+
return obj
|
|
1060
|
+
|
|
1061
|
+
def remove(self, name):
|
|
1062
|
+
"""Remove a previously named annotation. Raises ``KeyError`` if absent."""
|
|
1063
|
+
obj = self._named.pop(name, None)
|
|
1064
|
+
if obj is None:
|
|
1065
|
+
raise KeyError(f"no annotation named {name!r}")
|
|
1066
|
+
self.annotations.remove(obj)
|
|
1067
|
+
return obj
|
|
1068
|
+
|
|
1069
|
+
def clear_annotations(self, keep=("title_block",)):
|
|
1070
|
+
"""Remove all annotations except those named in *keep* (#74).
|
|
1071
|
+
|
|
1072
|
+
Wholesale removal that does not depend on the automatic naming
|
|
1073
|
+
scheme — ``dwg.clear_annotations()`` strips every automatic dimension,
|
|
1074
|
+
leader, and centreline but keeps the title block.
|
|
1075
|
+
|
|
1076
|
+
Returns:
|
|
1077
|
+
The list of removed annotation objects.
|
|
1078
|
+
"""
|
|
1079
|
+
keep_set = set(keep)
|
|
1080
|
+
kept_named = {n: o for n, o in self._named.items() if n in keep_set}
|
|
1081
|
+
kept_ids = {id(o) for o in kept_named.values()}
|
|
1082
|
+
removed = [o for o in self.annotations if id(o) not in kept_ids]
|
|
1083
|
+
self.annotations = [o for o in self.annotations if id(o) in kept_ids]
|
|
1084
|
+
self._named = kept_named
|
|
1085
|
+
return removed
|
|
1086
|
+
|
|
1087
|
+
# -- output ---------------------------------------------------------------
|
|
1088
|
+
def lint(self):
|
|
1089
|
+
"""Lint all annotations against all views; returns the list of issues.
|
|
1090
|
+
|
|
1091
|
+
When :attr:`part` is set, also runs :func:`lint_feature_coverage`.
|
|
1092
|
+
"""
|
|
1093
|
+
set_page(self.page_w, self.page_h, margin=10)
|
|
1094
|
+
view_shapes = [vis for vis, _ in self.views.values()]
|
|
1095
|
+
issues = lint_drawing(self.annotations, drawing_scale=self.scale, view_shapes=view_shapes)
|
|
1096
|
+
if self.part is not None:
|
|
1097
|
+
if self._cyl_cache is None:
|
|
1098
|
+
self._cyl_cache = analyse_cylinders(self.part)
|
|
1099
|
+
issues += lint_feature_coverage(self.part, self.annotations, cyls=self._cyl_cache)
|
|
1100
|
+
return issues
|
|
1101
|
+
|
|
1102
|
+
def export(self, out=None):
|
|
1103
|
+
"""Lint, then write SVG and DXF. Returns ``(svg_path, dxf_path)``."""
|
|
1104
|
+
out = out if out is not None else self.out
|
|
1105
|
+
for _ext in (".svg", ".dxf"):
|
|
1106
|
+
if out.endswith(_ext):
|
|
1107
|
+
out = out[: -len(_ext)]
|
|
1108
|
+
break
|
|
1109
|
+
|
|
1110
|
+
issues = self.lint()
|
|
1111
|
+
if issues:
|
|
1112
|
+
_log.warning("Lint issues:")
|
|
1113
|
+
for iss in issues:
|
|
1114
|
+
_log.warning(" [%s] %s: %s", iss.severity, iss.code, iss.message)
|
|
1115
|
+
else:
|
|
1116
|
+
_log.info("Lint: OK")
|
|
1117
|
+
|
|
1118
|
+
blk = Color(0, 0, 0)
|
|
1119
|
+
grey = Color(0.5, 0.5, 0.5)
|
|
1120
|
+
blue = Color(0, 0.2, 0.7)
|
|
1121
|
+
|
|
1122
|
+
svg = ExportSVG(margin=10)
|
|
1123
|
+
svg.add_layer("part", line_color=blk, line_weight=0.5)
|
|
1124
|
+
svg.add_layer("hidden", line_color=grey, line_weight=0.25, line_type=LineType.HIDDEN)
|
|
1125
|
+
svg.add_layer("dims", line_color=blue, fill_color=blue, line_weight=0.05)
|
|
1126
|
+
self._add_shapes(svg)
|
|
1127
|
+
svg_path = out + ".svg"
|
|
1128
|
+
svg.write(svg_path)
|
|
1129
|
+
fix_svg_page_size(svg_path, self.page_w, self.page_h)
|
|
1130
|
+
_log.info("SVG → %s", svg_path)
|
|
1131
|
+
|
|
1132
|
+
dxf = ExportDXF()
|
|
1133
|
+
dxf.add_layer("part", line_weight=0.5)
|
|
1134
|
+
dxf.add_layer("hidden", line_weight=0.25)
|
|
1135
|
+
dxf.add_layer("dims", line_weight=0.05)
|
|
1136
|
+
self._add_shapes(dxf)
|
|
1137
|
+
dxf_path = out + ".dxf"
|
|
1138
|
+
dxf.write(dxf_path)
|
|
1139
|
+
_log.info("DXF → %s", dxf_path)
|
|
1140
|
+
|
|
1141
|
+
self.svg_path = svg_path
|
|
1142
|
+
self.dxf_path = dxf_path
|
|
1143
|
+
return svg_path, dxf_path
|
|
1144
|
+
|
|
1145
|
+
def _add_shapes(self, exporter):
|
|
1146
|
+
"""Add every view layer and annotation to *exporter* with error context."""
|
|
1147
|
+
for name, (vis, hid) in self.views.items():
|
|
1148
|
+
_export_shape(exporter, vis, "part", f"view {name!r}")
|
|
1149
|
+
if hid:
|
|
1150
|
+
_export_shape(exporter, hid, "hidden", f"view {name!r}")
|
|
1151
|
+
for ann in self.annotations:
|
|
1152
|
+
label = getattr(ann, "label", "") or type(ann).__name__
|
|
1153
|
+
_export_shape(exporter, ann, "dims", f"annotation {label!r}")
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _elements(shape):
|
|
1157
|
+
"""Decompose *shape* for export retry: faces plus any loose edges."""
|
|
1158
|
+
faces = list(shape.faces())
|
|
1159
|
+
if not faces:
|
|
1160
|
+
return list(shape.edges())
|
|
1161
|
+
owned = {e for f in faces for e in f.edges()}
|
|
1162
|
+
return faces + [e for e in shape.edges() if e not in owned]
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def _export_shape(exporter, shape, layer, ctx):
|
|
1166
|
+
"""Add *shape* to *exporter*, degrading element-by-element on failure.
|
|
1167
|
+
|
|
1168
|
+
build123d's exporters abort the whole export on the first edge whose
|
|
1169
|
+
curve cannot be approximated (a bare ``AssertionError`` from OCCT, #83).
|
|
1170
|
+
Instead, drop only the offending elements with a warning naming the
|
|
1171
|
+
view/layer, and raise (with that context) only if nothing exported.
|
|
1172
|
+
|
|
1173
|
+
``ExportSVG.add_shape`` is atomic — it appends converted elements only
|
|
1174
|
+
after the whole shape succeeds — so the shape is tried in one call first.
|
|
1175
|
+
``ExportDXF`` writes edge-by-edge as it converts, so a mid-shape failure
|
|
1176
|
+
would leave partial output that a blind retry duplicates; for it (and any
|
|
1177
|
+
unknown exporter) every element is added individually from the start.
|
|
1178
|
+
"""
|
|
1179
|
+
first_err = None
|
|
1180
|
+
if isinstance(exporter, ExportSVG):
|
|
1181
|
+
try:
|
|
1182
|
+
exporter.add_shape(shape, layer=layer)
|
|
1183
|
+
return
|
|
1184
|
+
except Exception as exc:
|
|
1185
|
+
first_err = exc
|
|
1186
|
+
_log.warning(
|
|
1187
|
+
"%s (layer %r) failed to export as one shape: %s — retrying element-wise",
|
|
1188
|
+
ctx,
|
|
1189
|
+
layer,
|
|
1190
|
+
exc,
|
|
1191
|
+
)
|
|
1192
|
+
elements = _elements(shape)
|
|
1193
|
+
skipped = 0
|
|
1194
|
+
for element in elements:
|
|
1195
|
+
try:
|
|
1196
|
+
exporter.add_shape(element, layer=layer)
|
|
1197
|
+
except Exception as exc:
|
|
1198
|
+
first_err = first_err or exc
|
|
1199
|
+
skipped += 1
|
|
1200
|
+
_log.debug("%s (layer %r): element failed to convert: %s", ctx, layer, exc)
|
|
1201
|
+
if skipped == len(elements) and first_err is not None:
|
|
1202
|
+
raise RuntimeError(f"{ctx} (layer {layer!r}): nothing could be exported") from first_err
|
|
1203
|
+
if skipped:
|
|
1204
|
+
_log.warning(
|
|
1205
|
+
"%s (layer %r): skipped %d of %d elements that failed to convert",
|
|
1206
|
+
ctx,
|
|
1207
|
+
layer,
|
|
1208
|
+
skipped,
|
|
1209
|
+
len(elements),
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _auto_annotate(dwg, a):
|
|
1214
|
+
"""Add the standard automatic dimensions, centrelines, and title block."""
|
|
1215
|
+
draft = dwg.draft
|
|
1216
|
+
|
|
1217
|
+
def FX(x):
|
|
1218
|
+
return a.FV_X + (x - a.cx) * a.SCALE
|
|
1219
|
+
|
|
1220
|
+
def FZ(z):
|
|
1221
|
+
return a.FV_Y + (z - a.cz) * a.SCALE
|
|
1222
|
+
|
|
1223
|
+
def SX(y):
|
|
1224
|
+
return a.SV_X + (y - a.cy) * a.SCALE
|
|
1225
|
+
|
|
1226
|
+
def SZ(z):
|
|
1227
|
+
return a.SV_Y + (z - a.cz) * a.SCALE
|
|
1228
|
+
|
|
1229
|
+
def PX(x):
|
|
1230
|
+
return a.PV_X + (x - a.cx) * a.SCALE
|
|
1231
|
+
|
|
1232
|
+
def PY(y):
|
|
1233
|
+
return a.PV_Y + (y - a.cy) * a.SCALE
|
|
1234
|
+
|
|
1235
|
+
# Tighten right-strip outer_limits to the actual iso view left edge now
|
|
1236
|
+
# that the iso has been projected and fitted. Guard: only apply if the
|
|
1237
|
+
# limit is above the strip cursor — an overflowing iso can produce
|
|
1238
|
+
# _iso_x_limit < cursor, which would silence every right-strip allocation.
|
|
1239
|
+
_iso_x0, _, _, _ = _iso_bbox(dwg)
|
|
1240
|
+
_iso_x_limit = _iso_x0 - 4
|
|
1241
|
+
for _rs in (a.fv_zones.right, a.pv_zones.right, a.sv_zones.right):
|
|
1242
|
+
if _iso_x_limit > _rs._cursor:
|
|
1243
|
+
_rs.outer_limit = min(_rs.outer_limit, _iso_x_limit)
|
|
1244
|
+
else:
|
|
1245
|
+
_log.warning(
|
|
1246
|
+
"right-strip cursor %.1f >= iso_x limit %.1f: right-strip dims"
|
|
1247
|
+
" may overlap iso view (iso view overflows into annotation zone)",
|
|
1248
|
+
_rs._cursor,
|
|
1249
|
+
_iso_x_limit,
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
# Overall height — slot reserved in fv_zones.right.
|
|
1253
|
+
# _right_ladder tracks the witness x for the progressive ladder: each
|
|
1254
|
+
# subsequent dim witnesses from the previous dim's line, so extension
|
|
1255
|
+
# lines are adjacent rather than coincident.
|
|
1256
|
+
_right_ladder = FX(a.bb.max.X) + 2
|
|
1257
|
+
_px = a.fv_zones.right.allocate(_SLOT_DIM_HEIGHT)
|
|
1258
|
+
if _px is not None:
|
|
1259
|
+
dwg.add(
|
|
1260
|
+
Dimension(
|
|
1261
|
+
(_right_ladder, FZ(a.bb.min.Z), 0),
|
|
1262
|
+
(_right_ladder, FZ(a.bb.max.Z), 0),
|
|
1263
|
+
"right",
|
|
1264
|
+
_px - _right_ladder,
|
|
1265
|
+
draft,
|
|
1266
|
+
label=_fmt(a.z_size),
|
|
1267
|
+
),
|
|
1268
|
+
"dim_height",
|
|
1269
|
+
)
|
|
1270
|
+
_right_ladder = _px
|
|
1271
|
+
else:
|
|
1272
|
+
_log.warning("dim_height skipped: fv_zones.right strip full")
|
|
1273
|
+
|
|
1274
|
+
# Outer diameter — only for rotational (turned) parts, and from the
|
|
1275
|
+
# classified external OD cylinder, never a bore that happens to be the
|
|
1276
|
+
# largest diameter (#81)
|
|
1277
|
+
if a.is_rotational:
|
|
1278
|
+
od = a.od_diam
|
|
1279
|
+
dwg.add(
|
|
1280
|
+
Dimension(
|
|
1281
|
+
(FX(a.cx - od / 2), FZ(a.bb.max.Z) + 2, 0),
|
|
1282
|
+
(FX(a.cx + od / 2), FZ(a.bb.max.Z) + 2, 0),
|
|
1283
|
+
"above",
|
|
1284
|
+
8,
|
|
1285
|
+
draft,
|
|
1286
|
+
label=f"ø{_fmt(od)}",
|
|
1287
|
+
),
|
|
1288
|
+
"dim_od",
|
|
1289
|
+
)
|
|
1290
|
+
# Centreline through the rotation axis — front and side views
|
|
1291
|
+
dwg.add(
|
|
1292
|
+
Centerline(
|
|
1293
|
+
(FX(a.cx), FZ(a.bb.min.Z) - 5, 0),
|
|
1294
|
+
(FX(a.cx), FZ(a.bb.max.Z) + 5, 0),
|
|
1295
|
+
),
|
|
1296
|
+
"centerline_front",
|
|
1297
|
+
)
|
|
1298
|
+
dwg.add(
|
|
1299
|
+
Centerline(
|
|
1300
|
+
(SX(a.cy), SZ(a.bb.min.Z) - 5, 0),
|
|
1301
|
+
(SX(a.cy), SZ(a.bb.max.Z) + 5, 0),
|
|
1302
|
+
),
|
|
1303
|
+
"centerline_side",
|
|
1304
|
+
)
|
|
1305
|
+
|
|
1306
|
+
# Z-axis bore leaders to the left of the front view — these assume bores
|
|
1307
|
+
# concentric with the rotation axis, so rotational only (#81)
|
|
1308
|
+
bores = [d for d in a.z_diams if d != a.od_diam]
|
|
1309
|
+
if a.is_rotational and bores:
|
|
1310
|
+
left_edge = FX(a.bb.min.X)
|
|
1311
|
+
left_space = left_edge - a.margin
|
|
1312
|
+
if left_space >= a.DIM_PAD:
|
|
1313
|
+
ldr_length = a.DIM_PAD * 0.6
|
|
1314
|
+
elbow_x = left_edge - ldr_length
|
|
1315
|
+
for i, d in enumerate(bores[:3]):
|
|
1316
|
+
tip_z = FZ(a.cz) + (i - 1) * 10
|
|
1317
|
+
dwg.add(
|
|
1318
|
+
Leader(
|
|
1319
|
+
tip=(FX(a.cx - d / 2), tip_z, 0),
|
|
1320
|
+
elbow=(elbow_x, tip_z, 0),
|
|
1321
|
+
label=f"ø{_fmt(d)}",
|
|
1322
|
+
draft=draft,
|
|
1323
|
+
),
|
|
1324
|
+
f"ldr_z{i}",
|
|
1325
|
+
)
|
|
1326
|
+
else:
|
|
1327
|
+
_log.info("Additional diameters %s not annotated (insufficient left margin)", bores)
|
|
1328
|
+
|
|
1329
|
+
# Per-hole annotations from the feature records (#91, #92, #95): each
|
|
1330
|
+
# hole is annotated in the view its axis is normal to.
|
|
1331
|
+
view_of_axis = {
|
|
1332
|
+
"z": ("plan", lambda h: (PX(h.location[0]), PY(h.location[1]))),
|
|
1333
|
+
"y": ("front", lambda h: (FX(h.location[0]), FZ(h.location[2]))),
|
|
1334
|
+
"x": ("side", lambda h: (SX(h.location[1]), SZ(h.location[2]))),
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
def _axis_letter(h):
|
|
1338
|
+
return max(zip("xyz", h.axis, strict=True), key=lambda t: abs(t[1]))[0]
|
|
1339
|
+
|
|
1340
|
+
# Centre marks for every hole (all part classes)
|
|
1341
|
+
for i, h in enumerate(a.holes):
|
|
1342
|
+
view, to_page = view_of_axis[_axis_letter(h)]
|
|
1343
|
+
size = max(2.5, h.diameter * a.SCALE + 2.0)
|
|
1344
|
+
dwg.add(CenterMark(to_page(h), size, draft), f"cm_{view}{i}")
|
|
1345
|
+
|
|
1346
|
+
# Hole callouts, locations, and sections — prismatic parts only; turned
|
|
1347
|
+
# parts keep dim_od/ldr_z
|
|
1348
|
+
if not a.is_rotational and a.holes:
|
|
1349
|
+
_annotate_holes(dwg, a, view_of_axis, _axis_letter, a.patterns)
|
|
1350
|
+
_add_location_dims(dwg, a, _axis_letter, a.patterns)
|
|
1351
|
+
|
|
1352
|
+
if a.cross_diams and a.is_rotational:
|
|
1353
|
+
_log.info(
|
|
1354
|
+
"Cross-hole ø%s detected but not annotated (requires section view)",
|
|
1355
|
+
_fmt(a.cross_diams[0]),
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
# Step heights — only where the step is tall enough to fit a label;
|
|
1359
|
+
# each step witnesses from the previous dim's line (_right_ladder) so
|
|
1360
|
+
# extension lines are adjacent rather than coincident
|
|
1361
|
+
for col, z in enumerate([z for z in a.step_zs[:3] if (z - a.bb.min.Z) * a.SCALE >= 20]):
|
|
1362
|
+
_px = a.fv_zones.right.allocate(_SLOT_DIM_STEP)
|
|
1363
|
+
if _px is None:
|
|
1364
|
+
_log.warning("dim_step_%d skipped: fv_zones.right strip full", col)
|
|
1365
|
+
break
|
|
1366
|
+
dwg.add(
|
|
1367
|
+
Dimension(
|
|
1368
|
+
(_right_ladder, FZ(a.bb.min.Z), 0),
|
|
1369
|
+
(_right_ladder, FZ(z), 0),
|
|
1370
|
+
"right",
|
|
1371
|
+
_px - _right_ladder,
|
|
1372
|
+
draft,
|
|
1373
|
+
label=_fmt(z - a.bb.min.Z),
|
|
1374
|
+
),
|
|
1375
|
+
f"dim_step_{col}",
|
|
1376
|
+
)
|
|
1377
|
+
_right_ladder = _px
|
|
1378
|
+
|
|
1379
|
+
# Width (non-round / non-square parts only) — routed through pv_zones.below
|
|
1380
|
+
if abs(a.x_size - a.y_size) > max(a.x_size, a.y_size) * 0.05:
|
|
1381
|
+
_below_witness = PY(a.bb.min.Y) - 2
|
|
1382
|
+
_py = a.pv_zones.below.allocate(_SLOT_DIM_WIDTH)
|
|
1383
|
+
if _py is not None:
|
|
1384
|
+
dwg.add(
|
|
1385
|
+
Dimension(
|
|
1386
|
+
(PX(a.bb.min.X), _below_witness, 0),
|
|
1387
|
+
(PX(a.bb.max.X), _below_witness, 0),
|
|
1388
|
+
"below",
|
|
1389
|
+
_below_witness - _py,
|
|
1390
|
+
draft,
|
|
1391
|
+
label=_fmt(a.x_size),
|
|
1392
|
+
),
|
|
1393
|
+
"dim_width",
|
|
1394
|
+
)
|
|
1395
|
+
else:
|
|
1396
|
+
_log.warning("dim_width skipped: pv_zones.below strip full")
|
|
1397
|
+
|
|
1398
|
+
# Depth (Y envelope) — same guard as dim_width; routed through sv_zones.below
|
|
1399
|
+
if abs(a.x_size - a.y_size) > max(a.x_size, a.y_size) * 0.05:
|
|
1400
|
+
_below_witness_d = SZ(a.bb.min.Z) - 2
|
|
1401
|
+
_pd = a.sv_zones.below.allocate(_SLOT_DIM_DEPTH)
|
|
1402
|
+
if _pd is not None:
|
|
1403
|
+
dwg.add(
|
|
1404
|
+
Dimension(
|
|
1405
|
+
(SX(a.bb.min.Y), _below_witness_d, 0),
|
|
1406
|
+
(SX(a.bb.max.Y), _below_witness_d, 0),
|
|
1407
|
+
"below",
|
|
1408
|
+
_below_witness_d - _pd,
|
|
1409
|
+
draft,
|
|
1410
|
+
label=_fmt(a.y_size),
|
|
1411
|
+
),
|
|
1412
|
+
"dim_depth",
|
|
1413
|
+
)
|
|
1414
|
+
else:
|
|
1415
|
+
_log.warning("dim_depth skipped: sv_zones.below strip full")
|
|
1416
|
+
|
|
1417
|
+
# The section view goes last: its room check clears every annotation
|
|
1418
|
+
# already placed right of the side view (callout labels, height/step
|
|
1419
|
+
# dim ladders)
|
|
1420
|
+
if not a.is_rotational and a.holes:
|
|
1421
|
+
_add_section_view(dwg, a, _axis_letter)
|
|
1422
|
+
|
|
1423
|
+
# Phase 7 — strip footprint debug logging + post-placement overflow check.
|
|
1424
|
+
# Overflow can only occur when outer_limit was tightened after allocations
|
|
1425
|
+
# were already committed (e.g. iso-x tightening or iso-y cap guard).
|
|
1426
|
+
_all_strips = [
|
|
1427
|
+
("fv.right", a.fv_zones.right),
|
|
1428
|
+
("fv.left", a.fv_zones.left),
|
|
1429
|
+
("fv.above", a.fv_zones.above),
|
|
1430
|
+
("fv.below", a.fv_zones.below),
|
|
1431
|
+
("pv.right", a.pv_zones.right),
|
|
1432
|
+
("pv.left", a.pv_zones.left),
|
|
1433
|
+
("pv.above", a.pv_zones.above),
|
|
1434
|
+
("pv.below", a.pv_zones.below),
|
|
1435
|
+
("sv.right", a.sv_zones.right),
|
|
1436
|
+
("sv.left", a.sv_zones.left),
|
|
1437
|
+
("sv.above", a.sv_zones.above),
|
|
1438
|
+
("sv.below", a.sv_zones.below),
|
|
1439
|
+
]
|
|
1440
|
+
for _sn, _st in _all_strips:
|
|
1441
|
+
if _st is None:
|
|
1442
|
+
continue
|
|
1443
|
+
_log.debug(
|
|
1444
|
+
"strip %-10s anchor=%.1f limit=%.1f used=%.1f/%.1f mm",
|
|
1445
|
+
_sn,
|
|
1446
|
+
_st.anchor,
|
|
1447
|
+
_st.outer_limit,
|
|
1448
|
+
_st.depth_used,
|
|
1449
|
+
_st.available,
|
|
1450
|
+
)
|
|
1451
|
+
# Overflow check: if at least one allocation was made, the end of the
|
|
1452
|
+
# last slot must not have exceeded outer_limit.
|
|
1453
|
+
_initial = _st.anchor + _st.direction * _st.gap
|
|
1454
|
+
if abs(_st._cursor - _initial) > 0.1: # at least one allocation
|
|
1455
|
+
_last_end = _st._cursor - _st.direction * _st.spacing
|
|
1456
|
+
_over = _st.direction * (_last_end - _st.outer_limit)
|
|
1457
|
+
if _over > 0.5:
|
|
1458
|
+
_log.warning(
|
|
1459
|
+
"strip %s overflowed outer_limit by %.1f mm "
|
|
1460
|
+
"(limit=%.1f, last-slot-end=%.1f) — limit was likely "
|
|
1461
|
+
"tightened after allocations were committed",
|
|
1462
|
+
_sn,
|
|
1463
|
+
_over,
|
|
1464
|
+
_st.outer_limit,
|
|
1465
|
+
_last_end,
|
|
1466
|
+
)
|
|
1467
|
+
|
|
1468
|
+
if getattr(a, "pmi_mode", "off") == "annotate":
|
|
1469
|
+
_annotate_pmi(dwg, a, draft)
|
|
1470
|
+
|
|
1471
|
+
_add_title_block(dwg, a)
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def _annotate_pmi(dwg, a, draft) -> None:
|
|
1475
|
+
"""Add PMI-derived dimension annotations to *dwg* using remaining strip space.
|
|
1476
|
+
|
|
1477
|
+
Called from ``_auto_annotate`` after all automatic dimensions are placed so
|
|
1478
|
+
PMI dims consume the strips' leftover capacity. Skips records whose page
|
|
1479
|
+
projection is degenerate (< 3 mm span) or whose extension lines would exceed
|
|
1480
|
+
twice the nominal value.
|
|
1481
|
+
|
|
1482
|
+
View assignment:
|
|
1483
|
+
- dominant X → front view, fv_zones.above / fv_zones.below
|
|
1484
|
+
- dominant Z → front view, fv_zones.right / fv_zones.left
|
|
1485
|
+
- dominant Y → side view, sv_zones.above / sv_zones.below
|
|
1486
|
+
(falls back to pv_zones.below for Y dims that are
|
|
1487
|
+
too compressed in the side view)
|
|
1488
|
+
"""
|
|
1489
|
+
pmi = getattr(a, "pmi", [])
|
|
1490
|
+
usable = [r for r in pmi if r.value > 0 and len(r.ref_pts) >= 2]
|
|
1491
|
+
n_gtol = sum(
|
|
1492
|
+
1
|
|
1493
|
+
for r in pmi
|
|
1494
|
+
if r.kind
|
|
1495
|
+
not in (
|
|
1496
|
+
"linear",
|
|
1497
|
+
"diameter",
|
|
1498
|
+
"radius",
|
|
1499
|
+
"angular",
|
|
1500
|
+
"curved_dist",
|
|
1501
|
+
"oriented",
|
|
1502
|
+
"curve_length",
|
|
1503
|
+
"thickness",
|
|
1504
|
+
"label",
|
|
1505
|
+
"presentation",
|
|
1506
|
+
)
|
|
1507
|
+
and r.value > 0
|
|
1508
|
+
)
|
|
1509
|
+
if n_gtol:
|
|
1510
|
+
_log.debug("PMI annotate: %d gtol/datum record(s) not yet annotatable (Phase 4)", n_gtol)
|
|
1511
|
+
if not usable:
|
|
1512
|
+
_log.info("PMI annotate: no usable records (value>0 with 2+ ref pts)")
|
|
1513
|
+
return
|
|
1514
|
+
|
|
1515
|
+
def FX(x):
|
|
1516
|
+
return a.FV_X + (x - a.cx) * a.SCALE
|
|
1517
|
+
|
|
1518
|
+
def FZ(z):
|
|
1519
|
+
return a.FV_Y + (z - a.cz) * a.SCALE
|
|
1520
|
+
|
|
1521
|
+
def SX(y):
|
|
1522
|
+
return a.SV_X + (y - a.cy) * a.SCALE
|
|
1523
|
+
|
|
1524
|
+
def SZ(z):
|
|
1525
|
+
return a.SV_Y + (z - a.cz) * a.SCALE
|
|
1526
|
+
|
|
1527
|
+
def PX(x):
|
|
1528
|
+
return a.PV_X + (x - a.cx) * a.SCALE
|
|
1529
|
+
|
|
1530
|
+
def PY(y):
|
|
1531
|
+
return a.PV_Y + (y - a.cy) * a.SCALE
|
|
1532
|
+
|
|
1533
|
+
_SLOT = 10.0 # mm — slot size for PMI dim lines in the strip
|
|
1534
|
+
|
|
1535
|
+
def _bore_info(rec):
|
|
1536
|
+
"""For Size_Diameter / Size_Radius records, return (bore_axis, cx, cy, cz).
|
|
1537
|
+
|
|
1538
|
+
bore_axis is the bbox's LONGEST extent (the bore's depth direction).
|
|
1539
|
+
Reuses rec.dominant_axis set by extract_pmi; falls back to re-sorting
|
|
1540
|
+
the bbox spans only when dominant_axis is '?' (degenerate bbox).
|
|
1541
|
+
The diameter/radius is then placed perpendicular to the bore axis in the
|
|
1542
|
+
view where the bore appears as a circle. Returns None if ref_bbox absent.
|
|
1543
|
+
"""
|
|
1544
|
+
bb = rec.ref_bbox
|
|
1545
|
+
if bb is None:
|
|
1546
|
+
return None
|
|
1547
|
+
bore_axis = rec.dominant_axis
|
|
1548
|
+
if bore_axis == "?":
|
|
1549
|
+
xmin, ymin, zmin, xmax, ymax, zmax = bb
|
|
1550
|
+
spans = sorted(
|
|
1551
|
+
[("X", abs(xmax - xmin)), ("Y", abs(ymax - ymin)), ("Z", abs(zmax - zmin))],
|
|
1552
|
+
key=lambda t: t[1],
|
|
1553
|
+
reverse=True,
|
|
1554
|
+
)
|
|
1555
|
+
bore_axis = spans[0][0]
|
|
1556
|
+
cx_f = sum(p[0] for p in rec.ref_pts) / len(rec.ref_pts) if rec.ref_pts else 0.0
|
|
1557
|
+
cy_f = sum(p[1] for p in rec.ref_pts) / len(rec.ref_pts) if rec.ref_pts else 0.0
|
|
1558
|
+
cz_f = sum(p[2] for p in rec.ref_pts) / len(rec.ref_pts) if rec.ref_pts else 0.0
|
|
1559
|
+
return bore_axis, cx_f, cy_f, cz_f
|
|
1560
|
+
|
|
1561
|
+
def _witness_from_bbox(rec, view: str):
|
|
1562
|
+
"""Witness points from the outer edges of the combined reference bbox.
|
|
1563
|
+
|
|
1564
|
+
Gives the correct span for linear dims where both ref faces are flush
|
|
1565
|
+
(e.g. two parallel faces of a slot or step). Not suitable for bore
|
|
1566
|
+
diameters — use _bore_info instead.
|
|
1567
|
+
"""
|
|
1568
|
+
bb = rec.ref_bbox
|
|
1569
|
+
if bb is None:
|
|
1570
|
+
return None
|
|
1571
|
+
xmin, ymin, zmin, xmax, ymax, zmax = bb
|
|
1572
|
+
ax = rec.dominant_axis
|
|
1573
|
+
|
|
1574
|
+
if view == "front" and ax == "X":
|
|
1575
|
+
p1 = (FX(xmin), FZ((zmin + zmax) / 2), 0)
|
|
1576
|
+
p2 = (FX(xmax), FZ((zmin + zmax) / 2), 0)
|
|
1577
|
+
avg_t = FZ((zmin + zmax) / 2)
|
|
1578
|
+
elif view == "front" and ax == "Z":
|
|
1579
|
+
p1 = (FX((xmin + xmax) / 2), FZ(zmin), 0)
|
|
1580
|
+
p2 = (FX((xmin + xmax) / 2), FZ(zmax), 0)
|
|
1581
|
+
avg_t = FX((xmin + xmax) / 2)
|
|
1582
|
+
elif view == "side" and ax == "Y":
|
|
1583
|
+
p1 = (SX(ymin), SZ((zmin + zmax) / 2), 0)
|
|
1584
|
+
p2 = (SX(ymax), SZ((zmin + zmax) / 2), 0)
|
|
1585
|
+
avg_t = SZ((zmin + zmax) / 2)
|
|
1586
|
+
elif view == "plan" and ax == "Y":
|
|
1587
|
+
avg_x = (xmin + xmax) / 2
|
|
1588
|
+
p1 = (PX(avg_x), PY(ymin), 0)
|
|
1589
|
+
p2 = (PX(avg_x), PY(ymax), 0)
|
|
1590
|
+
avg_t = PX(avg_x)
|
|
1591
|
+
else:
|
|
1592
|
+
return None
|
|
1593
|
+
|
|
1594
|
+
span = math.hypot(p2[0] - p1[0], p2[1] - p1[1])
|
|
1595
|
+
if span < 3:
|
|
1596
|
+
return None
|
|
1597
|
+
return p1, p2, avg_t
|
|
1598
|
+
|
|
1599
|
+
def _try_above(p1, p2, strip, label, name):
|
|
1600
|
+
"""Place a horizontal dimension line ABOVE the witness points."""
|
|
1601
|
+
if strip is None:
|
|
1602
|
+
return False
|
|
1603
|
+
witness_y = max(p1[1], p2[1]) + 2
|
|
1604
|
+
if strip.peek(_SLOT) is None or strip.peek(_SLOT) <= witness_y:
|
|
1605
|
+
return False
|
|
1606
|
+
slot = strip.allocate(_SLOT)
|
|
1607
|
+
dwg.add(
|
|
1608
|
+
Dimension(
|
|
1609
|
+
(p1[0], witness_y, 0),
|
|
1610
|
+
(p2[0], witness_y, 0),
|
|
1611
|
+
"above",
|
|
1612
|
+
slot - witness_y,
|
|
1613
|
+
draft,
|
|
1614
|
+
label=label,
|
|
1615
|
+
),
|
|
1616
|
+
name,
|
|
1617
|
+
)
|
|
1618
|
+
return True
|
|
1619
|
+
|
|
1620
|
+
def _try_below(p1, p2, strip, label, name):
|
|
1621
|
+
"""Place a horizontal dimension line BELOW the witness points."""
|
|
1622
|
+
if strip is None:
|
|
1623
|
+
return False
|
|
1624
|
+
witness_y = min(p1[1], p2[1]) - 2
|
|
1625
|
+
if strip.peek(_SLOT) is None or strip.peek(_SLOT) >= witness_y:
|
|
1626
|
+
return False
|
|
1627
|
+
slot = strip.allocate(_SLOT)
|
|
1628
|
+
dwg.add(
|
|
1629
|
+
Dimension(
|
|
1630
|
+
(p1[0], witness_y, 0),
|
|
1631
|
+
(p2[0], witness_y, 0),
|
|
1632
|
+
"below",
|
|
1633
|
+
witness_y - slot,
|
|
1634
|
+
draft,
|
|
1635
|
+
label=label,
|
|
1636
|
+
),
|
|
1637
|
+
name,
|
|
1638
|
+
)
|
|
1639
|
+
return True
|
|
1640
|
+
|
|
1641
|
+
def _try_right(p1, p2, strip, label, name):
|
|
1642
|
+
"""Place a vertical dimension line to the RIGHT of the witness points."""
|
|
1643
|
+
if strip is None:
|
|
1644
|
+
return False
|
|
1645
|
+
witness_x = max(p1[0], p2[0]) + 2
|
|
1646
|
+
if strip.peek(_SLOT) is None or strip.peek(_SLOT) <= witness_x:
|
|
1647
|
+
return False
|
|
1648
|
+
slot = strip.allocate(_SLOT)
|
|
1649
|
+
dwg.add(
|
|
1650
|
+
Dimension(
|
|
1651
|
+
(witness_x, p1[1], 0),
|
|
1652
|
+
(witness_x, p2[1], 0),
|
|
1653
|
+
"right",
|
|
1654
|
+
slot - witness_x,
|
|
1655
|
+
draft,
|
|
1656
|
+
label=label,
|
|
1657
|
+
),
|
|
1658
|
+
name,
|
|
1659
|
+
)
|
|
1660
|
+
return True
|
|
1661
|
+
|
|
1662
|
+
def _try_left(p1, p2, strip, label, name):
|
|
1663
|
+
"""Place a vertical dimension line to the LEFT of the witness points."""
|
|
1664
|
+
if strip is None:
|
|
1665
|
+
return False
|
|
1666
|
+
witness_x = min(p1[0], p2[0]) - 2
|
|
1667
|
+
if strip.peek(_SLOT) is None or strip.peek(_SLOT) >= witness_x:
|
|
1668
|
+
return False
|
|
1669
|
+
slot = strip.allocate(_SLOT)
|
|
1670
|
+
dwg.add(
|
|
1671
|
+
Dimension(
|
|
1672
|
+
(witness_x, p1[1], 0),
|
|
1673
|
+
(witness_x, p2[1], 0),
|
|
1674
|
+
"left",
|
|
1675
|
+
witness_x - slot,
|
|
1676
|
+
draft,
|
|
1677
|
+
label=label,
|
|
1678
|
+
),
|
|
1679
|
+
name,
|
|
1680
|
+
)
|
|
1681
|
+
return True
|
|
1682
|
+
return False
|
|
1683
|
+
|
|
1684
|
+
emitted = 0
|
|
1685
|
+
for idx, rec in enumerate(usable):
|
|
1686
|
+
ax = rec.dominant_axis
|
|
1687
|
+
label = rec.label
|
|
1688
|
+
placed = False
|
|
1689
|
+
name_x = f"pmi_x_{idx}"
|
|
1690
|
+
name_z = f"pmi_z_{idx}"
|
|
1691
|
+
name_y = f"pmi_y_{idx}"
|
|
1692
|
+
name_d = f"pmi_d_{idx}"
|
|
1693
|
+
|
|
1694
|
+
if rec.kind in ("diameter", "radius"):
|
|
1695
|
+
# --- Bore size: centroid ± value/2 perpendicular to bore axis ---
|
|
1696
|
+
info = _bore_info(rec)
|
|
1697
|
+
if info is None:
|
|
1698
|
+
_log.debug("PMI dim[%d] diam: no ref_bbox, skip", idx)
|
|
1699
|
+
continue
|
|
1700
|
+
bore_axis, cx_f, cy_f, cz_f = info
|
|
1701
|
+
half = rec.value / 2 if rec.kind == "diameter" else rec.value
|
|
1702
|
+
|
|
1703
|
+
# Bore diameter page span = diameter × scale. When the span is
|
|
1704
|
+
# narrower than ~8 mm the centred label text overflows the gap
|
|
1705
|
+
# and the extension lines punch through it. Use a Leader
|
|
1706
|
+
# (arrowhead at bore edge, text on a horizontal shelf) for
|
|
1707
|
+
# narrow bores; bracket dims only when span fits the text.
|
|
1708
|
+
half_pg = half * a.SCALE # bore radius on page (mm)
|
|
1709
|
+
|
|
1710
|
+
if bore_axis == "Z":
|
|
1711
|
+
# Z-axis bore: circle visible in plan view.
|
|
1712
|
+
if half_pg >= 4.0:
|
|
1713
|
+
p1 = (PX(cx_f - half), PY(cy_f), 0)
|
|
1714
|
+
p2 = (PX(cx_f + half), PY(cy_f), 0)
|
|
1715
|
+
placed = _try_above(p1, p2, a.pv_zones.above, label, name_d) or _try_below(
|
|
1716
|
+
p1, p2, a.pv_zones.below, label, name_d
|
|
1717
|
+
)
|
|
1718
|
+
else:
|
|
1719
|
+
tip = (PX(cx_f), PY(cy_f) + half_pg, 0)
|
|
1720
|
+
slot = a.pv_zones.above.allocate(_SLOT)
|
|
1721
|
+
if slot is not None:
|
|
1722
|
+
dwg.add(Leader(tip, (PX(cx_f), slot, 0), label, draft), name_d)
|
|
1723
|
+
placed = True
|
|
1724
|
+
else:
|
|
1725
|
+
slot = a.pv_zones.below.allocate(_SLOT)
|
|
1726
|
+
if slot is not None:
|
|
1727
|
+
tip = (PX(cx_f), PY(cy_f) - half_pg, 0)
|
|
1728
|
+
dwg.add(Leader(tip, (PX(cx_f), slot, 0), label, draft), name_d)
|
|
1729
|
+
placed = True
|
|
1730
|
+
|
|
1731
|
+
elif bore_axis == "X":
|
|
1732
|
+
# X-axis bore: circle visible in side view.
|
|
1733
|
+
if half_pg >= 4.0:
|
|
1734
|
+
p1 = (SX(cy_f - half), SZ(cz_f), 0)
|
|
1735
|
+
p2 = (SX(cy_f + half), SZ(cz_f), 0)
|
|
1736
|
+
placed = _try_above(p1, p2, a.sv_zones.above, label, name_d) or _try_below(
|
|
1737
|
+
p1, p2, a.sv_zones.below, label, name_d
|
|
1738
|
+
)
|
|
1739
|
+
else:
|
|
1740
|
+
tip = (SX(cy_f), SZ(cz_f) + half_pg, 0)
|
|
1741
|
+
slot = a.sv_zones.above.allocate(_SLOT)
|
|
1742
|
+
if slot is not None:
|
|
1743
|
+
dwg.add(Leader(tip, (SX(cy_f), slot, 0), label, draft), name_d)
|
|
1744
|
+
placed = True
|
|
1745
|
+
else:
|
|
1746
|
+
slot = a.sv_zones.below.allocate(_SLOT)
|
|
1747
|
+
if slot is not None:
|
|
1748
|
+
tip = (SX(cy_f), SZ(cz_f) - half_pg, 0)
|
|
1749
|
+
dwg.add(Leader(tip, (SX(cy_f), slot, 0), label, draft), name_d)
|
|
1750
|
+
placed = True
|
|
1751
|
+
|
|
1752
|
+
elif bore_axis == "Y":
|
|
1753
|
+
# Y-axis bore: circle visible in front view as a circle.
|
|
1754
|
+
if half_pg >= 4.0:
|
|
1755
|
+
p1 = (FX(cx_f - half), FZ(cz_f), 0)
|
|
1756
|
+
p2 = (FX(cx_f + half), FZ(cz_f), 0)
|
|
1757
|
+
placed = _try_above(p1, p2, a.fv_zones.above, label, name_d) or _try_below(
|
|
1758
|
+
p1, p2, a.fv_zones.below, label, name_d
|
|
1759
|
+
)
|
|
1760
|
+
else:
|
|
1761
|
+
# Narrow bore: leader from bore bottom into the below strip.
|
|
1762
|
+
tip = (FX(cx_f), FZ(cz_f) - half_pg, 0)
|
|
1763
|
+
slot = a.fv_zones.below.allocate(_SLOT)
|
|
1764
|
+
if slot is not None:
|
|
1765
|
+
elbow = (FX(cx_f), slot, 0)
|
|
1766
|
+
dwg.add(Leader(tip, elbow, label, draft), name_d)
|
|
1767
|
+
placed = True
|
|
1768
|
+
else:
|
|
1769
|
+
# Fall back: leader upward into the above strip.
|
|
1770
|
+
slot = a.fv_zones.above.allocate(_SLOT)
|
|
1771
|
+
if slot is not None:
|
|
1772
|
+
tip = (FX(cx_f), FZ(cz_f) + half_pg, 0)
|
|
1773
|
+
elbow = (FX(cx_f), slot, 0)
|
|
1774
|
+
dwg.add(Leader(tip, elbow, label, draft), name_d)
|
|
1775
|
+
placed = True
|
|
1776
|
+
|
|
1777
|
+
elif ax == "X":
|
|
1778
|
+
wp = _witness_from_bbox(rec, "front")
|
|
1779
|
+
if wp is None:
|
|
1780
|
+
_log.debug("PMI dim[%d] X: degenerate bbox", idx)
|
|
1781
|
+
continue
|
|
1782
|
+
p1, p2, avg_pz = wp
|
|
1783
|
+
if avg_pz >= a.FV_Y:
|
|
1784
|
+
placed = _try_above(p1, p2, a.fv_zones.above, label, name_x)
|
|
1785
|
+
if not placed:
|
|
1786
|
+
placed = _try_below(p1, p2, a.fv_zones.below, label, name_x)
|
|
1787
|
+
|
|
1788
|
+
elif ax == "Z":
|
|
1789
|
+
wp = _witness_from_bbox(rec, "front")
|
|
1790
|
+
if wp is None:
|
|
1791
|
+
_log.debug("PMI dim[%d] Z: degenerate bbox", idx)
|
|
1792
|
+
continue
|
|
1793
|
+
p1, p2, avg_px = wp
|
|
1794
|
+
if avg_px >= a.FV_X:
|
|
1795
|
+
placed = _try_right(p1, p2, a.fv_zones.right, label, name_z)
|
|
1796
|
+
if not placed:
|
|
1797
|
+
placed = _try_left(p1, p2, a.fv_zones.left, label, name_z)
|
|
1798
|
+
|
|
1799
|
+
elif ax == "Y":
|
|
1800
|
+
# Try side view (Y maps to SX horizontal).
|
|
1801
|
+
wp = _witness_from_bbox(rec, "side")
|
|
1802
|
+
if wp is not None:
|
|
1803
|
+
p1, p2, avg_sz = wp
|
|
1804
|
+
if avg_sz >= a.SV_Y:
|
|
1805
|
+
placed = _try_above(p1, p2, a.sv_zones.above, label, name_y)
|
|
1806
|
+
if not placed:
|
|
1807
|
+
placed = _try_below(p1, p2, a.sv_zones.below, label, name_y)
|
|
1808
|
+
# Fall back: plan view (Y maps to PY vertical).
|
|
1809
|
+
if not placed:
|
|
1810
|
+
wp = _witness_from_bbox(rec, "plan")
|
|
1811
|
+
if wp is not None:
|
|
1812
|
+
p1, p2, _ = wp
|
|
1813
|
+
placed = _try_below(p1, p2, a.pv_zones.below, label, name_y)
|
|
1814
|
+
|
|
1815
|
+
if placed:
|
|
1816
|
+
emitted += 1
|
|
1817
|
+
_log.info("PMI dim[%d] %s %.3g → annotated (%s)", idx, ax, rec.value, label)
|
|
1818
|
+
else:
|
|
1819
|
+
_log.info("PMI dim[%d] %s %.3g → no strip space", idx, ax, rec.value)
|
|
1820
|
+
|
|
1821
|
+
_log.info("PMI annotate: %d/%d dims placed", emitted, len(usable))
|
|
1822
|
+
|
|
1823
|
+
|
|
1824
|
+
_MAX_CALLOUTS_PER_VIEW = 4
|
|
1825
|
+
|
|
1826
|
+
|
|
1827
|
+
_MAX_LOCATION_REFS = 4
|
|
1828
|
+
|
|
1829
|
+
|
|
1830
|
+
def _add_location_dims(dwg, a, axis_letter, patterns):
|
|
1831
|
+
"""Baseline X/Y location dimensions in the plan view (#93).
|
|
1832
|
+
|
|
1833
|
+
The datum corner is a *default* — the part's minimum-X/minimum-Y corner
|
|
1834
|
+
(lower-left in the plan view), per inspection practice; a human/LLM pass
|
|
1835
|
+
can re-anchor it. One reference per pattern (bolt-circle centre, array
|
|
1836
|
+
first hole) plus each unpatterned hole, capped at
|
|
1837
|
+
``_MAX_LOCATION_REFS`` (largest holes first, the rest logged). X dims
|
|
1838
|
+
tier above the plan view (below sit dim_width and the front view),
|
|
1839
|
+
Y dims tier to its left; a tier that would leave the page is skipped,
|
|
1840
|
+
never force-placed. Cross-axis holes are not located yet (logged).
|
|
1841
|
+
"""
|
|
1842
|
+
draft = dwg.draft
|
|
1843
|
+
z_holes = [h for h in a.holes if axis_letter(h) == "z"]
|
|
1844
|
+
if len(z_holes) < len(a.holes):
|
|
1845
|
+
_log.info("Cross-axis holes present; their locations are not auto-dimensioned")
|
|
1846
|
+
patterned = {h for p in patterns for h in p.holes}
|
|
1847
|
+
refs = [] # (world_x, world_y, sort_diameter)
|
|
1848
|
+
for p in patterns:
|
|
1849
|
+
if axis_letter(p.holes[0]) != "z":
|
|
1850
|
+
continue
|
|
1851
|
+
if isinstance(p, BoltCircle):
|
|
1852
|
+
refs.append((p.center[0], p.center[1], p.holes[0].diameter))
|
|
1853
|
+
else:
|
|
1854
|
+
# locate the array's member nearest the datum corner — the pitch
|
|
1855
|
+
# dim chains the rest outward (shortest baseline, per practice)
|
|
1856
|
+
near = min(
|
|
1857
|
+
p.holes,
|
|
1858
|
+
key=lambda h: (
|
|
1859
|
+
(h.location[0] - a.bb.min.X) ** 2 + (h.location[1] - a.bb.min.Y) ** 2
|
|
1860
|
+
),
|
|
1861
|
+
)
|
|
1862
|
+
refs.append((near.location[0], near.location[1], near.diameter))
|
|
1863
|
+
refs += [(h.location[0], h.location[1], h.diameter) for h in z_holes if h not in patterned]
|
|
1864
|
+
# dedupe coincident references (e.g. a hole at a bolt-circle's centre)
|
|
1865
|
+
unique: list = []
|
|
1866
|
+
for r in refs:
|
|
1867
|
+
if not any(abs(r[0] - u[0]) < 0.5 and abs(r[1] - u[1]) < 0.5 for u in unique):
|
|
1868
|
+
unique.append(r)
|
|
1869
|
+
refs = unique
|
|
1870
|
+
if not refs:
|
|
1871
|
+
return
|
|
1872
|
+
if len(refs) > _MAX_LOCATION_REFS:
|
|
1873
|
+
refs.sort(key=lambda r: r[2], reverse=True)
|
|
1874
|
+
_log.info(
|
|
1875
|
+
"%d location references; dimensioning the %d largest",
|
|
1876
|
+
len(refs),
|
|
1877
|
+
_MAX_LOCATION_REFS,
|
|
1878
|
+
)
|
|
1879
|
+
refs = refs[:_MAX_LOCATION_REFS]
|
|
1880
|
+
|
|
1881
|
+
def PX(x):
|
|
1882
|
+
return a.PV_X + (x - a.cx) * a.SCALE
|
|
1883
|
+
|
|
1884
|
+
def PY(y):
|
|
1885
|
+
return a.PV_Y + (y - a.cy) * a.SCALE
|
|
1886
|
+
|
|
1887
|
+
plan_top = PY(a.bb.max.Y)
|
|
1888
|
+
datum_x, datum_y = a.bb.min.X, a.bb.min.Y
|
|
1889
|
+
tier = draft.font_size * 3.0
|
|
1890
|
+
|
|
1891
|
+
# X locations: dims above the plan view, routed through pv_zones.above.
|
|
1892
|
+
# Pre-advance the strip past any pitch dims already placed above plan_top.
|
|
1893
|
+
x_refs: list = []
|
|
1894
|
+
for r in refs:
|
|
1895
|
+
if not any(abs(r[0] - u[0]) < 0.5 for u in x_refs):
|
|
1896
|
+
x_refs.append(r)
|
|
1897
|
+
for n, ann in dwg._named.items():
|
|
1898
|
+
if n.startswith("dim_pitch_plan") and getattr(ann, "dim_level_y", 0) > plan_top:
|
|
1899
|
+
a.pv_zones.above.allocate(10.0) # consume space used by pitch dim
|
|
1900
|
+
for i, (rx, ry, _) in enumerate(sorted(x_refs, key=lambda r: abs(r[0] - datum_x))):
|
|
1901
|
+
if abs(rx - datum_x) * a.SCALE < 1.0:
|
|
1902
|
+
continue # on the datum edge — nothing to dimension
|
|
1903
|
+
_py = a.pv_zones.above.allocate(tier)
|
|
1904
|
+
if _py is None:
|
|
1905
|
+
_log.info("X location dim for x=%s skipped (no room above plan view)", _fmt(rx))
|
|
1906
|
+
continue
|
|
1907
|
+
dwg.add(
|
|
1908
|
+
Dimension(
|
|
1909
|
+
(PX(datum_x), PY(ry), 0),
|
|
1910
|
+
(PX(rx), PY(ry), 0),
|
|
1911
|
+
"above",
|
|
1912
|
+
_py - PY(ry),
|
|
1913
|
+
draft,
|
|
1914
|
+
label=_fmt(rx - datum_x),
|
|
1915
|
+
),
|
|
1916
|
+
f"dim_locx{i}",
|
|
1917
|
+
)
|
|
1918
|
+
|
|
1919
|
+
# Y locations: the side view maps world Y horizontally, and the strip
|
|
1920
|
+
# above it is open (the plan view's left margin fits barely one tier) —
|
|
1921
|
+
# dims go above the side view, witness lines rising from its top edge at
|
|
1922
|
+
# each hole's axis position
|
|
1923
|
+
def SX(y):
|
|
1924
|
+
return a.SV_X + (y - a.cy) * a.SCALE
|
|
1925
|
+
|
|
1926
|
+
def SZ(z):
|
|
1927
|
+
return a.SV_Y + (z - a.cz) * a.SCALE
|
|
1928
|
+
|
|
1929
|
+
side_top = SZ(a.bb.max.Z)
|
|
1930
|
+
iso_x0, iso_y0, _, _ = _iso_bbox(dwg)
|
|
1931
|
+
y_refs: list = []
|
|
1932
|
+
for rx, ry, dia in refs:
|
|
1933
|
+
if not any(abs(ry - u[1]) < 0.5 for u in y_refs):
|
|
1934
|
+
y_refs.append((rx, ry, dia))
|
|
1935
|
+
# Y locations: dims above the side view, routed through sv_zones.above.
|
|
1936
|
+
# Pre-advance past any pitch dims already placed above side_top.
|
|
1937
|
+
for n, ann in dwg._named.items():
|
|
1938
|
+
if n.startswith("dim_pitch_side") and getattr(ann, "dim_level_y", 0) > side_top:
|
|
1939
|
+
a.sv_zones.above.allocate(10.0) # consume space used by pitch dim
|
|
1940
|
+
# Tighten outer_limit if any witness line approaches the iso view boundary.
|
|
1941
|
+
# Guard: only cap if iso_y0-4 is above the strip's current cursor — an iso
|
|
1942
|
+
# view that overflows left (too large to fit) can have iso_y0 below
|
|
1943
|
+
# sv_top_edge, which would make all allocations return None if applied.
|
|
1944
|
+
if y_refs and any(SX(ry) + 10 > iso_x0 - 4 for _, ry, _ in y_refs):
|
|
1945
|
+
cap = iso_y0 - 4
|
|
1946
|
+
above = a.sv_zones.above
|
|
1947
|
+
if cap > above._cursor:
|
|
1948
|
+
above.outer_limit = min(above.outer_limit, cap)
|
|
1949
|
+
else:
|
|
1950
|
+
_log.warning(
|
|
1951
|
+
"sv_zones.above cursor %.1f >= iso_y0 cap %.1f: Y-location dims may overlap iso view",
|
|
1952
|
+
above._cursor,
|
|
1953
|
+
cap,
|
|
1954
|
+
)
|
|
1955
|
+
for i, (_rx, ry, _) in enumerate(sorted(y_refs, key=lambda r: abs(r[1] - datum_y))):
|
|
1956
|
+
if abs(ry - datum_y) * a.SCALE < 1.0:
|
|
1957
|
+
continue
|
|
1958
|
+
_py = a.sv_zones.above.allocate(tier)
|
|
1959
|
+
if _py is None:
|
|
1960
|
+
_log.info("Y location dim for y=%s skipped (no room above the side view)", _fmt(ry))
|
|
1961
|
+
continue
|
|
1962
|
+
dwg.add(
|
|
1963
|
+
Dimension(
|
|
1964
|
+
(SX(datum_y), SZ(a.bb.max.Z), 0),
|
|
1965
|
+
(SX(ry), SZ(a.bb.max.Z), 0),
|
|
1966
|
+
"above",
|
|
1967
|
+
_py - side_top,
|
|
1968
|
+
draft,
|
|
1969
|
+
label=_fmt(ry - datum_y),
|
|
1970
|
+
),
|
|
1971
|
+
f"dim_locy{i}",
|
|
1972
|
+
)
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
def _section_hatch_edges(face, SX, SZ, spacing):
|
|
1976
|
+
"""Return 45° ISO 128-50 hatch Edge objects for one cut face in page coords.
|
|
1977
|
+
|
|
1978
|
+
Uses the even-odd rule: all boundary wires (outer + inner) are traversed;
|
|
1979
|
+
intersections of each hatch line with the boundary are sorted and filled in
|
|
1980
|
+
alternating spans. Curved edges are tessellated to straight segments so
|
|
1981
|
+
circular hole outlines clip correctly.
|
|
1982
|
+
"""
|
|
1983
|
+
segs = []
|
|
1984
|
+
for wire in [face.outer_wire()] + list(face.inner_wires()):
|
|
1985
|
+
for edge in wire.edges():
|
|
1986
|
+
if edge.geom_type == GeomType.LINE:
|
|
1987
|
+
pts = [edge.position_at(0), edge.position_at(1)]
|
|
1988
|
+
else:
|
|
1989
|
+
n = max(8, int(edge.length / spacing) + 1)
|
|
1990
|
+
pts = [edge.position_at(i / n) for i in range(n + 1)]
|
|
1991
|
+
ppts = [(SX(v.X), SZ(v.Z)) for v in pts]
|
|
1992
|
+
for j in range(len(ppts) - 1):
|
|
1993
|
+
segs.append((ppts[j], ppts[j + 1]))
|
|
1994
|
+
|
|
1995
|
+
if not segs:
|
|
1996
|
+
return []
|
|
1997
|
+
|
|
1998
|
+
all_xs = [p[0] for s in segs for p in s]
|
|
1999
|
+
all_ys = [p[1] for s in segs for p in s]
|
|
2000
|
+
# 45° lines satisfy y − x = c; step by spacing (perpendicular to lines)
|
|
2001
|
+
step = spacing
|
|
2002
|
+
c_min = min(all_ys) - max(all_xs) - step
|
|
2003
|
+
c_max = max(all_ys) - min(all_xs) + step
|
|
2004
|
+
|
|
2005
|
+
result = []
|
|
2006
|
+
c = c_min + step
|
|
2007
|
+
while c < c_max:
|
|
2008
|
+
hits = []
|
|
2009
|
+
for (x1, y1), (x2, y2) in segs:
|
|
2010
|
+
denom = (y2 - y1) - (x2 - x1)
|
|
2011
|
+
if abs(denom) < 1e-9:
|
|
2012
|
+
continue
|
|
2013
|
+
t = (c - (y1 - x1)) / denom
|
|
2014
|
+
if -1e-6 <= t < 1 - 1e-6: # half-open: each shared vertex counted once
|
|
2015
|
+
hits.append(x1 + t * (x2 - x1))
|
|
2016
|
+
hits.sort()
|
|
2017
|
+
for i in range(0, len(hits) - 1, 2):
|
|
2018
|
+
xa, xb = hits[i], hits[i + 1]
|
|
2019
|
+
if xb - xa > 0.2:
|
|
2020
|
+
result.append(Edge.make_line(Vector(xa, xa + c, 0), Vector(xb, xb + c, 0)))
|
|
2021
|
+
c += step
|
|
2022
|
+
return result
|
|
2023
|
+
|
|
2024
|
+
|
|
2025
|
+
def _add_section_view(dwg, a, axis_letter):
|
|
2026
|
+
"""Full section A–A when blind or stepped holes hide their structure (#94).
|
|
2027
|
+
|
|
2028
|
+
Trigger: any Z-axis hole with a counterbore/spotface or a non-through
|
|
2029
|
+
bottom — its internal profile is hidden-line-only in every standard
|
|
2030
|
+
view. The cut plane passes through the densest row of qualifying hole
|
|
2031
|
+
axes, parallel to the front view; material on the viewer's side is
|
|
2032
|
+
removed so the cut face shows the hole profiles as visible line-work.
|
|
2033
|
+
The section is placed right of the side view when there is room
|
|
2034
|
+
(skipped with a log otherwise), captioned, marked with ISO 128-44
|
|
2035
|
+
cutting-plane arrows and 'A' letters on the plan view, and filled with
|
|
2036
|
+
ISO 128-50 45° hatching on the cut face.
|
|
2037
|
+
"""
|
|
2038
|
+
cands = [
|
|
2039
|
+
h
|
|
2040
|
+
for h in a.holes
|
|
2041
|
+
if axis_letter(h) == "z" and (h.cbore or h.spotface or h.bottom != "through")
|
|
2042
|
+
]
|
|
2043
|
+
if not cands:
|
|
2044
|
+
return
|
|
2045
|
+
ys = [h.location[1] for h in cands]
|
|
2046
|
+
y_star = max(
|
|
2047
|
+
{round(y, 1) for y in ys},
|
|
2048
|
+
key=lambda v: (sum(1 for y in ys if abs(y - v) <= 0.5), -abs(v - a.cy)),
|
|
2049
|
+
)
|
|
2050
|
+
|
|
2051
|
+
# room check: same row as the front/side views, to the right — past any
|
|
2052
|
+
# side-view callout labels already placed there
|
|
2053
|
+
# the caption is ~19mm wide — narrow sections are bounded by it
|
|
2054
|
+
half_w = max(a.x_size * a.SCALE / 2, 12.0)
|
|
2055
|
+
half_h = a.z_size * a.SCALE / 2
|
|
2056
|
+
side_vis, side_hid = dwg.views["side"]
|
|
2057
|
+
side_right = side_vis.bounding_box().max.X
|
|
2058
|
+
if side_hid:
|
|
2059
|
+
side_right = max(side_right, side_hid.bounding_box().max.X)
|
|
2060
|
+
left_edge = side_right + 10
|
|
2061
|
+
for name, ann in dwg._named.items():
|
|
2062
|
+
# past side-view callout labels and the height/step dim ladder
|
|
2063
|
+
if name.startswith(("hc_side", "dim_height", "dim_step")) and getattr(
|
|
2064
|
+
ann, "label_bbox", None
|
|
2065
|
+
):
|
|
2066
|
+
left_edge = max(left_edge, ann.label_bbox[2] + 6)
|
|
2067
|
+
pos_x = left_edge + half_w
|
|
2068
|
+
iso_x0, iso_y0, _, _ = _iso_bbox(dwg)
|
|
2069
|
+
right_limit = a.PAGE_W - a.margin
|
|
2070
|
+
if a.FV_Y + half_h + 6 > iso_y0 - 2:
|
|
2071
|
+
right_limit = min(right_limit, iso_x0 - 4)
|
|
2072
|
+
tb_left = a.PAGE_W - a.TB_W - 11
|
|
2073
|
+
if a.FV_Y - half_h - 10 < 11 + _TB_H and pos_x + half_w > tb_left - 4:
|
|
2074
|
+
_log.info("Section A–A skipped (would collide with the title block)")
|
|
2075
|
+
return
|
|
2076
|
+
if pos_x + half_w > right_limit:
|
|
2077
|
+
_log.warning(
|
|
2078
|
+
"Section A–A skipped (no room right of the side view; "
|
|
2079
|
+
"a wider step-dimension corridor may have reduced the available space)"
|
|
2080
|
+
)
|
|
2081
|
+
return
|
|
2082
|
+
|
|
2083
|
+
big = 4 * a.bbox_max
|
|
2084
|
+
# STEP imports with PMI carry annotation curves beside the solid, and a
|
|
2085
|
+
# mixed-dimension compound cannot be cut — section the solids only, and
|
|
2086
|
+
# never let a failed boolean abort the whole drawing
|
|
2087
|
+
solids = a.part.solids()
|
|
2088
|
+
if not solids:
|
|
2089
|
+
_log.info("Section A–A skipped (no solid bodies to cut)")
|
|
2090
|
+
return
|
|
2091
|
+
body = solids[0] if len(solids) == 1 else Compound(children=list(solids))
|
|
2092
|
+
try:
|
|
2093
|
+
keep_behind = body - Pos(a.cx, y_star - big / 2, a.cz) * Box(big, big, big)
|
|
2094
|
+
except Exception as exc: # noqa: BLE001 — OCC booleans raise broadly
|
|
2095
|
+
_log.warning("Section A–A skipped (cut failed: %s)", exc)
|
|
2096
|
+
return
|
|
2097
|
+
camera = (dwg.look_at[0], dwg.look_at[1] - dwg.dist, dwg.look_at[2])
|
|
2098
|
+
dwg.add_view("section_aa", keep_behind, camera, (0, 0, 1), (pos_x, a.FV_Y))
|
|
2099
|
+
dwg.add(
|
|
2100
|
+
Note("SECTION A–A", (pos_x, a.FV_Y - half_h - 7), dwg.draft),
|
|
2101
|
+
"section_caption",
|
|
2102
|
+
)
|
|
2103
|
+
|
|
2104
|
+
# cutting-plane line + identification letters on the plan view
|
|
2105
|
+
def PX(x):
|
|
2106
|
+
return a.PV_X + (x - a.cx) * a.SCALE
|
|
2107
|
+
|
|
2108
|
+
def PY(y):
|
|
2109
|
+
return a.PV_Y + (y - a.cy) * a.SCALE
|
|
2110
|
+
|
|
2111
|
+
y_page = PY(y_star)
|
|
2112
|
+
# the line and its letters must clear pattern centrelines that sweep
|
|
2113
|
+
# past the part outline (a corner-hole bolt circle is always wider)
|
|
2114
|
+
ext_x0, ext_x1 = PX(a.bb.min.X), PX(a.bb.max.X)
|
|
2115
|
+
for name, ann in dwg._named.items():
|
|
2116
|
+
if name.startswith("bc_plan"):
|
|
2117
|
+
cb = ann.bounding_box()
|
|
2118
|
+
if cb.min.Y - 3 < y_page < cb.max.Y + 3:
|
|
2119
|
+
ext_x0 = min(ext_x0, cb.min.X)
|
|
2120
|
+
ext_x1 = max(ext_x1, cb.max.X)
|
|
2121
|
+
x0, x1 = ext_x0 - 4, ext_x1 + 4
|
|
2122
|
+
dwg.add(Centerline((x0, y_page, 0), (x1, y_page, 0)), "section_line")
|
|
2123
|
+
|
|
2124
|
+
# ISO 128-44: cutting-plane end indicators — thick wing stubs with solid
|
|
2125
|
+
# filled arrowheads at the tips pointing in the viewing direction (−Y).
|
|
2126
|
+
arrow_sz = dwg.draft.arrow_length
|
|
2127
|
+
wing_h = 2.5 * arrow_sz # perpendicular stub length
|
|
2128
|
+
for x_end, side in ((x0, "left"), (x1, "right")):
|
|
2129
|
+
tip_y = y_page - wing_h
|
|
2130
|
+
shaft = Edge.make_line(Vector(x_end, y_page, 0), Vector(x_end, tip_y, 0))
|
|
2131
|
+
filled = Arrow(
|
|
2132
|
+
arrow_size=arrow_sz,
|
|
2133
|
+
shaft_path=shaft,
|
|
2134
|
+
shaft_width=dwg.draft.line_width,
|
|
2135
|
+
head_at_start=False,
|
|
2136
|
+
head_type=HeadType.STRAIGHT,
|
|
2137
|
+
mode=Mode.PRIVATE,
|
|
2138
|
+
)
|
|
2139
|
+
dwg.add(Compound(children=list(filled.faces())), f"section_arrow_{side}")
|
|
2140
|
+
dwg.add(
|
|
2141
|
+
Compound(children=[Edge.make_line(Vector(x_end, y_page, 0), Vector(x_end, tip_y, 0))]),
|
|
2142
|
+
f"section_wing_{side}",
|
|
2143
|
+
)
|
|
2144
|
+
|
|
2145
|
+
# 'A' letters sit above the line ends, clear of any callout leaders
|
|
2146
|
+
lift = dwg.draft.font_size * 1.4
|
|
2147
|
+
dwg.add(Note("A", (x0 - 3, y_page + lift), dwg.draft), "section_a_left")
|
|
2148
|
+
dwg.add(Note("A", (x1 + 3, y_page + lift), dwg.draft), "section_a_right")
|
|
2149
|
+
|
|
2150
|
+
# ISO 128-50: 45° hatching on the cut face, in page coordinates
|
|
2151
|
+
def SX(wx):
|
|
2152
|
+
return pos_x + (wx - a.cx) * a.SCALE
|
|
2153
|
+
|
|
2154
|
+
def SZ(wz):
|
|
2155
|
+
return a.FV_Y + (wz - a.cz) * a.SCALE
|
|
2156
|
+
|
|
2157
|
+
hatch_spacing = dwg.draft.font_size * 1.5
|
|
2158
|
+
cut_faces = [f for f in keep_behind.faces() if f.normal_at().Y < -0.9]
|
|
2159
|
+
hatch_edges = []
|
|
2160
|
+
for cf in cut_faces:
|
|
2161
|
+
hatch_edges.extend(_section_hatch_edges(cf, SX, SZ, hatch_spacing))
|
|
2162
|
+
if hatch_edges:
|
|
2163
|
+
hatch = Compound(children=hatch_edges)
|
|
2164
|
+
hatch.is_section_hatch = True # exempt from view_annotation_overlap lint
|
|
2165
|
+
dwg.add(hatch, "section_hatch")
|
|
2166
|
+
|
|
2167
|
+
|
|
2168
|
+
def _add_furniture(dwg, a, view, j, pattern, to_page):
|
|
2169
|
+
"""Pattern sheet furniture, added once its callout is placed (#92)."""
|
|
2170
|
+
if isinstance(pattern, BoltCircle):
|
|
2171
|
+
cx = sum(to_page(h)[0] for h in pattern.holes) / len(pattern.holes)
|
|
2172
|
+
cy = sum(to_page(h)[1] for h in pattern.holes) / len(pattern.holes)
|
|
2173
|
+
dwg.add(CenterlineCircle((cx, cy), pattern.diameter * a.SCALE), f"bc_{view}{j}")
|
|
2174
|
+
elif isinstance(pattern, LinearArray):
|
|
2175
|
+
_add_pitch_dim(dwg, a, view, j, pattern, to_page)
|
|
2176
|
+
|
|
2177
|
+
|
|
2178
|
+
def _add_pitch_dim(dwg, a, view, j, pattern, to_page):
|
|
2179
|
+
"""Pitch dimension for a linear hole array: first→last hole centres,
|
|
2180
|
+
labelled ``(n-1)× pitch``, placed just outside the view on the side of
|
|
2181
|
+
the row's outward perpendicular (#92)."""
|
|
2182
|
+
p1 = to_page(pattern.holes[0])
|
|
2183
|
+
p2 = to_page(pattern.holes[-1])
|
|
2184
|
+
ux, uy = p2[0] - p1[0], p2[1] - p1[1]
|
|
2185
|
+
norm = math.hypot(ux, uy)
|
|
2186
|
+
if norm < 1e-9:
|
|
2187
|
+
return
|
|
2188
|
+
ux, uy = ux / norm, uy / norm
|
|
2189
|
+
mid = ((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2)
|
|
2190
|
+
# view extents in page coordinates, to push the dim line outside
|
|
2191
|
+
if view == "plan":
|
|
2192
|
+
corners = [
|
|
2193
|
+
(a.PV_X + (x - a.cx) * a.SCALE, a.PV_Y + (y - a.cy) * a.SCALE)
|
|
2194
|
+
for x in (a.bb.min.X, a.bb.max.X)
|
|
2195
|
+
for y in (a.bb.min.Y, a.bb.max.Y)
|
|
2196
|
+
]
|
|
2197
|
+
elif view == "front":
|
|
2198
|
+
corners = [
|
|
2199
|
+
(a.FV_X + (x - a.cx) * a.SCALE, a.FV_Y + (z - a.cz) * a.SCALE)
|
|
2200
|
+
for x in (a.bb.min.X, a.bb.max.X)
|
|
2201
|
+
for z in (a.bb.min.Z, a.bb.max.Z)
|
|
2202
|
+
]
|
|
2203
|
+
else:
|
|
2204
|
+
corners = [
|
|
2205
|
+
(a.SV_X + (y - a.cy) * a.SCALE, a.SV_Y + (z - a.cz) * a.SCALE)
|
|
2206
|
+
for y in (a.bb.min.Y, a.bb.max.Y)
|
|
2207
|
+
for z in (a.bb.min.Z, a.bb.max.Z)
|
|
2208
|
+
]
|
|
2209
|
+
# Pick the perpendicular side from the page layout, not raw distance:
|
|
2210
|
+
# below the plan view sit dim_width and the front view, above the front
|
|
2211
|
+
# view sits the plan — so plan dims go up, front dims go down, and
|
|
2212
|
+
# vertical rows go left (callouts own the right strip). The side view
|
|
2213
|
+
# alone uses the shorter reach. A row far from its chosen side simply
|
|
2214
|
+
# gets long extension lines — standard practice when the near side is
|
|
2215
|
+
# occupied.
|
|
2216
|
+
reach_pos = max((c[0] - mid[0]) * -uy + (c[1] - mid[1]) * ux for c in corners)
|
|
2217
|
+
reach_neg = max((c[0] - mid[0]) * uy + (c[1] - mid[1]) * -ux for c in corners)
|
|
2218
|
+
cands = (((-uy, ux, 0), reach_pos), ((uy, -ux, 0), reach_neg))
|
|
2219
|
+
if view == "side":
|
|
2220
|
+
side, reach = min(cands, key=lambda c: c[1])
|
|
2221
|
+
else:
|
|
2222
|
+
pref = (-0.3, 1.0) if view == "plan" else (-0.3, -1.0)
|
|
2223
|
+
side, reach = max(cands, key=lambda c: c[0][0] * pref[0] + c[0][1] * pref[1])
|
|
2224
|
+
# stack further pitch dims in this view on outer tiers
|
|
2225
|
+
prior = sum(1 for name in dwg._named if name.startswith(f"dim_pitch_{view}"))
|
|
2226
|
+
offset = reach + 8 + 10 * prior
|
|
2227
|
+
# never force-place: skip (and log) when the dim line would leave the page
|
|
2228
|
+
ox = mid[0] + side[0] * (offset + 6)
|
|
2229
|
+
oy = mid[1] + side[1] * (offset + 6)
|
|
2230
|
+
if not (a.margin <= ox <= a.PAGE_W - a.margin and a.margin <= oy <= a.PAGE_H - a.margin):
|
|
2231
|
+
_log.info(
|
|
2232
|
+
"Pitch dimension for the %s× %s array skipped (no room)",
|
|
2233
|
+
len(pattern.holes),
|
|
2234
|
+
_fmt(pattern.pitch),
|
|
2235
|
+
)
|
|
2236
|
+
return
|
|
2237
|
+
n = len(pattern.holes)
|
|
2238
|
+
dwg.add(
|
|
2239
|
+
Dimension(
|
|
2240
|
+
(p1[0], p1[1], 0),
|
|
2241
|
+
(p2[0], p2[1], 0),
|
|
2242
|
+
side,
|
|
2243
|
+
offset,
|
|
2244
|
+
dwg.draft,
|
|
2245
|
+
label=f"{n - 1}× {_fmt(pattern.pitch)}",
|
|
2246
|
+
),
|
|
2247
|
+
f"dim_pitch_{view}{j}",
|
|
2248
|
+
)
|
|
2249
|
+
|
|
2250
|
+
|
|
2251
|
+
def _greedy_strip_ys(natural_ys, min_gap, y_min, y_max, *, prefix=False):
|
|
2252
|
+
"""Greedy Y-placement: push each value down until the gap clears.
|
|
2253
|
+
|
|
2254
|
+
With *prefix=False* (default): returns None if any item overflows y_max.
|
|
2255
|
+
With *prefix=True*: stops at the first overflow and returns the placed prefix.
|
|
2256
|
+
"""
|
|
2257
|
+
result = []
|
|
2258
|
+
prev = y_min - min_gap
|
|
2259
|
+
for ny in natural_ys:
|
|
2260
|
+
y = max(prev + min_gap, ny)
|
|
2261
|
+
if y > y_max:
|
|
2262
|
+
if prefix:
|
|
2263
|
+
break
|
|
2264
|
+
return None
|
|
2265
|
+
result.append(y)
|
|
2266
|
+
prev = y
|
|
2267
|
+
return result
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
def _solve_strip_ys(natural_ys, min_gap, y_min, y_max):
|
|
2271
|
+
"""Cassowary Y-placement for bore-callout leaders sharing one strip.
|
|
2272
|
+
|
|
2273
|
+
Returns solved Y positions (same length as *natural_ys*), or ``None`` when
|
|
2274
|
+
the callouts don't fit within [y_min, y_max]. Falls back to the greedy
|
|
2275
|
+
cursor when kiwisolver is unavailable.
|
|
2276
|
+
|
|
2277
|
+
*natural_ys* must be sorted ascending; each solved value is bounded to
|
|
2278
|
+
[y_min, y_max] and adjacent values are at least *min_gap* apart.
|
|
2279
|
+
"""
|
|
2280
|
+
if not natural_ys:
|
|
2281
|
+
return []
|
|
2282
|
+
n = len(natural_ys)
|
|
2283
|
+
if (n - 1) * min_gap > y_max - y_min:
|
|
2284
|
+
return None # provably infeasible
|
|
2285
|
+
|
|
2286
|
+
try:
|
|
2287
|
+
import kiwisolver as ki
|
|
2288
|
+
except ImportError:
|
|
2289
|
+
return _greedy_strip_ys(natural_ys, min_gap, y_min, y_max)
|
|
2290
|
+
|
|
2291
|
+
solver = ki.Solver()
|
|
2292
|
+
ys = [ki.Variable(f"y{i}") for i in range(n)]
|
|
2293
|
+
try:
|
|
2294
|
+
for v in ys:
|
|
2295
|
+
solver.addConstraint((v >= y_min) | "required")
|
|
2296
|
+
solver.addConstraint((v <= y_max) | "required")
|
|
2297
|
+
for i in range(n - 1):
|
|
2298
|
+
solver.addConstraint((ys[i + 1] - ys[i] >= min_gap) | "required")
|
|
2299
|
+
for v, ny in zip(ys, natural_ys, strict=True):
|
|
2300
|
+
solver.addConstraint((v == ny) | "strong")
|
|
2301
|
+
solver.updateVariables()
|
|
2302
|
+
return [v.value() for v in ys]
|
|
2303
|
+
except ki.UnsatisfiableConstraint:
|
|
2304
|
+
return None
|
|
2305
|
+
|
|
2306
|
+
|
|
2307
|
+
def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns):
|
|
2308
|
+
"""Leader-attached HoleCallouts, one per distinct hole spec per view (#91).
|
|
2309
|
+
|
|
2310
|
+
Identical holes share one callout with an ``n×`` count prefix (#92's
|
|
2311
|
+
grouping half) — through holes group on diameter and steps regardless of
|
|
2312
|
+
wall thickness. The leader tip lands on the hole's circumference, on the
|
|
2313
|
+
group's hole nearest the callout.
|
|
2314
|
+
|
|
2315
|
+
Placement: plan- and side-view callouts go to the right of their view
|
|
2316
|
+
(the strip before the iso view / page margin; plan falls back to its
|
|
2317
|
+
left, the side view has no usable left strip), front-view callouts go
|
|
2318
|
+
below the front view, deconflicted so no leader shaft crosses an earlier
|
|
2319
|
+
callout's text. Each callout is width-checked; anything that fits
|
|
2320
|
+
nowhere is logged and skipped — never force-placed — and then surfaces
|
|
2321
|
+
through the coverage lint as ``feature_not_dimensioned``.
|
|
2322
|
+
"""
|
|
2323
|
+
draft = dwg.draft
|
|
2324
|
+
gap = draft.pad_around_text
|
|
2325
|
+
min_gap = draft.font_size * 2.2
|
|
2326
|
+
# Group on the same machining-spec key pattern detection uses (snapped
|
|
2327
|
+
# axis vector included): blind holes drilled from opposite faces are
|
|
2328
|
+
# different operations and get separate callouts, and a spec group's
|
|
2329
|
+
# hole set therefore lines up exactly with find_hole_patterns' groups.
|
|
2330
|
+
groups: dict = {}
|
|
2331
|
+
for h in a.holes:
|
|
2332
|
+
groups.setdefault(_spec_key(h), []).append(h)
|
|
2333
|
+
|
|
2334
|
+
by_view: dict = {}
|
|
2335
|
+
for holes in groups.values():
|
|
2336
|
+
by_view.setdefault(view_of_axis[axis_letter(holes[0])][0], []).append(holes)
|
|
2337
|
+
|
|
2338
|
+
_, iso_y0, _, _ = _iso_bbox(dwg)
|
|
2339
|
+
plan_right = a.PV_X + (a.bb.max.X - a.cx) * a.SCALE
|
|
2340
|
+
plan_left = a.PV_X + (a.bb.min.X - a.cx) * a.SCALE
|
|
2341
|
+
side_right = a.SV_X + (a.bb.max.Y - a.cy) * a.SCALE
|
|
2342
|
+
front_bottom = a.FV_Y + (a.bb.min.Z - a.cz) * a.SCALE
|
|
2343
|
+
tb_left = a.PAGE_W - a.TB_W - 11
|
|
2344
|
+
tb_top = 11 + _TB_H
|
|
2345
|
+
|
|
2346
|
+
# A section line will be placed when the part has z-axis holes with
|
|
2347
|
+
# counterbores, spotfaces, or blind bottoms (_add_section_view trigger).
|
|
2348
|
+
# When present, its extension lines overhang the plan view boundary by
|
|
2349
|
+
# ~arrow_length, so plan-view elbow must sit that far outside to clear them.
|
|
2350
|
+
# Room-check failures may still skip the section, but the offset is harmless.
|
|
2351
|
+
will_have_section_line = any(
|
|
2352
|
+
axis_letter(h) == "z" and (h.cbore or h.spotface or h.bottom != "through") for h in a.holes
|
|
2353
|
+
)
|
|
2354
|
+
|
|
2355
|
+
# A pattern annotates only when it accounts for the whole spec group —
|
|
2356
|
+
# a 7th same-size hole off the circle would make "7× ... EQ SP ON BC"
|
|
2357
|
+
# a lie about six of them.
|
|
2358
|
+
patterns = {frozenset(p.holes): p for p in found_patterns}
|
|
2359
|
+
|
|
2360
|
+
def _build_callout(holes, pattern):
|
|
2361
|
+
h = holes[0]
|
|
2362
|
+
step = h.cbore or h.spotface
|
|
2363
|
+
if h.cbore and h.spotface:
|
|
2364
|
+
_log.info(
|
|
2365
|
+
"Hole ø%s has both cbore and spotface; spotface not in the callout",
|
|
2366
|
+
_fmt(h.diameter),
|
|
2367
|
+
)
|
|
2368
|
+
step = h.cbore
|
|
2369
|
+
through = h.bottom == "through"
|
|
2370
|
+
suffix = (
|
|
2371
|
+
f"EQ SP ON ø{_fmt(pattern.diameter)} BC" if isinstance(pattern, BoltCircle) else None
|
|
2372
|
+
)
|
|
2373
|
+
return HoleCallout(
|
|
2374
|
+
_fmt(h.diameter),
|
|
2375
|
+
count=len(holes) if len(holes) > 1 else None,
|
|
2376
|
+
through=through,
|
|
2377
|
+
depth=None if through else _fmt(h.depth),
|
|
2378
|
+
cbore_dia=_fmt(step.diameter) if step else None,
|
|
2379
|
+
cbore_depth=_fmt(step.depth) if step else None,
|
|
2380
|
+
suffix=suffix,
|
|
2381
|
+
draft=draft,
|
|
2382
|
+
)
|
|
2383
|
+
|
|
2384
|
+
def _rim_tip(centre, elbow, holes):
|
|
2385
|
+
"""Pull the tip from the hole centre to its circumference."""
|
|
2386
|
+
r = holes[0].diameter * a.SCALE / 2
|
|
2387
|
+
dx, dy = elbow[0] - centre[0], elbow[1] - centre[1]
|
|
2388
|
+
norm = math.hypot(dx, dy)
|
|
2389
|
+
if norm <= r:
|
|
2390
|
+
return centre
|
|
2391
|
+
return (centre[0] + dx / norm * r, centre[1] + dy / norm * r)
|
|
2392
|
+
|
|
2393
|
+
def _add(view, i, tip, elbow, side, callout):
|
|
2394
|
+
dwg.add(
|
|
2395
|
+
Leader(
|
|
2396
|
+
tip=(tip[0], tip[1], 0),
|
|
2397
|
+
elbow=(elbow[0], elbow[1], 0),
|
|
2398
|
+
label="",
|
|
2399
|
+
draft=draft,
|
|
2400
|
+
text_side=side,
|
|
2401
|
+
callout=callout,
|
|
2402
|
+
),
|
|
2403
|
+
f"hc_{view}{i}",
|
|
2404
|
+
)
|
|
2405
|
+
|
|
2406
|
+
for view, view_groups in by_view.items():
|
|
2407
|
+
to_page = view_of_axis[{"plan": "z", "front": "y", "side": "x"}[view]][1]
|
|
2408
|
+
specs = []
|
|
2409
|
+
for holes in view_groups:
|
|
2410
|
+
pattern = patterns.get(frozenset(holes))
|
|
2411
|
+
specs.append((holes, _build_callout(holes, pattern), pattern))
|
|
2412
|
+
if len(specs) > _MAX_CALLOUTS_PER_VIEW:
|
|
2413
|
+
# annotate the largest features; the rest surface via the lint
|
|
2414
|
+
# (their pattern furniture is withheld too — a bare pitch circle
|
|
2415
|
+
# with no callout referencing it explains nothing)
|
|
2416
|
+
specs.sort(key=lambda s: s[0][0].diameter, reverse=True)
|
|
2417
|
+
_log.info(
|
|
2418
|
+
"%d hole specs in %s view; annotating the %d largest "
|
|
2419
|
+
"(the rest surface as feature_not_dimensioned)",
|
|
2420
|
+
len(specs),
|
|
2421
|
+
view,
|
|
2422
|
+
_MAX_CALLOUTS_PER_VIEW,
|
|
2423
|
+
)
|
|
2424
|
+
specs = specs[:_MAX_CALLOUTS_PER_VIEW]
|
|
2425
|
+
|
|
2426
|
+
if view == "front":
|
|
2427
|
+
# Below the view, vertical shafts. Rows are assigned right-to-
|
|
2428
|
+
# left so a deeper row's shaft never crosses a shallower row's
|
|
2429
|
+
# right-running label; left-side labels get an explicit guard.
|
|
2430
|
+
specs.sort(key=lambda s: max(to_page(h)[0] for h in s[0]), reverse=True)
|
|
2431
|
+
occupied: list[tuple] = [] # (x0, x1, row_y) of placed labels
|
|
2432
|
+
for i, (holes, callout, pattern) in enumerate(specs):
|
|
2433
|
+
w = callout.callout_width
|
|
2434
|
+
centre = to_page(max(holes, key=lambda h: to_page(h)[0]))
|
|
2435
|
+
elbow_y = front_bottom - 0.6 * a.DIM_PAD - i * min_gap
|
|
2436
|
+
if centre[0] + gap + w <= a.PAGE_W - a.margin:
|
|
2437
|
+
side, x0, x1 = "right", centre[0] + gap, centre[0] + gap + w
|
|
2438
|
+
elif centre[0] - gap - w >= a.margin:
|
|
2439
|
+
side, x0, x1 = "left", centre[0] - gap - w, centre[0] - gap
|
|
2440
|
+
else:
|
|
2441
|
+
_log.info("Hole callout ø%s skipped (no room)", _fmt(holes[0].diameter))
|
|
2442
|
+
continue
|
|
2443
|
+
# the title block only constrains rows that reach its x-range
|
|
2444
|
+
floor = (tb_top + 4) if x1 > tb_left - 4 else a.margin + 4
|
|
2445
|
+
if elbow_y < floor:
|
|
2446
|
+
_log.info(
|
|
2447
|
+
"Hole callout ø%s skipped (front strip full)", _fmt(holes[0].diameter)
|
|
2448
|
+
)
|
|
2449
|
+
continue
|
|
2450
|
+
if any(
|
|
2451
|
+
ox0 <= centre[0] <= ox1 and row_y > elbow_y for ox0, ox1, row_y in occupied
|
|
2452
|
+
):
|
|
2453
|
+
_log.info(
|
|
2454
|
+
"Hole callout ø%s skipped (shaft would cross another callout)",
|
|
2455
|
+
_fmt(holes[0].diameter),
|
|
2456
|
+
)
|
|
2457
|
+
continue
|
|
2458
|
+
elbow = (centre[0], elbow_y)
|
|
2459
|
+
occupied.append((x0, x1, elbow_y))
|
|
2460
|
+
_add(view, i, _rim_tip(centre, elbow, holes), elbow, side, callout)
|
|
2461
|
+
_add_furniture(dwg, a, view, i, pattern, to_page)
|
|
2462
|
+
continue
|
|
2463
|
+
|
|
2464
|
+
# plan / side: two-pass leader placement.
|
|
2465
|
+
# Pass 1 — boundary assignment: each spec goes to the nearest strip
|
|
2466
|
+
# boundary (right or left) whose label fits within the page.
|
|
2467
|
+
# Pass 2 — Y placement via Cassowary: leaders stay within the view's
|
|
2468
|
+
# Y extent, are at least min_gap apart, and stay near their natural
|
|
2469
|
+
# (hole-centre) Y position.
|
|
2470
|
+
edge_right = plan_right if view == "plan" else side_right
|
|
2471
|
+
edge_left = plan_left if view == "plan" else None
|
|
2472
|
+
|
|
2473
|
+
right_strip = a.pv_zones.right if view == "plan" else a.sv_zones.right
|
|
2474
|
+
# Elbow offset past the view boundary: only needed in the plan view when
|
|
2475
|
+
# a section line will be placed (its extension lines overhang by
|
|
2476
|
+
# ~arrow_length). Side view and section-free plan views use 0 so the
|
|
2477
|
+
# shaft terminates at the boundary instead of crossing it.
|
|
2478
|
+
elbow_dx = draft.arrow_length if view == "plan" and will_have_section_line else 0.0
|
|
2479
|
+
|
|
2480
|
+
# Y bounds: elbows must stay within the view's projected Y extent.
|
|
2481
|
+
if view == "plan":
|
|
2482
|
+
y_min, y_max = a.PV_Y - a.pv_hh, a.PV_Y + a.pv_hh
|
|
2483
|
+
else:
|
|
2484
|
+
y_min, y_max = a.SV_Y - a.fv_hh, a.SV_Y + a.fv_hh
|
|
2485
|
+
|
|
2486
|
+
# --- Pass 1: boundary assignment ---
|
|
2487
|
+
right_queue = [] # (holes, callout, pattern, natural_y, rep)
|
|
2488
|
+
left_queue = []
|
|
2489
|
+
|
|
2490
|
+
for holes, callout, pattern in specs:
|
|
2491
|
+
w = callout.callout_width
|
|
2492
|
+
rep_r = max(holes, key=lambda h: to_page(h)[0])
|
|
2493
|
+
centre_r = to_page(rep_r)
|
|
2494
|
+
d_right = edge_right - centre_r[0]
|
|
2495
|
+
|
|
2496
|
+
if edge_left is not None:
|
|
2497
|
+
rep_l = min(holes, key=lambda h: to_page(h)[0])
|
|
2498
|
+
centre_l = to_page(rep_l)
|
|
2499
|
+
d_left = centre_l[0] - edge_left
|
|
2500
|
+
else:
|
|
2501
|
+
rep_l = centre_l = None
|
|
2502
|
+
d_left = float("inf")
|
|
2503
|
+
|
|
2504
|
+
# Side callouts below the iso view (always the case in practice) may
|
|
2505
|
+
# reach the full page width; plan callouts are constrained by the iso.
|
|
2506
|
+
right_limit = (
|
|
2507
|
+
right_strip.outer_limit
|
|
2508
|
+
if view == "plan" or centre_r[1] >= iso_y0 - draft.font_size
|
|
2509
|
+
else a.PAGE_W - a.margin
|
|
2510
|
+
)
|
|
2511
|
+
can_right = (edge_right + elbow_dx) + gap + w <= right_limit
|
|
2512
|
+
can_left = edge_left is not None and (edge_left - elbow_dx) - gap - w >= a.margin
|
|
2513
|
+
|
|
2514
|
+
if not can_right and not can_left:
|
|
2515
|
+
_log.info("Hole callout ø%s skipped (no room)", _fmt(holes[0].diameter))
|
|
2516
|
+
continue
|
|
2517
|
+
|
|
2518
|
+
if can_right and (not can_left or d_right <= d_left):
|
|
2519
|
+
right_queue.append((holes, callout, pattern, centre_r[1], rep_r))
|
|
2520
|
+
else:
|
|
2521
|
+
left_queue.append((holes, callout, pattern, centre_l[1], rep_l))
|
|
2522
|
+
|
|
2523
|
+
# Sort each queue by natural Y so leaders don't cross.
|
|
2524
|
+
right_queue.sort(key=lambda s: s[3])
|
|
2525
|
+
left_queue.sort(key=lambda s: s[3])
|
|
2526
|
+
|
|
2527
|
+
# --- Pass 2: Y placement ---
|
|
2528
|
+
right_ys = _solve_strip_ys([s[3] for s in right_queue], min_gap, y_min, y_max)
|
|
2529
|
+
left_ys = _solve_strip_ys([s[3] for s in left_queue], min_gap, y_min, y_max)
|
|
2530
|
+
|
|
2531
|
+
if right_ys is None and right_queue:
|
|
2532
|
+
right_ys = _greedy_strip_ys(
|
|
2533
|
+
[s[3] for s in right_queue], min_gap, y_min, y_max, prefix=True
|
|
2534
|
+
)
|
|
2535
|
+
n_drop = len(right_queue) - len(right_ys)
|
|
2536
|
+
if n_drop:
|
|
2537
|
+
_log.warning(
|
|
2538
|
+
"plan/side right strip: %d of %d bore callouts skipped (strip full)",
|
|
2539
|
+
n_drop,
|
|
2540
|
+
len(right_queue),
|
|
2541
|
+
)
|
|
2542
|
+
right_queue = right_queue[: len(right_ys)]
|
|
2543
|
+
if left_ys is None and left_queue:
|
|
2544
|
+
left_ys = _greedy_strip_ys(
|
|
2545
|
+
[s[3] for s in left_queue], min_gap, y_min, y_max, prefix=True
|
|
2546
|
+
)
|
|
2547
|
+
n_drop = len(left_queue) - len(left_ys)
|
|
2548
|
+
if n_drop:
|
|
2549
|
+
_log.warning(
|
|
2550
|
+
"plan/side left strip: %d of %d bore callouts skipped (strip full)",
|
|
2551
|
+
n_drop,
|
|
2552
|
+
len(left_queue),
|
|
2553
|
+
)
|
|
2554
|
+
left_queue = left_queue[: len(left_ys)]
|
|
2555
|
+
|
|
2556
|
+
for i, ((holes, callout, pattern, _, rep), elbow_y) in enumerate(
|
|
2557
|
+
zip(right_queue, right_ys, strict=True)
|
|
2558
|
+
):
|
|
2559
|
+
centre = to_page(rep)
|
|
2560
|
+
elbow = (edge_right + elbow_dx, elbow_y)
|
|
2561
|
+
tip = _rim_tip(centre, elbow, holes)
|
|
2562
|
+
# Safety clamp: arrowhead must sit inside the view boundary.
|
|
2563
|
+
tip = (min(tip[0], edge_right - draft.arrow_length), tip[1])
|
|
2564
|
+
_add(view, i, tip, elbow, "right", callout)
|
|
2565
|
+
_add_furniture(dwg, a, view, i, pattern, to_page)
|
|
2566
|
+
|
|
2567
|
+
assert edge_left is not None or not left_queue # populated only when edge_left is set
|
|
2568
|
+
for i, ((holes, callout, pattern, _, rep), elbow_y) in enumerate(
|
|
2569
|
+
zip(left_queue, left_ys, strict=True), start=len(right_queue)
|
|
2570
|
+
):
|
|
2571
|
+
centre = to_page(rep)
|
|
2572
|
+
elbow = (edge_left - elbow_dx, elbow_y) # type: ignore[operator]
|
|
2573
|
+
tip = _rim_tip(centre, elbow, holes)
|
|
2574
|
+
tip = (max(tip[0], edge_left + draft.arrow_length), tip[1])
|
|
2575
|
+
_add(view, i, tip, elbow, "left", callout)
|
|
2576
|
+
_add_furniture(dwg, a, view, i, pattern, to_page)
|
|
2577
|
+
|
|
2578
|
+
|
|
2579
|
+
def _add_title_block(dwg, a):
|
|
2580
|
+
"""Add the title block annotation."""
|
|
2581
|
+
tb = TitleBlock(
|
|
2582
|
+
a.title,
|
|
2583
|
+
a.number,
|
|
2584
|
+
scale=format_drawing_scale(a.SCALE),
|
|
2585
|
+
general_tolerance=a.tolerance,
|
|
2586
|
+
designed_by=a.drawn_by,
|
|
2587
|
+
revision="A",
|
|
2588
|
+
legal_owner="",
|
|
2589
|
+
width=a.TB_W,
|
|
2590
|
+
draft=dwg.draft,
|
|
2591
|
+
).locate(Location((a.PAGE_W - a.TB_W - 11, 11, 0)))
|
|
2592
|
+
dwg.add(tb, "title_block")
|
|
2593
|
+
|
|
2594
|
+
|
|
2595
|
+
_ISO_SHRINK_FACTORS = (0.5, 0.2, 0.1)
|
|
2596
|
+
|
|
2597
|
+
|
|
2598
|
+
def _iso_bbox(dwg):
|
|
2599
|
+
"""(min_x, min_y, max_x, max_y) of the placed iso view, hidden lines included."""
|
|
2600
|
+
vis, hid = dwg.views["iso"]
|
|
2601
|
+
bb = vis.bounding_box()
|
|
2602
|
+
x0, y0, x1, y1 = bb.min.X, bb.min.Y, bb.max.X, bb.max.Y
|
|
2603
|
+
if hid:
|
|
2604
|
+
hb = hid.bounding_box()
|
|
2605
|
+
x0, y0 = min(x0, hb.min.X), min(y0, hb.min.Y)
|
|
2606
|
+
x1, y1 = max(x1, hb.max.X), max(y1, hb.max.Y)
|
|
2607
|
+
return x0, y0, x1, y1
|
|
2608
|
+
|
|
2609
|
+
|
|
2610
|
+
def _bbox_within(bb, region, tol: float = 0.5) -> bool:
|
|
2611
|
+
"""True if (min_x, min_y, max_x, max_y) *bb* fits inside *region* within *tol*."""
|
|
2612
|
+
return (
|
|
2613
|
+
bb[0] >= region[0] - tol
|
|
2614
|
+
and bb[1] >= region[1] - tol
|
|
2615
|
+
and bb[2] <= region[2] + tol
|
|
2616
|
+
and bb[3] <= region[3] + tol
|
|
2617
|
+
)
|
|
2618
|
+
|
|
2619
|
+
|
|
2620
|
+
def _project_iso(dwg, a, scale, shape_s=None):
|
|
2621
|
+
"""(Re-)project the iso view at *scale* (an absolute factor, not a fraction).
|
|
2622
|
+
|
|
2623
|
+
Pass *shape_s* when the part is already scaled by *scale* to skip the copy.
|
|
2624
|
+
"""
|
|
2625
|
+
la = (a.cx * scale, a.cy * scale, a.cz * scale)
|
|
2626
|
+
off = (a.bbox_max * scale + 100) / math.sqrt(3)
|
|
2627
|
+
camera = (la[0] + off, la[1] + off, la[2] + off)
|
|
2628
|
+
dwg.add_view(
|
|
2629
|
+
"iso",
|
|
2630
|
+
shape_s if shape_s is not None else a.part.scale(scale),
|
|
2631
|
+
camera,
|
|
2632
|
+
(0, 0, 1),
|
|
2633
|
+
(a.ISO_X, a.ISO_Y),
|
|
2634
|
+
look_at=la,
|
|
2635
|
+
scaled=True,
|
|
2636
|
+
)
|
|
2637
|
+
if scale != dwg.scale:
|
|
2638
|
+
# add_view derives ViewCoordinates from the drawing scale; an iso
|
|
2639
|
+
# projected at a different scale needs them rebuilt so
|
|
2640
|
+
# dwg.at("iso", ...) keeps mapping world points correctly.
|
|
2641
|
+
axes = view_axes(camera, (0, 0, 1), la)
|
|
2642
|
+
dwg._coords["iso"] = ViewCoordinates(axes, a.ISO_X, a.ISO_Y, a.cx, a.cy, a.cz, scale)
|
|
2643
|
+
|
|
2644
|
+
|
|
2645
|
+
def _fit_iso_view(dwg, a):
|
|
2646
|
+
"""Shrink the iso view to fit its page region, captioning it NTS (#75).
|
|
2647
|
+
|
|
2648
|
+
The layout reserves ~0.7 × bbox_max for the iso column, but the true
|
|
2649
|
+
projected extent can be wider (long prismatic parts), pushing the iso past
|
|
2650
|
+
the page edge or into the side view's dimension space. When the projected
|
|
2651
|
+
iso bbox overflows the region, re-project at a clean fraction of sheet
|
|
2652
|
+
scale and add an "ISO VIEW (NTS)" caption below it.
|
|
2653
|
+
"""
|
|
2654
|
+
region = (a.sv_right, a.margin, a.iso_right_limit, a.PAGE_H - a.margin)
|
|
2655
|
+
bb = _iso_bbox(dwg)
|
|
2656
|
+
# Exact check (no tolerance): the lint's view_out_of_bounds is exact, so
|
|
2657
|
+
# accepting a sub-tolerance overflow here would pass the fit yet fail lint.
|
|
2658
|
+
if _bbox_within(bb, region, tol=0.0):
|
|
2659
|
+
return
|
|
2660
|
+
# Orthographic projection is linear and the view centre maps to
|
|
2661
|
+
# (ISO_X, ISO_Y), so each bbox side's offset from the centre scales
|
|
2662
|
+
# exactly with the shape scale — the factor needed to fit can be computed
|
|
2663
|
+
# from the measured extents, costing a single re-projection.
|
|
2664
|
+
ratios = [
|
|
2665
|
+
avail / extent
|
|
2666
|
+
for extent, avail in (
|
|
2667
|
+
(a.ISO_X - bb[0], a.ISO_X - region[0]),
|
|
2668
|
+
(bb[2] - a.ISO_X, region[2] - a.ISO_X),
|
|
2669
|
+
(a.ISO_Y - bb[1], a.ISO_Y - region[1]),
|
|
2670
|
+
(bb[3] - a.ISO_Y, region[3] - a.ISO_Y),
|
|
2671
|
+
)
|
|
2672
|
+
if extent > 0
|
|
2673
|
+
]
|
|
2674
|
+
needed = min(ratios, default=1.0)
|
|
2675
|
+
factor = next((f for f in _ISO_SHRINK_FACTORS if f <= needed), _ISO_SHRINK_FACTORS[-1])
|
|
2676
|
+
_project_iso(dwg, a, a.SCALE * factor)
|
|
2677
|
+
bb = _iso_bbox(dwg)
|
|
2678
|
+
if not _bbox_within(bb, region):
|
|
2679
|
+
_log.warning("Iso view still overflows its page region at %g× sheet scale", factor)
|
|
2680
|
+
font = dwg.draft.font_size
|
|
2681
|
+
dwg.add(
|
|
2682
|
+
Note(
|
|
2683
|
+
"ISO VIEW (NTS)",
|
|
2684
|
+
(a.ISO_X, max(bb[1] - 2 * font, a.margin + font)),
|
|
2685
|
+
dwg.draft,
|
|
2686
|
+
),
|
|
2687
|
+
"note_iso_nts",
|
|
2688
|
+
)
|
|
2689
|
+
_log.info("Iso view shrunk to %g× sheet scale (NTS)", factor)
|
|
2690
|
+
|
|
2691
|
+
|
|
2692
|
+
def build_drawing(
|
|
2693
|
+
step_file: str | Path | Shape,
|
|
2694
|
+
out: str | None = None,
|
|
2695
|
+
title: str | None = None,
|
|
2696
|
+
number: str = "DWG-001",
|
|
2697
|
+
tolerance: str = "ISO 2768-m",
|
|
2698
|
+
drawn_by: str = "",
|
|
2699
|
+
scale: float | None = None,
|
|
2700
|
+
page: str | tuple | None = None,
|
|
2701
|
+
auto_dims: bool = True,
|
|
2702
|
+
pmi: Literal["off", "report", "annotate"] = "off",
|
|
2703
|
+
) -> Drawing:
|
|
2704
|
+
"""Build a customisable 4-view :class:`Drawing` without exporting it.
|
|
2705
|
+
|
|
2706
|
+
Same arguments as :func:`make_drawing`, but returns the live :class:`Drawing`
|
|
2707
|
+
so you can add or remove annotations and add section/auxiliary views before
|
|
2708
|
+
calling :meth:`Drawing.export`. ``make_drawing(...)`` is exactly
|
|
2709
|
+
``build_drawing(...).export()``.
|
|
2710
|
+
|
|
2711
|
+
Args:
|
|
2712
|
+
auto_dims: pass ``False`` to skip the automatic dimensions,
|
|
2713
|
+
centrelines, and leaders (#74) — the automatic set assumes a
|
|
2714
|
+
turned part and is wrong for prismatic geometry. Views, scale,
|
|
2715
|
+
page, and title block are still produced; add your own
|
|
2716
|
+
annotations before export. (Annotations added by the default can
|
|
2717
|
+
also be removed wholesale with :meth:`Drawing.clear_annotations`.)
|
|
2718
|
+
|
|
2719
|
+
Returns:
|
|
2720
|
+
A :class:`Drawing` with the standard front/plan/side/iso views projected
|
|
2721
|
+
and the automatic dimensions + title block already added.
|
|
2722
|
+
"""
|
|
2723
|
+
stem = "drawing" if isinstance(step_file, Shape) else Path(step_file).stem
|
|
2724
|
+
out = out or stem
|
|
2725
|
+
for _ext in (".svg", ".dxf"):
|
|
2726
|
+
if out.endswith(_ext):
|
|
2727
|
+
out = out[: -len(_ext)]
|
|
2728
|
+
break
|
|
2729
|
+
title = title or stem.replace("_", " ").upper()
|
|
2730
|
+
|
|
2731
|
+
a = _analyse(
|
|
2732
|
+
step_file, title, number, tolerance, drawn_by, out, scale=scale, page=page, pmi=pmi
|
|
2733
|
+
)
|
|
2734
|
+
|
|
2735
|
+
cxs, cys, czs = a.cx * a.SCALE, a.cy * a.SCALE, a.cz * a.SCALE
|
|
2736
|
+
look_at = (cxs, cys, czs)
|
|
2737
|
+
dist = a.bbox_max * a.SCALE + 100
|
|
2738
|
+
|
|
2739
|
+
dwg = Drawing(
|
|
2740
|
+
scale=a.SCALE,
|
|
2741
|
+
page_w=a.PAGE_W,
|
|
2742
|
+
page_h=a.PAGE_H,
|
|
2743
|
+
tb_w=a.TB_W,
|
|
2744
|
+
draft=draft_preset(font_size=3.0, decimal_precision=1),
|
|
2745
|
+
look_at=look_at,
|
|
2746
|
+
dist=dist,
|
|
2747
|
+
centroid=(a.cx, a.cy, a.cz),
|
|
2748
|
+
out=out,
|
|
2749
|
+
part=a.part,
|
|
2750
|
+
cyls=a.cyls,
|
|
2751
|
+
)
|
|
2752
|
+
dwg._analysis = a # expose analysis namespace for testing and future strip access
|
|
2753
|
+
|
|
2754
|
+
part_s = a.part.scale(a.SCALE)
|
|
2755
|
+
dwg.add_view("front", part_s, (cxs, cys - dist, czs), (0, 0, 1), (a.FV_X, a.FV_Y), scaled=True)
|
|
2756
|
+
dwg.add_view("plan", part_s, (cxs, cys, czs + dist), (0, 1, 0), (a.PV_X, a.PV_Y), scaled=True)
|
|
2757
|
+
dwg.add_view("side", part_s, (cxs + dist, cys, czs), (0, 0, 1), (a.SV_X, a.SV_Y), scaled=True)
|
|
2758
|
+
_project_iso(dwg, a, a.SCALE, shape_s=part_s)
|
|
2759
|
+
_fit_iso_view(dwg, a)
|
|
2760
|
+
|
|
2761
|
+
if auto_dims:
|
|
2762
|
+
_auto_annotate(dwg, a)
|
|
2763
|
+
else:
|
|
2764
|
+
_add_title_block(dwg, a)
|
|
2765
|
+
return dwg
|
|
2766
|
+
|
|
2767
|
+
|
|
2768
|
+
# ---------------------------------------------------------------------------
|
|
2769
|
+
# Direct export (SVG + DXF)
|
|
2770
|
+
# ---------------------------------------------------------------------------
|
|
2771
|
+
|
|
2772
|
+
|
|
2773
|
+
def make_drawing(
|
|
2774
|
+
step_file: str | Path | Shape,
|
|
2775
|
+
out: str | None = None,
|
|
2776
|
+
title: str | None = None,
|
|
2777
|
+
number: str = "DWG-001",
|
|
2778
|
+
tolerance: str = "ISO 2768-m",
|
|
2779
|
+
drawn_by: str = "",
|
|
2780
|
+
scale: float | None = None,
|
|
2781
|
+
page: str | tuple | None = None,
|
|
2782
|
+
auto_dims: bool = True,
|
|
2783
|
+
pmi: Literal["off", "report", "annotate"] = "off",
|
|
2784
|
+
) -> tuple[str, str]:
|
|
2785
|
+
"""Generate a 4-view technical drawing from a STEP file or build123d object.
|
|
2786
|
+
|
|
2787
|
+
Args:
|
|
2788
|
+
step_file: Path to a STEP/STP file, or a build123d ``Shape`` (e.g. a
|
|
2789
|
+
``Part``, ``Solid``, or ``Compound``) to draw directly.
|
|
2790
|
+
out: Output path stem (default: input filename stem, or ``"drawing"``
|
|
2791
|
+
when a build123d object is passed).
|
|
2792
|
+
title: Part title for the title block (default: stem uppercased).
|
|
2793
|
+
number: Drawing number (e.g. ``"DWG-042"``).
|
|
2794
|
+
tolerance: General tolerance string (e.g. ``"ISO 2768-m"``).
|
|
2795
|
+
drawn_by: Designer name for the title block.
|
|
2796
|
+
scale: Drawing-scale override (e.g. ``5`` for 5:1, ``0.5`` for 1:2).
|
|
2797
|
+
Default: chosen automatically by :func:`choose_scale`.
|
|
2798
|
+
page: Page-size override — an ISO name (``"A3"``), ``"WIDTHxHEIGHT"``
|
|
2799
|
+
in mm, or a ``(width, height)`` tuple. Default: chosen
|
|
2800
|
+
automatically by :func:`choose_scale`.
|
|
2801
|
+
auto_dims: pass ``False`` to skip the automatic dimensions,
|
|
2802
|
+
centrelines, and leaders (#74) — views, scale, page, and title
|
|
2803
|
+
block only.
|
|
2804
|
+
|
|
2805
|
+
Returns:
|
|
2806
|
+
Tuple of ``(svg_path, dxf_path)`` for the generated files.
|
|
2807
|
+
|
|
2808
|
+
This is a thin wrapper: ``make_drawing(...)`` is ``build_drawing(...).export()``.
|
|
2809
|
+
To add or remove annotations or add section/auxiliary views before export,
|
|
2810
|
+
call :func:`build_drawing` and use the returned :class:`Drawing`.
|
|
2811
|
+
"""
|
|
2812
|
+
return build_drawing(
|
|
2813
|
+
step_file,
|
|
2814
|
+
out=out,
|
|
2815
|
+
title=title,
|
|
2816
|
+
number=number,
|
|
2817
|
+
tolerance=tolerance,
|
|
2818
|
+
drawn_by=drawn_by,
|
|
2819
|
+
scale=scale,
|
|
2820
|
+
page=page,
|
|
2821
|
+
auto_dims=auto_dims,
|
|
2822
|
+
pmi=pmi,
|
|
2823
|
+
).export()
|
|
2824
|
+
|
|
2825
|
+
|
|
2826
|
+
# ---------------------------------------------------------------------------
|
|
2827
|
+
# Script generation (Cog-enabled .py output)
|
|
2828
|
+
# ---------------------------------------------------------------------------
|
|
2829
|
+
|
|
2830
|
+
|
|
2831
|
+
def _write_script(a) -> str:
|
|
2832
|
+
"""Write an editable script at ``a.out + '.py'`` that calls make_drawing()."""
|
|
2833
|
+
py_path = a.out + ".py"
|
|
2834
|
+
py_name = Path(py_path).name
|
|
2835
|
+
|
|
2836
|
+
cog_output = "\n".join(
|
|
2837
|
+
[
|
|
2838
|
+
f"STEP_FILE = {a.step_file!r}",
|
|
2839
|
+
f"TITLE = {a.title!r}",
|
|
2840
|
+
f"NUMBER = {a.number!r}",
|
|
2841
|
+
f"TOLERANCE = {a.tolerance!r}",
|
|
2842
|
+
f"DRAWN_BY = {a.drawn_by!r}",
|
|
2843
|
+
]
|
|
2844
|
+
)
|
|
2845
|
+
|
|
2846
|
+
cog_block = (
|
|
2847
|
+
"# [[[cog\n"
|
|
2848
|
+
"# ── Config: edit these, then run `cog -r <script>.py` to update ────────────\n"
|
|
2849
|
+
f"_STEP_FILE = {a.step_file!r}\n"
|
|
2850
|
+
f"_TITLE = {a.title!r}\n"
|
|
2851
|
+
f"_NUMBER = {a.number!r}\n"
|
|
2852
|
+
f"_TOLERANCE = {a.tolerance!r}\n"
|
|
2853
|
+
f"_DRAWN_BY = {a.drawn_by!r}\n"
|
|
2854
|
+
"try:\n"
|
|
2855
|
+
" cog # NameError → not under cog\n"
|
|
2856
|
+
" for _k, _v in [\n"
|
|
2857
|
+
" ('STEP_FILE', repr(_STEP_FILE)), ('TITLE', repr(_TITLE)),\n"
|
|
2858
|
+
" ('NUMBER', repr(_NUMBER)), ('TOLERANCE', repr(_TOLERANCE)),\n"
|
|
2859
|
+
" ('DRAWN_BY', repr(_DRAWN_BY)),\n"
|
|
2860
|
+
" ]:\n"
|
|
2861
|
+
" cog.outl(f'{_k} = {_v}')\n"
|
|
2862
|
+
"except NameError:\n"
|
|
2863
|
+
" pass\n"
|
|
2864
|
+
"# ]]]\n"
|
|
2865
|
+
f"{cog_output}\n"
|
|
2866
|
+
"# [[[end]]]"
|
|
2867
|
+
)
|
|
2868
|
+
|
|
2869
|
+
_tq = '"""'
|
|
2870
|
+
_safe_doc_title = a.title.replace(_tq, "'''")
|
|
2871
|
+
_safe_doc_number = a.number.replace(_tq, "'''")
|
|
2872
|
+
header = (
|
|
2873
|
+
f"#!/usr/bin/env python3\n"
|
|
2874
|
+
f'"""\n'
|
|
2875
|
+
f"{_safe_doc_title} — Technical drawing ({_safe_doc_number}).\n"
|
|
2876
|
+
f"\n"
|
|
2877
|
+
f"Auto-generated by make-drawing. Edit freely.\n"
|
|
2878
|
+
f"To update metadata: edit _STEP_FILE / _TITLE / etc. in the cog block, then run:\n"
|
|
2879
|
+
f" cog -r {py_name} (pip install cogapp)\n"
|
|
2880
|
+
f"\n"
|
|
2881
|
+
f"Run: uv run python {py_name}\n"
|
|
2882
|
+
f'"""\n'
|
|
2883
|
+
f"import os as _os\n"
|
|
2884
|
+
f"from draftwright import build_drawing\n"
|
|
2885
|
+
f"\n"
|
|
2886
|
+
f"# ── Config (auto-updated by cog) ──────────────────────────────────────────────\n"
|
|
2887
|
+
)
|
|
2888
|
+
|
|
2889
|
+
run_section = (
|
|
2890
|
+
"\n"
|
|
2891
|
+
"# ── Build drawing (standard 4-view layout + automatic dimensions) ─────────────\n"
|
|
2892
|
+
"_stem = _os.path.splitext(__file__)[0]\n"
|
|
2893
|
+
"dwg = build_drawing(\n"
|
|
2894
|
+
" STEP_FILE,\n"
|
|
2895
|
+
" out=_stem,\n"
|
|
2896
|
+
" title=TITLE,\n"
|
|
2897
|
+
" number=NUMBER,\n"
|
|
2898
|
+
" tolerance=TOLERANCE,\n"
|
|
2899
|
+
" drawn_by=DRAWN_BY,\n"
|
|
2900
|
+
")\n"
|
|
2901
|
+
"\n"
|
|
2902
|
+
"# ── Customise here — runs BEFORE export, so edits land in the output ───────────\n"
|
|
2903
|
+
"# dwg.views 'front' 'plan' 'side' 'iso' → (visible, hidden) compounds\n"
|
|
2904
|
+
"# dwg.annotations mutable list of annotation objects\n"
|
|
2905
|
+
"# dwg.at(view, x, y, z) → page point (px, py, 0) mapped from world coordinates\n"
|
|
2906
|
+
"# dwg.add(obj, name) / dwg.remove(name)\n"
|
|
2907
|
+
"# dwg.add_view(name, shape, camera, up, position) → section / auxiliary view\n"
|
|
2908
|
+
"# Example:\n"
|
|
2909
|
+
"# from build123d_drafting import Leader\n"
|
|
2910
|
+
"# dwg.add(Leader(tip=dwg.at('front', 10, 0, 5), elbow=(8, 40, 0),\n"
|
|
2911
|
+
"# label='ø4 BORE', draft=dwg.draft), 'ldr_bore')\n"
|
|
2912
|
+
"# dwg.remove('dim_height')\n"
|
|
2913
|
+
"\n"
|
|
2914
|
+
"# ── Export ────────────────────────────────────────────────────────────────────\n"
|
|
2915
|
+
"svg_path, dxf_path = dwg.export(_stem)\n"
|
|
2916
|
+
'print(f"SVG \\u2192 {svg_path}")\n'
|
|
2917
|
+
'print(f"DXF \\u2192 {dxf_path}")\n'
|
|
2918
|
+
)
|
|
2919
|
+
|
|
2920
|
+
content = header + cog_block + run_section
|
|
2921
|
+
Path(py_path).write_text(content, encoding="utf-8")
|
|
2922
|
+
_log.info("Script → %s", py_path)
|
|
2923
|
+
return py_path
|
|
2924
|
+
|
|
2925
|
+
|
|
2926
|
+
def generate_script(
|
|
2927
|
+
step_file: str,
|
|
2928
|
+
out: str | None = None,
|
|
2929
|
+
title: str | None = None,
|
|
2930
|
+
number: str = "DWG-001",
|
|
2931
|
+
tolerance: str = "ISO 2768-m",
|
|
2932
|
+
drawn_by: str = "",
|
|
2933
|
+
pmi: Literal["off", "report", "annotate"] = "off",
|
|
2934
|
+
) -> str:
|
|
2935
|
+
"""Generate an editable Cog-enabled drawing script from a STEP file.
|
|
2936
|
+
|
|
2937
|
+
Returns:
|
|
2938
|
+
Path to the generated ``.py`` file.
|
|
2939
|
+
"""
|
|
2940
|
+
if isinstance(step_file, Shape):
|
|
2941
|
+
raise TypeError(
|
|
2942
|
+
"generate_script() requires a STEP file path — the generated script "
|
|
2943
|
+
"reloads geometry from disk and cannot embed a live build123d object. "
|
|
2944
|
+
"Use make_drawing() directly to draw an in-memory object."
|
|
2945
|
+
)
|
|
2946
|
+
stem = Path(step_file).stem
|
|
2947
|
+
out = out or stem
|
|
2948
|
+
for _ext in (".py", ".svg", ".dxf"):
|
|
2949
|
+
if out.endswith(_ext):
|
|
2950
|
+
out = out[: -len(_ext)]
|
|
2951
|
+
break
|
|
2952
|
+
title = title or stem.replace("_", " ").upper()
|
|
2953
|
+
a = _analyse(step_file, title, number, tolerance, drawn_by, out, pmi=pmi)
|
|
2954
|
+
return _write_script(a)
|
|
2955
|
+
|
|
2956
|
+
|
|
2957
|
+
# ---------------------------------------------------------------------------
|
|
2958
|
+
# CLI
|
|
2959
|
+
# ---------------------------------------------------------------------------
|
|
2960
|
+
|
|
2961
|
+
|
|
2962
|
+
def _cli():
|
|
2963
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
2964
|
+
ap = argparse.ArgumentParser(
|
|
2965
|
+
description="Zero-AI STEP → technical drawing (SVG + DXF, or editable .py script)",
|
|
2966
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
2967
|
+
)
|
|
2968
|
+
ap.add_argument("step_file", help="Input STEP file (.step / .stp)")
|
|
2969
|
+
ap.add_argument("--out", default=None, help="Output prefix (default: input stem)")
|
|
2970
|
+
ap.add_argument("--title", default=None, help="Part title for title block")
|
|
2971
|
+
ap.add_argument("--number", default="DWG-001", help="Drawing number")
|
|
2972
|
+
ap.add_argument("--tolerance", default="ISO 2768-m", help="General tolerance")
|
|
2973
|
+
ap.add_argument("--drawn-by", default="", help="Designer name")
|
|
2974
|
+
ap.add_argument(
|
|
2975
|
+
"--scale",
|
|
2976
|
+
type=float,
|
|
2977
|
+
default=None,
|
|
2978
|
+
help="Drawing-scale override, e.g. 5 for 5:1 or 0.5 for 1:2 (default: auto)",
|
|
2979
|
+
)
|
|
2980
|
+
ap.add_argument(
|
|
2981
|
+
"--page",
|
|
2982
|
+
default=None,
|
|
2983
|
+
help="Page-size override: A4..A0 or WIDTHxHEIGHT in mm, e.g. 420x297 (default: auto)",
|
|
2984
|
+
)
|
|
2985
|
+
ap.add_argument(
|
|
2986
|
+
"--script",
|
|
2987
|
+
action="store_true",
|
|
2988
|
+
help="Write an editable .py drawing script instead of SVG+DXF",
|
|
2989
|
+
)
|
|
2990
|
+
ap.add_argument(
|
|
2991
|
+
"--pmi",
|
|
2992
|
+
default="off",
|
|
2993
|
+
choices=["off", "report", "annotate"],
|
|
2994
|
+
help=(
|
|
2995
|
+
"AP242 PMI handling: 'off' (default) — ignore; "
|
|
2996
|
+
"'report' — log extracted PMI without annotating; "
|
|
2997
|
+
"'annotate' — add PMI-derived dimensions to the drawing"
|
|
2998
|
+
),
|
|
2999
|
+
)
|
|
3000
|
+
args = ap.parse_args()
|
|
3001
|
+
|
|
3002
|
+
if args.script and (args.scale is not None or args.page is not None):
|
|
3003
|
+
ap.error("--scale/--page only apply to direct output; edit the generated script instead")
|
|
3004
|
+
|
|
3005
|
+
if args.script:
|
|
3006
|
+
generate_script(
|
|
3007
|
+
step_file=args.step_file,
|
|
3008
|
+
out=args.out,
|
|
3009
|
+
title=args.title,
|
|
3010
|
+
number=args.number,
|
|
3011
|
+
tolerance=args.tolerance,
|
|
3012
|
+
drawn_by=args.drawn_by,
|
|
3013
|
+
pmi=args.pmi,
|
|
3014
|
+
)
|
|
3015
|
+
else:
|
|
3016
|
+
make_drawing(
|
|
3017
|
+
step_file=args.step_file,
|
|
3018
|
+
out=args.out,
|
|
3019
|
+
title=args.title,
|
|
3020
|
+
number=args.number,
|
|
3021
|
+
tolerance=args.tolerance,
|
|
3022
|
+
drawn_by=args.drawn_by,
|
|
3023
|
+
scale=args.scale,
|
|
3024
|
+
page=args.page,
|
|
3025
|
+
pmi=args.pmi,
|
|
3026
|
+
)
|
|
3027
|
+
|
|
3028
|
+
|
|
3029
|
+
if __name__ == "__main__":
|
|
3030
|
+
_cli()
|