draftwright 0.3.1__tar.gz → 0.3.3__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 (130) hide show
  1. {draftwright-0.3.1 → draftwright-0.3.3}/CHANGELOG.md +89 -0
  2. {draftwright-0.3.1 → draftwright-0.3.3}/PKG-INFO +2 -2
  3. {draftwright-0.3.1 → draftwright-0.3.3}/pyproject.toml +2 -2
  4. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/_core.py +29 -16
  5. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/analysis.py +5 -0
  6. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/annotations/_common.py +278 -20
  7. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/annotations/from_model.py +306 -117
  8. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/annotations/holes.py +23 -5
  9. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/annotations/orchestrator.py +6 -0
  10. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/annotations/sections.py +20 -1
  11. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/builder.py +15 -9
  12. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/drawing.py +89 -15
  13. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/export.py +27 -0
  14. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/linting/structural.py +102 -35
  15. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/recognition/fillets.py +23 -11
  16. draftwright-0.3.3/src/draftwright/repair.py +198 -0
  17. {draftwright-0.3.1 → draftwright-0.3.3}/tests/refactor_golden/centered_rebate.json +3 -3
  18. draftwright-0.3.3/tests/refactor_golden/grid_plate.json +474 -0
  19. {draftwright-0.3.1 → draftwright-0.3.3}/tests/refactor_golden/holed_slot.json +2 -2
  20. {draftwright-0.3.1 → draftwright-0.3.3}/tests/refactor_golden/prismatic_ladder.json +3 -3
  21. draftwright-0.3.3/tests/refactor_golden/scattered_plate.json +649 -0
  22. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_carve_free_position_callers.py +14 -5
  23. draftwright-0.3.3/tests/test_detect_once.py +81 -0
  24. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_drawing_encapsulation.py +65 -0
  25. draftwright-0.3.3/tests/test_export_dxf_zoom.py +65 -0
  26. draftwright-0.3.3/tests/test_fillets_adjacency.py +53 -0
  27. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_layout_cleanliness.py +84 -53
  28. draftwright-0.3.3/tests/test_lint_box_cache.py +117 -0
  29. draftwright-0.3.3/tests/test_occupancy_boxes.py +39 -0
  30. draftwright-0.3.3/tests/test_pitch_dim_footprint.py +157 -0
  31. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_refactor_golden.py +40 -1
  32. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_sheet_emit.py +7 -2
  33. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_strip_layout.py +45 -10
  34. draftwright-0.3.3/tests/test_witness_label_reconciliation.py +56 -0
  35. draftwright-0.3.1/src/draftwright/repair.py +0 -91
  36. {draftwright-0.3.1 → draftwright-0.3.3}/.gitignore +0 -0
  37. {draftwright-0.3.1 → draftwright-0.3.3}/LICENSE +0 -0
  38. {draftwright-0.3.1 → draftwright-0.3.3}/README.md +0 -0
  39. {draftwright-0.3.1 → draftwright-0.3.3}/skills/SKILL.md +0 -0
  40. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/__init__.py +0 -0
  41. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/_geometry.py +0 -0
  42. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/annotate.py +0 -0
  43. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/annotations/__init__.py +0 -0
  44. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/cli.py +0 -0
  45. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/compose.py +0 -0
  46. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/fits.py +0 -0
  47. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  48. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  49. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  50. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  51. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/fonts/__init__.py +0 -0
  52. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/intents.py +0 -0
  53. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/layout.py +0 -0
  54. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/linting/__init__.py +0 -0
  55. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/linting/coverage.py +0 -0
  56. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/linting/issues.py +0 -0
  57. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/linting/suggest.py +0 -0
  58. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/make_drawing.py +0 -0
  59. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/model/__init__.py +0 -0
  60. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/model/declare.py +0 -0
  61. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/model/detect.py +0 -0
  62. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/model/ir.py +0 -0
  63. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/model/planner.py +0 -0
  64. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/pmi.py +0 -0
  65. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/projection.py +0 -0
  66. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/recognition/__init__.py +0 -0
  67. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/recognition/_features.py +0 -0
  68. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/recognition/_record.py +0 -0
  69. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/recognition/chamfers.py +0 -0
  70. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/recognition/countersinks.py +0 -0
  71. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/recognition/flats.py +0 -0
  72. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/recognition/grooves.py +0 -0
  73. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/recognition/levels.py +0 -0
  74. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/recognition/plates.py +0 -0
  75. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/recognition/slots.py +0 -0
  76. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/recognition/turned.py +0 -0
  77. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/registry.py +0 -0
  78. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/score.py +0 -0
  79. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/sheet.py +0 -0
  80. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/sheet_dsl.py +0 -0
  81. {draftwright-0.3.1 → draftwright-0.3.3}/src/draftwright/sheet_emit.py +0 -0
  82. {draftwright-0.3.1 → draftwright-0.3.3}/tests/_kernel.py +0 -0
  83. {draftwright-0.3.1 → draftwright-0.3.3}/tests/_layout_sig.py +0 -0
  84. {draftwright-0.3.1 → draftwright-0.3.3}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  85. {draftwright-0.3.1 → draftwright-0.3.3}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  86. {draftwright-0.3.1 → draftwright-0.3.3}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  87. {draftwright-0.3.1 → draftwright-0.3.3}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  88. {draftwright-0.3.1 → draftwright-0.3.3}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  89. {draftwright-0.3.1 → draftwright-0.3.3}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  90. {draftwright-0.3.1 → draftwright-0.3.3}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  91. {draftwright-0.3.1 → draftwright-0.3.3}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  92. {draftwright-0.3.1 → draftwright-0.3.3}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  93. {draftwright-0.3.1 → draftwright-0.3.3}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  94. {draftwright-0.3.1 → draftwright-0.3.3}/tests/refactor_golden/bracket_section.json +0 -0
  95. {draftwright-0.3.1 → draftwright-0.3.3}/tests/refactor_golden/chamfered.json +0 -0
  96. {draftwright-0.3.1 → draftwright-0.3.3}/tests/refactor_golden/filleted.json +0 -0
  97. {draftwright-0.3.1 → draftwright-0.3.3}/tests/refactor_golden/flange_dense.json +0 -0
  98. {draftwright-0.3.1 → draftwright-0.3.3}/tests/refactor_golden/grooved_shaft.json +0 -0
  99. {draftwright-0.3.1 → draftwright-0.3.3}/tests/refactor_golden/hex_bar.json +0 -0
  100. {draftwright-0.3.1 → draftwright-0.3.3}/tests/refactor_golden/pocketed.json +0 -0
  101. {draftwright-0.3.1 → draftwright-0.3.3}/tests/refactor_golden/side_drilled.json +0 -0
  102. {draftwright-0.3.1 → draftwright-0.3.3}/tests/refactor_golden/turned_stepped.json +0 -0
  103. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_declare.py +0 -0
  104. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_e2e_slice.py +0 -0
  105. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_e2e_standards.py +0 -0
  106. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_fits.py +0 -0
  107. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_gdt_placement.py +0 -0
  108. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_import_boundaries.py +0 -0
  109. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_layout.py +0 -0
  110. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_layout_hypothesis.py +0 -0
  111. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_layout_property.py +0 -0
  112. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_lint_reconciliation.py +0 -0
  113. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_lint_structural.py +0 -0
  114. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_linting.py +0 -0
  115. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_make_drawing.py +0 -0
  116. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_object_aspects.py +0 -0
  117. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_part_model.py +0 -0
  118. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_pmi.py +0 -0
  119. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_private_test_imports.py +0 -0
  120. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_recogniser_contract.py +0 -0
  121. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_recognition.py +0 -0
  122. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_registry.py +0 -0
  123. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_render_seam.py +0 -0
  124. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_score.py +0 -0
  125. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_sheet_gdt.py +0 -0
  126. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_sheet_notes.py +0 -0
  127. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_sheet_of.py +0 -0
  128. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_sheet_tables.py +0 -0
  129. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_tolerances.py +0 -0
  130. {draftwright-0.3.1 → draftwright-0.3.3}/tests/test_turned_steps.py +0 -0
