draftwright 0.2.6__tar.gz → 0.2.7__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 (95) hide show
  1. {draftwright-0.2.6 → draftwright-0.2.7}/CHANGELOG.md +28 -0
  2. {draftwright-0.2.6 → draftwright-0.2.7}/PKG-INFO +1 -1
  3. {draftwright-0.2.6 → draftwright-0.2.7}/pyproject.toml +1 -1
  4. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/cli.py +27 -11
  5. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/model/declare.py +39 -0
  6. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/sheet_dsl.py +85 -1
  7. draftwright-0.2.7/src/draftwright/sheet_emit.py +191 -0
  8. draftwright-0.2.7/tests/test_object_aspects.py +89 -0
  9. draftwright-0.2.7/tests/test_sheet_emit.py +199 -0
  10. draftwright-0.2.7/tests/test_sheet_of.py +81 -0
  11. {draftwright-0.2.6 → draftwright-0.2.7}/.gitignore +0 -0
  12. {draftwright-0.2.6 → draftwright-0.2.7}/LICENSE +0 -0
  13. {draftwright-0.2.6 → draftwright-0.2.7}/README.md +0 -0
  14. {draftwright-0.2.6 → draftwright-0.2.7}/skills/SKILL.md +0 -0
  15. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/__init__.py +0 -0
  16. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/_core.py +0 -0
  17. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/analysis.py +0 -0
  18. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/annotate.py +0 -0
  19. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/annotations/__init__.py +0 -0
  20. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/annotations/_common.py +0 -0
  21. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/annotations/from_model.py +0 -0
  22. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/annotations/holes.py +0 -0
  23. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/annotations/orchestrator.py +0 -0
  24. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/annotations/sections.py +0 -0
  25. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/builder.py +0 -0
  26. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/drawing.py +0 -0
  27. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/export.py +0 -0
  28. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/features.py +0 -0
  29. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/fits.py +0 -0
  30. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
  31. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
  32. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
  33. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
  34. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/fonts/__init__.py +0 -0
  35. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/intents.py +0 -0
  36. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/layout.py +0 -0
  37. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/linting/__init__.py +0 -0
  38. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/linting/coverage.py +0 -0
  39. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/linting/issues.py +0 -0
  40. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/linting/structural.py +0 -0
  41. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/linting/suggest.py +0 -0
  42. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/make_drawing.py +0 -0
  43. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/model/__init__.py +0 -0
  44. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/model/detect.py +0 -0
  45. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/model/ir.py +0 -0
  46. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/model/planner.py +0 -0
  47. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/pmi.py +0 -0
  48. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/projection.py +0 -0
  49. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/recognition/__init__.py +0 -0
  50. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/recognition/_features.py +0 -0
  51. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/recognition/levels.py +0 -0
  52. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/recognition/slots.py +0 -0
  53. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/recognition/turned.py +0 -0
  54. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/registry.py +0 -0
  55. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/repair.py +0 -0
  56. {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/sheet.py +0 -0
  57. {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
  58. {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
  59. {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
  60. {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
  61. {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
  62. {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
  63. {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
  64. {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
  65. {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
  66. {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
  67. {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/box.json +0 -0
  68. {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/bracket.json +0 -0
  69. {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/drive_screw_x.json +0 -0
  70. {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/dshape.json +0 -0
  71. {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/flange.json +0 -0
  72. {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/holed_slot.json +0 -0
  73. {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/plate_holes.json +0 -0
  74. {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/side_drilled.json +0 -0
  75. {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/slotted.json +0 -0
  76. {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/turned_shaft.json +0 -0
  77. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_declare.py +0 -0
  78. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_e2e_slice.py +0 -0
  79. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_e2e_standards.py +0 -0
  80. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_fits.py +0 -0
  81. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_layout.py +0 -0
  82. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_layout_cleanliness.py +0 -0
  83. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_layout_property.py +0 -0
  84. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_layout_snapshot.py +0 -0
  85. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_lint_structural.py +0 -0
  86. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_linting.py +0 -0
  87. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_make_drawing.py +0 -0
  88. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_part_model.py +0 -0
  89. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_pmi.py +0 -0
  90. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_recognition.py +0 -0
  91. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_registry.py +0 -0
  92. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_render_seam.py +0 -0
  93. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_strip_layout.py +0 -0
  94. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_tolerances.py +0 -0
  95. {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_turned_steps.py +0 -0
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.2.7 — 2026-07-05
4
+
5
+ **The three authoring modes (ADR 0011 Amendment 1).** Naming how you drive draftwright —
6
+ *just do it* / *auto then tweak* / *generate an editable script* — and filling in the third:
7
+ a declarative `Sheet`-DSL generator, and number-free authoring against the build123d objects
8
+ you built.
9
+
10
+ ### Added
11
+
12
+ - **`draftwright part.step --script --style sheet`** — generate an **editable declarative
13
+ `Sheet` script** from a detected part: one commentable line per feature (hole / diameter /
14
+ step / slot / pattern / envelope), views noted, the auto-section flagged. Comment a line out
15
+ to drop that feature; the script re-runs to the drawing. The existing `--script` (imperative
16
+ edit-verb reconstruction) stays the default (#461).
17
+ - **Object-reading `.cbore(tool)` / `.spotface(tool)`** on a declared hole — read the
18
+ counterbore/spotface's ⌀ **and** depth off the tool object + part (depth measured from the
19
+ local open face, so a rib/wall elsewhere doesn't skew it). A `Sheet`-declared drawing can now
20
+ carry **zero magic numbers** and track the geometry parametrically (#462).
21
+ - **`Sheet.of(feature)`** — a fluent handle onto an **existing / detection-seeded** feature (by
22
+ the build123d object, an index, or the `Feature` itself), so you can `.fit(...)` /
23
+ `.tolerance(...)` a feature from `Sheet.from_part()` without re-declaring it (#463).
24
+
25
+ ### Docs
26
+
27
+ - **ADR 0011 Amendment 1** — the three authoring modes and the mode-3 generation surface;
28
+ records the decision to emit **honest detected numbers** for a detected part rather than
29
+ fabricate a build123d reconstruction that misrepresents the geometry (#464).
30
+
3
31
  ## v0.2.6 — 2026-07-05
4
32
 
5
33
  **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.7
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.6"
7
+ version = "0.2.7"
8
8
  description = "Automated technical-drawing generation for build123d"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -105,6 +105,12 @@ def main(
105
105
  script: bool = typer.Option(
106
106
  False, "--script", help="Write an editable .py drawing script instead of SVG+DXF"
107
107
  ),
108
+ style: str = typer.Option(
109
+ "imperative",
110
+ "--style",
111
+ help="--script flavour: 'imperative' (edit-verb reconstruction) or "
112
+ "'sheet' (declarative Sheet DSL - one line per feature)",
113
+ ),
108
114
  pmi: PmiMode = typer.Option(
109
115
  PmiMode.off,
110
116
  help=(
@@ -136,6 +142,9 @@ def main(
136
142
  logging.basicConfig(level=logging.INFO if verbose else logging.WARNING, format="%(message)s")
137
143
 
138
144
  formats = _parse_formats(output_format)
145
+ if script and style not in ("imperative", "sheet"):
146
+ # validate before the ~5 s engine import so a typo fails fast
147
+ raise typer.BadParameter("--style must be 'imperative' or 'sheet'", param_hint="--style")
139
148
 
140
149
  # Import the engine lazily, only on the build path: it pulls in build123d/OCP
141
150
  # (~5 s of CAD-kernel import). Keeping it out of module scope means shell
@@ -144,17 +153,24 @@ def main(
144
153
  from draftwright.builder import build_drawing, generate_script
145
154
 
146
155
  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
- )
156
+ if style == "sheet":
157
+ from draftwright.sheet_emit import generate_sheet_script
158
+
159
+ py_path = generate_sheet_script(
160
+ step_file, out=out, title=title, number=number, pmi=pmi.value
161
+ )
162
+ else:
163
+ py_path = generate_script(
164
+ step_file=step_file,
165
+ out=out,
166
+ title=title,
167
+ number=number,
168
+ tolerance=tolerance,
169
+ drawn_by=drawn_by,
170
+ pmi=pmi.value,
171
+ scale=scale,
172
+ page=page,
173
+ )
158
174
  print(py_path)
159
175
  return
160
176
 
@@ -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)
@@ -23,7 +23,9 @@ deliberately **not** stubbed here, so the surface only exposes what actually dra
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)``."""
@@ -0,0 +1,191 @@
1
+ """sheet_emit — the declarative ``Sheet``-DSL emitter (ADR 0011 Amendment 1, #461).
2
+
3
+ Mode 3 of the three authoring modes: *generate an editable beautiful-Python script*. Walk a
4
+ **detected** :class:`PartModel` and print a :class:`~draftwright.Sheet` script — one commentable
5
+ line per feature — that the user edits / comments-out / extends, then re-runs.
6
+
7
+ **Detected input only writes numbers (the part-seam form, ADR 0011 Amdt 1 decision).** For a STEP
8
+ file or a recovered solid the number *is* the ground truth, so a detected value is honest. We never
9
+ fabricate a build123d part to chase a number-free layer — a synthesised solid silently drops what
10
+ detection didn't model (chamfers, fillets, turned profiles) yet reads as authoritative. A caller who
11
+ *has* the objects (mode 3b) wires their real part into the seam and swaps the number lines for
12
+ ``sheet.hole(obj)`` references — the emitter's numbers are a starting point, not a ceiling.
13
+
14
+ Kinds with no declarative verb yet (``step_level``/``rotational``/``pmi``) are flagged inline as
15
+ auto-dimensioned — never silently dropped. Fidelity: the script reproduces a lint-clean drawing of
16
+ the same features (the auto-pass runs over the declared model), not a byte copy of the detected sheet.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+
23
+ from build123d import Shape
24
+
25
+ from draftwright.builder import detect_part_model
26
+
27
+
28
+ def _n(v) -> float | int:
29
+ """A tidy number: int when integral, else rounded to 3 dp."""
30
+ v = round(float(v), 3)
31
+ return int(v) if v == int(v) else v
32
+
33
+
34
+ def _pt(p) -> str:
35
+ return "(" + ", ".join(str(_n(c)) for c in p) + ")"
36
+
37
+
38
+ def _hole_line(f) -> str:
39
+ kw = [f"diameter={_n(f.diameter)}", f"at={_pt(f.frame.origin)}", f'axis="{f.frame.axis}"']
40
+ if f.count and f.count > 1:
41
+ kw.append(f"count={f.count}")
42
+ # A count-group carries its member positions; without them the render collapses to a
43
+ # single hole at the anchor (fidelity loss). Patterns recompute members from the
44
+ # arrangement, so only a plain count-group needs them spelled out.
45
+ if f.members:
46
+ kw.append("members=[" + ", ".join(_pt(m) for m in f.members) + "]")
47
+ if f.cbore:
48
+ kw.append(f"cbore=({_n(f.cbore[0])}, {_n(f.cbore[1])})")
49
+ if f.spotface:
50
+ kw.append(f"spotface=({_n(f.spotface[0])}, {_n(f.spotface[1])})")
51
+ line = f"sheet.hole({', '.join(kw)})"
52
+ if not f.through and f.depth is not None:
53
+ line += f".depth({_n(f.depth)})"
54
+ return line
55
+
56
+
57
+ def _member_hole_str(m) -> str:
58
+ """The ``hole(...)`` template for a pattern member — carries its ⌀ AND its
59
+ counterbore / spotface / blind-depth so a counterbored bolt circle keeps those
60
+ callouts on re-run (declare.hole takes depth=/through=/cbore=/spotface= kwargs)."""
61
+ kw = [f"diameter={_n(m.diameter)}", f"at={_pt(m.frame.origin)}", f'axis="{m.frame.axis}"']
62
+ if m.cbore:
63
+ kw.append(f"cbore=({_n(m.cbore[0])}, {_n(m.cbore[1])})")
64
+ if m.spotface:
65
+ kw.append(f"spotface=({_n(m.spotface[0])}, {_n(m.spotface[1])})")
66
+ if not m.through and m.depth is not None:
67
+ kw.append(f"depth={_n(m.depth)}")
68
+ kw.append("through=False")
69
+ return f"hole({', '.join(kw)})"
70
+
71
+
72
+ def _feature_line(f) -> str:
73
+ k = f.kind
74
+ if k == "envelope":
75
+ return f"sheet.envelope() # {_n(f.width)} × {_n(f.height)} × {_n(f.depth)}"
76
+ if k == "hole":
77
+ return _hole_line(f)
78
+ if k == "boss":
79
+ return f'sheet.diameter(diameter={_n(f.diameter)}, at={_pt(f.frame.origin)}, axis="{f.frame.axis}")'
80
+ if k == "step":
81
+ return (
82
+ f"sheet.step(diameter={_n(f.diameter)}, length={_n(f.length)}, "
83
+ f'at={_pt(f.frame.origin)}, axis="{f.frame.axis}")'
84
+ )
85
+ if k == "slot":
86
+ lo, hi = _n(f.lo), _n(f.hi)
87
+ # Derive length from the EMITTED lo/hi so hi - lo == length exactly — declare.slot()
88
+ # rejects the recogniser's independently-rounded (lo, hi, length) with a 1e-6 tolerance.
89
+ length = _n(round(float(hi) - float(lo), 3))
90
+ return (
91
+ f"sheet.slot(width={_n(f.width)}, length={length}, "
92
+ f'long_axis="{f.long_axis}", width_axis="{f.width_axis}", '
93
+ f"lo={lo}, hi={hi}, w_center={_n(f.w_center)})"
94
+ )
95
+ if k == "pattern":
96
+ # Defining dims for the furniture (BCD centreline / pitch / grid dims) PLUS the exact
97
+ # member positions. The arrangement alone can't be recomputed faithfully — the
98
+ # detector records no bolt-circle START ANGLE (nor a linear direction reliably) — so
99
+ # spelling out members= is the only fidelity-safe form (declare uses them as-is).
100
+ parts = [f'kind="{f.pattern}"', f"count={f.count}"]
101
+ if f.pattern == "bolt_circle" and f.bcd:
102
+ parts.append(f"bcd={_n(f.bcd)}")
103
+ elif f.pattern == "linear" and f.pitch:
104
+ parts.append(f"pitch={_n(f.pitch)}")
105
+ elif f.pattern == "grid" and f.grid:
106
+ parts.append(f"grid=({_n(f.grid[0])}, {_n(f.grid[1])}), rows={f.rows}, cols={f.cols}")
107
+ if f.members:
108
+ parts.append("members=[" + ", ".join(_pt(p) for p in f.members) + "]")
109
+ return f"sheet.pattern({_member_hole_str(f.member)}, " + ", ".join(parts) + ")"
110
+ # kinds with no declarative verb yet — the auto-pass still dimensions them.
111
+ return f"# {k} @ {_pt(f.frame.origin)} — auto-dimensioned (no declarative verb yet)"
112
+
113
+
114
+ def _needs_section(model) -> bool:
115
+ return any(
116
+ f.kind == "hole" and f.frame.axis == "z" and (f.cbore or f.spotface or not f.through)
117
+ for f in model.features
118
+ )
119
+
120
+
121
+ _HEADER = '''"""Editable drawing — generated by draftwright (declarative Sheet DSL).
122
+
123
+ Each line below declares one feature. Comment a line out to drop that feature; edit a
124
+ value freely; chain .tolerance(lo, hi) / .fit("H7") onto any diameter. Then re-run this file.
125
+
126
+ The values are DETECTED off the geometry (honest for a STEP / recovered solid). If you
127
+ built the part yourself, wire your object into the `part = …` seam and swap a numbered
128
+ line for a reference — e.g. sheet.hole(my_bore) — to read the size off the object.
129
+ """'''
130
+
131
+
132
+ def emit_sheet_script(model, part_expr: str, stem: str, *, title: str, number: str) -> str:
133
+ """The generated declarative ``Sheet`` script text for a detected *model*.
134
+
135
+ *part_expr* is the Python that binds ``part`` (a STEP ``import_step`` or a ``part = …``
136
+ seam); *stem* is the output basename the script exports to."""
137
+ needs_hole = any(f.kind in ("hole", "pattern") for f in model.features)
138
+ lines = [
139
+ _HEADER,
140
+ "from draftwright import Sheet",
141
+ *(["from draftwright.model import hole"] if needs_hole else []),
142
+ "",
143
+ part_expr,
144
+ "",
145
+ f"sheet = Sheet(part, title={title!r}, number={number!r})",
146
+ "",
147
+ "# ── Features (each line is one declared feature) ──────────────────────────────",
148
+ *(_feature_line(f) for f in model.features),
149
+ "",
150
+ "# ── Views ─────────────────────────────────────────────────────────────────────",
151
+ "# front / plan / side / iso are always produced.",
152
+ ]
153
+ if _needs_section(model):
154
+ lines.append("# Section A–A auto-triggers from the counterbore/blind bore above.")
155
+ lines += ["", f"sheet.export({stem!r})"]
156
+ return "\n".join(lines) + "\n"
157
+
158
+
159
+ def generate_sheet_script(
160
+ step_file: str | Shape,
161
+ out: str | None = None,
162
+ *,
163
+ title: str | None = None,
164
+ number: str = "DWG-001",
165
+ pmi: str = "off",
166
+ ) -> str:
167
+ """Write a declarative ``Sheet``-DSL script for *step_file* (a STEP path or a build123d
168
+ object). Returns the path to the generated ``.py``. The mode-3 declarative counterpart of
169
+ :func:`draftwright.builder.generate_script` (which emits the imperative reconstruction).
170
+
171
+ ``pmi`` is threaded to detection so AP242 PMI features surface (flagged inline)."""
172
+ is_shape = isinstance(step_file, Shape)
173
+ stem = out or ("drawing" if is_shape else Path(step_file).stem)
174
+ for _ext in (".py", ".svg", ".dxf"):
175
+ if stem.endswith(_ext):
176
+ stem = stem[: -len(_ext)]
177
+ break
178
+ title = title or (Path(stem).name.replace("_", " ").upper() if not is_shape else "DRAWING")
179
+
180
+ if is_shape:
181
+ part_expr = "part = ... # ← wire in your build123d object (built above)"
182
+ else:
183
+ # absolute so the generated script runs from any working directory
184
+ abspath = str(Path(step_file).resolve())
185
+ part_expr = f"from build123d import import_step\npart = import_step({abspath!r})"
186
+
187
+ model = detect_part_model(step_file, pmi=pmi)
188
+ script = emit_sheet_script(model, part_expr, stem, title=title, number=number)
189
+ py_path = f"{stem}.py"
190
+ Path(py_path).write_text(script, encoding="utf-8") # the script has box-drawing / × / ← glyphs
191
+ return py_path
@@ -0,0 +1,89 @@
1
+ """Object-reading aspect verbs — the number-free 3b layer (ADR 0011 #462).
2
+
3
+ `.cbore(tool)` / `.spotface(tool)` read a counterbore/spotface's ⌀ + depth off the tool
4
+ object and the part, so the drawing layer restates no numbers and tracks the geometry.
5
+ """
6
+
7
+ import pytest
8
+ from build123d import Box, Cylinder, Pos
9
+
10
+ from draftwright import Sheet
11
+ from draftwright.model.declare import read_bore_step
12
+
13
+
14
+ class TestReadBoreStep:
15
+ def test_top_counterbore_reads_diameter_and_depth(self):
16
+ part = Box(100, 70, 24) - Pos(0, 0, 0) * Cylinder(9, 40) - Pos(0, 0, 8) * Cylinder(15, 20)
17
+ tool = Pos(0, 0, 8) * Cylinder(15, 20) # ⌀30, overhangs the z=12 top → depth 14
18
+ assert read_bore_step(part, tool, "z") == pytest.approx((30.0, 14.0))
19
+
20
+ def test_depth_measured_from_the_near_face_both_orientations(self):
21
+ part = Box(40, 40, 20) # z spans -10..10
22
+ top = Pos(0, 0, 6) * Cylinder(8, 12) # sits high → opens at +z (10); floor at 0 → depth 10
23
+ bot = Pos(0, 0, -6) * Cylinder(
24
+ 8, 12
25
+ ) # sits low → opens at -z (-10); floor at 0 → depth 10
26
+ assert read_bore_step(part, top, "z")[1] == pytest.approx(10.0)
27
+ assert read_bore_step(part, bot, "z")[1] == pytest.approx(10.0)
28
+
29
+ def test_diameter_tracks_the_tool_parametrically(self):
30
+ part = Box(60, 60, 20)
31
+ assert read_bore_step(part, Pos(0, 0, 6) * Cylinder(10, 12), "z")[0] == pytest.approx(20.0)
32
+ assert read_bore_step(part, Pos(0, 0, 6) * Cylinder(12, 12), "z")[0] == pytest.approx(24.0)
33
+
34
+ def test_depth_is_local_not_global_bbox(self):
35
+ # #462 review: a rib/wall elsewhere on the axis must NOT skew the depth — the open face
36
+ # is found from the LOCAL part column under the tool, not the whole part bounding box.
37
+ base = Pos(0, 0, 2.5) * Box(60, 30, 5) # plate z 0..5
38
+ wall = Pos(-25, 0, 25) * Box(10, 30, 50) # a wall rising to z 50, away from the hole
39
+ tool = Pos(0, 0, 3.5) * Cylinder(7.5, 3) # counterbore z 2..5 → true depth 3
40
+ lbr = (base + wall) - Pos(0, 0, 0) * Cylinder(3, 10) - tool
41
+ assert read_bore_step(lbr, tool, "z") == pytest.approx((15.0, 3.0)) # not (15, 5)
42
+
43
+
44
+ class TestCboreVerb:
45
+ def _plate(self):
46
+ plate = Box(100, 70, 24)
47
+ bore = Pos(0, 0, 0) * Cylinder(9, 40)
48
+ cbore = Pos(0, 0, 8) * Cylinder(15, 20)
49
+ return (plate - bore - cbore), bore, cbore
50
+
51
+ def test_number_free_counterbore_renders_lint_clean(self):
52
+ part, bore, cbore = self._plate()
53
+ s = Sheet(part, title="MP")
54
+ s.envelope()
55
+ s.hole(bore).cbore(cbore) # ⌀ + depth read off the objects — no numbers
56
+ dwg = s.build()
57
+ assert s.features[1].cbore == pytest.approx((30.0, 14.0))
58
+ assert [i for i in dwg.lint() if i.severity in ("warning", "error")] == []
59
+
60
+ def test_explicit_kwargs_override_the_object_read(self):
61
+ part, bore, cbore = self._plate()
62
+ s = Sheet(part)
63
+ s.hole(bore).cbore(cbore, depth=6) # keep the read ⌀, override the depth
64
+ assert s.features[0].cbore == pytest.approx((30.0, 6.0))
65
+
66
+ def test_explicit_values_without_an_object(self):
67
+ s = Sheet(Box(40, 40, 10))
68
+ s.hole(diameter=6, at=(0, 0, 0), axis="z").cbore(diameter=12, depth=4)
69
+ assert s.features[0].cbore == (12, 4)
70
+
71
+ def test_needs_object_or_explicit_values(self):
72
+ s = Sheet(Box(40, 40, 10))
73
+ with pytest.raises(ValueError):
74
+ s.hole(diameter=6, at=(0, 0, 0), axis="z").cbore()
75
+
76
+ def test_rejects_nonpositive_cbore(self):
77
+ # #462 review: match declare.hole()'s positivity guard — a bad explicit value fails loud
78
+ s = Sheet(Box(40, 40, 10))
79
+ with pytest.raises(ValueError):
80
+ s.hole(diameter=6, at=(0, 0, 0), axis="z").cbore(diameter=-5, depth=6)
81
+ with pytest.raises(ValueError):
82
+ s.hole(diameter=6, at=(0, 0, 0), axis="z").cbore(diameter=12, depth=0)
83
+
84
+ def test_spotface_reads_off_the_tool(self):
85
+ part = Box(60, 60, 20)
86
+ sf = Pos(0, 0, 8) * Cylinder(12, 6) # ⌀24, opens at +z(10), floor at 5 → depth 5
87
+ s = Sheet(part - Pos(0, 0, 0) * Cylinder(4, 40) - sf)
88
+ s.hole(Pos(0, 0, 0) * Cylinder(4, 40)).spotface(sf)
89
+ assert s.features[0].spotface == pytest.approx((24.0, 5.0))
@@ -0,0 +1,199 @@
1
+ """The declarative Sheet-DSL emitter (ADR 0011 Amendment 1, #461).
2
+
3
+ Generates a `Sheet(...)` script from a detected part — one commentable line per feature.
4
+ Detected input only writes numbers (the part-seam form); we never fabricate geometry.
5
+ """
6
+
7
+ import ast
8
+ import math
9
+ import os
10
+
11
+ from build123d import Box, Cylinder, Pos, export_step
12
+
13
+ from draftwright.builder import detect_part_model
14
+ from draftwright.sheet_emit import emit_sheet_script, generate_sheet_script
15
+
16
+
17
+ def _plate():
18
+ return Box(80, 50, 8) - Pos(20, 10, 4) * Cylinder(4, 20) - Pos(-20, 10, 4) * Cylinder(4, 20)
19
+
20
+
21
+ def _script_for(part, part_expr="part = PART", stem="drawing", **kw):
22
+ return emit_sheet_script(detect_part_model(part), part_expr, stem, title="T", number="N", **kw)
23
+
24
+
25
+ class TestEmit:
26
+ def test_emits_one_declarative_line_per_feature(self):
27
+ src = _script_for(_plate())
28
+ assert "sheet = Sheet(part, title='T', number='N')" in src
29
+ assert "sheet.hole(diameter=8" in src # the ⌀8 holes
30
+ assert "sheet.envelope()" in src
31
+ assert src.rstrip().endswith("sheet.export('drawing')")
32
+
33
+ def test_count_group_hole_carries_its_members(self):
34
+ # a count>1 hole MUST emit members= with every position — without them the render
35
+ # collapses to a single hole at the anchor (fidelity loss). The plate has two ⌀8 holes.
36
+ line = next(
37
+ ln
38
+ for ln in _script_for(_plate()).splitlines()
39
+ if ln.startswith("sheet.hole(diameter=8")
40
+ )
41
+ call = ast.parse(line, mode="eval").body # the sheet.hole(...) Call node
42
+ kw = {k.arg: k.value for k in call.keywords}
43
+ assert ast.literal_eval(kw["count"]) == 2
44
+ assert len(kw["members"].elts) == 2 # both hole positions spelled out
45
+
46
+ def test_output_is_valid_python(self):
47
+ # the whole emitted script must parse — a generated script that doesn't is useless
48
+ ast.parse(_script_for(_plate()))
49
+
50
+ def test_counterbore_flags_the_auto_section(self):
51
+ part = Box(60, 60, 16) - Pos(0, 0, 0) * Cylinder(4, 30) - Pos(0, 0, 4) * Cylinder(8, 12)
52
+ src = _script_for(part)
53
+ assert "cbore=(" in src # the counterbore rides the hole line
54
+ assert "Section A–A auto-triggers" in src
55
+
56
+ def test_blind_hole_gets_depth(self):
57
+ part = Box(40, 40, 20) - Pos(0, 0, 6) * Cylinder(4, 16) # blind ⌀8
58
+ src = _script_for(part)
59
+ assert ".depth(" in src
60
+
61
+ def _bolt_circle(self, cbore=False):
62
+ part = Cylinder(40, 8)
63
+ for i in range(6):
64
+ a = i * math.pi / 3
65
+ c = Pos(25 * math.cos(a), 25 * math.sin(a), 0)
66
+ part -= c * Cylinder(3, 20)
67
+ if cbore:
68
+ part -= c * Pos(0, 0, 4) * Cylinder(5, 8)
69
+ return part
70
+
71
+ def test_pattern_emits_the_pattern_verb(self):
72
+ src = _script_for(self._bolt_circle())
73
+ assert "sheet.pattern(hole(" in src and 'kind="bolt_circle"' in src
74
+
75
+ def test_bolt_circle_spells_out_members(self):
76
+ # #461 review r2: the detector records no start ANGLE, so recomputing members at angle 0
77
+ # rotates the holes — the emitter must spell out the real member positions.
78
+ line = next(
79
+ ln
80
+ for ln in _script_for(self._bolt_circle()).splitlines()
81
+ if ln.startswith("sheet.pattern(")
82
+ )
83
+ assert "members=[" in line and line.count("(") >= 7 # member hole + 6 positions
84
+
85
+ def test_pattern_member_keeps_its_counterbore(self):
86
+ # #461 review r2: a counterbored bolt circle must keep the member's cbore on re-run
87
+ line = next(
88
+ ln
89
+ for ln in _script_for(self._bolt_circle(cbore=True)).splitlines()
90
+ if ln.startswith("sheet.pattern(")
91
+ )
92
+ assert "cbore=(" in line # on the member hole(...) template
93
+
94
+ def test_non_declarable_kind_is_flagged_not_dropped(self):
95
+ # a counterbored plate carries a step_level (horizontal face levels) with no Sheet verb —
96
+ # it must surface as an inline comment, never silently vanish
97
+ part = Box(100, 70, 24) - Pos(0, 0, 0) * Cylinder(9, 40) - Pos(0, 0, 8) * Cylinder(15, 20)
98
+ src = _script_for(part)
99
+ assert any(
100
+ ln.startswith("#") and "no declarative verb yet" in ln for ln in src.splitlines()
101
+ )
102
+
103
+ def test_needs_hole_import_only_when_a_pattern_is_present(self):
104
+ # `hole` is only imported when a pattern line references it
105
+ assert "from draftwright.model import hole" not in _script_for(Box(20, 20, 5))
106
+
107
+ def test_slot_line_re_runs_without_the_length_invariant_error(self):
108
+ # #461 review: declare.slot() checks hi - lo == length to 1e-6; the emitter must derive
109
+ # length from the emitted lo/hi so the generated slot line doesn't raise on re-run.
110
+ from draftwright import Sheet
111
+
112
+ part = Box(60, 30, 12) - Pos(0, 0, 0) * Box(20.33, 8, 20) # off-round → stresses rounding
113
+ line = next(ln for ln in _script_for(part).splitlines() if ln.startswith("sheet.slot("))
114
+ eval(line, {"sheet": Sheet(part)}) # declare.slot() must not raise
115
+
116
+ def test_linear_pattern_spells_out_members(self):
117
+ # #461 review: the arrangement can't be recomputed faithfully (no reliable direction/angle),
118
+ # so the emitter spells out the exact member positions for every pattern kind.
119
+ from draftwright.model import Frame, HoleFeature, PatternFeature
120
+ from draftwright.sheet_emit import _feature_line
121
+
122
+ member = HoleFeature(Frame((0, 0, 0), "z"), 4.0, depth=None, through=True)
123
+ pat = PatternFeature(
124
+ frame=Frame((0, 0, 0), "z"),
125
+ pattern="linear",
126
+ count=3,
127
+ member=member,
128
+ members=((0, -10, 0), (0, 0, 0), (0, 10, 0)),
129
+ pitch=10,
130
+ direction=(0, 1, 0),
131
+ )
132
+ line = _feature_line(pat)
133
+ assert "members=[(0, -10, 0), (0, 0, 0), (0, 10, 0)]" in line and "pitch=10" in line
134
+
135
+
136
+ class TestGenerate:
137
+ def test_step_input_emits_a_self_contained_import_seam(self, tmp_path):
138
+ step = tmp_path / "plate.step"
139
+ export_step(_plate(), str(step))
140
+ py = generate_sheet_script(str(step), out=str(tmp_path / "gen"))
141
+ src = open(py, encoding="utf-8").read()
142
+ assert "import_step(" in src and "part = ..." not in src
143
+
144
+ def test_shape_input_leaves_a_part_seam(self, tmp_path):
145
+ py = generate_sheet_script(_plate(), out=str(tmp_path / "gen"))
146
+ src = open(py, encoding="utf-8").read()
147
+ assert "part = ..." in src and "import_step(" not in src
148
+
149
+ def test_generated_step_script_round_trips_to_a_drawing(self, tmp_path):
150
+ # the whole point: the generated script RUNS and produces a real drawing
151
+ step = tmp_path / "plate.step"
152
+ export_step(_plate(), str(step))
153
+ stem = tmp_path / "gen"
154
+ py = generate_sheet_script(str(step), out=str(stem))
155
+ exec(compile(open(py, encoding="utf-8").read(), py, "exec"), {})
156
+ assert (tmp_path / "gen.svg").exists()
157
+
158
+ def test_title_from_basename_not_the_out_path(self, tmp_path):
159
+ step = tmp_path / "widget.step"
160
+ export_step(Box(20, 20, 5), str(step))
161
+ py = generate_sheet_script(str(step), out=str(tmp_path / "gen"))
162
+ src = open(py, encoding="utf-8").read()
163
+ assert "title='GEN'" in src # basename of out, upper — not the full path
164
+
165
+ def test_step_path_is_absolute_for_cwd_independence(self, tmp_path, monkeypatch):
166
+ export_step(_plate(), str(tmp_path / "plate.step"))
167
+ monkeypatch.chdir(tmp_path)
168
+ py = generate_sheet_script("plate.step", out="gen") # relative input
169
+ # the emitted import_step path must be absolute so the script runs from any CWD
170
+ import_line = next(
171
+ ln for ln in open(py, encoding="utf-8").read().splitlines() if "import_step(" in ln
172
+ )
173
+ path = ast.literal_eval(import_line.split("import_step(", 1)[1].rsplit(")", 1)[0])
174
+ assert os.path.isabs(path) # platform-agnostic (C:\… on Windows, /… on POSIX)
175
+
176
+
177
+ class TestCli:
178
+ def test_style_sheet_routes_to_the_declarative_emitter(self, tmp_path):
179
+ from typer.testing import CliRunner
180
+
181
+ from draftwright.cli import app
182
+
183
+ step = tmp_path / "plate.step"
184
+ export_step(_plate(), str(step))
185
+ r = CliRunner().invoke(
186
+ app, [str(step), "--script", "--style", "sheet", "--out", str(tmp_path / "g")]
187
+ )
188
+ assert r.exit_code == 0, r.output
189
+ assert "sheet.hole(" in open(tmp_path / "g.py", encoding="utf-8").read()
190
+
191
+ def test_bad_style_is_rejected(self, tmp_path):
192
+ from typer.testing import CliRunner
193
+
194
+ from draftwright.cli import app
195
+
196
+ step = tmp_path / "plate.step"
197
+ export_step(_plate(), str(step))
198
+ r = CliRunner().invoke(app, [str(step), "--script", "--style", "bogus"])
199
+ assert r.exit_code != 0
@@ -0,0 +1,81 @@
1
+ """sheet.of(feature) — decorate a generated/existing feature (ADR 0011, #463).
2
+
3
+ Where the from_part-generated feature list and the aspect layer (P2a/P2a.2) meet: a handle
4
+ onto an existing feature so `.fit(...)` / `.tolerance(...)` reach a feature you didn't declare
5
+ from scratch.
6
+ """
7
+
8
+ import pytest
9
+ from build123d import Box, Cylinder, Pos, Rot
10
+
11
+ from draftwright import Sheet
12
+
13
+
14
+ def _shaft():
15
+ return (Rot(0, 90, 0) * Cylinder(4, 20)) + (Pos(15, 0, 0) * Rot(0, 90, 0) * Cylinder(6, 10))
16
+
17
+
18
+ class TestOf:
19
+ def test_of_object_decorates_a_generated_step(self):
20
+ # the headline: from_part generates the list; .of(object).fit() decorates one, end to end
21
+ s = Sheet.from_part(_shaft())
22
+ s.of(Pos(15, 0, 0) * Rot(0, 90, 0) * Cylinder(6, 10)).fit("g6") # the ⌀12 collar
23
+ dwg = s.build()
24
+ dias = {n: dwg._named[n].label for n in dwg._named if n.startswith("m_dia")}
25
+ assert any(lbl == "ø12 g6" for lbl in dias.values()), dias
26
+
27
+ def test_of_index_returns_a_decoratable_handle(self):
28
+ s = Sheet.from_part(Box(80, 50, 8) - Pos(20, 10, 4) * Cylinder(4, 20))
29
+ i = next(i for i, f in enumerate(s.features) if f.kind == "hole")
30
+ s.of(i).fit("H7") # records the fit on the hole's bore ⌀
31
+ assert (i, "diameter") in s._tolerances
32
+
33
+ def test_of_feature_identity(self):
34
+ s = Sheet.from_part(_shaft())
35
+ step = next(f for f in s.features if f.kind == "step")
36
+ s.of(step).tolerance(0.0, 0.1) # by the Feature object itself
37
+ assert any(k[1] == "length" for k in s._tolerances)
38
+
39
+ def test_of_hole_handle_supports_cbore(self):
40
+ part = Box(100, 70, 24) - Pos(0, 0, 0) * Cylinder(9, 40) - Pos(0, 0, 8) * Cylinder(15, 20)
41
+ s = Sheet.from_part(part)
42
+ i = next(i for i, f in enumerate(s.features) if f.kind == "hole")
43
+ s.of(i).cbore(Pos(0, 0, 8) * Cylinder(15, 20)) # the hole handle carries .cbore
44
+ assert s.features[i].cbore == pytest.approx((30.0, 14.0))
45
+
46
+ def test_of_unmatched_object_raises(self):
47
+ s = Sheet.from_part(Box(80, 50, 8) - Pos(20, 10, 4) * Cylinder(4, 20))
48
+ with pytest.raises(ValueError):
49
+ s.of(Pos(0, 0, 0) * Cylinder(3, 10)) # ⌀6 at origin — no such feature
50
+
51
+ def test_of_matches_on_axis_not_just_in_plane(self):
52
+ # #463 review: a same-⌀ feature on a DIFFERENT axis (a cross-hole) sharing the in-plane
53
+ # coords must not match — the axis is part of the identity.
54
+ s = Sheet(Box(40, 40, 40))
55
+ s.hole(diameter=8, at=(0, 0, 0), axis="z") # the intended target
56
+ s.diameter(diameter=8, at=(0, 0, 0), axis="x") # same ⌀ + in-plane, different axis
57
+ h = s.of(Pos(0, 0, 0) * Cylinder(4, 20)) # a z-axis tool → only the z hole
58
+ assert s.features[h._i].frame.axis == "z"
59
+
60
+ def test_of_ambiguous_object_raises(self):
61
+ s = Sheet(Box(40, 40, 30))
62
+ s.hole(diameter=8, at=(0, 0, 5), axis="z")
63
+ s.hole(diameter=8, at=(0, 0, 20), axis="z") # same ⌀ + in-plane (0,0)
64
+ with pytest.raises(ValueError):
65
+ s.of(Pos(0, 0, 10) * Cylinder(4, 10)) # matches both → ambiguous
66
+
67
+ def test_of_non_diameter_feature_raises(self):
68
+ s = Sheet(Box(30, 20, 5))
69
+ s.envelope()
70
+ with pytest.raises(ValueError):
71
+ s.of(0) # an envelope has no ⌀ aspect handle
72
+
73
+ def test_of_out_of_range_index_raises(self):
74
+ s = Sheet.from_part(Box(30, 20, 5))
75
+ with pytest.raises(IndexError):
76
+ s.of(99)
77
+
78
+ def test_of_bool_rejected(self):
79
+ s = Sheet.from_part(Box(30, 20, 5))
80
+ with pytest.raises(TypeError):
81
+ s.of(True)
File without changes
File without changes
File without changes
File without changes