draftwright 0.3.4__tar.gz → 0.3.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. {draftwright-0.3.4 → draftwright-0.3.5}/CHANGELOG.md +39 -0
  2. {draftwright-0.3.4 → draftwright-0.3.5}/PKG-INFO +2 -2
  3. {draftwright-0.3.4 → draftwright-0.3.5}/pyproject.toml +2 -2
  4. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/_core.py +144 -0
  5. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/analysis.py +19 -1
  6. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/annotations/orchestrator.py +25 -0
  7. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/builder.py +22 -0
  8. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/cli.py +18 -0
  9. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/compose.py +23 -2
  10. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/linting/structural.py +16 -1
  11. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/sheet.py +6 -0
  12. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/sheet_emit.py +15 -0
  13. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_make_drawing.py +142 -1
  14. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_sheet_emit.py +43 -0
  15. {draftwright-0.3.4 → draftwright-0.3.5}/.gitignore +0 -0
  16. {draftwright-0.3.4 → draftwright-0.3.5}/LICENSE +0 -0
  17. {draftwright-0.3.4 → draftwright-0.3.5}/README.md +0 -0
  18. {draftwright-0.3.4 → draftwright-0.3.5}/docs/adr/README.md +0 -0
  19. {draftwright-0.3.4 → draftwright-0.3.5}/skills/SKILL.md +0 -0
  20. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/__init__.py +0 -0
  21. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/_geometry.py +0 -0
  22. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/annotate.py +0 -0
  23. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/annotations/__init__.py +0 -0
  24. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/annotations/_common.py +0 -0
  25. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/annotations/balloons.py +0 -0
  26. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/annotations/from_model.py +0 -0
  27. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/annotations/holes.py +0 -0
  28. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/annotations/sections.py +0 -0
  29. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/drawing.py +0 -0
  30. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/export.py +0 -0
  31. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/fits.py +0 -0
  32. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  33. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  34. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  35. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  36. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/fonts/__init__.py +0 -0
  37. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/intents.py +0 -0
  38. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/layout.py +0 -0
  39. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/linting/__init__.py +0 -0
  40. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/linting/coverage.py +0 -0
  41. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/linting/issues.py +0 -0
  42. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/linting/suggest.py +0 -0
  43. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/make_drawing.py +0 -0
  44. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/model/__init__.py +0 -0
  45. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/model/declare.py +0 -0
  46. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/model/detect.py +0 -0
  47. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/model/ir.py +0 -0
  48. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/model/planner.py +0 -0
  49. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/pmi.py +0 -0
  50. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/projection.py +0 -0
  51. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/recognition/__init__.py +0 -0
  52. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/recognition/_features.py +0 -0
  53. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/recognition/_record.py +0 -0
  54. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/recognition/chamfers.py +0 -0
  55. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/recognition/countersinks.py +0 -0
  56. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/recognition/fillets.py +0 -0
  57. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/recognition/flats.py +0 -0
  58. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/recognition/grooves.py +0 -0
  59. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/recognition/levels.py +0 -0
  60. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/recognition/plates.py +0 -0
  61. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/recognition/slots.py +0 -0
  62. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/recognition/turned.py +0 -0
  63. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/registry.py +0 -0
  64. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/repair.py +0 -0
  65. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/score.py +0 -0
  66. {draftwright-0.3.4 → draftwright-0.3.5}/src/draftwright/sheet_dsl.py +0 -0
  67. {draftwright-0.3.4 → draftwright-0.3.5}/tests/_kernel.py +0 -0
  68. {draftwright-0.3.4 → draftwright-0.3.5}/tests/_layout_sig.py +0 -0
  69. {draftwright-0.3.4 → draftwright-0.3.5}/tests/fixtures/grm03_thumbwheel_drive_screw.step +0 -0
  70. {draftwright-0.3.4 → draftwright-0.3.5}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  71. {draftwright-0.3.4 → draftwright-0.3.5}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  72. {draftwright-0.3.4 → draftwright-0.3.5}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  73. {draftwright-0.3.4 → draftwright-0.3.5}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  74. {draftwright-0.3.4 → draftwright-0.3.5}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  75. {draftwright-0.3.4 → draftwright-0.3.5}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  76. {draftwright-0.3.4 → draftwright-0.3.5}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  77. {draftwright-0.3.4 → draftwright-0.3.5}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  78. {draftwright-0.3.4 → draftwright-0.3.5}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  79. {draftwright-0.3.4 → draftwright-0.3.5}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  80. {draftwright-0.3.4 → draftwright-0.3.5}/tests/refactor_golden/bracket_section.json +0 -0
  81. {draftwright-0.3.4 → draftwright-0.3.5}/tests/refactor_golden/centered_rebate.json +0 -0
  82. {draftwright-0.3.4 → draftwright-0.3.5}/tests/refactor_golden/chamfered.json +0 -0
  83. {draftwright-0.3.4 → draftwright-0.3.5}/tests/refactor_golden/filleted.json +0 -0
  84. {draftwright-0.3.4 → draftwright-0.3.5}/tests/refactor_golden/flange_dense.json +0 -0
  85. {draftwright-0.3.4 → draftwright-0.3.5}/tests/refactor_golden/grid_plate.json +0 -0
  86. {draftwright-0.3.4 → draftwright-0.3.5}/tests/refactor_golden/grooved_shaft.json +0 -0
  87. {draftwright-0.3.4 → draftwright-0.3.5}/tests/refactor_golden/hex_bar.json +0 -0
  88. {draftwright-0.3.4 → draftwright-0.3.5}/tests/refactor_golden/holed_slot.json +0 -0
  89. {draftwright-0.3.4 → draftwright-0.3.5}/tests/refactor_golden/pocketed.json +0 -0
  90. {draftwright-0.3.4 → draftwright-0.3.5}/tests/refactor_golden/prismatic_ladder.json +0 -0
  91. {draftwright-0.3.4 → draftwright-0.3.5}/tests/refactor_golden/scattered_plate.json +0 -0
  92. {draftwright-0.3.4 → draftwright-0.3.5}/tests/refactor_golden/side_drilled.json +0 -0
  93. {draftwright-0.3.4 → draftwright-0.3.5}/tests/refactor_golden/turned_stepped.json +0 -0
  94. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_architecture_docs.py +0 -0
  95. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_carve_free_position_callers.py +0 -0
  96. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_declare.py +0 -0
  97. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_detect_once.py +0 -0
  98. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_drawing_encapsulation.py +0 -0
  99. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_e2e_slice.py +0 -0
  100. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_e2e_standards.py +0 -0
  101. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_export_dxf_zoom.py +0 -0
  102. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_fillets_adjacency.py +0 -0
  103. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_fits.py +0 -0
  104. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_gdt_placement.py +0 -0
  105. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_import_boundaries.py +0 -0
  106. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_layout.py +0 -0
  107. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_layout_cleanliness.py +0 -0
  108. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_layout_hypothesis.py +0 -0
  109. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_layout_property.py +0 -0
  110. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_lint_box_cache.py +0 -0
  111. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_lint_reconciliation.py +0 -0
  112. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_lint_structural.py +0 -0
  113. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_linting.py +0 -0
  114. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_object_aspects.py +0 -0
  115. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_occupancy_boxes.py +0 -0
  116. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_part_model.py +0 -0
  117. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_pitch_dim_footprint.py +0 -0
  118. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_pmi.py +0 -0
  119. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_private_test_imports.py +0 -0
  120. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_recogniser_contract.py +0 -0
  121. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_recognition.py +0 -0
  122. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_refactor_golden.py +0 -0
  123. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_registry.py +0 -0
  124. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_render_seam.py +0 -0
  125. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_score.py +0 -0
  126. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_script_detail_parity.py +0 -0
  127. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_sheet_gdt.py +0 -0
  128. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_sheet_notes.py +0 -0
  129. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_sheet_of.py +0 -0
  130. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_sheet_tables.py +0 -0
  131. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_solve_trace.py +0 -0
  132. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_strip_layout.py +0 -0
  133. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_tolerances.py +0 -0
  134. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_turned_steps.py +0 -0
  135. {draftwright-0.3.4 → draftwright-0.3.5}/tests/test_witness_label_reconciliation.py +0 -0
