draftwright 0.3.1__tar.gz → 0.3.2__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 (127) hide show
  1. {draftwright-0.3.1 → draftwright-0.3.2}/CHANGELOG.md +45 -0
  2. {draftwright-0.3.1 → draftwright-0.3.2}/PKG-INFO +2 -2
  3. {draftwright-0.3.1 → draftwright-0.3.2}/pyproject.toml +2 -2
  4. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/_core.py +29 -16
  5. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/analysis.py +5 -0
  6. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/annotations/_common.py +228 -17
  7. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/annotations/from_model.py +170 -9
  8. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/annotations/holes.py +23 -5
  9. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/builder.py +8 -3
  10. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/drawing.py +19 -1
  11. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/export.py +27 -0
  12. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/linting/structural.py +102 -35
  13. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/fillets.py +23 -11
  14. draftwright-0.3.2/tests/refactor_golden/grid_plate.json +474 -0
  15. {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/holed_slot.json +2 -2
  16. draftwright-0.3.2/tests/refactor_golden/scattered_plate.json +649 -0
  17. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_carve_free_position_callers.py +7 -1
  18. draftwright-0.3.2/tests/test_detect_once.py +81 -0
  19. draftwright-0.3.2/tests/test_export_dxf_zoom.py +65 -0
  20. draftwright-0.3.2/tests/test_fillets_adjacency.py +53 -0
  21. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_layout_cleanliness.py +8 -1
  22. draftwright-0.3.2/tests/test_lint_box_cache.py +117 -0
  23. draftwright-0.3.2/tests/test_pitch_dim_footprint.py +157 -0
  24. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_refactor_golden.py +40 -1
  25. {draftwright-0.3.1 → draftwright-0.3.2}/.gitignore +0 -0
  26. {draftwright-0.3.1 → draftwright-0.3.2}/LICENSE +0 -0
  27. {draftwright-0.3.1 → draftwright-0.3.2}/README.md +0 -0
  28. {draftwright-0.3.1 → draftwright-0.3.2}/skills/SKILL.md +0 -0
  29. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/__init__.py +0 -0
  30. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/_geometry.py +0 -0
  31. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/annotate.py +0 -0
  32. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/annotations/__init__.py +0 -0
  33. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/annotations/orchestrator.py +0 -0
  34. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/annotations/sections.py +0 -0
  35. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/cli.py +0 -0
  36. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/compose.py +0 -0
  37. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/fits.py +0 -0
  38. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  39. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  40. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  41. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  42. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/fonts/__init__.py +0 -0
  43. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/intents.py +0 -0
  44. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/layout.py +0 -0
  45. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/linting/__init__.py +0 -0
  46. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/linting/coverage.py +0 -0
  47. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/linting/issues.py +0 -0
  48. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/linting/suggest.py +0 -0
  49. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/make_drawing.py +0 -0
  50. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/model/__init__.py +0 -0
  51. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/model/declare.py +0 -0
  52. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/model/detect.py +0 -0
  53. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/model/ir.py +0 -0
  54. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/model/planner.py +0 -0
  55. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/pmi.py +0 -0
  56. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/projection.py +0 -0
  57. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/__init__.py +0 -0
  58. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/_features.py +0 -0
  59. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/_record.py +0 -0
  60. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/chamfers.py +0 -0
  61. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/countersinks.py +0 -0
  62. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/flats.py +0 -0
  63. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/grooves.py +0 -0
  64. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/levels.py +0 -0
  65. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/plates.py +0 -0
  66. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/slots.py +0 -0
  67. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/recognition/turned.py +0 -0
  68. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/registry.py +0 -0
  69. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/repair.py +0 -0
  70. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/score.py +0 -0
  71. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/sheet.py +0 -0
  72. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/sheet_dsl.py +0 -0
  73. {draftwright-0.3.1 → draftwright-0.3.2}/src/draftwright/sheet_emit.py +0 -0
  74. {draftwright-0.3.1 → draftwright-0.3.2}/tests/_kernel.py +0 -0
  75. {draftwright-0.3.1 → draftwright-0.3.2}/tests/_layout_sig.py +0 -0
  76. {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  77. {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  78. {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  79. {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  80. {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  81. {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  82. {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  83. {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  84. {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  85. {draftwright-0.3.1 → draftwright-0.3.2}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  86. {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/bracket_section.json +0 -0
  87. {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/centered_rebate.json +0 -0
  88. {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/chamfered.json +0 -0
  89. {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/filleted.json +0 -0
  90. {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/flange_dense.json +0 -0
  91. {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/grooved_shaft.json +0 -0
  92. {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/hex_bar.json +0 -0
  93. {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/pocketed.json +0 -0
  94. {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/prismatic_ladder.json +0 -0
  95. {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/side_drilled.json +0 -0
  96. {draftwright-0.3.1 → draftwright-0.3.2}/tests/refactor_golden/turned_stepped.json +0 -0
  97. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_declare.py +0 -0
  98. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_drawing_encapsulation.py +0 -0
  99. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_e2e_slice.py +0 -0
  100. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_e2e_standards.py +0 -0
  101. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_fits.py +0 -0
  102. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_gdt_placement.py +0 -0
  103. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_import_boundaries.py +0 -0
  104. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_layout.py +0 -0
  105. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_layout_hypothesis.py +0 -0
  106. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_layout_property.py +0 -0
  107. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_lint_reconciliation.py +0 -0
  108. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_lint_structural.py +0 -0
  109. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_linting.py +0 -0
  110. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_make_drawing.py +0 -0
  111. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_object_aspects.py +0 -0
  112. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_part_model.py +0 -0
  113. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_pmi.py +0 -0
  114. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_private_test_imports.py +0 -0
  115. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_recogniser_contract.py +0 -0
  116. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_recognition.py +0 -0
  117. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_registry.py +0 -0
  118. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_render_seam.py +0 -0
  119. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_score.py +0 -0
  120. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_sheet_emit.py +0 -0
  121. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_sheet_gdt.py +0 -0
  122. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_sheet_notes.py +0 -0
  123. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_sheet_of.py +0 -0
  124. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_sheet_tables.py +0 -0
  125. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_strip_layout.py +0 -0
  126. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_tolerances.py +0 -0
  127. {draftwright-0.3.1 → draftwright-0.3.2}/tests/test_turned_steps.py +0 -0
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.2 — 2026-07-18
4
+
5
+ The **performance release**: the #602 perf loop (six PRs) plus the
6
+ build123d-drafting-helpers 0.14 upgrade. A dimension-dense drawing now builds
7
+ roughly **14× faster** than v0.3.1 (grid-plate benchmark 40 s → 2.8 s; NIST
8
+ CTC-02 ~107 s → well under half); the full test tier dropped ~14 min → ~3.5 min.
9
+
10
+ ### Performance
11
+
12
+ - **Placement measures, then builds** (#678, #679): corridor/strip candidates are
13
+ evaluated on analytical footprints (`dim_footprint`) or a single probe box; an
14
+ accepted candidate builds its OCC geometry exactly once and re-validates the
15
+ real box. Dimension constructions on the scattered-plate benchmark: 59 → 21
16
+ (= the placed count).
17
+ - **Detect once** (#682): the sizing model `_analyse` builds pre-scale is stored
18
+ on `Analysis.model` and reused as the render model — restoring ADR 0008
19
+ Amendment 5 ("one inventory, detected once"). ~13 s off CTC-02.
20
+ - **Fillet recognition de-quadratised** (#683): edge→faces adjacency map replaces
21
+ the O(faces²×edges²) `IsSame` sweep (3.7 M calls on CTC-02); 10.8 s → 4.6 s,
22
+ byte-identical records.
23
+ - **Lint bounding boxes memoised** (#681): `Drawing.lint()` 0.88 s → 0.07 s per
24
+ call (identity+location-token-checked cache beside the #143 view-edge cache).
25
+ - **DXF viewport from the known page window** (#680): skips `ezdxf.zoom.extents`'
26
+ per-entity walk; ~2.4 s per DXF export.
27
+ - **helpers 0.14** (#684): boolean-free dimension ink (upstream #177, filed and
28
+ fixed same-day) — ~25 ms per `Dimension`, was ~234 ms.
29
+
30
+ ### Changed
31
+
32
+ - **Tight-span dimensions now render honestly** (helpers 0.14): ISO outside
33
+ arrows where 0.13 silently degraded to a bare line. Ink extents grow on such
34
+ dims; placement models the flip analytically. Two placement-golden fixtures
35
+ re-blessed (ink extent only).
36
+ - Diameter-row leader labels space by **measured text width** and repair
37
+ helpers' shelf-flip collisions (crowded rows only; ordinary rows unchanged).
38
+ - A plate-thickness dim whose home strip corner is structurally contested falls
39
+ through to the opposite strip / side view instead of dropping (#684; the
40
+ underlying one-box-occupancy artifact is tracked as #685).
41
+
42
+ ### Fixed
43
+
44
+ - `recognise_fillets` neighbour tie-break is order-independent (#683 review).
45
+ - Lint's annotation-box cache prunes departed objects and detects in-place
46
+ relocation (#681 review).
47
+
3
48
  ## v0.3.1 — 2026-07-17
4
49
 
5
50
  Also lands the **#635 consolidation epic** (P1–P6, all six children closed): the render
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.3.1
3
+ Version: 0.3.2
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
@@ -682,7 +682,7 @@ Classifier: Programming Language :: Python :: 3.13
682
682
  Classifier: Programming Language :: Python :: 3.14
683
683
  Classifier: Topic :: Scientific/Engineering
684
684
  Requires-Python: <3.15,>=3.10
685
- Requires-Dist: build123d-drafting-helpers>=0.13.1
685
+ Requires-Dist: build123d-drafting-helpers>=0.14.0
686
686
  Requires-Dist: build123d<0.11,>=0.9.0; python_full_version < '3.13'
687
687
  Requires-Dist: build123d<0.12,>=0.11; python_full_version >= '3.13'
688
688
  Requires-Dist: numpy>=1.24
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -17,7 +17,7 @@ dependencies = [
17
17
  # 3.13+. Mirrors pzfreo/build123d-mcp's dependency handling.
18
18
  "build123d>=0.9.0,<0.11 ; python_full_version < '3.13'",
19
19
  "build123d>=0.11,<0.12 ; python_full_version >= '3.13'",
20
- "build123d-drafting-helpers>=0.13.1",
20
+ "build123d-drafting-helpers>=0.14.0",
21
21
  "numpy>=1.24",
22
22
  # PNG raster export (SVG→PDF→PNG). Both permissively licensed (Pillow HPND; pypdfium2
23
23
  # Apache-2.0 wrapping Google PDFium BSD-3) and ship pre-built wheels with NO native system
@@ -107,8 +107,10 @@ _QUOTED_RE = re.compile(r"'([^']*)'")
107
107
 
108
108
 
109
109
  @functools.lru_cache(maxsize=512)
110
- def _text_width(text: str, font_size: float, font_path: str = PLEX_MONO) -> float:
111
- """Measured rendered width (page-mm) of *text* at *font_size*.
110
+ def _text_size(
111
+ text: str, font_size: float, font_path: str | None = PLEX_MONO, font: str = "Arial"
112
+ ) -> tuple[float, float]:
113
+ """Measured rendered (width, height) (page-mm) of *text* at *font_size*.
112
114
 
113
115
  Uses build123d's ``Text`` — the same primitive ``Dimension``/``HoleCallout``
114
116
  stroke their labels with — so callout-width estimates use real glyph metrics
@@ -116,22 +118,27 @@ def _text_width(text: str, font_size: float, font_path: str = PLEX_MONO) -> floa
116
118
  (``font_path``), not a system font *name*: name resolution substitutes a
117
119
  different font on Linux, which makes this estimate — and the layout it feeds —
118
120
  platform-variant (#149). The default is the same face the annotations render
119
- with, so estimate and render agree. Cached because the same numeric labels
120
- recur across holes and the rasterisation is the costly part.
121
+ with, so estimate and render agree. *font* only matters when a caller opts out
122
+ of path-pinning (``font_path=None``) then the *name* resolves through the OS
123
+ font stack, exactly as the renderer would. Cached because the same numeric
124
+ labels recur across holes and the rasterisation is the costly part.
121
125
  """
122
126
  if not text:
123
- return 0.0
124
- return (
125
- Text(
126
- txt=text,
127
- font_size=font_size,
128
- font_path=font_path,
129
- align=(Align.CENTER, Align.CENTER),
130
- mode=Mode.PRIVATE,
131
- )
132
- .bounding_box()
133
- .size.X
134
- )
127
+ return (0.0, 0.0)
128
+ bb = Text(
129
+ txt=text,
130
+ font_size=font_size,
131
+ font=font,
132
+ font_path=font_path,
133
+ align=(Align.CENTER, Align.CENTER),
134
+ mode=Mode.PRIVATE,
135
+ ).bounding_box()
136
+ return (bb.size.X, bb.size.Y)
137
+
138
+
139
+ def _text_width(text: str, font_size: float, font_path: str = PLEX_MONO) -> float:
140
+ """Measured rendered width (page-mm) of *text* — see :func:`_text_size`."""
141
+ return _text_size(text, font_size, font_path)[0]
135
142
 
136
143
 
137
144
  # Cheap mean glyph advance as a fraction of the em (font size), for label-width ESTIMATES in
@@ -556,6 +563,12 @@ class Analysis:
556
563
  out: str
557
564
  pmi: list
558
565
  pmi_mode: str
566
+ # The PartModel built by _analyse's pre-scale sizing pass (#584 WP1 A) — stored so
567
+ # the render path reuses it instead of re-running the detectors (ADR 0008 Amdt 5:
568
+ # one inventory, detected once; #602). Typed `object` because model/ sits above
569
+ # _core in the DAG. None when the caller declared a model (ADR 0011) or on a
570
+ # manually-built Analysis — consumers fall back to build_model(a).
571
+ model: object | None = None
559
572
 
560
573
 
561
574
  _greedy_strip_ys = _greedy_strip_1d
@@ -772,4 +772,9 @@ def _analyse(
772
772
  out=out,
773
773
  pmi=pmi_records,
774
774
  pmi_mode=pmi,
775
+ # The sizing model IS the render model when detection ran (identical inputs by
776
+ # construction — #584 WP1 A); store it so the pipeline never detects twice
777
+ # (ADR 0008 Amdt 5, #602). A declared model (layout_model) is NOT stored: the
778
+ # builder coerces + decorates the caller's model itself.
779
+ model=sizing_model if layout_model is None else None,
775
780
  )
@@ -8,11 +8,13 @@ and an AABB overlap test (`_box_hits`). Bottom of the annotations DAG.
8
8
  from __future__ import annotations
9
9
 
10
10
  import logging
11
+ import math
11
12
  from dataclasses import dataclass, field
12
13
  from typing import Any
13
14
 
14
- from build123d_drafting.helpers import Dimension, SafeDimension
15
+ from build123d_drafting.helpers import DEFAULT_FONT_PATH, Dimension, SafeDimension
15
16
 
17
+ from draftwright._core import _text_size
16
18
  from draftwright.layout import StripCandidate, plan_strip
17
19
  from draftwright.linting.issues import LintIssue
18
20
  from draftwright.linting.structural import _centerline_extent
@@ -100,6 +102,84 @@ def _geom_box(o):
100
102
  return None
101
103
 
102
104
 
105
+ def dim_footprint(p1, p2, side, distance, draft, label):
106
+ """Analytical page-mm AABB ``(x0, y0, x1, y1)`` of the :class:`Dimension` that
107
+ ``_dim(p1, p2, side, distance, draft, label=label)`` would build — WITHOUT
108
+ constructing any OCC geometry (#602: a rejected candidate must not pay the
109
+ rendering cost).
110
+
111
+ Mirrors ``helpers.Dimension`` / build123d ``ExtensionLine``: each extension line
112
+ is the object→dimension-line segment *translated* ``extension_gap`` along itself,
113
+ so it spans ``p + side·gap`` → ``p + side·(distance + gap)`` — starting ``gap``
114
+ clear of the object and overshooting the dimension line by the same ``gap``. The
115
+ measured label is centred on the line's midpoint with its extents swapped for a
116
+ vertical measured segment (the label is rotated); everything strokes at
117
+ ``line_width``. With inside arrows the heads lie within the extension-line
118
+ overshoot (at preset sizes), so they add nothing to the hull.
119
+
120
+ Tight spans mirror helpers ≥0.14's outside-arrows flip (``_dim_line_ink``):
121
+ when the label and both heads don't fit — ``w + 2·al ≥ length`` or the shaft
122
+ piece beside a head would vanish — the ink extends ``2·arrow_length`` past
123
+ each end along the line. The label itself stays centred regardless of width
124
+ (``Dimension`` always passes an explicit ``label_t``, so the hang branch
125
+ never runs), and when its keep-clear reaches a witness end the witness's
126
+ overshoot past the dimension line is cut away with it. Without this model
127
+ the estimate under-covers exactly the dims v0.14 widens, and the
128
+ accept-time rebuild fails validation until the candidate is dropped (the
129
+ declared-plate 8 mm thickness dim was the first casualty). Assumes the
130
+ centred label (``label_offset_x=0``), like the rest of the estimate.
131
+ Callers accepting a candidate off this footprint must still build the real
132
+ geometry once and re-validate its box (the #602 validation fallback) so any
133
+ residual mismatch degrades to a wasted probe, never a collision.
134
+ """
135
+ if isinstance(side, str): # mirror helpers._SIDE_VECTORS ("above"/"below"/"left"/"right")
136
+ side = {
137
+ "above": (0.0, 1.0, 0.0),
138
+ "below": (0.0, -1.0, 0.0),
139
+ "left": (-1.0, 0.0, 0.0),
140
+ "right": (1.0, 0.0, 0.0),
141
+ }[side]
142
+ sx, sy = side[0], side[1]
143
+ off = abs(distance)
144
+ gap = draft.extension_gap
145
+ dxp, dyp = p2[0] - p1[0], p2[1] - p1[1]
146
+ # Mirror the renderer's font resolution exactly (helpers._font_path): the pinned
147
+ # font *file* when set, else the helpers default; an explicit font_path=None means
148
+ # name-based resolution of draft.font — so pass the name through too.
149
+ w, h = _text_size(
150
+ label,
151
+ draft.font_size,
152
+ getattr(draft, "font_path", DEFAULT_FONT_PATH),
153
+ getattr(draft, "font", "Arial"),
154
+ )
155
+ hx, hy = (h / 2.0, w / 2.0) if abs(dyp) > abs(dxp) else (w / 2.0, h / 2.0)
156
+ lcx = (p1[0] + p2[0]) / 2.0 + sx * off
157
+ lcy = (p1[1] + p2[1]) / 2.0 + sy * off
158
+ far = off + gap
159
+ xs = [p1[0] + sx * gap, p2[0] + sx * gap, lcx - hx, lcx + hx]
160
+ ys = [p1[1] + sy * gap, p2[1] + sy * gap, lcy - hy, lcy + hy]
161
+ # Helpers ≥0.14 tight-span behaviour. Along the line: the label's half-extent
162
+ # is w/2 regardless of orientation (the text rotates with the line); outside
163
+ # arrows extend the ink 2·al past each end when label+heads don't fit. Along
164
+ # the witness: the label keep-clear (h/2 + pad either side of the line) is
165
+ # cut out of a witness the centred label reaches, removing its overshoot
166
+ # unless a stub past the keep-clear survives (gap > h/2 + pad).
167
+ length = math.hypot(dxp, dyp)
168
+ al = getattr(draft, "arrow_length", 0.9 * draft.font_size)
169
+ tpad = getattr(draft, "pad_around_text", 0.0)
170
+ fits = length > 0 and (w + 2.0 * al < length) and (length / 2.0 - w / 2.0 - tpad > al / 2.0)
171
+ if not fits and length > 0:
172
+ ux, uy = dxp / length, dyp / length
173
+ xs += [p1[0] + sx * off - ux * 2.0 * al, p2[0] + sx * off + ux * 2.0 * al]
174
+ ys += [p1[1] + sy * off - uy * 2.0 * al, p2[1] + sy * off + uy * 2.0 * al]
175
+ label_covers_witness = length > 0 and length / 2.0 < w / 2.0 + tpad
176
+ if not label_covers_witness or gap > h / 2.0 + tpad:
177
+ xs += [p1[0] + sx * far, p2[0] + sx * far]
178
+ ys += [p1[1] + sy * far, p2[1] + sy * far]
179
+ pad = draft.line_width / 2.0
180
+ return (min(xs) - pad, min(ys) - pad, max(xs) + pad, max(ys) + pad)
181
+
182
+
103
183
  CROSSABLE_TYPES = frozenset({"Centerline", "CenterlineCircle", "CenterMark"})
104
184
  """Annotation types a *dimension* may legitimately cross (ISO 128): centre lines
105
185
  and centre marks. A **leader**, by contrast, must avoid them (#305) — so this is a
@@ -415,9 +495,20 @@ class CorridorCandidate:
415
495
  # the title block, which is placed after the corridor drain so the strip carve can't see
416
496
  # it (#481). ``None`` (every dim) skips the check → byte-identical.
417
497
  forbid: object | None = None
418
-
419
-
420
- def solve_corridor(dwg, strip, view, axis, cands, tier):
498
+ # Analytical ``pos -> (x0, y0, x1, y1)`` footprint of the geometry ``build(pos)``
499
+ # would produce (#602): lets the strip solve measure and evaluate this candidate
500
+ # without constructing OCC geometry at all (see :func:`dim_footprint`). ``None``
501
+ # falls back to one probe build at the strip edge + the box-shift model. CONTRACT:
502
+ # the footprint must be accurate — its PERPENDICULAR extent feeds the obstacle
503
+ # band filter, so an underestimate hides obstacles from the carve. The solve
504
+ # re-validates each built survivor against the blockers, the forbid box and the
505
+ # band-filtered-out obstacles, so a miss costs a wasted build and a retry, but
506
+ # keeping footprints truthful (``dim_footprint``, ±0.05 mm) is what keeps that
507
+ # fallback rare and the placement identical to the probe path.
508
+ footprint: object | None = None
509
+
510
+
511
+ def solve_corridor(dwg, strip, view, axis, cands, tier, corner_reserves=()):
421
512
  """One collect-then-solve over every :class:`CorridorCandidate` a shared strip
422
513
  accumulated across passes (ADR 0009 end state). Dedup → order → one non-force
423
514
  :func:`place_strip_candidates` pass → a force pass for the force-eligible leftovers →
@@ -472,6 +563,7 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
472
563
  prio = {c.name: c.priority for c in kept if c.priority} # over-capacity survival rank (#357)
473
564
  anchored = {c.name: c.anchored for c in kept if c.anchored}
474
565
  naturals = {c.name: c.natural for c in kept if c.natural is not None}
566
+ foots = {c.name: c.footprint for c in kept if c.footprint is not None} # analytical (#602)
475
567
  left = {
476
568
  n
477
569
  for n, _ in place_strip_candidates(
@@ -487,6 +579,8 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
487
579
  priorities=prio,
488
580
  anchored=anchored,
489
581
  naturals=naturals,
582
+ footprints=foots,
583
+ corner_reserves=corner_reserves,
490
584
  )
491
585
  }
492
586
  force_pairs = [(c.name, c.build) for c in kept if c.name in left and c.force]
@@ -501,6 +595,8 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
501
595
  force_pairs,
502
596
  tier,
503
597
  force=True,
598
+ footprints=foots,
599
+ corner_reserves=corner_reserves,
504
600
  features=feats,
505
601
  sizes=sizes,
506
602
  forbid=forbid,
@@ -540,6 +636,10 @@ class PlacementContext:
540
636
  corridor_batch: dict = field(default_factory=dict)
541
637
  escalations: list = field(default_factory=list)
542
638
  detail_requests: list = field(default_factory=list)
639
+ # Fallthrough callbacks a pass's on_drop queues to run AFTER every corridor has
640
+ # drained (#684 review): a mid-drain carve could occupy space a later sibling
641
+ # corridor's force candidate needs; deferral makes "post-drain" literally true.
642
+ post_drain: list = field(default_factory=list)
543
643
  # The drawing's build-state stores, referenced (not owned) by the run's passes (#639).
544
644
  # Duck-typed as ``Any`` — matching the untyped ``Drawing._record_build_issue`` they replace —
545
645
  # so mypy does not reject the delegating calls below.
@@ -601,10 +701,43 @@ def register_corridor(ctx, key, strip, view, axis, tier, cand):
601
701
  def drain_corridors(ctx, dwg):
602
702
  """Solve every registered corridor (one :func:`solve_corridor` per strip), then clear
603
703
  the batch. Called once, after all corridor-feeding passes have registered. Takes both the
604
- scratch *ctx* (the batch) and *dwg* (the drawing :func:`solve_corridor` places onto)."""
605
- for b in ctx.corridor_batch.values():
606
- solve_corridor(dwg, b["strip"], b["view"], b["axis"], b["cands"], b["tier"])
704
+ scratch *ctx* (the batch) and *dwg* (the drawing :func:`solve_corridor` places onto).
705
+
706
+ Corner coordination (helpers ≥0.14): perpendicular strips of one view contest the
707
+ view corners — a tight-span dim's outside-arrow tails overhang past the view edge
708
+ into the sibling strip's band (an 8 mm plate-thickness dim on the left strip dips
709
+ below the view bottom; the below strip's own tight dim pokes left of the view edge).
710
+ Solved sequentially and blind, the first drain fills the corner and the second's
711
+ force-kept candidate hard-drops. So each solve receives the *innermost-tier
712
+ footprint boxes* of every not-yet-drained same-view sibling's **force** candidates
713
+ as extra obstacles: the earlier drain places clear of the corner the later one
714
+ provably needs. Reservation is exact (the candidate's own analytical footprint, at
715
+ the innermost position it would take), restricted to force candidates — principal
716
+ dims that would otherwise drop rather than relocate — so best-effort occupants
717
+ never lose capacity to it. Already-drained siblings need nothing: their dims are
718
+ real obstacles via :func:`strip_obstacles`."""
719
+ batches = list(ctx.corridor_batch.values())
720
+ for i, b in enumerate(batches):
721
+ reserves = []
722
+ for sib in batches[i + 1 :]:
723
+ if sib["view"] != b["view"] or sib["strip"] is None:
724
+ continue
725
+ s = sib["strip"]
726
+ inner_pos = s.anchor + s.direction * s.gap
727
+ for c in sib["cands"]:
728
+ if c.force and c.footprint is not None:
729
+ box = c.footprint(inner_pos)
730
+ if box is not None:
731
+ reserves.append(box)
732
+ solve_corridor(
733
+ dwg, b["strip"], b["view"], b["axis"], b["cands"], b["tier"], corner_reserves=reserves
734
+ )
607
735
  ctx.corridor_batch = {}
736
+ # Deferred fallthroughs (opposite-strip retries) run once every strip has drained,
737
+ # so a retry can never preempt a corner a later sibling's force candidate needs.
738
+ pending, ctx.post_drain = ctx.post_drain, []
739
+ for cb in pending:
740
+ cb()
608
741
 
609
742
 
610
743
  def place_strip_candidates(
@@ -622,6 +755,8 @@ def place_strip_candidates(
622
755
  priorities=None,
623
756
  anchored=None,
624
757
  naturals=None,
758
+ footprints=None,
759
+ corner_reserves=(),
625
760
  ):
626
761
  """Collect-then-solve placement of location/feature dims on one strip (ADR 0009).
627
762
  The single shared strip placer that retires the ``Strip.allocate`` cursor (#150,
@@ -699,13 +834,59 @@ def place_strip_candidates(
699
834
  # obstacles out first. The perpendicular extent is independent of the tier position,
700
835
  # so a single probe build per candidate suffices; the corridor check below already
701
836
  # uses the full 2-D box, so it needs no such filter.
702
- pbands = [
703
- (b[perp], b[perp + 2]) for _n, build in cands if (b := _geom_box(build(lo))) is not None
704
- ]
837
+ #
838
+ # That one probe build is also this call's entire MEASUREMENT step (#602): every
839
+ # candidate is a fixed feature-side anchor (witness origin / leader shaft end)
840
+ # plus a dim line that translates with the tier position, so its box at position
841
+ # ``pos`` is the probe box with the OUTWARD stacking-axis edge shifted by
842
+ # ``pos - lo`` — no further geometry is built to evaluate a position. The
843
+ # segment loop below re-solves and re-checks on these predicted boxes only;
844
+ # each finally-accepted candidate is built once and its real box re-validated
845
+ # (a prediction miss degrades to a later-segment retry, never a collision).
846
+ # A candidate with an analytical *footprints* entry (#602) needs no probe build at
847
+ # all — its box at any position is computed, not measured.
848
+ probe_boxes = {
849
+ name: (footprints[name](lo) if name in (footprints or {}) else _geom_box(build(lo)))
850
+ for name, build in cands
851
+ }
852
+ pbands = [(b[perp], b[perp + 2]) for b in probe_boxes.values() if b is not None]
853
+
854
+ def _predicted_box(name, pos):
855
+ fp = (footprints or {}).get(name)
856
+ if fp is not None:
857
+ return fp(pos)
858
+ pb = probe_boxes.get(name)
859
+ if pb is None:
860
+ return None
861
+ box = list(pb)
862
+ # The moving edge is the one AWAY from the view (`inner`); the feature-side
863
+ # edge is anchored geometry and stays put.
864
+ box[idx + 2 if inner == lo else idx] += pos - lo
865
+ return tuple(box)
866
+
705
867
  occupied = strip_obstacles(dwg, view=view, crossable=CROSSABLE_TYPES)
868
+ # Obstacles OUTSIDE the batch's predicted perpendicular band are invisible to the
869
+ # carve below by design — but that makes the band prediction itself load-bearing: a
870
+ # candidate whose real geometry exceeds its predicted band could land on one with no
871
+ # check ever seeing it (review #679). Keep the filtered-out set: the post-build
872
+ # validation re-checks each survivor's REAL box against it. In-band overlaps are NOT
873
+ # validated — witness lines legitimately cross the boxes of dims stacked further in
874
+ # (ISO 129-1), which is exactly why the carve projects onto the stacking axis only.
875
+ out_of_band: list = []
706
876
  if pbands:
707
877
  band_lo, band_hi = min(p[0] for p in pbands), max(p[1] for p in pbands)
708
- occupied = [b for b in occupied if b[perp] < band_hi and b[perp + 2] > band_lo]
878
+ in_band = [b for b in occupied if b[perp] < band_hi and b[perp + 2] > band_lo]
879
+ out_of_band = [b for b in occupied if not (b[perp] < band_hi and b[perp + 2] > band_lo)]
880
+ occupied = in_band
881
+ # Corner reserves (drain_corridors): projected boxes of not-yet-drained sibling
882
+ # corridors' force candidates. Same band relevance filter as real obstacles, but
883
+ # NEVER in out_of_band — a reserve is a projection, not geometry, so a survivor's
884
+ # real box must not be failed against it.
885
+ occupied += [
886
+ r
887
+ for r in corner_reserves
888
+ if r is not None and r[perp] < band_hi and r[perp + 2] > band_lo
889
+ ]
709
890
  blockers = () if force else corridor_blockers(dwg, view)
710
891
  segs = carve_free_segments(lo, hi, [(b[idx], b[idx + 2]) for b in occupied], pad)
711
892
  # Fill innermost-first (nearest the view), matching the old cursor's stack order.
@@ -764,8 +945,15 @@ def place_strip_candidates(
764
945
  if pos is None: # segment over its estimated capacity (shouldn't occur)
765
946
  rejected.append((name, build))
766
947
  continue
767
- dim = build(pos)
768
- if not force and _box_hits(_geom_box(dim), blockers): # corridor crosses a leader
948
+ # Predicted box, not built geometry (#602): the refill loop re-evaluates
949
+ # every already-accepted candidate each iteration, so building here made
950
+ # the drain quadratic in OCC builds.
951
+ box = _predicted_box(name, pos)
952
+ if box is None: # probe didn't bbox — measure the old way, once per check
953
+ box = _geom_box(build(pos))
954
+ if (
955
+ not force and box is not None and _box_hits(box, blockers)
956
+ ): # corridor crosses a leader
769
957
  rejected.append((name, build))
770
958
  continue
771
959
  # A forbidden box (the title block, #481) is rejected even under force — it is
@@ -773,10 +961,10 @@ def place_strip_candidates(
773
961
  # must still not stack onto it. `forbid` maps names to their box (only GD&T sets it,
774
962
  # so dims are byte-identical). Returned unplaced → the caller's on_drop fallthrough.
775
963
  fb = (forbid or {}).get(name)
776
- if fb is not None and _box_hits(_geom_box(dim), (fb,)):
964
+ if fb is not None and box is not None and _box_hits(box, (fb,)):
777
965
  rejected.append((name, build))
778
966
  continue
779
- accepted.append(((name, build), dim))
967
+ accepted.append(((name, build), pos))
780
968
  return accepted, rejected
781
969
 
782
970
  for seg_lo, seg_hi in segs:
@@ -792,9 +980,32 @@ def place_strip_candidates(
792
980
  if vacancies <= 0 or not todo:
793
981
  break
794
982
  fill, todo = _take_for_segment(todo, vacancies)
795
- take = [nb for nb, _dim in accepted] + fill
983
+ take = [nb for nb, _pos in accepted] + fill
984
+ # Build each survivor ONCE at its solved position and re-validate the real box
985
+ # (the #602 validation fallback): a prediction miss is returned to the pool for
986
+ # the next segment — exactly where a same-segment rejection would have sent it.
987
+ placed = []
988
+ for (name, build), pos in accepted:
989
+ dim = build(pos)
990
+ real = _geom_box(dim)
991
+ if real is not None:
992
+ if not force and _box_hits(real, blockers):
993
+ rejected_total.append((name, build))
994
+ continue
995
+ fb = (forbid or {}).get(name)
996
+ if fb is not None and _box_hits(real, (fb,)):
997
+ rejected_total.append((name, build))
998
+ continue
999
+ # A survivor whose real geometry escaped the batch's predicted
1000
+ # perpendicular band could overlap an obstacle the carve was never
1001
+ # shown (review #679) — the one collision class the stacking-axis
1002
+ # model cannot tolerate. Never fires while predictions are accurate.
1003
+ if _box_hits(real, out_of_band):
1004
+ rejected_total.append((name, build))
1005
+ continue
1006
+ placed.append((name, dim))
796
1007
  todo = todo + rejected_total
797
- for (name, _build), dim in accepted:
1008
+ for name, dim in placed:
798
1009
  # Record feature provenance (ADR 0010): the drain-time seam for corridor-placed
799
1010
  # dims — `features` maps this batch's names to their source IR feature.
800
1011
  dwg.add(dim, name, view=view, feature=(features or {}).get(name))