draftwright 0.2.10__tar.gz → 0.2.12__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 (101) hide show
  1. {draftwright-0.2.10 → draftwright-0.2.12}/CHANGELOG.md +64 -0
  2. {draftwright-0.2.10 → draftwright-0.2.12}/PKG-INFO +5 -5
  3. {draftwright-0.2.10 → draftwright-0.2.12}/README.md +4 -4
  4. {draftwright-0.2.10 → draftwright-0.2.12}/pyproject.toml +1 -1
  5. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/_core.py +11 -5
  6. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/analysis.py +159 -14
  7. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/annotations/_common.py +77 -11
  8. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/annotations/from_model.py +406 -247
  9. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/annotations/holes.py +232 -83
  10. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/annotations/orchestrator.py +30 -49
  11. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/annotations/sections.py +11 -13
  12. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/builder.py +59 -35
  13. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/drawing.py +439 -113
  14. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/linting/suggest.py +13 -9
  15. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/model/__init__.py +4 -0
  16. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/model/detect.py +28 -9
  17. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/model/ir.py +53 -9
  18. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/repair.py +8 -25
  19. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/sheet.py +239 -82
  20. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/sheet_dsl.py +73 -1
  21. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/sheet_emit.py +90 -13
  22. {draftwright-0.2.10 → draftwright-0.2.12}/tests/layout_snapshots/bracket.json +12 -12
  23. {draftwright-0.2.10 → draftwright-0.2.12}/tests/layout_snapshots/dshape.json +3 -3
  24. {draftwright-0.2.10 → draftwright-0.2.12}/tests/layout_snapshots/side_drilled.json +3 -3
  25. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_make_drawing.py +562 -60
  26. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_pmi.py +37 -13
  27. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_render_seam.py +34 -11
  28. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_sheet_emit.py +160 -15
  29. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_strip_layout.py +119 -0
  30. {draftwright-0.2.10 → draftwright-0.2.12}/.gitignore +0 -0
  31. {draftwright-0.2.10 → draftwright-0.2.12}/LICENSE +0 -0
  32. {draftwright-0.2.10 → draftwright-0.2.12}/skills/SKILL.md +0 -0
  33. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/__init__.py +0 -0
  34. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/annotate.py +0 -0
  35. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/annotations/__init__.py +0 -0
  36. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/cli.py +0 -0
  37. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/export.py +0 -0
  38. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/features.py +0 -0
  39. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/fits.py +0 -0
  40. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  41. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  42. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  43. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  44. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/fonts/__init__.py +0 -0
  45. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/intents.py +0 -0
  46. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/layout.py +0 -0
  47. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/linting/__init__.py +0 -0
  48. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/linting/coverage.py +0 -0
  49. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/linting/issues.py +0 -0
  50. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/linting/structural.py +0 -0
  51. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/make_drawing.py +0 -0
  52. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/model/declare.py +0 -0
  53. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/model/planner.py +0 -0
  54. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/pmi.py +0 -0
  55. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/projection.py +0 -0
  56. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/recognition/__init__.py +0 -0
  57. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/recognition/_features.py +0 -0
  58. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/recognition/levels.py +0 -0
  59. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/recognition/slots.py +0 -0
  60. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/recognition/turned.py +0 -0
  61. {draftwright-0.2.10 → draftwright-0.2.12}/src/draftwright/registry.py +0 -0
  62. {draftwright-0.2.10 → draftwright-0.2.12}/tests/_kernel.py +0 -0
  63. {draftwright-0.2.10 → draftwright-0.2.12}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  64. {draftwright-0.2.10 → draftwright-0.2.12}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  65. {draftwright-0.2.10 → draftwright-0.2.12}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  66. {draftwright-0.2.10 → draftwright-0.2.12}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  67. {draftwright-0.2.10 → draftwright-0.2.12}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  68. {draftwright-0.2.10 → draftwright-0.2.12}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  69. {draftwright-0.2.10 → draftwright-0.2.12}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  70. {draftwright-0.2.10 → draftwright-0.2.12}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  71. {draftwright-0.2.10 → draftwright-0.2.12}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  72. {draftwright-0.2.10 → draftwright-0.2.12}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  73. {draftwright-0.2.10 → draftwright-0.2.12}/tests/layout_snapshots/box.json +0 -0
  74. {draftwright-0.2.10 → draftwright-0.2.12}/tests/layout_snapshots/drive_screw_x.json +0 -0
  75. {draftwright-0.2.10 → draftwright-0.2.12}/tests/layout_snapshots/flange.json +0 -0
  76. {draftwright-0.2.10 → draftwright-0.2.12}/tests/layout_snapshots/holed_slot.json +0 -0
  77. {draftwright-0.2.10 → draftwright-0.2.12}/tests/layout_snapshots/plate_holes.json +0 -0
  78. {draftwright-0.2.10 → draftwright-0.2.12}/tests/layout_snapshots/slotted.json +0 -0
  79. {draftwright-0.2.10 → draftwright-0.2.12}/tests/layout_snapshots/turned_shaft.json +0 -0
  80. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_declare.py +0 -0
  81. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_e2e_slice.py +0 -0
  82. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_e2e_standards.py +0 -0
  83. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_fits.py +0 -0
  84. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_gdt_placement.py +0 -0
  85. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_layout.py +0 -0
  86. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_layout_cleanliness.py +0 -0
  87. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_layout_property.py +0 -0
  88. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_layout_snapshot.py +0 -0
  89. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_lint_reconciliation.py +0 -0
  90. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_lint_structural.py +0 -0
  91. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_linting.py +0 -0
  92. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_object_aspects.py +0 -0
  93. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_part_model.py +0 -0
  94. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_recognition.py +0 -0
  95. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_registry.py +0 -0
  96. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_sheet_gdt.py +0 -0
  97. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_sheet_notes.py +0 -0
  98. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_sheet_of.py +0 -0
  99. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_sheet_tables.py +0 -0
  100. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_tolerances.py +0 -0
  101. {draftwright-0.2.10 → draftwright-0.2.12}/tests/test_turned_steps.py +0 -0
