v1.2.0
Adds production-grade engineering features and tools: 2D GD&T (feature control frames + datum features) via engineering/gdt.py, ISO‑129 dimension tolerance support (engineering/tolerances.py) and an ezdxf/COM plumbing for toleranced dims, a scalar drawing score + invalidity ratio (engineering/scoring.py) wired into drawing_finalize, in-place entity editors (entity_edit_text/entity_edit_geometry), and a friendly drawing_settings facade. Includes server tool wiring, COM/ezdxf backend implementations, datum-consistency critique, many unit tests, README/CLAUDE/CHANGELOG updates, and a version bump to 1.2.0. Also corrects COM backend capability label (entities_2d).
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)]
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user