Add deterministic engineering CAD layer (gear, keyway, titleblock, validator)

Introduces an engineering/ package that lets MCP clients produce production-
quality technical drawings without LLM-improvised geometry. Adds 7 new MCP
tools, an 8-step pre-completion validator, and an automatic linetype + layer
bootstrap on drawing_new so CENTER/HIDDEN/PHANTOM and standard mechanical
layers (GEOMETRY, DIM, HATCH, TEXT, TITLEBLOCK, ...) are always available.

- engineering/gear.py: pure-Python involute math, 40+ points/flank, helical
  + spur front views, deterministic section A-A (no helix lines in section).
- engineering/keyway.py: DIN 6885 keyway table + parametric keyed bore /
  side-section drawers.
- engineering/titleblock.py: ISO 7200 A3 (420x297 mm) title block; title
  text is written verbatim to the title cell.
- engineering/validator.py: DrawingValidator with 8 checks (real DIMENSION
  entities, no hidden orphans, linetypes loaded, bore + keyway in both
  views when expected, title consistency e.g. forbid 'HELICAL SPUR GEAR'
  when helix_angle truthy, saved-to-disk + file-on-disk, screenshot
  exported, dwg path returned).
- backends/ezdxf_backend.py: AutoCAD-only linetype fallback (HIDDEN,
  BORDER families) so ezdxf doc gets correct patterns when linetype_load
  is called for names ezdxf.tools.standards does not ship.
- backends/base.py: concrete helpers set_layer_active / ensure_linetypes.
- server.py: Section 12 with 7 engineering tools, drawing_new(bootstrap=
  True), and an ENGINEERING WORKFLOW block in the MCP instructions string.
- 30 new tests covering gear math, DIN6885 lookup, ISO A3 layout, title
  verbatim, and every validator check. Tool count 89 -> 96.
