draftwright 0.2.9__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 (101) hide show
  1. {draftwright-0.2.9 → draftwright-0.2.10}/CHANGELOG.md +43 -0
  2. {draftwright-0.2.9 → draftwright-0.2.10}/PKG-INFO +4 -4
  3. {draftwright-0.2.9 → draftwright-0.2.10}/README.md +3 -2
  4. {draftwright-0.2.9 → draftwright-0.2.10}/pyproject.toml +1 -2
  5. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/_core.py +12 -1
  6. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/analysis.py +34 -12
  7. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/annotations/_common.py +36 -2
  8. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/annotations/from_model.py +45 -5
  9. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/annotations/holes.py +59 -20
  10. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/builder.py +18 -1
  11. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/cli.py +20 -21
  12. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/drawing.py +7 -0
  13. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/layout.py +27 -63
  14. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/linting/__init__.py +2 -0
  15. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/linting/coverage.py +69 -0
  16. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/model/__init__.py +4 -1
  17. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/model/declare.py +88 -27
  18. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/model/detect.py +31 -19
  19. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/model/ir.py +21 -0
  20. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/sheet_dsl.py +136 -9
  21. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/sheet_emit.py +74 -8
  22. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_declare.py +81 -0
  23. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_layout.py +5 -6
  24. draftwright-0.2.10/tests/test_lint_reconciliation.py +135 -0
  25. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_make_drawing.py +52 -15
  26. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_pmi.py +40 -0
  27. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_sheet_emit.py +151 -11
  28. draftwright-0.2.10/tests/test_sheet_notes.py +131 -0
  29. draftwright-0.2.10/tests/test_sheet_tables.py +124 -0
  30. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_strip_layout.py +125 -0
  31. {draftwright-0.2.9 → draftwright-0.2.10}/.gitignore +0 -0
  32. {draftwright-0.2.9 → draftwright-0.2.10}/LICENSE +0 -0
  33. {draftwright-0.2.9 → draftwright-0.2.10}/skills/SKILL.md +0 -0
  34. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/__init__.py +0 -0
  35. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/annotate.py +0 -0
  36. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/annotations/__init__.py +0 -0
  37. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/annotations/orchestrator.py +0 -0
  38. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/annotations/sections.py +0 -0
  39. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/export.py +0 -0
  40. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/features.py +0 -0
  41. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/fits.py +0 -0
  42. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  43. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  44. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  45. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  46. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/fonts/__init__.py +0 -0
  47. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/intents.py +0 -0
  48. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/linting/issues.py +0 -0
  49. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/linting/structural.py +0 -0
  50. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/linting/suggest.py +0 -0
  51. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/make_drawing.py +0 -0
  52. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/model/planner.py +0 -0
  53. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/pmi.py +0 -0
  54. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/projection.py +0 -0
  55. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/recognition/__init__.py +0 -0
  56. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/recognition/_features.py +0 -0
  57. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/recognition/levels.py +0 -0
  58. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/recognition/slots.py +0 -0
  59. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/recognition/turned.py +0 -0
  60. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/registry.py +0 -0
  61. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/repair.py +0 -0
  62. {draftwright-0.2.9 → draftwright-0.2.10}/src/draftwright/sheet.py +0 -0
  63. {draftwright-0.2.9 → draftwright-0.2.10}/tests/_kernel.py +0 -0
  64. {draftwright-0.2.9 → draftwright-0.2.10}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  65. {draftwright-0.2.9 → draftwright-0.2.10}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  66. {draftwright-0.2.9 → draftwright-0.2.10}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  67. {draftwright-0.2.9 → draftwright-0.2.10}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  68. {draftwright-0.2.9 → draftwright-0.2.10}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  69. {draftwright-0.2.9 → draftwright-0.2.10}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  70. {draftwright-0.2.9 → draftwright-0.2.10}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  71. {draftwright-0.2.9 → draftwright-0.2.10}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  72. {draftwright-0.2.9 → draftwright-0.2.10}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  73. {draftwright-0.2.9 → draftwright-0.2.10}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  74. {draftwright-0.2.9 → draftwright-0.2.10}/tests/layout_snapshots/box.json +0 -0
  75. {draftwright-0.2.9 → draftwright-0.2.10}/tests/layout_snapshots/bracket.json +0 -0
  76. {draftwright-0.2.9 → draftwright-0.2.10}/tests/layout_snapshots/drive_screw_x.json +0 -0
  77. {draftwright-0.2.9 → draftwright-0.2.10}/tests/layout_snapshots/dshape.json +0 -0
  78. {draftwright-0.2.9 → draftwright-0.2.10}/tests/layout_snapshots/flange.json +0 -0
  79. {draftwright-0.2.9 → draftwright-0.2.10}/tests/layout_snapshots/holed_slot.json +0 -0
  80. {draftwright-0.2.9 → draftwright-0.2.10}/tests/layout_snapshots/plate_holes.json +0 -0
  81. {draftwright-0.2.9 → draftwright-0.2.10}/tests/layout_snapshots/side_drilled.json +0 -0
  82. {draftwright-0.2.9 → draftwright-0.2.10}/tests/layout_snapshots/slotted.json +0 -0
  83. {draftwright-0.2.9 → draftwright-0.2.10}/tests/layout_snapshots/turned_shaft.json +0 -0
  84. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_e2e_slice.py +0 -0
  85. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_e2e_standards.py +0 -0
  86. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_fits.py +0 -0
  87. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_gdt_placement.py +0 -0
  88. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_layout_cleanliness.py +0 -0
  89. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_layout_property.py +0 -0
  90. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_layout_snapshot.py +0 -0
  91. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_lint_structural.py +0 -0
  92. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_linting.py +0 -0
  93. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_object_aspects.py +0 -0
  94. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_part_model.py +0 -0
  95. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_recognition.py +0 -0
  96. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_registry.py +0 -0
  97. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_render_seam.py +0 -0
  98. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_sheet_gdt.py +0 -0
  99. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_sheet_of.py +0 -0
  100. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_tolerances.py +0 -0
  101. {draftwright-0.2.9 → draftwright-0.2.10}/tests/test_turned_steps.py +0 -0
