draftwright 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {draftwright-0.2.0 → draftwright-0.2.1}/CHANGELOG.md +40 -0
  2. {draftwright-0.2.0 → draftwright-0.2.1}/PKG-INFO +1 -1
  3. {draftwright-0.2.0 → draftwright-0.2.1}/pyproject.toml +1 -1
  4. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/_core.py +43 -0
  5. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/annotations/from_model.py +128 -66
  6. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/annotations/holes.py +41 -2
  7. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/annotations/orchestrator.py +17 -6
  8. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/annotations/sections.py +182 -138
  9. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/builder.py +30 -1
  10. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/linting/coverage.py +69 -36
  11. {draftwright-0.2.0 → draftwright-0.2.1}/tests/test_make_drawing.py +278 -2
  12. {draftwright-0.2.0 → draftwright-0.2.1}/.gitignore +0 -0
  13. {draftwright-0.2.0 → draftwright-0.2.1}/LICENSE +0 -0
  14. {draftwright-0.2.0 → draftwright-0.2.1}/README.md +0 -0
  15. {draftwright-0.2.0 → draftwright-0.2.1}/skills/SKILL.md +0 -0
  16. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/__init__.py +0 -0
  17. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/analysis.py +0 -0
  18. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/annotate.py +0 -0
  19. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/annotations/__init__.py +0 -0
  20. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/annotations/_common.py +0 -0
  21. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/cli.py +0 -0
  22. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/drawing.py +0 -0
  23. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/export.py +0 -0
  24. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/features.py +0 -0
  25. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  26. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  27. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  28. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  29. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/fonts/__init__.py +0 -0
  30. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/layout.py +0 -0
  31. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/linting/__init__.py +0 -0
  32. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/linting/issues.py +0 -0
  33. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/linting/structural.py +0 -0
  34. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/linting/suggest.py +0 -0
  35. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/make_drawing.py +0 -0
  36. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/model/__init__.py +0 -0
  37. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/model/detect.py +0 -0
  38. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/model/ir.py +0 -0
  39. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/model/planner.py +0 -0
  40. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/pmi.py +0 -0
  41. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/projection.py +0 -0
  42. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/recognition/__init__.py +0 -0
  43. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/recognition/_features.py +0 -0
  44. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/recognition/levels.py +0 -0
  45. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/recognition/slots.py +0 -0
  46. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/recognition/turned.py +0 -0
  47. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/registry.py +0 -0
  48. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/repair.py +0 -0
  49. {draftwright-0.2.0 → draftwright-0.2.1}/src/draftwright/sheet.py +0 -0
  50. {draftwright-0.2.0 → draftwright-0.2.1}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  51. {draftwright-0.2.0 → draftwright-0.2.1}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  52. {draftwright-0.2.0 → draftwright-0.2.1}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  53. {draftwright-0.2.0 → draftwright-0.2.1}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  54. {draftwright-0.2.0 → draftwright-0.2.1}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  55. {draftwright-0.2.0 → draftwright-0.2.1}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  56. {draftwright-0.2.0 → draftwright-0.2.1}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  57. {draftwright-0.2.0 → draftwright-0.2.1}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  58. {draftwright-0.2.0 → draftwright-0.2.1}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  59. {draftwright-0.2.0 → draftwright-0.2.1}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  60. {draftwright-0.2.0 → draftwright-0.2.1}/tests/test_e2e_slice.py +0 -0
  61. {draftwright-0.2.0 → draftwright-0.2.1}/tests/test_e2e_standards.py +0 -0
  62. {draftwright-0.2.0 → draftwright-0.2.1}/tests/test_layout.py +0 -0
  63. {draftwright-0.2.0 → draftwright-0.2.1}/tests/test_lint_structural.py +0 -0
  64. {draftwright-0.2.0 → draftwright-0.2.1}/tests/test_linting.py +0 -0
  65. {draftwright-0.2.0 → draftwright-0.2.1}/tests/test_part_model.py +0 -0
  66. {draftwright-0.2.0 → draftwright-0.2.1}/tests/test_pmi.py +0 -0
  67. {draftwright-0.2.0 → draftwright-0.2.1}/tests/test_recognition.py +0 -0
  68. {draftwright-0.2.0 → draftwright-0.2.1}/tests/test_registry.py +0 -0
  69. {draftwright-0.2.0 → draftwright-0.2.1}/tests/test_render_seam.py +0 -0
  70. {draftwright-0.2.0 → draftwright-0.2.1}/tests/test_turned_steps.py +0 -0
