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.
@@ -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()