draftwright 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {draftwright-0.2.0 → draftwright-0.2.2}/CHANGELOG.md +65 -0
  2. {draftwright-0.2.0 → draftwright-0.2.2}/PKG-INFO +1 -1
  3. {draftwright-0.2.0 → draftwright-0.2.2}/pyproject.toml +1 -1
  4. draftwright-0.2.2/src/draftwright/__init__.py +103 -0
  5. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/_core.py +43 -0
  6. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/annotations/from_model.py +145 -69
  7. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/annotations/holes.py +93 -19
  8. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/annotations/orchestrator.py +48 -9
  9. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/annotations/sections.py +182 -138
  10. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/builder.py +30 -1
  11. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/cli.py +6 -2
  12. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/linting/coverage.py +69 -36
  13. {draftwright-0.2.0 → draftwright-0.2.2}/tests/test_make_drawing.py +408 -2
  14. draftwright-0.2.0/src/draftwright/__init__.py +0 -62
  15. {draftwright-0.2.0 → draftwright-0.2.2}/.gitignore +0 -0
  16. {draftwright-0.2.0 → draftwright-0.2.2}/LICENSE +0 -0
  17. {draftwright-0.2.0 → draftwright-0.2.2}/README.md +0 -0
  18. {draftwright-0.2.0 → draftwright-0.2.2}/skills/SKILL.md +0 -0
  19. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/analysis.py +0 -0
  20. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/annotate.py +0 -0
  21. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/annotations/__init__.py +0 -0
  22. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/annotations/_common.py +0 -0
  23. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/drawing.py +0 -0
  24. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/export.py +0 -0
  25. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/features.py +0 -0
  26. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  27. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  28. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  29. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  30. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/fonts/__init__.py +0 -0
  31. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/layout.py +0 -0
  32. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/linting/__init__.py +0 -0
  33. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/linting/issues.py +0 -0
  34. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/linting/structural.py +0 -0
  35. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/linting/suggest.py +0 -0
  36. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/make_drawing.py +0 -0
  37. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/model/__init__.py +0 -0
  38. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/model/detect.py +0 -0
  39. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/model/ir.py +0 -0
  40. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/model/planner.py +0 -0
  41. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/pmi.py +0 -0
  42. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/projection.py +0 -0
  43. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/recognition/__init__.py +0 -0
  44. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/recognition/_features.py +0 -0
  45. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/recognition/levels.py +0 -0
  46. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/recognition/slots.py +0 -0
  47. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/recognition/turned.py +0 -0
  48. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/registry.py +0 -0
  49. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/repair.py +0 -0
  50. {draftwright-0.2.0 → draftwright-0.2.2}/src/draftwright/sheet.py +0 -0
  51. {draftwright-0.2.0 → draftwright-0.2.2}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  52. {draftwright-0.2.0 → draftwright-0.2.2}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  53. {draftwright-0.2.0 → draftwright-0.2.2}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  54. {draftwright-0.2.0 → draftwright-0.2.2}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  55. {draftwright-0.2.0 → draftwright-0.2.2}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  56. {draftwright-0.2.0 → draftwright-0.2.2}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  57. {draftwright-0.2.0 → draftwright-0.2.2}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  58. {draftwright-0.2.0 → draftwright-0.2.2}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  59. {draftwright-0.2.0 → draftwright-0.2.2}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  60. {draftwright-0.2.0 → draftwright-0.2.2}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  61. {draftwright-0.2.0 → draftwright-0.2.2}/tests/test_e2e_slice.py +0 -0
  62. {draftwright-0.2.0 → draftwright-0.2.2}/tests/test_e2e_standards.py +0 -0
  63. {draftwright-0.2.0 → draftwright-0.2.2}/tests/test_layout.py +0 -0
  64. {draftwright-0.2.0 → draftwright-0.2.2}/tests/test_lint_structural.py +0 -0
  65. {draftwright-0.2.0 → draftwright-0.2.2}/tests/test_linting.py +0 -0
  66. {draftwright-0.2.0 → draftwright-0.2.2}/tests/test_part_model.py +0 -0
  67. {draftwright-0.2.0 → draftwright-0.2.2}/tests/test_pmi.py +0 -0
  68. {draftwright-0.2.0 → draftwright-0.2.2}/tests/test_recognition.py +0 -0
  69. {draftwright-0.2.0 → draftwright-0.2.2}/tests/test_registry.py +0 -0
  70. {draftwright-0.2.0 → draftwright-0.2.2}/tests/test_render_seam.py +0 -0
  71. {draftwright-0.2.0 → draftwright-0.2.2}/tests/test_turned_steps.py +0 -0
