draftwright 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {draftwright-0.2.1 → draftwright-0.2.2}/CHANGELOG.md +25 -0
  2. {draftwright-0.2.1 → draftwright-0.2.2}/PKG-INFO +1 -1
  3. {draftwright-0.2.1 → draftwright-0.2.2}/pyproject.toml +1 -1
  4. draftwright-0.2.2/src/draftwright/__init__.py +103 -0
  5. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/annotations/from_model.py +17 -3
  6. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/annotations/holes.py +58 -23
  7. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/annotations/orchestrator.py +31 -3
  8. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/cli.py +6 -2
  9. {draftwright-0.2.1 → draftwright-0.2.2}/tests/test_make_drawing.py +149 -19
  10. draftwright-0.2.1/src/draftwright/__init__.py +0 -62
  11. {draftwright-0.2.1 → draftwright-0.2.2}/.gitignore +0 -0
  12. {draftwright-0.2.1 → draftwright-0.2.2}/LICENSE +0 -0
  13. {draftwright-0.2.1 → draftwright-0.2.2}/README.md +0 -0
  14. {draftwright-0.2.1 → draftwright-0.2.2}/skills/SKILL.md +0 -0
  15. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/_core.py +0 -0
  16. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/analysis.py +0 -0
  17. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/annotate.py +0 -0
  18. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/annotations/__init__.py +0 -0
  19. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/annotations/_common.py +0 -0
  20. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/annotations/sections.py +0 -0
  21. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/builder.py +0 -0
  22. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/drawing.py +0 -0
  23. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/export.py +0 -0
  24. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/features.py +0 -0
  25. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  26. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  27. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  28. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  29. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/fonts/__init__.py +0 -0
  30. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/layout.py +0 -0
  31. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/linting/__init__.py +0 -0
  32. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/linting/coverage.py +0 -0
  33. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/linting/issues.py +0 -0
  34. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/linting/structural.py +0 -0
  35. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/linting/suggest.py +0 -0
  36. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/make_drawing.py +0 -0
  37. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/model/__init__.py +0 -0
  38. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/model/detect.py +0 -0
  39. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/model/ir.py +0 -0
  40. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/model/planner.py +0 -0
  41. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/pmi.py +0 -0
  42. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/projection.py +0 -0
  43. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/recognition/__init__.py +0 -0
  44. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/recognition/_features.py +0 -0
  45. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/recognition/levels.py +0 -0
  46. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/recognition/slots.py +0 -0
  47. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/recognition/turned.py +0 -0
  48. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/registry.py +0 -0
  49. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/repair.py +0 -0
  50. {draftwright-0.2.1 → draftwright-0.2.2}/src/draftwright/sheet.py +0 -0
  51. {draftwright-0.2.1 → draftwright-0.2.2}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  52. {draftwright-0.2.1 → draftwright-0.2.2}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  53. {draftwright-0.2.1 → draftwright-0.2.2}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  54. {draftwright-0.2.1 → draftwright-0.2.2}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  55. {draftwright-0.2.1 → draftwright-0.2.2}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  56. {draftwright-0.2.1 → draftwright-0.2.2}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  57. {draftwright-0.2.1 → draftwright-0.2.2}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  58. {draftwright-0.2.1 → draftwright-0.2.2}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  59. {draftwright-0.2.1 → draftwright-0.2.2}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  60. {draftwright-0.2.1 → draftwright-0.2.2}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  61. {draftwright-0.2.1 → draftwright-0.2.2}/tests/test_e2e_slice.py +0 -0
  62. {draftwright-0.2.1 → draftwright-0.2.2}/tests/test_e2e_standards.py +0 -0
  63. {draftwright-0.2.1 → draftwright-0.2.2}/tests/test_layout.py +0 -0
  64. {draftwright-0.2.1 → draftwright-0.2.2}/tests/test_lint_structural.py +0 -0
  65. {draftwright-0.2.1 → draftwright-0.2.2}/tests/test_linting.py +0 -0
  66. {draftwright-0.2.1 → draftwright-0.2.2}/tests/test_part_model.py +0 -0
  67. {draftwright-0.2.1 → draftwright-0.2.2}/tests/test_pmi.py +0 -0
  68. {draftwright-0.2.1 → draftwright-0.2.2}/tests/test_recognition.py +0 -0
  69. {draftwright-0.2.1 → draftwright-0.2.2}/tests/test_registry.py +0 -0
  70. {draftwright-0.2.1 → draftwright-0.2.2}/tests/test_render_seam.py +0 -0
  71. {draftwright-0.2.1 → draftwright-0.2.2}/tests/test_turned_steps.py +0 -0
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.2.2 — 2026-06-30
6
+
7
+ A patch release of turned-part dimension-placement fixes and a CLI start-up
8
+ speed-up. Drawing output changes for the affected turned/cross-drilled parts.
9
+
10
+ ### Fixed
11
+
12
+ - **A coaxial bore callout on a *stepped* turned shaft is now lifted off the round
13
+ view's centre axis** (#305). The earlier fix only triggered for a uniform
14
+ (`is_rotational`) cylinder; a stepped shaft (e.g. the gramel GRM-03 drive screw)
15
+ has a turned step profile but isn't classified rotational, so its `⌀… ↓…` bore
16
+ callout was still leadered straight along the centreline, with the centre mark
17
+ running through the text. The lift now also fires for a turned-profile part.
18
+ - **A side-drilled hole's location dimension now stacks *inside* the overall
19
+ envelope dimension** (ISO order — overall dim outermost, feature/location dims
20
+ nearer the view). It was placed *outside* the envelope, which forced the shorter
21
+ location dim's arrowheads to flip outward and clash (seen on GRM-01 and GRM-02).
22
+ The mandatory overall dimension is still guaranteed placement.
23
+
24
+ ### Changed
25
+
26
+ - **CLI shell completion and `--help` are fast again** (#313). The Typer CLI and
27
+ the heavy CAD engine are now imported lazily, so tab-completion and `--help` no
28
+ longer pay a ~6 s engine-import cost; a real drawing run is unaffected.
29
+
5
30
  ## v0.2.1 — 2026-06-30
6
31
 
7
32
  A patch release focused on **turned-part dimensioning legibility**: crowded
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Automated technical-drawing generation for build123d
5
5
  Project-URL: Homepage, https://github.com/pzfreo/draftwright
6
6
  Project-URL: Repository, https://github.com/pzfreo/draftwright
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -0,0 +1,103 @@
1
+ """draftwright — automated technical-drawing generation for build123d.
2
+
3
+ Takes a build123d solid and produces a fully-annotated multi-view technical
4
+ drawing (orthographic views, dimensions, section A–A, title block) ready for
5
+ DXF/SVG export::
6
+
7
+ from draftwright import make_drawing
8
+ make_drawing(my_part, out="drawing")
9
+
10
+ Requires build123d-drafting-helpers for annotation primitives.
11
+ Licensed under the GNU Affero General Public License v3 (AGPL-3.0).
12
+ """
13
+
14
+ import importlib as _importlib
15
+ import sys as _sys
16
+ import types as _types
17
+ from typing import TYPE_CHECKING
18
+
19
+ # Public API resolved lazily (PEP 562): `import draftwright` — and, crucially,
20
+ # `import draftwright.cli` (which runs this __init__ first) — must NOT eagerly
21
+ # pull in the engine. The engine drags build123d/OCP, ~5 s of CAD-kernel import.
22
+ # The CLI's shell completion, --help and --version import this package but touch
23
+ # none of these names, so they stay sub-second instead of paying for the kernel
24
+ # on every TAB press (#313). Each name maps to the submodule that provides it.
25
+ _LAZY = {
26
+ "analyse_face_levels": "draftwright.analysis",
27
+ "dedup_diams": "draftwright.analysis",
28
+ "build_drawing": "draftwright.builder",
29
+ "generate_script": "draftwright.builder",
30
+ "make_drawing": "draftwright.builder",
31
+ "Drawing": "draftwright.drawing",
32
+ "FeatureInfo": "draftwright.drawing",
33
+ "fix_svg_page_size": "draftwright.export",
34
+ "lint_feature_coverage": "draftwright.linting",
35
+ "PmiRecord": "draftwright.pmi",
36
+ "extract_pmi": "draftwright.pmi",
37
+ "choose_scale": "draftwright.sheet",
38
+ }
39
+
40
+
41
+ def _resolve(name):
42
+ """Import the providing submodule and cache the public object on the package."""
43
+ value = getattr(_importlib.import_module(_LAZY[name]), name)
44
+ _types.ModuleType.__setattr__(_sys.modules[__name__], name, value)
45
+ return value
46
+
47
+
48
+ class _DraftwrightModule(_types.ModuleType):
49
+ def __getattr__(self, name):
50
+ # PEP 562 miss handler: resolve a public name on first access.
51
+ if name in _LAZY:
52
+ return _resolve(name)
53
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
54
+
55
+ def __getattribute__(self, name):
56
+ # `draftwright.make_drawing` must be the FUNCTION even after the compat
57
+ # SUBMODULE of the same name is imported and shadows it as a package
58
+ # attribute (importing draftwright.make_drawing binds the module here).
59
+ if name == "make_drawing":
60
+ namespace = _types.ModuleType.__getattribute__(self, "__dict__")
61
+ value = namespace.get("make_drawing")
62
+ if (
63
+ isinstance(value, _types.ModuleType)
64
+ and value.__name__ == "draftwright.make_drawing"
65
+ ):
66
+ return _resolve("make_drawing")
67
+ return _types.ModuleType.__getattribute__(self, name)
68
+
69
+
70
+ _sys.modules[__name__].__class__ = _DraftwrightModule
71
+
72
+
73
+ if TYPE_CHECKING: # static analysers / IDEs — no runtime import, no kernel cost
74
+ from draftwright.analysis import analyse_face_levels, dedup_diams
75
+ from draftwright.builder import build_drawing, generate_script, make_drawing
76
+ from draftwright.drawing import Drawing, FeatureInfo
77
+ from draftwright.export import fix_svg_page_size
78
+ from draftwright.linting import lint_feature_coverage
79
+ from draftwright.pmi import PmiRecord, extract_pmi
80
+ from draftwright.sheet import choose_scale
81
+
82
+
83
+ def __dir__():
84
+ # Surface the lazy public names (not in __dict__ until first accessed)
85
+ # *alongside* the normal module contents — dunders, imported submodules — so
86
+ # introspection / REPL completion sees the full surface, not a subset.
87
+ return sorted(set(globals()) | set(__all__))
88
+
89
+
90
+ __all__ = [
91
+ "Drawing",
92
+ "FeatureInfo",
93
+ "PmiRecord",
94
+ "analyse_face_levels",
95
+ "build_drawing",
96
+ "choose_scale",
97
+ "dedup_diams",
98
+ "extract_pmi",
99
+ "fix_svg_page_size",
100
+ "generate_script",
101
+ "lint_feature_coverage",
102
+ "make_drawing",
103
+ ]
@@ -532,6 +532,20 @@ def _env_pd(group, role):
532
532
  return next((pd for pd in group.dims if pd.param.role == role), None)
533
533
 
534
534
 
535
+ def envelope_group(groups):
536
+ """The envelope `DimensionGroup` in *groups*, or None."""
537
+ return next((g for g in groups if g.feature_kind == "envelope"), None)
538
+
539
+
540
+ def env_dim_placed(pd) -> bool:
541
+ """Whether :func:`render_envelope` will actually place an envelope dim for the
542
+ PlannedDimension *pd* — present, not suppressed by the planner (square footprint /
543
+ X-turned, #250), and carrying a span. The single source of truth for that
544
+ decision, shared with the orchestrator's side-below tier reservation so the two
545
+ can never drift (#316 review)."""
546
+ return pd is not None and not pd.suppressed and pd.param.span is not None
547
+
548
+
535
549
  def render_envelope(dwg, groups, a) -> int:
536
550
  """Overall width (plan, below) + depth (side, below) envelope dims via the IR,
537
551
  placed through the engine's below-strip zone allocators (the zone-aware render
@@ -539,12 +553,12 @@ def render_envelope(dwg, groups, a) -> int:
539
553
  those strips). The **planner** decides suppression (square footprint / X-turned;
540
554
  #250); this renderer just skips suppressed dims and places the rest. Returns the
541
555
  count placed."""
542
- env = next((g for g in groups if g.feature_kind == "envelope"), None)
556
+ env = envelope_group(groups)
543
557
  if env is None:
544
558
  return 0
545
559
  n = 0
546
560
  width = _env_pd(env, "width")
547
- if width is not None and not width.suppressed and width.param.span is not None:
561
+ if env_dim_placed(width):
548
562
  (x0, y0, z0), (x1, _, _) = width.param.span
549
563
  p1, p2 = dwg.at("plan", x0, y0, z0), dwg.at("plan", x1, y0, z0)
550
564
  witness = p1[1] - 2
@@ -564,7 +578,7 @@ def render_envelope(dwg, groups, a) -> int:
564
578
  )
