draftwright 0.1.6__tar.gz → 0.1.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- draftwright-0.1.7/CHANGELOG.md +148 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/PKG-INFO +1 -1
- {draftwright-0.1.6 → draftwright-0.1.7}/pyproject.toml +1 -1
- {draftwright-0.1.6 → draftwright-0.1.7}/src/draftwright/make_drawing.py +261 -62
- {draftwright-0.1.6 → draftwright-0.1.7}/tests/test_e2e_standards.py +6 -6
- {draftwright-0.1.6 → draftwright-0.1.7}/tests/test_make_drawing.py +312 -9
- draftwright-0.1.6/CHANGELOG.md +0 -77
- {draftwright-0.1.6 → draftwright-0.1.7}/.gitignore +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/LICENSE +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/README.md +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/skills/SKILL.md +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/src/draftwright/__init__.py +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/src/draftwright/pmi.py +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
- {draftwright-0.1.6 → draftwright-0.1.7}/tests/test_pmi.py +0 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **Hole callouts are no longer capped at four per view.** Every distinct bore
|
|
8
|
+
is attempted; the per-view placement bounds (front-view shaft rows, plan/side
|
|
9
|
+
strip Y-solver) are the real limit, and a callout that genuinely doesn't fit
|
|
10
|
+
surfaces as `callout_dropped` (a warning, with its diameter, excluded from
|
|
11
|
+
`feature_not_dimensioned`). Three previously-silent front-view skip paths now
|
|
12
|
+
surface too. The bore-callout "no room"/"strip full" drops are reclassified
|
|
13
|
+
from error (`placement_unsatisfiable`) to this warning, since under the
|
|
14
|
+
adaptive model an unplaceable callout is an incomplete — not invalid —
|
|
15
|
+
drawing. Completes the adaptive-caps work (#36); NIST CTC parts now place
|
|
16
|
+
5–9 callouts (vs a capped 4) with no error-severity lint.
|
|
17
|
+
- **Step-height dimensions are no longer capped at three.** The `fv_zones.right`
|
|
18
|
+
corridor is now sized for every legible step (`_est_right_strip_depth` no
|
|
19
|
+
longer caps the count), and a step dim is placed for each legible level. A
|
|
20
|
+
part with several shoulders gets them all dimensioned instead of an arbitrary
|
|
21
|
+
three; the strip allocator remains the real bound (an unplaceable step
|
|
22
|
+
surfaces as `placement_unsatisfiable`). Verified the NIST CTC parts (8–16
|
|
23
|
+
step faces each) build with no error-severity lint. Second step of the
|
|
24
|
+
adaptive-caps work (#36); the per-view callout cap follows. The
|
|
25
|
+
`step_dim_dropped` lint code is removed (the cap that produced it is gone).
|
|
26
|
+
- Hole **location dimensions are no longer capped at four** per part: they are
|
|
27
|
+
placed nearest-datum-first (baseline practice) until the above-view tier
|
|
28
|
+
strips fill, so a part with room gets all its holes located instead of an
|
|
29
|
+
arbitrary four. Refs that genuinely don't fit are skipped (never
|
|
30
|
+
force-placed) and surface as `location_ref_dropped` (#36). First step of the
|
|
31
|
+
adaptive-caps work; step-height and per-view callout caps follow.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- `Drawing.lint_summary()` — a JSON-friendly aggregate of `lint()` for
|
|
36
|
+
non-interactive callers (scripts, or an LLM via the API): severity counts,
|
|
37
|
+
per-code counts, a `geometry_issues` tally (standards/geometry checks vs pure
|
|
38
|
+
layout), a `passed` flag, a coarse 0–1 `score`, and the full issue list. Gives
|
|
39
|
+
a single signal to gate and optimise on without rendering the SVG (#32).
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- Annotations the layout had to drop (hole callouts past the per-view cap,
|
|
44
|
+
location references past the per-part cap, step-height dimensions past the
|
|
45
|
+
first three, bore callouts with no room or an unsatisfiable strip) are no
|
|
46
|
+
longer silent: each is recorded during the build and surfaced by `lint()`
|
|
47
|
+
under a dedicated code (`callout_dropped`, `location_ref_dropped`,
|
|
48
|
+
`step_dim_dropped`, `placement_unsatisfiable`), so a short drawing always
|
|
49
|
+
carries a machine-readable reason (#32).
|
|
50
|
+
- `placement_unsatisfiable` (the engine could not place an annotation it wanted
|
|
51
|
+
to, as opposed to a deliberate cap) is **error** severity, so it fails the
|
|
52
|
+
`lint_summary()` `passed` gate; the deliberate-cap drops stay warnings (#32).
|
|
53
|
+
- A callout dropped by the per-view cap is no longer double-reported: the
|
|
54
|
+
dropped diameters are named in the `callout_dropped` message and excluded from
|
|
55
|
+
`feature_not_dimensioned` (#32).
|
|
56
|
+
- `_auto_annotate` is idempotent for build-time lint records — re-annotating a
|
|
57
|
+
drawing no longer accumulates duplicate drop reports (#32).
|
|
58
|
+
|
|
59
|
+
## v0.1.6 — 2026-06-15
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- Section-view boolean cut on cast geometry: the exact `body - Box(...)` boolean
|
|
64
|
+
raised an uncatchable `Standard_DomainError` (C++ abort, SIGABRT) on some parts
|
|
65
|
+
(NIST CTC-04), crashing the whole drawing. `_fuzzy_cut()` now runs
|
|
66
|
+
`BRepAlgoAPI_Cut` with a small fuzzy tolerance and keeps solids-only, making
|
|
67
|
+
the section cut robust (#20, #22).
|
|
68
|
+
|
|
69
|
+
### Tests
|
|
70
|
+
|
|
71
|
+
- NIST CTC-04 (both AP203 and AP242) now build with a clean section view and are
|
|
72
|
+
covered by the CTC build tests.
|
|
73
|
+
- Known: CTC-02 AP242 still segfaults inside OCCT's AP242/PMI STEP read (#20),
|
|
74
|
+
excluded from build tests.
|
|
75
|
+
|
|
76
|
+
## v0.1.5 — 2026-06-15
|
|
77
|
+
|
|
78
|
+
### Fixed
|
|
79
|
+
|
|
80
|
+
- CTC-02 spurious full-page line: build123d's `ExportSVG` projected
|
|
81
|
+
circle-edge-on edges (hole/fillet rims seen edge-on) as elliptical arcs with
|
|
82
|
+
a near-zero minor radius, which renderers blow up into full-page lines.
|
|
83
|
+
`sanitize_svg_arcs()` rewrites any arc with a sub-1e-3 mm radius into the
|
|
84
|
+
straight line it actually is, leaving real arcs untouched (#19). Not a PMI
|
|
85
|
+
issue — the file is AP203 geometry-only.
|
|
86
|
+
|
|
87
|
+
### Tests
|
|
88
|
+
|
|
89
|
+
- Added the full NIST CTC set (01–05) as fixtures, both AP203 geometry-only and
|
|
90
|
+
AP242 (with-PMI) variants.
|
|
91
|
+
- Heavy end-to-end CTC fixture builds are marked `slow` and deselected from the
|
|
92
|
+
default `pytest` run (fast normal run, ~4.5 min); CI runs the fast tier across
|
|
93
|
+
the OS/Python matrix and the slow tier once.
|
|
94
|
+
- Known: AP242 CTC-02 and both CTC-04 variants crash OCCT on import (#20); their
|
|
95
|
+
fixtures are excluded from build tests.
|
|
96
|
+
|
|
97
|
+
## v0.1.4 — 2026-06-15
|
|
98
|
+
|
|
99
|
+
### Changed
|
|
100
|
+
|
|
101
|
+
- Feature annotations (hole callouts, location dimensions, section view) now
|
|
102
|
+
fire on feature presence independent of the turned/prismatic classification,
|
|
103
|
+
so turned-and-drilled parts (e.g. flanges) get both the OD/centreline base
|
|
104
|
+
set and per-hole callouts plus bolt-circle furniture (#10).
|
|
105
|
+
- Isometric view placement now uses a general largest-empty-rectangle search in
|
|
106
|
+
place of the wide/flat-on-A3 special case (#11).
|
|
107
|
+
- Concentric bore-leader stacking is generalised beyond three, and the
|
|
108
|
+
step-height dimension gate is now a single derived constant (#10, #12).
|
|
109
|
+
|
|
110
|
+
### Internal
|
|
111
|
+
|
|
112
|
+
- Single-sourced duplicated geometry constants from the draft preset (#12).
|
|
113
|
+
- Minor comment and logging cleanups.
|
|
114
|
+
|
|
115
|
+
## v0.1.0 — 2026-06-14
|
|
116
|
+
|
|
117
|
+
Initial release — spun out of `build123d-drafting-helpers` v0.9.1.
|
|
118
|
+
|
|
119
|
+
The automated drawing engine (`make_drawing`, `build_drawing`, `Drawing`)
|
|
120
|
+
was previously part of `build123d-drafting-helpers`. It is now a separate
|
|
121
|
+
AGPL-licensed package that depends on `build123d-drafting-helpers>=0.9.1`
|
|
122
|
+
for annotation primitives.
|
|
123
|
+
|
|
124
|
+
### Migration from build123d-drafting-helpers
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
# Before
|
|
128
|
+
from build123d_drafting import make_drawing, Drawing, build_drawing
|
|
129
|
+
|
|
130
|
+
# After
|
|
131
|
+
from draftwright import make_drawing, Drawing, build_drawing
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Features (carried over from build123d-drafting-helpers)
|
|
135
|
+
|
|
136
|
+
- **`make_drawing`** / **`build_drawing`** — automatic multi-view technical
|
|
137
|
+
drawing from a build123d solid: view layout, scale selection, orthographic
|
|
138
|
+
projection, dimension placement, title block.
|
|
139
|
+
- **`Drawing`** — composable drawing object with `.lint()`, `.add()`,
|
|
140
|
+
`.export_svg()`, `.export_dxf()`.
|
|
141
|
+
- **`choose_scale`** — ISO/ASME standard scale selection.
|
|
142
|
+
- **`lint_feature_coverage`** — checks annotation coverage against detected
|
|
143
|
+
part features (holes, bosses, bolt circles).
|
|
144
|
+
- **Section A–A views** — automatic section view for blind/stepped holes,
|
|
145
|
+
with ISO 128-44 solid filled cutting-plane arrows and ISO 128-50 45°
|
|
146
|
+
hatching on the cut face.
|
|
147
|
+
- **`generate_script`** — generates a standalone drawing script from a STEP
|
|
148
|
+
file.
|
|
@@ -17,6 +17,7 @@ CLI (registered as ``make-drawing``)::
|
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
19
|
import argparse
|
|
20
|
+
import functools
|
|
20
21
|
import logging
|
|
21
22
|
import math
|
|
22
23
|
import re
|
|
@@ -26,6 +27,7 @@ from types import SimpleNamespace
|
|
|
26
27
|
from typing import Literal
|
|
27
28
|
|
|
28
29
|
from build123d import (
|
|
30
|
+
Align,
|
|
29
31
|
Arrow,
|
|
30
32
|
Box,
|
|
31
33
|
Color,
|
|
@@ -40,8 +42,8 @@ from build123d import (
|
|
|
40
42
|
Mode,
|
|
41
43
|
Pos,
|
|
42
44
|
Shape,
|
|
45
|
+
Text,
|
|
43
46
|
Vector,
|
|
44
|
-
import_step,
|
|
45
47
|
)
|
|
46
48
|
from build123d_drafting.features import (
|
|
47
49
|
BoltCircle,
|
|
@@ -73,6 +75,8 @@ from build123d_drafting.helpers import (
|
|
|
73
75
|
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
|
74
76
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
|
|
75
77
|
from OCP.GeomAbs import GeomAbs_Plane
|
|
78
|
+
from OCP.IFSelect import IFSelect_ReturnStatus
|
|
79
|
+
from OCP.STEPControl import STEPControl_Reader
|
|
76
80
|
from OCP.TopTools import TopTools_ListOfShape
|
|
77
81
|
|
|
78
82
|
_log = logging.getLogger(__name__)
|
|
@@ -167,6 +171,23 @@ def sanitize_svg_arcs(svg_path: str) -> int:
|
|
|
167
171
|
return n
|
|
168
172
|
|
|
169
173
|
|
|
174
|
+
def _import_step(path) -> Compound:
|
|
175
|
+
"""Read solid geometry from a STEP file via OCCT's ``STEPControl_Reader``.
|
|
176
|
+
|
|
177
|
+
build123d's ``import_step`` uses the XCAF reader (colours, names, PMI), which
|
|
178
|
+
**segfaults** on some AP242 files carrying semantic PMI — e.g. NIST CTC-02
|
|
179
|
+
AP242 (#20) — before any Python code can intervene. draftwright needs only
|
|
180
|
+
the solid geometry (it drops PMI presentation data anyway), so we read the
|
|
181
|
+
geometry directly. Verified to produce identical shapes (solids, edges, bbox)
|
|
182
|
+
to ``import_step`` on the files that read in both, minus the unused metadata.
|
|
183
|
+
"""
|
|
184
|
+
reader = STEPControl_Reader()
|
|
185
|
+
if reader.ReadFile(str(path)) != IFSelect_ReturnStatus.IFSelect_RetDone:
|
|
186
|
+
raise ValueError(f"could not read STEP file {path!r}")
|
|
187
|
+
reader.TransferRoots()
|
|
188
|
+
return Compound(reader.OneShape())
|
|
189
|
+
|
|
190
|
+
|
|
170
191
|
# ---------------------------------------------------------------------------
|
|
171
192
|
# Geometry analysis
|
|
172
193
|
# ---------------------------------------------------------------------------
|
|
@@ -188,6 +209,30 @@ def _fmt(v: float) -> str:
|
|
|
188
209
|
return str(r) if abs(v - r) < 1e-6 else f"{v:.1f}"
|
|
189
210
|
|
|
190
211
|
|
|
212
|
+
@functools.lru_cache(maxsize=512)
|
|
213
|
+
def _text_width(text: str, font_size: float, font: str = "Arial") -> float:
|
|
214
|
+
"""Measured rendered width (page-mm) of *text* in *font* at *font_size*.
|
|
215
|
+
|
|
216
|
+
Uses build123d's ``Text`` — the same primitive ``Dimension``/``HoleCallout``
|
|
217
|
+
stroke their labels with — so callout-width estimates use real glyph metrics
|
|
218
|
+
instead of a character-count fudge (#31). Cached because the same numeric
|
|
219
|
+
labels recur across holes and the rasterisation is the costly part.
|
|
220
|
+
"""
|
|
221
|
+
if not text:
|
|
222
|
+
return 0.0
|
|
223
|
+
return (
|
|
224
|
+
Text(
|
|
225
|
+
txt=text,
|
|
226
|
+
font_size=font_size,
|
|
227
|
+
font=font,
|
|
228
|
+
align=(Align.CENTER, Align.CENTER),
|
|
229
|
+
mode=Mode.PRIVATE,
|
|
230
|
+
)
|
|
231
|
+
.bounding_box()
|
|
232
|
+
.size.X
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
191
236
|
_DIAM_RE = re.compile(r"[øØ⌀]\s*(\d+(?:\.\d+)?)")
|
|
192
237
|
|
|
193
238
|
# Turned-part classification (#81): a rotational part's bounding box is
|
|
@@ -252,9 +297,14 @@ def _concentric_bore_diams(a) -> list:
|
|
|
252
297
|
return [d for d in a.z_diams if d != a.od_diam and any(abs(d - c) <= 0.15 for c in concentric)]
|
|
253
298
|
|
|
254
299
|
|
|
255
|
-
def lint_feature_coverage(part, annotations, tol: float = 0.15, cyls=None) -> list:
|
|
300
|
+
def lint_feature_coverage(part, annotations, tol: float = 0.15, cyls=None, exclude=None) -> list:
|
|
256
301
|
"""Coarse completeness check: report part diameters with no callout (#80).
|
|
257
302
|
|
|
303
|
+
``exclude`` is an optional iterable of diameters already accounted for by a
|
|
304
|
+
more specific build-time lint (e.g. the per-view callout cap's
|
|
305
|
+
``callout_dropped``); these are skipped here so a dropped callout is not
|
|
306
|
+
double-reported as ``feature_not_dimensioned``.
|
|
307
|
+
|
|
258
308
|
Builds a feature inventory from *part*'s hole/boss diameters (cylinder
|
|
259
309
|
patches spanning at least ~half a turn around their axis in total, so
|
|
260
310
|
fillets are ignored) and diffs it against every ø value mentioned in the
|
|
@@ -294,6 +344,7 @@ def lint_feature_coverage(part, annotations, tol: float = 0.15, cyls=None) -> li
|
|
|
294
344
|
mentioned.add(float(v))
|
|
295
345
|
provided[float(v)] = provided.get(float(v), 0) + count
|
|
296
346
|
|
|
347
|
+
exclude = exclude or ()
|
|
297
348
|
issues = [
|
|
298
349
|
LintIssue(
|
|
299
350
|
severity="warning",
|
|
@@ -302,6 +353,7 @@ def lint_feature_coverage(part, annotations, tol: float = 0.15, cyls=None) -> li
|
|
|
302
353
|
)
|
|
303
354
|
for d in inventory
|
|
304
355
|
if not any(abs(d - v) <= tol for v in mentioned)
|
|
356
|
+
and not any(abs(d - e) <= tol for e in exclude)
|
|
305
357
|
]
|
|
306
358
|
|
|
307
359
|
required: dict[float, int] = {}
|
|
@@ -326,6 +378,30 @@ def lint_feature_coverage(part, annotations, tol: float = 0.15, cyls=None) -> li
|
|
|
326
378
|
return issues
|
|
327
379
|
|
|
328
380
|
|
|
381
|
+
# --- lint scoring (see Drawing.lint_summary) -------------------------------
|
|
382
|
+
# Codes that check standards/geometry correctness rather than pure page
|
|
383
|
+
# layout. Grouped so a caller (and the #30 repair loop) can tell a wrong
|
|
384
|
+
# drawing from a merely tight one.
|
|
385
|
+
_GEOMETRY_AWARE_CODES = frozenset(
|
|
386
|
+
{
|
|
387
|
+
"feature_not_dimensioned",
|
|
388
|
+
"feature_count_mismatch",
|
|
389
|
+
"missing_principal_dimension",
|
|
390
|
+
"label_vs_measured",
|
|
391
|
+
"dim_inside_part",
|
|
392
|
+
"callout_dropped",
|
|
393
|
+
"location_ref_dropped",
|
|
394
|
+
"placement_unsatisfiable",
|
|
395
|
+
}
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Coarse 0–1 quality heuristic: a clean sheet scores 1.0; each issue subtracts
|
|
399
|
+
# a flat per-severity penalty (clamped at 0). A convenience signal only — the
|
|
400
|
+
# severity/code counts in the summary are the authoritative output.
|
|
401
|
+
_SCORE_ERROR_PENALTY = 0.2
|
|
402
|
+
_SCORE_WARNING_PENALTY = 0.05
|
|
403
|
+
|
|
404
|
+
|
|
329
405
|
def analyse_face_levels(part, tol: float = 0.5) -> list:
|
|
330
406
|
"""Return sorted unique Z-coords of horizontal (normal≈±Z) planar faces.
|
|
331
407
|
|
|
@@ -501,14 +577,37 @@ def _parse_page(page) -> tuple:
|
|
|
501
577
|
_STRIP_GAP = 8.0
|
|
502
578
|
_STRIP_SPACING = 4.0
|
|
503
579
|
|
|
580
|
+
# Horizontal page budget to reserve for the isometric view during scale
|
|
581
|
+
# selection and view placement, as a fraction of bbox_max * scale. This is a
|
|
582
|
+
# deliberate *under-estimate*, not the true projected size (a cube's iso
|
|
583
|
+
# projection is ~1.63*bbox_max wide): the iso is the last column and is fitted
|
|
584
|
+
# to the actual largest-empty-rect afterwards by _fit_iso_view(), which shrinks
|
|
585
|
+
# it to whatever space is genuinely left. A true fit test here is circular —
|
|
586
|
+
# the empty rect depends on the very view positions this estimate feeds — so
|
|
587
|
+
# the budget stays a single, named factor rather than a recomputed fit (#31).
|
|
588
|
+
_ISO_WIDTH_BUDGET = 0.7
|
|
589
|
+
|
|
504
590
|
# Slot sizes for the annotations that allocate from fv/pv/sv strips.
|
|
505
591
|
# Shared between the depth estimators below and the allocate() call-sites in
|
|
506
592
|
# _auto_annotate() so that a slot-size change is automatically reflected in
|
|
507
593
|
# the estimator-driven corridor widths.
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
594
|
+
#
|
|
595
|
+
# A slot is the perpendicular depth (page-mm) reserved for one Dimension: its
|
|
596
|
+
# dim-line offset from the view edge plus the label, which sits exactly
|
|
597
|
+
# pad_around_text beyond the line (measured: a "right"/"below" Dimension's
|
|
598
|
+
# perpendicular span equals offset + pad_around_text - extension_gap, and
|
|
599
|
+
# pad == extension_gap in the draft preset). Each slot is therefore derived
|
|
600
|
+
# from text metrics (font_size + pad_around_text), like _MIN_STEP_DIM_MM, so it
|
|
601
|
+
# rescales with _FONT_SIZE instead of being a bare mm guess (#31).
|
|
602
|
+
_PAD = draft_preset(font_size=_FONT_SIZE, decimal_precision=1).pad_around_text
|
|
603
|
+
# Single overall dim: two glyph-heights of line offset + the outboard label pad.
|
|
604
|
+
_SLOT_DIM_WIDTH = 2 * _FONT_SIZE + _PAD # pv_zones.below: overall width dimension
|
|
605
|
+
_SLOT_DIM_DEPTH = 2 * _FONT_SIZE + _PAD # sv_zones.below: overall depth dimension
|
|
606
|
+
# The overall height dim leads the right ladder, so it carries an extra pad of
|
|
607
|
+
# clearance from the view above the first step dim's witness.
|
|
608
|
+
_SLOT_DIM_HEIGHT = 2 * _FONT_SIZE + 2 * _PAD # fv_zones.right: overall height dim
|
|
609
|
+
# Stacked step dims sit deeper so each ladder rung's label clears the rung below.
|
|
610
|
+
_SLOT_DIM_STEP = 4 * _FONT_SIZE + _PAD # fv_zones.right: step-height dimension
|
|
512
611
|
|
|
513
612
|
# Smallest projected step height (page-mm) that can still carry a *legible*
|
|
514
613
|
# stacked dimension between its two extension lines. Derived from what has to
|
|
@@ -536,12 +635,11 @@ _MIN_STEP_DIM_MM = (
|
|
|
536
635
|
def _est_right_strip_depth(n_steps: int) -> float:
|
|
537
636
|
"""Depth needed to the right of the front view.
|
|
538
637
|
|
|
539
|
-
Always includes dim_height (1 slot).
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
allocations succeed.
|
|
638
|
+
Always includes dim_height (1 slot). *n_steps* dim_step slots follow if
|
|
639
|
+
any step levels are present. Returns the minimum corridor width (from view
|
|
640
|
+
edge to outer_limit) that makes all those allocations succeed.
|
|
543
641
|
"""
|
|
544
|
-
n = 1 +
|
|
642
|
+
n = 1 + max(n_steps, 0) # dim_height + one slot per step dim
|
|
545
643
|
# gap + dim_height + (n-1) step slots each preceded by one spacing
|
|
546
644
|
return _STRIP_GAP + _SLOT_DIM_HEIGHT + (n - 1) * (_STRIP_SPACING + _SLOT_DIM_STEP)
|
|
547
645
|
|
|
@@ -597,10 +695,13 @@ def _est_bore_callout_width(
|
|
|
597
695
|
bc_by_spec[_spec_key(p.holes[0])] = p
|
|
598
696
|
|
|
599
697
|
h_fs = font_size
|
|
698
|
+
# gap (inter-token spacing) and sym_w (geometry-symbol cell width) mirror
|
|
699
|
+
# HoleCallout's own internal layout constants so the estimate matches what
|
|
700
|
+
# the primitive actually strokes. Variable text tokens are measured with
|
|
701
|
+
# real glyph metrics (_text_width) rather than a character-count fudge (#31).
|
|
600
702
|
gap = 0.45 * h_fs
|
|
601
703
|
sym_w = h_fs
|
|
602
704
|
pad = pad_around_text
|
|
603
|
-
char_w = 0.6 * h_fs # avg character width
|
|
604
705
|
|
|
605
706
|
max_w = 0.0
|
|
606
707
|
for spec_key, group in groups.items():
|
|
@@ -611,26 +712,26 @@ def _est_bore_callout_width(
|
|
|
611
712
|
|
|
612
713
|
token_w: list[float] = []
|
|
613
714
|
if count:
|
|
614
|
-
token_w.append(
|
|
715
|
+
token_w.append(_text_width(f"{count}×", h_fs))
|
|
615
716
|
token_w.append(sym_w) # ⌀ symbol
|
|
616
|
-
token_w.append(
|
|
717
|
+
token_w.append(_text_width(_fmt(rep.diameter), h_fs))
|
|
617
718
|
if through:
|
|
618
|
-
token_w.append(
|
|
719
|
+
token_w.append(_text_width("THRU", h_fs))
|
|
619
720
|
elif rep.depth:
|
|
620
721
|
token_w.append(sym_w) # depth symbol
|
|
621
|
-
token_w.append(
|
|
722
|
+
token_w.append(_text_width(_fmt(rep.depth), h_fs))
|
|
622
723
|
if step:
|
|
623
724
|
token_w.append(sym_w) # counterbore/spotface symbol
|
|
624
725
|
token_w.append(sym_w) # ⌀
|
|
625
|
-
token_w.append(
|
|
726
|
+
token_w.append(_text_width(_fmt(step.diameter), h_fs))
|
|
626
727
|
if step.depth:
|
|
627
728
|
token_w.append(sym_w) # depth symbol
|
|
628
|
-
token_w.append(
|
|
729
|
+
token_w.append(_text_width(_fmt(step.depth), h_fs))
|
|
629
730
|
|
|
630
731
|
# BoltCircle suffix: "EQ SP ON ø{bc_dia} BC"
|
|
631
732
|
bc = bc_by_spec.get(spec_key)
|
|
632
733
|
if bc is not None:
|
|
633
|
-
token_w.append(
|
|
734
|
+
token_w.append(_text_width(f"EQ SP ON ø{_fmt(bc.diameter)} BC", h_fs))
|
|
634
735
|
|
|
635
736
|
n = len(token_w)
|
|
636
737
|
w = sum(token_w) + max(n - 1, 0) * gap + pad
|
|
@@ -705,7 +806,7 @@ def _fits(
|
|
|
705
806
|
+ gap_fv_sv
|
|
706
807
|
+ y_size * scale
|
|
707
808
|
+ _DIM_PAD
|
|
708
|
-
+ bbox_max * scale *
|
|
809
|
+
+ bbox_max * scale * _ISO_WIDTH_BUDGET
|
|
709
810
|
+ _DIM_PAD
|
|
710
811
|
+ tb_w
|
|
711
812
|
+ _MARGIN
|
|
@@ -840,7 +941,7 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
|
|
|
840
941
|
part = step_file
|
|
841
942
|
src = "build123d object"
|
|
842
943
|
else:
|
|
843
|
-
part =
|
|
944
|
+
part = _import_step(step_file)
|
|
844
945
|
src = str(step_file)
|
|
845
946
|
# AP242 STEP files carry PMI presentation geometry (annotation-plane
|
|
846
947
|
# border wires, leader curves) beside the solid; left in, it draws as
|
|
@@ -923,7 +1024,7 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
|
|
|
923
1024
|
|
|
924
1025
|
# Conservative upper bound for page selection: count all candidate step
|
|
925
1026
|
# faces without the SCALE-dependent _MIN_STEP_DIM_MM gate (SCALE not yet known).
|
|
926
|
-
n_steps_ub = len(step_zs
|
|
1027
|
+
n_steps_ub = len(step_zs)
|
|
927
1028
|
strips_ub = _measure_strips(
|
|
928
1029
|
holes,
|
|
929
1030
|
patterns,
|
|
@@ -954,7 +1055,7 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
|
|
|
954
1055
|
DIM_PAD = _DIM_PAD
|
|
955
1056
|
margin = _MARGIN
|
|
956
1057
|
# Refine: apply the same legibility gate _auto_annotate uses for dim_step.
|
|
957
|
-
n_steps = len([z for z in step_zs
|
|
1058
|
+
n_steps = len([z for z in step_zs if (z - bb.min.Z) * SCALE >= _MIN_STEP_DIM_MM])
|
|
958
1059
|
strips = _measure_strips(
|
|
959
1060
|
holes,
|
|
960
1061
|
patterns,
|
|
@@ -980,7 +1081,7 @@ def _analyse(step_file, title, number, tolerance, drawn_by, out, scale=None, pag
|
|
|
980
1081
|
+ x_size * SCALE
|
|
981
1082
|
+ y_size * SCALE
|
|
982
1083
|
+ 2 * DIM_PAD
|
|
983
|
-
+ bbox_max * SCALE *
|
|
1084
|
+
+ bbox_max * SCALE * _ISO_WIDTH_BUDGET
|
|
984
1085
|
)
|
|
985
1086
|
x_offset = max(0.0, (PAGE_W - 2 * margin - TB_W - total_content_w) / 2)
|
|
986
1087
|
|
|
@@ -1220,6 +1321,14 @@ class Drawing:
|
|
|
1220
1321
|
self.svg_path: str | None = None
|
|
1221
1322
|
self.dxf_path: str | None = None
|
|
1222
1323
|
self._analysis: SimpleNamespace | None = None
|
|
1324
|
+
# Lint issues found while building (e.g. annotations the layout had to
|
|
1325
|
+
# drop). Recorded here so :meth:`lint` can surface them — a dropped
|
|
1326
|
+
# feature must never be silent. Diameters dropped by the per-view
|
|
1327
|
+
# callout cap are tracked separately so :meth:`lint` can suppress the
|
|
1328
|
+
# redundant feature_not_dimensioned for them. Both are reset at the
|
|
1329
|
+
# top of :func:`_auto_annotate` so re-annotation does not accumulate.
|
|
1330
|
+
self._build_issues: list = []
|
|
1331
|
+
self._dropped_callout_diams: list = []
|
|
1223
1332
|
|
|
1224
1333
|
# -- views ----------------------------------------------------------------
|
|
1225
1334
|
def add_view(self, name, shape, camera, up, position, *, look_at=None, scaled=False):
|
|
@@ -1311,11 +1420,18 @@ class Drawing:
|
|
|
1311
1420
|
self._named = kept_named
|
|
1312
1421
|
return removed
|
|
1313
1422
|
|
|
1423
|
+
def _record_build_issue(self, severity, code, message):
|
|
1424
|
+
"""Record a lint issue discovered during construction (e.g. an
|
|
1425
|
+
annotation the layout had to drop). Surfaced by :meth:`lint` so a
|
|
1426
|
+
dropped feature is never silent."""
|
|
1427
|
+
self._build_issues.append(LintIssue(severity=severity, code=code, message=message))
|
|
1428
|
+
|
|
1314
1429
|
# -- output ---------------------------------------------------------------
|
|
1315
1430
|
def lint(self):
|
|
1316
1431
|
"""Lint all annotations against all views; returns the list of issues.
|
|
1317
1432
|
|
|
1318
1433
|
When :attr:`part` is set, also runs :func:`lint_feature_coverage`.
|
|
1434
|
+
Build-time drops recorded via :meth:`_record_build_issue` are included.
|
|
1319
1435
|
"""
|
|
1320
1436
|
set_page(self.page_w, self.page_h, margin=10)
|
|
1321
1437
|
view_shapes = [vis for vis, _ in self.views.values()]
|
|
@@ -1323,9 +1439,59 @@ class Drawing:
|
|
|
1323
1439
|
if self.part is not None:
|
|
1324
1440
|
if self._cyl_cache is None:
|
|
1325
1441
|
self._cyl_cache = analyse_cylinders(self.part)
|
|
1326
|
-
issues += lint_feature_coverage(
|
|
1442
|
+
issues += lint_feature_coverage(
|
|
1443
|
+
self.part,
|
|
1444
|
+
self.annotations,
|
|
1445
|
+
cyls=self._cyl_cache,
|
|
1446
|
+
exclude=self._dropped_callout_diams,
|
|
1447
|
+
)
|
|
1448
|
+
issues += list(self._build_issues)
|
|
1327
1449
|
return issues
|
|
1328
1450
|
|
|
1451
|
+
def lint_summary(self) -> dict:
|
|
1452
|
+
"""Aggregate :meth:`lint` into a JSON-friendly quality summary.
|
|
1453
|
+
|
|
1454
|
+
Gives a non-interactive caller (a script, or an LLM via the API) a
|
|
1455
|
+
single signal to gate and optimise on without rendering the SVG:
|
|
1456
|
+
|
|
1457
|
+
- ``passed`` — no error-severity issues;
|
|
1458
|
+
- ``score`` — coarse 0–1 quality heuristic (see ``_SCORE_*``);
|
|
1459
|
+
- ``errors`` / ``warnings`` / ``infos`` — counts by severity;
|
|
1460
|
+
- ``by_code`` — per-check counts;
|
|
1461
|
+
- ``geometry_issues`` — count of standards/geometry-correctness issues
|
|
1462
|
+
as opposed to pure layout (see ``_GEOMETRY_AWARE_CODES``);
|
|
1463
|
+
- ``issues`` — the full list, each as a plain dict.
|
|
1464
|
+
"""
|
|
1465
|
+
issues = self.lint()
|
|
1466
|
+
errors = sum(1 for i in issues if i.severity == "error")
|
|
1467
|
+
warnings = sum(1 for i in issues if i.severity == "warning")
|
|
1468
|
+
infos = sum(1 for i in issues if i.severity == "info")
|
|
1469
|
+
by_code: dict[str, int] = {}
|
|
1470
|
+
for i in issues:
|
|
1471
|
+
by_code[i.code] = by_code.get(i.code, 0) + 1
|
|
1472
|
+
score = max(
|
|
1473
|
+
0.0,
|
|
1474
|
+
1.0 - errors * _SCORE_ERROR_PENALTY - warnings * _SCORE_WARNING_PENALTY,
|
|
1475
|
+
)
|
|
1476
|
+
return {
|
|
1477
|
+
"passed": errors == 0,
|
|
1478
|
+
"score": score,
|
|
1479
|
+
"errors": errors,
|
|
1480
|
+
"warnings": warnings,
|
|
1481
|
+
"infos": infos,
|
|
1482
|
+
"by_code": by_code,
|
|
1483
|
+
"geometry_issues": sum(1 for i in issues if i.code in _GEOMETRY_AWARE_CODES),
|
|
1484
|
+
"issues": [
|
|
1485
|
+
{
|
|
1486
|
+
"severity": i.severity,
|
|
1487
|
+
"code": i.code,
|
|
1488
|
+
"message": i.message,
|
|
1489
|
+
"location": i.location,
|
|
1490
|
+
}
|
|
1491
|
+
for i in issues
|
|
1492
|
+
],
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1329
1495
|
def export(self, out=None):
|
|
1330
1496
|
"""Lint, then write SVG and DXF. Returns ``(svg_path, dxf_path)``."""
|
|
1331
1497
|
out = out if out is not None else self.out
|
|
@@ -1464,6 +1630,10 @@ def _export_shape(exporter, shape, layer, ctx):
|
|
|
1464
1630
|
def _auto_annotate(dwg, a):
|
|
1465
1631
|
"""Add the standard automatic dimensions, centrelines, and title block."""
|
|
1466
1632
|
draft = dwg.draft
|
|
1633
|
+
# Idempotent: clear build-time lint state so a second annotation pass does
|
|
1634
|
+
# not accumulate duplicate drop records.
|
|
1635
|
+
dwg._build_issues = []
|
|
1636
|
+
dwg._dropped_callout_diams = []
|
|
1467
1637
|
|
|
1468
1638
|
def FX(x):
|
|
1469
1639
|
return a.FV_X + (x - a.cx) * a.SCALE
|
|
@@ -1644,13 +1814,24 @@ def _auto_annotate(dwg, a):
|
|
|
1644
1814
|
|
|
1645
1815
|
# Step heights — only where the step is tall enough to fit a label;
|
|
1646
1816
|
# each step witnesses from the previous dim's line (_right_ladder) so
|
|
1647
|
-
# extension lines are adjacent rather than coincident
|
|
1648
|
-
for
|
|
1649
|
-
|
|
1650
|
-
|
|
1817
|
+
# extension lines are adjacent rather than coincident. No fixed cap: the
|
|
1818
|
+
# fv_zones.right corridor is sized for every legible step (#36), and the
|
|
1819
|
+
# strip allocator is the real bound — a step that doesn't fit surfaces via
|
|
1820
|
+
# lint rather than being silently dropped.
|
|
1821
|
+
def _step_legible(z):
|
|
1822
|
+
return (z - a.bb.min.Z) * a.SCALE >= _MIN_STEP_DIM_MM
|
|
1823
|
+
|
|
1824
|
+
_step_zs = [z for z in a.step_zs if _step_legible(z)]
|
|
1825
|
+
for col, z in enumerate(_step_zs):
|
|
1651
1826
|
_px = a.fv_zones.right.allocate(_SLOT_DIM_STEP)
|
|
1652
1827
|
if _px is None:
|
|
1653
1828
|
_log.warning("dim_step_%d skipped: fv_zones.right strip full", col)
|
|
1829
|
+
dwg._record_build_issue(
|
|
1830
|
+
"error",
|
|
1831
|
+
"placement_unsatisfiable",
|
|
1832
|
+
f"{len(_step_zs) - col} step-height dimension(s) dropped "
|
|
1833
|
+
"(front-view right strip full)",
|
|
1834
|
+
)
|
|
1654
1835
|
break
|
|
1655
1836
|
dwg.add(
|
|
1656
1837
|
Dimension(
|
|
@@ -2111,10 +2292,20 @@ def _annotate_pmi(dwg, a, draft) -> None:
|
|
|
2111
2292
|
_log.info("PMI annotate: %d/%d dims placed", emitted, len(usable))
|
|
2112
2293
|
|
|
2113
2294
|
|
|
2114
|
-
|
|
2295
|
+
def _record_callout_drop(dwg, view, diam, reason):
|
|
2296
|
+
"""Record a hole callout the layout could not place (#36).
|
|
2115
2297
|
|
|
2116
|
-
|
|
2117
|
-
|
|
2298
|
+
A warning (the drawing is incomplete, not invalid), whose diameter is
|
|
2299
|
+
excluded from ``feature_not_dimensioned`` like the old per-view cap drop —
|
|
2300
|
+
so a callout that genuinely doesn't fit is surfaced once, with a reason,
|
|
2301
|
+
and not double-reported.
|
|
2302
|
+
"""
|
|
2303
|
+
dwg._dropped_callout_diams.append(diam)
|
|
2304
|
+
dwg._record_build_issue(
|
|
2305
|
+
"warning",
|
|
2306
|
+
"callout_dropped",
|
|
2307
|
+
f"hole callout ø{_fmt(diam)} dropped from the {view} view ({reason})",
|
|
2308
|
+
)
|
|
2118
2309
|
|
|
2119
2310
|
|
|
2120
2311
|
def _add_location_dims(dwg, a, axis_letter, patterns, holes_in=None):
|
|
@@ -2123,11 +2314,13 @@ def _add_location_dims(dwg, a, axis_letter, patterns, holes_in=None):
|
|
|
2123
2314
|
The datum corner is a *default* — the part's minimum-X/minimum-Y corner
|
|
2124
2315
|
(lower-left in the plan view), per inspection practice; a human/LLM pass
|
|
2125
2316
|
can re-anchor it. One reference per pattern (bolt-circle centre, array
|
|
2126
|
-
first hole) plus each unpatterned hole
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2317
|
+
first hole) plus each unpatterned hole. There is no fixed cap: dims are
|
|
2318
|
+
placed nearest-datum-first (baseline practice) until the above-view tier
|
|
2319
|
+
strips fill — a tier that would leave the page is skipped, never
|
|
2320
|
+
force-placed, and the unplaced ref surfaces as ``location_ref_dropped``
|
|
2321
|
+
(#36). X dims tier above the plan view (below sit dim_width and the front
|
|
2322
|
+
view), Y dims tier above the side view. Cross-axis holes are not located
|
|
2323
|
+
yet (logged).
|
|
2131
2324
|
"""
|
|
2132
2325
|
draft = dwg.draft
|
|
2133
2326
|
all_holes = a.holes if holes_in is None else holes_in
|
|
@@ -2160,14 +2353,6 @@ def _add_location_dims(dwg, a, axis_letter, patterns, holes_in=None):
|
|
|
2160
2353
|
refs = unique
|
|
2161
2354
|
if not refs:
|
|
2162
2355
|
return
|
|
2163
|
-
if len(refs) > _MAX_LOCATION_REFS:
|
|
2164
|
-
refs.sort(key=lambda r: r[2], reverse=True)
|
|
2165
|
-
_log.info(
|
|
2166
|
-
"%d location references; dimensioning the %d largest",
|
|
2167
|
-
len(refs),
|
|
2168
|
-
_MAX_LOCATION_REFS,
|
|
2169
|
-
)
|
|
2170
|
-
refs = refs[:_MAX_LOCATION_REFS]
|
|
2171
2356
|
|
|
2172
2357
|
def PX(x):
|
|
2173
2358
|
return a.PV_X + (x - a.cx) * a.SCALE
|
|
@@ -2194,6 +2379,11 @@ def _add_location_dims(dwg, a, axis_letter, patterns, holes_in=None):
|
|
|
2194
2379
|
_py = a.pv_zones.above.allocate(tier)
|
|
2195
2380
|
if _py is None:
|
|
2196
2381
|
_log.info("X location dim for x=%s skipped (no room above plan view)", _fmt(rx))
|
|
2382
|
+
dwg._record_build_issue(
|
|
2383
|
+
"warning",
|
|
2384
|
+
"location_ref_dropped",
|
|
2385
|
+
f"X location dim for x={_fmt(rx)} not placed (no room above the plan view)",
|
|
2386
|
+
)
|
|
2197
2387
|
continue
|
|
2198
2388
|
dwg.add(
|
|
2199
2389
|
Dimension(
|
|
@@ -2249,6 +2439,11 @@ def _add_location_dims(dwg, a, axis_letter, patterns, holes_in=None):
|
|
|
2249
2439
|
_py = a.sv_zones.above.allocate(tier)
|
|
2250
2440
|
if _py is None:
|
|
2251
2441
|
_log.info("Y location dim for y=%s skipped (no room above the side view)", _fmt(ry))
|
|
2442
|
+
dwg._record_build_issue(
|
|
2443
|
+
"warning",
|
|
2444
|
+
"location_ref_dropped",
|
|
2445
|
+
f"Y location dim for y={_fmt(ry)} not placed (no room above the side view)",
|
|
2446
|
+
)
|
|
2252
2447
|
continue
|
|
2253
2448
|
dwg.add(
|
|
2254
2449
|
Dimension(
|
|
@@ -2652,7 +2847,11 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
|
|
|
2652
2847
|
"""
|
|
2653
2848
|
draft = dwg.draft
|
|
2654
2849
|
gap = draft.pad_around_text
|
|
2655
|
-
|
|
2850
|
+
# Minimum vertical separation between stacked bore-callout labels: one label
|
|
2851
|
+
# height (font_size) plus pad_around_text clearance above and below, so
|
|
2852
|
+
# adjacent labels never touch. Derived from text metrics rather than a bare
|
|
2853
|
+
# font-size ratio (#31).
|
|
2854
|
+
min_gap = draft.font_size + 2 * gap
|
|
2656
2855
|
# Group on the same machining-spec key pattern detection uses (snapped
|
|
2657
2856
|
# axis vector included): blind holes drilled from opposite faces are
|
|
2658
2857
|
# different operations and get separate callouts, and a spec group's
|
|
@@ -2739,19 +2938,12 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
|
|
|
2739
2938
|
for holes in view_groups:
|
|
2740
2939
|
pattern = patterns.get(frozenset(holes))
|
|
2741
2940
|
specs.append((holes, _build_callout(holes, pattern), pattern))
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
"%d hole specs in %s view; annotating the %d largest "
|
|
2749
|
-
"(the rest surface as feature_not_dimensioned)",
|
|
2750
|
-
len(specs),
|
|
2751
|
-
view,
|
|
2752
|
-
_MAX_CALLOUTS_PER_VIEW,
|
|
2753
|
-
)
|
|
2754
|
-
specs = specs[:_MAX_CALLOUTS_PER_VIEW]
|
|
2941
|
+
# No fixed cap (#36): every spec is attempted; the per-view placement
|
|
2942
|
+
# bounds below (front-view shaft rows, plan/side strip Y-solver) are the
|
|
2943
|
+
# real limit, and any callout that genuinely doesn't fit surfaces as
|
|
2944
|
+
# callout_dropped. Largest diameters first so the most significant
|
|
2945
|
+
# features win the available room.
|
|
2946
|
+
specs.sort(key=lambda s: s[0][0].diameter, reverse=True)
|
|
2755
2947
|
|
|
2756
2948
|
if view == "front":
|
|
2757
2949
|
# Below the view, vertical shafts. Rows are assigned right-to-
|
|
@@ -2769,6 +2961,7 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
|
|
|
2769
2961
|
side, x0, x1 = "left", centre[0] - gap - w, centre[0] - gap
|
|
2770
2962
|
else:
|
|
2771
2963
|
_log.info("Hole callout ø%s skipped (no room)", _fmt(holes[0].diameter))
|
|
2964
|
+
_record_callout_drop(dwg, view, holes[0].diameter, "no room beside the view")
|
|
2772
2965
|
continue
|
|
2773
2966
|
# the title block only constrains rows that reach its x-range
|
|
2774
2967
|
floor = (tb_top + 4) if x1 > tb_left - 4 else a.margin + 4
|
|
@@ -2776,6 +2969,7 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
|
|
|
2776
2969
|
_log.info(
|
|
2777
2970
|
"Hole callout ø%s skipped (front strip full)", _fmt(holes[0].diameter)
|
|
2778
2971
|
)
|
|
2972
|
+
_record_callout_drop(dwg, view, holes[0].diameter, "front strip full")
|
|
2779
2973
|
continue
|
|
2780
2974
|
if any(
|
|
2781
2975
|
ox0 <= centre[0] <= ox1 and row_y > elbow_y for ox0, ox1, row_y in occupied
|
|
@@ -2784,6 +2978,9 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
|
|
|
2784
2978
|
"Hole callout ø%s skipped (shaft would cross another callout)",
|
|
2785
2979
|
_fmt(holes[0].diameter),
|
|
2786
2980
|
)
|
|
2981
|
+
_record_callout_drop(
|
|
2982
|
+
dwg, view, holes[0].diameter, "shaft would cross another callout"
|
|
2983
|
+
)
|
|
2787
2984
|
continue
|
|
2788
2985
|
elbow = (centre[0], elbow_y)
|
|
2789
2986
|
occupied.append((x0, x1, elbow_y))
|
|
@@ -2843,6 +3040,7 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
|
|
|
2843
3040
|
|
|
2844
3041
|
if not can_right and not can_left:
|
|
2845
3042
|
_log.info("Hole callout ø%s skipped (no room)", _fmt(holes[0].diameter))
|
|
3043
|
+
_record_callout_drop(dwg, view, holes[0].diameter, "no room beside the view")
|
|
2846
3044
|
continue
|
|
2847
3045
|
|
|
2848
3046
|
if can_right and (not can_left or d_right <= d_left):
|
|
@@ -2869,6 +3067,8 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
|
|
|
2869
3067
|
n_drop,
|
|
2870
3068
|
len(right_queue),
|
|
2871
3069
|
)
|
|
3070
|
+
for holes, *_ in right_queue[len(right_ys) :]:
|
|
3071
|
+
_record_callout_drop(dwg, view, holes[0].diameter, "right strip full")
|
|
2872
3072
|
right_queue = right_queue[: len(right_ys)]
|
|
2873
3073
|
if left_ys is None and left_queue:
|
|
2874
3074
|
left_ys = _greedy_strip_ys(
|
|
@@ -2881,6 +3081,8 @@ def _annotate_holes(dwg, a, view_of_axis, axis_letter, found_patterns, holes_in=
|
|
|
2881
3081
|
n_drop,
|
|
2882
3082
|
len(left_queue),
|
|
2883
3083
|
)
|
|
3084
|
+
for holes, *_ in left_queue[len(left_ys) :]:
|
|
3085
|
+
_record_callout_drop(dwg, view, holes[0].diameter, "left strip full")
|
|
2884
3086
|
left_queue = left_queue[: len(left_ys)]
|
|
2885
3087
|
|
|
2886
3088
|
for i, ((holes, callout, pattern, _, rep), elbow_y) in enumerate(
|
|
@@ -2922,9 +3124,6 @@ def _add_title_block(dwg, a):
|
|
|
2922
3124
|
dwg.add(tb, "title_block")
|
|
2923
3125
|
|
|
2924
3126
|
|
|
2925
|
-
_ISO_SHRINK_FACTORS = (0.5, 0.2, 0.1)
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
3127
|
def _iso_bbox(dwg):
|
|
2929
3128
|
"""(min_x, min_y, max_x, max_y) of the placed iso view, hidden lines included."""
|
|
2930
3129
|
vis, hid = dwg.views["iso"]
|
|
@@ -91,15 +91,15 @@ def test_e2e_from_step_meets_standards(tmp_path):
|
|
|
91
91
|
|
|
92
92
|
# ---------------------------------------------------------------------------
|
|
93
93
|
# NIST MBE PMI Combined Test Cases (CTC), public-domain models.
|
|
94
|
-
# All 10 variants ship as fixtures.
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
94
|
+
# All 10 variants ship as fixtures and now build. Two #20 fixes got them there:
|
|
95
|
+
# the fuzzy section cut (_fuzzy_cut) unblocked CTC-04, and the direct
|
|
96
|
+
# STEPControl_Reader importer (_import_step) avoids the XCAF/PMI segfault that
|
|
97
|
+
# build123d's import_step hit on CTC-02 AP242.
|
|
98
98
|
# ---------------------------------------------------------------------------
|
|
99
99
|
|
|
100
100
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
101
|
-
_CTC_AP203_OK = ["01", "02", "03", "04", "05"]
|
|
102
|
-
_CTC_AP242_OK = ["01", "03", "04", "05"]
|
|
101
|
+
_CTC_AP203_OK = ["01", "02", "03", "04", "05"]
|
|
102
|
+
_CTC_AP242_OK = ["01", "02", "03", "04", "05"]
|
|
103
103
|
|
|
104
104
|
|
|
105
105
|
@pytest.mark.slow
|
|
@@ -533,11 +533,18 @@ class TestDepthEstimators:
|
|
|
533
533
|
# dim_height (10) + 3×dim_step (14 each) + 3×spacing (4 each) = 8+10+4+14+4+14+4+14 = 72
|
|
534
534
|
assert _est_right_strip_depth(3) == pytest.approx(72.0, abs=0.01)
|
|
535
535
|
|
|
536
|
-
def
|
|
537
|
-
from draftwright.make_drawing import
|
|
536
|
+
def test_right_depth_grows_per_step_uncapped(self):
|
|
537
|
+
from draftwright.make_drawing import (
|
|
538
|
+
_SLOT_DIM_STEP,
|
|
539
|
+
_STRIP_SPACING,
|
|
540
|
+
_est_right_strip_depth,
|
|
541
|
+
)
|
|
538
542
|
|
|
539
|
-
#
|
|
540
|
-
assert _est_right_strip_depth(
|
|
543
|
+
# #36: no cap — each further step adds one slot + one spacing.
|
|
544
|
+
assert _est_right_strip_depth(10) > _est_right_strip_depth(3)
|
|
545
|
+
assert _est_right_strip_depth(10) - _est_right_strip_depth(3) == pytest.approx(
|
|
546
|
+
7 * (_STRIP_SPACING + _SLOT_DIM_STEP), abs=0.01
|
|
547
|
+
)
|
|
541
548
|
|
|
542
549
|
def test_right_depth_increases_with_steps(self):
|
|
543
550
|
from draftwright.make_drawing import _est_right_strip_depth
|
|
@@ -586,6 +593,54 @@ class TestDepthEstimators:
|
|
|
586
593
|
assert s.allocate(_SLOT_DIM_WIDTH) is not None, "dim_width must fit in pv_below corridor"
|
|
587
594
|
|
|
588
595
|
|
|
596
|
+
# ---------------------------------------------------------------------------
|
|
597
|
+
# #31: layout constants derived from text metrics
|
|
598
|
+
# ---------------------------------------------------------------------------
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
class TestDerivedLayoutConstants:
|
|
602
|
+
"""Slots / callout widths / iso budget derive from text metrics, not bare mm."""
|
|
603
|
+
|
|
604
|
+
def test_slots_derive_from_font_metrics(self):
|
|
605
|
+
from draftwright.make_drawing import (
|
|
606
|
+
_FONT_SIZE,
|
|
607
|
+
_PAD,
|
|
608
|
+
_SLOT_DIM_DEPTH,
|
|
609
|
+
_SLOT_DIM_HEIGHT,
|
|
610
|
+
_SLOT_DIM_STEP,
|
|
611
|
+
_SLOT_DIM_WIDTH,
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
assert _SLOT_DIM_WIDTH == pytest.approx(2 * _FONT_SIZE + _PAD)
|
|
615
|
+
assert _SLOT_DIM_DEPTH == pytest.approx(2 * _FONT_SIZE + _PAD)
|
|
616
|
+
assert _SLOT_DIM_HEIGHT == pytest.approx(2 * _FONT_SIZE + 2 * _PAD)
|
|
617
|
+
assert _SLOT_DIM_STEP == pytest.approx(4 * _FONT_SIZE + _PAD)
|
|
618
|
+
# The slots are linear in font metrics — a hypothetical larger font
|
|
619
|
+
# would yield larger slots — so they are not frozen mm constants.
|
|
620
|
+
assert (2 * (2 * _FONT_SIZE) + 2 * _PAD) > _SLOT_DIM_HEIGHT
|
|
621
|
+
|
|
622
|
+
def test_text_width_returns_real_glyph_metrics(self):
|
|
623
|
+
from draftwright.make_drawing import _text_width
|
|
624
|
+
|
|
625
|
+
assert _text_width("", 3.0) == 0.0
|
|
626
|
+
# A real measurement is positive and grows with the string.
|
|
627
|
+
w1 = _text_width("8", 3.0)
|
|
628
|
+
w3 = _text_width("888", 3.0)
|
|
629
|
+
assert 0.0 < w1 < w3
|
|
630
|
+
# Wider glyphs (uppercase) measure wider than the old 0.6*font fudge
|
|
631
|
+
# would have estimated — the whole point of using real metrics (#31).
|
|
632
|
+
assert _text_width("THRU", 3.0) > 4 * 0.6 * 3.0
|
|
633
|
+
|
|
634
|
+
def test_bore_callout_width_scales_with_font_size(self):
|
|
635
|
+
from draftwright.make_drawing import _est_bore_callout_width, find_holes
|
|
636
|
+
|
|
637
|
+
part = Box(60, 40, 12) - Pos(0, 0, 6) * Cylinder(3, 12)
|
|
638
|
+
holes = find_holes(part)
|
|
639
|
+
small = _est_bore_callout_width(holes, font_size=3.0)
|
|
640
|
+
large = _est_bore_callout_width(holes, font_size=6.0)
|
|
641
|
+
assert large > small
|
|
642
|
+
|
|
643
|
+
|
|
589
644
|
# ---------------------------------------------------------------------------
|
|
590
645
|
# Phase 3 (#118): dynamic FV→SV corridor
|
|
591
646
|
# ---------------------------------------------------------------------------
|
|
@@ -1633,7 +1688,10 @@ class TestAutoHoleAnnotations:
|
|
|
1633
1688
|
assert [i for i in dwg.lint() if i.severity != "info"] == []
|
|
1634
1689
|
|
|
1635
1690
|
@pytest.mark.timeout(60)
|
|
1636
|
-
def
|
|
1691
|
+
def test_all_distinct_bores_get_callouts(self):
|
|
1692
|
+
# #36: no per-view callout cap — six distinct-diameter holes in a row
|
|
1693
|
+
# all get callouts (previously capped at the four largest), and nothing
|
|
1694
|
+
# is dropped because they fit.
|
|
1637
1695
|
part = Box(120, 80, 10)
|
|
1638
1696
|
for i, r in enumerate([1, 1.5, 2, 2.5, 3, 4]):
|
|
1639
1697
|
part = part - Pos(-50 + i * 20, 0, 0) * Cylinder(r, 10)
|
|
@@ -1642,10 +1700,8 @@ class TestAutoHoleAnnotations:
|
|
|
1642
1700
|
for name, ann in dwg._named.items():
|
|
1643
1701
|
if name.startswith("hc_"):
|
|
1644
1702
|
covered.update(ann.covers_diameters)
|
|
1645
|
-
assert covered == {4.0, 5.0, 6.0, 8.0}
|
|
1646
|
-
|
|
1647
|
-
flagged = {i.message for i in dwg.lint() if i.code == "feature_not_dimensioned"}
|
|
1648
|
-
assert len(flagged) == 2
|
|
1703
|
+
assert covered == {2.0, 3.0, 4.0, 5.0, 6.0, 8.0}
|
|
1704
|
+
assert "callout_dropped" not in {i.code for i in dwg.lint()}
|
|
1649
1705
|
|
|
1650
1706
|
@pytest.mark.timeout(60)
|
|
1651
1707
|
def test_rotational_part_keeps_leader_annotations(self):
|
|
@@ -2254,3 +2310,250 @@ class TestSanitizeSvgArcs:
|
|
|
2254
2310
|
n = sanitize_svg_arcs(f)
|
|
2255
2311
|
assert n == 0
|
|
2256
2312
|
assert "A 5.0 5.0 0 0 1 20 20" in Path(f).read_text(encoding="utf-8")
|
|
2313
|
+
|
|
2314
|
+
|
|
2315
|
+
# ---------------------------------------------------------------------------
|
|
2316
|
+
# Lint summary + surfacing of build-time annotation drops (#32)
|
|
2317
|
+
# ---------------------------------------------------------------------------
|
|
2318
|
+
|
|
2319
|
+
|
|
2320
|
+
class TestLintSummaryAndDrops:
|
|
2321
|
+
def test_summary_shape_is_consistent_with_lint(self):
|
|
2322
|
+
from build123d import Box, Cylinder
|
|
2323
|
+
|
|
2324
|
+
from draftwright import build_drawing
|
|
2325
|
+
|
|
2326
|
+
dwg = build_drawing(Box(80, 60, 20) - Cylinder(5, 20))
|
|
2327
|
+
issues = dwg.lint()
|
|
2328
|
+
s = dwg.lint_summary()
|
|
2329
|
+
|
|
2330
|
+
assert set(s) == {
|
|
2331
|
+
"passed",
|
|
2332
|
+
"score",
|
|
2333
|
+
"errors",
|
|
2334
|
+
"warnings",
|
|
2335
|
+
"infos",
|
|
2336
|
+
"by_code",
|
|
2337
|
+
"geometry_issues",
|
|
2338
|
+
"issues",
|
|
2339
|
+
}
|
|
2340
|
+
assert s["errors"] + s["warnings"] + s["infos"] == len(issues)
|
|
2341
|
+
assert s["passed"] is (s["errors"] == 0)
|
|
2342
|
+
assert 0.0 <= s["score"] <= 1.0
|
|
2343
|
+
assert sum(s["by_code"].values()) == len(issues)
|
|
2344
|
+
assert len(s["issues"]) == len(issues)
|
|
2345
|
+
# A single-hole plate doesn't overflow the per-view callout cap.
|
|
2346
|
+
assert "callout_dropped" not in s["by_code"]
|
|
2347
|
+
|
|
2348
|
+
def test_recorded_build_issue_surfaces_and_counts(self):
|
|
2349
|
+
from build123d import Box
|
|
2350
|
+
|
|
2351
|
+
from draftwright import build_drawing
|
|
2352
|
+
|
|
2353
|
+
dwg = build_drawing(Box(60, 40, 30))
|
|
2354
|
+
before = dwg.lint_summary()
|
|
2355
|
+
dwg._record_build_issue("warning", "callout_dropped", "synthetic drop")
|
|
2356
|
+
|
|
2357
|
+
codes = {i.code for i in dwg.lint()}
|
|
2358
|
+
assert "callout_dropped" in codes
|
|
2359
|
+
|
|
2360
|
+
after = dwg.lint_summary()
|
|
2361
|
+
assert after["warnings"] == before["warnings"] + 1
|
|
2362
|
+
assert after["by_code"]["callout_dropped"] == 1
|
|
2363
|
+
# callout_dropped is a geometry-aware code, so it lifts that count too.
|
|
2364
|
+
assert after["geometry_issues"] == before["geometry_issues"] + 1
|
|
2365
|
+
|
|
2366
|
+
def test_dropped_callout_diameter_excluded_from_feature_lint(self):
|
|
2367
|
+
# The de-dup contract: a diameter recorded as a dropped callout is
|
|
2368
|
+
# excluded from feature_not_dimensioned, so a callout the layout could
|
|
2369
|
+
# not place (#36) is surfaced once (as callout_dropped) and not
|
|
2370
|
+
# double-reported.
|
|
2371
|
+
from build123d import Box, Cylinder
|
|
2372
|
+
|
|
2373
|
+
from draftwright.make_drawing import lint_feature_coverage
|
|
2374
|
+
|
|
2375
|
+
part = Box(60, 40, 20) - Cylinder(5, 20) # one undimensioned ø10 bore
|
|
2376
|
+
base = lint_feature_coverage(part, [])
|
|
2377
|
+
assert any(i.code == "feature_not_dimensioned" for i in base)
|
|
2378
|
+
excluded = lint_feature_coverage(part, [], exclude=[10.0])
|
|
2379
|
+
assert not any(i.code == "feature_not_dimensioned" for i in excluded)
|
|
2380
|
+
|
|
2381
|
+
@pytest.mark.timeout(120)
|
|
2382
|
+
def test_step_dims_are_adaptive_not_capped(self):
|
|
2383
|
+
# #36: no fixed 3-step cap. Five stacked ledges → a step dim for each
|
|
2384
|
+
# legible ledge (well over the old cap of three), corridor sized to fit
|
|
2385
|
+
# them all, no error-severity lint.
|
|
2386
|
+
from build123d import Box, Pos
|
|
2387
|
+
|
|
2388
|
+
from draftwright import build_drawing
|
|
2389
|
+
|
|
2390
|
+
tower = Box(120, 120, 15)
|
|
2391
|
+
for i in range(1, 6):
|
|
2392
|
+
side = 120 - i * 18
|
|
2393
|
+
tower += Pos(0, 0, i * 15) * Box(side, side, 15)
|
|
2394
|
+
dwg = build_drawing(tower)
|
|
2395
|
+
n_steps = len([n for n in dwg._named if n.startswith("dim_step")])
|
|
2396
|
+
assert n_steps > 3, f"expected adaptive >3 step dims, got {n_steps}"
|
|
2397
|
+
assert [i for i in dwg.lint() if i.severity == "error"] == []
|
|
2398
|
+
|
|
2399
|
+
@pytest.mark.timeout(120)
|
|
2400
|
+
def test_location_dims_are_adaptive_not_capped(self):
|
|
2401
|
+
# #36: location dims have no fixed cap. Six scattered holes (distinct X
|
|
2402
|
+
# and Y, varied diameters so no array collapses them) get far more than
|
|
2403
|
+
# the old cap of four location dims, with nothing dropped — they fit.
|
|
2404
|
+
from build123d import Box, Cylinder, Pos
|
|
2405
|
+
|
|
2406
|
+
from draftwright import build_drawing
|
|
2407
|
+
|
|
2408
|
+
plate = Box(140, 90, 8)
|
|
2409
|
+
for x, y, r in [
|
|
2410
|
+
(-55, -35, 2.0),
|
|
2411
|
+
(-33, -12, 2.5),
|
|
2412
|
+
(-11, 15, 3.0),
|
|
2413
|
+
(12, -20, 3.5),
|
|
2414
|
+
(34, 28, 2.0),
|
|
2415
|
+
(55, 5, 2.5),
|
|
2416
|
+
]:
|
|
2417
|
+
plate -= Pos(x, y, 0) * Cylinder(r, 8)
|
|
2418
|
+
dwg = build_drawing(plate)
|
|
2419
|
+
n_loc = len([n for n in dwg._named if n.startswith(("dim_locx", "dim_locy"))])
|
|
2420
|
+
assert n_loc > 4, f"expected adaptive >4 location dims, got {n_loc}"
|
|
2421
|
+
assert "location_ref_dropped" not in {i.code for i in dwg.lint()}
|
|
2422
|
+
|
|
2423
|
+
@pytest.mark.timeout(120)
|
|
2424
|
+
def test_auto_annotate_clears_stale_build_issues(self):
|
|
2425
|
+
# Re-annotating starts build-time lint tracking from a clean slate:
|
|
2426
|
+
# stale drop records from a prior pass are cleared, not accumulated.
|
|
2427
|
+
# (A full second pass is not idempotent — strip cursors advance — but
|
|
2428
|
+
# the records always reflect only the latest pass.)
|
|
2429
|
+
from build123d import Box
|
|
2430
|
+
|
|
2431
|
+
from draftwright import build_drawing
|
|
2432
|
+
from draftwright.make_drawing import _auto_annotate
|
|
2433
|
+
|
|
2434
|
+
dwg = build_drawing(Box(60, 40, 30))
|
|
2435
|
+
dwg._record_build_issue("warning", "callout_dropped", "stale")
|
|
2436
|
+
assert any(i.message == "stale" for i in dwg._build_issues)
|
|
2437
|
+
_auto_annotate(dwg, dwg._analysis)
|
|
2438
|
+
assert not any(i.message == "stale" for i in dwg._build_issues)
|
|
2439
|
+
assert dwg._dropped_callout_diams == []
|
|
2440
|
+
|
|
2441
|
+
def test_repeated_lint_is_stable(self):
|
|
2442
|
+
# lint()/lint_summary() are idempotent — repeated calls return the same
|
|
2443
|
+
# issues and never accumulate the build-time drop records.
|
|
2444
|
+
from build123d import Box, Cylinder, Pos
|
|
2445
|
+
|
|
2446
|
+
from draftwright import build_drawing
|
|
2447
|
+
|
|
2448
|
+
plate = Box(120, 60, 8)
|
|
2449
|
+
for x, r in zip((-48, -24, 0, 24, 48), (2.0, 2.5, 3.0, 3.5, 4.0)):
|
|
2450
|
+
plate -= Pos(x, 0, 0) * Cylinder(r, 8)
|
|
2451
|
+
dwg = build_drawing(plate)
|
|
2452
|
+
first, second = dwg.lint(), dwg.lint()
|
|
2453
|
+
assert len(first) == len(second)
|
|
2454
|
+
assert dwg.lint_summary()["by_code"] == dwg.lint_summary()["by_code"]
|
|
2455
|
+
|
|
2456
|
+
def test_placement_unsatisfiable_is_error_severity(self):
|
|
2457
|
+
# placement_unsatisfiable (engine could not place a wanted annotation)
|
|
2458
|
+
# is error-severity, so it fails the `passed` gate.
|
|
2459
|
+
from build123d import Box
|
|
2460
|
+
|
|
2461
|
+
from draftwright import build_drawing
|
|
2462
|
+
|
|
2463
|
+
dwg = build_drawing(Box(60, 40, 30))
|
|
2464
|
+
assert dwg.lint_summary()["passed"] is True
|
|
2465
|
+
dwg._record_build_issue("error", "placement_unsatisfiable", "synthetic")
|
|
2466
|
+
s = dwg.lint_summary()
|
|
2467
|
+
assert s["passed"] is False
|
|
2468
|
+
assert s["errors"] >= 1
|
|
2469
|
+
assert s["by_code"]["placement_unsatisfiable"] == 1
|
|
2470
|
+
|
|
2471
|
+
|
|
2472
|
+
# ---------------------------------------------------------------------------
|
|
2473
|
+
# Layout generalisation guards (#13) — pin the *general* behaviour the
|
|
2474
|
+
# algorithm should have on turned/hybrid parts and at the step-legibility
|
|
2475
|
+
# boundary, so the overfitting that #10–#12/#31 removed cannot creep back.
|
|
2476
|
+
# ---------------------------------------------------------------------------
|
|
2477
|
+
|
|
2478
|
+
|
|
2479
|
+
class TestLayoutGeneralisation:
|
|
2480
|
+
@pytest.mark.timeout(120)
|
|
2481
|
+
def test_turned_flange_gets_both_od_and_hole_furniture(self):
|
|
2482
|
+
# A turned-and-drilled flange (cylinder OD + centre bore + bolt circle)
|
|
2483
|
+
# must get the turned base set (OD dim + centrelines) AND the drilled
|
|
2484
|
+
# furniture (hole callout + pitch circle) — not one or the other. This
|
|
2485
|
+
# is the feature-presence composition from #10, on a genuinely
|
|
2486
|
+
# rotational part rather than a prismatic plate.
|
|
2487
|
+
import math
|
|
2488
|
+
|
|
2489
|
+
from build123d import Cylinder, Pos
|
|
2490
|
+
|
|
2491
|
+
from draftwright import build_drawing
|
|
2492
|
+
|
|
2493
|
+
flange = Cylinder(radius=40, height=10) - Cylinder(radius=8, height=10)
|
|
2494
|
+
for i in range(6):
|
|
2495
|
+
ang = math.radians(60 * i)
|
|
2496
|
+
flange -= Pos(28 * math.cos(ang), 28 * math.sin(ang), 0) * Cylinder(2.5, 10)
|
|
2497
|
+
dwg = build_drawing(flange)
|
|
2498
|
+
|
|
2499
|
+
assert dwg._analysis.is_rotational, "flange should classify as rotational"
|
|
2500
|
+
# Turned base set.
|
|
2501
|
+
assert "dim_od" in dwg._named
|
|
2502
|
+
assert "centerline_front" in dwg._named
|
|
2503
|
+
assert "centerline_side" in dwg._named
|
|
2504
|
+
# Drilled furniture.
|
|
2505
|
+
assert any(n.startswith("hc_") for n in dwg._named), "expected a hole callout"
|
|
2506
|
+
assert any(n.startswith("bc_") for n in dwg._named), "expected a pitch circle"
|
|
2507
|
+
# No error-severity lint (warnings tolerated).
|
|
2508
|
+
assert [i for i in dwg.lint() if i.severity == "error"] == []
|
|
2509
|
+
|
|
2510
|
+
@pytest.mark.timeout(120)
|
|
2511
|
+
def test_turned_flange_dimensions_all_its_bores(self):
|
|
2512
|
+
# #36: no per-view callout cap — a turned part with five distinct bores
|
|
2513
|
+
# gets a callout for every one (was capped at four largest), more than
|
|
2514
|
+
# the old cap, with nothing dropped because they fit.
|
|
2515
|
+
import math
|
|
2516
|
+
|
|
2517
|
+
from build123d import Cylinder, Pos
|
|
2518
|
+
|
|
2519
|
+
from draftwright import build_drawing
|
|
2520
|
+
|
|
2521
|
+
flange = Cylinder(radius=45, height=10) - Cylinder(radius=8, height=10)
|
|
2522
|
+
for i, r in enumerate((2.0, 2.5, 3.0, 3.5, 4.0)):
|
|
2523
|
+
ang = math.radians(72 * i)
|
|
2524
|
+
flange -= Pos(30 * math.cos(ang), 30 * math.sin(ang), 0) * Cylinder(r, 10)
|
|
2525
|
+
dwg = build_drawing(flange)
|
|
2526
|
+
|
|
2527
|
+
n_callouts = len([n for n in dwg._named if n.startswith("hc_")])
|
|
2528
|
+
assert n_callouts > 4, f"expected adaptive >4 callouts, got {n_callouts}"
|
|
2529
|
+
assert "callout_dropped" not in {i.code for i in dwg.lint()}
|
|
2530
|
+
|
|
2531
|
+
@pytest.mark.timeout(120)
|
|
2532
|
+
def test_step_height_legibility_threshold(self):
|
|
2533
|
+
# The step-height dimension gate is the legibility constant
|
|
2534
|
+
# (_MIN_STEP_DIM_MM), not an incidental cutoff: a shoulder whose
|
|
2535
|
+
# page-projected height falls just below the gate gets no step dim;
|
|
2536
|
+
# just above, it does. Pin the gate, not a magic millimetre value.
|
|
2537
|
+
from build123d import Cylinder, Pos
|
|
2538
|
+
|
|
2539
|
+
from draftwright import build_drawing
|
|
2540
|
+
from draftwright.make_drawing import _MIN_STEP_DIM_MM
|
|
2541
|
+
|
|
2542
|
+
def shaft_with_shoulder_at(length):
|
|
2543
|
+
# Lower segment height == `length`; shoulder sits `length` above the
|
|
2544
|
+
# base (bb.min.Z), so legibility = length * SCALE.
|
|
2545
|
+
return Pos(0, 0, length / 2) * Cylinder(22, length) + Pos(
|
|
2546
|
+
0, 0, length + 12.5
|
|
2547
|
+
) * Cylinder(11, 25)
|
|
2548
|
+
|
|
2549
|
+
for length, expect in ((12.0, False), (13.0, True)):
|
|
2550
|
+
dwg = build_drawing(shaft_with_shoulder_at(length))
|
|
2551
|
+
a = dwg._analysis
|
|
2552
|
+
legible = length * a.SCALE >= _MIN_STEP_DIM_MM
|
|
2553
|
+
assert legible is expect, (
|
|
2554
|
+
f"length={length} scale={a.SCALE}: legibility expectation wrong"
|
|
2555
|
+
)
|
|
2556
|
+
has_step = "dim_step_0" in dwg._named
|
|
2557
|
+
assert has_step is expect, (
|
|
2558
|
+
f"length={length}: step dim present={has_step}, expected {expect}"
|
|
2559
|
+
)
|
draftwright-0.1.6/CHANGELOG.md
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## Unreleased
|
|
4
|
-
|
|
5
|
-
## v0.1.5 — 2026-06-15
|
|
6
|
-
|
|
7
|
-
### Fixed
|
|
8
|
-
|
|
9
|
-
- CTC-02 spurious full-page line: build123d's `ExportSVG` projected
|
|
10
|
-
circle-edge-on edges (hole/fillet rims seen edge-on) as elliptical arcs with
|
|
11
|
-
a near-zero minor radius, which renderers blow up into full-page lines.
|
|
12
|
-
`sanitize_svg_arcs()` rewrites any arc with a sub-1e-3 mm radius into the
|
|
13
|
-
straight line it actually is, leaving real arcs untouched (#19). Not a PMI
|
|
14
|
-
issue — the file is AP203 geometry-only.
|
|
15
|
-
|
|
16
|
-
### Tests
|
|
17
|
-
|
|
18
|
-
- Added the full NIST CTC set (01–05) as fixtures, both AP203 geometry-only and
|
|
19
|
-
AP242 (with-PMI) variants.
|
|
20
|
-
- Heavy end-to-end CTC fixture builds are marked `slow` and deselected from the
|
|
21
|
-
default `pytest` run (fast normal run, ~4.5 min); CI runs the fast tier across
|
|
22
|
-
the OS/Python matrix and the slow tier once.
|
|
23
|
-
- Known: AP242 CTC-02 and both CTC-04 variants crash OCCT on import (#20); their
|
|
24
|
-
fixtures are excluded from build tests.
|
|
25
|
-
|
|
26
|
-
## v0.1.4 — 2026-06-15
|
|
27
|
-
|
|
28
|
-
### Changed
|
|
29
|
-
|
|
30
|
-
- Feature annotations (hole callouts, location dimensions, section view) now
|
|
31
|
-
fire on feature presence independent of the turned/prismatic classification,
|
|
32
|
-
so turned-and-drilled parts (e.g. flanges) get both the OD/centreline base
|
|
33
|
-
set and per-hole callouts plus bolt-circle furniture (#10).
|
|
34
|
-
- Isometric view placement now uses a general largest-empty-rectangle search in
|
|
35
|
-
place of the wide/flat-on-A3 special case (#11).
|
|
36
|
-
- Concentric bore-leader stacking is generalised beyond three, and the
|
|
37
|
-
step-height dimension gate is now a single derived constant (#10, #12).
|
|
38
|
-
|
|
39
|
-
### Internal
|
|
40
|
-
|
|
41
|
-
- Single-sourced duplicated geometry constants from the draft preset (#12).
|
|
42
|
-
- Minor comment and logging cleanups.
|
|
43
|
-
|
|
44
|
-
## v0.1.0 — 2026-06-14
|
|
45
|
-
|
|
46
|
-
Initial release — spun out of `build123d-drafting-helpers` v0.9.1.
|
|
47
|
-
|
|
48
|
-
The automated drawing engine (`make_drawing`, `build_drawing`, `Drawing`)
|
|
49
|
-
was previously part of `build123d-drafting-helpers`. It is now a separate
|
|
50
|
-
AGPL-licensed package that depends on `build123d-drafting-helpers>=0.9.1`
|
|
51
|
-
for annotation primitives.
|
|
52
|
-
|
|
53
|
-
### Migration from build123d-drafting-helpers
|
|
54
|
-
|
|
55
|
-
```python
|
|
56
|
-
# Before
|
|
57
|
-
from build123d_drafting import make_drawing, Drawing, build_drawing
|
|
58
|
-
|
|
59
|
-
# After
|
|
60
|
-
from draftwright import make_drawing, Drawing, build_drawing
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
### Features (carried over from build123d-drafting-helpers)
|
|
64
|
-
|
|
65
|
-
- **`make_drawing`** / **`build_drawing`** — automatic multi-view technical
|
|
66
|
-
drawing from a build123d solid: view layout, scale selection, orthographic
|
|
67
|
-
projection, dimension placement, title block.
|
|
68
|
-
- **`Drawing`** — composable drawing object with `.lint()`, `.add()`,
|
|
69
|
-
`.export_svg()`, `.export_dxf()`.
|
|
70
|
-
- **`choose_scale`** — ISO/ASME standard scale selection.
|
|
71
|
-
- **`lint_feature_coverage`** — checks annotation coverage against detected
|
|
72
|
-
part features (holes, bosses, bolt circles).
|
|
73
|
-
- **Section A–A views** — automatic section view for blind/stepped holes,
|
|
74
|
-
with ISO 128-44 solid filled cutting-plane arrows and ISO 128-50 45°
|
|
75
|
-
hatching on the cut face.
|
|
76
|
-
- **`generate_script`** — generates a standalone drawing script from a STEP
|
|
77
|
-
file.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|