style: apply ruff format across the repository

ruff format was never run repo-wide; CI now enforces 'ruff format --check', so normalize every module once. No behavior changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Umutcan Edizsalan
2026-07-23 15:14:51 +03:00
co-authored by Cursor
parent 4a2e9d658f
commit 9ec6c4a989
43 changed files with 785 additions and 376 deletions
+1
View File
@@ -1,4 +1,5 @@
"""AutoCAD MCP Pro – backend package.""" """AutoCAD MCP Pro – backend package."""
from .base import AutoCADBackend, BlockInfo, DrawingInfo, EntityInfo, LayerInfo from .base import AutoCADBackend, BlockInfo, DrawingInfo, EntityInfo, LayerInfo
from .com_backend import ComBackend from .com_backend import ComBackend
from .ezdxf_backend import EzdxfBackend from .ezdxf_backend import EzdxfBackend
+5 -4
View File
@@ -90,10 +90,11 @@ class AutoCADMCPProAdapter(BenchmarkAdapter):
table = await self.backend.entity_create_table( table = await self.backend.entity_create_table(
0, 20, [["A", "1"]], headers=["ITEM", "QTY"], layer="TEXT" 0, 20, [["A", "1"]], headers=["ITEM", "QTY"], layer="TEXT"
) )
leader = await self.backend.leader_create_mleader( leader = await self.backend.leader_create_mleader([[0, 0], [10, 10]], "NOTE", layer="DIM")
[[0, 0], [10, 10]], "NOTE", layer="DIM" representations = [
) table.properties.get("representation"),
representations = [table.properties.get("representation"), leader.properties.get("representation")] leader.properties.get("representation"),
]
return all(representations), {"representations": representations}, [] return all(representations), {"representations": representations}, []
async def _task_transactions(self): async def _task_transactions(self):
+26 -13
View File
@@ -11,6 +11,7 @@ recorded as a miss instead of taking down the whole run.
The older ref is checked out into a throwaway git worktree, benchmarked, and The older ref is checked out into a throwaway git worktree, benchmarked, and
removed. The "new" side is always the current repository checkout. removed. The "new" side is always the current repository checkout.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@@ -27,8 +28,7 @@ PY = sys.executable
def _run(cmd, cwd, repo_on_path): def _run(cmd, cwd, repo_on_path):
env = {**os.environ, "PYTHONPATH": repo_on_path} env = {**os.environ, "PYTHONPATH": repo_on_path}
return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, timeout=120)
text=True, timeout=120)
def list_checks(): def list_checks():
@@ -43,9 +43,14 @@ def list_checks():
def run_one(repo, suite, name): def run_one(repo, suite, name):
try: try:
r = subprocess.run([PY, suite, name], cwd=repo, r = subprocess.run(
env={**os.environ, "PYTHONPATH": repo}, [PY, suite, name],
capture_output=True, text=True, timeout=120) cwd=repo,
env={**os.environ, "PYTHONPATH": repo},
capture_output=True,
text=True,
timeout=120,
)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return "timeout" return "timeout"
last = (r.stdout.strip().splitlines() or [""])[-1].strip() last = (r.stdout.strip().splitlines() or [""])[-1].strip()
@@ -82,22 +87,30 @@ def main():
op = sum(r["old"] == "pass" for r in rows) op = sum(r["old"] == "pass" for r in rows)
npass = sum(r["new"] == "pass" for r in rows) npass = sum(r["new"] == "pass" for r in rows)
summary = { summary = {
"baseline_ref": ref, "total": total, "baseline_ref": ref,
"old_pass": op, "new_pass": npass, "total": total,
"old_pct": round(100 * op / total, 1), "new_pct": round(100 * npass / total, 1), "old_pass": op,
"new_pass": npass,
"old_pct": round(100 * op / total, 1),
"new_pct": round(100 * npass / total, 1),
"fixed": sum(r["old"] != "pass" and r["new"] == "pass" for r in rows), "fixed": sum(r["old"] != "pass" and r["new"] == "pass" for r in rows),
"regressed": sum(r["old"] == "pass" and r["new"] != "pass" for r in rows), "regressed": sum(r["old"] == "pass" and r["new"] != "pass" for r in rows),
} }
print(f"{'CHECK':<32} {'CATEGORY':<13} {'baseline':<8} {'current':<8}") print(f"{'CHECK':<32} {'CATEGORY':<13} {'baseline':<8} {'current':<8}")
print("-" * 66) print("-" * 66)
for r in rows: for r in rows:
mark = " ->FIXED" if (r["old"] != "pass" and r["new"] == "pass") else ( mark = (
" !!REGRESS" if (r["old"] == "pass" and r["new"] != "pass") else "") " ->FIXED"
if (r["old"] != "pass" and r["new"] == "pass")
else (" !!REGRESS" if (r["old"] == "pass" and r["new"] != "pass") else "")
)
print(f"{r['check']:<32} {r['category']:<13} {r['old']:<8} {r['new']:<8}{mark}") print(f"{r['check']:<32} {r['category']:<13} {r['old']:<8} {r['new']:<8}{mark}")
print("-" * 66) print("-" * 66)
print(f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) " print(
f"current: {npass}/{total} ({summary['new_pct']}%) " f"baseline {ref}: {op}/{total} ({summary['old_pct']}%) "
f"fixed: {summary['fixed']} regressed: {summary['regressed']}") f"current: {npass}/{total} ({summary['new_pct']}%) "
f"fixed: {summary['fixed']} regressed: {summary['regressed']}"
)
if json_out: if json_out:
with open(json_out, "w") as fh: with open(json_out, "w") as fh:
json.dump({"summary": summary, "rows": rows}, fh, indent=2) json.dump({"summary": summary, "rows": rows}, fh, indent=2)
+25
View File
@@ -12,6 +12,7 @@ The same file is executed against both the baseline worktree and the current
checkout (PYTHONPATH + cwd point at each repo), so a check that exercises a checkout (PYTHONPATH + cwd point at each repo), so a check that exercises a
method the old version lacks simply raises AttributeError -> miss. method the old version lacks simply raises AttributeError -> miss.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -23,6 +24,7 @@ import tempfile
async def _b(): async def _b():
from backends.ezdxf_backend import EzdxfBackend from backends.ezdxf_backend import EzdxfBackend
be = EzdxfBackend() be = EzdxfBackend()
await be.connect() await be.connect()
await be.drawing_new() await be.drawing_new()
@@ -31,53 +33,62 @@ async def _b():
# ── Core capability (expected to pass on BOTH versions — sanity / not rigged) ── # ── Core capability (expected to pass on BOTH versions — sanity / not rigged) ──
async def core_line_length(): async def core_line_length():
b = await _b() b = await _b()
ln = await b.entity_create_line(0, 0, 3, 4) ln = await b.entity_create_line(0, 0, 3, 4)
info = await b.entity_get(ln.handle) info = await b.entity_get(ln.handle)
return abs(float(info.properties["length"]) - 5.0) < 1e-6 return abs(float(info.properties["length"]) - 5.0) < 1e-6
async def core_circle_radius(): async def core_circle_radius():
b = await _b() b = await _b()
c = await b.entity_create_circle(0, 0, 10) c = await b.entity_create_circle(0, 0, 10)
info = await b.entity_get(c.handle) info = await b.entity_get(c.handle)
return abs(float(info.properties["radius"]) - 10.0) < 1e-6 return abs(float(info.properties["radius"]) - 10.0) < 1e-6
async def core_layer_create(): async def core_layer_create():
b = await _b() b = await _b()
await b.layer_create("BENCH", color=1) await b.layer_create("BENCH", color=1)
names = {lyr.name for lyr in await b.layer_list()} names = {lyr.name for lyr in await b.layer_list()}
return "BENCH" in names return "BENCH" in names
async def core_polyline_closed(): async def core_polyline_closed():
b = await _b() b = await _b()
p = await b.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True) p = await b.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True)
info = await b.entity_get(p.handle) info = await b.entity_get(p.handle)
return bool(info.properties.get("closed")) return bool(info.properties.get("closed"))
async def core_linear_dim(): async def core_linear_dim():
b = await _b() b = await _b()
d = await b.dimension_linear(0, 0, 100, 0, 50, 20) d = await b.dimension_linear(0, 0, 100, 0, 50, 20)
info = await b.entity_get(d.handle) info = await b.entity_get(d.handle)
return "DIM" in info.type.upper() return "DIM" in info.type.upper()
async def core_save_dxf_roundtrip(): async def core_save_dxf_roundtrip():
b = await _b() b = await _b()
await b.entity_create_line(0, 0, 10, 10) await b.entity_create_line(0, 0, 10, 10)
path = os.path.join(tempfile.mkdtemp(), "rt.dxf") path = os.path.join(tempfile.mkdtemp(), "rt.dxf")
await b.drawing_save_as(path, "dxf") await b.drawing_save_as(path, "dxf")
import ezdxf import ezdxf
ezdxf.readfile(path) # raises if not a valid DXF ezdxf.readfile(path) # raises if not a valid DXF
return os.path.getsize(path) > 0 return os.path.getsize(path) > 0
# ── Dimensions (I16) ────────────────────────────────────────────────────────── # ── Dimensions (I16) ──────────────────────────────────────────────────────────
async def dim_aligned_no_error(): async def dim_aligned_no_error():
b = await _b() b = await _b()
d = await b.dimension_aligned(0, 0, 50, 50, 30, 70) d = await b.dimension_aligned(0, 0, 50, 50, 30, 70)
return d is not None return d is not None
async def dim_angular_no_error(): async def dim_angular_no_error():
b = await _b() b = await _b()
d = await b.dimension_angular(0, 0, 100, 0, 0, 100, 60, 60) d = await b.dimension_angular(0, 0, 100, 0, 0, 100, 60, 60)
@@ -86,6 +97,7 @@ async def dim_angular_no_error():
# ── Polar array full circle (R9) ────────────────────────────────────────────── # ── Polar array full circle (R9) ──────────────────────────────────────────────
async def array_polar_360_distinct(): async def array_polar_360_distinct():
b = await _b() b = await _b()
c = await b.entity_create_circle(10, 0, 1) c = await b.entity_create_circle(10, 0, 1)
@@ -101,6 +113,7 @@ async def array_polar_360_distinct():
# ── Deterministic geometry (I9) ──────────────────────────────────────────────── # ── Deterministic geometry (I9) ────────────────────────────────────────────────
async def point_intersection_line_line(): async def point_intersection_line_line():
b = await _b() b = await _b()
l1 = await b.entity_create_line(0, 0, 10, 0) l1 = await b.entity_create_line(0, 0, 10, 0)
@@ -108,6 +121,7 @@ async def point_intersection_line_line():
pt = await b.point_intersection(l1.handle, l2.handle) pt = await b.point_intersection(l1.handle, l2.handle)
return abs(pt[0] - 5.0) < 1e-6 and abs(pt[1] - 0.0) < 1e-6 return abs(pt[0] - 5.0) < 1e-6 and abs(pt[1] - 0.0) < 1e-6
async def point_tangent_external(): async def point_tangent_external():
b = await _b() b = await _b()
c = await b.entity_create_circle(0, 0, 3) c = await b.entity_create_circle(0, 0, 3)
@@ -120,18 +134,21 @@ async def point_tangent_external():
# ── Selection / property parity (N3, N5) ─────────────────────────────────────── # ── Selection / property parity (N3, N5) ───────────────────────────────────────
async def arc_has_length(): async def arc_has_length():
b = await _b() b = await _b()
a = await b.entity_create_arc(0, 0, 10, 0, 90) a = await b.entity_create_arc(0, 0, 10, 0, 90)
info = await b.entity_get(a.handle) info = await b.entity_get(a.handle)
return abs(float(info.properties["length"]) - (10 * math.pi / 2)) < 1e-3 return abs(float(info.properties["length"]) - (10 * math.pi / 2)) < 1e-3
async def arc_select_by_length(): async def arc_select_by_length():
b = await _b() b = await _b()
await b.entity_create_arc(0, 0, 10, 0, 90) # length ~15.7 await b.entity_create_arc(0, 0, 10, 0, 90) # length ~15.7
sel = await b.entity_select_smart({"type": "ARC", "length_range": [15, 16]}) sel = await b.entity_select_smart({"type": "ARC", "length_range": [15, 16]})
return len(sel) == 1 return len(sel) == 1
async def ezdxf_bounding_box(): async def ezdxf_bounding_box():
b = await _b() b = await _b()
ln = await b.entity_create_line(0, 0, 30, 40) ln = await b.entity_create_line(0, 0, 30, 40)
@@ -139,6 +156,7 @@ async def ezdxf_bounding_box():
bb = info.properties.get("bounding_box") bb = info.properties.get("bounding_box")
return isinstance(bb, dict) and set(bb) == {"min", "max"} return isinstance(bb, dict) and set(bb) == {"min", "max"}
async def mtext_rotation_roundtrip(): async def mtext_rotation_roundtrip():
b = await _b() b = await _b()
mt = await b.entity_create_mtext("r", 0, 0, width=50, height=2.5, rotation=30.0) mt = await b.entity_create_mtext("r", 0, 0, width=50, height=2.5, rotation=30.0)
@@ -148,6 +166,7 @@ async def mtext_rotation_roundtrip():
# ── Rendering (screenshot Agg / SIGSEGV) ─────────────────────────────────────── # ── Rendering (screenshot Agg / SIGSEGV) ───────────────────────────────────────
async def screenshot_png(): async def screenshot_png():
b = await _b() b = await _b()
await b.entity_create_line(0, 0, 100, 100) await b.entity_create_line(0, 0, 100, 100)
@@ -157,6 +176,7 @@ async def screenshot_png():
# ── Quality gate / critique (R4, N4) ─────────────────────────────────────────── # ── Quality gate / critique (R4, N4) ───────────────────────────────────────────
async def dim_overlap_critique_fires(): async def dim_overlap_critique_fires():
b = await _b() b = await _b()
await b.drawing_apply_iso_layers("mech") await b.drawing_apply_iso_layers("mech")
@@ -165,6 +185,7 @@ async def dim_overlap_critique_fires():
issues = await b.drawing_critique(focus=["dim_overlap"]) issues = await b.drawing_critique(focus=["dim_overlap"])
return len(issues) >= 1 return len(issues) >= 1
async def iso13567_dim_layer(): async def iso13567_dim_layer():
b = await _b() b = await _b()
await b.drawing_apply_iso_layers("iso13567") await b.drawing_apply_iso_layers("iso13567")
@@ -172,6 +193,7 @@ async def iso13567_dim_layer():
dims = await b.dimension_auto([ln.handle], style="chain") dims = await b.dimension_auto([ln.handle], style="chain")
return dims[0].layer == "M-DIMEN-T-N" return dims[0].layer == "M-DIMEN-T-N"
async def construction_left_iso_caught(): async def construction_left_iso_caught():
b = await _b() b = await _b()
await b.drawing_apply_iso_layers("iso13567") await b.drawing_apply_iso_layers("iso13567")
@@ -182,8 +204,10 @@ async def construction_left_iso_caught():
# ── Engineering (gear) ────────────────────────────────────────────────────────── # ── Engineering (gear) ──────────────────────────────────────────────────────────
async def gear_no_self_overlap(): async def gear_no_self_overlap():
from engineering.gear import generate_full_gear_outline from engineering.gear import generate_full_gear_outline
pts = generate_full_gear_outline(2, 50, 20.0) # z=50 -> root_r >= base_r pts = generate_full_gear_outline(2, 50, 20.0) # z=50 -> root_r >= base_r
root_r = (2 * 50 / 2.0) - 1.25 * 2 root_r = (2 * 50 / 2.0) - 1.25 * 2
return min(math.hypot(x, y) for x, y in pts) >= root_r - 1e-6 return min(math.hypot(x, y) for x, y in pts) >= root_r - 1e-6
@@ -191,6 +215,7 @@ async def gear_no_self_overlap():
# ── Linetype on demand (R24) ─────────────────────────────────────────────────── # ── Linetype on demand (R24) ───────────────────────────────────────────────────
async def center_linetype_applied(): async def center_linetype_applied():
b = await _b() b = await _b()
ln = await b.entity_create_line(0, 0, 50, 0, linetype="CENTER") ln = await b.entity_create_line(0, 0, 50, 0, linetype="CENTER")
+95 -72
View File
@@ -29,8 +29,13 @@ _EXPECTED_LAYER_COLOR: dict[str, int] = {
# Dimension entity types that should sit on the DIM layer. # Dimension entity types that should sit on the DIM layer.
_DIM_TYPES = { _DIM_TYPES = {
"DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR", "DIMENSION",
"DIMRADIUS", "DIMDIAMETER", "DIMORDINATE", "DIMLINEAR",
"DIMALIGNED",
"DIMANGULAR",
"DIMRADIUS",
"DIMDIAMETER",
"DIMORDINATE",
} }
@@ -57,15 +62,17 @@ async def _check_iso128(backend: AutoCADBackend) -> list[Issue]:
# (e.g. 25 == 0.25mm) — they are NOT pre-normalised to mm, so accept # (e.g. 25 == 0.25mm) — they are NOT pre-normalised to mm, so accept
# either the raw mm value or hundredths/100. # either the raw mm value or hundredths/100.
if lw not in allowed and (lw / 100.0) not in allowed: if lw not in allowed and (lw / 100.0) not in allowed:
issues.append(Issue( issues.append(
severity="warning", Issue(
focus="iso128", severity="warning",
message=( focus="iso128",
f"Layer '{lyr.name}' lineweight {lw} mm is not in the " message=(
f"ISO 128 set {sorted(allowed)}." f"Layer '{lyr.name}' lineweight {lw} mm is not in the "
), f"ISO 128 set {sorted(allowed)}."
detail={"layer": lyr.name, "lineweight": lw}, ),
)) detail={"layer": lyr.name, "lineweight": lw},
)
)
return issues return issues
@@ -79,17 +86,19 @@ async def _check_layer_color(backend: AutoCADBackend) -> list[Issue]:
for lyr in layers: for lyr in layers:
expected = _EXPECTED_LAYER_COLOR.get(lyr.name) expected = _EXPECTED_LAYER_COLOR.get(lyr.name)
if expected is None: if expected is None:
continue # unknown layer — caller's responsibility continue # unknown layer — caller's responsibility
if int(lyr.color) != int(expected): if int(lyr.color) != int(expected):
issues.append(Issue( issues.append(
severity="warning", Issue(
focus="layer_color", severity="warning",
message=( focus="layer_color",
f"Layer '{lyr.name}' has color {lyr.color}; " message=(
f"engineering standard expects {expected}." f"Layer '{lyr.name}' has color {lyr.color}; "
), f"engineering standard expects {expected}."
detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected}, ),
)) detail={"layer": lyr.name, "got": int(lyr.color), "expected": expected},
)
)
return issues return issues
@@ -107,17 +116,21 @@ async def _check_construction_left(backend: AutoCADBackend) -> list[Issue]:
leftover = [e for e in ents if "CONST" in (e.layer or "").upper()] leftover = [e for e in ents if "CONST" in (e.layer or "").upper()]
if not leftover: if not leftover:
return [] return []
return [Issue( return [
severity="error", Issue(
focus="construction_left", severity="error",
message=( focus="construction_left",
f"{len(leftover)} entity/entities still on a construction layer. " message=(
"Call construction_clear() before drawing_finalize." f"{len(leftover)} entity/entities still on a construction layer. "
), "Call construction_clear() before drawing_finalize."
handles=[e.handle for e in leftover[:50]], ),
detail={"count": len(leftover), handles=[e.handle for e in leftover[:50]],
"layers": sorted({e.layer for e in leftover if e.layer})}, detail={
)] "count": len(leftover),
"layers": sorted({e.layer for e in leftover if e.layer}),
},
)
]
async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]: async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
@@ -150,16 +163,18 @@ async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
if key in seen: if key in seen:
continue continue
seen.add(key) seen.add(key)
issues.append(Issue( issues.append(
severity="warning", Issue(
focus="untrimmed_corner", severity="warning",
message=( focus="untrimmed_corner",
f"Lines {h1} and {h2} have endpoints {d:.3f} mm apart — " message=(
"likely an untrimmed corner (use entity_trim or entity_fillet)." f"Lines {h1} and {h2} have endpoints {d:.3f} mm apart — "
), "likely an untrimmed corner (use entity_trim or entity_fillet)."
handles=[h1, h2], ),
detail={"gap_mm": d, "p1": list(p1), "p2": list(p2)}, handles=[h1, h2],
)) detail={"gap_mm": d, "p1": list(p1), "p2": list(p2)},
)
)
return issues return issues
@@ -180,12 +195,14 @@ async def _check_duplicate_entities(backend: AutoCADBackend) -> list[Issue]:
k2 = (k1[2], k1[3], k1[0], k1[1]) k2 = (k1[2], k1[3], k1[0], k1[1])
for key in (k1, k2): for key in (k1, k2):
if key in seen: if key in seen:
issues.append(Issue( issues.append(
severity="warning", Issue(
focus="duplicate_entities", severity="warning",
message=f"Lines {seen[key]} and {ln.handle} are duplicates.", focus="duplicate_entities",
handles=[seen[key], ln.handle], message=f"Lines {seen[key]} and {ln.handle} are duplicates.",
)) handles=[seen[key], ln.handle],
)
)
break break
else: else:
seen[k1] = ln.handle seen[k1] = ln.handle
@@ -243,12 +260,14 @@ async def _check_dim_overlap(backend: AutoCADBackend) -> list[Issue]:
if key in seen: if key in seen:
continue continue
seen.add(key) seen.add(key)
issues.append(Issue( issues.append(
severity="warning", Issue(
focus="dim_overlap", severity="warning",
message=f"Dimensions {h1} and {h2} are {d:.2f} mm apart — likely overlapping.", focus="dim_overlap",
handles=[h1, h2], message=f"Dimensions {h1} and {h2} are {d:.2f} mm apart — likely overlapping.",
)) handles=[h1, h2],
)
)
return issues return issues
@@ -267,19 +286,21 @@ async def _check_gdt(backend: AutoCADBackend) -> list[Issue]:
missing = sorted(referenced - defined) missing = sorted(referenced - defined)
if not missing: if not missing:
return [] return []
return [Issue( return [
severity="error", Issue(
focus="gdt", severity="error",
message=( focus="gdt",
f"Feature control frame(s) reference datum(s) {missing} with no " message=(
"matching datum feature. Add draw_datum_feature for each datum." f"Feature control frame(s) reference datum(s) {missing} with no "
), "matching datum feature. Add draw_datum_feature for each datum."
detail={ ),
"missing_datums": missing, detail={
"defined": sorted(defined), "missing_datums": missing,
"referenced": sorted(referenced), "defined": sorted(defined),
}, "referenced": sorted(referenced),
)] },
)
]
_FOCUS_DISPATCH = { _FOCUS_DISPATCH = {
@@ -303,11 +324,13 @@ async def run_critique(
for f in foci: for f in foci:
check = _FOCUS_DISPATCH.get(f) check = _FOCUS_DISPATCH.get(f)
if check is None: if check is None:
issues.append(Issue( issues.append(
severity="warning", Issue(
focus=f, # type: ignore[arg-type] severity="warning",
message=f"Unknown critique focus '{f}' (closed enum).", focus=f, # type: ignore[arg-type]
)) message=f"Unknown critique focus '{f}' (closed enum).",
)
)
continue continue
issues.extend(await check(backend)) issues.extend(await check(backend))
return issues return issues
+4 -4
View File
@@ -163,7 +163,9 @@ async def deliver_drawing(
expected: dict | None = None, expected: dict | None = None,
) -> DeliveryResult: ) -> DeliveryResult:
"""Export, validate, hash and re-open a release-ready drawing bundle.""" """Export, validate, hash and re-open a release-ready drawing bundle."""
requested = list(dict.fromkeys(item.lower().strip() for item in (formats or ["dxf", "pdf", "png"]))) requested = list(
dict.fromkeys(item.lower().strip() for item in (formats or ["dxf", "pdf", "png"]))
)
unknown = [item for item in requested if item not in _FORMAT_FILENAMES] unknown = [item for item in requested if item not in _FORMAT_FILENAMES]
if unknown: if unknown:
raise ValueError(f"Unsupported delivery format(s): {', '.join(unknown)}") raise ValueError(f"Unsupported delivery format(s): {', '.join(unknown)}")
@@ -210,9 +212,7 @@ async def deliver_drawing(
artifact_path.write_bytes(png) artifact_path.write_bytes(png)
artifacts.append(_artifact_record(fmt, artifact_path, status="created")) artifacts.append(_artifact_record(fmt, artifact_path, status="created"))
except Exception as exc: except Exception as exc:
artifacts.append( artifacts.append(_artifact_record(fmt, artifact_path, status="failed", error=str(exc)))
_artifact_record(fmt, artifact_path, status="failed", error=str(exc))
)
finally: finally:
timings[f"export_{fmt}_ms"] = round((time.perf_counter() - export_started) * 1000, 2) timings[f"export_{fmt}_ms"] = round((time.perf_counter() - export_started) * 1000, 2)
+35 -13
View File
@@ -67,16 +67,18 @@ def generate_involute_flank(
return points return points
def _rotate_points( def _rotate_points(pts: list[tuple[float, float]], angle: float) -> list[tuple[float, float]]:
pts: list[tuple[float, float]], angle: float
) -> list[tuple[float, float]]:
c, s = math.cos(angle), math.sin(angle) c, s = math.cos(angle), math.sin(angle)
return [(c * x - s * y, s * x + c * y) for x, y in pts] return [(c * x - s * y, s * x + c * y) for x, y in pts]
def _arc_points( def _arc_points(
cx: float, cy: float, radius: float, cx: float,
start_angle: float, end_angle: float, n: int = 5, cy: float,
radius: float,
start_angle: float,
end_angle: float,
n: int = 5,
) -> list[tuple[float, float]]: ) -> list[tuple[float, float]]:
"""Sample n points along an arc (start_angle → end_angle, radians, ccw).""" """Sample n points along an arc (start_angle → end_angle, radians, ccw)."""
if n < 2: if n < 2:
@@ -232,20 +234,29 @@ async def draw_helical_gear_front_view(
root_circ = await backend.entity_create_circle(cx, cy, root_r, layer="GEOMETRY") root_circ = await backend.entity_create_circle(cx, cy, root_r, layer="GEOMETRY")
pts = generate_full_gear_outline( pts = generate_full_gear_outline(
module=module, teeth=teeth, pressure_angle=pressure_angle, module=module,
helix_angle=helix_angle, hand=hand, center=(cx, cy), teeth=teeth,
pressure_angle=pressure_angle,
helix_angle=helix_angle,
hand=hand,
center=(cx, cy),
) )
outline = await backend.entity_create_polyline( outline = await backend.entity_create_polyline(
[[float(x), float(y)] for x, y in pts], [[float(x), float(y)] for x, y in pts],
closed=True, layer="GEOMETRY", closed=True,
layer="GEOMETRY",
) )
helix_handles: list[str] | None = None helix_handles: list[str] | None = None
helix_label: str | None = None helix_label: str | None = None
if helix_angle and abs(helix_angle) > 1e-9: if helix_angle and abs(helix_angle) > 1e-9:
helix_handles, helix_label = await _draw_helix_symbol( helix_handles, helix_label = await _draw_helix_symbol(
backend, cx=cx, cy=cy, outer_r=outer_r, backend,
helix_angle=helix_angle, hand=hand, cx=cx,
cy=cy,
outer_r=outer_r,
helix_angle=helix_angle,
hand=hand,
) )
bore_handle: str | None = None bore_handle: str | None = None
@@ -263,7 +274,10 @@ async def draw_helical_gear_front_view(
keyway_dict = keyed keyway_dict = keyed
else: else:
bore_only = await backend.entity_create_circle( bore_only = await backend.entity_create_circle(
cx, cy, bore_diameter / 2.0, layer="GEOMETRY", cx,
cy,
bore_diameter / 2.0,
layer="GEOMETRY",
) )
bore_handle = bore_only.handle bore_handle = bore_only.handle
@@ -385,10 +399,18 @@ async def draw_gear_section_aa(
bore_handles = [keyway_dict["bore_top"], keyway_dict["bore_bottom"]] bore_handles = [keyway_dict["bore_top"], keyway_dict["bore_bottom"]]
else: else:
bore_top = await backend.entity_create_line( bore_top = await backend.entity_create_line(
x_left, cy + bore_r, x_right, cy + bore_r, layer="GEOMETRY", x_left,
cy + bore_r,
x_right,
cy + bore_r,
layer="GEOMETRY",
) )
bore_bot = await backend.entity_create_line( bore_bot = await backend.entity_create_line(
x_left, cy - bore_r, x_right, cy - bore_r, layer="GEOMETRY", x_left,
cy - bore_r,
x_right,
cy - bore_r,
layer="GEOMETRY",
) )
bore_handles = [bore_top.handle, bore_bot.handle] bore_handles = [bore_top.handle, bore_bot.handle]
+43 -9
View File
@@ -45,7 +45,9 @@ def keyway_dimensions(bore_diameter: float) -> dict:
f"({DIN6885_TABLE[-1][1]} mm)." f"({DIN6885_TABLE[-1][1]} mm)."
) )
for b_min, b_max, width, height, t1 in DIN6885_TABLE: for b_min, b_max, width, height, t1 in DIN6885_TABLE:
if b_min < bore_diameter <= b_max or (bore_diameter == b_min and b_min == DIN6885_TABLE[0][0]): if b_min < bore_diameter <= b_max or (
bore_diameter == b_min and b_min == DIN6885_TABLE[0][0]
):
return { return {
"width": float(width), "width": float(width),
"height": float(height), "height": float(height),
@@ -95,10 +97,18 @@ async def draw_keyed_bore(
overshoot = max(r * 0.2, 5.0) overshoot = max(r * 0.2, 5.0)
horiz = await backend.entity_create_line( horiz = await backend.entity_create_line(
cx - r - overshoot, cy, cx + r + overshoot, cy, layer=center_layer, cx - r - overshoot,
cy,
cx + r + overshoot,
cy,
layer=center_layer,
) )
vert = await backend.entity_create_line( vert = await backend.entity_create_line(
cx, cy - r - overshoot, cx, cy + r + depth + overshoot, layer=center_layer, cx,
cy - r - overshoot,
cx,
cy + r + depth + overshoot,
layer=center_layer,
) )
return { return {
@@ -142,23 +152,47 @@ async def draw_keyway_section(
notch_right = cx + half_kw notch_right = cx + half_kw
bore_top = await backend.entity_create_line( bore_top = await backend.entity_create_line(
x_left, top_y, x_right, top_y, layer=layer, x_left,
top_y,
x_right,
top_y,
layer=layer,
) )
bore_bottom = await backend.entity_create_line( bore_bottom = await backend.entity_create_line(
x_left, bot_y, x_right, bot_y, layer=layer, x_left,
bot_y,
x_right,
bot_y,
layer=layer,
) )
notch_bottom = await backend.entity_create_line( notch_bottom = await backend.entity_create_line(
notch_left, top_y, notch_right, top_y, layer=layer, notch_left,
top_y,
notch_right,
top_y,
layer=layer,
) )
notch_left_wall = await backend.entity_create_line( notch_left_wall = await backend.entity_create_line(
notch_left, top_y, notch_left, notch_top_y, layer=layer, notch_left,
top_y,
notch_left,
notch_top_y,
layer=layer,
) )
notch_top = await backend.entity_create_line( notch_top = await backend.entity_create_line(
notch_left, notch_top_y, notch_right, notch_top_y, layer=layer, notch_left,
notch_top_y,
notch_right,
notch_top_y,
layer=layer,
) )
notch_right_wall = await backend.entity_create_line( notch_right_wall = await backend.entity_create_line(
notch_right, notch_top_y, notch_right, top_y, layer=layer, notch_right,
notch_top_y,
notch_right,
top_y,
layer=layer,
) )
return { return {
+45 -41
View File
@@ -13,52 +13,52 @@ log = logging.getLogger(__name__)
ENGINEERING_LAYERS: list[tuple[str, int, str, float, str]] = [ ENGINEERING_LAYERS: list[tuple[str, int, str, float, str]] = [
# (name, color, linetype, lineweight, description) # (name, color, linetype, lineweight, description)
("0", 7, "Continuous", 0.25, "default"), ("0", 7, "Continuous", 0.25, "default"),
("GEOMETRY", 7, "Continuous", 0.50, "visible solid edges"), ("GEOMETRY", 7, "Continuous", 0.50, "visible solid edges"),
("HIDDEN", 3, "HIDDEN", 0.25, "hidden edges"), ("HIDDEN", 3, "HIDDEN", 0.25, "hidden edges"),
("CENTER", 1, "CENTER", 0.18, "centerlines"), ("CENTER", 1, "CENTER", 0.18, "centerlines"),
("PHANTOM", 6, "PHANTOM", 0.18, "section / cutting planes"), ("PHANTOM", 6, "PHANTOM", 0.18, "section / cutting planes"),
("DIM", 2, "Continuous", 0.18, "dimensions"), ("DIM", 2, "Continuous", 0.18, "dimensions"),
("TEXT", 7, "Continuous", 0.25, "annotations"), ("TEXT", 7, "Continuous", 0.25, "annotations"),
("HATCH", 8, "Continuous", 0.13, "section hatching"), ("HATCH", 8, "Continuous", 0.13, "section hatching"),
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"), ("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"), ("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"),
] ]
# P&ID standard layer set (de-facto, distilled from ISO 10628 + common practice). # P&ID standard layer set (de-facto, distilled from ISO 10628 + common practice).
PID_LAYERS: list[tuple[str, int, str, float, str]] = [ PID_LAYERS: list[tuple[str, int, str, float, str]] = [
("0", 7, "Continuous", 0.25, "default"), ("0", 7, "Continuous", 0.25, "default"),
("PROCESS-PIPING-MAIN", 1, "Continuous", 0.70, "main process line (heavy)"), ("PROCESS-PIPING-MAIN", 1, "Continuous", 0.70, "main process line (heavy)"),
("PROCESS-PIPING-SECONDARY", 2, "Continuous", 0.50, "secondary process line"), ("PROCESS-PIPING-SECONDARY", 2, "Continuous", 0.50, "secondary process line"),
("PROCESS-EQUIPMENT", 7, "Continuous", 0.50, "vessels / pumps / drums"), ("PROCESS-EQUIPMENT", 7, "Continuous", 0.50, "vessels / pumps / drums"),
("PROCESS-VALVES", 3, "Continuous", 0.35, "valves / fittings"), ("PROCESS-VALVES", 3, "Continuous", 0.35, "valves / fittings"),
("INSTRUMENT-SYMBOL", 4, "Continuous", 0.35, "instrument bubbles / FCFs"), ("INSTRUMENT-SYMBOL", 4, "Continuous", 0.35, "instrument bubbles / FCFs"),
("INSTRUMENT-LINE-SIGNAL", 4, "DASHED", 0.18, "pneumatic / electrical signal"), ("INSTRUMENT-LINE-SIGNAL", 4, "DASHED", 0.18, "pneumatic / electrical signal"),
("INSTRUMENT-TAG-TEXT", 7, "Continuous", 0.25, "tag labels"), ("INSTRUMENT-TAG-TEXT", 7, "Continuous", 0.25, "tag labels"),
("ELECTRICAL-LINE", 6, "DASHDOT", 0.18, "electrical line"), ("ELECTRICAL-LINE", 6, "DASHDOT", 0.18, "electrical line"),
("UTILITY-LINE", 5, "PHANTOM", 0.25, "utility (steam, water, air)"), ("UTILITY-LINE", 5, "PHANTOM", 0.25, "utility (steam, water, air)"),
("INSULATION-HATCH", 8, "Continuous", 0.13, "insulation hatch"), ("INSULATION-HATCH", 8, "Continuous", 0.13, "insulation hatch"),
("DIM", 2, "Continuous", 0.18, "dimensions"), ("DIM", 2, "Continuous", 0.18, "dimensions"),
("TEXT-NOTES", 7, "Continuous", 0.25, "free-form notes"), ("TEXT-NOTES", 7, "Continuous", 0.25, "free-form notes"),
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"), ("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"), ("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"),
] ]
# ISO 13567-style layer naming kit (Agent2-Element6-Presentation2-Status1). # ISO 13567-style layer naming kit (Agent2-Element6-Presentation2-Status1).
# This is a *starter* set — extend as needed per discipline. # This is a *starter* set — extend as needed per discipline.
ISO13567_LAYERS: list[tuple[str, int, str, float, str]] = [ ISO13567_LAYERS: list[tuple[str, int, str, float, str]] = [
("0", 7, "Continuous", 0.25, "default"), ("0", 7, "Continuous", 0.25, "default"),
("M-GEOMET-E-N", 7, "Continuous", 0.50, "Mechanical / geometry / edge / new"), ("M-GEOMET-E-N", 7, "Continuous", 0.50, "Mechanical / geometry / edge / new"),
("M-HIDDEN-E-N", 3, "HIDDEN", 0.25, "Mechanical / hidden edge"), ("M-HIDDEN-E-N", 3, "HIDDEN", 0.25, "Mechanical / hidden edge"),
("M-CENTL-E-N", 1, "CENTER", 0.18, "Mechanical / centerlines"), ("M-CENTL-E-N", 1, "CENTER", 0.18, "Mechanical / centerlines"),
("M-PHANT-E-N", 6, "PHANTOM", 0.18, "Mechanical / phantom"), ("M-PHANT-E-N", 6, "PHANTOM", 0.18, "Mechanical / phantom"),
("M-DIMEN-T-N", 2, "Continuous", 0.18, "Mechanical / dimension text"), ("M-DIMEN-T-N", 2, "Continuous", 0.18, "Mechanical / dimension text"),
("M-TEXT-T-N", 7, "Continuous", 0.25, "Mechanical / annotation text"), ("M-TEXT-T-N", 7, "Continuous", 0.25, "Mechanical / annotation text"),
("M-HATCH-H-N", 8, "Continuous", 0.13, "Mechanical / hatch"), ("M-HATCH-H-N", 8, "Continuous", 0.13, "Mechanical / hatch"),
("M-TITLE-T-N", 7, "Continuous", 0.50, "Title block"), ("M-TITLE-T-N", 7, "Continuous", 0.50, "Title block"),
("M-CONST-E-N", 250, "Continuous", 0.05, "Construction (scratch)"), ("M-CONST-E-N", 250, "Continuous", 0.05, "Construction (scratch)"),
] ]
@@ -73,9 +73,9 @@ LAYER_SET_REGISTRY: dict[str, list[tuple[str, int, str, float, str]]] = {
# "DIM"/"CONSTRUCTION" (which only exist in the mech/pid sets — iso13567 uses # "DIM"/"CONSTRUCTION" (which only exist in the mech/pid sets — iso13567 uses
# M-DIMEN-T-N / M-CONST-E-N). # M-DIMEN-T-N / M-CONST-E-N).
LAYER_ROLES: dict[str, dict[str, str]] = { LAYER_ROLES: dict[str, dict[str, str]] = {
"mech": {"dim": "DIM", "construction": "CONSTRUCTION"}, "mech": {"dim": "DIM", "construction": "CONSTRUCTION"},
"pid": {"dim": "DIM", "construction": "CONSTRUCTION"}, "pid": {"dim": "DIM", "construction": "CONSTRUCTION"},
"iso13567": {"dim": "M-DIMEN-T-N", "construction": "M-CONST-E-N"}, "iso13567": {"dim": "M-DIMEN-T-N", "construction": "M-CONST-E-N"},
} }
@@ -85,6 +85,7 @@ def resolve_role_layer(layer_set_id: str | None, role: str) -> str:
roles = LAYER_ROLES.get(layer_set_id or "mech", LAYER_ROLES["mech"]) roles = LAYER_ROLES.get(layer_set_id or "mech", LAYER_ROLES["mech"])
return roles.get(role, LAYER_ROLES["mech"][role]) return roles.get(role, LAYER_ROLES["mech"][role])
STANDARD_LINETYPES: list[str] = ["CENTER", "HIDDEN", "PHANTOM", "DASHED", "DASHDOT"] STANDARD_LINETYPES: list[str] = ["CENTER", "HIDDEN", "PHANTOM", "DASHED", "DASHDOT"]
@@ -115,14 +116,14 @@ async def ensure_engineering_layers(backend: AutoCADBackend) -> dict[str, str]:
async def apply_layer_set( async def apply_layer_set(
backend: AutoCADBackend, standard: str = "mech", backend: AutoCADBackend,
standard: str = "mech",
) -> dict[str, str]: ) -> dict[str, str]:
"""Idempotently apply a named layer set ('mech', 'pid', 'iso13567').""" """Idempotently apply a named layer set ('mech', 'pid', 'iso13567')."""
layer_set = LAYER_SET_REGISTRY.get(standard) layer_set = LAYER_SET_REGISTRY.get(standard)
if layer_set is None: if layer_set is None:
raise RuntimeError( raise RuntimeError(
f"Unknown layer set '{standard}'. " f"Unknown layer set '{standard}'. Choose one of: {sorted(LAYER_SET_REGISTRY)}"
f"Choose one of: {sorted(LAYER_SET_REGISTRY)}"
) )
await ensure_standard_linetypes(backend) await ensure_standard_linetypes(backend)
@@ -139,7 +140,10 @@ async def apply_layer_set(
continue continue
try: try:
await backend.layer_create( await backend.layer_create(
name=name, color=color, linetype=linetype, lineweight=lineweight, name=name,
color=color,
linetype=linetype,
lineweight=lineweight,
) )
results[name] = "created" results[name] = "created"
except Exception as exc: except Exception as exc:
+9 -1
View File
@@ -91,5 +91,13 @@ class Issue:
# ISO 128 lineweight set (mm). Any lineweight outside this set is flagged. # ISO 128 lineweight set (mm). Any lineweight outside this set is flagged.
ISO_128_LINEWEIGHTS_MM: tuple[float, ...] = ( ISO_128_LINEWEIGHTS_MM: tuple[float, ...] = (
0.13, 0.18, 0.25, 0.35, 0.50, 0.70, 1.00, 1.40, 2.00, 0.13,
0.18,
0.25,
0.35,
0.50,
0.70,
1.00,
1.40,
2.00,
) )
+1 -3
View File
@@ -99,9 +99,7 @@ def preflight_drawing(
assumptions.append({"field": "units", "value": "mm", "reason": "default"}) assumptions.append({"field": "units", "value": "mm", "reason": "default"})
if allow_assumptions and not req.get("tolerance_policy"): if allow_assumptions and not req.get("tolerance_policy"):
req["tolerance_policy"] = "general" req["tolerance_policy"] = "general"
assumptions.append( assumptions.append({"field": "tolerance_policy", "value": "general", "reason": "default"})
{"field": "tolerance_policy", "value": "general", "reason": "default"}
)
if isinstance(req.get("units"), str): if isinstance(req.get("units"), str):
req["units"] = req["units"].lower() req["units"] = req["units"].lower()
+2 -6
View File
@@ -64,12 +64,8 @@ async def repair_untrimmed_corner(backend: AutoCADBackend, issue: Issue) -> dict
midpoint = ((float(p1[0]) + float(p2[0])) / 2, (float(p1[1]) + float(p2[1])) / 2) midpoint = ((float(p1[0]) + float(p2[0])) / 2, (float(p1[1]) + float(p2[1])) / 2)
first = await backend.entity_get(issue.handles[0]) first = await backend.entity_get(issue.handles[0])
second = await backend.entity_get(issue.handles[1]) second = await backend.entity_get(issue.handles[1])
await backend.entity_edit_geometry( await backend.entity_edit_geometry(issue.handles[0], **_endpoint_kwargs(first, p1, midpoint))
issue.handles[0], **_endpoint_kwargs(first, p1, midpoint) await backend.entity_edit_geometry(issue.handles[1], **_endpoint_kwargs(second, p2, midpoint))
)
await backend.entity_edit_geometry(
issue.handles[1], **_endpoint_kwargs(second, p2, midpoint)
)
return {"handles": list(issue.handles), "corner": list(midpoint)} return {"handles": list(issue.handles), "corner": list(midpoint)}
+29 -7
View File
@@ -72,7 +72,9 @@ async def apply_iso_a3_titleblock(
[ox, oy + SHEET_H], [ox, oy + SHEET_H],
] ]
outer = await backend.entity_create_polyline( outer = await backend.entity_create_polyline(
points=outer_pts, closed=True, layer=LAYER_BORDER, points=outer_pts,
closed=True,
layer=LAYER_BORDER,
) )
inner_x0 = ox + LEFT_MARGIN inner_x0 = ox + LEFT_MARGIN
@@ -86,7 +88,9 @@ async def apply_iso_a3_titleblock(
[inner_x0, inner_y1], [inner_x0, inner_y1],
] ]
inner = await backend.entity_create_polyline( inner = await backend.entity_create_polyline(
points=inner_pts, closed=True, layer=LAYER_BORDER, points=inner_pts,
closed=True,
layer=LAYER_BORDER,
) )
# ---- title block (lower-right corner of inner border) ---------------------- # ---- title block (lower-right corner of inner border) ----------------------
@@ -103,7 +107,9 @@ async def apply_iso_a3_titleblock(
[tb_x0, tb_y1], [tb_x0, tb_y1],
] ]
tb_frame = await backend.entity_create_polyline( tb_frame = await backend.entity_create_polyline(
points=tb_frame_pts, closed=True, layer=LAYER_BORDER, points=tb_frame_pts,
closed=True,
layer=LAYER_BORDER,
) )
# Row baselines (from bottom up): row4_top, row3_top, row2_top # Row baselines (from bottom up): row4_top, row3_top, row2_top
@@ -115,13 +121,21 @@ async def apply_iso_a3_titleblock(
async def hline(y: float) -> str: async def hline(y: float) -> str:
ent = await backend.entity_create_line( ent = await backend.entity_create_line(
tb_x0, y, tb_x1, y, layer=LAYER_BORDER, tb_x0,
y,
tb_x1,
y,
layer=LAYER_BORDER,
) )
return ent.handle return ent.handle
async def vline(x: float, y0: float, y1: float) -> str: async def vline(x: float, y0: float, y1: float) -> str:
ent = await backend.entity_create_line( ent = await backend.entity_create_line(
x, y0, x, y1, layer=LAYER_BORDER, x,
y0,
x,
y1,
layer=LAYER_BORDER,
) )
return ent.handle return ent.handle
@@ -157,13 +171,21 @@ async def apply_iso_a3_titleblock(
async def label(text: str, x: float, y: float) -> str: async def label(text: str, x: float, y: float) -> str:
ent = await backend.entity_create_text( ent = await backend.entity_create_text(
text=text, x=x, y=y, height=LABEL_HEIGHT, layer=LAYER_TEXT, text=text,
x=x,
y=y,
height=LABEL_HEIGHT,
layer=LAYER_TEXT,
) )
return ent.handle return ent.handle
async def value(text: str, x: float, y: float, height: float = VALUE_HEIGHT) -> str: async def value(text: str, x: float, y: float, height: float = VALUE_HEIGHT) -> str:
ent = await backend.entity_create_text( ent = await backend.entity_create_text(
text=text, x=x, y=y, height=height, layer=LAYER_TEXT, text=text,
x=x,
y=y,
height=height,
layer=LAYER_TEXT,
) )
return ent.handle return ent.handle
+131 -91
View File
@@ -12,8 +12,13 @@ if TYPE_CHECKING:
REAL_DIMENSION_TYPES = { REAL_DIMENSION_TYPES = {
"DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR", "DIMENSION",
"DIMRADIUS", "DIMDIAMETER", "DIMORDINATE", "DIMLINEAR",
"DIMALIGNED",
"DIMANGULAR",
"DIMRADIUS",
"DIMDIAMETER",
"DIMORDINATE",
} }
# Patterns that suggest a TEXT/MTEXT entity is masquerading as a dimension. # Patterns that suggest a TEXT/MTEXT entity is masquerading as a dimension.
@@ -35,7 +40,7 @@ _TITLE_LAYERS = {"TITLE", "TITLEBLOCK"}
class ValidationFinding: class ValidationFinding:
"""One issue surfaced by the validator.""" """One issue surfaced by the validator."""
severity: str # "error" | "warning" | "info" severity: str # "error" | "warning" | "info"
code: str code: str
message: str message: str
detail: dict = field(default_factory=dict) detail: dict = field(default_factory=dict)
@@ -84,40 +89,47 @@ class DrawingValidator:
try: try:
ents = await backend.entity_list(limit=10000) ents = await backend.entity_list(limit=10000)
text_likes = [ text_likes = [
e for e in ents e
for e in ents
if e.type in ("TEXT", "MTEXT") if e.type in ("TEXT", "MTEXT")
and DIMENSION_LIKE_TEXT.search( and DIMENSION_LIKE_TEXT.search(getattr(e, "properties", {}).get("text", "") or "")
getattr(e, "properties", {}).get("text", "") or ""
)
] ]
real_dims = [e for e in ents if e.type in REAL_DIMENSION_TYPES] real_dims = [e for e in ents if e.type in REAL_DIMENSION_TYPES]
if text_likes: if text_likes:
findings.append(ValidationFinding( findings.append(
"warning", ValidationFinding(
"fake_dimension_text", "warning",
f"{len(text_likes)} TEXT/MTEXT entities look like dimensions " "fake_dimension_text",
"(Ø, ±, R, etc.) — use dimension_* tools instead", f"{len(text_likes)} TEXT/MTEXT entities look like dimensions "
{ "(Ø, ±, R, etc.) — use dimension_* tools instead",
"sample_handles": [t.handle for t in text_likes[:5]], {
"real_dim_count": len(real_dims), "sample_handles": [t.handle for t in text_likes[:5]],
}, "real_dim_count": len(real_dims),
)) },
)
)
except Exception as exc: except Exception as exc:
findings.append(ValidationFinding( findings.append(
"warning", "dim_check_failed", str(exc), ValidationFinding(
)) "warning",
"dim_check_failed",
str(exc),
)
)
# 2 — hidden orphans # 2 — hidden orphans
try: try:
hidden = [e for e in ents if not e.visible] hidden = [e for e in ents if not e.visible]
if hidden: if hidden:
findings.append(ValidationFinding( findings.append(
"error", ValidationFinding(
"hidden_orphans", "error",
f"{len(hidden)} entities have visible=False — hide-fallback " "hidden_orphans",
"is forbidden, use entity_delete", f"{len(hidden)} entities have visible=False — hide-fallback "
{"sample_handles": [h.handle for h in hidden[:5]]}, "is forbidden, use entity_delete",
)) {"sample_handles": [h.handle for h in hidden[:5]]},
)
)
except Exception: except Exception:
pass pass
@@ -126,45 +138,53 @@ class DrawingValidator:
lts = {x.lower() for x in await backend.linetype_list()} lts = {x.lower() for x in await backend.linetype_list()}
missing = [lt for lt in ("center", "hidden", "phantom") if lt not in lts] missing = [lt for lt in ("center", "hidden", "phantom") if lt not in lts]
if missing: if missing:
findings.append(ValidationFinding( findings.append(
"error", ValidationFinding(
"linetypes_missing", "error",
f"Standard linetypes not loaded: {', '.join(missing).upper()}", "linetypes_missing",
{"missing": missing}, f"Standard linetypes not loaded: {', '.join(missing).upper()}",
)) {"missing": missing},
)
)
except Exception as exc: except Exception as exc:
findings.append(ValidationFinding( findings.append(
"warning", "linetype_check_failed", str(exc), ValidationFinding(
)) "warning",
"linetype_check_failed",
str(exc),
)
)
# 4 — gear views (heuristic, only if expected demands) # 4 — gear views (heuristic, only if expected demands)
if expected.get("must_have_bore") or expected.get("must_have_keyway"): if expected.get("must_have_bore") or expected.get("must_have_keyway"):
bore_circles = [ bore_circles = [e for e in ents if e.type == "CIRCLE" and e.layer == "GEOMETRY"]
e for e in ents
if e.type == "CIRCLE" and e.layer == "GEOMETRY"
]
if expected.get("must_have_bore") and len(bore_circles) < 1: if expected.get("must_have_bore") and len(bore_circles) < 1:
findings.append(ValidationFinding( findings.append(
"error", ValidationFinding(
"bore_missing", "error",
"Expected bore but no circle on GEOMETRY layer found", "bore_missing",
)) "Expected bore but no circle on GEOMETRY layer found",
)
)
# draw_keyed_bore emits the keyway notch as an OPEN 4-point polyline # draw_keyed_bore emits the keyway notch as an OPEN 4-point polyline
# on GEOMETRY — requiring `closed` here made this a permanent # on GEOMETRY — requiring `closed` here made this a permanent
# false-negative. Match a small (3-6 point) polyline, open or closed. # false-negative. Match a small (3-6 point) polyline, open or closed.
keyway_polys = [ keyway_polys = [
e for e in ents e
for e in ents
if e.type in ("LWPOLYLINE", "POLYLINE") if e.type in ("LWPOLYLINE", "POLYLINE")
and e.layer == "GEOMETRY" and e.layer == "GEOMETRY"
and 3 <= len(e.properties.get("points", [])) <= 6 and 3 <= len(e.properties.get("points", [])) <= 6
] ]
if expected.get("must_have_keyway") and len(keyway_polys) < 1: if expected.get("must_have_keyway") and len(keyway_polys) < 1:
findings.append(ValidationFinding( findings.append(
"warning", ValidationFinding(
"keyway_section_unverified", "warning",
"Could not confirm keyway via heuristic " "keyway_section_unverified",
"(small 3-6 point polyline on GEOMETRY)", "Could not confirm keyway via heuristic "
)) "(small 3-6 point polyline on GEOMETRY)",
)
)
# 5 — title consistency # 5 — title consistency
helix = expected.get("helix_angle") helix = expected.get("helix_angle")
@@ -190,19 +210,23 @@ class DrawingValidator:
has_spur = bool(_SPUR_RE.search(joined)) has_spur = bool(_SPUR_RE.search(joined))
has_helical = bool(_HELICAL_RE.search(joined)) has_helical = bool(_HELICAL_RE.search(joined))
if has_spur and has_helical: if has_spur and has_helical:
findings.append(ValidationFinding( findings.append(
"error", ValidationFinding(
"title_helical_spur", "error",
"Title contains 'HELICAL SPUR GEAR' — invalid; use " "title_helical_spur",
"'HELICAL GEAR' or 'SPUR GEAR'", "Title contains 'HELICAL SPUR GEAR' — invalid; use "
)) "'HELICAL GEAR' or 'SPUR GEAR'",
)
)
elif has_spur: elif has_spur:
findings.append(ValidationFinding( findings.append(
"error", ValidationFinding(
"title_says_spur_for_helical", "error",
f"Title contains SPUR but part is helical (helix_angle={helix})", "title_says_spur_for_helical",
{"sample": joined[:200]}, f"Title contains SPUR but part is helical (helix_angle={helix})",
)) {"sample": joined[:200]},
)
)
# 6 — saved to disk # 6 — saved to disk
try: try:
@@ -210,46 +234,62 @@ class DrawingValidator:
saved_attr = getattr(info, "saved", None) saved_attr = getattr(info, "saved", None)
full_path = getattr(info, "full_path", "") or "" full_path = getattr(info, "full_path", "") or ""
if not saved_attr: if not saved_attr:
findings.append(ValidationFinding( findings.append(
"error", ValidationFinding(
"not_saved", "error",
"Drawing has unsaved changes — call drawing_save before finalize", "not_saved",
{"full_path": full_path}, "Drawing has unsaved changes — call drawing_save before finalize",
)) {"full_path": full_path},
)
)
elif full_path and not Path(full_path).exists(): elif full_path and not Path(full_path).exists():
findings.append(ValidationFinding( findings.append(
"error", ValidationFinding(
"file_missing", "error",
f"Drawing reports saved=True but file not on disk: {full_path}", "file_missing",
)) f"Drawing reports saved=True but file not on disk: {full_path}",
)
)
except Exception as exc: except Exception as exc:
findings.append(ValidationFinding( findings.append(
"warning", "save_check_failed", str(exc), ValidationFinding(
)) "warning",
"save_check_failed",
str(exc),
)
)
# 7 — screenshot # 7 — screenshot
try: try:
shot = await backend.view_screenshot() shot = await backend.view_screenshot()
if not shot: if not shot:
findings.append(ValidationFinding( findings.append(
"info", ValidationFinding(
"screenshot_unavailable", "info",
"Screenshot returned no bytes (ezdxf needs matplotlib install)", "screenshot_unavailable",
)) "Screenshot returned no bytes (ezdxf needs matplotlib install)",
)
)
except Exception as exc: except Exception as exc:
findings.append(ValidationFinding( findings.append(
"info", "screenshot_failed", str(exc), ValidationFinding(
)) "info",
"screenshot_failed",
str(exc),
)
)
# 8 — dwg path returned # 8 — dwg path returned
try: try:
info = await backend.drawing_info() info = await backend.drawing_info()
if not getattr(info, "full_path", ""): if not getattr(info, "full_path", ""):
findings.append(ValidationFinding( findings.append(
"warning", ValidationFinding(
"no_path", "warning",
"drawing_info.full_path is empty", "no_path",
)) "drawing_info.full_path is empty",
)
)
except Exception: except Exception:
pass pass
+6 -4
View File
@@ -61,10 +61,12 @@ async def _run() -> int:
print(f"ok (center=({cx},{cy}), r=5)") print(f"ok (center=({cx},{cy}), r=5)")
print("[6/9] entity_select_smart ...", end=" ") print("[6/9] entity_select_smart ...", end=" ")
geom = await backend.entity_select_smart({ geom = await backend.entity_select_smart(
"type": "LINE", {
"layer": "GEOMETRY", "type": "LINE",
}) "layer": "GEOMETRY",
}
)
assert len(geom) >= 2, len(geom) assert len(geom) >= 2, len(geom)
print(f"ok ({len(geom)} lines selected)") print(f"ok ({len(geom)} lines selected)")
+44 -24
View File
@@ -106,32 +106,50 @@ def validate_path(path: str, *, allow_write: bool = False) -> Path:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_DANGEROUS_COMMANDS = [ _DANGEROUS_COMMANDS = [
"ERASE", "_ERASE", "ERASE",
"DELETE", "_DELETE", "_ERASE",
"PURGE", "_PURGE", "DELETE",
"QUIT", "_QUIT", "_DELETE",
"CLOSE", "_CLOSE", "PURGE",
"QSAVE", "_QSAVE", "_PURGE",
"SAVEAS", "_SAVEAS", "QUIT",
"NEW", "_NEW", "_QUIT",
"OPEN", "_OPEN", "CLOSE",
"RECOVER", "_RECOVER", "_CLOSE",
"WBLOCK", "_WBLOCK", "QSAVE",
"INSERT", "_INSERT", "_QSAVE",
"XREF", "_XREF", "SAVEAS",
"SHELL", "_SHELL", "_SAVEAS",
"SCRIPT", "_SCRIPT", "NEW",
"APPLOAD", "_APPLOAD", "_NEW",
"NETLOAD", "_NETLOAD", "OPEN",
"VBARUN", "_VBARUN", "_OPEN",
"VBALOAD", "_VBALOAD", "RECOVER",
"SECURITYOPTIONS", "_SECURITYOPTIONS", "_RECOVER",
"WBLOCK",
"_WBLOCK",
"INSERT",
"_INSERT",
"XREF",
"_XREF",
"SHELL",
"_SHELL",
"SCRIPT",
"_SCRIPT",
"APPLOAD",
"_APPLOAD",
"NETLOAD",
"_NETLOAD",
"VBARUN",
"_VBARUN",
"VBALOAD",
"_VBALOAD",
"SECURITYOPTIONS",
"_SECURITYOPTIONS",
] ]
_DANGEROUS_COMMAND_PATTERNS = re.compile( _DANGEROUS_COMMAND_PATTERNS = re.compile(
r"\b(" r"\b(" + "|".join(re.escape(cmd) for cmd in _DANGEROUS_COMMANDS) + r")\b",
+ "|".join(re.escape(cmd) for cmd in _DANGEROUS_COMMANDS)
+ r")\b",
re.IGNORECASE, re.IGNORECASE,
) )
@@ -213,7 +231,9 @@ def sanitize_lisp(expression: str) -> str:
raise ToolError("LISP expression cannot be empty.") raise ToolError("LISP expression cannot be empty.")
if config.settings.dangerous_commands_enabled: if config.settings.dangerous_commands_enabled:
log.info("Dangerous commands enabled — bypassing LISP sanitization for: %s", expression[:80]) log.info(
"Dangerous commands enabled — bypassing LISP sanitization for: %s", expression[:80]
)
return expression return expression
if _DANGEROUS_LISP_PATTERN.search(expression): if _DANGEROUS_LISP_PATTERN.search(expression):
+4 -1
View File
@@ -68,7 +68,10 @@ async def test_analysis_entity_stats(backend):
stats = await backend.analysis_stats() stats = await backend.analysis_stats()
assert isinstance(stats, dict) assert isinstance(stats, dict)
by_type = stats.get("by_type", stats) by_type = stats.get("by_type", stats)
total = stats.get("total", stats.get("total_entities", sum(by_type.values()) if isinstance(by_type, dict) else 0)) total = stats.get(
"total",
stats.get("total_entities", sum(by_type.values()) if isinstance(by_type, dict) else 0),
)
assert total >= 3 assert total >= 3
+11 -4
View File
@@ -51,8 +51,12 @@ async def test_batch_modify_delete(backend):
async def test_template_architectural_layers(backend): async def test_template_architectural_layers(backend):
"""Architectural template should create standard layers.""" """Architectural template should create standard layers."""
layer_defs = [ layer_defs = [
("WALLS", 7), ("DOORS", 3), ("WINDOWS", 4), ("WALLS", 7),
("FURNITURE", 8), ("DIMENSIONS", 2), ("TEXT", 7), ("DOORS", 3),
("WINDOWS", 4),
("FURNITURE", 8),
("DIMENSIONS", 2),
("TEXT", 7),
] ]
for name, color in layer_defs: for name, color in layer_defs:
await backend.layer_create(name, color=color) await backend.layer_create(name, color=color)
@@ -67,8 +71,11 @@ async def test_template_architectural_layers(backend):
async def test_template_mechanical_layers(backend): async def test_template_mechanical_layers(backend):
"""Mechanical template layers.""" """Mechanical template layers."""
layer_defs = [ layer_defs = [
("VISIBLE", 7), ("HIDDEN", 1), ("CENTER", 3), ("VISIBLE", 7),
("DIMENSIONS", 2), ("SECTION", 5), ("HIDDEN", 1),
("CENTER", 3),
("DIMENSIONS", 2),
("SECTION", 5),
] ]
for name, color in layer_defs: for name, color in layer_defs:
await backend.layer_create(name, color=color) await backend.layer_create(name, color=color)
+10 -4
View File
@@ -48,6 +48,7 @@ async def test_active_document_switch_clears_com_document_state(monkeypatch):
assert backend._preflight_result is None assert backend._preflight_result is None
assert backend._gdt_datums_defined == set() assert backend._gdt_datums_defined == set()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# normalize_lineweight — parametrized table # normalize_lineweight — parametrized table
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -107,7 +108,12 @@ def test_normalize_lineweight_non_numeric_passthrough():
# _safe_send_command — CMDACTIVE polling / timeout / var-restore # _safe_send_command — CMDACTIVE polling / timeout / var-restore
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _make_mock_doc(cmdactive_sequence: list[int], modelspace_handles_pre: set[str] | None = None, modelspace_handles_post: set[str] | None = None) -> MagicMock:
def _make_mock_doc(
cmdactive_sequence: list[int],
modelspace_handles_pre: set[str] | None = None,
modelspace_handles_post: set[str] | None = None,
) -> MagicMock:
"""Build a minimal mock AutoCAD document for _safe_send_command tests.""" """Build a minimal mock AutoCAD document for _safe_send_command tests."""
doc = MagicMock() doc = MagicMock()
@@ -270,9 +276,9 @@ async def test_dimension_linear_uses_add_dim_rotated():
mspace.AddDimRotated.assert_called_once() mspace.AddDimRotated.assert_called_once()
mspace.AddDimLinear.assert_not_called() mspace.AddDimLinear.assert_not_called()
args = mspace.AddDimRotated.call_args[0] args = mspace.AddDimRotated.call_args[0]
assert args[0] == (0.0, 0.0) # ext line 1 origin assert args[0] == (0.0, 0.0) # ext line 1 origin
assert args[1] == (100.0, 0.0) # ext line 2 origin assert args[1] == (100.0, 0.0) # ext line 2 origin
assert args[2] == (50.0, 20.0) # dim line location assert args[2] == (50.0, 20.0) # dim line location
assert args[3] == pytest.approx(0.0) # rotation in radians assert args[3] == pytest.approx(0.0) # rotation in radians
+6 -2
View File
@@ -53,6 +53,7 @@ class _Doc:
# ── NEW-com-set-variable-coercion ─────────────────────────────────────────── # ── NEW-com-set-variable-coercion ───────────────────────────────────────────
async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch): async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch):
app = MagicMock() app = MagicMock()
app.GetVariable.return_value = 4159 # OSMODE current value is an int app.GetVariable.return_value = 4159 # OSMODE current value is an int
@@ -69,6 +70,7 @@ async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch):
# ── R23 / N6 — run_lisp guard + value capture ─────────────────────────────── # ── R23 / N6 — run_lisp guard + value capture ───────────────────────────────
async def test_run_lisp_refuses_when_cmdactive(monkeypatch): async def test_run_lisp_refuses_when_cmdactive(monkeypatch):
app = MagicMock() app = MagicMock()
app.GetVariable.side_effect = lambda n: 1 if n == "CMDACTIVE" else 0 app.GetVariable.side_effect = lambda n: 1 if n == "CMDACTIVE" else 0
@@ -90,12 +92,13 @@ async def test_run_lisp_captures_value_from_users1(monkeypatch):
b = _backend_no_executor() b = _backend_no_executor()
out = await b.system_run_lisp("(+ 1 2)") out = await b.system_run_lisp("(+ 1 2)")
assert out["result"] == "3" # captured, not the bogus "nil" assert out["result"] == "3" # captured, not the bogus "nil"
assert any("USERS1" in c for c in doc.sent) # value stashed out-of-band assert any("USERS1" in c for c in doc.sent) # value stashed out-of-band
# ── R16 — transaction flag cleared even on error ──────────────────────────── # ── R16 — transaction flag cleared even on error ────────────────────────────
async def test_transaction_begin_sets_flag_and_marks_undo(monkeypatch): async def test_transaction_begin_sets_flag_and_marks_undo(monkeypatch):
doc = _Doc() doc = _Doc()
doc.StartUndoMark = MagicMock() doc.StartUndoMark = MagicMock()
@@ -136,10 +139,11 @@ async def test_transaction_rollback_uses_safe_send_command(monkeypatch):
# ── R13 — offset honors side_x/side_y and deletes the unused copy ─────────── # ── R13 — offset honors side_x/side_y and deletes the unused copy ───────────
async def test_offset_picks_copy_nearest_side_point(monkeypatch): async def test_offset_picks_copy_nearest_side_point(monkeypatch):
# ent.Offset(+d) -> copy centred at (0, +10); ent.Offset(-d) -> (0, -10). # ent.Offset(+d) -> copy centred at (0, +10); ent.Offset(-d) -> (0, -10).
pos_copy = MagicMock(name="pos") pos_copy = MagicMock(name="pos")
pos_copy.GetBoundingBox.return_value = ((-1, 9), (1, 11)) # centre (0, 10) pos_copy.GetBoundingBox.return_value = ((-1, 9), (1, 11)) # centre (0, 10)
neg_copy = MagicMock(name="neg") neg_copy = MagicMock(name="neg")
neg_copy.GetBoundingBox.return_value = ((-1, -11), (1, -9)) # centre (0, -10) neg_copy.GetBoundingBox.return_value = ((-1, -11), (1, -9)) # centre (0, -10)
+1
View File
@@ -33,6 +33,7 @@ async def test_drawing_export_dxf(backend, tmp_path):
result = await backend.drawing_export_dxf(export_path) result = await backend.drawing_export_dxf(export_path)
assert result.get("ok") is True assert result.get("ok") is True
import os import os
assert os.path.exists(export_path) assert os.path.exists(export_path)
+8 -3
View File
@@ -33,9 +33,13 @@ async def test_set_units_cm_and_inch_codes(backend):
async def test_set_numeric_settings(backend): async def test_set_numeric_settings(backend):
res = await backend.drawing_settings({ res = await backend.drawing_settings(
"dimscale": 2.0, "linear_precision": 3, "ltscale": 0.5, {
}) "dimscale": 2.0,
"linear_precision": 3,
"ltscale": 0.5,
}
)
assert res["ok"] is True assert res["ok"] is True
snap = (await backend.drawing_settings())["settings"] snap = (await backend.drawing_settings())["settings"]
assert snap["dimscale"] == 2.0 assert snap["dimscale"] == 2.0
@@ -57,6 +61,7 @@ async def test_bad_unit_value_is_reported(backend):
# ── server tool wiring ────────────────────────────────────────────────────── # ── server tool wiring ──────────────────────────────────────────────────────
class _FakeCtx: class _FakeCtx:
def __init__(self, backend): def __init__(self, backend):
self.lifespan_context = {"backend": backend} self.lifespan_context = {"backend": backend}
+51 -13
View File
@@ -66,19 +66,41 @@ async def test_helical_gear_front_view_returns_metadata(backend):
await ensure_standard_linetypes(backend) await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend) await ensure_engineering_layers(backend)
res = await draw_helical_gear_front_view( res = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0, hand="RH", backend,
module=3,
teeth=24,
helix_angle=20.0,
hand="RH",
center=(0.0, 0.0), center=(0.0, 0.0),
) )
expected_keys = { expected_keys = {
"outline", "pitch_circle", "base_circle", "outer_circle", "root_circle", "outline",
"helix_symbol", "helix_label", "bore", "keyway", "metadata", "pitch_circle",
"base_circle",
"outer_circle",
"root_circle",
"helix_symbol",
"helix_label",
"bore",
"keyway",
"metadata",
} }
assert expected_keys.issubset(res.keys()) assert expected_keys.issubset(res.keys())
md = res["metadata"] md = res["metadata"]
for k in ( for k in (
"module", "teeth", "pressure_angle", "helix_angle", "hand", "center", "module",
"bore_diameter", "keyway_width", "keyway_depth", "teeth",
"pitch_radius", "outer_radius", "base_radius", "root_radius", "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}" assert k in md, f"metadata missing key {k!r}"
@@ -88,8 +110,13 @@ async def test_helical_gear_with_bore_and_keyway_handles_present(backend):
await ensure_standard_linetypes(backend) await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend) await ensure_engineering_layers(backend)
res = await draw_helical_gear_front_view( res = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0, backend,
bore_diameter=25.0, keyway_width=8.0, keyway_depth=4.0, 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["bore"] is not None
assert res["keyway"] is not None assert res["keyway"] is not None
@@ -102,7 +129,11 @@ async def test_helical_gear_metadata_radii_correct(backend):
await ensure_standard_linetypes(backend) await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend) await ensure_engineering_layers(backend)
res = await draw_helical_gear_front_view( res = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0, pressure_angle=20.0, backend,
module=3,
teeth=24,
helix_angle=20.0,
pressure_angle=20.0,
) )
md = res["metadata"] md = res["metadata"]
assert math.isclose(md["pitch_radius"], 36.0, abs_tol=1e-6) assert math.isclose(md["pitch_radius"], 36.0, abs_tol=1e-6)
@@ -116,12 +147,19 @@ async def test_section_aa_does_not_draw_helix_lines(backend):
await ensure_standard_linetypes(backend) await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend) await ensure_engineering_layers(backend)
front = await draw_helical_gear_front_view( front = await draw_helical_gear_front_view(
backend, module=3, teeth=24, helix_angle=20.0, backend,
bore_diameter=25.0, keyway_width=8.0, keyway_depth=4.0, 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( section = await draw_gear_section_aa(
backend, gear_metadata=front["metadata"], backend,
x_offset=200.0, face_width=40.0, gear_metadata=front["metadata"],
x_offset=200.0,
face_width=40.0,
) )
assert "helix" not in section assert "helix" not in section
assert "helix_symbol" not in section assert "helix_symbol" not in section
+11 -4
View File
@@ -37,8 +37,11 @@ async def test_draw_keyed_bore_returns_expected_handles(backend):
await ensure_standard_linetypes(backend) await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend) await ensure_engineering_layers(backend)
res = await draw_keyed_bore( res = await draw_keyed_bore(
backend, center=(0.0, 0.0), bore_diameter=25.0, backend,
keyway_width=8.0, keyway_depth=4.0, 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"): for k in ("bore", "keyway_polyline", "centerline_h", "centerline_v"):
assert k in res assert k in res
@@ -50,8 +53,12 @@ async def test_draw_keyway_section_geometry(backend):
await ensure_standard_linetypes(backend) await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend) await ensure_engineering_layers(backend)
res = await draw_keyway_section( res = await draw_keyway_section(
backend, center=(0.0, 0.0), bore_diameter=25.0, face_width=40.0, backend,
keyway_width=8.0, keyway_depth=4.0, 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_top" in res
assert "bore_bottom" in res assert "bore_bottom" in res
+15 -5
View File
@@ -39,7 +39,8 @@ async def test_titleblock_outer_border_dimensions(backend):
async def test_title_text_exact_match(backend): async def test_title_text_exact_match(backend):
"""Title text in the drawing must equal metadata.title verbatim.""" """Title text in the drawing must equal metadata.title verbatim."""
result = await apply_iso_a3_titleblock( result = await apply_iso_a3_titleblock(
backend, metadata=_meta(title="HELICAL GEAR"), backend,
metadata=_meta(title="HELICAL GEAR"),
) )
title_ent = await backend.entity_get(result["title_text"]) title_ent = await backend.entity_get(result["title_text"])
assert title_ent.type == "TEXT" assert title_ent.type == "TEXT"
@@ -50,9 +51,16 @@ async def test_titleblock_creates_text_for_each_metadata_field(backend):
"""value_texts must contain a handle for every metadata field.""" """value_texts must contain a handle for every metadata field."""
result = await apply_iso_a3_titleblock(backend, metadata=_meta()) result = await apply_iso_a3_titleblock(backend, metadata=_meta())
expected_keys = { expected_keys = {
"drawing_no", "revision", "sheet", "drawing_no",
"part_no", "material", "scale", "revision",
"drawn_by", "checked_by", "date", "company", "sheet",
"part_no",
"material",
"scale",
"drawn_by",
"checked_by",
"date",
"company",
} }
assert set(result["value_texts"].keys()) == expected_keys assert set(result["value_texts"].keys()) == expected_keys
# All handles must resolve to TEXT entities # All handles must resolve to TEXT entities
@@ -64,7 +72,9 @@ async def test_titleblock_creates_text_for_each_metadata_field(backend):
async def test_titleblock_origin_offset(backend): async def test_titleblock_origin_offset(backend):
"""Passing origin=(100,50) must shift the bbox by that amount.""" """Passing origin=(100,50) must shift the bbox by that amount."""
result = await apply_iso_a3_titleblock( result = await apply_iso_a3_titleblock(
backend, metadata=_meta(), origin=(100.0, 50.0), backend,
metadata=_meta(),
origin=(100.0, 50.0),
) )
bbox = result["bbox"] bbox = result["bbox"]
assert bbox["min"][0] == pytest.approx(100.0) assert bbox["min"][0] == pytest.approx(100.0)
+12 -3
View File
@@ -73,7 +73,11 @@ async def test_validator_catches_helical_spur_title(backend, tmp_path):
await ensure_standard_linetypes(backend) await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend) await ensure_engineering_layers(backend)
await backend.entity_create_text( await backend.entity_create_text(
text="HELICAL SPUR GEAR", x=0, y=0, height=5, layer="TEXT", text="HELICAL SPUR GEAR",
x=0,
y=0,
height=5,
layer="TEXT",
) )
await backend.drawing_save_as(str(tmp_path / "tb.dxf")) await backend.drawing_save_as(str(tmp_path / "tb.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 20}) result = await DrawingValidator().run(backend, expected={"helix_angle": 20})
@@ -86,7 +90,11 @@ async def test_validator_catches_fake_dimension_text(backend, tmp_path):
await ensure_standard_linetypes(backend) await ensure_standard_linetypes(backend)
await ensure_engineering_layers(backend) await ensure_engineering_layers(backend)
await backend.entity_create_text( await backend.entity_create_text(
text="Ø78", x=10, y=10, height=3.5, layer="DIM", text="Ø78",
x=10,
y=10,
height=3.5,
layer="DIM",
) )
await backend.drawing_save_as(str(tmp_path / "fake.dxf")) await backend.drawing_save_as(str(tmp_path / "fake.dxf"))
result = await DrawingValidator().run(backend) result = await DrawingValidator().run(backend)
@@ -124,7 +132,8 @@ async def test_validator_bore_missing_when_expected(backend, tmp_path):
# No circle drawn at all # No circle drawn at all
await backend.drawing_save_as(str(tmp_path / "nobore.dxf")) await backend.drawing_save_as(str(tmp_path / "nobore.dxf"))
result = await DrawingValidator().run( result = await DrawingValidator().run(
backend, expected={"must_have_bore": True}, backend,
expected={"must_have_bore": True},
) )
assert "bore_missing" in _codes(result) assert "bore_missing" in _codes(result)
+4
View File
@@ -15,6 +15,7 @@ def _close(a, b, tol=1e-6):
# ─── TRIM ────────────────────────────────────────────────────────────────── # ─── TRIM ──────────────────────────────────────────────────────────────────
class TestTrim: class TestTrim:
async def test_trim_keep_left(self, backend): async def test_trim_keep_left(self, backend):
target = await backend.entity_create_line(0, 0, 100, 0) target = await backend.entity_create_line(0, 0, 100, 0)
@@ -55,6 +56,7 @@ class TestTrim:
# ─── EXTEND ──────────────────────────────────────────────────────────────── # ─── EXTEND ────────────────────────────────────────────────────────────────
class TestExtend: class TestExtend:
async def test_extend_auto_endpoint(self, backend): async def test_extend_auto_endpoint(self, backend):
# Target ends at x=40; boundary is vertical at x=80. # Target ends at x=40; boundary is vertical at x=80.
@@ -84,6 +86,7 @@ class TestExtend:
# ─── FILLET ──────────────────────────────────────────────────────────────── # ─── FILLET ────────────────────────────────────────────────────────────────
class TestFillet: class TestFillet:
async def test_fillet_90_degree_corner(self, backend): async def test_fillet_90_degree_corner(self, backend):
# L-shape: horizontal (0,0)-(100,0) + vertical (100,0)-(100,80). # L-shape: horizontal (0,0)-(100,0) + vertical (100,0)-(100,80).
@@ -139,6 +142,7 @@ class TestFillet:
# ─── CHAMFER ─────────────────────────────────────────────────────────────── # ─── CHAMFER ───────────────────────────────────────────────────────────────
class TestChamfer: class TestChamfer:
async def test_chamfer_symmetric(self, backend): async def test_chamfer_symmetric(self, backend):
l1 = await backend.entity_create_line(0, 0, 100, 0) l1 = await backend.entity_create_line(0, 0, 100, 0)
+3 -1
View File
@@ -14,10 +14,11 @@ pytestmark = pytest.mark.asyncio
# ── entity_edit_text ──────────────────────────────────────────────────────── # ── entity_edit_text ────────────────────────────────────────────────────────
async def test_edit_text_changes_content_keeps_handle(backend): 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") 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") updated = await backend.entity_edit_text(t.handle, text="NEW LABEL")
assert updated.handle == t.handle # handle preserved assert updated.handle == t.handle # handle preserved
assert updated.properties["text"] == "NEW LABEL" assert updated.properties["text"] == "NEW LABEL"
@@ -44,6 +45,7 @@ async def test_edit_text_rejects_non_text(backend):
# ── entity_edit_geometry ──────────────────────────────────────────────────── # ── entity_edit_geometry ────────────────────────────────────────────────────
async def test_edit_circle_radius_and_center(backend): async def test_edit_circle_radius_and_center(backend):
c = await backend.entity_create_circle(0, 0, 10, layer="GEOMETRY") 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) updated = await backend.entity_edit_geometry(c.handle, cx=5, cy=5, radius=20)
-1
View File
@@ -1,6 +1,5 @@
"""Tests for ezdxf backend — no AutoCAD required.""" """Tests for ezdxf backend — no AutoCAD required."""
import pytest import pytest
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
+16 -1
View File
@@ -46,15 +46,18 @@ def _reload_server_with_token(token: str | None):
# Auth object construction # Auth object construction
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_auth_object_built_when_token_set(): def test_auth_object_built_when_token_set():
"""_auth is a StaticTokenVerifier when MCP_AUTH_TOKEN is non-empty.""" """_auth is a StaticTokenVerifier when MCP_AUTH_TOKEN is non-empty."""
with patch.dict("os.environ", {"MCP_AUTH_TOKEN": "secret-test-token-123"}): with patch.dict("os.environ", {"MCP_AUTH_TOKEN": "secret-test-token-123"}):
import config as cfg import config as cfg
old_token = cfg.settings.mcp_auth_token old_token = cfg.settings.mcp_auth_token
cfg.settings.mcp_auth_token = "secret-test-token-123" cfg.settings.mcp_auth_token = "secret-test-token-123"
try: try:
sys.modules.pop("server", None) sys.modules.pop("server", None)
import server as srv import server as srv
assert srv._auth is not None assert srv._auth is not None
assert isinstance(srv._auth, StaticTokenVerifier) assert isinstance(srv._auth, StaticTokenVerifier)
finally: finally:
@@ -65,11 +68,13 @@ def test_auth_object_built_when_token_set():
def test_auth_object_none_when_no_token(): def test_auth_object_none_when_no_token():
"""_auth is None when MCP_AUTH_TOKEN is empty (anonymous loopback mode).""" """_auth is None when MCP_AUTH_TOKEN is empty (anonymous loopback mode)."""
import config as cfg import config as cfg
old_token = cfg.settings.mcp_auth_token old_token = cfg.settings.mcp_auth_token
cfg.settings.mcp_auth_token = "" cfg.settings.mcp_auth_token = ""
try: try:
sys.modules.pop("server", None) sys.modules.pop("server", None)
import server as srv import server as srv
assert srv._auth is None assert srv._auth is None
finally: finally:
cfg.settings.mcp_auth_token = old_token cfg.settings.mcp_auth_token = old_token
@@ -79,11 +84,13 @@ def test_auth_object_none_when_no_token():
def test_mcp_receives_auth_when_set(): def test_mcp_receives_auth_when_set():
"""The FastMCP instance's auth attribute matches the _auth object.""" """The FastMCP instance's auth attribute matches the _auth object."""
import config as cfg import config as cfg
old_token = cfg.settings.mcp_auth_token old_token = cfg.settings.mcp_auth_token
cfg.settings.mcp_auth_token = "another-secret" cfg.settings.mcp_auth_token = "another-secret"
try: try:
sys.modules.pop("server", None) sys.modules.pop("server", None)
import server as srv import server as srv
# FastMCP stores auth as _auth_provider or similar; we check it via # FastMCP stores auth as _auth_provider or similar; we check it via
# the _auth module-level variable matching what was passed in. # the _auth module-level variable matching what was passed in.
assert srv._auth is not None assert srv._auth is not None
@@ -97,11 +104,13 @@ def test_mcp_receives_auth_when_set():
# _validate_http_bind guard # _validate_http_bind guard
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_validate_http_bind_loopback_no_error(): def test_validate_http_bind_loopback_no_error():
"""Loopback hosts pass validation regardless of token/allow_remote flags.""" """Loopback hosts pass validation regardless of token/allow_remote flags."""
sys.modules.pop("server", None) sys.modules.pop("server", None)
import config as cfg import config as cfg
import server as srv import server as srv
old_remote = cfg.settings.allow_remote_http old_remote = cfg.settings.allow_remote_http
old_token = cfg.settings.mcp_auth_token old_token = cfg.settings.mcp_auth_token
cfg.settings.allow_remote_http = False cfg.settings.allow_remote_http = False
@@ -121,6 +130,7 @@ def test_validate_http_bind_remote_without_flag_raises():
sys.modules.pop("server", None) sys.modules.pop("server", None)
import config as cfg import config as cfg
import server as srv import server as srv
old = cfg.settings.allow_remote_http old = cfg.settings.allow_remote_http
cfg.settings.allow_remote_http = False cfg.settings.allow_remote_http = False
try: try:
@@ -135,6 +145,7 @@ def test_validate_http_bind_remote_without_token_raises():
sys.modules.pop("server", None) sys.modules.pop("server", None)
import config as cfg import config as cfg
import server as srv import server as srv
old_remote = cfg.settings.allow_remote_http old_remote = cfg.settings.allow_remote_http
old_token = cfg.settings.mcp_auth_token old_token = cfg.settings.mcp_auth_token
cfg.settings.allow_remote_http = True cfg.settings.allow_remote_http = True
@@ -150,9 +161,11 @@ def test_validate_http_bind_remote_without_token_raises():
def test_validate_http_bind_remote_with_token_logs_warning(caplog): def test_validate_http_bind_remote_with_token_logs_warning(caplog):
"""Remote bind with both flag and token logs a warning but does not raise.""" """Remote bind with both flag and token logs a warning but does not raise."""
import logging import logging
sys.modules.pop("server", None) sys.modules.pop("server", None)
import config as cfg import config as cfg
import server as srv import server as srv
old_remote = cfg.settings.allow_remote_http old_remote = cfg.settings.allow_remote_http
old_token = cfg.settings.mcp_auth_token old_token = cfg.settings.mcp_auth_token
cfg.settings.allow_remote_http = True cfg.settings.allow_remote_http = True
@@ -160,7 +173,9 @@ def test_validate_http_bind_remote_with_token_logs_warning(caplog):
try: try:
with caplog.at_level(logging.WARNING, logger="server"): with caplog.at_level(logging.WARNING, logger="server"):
srv._validate_http_bind("0.0.0.0") # should not raise srv._validate_http_bind("0.0.0.0") # should not raise
assert any("non-loopback" in r.message.lower() or "0.0.0.0" in r.message for r in caplog.records) assert any(
"non-loopback" in r.message.lower() or "0.0.0.0" in r.message for r in caplog.records
)
finally: finally:
cfg.settings.allow_remote_http = old_remote cfg.settings.allow_remote_http = old_remote
cfg.settings.mcp_auth_token = old_token cfg.settings.mcp_auth_token = old_token
+15 -4
View File
@@ -16,10 +16,13 @@ def _close(a, b, tol=1e-6):
# ─── drawing_plan ────────────────────────────────────────────────────────── # ─── drawing_plan ──────────────────────────────────────────────────────────
class TestDrawingPlan: class TestDrawingPlan:
async def test_plan_returns_spec_with_intent(self, backend): async def test_plan_returns_spec_with_intent(self, backend):
plan = await backend.drawing_plan( plan = await backend.drawing_plan(
"L-bracket 50x80", sheet_size="A4", scale=1.0, "L-bracket 50x80",
sheet_size="A4",
scale=1.0,
) )
assert plan.intent == "L-bracket 50x80" assert plan.intent == "L-bracket 50x80"
assert plan.sheet_size == "A4" assert plan.sheet_size == "A4"
@@ -36,6 +39,7 @@ class TestDrawingPlan:
# ─── drawing_critique ────────────────────────────────────────────────────── # ─── drawing_critique ──────────────────────────────────────────────────────
class TestDrawingCritique: class TestDrawingCritique:
async def test_critique_clean_drawing(self, backend): async def test_critique_clean_drawing(self, backend):
# Bootstrap layer set so layer_color/iso128 don't trigger. # Bootstrap layer set so layer_color/iso128 don't trigger.
@@ -73,6 +77,7 @@ class TestDrawingCritique:
# ─── point_from_snap ─────────────────────────────────────────────────────── # ─── point_from_snap ───────────────────────────────────────────────────────
class TestPointFromSnap: class TestPointFromSnap:
async def test_snap_end_default(self, backend): async def test_snap_end_default(self, backend):
line = await backend.entity_create_line(0, 0, 100, 0) line = await backend.entity_create_line(0, 0, 100, 0)
@@ -122,6 +127,7 @@ class TestPointFromSnap:
# ─── construction layer ─────────────────────────────────────────────────── # ─── construction layer ───────────────────────────────────────────────────
class TestConstructionLayer: class TestConstructionLayer:
async def test_xline_creates_on_construction_layer(self, backend): async def test_xline_creates_on_construction_layer(self, backend):
xl = await backend.construction_xline(0, 0, 45) xl = await backend.construction_xline(0, 0, 45)
@@ -140,6 +146,7 @@ class TestConstructionLayer:
# ─── drawing_apply_iso_layers ────────────────────────────────────────────── # ─── drawing_apply_iso_layers ──────────────────────────────────────────────
class TestApplyISOLayers: class TestApplyISOLayers:
async def test_mech_layer_set(self, backend): async def test_mech_layer_set(self, backend):
result = await backend.drawing_apply_iso_layers("mech") result = await backend.drawing_apply_iso_layers("mech")
@@ -170,13 +177,15 @@ class TestApplyISOLayers:
# ─── dimension_auto ──────────────────────────────────────────────────────── # ─── dimension_auto ────────────────────────────────────────────────────────
class TestDimensionAuto: class TestDimensionAuto:
async def test_chain_creates_one_dim_per_segment(self, backend): async def test_chain_creates_one_dim_per_segment(self, backend):
l1 = await backend.entity_create_line(0, 0, 30, 0) l1 = await backend.entity_create_line(0, 0, 30, 0)
l2 = await backend.entity_create_line(30, 0, 70, 0) l2 = await backend.entity_create_line(30, 0, 70, 0)
l3 = await backend.entity_create_line(70, 0, 100, 0) l3 = await backend.entity_create_line(70, 0, 100, 0)
dims = await backend.dimension_auto( dims = await backend.dimension_auto(
[l1.handle, l2.handle, l3.handle], style="chain", [l1.handle, l2.handle, l3.handle],
style="chain",
) )
assert len(dims) == 3 assert len(dims) == 3
@@ -198,6 +207,7 @@ class TestDimensionAuto:
# ─── entity_select_smart ─────────────────────────────────────────────────── # ─── entity_select_smart ───────────────────────────────────────────────────
class TestEntitySelectSmart: class TestEntitySelectSmart:
async def test_select_by_type(self, backend): async def test_select_by_type(self, backend):
await backend.entity_create_line(0, 0, 50, 0) await backend.entity_create_line(0, 0, 50, 0)
@@ -214,13 +224,13 @@ class TestEntitySelectSmart:
assert len(result) == 1 assert len(result) == 1
async def test_select_by_near(self, backend): async def test_select_by_near(self, backend):
await backend.entity_create_line(0, 0, 10, 0) # near origin await backend.entity_create_line(0, 0, 10, 0) # near origin
await backend.entity_create_line(1000, 1000, 1010, 1000) # far await backend.entity_create_line(1000, 1000, 1010, 1000) # far
result = await backend.entity_select_smart({"type": "LINE", "near": [0, 0, 50]}) result = await backend.entity_select_smart({"type": "LINE", "near": [0, 0, 50]})
assert len(result) == 1 assert len(result) == 1
async def test_select_by_length_range(self, backend): async def test_select_by_length_range(self, backend):
await backend.entity_create_line(0, 0, 10, 0) # length 10 await backend.entity_create_line(0, 0, 10, 0) # length 10
await backend.entity_create_line(0, 50, 100, 50) # length 100 await backend.entity_create_line(0, 50, 100, 50) # length 100
result = await backend.entity_select_smart({"type": "LINE", "length_range": [50, 200]}) result = await backend.entity_select_smart({"type": "LINE", "length_range": [50, 200]})
assert len(result) == 1 assert len(result) == 1
@@ -228,6 +238,7 @@ class TestEntitySelectSmart:
# ─── Integration: plan → draw → critique pipeline ────────────────────────── # ─── Integration: plan → draw → critique pipeline ──────────────────────────
class TestPremiumPipeline: class TestPremiumPipeline:
async def test_full_l_bracket_pipeline_clean(self, backend): async def test_full_l_bracket_pipeline_clean(self, backend):
"""plan → apply_layers → draw + fillet → clear construction → critique = [].""" """plan → apply_layers → draw + fillet → clear construction → critique = []."""
+2
View File
@@ -13,6 +13,7 @@ pytestmark = pytest.mark.asyncio
# point_intersection # point_intersection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestPointIntersection: class TestPointIntersection:
async def test_line_line_perpendicular(self, backend): async def test_line_line_perpendicular(self, backend):
"""Horizontal and vertical lines cross at (50, 50).""" """Horizontal and vertical lines cross at (50, 50)."""
@@ -91,6 +92,7 @@ class TestPointIntersection:
# point_tangent # point_tangent
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestPointTangent: class TestPointTangent:
async def test_tangent_from_outside(self, backend): async def test_tangent_from_outside(self, backend):
"""From (0, 0) to unit circle at (5, 0): tangent point is at (25/5, ±12/5).""" """From (0, 0) to unit circle at (5, 0): tangent point is at (25/5, ±12/5)."""
+6 -3
View File
@@ -100,7 +100,8 @@ def test_preflight_does_not_invent_values_when_assumptions_disabled():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_backend_plan_accepts_matching_ready_preflight(backend): async def test_backend_plan_accepts_matching_ready_preflight(backend):
preflight = await backend.drawing_preflight( preflight = await backend.drawing_preflight(
"L bracket", requirements=_complete_requirements(), "L bracket",
requirements=_complete_requirements(),
) )
plan = await backend.drawing_plan( plan = await backend.drawing_plan(
@@ -125,7 +126,8 @@ async def test_backend_plan_rejects_not_ready_preflight_hash(backend):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_backend_plan_rejects_fields_that_do_not_match_preflight_hash(backend): async def test_backend_plan_rejects_fields_that_do_not_match_preflight_hash(backend):
preflight = await backend.drawing_preflight( preflight = await backend.drawing_preflight(
"L bracket", requirements=_complete_requirements(), "L bracket",
requirements=_complete_requirements(),
) )
with pytest.raises(RuntimeError, match="do not match"): with pytest.raises(RuntimeError, match="do not match"):
@@ -148,7 +150,8 @@ async def test_legacy_plan_remains_compatible_and_marks_preflight_skipped(backen
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_document_change_clears_plan_preflight_and_gdt_state(backend): async def test_document_change_clears_plan_preflight_and_gdt_state(backend):
preflight = await backend.drawing_preflight( preflight = await backend.drawing_preflight(
"First drawing", requirements=_complete_requirements(), "First drawing",
requirements=_complete_requirements(),
) )
await backend.drawing_plan( await backend.drawing_plan(
"First drawing", "First drawing",
+1 -3
View File
@@ -66,9 +66,7 @@ async def test_refiner_repairs_construction_and_duplicates(backend):
assert result.status == "threshold_met" assert result.status == "threshold_met"
assert result.final_score == 100.0 assert result.final_score == 100.0
assert result.rounds[0].score_after > result.rounds[0].score_before assert result.rounds[0].score_after > result.rounds[0].score_before
assert await backend.drawing_critique( assert await backend.drawing_critique(["duplicate_entities", "construction_left"]) == []
["duplicate_entities", "construction_left"]
) == []
async def test_refiner_dry_run_does_not_mutate_drawing(backend): async def test_refiner_dry_run_does_not_mutate_drawing(backend):
+4
View File
@@ -73,6 +73,7 @@ class TestValidatePath:
monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir)) monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir))
import config import config
config.settings = Settings() config.settings = Settings()
blocked = tmp_path / "blocked" / "file.dxf" blocked = tmp_path / "blocked" / "file.dxf"
@@ -98,6 +99,7 @@ class TestValidatePath:
monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir)) monkeypatch.setenv("ALLOWED_PATHS", str(allowed_dir))
import config import config
config.settings = Settings() config.settings = Settings()
result = validate_path(str(test_file)) result = validate_path(str(test_file))
@@ -160,6 +162,7 @@ class TestSanitizeCommand:
monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true") monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true")
import config import config
config.settings = Settings() config.settings = Settings()
result = sanitize_command("ERASE ALL") result = sanitize_command("ERASE ALL")
@@ -222,6 +225,7 @@ class TestSanitizeLisp:
monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true") monkeypatch.setenv("DANGEROUS_COMMANDS_ENABLED", "true")
import config import config
config.settings = Settings() config.settings = Settings()
result = sanitize_lisp('(startapp "cmd")') result = sanitize_lisp('(startapp "cmd")')
+30 -9
View File
@@ -32,6 +32,7 @@ class _FakeCtx:
# ── NEW-screenshot-1 ──────────────────────────────────────────────────────── # ── NEW-screenshot-1 ────────────────────────────────────────────────────────
async def test_screenshot_renders_png_without_gui_thread_crash(backend): async def test_screenshot_renders_png_without_gui_thread_crash(backend):
"""view_screenshot runs in a worker thread; the Agg figure must not crash and """view_screenshot runs in a worker thread; the Agg figure must not crash and
must return valid PNG bytes.""" must return valid PNG bytes."""
@@ -43,6 +44,7 @@ async def test_screenshot_renders_png_without_gui_thread_crash(backend):
# ── I15 — critique is part of the finalize gate ───────────────────────────── # ── I15 — critique is part of the finalize gate ─────────────────────────────
async def test_finalize_runs_critique_and_blocks_on_construction(backend, tmp_path): async def test_finalize_runs_critique_and_blocks_on_construction(backend, tmp_path):
from fastmcp.exceptions import ToolError from fastmcp.exceptions import ToolError
@@ -68,6 +70,7 @@ async def test_finalize_runs_critique_and_blocks_on_construction(backend, tmp_pa
# ── R4 — dim_overlap critique fires ───────────────────────────────────────── # ── R4 — dim_overlap critique fires ─────────────────────────────────────────
async def test_dim_overlap_critique_is_not_a_noop(backend): async def test_dim_overlap_critique_is_not_a_noop(backend):
await backend.drawing_apply_iso_layers("mech") await backend.drawing_apply_iso_layers("mech")
# Two dimensions measuring the same segment overlap exactly. # Two dimensions measuring the same segment overlap exactly.
@@ -80,6 +83,7 @@ async def test_dim_overlap_critique_is_not_a_noop(backend):
# ── N4 — dim / construction layers follow the active layer set ────────────── # ── N4 — dim / construction layers follow the active layer set ──────────────
async def test_iso13567_dim_and_construction_layers_resolve(backend): async def test_iso13567_dim_and_construction_layers_resolve(backend):
await backend.drawing_apply_iso_layers("iso13567") await backend.drawing_apply_iso_layers("iso13567")
assert backend._active_layer_set_id() == "iso13567" assert backend._active_layer_set_id() == "iso13567"
@@ -102,6 +106,7 @@ async def test_iso13567_dim_and_construction_layers_resolve(backend):
# ── NEW-AUTH-1 — bind guard on the run_async launch path ──────────────────── # ── NEW-AUTH-1 — bind guard on the run_async launch path ────────────────────
async def test_run_async_invokes_bind_guard_for_http(monkeypatch): async def test_run_async_invokes_bind_guard_for_http(monkeypatch):
import server import server
@@ -121,8 +126,7 @@ async def test_run_async_skips_guard_for_stdio(monkeypatch):
import server import server
seen = {} seen = {}
monkeypatch.setattr(server, "_validate_http_bind", monkeypatch.setattr(server, "_validate_http_bind", lambda h: seen.setdefault("host", h))
lambda h: seen.setdefault("host", h))
async def fake_orig(*args, **kwargs): async def fake_orig(*args, **kwargs):
return "stdio-ran" return "stdio-ran"
@@ -135,6 +139,7 @@ async def test_run_async_skips_guard_for_stdio(monkeypatch):
# ── NEW-gear-roottooth — outline never dips inside the root circle ────────── # ── NEW-gear-roottooth — outline never dips inside the root circle ──────────
async def test_gear_outline_stays_outside_root_circle(): async def test_gear_outline_stays_outside_root_circle():
from engineering.gear import generate_full_gear_outline from engineering.gear import generate_full_gear_outline
@@ -144,19 +149,28 @@ async def test_gear_outline_stays_outside_root_circle():
pts = generate_full_gear_outline(module, teeth, pa) pts = generate_full_gear_outline(module, teeth, pa)
radii = [math.hypot(x, y) for x, y in pts] radii = [math.hypot(x, y) for x, y in pts]
# No point may sit inside the root circle (the self-overlap bug for z>=~42). # No point may sit inside the root circle (the self-overlap bug for z>=~42).
assert min(radii) >= root_r - 1e-6, f"m={module} z={teeth}: min_r {min(radii)} < root_r {root_r}" assert min(radii) >= root_r - 1e-6, (
f"m={module} z={teeth}: min_r {min(radii)} < root_r {root_r}"
)
# ── NEW-section-dupbore / NEW-validator-keyway / NEW-gear-baseconstr ───────── # ── NEW-section-dupbore / NEW-validator-keyway / NEW-gear-baseconstr ─────────
async def test_gear_front_view_base_circle_persists_and_keyway_validates(backend, tmp_path): async def test_gear_front_view_base_circle_persists_and_keyway_validates(backend, tmp_path):
from engineering.gear import draw_helical_gear_front_view from engineering.gear import draw_helical_gear_front_view
from engineering.validator import DrawingValidator from engineering.validator import DrawingValidator
await backend.drawing_apply_iso_layers("mech") await backend.drawing_apply_iso_layers("mech")
res = await draw_helical_gear_front_view( res = await draw_helical_gear_front_view(
backend, module=2, teeth=20, helix_angle=0, center=(0, 0), backend,
bore_diameter=20, keyway_width=6, keyway_depth=2.8, module=2,
teeth=20,
helix_angle=0,
center=(0, 0),
bore_diameter=20,
keyway_width=6,
keyway_depth=2.8,
) )
# NEW-gear-baseconstr: base circle is a persistent PHANTOM reference, not CONSTRUCTION. # NEW-gear-baseconstr: base circle is a persistent PHANTOM reference, not CONSTRUCTION.
base = await backend.entity_get(res["base_circle"]) base = await backend.entity_get(res["base_circle"])
@@ -167,7 +181,8 @@ async def test_gear_front_view_base_circle_persists_and_keyway_validates(backend
# NEW-validator-keyway: the open 4-point keyway polyline is now recognised. # NEW-validator-keyway: the open 4-point keyway polyline is now recognised.
await backend.drawing_save_as(str(tmp_path / "gear.dxf")) await backend.drawing_save_as(str(tmp_path / "gear.dxf"))
vr = await DrawingValidator().run( vr = await DrawingValidator().run(
backend, expected={"must_have_bore": True, "must_have_keyway": True}, backend,
expected={"must_have_bore": True, "must_have_keyway": True},
) )
assert "keyway_section_unverified" not in [f.code for f in vr.findings] assert "keyway_section_unverified" not in [f.code for f in vr.findings]
@@ -178,9 +193,15 @@ async def test_gear_section_does_not_duplicate_bore_lines(backend):
await backend.drawing_apply_iso_layers("mech") await backend.drawing_apply_iso_layers("mech")
await draw_gear_section_aa( await draw_gear_section_aa(
backend, backend,
gear_metadata={"outer_radius": 22, "center": [0, 0], "bore_diameter": 20, gear_metadata={
"keyway_width": 6, "keyway_depth": 2.8}, "outer_radius": 22,
x_offset=100, face_width=30, "center": [0, 0],
"bore_diameter": 20,
"keyway_width": 6,
"keyway_depth": 2.8,
},
x_offset=100,
face_width=30,
) )
dups = await backend.drawing_critique(focus=["duplicate_entities"]) dups = await backend.drawing_critique(focus=["duplicate_entities"])
assert dups == [] assert dups == []
+10 -5
View File
@@ -1,10 +1,10 @@
"""Regression tests for the CI-verifiable (ezdxf / shared) Sprint-3 fixes. """Regression tests for the CI-verifiable (ezdxf / shared) Sprint-3 fixes.
* N2 — drawing_save_as derives the on-disk format from the path extension * N2 — drawing_save_as derives the on-disk format from the path extension
* N3 — ARC carries a `length` property; entity_select_smart length_range selects it * N3 — ARC carries a `length` property; entity_select_smart length_range selects it
* N5 — properties parity: ezdxf sets bounding_box; MTEXT carries char_height + rotation * N5 — properties parity: ezdxf sets bounding_box; MTEXT carries char_height + rotation
* NEW-dimauto-baseline-rotation — baseline dims run parallel to a rotated baseline * NEW-dimauto-baseline-rotation — baseline dims run parallel to a rotated baseline
* NEW-dimauto-ordinate-misnomer — ordinate dims are staggered, not stacked at one offset * NEW-dimauto-ordinate-misnomer — ordinate dims are staggered, not stacked at one offset
""" """
from __future__ import annotations from __future__ import annotations
@@ -27,6 +27,7 @@ class _Ctx:
# ── N2 — format follows the file extension ────────────────────────────────── # ── N2 — format follows the file extension ──────────────────────────────────
async def test_save_as_derives_format_from_extension(backend): async def test_save_as_derives_format_from_extension(backend):
import server import server
@@ -42,6 +43,7 @@ async def test_save_as_derives_format_from_extension(backend):
# ── N3 — ARC length + length_range selection ──────────────────────────────── # ── N3 — ARC length + length_range selection ────────────────────────────────
async def test_arc_has_length_and_is_selectable(backend): async def test_arc_has_length_and_is_selectable(backend):
arc = await backend.entity_create_arc(0, 0, 10, 0, 90) # r=10, 90deg -> len ~15.708 arc = await backend.entity_create_arc(0, 0, 10, 0, 90) # r=10, 90deg -> len ~15.708
info = await backend.entity_get(arc.handle) info = await backend.entity_get(arc.handle)
@@ -55,6 +57,7 @@ async def test_arc_has_length_and_is_selectable(backend):
# ── N5 — cross-backend property parity ────────────────────────────────────── # ── N5 — cross-backend property parity ──────────────────────────────────────
async def test_ezdxf_populates_bounding_box(backend): async def test_ezdxf_populates_bounding_box(backend):
line = await backend.entity_create_line(0, 0, 30, 40) line = await backend.entity_create_line(0, 0, 30, 40)
info = await backend.entity_get(line.handle) info = await backend.entity_get(line.handle)
@@ -78,6 +81,7 @@ async def test_mtext_honors_caller_rotation(backend):
# ── NEW-dimauto-baseline-rotation ─────────────────────────────────────────── # ── NEW-dimauto-baseline-rotation ───────────────────────────────────────────
async def test_baseline_dim_runs_parallel_to_rotated_baseline(backend): async def test_baseline_dim_runs_parallel_to_rotated_baseline(backend):
ln = await backend.entity_create_line(0, 0, 40, 40) # 45-degree baseline ln = await backend.entity_create_line(0, 0, 40, 40) # 45-degree baseline
dims = await backend.dimension_auto([ln.handle], style="baseline") dims = await backend.dimension_auto([ln.handle], style="baseline")
@@ -90,6 +94,7 @@ async def test_baseline_dim_runs_parallel_to_rotated_baseline(backend):
# ── NEW-dimauto-ordinate-misnomer ─────────────────────────────────────────── # ── NEW-dimauto-ordinate-misnomer ───────────────────────────────────────────
async def test_ordinate_dims_are_staggered_not_overlapping(backend): async def test_ordinate_dims_are_staggered_not_overlapping(backend):
l1 = await backend.entity_create_line(0, 0, 20, 10) l1 = await backend.entity_create_line(0, 0, 20, 10)
l2 = await backend.entity_create_line(0, 0, 40, 30) l2 = await backend.entity_create_line(0, 0, 40, 30)
+6
View File
@@ -25,6 +25,7 @@ pytestmark = pytest.mark.asyncio
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _on_arc(pt, cx, cy, r, sa_deg, ea_deg, tol=1e-6): def _on_arc(pt, cx, cy, r, sa_deg, ea_deg, tol=1e-6):
"""True if pt lies on the circle of radius r AND within the [sa, ea] sweep """True if pt lies on the circle of radius r AND within the [sa, ea] sweep
(degrees, CCW, with wraparound).""" (degrees, CCW, with wraparound)."""
@@ -41,10 +42,12 @@ def _on_arc(pt, cx, cy, r, sa_deg, ea_deg, tol=1e-6):
# N7 — ARC snap angle-missing raises RuntimeError, not KeyError # N7 — ARC snap angle-missing raises RuntimeError, not KeyError
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestN7ArcAngleMissing: class TestN7ArcAngleMissing:
async def _patch_entity_get(self, backend, info: EntityInfo): async def _patch_entity_get(self, backend, info: EntityInfo):
async def _fake(handle): async def _fake(handle):
return info return info
backend.entity_get = _fake # type: ignore[method-assign] backend.entity_get = _fake # type: ignore[method-assign]
async def test_end_snap_missing_angles_raises_runtimeerror(self, backend): async def test_end_snap_missing_angles_raises_runtimeerror(self, backend):
@@ -107,6 +110,7 @@ class TestN7ArcAngleMissing:
# NEW-base-1 — point_tangent on/near the perimeter must not raise ValueError # NEW-base-1 — point_tangent on/near the perimeter must not raise ValueError
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestNewBase1TangentClamp: class TestNewBase1TangentClamp:
async def test_from_point_exactly_on_circle_no_valueerror(self, backend): async def test_from_point_exactly_on_circle_no_valueerror(self, backend):
"""A from-point exactly on the perimeter (d == r) degenerates to the """A from-point exactly on the perimeter (d == r) degenerates to the
@@ -145,6 +149,7 @@ class TestNewBase1TangentClamp:
# NEW-snap-quad-arc / NEW-snap-near-arc — snaps stay on the arc sweep # NEW-snap-quad-arc / NEW-snap-near-arc — snaps stay on the arc sweep
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestNewSnapArcSweep: class TestNewSnapArcSweep:
async def test_quad_on_quarter_arc_stays_on_sweep(self, backend): async def test_quad_on_quarter_arc_stays_on_sweep(self, backend):
"""Quarter arc 0..90 deg, r=10 at origin. Only the 0-deg and 90-deg """Quarter arc 0..90 deg, r=10 at origin. Only the 0-deg and 90-deg
@@ -208,6 +213,7 @@ class TestNewSnapArcSweep:
# N8 — get_plan_spec accessor exposes the stored PlanSpec # N8 — get_plan_spec accessor exposes the stored PlanSpec
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestN8GetPlanSpec: class TestN8GetPlanSpec:
async def test_none_before_plan(self, backend): async def test_none_before_plan(self, backend):
assert backend.get_plan_spec() is None assert backend.get_plan_spec() is None
+8 -4
View File
@@ -34,6 +34,7 @@ def _backend_no_executor() -> ComBackend:
# ── R28 — _find_autocad_hwnd prefers Application.HWND over EnumWindows ─────── # ── R28 — _find_autocad_hwnd prefers Application.HWND over EnumWindows ───────
async def test_find_hwnd_prefers_application_hwnd(monkeypatch): async def test_find_hwnd_prefers_application_hwnd(monkeypatch):
"""The real main-frame handle from the COM app wins; EnumWindows is not """The real main-frame handle from the COM app wins; EnumWindows is not
consulted when the HWND read succeeds.""" consulted when the HWND read succeeds."""
@@ -98,6 +99,7 @@ async def test_find_hwnd_returns_none_when_win32_unavailable(monkeypatch):
# ── S3 — block_list populates BlockInfo.description from .Comments ─────────── # ── S3 — block_list populates BlockInfo.description from .Comments ───────────
class _Block: class _Block:
"""Minimal stand-in for an AutoCAD block definition.""" """Minimal stand-in for an AutoCAD block definition."""
@@ -167,10 +169,12 @@ async def test_block_list_description_empty_when_comments_none(monkeypatch):
async def test_block_list_skips_anonymous_layout_blocks(monkeypatch): async def test_block_list_skips_anonymous_layout_blocks(monkeypatch):
doc = _doc_with_blocks([ doc = _doc_with_blocks(
_Block("*Model_Space", comments="ignored"), [
_Block("GEAR", comments="spur gear"), _Block("*Model_Space", comments="ignored"),
]) _Block("GEAR", comments="spur gear"),
]
)
monkeypatch.setattr(cb, "_acad_doc", lambda: doc) monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _backend_no_executor() b = _backend_no_executor()
+4
View File
@@ -21,6 +21,7 @@ pytestmark = pytest.mark.asyncio
# ── R24 — CENTER/HIDDEN linetype actually loaded + applied ────────────────── # ── R24 — CENTER/HIDDEN linetype actually loaded + applied ──────────────────
async def test_create_line_loads_center_linetype(backend): async def test_create_line_loads_center_linetype(backend):
# Fresh doc: CENTER is not loaded yet. # Fresh doc: CENTER is not loaded yet.
assert "CENTER" not in backend._doc.linetypes assert "CENTER" not in backend._doc.linetypes
@@ -53,6 +54,7 @@ async def test_create_line_continuous_does_not_error(backend):
# ── S3 — block description surfaced from the definition ───────────────────── # ── S3 — block description surfaced from the definition ─────────────────────
async def test_block_list_surfaces_description(backend): async def test_block_list_surfaces_description(backend):
# Create a block from an entity, then stamp a description on its definition. # Create a block from an entity, then stamp a description on its definition.
seg = await backend.entity_create_line(0, 0, 10, 0) seg = await backend.entity_create_line(0, 0, 10, 0)
@@ -76,6 +78,7 @@ async def test_block_list_description_defaults_empty(backend):
# ── R26 / N9 — honest zoom payload shape ──────────────────────────────────── # ── R26 / N9 — honest zoom payload shape ────────────────────────────────────
async def test_zoom_extents_reports_not_applied(backend): async def test_zoom_extents_reports_not_applied(backend):
res = await backend.view_zoom_extents() res = await backend.view_zoom_extents()
assert res["ok"] is True assert res["ok"] is True
@@ -92,6 +95,7 @@ async def test_zoom_window_reports_not_applied(backend):
# ── NEW-undo-1 — undo/transaction stack behaviour under lock ──────────────── # ── NEW-undo-1 — undo/transaction stack behaviour under lock ────────────────
async def test_undo_empty_stack_returns_error(backend): async def test_undo_empty_stack_returns_error(backend):
res = await backend.drawing_undo() res = await backend.drawing_undo()
assert res["ok"] is False assert res["ok"] is False
+4
View File
@@ -35,6 +35,7 @@ class _Ctx:
# ── R20 — tool count comes from the public accessor and is a real number ───── # ── R20 — tool count comes from the public accessor and is a real number ─────
async def test_registered_tool_count_is_positive_never_minus_one(): async def test_registered_tool_count_is_positive_never_minus_one():
import server import server
@@ -59,6 +60,7 @@ async def test_registered_tools_returns_objects_with_name_and_tags():
# ── R15 — system_about tool_groups is dynamic + correct ────────────────────── # ── R15 — system_about tool_groups is dynamic + correct ──────────────────────
async def test_system_about_includes_engineering_group(backend): async def test_system_about_includes_engineering_group(backend):
import server import server
@@ -122,6 +124,7 @@ async def test_drawing_close_appears_in_groups(backend):
# ── R20 — system_status reports a real tool_count, never -1 ────────────────── # ── R20 — system_status reports a real tool_count, never -1 ──────────────────
async def test_system_status_reports_real_tool_count(backend): async def test_system_status_reports_real_tool_count(backend):
import server import server
@@ -134,6 +137,7 @@ async def test_system_status_reports_real_tool_count(backend):
# ── N8 — drawing_plan docstring corrected ──────────────────────────────────── # ── N8 — drawing_plan docstring corrected ────────────────────────────────────
async def test_drawing_plan_docstring_not_misleading(): async def test_drawing_plan_docstring_not_misleading():
import server import server
+40 -8
View File
@@ -37,7 +37,11 @@ async def test_spur_as_substring_does_not_false_match(backend, tmp_path):
"""R27: 'SPURIOUS' contains 'spur' but must NOT trip a SPUR mismatch.""" """R27: 'SPURIOUS' contains 'spur' but must NOT trip a SPUR mismatch."""
await _bootstrap(backend) await _bootstrap(backend)
await backend.entity_create_text( await backend.entity_create_text(
text="SPURIOUS HELICAL GEAR", x=0, y=0, height=5, layer="TEXT", text="SPURIOUS HELICAL GEAR",
x=0,
y=0,
height=5,
layer="TEXT",
) )
await backend.drawing_save_as(str(tmp_path / "spurious.dxf")) await backend.drawing_save_as(str(tmp_path / "spurious.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15}) result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
@@ -50,7 +54,11 @@ async def test_genuine_spur_title_flags_when_helical_expected(backend, tmp_path)
"""R27: a real 'SPUR GEAR' title still flags the mismatch when helix declared.""" """R27: a real 'SPUR GEAR' title still flags the mismatch when helix declared."""
await _bootstrap(backend) await _bootstrap(backend)
await backend.entity_create_text( await backend.entity_create_text(
text="SPUR GEAR", x=0, y=0, height=5, layer="TEXT", text="SPUR GEAR",
x=0,
y=0,
height=5,
layer="TEXT",
) )
await backend.drawing_save_as(str(tmp_path / "spur.dxf")) await backend.drawing_save_as(str(tmp_path / "spur.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15}) result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
@@ -63,7 +71,11 @@ async def test_spur_word_in_titleblock_layer_flags(backend, tmp_path):
"""R27: title-block-scoped scan — SPUR on TITLEBLOCK layer still flags.""" """R27: title-block-scoped scan — SPUR on TITLEBLOCK layer still flags."""
await _bootstrap(backend) await _bootstrap(backend)
await backend.entity_create_text( await backend.entity_create_text(
text="SPUR GEAR", x=0, y=0, height=5, layer="TITLEBLOCK", text="SPUR GEAR",
x=0,
y=0,
height=5,
layer="TITLEBLOCK",
) )
await backend.drawing_save_as(str(tmp_path / "spur_tb.dxf")) await backend.drawing_save_as(str(tmp_path / "spur_tb.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15}) result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
@@ -74,7 +86,11 @@ async def test_substring_in_titleblock_layer_does_not_false_match(backend, tmp_p
"""R27: 'SPURIOUS' on TITLEBLOCK layer must not trip a SPUR match either.""" """R27: 'SPURIOUS' on TITLEBLOCK layer must not trip a SPUR match either."""
await _bootstrap(backend) await _bootstrap(backend)
await backend.entity_create_text( await backend.entity_create_text(
text="SPURIOUS NOTE", x=0, y=0, height=5, layer="TITLEBLOCK", text="SPURIOUS NOTE",
x=0,
y=0,
height=5,
layer="TITLEBLOCK",
) )
await backend.drawing_save_as(str(tmp_path / "spurious_tb.dxf")) await backend.drawing_save_as(str(tmp_path / "spurious_tb.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15}) result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
@@ -87,7 +103,11 @@ async def test_helical_spur_combo_still_flags_invalid(backend, tmp_path):
"""R27: both whole words present -> invalid combo error preserved.""" """R27: both whole words present -> invalid combo error preserved."""
await _bootstrap(backend) await _bootstrap(backend)
await backend.entity_create_text( await backend.entity_create_text(
text="HELICAL SPUR GEAR", x=0, y=0, height=5, layer="TEXT", text="HELICAL SPUR GEAR",
x=0,
y=0,
height=5,
layer="TEXT",
) )
await backend.drawing_save_as(str(tmp_path / "combo.dxf")) await backend.drawing_save_as(str(tmp_path / "combo.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15}) result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
@@ -99,7 +119,11 @@ async def test_no_helix_expected_skips_title_check(backend, tmp_path):
"""Gating preserved: without helix_angle, a SPUR title raises no title finding.""" """Gating preserved: without helix_angle, a SPUR title raises no title finding."""
await _bootstrap(backend) await _bootstrap(backend)
await backend.entity_create_text( await backend.entity_create_text(
text="SPUR GEAR", x=0, y=0, height=5, layer="TEXT", text="SPUR GEAR",
x=0,
y=0,
height=5,
layer="TEXT",
) )
await backend.drawing_save_as(str(tmp_path / "nohelix.dxf")) await backend.drawing_save_as(str(tmp_path / "nohelix.dxf"))
result = await DrawingValidator().run(backend) result = await DrawingValidator().run(backend)
@@ -116,10 +140,18 @@ async def test_titleblock_layer_takes_precedence_over_text_layer(backend, tmp_pa
""" """
await _bootstrap(backend) await _bootstrap(backend)
await backend.entity_create_text( await backend.entity_create_text(
text="HELICAL GEAR", x=0, y=0, height=5, layer="TITLEBLOCK", text="HELICAL GEAR",
x=0,
y=0,
height=5,
layer="TITLEBLOCK",
) )
await backend.entity_create_text( await backend.entity_create_text(
text="SPUR TOOTH DETAIL NOTE", x=0, y=-20, height=3.5, layer="TEXT", text="SPUR TOOTH DETAIL NOTE",
x=0,
y=-20,
height=3.5,
layer="TEXT",
) )
await backend.drawing_save_as(str(tmp_path / "scope.dxf")) await backend.drawing_save_as(str(tmp_path / "scope.dxf"))
result = await DrawingValidator().run(backend, expected={"helix_angle": 15}) result = await DrawingValidator().run(backend, expected={"helix_angle": 15})
+2 -6
View File
@@ -29,9 +29,7 @@ async def test_ezdxf_table_returns_composite_contract(backend):
async def test_ezdxf_table_survives_save_and_reopen(backend, tmp_path): async def test_ezdxf_table_survives_save_and_reopen(backend, tmp_path):
result = await backend.entity_create_table( result = await backend.entity_create_table(0, 30, rows=[["A", "1"]], headers=["Name", "Qty"])
0, 30, rows=[["A", "1"]], headers=["Name", "Qty"]
)
path = tmp_path / "table.dxf" path = tmp_path / "table.dxf"
await backend.drawing_save_as(str(path), fmt="dxf") await backend.drawing_save_as(str(path), fmt="dxf")
await backend.drawing_open(str(path)) await backend.drawing_open(str(path))
@@ -39,9 +37,7 @@ async def test_ezdxf_table_survives_save_and_reopen(backend, tmp_path):
entities = await backend.entity_list(limit=100) entities = await backend.entity_list(limit=100)
handles = {entity.handle for entity in entities} handles = {entity.handle for entity in entities}
texts = { texts = {
entity.properties.get("text") entity.properties.get("text") for entity in entities if entity.type in {"TEXT", "MTEXT"}
for entity in entities
if entity.type in {"TEXT", "MTEXT"}
} }
assert set(result.properties["child_handles"]).issubset(handles) assert set(result.properties["child_handles"]).issubset(handles)
assert {"Name", "Qty", "A", "1"}.issubset(texts) assert {"Name", "Qty", "A", "1"}.issubset(texts)