engineering/mech/draw.py is the only module in the package that touches a backend: it turns the pure engine's primitive descriptions into entity_create_* and dimension_* calls, places them in WCS, and writes the part model back onto the drawing as ACADMCP_MECH XDATA on an anchor POINT. It is not a contract member and neither are the tools - exactly like gear_draw_* and titleblock_apply_iso_a3, it composes the generic members, so it is written once and works on both engines by construction and no capability key is needed. No new ActiveX call was written, so there was nothing to probe on the live seat; measured instead that every one of the 23 backend members draw.py calls exists on ComBackend and that ComBackend has no abstract methods left. mech_part_draw takes one model - a turned profile or a plate outline plus typed features - and draws its views in first-angle positions (third on request), dimensions them and records the placement. mech_view_add reads the model back out of the drawing and adds a side, top, section (full/half/offset/revolved) or detail view months later without the caller re-describing the part. mech_dimension_part lays out ISO 129 dimensions with no overlapping text, resolves ISO 286 fits against the measured nominal, and moves the holes into a table past a threshold. mech_hole_pattern exposes the same expansion for a drawing that was never built from the model - with its own diameter guard, because Hole.validate needs a part and a standalone pattern has none. mech_part_inspect reads the payload back and never modifies the drawing. mech_part_from_spec draws a whole sheet inside one transaction. Every part is validated before the transaction opens, so a refusal leaves the drawing untouched and names the path (parts[1]: segments[0].d_outer: ...), and the rollback is shielded with the shape engineering/pid/spec.py documents: anyio cancellation is level-triggered, so a bare await in the handler would be cancelled before the rollback body ran and leave half a sheet inside an open transaction. fits goes through dimension_intents(fits=...), the API Task 6 shipped, rather than being stamped on at draw time: that is the path that validates a key by name and refuses a fit on a reference dimension or on a hole that moved into the table. It is scoped per view first - a fit on segment[0] names nothing in the end view, and refusing there would make a correct three-view sheet undrawable - and any key no view claimed is handed back to dimension_intents so the refusal is written in its own vocabulary. SECTION 21 registers the six tools in the new mech pack, with alias records (SECTIONPLANE, ARRAY plus shop-floor synonyms in English and Turkish - mil, kademeli mil, kama, pah, kesit, gorunus), a regenerated tool inventory, the README release snapshot at 210 tools in 24 groups, and the frozen group-size snapshot gaining mech: 6. Lean is deliberately untouched here: spec section 11 puts three of these on the lean surface together with group G's two sheet tools, and Task 15 makes that edit once across all four branches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
284 lines
11 KiB
Python
284 lines
11 KiB
Python
"""The async layer: primitives to entities, the part payload, and the sheet.
|
|
|
|
Headless ezdxf against a real document - the `backend` fixture in
|
|
tests/conftest.py. The COM paths are the same code (draw.py composes the
|
|
generic contract members), which is the whole point of the pattern
|
|
`gear_draw_*` and `titleblock_apply_iso_a3` already use.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from engineering.mech.draw import (
|
|
ANCHOR_LAYER,
|
|
add_view,
|
|
dimension_part,
|
|
draw_from_spec,
|
|
draw_hole_pattern,
|
|
draw_part,
|
|
draw_prims,
|
|
read_part,
|
|
)
|
|
from engineering.mech.part import build_part
|
|
from engineering.mech.primitives import Arc, Circle, HatchArea, Line, Poly, Text
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
SHAFT = {
|
|
"kind": "revolved",
|
|
"name": "shaft",
|
|
"material": "steel",
|
|
"segments": [
|
|
{"length": 20.0, "d_outer": 25.0},
|
|
{"length": 40.0, "d_outer": 30.0},
|
|
{"length": 15.0, "d_outer": 20.0},
|
|
],
|
|
"features": [{"kind": "chamfer", "id": "c1", "at": "start", "size": 2.0}],
|
|
}
|
|
|
|
PLATE = {
|
|
"kind": "prismatic",
|
|
"name": "plate",
|
|
"material": "aluminium",
|
|
"thickness": 8.0,
|
|
"outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]],
|
|
"features": [{"kind": "hole", "id": "h1", "x": 40.0, "y": 25.0, "diameter": 10.0}],
|
|
}
|
|
|
|
|
|
async def entities(backend, type_filter=None, layer=None):
|
|
return await backend.entity_list(type_filter=type_filter, layer_filter=layer, limit=2000)
|
|
|
|
|
|
async def test_draw_prims_puts_every_role_on_its_own_layer(backend):
|
|
result = await draw_prims(
|
|
backend,
|
|
[
|
|
Line((0.0, 0.0), (10.0, 0.0), "visible"),
|
|
Line((0.0, 5.0), (10.0, 5.0), "hidden"),
|
|
Line((-2.0, 2.5), (12.0, 2.5), "center"),
|
|
Circle((5.0, 2.5), 1.0, "visible"),
|
|
Arc((5.0, 2.5), 2.0, 0.0, 90.0, "visible"),
|
|
Poly(((0.0, 0.0), (10.0, 0.0), (10.0, 5.0)), True, "visible"),
|
|
Text((5.0, 8.0), "A", 3.5, 0.0, "text"),
|
|
HatchArea((((0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (0.0, 5.0)),), "steel"),
|
|
],
|
|
)
|
|
assert len(result["handles"]) == 8
|
|
layers = {e.layer for e in await entities(backend)}
|
|
assert {"GEOMETRY", "HIDDEN", "CENTER", "TEXT", "HATCH"} <= layers
|
|
|
|
|
|
async def test_draw_prims_places_and_rotates_into_wcs(backend):
|
|
await draw_prims(backend, [Line((0.0, 0.0), (10.0, 0.0), "visible")], at=(100.0, 50.0))
|
|
(line,) = await entities(backend, type_filter="LINE")
|
|
start = line.properties["start"]
|
|
assert (round(start[0], 6), round(start[1], 6)) == (100.0, 50.0)
|
|
|
|
|
|
async def test_draw_part_writes_the_model_back_onto_the_drawing(backend):
|
|
part = build_part(SHAFT)
|
|
result = await draw_part(backend, part, at=(50.0, 100.0))
|
|
assert result["part_id"]
|
|
assert result["views"][0]["kind"] == "front"
|
|
assert result["handles"]
|
|
|
|
anchors = await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)
|
|
assert len(anchors) == 1
|
|
assert anchors[0].handle == result["part_id"]
|
|
|
|
read = await read_part(backend, result["part_id"])
|
|
assert read["part"]["name"] == "shaft"
|
|
assert [f["id"] for f in read["part"]["features"]] == ["c1"]
|
|
assert build_part(read["part"]) == part
|
|
|
|
|
|
async def test_read_part_without_an_id_finds_the_only_part(backend):
|
|
part = build_part(SHAFT)
|
|
drawn = await draw_part(backend, part)
|
|
read = await read_part(backend)
|
|
assert read["part_id"] == drawn["part_id"]
|
|
|
|
|
|
async def test_read_part_refuses_when_there_is_more_than_one_and_no_id(backend):
|
|
await draw_part(backend, build_part(SHAFT), at=(0.0, 0.0))
|
|
await draw_part(backend, build_part(PLATE), at=(200.0, 0.0))
|
|
with pytest.raises(ValueError) as excinfo:
|
|
await read_part(backend)
|
|
assert "part_id" in str(excinfo.value)
|
|
|
|
|
|
async def test_two_views_are_placed_in_first_angle_positions(backend):
|
|
part = build_part(SHAFT)
|
|
result = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side"))
|
|
front, end = result["views"]
|
|
assert end["kind"] == "side"
|
|
# first angle: the view from the right sits to the LEFT of the front view
|
|
assert end["bbox"][2] <= front["bbox"][0] + 1e-6
|
|
|
|
|
|
async def test_third_angle_puts_the_end_view_on_the_other_side(backend):
|
|
part = build_part(SHAFT)
|
|
result = await draw_part(
|
|
backend, part, at=(0.0, 0.0), views=("front", "side"), projection="third"
|
|
)
|
|
front, end = result["views"]
|
|
assert end["bbox"][0] >= front["bbox"][2] - 1e-6
|
|
|
|
|
|
async def test_add_view_reads_the_model_back_and_appends_a_section(backend):
|
|
part = build_part(SHAFT)
|
|
drawn = await draw_part(backend, part, at=(0.0, 0.0))
|
|
added = await add_view(
|
|
backend,
|
|
drawn["part_id"],
|
|
"section",
|
|
plane={"p1": [0.0, 0.0], "p2": [100.0, 0.0], "label": "A"},
|
|
)
|
|
assert added["view"]["kind"] == "section"
|
|
assert added["view"]["label"] == "A-A"
|
|
assert len(await entities(backend, type_filter="HATCH")) == 2
|
|
read = await read_part(backend, drawn["part_id"])
|
|
assert [v["kind"] for v in read["views"]] == ["front", "section"]
|
|
|
|
|
|
async def test_a_detail_view_also_marks_its_circle_on_the_parent(backend):
|
|
part = build_part(SHAFT)
|
|
drawn = await draw_part(backend, part, at=(0.0, 0.0))
|
|
added = await add_view(
|
|
backend,
|
|
drawn["part_id"],
|
|
"detail",
|
|
detail={"center": [20.0, 12.5], "radius": 8.0, "scale": 5.0, "label": "D"},
|
|
)
|
|
assert added["view"]["label"] == "D (5:1)"
|
|
texts = [e for e in await entities(backend, type_filter="TEXT")]
|
|
assert any(e.properties.get("text") == "D" for e in texts)
|
|
|
|
|
|
async def test_dimension_part_creates_real_dimension_entities(backend):
|
|
part = build_part(SHAFT)
|
|
drawn = await draw_part(backend, part, at=(0.0, 0.0), dimension=False)
|
|
assert not await entities(backend, type_filter="DIMENSION")
|
|
result = await dimension_part(backend, drawn["part_id"])
|
|
assert result["count"] > 0
|
|
dims = await entities(backend, type_filter="DIMENSION")
|
|
assert len(dims) == result["count"]
|
|
assert {e.layer for e in dims} == {"DIM"}
|
|
|
|
|
|
async def test_an_iso_286_fit_reaches_the_dimension_as_a_real_tolerance(backend):
|
|
"""`fits` names a dimension, and 0.025 is H7 on the 40 mm bore.
|
|
|
|
The key is the one `engineering/mech/dimension.segment_key` mints, not the
|
|
part's name: `dimension_intents` addresses the part's own diameters one at
|
|
a time (`segment[0]`, `segment[0].bore`) precisely so a fit lands on the
|
|
bore rather than on every dimension the part emits. Measured: H7 is
|
|
+0.025/0 at 40 mm, +0.030/0 at 60 mm and +0.021/0 at the 30 mm length.
|
|
"""
|
|
spec = {
|
|
"kind": "revolved",
|
|
"name": "bore",
|
|
"segments": [{"length": 30.0, "d_outer": 60.0, "d_inner": 40.0}],
|
|
"features": [],
|
|
}
|
|
part = build_part(spec)
|
|
drawn = await draw_part(backend, part, at=(0.0, 0.0), dimension=False)
|
|
result = await dimension_part(backend, drawn["part_id"], fits={"segment[0].bore": "H7"})
|
|
assert result["count"] > 0
|
|
assert any(
|
|
row.get("fit") == "H7"
|
|
and row["tol_upper"] == pytest.approx(0.025)
|
|
and row["tol_lower"] == pytest.approx(0.0)
|
|
and row["tol_mode"] == "deviation"
|
|
and "H7" in row["text_override"]
|
|
for row in result["resolved"]
|
|
)
|
|
# and the outside diameter of the same segment is untouched
|
|
assert all(
|
|
row["tol_mode"] == "none"
|
|
for row in result["resolved"]
|
|
if row["feature"] != "segment[0].bore"
|
|
)
|
|
|
|
|
|
async def test_a_fit_that_names_nothing_is_refused_with_the_names_that_exist(backend):
|
|
part = build_part(SHAFT)
|
|
drawn = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side"), dimension=False)
|
|
with pytest.raises(ValueError) as excinfo:
|
|
await dimension_part(backend, drawn["part_id"], fits={"bearing_seat": "k6"})
|
|
assert "bearing_seat" in str(excinfo.value)
|
|
assert "segment[0]" in str(excinfo.value)
|
|
|
|
|
|
async def test_a_fit_on_one_view_is_not_refused_because_another_view_lacks_it(backend):
|
|
"""A part drawn front + side: `segment[0]` is dimensioned in the front view
|
|
only, and the end view (which dimensions the *last* segment) must not turn
|
|
a correct sheet into a refusal."""
|
|
part = build_part(SHAFT)
|
|
drawn = await draw_part(backend, part, at=(0.0, 0.0), views=("front", "side"), dimension=False)
|
|
result = await dimension_part(backend, drawn["part_id"], fits={"segment[0]": "k6"})
|
|
assert any(row.get("fit") == "k6" for row in result["resolved"])
|
|
|
|
|
|
async def test_a_hole_pattern_draws_its_holes_and_centre_marks(backend):
|
|
result = await draw_hole_pattern(
|
|
backend, pattern="polar", x=0.0, y=0.0, diameter=8.0, count=6, pcd=80.0
|
|
)
|
|
assert result["count"] == 6
|
|
circles = await entities(backend, type_filter="CIRCLE")
|
|
assert len(circles) == 6
|
|
assert len(await entities(backend, layer="CENTER")) >= 12
|
|
|
|
|
|
async def test_a_hole_pattern_with_no_spacing_is_refused_before_anything_is_drawn(backend):
|
|
with pytest.raises(ValueError, match="spacing"):
|
|
await draw_hole_pattern(backend, pattern="linear", x=0.0, y=0.0, diameter=8.0, count=4)
|
|
assert not await entities(backend, type_filter="CIRCLE")
|
|
|
|
|
|
async def test_draw_from_spec_draws_a_whole_sheet_in_one_transaction(backend):
|
|
result = await draw_from_spec(
|
|
backend,
|
|
{
|
|
"sheet": {"intent": "mechanical sheet", "sheet_size": "A3", "scale": 1.0},
|
|
"parts": [
|
|
{"part": SHAFT, "at": [40.0, 200.0], "views": ["front", "side"]},
|
|
{"part": PLATE, "at": [40.0, 60.0], "views": ["front"]},
|
|
],
|
|
},
|
|
)
|
|
assert len(result["parts"]) == 2
|
|
assert len(await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)) == 2
|
|
|
|
|
|
async def test_draw_from_spec_validates_every_part_before_it_draws_anything(backend):
|
|
bad = dict(SHAFT, segments=[{"length": 20.0, "d_outer": float("inf")}])
|
|
with pytest.raises(ValueError) as excinfo:
|
|
await draw_from_spec(
|
|
backend,
|
|
{"parts": [{"part": PLATE, "at": [0.0, 0.0]}, {"part": bad, "at": [200.0, 0.0]}]},
|
|
)
|
|
assert "parts[1]" in str(excinfo.value)
|
|
assert "segments[0].d_outer" in str(excinfo.value)
|
|
assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)
|
|
|
|
|
|
async def test_draw_from_spec_rolls_back_when_a_draw_fails_midway(backend, monkeypatch):
|
|
calls = {"n": 0}
|
|
real = backend.entity_create_line
|
|
|
|
async def explode(*args, **kwargs):
|
|
calls["n"] += 1
|
|
if calls["n"] > 3:
|
|
raise RuntimeError("the seat went away")
|
|
return await real(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(backend, "entity_create_line", explode)
|
|
with pytest.raises(RuntimeError, match="the seat went away"):
|
|
await draw_from_spec(backend, {"parts": [{"part": SHAFT, "at": [0.0, 0.0]}]})
|
|
monkeypatch.undo()
|
|
assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)
|
|
assert not await entities(backend, type_filter="LINE")
|