draftwright 0.2.3__tar.gz → 0.2.4__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 (84) hide show
  1. {draftwright-0.2.3 → draftwright-0.2.4}/CHANGELOG.md +31 -0
  2. {draftwright-0.2.3 → draftwright-0.2.4}/PKG-INFO +63 -49
  3. {draftwright-0.2.3 → draftwright-0.2.4}/README.md +62 -48
  4. {draftwright-0.2.3 → draftwright-0.2.4}/pyproject.toml +1 -1
  5. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/_core.py +18 -1
  6. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/annotations/_common.py +141 -2
  7. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/annotations/from_model.py +147 -65
  8. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/annotations/orchestrator.py +42 -22
  9. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/annotations/sections.py +7 -7
  10. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/builder.py +34 -9
  11. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/drawing.py +21 -6
  12. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/registry.py +14 -3
  13. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/repair.py +2 -2
  14. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/sheet.py +47 -0
  15. draftwright-0.2.4/tests/layout_snapshots/holed_slot.json +299 -0
  16. {draftwright-0.2.3 → draftwright-0.2.4}/tests/layout_snapshots/slotted.json +3 -3
  17. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_layout_cleanliness.py +13 -0
  18. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_layout_snapshot.py +18 -0
  19. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_make_drawing.py +79 -4
  20. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_pmi.py +19 -17
  21. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_registry.py +28 -0
  22. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_strip_layout.py +95 -0
  23. {draftwright-0.2.3 → draftwright-0.2.4}/.gitignore +0 -0
  24. {draftwright-0.2.3 → draftwright-0.2.4}/LICENSE +0 -0
  25. {draftwright-0.2.3 → draftwright-0.2.4}/skills/SKILL.md +0 -0
  26. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/__init__.py +0 -0
  27. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/analysis.py +0 -0
  28. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/annotate.py +0 -0
  29. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/annotations/__init__.py +0 -0
  30. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/annotations/holes.py +0 -0
  31. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/cli.py +0 -0
  32. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/export.py +0 -0
  33. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/features.py +0 -0
  34. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  35. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  36. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  37. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  38. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/fonts/__init__.py +0 -0
  39. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/layout.py +0 -0
  40. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/linting/__init__.py +0 -0
  41. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/linting/coverage.py +0 -0
  42. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/linting/issues.py +0 -0
  43. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/linting/structural.py +0 -0
  44. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/linting/suggest.py +0 -0
  45. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/make_drawing.py +0 -0
  46. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/model/__init__.py +0 -0
  47. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/model/detect.py +0 -0
  48. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/model/ir.py +0 -0
  49. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/model/planner.py +0 -0
  50. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/pmi.py +0 -0
  51. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/projection.py +0 -0
  52. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/recognition/__init__.py +0 -0
  53. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/recognition/_features.py +0 -0
  54. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/recognition/levels.py +0 -0
  55. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/recognition/slots.py +0 -0
  56. {draftwright-0.2.3 → draftwright-0.2.4}/src/draftwright/recognition/turned.py +0 -0
  57. {draftwright-0.2.3 → draftwright-0.2.4}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  58. {draftwright-0.2.3 → draftwright-0.2.4}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  59. {draftwright-0.2.3 → draftwright-0.2.4}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  60. {draftwright-0.2.3 → draftwright-0.2.4}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  61. {draftwright-0.2.3 → draftwright-0.2.4}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  62. {draftwright-0.2.3 → draftwright-0.2.4}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  63. {draftwright-0.2.3 → draftwright-0.2.4}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  64. {draftwright-0.2.3 → draftwright-0.2.4}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  65. {draftwright-0.2.3 → draftwright-0.2.4}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  66. {draftwright-0.2.3 → draftwright-0.2.4}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  67. {draftwright-0.2.3 → draftwright-0.2.4}/tests/layout_snapshots/box.json +0 -0
  68. {draftwright-0.2.3 → draftwright-0.2.4}/tests/layout_snapshots/bracket.json +0 -0
  69. {draftwright-0.2.3 → draftwright-0.2.4}/tests/layout_snapshots/drive_screw_x.json +0 -0
  70. {draftwright-0.2.3 → draftwright-0.2.4}/tests/layout_snapshots/dshape.json +0 -0
  71. {draftwright-0.2.3 → draftwright-0.2.4}/tests/layout_snapshots/flange.json +0 -0
  72. {draftwright-0.2.3 → draftwright-0.2.4}/tests/layout_snapshots/plate_holes.json +0 -0
  73. {draftwright-0.2.3 → draftwright-0.2.4}/tests/layout_snapshots/side_drilled.json +0 -0
  74. {draftwright-0.2.3 → draftwright-0.2.4}/tests/layout_snapshots/turned_shaft.json +0 -0
  75. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_e2e_slice.py +0 -0
  76. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_e2e_standards.py +0 -0
  77. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_layout.py +0 -0
  78. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_layout_property.py +0 -0
  79. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_lint_structural.py +0 -0
  80. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_linting.py +0 -0
  81. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_part_model.py +0 -0
  82. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_recognition.py +0 -0
  83. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_render_seam.py +0 -0
  84. {draftwright-0.2.3 → draftwright-0.2.4}/tests/test_turned_steps.py +0 -0
