draftwright 0.1.13__tar.gz → 0.2.0__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 (80) hide show
  1. {draftwright-0.1.13 → draftwright-0.2.0}/CHANGELOG.md +68 -0
  2. {draftwright-0.1.13 → draftwright-0.2.0}/PKG-INFO +21 -8
  3. {draftwright-0.1.13 → draftwright-0.2.0}/README.md +17 -5
  4. {draftwright-0.1.13 → draftwright-0.2.0}/pyproject.toml +5 -5
  5. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/__init__.py +27 -5
  6. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/_core.py +62 -0
  7. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/analysis.py +54 -47
  8. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/annotate.py +1 -1
  9. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/annotations/_common.py +1 -1
  10. draftwright-0.2.0/src/draftwright/annotations/from_model.py +1296 -0
  11. draftwright-0.2.0/src/draftwright/annotations/holes.py +700 -0
  12. draftwright-0.2.0/src/draftwright/annotations/orchestrator.py +379 -0
  13. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/annotations/sections.py +12 -26
  14. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/builder.py +13 -88
  15. draftwright-0.2.0/src/draftwright/cli.py +172 -0
  16. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/drawing.py +132 -52
  17. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/export.py +31 -36
  18. draftwright-0.2.0/src/draftwright/features.py +23 -0
  19. draftwright-0.2.0/src/draftwright/linting/__init__.py +37 -0
  20. draftwright-0.2.0/src/draftwright/linting/coverage.py +394 -0
  21. draftwright-0.2.0/src/draftwright/linting/issues.py +21 -0
  22. draftwright-0.2.0/src/draftwright/linting/structural.py +635 -0
  23. draftwright-0.2.0/src/draftwright/linting/suggest.py +100 -0
  24. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/make_drawing.py +3 -0
  25. draftwright-0.2.0/src/draftwright/model/__init__.py +72 -0
  26. draftwright-0.2.0/src/draftwright/model/detect.py +301 -0
  27. draftwright-0.2.0/src/draftwright/model/ir.py +337 -0
  28. draftwright-0.2.0/src/draftwright/model/planner.py +259 -0
  29. draftwright-0.2.0/src/draftwright/recognition/__init__.py +54 -0
  30. draftwright-0.2.0/src/draftwright/recognition/_features.py +1038 -0
  31. draftwright-0.2.0/src/draftwright/recognition/levels.py +51 -0
  32. draftwright-0.1.13/src/draftwright/features.py → draftwright-0.2.0/src/draftwright/recognition/slots.py +5 -6
  33. draftwright-0.2.0/src/draftwright/recognition/turned.py +165 -0
  34. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/registry.py +21 -0
  35. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/repair.py +4 -7
  36. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/sheet.py +5 -6
  37. draftwright-0.2.0/tests/test_e2e_slice.py +72 -0
  38. draftwright-0.2.0/tests/test_lint_structural.py +419 -0
  39. {draftwright-0.1.13 → draftwright-0.2.0}/tests/test_linting.py +10 -6
  40. {draftwright-0.1.13 → draftwright-0.2.0}/tests/test_make_drawing.py +531 -91
  41. draftwright-0.2.0/tests/test_part_model.py +326 -0
  42. draftwright-0.2.0/tests/test_recognition.py +847 -0
  43. draftwright-0.2.0/tests/test_render_seam.py +63 -0
  44. draftwright-0.2.0/tests/test_turned_steps.py +87 -0
  45. draftwright-0.1.13/src/draftwright/annotations/holes.py +0 -1063
  46. draftwright-0.1.13/src/draftwright/annotations/orchestrator.py +0 -551
  47. draftwright-0.1.13/src/draftwright/annotations/pmi.py +0 -371
  48. draftwright-0.1.13/src/draftwright/annotations/turned.py +0 -196
  49. draftwright-0.1.13/src/draftwright/linting.py +0 -268
  50. draftwright-0.1.13/tests/golden/ctc01.json +0 -335
  51. draftwright-0.1.13/tests/golden/cylinder.json +0 -176
  52. draftwright-0.1.13/tests/golden/plate.json +0 -156
  53. draftwright-0.1.13/tests/golden/stepped.json +0 -148
  54. draftwright-0.1.13/tests/test_golden.py +0 -309
  55. {draftwright-0.1.13 → draftwright-0.2.0}/.gitignore +0 -0
  56. {draftwright-0.1.13 → draftwright-0.2.0}/LICENSE +0 -0
  57. {draftwright-0.1.13 → draftwright-0.2.0}/skills/SKILL.md +0 -0
  58. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/annotations/__init__.py +0 -0
  59. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  60. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  61. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  62. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  63. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/fonts/__init__.py +0 -0
  64. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/layout.py +0 -0
  65. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/pmi.py +0 -0
  66. {draftwright-0.1.13 → draftwright-0.2.0}/src/draftwright/projection.py +0 -0
  67. {draftwright-0.1.13 → draftwright-0.2.0}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  68. {draftwright-0.1.13 → draftwright-0.2.0}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  69. {draftwright-0.1.13 → draftwright-0.2.0}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  70. {draftwright-0.1.13 → draftwright-0.2.0}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  71. {draftwright-0.1.13 → draftwright-0.2.0}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  72. {draftwright-0.1.13 → draftwright-0.2.0}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  73. {draftwright-0.1.13 → draftwright-0.2.0}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  74. {draftwright-0.1.13 → draftwright-0.2.0}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  75. {draftwright-0.1.13 → draftwright-0.2.0}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  76. {draftwright-0.1.13 → draftwright-0.2.0}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  77. {draftwright-0.1.13 → draftwright-0.2.0}/tests/test_e2e_standards.py +0 -0
  78. {draftwright-0.1.13 → draftwright-0.2.0}/tests/test_layout.py +0 -0
  79. {draftwright-0.1.13 → draftwright-0.2.0}/tests/test_pmi.py +0 -0
  80. {draftwright-0.1.13 → draftwright-0.2.0}/tests/test_registry.py +0 -0