@@ -1,5 +1,48 @@
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
+
3
46
  ## v0.2.9 — 2026-07-06
4
47
 
5
48
  **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.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
@@ -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.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
 
@@ -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.
@@ -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,6 +306,7 @@ 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.
@@ -369,10 +375,20 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
369
375
  feats = {c.name: c.feature for c in kept if c.feature is not None} # provenance (ADR 0010)
370
376
  sizes = {c.name: c.size for c in kept if c.size is not None} # real footprint (#61)
371
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)
372
379
  left = {
373
380
  n
374
381
  for n, _ in place_strip_candidates(
375
- dwg, strip, view, axis, pairs, tier, features=feats, sizes=sizes, forbid=forbid
382
+ dwg,
383
+ strip,
384
+ view,
385
+ axis,
386
+ pairs,
387
+ tier,
388
+ features=feats,
389
+ sizes=sizes,
390
+ forbid=forbid,
391
+ priorities=prio,
376
392
  )
377
393
  }
378
394
  force_pairs = [(c.name, c.build) for c in kept if c.name in left and c.force]
@@ -390,6 +406,7 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
390
406
  features=feats,
391
407
  sizes=sizes,
392
408
  forbid=forbid,
409
+ priorities=prio,
393
410
  )
394
411
  }
395
412
  if force_pairs
@@ -424,7 +441,18 @@ def drain_corridors(dwg):
424
441
 
425
442
 
426
443
  def place_strip_candidates(
427
- dwg, strip, view, axis, cands, tier, *, force=False, features=None, sizes=None, forbid=None
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,
428
456
  ):