@@ -2,6 +2,45 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## v0.3.5 — 2026-07-20
6
+
7
+ ### Fixed
8
+
9
+ - **Generated `Sheet` script round-trips `--frame` / `--zones` / `--projection`**:
10
+ the sheet-furniture flags added in #767/#768/#769 were not emitted into the
11
+ generated `Sheet(...)` constructor (nor forwarded by the CLI's `--script` path),
12
+ so a regenerated script dropped the border / zone ruler / projection symbol —
13
+ the script no longer matched the direct CLI drawing. The emitter now emits
14
+ `frame=True` / `zones=True` / `projection=…` when set (a plain drawing keeps a
15
+ clean constructor), and the CLI forwards them to `generate_sheet_script`.
16
+
17
+ ### Added
18
+
19
+ - **ISO 5457 zone-grid border ruler** (#768): `build_drawing(zones=True)` /
20
+ `Sheet(zones=True)` / `--zones` draws the grid reference system — numbers 1..
21
+ along the top/bottom edges, letters A.. (skipping I/O) down the sides — in the
22
+ band between the frame and the page edge, with the standard division count per
23
+ A-series page (~50 mm zones). Implies a frame (the ticks sit on it). Off by
24
+ default → byte-identical. The border ticks are lint-exempt like the frame; the
25
+ labels are exempt only from the page-bounds check (they legitimately sit outside
26
+ the drawable), still covered by overlap lint.
27
+ - **Projection-method symbol** (#769): `build_drawing(projection="third"|"first")` /
28
+ `Sheet(projection=…)` / `--projection` draws the ISO 5456-2 third-/first-angle
29
+ glyph in the reserved title-block band (above the title block). Uses the new
30
+ `ProjectionSymbol` primitive from build123d-drafting-helpers **0.14.1** (the pin
31
+ is bumped). Off by default → byte-identical. Unlike the page-spanning frame, the
32
+ small well-placed glyph is *not* lint-exempt — lint covers it so a future
33
+ mispositioning is caught.
34
+ - **Opt-in drawn sheet frame / border** (#767): `build_drawing(frame=True)` /
35
+ `Sheet(frame=True)` / `--frame` draws a border rectangle at the page margin. It's
36
+ the *content boundary* (Option B), not a rectangle over the drawing: turning it on
37
+ raises the effective content margin, and because `compose._layout_geometry` is the
38
+ single authority shared by scale/page selection **and** placement, the reservation
39
+ flows through `choose_scale` — so a framed drawing may pick a smaller scale / larger
40
+ page (ADR 0004), with content clearing the border. Default `frame=False` is
41
+ byte-identical (guarded by the golden + layout-cleanliness suites). The page-spanning
42
+ border carries an `is_sheet_frame` rider so lint skips it.
43
+
5
44
  ## v0.3.4 — 2026-07-20
6
45
 
7
46
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.3.4
3
+ Version: 0.3.5
4
4
  Summary: Automated technical-drawing generation for build123d
5
5
  Project-URL: Homepage, https://github.com/pzfreo/draftwright
6
6
  Project-URL: Repository, https://github.com/pzfreo/draftwright
@@ -682,7 +682,7 @@ Classifier: Programming Language :: Python :: 3.13
682
682
  Classifier: Programming Language :: Python :: 3.14
683
683
  Classifier: Topic :: Scientific/Engineering
684
684
  Requires-Python: <3.15,>=3.10
685
- Requires-Dist: build123d-drafting-helpers>=0.14.0
685
+ Requires-Dist: build123d-drafting-helpers>=0.14.1
686
686
  Requires-Dist: build123d<0.11,>=0.9.0; python_full_version < '3.13'
687
687
  Requires-Dist: build123d<0.12,>=0.11; python_full_version >= '3.13'
688
688
  Requires-Dist: numpy>=1.24
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.3.4"
7
+ version = "0.3.5"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -17,7 +17,7 @@ dependencies = [
17
17
  # 3.13+. Mirrors pzfreo/build123d-mcp's dependency handling.
18
18
  "build123d>=0.9.0,<0.11 ; python_full_version < '3.13'",
19
19
  "build123d>=0.11,<0.12 ; python_full_version >= '3.13'",
20
- "build123d-drafting-helpers>=0.14.0",
20
+ "build123d-drafting-helpers>=0.14.1",
21
21
  "numpy>=1.24",
22
22
  # PNG raster export (SVG→PDF→PNG). Both permissively licensed (Pillow HPND; pypdfium2
23
23
  # Apache-2.0 wrapping Google PDFium BSD-3) and ship pre-built wheels with NO native system
@@ -50,6 +50,44 @@ _log = logging.getLogger(__name__)
50
50
 
51
51
  _MARGIN = 10.0
52
52
 
53
+ # When a sheet frame is drawn (#767), content reserves this extra band inside the border so
54
+ # it clears the drawn line rather than sitting on it. The frame draws AT _MARGIN (the old
55
+ # drawable boundary); content insets to _content_margin(frame).
56
+ _FRAME_BAND = 6.0
57
+
58
+
59
+ def _content_margin(frame: bool) -> float:
60
+ """The effective content margin: ``_MARGIN``, plus the frame clearance band when a sheet
61
+ frame is drawn (#767). Threaded into the layout authority so the reservation flows through
62
+ scale/page selection (ADR 0004), not just the render."""
63
+ return _MARGIN + (_FRAME_BAND if frame else 0.0)
64
+
65
+
66
+ # ISO 5457 zone-grid letters (vertical edges): A.. skipping I and O (confusable with 1 / 0).
67
+ _ZONE_LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ"
68
+
69
+ # ISO 5457 reference-grid division counts (columns=numbers × rows=letters) per A-series page,
70
+ # keyed by the full (width, height) so a same-width custom page doesn't borrow the count.
71
+ _ZONE_DIVISIONS = {
72
+ (297, 210): (6, 4),
73
+ (420, 297): (8, 6),
74
+ (594, 420): (12, 8),
75
+ (841, 594): (16, 12),
76
+ (1189, 841): (24, 16),
77
+ }
78
+
79
+
80
+ def _zone_divisions(page_w: float, page_h: float) -> tuple[int, int]:
81
+ """``(columns, rows)`` for the ISO 5457 zone grid (#768) — the standard count for an
82
+ A-series page (matched on BOTH dimensions), else ~50 mm zones for a custom page. Rows are
83
+ clamped to the available letters (I/O skipped) so a very tall custom page can't over-index."""
84
+ std = _ZONE_DIVISIONS.get((int(round(page_w)), int(round(page_h))))
85
+ if std is not None:
86
+ return std
87
+ cols = max(2, round(page_w / 49.5))
88
+ rows = min(len(_ZONE_LETTERS), max(2, round(page_h / 49.5)))
89
+ return (cols, rows)
90
+
53
91
 
54
92
  _TB_CLEAR = _MARGIN + 1.0 # title-block inset: one extra mm over _MARGIN for clearance
55
93
 
@@ -711,6 +749,13 @@ class Analysis:
711
749
  date: str = ""
712
750
  revision: str = "A"
713
751
  company: str = ""
752
+ # Draw a sheet border/frame (#767). When True, `margin` is already the reserved content
753
+ # margin (`_content_margin(True)`), so content clears the frame drawn at `_MARGIN`.
754
+ frame: bool = False
755
+ # Projection-method symbol (#769): "third" / "first" (ISO 5456-2) or None (omit).
756
+ projection: str | None = None
757
+ # Draw the ISO 5457 zone-grid border ruler (#768). Implies a frame (the ticks sit on it).
758
+ zones: bool = False
714
759
  # The PartModel built by _analyse's pre-scale sizing pass (#584 WP1 A) — stored so
715
760
  # the render path reuses it instead of re-running the detectors (ADR 0008 Amdt 5:
716
761
  # one inventory, detected once; #602). Typed `object` to keep _core free of a
@@ -799,6 +844,105 @@ def _add_title_block(dwg, a: Analysis):
799
844
  dwg.add(tb, "title_block")
800
845
 
801
846
 
847
+ def _make_sheet_frame(a: Analysis) -> Compound:
848
+ """The sheet border rectangle (#767) — a closed outline at the ``_MARGIN`` inset (the old
849
+ drawable boundary). Content clears it because ``a.margin`` is the reserved content margin.
850
+ Carries an ``is_sheet_frame`` rider (like ``is_centerline``) so lint skips its page-spanning
851
+ box, and so ``get_annotation`` / a removed frame drop it cleanly."""
852
+ x0, y0 = _MARGIN, _MARGIN
853
+ x1, y1 = a.PAGE_W - _MARGIN, a.PAGE_H - _MARGIN
854
+ frame = Compound(
855
+ children=[
856
+ Edge.make_line(Vector(x0, y0, 0), Vector(x1, y0, 0)),
857
+ Edge.make_line(Vector(x1, y0, 0), Vector(x1, y1, 0)),
858
+ Edge.make_line(Vector(x1, y1, 0), Vector(x0, y1, 0)),
859
+ Edge.make_line(Vector(x0, y1, 0), Vector(x0, y0, 0)),
860
+ ]
861
+ )
862
+ frame.is_sheet_frame = True # furniture, not a dimension/view — exempt from overlap lint
863
+ return frame
864
+
865
+
866
+ def _add_sheet_frame(dwg, a: Analysis):
867
+ """Add the sheet border (#767), drawn last like the title block. No-op is the caller's
868
+ (gated on ``a.frame``)."""
869
+ dwg.add(_make_sheet_frame(a), "sheet_frame")
870
+
871
+
872
+ def _add_projection_symbol(dwg, a: Analysis):
873
+ """Place the ISO 5456-2 projection-method glyph (#769) in the reserved title-block band,
874
+ just above the drawn title block (deterministic empty space — the block reserves _TB_H but
875
+ draws shorter). Registered ``projection_symbol`` with an ``is_projection_symbol`` identity
876
+ rider. Unlike the page-spanning frame it is NOT lint-exempt: it's a small, well-placed glyph,
877
+ so lint covers it and a future mispositioning is caught. Gated on ``a.projection``."""
878
+ from build123d_drafting import ProjectionSymbol
879
+
880
+ sym = ProjectionSymbol(
881
+ a.projection,
882
+ draft=draft_preset(
883
+ font_size=dwg.draft.font_size,
884
+ decimal_precision=dwg.draft.decimal_precision,
885
+ font_path=PLEX_SANS_CONDENSED,
886
+ ),
887
+ )
888
+ b = sym.bounding_box()
889
+ bx, by = (b.min.X + b.max.X) / 2, (b.min.Y + b.max.Y) / 2
890
+ w, h = b.max.X - b.min.X, b.max.Y - b.min.Y
891
+ # Right side of the title-block column, near the top of its reserved band.
892
+ cx = a.PAGE_W - _TB_CLEAR - w / 2 - 3
893
+ cy = _TB_CLEAR + _TB_H - h / 2 - 2
894
+ sym = sym.locate(Location((cx - bx, cy - by, 0)))
895
+ sym.is_projection_symbol = True
896
+ dwg.add(sym, "projection_symbol")
897
+
898
+
899
+ def _add_zone_grid(dwg, a: Analysis):
900
+ """Draw the ISO 5457 zone-grid border ruler (#768) — numbers 1.. along the top/bottom
901
+ edges, letters A.. (skipping I/O) down the left/right — in the band between the frame
902
+ (at ``_MARGIN``) and the page edge. Requires ``a.frame`` (the ticks sit on the border);
903
+ the caller gates on ``a.zones`` and ensures the frame. Tick lines register as ``zone_grid``
904
+ (``is_zone_grid``); each label carries an ``is_zone_label`` rider so it is exempt from the
905
+ page-bounds lint (it legitimately sits outside the drawable) — but NOT from overlap lint."""
906
+ from build123d_drafting import Note
907
+
908
+ cols, rows = _zone_divisions(a.PAGE_W, a.PAGE_H)
909
+ x0, y0, x1, y1 = _MARGIN, _MARGIN, a.PAGE_W - _MARGIN, a.PAGE_H - _MARGIN
910
+ cw, rh = (x1 - x0) / cols, (y1 - y0) / rows
911
+ band = _MARGIN
912
+ tick = min(3.0, band * 0.6)
913
+ draft = draft_preset(
914
+ font_size=dwg.draft.font_size * 0.8,
915
+ decimal_precision=dwg.draft.decimal_precision,
916
+ font_path=PLEX_SANS_CONDENSED,
917
+ )
918
+ ticks = []
919
+ for i in range(1, cols): # interior column boundaries → ticks on top + bottom edges
920
+ xb = x0 + i * cw
921
+ ticks.append(Edge.make_line(Vector(xb, y0, 0), Vector(xb, y0 - tick, 0)))
922
+ ticks.append(Edge.make_line(Vector(xb, y1, 0), Vector(xb, y1 + tick, 0)))
923
+ for j in range(1, rows): # interior row boundaries → ticks on left + right edges
924
+ yb = y0 + j * rh
925
+ ticks.append(Edge.make_line(Vector(x0, yb, 0), Vector(x0 - tick, yb, 0)))
926
+ ticks.append(Edge.make_line(Vector(x1, yb, 0), Vector(x1 + tick, yb, 0)))
927
+ grid = Compound(children=ticks)
928
+ grid.is_zone_grid = True
929
+ dwg.add(grid, "zone_grid")
930
+
931
+ def _label(text, cx, cy, name):
932
+ note = Note(text, (cx, cy), draft, align=(Align.CENTER, Align.CENTER))
933
+ note.is_zone_label = True
934
+ dwg.add(note, name)
935
+
936
+ for i in range(cols): # numbers 1.. left→right, in the bottom + top bands
937
+ cx = x0 + (i + 0.5) * cw
938
+ _label(str(i + 1), cx, y0 - band / 2, f"zone_num_b_{i}")
939
+ _label(str(i + 1), cx, y1 + band / 2, f"zone_num_t_{i}")
940
+ for j in range(rows): # letters A.. top→bottom, in the left + right bands
941
+ cy = y1 - (j + 0.5) * rh
942
+ _label(_ZONE_LETTERS[j], x0 - band / 2, cy, f"zone_ltr_l_{j}")
943
+ _label(_ZONE_LETTERS[j], x1 + band / 2, cy, f"zone_ltr_r_{j}")
944
+
945
+
802
946
  def _iso_bbox(dwg):
803
947
  """(min_x, min_y, max_x, max_y) of the placed iso view, hidden lines included."""
804
948
  return dwg.view_bounds("iso")
@@ -29,6 +29,7 @@ from draftwright._core import (
29
29
  _MIN_RENDER_MM,
30
30
  _MIN_VIEW_MM,
31
31
  Analysis,
32
+ _content_margin,
32
33
  _legible_steps,
33
34
  _Projector,
34
35
  )
@@ -409,6 +410,7 @@ def _validate_explicit_scale(
409
410
  strips_i,
410
411
  layout_section,
411
412
  layout_table_sizes,
413
+ margin=_MARGIN,
412
414
  ) -> None:
413
415
  """Enforce the two scale floors when the caller pinned an explicit *scale* (#489, #590 split
414
416
  of :func:`_analyse`). An explicit scale is the user's call — honour it, subject to:
@@ -441,6 +443,7 @@ def _validate_explicit_scale(
441
443
  strips=strips_i,
442
444
  section=layout_section,
443
445
  table_sizes=layout_table_sizes,
446
+ margin=margin,
444
447
  )
445
448
  # Warn only when omitting the scale would truly give a legible fit (auto scale itself is
446
449
  # legible) but the requested scale is below the floor. A part illegible at every
@@ -474,11 +477,20 @@ def _analyse(
474
477
  date="",
475
478
  revision="A",
476
479
  company="",
480
+ frame: bool = False,
481
+ projection: str | None = None,
482
+ zones: bool = False,
477
483
  ) -> Analysis:
478
484
  """Load STEP or use a build123d Shape, analyse geometry, compute layout.
479
485
 
480
486
  Returns an :class:`Analysis`.
481
487
  """
488
+ # The zone-grid ruler (#768) draws its ticks on the frame, so it implies one.
489
+ frame = frame or zones
490
+ # The content margin — raised by the sheet-frame band (#767) so scale/page selection and
491
+ # placement both reserve room for the border. Computed up front so the choose_scale inside
492
+ # step-count convergence sees it too.
493
+ margin = _content_margin(frame)
482
494
  if isinstance(step_file, Shape):
483
495
  part = step_file
484
496
  src = "build123d object"
@@ -609,6 +621,7 @@ def _analyse(
609
621
  strips=strips_i,
610
622
  section=layout_section,
611
623
  table_sizes=layout_table_sizes,
624
+ margin=margin,
612
625
  )
613
626
 
614
627
  (SCALE, PAGE_W, PAGE_H, TB_W), strips_i, n_for_sizing = _converge_step_sizing(
@@ -628,9 +641,10 @@ def _analyse(
628
641
  strips_i,
629
642
  layout_section,
630
643
  layout_table_sizes,
644
+ margin=margin,
631
645
  )
632
646
  DIM_PAD = _DIM_PAD
633
- margin = _MARGIN
647
+ # margin was computed up front (_content_margin(frame)) so scale selection already saw it.
634
648
  # Refine: apply the same legibility gate _auto_annotate uses for dim_step.
635
649
  n_steps = len(_legible_steps(step_zs, bb.min.Z, SCALE)[0])
636
650
  strips = _measure_strips(
@@ -656,6 +670,7 @@ def _analyse(
656
670
  n_steps,
657
671
  section=layout_section,
658
672
  table_sizes=layout_table_sizes,
673
+ margin=margin,
659
674
  )
660
675
  fv_hw = _g.fv_hw
661
676
  fv_hh = _g.fv_hh
@@ -778,6 +793,9 @@ def _analyse(
778
793
  date=date,
779
794
  revision=revision,
780
795
  company=company,
796
+ frame=frame,
797
+ projection=projection,
798
+ zones=zones,
781
799
  out=out,
782
800
  pmi=pmi_records,
783
801
  pmi_mode=pmi,
@@ -21,7 +21,10 @@ from draftwright._core import (
21
21
  _TABULATE_MIN_HOLES,
22
22
  Analysis,
23
23
  HoleRef,
24
+ _add_projection_symbol,
25
+ _add_sheet_frame,
24
26
  _add_title_block,
27
+ _add_zone_grid,
25
28
  _concentric_with_axis,
26
29
  _fmt,
27
30
  _iso_bbox,
@@ -125,6 +128,9 @@ _PASS_SEQUENCE: tuple[str, ...] = (
125
128
  "details",
126
129
  "title_block",
127
130
  "tabulate",
131
+ "sheet_frame",
132
+ "zone_grid",
133
+ "projection_symbol",
128
134
  )
129
135
 
130
136
 
@@ -508,6 +514,22 @@ def _auto_annotate(dwg, a: Analysis, *, detail_view: bool = False):
508
514
  def _s_title_block():
509
515
  _add_title_block(dwg, a)
510
516
 
517
+ def _s_sheet_frame():
518
+ # The sheet border, drawn LAST (#767) — gated on the frame opt-in; content already
519
+ # reserved room via the raised a.margin, so this only draws.
520
+ if a.frame:
521
+ _add_sheet_frame(dwg, a)
522
+
523
+ def _s_zone_grid():
524
+ # ISO 5457 zone-grid border ruler (#768), on the frame (a.zones implies a.frame).
525
+ if a.zones:
526
+ _add_zone_grid(dwg, a)
527
+
528
+ def _s_projection_symbol():
529
+ # ISO 5456-2 projection-method glyph (#769) in the reserved title-block band.
530
+ if a.projection:
531
+ _add_projection_symbol(dwg, a)
532
+
511
533
  def _s_tabulate():
512
534
  # Escalate to a hole table when the plan view is too dense to dimension
513
535
  # every hole — runs last so the table avoids every placed annotation
@@ -545,6 +567,9 @@ def _auto_annotate(dwg, a: Analysis, *, detail_view: bool = False):
545
567
  "details": _s_details,
546
568
  "title_block": _s_title_block,
547
569
  "tabulate": _s_tabulate,
570
+ "sheet_frame": _s_sheet_frame,
571
+ "zone_grid": _s_zone_grid,
572
+ "projection_symbol": _s_projection_symbol,
548
573
  }
549
574
  )
550
575
  if ctx.trace is not None: # snapshot the run's escalations into the trace (#736)
@@ -29,7 +29,10 @@ from draftwright._core import (
29
29
  _PAGE_SIZES,
30
30
  _SCALES,
31
31
  Analysis,
32
+ _add_projection_symbol,
33
+ _add_sheet_frame,
32
34
  _add_title_block,
35
+ _add_zone_grid,
33
36
  _iso_bbox,
34
37
  _log,
35
38
  _parse_page,
@@ -336,6 +339,12 @@ def _assemble(
336
339
  else:
337
340
  _fit_iso_view(dwg, a, annotate=False)
338
341
  _add_title_block(dwg, a)
342
+ if a.frame: # sheet border, drawn last (#767) — auto path adds it via the orchestrator
343
+ _add_sheet_frame(dwg, a)
344
+ if a.zones: # zone-grid ruler (#768), on the frame
345
+ _add_zone_grid(dwg, a)
346
+ if a.projection: # projection-method glyph (#769) — auto path adds it via the orchestrator
347
+ _add_projection_symbol(dwg, a)
339
348
  return dwg
340
349
 
341
350
 
@@ -409,6 +418,7 @@ def _repack(
409
418
  section=a.layout_section,
410
419
  table_sizes=a.layout_table_sizes,
411
420
  warn_no_iso=False,
421
+ margin=a.margin,
412
422
  )
413
423
 
414
424
  candidates = _repack_candidates(a, scale, page)
@@ -603,6 +613,9 @@ def build_drawing(
603
613
  date: str = "",
604
614
  revision: str = "A",
605
615
  company: str = "",
616
+ frame: bool = False,
617
+ projection: str | None = None,
618
+ zones: bool = False,
606
619
  ) -> Drawing:
607
620
  """Build a customisable 4-view :class:`Drawing` without exporting it.
608
621
 
@@ -690,6 +703,9 @@ def build_drawing(
690
703
  date=date,
691
704
  revision=revision,
692
705
  company=company,
706
+ frame=frame,
707
+ projection=projection,
708
+ zones=zones,
693
709
  )
694
710
 
695
711
  # Pass 1: place + annotate from the estimated layout, then measure the real
@@ -754,6 +770,9 @@ def make_drawing(
754
770
  date: str = "",
755
771
  revision: str = "A",
756
772
  company: str = "",
773
+ frame: bool = False,
774
+ projection: str | None = None,
775
+ zones: bool = False,
757
776
  ) -> tuple[str, str]:
758
777
  """Generate a 4-view technical drawing from a STEP file or build123d object.
759
778
 
@@ -799,6 +818,9 @@ def make_drawing(
799
818
  date=date,
800
819
  revision=revision,
801
820
  company=company,
821
+ frame=frame,
822
+ projection=projection,
823
+ zones=zones,
802
824
  ).export()
803
825
  assert svg_path is not None and dxf_path is not None # export() writes both by default
804
826
  return svg_path, dxf_path
@@ -106,6 +106,15 @@ def main(
106
106
  date: str = typer.Option("", help="Date for the title block"),
107
107
  revision: str = typer.Option("A", help="Revision for the title block"),
108
108
  company: str = typer.Option("", help="Company / legal owner for the title block"),
109
+ frame: bool = typer.Option(
110
+ False, "--frame", help="Draw a sheet border; content reserves clearance inside it"
111
+ ),
112
+ projection: str = typer.Option(
113
+ "", "--projection", help="Projection-method symbol: 'third' or 'first' (default: none)"
114
+ ),
115
+ zones: bool = typer.Option(
116
+ False, "--zones", help="Draw the ISO 5457 zone-grid border ruler (implies --frame)"
117
+ ),
109
118
  scale: float | None = typer.Option(
110
119
  None, help="Drawing-scale override, e.g. 5 for 5:1 or 0.5 for 1:2 (default: auto)"
111
120
  ),
@@ -186,6 +195,9 @@ def main(
186
195
  date=date,
187
196
  revision=revision,
188
197
  company=company,
198
+ frame=frame,
199
+ zones=zones,
200
+ projection=projection or None,
189
201
  part_expr=seam,
190
202
  formats=tuple(formats),
191
203
  )
@@ -203,6 +215,9 @@ def main(
203
215
  date=date,
204
216
  revision=revision,
205
217
  company=company,
218
+ frame=frame,
219
+ zones=zones,
220
+ projection=projection or None,
206
221
  pmi=pmi.value,
207
222
  formats=tuple(formats),
208
223
  )
@@ -242,6 +257,9 @@ def main(
242
257
  date=date,
243
258
  revision=revision,
244
259
  company=company,
260
+ frame=frame,
261
+ projection=projection or None,
262
+ zones=zones,
245
263
  )
246
264
  for path in _emit(dwg, formats):
247
265
  print(path)
@@ -435,6 +435,7 @@ def _fits(
435
435
  pack_iso_2d: bool = False,
436
436
  section: bool = False,
437
437
  table_sizes=(),
438
+ margin: float = _MARGIN,
438
439
  ) -> bool:
439
440
  """True if the composed 4-view footprint fits the page at this scale.
440
441
 
@@ -456,12 +457,24 @@ def _fits(
456
457
  section=section,
457
458
  table_sizes=table_sizes,
458
459
  warn_no_iso=False,
460
+ margin=margin,
459
461
  )
460
462
  return bool(g.fits if pack_iso_2d else g.auto_fits)
461
463
 
462
464
 
463
465
  def _bisect_fit_scale(
464
- x_size, y_size, z_size, pw, ph, tb, n_steps, strips, pack_iso_2d, section=False, table_sizes=()
466
+ x_size,
467
+ y_size,
468
+ z_size,
469
+ pw,
470
+ ph,
471
+ tb,
472
+ n_steps,
473
+ strips,
474
+ pack_iso_2d,
475
+ section=False,
476
+ table_sizes=(),
477
+ margin=_MARGIN,
465
478
  ):
466
479
  """Largest scale at which the 4-view layout fits ``(pw, ph)``, found by bisection —
467
480
  the layout is monotone in scale (a smaller scale never fits worse). Used only as the
@@ -485,6 +498,7 @@ def _bisect_fit_scale(
485
498
  pack_iso_2d=pack_iso_2d,
486
499
  section=section,
487
500
  table_sizes=table_sizes,
501
+ margin=margin,
488
502
  ):
489
503
  lo = mid
490
504
  else:
@@ -502,6 +516,7 @@ def choose_scale(
502
516
  strips: StripDepths | None = None,
503
517
  section: bool = False,
504
518
  table_sizes=(),
519
+ margin: float = _MARGIN,
505
520
  ) -> tuple:
506
521
  """Return (SCALE, PAGE_W, PAGE_H, TB_W) for a 4-view layout.
507
522
 
@@ -539,6 +554,7 @@ def choose_scale(
539
554
  pack_iso_2d=True,
540
555
  section=section,
541
556
  table_sizes=table_sizes,
557
+ margin=margin,
542
558
  ):
543
559
  _log.warning(
544
560
  "Requested scale %s on %s page may not fit the 4-view layout", scale, page
@@ -565,6 +581,7 @@ def choose_scale(
565
581
  pack_iso_2d=pack_iso_2d,
566
582
  section=section,
567
583
  table_sizes=table_sizes,
584
+ margin=margin,
568
585
  ):
569
586
  return cand
570
587
  # The ISO 5455 ladder exhausted with no standard fit (a part too large even for
@@ -586,6 +603,7 @@ def choose_scale(
586
603
  pack_iso_2d,
587
604
  section,
588
605
  table_sizes,
606
+ margin,
589
607
  )
590
608
  if s is not None:
591
609
  _log.warning(
@@ -758,6 +776,7 @@ def _layout_geometry(
758
776
  section: bool = False,
759
777
  table_sizes=(),
760
778
  warn_no_iso=True,
779
+ margin: float = _MARGIN,
761
780
  ):
762
781
  """Compute the 4-view layout geometry for a part at a given scale/page.
763
782
 
@@ -770,7 +789,9 @@ def _layout_geometry(
770
789
  step-count estimate (used during scale selection before strips are
771
790
  measured); otherwise the measured strip depths are used.
772
791
  """
773
- margin = _MARGIN
792
+ # margin is a parameter (default _MARGIN) so a reserved content margin — e.g. the
793
+ # #767 sheet-frame band — flows through BOTH scale selection and placement, which
794
+ # share this one authority. Default keeps every existing caller byte-identical.
774
795
  DIM_PAD = _DIM_PAD
775
796
  bbox_max = max(x_size, y_size, z_size)
776
797
  fv_hw = x_size * scale / 2
@@ -278,7 +278,13 @@ def lint_drawing(
278
278
 
279
279
  boxes: list = []
280
280
  for item in items:
281
- if getattr(item, "is_centerline", False):
281
+ # The sheet frame (#767) spans the page by design; a None box excludes it from every
282
+ # pairwise overlap (like a centerline), so it doesn't "overlap" every annotation.
283
+ if (
284
+ getattr(item, "is_centerline", False)
285
+ or getattr(item, "is_sheet_frame", False)
286
+ or getattr(item, "is_zone_grid", False) # border ticks span the frame, like the frame
287
+ ):
282
288
  boxes.append(None)
283
289
  continue
284
290
  boxes.append(_label_box(item))
@@ -333,6 +339,13 @@ def lint_drawing(
333
339
  # (#701: unguarded — _ann_box absorbs the fragile measure; the rest is arithmetic.)
334
340
  if page_bbox is not None:
335
341
  for item in items:
342
+ if (
343
+ getattr(item, "is_sheet_frame", False)
344
+ or getattr(item, "is_zone_grid", False)
345
+ or getattr(item, "is_zone_label", False)
346
+ ):
347
+ # Frame + zone ruler sit ON / outside the drawable boundary by design (#767/#768).
348
+ continue
336
349
  bb = _ann_box(item, box_cache)
337
350
  if bb is None:
338
351
  continue
@@ -498,6 +511,8 @@ def _lint_view_shapes(
498
511
  continue # a datum target sits on the part face by definition
499
512
  if getattr(ann, "is_section_hatch", False):
500
513
  continue # hatching is intentionally inside the section view
514
+ if getattr(ann, "is_sheet_frame", False) or getattr(ann, "is_zone_grid", False):
515
+ continue # sheet border / zone ticks span the page, enclosing every view (#767/#768)
501
516
  # #701: unguarded — _label_bbox/_ann_box/_view_edge_entries absorb the
502
517
  # fragile reads; a bug in the check itself must fail loudly.
503
518
  label_box = _label_bbox(ann, warned)
@@ -374,6 +374,9 @@ class Sheet:
374
374
  date=None,
375
375
  revision=None,
376
376
  company=None,
377
+ frame=None,
378
+ projection=None,
379
+ zones=None,
377
380
  ):
378
381
  self._part = part
379
382
  self._features: list = []
@@ -404,6 +407,9 @@ class Sheet:
404
407
  ("date", date),
405
408
  ("revision", revision),
406
409
  ("company", company),
410
+ ("frame", frame),
411
+ ("projection", projection),
412
+ ("zones", zones),
407
413
  ):
408
414
  if _v is not None:
409
415
  self._opts[_k] = _v