draftwright 0.2.11__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.11 → draftwright-0.2.12}/CHANGELOG.md +36 -0
  2. {draftwright-0.2.11 → draftwright-0.2.12}/PKG-INFO +5 -5
  3. {draftwright-0.2.11 → draftwright-0.2.12}/README.md +4 -4
  4. {draftwright-0.2.11 → draftwright-0.2.12}/pyproject.toml +1 -1
  5. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/annotations/_common.py +29 -3
  6. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/annotations/from_model.py +140 -84
  7. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/annotations/holes.py +106 -29
  8. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/annotations/orchestrator.py +14 -31
  9. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/builder.py +26 -17
  10. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/drawing.py +288 -56
  11. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/linting/suggest.py +13 -9
  12. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/model/__init__.py +4 -0
  13. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/model/detect.py +28 -9
  14. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/model/ir.py +53 -9
  15. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/sheet_dsl.py +73 -1
  16. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/sheet_emit.py +90 -13
  17. {draftwright-0.2.11 → draftwright-0.2.12}/tests/layout_snapshots/dshape.json +3 -3
  18. {draftwright-0.2.11 → draftwright-0.2.12}/tests/layout_snapshots/side_drilled.json +3 -3
  19. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_make_drawing.py +214 -13
  20. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_pmi.py +30 -9
  21. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_render_seam.py +34 -11
  22. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_sheet_emit.py +160 -15
  23. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_strip_layout.py +66 -0
  24. {draftwright-0.2.11 → draftwright-0.2.12}/.gitignore +0 -0
  25. {draftwright-0.2.11 → draftwright-0.2.12}/LICENSE +0 -0
  26. {draftwright-0.2.11 → draftwright-0.2.12}/skills/SKILL.md +0 -0
  27. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/__init__.py +0 -0
  28. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/_core.py +0 -0
  29. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/analysis.py +0 -0
  30. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/annotate.py +0 -0
  31. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/annotations/__init__.py +0 -0
  32. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/annotations/sections.py +0 -0
  33. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/cli.py +0 -0
  34. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/export.py +0 -0
  35. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/features.py +0 -0
  36. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/fits.py +0 -0
  37. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  38. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  39. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  40. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  41. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/fonts/__init__.py +0 -0
  42. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/intents.py +0 -0
  43. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/layout.py +0 -0
  44. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/linting/__init__.py +0 -0
  45. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/linting/coverage.py +0 -0
  46. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/linting/issues.py +0 -0
  47. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/linting/structural.py +0 -0
  48. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/make_drawing.py +0 -0
  49. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/model/declare.py +0 -0
  50. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/model/planner.py +0 -0
  51. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/pmi.py +0 -0
  52. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/projection.py +0 -0
  53. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/recognition/__init__.py +0 -0
  54. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/recognition/_features.py +0 -0
  55. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/recognition/levels.py +0 -0
  56. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/recognition/slots.py +0 -0
  57. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/recognition/turned.py +0 -0
  58. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/registry.py +0 -0
  59. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/repair.py +0 -0
  60. {draftwright-0.2.11 → draftwright-0.2.12}/src/draftwright/sheet.py +0 -0
  61. {draftwright-0.2.11 → draftwright-0.2.12}/tests/_kernel.py +0 -0
  62. {draftwright-0.2.11 → draftwright-0.2.12}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  63. {draftwright-0.2.11 → draftwright-0.2.12}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  64. {draftwright-0.2.11 → draftwright-0.2.12}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  65. {draftwright-0.2.11 → draftwright-0.2.12}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  66. {draftwright-0.2.11 → draftwright-0.2.12}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  67. {draftwright-0.2.11 → draftwright-0.2.12}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  68. {draftwright-0.2.11 → draftwright-0.2.12}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  69. {draftwright-0.2.11 → draftwright-0.2.12}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  70. {draftwright-0.2.11 → draftwright-0.2.12}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  71. {draftwright-0.2.11 → draftwright-0.2.12}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  72. {draftwright-0.2.11 → draftwright-0.2.12}/tests/layout_snapshots/box.json +0 -0
  73. {draftwright-0.2.11 → draftwright-0.2.12}/tests/layout_snapshots/bracket.json +0 -0
  74. {draftwright-0.2.11 → draftwright-0.2.12}/tests/layout_snapshots/drive_screw_x.json +0 -0
  75. {draftwright-0.2.11 → draftwright-0.2.12}/tests/layout_snapshots/flange.json +0 -0
  76. {draftwright-0.2.11 → draftwright-0.2.12}/tests/layout_snapshots/holed_slot.json +0 -0
  77. {draftwright-0.2.11 → draftwright-0.2.12}/tests/layout_snapshots/plate_holes.json +0 -0
  78. {draftwright-0.2.11 → draftwright-0.2.12}/tests/layout_snapshots/slotted.json +0 -0
  79. {draftwright-0.2.11 → draftwright-0.2.12}/tests/layout_snapshots/turned_shaft.json +0 -0
  80. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_declare.py +0 -0
  81. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_e2e_slice.py +0 -0
  82. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_e2e_standards.py +0 -0
  83. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_fits.py +0 -0
  84. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_gdt_placement.py +0 -0
  85. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_layout.py +0 -0
  86. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_layout_cleanliness.py +0 -0
  87. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_layout_property.py +0 -0
  88. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_layout_snapshot.py +0 -0
  89. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_lint_reconciliation.py +0 -0
  90. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_lint_structural.py +0 -0
  91. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_linting.py +0 -0
  92. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_object_aspects.py +0 -0
  93. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_part_model.py +0 -0
  94. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_recognition.py +0 -0
  95. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_registry.py +0 -0
  96. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_sheet_gdt.py +0 -0
  97. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_sheet_notes.py +0 -0
  98. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_sheet_of.py +0 -0
  99. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_sheet_tables.py +0 -0
  100. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_tolerances.py +0 -0
  101. {draftwright-0.2.11 → draftwright-0.2.12}/tests/test_turned_steps.py +0 -0