@@ -2,6 +2,71 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.2.2 — 2026-06-30
6
+
7
+ A patch release of turned-part dimension-placement fixes and a CLI start-up
8
+ speed-up. Drawing output changes for the affected turned/cross-drilled parts.
9
+
10
+ ### Fixed
11
+
12
+ - **A coaxial bore callout on a *stepped* turned shaft is now lifted off the round
13
+ view's centre axis** (#305). The earlier fix only triggered for a uniform
14
+ (`is_rotational`) cylinder; a stepped shaft (e.g. the gramel GRM-03 drive screw)
15
+ has a turned step profile but isn't classified rotational, so its `⌀… ↓…` bore
16
+ callout was still leadered straight along the centreline, with the centre mark
17
+ running through the text. The lift now also fires for a turned-profile part.
18
+ - **A side-drilled hole's location dimension now stacks *inside* the overall
19
+ envelope dimension** (ISO order — overall dim outermost, feature/location dims
20
+ nearer the view). It was placed *outside* the envelope, which forced the shorter
21
+ location dim's arrowheads to flip outward and clash (seen on GRM-01 and GRM-02).
22
+ The mandatory overall dimension is still guaranteed placement.
23
+
24
+ ### Changed
25
+
26
+ - **CLI shell completion and `--help` are fast again** (#313). The Typer CLI and
27
+ the heavy CAD engine are now imported lazily, so tab-completion and `--help` no
28
+ longer pay a ~6 s engine-import cost; a real drawing run is unaffected.
29
+
30
+ ## v0.2.1 — 2026-06-30
31
+
32
+ A patch release focused on **turned-part dimensioning legibility**: crowded
33
+ step-length chains and fine turned heads are now drawn legibly instead of crammed.
34
+ Drawing output changes for affected turned parts.
35
+
36
+ ### Added
37
+
38
+ - **Automatic enlarged detail view for a crowded turned head** (#304). A turned
39
+ part with a fine cluster of steps near one end and a long shaft (e.g. a thumbwheel
40
+ drive screw) cannot have its head dimensioned legibly in line at any sensible
41
+ scale. The head is now located as one block on the main view and broken out into
42
+ an enlarged **DETAIL A — SCALE n:1** — the textbook treatment — firing
43
+ automatically when a head's shoulders fall below the page legibility floor.
44
+
45
+ ### Changed
46
+
47
+ - **Crowded turned step-length chains stagger across two tiers** (#293) instead of
48
+ cramming or being skipped. When the labels would collide on one line, the ISO
49
+ 129-1 staggered convention alternates them between a near and a far tier so every
50
+ step length stays legible at the drawing's own scale — no rescale needed. A roomy
51
+ chain stays on a single tier.
52
+ - **Detail views are now one unified pipeline** (#307). The prismatic step-height
53
+ detail and the new turned-head detail flow through a single
54
+ detect → request → render path; several crowded regions become DETAIL A/B/…
55
+ - **Output changes** for turned parts whose step chains were previously crammed, or
56
+ whose fine heads are now broken out into a detail view.
57
+
58
+ ### Fixed
59
+
60
+ - **A coaxial bore callout no longer overlaps the round view's centreline** (#305):
61
+ its leader is angled off the centre axis so the callout text sits in clear space.
62
+
63
+ ### Internal
64
+
65
+ - A new **layout-cleanliness invariant test** asserts that finished drawings have no
66
+ view/annotation collisions across representative part archetypes, and the
67
+ measure-and-repack pass gained a trigger for an annotation growing into a
68
+ neighbouring view's line-work (so the views spread to make room).
69
+
5
70
  ## v0.2.0 — 2026-06-30
6
71
 
7
72
  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.2
4
4
  Summary: Automated technical-drawing generation for build123d
5
5
  Project-URL: Homepage, https://github.com/pzfreo/draftwright
6
6
  Project-URL: Repository, https://github.com/pzfreo/draftwright
@@ -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.2"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -0,0 +1,103 @@
1
+ """draftwright — automated technical-drawing generation for build123d.
2
+
3
+ Takes a build123d solid and produces a fully-annotated multi-view technical
4
+ drawing (orthographic views, dimensions, section A–A, title block) ready for
5
+ DXF/SVG export::
6
+
7
+ from draftwright import make_drawing
8
+ make_drawing(my_part, out="drawing")
9
+
10
+ Requires build123d-drafting-helpers for annotation primitives.
11
+ Licensed under the GNU Affero General Public License v3 (AGPL-3.0).
12
+ """
13
+
14
+ import importlib as _importlib
15
+ import sys as _sys
16
+ import types as _types
17
+ from typing import TYPE_CHECKING
18
+
19
+ # Public API resolved lazily (PEP 562): `import draftwright` — and, crucially,
20
+ # `import draftwright.cli` (which runs this __init__ first) — must NOT eagerly
21
+ # pull in the engine. The engine drags build123d/OCP, ~5 s of CAD-kernel import.
22
+ # The CLI's shell completion, --help and --version import this package but touch
23
+ # none of these names, so they stay sub-second instead of paying for the kernel
24
+ # on every TAB press (#313). Each name maps to the submodule that provides it.
25
+ _LAZY = {
26
+ "analyse_face_levels": "draftwright.analysis",
27
+ "dedup_diams": "draftwright.analysis",
28
+ "build_drawing": "draftwright.builder",
29
+ "generate_script": "draftwright.builder",
30
+ "make_drawing": "draftwright.builder",
31
+ "Drawing": "draftwright.drawing",
32
+ "FeatureInfo": "draftwright.drawing",
33
+ "fix_svg_page_size": "draftwright.export",
34
+ "lint_feature_coverage": "draftwright.linting",
35
+ "PmiRecord": "draftwright.pmi",
36
+ "extract_pmi": "draftwright.pmi",
37
+ "choose_scale": "draftwright.sheet",
38
+ }
39
+
40
+
41
+ def _resolve(name):
42
+ """Import the providing submodule and cache the public object on the package."""
43
+ value = getattr(_importlib.import_module(_LAZY[name]), name)
44
+ _types.ModuleType.__setattr__(_sys.modules[__name__], name, value)
45
+ return value
46
+
47
+
48
+ class _DraftwrightModule(_types.ModuleType):
49
+ def __getattr__(self, name):
50
+ # PEP 562 miss handler: resolve a public name on first access.
51
+ if name in _LAZY:
52
+ return _resolve(name)
53
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
54
+
55
+ def __getattribute__(self, name):
56
+ # `draftwright.make_drawing` must be the FUNCTION even after the compat
57
+ # SUBMODULE of the same name is imported and shadows it as a package
58
+ # attribute (importing draftwright.make_drawing binds the module here).
59
+ if name == "make_drawing":
60
+ namespace = _types.ModuleType.__getattribute__(self, "__dict__")
61
+ value = namespace.get("make_drawing")
62
+ if (
63
+ isinstance(value, _types.ModuleType)
64
+ and value.__name__ == "draftwright.make_drawing"
65
+ ):
66
+ return _resolve("make_drawing")
67
+ return _types.ModuleType.__getattribute__(self, name)
68
+
69
+
70
+ _sys.modules[__name__].__class__ = _DraftwrightModule
71
+
72
+
73
+ if TYPE_CHECKING: # static analysers / IDEs — no runtime import, no kernel cost
74
+ from draftwright.analysis import analyse_face_levels, dedup_diams
75
+ from draftwright.builder import build_drawing, generate_script, make_drawing
76
+ from draftwright.drawing import Drawing, FeatureInfo
77
+ from draftwright.export import fix_svg_page_size
78
+ from draftwright.linting import lint_feature_coverage
79
+ from draftwright.pmi import PmiRecord, extract_pmi
80
+ from draftwright.sheet import choose_scale
81
+
82
+
83
+ def __dir__():
84
+ # Surface the lazy public names (not in __dict__ until first accessed)
85
+ # *alongside* the normal module contents — dunders, imported submodules — so
86
+ # introspection / REPL completion sees the full surface, not a subset.
87
+ return sorted(set(globals()) | set(__all__))
88
+
89
+
90
+ __all__ = [
91
+ "Drawing",
92
+ "FeatureInfo",
93
+ "PmiRecord",
94
+ "analyse_face_levels",
95
+ "build_drawing",
96
+ "choose_scale",
97
+ "dedup_diams",
98
+ "extract_pmi",
99
+ "fix_svg_page_size",
100
+ "generate_script",
101
+ "lint_feature_coverage",
102
+ "make_drawing",
103
+ ]
@@ -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,
@@ -530,6 +532,20 @@ def _env_pd(group, role):
530
532
  return next((pd for pd in group.dims if pd.param.role == role), None)
531
533
 
532
534
 
535
+ def envelope_group(groups):
536
+ """The envelope `DimensionGroup` in *groups*, or None."""
537
+ return next((g for g in groups if g.feature_kind == "envelope"), None)
538
+
539
+
540
+ def env_dim_placed(pd) -> bool:
541
+ """Whether :func:`render_envelope` will actually place an envelope dim for the
542
+ PlannedDimension *pd* — present, not suppressed by the planner (square footprint /
543
+ X-turned, #250), and carrying a span. The single source of truth for that
544
+ decision, shared with the orchestrator's side-below tier reservation so the two
545
+ can never drift (#316 review)."""
546
+ return pd is not None and not pd.suppressed and pd.param.span is not None
547
+
548
+
533
549
  def render_envelope(dwg, groups, a) -> int:
534
550
  """Overall width (plan, below) + depth (side, below) envelope dims via the IR,
535
551
  placed through the engine's below-strip zone allocators (the zone-aware render
@@ -537,12 +553,12 @@ def render_envelope(dwg, groups, a) -> int:
537
553
  those strips). The **planner** decides suppression (square footprint / X-turned;
538
554
  #250); this renderer just skips suppressed dims and places the rest. Returns the
539
555
  count placed."""
540
- env = next((g for g in groups if g.feature_kind == "envelope"), None)
556
+ env = envelope_group(groups)
541
557
  if env is None:
542
558
  return 0
543
559
  n = 0
544
560
  width = _env_pd(env, "width")
545
- if width is not None and not width.suppressed and width.param.span is not None:
561
+ if env_dim_placed(width):
546
562
  (x0, y0, z0), (x1, _, _) = width.param.span
547
563
  p1, p2 = dwg.at("plan", x0, y0, z0), dwg.at("plan", x1, y0, z0)
548
564
  witness = p1[1] - 2
@@ -562,7 +578,7 @@ def render_envelope(dwg, groups, a) -> int:
562
578
  )
563
579
  n += 1
564
580
  depth = _env_pd(env, "depth")
565
- if depth is not None and not depth.suppressed and depth.param.span is not None:
581
+ if env_dim_placed(depth):
566
582
  (x0, y0, z0), (_, y1, _) = depth.param.span
567
583
  p1, p2 = dwg.at("side", x0, y0, z0), dwg.at("side", x0, y1, z0)
568
584
  witness = p1[1] - 2
@@ -584,52 +600,30 @@ def render_envelope(dwg, groups, a) -> int:
584
600
  return n
585
601
 
586
602
 
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))
603
+ def _draw_step_chain(dwg, view, segs, name_prefix, detail_scale=None, allow_collapse=True) -> int:
604
+ """Place a turned step-length chain in *view* from *segs* each ``(pa, pb,
605
+ value)`` already projected to *view*'s page coords, in axis order. Orientation is
606
+ data (the projected span direction): horizontal chain above the view, vertical
607
+ chain to the right. A uniform run collapses to one ``N× v`` dim (#230); else a
608
+ per-segment chain, staggered into a near/far tier only when crowded (ISO 129-1,
609
+ #293); skipped if even two tiers can't separate the labels, or if any dim would
610
+ fall off the page. ``detail_scale`` tags the dims for label-vs-measured lint when
611
+ drawing inside a scaled detail view. ``allow_collapse=False`` disables the ``N× v``
612
+ collapse used when the chain mixes a synthetic head-*block* with real steps, where
613
+ a uniform-staircase representative would be a false claim of N equal steps (#307
614
+ review). Returns the count placed."""
611
615
  if not segs:
612
616
  return 0
613
- vb = dwg.view_bounds("front")
617
+ vb = dwg.view_bounds(view)
614
618
  if vb is None:
615
619
  return 0
616
620
  x0, y0, x1, y1 = vb
617
621
  draft = dwg.draft
618
622
  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
623
  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
624
  vals = [v for *_, v in segs]
631
625
  mean_v = sum(vals) / len(vals)
632
- if len(segs) >= 3 and (max(vals) - min(vals)) <= 0.10 * mean_v:
626
+ if allow_collapse and len(segs) >= 3 and (max(vals) - min(vals)) <= 0.10 * mean_v:
633
627
  label = f"{len(segs)}× {_fmt(mean_v)}"
634
628
  xs = [p[0] for pa, pb, _ in segs for p in (pa, pb)]
635
629
  ys = [p[1] for pa, pb, _ in segs for p in (pa, pb)]
@@ -637,48 +631,47 @@ def render_step_lengths(dwg, groups) -> int:
637
631
  dim = _dim((min(xs), y1, 0), (max(xs), y1, 0), "above", gap, draft, label=label)
638
632
  else:
639
633
  dim = _dim((x1, min(ys), 0), (x1, max(ys), 0), "right", gap, draft, label=label)
640
- candidates = [("m_steplen_typ", dim)]
634
+ candidates = [(f"{name_prefix}_typ", dim)]
641
635
  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
636
+ tier_step = draft.font_size + 2 * draft.pad_around_text
637
+ tiers = [0] * len(segs)
648
638
  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))
