draftwright 0.2.9__tar.gz → 0.2.11__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 (101) hide show
  1. {draftwright-0.2.9 → draftwright-0.2.11}/CHANGELOG.md +71 -0
  2. {draftwright-0.2.9 → draftwright-0.2.11}/PKG-INFO +4 -4
  3. {draftwright-0.2.9 → draftwright-0.2.11}/README.md +3 -2
  4. {draftwright-0.2.9 → draftwright-0.2.11}/pyproject.toml +1 -2
  5. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/_core.py +23 -6
  6. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/analysis.py +193 -26
  7. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/annotations/_common.py +84 -10
  8. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/annotations/from_model.py +313 -170
  9. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/annotations/holes.py +162 -51
  10. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/annotations/orchestrator.py +17 -19
  11. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/annotations/sections.py +11 -13
  12. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/builder.py +51 -19
  13. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/cli.py +20 -21
  14. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/drawing.py +158 -57
  15. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/layout.py +27 -63
  16. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/linting/__init__.py +2 -0
  17. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/linting/coverage.py +69 -0
  18. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/model/__init__.py +4 -1
  19. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/model/declare.py +88 -27
  20. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/model/detect.py +31 -19
  21. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/model/ir.py +21 -0
  22. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/repair.py +8 -25
  23. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/sheet.py +239 -82
  24. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/sheet_dsl.py +136 -9
  25. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/sheet_emit.py +74 -8
  26. {draftwright-0.2.9 → draftwright-0.2.11}/tests/layout_snapshots/bracket.json +12 -12
  27. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_declare.py +81 -0
  28. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_layout.py +5 -6
  29. draftwright-0.2.11/tests/test_lint_reconciliation.py +135 -0
  30. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_make_drawing.py +400 -62
  31. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_pmi.py +43 -0
  32. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_sheet_emit.py +151 -11
  33. draftwright-0.2.11/tests/test_sheet_notes.py +131 -0
  34. draftwright-0.2.11/tests/test_sheet_tables.py +124 -0
  35. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_strip_layout.py +178 -0
  36. {draftwright-0.2.9 → draftwright-0.2.11}/.gitignore +0 -0
  37. {draftwright-0.2.9 → draftwright-0.2.11}/LICENSE +0 -0
  38. {draftwright-0.2.9 → draftwright-0.2.11}/skills/SKILL.md +0 -0
  39. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/__init__.py +0 -0
  40. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/annotate.py +0 -0
  41. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/annotations/__init__.py +0 -0
  42. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/export.py +0 -0
  43. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/features.py +0 -0
  44. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/fits.py +0 -0
  45. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  46. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  47. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  48. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  49. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/fonts/__init__.py +0 -0
  50. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/intents.py +0 -0
  51. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/linting/issues.py +0 -0
  52. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/linting/structural.py +0 -0
  53. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/linting/suggest.py +0 -0
  54. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/make_drawing.py +0 -0
  55. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/model/planner.py +0 -0
  56. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/pmi.py +0 -0
  57. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/projection.py +0 -0
  58. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/recognition/__init__.py +0 -0
  59. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/recognition/_features.py +0 -0
  60. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/recognition/levels.py +0 -0
  61. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/recognition/slots.py +0 -0
  62. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/recognition/turned.py +0 -0
  63. {draftwright-0.2.9 → draftwright-0.2.11}/src/draftwright/registry.py +0 -0
  64. {draftwright-0.2.9 → draftwright-0.2.11}/tests/_kernel.py +0 -0
  65. {draftwright-0.2.9 → draftwright-0.2.11}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  66. {draftwright-0.2.9 → draftwright-0.2.11}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  67. {draftwright-0.2.9 → draftwright-0.2.11}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  68. {draftwright-0.2.9 → draftwright-0.2.11}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  69. {draftwright-0.2.9 → draftwright-0.2.11}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  70. {draftwright-0.2.9 → draftwright-0.2.11}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  71. {draftwright-0.2.9 → draftwright-0.2.11}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  72. {draftwright-0.2.9 → draftwright-0.2.11}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  73. {draftwright-0.2.9 → draftwright-0.2.11}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  74. {draftwright-0.2.9 → draftwright-0.2.11}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  75. {draftwright-0.2.9 → draftwright-0.2.11}/tests/layout_snapshots/box.json +0 -0
  76. {draftwright-0.2.9 → draftwright-0.2.11}/tests/layout_snapshots/drive_screw_x.json +0 -0
  77. {draftwright-0.2.9 → draftwright-0.2.11}/tests/layout_snapshots/dshape.json +0 -0
  78. {draftwright-0.2.9 → draftwright-0.2.11}/tests/layout_snapshots/flange.json +0 -0
  79. {draftwright-0.2.9 → draftwright-0.2.11}/tests/layout_snapshots/holed_slot.json +0 -0
  80. {draftwright-0.2.9 → draftwright-0.2.11}/tests/layout_snapshots/plate_holes.json +0 -0
  81. {draftwright-0.2.9 → draftwright-0.2.11}/tests/layout_snapshots/side_drilled.json +0 -0
  82. {draftwright-0.2.9 → draftwright-0.2.11}/tests/layout_snapshots/slotted.json +0 -0
  83. {draftwright-0.2.9 → draftwright-0.2.11}/tests/layout_snapshots/turned_shaft.json +0 -0
  84. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_e2e_slice.py +0 -0
  85. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_e2e_standards.py +0 -0
  86. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_fits.py +0 -0
  87. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_gdt_placement.py +0 -0
  88. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_layout_cleanliness.py +0 -0
  89. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_layout_property.py +0 -0
  90. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_layout_snapshot.py +0 -0
  91. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_lint_structural.py +0 -0
  92. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_linting.py +0 -0
  93. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_object_aspects.py +0 -0
  94. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_part_model.py +0 -0
  95. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_recognition.py +0 -0
  96. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_registry.py +0 -0
  97. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_render_seam.py +0 -0
  98. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_sheet_gdt.py +0 -0
  99. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_sheet_of.py +0 -0
  100. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_tolerances.py +0 -0
  101. {draftwright-0.2.9 → draftwright-0.2.11}/tests/test_turned_steps.py +0 -0