@@ -1,5 +1,41 @@
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
+
3
39
  ## v0.2.11 — 2026-07-08
4
40
 
5
41
  **Automatic layout now has one authority.** Page/scale selection, section placement,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.2.11
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.11"
7
+ version = "0.2.12"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -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
@@ -562,7 +584,6 @@ def place_strip_candidates(
562
584
 
563
585
  def _evaluate_segment(take, seg_lo, seg_hi):
564
586
  nat = seg_lo if inner == lo else seg_hi
565
- anch = (0.0, nat) if axis == "y" else (nat, 0.0)
566
587
  # Keys order the tiers so the FIRST candidate lands on the inner tier: for an
567
588
  # inner=lo strip that is the lowest position (ascending keys); for a below strip
568
589
  # (inner=hi) it is the highest, so the keys reverse.
@@ -570,9 +591,14 @@ def place_strip_candidates(
570
591
  (
571
592
  StripCandidate(
572
593
  f"{(k if inner == lo else len(take) - 1 - k):04d}",
573
- 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
+ ),
574
599
  (sizes or {}).get(nb[0], (tier, tier)),
575
600
  priority=(priorities or {}).get(nb[0], 0.0),
601
+ anchored=(anchored or {}).get(nb[0], False),
576
602
  ),
577
603
  nb,
578
604
  )
@@ -59,7 +59,7 @@ from draftwright.annotations._common import (
59
59
  strip_obstacles,
60
60
  )
61
61
  from draftwright.layout import StripCandidate, plan_strip
62
- from draftwright.model.ir import HoleFeature, PatternFeature
62
+ from draftwright.model.ir import AUTHORED_DIMENSION_KINDS, HoleFeature, PatternFeature
63
63
  from draftwright.model.planner import DimensionGroup, plan_locations
64
64
 
65
65
 
@@ -361,15 +361,32 @@ def render_slots(dwg, model, a, *, only=None) -> int:
361
361
  # by datum distance). Segregating the two runs keeps a slot length from landing mid-ladder.
362
362
  _SIZE_SUBCHAIN = 0
363
363
  _LOC_SUBCHAIN = 1
364
-
365
-
366
- def _location_candidate(dwg, name, *, view, span_key, distance, build, feature=None):
364
+ _OVERALL_SUBCHAIN = 2
365
+ _MANDATORY_OVERALL_PRIORITY = 100.0
366
+
367
+
368
+ def _location_candidate(
369
+ dwg,
370
+ name,
371
+ *,
372
+ view,
373
+ span_key,
374
+ distance,
375
+ build,
376
+ feature=None,
377
+ pinned=False,
378
+ ):
367
379
  """A :class:`CorridorCandidate` for a datum-referenced hole/pattern location dim.
368
380
  Location dims outrank a coincident slot-position line in dedup (#345) and form the
369
381
  outer, datum-distance-ordered run of the ladder (#346). Force-kept (policy B): a plan-X
370
382
  / side-Y location has no alternate view, so a corridor block keeps it rather than drops
371
383
  it; only a physically full strip drops (``location_ref_dropped`` → hole-table escalate)."""
372
384
 
385
+ def _placed(nm):
386
+ dwg._cover_scattered_hole_doc(nm)
387
+ if pinned:
388
+ dwg.pin(nm)
389
+
373
390
  def _drop(nm):
374
391
  edge = "plan view" if view == "plan" else "side view"
375
392
  dwg._record_build_issue(
@@ -382,16 +399,17 @@ def _location_candidate(dwg, name, *, view, span_key, distance, build, feature=N
382
399
  build=build,
383
400
  order=(_LOC_SUBCHAIN, distance, name),
384
401
  # A placed location may later be replaced by the scattered-hole table (#351 PR-4c).
385
- on_place=lambda nm: dwg._cover_scattered_hole_doc(nm),
402
+ on_place=_placed,
386
403
  on_drop=_drop,
387
404
  dedup=(view, span_key[0], span_key[1]),
388
- precedence=2,
405
+ precedence=3 if pinned else 2,
406
+ priority=100.0 if pinned else 0.0,
389
407
  force=True,
390
408
  feature=feature, # provenance (ADR 0010): the located hole/pattern
391
409
  )
392
410
 
393
411
 
394
- def render_locations(dwg, model, a, *, only=None) -> int:
412
+ def render_locations(dwg, model, a, *, only=None, pinned=None) -> int:
395
413
  """Baseline X/Y hole-location dims from the IR (#238). The planner decides the
396
414
  intent (`plan_locations`: which refs, from which datum); this renderer owns the
397
415
  layout (Amendment 4) — X dims tier above the plan view, Y dims above the side
@@ -402,7 +420,11 @@ def render_locations(dwg, model, a, *, only=None) -> int:
402
420
  *only*, when given, restricts placement to refs whose source feature is in the set —
403
421
  the #426 finalize() path passes the recorded ``locate`` intents' features so the
404
422
  corridor solve runs over the user's edited subset. ``None`` (the auto-pass) places
405
- every ref, byte-identically."""
423
+ every ref, byte-identically.
424
+
425
+ *pinned* carries the #511 first slice: deferred user ``locate(..., pin=True)`` calls
426
+ remain first-class corridor candidates, but get higher survival/dedup priority and
427
+ pin their placed names instead of being hand-added after the solve."""
406
428
  planned = plan_locations(model)
407
429
  if not planned:
408
430
  return 0
@@ -430,6 +452,7 @@ def render_locations(dwg, model, a, *, only=None) -> int:
430
452
  refs.append((rx, ry, pd.feature)) # carry the source feature for provenance (ADR 0010)
431
453
  if not refs:
432
454
  return 0
455
+ pinned_set = set(pinned or ())
433
456
  tier = draft.font_size + 2 * draft.pad_around_text
434
457
  n = 0
435
458
 
@@ -452,8 +475,12 @@ def render_locations(dwg, model, a, *, only=None) -> int:
452
475
  PX, PY = a.proj.plan_x, a.proj.plan_y
453
476
  x_refs: list = []
454
477
  for r in refs:
455
- if not any(abs(r[0] - u[0]) < 0.5 for u in x_refs):
456
- x_refs.append(r)
478
+ for u in x_refs:
479
+ if abs(r[0] - u[0]) < 0.5:
480
+ u[3] = u[3] or r[2] in pinned_set
481
+ break
482
+ else:
483
+ x_refs.append([r[0], r[1], r[2], r[2] in pinned_set])
457
484
  _x_drawable = {r[0] for r in x_refs if abs(r[0] - datum_x) * a.SCALE >= 1.0}
458
485
  _kept_x, _n_x_close = _legible_locations(_x_drawable, a.SCALE)
459
486
  if _n_x_close:
@@ -472,7 +499,7 @@ def render_locations(dwg, model, a, *, only=None) -> int:
472
499
  # pass carving around the other and interleaving. No alternate view for a plan-X
473
500
  # location, so a corridor-blocked dim is force-kept (policy B), not relocated; only a
474
501
  # physically full strip drops (→ location_ref_dropped, escalates the hole table).
475
- for i, (rx, ry, feat) in enumerate(sorted(x_refs, key=lambda r: abs(r[0] - datum_x))):
502
+ for i, (rx, ry, feat, pin_ref) in enumerate(sorted(x_refs, key=lambda r: abs(r[0] - datum_x))):
476
503
  if abs(rx - datum_x) * a.SCALE < 1.0:
477
504
  continue # on the datum edge — nothing to dimension
478
505
  n += 1
@@ -502,6 +529,7 @@ def render_locations(dwg, model, a, *, only=None) -> int:
502
529
  label=_fmt(_rx - datum_x),
503
530
  ),
504
531
  feature=_xfeat,
532
+ pinned=pin_ref,
505
533
  ),
506
534
  )
507
535
 
@@ -511,8 +539,12 @@ def render_locations(dwg, model, a, *, only=None) -> int:
511
539
  iso_x0, iso_y0, _, _ = _iso_bbox(dwg)
512
540
  y_refs: list = []
513
541
  for r in refs:
514
- if not any(abs(r[1] - u[1]) < 0.5 for u in y_refs):
515
- y_refs.append(r)
542
+ for u in y_refs:
543
+ if abs(r[1] - u[1]) < 0.5:
544
+ u[3] = u[3] or r[2] in pinned_set
545
+ break
546
+ else:
547
+ y_refs.append([r[0], r[1], r[2], r[2] in pinned_set])
516
548
  _y_drawable = {r[1] for r in y_refs if abs(r[1] - datum_y) * a.SCALE >= 1.0}
517
549
  _kept_y, _n_y_close = _legible_locations(_y_drawable, a.SCALE)
518
550
  if _n_y_close:
@@ -528,9 +560,9 @@ def render_locations(dwg, model, a, *, only=None) -> int:
528
560
  # Cap the side-above strip below the iso view so Y-location dims never run under it
529
561
  # (the carve respects outer_limit); the dim_pitch_side dims are obstacles the carve
530
562
  # avoids structurally, retiring the old manual allocate(10.0) reservation + cursor.
531
- if y_refs and any(SX(ry) + 10 > iso_x0 - 4 for _, ry, _ in y_refs):
563
+ if y_refs and any(SX(ry) + 10 > iso_x0 - 4 for _, ry, _feat, _pin in y_refs):
532
564
  a.sv_zones.above.outer_limit = min(a.sv_zones.above.outer_limit, iso_y0 - 4)
533
- for i, (rx, ry, feat) in enumerate(sorted(y_refs, key=lambda r: abs(r[1] - datum_y))):
565
+ for i, (rx, ry, feat, pin_ref) in enumerate(sorted(y_refs, key=lambda r: abs(r[1] - datum_y))):
534
566
  if abs(ry - datum_y) * a.SCALE < 1.0:
535
567
  continue
536
568
  n += 1
@@ -558,6 +590,7 @@ def render_locations(dwg, model, a, *, only=None) -> int:
558
590
  label=_fmt(_ry - datum_y),
559
591
  ),
560
592
  feature=_yfeat,
593
+ pinned=pin_ref,
561
594
  ),
562
595
  )
563
596
  return n
@@ -802,8 +835,8 @@ def _envelope_tier(dwg, strip, view, size):
802
835
  ``allocate`` gave the right order only because an earlier pass had advanced a
803
836
  shared cursor; that coupling inverted the moment the location pass moved to
804
837
  ``plan_strip`` (#321), which never advances the cursor. Reading obstacle boxes
805
- decouples the two passes. #133 mandatory-dim starvation is still guarded upstream
806
- by the orchestrator's tier reservation.
838
+ decouples the two passes. The current renderer queues envelope dims into the shared
839
+ corridor instead; #133 mandatory-dim starvation is guarded by envelope priority.
807
840
 
808
841
  Assumes a below/above strip (Y stacking axis) — the only strips ``render_envelope``
809
842
  uses; a left/right strip would need the X interval of each obstacle box."""
@@ -822,55 +855,78 @@ def _envelope_tier(dwg, strip, view, size):
822
855
 
823
856
  def render_envelope(dwg, groups, a) -> int:
824
857
  """Overall width (plan, below) + depth (side, below) envelope dims via the IR,
825
- placed by carving each below-strip around the feature/location dims already on it
826
- (ADR 0009) so the overall dim stacks outermost by construction, no longer via a
827
- shared strip cursor an earlier pass had to advance. The **planner** decides
828
- suppression (square footprint / X-turned; #250); this renderer just skips
829
- suppressed dims and places the rest. Returns the count placed."""
858
+ registered into the same below-strip corridor as feature/location/GD&T/PMI candidates.
859
+ The overall dims use the last ladder subchain so they stack outermost by construction,
860
+ while their mandatory priority prevents best-effort below-strip occupants from starving
861
+ principal dimensions. The **planner** decides suppression (square footprint / X-turned;
862
+ #250); this renderer just skips suppressed dims and queues the rest. Returns the count
863
+ queued."""
830
864
  env = envelope_group(groups)
831
865
  if env is None:
832
866
  return 0
833
867
  n = 0
868
+
869
+ def _queue(name, strip, view, tier, distance, build):
870
+ register_corridor(
871
+ dwg,
872
+ (view, "below"),
873
+ strip,
874
+ view,
875
+ "y",
876
+ tier,
877
+ CorridorCandidate(
878
+ name=name,
879
+ build=build,
880
+ order=(_OVERALL_SUBCHAIN, distance, name),
881
+ on_place=lambda _nm: None,
882
+ on_drop=lambda _nm: None,
883
+ priority=_MANDATORY_OVERALL_PRIORITY,
884
+ force=True,
885
+ ),
886
+ )
887
+
834
888
  width = _env_pd(env, "width")
835
889
  if env_dim_placed(width):
836
890
  (x0, y0, z0), (x1, _, _) = width.param.span
837
891
  p1, p2 = dwg.at("plan", x0, y0, z0), dwg.at("plan", x1, y0, z0)
838
892
  witness = p1[1] - 2
839
- py = _envelope_tier(dwg, a.pv_zones.below, "plan", _SLOT_DIM_WIDTH)
840
- if py is not None:
841
- dwg.add(
842
- _dim(
843
- (p1[0], witness, 0),
844
- (p2[0], witness, 0),
845
- "below",
846
- witness - py,
847
- dwg.draft,
848
- label=_fmt(width.param.value),
849
- ),
850
- "m_env_width",
851
- view="plan",
852
- )
853
- n += 1
893
+ _queue(
894
+ "m_env_width",
895
+ a.pv_zones.below,
896
+ "plan",
897
+ _SLOT_DIM_WIDTH,
898
+ abs(x1 - x0),
899
+ lambda pos, _p1=p1, _p2=p2, _w=witness, _v=width.param.value: _dim(
900
+ (_p1[0], _w, 0),
901
+ (_p2[0], _w, 0),
902
+ "below",
903
+ _w - pos,
904
+ dwg.draft,
905
+ label=_fmt(_v),
906
+ ),
907
+ )
908
+ n += 1
854
909
  depth = _env_pd(env, "depth")
855
910
  if env_dim_placed(depth):
856
911
  (x0, y0, z0), (_, y1, _) = depth.param.span
857
912
  p1, p2 = dwg.at("side", x0, y0, z0), dwg.at("side", x0, y1, z0)
858
913
  witness = p1[1] - 2
859
- pd = _envelope_tier(dwg, a.sv_zones.below, "side", _SLOT_DIM_DEPTH)
860
- if pd is not None:
861
- dwg.add(
862
- _dim(
863
- (p1[0], witness, 0),
864
- (p2[0], witness, 0),
865
- "below",
866
- witness - pd,
867
- dwg.draft,
868
- label=_fmt(depth.param.value),
869
- ),
870
- "m_env_depth",
871
- view="side",
872
- )
873
- n += 1
914
+ _queue(
915
+ "m_env_depth",
916
+ a.sv_zones.below,
917
+ "side",
918
+ _SLOT_DIM_DEPTH,
919
+ abs(y1 - y0),
920
+ lambda pos, _p1=p1, _p2=p2, _w=witness, _v=depth.param.value: _dim(
921
+ (_p1[0], _w, 0),
922
+ (_p2[0], _w, 0),
923
+ "below",
924
+ _w - pos,
925
+ dwg.draft,
926
+ label=_fmt(_v),
927
+ ),
928
+ )
929
+ n += 1
874
930
  return n
875
931
 
876
932
 
@@ -1122,7 +1178,7 @@ def _detect_step_repeat(step_zs, bb_min_z, bb_max_z, tol_frac=0.10):
1122
1178
  return n, mean_rise
1123
1179
 
1124
1180
 
1125
- def render_height_ladder(dwg, model, a) -> int:
1181
+ def render_height_ladder(dwg, model, a, *, include_overall: bool = True) -> int:
1126
1182
  """Front-view right ladder: prismatic step heights (from `StepLevelFeature`)
1127
1183
  stacked inner→outer, then the overall height outermost — through `fv_zones.right`,
1128
1184
  preserving the leapfrog witness cursor (#237). Replaces the engine's inline
@@ -1214,7 +1270,7 @@ def render_height_ladder(dwg, model, a) -> int:
1214
1270
  # — #222).
1215
1271
  rot = next((f for f in model.features if f.kind == "rotational"), None)
1216
1272
  od_is_height = rot is not None and rot.frame.axis in ("x", "y")
1217
- suppress_height = model.orientation == "z" or od_is_height
1273
+ suppress_height = (not include_overall) or model.orientation == "z" or od_is_height
1218
1274
  px = (
1219
1275
  None
1220
1276
  if suppress_height
@@ -1434,9 +1490,9 @@ def _bore_half_span(pmi_kind: str, value: float) -> float:
1434
1490
  """Half the perpendicular span of a bore-size dim from the bore centroid — the
1435
1491
  distance to each witness base point. A ``"diameter"`` record stores the full
1436
1492
  diameter (so half = radius = value/2); a ``"radius"`` record already stores the
1437
- radius (half = value). Keyed on ``PmiFeature.pmi_kind`` (the PMI category), NOT
1438
- ``.kind`` (the feature kind, always ``"pmi"``) — the #360 bug used the latter, so
1439
- the diameter branch was dead and every diameter dim spanned ±diameter (2× wide)."""
1493
+ radius (half = value). Keyed on the drafting dimension category, NOT ``.kind`` (the
1494
+ IR feature kind) — the #360 bug used the latter, so the diameter branch was dead and
1495
+ every diameter dim spanned ±diameter (2× wide)."""
1440
1496
  return value / 2 if pmi_kind == "diameter" else value
1441
1497
 
1442
1498
 
@@ -1447,10 +1503,26 @@ _PMI_SUBCHAIN = 3
1447
1503
  _PMI_CORRIDOR_PRIORITY = 1.0
1448
1504
 
1449
1505
 
1506
+ def _renderable_pmi_records(records):
1507
+ """PMI records the dimension renderer may place.
1508
+
1509
+ Raw ``PmiFeature`` fallbacks can preserve unsupported AP242 records. Do not render those
1510
+ just because they happen to carry a numeric value and references; only drafting dimension
1511
+ categories belong in this placement path.
1512
+ """
1513
+ return [
1514
+ r
1515
+ for r in records
1516
+ if r.pmi_kind in AUTHORED_DIMENSION_KINDS and r.value > 0 and len(r.ref_pts) >= 2
1517
+ ]
1518
+
1519
+
1450
1520
  def render_pmi(dwg, model, a) -> int:
1451
- """Render pre-authored PMI annotations (STEP AP242) from the IR `PmiFeature`s
1452
- as first-class corridor candidates (#208/#393). Replaces the engine's
1453
- `_annotate_pmi`.
1521
+ """Render imported authored dimensions from concept IR as first-class candidates.
1522
+
1523
+ AP242 dimensional PMI lowers to ``AuthoredDimension``; unsupported raw PMI fallback
1524
+ records still ride as ``PmiFeature`` so they remain visible to diagnostics (#208/#393).
1525
+ Replaces the engine's ``_annotate_pmi``.
1454
1526
 
1455
1527
  Called from ``_auto_annotate`` before ``drain_corridors`` so authored PMI
1456
1528
  co-solves with automatic strip candidates. Skips records whose page
@@ -1464,26 +1536,9 @@ def render_pmi(dwg, model, a) -> int:
1464
1536
  too compressed in the side view)
1465
1537
  """
1466
1538
  draft = dwg.draft
1467
- pmi = [f for f in model.features if f.kind == "pmi"]
1468
- usable = [r for r in pmi if r.value > 0 and len(r.ref_pts) >= 2]
1469
- n_gtol = sum(
1470
- 1
1471
- for r in pmi
1472
- if r.pmi_kind
1473
- not in (
1474
- "linear",
1475
- "diameter",
1476
- "radius",
1477
- "angular",
1478
- "curved_dist",
1479
- "oriented",
1480
- "curve_length",
1481
- "thickness",
1482
- "label",
1483
- "presentation",
1484
- )
1485
- and r.value > 0
1486
- )
1539
+ pmi = [f for f in model.features if f.kind in ("authored_dimension", "pmi")]
1540
+ usable = _renderable_pmi_records(pmi)
1541
+ n_gtol = sum(1 for r in pmi if r.pmi_kind not in AUTHORED_DIMENSION_KINDS and r.value > 0)
1487
1542
  if n_gtol:
1488
1543
  _log.debug("PMI annotate: %d gtol/datum record(s) not yet annotatable (Phase 4)", n_gtol)
1489
1544
  if not usable:
@@ -1920,10 +1975,11 @@ def render_pmi(dwg, model, a) -> int:
1920
1975
  # NOT through the dimension planner (their IR items carry no DimParameters). "note" is a
1921
1976
  # free-text manufacturing note (#488) — the same leader-into-a-strip mechanism, glyph = text.
1922
1977
  _GDT_KINDS = ("control_frame", "datum_ref", "finish", "note")
1923
- # Outer run of the shared corridor ladder: GD&T frames tier BEYOND the feature-size
1924
- # (_SIZE_SUBCHAIN=0) and datum-location (_LOC_SUBCHAIN=1) dim runs, so a frame never lands
1925
- # mid-ladder among the dimensions it annotates.
1926
- _GDT_SUBCHAIN = 2
1978
+ # Authored-intent run of the shared corridor ladder: GD&T frames tier BEYOND the
1979
+ # feature-size (_SIZE_SUBCHAIN=0), datum-location (_LOC_SUBCHAIN=1), and overall
1980
+ # envelope (_OVERALL_SUBCHAIN=2) dim runs, so a frame never lands mid-ladder among
1981
+ # the dimensions it annotates.
1982
+ _GDT_SUBCHAIN = 3
1927
1983
  # Over-capacity survival rank for an authored GD&T frame (#357): a declared control frame /
1928
1984
  # datum / finish / note is deliberate intent, so on a strip too full for every candidate it is
1929
1985
  # kept over the auto dims (locations/slots, priority 0) rather than dropped by stacking-key order.