639
+ cw = [
640
+ ((pa[0] + pb[0]) / 2, len(_fmt(v)) * draft.font_size * 0.62) for pa, pb, v in segs
641
+ ]
642
+
643
+ def _clear(items): # (center, width) pairs in x order — labels don't overlap
644
+ return all(
645
+ c2 - c1 >= (w1 + w2) / 2 + draft.pad_around_text
646
+ for (c1, w1), (c2, w2) in zip(items, items[1:])
647
+ )
648
+
649
+ if _clear(cw):
650
+ pass # one tier suffices — no needless zig-zag for a roomy chain
651
+ elif _clear(cw[0::2]) and _clear(cw[1::2]):
652
+ tiers = [i % 2 for i in range(len(segs))] # alternate to make room
653
+ else:
654
+ _log.info("step-length chain skipped: too dense even when staggered")
658
655
  return 0
659
- if solved:
660
- offsets = [s - c for s, c in zip(solved, centers)]
661
656
  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
657
  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:])):
658
+ if any(b - a < tier_step for a, b in zip(shoulder_ys, shoulder_ys[1:])):
666
659
  _log.info("step-length chain skipped: shoulders too close to dimension")
667
660
  return 0
668
661
 
669
662
  candidates = []
670
663
  for i, (pa, pb, value) in enumerate(segs):
