draftwright 0.4.0__tar.gz → 0.4.1__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 (192) hide show
  1. {draftwright-0.4.0 → draftwright-0.4.1}/CHANGELOG.md +91 -0
  2. {draftwright-0.4.0 → draftwright-0.4.1}/PKG-INFO +12 -3
  3. {draftwright-0.4.0 → draftwright-0.4.1}/README.md +11 -2
  4. {draftwright-0.4.0 → draftwright-0.4.1}/docs/adr/README.md +2 -0
  5. {draftwright-0.4.0 → draftwright-0.4.1}/pyproject.toml +18 -3
  6. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/__init__.py +7 -0
  7. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/_core.py +177 -42
  8. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/_geometry.py +97 -0
  9. draftwright-0.4.1/src/draftwright/_warnings.py +39 -0
  10. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/analysis.py +117 -28
  11. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/annotations/_common.py +32 -5
  12. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/annotations/from_model.py +367 -56
  13. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/annotations/holes.py +202 -39
  14. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/annotations/orchestrator.py +47 -6
  15. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/annotations/sections.py +108 -71
  16. draftwright-0.4.1/src/draftwright/audit.py +249 -0
  17. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/builder.py +36 -4
  18. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/compose.py +38 -7
  19. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/drawing.py +281 -19
  20. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/linting/__init__.py +10 -0
  21. draftwright-0.4.1/src/draftwright/linting/channel_coverage.py +190 -0
  22. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/linting/coverage.py +485 -11
  23. draftwright-0.4.1/src/draftwright/linting/flat_coverage.py +207 -0
  24. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/linting/issues.py +4 -0
  25. draftwright-0.4.1/src/draftwright/linting/profiled_bore_coverage.py +122 -0
  26. draftwright-0.4.1/src/draftwright/linting/slot_coverage.py +315 -0
  27. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/model/__init__.py +8 -0
  28. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/model/callout.py +33 -2
  29. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/model/compiled.py +150 -27
  30. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/model/declare.py +245 -18
  31. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/model/detect.py +103 -13
  32. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/model/ir.py +194 -4
  33. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/model/planner.py +157 -40
  34. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/projection.py +9 -1
  35. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/recognition/__init__.py +29 -5
  36. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/recognition/flats.py +103 -3
  37. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/recognition/levels.py +170 -44
  38. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/recognition/pads.py +5 -4
  39. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/recognition/plates.py +5 -0
  40. draftwright-0.4.1/src/draftwright/recognition/profiled_bores.py +368 -0
  41. draftwright-0.4.1/src/draftwright/recognition/result.py +277 -0
  42. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/recognition/slots.py +127 -19
  43. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/registry.py +109 -1
  44. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/repair.py +11 -3
  45. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/score.py +2 -0
  46. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/sheet.py +81 -20
  47. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/sheet_emit.py +77 -2
  48. draftwright-0.4.1/tests/conftest.py +84 -0
  49. draftwright-0.4.1/tests/fixtures/if_step_flat_across_cylinder.step +189 -0
  50. draftwright-0.4.1/tests/fixtures/issue_1058_wheel_rh.step +64624 -0
  51. draftwright-0.4.1/tests/fixtures/issue_909_basic_part_design_017_body.step +465 -0
  52. draftwright-0.4.1/tests/fixtures/issue_915_case_study_2.step +3527 -0
  53. {draftwright-0.4.0 → draftwright-0.4.1}/tests/refactor_golden/pocketed.json +2 -2
  54. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_add_dimension.py +36 -10
  55. draftwright-0.4.1/tests/test_audit_differential.py +768 -0
  56. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_compiled_plan_boundary.py +24 -0
  57. draftwright-0.4.1/tests/test_counting_calls.py +117 -0
  58. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_declare.py +54 -2
  59. draftwright-0.4.1/tests/test_declared_recognition_gate.py +542 -0
  60. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_detect_once.py +38 -36
  61. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_detect_registry.py +12 -3
  62. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_drawing_encapsulation.py +28 -4
  63. draftwright-0.4.1/tests/test_flat_completeness.py +330 -0
  64. draftwright-0.4.1/tests/test_flat_stock_identity.py +240 -0
  65. draftwright-0.4.1/tests/test_flat_stock_opposition.py +63 -0
  66. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_import_boundaries.py +9 -0
  67. draftwright-0.4.1/tests/test_issue_1058_wheel_profile.py +676 -0
  68. draftwright-0.4.1/tests/test_issue_909_sloped_profile.py +150 -0
  69. draftwright-0.4.1/tests/test_issue_915_dense_case.py +108 -0
  70. draftwright-0.4.1/tests/test_issue_916_pocket_leader.py +102 -0
  71. draftwright-0.4.1/tests/test_issue_917_open_channel.py +189 -0
  72. draftwright-0.4.1/tests/test_location_vocabulary.py +400 -0
  73. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_make_drawing.py +278 -32
  74. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_parameter_id.py +27 -0
  75. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_part_model.py +2 -2
  76. draftwright-0.4.1/tests/test_principal_profile_classifier.py +122 -0
  77. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_recogniser_contract.py +35 -4
  78. draftwright-0.4.1/tests/test_recognition_manifest.py +567 -0
  79. draftwright-0.4.1/tests/test_recognition_result.py +340 -0
  80. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_registry.py +93 -0
  81. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_render_seam.py +1 -1
  82. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_script_detail_parity.py +42 -4
  83. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_sheet_emit.py +66 -1
  84. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_sheet_identity_invariant.py +2 -0
  85. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_slanted_blind_step.py +15 -7
  86. draftwright-0.4.1/tests/test_slot_completeness.py +595 -0
  87. draftwright-0.4.1/tests/test_soft_deprecation.py +221 -0
  88. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_strip_layout.py +5 -5
  89. draftwright-0.4.1/tests/test_suppression_ledger.py +380 -0
  90. {draftwright-0.4.0 → draftwright-0.4.1}/.gitignore +0 -0
  91. {draftwright-0.4.0 → draftwright-0.4.1}/LICENSE +0 -0
  92. {draftwright-0.4.0 → draftwright-0.4.1}/skills/SKILL.md +0 -0
  93. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/annotate.py +0 -0
  94. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/annotations/__init__.py +0 -0
  95. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/annotations/balloons.py +0 -0
  96. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/cli.py +0 -0
  97. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/export.py +0 -0
  98. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/fits.py +0 -0
  99. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  100. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  101. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  102. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  103. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/fonts/__init__.py +0 -0
  104. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/intents.py +0 -0
  105. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/layout.py +0 -0
  106. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/linting/structural.py +0 -0
  107. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/linting/suggest.py +0 -0
  108. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/make_drawing.py +0 -0
  109. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/pmi.py +0 -0
  110. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/py.typed +0 -0
  111. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/recognition/_features.py +0 -0
  112. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/recognition/_record.py +0 -0
  113. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/recognition/chamfers.py +0 -0
  114. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/recognition/countersinks.py +0 -0
  115. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/recognition/fillets.py +0 -0
  116. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/recognition/grooves.py +0 -0
  117. {draftwright-0.4.0 → draftwright-0.4.1}/src/draftwright/recognition/turned.py +0 -0
  118. {draftwright-0.4.0 → draftwright-0.4.1}/tests/_kernel.py +0 -0
  119. {draftwright-0.4.0 → draftwright-0.4.1}/tests/_layout_sig.py +0 -0
  120. {draftwright-0.4.0 → draftwright-0.4.1}/tests/fixtures/grm03_thumbwheel_drive_screw.step +0 -0
  121. {draftwright-0.4.0 → draftwright-0.4.1}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  122. {draftwright-0.4.0 → draftwright-0.4.1}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  123. {draftwright-0.4.0 → draftwright-0.4.1}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  124. {draftwright-0.4.0 → draftwright-0.4.1}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  125. {draftwright-0.4.0 → draftwright-0.4.1}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  126. {draftwright-0.4.0 → draftwright-0.4.1}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  127. {draftwright-0.4.0 → draftwright-0.4.1}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  128. {draftwright-0.4.0 → draftwright-0.4.1}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  129. {draftwright-0.4.0 → draftwright-0.4.1}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  130. {draftwright-0.4.0 → draftwright-0.4.1}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  131. {draftwright-0.4.0 → draftwright-0.4.1}/tests/fixtures/tuner_jig_blind_obround_pockets.step +0 -0
  132. {draftwright-0.4.0 → draftwright-0.4.1}/tests/refactor_golden/bracket_section.json +0 -0
  133. {draftwright-0.4.0 → draftwright-0.4.1}/tests/refactor_golden/centered_rebate.json +0 -0
  134. {draftwright-0.4.0 → draftwright-0.4.1}/tests/refactor_golden/chamfered.json +0 -0
  135. {draftwright-0.4.0 → draftwright-0.4.1}/tests/refactor_golden/filleted.json +0 -0
  136. {draftwright-0.4.0 → draftwright-0.4.1}/tests/refactor_golden/flange_dense.json +0 -0
  137. {draftwright-0.4.0 → draftwright-0.4.1}/tests/refactor_golden/grid_plate.json +0 -0
  138. {draftwright-0.4.0 → draftwright-0.4.1}/tests/refactor_golden/grooved_shaft.json +0 -0
  139. {draftwright-0.4.0 → draftwright-0.4.1}/tests/refactor_golden/hex_bar.json +0 -0
  140. {draftwright-0.4.0 → draftwright-0.4.1}/tests/refactor_golden/holed_slot.json +0 -0
  141. {draftwright-0.4.0 → draftwright-0.4.1}/tests/refactor_golden/prismatic_ladder.json +0 -0
  142. {draftwright-0.4.0 → draftwright-0.4.1}/tests/refactor_golden/scattered_plate.json +0 -0
  143. {draftwright-0.4.0 → draftwright-0.4.1}/tests/refactor_golden/side_drilled.json +0 -0
  144. {draftwright-0.4.0 → draftwright-0.4.1}/tests/refactor_golden/turned_stepped.json +0 -0
  145. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_agents_guide.py +0 -0
  146. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_architecture_docs.py +0 -0
  147. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_balloon_ring_standoff.py +0 -0
  148. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_carve_free_position_callers.py +0 -0
  149. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_dense_sheet_canary.py +0 -0
  150. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_deprecation_dates.py +0 -0
  151. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_dimension_role_vocabulary.py +0 -0
  152. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_e2e_slice.py +0 -0
  153. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_e2e_standards.py +0 -0
  154. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_export_dxf_zoom.py +0 -0
  155. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_fillets_adjacency.py +0 -0
  156. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_fits.py +0 -0
  157. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_gdt_placement.py +0 -0
  158. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_grid_lattice_convention.py +0 -0
  159. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_issue_885_prismatic_coverage.py +0 -0
  160. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_label_provenance.py +0 -0
  161. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_layout.py +0 -0
  162. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_layout_cleanliness.py +0 -0
  163. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_layout_hypothesis.py +0 -0
  164. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_layout_property.py +0 -0
  165. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_lint_box_cache.py +0 -0
  166. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_lint_reconciliation.py +0 -0
  167. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_lint_structural.py +0 -0
  168. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_linting.py +0 -0
  169. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_note_verb.py +0 -0
  170. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_object_aspects.py +0 -0
  171. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_occupancy_boxes.py +0 -0
  172. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_pitch_dim_footprint.py +0 -0
  173. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_pmi.py +0 -0
  174. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_pocket_pattern.py +0 -0
  175. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_pocket_pattern_recognition.py +0 -0
  176. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_private_test_attr_reads.py +0 -0
  177. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_private_test_imports.py +0 -0
  178. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_recognition.py +0 -0
  179. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_refactor_golden.py +0 -0
  180. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_score.py +0 -0
  181. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_sheet_gdt.py +0 -0
  182. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_sheet_notes.py +0 -0
  183. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_sheet_of.py +0 -0
  184. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_sheet_section.py +0 -0
  185. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_sheet_tables.py +0 -0
  186. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_slot_pattern.py +0 -0
  187. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_slot_pattern_recognition.py +0 -0
  188. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_solve_trace.py +0 -0
  189. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_suppression_marks.py +0 -0
  190. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_tolerances.py +0 -0
  191. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_turned_steps.py +0 -0
  192. {draftwright-0.4.0 → draftwright-0.4.1}/tests/test_witness_label_reconciliation.py +0 -0
