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:
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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],
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user