draftwright 0.3.2__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.2 → draftwright-0.3.3}/CHANGELOG.md +44 -0
  2. {draftwright-0.3.2 → draftwright-0.3.3}/PKG-INFO +1 -1
  3. {draftwright-0.3.2 → draftwright-0.3.3}/pyproject.toml +1 -1
  4. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/annotations/_common.py +50 -3
  5. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/annotations/from_model.py +136 -108
  6. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/annotations/orchestrator.py +6 -0
  7. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/annotations/sections.py +20 -1
  8. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/builder.py +7 -6
  9. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/drawing.py +78 -22
  10. draftwright-0.3.3/src/draftwright/repair.py +198 -0
  11. {draftwright-0.3.2 → draftwright-0.3.3}/tests/refactor_golden/centered_rebate.json +3 -3
  12. {draftwright-0.3.2 → draftwright-0.3.3}/tests/refactor_golden/grid_plate.json +6 -6
  13. {draftwright-0.3.2 → draftwright-0.3.3}/tests/refactor_golden/prismatic_ladder.json +3 -3
  14. {draftwright-0.3.2 → draftwright-0.3.3}/tests/refactor_golden/scattered_plate.json +27 -27
  15. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_carve_free_position_callers.py +7 -4
  16. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_drawing_encapsulation.py +65 -0
  17. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_layout_cleanliness.py +82 -58
  18. draftwright-0.3.3/tests/test_occupancy_boxes.py +39 -0
  19. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_sheet_emit.py +7 -2
  20. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_strip_layout.py +45 -10
  21. draftwright-0.3.3/tests/test_witness_label_reconciliation.py +56 -0
  22. draftwright-0.3.2/src/draftwright/repair.py +0 -91
  23. {draftwright-0.3.2 → draftwright-0.3.3}/.gitignore +0 -0
  24. {draftwright-0.3.2 → draftwright-0.3.3}/LICENSE +0 -0
  25. {draftwright-0.3.2 → draftwright-0.3.3}/README.md +0 -0
  26. {draftwright-0.3.2 → draftwright-0.3.3}/skills/SKILL.md +0 -0
  27. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/__init__.py +0 -0
  28. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/_core.py +0 -0
  29. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/_geometry.py +0 -0
  30. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/analysis.py +0 -0
  31. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/annotate.py +0 -0
  32. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/annotations/__init__.py +0 -0
  33. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/annotations/holes.py +0 -0
  34. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/cli.py +0 -0
  35. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/compose.py +0 -0
  36. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/export.py +0 -0
  37. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/fits.py +0 -0
  38. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  39. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  40. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  41. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  42. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/fonts/__init__.py +0 -0
  43. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/intents.py +0 -0
  44. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/layout.py +0 -0
  45. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/linting/__init__.py +0 -0
  46. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/linting/coverage.py +0 -0
  47. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/linting/issues.py +0 -0
  48. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/linting/structural.py +0 -0
  49. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/linting/suggest.py +0 -0
  50. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/make_drawing.py +0 -0
  51. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/model/__init__.py +0 -0
  52. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/model/declare.py +0 -0
  53. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/model/detect.py +0 -0
  54. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/model/ir.py +0 -0
  55. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/model/planner.py +0 -0
  56. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/pmi.py +0 -0
  57. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/projection.py +0 -0
  58. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/recognition/__init__.py +0 -0
  59. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/recognition/_features.py +0 -0
  60. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/recognition/_record.py +0 -0
  61. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/recognition/chamfers.py +0 -0
  62. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/recognition/countersinks.py +0 -0
  63. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/recognition/fillets.py +0 -0
  64. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/recognition/flats.py +0 -0
  65. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/recognition/grooves.py +0 -0
  66. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/recognition/levels.py +0 -0
  67. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/recognition/plates.py +0 -0
  68. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/recognition/slots.py +0 -0
  69. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/recognition/turned.py +0 -0
  70. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/registry.py +0 -0
  71. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/score.py +0 -0
  72. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/sheet.py +0 -0
  73. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/sheet_dsl.py +0 -0
  74. {draftwright-0.3.2 → draftwright-0.3.3}/src/draftwright/sheet_emit.py +0 -0
  75. {draftwright-0.3.2 → draftwright-0.3.3}/tests/_kernel.py +0 -0
  76. {draftwright-0.3.2 → draftwright-0.3.3}/tests/_layout_sig.py +0 -0
  77. {draftwright-0.3.2 → draftwright-0.3.3}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  78. {draftwright-0.3.2 → draftwright-0.3.3}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  79. {draftwright-0.3.2 → draftwright-0.3.3}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  80. {draftwright-0.3.2 → draftwright-0.3.3}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  81. {draftwright-0.3.2 → draftwright-0.3.3}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  82. {draftwright-0.3.2 → draftwright-0.3.3}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  83. {draftwright-0.3.2 → draftwright-0.3.3}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  84. {draftwright-0.3.2 → draftwright-0.3.3}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  85. {draftwright-0.3.2 → draftwright-0.3.3}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  86. {draftwright-0.3.2 → draftwright-0.3.3}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  87. {draftwright-0.3.2 → draftwright-0.3.3}/tests/refactor_golden/bracket_section.json +0 -0
  88. {draftwright-0.3.2 → draftwright-0.3.3}/tests/refactor_golden/chamfered.json +0 -0
  89. {draftwright-0.3.2 → draftwright-0.3.3}/tests/refactor_golden/filleted.json +0 -0
  90. {draftwright-0.3.2 → draftwright-0.3.3}/tests/refactor_golden/flange_dense.json +0 -0
  91. {draftwright-0.3.2 → draftwright-0.3.3}/tests/refactor_golden/grooved_shaft.json +0 -0
  92. {draftwright-0.3.2 → draftwright-0.3.3}/tests/refactor_golden/hex_bar.json +0 -0
  93. {draftwright-0.3.2 → draftwright-0.3.3}/tests/refactor_golden/holed_slot.json +0 -0
  94. {draftwright-0.3.2 → draftwright-0.3.3}/tests/refactor_golden/pocketed.json +0 -0
  95. {draftwright-0.3.2 → draftwright-0.3.3}/tests/refactor_golden/side_drilled.json +0 -0
  96. {draftwright-0.3.2 → draftwright-0.3.3}/tests/refactor_golden/turned_stepped.json +0 -0
  97. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_declare.py +0 -0
  98. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_detect_once.py +0 -0
  99. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_e2e_slice.py +0 -0
  100. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_e2e_standards.py +0 -0
  101. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_export_dxf_zoom.py +0 -0
  102. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_fillets_adjacency.py +0 -0
  103. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_fits.py +0 -0
  104. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_gdt_placement.py +0 -0
  105. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_import_boundaries.py +0 -0
  106. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_layout.py +0 -0
  107. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_layout_hypothesis.py +0 -0
  108. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_layout_property.py +0 -0
  109. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_lint_box_cache.py +0 -0
  110. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_lint_reconciliation.py +0 -0
  111. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_lint_structural.py +0 -0
  112. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_linting.py +0 -0
  113. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_make_drawing.py +0 -0
  114. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_object_aspects.py +0 -0
  115. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_part_model.py +0 -0
  116. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_pitch_dim_footprint.py +0 -0
  117. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_pmi.py +0 -0
  118. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_private_test_imports.py +0 -0
  119. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_recogniser_contract.py +0 -0
  120. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_recognition.py +0 -0
  121. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_refactor_golden.py +0 -0
  122. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_registry.py +0 -0
  123. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_render_seam.py +0 -0
  124. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_score.py +0 -0
  125. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_sheet_gdt.py +0 -0
  126. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_sheet_notes.py +0 -0
  127. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_sheet_of.py +0 -0
  128. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_sheet_tables.py +0 -0
  129. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_tolerances.py +0 -0
  130. {draftwright-0.3.2 → draftwright-0.3.3}/tests/test_turned_steps.py +0 -0
