draftwright 0.2.10__tar.gz → 0.2.11__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.11}/CHANGELOG.md +28 -0
  2. {draftwright-0.2.10 → draftwright-0.2.11}/PKG-INFO +1 -1
  3. {draftwright-0.2.10 → draftwright-0.2.11}/pyproject.toml +1 -1
  4. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/_core.py +11 -5
  5. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/analysis.py +159 -14
  6. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/annotations/_common.py +48 -8
  7. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/annotations/from_model.py +268 -165
  8. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/annotations/holes.py +126 -54
  9. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/annotations/orchestrator.py +17 -19
  10. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/annotations/sections.py +11 -13
  11. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/builder.py +33 -18
  12. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/drawing.py +151 -57
  13. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/repair.py +8 -25
  14. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/sheet.py +239 -82
  15. {draftwright-0.2.10 → draftwright-0.2.11}/tests/layout_snapshots/bracket.json +12 -12
  16. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_make_drawing.py +348 -47
  17. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_pmi.py +7 -4
  18. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_strip_layout.py +53 -0
  19. {draftwright-0.2.10 → draftwright-0.2.11}/.gitignore +0 -0
  20. {draftwright-0.2.10 → draftwright-0.2.11}/LICENSE +0 -0
  21. {draftwright-0.2.10 → draftwright-0.2.11}/README.md +0 -0
  22. {draftwright-0.2.10 → draftwright-0.2.11}/skills/SKILL.md +0 -0
  23. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/__init__.py +0 -0
  24. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/annotate.py +0 -0
  25. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/annotations/__init__.py +0 -0
  26. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/cli.py +0 -0
  27. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/export.py +0 -0
  28. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/features.py +0 -0
  29. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/fits.py +0 -0
  30. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  31. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  32. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  33. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  34. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/fonts/__init__.py +0 -0
  35. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/intents.py +0 -0
  36. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/layout.py +0 -0
  37. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/linting/__init__.py +0 -0
  38. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/linting/coverage.py +0 -0
  39. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/linting/issues.py +0 -0
  40. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/linting/structural.py +0 -0
  41. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/linting/suggest.py +0 -0
  42. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/make_drawing.py +0 -0
  43. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/model/__init__.py +0 -0
  44. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/model/declare.py +0 -0
  45. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/model/detect.py +0 -0
  46. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/model/ir.py +0 -0
  47. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/model/planner.py +0 -0
  48. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/pmi.py +0 -0
  49. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/projection.py +0 -0
  50. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/recognition/__init__.py +0 -0
  51. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/recognition/_features.py +0 -0
  52. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/recognition/levels.py +0 -0
  53. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/recognition/slots.py +0 -0
  54. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/recognition/turned.py +0 -0
  55. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/registry.py +0 -0
  56. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/sheet_dsl.py +0 -0
  57. {draftwright-0.2.10 → draftwright-0.2.11}/src/draftwright/sheet_emit.py +0 -0
  58. {draftwright-0.2.10 → draftwright-0.2.11}/tests/_kernel.py +0 -0
  59. {draftwright-0.2.10 → draftwright-0.2.11}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  60. {draftwright-0.2.10 → draftwright-0.2.11}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  61. {draftwright-0.2.10 → draftwright-0.2.11}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  62. {draftwright-0.2.10 → draftwright-0.2.11}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  63. {draftwright-0.2.10 → draftwright-0.2.11}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  64. {draftwright-0.2.10 → draftwright-0.2.11}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  65. {draftwright-0.2.10 → draftwright-0.2.11}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  66. {draftwright-0.2.10 → draftwright-0.2.11}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  67. {draftwright-0.2.10 → draftwright-0.2.11}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  68. {draftwright-0.2.10 → draftwright-0.2.11}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  69. {draftwright-0.2.10 → draftwright-0.2.11}/tests/layout_snapshots/box.json +0 -0
  70. {draftwright-0.2.10 → draftwright-0.2.11}/tests/layout_snapshots/drive_screw_x.json +0 -0
  71. {draftwright-0.2.10 → draftwright-0.2.11}/tests/layout_snapshots/dshape.json +0 -0
  72. {draftwright-0.2.10 → draftwright-0.2.11}/tests/layout_snapshots/flange.json +0 -0
  73. {draftwright-0.2.10 → draftwright-0.2.11}/tests/layout_snapshots/holed_slot.json +0 -0
  74. {draftwright-0.2.10 → draftwright-0.2.11}/tests/layout_snapshots/plate_holes.json +0 -0
  75. {draftwright-0.2.10 → draftwright-0.2.11}/tests/layout_snapshots/side_drilled.json +0 -0
  76. {draftwright-0.2.10 → draftwright-0.2.11}/tests/layout_snapshots/slotted.json +0 -0
  77. {draftwright-0.2.10 → draftwright-0.2.11}/tests/layout_snapshots/turned_shaft.json +0 -0
  78. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_declare.py +0 -0
  79. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_e2e_slice.py +0 -0
  80. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_e2e_standards.py +0 -0
  81. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_fits.py +0 -0
  82. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_gdt_placement.py +0 -0
  83. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_layout.py +0 -0
  84. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_layout_cleanliness.py +0 -0
  85. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_layout_property.py +0 -0
  86. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_layout_snapshot.py +0 -0
  87. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_lint_reconciliation.py +0 -0
  88. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_lint_structural.py +0 -0
  89. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_linting.py +0 -0
  90. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_object_aspects.py +0 -0
  91. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_part_model.py +0 -0
  92. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_recognition.py +0 -0
  93. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_registry.py +0 -0
  94. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_render_seam.py +0 -0
  95. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_sheet_emit.py +0 -0
  96. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_sheet_gdt.py +0 -0
  97. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_sheet_notes.py +0 -0
  98. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_sheet_of.py +0 -0
  99. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_sheet_tables.py +0 -0
  100. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_tolerances.py +0 -0
  101. {draftwright-0.2.10 → draftwright-0.2.11}/tests/test_turned_steps.py +0 -0
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.11 — 2026-07-08
4
+
5
+ **Automatic layout now has one authority.** Page/scale selection, section placement,
6
+ furniture footprints, and table/balloon escalation now negotiate through the same layout
7
+ fitness model instead of using separate fixed offsets or first-fit policies after the main
8
+ solve.
9
+
10
+ ### Fixed
11
+
12
+ - **Solver placement paths are more consistent.** STEP PMI dimensions now queue into
13
+ the shared ADR 0009 corridor solve instead of carving after the drain; front-view
14
+ hole callouts use the strip solver instead of fixed row stepping; pitch-dimension
15
+ fallback searches bounded, obstacle-aware positions; repair no longer hides
16
+ `annotation_overlap` with a fixed-step nudge; and step-count sizing now handles
17
+ non-convergence conservatively. (#524)
18
+ - **Page/scale fitness is shared across initial selection and repack.** Later layout
19
+ passes now compare candidates with the same model as the page chooser, preventing a
20
+ nominally "better" repack from invalidating the original layout decision. (#519)
21
+ - **Section A-A participates in layout selection.** Section placement is measured as
22
+ part of page/scale fitness, so a section view is no longer a fixed-offset afterthought
23
+ that can disappear on dense sheets. (#515)
24
+ - **Furniture placement reserves full rendered footprints.** Section arrows, detail
25
+ views, balloons, hole/data tables, and other furniture now reserve/check their true
26
+ rendered footprint instead of just their labels. (#518)
27
+ - **Hole/data tables and balloon rings escalate through the layout model.** Dense table
28
+ and balloon outputs can move or negotiate for available room rather than dropping from
29
+ greedy first-fit placement when a small adjustment would fit. (#516, #517)
30
+
3
31
  ## v0.2.10 — 2026-07-07
4
32
 
5
33
  **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.11
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.10"
7
+ version = "0.2.11"
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,
@@ -537,11 +537,30 @@ def place_strip_candidates(
537
537
  # Fill innermost-first (nearest the view), matching the old cursor's stack order.
538
538
  segs.sort(key=lambda s: abs((s[0] if inner == lo else s[1]) - inner))
539
539
  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:]
540
+
541
+ def _take_for_segment(items, n):
542
+ if len(items) <= n:
543
+ return items, []
544
+ # Do not let segment-cap slicing preempt the ranked selection step (#357/#393).
545
+ # `plan_strip` drops the lowest (priority, generated-key), but a narrow segment
546
+ # can only see the candidates we hand it. Preselect the highest-priority members
547
+ # for this segment, preserving their original order for crossing-free placement;
548
+ # ties mirror the generated key below (inner=lo keeps later candidates, inner=hi
549
+ # keeps earlier candidates).
550
+ ranked = sorted(
551
+ enumerate(items),
552
+ key=lambda item: (
553
+ (priorities or {}).get(item[1][0], 0.0),
554
+ item[0] if inner == lo else -item[0],
555
+ ),
556
+ reverse=True,
557
+ )
558
+ chosen = {i for i, _ in ranked[:n]}
559
+ take = [nb for i, nb in enumerate(items) if i in chosen]
560
+ rest = [nb for i, nb in enumerate(items) if i not in chosen]
561
+ return take, rest
562
+
563
+ def _evaluate_segment(take, seg_lo, seg_hi):
545
564
  nat = seg_lo if inner == lo else seg_hi
546
565
  anch = (0.0, nat) if axis == "y" else (nat, 0.0)
547
566
  # Keys order the tiers so the FIRST candidate lands on the inner tier: for an
@@ -560,14 +579,16 @@ def place_strip_candidates(
560
579
  for k, nb in enumerate(take)
561
580
  ]
562
581
  res = plan_strip([sc for sc, _ in triples], seg_lo, seg_hi, pad, axis=axis)
582
+ accepted = []
583
+ rejected = []
563
584
  for sc, (name, build) in triples:
564
585
  pos = res.placed.get(sc.key)
565
586
  if pos is None: # segment over its estimated capacity (shouldn't occur)
566
- todo.append((name, build))
587
+ rejected.append((name, build))
567
588
  continue
568
589
  dim = build(pos)
569
590
  if not force and _box_hits(_geom_box(dim), blockers): # corridor crosses a leader
570
- todo.append((name, build))
591
+ rejected.append((name, build))
571
592
  continue
572
593
  # A forbidden box (the title block, #481) is rejected even under force — it is
573
594
  # placed after the drain, so the strip carve can't see it; a force-kept GD&T frame
@@ -575,8 +596,27 @@ def place_strip_candidates(
575
596
  # so dims are byte-identical). Returned unplaced → the caller's on_drop fallthrough.
576
597
  fb = (forbid or {}).get(name)
577
598
  if fb is not None and _box_hits(_geom_box(dim), (fb,)):
578
- todo.append((name, build))
599
+ rejected.append((name, build))
579
600
  continue
601
+ accepted.append(((name, build), dim))
602
+ return accepted, rejected
603
+
604
+ for seg_lo, seg_hi in segs:
605
+ if not todo:
606
+ break
607
+ cap = int((seg_hi - seg_lo) / pad) + 1
608
+ take, todo = _take_for_segment(todo, cap)
609
+ rejected_total = []
610
+ while take:
611
+ accepted, rejected = _evaluate_segment(take, seg_lo, seg_hi)
612
+ rejected_total.extend(rejected)
613
+ vacancies = cap - len(accepted)
614
+ if vacancies <= 0 or not todo:
615
+ break
616
+ fill, todo = _take_for_segment(todo, vacancies)
617
+ take = [nb for nb, _dim in accepted] + fill
618
+ todo = todo + rejected_total
619
+ for (name, _build), dim in accepted:
580
620
  # Record feature provenance (ADR 0010): the drain-time seam for corridor-placed
581
621
  # dims — `features` maps this batch's names to their source IR feature.
582
622
  dwg.add(dim, name, view=view, feature=(features or {}).get(name))