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
+3 -3
View File
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · 3284 collected tests.
> 204 is the **registered** count; a default install advertises 199 over `tools/list`,
> **v1.5 release snapshot:** 210 tools · 8 resources · 5 prompt templates · 3284 collected tests.
> 210 is the **registered** count; a default install advertises 205 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
## Why this exists
@@ -117,7 +117,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
| Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0–100 score) |
| Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks |
<sub>204 tools in 23 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
<sub>210 tools in 24 groups; <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. Plus 8 resources that cost nothing in the tool budget (<code>autocad://drawing/info</code>, <code>layers</code>, <code>blocks</code>, <code>entities/stats</code>, <code>entities/{layer_name}</code>, <code>system/status</code>, <code>pid/symbols</code>, <code>standards/isa51</code>) and 5 prompt templates.</sub>
**Two rules worth knowing.** Every coordinate in and out of a tool is WCS on both engines — the one exception is TEXT `rotation`, which stays in the entity frame because a mirrored TEXT is mirror-imaged and no scalar angle expresses that. And never read vertices back and shoelace them: that loses **28.2%** of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry and states its own accuracy.
+90
View File
@@ -1859,6 +1859,96 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
"save a detail view",
),
),
# ── Mechanical parts (SECTION 21, tracks B+G group M) ───────────────────
"mech_part_draw": ToolAliases(
acad=(),
synonyms=(
"draw a shaft",
"stepped shaft",
"draw a machine part",
"turned part",
"draw a plate",
"flange",
"part from a segment list",
"shaft with a keyway",
"mil",
"mil ciz",
"kademeli mil",
"kama",
"pah",
"plaka",
"flans",
),
),
"mech_view_add": ToolAliases(
acad=("SECTIONPLANE",),
synonyms=(
"add a section view",
"section a-a",
"cross section",
"half section",
"offset section",
"detail view",
"blown-up detail",
"end view",
"side view",
"another view of the part",
"kesit",
"kesit al",
"gorunus",
"yan gorunus",
"detay",
),
),
"mech_dimension_part": ToolAliases(
acad=(),
synonyms=(
"dimension the part",
"put the dimensions on",
"chain dimensions",
"baseline dimensions",
"ordinate dimensions",
"hole table",
"iso 286 fit on a dimension",
"h7 bore",
"olculendir",
"olcu ver",
"tolerans",
),
),
"mech_hole_pattern": ToolAliases(
acad=("ARRAY",),
synonyms=(
"bolt circle",
"hole pattern",
"hole array",
"pitch circle of holes",
"grid of holes",
"delik deseni",
"civata dairesi",
"delik aynasi",
),
),
"mech_part_from_spec": ToolAliases(
acad=(),
synonyms=(
"draw the whole sheet",
"several parts at once",
"mechanical sheet from a spec",
"batch draw parts",
"parca sayfasi",
),
),
"mech_part_inspect": ToolAliases(
acad=(),
synonyms=(
"what part is this",
"read the part model back",
"part features",
"which views were drawn",
"parca bilgisi",
),
),
}
+87 -2
View File
@@ -3,10 +3,10 @@
"schema_version": 1,
"package_version": "1.5.1",
"totals": {
"tools": 204,
"tools": 210,
"resources": 8,
"prompts": 5,
"groups": 23
"groups": 24
},
"groups": {
"analysis": 12,
@@ -22,6 +22,7 @@
"environment": 22,
"layers": 14,
"layouts": 12,
"mech": 6,
"page_setup": 6,
"pid": 9,
"premium": 12,
@@ -1968,6 +1969,90 @@
"readOnlyHint": false
}
},
{
"name": "mech_dimension_part",
"group": "mech",
"cost": "mutate",
"summary": "Dimension a drawn part: ISO 129 dimensions, ISO 286 fits, a hole table.",
"tags": [
"dimension",
"mech"
],
"annotations": {
"title": "Mechanical: Dimension Part",
"destructiveHint": false
}
},
{
"name": "mech_hole_pattern",
"group": "mech",
"cost": "mutate",
"summary": "Draw a linear, grid or polar hole pattern with centre marks on any drawing.",
"tags": [
"create",
"mech"
],
"annotations": {
"title": "Mechanical: Hole Pattern",
"destructiveHint": false
}
},
{
"name": "mech_part_draw",
"group": "mech",
"cost": "mutate",
"summary": "Draw a machine part from a segment list or an outline plus typed features.",
"tags": [
"create",
"mech"
],
"annotations": {
"title": "Mechanical: Draw Part",
"destructiveHint": false
}
},
{
"name": "mech_part_from_spec",
"group": "mech",
"cost": "mutate",
"summary": "Draw a whole mechanical sheet - several parts and their views - in one transaction.",
"tags": [
"create",
"mech"
],
"annotations": {
"title": "Mechanical: Sheet From Spec",
"destructiveHint": false
}
},
{
"name": "mech_part_inspect",
"group": "mech",
"cost": "read",
"summary": "Read the part model back off the drawing: segments, features, views, omissions.",
"tags": [
"mech",
"query"
],
"annotations": {
"title": "Mechanical: Inspect Part",
"readOnlyHint": true
}
},
{
"name": "mech_view_add",
"group": "mech",
"cost": "mutate",
"summary": "Add another view of a drawn part: side, top, section (full/half/offset) or detail.",
"tags": [
"create",
"mech"
],
"annotations": {
"title": "Mechanical: Add View",
"destructiveHint": false
}
},
{
"name": "mleaderstyle_create",
"group": "styles",
+18
View File
@@ -22,6 +22,16 @@ from .dimension import (
segment_key,
text_rects,
)
from .draw import (
ANCHOR_LAYER,
add_view,
dimension_part,
draw_from_spec,
draw_hole_pattern,
draw_part,
draw_prims,
read_part,
)
from .features import (
FEATURE_KINDS,
AxialBore,
@@ -105,6 +115,7 @@ from .views import (
)
__all__ = [
"ANCHOR_LAYER",
"Arc",
"AxialBore",
"Band",
@@ -153,6 +164,7 @@ __all__ = [
"Undercut",
"VIEW_KINDS",
"View",
"add_view",
"bbox",
"build_part",
"build_view",
@@ -160,6 +172,11 @@ __all__ = [
"detail_marker_prims",
"detail_view",
"dimension_intents",
"dimension_part",
"draw_from_spec",
"draw_hole_pattern",
"draw_part",
"draw_prims",
"feature_from_dict",
"hole_table_rows",
"inner_radius_at",
@@ -180,6 +197,7 @@ __all__ = [
"point_in_outline",
"points_of",
"radial_profile",
"read_part",
"redundant_linear",
"resolve_tolerance",
"rotate",
+804
View File
@@ -0,0 +1,804 @@
"""The only module in `engineering/mech/` that touches a backend.
Everything above it is pure: the part model, the features, the view engine and
the dimension layout take numbers and return primitive *descriptions*. This
module turns those descriptions into `entity_create_*` / `dimension_*` calls,
places them in WCS, and writes the part model back onto the drawing as
`ACADMCP_MECH` XDATA so `mech_view_add` can add a view months later without the
caller re-describing the part.
It is not a contract member and neither are the tools above it - exactly like
`gear_draw_*`, `keyway_draw_*` and `titleblock_apply_iso_a3`, it composes the
generic members, so it is written once and works on both engines by
construction.
"""
from __future__ import annotations
import logging
import math
from typing import Any
import anyio
from engineering.mech.dimension import (
dimension_intents,
hole_table_rows,
intent_text,
layout_dimensions,
resolve_tolerance,
)
from engineering.mech.features import HolePattern
from engineering.mech.part import build_part, part_to_dict, validate_part
from engineering.mech.primitives import (
ROLE_LAYER,
Arc,
Circle,
DimIntent,
HatchArea,
Line,
Poly,
Text,
layer_for,
rotate,
translate,
)
from engineering.mech.standards.materials import hatch_for
from engineering.mech.views import (
View,
build_view,
detail_marker_prims,
layout_origin,
normalise_plane,
)
from engineering.mech.xdata import APP_ID, PAYLOAD_VERSION, decode, to_values
log = logging.getLogger(__name__)
#: The layer the invisible per-part anchor POINT lives on. It carries the
#: ACADMCP_MECH payload and nothing else, so `read_part` can find every part in
#: a drawing with one filtered `entity_list`.
ANCHOR_LAYER = "MECH"
DEFAULT_GAP = 20.0
#: How far a standalone hole pattern's ISO 128-23 centre mark reaches past the
#: hole it marks, in drawing units. DECLARED, not standard: ISO 128-23 fixes
#: that the mark overruns the circle, not by how much.
CENTRE_MARK_OVERRUN = 3.0
# -- the XDATA adapter -------------------------------------------------------
#
# `engineering/mech/xdata.encode` returns DXF (group code, value) rows, which is
# the on-disk shape. `entity_set_xdata` takes plain JSON values and assigns the
# codes itself through backends/xdata_specs.py, so the 1001 APPID row is the
# backend's business and only the 1000 chunks travel. `to_values(payload)` is
# that projection and it lives in `engineering/mech/xdata.py` (Task 1) - every
# writer in this repository calls it rather than re-deriving the split, so a
# change to the chunking is made in one place.
def _xdata_payload(values) -> dict:
return decode(values)
def _number(value, where: str, *, positive: bool = True) -> float:
try:
number = float(value)
except (TypeError, ValueError):
raise ValueError(f"{where}: expected a number, got {value!r}.") from None
if not math.isfinite(number):
raise ValueError(f"{where}: must be finite, got {value!r}.")
if positive and number <= 0.0:
raise ValueError(f"{where}: must be greater than zero, got {number:g}.")
return number
# -- primitives to entities --------------------------------------------------
async def draw_prims(
backend,
prims,
*,
at: tuple[float, float] = (0.0, 0.0),
rotation: float = 0.0,
layer_prefix: str = "",
) -> dict:
"""Draw primitive descriptions, placed at ``at`` and turned by ``rotation``."""
placed = tuple(prims)
if abs(float(rotation)) > 1e-12:
placed = rotate(placed, float(rotation), (0.0, 0.0))
if abs(float(at[0])) > 1e-12 or abs(float(at[1])) > 1e-12:
placed = translate(placed, float(at[0]), float(at[1]))
handles: list[str] = []
counts: dict[str, int] = {}
for prim in placed:
layer = f"{layer_prefix}{layer_for(prim)}"
if isinstance(prim, Line):
info = await backend.entity_create_line(
prim.p1[0], prim.p1[1], prim.p2[0], prim.p2[1], layer=layer
)
elif isinstance(prim, Arc):
info = await backend.entity_create_arc(
prim.center[0],
prim.center[1],
prim.radius,
prim.start_deg,
prim.end_deg,
layer=layer,
)
elif isinstance(prim, Circle):
info = await backend.entity_create_circle(
prim.center[0], prim.center[1], prim.radius, layer=layer
)
elif isinstance(prim, Poly):
info = await backend.entity_create_polyline(
[[x, y] for x, y in prim.points], prim.closed, layer=layer
)
elif isinstance(prim, Text):
info = await backend.entity_create_text(
prim.text,
prim.at[0],
prim.at[1],
prim.height,
prim.rotation,
layer=layer,
)
elif isinstance(prim, HatchArea):
style = hatch_for(prim.material)
info = await backend.entity_create_hatch(
style["pattern"],
[[x, y] for x, y in prim.loops[0]],
style["scale"],
style["angle"],
layer=layer,
)
for island in prim.loops[1:]:
points = list(island)
edges = [
{
"type": "line",
"start": [points[i][0], points[i][1]],
"end": [points[(i + 1) % len(points)][0], points[(i + 1) % len(points)][1]],
}
for i in range(len(points))
]
await backend.hatch_add_boundary(info.handle, edges)
else: # pragma: no cover - the Prim union is closed
raise ValueError(f"draw_prims: unknown primitive {type(prim).__name__}.")
handles.append(info.handle)
key = type(prim).__name__.lower()
counts[key] = counts.get(key, 0) + 1
return {"handles": handles, "counts": counts}
# -- dimensions --------------------------------------------------------------
async def _draw_dimension(backend, intent) -> tuple[str | None, dict]:
"""One laid-out intent as a real DIMENSION. Returns (handle, the resolved row)."""
resolved = resolve_tolerance(intent)
row = {
"feature": intent.feature,
"kind": intent.kind,
"text": intent_text(intent),
"fit": intent.fit,
**resolved,
}
text_at = intent.text_at or (
(intent.p1[0] + intent.p2[0]) / 2.0,
(intent.p1[1] + intent.p2[1]) / 2.0,
)
tol = {
"tol_upper": resolved["tol_upper"],
"tol_lower": resolved["tol_lower"],
"tol_mode": resolved["tol_mode"],
"text_override": resolved["text_override"],
}
if intent.kind == "linear":
horizontal = abs(intent.p2[1] - intent.p1[1]) <= abs(intent.p2[0] - intent.p1[0])
info = await backend.dimension_linear(
intent.p1[0],
intent.p1[1],
intent.p2[0],
intent.p2[1],
text_at[0],
text_at[1],
0.0 if horizontal else 90.0,
ROLE_LAYER["dim"],
**tol,
)
elif intent.kind == "aligned":
if tol["tol_mode"] != "none" or tol["text_override"]:
angle = math.degrees(
math.atan2(intent.p2[1] - intent.p1[1], intent.p2[0] - intent.p1[0])
)
# dimension_aligned carries no tolerance arguments, so a toleranced
# aligned dimension is drawn as a rotated linear one - the same
# measurement, with the tolerance the caller asked for.
info = await backend.dimension_linear(
intent.p1[0],
intent.p1[1],
intent.p2[0],
intent.p2[1],
text_at[0],
text_at[1],
angle,
ROLE_LAYER["dim"],
**tol,
)
else:
info = await backend.dimension_aligned(
intent.p1[0],
intent.p1[1],
intent.p2[0],
intent.p2[1],
text_at[0],
text_at[1],
ROLE_LAYER["dim"],
)
elif intent.kind == "diameter":
info = await backend.dimension_diameter(
intent.p1[0],
intent.p1[1],
intent.p2[0],
intent.p2[1],
10.0,
ROLE_LAYER["dim"],
**tol,
)
elif intent.kind == "radius":
info = await backend.dimension_radius(
intent.p1[0],
intent.p1[1],
intent.p2[0],
intent.p2[1],
10.0,
ROLE_LAYER["dim"],
**tol,
)
else:
return None, {
**row,
"skipped": "an angular intent needs a vertex, which a DimIntent does not carry; "
"no feature in this release emits one",
}
return info.handle, row
# -- the part payload --------------------------------------------------------
def _view_record(view: View, offset: tuple[float, float], kw: dict) -> dict:
x0, y0, x1, y1 = view.bbox
return {
"kind": view.kind,
"label": view.label,
"scale": view.scale,
"offset": [float(offset[0]), float(offset[1])],
"bbox": [x0 + offset[0], y0 + offset[1], x1 + offset[0], y1 + offset[1]],
"kw": kw,
}
def _offset_for(view: View, lower_left: tuple[float, float]) -> tuple[float, float]:
return (lower_left[0] - view.bbox[0], lower_left[1] - view.bbox[1])
def _placed(view: View, offset) -> View:
x0, y0, x1, y1 = view.bbox
return View(
kind=view.kind,
prims=view.prims,
dims=view.dims,
omitted=view.omitted,
bbox=(x0 + offset[0], y0 + offset[1], x1 + offset[0], y1 + offset[1]),
label=view.label,
scale=view.scale,
)
def _view_from_record(part, payload: dict, record: dict) -> View:
"""Rebuild the view a record describes, with the keywords it was drawn with."""
kw = record.get("kw") or {}
return build_view(
part,
record["kind"],
side=str(kw.get("side", "right")),
plane=kw.get("plane"),
style=str(kw.get("style", "full")),
scale=float(kw.get("scale", 1.0)),
projection=str((payload.get("placement") or {}).get("projection", "first")),
detail=kw.get("detail"),
)
async def draw_part(
backend,
part,
*,
at: tuple[float, float] = (0.0, 0.0),
rotation: float = 0.0,
views=("front",),
projection: str = "first",
dimension: bool = True,
style: str = "chain",
scale: float = 1.0,
) -> dict:
"""Draw a part's views, dimension them, and write the model onto the drawing."""
validate_part(part)
kinds = [str(kind).strip().lower() for kind in (views or ())]
if not kinds:
raise ValueError("views: name at least one view to draw.")
handles: list[str] = []
records: list[dict] = []
omitted: list[dict] = []
parent: View | None = None
parent_offset = (0.0, 0.0)
for index, kind in enumerate(kinds):
view = build_view(part, kind, projection=projection, scale=scale)
if index == 0:
offset = _offset_for(view, (float(at[0]), float(at[1])))
else:
lower_left = layout_origin(
_placed(parent, parent_offset),
kind,
projection=projection,
gap=DEFAULT_GAP,
child=view,
)
offset = _offset_for(view, lower_left)
drawn = await draw_prims(backend, view.prims, at=offset, rotation=rotation)
handles.extend(drawn["handles"])
omitted.extend(view.omitted)
records.append(_view_record(view, offset, {"scale": scale}))
if index == 0:
parent, parent_offset = view, offset
anchor = await backend.entity_create_point(float(at[0]), float(at[1]), layer=ANCHOR_LAYER)
payload = {
"v": PAYLOAD_VERSION,
"kind": "part",
"id": anchor.handle,
"view": records[0]["kind"],
"part": part_to_dict(part),
"placement": {
"at": [float(at[0]), float(at[1])],
"rotation": float(rotation),
"projection": projection,
},
"views": records,
}
await backend.entity_set_xdata(anchor.handle, APP_ID, to_values(payload))
result = {
"part_id": anchor.handle,
"views": records,
"handles": handles,
"omitted": omitted,
"backend": backend.name,
}
if dimension:
dimensioned = await dimension_part(backend, anchor.handle, style=style)
result["dimensions"] = dimensioned["count"]
result["handles"].extend(dimensioned["handles"])
return result
async def read_part(backend, part_id: str | None = None) -> dict:
"""The part model written onto the drawing, read back out of its anchor."""
if part_id:
raw = await backend.entity_get_xdata(part_id, APP_ID)
values = (raw.get("xdata") or {}).get(APP_ID) or []
if not values:
raise ValueError(f"{part_id}: carries no {APP_ID} part payload.")
payload = _xdata_payload(values)
return {"part_id": part_id, **payload}
anchors = await backend.entity_list(type_filter="POINT", layer_filter=ANCHOR_LAYER, limit=1000)
found: list[tuple[str, dict]] = []
for anchor in anchors:
raw = await backend.entity_get_xdata(anchor.handle, APP_ID)
values = (raw.get("xdata") or {}).get(APP_ID) or []
if values:
found.append((anchor.handle, _xdata_payload(values)))
if not found:
raise ValueError(
f"this drawing carries no {APP_ID} part payload; draw one with mech_part_draw."
)
if len(found) > 1:
raise ValueError(
"this drawing carries "
f"{len(found)} parts ({', '.join(handle for handle, _ in found)}); "
"name one with part_id."
)
handle, payload = found[0]
return {"part_id": handle, **payload}
async def add_view(backend, part_id: str, kind: str, **kw) -> dict:
"""Add another view of a part already on the drawing, laid out against its first."""
payload = await read_part(backend, part_id)
part = build_part(payload["part"])
records = list(payload.get("views") or [])
if not records:
raise ValueError(f"{part_id}: the payload carries no view to lay this one out against.")
placement = payload.get("placement") or {}
projection = str(placement.get("projection", "first"))
rotation = float(placement.get("rotation", 0.0))
plane = kw.pop("plane", None)
if plane is not None:
plane = normalise_plane(plane)
detail = kw.pop("detail", None)
gap = float(kw.pop("gap", DEFAULT_GAP))
side = str(kw.pop("side", "right"))
style = str(kw.pop("style", "full"))
scale = float(kw.pop("scale", 1.0))
if kw:
raise ValueError(f"add_view: unknown argument(s) {sorted(kw)}.")
view = build_view(
part,
kind,
side=side,
plane=plane,
style=style,
scale=scale,
projection=projection,
detail=detail,
)
parent_record = records[0]
parent_view = _view_from_record(part, payload, parent_record)
lower_left = layout_origin(
_placed(parent_view, parent_record["offset"]),
kind,
side=side,
projection=projection,
gap=gap,
child=view,
)
# step past every view already placed, so a third view never lands on the second
width = view.bbox[2] - view.bbox[0]
for record in records[1:]:
x0, y0, x1, y1 = record["bbox"]
del y0, y1
if lower_left[0] < x1 + gap and lower_left[0] + width > x0 - gap:
lower_left = (x0 - gap - width, lower_left[1])
offset = _offset_for(view, lower_left)
drawn = await draw_prims(backend, view.prims, at=offset, rotation=rotation)
marker_handles: list[str] = []
if kind == "detail":
marker = detail_marker_prims(detail or {})
marker_drawn = await draw_prims(
backend, marker, at=parent_record["offset"], rotation=rotation
)
marker_handles = marker_drawn["handles"]
records.append(
_view_record(
view,
offset,
{
"side": side,
"style": style,
"scale": scale,
"plane": _plane_to_json(plane),
"detail": detail,
},
)
)
payload["views"] = records
await backend.entity_set_xdata(
part_id, APP_ID, to_values({k: v for k, v in payload.items() if k != "part_id"})
)
return {
"part_id": part_id,
"view": records[-1],
"handles": drawn["handles"] + marker_handles,
"omitted": list(view.omitted),
"backend": backend.name,
}
def _plane_to_json(plane: dict | None) -> dict | None:
"""`normalise_plane` answers with tuples; JSON round-trips them as lists."""
if plane is None:
return None
return {
"p1": [float(plane["p1"][0]), float(plane["p1"][1])],
"p2": [float(plane["p2"][0]), float(plane["p2"][1])],
"label": str(plane["label"]),
"direction": [float(plane["direction"][0]), float(plane["direction"][1])],
"style": str(plane["style"]),
"via": [[float(p[0]), float(p[1])] for p in plane.get("via", ())],
}
def _fits_for_view(part, view, fits: dict, *, style: str, threshold: int) -> dict:
"""The subset of ``fits`` this view actually dimensions.
``dimension_intents`` refuses a key that names nothing *in the view it is
given*, which is right for one view and wrong for a part drawn in three:
a fit on ``segment[0]`` names nothing in the end view, and refusing there
would make a correct sheet undrawable. So the split happens here and the
keys nothing anywhere claims are re-offered to :func:`dimension_intents`
afterwards, which refuses them in its own words.
"""
if not fits:
return {}
plain = dimension_intents(part, view, style=style, hole_table_threshold=threshold)
available = {intent.feature for intent in plain if intent.feature}
return {key: code for key, code in fits.items() if str(key) in available}
async def dimension_part(
backend,
part_id: str,
*,
style: str = "chain",
hole_table_threshold: int = 8,
fits: dict | None = None,
) -> dict:
"""Dimension every view of a part, laid out so no two texts overlap."""
payload = await read_part(backend, part_id)
part = build_part(payload["part"])
records = payload.get("views") or []
if fits is not None and not isinstance(fits, dict):
raise ValueError(
f"fits: expected a dict of {{key: ISO 286 code}}, got {type(fits).__name__}."
)
fits = dict(fits or {})
unclaimed = set(fits)
handles: list[str] = []
resolved_rows: list[dict] = []
skipped: list[dict] = []
table_handle: str | None = None
for record in records:
view = _view_from_record(part, payload, record)
mine = _fits_for_view(part, view, fits, style=style, threshold=int(hole_table_threshold))
unclaimed -= set(mine)
intents = dimension_intents(
part, view, style=style, hole_table_threshold=hole_table_threshold, fits=mine or None
)
placed = layout_dimensions(view, intents)
offset = record["offset"]
for intent in placed:
moved = intent._replace(
p1=(intent.p1[0] + offset[0], intent.p1[1] + offset[1]),
p2=(intent.p2[0] + offset[0], intent.p2[1] + offset[1]),
text_at=(intent.text_at[0] + offset[0], intent.text_at[1] + offset[1]),
)
handle, row = await _draw_dimension(backend, moved)
if handle is None:
skipped.append(row)
continue
handles.append(handle)
resolved_rows.append(row)
rows = hole_table_rows(part, view, hole_table_threshold=hole_table_threshold)
if rows and table_handle is None:
info = await backend.entity_create_table(
record["bbox"][2] + DEFAULT_GAP,
record["bbox"][3],
[[r["tag"], f"{r['d']:g}", str(r["qty"]), r["note"]] for r in rows],
["TAG", "DIA", "QTY", "NOTE"],
None,
7.0,
2.5,
"HOLE TABLE",
ROLE_LAYER["text"],
)
table_handle = info.handle
handles.append(info.handle)
if unclaimed:
# No view claimed these keys. Hand them back to the module that owns the
# vocabulary so the refusal names the keys that do exist, and the hole
# table's own message survives.
dimension_intents(
part,
_view_from_record(part, payload, records[0]),
style=style,
hole_table_threshold=hole_table_threshold,
fits={key: fits[key] for key in sorted(unclaimed)},
)
return {
"part_id": part_id,
"count": len(handles) - (1 if table_handle else 0),
"handles": handles,
"resolved": resolved_rows,
"skipped": skipped,
"hole_table": table_handle,
"backend": backend.name,
}
# -- the standalone hole pattern ---------------------------------------------
async def draw_hole_pattern(
backend,
*,
pattern: str,
x: float,
y: float,
diameter: float,
count: int = 2,
spacing: float = 0.0,
count_y: int = 1,
spacing_y: float = 0.0,
pcd: float = 0.0,
start_angle: float = 0.0,
angle: float = 0.0,
layer: str | None = None,
centre_marks: bool = True,
dimension: bool = False,
) -> dict:
"""Draw a hole pattern on any drawing - no part model needed."""
# A standalone pattern has no part, so `Hole.validate` (which needs one) is
# out of reach: the size guard is made here rather than left to the drawing.
diameter = _number(diameter, "diameter")
_number(x, "x", positive=False)
_number(y, "y", positive=False)
expansion = HolePattern(
id="pattern",
pattern=pattern,
base={"x": float(x), "y": float(y), "diameter": diameter},
count=int(count),
spacing=float(spacing),
count_y=int(count_y),
spacing_y=float(spacing_y),
pcd=float(pcd),
start_angle=float(start_angle),
angle=float(angle),
).expand() # validates the pattern before anything is drawn
prims: list[Any] = []
for hole in expansion:
radius = float(hole.diameter) / 2.0
prims.append(Circle((float(hole.x), float(hole.y)), radius, "visible"))
if centre_marks:
reach = radius + CENTRE_MARK_OVERRUN
prims.append(Line((hole.x - reach, hole.y), (hole.x + reach, hole.y), "center"))
prims.append(Line((hole.x, hole.y - reach), (hole.x, hole.y + reach), "center"))
drawn = await draw_prims(backend, prims)
if layer:
# the role map owns the layers; an explicit override moves the circles
# only, so the ISO 128-23 centre marks stay on CENTER.
for handle, prim in zip(drawn["handles"], prims, strict=True):
if isinstance(prim, Circle):
await backend.entity_set_properties(handle, layer=layer)
result = {
"count": len(expansion),
"handles": drawn["handles"],
"centers": [[float(h.x), float(h.y)] for h in expansion],
"backend": backend.name,
}
if dimension:
first = expansion[0]
radius = float(first.diameter) / 2.0
handle, _row = await _draw_dimension(
backend,
DimIntent(
kind="diameter",
p1=(first.x - radius, first.y),
p2=(first.x + radius, first.y),
text_at=(first.x + radius + 12.0, first.y + 12.0),
text_override=f"{len(expansion)}x ⌀{float(first.diameter):g}",
feature="pattern",
),
)
if handle:
result["handles"].append(handle)
return result
# -- the whole sheet ---------------------------------------------------------
async def draw_from_spec(backend, spec: dict) -> dict:
"""A whole mechanical sheet from one declarative spec, inside one transaction.
Every part is validated before the transaction opens, so a refusal leaves
the drawing untouched. The rollback is shielded for the reason
`engineering/pid/spec.py` documents at length: the MCP SDK cancels a request
by cancelling an anyio scope, anyio cancellation is level-triggered, and a
bare ``await`` in the handler would itself be cancelled before the rollback
body ran - leaving half a sheet inside an open transaction.
"""
if not isinstance(spec, dict):
raise ValueError(f"spec: expected a dict, got {type(spec).__name__}.")
raw_parts = spec.get("parts")
if not isinstance(raw_parts, (list, tuple)) or not raw_parts:
raise ValueError("parts: name at least one part to draw.")
jobs: list[dict] = []
for index, item in enumerate(raw_parts):
if not isinstance(item, dict):
raise ValueError(f"parts[{index}]: expected a dict with a 'part' key.")
try:
part = build_part(item.get("part") or {})
except ValueError as exc:
raise ValueError(f"parts[{index}]: {exc}") from exc
at = item.get("at") or [0.0, 0.0]
jobs.append(
{
"part": part,
"at": (float(at[0]), float(at[1])),
"rotation": float(item.get("rotation", 0.0)),
"views": tuple(item.get("views") or ("front",)),
"projection": str(item.get("projection", "first")),
"dimension": bool(item.get("dimension", True)),
"style": str(item.get("style", "chain")),
}
)
sheet = spec.get("sheet") or {}
if backend.get_plan_spec() is None:
await backend.drawing_plan(
sheet.get("intent", "mechanical part sheet"),
sheet.get("sheet_size", "A3"),
float(sheet.get("scale", 1.0)),
sheet.get("layer_set", "mech"),
)
begun = await backend.transaction_begin()
if not isinstance(begun, dict) or not begun.get("ok"):
raise RuntimeError(f"mech_part_from_spec: could not open a transaction: {begun}")
drawn: list[dict] = []
try:
for index, job in enumerate(jobs):
try:
drawn.append(
await draw_part(
backend,
job["part"],
at=job["at"],
rotation=job["rotation"],
views=job["views"],
projection=job["projection"],
dimension=job["dimension"],
style=job["style"],
)
)
except ValueError as exc:
raise ValueError(f"parts[{index}]: {exc}") from exc
except BaseException as exc:
cancelled = isinstance(exc, anyio.get_cancelled_exc_class())
with anyio.CancelScope(shield=True):
try:
await backend.transaction_rollback()
except Exception as rollback_exc:
if not cancelled:
raise
log.error(
"mech_part_from_spec: rollback after a cancelled request failed (%s: %s); "
"the sheet may be half-drawn - check system_status and "
"transaction_rollback before retrying",
type(rollback_exc).__name__,
rollback_exc,
)
raise
await backend.transaction_commit()
return {
"parts": drawn,
"part_ids": [item["part_id"] for item in drawn],
"backend": backend.name,
}
+411 -1
View File
@@ -690,6 +690,7 @@ async def _registered_tool_count() -> int | None:
# entity/drawing tags. Tools whose tags match none fall into "other".
_GROUP_TAG_PRIORITY = (
"pid",
"mech",
"style",
"pagesetup",
"environment",
@@ -718,6 +719,7 @@ _GROUP_TAG_PRIORITY = (
# Map the winning tag to a human-readable group label for the breakdown.
_GROUP_TAG_LABELS = {
"pid": "pid",
"mech": "mech",
"style": "styles",
"pagesetup": "page_setup",
"environment": "environment",
@@ -960,7 +962,7 @@ SOLID_TOOL_NAMES = frozenset(
# mechanically should not pay for the P&ID surface, and a client that never
# talks to a live seat should not pay for the environment surface. Styles and
# page setup (SECTION 18/19) are drafting essentials and stay in core.
TOOL_PACK_NAMES = ("core", "pid", "settings")
TOOL_PACK_NAMES = ("core", "pid", "settings", "mech")
PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
"pid": frozenset(
{
@@ -1002,6 +1004,17 @@ PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
"system_prompt_message",
}
),
# SECTION 21 - the mechanical part model and its view engine (tracks B+G).
"mech": frozenset(
{
"mech_part_draw",
"mech_view_add",
"mech_dimension_part",
"mech_hole_pattern",
"mech_part_from_spec",
"mech_part_inspect",
}
),
}
_active_tool_profile: dict | None = None
@@ -8123,6 +8136,403 @@ async def drawing_properties_set(
return await _backend(ctx).drawing_properties_set(summary, custom)
# ---------------------------------------------------------------------------
# ── SECTION 21: Mechanical Parts (6 tools) ──────────────────────────────────
# ---------------------------------------------------------------------------
@cad_tool(
summary="Draw a machine part from a segment list or an outline plus typed features.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Mechanical: Draw Part", "destructiveHint": False},
tags={"mech", "create"},
)
async def mech_part_draw(
spec: Annotated[
dict,
Field(
description=(
"The part: {kind: 'revolved'|'prismatic', name, material, "
"segments:[{length, d_outer, d_inner, taper_to}] | outline:[[x,y]] + thickness, "
"features:[{kind, id, ...}]}"
)
),
],
at_x: Annotated[
float, Field(default=0.0, description="WCS X of the first view's lower-left")
] = 0.0,
at_y: Annotated[
float, Field(default=0.0, description="WCS Y of the first view's lower-left")
] = 0.0,
rotation: Annotated[
float, Field(default=0.0, description="Degrees CCW applied to every view")
] = 0.0,
views: Annotated[
list[str] | None,
Field(
default=None,
description="Views to draw in order: front | side | top (default ['front'])",
),
] = None,
projection: Annotated[
str, Field(default="first", description="first (ISO 128-30, default) | third")
] = "first",
dimension: Annotated[
bool, Field(default=True, description="Also dimension the part (ISO 129 + ISO 286 fits)")
] = True,
style: Annotated[
str,
Field(default="chain", description="Axial dimension style: chain | baseline | ordinate"),
] = "chain",
ctx: Context = None,
) -> dict:
"""One part model - a turned profile or a plate outline plus typed features - drawn as views.
The model is written onto the drawing as `ACADMCP_MECH` XDATA on an anchor
POINT, so `mech_view_add` can add a section months later without the caller
re-describing the part, and `mech_part_inspect` reads it back.
Refused **before anything is drawn**, by path: a non-finite or non-positive
number (`segments[2].d_outer`), a bore that is not smaller than its outside,
an outline with fewer than three distinct vertices or one that crosses
itself, an unknown material, a feature whose placement is off the part, two
features that would remove the same material (both names given), a thread
outside the transcribed ISO 261 table, a keyway outside DIN 6885's bore
range, gear teeth whose tip diameter disagrees with their segment. A DIN 509
undercut, a DIN 471/472 groove, a DIN 332 centre hole and an ISO 3601-2
O-ring groove need explicit dimensions in this build: those tables are not
transcribed and the lookup refuses by name rather than interpolate. A
feature that cannot be projected into a requested view is listed in
`omitted`, never dropped.
"""
from engineering.mech.draw import draw_part
from engineering.mech.part import build_part
try:
part = build_part(spec)
except ValueError as exc:
raise ToolError(str(exc)) from exc
await ctx.info(f"mech_part_draw: {part.name} at ({at_x}, {at_y})")
try:
return await draw_part(
_backend(ctx),
part,
at=(at_x, at_y),
rotation=rotation,
views=tuple(views or ("front",)),
projection=projection,
dimension=dimension,
style=style,
)
except ValueError as exc:
raise ToolError(str(exc)) from exc
@cad_tool(
summary="Add another view of a drawn part: side, top, section (full/half/offset) or detail.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Mechanical: Add View", "destructiveHint": False},
tags={"mech", "create"},
)
async def mech_view_add(
part_id: Annotated[
str | None,
Field(
default=None,
description="Anchor handle from mech_part_draw; omit if the drawing holds one part",
),
] = None,
kind: Annotated[
str, Field(default="section", description="front | side | top | section | detail")
] = "section",
side: Annotated[
str, Field(default="right", description="Which end an end view looks from: right | left")
] = "right",
style: Annotated[
str,
Field(default="full", description="Section style: full | half | offset | revolved"),
] = "full",
plane: Annotated[
dict | None,
Field(
default=None,
description="Cutting plane {p1:[x,y], p2:[x,y], label, direction:[dx,dy], via:[[x,y]]} - required for a section",
),
] = None,
detail: Annotated[
dict | None,
Field(
default=None,
description="Detail region {center:[x,y], radius, scale, label, of:'front'} - required for a detail",
),
] = None,
gap: Annotated[
float, Field(default=20.0, description="Gap between this view and its parent (mm)")
] = 20.0,
scale: Annotated[
float, Field(default=1.0, description="View scale recorded on the view")
] = 1.0,
ctx: Context = None,
) -> dict:
"""Another view of a part already on the drawing, laid out on its projection axis.
The part model comes back out of the drawing's `ACADMCP_MECH` XDATA, so the
caller never re-describes it, and the placement follows the projection angle
recorded when the part was drawn (first angle by default: the view from the
right goes to the left).
Refused by name: a section without a plane, or with a plane of zero length,
a plane that misses the part, or one that grazes an outline vertex so its
crossings cannot be paired; a half or offset section on the wrong kind of
plane (the message names the style that does apply); a detail without a
radius or with a scale of zero; an unknown part_id, and `part_id=None` on a
drawing that holds more than one part.
"""
from engineering.mech.draw import add_view, read_part
backend = _backend(ctx)
try:
resolved = part_id or (await read_part(backend))["part_id"]
await ctx.info(f"mech_view_add: {kind} on {resolved}")
return await add_view(
backend,
resolved,
kind,
side=side,
style=style,
plane=plane,
detail=detail,
gap=gap,
scale=scale,
)
except ValueError as exc:
raise ToolError(str(exc)) from exc
@cad_tool(
summary="Dimension a drawn part: ISO 129 dimensions, ISO 286 fits, a hole table.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Mechanical: Dimension Part", "destructiveHint": False},
tags={"mech", "dimension"},
)
async def mech_dimension_part(
part_id: Annotated[
str | None,
Field(
default=None,
description="Anchor handle from mech_part_draw; omit if the drawing holds one part",
),
] = None,
style: Annotated[
str,
Field(default="chain", description="Axial dimension style: chain | baseline | ordinate"),
] = "chain",
hole_table_threshold: Annotated[
int,
Field(default=8, description="Above this many holes the diameters move into a hole table"),
] = 8,
fits: Annotated[
dict | None,
Field(
default=None,
description="ISO 286 fit per feature id, e.g. {'bearing_seat': 'k6', 'bore': 'H7'}",
),
] = None,
ctx: Context = None,
) -> dict:
"""Every dimension the part and its features ask for, laid out without overlaps.
Axial lengths are chained, stacked from a baseline or measured from an
origin; diameters stack outside the silhouette; fillets get a radius. Each
measurement appears once (ISO 129-1). `fits` resolves an ISO 286 code
against the measured nominal through the repository's authored tables and
appends the code to the dimension text; an explicit `tol` on a feature's own
intent does the same through the ISO 129 path.
Refused by name: a fit together with an explicit tolerance, a fit outside
the authored ISO 286 range (over 1 mm to 500 mm, IT4-IT11), a fit on an
intent with no measurable nominal, an unknown style, an unknown part_id, and
`part_id=None` on a drawing that holds more than one part. An angular intent
is reported in `skipped` rather than drawn, because a DimIntent carries no
vertex.
"""
from engineering.mech.draw import dimension_part, read_part
backend = _backend(ctx)
try:
resolved = part_id or (await read_part(backend))["part_id"]
await ctx.info(f"mech_dimension_part: {resolved} ({style})")
return await dimension_part(
backend,
resolved,
style=style,
hole_table_threshold=hole_table_threshold,
fits=fits,
)
except ValueError as exc:
raise ToolError(str(exc)) from exc
@cad_tool(
summary="Draw a linear, grid or polar hole pattern with centre marks on any drawing.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Mechanical: Hole Pattern", "destructiveHint": False},
tags={"mech", "create"},
)
async def mech_hole_pattern(
pattern: Annotated[str, Field(description="linear | grid | polar")],
x: Annotated[float, Field(description="WCS X of the first hole (polar: the circle centre)")],
y: Annotated[float, Field(description="WCS Y of the first hole (polar: the circle centre)")],
diameter: Annotated[float, Field(description="Hole diameter in drawing units")],
count: Annotated[
int, Field(default=2, description="Holes along the pattern (polar: around the circle)")
] = 2,
spacing: Annotated[
float, Field(default=0.0, description="Pitch between holes (linear, grid columns)")
] = 0.0,
count_y: Annotated[int, Field(default=1, description="Rows, for a grid")] = 1,
spacing_y: Annotated[float, Field(default=0.0, description="Row pitch, for a grid")] = 0.0,
pcd: Annotated[
float, Field(default=0.0, description="Pitch-circle diameter, for a polar pattern")
] = 0.0,
start_angle: Annotated[
float, Field(default=0.0, description="Angle of the first hole (polar), degrees CCW")
] = 0.0,
angle: Annotated[
float, Field(default=0.0, description="Direction of a linear pattern, degrees CCW")
] = 0.0,
layer: Annotated[
str | None, Field(default=None, description="Override the GEOMETRY layer for the circles")
] = None,
centre_marks: Annotated[
bool, Field(default=True, description="Draw ISO 128-23 centre marks")
] = True,
dimension: Annotated[
bool, Field(default=False, description="Add a '6x ⌀8' diameter callout")
] = False,
ctx: Context = None,
) -> dict:
"""A hole pattern on any drawing - the part model is not required.
The same expansion `HolePattern` performs inside a part, exposed for a
drawing that was not built from the model.
Refused before anything is drawn: an unknown pattern, a count below 1 (below
2 for polar), a linear pattern with no spacing, a grid with more than one
column or row and no pitch for it, a polar pattern with no pitch-circle
diameter, and a non-finite or non-positive diameter.
"""
from engineering.mech.draw import draw_hole_pattern
await ctx.info(f"mech_hole_pattern: {pattern} x{count}")
try:
return await draw_hole_pattern(
_backend(ctx),
pattern=pattern,
x=x,
y=y,
diameter=diameter,
count=count,
spacing=spacing,
count_y=count_y,
spacing_y=spacing_y,
pcd=pcd,
start_angle=start_angle,
angle=angle,
layer=layer,
centre_marks=centre_marks,
dimension=dimension,
)
except ValueError as exc:
raise ToolError(str(exc)) from exc
@cad_tool(
summary="Draw a whole mechanical sheet - several parts and their views - in one transaction.",
cost="mutate",
)
@mcp.tool(
annotations={"title": "Mechanical: Sheet From Spec", "destructiveHint": False},
tags={"mech", "create"},
)
async def mech_part_from_spec(
spec: Annotated[
dict,
Field(
description=(
"{sheet: {intent, sheet_size, scale, layer_set}, "
"parts: [{part: {...}, at: [x, y], rotation, views: ['front','side'], "
"projection, dimension, style}]}"
)
),
],
ctx: Context = None,
) -> dict:
"""Several parts, their views and their dimensions, drawn inside one transaction.
Every part is validated before the transaction opens, so a refusal leaves
the drawing untouched and names the offending path (`parts[1]:
segments[0].d_outer: ...`). If a draw fails or the request is cancelled
part-way, the transaction is rolled back under an anyio shield, so a
cancelled call cannot leave half a sheet inside an open transaction.
Refused by name: an empty `parts` list, any part refusal from
`mech_part_draw`, and a transaction that cannot be opened.
"""
from engineering.mech.draw import draw_from_spec
await ctx.info(f"mech_part_from_spec: {len((spec or {}).get('parts') or [])} part(s)")
try:
return await draw_from_spec(_backend(ctx), spec)
except ValueError as exc:
raise ToolError(str(exc)) from exc
@cad_tool(
summary="Read the part model back off the drawing: segments, features, views, omissions.",
cost="read",
)
@mcp.tool(
annotations={"title": "Mechanical: Inspect Part", "readOnlyHint": True},
tags={"mech", "query"},
)
async def mech_part_inspect(
part_id: Annotated[
str | None,
Field(
default=None,
description="Anchor handle from mech_part_draw; omit if the drawing holds one part",
),
] = None,
ctx: Context = None,
) -> dict:
"""The `ACADMCP_MECH` payload a drawn part carries, decoded.
Never modifies the drawing. Returns the part model exactly as
`mech_part_draw` accepts it (`part`), the views drawn so far with their
placements, and the payload version.
Refused by name: an unknown part_id, a handle that carries no
`ACADMCP_MECH` payload, a corrupt or foreign payload, and `part_id=None` on
a drawing that holds no part or more than one (the message lists the
handles).
"""
from engineering.mech.draw import read_part
try:
return await read_part(_backend(ctx), part_id)
except ValueError as exc:
raise ToolError(str(exc)) from exc
# ---------------------------------------------------------------------------
# ── RESOURCES ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+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