draftwright 0.2.7__tar.gz → 0.2.9__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 (99) hide show
  1. {draftwright-0.2.7 → draftwright-0.2.9}/CHANGELOG.md +57 -0
  2. {draftwright-0.2.7 → draftwright-0.2.9}/PKG-INFO +36 -3
  3. {draftwright-0.2.7 → draftwright-0.2.9}/README.md +31 -1
  4. {draftwright-0.2.7 → draftwright-0.2.9}/pyproject.toml +18 -2
  5. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/_core.py +19 -2
  6. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/annotate.py +1 -0
  7. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/annotations/_common.py +69 -13
  8. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/annotations/from_model.py +181 -0
  9. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/annotations/orchestrator.py +22 -0
  10. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/builder.py +12 -1
  11. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/cli.py +52 -8
  12. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/model/__init__.py +14 -1
  13. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/model/declare.py +130 -0
  14. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/model/ir.py +70 -0
  15. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/sheet_dsl.py +209 -6
  16. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/sheet_emit.py +104 -9
  17. draftwright-0.2.9/tests/_kernel.py +19 -0
  18. draftwright-0.2.9/tests/test_gdt_placement.py +230 -0
  19. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_layout_snapshot.py +5 -0
  20. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_make_drawing.py +6 -0
  21. draftwright-0.2.9/tests/test_sheet_emit.py +541 -0
  22. draftwright-0.2.9/tests/test_sheet_gdt.py +188 -0
  23. draftwright-0.2.7/tests/test_sheet_emit.py +0 -199
  24. {draftwright-0.2.7 → draftwright-0.2.9}/.gitignore +0 -0
  25. {draftwright-0.2.7 → draftwright-0.2.9}/LICENSE +0 -0
  26. {draftwright-0.2.7 → draftwright-0.2.9}/skills/SKILL.md +0 -0
  27. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/__init__.py +0 -0
  28. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/analysis.py +0 -0
  29. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/annotations/__init__.py +0 -0
  30. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/annotations/holes.py +0 -0
  31. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/annotations/sections.py +0 -0
  32. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/drawing.py +0 -0
  33. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/export.py +0 -0
  34. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/features.py +0 -0
  35. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/fits.py +0 -0
  36. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  37. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  38. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  39. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  40. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/fonts/__init__.py +0 -0
  41. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/intents.py +0 -0
  42. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/layout.py +0 -0
  43. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/linting/__init__.py +0 -0
  44. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/linting/coverage.py +0 -0
  45. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/linting/issues.py +0 -0
  46. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/linting/structural.py +0 -0
  47. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/linting/suggest.py +0 -0
  48. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/make_drawing.py +0 -0
  49. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/model/detect.py +0 -0
  50. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/model/planner.py +0 -0
  51. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/pmi.py +0 -0
  52. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/projection.py +0 -0
  53. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/recognition/__init__.py +0 -0
  54. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/recognition/_features.py +0 -0
  55. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/recognition/levels.py +0 -0
  56. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/recognition/slots.py +0 -0
  57. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/recognition/turned.py +0 -0
  58. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/registry.py +0 -0
  59. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/repair.py +0 -0
  60. {draftwright-0.2.7 → draftwright-0.2.9}/src/draftwright/sheet.py +0 -0
  61. {draftwright-0.2.7 → draftwright-0.2.9}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  62. {draftwright-0.2.7 → draftwright-0.2.9}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  63. {draftwright-0.2.7 → draftwright-0.2.9}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  64. {draftwright-0.2.7 → draftwright-0.2.9}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  65. {draftwright-0.2.7 → draftwright-0.2.9}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  66. {draftwright-0.2.7 → draftwright-0.2.9}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  67. {draftwright-0.2.7 → draftwright-0.2.9}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  68. {draftwright-0.2.7 → draftwright-0.2.9}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  69. {draftwright-0.2.7 → draftwright-0.2.9}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  70. {draftwright-0.2.7 → draftwright-0.2.9}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  71. {draftwright-0.2.7 → draftwright-0.2.9}/tests/layout_snapshots/box.json +0 -0
  72. {draftwright-0.2.7 → draftwright-0.2.9}/tests/layout_snapshots/bracket.json +0 -0
  73. {draftwright-0.2.7 → draftwright-0.2.9}/tests/layout_snapshots/drive_screw_x.json +0 -0
  74. {draftwright-0.2.7 → draftwright-0.2.9}/tests/layout_snapshots/dshape.json +0 -0
  75. {draftwright-0.2.7 → draftwright-0.2.9}/tests/layout_snapshots/flange.json +0 -0
  76. {draftwright-0.2.7 → draftwright-0.2.9}/tests/layout_snapshots/holed_slot.json +0 -0
  77. {draftwright-0.2.7 → draftwright-0.2.9}/tests/layout_snapshots/plate_holes.json +0 -0
  78. {draftwright-0.2.7 → draftwright-0.2.9}/tests/layout_snapshots/side_drilled.json +0 -0
  79. {draftwright-0.2.7 → draftwright-0.2.9}/tests/layout_snapshots/slotted.json +0 -0
  80. {draftwright-0.2.7 → draftwright-0.2.9}/tests/layout_snapshots/turned_shaft.json +0 -0
  81. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_declare.py +0 -0
  82. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_e2e_slice.py +0 -0
  83. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_e2e_standards.py +0 -0
  84. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_fits.py +0 -0
  85. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_layout.py +0 -0
  86. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_layout_cleanliness.py +0 -0
  87. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_layout_property.py +0 -0
  88. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_lint_structural.py +0 -0
  89. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_linting.py +0 -0
  90. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_object_aspects.py +0 -0
  91. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_part_model.py +0 -0
  92. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_pmi.py +0 -0
  93. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_recognition.py +0 -0
  94. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_registry.py +0 -0
  95. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_render_seam.py +0 -0
  96. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_sheet_of.py +0 -0
  97. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_strip_layout.py +0 -0
  98. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_tolerances.py +0 -0
  99. {draftwright-0.2.7 → draftwright-0.2.9}/tests/test_turned_steps.py +0 -0
