draftwright 0.2.4__tar.gz → 0.2.5__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 (85) hide show
  1. {draftwright-0.2.4 → draftwright-0.2.5}/CHANGELOG.md +9 -0
  2. {draftwright-0.2.4 → draftwright-0.2.5}/PKG-INFO +1 -1
  3. {draftwright-0.2.4 → draftwright-0.2.5}/pyproject.toml +1 -1
  4. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/_core.py +16 -5
  5. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/annotate.py +1 -0
  6. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/annotations/_common.py +12 -4
  7. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/annotations/from_model.py +152 -50
  8. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/annotations/holes.py +502 -29
  9. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/annotations/orchestrator.py +41 -43
  10. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/annotations/sections.py +58 -0
  11. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/builder.py +154 -9
  12. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/cli.py +2 -5
  13. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/drawing.py +571 -9
  14. draftwright-0.2.5/src/draftwright/intents.py +39 -0
  15. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/model/planner.py +15 -9
  16. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/registry.py +36 -2
  17. {draftwright-0.2.4 → draftwright-0.2.5}/tests/layout_snapshots/box.json +47 -47
  18. {draftwright-0.2.4 → draftwright-0.2.5}/tests/layout_snapshots/bracket.json +86 -86
  19. {draftwright-0.2.4 → draftwright-0.2.5}/tests/layout_snapshots/drive_screw_x.json +36 -36
  20. {draftwright-0.2.4 → draftwright-0.2.5}/tests/layout_snapshots/dshape.json +54 -54
  21. {draftwright-0.2.4 → draftwright-0.2.5}/tests/layout_snapshots/flange.json +66 -66
  22. {draftwright-0.2.4 → draftwright-0.2.5}/tests/layout_snapshots/holed_slot.json +71 -71
  23. {draftwright-0.2.4 → draftwright-0.2.5}/tests/layout_snapshots/plate_holes.json +61 -61
  24. {draftwright-0.2.4 → draftwright-0.2.5}/tests/layout_snapshots/side_drilled.json +50 -50
  25. {draftwright-0.2.4 → draftwright-0.2.5}/tests/layout_snapshots/slotted.json +72 -72
  26. {draftwright-0.2.4 → draftwright-0.2.5}/tests/layout_snapshots/turned_shaft.json +79 -79
  27. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_e2e_standards.py +62 -0
  28. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_make_drawing.py +1350 -16
  29. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_render_seam.py +6 -2
  30. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_strip_layout.py +7 -8
  31. {draftwright-0.2.4 → draftwright-0.2.5}/.gitignore +0 -0
  32. {draftwright-0.2.4 → draftwright-0.2.5}/LICENSE +0 -0
  33. {draftwright-0.2.4 → draftwright-0.2.5}/README.md +0 -0
  34. {draftwright-0.2.4 → draftwright-0.2.5}/skills/SKILL.md +0 -0
  35. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/__init__.py +0 -0
  36. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/analysis.py +0 -0
  37. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/annotations/__init__.py +0 -0
  38. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/export.py +0 -0
  39. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/features.py +0 -0
  40. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  41. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  42. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  43. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  44. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/fonts/__init__.py +0 -0
  45. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/layout.py +0 -0
  46. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/linting/__init__.py +0 -0
  47. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/linting/coverage.py +0 -0
  48. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/linting/issues.py +0 -0
  49. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/linting/structural.py +0 -0
  50. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/linting/suggest.py +0 -0
  51. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/make_drawing.py +0 -0
  52. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/model/__init__.py +0 -0
  53. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/model/detect.py +0 -0
  54. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/model/ir.py +0 -0
  55. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/pmi.py +0 -0
  56. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/projection.py +0 -0
  57. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/recognition/__init__.py +0 -0
  58. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/recognition/_features.py +0 -0
  59. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/recognition/levels.py +0 -0
  60. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/recognition/slots.py +0 -0
  61. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/recognition/turned.py +0 -0
  62. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/repair.py +0 -0
  63. {draftwright-0.2.4 → draftwright-0.2.5}/src/draftwright/sheet.py +0 -0
  64. {draftwright-0.2.4 → draftwright-0.2.5}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  65. {draftwright-0.2.4 → draftwright-0.2.5}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  66. {draftwright-0.2.4 → draftwright-0.2.5}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  67. {draftwright-0.2.4 → draftwright-0.2.5}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  68. {draftwright-0.2.4 → draftwright-0.2.5}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  69. {draftwright-0.2.4 → draftwright-0.2.5}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  70. {draftwright-0.2.4 → draftwright-0.2.5}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  71. {draftwright-0.2.4 → draftwright-0.2.5}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  72. {draftwright-0.2.4 → draftwright-0.2.5}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  73. {draftwright-0.2.4 → draftwright-0.2.5}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  74. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_e2e_slice.py +0 -0
  75. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_layout.py +0 -0
  76. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_layout_cleanliness.py +0 -0
  77. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_layout_property.py +0 -0
  78. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_layout_snapshot.py +0 -0
  79. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_lint_structural.py +0 -0
  80. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_linting.py +0 -0
  81. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_part_model.py +0 -0
  82. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_pmi.py +0 -0
  83. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_recognition.py +0 -0
  84. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_registry.py +0 -0
  85. {draftwright-0.2.4 → draftwright-0.2.5}/tests/test_turned_steps.py +0 -0
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### Changed
6
+
7
+ - **Dimension-line spacing now follows ISO 129-1 / ASME Y14.5 convention** (#347).
8
+ The first dimension line sits further from the view outline (first-line gap
9
+ 8 → 10 mm) and successive parallel lines stack tighter and uniform (between-line
10
+ clear gap 4 → 2.5 mm). The inter-view corridor widens in step (`_DIM_PAD`
11
+ 18 → 20 mm) so the wider first-line gap does not crowd the between-view
12
+ dimensions. Re-drifts the layout of every drawing.
13
+
5
14
  ## v0.2.4 — 2026-07-03
6
15
 
7
16
  A follow-up patch on the ADR 0009 placement rebuild in 0.2.3: it finishes unifying
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.2.4
3
+ Version: 0.2.5
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.4"
7
+ version = "0.2.5"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -162,6 +162,15 @@ def _dim(p1, p2, side, distance, draft, **kwargs):
162
162
  return d
163
163
 
164
164
 
165
+ # Dimension-line spacing (page-mm, scale-independent), the single source of truth for
166
+ # BOTH the ADR 0009 strip carve (via the `Strip` dataclass defaults below) and the
167
+ # sheet.py halo/depth estimates that must reserve the same space. Per ISO 129-1 / ASME
168
+ # Y14.5, the FIRST dimension line sits furthest from the outline (clears the outline +
169
+ # extension-line origins) and subsequent parallel lines stack tighter and uniform (#347).
170
+ _STRIP_GAP = 10.0 # clearance between the view outline and the first dimension line
171
+ _STRIP_SPACING = 2.5 # clear gap between successive parallel dimension lines (beyond the label)
172
+
173
+
165
174
  @dataclass
166
175
  class Strip:
167
176
  """A one-dimensional annotation band adjacent to an orthographic view.
@@ -184,8 +193,8 @@ class Strip:
184
193
  anchor: float
185
194
  outer_limit: float
186
195
  direction: float = 1.0
187
- gap: float = 8.0
188
- spacing: float = 4.0
196
+ gap: float = _STRIP_GAP
197
+ spacing: float = _STRIP_SPACING
189
198
 
190
199
  @property
191
200
  def available(self) -> float:
@@ -220,7 +229,6 @@ _SLOT_DIM_DEPTH = 2 * _FONT_SIZE + _PAD # sv_zones.below: overall depth dimensi
220
229
  _SLOT_DIM_HEIGHT = 2 * _FONT_SIZE + 2 * _PAD # fv_zones.right: overall height dim
221
230
 
222
231
 
223
- _STRIP_SPACING = 4.0 # page-mm between successive annotations in a strip
224
232
  _MIN_VIEW_MM = (
225
233
  10.0 # min projected view dimension; below it annotation geometry degenerates (#129)
226
234
  )
@@ -572,8 +580,11 @@ def _iso_bbox(dwg):
572
580
  # Relocated from make_drawing for the sheet.py split (#162). Shared by sheet.py
573
581
  # (choose_scale/_layout_geometry) and make_drawing's repack pass, so they live
574
582
  # here in the shared base to keep the DAG acyclic.
575
- _DIM_PAD = 18.0
576
- _STRIP_GAP = 8.0
583
+ # The base inter-view corridor: one first-line gap + one dimension tier. Tracks
584
+ # _STRIP_GAP so widening the first-line gap (#347) keeps the below-plan / between-view
585
+ # corridors from razor-fitting the first dim line (the #130 slack guarantee): 10 + 10.
586
+ _DIM_PAD = _STRIP_GAP + _SLOT_DIM_HEIGHT # 20.0
587
+ # _STRIP_GAP / _STRIP_SPACING are defined above (beside the `Strip` dataclass they seed).
577
588
 
578
589
  _PAGE_SIZES = {
579
590
  "A4": (297.0, 210.0),
@@ -10,4 +10,5 @@ from draftwright.annotations.from_model import _detect_step_repeat # noqa: F401
10
10
  from draftwright.annotations.orchestrator import ( # noqa: F401
11
11
  _auto_annotate,
12
12
  _wrap_rows,
13
+ build_model,
13
14
  )
@@ -302,6 +302,9 @@ class CorridorCandidate:
302
302
  dedup: tuple | None = None
303
303
  precedence: int = 0
304
304
  force: bool = False
305
+ # The source IR feature this dim was rendered for — recorded as provenance when the
306
+ # dim is placed at drain (ADR 0010). ``None`` leaves the annotation feature-less.
307
+ feature: object | None = None
305
308
 
306
309
 
307
310
  def solve_corridor(dwg, strip, view, axis, cands, tier):
@@ -353,13 +356,16 @@ def solve_corridor(dwg, strip, view, axis, cands, tier):
353
356
  _promote_losers(c)
354
357
  return
355
358
  pairs = [(c.name, c.build) for c in kept]
356
- left = {n for n, _ in place_strip_candidates(dwg, strip, view, axis, pairs, tier)}
359
+ feats = {c.name: c.feature for c in kept if c.feature is not None} # provenance (ADR 0010)
360
+ left = {
361
+ n for n, _ in place_strip_candidates(dwg, strip, view, axis, pairs, tier, features=feats)
362
+ }
357
363
  force_pairs = [(c.name, c.build) for c in kept if c.name in left and c.force]
358
364
  still = (
359
365
  {
360
366
  n
361
367
  for n, _ in place_strip_candidates(
362
- dwg, strip, view, axis, force_pairs, tier, force=True
368
+ dwg, strip, view, axis, force_pairs, tier, force=True, features=feats
363
369
  )
364
370
  }
365
371
  if force_pairs
@@ -393,7 +399,7 @@ def drain_corridors(dwg):
393
399
  dwg._corridor_batch = {}
394
400
 
395
401
 
396
- def place_strip_candidates(dwg, strip, view, axis, cands, tier, *, force=False):
402
+ def place_strip_candidates(dwg, strip, view, axis, cands, tier, *, force=False, features=None):
397
403
  """Collect-then-solve placement of location/feature dims on one strip (ADR 0009).
398
404
  The single shared strip placer that retires the ``Strip.allocate`` cursor (#150,
399
405
  P3): each candidate in *cands* — an ``(name, build(pos)->dim)`` pair — is spaced by
@@ -481,7 +487,9 @@ def place_strip_candidates(dwg, strip, view, axis, cands, tier, *, force=False):
481
487
  if not force and _box_hits(_geom_box(dim), blockers): # corridor crosses a leader
482
488
  todo.append((name, build))
483
489
  continue
484
- dwg.add(dim, name, view=view)
490
+ # Record feature provenance (ADR 0010): the drain-time seam for corridor-placed
491
+ # dims — `features` maps this batch's names to their source IR feature.
492
+ dwg.add(dim, name, view=view, feature=(features or {}).get(name))
485
493
  return todo
486
494
 
487
495
 
@@ -151,13 +151,19 @@ def _record_slot_drop(dwg, kind, idx, view, feat):
151
151
  )