@@ -1,5 +1,76 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.11 — 2026-07-08
4
+
5
+ **Automatic layout now has one authority.** Page/scale selection, section placement,
6
+ furniture footprints, and table/balloon escalation now negotiate through the same layout
7
+ fitness model instead of using separate fixed offsets or first-fit policies after the main
8
+ solve.
9
+
10
+ ### Fixed
11
+
12
+ - **Solver placement paths are more consistent.** STEP PMI dimensions now queue into
13
+ the shared ADR 0009 corridor solve instead of carving after the drain; front-view
14
+ hole callouts use the strip solver instead of fixed row stepping; pitch-dimension
15
+ fallback searches bounded, obstacle-aware positions; repair no longer hides
16
+ `annotation_overlap` with a fixed-step nudge; and step-count sizing now handles
17
+ non-convergence conservatively. (#524)
18
+ - **Page/scale fitness is shared across initial selection and repack.** Later layout
19
+ passes now compare candidates with the same model as the page chooser, preventing a
20
+ nominally "better" repack from invalidating the original layout decision. (#519)
21
+ - **Section A-A participates in layout selection.** Section placement is measured as
22
+ part of page/scale fitness, so a section view is no longer a fixed-offset afterthought
23
+ that can disappear on dense sheets. (#515)
24
+ - **Furniture placement reserves full rendered footprints.** Section arrows, detail
25
+ views, balloons, hole/data tables, and other furniture now reserve/check their true
26
+ rendered footprint instead of just their labels. (#518)
27
+ - **Hole/data tables and balloon rings escalate through the layout model.** Dense table
28
+ and balloon outputs can move or negotiate for available room rather than dropping from
29
+ greedy first-fit placement when a small adjustment would fit. (#516, #517)
30
+
31
+ ## v0.2.10 — 2026-07-07
32
+
33
+ **Declarative-surface fidelity and layout-engine unification.** Editing and re-running a
34
+ declarative `Sheet` script now reproduces more of the drawing (title block, layout, PMI on the
35
+ declared path), a new lint catches stale declarations, and an intentional scale is respected.
36
+ Internally the annotation placer moves further onto the single collect-then-solve (ADR 0009),
37
+ and `kiwisolver` is dropped as a dependency.
38
+
39
+ ### Added
40
+
41
+ - **Declaration-vs-geometry reconciliation lint** — `lint()` now flags a declared cylindrical
42
+ feature (hole/boss/step) with **no matching geometry** in the part (`declared_feature_absent`):
43
+ a callout drawn over empty space because the part was edited but the declaration went stale.
44
+ Closes the last gap in the "did my edit break the drawing?" loop (over-declaration; coverage
45
+ lint already caught under-declaration). Matches on axis + ⌀ + in-plane position + bore/boss
46
+ polarity. Gated on a caller-supplied `model=` (detection can't over-declare). (#487)
47
+ - **Title-block + layout aspects on the `Sheet` DSL** — `Sheet(drawn_by=…, tolerance=…, scale=…,
48
+ page=…)`; the generated `--script` reproduces them, and the CLI forwards
49
+ `--drawn-by`/`--tolerance`/`--scale`/`--page` on the sheet path (no more inert-flag warning). (#474)
50
+ - **PMI reproduced on the declared-model path** — `build_drawing(path, model=…, pmi="annotate")`
51
+ synthesises the STEP AP242 PMI into the declared model, so a declared build draws the same PMI
52
+ dimensions as the detection path. (#472)
53
+
54
+ ### Changed
55
+
56
+ - **An intentional explicit `scale=` below the legibility floor is honoured with a warning**
57
+ rather than rejected. A part deliberately drawn at 1:1 (or `Sheet(scale="1:10")`) whose smallest
58
+ feature falls under the 10 mm legibility floor now renders (annotations may crowd) instead of
59
+ raising `ValueError`; only a genuinely degenerate scale (< 0.1 mm projected, where OCCT arcs
60
+ collapse) is rejected. The floor now binds only the *automatic* scale. (#489)
61
+ - **`kiwisolver` is no longer a dependency.** The 1-D strip solve delegates to the built-in
62
+ deterministic minimum-total-leader-length PAVA algorithm, which supersedes the Cassowary
63
+ solver — same placement contract, one fewer third-party dependency. (#507)
64
+
65
+ ### Fixed
66
+
67
+ - **Dimension placement is more robust under contention.** Candidate **priority** is plumbed
68
+ through the shared corridor solve, so an authored GD&T frame is kept over a lower-value
69
+ automatic dimension when a strip is over capacity (#357). Rotational concentric-bore leaders
70
+ are now bounded to the front-view height with ranked drops, and pitch dimensions are placed
71
+ onto the obstacle-aware per-side zone-strip solve — retiring the last fixed-offset,
72
+ count-based stacking placers (the shape behind earlier dense-sheet overruns). (#374)
73
+
3
74
  ## v0.2.9 — 2026-07-06
4
75
 
5
76
  **Declarative GD&T, datums, and surface finish (ADR 0011 Phase 2b/2c).** The `Sheet` DSL can
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.2.9
3
+ Version: 0.2.11
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
@@ -786,7 +785,7 @@ bore = Pos(0, 0, 0) * Cylinder(4, 20)
786
785
  sheet = Sheet(plate - bore, title="Plate", number="DWG-002")
787
786
  sheet.envelope()
788
787
  sheet.datum("A", plate.faces().sort_by()[-1]) # datum A on the top face
789
- hole = sheet.hole(bore).finish("1.6") # ⌀8 bore, Ra 1.6
788
+ hole = sheet.hole(bore).finish("1.6").note("M3x0.5 TAP") # ⌀8 bore, Ra 1.6, tapped
790
789
  sheet.control(hole).position(0.1, to="A", diameter=True) # ⌀0.1 position wrt A
791
790
  sheet.export("plate") # writes plate.svg + plate.dxf
792
791
  ```
@@ -794,7 +793,8 @@ sheet.export("plate") # writes plate.svg + p
794
793
  Every aspect the geometry can't carry is a declared verb: `.tolerance(±)` / `.fit("H7")`
795
794
  (ISO 286), `.finish("Ra")` (ISO 1302), `sheet.datum(letter, ref)` (ISO 5459), and
796
795
  `sheet.control(ref)` with all 14 ISO 1101 characteristics
797
- (`.position`/`.flatness`/`.perpendicularity`/`.circular_runout`/…). A feature verb returns
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
798
  a chainable handle (`sheet.hole(bore)`) that the aspect and `control(...)` verbs decorate;
799
799
  targets are placed automatically — the view and strip are derived from the referenced
800
800
  feature or face, with `view=`/`side=` overrides.
@@ -92,7 +92,7 @@ bore = Pos(0, 0, 0) * Cylinder(4, 20)
92
92
  sheet = Sheet(plate - bore, title="Plate", number="DWG-002")
93
93
  sheet.envelope()
94
94
  sheet.datum("A", plate.faces().sort_by()[-1]) # datum A on the top face
95
- hole = sheet.hole(bore).finish("1.6") # ⌀8 bore, Ra 1.6
95
+ hole = sheet.hole(bore).finish("1.6").note("M3x0.5 TAP") # ⌀8 bore, Ra 1.6, tapped
96
96
  sheet.control(hole).position(0.1, to="A", diameter=True) # ⌀0.1 position wrt A
97
97
  sheet.export("plate") # writes plate.svg + plate.dxf
98
98
  ```
@@ -100,7 +100,8 @@ sheet.export("plate") # writes plate.svg + p
100
100
  Every aspect the geometry can't carry is a declared verb: `.tolerance(±)` / `.fit("H7")`
101
101
  (ISO 286), `.finish("Ra")` (ISO 1302), `sheet.datum(letter, ref)` (ISO 5459), and
102
102
  `sheet.control(ref)` with all 14 ISO 1101 characteristics
103
- (`.position`/`.flatness`/`.perpendicularity`/`.circular_runout`/…). A feature verb returns
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
104
105
  a chainable handle (`sheet.hole(bore)`) that the aspect and `control(...)` verbs decorate;
105
106
  targets are placed automatically — the view and strip are derived from the referenced
106
107
  feature or face, with `view=`/`side=` overrides.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.2.9"
7
+ version = "0.2.11"
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",
@@ -21,6 +21,7 @@ from typing import TYPE_CHECKING
21
21
 
22
22
  if TYPE_CHECKING:
23
23
  from draftwright.recognition import TurnedProfile
24
+ from draftwright.sheet import StripDepths
24
25
 
25
26
  from build123d import Align, BoundBox, Location, Mode, Shape, Text
26
27
  from build123d_drafting.helpers import (
@@ -255,10 +256,21 @@ _SLOT_DIM_HEIGHT = 2 * _FONT_SIZE + 2 * _PAD # fv_zones.right: overall height d
255
256
 
256
257
 
257
258
  _MIN_VIEW_MM = (
258
- 10.0 # min projected view dimension; below it annotation geometry degenerates (#129)
259
+ 10.0 # legibility floor: the projected size below which an *explicit* scale earns a warning.
260
+ # It is NOT a bound on the auto scale (choose_scale is a pure geometric page fit) and does NOT
261
+ # gate which annotations exist (step/location legibility use _MIN_STEP_*/_MIN_LOC_SEP_MM). Its
262
+ # only use is the explicit-scale advisory in analysis.py: below it a user scale is honoured
263
+ # with a warning, not rejected (#489).
259
264
  )
260
265
 
261
266
 
267
+ # Hard geometry floor: below this projected size OCCT's annotation arcs collapse
268
+ # (Geom_TrimmedCurve U1==U2), which happens near 1e-4 mm empirically — 0.1 mm is a conservative
269
+ # floor far above that and far below any real drawing. An explicit scale under it is rejected with
270
+ # a clean message rather than a cryptic OCP error (#489).
271
+ _MIN_RENDER_MM = 0.1
272
+
273
+
262
274
  _SLOT_DIM_STEP = 4 * _FONT_SIZE + _PAD # fv_zones.right: step-height dimension
263
275
 
264
276
 
@@ -327,7 +339,7 @@ def _legible_locations(positions, scale):
327
339
  return kept, n_too_close
328
340
 
329
341
 
330
- def _largest_empty_rect(drawable, obstacles):
342
+ def _largest_empty_rect(drawable, obstacles, *, warn: bool = True):
331
343
  """Largest axis-aligned empty rectangle in *drawable* avoiding *obstacles*.
332
344
 
333
345
  *drawable* and each obstacle are ``(x0, y0, x1, y1)`` page-mm boxes. Returns
@@ -365,10 +377,11 @@ def _largest_empty_rect(drawable, obstacles):
365
377
  # is unreachable in practice — choose_scale always leaves a gap — but
366
378
  # if it ever happens the iso would render over the other views, so flag
367
379
  # it rather than fail silently.
368
- _log.warning(
369
- "No empty rectangle found for the iso view; obstacles fill the "
370
- "drawable area iso may overlap other views"
371
- )
380
+ if warn:
381
+ _log.warning(
382
+ "No empty rectangle found for the iso view; obstacles fill the "
383
+ "drawable area — iso may overlap other views"
384
+ )
372
385
  return drawable
373
386
  return best
374
387
 
@@ -503,6 +516,10 @@ class Analysis:
503
516
  is_rotational: bool
504
517
  od_axis: str # rotation/turning axis of a rotational part ("z" default; "x"/"y" #222)
505
518
  step_zs: list[float]
519
+ layout_strips: StripDepths
520
+ layout_n_steps: int
521
+ layout_section: bool
522
+ layout_table_sizes: tuple[tuple[float, float], ...]
506
523
  sv_right: float
507
524
  iso_right_limit: float
508
525
  SCALE: float
@@ -12,6 +12,8 @@ from __future__ import annotations
12
12
 
13
13
  import logging
14
14
  import math
15
+ import warnings
16
+ from collections.abc import Callable
15
17
 
16
18
  from build123d import Compound, Shape
17
19
  from build123d_drafting.helpers import draft_preset
@@ -19,9 +21,11 @@ from OCP.IFSelect import IFSelect_ReturnStatus
19
21
  from OCP.STEPControl import STEPControl_Reader
20
22
 
21
23
  from draftwright._core import (
24
+ _CONCENTRIC_TOL_MM,
22
25
  _DIM_PAD,
23
26
  _FONT_SIZE,
24
27
  _MARGIN,
28
+ _MIN_RENDER_MM,
25
29
  _MIN_VIEW_MM,
26
30
  Analysis,
27
31
  _legible_steps,
@@ -38,9 +42,12 @@ from draftwright.recognition import (
38
42
  full_cylinders,
39
43
  )
40
44
  from draftwright.sheet import (
45
+ StripDepths,
41
46
  _build_zones,
47
+ _est_hole_table_sizes,
42
48
  _layout_geometry,
43
49
  _measure_strips,
50
+ _will_section,
44
51
  choose_scale,
45
52
  )
46
53
 
@@ -52,6 +59,41 @@ _log = logging.getLogger(__name__)
52
59
  _SQUARENESS_TOL = 0.05
53
60
  _OD_FILL_MIN = 0.8
54
61
  _OD_AXIS_TOL = 0.05
62
+ _ScalePick = tuple[float, float, float, float]
63
+
64
+
65
+ def _declared_will_section(model, *, is_rotational=False, cx=0.0, cy=0.0) -> bool:
66
+ """True when a caller-supplied IR model contains a section-driving Z hole.
67
+
68
+ Detection-based layout uses recogniser holes; declared-model builds may
69
+ intentionally supply features detection missed. Inspect the public IR shape
70
+ duck-typed here so declared sections get the same page/scale reservation.
71
+ """
72
+
73
+ if model is None:
74
+ return False
75
+ features = getattr(model, "features", model)
76
+
77
+ def feature_member(pt) -> bool:
78
+ return not (is_rotational and math.hypot(pt[0] - cx, pt[1] - cy) <= _CONCENTRIC_TOL_MM)
79
+
80
+ for feat in features:
81
+ if getattr(feat, "kind", None) not in ("hole", "pattern"):
82
+ continue
83
+ frame = getattr(feat, "frame", None)
84
+ if frame is None or frame.axis != "z":
85
+ continue
86
+ members = getattr(feat, "members", ()) or (frame.origin,)
87
+ if not any(feature_member(m) for m in members):
88
+ continue
89
+ bore = getattr(feat, "member", feat)
90
+ if (
91
+ getattr(bore, "cbore", None) is not None
92
+ or getattr(bore, "spotface", None) is not None
93
+ or not getattr(bore, "through", True)
94
+ ):
95
+ return True
96
+ return False
55
97
 
56
98
 
57
99
  def _import_step(path) -> Compound:
@@ -104,6 +146,53 @@ def _is_rotational(x_size, y_size, od_diam, od_axis_offset) -> bool:
104
146
  )
105
147
 
106
148
 
149
+ def _converge_step_sizing(
150
+ initial_steps: int,
151
+ measure_strips: Callable[[int], StripDepths],
152
+ pick_scale: Callable[[int, StripDepths], _ScalePick],
153
+ count_legible: Callable[[float], int],
154
+ ) -> tuple[_ScalePick, StripDepths, int]:
155
+ """Choose scale/page with a step-corridor count that matches legibility.
156
+
157
+ The right-side step ladder is reserved before the scale is known, but the
158
+ actual step list is filtered by the chosen scale. Iterate that dependency
159
+ until it reaches a fixed point; if it cycles, reserve the largest count seen
160
+ so the sheet is sized conservatively instead of silently accepting whichever
161
+ value happened to appear on a fixed iteration budget (#520).
162
+ """
163
+ n_for_sizing = initial_steps
164
+ seen: set[int] = set()
165
+ attempted: list[int] = []
166
+ max_iter = max(4, initial_steps + 2)
167
+
168
+ for _ in range(max_iter):
169
+ if n_for_sizing in seen:
170
+ break
171
+ seen.add(n_for_sizing)
172
+ attempted.append(n_for_sizing)
173
+
174
+ strips = measure_strips(n_for_sizing)
175
+ scale_pick = pick_scale(n_for_sizing, strips)
176
+ n_next = count_legible(scale_pick[0])
177
+ if n_next == n_for_sizing:
178
+ return scale_pick, strips, n_for_sizing
179
+ if n_next in seen:
180
+ n_for_sizing = n_next
181
+ break
182
+ n_for_sizing = n_next
183
+
184
+ conservative_n = max(attempted + [n_for_sizing], default=initial_steps)
185
+ strips = measure_strips(conservative_n)
186
+ scale_pick = pick_scale(conservative_n, strips)
187
+ _log.warning(
188
+ "Step-corridor sizing did not converge from %d steps (tried %s); reserving %d steps",
189
+ initial_steps,
190
+ attempted,
191
+ conservative_n,
192
+ )
193
+ return scale_pick, strips, conservative_n
194
+
195
+
107
196
  # A hole is "concentric" with a turned part's rotation axis when its drilling
108
197
  # axis is the Z (OD) axis and its opening sits on the part centreline. Such
109
198
  # bores are already dimensioned by the ldr_z bore leaders, so they must not
@@ -200,7 +289,16 @@ def _solids_body(part, src: str = "part"):
200
289
 
201
290
 
202
291
  def _analyse(
203
- step_file, title, number, tolerance, drawn_by, out, scale=None, page=None, pmi="off"
292
+ step_file,
293
+ title,
294
+ number,
295
+ tolerance,
296
+ drawn_by,
297
+ out,
298
+ scale=None,
299
+ page=None,
300
+ pmi="off",
301
+ model=None,
204
302
  ) -> Analysis:
205
303
  """Load STEP or use a build123d Shape, analyse geometry, compute layout.
206
304
 
@@ -315,6 +413,20 @@ def _analyse(
315
413
  patterns = find_hole_patterns(holes)
316
414
  bosses = find_bosses(part, cyls=(z_cyls, cross_cyls)) # detect once — the one inventory (#264)
317
415
  slots = find_slots(part)
416
+ layout_section = _will_section(
417
+ holes,
418
+ patterns,
419
+ is_rotational=is_rotational,
420
+ cx=cx,
421
+ cy=cy,
422
+ ) or _declared_will_section(model, is_rotational=is_rotational, cx=cx, cy=cy)
423
+ layout_table_sizes = _est_hole_table_sizes(
424
+ holes,
425
+ patterns,
426
+ bb,
427
+ font_size=_FONT_SIZE,
428
+ pad_around_text=_pad_around_text,
429
+ )
318
430
 
319
431
  # Choose scale/page, iterating so the reserved step corridor matches the
320
432
  # number of steps the legibility gate will actually place (#1) — not the raw
@@ -322,40 +434,79 @@ def _analyse(
322
434
  # with 15 tiny treads) reserves a phantom step ladder that blocks a larger
323
435
  # scale. Seed conservatively (all faces), then re-gate at the chosen scale;
324
436
  # converges in a couple of rounds.
325
- n_for_sizing = len(step_zs)
326
- strips_i = None
327
- for _ in range(3):
328
- strips_i = _measure_strips(
437
+ def _measure_for_step_count(n_steps_i: int) -> StripDepths:
438
+ return _measure_strips(
329
439
  holes,
330
440
  patterns,
331
- n_for_sizing,
441
+ n_steps_i,
332
442
  bb,
333
443
  arrow_length=_arrow_length,
334
444
  pad_around_text=_pad_around_text,
335
445
  )
336
- SCALE, PAGE_W, PAGE_H, TB_W = choose_scale(
337
- x_size, y_size, z_size, n_steps=n_for_sizing, scale=scale, page=page, strips=strips_i
446
+
447
+ def _pick_for_step_count(n_steps_i: int, strips_i: StripDepths) -> _ScalePick:
448
+ return choose_scale(
449
+ x_size,
450
+ y_size,
451
+ z_size,
452
+ n_steps=n_steps_i,
453
+ scale=scale,
454
+ page=page,
455
+ strips=strips_i,
456
+ section=layout_section,
457
+ table_sizes=layout_table_sizes,
338
458
  )
339
- n_next = len(_legible_steps(step_zs, bb.min.Z, SCALE)[0])
340
- if n_next == n_for_sizing:
341
- break
342
- n_for_sizing = n_next
459
+
460
+ (SCALE, PAGE_W, PAGE_H, TB_W), strips_i, n_for_sizing = _converge_step_sizing(
461
+ len(step_zs),
462
+ _measure_for_step_count,
463
+ _pick_for_step_count,
464
+ lambda scale_i: len(_legible_steps(step_zs, bb.min.Z, scale_i)[0]),
465
+ )
343
466
  if scale is not None:
467
+ # An explicit scale is the user's call — honour it (#489). Two floors apply:
468
+ # - _MIN_RENDER_MM: a hard geometry limit; below it OCCT's annotation arcs degenerate
469
+ # (Geom_TrimmedCurve U1==U2, ~1e-4 mm empirically — 0.1 mm is a conservative floor).
470
+ # Reject with a clean message; there is no meaningful drawing this small anyway.
471
+ # - _MIN_VIEW_MM: a legibility floor; below it annotations crowd but the drawing is
472
+ # valid, so honour the scale with a warning. This floor does NOT bound the auto scale
473
+ # (choose_scale is a pure geometric page fit), so a warning is only useful when a
474
+ # legible page-fitting scale actually exists — i.e. the auto scale is itself legible.
475
+ min_dim = min(x_size, y_size, z_size)
476
+ min_view = min_dim * SCALE
477
+ if min_view < _MIN_RENDER_MM:
478
+ safe = _MIN_RENDER_MM / min_dim
479
+ raise ValueError(
480
+ f"scale {SCALE!r} projects the smallest part dimension "
481
+ f"({min_dim:.0f} mm) to {min_view:.3g} mm — the drawing geometry degenerates "
482
+ f"below {_MIN_RENDER_MM:g} mm (OCCT arc construction fails). "
483
+ f"Use scale ≥ {safe:.3g} or omit the scale for automatic selection."
484
+ )
344
485
  auto_scale, _, _, _ = choose_scale(
345
- x_size, y_size, z_size, n_steps=n_for_sizing, scale=None, page=page, strips=strips_i
486
+ x_size,
487
+ y_size,
488
+ z_size,
489
+ n_steps=n_for_sizing,
490
+ scale=None,
491
+ page=page,
492
+ strips=strips_i,
493
+ section=layout_section,
494
+ table_sizes=layout_table_sizes,
346
495
  )
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
- )
496
+ # Warn only when omitting the scale would truly give a legible fit (auto scale itself is
497
+ # legible) but the requested scale is below the floor. A part illegible at every
498
+ # page-fitting scale can't be helped by a bigger one, so nagging there would be false.
499
+ if min_view < _MIN_VIEW_MM <= auto_scale * min_dim:
500
+ safe = _MIN_VIEW_MM / min_dim
501
+ # No stacklevel that reaches user code: this fires deep in _analyse, and the public
502
+ # entry points (make_drawing, Sheet.export, build_drawing) sit at different depths.
503
+ # The message is self-contained (names the scale, the projection, and the fix).
504
+ warnings.warn(
505
+ f"scale {SCALE!r} projects the smallest part dimension ({min_dim:.0f} mm) to "
506
+ f"{min_view:.1f} mm, below the {_MIN_VIEW_MM:.0f} mm legibility floor "
507
+ f"annotations may crowd or overlap. Honouring the requested scale; use "
508
+ f"scale ≥ {safe:.3g} or omit the scale for an automatic legible fit."
509
+ )
359
510
  DIM_PAD = _DIM_PAD
360
511
  margin = _MARGIN
361
512
  # Refine: apply the same legibility gate _auto_annotate uses for dim_step.
@@ -371,7 +522,19 @@ def _analyse(
371
522
  # View positions + iso empty-rectangle, shared with scale selection (_fits)
372
523
  # via _layout_geometry so placement and fit never diverge (#11). _fit_iso_view
373
524
  # later scales the iso to fill its rectangle.
374
- _g = _layout_geometry(x_size, y_size, z_size, SCALE, PAGE_W, PAGE_H, TB_W, strips, n_steps)
525
+ _g = _layout_geometry(
526
+ x_size,
527
+ y_size,
528
+ z_size,
529
+ SCALE,
530
+ PAGE_W,
531
+ PAGE_H,
532
+ TB_W,
533
+ strips,
534
+ n_steps,
535
+ section=layout_section,
536
+ table_sizes=layout_table_sizes,
537
+ )
375
538
  fv_hw = _g.fv_hw
376
539
  fv_hh = _g.fv_hh
377
540
  pv_hh = _g.pv_hh
@@ -439,6 +602,10 @@ def _analyse(
439
602
  is_rotational=is_rotational,
440
603
  od_axis=od_axis,
441
604
  step_zs=step_zs,
605
+ layout_strips=strips,
606
+ layout_n_steps=n_steps,
607
+ layout_section=layout_section,
608
+ layout_table_sizes=layout_table_sizes,
442
609
  sv_right=sv_right,
443
610
  iso_right_limit=iso_right_limit,
444
611
  SCALE=SCALE,