draftwright 0.1.11__tar.gz → 0.1.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 (58) hide show
  1. {draftwright-0.1.11 → draftwright-0.1.13}/CHANGELOG.md +70 -0
  2. {draftwright-0.1.11 → draftwright-0.1.13}/PKG-INFO +4 -5
  3. {draftwright-0.1.11 → draftwright-0.1.13}/README.md +1 -2
  4. {draftwright-0.1.11 → draftwright-0.1.13}/pyproject.toml +23 -7
  5. {draftwright-0.1.11 → draftwright-0.1.13}/src/draftwright/__init__.py +2 -3
  6. {draftwright-0.1.11 → draftwright-0.1.13}/src/draftwright/_core.py +184 -4
  7. draftwright-0.1.13/src/draftwright/analysis.py +479 -0
  8. draftwright-0.1.13/src/draftwright/annotate.py +13 -0
  9. draftwright-0.1.13/src/draftwright/annotations/__init__.py +6 -0
  10. draftwright-0.1.13/src/draftwright/annotations/_common.py +51 -0
  11. draftwright-0.1.13/src/draftwright/annotations/holes.py +1063 -0
  12. draftwright-0.1.13/src/draftwright/annotations/orchestrator.py +551 -0
  13. draftwright-0.1.13/src/draftwright/annotations/pmi.py +371 -0
  14. draftwright-0.1.13/src/draftwright/annotations/sections.py +466 -0
  15. draftwright-0.1.13/src/draftwright/annotations/turned.py +196 -0
  16. draftwright-0.1.13/src/draftwright/builder.py +705 -0
  17. draftwright-0.1.13/src/draftwright/drawing.py +1122 -0
  18. draftwright-0.1.13/src/draftwright/export.py +257 -0
  19. draftwright-0.1.13/src/draftwright/features.py +318 -0
  20. draftwright-0.1.13/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  21. draftwright-0.1.13/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  22. draftwright-0.1.13/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +93 -0
  23. draftwright-0.1.13/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +93 -0
  24. draftwright-0.1.13/src/draftwright/fonts/__init__.py +20 -0
  25. draftwright-0.1.13/src/draftwright/linting.py +268 -0
  26. draftwright-0.1.13/src/draftwright/make_drawing.py +17 -0
  27. {draftwright-0.1.11 → draftwright-0.1.13}/src/draftwright/pmi.py +3 -2
  28. draftwright-0.1.13/src/draftwright/projection.py +291 -0
  29. draftwright-0.1.13/src/draftwright/registry.py +126 -0
  30. draftwright-0.1.13/src/draftwright/repair.py +111 -0
  31. draftwright-0.1.13/src/draftwright/sheet.py +893 -0
  32. draftwright-0.1.13/tests/golden/ctc01.json +335 -0
  33. draftwright-0.1.13/tests/golden/cylinder.json +176 -0
  34. draftwright-0.1.13/tests/golden/plate.json +156 -0
  35. draftwright-0.1.13/tests/golden/stepped.json +148 -0
  36. {draftwright-0.1.11 → draftwright-0.1.13}/tests/test_e2e_standards.py +42 -1
  37. draftwright-0.1.13/tests/test_golden.py +309 -0
  38. {draftwright-0.1.11 → draftwright-0.1.13}/tests/test_layout.py +6 -2
  39. draftwright-0.1.13/tests/test_linting.py +36 -0
  40. {draftwright-0.1.11 → draftwright-0.1.13}/tests/test_make_drawing.py +916 -139
  41. draftwright-0.1.13/tests/test_registry.py +105 -0
  42. draftwright-0.1.11/src/draftwright/annotate.py +0 -2039
  43. draftwright-0.1.11/src/draftwright/make_drawing.py +0 -2934
  44. {draftwright-0.1.11 → draftwright-0.1.13}/.gitignore +0 -0
  45. {draftwright-0.1.11 → draftwright-0.1.13}/LICENSE +0 -0
  46. {draftwright-0.1.11 → draftwright-0.1.13}/skills/SKILL.md +0 -0
  47. {draftwright-0.1.11 → draftwright-0.1.13}/src/draftwright/layout.py +0 -0
  48. {draftwright-0.1.11 → draftwright-0.1.13}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  49. {draftwright-0.1.11 → draftwright-0.1.13}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  50. {draftwright-0.1.11 → draftwright-0.1.13}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  51. {draftwright-0.1.11 → draftwright-0.1.13}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  52. {draftwright-0.1.11 → draftwright-0.1.13}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  53. {draftwright-0.1.11 → draftwright-0.1.13}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  54. {draftwright-0.1.11 → draftwright-0.1.13}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  55. {draftwright-0.1.11 → draftwright-0.1.13}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  56. {draftwright-0.1.11 → draftwright-0.1.13}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  57. {draftwright-0.1.11 → draftwright-0.1.13}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  58. {draftwright-0.1.11 → draftwright-0.1.13}/tests/test_pmi.py +0 -0