@@ -2,6 +2,74 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.2.0 — 2026-06-30
6
+
7
+ A major release. draftwright took ownership of feature recognition and linting
8
+ (ADR 0007) and was re-architected onto a feature-IR + dimensioning-planner
9
+ "compiler" (ADR 0008), gaining a Typer CLI and a portable, pure-Python PDF path
10
+ along the way. **Generated drawings change** versus v0.1.13: the new pipeline
11
+ dimensions parts more completely and consistently, so placement and the set of
12
+ dimensions can differ — output is not byte-compatible with prior releases.
13
+
14
+ ### Changed
15
+
16
+ - **Re-architected onto a feature-IR + dimensioning planner** (ADR 0008). The
17
+ engine is now a compiler: detectors build one feature inventory → a typed IR /
18
+ `PartModel` → a dimensioning planner emits render-intents → shared
19
+ layout/projection/export. Orientation and feature-kind are *data in the IR*,
20
+ not code branches, and every feature class (holes, patterns, counterbores,
21
+ slots, turned diameters/steps, centre marks, location dims, envelope/OD,
22
+ section A–A, PMI/GD&T) was migrated onto this one path; the old parallel
23
+ recognisers and placement passes were deleted as each was replaced. Net effect
24
+ for users: more complete, more consistent drawings — but output differs from
25
+ v0.1.13.
26
+ - **draftwright owns feature recognition and linting** (ADR 0007). The hole/
27
+ boss/cylinder/pattern recognisers, the slot/turned-step recognisers, and the
28
+ feature-coverage lint engine are vendored into `recognition/` and `linting.py`;
29
+ `build123d-drafting-helpers` is now purely the rendering library.
30
+ - **The CLI writes a PDF by default** and takes a `--format` selector (#288).
31
+ Previously `draftwright part.step` emitted SVG + DXF; it now emits a single PDF.
32
+ Choose outputs with `--format` (a comma-list, with an `all` alias) —
33
+ `--format pdf,dxf`, `--format svg`, `--format all`. The library API is
34
+ unchanged: `make_drawing(...)` / `Drawing.export()` still write SVG + DXF.
35
+ - **PDF export is now pure-Python and a core capability** (#288). The renderer
36
+ moved from `cairosvg` (which `dlopen`s the native `libcairo` system library —
37
+ absent on stock macOS/Windows, so PDF-by-default would have crashed there) to
38
+ `svglib` + `reportlab`, both pip-installable wheels with no system dependency.
39
+ PDF therefore works out of the box on every platform; output is visually
40
+ identical to the cairo renderer.
41
+
42
+ ### Added
43
+
44
+ - **A Typer command-line interface** (#289/#291): shell completion
45
+ (`--install-completion` / `--show-completion`), rich `--help`, and `--version`
46
+ (reports the installed distribution version). All existing flags are preserved.
47
+ - **`--format` output selector** (#288) — `pdf` (default), `svg`, `dxf`, or `all`,
48
+ as a comma-list.
49
+ - **Turned-part dimensioning**: axial step-length recognition and chains
50
+ (#188/#189/#231), step-diameter callouts, collapse of a uniform step
51
+ staircase to an "N× length" note (#290), and OD of a horizontal (X/Y) round
52
+ body dimensioned on the profile view (#292).
53
+ - **Slot recognition and dimensioning** converged onto the IR as `SlotFeature`
54
+ (#242), and **section A–A** is now triggered by the planner (#271).
55
+ - **A Contributor License Agreement** (#183).
56
+
57
+ ### Removed
58
+
59
+ - **The `--pdf` flag** (use `--format pdf`, the new default), the **`[pdf]`
60
+ install extra**, and the **`cairosvg` dependency** (#288).
61
+ - The byte-exact golden-output test harness (#190) — regression coverage rests on
62
+ the geometry-level and standards suites (ADR 0005 §3 / ADR 0007).
63
+
64
+ ### Fixed
65
+
66
+ - Locate **every** side-drilled (off-axis) hole, not just the first (#225/#286).
67
+ - Don't mis-detect a prismatic part with incidental cylinders as a turned part
68
+ (#293/#294); drop phantom zero-diameter turned steps (#279/#284); skip an
69
+ illegibly-dense step-length chain rather than cram it (#293/#296).
70
+ - Degraded hole-pattern callout consistency (#262/#274).
71
+ - Windows `python -m draftwright.make_drawing` CLI smoke / entrypoint (#181/#182).
72
+
5
73
  ## v0.1.13 — 2026-06-27
6
74
 
7
75
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.1.13
3
+ Version: 0.2.0
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.0"
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",
@@ -16,6 +16,10 @@ import re
16
16
  from dataclasses import dataclass, field
17
17
  from pathlib import Path
18
18
  from types import SimpleNamespace
19
+ from typing import TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ from draftwright.recognition import TurnedProfile
19
23
 
20
24
  from build123d import Align, BoundBox, Location, Mode, Shape, Text
21
25
  from build123d_drafting.helpers import (
@@ -36,6 +40,38 @@ _MARGIN = 10.0
36
40
 
37
41
  _TB_CLEAR = _MARGIN + 1.0 # title-block inset: one extra mm over _MARGIN for clearance
38
42
 
43
+ # The orthographic view a feature is dimensioned end-on in — the view normal to its
44
+ # axis. Single source of truth shared by the planner (view choice) and the lint
45
+ # coverage checks (where to look for a feature's dims). Orientation is data.
46
+ _END_ON = {"x": "side", "y": "front", "z": "plan"}
47
+
48
+
49
+ def _xyz(loc) -> tuple[float, float, float]:
50
+ """A build123d ``Vector`` (has ``.X/.Y/.Z``) or an ``(x, y, z)`` sequence → an
51
+ ``(x, y, z)`` float tuple. Shared by the detectors and the lint coverage checks
52
+ so the Vector-unpacking idiom lives in one place."""
53
+ if hasattr(loc, "X"):
54
+ return (loc.X, loc.Y, loc.Z)
55
+ x, y, z = loc
56
+ return (float(x), float(y), float(z))
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class HoleRef:
61
+ """A position-keyed reference to a hole — the IR-typed value the cover / hole-table
62
+ bookkeeping matches on, so the shared escalation never needs a recogniser ``Hole``
63
+ object (ADR 0008 Amendment 6). Built from any location via :meth:`of` (rounded, so
64
+ two references at the same position compare equal)."""
65
+
66
+ x: float
67
+ y: float
68
+ z: float
69
+
70
+ @classmethod
71
+ def of(cls, loc) -> HoleRef:
72
+ x, y, z = _xyz(loc)
73
+ return cls(round(x, 3), round(y, 3), round(z, 3))
74
+
39
75
 
40
76
  _FONT_SIZE = 3.0 # annotation text height (page-mm); the draft preset is built with this
41
77
 
@@ -277,6 +313,29 @@ def _legible_steps(step_zs, bb_min_z, scale):
277
313
  return kept, n_too_close
278
314
 
279
315
 
316
+ def _legible_locations(positions, scale):
317
+ """Axis positions far enough apart on the page to dimension legibly.
318
+
319
+ Given world-coordinate *positions* along one axis, keep a position only if it
320
+ is at least ``_MIN_LOC_SEP_MM`` page-mm from the previously kept one;
321
+ consecutive holes closer than that produce baseline witness lines that read
322
+ as a single busy cluster (#43). Returns ``(kept, n_too_close)``: the positions
323
+ to dimension and the count dropped for spacing (the caller surfaces these via
324
+ ``location_ref_dropped`` lint; the full-fidelity answer is a detail view, #42).
325
+ Mirrors :func:`_legible_steps` for hole locations.
326
+ """
327
+ kept: list[float] = []
328
+ n_too_close = 0
329
+ last = None
330
+ for p in sorted(positions):
331
+ if last is not None and (p - last) * scale < _MIN_LOC_SEP_MM:
332
+ n_too_close += 1
333
+ continue
334
+ kept.append(p)
335
+ last = p
336
+ return kept, n_too_close
337
+
338
+
280
339
  def _largest_empty_rect(drawable, obstacles):
281
340
  """Largest axis-aligned empty rectangle in *drawable* avoiding *obstacles*.
282
341
 
@@ -401,12 +460,15 @@ class Analysis:
401
460
  bbox_max: float
402
461
  holes: list
403
462
  patterns: list
463
+ bosses: list # external bosses (find_bosses), detected once — the one inventory (#244)
404
464
  slots: list
405
465
  z_diams: list[float]
406
466
  cross_diams: list[float]
407
467
  cyls: tuple[list, list]
468
+ prof: TurnedProfile | None # turned step profile (find_turned_steps), detected once
408
469
  od_diam: float | None
409
470
  is_rotational: bool
471
+ od_axis: str # rotation/turning axis of a rotational part ("z" default; "x"/"y" #222)
410
472
  step_zs: list[float]
411
473
  sv_right: float
412
474
  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)