This commit is contained in:
U-C4N
2026-04-28 12:24:08 +03:00
parent 59f3bc5fd5
commit 88a9005a6a
16 changed files with 2320 additions and 10 deletions
+65
View File
@@ -0,0 +1,65 @@
"""Deterministic engineering CAD layer for AutoCAD MCP Pro."""
from .gear import (
draw_gear_section_aa,
draw_helical_gear_front_view,
draw_spur_gear_front_view,
generate_full_gear_outline,
generate_involute_flank,
generate_tooth_profile,
involute_xy,
)
from .keyway import (
DIN6885_TABLE,
draw_keyed_bore,
draw_keyway_section,
keyway_dimensions,
)
from .layers import (
ENGINEERING_LAYERS,
STANDARD_LINETYPES,
ensure_engineering_layers,
ensure_standard_linetypes,
)
from .section import apply_section_hatch, cutting_plane_line
# Agent B contributes titleblock + validator. Imports are guarded so this
# package remains usable while B is still in flight; once those modules land
# the symbols flow through transparently.
try:
from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock
except ImportError:
TitleBlockMetadata = None # type: ignore[assignment]
apply_iso_a3_titleblock = None # type: ignore[assignment]
try:
from .validator import DrawingValidator, ValidationFinding, ValidationResult
except ImportError:
DrawingValidator = None # type: ignore[assignment]
ValidationFinding = None # type: ignore[assignment]
ValidationResult = None # type: ignore[assignment]
__all__ = [
"draw_helical_gear_front_view",
"draw_spur_gear_front_view",
"draw_gear_section_aa",
"generate_full_gear_outline",
"generate_involute_flank",
"generate_tooth_profile",
"involute_xy",
"draw_keyed_bore",
"draw_keyway_section",
"keyway_dimensions",
"DIN6885_TABLE",
"cutting_plane_line",
"apply_section_hatch",
"ENGINEERING_LAYERS",
"STANDARD_LINETYPES",
"ensure_engineering_layers",
"ensure_standard_linetypes",
"apply_iso_a3_titleblock",
"TitleBlockMetadata",
"DrawingValidator",
"ValidationResult",
"ValidationFinding",
]
+431
View File
@@ -0,0 +1,431 @@
"""Parametric involute spur and helical gear generators (pure-Python math)."""
from __future__ import annotations
import logging
import math
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from backends.base import AutoCADBackend
from .keyway import draw_keyed_bore, draw_keyway_section
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pure math
# ---------------------------------------------------------------------------
def involute_xy(base_radius: float, t: float) -> tuple[float, float]:
"""Single involute point at parameter t (radians) on the base circle."""
return (
base_radius * (math.cos(t) + t * math.sin(t)),
base_radius * (math.sin(t) - t * math.cos(t)),
)
def generate_involute_flank(
base_r: float,
root_r: float,
outer_r: float,
n_points: int = 40,
) -> list[tuple[float, float]]:
"""One tooth flank from root through base circle out to addendum tip."""
if n_points < 30:
n_points = 30
points: list[tuple[float, float]] = []
if root_r < base_r:
points.append((root_r, 0.0))
ratio = outer_r / base_r if base_r > 0 else 1.0
if ratio <= 1.0:
points.append((base_r, 0.0))
return points
t_max = math.sqrt(ratio * ratio - 1.0)
for i in range(n_points):
t = t_max * i / (n_points - 1)
points.append(involute_xy(base_r, t))
return points
def _rotate_points(
pts: list[tuple[float, float]], angle: float
) -> list[tuple[float, float]]:
c, s = math.cos(angle), math.sin(angle)
return [(c * x - s * y, s * x + c * y) for x, y in pts]
def _arc_points(
cx: float, cy: float, radius: float,
start_angle: float, end_angle: float, n: int = 5,
) -> list[tuple[float, float]]:
"""Sample n points along an arc (start_angle → end_angle, radians, ccw)."""
if n < 2:
n = 2
pts: list[tuple[float, float]] = []
for i in range(n):
a = start_angle + (end_angle - start_angle) * i / (n - 1)
pts.append((cx + radius * math.cos(a), cy + radius * math.sin(a)))
return pts
def generate_tooth_profile(
module: float,
teeth: int,
pressure_angle: float = 20.0,
n_points_per_flank: int = 40,
) -> list[tuple[float, float]]:
"""Single tooth profile: right flank, tip arc, mirrored left flank."""
pa = math.radians(pressure_angle)
pitch_r = module * teeth / 2.0
base_r = pitch_r * math.cos(pa)
outer_r = pitch_r + module
root_r = pitch_r - 1.25 * module
# Tooth half-thickness angle on the pitch circle.
s = math.pi * module / 2.0
theta_pitch = s / pitch_r / 2.0
# Find involute parameter at pitch circle so we can shift the flank to be
# symmetric about the tooth centerline (y=0 axis after rotation).
if outer_r > base_r:
t_pitch = math.sqrt((pitch_r / base_r) ** 2 - 1.0) if pitch_r > base_r else 0.0
else:
t_pitch = 0.0
inv_pitch = math.tan(pa) - pa # involute function at pressure angle
# The flank generated by `generate_involute_flank` starts at theta=0 on the
# base circle. The involute point at the pitch radius makes angle inv_pitch
# (approx). We rotate the flank by -inv_pitch + theta_pitch so the pitch
# crossing sits at +theta_pitch above the tooth-center axis.
rot = -inv_pitch + theta_pitch
right_flank = generate_involute_flank(base_r, root_r, outer_r, n_points_per_flank)
right_flank = _rotate_points(right_flank, rot)
# Left flank by mirroring across the X axis (tooth centerline).
left_flank = [(x, -y) for x, y in right_flank]
# Tip arc connects the outermost right-flank point to the outermost left-flank point.
tip_right = right_flank[-1]
tip_left = left_flank[-1]
a_right = math.atan2(tip_right[1], tip_right[0])
a_left = math.atan2(tip_left[1], tip_left[0])
if a_left > a_right:
a_left -= 2 * math.pi
tip_arc = _arc_points(0.0, 0.0, outer_r, a_right, a_left, n=5)
profile: list[tuple[float, float]] = []
profile.extend(right_flank)
profile.extend(tip_arc[1:-1])
profile.extend(reversed(left_flank))
return profile
def generate_full_gear_outline(
module: float,
teeth: int,
pressure_angle: float = 20.0,
helix_angle: float = 0.0,
hand: str = "RH",
center: tuple[float, float] = (0.0, 0.0),
n_points_per_flank: int = 40,
) -> list[tuple[float, float]]:
"""Closed polyline of all teeth around the gear, optionally translated to `center`."""
del helix_angle, hand # helix is annotated by symbol, outline stays the same
pa = math.radians(pressure_angle)
pitch_r = module * teeth / 2.0
base_r = pitch_r * math.cos(pa)
outer_r = pitch_r + module
root_r = pitch_r - 1.25 * module
s = math.pi * module / 2.0
theta_pitch = s / pitch_r / 2.0
if pitch_r > base_r:
inv_pitch = math.tan(pa) - pa
else:
inv_pitch = 0.0
rot = -inv_pitch + theta_pitch
base_right = generate_involute_flank(base_r, root_r, outer_r, n_points_per_flank)
base_right = _rotate_points(base_right, rot)
base_left = [(x, -y) for x, y in base_right]
tip_right = base_right[-1]
tip_left = base_left[-1]
a_tip_right = math.atan2(tip_right[1], tip_right[0])
a_tip_left = math.atan2(tip_left[1], tip_left[0])
if a_tip_left > a_tip_right:
a_tip_left -= 2 * math.pi
tip_arc = _arc_points(0.0, 0.0, outer_r, a_tip_right, a_tip_left, n=5)
root_right = base_right[0]
root_left = base_left[0]
a_root_right = math.atan2(root_right[1], root_right[0])
a_root_left = math.atan2(root_left[1], root_left[0])
pitch_step = 2.0 * math.pi / teeth
cx, cy = float(center[0]), float(center[1])
outline: list[tuple[float, float]] = []
for k in range(teeth):
angle = k * pitch_step
# Right flank (root → tip)
for x, y in _rotate_points(base_right, angle):
outline.append((cx + x, cy + y))
# Tip arc (skip endpoints to avoid duplicates with flanks)
for x, y in _rotate_points(tip_arc[1:-1], angle):
outline.append((cx + x, cy + y))
# Left flank (tip → root, reversed)
for x, y in _rotate_points(list(reversed(base_left)), angle):
outline.append((cx + x, cy + y))
# Root arc to next tooth's right-flank root
next_angle = (k + 1) * pitch_step
start = a_root_left + angle
end = a_root_right + next_angle
if end < start:
end += 2.0 * math.pi
for x, y in _arc_points(0.0, 0.0, root_r, start, end, n=5)[1:-1]:
outline.append((cx + x, cy + y))
if outline and outline[0] != outline[-1]:
outline.append(outline[0])
return outline
# ---------------------------------------------------------------------------
# Drawing orchestration
# ---------------------------------------------------------------------------
async def _draw_helix_symbol(
backend: "AutoCADBackend",
*,
cx: float,
cy: float,
outer_r: float,
helix_angle: float,
hand: str,
) -> tuple[list[str], str]:
"""Three short diagonal lines + 'XX° hand' text near top-right of gear."""
base_x = cx + outer_r * 0.3
base_y = cy + outer_r * 0.6
seg_len = max(outer_r * 0.15, 4.0)
spacing = max(outer_r * 0.06, 2.0)
sign = -1.0 if hand.upper() == "LH" else 1.0
a = math.radians(helix_angle) * sign
handles: list[str] = []
for i in range(3):
x0 = base_x + i * spacing
y0 = base_y
x1 = x0 + seg_len * math.sin(a)
y1 = y0 + seg_len * math.cos(a)
line = await backend.entity_create_line(x0, y0, x1, y1, layer="GEOMETRY")
handles.append(line.handle)
label_text = f"{helix_angle:g}° {hand.upper()}"
label = await backend.entity_create_text(
label_text,
base_x + 3 * spacing + seg_len,
base_y + seg_len * 0.5,
height=max(outer_r * 0.04, 2.5),
layer="TEXT",
)
return handles, label.handle
async def draw_helical_gear_front_view(
backend: "AutoCADBackend",
*,
module: float,
teeth: int,
helix_angle: float,
pressure_angle: float = 20.0,
hand: str = "RH",
center: tuple[float, float] = (0.0, 0.0),
bore_diameter: float | None = None,
keyway_width: float | None = None,
keyway_depth: float | None = None,
) -> dict[str, Any]:
"""Draw front view of a helical gear: pitch/base/outer/root circles, full outline,
helix symbol + label, and optional keyed bore."""
pa_rad = math.radians(pressure_angle)
pitch_r = module * teeth / 2.0
base_r = pitch_r * math.cos(pa_rad)
outer_r = pitch_r + module
root_r = pitch_r - 1.25 * module
cx, cy = float(center[0]), float(center[1])
outer_circ = await backend.entity_create_circle(cx, cy, outer_r, layer="GEOMETRY")
pitch_circ = await backend.entity_create_circle(cx, cy, pitch_r, layer="CENTER")
base_circ = await backend.entity_create_circle(cx, cy, base_r, layer="CONSTRUCTION")
root_circ = await backend.entity_create_circle(cx, cy, root_r, layer="GEOMETRY")
pts = generate_full_gear_outline(
module=module, teeth=teeth, pressure_angle=pressure_angle,
helix_angle=helix_angle, hand=hand, center=(cx, cy),
)
outline = await backend.entity_create_polyline(
[[float(x), float(y)] for x, y in pts],
closed=True, layer="GEOMETRY",
)
helix_handles: list[str] | None = None
helix_label: str | None = None
if helix_angle and abs(helix_angle) > 1e-9:
helix_handles, helix_label = await _draw_helix_symbol(
backend, cx=cx, cy=cy, outer_r=outer_r,
helix_angle=helix_angle, hand=hand,
)
bore_handle: str | None = None
keyway_dict: dict | None = None
if bore_diameter is not None:
if keyway_width is not None or keyway_depth is not None:
keyed = await draw_keyed_bore(
backend,
center=(cx, cy),
bore_diameter=bore_diameter,
keyway_width=keyway_width,
keyway_depth=keyway_depth,
)
bore_handle = keyed["bore"]
keyway_dict = keyed
else:
bore_only = await backend.entity_create_circle(
cx, cy, bore_diameter / 2.0, layer="GEOMETRY",
)
bore_handle = bore_only.handle
metadata = {
"module": float(module),
"teeth": int(teeth),
"pressure_angle": float(pressure_angle),
"helix_angle": float(helix_angle),
"hand": str(hand),
"center": [cx, cy],
"bore_diameter": float(bore_diameter) if bore_diameter is not None else None,
"keyway_width": float(keyway_width) if keyway_width is not None else None,
"keyway_depth": float(keyway_depth) if keyway_depth is not None else None,
"pitch_radius": pitch_r,
"outer_radius": outer_r,
"base_radius": base_r,
"root_radius": root_r,
}
return {
"outline": outline.handle,
"pitch_circle": pitch_circ.handle,
"base_circle": base_circ.handle,
"outer_circle": outer_circ.handle,
"root_circle": root_circ.handle,
"helix_symbol": helix_handles,
"helix_label": helix_label,
"bore": bore_handle,
"keyway": keyway_dict,
"metadata": metadata,
}
async def draw_spur_gear_front_view(
backend: "AutoCADBackend",
*,
module: float,
teeth: int,
pressure_angle: float = 20.0,
center: tuple[float, float] = (0.0, 0.0),
bore_diameter: float | None = None,
keyway_width: float | None = None,
keyway_depth: float | None = None,
) -> dict[str, Any]:
"""Front view of a spur gear (helix symbol omitted)."""
return await draw_helical_gear_front_view(
backend,
module=module,
teeth=teeth,
helix_angle=0.0,
pressure_angle=pressure_angle,
hand="RH",
center=center,
bore_diameter=bore_diameter,
keyway_width=keyway_width,
keyway_depth=keyway_depth,
)
async def draw_gear_section_aa(
backend: "AutoCADBackend",
*,
gear_metadata: dict,
x_offset: float,
face_width: float,
) -> dict[str, Any]:
"""Side cross-section view: outer rectangle, bore lines, optional keyway notch, ANSI31 hatch."""
outer_r = float(gear_metadata["outer_radius"])
cy = float(gear_metadata["center"][1])
bore_d = gear_metadata.get("bore_diameter")
keyway_w = gear_metadata.get("keyway_width")
keyway_h = gear_metadata.get("keyway_depth")
x_left = float(x_offset)
x_right = x_left + float(face_width)
y_top = cy + outer_r
y_bot = cy - outer_r
section_cx = (x_left + x_right) / 2.0
top = await backend.entity_create_line(x_left, y_top, x_right, y_top, layer="GEOMETRY")
bottom = await backend.entity_create_line(x_left, y_bot, x_right, y_bot, layer="GEOMETRY")
left = await backend.entity_create_line(x_left, y_bot, x_left, y_top, layer="GEOMETRY")
right = await backend.entity_create_line(x_right, y_bot, x_right, y_top, layer="GEOMETRY")
hatch_handle = ""
try:
hatch_info = await backend.entity_create_hatch(
pattern="ANSI31",
boundary_points=[
[x_left, y_bot],
[x_right, y_bot],
[x_right, y_top],
[x_left, y_top],
],
scale=1.0,
angle=45.0,
layer="HATCH",
)
hatch_handle = hatch_info.handle
except Exception as exc:
log.warning("section A-A hatch failed: %s", exc)
bore_handles: list[str] = []
keyway_dict: dict | None = None
if bore_d is not None:
bore_r = float(bore_d) / 2.0
bore_top = await backend.entity_create_line(
x_left, cy + bore_r, x_right, cy + bore_r, layer="GEOMETRY",
)
bore_bot = await backend.entity_create_line(
x_left, cy - bore_r, x_right, cy - bore_r, layer="GEOMETRY",
)
bore_handles = [bore_top.handle, bore_bot.handle]
if keyway_w is not None or keyway_h is not None:
keyway_dict = await draw_keyway_section(
backend,
center=(section_cx, cy),
bore_diameter=float(bore_d),
face_width=float(face_width),
keyway_width=keyway_w,
keyway_depth=keyway_h,
)
return {
"top": top.handle,
"bottom": bottom.handle,
"left": left.handle,
"right": right.handle,
"bore": bore_handles,
"keyway": keyway_dict,
"hatch": hatch_handle,
}
+173
View File
@@ -0,0 +1,173 @@
"""DIN 6885 keyway geometry — bore + keyway cutter for shafts and hubs."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import AutoCADBackend
log = logging.getLogger(__name__)
DIN6885_TABLE: list[tuple[float, float, float, float, float]] = [
# (bore_min, bore_max, key_width, key_height, depth_in_shaft_t1)
(6, 8, 2, 2, 1.2),
(8, 10, 3, 3, 1.8),
(10, 12, 4, 4, 2.5),
(12, 17, 5, 5, 3.0),
(17, 22, 6, 6, 3.5),
(22, 30, 8, 7, 4.0),
(30, 38, 10, 8, 5.0),
(38, 44, 12, 8, 5.0),
(44, 50, 14, 9, 5.5),
(50, 58, 16, 10, 6.0),
(58, 65, 18, 11, 7.0),
(65, 75, 20, 12, 7.5),
(75, 85, 22, 14, 9.0),
(85, 95, 25, 14, 9.0),
(95, 110, 28, 16, 10.0),
(110, 130, 32, 18, 11.0),
]
def keyway_dimensions(bore_diameter: float) -> dict:
"""Look up DIN 6885 key width/height/depth for a given bore diameter (mm)."""
if bore_diameter < DIN6885_TABLE[0][0]:
raise ValueError(
f"bore_diameter {bore_diameter} mm is below DIN 6885 minimum "
f"({DIN6885_TABLE[0][0]} mm)."
)
if bore_diameter > DIN6885_TABLE[-1][1]:
raise ValueError(
f"bore_diameter {bore_diameter} mm exceeds DIN 6885 maximum "
f"({DIN6885_TABLE[-1][1]} mm)."
)
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]):
return {
"width": float(width),
"height": float(height),
"depth_shaft": float(t1),
"depth_hub": float(height) - float(t1),
}
raise ValueError(f"No DIN 6885 row matched bore_diameter={bore_diameter}.")
async def draw_keyed_bore(
backend: "AutoCADBackend",
*,
center: tuple[float, float],
bore_diameter: float,
keyway_width: float | None = None,
keyway_depth: float | None = None,
layer: str = "GEOMETRY",
center_layer: str = "CENTER",
) -> dict:
"""Draw bore + upward-opening keyway notch + horizontal/vertical centerlines."""
cx, cy = float(center[0]), float(center[1])
r = float(bore_diameter) / 2.0
if keyway_width is None or keyway_depth is None:
dims = keyway_dimensions(bore_diameter)
if keyway_width is None:
keyway_width = dims["width"]
if keyway_depth is None:
keyway_depth = dims["depth_hub"]
bore = await backend.entity_create_circle(cx, cy, r, layer=layer)
half_w = float(keyway_width) / 2.0
depth = float(keyway_depth)
bore_top_y = cy + r
notch_top_y = bore_top_y + depth
left_x = cx - half_w
right_x = cx + half_w
points = [
[left_x, bore_top_y],
[left_x, notch_top_y],
[right_x, notch_top_y],
[right_x, bore_top_y],
]
keyway_poly = await backend.entity_create_polyline(points, closed=False, layer=layer)
overshoot = max(r * 0.2, 5.0)
horiz = await backend.entity_create_line(
cx - r - overshoot, cy, cx + r + overshoot, cy, layer=center_layer,
)
vert = await backend.entity_create_line(
cx, cy - r - overshoot, cx, cy + r + depth + overshoot, layer=center_layer,
)
return {
"bore": bore.handle,
"keyway_polyline": keyway_poly.handle,
"centerline_h": horiz.handle,
"centerline_v": vert.handle,
}
async def draw_keyway_section(
backend: "AutoCADBackend",
*,
center: tuple[float, float],
bore_diameter: float,
face_width: float,
keyway_width: float | None = None,
keyway_depth: float | None = None,
layer: str = "GEOMETRY",
) -> dict:
"""Side cross-section: bore as two horizontal lines with rectangular keyway notch on top."""
cx, cy = float(center[0]), float(center[1])
r = float(bore_diameter) / 2.0
half_face = float(face_width) / 2.0
if keyway_width is None or keyway_depth is None:
dims = keyway_dimensions(bore_diameter)
if keyway_width is None:
keyway_width = dims["width"]
if keyway_depth is None:
keyway_depth = dims["depth_hub"]
half_kw = float(keyway_width) / 2.0
depth = float(keyway_depth)
top_y = cy + r
bot_y = cy - r
notch_top_y = top_y + depth
x_left = cx - half_face
x_right = cx + half_face
notch_left = cx - half_kw
notch_right = cx + half_kw
bore_top = await backend.entity_create_line(
x_left, top_y, x_right, top_y, layer=layer,
)
bore_bottom = await backend.entity_create_line(
x_left, bot_y, x_right, bot_y, layer=layer,
)
notch_bottom = await backend.entity_create_line(
notch_left, top_y, notch_right, top_y, layer=layer,
)
notch_left_wall = await backend.entity_create_line(
notch_left, top_y, notch_left, notch_top_y, layer=layer,
)
notch_top = await backend.entity_create_line(
notch_left, notch_top_y, notch_right, notch_top_y, layer=layer,
)
notch_right_wall = await backend.entity_create_line(
notch_right, notch_top_y, notch_right, top_y, layer=layer,
)
return {
"bore_top": bore_top.handle,
"bore_bottom": bore_bottom.handle,
"keyway": [
notch_bottom.handle,
notch_left_wall.handle,
notch_top.handle,
notch_right_wall.handle,
],
}
+74
View File
@@ -0,0 +1,74 @@
"""Standard engineering layer + linetype scaffold for production drawings."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import AutoCADBackend
log = logging.getLogger(__name__)
ENGINEERING_LAYERS: list[tuple[str, int, str, float, str]] = [
# (name, color, linetype, lineweight, description)
("0", 7, "Continuous", 0.25, "default"),
("GEOMETRY", 7, "Continuous", 0.50, "visible solid edges"),
("HIDDEN", 3, "HIDDEN", 0.25, "hidden edges"),
("CENTER", 1, "CENTER", 0.18, "centerlines"),
("PHANTOM", 6, "PHANTOM", 0.18, "section / cutting planes"),
("DIM", 2, "Continuous", 0.18, "dimensions"),
("TEXT", 7, "Continuous", 0.25, "annotations"),
("HATCH", 8, "Continuous", 0.13, "section hatching"),
("TITLEBLOCK", 7, "Continuous", 0.50, "drawing border + title"),
("CONSTRUCTION", 250, "Continuous", 0.05, "scratch / construction"),
]
STANDARD_LINETYPES: list[str] = ["CENTER", "HIDDEN", "PHANTOM", "DASHED", "DASHDOT"]
async def ensure_standard_linetypes(backend: "AutoCADBackend") -> dict[str, str]:
"""Idempotently load STANDARD_LINETYPES via backend.linetype_load."""
try:
existing = {ln.lower() for ln in await backend.linetype_list()}
except Exception as exc:
log.warning("linetype_list failed; assuming none loaded: %s", exc)
existing = set()
results: dict[str, str] = {}
for name in STANDARD_LINETYPES:
if name.lower() in existing:
results[name] = "already_loaded"
continue
try:
await backend.linetype_load(name)
results[name] = "loaded"
except Exception as exc:
results[name] = f"failed: {exc}"
return results
async def ensure_engineering_layers(backend: "AutoCADBackend") -> dict[str, str]:
"""Idempotently create every layer in ENGINEERING_LAYERS via backend.layer_create."""
await ensure_standard_linetypes(backend)
try:
existing = {lyr.name.lower() for lyr in await backend.layer_list()}
except Exception as exc:
log.warning("layer_list failed; assuming empty: %s", exc)
existing = set()
results: dict[str, str] = {}
for name, color, linetype, lineweight, _desc in ENGINEERING_LAYERS:
if name.lower() in existing:
results[name] = "exists"
continue
try:
await backend.layer_create(
name=name, color=color, linetype=linetype, lineweight=lineweight,
)
results[name] = "created"
except Exception as exc:
results[name] = f"failed: {exc}"
return results
+82
View File
@@ -0,0 +1,82 @@
"""Generic cutting-plane and section-hatch helpers."""
from __future__ import annotations
import math
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import AutoCADBackend
async def cutting_plane_line(
backend: "AutoCADBackend",
*,
p1: tuple[float, float],
p2: tuple[float, float],
label: str = "A",
layer: str = "PHANTOM",
) -> dict:
"""Draw a section line, arrowheads at each end, and 'X-X' labels."""
x1, y1 = float(p1[0]), float(p1[1])
x2, y2 = float(p2[0]), float(p2[1])
line = await backend.entity_create_line(x1, y1, x2, y2, layer=layer)
dx, dy = x2 - x1, y2 - y1
length = math.hypot(dx, dy) or 1.0
ux, uy = dx / length, dy / length
nx, ny = -uy, ux
arrow_len = max(length * 0.05, 4.0)
arrow_w = arrow_len * 0.4
arrows: list[str] = []
for tip_x, tip_y, dir_x, dir_y in (
(x1, y1, -ux, -uy),
(x2, y2, ux, uy),
):
bx = tip_x + dir_x * arrow_len
by = tip_y + dir_y * arrow_len
wing1_x = bx + nx * arrow_w
wing1_y = by + ny * arrow_w
wing2_x = bx - nx * arrow_w
wing2_y = by - ny * arrow_w
a1 = await backend.entity_create_line(tip_x, tip_y, wing1_x, wing1_y, layer=layer)
a2 = await backend.entity_create_line(tip_x, tip_y, wing2_x, wing2_y, layer=layer)
arrows.extend([a1.handle, a2.handle])
text = f"{label}-{label}"
text_h = max(arrow_len, 3.5)
offset = arrow_len * 1.5
labels: list[str] = []
for tip_x, tip_y, dir_x, dir_y in (
(x1, y1, -ux, -uy),
(x2, y2, ux, uy),
):
tx = tip_x + dir_x * offset + nx * offset * 0.5
ty = tip_y + dir_y * offset + ny * offset * 0.5
t = await backend.entity_create_text(text, tx, ty, height=text_h, layer="TEXT")
labels.append(t.handle)
return {"line": line.handle, "arrows": arrows, "labels": labels}
async def apply_section_hatch(
backend: "AutoCADBackend",
*,
boundary_points: list[list[float]],
pattern: str = "ANSI31",
angle: float = 45.0,
scale: float = 1.0,
layer: str = "HATCH",
) -> str:
"""Wrapper around backend.entity_create_hatch returning the new handle."""
info = await backend.entity_create_hatch(
pattern=pattern,
boundary_points=boundary_points,
scale=scale,
angle=angle,
layer=layer,
)
return info.handle
+254
View File
@@ -0,0 +1,254 @@
"""ISO 7200 / A3 (420 x 297 mm) title block generator.
Deterministic — the title text is written verbatim from metadata.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import AutoCADBackend
# A3 paper, ISO 7200 layout constants (mm)
SHEET_W = 420.0
SHEET_H = 297.0
LEFT_MARGIN = 20.0
RIGHT_MARGIN = 10.0
TOP_MARGIN = 10.0
BOTTOM_MARGIN = 10.0
TB_WIDTH = 180.0
TB_HEIGHT = 60.0
ROW1_H = 20.0 # title
ROW2_H = 15.0 # drawing_no | revision | sheet
ROW3_H = 15.0 # part_no | material | scale
ROW4_H = 10.0 # drawn_by | checked_by | date | company
LABEL_HEIGHT = 2.0
VALUE_HEIGHT = 3.5
TITLE_HEIGHT = 5.0
LAYER_BORDER = "TITLEBLOCK"
LAYER_TEXT = "TEXT"
@dataclass
class TitleBlockMetadata:
"""ISO 7200 title block fields (all strings — no LLM transformation)."""
title: str
drawing_no: str
part_no: str = ""
material: str = ""
scale: str = "1:1"
units: str = "mm"
drawn_by: str = ""
checked_by: str = ""
date: str = ""
sheet: str = "1/1"
revision: str = "A"
company: str = "Anka-Makine"
async def apply_iso_a3_titleblock(
backend: "AutoCADBackend",
*,
metadata: TitleBlockMetadata,
origin: tuple[float, float] = (0.0, 0.0),
) -> dict:
"""Draw a 420x297 ISO 7200 title block at `origin` (lower-left of sheet)."""
ox, oy = float(origin[0]), float(origin[1])
# ---- outer + inner borders -------------------------------------------------
outer_pts = [
[ox, oy],
[ox + SHEET_W, oy],
[ox + SHEET_W, oy + SHEET_H],
[ox, oy + SHEET_H],
]
outer = await backend.entity_create_polyline(
points=outer_pts, closed=True, layer=LAYER_BORDER,
)
inner_x0 = ox + LEFT_MARGIN
inner_y0 = oy + BOTTOM_MARGIN
inner_x1 = ox + SHEET_W - RIGHT_MARGIN
inner_y1 = oy + SHEET_H - TOP_MARGIN
inner_pts = [
[inner_x0, inner_y0],
[inner_x1, inner_y0],
[inner_x1, inner_y1],
[inner_x0, inner_y1],
]
inner = await backend.entity_create_polyline(
points=inner_pts, closed=True, layer=LAYER_BORDER,
)
# ---- title block (lower-right corner of inner border) ----------------------
tb_x0 = inner_x1 - TB_WIDTH
tb_y0 = inner_y0
tb_x1 = inner_x1
tb_y1 = inner_y0 + TB_HEIGHT
# Title block frame (closed polyline)
tb_frame_pts = [
[tb_x0, tb_y0],
[tb_x1, tb_y0],
[tb_x1, tb_y1],
[tb_x0, tb_y1],
]
tb_frame = await backend.entity_create_polyline(
points=tb_frame_pts, closed=True, layer=LAYER_BORDER,
)
# Row baselines (from bottom up): row4_top, row3_top, row2_top
row4_top = tb_y0 + ROW4_H
row3_top = row4_top + ROW3_H
row2_top = row3_top + ROW2_H # == tb_y1 - ROW1_H
titleblock_lines: list[str] = [tb_frame.handle]
async def hline(y: float) -> str:
ent = await backend.entity_create_line(
tb_x0, y, tb_x1, y, layer=LAYER_BORDER,
)
return ent.handle
async def vline(x: float, y0: float, y1: float) -> str:
ent = await backend.entity_create_line(
x, y0, x, y1, layer=LAYER_BORDER,
)
return ent.handle
titleblock_lines.append(await hline(row4_top))
titleblock_lines.append(await hline(row3_top))
titleblock_lines.append(await hline(row2_top))
# Row 2 splits: left half = drawing_no, then quarter rev, quarter sheet
r2_split1 = tb_x0 + TB_WIDTH * 0.5
r2_split2 = tb_x0 + TB_WIDTH * 0.75
titleblock_lines.append(await vline(r2_split1, row3_top, row2_top))
titleblock_lines.append(await vline(r2_split2, row3_top, row2_top))
# Row 3 splits: thirds — part_no | material | scale
r3_split1 = tb_x0 + TB_WIDTH / 3.0
r3_split2 = tb_x0 + TB_WIDTH * 2.0 / 3.0
titleblock_lines.append(await vline(r3_split1, row4_top, row3_top))
titleblock_lines.append(await vline(r3_split2, row4_top, row3_top))
# Row 4 splits: quarters — drawn_by | checked_by | date | company
r4_split1 = tb_x0 + TB_WIDTH * 0.25
r4_split2 = tb_x0 + TB_WIDTH * 0.50
r4_split3 = tb_x0 + TB_WIDTH * 0.75
titleblock_lines.append(await vline(r4_split1, tb_y0, row4_top))
titleblock_lines.append(await vline(r4_split2, tb_y0, row4_top))
titleblock_lines.append(await vline(r4_split3, tb_y0, row4_top))
# ---- text content ----------------------------------------------------------
label_pad_x = 1.5
label_pad_y = 1.5
value_pad_x = 3.0
value_pad_y = 5.0
async def label(text: str, x: float, y: float) -> str:
ent = await backend.entity_create_text(
text=text, x=x, y=y, height=LABEL_HEIGHT, layer=LAYER_TEXT,
)
return ent.handle
async def value(text: str, x: float, y: float, height: float = VALUE_HEIGHT) -> str:
ent = await backend.entity_create_text(
text=text, x=x, y=y, height=height, layer=LAYER_TEXT,
)
return ent.handle
# Row 1 — TITLE (verbatim)
title_x = tb_x0 + TB_WIDTH * 0.5 - (len(metadata.title) * TITLE_HEIGHT * 0.3)
title_y = row2_top + (ROW1_H - TITLE_HEIGHT) / 2.0
title_text_handle = await value(metadata.title, title_x, title_y, height=TITLE_HEIGHT)
value_texts: dict[str, str] = {}
# Row 2: drawing_no (left half) | revision (3rd qtr) | sheet (4th qtr)
await label("DWG NO", tb_x0 + label_pad_x, row2_top - LABEL_HEIGHT - label_pad_y)
value_texts["drawing_no"] = await value(
metadata.drawing_no,
tb_x0 + value_pad_x,
row3_top + value_pad_y,
)
await label("REV", r2_split1 + label_pad_x, row2_top - LABEL_HEIGHT - label_pad_y)
value_texts["revision"] = await value(
metadata.revision,
r2_split1 + value_pad_x,
row3_top + value_pad_y,
)
await label("SHEET", r2_split2 + label_pad_x, row2_top - LABEL_HEIGHT - label_pad_y)
value_texts["sheet"] = await value(
metadata.sheet,
r2_split2 + value_pad_x,
row3_top + value_pad_y,
)
# Row 3: part_no | material | scale
await label("PART NO", tb_x0 + label_pad_x, row3_top - LABEL_HEIGHT - label_pad_y)
value_texts["part_no"] = await value(
metadata.part_no,
tb_x0 + value_pad_x,
row4_top + value_pad_y,
)
await label("MATERIAL", r3_split1 + label_pad_x, row3_top - LABEL_HEIGHT - label_pad_y)
value_texts["material"] = await value(
metadata.material,
r3_split1 + value_pad_x,
row4_top + value_pad_y,
)
await label("SCALE", r3_split2 + label_pad_x, row3_top - LABEL_HEIGHT - label_pad_y)
value_texts["scale"] = await value(
metadata.scale,
r3_split2 + value_pad_x,
row4_top + value_pad_y,
)
# Row 4: drawn_by | checked_by | date | company
await label("DRAWN", tb_x0 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y)
value_texts["drawn_by"] = await value(
metadata.drawn_by,
tb_x0 + value_pad_x,
tb_y0 + 1.5,
)
await label("CHECKED", r4_split1 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y)
value_texts["checked_by"] = await value(
metadata.checked_by,
r4_split1 + value_pad_x,
tb_y0 + 1.5,
)
await label("DATE", r4_split2 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y)
value_texts["date"] = await value(
metadata.date,
r4_split2 + value_pad_x,
tb_y0 + 1.5,
)
await label("COMPANY", r4_split3 + label_pad_x, row4_top - LABEL_HEIGHT - label_pad_y)
value_texts["company"] = await value(
metadata.company,
r4_split3 + value_pad_x,
tb_y0 + 1.5,
)
return {
"outer_border": outer.handle,
"inner_border": inner.handle,
"titleblock_lines": titleblock_lines,
"title_text": title_text_handle,
"value_texts": value_texts,
"metadata": asdict(metadata),
"bbox": {
"min": [ox, oy],
"max": [ox + SHEET_W, oy + SHEET_H],
},
}
+240
View File
@@ -0,0 +1,240 @@
"""8-step pre-completion validator for engineering drawings."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backends.base import AutoCADBackend
REAL_DIMENSION_TYPES = {
"DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR",
"DIMRADIUS", "DIMDIAMETER", "DIMORDINATE",
}
# Patterns that suggest a TEXT/MTEXT entity is masquerading as a dimension.
DIMENSION_LIKE_TEXT = re.compile(
r"(Ø|⌀|∅|%%c|%%C|R\d|±|%%p|\d+\s*[×x]\s*\d+|\d+\.\d+\s*mm)",
re.IGNORECASE,
)
@dataclass
class ValidationFinding:
"""One issue surfaced by the validator."""
severity: str # "error" | "warning" | "info"
code: str
message: str
detail: dict = field(default_factory=dict)
@dataclass
class ValidationResult:
"""Aggregate of validator findings."""
ok: bool
findings: list[ValidationFinding]
summary: dict
def to_dict(self) -> dict:
return {
"ok": self.ok,
"summary": self.summary,
"findings": [
{
"severity": f.severity,
"code": f.code,
"message": f.message,
"detail": f.detail,
}
for f in self.findings
],
}
class DrawingValidator:
"""8-step pre-completion validator."""
async def run(
self,
backend: "AutoCADBackend",
*,
expected: dict | None = None,
) -> ValidationResult:
"""Run all 8 checks; return aggregate ValidationResult."""
findings: list[ValidationFinding] = []
expected = expected or {}
ents: list = []
# 1 — fake dimensions
try:
ents = await backend.entity_list(limit=10000)
text_likes = [
e for e in ents
if e.type in ("TEXT", "MTEXT")
and DIMENSION_LIKE_TEXT.search(
getattr(e, "properties", {}).get("text", "") or ""
)
]
real_dims = [e for e in ents if e.type in REAL_DIMENSION_TYPES]
if text_likes:
findings.append(ValidationFinding(
"warning",
"fake_dimension_text",
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),
},
))
except Exception as exc:
findings.append(ValidationFinding(
"warning", "dim_check_failed", str(exc),
))
# 2 — hidden orphans
try:
hidden = [e for e in ents if not e.visible]
if hidden:
findings.append(ValidationFinding(
"error",
"hidden_orphans",
f"{len(hidden)} entities have visible=False — hide-fallback "
"is forbidden, use entity_delete",
{"sample_handles": [h.handle for h in hidden[:5]]},
))
except Exception:
pass
# 3 — linetypes
try:
lts = {x.lower() for x in await backend.linetype_list()}
missing = [lt for lt in ("center", "hidden", "phantom") if lt not in lts]
if missing:
findings.append(ValidationFinding(
"error",
"linetypes_missing",
f"Standard linetypes not loaded: {', '.join(missing).upper()}",
{"missing": missing},
))
except Exception as exc:
findings.append(ValidationFinding(
"warning", "linetype_check_failed", str(exc),
))
# 4 — gear views (heuristic, only if expected demands)
if expected.get("must_have_bore") or expected.get("must_have_keyway"):
bore_circles = [
e for e in ents
if e.type == "CIRCLE" and e.layer == "GEOMETRY"
]
if expected.get("must_have_bore") and len(bore_circles) < 1:
findings.append(ValidationFinding(
"error",
"bore_missing",
"Expected bore but no circle on GEOMETRY layer found",
))
keyway_polys = [
e for e in ents
if e.type in ("LWPOLYLINE", "POLYLINE")
and e.layer == "GEOMETRY"
and len(e.properties.get("points", [])) <= 6
and e.properties.get("closed")
]
if expected.get("must_have_keyway") and len(keyway_polys) < 1:
findings.append(ValidationFinding(
"warning",
"keyway_section_unverified",
"Could not confirm keyway in section view via heuristic "
"(small closed polyline on GEOMETRY)",
))
# 5 — title consistency
helix = expected.get("helix_angle")
if helix:
title_texts = [
(e.properties.get("text", "") or "").upper()
for e in ents
if e.type in ("TEXT", "MTEXT") and e.layer == "TEXT"
]
joined = " | ".join(title_texts)
if "HELICAL SPUR" in joined:
findings.append(ValidationFinding(
"error",
"title_helical_spur",
"Title contains 'HELICAL SPUR GEAR' — invalid; use "
"'HELICAL GEAR' or 'SPUR GEAR'",
))
elif "SPUR" in joined and "HELICAL" not in joined:
findings.append(ValidationFinding(
"error",
"title_says_spur_for_helical",
f"Title contains SPUR but part is helical (helix_angle={helix})",
{"sample": joined[:200]},
))
# 6 — saved to disk
try:
info = await backend.drawing_info()
saved_attr = getattr(info, "saved", None)
full_path = getattr(info, "full_path", "") or ""
if not saved_attr:
findings.append(ValidationFinding(
"error",
"not_saved",
"Drawing has unsaved changes — call drawing_save before finalize",
{"full_path": full_path},
))
elif full_path and not Path(full_path).exists():
findings.append(ValidationFinding(
"error",
"file_missing",
f"Drawing reports saved=True but file not on disk: {full_path}",
))
except Exception as exc:
findings.append(ValidationFinding(
"warning", "save_check_failed", str(exc),
))
# 7 — screenshot
try:
shot = await backend.view_screenshot()
if not shot:
findings.append(ValidationFinding(
"info",
"screenshot_unavailable",
"Screenshot returned no bytes (ezdxf needs matplotlib install)",
))
except Exception as exc:
findings.append(ValidationFinding(
"info", "screenshot_failed", str(exc),
))
# 8 — dwg path returned
try:
info = await backend.drawing_info()
if not getattr(info, "full_path", ""):
findings.append(ValidationFinding(
"warning",
"no_path",
"drawing_info.full_path is empty",
))
except Exception:
pass
summary = {
sev: sum(1 for f in findings if f.severity == sev)
for sev in ("error", "warning", "info")
}
return ValidationResult(
ok=summary["error"] == 0,
findings=findings,
summary=summary,
)