@@ -2,6 +2,76 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.1.13 — 2026-06-27
6
+
7
+ ### Changed
8
+
9
+ - **Requires `build123d-drafting-helpers>=0.13.0`; text pinned to bundled fonts**
10
+ (#149, ADR 0006). draftwright now vendors IBM Plex (OFL-1.1) and renders and
11
+ measures all text via `font_path` — IBM Plex Mono for dimensions/callouts/notes,
12
+ IBM Plex Sans Condensed for the title block — instead of resolving the system
13
+ font name `"Arial"`. Resolving a name substitutes a different font on Linux,
14
+ which shifted the whole sheet ~1 mm; pinning a bundled font file makes generated
15
+ layout **deterministic across Linux/macOS/Windows** and gives a consistent
16
+ typeface. **Drawing output changes**: positions shift slightly from prior
17
+ releases and labels render in IBM Plex (helpers #172).
18
+
19
+ ### Internal
20
+
21
+ - **Compiler-pipeline module split** (#138, ADR 0005). The two large modules
22
+ `make_drawing.py` (3,907 lines) and `annotate.py` (2,587) were decomposed into a
23
+ DAG of focused stage modules — `projection`, `sheet`, `analysis`, `drawing`,
24
+ `builder`, the `annotations/` subpackage (sections/turned/pmi/holes/orchestrator),
25
+ alongside the existing `registry`/`linting`/`repair`/`export`/`fonts`. Annotation
26
+ identity, the lint coverage signal, and the deterministic repair loop each gained a
27
+ single owner; `make_drawing.py` / `annotate.py` are now thin compat facades, so all
28
+ existing imports and the `draftwright` CLI entry point keep working. A golden-output
29
+ regression gate verified every step is behaviour-preserving (output byte-identical),
30
+ and mypy was tightened on the settled contracts. No public API or drawing-output
31
+ change. (Phases #160–#166.)
32
+
33
+ ## v0.1.12 — 2026-06-21
34
+
35
+ ### Changed
36
+
37
+ - **Requires `build123d-drafting-helpers>=0.12.0`** (#92, #122). draftwright now
38
+ consumes the new sub-clustered hole-pattern recognition, the
39
+ `feature_diameters()` coverage inventory, the persistent `view_edge_cache`,
40
+ and the `ViewCoordinates.from_viewport()` ISO projection basis.
41
+ - **Grouped hole-pattern callouts** (#92, #111, #114). A recognised perimeter,
42
+ grid, or bolt circle collapses to a single `n× ⌀ …` callout plus its pattern
43
+ dimensions instead of a balloon on every hole. A spec group now sub-clusters
44
+ into multiple patterns (a perimeter → its edge `LinearArray` rows, a filled
45
+ lattice → one `RectGrid` with a `(rows×cols)` callout and both pitch
46
+ dimensions); only genuinely unpatterned holes fall back to the per-hole table.
47
+ On NIST CTC-02 the table shrinks from 61 rows to the unpatterned remainder.
48
+ - **Layout overhaul — compose-then-pack** (#121, #112, ADR 0004). Each view owns
49
+ the annotations created against it, and the resulting view blocks are packed
50
+ disjoint with automatic page/scale escalation. This eliminates cross-view
51
+ overlap — most visibly, plan-view balloons landing on front-view dimensions.
52
+ - **Drawing attribution** (#120). The title block records the author, the SVG
53
+ and PDF carry a clickable draftwright hyperlink, and a "generated by
54
+ draftwright" note is written to the SVG/DXF/PDF file metadata.
55
+ - **Gap between wrapped hole-table column blocks** (#123) so a chart that wraps
56
+ into several blocks reads as distinct columns.
57
+
58
+ ### Fixed
59
+
60
+ - **Plan-view top balloon ring no longer floats over a phantom corridor** (#125).
61
+ The hole-table escalation deletes the X-location dimensions but left their
62
+ stale depth in the strip cursor, so the top balloons were parked far above the
63
+ view. The ring is now sized to the real dimension stack, so the top-side
64
+ leaders are short like the other three sides.
65
+ - **No more phantom `feature_not_dimensioned` warnings** on slot-ends and shallow
66
+ recesses, via the helpers 0.12.0 `feature_diameters()` coverage inventory
67
+ (#92).
68
+
69
+ ### Internal
70
+
71
+ - `AnnoBox` box-model footprint foundation and the four-side balloon ring placed
72
+ in a reserved view halo (#111, #112); the title block is pinned as a
73
+ first-class layout block (#112).
74
+
5
75
  ## v0.1.11 — 2026-06-19
6
76
 
7
77
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.1.11
3
+ Version: 0.1.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
@@ -679,8 +679,8 @@ Classifier: Programming Language :: Python :: 3.10
679
679
  Classifier: Programming Language :: Python :: 3.11
680
680
  Classifier: Programming Language :: Python :: 3.12
681
681
  Classifier: Topic :: Scientific/Engineering
682
- Requires-Python: >=3.10
683
- Requires-Dist: build123d-drafting-helpers>=0.10.1
682
+ Requires-Python: <3.15,>=3.10
683
+ Requires-Dist: build123d-drafting-helpers>=0.13.0
684
684
  Requires-Dist: build123d>=0.9.0
685
685
  Requires-Dist: kiwisolver<2,>=1.4
686
686
  Requires-Dist: numpy>=1.24
@@ -756,8 +756,7 @@ make_drawing(part, out="drawing", title="My Part", number="DWG-001")
756
756
  # Composable: get a Drawing object to inspect or extend
757
757
  dwg = build_drawing(part, title="My Part")
758
758
  issues = dwg.lint() # list[LintIssue]
759
- dwg.export_svg("drawing.svg")
760
- dwg.export_dxf("drawing.dxf")
759
+ svg_path, dxf_path = dwg.export("drawing")
761
760
  ```
762
761
 
763
762
  ### From a STEP file
@@ -66,8 +66,7 @@ make_drawing(part, out="drawing", title="My Part", number="DWG-001")
66
66
  # Composable: get a Drawing object to inspect or extend
67
67
  dwg = build_drawing(part, title="My Part")
68
68
  issues = dwg.lint() # list[LintIssue]
69
- dwg.export_svg("drawing.svg")
70
- dwg.export_dxf("drawing.dxf")
69
+ svg_path, dxf_path = dwg.export("drawing")
71
70
  ```
72
71
 
73
72
  ### From a STEP file
@@ -4,15 +4,15 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.1.11"
7
+ version = "0.1.13"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
11
11
  authors = [{ name = "Paul Fremantle", email = "pzfreo@gmail.com" }]
12
- requires-python = ">=3.10"
12
+ requires-python = ">=3.10,<3.15"
13
13
  dependencies = [
14
14
  "build123d>=0.9.0",
15
- "build123d-drafting-helpers>=0.10.1",
15
+ "build123d-drafting-helpers>=0.13.0",
16
16
  "kiwisolver>=1.4,<2",
17
17
  "numpy>=1.24",
18
18
  ]
@@ -59,10 +59,17 @@ include = [
59
59
  testpaths = ["tests"]
60
60
  timeout = 300
61
61
  # `slow` covers the heavy end-to-end builds over the large NIST CTC fixtures
62
- # (minutes each). They are deselected by default for a fast normal run; run the
63
- # full suite with `-m ""` or just the slow tier with `-m slow` (CI runs both).
64
- markers = ["slow: heavy end-to-end CTC fixture builds (deselected by default)"]
65
- addopts = "--tb=short --cov=src/draftwright --cov-report=term-missing --cov-report=xml -m 'not slow'"
62
+ # (minutes each), deselected by default. `smoke` is a curated, build-light subset
63
+ # (~30 s) for a quick local "did I break something obvious" check run it with
64
+ # `uv run pytest -m smoke`. The default run is the full fast tier (`-m 'not slow'`,
65
+ # ~8 min); CI owns that plus the slow tier across the OS/Python matrix.
66
+ markers = [
67
+ "slow: heavy end-to-end CTC fixture builds (deselected by default)",
68
+ "smoke: curated build-light subset for a fast local check (~30 s)",
69
+ ]
70
+ # Coverage is a CI concern (it adds ~13% locally and instruments every line); the
71
+ # CI workflow passes the --cov flags explicitly. Keep the local default lean.
72
+ addopts = "--tb=short -m 'not slow'"
66
73
 
67
74
  [tool.coverage.run]
68
75
  source = ["src/draftwright"]
@@ -79,6 +86,14 @@ python_version = "3.10"
79
86
  ignore_missing_imports = true
80
87
  check_untyped_defs = true
81
88
  warn_unused_ignores = true
89
+ # Tightened on the settled module contracts (#138 / ADR 0005 P7). disallow_untyped_defs
90
+ # stays off (geometry helpers lean on untyped build123d returns); these flags catch
91
+ # real gaps without demanding annotations everywhere.
92
+ no_implicit_optional = true
93
+ warn_redundant_casts = true
94
+ strict_equality = true
95
+ warn_return_any = true
96
+ extra_checks = true
82
97
 
83
98
  [tool.ruff]
84
99
  line-length = 99
@@ -94,5 +109,6 @@ dev = [
94
109
  "pytest>=8",
95
110
  "pytest-cov>=5",
96
111
  "pytest-timeout>=2",
112
+ "pytest-xdist>=3",
97
113
  "ruff>=0.4",
98
114
  ]
@@ -11,19 +11,18 @@ Requires build123d-drafting-helpers for annotation primitives.
11
11
  Licensed under the GNU Affero General Public License v3 (AGPL-3.0).
12
12
  """
13
13
 
14
+ from draftwright.analysis import analyse_face_levels, dedup_diams
14
15
  from draftwright.make_drawing import (
15
16
  Drawing,
16
17
  FeatureInfo,
17
- analyse_face_levels,
18
18
  build_drawing,
19
- choose_scale,
20
- dedup_diams,
21
19
  fix_svg_page_size,
22
20
  generate_script,
23
21
  lint_feature_coverage,
24
22
  make_drawing,
25
23
  )
26
24
  from draftwright.pmi import PmiRecord, extract_pmi
25
+ from draftwright.sheet import choose_scale
27
26
 
28
27
  __all__ = [
29
28
  "Drawing",
@@ -10,13 +10,14 @@ the dimension/format helpers, and the layout constants). It imports only from
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import functools
13
14
  import logging
14
15
  import re
15
16
  from dataclasses import dataclass, field
16
17
  from pathlib import Path
17
18
  from types import SimpleNamespace
18
19
 
19
- from build123d import BoundBox, Location, Shape
20
+ from build123d import Align, BoundBox, Location, Mode, Shape, Text
20
21
  from build123d_drafting.helpers import (
21
22
  Dimension,
22
23
  TitleBlock,
@@ -24,6 +25,7 @@ from build123d_drafting.helpers import (
24
25
  format_drawing_scale,
25
26
  )
26
27
 
28
+ from draftwright.fonts import PLEX_MONO, PLEX_SANS_CONDENSED
27
29
  from draftwright.layout import _greedy_strip_1d, _solve_strip_1d
28
30
 
29
31
  _log = logging.getLogger(__name__)
@@ -63,6 +65,38 @@ def _tag_sequence(n):
63
65
 
64
66
  _DIAM_RE = re.compile(r"[øØ⌀]\s*(\d+(?:\.\d+)?)")
65
67
 
68
+ # A single-quoted label lifted from a lint message, e.g. "labels 'A' and 'B' …".
69
+ # Shared by the #29 lint suggestions (linting.py) and the #30 repair loop.
70
+ _QUOTED_RE = re.compile(r"'([^']*)'")
71
+
72
+
73
+ @functools.lru_cache(maxsize=512)
74
+ def _text_width(text: str, font_size: float, font_path: str = PLEX_MONO) -> float:
75
+ """Measured rendered width (page-mm) of *text* at *font_size*.
76
+
77
+ Uses build123d's ``Text`` — the same primitive ``Dimension``/``HoleCallout``
78
+ stroke their labels with — so callout-width estimates use real glyph metrics
79
+ instead of a character-count fudge (#31). Pinned to a vendored font **file**
80
+ (``font_path``), not a system font *name*: name resolution substitutes a
81
+ different font on Linux, which makes this estimate — and the layout it feeds —
82
+ platform-variant (#149). The default is the same face the annotations render
83
+ with, so estimate and render agree. Cached because the same numeric labels
84
+ recur across holes and the rasterisation is the costly part.
85
+ """
86
+ if not text:
87
+ return 0.0
88
+ return (
89
+ Text(
90
+ txt=text,
91
+ font_size=font_size,
92
+ font_path=font_path,
93
+ align=(Align.CENTER, Align.CENTER),
94
+ mode=Mode.PRIVATE,
95
+ )
96
+ .bounding_box()
97
+ .size.X
98
+ )
99
+
66
100
 
67
101
  def _axis_letter(obj) -> str:
68
102
  """Letter (``"x"``/``"y"``/``"z"``) of ``obj.axis``'s dominant component.
@@ -192,6 +226,12 @@ _SLOT_DIM_DEPTH = 2 * _FONT_SIZE + _PAD # sv_zones.below: overall depth dimensi
192
226
  _SLOT_DIM_HEIGHT = 2 * _FONT_SIZE + 2 * _PAD # fv_zones.right: overall height dim
193
227
 
194
228
 
229
+ _STRIP_SPACING = 4.0 # page-mm between successive annotations in a strip
230
+ _MIN_VIEW_MM = (
231
+ 10.0 # min projected view dimension; below it annotation geometry degenerates (#129)
232
+ )
233
+
234
+
195
235
  _SLOT_DIM_STEP = 4 * _FONT_SIZE + _PAD # fv_zones.right: step-height dimension
196
236
 
197
237
 
@@ -361,6 +401,7 @@ class Analysis:
361
401
  bbox_max: float
362
402
  holes: list
363
403
  patterns: list
404
+ slots: list
364
405
  z_diams: list[float]
365
406
  cross_diams: list[float]
366
407
  cyls: tuple[list, list]
@@ -411,6 +452,16 @@ _greedy_strip_ys = _greedy_strip_1d
411
452
  _solve_strip_ys = _solve_strip_1d
412
453
 
413
454
 
455
+ _DRAFTWRIGHT_URL = "https://github.com/pzfreo/draftwright"
456
+
457
+
458
+ def _attribution_author(drawn_by: str | None) -> str:
459
+ """ISO 7200 "drawn by" value: the human author and draftwright, or just
460
+ draftwright when no author was supplied."""
461
+ author = (drawn_by or "").strip()
462
+ return f"{author} / draftwright" if author else "draftwright"
463
+
464
+
414
465
  def _add_title_block(dwg, a: Analysis):
415
466
  """Add the title block annotation."""
416
467
  tb = TitleBlock(
@@ -418,15 +469,144 @@ def _add_title_block(dwg, a: Analysis):
418
469
  a.number,
419
470
  scale=format_drawing_scale(a.SCALE),
420
471
  general_tolerance=a.tolerance,
421
- designed_by=a.drawn_by,
472
+ designed_by=_attribution_author(a.drawn_by),
422
473
  revision="A",
423
474
  legal_owner="",
424
475
  width=a.TB_W,
425
- draft=dwg.draft,
426
- ).locate(Location((a.PAGE_W - a.TB_W - 11, 11, 0)))
476
+ # Title block renders in condensed sans (the tight ISO 7200 cells), a
477
+ # different face from the monospace dimensions — so it carries its own
478
+ # pinned-font draft rather than reusing dwg.draft (#149).
479
+ draft=draft_preset(
480
+ font_size=dwg.draft.font_size,
481
+ decimal_precision=dwg.draft.decimal_precision,
482
+ font_path=PLEX_SANS_CONDENSED,
483
+ ),
484
+ )
485
+ # Drawn-by cell geometry, from the block's own public cell bbox (#139) rather
486
+ # than hardcoded column fractions, so the hyperlink rect tracks any upstream
487
+ # TitleBlock layout change. Build-frame bbox; translated to page space below.
488
+ cell = tb.drawn_by_cell_bbox()
489
+ tb = tb.locate(Location((a.PAGE_W - a.TB_W - _TB_CLEAR, _TB_CLEAR, 0)))
427
490
  dwg.add(tb, "title_block")
428
491
 
492
+ # Record that cell's page-space rectangle so export() can place a clickable
493
+ # draftwright hyperlink over the "… / draftwright" author text. The build-frame
494
+ # cell corners are offset by the block's page location (bx, _TB_CLEAR).
495
+ bx = a.PAGE_W - a.TB_W - _TB_CLEAR
496
+ dwg._draftwright_link_rect = (
497
+ bx + cell["min_x"],
498
+ _TB_CLEAR + cell["min_y"],
499
+ bx + cell["max_x"],
500
+ _TB_CLEAR + cell["max_y"],
501
+ )
502
+
429
503
 
430
504
  def _iso_bbox(dwg):
431
505
  """(min_x, min_y, max_x, max_y) of the placed iso view, hidden lines included."""
432
506
  return dwg.view_bounds("iso")
507
+
508
+
509
+ # --- page/scale selection + sheet-layout constants and helpers --------------
510
+ # Relocated from make_drawing for the sheet.py split (#162). Shared by sheet.py
511
+ # (choose_scale/_layout_geometry) and make_drawing's repack pass, so they live
512
+ # here in the shared base to keep the DAG acyclic.
513
+ _DIM_PAD = 18.0
514
+ _STRIP_GAP = 8.0
515
+
516
+ _PAGE_SIZES = {
517
+ "A4": (297.0, 210.0),
518
+ "A3": (420.0, 297.0),
519
+ "A2": (594.0, 420.0),
520
+ "A1": (841.0, 594.0),
521
+ "A0": (1189.0, 841.0),
522
+ }
523
+
524
+ _SCALES = [10.0, 5.0, 2.0, 1.0, 0.5, 0.2]
525
+
526
+ # Horizontal page budget to reserve for the isometric view during scale
527
+ # selection and view placement, as a fraction of bbox_max * scale. This is a
528
+ # deliberate *under-estimate*, not the true projected size (a cube's iso
529
+ # projection is ~1.63*bbox_max wide): the iso is the last column and is fitted
530
+ # to the actual largest-empty-rect afterwards by _fit_iso_view(), which shrinks
531
+ # it to whatever space is genuinely left. A true fit test here is circular —
532
+ # the empty rect depends on the very view positions this estimate feeds — so
533
+ # the budget stays a single, named factor rather than a recomputed fit (#31).
534
+ _ISO_WIDTH_BUDGET = 0.7
535
+
536
+ # Scale selection accepts a layout when the largest empty rectangle left for the
537
+ # iso view can hold a square of at least this fraction of the iso's natural size
538
+ # (bbox_max * scale * _ISO_WIDTH_BUDGET). Below 1.0 because _fit_iso_view scales
539
+ # the iso down to whatever space remains, so a modestly smaller rectangle still
540
+ # renders a legible iso — letting a long/short part enlarge onto a sheet (e.g.
541
+ # 2:1 on A3) where the strict row model would have under-scaled it.
542
+ _ISO_MIN_FIT_FRAC = 0.6
543
+
544
+
545
+ # Automatic scale/page preference ladder, first-fit. The enlargement/unity
546
+ # region is page-major: every standard scale on the smallest sheet (A4) is tried
547
+ # before moving to the next sheet, so a part lands on the smallest sheet it fits
548
+ # at the largest scale that sheet allows — e.g. a 20×15×10 part gets 2:1 on A4,
549
+ # not 5:1 on A3. Reductions (below 1:1) keep their legibility-vs-sheet balance
550
+ # (least reduction first) so a large part is not over-reduced onto a small sheet.
551
+ _LADDER = [
552
+ # A4 — smallest sheet first, largest scale first
553
+ (10.0, 297.0, 210.0, 120.0), # A4 10:1
554
+ (5.0, 297.0, 210.0, 120.0), # A4 5:1
555
+ (2.0, 297.0, 210.0, 120.0), # A4 2:1
556
+ (1.0, 297.0, 210.0, 120.0), # A4 1:1
557
+ # A3
558
+ (5.0, 420.0, 297.0, 150.0), # A3 5:1
559
+ (2.0, 420.0, 297.0, 150.0), # A3 2:1
560
+ (1.0, 420.0, 297.0, 150.0), # A3 1:1
561
+ # A2
562
+ (2.0, 594.0, 420.0, 150.0), # A2 2:1
563
+ (1.0, 594.0, 420.0, 150.0), # A2 1:1
564
+ # A1
565
+ (1.0, 841.0, 594.0, 150.0), # A1 1:1
566
+ # Reductions — least reduction first, so a too-big part is not crammed onto a
567
+ # small sheet at an illegible scale.
568
+ (0.5, 594.0, 420.0, 150.0), # A2 1:2
569
+ (0.2, 420.0, 297.0, 150.0), # A3 1:5
570
+ (0.2, 594.0, 420.0, 150.0), # A2 1:5
571
+ (0.5, 841.0, 594.0, 150.0), # A1 1:2
572
+ (0.2, 841.0, 594.0, 150.0), # A1 1:5
573
+ (0.5, 1189.0, 841.0, 150.0), # A0 1:2
574
+ (0.2, 1189.0, 841.0, 150.0), # A0 1:5
575
+ ]
576
+
577
+
578
+ def _tb_width(page_w: float) -> float:
579
+ """Title-block width for a page: 120 mm on A4, 150 mm on A3 and larger."""
580
+ return 120.0 if page_w <= 297.0 else 150.0
581
+
582
+
583
+ def _parse_page(page) -> tuple:
584
+ """Resolve a page spec to ``(PAGE_W, PAGE_H, TB_W)``.
585
+
586
+ Accepts an ISO name (``"A4"``…``"A0"``, case-insensitive), a
587
+ ``"WIDTHxHEIGHT"`` string in mm (e.g. ``"420x297"``), or a
588
+ ``(width, height)`` tuple in mm.
589
+ """
590
+ if isinstance(page, str):
591
+ name = page.strip().upper()
592
+ if name in _PAGE_SIZES:
593
+ pw, ph = _PAGE_SIZES[name]
594
+ else:
595
+ m = re.fullmatch(r"(\d+(?:\.\d+)?)\s*[xX×]\s*(\d+(?:\.\d+)?)", page.strip())
596
+ if not m:
597
+ raise ValueError(
598
+ f"unknown page size {page!r} — expected one of "
599
+ f"{', '.join(_PAGE_SIZES)} or WIDTHxHEIGHT in mm (e.g. '420x297')"
600
+ )
601
+ pw, ph = float(m.group(1)), float(m.group(2))
602
+ else:
603
+ try:
604
+ pw, ph = float(page[0]), float(page[1])
605
+ except (TypeError, ValueError, IndexError):
606
+ raise ValueError(
607
+ f"invalid page size {page!r} — expected an ISO name, "
608
+ f"'WIDTHxHEIGHT', or a (width, height) tuple in mm"
609
+ ) from None
610
+ if pw <= 0 or ph <= 0:
611
+ raise ValueError(f"page dimensions must be positive, got {page!r}")
612
+ return pw, ph, _tb_width(pw)