Adds production-grade engineering features and tools: 2D GD&T (feature control frames + datum features) via engineering/gdt.py, ISO‑129 dimension tolerance support (engineering/tolerances.py) and an ezdxf/COM plumbing for toleranced dims, a scalar drawing score + invalidity ratio (engineering/scoring.py) wired into drawing_finalize, in-place entity editors (entity_edit_text/entity_edit_geometry), and a friendly drawing_settings facade. Includes server tool wiring, COM/ezdxf backend implementations, datum-consistency critique, many unit tests, README/CLAUDE/CHANGELOG updates, and a version bump to 1.2.0. Also corrects COM backend capability label (entities_2d).
This commit is contained in:
Umutcan Edizsalan
2026-07-07 11:03:08 +03:00
parent dfb21a734a
commit 712cf00c5c
18 changed files with 1703 additions and 36 deletions
+108
View File
@@ -0,0 +1,108 @@
"""Tests for ISO 129 dimension tolerances (engineering/tolerances.py + the
ezdxf dimension_* override plumbing)."""
from __future__ import annotations
import pytest
from engineering.tolerances import build_dim_override
pytestmark = pytest.mark.asyncio
# ── pure: override builder ──────────────────────────────────────────────────
async def test_none_mode_is_empty():
override, text = build_dim_override(tol_mode="none")
assert override == {}
assert text is None
async def test_symmetric_sets_equal_tp_tm():
override, _ = build_dim_override(tol_upper=0.05, tol_mode="symmetric")
assert override["dimtol"] == 1
assert override["dimtp"] == 0.05
assert override["dimtm"] == 0.05
async def test_deviation_sets_distinct_tp_tm():
override, _ = build_dim_override(0.02, 0.01, "deviation")
assert override["dimtp"] == 0.02
assert override["dimtm"] == 0.01
assert override["dimtol"] == 1
async def test_limit_mode_sets_dimlim():
override, _ = build_dim_override(0.02, 0.01, "limit")
assert override["dimlim"] == 1
assert override["dimtol"] == 0
async def test_basic_mode_boxes_text():
override, _ = build_dim_override(tol_mode="basic")
assert override["dimgap"] < 0
async def test_text_override_passthrough():
_, text = build_dim_override(text_override="20 H7")
assert text == "20 H7"
async def test_bad_mode_and_missing_values_raise():
with pytest.raises(ValueError):
build_dim_override(tol_mode="wat")
with pytest.raises(ValueError):
build_dim_override(tol_mode="symmetric") # no tol_upper
with pytest.raises(ValueError):
build_dim_override(0.02, None, "deviation") # no tol_lower
# ── backend: toleranced dims are created without error ──────────────────────
async def test_linear_dim_with_symmetric_tolerance(backend):
await backend.drawing_apply_iso_layers("mech")
dim = await backend.dimension_linear(
0,
0,
100,
0,
50,
20,
layer="DIM",
tol_upper=0.05,
tol_mode="symmetric",
)
assert dim.handle
assert dim.type in ("DIMENSION", "DIMLINEAR")
async def test_diameter_dim_with_text_override(backend):
await backend.drawing_apply_iso_layers("mech")
c = await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY")
assert c.handle
dim = await backend.dimension_diameter(
-10,
0,
10,
0,
layer="DIM",
text_override="<> H7",
)
assert dim.handle
async def test_radius_dim_with_deviation_tolerance(backend):
await backend.drawing_apply_iso_layers("mech")
dim = await backend.dimension_radius(
0,
0,
10,
0,
layer="DIM",
tol_upper=0.03,
tol_lower=0.01,
tol_mode="deviation",
)
assert dim.handle
+75
View File
@@ -0,0 +1,75 @@
"""Tests for the drawing_settings facade — friendly read/change of AutoCAD
system variables (units, precision, scales, osnap, …)."""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
async def test_read_snapshot_returns_known_keys(backend):
snap = await backend.drawing_settings()
assert snap["ok"] is True
s = snap["settings"]
for key in ("units", "linear_precision", "ltscale", "dimscale", "osmode"):
assert key in s
async def test_set_and_read_back_units_by_name(backend):
res = await backend.drawing_settings({"units": "mm"})
assert res["ok"] is True
assert res["applied"]["units"] == "mm"
snap = await backend.drawing_settings()
assert snap["settings"]["units"]["name"] == "mm"
assert snap["settings"]["units"]["code"] == 4
async def test_set_units_cm_and_inch_codes(backend):
await backend.drawing_settings({"units": "cm"})
assert (await backend.drawing_settings())["settings"]["units"]["code"] == 5
await backend.drawing_settings({"units": "inch"})
assert (await backend.drawing_settings())["settings"]["units"]["code"] == 1
async def test_set_numeric_settings(backend):
res = await backend.drawing_settings({
"dimscale": 2.0, "linear_precision": 3, "ltscale": 0.5,
})
assert res["ok"] is True
snap = (await backend.drawing_settings())["settings"]
assert snap["dimscale"] == 2.0
assert snap["linear_precision"] == 3
assert snap["ltscale"] == 0.5
async def test_unknown_key_reports_error_without_failing_others(backend):
res = await backend.drawing_settings({"dimscale": 1.5, "bogus_key": 1})
assert res["applied"]["dimscale"] == 1.5
assert "bogus_key" in res["errors"]
assert res["ok"] is False
async def test_bad_unit_value_is_reported(backend):
res = await backend.drawing_settings({"units": "furlongs"})
assert "units" in res["errors"]
# ── server tool wiring ──────────────────────────────────────────────────────
class _FakeCtx:
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
async def info(self, *a, **k):
pass
async def test_server_tool_reads_and_writes(backend):
import server
ctx = _FakeCtx(backend)
write = await server.drawing_settings(settings={"units": "mm", "dimscale": 1.0}, ctx=ctx)
assert write["ok"] is True
read = await server.drawing_settings(ctx=ctx)
assert read["settings"]["units"]["name"] == "mm"
+76
View File
@@ -0,0 +1,76 @@
"""Tests for in-place editing: entity_edit_text and entity_edit_geometry.
These let a user re-label text or re-drive geometry without deleting and
recreating (which would lose the entity handle)."""
from __future__ import annotations
import math
import pytest
pytestmark = pytest.mark.asyncio
# ── entity_edit_text ────────────────────────────────────────────────────────
async def test_edit_text_changes_content_keeps_handle(backend):
t = await backend.entity_create_text("OLD LABEL", 0, 0, height=2.5, layer="TEXT")
updated = await backend.entity_edit_text(t.handle, text="NEW LABEL")
assert updated.handle == t.handle # handle preserved
assert updated.properties["text"] == "NEW LABEL"
async def test_edit_text_changes_height_and_rotation(backend):
t = await backend.entity_create_text("HELLO", 0, 0, height=2.5, layer="TEXT")
updated = await backend.entity_edit_text(t.handle, height=5.0, rotation=90.0)
assert math.isclose(updated.properties["height"], 5.0)
assert math.isclose(updated.properties["rotation"], 90.0)
# Content untouched when text is omitted.
assert updated.properties["text"] == "HELLO"
async def test_edit_mtext_content(backend):
m = await backend.entity_create_mtext("first draft", 0, 0, width=100, height=3, layer="TEXT")
updated = await backend.entity_edit_text(m.handle, text="final copy")
assert "final copy" in updated.properties["text"]
async def test_edit_text_rejects_non_text(backend):
c = await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY")
with pytest.raises(RuntimeError):
await backend.entity_edit_text(c.handle, text="nope")
# ── entity_edit_geometry ────────────────────────────────────────────────────
async def test_edit_circle_radius_and_center(backend):
c = await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY")
updated = await backend.entity_edit_geometry(c.handle, cx=5, cy=5, radius=20)
assert updated.handle == c.handle
assert math.isclose(updated.properties["radius"], 20.0)
center = updated.properties["center"]
assert math.isclose(center[0], 5.0) and math.isclose(center[1], 5.0)
async def test_edit_line_endpoints(backend):
ln = await backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
updated = await backend.entity_edit_geometry(ln.handle, x2=10, y2=10)
end = updated.properties["end"]
assert math.isclose(end[0], 10.0) and math.isclose(end[1], 10.0)
# Start unchanged.
start = updated.properties["start"]
assert math.isclose(start[0], 0.0) and math.isclose(start[1], 0.0)
async def test_edit_arc_angles(backend):
arc = await backend.entity_create_arc(0, 0, 10, 0, 90, layer="GEOMETRY")
updated = await backend.entity_edit_geometry(arc.handle, start_angle=10, end_angle=170)
assert math.isclose(updated.properties["start_angle"], 10.0)
assert math.isclose(updated.properties["end_angle"], 170.0)
async def test_edit_geometry_rejects_unsupported(backend):
poly = await backend.entity_create_polyline([[0, 0], [1, 1]], layer="GEOMETRY")
with pytest.raises(RuntimeError):
await backend.entity_edit_geometry(poly.handle, radius=5)
+143
View File
@@ -0,0 +1,143 @@
"""Tests for 2D GD&T (ISO 1101) — pure layout math, backend composition, and
the datum-consistency critique focus."""
from __future__ import annotations
import pytest
from engineering.gdt import (
DIAMETER_PREFIX,
GEOMETRIC_SYMBOLS,
MATERIAL_MODIFIERS,
datum_triangle,
fcf_compartments,
fcf_layout,
)
pytestmark = pytest.mark.asyncio
# ── pure: compartments ──────────────────────────────────────────────────────
async def test_form_tolerance_needs_no_datum():
comps = fcf_compartments("flatness", 0.1)
assert comps[0] == GEOMETRIC_SYMBOLS["flatness"]
assert comps[1] == "0.1"
assert len(comps) == 2
async def test_position_requires_datum():
with pytest.raises(ValueError):
fcf_compartments("position", 0.2) # location control, no datum
async def test_position_with_datums_and_diameter_and_modifier():
comps = fcf_compartments(
"position",
0.2,
["A", "B", "C"],
diameter=True,
modifier="M",
)
assert comps[0] == GEOMETRIC_SYMBOLS["position"]
assert comps[1] == f"{DIAMETER_PREFIX}0.2{MATERIAL_MODIFIERS['M']}"
assert comps[2:] == ["A", "B", "C"]
async def test_unknown_symbol_and_modifier_raise():
with pytest.raises(ValueError):
fcf_compartments("bogus", 0.1)
with pytest.raises(ValueError):
fcf_compartments("flatness", 0.1, modifier="Z")
# ── pure: layout geometry ───────────────────────────────────────────────────
async def test_fcf_layout_box_closed_and_dividers_count():
comps = fcf_compartments("position", 0.2, ["A", "B"], diameter=True)
lay = fcf_layout(comps, 0.0, 0.0, 5.0)
# Closed rectangle: first == last point.
assert lay["box"][0] == lay["box"][-1]
# n compartments -> n-1 internal dividers, n labels.
assert len(lay["dividers"]) == len(comps) - 1
assert len(lay["labels"]) == len(comps)
assert lay["width"] > 0 and lay["height"] == 5.0
async def test_datum_triangle_is_closed_triangle():
tri = datum_triangle(10.0, 20.0, 4.0, down=True)
assert tri[0] == (10.0, 20.0) # apex
assert tri[0] == tri[-1] # closed
assert len(tri) == 4
# ── backend composition ─────────────────────────────────────────────────────
async def test_draw_feature_control_frame_creates_entities(backend):
await backend.drawing_apply_iso_layers("mech")
res = await backend.draw_feature_control_frame(
"perpendicularity",
0.05,
0.0,
0.0,
datums=["A"],
height=5.0,
)
assert res["ok"] is True
assert res["layer"] == "DIM"
# box + 2 dividers (3 compartments) + 3 texts = several handles
assert len(res["handles"]) >= 5
for h in res["handles"]:
assert await backend.entity_get(h) is not None
async def test_draw_datum_feature_records_letter(backend):
await backend.drawing_apply_iso_layers("mech")
res = await backend.draw_datum_feature("A", 0.0, 0.0, size=5.0)
assert res["datum"] == "A"
assert "A" in backend._gdt_datums_defined
# ── gdt critique focus ──────────────────────────────────────────────────────
async def test_gdt_critique_flags_missing_datum(backend):
await backend.drawing_apply_iso_layers("mech")
await backend.draw_feature_control_frame(
"position",
0.2,
0.0,
0.0,
datums=["A", "B"],
diameter=True,
)
issues = await backend.drawing_critique(focus=["gdt"])
assert len(issues) == 1
assert issues[0].focus == "gdt"
assert issues[0].severity == "error"
assert set(issues[0].detail["missing_datums"]) == {"A", "B"}
async def test_gdt_critique_passes_when_datums_defined(backend):
await backend.drawing_apply_iso_layers("mech")
await backend.draw_feature_control_frame(
"position",
0.2,
0.0,
0.0,
datums=["A"],
diameter=True,
)
await backend.draw_datum_feature("A", 50.0, 0.0)
issues = await backend.drawing_critique(focus=["gdt"])
assert issues == []
async def test_clean_drawing_gdt_focus_is_noop(backend):
await backend.drawing_apply_iso_layers("mech")
# No FCFs at all — gdt must not fire, and full critique stays clean.
assert await backend.drawing_critique(focus=["gdt"]) == []
assert await backend.drawing_critique() == []
+71
View File
@@ -0,0 +1,71 @@
"""Tests for the scalar drawing-score / invalidity-ratio (engineering/scoring.py)
and its wiring into drawing_finalize."""
from __future__ import annotations
import pytest
from engineering.scoring import combine, score_findings
pytestmark = pytest.mark.asyncio
async def test_clean_drawing_scores_100():
s = score_findings(0, 0, 0)
assert s["score"] == 100.0
assert s["invalidity_ratio"] == 0.0
assert s["grade"] == "A"
async def test_errors_dominate_penalty():
err = score_findings(1, 0, 0)["score"]
warn = score_findings(0, 1, 0)["score"]
info = score_findings(0, 0, 1)["score"]
assert err < warn < info < 100.0
async def test_score_clamped_to_zero():
s = score_findings(100, 100, 100)
assert s["score"] == 0.0
assert s["grade"] == "F"
async def test_invalidity_ratio_is_error_fraction():
s = score_findings(1, 3, 0) # 1 of 4 findings is a hard error
assert s["invalidity_ratio"] == 0.25
async def test_combine_sums_summaries():
a = {"error": 1, "warning": 0, "info": 0}
b = {"error": 0, "warning": 2, "info": 1}
combined = combine(a, b)
assert combined["errors"] == 1
assert combined["warnings"] == 2
assert combined["info"] == 1
# ── finalize wiring ─────────────────────────────────────────────────────────
class _FakeCtx:
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
async def warning(self, message):
pass
async def test_finalize_payload_carries_score(backend, tmp_path):
import server
await backend.drawing_apply_iso_layers("mech")
await backend.entity_create_line(0, 0, 100, 0, layer="GEOMETRY")
ctx = _FakeCtx(backend)
payload = await server.drawing_finalize(
save_path=str(tmp_path / "part.dxf"),
ctx=ctx,
)
assert "score" in payload
assert 0.0 <= payload["score"]["score"] <= 100.0
assert "invalidity_ratio" in payload["score"]
assert payload["score"]["grade"] in ("A", "B", "C", "D", "F")