draftwright 0.3.8__tar.gz → 0.3.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 (146) hide show
  1. {draftwright-0.3.8 → draftwright-0.3.9}/CHANGELOG.md +68 -0
  2. {draftwright-0.3.8 → draftwright-0.3.9}/PKG-INFO +10 -2
  3. {draftwright-0.3.8 → draftwright-0.3.9}/README.md +9 -1
  4. {draftwright-0.3.8 → draftwright-0.3.9}/pyproject.toml +1 -1
  5. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/_core.py +8 -7
  6. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/analysis.py +4 -0
  7. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/annotations/from_model.py +93 -36
  8. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/annotations/holes.py +219 -5
  9. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/annotations/orchestrator.py +27 -6
  10. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/annotations/sections.py +31 -20
  11. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/builder.py +17 -2
  12. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/drawing.py +97 -36
  13. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/model/__init__.py +8 -0
  14. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/model/declare.py +258 -5
  15. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/model/detect.py +152 -3
  16. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/model/ir.py +79 -0
  17. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/model/planner.py +8 -1
  18. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/projection.py +39 -2
  19. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/recognition/__init__.py +18 -1
  20. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/recognition/_features.py +42 -21
  21. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/recognition/slots.py +405 -35
  22. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/sheet.py +114 -1
  23. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/sheet_emit.py +71 -2
  24. draftwright-0.3.9/tests/fixtures/tuner_jig_blind_obround_pockets.step +4109 -0
  25. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_declare.py +111 -0
  26. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_drawing_encapsulation.py +51 -20
  27. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_gdt_placement.py +38 -9
  28. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_make_drawing.py +59 -0
  29. draftwright-0.3.9/tests/test_pocket_pattern.py +259 -0
  30. draftwright-0.3.9/tests/test_pocket_pattern_recognition.py +191 -0
  31. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_recogniser_contract.py +56 -0
  32. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_render_seam.py +4 -4
  33. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_sheet_notes.py +35 -0
  34. draftwright-0.3.9/tests/test_sheet_section.py +103 -0
  35. draftwright-0.3.9/tests/test_slot_pattern.py +151 -0
  36. draftwright-0.3.9/tests/test_slot_pattern_recognition.py +181 -0
  37. {draftwright-0.3.8 → draftwright-0.3.9}/.gitignore +0 -0
  38. {draftwright-0.3.8 → draftwright-0.3.9}/LICENSE +0 -0
  39. {draftwright-0.3.8 → draftwright-0.3.9}/docs/adr/README.md +0 -0
  40. {draftwright-0.3.8 → draftwright-0.3.9}/skills/SKILL.md +0 -0
  41. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/__init__.py +0 -0
  42. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/_geometry.py +0 -0
  43. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/annotate.py +0 -0
  44. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/annotations/__init__.py +0 -0
  45. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/annotations/_common.py +0 -0
  46. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/annotations/balloons.py +0 -0
  47. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/cli.py +0 -0
  48. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/compose.py +0 -0
  49. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/export.py +0 -0
  50. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/fits.py +0 -0
  51. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  52. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  53. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  54. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  55. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/fonts/__init__.py +0 -0
  56. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/intents.py +0 -0
  57. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/layout.py +0 -0
  58. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/linting/__init__.py +0 -0
  59. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/linting/coverage.py +0 -0
  60. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/linting/issues.py +0 -0
  61. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/linting/structural.py +0 -0
  62. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/linting/suggest.py +0 -0
  63. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/make_drawing.py +0 -0
  64. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/pmi.py +0 -0
  65. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/recognition/_record.py +0 -0
  66. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/recognition/chamfers.py +0 -0
  67. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/recognition/countersinks.py +0 -0
  68. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/recognition/fillets.py +0 -0
  69. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/recognition/flats.py +0 -0
  70. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/recognition/grooves.py +0 -0
  71. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/recognition/levels.py +0 -0
  72. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/recognition/plates.py +0 -0
  73. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/recognition/turned.py +0 -0
  74. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/registry.py +0 -0
  75. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/repair.py +0 -0
  76. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/score.py +0 -0
  77. {draftwright-0.3.8 → draftwright-0.3.9}/src/draftwright/sheet_dsl.py +0 -0
  78. {draftwright-0.3.8 → draftwright-0.3.9}/tests/_kernel.py +0 -0
  79. {draftwright-0.3.8 → draftwright-0.3.9}/tests/_layout_sig.py +0 -0
  80. {draftwright-0.3.8 → draftwright-0.3.9}/tests/fixtures/grm03_thumbwheel_drive_screw.step +0 -0
  81. {draftwright-0.3.8 → draftwright-0.3.9}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  82. {draftwright-0.3.8 → draftwright-0.3.9}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  83. {draftwright-0.3.8 → draftwright-0.3.9}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  84. {draftwright-0.3.8 → draftwright-0.3.9}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  85. {draftwright-0.3.8 → draftwright-0.3.9}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  86. {draftwright-0.3.8 → draftwright-0.3.9}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  87. {draftwright-0.3.8 → draftwright-0.3.9}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  88. {draftwright-0.3.8 → draftwright-0.3.9}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  89. {draftwright-0.3.8 → draftwright-0.3.9}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  90. {draftwright-0.3.8 → draftwright-0.3.9}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  91. {draftwright-0.3.8 → draftwright-0.3.9}/tests/refactor_golden/bracket_section.json +0 -0
  92. {draftwright-0.3.8 → draftwright-0.3.9}/tests/refactor_golden/centered_rebate.json +0 -0
  93. {draftwright-0.3.8 → draftwright-0.3.9}/tests/refactor_golden/chamfered.json +0 -0
  94. {draftwright-0.3.8 → draftwright-0.3.9}/tests/refactor_golden/filleted.json +0 -0
  95. {draftwright-0.3.8 → draftwright-0.3.9}/tests/refactor_golden/flange_dense.json +0 -0
  96. {draftwright-0.3.8 → draftwright-0.3.9}/tests/refactor_golden/grid_plate.json +0 -0
  97. {draftwright-0.3.8 → draftwright-0.3.9}/tests/refactor_golden/grooved_shaft.json +0 -0
  98. {draftwright-0.3.8 → draftwright-0.3.9}/tests/refactor_golden/hex_bar.json +0 -0
  99. {draftwright-0.3.8 → draftwright-0.3.9}/tests/refactor_golden/holed_slot.json +0 -0
  100. {draftwright-0.3.8 → draftwright-0.3.9}/tests/refactor_golden/pocketed.json +0 -0
  101. {draftwright-0.3.8 → draftwright-0.3.9}/tests/refactor_golden/prismatic_ladder.json +0 -0
  102. {draftwright-0.3.8 → draftwright-0.3.9}/tests/refactor_golden/scattered_plate.json +0 -0
  103. {draftwright-0.3.8 → draftwright-0.3.9}/tests/refactor_golden/side_drilled.json +0 -0
  104. {draftwright-0.3.8 → draftwright-0.3.9}/tests/refactor_golden/turned_stepped.json +0 -0
  105. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_agents_guide.py +0 -0
  106. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_architecture_docs.py +0 -0
  107. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_carve_free_position_callers.py +0 -0
  108. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_dense_sheet_canary.py +0 -0
  109. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_detect_once.py +0 -0
  110. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_detect_registry.py +0 -0
  111. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_e2e_slice.py +0 -0
  112. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_e2e_standards.py +0 -0
  113. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_export_dxf_zoom.py +0 -0
  114. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_fillets_adjacency.py +0 -0
  115. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_fits.py +0 -0
  116. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_import_boundaries.py +0 -0
  117. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_layout.py +0 -0
  118. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_layout_cleanliness.py +0 -0
  119. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_layout_hypothesis.py +0 -0
  120. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_layout_property.py +0 -0
  121. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_lint_box_cache.py +0 -0
  122. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_lint_reconciliation.py +0 -0
  123. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_lint_structural.py +0 -0
  124. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_linting.py +0 -0
  125. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_note_verb.py +0 -0
  126. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_object_aspects.py +0 -0
  127. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_occupancy_boxes.py +0 -0
  128. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_part_model.py +0 -0
  129. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_pitch_dim_footprint.py +0 -0
  130. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_pmi.py +0 -0
  131. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_private_test_attr_reads.py +0 -0
  132. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_private_test_imports.py +0 -0
  133. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_recognition.py +0 -0
  134. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_refactor_golden.py +0 -0
  135. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_registry.py +0 -0
  136. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_score.py +0 -0
  137. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_script_detail_parity.py +0 -0
  138. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_sheet_emit.py +0 -0
  139. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_sheet_gdt.py +0 -0
  140. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_sheet_of.py +0 -0
  141. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_sheet_tables.py +0 -0
  142. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_solve_trace.py +0 -0
  143. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_strip_layout.py +0 -0
  144. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_tolerances.py +0 -0
  145. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_turned_steps.py +0 -0
  146. {draftwright-0.3.8 → draftwright-0.3.9}/tests/test_witness_label_reconciliation.py +0 -0
