draftwright 0.2.8__tar.gz → 0.2.10__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 (102) hide show
  1. {draftwright-0.2.8 → draftwright-0.2.10}/CHANGELOG.md +82 -0
  2. {draftwright-0.2.8 → draftwright-0.2.10}/PKG-INFO +32 -2
  3. {draftwright-0.2.8 → draftwright-0.2.10}/README.md +31 -0
  4. {draftwright-0.2.8 → draftwright-0.2.10}/pyproject.toml +1 -2
  5. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/_core.py +31 -3
  6. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/analysis.py +34 -12
  7. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/annotate.py +1 -0
  8. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/annotations/_common.py +103 -13
  9. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/annotations/from_model.py +223 -2
  10. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/annotations/holes.py +59 -20
  11. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/annotations/orchestrator.py +22 -0
  12. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/builder.py +30 -2
  13. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/cli.py +20 -21
  14. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/drawing.py +7 -0
  15. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/layout.py +27 -63
  16. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/linting/__init__.py +2 -0
  17. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/linting/coverage.py +69 -0
  18. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/model/__init__.py +18 -2
  19. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/model/declare.py +218 -27
  20. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/model/detect.py +31 -19
  21. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/model/ir.py +91 -0
  22. draftwright-0.2.10/src/draftwright/sheet_dsl.py +658 -0
  23. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/sheet_emit.py +74 -8
  24. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_declare.py +81 -0
  25. draftwright-0.2.10/tests/test_gdt_placement.py +230 -0
  26. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_layout.py +5 -6
  27. draftwright-0.2.10/tests/test_lint_reconciliation.py +135 -0
  28. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_make_drawing.py +52 -15
  29. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_pmi.py +40 -0
  30. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_sheet_emit.py +222 -12
  31. draftwright-0.2.10/tests/test_sheet_gdt.py +188 -0
  32. draftwright-0.2.10/tests/test_sheet_notes.py +131 -0
  33. draftwright-0.2.10/tests/test_sheet_tables.py +124 -0
  34. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_strip_layout.py +125 -0
  35. draftwright-0.2.8/src/draftwright/sheet_dsl.py +0 -328
  36. {draftwright-0.2.8 → draftwright-0.2.10}/.gitignore +0 -0
  37. {draftwright-0.2.8 → draftwright-0.2.10}/LICENSE +0 -0
  38. {draftwright-0.2.8 → draftwright-0.2.10}/skills/SKILL.md +0 -0
  39. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/__init__.py +0 -0
  40. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/annotations/__init__.py +0 -0
  41. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/annotations/sections.py +0 -0
  42. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/export.py +0 -0
  43. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/features.py +0 -0
  44. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/fits.py +0 -0
  45. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  46. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  47. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  48. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  49. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/fonts/__init__.py +0 -0
  50. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/intents.py +0 -0
  51. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/linting/issues.py +0 -0
  52. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/linting/structural.py +0 -0
  53. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/linting/suggest.py +0 -0
  54. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/make_drawing.py +0 -0
  55. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/model/planner.py +0 -0
  56. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/pmi.py +0 -0
  57. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/projection.py +0 -0
  58. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/recognition/__init__.py +0 -0
  59. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/recognition/_features.py +0 -0
  60. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/recognition/levels.py +0 -0
  61. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/recognition/slots.py +0 -0
  62. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/recognition/turned.py +0 -0
  63. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/registry.py +0 -0
  64. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/repair.py +0 -0
  65. {draftwright-0.2.8 → draftwright-0.2.10}/src/draftwright/sheet.py +0 -0
  66. {draftwright-0.2.8 → draftwright-0.2.10}/tests/_kernel.py +0 -0
  67. {draftwright-0.2.8 → draftwright-0.2.10}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  68. {draftwright-0.2.8 → draftwright-0.2.10}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  69. {draftwright-0.2.8 → draftwright-0.2.10}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  70. {draftwright-0.2.8 → draftwright-0.2.10}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  71. {draftwright-0.2.8 → draftwright-0.2.10}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  72. {draftwright-0.2.8 → draftwright-0.2.10}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  73. {draftwright-0.2.8 → draftwright-0.2.10}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  74. {draftwright-0.2.8 → draftwright-0.2.10}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  75. {draftwright-0.2.8 → draftwright-0.2.10}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  76. {draftwright-0.2.8 → draftwright-0.2.10}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  77. {draftwright-0.2.8 → draftwright-0.2.10}/tests/layout_snapshots/box.json +0 -0
  78. {draftwright-0.2.8 → draftwright-0.2.10}/tests/layout_snapshots/bracket.json +0 -0
  79. {draftwright-0.2.8 → draftwright-0.2.10}/tests/layout_snapshots/drive_screw_x.json +0 -0
  80. {draftwright-0.2.8 → draftwright-0.2.10}/tests/layout_snapshots/dshape.json +0 -0
  81. {draftwright-0.2.8 → draftwright-0.2.10}/tests/layout_snapshots/flange.json +0 -0
  82. {draftwright-0.2.8 → draftwright-0.2.10}/tests/layout_snapshots/holed_slot.json +0 -0
  83. {draftwright-0.2.8 → draftwright-0.2.10}/tests/layout_snapshots/plate_holes.json +0 -0
  84. {draftwright-0.2.8 → draftwright-0.2.10}/tests/layout_snapshots/side_drilled.json +0 -0
  85. {draftwright-0.2.8 → draftwright-0.2.10}/tests/layout_snapshots/slotted.json +0 -0
  86. {draftwright-0.2.8 → draftwright-0.2.10}/tests/layout_snapshots/turned_shaft.json +0 -0
  87. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_e2e_slice.py +0 -0
  88. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_e2e_standards.py +0 -0
  89. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_fits.py +0 -0
  90. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_layout_cleanliness.py +0 -0
  91. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_layout_property.py +0 -0
  92. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_layout_snapshot.py +0 -0
  93. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_lint_structural.py +0 -0
  94. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_linting.py +0 -0
  95. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_object_aspects.py +0 -0
  96. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_part_model.py +0 -0
  97. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_recognition.py +0 -0
  98. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_registry.py +0 -0
  99. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_render_seam.py +0 -0
  100. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_sheet_of.py +0 -0
  101. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_tolerances.py +0 -0
  102. {draftwright-0.2.8 → draftwright-0.2.10}/tests/test_turned_steps.py +0 -0
