Add deterministic engineering CAD layer (gear, keyway, titleblock, validator)

Introduces an engineering/ package that lets MCP clients produce production-
quality technical drawings without LLM-improvised geometry. Adds 7 new MCP
tools, an 8-step pre-completion validator, and an automatic linetype + layer
bootstrap on drawing_new so CENTER/HIDDEN/PHANTOM and standard mechanical
layers (GEOMETRY, DIM, HATCH, TEXT, TITLEBLOCK, ...) are always available.

- engineering/gear.py: pure-Python involute math, 40+ points/flank, helical
  + spur front views, deterministic section A-A (no helix lines in section).
- engineering/keyway.py: DIN 6885 keyway table + parametric keyed bore /
  side-section drawers.
- engineering/titleblock.py: ISO 7200 A3 (420x297 mm) title block; title
  text is written verbatim to the title cell.
- engineering/validator.py: DrawingValidator with 8 checks (real DIMENSION
  entities, no hidden orphans, linetypes loaded, bore + keyway in both
  views when expected, title consistency e.g. forbid 'HELICAL SPUR GEAR'
  when helix_angle truthy, saved-to-disk + file-on-disk, screenshot
  exported, dwg path returned).
- backends/ezdxf_backend.py: AutoCAD-only linetype fallback (HIDDEN,
  BORDER families) so ezdxf doc gets correct patterns when linetype_load
  is called for names ezdxf.tools.standards does not ship.
- backends/base.py: concrete helpers set_layer_active / ensure_linetypes.
- server.py: Section 12 with 7 engineering tools, drawing_new(bootstrap=
  True), and an ENGINEERING WORKFLOW block in the MCP instructions string.
- 30 new tests covering gear math, DIN6885 lookup, ISO A3 layout, title
  verbatim, and every validator check. Tool count 89 -> 96.
