feat(task-7): SECTION 21 - the mechanical part model as six tools

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
This commit is contained in:
Umut CAN
2026-09-22 23:17:29 +03:00
co-authored by Claude Opus 5
parent bd73b3a854
commit 8b14d4ef8d
9 changed files with 1722 additions and 13 deletions
+283
View File
@@ -0,0 +1,283 @@
"""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")
+15 -4
View File
@@ -86,7 +86,16 @@ LEAN_SETTINGS_ESSENTIALS = {
"textstyle_set_current",
}
ALL_PACKS = ["core", "pid", "settings"]
ALL_PACKS = ["core", "pid", "settings", "mech"]
ALL_PACKS_SORTED = sorted(ALL_PACKS)
MECH_TOOLS = {
"mech_part_draw",
"mech_view_add",
"mech_dimension_part",
"mech_hole_pattern",
"mech_part_from_spec",
"mech_part_inspect",
}
@pytest_asyncio.fixture(autouse=True)
@@ -97,8 +106,9 @@ async def _restore(monkeypatch):
def test_pack_registry_names_real_tools_and_only_them():
assert server.TOOL_PACK_NAMES == ("core", "pid", "settings")
assert server.TOOL_PACK_NAMES == ("core", "pid", "settings", "mech")
assert server.PACK_TOOL_NAMES["pid"] == frozenset(PID_TOOLS)
assert server.PACK_TOOL_NAMES["mech"] == frozenset(MECH_TOOLS)
assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS)
assert len(SETTINGS_TOOLS) == 22
assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"])
@@ -137,7 +147,7 @@ async def test_all_is_the_default_and_enables_every_pack(monkeypatch):
info = await server._apply_tool_profile("full")
disabled = set(info["disabled_tools"])
assert not ((PID_TOOLS | SETTINGS_TOOLS) & disabled)
assert info["tool_packs"]["enabled"] == ALL_PACKS
assert info["tool_packs"]["enabled"] == ALL_PACKS_SORTED
async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped(
@@ -184,7 +194,8 @@ async def test_the_three_track_e_sections_file_under_their_own_groups():
assert set(groups["styles"]) == STYLE_TOOLS
assert set(groups["page_setup"]) == PAGE_SETUP_TOOLS
assert set(groups["environment"]) == SETTINGS_TOOLS
assert len(groups) == 23
assert set(groups["mech"]) == MECH_TOOLS
assert len(groups) == 24
async def test_system_about_reports_packs():
+11 -3
View File
@@ -527,8 +527,15 @@ async def test_tool_group_sizes_are_unchanged():
merge task tagged them, the four groups had filed those 28 tools under
`layouts` / `templates` / `drawing` / `layers` / `view` / `system` by
their secondary tags; those counts returned to their pre-track-E values
when the section tags landed.) Every other number here has been unchanged
since the snapshot was taken.
when the section tags landed.) `mech` appeared (0 -> 6) when v1.6's
SECTION 21 opened with the part model and its view engine
(`mech_part_draw` / `mech_view_add` / `mech_dimension_part` /
`mech_hole_pattern` / `mech_part_from_spec` / `mech_part_inspect`); the
`mech` tag is ranked directly after `pid` in `_GROUP_TAG_PRIORITY`, so
those tools file under `mech` rather than under their secondary `create` /
`dimension` / `query` tags. This snapshot is branch-local: the merge task
recomputes it once all four track B+G branches have landed. Every other
number here has been unchanged since the snapshot was taken.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
@@ -545,6 +552,7 @@ async def test_tool_group_sizes_are_unchanged():
"environment": 22,
"layers": 14,
"layouts": 12,
"mech": 6,
"page_setup": 6,
"pid": 9,
"premium": 12,
@@ -556,4 +564,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 204
assert sum(sizes.values()) == 210