draftwright 0.2.6__tar.gz → 0.2.8__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 (96) hide show
  1. {draftwright-0.2.6 → draftwright-0.2.8}/CHANGELOG.md +46 -0
  2. {draftwright-0.2.6 → draftwright-0.2.8}/PKG-INFO +6 -3
  3. {draftwright-0.2.6 → draftwright-0.2.8}/README.md +1 -1
  4. {draftwright-0.2.6 → draftwright-0.2.8}/pyproject.toml +18 -2
  5. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/cli.py +71 -11
  6. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/model/declare.py +39 -0
  7. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/sheet_dsl.py +88 -4
  8. draftwright-0.2.8/src/draftwright/sheet_emit.py +286 -0
  9. draftwright-0.2.8/tests/_kernel.py +19 -0
  10. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_layout_snapshot.py +5 -0
  11. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_make_drawing.py +6 -0
  12. draftwright-0.2.8/tests/test_object_aspects.py +89 -0
  13. draftwright-0.2.8/tests/test_sheet_emit.py +471 -0
  14. draftwright-0.2.8/tests/test_sheet_of.py +81 -0
  15. {draftwright-0.2.6 → draftwright-0.2.8}/.gitignore +0 -0
  16. {draftwright-0.2.6 → draftwright-0.2.8}/LICENSE +0 -0
  17. {draftwright-0.2.6 → draftwright-0.2.8}/skills/SKILL.md +0 -0
  18. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/__init__.py +0 -0
  19. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/_core.py +0 -0
  20. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/analysis.py +0 -0
  21. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/annotate.py +0 -0
  22. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/annotations/__init__.py +0 -0
  23. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/annotations/_common.py +0 -0
  24. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/annotations/from_model.py +0 -0
  25. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/annotations/holes.py +0 -0
  26. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/annotations/orchestrator.py +0 -0
  27. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/annotations/sections.py +0 -0
  28. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/builder.py +0 -0
  29. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/drawing.py +0 -0
  30. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/export.py +0 -0
  31. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/features.py +0 -0
  32. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/fits.py +0 -0
  33. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  34. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  35. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  36. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  37. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/fonts/__init__.py +0 -0
  38. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/intents.py +0 -0
  39. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/layout.py +0 -0
  40. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/linting/__init__.py +0 -0
  41. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/linting/coverage.py +0 -0
  42. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/linting/issues.py +0 -0
  43. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/linting/structural.py +0 -0
  44. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/linting/suggest.py +0 -0
  45. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/make_drawing.py +0 -0
  46. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/model/__init__.py +0 -0
  47. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/model/detect.py +0 -0
  48. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/model/ir.py +0 -0
  49. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/model/planner.py +0 -0
  50. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/pmi.py +0 -0
  51. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/projection.py +0 -0
  52. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/recognition/__init__.py +0 -0
  53. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/recognition/_features.py +0 -0
  54. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/recognition/levels.py +0 -0
  55. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/recognition/slots.py +0 -0
  56. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/recognition/turned.py +0 -0
  57. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/registry.py +0 -0
  58. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/repair.py +0 -0
  59. {draftwright-0.2.6 → draftwright-0.2.8}/src/draftwright/sheet.py +0 -0
  60. {draftwright-0.2.6 → draftwright-0.2.8}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  61. {draftwright-0.2.6 → draftwright-0.2.8}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  62. {draftwright-0.2.6 → draftwright-0.2.8}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  63. {draftwright-0.2.6 → draftwright-0.2.8}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  64. {draftwright-0.2.6 → draftwright-0.2.8}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  65. {draftwright-0.2.6 → draftwright-0.2.8}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  66. {draftwright-0.2.6 → draftwright-0.2.8}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  67. {draftwright-0.2.6 → draftwright-0.2.8}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  68. {draftwright-0.2.6 → draftwright-0.2.8}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  69. {draftwright-0.2.6 → draftwright-0.2.8}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  70. {draftwright-0.2.6 → draftwright-0.2.8}/tests/layout_snapshots/box.json +0 -0
  71. {draftwright-0.2.6 → draftwright-0.2.8}/tests/layout_snapshots/bracket.json +0 -0
  72. {draftwright-0.2.6 → draftwright-0.2.8}/tests/layout_snapshots/drive_screw_x.json +0 -0
  73. {draftwright-0.2.6 → draftwright-0.2.8}/tests/layout_snapshots/dshape.json +0 -0
  74. {draftwright-0.2.6 → draftwright-0.2.8}/tests/layout_snapshots/flange.json +0 -0
  75. {draftwright-0.2.6 → draftwright-0.2.8}/tests/layout_snapshots/holed_slot.json +0 -0
  76. {draftwright-0.2.6 → draftwright-0.2.8}/tests/layout_snapshots/plate_holes.json +0 -0
  77. {draftwright-0.2.6 → draftwright-0.2.8}/tests/layout_snapshots/side_drilled.json +0 -0
  78. {draftwright-0.2.6 → draftwright-0.2.8}/tests/layout_snapshots/slotted.json +0 -0
  79. {draftwright-0.2.6 → draftwright-0.2.8}/tests/layout_snapshots/turned_shaft.json +0 -0
  80. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_declare.py +0 -0
  81. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_e2e_slice.py +0 -0
  82. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_e2e_standards.py +0 -0
  83. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_fits.py +0 -0
  84. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_layout.py +0 -0
  85. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_layout_cleanliness.py +0 -0
  86. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_layout_property.py +0 -0
  87. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_lint_structural.py +0 -0
  88. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_linting.py +0 -0
  89. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_part_model.py +0 -0
  90. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_pmi.py +0 -0
  91. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_recognition.py +0 -0
  92. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_registry.py +0 -0
  93. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_render_seam.py +0 -0
  94. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_strip_layout.py +0 -0
  95. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_tolerances.py +0 -0
  96. {draftwright-0.2.6 → draftwright-0.2.8}/tests/test_turned_steps.py +0 -0
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.8 — 2026-07-06
4
+
5
+ **`--script` now defaults to the declarative `Sheet` DSL.** The editable script the CLI
6
+ writes is the beautiful-Python surface — one commentable line per feature — for both STEP and
7
+ build123d-object input.
8
+
9
+ ### Changed
10
+
11
+ - **`draftwright … --script` now emits the declarative `Sheet` script by default** (was the
12
+ imperative edit-verb reconstruction). The imperative reconstruction is still available via
13
+ `--script --style imperative`. For prismatic parts the generated script reproduces the same
14
+ drawing as a direct build; a turned shaft's centre lines and base-diameter style are not yet
15
+ identical (the remaining validity gap, tracked separately). `--style imperative` now errors
16
+ clearly on a `module:attr` object spec (it reads a STEP file; use the default `sheet`).
17
+ The Sheet DSL doesn't yet model the title-block / layout aspects the imperative script
18
+ embeds, so `--script --style sheet` **warns** when `--drawn-by`, `--tolerance`, `--scale`
19
+ or `--page` is set rather than silently ignoring it (use `--style imperative` to embed them).
20
+
21
+ ## v0.2.7 — 2026-07-05
22
+
23
+ **The three authoring modes (ADR 0011 Amendment 1).** Naming how you drive draftwright —
24
+ *just do it* / *auto then tweak* / *generate an editable script* — and filling in the third:
25
+ a declarative `Sheet`-DSL generator, and number-free authoring against the build123d objects
26
+ you built.
27
+
28
+ ### Added
29
+
30
+ - **`draftwright part.step --script --style sheet`** — generate an **editable declarative
31
+ `Sheet` script** from a detected part: one commentable line per feature (hole / diameter /
32
+ step / slot / pattern / envelope), views noted, the auto-section flagged. Comment a line out
33
+ to drop that feature; the script re-runs to the drawing. The existing `--script` (imperative
34
+ edit-verb reconstruction) stays the default (#461).
35
+ - **Object-reading `.cbore(tool)` / `.spotface(tool)`** on a declared hole — read the
36
+ counterbore/spotface's ⌀ **and** depth off the tool object + part (depth measured from the
37
+ local open face, so a rib/wall elsewhere doesn't skew it). A `Sheet`-declared drawing can now
38
+ carry **zero magic numbers** and track the geometry parametrically (#462).
39
+ - **`Sheet.of(feature)`** — a fluent handle onto an **existing / detection-seeded** feature (by
40
+ the build123d object, an index, or the `Feature` itself), so you can `.fit(...)` /
41
+ `.tolerance(...)` a feature from `Sheet.from_part()` without re-declaring it (#463).
42
+
43
+ ### Docs
44
+
45
+ - **ADR 0011 Amendment 1** — the three authoring modes and the mode-3 generation surface;
46
+ records the decision to emit **honest detected numbers** for a detected part rather than
47
+ fabricate a build123d reconstruction that misrepresents the geometry (#464).
48
+
3
49
  ## v0.2.6 — 2026-07-05
4
50
 
5
51
  **ADR 0011 — the IR as a public input: declare features, don't only detect them.**
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: draftwright
3
- Version: 0.2.6
3
+ Version: 0.2.8
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
@@ -678,10 +678,13 @@ Classifier: Programming Language :: Python :: 3
678
678
  Classifier: Programming Language :: Python :: 3.10
679
679
  Classifier: Programming Language :: Python :: 3.11
680
680
  Classifier: Programming Language :: Python :: 3.12
681
+ Classifier: Programming Language :: Python :: 3.13
682
+ Classifier: Programming Language :: Python :: 3.14
681
683
  Classifier: Topic :: Scientific/Engineering
682
684
  Requires-Python: <3.15,>=3.10
683
685
  Requires-Dist: build123d-drafting-helpers>=0.13.0
684
- Requires-Dist: build123d>=0.9.0
686
+ Requires-Dist: build123d<0.11,>=0.9.0; python_full_version < '3.13'
687
+ Requires-Dist: build123d<0.12,>=0.11; python_full_version >= '3.13'
685
688
  Requires-Dist: kiwisolver<2,>=1.4
686
689
  Requires-Dist: numpy>=1.24
687
690
  Requires-Dist: reportlab>=4.0
@@ -729,7 +732,7 @@ Choose formats, scale, and page; or emit an editable drawing script:
729
732
  ```
730
733
  draftwright my_part.step --format pdf,dxf # also: svg, all
731
734
  draftwright my_part.step --scale 2 --page A3 # override the auto scale / page
732
- draftwright my_part.step --script # write an editable .py drawing script
735
+ draftwright my_part.step --script # write an editable declarative Sheet script
733
736
  ```
734
737
 
735
738
  `draftwright --help` lists every flag; `--version` prints the version.
@@ -38,7 +38,7 @@ Choose formats, scale, and page; or emit an editable drawing script:
38
38
  ```
39
39
  draftwright my_part.step --format pdf,dxf # also: svg, all
40
40
  draftwright my_part.step --scale 2 --page A3 # override the auto scale / page
41
- draftwright my_part.step --script # write an editable .py drawing script
41
+ draftwright my_part.step --script # write an editable declarative Sheet script
42
42
  ```
43
43
 
44
44
  `draftwright --help` lists every flag; `--version` prints the version.
@@ -4,14 +4,19 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "draftwright"
7
- version = "0.2.6"
7
+ version = "0.2.8"
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
12
  requires-python = ">=3.10,<3.15"
13
13
  dependencies = [
14
- "build123d>=0.9.0",
14
+ # build123d0.10 pulls cadquery-ocp (VTK), whose VTK caps at Python 3.12; 0.11 switched to
15
+ # cadquery-ocp-novtk, which ships 3.13/3.14 wheels and drops VTK entirely (draftwright is
16
+ # headless — it never imports VTK). Split by Python: keep the proven ≤3.12 stack, use 0.11 on
17
+ # 3.13+. Mirrors pzfreo/build123d-mcp's dependency handling.
18
+ "build123d>=0.9.0,<0.11 ; python_full_version < '3.13'",
19
+ "build123d>=0.11,<0.12 ; python_full_version >= '3.13'",
15
20
  "build123d-drafting-helpers>=0.13.0",
16
21
  "kiwisolver>=1.4,<2",
17
22
  "numpy>=1.24",
@@ -30,6 +35,8 @@ classifiers = [
30
35
  "Programming Language :: Python :: 3.10",
31
36
  "Programming Language :: Python :: 3.11",
32
37
  "Programming Language :: Python :: 3.12",
38
+ "Programming Language :: Python :: 3.13",
39
+ "Programming Language :: Python :: 3.14",
33
40
  ]
34
41
 
35
42
  [project.scripts]
@@ -112,3 +119,12 @@ dev = [
112
119
  "pytest-xdist>=3",
113
120
  "ruff>=0.4",
114
121
  ]
122
+
123
+ [tool.uv]
124
+ # On Python 3.13/3.14, build123d resolves to 0.11, whose OCP kernel is
125
+ # cadquery-ocp-novtk (VTK dropped from the transitive tree — fine here, draftwright
126
+ # renders headless and never imports VTK). Its 7.9.3.1.1 release ships a broken macOS
127
+ # wheel (no OCP.GccEnt module → build123d fails to import), so pin it out. Only bites
128
+ # when novtk is actually resolved (build123d 0.11); harmless on 0.10/cadquery-ocp.
129
+ # Mirrors pzfreo/build123d-mcp. Remove once upstream fixes the wheel.
130
+ constraint-dependencies = ["cadquery-ocp-novtk!=7.9.3.1.1"]
@@ -73,6 +73,18 @@ def _emit(dwg, formats: list[str]) -> list[str]:
73
73
  return [paths[f] for f in formats]
74
74
 
75
75
 
76
+ def _looks_like_object_spec(s: str) -> bool:
77
+ """True when *s* is a ``module:attr`` / ``file.py:attr`` object reference rather than a STEP
78
+ path (#469). An existing file is never a spec; a plain path — a STEP file, or a Windows
79
+ ``C:\\…\\part.step`` drive path — has no trailing ``:identifier``, so the pattern rejects it
80
+ (a Windows drive colon is followed by ``\\``, not an identifier; filenames can't contain ':')."""
81
+ import re
82
+
83
+ if os.path.exists(s):
84
+ return False
85
+ return re.match(r"^.+:[A-Za-z_][A-Za-z0-9_]*$", s) is not None
86
+
87
+
76
88
  def _installed_version() -> str:
77
89
  """The installed distribution version (the PyPI version once pip-installed);
78
90
  ``"unknown"`` when running from a source tree with no installed metadata."""
@@ -105,6 +117,12 @@ def main(
105
117
  script: bool = typer.Option(
106
118
  False, "--script", help="Write an editable .py drawing script instead of SVG+DXF"
107
119
  ),
120
+ style: str = typer.Option(
121
+ "sheet",
122
+ "--style",
123
+ help="--script flavour: 'sheet' (declarative Sheet DSL - one line per feature, "
124
+ "default) or 'imperative' (edit-verb reconstruction)",
125
+ ),
108
126
  pmi: PmiMode = typer.Option(
109
127
  PmiMode.off,
110
128
  help=(
@@ -136,6 +154,9 @@ def main(
136
154
  logging.basicConfig(level=logging.INFO if verbose else logging.WARNING, format="%(message)s")
137
155
 
138
156
  formats = _parse_formats(output_format)
157
+ if script and style not in ("imperative", "sheet"):
158
+ # validate before the ~5 s engine import so a typo fails fast
159
+ raise typer.BadParameter("--style must be 'imperative' or 'sheet'", param_hint="--style")
139
160
 
140
161
  # Import the engine lazily, only on the build path: it pulls in build123d/OCP
141
162
  # (~5 s of CAD-kernel import). Keeping it out of module scope means shell
@@ -144,17 +165,56 @@ def main(
144
165
  from draftwright.builder import build_drawing, generate_script
145
166
 
146
167
  if script:
147
- py_path = generate_script(
148
- step_file=step_file,
149
- out=out,
150
- title=title,
151
- number=number,
152
- tolerance=tolerance,
153
- drawn_by=drawn_by,
154
- pmi=pmi.value,
155
- scale=scale,
156
- page=page,
157
- )
168
+ if style == "sheet":
169
+ from draftwright.sheet_emit import generate_sheet_script, resolve_object_spec
170
+
171
+ # The Sheet DSL doesn't yet model the title-block / layout aspects the
172
+ # imperative script embeds. Warn rather than silently drop them so a
173
+ # `--script --drawn-by … --scale …` invocation isn't quietly a no-op.
174
+ ignored = [
175
+ flag
176
+ for flag, set_ in (
177
+ ("--tolerance", tolerance != "ISO 2768-m"),
178
+ ("--drawn-by", bool(drawn_by)),
179
+ ("--scale", scale is not None),
180
+ ("--page", page is not None),
181
+ )
182
+ if set_
183
+ ]
184
+ if ignored:
185
+ typer.echo(
186
+ f"warning: {', '.join(ignored)} not supported by --style sheet; ignored "
187
+ "(use --style imperative to embed them)",
188
+ err=True,
189
+ )
190
+ if _looks_like_object_spec(step_file):
191
+ # STEP_FILE is a `module:attr` / `file.py:attr` spec → reference a live object
192
+ obj, seam = resolve_object_spec(step_file)
193
+ py_path = generate_sheet_script(
194
+ obj, out=out, title=title, number=number, part_expr=seam
195
+ )
196
+ else:
197
+ py_path = generate_sheet_script(
198
+ step_file, out=out, title=title, number=number, pmi=pmi.value
199
+ )
200
+ else:
201
+ if _looks_like_object_spec(step_file):
202
+ raise typer.BadParameter(
203
+ "the imperative reconstruction reads a STEP file; use --style sheet "
204
+ "(the default) to reference a 'module:attr' object",
205
+ param_hint="--style",
206
+ )
207
+ py_path = generate_script(
208
+ step_file=step_file,
209
+ out=out,
210
+ title=title,
211
+ number=number,
212
+ tolerance=tolerance,
213
+ drawn_by=drawn_by,
214
+ pmi=pmi.value,
215
+ scale=scale,
216
+ page=page,
217
+ )
158
218
  print(py_path)
159
219
  return
160
220
 
@@ -128,6 +128,45 @@ def _read_cylinder(obj) -> tuple[str, float, Point]:
128
128
  return axis, dia, center
129
129
 
130
130
 
131
+ def read_bore_step(part, tool, axis: str) -> tuple[float, float]:
132
+ """``(diameter, depth)`` of a counterbore / spotface *tool* cut into *part* (ADR 0011 #462).
133
+
134
+ ⌀ comes from the tool's cylindrical face (like :func:`_read_cylinder`); **depth** is measured
135
+ along the hole *axis* from the part's **open face at the hole** to the tool's inner (floor)
136
+ face — so both values track the geometry you built, restating no numbers.
137
+
138
+ The open face is found *locally*: the part is intersected with an axial prism over the tool's
139
+ cross-section, so a rib / wall / boss elsewhere on the part doesn't skew the reading (the
140
+ global bounding box would). The counterbore opens on whichever end of that local column the
141
+ tool sits nearer, even when the tool overhangs the part."""
142
+ from build123d import Box, Pos
143
+
144
+ axis = _norm_axis(axis)
145
+ dia = _read_cylinder(tool)[1]
146
+ i = "xyz".index(axis)
147
+ tb, pb = tool.bounding_box(), part.bounding_box()
148
+ t_lo, t_hi = [tb.min.X, tb.min.Y, tb.min.Z][i], [tb.max.X, tb.max.Y, tb.max.Z][i]
149
+
150
+ # A prism over the tool's cross-section, spanning the part along the axis; intersect it with
151
+ # the part to get the LOCAL material column under the tool (the open face is its axial edge).
152
+ size = [tb.size.X, tb.size.Y, tb.size.Z]
153
+ cen = [tb.center().X, tb.center().Y, tb.center().Z]
154
+ size[i] = [pb.size.X, pb.size.Y, pb.size.Z][i] * 3 or 1.0
155
+ cen[i] = [pb.center().X, pb.center().Y, pb.center().Z][i]
156
+ try:
157
+ column = part & (Pos(*cen) * Box(*size))
158
+ cb = column.bounding_box()
159
+ p_lo, p_hi = [cb.min.X, cb.min.Y, cb.min.Z][i], [cb.max.X, cb.max.Y, cb.max.Z][i]
160
+ except Exception: # noqa: BLE001 — degenerate boolean; fall back to the global bbox
161
+ p_lo, p_hi = [pb.min.X, pb.min.Y, pb.min.Z][i], [pb.max.X, pb.max.Y, pb.max.Z][i]
162
+
163
+ if (t_lo + t_hi) / 2 >= (p_lo + p_hi) / 2:
164
+ depth = p_hi - t_lo # floor at the tool bottom, open at the max face
165
+ else:
166
+ depth = t_hi - p_lo # floor at the tool top, open at the min face
167
+ return (round(dia, 3), round(depth, 3))
168
+
169
+
131
170
  def _span(at: Point, axis: str, length: float) -> tuple[Point, Point]:
132
171
  """The two axial end-points of a segment of *length* centred at *at* along *axis*."""
133
172
  idx = "xyz".index(axis)
@@ -15,15 +15,17 @@ detection is skipped and the auto-pass dimensions exactly the declared features:
15
15
  **Scope (this module):** the *feature-declaration* surface over the renderers the
16
16
  engine has today — dimensions, ⌀ callouts, holes (through / blind), turned steps,
17
17
  slots, patterns, the overall envelope, and the auto section — plus the P2a
18
- **``.tolerance``** aspect (a ± / limit tolerance on a diameter, a step, or a hole
19
- bore). The remaining #445 aspect verbs that still need new rendering — ``.fit``
20
- (fit-class ISO 286 deviation), ``.thread``, ``.finish`` (surface symbols) and
18
+ **``.tolerance``** (a ± / limit tolerance on a diameter, a step, or a hole bore) and
19
+ **``.fit``** (fit-class → ISO 286 deviation, P2a.2) aspects. The remaining #445 aspect
20
+ verbs that still need new rendering — ``.thread``, ``.finish`` (surface symbols) and
21
21
  ``control(...)`` (GD&T) — are the later Phase-2 items (roadmap #446) and are
22
22
  deliberately **not** stubbed here, so the surface only exposes what actually draws.
23
23
 
24
24
  **Hybrid.** :meth:`Sheet.from_part` seeds the declared set from *detection*, so you
25
25
  can start from the detected model and override specific features (declaration is for
26
- where you know better than detection, not everywhere — ADR 0011 §3).
26
+ where you know better than detection, not everywhere — ADR 0011 §3); :meth:`Sheet.of`
27
+ returns a fluent handle onto one of those generated features (by object, index, or the
28
+ feature itself) so you can ``.fit(...)`` / ``.tolerance(...)`` it without re-declaring (#463).
27
29
  """
28
30
 
29
31
  from __future__ import annotations
@@ -33,12 +35,15 @@ from dataclasses import replace
33
35
  from draftwright.analysis import _solids_body
34
36
  from draftwright.builder import _coerce_model, build_drawing, detect_part_model
35
37
  from draftwright.fits import fit_class
38
+ from draftwright.model import Feature
36
39
  from draftwright.model import boss as _boss
37
40
  from draftwright.model import envelope as _envelope
38
41
  from draftwright.model import hole as _hole
39
42
  from draftwright.model import pattern as _pattern
40
43
  from draftwright.model import slot as _slot
41
44
  from draftwright.model import step as _step
45
+ from draftwright.model.declare import _norm_axis, _read_cylinder, _require_positive
46
+ from draftwright.model.declare import read_bore_step as _read_bore_step
42
47
 
43
48
 
44
49
  def _parse_scale(scale):
@@ -98,6 +103,34 @@ class _Hole:
98
103
  )
99
104
  return self
100
105
 
106
+ def cbore(
107
+ self, obj=None, *, diameter: float | None = None, depth: float | None = None
108
+ ) -> _Hole:
109
+ """A counterbore on this hole. ``.cbore(cbore_cyl)`` reads its ⌀ + depth off the
110
+ counterbore tool object (⌀ from the cylindrical face, depth from the part + tool along
111
+ the hole axis — no numbers restated), or pass explicit ``.cbore(diameter=…, depth=…)``.
112
+ An object supplies defaults; explicit kwargs override (#462)."""
113
+ return self._set(cbore=self._read_step("cbore", obj, diameter, depth))
114
+
115
+ def spotface(
116
+ self, obj=None, *, diameter: float | None = None, depth: float | None = None
117
+ ) -> _Hole:
118
+ """A spotface on this hole — same as :meth:`cbore` but a shallow facing (#462)."""
119
+ return self._set(spotface=self._read_step("spotface", obj, diameter, depth))
120
+
121
+ def _read_step(self, kind, obj, diameter, depth) -> tuple[float, float]:
122
+ if obj is not None:
123
+ rd, rdp = _read_bore_step(
124
+ self._sheet._part, obj, self._sheet._features[self._i].frame.axis
125
+ )
126
+ diameter = rd if diameter is None else diameter
127
+ depth = rdp if depth is None else depth
128
+ if diameter is None or depth is None:
129
+ raise ValueError(f"{kind} needs a tool object, or explicit diameter= and depth=")
130
+ # same positivity guard declare.hole() applies to cbore/spotface (#452/#462 review)
131
+ _require_positive(**{f"{kind} diameter": diameter, f"{kind} depth": depth})
132
+ return (diameter, depth)
133
+
101
134
  def _set(self, **kw) -> _Hole:
102
135
  self._sheet._features[self._i] = replace(self._sheet._features[self._i], **kw)
103
136
  return self
@@ -168,6 +201,57 @@ class Sheet:
168
201
  self._features.append(feature)
169
202
  return self
170
203
 
204
+ def of(self, ref) -> _Hole | _Dim:
205
+ """A decoratable handle onto an **existing** feature — the hybrid seam (#463).
206
+
207
+ *ref* is a feature index, a :class:`Feature` already in :attr:`features` (e.g. seeded by
208
+ :meth:`from_part`), or the build123d **object** you built (matched by ⌀ + in-plane
209
+ position). Returns the same fluent handle the declaration verbs do, so you can
210
+ ``.fit(...)`` / ``.tolerance(...)`` — and, for a hole, ``.cbore(...)`` — a feature you
211
+ did not declare from scratch. Raises if the object matches no feature or is ambiguous."""
212
+ i = self._index_of(ref)
213
+ kind = self._features[i].kind
214
+ if kind == "hole":
215
+ return _Hole(self, i)
216
+ if kind in ("boss", "step"):
217
+ return _Dim(self, i, "diameter" if kind == "boss" else "length")
218
+ raise ValueError(f"of(): no aspect handle for a {kind!r} feature (holes / bosses / steps)")
219
+
220
+ def _index_of(self, ref) -> int:
221
+ if isinstance(ref, bool):
222
+ raise TypeError("of(): ref must be an index, a Feature, or a build123d object")
223
+ if isinstance(ref, int):
224
+ n = len(self._features)
225
+ if not -n <= ref < n:
226
+ raise IndexError(f"of(): feature index {ref} out of range (have {n})")
227
+ return ref % n
228
+ if isinstance(ref, Feature):
229
+ for i, f in enumerate(self._features):
230
+ if f is ref:
231
+ return i
232
+ raise ValueError("of(): that Feature is not in this sheet's features")
233
+ return self._match_object(ref)
234
+
235
+ def _match_object(self, obj) -> int:
236
+ """The index of the declared feature the build123d *obj* refers to, by axis + ⌀ + the
237
+ two in-plane coordinates (the axial position is where they legitimately differ)."""
238
+ axis, dia, center = _read_cylinder(obj)
239
+ axis = _norm_axis(axis)
240
+ perp = [k for k in range(3) if k != "xyz".index(axis)]
241
+ matches = [
242
+ i
243
+ for i, f in enumerate(self._features)
244
+ if getattr(f, "diameter", None) is not None
245
+ and _norm_axis(f.frame.axis) == axis # same axis — a cross-hole must not match
246
+ and abs(f.diameter - dia) <= 0.2
247
+ and all(abs(f.frame.origin[k] - center[k]) <= 0.5 for k in perp)
248
+ ]
249
+ if not matches:
250
+ raise ValueError("of(): no declared feature matches that object (⌀ + position)")
251
+ if len(matches) > 1:
252
+ raise ValueError("of(): the object matches several features — pass an index instead")
253
+ return matches[0]
254
+
171
255
  def hole(self, obj=None, **kw) -> _Hole:
172
256
  """Declare a hole from the tool cylinder you subtracted (or explicit values).
173
257
  Returns a fluent handle: ``.through()`` (default) / ``.depth(d)``."""