@@ -1,5 +1,62 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.9 — 2026-07-06
4
+
5
+ **Declarative GD&T, datums, and surface finish (ADR 0011 Phase 2b/2c).** The `Sheet` DSL can
6
+ now author the drawing information geometry can't carry — geometric tolerances, datum feature
7
+ symbols, and surface-finish marks — by pointing at a build123d feature or face. They render
8
+ through the auto-layout engine as first-class ADR 0009 corridor candidates, placed and spaced
9
+ crossing-free alongside the automatic dimensions (not a post-hoc overlay).
10
+
11
+ ```python
12
+ sheet.datum("A", base_face)
13
+ sheet.control(bore).position(0.1, to="A", diameter=True).perpendicularity(0.05, to="A")
14
+ sheet.diameter(journal).finish("0.8")
15
+ ```
16
+
17
+ ### Added
18
+
19
+ - **Feature control frames (ISO 1101)** — `sheet.control(ref)` returns a chainable builder with
20
+ one method per **all 14** characteristics (`position`, `flatness`, `perpendicularity`,
21
+ `cylindricity`, `circular_runout`, `total_runout`, …); `to=` names the referenced datum(s),
22
+ `diameter=` prefixes the ⌀ tolerance zone, `modifier=` adds a material-condition symbol (Ⓜ/Ⓛ/Ⓟ).
23
+ - **Datum feature symbols (ISO 5459)** — `sheet.datum("A", face_or_feature)`.
24
+ - **Surface finish (ISO 1302, Ra)** — `.finish("1.6")` on a hole / diameter / step handle, or
25
+ `sheet.finish(ra, face)`.
26
+ - The target view + strip side are **derived from the referenced geometry** (a feature's axis →
27
+ face-on view, a planar face's normal → edge-on view); `view=`/`side=` override.
28
+ - Render core: `ControlFrame` / `DatumRef` / `Finish` IR features placed by `render_gdt` as
29
+ first-class corridor candidates, with the real-footprint plumbing needed to space wide frames.
30
+
31
+ ### Fixed
32
+
33
+ - A control frame referencing an **undeclared datum letter** now warns at build.
34
+ - GD&T placement is **title-block aware** and **falls through to the opposite strip side** before
35
+ dropping a frame, so stacked control frames place robustly rather than vanishing or overlapping
36
+ the title block.
37
+ - An invalid glyph spec / degenerate leader in a caller-supplied model **drops the one item with a
38
+ warning** instead of crashing the whole build (the IR is public input).
39
+ - Turned-shaft rotational furniture now reproduces correctly through the declared-model path
40
+ (`model=` / `Sheet`) (#476).
41
+
42
+ ## v0.2.8 — 2026-07-06
43
+
44
+ **`--script` now defaults to the declarative `Sheet` DSL.** The editable script the CLI
45
+ writes is the beautiful-Python surface — one commentable line per feature — for both STEP and
46
+ build123d-object input.
47
+
48
+ ### Changed
49
+
50
+ - **`draftwright … --script` now emits the declarative `Sheet` script by default** (was the
51
+ imperative edit-verb reconstruction). The imperative reconstruction is still available via
52
+ `--script --style imperative`. For prismatic parts the generated script reproduces the same
53
+ drawing as a direct build; a turned shaft's centre lines and base-diameter style are not yet
54
+ identical (the remaining validity gap, tracked separately). `--style imperative` now errors
55
+ clearly on a `module:attr` object spec (it reads a STEP file; use the default `sheet`).
56
+ The Sheet DSL doesn't yet model the title-block / layout aspects the imperative script
57
+ embeds, so `--script --style sheet` **warns** when `--drawn-by`, `--tolerance`, `--scale`
58
+ or `--page` is set rather than silently ignoring it (use `--style imperative` to embed them).
59
+
3
60
  ## v0.2.7 — 2026-07-05
4
61
 
5
62
  **The three authoring modes (ADR 0011 Amendment 1).** Naming how you drive draftwright —
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.2.7
3
+ Version: 0.2.9
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
@@ -678,10 +678,13 @@ Classifier: Programming Language :: Python :: 3
678
678
  Classifier: Programming Language :: Python :: 3.10
679
679
  Classifier: Programming Language :: Python :: 3.11
680
680
  Classifier: Programming Language :: Python :: 3.12
681
+ Classifier: Programming Language :: Python :: 3.13
682
+ Classifier: Programming Language :: Python :: 3.14
681
683
  Classifier: Topic :: Scientific/Engineering
682
684
  Requires-Python: <3.15,>=3.10
683
685
  Requires-Dist: build123d-drafting-helpers>=0.13.0
684
- Requires-Dist: build123d>=0.9.0
686
+ Requires-Dist: build123d<0.11,>=0.9.0; python_full_version < '3.13'
687
+ Requires-Dist: build123d<0.12,>=0.11; python_full_version >= '3.13'
685
688
  Requires-Dist: kiwisolver<2,>=1.4
686
689
  Requires-Dist: numpy>=1.24
687
690
  Requires-Dist: reportlab>=4.0
@@ -729,7 +732,7 @@ Choose formats, scale, and page; or emit an editable drawing script:
729
732
  ```
730
733
  draftwright my_part.step --format pdf,dxf # also: svg, all
731
734
  draftwright my_part.step --scale 2 --page A3 # override the auto scale / page
732
- draftwright my_part.step --script # write an editable .py drawing script
735
+ draftwright my_part.step --script # write an editable declarative Sheet script
733
736
  ```
734
737
 
735
738
  `draftwright --help` lists every flag; `--version` prints the version.
@@ -766,6 +769,36 @@ issues = dwg.lint() # list[LintIssue] — coverage, page b
766
769
  svg_path, dxf_path = dwg.export("my_part")
767
770
  ```
768
771
 
772
+ ### Declarative drawings — reference features, declare intent
773
+
774
+ Instead of relying on detection, **reference the build123d objects you built** and declare
775
+ only the drawing intent — geometry supplies the sizes (⌀ read off the object), you supply
776
+ tolerances, fits, datums, GD&T, and surface finish. The fluent `Sheet` skips detection and
777
+ dimensions exactly what you declare (ADR 0011):
778
+
779
+ ```python
780
+ from build123d import Box, Cylinder, Pos
781
+ from draftwright import Sheet
782
+
783
+ plate = Box(120, 80, 20)
784
+ bore = Pos(0, 0, 0) * Cylinder(4, 20)
785
+
786
+ sheet = Sheet(plate - bore, title="Plate", number="DWG-002")
787
+ sheet.envelope()
788
+ 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
790
+ sheet.control(hole).position(0.1, to="A", diameter=True) # ⌀0.1 position wrt A
791
+ sheet.export("plate") # writes plate.svg + plate.dxf
792
+ ```
793
+
794
+ Every aspect the geometry can't carry is a declared verb: `.tolerance(±)` / `.fit("H7")`
795
+ (ISO 286), `.finish("Ra")` (ISO 1302), `sheet.datum(letter, ref)` (ISO 5459), and
796
+ `sheet.control(ref)` with all 14 ISO 1101 characteristics
797
+ (`.position`/`.flatness`/`.perpendicularity`/`.circular_runout`/…). 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
+
769
802
  ## What it produces
770
803
 
771
804
  - **Three orthographic views** (front, plan, side) sized and scaled automatically to the
@@ -38,7 +38,7 @@ Choose formats, scale, and page; or emit an editable drawing script:
38
38
  ```
39
39
  draftwright my_part.step --format pdf,dxf # also: svg, all
40
40
  draftwright my_part.step --scale 2 --page A3 # override the auto scale / page
41
- draftwright my_part.step --script # write an editable .py drawing script
41
+ draftwright my_part.step --script # write an editable declarative Sheet script
42
42
  ```
43
43
 
44
44
  `draftwright --help` lists every flag; `--version` prints the version.
@@ -75,6 +75,36 @@ 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") # ⌀8 bore, Ra 1.6
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`/…). A feature verb returns
104
+ a chainable handle (`sheet.hole(bore)`) that the aspect and `control(...)` verbs decorate;
105
+ targets are placed automatically — the view and strip are derived from the referenced
106
+ feature or face, with `view=`/`side=` overrides.
107
+
78
108
  ## What it produces
79
109
 
80
110
  - **Three orthographic views** (front, plan, side) sized and scaled automatically to the
@@ -4,14 +4,19 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.2.7"
7
+ version = "0.2.9"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
11
11
  authors = [{ name = "Paul Fremantle", email = "pzfreo@gmail.com" }]
12
12
  requires-python = ">=3.10,<3.15"
13
13
  dependencies = [
14
- "build123d>=0.9.0",
14
+ # build123d0.10 pulls cadquery-ocp (VTK), whose VTK caps at Python 3.12; 0.11 switched to
15
+ # cadquery-ocp-novtk, which ships 3.13/3.14 wheels and drops VTK entirely (draftwright is
16
+ # headless — it never imports VTK). Split by Python: keep the proven ≤3.12 stack, use 0.11 on
17
+ # 3.13+. Mirrors pzfreo/build123d-mcp's dependency handling.
18
+ "build123d>=0.9.0,<0.11 ; python_full_version < '3.13'",
19
+ "build123d>=0.11,<0.12 ; python_full_version >= '3.13'",
15
20
  "build123d-drafting-helpers>=0.13.0",
16
21
  "kiwisolver>=1.4,<2",
17
22
  "numpy>=1.24",
@@ -30,6 +35,8 @@ classifiers = [
30
35
  "Programming Language :: Python :: 3.10",
31
36
  "Programming Language :: Python :: 3.11",
32
37
  "Programming Language :: Python :: 3.12",
38
+ "Programming Language :: Python :: 3.13",
39
+ "Programming Language :: Python :: 3.14",
33
40
  ]
34
41
 
35
42
  [project.scripts]
@@ -112,3 +119,12 @@ dev = [
112
119
  "pytest-xdist>=3",
113
120
  "ruff>=0.4",
114
121
  ]
122
+
123
+ [tool.uv]
124
+ # On Python 3.13/3.14, build123d resolves to 0.11, whose OCP kernel is
125
+ # cadquery-ocp-novtk (VTK dropped from the transitive tree — fine here, draftwright
126
+ # renders headless and never imports VTK). Its 7.9.3.1.1 release ships a broken macOS
127
+ # wheel (no OCP.GccEnt module → build123d fails to import), so pin it out. Only bites
128
+ # when novtk is actually resolved (build123d 0.11); harmless on 0.10/cadquery-ocp.
129
+ # Mirrors pzfreo/build123d-mcp. Remove once upstream fixes the wheel.
130
+ constraint-dependencies = ["cadquery-ocp-novtk!=7.9.3.1.1"]
@@ -557,8 +557,11 @@ def _attribution_author(drawn_by: str | None) -> str:
557
557
  return f"{author} / draftwright" if author else "draftwright"
558
558
 
559
559
 
560
- def _add_title_block(dwg, a: Analysis):
561
- """Add the title block annotation."""
560
+ def _make_title_block(dwg, a: Analysis):
561
+ """Construct + page-locate the title block, returning ``(tb, cell)`` where *cell* is its
562
+ drawn-by cell bbox (for the hyperlink rect). Shared by :func:`_add_title_block` (which adds
563
+ it, last) and :func:`_title_block_box` (which measures its footprint for GD&T avoidance, #481)
564
+ so the two never drift."""
562
565
  tb = TitleBlock(
563
566
  a.title,
564
567
  a.number,
@@ -582,6 +585,20 @@ def _add_title_block(dwg, a: Analysis):
582
585
  # TitleBlock layout change. Build-frame bbox; translated to page space below.
583
586
  cell = tb.drawn_by_cell_bbox()
584
587
  tb = tb.locate(Location((a.PAGE_W - a.TB_W - _TB_CLEAR, _TB_CLEAR, 0)))
588
+ return tb, cell
589
+
590
+
591
+ def _title_block_box(dwg, a: Analysis):
592
+ """The title block's real page-space bbox ``(x0, y0, x1, y1)``. GD&T placement avoids it
593
+ (#481): the block is added last, so strip placement can't see it, but it's deterministic."""
594
+ tb, _ = _make_title_block(dwg, a)
595
+ b = tb.bounding_box()
596
+ return (b.min.X, b.min.Y, b.max.X, b.max.Y)
597
+
598
+
599
+ def _add_title_block(dwg, a: Analysis):
600
+ """Add the title block annotation."""
601
+ tb, cell = _make_title_block(dwg, a)
585
602
  dwg.add(tb, "title_block")
586
603
 
587
604
  # Record that cell's page-space rectangle so export() can place a clickable
@@ -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
  )
@@ -305,6 +305,16 @@ class CorridorCandidate:
305
305
  # The source IR feature this dim was rendered for — recorded as provenance when the
306
306
  # dim is placed at drain (ADR 0010). ``None`` leaves the annotation feature-less.
307
307
  feature: object | None = None
308
+ # Real stacking-axis + perpendicular footprint ``(w, h)`` in page-mm, or ``None`` to
309
+ # use the dimension default ``(tier, tier)``. Wide/tall occupants (a GD&T feature
310
+ # control frame is ~24×6 mm) set this so the strip solve reserves their true extent
311
+ # instead of one label-height (ADR 0009 real-footprint plumbing, #61). A dim leaves
312
+ # it ``None`` — byte-identical to the pre-plumbing placement.
313
+ size: tuple | None = None
314
+ # An ``(x0, y0, x1, y1)`` page-box this candidate must NOT overlap even when force-kept —
315
+ # the title block, which is placed after the corridor drain so the strip carve can't see
316
+ # it (#481). ``None`` (every dim) skips the check → byte-identical.
317
+ forbid: object | None = None
308
318
 
309
319
 
310
320
  def solve_corridor(dwg, strip, view, axis, cands, tier):
@@ -357,15 +367,29 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
357
367
  return
358
368
  pairs = [(c.name, c.build) for c in kept]
359
369
  feats = {c.name: c.feature for c in kept if c.feature is not None} # provenance (ADR 0010)
370
+ sizes = {c.name: c.size for c in kept if c.size is not None} # real footprint (#61)
371
+ forbid = {c.name: c.forbid for c in kept if c.forbid is not None} # title-block box (#481)
360
372
  left = {
361
- n for n, _ in place_strip_candidates(dwg, strip, view, axis, pairs, tier, features=feats)
373
+ n
374
+ for n, _ in place_strip_candidates(
375
+ dwg, strip, view, axis, pairs, tier, features=feats, sizes=sizes, forbid=forbid
376
+ )
362
377
  }
363
378
  force_pairs = [(c.name, c.build) for c in kept if c.name in left and c.force]
364
379
  still = (
365
380
  {
366
381
  n
367
382
  for n, _ in place_strip_candidates(
368
- dwg, strip, view, axis, force_pairs, tier, force=True, features=feats
383
+ dwg,
384
+ strip,
385
+ view,
386
+ axis,
387
+ force_pairs,
388
+ tier,
389
+ force=True,
390
+ features=feats,
391
+ sizes=sizes,
392
+ forbid=forbid,
369
393
  )
370
394
  }
371
395
  if force_pairs
@@ -399,7 +423,9 @@ def drain_corridors(dwg):
399
423
  dwg._corridor_batch = {}
400
424
 
401
425
 
402
- def place_strip_candidates(dwg, strip, view, axis, cands, tier, *, force=False, features=None):
426
+ def place_strip_candidates(
427
+ dwg, strip, view, axis, cands, tier, *, force=False, features=None, sizes=None, forbid=None
428
+ ):
403
429
  """Collect-then-solve placement of location/feature dims on one strip (ADR 0009).
404
430
  The single shared strip placer that retires the ``Strip.allocate`` cursor (#150,
405
431
  P3): each candidate in *cands* — an ``(name, build(pos)->dim)`` pair — is spaced by
@@ -418,6 +444,12 @@ def place_strip_candidates(dwg, strip, view, axis, cands, tier, *, force=False,
418
444
  1-D strip carve cannot represent: a leader in that corridor is crossed no matter how
419
445
  far out the dim line lands. By default such a placement is rejected so the caller can
420
446
  route the dim to the other view (its disjoint block cannot cross this leader).
447
+
448
+ *sizes* maps a candidate's name to its real page-mm footprint ``(w, h)``; absent
449
+ names use the dimension default ``(tier, tier)``. A wide/tall occupant (a GD&T
450
+ frame, #61) sets it so :func:`plan_strip` enforces its true stacking gap — over
451
+ capacity it is relocated to the next segment or dropped, never overlapped.
452
+
421
453
  ``force=True`` skips that corridor check — the caller's last resort when no view took
422
454
  the dim cleanly: keep it on its natural view and accept the (same-feature) leader
423
455
  crossing rather than drop a real dimension (policy B). Candidates that find no strip
@@ -426,17 +458,31 @@ def place_strip_candidates(dwg, strip, view, axis, cands, tier, *, force=False,
426
458
  if strip is None or not cands:
427
459
  return list(cands)
428
460
  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.
461
+ idx = 1 if axis == "y" else 0
462
+
463
+ # Reserve the outermost label's OUTWARD extent at the strip boundary. plan_strip bounds
464
+ # the dim-LINE position, but the label extends outward from it so without this the last
465
+ # tier's label overshoots outer_limit (into the iso view / page margin), unlike the old
466
+ # Strip.allocate which checked `start + tier <= outer_limit` (#338 review). A plain dim's
467
+ # label extends one `tier` outward (one-sided). A GD&T glyph (#61) hangs off a Leader that
468
+ # CENTRES it on the elbow for an above/below strip (real outward extent = height/2) but
469
+ # places it one-sided for a left/right strip (extent = full width). Reserve the MAX real
470
+ # outward extent among these candidates — else a glyph wider than `tier` renders off the
471
+ # sheet (annotation_out_of_bounds) instead of dropping when the strip is too narrow (ADR
472
+ # 0009 Amdt 7 fixed inter-candidate gaps but not this edge). With no `sizes` (every dim)
473
+ # this is `tier`, byte-identical. The strip edge is not an obstacle (obstacles carry their
474
+ # own footprint + pad), so only the boundary needs it.
475
+ def _outward(name):
476
+ sz = (sizes or {}).get(name)
477
+ if sz is None:
478
+ return tier # a dim: one-sided tier reservation (unchanged)
479
+ return sz[idx] if axis == "x" else sz[idx] / 2 # GD&T: one-sided (L/R) vs centred (A/B)
480
+
481
+ reserve = max([tier, *(_outward(n) for n, _ in cands)])
435
482
  if inner == lo:
436
- hi -= tier
483
+ hi -= reserve
437
484
  else:
438
- lo += tier
439
- idx = 1 if axis == "y" else 0
485
+ lo += reserve
440
486
  perp = 0 if axis == "y" else 1 # the axis the dims do NOT stack along
441
487
  pad = tier + strip.spacing # min separation between stacked dim lines
442
488
  # Perpendicular band of these candidates. The 1-D carve projects obstacles onto the
@@ -471,7 +517,9 @@ def place_strip_candidates(dwg, strip, view, axis, cands, tier, *, force=False,
471
517
  triples = [
472
518
  (
473
519
  StripCandidate(
474
- f"{(k if inner == lo else len(take) - 1 - k):04d}", anch, (tier, tier)
520
+ f"{(k if inner == lo else len(take) - 1 - k):04d}",
521
+ anch,
522
+ (sizes or {}).get(nb[0], (tier, tier)),
475
523
  ),
476
524
  nb,
477
525
  )
@@ -487,6 +535,14 @@ def place_strip_candidates(dwg, strip, view, axis, cands, tier, *, force=False,
487
535
  if not force and _box_hits(_geom_box(dim), blockers): # corridor crosses a leader
488
536
  todo.append((name, build))
489
537
  continue
538
+ # A forbidden box (the title block, #481) is rejected even under force — it is
539
+ # placed after the drain, so the strip carve can't see it; a force-kept GD&T frame
540
+ # must still not stack onto it. `forbid` maps names to their box (only GD&T sets it,
541
+ # so dims are byte-identical). Returned unplaced → the caller's on_drop fallthrough.
542
+ fb = (forbid or {}).get(name)
543
+ if fb is not None and _box_hits(_geom_box(dim), (fb,)):
544
+ todo.append((name, build))
545
+ continue
490
546
  # Record feature provenance (ADR 0010): the drain-time seam for corridor-placed
491
547
  # dims — `features` maps this batch's names to their source IR feature.
492
548
  dwg.add(dim, name, view=view, feature=(features or {}).get(name))
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
 
21
21
  import math
22
22
 
23
+ from build123d_drafting import DatumFeature, FeatureControlFrame, SurfaceFinish
23
24
  from build123d_drafting.helpers import Centerline, CenterMark, HoleCallout, Leader, TitleBlock
24
25
 
25
26
  from draftwright._core import (
@@ -41,6 +42,7 @@ from draftwright._core import (
41
42
  _legible_steps,
42
43
  _log,
43
44
  _solve_strip_ys,
45
+ _title_block_box,
44
46
  _tol_suffix,
45
47
  )
46
48
  from draftwright.annotations._common import (
@@ -1775,3 +1777,182 @@ def render_pmi(dwg, model, a) -> int:
1775
1777
 
1776
1778
  _log.info("PMI annotate: %d/%d dims placed", emitted, len(usable))
1777
1779
  return emitted
1780
+
1781
+
1782
+ # GD&T aspect side-layer (ADR 0011 §4, #61) — declared feature control frames / datum
1783
+ # 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")
1786
+ # Outer run of the shared corridor ladder: GD&T frames tier BEYOND the feature-size
1787
+ # (_SIZE_SUBCHAIN=0) and datum-location (_LOC_SUBCHAIN=1) dim runs, so a frame never lands
1788
+ # mid-ladder among the dimensions it annotates.
1789
+ _GDT_SUBCHAIN = 2
1790
+ # Minimum GD&T leader shaft length (page-mm). A zero-length Leader (site == solved tier)
1791
+ # makes OCC's edge builder raise; nudging to this keeps `_build` total (#61 review).
1792
+ _MIN_LEADER = 0.05
1793
+
1794
+
1795
+ def _gdt_glyph(item, draft):
1796
+ """Build the ISO 1101/5459/1302 glyph sketch for one GD&T IR item at the origin
1797
+ (the :class:`Leader` repositions it). A fresh sketch per call — the leader translate
1798
+ must not alias a shared object across the strip solve's repeated probe builds."""
1799
+ if item.kind == "control_frame":
1800
+ return FeatureControlFrame(
1801
+ item.characteristic,
1802
+ item.tolerance,
1803
+ datums=item.datums,
1804
+ draft=draft,
1805
+ diameter=item.diameter,
1806
+ modifier=item.modifier,
1807
+ )
1808
+ if item.kind == "datum_ref":
1809
+ return DatumFeature(item.letter, draft=draft)
1810
+ return SurfaceFinish(item.ra, position=(0.0, 0.0), draft=draft)
1811
+
1812
+
1813
+ def render_gdt(dwg, model, a) -> int:
1814
+ """Place declared GD&T frames / datum symbols / surface finishes (#61) as first-class
1815
+ ADR 0009 corridor candidates — registered into the SAME strip the feature's dimensions
1816
+ use, BEFORE ``drain_corridors``, so one solve orders and spaces them crossing-free with
1817
+ the dims (not a leftover first-fit like :func:`render_pmi`). Each item carries its target
1818
+ ``(view, side)`` strip + model-space site; the leader hangs the glyph off the site into
1819
+ that strip. The strip footprint is the GLYPH's own box — NOT the leader+glyph box, whose
1820
+ shaft back to the feature would inflate the stacking extent (the same reason dims reserve
1821
+ one label-height). Cross-view separation is the compose-then-pack repack's job (ADR 0004):
1822
+ every placed frame is ``view=``-tagged, so ``_measure_blocks`` folds it into the block.
1823
+ Returns the count registered."""
1824
+ items = [f for f in model.features if f.kind in _GDT_KINDS]
1825
+ if not items:
1826
+ return 0
1827
+ draft = dwg.draft
1828
+ tier = draft.font_size + 2 * draft.pad_around_text
1829
+ # (zones, h-projector, v-projector, h-model-index, v-model-index) per view.
1830
+ views = {
1831
+ "plan": (a.pv_zones, a.proj.plan_x, a.proj.plan_y, 0, 1),
1832
+ "front": (a.fv_zones, a.proj.front_x, a.proj.front_z, 0, 2),
1833
+ "side": (a.sv_zones, a.proj.side_x, a.proj.side_z, 1, 2),
1834
+ }
1835
+ # The title block (bottom-right) is added AFTER drain_corridors, so strip placement can't
1836
+ # see it — a below/right strip runs down into its region. Its box is deterministic, so
1837
+ # reject any GD&T placement that would land on it (BOTH the primary corridor path, via the
1838
+ # candidate's `forbid`, AND the fallthrough) else the frame overlaps 'DRAWING' (#481 review).
1839
+ tb_box = _title_block_box(dwg, a)
1840
+ n = 0
1841
+ for i, item in enumerate(items):
1842
+ name = f"m_gdt{i}"
1843
+ vk = views.get(item.view)
1844
+ if vk is None or item.side not in ("above", "below", "left", "right"):
1845
+ dwg._record_build_issue(
1846
+ "warning", "gdt_dropped", f"{name}: bad target {item.view!r}/{item.side!r}"
1847
+ )
1848
+ continue
1849
+ zones, hproj, vproj, hi, vi = vk
1850
+ strip = getattr(zones, item.side)
1851
+ o = item.frame.origin
1852
+ px, py = hproj(o[hi]), vproj(o[vi])
1853
+ horizontal = item.side in ("above", "below") # frame stacks along y
1854
+ axis = "y" if horizontal else "x"
1855
+ # The IR is public input (ADR 0011), so an invalid glyph spec (a mistyped
1856
+ # characteristic, a bad tolerance) must drop THIS item with a warning — never crash
1857
+ # the whole drawing build. The helper raises on a bad spec; catch it at the measure
1858
+ # (the first build) and drop. `_build` below re-runs `_gdt_glyph` with the same args
1859
+ # (so a spec error can't reappear there) AND is made total against the OTHER raise
1860
+ # source — a zero-length Leader shaft (see the min-leader guard in `_build`).
1861
+ try:
1862
+ gb = _gdt_glyph(item, draft).bounding_box().size
1863
+ except Exception as e: # noqa: BLE001 — any glyph-spec error drops one item, not the build
1864
+ dwg._record_build_issue(
1865
+ "warning", "gdt_dropped", f"{name}: cannot render ({type(e).__name__}: {e})"
1866
+ )
1867
+ continue
1868
+ size = (gb.X, gb.Y)
1869
+
1870
+ def _build(pos, _px=px, _py=py, _hz=horizontal, _it=item):
1871
+ g = _gdt_glyph(_it, draft)
1872
+ tip = (_px, _py)
1873
+ # A zero-length leader shaft (the projected site coincides with the solved tier —
1874
+ # `pos == py` above/below, `pos == px` left/right) makes OCC's edge builder raise,
1875
+ # which would crash the whole build on a public-IR declaration. Guarantee a
1876
+ # minimum shaft along the stacking axis (nudge outward; 0.05 mm is invisible) so
1877
+ # `_build` is total — the drop-don't-crash invariant holds for every build() call.
1878
+ if _hz:
1879
+ dy = pos - _py
1880
+ pos = (
1881
+ pos if abs(dy) >= _MIN_LEADER else _py + math.copysign(_MIN_LEADER, dy or 1.0)
1882
+ )
1883
+ elbow = (_px, pos)
1884
+ else:
1885
+ dx = pos - _px
1886
+ pos = (
1887
+ pos if abs(dx) >= _MIN_LEADER else _px + math.copysign(_MIN_LEADER, dx or 1.0)
1888
+ )
1889
+ elbow = (pos, _py)
1890
+ return Leader(tip=tip, elbow=elbow, label="", draft=draft, callout=g)
1891
+
1892
+ def _drop(
1893
+ nm,
1894
+ _v=item.view,
1895
+ _s=item.side,
1896
+ _zones=zones,
1897
+ _px=px,
1898
+ _py=py,
1899
+ _hz=horizontal,
1900
+ _sz=size,
1901
+ _bld=_build,
1902
+ _feat=item.origin,
1903
+ _tb=tb_box,
1904
+ ):
1905
+ # Fallthrough (#481): the declared/derived side is full — try the OPPOSITE side of
1906
+ # the same view before dropping, so a congested default still places somewhere
1907
+ # legible rather than vanishing. Best-effort (mirrors render_slots' below-fallthrough):
1908
+ # carve a free tier on the alternate strip and place there; carve_free_position only
1909
+ # ever sees already-placed annotations, so it can't collide with a not-yet-drained
1910
+ # sibling corridor. Force semantics (no corridor-cross check) match the primary path,
1911
+ # BUT reject a spot over the (not-yet-placed) title block — a below/right strip runs
1912
+ # into it, and carve can't see it (#481 review).
1913
+ alt = {"above": "below", "below": "above", "left": "right", "right": "left"}[_s]
1914
+ alt_strip = getattr(_zones, alt, None)
1915
+ if alt_strip is not None:
1916
+ axis2 = "y" if alt in ("above", "below") else "x"
1917
+ extent = _sz[1] if axis2 == "y" else _sz[0] # the glyph's stacking-axis size
1918
+ perp = (_px, _px + _sz[0]) if _hz else (_py - _sz[1] / 2, _py + _sz[1] / 2)
1919
+ pos = carve_free_position(dwg, alt_strip, _v, axis2, max(tier, extent), perp)
1920
+ if pos is not None:
1921
+ dim = _bld(pos)
1922
+ if not _box_hits(_anno_box(dim), (_tb,)): # clear of the title block
1923
+ dwg.add(dim, nm, view=_v, feature=_feat) # placed on the alternate side
1924
+ return
1925
+ dwg._record_build_issue(
1926
+ "warning",
1927
+ "gdt_dropped",
1928
+ f"{nm} not placed (no room in the {_v} {_s} strip or its opposite)",
1929
+ )
1930
+
1931
+ register_corridor(
1932
+ dwg,
1933
+ (item.view, item.side),
1934
+ strip,
1935
+ item.view,
1936
+ axis,
1937
+ tier,
1938
+ CorridorCandidate(
1939
+ name=name,
1940
+ build=_build,
1941
+ order=(_GDT_SUBCHAIN, px if horizontal else py, name),
1942
+ on_place=lambda nm: None,
1943
+ on_drop=_drop,
1944
+ dedup=None,
1945
+ precedence=0,
1946
+ # A declared frame has no alternate view — force-keep (policy B) rather than
1947
+ # drop a user-authored annotation; only a physically full strip drops.
1948
+ force=True,
1949
+ feature=item.origin, # provenance (ADR 0010): the decorated feature
1950
+ size=size,
1951
+ # Even a force-kept frame must not stack into the title block (#481 review) —
1952
+ # place_strip_candidates rejects a placement hitting this box, then on_drop's
1953
+ # fallthrough tries the other side.
1954
+ forbid=tb_box,
1955
+ ),
1956
+ )
1957
+ n += 1
1958
+ return n
@@ -39,6 +39,7 @@ from draftwright.annotations.from_model import (
39
39
  render_centermarks,
40
40
  render_diameters,
41
41
  render_envelope,
42
+ render_gdt,
42
43
  render_height_ladder,
43
44
  render_locations,
44
45
  render_pmi,
@@ -59,8 +60,10 @@ from draftwright.annotations.sections import (
59
60
  feature_holes_of,
60
61
  )
61
62
  from draftwright.model import (
63
+ Frame,
62
64
  HoleFeature,
63
65
  PatternFeature,
66
+ RotationalFeature,
64
67
  build_part_model,
65
68
  plan_dimensions,
66
69
  plan_sections,
@@ -130,6 +133,20 @@ def build_model(a: Analysis):
130
133
  )
131
134
 
132
135
 
136
+ def build_rotational_feature(a: Analysis):
137
+ """The rotational furniture feature (OD + centrelines + concentric bores) for *a*,
138
+ or ``None`` when the part isn't rotational. Mirrors the ``rotational`` branch of
139
+ :func:`build_model` / ``detect.build_part_model`` (detect.py) so the declared-model
140
+ path can synthesise the same furniture detection produces: a declared turned shaft
141
+ otherwise renders with no centrelines and its OD as a leader, not a dimension (#472).
142
+ Concentric bores are a Z-axis construction (as in :func:`build_model`)."""
143
+ if not a.is_rotational or a.od_diam is None:
144
+ return None
145
+ bores = tuple(_concentric_bore_diams(a)) if a.od_axis == "z" else ()
146
+ c = a.part.bounding_box().center()
147
+ return RotationalFeature(frame=Frame((c.X, c.Y, c.Z), a.od_axis), od=a.od_diam, bores=bores)
148
+
149
+
133
150
  def _declared_feature_keys(groups, a: Analysis) -> set:
134
151
  """The :class:`HoleRef` position keys of every DECLARED hole/pattern member (ADR 0011
135
152
  #448), so a caller-declared hole/pattern renders at its declared position even where
@@ -333,6 +350,11 @@ def _auto_annotate(dwg, a: Analysis, *, detail_view: bool = False):
333
350
  # after every hole/diameter pass so it claims strip space last.
334
351
  render_slots(dwg, _model, a)
335
352
 
353
+ # Declared GD&T frames / datum symbols / surface finishes (ADR 0011 §4, #61) — register
354
+ # into the same strips as first-class candidates BEFORE the drain, so the one solve orders
355
+ # and spaces them crossing-free with the dims (not a leftover first-fit like render_pmi).
356
+ render_gdt(dwg, _model, a)
357
+
336
358
  # Now every feeder pass (locations + slots) has registered; solve each shared above
337
359
  # corridor once (ADR 0009 end state, #345/#346) — dedup coincident spans, order the
338
360
  # ladder — BEFORE detail views and PMI, so they see the placed ladder as an obstacle.