@@ -1,5 +1,87 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.10 — 2026-07-07
4
+
5
+ **Declarative-surface fidelity and layout-engine unification.** Editing and re-running a
6
+ declarative `Sheet` script now reproduces more of the drawing (title block, layout, PMI on the
7
+ declared path), a new lint catches stale declarations, and an intentional scale is respected.
8
+ Internally the annotation placer moves further onto the single collect-then-solve (ADR 0009),
9
+ and `kiwisolver` is dropped as a dependency.
10
+
11
+ ### Added
12
+
13
+ - **Declaration-vs-geometry reconciliation lint** — `lint()` now flags a declared cylindrical
14
+ feature (hole/boss/step) with **no matching geometry** in the part (`declared_feature_absent`):
15
+ a callout drawn over empty space because the part was edited but the declaration went stale.
16
+ Closes the last gap in the "did my edit break the drawing?" loop (over-declaration; coverage
17
+ lint already caught under-declaration). Matches on axis + ⌀ + in-plane position + bore/boss
18
+ polarity. Gated on a caller-supplied `model=` (detection can't over-declare). (#487)
19
+ - **Title-block + layout aspects on the `Sheet` DSL** — `Sheet(drawn_by=…, tolerance=…, scale=…,
20
+ page=…)`; the generated `--script` reproduces them, and the CLI forwards
21
+ `--drawn-by`/`--tolerance`/`--scale`/`--page` on the sheet path (no more inert-flag warning). (#474)
22
+ - **PMI reproduced on the declared-model path** — `build_drawing(path, model=…, pmi="annotate")`
23
+ synthesises the STEP AP242 PMI into the declared model, so a declared build draws the same PMI
24
+ dimensions as the detection path. (#472)
25
+
26
+ ### Changed
27
+
28
+ - **An intentional explicit `scale=` below the legibility floor is honoured with a warning**
29
+ rather than rejected. A part deliberately drawn at 1:1 (or `Sheet(scale="1:10")`) whose smallest
30
+ feature falls under the 10 mm legibility floor now renders (annotations may crowd) instead of
31
+ raising `ValueError`; only a genuinely degenerate scale (< 0.1 mm projected, where OCCT arcs
32
+ collapse) is rejected. The floor now binds only the *automatic* scale. (#489)
33
+ - **`kiwisolver` is no longer a dependency.** The 1-D strip solve delegates to the built-in
34
+ deterministic minimum-total-leader-length PAVA algorithm, which supersedes the Cassowary
35
+ solver — same placement contract, one fewer third-party dependency. (#507)
36
+
37
+ ### Fixed
38
+
39
+ - **Dimension placement is more robust under contention.** Candidate **priority** is plumbed
40
+ through the shared corridor solve, so an authored GD&T frame is kept over a lower-value
41
+ automatic dimension when a strip is over capacity (#357). Rotational concentric-bore leaders
42
+ are now bounded to the front-view height with ranked drops, and pitch dimensions are placed
43
+ onto the obstacle-aware per-side zone-strip solve — retiring the last fixed-offset,
44
+ count-based stacking placers (the shape behind earlier dense-sheet overruns). (#374)
45
+
46
+ ## v0.2.9 — 2026-07-06
47
+
48
+ **Declarative GD&T, datums, and surface finish (ADR 0011 Phase 2b/2c).** The `Sheet` DSL can
49
+ now author the drawing information geometry can't carry — geometric tolerances, datum feature
50
+ symbols, and surface-finish marks — by pointing at a build123d feature or face. They render
51
+ through the auto-layout engine as first-class ADR 0009 corridor candidates, placed and spaced
52
+ crossing-free alongside the automatic dimensions (not a post-hoc overlay).
53
+
54
+ ```python
55
+ sheet.datum("A", base_face)
56
+ sheet.control(bore).position(0.1, to="A", diameter=True).perpendicularity(0.05, to="A")
57
+ sheet.diameter(journal).finish("0.8")
58
+ ```
59
+
60
+ ### Added
61
+
62
+ - **Feature control frames (ISO 1101)** — `sheet.control(ref)` returns a chainable builder with
63
+ one method per **all 14** characteristics (`position`, `flatness`, `perpendicularity`,
64
+ `cylindricity`, `circular_runout`, `total_runout`, …); `to=` names the referenced datum(s),
65
+ `diameter=` prefixes the ⌀ tolerance zone, `modifier=` adds a material-condition symbol (Ⓜ/Ⓛ/Ⓟ).
66
+ - **Datum feature symbols (ISO 5459)** — `sheet.datum("A", face_or_feature)`.
67
+ - **Surface finish (ISO 1302, Ra)** — `.finish("1.6")` on a hole / diameter / step handle, or
68
+ `sheet.finish(ra, face)`.
69
+ - The target view + strip side are **derived from the referenced geometry** (a feature's axis →
70
+ face-on view, a planar face's normal → edge-on view); `view=`/`side=` override.
71
+ - Render core: `ControlFrame` / `DatumRef` / `Finish` IR features placed by `render_gdt` as
72
+ first-class corridor candidates, with the real-footprint plumbing needed to space wide frames.
73
+
74
+ ### Fixed
75
+
76
+ - A control frame referencing an **undeclared datum letter** now warns at build.
77
+ - GD&T placement is **title-block aware** and **falls through to the opposite strip side** before
78
+ dropping a frame, so stacked control frames place robustly rather than vanishing or overlapping
79
+ the title block.
80
+ - An invalid glyph spec / degenerate leader in a caller-supplied model **drops the one item with a
81
+ warning** instead of crashing the whole build (the IR is public input).
82
+ - Turned-shaft rotational furniture now reproduces correctly through the declared-model path
83
+ (`model=` / `Sheet`) (#476).
84
+
3
85
  ## v0.2.8 — 2026-07-06
4
86
 
5
87
  **`--script` now defaults to the declarative `Sheet` DSL.** The editable script the CLI
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.2.8
3
+ Version: 0.2.10
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
@@ -685,7 +685,6 @@ Requires-Python: <3.15,>=3.10
685
685
  Requires-Dist: build123d-drafting-helpers>=0.13.0
686
686
  Requires-Dist: build123d<0.11,>=0.9.0; python_full_version < '3.13'
687
687
  Requires-Dist: build123d<0.12,>=0.11; python_full_version >= '3.13'
688
- Requires-Dist: kiwisolver<2,>=1.4
689
688
  Requires-Dist: numpy>=1.24
690
689
  Requires-Dist: reportlab>=4.0
691
690
  Requires-Dist: svglib>=1.5
@@ -769,6 +768,37 @@ issues = dwg.lint() # list[LintIssue] — coverage, page b
769
768
  svg_path, dxf_path = dwg.export("my_part")
770
769
  ```
771
770
 
771
+ ### Declarative drawings — reference features, declare intent
772
+
773
+ Instead of relying on detection, **reference the build123d objects you built** and declare
774
+ only the drawing intent — geometry supplies the sizes (⌀ read off the object), you supply
775
+ tolerances, fits, datums, GD&T, and surface finish. The fluent `Sheet` skips detection and
776
+ dimensions exactly what you declare (ADR 0011):
777
+
778
+ ```python
779
+ from build123d import Box, Cylinder, Pos
780
+ from draftwright import Sheet
781
+
782
+ plate = Box(120, 80, 20)
783
+ bore = Pos(0, 0, 0) * Cylinder(4, 20)
784
+
785
+ sheet = Sheet(plate - bore, title="Plate", number="DWG-002")
786
+ sheet.envelope()
787
+ sheet.datum("A", plate.faces().sort_by()[-1]) # datum A on the top face
788
+ hole = sheet.hole(bore).finish("1.6").note("M3x0.5 TAP") # ⌀8 bore, Ra 1.6, tapped
789
+ sheet.control(hole).position(0.1, to="A", diameter=True) # ⌀0.1 position wrt A
790
+ sheet.export("plate") # writes plate.svg + plate.dxf
791
+ ```
792
+
793
+ Every aspect the geometry can't carry is a declared verb: `.tolerance(±)` / `.fit("H7")`
794
+ (ISO 286), `.finish("Ra")` (ISO 1302), `sheet.datum(letter, ref)` (ISO 5459), and
795
+ `sheet.control(ref)` with all 14 ISO 1101 characteristics
796
+ (`.position`/`.flatness`/`.perpendicularity`/`.circular_runout`/…), plus `sheet.note(text, ref)`
797
+ / `.note(text)` for free-text shop callouts (thread specs, `DEBURR`, knurl). A feature verb returns
798
+ a chainable handle (`sheet.hole(bore)`) that the aspect and `control(...)` verbs decorate;
799
+ targets are placed automatically — the view and strip are derived from the referenced
800
+ feature or face, with `view=`/`side=` overrides.
801
+
772
802
  ## What it produces
773
803
 
774
804
  - **Three orthographic views** (front, plan, side) sized and scaled automatically to the
@@ -75,6 +75,37 @@ issues = dwg.lint() # list[LintIssue] — coverage, page b
75
75
  svg_path, dxf_path = dwg.export("my_part")
76
76
  ```
77
77
 
78
+ ### Declarative drawings — reference features, declare intent
79
+
80
+ Instead of relying on detection, **reference the build123d objects you built** and declare
81
+ only the drawing intent — geometry supplies the sizes (⌀ read off the object), you supply
82
+ tolerances, fits, datums, GD&T, and surface finish. The fluent `Sheet` skips detection and
83
+ dimensions exactly what you declare (ADR 0011):
84
+
85
+ ```python
86
+ from build123d import Box, Cylinder, Pos
87
+ from draftwright import Sheet
88
+
89
+ plate = Box(120, 80, 20)
90
+ bore = Pos(0, 0, 0) * Cylinder(4, 20)
91
+
92
+ sheet = Sheet(plate - bore, title="Plate", number="DWG-002")
93
+ sheet.envelope()
94
+ sheet.datum("A", plate.faces().sort_by()[-1]) # datum A on the top face
95
+ hole = sheet.hole(bore).finish("1.6").note("M3x0.5 TAP") # ⌀8 bore, Ra 1.6, tapped
96
+ sheet.control(hole).position(0.1, to="A", diameter=True) # ⌀0.1 position wrt A
97
+ sheet.export("plate") # writes plate.svg + plate.dxf
98
+ ```
99
+
100
+ Every aspect the geometry can't carry is a declared verb: `.tolerance(±)` / `.fit("H7")`
101
+ (ISO 286), `.finish("Ra")` (ISO 1302), `sheet.datum(letter, ref)` (ISO 5459), and
102
+ `sheet.control(ref)` with all 14 ISO 1101 characteristics
103
+ (`.position`/`.flatness`/`.perpendicularity`/`.circular_runout`/…), plus `sheet.note(text, ref)`
104
+ / `.note(text)` for free-text shop callouts (thread specs, `DEBURR`, knurl). A feature verb returns
105
+ a chainable handle (`sheet.hole(bore)`) that the aspect and `control(...)` verbs decorate;
106
+ targets are placed automatically — the view and strip are derived from the referenced
107
+ feature or face, with `view=`/`side=` overrides.
108
+
78
109
  ## What it produces
79
110
 
80
111
  - **Three orthographic views** (front, plan, side) sized and scaled automatically to the
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.2.8"
7
+ version = "0.2.10"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -18,7 +18,6 @@ dependencies = [
18
18
  "build123d>=0.9.0,<0.11 ; python_full_version < '3.13'",
19
19
  "build123d>=0.11,<0.12 ; python_full_version >= '3.13'",
20
20
  "build123d-drafting-helpers>=0.13.0",
21
- "kiwisolver>=1.4,<2",
22
21
  "numpy>=1.24",
23
22
  "reportlab>=4.0",
24
23
  "svglib>=1.5",
@@ -255,10 +255,21 @@ _SLOT_DIM_HEIGHT = 2 * _FONT_SIZE + 2 * _PAD # fv_zones.right: overall height d
255
255
 
256
256
 
257
257
  _MIN_VIEW_MM = (
258
- 10.0 # min projected view dimension; below it annotation geometry degenerates (#129)
258
+ 10.0 # legibility floor: the projected size below which an *explicit* scale earns a warning.
259
+ # It is NOT a bound on the auto scale (choose_scale is a pure geometric page fit) and does NOT
260
+ # gate which annotations exist (step/location legibility use _MIN_STEP_*/_MIN_LOC_SEP_MM). Its
261
+ # only use is the explicit-scale advisory in analysis.py: below it a user scale is honoured
262
+ # with a warning, not rejected (#489).
259
263
  )
260
264
 
261
265
 
266
+ # Hard geometry floor: below this projected size OCCT's annotation arcs collapse
267
+ # (Geom_TrimmedCurve U1==U2), which happens near 1e-4 mm empirically — 0.1 mm is a conservative
268
+ # floor far above that and far below any real drawing. An explicit scale under it is rejected with
269
+ # a clean message rather than a cryptic OCP error (#489).
270
+ _MIN_RENDER_MM = 0.1
271
+
272
+
262
273
  _SLOT_DIM_STEP = 4 * _FONT_SIZE + _PAD # fv_zones.right: step-height dimension
263
274
 
264
275
 
@@ -557,8 +568,11 @@ def _attribution_author(drawn_by: str | None) -> str:
557
568
  return f"{author} / draftwright" if author else "draftwright"
558
569
 
559
570
 
560
- def _add_title_block(dwg, a: Analysis):
561
- """Add the title block annotation."""
571
+ def _make_title_block(dwg, a: Analysis):
572
+ """Construct + page-locate the title block, returning ``(tb, cell)`` where *cell* is its
573
+ drawn-by cell bbox (for the hyperlink rect). Shared by :func:`_add_title_block` (which adds
574
+ it, last) and :func:`_title_block_box` (which measures its footprint for GD&T avoidance, #481)
575
+ so the two never drift."""
562
576
  tb = TitleBlock(
563
577
  a.title,
564
578
  a.number,
@@ -582,6 +596,20 @@ def _add_title_block(dwg, a: Analysis):
582
596
  # TitleBlock layout change. Build-frame bbox; translated to page space below.
583
597
  cell = tb.drawn_by_cell_bbox()
584
598
  tb = tb.locate(Location((a.PAGE_W - a.TB_W - _TB_CLEAR, _TB_CLEAR, 0)))
599
+ return tb, cell
600
+
601
+
602
+ def _title_block_box(dwg, a: Analysis):
603
+ """The title block's real page-space bbox ``(x0, y0, x1, y1)``. GD&T placement avoids it
604
+ (#481): the block is added last, so strip placement can't see it, but it's deterministic."""
605
+ tb, _ = _make_title_block(dwg, a)
606
+ b = tb.bounding_box()
607
+ return (b.min.X, b.min.Y, b.max.X, b.max.Y)
608
+
609
+
610
+ def _add_title_block(dwg, a: Analysis):
611
+ """Add the title block annotation."""
612
+ tb, cell = _make_title_block(dwg, a)
585
613
  dwg.add(tb, "title_block")
586
614
 
587
615
  # Record that cell's page-space rectangle so export() can place a clickable
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import logging
14
14
  import math
15
+ import warnings
15
16
 
16
17
  from build123d import Compound, Shape
17
18
  from build123d_drafting.helpers import draft_preset
@@ -22,6 +23,7 @@ from draftwright._core import (
22
23
  _DIM_PAD,
23
24
  _FONT_SIZE,
24
25
  _MARGIN,
26
+ _MIN_RENDER_MM,
25
27
  _MIN_VIEW_MM,
26
28
  Analysis,
27
29
  _legible_steps,
@@ -341,21 +343,41 @@ def _analyse(
341
343
  break
342
344
  n_for_sizing = n_next
343
345
  if scale is not None:
346
+ # An explicit scale is the user's call — honour it (#489). Two floors apply:
347
+ # - _MIN_RENDER_MM: a hard geometry limit; below it OCCT's annotation arcs degenerate
348
+ # (Geom_TrimmedCurve U1==U2, ~1e-4 mm empirically — 0.1 mm is a conservative floor).
349
+ # Reject with a clean message; there is no meaningful drawing this small anyway.
350
+ # - _MIN_VIEW_MM: a legibility floor; below it annotations crowd but the drawing is
351
+ # valid, so honour the scale with a warning. This floor does NOT bound the auto scale
352
+ # (choose_scale is a pure geometric page fit), so a warning is only useful when a
353
+ # legible page-fitting scale actually exists — i.e. the auto scale is itself legible.
354
+ min_dim = min(x_size, y_size, z_size)
355
+ min_view = min_dim * SCALE
356
+ if min_view < _MIN_RENDER_MM:
357
+ safe = _MIN_RENDER_MM / min_dim
358
+ raise ValueError(
359
+ f"scale {SCALE!r} projects the smallest part dimension "
360
+ f"({min_dim:.0f} mm) to {min_view:.3g} mm — the drawing geometry degenerates "
361
+ f"below {_MIN_RENDER_MM:g} mm (OCCT arc construction fails). "
362
+ f"Use scale ≥ {safe:.3g} or omit the scale for automatic selection."
363
+ )
344
364
  auto_scale, _, _, _ = choose_scale(
345
365
  x_size, y_size, z_size, n_steps=n_for_sizing, scale=None, page=page, strips=strips_i
346
366
  )
347
- if SCALE < auto_scale:
348
- min_dim = min(x_size, y_size, z_size)
349
- min_view = min_dim * SCALE
350
- if min_view < _MIN_VIEW_MM:
351
- safe = _MIN_VIEW_MM / min_dim
352
- raise ValueError(
353
- f"scale {SCALE!r} projects the smallest part dimension "
354
- f"({min_dim:.0f} mm) to {min_view:.1f} mm "
355
- f"annotation geometry degenerates below {_MIN_VIEW_MM:.0f} mm "
356
- f"(OCCT Standard_DomainError / SIGABRT). "
357
- f"Use scale {safe:.3g} or omit --scale for automatic selection."
358
- )
367
+ # Warn only when omitting the scale would truly give a legible fit (auto scale itself is
368
+ # legible) but the requested scale is below the floor. A part illegible at every
369
+ # page-fitting scale can't be helped by a bigger one, so nagging there would be false.
370
+ if min_view < _MIN_VIEW_MM <= auto_scale * min_dim:
371
+ safe = _MIN_VIEW_MM / min_dim
372
+ # No stacklevel that reaches user code: this fires deep in _analyse, and the public
373
+ # entry points (make_drawing, Sheet.export, build_drawing) sit at different depths.
374
+ # The message is self-contained (names the scale, the projection, and the fix).
375
+ warnings.warn(
376
+ f"scale {SCALE!r} projects the smallest part dimension ({min_dim:.0f} mm) to "
377
+ f"{min_view:.1f} mm, below the {_MIN_VIEW_MM:.0f} mm legibility floor "
378
+ f"annotations may crowd or overlap. Honouring the requested scale; use "
379
+ f"scale ≥ {safe:.3g} or omit the scale for an automatic legible fit."
380
+ )
359
381
  DIM_PAD = _DIM_PAD
360
382
  margin = _MARGIN
361
383
  # Refine: apply the same legibility gate _auto_annotate uses for dim_step.
@@ -11,4 +11,5 @@ from draftwright.annotations.orchestrator import ( # noqa: F401
11
11
  _auto_annotate,
12
12
  _wrap_rows,
13
13
  build_model,
14
+ build_rotational_feature,
14
15
  )
@@ -288,6 +288,11 @@ class CorridorCandidate:
288
288
  the same physical dimension; the higher-``precedence`` one survives (#345).
289
289
  precedence: dedup survivor rank — a hole *location* dim (feeds coverage/table
290
290
  escalation) outranks a coincident slot *position* line.
291
+ priority: over-capacity survival rank (#357). When a strip cannot hold every
292
+ candidate, :func:`plan_strip` drops the lowest ``(priority, key)`` — so a higher
293
+ ``priority`` is kept. An authored GD&T frame sets this above the auto dims so it
294
+ is not dropped in favour of a lower-value auto dim purely by stacking-key order.
295
+ Default 0 (every auto dim) → key order, unchanged.
291
296
  on_place/on_drop: the pass's own post-placement bookkeeping — coverage
292
297
  registration / drop lint + `Escalation`, or a slot's below-side fallthrough.
293
298
  force: policy-B force-keep after the corridor-respecting pass (locations have
@@ -301,10 +306,21 @@ class CorridorCandidate:
301
306
  on_drop: object
302
307
  dedup: tuple | None = None
303
308
  precedence: int = 0
309
+ priority: float = 0
304
310
  force: bool = False
305
311
  # The source IR feature this dim was rendered for — recorded as provenance when the
306
312
  # dim is placed at drain (ADR 0010). ``None`` leaves the annotation feature-less.
307
313
  feature: object | None = None
314
+ # Real stacking-axis + perpendicular footprint ``(w, h)`` in page-mm, or ``None`` to
315
+ # use the dimension default ``(tier, tier)``. Wide/tall occupants (a GD&T feature
316
+ # control frame is ~24×6 mm) set this so the strip solve reserves their true extent
317
+ # instead of one label-height (ADR 0009 real-footprint plumbing, #61). A dim leaves
318
+ # it ``None`` — byte-identical to the pre-plumbing placement.
319
+ size: tuple | None = None
320
+ # An ``(x0, y0, x1, y1)`` page-box this candidate must NOT overlap even when force-kept —
321
+ # the title block, which is placed after the corridor drain so the strip carve can't see
322
+ # it (#481). ``None`` (every dim) skips the check → byte-identical.
323
+ forbid: object | None = None
308
324
 
309
325
 
310
326
  def solve_corridor(dwg, strip, view, axis, cands, tier):
@@ -357,15 +373,40 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
357
373
  return
358
374
  pairs = [(c.name, c.build) for c in kept]
359
375
  feats = {c.name: c.feature for c in kept if c.feature is not None} # provenance (ADR 0010)
376
+ sizes = {c.name: c.size for c in kept if c.size is not None} # real footprint (#61)
377
+ forbid = {c.name: c.forbid for c in kept if c.forbid is not None} # title-block box (#481)
378
+ prio = {c.name: c.priority for c in kept if c.priority} # over-capacity survival rank (#357)
360
379
  left = {
361
- n for n, _ in place_strip_candidates(dwg, strip, view, axis, pairs, tier, features=feats)
380
+ n
381
+ for n, _ in place_strip_candidates(
382
+ dwg,
383
+ strip,
384
+ view,
385
+ axis,
386
+ pairs,
387
+ tier,
388
+ features=feats,
389
+ sizes=sizes,
390
+ forbid=forbid,
391
+ priorities=prio,
392
+ )
362
393
  }
363
394
  force_pairs = [(c.name, c.build) for c in kept if c.name in left and c.force]
364
395
  still = (
365
396
  {
366
397
  n
367
398
  for n, _ in place_strip_candidates(
368
- dwg, strip, view, axis, force_pairs, tier, force=True, features=feats
399
+ dwg,
400
+ strip,
401
+ view,
402
+ axis,
403
+ force_pairs,
404
+ tier,
405
+ force=True,
406
+ features=feats,
407
+ sizes=sizes,
408
+ forbid=forbid,
409
+ priorities=prio,
369
410
  )
370
411
  }
371
412
  if force_pairs
@@ -399,7 +440,20 @@ def drain_corridors(dwg):
399
440
  dwg._corridor_batch = {}
400
441
 
401
442
 
402
- def place_strip_candidates(dwg, strip, view, axis, cands, tier, *, force=False, features=None):
443
+ def place_strip_candidates(
444
+ dwg,
445
+ strip,
446
+ view,
447
+ axis,
448
+ cands,
449
+ tier,
450
+ *,
451
+ force=False,
452
+ features=None,
453
+ sizes=None,
454
+ forbid=None,
455
+ priorities=None,
456
+ ):
403
457
  """Collect-then-solve placement of location/feature dims on one strip (ADR 0009).
404
458
  The single shared strip placer that retires the ``Strip.allocate`` cursor (#150,
405
459
  P3): each candidate in *cands* — an ``(name, build(pos)->dim)`` pair — is spaced by
@@ -418,6 +472,17 @@ def place_strip_candidates(dwg, strip, view, axis, cands, tier, *, force=False,
418
472
  1-D strip carve cannot represent: a leader in that corridor is crossed no matter how
419
473
  far out the dim line lands. By default such a placement is rejected so the caller can
420
474
  route the dim to the other view (its disjoint block cannot cross this leader).
475
+
476
+ *sizes* maps a candidate's name to its real page-mm footprint ``(w, h)``; absent
477
+ names use the dimension default ``(tier, tier)``. A wide/tall occupant (a GD&T
478
+ frame, #61) sets it so :func:`plan_strip` enforces its true stacking gap — over
479
+ capacity it is relocated to the next segment or dropped, never overlapped.
480
+
481
+ *priorities* maps a candidate's name to its over-capacity survival rank (#357);
482
+ absent names default to 0. When a segment is over capacity :func:`plan_strip` drops
483
+ the lowest ``(priority, key)``, so a higher priority is kept — an authored GD&T frame
484
+ is not dropped for a lower-value auto dim purely by stacking-key order.
485
+
421
486
  ``force=True`` skips that corridor check — the caller's last resort when no view took
422
487
  the dim cleanly: keep it on its natural view and accept the (same-feature) leader
423
488
  crossing rather than drop a real dimension (policy B). Candidates that find no strip
@@ -426,17 +491,31 @@ def place_strip_candidates(dwg, strip, view, axis, cands, tier, *, force=False,
426
491
  if strip is None or not cands:
427
492
  return list(cands)
428
493
  lo, hi, inner = strip_free_span(strip)
429
- # Reserve the outermost label's height at the strip boundary. plan_strip bounds the
430
- # dim-LINE position, but the label extends `tier` OUTWARD from it — so without this
431
- # the last tier's label overshoots outer_limit (into the iso view / page margin),
432
- # unlike the old Strip.allocate which checked `start + tier <= outer_limit` (#338
433
- # review). The strip edge is not an obstacle (obstacles carry their own footprint +
434
- # pad), so only the boundary needs it; obstacle-bounded segments are unaffected.
494
+ idx = 1 if axis == "y" else 0
495
+
496
+ # Reserve the outermost label's OUTWARD extent at the strip boundary. plan_strip bounds
497
+ # the dim-LINE position, but the label extends outward from it so without this the last
498
+ # tier's label overshoots outer_limit (into the iso view / page margin), unlike the old
499
+ # Strip.allocate which checked `start + tier <= outer_limit` (#338 review). A plain dim's
500
+ # label extends one `tier` outward (one-sided). A GD&T glyph (#61) hangs off a Leader that
501
+ # CENTRES it on the elbow for an above/below strip (real outward extent = height/2) but
502
+ # places it one-sided for a left/right strip (extent = full width). Reserve the MAX real
503
+ # outward extent among these candidates — else a glyph wider than `tier` renders off the
504
+ # sheet (annotation_out_of_bounds) instead of dropping when the strip is too narrow (ADR
505
+ # 0009 Amdt 7 fixed inter-candidate gaps but not this edge). With no `sizes` (every dim)
506
+ # this is `tier`, byte-identical. The strip edge is not an obstacle (obstacles carry their
507
+ # own footprint + pad), so only the boundary needs it.
508
+ def _outward(name):
509
+ sz = (sizes or {}).get(name)
510
+ if sz is None:
511
+ return tier # a dim: one-sided tier reservation (unchanged)
512
+ return sz[idx] if axis == "x" else sz[idx] / 2 # GD&T: one-sided (L/R) vs centred (A/B)
513
+
514
+ reserve = max([tier, *(_outward(n) for n, _ in cands)])
435
515
  if inner == lo:
436
- hi -= tier
516
+ hi -= reserve
437
517
  else:
438
- lo += tier
439
- idx = 1 if axis == "y" else 0
518
+ lo += reserve
440
519
  perp = 0 if axis == "y" else 1 # the axis the dims do NOT stack along
441
520
  pad = tier + strip.spacing # min separation between stacked dim lines
442
521
  # Perpendicular band of these candidates. The 1-D carve projects obstacles onto the
@@ -471,7 +550,10 @@ def place_strip_candidates(dwg, strip, view, axis, cands, tier, *, force=False,
471
550
  triples = [
472
551
  (
473
552
  StripCandidate(
474
- f"{(k if inner == lo else len(take) - 1 - k):04d}", anch, (tier, tier)
553
+ f"{(k if inner == lo else len(take) - 1 - k):04d}",
554
+ anch,
555
+ (sizes or {}).get(nb[0], (tier, tier)),
556
+ priority=(priorities or {}).get(nb[0], 0.0),
475
557
  ),
476
558
  nb,
477
559
  )
@@ -487,6 +569,14 @@ def place_strip_candidates(dwg, strip, view, axis, cands, tier, *, force=False,
487
569
  if not force and _box_hits(_geom_box(dim), blockers): # corridor crosses a leader
488
570
  todo.append((name, build))
489
571
  continue
572
+ # A forbidden box (the title block, #481) is rejected even under force — it is
573
+ # placed after the drain, so the strip carve can't see it; a force-kept GD&T frame
574
+ # must still not stack onto it. `forbid` maps names to their box (only GD&T sets it,
575
+ # so dims are byte-identical). Returned unplaced → the caller's on_drop fallthrough.
576
+ fb = (forbid or {}).get(name)
577
+ if fb is not None and _box_hits(_geom_box(dim), (fb,)):
578
+ todo.append((name, build))
579
+ continue
490
580
  # Record feature provenance (ADR 0010): the drain-time seam for corridor-placed
491
581
  # dims — `features` maps this batch's names to their source IR feature.
492
582
  dwg.add(dim, name, view=view, feature=(features or {}).get(name))