draftwright 0.2.12__tar.gz → 0.2.13__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.12 → draftwright-0.2.13}/CHANGELOG.md +34 -0
  2. {draftwright-0.2.12 → draftwright-0.2.13}/PKG-INFO +1 -1
  3. {draftwright-0.2.12 → draftwright-0.2.13}/pyproject.toml +1 -1
  4. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/analysis.py +44 -0
  5. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/builder.py +47 -6
  6. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/sheet.py +165 -69
  7. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_make_drawing.py +247 -9
  8. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_tolerances.py +16 -0
  9. {draftwright-0.2.12 → draftwright-0.2.13}/.gitignore +0 -0
  10. {draftwright-0.2.12 → draftwright-0.2.13}/LICENSE +0 -0
  11. {draftwright-0.2.12 → draftwright-0.2.13}/README.md +0 -0
  12. {draftwright-0.2.12 → draftwright-0.2.13}/skills/SKILL.md +0 -0
  13. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/__init__.py +0 -0
  14. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/_core.py +0 -0
  15. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/annotate.py +0 -0
  16. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/annotations/__init__.py +0 -0
  17. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/annotations/_common.py +0 -0
  18. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/annotations/from_model.py +0 -0
  19. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/annotations/holes.py +0 -0
  20. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/annotations/orchestrator.py +0 -0
  21. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/annotations/sections.py +0 -0
  22. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/cli.py +0 -0
  23. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/drawing.py +0 -0
  24. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/export.py +0 -0
  25. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/features.py +0 -0
  26. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/fits.py +0 -0
  27. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  28. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  29. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  30. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  31. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/fonts/__init__.py +0 -0
  32. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/intents.py +0 -0
  33. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/layout.py +0 -0
  34. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/linting/__init__.py +0 -0
  35. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/linting/coverage.py +0 -0
  36. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/linting/issues.py +0 -0
  37. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/linting/structural.py +0 -0
  38. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/linting/suggest.py +0 -0
  39. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/make_drawing.py +0 -0
  40. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/model/__init__.py +0 -0
  41. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/model/declare.py +0 -0
  42. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/model/detect.py +0 -0
  43. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/model/ir.py +0 -0
  44. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/model/planner.py +0 -0
  45. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/pmi.py +0 -0
  46. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/projection.py +0 -0
  47. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/recognition/__init__.py +0 -0
  48. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/recognition/_features.py +0 -0
  49. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/recognition/levels.py +0 -0
  50. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/recognition/slots.py +0 -0
  51. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/recognition/turned.py +0 -0
  52. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/registry.py +0 -0
  53. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/repair.py +0 -0
  54. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/sheet_dsl.py +0 -0
  55. {draftwright-0.2.12 → draftwright-0.2.13}/src/draftwright/sheet_emit.py +0 -0
  56. {draftwright-0.2.12 → draftwright-0.2.13}/tests/_kernel.py +0 -0
  57. {draftwright-0.2.12 → draftwright-0.2.13}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  58. {draftwright-0.2.12 → draftwright-0.2.13}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  59. {draftwright-0.2.12 → draftwright-0.2.13}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  60. {draftwright-0.2.12 → draftwright-0.2.13}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  61. {draftwright-0.2.12 → draftwright-0.2.13}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  62. {draftwright-0.2.12 → draftwright-0.2.13}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  63. {draftwright-0.2.12 → draftwright-0.2.13}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  64. {draftwright-0.2.12 → draftwright-0.2.13}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  65. {draftwright-0.2.12 → draftwright-0.2.13}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  66. {draftwright-0.2.12 → draftwright-0.2.13}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  67. {draftwright-0.2.12 → draftwright-0.2.13}/tests/layout_snapshots/box.json +0 -0
  68. {draftwright-0.2.12 → draftwright-0.2.13}/tests/layout_snapshots/bracket.json +0 -0
  69. {draftwright-0.2.12 → draftwright-0.2.13}/tests/layout_snapshots/drive_screw_x.json +0 -0
  70. {draftwright-0.2.12 → draftwright-0.2.13}/tests/layout_snapshots/dshape.json +0 -0
  71. {draftwright-0.2.12 → draftwright-0.2.13}/tests/layout_snapshots/flange.json +0 -0
  72. {draftwright-0.2.12 → draftwright-0.2.13}/tests/layout_snapshots/holed_slot.json +0 -0
  73. {draftwright-0.2.12 → draftwright-0.2.13}/tests/layout_snapshots/plate_holes.json +0 -0
  74. {draftwright-0.2.12 → draftwright-0.2.13}/tests/layout_snapshots/side_drilled.json +0 -0
  75. {draftwright-0.2.12 → draftwright-0.2.13}/tests/layout_snapshots/slotted.json +0 -0
  76. {draftwright-0.2.12 → draftwright-0.2.13}/tests/layout_snapshots/turned_shaft.json +0 -0
  77. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_declare.py +0 -0
  78. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_e2e_slice.py +0 -0
  79. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_e2e_standards.py +0 -0
  80. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_fits.py +0 -0
  81. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_gdt_placement.py +0 -0
  82. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_layout.py +0 -0
  83. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_layout_cleanliness.py +0 -0
  84. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_layout_property.py +0 -0
  85. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_layout_snapshot.py +0 -0
  86. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_lint_reconciliation.py +0 -0
  87. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_lint_structural.py +0 -0
  88. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_linting.py +0 -0
  89. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_object_aspects.py +0 -0
  90. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_part_model.py +0 -0
  91. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_pmi.py +0 -0
  92. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_recognition.py +0 -0
  93. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_registry.py +0 -0
  94. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_render_seam.py +0 -0
  95. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_sheet_emit.py +0 -0
  96. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_sheet_gdt.py +0 -0
  97. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_sheet_notes.py +0 -0
  98. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_sheet_of.py +0 -0
  99. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_sheet_tables.py +0 -0
  100. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_strip_layout.py +0 -0
  101. {draftwright-0.2.12 → draftwright-0.2.13}/tests/test_turned_steps.py +0 -0
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.13 — 2026-07-09
4
+
5
+ **Compose-then-pack is now the layout authority.** This patch finishes the
6
+ ViewBlock footprint migration: estimated and measured layout paths now use the
7
+ same composed block model, furniture declarations reserve their real footprint,
8
+ and measure/repack iterates until stable instead of trusting one measured pass.
9
+
10
+ ### Changed
11
+
12
+ - **ViewBlock footprints now drive the orthographic stack.** The old
13
+ estimator-only plan-view balloon lift is gone; plan balloon headroom is part
14
+ of the plan block in both estimated and measured/repack layout. (#112)
15
+ - **Measure/repack iterates to a fixed point.** Repack now keeps measuring and
16
+ repacking while view-owned footprints continue to grow, with bounded
17
+ convergence and tests for multi-pass growth. (#302)
18
+ - **Estimated view-block composition is explicit.** Scale/page fitness, repack,
19
+ and furniture placement share the same composed block semantics instead of
20
+ rebuilding corridor estimates independently.
21
+
22
+ ### Fixed
23
+
24
+ - **Declared callout furniture reserves layout footprint.** User-declared
25
+ furniture is now measured and fed into the layout model, reducing post-hoc
26
+ collisions from declarations that previously existed only at render time.
27
+ - **Annotation-box composition owns strip sizing.** Strip depths reduce from
28
+ composed annotation boxes, making footprint sizing less dependent on divergent
29
+ scalar estimates.
30
+
31
+ ### Documentation
32
+
33
+ - **Layout architecture docs reflect the current state.** ADR 0004 and the
34
+ layout primer now mark the core layout-authority tranche complete and separate
35
+ remaining work into hardening, coverage, detail-view, and manual-intent phases.
36
+
3
37
  ## v0.2.12 — 2026-07-09
4
38
 
5
39
  **Sheet scripts now round-trip authored layout intent.** This patch finishes the next
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.2.12
3
+ Version: 0.2.13
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.12"
7
+ version = "0.2.13"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -14,6 +14,7 @@ import logging
14
14
  import math
15
15
  import warnings
16
16
  from collections.abc import Callable
17
+ from dataclasses import replace
17
18
 
18
19
  from build123d import Compound, Shape
19
20
  from build123d_drafting.helpers import draft_preset
@@ -31,6 +32,8 @@ from draftwright._core import (
31
32
  _legible_steps,
32
33
  _Projector,
33
34
  )
35
+ from draftwright.model.ir import Datum, PartModel, StepFeature
36
+ from draftwright.model.planner import plan_dimensions
34
37
  from draftwright.recognition import (
35
38
  analyse_cylinders,
36
39
  analyse_face_levels,
@@ -45,6 +48,7 @@ from draftwright.sheet import (
45
48
  StripDepths,
46
49
  _build_zones,
47
50
  _est_hole_table_sizes,
51
+ _est_planned_bore_callout_width,
48
52
  _layout_geometry,
49
53
  _measure_strips,
50
54
  _will_section,
@@ -96,6 +100,32 @@ def _declared_will_section(model, *, is_rotational=False, cx=0.0, cy=0.0) -> boo
96
100
  return False
97
101
 
98
102
 
103
+ def _coerce_layout_model(model, part, decorations=None) -> PartModel | None:
104
+ """Return the caller-declared IR with authored decorations for layout sizing.
105
+
106
+ This mirrors the builder's render-time coercion, but stays local to analysis so
107
+ page/scale/strip selection can see the same authored callout text the renderer
108
+ will later place (#450).
109
+ """
110
+ if model is None:
111
+ return None
112
+ if isinstance(model, PartModel):
113
+ if decorations:
114
+ return replace(model, decorations={**model.decorations, **decorations})
115
+ return model
116
+ features = list(model)
117
+ bbox = part.bounding_box()
118
+ orientation = next((f.frame.axis for f in features if isinstance(f, StepFeature)), None)
119
+ datum = Datum(id="datum_xy", kind="point", at=(bbox.min.X, bbox.min.Y, bbox.min.Z))
120
+ return PartModel(
121
+ bbox=bbox,
122
+ orientation=orientation,
123
+ features=features,
124
+ datums=[datum],
125
+ decorations=decorations or {},
126
+ )
127
+
128
+
99
129
  def _import_step(path) -> Compound:
100
130
  """Read solid geometry from a STEP file via OCCT's ``STEPControl_Reader``.
101
131
 
@@ -299,6 +329,7 @@ def _analyse(
299
329
  page=None,
300
330
  pmi="off",
301
331
  model=None,
332
+ decorations=None,
302
333
  ) -> Analysis:
303
334
  """Load STEP or use a build123d Shape, analyse geometry, compute layout.
304
335
 
@@ -409,6 +440,17 @@ def _analyse(
409
440
  _draft_est = draft_preset(font_size=_FONT_SIZE, decimal_precision=1)
410
441
  _arrow_length = _draft_est.arrow_length
411
442
  _pad_around_text = _draft_est.pad_around_text
443
+ layout_model = _coerce_layout_model(model, part, decorations)
444
+ declared_bore_width = (
445
+ _est_planned_bore_callout_width(
446
+ plan_dimensions(layout_model),
447
+ _draft_est,
448
+ font_size=_FONT_SIZE,
449
+ pad_around_text=_pad_around_text,
450
+ )
451
+ if layout_model is not None
452
+ else 0.0
453
+ )
412
454
  holes = find_holes(part, cyls=(z_cyls, cross_cyls))
413
455
  patterns = find_hole_patterns(holes)
414
456
  bosses = find_bosses(part, cyls=(z_cyls, cross_cyls)) # detect once — the one inventory (#264)
@@ -442,6 +484,7 @@ def _analyse(
442
484
  bb,
443
485
  arrow_length=_arrow_length,
444
486
  pad_around_text=_pad_around_text,
487
+ declared_bore_callout_width=declared_bore_width,
445
488
  )
446
489
 
447
490
  def _pick_for_step_count(n_steps_i: int, strips_i: StripDepths) -> _ScalePick:
@@ -518,6 +561,7 @@ def _analyse(
518
561
  bb,
519
562
  arrow_length=_arrow_length,
520
563
  pad_around_text=_pad_around_text,
564
+ declared_bore_callout_width=declared_bore_width,
521
565
  )
522
566
  # View positions + iso empty-rectangle, shared with scale selection (_fits)
523
567
  # via _layout_geometry so placement and fit never diverge (#11). _fit_iso_view
@@ -77,6 +77,7 @@ _TB_W = 150.0
77
77
  # approximation error, tight enough not to round a genuinely off-axis curve.
78
78
 
79
79
  _REPACK_TOL = 0.75
80
+ _REPACK_MAX_ITER = 3
80
81
 
81
82
 
82
83
  def _cross_view_overlaps(dwg, a) -> int:
@@ -345,6 +346,15 @@ def _repack_candidates(a, scale, page):
345
346
  return list(_LADDER)
346
347
 
347
348
 
349
+ def _needs_repack(dwg, a) -> bool:
350
+ """True when the measured drawing still needs a compose-then-pack pass."""
351
+ return (
352
+ _cross_view_overlaps(dwg, a) != 0
353
+ or _annotation_view_overlaps(dwg, a) != 0
354
+ or _annotations_out_of_bounds(dwg, a)
355
+ )
356
+
357
+
348
358
  def _repack(
349
359
  a, dwg, out, assembly, detail_view, scale=None, page=None, model=None, decorations=None
350
360
  ):
@@ -360,11 +370,7 @@ def _repack(
360
370
  byte-identical) or when the repack would change nothing (same sheet/scale and
361
371
  no view actually moves).
362
372
  """
363
- if (
364
- _cross_view_overlaps(dwg, a) == 0
365
- and _annotation_view_overlaps(dwg, a) == 0
366
- and not _annotations_out_of_bounds(dwg, a)
367
- ):
373
+ if not _needs_repack(dwg, a):
368
374
  return None
369
375
  blocks = _measure_blocks(dwg, a)
370
376
 
@@ -485,6 +491,40 @@ def _repack(
485
491
  return a2, dwg2
486
492
 
487
493
 
494
+ def _repack_to_fixed_point(
495
+ a, dwg, out, assembly, detail_view, scale=None, page=None, model=None, decorations=None
496
+ ):
497
+ """Iterate measure→repack→assemble until stable or bounded (#302)."""
498
+ cur_a, cur_dwg = a, dwg
499
+ for i in range(_REPACK_MAX_ITER):
500
+ repacked = _repack(
501
+ cur_a,
502
+ cur_dwg,
503
+ out,
504
+ assembly,
505
+ detail_view,
506
+ scale=scale,
507
+ page=page,
508
+ model=model,
509
+ decorations=decorations,
510
+ )
511
+ if repacked is None:
512
+ if _needs_repack(cur_dwg, cur_a):
513
+ _log.warning(
514
+ "measure-repack: stalled after %d iteration(s) with residual layout triggers",
515
+ i,
516
+ )
517
+ return (cur_a, cur_dwg) if i else None
518
+ cur_a, cur_dwg = repacked
519
+
520
+ if _needs_repack(cur_dwg, cur_a):
521
+ _log.warning(
522
+ "measure-repack: reached iteration limit (%d) with residual layout triggers",
523
+ _REPACK_MAX_ITER,
524
+ )
525
+ return cur_a, cur_dwg
526
+
527
+
488
528
  def build_drawing(
489
529
  step_file: str | Path | Shape,
490
530
  out: str | None = None,
@@ -561,6 +601,7 @@ def build_drawing(
561
601
  page=page,
562
602
  pmi=pmi,
563
603
  model=model,
604
+ decorations=decorations,
564
605
  )
565
606
 
566
607
  # Pass 1: place + annotate from the estimated layout, then measure the real
@@ -569,7 +610,7 @@ def build_drawing(
569
610
  # measure ≈ estimate, so they skip pass 2 and stand byte-identical.
570
611
  dwg = _assemble(a, out, assembly, detail_view, auto_dims, model=model, decorations=decorations)
571
612
  if auto_dims:
572
- repacked = _repack(
613
+ repacked = _repack_to_fixed_point(
573
614
  a,
574
615
  dwg,
575
616
  out,
@@ -46,6 +46,7 @@ from draftwright._core import (
46
46
  _tag_sequence,
47
47
  _tb_width,
48
48
  _text_width,
49
+ _tol_suffix,
49
50
  )
50
51
  from draftwright.layout import fit_box
51
52
  from draftwright.recognition import BoltCircle, HoleSpec, RectGrid
@@ -337,6 +338,80 @@ def _est_bore_callout_width(
337
338
  return max_w
338
339
 
339
340
 
341
+ def _est_planned_bore_callout_width(
342
+ groups, draft, font_size: float = _FONT_SIZE, pad_around_text: float = 2.0
343
+ ) -> float:
344
+ """Estimate widest hole/pattern callout from planned IR dimensions.
345
+
346
+ This is the declared-model companion to :func:`_est_bore_callout_width`.
347
+ Detection-derived ``HoleSpec`` values cannot see authored decorations such as
348
+ bore tolerances; planned groups can. Keep this in the layout estimator layer
349
+ so page/scale selection does not import renderers just to size text (#450).
350
+ """
351
+
352
+ def _first(group, kind: str, *roles: str) -> float | None:
353
+ for role in roles:
354
+ for pd in group.dims:
355
+ if pd.param.kind == kind and pd.param.role == role:
356
+ return float(pd.param.value)
357
+ return None
358
+
359
+ def _tol(group):
360
+ return next(
361
+ (
362
+ pd.param.tolerance
363
+ for pd in group.dims
364
+ if pd.param.kind == "diameter" and pd.param.role == "bore"
365
+ ),
366
+ None,
367
+ )
368
+
369
+ gap = 0.45 * font_size
370
+ sym_w = font_size
371
+ max_w = 0.0
372
+ for group in groups:
373
+ feat = group.feature
374
+ if getattr(feat, "kind", None) not in ("hole", "pattern"):
375
+ continue
376
+ bore = _first(group, "diameter", "bore")
377
+ if bore is None:
378
+ continue
379
+ depth = _first(group, "depth", "bore")
380
+ cbore_dia = _first(group, "diameter", "counterbore", "spotface")
381
+ cbore_depth = _first(group, "depth", "counterbore", "spotface")
382
+ suffix = None
383
+ if getattr(feat, "kind", None) == "pattern":
384
+ if getattr(feat, "pattern", None) == "bolt_circle" and feat.bcd is not None:
385
+ suffix = f"EQ SP ON ø{_fmt(feat.bcd)} BC"
386
+ elif getattr(feat, "pattern", None) == "grid" and feat.rows and feat.cols:
387
+ suffix = f"({feat.rows}×{feat.cols})"
388
+
389
+ token_w: list[float] = []
390
+ count = getattr(feat, "count", None)
391
+ if count and count > 1:
392
+ token_w.append(_text_width(f"{count}×", font_size))
393
+ token_w.append(sym_w) # ⌀ symbol
394
+ token_w.append(_text_width(f"{_fmt(bore)}{_tol_suffix(_tol(group), draft)}", font_size))
395
+ if depth is None:
396
+ token_w.append(_text_width("THRU", font_size))
397
+ else:
398
+ token_w.append(sym_w) # depth symbol
399
+ token_w.append(_text_width(_fmt(depth), font_size))
400
+ if cbore_dia is not None:
401
+ token_w.append(sym_w) # counterbore/spotface symbol
402
+ token_w.append(sym_w) # ⌀
403
+ token_w.append(_text_width(_fmt(cbore_dia), font_size))
404
+ if cbore_depth is not None:
405
+ token_w.append(sym_w) # depth symbol
406
+ token_w.append(_text_width(_fmt(cbore_depth), font_size))
407
+ if suffix is not None:
408
+ token_w.append(_text_width(suffix, font_size))
409
+
410
+ n = len(token_w)
411
+ max_w = max(max_w, sum(token_w) + max(n - 1, 0) * gap + pad_around_text)
412
+ return max_w
413
+
414
+
340
415
  @dataclass
341
416
  class StripDepths:
342
417
  """Annotation strip depths (page-mm) computed before view positions are fixed.
@@ -358,26 +433,25 @@ def _measure_strips(
358
433
  font_size: float = _FONT_SIZE,
359
434
  arrow_length: float = 2.7,
360
435
  pad_around_text: float = 2.0,
436
+ declared_bore_callout_width: float = 0.0,
361
437
  ) -> StripDepths:
362
- """Compute annotation strip depths from hole geometry (Pass 1 of #131).
438
+ """Compute annotation strip depths from composed annotation boxes (Pass 1 of #131).
363
439
 
364
440
  All annotation sizes are scale-independent because font_size is a fixed
365
441
  page-mm constant, so there is no circularity with choose_scale().
366
442
  *arrow_length* and *pad_around_text* should come from ``draft_preset(...)``.
367
443
  """
368
- bore_depth = _est_bore_callout_width(
369
- holes, font_size, patterns=patterns, pad_around_text=pad_around_text
444
+ return _footprint_from_boxes(
445
+ _compose_anno_boxes(
446
+ holes,
447
+ patterns,
448
+ n_steps,
449
+ font_size=font_size,
450
+ arrow_length=arrow_length,
451
+ pad_around_text=pad_around_text,
452
+ declared_bore_callout_width=declared_bore_callout_width,
453
+ )
370
454
  )
371
- # Add elbow clearance and leader-to-label gap so gap_fv_sv fully contains
372
- # the composed leader: elbow_dx (= draft.arrow_length) + gap
373
- # (= draft.pad_around_text), always present.
374
- if bore_depth > 0:
375
- bore_depth += pad_around_text + arrow_length
376
- right = max(_est_right_strip_depth(n_steps), bore_depth)
377
- left = max(_DIM_PAD, bore_depth)
378
- top = _est_pv_above_depth(holes, patterns, font_size, pad_around_text)
379
- pv_halo = _est_plan_halo(font_size) if _will_balloon(holes, patterns) else 0.0
380
- return StripDepths(right=right, left=left, top=top, pv_halo=pv_halo)
381
455
 
382
456
 
383
457
  @dataclass(frozen=True)
@@ -409,17 +483,21 @@ def _compose_anno_boxes(
409
483
  font_size: float = _FONT_SIZE,
410
484
  arrow_length: float = 2.7,
411
485
  pad_around_text: float = 2.0,
486
+ declared_bore_callout_width: float = 0.0,
412
487
  ) -> list[AnnoBox]:
413
488
  """Compose a drawing's annotation bands as ``AnnoBox`` boxes (#112, Step 4a).
414
489
 
415
- Mirrors ``_measure_strips`` exactly, but emits each contributing band as a
416
- box rather than folding them into three scalars up front.
417
- ``_footprint_from_boxes`` reduces these back to the identical
418
- ``StripDepths``.
490
+ This is the annotation-footprint authority for scale/page layout. Each
491
+ contributing furniture band is emitted as a box; ``_measure_strips`` only
492
+ reduces these boxes to the legacy ``StripDepths`` shape. Declared-model
493
+ callout widths flow through the same box path as detected geometry (#540).
419
494
  """
420
495
  boxes = [AnnoBox("right", _est_right_strip_depth(n_steps))] # FV right dim ladder
421
- bore_depth = _est_bore_callout_width(
422
- holes, font_size, patterns=patterns, pad_around_text=pad_around_text
496
+ bore_depth = max(
497
+ _est_bore_callout_width(
498
+ holes, font_size, patterns=patterns, pad_around_text=pad_around_text
499
+ ),
500
+ declared_bore_callout_width,
423
501
  )
424
502
  if bore_depth > 0:
425
503
  # elbow clearance + leader-to-label gap, as in _measure_strips
@@ -732,6 +810,65 @@ def _padded_box(cx, cy, hw, hh, pad=_DIM_PAD):
732
810
  return ViewBlock(hw, hh, pad, pad, pad, pad).footprint(cx, cy)
733
811
 
734
812
 
813
+ def _compose_view_blocks(
814
+ x_size,
815
+ y_size,
816
+ z_size,
817
+ scale,
818
+ strips: StripDepths | None,
819
+ n_steps: int = 0,
820
+ *,
821
+ section: bool = False,
822
+ ) -> dict[str, ViewBlock]:
823
+ """Compose estimated orthographic view footprints (#112).
824
+
825
+ Each returned ``ViewBlock`` combines the view geometry half-extents with the
826
+ annotation bands reserved for that view. `_layout_geometry` packs these
827
+ blocks; it does not reconstruct bare-view corridor padding itself.
828
+ """
829
+ DIM_PAD = _DIM_PAD
830
+ fv_hw = x_size * scale / 2
831
+ fv_hh = z_size * scale / 2
832
+ pv_hh = y_size * scale / 2
833
+ sv_hw = y_size * scale / 2
834
+
835
+ # The front and plan views form a vertical column sharing the left/right
836
+ # corridors (max of the two); the side view shares the FV↔SV corridor; the
837
+ # front↔plan gap is the abutting pair (fv.top + pv.bottom). When the plan
838
+ # view is ballooned (halo > 0), its halo becomes explicit per-side bands so
839
+ # the ballooned plan view is positioned as a unit (#111/#112).
840
+ halo = strips.pv_halo if strips else 0.0
841
+ strip_top = strips.top if strips else 0.0
842
+ gap_fv_sv = max(DIM_PAD, strips.right if strips else _est_right_strip_depth(n_steps), halo)
843
+ gap_left = max(DIM_PAD, strips.left if strips else DIM_PAD, halo)
844
+ pv_below = _est_pv_below_depth()
845
+ # Top band above PV. When the plan view is ballooned, the ring sits beyond
846
+ # the tiered X-location dims, so reserve their real depth (strip_top) plus a
847
+ # balloon row. When not ballooned, keep the historic DIM_PAD.
848
+ pv_top = (max(DIM_PAD, strip_top) + halo) if halo > 0 else DIM_PAD
849
+ sv_right_band = max(DIM_PAD, strips.right if (section and strips) else DIM_PAD)
850
+
851
+ return {
852
+ "front": ViewBlock(
853
+ fv_hw,
854
+ fv_hh,
855
+ top=DIM_PAD - pv_below,
856
+ right=gap_fv_sv,
857
+ bottom=DIM_PAD,
858
+ left=gap_left,
859
+ ),
860
+ "plan": ViewBlock(
861
+ fv_hw,
862
+ pv_hh,
863
+ top=pv_top,
864
+ right=gap_fv_sv,
865
+ bottom=max(pv_below, halo),
866
+ left=gap_left,
867
+ ),
868
+ "side": ViewBlock(sv_hw, fv_hh, right=sv_right_band),
869
+ }
870
+
871
+
735
872
  def _layout_geometry(
736
873
  x_size,
737
874
  y_size,
@@ -766,42 +903,10 @@ def _layout_geometry(
766
903
  pv_hh = y_size * scale / 2
767
904
  sv_hw = y_size * scale / 2
768
905
 
769
- # Compose each view as a block: geometry half-extents + reserved annotation
770
- # bands per side (#112). The front and plan views form a vertical column
771
- # sharing the left/right corridors (max of the two); the side view shares
772
- # the FV↔SV corridor; the front↔plan gap is the abutting pair
773
- # (fv.top + pv.bottom). When the plan view is ballooned (halo > 0) its halo
774
- # becomes explicit per-side bands so the ballooned plan view is placed as a
775
- # unit — including a BOTTOM band that pushes the front view down so balloons
776
- # ring the part below it, not just left/right/top (#111/#112 Phase 2). All
777
- # bands reduce to today's arithmetic when halo = 0 (byte-identical).
778
- halo = strips.pv_halo if strips else 0.0
779
- strip_top = strips.top if strips else 0.0
780
- gap_fv_sv = max(DIM_PAD, strips.right if strips else _est_right_strip_depth(n_steps), halo)
781
- gap_left = max(DIM_PAD, strips.left if strips else DIM_PAD, halo)
782
- pv_below = _est_pv_below_depth()
783
- # Top band above PV. When the plan view is ballooned, the ring sits beyond the
784
- # tiered X-location dims, so reserve their real depth (strip_top) PLUS a
785
- # balloon row — otherwise the ring overruns the page (#121). When NOT
786
- # ballooned, keep the historic DIM_PAD: the dim tiers spill harmlessly into
787
- # the headroom above PV, and reserving more would needlessly grow the layout
788
- # (and can starve the section view of its leftover space).
789
- pv_top = (max(DIM_PAD, strip_top) + halo) if halo > 0 else DIM_PAD
790
- # Estimated blocks (always built): the scale-derived geometry half-extents
791
- # plus the heuristic per-side corridor depths.
792
- est_fv = ViewBlock(
793
- fv_hw, fv_hh, top=DIM_PAD - pv_below, right=gap_fv_sv, bottom=DIM_PAD, left=gap_left
794
- )
795
- est_pv = ViewBlock(
796
- fv_hw,
797
- pv_hh,
798
- top=pv_top,
799
- right=gap_fv_sv,
800
- bottom=max(pv_below, halo), # band below PV holds the width dim + a balloon row
801
- left=gap_left,
906
+ est_blocks = _compose_view_blocks(
907
+ x_size, y_size, z_size, scale, strips, n_steps, section=section
802
908
  )
803
- sv_right_band = max(DIM_PAD, strips.right if (section and strips) else DIM_PAD)
804
- est_sv = ViewBlock(sv_hw, fv_hh, right=sv_right_band)
909
+ est_fv, est_pv, est_sv = est_blocks["front"], est_blocks["plan"], est_blocks["side"]
805
910
  section_hw = max(fv_hw, 12.0)
806
911
  section_hh = fv_hh
807
912
  if blocks is not None:
@@ -835,18 +940,11 @@ def _layout_geometry(
835
940
  col_left = max(fv.left, pv.left)
836
941
  col_right = max(fv.right, pv.right)
837
942
 
838
- # Bottom balloon band: rather than pushing the front view down (which would
839
- # cascade into the iso/table and the scale choice), LIFT the plan view up
840
- # into the empty top headroom above it the front/side views, iso and title
841
- # block stay anchored, so the table is undisturbed. The lift is implicit:
842
- # the vertical stack is centred with the BASE front↔plan gap (so FV/SV centre
843
- # exactly as when halo = 0), while PV is positioned with the full ballooned
844
- # gap, leaving it max(0, halo - pv_below) higher. Byte-identical when
845
- # halo = 0. (#112, ADR 0004.)
846
- # FV↔PV vertical gap = fv.top + pv.bottom (abutting → sum). The estimator path
847
- # keeps its lift trick (centre on the base gap, place PV on the full gap);
848
- # the measured path uses the real gap directly.
849
- base_gap = (fv.top + pv.bottom) if blocks is not None else (fv.top + pv_below)
943
+ # FV↔PV vertical gap = fv.top + pv.bottom (abutting sum). Estimated and
944
+ # measured paths now use the same block footprint semantics: if the plan
945
+ # view carries a bottom halo, that band is part of the stacked block layout
946
+ # rather than a special-case lift outside the ViewBlock model (#112).
947
+ base_gap = fv.top + pv.bottom
850
948
  total_h = 2 * margin + fv.bottom + 2 * fv.hh + base_gap + 2 * pv.hh + pv.top
851
949
  y_offset = max(0.0, (page_h - total_h) / 2)
852
950
 
@@ -869,9 +967,7 @@ def _layout_geometry(
869
967
  FV_X = margin + x_offset + col_left + fv.hw
870
968
  FV_Y = y_offset + margin + fv.bottom + fv.hh
871
969
  PV_X = FV_X
872
- # PV uses the full (ballooned) front↔plan gap while FV/SV were centred with
873
- # the base gap — so the plan view sits pv_lift higher: lifted into the
874
- # headroom, front view anchored.
970
+ # PV abuts the front-view block: gap = front top band + plan bottom band.
875
971
  PV_Y = FV_Y + fv.hh + (fv.top + pv.bottom) + pv.hh
876
972
  # SV abuts the FV/PV column: gap = column right band + SV's own left band
877
973
  # (disjoint sum). Byte-identical to the old max(fv.right, sv.left) on the
@@ -862,13 +862,9 @@ class TestComposeAnnoBoxes:
862
862
  from draftwright.builder import _FONT_SIZE, draft_preset
863
863
  from draftwright.sheet import _compose_anno_boxes, _footprint_from_boxes, _measure_strips
864
864
 
865
- # The composer must reproduce StripDepths exactly for ANY clearance
866
- # args (#112, Step 4b): the bore-band elbow+gap overhead
867
- # (arrow_length + pad_around_text) is added identically on both paths,
868
- # so byte-identity cannot depend on the values. We test the function
869
- # defaults, the production draft preset (which today equals the
870
- # defaults — a forward-guard if it ever diverges), and a deliberately
871
- # divergent set that actually exercises a different overhead.
865
+ # The composer is the footprint authority (#112): _measure_strips is only
866
+ # a compatibility reducer around these boxes. Exercise defaults, the
867
+ # production draft preset, and deliberately divergent clearance values.
872
868
  preset = draft_preset(font_size=_FONT_SIZE, decimal_precision=1)
873
869
  arg_sets = (
874
870
  {},
@@ -880,6 +876,43 @@ class TestComposeAnnoBoxes:
880
876
  scalar = _measure_strips(holes, patterns, n_steps, bb, **kw)
881
877
  assert composed == scalar, (label, n_steps, kw)
882
878
 
879
+ def test_declared_bore_width_flows_through_boxes(self):
880
+ # #540: declared Sheet/IR callouts can be wider than detected geometry
881
+ # because authored tolerances live on the planned dimensions. That width
882
+ # must be represented as an annotation box, not as a scalar-only side
883
+ # channel in _measure_strips.
884
+ from draftwright.builder import _FONT_SIZE, draft_preset
885
+ from draftwright.recognition import find_hole_patterns, find_holes
886
+ from draftwright.sheet import _compose_anno_boxes, _footprint_from_boxes, _measure_strips
887
+
888
+ part = Box(60, 40, 12) - Pos(0, 0, 6) * Cylinder(3, 12)
889
+ holes = find_holes(part)
890
+ patterns = find_hole_patterns(holes)
891
+ bb = part.bounding_box()
892
+ draft = draft_preset(font_size=_FONT_SIZE, decimal_precision=1)
893
+ declared_width = 55.0
894
+ expected_bore_depth = declared_width + draft.pad_around_text + draft.arrow_length
895
+
896
+ boxes = _compose_anno_boxes(
897
+ holes,
898
+ patterns,
899
+ 0,
900
+ arrow_length=draft.arrow_length,
901
+ pad_around_text=draft.pad_around_text,
902
+ declared_bore_callout_width=declared_width,
903
+ )
904
+ assert expected_bore_depth in [b.depth for b in boxes if b.side == "right"]
905
+ assert expected_bore_depth in [b.depth for b in boxes if b.side == "left"]
906
+ assert _footprint_from_boxes(boxes) == _measure_strips(
907
+ holes,
908
+ patterns,
909
+ 0,
910
+ bb,
911
+ arrow_length=draft.arrow_length,
912
+ pad_around_text=draft.pad_around_text,
913
+ declared_bore_callout_width=declared_width,
914
+ )
915
+
883
916
  def test_matches_for_plain_part(self):
884
917
  from draftwright.recognition import find_hole_patterns, find_holes
885
918
 
@@ -937,8 +970,8 @@ class TestComposeAnnoBoxesCorpus:
937
970
  """Step 4b (#112): de-risk the 4c reservation switch by proving the AnnoBox
938
971
  composer is a faithful drop-in for _measure_strips across the full part
939
972
  archetype corpus, and by pinning the per-side box *structure* that 4c will
940
- consume. Pure validation nothing yet uses the composer for layout, so the
941
- rendered output is byte-identical."""
973
+ consume. The strip estimate now reduces these boxes, so byte-identity here
974
+ guards the active layout path."""
942
975
 
943
976
  @staticmethod
944
977
  def _corpus():
@@ -1008,6 +1041,140 @@ class TestComposeAnnoBoxesCorpus:
1008
1041
  assert bool(halos) == _will_balloon(holes, patterns), (label, n_steps)
1009
1042
 
1010
1043
 
1044
+ class TestComposeViewBlocks:
1045
+ """#112: estimated view footprints are explicit ViewBlocks."""
1046
+
1047
+ def test_fallback_blocks_without_measured_strips(self):
1048
+ from draftwright._core import _DIM_PAD
1049
+ from draftwright.sheet import (
1050
+ _compose_view_blocks,
1051
+ _est_pv_below_depth,
1052
+ _est_right_strip_depth,
1053
+ )
1054
+
1055
+ blocks = _compose_view_blocks(60.0, 40.0, 20.0, 2.0, None, n_steps=3)
1056
+ assert set(blocks) == {"front", "plan", "side"}
1057
+ assert blocks["front"].hw == pytest.approx(60.0)
1058
+ assert blocks["front"].hh == pytest.approx(20.0)
1059
+ assert blocks["front"].right == pytest.approx(_est_right_strip_depth(3))
1060
+ assert blocks["front"].left == pytest.approx(_DIM_PAD)
1061
+ assert blocks["front"].top == pytest.approx(_DIM_PAD - _est_pv_below_depth())
1062
+ assert blocks["plan"].bottom == pytest.approx(_est_pv_below_depth())
1063
+ assert blocks["side"].right == pytest.approx(_DIM_PAD)
1064
+
1065
+ def test_ballooned_plan_halo_is_part_of_plan_block(self):
1066
+ from draftwright._core import _DIM_PAD
1067
+ from draftwright.sheet import StripDepths, _compose_view_blocks, _est_pv_below_depth
1068
+
1069
+ strips = StripDepths(right=10.0, left=12.0, top=20.0, pv_halo=30.0)
1070
+ blocks = _compose_view_blocks(60.0, 40.0, 20.0, 1.0, strips)
1071
+
1072
+ # The shared column corridors must hold the plan halo, even though the
1073
+ # scalar right/left strip estimates are smaller.
1074
+ assert blocks["front"].right == pytest.approx(30.0)
1075
+ assert blocks["front"].left == pytest.approx(30.0)
1076
+ assert blocks["plan"].right == pytest.approx(30.0)
1077
+ assert blocks["plan"].left == pytest.approx(30.0)
1078
+ assert blocks["plan"].top == pytest.approx(max(_DIM_PAD, strips.top) + strips.pv_halo)
1079
+ assert blocks["plan"].bottom == pytest.approx(max(_est_pv_below_depth(), strips.pv_halo))
1080
+
1081
+ def test_section_layout_reserves_side_right_band(self):
1082
+ from draftwright._core import _DIM_PAD
1083
+ from draftwright.sheet import StripDepths, _compose_view_blocks
1084
+
1085
+ strips = StripDepths(right=42.0, left=10.0)
1086
+ without_section = _compose_view_blocks(60.0, 40.0, 20.0, 1.0, strips, section=False)
1087
+ with_section = _compose_view_blocks(60.0, 40.0, 20.0, 1.0, strips, section=True)
1088
+
1089
+ assert without_section["side"].right == pytest.approx(_DIM_PAD)
1090
+ assert with_section["side"].right == pytest.approx(strips.right)
1091
+
1092
+ def test_layout_geometry_consumes_composed_blocks(self, monkeypatch):
1093
+ import draftwright.sheet as sheet
1094
+
1095
+ calls = []
1096
+ original = sheet._compose_view_blocks
1097
+
1098
+ def wrapped(*args, **kwargs):
1099
+ calls.append((args, kwargs))
1100
+ return original(*args, **kwargs)
1101
+
1102
+ monkeypatch.setattr(sheet, "_compose_view_blocks", wrapped)
1103
+ strips = sheet.StripDepths(right=42.0, left=10.0)
1104
+ g = sheet._layout_geometry(
1105
+ 60.0,
1106
+ 40.0,
1107
+ 20.0,
1108
+ 1.0,
1109
+ 420.0,
1110
+ 297.0,
1111
+ 150.0,
1112
+ strips,
1113
+ n_steps=2,
1114
+ section=True,
1115
+ )
1116
+
1117
+ assert g.fits
1118
+ assert len(calls) == 1
1119
+ args, kwargs = calls[0]
1120
+ assert args[:6] == (60.0, 40.0, 20.0, 1.0, strips, 2)
1121
+ assert kwargs == {"section": True}
1122
+
1123
+ def test_estimator_vertical_stack_is_centred_from_composed_blocks(self):
1124
+ from draftwright._core import _MARGIN
1125
+ from draftwright.sheet import _compose_view_blocks, _layout_geometry
1126
+
1127
+ page_h = 297.0
1128
+ blocks = _compose_view_blocks(60.0, 40.0, 20.0, 1.0, None, n_steps=3)
1129
+ g = _layout_geometry(
1130
+ 60.0,
1131
+ 40.0,
1132
+ 20.0,
1133
+ 1.0,
1134
+ 420.0,
1135
+ page_h,
1136
+ 150.0,
1137
+ None,
1138
+ n_steps=3,
1139
+ warn_no_iso=False,
1140
+ )
1141
+
1142
+ fv, pv = blocks["front"], blocks["plan"]
1143
+ block_stack_h = fv.bottom + 2 * fv.hh + fv.top + pv.bottom + 2 * pv.hh + pv.top
1144
+ expected_y_offset = max(0.0, (page_h - 2 * _MARGIN - block_stack_h) / 2)
1145
+ actual_y_offset = g.FV_Y - _MARGIN - fv.bottom - fv.hh
1146
+
1147
+ assert actual_y_offset == pytest.approx(expected_y_offset)
1148
+
1149
+ def test_estimator_vertical_stack_centres_ballooned_plan_block(self):
1150
+ from draftwright._core import _MARGIN
1151
+ from draftwright.sheet import StripDepths, _compose_view_blocks, _layout_geometry
1152
+
1153
+ page_h = 297.0
1154
+ strips = StripDepths(right=10.0, left=12.0, top=20.0, pv_halo=30.0)
1155
+ blocks = _compose_view_blocks(60.0, 40.0, 20.0, 1.0, strips, n_steps=3)
1156
+ g = _layout_geometry(
1157
+ 60.0,
1158
+ 40.0,
1159
+ 20.0,
1160
+ 1.0,
1161
+ 420.0,
1162
+ page_h,
1163
+ 150.0,
1164
+ strips,
1165
+ n_steps=3,
1166
+ warn_no_iso=False,
1167
+ )
1168
+
1169
+ fv, pv = blocks["front"], blocks["plan"]
1170
+ block_stack_h = fv.bottom + 2 * fv.hh + fv.top + pv.bottom + 2 * pv.hh + pv.top
1171
+ expected_y_offset = max(0.0, (page_h - 2 * _MARGIN - block_stack_h) / 2)
1172
+ actual_y_offset = g.FV_Y - _MARGIN - fv.bottom - fv.hh
1173
+
1174
+ assert pv.bottom > 20.0
1175
+ assert actual_y_offset == pytest.approx(expected_y_offset)
1176
+
1177
+
1011
1178
  # ---------------------------------------------------------------------------
1012
1179
  # Phase 3 (#118): dynamic FV→SV corridor
1013
1180
  # ---------------------------------------------------------------------------
@@ -1463,6 +1630,77 @@ class TestComposeThenPackRepack:
1463
1630
  cands = _repack_candidates(a, 2.0, "A3")
1464
1631
  assert len(cands) == 1 and cands[0][0] == 2.0
1465
1632
 
1633
+ def test_repack_to_fixed_point_iterates_until_stable(self, monkeypatch):
1634
+ from types import SimpleNamespace
1635
+
1636
+ import draftwright.builder as builder
1637
+
1638
+ a0, d0 = SimpleNamespace(pass_id=0), SimpleNamespace(pass_id=0)
1639
+ a1, d1 = SimpleNamespace(pass_id=1), SimpleNamespace(pass_id=1)
1640
+ a2, d2 = SimpleNamespace(pass_id=2), SimpleNamespace(pass_id=2)
1641
+ returns = [(a1, d1), (a2, d2), None]
1642
+ calls = []
1643
+
1644
+ def fake_repack(a, dwg, *args, **kwargs):
1645
+ calls.append((a.pass_id, dwg.pass_id))
1646
+ return returns.pop(0)
1647
+
1648
+ monkeypatch.setattr(builder, "_repack", fake_repack)
1649
+ monkeypatch.setattr(builder, "_needs_repack", lambda dwg, a: False)
1650
+
1651
+ out = builder._repack_to_fixed_point(a0, d0, "out", None, False)
1652
+
1653
+ assert out == (a2, d2)
1654
+ assert calls == [(0, 0), (1, 1), (2, 2)]
1655
+
1656
+ def test_repack_to_fixed_point_warns_at_iteration_limit(self, monkeypatch, caplog):
1657
+ from types import SimpleNamespace
1658
+
1659
+ import draftwright.builder as builder
1660
+
1661
+ calls = []
1662
+
1663
+ def fake_repack(a, dwg, *args, **kwargs):
1664
+ calls.append((a.pass_id, dwg.pass_id))
1665
+ pass_id = len(calls)
1666
+ return SimpleNamespace(pass_id=pass_id), SimpleNamespace(pass_id=pass_id)
1667
+
1668
+ monkeypatch.setattr(builder, "_repack", fake_repack)
1669
+ monkeypatch.setattr(builder, "_needs_repack", lambda dwg, a: True)
1670
+
1671
+ with caplog.at_level(logging.WARNING):
1672
+ out_a, out_dwg = builder._repack_to_fixed_point(
1673
+ SimpleNamespace(pass_id=0),
1674
+ SimpleNamespace(pass_id=0),
1675
+ "out",
1676
+ None,
1677
+ False,
1678
+ )
1679
+
1680
+ assert len(calls) == builder._REPACK_MAX_ITER
1681
+ assert out_a.pass_id == out_dwg.pass_id == builder._REPACK_MAX_ITER
1682
+ assert "reached iteration limit" in caplog.text
1683
+
1684
+ def test_repack_to_fixed_point_warns_on_stalled_trigger(self, monkeypatch, caplog):
1685
+ from types import SimpleNamespace
1686
+
1687
+ import draftwright.builder as builder
1688
+
1689
+ monkeypatch.setattr(builder, "_repack", lambda *args, **kwargs: None)
1690
+ monkeypatch.setattr(builder, "_needs_repack", lambda dwg, a: True)
1691
+
1692
+ with caplog.at_level(logging.WARNING):
1693
+ out = builder._repack_to_fixed_point(
1694
+ SimpleNamespace(pass_id=0),
1695
+ SimpleNamespace(pass_id=0),
1696
+ "out",
1697
+ None,
1698
+ False,
1699
+ )
1700
+
1701
+ assert out is None
1702
+ assert "stalled after 0 iteration" in caplog.text
1703
+
1466
1704
  @pytest.mark.timeout(120)
1467
1705
  def test_repack_honours_pinned_scale_on_oversized_part(self):
1468
1706
  # #350 review: when no candidate fits the measured layout, the repack backstop
@@ -172,6 +172,22 @@ class TestSheetTolerance:
172
172
  self._steplen_tol(dwg, n) is None for n in dwg._named if n.startswith("m_steplen")
173
173
  )
174
174
 
175
+ def test_toleranced_hole_callout_participates_in_layout_sizing(self):
176
+ # #450: a Sheet-authored bore tolerance widens the HoleCallout. Layout
177
+ # sizing must reserve that declared footprint before the plan/side/iso
178
+ # blocks are placed; otherwise the real callout is later dropped from the
179
+ # iso-bounded plan strip even though a wider corridor would fit.
180
+ plate = Box(120, 90, 8)
181
+ part = plate - (Pos(0, 0, 4) * Cylinder(4, 8))
182
+
183
+ s = Sheet(part)
184
+ s.envelope(plate)
185
+ s.hole(diameter=8, at=(0, 0, 4), axis="z").tolerance(0.1)
186
+ dwg = s.build()
187
+
188
+ assert any(n.startswith("hc_plan") for n in dwg._named)
189
+ assert "callout_dropped" not in {i.code for i in dwg.lint()}
190
+
175
191
 
176
192
  class TestToleranceHandle:
177
193
  def test_hole_tolerance_survives_feature_replacement(self):
File without changes
File without changes
File without changes