@@ -1,5 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.9 — 2026-07-26
4
+
5
+ ### Added
6
+
7
+ - **Repeated recesses are dimensioned as one pattern, not N competing callouts**
8
+ (#841): identical blind pockets and identical through-slots now collapse to a single
9
+ grouped `5× 7.9 × 13.6 × 19 DEEP` / `4× SLOT 8 × 30` leader plus `(n-1)× pitch`
10
+ dim(s). Previously each member competed for its own size dims and some silently
11
+ dropped for want of strip room — five declared slots rendered only three length dims.
12
+ Both new kinds (`PocketPatternFeature`, `SlotPatternFeature`) round-trip every
13
+ surface: declare (`sheet.pocket_pattern(...)` / `sheet.slot_pattern(...)`),
14
+ recognition from a real solid (coplanar, same-facing members only), the emitted
15
+ Sheet script, and the editable surface (`dwg.callout(feature)` + `finalize()`).
16
+ - **`Sheet.section()` and `Sheet.detail()` — ask for the view you need** (#841/#847):
17
+ a section A–A auto-fires only for a Z-axis hole with a counterbore, spotface, or
18
+ blind bottom, so a blind pocket's floor and depth stayed hidden-line-only with no
19
+ supported way to request a cut. `sheet.section(feature)` / `section(at=y)` /
20
+ bare `section()` force one (the cut plane is validated to lie strictly inside the
21
+ part), and `sheet.detail()` exposes the existing `detail_view=True` opt-in as a
22
+ chainable verb.
23
+ - **External threads are a first-class declared aspect** (#859): the turned analog of
24
+ the #764 internal thread — `sheet.step(shaft).thread("M3x0.5")`,
25
+ `sheet.diameter(...).thread(...)`, and `sheet.boss(...).thread(...)` append the
26
+ spec to the ⌀ callout (`ø3 M3x0.5`) instead of needing a free-text `.note()`. It
27
+ composes with `.finish()` for Ra-on-thread, keys the callout bucket on `(⌀, thread)`
28
+ so a threaded and a plain ø6 stay distinct, and now round-trips through the Sheet
29
+ emitter — including pattern-member holes, closing the symmetric latent #764 gap.
30
+ Declaration-only: a plain cylinder is geometrically indistinguishable from a
31
+ threaded one, so there is no recogniser. *(Known limitation: the incremental
32
+ `callout`/`finalize(only=…)` paths still dedup thread-blind — #863.)*
33
+ - **Blind obround pocket recognition, including imported STEP** (#837): the blind
34
+ counterpart of the #816 through-slot work. A stubby floored obround has side walls
35
+ too short to pair, so it is recovered from its semicircular end caps — clustering
36
+ faces by axis proximity so the quarter-cylinder split a STEP importer commonly
37
+ produces recognises as well as build123d's half-cylinder. A sealed internal void
38
+ (capped at both ends) is correctly *not* a pocket. A real tuner-jig STEP with five
39
+ blind pockets ships as a fixture.
40
+ - **`sheet.slot(...).note(...)` / `sheet.pocket(...).note(...)`** (#841/#845): the
41
+ slot/pocket handle now anchors a note to its own feature, with no `ref=` needed
42
+ (an explicit `ref` still forwards, as before).
43
+
44
+ ### Changed
45
+
46
+ - **An anchored note, GD&T frame, or finish relaxes its side instead of silently
47
+ vanishing** (#841/#855): an annotation declared with an explicit `view=`/`side=`
48
+ used to drop with only a warning when that strip was full, while the export still
49
+ reported success. The requested side is now a *preference* — when the strip has no
50
+ room the placer tries the opposite side, then the two perpendicular sides, taking
51
+ the first with room (via the same bounds and title-block checks, so it never
52
+ overshoots). A relaxed placement records an INFO `gdt_side_relaxed` issue naming
53
+ requested versus actual. Only when no strip fits does it drop.
54
+
55
+ ### Internal
56
+
57
+ - **The state-bus endgame is closed to its permanent seam** (#830/#840): the
58
+ solve trace is filled into `BuildState` at the one construction site, the redundant
59
+ part-model re-attach is gone, and the detail view is now *transactional* — geometry
60
+ is projected at the detail scale and committed only if dimensions land, so
61
+ `_drop_view_coordinates` has zero engine callers. With those removed, the #817
62
+ decision-D method-call exemption narrows from a blanket pass to a named
63
+ `_LAYOUT_SEAM` allowlist (`_add_view`, `_set_view_coordinates`), accepted as the
64
+ permanent interactive-layout seam; every other private-method call on the drawing
65
+ is now flagged.
66
+ - **A draftwright logo set** (#843) — drafting-idiom mark, all text outlined so the
67
+ SVGs render font-independently; the README now leads with the lockup.
68
+ - **A worked multi-feature object-reference example** (#853) in
69
+ `docs/multi-feature-object-reference-workflow.md`.
70
+
3
71
  ## v0.3.8 — 2026-07-23
4
72
 
5
73
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.3.8
3
+ Version: 0.3.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
@@ -694,7 +694,9 @@ Requires-Dist: typer>=0.12
694
694
  Requires-Dist: typing-extensions>=4.5; python_full_version < '3.13'
695
695
  Description-Content-Type: text/markdown
696
696
 
697
- # draftwright
697
+ <h1 align="center">
698
+ <img src="docs/images/draftwright-logo.png" alt="draftwright" width="460">
699
+ </h1>
698
700
 
699
701
  [![CI](https://github.com/pzfreo/draftwright/actions/workflows/ci.yml/badge.svg)](https://github.com/pzfreo/draftwright/actions/workflows/ci.yml)
700
702
  [![codecov](https://codecov.io/gh/pzfreo/draftwright/branch/main/graph/badge.svg)](https://codecov.io/gh/pzfreo/draftwright)
@@ -832,6 +834,12 @@ sheet.hole(features.m3_bore).thread("M3x0.5").finish("1.6") # tapped + Ra on t
832
834
  An object-sourced script carries an inline tip pointing at exactly this edit; a STEP-sourced
833
835
  script keeps the detected numbers (there's no object to reference).
834
836
 
837
+ See [`docs/multi-feature-object-reference-workflow.md`](docs/multi-feature-object-reference-workflow.md)
838
+ for a complete, runnable walkthrough of this pattern on a multi-feature turned part —
839
+ including the backward-compatible refactor, external threads (no `.thread()` on a step —
840
+ use `.note(...)`), and why an object-sourced script should point at the cheap geometry
841
+ variant of your source.
842
+
835
843
  ## What it produces
836
844
 
837
845
  - **Three orthographic views** (front, plan, side) sized and scaled automatically to the
@@ -1,4 +1,6 @@
1
- # draftwright
1
+ <h1 align="center">
2
+ <img src="docs/images/draftwright-logo.png" alt="draftwright" width="460">
3
+ </h1>
2
4
 
3
5
  [![CI](https://github.com/pzfreo/draftwright/actions/workflows/ci.yml/badge.svg)](https://github.com/pzfreo/draftwright/actions/workflows/ci.yml)
4
6
  [![codecov](https://codecov.io/gh/pzfreo/draftwright/branch/main/graph/badge.svg)](https://codecov.io/gh/pzfreo/draftwright)
@@ -136,6 +138,12 @@ sheet.hole(features.m3_bore).thread("M3x0.5").finish("1.6") # tapped + Ra on t
136
138
  An object-sourced script carries an inline tip pointing at exactly this edit; a STEP-sourced
137
139
  script keeps the detected numbers (there's no object to reference).
138
140
 
141
+ See [`docs/multi-feature-object-reference-workflow.md`](docs/multi-feature-object-reference-workflow.md)
142
+ for a complete, runnable walkthrough of this pattern on a multi-feature turned part —
143
+ including the backward-compatible refactor, external threads (no `.thread()` on a step —
144
+ use `.note(...)`), and why an object-sourced script should point at the cheap geometry
145
+ variant of your source.
146
+
139
147
  ## What it produces
140
148
 
141
149
  - **Three orthographic views** (front, plan, side) sized and scaled automatically to the
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.3.8"
7
+ version = "0.3.9"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -604,13 +604,14 @@ class DetailRequest:
604
604
  axis: part axis the band spans / is cropped along ("x"/"y"/"z").
605
605
  lo, hi: band bounds along ``axis`` (world mm).
606
606
  scale_needed: detail world→page scale that makes the region legible.
607
- redraw: ``redraw(dwg, view_name, detail_scale) -> int`` — draws the
608
- detail's dimensions in the placed detail view's coordinate system
609
- and returns the count placed (0 the detailer rolls the view
610
- back rather than leave an empty box). Called once the detail is
611
- placed; the main view always carries the located head/block
612
- inline regardless, so a placement failure loses no coverage (lint
613
- reports the un-located interior instead).
607
+ redraw: ``redraw(dwg, view_name, coords, detail_scale) -> int`` — draws the
608
+ detail's dimensions against *coords* (the detail's
609
+ :class:`ViewCoordinates`, mapped via ``coords.pp``) and returns the count
610
+ placed (0 the detailer drops the detail without committing a view, #840).
611
+ Called on the SCRATCH-projected coords before the view is committed, so a
612
+ zero result needs no rollback; the main view always carries the located
613
+ head/block inline regardless, so a placement failure loses no coverage
614
+ (lint reports the un-located interior instead).
614
615
  pad_top: page-mm band reserved above the detail view (a horizontal
615
616
  chain); reserved in the fit + placement.
616
617
  pads: optional ``pads(detail_scale) -> (pad_right, pad_top)`` for a
@@ -93,6 +93,10 @@ def _will_section(model, *, is_rotational=False, cx=0.0, cy=0.0) -> bool:
93
93
 
94
94
  if model is None:
95
95
  return False
96
+ # An explicit Sheet.section() request (ADR 0011, #841) reserves the row even when no
97
+ # hole gate qualifies — a blind pocket's floor/depth section has no driving Z hole.
98
+ if getattr(model, "decorations", {}).get("section") is not None:
99
+ return True
96
100
  features = getattr(model, "features", model)
97
101
 
98
102
  def feature_member(pt) -> bool:
@@ -778,14 +778,15 @@ def _diameter_row_below(dwg, items, start: int = 0, trace=None, *, ctx) -> int:
778
778
  if ev is not None:
779
779
  ev["items"].extend(
780
780
  {"label": f"ø{_fmt(d)}", "outcome": "dropped", "reason": "no_room_below"}
781
- for _, d, _, _ in items
781
+ for _, d, _, _, _ in items
782
782
  )
783
783
  return 0
784
784
  specs = [] # (tip_page, dia, label, feature), tip on the step's bottom silhouette,
785
- for anchor, dia, feat, dtol in items: # centred along the feature's length (not a corner)
785
+ for anchor, dia, feat, dtol, thr in items: # centred along the feature's length (not a corner)
786
786
  ax, ay, az = anchor
787
787
  tip = dwg.at("front", ax, ay, az - dia / 2)
788
- specs.append((tip, dia, f"ø{_fmt(dia)}{_tol_suffix(dtol, draft)}", feat))
788
+ label = f"ø{_fmt(dia)}{_tol_suffix(dtol, draft)}" + (f" {thr}" if thr else "") # #859
789
+ specs.append((tip, dia, label, feat))
789
790
  # Real measured width, not the per-char estimate: helpers >=0.14 label boxes are
790
791
  # honest about the rendered string, so an underestimated min_gap here surfaces as a
791
792
  # visible annotation_overlap between adjacent labels (hypothesis tier).
@@ -811,7 +812,7 @@ def _diameter_row_below(dwg, items, start: int = 0, trace=None, *, ctx) -> int:
811
812
  # when direction-aware label intervals actually collide, enforce elbow ≥ tip with
812
813
  # a left-to-right min_gap cascade; overflow drops the smallest ø (#298) and
813
814
  # re-solves. No-op for ordinary rows.
814
- _SHELF = 2.0 # helpers Leader: the label hangs one shelf-length off the elbow
815
+ _SHELF = draft.pad_around_text # helpers Leader shelf_len = gap = draft.pad_around_text
815
816
 
816
817
  def _label_ivals(svs, positions):
817
818
  out = []
@@ -882,24 +883,36 @@ def _diameter_column_left(dwg, items, start: int = 0, trace=None, *, ctx) -> int
882
883
  ev = trace.pass_event("diameter_column_left", view="front") if trace is not None else None
883
884
  draft = dwg.draft
884
885
  fx0, fy0, _, fy1 = dwg.view_bounds("front")
885
- label_w = (
886
- max(len(f"ø{_fmt(dia)}{_tol_suffix(dtol, draft)}") for _, dia, _, dtol in items)
887
- * draft.font_size
888
- * _EST_CHAR_WIDTH_EM
886
+ # Real measured width, not the per-char estimate (#859): a thread spec is arbitrary text,
887
+ # so `len * 0.62 em` can underestimate a wide label and let it cross the margin
888
+ # (annotation_out_of_bounds). Measure the completed label like the row-below path does.
889
+ label_w = max(
890
+ _text_size(
891
+ f"ø{_fmt(dia)}{_tol_suffix(dtol, draft)}" + (f" {thr}" if thr else ""),
892
+ draft.font_size,
893
+ getattr(draft, "font_path", DEFAULT_FONT_PATH),
894
+ getattr(draft, "font", "Arial"),
895
+ )[0]
896
+ for _, dia, _, dtol, thr in items
889
897
  )
890
898
  elbow_x = fx0 - (draft.font_size + 2 * draft.pad_around_text)
891
- if elbow_x - label_w < _MARGIN:
899
+ # A left-directed leader hangs its label a shelf-length PAST the elbow, so the label's left
900
+ # edge sits at elbow_x - shelf - label_w; the guard must reserve the shelf or a near-boundary
901
+ # label overshoots the margin. The shelf is the helpers Leader's gap = draft.pad_around_text,
902
+ # not a fixed 2.0 (#859, Codex #862 r4/r5).
903
+ if elbow_x - draft.pad_around_text - label_w < _MARGIN:
892
904
  if ev is not None:
893
905
  ev["items"].extend(
894
906
  {"label": f"ø{_fmt(d)}", "outcome": "dropped", "reason": "no_room_left"}
895
- for _, d, _, _ in items
907
+ for _, d, _, _, _ in items
896
908
  )
897
909
  return 0
898
910
  specs = [] # (tip_page, dia, label, feature), tip on the step's left silhouette,
899
- for anchor, dia, feat, dtol in items: # centred along the feature's length (not a corner)
911
+ for anchor, dia, feat, dtol, thr in items: # centred along the feature's length (not a corner)
900
912
  ax, ay, az = anchor
901
913
  tip = dwg.at("front", ax - dia / 2, ay, az)
902
- specs.append((tip, dia, f"ø{_fmt(dia)}{_tol_suffix(dtol, draft)}", feat))
914
+ label = f"ø{_fmt(dia)}{_tol_suffix(dtol, draft)}" + (f" {thr}" if thr else "") # #859
915
+ specs.append((tip, dia, label, feat))
903
916
  half_h = draft.font_size / 2 + draft.pad_around_text
904
917
  min_gap = 2 * half_h
905
918
  # Place what fits; drop the smallest ø first, never the whole column (#298).
@@ -1006,24 +1019,30 @@ def render_diameters(dwg, groups, tol: float = 0.15, *, ctx, only=None) -> int:
1006
1019
  if dpd is None:
1007
1020
  continue
1008
1021
  dia = dpd.param.value
1009
- if any(abs(dia - m) <= tol for m in mentioned):
1022
+ # An EXTERNAL thread (#859) makes a distinct callout: a threaded ⌀6 ("ø6 M6x1") and a
1023
+ # plain ⌀6 are NOT the same label, so the bucket keys on (⌀, thread) — this never drops a
1024
+ # thread on a shared ⌀, and a threadless model keys every entry on (⌀, None) exactly as
1025
+ # before (byte-identical). entry = [anchor, dia, {features}, ± tolerance, thread]. A
1026
+ # callout is per (axis, ⌀, thread); the first authored tolerance on a shared ⌀ wins.
1027
+ thr = getattr(g.feature, "thread", None)
1028
+ # A coincident plain ⌀ already drawn (a bore, another step) dedups only an UNTHREADED ⌀;
1029
+ # a threaded ⌀ is a distinct callout, so a bare ⌀8 mention must not suppress ø8 M8x1.25.
1030
+ if thr is None and any(abs(dia - m) <= tol for m in mentioned):
1010
1031
  continue
1011
1032
  bucket = {"x": row_buckets, "z": col_buckets}.get(g.feature.frame.axis)
1012
1033
  if bucket is None:
1013
1034
  continue
1014
- dkey = round(dia, 2)
1015
1035
  dtol = dpd.param.tolerance
1016
- # entry = [anchor, dia, {features}, ± tolerance]. A callout is per (axis, ⌀); the
1017
- # first authored tolerance on a shared ⌀ wins (P2a — a single callout, one label).
1018
- entry = bucket.setdefault(dkey, [g.anchor, dia, set(), dtol])
1036
+ dkey = (round(dia, 2), thr)
1037
+ entry = bucket.setdefault(dkey, [g.anchor, dia, set(), dtol, thr])
1019
1038
  entry[2].add(g.feature)
1020
1039
  if entry[3] is None:
1021
1040
  entry[3] = dtol
1022
1041
 
1023
1042
  def _items(buckets):
1024
1043
  return [
1025
- (_diameter_step_anchor(a, fs), d, next(iter(fs)) if len(fs) == 1 else None, t)
1026
- for a, d, fs, t in buckets.values()
1044
+ (_diameter_step_anchor(a, fs), d, next(iter(fs)) if len(fs) == 1 else None, t, thr)
1045
+ for a, d, fs, t, thr in buckets.values()
1027
1046
  ]
1028
1047
 
1029
1048
  # The placers name leaders m_dia_{x,z}{start+i} CONTIGUOUSLY from one start. The auto-pass
@@ -1541,6 +1560,13 @@ def _pocket_label(width, length, depth, wsfx="", lsfx="", dsfx="") -> str:
1541
1560
  return f"{_fmt(width)}{wsfx} × {_fmt(length)}{lsfx} × {_fmt(depth)}{dsfx} DEEP"
1542
1561
 
1543
1562
 
1563
+ def _slot_label(width, length, wsfx="", lsfx="") -> str:
1564
+ """The grouped slot-array callout string: ``SLOT {width} × {length}`` (#841). A slot has no
1565
+ depth, so — unlike :func:`_pocket_label` — there is no ``× depth DEEP``; the ``SLOT`` prefix
1566
+ names the feature the way a lone slot's linear dims otherwise would."""
1567
+ return f"SLOT {_fmt(width)}{wsfx} × {_fmt(length)}{lsfx}"
1568
+
1569
+
1544
1570
  # Unit lead directions tried (nearest-clear wins), diagonals first so a central pocket's
1545
1571
  # leader exits toward a corner (usually the emptiest margin) before an edge.
1546
1572
  _POCKET_LEAD_DIRS = (
@@ -1730,8 +1756,11 @@ def render_boss_diameters(dwg, groups, a, *, ctx) -> int:
1730
1756
  continue
1731
1757
  dia = dpd.param.value
1732
1758
  dtol = dpd.param.tolerance
1733
- if any(abs(dia - m) <= 0.15 for m in mentioned):
1734
- continue # a coincident bore / step already carries this ø
1759
+ thr = getattr(b, "thread", None) # external thread appends to the OD callout (#859)
1760
+ # A coincident plain dedups only an UNTHREADED boss; a threaded ⌀ is a distinct callout,
1761
+ # so a bare ⌀8 mention (a bore, a step) must not suppress ø8 M8x1.25 (#859).
1762
+ if thr is None and any(abs(dia - m) <= 0.15 for m in mentioned):
1763
+ continue
1735
1764
  view = view_of.get(b.frame.axis)
1736
1765
  if view is None:
1737
1766
  continue
@@ -1743,7 +1772,7 @@ def render_boss_diameters(dwg, groups, a, *, ctx) -> int:
1743
1772
  f"m_bossdia_{b.frame.axis}{bi}",
1744
1773
  view,
1745
1774
  vb,
1746
- f"ø{_fmt(dia)}{_tol_suffix(dtol, draft)}",
1775
+ f"ø{_fmt(dia)}{_tol_suffix(dtol, draft)}" + (f" {thr}" if thr else ""),
1747
1776
  # arrowhead on the boss circle's rim, not its centre
1748
1777
  _radial_candidates(dwg, view, vb, b, reach, rim=dia / 2 * a.SCALE),
1749
1778
  )
@@ -2298,9 +2327,15 @@ def render_step_lengths(dwg, groups, *, ctx, only=None) -> int:
2298
2327
  (dwg.at("front", hlo, 0, 0), dwg.at("front", hhi, 0, 0), hhi - hlo, None)
2299
2328
  )
2300
2329
 
2301
- def _redraw(dwg, view, detail_scale, _hw=ra):
2330
+ def _redraw(dwg, view, coords, detail_scale, _hw=ra):
2302
2331
  # View-scoped name prefix so two detail views never collide (#307 review).
2303
- hsegs = [(dwg.at(view, *a), dwg.at(view, *b), v, t) for a, b, v, t in _hw]
2332
+ # Map world→page against the detail coords (not a live dwg.at) so the view can be
2333
+ # committed only after these dims land — no place-then-roll-back (#840).
2334
+ def _at(x, y, z):
2335
+ px, py = coords.pp(x, y, z)
2336
+ return (px, py, 0.0)
2337
+
2338
+ hsegs = [(_at(*a), _at(*b), v, t) for a, b, v, t in _hw]
2304
2339
  return _draw_step_chain(
2305
2340
  dwg, view, hsegs, f"dim_{view}_steplen", detail_scale, ctx=ctx
2306
2341
  )
@@ -3435,28 +3470,50 @@ def render_gdt(dwg, model, a, *, ctx) -> int:
3435
3470
  _zones=_zones,
3436
3471
  _px=_px,
3437
3472
  _py=_py,
3438
- _hz=_hz,
3439
3473
  _sz=_sz,
3440
3474
  _bld=_bld,
3441
3475
  _feat=_feat,
3442
3476
  _tb=_tb,
3443
3477
  ):
3444
- alt = {"above": "below", "below": "above", "left": "right", "right": "left"}[_s]
3445
- alt_strip = getattr(_zones, alt, None)
3446
- if alt_strip is not None:
3447
- axis2 = "y" if alt in ("above", "below") else "x"
3478
+ # Auto-relax the requested side (#841 outcome C): the requested strip is full, so
3479
+ # try the OPPOSITE side, then the two PERPENDICULAR sides, placing on the first
3480
+ # with room. A note the caller asked to see should appear somewhere legible
3481
+ # rather than vanish; when the requested strip has no room, an explicit `side=`
3482
+ # is a preference, not a hard constraint. A perpendicular side flips the leader
3483
+ # orientation (`_bld(pos, _hz=hz)`). If the placement lands on a side other than
3484
+ # requested, record an INFO issue so the relaxation is visible, not silent — the
3485
+ # #841 goal is that a requested annotation is never *silently* lost.
3486
+ relax_order = {
3487
+ "above": ("below", "right", "left"),
3488
+ "below": ("above", "right", "left"),
3489
+ "left": ("right", "above", "below"),
3490
+ "right": ("left", "above", "below"),
3491
+ }[_s]
3492
+ for alt in relax_order:
3493
+ alt_strip = getattr(_zones, alt, None)
3494
+ if alt_strip is None:
3495
+ continue
3496
+ hz = alt in ("above", "below") # perpendicular sides flip the leader axis
3497
+ axis2 = "y" if hz else "x"
3448
3498
  extent = _sz[1] if axis2 == "y" else _sz[0] # the glyph's stacking-axis size
3449
- perp = (_px, _px + _sz[0]) if _hz else (_py - _sz[1] / 2, _py + _sz[1] / 2)
3499
+ perp = (_px, _px + _sz[0]) if hz else (_py - _sz[1] / 2, _py + _sz[1] / 2)
3450
3500
  pos = carve_free_position(dwg, alt_strip, _v, axis2, max(tier, extent), perp)
3451
- if pos is not None:
3452
- dim = _bld(pos)
3453
- if not _box_hits(_anno_box(dim), (_tb,)): # clear of the title block
3454
- ctx.place(dim, nm, view=_v, feature=_feat) # alternate side
3455
- return
3501
+ if pos is None:
3502
+ continue
3503
+ dim = _bld(pos, _hz=hz)
3504
+ if _box_hits(_anno_box(dim), (_tb,)): # would overlap the title block — skip
3505
+ continue
3506
+ ctx.place(dim, nm, view=_v, feature=_feat) # relaxed side
3507
+ ctx.record_issue(
3508
+ "info",
3509
+ "gdt_side_relaxed",
3510
+ f"{nm}: the {_v} {_s} strip was full — placed on {alt} instead",
3511
+ )
3512
+ return
3456
3513
  ctx.record_issue(
3457
3514
  "warning",
3458
3515
  "gdt_dropped",
3459
- f"{nm} not placed (no room in the {_v} {_s} strip or its opposite)",
3516
+ f"{nm} not placed (no room in any {_v} strip)",
3460
3517
  )
3461
3518
 
3462
3519
  ctx.post_drain.append(_retry)