565
579
  n += 1
566
580
  depth = _env_pd(env, "depth")
567
- if depth is not None and not depth.suppressed and depth.param.span is not None:
581
+ if env_dim_placed(depth):
568
582
  (x0, y0, z0), (_, y1, _) = depth.param.span
569
583
  p1, p2 = dwg.at("side", x0, y0, z0), dwg.at("side", x0, y1, z0)
570
584
  witness = p1[1] - 2
@@ -73,17 +73,29 @@ def _record_callout_drop(dwg, view, diam, reason):
73
73
  )
74
74
 
75
75
 
76
- def _locate_off_axis_holes(dwg, a: Analysis, holes_in=None):
76
+ def _locate_off_axis_holes(dwg, a: Analysis, holes_in=None, *, which):
77
77
  """Location dimensions for side-drilled holes (#133).
78
78
 
79
79
  An X-axis hole is a circle in the SIDE view (locate its Y below the view and
80
80
  its Z to the right — the side view has no left strip); a Y-axis hole is a
81
81
  circle in the FRONT view (locate its X below and its Z to the right). Each
82
- offset is allocated from the view's strip so dims stack without overlap, and
83
- this pass runs AFTER the envelope and turned-diameter passes so it can never
84
- evict an overall dimension. A tier with no room is dropped and recorded as
85
- ``off_axis_location_dropped`` — never force-stacked. Holes already covered by
86
- a pattern callout are skipped, as in the plan path.
82
+ offset is allocated from the view's strip so dims stack without overlap. A tier
83
+ with no room is dropped and recorded as ``off_axis_location_dropped`` never
84
+ force-stacked. Holes already covered by a pattern callout are skipped.
85
+
86
+ Run in two phases (``which`` is ``"across"`` or ``"along"``) so each dim stacks in
87
+ the ISO order — overall dim OUTERMOST, feature/location dims nearer the view:
88
+
89
+ - ``"across"`` — an X-axis hole's in-plane (Y, side-below) location, placed BEFORE
90
+ the envelope so the overall depth dim lands outside it (the side-view
91
+ counterpart of the plan view, where location dims already precede the
92
+ envelope). Fixes the inverted stack where the overall dim sat innermost and
93
+ forced the shorter location dim's arrows outside. The orchestrator reserves the
94
+ envelope's tier first, so these best-effort dims can't starve it.
95
+ - ``"along"`` — a Y-axis hole's X (front-below) location and every hole's height
96
+ (Z, right-strip), placed AFTER the envelope and the turned-diameter passes so
97
+ they never evict those overall dims from the contended front-below / right
98
+ strips (#133).
87
99
  """