@@ -2,6 +2,37 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.2.4 — 2026-07-03
6
+
7
+ A follow-up patch on the ADR 0009 placement rebuild in 0.2.3: it finishes unifying
8
+ the shared "above-view" dimension corridor, adds a layout-overflow safety net, and
9
+ makes two more drop paths non-silent.
10
+
11
+ ### Changed
12
+
13
+ - **Plan-view X location dimensions, side-view Y location dimensions, and a
14
+ coincident slot-position dimension now share one collect-then-solve pass** (ADR
15
+ 0009 Amendment 6, #345/#346). Previously each pass carved the strip independently,
16
+ so a hole location and a slot position measuring the same datum span could both be
17
+ drawn, and the location ladder could come out non-monotonic. One solve now dedups
18
+ the coincident span (keeping the higher-priority location dimension) and orders the
19
+ whole ladder as segregated, monotonic runs — feature-size dimensions nearest the
20
+ view, datum locations nesting outward by distance.
21
+
22
+ ### Fixed
23
+
24
+ - **`choose_scale` never returns an overflowing layout** (#350). Scale selection
25
+ could pick a scale whose composed block layout exceeded the drawable area; it now
26
+ rejects any overflowing candidate.
27
+ - **A hole location and a coincident slot position are no longer drawn twice** (#345),
28
+ including at fractional datum distances where a display-value snap gap previously let
29
+ the duplicate escape deduplication.
30
+ - **The plan-view location ladder is monotonic** (#346) — running dimensions off a
31
+ shared datum stack outward in ascending order instead of interleaving.
32
+ - **A dropped balloon is non-silent** (#387). A balloon that cannot be placed now
33
+ reports the drop and clears its `callout_dropped` state precisely, instead of
34
+ vanishing with no on-sheet signal.
35
+
5
36
  ## v0.2.3 — 2026-07-03
6
37
 
7
38
  A large patch release: the **annotation-placement engine was rebuilt** as a
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.2.3
3
+ Version: 0.2.4
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
@@ -700,24 +700,70 @@ Description-Content-Type: text/markdown
700
700
  Automated technical-drawing generation for [build123d](https://github.com/gumyr/build123d).
701
701
  Point it at a solid (or a STEP file) and get a fully-annotated multi-view engineering
702
702
  drawing — orthographic views, dimensions, section A–A, ISO hatching, title block — ready
703
- to export as SVG and DXF.
703
+ to export as PDF, SVG, and DXF.
704
+
705
+ ![Example engineering drawing generated by draftwright](docs/images/example.png)
706
+
707
+ *A mounting plate, generated automatically: three dimensioned orthographic views, a
708
+ counterbored bore callout (`⌀18 THRU ⊔ ⌀30 ↓14`), a `4× ⌀8 THRU` hole-pattern callout,
709
+ Section A–A with ISO hatching, an isometric, and an ISO 7200 title block — every
710
+ annotation placed by the engine.*
711
+
712
+ ## Quick start
713
+
714
+ ```
715
+ pip install draftwright
716
+ ```
717
+
718
+ ### Command line
719
+
720
+ Point it at a STEP file — that's the whole workflow:
721
+
722
+ ```
723
+ draftwright my_part.step --title "Mounting Plate" --number DWG-001
724
+ # writes my_part.pdf (the default)
725
+ ```
726
+
727
+ Choose formats, scale, and page; or emit an editable drawing script:
728
+
729
+ ```
730
+ draftwright my_part.step --format pdf,dxf # also: svg, all
731
+ draftwright my_part.step --scale 2 --page A3 # override the auto scale / page
732
+ draftwright my_part.step --script # write an editable .py drawing script
733
+ ```
734
+
735
+ `draftwright --help` lists every flag; `--version` prints the version.
736
+
737
+ ### Python
738
+
739
+ One call turns a build123d solid (or a STEP file) into a drawing. This is the exact
740
+ part in the image above:
704
741
 
705
742
  ```python
706
743
  from build123d import Box, Cylinder, Pos
707
744
  from draftwright import make_drawing
708
745
 
709
- part = Box(80, 60, 20) - Pos(0, 0, 5) * Cylinder(8, 20)
710
- make_drawing(part, out="my_part", title="Mounting Block", number="DWG-001")
711
- # writes my_part.svg and my_part.dxf
746
+ part = (
747
+ Box(100, 70, 24)
748
+ - Pos(0, 0, 0) * Cylinder(9, 40) # central bore, counterbored below
749
+ - Pos(0, 0, 8) * Cylinder(15, 20) # counterbore → triggers Section A–A
750
+ - Pos(-38, 24, 0) * Cylinder(4, 40) # 4× corner holes (recognised as a pattern)
751
+ - Pos(38, 24, 0) * Cylinder(4, 40)
752
+ - Pos(-38, -24, 0) * Cylinder(4, 40)
753
+ - Pos(38, -24, 0) * Cylinder(4, 40)
754
+ )
755
+ make_drawing(part, out="my_part", title="Mounting Plate", number="DWG-001")
756
+ # writes my_part.svg and my_part.dxf (or from a STEP file: make_drawing(step_file="p.step", out="my_part"))
712
757
  ```
713
758
 
714
- Or from the command line:
759
+ For inspection and editing, get a composable `Drawing`:
715
760
 
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
761
+ ```python
762
+ from draftwright import build_drawing
763
+
764
+ dwg = build_drawing(part, title="Mounting Plate")
765
+ issues = dwg.lint() # list[LintIssue] coverage, page bounds, ISO
766
+ svg_path, dxf_path = dwg.export("my_part")
721
767
  ```
722
768
 
723
769
  ## What it produces
@@ -734,48 +780,14 @@ draftwright my_part.step --format all # PDF + SVG + DXF
734
780
  - **Lint** — `Drawing.lint()` checks annotation coverage, page bounds, and ISO compliance
735
781
  and returns structured `LintIssue` objects
736
782
 
737
- All output is real build123d geometry, so SVG and DXF export come from the same source
738
- and dimensions are live on the DXF layer.
739
-
740
- ## Installation
741
-
742
- ```
743
- pip install draftwright
744
- ```
783
+ All output is real build123d geometry, so PDF, SVG, and DXF all come from the same
784
+ source and dimensions are live on the DXF layer.
745
785
 
746
786
  Requires Python ≥ 3.10 and build123d ≥ 0.9.0. Annotation primitives are provided by
747
787
  [`build123d-drafting-helpers`](https://github.com/pzfreo/build123d-drafting-helpers),
748
- which is installed automatically as a dependency.
749
-
750
- ## Usage
788
+ installed automatically as a dependency.
751
789
 
752
- ### From a build123d solid
753
-
754
- ```python
755
- from draftwright import make_drawing, build_drawing, Drawing
756
-
757
- # One-shot: write SVG + DXF
758
- make_drawing(part, out="drawing", title="My Part", number="DWG-001")
759
-
760
- # Composable: get a Drawing object to inspect or extend
761
- dwg = build_drawing(part, title="My Part")
762
- issues = dwg.lint() # list[LintIssue]
763
- svg_path, dxf_path = dwg.export("drawing")
764
- ```
765
-
766
- ### From a STEP file
767
-
768
- ```python
769
- from draftwright import make_drawing
770
- make_drawing(step_file="part.step", out="drawing")
771
- ```
772
-
773
- Or via the CLI:
774
-
775
- ```
776
- draftwright part.step --out drawing --scale 2 --page A3
777
- draftwright part.step --script # write an editable .py drawing script instead
778
- ```
790
+ ## Going further
779
791
 
780
792
  ### Scale and page control
781
793
 
@@ -833,3 +845,5 @@ build → plan → render). See
833
845
  [`docs/target-architecture.md`](docs/target-architecture.md) and
834
846
  [`docs/adr/`](docs/adr/). The engine handles view layout (strip/zone model), scale
835
847
  selection, annotation placement, and section rendering.
848
+
849
+ The codebase has been entirely written by LLM (Claude Code) under detailed guidance from a person.
@@ -9,24 +9,70 @@
9
9
  Automated technical-drawing generation for [build123d](https://github.com/gumyr/build123d).
10
10
  Point it at a solid (or a STEP file) and get a fully-annotated multi-view engineering
11
11
  drawing — orthographic views, dimensions, section A–A, ISO hatching, title block — ready
12
- to export as SVG and DXF.
12
+ to export as PDF, SVG, and DXF.
13
+
14
+ ![Example engineering drawing generated by draftwright](docs/images/example.png)
15
+
16
+ *A mounting plate, generated automatically: three dimensioned orthographic views, a
17
+ counterbored bore callout (`⌀18 THRU ⊔ ⌀30 ↓14`), a `4× ⌀8 THRU` hole-pattern callout,
18
+ Section A–A with ISO hatching, an isometric, and an ISO 7200 title block — every
19
+ annotation placed by the engine.*
20
+
21
+ ## Quick start
22
+
23
+ ```
24
+ pip install draftwright
25
+ ```
26
+
27
+ ### Command line
28
+
29
+ Point it at a STEP file — that's the whole workflow:
30
+
31
+ ```
32
+ draftwright my_part.step --title "Mounting Plate" --number DWG-001
33
+ # writes my_part.pdf (the default)
34
+ ```
35
+
36
+ Choose formats, scale, and page; or emit an editable drawing script:
37
+
38
+ ```
39
+ draftwright my_part.step --format pdf,dxf # also: svg, all
40
+ draftwright my_part.step --scale 2 --page A3 # override the auto scale / page
41
+ draftwright my_part.step --script # write an editable .py drawing script
42
+ ```
43
+
44
+ `draftwright --help` lists every flag; `--version` prints the version.
45
+
46
+ ### Python
47
+
48
+ One call turns a build123d solid (or a STEP file) into a drawing. This is the exact
49
+ part in the image above:
13
50
 
14
51
  ```python
15
52
  from build123d import Box, Cylinder, Pos
16
53
  from draftwright import make_drawing
17
54
 
18
- part = Box(80, 60, 20) - Pos(0, 0, 5) * Cylinder(8, 20)
19
- make_drawing(part, out="my_part", title="Mounting Block", number="DWG-001")
20
- # writes my_part.svg and my_part.dxf
55
+ part = (
56
+ Box(100, 70, 24)
57
+ - Pos(0, 0, 0) * Cylinder(9, 40) # central bore, counterbored below
58
+ - Pos(0, 0, 8) * Cylinder(15, 20) # counterbore → triggers Section A–A
59
+ - Pos(-38, 24, 0) * Cylinder(4, 40) # 4× corner holes (recognised as a pattern)
60
+ - Pos(38, 24, 0) * Cylinder(4, 40)
61
+ - Pos(-38, -24, 0) * Cylinder(4, 40)
62
+ - Pos(38, -24, 0) * Cylinder(4, 40)
63
+ )
64
+ make_drawing(part, out="my_part", title="Mounting Plate", number="DWG-001")
65
+ # writes my_part.svg and my_part.dxf (or from a STEP file: make_drawing(step_file="p.step", out="my_part"))
21
66
  ```
22
67
 
23
- Or from the command line:
68
+ For inspection and editing, get a composable `Drawing`:
24
69
 
25
- ```
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
70
+ ```python
71
+ from draftwright import build_drawing
72
+
73
+ dwg = build_drawing(part, title="Mounting Plate")
74
+ issues = dwg.lint() # list[LintIssue] coverage, page bounds, ISO
75
+ svg_path, dxf_path = dwg.export("my_part")
30
76
  ```
31
77
 
32
78
  ## What it produces
@@ -43,48 +89,14 @@ draftwright my_part.step --format all # PDF + SVG + DXF
43
89
  - **Lint** — `Drawing.lint()` checks annotation coverage, page bounds, and ISO compliance
44
90
  and returns structured `LintIssue` objects
45
91
 
46
- All output is real build123d geometry, so SVG and DXF export come from the same source
47
- and dimensions are live on the DXF layer.
48
-
49
- ## Installation
50
-
51
- ```
52
- pip install draftwright
53
- ```
92
+ All output is real build123d geometry, so PDF, SVG, and DXF all come from the same
93
+ source and dimensions are live on the DXF layer.
54
94
 
55
95
  Requires Python ≥ 3.10 and build123d ≥ 0.9.0. Annotation primitives are provided by
56
96
  [`build123d-drafting-helpers`](https://github.com/pzfreo/build123d-drafting-helpers),
57
- which is installed automatically as a dependency.
58
-
59
- ## Usage
97
+ installed automatically as a dependency.
60
98
 
61
- ### From a build123d solid
62
-
63
- ```python
64
- from draftwright import make_drawing, build_drawing, Drawing
65
-
66
- # One-shot: write SVG + DXF
67
- make_drawing(part, out="drawing", title="My Part", number="DWG-001")
68
-
69
- # Composable: get a Drawing object to inspect or extend
70
- dwg = build_drawing(part, title="My Part")
71
- issues = dwg.lint() # list[LintIssue]
72
- svg_path, dxf_path = dwg.export("drawing")
73
- ```
74
-
75
- ### From a STEP file
76
-
77
- ```python
78
- from draftwright import make_drawing
79
- make_drawing(step_file="part.step", out="drawing")
80
- ```
81
-
82
- Or via the CLI:
83
-
84
- ```
85
- draftwright part.step --out drawing --scale 2 --page A3
86
- draftwright part.step --script # write an editable .py drawing script instead
87
- ```
99
+ ## Going further
88
100
 
89
101
  ### Scale and page control
90
102
 
@@ -142,3 +154,5 @@ build → plan → render). See
142
154
  [`docs/target-architecture.md`](docs/target-architecture.md) and
143
155
  [`docs/adr/`](docs/adr/). The engine handles view layout (strip/zone model), scale
144
156
  selection, annotation placement, and section rendering.
157
+
158
+ The codebase has been entirely written by LLM (Claude Code) under detailed guidance from a person.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.2.3"
7
+ version = "0.2.4"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -583,7 +583,11 @@ _PAGE_SIZES = {
583
583
  "A0": (1189.0, 841.0),
584
584
  }
585
585
 
586
- _SCALES = [10.0, 5.0, 2.0, 1.0, 0.5, 0.2]
586
+ # ISO 5455 scale series (1-2-5 decades). Enlargements + 1:1 first, then reductions
587
+ # down to 1:10000 so a very large part still gets a scale that FITS rather than an
588
+ # overflowing layout (#350). Ordered largest-scale-first for "least reduction first".
589
+ _SCALES = [10.0, 5.0, 2.0, 1.0]
590
+ _SCALES += [0.5, 0.2, 0.1, 0.05, 0.02, 0.01, 0.005, 0.002, 0.001, 0.0005, 0.0002, 0.0001]
587
591
 
588
592
  # Horizontal page budget to reserve for the isometric view during scale
589
593
  # selection and view placement, as a fraction of bbox_max * scale. This is a
@@ -634,6 +638,19 @@ _LADDER = [
634
638
  (0.2, 841.0, 594.0, 150.0), # A1 1:5
635
639
  (0.5, 1189.0, 841.0, 150.0), # A0 1:2
636
640
  (0.2, 1189.0, 841.0, 150.0), # A0 1:5
641
+ # Past 1:5 keep reducing on A0 (the largest sheet) through the rest of the ISO 5455
642
+ # series, so a part too big for A0 1:5 still gets a scale that FITS rather than an
643
+ # overflowing layout (#350). A0 1:10000 holds anything up to ~8.4 m of drawn height.
644
+ (0.1, 1189.0, 841.0, 150.0), # A0 1:10
645
+ (0.05, 1189.0, 841.0, 150.0), # A0 1:20
646
+ (0.02, 1189.0, 841.0, 150.0), # A0 1:50
647
+ (0.01, 1189.0, 841.0, 150.0), # A0 1:100
648
+ (0.005, 1189.0, 841.0, 150.0), # A0 1:200
649
+ (0.002, 1189.0, 841.0, 150.0), # A0 1:500
650
+ (0.001, 1189.0, 841.0, 150.0), # A0 1:1000
651
+ (0.0005, 1189.0, 841.0, 150.0), # A0 1:2000
652
+ (0.0002, 1189.0, 841.0, 150.0), # A0 1:5000
653
+ (0.0001, 1189.0, 841.0, 150.0), # A0 1:10000
637
654
  ]
638
655
 
639
656
 
@@ -269,6 +269,130 @@ def _segment_hits_box(p1, p2, box) -> bool:
269
269
  return any(_seg_seg(p1, p2, corners[i], corners[(i + 1) % 4]) for i in range(4))
270
270
 
271
271
 
272
+ @dataclass
273
+ class CorridorCandidate:
274
+ """One datum-referenced linear dim collected for a shared corridor's single solve
275
+ (ADR 0009 end state, #345/#346). Multiple render passes (`render_locations`,
276
+ `render_slots`) feed the SAME above-view strip; committing per-pass interleaves the
277
+ dims and cannot dedup coincident spans. Each pass instead registers a candidate here;
278
+ one :func:`solve_corridor` per strip dedups, orders, and places the whole set.
279
+
280
+ Attributes:
281
+ name/build: the ``(name, pos->Dimension)`` pair :func:`place_strip_candidates`
282
+ consumes — unchanged.
283
+ order: sort key placing the candidate in the corridor ladder. Location dims
284
+ key on datum distance (the monotonic ISO ladder); size dims form a separate
285
+ contiguous run so a slot length never lands mid-ladder (#346).
286
+ dedup: coincidence key ``(view, meas-origin, meas-endpoint)`` on the MEASURED
287
+ axis, or ``None`` to never dedup (size dims). Two candidates with equal keys are
288
+ the same physical dimension; the higher-``precedence`` one survives (#345).
289
+ precedence: dedup survivor rank — a hole *location* dim (feeds coverage/table
290
+ escalation) outranks a coincident slot *position* line.
291
+ on_place/on_drop: the pass's own post-placement bookkeeping — coverage
292
+ registration / drop lint + `Escalation`, or a slot's below-side fallthrough.
293
+ force: policy-B force-keep after the corridor-respecting pass (locations have
294
+ no alternate view); size/position slot dims fall through instead (``on_drop``).
295
+ """
296
+
297
+ name: str
298
+ build: object
299
+ order: tuple
300
+ on_place: object
301
+ on_drop: object
302
+ dedup: tuple | None = None
303
+ precedence: int = 0
304
+ force: bool = False
305
+
306
+
307
+ def solve_corridor(dwg, strip, view, axis, cands, tier):
308
+ """One collect-then-solve over every :class:`CorridorCandidate` a shared strip
309
+ accumulated across passes (ADR 0009 end state). Dedup → order → one non-force
310
+ :func:`place_strip_candidates` pass → a force pass for the force-eligible leftovers →
311
+ dispatch each candidate's ``on_place``/``on_drop``. This is what removes the duplicate
312
+ span (#345) and the interleaved ladder (#346) by construction: a single solve sees the
313
+ full set, so coincident spans collapse and the order is one monotonic chain."""
314
+ if not cands:
315
+ return
316
+ # Dedup: keep the highest-precedence candidate per coincidence key (tie-break on name,
317
+ # deterministic — ADR 0001). A displaced duplicate is a *loser*: while its winner is
318
+ # drawn it is silently dropped (never starved, so firing its pass's drop lint would be a
319
+ # false report) — but if the winner itself fails to place, the top loser is promoted so
320
+ # the measurement still gets its pass's fallthrough/drop handling (no silent vanish).
321
+ winners: dict = {}
322
+ for c in cands:
323
+ if c.dedup is None:
324
+ continue
325
+ prev = winners.get(c.dedup)
326
+ # Winner: highest precedence, ties broken by the lexicographically smaller name.
327
+ if (
328
+ prev is None
329
+ or c.precedence > prev.precedence
330
+ or (c.precedence == prev.precedence and c.name < prev.name)
331
+ ):
332
+ winners[c.dedup] = c
333
+ kept = [c for c in cands if c.dedup is None or winners.get(c.dedup) is c]
334
+ losers: dict = {} # dedup key → its displaced candidates (highest precedence first)
335
+ for c in cands:
336
+ if c.dedup is not None and winners.get(c.dedup) is not c:
337
+ losers.setdefault(c.dedup, []).append(c)
338
+ for group in losers.values():
339
+ group.sort(key=lambda c: (-c.precedence, c.name))
340
+ kept.sort(key=lambda c: c.order)
341
+
342
+ def _promote_losers(dropped_winner):
343
+ # The winner did not place → hand its measurement to the best surviving loser
344
+ # (e.g. the slot position's below-strip fallthrough), then stop.
345
+ for loser in losers.get(dropped_winner.dedup, ()):
346
+ loser.on_drop(loser.name)
347
+ break
348
+
349
+ if strip is None: # no such strip on this drawing — every candidate drops
350
+ for c in kept:
351
+ c.on_drop(c.name)
352
+ if c.dedup is not None:
353
+ _promote_losers(c)
354
+ return
355
+ pairs = [(c.name, c.build) for c in kept]
356
+ left = {n for n, _ in place_strip_candidates(dwg, strip, view, axis, pairs, tier)}
357
+ force_pairs = [(c.name, c.build) for c in kept if c.name in left and c.force]
358
+ still = (
359
+ {
360
+ n
361
+ for n, _ in place_strip_candidates(
362
+ dwg, strip, view, axis, force_pairs, tier, force=True
363
+ )
364
+ }
365
+ if force_pairs
366
+ else set()
367
+ )
368
+ for c in kept:
369
+ placed = c.name not in left or (c.force and c.name not in still)
370
+ if placed:
371
+ c.on_place(c.name) # placed in the corridor-respecting pass or the force pass
372
+ else:
373
+ c.on_drop(c.name) # dropped / not force-kept — the pass's drop handler runs
374
+ if c.dedup is not None: # a deduped winner failed → promote its top loser
375
+ _promote_losers(c)
376
+
377
+
378
+ def register_corridor(dwg, key, strip, view, axis, tier, cand):
379
+ """Queue a :class:`CorridorCandidate` under a shared corridor *key* so one
380
+ :func:`drain_corridors` places the whole cross-pass set together (ADR 0009 end state).
381
+ The first registration for a key fixes its ``(strip, view, axis, tier)``."""
382
+ b = dwg._corridor_batch.setdefault(
383
+ key, {"strip": strip, "view": view, "axis": axis, "tier": tier, "cands": []}
384
+ )
385
+ b["cands"].append(cand)
386
+
387
+
388
+ def drain_corridors(dwg):
389
+ """Solve every registered corridor (one :func:`solve_corridor` per strip), then clear
390
+ the batch. Called once, after all corridor-feeding passes have registered."""
391
+ for b in dwg._corridor_batch.values():
392
+ solve_corridor(dwg, b["strip"], b["view"], b["axis"], b["cands"], b["tier"])
393
+ dwg._corridor_batch = {}
394
+
395
+
272
396
  def place_strip_candidates(dwg, strip, view, axis, cands, tier, *, force=False):
273
397
  """Collect-then-solve placement of location/feature dims on one strip (ADR 0009).
274
398
  The single shared strip placer that retires the ``Strip.allocate`` cursor (#150,
@@ -372,8 +496,23 @@ def carve_free_position(dwg, strip, view, axis, tier, perp_span, *, outermost=Fa
372
496
  next — the height-ladder leapfrog chain, where each step dim's witness base is the
373
497
  previous dim's line — uses this. Same carve: outer-label tier reservation, the
374
498
  perpendicular-band filter (*perp_span* drops obstacles disjoint from this dim's own
375
- perpendicular extent), and innermost-first fill. No corridor check (a single-strip
376
- ladder has no alternate view to route to; obstacle tiers are still avoided)."""
499
+ perpendicular extent), and innermost-first fill.
500
+
501
+ **No corridor check, by construction — not just omission.** This avoids obstacle
502
+ *tiers* on the strip but does not reject a position whose witness *corridor* (feature
503
+ → dim line, across *perp_span*) crosses a leader/callout. Crucially, a single-position
504
+ return *cannot* fix a corridor crossing by choosing a different tier: every tier on
505
+ one side shares that corridor, and a farther tier's corridor is a **superset** of a
506
+ nearer one's, so the innermost free tier this already returns has the shortest
507
+ corridor and the fewest crossings — moving outward only adds crossings. Corridor
508
+ avoidance is therefore inherently a **relocation** problem (reject this position →
509
+ place on another view/side), which is :func:`place_strip_candidates`' job and out of
510
+ scope for a position return. Per caller: the height-ladder chain has no alternate
511
+ view (correct to omit); public ``Drawing.place_dim`` takes the view AND side from the
512
+ caller, so it cannot relocate; the PMI dim helpers already fall through sides
513
+ (``_try_above(...) or _try_below(...)``) and are where a corridor-reject would go if
514
+ ever wanted. Left as a documented known-limitation — the crossing is unobserved on
515
+ the corpus (the cleanliness ratchet would catch it)."""
377
516
  if strip is None:
378
517
  return None
379
518
  lo, hi, inner = strip_free_span(strip)