@@ -1,5 +1,69 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.12 — 2026-07-09
4
+
5
+ **Sheet scripts now round-trip authored layout intent.** This patch finishes the next
6
+ slice of the layout-authority work: below/right ladders, pinned edits, height ladders,
7
+ detected envelopes, and AP242 authored dimensions now survive the declarative Sheet path
8
+ without being invalidated by post-hoc placement or raw imported metadata.
9
+
10
+ ### Added
11
+
12
+ - **Pinned edit intents route through the corridor solve.** `locate(..., pin=True)` and
13
+ pinned dimensions now become priority-ranked candidates in the shared corridor instead
14
+ of fixed post-hoc edits, so user intent participates in the same ordering/spacing model
15
+ as automatic dimensions. (#511)
16
+ - **AP242 dimensional PMI lowers to authored drafting dimensions.** Imported AP242
17
+ size/location dimensions now become `AuthoredDimension` IR and generated Sheet scripts
18
+ emit `sheet.dimension(...)`; unsupported PMI records remain explicit raw fallbacks.
19
+ (#503, #422, #62)
20
+
21
+ ### Changed
22
+
23
+ - **`place_dim` is deprecated.** Manual dimensions should use pinned candidate/dimension
24
+ intent instead of the old incremental edit path.
25
+ - **Prismatic height ladders and detected envelopes round-trip through Sheet scripts.**
26
+ Generated scripts now preserve `StepLevelFeature` and detected `EnvelopeFeature`
27
+ fallbacks, preventing CTC01-style ladder swaps and raw STEP-envelope remeasurement
28
+ drift.
29
+
30
+ ### Fixed
31
+
32
+ - **Below/right corridors now share one solve.** Side-hole locations, height ladders,
33
+ PMI/GD&T, envelope dims, and related below/right strip occupants negotiate in the shared
34
+ corridor rather than competing through separate late passes. (#477)
35
+ - **Sheet script parity is tighter.** Generated scripts preserve member positions, step
36
+ levels, AP242 authored dimensions, raw PMI fallbacks, and detected envelopes so
37
+ direct-vs-script CTC01 output has matching annotation names.
38
+
39
+ ## v0.2.11 — 2026-07-08
40
+
41
+ **Automatic layout now has one authority.** Page/scale selection, section placement,
42
+ furniture footprints, and table/balloon escalation now negotiate through the same layout
43
+ fitness model instead of using separate fixed offsets or first-fit policies after the main
44
+ solve.
45
+
46
+ ### Fixed
47
+
48
+ - **Solver placement paths are more consistent.** STEP PMI dimensions now queue into
49
+ the shared ADR 0009 corridor solve instead of carving after the drain; front-view
50
+ hole callouts use the strip solver instead of fixed row stepping; pitch-dimension
51
+ fallback searches bounded, obstacle-aware positions; repair no longer hides
52
+ `annotation_overlap` with a fixed-step nudge; and step-count sizing now handles
53
+ non-convergence conservatively. (#524)
54
+ - **Page/scale fitness is shared across initial selection and repack.** Later layout
55
+ passes now compare candidates with the same model as the page chooser, preventing a
56
+ nominally "better" repack from invalidating the original layout decision. (#519)
57
+ - **Section A-A participates in layout selection.** Section placement is measured as
58
+ part of page/scale fitness, so a section view is no longer a fixed-offset afterthought
59
+ that can disappear on dense sheets. (#515)
60
+ - **Furniture placement reserves full rendered footprints.** Section arrows, detail
61
+ views, balloons, hole/data tables, and other furniture now reserve/check their true
62
+ rendered footprint instead of just their labels. (#518)
63
+ - **Hole/data tables and balloon rings escalate through the layout model.** Dense table
64
+ and balloon outputs can move or negotiate for available room rather than dropping from
65
+ greedy first-fit placement when a small adjustment would fit. (#516, #517)
66
+
3
67
  ## v0.2.10 — 2026-07-07
4
68
 
5
69
  **Declarative-surface fidelity and layout-engine unification.** Editing and re-running a
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.2.10
3
+ Version: 0.2.12
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
@@ -842,10 +842,9 @@ automatically, so you say *what* to dimension, not *where*:
842
842
  ```python
843
843
  dwg = build_drawing(part)
844
844
 
845
- # Inspect detected features and add a dimension in domain terms (auto-placed):
846
- for f in dwg.features("plan"):
847
- dwg.place_dim(f.page_pos, (f.page_pos[0] + f.diameter, f.page_pos[1]),
848
- "below", "plan", dwg.draft, name="dim_pocket")
845
+ # Inspect detected features and add a pinned dimension in domain terms:
846
+ env = next(f for f in dwg.model().features if f.kind == "envelope")
847
+ dwg.dimension(env, "length", role="width", side="below", pin=True)
849
848
 
850
849
  crit = dwg.lint_summary() # {"passed", "score", "by_code", "issues":[…suggestion]}
851
850
  dwg.repair() # auto-fix mechanically-fixable lint; never worsens
@@ -876,6 +875,7 @@ the section A–A trigger, the prismatic step-ladder + rotational furniture, and
876
875
  are all on the IR — the migration is complete (one path; the orchestrator is
877
876
  build → plan → render). See
878
877
  [`docs/target-architecture.md`](docs/target-architecture.md) and
878
+ [`docs/layout-algorithm-primer.md`](docs/layout-algorithm-primer.md) for a short walkthrough, plus
879
879
  [`docs/adr/`](docs/adr/). The engine handles view layout (strip/zone model), scale
880
880
  selection, annotation placement, and section rendering.
881
881
 
@@ -149,10 +149,9 @@ automatically, so you say *what* to dimension, not *where*:
149
149
  ```python
150
150
  dwg = build_drawing(part)
151
151
 
152
- # Inspect detected features and add a dimension in domain terms (auto-placed):
153
- for f in dwg.features("plan"):
154
- dwg.place_dim(f.page_pos, (f.page_pos[0] + f.diameter, f.page_pos[1]),
155
- "below", "plan", dwg.draft, name="dim_pocket")
152
+ # Inspect detected features and add a pinned dimension in domain terms:
153
+ env = next(f for f in dwg.model().features if f.kind == "envelope")
154
+ dwg.dimension(env, "length", role="width", side="below", pin=True)
156
155
 
157
156
  crit = dwg.lint_summary() # {"passed", "score", "by_code", "issues":[…suggestion]}
158
157
  dwg.repair() # auto-fix mechanically-fixable lint; never worsens
@@ -183,6 +182,7 @@ the section A–A trigger, the prismatic step-ladder + rotational furniture, and
183
182
  are all on the IR — the migration is complete (one path; the orchestrator is
184
183
  build → plan → render). See
185
184
  [`docs/target-architecture.md`](docs/target-architecture.md) and
185
+ [`docs/layout-algorithm-primer.md`](docs/layout-algorithm-primer.md) for a short walkthrough, plus
186
186
  [`docs/adr/`](docs/adr/). The engine handles view layout (strip/zone model), scale
187
187
  selection, annotation placement, and section rendering.
188
188
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.2.10"
7
+ version = "0.2.12"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -21,6 +21,7 @@ from typing import TYPE_CHECKING
21
21
 
22
22
  if TYPE_CHECKING:
23
23
  from draftwright.recognition import TurnedProfile
24
+ from draftwright.sheet import StripDepths
24
25
 
25
26
  from build123d import Align, BoundBox, Location, Mode, Shape, Text
26
27
  from build123d_drafting.helpers import (
@@ -338,7 +339,7 @@ def _legible_locations(positions, scale):
338
339
  return kept, n_too_close
339
340
 
340
341
 
341
- def _largest_empty_rect(drawable, obstacles):
342
+ def _largest_empty_rect(drawable, obstacles, *, warn: bool = True):
342
343
  """Largest axis-aligned empty rectangle in *drawable* avoiding *obstacles*.
343
344
 
344
345
  *drawable* and each obstacle are ``(x0, y0, x1, y1)`` page-mm boxes. Returns
@@ -376,10 +377,11 @@ def _largest_empty_rect(drawable, obstacles):
376
377
  # is unreachable in practice — choose_scale always leaves a gap — but
377
378
  # if it ever happens the iso would render over the other views, so flag
378
379
  # it rather than fail silently.
379
- _log.warning(
380
- "No empty rectangle found for the iso view; obstacles fill the "
381
- "drawable area iso may overlap other views"
382
- )
380
+ if warn:
381
+ _log.warning(
382
+ "No empty rectangle found for the iso view; obstacles fill the "
383
+ "drawable area — iso may overlap other views"
384
+ )
383
385
  return drawable
384
386
  return best
385
387
 
@@ -514,6 +516,10 @@ class Analysis:
514
516
  is_rotational: bool
515
517
  od_axis: str # rotation/turning axis of a rotational part ("z" default; "x"/"y" #222)
516
518
  step_zs: list[float]
519
+ layout_strips: StripDepths
520
+ layout_n_steps: int
521
+ layout_section: bool
522
+ layout_table_sizes: tuple[tuple[float, float], ...]
517
523
  sv_right: float
518
524
  iso_right_limit: float
519
525
  SCALE: float
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
  import logging
14
14
  import math
15
15
  import warnings
16
+ from collections.abc import Callable
16
17
 
17
18
  from build123d import Compound, Shape
18
19
  from build123d_drafting.helpers import draft_preset
@@ -20,6 +21,7 @@ from OCP.IFSelect import IFSelect_ReturnStatus
20
21
  from OCP.STEPControl import STEPControl_Reader
21
22
 
22
23
  from draftwright._core import (
24
+ _CONCENTRIC_TOL_MM,
23
25
  _DIM_PAD,
24
26
  _FONT_SIZE,
25
27
  _MARGIN,
@@ -40,9 +42,12 @@ from draftwright.recognition import (
40
42
  full_cylinders,
41
43
  )
42
44
  from draftwright.sheet import (
45
+ StripDepths,
43
46
  _build_zones,
47
+ _est_hole_table_sizes,
44
48
  _layout_geometry,
45
49
  _measure_strips,
50
+ _will_section,
46
51
  choose_scale,
47
52
  )
48
53
 
@@ -54,6 +59,41 @@ _log = logging.getLogger(__name__)
54
59
  _SQUARENESS_TOL = 0.05
55
60
  _OD_FILL_MIN = 0.8
56
61
  _OD_AXIS_TOL = 0.05
62
+ _ScalePick = tuple[float, float, float, float]
63
+
64
+
65
+ def _declared_will_section(model, *, is_rotational=False, cx=0.0, cy=0.0) -> bool:
66
+ """True when a caller-supplied IR model contains a section-driving Z hole.
67
+
68
+ Detection-based layout uses recogniser holes; declared-model builds may
69
+ intentionally supply features detection missed. Inspect the public IR shape
70
+ duck-typed here so declared sections get the same page/scale reservation.
71
+ """
72
+
73
+ if model is None:
74
+ return False
75
+ features = getattr(model, "features", model)
76
+
77
+ def feature_member(pt) -> bool:
78
+ return not (is_rotational and math.hypot(pt[0] - cx, pt[1] - cy) <= _CONCENTRIC_TOL_MM)
79
+
80
+ for feat in features:
81
+ if getattr(feat, "kind", None) not in ("hole", "pattern"):
82
+ continue
83
+ frame = getattr(feat, "frame", None)
84
+ if frame is None or frame.axis != "z":
85
+ continue
86
+ members = getattr(feat, "members", ()) or (frame.origin,)
87
+ if not any(feature_member(m) for m in members):
88
+ continue
89
+ bore = getattr(feat, "member", feat)
90
+ if (
91
+ getattr(bore, "cbore", None) is not None
92
+ or getattr(bore, "spotface", None) is not None
93
+ or not getattr(bore, "through", True)
94
+ ):
95
+ return True
96
+ return False
57
97
 
58
98
 
59
99
  def _import_step(path) -> Compound:
@@ -106,6 +146,53 @@ def _is_rotational(x_size, y_size, od_diam, od_axis_offset) -> bool:
106
146
  )
107
147
 
108
148
 
149
+ def _converge_step_sizing(
150
+ initial_steps: int,
151
+ measure_strips: Callable[[int], StripDepths],
152
+ pick_scale: Callable[[int, StripDepths], _ScalePick],
153
+ count_legible: Callable[[float], int],
154
+ ) -> tuple[_ScalePick, StripDepths, int]:
155
+ """Choose scale/page with a step-corridor count that matches legibility.
156
+
157
+ The right-side step ladder is reserved before the scale is known, but the
158
+ actual step list is filtered by the chosen scale. Iterate that dependency
159
+ until it reaches a fixed point; if it cycles, reserve the largest count seen
160
+ so the sheet is sized conservatively instead of silently accepting whichever
161
+ value happened to appear on a fixed iteration budget (#520).
162
+ """
163
+ n_for_sizing = initial_steps
164
+ seen: set[int] = set()
165
+ attempted: list[int] = []
166
+ max_iter = max(4, initial_steps + 2)
167
+
168
+ for _ in range(max_iter):
169
+ if n_for_sizing in seen:
170
+ break
171
+ seen.add(n_for_sizing)
172
+ attempted.append(n_for_sizing)
173
+
174
+ strips = measure_strips(n_for_sizing)
175
+ scale_pick = pick_scale(n_for_sizing, strips)
176
+ n_next = count_legible(scale_pick[0])
177
+ if n_next == n_for_sizing:
178
+ return scale_pick, strips, n_for_sizing
179
+ if n_next in seen:
180
+ n_for_sizing = n_next
181
+ break
182
+ n_for_sizing = n_next
183
+
184
+ conservative_n = max(attempted + [n_for_sizing], default=initial_steps)
185
+ strips = measure_strips(conservative_n)
186
+ scale_pick = pick_scale(conservative_n, strips)
187
+ _log.warning(
188
+ "Step-corridor sizing did not converge from %d steps (tried %s); reserving %d steps",
189
+ initial_steps,
190
+ attempted,
191
+ conservative_n,
192
+ )
193
+ return scale_pick, strips, conservative_n
194
+
195
+
109
196
  # A hole is "concentric" with a turned part's rotation axis when its drilling
110
197
  # axis is the Z (OD) axis and its opening sits on the part centreline. Such
111
198
  # bores are already dimensioned by the ldr_z bore leaders, so they must not
@@ -202,7 +289,16 @@ def _solids_body(part, src: str = "part"):
202
289
 
203
290
 
204
291
  def _analyse(
205
- step_file, title, number, tolerance, drawn_by, out, scale=None, page=None, pmi="off"
292
+ step_file,
293
+ title,
294
+ number,
295
+ tolerance,
296
+ drawn_by,
297
+ out,
298
+ scale=None,
299
+ page=None,
300
+ pmi="off",
301
+ model=None,
206
302
  ) -> Analysis:
207
303
  """Load STEP or use a build123d Shape, analyse geometry, compute layout.
208
304
 
@@ -317,6 +413,20 @@ def _analyse(
317
413
  patterns = find_hole_patterns(holes)
318
414
  bosses = find_bosses(part, cyls=(z_cyls, cross_cyls)) # detect once — the one inventory (#264)
319
415
  slots = find_slots(part)
416
+ layout_section = _will_section(
417
+ holes,
418
+ patterns,
419
+ is_rotational=is_rotational,
420
+ cx=cx,
421
+ cy=cy,
422
+ ) or _declared_will_section(model, is_rotational=is_rotational, cx=cx, cy=cy)
423
+ layout_table_sizes = _est_hole_table_sizes(
424
+ holes,
425
+ patterns,
426
+ bb,
427
+ font_size=_FONT_SIZE,
428
+ pad_around_text=_pad_around_text,
429
+ )
320
430
 
321
431
  # Choose scale/page, iterating so the reserved step corridor matches the
322
432
  # number of steps the legibility gate will actually place (#1) — not the raw
@@ -324,24 +434,35 @@ def _analyse(
324
434
  # with 15 tiny treads) reserves a phantom step ladder that blocks a larger
325
435
  # scale. Seed conservatively (all faces), then re-gate at the chosen scale;
326
436
  # converges in a couple of rounds.
327
- n_for_sizing = len(step_zs)
328
- strips_i = None
329
- for _ in range(3):
330
- strips_i = _measure_strips(
437
+ def _measure_for_step_count(n_steps_i: int) -> StripDepths:
438
+ return _measure_strips(
331
439
  holes,
332
440
  patterns,
333
- n_for_sizing,
441
+ n_steps_i,
334
442
  bb,
335
443
  arrow_length=_arrow_length,
336
444
  pad_around_text=_pad_around_text,
337
445
  )
338
- SCALE, PAGE_W, PAGE_H, TB_W = choose_scale(
339
- x_size, y_size, z_size, n_steps=n_for_sizing, scale=scale, page=page, strips=strips_i
446
+
447
+ def _pick_for_step_count(n_steps_i: int, strips_i: StripDepths) -> _ScalePick:
448
+ return choose_scale(
449
+ x_size,
450
+ y_size,
451
+ z_size,
452
+ n_steps=n_steps_i,
453
+ scale=scale,
454
+ page=page,
455
+ strips=strips_i,
456
+ section=layout_section,
457
+ table_sizes=layout_table_sizes,
340
458
  )
341
- n_next = len(_legible_steps(step_zs, bb.min.Z, SCALE)[0])
342
- if n_next == n_for_sizing:
343
- break
344
- n_for_sizing = n_next
459
+
460
+ (SCALE, PAGE_W, PAGE_H, TB_W), strips_i, n_for_sizing = _converge_step_sizing(
461
+ len(step_zs),
462
+ _measure_for_step_count,
463
+ _pick_for_step_count,
464
+ lambda scale_i: len(_legible_steps(step_zs, bb.min.Z, scale_i)[0]),
465
+ )
345
466
  if scale is not None:
346
467
  # An explicit scale is the user's call — honour it (#489). Two floors apply:
347
468
  # - _MIN_RENDER_MM: a hard geometry limit; below it OCCT's annotation arcs degenerate
@@ -362,7 +483,15 @@ def _analyse(
362
483
  f"Use scale ≥ {safe:.3g} or omit the scale for automatic selection."
363
484
  )
364
485
  auto_scale, _, _, _ = choose_scale(
365
- x_size, y_size, z_size, n_steps=n_for_sizing, scale=None, page=page, strips=strips_i
486
+ x_size,
487
+ y_size,
488
+ z_size,
489
+ n_steps=n_for_sizing,
490
+ scale=None,
491
+ page=page,
492
+ strips=strips_i,
493
+ section=layout_section,
494
+ table_sizes=layout_table_sizes,
366
495
  )
367
496
  # Warn only when omitting the scale would truly give a legible fit (auto scale itself is
368
497
  # legible) but the requested scale is below the floor. A part illegible at every
@@ -393,7 +522,19 @@ def _analyse(
393
522
  # View positions + iso empty-rectangle, shared with scale selection (_fits)
394
523
  # via _layout_geometry so placement and fit never diverge (#11). _fit_iso_view
395
524
  # later scales the iso to fill its rectangle.
396
- _g = _layout_geometry(x_size, y_size, z_size, SCALE, PAGE_W, PAGE_H, TB_W, strips, n_steps)
525
+ _g = _layout_geometry(
526
+ x_size,
527
+ y_size,
528
+ z_size,
529
+ SCALE,
530
+ PAGE_W,
531
+ PAGE_H,
532
+ TB_W,
533
+ strips,
534
+ n_steps,
535
+ section=layout_section,
536
+ table_sizes=layout_table_sizes,
537
+ )
397
538
  fv_hw = _g.fv_hw
398
539
  fv_hh = _g.fv_hh
399
540
  pv_hh = _g.pv_hh
@@ -461,6 +602,10 @@ def _analyse(
461
602
  is_rotational=is_rotational,
462
603
  od_axis=od_axis,
463
604
  step_zs=step_zs,
605
+ layout_strips=strips,
606
+ layout_n_steps=n_steps,
607
+ layout_section=layout_section,
608
+ layout_table_sizes=layout_table_sizes,
464
609
  sv_right=sv_right,
465
610
  iso_right_limit=iso_right_limit,
466
611
  SCALE=SCALE,
@@ -293,6 +293,10 @@ class CorridorCandidate:
293
293
  ``priority`` is kept. An authored GD&T frame sets this above the auto dims so it
294
294
  is not dropped in favour of a lower-value auto dim purely by stacking-key order.
295
295
  Default 0 (every auto dim) → key order, unchanged.
296
+ anchored/natural: when ``anchored`` is true, the strip solve keeps this candidate
297
+ near its own natural stacking-axis page coordinate instead of the segment edge.
298
+ This is how user-authored pinned dimension intents join the shared solve
299
+ without being invalidated by a later first-fit pass.
296
300
  on_place/on_drop: the pass's own post-placement bookkeeping — coverage
297
301
  registration / drop lint + `Escalation`, or a slot's below-side fallthrough.
298
302
  force: policy-B force-keep after the corridor-respecting pass (locations have
@@ -307,6 +311,8 @@ class CorridorCandidate:
307
311
  dedup: tuple | None = None
308
312
  precedence: int = 0
309
313
  priority: float = 0
314
+ anchored: bool = False
315
+ natural: float | None = None
310
316
  force: bool = False
311
317
  # The source IR feature this dim was rendered for — recorded as provenance when the
312
318
  # dim is placed at drain (ADR 0010). ``None`` leaves the annotation feature-less.
@@ -376,6 +382,8 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
376
382
  sizes = {c.name: c.size for c in kept if c.size is not None} # real footprint (#61)
377
383
  forbid = {c.name: c.forbid for c in kept if c.forbid is not None} # title-block box (#481)
378
384
  prio = {c.name: c.priority for c in kept if c.priority} # over-capacity survival rank (#357)
385
+ anchored = {c.name: c.anchored for c in kept if c.anchored}
386
+ naturals = {c.name: c.natural for c in kept if c.natural is not None}
379
387
  left = {
380
388
  n
381
389
  for n, _ in place_strip_candidates(
@@ -389,6 +397,8 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
389
397
  sizes=sizes,
390
398
  forbid=forbid,
391
399
  priorities=prio,
400
+ anchored=anchored,
401
+ naturals=naturals,
392
402
  )
393
403
  }
394
404
  force_pairs = [(c.name, c.build) for c in kept if c.name in left and c.force]
@@ -407,6 +417,8 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
407
417
  sizes=sizes,
408
418
  forbid=forbid,
409
419
  priorities=prio,
420
+ anchored=anchored,
421
+ naturals=naturals,
410
422
  )
411
423
  }
412
424
  if force_pairs
@@ -425,10 +437,13 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
425
437
  def register_corridor(dwg, key, strip, view, axis, tier, cand):
426
438
  """Queue a :class:`CorridorCandidate` under a shared corridor *key* so one
427
439
  :func:`drain_corridors` places the whole cross-pass set together (ADR 0009 end state).
428
- The first registration for a key fixes its ``(strip, view, axis, tier)``."""
440
+ The first registration for a key fixes its ``(strip, view, axis)``; mixed producers on
441
+ the same corridor use the largest requested tier so spacing is not registration-order
442
+ dependent."""
429
443
  b = dwg._corridor_batch.setdefault(
430
444
  key, {"strip": strip, "view": view, "axis": axis, "tier": tier, "cands": []}
431
445
  )
446
+ b["tier"] = max(b["tier"], tier)
432
447
  b["cands"].append(cand)
433
448
 
434
449
 
@@ -453,6 +468,8 @@ def place_strip_candidates(
453
468
  sizes=None,
454
469
  forbid=None,
455
470
  priorities=None,
471
+ anchored=None,
472
+ naturals=None,
456
473
  ):
457
474
  """Collect-then-solve placement of location/feature dims on one strip (ADR 0009).
458
475
  The single shared strip placer that retires the ``Strip.allocate`` cursor (#150,
@@ -483,6 +500,11 @@ def place_strip_candidates(
483
500
  the lowest ``(priority, key)``, so a higher priority is kept — an authored GD&T frame
484
501
  is not dropped for a lower-value auto dim purely by stacking-key order.
485
502
 
503
+ *anchored* and *naturals* opt individual candidates into the weighted anchoring
504
+ mode in :func:`plan_strip`. This preserves the old segment-edge natural for every
505
+ caller that does not pass them, while letting authored pinned candidates express the
506
+ page coordinate they asked for inside the same shared solve.
507
+
486
508
  ``force=True`` skips that corridor check — the caller's last resort when no view took
487
509
  the dim cleanly: keep it on its natural view and accept the (same-feature) leader
488
510
  crossing rather than drop a real dimension (policy B). Candidates that find no strip
@@ -537,13 +559,31 @@ def place_strip_candidates(
537
559
  # Fill innermost-first (nearest the view), matching the old cursor's stack order.
538
560
  segs.sort(key=lambda s: abs((s[0] if inner == lo else s[1]) - inner))
539
561
  todo = list(cands)
540
- for seg_lo, seg_hi in segs:
541
- if not todo:
542
- break
543
- cap = int((seg_hi - seg_lo) / pad) + 1
544
- take, todo = todo[:cap], todo[cap:]
562
+
563
+ def _take_for_segment(items, n):
564
+ if len(items) <= n:
565
+ return items, []
566
+ # Do not let segment-cap slicing preempt the ranked selection step (#357/#393).
567
+ # `plan_strip` drops the lowest (priority, generated-key), but a narrow segment
568
+ # can only see the candidates we hand it. Preselect the highest-priority members
569
+ # for this segment, preserving their original order for crossing-free placement;
570
+ # ties mirror the generated key below (inner=lo keeps later candidates, inner=hi
571
+ # keeps earlier candidates).
572
+ ranked = sorted(
573
+ enumerate(items),
574
+ key=lambda item: (
575
+ (priorities or {}).get(item[1][0], 0.0),
576
+ item[0] if inner == lo else -item[0],
577
+ ),
578
+ reverse=True,
579
+ )
580
+ chosen = {i for i, _ in ranked[:n]}
581
+ take = [nb for i, nb in enumerate(items) if i in chosen]
582
+ rest = [nb for i, nb in enumerate(items) if i not in chosen]
583
+ return take, rest
584
+
585
+ def _evaluate_segment(take, seg_lo, seg_hi):
545
586
  nat = seg_lo if inner == lo else seg_hi
546
- anch = (0.0, nat) if axis == "y" else (nat, 0.0)
547
587
  # Keys order the tiers so the FIRST candidate lands on the inner tier: for an
548
588
  # inner=lo strip that is the lowest position (ascending keys); for a below strip
549
589
  # (inner=hi) it is the highest, so the keys reverse.
@@ -551,23 +591,30 @@ def place_strip_candidates(
551
591
  (
552
592
  StripCandidate(
553
593
  f"{(k if inner == lo else len(take) - 1 - k):04d}",
554
- anch,
594
+ (
595
+ (0.0, (naturals or {}).get(nb[0], nat))
596
+ if axis == "y"
597
+ else ((naturals or {}).get(nb[0], nat), 0.0)
598
+ ),
555
599
  (sizes or {}).get(nb[0], (tier, tier)),
556
600
  priority=(priorities or {}).get(nb[0], 0.0),
601
+ anchored=(anchored or {}).get(nb[0], False),
557
602
  ),
558
603
  nb,
559
604
  )
560
605
  for k, nb in enumerate(take)
561
606
  ]
562
607
  res = plan_strip([sc for sc, _ in triples], seg_lo, seg_hi, pad, axis=axis)
608
+ accepted = []
609
+ rejected = []
563
610
  for sc, (name, build) in triples:
564
611
  pos = res.placed.get(sc.key)
565
612
  if pos is None: # segment over its estimated capacity (shouldn't occur)
566
- todo.append((name, build))
613
+ rejected.append((name, build))
567
614
  continue
568
615
  dim = build(pos)
569
616
  if not force and _box_hits(_geom_box(dim), blockers): # corridor crosses a leader
570
- todo.append((name, build))
617
+ rejected.append((name, build))
571
618
  continue
572
619
  # A forbidden box (the title block, #481) is rejected even under force — it is
573
620
  # placed after the drain, so the strip carve can't see it; a force-kept GD&T frame
@@ -575,8 +622,27 @@ def place_strip_candidates(
575
622
  # so dims are byte-identical). Returned unplaced → the caller's on_drop fallthrough.
576
623
  fb = (forbid or {}).get(name)
577
624
  if fb is not None and _box_hits(_geom_box(dim), (fb,)):
578
- todo.append((name, build))
625
+ rejected.append((name, build))
579
626
  continue
627
+ accepted.append(((name, build), dim))
628
+ return accepted, rejected
629
+
630
+ for seg_lo, seg_hi in segs:
631
+ if not todo:
632
+ break
633
+ cap = int((seg_hi - seg_lo) / pad) + 1
634
+ take, todo = _take_for_segment(todo, cap)
635
+ rejected_total = []
636
+ while take:
637
+ accepted, rejected = _evaluate_segment(take, seg_lo, seg_hi)
638
+ rejected_total.extend(rejected)
639
+ vacancies = cap - len(accepted)
640
+ if vacancies <= 0 or not todo:
641
+ break
642
+ fill, todo = _take_for_segment(todo, vacancies)
643
+ take = [nb for nb, _dim in accepted] + fill
644
+ todo = todo + rejected_total
645
+ for (name, _build), dim in accepted:
580
646
  # Record feature provenance (ADR 0010): the drain-time seam for corridor-placed
581
647
  # dims — `features` maps this batch's names to their source IR feature.
582
648
  dwg.add(dim, name, view=view, feature=(features or {}).get(name))