152
152
 
153
153
 
154
- def render_slots(dwg, model, a) -> int:
154
+ def render_slots(dwg, model, a, *, only=None) -> int:
155
155
  """Dimension milled slots from the IR — width (the defining size, across
156
156
  ``width_axis``) + length (along ``long_axis``) + a position dim from the part
157
157
  datum, in the view the two axes span. Places through the engine's zone strips
158
158
  (shared infra, ADR 0008 Amend. 4); a dim with no clear room is dropped and
159
159
  recorded at info severity (place-what-fits). Sources `SlotFeature`s from the
160
- model; replaces the engine's `_annotate_slots`. Returns the count placed."""
160
+ model; replaces the engine's `_annotate_slots`. Returns the count placed.
161
+
162
+ ``only`` (a set of `SlotFeature`s, #426 Phase 2b) restricts placement to a recorded
163
+ subset for ``finalize()``; ``only=None`` (the auto-pass) places all slots, byte-
164
+ identically. Skips filtered slots **in place** so ``i`` stays the slot's model index
165
+ (the ``m_slot{i}_*`` names must match the auto-pass — never re-enumerate a compacted
166
+ list)."""
161
167
  slots = [f for f in model.features if f.kind == "slot"]
162
168
  if not slots:
163
169
  return 0
@@ -174,6 +180,8 @@ def render_slots(dwg, model, a) -> int:
174
180
 
175
181
  count = 0
176
182
  for i, s in enumerate(slots):
183
+ if only is not None and s not in only:
184
+ continue # #426 Ph2b: skip in place — i must stay the model index
177
185
  view = views[frozenset((s.width_axis, s.long_axis))]
178
186
  name, zones, h_axis, h_proj, _v_axis, v_proj = view
179
187
 
@@ -244,7 +252,13 @@ def render_slots(dwg, model, a) -> int:
244
252
 
245
253
  def _below_or_drop(nm, _bs=below_strip, _bh=below_hi, _feat=s, _dw=drop_word):
246
254
  if _bs is not None and not place_strip_candidates(
247
- dwg, _bs, vw[0], "y", [_cand_for("below", _bh)], tier
255
+ dwg,
256
+ _bs,
257
+ vw[0],
258
+ "y",
259
+ [_cand_for("below", _bh)],
260
+ tier,
261
+ features={cname: _feat},
248
262
  ):
249
263
  return # placed on the below strip
250
264
  _record_slot_drop(dwg, _dw, idx, vw[0], _feat)
@@ -275,6 +289,7 @@ def render_slots(dwg, model, a) -> int:
275
289
  ),
276
290
  precedence=1 if is_pos else 0,
277
291
  force=False,
292
+ feature=s, # provenance (ADR 0010): this dim belongs to the slot
278
293
  ),
279
294
  )
280
295
  return True # deferred — the callback owns the drop; caller's else must not fire
@@ -286,7 +301,7 @@ def render_slots(dwg, model, a) -> int:
286
301
  continue
287
302
  axis = "y" if side in ("above", "below") else "x"
288
303
  if not place_strip_candidates(
289
- dwg, strip, vw[0], axis, [_cand_for(side, hi)], tier
304
+ dwg, strip, vw[0], axis, [_cand_for(side, hi)], tier, features={cname: s}
290
305
  ):
291
306
  return True
292
307
  return False
@@ -329,7 +344,7 @@ _SIZE_SUBCHAIN = 0
329
344
  _LOC_SUBCHAIN = 1
330
345
 
331
346
 
332
- def _location_candidate(dwg, name, *, view, span_key, distance, build):
347
+ def _location_candidate(dwg, name, *, view, span_key, distance, build, feature=None):
333
348
  """A :class:`CorridorCandidate` for a datum-referenced hole/pattern location dim.
334
349
  Location dims outrank a coincident slot-position line in dedup (#345) and form the
335
350
  outer, datum-distance-ordered run of the ladder (#346). Force-kept (policy B): a plan-X
@@ -353,16 +368,22 @@ def _location_candidate(dwg, name, *, view, span_key, distance, build):
353
368
  dedup=(view, span_key[0], span_key[1]),
354
369
  precedence=2,
355
370
  force=True,
371
+ feature=feature, # provenance (ADR 0010): the located hole/pattern
356
372
  )
357
373
 
358
374
 
359
- def render_locations(dwg, model, a) -> int:
375
+ def render_locations(dwg, model, a, *, only=None) -> int:
360
376
  """Baseline X/Y hole-location dims from the IR (#238). The planner decides the
361
377
  intent (`plan_locations`: which refs, from which datum); this renderer owns the
362
378
  layout (Amendment 4) — X dims tier above the plan view, Y dims above the side
363
379
  view, nearest-datum-first, legibility-gated, allocated from the existing strips;
364
380
  a ref with no room is dropped as `location_ref_dropped`. Replaces the engine's
365
- `_add_location_dims`. Returns the count placed."""
381
+ `_add_location_dims`. Returns the count placed.
382
+
383
+ *only*, when given, restricts placement to refs whose source feature is in the set —
384
+ the #426 finalize() path passes the recorded ``locate`` intents' features so the
385
+ corridor solve runs over the user's edited subset. ``None`` (the auto-pass) places
386
+ every ref, byte-identically."""
366
387
  planned = plan_locations(model)
367
388
  if not planned:
368
389
  return 0
@@ -372,6 +393,8 @@ def render_locations(dwg, model, a) -> int:
372
393
  datum_x, datum_y = datum.at[0], datum.at[1]
373
394
  refs = []
374
395
  for pd in planned:
396
+ if only is not None and pd.feature not in only: # #426: recorded subset only
397
+ continue
375
398
  if pd.param.span is None:
376
399
  continue
377
400
  rx, ry = pd.param.span[1][0], pd.param.span[1][1]
@@ -385,12 +408,27 @@ def render_locations(dwg, model, a) -> int:
385
408
  and math.hypot(rx - a.cx, ry - a.cy) <= _CONCENTRIC_TOL_MM
386
409
  ):
387
410
  continue
388
- refs.append((rx, ry))
411
+ refs.append((rx, ry, pd.feature)) # carry the source feature for provenance (ADR 0010)
389
412
  if not refs:
390
413
  return 0
391
414
  tier = draft.font_size + 2 * draft.pad_around_text
392
415
  n = 0
393
416
 
417
+ # Location-dim names. The auto-pass (only is None) numbers them positionally —
418
+ # m_locx{i}, the historical byte-identical scheme. The finalize() path (only set) may
419
+ # run AFTER live-replayed locate() dims already hold m_loc names, so there it allocates
420
+ # the first FREE index to avoid Drawing.add silently replacing one (#429 review).
421
+ _loc_used = set(dwg._named) if only is not None else None
422
+
423
+ def _loc_name(prefix: str, i: int) -> str:
424
+ if _loc_used is None:
425
+ return f"{prefix}{i}" # auto-pass: unchanged, byte-identical
426
+ j = 0
427
+ while f"{prefix}{j}" in _loc_used:
428
+ j += 1
429
+ _loc_used.add(f"{prefix}{j}")
430
+ return f"{prefix}{j}"
431
+
394
432
  # --- X locations: tier above the plan view ---
395
433
  PX, PY = a.proj.plan_x, a.proj.plan_y
396
434
  x_refs: list = []
@@ -415,10 +453,14 @@ def render_locations(dwg, model, a) -> int:
415
453
  # pass carving around the other and interleaving. No alternate view for a plan-X
416
454
  # location, so a corridor-blocked dim is force-kept (policy B), not relocated; only a
417
455
  # physically full strip drops (→ location_ref_dropped, escalates the hole table).
418
- for i, (rx, ry) in enumerate(sorted(x_refs, key=lambda r: abs(r[0] - datum_x))):
456
+ for i, (rx, ry, feat) in enumerate(sorted(x_refs, key=lambda r: abs(r[0] - datum_x))):
419
457
  if abs(rx - datum_x) * a.SCALE < 1.0:
420
458
  continue # on the datum edge — nothing to dimension
421
459
  n += 1
460
+ # A single X-location dim shared by two *distinct* features at this X belongs to
461
+ # neither exclusively — leave it unowned so drop() cannot over-strip a sibling's
462
+ # dimension and annotations_of never over-claims it (review #406, ADR 0010).
463
+ _xfeat = None if any(abs(o[0] - rx) < 0.5 and o[2] != feat for o in refs) else feat
422
464
  register_corridor(
423
465
  dwg,
424
466
  ("plan", "above"),
@@ -428,7 +470,7 @@ def render_locations(dwg, model, a) -> int:
428
470
  tier,
429
471
  _location_candidate(
430
472
  dwg,
431
- f"m_locx{i}",
473
+ _loc_name("m_locx", i),
432
474
  view="plan",
433
475
  span_key=(round(PX(datum_x), 1), round(PX(rx), 1)),
434
476
  distance=abs(rx - datum_x),
@@ -440,6 +482,7 @@ def render_locations(dwg, model, a) -> int:
440
482
  draft,
441
483
  label=_fmt(_rx - datum_x),
442
484
  ),
485
+ feature=_xfeat,
443
486
  ),
444
487
  )
445
488
 
@@ -466,12 +509,14 @@ def render_locations(dwg, model, a) -> int:
466
509
  # Cap the side-above strip below the iso view so Y-location dims never run under it
467
510
  # (the carve respects outer_limit); the dim_pitch_side dims are obstacles the carve
468
511
  # avoids structurally, retiring the old manual allocate(10.0) reservation + cursor.
469
- if y_refs and any(SX(ry) + 10 > iso_x0 - 4 for _, ry in y_refs):
512
+ if y_refs and any(SX(ry) + 10 > iso_x0 - 4 for _, ry, _ in y_refs):
470
513
  a.sv_zones.above.outer_limit = min(a.sv_zones.above.outer_limit, iso_y0 - 4)
471
- for i, (rx, ry) in enumerate(sorted(y_refs, key=lambda r: abs(r[1] - datum_y))):
514
+ for i, (rx, ry, feat) in enumerate(sorted(y_refs, key=lambda r: abs(r[1] - datum_y))):
472
515
  if abs(ry - datum_y) * a.SCALE < 1.0:
473
516
  continue
474
517
  n += 1
518
+ # Shared-Y location dim → unowned (see the X loop; review #406).
519
+ _yfeat = None if any(abs(o[1] - ry) < 0.5 and o[2] != feat for o in refs) else feat
475
520
  register_corridor(
476
521
  dwg,
477
522
  ("side", "above"),
@@ -481,7 +526,7 @@ def render_locations(dwg, model, a) -> int:
481
526
  tier,
482
527
  _location_candidate(
483
528
  dwg,
484
- f"m_locy{i}",
529
+ _loc_name("m_locy", i),
485
530
  view="side",
486
531
  span_key=(round(SX(datum_y), 1), round(SX(ry), 1)),
487
532
  distance=abs(ry - datum_y),
@@ -493,6 +538,7 @@ def render_locations(dwg, model, a) -> int:
493
538
  draft,
494
539
  label=_fmt(_ry - datum_y),
495
540
  ),
541
+ feature=_yfeat,
496
542
  ),
497
543
  )
498
544
  return n
@@ -513,7 +559,7 @@ def render_centermarks(dwg, groups) -> int:
513
559
  members = feat.members or (g.anchor,)
514
560
  for loc in members:
515
561
  px, py, *_ = dwg.at(view, *loc)
516
- dwg.add(CenterMark((px, py, 0), size, dwg.draft), f"m_cm{n}", view=view)
562
+ dwg.add(CenterMark((px, py, 0), size, dwg.draft), f"m_cm{n}", view=view, feature=feat)
517
563
  n += 1
518
564
  return n
519
565
 
@@ -532,7 +578,7 @@ def _mentioned_diameters(dwg) -> set[float]:
532
578
  return diams
533
579
 
534
580
 
535
- def _diameter_row_below(dwg, items) -> int:
581
+ def _diameter_row_below(dwg, items, start: int = 0) -> int:
536
582
  """ø-callout row BELOW the front view for X-turned step/boss diameters (#77).
537
583
  *items* is ``[(anchor, diameter), ...]``. The row is dropped clear of anything
538
584
  already below the profile; labels spread along page-x by the ADR-0003 strip
@@ -553,30 +599,31 @@ def _diameter_row_below(dwg, items) -> int:
553
599
  label_y = obstacle_bottom - (draft.font_size + 4 * draft.pad_around_text)
554
600
  if label_y < _MARGIN + draft.font_size:
555
601
  return 0
556
- specs = [] # (tip_page, label), tip on the step's bottom silhouette
557
- for anchor, dia in items:
602
+ specs = [] # (tip_page, label, feature), tip on the step's bottom silhouette
603
+ for anchor, dia, feat in items:
558
604
  ax, ay, az = anchor
559
605
  tip = dwg.at("front", ax, ay, az - dia / 2)
560
- specs.append((tip, f"ø{_fmt(dia)}"))
606
+ specs.append((tip, f"ø{_fmt(dia)}", feat))
561
607
  specs.sort(key=lambda s: s[0][0])
562
- half_w = max(len(label) for _, label in specs) * draft.font_size * 0.62 / 2
608
+ half_w = max(len(label) for _, label, _ in specs) * draft.font_size * 0.62 / 2
563
609
  min_gap = 2 * half_w + 2 * draft.pad_around_text
564
- naturals = [tip[0] for tip, _ in specs]
610
+ naturals = [tip[0] for tip, _, _ in specs]
565
611
  xs = _solve_strip_ys(naturals, min_gap, fx0 + half_w, fx1 - half_w) or _greedy_strip_ys(
566
612
  naturals, min_gap, fx0 + half_w, fx1 - half_w
567
613
  )
568
614
  if xs is None:
569
615
  return 0
570
- for i, ((tip, label), lx) in enumerate(zip(specs, xs, strict=True)):
616
+ for i, ((tip, label, feat), lx) in enumerate(zip(specs, xs, strict=True)):
571
617
  dwg.add(
572
618
  Leader(tip=(tip[0], tip[1], 0), elbow=(lx, label_y, 0), label=label, draft=draft),
573
- f"m_dia_x{i}",
619
+ f"m_dia_x{start + i}",
574
620
  view="front",
621
+ feature=feat,
575
622
  )
576
623
  return len(specs)
577
624
 
578
625
 
579
- def _diameter_column_left(dwg, items) -> int:
626
+ def _diameter_column_left(dwg, items, start: int = 0) -> int:
580
627
  """ø-callout column to the LEFT of the front view for Z-turned step/boss
581
628
  diameters (#131) — the page-Y mirror of the row-below. A per-label occupancy
582
629
  gate drops only a label that would overprint a bore leader / existing callout
@@ -585,19 +632,19 @@ def _diameter_column_left(dwg, items) -> int:
585
632
  return 0
586
633
  draft = dwg.draft
587
634
  fx0, fy0, _, fy1 = dwg.view_bounds("front")
588
- label_w = max(len(f"ø{_fmt(dia)}") for _, dia in items) * draft.font_size * 0.62
635
+ label_w = max(len(f"ø{_fmt(dia)}") for _, dia, _ in items) * draft.font_size * 0.62
589
636
  elbow_x = fx0 - (draft.font_size + 2 * draft.pad_around_text)
590
637
  if elbow_x - label_w < _MARGIN:
591
638
  return 0
592
- specs = [] # (tip_page, label), tip on the step's left silhouette
593
- for anchor, dia in items:
639
+ specs = [] # (tip_page, label, feature), tip on the step's left silhouette
640
+ for anchor, dia, feat in items:
594
641
  ax, ay, az = anchor
595
642
  tip = dwg.at("front", ax - dia / 2, ay, az)
596
- specs.append((tip, f"ø{_fmt(dia)}"))
643
+ specs.append((tip, f"ø{_fmt(dia)}", feat))
597
644
  specs.sort(key=lambda s: s[0][1])
598
645
  half_h = draft.font_size / 2 + draft.pad_around_text
599
646
  min_gap = 2 * half_h
600
- naturals = [tip[1] for tip, _ in specs]
647
+ naturals = [tip[1] for tip, _, _ in specs]
601
648
  ys = _solve_strip_ys(naturals, min_gap, fy0 + half_h, fy1 - half_h) or _greedy_strip_ys(
602
649
  naturals, min_gap, fy0 + half_h, fy1 - half_h
603
650
  )
@@ -609,42 +656,70 @@ def _diameter_column_left(dwg, items) -> int:
609
656
  # occupant class, #358). Centre lines stay crossable (a diameter dim may cross one).
610
657
  occupied = strip_obstacles(dwg, view="front", crossable=CROSSABLE_TYPES)
611
658
  placed = 0
612
- for i, ((tip, label), ly) in enumerate(zip(specs, ys, strict=True)):
659
+ for i, ((tip, label, feat), ly) in enumerate(zip(specs, ys, strict=True)):
613
660
  ldr = Leader(tip=(tip[0], tip[1], 0), elbow=(elbow_x, ly, 0), label=label, draft=draft)
614
661
  if _box_hits(_anno_box(ldr), occupied):
615
662
  continue # would overprint a bore leader / existing callout — drop just this one
616
- dwg.add(ldr, f"m_dia_z{i}", view="front")
663
+ dwg.add(ldr, f"m_dia_z{start + i}", view="front", feature=feat)
617
664
  occupied.append(_anno_box(ldr))
618
665
  placed += 1
619
666
  return placed
620
667
 
621
668
 
622
- def render_diameters(dwg, groups, tol: float = 0.15) -> int:
669
+ def render_diameters(dwg, groups, tol: float = 0.15, *, only=None) -> int:
623
670
  """ø leaders for a turned part's external step/boss diameters, from the IR —
624
671
  one distinct callout per diameter, in a tidy row below the front view
625
672
  (X-turning) or a column to its left (Z-turning). Orientation is the feature
626
673
  frame's axis, not two passes. Replaces the engine's ``_annotate_turned_diameters``
627
- (ADR 0008 convergence). Diameters another annotation already covers are skipped."""
674
+ (ADR 0008 convergence). Diameters another annotation already covers are skipped.
675
+
676
+ *only*, when given, restricts placement to step/boss features in the set — the #426
677
+ finalize() path passes the recorded step/boss ``callout`` intents' features. ``None``
678
+ (the auto-pass) places every diameter with the historical 0-based ``m_dia_{x,z}``
679
+ naming, byte-identically."""
628
680
  mentioned = _mentioned_diameters(dwg)
629
- seen: set[tuple[str, float]] = set()
630
- rows: list = [] # X-turned (anchor, dia)
631
- cols: list = [] # Z-turned (anchor, dia)
681
+ # One distinct callout per (axis, diameter). Accumulate EVERY feature that shares a
682
+ # diameter (insertion-ordered), so provenance (#412) can tag the callout with its
683
+ # single owner or leave it unowned when two distinct features share the diameter
684
+ # (the #398c/#406 shared-value rule, so drop can't over-strip a sibling).
685
+ row_buckets: dict = {} # round(dia,2) -> [anchor, dia, {features}] (X-turned)
686
+ col_buckets: dict = {} # Z-turned
632
687
  for g in groups:
633
688
  if g.feature_kind not in ("step", "boss"):
634
689
  continue
690
+ if only is not None and g.feature not in only: # #426 finalize: recorded subset
691
+ continue
635
692
  dia = next((pd.param.value for pd in g.dims if pd.param.kind == "diameter"), None)
636
693
  if dia is None or any(abs(dia - m) <= tol for m in mentioned):
637
694
  continue
638
- axis = g.feature.frame.axis
639
- key = (axis, round(dia, 2))
640
- if key in seen: # one distinct callout per diameter
695
+ bucket = {"x": row_buckets, "z": col_buckets}.get(g.feature.frame.axis)
696
+ if bucket is None:
641
697
  continue
642
- seen.add(key)
643
- if axis == "x":
644
- rows.append((g.anchor, dia))
645
- elif axis == "z":
646
- cols.append((g.anchor, dia))
647
- return _diameter_row_below(dwg, rows) + _diameter_column_left(dwg, cols)
698
+ dkey = round(dia, 2)
699
+ bucket.setdefault(dkey, [g.anchor, dia, set()])[2].add(g.feature)
700
+
701
+ def _items(buckets):
702
+ return [(a, d, next(iter(fs)) if len(fs) == 1 else None) for a, d, fs in buckets.values()]
703
+
704
+ # The placers name leaders m_dia_{x,z}{start+i} CONTIGUOUSLY from one start. The auto-pass
705
+ # (only None) uses start=0 — byte-identical. The finalize path (only set) may run after
706
+ # existing m_dia names (a prior batch), so it starts past the MAX existing index — NOT the
707
+ # first-free (which is unsound for a multi-item run when the names are non-contiguous, e.g.
708
+ # after drop(): a gap below an occupied index would let the run wrap onto it and silently
709
+ # overwrite an earlier leader — #432 review). Starting past the max keeps the whole run free.
710
+ def _next_start(prefix):
711
+ idxs = [
712
+ int(n[len(prefix) :])
713
+ for n in dwg._named
714
+ if n.startswith(prefix) and n[len(prefix) :].isdigit()
715
+ ]
716
+ return max(idxs) + 1 if idxs else 0
717
+
718
+ start_x = _next_start("m_dia_x") if only is not None else 0
719
+ start_z = _next_start("m_dia_z") if only is not None else 0
720
+ return _diameter_row_below(dwg, _items(row_buckets), start=start_x) + _diameter_column_left(
721
+ dwg, _items(col_buckets), start=start_z
722
+ )
648
723
 
649
724
 
650
725
  def _env_pd(group, role):
@@ -769,7 +844,9 @@ def _record_step_chain_drop(dwg, why: str) -> None:
769
844
  )
770
845
 
771
846
 
772
- def _draw_step_chain(dwg, view, segs, name_prefix, detail_scale=None, allow_collapse=True) -> int:
847
+ def _draw_step_chain(
848
+ dwg, view, segs, name_prefix, detail_scale=None, allow_collapse=True, *, start=0
849
+ ) -> int:
773
850
  """Place a turned step-length chain in *view* from *segs* — each ``(pa, pb,
774
851
  value)`` already projected to *view*'s page coords, in axis order. Orientation is
775
852
  data (the projected span direction): horizontal → chain above the view, vertical
@@ -800,7 +877,8 @@ def _draw_step_chain(dwg, view, segs, name_prefix, detail_scale=None, allow_coll
800
877
  dim = _dim((min(xs), y1, 0), (max(xs), y1, 0), "above", gap, draft, label=label)
801
878
  else:
802
879
  dim = _dim((x1, min(ys), 0), (x1, max(ys), 0), "right", gap, draft, label=label)
803
- candidates = [(f"{name_prefix}_typ", dim)]
880
+ typ_name = f"{name_prefix}_typ" if start == 0 else f"{name_prefix}_typ{start}"
881
+ candidates = [(typ_name, dim)]
804
882
  else:
805
883
  tier_step = draft.font_size + 2 * draft.pad_around_text
806
884
  tiers = [0] * len(segs)
@@ -841,7 +919,7 @@ def _draw_step_chain(dwg, view, segs, name_prefix, detail_scale=None, allow_coll
841
919
  p1, p2, side = (x1, pa[1], 0), (x1, pb[1], 0), "right"
842
920
  dist = gap
843
921
  candidates.append(
844
- (f"{name_prefix}{i}", _dim(p1, p2, side, dist, draft, label=_fmt(value)))
922
+ (f"{name_prefix}{start + i}", _dim(p1, p2, side, dist, draft, label=_fmt(value)))
845
923
  )
846
924
 
847
925
  # Room guard: if any dim would fall off the drawable page, place NONE.
@@ -860,7 +938,24 @@ def _draw_step_chain(dwg, view, segs, name_prefix, detail_scale=None, allow_coll
860
938
  return len(candidates)
861
939
 
862
940
 
863
- def render_step_lengths(dwg, groups) -> int:
941
+ def _next_steplen_start(dwg, prefix: str = "m_steplen") -> int:
942
+ """First free m_steplen index past the MAX existing one — the #426 finalize path names
943
+ the chain as a contiguous run from one start, so it must clear every existing name (max+1,
944
+ not first-free: a gap below an occupied index would let the run wrap onto it, #432)."""
945
+ idxs: list[int] = []
946
+ for n in dwg._named:
947
+ if not n.startswith(prefix):
948
+ continue
949
+ rest = n[len(prefix) :]
950
+ if rest.isdigit():
951
+ idxs.append(int(rest))
952
+ elif rest.startswith("_typ"): # the N× collapse name m_steplen_typ{start}
953
+ tail = rest[4:]
954
+ idxs.append(int(tail) if tail.isdigit() else 0)
955
+ return max(idxs) + 1 if idxs else 0
956
+
957
+
958
+ def render_step_lengths(dwg, groups, *, only=None) -> int:
864
959
  """Unified turned step-length chain (ADR 0008 #223): each `StepFeature`'s length
865
960
  span projects into the front view and joins the chain that tiles the turning axis
866
961
  so every shoulder is located. X-turned → horizontal chain above the view;
@@ -877,6 +972,8 @@ def render_step_lengths(dwg, groups) -> int:
877
972
  for g in groups:
878
973
  if g.feature_kind != "step":
879
974
  continue
975
+ if only is not None and g.feature not in only: # #426 finalize: recorded subset
976
+ continue
880
977
  length = next(
881
978
  (pd.param for pd in g.dims if pd.param.kind == "length" and pd.param.span is not None),
882
979
  None,
@@ -887,6 +984,9 @@ def render_step_lengths(dwg, groups) -> int:
887
984
  if not rows:
888
985
  return 0
889
986
  draft = dwg.draft
987
+ # only=None (auto-pass) → start=0, historical m_steplen naming, byte-identical. The
988
+ # finalize path (only set) starts past existing m_steplen names (#426 naming seam).
989
+ start = _next_steplen_start(dwg) if only is not None else 0
890
990
  fsegs = [(dwg.at("front", *a), dwg.at("front", *b), v) for a, b, v in rows]
891
991
  horizontal = abs(fsegs[0][1][0] - fsegs[0][0][0]) >= abs(fsegs[0][1][1] - fsegs[0][0][1])
892
992
 
@@ -936,9 +1036,11 @@ def render_step_lengths(dwg, groups) -> int:
936
1036
  main.sort(key=lambda s: s[0][0])
937
1037
  # The chain now mixes head-block(s) with real steps — never collapse it to a
938
1038
  # uniform "N× v" representative (a block is not a repeated step, #307 review).
939
- return _draw_step_chain(dwg, "front", main, "m_steplen", allow_collapse=False)
1039
+ return _draw_step_chain(
1040
+ dwg, "front", main, "m_steplen", allow_collapse=False, start=start
1041
+ )
940
1042
 
941
- return _draw_step_chain(dwg, "front", fsegs, "m_steplen")
1043
+ return _draw_step_chain(dwg, "front", fsegs, "m_steplen", start=start)
942
1044
 
943
1045
 
944
1046
  def _detect_step_repeat(step_zs, bb_min_z, bb_max_z, tol_frac=0.10):