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.
- {draftwright-0.2.6 → draftwright-0.2.7}/CHANGELOG.md +28 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/PKG-INFO +1 -1
- {draftwright-0.2.6 → draftwright-0.2.7}/pyproject.toml +1 -1
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/cli.py +27 -11
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/model/declare.py +39 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/sheet_dsl.py +85 -1
- draftwright-0.2.7/src/draftwright/sheet_emit.py +191 -0
- draftwright-0.2.7/tests/test_object_aspects.py +89 -0
- draftwright-0.2.7/tests/test_sheet_emit.py +199 -0
- draftwright-0.2.7/tests/test_sheet_of.py +81 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/.gitignore +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/LICENSE +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/README.md +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/skills/SKILL.md +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/__init__.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/_core.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/analysis.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/annotate.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/annotations/__init__.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/annotations/_common.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/annotations/from_model.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/annotations/holes.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/annotations/orchestrator.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/annotations/sections.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/builder.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/drawing.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/export.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/features.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/fits.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/fonts/IBMPlexMono-Regular.ttf +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/fonts/LICENSE-IBMPlexMono-OFL.txt +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/fonts/__init__.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/intents.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/layout.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/linting/__init__.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/linting/coverage.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/linting/issues.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/linting/structural.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/linting/suggest.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/make_drawing.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/model/__init__.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/model/detect.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/model/ir.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/model/planner.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/pmi.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/projection.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/recognition/__init__.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/recognition/_features.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/recognition/levels.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/recognition/slots.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/recognition/turned.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/registry.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/repair.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/sheet.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_01_asme1_ap203.stp +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_01_asme1_ap242.stp +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_02_asme1_ap203.stp +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_02_asme1_ap242.stp +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_03_asme1_ap203.stp +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_03_asme1_ap242.stp +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_04_asme1_ap203.stp +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_04_asme1_ap242.stp +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_05_asme1_ap203.stp +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/fixtures/nist_ctc_05_asme1_ap242.stp +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/box.json +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/bracket.json +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/drive_screw_x.json +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/dshape.json +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/flange.json +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/holed_slot.json +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/plate_holes.json +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/side_drilled.json +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/slotted.json +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/layout_snapshots/turned_shaft.json +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_declare.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_e2e_slice.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_e2e_standards.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_fits.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_layout.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_layout_cleanliness.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_layout_property.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_layout_snapshot.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_lint_structural.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_linting.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_make_drawing.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_part_model.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_pmi.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_recognition.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_registry.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_render_seam.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_strip_layout.py +0 -0
- {draftwright-0.2.6 → draftwright-0.2.7}/tests/test_tolerances.py +0 -0
- {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.**
|
|
@@ -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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/fonts/IBMPlexSansCondensed-Regular.ttf
RENAMED
|
File without changes
|
|
File without changes
|
{draftwright-0.2.6 → draftwright-0.2.7}/src/draftwright/fonts/LICENSE-IBMPlexSansCondensed-OFL.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|