@@ -1,5 +1,49 @@
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
+
3
47
  ## v0.3.2 — 2026-07-18
4
48
 
5
49
  The **performance release**: the #602 perf loop (six PRs) plus the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.3.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -255,6 +255,52 @@ def clear_label_of_centerlines(label_bbox, centerlines, gap):
255
255
  return target_x - lmin_x
256
256
 
257
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
+
258
304
  def strip_obstacles(dwg, view=None, *, crossable=()):
259
305
  """The COMPLETE occupancy for strip placement (ADR 0009): every placed
260
306
  annotation's full rendered footprint, optionally restricted to *view*, minus
@@ -284,6 +330,9 @@ def strip_obstacles(dwg, view=None, *, crossable=()):
284
330
 
285
331
  The occupancy source for the collect-then-solve carve — every migrated renderer's
286
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
287
336
  boxes = []
288
337
  for name, o in dwg.iter_annotations():
289
338
  if view is not None:
@@ -292,9 +341,7 @@ def strip_obstacles(dwg, view=None, *, crossable=()):
292
341
  continue # owned by a different ortho view → its own (disjoint) block
293
342
  if type(o).__name__ in crossable:
294
343
  continue # this consumer may cross it (centre lines/marks for a dim)
295
- bb = _geom_box(o)
296
- if bb is not None:
297
- boxes.append(bb)
344
+ boxes.extend(occupancy_boxes(o, stroke_pad=pad)) # decomposed, not one hull (#685)
298
345
  return boxes
299
346
 
300
347
 
@@ -1846,46 +1846,40 @@ def _detect_step_repeat(step_zs, bb_min_z, bb_max_z, tol_frac=0.10):
1846
1846
 
1847
1847
  def render_height_ladder(dwg, model, a, *, ctx, include_overall: bool = True) -> int:
1848
1848
  """Front-view right ladder: prismatic step heights (from `StepLevelFeature`)