@@ -2,6 +2,46 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.2.1 — 2026-06-30
6
+
7
+ A patch release focused on **turned-part dimensioning legibility**: crowded
8
+ step-length chains and fine turned heads are now drawn legibly instead of crammed.
9
+ Drawing output changes for affected turned parts.
10
+
11
+ ### Added
12
+
13
+ - **Automatic enlarged detail view for a crowded turned head** (#304). A turned
14
+ part with a fine cluster of steps near one end and a long shaft (e.g. a thumbwheel
15
+ drive screw) cannot have its head dimensioned legibly in line at any sensible
16
+ scale. The head is now located as one block on the main view and broken out into
17
+ an enlarged **DETAIL A — SCALE n:1** — the textbook treatment — firing
18
+ automatically when a head's shoulders fall below the page legibility floor.
19
+
20
+ ### Changed
21
+
22
+ - **Crowded turned step-length chains stagger across two tiers** (#293) instead of
23
+ cramming or being skipped. When the labels would collide on one line, the ISO
24
+ 129-1 staggered convention alternates them between a near and a far tier so every
25
+ step length stays legible at the drawing's own scale — no rescale needed. A roomy
26
+ chain stays on a single tier.
27
+ - **Detail views are now one unified pipeline** (#307). The prismatic step-height
28
+ detail and the new turned-head detail flow through a single
29
+ detect → request → render path; several crowded regions become DETAIL A/B/…
30
+ - **Output changes** for turned parts whose step chains were previously crammed, or
31
+ whose fine heads are now broken out into a detail view.
32
+
33
+ ### Fixed
34
+
35
+ - **A coaxial bore callout no longer overlaps the round view's centreline** (#305):
36
+ its leader is angled off the centre axis so the callout text sits in clear space.
37
+
38
+ ### Internal
39
+
40
+ - A new **layout-cleanliness invariant test** asserts that finished drawings have no
41
+ view/annotation collisions across representative part archetypes, and the
42
+ measure-and-repack pass gained a trigger for an annotation growing into a
43
+ neighbouring view's line-work (so the views spread to make room).
44
+
5
45
  ## v0.2.0 — 2026-06-30
6
46
 
7
47
  A major release. draftwright took ownership of feature recognition and linting
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Automated technical-drawing generation for build123d
5
5
  Project-URL: Homepage, https://github.com/pzfreo/draftwright
6
6
  Project-URL: Repository, https://github.com/pzfreo/draftwright
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
  import functools
14
14
  import logging
15
15
  import re
16
+ from collections.abc import Callable
16
17
  from dataclasses import dataclass, field
17
18
  from pathlib import Path
18
19
  from types import SimpleNamespace
@@ -382,6 +383,48 @@ def _largest_empty_rect(drawable, obstacles):
382
383
  return best
383
384
 
384
385
 
386
+ @dataclass
387
+ class DetailRequest:
388
+ """A renderer's request for an enlarged detail of a region it could not draw
389
+ legibly at sheet scale (#307). Renderers append these to ``dwg._detail_requests``
390
+ instead of building bespoke detail views; ``_resolve_details`` resolves them all
391
+ through one generic detailer (crop → project → place → caption → marker), then
392
+ calls ``redraw`` to draw the feature's own dims inside the placed detail view.
393
+
394
+ The single ``detect → request → generic render`` path that folds the prismatic
395
+ step detail (#42) and the turned-head detail (#304) into one, mirroring the
396
+ section pipeline (``plan_sections``/``SectionPlan``).
397
+
398
+ Fields:
399
+ axis: part axis the band spans / is cropped along ("x"/"y"/"z").
400
+ lo, hi: band bounds along ``axis`` (world mm).
401
+ scale_needed: detail world→page scale that makes the region legible.
402
+ redraw: ``redraw(dwg, view_name, detail_scale) -> int`` — draws the
403
+ detail's dimensions in the placed detail view's coordinate system
404
+ and returns the count placed (0 → the detailer rolls the view
405
+ back rather than leave an empty box). Called once the detail is
406
+ placed; the main view always carries the located head/block
407
+ inline regardless, so a placement failure loses no coverage (lint
408
+ reports the un-located interior instead).
409
+ pad_top: page-mm band reserved above the detail view (a horizontal
410
+ chain); reserved in the fit + placement.
411
+ pads: optional ``pads(detail_scale) -> (pad_right, pad_top)`` for a
412
+ footprint that depends on the chosen scale (the prismatic
413
+ ladder reserves one rung per *legible-at-that-scale* step, so it
414
+ shrinks with the scale during the fit). Overrides ``pad_top``.
415
+ kind: short label for logging.
416
+ """
417
+
418
+ axis: str
419
+ lo: float
420
+ hi: float
421
+ scale_needed: float
422
+ redraw: Callable[..., int]
423
+ pad_top: float = 0.0
424
+ pads: Callable[[float], tuple[float, float]] | None = None
425
+ kind: str = "detail"
426
+
427
+
385
428
  @dataclass(frozen=True)
386
429
  class _Projector:
387
430
  """Model → page coordinate projection for the orthographic views.
@@ -27,10 +27,12 @@ from draftwright._core import (
27
27
  _DIAM_RE,
28
28
  _END_ON,
29
29
  _MARGIN,
30
+ _MIN_STEP_SEP_MM,
30
31
  _SLOT_DIM_DEPTH,
31
32
  _SLOT_DIM_HEIGHT,
32
33
  _SLOT_DIM_STEP,
33
34
  _SLOT_DIM_WIDTH,
35
+ DetailRequest,
34
36
  _dim,
35
37
  _fmt,
36
38
  _greedy_strip_ys,
@@ -584,52 +586,30 @@ def render_envelope(dwg, groups, a) -> int:
584
586
  return n
585
587
 
586
588
 
587
- def render_step_lengths(dwg, groups) -> int:
588
- """Unified turned step-length chain (ADR 0008 #223)one IR-driven path that
589
- replaces the engine's asymmetric X-chain / Z-ladder. Each `StepFeature`'s length
590
- span is projected into the front view; the chain runs *along the projected axis*
591
- just outside the view **horizontal** for an X-turned part, **vertical** for a
592
- Z-turned part. Orientation is the projected span direction, not a branch, so X
593
- and Z get the same complete chain. Returns the number of step dims placed.
594
-
595
- The chain is collinear (all segments share one offset line) and tiles end to
596
- end, so every shoulder is located. Crowded labels are spread along the line by
597
- the ADR-0003 strip solve (the primitive the engine's X chain already used)."""
598
- segs = [] # (page_lo, page_hi, value), in axis order
599
- for g in groups:
600
- if g.feature_kind != "step":
601
- continue
602
- length = next(
603
- (pd.param for pd in g.dims if pd.param.kind == "length" and pd.param.span is not None),
604
- None,
605
- )
606
- if length is None or length.span is None:
607
- continue
608
- a, b = length.span
609
- pa, pb = dwg.at("front", *a), dwg.at("front", *b)
610
- segs.append((pa, pb, length.value))
589
+ def _draw_step_chain(dwg, view, segs, name_prefix, detail_scale=None, allow_collapse=True) -> int:
590
+ """Place a turned step-length chain in *view* from *segs* each ``(pa, pb,
591
+ value)`` already projected to *view*'s page coords, in axis order. Orientation is
592
+ data (the projected span direction): horizontal chain above the view, vertical
593
+ chain to the right. A uniform run collapses to one ``N× v`` dim (#230); else a
594
+ per-segment chain, staggered into a near/far tier only when crowded (ISO 129-1,
595
+ #293); skipped if even two tiers can't separate the labels, or if any dim would
596
+ fall off the page. ``detail_scale`` tags the dims for label-vs-measured lint when
597
+ drawing inside a scaled detail view. ``allow_collapse=False`` disables the ``N× v``
598
+ collapse used when the chain mixes a synthetic head-*block* with real steps, where
599
+ a uniform-staircase representative would be a false claim of N equal steps (#307
600
+ review). Returns the count placed."""
611
601
  if not segs:
612
602
  return 0
613
- vb = dwg.view_bounds("front")
603
+ vb = dwg.view_bounds(view)
614
604
  if vb is None:
615
605
  return 0
616
606
  x0, y0, x1, y1 = vb
617
607
  draft = dwg.draft
618
608
  gap = draft.font_size + 4 * draft.pad_around_text
619
- # Orientation is data: the projected span direction. Horizontal → X-turned
620
- # (chain above the view); vertical → Z-turned (chain left of the view).
621
609
  horizontal = abs(segs[0][1][0] - segs[0][0][0]) >= abs(segs[0][1][1] - segs[0][0][1])
622
-
623
- # Spread crowded labels along a horizontal chain (ADR-0003 strip solve), then
624
- # carry each label back to its segment via label_offset_x (the only along-line
625
- # offset the Dimension primitive supports). A vertical chain places plain dims.
626
- # Uniform staircase → one representative "N× length" dim spanning the whole run
627
- # (#230), instead of N identical segment dims. Mirrors the prismatic ladder's
628
- # _detect_step_repeat: ≥3 segments, all within 10% of the mean (so a 2-step part
629
- # or a mixed chain still dimensions each segment).
630
610
  vals = [v for *_, v in segs]
631
611
  mean_v = sum(vals) / len(vals)
632
- if len(segs) >= 3 and (max(vals) - min(vals)) <= 0.10 * mean_v:
612
+ if allow_collapse and len(segs) >= 3 and (max(vals) - min(vals)) <= 0.10 * mean_v:
633
613
  label = f"{len(segs)}× {_fmt(mean_v)}"
634
614
  xs = [p[0] for pa, pb, _ in segs for p in (pa, pb)]
635
615
  ys = [p[1] for pa, pb, _ in segs for p in (pa, pb)]
@@ -637,48 +617,47 @@ def render_step_lengths(dwg, groups) -> int:
637
617
  dim = _dim((min(xs), y1, 0), (max(xs), y1, 0), "above", gap, draft, label=label)
638
618
  else:
639
619
  dim = _dim((x1, min(ys), 0), (x1, max(ys), 0), "right", gap, draft, label=label)
640
- candidates = [("m_steplen_typ", dim)]
620
+ candidates = [(f"{name_prefix}_typ", dim)]
641
621
  else:
642
- offsets = [0.0] * len(segs)
643
- # Legibility guard (#293): a too-dense chain must SKIP, not overprint. Placing
644
- # an unreadable wall of overlapping dims is worse than none — lint then reports
645
- # axial_length_missing, and the user resolves it with a larger scale or a detail
646
- # view. The room guard's sibling, for crowding rather than page overflow.
647
- gap_min = draft.font_size + 2 * draft.pad_around_text
622
+ tier_step = draft.font_size + 2 * draft.pad_around_text
623
+ tiers = [0] * len(segs)
648
624
  if horizontal:
649
- centers = [(pa[0] + pb[0]) / 2 for pa, pb, _ in segs]
650
- half_w = max(len(_fmt(v)) for *_, v in segs) * draft.font_size * 0.62 / 2
651
- min_gap = 2 * half_w + 2 * draft.pad_around_text
652
- solved = _solve_strip_ys(
653
- centers, min_gap, x0 + half_w, x1 - half_w
654
- ) or _greedy_strip_ys(centers, min_gap, x0 + half_w, x1 - half_w)
655
- placed = sorted(solved) if solved else sorted(centers)
656
- if any(b - a < min_gap - 0.01 for a, b in zip(placed, placed[1:])):
657
- _log.info("step-length chain skipped: %d labels too dense to space", len(segs))
625
+ cw = [
626
+ ((pa[0] + pb[0]) / 2, len(_fmt(v)) * draft.font_size * 0.62) for pa, pb, v in segs
627
+ ]
628
+
629
+ def _clear(items): # (center, width) pairs in x order — labels don't overlap
630
+ return all(
631
+ c2 - c1 >= (w1 + w2) / 2 + draft.pad_around_text
632
+ for (c1, w1), (c2, w2) in zip(items, items[1:])
633
+ )
634
+
635
+ if _clear(cw):
636
+ pass # one tier suffices — no needless zig-zag for a roomy chain
637
+ elif _clear(cw[0::2]) and _clear(cw[1::2]):
638
+ tiers = [i % 2 for i in range(len(segs))] # alternate to make room
639
+ else:
640
+ _log.info("step-length chain skipped: too dense even when staggered")
658
641
  return 0
659
- if solved:
660
- offsets = [s - c for s, c in zip(solved, centers)]
661
642
  else:
662
- # Z-turned chain places plain dims at the shoulders (no along-line spread is
663
- # available vertically), so its legibility is the raw shoulder spacing.
664
643
  shoulder_ys = sorted({c for pa, pb, _ in segs for c in (pa[1], pb[1])})
665
- if any(b - a < gap_min for a, b in zip(shoulder_ys, shoulder_ys[1:])):
644
+ if any(b - a < tier_step for a, b in zip(shoulder_ys, shoulder_ys[1:])):
666
645
  _log.info("step-length chain skipped: shoulders too close to dimension")
667
646
  return 0
668
647
 
669
648
  candidates = []
670
649
  for i, (pa, pb, value) in enumerate(segs):
671
- if horizontal: # X-turned: chain above the view, witnesses rise from the top
650
+ if horizontal:
672
651
  p1, p2, side = (pa[0], y1, 0), (pb[0], y1, 0), "above"
673
- kw = {"label": _fmt(value), "label_offset_x": offsets[i]}
674
- else: # Z-turned: chain right of the view, witnesses from the right edge
652
+ dist = gap + tiers[i] * tier_step
653
+ else:
675
654
  p1, p2, side = (x1, pa[1], 0), (x1, pb[1], 0), "right"
676
- kw = {"label": _fmt(value)}
677
- candidates.append((f"m_steplen{i}", _dim(p1, p2, side, gap, draft, **kw)))
655
+ dist = gap
656
+ candidates.append(
657
+ (f"{name_prefix}{i}", _dim(p1, p2, side, dist, draft, label=_fmt(value)))
658
+ )
678
659
 
679
- # Room guard (the engine's contract): if any dim would fall off the drawable
680
- # page, place NONE and let lint report axial_length_missing — never run the
681
- # chain off the page edge.
660
+ # Room guard: if any dim would fall off the drawable page, place NONE.
682
661
  page = (_MARGIN, _MARGIN, dwg.page_w - _MARGIN, dwg.page_h - _MARGIN)
683
662
  for _, dim in candidates:
684
663
  box = _anno_box(dim)
@@ -687,10 +666,93 @@ def render_step_lengths(dwg, groups) -> int:
687
666
  ):
688
667
  return 0
689
668
  for name, dim in candidates:
690
- dwg.add(dim, name, view="front")
669
+ if detail_scale is not None:
670
+ dim._dw_scale = detail_scale
671
+ dwg.add(dim, name, view=view)
691
672
  return len(candidates)
692
673
 
693
674
 
675
+ def render_step_lengths(dwg, groups) -> int:
676
+ """Unified turned step-length chain (ADR 0008 #223): each `StepFeature`'s length
677
+ span projects into the front view and joins the chain that tiles the turning axis
678
+ so every shoulder is located. X-turned → horizontal chain above the view;
679
+ Z-turned → vertical chain to the right.
680
+
681
+ A crowded **X-turned head** — a contiguous run of steps too short to dimension
682
+ legibly even staggered (shoulders below the page arrowhead floor) — is not crammed
683
+ in line: the main view locates that run as one *block* dim and an enlarged
684
+ `DetailRequest` (#304/#307) is queued to break it down. If the detail later can't
685
+ place, the block still locates the head extent and lint reports the un-located
686
+ interior shoulders — never worse than the prior skip. Returns the count placed on
687
+ the front view."""
688
+ rows = [] # (a_world, b_world, value) in axis order
689
+ for g in groups:
690
+ if g.feature_kind != "step":
691
+ continue
692
+ length = next(
693
+ (pd.param for pd in g.dims if pd.param.kind == "length" and pd.param.span is not None),
694
+ None,
695
+ )
696
+ if length is None or length.span is None:
697
+ continue
698
+ rows.append((length.span[0], length.span[1], length.value))
699
+ if not rows:
700
+ return 0
701
+ draft = dwg.draft
702
+ fsegs = [(dwg.at("front", *a), dwg.at("front", *b), v) for a, b, v in rows]
703
+ horizontal = abs(fsegs[0][1][0] - fsegs[0][0][0]) >= abs(fsegs[0][1][1] - fsegs[0][0][1])
704
+
705
+ # X-turned crowded-head detour (#307): split off each contiguous *run of ≥2*
706
+ # sub-floor steps (segment narrower than two arrowheads on the page), locate it as
707
+ # a block, and queue an enlarged detail. A single isolated thin step is left in the
708
+ # main chain — a one-step block would just be that step at its sub-floor width
709
+ # (#307 review). The legible steps + blocks stay as the main chain.
710
+ if horizontal:
711
+ floor_pg = 2 * draft.arrow_length
712
+ sub = [i for i, (pa, pb, _) in enumerate(fsegs) if abs(pb[0] - pa[0]) < floor_pg]
713
+ runs: list[list[int]] = []
714
+ for j in sub:
715
+ (runs[-1].append(j) if runs and j == runs[-1][-1] + 1 else runs.append([j]))
716
+ heads = [run for run in runs if len(run) >= 2]
717
+ if heads:
718
+ blocks = []
719
+ for run in heads:
720
+ ra = [rows[i] for i in run]
721
+ hlo = min(min(a[0], b[0]) for a, b, _ in ra)
722
+ hhi = max(max(a[0], b[0]) for a, b, _ in ra)
723
+ minlen = min(v for *_, v in ra)
724
+ # World→page scale for the detail (no sheet factor — detail_scale is an
725
+ # absolute world→page scale). (#307 review)
726
+ scale_needed = _MIN_STEP_SEP_MM / minlen if minlen > 0 else float("inf")
727
+ blocks.append((dwg.at("front", hlo, 0, 0), dwg.at("front", hhi, 0, 0), hhi - hlo))
728
+
729
+ def _redraw(dwg, view, detail_scale, _hw=ra):
730
+ # View-scoped name prefix so two detail views never collide (#307 review).
731
+ hsegs = [(dwg.at(view, *a), dwg.at(view, *b), v) for a, b, v in _hw]
732
+ return _draw_step_chain(dwg, view, hsegs, f"dim_{view}_steplen", detail_scale)
733
+
734
+ dwg._detail_requests.append(
735
+ DetailRequest(
736
+ axis="x",
737
+ lo=hlo,
738
+ hi=hhi,
739
+ scale_needed=scale_needed,
740
+ redraw=_redraw,
741
+ pad_top=2 * (draft.font_size + 2 * draft.pad_around_text)
742
+ + draft.arrow_length,
743
+ kind="turned-head",
744
+ )
745
+ )
746
+ head = {i for run in heads for i in run}
747
+ main = [fsegs[i] for i in range(len(fsegs)) if i not in head] + blocks
748
+ main.sort(key=lambda s: s[0][0])
749
+ # The chain now mixes head-block(s) with real steps — never collapse it to a
750
+ # uniform "N× v" representative (a block is not a repeated step, #307 review).
751
+ return _draw_step_chain(dwg, "front", main, "m_steplen", allow_collapse=False)
752
+
753
+ return _draw_step_chain(dwg, "front", fsegs, "m_steplen")
754
+
755
+
694
756
  def _detect_step_repeat(step_zs, bb_min_z, bb_max_z, tol_frac=0.10):
695
757
  """Return (n, rise) if *step_zs* form a uniform staircase, else None.
696
758
 
@@ -514,6 +514,37 @@ def _annotate_holes(dwg, a: Analysis, view_of_axis, groups, feature_keys):
514
514
  return centre
515
515
  return (centre[0] + dx / norm * r, centre[1] + dy / norm * r)
516
516
 
517
+ def _coaxial_lift(centre, ny, view_cx, view_cy, y_min, y_max):
518
+ """Leader row for a hole, lifted clear of the round view's centre axis when
519
+ the hole is a *coaxial bore* (#305); *ny* unchanged otherwise.
520
+
521
+ A bore on the turning axis is led out along the view's horizontal centre
522
+ axis, so the centre mark / centreline runs straight through the "⌀… ↓…"
523
+ callout text. Detect that one bore — a rotational part, hole at the view
524
+ centre — and lift its row a clearance off the axis (an angled leader to a
525
+ central feature is standard practice), toward the roomier side. Off-axis
526
+ holes and every prismatic-part hole are untouched (front-view round parts
527
+ place coaxial bores as vertical shafts below the view, not along an axis,
528
+ so they can't hit this and are exempt by construction).
529
+
530
+ Tactical: the principled fix is to not draw the crossing line at all — a
531
+ centred bore is located by the axis, so its linear location dims are
532
+ redundant (#309) — or to make this a layout-solver separation constraint
533
+ (ADR 0003). This nudge becomes dead code once either lands."""
534
+ tol = draft.font_size # "hole at the view centre" tolerance (page mm)
535
+ if not (
536
+ a.is_rotational and abs(centre[0] - view_cx) < tol and abs(centre[1] - view_cy) < tol
537
+ ):
538
+ return ny
539
+ # Lift the row a full text height + padding clear of the axis: enough for
540
+ # the text box (half a font tall) to sit wholly off the centre line with a
541
+ # pad of margin, giving a legible leader angle rather than a near-flat one.
542
+ lift = draft.font_size + 3 * draft.pad_around_text
543
+ # Toward the roomier half-view (geometric, not occupancy-aware — safe here
544
+ # because the round view of a coaxial bore is otherwise near-empty).
545
+ up = (y_max - view_cy) >= (view_cy - y_min)
546
+ return min(view_cy + lift, y_max) if up else max(view_cy - lift, y_min)
547
+
517
548
  def _add(view, i, tip, elbow, side, callout):
518
549
  dwg.add(
519
550
  Leader(
@@ -598,6 +629,12 @@ def _annotate_holes(dwg, a: Analysis, view_of_axis, groups, feature_keys):
598
629
  else:
599
630
  y_min, y_max = a.SV_Y - a.fv_hh, a.SV_Y + a.fv_hh
600
631
 
632
+ # Round view's horizontal centre axis — a coaxial bore led out along it has
633
+ # its callout text crossed by the centre mark / centreline (#305); see
634
+ # _coaxial_lift.
635
+ view_cx = a.PV_X if view == "plan" else a.SV_X
636
+ view_cy = a.PV_Y if view == "plan" else a.SV_Y
637
+
601
638
  # --- Pass 1: boundary assignment ---
602
639
  right_queue = [] # (locs, dia, callout, feat, natural_y, rep)
603
640
  left_queue = []
@@ -632,9 +669,11 @@ def _annotate_holes(dwg, a: Analysis, view_of_axis, groups, feature_keys):
632
669
  continue
633
670
 
634
671
  if can_right and (not can_left or d_right <= d_left):
635
- right_queue.append((locs, dia, callout, feat, centre_r[1], rep_r))
672
+ ny = _coaxial_lift(centre_r, centre_r[1], view_cx, view_cy, y_min, y_max)
673
+ right_queue.append((locs, dia, callout, feat, ny, rep_r))
636
674
  else:
637
- left_queue.append((locs, dia, callout, feat, centre_l[1], rep_l))
675
+ ny = _coaxial_lift(centre_l, centre_l[1], view_cx, view_cy, y_min, y_max)
676
+ left_queue.append((locs, dia, callout, feat, ny, rep_l))
638
677
 
639
678
  # Sort each queue by natural Y so leaders don't cross.
640
679
  right_queue.sort(key=lambda s: s[4])
@@ -44,7 +44,11 @@ from draftwright.annotations.holes import (
44
44
  _annotate_holes,
45
45
  _locate_off_axis_holes,
46
46
  )
47
- from draftwright.annotations.sections import _add_detail_view, _add_section_view
47
+ from draftwright.annotations.sections import (
48
+ _add_section_view,
49
+ _request_prismatic_detail,
50
+ _resolve_details,
51
+ )
48
52
  from draftwright.model import build_part_model, plan_dimensions, plan_sections
49
53
  from draftwright.recognition import (
50
54
  full_cylinders,
@@ -99,6 +103,7 @@ def _auto_annotate(dwg, a: Analysis, *, detail_view: bool = False):
99
103
  # not accumulate duplicate drop records.
100
104
  dwg._reset_build_issues()
101
105
  dwg._reset_dropped_callout_diams()
106
+ dwg._detail_requests = [] # renderers queue enlarged-detail requests here (#307)
102
107
 
103
108
  FX = a.proj.front_x
104
109
  FZ = a.proj.front_z
@@ -223,18 +228,19 @@ def _auto_annotate(dwg, a: Analysis, *, detail_view: bool = False):
223
228
  if section is not None:
224
229
  _add_section_view(dwg, a, section)
225
230
 
226
- # Detail view: only when explicitly requested via build_drawing(detail_view=True).
231
+ # Prismatic step-height detail: queue it (only when build_drawing(detail_view=True))
232
+ # — resolved with every other detail request below (#307).
227
233
  if detail_view:
228
- _add_detail_view(dwg, a)
234
+ _request_prismatic_detail(dwg, a)
229
235
 
230
236
  # Turned-part dimensions via the IR (ADR 0008 convergence). The model is built
231
237
  # once and fed to both renderers (#229 — no per-pass rebuild):
232
238
  # - diameters: ø leaders, row below (X) / column left (Z), one path by frame
233
239
  # axis. Replaces _annotate_turned_diameters.
234
240
  # - step lengths: the chain that locates every shoulder, X and Z from one path
235
- # (#223). Replaces the old X-only chain + the Z step-height ladder (skipped
236
- # above for turned parts); the envelope dim along the turning axis was
237
- # suppressed so the chain does not double-dimension the length.
241
+ # (#223). A crowded X-turned head queues an enlarged detail request (#304/#307)
242
+ # instead of cramming; the envelope dim along the turning axis was suppressed
243
+ # so the chain does not double-dimension the length.
238
244
  render_diameters(dwg, _groups)
239
245
  if a.prof is not None:
240
246
  render_step_lengths(dwg, _groups)
@@ -249,6 +255,11 @@ def _auto_annotate(dwg, a: Analysis, *, detail_view: bool = False):
249
255
  # after every hole/diameter pass so it claims strip space last.
250
256
  render_slots(dwg, _model, a)
251
257
 
258
+ # Resolve every queued enlarged-detail request (#307) — prismatic step bands and
259
+ # crowded turned heads alike — through the one generic detailer, now that all
260
+ # views and main-view annotations are placed (so the detail avoids them).
261
+ _resolve_details(dwg, a)
262
+
252
263
  # Phase 7 — strip footprint debug logging + post-placement overflow check.
253
264
  # Overflow can only occur when outer_limit was tightened after allocations
254
265
  # were already committed (e.g. iso-x tightening or iso-y cap guard).