Adds production-grade engineering features and tools: 2D GD&T (feature control frames + datum features) via engineering/gdt.py, ISO‑129 dimension tolerance support (engineering/tolerances.py) and an ezdxf/COM plumbing for toleranced dims, a scalar drawing score + invalidity ratio (engineering/scoring.py) wired into drawing_finalize, in-place entity editors (entity_edit_text/entity_edit_geometry), and a friendly drawing_settings facade. Includes server tool wiring, COM/ezdxf backend implementations, datum-consistency critique, many unit tests, README/CLAUDE/CHANGELOG updates, and a version bump to 1.2.0. Also corrects COM backend capability label (entities_2d).
This commit is contained in:
Umutcan Edizsalan
2026-07-07 11:03:08 +03:00
parent dfb21a734a
commit 712cf00c5c
18 changed files with 1703 additions and 36 deletions
+31
View File
@@ -252,6 +252,36 @@ async def _check_dim_overlap(backend: AutoCADBackend) -> list[Issue]:
return issues
async def _check_gdt(backend: AutoCADBackend) -> list[Issue]:
"""Every datum referenced by a feature control frame must have a matching
datum feature symbol.
ISO 1101 / ASME Y14.5: a position/orientation/runout FCF that cites datum A
is meaningless unless datum A is actually established on the part. The base
backend records referenced/defined datum letters as `draw_feature_control_frame`
and `draw_datum_feature` run, so this check needs no geometry parsing and is
identical on both engines.
"""
referenced = set(getattr(backend, "_gdt_datums_referenced", None) or set())
defined = set(getattr(backend, "_gdt_datums_defined", None) or set())
missing = sorted(referenced - defined)
if not missing:
return []
return [Issue(
severity="error",
focus="gdt",
message=(
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,
"defined": sorted(defined),
"referenced": sorted(referenced),
},
)]
_FOCUS_DISPATCH = {
"iso128": _check_iso128,
"layer_color": _check_layer_color,
@@ -259,6 +289,7 @@ _FOCUS_DISPATCH = {
"untrimmed_corner": _check_untrimmed_corner,
"duplicate_entities": _check_duplicate_entities,
"construction_left": _check_construction_left,
"gdt": _check_gdt,
}
+171
View File
@@ -0,0 +1,171 @@
"""2D GD&T (ISO 1101 / ASME Y14.5) feature control frames + datum features.
This is the single biggest "no competitor has this" capability: no surveyed
text-to-CAD product or competing CAD MCP ships 2D geometric-tolerance authoring
*with validation*. Frames are composed from plain LINE + TEXT primitives (not
ezdxf's native TOLERANCE entity, which renders blank through the matplotlib
frontend), so the exact same output lands on both the COM and ezdxf backends.
The layout math here is pure/deterministic and unit-tested without a backend;
the async drawing orchestration lives on `AutoCADBackend` (base.py) so both
engines inherit identical behaviour.
"""
from __future__ import annotations
# ISO 1101 geometric characteristic symbols (Unicode glyph per characteristic).
# ASCII keys keep the tool schema legible; the glyph is what lands in the DXF.
GEOMETRIC_SYMBOLS: dict[str, str] = {
"straightness": "\u23e4", # ⏤
"flatness": "\u23e5", # ⏥
"circularity": "\u25cb", # ○
"cylindricity": "\u232d", # ⌭
"profile_line": "\u2312", # ⌒ profile of a line
"profile_surface": "\u2313", # ⌓ profile of a surface
"angularity": "\u2220", # ∠
"perpendicularity": "\u27c2", # ⟂
"parallelism": "\u2225", # ∥
"position": "\u2316", # ⌖
"concentricity": "\u25ce", # ◎
"symmetry": "\u232f", # ⌯
"circular_runout": "\u2197", # ↗
"total_runout": "\u2913", # ⤓ (double-arrow stand-in for total runout)
}
# Which characteristics are legal *without* a datum reference (form tolerances).
# Everything else (orientation / location / runout) requires at least one datum.
FORM_TOLERANCES: frozenset[str] = frozenset(
{"straightness", "flatness", "circularity", "cylindricity", "profile_line", "profile_surface"}
)
# Material-condition modifiers (ASME Y14.5): circled M / L / S.
MATERIAL_MODIFIERS: dict[str, str] = {
"M": "\u24c2", # Ⓜ maximum material condition
"L": "\u24c1", # Ⓛ least material condition
"S": "\u24c8", # Ⓢ regardless of feature size
}
# Diameter prefix for cylindrical tolerance zones (true position of a hole, …).
DIAMETER_PREFIX = "\u2300" # ⌀
def _tolerance_text(tolerance: str | float, diameter: bool, modifier: str | None) -> str:
"""Build the tolerance compartment string: optional ⌀, value, optional Ⓜ/Ⓛ/Ⓢ."""
if isinstance(tolerance, (int, float)):
# Trim a trailing ".0" so 0.10 -> "0.1", 5.0 -> "5".
tol = f"{float(tolerance):g}"
else:
tol = str(tolerance).strip()
parts = ""
if diameter:
parts += DIAMETER_PREFIX
parts += tol
if modifier:
mod = modifier.strip().upper()
if mod not in MATERIAL_MODIFIERS:
raise ValueError(
f"gdt: unknown material modifier {modifier!r}. "
f"Use one of {sorted(MATERIAL_MODIFIERS)}."
)
parts += MATERIAL_MODIFIERS[mod]
return parts
def fcf_compartments(
symbol: str,
tolerance: str | float,
datums: list[str] | None = None,
*,
diameter: bool = False,
modifier: str | None = None,
) -> list[str]:
"""Return the ordered compartment strings of a feature control frame.
``[symbol_glyph, tolerance, datum1, datum2, …]``. Raises ValueError for an
unknown characteristic, or for an orientation/location/runout characteristic
given no datum (that combination is meaningless per ISO 1101).
"""
key = symbol.strip().lower()
if key not in GEOMETRIC_SYMBOLS:
raise ValueError(
f"gdt: unknown characteristic {symbol!r}. Use one of {sorted(GEOMETRIC_SYMBOLS)}."
)
datums = [d.strip().upper() for d in (datums or []) if str(d).strip()]
if key not in FORM_TOLERANCES and not datums:
raise ValueError(
f"gdt: characteristic '{key}' is an orientation/location/runout "
"control and requires at least one datum reference."
)
comps = [GEOMETRIC_SYMBOLS[key], _tolerance_text(tolerance, diameter, modifier)]
comps.extend(datums)
return comps
def fcf_layout(
compartments: list[str],
origin_x: float,
origin_y: float,
height: float,
) -> dict:
"""Deterministic geometry for a feature-control frame.
Given the compartment strings and a bottom-left origin, returns::
{
"box": [(x, y), …, (x0, y0)], # closed outer rectangle
"dividers": [((x, y0), (x, y1)), …],# internal vertical separators
"labels": [(text, cx, cy), …], # centred text per compartment
"width": float, "height": float,
}
Widths: symbol + datum compartments are square (``height`` wide); the
tolerance compartment grows with its text so it never clips.
"""
h = float(height)
x = float(origin_x)
y0 = float(origin_y)
y1 = y0 + h
text_h = h * 0.6
widths: list[float] = []
for i, text in enumerate(compartments):
if i == 1: # tolerance compartment — size to content
widths.append(max(h * 1.6, len(text) * text_h * 0.75 + h * 0.5))
else:
widths.append(h)
labels: list[tuple[str, float, float]] = []
dividers: list[tuple[tuple[float, float], tuple[float, float]]] = []
cx = x
for i, (text, w) in enumerate(zip(compartments, widths, strict=True)):
labels.append((text, cx + w / 2.0, y0 + h / 2.0))
cx += w
if i < len(compartments) - 1:
dividers.append(((cx, y0), (cx, y1)))
total_w = sum(widths)
box = [
(x, y0),
(x + total_w, y0),
(x + total_w, y1),
(x, y1),
(x, y0),
]
return {
"box": box,
"dividers": dividers,
"labels": labels,
"width": total_w,
"height": h,
"text_height": text_h,
}
def datum_triangle(
x: float, y: float, size: float, *, down: bool = True
) -> list[tuple[float, float]]:
"""Filled datum triangle (closed 3-point path) with apex at (x, y)."""
s = float(size)
if down:
return [(x, y), (x - s / 2.0, y - s), (x + s / 2.0, y - s), (x, y)]
return [(x, y), (x - s / 2.0, y + s), (x + s / 2.0, y + s), (x, y)]
+2
View File
@@ -20,6 +20,7 @@ CritiqueFocus = Literal[
"untrimmed_corner",
"duplicate_entities",
"construction_left",
"gdt",
]
SnapType = Literal["end", "mid", "center", "quad", "int", "perp", "near"]
@@ -30,6 +31,7 @@ ALL_CRITIQUE_FOCUSES: tuple[CritiqueFocus, ...] = (
"untrimmed_corner",
"duplicate_entities",
"construction_left",
"gdt",
)
+85
View File
@@ -0,0 +1,85 @@
"""Scalar drawing-quality score + invalidity ratio.
Every 2025-26 CAD-generation benchmark (MUSE, CadBench) grades an *Invalidity
Ratio*, not just shape similarity. This module turns the validator's and
critique's severity counts into a single regression-trackable scalar so the
premium workflow has an objective function (the prerequisite for an automated
refiner loop and for smoke-test regression tracking).
The weights are a documented convention, not ground truth — they exist so the
number moves in the right direction (errors hurt most, info barely registers)
and can be asserted in tests.
"""
from __future__ import annotations
# Penalty weights per severity. Errors dominate; info is nearly free.
_W_ERROR = 25.0
_W_WARNING = 8.0
_W_INFO = 1.0
def score_findings(
errors: int,
warnings: int,
info: int = 0,
*,
w_error: float = _W_ERROR,
w_warning: float = _W_WARNING,
w_info: float = _W_INFO,
) -> dict:
"""Collapse severity counts into a 0-100 score + invalidity ratio.
- ``score``: ``100 - (errors·Wₑ + warnings·Wᵥᵥ + info·Wᵢ)`` clamped to [0, 100].
A clean drawing scores 100.
- ``invalidity_ratio``: ``errors / max(1, total_findings)`` — the fraction of
findings that are hard failures (0.0 when there are no findings).
- ``grade``: coarse A-F bucket for at-a-glance reporting.
"""
errors = max(0, int(errors))
warnings = max(0, int(warnings))
info = max(0, int(info))
penalty = errors * w_error + warnings * w_warning + info * w_info
score = max(0.0, min(100.0, 100.0 - penalty))
total = errors + warnings + info
invalidity_ratio = (errors / total) if total else 0.0
return {
"score": round(score, 1),
"invalidity_ratio": round(invalidity_ratio, 3),
"grade": _grade(score),
"errors": errors,
"warnings": warnings,
"info": info,
}
def combine(*severity_dicts: dict) -> dict:
"""Sum several ``{'error': n, 'warning': n, 'info': n}`` summaries then score.
Accepts the shapes emitted by both ``DrawingValidator`` (``summary``) and the
finalize ``critique_summary`` so the finalize gate can score the union of the
structural validator and the premium critique in one number.
"""
errors = warnings = info = 0
for d in severity_dicts:
if not d:
continue
errors += int(d.get("error", 0))
warnings += int(d.get("warning", 0))
info += int(d.get("info", 0))
return score_findings(errors, warnings, info)
def _grade(score: float) -> str:
if score >= 95.0:
return "A"
if score >= 85.0:
return "B"
if score >= 70.0:
return "C"
if score >= 50.0:
return "D"
return "F"
+78
View File
@@ -0,0 +1,78 @@
"""ISO 129-1 dimension tolerance helpers.
A production drawing without ±/limit callouts is not manufacturable — this was
the dossier's "biggest functional gap". These helpers turn a small, explicit
tolerance spec into the ezdxf/AutoCAD dimension-variable (DIMVAR) overrides that
render tolerances, so `dimension_linear/radius/diameter` can emit toleranced
dims on both backends.
Scope (intentionally CI-testable): explicit upper/lower deviations with
symmetric / deviation / limit / basic display modes, plus a free-form text
override. The full ISO 286 fit table (H7/g6 …) is a large authored dataset and
is deferred to a follow-up; callers can already pass the resolved deviations.
"""
from __future__ import annotations
TOL_MODES = ("none", "symmetric", "deviation", "limit", "basic")
def build_dim_override(
tol_upper: float | None = None,
tol_lower: float | None = None,
tol_mode: str = "none",
text_override: str | None = None,
) -> tuple[dict, str | None]:
"""Return ``(dimvar_override, text)`` for an ezdxf ``add_*_dim`` call.
- ``tol_mode="symmetric"``: a single ± value (uses ``tol_upper``; ``±tol``).
- ``tol_mode="deviation"``: distinct upper/lower deviations (e.g. +0.02/-0.01).
- ``tol_mode="limit"``: upper/lower limits stacked instead of ± (DIMLIM).
- ``tol_mode="basic"``: theoretically-exact (boxed) dimension, no tolerance.
- ``tol_mode="none"``: no tolerance (default) — empty override.
- ``text_override``: replaces the measured text ("<>" keeps the measurement).
Raises ValueError for an unknown mode or a mode missing its required values.
"""
mode = (tol_mode or "none").strip().lower()
if mode not in TOL_MODES:
raise ValueError(
f"build_dim_override: unknown tol_mode {tol_mode!r}. Use one of {TOL_MODES}."
)
override: dict = {}
if mode == "symmetric":
if tol_upper is None:
raise ValueError("tol_mode='symmetric' requires tol_upper (the ± value).")
t = abs(float(tol_upper))
override.update({"dimtol": 1, "dimlim": 0, "dimtp": t, "dimtm": t})
elif mode == "deviation":
if tol_upper is None or tol_lower is None:
raise ValueError("tol_mode='deviation' requires tol_upper and tol_lower.")
override.update(
{
"dimtol": 1,
"dimlim": 0,
"dimtp": float(tol_upper),
"dimtm": abs(float(tol_lower)),
}
)
elif mode == "limit":
if tol_upper is None or tol_lower is None:
raise ValueError("tol_mode='limit' requires tol_upper and tol_lower.")
override.update(
{
"dimlim": 1,
"dimtol": 0,
"dimtp": float(tol_upper),
"dimtm": abs(float(tol_lower)),
}
)
elif mode == "basic":
# Theoretically-exact dimension: a box around the text. In AutoCAD a
# negative DIMGAP draws that box.
override.update({"dimgap": -1.0})
text = text_override if text_override not in (None, "") else None
return override, text