1849
- stacked inner→outer, then the overall height outermost — through `fv_zones.right`,
1850
- preserving the leapfrog witness cursor (#237). Replaces the engine's inline
1851
- `dim_step_*` + `dim_height`. A turned part has no `StepLevelFeature` (its steps are
1852
- the IR length chain); a Z-turned part suppresses the overall height (the chain
1853
- tiles it, ISO 129). Returns the count placed."""
1849
+ stacked inner→outer, then the overall height outermost — registered as
1850
+ :class:`CorridorCandidate`s in the shared ``(front, right)`` corridor (#636;
1851
+ formerly the last solver-invisible `carve_free_position` auto-pass). The leapfrog
1852
+ witness cursor (#237) survives as a *build-time chain*: candidates share a
1853
+ ``solved`` position map, and each dim's witness anchors on its nearest already-
1854
+ built predecessor's line (the view edge for the first) — build order follows the
1855
+ candidate order, so the chain is deterministic. Prediction uses an analytical
1856
+ footprint with the witness modelled from the view edge (a conservative
1857
+ over-cover along the stack axis; the accept-time real-box validation trims any
1858
+ drift). A turned part has no `StepLevelFeature`; a Z-turned part suppresses the
1859
+ overall height (the chain tiles it, ISO 129). Returns the count REGISTERED."""
1854
1860
  draft = dwg.draft
1855
1861
  FX, FZ = a.proj.front_x, a.proj.front_z
1856
- right_ladder = FX(a.bb.max.X) + 2
1857
- n = 0
1862
+ edge2 = FX(a.bb.max.X) + 2
1863
+ zmin = FZ(a.bb.min.Z)
1864
+ tier = draft.font_size + 2 * draft.pad_around_text
1858
1865
  step = next((f for f in model.features if f.kind == "step_level"), None)
1859
1866
  levels = list(step.levels) if step is not None else []
1860
- # Uniform staircase → one representative "N× rise" dim; else the per-step ladder
1861
- # (legibility-gated). Turned parts have no levels, so neither fires.
1862
1867
  rep = _detect_step_repeat(levels, a.bb.min.Z, a.bb.max.Z) if levels else None
1868
+
1869
+ # The chain, inner→outer: (name, page-z top, label, tier size, drop message).
1870
+ chain: list = []
1863
1871
  if rep is not None:
1864
1872
  n_rep, rise = rep
1865
1873
  first = sorted(levels)[0]
1866
- perp = tuple(sorted((FZ(a.bb.min.Z), FZ(first))))
1867
- px = carve_free_position(dwg, a.fv_zones.right, "front", "x", _SLOT_DIM_STEP, perp)
1868
- if px is not None:
1869
- dwg.add(
1870
- _dim(
1871
- (right_ladder, FZ(a.bb.min.Z), 0),
1872
- (right_ladder, FZ(first), 0),
1873
- "right",
1874
- px - right_ladder,
1875
- draft,
1876
- label=f"{n_rep}× {_fmt(rise)}",
1877
- ),
1874
+ chain.append(
1875
+ (
1878
1876
  "dim_step_typ",
1879
- view="front",
1880
- )
1881
- right_ladder = px
1882
- n += 1
1883
- else:
1884
- ctx.record_issue(
1885
- "error",
1886
- "placement_unsatisfiable",
1877
+ FZ(first),
1878
+ f"{n_rep}× {_fmt(rise)}",
1879
+ _SLOT_DIM_STEP,
1887
1880
  "representative step-height dimension dropped (front-view right strip full)",
1888
1881
  )
1882
+ )
1889
1883
  elif levels:
1890
1884
  kept, n_close = _legible_steps(levels, a.bb.min.Z, a.SCALE)
1891
1885
  if n_close:
@@ -1896,77 +1890,96 @@ def render_height_ladder(dwg, model, a, *, ctx, include_overall: bool = True) ->
1896
1890
  "(use a detail view)",
1897
1891
  )
1898
1892
  # First-class escalation alongside the lint code (ADR 0009 Amdt 1, #351
1899
- # PR-4b) — `_request_prismatic_detail` (sections.py) triggers the detail-view
1900
- # remedy on this instead of independently recomputing the same legibility
1901
- # gate, which previously could queue a spurious detail even when the uniform-
1902
- # staircase branch above already fully documented the part with one
1903
- # representative dim (a real bug this routing fixes as a side effect).
1893
+ # PR-4b) — `_request_prismatic_detail` (sections.py) consumes this instead
1894
+ # of recomputing the legibility gate.
1904
1895
  ctx.escalations.append(
1905
1896
  Escalation(kind="step", view="front", feature=step, reason="illegible")
1906
1897
  )
1907
1898
  for col, z in enumerate(kept):
1908
- perp = tuple(sorted((FZ(a.bb.min.Z), FZ(z))))
1909
- px = carve_free_position(dwg, a.fv_zones.right, "front", "x", _SLOT_DIM_STEP, perp)
1910
- if px is None:
1911
- ctx.record_issue(
1912
- "error",
1913
- "placement_unsatisfiable",
1914
- f"{len(kept) - col} step-height dimension(s) dropped "
1915
- "(front-view right strip full)",
1899
+ chain.append(
1900
+ (
1901
+ f"dim_step_{col}",
1902
+ FZ(z),
1903
+ _fmt(z - a.bb.min.Z),
1904
+ _SLOT_DIM_STEP,
1905
+ "step-height dimension dropped (front-view right strip full)",
1916
1906
  )
1917
- break
1918
- dwg.add(
1919
- _dim(
1920
- (right_ladder, FZ(a.bb.min.Z), 0),
1921
- (right_ladder, FZ(z), 0),
1922
- "right",
1923
- px - right_ladder,
1924
- draft,
1925
- label=_fmt(z - a.bb.min.Z),
1926
- ),
1927
- f"dim_step_{col}",
1928
- view="front",
1929
1907
  )
1930
- right_ladder = px
1931
- n += 1
1932
1908
 
1933
- # Overall height — placed last so it sits OUTERMOST; suppressed for a Z-turned
1934
- # part (its IR step-length chain already tiles the full height, ISO 129) and for
1935
- # an X/Y rotational body (its Z extent IS the OD, dimensioned by render_rotational
1936
- # — #222).
1937
1909
  rot = next((f for f in model.features if f.kind == "rotational"), None)
1938
1910
  od_is_height = rot is not None and rot.frame.axis in ("x", "y")
1939
1911
  suppress_height = (not include_overall) or model.orientation == "z" or od_is_height
1940
- px = (
1941
- None
1942
- if suppress_height
1943
- else carve_free_position(
1944
- dwg,
1912
+ if not suppress_height:
1913
+ chain.append(
1914
+ (
1915
+ "dim_height",
1916
+ FZ(a.bb.max.Z),
1917
+ _fmt(a.z_size),
1918
+ _SLOT_DIM_HEIGHT,
1919
+ "overall height dimension dropped (front-view right strip full)",
1920
+ )
1921
+ )
1922
+
1923
+ names = [c[0] for c in chain]
1924
+ solved: dict[str, float] = {}
1925
+ for k, (name, ztop, label, _tsize, drop_msg) in enumerate(chain):
1926
+
1927
+ def _build(pos, name=name, ztop=ztop, label=label, k=k):
1928
+ base = edge2
1929
+ for pn in reversed(names[:k]): # nearest already-built predecessor's line
1930
+ if pn in solved:
1931
+ base = solved[pn]
1932
+ break
1933
+ solved[name] = pos
1934
+ return _dim((base, zmin, 0), (base, ztop, 0), "right", pos - base, draft, label=label)
1935
+
1936
+ def _foot(pos, ztop=ztop, label=label, k=k):
1937
+ # Predecessor-aware prediction (#689 review): the conservative
1938
+ # edge-anchored witness can falsely exhaust the strip when an inner
1939
+ # obstacle sits in the already-traversed region. Use the same solved
1940
+ # map the build chain uses — empty on the first evaluation (edge-based,
1941
+ # the prior behaviour), tight on later-segment retries.
1942
+ base = edge2
1943
+ for pn in reversed(names[:k]):
1944
+ if pn in solved:
1945
+ base = solved[pn]
1946
+ break
1947
+ if pos - base < 0.5: # degenerate guard: never model a zero-length offset
1948
+ base = edge2
1949
+ return dim_footprint(
1950
+ (base, zmin, 0), (base, ztop, 0), "right", pos - base, draft, label
1951
+ )
1952
+
1953
+ def _drop(nm, drop_msg=drop_msg, name=name):
1954
+ solved.pop(name, None)
1955
+ ctx.record_issue("error", "placement_unsatisfiable", drop_msg)
1956
+
1957
+ register_corridor(
1958
+ ctx,
1959
+ ("front", "right"),
1945
1960
  a.fv_zones.right,
1946
1961
  "front",
1947
1962
  "x",
1948
- _SLOT_DIM_HEIGHT,
1949
- tuple(sorted((FZ(a.bb.min.Z), FZ(a.bb.max.Z)))),
1950
- outermost=True,
1951
- )
1952
- )
1953
- if px is not None:
1954
- dwg.add(
1955
- _dim(
1956
- (right_ladder, FZ(a.bb.min.Z), 0),
1957
- (right_ladder, FZ(a.bb.max.Z), 0),
1958
- "right",
1959
- px - right_ladder,
1960
- draft,
1961
- label=_fmt(a.z_size),
1963
+ tier,
1964
+ CorridorCandidate(
1965
+ name=name,
1966
+ build=_build,
1967
+ # Steps stack inner→outer in chain order; the overall height rides the
1968
+ # OVERALL subchain so it lands outermost by construction (as the
1969
+ # envelope dims do), replacing the old carve's outermost=True.
1970
+ order=(
1971
+ (_OVERALL_SUBCHAIN, 0, name)
1972
+ if name == "dim_height"
1973
+ else (_SIZE_SUBCHAIN, k, name)
1974
+ ),
1975
+ on_place=lambda nm: None,
1976
+ on_drop=_drop,
1977
+ force=True, # principal dims: only a physically full strip drops them
1978
+ feature=step if name != "dim_height" else None,
1979
+ footprint=_foot,
1962
1980
  ),
1963
- "dim_height",
1964
- view="front",
1965
1981
  )
1966
- n += 1
1967
- elif not suppress_height:
1968
- _log.warning("dim_height skipped: fv_zones.right strip full")
1969
- return n
1982
+ return len(chain)
1970
1983
 
1971
1984
 
1972
1985
  def render_step_positions(dwg, model, a, *, ctx) -> int:
@@ -2868,29 +2881,44 @@ def render_gdt(dwg, model, a, *, ctx) -> int:
2868
2881
  ):
2869
2882
  # Fallthrough (#481): the declared/derived side is full — try the OPPOSITE side of
2870
2883
  # the same view before dropping, so a congested default still places somewhere
2871
- # legible rather than vanishing. Best-effort (mirrors render_slots' below-fallthrough):
2872
- # carve a free tier on the alternate strip and place there; carve_free_position only
2873
- # ever sees already-placed annotations, so it can't collide with a not-yet-drained
2874
- # sibling corridor. Force semantics (no corridor-cross check) match the primary path,
2875
- # BUT reject a spot over the (not-yet-placed) title block — a below/right strip runs
2876
- # into it, and carve can't see it (#481 review).
2877
- alt = {"above": "below", "below": "above", "left": "right", "right": "left"}[_s]
2878
- alt_strip = getattr(_zones, alt, None)
2879
- if alt_strip is not None:
2880
- axis2 = "y" if alt in ("above", "below") else "x"
2881
- extent = _sz[1] if axis2 == "y" else _sz[0] # the glyph's stacking-axis size
2882
- perp = (_px, _px + _sz[0]) if _hz else (_py - _sz[1] / 2, _py + _sz[1] / 2)
2883
- pos = carve_free_position(dwg, alt_strip, _v, axis2, max(tier, extent), perp)
2884
- if pos is not None:
2885
- dim = _bld(pos)
2886
- if not _box_hits(_anno_box(dim), (_tb,)): # clear of the title block
2887
- dwg.add(dim, nm, view=_v, feature=_feat) # placed on the alternate side
2888
- return
2889
- ctx.record_issue(
2890
- "warning",
2891
- "gdt_dropped",
2892
- f"{nm} not placed (no room in the {_v} {_s} strip or its opposite)",
2893
- )
2884
+ # legible rather than vanishing. DEFERRED via ctx.post_drain (#636, the plate
2885
+ # pattern): the carve then runs after EVERY corridor has drained, so it cannot
2886
+ # preempt a corner a later sibling's force candidate needs. Force semantics
2887
+ # (no corridor-cross check) match the primary path, BUT reject a spot over the
2888
+ # (not-yet-placed) title block — a below/right strip runs into it, and the
2889
+ # carve can't see it (#481 review).
2890
+ def _retry(
2891
+ nm=nm,
2892
+ _v=_v,
2893
+ _s=_s,
2894
+ _zones=_zones,
2895
+ _px=_px,
2896
+ _py=_py,
2897
+ _hz=_hz,
2898
+ _sz=_sz,
2899
+ _bld=_bld,
2900
+ _feat=_feat,
2901
+ _tb=_tb,
2902
+ ):
2903
+ alt = {"above": "below", "below": "above", "left": "right", "right": "left"}[_s]
2904
+ alt_strip = getattr(_zones, alt, None)
2905
+ if alt_strip is not None:
2906
+ axis2 = "y" if alt in ("above", "below") else "x"
2907
+ extent = _sz[1] if axis2 == "y" else _sz[0] # the glyph's stacking-axis size
2908
+ perp = (_px, _px + _sz[0]) if _hz else (_py - _sz[1] / 2, _py + _sz[1] / 2)
2909
+ pos = carve_free_position(dwg, alt_strip, _v, axis2, max(tier, extent), perp)
2910
+ if pos is not None:
2911
+ dim = _bld(pos)
2912
+ if not _box_hits(_anno_box(dim), (_tb,)): # clear of the title block
2913
+ dwg.add(dim, nm, view=_v, feature=_feat) # alternate side
2914
+ return
2915
+ ctx.record_issue(
2916
+ "warning",
2917
+ "gdt_dropped",
2918
+ f"{nm} not placed (no room in the {_v} {_s} strip or its opposite)",
2919
+ )
2920
+
2921
+ ctx.post_drain.append(_retry)
2894
2922
 
2895
2923
  register_corridor(
2896
2924
  ctx,
@@ -72,6 +72,7 @@ from draftwright.model import (
72
72
  plan_dimensions,
73
73
  plan_sections,
74
74
  )
75
+ from draftwright.repair import reconcile_witness_labels
75
76
 
76
77
 
77
78
  def _wrap_rows(header, data, ncols):
@@ -356,6 +357,11 @@ def _auto_annotate(dwg, a: Analysis, *, detail_view: bool = False):
356
357
  # (ADR 0009 end state, #345/#346/#393) — dedup coincident spans, order the ladder —
357
358
  # BEFORE the section/detail views so they see the placed ladder as an obstacle.
358
359
  drain_corridors(ctx, dwg)
360
+ # (#690) Witness-crossing label reconciliation — after every corridor (and its
361
+ # deferred fallthroughs) has placed: shift any dim label a FOREIGN transverse
362
+ # stroke crosses, along its own line, via the repair machinery. Deterministic,
363
+ # runs in both build paths.
364
+ reconcile_witness_labels(dwg)
359
365
 
360
366
  # Turned/circlip-groove callouts (#148c): {width} WIDE × ø{dia} via a leader off each
361
367
  # groove. A groove is a secondary leader-callout on a turned shaft — exactly where the
@@ -562,7 +562,26 @@ def _resolve_details(dwg, a: Analysis, *, ctx) -> None:
562
562
  _log.info("detail request '%s' dropped: detail letters A–H exhausted", req.kind)
563
563
  continue
564
564
  letter = _DETAIL_LETTERS[n_placed]
565
- if _render_detail(dwg, a, req, f"detail_{letter.lower()}", letter):
565
+ placed = _render_detail(dwg, a, req, f"detail_{letter.lower()}", letter)
566
+ if not placed and req.kind == "prismatic-steps" and "dim_height" in dwg.annotations():
567
+ # (#636) The user's explicit detail request outranks the overall-height
568
+ # dim: pre-migration, the solver-invisible carve silently dropped
569
+ # dim_height on exactly these crowded parts, and the detail took that
570
+ # room. The corridor solve now places dim_height when it fits — so on a
571
+ # no-room prismatic detail, demote it DELIBERATELY (same outcome, now
572
+ # explicit and logged) and retry the detail once.
573
+ hview = dwg.view_of("dim_height")
574
+ hobj = dwg.remove("dim_height")
575
+ placed = _render_detail(dwg, a, req, f"detail_{letter.lower()}", letter)
576
+ if placed:
577
+ _log.warning(
578
+ "dim_height demoted: the requested crowded-step detail view takes its room"
579
+ )
580
+ else:
581
+ # Transactional (#689 review): the detail may fail for reasons other
582
+ # than dim_height's room — restore it rather than losing BOTH.
583
+ dwg.add(hobj, "dim_height", view=hview)
584
+ if placed:
566
585
  n_placed += 1
567
586
 
568
587
 
@@ -265,14 +265,13 @@ def _assemble(a, out, assembly, detail_view, auto_dims, model=None, decorations=
265
265
  cyls=a.cyls,
266
266
  assembly=assembly,
267
267
  )
268
- dwg._analysis = a # expose analysis namespace for testing and future strip access
269
268
  # Detect the IR here — before the auto_dims gate — so dwg.model() and feature edits
270
269
  # work even in manual mode (#398). _auto_annotate reads this attached model rather
271
270
  # than rebuilding. On a repack this runs again on the pass-2 drawing (freshness).
272
271
  # Detected path: reuse the model _analyse already built for sizing (#584 WP1 A) —
273
272
  # detectors run once per build (ADR 0008 Amdt 5, #602). build_model(a) remains the
274
273
  # fallback for a manually-constructed Analysis with no stored model.
275
- dwg._part_model = (
274
+ pm = (
276
275
  _coerce_model(model, a.part, decorations)
277
276
  if model is not None
278
277
  else (a.model if a.model is not None else build_model(a))
@@ -283,23 +282,25 @@ def _assemble(a, out, assembly, detail_view, auto_dims, model=None, decorations=
283
282
  # (rot furniture). Synthesise it from the (unconditional) analysis so a declared /
284
283
  # emitted-script turned part reproduces the detected drawing (#472). Gated on the
285
284
  # caller not having declared one, so an explicit choice wins.
286
- pm = dwg._part_model
287
285
  if not any(f.kind == "rotational" for f in pm.features):
288
286
  rot = build_rotational_feature(a)
289
287
  if rot is not None:
290
- dwg._part_model = replace(pm, features=[*pm.features, rot])
288
+ pm = replace(pm, features=[*pm.features, rot])
291
289
  # PMI (STEP AP242) is likewise detection-sourced, so a declared / emitted-script model
292
290
  # carries none. When PMI annotation is on, synthesise the same imported drafting
293
291
  # annotations detection would (render_pmi reads them off the model, gated on a.pmi_mode)
294
292
  # so a re-run reproduces the PMI dims (#472). Gated on the caller not having declared
295
293
  # imported authored annotations, so an explicit set wins.
296
- pm = dwg._part_model
297
294
  if a.pmi_mode == "annotate" and not any(
298
295
  f.kind in ("authored_dimension", "pmi") for f in pm.features
299
296
  ):
300
297
  pmi_feats = build_pmi_features(a.pmi, a.part.bounding_box())
301
298
  if pmi_feats:
302
- dwg._part_model = replace(pm, features=[*pm.features, *pmi_feats])
299
+ pm = replace(pm, features=[*pm.features, *pmi_feats])
300
+ # ADR 0005 §2 (#639): the ONE build-context attachment — analysis + finished model
301
+ # in a single typed BuildState; the compat properties on Drawing read through it.
302
+ dwg._build.analysis = a
303
+ dwg._build.part_model = pm
303
304
  dwg._model_declared = model is not None # ADR 0011 #448: gate model-driven hole render
304
305
 
305
306
  part_s = a.part.scale(a.SCALE)