88
100
  draft = dwg.draft
89
101
  all_holes = a.holes if holes_in is None else holes_in
@@ -150,17 +162,35 @@ def _locate_off_axis_holes(dwg, a: Analysis, holes_in=None):
150
162
  ):
151
163
  _drop(axis, view)
152
164
 
153
- # In-plane offset: X-axis hole -> Y below the side view; Y-axis hole -> X
154
- # below the front view (each view's below strip is its own, uncontended).
155
- yw, xw = SZ(dz) - 2, FZ(dz) - 2
156
- seen_y, seen_x = set(), set()
157
- for h in (h for h in off if _axis_letter(h) == "x"):
158
- yo = round(abs(h.location[1] - dy), 2)
159
- if yo * a.SCALE >= 1.0 and yo not in seen_y:
160
- seen_y.add(yo)
161
- _below(
162
- a.sv_zones.below, "side", (SX(dy), yw, 0), (SX(h.location[1]), yw, 0), yw, yo, "y"
163
- )
165
+ # "across" phase — an X-axis hole's Y position below the SIDE view, placed BEFORE
166
+ # the envelope so the overall depth dim stacks outside it (ISO order). Confined to
167
+ # the side view: a Y-axis hole's X position contends the FRONT-below strip with the
168
+ # turned-diameter ø-row, so it stays in the "along" phase (after the diameter pass),
169
+ # preserving the #133 priority. The orchestrator reserves one sv_zones.below tier
170
+ # for the mandatory envelope depth before calling this, so these best-effort
171
+ # location dims can never starve it.
172
+ if which == "across":
173
+ yw = SZ(dz) - 2
174
+ seen_y: set = set()
175
+ for h in (h for h in off if _axis_letter(h) == "x"):
176
+ yo = round(abs(h.location[1] - dy), 2)
177
+ if yo * a.SCALE >= 1.0 and yo not in seen_y:
178
+ seen_y.add(yo)
179
+ _below(
180
+ a.sv_zones.below,
181
+ "side",
182
+ (SX(dy), yw, 0),
183
+ (SX(h.location[1]), yw, 0),
184
+ yw,
185
+ yo,
186
+ "y",
187
+ )
188
+ return
189
+
190
+ # "along" phase (after the envelope + turned-diameter passes): a Y-axis hole's X
191
+ # position below the FRONT view, then every hole's height (Z) to the right.
192
+ xw = FZ(dz) - 2
193
+ seen_x: set = set()
164
194
  for h in (h for h in off if _axis_letter(h) == "y"):