429
457
  """Collect-then-solve placement of location/feature dims on one strip (ADR 0009).
430
458
  The single shared strip placer that retires the ``Strip.allocate`` cursor (#150,
@@ -450,6 +478,11 @@ def place_strip_candidates(
450
478
  frame, #61) sets it so :func:`plan_strip` enforces its true stacking gap — over
451
479
  capacity it is relocated to the next segment or dropped, never overlapped.
452
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
+
453
486
  ``force=True`` skips that corridor check — the caller's last resort when no view took
454
487
  the dim cleanly: keep it on its natural view and accept the (same-feature) leader
455
488
  crossing rather than drop a real dimension (policy B). Candidates that find no strip
@@ -520,6 +553,7 @@ def place_strip_candidates(
520
553
  f"{(k if inner == lo else len(take) - 1 - k):04d}",
521
554
  anch,
522
555
  (sizes or {}).get(nb[0], (tier, tier)),
556
+ priority=(priorities or {}).get(nb[0], 0.0),
523
557
  ),
524
558
  nb,
525
559
  )
@@ -20,7 +20,7 @@ from __future__ import annotations
20
20
 
21
21
  import math
22
22
 
23
- from build123d_drafting import DatumFeature, FeatureControlFrame, SurfaceFinish
23
+ from build123d_drafting import DatumFeature, FeatureControlFrame, SurfaceFinish, TextBlock
24
24
  from build123d_drafting.helpers import Centerline, CenterMark, HoleCallout, Leader, TitleBlock
25
25
 
26
26
  from draftwright._core import (
@@ -58,6 +58,7 @@ from draftwright.annotations._common import (
58
58
  strip_free_span,
59
59
  strip_obstacles,
60
60
  )
61
+ from draftwright.layout import StripCandidate, plan_strip
61
62
  from draftwright.model.ir import HoleFeature, PatternFeature
62
63
  from draftwright.model.planner import DimensionGroup, plan_locations
63
64
 
@@ -1294,10 +1295,29 @@ def render_rotational(dwg, model, a) -> int:
1294
1295
  left_edge = FX(a.bb.min.X)
1295
1296
  if left_edge - a.margin >= a.DIM_PAD:
1296
1297
  elbow_x = left_edge - a.DIM_PAD * 0.6
1297
- nb = len(rot.bores)
1298
1298
  pitch = max(10.0, draft.font_size * 3.0)
1299
+ # Bound the leader stack to the front-view height and space it via the shared
1300
+ # solve (#374). The old fixed `tip_z = cz + (i-(nb-1)/2)*pitch` had no bound,
1301
+ # so enough concentric bores overran the view (the CTC-02 defect shape). Each
1302
+ # leader keeps that same symmetric natural, so plan_strip reproduces the old
1303
+ # positions exactly when there is room (zero displacement) and only compresses /
1304
+ # drops (larger bore outranks smaller, priority=d) when the band is over capacity.
1305
+ nb = len(rot.bores)
1306
+ z_lo, z_hi = a.FV_Y - a.fv_hh, a.FV_Y + a.fv_hh
1307
+ cands = [
1308
+ StripCandidate(
1309
+ key=f"{i:03d}",
1310
+ anchor=(elbow_x, FZ(a.cz) + (i - (nb - 1) / 2) * pitch),
1311
+ size=(draft.font_size * 3, pitch),
1312
+ priority=d,
1313
+ )
1314
+ for i, d in enumerate(rot.bores)
1315
+ ]
1316
+ placed = plan_strip(cands, z_lo, z_hi, pitch, axis="y").placed
1299
1317
  for i, d in enumerate(rot.bores):
1300
- tip_z = FZ(a.cz) + (i - (nb - 1) / 2) * pitch
1318
+ tip_z = placed.get(f"{i:03d}")
1319
+ if tip_z is None:
1320
+ continue # over the front-view capacity — dropped (ranked), logged below
1301
1321
  dwg.add(
1302
1322
  Leader(
1303
1323
  tip=(FX(a.cx - d / 2), tip_z, 0),
@@ -1309,6 +1329,18 @@ def render_rotational(dwg, model, a) -> int:
1309
1329
  view="front",
1310
1330
  )
1311
1331
  n += 1
1332
+ dropped = [d for i, d in enumerate(rot.bores) if placed.get(f"{i:03d}") is None]
1333
+ for d in dropped:
1334
+ dwg._drop_callout_diam(
1335
+ d
1336
+ ) # exclude from coverage — else double-reported (#374 rev)
1337
+ if dropped:
1338
+ dwg._record_build_issue(
1339
+ "warning",
1340
+ "callout_dropped",
1341
+ f"{len(dropped)} concentric-bore diameter(s) {dropped} not annotated "
1342
+ "(front-view height full) — use a detail view",
1343
+ )
1312
1344
  else:
1313
1345
  _log.info(
1314
1346
  "Additional diameters %s not annotated (insufficient left margin)",
@@ -1781,12 +1813,17 @@ def render_pmi(dwg, model, a) -> int:
1781
1813
 
1782
1814
  # GD&T aspect side-layer (ADR 0011 §4, #61) — declared feature control frames / datum
1783
1815
  # feature symbols / surface finishes. Placed as first-class ADR 0009 corridor candidates,
1784
- # NOT through the dimension planner (their IR items carry no DimParameters).
1785
- _GDT_KINDS = ("control_frame", "datum_ref", "finish")
1816
+ # NOT through the dimension planner (their IR items carry no DimParameters). "note" is a
1817
+ # free-text manufacturing note (#488) — the same leader-into-a-strip mechanism, glyph = text.
1818
+ _GDT_KINDS = ("control_frame", "datum_ref", "finish", "note")
1786
1819
  # Outer run of the shared corridor ladder: GD&T frames tier BEYOND the feature-size
1787
1820
  # (_SIZE_SUBCHAIN=0) and datum-location (_LOC_SUBCHAIN=1) dim runs, so a frame never lands
1788
1821
  # mid-ladder among the dimensions it annotates.
1789
1822
  _GDT_SUBCHAIN = 2
1823
+ # Over-capacity survival rank for an authored GD&T frame (#357): a declared control frame /
1824
+ # datum / finish / note is deliberate intent, so on a strip too full for every candidate it is
1825
+ # kept over the auto dims (locations/slots, priority 0) rather than dropped by stacking-key order.
1826
+ _GDT_CORRIDOR_PRIORITY = 1.0
1790
1827
  # Minimum GD&T leader shaft length (page-mm). A zero-length Leader (site == solved tier)
1791
1828
  # makes OCC's edge builder raise; nudging to this keeps `_build` total (#61 review).
1792
1829
  _MIN_LEADER = 0.05
@@ -1807,6 +1844,8 @@ def _gdt_glyph(item, draft):
1807
1844
  )
1808
1845
  if item.kind == "datum_ref":
1809
1846
  return DatumFeature(item.letter, draft=draft)
1847
+ if item.kind == "note": # free-text manufacturing note (#488) — a single-line text glyph
1848
+ return TextBlock([item.text], position=(0.0, 0.0), draft=draft)
1810
1849
  return SurfaceFinish(item.ra, position=(0.0, 0.0), draft=draft)
1811
1850
 
1812
1851
 
@@ -1943,6 +1982,7 @@ def render_gdt(dwg, model, a) -> int:
1943
1982
  on_drop=_drop,
1944
1983
  dedup=None,
1945
1984
  precedence=0,
1985
+ priority=_GDT_CORRIDOR_PRIORITY, # authored intent outranks auto dims (#357)
1946
1986
  # A declared frame has no alternate view — force-keep (policy B) rather than
1947
1987
  # drop a user-authored annotation; only a physically full strip drops.
1948
1988
  force=True,
@@ -861,32 +861,71 @@ def _place_pitch_dim(dwg, a: Analysis, view, loc1, loc2, n, pitch, to_page, name
861
861
  else:
862
862
  pref = (-0.3, 1.0) if view == "plan" else (-0.3, -1.0)
863
863
  side, reach = max(cands, key=lambda c: c[0][0] * pref[0] + c[0][1] * pref[1])
864
- # stack further pitch dims in this view on outer tiers
864
+
865
+ def _place(off):
866
+ dwg.add(
867
+ _dim(
868
+ (p1[0], p1[1], 0),
869
+ (p2[0], p2[1], 0),
870
+ side,
871
+ off,
872
+ dwg.draft,
873
+ label=f"{n - 1}× {_fmt(pitch)}",
874
+ ),
875
+ name,
876
+ view=view,
877
+ feature=feature,
878
+ )
879
+
880
+ # Place onto the zone strip for the chosen side (#374): each side is its own strip, so the
881
+ # obstacle-aware carve stacks this dim clear of placed content — where an arbitrary-direction
882
+ # 1-D search would be defeated by a dim on a *different* side (rotated/two-axis grids). An
883
+ # axis-aligned side maps to a populated strip; a diagonal side, the side view's absent left
884
+ # strip, or a genuinely full strip fall through to the bounded vector placement below
885
+ # (behaviour unchanged for those residual cases).
886
+ # Only a GENUINELY axis-aligned row uses the strip carve: its dim is a clean horizontal /
887
+ # vertical line occupying one strip tier, and the coord bridge is exact. `_dim` places the
888
+ # line at `mid + side*distance`, so the witness is the MIDPOINT component on the strip axis and
889
+ # `sgn` the exact outward sign (±1); `distance = sgn*(pos - mid[axis])` then lands the line at
890
+ # the carved `pos`. The tight threshold (≈1.6°) keeps a tilted row off this path — its dim
891
+ # isn't axis-aligned, so it can't cleanly occupy a tier — routing it to the vector fallback.
892
+ zones = {"plan": a.pv_zones, "front": a.fv_zones, "side": a.sv_zones}[view]
893
+ sx, sy = side[0], side[1]
894
+ strip = axis = perp = None
895
+ witness = sgn = 0.0
896
+ if abs(sx) > 0.9996: # horizontal side ⟂ a vertical row → left/right strip, stacks along x
897
+ strip, axis, perp, witness, sgn = (
898
+ (zones.right if sx > 0 else zones.left),
899
+ "x",
900
+ tuple(sorted((p1[1], p2[1]))),
901
+ mid[0],
902
+ math.copysign(1.0, sx),
903
+ )
904
+ elif abs(sy) > 0.9996: # vertical side ⟂ a horizontal row → above/below strip, stacks along y
905
+ strip, axis, perp, witness, sgn = (
906
+ (zones.above if sy > 0 else zones.below),
907
+ "y",
908
+ tuple(sorted((p1[0], p2[0]))),
909
+ mid[1],
910
+ math.copysign(1.0, sy),
911
+ )
912
+ if strip is not None:
913
+ tier = max(10.0, dwg.draft.font_size * 3.0)
914
+ pos = carve_free_position(dwg, strip, view, axis, tier, perp)
915
+ if pos is not None:
916
+ _place(sgn * (pos - witness))
917
+ return
918
+
919
+ # Fallback: diagonal side / absent strip / full strip → the pre-#374 bounded vector placement,
920
+ # count-stacked and margin-guarded, so those residual cases are byte-identical to before.
865
921
  prior = sum(1 for nm, _ in dwg.iter_annotations() if nm.startswith(f"dim_pitch_{view}"))
866
922
  offset = reach + 8 + 10 * prior
867
- # never force-place: skip (and log) when the dim line would leave the page
868
923
  ox = mid[0] + side[0] * (offset + 6)
869
924
  oy = mid[1] + side[1] * (offset + 6)
870
925
  if not (a.margin <= ox <= a.PAGE_W - a.margin and a.margin <= oy <= a.PAGE_H - a.margin):
871
- _log.info(
872
- "Pitch dimension for the %s× %s array skipped (no room)",
873
- n,
874
- _fmt(pitch),
875
- )
926
+ _log.info("Pitch dimension for the %s× %s array skipped (no room)", n, _fmt(pitch))
876
927
  return
877
- dwg.add(
878
- _dim(
879
- (p1[0], p1[1], 0),
880
- (p2[0], p2[1], 0),
881
- side,
882
- offset,
883
- dwg.draft,
884
- label=f"{n - 1}× {_fmt(pitch)}",
885
- ),
886
- name,
887
- view=view,
888
- feature=feature,
889
- )
928
+ _place(offset)
890
929
 
891
930
 
892
931
  def build_view_of_axis(a: Analysis):
@@ -40,7 +40,15 @@ from draftwright.annotate import _auto_annotate, build_model, build_rotational_f
40
40
  from draftwright.annotations.sections import feature_hole_keys
41
41
  from draftwright.drawing import Drawing
42
42
  from draftwright.fonts import PLEX_MONO
43
- from draftwright.model import Datum, Feature, PartModel, StepFeature, display, plan_sections
43
+ from draftwright.model import (
44
+ Datum,
45
+ Feature,
46
+ PartModel,
47
+ StepFeature,
48
+ build_pmi_features,
49
+ display,
50
+ plan_sections,
51
+ )
44
52
  from draftwright.projection import (
45
53
  _fit_iso_view,
46
54
  _project_iso,
@@ -274,6 +282,15 @@ def _assemble(a, out, assembly, detail_view, auto_dims, model=None, decorations=
274
282
  rot = build_rotational_feature(a)
275
283
  if rot is not None:
276
284
  dwg._part_model = replace(pm, features=[*pm.features, rot])
285
+ # PMI (STEP AP242) is likewise detection-sourced, so a declared / emitted-script model
286
+ # carries none. When PMI annotation is on, synthesise the same PmiFeatures detection
287
+ # would (render_pmi reads them off the model, gated on a.pmi_mode) so a re-run reproduces
288
+ # the PMI dims (#472). Gated on the caller not having declared PMI, so an explicit set wins.
289
+ pm = dwg._part_model
290
+ if a.pmi_mode == "annotate" and not any(f.kind == "pmi" for f in pm.features):
291
+ pmi_feats = build_pmi_features(a.pmi, a.part.bounding_box())
292
+ if pmi_feats:
293
+ dwg._part_model = replace(pm, features=[*pm.features, *pmi_feats])
277
294
  dwg._model_declared = model is not None # ADR 0011 #448: gate model-driven hole render
278
295
 
279
296
  part_s = a.part.scale(a.SCALE)
@@ -168,34 +168,33 @@ def main(
168
168
  if style == "sheet":
169
169
  from draftwright.sheet_emit import generate_sheet_script, resolve_object_spec
170
170
 
171
- # The Sheet DSL doesn't yet model the title-block / layout aspects the
172
- # imperative script embeds. Warn rather than silently drop them so a
173
- # `--script --drawn-by … --scale …` invocation isn't quietly a no-op.
174
- ignored = [
175
- flag
176
- for flag, set_ in (
177
- ("--tolerance", tolerance != "ISO 2768-m"),
178
- ("--drawn-by", bool(drawn_by)),
179
- ("--scale", scale is not None),
180
- ("--page", page is not None),
181
- )
182
- if set_
183
- ]
184
- if ignored:
185
- typer.echo(
186
- f"warning: {', '.join(ignored)} not supported by --style sheet; ignored "
187
- "(use --style imperative to embed them)",
188
- err=True,
189
- )
171
+ # The Sheet DSL now carries the title-block / layout aspects (#474), so forward all
172
+ # four flags — the generated script reproduces them on re-run (no more inert warning).
190
173
  if _looks_like_object_spec(step_file):
191
174
  # STEP_FILE is a `module:attr` / `file.py:attr` spec → reference a live object
192
175
  obj, seam = resolve_object_spec(step_file)
193
176
  py_path = generate_sheet_script(
194
- obj, out=out, title=title, number=number, part_expr=seam
177
+ obj,
178
+ out=out,
179
+ title=title,
180
+ number=number,
181
+ tolerance=tolerance,
182
+ drawn_by=drawn_by,
183
+ scale=scale,
184
+ page=page,
185
+ part_expr=seam,
195
186
  )
196
187
  else:
197
188
  py_path = generate_sheet_script(
198
- step_file, out=out, title=title, number=number, pmi=pmi.value
189
+ step_file,
190
+ out=out,
191
+ title=title,
192
+ number=number,
193
+ tolerance=tolerance,
194
+ drawn_by=drawn_by,
195
+ scale=scale,
196
+ page=page,
197
+ pmi=pmi.value,
199
198
  )
200
199
  else:
201
200
  if _looks_like_object_spec(step_file):
@@ -70,6 +70,7 @@ from draftwright.linting import (
70
70
  LintIssue,
71
71
  _suggest_fix,
72
72
  lint_axial_coverage,
73
+ lint_declaration_reconciliation,
73
74
  lint_drawing,
74
75
  lint_feature_coverage,
75
76
  lint_location_coverage,
@@ -1649,6 +1650,12 @@ class Drawing:
1649
1650
  holes=holes,
1650
1651
  patterns=patterns,
1651
1652
  )
1653
+ # Reverse direction (#487): a DECLARED feature with no matching geometry (a stale
1654
+ # phantom callout). Only for a caller-supplied model — detection can't over-declare.
1655
+ # _part_model is typed `object` (deliberately loose, #397); read features duck-typed.
1656
+ if self._model_declared and self._part_model is not None:
1657
+ features = getattr(self._part_model, "features", ())
1658
+ issues += lint_declaration_reconciliation(features, cyls)
1652
1659
  issues += list(self._build_issues)
1653
1660
  # Attach a ready-to-paste fix snippet where one is computable (#29).
1654
1661
  # str | None — None when no concrete repair can be inferred.