draftwright 0.1.13__tar.gz → 0.2.1__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 (79) hide show
  1. {draftwright-0.1.13 → draftwright-0.2.1}/CHANGELOG.md +108 -0
  2. {draftwright-0.1.13 → draftwright-0.2.1}/PKG-INFO +21 -8
  3. {draftwright-0.1.13 → draftwright-0.2.1}/README.md +17 -5
  4. {draftwright-0.1.13 → draftwright-0.2.1}/pyproject.toml +5 -5
  5. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/__init__.py +27 -5
  6. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/_core.py +105 -0
  7. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/analysis.py +54 -47
  8. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/annotate.py +1 -1
  9. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/annotations/_common.py +1 -1
  10. draftwright-0.2.1/src/draftwright/annotations/from_model.py +1358 -0
  11. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/annotations/holes.py +179 -503
  12. draftwright-0.2.1/src/draftwright/annotations/orchestrator.py +390 -0
  13. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/annotations/sections.py +194 -164
  14. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/builder.py +43 -89
  15. draftwright-0.2.1/src/draftwright/cli.py +172 -0
  16. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/drawing.py +132 -52
  17. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/export.py +31 -36
  18. draftwright-0.2.1/src/draftwright/features.py +23 -0
  19. draftwright-0.2.1/src/draftwright/linting/__init__.py +37 -0
  20. draftwright-0.2.1/src/draftwright/linting/coverage.py +427 -0
  21. draftwright-0.2.1/src/draftwright/linting/issues.py +21 -0
  22. draftwright-0.2.1/src/draftwright/linting/structural.py +635 -0
  23. draftwright-0.2.1/src/draftwright/linting/suggest.py +100 -0
  24. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/make_drawing.py +3 -0
  25. draftwright-0.2.1/src/draftwright/model/__init__.py +72 -0
  26. draftwright-0.2.1/src/draftwright/model/detect.py +301 -0
  27. draftwright-0.2.1/src/draftwright/model/ir.py +337 -0
  28. draftwright-0.2.1/src/draftwright/model/planner.py +259 -0
  29. draftwright-0.2.1/src/draftwright/recognition/__init__.py +54 -0
  30. draftwright-0.2.1/src/draftwright/recognition/_features.py +1038 -0
  31. draftwright-0.2.1/src/draftwright/recognition/levels.py +51 -0
  32. draftwright-0.1.13/src/draftwright/features.py → draftwright-0.2.1/src/draftwright/recognition/slots.py +5 -6
  33. draftwright-0.2.1/src/draftwright/recognition/turned.py +165 -0
  34. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/registry.py +21 -0
  35. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/repair.py +4 -7
  36. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/sheet.py +5 -6
  37. draftwright-0.2.1/tests/test_e2e_slice.py +72 -0
  38. draftwright-0.2.1/tests/test_lint_structural.py +419 -0
  39. {draftwright-0.1.13 → draftwright-0.2.1}/tests/test_linting.py +10 -6
  40. {draftwright-0.1.13 → draftwright-0.2.1}/tests/test_make_drawing.py +809 -93
  41. draftwright-0.2.1/tests/test_part_model.py +326 -0
  42. draftwright-0.2.1/tests/test_recognition.py +847 -0
  43. draftwright-0.2.1/tests/test_render_seam.py +63 -0
  44. draftwright-0.2.1/tests/test_turned_steps.py +87 -0
  45. draftwright-0.1.13/src/draftwright/annotations/orchestrator.py +0 -551
  46. draftwright-0.1.13/src/draftwright/annotations/pmi.py +0 -371
  47. draftwright-0.1.13/src/draftwright/annotations/turned.py +0 -196
  48. draftwright-0.1.13/src/draftwright/linting.py +0 -268
  49. draftwright-0.1.13/tests/golden/ctc01.json +0 -335
  50. draftwright-0.1.13/tests/golden/cylinder.json +0 -176
  51. draftwright-0.1.13/tests/golden/plate.json +0 -156
  52. draftwright-0.1.13/tests/golden/stepped.json +0 -148
  53. draftwright-0.1.13/tests/test_golden.py +0 -309
  54. {draftwright-0.1.13 → draftwright-0.2.1}/.gitignore +0 -0
  55. {draftwright-0.1.13 → draftwright-0.2.1}/LICENSE +0 -0
  56. {draftwright-0.1.13 → draftwright-0.2.1}/skills/SKILL.md +0 -0
  57. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/annotations/__init__.py +0 -0
  58. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  59. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  60. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  61. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  62. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/fonts/__init__.py +0 -0
  63. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/layout.py +0 -0
  64. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/pmi.py +0 -0
  65. {draftwright-0.1.13 → draftwright-0.2.1}/src/draftwright/projection.py +0 -0
  66. {draftwright-0.1.13 → draftwright-0.2.1}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  67. {draftwright-0.1.13 → draftwright-0.2.1}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  68. {draftwright-0.1.13 → draftwright-0.2.1}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  69. {draftwright-0.1.13 → draftwright-0.2.1}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  70. {draftwright-0.1.13 → draftwright-0.2.1}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  71. {draftwright-0.1.13 → draftwright-0.2.1}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  72. {draftwright-0.1.13 → draftwright-0.2.1}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  73. {draftwright-0.1.13 → draftwright-0.2.1}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  74. {draftwright-0.1.13 → draftwright-0.2.1}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  75. {draftwright-0.1.13 → draftwright-0.2.1}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  76. {draftwright-0.1.13 → draftwright-0.2.1}/tests/test_e2e_standards.py +0 -0
  77. {draftwright-0.1.13 → draftwright-0.2.1}/tests/test_layout.py +0 -0
  78. {draftwright-0.1.13 → draftwright-0.2.1}/tests/test_pmi.py +0 -0
  79. {draftwright-0.1.13 → draftwright-0.2.1}/tests/test_registry.py +0 -0