@@ -2,6 +2,97 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.4.1 — 2026-08-07
6
+
7
+ **The recognition trust release.** Automatic drawings now fail closed on unsupported inner
8
+ profiles, prove semantic coverage for flats, slots and patterns, and retain the measurement
9
+ identity needed to explain placed, suppressed, dropped and missing definitions. Several real
10
+ STEP case studies also move from clean-looking but incomplete output to complete drawings or
11
+ actionable lint.
12
+
13
+ ### Discouraged (supported)
14
+
15
+ - **`Sheet.auto_dimensions()` and `Sheet.add_dimension()` now emit
16
+ `SoftDeprecationWarning`** (#1043). They remain supported with no removal planned; the
17
+ warning steers declared scripts toward `authored_dimensions()` plus explicit
18
+ `dimension(feature, parameter_id)` lines, where omission can mean suppression. Automatic
19
+ `build_drawing(part)` and the `Sheet.from_part(part)` on-ramp remain silent. See
20
+ `docs/deprecations.md` for filtering and migration guidance.
21
+
22
+ ### Added
23
+
24
+ - **Finished drawings expose why measurements were omitted and, on covered placement paths,
25
+ what an annotation measures** (#996/#1002). `Drawing.suppressions()` returns the compiler's
26
+ omission ledger, `Drawing.measurement_keys(name)` exposes recorded measurement provenance, and
27
+ `draftwright.audit.diff_builds(before, after)` reports losses, substitutions and candidate
28
+ suppression explanations. The differential is deliberately a triage aid, not a proof where
29
+ renderer identity remains incomplete.
30
+
31
+ - **Physical-completeness lint now follows semantic correspondence for flats, lone slots and
32
+ slot patterns** (#1018). Coverage is joined through recognition, IR and compiler identities
33
+ rather than inferred from label text or page geometry, and distinguishes authored
34
+ suppression, placement drops, missing definitions and unverifiable provenance.
35
+
36
+ - **Full-span floored open channels are recognised and dimensioned as first-class features**
37
+ (#917). Automatic and declared drawings state the independent overall extent, one wall and
38
+ channel width, suppress the derived opposite wall, and report any missing member of that
39
+ defining chain.
40
+
41
+ ### Fixed
42
+
43
+ - **Square and near-square parts retain both independent planar extents** (#997). A plain
44
+ square plate could previously lose both plan dimensions, while parts up to five percent off
45
+ square could lose one and be silently represented as square. The unsafe suppression rule is
46
+ removed; explicit square notation remains tracked separately in #918.
47
+
48
+ - **Machined flats retain their physical stock identity from recognition through placement
49
+ and completeness lint** (#1013/#1015/#1034/#1036). Flats on separate parallel, coaxial or
50
+ slanted stock no longer collapse into one definition or borrow an opposite face from another
51
+ stock region. Independent callouts receive clear-margin candidates, while double-D faces on
52
+ one stock still form one A/F requirement.
53
+
54
+ - **Dense callout and step-detail cases keep their complete defining set** (#915). Hole
55
+ callout batches are reconciled as a queue, detail views fit their actual aspect, step levels
56
+ retain the supporting-face correspondence needed for truthful crops, and a detail redraws
57
+ only rungs omitted from its parent view.
58
+
59
+ - **Pocket leaders start on the physical opening rim** (#916), removing the spurious inner
60
+ silhouette crossing while preserving the solved label corridor and approved dimensions on
61
+ X-, Y- and Z-depth pockets.
62
+
63
+ - **Wedge-mounted raised pads survive recognition** (#909). Lower ledges that touch only in
64
+ plan no longer make the recogniser discard a valid upper pad; true staircase tiers remain
65
+ excluded.
66
+
67
+ - **Annotation-dense detail placement no longer stalls the build** (#1065). Detail views
68
+ avoid every decomposed annotation shaft, witness, label and item footprint; CTC-02
69
+ supplies 571 such boxes. The free-rectangle search previously treated that set as tiny
70
+ and admitted roughly 34 billion candidate rectangles, exhausting the slow tier's
71
+ ten-minute test budget before export. A compressed-coordinate sweep preserves the same
72
+ deterministic winning rectangle while completing the real search in seconds.
73
+
74
+ - **Through double-D bores are recognised and called out as first-class hole profiles**
75
+ (#1061). Automatic and declared builds preserve the parent-circle diameter, A/F size,
76
+ orientation and depth, and render one compound `DOUBLE-D ... A/F` bore callout. Physical
77
+ critique now accepts that supported inner profile while retaining a separate warning for
78
+ 13 evenly spaced common-circle arcs on the unresolved outer boundary; it does not infer a
79
+ repeating full profile or a gear standard.
80
+
81
+ - **Unsupported internal profiles no longer pass physical-completeness lint silently**
82
+ (#1058). Inner loops on principal boundary faces are checked against the current
83
+ circular-hole, rectangular-opening, true-obround, and proven through-double-D vocabulary.
84
+ Profiles outside it now produce
85
+ `unrecognised_defining_geometry` and reduce the lint score instead of allowing an
86
+ envelope-only drawing to score 1.0.
87
+
88
+ ### Changed
89
+
90
+ - **Crowded prismatic step dimensions recover into an enlarged detail view by default**
91
+ (#909). The automatic `build_drawing` / `make_drawing` / CLI paths and generated
92
+ `Sheet` scripts now preserve the omitted dimensions instead of returning
93
+ `step_dim_dropped` unless the caller remembered `detail_view=True`. Pass
94
+ `detail_view=False` to retain the parent-view-only behavior and its lint warning.
95
+
5
96
  ## v0.4.0 — 2026-08-02
6
97
 
7
98
  **The compat-exit release.** Every surface whose documented removal target was 0.4.0 is gone,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.4.0
3
+ Version: 0.4.1
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
@@ -771,9 +771,12 @@ from draftwright import build_drawing
771
771
 
772
772
  dwg = build_drawing(part, title="Mounting Plate")
773
773
  issues = dwg.lint() # list[LintIssue] — coverage, page bounds, ISO
774
- svg_path, dxf_path = dwg.export("my_part")
774
+ paths = dwg.export("my_part", formats=("svg", "dxf")) # {"svg": ..., "dxf": ...}
775
775
  ```
776
776
 
777
+ `export()` with no `formats=` returns the old `(svg, dxf)` tuple; that shape is deprecated
778
+ and removed in 0.5.0 (see [docs/deprecations.md](docs/deprecations.md)).
779
+
777
780
  ### Declarative drawings — reference features, declare intent
778
781
 
779
782
  Instead of relying on detection, **reference the build123d objects you built** and declare
@@ -789,10 +792,16 @@ plate = Box(120, 80, 20)
789
792
  bore = Pos(0, 0, 0) * Cylinder(4, 20)
790
793
 
791
794
  sheet = Sheet(plate - bore, title="Plate", number="DWG-002")
792
- sheet.envelope()
795
+ env = sheet.envelope()
793
796
  sheet.datum("A", plate.faces().sort_by()[-1]) # datum A on the top face
794
797
  hole = sheet.hole(bore).finish("1.6").note("M3x0.5 TAP") # ⌀8 bore, Ra 1.6, tapped
795
798
  sheet.control(hole).position(0.1, to="A", diameter=True) # ⌀0.1 position wrt A
799
+
800
+ sheet.authored_dimensions() # THIS is the complete set
801
+ sheet.dimension(env, "width.length") # (ADR 0016: a build says where
802
+ sheet.dimension(env, "height.length") # its dimensions come from, and
803
+ sheet.dimension(hole, "bore.diameter") # omitting one means suppress it)
804
+
796
805
  sheet.export("plate") # writes plate.pdf
797
806
  ```
798
807
 
@@ -75,9 +75,12 @@ from draftwright import build_drawing
75
75
 
76
76
  dwg = build_drawing(part, title="Mounting Plate")
77
77
  issues = dwg.lint() # list[LintIssue] — coverage, page bounds, ISO
78
- svg_path, dxf_path = dwg.export("my_part")
78
+ paths = dwg.export("my_part", formats=("svg", "dxf")) # {"svg": ..., "dxf": ...}
79
79
  ```
80
80
 
81
+ `export()` with no `formats=` returns the old `(svg, dxf)` tuple; that shape is deprecated
82
+ and removed in 0.5.0 (see [docs/deprecations.md](docs/deprecations.md)).
83
+
81
84
  ### Declarative drawings — reference features, declare intent
82
85
 
83
86
  Instead of relying on detection, **reference the build123d objects you built** and declare
@@ -93,10 +96,16 @@ plate = Box(120, 80, 20)
93
96
  bore = Pos(0, 0, 0) * Cylinder(4, 20)
94
97
 
95
98
  sheet = Sheet(plate - bore, title="Plate", number="DWG-002")
96
- sheet.envelope()
99
+ env = sheet.envelope()
97
100
  sheet.datum("A", plate.faces().sort_by()[-1]) # datum A on the top face
98
101
  hole = sheet.hole(bore).finish("1.6").note("M3x0.5 TAP") # ⌀8 bore, Ra 1.6, tapped
99
102
  sheet.control(hole).position(0.1, to="A", diameter=True) # ⌀0.1 position wrt A
103
+
104
+ sheet.authored_dimensions() # THIS is the complete set
105
+ sheet.dimension(env, "width.length") # (ADR 0016: a build says where
106
+ sheet.dimension(env, "height.length") # its dimensions come from, and
107
+ sheet.dimension(hole, "bore.diameter") # omitting one means suppress it)
108
+
100
109
  sheet.export("plate") # writes plate.pdf
101
110
  ```
102
111
 
@@ -33,6 +33,7 @@ architecture** table; open retired or superseded records only for design history
33
33
  | [0014](0014-collect-then-solve-annotation-placement.md) | Collect-then-solve annotation placement (as built) | Collect, select, assign, and deterministically solve annotations per corridor before rendering. | Accepted; supersedes 0009 | `test_carve_free_position_callers.py`, `test_strip_layout.py`, `test_layout_property.py`, `test_import_boundaries.py` |
34
34
  | [0015](0015-part-drawing-compiler-as-built.md) | The part-drawing compiler, as built | Use one detected-or-declared feature IR and planner-fed dimension groups as the compiler waist. | Accepted; supersedes 0008 | `test_part_model.py`, `test_detect_once.py`, `test_import_boundaries.py` |
35
35
  | [0016](0016-declared-dimensioning-intent.md) | Declared dimensioning intent: capture what to measure, let the engine place it | Declare which measurements matter as scale-independent intent routed through the planner and corridor solve; never hardcode dimension geometry. | Accepted; epic #867 complete; phase 6 landed (#940) | `test_compiled_plan_boundary.py`, `test_label_provenance.py`, `test_sheet_emit.py`, `test_add_dimension.py` |
36
+ | [0017](0017-recognition-inventory-correspondence-and-measurement-provenance.md) | One recognition result per run; correspondence is evidence-gated | Produce one explicit recognition result owned by `BuildState`; require vertical-slice evidence before generalising correspondence, identity, requirements, outcomes, or reconciliation. | Accepted; ownership phase landed, extensions gated by #1018 | `test_recognition_manifest.py`, `test_recognition_result.py`, `test_declared_recognition_gate.py` |
36
37
 
37
38
  ## Historical records
38
39
 
@@ -49,5 +50,6 @@ architecture** table; open retired or superseded records only for design history
49
50
  - Layout and placement: 0004 → 0014 → 0012.
50
51
  - Declared intent and the editable surface: 0001 → 0011 → 0012 → 0016.
51
52
  - Quality and correction: 0002, with provenance from 0010.
53
+ - Recognition correspondence and completeness: 0007 → 0013 → 0015 → 0017.
52
54
 
53
55
  Tracking issue: #745.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -80,6 +80,18 @@ markers = [
80
80
  "slow: heavy end-to-end CTC fixture builds (deselected by default)",
81
81
  "smoke: curated build-light subset for a fast local check (~30 s)",
82
82
  ]
83
+ # `SoftDeprecationWarning` marks a surface that is discouraged but SUPPORTED and not going
84
+ # away (#1043). The suite exercises `Sheet.auto_dimensions()` in ~170 places, deliberately —
85
+ # it is still a supported path and its behaviour must keep being tested. Migrating those to
86
+ # authored dimensions would be churn that tests less, so the category is silenced here rather
87
+ # than at ~170 call sites.
88
+ #
89
+ # This does NOT weaken the guard: `tests/test_soft_deprecation.py` asserts the warning with
90
+ # `pytest.warns`, which fires regardless of this filter. Real `DeprecationWarning`s are
91
+ # untouched and still carry removal dates (`tests/test_deprecation_dates.py`).
92
+ filterwarnings = [
93
+ "ignore::draftwright.SoftDeprecationWarning",
94
+ ]
83
95
  # Coverage is a CI concern (it adds ~13% locally and instruments every line); the
84
96
  # CI workflow passes the --cov flags explicitly. Keep the local default lean.
85
97
  addopts = "--tb=short -m 'not slow'"
@@ -87,6 +99,9 @@ addopts = "--tb=short -m 'not slow'"
87
99
  [tool.coverage.run]
88
100
  source = ["src/draftwright"]
89
101
  omit = ["src/draftwright/__init__.py"]
102
+ # Keep XML paths anchored at the repository root so local diff coverage and Codecov
103
+ # measure the same changed source lines rather than relying on runner-specific paths.
104
+ relative_files = true
90
105
  # Branch coverage makes the regression gate sensitive to untested decision paths,
91
106
  # which matter more than raw statement execution in the recognition/layout code.
92
107
  branch = true
@@ -94,8 +109,8 @@ branch = true
94
109
  [tool.coverage.report]
95
110
  # Baseline measured on the full fast tier on Linux/Python 3.13 (#825):
96
111
  # 92.05% combined line+branch coverage (93.90% statements, 86.89% branches).
97
- # Keep two points of cross-platform/kernel headroom; ratchet upward only after the
98
- # lowest CI matrix result has stayed above the proposed floor.
112
+ # Keep two points of headroom; ratchet upward only after the canonical coverage job
113
+ # has stayed above the proposed floor.
99
114
  fail_under = 90
100
115
  precision = 1
101
116
  exclude_lines = [
@@ -32,6 +32,11 @@ _LAZY = {
32
32
  "PmiRecord": "draftwright.pmi",
33
33
  "extract_pmi": "draftwright.pmi",
34
34
  "choose_scale": "draftwright.compose",
35
+ # A warning category users are told to filter must be importable without reaching into a
36
+ # private module (#1043 review) — and without paying for the CAD kernel. It lives in the
37
+ # dependency-free `_warnings` leaf for that second reason: defined in `_core` it cost ~6 s
38
+ # to reach, and the pytest filterwarnings entry naming it paid that on every invocation.
39
+ "SoftDeprecationWarning": "draftwright._warnings",
35
40
  }
36
41
 
37
42
 
@@ -68,6 +73,7 @@ _sys.modules[__name__].__class__ = _DraftwrightModule
68
73
 
69
74
 
70
75
  if TYPE_CHECKING: # static analysers / IDEs — no runtime import, no kernel cost
76
+ from draftwright._warnings import SoftDeprecationWarning
71
77
  from draftwright.builder import build_drawing, make_drawing
72
78
  from draftwright.compose import choose_scale
73
79
  from draftwright.drawing import Drawing, FeatureInfo
@@ -85,6 +91,7 @@ def __dir__():
85
91
 
86
92
  __all__ = [
87
93
  "Drawing",
94
+ "SoftDeprecationWarning",
88
95
  "FeatureInfo",
89
96
  "PmiRecord",
90
97
  "Sheet",
@@ -17,7 +17,7 @@ import functools
17
17
  import logging
18
18
  import math
19
19
  import re
20
- from bisect import bisect_right
20
+ from bisect import bisect_left, bisect_right
21
21
  from collections.abc import Callable
22
22
  from dataclasses import dataclass
23
23
  from pathlib import Path
@@ -26,7 +26,7 @@ from typing import TYPE_CHECKING, Literal
26
26
 
27
27
  if TYPE_CHECKING:
28
28
  from draftwright.compose import StripDepths
29
- from draftwright.recognition import TurnedProfile
29
+ from draftwright.recognition import RecognitionResult, TurnedProfile
30
30
 
31
31
  from build123d import Align, BoundBox, Compound, Edge, Location, Mode, Shape, Text, Vector
32
32
  from build123d_drafting.helpers import (
@@ -49,18 +49,21 @@ from draftwright.layout import _greedy_strip_1d, _solve_strip_1d
49
49
  _log = logging.getLogger(__name__)
50
50
 
51
51
 
52
- def place_annotation(registry, items, obj, name=None, view=None, feature=None):
52
+ def place_annotation(registry, items, obj, name=None, view=None, feature=None, measurement=None):
53
53
  """The annotation-placement primitive (#817): register *obj* under *name* — replacing any
54
54
  prior object of that name (dropped from the render list *items*) so a name maps to one
55
55
  object — append it to *items*, and record its owning *view* + source *feature* in *registry*.
56
56
  Shared by :meth:`Drawing._add` and :meth:`PlacementContext.place` so the render passes place
57
- annotations without reaching into the ``Drawing``. Returns *obj*."""
57
+ annotations without reaching into the ``Drawing``. Returns *obj*.
58
+
59
+ *measurement* is the `DimensionId` *obj* draws, where the caller holds one (#1002) —
60
+ one axis finer than *feature*, since a feature has several measurements."""
58
61
  displaced = registry.named(name) if name is not None else None
59
62
  if displaced is not None:
60
63
  items.remove(displaced)
61
64
  annotate(obj, name)
62
65
  items.append(obj)
63
- registry.add(obj, name, view, feature)
66
+ registry.add(obj, name, view, feature, measurement)
64
67
  return obj
65
68
 
66
69
 
@@ -536,58 +539,184 @@ def _legible_locations(positions, scale):
536
539
  return kept, n_too_close
537
540
 
538
541
 
539
- def _largest_empty_rect(drawable, obstacles, *, warn: bool = True):
542
+ class _FreeRunIndex:
543
+ """Range-covered Y coordinates with the longest remaining free run.
544
+
545
+ Coordinate points are represented as zero-length cells between the ordinary
546
+ intervals. That lets a degenerate obstacle split a gap exactly as the open
547
+ AABB overlap predicate does, without assigning the point any physical width.
548
+ """
549
+
550
+ def __init__(self, coordinates):
551
+ self.coordinates = coordinates
552
+ lengths = []
553
+ for i, value in enumerate(coordinates):
554
+ lengths.append(0.0)
555
+ if i + 1 < len(coordinates):
556
+ lengths.append(coordinates[i + 1] - value)
557
+ self._lengths = lengths
558
+ self._n = len(lengths)
559
+ size = 4 * self._n
560
+ self._cover = [0] * size
561
+ self._total = [0.0] * size
562
+ self._prefix = [0.0] * size
563
+ self._suffix = [0.0] * size
564
+ self._longest = [0.0] * size
565
+ self._all_free = [True] * size
566
+ self._build(1, 0, self._n)
567
+
568
+ def _build(self, node, lo, hi):
569
+ if hi - lo == 1:
570
+ length = self._lengths[lo]
571
+ self._total[node] = length
572
+ self._prefix[node] = length
573
+ self._suffix[node] = length
574
+ self._longest[node] = length
575
+ return
576
+ mid = (lo + hi) // 2
577
+ self._build(node * 2, lo, mid)
578
+ self._build(node * 2 + 1, mid, hi)
579
+ self._pull(node)
580
+
581
+ def _pull(self, node):
582
+ if self._cover[node]:
583
+ self._all_free[node] = False
584
+ self._prefix[node] = self._suffix[node] = self._longest[node] = 0.0
585
+ return
586
+ left, right = node * 2, node * 2 + 1
587
+ self._total[node] = self._total[left] + self._total[right]
588
+ self._all_free[node] = self._all_free[left] and self._all_free[right]
589
+ self._prefix[node] = self._prefix[left]
590
+ if self._all_free[left]:
591
+ self._prefix[node] += self._prefix[right]
592
+ self._suffix[node] = self._suffix[right]
593
+ if self._all_free[right]:
594
+ self._suffix[node] += self._suffix[left]
595
+ self._longest[node] = max(
596
+ self._longest[left],
597
+ self._suffix[left] + self._prefix[right],
598
+ self._longest[right],
599
+ )
600
+
601
+ @property
602
+ def longest(self):
603
+ return self._longest[1]
604
+
605
+ def block(self, lo, hi):
606
+ """Add one obstacle interval in coordinate space."""
607
+ first = bisect_left(self.coordinates, lo)
608
+ last = bisect_left(self.coordinates, hi)
609
+ if lo == hi:
610
+ unit_lo, unit_hi = 2 * first, 2 * first + 1
611
+ else:
612
+ # Block the open span but not its endpoints: touching an obstacle is
613
+ # permitted by the placement AABB predicate.
614
+ unit_lo, unit_hi = 2 * first + 1, 2 * last
615
+ self._block(1, 0, self._n, unit_lo, unit_hi)
616
+
617
+ def _block(self, node, lo, hi, block_lo, block_hi):
618
+ if block_lo <= lo and hi <= block_hi:
619
+ self._cover[node] += 1
620
+ self._pull(node)
621
+ return
622
+ mid = (lo + hi) // 2
623
+ if block_lo < mid:
624
+ self._block(node * 2, lo, mid, block_lo, block_hi)
625
+ if mid < block_hi:
626
+ self._block(node * 2 + 1, mid, hi, block_lo, block_hi)
627
+ self._pull(node)
628
+
629
+ def first_run(self, minimum):
630
+ """Lowest-Y free run at least *minimum* long."""
631
+ return self._first_run(1, 0, self._n, self.coordinates[0], minimum)
632
+
633
+ def _first_run(self, node, lo, hi, origin, minimum):
634
+ if hi - lo == 1:
635
+ return origin
636
+ left, right = node * 2, node * 2 + 1
637
+ mid = (lo + hi) // 2
638
+ if self._longest[left] >= minimum:
639
+ return self._first_run(left, lo, mid, origin, minimum)
640
+ if self._suffix[left] + self._prefix[right] >= minimum:
641
+ return origin + self._total[left] - self._suffix[left]
642
+ return self._first_run(
643
+ right,
644
+ mid,
645
+ hi,
646
+ origin + self._total[left],
647
+ minimum,
648
+ )
649
+
650
+
651
+ def _largest_empty_rect(drawable, obstacles, *, target_size=None, warn: bool = True):
540
652
  """Largest axis-aligned empty rectangle in *drawable* avoiding *obstacles*.
541
653
 
542
654
  *drawable* and each obstacle are ``(x0, y0, x1, y1)`` page-mm boxes. Returns
543
655
  the empty sub-rectangle of *drawable* (overlapping no obstacle) that maximises
544
- the side of the largest square it can hold — i.e. ``min(width, height)`` so
545
- the (near-square) iso view can be scaled up as far as possible.
546
-
547
- The obstacle set is tiny (front/plan/side views + title block), so a
548
- gap-based search over candidate edges is both exact enough and cheap: every
549
- maximal empty rectangle has edges drawn from the drawable bounds and the
550
- obstacle bounds, so enumerating those cut lines finds the optimum.
656
+ the uniform scale of *target_size*. The default ``(1, 1)`` maximises the side
657
+ of the largest square it can hold, preserving the iso-view policy. A caller
658
+ placing a wide or tall fixed-aspect footprint can supply its minimum
659
+ ``(width, height)`` instead.
660
+
661
+ Every maximal empty rectangle has edges drawn from the drawable bounds and
662
+ the obstacle bounds. Sweep candidate X strips while adding their blocking Y
663
+ intervals to a compressed-coordinate free-run index. This is equivalent to
664
+ enumerating every pair of Y edges, without the quartic blow-up when a detail
665
+ view includes hundreds of decomposed annotation segments (#1065).
551
666
  """
667
+ target_w, target_h = target_size or (1.0, 1.0)
668
+ if not (math.isfinite(target_w) and target_w > 0 and math.isfinite(target_h) and target_h > 0):
669
+ raise ValueError("target_size must contain finite positive dimensions")
670
+
552
671
  dx0, dy0, dx1, dy1 = drawable
553
672
  xs = sorted({dx0, dx1, *(c for o in obstacles for c in (o[0], o[2]) if dx0 < c < dx1)})
554
673
  ys = sorted({dy0, dy1, *(c for o in obstacles for c in (o[1], o[3]) if dy0 < c < dy1)})
555
674
 
556
- # The score is min(width, height), so any candidate whose width OR height is
557
- # ``<= best_score`` cannot beat the best found so far. Because ``xs``/``ys`` are
558
- # sorted and ``best_score`` only grows, we skip those candidates outright rather
559
- # than enumerate-then-reject them: ``break`` the outer loop once even its widest
560
- # candidate is too small, and ``bisect`` the inner loop's start past every pair
561
- # narrower than ``best_score``. This is an exact prune — skipped candidates could
562
- # never satisfy ``score > best_score`` — so the result (and its tie-breaking) is
563
- # identical to the naive quadruple loop, but the detail-view caller (which passes
564
- # every placed-annotation footprint, not just the handful of views) no longer
565
- # triggers an O(N⁴) blow-up (#661).
675
+ events = []
676
+ for ox0, oy0, ox1, oy1 in obstacles:
677
+ block_lo = max(dy0, oy0)
678
+ block_hi = min(dy1, oy1)
679
+ if block_hi < dy0 or block_lo > dy1:
680
+ continue
681
+ events.append((ox0, ox1, block_lo, block_hi))
682
+ events.sort(key=lambda event: event[0])
683
+
684
+ # The score is min(width / target_w, height / target_h), so any candidate whose
685
+ # width or height cannot exceed the corresponding scaled target cannot beat the
686
+ # best found so far. This is the same exact prune as the square-default path,
687
+ # normalized by the requested footprint.
566
688
  best = None
567
689
  best_score = 0.0
568
- nx, ny = len(xs), len(ys)
690
+ nx = len(xs)
569
691
  for i in range(nx - 1):
570
692
  rx0 = xs[i]
571
- if xs[-1] - rx0 <= best_score:
693
+ if xs[-1] - rx0 <= best_score * target_w:
572
694
  break # widest strip from here on can't beat best (rx0 only grows)
573
- for j in range(bisect_right(xs, rx0 + best_score), nx):
695
+ free_y = _FreeRunIndex(ys)
696
+ event_index = 0
697
+ for j in range(bisect_right(xs, rx0 + best_score * target_w), nx):
574
698
  rx1 = xs[j]
575
699
  width = rx1 - rx0
576
- # only obstacles overlapping the x-strip [rx0, rx1] can block it
577
- strip = [(o[1], o[3]) for o in obstacles if o[0] < rx1 and rx0 < o[2]]
578
- for k in range(ny - 1):
579
- ry0 = ys[k]
580
- if ys[-1] - ry0 <= best_score:
581
- break # tallest gap from here can't beat best (ry0 only grows)
582
- for m in range(bisect_right(ys, ry0 + best_score), ny):
583
- ry1 = ys[m]
584
- if any(ry0 < oy1 and oy0 < ry1 for (oy0, oy1) in strip):
585
- continue
586
- height = ry1 - ry0
587
- score = width if width < height else height
588
- if score > best_score:
589
- best_score = score
590
- best = (rx0, ry0, rx1, ry1)
700
+ while event_index < len(events) and events[event_index][0] < rx1:
701
+ _, ox1, block_lo, block_hi = events[event_index]
702
+ if rx0 < ox1:
703
+ free_y.block(block_lo, block_hi)
704
+ event_index += 1
705
+
706
+ height = free_y.longest
707
+ score = min(width / target_w, height / target_h)
708
+ if score <= best_score:
709
+ continue
710
+
711
+ # If width caps the score, select the first global Y edge that makes
712
+ # the target fit. Otherwise select the end of the first tallest gap.
713
+ # These are the same deterministic winners as the exhaustive search.
714
+ needed_height = min(width * target_h / target_w, height)
715
+ ry0 = free_y.first_run(needed_height)
716
+ edge = bisect_left(ys, ry0 + needed_height)
717
+ ry1 = ys[min(edge, len(ys) - 1)]
718
+ best_score = score
719
+ best = (rx0, ry0, rx1, ry1)
591
720
  if best is None:
592
721
  # No empty rectangle exists (obstacles cover the drawable area). This
593
722
  # is unreachable in practice — choose_scale always leaves a gap — but
@@ -819,6 +948,12 @@ class Analysis:
819
948
  """
820
949
 
821
950
  part: Shape
951
+ #: The ADR 0017 aggregate, or ``None`` on a DECLARED build — which recognises nothing
952
+ #: (ADR 0011 / #1022). ``None`` means "not detected", never "detected and empty": a
953
+ #: consumer needing an inventory on that path must go through the lazy
954
+ #: ``Drawing._recognition()``, which builds one on demand rather than reading an absence
955
+ #: as an answer.
956
+ recognition: RecognitionResult | None
822
957
  bb: BoundBox
823
958
  x_size: float
824
959
  y_size: float
@@ -836,7 +971,7 @@ class Analysis:
836
971
  pads: list # geometry-derived rectangular-pad coverage inventory (#885)
837
972
  z_diams: list[float]
838
973
  cross_diams: list[float]
839
- cyls: tuple[list, list]
974
+ cyls: tuple[tuple, tuple]
840
975
  prof: TurnedProfile | None # turned step profile (recognise_turned_steps), detected once
841
976
  od_diam: float | None
842
977
  is_rotational: bool
@@ -12,6 +12,7 @@ drawing grab-bag (ADR 0008; #584 WP2). This module imports nothing from
12
12
  from __future__ import annotations
13
13
 
14
14
  import logging
15
+ import math
15
16
  from dataclasses import dataclass
16
17
 
17
18
  from build123d import Compound
@@ -108,6 +109,102 @@ def _axis_letter_of(axis) -> str:
108
109
  return max(zip("xyz", axis, strict=True), key=lambda t: abs(t[1]))[0]
109
110
 
110
111
 
112
+ def _axis_direction_components(axis: str, direction=None):
113
+ """Validate a direction and return ``(raw, norm, dominant_index)``."""
114
+ if axis not in "xyz" or len(axis) != 1:
115
+ raise ValueError("axis must be 'x', 'y', or 'z'")
116
+ if direction is None:
117
+ direction = tuple(1.0 if letter == axis else 0.0 for letter in "xyz")
118
+ try:
119
+ raw = tuple(float(component) for component in direction)
120
+ except (TypeError, ValueError) as exc:
121
+ raise ValueError("axis_direction must be a 3-vector") from exc
122
+ if len(raw) != 3:
123
+ raise ValueError("axis_direction must be a 3-vector")
124
+ if not all(math.isfinite(component) for component in raw):
125
+ raise ValueError("axis_direction must contain only finite values")
126
+ norm = math.hypot(*raw)
127
+ if norm <= 1e-12:
128
+ raise ValueError("axis_direction must be non-zero")
129
+ idx = "xyz".index(axis)
130
+ if abs(raw[idx]) + norm * 1e-9 < max(abs(component) for component in raw):
131
+ raise ValueError(f"axis_direction's dominant component must match axis={axis!r}")
132
+ return raw, norm, idx
133
+
134
+
135
+ def _normalised_axis_direction(axis: str, direction=None) -> tuple[float, float, float]:
136
+ """Full-precision unit direction used for geometric projection."""
137
+ raw, norm, idx = _axis_direction_components(axis, direction)
138
+ sign = -1.0 if raw[idx] < 0 else 1.0
139
+ return (
140
+ sign * raw[0] / norm,
141
+ sign * raw[1] / norm,
142
+ sign * raw[2] / norm,
143
+ )
144
+
145
+
146
+ def _canonical_axis_direction(axis: str, direction=None) -> tuple[float, float, float]:
147
+ """Return a stable unit direction whose named dominant component is positive.
148
+
149
+ ``axis`` remains the orthographic routing hint used by the IR. ``direction`` is the
150
+ actual geometric axis needed to distinguish slanted stock lines; omitting it means the
151
+ corresponding principal axis. Six decimal places preserve direction while keeping
152
+ recognition/declaration identities stable across round trips.
153
+ """
154
+ raw, norm, idx = _axis_direction_components(axis, direction)
155
+ sign = -1.0 if raw[idx] < 0 else 1.0
156
+ # A previously canonical six-decimal vector is close enough to unit length that
157
+ # normalising it again can move the last digit. Preserve it to make emit -> declare
158
+ # idempotent; arbitrary vectors are still normalised on first entry.
159
+ unit = (
160
+ tuple(sign * component for component in raw)
161
+ if abs(norm - 1.0) <= 2e-6
162
+ else _normalised_axis_direction(axis, raw)
163
+ )
164
+ rounded = tuple(0.0 if abs(component) < 0.5e-6 else round(component, 6) for component in unit)
165
+ return (rounded[0], rounded[1], rounded[2])
166
+
167
+
168
+ def _canonical_axis_span(axis: str, direction, span) -> tuple[float, float]:
169
+ """Express an axial extent along the canonical positive direction."""
170
+ raw, _norm, idx = _axis_direction_components(axis, direction)
171
+ lo, hi = (float(value) for value in span)
172
+ if raw[idx] < 0:
173
+ lo, hi = -hi, -lo
174
+ return (round(lo, 3), round(hi, 3))
175
+
176
+
177
+ def _axis_line_coordinates(axis: str, point, direction=None) -> tuple[float, float]:
178
+ """Canonical in-plane coordinates of a 3-D axis line.
179
+
180
+ The perpendicular foot from the origin makes the result invariant to which point on the
181
+ line a geometry kernel reports. The named dominant coordinate is omitted; together with
182
+ the direction it is recoverable from the foot's perpendicularity, so two numbers retain
183
+ the aligned-stock representation while remaining sufficient for slanted stock.
184
+ """
185
+ px, py, pz = (float(component) for component in point)
186
+ # Use the unrounded unit vector here. Rounding before projection amplifies angular error
187
+ # into millimetres when the reported axis point is tens of metres from the origin.
188
+ vector = _normalised_axis_direction(axis, direction)
189
+ along = px * vector[0] + py * vector[1] + pz * vector[2]
190
+ foot = tuple(component - along * delta for component, delta in zip((px, py, pz), vector))
191
+ keep = [i for i, letter in enumerate("xyz") if letter != axis]
192
+ coordinates = tuple(round(foot[i], 3) for i in keep)
193
+ return (
194
+ 0.0 if coordinates[0] == 0 else coordinates[0],
195
+ 0.0 if coordinates[1] == 0 else coordinates[1],
196
+ )
197
+
198
+
199
+ def _axis_direction_is_aligned(axis: str, direction, *, tol: float = 1e-3) -> bool:
200
+ """Whether a canonical direction follows the principal axis named by ``axis``."""
201
+ vector = _canonical_axis_direction(axis, direction)
202
+ idx = "xyz".index(axis)
203
+ return abs(vector[idx] - 1.0) <= tol and all(
204
+ abs(vector[j]) <= tol for j in range(3) if j != idx
205
+ )
206
+
207
+
111
208
  def _fmt(v: float) -> str:
112
209
  """Format a float as integer string if whole, otherwise 1 dp. The one number
113
210
  formatter the IR (:mod:`draftwright.model.ir`) and the drawing layers