This commit is contained in:
U-C4N
2026-04-28 12:24:08 +03:00
parent 59f3bc5fd5
commit 88a9005a6a
16 changed files with 2320 additions and 10 deletions
+128
View File
@@ -0,0 +1,128 @@
"""Tests for engineering.gear — pure-math + drawing orchestration."""
from __future__ import annotations
import math
import pytest
from engineering.gear import (
draw_gear_section_aa,
draw_helical_gear_front_view,
generate_full_gear_outline,
generate_involute_flank,
involute_xy,
)
from engineering.layers import (
ensure_engineering_layers,
ensure_standard_linetypes,
)
# ---------------------------------------------------------------------------
# Pure math (sync)
# ---------------------------------------------------------------------------
def test_involute_xy_at_zero_returns_base_point():
x, y = involute_xy(10.0, 0.0)
assert math.isclose(x, 10.0, abs_tol=1e-9)
assert math.isclose(y, 0.0, abs_tol=1e-9)
def test_generate_involute_flank_min_30_points():
flank = generate_involute_flank(base_r=20.0, root_r=18.0, outer_r=25.0, n_points=5)
assert len(flank) >= 30
def test_generate_involute_flank_radius_progression():
base_r, root_r, outer_r = 20.0, 18.0, 25.0
flank = generate_involute_flank(base_r=base_r, root_r=root_r, outer_r=outer_r)
first_r = math.hypot(*flank[0])
last_r = math.hypot(*flank[-1])
assert math.isclose(first_r, root_r, abs_tol=1e-6)
assert math.isclose(last_r, outer_r, rel_tol=1e-3)
def test_generate_full_gear_outline_closed():
pts = generate_full_gear_outline(module=3.0, teeth=24)
assert pts[0] == pts[-1]
def test_generate_full_gear_outline_point_count():
teeth = 24
pts = generate_full_gear_outline(module=3.0, teeth=teeth)
# At minimum: teeth * (40 right + 40 left) = 1920 unique flank points,
# plus tip and root arc samples. Closing duplicate adds 1.
assert len(pts) >= teeth * 40
# ---------------------------------------------------------------------------
# Drawing orchestration (async, ezdxf backend)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_helical_gear_front_view_returns_metadata(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
res = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0, hand="RH",
center=(0.0, 0.0),
)
expected_keys = {
"outline", "pitch_circle", "base_circle", "outer_circle", "root_circle",
"helix_symbol", "helix_label", "bore", "keyway", "metadata",
}
assert expected_keys.issubset(res.keys())
md = res["metadata"]
for k in (
"module", "teeth", "pressure_angle", "helix_angle", "hand", "center",
"bore_diameter", "keyway_width", "keyway_depth",
"pitch_radius", "outer_radius", "base_radius", "root_radius",
):
assert k in md, f"metadata missing key {k!r}"
@pytest.mark.asyncio
async def test_helical_gear_with_bore_and_keyway_handles_present(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
res = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0,
bore_diameter=25.0, keyway_width=8.0, keyway_depth=4.0,
)
assert res["bore"] is not None
assert res["keyway"] is not None
assert "bore" in res["keyway"]
assert "keyway_polyline" in res["keyway"]
@pytest.mark.asyncio
async def test_helical_gear_metadata_radii_correct(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
res = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0, pressure_angle=20.0,
)
md = res["metadata"]
assert math.isclose(md["pitch_radius"], 36.0, abs_tol=1e-6)
assert math.isclose(md["outer_radius"], 39.0, abs_tol=1e-6)
assert math.isclose(md["base_radius"], 36.0 * math.cos(math.radians(20.0)), abs_tol=1e-3)
assert math.isclose(md["root_radius"], 32.25, abs_tol=1e-6)
@pytest.mark.asyncio
async def test_section_aa_does_not_draw_helix_lines(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
front = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0,
bore_diameter=25.0, keyway_width=8.0, keyway_depth=4.0,
)
section = await draw_gear_section_aa(
backend, gear_metadata=front["metadata"],
x_offset=200.0, face_width=40.0,
)
assert "helix" not in section
assert "helix_symbol" not in section
+66
View File
@@ -0,0 +1,66 @@
"""Tests for engineering.keyway — DIN 6885 lookup + drawing helpers."""
from __future__ import annotations
import pytest
from engineering.keyway import (
draw_keyed_bore,
draw_keyway_section,
keyway_dimensions,
)
from engineering.layers import (
ensure_engineering_layers,
ensure_standard_linetypes,
)
def test_din6885_lookup_22_30_range():
dims = keyway_dimensions(25.0)
assert dims["width"] == 8.0
assert dims["height"] == 7.0
assert dims["depth_shaft"] == 4.0
def test_din6885_lookup_38_44_range():
dims = keyway_dimensions(40.0)
assert dims["width"] == 12.0
def test_keyway_dimensions_below_min_raises_or_clamps():
with pytest.raises(ValueError):
keyway_dimensions(3.0)
@pytest.mark.asyncio
async def test_draw_keyed_bore_returns_expected_handles(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
res = await draw_keyed_bore(
backend, center=(0.0, 0.0), bore_diameter=25.0,
keyway_width=8.0, keyway_depth=4.0,
)
for k in ("bore", "keyway_polyline", "centerline_h", "centerline_v"):
assert k in res
assert res[k]
@pytest.mark.asyncio
async def test_draw_keyway_section_geometry(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
res = await draw_keyway_section(
backend, center=(0.0, 0.0), bore_diameter=25.0, face_width=40.0,
keyway_width=8.0, keyway_depth=4.0,
)
assert "bore_top" in res
assert "bore_bottom" in res
assert isinstance(res["keyway"], list)
assert len(res["keyway"]) == 4
# Verify the notch top sits above the bore top by reading entities back.
bore_top = await backend.entity_get(res["bore_top"])
bore_top_y = bore_top.properties["start"][1]
notch_top_handle = res["keyway"][2] # notch_top in our return order
notch_top_ent = await backend.entity_get(notch_top_handle)
notch_top_y = notch_top_ent.properties["start"][1]
assert notch_top_y > bore_top_y
+104
View File
@@ -0,0 +1,104 @@
"""Tests for engineering.titleblock — ISO 7200 A3 layout."""
import pytest
from engineering.titleblock import TitleBlockMetadata, apply_iso_a3_titleblock
pytestmark = pytest.mark.asyncio
def _meta(**overrides) -> TitleBlockMetadata:
base = dict(
title="HELICAL GEAR",
drawing_no="AM-2026-001",
part_no="GR-24T-M3",
material="C45",
scale="1:1",
units="mm",
drawn_by="Umutcan",
checked_by="QA",
date="2026-04-28",
sheet="1/1",
revision="A",
company="Anka-Makine",
)
base.update(overrides)
return TitleBlockMetadata(**base)
async def test_titleblock_outer_border_dimensions(backend):
"""Outer border bbox must equal A3 sheet (420 x 297 mm)."""
result = await apply_iso_a3_titleblock(backend, metadata=_meta())
bbox = result["bbox"]
width = bbox["max"][0] - bbox["min"][0]
height = bbox["max"][1] - bbox["min"][1]
assert width == pytest.approx(420.0)
assert height == pytest.approx(297.0)
async def test_title_text_exact_match(backend):
"""Title text in the drawing must equal metadata.title verbatim."""
result = await apply_iso_a3_titleblock(
backend, metadata=_meta(title="HELICAL GEAR"),
)
title_ent = await backend.entity_get(result["title_text"])
assert title_ent.type == "TEXT"
assert title_ent.properties["text"] == "HELICAL GEAR"
async def test_titleblock_creates_text_for_each_metadata_field(backend):
"""value_texts must contain a handle for every metadata field."""
result = await apply_iso_a3_titleblock(backend, metadata=_meta())
expected_keys = {
"drawing_no", "revision", "sheet",
"part_no", "material", "scale",
"drawn_by", "checked_by", "date", "company",
}
assert set(result["value_texts"].keys()) == expected_keys
# All handles must resolve to TEXT entities
for key, handle in result["value_texts"].items():
ent = await backend.entity_get(handle)
assert ent.type == "TEXT", f"{key} expected TEXT, got {ent.type}"
async def test_titleblock_origin_offset(backend):
"""Passing origin=(100,50) must shift the bbox by that amount."""
result = await apply_iso_a3_titleblock(
backend, metadata=_meta(), origin=(100.0, 50.0),
)
bbox = result["bbox"]
assert bbox["min"][0] == pytest.approx(100.0)
assert bbox["min"][1] == pytest.approx(50.0)
assert bbox["max"][0] == pytest.approx(520.0)
assert bbox["max"][1] == pytest.approx(347.0)
async def test_titleblock_borders_on_titleblock_layer(backend):
"""Outer + inner borders + grid lines must be on TITLEBLOCK layer."""
result = await apply_iso_a3_titleblock(backend, metadata=_meta())
outer = await backend.entity_get(result["outer_border"])
inner = await backend.entity_get(result["inner_border"])
assert outer.layer == "TITLEBLOCK"
assert inner.layer == "TITLEBLOCK"
for handle in result["titleblock_lines"]:
ent = await backend.entity_get(handle)
assert ent.layer == "TITLEBLOCK"
async def test_titleblock_text_on_text_layer(backend):
"""Title and all value texts must be on TEXT layer."""
result = await apply_iso_a3_titleblock(backend, metadata=_meta())
title = await backend.entity_get(result["title_text"])
assert title.layer == "TEXT"
for handle in result["value_texts"].values():
ent = await backend.entity_get(handle)
assert ent.layer == "TEXT"
async def test_titleblock_metadata_in_result(backend):
"""Returned metadata dict must round-trip the input metadata."""
md = _meta(title="SPUR GEAR", drawing_no="AM-2026-002")
result = await apply_iso_a3_titleblock(backend, metadata=md)
assert result["metadata"]["title"] == "SPUR GEAR"
assert result["metadata"]["drawing_no"] == "AM-2026-002"
assert result["metadata"]["company"] == "Anka-Makine"
+138
View File
@@ -0,0 +1,138 @@
"""Tests for engineering.validator — 8-step pre-completion validator."""
import os
import pytest
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
from engineering.validator import DrawingValidator
pytestmark = pytest.mark.asyncio
def _codes(result) -> set[str]:
return {f.code for f in result.findings}
async def _ensure_hidden_linetype(backend) -> None:
"""ezdxf doesn't ship a HIDDEN pattern; add a synthetic one so the validator passes."""
doc = backend._require_doc()
if "HIDDEN" not in doc.linetypes:
doc.linetypes.add(
"HIDDEN",
pattern=[6.35, 5.0, -1.27],
description="Hidden _ _ _ _ _ _",
)
async def _setup_clean(backend, tmp_path):
"""Bootstrap layers/linetypes, draw a bore + a real DIMENSION, save to disk."""
await ensure_standard_linetypes(backend)
await _ensure_hidden_linetype(backend)
await ensure_engineering_layers(backend)
# Bore on GEOMETRY layer
await backend.entity_create_circle(0, 0, 12.5, layer="GEOMETRY")
# Real dimension entity
await backend.dimension_linear(0, 0, 25, 0, dim_x=12.5, dim_y=20, layer="DIM")
save_path = str(tmp_path / "clean.dxf")
await backend.drawing_save_as(save_path)
return save_path
async def test_validator_passes_clean_drawing(backend, tmp_path):
await _setup_clean(backend, tmp_path)
result = await DrawingValidator().run(backend)
# Should have zero errors; warnings/info okay (e.g. screenshot info)
assert result.ok is True, f"unexpected findings: {[f.code for f in result.findings]}"
assert result.summary["error"] == 0
async def test_validator_catches_unsaved_drawing(backend):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY")
# Do not save
result = await DrawingValidator().run(backend)
assert "not_saved" in _codes(result)
assert result.ok is False
async def test_validator_catches_missing_file(backend, tmp_path):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
save_path = str(tmp_path / "vanish.dxf")
await backend.drawing_save_as(save_path)
# Delete the file out from under the backend
os.remove(save_path)
result = await DrawingValidator().run(backend)
assert "file_missing" in _codes(result)
assert result.ok is False
async def test_validator_catches_helical_spur_title(backend, tmp_path):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
await backend.entity_create_text(
text="HELICAL SPUR GEAR", x=0, y=0, height=5, layer="TEXT",
)
await backend.drawing_save_as(str(tmp_path / "tb.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 20})
codes = _codes(result)
assert "title_helical_spur" in codes or "title_says_spur_for_helical" in codes
assert result.ok is False
async def test_validator_catches_fake_dimension_text(backend, tmp_path):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
await backend.entity_create_text(
text="Ø78", x=10, y=10, height=3.5, layer="DIM",
)
await backend.drawing_save_as(str(tmp_path / "fake.dxf"))
result = await DrawingValidator().run(backend)
assert "fake_dimension_text" in _codes(result)
async def test_validator_catches_hidden_orphans(backend, tmp_path):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
info = await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY")
await backend.entity_set_properties(info.handle, visible=False)
await backend.drawing_save_as(str(tmp_path / "hidden.dxf"))
result = await DrawingValidator().run(backend)
assert "hidden_orphans" in _codes(result)
assert result.ok is False
async def test_validator_catches_missing_linetypes(backend, tmp_path):
"""Fresh drawing without ensure_standard_linetypes lacks CENTER/HIDDEN/PHANTOM."""
# Skip the bootstrap step entirely
await backend.entity_create_line(0, 0, 100, 0)
await backend.drawing_save_as(str(tmp_path / "bare.dxf"))
result = await DrawingValidator().run(backend)
codes = _codes(result)
assert "linetypes_missing" in codes
# And the missing detail names them
missing_finding = next(f for f in result.findings if f.code == "linetypes_missing")
missing_set = set(missing_finding.detail.get("missing", []))
assert {"center", "hidden", "phantom"}.issubset(missing_set)
async def test_validator_bore_missing_when_expected(backend, tmp_path):
await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend)
# No circle drawn at all
await backend.drawing_save_as(str(tmp_path / "nobore.dxf"))
result = await DrawingValidator().run(
backend, expected={"must_have_bore": True},
)
assert "bore_missing" in _codes(result)
async def test_validation_result_to_dict_serializes(backend):
result = await DrawingValidator().run(backend)
d = result.to_dict()
assert "ok" in d
assert "summary" in d
assert "findings" in d
assert isinstance(d["findings"], list)