@@ -2,6 +2,114 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.2.1 — 2026-06-30
6
+
7
+ A patch release focused on **turned-part dimensioning legibility**: crowded
8
+ step-length chains and fine turned heads are now drawn legibly instead of crammed.
9
+ Drawing output changes for affected turned parts.
10
+
11
+ ### Added
12
+
13
+ - **Automatic enlarged detail view for a crowded turned head** (#304). A turned
14
+ part with a fine cluster of steps near one end and a long shaft (e.g. a thumbwheel
15
+ drive screw) cannot have its head dimensioned legibly in line at any sensible
16
+ scale. The head is now located as one block on the main view and broken out into
17
+ an enlarged **DETAIL A — SCALE n:1** — the textbook treatment — firing
18
+ automatically when a head's shoulders fall below the page legibility floor.
19
+
20
+ ### Changed
21
+
22
+ - **Crowded turned step-length chains stagger across two tiers** (#293) instead of
23
+ cramming or being skipped. When the labels would collide on one line, the ISO
24
+ 129-1 staggered convention alternates them between a near and a far tier so every
25
+ step length stays legible at the drawing's own scale — no rescale needed. A roomy
26
+ chain stays on a single tier.
27
+ - **Detail views are now one unified pipeline** (#307). The prismatic step-height
28
+ detail and the new turned-head detail flow through a single
29
+ detect → request → render path; several crowded regions become DETAIL A/B/…
30
+ - **Output changes** for turned parts whose step chains were previously crammed, or
31
+ whose fine heads are now broken out into a detail view.
32
+
33
+ ### Fixed
34
+
35
+ - **A coaxial bore callout no longer overlaps the round view's centreline** (#305):
36
+ its leader is angled off the centre axis so the callout text sits in clear space.
37
+
38
+ ### Internal
39
+
40
+ - A new **layout-cleanliness invariant test** asserts that finished drawings have no
41
+ view/annotation collisions across representative part archetypes, and the
42
+ measure-and-repack pass gained a trigger for an annotation growing into a
43
+ neighbouring view's line-work (so the views spread to make room).
44
+
45
+ ## v0.2.0 — 2026-06-30
46
+
47
+ A major release. draftwright took ownership of feature recognition and linting
48
+ (ADR 0007) and was re-architected onto a feature-IR + dimensioning-planner
49
+ "compiler" (ADR 0008), gaining a Typer CLI and a portable, pure-Python PDF path
50
+ along the way. **Generated drawings change** versus v0.1.13: the new pipeline
51
+ dimensions parts more completely and consistently, so placement and the set of
52
+ dimensions can differ — output is not byte-compatible with prior releases.
53
+
54
+ ### Changed
55
+
56
+ - **Re-architected onto a feature-IR + dimensioning planner** (ADR 0008). The
57
+ engine is now a compiler: detectors build one feature inventory → a typed IR /
58
+ `PartModel` → a dimensioning planner emits render-intents → shared
59
+ layout/projection/export. Orientation and feature-kind are *data in the IR*,
60
+ not code branches, and every feature class (holes, patterns, counterbores,
61
+ slots, turned diameters/steps, centre marks, location dims, envelope/OD,
62
+ section A–A, PMI/GD&T) was migrated onto this one path; the old parallel
63
+ recognisers and placement passes were deleted as each was replaced. Net effect
64
+ for users: more complete, more consistent drawings — but output differs from
65
+ v0.1.13.
66
+ - **draftwright owns feature recognition and linting** (ADR 0007). The hole/
67
+ boss/cylinder/pattern recognisers, the slot/turned-step recognisers, and the
68
+ feature-coverage lint engine are vendored into `recognition/` and `linting.py`;
69
+ `build123d-drafting-helpers` is now purely the rendering library.
70
+ - **The CLI writes a PDF by default** and takes a `--format` selector (#288).
71
+ Previously `draftwright part.step` emitted SVG + DXF; it now emits a single PDF.
72
+ Choose outputs with `--format` (a comma-list, with an `all` alias) —
73
+ `--format pdf,dxf`, `--format svg`, `--format all`. The library API is
74
+ unchanged: `make_drawing(...)` / `Drawing.export()` still write SVG + DXF.
75
+ - **PDF export is now pure-Python and a core capability** (#288). The renderer
76
+ moved from `cairosvg` (which `dlopen`s the native `libcairo` system library —
77
+ absent on stock macOS/Windows, so PDF-by-default would have crashed there) to
78
+ `svglib` + `reportlab`, both pip-installable wheels with no system dependency.
79
+ PDF therefore works out of the box on every platform; output is visually
80
+ identical to the cairo renderer.
81
+
82
+ ### Added
83
+
84
+ - **A Typer command-line interface** (#289/#291): shell completion
85
+ (`--install-completion` / `--show-completion`), rich `--help`, and `--version`
86
+ (reports the installed distribution version). All existing flags are preserved.
87
+ - **`--format` output selector** (#288) — `pdf` (default), `svg`, `dxf`, or `all`,
88
+ as a comma-list.
89
+ - **Turned-part dimensioning**: axial step-length recognition and chains
90
+ (#188/#189/#231), step-diameter callouts, collapse of a uniform step
91
+ staircase to an "N× length" note (#290), and OD of a horizontal (X/Y) round
92
+ body dimensioned on the profile view (#292).
93
+ - **Slot recognition and dimensioning** converged onto the IR as `SlotFeature`
94
+ (#242), and **section A–A** is now triggered by the planner (#271).
95
+ - **A Contributor License Agreement** (#183).
96
+
97
+ ### Removed
98
+
99
+ - **The `--pdf` flag** (use `--format pdf`, the new default), the **`[pdf]`
100
+ install extra**, and the **`cairosvg` dependency** (#288).
101
+ - The byte-exact golden-output test harness (#190) — regression coverage rests on
102
+ the geometry-level and standards suites (ADR 0005 §3 / ADR 0007).
103
+
104
+ ### Fixed
105
+
106
+ - Locate **every** side-drilled (off-axis) hole, not just the first (#225/#286).
107
+ - Don't mis-detect a prismatic part with incidental cylinders as a turned part
108
+ (#293/#294); drop phantom zero-diameter turned steps (#279/#284); skip an
109
+ illegibly-dense step-length chain rather than cram it (#293/#296).
110
+ - Degraded hole-pattern callout consistency (#262/#274).
111
+ - Windows `python -m draftwright.make_drawing` CLI smoke / entrypoint (#181/#182).
112
+
5
113
  ## v0.1.13 — 2026-06-27
6
114
 
7
115
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.1.13
3
+ Version: 0.2.1
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
@@ -684,8 +684,9 @@ Requires-Dist: build123d-drafting-helpers>=0.13.0
684
684
  Requires-Dist: build123d>=0.9.0
685
685
  Requires-Dist: kiwisolver<2,>=1.4
686
686
  Requires-Dist: numpy>=1.24
687
- Provides-Extra: pdf
688
- Requires-Dist: cairosvg>=2.7; extra == 'pdf'
687
+ Requires-Dist: reportlab>=4.0
688
+ Requires-Dist: svglib>=1.5
689
+ Requires-Dist: typer>=0.12
689
690
  Description-Content-Type: text/markdown
690
691
 
691
692
  # draftwright
@@ -714,6 +715,9 @@ Or from the command line:
714
715
 
715
716
  ```
716
717
  draftwright my_part.step --title "Mounting Block" --number DWG-001
718
+ # writes my_part.pdf (the default); add --format to choose:
719
+ draftwright my_part.step --format pdf,dxf # PDF + DXF
720
+ draftwright my_part.step --format all # PDF + SVG + DXF
717
721
  ```
718
722
 
719
723
  ## What it produces
@@ -808,7 +812,10 @@ generation, the lint→repair loop, and the constraint-based layout engine).
808
812
 
809
813
  ## Architecture
810
814
 
811
- draftwright is a single module (`make_drawing.py`) on top of two libraries:
815
+ draftwright is structured as a **part-drawing compiler** (ADR 0008): feature
816
+ detectors → a `PartModel` IR (`Feature`s exposing `DimParameter`s) → a dimensioning
817
+ planner → renderers, feeding a shared layout/projection/export stack. It builds on
818
+ two libraries:
812
819
 
813
820
  ```
814
821
  draftwright
@@ -816,7 +823,13 @@ draftwright
816
823
  └── build123d — CAD kernel
817
824
  ```
818
825
 
819
- The engine handles view layout (strip/zone model), scale selection, feature recognition
820
- (holes, bosses, bolt circles), annotation placement, and section view generation.
821
- Annotation primitives (`Dimension`, `Leader`, `lint_drawing`, etc.) live in
822
- `build123d-drafting-helpers` and can be used independently.
826
+ It owns feature recognition (`recognition/`) and linting (`linting/`); annotation
827
+ primitives (`Dimension`, `Leader`, etc.) live in `build123d-drafting-helpers` and can
828
+ be used independently. The compiler is largely converged in production — turned
829
+ dims/lengths, centre marks, envelope, slots, holes (callouts/locations/grouping),
830
+ the section A–A trigger, the prismatic step-ladder + rotational furniture, and PMI/GD&T
831
+ are all on the IR — the migration is complete (one path; the orchestrator is
832
+ build → plan → render). See
833
+ [`docs/target-architecture.md`](docs/target-architecture.md) and
834
+ [`docs/adr/`](docs/adr/). The engine handles view layout (strip/zone model), scale
835
+ selection, annotation placement, and section rendering.
@@ -24,6 +24,9 @@ Or from the command line:
24
24
 
25
25
  ```
26
26
  draftwright my_part.step --title "Mounting Block" --number DWG-001
27
+ # writes my_part.pdf (the default); add --format to choose:
28
+ draftwright my_part.step --format pdf,dxf # PDF + DXF
29
+ draftwright my_part.step --format all # PDF + SVG + DXF
27
30
  ```
28
31
 
29
32
  ## What it produces
@@ -118,7 +121,10 @@ generation, the lint→repair loop, and the constraint-based layout engine).
118
121
 
119
122
  ## Architecture
120
123
 
121
- draftwright is a single module (`make_drawing.py`) on top of two libraries:
124
+ draftwright is structured as a **part-drawing compiler** (ADR 0008): feature
125
+ detectors → a `PartModel` IR (`Feature`s exposing `DimParameter`s) → a dimensioning
126
+ planner → renderers, feeding a shared layout/projection/export stack. It builds on
127
+ two libraries:
122
128
 
123
129
  ```
124
130
  draftwright
@@ -126,7 +132,13 @@ draftwright
126
132
  └── build123d — CAD kernel
127
133
  ```
128
134
 
129
- The engine handles view layout (strip/zone model), scale selection, feature recognition
130
- (holes, bosses, bolt circles), annotation placement, and section view generation.
131
- Annotation primitives (`Dimension`, `Leader`, `lint_drawing`, etc.) live in
132
- `build123d-drafting-helpers` and can be used independently.
135
+ It owns feature recognition (`recognition/`) and linting (`linting/`); annotation
136
+ primitives (`Dimension`, `Leader`, etc.) live in `build123d-drafting-helpers` and can
137
+ be used independently. The compiler is largely converged in production — turned
138
+ dims/lengths, centre marks, envelope, slots, holes (callouts/locations/grouping),
139
+ the section A–A trigger, the prismatic step-ladder + rotational furniture, and PMI/GD&T
140
+ are all on the IR — the migration is complete (one path; the orchestrator is
141
+ build → plan → render). See
142
+ [`docs/target-architecture.md`](docs/target-architecture.md) and
143
+ [`docs/adr/`](docs/adr/). The engine handles view layout (strip/zone model), scale
144
+ selection, annotation placement, and section rendering.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.1.13"
7
+ version = "0.2.1"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -15,6 +15,9 @@ dependencies = [
15
15
  "build123d-drafting-helpers>=0.13.0",
16
16
  "kiwisolver>=1.4,<2",
17
17
  "numpy>=1.24",
18
+ "reportlab>=4.0",
19
+ "svglib>=1.5",
20
+ "typer>=0.12",
18
21
  ]
19
22
  keywords = ["build123d", "cad", "drafting", "engineering-drawing", "technical-drawing", "automation"]
20
23
  classifiers = [
@@ -29,11 +32,8 @@ classifiers = [
29
32
  "Programming Language :: Python :: 3.12",
30
33
  ]
31
34
 
32
- [project.optional-dependencies]
33
- pdf = ["cairosvg>=2.7"]
34
-
35
35
  [project.scripts]
36
- draftwright = "draftwright.make_drawing:_cli"
36
+ draftwright = "draftwright.cli:app"
37
37
 
38
38
  [project.urls]
39
39
  Homepage = "https://github.com/pzfreo/draftwright"
@@ -11,19 +11,41 @@ Requires build123d-drafting-helpers for annotation primitives.
11
11
  Licensed under the GNU Affero General Public License v3 (AGPL-3.0).
12
12
  """
13
13
 
14
+ import sys as _sys
15
+ import types as _types
16
+
14
17
  from draftwright.analysis import analyse_face_levels, dedup_diams
15
- from draftwright.make_drawing import (
16
- Drawing,
17
- FeatureInfo,
18
+ from draftwright.builder import (
18
19
  build_drawing,
19
- fix_svg_page_size,
20
20
  generate_script,
21
- lint_feature_coverage,
22
21
  make_drawing,
23
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
24
26
  from draftwright.pmi import PmiRecord, extract_pmi
25
27
  from draftwright.sheet import choose_scale
26
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
+
27
49
  __all__ = [
28
50
  "Drawing",
29
51
  "FeatureInfo",
@@ -13,9 +13,14 @@ from __future__ import annotations
13
13
  import functools
14
14
  import logging
15
15
  import re
16
+ from collections.abc import Callable
16
17
  from dataclasses import dataclass, field
17
18
  from pathlib import Path
18
19
  from types import SimpleNamespace
20
+ from typing import TYPE_CHECKING
21
+
22
+ if TYPE_CHECKING:
23
+ from draftwright.recognition import TurnedProfile
19
24
 
20
25
  from build123d import Align, BoundBox, Location, Mode, Shape, Text
21
26
  from build123d_drafting.helpers import (
@@ -36,6 +41,38 @@ _MARGIN = 10.0
36
41
 
37
42
  _TB_CLEAR = _MARGIN + 1.0 # title-block inset: one extra mm over _MARGIN for clearance
38
43
 
44
+ # The orthographic view a feature is dimensioned end-on in — the view normal to its
45
+ # axis. Single source of truth shared by the planner (view choice) and the lint
46
+ # coverage checks (where to look for a feature's dims). Orientation is data.
47
+ _END_ON = {"x": "side", "y": "front", "z": "plan"}
48
+
49
+
50
+ def _xyz(loc) -> tuple[float, float, float]:
51
+ """A build123d ``Vector`` (has ``.X/.Y/.Z``) or an ``(x, y, z)`` sequence → an
52
+ ``(x, y, z)`` float tuple. Shared by the detectors and the lint coverage checks
53
+ so the Vector-unpacking idiom lives in one place."""
54
+ if hasattr(loc, "X"):
55
+ return (loc.X, loc.Y, loc.Z)
56
+ x, y, z = loc
57
+ return (float(x), float(y), float(z))
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class HoleRef:
62
+ """A position-keyed reference to a hole — the IR-typed value the cover / hole-table
63
+ bookkeeping matches on, so the shared escalation never needs a recogniser ``Hole``
64
+ object (ADR 0008 Amendment 6). Built from any location via :meth:`of` (rounded, so
65
+ two references at the same position compare equal)."""
66
+
67
+ x: float
68
+ y: float
69
+ z: float
70
+
71
+ @classmethod
72
+ def of(cls, loc) -> HoleRef:
73
+ x, y, z = _xyz(loc)
74
+ return cls(round(x, 3), round(y, 3), round(z, 3))
75
+
39
76
 
40
77
  _FONT_SIZE = 3.0 # annotation text height (page-mm); the draft preset is built with this
41
78
 
@@ -277,6 +314,29 @@ def _legible_steps(step_zs, bb_min_z, scale):
277
314
  return kept, n_too_close
278
315
 
279
316
 
317
+ def _legible_locations(positions, scale):
318
+ """Axis positions far enough apart on the page to dimension legibly.
319
+
320
+ Given world-coordinate *positions* along one axis, keep a position only if it
321
+ is at least ``_MIN_LOC_SEP_MM`` page-mm from the previously kept one;
322
+ consecutive holes closer than that produce baseline witness lines that read
323
+ as a single busy cluster (#43). Returns ``(kept, n_too_close)``: the positions
324
+ to dimension and the count dropped for spacing (the caller surfaces these via
325
+ ``location_ref_dropped`` lint; the full-fidelity answer is a detail view, #42).
326
+ Mirrors :func:`_legible_steps` for hole locations.
327
+ """
328
+ kept: list[float] = []
329
+ n_too_close = 0
330
+ last = None
331
+ for p in sorted(positions):
332
+ if last is not None and (p - last) * scale < _MIN_LOC_SEP_MM:
333
+ n_too_close += 1
334
+ continue
335
+ kept.append(p)
336
+ last = p
337
+ return kept, n_too_close
338
+
339
+
280
340
  def _largest_empty_rect(drawable, obstacles):
281
341
  """Largest axis-aligned empty rectangle in *drawable* avoiding *obstacles*.
282
342
 
@@ -323,6 +383,48 @@ def _largest_empty_rect(drawable, obstacles):
323
383
  return best
324
384
 
325
385
 
386
+ @dataclass
387
+ class DetailRequest:
388
+ """A renderer's request for an enlarged detail of a region it could not draw
389
+ legibly at sheet scale (#307). Renderers append these to ``dwg._detail_requests``
390
+ instead of building bespoke detail views; ``_resolve_details`` resolves them all
391
+ through one generic detailer (crop → project → place → caption → marker), then
392
+ calls ``redraw`` to draw the feature's own dims inside the placed detail view.
393
+
394
+ The single ``detect → request → generic render`` path that folds the prismatic
395
+ step detail (#42) and the turned-head detail (#304) into one, mirroring the
396
+ section pipeline (``plan_sections``/``SectionPlan``).
397
+
398
+ Fields:
399
+ axis: part axis the band spans / is cropped along ("x"/"y"/"z").
400
+ lo, hi: band bounds along ``axis`` (world mm).
401
+ scale_needed: detail world→page scale that makes the region legible.
402
+ redraw: ``redraw(dwg, view_name, detail_scale) -> int`` — draws the
403
+ detail's dimensions in the placed detail view's coordinate system
404
+ and returns the count placed (0 → the detailer rolls the view
405
+ back rather than leave an empty box). Called once the detail is
406
+ placed; the main view always carries the located head/block
407
+ inline regardless, so a placement failure loses no coverage (lint
408
+ reports the un-located interior instead).
409
+ pad_top: page-mm band reserved above the detail view (a horizontal
410
+ chain); reserved in the fit + placement.
411
+ pads: optional ``pads(detail_scale) -> (pad_right, pad_top)`` for a
412
+ footprint that depends on the chosen scale (the prismatic
413
+ ladder reserves one rung per *legible-at-that-scale* step, so it
414
+ shrinks with the scale during the fit). Overrides ``pad_top``.
415
+ kind: short label for logging.
416
+ """
417
+
418
+ axis: str
419
+ lo: float
420
+ hi: float
421
+ scale_needed: float
422
+ redraw: Callable[..., int]
423
+ pad_top: float = 0.0
424
+ pads: Callable[[float], tuple[float, float]] | None = None
425
+ kind: str = "detail"
426
+
427
+
326
428
  @dataclass(frozen=True)
327
429
  class _Projector:
328
430
  """Model → page coordinate projection for the orthographic views.
@@ -401,12 +503,15 @@ class Analysis:
401
503
  bbox_max: float
402
504
  holes: list
403
505
  patterns: list
506
+ bosses: list # external bosses (find_bosses), detected once — the one inventory (#244)
404
507
  slots: list
405
508
  z_diams: list[float]
406
509
  cross_diams: list[float]
407
510
  cyls: tuple[list, list]
511
+ prof: TurnedProfile | None # turned step profile (find_turned_steps), detected once
408
512
  od_diam: float | None
409
513
  is_rotational: bool
514
+ od_axis: str # rotation/turning axis of a rotational part ("z" default; "x"/"y" #222)
410
515
  step_zs: list[float]
411
516
  sv_right: float
412
517
  iso_right_limit: float
@@ -14,17 +14,7 @@ import logging
14
14
  import math
15
15
 
16
16
  from build123d import Compound, Shape
17
- from build123d_drafting.features import (
18
- analyse_cylinders,
19
- find_hole_patterns,
20
- find_holes,
21
- full_cylinders,
22
- )
23
17
  from build123d_drafting.helpers import draft_preset
24
- from OCP.BRepAdaptor import BRepAdaptor_Surface
25
- from OCP.BRepGProp import BRepGProp
26
- from OCP.GeomAbs import GeomAbs_Plane
27
- from OCP.GProp import GProp_GProps
28
18
  from OCP.IFSelect import IFSelect_ReturnStatus
29
19
  from OCP.STEPControl import STEPControl_Reader
30
20
 
@@ -37,7 +27,16 @@ from draftwright._core import (
37
27
  _legible_steps,
38
28
  _Projector,
39
29
  )
40
- from draftwright.features import find_slots
30
+ from draftwright.recognition import (
31
+ analyse_cylinders,
32
+ analyse_face_levels,
33
+ find_bosses,
34
+ find_hole_patterns,
35
+ find_holes,
36
+ find_slots,
37
+ find_turned_steps,
38
+ full_cylinders,
39
+ )
41
40
  from draftwright.sheet import (
42
41
  _build_zones,
43
42
  _layout_geometry,
@@ -115,40 +114,6 @@ def _is_rotational(x_size, y_size, od_diam, od_axis_offset) -> bool:
115
114
  # --- lint scoring (see Drawing.lint_summary) -------------------------------
116
115
 
117
116
 
118
- def analyse_face_levels(part, tol: float = 0.5, min_area_frac: float = 0.0) -> list:
119
- """Return sorted unique Z-coords of horizontal (normal≈±Z) planar faces.
120
-
121
- Uses tol-bucket deduplication but returns the actual face Z, not the rounded
122
- bucket centre, so dimension labels match the true geometry.
123
-
124
- When *min_area_frac* > 0, a Z level is kept only if the total area of its
125
- horizontal faces is at least ``min_area_frac × (x_size × y_size)`` (the
126
- part's plan footprint). This drops sub-feature faces — e.g. fragments of
127
- engraved text/numbers — that are not real steps and would otherwise be
128
- dimensioned as phantom shoulders (staircase.step review).
129
- """
130
- buckets: dict = {} # bucket key -> representative z
131
- areas: dict = {} # bucket key -> total horizontal-face area
132
- for face in part.faces():
133
- surf = BRepAdaptor_Surface(face.wrapped)
134
- if surf.GetType() == GeomAbs_Plane:
135
- ax = surf.Plane().Axis().Direction()
136
- if abs(ax.Z()) > 0.99:
137
- z = surf.Plane().Location().Z()
138
- key = round(z / tol) * tol
139
- buckets.setdefault(key, z)
140
- if min_area_frac > 0.0:
141
- props = GProp_GProps()
142
- BRepGProp.SurfaceProperties_s(face.wrapped, props)
143
- areas[key] = areas.get(key, 0.0) + props.Mass()
144
- if min_area_frac > 0.0:
145
- bb = part.bounding_box()
146
- footprint = (bb.max.X - bb.min.X) * (bb.max.Y - bb.min.Y)
147
- threshold = min_area_frac * footprint
148
- return sorted(z for key, z in buckets.items() if areas.get(key, 0.0) >= threshold)
149
- return sorted(buckets.values())
150
-
151
-
152
117
  # ---------------------------------------------------------------------------
153
118
  # Strip / zone layout model
154
119
  # ---------------------------------------------------------------------------
@@ -288,11 +253,49 @@ def _analyse(
288
253
  math.hypot(od_cyl["axis_xyz"][0] - cx, od_cyl["axis_xyz"][1] - cy) if od_cyl else 0.0
289
254
  )
290
255
  is_rotational = _is_rotational(x_size, y_size, od_diam, od_axis_offset)
256
+ od_axis = "z"
257
+ if not is_rotational:
258
+ # Fallback: a *horizontal* (X/Y) round body — its OD is a cross-axis cylinder
259
+ # filling the square envelope perpendicular to that axis (#222). The Z check
260
+ # above is untouched, so vertical parts classify exactly as before; this only
261
+ # fires when Z-rotational fails and a cross-axis round body is present.
262
+ sizes = {"x": x_size, "y": y_size, "z": z_size}
263
+ ctr = {"x": cx, "y": cy, "z": cz}
264
+ cross_full = full_cylinders(cross_cyls)
265
+ for ax in ("x", "y"):
266
+ ext = [c for c in cross_full if c.get("axis") == ax and c["external"]]
267
+ # #222 targets a *single-OD* round body. A stepped shaft (multiple distinct
268
+ # cross diameters) stays on the turned-diameter path, not the OD furniture.
269
+ if len({round(c["diameter"], 1) for c in ext}) != 1:
270
+ continue
271
+ oc = max(ext, key=lambda c: c["diameter"], default=None)
272
+ if oc is None:
273
+ continue
274
+ p0, p1 = (a for a in "xyz" if a != ax)
275
+ off = math.hypot(
276
+ oc["axis_xyz"]["xyz".index(p0)] - ctr[p0],
277
+ oc["axis_xyz"]["xyz".index(p1)] - ctr[p1],
278
+ )
279
+ if _is_rotational(sizes[p0], sizes[p1], oc["diameter"], off):
280
+ od_diam, od_axis_offset, od_axis, is_rotational = oc["diameter"], off, ax, True
281
+ break
291
282
  if z_diams and not is_rotational:
292
283
  _log.info("Part classified prismatic; skipping OD/centreline/bore annotations")
293
284
 
294
- face_zs = analyse_face_levels(part, min_area_frac=_STEP_MIN_AREA_FRAC)
295
- step_zs = [z for z in face_zs if z > bb.min.Z + 0.6 and z < bb.max.Z - 0.6]
285
+ # Step Z-levels feed both the step-height ladder and the page-sizing step
286
+ # count. For a vertical (Z-axis) turned part, take them from the unified
287
+ # turned-step model (ADR 0008 step 1): it filters shoulders by the OD
288
+ # silhouette, so an internal feature face — a blind bore's flat floor — is
289
+ # never read as a phantom OD shoulder (the area-only filter in
290
+ # analyse_face_levels admitted it). Prismatic and other parts keep the
291
+ # general face-level scan, which find_turned_steps cannot replace (no
292
+ # cylinders → no profile).
293
+ _turned = find_turned_steps(part)
294
+ if _turned is not None and _turned.axis == "z":
295
+ step_zs = [z for z in _turned.shoulders if bb.min.Z + 0.6 < z < bb.max.Z - 0.6]
296
+ else:
297
+ face_zs = analyse_face_levels(part, min_area_frac=_STEP_MIN_AREA_FRAC)
298
+ step_zs = [z for z in face_zs if z > bb.min.Z + 0.6 and z < bb.max.Z - 0.6]
296
299
 
297
300
  # Pass 1 (two-pass layout, #131): measure annotation strip depths before
298
301
  # view positions are fixed. font_size=3.0 is a fixed page-mm constant so
@@ -305,6 +308,7 @@ def _analyse(
305
308
  _pad_around_text = _draft_est.pad_around_text
306
309
  holes = find_holes(part, cyls=(z_cyls, cross_cyls))
307
310
  patterns = find_hole_patterns(holes)
311
+ bosses = find_bosses(part, cyls=(z_cyls, cross_cyls)) # detect once — the one inventory (#264)
308
312
  slots = find_slots(part)
309
313
 
310
314
  # Choose scale/page, iterating so the reserved step corridor matches the
@@ -420,12 +424,15 @@ def _analyse(
420
424
  bbox_max=bbox_max,
421
425
  holes=holes,
422
426
  patterns=patterns,
427
+ bosses=bosses,
423
428
  slots=slots,
424
429
  z_diams=z_diams,
425
430
  cross_diams=cross_diams,
426
431
  cyls=(z_cyls, cross_cyls),
432
+ prof=_turned,
427
433
  od_diam=od_diam,
428
434
  is_rotational=is_rotational,
435
+ od_axis=od_axis,
429
436
  step_zs=step_zs,
430
437
  sv_right=sv_right,
431
438
  iso_right_limit=iso_right_limit,
@@ -6,8 +6,8 @@ the orchestrator entry point and the two helpers still referenced by name elsewh
6
6
  so `from draftwright.annotate import _auto_annotate` keeps working.
7
7
  """
8
8
 
9
+ from draftwright.annotations.from_model import _detect_step_repeat # noqa: F401
9
10
  from draftwright.annotations.orchestrator import ( # noqa: F401
10
11
  _auto_annotate,
11
- _detect_step_repeat,
12
12
  _wrap_rows,
13
13
  )
@@ -29,7 +29,7 @@ def _occupied_boxes(dwg):
29
29
  hatch. Bare centrelines/leaders are excluded — those legitimately cross a
30
30
  dimension and lint does not flag them."""
31
31
  boxes = []
32
- for name, o in dwg._named.items():
32
+ for name, o in dwg.iter_annotations():
33
33
  if getattr(o, "label_bbox", None) is None and name != "section_hatch":
34
34
  continue
35
35
  bb = _anno_box(o)