@@ -1,5 +1,94 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.3 — 2026-07-18
4
+
5
+ The **layout-quality release**: the occupancy model gets honest about ink shape,
6
+ the last solver-invisible pass joins the corridor solve, and crossed labels shift
7
+ themselves clear. Also completes the #635 consolidation epic (ADR 0005 fully
8
+ executed; ADR 0009's no-invisible-occupant guarantee now holds by construction
9
+ for every auto-pass).
10
+
11
+ ### Added
12
+
13
+ - **Witness-crossing label shifts** (#690): a dimension label crossed by another
14
+ dimension's witness stroke now shifts along its own line to clear it — a
15
+ deterministic post-drain pass using the repair machinery (destination-aware,
16
+ axis-aligned only, pinned dims exempt).
17
+
18
+ ### Changed
19
+
20
+ - **L-shaped occupancy** (#685): placement obstacles decompose into per-stroke
21
+ boxes + label box (helpers ≥0.14 `segments`) instead of one hull, with
22
+ preset-aware arrowhead pads. View-corner contention between perpendicular
23
+ strips disappears by construction (a plate-thickness dim returns to its
24
+ natural strip); packing tightens. The cleanliness ratchet mirrors the model —
25
+ only transverse stroke crossings are exempt, so parallel overprints and label
26
+ contacts are detected honestly (twelve hull-artifact allowlist entries burned
27
+ down; the legitimate shared-datum running-dimension pairs are documented).
28
+ - **Height ladder joins the corridor solve** (#636): step heights + overall
29
+ height are corridor candidates (the leapfrog witness cursor survives as a
30
+ deterministic build-time chain; the overall height stacks outermost by
31
+ construction and packs tighter). An explicitly-requested crowded-step detail
32
+ view now *deliberately* demotes the overall-height dim when the sheet cannot
33
+ hold both (previously the carve dropped it silently); a Y-drilled hole's Z
34
+ location joins the front running ladder per the documented routing.
35
+ - **One typed `BuildState`** (#639): the drawing's build context (analysis,
36
+ part model, lint geometry caches) consolidates into a single object filled at
37
+ one builder site, single-writer-guarded; the render passes make zero private
38
+ `Drawing` reads (fail-closed empty allowlist).
39
+
40
+ ### Fixed
41
+
42
+ - A prismatic detail view no longer loses both the detail *and* the height dim
43
+ when space is tight (transactional demotion).
44
+ - The GD&T alternate-strip fallback defers until every corridor has drained, so
45
+ it can never preempt a sibling strip's reserved corner.
46
+
47
+ ## v0.3.2 — 2026-07-18
48
+
49
+ The **performance release**: the #602 perf loop (six PRs) plus the
50
+ build123d-drafting-helpers 0.14 upgrade. A dimension-dense drawing now builds
51
+ roughly **14× faster** than v0.3.1 (grid-plate benchmark 40 s → 2.8 s; NIST
52
+ CTC-02 ~107 s → well under half); the full test tier dropped ~14 min → ~3.5 min.
53
+
54
+ ### Performance
55
+
56
+ - **Placement measures, then builds** (#678, #679): corridor/strip candidates are
57
+ evaluated on analytical footprints (`dim_footprint`) or a single probe box; an
58
+ accepted candidate builds its OCC geometry exactly once and re-validates the
59
+ real box. Dimension constructions on the scattered-plate benchmark: 59 → 21
60
+ (= the placed count).
61
+ - **Detect once** (#682): the sizing model `_analyse` builds pre-scale is stored
62
+ on `Analysis.model` and reused as the render model — restoring ADR 0008
63
+ Amendment 5 ("one inventory, detected once"). ~13 s off CTC-02.
64
+ - **Fillet recognition de-quadratised** (#683): edge→faces adjacency map replaces
65
+ the O(faces²×edges²) `IsSame` sweep (3.7 M calls on CTC-02); 10.8 s → 4.6 s,
66
+ byte-identical records.
67
+ - **Lint bounding boxes memoised** (#681): `Drawing.lint()` 0.88 s → 0.07 s per
68
+ call (identity+location-token-checked cache beside the #143 view-edge cache).
69
+ - **DXF viewport from the known page window** (#680): skips `ezdxf.zoom.extents`'
70
+ per-entity walk; ~2.4 s per DXF export.
71
+ - **helpers 0.14** (#684): boolean-free dimension ink (upstream #177, filed and
72
+ fixed same-day) — ~25 ms per `Dimension`, was ~234 ms.
73
+
74
+ ### Changed
75
+
76
+ - **Tight-span dimensions now render honestly** (helpers 0.14): ISO outside
77
+ arrows where 0.13 silently degraded to a bare line. Ink extents grow on such
78
+ dims; placement models the flip analytically. Two placement-golden fixtures
79
+ re-blessed (ink extent only).
80
+ - Diameter-row leader labels space by **measured text width** and repair
81
+ helpers' shelf-flip collisions (crowded rows only; ordinary rows unchanged).
82
+ - A plate-thickness dim whose home strip corner is structurally contested falls
83
+ through to the opposite strip / side view instead of dropping (#684; the
84
+ underlying one-box-occupancy artifact is tracked as #685).
85
+
86
+ ### Fixed
87
+
88
+ - `recognise_fillets` neighbour tie-break is order-independent (#683 review).
89
+ - Lint's annotation-box cache prunes departed objects and detects in-place
90
+ relocation (#681 review).
91
+
3
92
  ## v0.3.1 — 2026-07-17
4
93
 
5
94
  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.3
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.3"
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
@@ -175,6 +255,52 @@ def clear_label_of_centerlines(label_bbox, centerlines, gap):
175
255
  return target_x - lmin_x
176
256
 
177
257
 
258
+ def occupancy_boxes(o, stroke_pad=None):
259
+ """*o*'s occupancy as a list of AABBs — decomposed, not one hull (#685).
260
+
261
+ An annotation's rendered hull includes large EMPTY corner regions (a dimension's
262
+ ink is L-shaped: witness lines + a dim-line band), and helpers ≥0.14's honest
263
+ tight-span rendering made those hulls big enough to collide structurally at view
264
+ corners while the inks stay disjoint. For an annotation exposing ``.segments``
265
+ (helpers ≥0.14 reports the drawn line pieces: witness lines, shafts, box
266
+ strokes), return one box per stroke — inflated by ``_STROKE_PAD`` to cover line
267
+ width plus the arrowheads at stroke junctions — plus its ``label_bbox``.
268
+ Anything else (leaders, hatch, title block) keeps its single hull box.
269
+
270
+ Consumers treat the result exactly like a list of hull boxes; the
271
+ perpendicular-band filter in the carve then does the rest — a witness sliver
272
+ carves only its own sliver, and an out-of-band dim-line band stops blocking a
273
+ sibling strip's corner entirely.
274
+ """
275
+ segs = getattr(o, "segments", None)
276
+ if not segs:
277
+ b = _geom_box(o)
278
+ return [b] if b is not None else []
279
+ pad = _STROKE_PAD if stroke_pad is None else stroke_pad
280
+ out = []
281
+ for (x0, y0), (x1, y1) in segs:
282
+ out.append(
283
+ (
284
+ min(x0, x1) - pad,
285
+ min(y0, y1) - pad,
286
+ max(x0, x1) + pad,
287
+ max(y0, y1) + pad,
288
+ )
289
+ )
290
+ lb = getattr(o, "label_bbox", None)
291
+ if lb is not None:
292
+ out.append((lb[0], lb[1], lb[2], lb[3]))
293
+ return out
294
+
295
+
296
+ # Fallback stroke inflation for decomposed occupancy: line_width/2 (~0.08) plus the
297
+ # arrowhead half-width at stroke junctions at DEFAULT presets. Arrow geometry scales
298
+ # with font_size (#688 review), so callers that know the draft derive the pad as
299
+ # max(_STROKE_PAD, draft.arrow_length / 2) — arrow half-LENGTH bounds the head's
300
+ # half-width (aspect < 1) AND its protrusion past an inside-arrow shaft trim (al/2).
301
+ _STROKE_PAD = 1.2
302
+
303
+
178
304
  def strip_obstacles(dwg, view=None, *, crossable=()):
179
305
  """The COMPLETE occupancy for strip placement (ADR 0009): every placed
180
306
  annotation's full rendered footprint, optionally restricted to *view*, minus
@@ -204,6 +330,9 @@ def strip_obstacles(dwg, view=None, *, crossable=()):
204
330
 
205
331
  The occupancy source for the collect-then-solve carve — every migrated renderer's
206
332
  ``place_strip_candidates`` call wires this in (#321/#150/P3)."""
333
+ # Preset-aware stroke pad (#688 review): arrowheads scale with font_size.
334
+ al = getattr(getattr(dwg, "draft", None), "arrow_length", None)
335
+ pad = max(_STROKE_PAD, al / 2) if al else _STROKE_PAD
207
336
  boxes = []
208
337
  for name, o in dwg.iter_annotations():
209
338
  if view is not None:
@@ -212,9 +341,7 @@ def strip_obstacles(dwg, view=None, *, crossable=()):
212
341
  continue # owned by a different ortho view → its own (disjoint) block
213
342
  if type(o).__name__ in crossable:
214
343
  continue # this consumer may cross it (centre lines/marks for a dim)
215
- bb = _geom_box(o)
216
- if bb is not None:
217
- boxes.append(bb)
344
+ boxes.extend(occupancy_boxes(o, stroke_pad=pad)) # decomposed, not one hull (#685)
218
345
  return boxes
219
346
 
220
347
 
@@ -415,9 +542,20 @@ class CorridorCandidate:
415
542
  # the title block, which is placed after the corridor drain so the strip carve can't see
416
543
  # it (#481). ``None`` (every dim) skips the check → byte-identical.
417
544
  forbid: object | None = None
418
-
419
-
420
- def solve_corridor(dwg, strip, view, axis, cands, tier):
545
+ # Analytical ``pos -> (x0, y0, x1, y1)`` footprint of the geometry ``build(pos)``
546
+ # would produce (#602): lets the strip solve measure and evaluate this candidate
547
+ # without constructing OCC geometry at all (see :func:`dim_footprint`). ``None``
548
+ # falls back to one probe build at the strip edge + the box-shift model. CONTRACT:
549
+ # the footprint must be accurate — its PERPENDICULAR extent feeds the obstacle
550
+ # band filter, so an underestimate hides obstacles from the carve. The solve
551
+ # re-validates each built survivor against the blockers, the forbid box and the
552
+ # band-filtered-out obstacles, so a miss costs a wasted build and a retry, but
553
+ # keeping footprints truthful (``dim_footprint``, ±0.05 mm) is what keeps that
554
+ # fallback rare and the placement identical to the probe path.
555
+ footprint: object | None = None
556
+
557
+
558
+ def solve_corridor(dwg, strip, view, axis, cands, tier, corner_reserves=()):
421
559
  """One collect-then-solve over every :class:`CorridorCandidate` a shared strip
422
560
  accumulated across passes (ADR 0009 end state). Dedup → order → one non-force
423
561
  :func:`place_strip_candidates` pass → a force pass for the force-eligible leftovers →
@@ -472,6 +610,7 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
472
610
  prio = {c.name: c.priority for c in kept if c.priority} # over-capacity survival rank (#357)
473
611
  anchored = {c.name: c.anchored for c in kept if c.anchored}
474
612
  naturals = {c.name: c.natural for c in kept if c.natural is not None}
613
+ foots = {c.name: c.footprint for c in kept if c.footprint is not None} # analytical (#602)
475
614
  left = {
476
615
  n
477
616
  for n, _ in place_strip_candidates(
@@ -487,6 +626,8 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
487
626
  priorities=prio,
488
627
  anchored=anchored,
489
628
  naturals=naturals,
629
+ footprints=foots,
630
+ corner_reserves=corner_reserves,
490
631
  )
491
632
  }
492
633
  force_pairs = [(c.name, c.build) for c in kept if c.name in left and c.force]
@@ -501,6 +642,8 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
501
642
  force_pairs,
502
643
  tier,
503
644
  force=True,
645
+ footprints=foots,
646
+ corner_reserves=corner_reserves,
504
647
  features=feats,
505
648
  sizes=sizes,
506
649
  forbid=forbid,
@@ -540,6 +683,10 @@ class PlacementContext:
540
683
  corridor_batch: dict = field(default_factory=dict)
541
684
  escalations: list = field(default_factory=list)
542
685
  detail_requests: list = field(default_factory=list)
686
+ # Fallthrough callbacks a pass's on_drop queues to run AFTER every corridor has
687
+ # drained (#684 review): a mid-drain carve could occupy space a later sibling
688
+ # corridor's force candidate needs; deferral makes "post-drain" literally true.
689
+ post_drain: list = field(default_factory=list)
543
690
  # The drawing's build-state stores, referenced (not owned) by the run's passes (#639).
544
691
  # Duck-typed as ``Any`` — matching the untyped ``Drawing._record_build_issue`` they replace —
545
692
  # so mypy does not reject the delegating calls below.
@@ -601,10 +748,43 @@ def register_corridor(ctx, key, strip, view, axis, tier, cand):
601
748
  def drain_corridors(ctx, dwg):
602
749
  """Solve every registered corridor (one :func:`solve_corridor` per strip), then clear
603
750
  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"])
751
+ scratch *ctx* (the batch) and *dwg* (the drawing :func:`solve_corridor` places onto).
752
+
753
+ Corner coordination (helpers ≥0.14): perpendicular strips of one view contest the
754
+ view corners — a tight-span dim's outside-arrow tails overhang past the view edge
755
+ into the sibling strip's band (an 8 mm plate-thickness dim on the left strip dips
756
+ below the view bottom; the below strip's own tight dim pokes left of the view edge).
757
+ Solved sequentially and blind, the first drain fills the corner and the second's
758
+ force-kept candidate hard-drops. So each solve receives the *innermost-tier
759
+ footprint boxes* of every not-yet-drained same-view sibling's **force** candidates
760
+ as extra obstacles: the earlier drain places clear of the corner the later one
761
+ provably needs. Reservation is exact (the candidate's own analytical footprint, at
762
+ the innermost position it would take), restricted to force candidates — principal
763
+ dims that would otherwise drop rather than relocate — so best-effort occupants
764
+ never lose capacity to it. Already-drained siblings need nothing: their dims are
765
+ real obstacles via :func:`strip_obstacles`."""
766
+ batches = list(ctx.corridor_batch.values())
767
+ for i, b in enumerate(batches):
768
+ reserves = []
769
+ for sib in batches[i + 1 :]:
770
+ if sib["view"] != b["view"] or sib["strip"] is None:
771
+ continue
772
+ s = sib["strip"]
773
+ inner_pos = s.anchor + s.direction * s.gap
774
+ for c in sib["cands"]:
775
+ if c.force and c.footprint is not None:
776
+ box = c.footprint(inner_pos)
777
+ if box is not None:
778
+ reserves.append(box)
779
+ solve_corridor(
780
+ dwg, b["strip"], b["view"], b["axis"], b["cands"], b["tier"], corner_reserves=reserves
781
+ )
607
782
  ctx.corridor_batch = {}
783
+ # Deferred fallthroughs (opposite-strip retries) run once every strip has drained,
784
+ # so a retry can never preempt a corner a later sibling's force candidate needs.
785
+ pending, ctx.post_drain = ctx.post_drain, []
786
+ for cb in pending:
787
+ cb()
608
788
 
609
789
 
610
790
  def place_strip_candidates(
@@ -622,6 +802,8 @@ def place_strip_candidates(
622
802
  priorities=None,
623
803
  anchored=None,
624
804
  naturals=None,
805
+ footprints=None,
806
+ corner_reserves=(),
625
807
  ):
626
808
  """Collect-then-solve placement of location/feature dims on one strip (ADR 0009).
627
809
  The single shared strip placer that retires the ``Strip.allocate`` cursor (#150,
@@ -699,13 +881,59 @@ def place_strip_candidates(
699
881
  # obstacles out first. The perpendicular extent is independent of the tier position,
700
882
  # so a single probe build per candidate suffices; the corridor check below already
701
883
  # 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
- ]
884
+ #
885
+ # That one probe build is also this call's entire MEASUREMENT step (#602): every
886
+ # candidate is a fixed feature-side anchor (witness origin / leader shaft end)
887
+ # plus a dim line that translates with the tier position, so its box at position
888
+ # ``pos`` is the probe box with the OUTWARD stacking-axis edge shifted by
889
+ # ``pos - lo`` — no further geometry is built to evaluate a position. The
890
+ # segment loop below re-solves and re-checks on these predicted boxes only;
891
+ # each finally-accepted candidate is built once and its real box re-validated
892
+ # (a prediction miss degrades to a later-segment retry, never a collision).
893
+ # A candidate with an analytical *footprints* entry (#602) needs no probe build at
894
+ # all — its box at any position is computed, not measured.
895
+ probe_boxes = {
896
+ name: (footprints[name](lo) if name in (footprints or {}) else _geom_box(build(lo)))
897
+ for name, build in cands
898
+ }
899
+ pbands = [(b[perp], b[perp + 2]) for b in probe_boxes.values() if b is not None]
900
+
901
+ def _predicted_box(name, pos):
902
+ fp = (footprints or {}).get(name)
903
+ if fp is not None:
904
+ return fp(pos)
905
+ pb = probe_boxes.get(name)
906
+ if pb is None:
907
+ return None
908
+ box = list(pb)
909
+ # The moving edge is the one AWAY from the view (`inner`); the feature-side
910
+ # edge is anchored geometry and stays put.
911
+ box[idx + 2 if inner == lo else idx] += pos - lo
912
+ return tuple(box)
913
+
705
914
  occupied = strip_obstacles(dwg, view=view, crossable=CROSSABLE_TYPES)
915
+ # Obstacles OUTSIDE the batch's predicted perpendicular band are invisible to the
916
+ # carve below by design — but that makes the band prediction itself load-bearing: a
917
+ # candidate whose real geometry exceeds its predicted band could land on one with no
918
+ # check ever seeing it (review #679). Keep the filtered-out set: the post-build
919
+ # validation re-checks each survivor's REAL box against it. In-band overlaps are NOT
920
+ # validated — witness lines legitimately cross the boxes of dims stacked further in
921
+ # (ISO 129-1), which is exactly why the carve projects onto the stacking axis only.
922
+ out_of_band: list = []
706
923
  if pbands:
707
924
  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]
925
+ in_band = [b for b in occupied if b[perp] < band_hi and b[perp + 2] > band_lo]
926
+ out_of_band = [b for b in occupied if not (b[perp] < band_hi and b[perp + 2] > band_lo)]
927
+ occupied = in_band
928
+ # Corner reserves (drain_corridors): projected boxes of not-yet-drained sibling
929
+ # corridors' force candidates. Same band relevance filter as real obstacles, but
930
+ # NEVER in out_of_band — a reserve is a projection, not geometry, so a survivor's
931
+ # real box must not be failed against it.
932
+ occupied += [
933
+ r
934
+ for r in corner_reserves
935
+ if r is not None and r[perp] < band_hi and r[perp + 2] > band_lo
936
+ ]
709
937
  blockers = () if force else corridor_blockers(dwg, view)
710
938
  segs = carve_free_segments(lo, hi, [(b[idx], b[idx + 2]) for b in occupied], pad)
711
939
  # Fill innermost-first (nearest the view), matching the old cursor's stack order.
@@ -764,8 +992,15 @@ def place_strip_candidates(
764
992
  if pos is None: # segment over its estimated capacity (shouldn't occur)
765
993
  rejected.append((name, build))
766
994
  continue
767
- dim = build(pos)
768
- if not force and _box_hits(_geom_box(dim), blockers): # corridor crosses a leader
995
+ # Predicted box, not built geometry (#602): the refill loop re-evaluates
996
+ # every already-accepted candidate each iteration, so building here made
997
+ # the drain quadratic in OCC builds.
998
+ box = _predicted_box(name, pos)
999
+ if box is None: # probe didn't bbox — measure the old way, once per check
1000
+ box = _geom_box(build(pos))
1001
+ if (
1002
+ not force and box is not None and _box_hits(box, blockers)
1003
+ ): # corridor crosses a leader
769
1004
  rejected.append((name, build))
770
1005
  continue
771
1006
  # A forbidden box (the title block, #481) is rejected even under force — it is
@@ -773,10 +1008,10 @@ def place_strip_candidates(
773
1008
  # must still not stack onto it. `forbid` maps names to their box (only GD&T sets it,
774
1009
  # so dims are byte-identical). Returned unplaced → the caller's on_drop fallthrough.
775
1010
  fb = (forbid or {}).get(name)
776
- if fb is not None and _box_hits(_geom_box(dim), (fb,)):
1011
+ if fb is not None and box is not None and _box_hits(box, (fb,)):
777
1012
  rejected.append((name, build))
778
1013
  continue
779
- accepted.append(((name, build), dim))
1014
+ accepted.append(((name, build), pos))
780
1015
  return accepted, rejected
781
1016
 
782
1017
  for seg_lo, seg_hi in segs:
@@ -792,9 +1027,32 @@ def place_strip_candidates(
792
1027
  if vacancies <= 0 or not todo:
793
1028
  break
794
1029
  fill, todo = _take_for_segment(todo, vacancies)
795
- take = [nb for nb, _dim in accepted] + fill
1030
+ take = [nb for nb, _pos in accepted] + fill
1031
+ # Build each survivor ONCE at its solved position and re-validate the real box
1032
+ # (the #602 validation fallback): a prediction miss is returned to the pool for
1033
+ # the next segment — exactly where a same-segment rejection would have sent it.
1034
+ placed = []
1035
+ for (name, build), pos in accepted:
1036
+ dim = build(pos)
1037
+ real = _geom_box(dim)
1038
+ if real is not None:
1039
+ if not force and _box_hits(real, blockers):
1040
+ rejected_total.append((name, build))
1041
+ continue
1042
+ fb = (forbid or {}).get(name)
1043
+ if fb is not None and _box_hits(real, (fb,)):
1044
+ rejected_total.append((name, build))
1045
+ continue
1046
+ # A survivor whose real geometry escaped the batch's predicted
1047
+ # perpendicular band could overlap an obstacle the carve was never
1048
+ # shown (review #679) — the one collision class the stacking-axis
1049
+ # model cannot tolerate. Never fires while predictions are accurate.
1050
+ if _box_hits(real, out_of_band):
1051
+ rejected_total.append((name, build))
1052
+ continue
1053
+ placed.append((name, dim))
796
1054
  todo = todo + rejected_total
797
- for (name, _build), dim in accepted:
1055
+ for name, dim in placed:
798
1056
  # Record feature provenance (ADR 0010): the drain-time seam for corridor-placed
799
1057
  # dims — `features` maps this batch's names to their source IR feature.
800
1058
  dwg.add(dim, name, view=view, feature=(features or {}).get(name))