165
195
  xo = round(abs(h.location[0] - dx), 2)
166
196
  if xo * a.SCALE >= 1.0 and xo not in seen_x:
@@ -520,21 +550,26 @@ def _annotate_holes(dwg, a: Analysis, view_of_axis, groups, feature_keys):
520
550
 
521
551
  A bore on the turning axis is led out along the view's horizontal centre
522
552
  axis, so the centre mark / centreline runs straight through the "⌀… ↓…"
523
- callout text. Detect that one bore — a rotational part, hole at the view
524
- centre — and lift its row a clearance off the axis (an angled leader to a
525
- central feature is standard practice), toward the roomier side. Off-axis
553
+ callout text. Detect that one bore — a **turned/rotational** part, hole at
554
+ the view centre — and lift its row a clearance off the axis (an angled leader
555
+ to a central feature is standard practice), toward the roomier side. Off-axis
526
556
  holes and every prismatic-part hole are untouched (front-view round parts
527
557
  place coaxial bores as vertical shafts below the view, not along an axis,
528
558
  so they can't hit this and are exempt by construction).
529
559
 
560
+ The "turned/rotational" gate is ``is_rotational OR prof`` — a *stepped*
561
+ turned shaft (e.g. the GRM-03 drive screw) has a turned step profile but is
562
+ not ``is_rotational`` (its varying OD doesn't fill a square cross-section),
563
+ yet its coaxial bore hits exactly this defect. A prismatic part has neither,
564
+ so it stays excluded (the regression the original gate guarded against, #305).
565
+
530
566
  Tactical: the principled fix is to not draw the crossing line at all — a
531
567
  centred bore is located by the axis, so its linear location dims are
532
568
  redundant (#309) — or to make this a layout-solver separation constraint
533
569
  (ADR 0003). This nudge becomes dead code once either lands."""
534
570
  tol = draft.font_size # "hole at the view centre" tolerance (page mm)
535
- if not (
536
- a.is_rotational and abs(centre[0] - view_cx) < tol and abs(centre[1] - view_cy) < tol
537
- ):
571
+ turned = a.is_rotational or a.prof is not None
572
+ if not (turned and abs(centre[0] - view_cx) < tol and abs(centre[1] - view_cy) < tol):
538
573
  return ny
539
574
  # Lift the row a full text height + padding clear of the axis: enough for
540
575
  # the text box (half a font tall) to sit wholly off the centre line with a
@@ -19,6 +19,7 @@ import math
19
19
 
20
20
  from draftwright._core import (
21
21
  _CONCENTRIC_TOL_MM,
22
+ _SLOT_DIM_DEPTH,
22
23
  _TABULATE_MIN_HOLES,
23
24
  Analysis,
24
25
  HoleRef,
@@ -30,6 +31,9 @@ from draftwright._core import (
30
31
  _tag_sequence,
31
32
  )
32
33
  from draftwright.annotations.from_model import (
34
+ _env_pd,
35
+ env_dim_placed,
36
+ envelope_group,
33
37
  render_centermarks,
34
38
  render_diameters,
35
39
  render_envelope,
@@ -213,6 +217,29 @@ def _auto_annotate(dwg, a: Analysis, *, detail_view: bool = False):
213
217
  # turned parts, and a Z-turned overall height is suppressed there (ISO 129).
214
218
  render_height_ladder(dwg, _model, a)
215
219
 
220
+ # Side-drilled holes' in-plane (side-below) locations FIRST, so the overall
221
+ # envelope depth lands OUTSIDE them — ISO stacks the overall dim outermost,
222
+ # feature/location dims nearer the view (matches the plan view, where location
223
+ # dims precede the envelope). To keep the #133 guarantee that the MANDATORY
224
+ # envelope dim is never starved, reserve its tier in the side-below strip first
225
+ # (shrink the strip by one depth slot for the location pass, then restore) — so
226
+ # the best-effort locations fill inner tiers and the envelope always gets the
227
+ # outermost. The height (right-strip) locations stay in the "along" phase.
228
+ if feature_holes:
229
+ # Only reserve when render_envelope will actually place the depth dim — the
230
+ # planner suppresses it for a square footprint / X-turned part (#250); reserving
231
+ # a tier it never claims would needlessly shrink the strip and drop a location
232
+ # dim that would otherwise fit (#316 review). Uses render_envelope's own
233
+ # place-predicate (env_dim_placed) so the two can never drift.
234
+ _env_g = envelope_group(_groups)
235
+ _reserve = _env_g is not None and env_dim_placed(_env_pd(_env_g, "depth"))
236
+ _below = a.sv_zones.below
237
+ _saved_limit = _below.outer_limit
238
+ if _reserve:
239
+ _below.outer_limit -= _below.direction * (_SLOT_DIM_DEPTH + _below.spacing)
240
+ _locate_off_axis_holes(dwg, a, holes_in=feature_holes, which="across")
241
+ _below.outer_limit = _saved_limit
242
+
216
243
  # Overall width (plan, below) + depth (side, below) envelope dims — IR renderer,
217
244
  # placed through the same below-strip zone allocators the engine used (zone-aware
218
245
  # render stage, ADR 0008). Suppression (square footprint / X-turned width) is now
@@ -245,10 +272,11 @@ def _auto_annotate(dwg, a: Analysis, *, detail_view: bool = False):
245
272
  if a.prof is not None:
246
273
  render_step_lengths(dwg, _groups)
247
274
 
248
- # Side-drilled (X/Y-axis) hole locations — last, so the envelope and
249
- # turned-diameter dims claim their strip space first and are never evicted (#133).
275
+ # Side-drilled (X/Y-axis) hole HEIGHT locations — last, so the envelope and
276
+ # turned-diameter dims claim their (contended right) strip space first and are
277
+ # never evicted (#133). The in-plane locations were placed before the envelope.
250
278
  if feature_holes:
251
- _locate_off_axis_holes(dwg, a, holes_in=feature_holes)
279
+ _locate_off_axis_holes(dwg, a, holes_in=feature_holes, which="along")
252
280
 
253
281
  # Non-cylindrical machined features: slots / reduced across-flats sections
254
282
  # (#135) — IR renderer, placed through the zone strips (shared infra). Runs
@@ -17,8 +17,6 @@ from importlib.metadata import version as _pkg_version
17
17
 
18
18
  import typer
19
19
 
20
- from draftwright.builder import build_drawing, generate_script
21
-
22
20
  app = typer.Typer(
23
21
  add_completion=True,
24
22
  # Plain tracebacks on an engine error, matching the old argparse CLI — a rich
@@ -144,6 +142,12 @@ def main(
144
142
 
145
143
  formats = _parse_formats(output_format)
146
144
 
145
+ # Import the engine lazily, only on the build path: it pulls in build123d/OCP
146
+ # (~5 s of CAD-kernel import). Keeping it out of module scope means shell
147
+ # completion, --help and --version (which import this module but never call
148
+ # the command) stay sub-second instead of paying for the kernel every time.
149
+ from draftwright.builder import build_drawing, generate_script
150
+
147
151
  if script:
148
152
  py_path = generate_script(
149
153
  step_file=step_file,
@@ -1648,6 +1648,42 @@ def test_cli_version_reports_installed_version():
1648
1648
  assert result.stdout.strip() == f"draftwright {_pkg_version('draftwright')}"
1649
1649
 
1650
1650
 
1651
+ def test_cli_import_does_not_load_the_engine():
1652
+ """Importing the CLI must not pull in build123d/OCP (#313). Shell completion,
1653
+ --help and --version import this module on every invocation; loading the
1654
+ ~5 s CAD kernel there made each TAB press take ~6 s. Guard the lazy boundary:
1655
+ the engine is imported only on the actual build path, in a fresh process so
1656
+ other tests' imports can't mask a regression."""
1657
+ code = (
1658
+ "import sys, draftwright.cli; "
1659
+ "heavy = [m for m in ('build123d', 'OCP') if m in sys.modules]; "
1660
+ "print(','.join(heavy)); sys.exit(1 if heavy else 0)"
1661
+ )
1662
+ result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
1663
+ assert result.returncode == 0, (
1664
+ f"`import draftwright.cli` eagerly loaded: {result.stdout.strip()}"
1665
+ )
1666
+
1667
+
1668
+ def test_lazy_public_api_preserves_make_drawing_identity():
1669
+ """The lazy package __init__ (#313) must still expose the public API, and
1670
+ `draftwright.make_drawing` must stay the FUNCTION even after the compat
1671
+ submodule of the same name is imported and would otherwise shadow it."""
1672
+ code = (
1673
+ "import types, draftwright as d; "
1674
+ "from draftwright import make_drawing, build_drawing, Drawing, choose_scale; "
1675
+ "assert callable(make_drawing) and not isinstance(make_drawing, types.ModuleType); "
1676
+ "assert d.make_drawing is make_drawing; "
1677
+ "import draftwright.make_drawing; " # provoke the shadowing path
1678
+ "assert callable(d.make_drawing) and not isinstance(d.make_drawing, types.ModuleType); "
1679
+ "print('ok')"
1680
+ )
1681
+ result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
1682
+ assert result.returncode == 0 and result.stdout.strip() == "ok", (
1683
+ f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
1684
+ )
1685
+
1686
+
1651
1687
  # ---------------------------------------------------------------------------
1652
1688
  # --format selector (#288) — pure logic, no OCP build needed
1653
1689
  # ---------------------------------------------------------------------------
@@ -2294,6 +2330,68 @@ class TestPrismaticClassification:
2294
2330
  # the right strips — the sheet stays lint-clean (#133 rework).
2295
2331
  assert [i for i in dwg.lint() if i.severity != "info"] == []
2296
2332
 
2333
+ def test_side_view_location_dim_stacks_inside_the_envelope(self):
2334
+ # ISO stacking: the overall (envelope) dim sits OUTERMOST, the feature/location
2335
+ # dim nearer the view. A side-drilled hole's in-plane location must therefore
2336
+ # be CLOSER to the side view than the envelope-depth dim. The inverted stack
2337
+ # (envelope innermost) forced the shorter location dim's arrows to flip outward
2338
+ # and clash with the envelope (GRM-01 / GRM-02).
2339
+ from build123d import Rot
2340
+
2341
+ # Off-centre hole (y=2, not the centreline) so the location dim is real, not a
2342
+ # redundant centred one — this isolates the stacking order.
2343
+ part = Box(12, 11, 40) - Pos(0, 2, 6) * Rot(0, 90, 0) * Cylinder(3, 12)
2344
+ dwg = build_drawing(part)
2345
+ env = dwg._named.get("m_env_depth")
2346
+ loc = [o for n, o in dwg._named.items() if n.startswith("dim_loc_side_y")]
2347
+ assert env is not None and loc, "expected an envelope-depth dim and a side location dim"
2348
+
2349
+ def ymid(o):
2350
+ bb = o.bounding_box()
2351
+ return (bb.min.Y + bb.max.Y) / 2
2352
+
2353
+ # The below strip extends downward from the side view, so nearer the view =
2354
+ # higher Y. The location dim must sit nearer the view than the overall dim.
2355
+ assert min(ymid(o) for o in loc) > ymid(env), "location must stack inside the envelope"
2356
+ assert [i for i in dwg.lint() if i.severity != "info"] == []
2357
+
2358
+ def test_envelope_depth_survives_many_side_location_dims(self):
2359
+ # The mandatory overall depth dim must always be placed, even when several
2360
+ # side-drilled holes fill the side-below strip with location dims. The
2361
+ # location pass now runs before the envelope (for ISO stacking), so the
2362
+ # orchestrator reserves the envelope's tier first — best-effort location dims
2363
+ # can never starve it (the #316-review regression).
2364
+ from build123d import Cylinder, Pos, Rot
2365
+
2366
+ part = Box(12, 24, 60)
2367
+ for y, z in [(-9, -20), (-5, -8), (7, 4), (10, 16)]:
2368
+ part -= Pos(0, y, z) * Rot(0, 90, 0) * Cylinder(1.5, 12)
2369
+ dwg = build_drawing(part)
2370
+ ylocs = [n for n in dwg._named if n.startswith("dim_loc_side_y")]
2371
+ assert len(ylocs) >= 2, "expected several side-below location dims for strip pressure"
2372
+ assert "m_env_depth" in dwg._named, "the mandatory overall depth dim was starved"
2373
+ assert dwg.lint_summary()["by_code"].get("missing_principal_dimension", 0) == 0
2374
+
2375
+ def ymid(o):
2376
+ bb = o.bounding_box()
2377
+ return (bb.min.Y + bb.max.Y) / 2
2378
+
2379
+ env = dwg._named["m_env_depth"]
2380
+ assert all(ymid(dwg._named[n]) > ymid(env) for n in ylocs), "locations must stack inside"
2381
+
2382
+ def test_square_footprint_does_not_reserve_a_suppressed_envelope_tier(self):
2383
+ # When the planner suppresses the depth dim (square footprint / X-turned),
2384
+ # the side-below envelope-tier reservation must NOT fire — reserving a tier
2385
+ # render_envelope never claims would needlessly shrink the strip and drop a
2386
+ # side location that otherwise fits (#316 review).
2387
+ from build123d import Cylinder, Pos, Rot
2388
+
2389
+ part = Box(20, 20, 40) - Pos(0, 4, 0) * Rot(0, 90, 0) * Cylinder(2, 20)
2390
+ dwg = build_drawing(part)
2391
+ assert "m_env_depth" not in dwg._named # square footprint → depth suppressed
2392
+ assert [n for n in dwg._named if n.startswith("dim_loc_side_y")], "location was dropped"
2393
+ assert dwg.lint_summary()["by_code"].get("off_axis_location_dropped", 0) == 0
2394
+
2297
2395
  @pytest.mark.timeout(60)
2298
2396
  def test_locates_every_side_drilled_hole_not_just_the_first(self):
2299
2397
  # Two side-drilled (Y-axis) holes at distinct x: each must get its own
@@ -3687,6 +3785,32 @@ class TestLintSummaryAndDrops:
3687
3785
 
3688
3786
 
3689
3787
  class TestLayoutGeneralisation:
3788
+ @staticmethod
3789
+ def _lines_crossing_label(dwg, callout_name, label_bbox):
3790
+ """Horizontal lines (other than the callout's own shelf) that cross the
3791
+ callout's text box — the #305 'line through the callout text' defect. Shared
3792
+ by the coaxial-bore tests."""
3793
+ tx0, ty0, tx1, ty1 = label_bbox
3794
+ crossings = []
3795
+ for n, o in dwg._named.items():
3796
+ if n == callout_name:
3797
+ continue
3798
+ try:
3799
+ edges = list(o.edges())
3800
+ except Exception:
3801
+ continue
3802
+ for e in edges:
3803
+ vs = e.vertices()
3804
+ if len(vs) != 2:
3805
+ continue
3806
+ (x0, y0), (x1, y1) = (vs[0].X, vs[0].Y), (vs[1].X, vs[1].Y)
3807
+ if abs(y0 - y1) < 0.05 and abs(x0 - x1) > 1.0: # a horizontal line
3808
+ ym = (y0 + y1) / 2
3809
+ xa, xb = min(x0, x1), max(x0, x1)
3810
+ if ty0 + 0.3 < ym < ty1 - 0.3 and xb > tx0 + 0.3 and xa < tx1 - 0.3:
3811
+ crossings.append((n, round(ym, 2)))
3812
+ return crossings
3813
+
3690
3814
  @pytest.mark.timeout(120)
3691
3815
  def test_turned_flange_gets_both_od_and_hole_furniture(self):
3692
3816
  # A turned-and-drilled flange (cylinder OD + centre bore + bolt circle)
@@ -3756,26 +3880,32 @@ class TestLayoutGeneralisation:
3756
3880
  hc = [(n, o) for n, o in dwg._named.items() if n.startswith("hc_side")]
3757
3881
  assert hc, "expected a bore callout on the round (side) view"
3758
3882
  name, leader = hc[0]
3759
- tx0, ty0, tx1, ty1 = leader.label_bbox
3883
+ crossings = self._lines_crossing_label(dwg, name, leader.label_bbox)
3884
+ assert not crossings, f"line(s) cross the bore callout text: {crossings}"
3760
3885
 
3761
- crossings = []
3762
- for n, o in dwg._named.items():
3763
- if n == name:
3764
- continue # the callout's own shelf ends before its text
3765
- try:
3766
- edges = list(o.edges())
3767
- except Exception:
3768
- continue
3769
- for e in edges:
3770
- vs = e.vertices()
3771
- if len(vs) != 2:
3772
- continue
3773
- (x0, y0), (x1, y1) = (vs[0].X, vs[0].Y), (vs[1].X, vs[1].Y)
3774
- if abs(y0 - y1) < 0.05 and abs(x0 - x1) > 1.0: # a horizontal line
3775
- ym = (y0 + y1) / 2
3776
- xa, xb = min(x0, x1), max(x0, x1)
3777
- if ty0 + 0.3 < ym < ty1 - 0.3 and xb > tx0 + 0.3 and xa < tx1 - 0.3:
3778
- crossings.append((n, round(ym, 2)))
3886
+ def test_coaxial_bore_callout_clears_centre_axis_on_stepped_shaft(self):
3887
+ # #305 regression: the lift must also fire for a *stepped* turned shaft (the
3888
+ # GRM-03 drive screw), which has a turned step profile but is NOT
3889
+ # is_rotational (its varying OD doesn't fill a square cross-section) the
3890
+ # original is_rotational-only gate missed it, leaving the bore callout led
3891
+ # straight along the centre axis. Assert no horizontal line crosses the text.
3892
+ from build123d import Align, Cylinder, Pos, Rotation
3893
+
3894
+ from draftwright import build_drawing
3895
+
3896
+ b = Align.MIN
3897
+ part = (
3898
+ Cylinder(6, 12, align=(Align.CENTER, Align.CENTER, b))
3899
+ + Pos(0, 0, 12) * Cylinder(4, 12, align=(Align.CENTER, Align.CENTER, b))
3900
+ - Cylinder(0.8, 8, align=(Align.CENTER, Align.CENTER, b))
3901
+ )
3902
+ dwg = build_drawing(Rotation(0, 90, 0) * part, scale=2.0)
3903
+ assert dwg._analysis.prof is not None and not dwg._analysis.is_rotational
3904
+
3905
+ hc = [(n, o) for n, o in dwg._named.items() if n.startswith("hc_side")]
3906
+ assert hc, "expected a bore callout on the round (side) view"
3907
+ name, leader = hc[0]
3908
+ crossings = self._lines_crossing_label(dwg, name, leader.label_bbox)
3779
3909
  assert not crossings, f"line(s) cross the bore callout text: {crossings}"
3780
3910
 
3781
3911
  def test_prismatic_central_hole_callout_not_lifted(self):
@@ -1,62 +0,0 @@
1
- """draftwright — automated technical-drawing generation for build123d.
2
-
3
- Takes a build123d solid and produces a fully-annotated multi-view technical
4
- drawing (orthographic views, dimensions, section A–A, title block) ready for
5
- DXF/SVG export::
6
-
7
- from draftwright import make_drawing
8
- make_drawing(my_part, out="drawing")
9
-
10
- Requires build123d-drafting-helpers for annotation primitives.
11
- Licensed under the GNU Affero General Public License v3 (AGPL-3.0).
12
- """
13
-
14
- import sys as _sys
15
- import types as _types
16
-
17
- from draftwright.analysis import analyse_face_levels, dedup_diams
18
- from draftwright.builder import (
19
- build_drawing,
20
- generate_script,
21
- make_drawing,
22
- )
23
- from draftwright.drawing import Drawing, FeatureInfo
24
- from draftwright.export import fix_svg_page_size
25
- from draftwright.linting import lint_feature_coverage
26
- from draftwright.pmi import PmiRecord, extract_pmi
27
- from draftwright.sheet import choose_scale
28
-
29
- _make_drawing_function = make_drawing
30
-
31
-
32
- class _DraftwrightModule(_types.ModuleType):
33
- def __getattribute__(self, name):
34
- if name == "make_drawing":
35
- namespace = _types.ModuleType.__getattribute__(self, "__dict__")
36
- value = namespace.get(name)
37
- if (
38
- isinstance(value, _types.ModuleType)
39
- and value.__name__ == "draftwright.make_drawing"
40
- ):
41
- public = namespace["_make_drawing_function"]
42
- _types.ModuleType.__setattr__(self, name, public)
43
- return public
44
- return _types.ModuleType.__getattribute__(self, name)
45
-
46
-
47
- _sys.modules[__name__].__class__ = _DraftwrightModule
48
-
49
- __all__ = [
50
- "Drawing",
51
- "FeatureInfo",
52
- "PmiRecord",
53
- "analyse_face_levels",
54
- "build_drawing",
55
- "choose_scale",
56
- "dedup_diams",
57
- "extract_pmi",
58
- "fix_svg_page_size",
59
- "generate_script",
60
- "lint_feature_coverage",
61
- "make_drawing",
62
- ]
File without changes
File without changes
File without changes
File without changes