v1.2.0
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:
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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() == []
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user