diff --git a/backends/base.py b/backends/base.py index 4dbfa33..0d5a63a 100644 --- a/backends/base.py +++ b/backends/base.py @@ -26,9 +26,9 @@ if TYPE_CHECKING: @dataclass class EntityInfo: handle: str - type: str # e.g. "LINE", "CIRCLE", "ARC" + type: str # e.g. "LINE", "CIRCLE", "ARC" layer: str - color: int # ACI color (256=ByLayer) + color: int # ACI color (256=ByLayer) linetype: str visible: bool properties: dict = field(default_factory=dict) @@ -121,12 +121,12 @@ def normalize_lineweight(lw: float | int | None) -> int | None: except (TypeError, ValueError): return lw # leave exotic / already-int values untouched if v < 0: - return int(round(v)) # -1/-2/-3 sentinels + return int(round(v)) # -1/-2/-3 sentinels if v == 0: return 0 if v <= 2.05: - return int(round(v * 100.0)) # millimetres -> hundredths - return int(round(v)) # already hundredths + return int(round(v * 100.0)) # millimetres -> hundredths + return int(round(v)) # already hundredths # --------------------------------------------------------------------------- @@ -175,11 +175,87 @@ class AutoCADBackend(ABC): async def drawing_export_dxf(self, path: str) -> dict: ... @abstractmethod - async def drawing_export_pdf(self, path: str) -> dict: ... + async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: ... @abstractmethod async def drawing_purge(self) -> dict: ... + # ── layouts / paper space ──────────────────────────────────────────────── + @abstractmethod + async def layout_list(self) -> dict: + """Return {ok, layouts: [names], current: name} incl. 'Model'.""" + ... + + @abstractmethod + async def layout_create(self, name: str) -> dict: + """Create a new paper-space layout.""" + ... + + @abstractmethod + async def layout_set_current(self, name: str) -> dict: + """Activate a layout tab ('Model' or a paper-space layout name).""" + ... + + @abstractmethod + async def viewport_create( + self, + layout: str, + center_x: float, + center_y: float, + width: float, + height: float, + view_center_x: float, + view_center_y: float, + scale: float = 1.0, + ) -> dict: + """Place a scaled model-space viewport on a paper-space layout. + + ``scale`` is paper:model (1.0 = 1:1, 0.5 = 1:2). The viewport shows + the model window centered at (view_center_x, view_center_y) with + view height = height / scale. + """ + ... + + # ── 3D solids (opt-in; native on live AutoCAD only) ───────────────────── + @abstractmethod + async def solid_box( + self, cx: float, cy: float, cz: float, length: float, width: float, height: float + ) -> dict: + """Create a 3D solid box centered at (cx, cy, cz).""" + ... + + @abstractmethod + async def solid_cylinder( + self, cx: float, cy: float, cz: float, radius: float, height: float + ) -> dict: + """Create a 3D solid cylinder centered at (cx, cy, cz).""" + ... + + @abstractmethod + async def solid_extrude( + self, profile_handle: str, height: float, taper_angle: float = 0.0 + ) -> dict: + """Extrude a closed profile (circle/closed polyline) into a solid.""" + ... + + @abstractmethod + async def solid_revolve( + self, + profile_handle: str, + axis_x1: float, + axis_y1: float, + axis_x2: float, + axis_y2: float, + angle: float = 360.0, + ) -> dict: + """Revolve a closed profile around an axis into a solid.""" + ... + + @abstractmethod + async def solid_boolean(self, target_handle: str, tool_handle: str, operation: str) -> dict: + """Boolean two solids: operation is union | subtract | intersect.""" + ... + @abstractmethod async def drawing_audit(self) -> dict: ... @@ -195,43 +271,72 @@ class AutoCADBackend(ABC): # ── entity creation ────────────────────────────────────────────────────── @abstractmethod async def entity_create_line( - self, x1: float, y1: float, x2: float, y2: float, - z1: float = 0.0, z2: float = 0.0, - layer: str | None = None, color: int | None = None, + self, + x1: float, + y1: float, + x2: float, + y2: float, + z1: float = 0.0, + z2: float = 0.0, + layer: str | None = None, + color: int | None = None, linetype: str | None = None, ) -> EntityInfo: ... @abstractmethod async def entity_create_circle( - self, cx: float, cy: float, radius: float, - layer: str | None = None, color: int | None = None, + self, + cx: float, + cy: float, + radius: float, + layer: str | None = None, + color: int | None = None, ) -> EntityInfo: ... @abstractmethod async def entity_create_arc( - self, cx: float, cy: float, radius: float, - start_angle: float, end_angle: float, - layer: str | None = None, color: int | None = None, + self, + cx: float, + cy: float, + radius: float, + start_angle: float, + end_angle: float, + layer: str | None = None, + color: int | None = None, ) -> EntityInfo: ... @abstractmethod async def entity_create_polyline( - self, points: list[list[float]], closed: bool = False, - layer: str | None = None, color: int | None = None, + self, + points: list[list[float]], + closed: bool = False, + layer: str | None = None, + color: int | None = None, ) -> EntityInfo: ... @abstractmethod async def entity_create_text( - self, text: str, x: float, y: float, - height: float = 2.5, rotation: float = 0.0, - layer: str | None = None, color: int | None = None, + self, + text: str, + x: float, + y: float, + height: float = 2.5, + rotation: float = 0.0, + layer: str | None = None, + color: int | None = None, ) -> EntityInfo: ... @abstractmethod async def entity_create_mtext( - self, text: str, x: float, y: float, - width: float = 100.0, height: float = 2.5, rotation: float = 0.0, - layer: str | None = None, color: int | None = None, + self, + text: str, + x: float, + y: float, + width: float = 100.0, + height: float = 2.5, + rotation: float = 0.0, + layer: str | None = None, + color: int | None = None, ) -> EntityInfo: ... @abstractmethod @@ -261,42 +366,67 @@ class AutoCADBackend(ABC): @abstractmethod async def entity_create_hatch( - self, pattern: str, boundary_points: list[list[float]], - scale: float = 1.0, angle: float = 0.0, - layer: str | None = None, color: int | None = None, + self, + pattern: str, + boundary_points: list[list[float]], + scale: float = 1.0, + angle: float = 0.0, + layer: str | None = None, + color: int | None = None, ) -> EntityInfo: ... @abstractmethod async def entity_create_spline( - self, fit_points: list[list[float]], - layer: str | None = None, color: int | None = None, + self, + fit_points: list[list[float]], + layer: str | None = None, + color: int | None = None, ) -> EntityInfo: ... @abstractmethod async def entity_create_ellipse( - self, cx: float, cy: float, - major_x: float, major_y: float, ratio: float = 0.5, - layer: str | None = None, color: int | None = None, + self, + cx: float, + cy: float, + major_x: float, + major_y: float, + ratio: float = 0.5, + layer: str | None = None, + color: int | None = None, ) -> EntityInfo: ... @abstractmethod async def entity_create_point( - self, x: float, y: float, - layer: str | None = None, color: int | None = None, + self, + x: float, + y: float, + layer: str | None = None, + color: int | None = None, ) -> EntityInfo: ... @abstractmethod async def entity_create_block_ref( - self, name: str, x: float, y: float, - scale_x: float = 1.0, scale_y: float = 1.0, rotation: float = 0.0, + self, + name: str, + x: float, + y: float, + scale_x: float = 1.0, + scale_y: float = 1.0, + rotation: float = 0.0, layer: str | None = None, ) -> EntityInfo: ... # ── dimensions ─────────────────────────────────────────────────────────── @abstractmethod async def dimension_linear( - self, x1: float, y1: float, x2: float, y2: float, - dim_x: float, dim_y: float, rotation: float = 0.0, + self, + x1: float, + y1: float, + x2: float, + y2: float, + dim_x: float, + dim_y: float, + rotation: float = 0.0, layer: str | None = None, tol_upper: float | None = None, tol_lower: float | None = None, @@ -306,23 +436,39 @@ class AutoCADBackend(ABC): @abstractmethod async def dimension_aligned( - self, x1: float, y1: float, x2: float, y2: float, - dim_x: float, dim_y: float, + self, + x1: float, + y1: float, + x2: float, + y2: float, + dim_x: float, + dim_y: float, layer: str | None = None, ) -> EntityInfo: ... @abstractmethod async def dimension_angular( - self, vx: float, vy: float, - x1: float, y1: float, x2: float, y2: float, - tx: float, ty: float, + self, + vx: float, + vy: float, + x1: float, + y1: float, + x2: float, + y2: float, + tx: float, + ty: float, layer: str | None = None, ) -> EntityInfo: ... @abstractmethod async def dimension_radius( - self, cx: float, cy: float, chord_x: float, chord_y: float, - leader_length: float = 10.0, layer: str | None = None, + self, + cx: float, + cy: float, + chord_x: float, + chord_y: float, + leader_length: float = 10.0, + layer: str | None = None, tol_upper: float | None = None, tol_lower: float | None = None, tol_mode: str = "none", @@ -331,8 +477,13 @@ class AutoCADBackend(ABC): @abstractmethod async def dimension_diameter( - self, x1: float, y1: float, x2: float, y2: float, - leader_length: float = 10.0, layer: str | None = None, + self, + x1: float, + y1: float, + x2: float, + y2: float, + leader_length: float = 10.0, + layer: str | None = None, tol_upper: float | None = None, tol_lower: float | None = None, tol_mode: str = "none", @@ -344,29 +495,46 @@ class AutoCADBackend(ABC): async def entity_move(self, handle: str, dx: float, dy: float, dz: float = 0.0) -> dict: ... @abstractmethod - async def entity_copy(self, handle: str, dx: float, dy: float, dz: float = 0.0) -> EntityInfo: ... + async def entity_copy( + self, handle: str, dx: float, dy: float, dz: float = 0.0 + ) -> EntityInfo: ... @abstractmethod async def entity_rotate( - self, handle: str, base_x: float, base_y: float, angle_deg: float, + self, + handle: str, + base_x: float, + base_y: float, + angle_deg: float, ) -> dict: ... @abstractmethod async def entity_scale( - self, handle: str, base_x: float, base_y: float, factor: float, + self, + handle: str, + base_x: float, + base_y: float, + factor: float, ) -> dict: ... @abstractmethod async def entity_mirror( - self, handle: str, - x1: float, y1: float, x2: float, y2: float, + self, + handle: str, + x1: float, + y1: float, + x2: float, + y2: float, delete_original: bool = False, ) -> EntityInfo: ... @abstractmethod async def entity_offset( - self, handle: str, distance: float, - side_x: float | None = None, side_y: float | None = None, + self, + handle: str, + distance: float, + side_x: float | None = None, + side_y: float | None = None, ) -> EntityInfo: ... @abstractmethod @@ -374,22 +542,32 @@ class AutoCADBackend(ABC): @abstractmethod async def entity_array_rectangular( - self, handle: str, - rows: int, cols: int, row_spacing: float, col_spacing: float, + self, + handle: str, + rows: int, + cols: int, + row_spacing: float, + col_spacing: float, ) -> list[EntityInfo]: ... @abstractmethod async def entity_array_polar( - self, handle: str, - count: int, fill_angle: float, - center_x: float, center_y: float, + self, + handle: str, + count: int, + fill_angle: float, + center_x: float, + center_y: float, ) -> list[EntityInfo]: ... # ── corner operations (trim/extend/fillet/chamfer) ─────────────────────── @abstractmethod async def entity_trim( - self, target_handle: str, cutter_handle: str, - keep_x: float, keep_y: float, + self, + target_handle: str, + cutter_handle: str, + keep_x: float, + keep_y: float, ) -> EntityInfo: """Trim `target` against `cutter`; keep the segment containing (keep_x, keep_y). Raises ToolError if no intersection.""" @@ -397,8 +575,11 @@ class AutoCADBackend(ABC): @abstractmethod async def entity_extend( - self, target_handle: str, boundary_handle: str, - end_x: float | None = None, end_y: float | None = None, + self, + target_handle: str, + boundary_handle: str, + end_x: float | None = None, + end_y: float | None = None, ) -> EntityInfo: """Extend `target` to meet `boundary`. If `end_x/y` is None, the target endpoint nearest the boundary is auto-selected.""" @@ -406,7 +587,10 @@ class AutoCADBackend(ABC): @abstractmethod async def entity_fillet( - self, handle1: str, handle2: str, radius: float, + self, + handle1: str, + handle2: str, + radius: float, trim: bool = True, ) -> EntityInfo: """Fillet two entities with the given radius. Returns the new ARC. @@ -416,8 +600,11 @@ class AutoCADBackend(ABC): @abstractmethod async def entity_chamfer( - self, handle1: str, handle2: str, - dist1: float, dist2: float | None = None, + self, + handle1: str, + handle2: str, + dist1: float, + dist2: float | None = None, trim: bool = True, ) -> EntityInfo: """Chamfer two entities. When `dist2` is None it defaults to `dist1` @@ -430,7 +617,8 @@ class AutoCADBackend(ABC): @abstractmethod async def entity_set_properties( - self, handle: str, + self, + handle: str, layer: str | None = None, color: int | None = None, linetype: str | None = None, @@ -440,7 +628,8 @@ class AutoCADBackend(ABC): @abstractmethod async def entity_edit_text( - self, handle: str, + self, + handle: str, text: str | None = None, height: float | None = None, rotation: float | None = None, @@ -452,12 +641,17 @@ class AutoCADBackend(ABC): @abstractmethod async def entity_edit_geometry( - self, handle: str, - cx: float | None = None, cy: float | None = None, + self, + handle: str, + cx: float | None = None, + cy: float | None = None, radius: float | None = None, - x1: float | None = None, y1: float | None = None, - x2: float | None = None, y2: float | None = None, - start_angle: float | None = None, end_angle: float | None = None, + x1: float | None = None, + y1: float | None = None, + x2: float | None = None, + y2: float | None = None, + start_angle: float | None = None, + end_angle: float | None = None, ) -> EntityInfo: """Edit the defining geometry of an existing entity in place. - CIRCLE: cx / cy / radius @@ -481,7 +675,8 @@ class AutoCADBackend(ABC): @abstractmethod async def layer_create( - self, name: str, + self, + name: str, color: int = 7, linetype: str = "Continuous", lineweight: float = -3, @@ -495,7 +690,8 @@ class AutoCADBackend(ABC): @abstractmethod async def layer_modify( - self, name: str, + self, + name: str, color: int | None = None, linetype: str | None = None, lineweight: float | None = None, @@ -525,7 +721,9 @@ class AutoCADBackend(ABC): @abstractmethod async def linetype_load( - self, name: str, file: str | None = None, + self, + name: str, + file: str | None = None, ) -> dict: ... # ── block operations ───────────────────────────────────────────────────── @@ -534,8 +732,13 @@ class AutoCADBackend(ABC): @abstractmethod async def block_insert( - self, name: str, x: float, y: float, - scale_x: float = 1.0, scale_y: float = 1.0, rotation: float = 0.0, + self, + name: str, + x: float, + y: float, + scale_x: float = 1.0, + scale_y: float = 1.0, + rotation: float = 0.0, attributes: dict | None = None, layer: str | None = None, ) -> EntityInfo: ... @@ -551,8 +754,11 @@ class AutoCADBackend(ABC): @abstractmethod async def block_create_from_entities( - self, name: str, handles: list[str], - base_x: float = 0.0, base_y: float = 0.0, + self, + name: str, + handles: list[str], + base_x: float = 0.0, + base_y: float = 0.0, ) -> dict: ... # ── analysis / query ───────────────────────────────────────────────────── @@ -561,12 +767,20 @@ class AutoCADBackend(ABC): @abstractmethod async def analysis_entities_in_region( - self, x1: float, y1: float, x2: float, y2: float, + self, + x1: float, + y1: float, + x2: float, + y2: float, ) -> list[EntityInfo]: ... @abstractmethod async def analysis_measure_distance( - self, x1: float, y1: float, x2: float, y2: float, + self, + x1: float, + y1: float, + x2: float, + y2: float, ) -> float: ... @abstractmethod @@ -607,7 +821,11 @@ class AutoCADBackend(ABC): @abstractmethod async def view_zoom_window( - self, x1: float, y1: float, x2: float, y2: float, + self, + x1: float, + y1: float, + x2: float, + y2: float, ) -> dict: ... @abstractmethod @@ -690,7 +908,8 @@ class AutoCADBackend(ABC): return result async def drawing_plan( - self, intent: str, + self, + intent: str, sheet_size: SheetSize = "A3", scale: float = 1.0, layer_set_id: LayerSetId = "mech", @@ -763,7 +982,8 @@ class AutoCADBackend(ABC): return plan async def drawing_critique( - self, focus: list[CritiqueFocus] | None = None, + self, + focus: list[CritiqueFocus] | None = None, ) -> list[Issue]: """Run premium-quality geometric checks; return zero issues before `drawing_finalize`. `focus=None` runs all checks. (N8: this does NOT @@ -781,8 +1001,11 @@ class AutoCADBackend(ABC): return plan.to_dict() if plan is not None else None async def point_from_snap( - self, handle: str, snap: SnapType, - ref_x: float | None = None, ref_y: float | None = None, + self, + handle: str, + snap: SnapType, + ref_x: float | None = None, + ref_y: float | None = None, ) -> tuple[float, float]: """Return a deterministic snap point on `handle`. snap ∈ {end, mid, center, quad, perp, near}. For `near/perp`, @@ -791,27 +1014,20 @@ class AutoCADBackend(ABC): ent = await self.entity_get(handle) et = ent.type p = ent.properties - ref = ( - (float(ref_x), float(ref_y)) - if ref_x is not None and ref_y is not None else None - ) + ref = (float(ref_x), float(ref_y)) if ref_x is not None and ref_y is not None else None def _line_pts(): s = p.get("start") e = p.get("end") if s is None or e is None: - raise RuntimeError( - f"point_from_snap: entity {handle} ({et}) has no start/end." - ) + raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no start/end.") return (float(s[0]), float(s[1])), (float(e[0]), float(e[1])) def _circle_geom(): c = p.get("center") r = p.get("radius") if c is None or r is None: - raise RuntimeError( - f"point_from_snap: entity {handle} ({et}) has no center/radius." - ) + raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no center/radius.") return float(c[0]), float(c[1]), float(r) def _arc_angles(): @@ -822,8 +1038,7 @@ class AutoCADBackend(ABC): ea = p.get("end_angle") if sa is None or ea is None: raise RuntimeError( - f"point_from_snap: entity {handle} ({et}) has no " - "start_angle/end_angle." + f"point_from_snap: entity {handle} ({et}) has no start_angle/end_angle." ) return math.radians(float(sa)), math.radians(float(ea)) @@ -864,9 +1079,7 @@ class AutoCADBackend(ABC): if et in ("CIRCLE", "ARC", "ELLIPSE"): c = p.get("center") if c is None: - raise RuntimeError( - f"point_from_snap: entity {handle} ({et}) has no center." - ) + raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no center.") return (float(c[0]), float(c[1])) raise RuntimeError(f"point_from_snap: 'center' snap not supported on {et}") @@ -1106,7 +1319,7 @@ class AutoCADBackend(ABC): # Tangent point T lies on the circle such that (T-F)⊥(T-C). # Solving cos(θ - α) = -r/d where α = atan2(C-F) gives the two # circle angles θ at which the tangent touches the perimeter. - alpha = math.atan2(dy, dx) # direction from F to C + alpha = math.atan2(dy, dx) # direction from F to C # NEW-base-1: clamp the acos argument to [-1, 1] so a from-point in the # narrow band [r-1e-9, r) (past the internal-point guard but with # |-r/d| marginally > 1 from float error) degenerates gracefully to the @@ -1127,7 +1340,12 @@ class AutoCADBackend(ABC): @abstractmethod async def _create_xline( - self, x: float, y: float, dx: float, dy: float, layer: str, + self, + x: float, + y: float, + dx: float, + dy: float, + layer: str, ) -> EntityInfo: """Create an infinite construction line (XLINE) through (x, y) with unit direction (dx, dy) on `layer`. Backend-specific: XLINE has no @@ -1162,10 +1380,14 @@ class AutoCADBackend(ABC): active layer set, so dims/scaffolding land on the right layer for iso13567 (M-DIMEN-T-N / M-CONST-E-N) not just mech/pid (DIM/CONSTRUCTION).""" from engineering.layers import resolve_role_layer + return resolve_role_layer(self._active_layer_set_id(), role) async def construction_xline( - self, x: float, y: float, angle_deg: float, + self, + x: float, + y: float, + angle_deg: float, layer: str | None = None, ) -> EntityInfo: """Create an infinite construction line (XLINE) on the active layer set's @@ -1174,11 +1396,16 @@ class AutoCADBackend(ABC): await self._ensure_layer(layer, color=250) ang = math.radians(float(angle_deg)) return await self._create_xline( - float(x), float(y), math.cos(ang), math.sin(ang), layer, + float(x), + float(y), + math.cos(ang), + math.sin(ang), + layer, ) async def construction_clear( - self, layer: str | None = None, + self, + layer: str | None = None, ) -> dict: """Delete every entity on the active layer set's construction layer. Must be called before `drawing_finalize`.""" @@ -1197,18 +1424,23 @@ class AutoCADBackend(ABC): return {"ok": True, "layer": layer, "deleted": deleted} async def drawing_apply_iso_layers( - self, standard: LayerSetId = "mech", + self, + standard: LayerSetId = "mech", ) -> dict: """Bootstrap a full ISO-conformant layer set with correct colors and lineweights. Idempotent.""" from engineering.layers import apply_layer_set + result = await apply_layer_set(self, standard) self._active_layer_set = standard return {"ok": True, "standard": standard, "layers": result} async def dimension_auto( - self, handles: list[str], style: DimStyle = "chain", - offset: float = 10.0, layer: str | None = None, + self, + handles: list[str], + style: DimStyle = "chain", + offset: float = 10.0, + layer: str | None = None, ) -> list[EntityInfo]: """Generate ISO 129 dimensions across `handles` in the chosen style (chain | baseline | ordinate). `offset` is the dimension-line @@ -1219,16 +1451,14 @@ class AutoCADBackend(ABC): dim_layer = layer or self._role_layer("dim") if style not in ("chain", "baseline", "ordinate"): raise RuntimeError( - f"dimension_auto: unknown style '{style}'. " - "Use 'chain', 'baseline', or 'ordinate'." + f"dimension_auto: unknown style '{style}'. Use 'chain', 'baseline', or 'ordinate'." ) segs: list[tuple[float, float, float, float]] = [] for h in handles: ent = await self.entity_get(h) if ent.type != "LINE": raise RuntimeError( - f"dimension_auto V1: only LINE entities supported " - f"(handle {h} is {ent.type})." + f"dimension_auto V1: only LINE entities supported (handle {h} is {ent.type})." ) s = ent.properties.get("start") e = ent.properties.get("end") @@ -1249,8 +1479,14 @@ class AutoCADBackend(ABC): L = math.hypot(dx, dy) or 1.0 nx, ny = -dy / L, dx / L dim = await self.dimension_linear( - sx, sy, ex, ey, mx + nx * off, my + ny * off, - rotation=math.degrees(math.atan2(dy, dx)), layer=dim_layer, + sx, + sy, + ex, + ey, + mx + nx * off, + my + ny * off, + rotation=math.degrees(math.atan2(dy, dx)), + layer=dim_layer, ) results.append(dim) @@ -1262,11 +1498,16 @@ class AutoCADBackend(ABC): base_rot = math.degrees(math.atan2(dy, dx)) for i, (_sx, _sy, ex, ey) in enumerate(segs): dim = await self.dimension_linear( - sx0, sy0, ex, ey, - sx0 - uy * (off + i * off), sy0 + ux * (off + i * off), + sx0, + sy0, + ex, + ey, + sx0 - uy * (off + i * off), + sy0 + ux * (off + i * off), # Dimension line must run parallel to the (possibly rotated) # baseline, not horizontal. - rotation=base_rot, layer=dim_layer, + rotation=base_rot, + layer=dim_layer, ) results.append(dim) @@ -1275,22 +1516,33 @@ class AutoCADBackend(ABC): rung = off + i * off # stagger per feature to avoid coincident dims if abs(ex - sx) > 1e-9: dim = await self.dimension_linear( - sx, sy, ex, sy, - sx + (ex - sx) / 2.0, max(sy, ey) + rung, layer=dim_layer, + sx, + sy, + ex, + sy, + sx + (ex - sx) / 2.0, + max(sy, ey) + rung, + layer=dim_layer, ) results.append(dim) if abs(ey - sy) > 1e-9: dim = await self.dimension_linear( - ex, sy, ex, ey, - max(sx, ex) + rung, (sy + ey) / 2.0, - rotation=90.0, layer=dim_layer, + ex, + sy, + ex, + ey, + max(sx, ex) + rung, + (sy + ey) / 2.0, + rotation=90.0, + layer=dim_layer, ) results.append(dim) return results async def entity_select_smart( - self, predicate: dict, + self, + predicate: dict, ) -> list[EntityInfo]: """Semantic entity selection. Predicate keys (all optional, AND-ed): - type: "LINE" | "CIRCLE" | ... @@ -1303,8 +1555,8 @@ class AutoCADBackend(ABC): raise RuntimeError("entity_select_smart: predicate must be a dict.") ptype = predicate.get("type") player = predicate.get("layer") - pnear = predicate.get("near") # [x, y, radius] - plen = predicate.get("length_range") # [min, max] + pnear = predicate.get("near") # [x, y, radius] + plen = predicate.get("length_range") # [min, max] pcolor = predicate.get("color") ents = await self.entity_list( @@ -1321,13 +1573,15 @@ class AutoCADBackend(ABC): nx, ny, nr = float(pnear[0]), float(pnear[1]), float(pnear[2]) except Exception: return False - pt = (e.properties.get("start") - or e.properties.get("center") - or e.properties.get("insertion") - or e.properties.get("insertion_point")) + pt = ( + e.properties.get("start") + or e.properties.get("center") + or e.properties.get("insertion") + or e.properties.get("insertion_point") + ) if pt is None: return False - if (float(pt[0]) - nx) ** 2 + (float(pt[1]) - ny) ** 2 > nr ** 2: + if (float(pt[0]) - nx) ** 2 + (float(pt[1]) - ny) ** 2 > nr**2: return False if plen is not None and e.type in ("LINE", "ARC"): try: @@ -1349,14 +1603,23 @@ class AutoCADBackend(ABC): # every referenced datum is actually established on the part. async def _place_boxed_text( - self, text: str, cx: float, cy: float, text_h: float, layer: str, + self, + text: str, + cx: float, + cy: float, + text_h: float, + layer: str, ) -> str: """Create a TEXT entity roughly centred on (cx, cy). Returns its handle. entity_create_text has no alignment arg, so approximate the centring with the same width heuristic the title block uses.""" approx_w = len(text) * text_h * 0.7 t = await self.entity_create_text( - text, cx - approx_w / 2.0, cy - text_h / 2.0, height=text_h, layer=layer, + text, + cx - approx_w / 2.0, + cy - text_h / 2.0, + height=text_h, + layer=layer, ) return t.handle @@ -1386,8 +1649,13 @@ class AutoCADBackend(ABC): critique focus. """ from engineering.gdt import fcf_compartments, fcf_layout + comps = fcf_compartments( - symbol, tolerance, datums, diameter=diameter, modifier=modifier, + symbol, + tolerance, + datums, + diameter=diameter, + modifier=modifier, ) layer = layer or self._role_layer("dim") await self._ensure_layer(layer, color=2) @@ -1396,7 +1664,9 @@ class AutoCADBackend(ABC): box = lay["box"] box_poly = await self.entity_create_polyline( - [[bx, by] for bx, by in box], closed=False, layer=layer, + [[bx, by] for bx, by in box], + closed=False, + layer=layer, ) handles.append(box_poly.handle) for (sx, sy), (ex, ey) in lay["dividers"]: @@ -1410,7 +1680,7 @@ class AutoCADBackend(ABC): if refs is None: refs = set() self._gdt_datums_referenced = refs - for d in (datums or []): + for d in datums or []: d = str(d).strip().upper() if d: refs.add(d) @@ -1437,6 +1707,7 @@ class AutoCADBackend(ABC): with its apex at (x, y). Records the datum letter so a feature control frame that references it passes the `gdt` critique focus.""" from engineering.gdt import datum_triangle, fcf_layout + letter = str(letter).strip().upper() if not letter: raise RuntimeError("draw_datum_feature: a datum letter is required.") @@ -1447,20 +1718,22 @@ class AutoCADBackend(ABC): tri = datum_triangle(float(x), float(y), size, down=True) tri_poly = await self.entity_create_polyline( - [[px, py] for px, py in tri], closed=True, layer=layer, + [[px, py] for px, py in tri], + closed=True, + layer=layer, ) handles.append(tri_poly.handle) box_h = size * 1.4 lay = fcf_layout([letter], float(x) - box_h / 2.0, float(y) - size - box_h, box_h) box_poly = await self.entity_create_polyline( - [[bx, by] for bx, by in lay["box"]], closed=False, layer=layer, + [[bx, by] for bx, by in lay["box"]], + closed=False, + layer=layer, ) handles.append(box_poly.handle) for text, tcx, tcy in lay["labels"]: - handles.append( - await self._place_boxed_text(text, tcx, tcy, lay["text_height"], layer) - ) + handles.append(await self._place_boxed_text(text, tcx, tcy, lay["text_height"], layer)) defined = getattr(self, "_gdt_datums_defined", None) if defined is None: @@ -1516,7 +1789,6 @@ class AutoCADBackend(ABC): return result - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -1538,11 +1810,22 @@ _SETTING_MAP: dict[str, tuple[str, str]] = { # INSUNITS code table (AutoCAD $INSUNITS): friendly name <-> integer code. _UNIT_TO_CODE: dict[str, int] = { - "unitless": 0, "inch": 1, "inches": 1, "in": 1, - "feet": 2, "ft": 2, "foot": 2, - "mm": 4, "millimeter": 4, "millimeters": 4, - "cm": 5, "centimeter": 5, "centimeters": 5, - "m": 6, "meter": 6, "meters": 6, + "unitless": 0, + "inch": 1, + "inches": 1, + "in": 1, + "feet": 2, + "ft": 2, + "foot": 2, + "mm": 4, + "millimeter": 4, + "millimeters": 4, + "cm": 5, + "centimeter": 5, + "centimeters": 5, + "m": 6, + "meter": 6, + "meters": 6, } _CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"} diff --git a/backends/com_backend.py b/backends/com_backend.py index b5ec8f4..7526f1b 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -48,6 +48,7 @@ if _WIN32_AVAILABLE: import win32con # noqa: F401 import win32gui import win32ui + _COM_IMPORTS_OK = True except ImportError: _COM_IMPORTS_OK = False @@ -56,6 +57,7 @@ else: try: from PIL import Image as PILImage + _PIL_OK = True except ImportError: _PIL_OK = False @@ -65,7 +67,7 @@ except ImportError: # COM thread-state (accessed ONLY from the COM executor thread) # --------------------------------------------------------------------------- -_COM_STATE: dict[str, Any] = {} # keys: "app" +_COM_STATE: dict[str, Any] = {} # keys: "app" def _com_init(): @@ -113,6 +115,13 @@ def _ai(values: list[int]): ) +def _solid_3d_capability() -> FeatureCapability: + """3D solids are opt-in (ENABLE_3D=true) even on the live COM backend.""" + if config.settings.enable_3d: + return FeatureCapability(True, "native") + return FeatureCapability(False, reason="opt_in_disabled:set ENABLE_3D=true") + + def _acad_app(): """Return (or lazily create) the AutoCAD Application COM object. Must only be called from the COM executor thread.""" @@ -126,8 +135,7 @@ def _acad_app(): _COM_STATE["app"].Visible = True except Exception as exc: raise RuntimeError( - f"Cannot connect to AutoCAD: {exc}. " - "Make sure AutoCAD is installed and running." + f"Cannot connect to AutoCAD: {exc}. Make sure AutoCAD is installed and running." ) from exc return _COM_STATE["app"] @@ -157,8 +165,7 @@ def _ensure_linetype_loaded(name: str) -> None: if not name or name.lower() in _BUILTIN_LINETYPES: return doc = _acad_doc() - existing = {doc.Linetypes.Item(i).Name.lower() - for i in range(doc.Linetypes.Count)} + existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)} if name.lower() in existing: return app = _acad_app() @@ -199,7 +206,7 @@ def _regen(): if _COM_STATE.get("batch_mode"): return try: - _acad_doc().Regen(0) # 0 = acActiveViewport + _acad_doc().Regen(0) # 0 = acActiveViewport except Exception as exc: log.debug("Regen failed: %s", exc) @@ -214,6 +221,7 @@ def _apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode="none", text_overri property degrades gracefully instead of failing the dimension. """ from engineering.tolerances import build_dim_override + try: override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override) except ValueError: @@ -227,15 +235,15 @@ def _apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode="none", text_overri return # acTolerance* enums: 0=None, 1=Symmetrical, 2=Deviation, 3=Limits, 4=Basic try: - if "dimgap" in override: # basic (boxed) dimension + if "dimgap" in override: # basic (boxed) dimension dim.ToleranceDisplay = 4 return - if override.get("dimlim"): # limit dimension + if override.get("dimlim"): # limit dimension dim.ToleranceDisplay = 3 elif override.get("dimtp") == override.get("dimtm"): - dim.ToleranceDisplay = 1 # symmetrical ± + dim.ToleranceDisplay = 1 # symmetrical ± else: - dim.ToleranceDisplay = 2 # deviation + dim.ToleranceDisplay = 2 # deviation if "dimtp" in override: dim.ToleranceUpperLimit = float(override["dimtp"]) if "dimtm" in override: @@ -282,7 +290,7 @@ def _entity_info(entity) -> EntityInfo: props["length"] = entity.Radius * _sweep elif obj_name in ("AcDbLWPolyline", "AcDb2dPolyline"): coords = list(entity.Coordinates) - pts = [[coords[i], coords[i+1]] for i in range(0, len(coords), 2)] + pts = [[coords[i], coords[i + 1]] for i in range(0, len(coords), 2)] props["points"] = pts props["closed"] = bool(entity.Closed) props["length"] = entity.Length @@ -345,6 +353,7 @@ def _layer_info(layer, current_layer_name: str) -> LayerInfo: # Screenshot helpers (COM thread only) # --------------------------------------------------------------------------- + def _find_autocad_hwnd() -> int | None: """Find the main AutoCAD window handle.""" if not _WIN32_AVAILABLE: @@ -396,6 +405,7 @@ def _capture_window(hwnd: int) -> bytes | None: # PW_RENDERFULLCONTENT = 2 (works for hardware-accelerated windows) import ctypes + ctypes.windll.user32.PrintWindow(hwnd, save_dc.GetSafeHdc(), 2) bmp_str = save_bmp.GetBitmapBits(True) @@ -457,17 +467,16 @@ class ComBackend(AutoCADBackend): "preflight": FeatureCapability(True, "shared"), "refiner": FeatureCapability(True, "shared"), "delivery": FeatureCapability(True, "shared"), - "paper_space": FeatureCapability(False, reason="planned_1.4"), - "solid_3d": FeatureCapability(False, reason="planned_1.5"), + "paper_space": FeatureCapability(True, "native"), + "viewport_render": FeatureCapability(True, "native"), + "solid_3d": _solid_3d_capability(), "lisp": FeatureCapability(True, "sanitized"), }, ) async def connect(self) -> None: if not _COM_IMPORTS_OK: - raise RuntimeError( - "pywin32 not available. Install with: pip install pywin32" - ) + raise RuntimeError("pywin32 not available. Install with: pip install pywin32") # Lazy connection: just set up the executor. AutoCAD is connected # on the first actual tool call so the server starts even if AutoCAD # is not open yet — it will be found as soon as the user opens it. @@ -521,9 +530,7 @@ class ComBackend(AutoCADBackend): raise RuntimeError("ComBackend not connected. Call connect() first.") loop = asyncio.get_running_loop() timeout = config.settings.com_call_timeout - future = loop.run_in_executor( - self._executor, lambda: func(*args, **kwargs) - ) + future = loop.run_in_executor(self._executor, lambda: func(*args, **kwargs)) try: if timeout > 0: return await asyncio.wait_for(future, timeout=timeout) @@ -559,8 +566,7 @@ class ComBackend(AutoCADBackend): _COM_STATE.pop("app", None) self._connected = False raise RuntimeError( - f"AutoCAD COM error ({hr:#010x}): " - f"{e.args[1] if len(e.args) > 1 else e}" + f"AutoCAD COM error ({hr:#010x}): {e.args[1] if len(e.args) > 1 else e}" ) from e # ── drawing management ──────────────────────────────────────────────────── @@ -605,6 +611,7 @@ class ComBackend(AutoCADBackend): version=app.Version, backend="com", ) + return await self._run(_sync) async def drawing_new(self, template: str | None = None) -> dict: @@ -615,6 +622,7 @@ class ComBackend(AutoCADBackend): else: doc = app.Documents.Add() return {"ok": True, "name": doc.Name} + result = await self._run(_sync) await self._ensure_document_state() return result @@ -624,6 +632,7 @@ class ComBackend(AutoCADBackend): app = _acad_app() doc = app.Documents.Open(path) return {"ok": True, "name": doc.Name, "path": doc.FullName} + result = await self._run(_sync) await self._ensure_document_state() return result @@ -636,6 +645,7 @@ class ComBackend(AutoCADBackend): else: doc.Save() return {"ok": True, "path": doc.FullName} + return await self._run(_sync) async def drawing_save_as(self, path: str, fmt: str = "dwg") -> dict: @@ -646,17 +656,204 @@ class ComBackend(AutoCADBackend): acad_fmt = fmt_map.get(fmt.lower(), 12) doc.SaveAs(path, acad_fmt) return {"ok": True, "path": path, "format": fmt} + return await self._run(_sync) async def drawing_export_dxf(self, path: str) -> dict: return await self.drawing_save_as(path, "dxf") - async def drawing_export_pdf(self, path: str) -> dict: + async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: def _sync(): doc = _acad_doc() - plot = doc.Plot - plot.PlotToFile(path, "DWG To PDF.pc3") - return {"ok": True, "path": path} + previous = None + if layout: + previous = doc.ActiveLayout.Name + if previous != layout: + doc.ActiveLayout = doc.Layouts.Item(layout) + try: + plot = doc.Plot + plot.PlotToFile(path, "DWG To PDF.pc3") + result = {"ok": True, "path": path} + if layout: + result["layout"] = layout + return result + finally: + if previous is not None and previous != layout: + doc.ActiveLayout = doc.Layouts.Item(previous) + + return await self._run(_sync) + + # ── layouts / paper space ──────────────────────────────────────────────── + + async def layout_list(self) -> dict: + def _sync(): + doc = _acad_doc() + names = [doc.Layouts.Item(i).Name for i in range(doc.Layouts.Count)] + return {"ok": True, "layouts": names, "current": doc.ActiveLayout.Name} + + return await self._run(_sync) + + async def layout_create(self, name: str) -> dict: + def _sync(): + doc = _acad_doc() + existing = {doc.Layouts.Item(i).Name for i in range(doc.Layouts.Count)} + if name in existing: + return {"ok": False, "error": f"Layout already exists: {name}"} + doc.Layouts.Add(name) + return {"ok": True, "layout": name} + + return await self._run(_sync) + + async def layout_set_current(self, name: str) -> dict: + def _sync(): + doc = _acad_doc() + try: + doc.ActiveLayout = doc.Layouts.Item(name) + except Exception: + return {"ok": False, "error": f"Layout not found: {name}"} + return {"ok": True, "current": name} + + return await self._run(_sync) + + async def viewport_create( + self, + layout: str, + center_x: float, + center_y: float, + width: float, + height: float, + view_center_x: float, + view_center_y: float, + scale: float = 1.0, + ) -> dict: + def _sync(): + if scale <= 0: + return {"ok": False, "error": "scale must be > 0 (paper:model, e.g. 0.5 for 1:2)"} + doc = _acad_doc() + if layout == "Model": + return {"ok": False, "error": "Viewports require a paper-space layout"} + try: + target = doc.Layouts.Item(layout) + except Exception: + return {"ok": False, "error": f"Paper-space layout not found: {layout}"} + previous = doc.ActiveLayout.Name + if previous != layout: + doc.ActiveLayout = target + try: + viewport = doc.PaperSpace.AddPViewport( + _apoint(center_x, center_y), float(width), float(height) + ) + viewport.Display(True) + try: + viewport.CustomScale = float(scale) + except Exception: # some verticals expose StandardScale only + pass + try: + viewport.ViewCenter = _av([float(view_center_x), float(view_center_y)]) + except Exception: + pass + return { + "ok": True, + "handle": viewport.Handle, + "layout": layout, + "scale": scale, + "view_height": float(height) / float(scale), + } + finally: + if previous != layout: + doc.ActiveLayout = doc.Layouts.Item(previous) + + return await self._run(_sync) + + # ── 3D solids (native ActiveX; gated behind ENABLE_3D at the tool layer) ─ + + async def solid_box( + self, cx: float, cy: float, cz: float, length: float, width: float, height: float + ) -> dict: + def _sync(): + doc = _acad_doc() + solid = doc.ModelSpace.AddBox( + _apoint(cx, cy, cz), float(length), float(width), float(height) + ) + return {"ok": True, "handle": solid.Handle, "type": "3DSOLID"} + + return await self._run(_sync) + + async def solid_cylinder( + self, cx: float, cy: float, cz: float, radius: float, height: float + ) -> dict: + def _sync(): + doc = _acad_doc() + solid = doc.ModelSpace.AddCylinder(_apoint(cx, cy, cz), float(radius), float(height)) + return {"ok": True, "handle": solid.Handle, "type": "3DSOLID"} + + return await self._run(_sync) + + def _region_from_profile(self, doc, profile_handle: str): + """Build an ACIS region from a closed profile entity handle.""" + profile = doc.HandleToObject(profile_handle) + profiles = win32com.client.VARIANT(pythoncom.VT_ARRAY | pythoncom.VT_DISPATCH, [profile]) + regions = doc.ModelSpace.AddRegion(profiles) + if not regions: + raise RuntimeError( + f"AddRegion produced no region from profile {profile_handle} " + "(the profile must be closed)" + ) + return regions[0] + + async def solid_extrude( + self, profile_handle: str, height: float, taper_angle: float = 0.0 + ) -> dict: + def _sync(): + doc = _acad_doc() + region = self._region_from_profile(doc, profile_handle) + solid = doc.ModelSpace.AddExtrudedSolid( + region, float(height), math.radians(float(taper_angle)) + ) + region.Delete() + return {"ok": True, "handle": solid.Handle, "type": "3DSOLID"} + + return await self._run(_sync) + + async def solid_revolve( + self, + profile_handle: str, + axis_x1: float, + axis_y1: float, + axis_x2: float, + axis_y2: float, + angle: float = 360.0, + ) -> dict: + def _sync(): + doc = _acad_doc() + region = self._region_from_profile(doc, profile_handle) + axis_point = _apoint(axis_x1, axis_y1, 0.0) + axis_dir = _apoint(axis_x2 - axis_x1, axis_y2 - axis_y1, 0.0) + solid = doc.ModelSpace.AddRevolvedSolid( + region, axis_point, axis_dir, math.radians(float(angle)) + ) + region.Delete() + return {"ok": True, "handle": solid.Handle, "type": "3DSOLID"} + + return await self._run(_sync) + + async def solid_boolean(self, target_handle: str, tool_handle: str, operation: str) -> dict: + def _sync(): + op = (operation or "").strip().lower() + # AcBooleanType: acUnion=0, acIntersection=1, acSubtraction=2 + codes = {"union": 0, "intersect": 1, "intersection": 1, "subtract": 2} + if op not in codes: + return { + "ok": False, + "error": f"Unknown boolean operation {operation!r}. " + "Use union | subtract | intersect.", + } + doc = _acad_doc() + target = doc.HandleToObject(target_handle) + tool = doc.HandleToObject(tool_handle) + target.Boolean(codes[op], tool) + return {"ok": True, "handle": target.Handle, "operation": op, "type": "3DSOLID"} + return await self._run(_sync) async def drawing_purge(self) -> dict: @@ -664,6 +861,7 @@ class ComBackend(AutoCADBackend): doc = _acad_doc() doc.PurgeAll() return {"ok": True, "message": "Drawing purged"} + return await self._run(_sync) async def drawing_audit(self) -> dict: @@ -673,6 +871,7 @@ class ComBackend(AutoCADBackend): app.SetVariable("AUDITCTL", 1) doc.SendCommand("_AUDIT Y\n") return {"ok": True, "message": "Audit completed"} + return await self._run(_sync) async def drawing_close(self, save: bool = True) -> dict: @@ -680,6 +879,7 @@ class ComBackend(AutoCADBackend): doc = _acad_doc() doc.Close(save) return {"ok": True} + result = await self._run(_sync) await self._ensure_document_state() return result @@ -689,6 +889,7 @@ class ComBackend(AutoCADBackend): doc = _acad_doc() doc.SendCommand("_UNDO 1\n") return {"ok": True, "message": "Undo applied"} + return await self._run(_sync) async def drawing_redo(self) -> dict: @@ -696,13 +897,22 @@ class ComBackend(AutoCADBackend): doc = _acad_doc() doc.SendCommand("_REDO\n") return {"ok": True, "message": "Redo applied"} + return await self._run(_sync) # ── entity creation ─────────────────────────────────────────────────────── async def entity_create_line( - self, x1, y1, x2, y2, z1=0.0, z2=0.0, - layer=None, color=None, linetype=None, + self, + x1, + y1, + x2, + y2, + z1=0.0, + z2=0.0, + layer=None, + color=None, + linetype=None, ) -> EntityInfo: def _sync(): mspace = _msp() @@ -710,10 +920,16 @@ class ComBackend(AutoCADBackend): _apply_entity_attrs(line, layer, color, linetype) _regen() return _entity_info(line) + return await self._run(_sync) async def entity_create_circle( - self, cx, cy, radius, layer=None, color=None, + self, + cx, + cy, + radius, + layer=None, + color=None, ) -> EntityInfo: def _sync(): mspace = _msp() @@ -721,24 +937,39 @@ class ComBackend(AutoCADBackend): _apply_entity_attrs(circle, layer, color, None) _regen() return _entity_info(circle) + return await self._run(_sync) async def entity_create_arc( - self, cx, cy, radius, start_angle, end_angle, layer=None, color=None, + self, + cx, + cy, + radius, + start_angle, + end_angle, + layer=None, + color=None, ) -> EntityInfo: def _sync(): mspace = _msp() arc = mspace.AddArc( - _apoint(cx, cy), float(radius), - deg2rad(start_angle), deg2rad(end_angle), + _apoint(cx, cy), + float(radius), + deg2rad(start_angle), + deg2rad(end_angle), ) _apply_entity_attrs(arc, layer, color, None) _regen() return _entity_info(arc) + return await self._run(_sync) async def entity_create_polyline( - self, points, closed=False, layer=None, color=None, + self, + points, + closed=False, + layer=None, + color=None, ) -> EntityInfo: def _sync(): mspace = _msp() @@ -750,10 +981,18 @@ class ComBackend(AutoCADBackend): _apply_entity_attrs(pline, layer, color, None) _regen() return _entity_info(pline) + return await self._run(_sync) async def entity_create_text( - self, text, x, y, height=2.5, rotation=0.0, layer=None, color=None, + self, + text, + x, + y, + height=2.5, + rotation=0.0, + layer=None, + color=None, ) -> EntityInfo: def _sync(): mspace = _msp() @@ -762,10 +1001,19 @@ class ComBackend(AutoCADBackend): _apply_entity_attrs(txt, layer, color, None) _regen() return _entity_info(txt) + return await self._run(_sync) async def entity_create_mtext( - self, text, x, y, width=100.0, height=2.5, rotation=0.0, layer=None, color=None, + self, + text, + x, + y, + width=100.0, + height=2.5, + rotation=0.0, + layer=None, + color=None, ) -> EntityInfo: def _sync(): mspace = _msp() @@ -776,6 +1024,7 @@ class ComBackend(AutoCADBackend): _apply_entity_attrs(mt, layer, color, None) _regen() return _entity_info(mt) + return await self._run(_sync) async def entity_create_table( @@ -792,9 +1041,7 @@ class ComBackend(AutoCADBackend): ) -> EntityInfo: from engineering.annotation import prepare_table_layout - layout = prepare_table_layout( - rows, headers, column_widths, row_height, text_height, title - ) + layout = prepare_table_layout(rows, headers, column_widths, row_height, text_height, title) def _sync(): mspace = _msp() @@ -886,8 +1133,13 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) async def entity_create_hatch( - self, pattern, boundary_points, scale=1.0, angle=0.0, - layer=None, color=None, + self, + pattern, + boundary_points, + scale=1.0, + angle=0.0, + layer=None, + color=None, ) -> EntityInfo: def _sync(): mspace = _msp() @@ -901,19 +1153,21 @@ class ComBackend(AutoCADBackend): flat.extend([float(pt[0]), float(pt[1])]) bnd_pline = mspace.AddLightWeightPolyline(_av(flat)) bnd_pline.Closed = True - outer = win32com.client.VARIANT( - pythoncom.VT_ARRAY | pythoncom.VT_DISPATCH, [bnd_pline] - ) + outer = win32com.client.VARIANT(pythoncom.VT_ARRAY | pythoncom.VT_DISPATCH, [bnd_pline]) hatch.AppendOuterLoop(outer) hatch.Evaluate() bnd_pline.Delete() _apply_entity_attrs(hatch, layer, color, None) _regen() return _entity_info(hatch) + return await self._run(_sync) async def entity_create_spline( - self, fit_points, layer=None, color=None, + self, + fit_points, + layer=None, + color=None, ) -> EntityInfo: def _sync(): mspace = _msp() @@ -922,31 +1176,44 @@ class ComBackend(AutoCADBackend): flat.extend([float(pt[0]), float(pt[1]), 0.0]) sp = mspace.AddSpline( _av(flat), - _apoint(0, 1), # start tangent - _apoint(0, 1), # end tangent + _apoint(0, 1), # start tangent + _apoint(0, 1), # end tangent ) _apply_entity_attrs(sp, layer, color, None) _regen() return _entity_info(sp) + return await self._run(_sync) async def entity_create_ellipse( - self, cx, cy, major_x, major_y, ratio=0.5, layer=None, color=None, + self, + cx, + cy, + major_x, + major_y, + ratio=0.5, + layer=None, + color=None, ) -> EntityInfo: def _sync(): mspace = _msp() ellipse = mspace.AddEllipse( _apoint(cx, cy), _apoint(major_x, major_y), # major axis vector - float(ratio), # ratio of minor to major axis + float(ratio), # ratio of minor to major axis ) _apply_entity_attrs(ellipse, layer, color, None) _regen() return _entity_info(ellipse) + return await self._run(_sync) async def entity_create_point( - self, x, y, layer=None, color=None, + self, + x, + y, + layer=None, + color=None, ) -> EntityInfo: def _sync(): mspace = _msp() @@ -954,78 +1221,130 @@ class ComBackend(AutoCADBackend): _apply_entity_attrs(pt, layer, color, None) _regen() return _entity_info(pt) + return await self._run(_sync) async def entity_create_block_ref( - self, name, x, y, scale_x=1.0, scale_y=1.0, rotation=0.0, layer=None, + self, + name, + x, + y, + scale_x=1.0, + scale_y=1.0, + rotation=0.0, + layer=None, ) -> EntityInfo: def _sync(): mspace = _msp() ref = mspace.InsertBlock( - _apoint(x, y), name, - float(scale_x), float(scale_y), 1.0, + _apoint(x, y), + name, + float(scale_x), + float(scale_y), + 1.0, deg2rad(rotation), ) if layer: ref.Layer = layer _regen() return _entity_info(ref) + return await self._run(_sync) # ── dimensions ──────────────────────────────────────────────────────────── async def dimension_linear( - self, x1, y1, x2, y2, dim_x, dim_y, rotation=0.0, layer=None, - tol_upper=None, tol_lower=None, tol_mode="none", text_override=None, + self, + x1, + y1, + x2, + y2, + dim_x, + dim_y, + rotation=0.0, + layer=None, + tol_upper=None, + tol_lower=None, + tol_mode="none", + text_override=None, ) -> EntityInfo: def _sync(): mspace = _msp() # The ActiveX API has no AddDimLinear — rotated linear dims are # created with AddDimRotated (GH issue #3). dim = mspace.AddDimRotated( - _apoint(x1, y1), _apoint(x2, y2), - _apoint(dim_x, dim_y), deg2rad(rotation), + _apoint(x1, y1), + _apoint(x2, y2), + _apoint(dim_x, dim_y), + deg2rad(rotation), ) if layer: dim.Layer = layer _apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode, text_override) _regen() return _entity_info(dim) + return await self._run(_sync) async def dimension_aligned( - self, x1, y1, x2, y2, dim_x, dim_y, layer=None, + self, + x1, + y1, + x2, + y2, + dim_x, + dim_y, + layer=None, ) -> EntityInfo: def _sync(): mspace = _msp() - dim = mspace.AddDimAligned( - _apoint(x1, y1), _apoint(x2, y2), _apoint(dim_x, dim_y) - ) + dim = mspace.AddDimAligned(_apoint(x1, y1), _apoint(x2, y2), _apoint(dim_x, dim_y)) if layer: dim.Layer = layer _regen() return _entity_info(dim) + return await self._run(_sync) async def dimension_angular( - self, vx, vy, x1, y1, x2, y2, tx, ty, layer=None, + self, + vx, + vy, + x1, + y1, + x2, + y2, + tx, + ty, + layer=None, ) -> EntityInfo: def _sync(): mspace = _msp() dim = mspace.AddDimAngular( _apoint(vx, vy), - _apoint(x1, y1), _apoint(x2, y2), + _apoint(x1, y1), + _apoint(x2, y2), _apoint(tx, ty), ) if layer: dim.Layer = layer _regen() return _entity_info(dim) + return await self._run(_sync) async def dimension_radius( - self, cx, cy, chord_x, chord_y, leader_length=10.0, layer=None, - tol_upper=None, tol_lower=None, tol_mode="none", text_override=None, + self, + cx, + cy, + chord_x, + chord_y, + leader_length=10.0, + layer=None, + tol_upper=None, + tol_lower=None, + tol_mode="none", + text_override=None, ) -> EntityInfo: def _sync(): mspace = _msp() @@ -1037,22 +1356,31 @@ class ComBackend(AutoCADBackend): _apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode, text_override) _regen() return _entity_info(dim) + return await self._run(_sync) async def dimension_diameter( - self, x1, y1, x2, y2, leader_length=10.0, layer=None, - tol_upper=None, tol_lower=None, tol_mode="none", text_override=None, + self, + x1, + y1, + x2, + y2, + leader_length=10.0, + layer=None, + tol_upper=None, + tol_lower=None, + tol_mode="none", + text_override=None, ) -> EntityInfo: def _sync(): mspace = _msp() - dim = mspace.AddDimDiametric( - _apoint(x1, y1), _apoint(x2, y2), float(leader_length) - ) + dim = mspace.AddDimDiametric(_apoint(x1, y1), _apoint(x2, y2), float(leader_length)) if layer: dim.Layer = layer _apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode, text_override) _regen() return _entity_info(dim) + return await self._run(_sync) # ── entity modification ─────────────────────────────────────────────────── @@ -1063,6 +1391,7 @@ class ComBackend(AutoCADBackend): ent = doc.HandleToObject(handle) ent.Move(_apoint(0, 0, 0), _apoint(dx, dy, dz)) return {"ok": True, "handle": handle} + return await self._run(_sync) async def entity_copy(self, handle, dx, dy, dz=0.0) -> EntityInfo: @@ -1072,6 +1401,7 @@ class ComBackend(AutoCADBackend): copy = ent.Copy() copy.Move(_apoint(0, 0, 0), _apoint(dx, dy, dz)) return _entity_info(copy) + return await self._run(_sync) async def entity_rotate(self, handle, base_x, base_y, angle_deg) -> dict: @@ -1080,6 +1410,7 @@ class ComBackend(AutoCADBackend): ent = doc.HandleToObject(handle) ent.Rotate(_apoint(base_x, base_y), deg2rad(angle_deg)) return {"ok": True, "handle": handle} + return await self._run(_sync) async def entity_scale(self, handle, base_x, base_y, factor) -> dict: @@ -1088,10 +1419,17 @@ class ComBackend(AutoCADBackend): ent = doc.HandleToObject(handle) ent.ScaleEntity(_apoint(base_x, base_y), float(factor)) return {"ok": True, "handle": handle} + return await self._run(_sync) async def entity_mirror( - self, handle, x1, y1, x2, y2, delete_original=False, + self, + handle, + x1, + y1, + x2, + y2, + delete_original=False, ) -> EntityInfo: def _sync(): doc = _acad_doc() @@ -1100,10 +1438,15 @@ class ComBackend(AutoCADBackend): if delete_original: ent.Delete() return _entity_info(mirrored) + return await self._run(_sync) async def entity_offset( - self, handle, distance, side_x=None, side_y=None, + self, + handle, + distance, + side_x=None, + side_y=None, ) -> EntityInfo: def _sync(): doc = _acad_doc() @@ -1151,6 +1494,7 @@ class ComBackend(AutoCADBackend): except Exception: pass return _entity_info(result[0]) + return await self._run(_sync) async def entity_delete(self, handle) -> dict: @@ -1159,10 +1503,16 @@ class ComBackend(AutoCADBackend): ent = doc.HandleToObject(handle) ent.Delete() return {"ok": True, "deleted_handle": handle} + return await self._run(_sync) async def entity_array_rectangular( - self, handle, rows, cols, row_spacing, col_spacing, + self, + handle, + rows, + cols, + row_spacing, + col_spacing, ) -> list[EntityInfo]: def _sync(): _COM_STATE["batch_mode"] = True @@ -1170,8 +1520,12 @@ class ComBackend(AutoCADBackend): doc = _acad_doc() ent = doc.HandleToObject(handle) result = ent.ArrayRectangular( - int(rows), int(cols), 1, # numLevels=1 - float(row_spacing), float(col_spacing), 0.0, + int(rows), + int(cols), + 1, # numLevels=1 + float(row_spacing), + float(col_spacing), + 0.0, ) entities = [] try: @@ -1184,10 +1538,16 @@ class ComBackend(AutoCADBackend): finally: _COM_STATE["batch_mode"] = False _regen() + return await self._run(_sync) async def entity_array_polar( - self, handle, count, fill_angle, center_x, center_y, + self, + handle, + count, + fill_angle, + center_x, + center_y, ) -> list[EntityInfo]: def _sync(): _COM_STATE["batch_mode"] = True @@ -1208,6 +1568,7 @@ class ComBackend(AutoCADBackend): finally: _COM_STATE["batch_mode"] = False _regen() + return await self._run(_sync) # ── entity query / properties ────────────────────────────────────────────── @@ -1217,11 +1578,17 @@ class ComBackend(AutoCADBackend): doc = _acad_doc() ent = doc.HandleToObject(handle) return _entity_info(ent) + return await self._run(_sync) async def entity_set_properties( - self, handle, layer=None, color=None, linetype=None, - lineweight=None, visible=None, + self, + handle, + layer=None, + color=None, + linetype=None, + lineweight=None, + visible=None, ) -> dict: def _sync(): doc = _acad_doc() @@ -1238,10 +1605,15 @@ class ComBackend(AutoCADBackend): if visible is not None: ent.Visible = bool(visible) return {"ok": True, "handle": handle} + return await self._run(_sync) async def entity_edit_text( - self, handle, text=None, height=None, rotation=None, + self, + handle, + text=None, + height=None, + rotation=None, ) -> EntityInfo: def _sync(): doc = _acad_doc() @@ -1260,12 +1632,21 @@ class ComBackend(AutoCADBackend): ent.Rotation = deg2rad(float(rotation)) _regen() return _entity_info(ent) + return await self._run(_sync) async def entity_edit_geometry( - self, handle, cx=None, cy=None, radius=None, - x1=None, y1=None, x2=None, y2=None, - start_angle=None, end_angle=None, + self, + handle, + cx=None, + cy=None, + radius=None, + x1=None, + y1=None, + x2=None, + y2=None, + start_angle=None, + end_angle=None, ) -> EntityInfo: def _sync(): doc = _acad_doc() @@ -1307,10 +1688,15 @@ class ComBackend(AutoCADBackend): ) _regen() return _entity_info(ent) + return await self._run(_sync) async def entity_list( - self, type_filter=None, layer_filter=None, limit=200, offset=0, + self, + type_filter=None, + layer_filter=None, + limit=200, + offset=0, ) -> list[EntityInfo]: def _sync(): mspace = _msp() @@ -1341,6 +1727,7 @@ class ComBackend(AutoCADBackend): log.debug("entity_list: skip entity at index %d: %s", i, exc) continue return results + return await self._run(_sync) # ── layer management ────────────────────────────────────────────────────── @@ -1354,10 +1741,15 @@ class ComBackend(AutoCADBackend): lyr = doc.Layers.Item(i) layers.append(_layer_info(lyr, current)) return layers + return await self._run(_sync) async def layer_create( - self, name, color=7, linetype="Continuous", lineweight=-3, + self, + name, + color=7, + linetype="Continuous", + lineweight=-3, ) -> LayerInfo: def _sync(): doc = _acad_doc() @@ -1370,6 +1762,7 @@ class ComBackend(AutoCADBackend): log.warning("Failed to set linetype '%s' on layer '%s': %s", linetype, name, exc) lyr.LineWeight = normalize_lineweight(lineweight) return _layer_info(lyr, doc.ActiveLayer.Name) + return await self._run(_sync) async def layer_delete(self, name) -> dict: @@ -1378,6 +1771,7 @@ class ComBackend(AutoCADBackend): lyr = doc.Layers.Item(name) lyr.Delete() return {"ok": True, "deleted": name} + return await self._run(_sync) async def layer_set_current(self, name) -> dict: @@ -1386,10 +1780,15 @@ class ComBackend(AutoCADBackend): lyr = doc.Layers.Item(name) doc.ActiveLayer = lyr return {"ok": True, "current_layer": name} + return await self._run(_sync) async def layer_modify( - self, name, color=None, linetype=None, lineweight=None, + self, + name, + color=None, + linetype=None, + lineweight=None, ) -> LayerInfo: def _sync(): doc = _acad_doc() @@ -1402,6 +1801,7 @@ class ComBackend(AutoCADBackend): if lineweight is not None: lyr.LineWeight = normalize_lineweight(lineweight) return _layer_info(lyr, doc.ActiveLayer.Name) + return await self._run(_sync) async def layer_freeze(self, name) -> dict: @@ -1410,6 +1810,7 @@ class ComBackend(AutoCADBackend): lyr = doc.Layers.Item(name) lyr.Freeze = True return {"ok": True, "layer": name, "frozen": True} + return await self._run(_sync) async def layer_thaw(self, name) -> dict: @@ -1418,6 +1819,7 @@ class ComBackend(AutoCADBackend): lyr = doc.Layers.Item(name) lyr.Freeze = False return {"ok": True, "layer": name, "frozen": False} + return await self._run(_sync) async def layer_lock(self, name) -> dict: @@ -1426,6 +1828,7 @@ class ComBackend(AutoCADBackend): lyr = doc.Layers.Item(name) lyr.Lock = True return {"ok": True, "layer": name, "locked": True} + return await self._run(_sync) async def layer_unlock(self, name) -> dict: @@ -1434,6 +1837,7 @@ class ComBackend(AutoCADBackend): lyr = doc.Layers.Item(name) lyr.Lock = False return {"ok": True, "layer": name, "locked": False} + return await self._run(_sync) async def layer_hide(self, name) -> dict: @@ -1442,6 +1846,7 @@ class ComBackend(AutoCADBackend): lyr = doc.Layers.Item(name) lyr.LayerOn = False return {"ok": True, "layer": name, "visible": False} + return await self._run(_sync) async def layer_show(self, name) -> dict: @@ -1450,6 +1855,7 @@ class ComBackend(AutoCADBackend): lyr = doc.Layers.Item(name) lyr.LayerOn = True return {"ok": True, "layer": name, "visible": True} + return await self._run(_sync) # ── linetype management ─────────────────────────────────────────────────── @@ -1458,6 +1864,7 @@ class ComBackend(AutoCADBackend): def _sync(): doc = _acad_doc() return [doc.Linetypes.Item(i).Name for i in range(doc.Linetypes.Count)] + return await self._run(_sync) async def linetype_load(self, name, file=None) -> dict: @@ -1471,8 +1878,7 @@ class ComBackend(AutoCADBackend): app = _acad_app() doc = _acad_doc() - existing = {doc.Linetypes.Item(i).Name.lower() - for i in range(doc.Linetypes.Count)} + existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)} if name.lower() in existing: return {"ok": True, "name": name, "already_loaded": True} @@ -1502,8 +1908,7 @@ class ComBackend(AutoCADBackend): except Exception as exc: log.debug("FILEDIA restore failed: %s", exc) - after = {doc.Linetypes.Item(i).Name.lower() - for i in range(doc.Linetypes.Count)} + after = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)} if name.lower() not in after: raise RuntimeError( f"Failed to load linetype '{name}' from '{lin_file}'. " @@ -1511,6 +1916,7 @@ class ComBackend(AutoCADBackend): "on AutoCAD's support path." ) return {"ok": True, "name": name, "file": lin_file} + return await self._run(_sync) # ── block operations ────────────────────────────────────────────────────── @@ -1524,7 +1930,8 @@ class ComBackend(AutoCADBackend): if blk.Name.startswith("*"): # skip *Model_Space, *Paper_Space, etc. continue attr_count = sum( - 1 for j in range(blk.Count) + 1 + for j in range(blk.Count) if blk.Item(j).ObjectName == "AcDbAttributeDefinition" ) # S3: populate description from the block definition's .Comments. @@ -1532,26 +1939,39 @@ class ComBackend(AutoCADBackend): description = blk.Comments or "" except Exception: description = "" - blocks.append(BlockInfo( - name=blk.Name, - origin=(blk.Origin[0], blk.Origin[1]), - attribute_count=attr_count, - entity_count=blk.Count, - is_xref=bool(blk.IsXRef), - description=description, - )) + blocks.append( + BlockInfo( + name=blk.Name, + origin=(blk.Origin[0], blk.Origin[1]), + attribute_count=attr_count, + entity_count=blk.Count, + is_xref=bool(blk.IsXRef), + description=description, + ) + ) return blocks + return await self._run(_sync) async def block_insert( - self, name, x, y, scale_x=1.0, scale_y=1.0, rotation=0.0, - attributes=None, layer=None, + self, + name, + x, + y, + scale_x=1.0, + scale_y=1.0, + rotation=0.0, + attributes=None, + layer=None, ) -> EntityInfo: def _sync(): mspace = _msp() ref = mspace.InsertBlock( - _apoint(x, y), name, - float(scale_x), float(scale_y), 1.0, + _apoint(x, y), + name, + float(scale_x), + float(scale_y), + 1.0, deg2rad(rotation), ) if layer: @@ -1566,6 +1986,7 @@ class ComBackend(AutoCADBackend): except Exception as exc: log.debug("Block insert: GetAttributes or attribute setting failed: %s", exc) return _entity_info(ref) + return await self._run(_sync) async def block_explode(self, handle) -> dict: @@ -1574,6 +1995,7 @@ class ComBackend(AutoCADBackend): ent = doc.HandleToObject(handle) ent.Explode() return {"ok": True, "exploded_handle": handle} + return await self._run(_sync) async def block_get_attributes(self, handle) -> dict: @@ -1585,6 +2007,7 @@ class ComBackend(AutoCADBackend): for attr in attrs: result[attr.TagString] = attr.TextString return result + return await self._run(_sync) async def block_set_attributes(self, handle, attributes) -> dict: @@ -1599,15 +2022,20 @@ class ComBackend(AutoCADBackend): attr.TextString = str(attributes[tag]) updated.append(tag) return {"ok": True, "updated_tags": updated} + return await self._run(_sync) async def block_create_from_entities( - self, name, handles, base_x=0.0, base_y=0.0, + self, + name, + handles, + base_x=0.0, + base_y=0.0, ) -> dict: return { "ok": False, "error": "block_create_from_entities not supported in COM backend. " - "Use system_run_command with _BLOCK instead.", + "Use system_run_command with _BLOCK instead.", } # ── analysis / query ────────────────────────────────────────────────────── @@ -1632,10 +2060,15 @@ class ComBackend(AutoCADBackend): "by_type": dict(sorted(type_counts.items(), key=lambda x: -x[1])), "by_layer": dict(sorted(layer_counts.items(), key=lambda x: -x[1])), } + return await self._run(_sync) async def analysis_entities_in_region( - self, x1, y1, x2, y2, + self, + x1, + y1, + x2, + y2, ) -> list[EntityInfo]: def _sync(): doc = _acad_doc() @@ -1656,6 +2089,7 @@ class ComBackend(AutoCADBackend): ss.Delete() except Exception as exc: log.debug("SelectionSet cleanup failed: %s", exc) + return await self._run(_sync) async def analysis_measure_distance(self, x1, y1, x2, y2) -> float: @@ -1679,6 +2113,7 @@ class ComBackend(AutoCADBackend): except Exception as exc: log.debug("analysis_bounding_box: Database Extmin/Extmax read failed: %s", exc) return {"error": str(exc)} + return await self._run(_sync) async def analysis_select_by_layer(self, layer_name) -> list[EntityInfo]: @@ -1687,7 +2122,7 @@ class ComBackend(AutoCADBackend): ss_name = f"_BYLAYER_{uuid.uuid4().hex[:8]}" ss = doc.SelectionSets.Add(ss_name) try: - ft = _ai([8]) # group code 8 = layer + ft = _ai([8]) # group code 8 = layer fv = win32com.client.VARIANT( pythoncom.VT_ARRAY | pythoncom.VT_VARIANT, [layer_name] ) @@ -1698,6 +2133,7 @@ class ComBackend(AutoCADBackend): ss.Delete() except Exception as exc: log.debug("SelectionSet cleanup failed: %s", exc) + return await self._run(_sync) async def analysis_select_by_type(self, entity_type) -> list[EntityInfo]: @@ -1715,6 +2151,7 @@ class ComBackend(AutoCADBackend): log.debug("analysis_select_by_type: skip entity at index %d: %s", i, exc) continue return results + return await self._run(_sync) async def selection_get(self) -> dict: @@ -1761,7 +2198,7 @@ class ComBackend(AutoCADBackend): "ok": True, "count": len(handles), "handles": handles, - "entities": entities, # _dc()-converted at the server layer + "entities": entities, # _dc()-converted at the server layer "pickfirst": pickfirst, } if not handles: @@ -1771,7 +2208,7 @@ class ComBackend(AutoCADBackend): "ensure PICKFIRST=1." if pickfirst is not False else "PICKFIRST is 0; the noun/verb grip selection is disabled. " - "Set PICKFIRST=1 (system_set_variable) and re-select." + "Set PICKFIRST=1 (system_set_variable) and re-select." ) return result @@ -1785,6 +2222,7 @@ class ComBackend(AutoCADBackend): app.ZoomExtents() _acad_doc().Regen(0) return {"ok": True} + return await self._run(_sync) async def view_zoom_window(self, x1, y1, x2, y2) -> dict: @@ -1792,6 +2230,7 @@ class ComBackend(AutoCADBackend): app = _acad_app() app.ZoomWindow(_apoint(x1, y1), _apoint(x2, y2)) return {"ok": True} + return await self._run(_sync) async def view_screenshot(self) -> bytes | None: @@ -1800,6 +2239,7 @@ class ComBackend(AutoCADBackend): if hwnd is None: return None return _capture_window(hwnd) + return await self._run(_sync) # ── transactions ────────────────────────────────────────────────────────── @@ -1812,6 +2252,7 @@ class ComBackend(AutoCADBackend): doc = _acad_doc() doc.StartUndoMark() return {"ok": True, "message": "Transaction begun (AutoCAD undo mark set)"} + result = await self._run(_sync) self._transaction_active = True return result @@ -1821,6 +2262,7 @@ class ComBackend(AutoCADBackend): doc = _acad_doc() doc.EndUndoMark() return {"ok": True, "message": "Transaction committed"} + # R16: clear the flag even if _run raises, so a failed commit can't leave # _transaction_active permanently stale. try: @@ -1836,6 +2278,7 @@ class ComBackend(AutoCADBackend): # SendCommand that could deadlock if a command/prompt is active. self._safe_send_command(doc, "_UNDO B") return {"ok": True, "message": "Transaction rolled back"} + try: return await self._run(_sync) finally: @@ -1845,6 +2288,7 @@ class ComBackend(AutoCADBackend): async def system_status(self) -> dict: tx_active = self._transaction_active + def _sync(): try: app = _acad_app() @@ -1863,19 +2307,25 @@ class ComBackend(AutoCADBackend): # does NOT create 3D solids, so the old "all_entity_types" # claim was false — report "entities_2d" instead. "capabilities": [ - "live_control", "screenshot", "transactions", - "com_api", "lisp_execution", "entities_2d", + "live_control", + "screenshot", + "transactions", + "com_api", + "lisp_execution", + "entities_2d", ], } except Exception as exc: log.debug("system_status: _acad_app check failed: %s", exc) return {"backend": "com", "connected": False, "error": str(exc)} + return await self._run(_sync) async def system_get_variable(self, name) -> Any: def _sync(): app = _acad_app() return app.GetVariable(name) + return await self._run(_sync) async def system_set_variable(self, name, value) -> dict: @@ -1897,6 +2347,7 @@ class ComBackend(AutoCADBackend): log.debug("set_variable type probe for %s failed: %s", name, exc) app.SetVariable(name, coerced) return {"ok": True, "variable": name, "value": coerced} + return await self._run(_sync) async def system_run_command(self, command) -> dict: @@ -1920,6 +2371,7 @@ class ComBackend(AutoCADBackend): cmd = command if command.endswith("\n") else command + "\n" doc.SendCommand(cmd) return {"ok": True, "command": command} + return await self._run(_sync) async def system_run_lisp(self, expression) -> dict: @@ -1941,10 +2393,7 @@ class ComBackend(AutoCADBackend): # N6: SendCommand is void-returning, so the value can't be read from its # return. Stash it in USERS1 and read it back after the form completes # (CMDACTIVE-polled by _safe_send_command). - wrapped = ( - '(vl-load-com)' - f'(setvar "USERS1" (vl-princ-to-string (progn {expression})))' - ) + wrapped = f'(vl-load-com)(setvar "USERS1" (vl-princ-to-string (progn {expression})))' self._safe_send_command(doc, wrapped) try: result = app.GetVariable("USERS1") @@ -1955,6 +2404,7 @@ class ComBackend(AutoCADBackend): "expression": expression, "result": str(result) if result not in (None, "") else "nil", } + return await self._run(_sync) # ── corner ops ────────────────────────────────────────────────────────── @@ -2015,17 +2465,19 @@ class ComBackend(AutoCADBackend): async def entity_trim(self, target_handle, cutter_handle, keep_x, keep_y) -> EntityInfo: def _sync(): doc = _acad_doc() - cmd = ( - f'_TRIM\n(handent "{cutter_handle}")\n\n' - f'{float(keep_x)},{float(keep_y)}\n\n' - ) + cmd = f'_TRIM\n(handent "{cutter_handle}")\n\n{float(keep_x)},{float(keep_y)}\n\n' self._safe_send_command(doc, cmd) ent = doc.HandleToObject(target_handle) return _entity_info(ent) + return await self._run(_sync) async def entity_extend( - self, target_handle, boundary_handle, end_x=None, end_y=None, + self, + target_handle, + boundary_handle, + end_x=None, + end_y=None, ) -> EntityInfo: def _sync(): doc = _acad_doc() @@ -2047,12 +2499,10 @@ class ComBackend(AutoCADBackend): ex, ey = 0.0, 0.0 else: ex, ey = float(end_x), float(end_y) - cmd = ( - f'_EXTEND\n(handent "{boundary_handle}")\n\n' - f'{ex},{ey}\n\n' - ) + cmd = f'_EXTEND\n(handent "{boundary_handle}")\n\n{ex},{ey}\n\n' self._safe_send_command(doc, cmd) return _entity_info(doc.HandleToObject(target_handle)) + return await self._run(_sync) async def entity_fillet(self, handle1, handle2, radius, trim=True) -> EntityInfo: @@ -2060,7 +2510,7 @@ class ComBackend(AutoCADBackend): doc = _acad_doc() t = "T" if trim else "N" # T=Trim, N=No-trim cmd = ( - f'_FILLET\n_R\n{float(radius)}\n_T\n_{t}\n' + f"_FILLET\n_R\n{float(radius)}\n_T\n_{t}\n" f'(handent "{handle1}")\n(handent "{handle2}")\n' ) new_handles = self._safe_send_command(doc, cmd) @@ -2074,10 +2524,16 @@ class ComBackend(AutoCADBackend): except Exception: continue return _entity_info(doc.HandleToObject(handle1)) + return await self._run(_sync) async def entity_chamfer( - self, handle1, handle2, dist1, dist2=None, trim=True, + self, + handle1, + handle2, + dist1, + dist2=None, + trim=True, ) -> EntityInfo: def _sync(): doc = _acad_doc() @@ -2085,7 +2541,7 @@ class ComBackend(AutoCADBackend): d2 = float(dist1 if dist2 is None else dist2) t = "T" if trim else "N" cmd = ( - f'_CHAMFER\n_D\n{d1}\n{d2}\n_T\n_{t}\n' + f"_CHAMFER\n_D\n{d1}\n{d2}\n_T\n_{t}\n" f'(handent "{handle1}")\n(handent "{handle2}")\n' ) new_handles = self._safe_send_command(doc, cmd) @@ -2097,6 +2553,7 @@ class ComBackend(AutoCADBackend): except Exception: continue return _entity_info(doc.HandleToObject(handle1)) + return await self._run(_sync) # ── premium meta-tools live on AutoCADBackend (base.py) and are shared by @@ -2113,4 +2570,5 @@ class ComBackend(AutoCADBackend): _apply_entity_attrs(xline, layer, None, None) _regen() return _entity_info(xline) + return await self._run(_sync) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 8fc364b..8c65a88 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -58,6 +58,7 @@ try: from ezdxf import colors, units # noqa: F401 from ezdxf.enums import TextEntityAlignment # noqa: F401 from ezdxf.math import BSpline, Vec2, Vec3 # noqa: F401 + _EZDXF_OK = True except ImportError: _EZDXF_OK = False @@ -65,6 +66,7 @@ except ImportError: try: from ezdxf import bbox as ezdxf_bbox + _BBOX_OK = True except ImportError: _BBOX_OK = False @@ -80,12 +82,12 @@ _BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"} # Pattern format: [total_length, dash_1, gap_1, dash_2, gap_2, ...] (gaps negative). # Values match acadiso.lin so drawings render the same in AutoCAD viewers. _AUTOCAD_FALLBACK_LINETYPES: dict[str, tuple[str, list[float]]] = { - "HIDDEN": ("Hidden __ __ __ __ __ __ __ __ __", [7.5, 5.0, -2.5]), - "HIDDEN2": ("Hidden (.5x) _ _ _ _ _ _ _ _ _", [3.75, 2.5, -1.25]), - "HIDDENX2": ("Hidden (2x) ____ ____ ____ ____", [15.0, 10.0, -5.0]), - "BORDER": ("Border __ __ . __ __ . __ __ . __", [22.5, 7.5, -2.5, 7.5, -2.5, 0.0, -2.5]), - "BORDER2": ("Border (.5x) __.__.__.__.__.__", [11.25, 3.75, -1.25, 3.75, -1.25, 0.0, -1.25]), - "BORDERX2": ("Border (2x) ____ __ ____ __", [45.0, 15.0, -5.0, 15.0, -5.0, 0.0, -5.0]), + "HIDDEN": ("Hidden __ __ __ __ __ __ __ __ __", [7.5, 5.0, -2.5]), + "HIDDEN2": ("Hidden (.5x) _ _ _ _ _ _ _ _ _", [3.75, 2.5, -1.25]), + "HIDDENX2": ("Hidden (2x) ____ ____ ____ ____", [15.0, 10.0, -5.0]), + "BORDER": ("Border __ __ . __ __ . __ __ . __", [22.5, 7.5, -2.5, 7.5, -2.5, 0.0, -2.5]), + "BORDER2": ("Border (.5x) __.__.__.__.__.__", [11.25, 3.75, -1.25, 3.75, -1.25, 0.0, -1.25]), + "BORDERX2": ("Border (2x) ____ __ ____ __", [45.0, 15.0, -5.0, 15.0, -5.0, 0.0, -5.0]), } @@ -112,6 +114,7 @@ def _ensure_linetype_loaded(doc, name: str) -> None: return try: from ezdxf.tools import standards as ezdxf_standards + for lt_name, description, pattern in ezdxf_standards.linetypes(): if lt_name.lower() == name.lower(): doc.linetypes.add(lt_name, pattern=pattern, description=description) @@ -138,9 +141,10 @@ def _entity_info_dxf(ent) -> EntityInfo: visible = ent.dxf.get("invisible", False) is False props: dict = {} + def _v2(v): """Convert Vec3/tuple to [x, y] list.""" - return [float(v.x), float(v.y)] if hasattr(v, 'x') else [float(v[0]), float(v[1])] + return [float(v.x), float(v.y)] if hasattr(v, "x") else [float(v[0]), float(v[1])] try: if ent_type == "LINE": @@ -186,8 +190,15 @@ def _entity_info_dxf(ent) -> EntityInfo: if ent.dxf.hasattr("fit_points"): props["fit_point_count"] = len(list(ent.fit_points)) props["degree"] = ent.dxf.degree - elif ent_type in ("DIMENSION", "DIMLINEAR", "DIMALIGNED", "DIMANGULAR", - "DIMRADIUS", "DIMDIAMETER", "DIMORDINATE"): + elif ent_type in ( + "DIMENSION", + "DIMLINEAR", + "DIMALIGNED", + "DIMANGULAR", + "DIMRADIUS", + "DIMDIAMETER", + "DIMORDINATE", + ): props["dim_type"] = ent_type # Reference points for dim_overlap critique. ezdxf dimensions carry # a definition point and (usually) the text midpoint. @@ -208,6 +219,7 @@ def _entity_info_dxf(ent) -> EntityInfo: # reading properties["bounding_box"] work on both engines (N5). try: from ezdxf import bbox as _bbox + _bb = _bbox.extents([ent]) if _bb.has_data: props["bounding_box"] = { @@ -290,8 +302,13 @@ class EzdxfBackend(AutoCADBackend): "preflight": FeatureCapability(True, "shared"), "refiner": FeatureCapability(True, "shared"), "delivery": FeatureCapability(True, "shared"), - "paper_space": FeatureCapability(False, reason="planned_1.4"), - "solid_3d": FeatureCapability(False, reason="planned_1.5"), + "paper_space": FeatureCapability(True, "native"), + "viewport_render": FeatureCapability( + False, reason="viewport_model_projection_requires_live_autocad" + ), + "solid_3d": FeatureCapability( + False, reason="acis_generation_requires_live_autocad" + ), "lisp": FeatureCapability(False, reason="live_com_only"), }, ) @@ -316,9 +333,7 @@ class EzdxfBackend(AutoCADBackend): def _require_doc(self): if self._doc is None: - raise RuntimeError( - "No document open. Call drawing_new() or drawing_open() first." - ) + raise RuntimeError("No document open. Call drawing_new() or drawing_open() first.") return self._doc def _msp(self): @@ -369,6 +384,7 @@ class EzdxfBackend(AutoCADBackend): version=doc.dxfversion, backend="ezdxf", ) + return await self._async(_sync) async def drawing_new(self, template: str | None = None) -> dict: @@ -382,6 +398,7 @@ class EzdxfBackend(AutoCADBackend): self._current_layer = "0" self._reset_document_state() return {"ok": True, "name": "untitled.dxf"} + return await self._async(_sync) async def drawing_open(self, path: str) -> dict: @@ -407,6 +424,7 @@ class EzdxfBackend(AutoCADBackend): self._current_layer = "0" self._reset_document_state() return {"ok": True, "name": Path(path).name, "path": path} + return await self._async(_sync) async def drawing_save(self, path: str | None = None) -> dict: @@ -419,6 +437,7 @@ class EzdxfBackend(AutoCADBackend): self._doc_path = save_path self._dirty = False return {"ok": True, "path": save_path} + return await self._async(_sync) async def drawing_save_as(self, path: str, fmt: str = "dxf") -> dict: @@ -427,22 +446,36 @@ class EzdxfBackend(AutoCADBackend): "ezdxf backend cannot write true DWG binary. " "Use save_path with a .dxf extension, or switch to the COM backend." ) + def _sync(): doc = self._require_doc() doc.saveas(path) self._doc_path = path self._dirty = False return {"ok": True, "path": path, "format": "dxf"} + return await self._async(_sync) async def drawing_export_dxf(self, path: str) -> dict: return await self.drawing_save_as(path, "dxf") - async def drawing_export_pdf(self, path: str) -> dict: - """Export to PDF via ezdxf's matplotlib backend.""" + async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: + """Export to PDF via ezdxf's matplotlib backend. + + With ``layout`` set to a paper-space layout name the layout's own + entities (titleblock, viewport frames, annotation) are rendered. + Model content is not projected through viewports headlessly - that + remains a COM-native capability and is reported in the result. + """ + def _sync(): doc = self._require_doc() - msp = self._msp() + if layout and layout != "Model": + if layout not in doc.layouts.names(): + return {"ok": False, "error": f"Layout not found: {layout}"} + target = doc.layouts.get(layout) + else: + target = self._msp() try: from ezdxf.addons.drawing import Frontend, RenderContext from ezdxf.addons.drawing.matplotlib import MatplotlibBackend @@ -451,15 +484,142 @@ class EzdxfBackend(AutoCADBackend): ax = fig.add_axes([0, 0, 1, 1]) ctx = RenderContext(doc) out = MatplotlibBackend(ax) - Frontend(ctx, out).draw_layout(msp, finalize=True) + Frontend(ctx, out).draw_layout(target, finalize=True) fig.savefig(path, dpi=150) - return {"ok": True, "path": path} + result = {"ok": True, "path": path} + if layout and layout != "Model": + result["layout"] = layout + result["note"] = ( + "Paper-space entities and viewport frames are rendered; " + "projecting model content through viewports is COM-only." + ) + return result except ImportError: raise RuntimeError( "PDF export requires matplotlib: pip install matplotlib" ) from None + return await self._async(_sync) + # ── layouts / paper space ──────────────────────────────────────────────── + + async def layout_list(self) -> dict: + def _sync(): + doc = self._require_doc() + names = list(doc.layouts.names_in_taborder()) + # DXF stores the current tab as $TILEMODE (1 = Model) plus the + # active paper-space layout binding (*Paper_Space block record). + if int(doc.header.get("$TILEMODE", 1)): + current = "Model" + else: + current = doc.active_layout().name + return {"ok": True, "layouts": names, "current": current} + + return await self._async(_sync) + + async def layout_create(self, name: str) -> dict: + def _sync(): + doc = self._require_doc() + if name in doc.layouts.names(): + return {"ok": False, "error": f"Layout already exists: {name}"} + doc.layouts.new(name) + return {"ok": True, "layout": name} + + return await self._async(_sync) + + async def layout_set_current(self, name: str) -> dict: + def _sync(): + doc = self._require_doc() + if name not in doc.layouts.names(): + return {"ok": False, "error": f"Layout not found: {name}"} + if name == "Model": + doc.header["$TILEMODE"] = 1 + else: + # Rebind *Paper_Space so AutoCAD treats it as the active + # paper-space layout; TILEMODE=0 selects the paper tab. + doc.layouts.set_active_layout(name) + doc.header["$TILEMODE"] = 0 + return {"ok": True, "current": name} + + return await self._async(_sync) + + async def viewport_create( + self, + layout: str, + center_x: float, + center_y: float, + width: float, + height: float, + view_center_x: float, + view_center_y: float, + scale: float = 1.0, + ) -> dict: + def _sync(): + doc = self._require_doc() + if scale <= 0: + return {"ok": False, "error": "scale must be > 0 (paper:model, e.g. 0.5 for 1:2)"} + if layout == "Model" or layout not in doc.layouts.names(): + return {"ok": False, "error": f"Paper-space layout not found: {layout}"} + target = doc.layouts.get(layout) + view_height = height / scale + viewport = target.add_viewport( + center=(center_x, center_y), + size=(width, height), + view_center_point=(view_center_x, view_center_y), + view_height=view_height, + ) + return { + "ok": True, + "handle": viewport.dxf.handle, + "layout": layout, + "scale": scale, + "view_height": view_height, + } + + return await self._async(_sync) + + # ── 3D solids (unsupported headlessly — honest capability boundary) ───── + + @staticmethod + def _solid_unsupported(operation: str) -> dict: + return { + "ok": False, + "error": ( + f"{operation}: 3D ACIS solids cannot be generated headlessly; " + "use the live COM backend with ENABLE_3D=true" + ), + "capability": "solid_3d", + } + + async def solid_box( + self, cx: float, cy: float, cz: float, length: float, width: float, height: float + ) -> dict: + return self._solid_unsupported("solid_box") + + async def solid_cylinder( + self, cx: float, cy: float, cz: float, radius: float, height: float + ) -> dict: + return self._solid_unsupported("solid_cylinder") + + async def solid_extrude( + self, profile_handle: str, height: float, taper_angle: float = 0.0 + ) -> dict: + return self._solid_unsupported("solid_extrude") + + async def solid_revolve( + self, + profile_handle: str, + axis_x1: float, + axis_y1: float, + axis_x2: float, + axis_y2: float, + angle: float = 360.0, + ) -> dict: + return self._solid_unsupported("solid_revolve") + + async def solid_boolean(self, target_handle: str, tool_handle: str, operation: str) -> dict: + return self._solid_unsupported("solid_boolean") + async def drawing_purge(self) -> dict: def _sync(): doc = self._require_doc() @@ -525,6 +685,7 @@ class EzdxfBackend(AutoCADBackend): self._mark_dirty() return {"ok": True, "purged": purged} + return await self._async(_sync) async def drawing_audit(self) -> dict: @@ -536,6 +697,7 @@ class EzdxfBackend(AutoCADBackend): "errors": [str(e) for e in auditor.errors], "error_count": len(auditor.errors), } + return await self._async(_sync) async def drawing_close(self, save: bool = True) -> dict: @@ -548,6 +710,7 @@ class EzdxfBackend(AutoCADBackend): self._dirty = False self._reset_document_state() return {"ok": True} + return await self._async(_sync) async def drawing_undo(self) -> dict: @@ -566,6 +729,7 @@ class EzdxfBackend(AutoCADBackend): except OSError: pass return {"ok": True, "message": "Undone to last saved state"} + return await self._async(_sync) async def drawing_redo(self) -> dict: @@ -573,7 +737,9 @@ class EzdxfBackend(AutoCADBackend): # ── internal: apply common attrs ────────────────────────────────────────── - def _apply_attrs(self, entity, layer: str | None, color: int | None, linetype: str | None = None): + def _apply_attrs( + self, entity, layer: str | None, color: int | None, linetype: str | None = None + ): if layer is not None: entity.dxf.layer = layer # Ensure layer exists @@ -595,8 +761,16 @@ class EzdxfBackend(AutoCADBackend): # ── entity creation ─────────────────────────────────────────────────────── async def entity_create_line( - self, x1, y1, x2, y2, z1=0.0, z2=0.0, - layer=None, color=None, linetype=None, + self, + x1, + y1, + x2, + y2, + z1=0.0, + z2=0.0, + layer=None, + color=None, + linetype=None, ) -> EntityInfo: def _sync(): msp = self._msp() @@ -607,10 +781,16 @@ class EzdxfBackend(AutoCADBackend): self._apply_attrs(ent, layer, color, linetype) self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) async def entity_create_circle( - self, cx, cy, radius, layer=None, color=None, + self, + cx, + cy, + radius, + layer=None, + color=None, ) -> EntityInfo: def _sync(): msp = self._msp() @@ -618,24 +798,39 @@ class EzdxfBackend(AutoCADBackend): self._apply_attrs(ent, layer, color) self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) async def entity_create_arc( - self, cx, cy, radius, start_angle, end_angle, layer=None, color=None, + self, + cx, + cy, + radius, + start_angle, + end_angle, + layer=None, + color=None, ) -> EntityInfo: def _sync(): msp = self._msp() ent = msp.add_arc( - (float(cx), float(cy)), float(radius), - float(start_angle), float(end_angle), + (float(cx), float(cy)), + float(radius), + float(start_angle), + float(end_angle), ) self._apply_attrs(ent, layer, color) self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) async def entity_create_polyline( - self, points, closed=False, layer=None, color=None, + self, + points, + closed=False, + layer=None, + color=None, ) -> EntityInfo: def _sync(): msp = self._msp() @@ -644,10 +839,18 @@ class EzdxfBackend(AutoCADBackend): self._apply_attrs(ent, layer, color) self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) async def entity_create_text( - self, text, x, y, height=2.5, rotation=0.0, layer=None, color=None, + self, + text, + x, + y, + height=2.5, + rotation=0.0, + layer=None, + color=None, ) -> EntityInfo: def _sync(): msp = self._msp() @@ -662,10 +865,19 @@ class EzdxfBackend(AutoCADBackend): self._apply_attrs(ent, layer, color) self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) async def entity_create_mtext( - self, text, x, y, width=100.0, height=2.5, rotation=0.0, layer=None, color=None, + self, + text, + x, + y, + width=100.0, + height=2.5, + rotation=0.0, + layer=None, + color=None, ) -> EntityInfo: def _sync(): msp = self._msp() @@ -679,6 +891,7 @@ class EzdxfBackend(AutoCADBackend): self._apply_attrs(ent, layer, color) self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) async def entity_create_table( @@ -695,9 +908,7 @@ class EzdxfBackend(AutoCADBackend): ) -> EntityInfo: from engineering.annotation import prepare_table_layout - layout = prepare_table_layout( - rows, headers, column_widths, row_height, text_height, title - ) + layout = prepare_table_layout(rows, headers, column_widths, row_height, text_height, title) def _sync(): msp = self._msp() @@ -827,8 +1038,14 @@ class EzdxfBackend(AutoCADBackend): "points": [list(point) for point in normalized], "text": str(text), "bounds": { - "min": [min(point[0] for point in normalized), min(point[1] for point in normalized)], - "max": [max(point[0] for point in normalized), max(point[1] for point in normalized)], + "min": [ + min(point[0] for point in normalized), + min(point[1] for point in normalized), + ], + "max": [ + max(point[0] for point in normalized), + max(point[1] for point in normalized), + ], }, }, ) @@ -836,8 +1053,13 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) async def entity_create_hatch( - self, pattern, boundary_points, scale=1.0, angle=0.0, - layer=None, color=None, + self, + pattern, + boundary_points, + scale=1.0, + angle=0.0, + layer=None, + color=None, ) -> EntityInfo: def _sync(): msp = self._msp() @@ -848,10 +1070,14 @@ class EzdxfBackend(AutoCADBackend): self._apply_attrs(hatch, layer, color) self._mark_dirty() return _entity_info_dxf(hatch) + return await self._async(_sync) async def entity_create_spline( - self, fit_points, layer=None, color=None, + self, + fit_points, + layer=None, + color=None, ) -> EntityInfo: def _sync(): msp = self._msp() @@ -860,10 +1086,18 @@ class EzdxfBackend(AutoCADBackend): self._apply_attrs(ent, layer, color) self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) async def entity_create_ellipse( - self, cx, cy, major_x, major_y, ratio=0.5, layer=None, color=None, + self, + cx, + cy, + major_x, + major_y, + ratio=0.5, + layer=None, + color=None, ) -> EntityInfo: def _sync(): msp = self._msp() @@ -875,10 +1109,15 @@ class EzdxfBackend(AutoCADBackend): self._apply_attrs(ent, layer, color) self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) async def entity_create_point( - self, x, y, layer=None, color=None, + self, + x, + y, + layer=None, + color=None, ) -> EntityInfo: def _sync(): msp = self._msp() @@ -886,10 +1125,18 @@ class EzdxfBackend(AutoCADBackend): self._apply_attrs(ent, layer, color) self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) async def entity_create_block_ref( - self, name, x, y, scale_x=1.0, scale_y=1.0, rotation=0.0, layer=None, + self, + name, + x, + y, + scale_x=1.0, + scale_y=1.0, + rotation=0.0, + layer=None, ) -> EntityInfo: def _sync(): msp = self._msp() @@ -906,16 +1153,29 @@ class EzdxfBackend(AutoCADBackend): ent.dxf.layer = layer self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) # ── dimensions ─────────────────────────────────────────────────────────── async def dimension_linear( - self, x1, y1, x2, y2, dim_x, dim_y, rotation=0.0, layer=None, - tol_upper=None, tol_lower=None, tol_mode="none", text_override=None, + self, + x1, + y1, + x2, + y2, + dim_x, + dim_y, + rotation=0.0, + layer=None, + tol_upper=None, + tol_lower=None, + tol_mode="none", + text_override=None, ) -> EntityInfo: def _sync(): from engineering.tolerances import build_dim_override + override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override) msp = self._msp() dim = msp.add_linear_dim( @@ -932,17 +1192,27 @@ class EzdxfBackend(AutoCADBackend): ent.dxf.layer = layer self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) async def dimension_aligned( - self, x1, y1, x2, y2, dim_x, dim_y, layer=None, + self, + x1, + y1, + x2, + y2, + dim_x, + dim_y, + layer=None, ) -> EntityInfo: def _sync(): msp = self._msp() dim = msp.add_aligned_dim( p1=(float(x1), float(y1)), p2=(float(x2), float(y2)), - distance=math.sqrt((float(dim_x) - float(x1)) ** 2 + (float(dim_y) - float(y1)) ** 2), + distance=math.sqrt( + (float(dim_x) - float(x1)) ** 2 + (float(dim_y) - float(y1)) ** 2 + ), ) dim.render() ent = dim.dimension @@ -950,10 +1220,20 @@ class EzdxfBackend(AutoCADBackend): ent.dxf.layer = layer self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) async def dimension_angular( - self, vx, vy, x1, y1, x2, y2, tx, ty, layer=None, + self, + vx, + vy, + x1, + y1, + x2, + y2, + tx, + ty, + layer=None, ) -> EntityInfo: def _sync(): msp = self._msp() @@ -970,14 +1250,25 @@ class EzdxfBackend(AutoCADBackend): ent.dxf.layer = layer self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) async def dimension_radius( - self, cx, cy, chord_x, chord_y, leader_length=10.0, layer=None, - tol_upper=None, tol_lower=None, tol_mode="none", text_override=None, + self, + cx, + cy, + chord_x, + chord_y, + leader_length=10.0, + layer=None, + tol_upper=None, + tol_lower=None, + tol_mode="none", + text_override=None, ) -> EntityInfo: def _sync(): from engineering.tolerances import build_dim_override + override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override) msp = self._msp() cxf, cyf = float(cx), float(cy) @@ -1000,14 +1291,25 @@ class EzdxfBackend(AutoCADBackend): ent.dxf.layer = layer self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) async def dimension_diameter( - self, x1, y1, x2, y2, leader_length=10.0, layer=None, - tol_upper=None, tol_lower=None, tol_mode="none", text_override=None, + self, + x1, + y1, + x2, + y2, + leader_length=10.0, + layer=None, + tol_upper=None, + tol_lower=None, + tol_mode="none", + text_override=None, ) -> EntityInfo: def _sync(): from engineering.tolerances import build_dim_override + override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override) msp = self._msp() x1f, y1f, x2f, y2f = float(x1), float(y1), float(x2), float(y2) @@ -1032,6 +1334,7 @@ class EzdxfBackend(AutoCADBackend): ent.dxf.layer = layer self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) # ── entity modification ─────────────────────────────────────────────────── @@ -1049,6 +1352,7 @@ class EzdxfBackend(AutoCADBackend): ent.translate(float(dx), float(dy), float(dz)) self._mark_dirty() return {"ok": True, "handle": handle} + return await self._async(_sync) async def entity_copy(self, handle, dx, dy, dz=0.0) -> EntityInfo: @@ -1059,28 +1363,28 @@ class EzdxfBackend(AutoCADBackend): copy.translate(float(dx), float(dy), float(dz)) self._mark_dirty() return _entity_info_dxf(copy) + return await self._async(_sync) async def entity_rotate(self, handle, base_x, base_y, angle_deg) -> dict: def _sync(): ent = self._get_entity(handle) from ezdxf.math import Matrix44 + m = Matrix44.z_rotate(math.radians(float(angle_deg))) # Translate to origin, rotate, translate back bx, by = float(base_x), float(base_y) - ent.transform( - Matrix44.translate(-bx, -by, 0) - @ m - @ Matrix44.translate(bx, by, 0) - ) + ent.transform(Matrix44.translate(-bx, -by, 0) @ m @ Matrix44.translate(bx, by, 0)) self._mark_dirty() return {"ok": True, "handle": handle} + return await self._async(_sync) async def entity_scale(self, handle, base_x, base_y, factor) -> dict: def _sync(): ent = self._get_entity(handle) from ezdxf.math import Matrix44 + s = float(factor) bx, by = float(base_x), float(base_y) ent.transform( @@ -1090,16 +1394,24 @@ class EzdxfBackend(AutoCADBackend): ) self._mark_dirty() return {"ok": True, "handle": handle} + return await self._async(_sync) async def entity_mirror( - self, handle, x1, y1, x2, y2, delete_original=False, + self, + handle, + x1, + y1, + x2, + y2, + delete_original=False, ) -> EntityInfo: def _sync(): ent = self._get_entity(handle) copy = ent.copy() self._msp().add_entity(copy) from ezdxf.math import Matrix44 + # Mirror across the line defined by (x1,y1)-(x2,y2) dx = float(x2) - float(x1) dy = float(y2) - float(y1) @@ -1108,26 +1420,41 @@ class EzdxfBackend(AutoCADBackend): raise ValueError("Mirror line has zero length") cos2 = (dx * dx - dy * dy) / (length * length) sin2 = 2 * dx * dy / (length * length) - m = Matrix44(( - cos2, sin2, 0, 0, - sin2, -cos2, 0, 0, - 0, 0, 1, 0, - 0, 0, 0, 1, - )) - tx, ty = float(x1), float(y1) - copy.transform( - Matrix44.translate(-tx, -ty, 0) - @ m - @ Matrix44.translate(tx, ty, 0) + m = Matrix44( + ( + cos2, + sin2, + 0, + 0, + sin2, + -cos2, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 1, + ) ) + tx, ty = float(x1), float(y1) + copy.transform(Matrix44.translate(-tx, -ty, 0) @ m @ Matrix44.translate(tx, ty, 0)) if delete_original: self._msp().delete_entity(ent) self._mark_dirty() return _entity_info_dxf(copy) + return await self._async(_sync) async def entity_offset( - self, handle, distance, side_x=None, side_y=None, + self, + handle, + distance, + side_x=None, + side_y=None, ) -> EntityInfo: def _sync(): ent = self._get_entity(handle) @@ -1164,7 +1491,9 @@ class EzdxfBackend(AutoCADBackend): # side_x/side_y selects inside (shrink) or outside (grow); default = outside sign = 1.0 if side_x is not None and side_y is not None: - dist_to_center = math.sqrt((float(side_x) - cx) ** 2 + (float(side_y) - cy) ** 2) + dist_to_center = math.sqrt( + (float(side_x) - cx) ** 2 + (float(side_y) - cy) ** 2 + ) if dist_to_center < ent.dxf.radius: sign = -1.0 new_r = ent.dxf.radius + d * sign @@ -1172,7 +1501,8 @@ class EzdxfBackend(AutoCADBackend): raise ValueError("Offset distance too large for circle") msp = self._msp() new_ent = msp.add_circle( - (cx, cy), new_r, + (cx, cy), + new_r, dxfattribs={"layer": ent.dxf.layer}, ) self._mark_dirty() @@ -1180,6 +1510,7 @@ class EzdxfBackend(AutoCADBackend): else: raise RuntimeError(f"Offset not supported for {ent_type}") + return await self._async(_sync) async def entity_delete(self, handle) -> dict: @@ -1188,10 +1519,16 @@ class EzdxfBackend(AutoCADBackend): self._msp().delete_entity(ent) self._mark_dirty() return {"ok": True, "deleted_handle": handle} + return await self._async(_sync) async def entity_array_rectangular( - self, handle, rows, cols, row_spacing, col_spacing, + self, + handle, + rows, + cols, + row_spacing, + col_spacing, ) -> list[EntityInfo]: def _sync(): ent = self._get_entity(handle) @@ -1207,15 +1544,22 @@ class EzdxfBackend(AutoCADBackend): results.append(_entity_info_dxf(copy)) self._mark_dirty() return results + return await self._async(_sync) async def entity_array_polar( - self, handle, count, fill_angle, center_x, center_y, + self, + handle, + count, + fill_angle, + center_x, + center_y, ) -> list[EntityInfo]: def _sync(): ent = self._get_entity(handle) msp = self._msp() from ezdxf.math import Matrix44 + results = [] cx, cy = float(center_x), float(center_y) fa = float(fill_angle) @@ -1236,6 +1580,7 @@ class EzdxfBackend(AutoCADBackend): results.append(_entity_info_dxf(copy)) self._mark_dirty() return results + return await self._async(_sync) # ── entity query / properties ───────────────────────────────────────────── @@ -1243,11 +1588,17 @@ class EzdxfBackend(AutoCADBackend): async def entity_get(self, handle) -> EntityInfo: def _sync(): return _entity_info_dxf(self._get_entity(handle)) + return await self._async(_sync) async def entity_set_properties( - self, handle, layer=None, color=None, linetype=None, - lineweight=None, visible=None, + self, + handle, + layer=None, + color=None, + linetype=None, + lineweight=None, + visible=None, ) -> dict: def _sync(): ent = self._get_entity(handle) @@ -1264,10 +1615,15 @@ class EzdxfBackend(AutoCADBackend): ent.dxf.invisible = not bool(visible) self._mark_dirty() return {"ok": True, "handle": handle} + return await self._async(_sync) async def entity_edit_text( - self, handle, text=None, height=None, rotation=None, + self, + handle, + text=None, + height=None, + rotation=None, ) -> EntityInfo: def _sync(): ent = self._get_entity(handle) @@ -1292,12 +1648,21 @@ class EzdxfBackend(AutoCADBackend): ) self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) async def entity_edit_geometry( - self, handle, cx=None, cy=None, radius=None, - x1=None, y1=None, x2=None, y2=None, - start_angle=None, end_angle=None, + self, + handle, + cx=None, + cy=None, + radius=None, + x1=None, + y1=None, + x2=None, + y2=None, + start_angle=None, + end_angle=None, ) -> EntityInfo: def _sync(): ent = self._get_entity(handle) @@ -1342,10 +1707,15 @@ class EzdxfBackend(AutoCADBackend): ) self._mark_dirty() return _entity_info_dxf(ent) + return await self._async(_sync) async def entity_list( - self, type_filter=None, layer_filter=None, limit=200, offset=0, + self, + type_filter=None, + layer_filter=None, + limit=200, + offset=0, ) -> list[EntityInfo]: def _sync(): msp = self._msp() @@ -1365,6 +1735,7 @@ class EzdxfBackend(AutoCADBackend): if len(results) >= limit: break return results + return await self._async(_sync) # ── layer management ────────────────────────────────────────────────────── @@ -1372,14 +1743,16 @@ class EzdxfBackend(AutoCADBackend): async def layer_list(self) -> list[LayerInfo]: def _sync(): doc = self._require_doc() - return [ - _layer_info_dxf(lyr, self._current_layer) - for lyr in doc.layers - ] + return [_layer_info_dxf(lyr, self._current_layer) for lyr in doc.layers] + return await self._async(_sync) async def layer_create( - self, name, color=7, linetype="Continuous", lineweight=-3, + self, + name, + color=7, + linetype="Continuous", + lineweight=-3, ) -> LayerInfo: def _sync(): doc = self._require_doc() @@ -1392,6 +1765,7 @@ class EzdxfBackend(AutoCADBackend): ) self._mark_dirty() return _layer_info_dxf(lyr, self._current_layer) + return await self._async(_sync) async def layer_delete(self, name) -> dict: @@ -1401,6 +1775,7 @@ class EzdxfBackend(AutoCADBackend): doc.layers.remove(name) self._mark_dirty() return {"ok": True, "deleted": name} + return await self._async(_sync) async def layer_set_current(self, name) -> dict: @@ -1415,10 +1790,15 @@ class EzdxfBackend(AutoCADBackend): log.debug("writing $CLAYER to header: %s", exc) self._mark_dirty() return {"ok": True, "current_layer": name} + return await self._async(_sync) async def layer_modify( - self, name, color=None, linetype=None, lineweight=None, + self, + name, + color=None, + linetype=None, + lineweight=None, ) -> LayerInfo: def _sync(): doc = self._require_doc() @@ -1434,6 +1814,7 @@ class EzdxfBackend(AutoCADBackend): lyr.dxf.lineweight = normalize_lineweight(lineweight) self._mark_dirty() return _layer_info_dxf(lyr, self._current_layer) + return await self._async(_sync) async def layer_freeze(self, name) -> dict: @@ -1444,6 +1825,7 @@ class EzdxfBackend(AutoCADBackend): lyr.freeze() self._mark_dirty() return {"ok": True, "layer": name, "frozen": True} + return await self._async(_sync) async def layer_thaw(self, name) -> dict: @@ -1454,6 +1836,7 @@ class EzdxfBackend(AutoCADBackend): lyr.thaw() self._mark_dirty() return {"ok": True, "layer": name, "frozen": False} + return await self._async(_sync) async def layer_lock(self, name) -> dict: @@ -1464,6 +1847,7 @@ class EzdxfBackend(AutoCADBackend): lyr.lock() self._mark_dirty() return {"ok": True, "layer": name, "locked": True} + return await self._async(_sync) async def layer_unlock(self, name) -> dict: @@ -1474,6 +1858,7 @@ class EzdxfBackend(AutoCADBackend): lyr.unlock() self._mark_dirty() return {"ok": True, "layer": name, "locked": False} + return await self._async(_sync) async def layer_hide(self, name) -> dict: @@ -1484,6 +1869,7 @@ class EzdxfBackend(AutoCADBackend): lyr.off() self._mark_dirty() return {"ok": True, "layer": name, "visible": False} + return await self._async(_sync) async def layer_show(self, name) -> dict: @@ -1494,6 +1880,7 @@ class EzdxfBackend(AutoCADBackend): lyr.on() self._mark_dirty() return {"ok": True, "layer": name, "visible": True} + return await self._async(_sync) # ── linetype management ─────────────────────────────────────────────────── @@ -1502,6 +1889,7 @@ class EzdxfBackend(AutoCADBackend): def _sync(): doc = self._require_doc() return [lt.dxf.name for lt in doc.linetypes] + return await self._async(_sync) async def linetype_load(self, name, file=None) -> dict: @@ -1511,7 +1899,9 @@ class EzdxfBackend(AutoCADBackend): # The `file` parameter is accepted for API parity with COM but ignored # — ezdxf does not ship a .lin parser. from ezdxf.tools import standards as ezdxf_standards + del file + def _sync(): doc = self._require_doc() existing = {lt.dxf.name.lower() for lt in doc.linetypes} @@ -1521,18 +1911,17 @@ class EzdxfBackend(AutoCADBackend): if lt_name.lower() == name.lower(): doc.linetypes.add(lt_name, pattern=pattern, description=description) self._mark_dirty() - return {"ok": True, "name": lt_name, - "source": "ezdxf.tools.standards"} + return {"ok": True, "name": lt_name, "source": "ezdxf.tools.standards"} if _add_linetype_from_fallback(doc, name): self._mark_dirty() - return {"ok": True, "name": name.upper(), - "source": "autocad_fallback"} + return {"ok": True, "name": name.upper(), "source": "autocad_fallback"} raise RuntimeError( f"Linetype '{name}' is not in ezdxf's standard set or the " "AutoCAD fallback table. Available standards: CENTER*, DASHED*, " "DASHDOT*, PHANTOM*, DOT*, DIVIDE*. Available fallbacks: " "HIDDEN*, BORDER*. Custom .lin files require the COM backend." ) + return await self._async(_sync) # ── block operations ────────────────────────────────────────────────────── @@ -1544,27 +1933,35 @@ class EzdxfBackend(AutoCADBackend): for blk in doc.blocks: if blk.name.startswith("*"): continue - attr_count = sum( - 1 for e in blk if e.dxftype() == "ATTDEF" - ) + attr_count = sum(1 for e in blk if e.dxftype() == "ATTDEF") origin = blk.block.dxf.get("base_point", (0.0, 0.0, 0.0)) is_xref = bool(blk.block.dxf.get("xref_path", "")) # S3: surface the block definition's description instead of always "". description = blk.block.dxf.get("description", "") - blocks.append(BlockInfo( - name=blk.name, - origin=(origin[0], origin[1]), - attribute_count=attr_count, - entity_count=len(list(blk)), - is_xref=is_xref, - description=description, - )) + blocks.append( + BlockInfo( + name=blk.name, + origin=(origin[0], origin[1]), + attribute_count=attr_count, + entity_count=len(list(blk)), + is_xref=is_xref, + description=description, + ) + ) return blocks + return await self._async(_sync) async def block_insert( - self, name, x, y, scale_x=1.0, scale_y=1.0, rotation=0.0, - attributes=None, layer=None, + self, + name, + x, + y, + scale_x=1.0, + scale_y=1.0, + rotation=0.0, + attributes=None, + layer=None, ) -> EntityInfo: def _sync(): msp = self._msp() @@ -1583,6 +1980,7 @@ class EzdxfBackend(AutoCADBackend): ref = msp.add_blockref(name, (float(x), float(y)), dxfattribs=attribs) self._mark_dirty() return _entity_info_dxf(ref) + return await self._async(_sync) async def block_explode(self, handle) -> dict: @@ -1600,6 +1998,7 @@ class EzdxfBackend(AutoCADBackend): msp.delete_entity(ent) self._mark_dirty() return {"ok": True, "inserted_handles": inserted} + return await self._async(_sync) async def block_get_attributes(self, handle) -> dict: @@ -1611,6 +2010,7 @@ class EzdxfBackend(AutoCADBackend): for attrib in ent.attribs: result[attrib.dxf.tag] = attrib.dxf.text return result + return await self._async(_sync) async def block_set_attributes(self, handle, attributes) -> dict: @@ -1626,10 +2026,15 @@ class EzdxfBackend(AutoCADBackend): updated.append(tag) self._mark_dirty() return {"ok": True, "updated_tags": updated} + return await self._async(_sync) async def block_create_from_entities( - self, name, handles, base_x=0.0, base_y=0.0, + self, + name, + handles, + base_x=0.0, + base_y=0.0, ) -> dict: def _sync(): doc = self._require_doc() @@ -1646,6 +2051,7 @@ class EzdxfBackend(AutoCADBackend): log.debug("adding entity %s to block %s: %s", handle, name, exc) self._mark_dirty() return {"ok": True, "name": name, "entity_count": count} + return await self._async(_sync) # ── analysis / query ────────────────────────────────────────────────────── @@ -1666,10 +2072,15 @@ class EzdxfBackend(AutoCADBackend): "by_type": dict(sorted(type_counts.items(), key=lambda x: -x[1])), "by_layer": dict(sorted(layer_counts.items(), key=lambda x: -x[1])), } + return await self._async(_sync) async def analysis_entities_in_region( - self, x1, y1, x2, y2, + self, + x1, + y1, + x2, + y2, ) -> list[EntityInfo]: def _sync(): msp = self._msp() @@ -1681,8 +2092,13 @@ class EzdxfBackend(AutoCADBackend): for ent in msp: try: bb = ezdxf_bbox.extents([ent]) - if bb and bb.extmin.x >= mn_x and bb.extmax.x <= mx_x \ - and bb.extmin.y >= mn_y and bb.extmax.y <= mx_y: + if ( + bb + and bb.extmin.x >= mn_x + and bb.extmax.x <= mx_x + and bb.extmin.y >= mn_y + and bb.extmax.y <= mx_y + ): results.append(_entity_info_dxf(ent)) except Exception as exc: log.debug("bbox check for entity in region: %s", exc) @@ -1702,6 +2118,7 @@ class EzdxfBackend(AutoCADBackend): log.debug("insertion point check for entity in region: %s", exc) continue return results + return await self._async(_sync) async def analysis_measure_distance(self, x1, y1, x2, y2) -> float: @@ -1726,6 +2143,7 @@ class EzdxfBackend(AutoCADBackend): except Exception as e: return {"error": str(e)} return {"error": "ezdxf bbox not available"} + return await self._async(_sync) async def analysis_select_by_layer(self, layer_name) -> list[EntityInfo]: @@ -1736,17 +2154,15 @@ class EzdxfBackend(AutoCADBackend): for e in msp if e.dxf.get("layer", "0").lower() == layer_name.lower() ] + return await self._async(_sync) async def analysis_select_by_type(self, entity_type) -> list[EntityInfo]: def _sync(): msp = self._msp() et = entity_type.upper() - return [ - _entity_info_dxf(e) - for e in msp - if et == e.dxftype().upper() - ] + return [_entity_info_dxf(e) for e in msp if et == e.dxftype().upper()] + return await self._async(_sync) async def selection_get(self) -> dict: @@ -1785,6 +2201,7 @@ class EzdxfBackend(AutoCADBackend): async def view_screenshot(self) -> bytes | None: """Render drawing to PNG using matplotlib.""" + def _sync(): try: doc = self._require_doc() @@ -1805,6 +2222,7 @@ class EzdxfBackend(AutoCADBackend): except ImportError: log.warning("matplotlib not installed – screenshot unavailable") return None + return await self._async(_sync) # ── transactions ────────────────────────────────────────────────────────── @@ -1831,6 +2249,7 @@ class EzdxfBackend(AutoCADBackend): except OSError: pass return {"ok": True, "message": "Transaction begun (DXF snapshot saved for rollback)"} + return await self._async(_sync) async def transaction_commit(self) -> dict: @@ -1845,6 +2264,7 @@ class EzdxfBackend(AutoCADBackend): except OSError: pass return {"ok": True, "message": "Transaction committed (snapshot discarded)"} + return await self._async(_sync) async def transaction_rollback(self) -> dict: @@ -1864,6 +2284,7 @@ class EzdxfBackend(AutoCADBackend): except OSError: pass return {"ok": True, "message": "Transaction rolled back to snapshot"} + return await self._async(_sync) # ── system ──────────────────────────────────────────────────────────────── @@ -1878,9 +2299,15 @@ class EzdxfBackend(AutoCADBackend): "unsaved_changes": self._dirty, "transaction_depth": len(self._transaction_stack), "capabilities": [ - "file_read_write", "dxf_export", "pdf_export", - "entity_creation", "layer_management", "blocks", - "dimensions", "analysis", "screenshot_matplotlib", + "file_read_write", + "dxf_export", + "pdf_export", + "entity_creation", + "layer_management", + "blocks", + "dimensions", + "analysis", + "screenshot_matplotlib", ], } @@ -1888,6 +2315,7 @@ class EzdxfBackend(AutoCADBackend): def _sync(): doc = self._require_doc() return doc.header.get(f"${name.upper()}", None) + return await self._async(_sync) async def system_set_variable(self, name, value) -> dict: @@ -1896,6 +2324,7 @@ class EzdxfBackend(AutoCADBackend): doc.header[f"${name.upper()}"] = value self._mark_dirty() return {"ok": True, "variable": name, "value": value} + return await self._async(_sync) async def system_run_command(self, command) -> dict: @@ -1926,6 +2355,7 @@ class EzdxfBackend(AutoCADBackend): async def entity_trim(self, target_handle, cutter_handle, keep_x, keep_y) -> EntityInfo: def _sync(): from ezdxf.math import Vec2, intersection_line_line_2d + target = self._get_entity(target_handle) cutter = self._get_entity(cutter_handle) if target.dxftype() != "LINE" or cutter.dxftype() != "LINE": @@ -1958,13 +2388,19 @@ class EzdxfBackend(AutoCADBackend): target.dxf.start = (ix, iy, 0.0) self._mark_dirty() return _entity_info_dxf(target) + return await self._async(_sync) async def entity_extend( - self, target_handle, boundary_handle, end_x=None, end_y=None, + self, + target_handle, + boundary_handle, + end_x=None, + end_y=None, ) -> EntityInfo: def _sync(): from ezdxf.math import Vec2, intersection_line_line_2d + target = self._get_entity(target_handle) boundary = self._get_entity(boundary_handle) if target.dxftype() != "LINE" or boundary.dxftype() != "LINE": @@ -2000,6 +2436,7 @@ class EzdxfBackend(AutoCADBackend): target.dxf.end = (ix, iy, 0.0) self._mark_dirty() return _entity_info_dxf(target) + return await self._async(_sync) def _fillet_chamfer_setup(self, handle1: str, handle2: str, op: str): @@ -2007,6 +2444,7 @@ class EzdxfBackend(AutoCADBackend): Returns (line1, line2, P, d1, d2, A_far, B_far, theta) where d1/d2 are unit vectors from intersection P toward the far endpoint of each line.""" from ezdxf.math import Vec2, intersection_line_line_2d + line1 = self._get_entity(handle1) line2 = self._get_entity(handle2) if line1.dxftype() != "LINE" or line2.dxftype() != "LINE": @@ -2048,14 +2486,14 @@ class EzdxfBackend(AutoCADBackend): async def entity_fillet(self, handle1, handle2, radius, trim=True) -> EntityInfo: def _sync(): - line1, line2, P, d1, d2, A_far, B_far, theta = ( - self._fillet_chamfer_setup(handle1, handle2, "fillet") + line1, line2, P, d1, d2, A_far, B_far, theta = self._fillet_chamfer_setup( + handle1, handle2, "fillet" ) r = float(radius) if r < 0: raise RuntimeError("entity_fillet: radius must be >= 0.") half = theta / 2.0 - s = r / math.tan(half) if r > 0 else 0.0 # tangent distance from P + s = r / math.tan(half) if r > 0 else 0.0 # tangent distance from P T1 = (P[0] + s * d1[0], P[1] + s * d1[1]) T2 = (P[0] + s * d2[0], P[1] + s * d2[1]) msp = self._msp() @@ -2067,15 +2505,13 @@ class EzdxfBackend(AutoCADBackend): if bl < 1e-12: raise RuntimeError("entity_fillet: bisector is degenerate.") bux, buy = bx / bl, by / bl - C = (P[0] + (r / math.sin(half)) * bux, - P[1] + (r / math.sin(half)) * buy) + C = (P[0] + (r / math.sin(half)) * bux, P[1] + (r / math.sin(half)) * buy) # Arc start/end angles in degrees, CCW. Pick the orientation # that yields the short arc (interior fillet) using the cross # product sign of (T1-C) × (T2-C). a1 = math.degrees(math.atan2(T1[1] - C[1], T1[0] - C[0])) a2 = math.degrees(math.atan2(T2[1] - C[1], T2[0] - C[0])) - cross = ((T1[0] - C[0]) * (T2[1] - C[1]) - - (T1[1] - C[1]) * (T2[0] - C[0])) + cross = (T1[0] - C[0]) * (T2[1] - C[1]) - (T1[1] - C[1]) * (T2[0] - C[0]) if cross >= 0: start_a, end_a = a1, a2 else: @@ -2105,14 +2541,20 @@ class EzdxfBackend(AutoCADBackend): return _entity_info_dxf(self._get_entity(arc_handle)) # Zero-radius fillet: no arc; return line1 (the modified first line). return _entity_info_dxf(line1) + return await self._async(_sync) async def entity_chamfer( - self, handle1, handle2, dist1, dist2=None, trim=True, + self, + handle1, + handle2, + dist1, + dist2=None, + trim=True, ) -> EntityInfo: def _sync(): - line1, line2, P, d1, d2, A_far, B_far, theta = ( - self._fillet_chamfer_setup(handle1, handle2, "chamfer") + line1, line2, P, d1, d2, A_far, B_far, theta = self._fillet_chamfer_setup( + handle1, handle2, "chamfer" ) d1v = float(dist1) d2v = float(dist1 if dist2 is None else dist2) @@ -2122,7 +2564,8 @@ class EzdxfBackend(AutoCADBackend): T2 = (P[0] + d2v * d2[0], P[1] + d2v * d2[1]) msp = self._msp() chamfer_line = msp.add_line( - (T1[0], T1[1]), (T2[0], T2[1]), + (T1[0], T1[1]), + (T2[0], T2[1]), dxfattribs={"layer": line1.dxf.layer}, ) if trim: @@ -2138,6 +2581,7 @@ class EzdxfBackend(AutoCADBackend): line2.dxf.end = (T2[0], T2[1], 0.0) self._mark_dirty() return _entity_info_dxf(chamfer_line) + return await self._async(_sync) # ── premium meta-tools live on AutoCADBackend (base.py) and are shared by @@ -2153,4 +2597,5 @@ class EzdxfBackend(AutoCADBackend): ) self._mark_dirty() return _entity_info_dxf(xline) + return await self._async(_sync) diff --git a/config.py b/config.py index 474df77..a260e66 100644 --- a/config.py +++ b/config.py @@ -43,5 +43,16 @@ class Settings: ) self.mcp_auth_token: str = os.environ.get("MCP_AUTH_TOKEN", "").strip() + # Exposed tool profile: "full" (default, everything), "core" (hides the + # raw escape hatches and long-tail tools) or "lean" (a curated ~46-tool + # drafting/inspection core for clients with tight tool caps). Invalid + # values fall back to "full" with a warning at startup. + self.tool_profile: str = os.environ.get("TOOL_PROFILE", "full").lower().strip() + + # Opt-in 3D solids (COM backend only). Disabled by default because the + # headless backend cannot produce ACIS solids and live 3D modifies real + # documents; enable deliberately. + self.enable_3d: bool = os.environ.get("ENABLE_3D", "false").lower().strip() == "true" + settings = Settings() diff --git a/engineering/fits.py b/engineering/fits.py new file mode 100644 index 0000000..4c87186 --- /dev/null +++ b/engineering/fits.py @@ -0,0 +1,181 @@ +"""ISO 286-1/2 limits-and-fits lookup (authored table data, not formulas). + +`fit_lookup("H7", 20.0)` resolves a fit code into upper/lower deviations in +millimetres, ready to feed `tolerances.build_dim_override` (tol_mode +"deviation"). Values are the published ISO 286 table values — the standard's +rounding rules make naive formula evaluation drift by 1 µm or a full step in +places, so the tables are authored and pinned by tests instead. + +Scope (deliberate, documented): + +- Nominal sizes over 1 mm up to and including 500 mm. +- IT grades 4-11 (IT4 is carried for the shaft-k rule and future hole mirrors). +- Shaft letters: d, e, f, g, h, js, k (IT4-7), m, n, p. +- Hole letters: D, E, F, G, H, JS (mirrored clearance letters + H + JS). +- Transition/interference HOLE letters (K, M, N, P) require the ISO 286 + delta-rule and sub-stepped r/s tables; they are intentionally excluded and + raise a ValueError naming the supported set. + +This covers the preferred general-mechanical fits (H7/g6, H7/h6, H7/k6, +H7/n6, H7/p6, H8/f7, H9/d9, G7/h6, F8/h7, JS-symmetric, ...). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +# Size steps: (over, up_to_incl) in mm — ISO 286-1 main steps, 1..500 mm. +_SIZE_STEPS: tuple[tuple[float, float], ...] = ( + (1, 3), + (3, 6), + (6, 10), + (10, 18), + (18, 30), + (30, 50), + (50, 80), + (80, 120), + (120, 180), + (180, 250), + (250, 315), + (315, 400), + (400, 500), +) + +# Standard tolerance values IT4..IT11 in µm per size step (ISO 286-1 Table 1). +_IT_GRADES = (4, 5, 6, 7, 8, 9, 10, 11) +_IT_TABLE: tuple[tuple[int, ...], ...] = ( + # IT4 IT5 IT6 IT7 IT8 IT9 IT10 IT11 + (3, 4, 6, 10, 14, 25, 40, 60), # >1-3 + (4, 5, 8, 12, 18, 30, 48, 75), # >3-6 + (4, 6, 9, 15, 22, 36, 58, 90), # >6-10 + (5, 8, 11, 18, 27, 43, 70, 110), # >10-18 + (6, 9, 13, 21, 33, 52, 84, 130), # >18-30 + (7, 11, 16, 25, 39, 62, 100, 160), # >30-50 + (8, 13, 19, 30, 46, 74, 120, 190), # >50-80 + (10, 15, 22, 35, 54, 87, 140, 220), # >80-120 + (12, 18, 25, 40, 63, 100, 160, 250), # >120-180 + (14, 20, 29, 46, 72, 115, 185, 290), # >180-250 + (16, 23, 32, 52, 81, 130, 210, 320), # >250-315 + (18, 25, 36, 57, 89, 140, 230, 360), # >315-400 + (20, 27, 40, 63, 97, 155, 250, 400), # >400-500 +) + +# Fundamental deviations for shafts in µm per size step (ISO 286-1 Table 2/3). +# d..g carry the upper deviation es (negative); k..p carry the lower +# deviation ei (positive). h is es=0; js is symmetric (±IT/2). +_SHAFT_ES: dict[str, tuple[int, ...]] = { + "d": (-20, -30, -40, -50, -65, -80, -100, -120, -145, -170, -190, -210, -230), + "e": (-14, -20, -25, -32, -40, -50, -60, -72, -85, -100, -110, -125, -135), + "f": (-6, -10, -13, -16, -20, -25, -30, -36, -43, -50, -56, -62, -68), + "g": (-2, -4, -5, -6, -7, -9, -10, -12, -14, -15, -17, -18, -20), +} +_SHAFT_EI: dict[str, tuple[int, ...]] = { + # k applies to IT4..IT7 only (other grades have ei = 0 per the standard). + "k": (1, 1, 1, 1, 2, 2, 2, 3, 3, 4, 4, 4, 5), + "m": (2, 4, 6, 7, 8, 9, 11, 13, 15, 17, 20, 21, 23), + "n": (4, 8, 10, 12, 15, 17, 20, 23, 27, 31, 34, 37, 40), + "p": (6, 12, 15, 18, 22, 26, 32, 37, 43, 50, 56, 62, 68), +} + +SUPPORTED_SHAFT_LETTERS = ("d", "e", "f", "g", "h", "js", "k", "m", "n", "p") +SUPPORTED_HOLE_LETTERS = ("D", "E", "F", "G", "H", "JS") + +_FIT_CODE_RE = re.compile(r"^\s*(JS|js|[A-Ha-h]|[k-pk-p])(\d{1,2})\s*$") + + +@dataclass(frozen=True) +class FitDeviation: + """Resolved fit: deviations in mm relative to the nominal size.""" + + code: str + nominal: float + upper_mm: float + lower_mm: float + it_grade: int + it_value_mm: float + + def to_dict(self) -> dict: + return { + "code": self.code, + "nominal": self.nominal, + "upper_mm": self.upper_mm, + "lower_mm": self.lower_mm, + "it_grade": self.it_grade, + "it_value_mm": self.it_value_mm, + } + + +def parse_fit_code(code: str) -> tuple[str, int]: + """Split e.g. 'H7' -> ('H', 7) or 'js6' -> ('js', 6).""" + match = _FIT_CODE_RE.match(code or "") + if not match: + raise ValueError( + f"Invalid fit code {code!r}. Expected letter + IT grade, e.g. H7, g6, js9." + ) + return match.group(1), int(match.group(2)) + + +def _size_index(nominal_mm: float) -> int: + if not 1.0 < nominal_mm <= 500.0: + raise ValueError( + f"Nominal size {nominal_mm} mm is outside the authored ISO 286 range " + "(over 1 mm up to and including 500 mm)." + ) + for index, (over, up_to) in enumerate(_SIZE_STEPS): + if over < nominal_mm <= up_to: + return index + raise ValueError(f"No ISO 286 size step covers {nominal_mm} mm") # pragma: no cover + + +def _it_um(grade: int, size_index: int) -> int: + if grade not in _IT_GRADES: + raise ValueError(f"IT grade {grade} is outside the authored range IT4-IT11.") + return _IT_TABLE[size_index][_IT_GRADES.index(grade)] + + +def fit_lookup(code: str, nominal_mm: float) -> FitDeviation: + """Resolve a fit code for a nominal size into mm deviations. + + Holes use uppercase letters, shafts lowercase (ISO 286 convention). + Raises ValueError for codes outside the authored scope. + """ + letter, grade = parse_fit_code(code) + index = _size_index(float(nominal_mm)) + it_um = _it_um(grade, index) + + if letter in ("js", "JS"): + upper_um = it_um / 2.0 + lower_um = -it_um / 2.0 + elif letter == "h": + upper_um, lower_um = 0.0, float(-it_um) + elif letter == "H": + upper_um, lower_um = float(it_um), 0.0 + elif letter in _SHAFT_ES: # d, e, f, g — clearance shafts + es = float(_SHAFT_ES[letter][index]) + upper_um, lower_um = es, es - it_um + elif letter in ("D", "E", "F", "G"): # mirrored clearance holes: EI = -es + ei = float(-_SHAFT_ES[letter.lower()][index]) + upper_um, lower_um = ei + it_um, ei + elif letter in _SHAFT_EI: # k, m, n, p — transition/interference shafts + if letter == "k" and not 4 <= grade <= 7: + ei = 0.0 # ISO 286: k has ei = 0 outside IT4-IT7 + else: + ei = float(_SHAFT_EI[letter][index]) + upper_um, lower_um = ei + it_um, ei + else: + raise ValueError( + f"Fit letter {letter!r} is outside the authored scope. Supported: " + f"shafts {SUPPORTED_SHAFT_LETTERS}, holes {SUPPORTED_HOLE_LETTERS}. " + "Transition/interference hole letters (K, M, N, P) need the ISO 286 " + "delta-rule and are not authored yet." + ) + + return FitDeviation( + code=f"{letter}{grade}", + nominal=float(nominal_mm), + upper_mm=round(upper_um / 1000.0, 6), + lower_mm=round(lower_um / 1000.0, 6), + it_grade=grade, + it_value_mm=round(it_um / 1000.0, 6), + ) diff --git a/engineering/tolerances.py b/engineering/tolerances.py index 29c9a62..6b2a23b 100644 --- a/engineering/tolerances.py +++ b/engineering/tolerances.py @@ -50,12 +50,17 @@ def build_dim_override( elif mode == "deviation": if tol_upper is None or tol_lower is None: raise ValueError("tol_mode='deviation' requires tol_upper and tol_lower.") + # DIMTM convention: the displayed lower deviation is -DIMTM. Callers + # pass tol_lower as the magnitude of the minus deviation (0.01 means + # -0.01, legacy contract); a *negative* tol_lower expresses a plus + # lower deviation, which double-positive ISO 286 fits (e.g. p6 + # +0.035/+0.022) require. override.update( { "dimtol": 1, "dimlim": 0, "dimtp": float(tol_upper), - "dimtm": abs(float(tol_lower)), + "dimtm": float(tol_lower), } ) elif mode == "limit": @@ -66,7 +71,7 @@ def build_dim_override( "dimlim": 1, "dimtol": 0, "dimtp": float(tol_upper), - "dimtm": abs(float(tol_lower)), + "dimtm": float(tol_lower), } ) elif mode == "basic": diff --git a/server.py b/server.py index 9c02545..332a7a7 100644 --- a/server.py +++ b/server.py @@ -31,6 +31,7 @@ from fastmcp.server.middleware.timing import TimingMiddleware from pydantic import Field import config +from engineering.fits import fit_lookup from security import sanitize_command, sanitize_lisp, validate_path from version import __version__ @@ -56,12 +57,15 @@ def _detect_autocad_running() -> bool: return False try: import win32gui + result = {"found": False} + def _cb(hwnd, _): if "AutoCAD" in win32gui.GetWindowText(hwnd) and win32gui.IsWindowVisible(hwnd): result["found"] = True return False return True + win32gui.EnumWindows(_cb, None) return result["found"] except Exception as exc: @@ -75,6 +79,7 @@ async def _make_backend(): if backend_env == "ezdxf": from backends.ezdxf_backend import EzdxfBackend + b = EzdxfBackend() await b.connect() return b @@ -83,6 +88,7 @@ async def _make_backend(): if _WIN32: try: from backends.com_backend import ComBackend + b = ComBackend() await b.connect() log.info("Using COM backend (live AutoCAD control)") @@ -90,13 +96,12 @@ async def _make_backend(): except Exception as exc: log.warning("COM backend init failed (%s)", exc) if backend_env == "com": - raise RuntimeError( - f"COM backend requested but failed: {exc}" - ) from exc + raise RuntimeError(f"COM backend requested but failed: {exc}") from exc else: log.warning("COM backend requires Windows; falling back to ezdxf") from backends.ezdxf_backend import EzdxfBackend + b = EzdxfBackend() await b.connect() log.info("Using ezdxf backend (headless mode)") @@ -107,6 +112,7 @@ async def _make_backend(): # Lifespan – backend singleton # --------------------------------------------------------------------------- + @lifespan async def autocad_lifespan(server): """Initialize AutoCAD backend on server start, clean up on stop.""" @@ -117,6 +123,10 @@ async def autocad_lifespan(server): "DISABLED. The server will execute any AutoCAD command or LISP expression " "the client sends. Do NOT enable this on a network-reachable instance." ) + try: + await _apply_tool_profile() + except Exception as exc: # profile trouble must never block startup + log.warning("Tool profile could not be applied: %s", exc) try: backend = await _make_backend() log.info("Backend ready: %s", backend.name) @@ -132,6 +142,7 @@ async def autocad_lifespan(server): # Middleware: audit log for destructive operations # --------------------------------------------------------------------------- + class AuditMiddleware(Middleware): """Log all tool calls with timing for audit trail.""" @@ -225,6 +236,7 @@ mcp.add_middleware(LoggingMiddleware()) # Backend access helper # --------------------------------------------------------------------------- + def _backend(ctx: Context): """Get the backend from lifespan context, raising ToolError if not ready.""" b = ctx.lifespan_context.get("backend") @@ -288,6 +300,8 @@ _GROUP_TAG_PRIORITY = ( "template", "dimension", "transaction", + "layout", + "solid", "view", "validation", "analysis", @@ -310,6 +324,8 @@ _GROUP_TAG_LABELS = { "template": "templates", "dimension": "dimensions", "transaction": "transactions", + "layout": "layouts", + "solid": "solids", "view": "view", "analysis": "analysis", "validation": "validation", @@ -351,8 +367,158 @@ async def _tool_groups() -> dict: # --------------------------------------------------------------------------- -# ── SECTION 1: Drawing Management (11 tools) ──────────────────────────────── +# Tool profiles (capability-aware discovery) # --------------------------------------------------------------------------- +# Some MCP clients degrade (or hard-cap) when a server exposes 100+ tools. +# TOOL_PROFILE selects how much of the surface is advertised: +# full — everything (default, backwards compatible) +# core — everything except raw escape hatches and long-tail tools +# lean — a curated ~46-tool drafting/inspection core +# Disabled tools stay registered (system_about still reports the full +# inventory) but are hidden from MCP list_tools and rejected if called. + +TOOL_PROFILES = ("lean", "core", "full") + +LEAN_TOOL_NAMES = frozenset( + { + # drawing management + "drawing_new", + "drawing_open", + "drawing_save", + "drawing_save_as", + "drawing_export_dxf", + "drawing_export_pdf", + "drawing_info", + "drawing_audit", + "drawing_close", + # entity creation + "entity_create_line", + "entity_create_circle", + "entity_create_arc", + "entity_create_rectangle", + "entity_create_polyline", + "entity_create_text", + "entity_create_mtext", + # entity modification + "entity_move", + "entity_copy", + "entity_rotate", + "entity_scale", + "entity_mirror", + "entity_offset", + "entity_delete", + "entity_set_properties", + "entity_trim", + "entity_fillet", + # query + "entity_get", + "entity_list", + "entity_delete_many", + # layers + "layer_create", + "layer_list", + "layer_set_current", + "layer_modify", + "layer_delete", + # dimensions + "dimension_linear", + "dimension_aligned", + "dimension_radius", + "dimension_diameter", + # view + "view_zoom_extents", + "view_screenshot", + # transactions + "transaction_begin", + "transaction_commit", + "transaction_rollback", + # system + "system_status", + "system_about", + "system_capabilities", + } +) + +CORE_EXCLUDED_TOOL_NAMES = frozenset( + { + # raw escape hatches — deliberate opt-in only + "system_run_command", + "system_run_lisp", + # low-level variable access (drawing_settings covers the common cases) + "system_get_variable", + "system_set_variable", + # long-tail / niche tools + "selection_get", + "linetype_list", + "linetype_load", + "block_find_references", + "drawing_undo", + "drawing_redo", + "view_zoom_window", + "entity_create_point", + } +) + +SOLID_TOOL_NAMES = frozenset( + {"solid_box", "solid_cylinder", "solid_extrude", "solid_revolve", "solid_boolean"} +) + +_active_tool_profile: dict | None = None + + +def _profile_enabled_names(profile: str, registered: set[str]) -> set[str]: + if profile == "lean": + enabled = registered & LEAN_TOOL_NAMES + elif profile == "core": + enabled = registered - CORE_EXCLUDED_TOOL_NAMES + else: + enabled = set(registered) + if not config.settings.enable_3d: + # Capability-aware discovery: don't advertise opt-in 3D tools that + # would only reject the call. + enabled -= SOLID_TOOL_NAMES + return enabled + + +async def _apply_tool_profile(profile: str | None = None) -> dict: + """Enable/disable registered tools according to the selected profile. + + Runs in the server lifespan (and directly from tests). Re-applying a + different profile re-enables previously hidden tools first, so profile + switches are idempotent. + """ + global _active_tool_profile + selected = (profile or config.settings.tool_profile or "full").lower().strip() + if selected not in TOOL_PROFILES: + log.warning("Unknown TOOL_PROFILE %r - falling back to 'full'", selected) + selected = "full" + registered = {tool.name for tool in await _registered_tools() if getattr(tool, "name", None)} + enabled = _profile_enabled_names(selected, registered) + disabled = sorted(registered - enabled) + if enabled: + mcp.enable(names=set(enabled)) + if disabled: + mcp.disable(names=set(disabled)) + _active_tool_profile = { + "profile": selected, + "registered_count": len(registered), + "enabled_count": len(enabled), + "disabled_count": len(disabled), + "disabled_tools": disabled, + } + log.info( + "Tool profile '%s': %d enabled, %d hidden", + selected, + len(enabled), + len(disabled), + ) + return _active_tool_profile + + +# --------------------------------------------------------------------------- +# ── SECTION 1: Drawing Management (12 tools) ──────────────────────────────── +# --------------------------------------------------------------------------- + @mcp.tool( annotations={"title": "Drawing Info", "readOnlyHint": True}, @@ -375,11 +541,14 @@ async def drawing_info(ctx: Context) -> dict: ) async def drawing_new( template: Annotated[str | None, "Optional path to .dwt template file"] = None, - bootstrap: Annotated[bool, Field( - description="Auto-load CENTER/HIDDEN/PHANTOM linetypes and create standard " - "engineering layers (GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, " - "HATCH, TEXT, TITLEBLOCK). Disable for vanilla DXF.", - )] = True, + bootstrap: Annotated[ + bool, + Field( + description="Auto-load CENTER/HIDDEN/PHANTOM linetypes and create standard " + "engineering layers (GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, " + "HATCH, TEXT, TITLEBLOCK). Disable for vanilla DXF.", + ), + ] = True, ctx: Context = None, ) -> dict: """Create a new empty drawing, optionally from a template (.dwt). @@ -401,6 +570,7 @@ async def drawing_new( ensure_engineering_layers, ensure_standard_linetypes, ) + lt_status = await ensure_standard_linetypes(backend) layer_status = await ensure_engineering_layers(backend) result["bootstrap"] = {"ok": True, "linetypes": lt_status, "layers": layer_status} @@ -450,8 +620,11 @@ async def drawing_save( ) async def drawing_save_as( path: Annotated[str, "Full destination path including extension"], - format: Annotated[str, "Output format override: dwg, dxf, dwt. Default: derive " - "from the path extension (the extension is authoritative)."] = "", + format: Annotated[ + str, + "Output format override: dwg, dxf, dwt. Default: derive " + "from the path extension (the extension is authoritative).", + ] = "", ctx: Context = None, ) -> dict: """Save current drawing to a new path/format (DWG, DXF, or DWT template). @@ -462,6 +635,7 @@ async def drawing_save_as( """ validated = validate_path(path, allow_write=True) from pathlib import Path as _P + ext = _P(str(validated)).suffix.lstrip(".").lower() fmt = ext if ext in ("dxf", "dwg", "dwt") else (format.lower() or "dxf") await ctx.info(f"Saving as {fmt}: {validated}") @@ -488,13 +662,18 @@ async def drawing_export_dxf( ) async def drawing_export_pdf( path: Annotated[str, "Output .pdf file path"], + layout: Annotated[ + str | None, + "Paper-space layout to plot (default: model space). COM plots the layout " + "natively incl. viewport content; ezdxf renders the layout's own entities.", + ] = None, ctx: Context = None, ) -> dict: - """Export the current drawing to PDF.""" + """Export the current drawing (or a paper-space layout) to PDF.""" validated = validate_path(path, allow_write=True) await ctx.info(f"Exporting PDF: {validated}") await ctx.report_progress(0, 100) - result = await _backend(ctx).drawing_export_pdf(str(validated)) + result = await _backend(ctx).drawing_export_pdf(str(validated), layout=layout) await ctx.report_progress(100, 100) return result @@ -553,9 +732,10 @@ async def drawing_redo(ctx: Context = None) -> dict: # --------------------------------------------------------------------------- -# ── SECTION 2: Entity Creation (13 tools) ─────────────────────────────────── +# ── SECTION 2: Entity Creation (14 tools) ─────────────────────────────────── # --------------------------------------------------------------------------- + @mcp.tool( annotations={"title": "Create Line", "readOnlyHint": False}, tags={"entity", "create"}, @@ -612,7 +792,9 @@ async def entity_create_arc( ) -> dict: """Create a circular arc. Angles are in degrees, measured counter-clockwise from the positive X axis.""" await ctx.debug(f"Creating arc center=({cx},{cy}) r={radius} {start_angle}°→{end_angle}°") - result = await _backend(ctx).entity_create_arc(cx, cy, radius, start_angle, end_angle, layer, color) + result = await _backend(ctx).entity_create_arc( + cx, cy, radius, start_angle, end_angle, layer, color + ) return _dc(result) @@ -684,7 +866,9 @@ async def entity_create_text( tags={"entity", "create", "annotation"}, ) async def entity_create_mtext( - text: Annotated[str, "Text content (supports \\P for paragraph breaks, {\\H...;} for formatting)"], + text: Annotated[ + str, "Text content (supports \\P for paragraph breaks, {\\H...;} for formatting)" + ], x: Annotated[float, "Insertion point X"], y: Annotated[float, "Insertion point Y"], width: Annotated[float, "Text box width in drawing units"] = 100.0, @@ -696,7 +880,9 @@ async def entity_create_mtext( ) -> dict: """Create a multi-line text entity (MTEXT) with word-wrap at the specified width.""" await ctx.debug(f"Creating mtext at ({x},{y}) w={width}") - result = await _backend(ctx).entity_create_mtext(text, x, y, width, height, rotation, layer, color) + result = await _backend(ctx).entity_create_mtext( + text, x, y, width, height, rotation, layer, color + ) return _dc(result) @@ -758,7 +944,9 @@ async def entity_create_hatch( ) -> dict: """Create a hatch fill pattern inside a closed boundary polygon.""" await ctx.debug(f"Creating hatch pattern={pattern} scale={scale}") - result = await _backend(ctx).entity_create_hatch(pattern, boundary_points, scale, angle, layer, color) + result = await _backend(ctx).entity_create_hatch( + pattern, boundary_points, scale, angle, layer, color + ) return _dc(result) @@ -787,14 +975,20 @@ async def entity_create_ellipse( cy: Annotated[float, "Center Y"], major_x: Annotated[float, "Major axis endpoint X (relative to center)"], major_y: Annotated[float, "Major axis endpoint Y (relative to center)"], - ratio: Annotated[float, Field(description="Minor-to-major axis ratio (0 < ratio ≤ 1)", gt=0, le=1)] = 0.5, + ratio: Annotated[ + float, Field(description="Minor-to-major axis ratio (0 < ratio ≤ 1)", gt=0, le=1) + ] = 0.5, layer: Annotated[str | None, "Layer name"] = None, color: Annotated[int | None, "ACI color code"] = None, ctx: Context = None, ) -> dict: """Create an ellipse. major_x/major_y define the major axis vector from the center.""" - await ctx.debug(f"Creating ellipse center=({cx},{cy}) major=({major_x},{major_y}) ratio={ratio}") - result = await _backend(ctx).entity_create_ellipse(cx, cy, major_x, major_y, ratio, layer, color) + await ctx.debug( + f"Creating ellipse center=({cx},{cy}) major=({major_x},{major_y}) ratio={ratio}" + ) + result = await _backend(ctx).entity_create_ellipse( + cx, cy, major_x, major_y, ratio, layer, color + ) return _dc(result) @@ -830,7 +1024,9 @@ async def entity_create_block_ref( ) -> dict: """Insert a block reference (instance of an existing block definition).""" await ctx.debug(f"Inserting block '{name}' at ({x},{y})") - result = await _backend(ctx).entity_create_block_ref(name, x, y, scale_x, scale_y, rotation, layer) + result = await _backend(ctx).entity_create_block_ref( + name, x, y, scale_x, scale_y, rotation, layer + ) return _dc(result) @@ -838,6 +1034,37 @@ async def entity_create_block_ref( # ── SECTION 3: Dimensions (5 tools) ───────────────────────────────────────── # --------------------------------------------------------------------------- + +def _fit_to_tolerances( + fit: str | None, + nominal: float, + tol_upper: float | None, + tol_lower: float | None, + tol_mode: str, + text_override: str | None, +) -> tuple[float | None, float | None, str, str | None]: + """Resolve an ISO 286 fit code (e.g. 'H7') into the tolerance contract. + + Mutually exclusive with explicit tol_* values. Returns + (tol_upper, tol_lower, tol_mode, text_override) where tol_lower follows + the build_dim_override convention (positive = minus deviation). + """ + if not fit: + return tol_upper, tol_lower, tol_mode, text_override + if tol_upper is not None or tol_lower is not None or (tol_mode or "none") != "none": + raise ToolError("Pass either fit= or explicit tol_* values, not both.") + try: + deviation = fit_lookup(fit, nominal) + except ValueError as exc: + raise ToolError(str(exc)) from exc + return ( + deviation.upper_mm, + -deviation.lower_mm, + "deviation", + text_override or f"<> {deviation.code}", + ) + + @mcp.tool( annotations={"title": "Linear Dimension", "readOnlyHint": False}, tags={"annotation", "dimension"}, @@ -853,16 +1080,43 @@ async def dimension_linear( layer: Annotated[str | None, "Layer name"] = None, tol_upper: Annotated[float | None, "Upper deviation (mm), e.g. 0.02 for +0.02"] = None, tol_lower: Annotated[float | None, "Lower deviation (mm), e.g. 0.01 for -0.01"] = None, - tol_mode: Annotated[str, Field(default="none", - description="ISO 129 tolerance display: none | symmetric (±tol_upper) | " - "deviation (+tol_upper/-tol_lower) | limit (stacked limits) | basic (boxed).")] = "none", - text_override: Annotated[str | None, "Replace the measured text ('<>' keeps the measurement)"] = None, + tol_mode: Annotated[ + str, + Field( + default="none", + description="ISO 129 tolerance display: none | symmetric (±tol_upper) | " + "deviation (+tol_upper/-tol_lower) | limit (stacked limits) | basic (boxed).", + ), + ] = "none", + text_override: Annotated[ + str | None, "Replace the measured text ('<>' keeps the measurement)" + ] = None, + fit: Annotated[ + str | None, + "ISO 286 fit code (e.g. 'H7', 'g6', 'js9'); resolves deviations from the " + "authored tables for the measured nominal. Mutually exclusive with tol_*.", + ] = None, ctx: Context = None, ) -> dict: - """Create a linear dimension, optionally with an ISO 129 tolerance callout.""" + """Create a linear dimension, optionally toleranced (ISO 129 or ISO 286 fit).""" + angle = math.radians(rotation) + nominal = abs((x2 - x1) * math.cos(angle) + (y2 - y1) * math.sin(angle)) + tol_upper, tol_lower, tol_mode, text_override = _fit_to_tolerances( + fit, nominal, tol_upper, tol_lower, tol_mode, text_override + ) result = await _backend(ctx).dimension_linear( - x1, y1, x2, y2, dim_x, dim_y, rotation, layer, - tol_upper, tol_lower, tol_mode, text_override, + x1, + y1, + x2, + y2, + dim_x, + dim_y, + rotation, + layer, + tol_upper, + tol_lower, + tol_mode, + text_override, ) return _dc(result) @@ -922,15 +1176,38 @@ async def dimension_radius( layer: Annotated[str | None, "Layer name"] = None, tol_upper: Annotated[float | None, "Upper deviation (mm)"] = None, tol_lower: Annotated[float | None, "Lower deviation (mm)"] = None, - tol_mode: Annotated[str, Field(default="none", - description="ISO 129 tolerance display: none | symmetric | deviation | limit | basic.")] = "none", - text_override: Annotated[str | None, "Replace the measured text ('<>' keeps the measurement)"] = None, + tol_mode: Annotated[ + str, + Field( + default="none", + description="ISO 129 tolerance display: none | symmetric | deviation | limit | basic.", + ), + ] = "none", + text_override: Annotated[ + str | None, "Replace the measured text ('<>' keeps the measurement)" + ] = None, + fit: Annotated[ + str | None, + "ISO 286 fit code resolved for the measured radius value. Mutually exclusive with tol_*.", + ] = None, ctx: Context = None, ) -> dict: """Create a radius dimension for a circle or arc, optionally toleranced.""" + nominal = math.dist((center_x, center_y), (chord_x, chord_y)) + tol_upper, tol_lower, tol_mode, text_override = _fit_to_tolerances( + fit, nominal, tol_upper, tol_lower, tol_mode, text_override + ) result = await _backend(ctx).dimension_radius( - center_x, center_y, chord_x, chord_y, leader_length, layer, - tol_upper, tol_lower, tol_mode, text_override, + center_x, + center_y, + chord_x, + chord_y, + leader_length, + layer, + tol_upper, + tol_lower, + tol_mode, + text_override, ) return _dc(result) @@ -948,23 +1225,48 @@ async def dimension_diameter( layer: Annotated[str | None, "Layer name"] = None, tol_upper: Annotated[float | None, "Upper deviation (mm)"] = None, tol_lower: Annotated[float | None, "Lower deviation (mm)"] = None, - tol_mode: Annotated[str, Field(default="none", - description="ISO 129 tolerance display: none | symmetric | deviation | limit | basic.")] = "none", - text_override: Annotated[str | None, "Replace the measured text ('<>' keeps the measurement)"] = None, + tol_mode: Annotated[ + str, + Field( + default="none", + description="ISO 129 tolerance display: none | symmetric | deviation | limit | basic.", + ), + ] = "none", + text_override: Annotated[ + str | None, "Replace the measured text ('<>' keeps the measurement)" + ] = None, + fit: Annotated[ + str | None, + "ISO 286 fit code (e.g. 'H7' hole / 'g6' shaft) resolved for the measured " + "diameter. Mutually exclusive with tol_*.", + ] = None, ctx: Context = None, ) -> dict: """Create a diameter dimension for a circle, optionally toleranced (e.g. ⌀20 H7).""" + nominal = math.dist((x1, y1), (x2, y2)) + tol_upper, tol_lower, tol_mode, text_override = _fit_to_tolerances( + fit, nominal, tol_upper, tol_lower, tol_mode, text_override + ) result = await _backend(ctx).dimension_diameter( - x1, y1, x2, y2, leader_length, layer, - tol_upper, tol_lower, tol_mode, text_override, + x1, + y1, + x2, + y2, + leader_length, + layer, + tol_upper, + tol_lower, + tol_mode, + text_override, ) return _dc(result) # --------------------------------------------------------------------------- -# ── SECTION 4: Entity Modification (10 tools) ─────────────────────────────── +# ── SECTION 4: Entity Modification (16 tools) ─────────────────────────────── # --------------------------------------------------------------------------- + @mcp.tool( annotations={"title": "Move Entity", "readOnlyHint": False, "destructiveHint": False}, tags={"entity", "modify"}, @@ -1065,6 +1367,7 @@ async def entity_offset( # ── corner operations (trim/extend/fillet/chamfer) ────────────────────────── + @mcp.tool( annotations={"title": "Trim Entity", "readOnlyHint": False, "destructiveHint": False}, tags={"entity", "modify", "corner"}, @@ -1112,8 +1415,12 @@ async def entity_extend( async def entity_fillet( handle1: Annotated[str, "First entity handle"], handle2: Annotated[str, "Second entity handle"], - radius: Annotated[float, Field(description="Fillet radius (>= 0; 0 = sharp corner / corner-merge)", ge=0.0)], - trim: Annotated[bool, "If true, trim source entities to the tangent points (AutoCAD default)"] = True, + radius: Annotated[ + float, Field(description="Fillet radius (>= 0; 0 = sharp corner / corner-merge)", ge=0.0) + ], + trim: Annotated[ + bool, "If true, trim source entities to the tangent points (AutoCAD default)" + ] = True, ctx: Context = None, ) -> dict: """Round a corner with a tangent arc. Returns info on the new ARC entity @@ -1130,8 +1437,12 @@ async def entity_chamfer( handle1: Annotated[str, "First entity handle"], handle2: Annotated[str, "Second entity handle"], dist1: Annotated[float, Field(description="Chamfer distance along first line", gt=0.0)], - dist2: Annotated[float | None, "Chamfer distance along second line (None = symmetric, dist2=dist1)"] = None, - trim: Annotated[bool, "If true, trim source entities to the tangent points (AutoCAD default)"] = True, + dist2: Annotated[ + float | None, "Chamfer distance along second line (None = symmetric, dist2=dist1)" + ] = None, + trim: Annotated[ + bool, "If true, trim source entities to the tangent points (AutoCAD default)" + ] = True, ctx: Context = None, ) -> dict: """Bevel a corner with a chamfer line. Returns info on the new chamfer LINE. @@ -1169,7 +1480,9 @@ async def entity_array_rectangular( await ctx.info(f"Creating {rows}×{cols} rectangular array of entity {handle}") total = rows * cols await ctx.report_progress(0, total) - result = await _backend(ctx).entity_array_rectangular(handle, rows, cols, row_spacing, col_spacing) + result = await _backend(ctx).entity_array_rectangular( + handle, rows, cols, row_spacing, col_spacing + ) await ctx.report_progress(total, total) return [_dc(e) for e in result] @@ -1200,14 +1513,18 @@ async def entity_set_properties( handle: Annotated[str, "Entity handle"], layer: Annotated[str | None, "New layer name"] = None, color: Annotated[int | None, "New ACI color (256=ByLayer, 0=ByBlock, 1-255=specific)"] = None, - linetype: Annotated[str | None, "New linetype name (e.g. 'DASHED', 'CENTER', 'ByLayer')"] = None, + linetype: Annotated[ + str | None, "New linetype name (e.g. 'DASHED', 'CENTER', 'ByLayer')" + ] = None, lineweight: Annotated[int | None, "Lineweight in 0.01mm units (-3=ByLayer, -2=ByBlock)"] = None, visible: Annotated[bool | None, "Set entity visibility"] = None, ctx: Context = None, ) -> dict: """Change one or more properties of an entity (layer, color, linetype, lineweight, visibility).""" await ctx.debug(f"Setting properties for entity {handle}") - return await _backend(ctx).entity_set_properties(handle, layer, color, linetype, lineweight, visible) + return await _backend(ctx).entity_set_properties( + handle, layer, color, linetype, lineweight, visible + ) @mcp.tool( @@ -1255,7 +1572,16 @@ async def entity_edit_geometry( """ await ctx.debug(f"Editing geometry of entity {handle}") result = await _backend(ctx).entity_edit_geometry( - handle, cx, cy, radius, x1, y1, x2, y2, start_angle, end_angle, + handle, + cx, + cy, + radius, + x1, + y1, + x2, + y2, + start_angle, + end_angle, ) return _dc(result) @@ -1264,6 +1590,7 @@ async def entity_edit_geometry( # ── SECTION 5: Entity Query (4 tools) ─────────────────────────────────────── # --------------------------------------------------------------------------- + @mcp.tool( annotations={"title": "Get Entity", "readOnlyHint": True}, tags={"entity", "query"}, @@ -1282,7 +1609,10 @@ async def entity_get( tags={"entity", "query"}, ) async def entity_list( - type_filter: Annotated[str | None, "Filter by entity type: LINE, CIRCLE, ARC, LWPOLYLINE, TEXT, MTEXT, INSERT, HATCH, etc."] = None, + type_filter: Annotated[ + str | None, + "Filter by entity type: LINE, CIRCLE, ARC, LWPOLYLINE, TEXT, MTEXT, INSERT, HATCH, etc.", + ] = None, layer_filter: Annotated[str | None, "Filter by layer name"] = None, limit: Annotated[int, Field(description="Maximum entities to return", ge=1, le=1000)] = 100, offset: Annotated[int, Field(description="Number of entities to skip", ge=0)] = 0, @@ -1304,7 +1634,11 @@ async def entity_list( @mcp.tool( - annotations={"title": "Delete Multiple Entities", "readOnlyHint": False, "destructiveHint": True}, + annotations={ + "title": "Delete Multiple Entities", + "readOnlyHint": False, + "destructiveHint": True, + }, tags={"entity", "modify"}, ) async def entity_delete_many( @@ -1364,9 +1698,10 @@ async def selection_get(ctx: Context = None) -> dict: # --------------------------------------------------------------------------- -# ── SECTION 6: Layer Management (12 tools) ────────────────────────────────── +# ── SECTION 6: Layer Management (14 tools) ────────────────────────────────── # --------------------------------------------------------------------------- + @mcp.tool( annotations={"title": "List Layers", "readOnlyHint": True}, tags={"layer", "query"}, @@ -1385,7 +1720,9 @@ async def layer_create( name: Annotated[str, "New layer name"], color: Annotated[int, "ACI color code (1=Red, 2=Yellow, 3=Green, 4=Cyan, 5=Blue, 7=White)"] = 7, linetype: Annotated[str, "Linetype name"] = "Continuous", - lineweight: Annotated[int, "Lineweight (-3=ByLayer, 0=0.00mm, 13=0.13mm, 25=0.25mm, 50=0.50mm)"] = -3, + lineweight: Annotated[ + int, "Lineweight (-3=ByLayer, 0=0.00mm, 13=0.13mm, 25=0.25mm, 50=0.50mm)" + ] = -3, ctx: Context = None, ) -> dict: """Create a new layer with specified properties.""" @@ -1528,7 +1865,7 @@ async def linetype_load( file: Annotated[ str | None, "Optional .lin file. Defaults to acadiso.lin (metric) or acad.lin " - "(imperial), chosen from MEASUREMENT. Ignored by ezdxf backend." + "(imperial), chosen from MEASUREMENT. Ignored by ezdxf backend.", ] = None, ctx: Context = None, ) -> dict: @@ -1547,6 +1884,7 @@ async def linetype_load( # ── SECTION 7: Block Operations (7 tools) ─────────────────────────────────── # --------------------------------------------------------------------------- + @mcp.tool( annotations={"title": "List Blocks", "readOnlyHint": True}, tags={"block", "query"}, @@ -1574,7 +1912,9 @@ async def block_insert( ) -> dict: """Insert a block and optionally set attribute values.""" await ctx.info(f"Inserting block '{name}' at ({x},{y})") - result = await _backend(ctx).block_insert(name, x, y, scale_x, scale_y, rotation, attributes, layer) + result = await _backend(ctx).block_insert( + name, x, y, scale_x, scale_y, rotation, attributes, layer + ) return _dc(result) @@ -1655,6 +1995,7 @@ async def block_find_references( # ── SECTION 8: Analysis & Query (8 tools) ─────────────────────────────────── # --------------------------------------------------------------------------- + @mcp.tool( annotations={"title": "Entity Statistics", "readOnlyHint": True}, tags={"analysis", "query"}, @@ -1724,8 +2065,10 @@ async def analysis_measure_area( area = await _backend(ctx).analysis_measure_area(points) # Also compute perimeter perimeter = sum( - math.sqrt((points[(i+1) % len(points)][0] - points[i][0]) ** 2 + - (points[(i+1) % len(points)][1] - points[i][1]) ** 2) + math.sqrt( + (points[(i + 1) % len(points)][0] - points[i][0]) ** 2 + + (points[(i + 1) % len(points)][1] - points[i][1]) ** 2 + ) for i in range(len(points)) ) return { @@ -1767,7 +2110,10 @@ async def analysis_select_by_layer( tags={"analysis", "query"}, ) async def analysis_select_by_type( - entity_type: Annotated[str, "Entity type: LINE, CIRCLE, ARC, LWPOLYLINE, TEXT, MTEXT, INSERT, HATCH, SPLINE, ELLIPSE"], + entity_type: Annotated[ + str, + "Entity type: LINE, CIRCLE, ARC, LWPOLYLINE, TEXT, MTEXT, INSERT, HATCH, SPLINE, ELLIPSE", + ], ctx: Context = None, ) -> list[dict]: """Get all entities of a specific type. Returns entity list with handles.""" @@ -1793,7 +2139,9 @@ async def analysis_layer_stats(ctx: Context = None) -> dict: await ctx.report_progress(20, 100) all_entities = await b.entity_list(limit=50000) await ctx.report_progress(80, 100) - layer_data: dict[str, dict] = {lyr.name: {"layer": _dc(lyr), "count": 0, "types": {}} for lyr in layers} + layer_data: dict[str, dict] = { + lyr.name: {"layer": _dc(lyr), "count": 0, "types": {}} for lyr in layers + } for ent in all_entities: lyr_name = ent.layer if lyr_name not in layer_data: @@ -1812,12 +2160,16 @@ async def analysis_layer_stats(ctx: Context = None) -> dict: # ── SECTION 8b: Batch Operations (2 tools) ─────────────────────────────── # --------------------------------------------------------------------------- + @mcp.tool( annotations={"title": "Batch Create Entities", "readOnlyHint": False}, tags={"entity", "create", "batch"}, ) async def entity_batch_create( - entities: Annotated[list[dict], "List of entity definitions. Each dict must have 'type' and type-specific params. Types: line, circle, arc, polyline, rectangle, text, point"], + entities: Annotated[ + list[dict], + "List of entity definitions. Each dict must have 'type' and type-specific params. Types: line, circle, arc, polyline, rectangle, text, point", + ], ctx: Context = None, ) -> dict: """Create multiple entities in a single call for better performance. @@ -1869,7 +2221,10 @@ async def entity_batch_create( tags={"entity", "modify", "batch"}, ) async def entity_batch_modify( - operations: Annotated[list[dict], "List of operations. Each dict: {handle, action, ...params}. Actions: move(dx,dy), rotate(base_x,base_y,angle_deg), scale(base_x,base_y,factor), delete, set_properties(layer,color,...)"], + operations: Annotated[ + list[dict], + "List of operations. Each dict: {handle, action, ...params}. Actions: move(dx,dy), rotate(base_x,base_y,angle_deg), scale(base_x,base_y,factor), delete, set_properties(layer,color,...)", + ], ctx: Context = None, ) -> dict: """Apply multiple modifications in a single call. @@ -2023,12 +2378,15 @@ async def template_list(ctx: Context = None) -> dict: # ── SECTION 8d: Validation (1 tool) ────────────────────────────────────── # --------------------------------------------------------------------------- + @mcp.tool( annotations={"title": "Validate Drawing", "readOnlyHint": True}, tags={"analysis", "validation"}, ) async def validation_check( - checks: Annotated[list[str], "List of checks: empty_layers, zero_length, duplicate_entities"] = None, + checks: Annotated[ + list[str], "List of checks: empty_layers, zero_length, duplicate_entities" + ] = None, ctx: Context = None, ) -> dict: """Run quality checks on the current drawing. @@ -2051,12 +2409,14 @@ async def validation_check( used_layers = {e.layer for e in all_entities} for lyr in layers: if lyr.name != "0" and lyr.name not in used_layers: - issues.append({ - "check": "empty_layers", - "severity": "info", - "message": f"Layer '{lyr.name}' has no entities", - "layer": lyr.name, - }) + issues.append( + { + "check": "empty_layers", + "severity": "info", + "message": f"Layer '{lyr.name}' has no entities", + "layer": lyr.name, + } + ) if "zero_length" in checks: lines = await b.entity_list(type_filter="LINE", limit=10000) @@ -2069,12 +2429,14 @@ async def validation_check( dy = end[1] - start[1] length = (dx * dx + dy * dy) ** 0.5 if length < 0.001: - issues.append({ - "check": "zero_length", - "severity": "warning", - "message": f"Zero-length line at ({start[0]:.1f}, {start[1]:.1f})", - "handle": line.handle, - }) + issues.append( + { + "check": "zero_length", + "severity": "warning", + "message": f"Zero-length line at ({start[0]:.1f}, {start[1]:.1f})", + "handle": line.handle, + } + ) return { "ok": len(issues) == 0, @@ -2085,9 +2447,10 @@ async def validation_check( # --------------------------------------------------------------------------- -# ── SECTION 9: View & Screenshot (5 tools) ────────────────────────────────── +# ── SECTION 9: View & Screenshot (4 tools) ────────────────────────────────── # --------------------------------------------------------------------------- + @mcp.tool( annotations={"title": "Zoom Extents", "readOnlyHint": False, "destructiveHint": False}, tags={"view"}, @@ -2184,6 +2547,7 @@ async def view_zoom_and_screenshot( # ── SECTION 10: Transactions (3 tools) ────────────────────────────────────── # --------------------------------------------------------------------------- + @mcp.tool( annotations={"title": "Begin Transaction", "readOnlyHint": False, "destructiveHint": False}, tags={"transaction"}, @@ -2234,9 +2598,10 @@ async def transaction_rollback(ctx: Context = None) -> dict: # --------------------------------------------------------------------------- -# ── SECTION 11: System (6 tools) ──────────────────────────────────────────── +# ── SECTION 11: System (8 tools) ──────────────────────────────────────────── # --------------------------------------------------------------------------- + @mcp.tool( annotations={"title": "Server Status", "readOnlyHint": True}, tags={"system"}, @@ -2289,7 +2654,9 @@ async def system_capabilities(ctx: Context = None) -> dict: tags={"system"}, ) async def system_get_variable( - name: Annotated[str, "System variable name (e.g. DIMSCALE, LTSCALE, INSUNITS, CLAYER, MEASUREMENT)"], + name: Annotated[ + str, "System variable name (e.g. DIMSCALE, LTSCALE, INSUNITS, CLAYER, MEASUREMENT)" + ], ctx: Context = None, ) -> dict: """Get an AutoCAD system variable value.""" @@ -2311,16 +2678,26 @@ async def system_set_variable( @mcp.tool( - annotations={"title": "Drawing Settings (read / change)", "readOnlyHint": False, - "destructiveHint": False}, + annotations={ + "title": "Drawing Settings (read / change)", + "readOnlyHint": False, + "destructiveHint": False, + }, tags={"system"}, ) async def drawing_settings( - settings: Annotated[dict | None, Field(default=None, description=( - "Omit to READ every setting; pass a dict to CHANGE them. Friendly keys: " - "units (mm/cm/m/inch/feet), linear_precision, angular_precision, ltscale, " - "dimscale, text_size, point_mode, point_size, osmode, fillet_radius. " - "Example: {\"units\": \"mm\", \"dimscale\": 1.0, \"linear_precision\": 2}."))] = None, + settings: Annotated[ + dict | None, + Field( + default=None, + description=( + "Omit to READ every setting; pass a dict to CHANGE them. Friendly keys: " + "units (mm/cm/m/inch/feet), linear_precision, angular_precision, ltscale, " + "dimscale, text_size, point_mode, point_size, osmode, fillet_radius. " + 'Example: {"units": "mm", "dimscale": 1.0, "linear_precision": 2}.' + ), + ), + ] = None, ctx: Context = None, ) -> dict: """Read or change common AutoCAD drawing settings by friendly name. @@ -2362,7 +2739,7 @@ async def system_run_command( tags={"system"}, ) async def system_run_lisp( - expression: Annotated[str, "AutoLISP expression to evaluate (e.g. '(command \"ZOOM\" \"E\")')"], + expression: Annotated[str, 'AutoLISP expression to evaluate (e.g. \'(command "ZOOM" "E")\')'], ctx: Context = None, ) -> dict: """Execute an AutoLISP expression (COM backend only). @@ -2395,6 +2772,8 @@ async def system_about(ctx: Context = None) -> dict: "tool_groups": await _tool_groups(), "unsafe_mode": config.settings.dangerous_commands_enabled, "capabilities": b.capabilities().to_dict()["features"] if b else {}, + "tool_profile": _active_tool_profile + or {"profile": config.settings.tool_profile, "applied": False}, } # R20: omit total_tools when unknown rather than reporting a fake -1. if tool_count is not None: @@ -2406,26 +2785,40 @@ async def system_about(ctx: Context = None) -> dict: # ── SECTION 12: Engineering Tools (deterministic CAD generators) ──────────── # --------------------------------------------------------------------------- + @mcp.tool( annotations={"title": "Gear: Helical Front View", "destructiveHint": False}, tags={"engineering", "gear"}, ) async def gear_draw_helical_front_view( - module: Annotated[float, Field(gt=0, description="Module (mm). Pitch radius = module*teeth/2.")], + module: Annotated[ + float, Field(gt=0, description="Module (mm). Pitch radius = module*teeth/2.") + ], teeth: Annotated[int, Field(ge=6, description="Number of teeth.")], helix_angle: Annotated[float, Field(ge=0, lt=45, description="Helix angle in degrees.")], - pressure_angle: Annotated[float, Field(default=20.0, gt=0, lt=45, - description="Pressure angle (deg). Standard: 20.")] = 20.0, - hand: Annotated[str, Field(default="RH", - description="Helix hand: 'RH' (right) or 'LH' (left).")] = "RH", + pressure_angle: Annotated[ + float, Field(default=20.0, gt=0, lt=45, description="Pressure angle (deg). Standard: 20.") + ] = 20.0, + hand: Annotated[ + str, Field(default="RH", description="Helix hand: 'RH' (right) or 'LH' (left).") + ] = "RH", center_x: Annotated[float, Field(default=0.0)] = 0.0, center_y: Annotated[float, Field(default=0.0)] = 0.0, - bore_diameter: Annotated[float | None, Field(default=None, gt=0, - description="Optional bore diameter (mm). Adds a centered hole.")] = None, - keyway_width: Annotated[float | None, Field(default=None, gt=0, - description="Keyway width (b). Auto from DIN 6885 if bore set and this is None.")] = None, - keyway_depth: Annotated[float | None, Field(default=None, gt=0, - description="Keyway depth into hub (t2).")] = None, + bore_diameter: Annotated[ + float | None, + Field(default=None, gt=0, description="Optional bore diameter (mm). Adds a centered hole."), + ] = None, + keyway_width: Annotated[ + float | None, + Field( + default=None, + gt=0, + description="Keyway width (b). Auto from DIN 6885 if bore set and this is None.", + ), + ] = None, + keyway_depth: Annotated[ + float | None, Field(default=None, gt=0, description="Keyway depth into hub (t2).") + ] = None, ctx: Context = None, ) -> dict: """Deterministic helical gear front view: full involute outline (40 pts/flank), @@ -2434,12 +2827,22 @@ async def gear_draw_helical_front_view( Returns a handle bundle plus 'metadata' for downstream gear_draw_section_aa. """ from engineering import draw_helical_gear_front_view + backend = _backend(ctx) - await ctx.info(f"Drawing helical gear: m={module}, z={teeth}, beta={helix_angle} deg, hand={hand}") + await ctx.info( + f"Drawing helical gear: m={module}, z={teeth}, beta={helix_angle} deg, hand={hand}" + ) return await draw_helical_gear_front_view( - backend, module=module, teeth=teeth, helix_angle=helix_angle, - pressure_angle=pressure_angle, hand=hand, center=(center_x, center_y), - bore_diameter=bore_diameter, keyway_width=keyway_width, keyway_depth=keyway_depth, + backend, + module=module, + teeth=teeth, + helix_angle=helix_angle, + pressure_angle=pressure_angle, + hand=hand, + center=(center_x, center_y), + bore_diameter=bore_diameter, + keyway_width=keyway_width, + keyway_depth=keyway_depth, ) @@ -2460,12 +2863,18 @@ async def gear_draw_spur_front_view( ) -> dict: """Deterministic spur gear front view (no helix symbol).""" from engineering import draw_spur_gear_front_view + backend = _backend(ctx) await ctx.info(f"Drawing spur gear: m={module}, z={teeth}") return await draw_spur_gear_front_view( - backend, module=module, teeth=teeth, pressure_angle=pressure_angle, - center=(center_x, center_y), bore_diameter=bore_diameter, - keyway_width=keyway_width, keyway_depth=keyway_depth, + backend, + module=module, + teeth=teeth, + pressure_angle=pressure_angle, + center=(center_x, center_y), + bore_diameter=bore_diameter, + keyway_width=keyway_width, + keyway_depth=keyway_depth, ) @@ -2474,7 +2883,12 @@ async def gear_draw_spur_front_view( tags={"engineering", "gear"}, ) async def gear_draw_section_aa( - gear_metadata: Annotated[dict, Field(description="The 'metadata' dict returned by gear_draw_helical_front_view or gear_draw_spur_front_view.")], + gear_metadata: Annotated[ + dict, + Field( + description="The 'metadata' dict returned by gear_draw_helical_front_view or gear_draw_spur_front_view." + ), + ], x_offset: Annotated[float, Field(description="X position to place the section view.")], face_width: Annotated[float, Field(gt=0, description="Gear face width (mm).")], ctx: Context = None, @@ -2483,10 +2897,14 @@ async def gear_draw_section_aa( Includes top/bottom/left/right boundaries, bore lines, keyway notch, ANSI31 hatch. """ from engineering import draw_gear_section_aa + backend = _backend(ctx) await ctx.info(f"Drawing section A-A at x={x_offset}, face_width={face_width}") return await draw_gear_section_aa( - backend, gear_metadata=gear_metadata, x_offset=x_offset, face_width=face_width, + backend, + gear_metadata=gear_metadata, + x_offset=x_offset, + face_width=face_width, ) @@ -2505,10 +2923,15 @@ async def keyway_draw_keyed_bore( ) -> dict: """Bore + DIN 6885 keyway in front view. Auto-sizes keyway from bore if width/depth omitted.""" from engineering import draw_keyed_bore + backend = _backend(ctx) return await draw_keyed_bore( - backend, center=(center_x, center_y), bore_diameter=bore_diameter, - keyway_width=keyway_width, keyway_depth=keyway_depth, layer=layer, + backend, + center=(center_x, center_y), + bore_diameter=bore_diameter, + keyway_width=keyway_width, + keyway_depth=keyway_depth, + layer=layer, ) @@ -2527,10 +2950,15 @@ async def keyway_draw_section( ) -> dict: """Side cross-section view of a keyed bore.""" from engineering import draw_keyway_section + backend = _backend(ctx) return await draw_keyway_section( - backend, center=(center_x, center_y), bore_diameter=bore_diameter, - face_width=face_width, keyway_width=keyway_width, keyway_depth=keyway_depth, + backend, + center=(center_x, center_y), + bore_diameter=bore_diameter, + face_width=face_width, + keyway_width=keyway_width, + keyway_depth=keyway_depth, ) @@ -2557,31 +2985,59 @@ async def titleblock_apply_iso_a3( ) -> dict: """ISO 7200 / A3 (420x297 mm) title block. Title text is used verbatim.""" from engineering import TitleBlockMetadata, apply_iso_a3_titleblock + backend = _backend(ctx) metadata = TitleBlockMetadata( - title=title, drawing_no=drawing_no, part_no=part_no, material=material, - scale=scale, units=units, drawn_by=drawn_by, checked_by=checked_by, - date=date, sheet=sheet, revision=revision, company=company, + title=title, + drawing_no=drawing_no, + part_no=part_no, + material=material, + scale=scale, + units=units, + drawn_by=drawn_by, + checked_by=checked_by, + date=date, + sheet=sheet, + revision=revision, + company=company, ) return await apply_iso_a3_titleblock(backend, metadata=metadata, origin=(origin_x, origin_y)) @mcp.tool( - annotations={"title": "Drawing: Finalize (validate + save + screenshot)", - "destructiveHint": True}, + annotations={ + "title": "Drawing: Finalize (validate + save + screenshot)", + "destructiveHint": True, + }, tags={"engineering", "drawing", "validation"}, ) async def drawing_finalize( - save_path: Annotated[str | None, Field(default=None, - description="If given, save drawing here before validation. Pass full path including extension.")] = None, - screenshot_path: Annotated[str | None, Field(default=None, - description="If given, write PNG screenshot to this path.")] = None, - expected: Annotated[dict | None, Field(default=None, - description="Optional contract: {'part_type', 'helix_angle', 'must_have_bore', 'must_have_keyway'}")] = None, - strict_critique: Annotated[bool, Field(default=False, - description="If true, ANY critique issue (including warnings) fails the gate — the full " - "premium discipline. Default false: only critique 'error' issues (e.g. leftover " - "construction geometry) fail; warnings are surfaced in the payload.")] = False, + save_path: Annotated[ + str | None, + Field( + default=None, + description="If given, save drawing here before validation. Pass full path including extension.", + ), + ] = None, + screenshot_path: Annotated[ + str | None, Field(default=None, description="If given, write PNG screenshot to this path.") + ] = None, + expected: Annotated[ + dict | None, + Field( + default=None, + description="Optional contract: {'part_type', 'helix_angle', 'must_have_bore', 'must_have_keyway'}", + ), + ] = None, + strict_critique: Annotated[ + bool, + Field( + default=False, + description="If true, ANY critique issue (including warnings) fails the gate — the full " + "premium discipline. Default false: only critique 'error' issues (e.g. leftover " + "construction geometry) fail; warnings are surfaced in the payload.", + ), + ] = False, ctx: Context = None, ) -> dict: """Premium completion gate: runs BOTH the 8-step validator AND the premium critique focuses @@ -2593,11 +3049,13 @@ async def drawing_finalize( under payload['critique'] without failing the gate by default. """ from engineering import DrawingValidator + backend = _backend(ctx) if save_path: validated = validate_path(save_path, allow_write=True) from pathlib import Path as _P + _fmt = _P(str(validated)).suffix.lstrip(".").lower() or "dxf" await backend.drawing_save_as(str(validated), fmt=_fmt) @@ -2607,6 +3065,7 @@ async def drawing_finalize( shot = await backend.view_screenshot() if shot: from pathlib import Path as _P + _P(str(validated_shot)).write_bytes(shot) except Exception as exc: if ctx is not None: @@ -2627,6 +3086,7 @@ async def drawing_finalize( # validator and the premium critique (MUSE/CadBench grade an Invalidity Ratio, # not shape). 100 = clean; errors dominate the penalty. from engineering.scoring import combine + payload["score"] = combine(result.summary, crit_summary) info = await backend.drawing_info() @@ -2644,7 +3104,8 @@ async def drawing_finalize( ) gate_failures = [ - i for i in critique_issues + i + for i in critique_issues if i.severity == "error" or (strict_critique and i.severity != "info") ] if gate_failures: @@ -2712,6 +3173,7 @@ async def drawing_deliver( # plan → snap-aware geometry → critique → finalize. # See `.claude/skills/autocad-mcp-premium/` for the full discipline. + @mcp.tool( annotations={"title": "Drawing: Preflight", "readOnlyHint": True}, tags={"premium", "planning", "validation"}, @@ -2722,9 +3184,7 @@ async def drawing_preflight( dict | None, "Units, part_type, dimensions, tolerance_policy and optional constraints.", ] = None, - sheet_size: Annotated[ - str, Field(default="A3", description="A4 / A3 / A2 / A1 / A0.") - ] = "A3", + sheet_size: Annotated[str, Field(default="A3", description="A4 / A3 / A2 / A1 / A0.")] = "A3", scale: Annotated[float, Field(default=1.0, gt=0)] = 1.0, layer_set_id: Annotated[ str, Field(default="mech", description="mech / pid / iso13567.") @@ -2751,27 +3211,33 @@ async def drawing_preflight( ) return result.to_dict() + @mcp.tool( - annotations={"title": "Drawing: Plan (commit intent before drawing)", - "destructiveHint": False}, + annotations={"title": "Drawing: Plan (commit intent before drawing)", "destructiveHint": False}, tags={"premium", "planning"}, ) async def drawing_plan( intent: Annotated[str, "One-line description of what this drawing represents."], - sheet_size: Annotated[str, Field(default="A3", - description="Paper size: A4 / A3 / A2 / A1 / A0.")] = "A3", - scale: Annotated[float, Field(default=1.0, gt=0, - description="Drawing scale (1.0 = 1:1, 0.1 = 1:10, etc).")] = 1.0, - layer_set_id: Annotated[str, Field(default="mech", - description="Layer set to bootstrap: mech / pid / iso13567.")] = "mech", + sheet_size: Annotated[ + str, Field(default="A3", description="Paper size: A4 / A3 / A2 / A1 / A0.") + ] = "A3", + scale: Annotated[ + float, Field(default=1.0, gt=0, description="Drawing scale (1.0 = 1:1, 0.1 = 1:10, etc).") + ] = 1.0, + layer_set_id: Annotated[ + str, Field(default="mech", description="Layer set to bootstrap: mech / pid / iso13567.") + ] = "mech", view_count: Annotated[int, Field(default=1, ge=1)] = 1, - dim_style: Annotated[str, Field(default="chain", - description="Default dimensioning style: chain / baseline / ordinate / mixed.")] = "chain", + dim_style: Annotated[ + str, + Field( + default="chain", + description="Default dimensioning style: chain / baseline / ordinate / mixed.", + ), + ] = "chain", notes: Annotated[list[str] | None, "Free-form constraint notes."] = None, requirements: Annotated[dict | None, "Normalized preflight requirements."] = None, - spec_hash: Annotated[ - str | None, "Hash returned by the latest ready drawing_preflight." - ] = None, + spec_hash: Annotated[str | None, "Hash returned by the latest ready drawing_preflight."] = None, ctx: Context = None, ) -> dict: """Commit a PlanSpec before any geometry is created. @@ -2781,21 +3247,36 @@ async def drawing_plan( in a premium workflow. """ plan = await _backend(ctx).drawing_plan( - intent, sheet_size, scale, layer_set_id, view_count, dim_style, notes, - requirements, spec_hash, + intent, + sheet_size, + scale, + layer_set_id, + view_count, + dim_style, + notes, + requirements, + spec_hash, ) return _dc(plan) @mcp.tool( - annotations={"title": "Drawing: Critique (premium quality checks)", - "destructiveHint": False, "readOnlyHint": True}, + annotations={ + "title": "Drawing: Critique (premium quality checks)", + "destructiveHint": False, + "readOnlyHint": True, + }, tags={"premium", "validation"}, ) async def drawing_critique( - focus: Annotated[list[str] | None, Field(default=None, - description="Subset of: iso128, layer_color, dim_overlap, untrimmed_corner, " - "duplicate_entities, construction_left. None = run all.")] = None, + focus: Annotated[ + list[str] | None, + Field( + default=None, + description="Subset of: iso128, layer_color, dim_overlap, untrimmed_corner, " + "duplicate_entities, construction_left. None = run all.", + ), + ] = None, ctx: Context = None, ) -> list[dict]: """Run premium-quality checks. Returns zero issues for a clean drawing. @@ -2841,7 +3322,9 @@ async def drawing_refine( async def point_from_snap( handle: Annotated[str, "Entity handle to snap onto"], snap: Annotated[str, Field(description="Snap type: end | mid | center | quad | perp | near")], - ref_x: Annotated[float | None, "Reference X (required for perp/near; disambiguates end/quad)"] = None, + ref_x: Annotated[ + float | None, "Reference X (required for perp/near; disambiguates end/quad)" + ] = None, ref_y: Annotated[float | None, "Reference Y"] = None, ctx: Context = None, ) -> dict: @@ -2859,7 +3342,9 @@ async def point_from_snap( async def point_intersection( handle1: Annotated[str, "First entity handle (LINE or CIRCLE)"], handle2: Annotated[str, "Second entity handle (LINE or CIRCLE)"], - ref_x: Annotated[float | None, "Reference X to pick nearest candidate when multiple exist"] = None, + ref_x: Annotated[ + float | None, "Reference X to pick nearest candidate when multiple exist" + ] = None, ref_y: Annotated[float | None, "Reference Y"] = None, ctx: Context = None, ) -> dict: @@ -2879,7 +3364,9 @@ async def point_tangent( circle_handle: Annotated[str, "Handle of the CIRCLE entity"], from_x: Annotated[float, "X of the external point"], from_y: Annotated[float, "Y of the external point"], - ref_x: Annotated[float | None, "Reference X to pick nearest tangent point when two exist"] = None, + ref_x: Annotated[ + float | None, "Reference X to pick nearest tangent point when two exist" + ] = None, ref_y: Annotated[float | None, "Reference Y"] = None, ctx: Context = None, ) -> dict: @@ -2891,8 +3378,7 @@ async def point_tangent( @mcp.tool( - annotations={"title": "Construction: XLine (infinite reference)", - "destructiveHint": False}, + annotations={"title": "Construction: XLine (infinite reference)", "destructiveHint": False}, tags={"premium", "construction"}, ) async def construction_xline( @@ -2910,8 +3396,7 @@ async def construction_xline( @mcp.tool( - annotations={"title": "Construction: Clear (delete scaffold)", - "destructiveHint": True}, + annotations={"title": "Construction: Clear (delete scaffold)", "destructiveHint": True}, tags={"premium", "construction"}, ) async def construction_clear( @@ -2925,13 +3410,17 @@ async def construction_clear( @mcp.tool( - annotations={"title": "Drawing: Apply ISO Layer Set (bootstrap)", - "destructiveHint": False}, + annotations={"title": "Drawing: Apply ISO Layer Set (bootstrap)", "destructiveHint": False}, tags={"premium", "layers"}, ) async def drawing_apply_iso_layers( - standard: Annotated[str, Field(default="mech", - description="Layer set: mech (DIN/ISO mechanical), pid (P&ID), iso13567 (CAD layer naming).")] = "mech", + standard: Annotated[ + str, + Field( + default="mech", + description="Layer set: mech (DIN/ISO mechanical), pid (P&ID), iso13567 (CAD layer naming).", + ), + ] = "mech", ctx: Context = None, ) -> dict: """Bootstrap a full ISO-conformant layer set with correct colors and lineweights. @@ -2941,16 +3430,20 @@ async def drawing_apply_iso_layers( @mcp.tool( - annotations={"title": "Dimension: Auto (chain / baseline / ordinate)", - "destructiveHint": False}, + annotations={ + "title": "Dimension: Auto (chain / baseline / ordinate)", + "destructiveHint": False, + }, tags={"premium", "dimension"}, ) async def dimension_auto( handles: Annotated[list[str], "List of entity handles to dimension"], - style: Annotated[str, Field(default="chain", - description="chain | baseline | ordinate")] = "chain", - offset: Annotated[float, Field(default=10.0, gt=0, - description="Dimension-line offset from the geometry (mm)")] = 10.0, + style: Annotated[ + str, Field(default="chain", description="chain | baseline | ordinate") + ] = "chain", + offset: Annotated[ + float, Field(default=10.0, gt=0, description="Dimension-line offset from the geometry (mm)") + ] = 10.0, ctx: Context = None, ) -> list[dict]: """Generate ISO 129 dimensions across the listed entities in the chosen style. @@ -2961,16 +3454,20 @@ async def dimension_auto( @mcp.tool( - annotations={"title": "Entity: Smart Select (semantic predicate)", - "readOnlyHint": True}, + annotations={"title": "Entity: Smart Select (semantic predicate)", "readOnlyHint": True}, tags={"premium", "select"}, ) async def entity_select_smart( - predicate: Annotated[dict, Field(description=( - "Predicate dict (all keys optional, AND-ed): " - "type (e.g. 'LINE'), layer (name), near ([x,y,radius]), " - "length_range ([min,max], LINE/ARC only), color (ACI int)." - ))], + predicate: Annotated[ + dict, + Field( + description=( + "Predicate dict (all keys optional, AND-ed): " + "type (e.g. 'LINE'), layer (name), near ([x,y,radius]), " + "length_range ([min,max], LINE/ARC only), color (ACI int)." + ) + ), + ], ctx: Context = None, ) -> list[dict]: """Select entities by semantic predicate instead of memorising handles.""" @@ -2985,29 +3482,45 @@ async def entity_select_smart( # from LINE + TEXT so the same frame renders on COM and ezdxf. The datum- # consistency rule is enforced by the `gdt` critique focus at finalize time. + @mcp.tool( - annotations={"title": "GD&T: Feature Control Frame (ISO 1101)", - "destructiveHint": False}, + annotations={"title": "GD&T: Feature Control Frame (ISO 1101)", "destructiveHint": False}, tags={"engineering", "gdt"}, ) async def gd_frame( - symbol: Annotated[str, Field(description=( - "Geometric characteristic: straightness, flatness, circularity, " - "cylindricity, profile_line, profile_surface, angularity, " - "perpendicularity, parallelism, position, concentricity, symmetry, " - "circular_runout, total_runout."))], + symbol: Annotated[ + str, + Field( + description=( + "Geometric characteristic: straightness, flatness, circularity, " + "cylindricity, profile_line, profile_surface, angularity, " + "perpendicularity, parallelism, position, concentricity, symmetry, " + "circular_runout, total_runout." + ) + ), + ], tolerance: Annotated[float, "Tolerance zone value (mm)."], x: Annotated[float, "Frame bottom-left corner X."], y: Annotated[float, "Frame bottom-left corner Y."], - datums: Annotated[list[str] | None, Field(default=None, - description="Ordered datum references, e.g. ['A','B']. Required for " - "orientation/location/runout characteristics.")] = None, - height: Annotated[float, Field(default=5.0, gt=0, - description="Frame height (mm); text scales with it.")] = 5.0, - diameter: Annotated[bool, Field(default=False, - description="Prefix ⌀ for a cylindrical (diametral) tolerance zone.")] = False, - modifier: Annotated[str | None, Field(default=None, - description="Material-condition modifier: M (MMC), L (LMC), S (RFS).")] = None, + datums: Annotated[ + list[str] | None, + Field( + default=None, + description="Ordered datum references, e.g. ['A','B']. Required for " + "orientation/location/runout characteristics.", + ), + ] = None, + height: Annotated[ + float, Field(default=5.0, gt=0, description="Frame height (mm); text scales with it.") + ] = 5.0, + diameter: Annotated[ + bool, + Field(default=False, description="Prefix ⌀ for a cylindrical (diametral) tolerance zone."), + ] = False, + modifier: Annotated[ + str | None, + Field(default=None, description="Material-condition modifier: M (MMC), L (LMC), S (RFS)."), + ] = None, layer: Annotated[str | None, "Layer (defaults to the active DIM layer)."] = None, ctx: Context = None, ) -> dict: @@ -3017,21 +3530,27 @@ async def gd_frame( `gdt` critique focus flags any datum with no matching datum feature. """ return await _backend(ctx).draw_feature_control_frame( - symbol, tolerance, x, y, datums, height, diameter, modifier, layer, + symbol, + tolerance, + x, + y, + datums, + height, + diameter, + modifier, + layer, ) @mcp.tool( - annotations={"title": "GD&T: Datum Feature (ISO 1101)", - "destructiveHint": False}, + annotations={"title": "GD&T: Datum Feature (ISO 1101)", "destructiveHint": False}, tags={"engineering", "gdt"}, ) async def datum_feature( letter: Annotated[str, "Datum letter, e.g. 'A' (avoid I, O, Q per ISO 1101)."], x: Annotated[float, "Datum triangle apex X (on the referenced feature)."], y: Annotated[float, "Datum triangle apex Y."], - size: Annotated[float, Field(default=5.0, gt=0, - description="Triangle/label size (mm).")] = 5.0, + size: Annotated[float, Field(default=5.0, gt=0, description="Triangle/label size (mm).")] = 5.0, layer: Annotated[str | None, "Layer (defaults to the active DIM layer)."] = None, ctx: Context = None, ) -> dict: @@ -3043,10 +3562,185 @@ async def datum_feature( return await _backend(ctx).draw_datum_feature(letter, x, y, size, layer) +# --------------------------------------------------------------------------- +# ── SECTION 15: Layouts & Paper Space (4 tools) ───────────────────────────── +# --------------------------------------------------------------------------- + + +@mcp.tool( + annotations={"title": "List Layouts", "readOnlyHint": True}, + tags={"layout", "query"}, +) +async def layout_list(ctx: Context = None) -> dict: + """List all layout tabs (Model + paper-space layouts) and the current one.""" + return await _backend(ctx).layout_list() + + +@mcp.tool( + annotations={"title": "Create Layout", "destructiveHint": False}, + tags={"layout"}, +) +async def layout_create( + name: Annotated[str, "New paper-space layout name (e.g. 'A3-Sheet')."], + ctx: Context = None, +) -> dict: + """Create a new paper-space layout tab.""" + await ctx.info(f"Creating layout {name}") + return await _backend(ctx).layout_create(name) + + +@mcp.tool( + annotations={"title": "Set Current Layout"}, + tags={"layout"}, +) +async def layout_set_current( + name: Annotated[str, "Layout tab to activate ('Model' or a paper-space layout)."], + ctx: Context = None, +) -> dict: + """Activate a layout tab.""" + return await _backend(ctx).layout_set_current(name) + + +@mcp.tool( + annotations={"title": "Create Viewport", "destructiveHint": False}, + tags={"layout"}, +) +async def viewport_create( + layout: Annotated[str, "Paper-space layout that receives the viewport."], + center_x: Annotated[float, "Viewport center X in paper units."], + center_y: Annotated[float, "Viewport center Y in paper units."], + width: Annotated[float, Field(gt=0, description="Viewport width in paper units.")], + height: Annotated[float, Field(gt=0, description="Viewport height in paper units.")], + view_center_x: Annotated[float, "Model-space X the viewport looks at."], + view_center_y: Annotated[float, "Model-space Y the viewport looks at."], + scale: Annotated[ + float, + Field(gt=0, description="Paper:model scale (1.0 = 1:1, 0.5 = 1:2, 2.0 = 2:1)."), + ] = 1.0, + ctx: Context = None, +) -> dict: + """Place a scaled model-space viewport on a paper-space layout. + + The viewport window shows the model region centered at + (view_center_x, view_center_y); view height = height / scale. + """ + await ctx.info(f"Creating viewport on {layout} at {scale}:1") + return await _backend(ctx).viewport_create( + layout, center_x, center_y, width, height, view_center_x, view_center_y, scale + ) + + +# --------------------------------------------------------------------------- +# ── SECTION 16: 3D Solids (5 tools) — opt-in via ENABLE_3D ────────────────── +# --------------------------------------------------------------------------- + + +def _require_3d() -> None: + """3D solids are opt-in: hidden from discovery and rejected when disabled.""" + if not config.settings.enable_3d: + raise ToolError( + "3D solids are disabled. Set ENABLE_3D=true (COM backend with live " + "AutoCAD required; the headless backend cannot generate ACIS solids)." + ) + + +@mcp.tool( + annotations={"title": "Solid: Box", "destructiveHint": False}, + tags={"solid"}, +) +async def solid_box( + cx: Annotated[float, "Box center X."], + cy: Annotated[float, "Box center Y."], + cz: Annotated[float, "Box center Z."], + length: Annotated[float, Field(gt=0, description="Length along X (mm).")], + width: Annotated[float, Field(gt=0, description="Width along Y (mm).")], + height: Annotated[float, Field(gt=0, description="Height along Z (mm).")], + ctx: Context = None, +) -> dict: + """Create a native 3D solid box (COM backend, opt-in).""" + _require_3d() + return await _backend(ctx).solid_box(cx, cy, cz, length, width, height) + + +@mcp.tool( + annotations={"title": "Solid: Cylinder", "destructiveHint": False}, + tags={"solid"}, +) +async def solid_cylinder( + cx: Annotated[float, "Base-center X."], + cy: Annotated[float, "Base-center Y."], + cz: Annotated[float, "Center Z (AutoCAD places the cylinder center here)."], + radius: Annotated[float, Field(gt=0, description="Cylinder radius (mm).")], + height: Annotated[float, Field(gt=0, description="Cylinder height (mm).")], + ctx: Context = None, +) -> dict: + """Create a native 3D solid cylinder (COM backend, opt-in).""" + _require_3d() + return await _backend(ctx).solid_cylinder(cx, cy, cz, radius, height) + + +@mcp.tool( + annotations={"title": "Solid: Extrude", "destructiveHint": False}, + tags={"solid"}, +) +async def solid_extrude( + profile_handle: Annotated[str, "Handle of a closed profile (circle / closed polyline)."], + height: Annotated[float, "Extrusion height; negative extrudes downward."], + taper_angle: Annotated[ + float, Field(default=0.0, ge=-45, le=45, description="Taper angle in degrees.") + ] = 0.0, + ctx: Context = None, +) -> dict: + """Extrude a closed profile into a native 3D solid (COM backend, opt-in).""" + _require_3d() + return await _backend(ctx).solid_extrude(profile_handle, height, taper_angle) + + +@mcp.tool( + annotations={"title": "Solid: Revolve", "destructiveHint": False}, + tags={"solid"}, +) +async def solid_revolve( + profile_handle: Annotated[str, "Handle of a closed profile (circle / closed polyline)."], + axis_x1: Annotated[float, "Revolution axis start X."], + axis_y1: Annotated[float, "Revolution axis start Y."], + axis_x2: Annotated[float, "Revolution axis end X."], + axis_y2: Annotated[float, "Revolution axis end Y."], + angle: Annotated[ + float, Field(default=360.0, gt=0, le=360, description="Revolution angle in degrees.") + ] = 360.0, + ctx: Context = None, +) -> dict: + """Revolve a closed profile around an axis into a native 3D solid (COM, opt-in).""" + _require_3d() + return await _backend(ctx).solid_revolve( + profile_handle, axis_x1, axis_y1, axis_x2, axis_y2, angle + ) + + +@mcp.tool( + annotations={"title": "Solid: Boolean", "destructiveHint": True}, + tags={"solid"}, +) +async def solid_boolean( + target_handle: Annotated[str, "Handle of the solid that receives the result."], + tool_handle: Annotated[str, "Handle of the solid consumed by the operation."], + operation: Annotated[str, "union | subtract | intersect"], + ctx: Context = None, +) -> dict: + """Boolean-combine two native 3D solids (COM backend, opt-in). + + The tool solid is consumed; the target holds the result. + """ + _require_3d() + return await _backend(ctx).solid_boolean(target_handle, tool_handle, operation) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- + @mcp.resource( "autocad://drawing/info", name="Current Drawing Info", @@ -3171,6 +3865,7 @@ async def resource_entities_by_layer(layer_name: str, ctx: Context = None) -> st # ── PROMPTS ────────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- + @mcp.prompt(tags={"template", "architectural"}) def prompt_floor_plan( building_name: str = "Building A", diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index 5fba95b..ca0b3fd 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest from backends.base import CapabilityMap, FeatureCapability +from version import __version__ def test_capability_types_serialize_stably(): @@ -32,7 +33,11 @@ async def test_ezdxf_capabilities_are_machine_readable(backend): "reason": None, } assert caps["features"]["mleader"]["mode"] == "composite" - assert caps["features"]["paper_space"]["supported"] is False + # v1.4: paper-space layouts/viewports are native on both backends; + # projecting model content through viewports stays COM-only, and 3D + # ACIS solids remain impossible headlessly. + assert caps["features"]["paper_space"]["supported"] is True + assert caps["features"]["viewport_render"]["supported"] is False assert caps["features"]["solid_3d"]["supported"] is False @@ -70,5 +75,5 @@ async def test_system_about_uses_package_version_and_capability_summary(backend) result = await server.system_about(ctx=Ctx()) - assert result["version"] == "1.3.0" + assert result["version"] == __version__ assert result["capabilities"]["table"]["mode"] == "composite" diff --git a/tests/test_delivery.py b/tests/test_delivery.py index 8acc5c6..c764d43 100644 --- a/tests/test_delivery.py +++ b/tests/test_delivery.py @@ -10,6 +10,7 @@ import pytest import server from engineering.delivery import compare_drawing_snapshots, deliver_drawing, drawing_snapshot from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes +from version import __version__ async def _clean_drawing(backend) -> None: @@ -42,7 +43,7 @@ async def test_delivery_writes_hashed_manifest_and_reopen_parity(backend, tmp_pa assert dxf["status"] == "created" assert dxf["sha256"] == hashlib.sha256(artifact_path.read_bytes()).hexdigest() assert manifest["parity"]["ok"] is True - assert manifest["version"] == "1.3.0" + assert manifest["version"] == __version__ assert manifest["capabilities"]["backend"] == "ezdxf" @@ -107,9 +108,7 @@ async def test_snapshot_parity_rejects_incomplete_entity_inventory(backend, monk assert source["listed_entity_count"] == 1 assert source["inventory_complete"] is False assert result["ok"] is False - assert "source_inventory_complete" in { - item["field"] for item in result["differences"] - } + assert "source_inventory_complete" in {item["field"] for item in result["differences"]} @pytest.mark.asyncio diff --git a/tests/test_fits.py b/tests/test_fits.py new file mode 100644 index 0000000..e236e31 --- /dev/null +++ b/tests/test_fits.py @@ -0,0 +1,118 @@ +"""ISO 286 fit-table lookups pinned against published table values.""" + +from __future__ import annotations + +import pytest +from fastmcp.exceptions import ToolError + +from engineering.fits import FitDeviation, fit_lookup, parse_fit_code +from engineering.tolerances import build_dim_override +from server import _fit_to_tolerances + + +def _um(value_mm: float) -> float: + return round(value_mm * 1000.0, 3) + + +# (code, nominal, upper µm, lower µm) — published ISO 286-2 values. +CANONICAL_FITS = [ + ("H7", 20.0, 21, 0), + ("H7", 40.0, 25, 0), + ("H8", 30.0, 33, 0), + ("H9", 6.0, 30, 0), + ("H7", 200.0, 46, 0), + ("g6", 20.0, -7, -20), + ("g6", 6.0, -4, -12), + ("f7", 40.0, -25, -50), + ("e8", 25.0, -40, -73), + ("d9", 100.0, -120, -207), + ("h6", 30.0, 0, -13), # 30 mm is inside the >18-30 step (inclusive upper bound) + ("h7", 50.0, 0, -25), + ("k6", 20.0, 15, 2), + ("m6", 25.0, 21, 8), + ("n6", 20.0, 28, 15), + ("p6", 20.0, 35, 22), + ("js9", 25.0, 26, -26), + ("G7", 6.0, 16, 4), + ("F8", 40.0, 64, 25), + ("D10", 30.0, 149, 65), +] + + +@pytest.mark.parametrize("code,nominal,upper_um,lower_um", CANONICAL_FITS) +def test_canonical_fit_values(code, nominal, upper_um, lower_um): + deviation = fit_lookup(code, nominal) + assert isinstance(deviation, FitDeviation) + assert _um(deviation.upper_mm) == pytest.approx(upper_um), f"{code}@{nominal} upper" + assert _um(deviation.lower_mm) == pytest.approx(lower_um), f"{code}@{nominal} lower" + + +def test_k_outside_it4_to_7_has_zero_lower_deviation(): + deviation = fit_lookup("k9", 20.0) + assert deviation.lower_mm == 0.0 + assert _um(deviation.upper_mm) == 52 # IT9 @ 18-30 + + +def test_parse_fit_code(): + assert parse_fit_code("H7") == ("H", 7) + assert parse_fit_code("js10") == ("js", 10) + with pytest.raises(ValueError): + parse_fit_code("77") + with pytest.raises(ValueError): + parse_fit_code("") + + +@pytest.mark.parametrize( + "code,nominal", + [ + ("K7", 20.0), # transition hole letters need the delta-rule — excluded + ("P7", 20.0), + ("x6", 20.0), # letter outside the authored subset (regex rejects) + ("H3", 20.0), # grade below authored range + ("H12", 20.0), # grade above authored range + ("H7", 0.5), # below 1 mm + ("H7", 600.0), # above 500 mm + ], +) +def test_out_of_scope_lookups_raise(code, nominal): + with pytest.raises(ValueError): + fit_lookup(code, nominal) + + +def test_double_positive_fit_renders_correct_dimvars(): + """p6-style fits (+upper/+lower) must produce a negative DIMTM so AutoCAD + displays the plus lower deviation.""" + deviation = fit_lookup("p6", 20.0) + override, _ = build_dim_override( + tol_upper=deviation.upper_mm, + tol_lower=-deviation.lower_mm, # server convention: positive = minus + tol_mode="deviation", + ) + assert override["dimtp"] == pytest.approx(0.035) + assert override["dimtm"] == pytest.approx(-0.022) # displayed as +0.022 + + +def test_fit_to_tolerances_contract(): + tol_upper, tol_lower, tol_mode, text = _fit_to_tolerances("H7", 20.0, None, None, "none", None) + assert tol_upper == pytest.approx(0.021) + assert tol_lower == pytest.approx(0.0) + assert tol_mode == "deviation" + assert text == "<> H7" + + +def test_fit_to_tolerances_rejects_mixed_usage(): + with pytest.raises(ToolError): + _fit_to_tolerances("H7", 20.0, 0.1, None, "none", None) + with pytest.raises(ToolError): + _fit_to_tolerances("H7", 20.0, None, None, "symmetric", None) + with pytest.raises(ToolError): + _fit_to_tolerances("Q9", 20.0, None, None, "none", None) + + +def test_fit_passthrough_without_code(): + assert _fit_to_tolerances(None, 20.0, 0.1, 0.05, "deviation", "x") == ( + 0.1, + 0.05, + "deviation", + "x", + ) diff --git a/tests/test_layouts_solids.py b/tests/test_layouts_solids.py new file mode 100644 index 0000000..28bc792 --- /dev/null +++ b/tests/test_layouts_solids.py @@ -0,0 +1,134 @@ +"""Paper-space layouts, viewports, layout PDF export and opt-in 3D solids.""" + +from __future__ import annotations + +from importlib.util import find_spec + +import ezdxf +import pytest +import pytest_asyncio + +import config +import server +from backends.ezdxf_backend import EzdxfBackend + +pytestmark = pytest.mark.asyncio + + +@pytest_asyncio.fixture +async def backend(): + b = EzdxfBackend() + await b.connect() + await b.drawing_new() + yield b + await b.disconnect() + + +async def test_layout_list_reports_model_and_current(backend): + result = await backend.layout_list() + assert result["ok"] is True + assert "Model" in result["layouts"] + assert isinstance(result["current"], str) + + +async def test_layout_create_and_duplicate_rejected(backend): + created = await backend.layout_create("A3-Sheet") + assert created["ok"] is True + listing = await backend.layout_list() + assert "A3-Sheet" in listing["layouts"] + duplicate = await backend.layout_create("A3-Sheet") + assert duplicate["ok"] is False + + +async def test_layout_set_current_roundtrip(backend): + await backend.layout_create("A3-Sheet") + switched = await backend.layout_set_current("A3-Sheet") + assert switched["ok"] is True + assert (await backend.layout_list())["current"] == "A3-Sheet" + back = await backend.layout_set_current("Model") + assert back["ok"] is True + missing = await backend.layout_set_current("NOPE") + assert missing["ok"] is False + + +async def test_viewport_create_scaled(backend, tmp_path): + await backend.layout_create("A3-Sheet") + result = await backend.viewport_create( + "A3-Sheet", + center_x=200, + center_y=140, + width=180, + height=120, + view_center_x=50, + view_center_y=25, + scale=0.5, + ) + assert result["ok"] is True + assert result["view_height"] == pytest.approx(240.0) # height / scale + + exported = tmp_path / "layout_roundtrip.dxf" + save = await backend.drawing_export_dxf(str(exported)) + assert save["ok"] is True + doc = ezdxf.readfile(str(exported)) + viewports = doc.layouts.get("A3-Sheet").query("VIEWPORT") + assert len(viewports) >= 1 + created = [vp for vp in viewports if vp.dxf.handle == result["handle"]] + assert created, "created viewport handle must survive the DXF roundtrip" + assert created[0].dxf.view_height == pytest.approx(240.0) + + +async def test_viewport_rejects_model_and_bad_scale(backend): + bad_layout = await backend.viewport_create("Model", 0, 0, 10, 10, 0, 0, 1.0) + assert bad_layout["ok"] is False + await backend.layout_create("A3-Sheet") + bad_scale = await backend.viewport_create("A3-Sheet", 0, 0, 10, 10, 0, 0, 0.0) + assert bad_scale["ok"] is False + + +@pytest.mark.skipif(find_spec("matplotlib") is None, reason="matplotlib not installed") +async def test_export_pdf_layout(backend, tmp_path): + await backend.layout_create("A3-Sheet") + await backend.entity_create_line(0, 0, 100, 0) + target = tmp_path / "layout.pdf" + result = await backend.drawing_export_pdf(str(target), layout="A3-Sheet") + assert result["ok"] is True + assert result["layout"] == "A3-Sheet" + assert "note" in result # honest viewport-projection limitation + assert target.is_file() and target.stat().st_size > 0 + missing = await backend.drawing_export_pdf(str(tmp_path / "x.pdf"), layout="NOPE") + assert missing["ok"] is False + + +async def test_solids_unsupported_headlessly(backend): + box = await backend.solid_box(0, 0, 0, 10, 10, 10) + cylinder = await backend.solid_cylinder(0, 0, 0, 5, 10) + extrude = await backend.solid_extrude("FF", 10) + revolve = await backend.solid_revolve("FF", 0, 0, 0, 1) + boolean = await backend.solid_boolean("FF", "FE", "union") + for result in (box, cylinder, extrude, revolve, boolean): + assert result["ok"] is False + assert result["capability"] == "solid_3d" + + +async def test_capability_map_reports_paper_space_and_solids(backend): + features = backend.capabilities().to_dict()["features"] + assert features["paper_space"]["supported"] is True + assert features["viewport_render"]["supported"] is False + assert features["solid_3d"]["supported"] is False + + +class TestSolidToolGating: + @pytest.mark.asyncio + async def test_solid_tools_hidden_without_enable_3d(self, monkeypatch): + monkeypatch.setattr(config.settings, "enable_3d", False) + info = await server._apply_tool_profile("full") + assert server.SOLID_TOOL_NAMES <= set(info["disabled_tools"]) + await server._apply_tool_profile("full") + + @pytest.mark.asyncio + async def test_solid_tools_visible_with_enable_3d(self, monkeypatch): + monkeypatch.setattr(config.settings, "enable_3d", True) + info = await server._apply_tool_profile("full") + assert not (server.SOLID_TOOL_NAMES & set(info["disabled_tools"])) + monkeypatch.setattr(config.settings, "enable_3d", False) + await server._apply_tool_profile("full") diff --git a/tests/test_tool_profiles.py b/tests/test_tool_profiles.py new file mode 100644 index 0000000..23f5579 --- /dev/null +++ b/tests/test_tool_profiles.py @@ -0,0 +1,78 @@ +"""Tool-profile (TOOL_PROFILE=lean/core/full) behavior.""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from fastmcp import Client + +import config +import server + +pytestmark = pytest.mark.asyncio + + +@pytest_asyncio.fixture(autouse=True) +async def _restore_full_profile(): + """Profiles mutate global server enablement; always restore full.""" + yield + await server._apply_tool_profile("full") + + +async def _registered_names() -> set[str]: + return {tool.name for tool in await server._registered_tools() if getattr(tool, "name", None)} + + +async def test_lean_names_all_exist_in_registry(): + registered = await _registered_names() + missing = server.LEAN_TOOL_NAMES - registered + assert not missing, f"LEAN_TOOL_NAMES references unknown tools: {sorted(missing)}" + + +async def test_core_exclusions_all_exist_in_registry(): + registered = await _registered_names() + missing = server.CORE_EXCLUDED_TOOL_NAMES - registered + assert not missing, f"CORE_EXCLUDED_TOOL_NAMES references unknown tools: {sorted(missing)}" + + +async def test_full_profile_hides_only_gated_solids(): + """Full hides nothing except the opt-in 3D tools while ENABLE_3D=false.""" + info = await server._apply_tool_profile("full") + assert info["profile"] == "full" + assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES) + assert info["enabled_count"] == info["registered_count"] - len(server.SOLID_TOOL_NAMES) + + +async def test_invalid_profile_falls_back_to_full(): + info = await server._apply_tool_profile("does-not-exist") + assert info["profile"] == "full" + assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES) + + +async def test_lean_profile_filters_client_view(monkeypatch): + monkeypatch.setattr(config.settings, "backend", "ezdxf") + monkeypatch.setattr(config.settings, "tool_profile", "lean") + async with Client(server.mcp) as client: + visible = {tool.name for tool in await client.list_tools()} + assert visible == set(server.LEAN_TOOL_NAMES) + + +async def test_core_profile_hides_escape_hatches_keeps_engineering(monkeypatch): + monkeypatch.setattr(config.settings, "backend", "ezdxf") + monkeypatch.setattr(config.settings, "tool_profile", "core") + async with Client(server.mcp) as client: + visible = {tool.name for tool in await client.list_tools()} + assert "system_run_command" not in visible + assert "system_run_lisp" not in visible + assert "gear_draw_spur_front_view" in visible + assert "drawing_refine" in visible + registered = await _registered_names() + assert visible == registered - server.CORE_EXCLUDED_TOOL_NAMES - server.SOLID_TOOL_NAMES + + +async def test_profile_switch_is_idempotent(): + await server._apply_tool_profile("lean") + info = await server._apply_tool_profile("full") + assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES) + lean_again = await server._apply_tool_profile("lean") + assert lean_again["enabled_count"] == len(server.LEAN_TOOL_NAMES)