671
- if horizontal: # X-turned: chain above the view, witnesses rise from the top
664
+ if horizontal:
672
665
  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
666
+ dist = gap + tiers[i] * tier_step
667
+ else:
675
668
  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)))
669
+ dist = gap
670
+ candidates.append(
671
+ (f"{name_prefix}{i}", _dim(p1, p2, side, dist, draft, label=_fmt(value)))
672
+ )
678
673
 
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.
674
+ # Room guard: if any dim would fall off the drawable page, place NONE.
682
675
  page = (_MARGIN, _MARGIN, dwg.page_w - _MARGIN, dwg.page_h - _MARGIN)
683
676
  for _, dim in candidates:
684
677
  box = _anno_box(dim)
@@ -687,10 +680,93 @@ def render_step_lengths(dwg, groups) -> int:
687
680
  ):
688
681
  return 0
689
682
  for name, dim in candidates:
690
- dwg.add(dim, name, view="front")
683
+ if detail_scale is not None:
684
+ dim._dw_scale = detail_scale
685
+ dwg.add(dim, name, view=view)
691
686
  return len(candidates)
692
687
 
693
688
 
689
+ def render_step_lengths(dwg, groups) -> int:
690
+ """Unified turned step-length chain (ADR 0008 #223): each `StepFeature`'s length
691
+ span projects into the front view and joins the chain that tiles the turning axis
692
+ so every shoulder is located. X-turned → horizontal chain above the view;
693
+ Z-turned → vertical chain to the right.
694
+
695
+ A crowded **X-turned head** — a contiguous run of steps too short to dimension
696
+ legibly even staggered (shoulders below the page arrowhead floor) — is not crammed
697
+ in line: the main view locates that run as one *block* dim and an enlarged
698
+ `DetailRequest` (#304/#307) is queued to break it down. If the detail later can't
699
+ place, the block still locates the head extent and lint reports the un-located
700
+ interior shoulders — never worse than the prior skip. Returns the count placed on
701
+ the front view."""
702
+ rows = [] # (a_world, b_world, value) in axis order
703
+ for g in groups:
704
+ if g.feature_kind != "step":
705
+ continue
706
+ length = next(
707
+ (pd.param for pd in g.dims if pd.param.kind == "length" and pd.param.span is not None),
708
+ None,
709
+ )
710
+ if length is None or length.span is None:
711
+ continue
712
+ rows.append((length.span[0], length.span[1], length.value))
713
+ if not rows:
714
+ return 0
715
+ draft = dwg.draft
716
+ fsegs = [(dwg.at("front", *a), dwg.at("front", *b), v) for a, b, v in rows]
717
+ horizontal = abs(fsegs[0][1][0] - fsegs[0][0][0]) >= abs(fsegs[0][1][1] - fsegs[0][0][1])
718
+
719
+ # X-turned crowded-head detour (#307): split off each contiguous *run of ≥2*
720
+ # sub-floor steps (segment narrower than two arrowheads on the page), locate it as
721
+ # a block, and queue an enlarged detail. A single isolated thin step is left in the
722
+ # main chain — a one-step block would just be that step at its sub-floor width
723
+ # (#307 review). The legible steps + blocks stay as the main chain.
724
+ if horizontal:
725
+ floor_pg = 2 * draft.arrow_length
726
+ sub = [i for i, (pa, pb, _) in enumerate(fsegs) if abs(pb[0] - pa[0]) < floor_pg]
727
+ runs: list[list[int]] = []
728
+ for j in sub:
729
+ (runs[-1].append(j) if runs and j == runs[-1][-1] + 1 else runs.append([j]))
730
+ heads = [run for run in runs if len(run) >= 2]
731
+ if heads:
732
+ blocks = []
733
+ for run in heads:
734
+ ra = [rows[i] for i in run]
735
+ hlo = min(min(a[0], b[0]) for a, b, _ in ra)
736
+ hhi = max(max(a[0], b[0]) for a, b, _ in ra)
737
+ minlen = min(v for *_, v in ra)
738
+ # World→page scale for the detail (no sheet factor — detail_scale is an
739
+ # absolute world→page scale). (#307 review)
740
+ scale_needed = _MIN_STEP_SEP_MM / minlen if minlen > 0 else float("inf")
741
+ blocks.append((dwg.at("front", hlo, 0, 0), dwg.at("front", hhi, 0, 0), hhi - hlo))
742
+
743
+ def _redraw(dwg, view, detail_scale, _hw=ra):
744
+ # View-scoped name prefix so two detail views never collide (#307 review).
745
+ hsegs = [(dwg.at(view, *a), dwg.at(view, *b), v) for a, b, v in _hw]
746
+ return _draw_step_chain(dwg, view, hsegs, f"dim_{view}_steplen", detail_scale)
747
+
748
+ dwg._detail_requests.append(
749
+ DetailRequest(
750
+ axis="x",
751
+ lo=hlo,
752
+ hi=hhi,
753
+ scale_needed=scale_needed,
754
+ redraw=_redraw,
755
+ pad_top=2 * (draft.font_size + 2 * draft.pad_around_text)
756
+ + draft.arrow_length,
757
+ kind="turned-head",
758
+ )
759
+ )
760
+ head = {i for run in heads for i in run}
761
+ main = [fsegs[i] for i in range(len(fsegs)) if i not in head] + blocks
762
+ main.sort(key=lambda s: s[0][0])
763
+ # The chain now mixes head-block(s) with real steps — never collapse it to a
764
+ # uniform "N× v" representative (a block is not a repeated step, #307 review).
765
+ return _draw_step_chain(dwg, "front", main, "m_steplen", allow_collapse=False)
766
+
767
+ return _draw_step_chain(dwg, "front", fsegs, "m_steplen")
768
+
769
+
694
770
  def _detect_step_repeat(step_zs, bb_min_z, bb_max_z, tol_frac=0.10):
695
771
  """Return (n, rise) if *step_zs* form a uniform staircase, else None.
696
772