feat(server): tool profiles, paper-space layouts + viewports, ISO 286 fits, opt-in COM 3D solids
- TOOL_PROFILE=lean|core|full applied in the lifespan via server-level enable/disable; system_about reports the active profile; opt-in solid_* tools are hidden while ENABLE_3D=false - layouts: layout_list/create/set_current + viewport_create (scaled model viewports) on both backends; drawing_export_pdf(layout=...); ezdxf uses TILEMODE + active-layout binding, COM switches ActiveLayout and restores it - ISO 286: engineering/fits.py authored IT4-11 + fundamental-deviation tables (1-500 mm; shafts d..p/h/js, holes D..H/JS) with clear out-of-scope errors; dimension_linear/radius/diameter gained fit= resolved from the measured nominal - tolerances: DIMTM now signed in deviation/limit modes so double-positive fits (p6 +0.035/+0.022) render correctly; legacy positive-magnitude contract preserved - solids: solid_box/cylinder/extrude/revolve/boolean native on COM (AddBox/AddCylinder/AddRegion+AddExtrudedSolid/AddRevolvedSolid/Boolean); ezdxf reports the honest solid_3d capability boundary - capability maps: paper_space native, new viewport_render feature, ENABLE_3D-gated solid_3d - 60 new tests (profiles, layouts, viewports, layout PDF, fits, gating); version pins in tests now read version.__version__ Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
6d7c39e012
commit
3cbe30df54
+419
-136
@@ -175,11 +175,87 @@ class AutoCADBackend(ABC):
|
|||||||
async def drawing_export_dxf(self, path: str) -> dict: ...
|
async def drawing_export_dxf(self, path: str) -> dict: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def drawing_export_pdf(self, path: str) -> dict: ...
|
async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def drawing_purge(self) -> dict: ...
|
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
|
@abstractmethod
|
||||||
async def drawing_audit(self) -> dict: ...
|
async def drawing_audit(self) -> dict: ...
|
||||||
|
|
||||||
@@ -195,43 +271,72 @@ class AutoCADBackend(ABC):
|
|||||||
# ── entity creation ──────────────────────────────────────────────────────
|
# ── entity creation ──────────────────────────────────────────────────────
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_create_line(
|
async def entity_create_line(
|
||||||
self, x1: float, y1: float, x2: float, y2: float,
|
self,
|
||||||
z1: float = 0.0, z2: float = 0.0,
|
x1: float,
|
||||||
layer: str | None = None, color: int | None = None,
|
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,
|
linetype: str | None = None,
|
||||||
) -> EntityInfo: ...
|
) -> EntityInfo: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_create_circle(
|
async def entity_create_circle(
|
||||||
self, cx: float, cy: float, radius: float,
|
self,
|
||||||
layer: str | None = None, color: int | None = None,
|
cx: float,
|
||||||
|
cy: float,
|
||||||
|
radius: float,
|
||||||
|
layer: str | None = None,
|
||||||
|
color: int | None = None,
|
||||||
) -> EntityInfo: ...
|
) -> EntityInfo: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_create_arc(
|
async def entity_create_arc(
|
||||||
self, cx: float, cy: float, radius: float,
|
self,
|
||||||
start_angle: float, end_angle: float,
|
cx: float,
|
||||||
layer: str | None = None, color: int | None = None,
|
cy: float,
|
||||||
|
radius: float,
|
||||||
|
start_angle: float,
|
||||||
|
end_angle: float,
|
||||||
|
layer: str | None = None,
|
||||||
|
color: int | None = None,
|
||||||
) -> EntityInfo: ...
|
) -> EntityInfo: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_create_polyline(
|
async def entity_create_polyline(
|
||||||
self, points: list[list[float]], closed: bool = False,
|
self,
|
||||||
layer: str | None = None, color: int | None = None,
|
points: list[list[float]],
|
||||||
|
closed: bool = False,
|
||||||
|
layer: str | None = None,
|
||||||
|
color: int | None = None,
|
||||||
) -> EntityInfo: ...
|
) -> EntityInfo: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_create_text(
|
async def entity_create_text(
|
||||||
self, text: str, x: float, y: float,
|
self,
|
||||||
height: float = 2.5, rotation: float = 0.0,
|
text: str,
|
||||||
layer: str | None = None, color: int | None = None,
|
x: float,
|
||||||
|
y: float,
|
||||||
|
height: float = 2.5,
|
||||||
|
rotation: float = 0.0,
|
||||||
|
layer: str | None = None,
|
||||||
|
color: int | None = None,
|
||||||
) -> EntityInfo: ...
|
) -> EntityInfo: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_create_mtext(
|
async def entity_create_mtext(
|
||||||
self, text: str, x: float, y: float,
|
self,
|
||||||
width: float = 100.0, height: float = 2.5, rotation: float = 0.0,
|
text: str,
|
||||||
layer: str | None = None, color: int | None = None,
|
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: ...
|
) -> EntityInfo: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -261,42 +366,67 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_create_hatch(
|
async def entity_create_hatch(
|
||||||
self, pattern: str, boundary_points: list[list[float]],
|
self,
|
||||||
scale: float = 1.0, angle: float = 0.0,
|
pattern: str,
|
||||||
layer: str | None = None, color: int | None = None,
|
boundary_points: list[list[float]],
|
||||||
|
scale: float = 1.0,
|
||||||
|
angle: float = 0.0,
|
||||||
|
layer: str | None = None,
|
||||||
|
color: int | None = None,
|
||||||
) -> EntityInfo: ...
|
) -> EntityInfo: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_create_spline(
|
async def entity_create_spline(
|
||||||
self, fit_points: list[list[float]],
|
self,
|
||||||
layer: str | None = None, color: int | None = None,
|
fit_points: list[list[float]],
|
||||||
|
layer: str | None = None,
|
||||||
|
color: int | None = None,
|
||||||
) -> EntityInfo: ...
|
) -> EntityInfo: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_create_ellipse(
|
async def entity_create_ellipse(
|
||||||
self, cx: float, cy: float,
|
self,
|
||||||
major_x: float, major_y: float, ratio: float = 0.5,
|
cx: float,
|
||||||
layer: str | None = None, color: int | None = None,
|
cy: float,
|
||||||
|
major_x: float,
|
||||||
|
major_y: float,
|
||||||
|
ratio: float = 0.5,
|
||||||
|
layer: str | None = None,
|
||||||
|
color: int | None = None,
|
||||||
) -> EntityInfo: ...
|
) -> EntityInfo: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_create_point(
|
async def entity_create_point(
|
||||||
self, x: float, y: float,
|
self,
|
||||||
layer: str | None = None, color: int | None = None,
|
x: float,
|
||||||
|
y: float,
|
||||||
|
layer: str | None = None,
|
||||||
|
color: int | None = None,
|
||||||
) -> EntityInfo: ...
|
) -> EntityInfo: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_create_block_ref(
|
async def entity_create_block_ref(
|
||||||
self, name: str, x: float, y: float,
|
self,
|
||||||
scale_x: float = 1.0, scale_y: float = 1.0, rotation: float = 0.0,
|
name: str,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
scale_x: float = 1.0,
|
||||||
|
scale_y: float = 1.0,
|
||||||
|
rotation: float = 0.0,
|
||||||
layer: str | None = None,
|
layer: str | None = None,
|
||||||
) -> EntityInfo: ...
|
) -> EntityInfo: ...
|
||||||
|
|
||||||
# ── dimensions ───────────────────────────────────────────────────────────
|
# ── dimensions ───────────────────────────────────────────────────────────
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def dimension_linear(
|
async def dimension_linear(
|
||||||
self, x1: float, y1: float, x2: float, y2: float,
|
self,
|
||||||
dim_x: float, dim_y: float, rotation: float = 0.0,
|
x1: float,
|
||||||
|
y1: float,
|
||||||
|
x2: float,
|
||||||
|
y2: float,
|
||||||
|
dim_x: float,
|
||||||
|
dim_y: float,
|
||||||
|
rotation: float = 0.0,
|
||||||
layer: str | None = None,
|
layer: str | None = None,
|
||||||
tol_upper: float | None = None,
|
tol_upper: float | None = None,
|
||||||
tol_lower: float | None = None,
|
tol_lower: float | None = None,
|
||||||
@@ -306,23 +436,39 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def dimension_aligned(
|
async def dimension_aligned(
|
||||||
self, x1: float, y1: float, x2: float, y2: float,
|
self,
|
||||||
dim_x: float, dim_y: float,
|
x1: float,
|
||||||
|
y1: float,
|
||||||
|
x2: float,
|
||||||
|
y2: float,
|
||||||
|
dim_x: float,
|
||||||
|
dim_y: float,
|
||||||
layer: str | None = None,
|
layer: str | None = None,
|
||||||
) -> EntityInfo: ...
|
) -> EntityInfo: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def dimension_angular(
|
async def dimension_angular(
|
||||||
self, vx: float, vy: float,
|
self,
|
||||||
x1: float, y1: float, x2: float, y2: float,
|
vx: float,
|
||||||
tx: float, ty: float,
|
vy: float,
|
||||||
|
x1: float,
|
||||||
|
y1: float,
|
||||||
|
x2: float,
|
||||||
|
y2: float,
|
||||||
|
tx: float,
|
||||||
|
ty: float,
|
||||||
layer: str | None = None,
|
layer: str | None = None,
|
||||||
) -> EntityInfo: ...
|
) -> EntityInfo: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def dimension_radius(
|
async def dimension_radius(
|
||||||
self, cx: float, cy: float, chord_x: float, chord_y: float,
|
self,
|
||||||
leader_length: float = 10.0, layer: str | None = None,
|
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_upper: float | None = None,
|
||||||
tol_lower: float | None = None,
|
tol_lower: float | None = None,
|
||||||
tol_mode: str = "none",
|
tol_mode: str = "none",
|
||||||
@@ -331,8 +477,13 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def dimension_diameter(
|
async def dimension_diameter(
|
||||||
self, x1: float, y1: float, x2: float, y2: float,
|
self,
|
||||||
leader_length: float = 10.0, layer: str | None = None,
|
x1: float,
|
||||||
|
y1: float,
|
||||||
|
x2: float,
|
||||||
|
y2: float,
|
||||||
|
leader_length: float = 10.0,
|
||||||
|
layer: str | None = None,
|
||||||
tol_upper: float | None = None,
|
tol_upper: float | None = None,
|
||||||
tol_lower: float | None = None,
|
tol_lower: float | None = None,
|
||||||
tol_mode: str = "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: ...
|
async def entity_move(self, handle: str, dx: float, dy: float, dz: float = 0.0) -> dict: ...
|
||||||
|
|
||||||
@abstractmethod
|
@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
|
@abstractmethod
|
||||||
async def entity_rotate(
|
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: ...
|
) -> dict: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_scale(
|
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: ...
|
) -> dict: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_mirror(
|
async def entity_mirror(
|
||||||
self, handle: str,
|
self,
|
||||||
x1: float, y1: float, x2: float, y2: float,
|
handle: str,
|
||||||
|
x1: float,
|
||||||
|
y1: float,
|
||||||
|
x2: float,
|
||||||
|
y2: float,
|
||||||
delete_original: bool = False,
|
delete_original: bool = False,
|
||||||
) -> EntityInfo: ...
|
) -> EntityInfo: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_offset(
|
async def entity_offset(
|
||||||
self, handle: str, distance: float,
|
self,
|
||||||
side_x: float | None = None, side_y: float | None = None,
|
handle: str,
|
||||||
|
distance: float,
|
||||||
|
side_x: float | None = None,
|
||||||
|
side_y: float | None = None,
|
||||||
) -> EntityInfo: ...
|
) -> EntityInfo: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -374,22 +542,32 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_array_rectangular(
|
async def entity_array_rectangular(
|
||||||
self, handle: str,
|
self,
|
||||||
rows: int, cols: int, row_spacing: float, col_spacing: float,
|
handle: str,
|
||||||
|
rows: int,
|
||||||
|
cols: int,
|
||||||
|
row_spacing: float,
|
||||||
|
col_spacing: float,
|
||||||
) -> list[EntityInfo]: ...
|
) -> list[EntityInfo]: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_array_polar(
|
async def entity_array_polar(
|
||||||
self, handle: str,
|
self,
|
||||||
count: int, fill_angle: float,
|
handle: str,
|
||||||
center_x: float, center_y: float,
|
count: int,
|
||||||
|
fill_angle: float,
|
||||||
|
center_x: float,
|
||||||
|
center_y: float,
|
||||||
) -> list[EntityInfo]: ...
|
) -> list[EntityInfo]: ...
|
||||||
|
|
||||||
# ── corner operations (trim/extend/fillet/chamfer) ───────────────────────
|
# ── corner operations (trim/extend/fillet/chamfer) ───────────────────────
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_trim(
|
async def entity_trim(
|
||||||
self, target_handle: str, cutter_handle: str,
|
self,
|
||||||
keep_x: float, keep_y: float,
|
target_handle: str,
|
||||||
|
cutter_handle: str,
|
||||||
|
keep_x: float,
|
||||||
|
keep_y: float,
|
||||||
) -> EntityInfo:
|
) -> EntityInfo:
|
||||||
"""Trim `target` against `cutter`; keep the segment containing
|
"""Trim `target` against `cutter`; keep the segment containing
|
||||||
(keep_x, keep_y). Raises ToolError if no intersection."""
|
(keep_x, keep_y). Raises ToolError if no intersection."""
|
||||||
@@ -397,8 +575,11 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_extend(
|
async def entity_extend(
|
||||||
self, target_handle: str, boundary_handle: str,
|
self,
|
||||||
end_x: float | None = None, end_y: float | None = None,
|
target_handle: str,
|
||||||
|
boundary_handle: str,
|
||||||
|
end_x: float | None = None,
|
||||||
|
end_y: float | None = None,
|
||||||
) -> EntityInfo:
|
) -> EntityInfo:
|
||||||
"""Extend `target` to meet `boundary`. If `end_x/y` is None, the
|
"""Extend `target` to meet `boundary`. If `end_x/y` is None, the
|
||||||
target endpoint nearest the boundary is auto-selected."""
|
target endpoint nearest the boundary is auto-selected."""
|
||||||
@@ -406,7 +587,10 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_fillet(
|
async def entity_fillet(
|
||||||
self, handle1: str, handle2: str, radius: float,
|
self,
|
||||||
|
handle1: str,
|
||||||
|
handle2: str,
|
||||||
|
radius: float,
|
||||||
trim: bool = True,
|
trim: bool = True,
|
||||||
) -> EntityInfo:
|
) -> EntityInfo:
|
||||||
"""Fillet two entities with the given radius. Returns the new ARC.
|
"""Fillet two entities with the given radius. Returns the new ARC.
|
||||||
@@ -416,8 +600,11 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_chamfer(
|
async def entity_chamfer(
|
||||||
self, handle1: str, handle2: str,
|
self,
|
||||||
dist1: float, dist2: float | None = None,
|
handle1: str,
|
||||||
|
handle2: str,
|
||||||
|
dist1: float,
|
||||||
|
dist2: float | None = None,
|
||||||
trim: bool = True,
|
trim: bool = True,
|
||||||
) -> EntityInfo:
|
) -> EntityInfo:
|
||||||
"""Chamfer two entities. When `dist2` is None it defaults to `dist1`
|
"""Chamfer two entities. When `dist2` is None it defaults to `dist1`
|
||||||
@@ -430,7 +617,8 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_set_properties(
|
async def entity_set_properties(
|
||||||
self, handle: str,
|
self,
|
||||||
|
handle: str,
|
||||||
layer: str | None = None,
|
layer: str | None = None,
|
||||||
color: int | None = None,
|
color: int | None = None,
|
||||||
linetype: str | None = None,
|
linetype: str | None = None,
|
||||||
@@ -440,7 +628,8 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_edit_text(
|
async def entity_edit_text(
|
||||||
self, handle: str,
|
self,
|
||||||
|
handle: str,
|
||||||
text: str | None = None,
|
text: str | None = None,
|
||||||
height: float | None = None,
|
height: float | None = None,
|
||||||
rotation: float | None = None,
|
rotation: float | None = None,
|
||||||
@@ -452,12 +641,17 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def entity_edit_geometry(
|
async def entity_edit_geometry(
|
||||||
self, handle: str,
|
self,
|
||||||
cx: float | None = None, cy: float | None = None,
|
handle: str,
|
||||||
|
cx: float | None = None,
|
||||||
|
cy: float | None = None,
|
||||||
radius: float | None = None,
|
radius: float | None = None,
|
||||||
x1: float | None = None, y1: float | None = None,
|
x1: float | None = None,
|
||||||
x2: float | None = None, y2: float | None = None,
|
y1: float | None = None,
|
||||||
start_angle: float | None = None, end_angle: float | None = None,
|
x2: float | None = None,
|
||||||
|
y2: float | None = None,
|
||||||
|
start_angle: float | None = None,
|
||||||
|
end_angle: float | None = None,
|
||||||
) -> EntityInfo:
|
) -> EntityInfo:
|
||||||
"""Edit the defining geometry of an existing entity in place.
|
"""Edit the defining geometry of an existing entity in place.
|
||||||
- CIRCLE: cx / cy / radius
|
- CIRCLE: cx / cy / radius
|
||||||
@@ -481,7 +675,8 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def layer_create(
|
async def layer_create(
|
||||||
self, name: str,
|
self,
|
||||||
|
name: str,
|
||||||
color: int = 7,
|
color: int = 7,
|
||||||
linetype: str = "Continuous",
|
linetype: str = "Continuous",
|
||||||
lineweight: float = -3,
|
lineweight: float = -3,
|
||||||
@@ -495,7 +690,8 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def layer_modify(
|
async def layer_modify(
|
||||||
self, name: str,
|
self,
|
||||||
|
name: str,
|
||||||
color: int | None = None,
|
color: int | None = None,
|
||||||
linetype: str | None = None,
|
linetype: str | None = None,
|
||||||
lineweight: float | None = None,
|
lineweight: float | None = None,
|
||||||
@@ -525,7 +721,9 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def linetype_load(
|
async def linetype_load(
|
||||||
self, name: str, file: str | None = None,
|
self,
|
||||||
|
name: str,
|
||||||
|
file: str | None = None,
|
||||||
) -> dict: ...
|
) -> dict: ...
|
||||||
|
|
||||||
# ── block operations ─────────────────────────────────────────────────────
|
# ── block operations ─────────────────────────────────────────────────────
|
||||||
@@ -534,8 +732,13 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def block_insert(
|
async def block_insert(
|
||||||
self, name: str, x: float, y: float,
|
self,
|
||||||
scale_x: float = 1.0, scale_y: float = 1.0, rotation: float = 0.0,
|
name: str,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
scale_x: float = 1.0,
|
||||||
|
scale_y: float = 1.0,
|
||||||
|
rotation: float = 0.0,
|
||||||
attributes: dict | None = None,
|
attributes: dict | None = None,
|
||||||
layer: str | None = None,
|
layer: str | None = None,
|
||||||
) -> EntityInfo: ...
|
) -> EntityInfo: ...
|
||||||
@@ -551,8 +754,11 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def block_create_from_entities(
|
async def block_create_from_entities(
|
||||||
self, name: str, handles: list[str],
|
self,
|
||||||
base_x: float = 0.0, base_y: float = 0.0,
|
name: str,
|
||||||
|
handles: list[str],
|
||||||
|
base_x: float = 0.0,
|
||||||
|
base_y: float = 0.0,
|
||||||
) -> dict: ...
|
) -> dict: ...
|
||||||
|
|
||||||
# ── analysis / query ─────────────────────────────────────────────────────
|
# ── analysis / query ─────────────────────────────────────────────────────
|
||||||
@@ -561,12 +767,20 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def analysis_entities_in_region(
|
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]: ...
|
) -> list[EntityInfo]: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def analysis_measure_distance(
|
async def analysis_measure_distance(
|
||||||
self, x1: float, y1: float, x2: float, y2: float,
|
self,
|
||||||
|
x1: float,
|
||||||
|
y1: float,
|
||||||
|
x2: float,
|
||||||
|
y2: float,
|
||||||
) -> float: ...
|
) -> float: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -607,7 +821,11 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def view_zoom_window(
|
async def view_zoom_window(
|
||||||
self, x1: float, y1: float, x2: float, y2: float,
|
self,
|
||||||
|
x1: float,
|
||||||
|
y1: float,
|
||||||
|
x2: float,
|
||||||
|
y2: float,
|
||||||
) -> dict: ...
|
) -> dict: ...
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -690,7 +908,8 @@ class AutoCADBackend(ABC):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
async def drawing_plan(
|
async def drawing_plan(
|
||||||
self, intent: str,
|
self,
|
||||||
|
intent: str,
|
||||||
sheet_size: SheetSize = "A3",
|
sheet_size: SheetSize = "A3",
|
||||||
scale: float = 1.0,
|
scale: float = 1.0,
|
||||||
layer_set_id: LayerSetId = "mech",
|
layer_set_id: LayerSetId = "mech",
|
||||||
@@ -763,7 +982,8 @@ class AutoCADBackend(ABC):
|
|||||||
return plan
|
return plan
|
||||||
|
|
||||||
async def drawing_critique(
|
async def drawing_critique(
|
||||||
self, focus: list[CritiqueFocus] | None = None,
|
self,
|
||||||
|
focus: list[CritiqueFocus] | None = None,
|
||||||
) -> list[Issue]:
|
) -> list[Issue]:
|
||||||
"""Run premium-quality geometric checks; return zero issues before
|
"""Run premium-quality geometric checks; return zero issues before
|
||||||
`drawing_finalize`. `focus=None` runs all checks. (N8: this does NOT
|
`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
|
return plan.to_dict() if plan is not None else None
|
||||||
|
|
||||||
async def point_from_snap(
|
async def point_from_snap(
|
||||||
self, handle: str, snap: SnapType,
|
self,
|
||||||
ref_x: float | None = None, ref_y: float | None = None,
|
handle: str,
|
||||||
|
snap: SnapType,
|
||||||
|
ref_x: float | None = None,
|
||||||
|
ref_y: float | None = None,
|
||||||
) -> tuple[float, float]:
|
) -> tuple[float, float]:
|
||||||
"""Return a deterministic snap point on `handle`.
|
"""Return a deterministic snap point on `handle`.
|
||||||
snap ∈ {end, mid, center, quad, perp, near}. For `near/perp`,
|
snap ∈ {end, mid, center, quad, perp, near}. For `near/perp`,
|
||||||
@@ -791,27 +1014,20 @@ class AutoCADBackend(ABC):
|
|||||||
ent = await self.entity_get(handle)
|
ent = await self.entity_get(handle)
|
||||||
et = ent.type
|
et = ent.type
|
||||||
p = ent.properties
|
p = ent.properties
|
||||||
ref = (
|
ref = (float(ref_x), float(ref_y)) if ref_x is not None and ref_y is not None else None
|
||||||
(float(ref_x), float(ref_y))
|
|
||||||
if ref_x is not None and ref_y is not None else None
|
|
||||||
)
|
|
||||||
|
|
||||||
def _line_pts():
|
def _line_pts():
|
||||||
s = p.get("start")
|
s = p.get("start")
|
||||||
e = p.get("end")
|
e = p.get("end")
|
||||||
if s is None or e is None:
|
if s is None or e is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no start/end.")
|
||||||
f"point_from_snap: entity {handle} ({et}) has no start/end."
|
|
||||||
)
|
|
||||||
return (float(s[0]), float(s[1])), (float(e[0]), float(e[1]))
|
return (float(s[0]), float(s[1])), (float(e[0]), float(e[1]))
|
||||||
|
|
||||||
def _circle_geom():
|
def _circle_geom():
|
||||||
c = p.get("center")
|
c = p.get("center")
|
||||||
r = p.get("radius")
|
r = p.get("radius")
|
||||||
if c is None or r is None:
|
if c is None or r is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no center/radius.")
|
||||||
f"point_from_snap: entity {handle} ({et}) has no center/radius."
|
|
||||||
)
|
|
||||||
return float(c[0]), float(c[1]), float(r)
|
return float(c[0]), float(c[1]), float(r)
|
||||||
|
|
||||||
def _arc_angles():
|
def _arc_angles():
|
||||||
@@ -822,8 +1038,7 @@ class AutoCADBackend(ABC):
|
|||||||
ea = p.get("end_angle")
|
ea = p.get("end_angle")
|
||||||
if sa is None or ea is None:
|
if sa is None or ea is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"point_from_snap: entity {handle} ({et}) has no "
|
f"point_from_snap: entity {handle} ({et}) has no start_angle/end_angle."
|
||||||
"start_angle/end_angle."
|
|
||||||
)
|
)
|
||||||
return math.radians(float(sa)), math.radians(float(ea))
|
return math.radians(float(sa)), math.radians(float(ea))
|
||||||
|
|
||||||
@@ -864,9 +1079,7 @@ class AutoCADBackend(ABC):
|
|||||||
if et in ("CIRCLE", "ARC", "ELLIPSE"):
|
if et in ("CIRCLE", "ARC", "ELLIPSE"):
|
||||||
c = p.get("center")
|
c = p.get("center")
|
||||||
if c is None:
|
if c is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no center.")
|
||||||
f"point_from_snap: entity {handle} ({et}) has no center."
|
|
||||||
)
|
|
||||||
return (float(c[0]), float(c[1]))
|
return (float(c[0]), float(c[1]))
|
||||||
raise RuntimeError(f"point_from_snap: 'center' snap not supported on {et}")
|
raise RuntimeError(f"point_from_snap: 'center' snap not supported on {et}")
|
||||||
|
|
||||||
@@ -1127,7 +1340,12 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def _create_xline(
|
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:
|
) -> EntityInfo:
|
||||||
"""Create an infinite construction line (XLINE) through (x, y) with
|
"""Create an infinite construction line (XLINE) through (x, y) with
|
||||||
unit direction (dx, dy) on `layer`. Backend-specific: XLINE has no
|
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
|
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)."""
|
iso13567 (M-DIMEN-T-N / M-CONST-E-N) not just mech/pid (DIM/CONSTRUCTION)."""
|
||||||
from engineering.layers import resolve_role_layer
|
from engineering.layers import resolve_role_layer
|
||||||
|
|
||||||
return resolve_role_layer(self._active_layer_set_id(), role)
|
return resolve_role_layer(self._active_layer_set_id(), role)
|
||||||
|
|
||||||
async def construction_xline(
|
async def construction_xline(
|
||||||
self, x: float, y: float, angle_deg: float,
|
self,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
angle_deg: float,
|
||||||
layer: str | None = None,
|
layer: str | None = None,
|
||||||
) -> EntityInfo:
|
) -> EntityInfo:
|
||||||
"""Create an infinite construction line (XLINE) on the active layer set's
|
"""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)
|
await self._ensure_layer(layer, color=250)
|
||||||
ang = math.radians(float(angle_deg))
|
ang = math.radians(float(angle_deg))
|
||||||
return await self._create_xline(
|
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(
|
async def construction_clear(
|
||||||
self, layer: str | None = None,
|
self,
|
||||||
|
layer: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Delete every entity on the active layer set's construction layer.
|
"""Delete every entity on the active layer set's construction layer.
|
||||||
Must be called before `drawing_finalize`."""
|
Must be called before `drawing_finalize`."""
|
||||||
@@ -1197,18 +1424,23 @@ class AutoCADBackend(ABC):
|
|||||||
return {"ok": True, "layer": layer, "deleted": deleted}
|
return {"ok": True, "layer": layer, "deleted": deleted}
|
||||||
|
|
||||||
async def drawing_apply_iso_layers(
|
async def drawing_apply_iso_layers(
|
||||||
self, standard: LayerSetId = "mech",
|
self,
|
||||||
|
standard: LayerSetId = "mech",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Bootstrap a full ISO-conformant layer set with correct colors
|
"""Bootstrap a full ISO-conformant layer set with correct colors
|
||||||
and lineweights. Idempotent."""
|
and lineweights. Idempotent."""
|
||||||
from engineering.layers import apply_layer_set
|
from engineering.layers import apply_layer_set
|
||||||
|
|
||||||
result = await apply_layer_set(self, standard)
|
result = await apply_layer_set(self, standard)
|
||||||
self._active_layer_set = standard
|
self._active_layer_set = standard
|
||||||
return {"ok": True, "standard": standard, "layers": result}
|
return {"ok": True, "standard": standard, "layers": result}
|
||||||
|
|
||||||
async def dimension_auto(
|
async def dimension_auto(
|
||||||
self, handles: list[str], style: DimStyle = "chain",
|
self,
|
||||||
offset: float = 10.0, layer: str | None = None,
|
handles: list[str],
|
||||||
|
style: DimStyle = "chain",
|
||||||
|
offset: float = 10.0,
|
||||||
|
layer: str | None = None,
|
||||||
) -> list[EntityInfo]:
|
) -> list[EntityInfo]:
|
||||||
"""Generate ISO 129 dimensions across `handles` in the chosen style
|
"""Generate ISO 129 dimensions across `handles` in the chosen style
|
||||||
(chain | baseline | ordinate). `offset` is the dimension-line
|
(chain | baseline | ordinate). `offset` is the dimension-line
|
||||||
@@ -1219,16 +1451,14 @@ class AutoCADBackend(ABC):
|
|||||||
dim_layer = layer or self._role_layer("dim")
|
dim_layer = layer or self._role_layer("dim")
|
||||||
if style not in ("chain", "baseline", "ordinate"):
|
if style not in ("chain", "baseline", "ordinate"):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"dimension_auto: unknown style '{style}'. "
|
f"dimension_auto: unknown style '{style}'. Use 'chain', 'baseline', or 'ordinate'."
|
||||||
"Use 'chain', 'baseline', or 'ordinate'."
|
|
||||||
)
|
)
|
||||||
segs: list[tuple[float, float, float, float]] = []
|
segs: list[tuple[float, float, float, float]] = []
|
||||||
for h in handles:
|
for h in handles:
|
||||||
ent = await self.entity_get(h)
|
ent = await self.entity_get(h)
|
||||||
if ent.type != "LINE":
|
if ent.type != "LINE":
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"dimension_auto V1: only LINE entities supported "
|
f"dimension_auto V1: only LINE entities supported (handle {h} is {ent.type})."
|
||||||
f"(handle {h} is {ent.type})."
|
|
||||||
)
|
)
|
||||||
s = ent.properties.get("start")
|
s = ent.properties.get("start")
|
||||||
e = ent.properties.get("end")
|
e = ent.properties.get("end")
|
||||||
@@ -1249,8 +1479,14 @@ class AutoCADBackend(ABC):
|
|||||||
L = math.hypot(dx, dy) or 1.0
|
L = math.hypot(dx, dy) or 1.0
|
||||||
nx, ny = -dy / L, dx / L
|
nx, ny = -dy / L, dx / L
|
||||||
dim = await self.dimension_linear(
|
dim = await self.dimension_linear(
|
||||||
sx, sy, ex, ey, mx + nx * off, my + ny * off,
|
sx,
|
||||||
rotation=math.degrees(math.atan2(dy, dx)), layer=dim_layer,
|
sy,
|
||||||
|
ex,
|
||||||
|
ey,
|
||||||
|
mx + nx * off,
|
||||||
|
my + ny * off,
|
||||||
|
rotation=math.degrees(math.atan2(dy, dx)),
|
||||||
|
layer=dim_layer,
|
||||||
)
|
)
|
||||||
results.append(dim)
|
results.append(dim)
|
||||||
|
|
||||||
@@ -1262,11 +1498,16 @@ class AutoCADBackend(ABC):
|
|||||||
base_rot = math.degrees(math.atan2(dy, dx))
|
base_rot = math.degrees(math.atan2(dy, dx))
|
||||||
for i, (_sx, _sy, ex, ey) in enumerate(segs):
|
for i, (_sx, _sy, ex, ey) in enumerate(segs):
|
||||||
dim = await self.dimension_linear(
|
dim = await self.dimension_linear(
|
||||||
sx0, sy0, ex, ey,
|
sx0,
|
||||||
sx0 - uy * (off + i * off), sy0 + ux * (off + i * off),
|
sy0,
|
||||||
|
ex,
|
||||||
|
ey,
|
||||||
|
sx0 - uy * (off + i * off),
|
||||||
|
sy0 + ux * (off + i * off),
|
||||||
# Dimension line must run parallel to the (possibly rotated)
|
# Dimension line must run parallel to the (possibly rotated)
|
||||||
# baseline, not horizontal.
|
# baseline, not horizontal.
|
||||||
rotation=base_rot, layer=dim_layer,
|
rotation=base_rot,
|
||||||
|
layer=dim_layer,
|
||||||
)
|
)
|
||||||
results.append(dim)
|
results.append(dim)
|
||||||
|
|
||||||
@@ -1275,22 +1516,33 @@ class AutoCADBackend(ABC):
|
|||||||
rung = off + i * off # stagger per feature to avoid coincident dims
|
rung = off + i * off # stagger per feature to avoid coincident dims
|
||||||
if abs(ex - sx) > 1e-9:
|
if abs(ex - sx) > 1e-9:
|
||||||
dim = await self.dimension_linear(
|
dim = await self.dimension_linear(
|
||||||
sx, sy, ex, sy,
|
sx,
|
||||||
sx + (ex - sx) / 2.0, max(sy, ey) + rung, layer=dim_layer,
|
sy,
|
||||||
|
ex,
|
||||||
|
sy,
|
||||||
|
sx + (ex - sx) / 2.0,
|
||||||
|
max(sy, ey) + rung,
|
||||||
|
layer=dim_layer,
|
||||||
)
|
)
|
||||||
results.append(dim)
|
results.append(dim)
|
||||||
if abs(ey - sy) > 1e-9:
|
if abs(ey - sy) > 1e-9:
|
||||||
dim = await self.dimension_linear(
|
dim = await self.dimension_linear(
|
||||||
ex, sy, ex, ey,
|
ex,
|
||||||
max(sx, ex) + rung, (sy + ey) / 2.0,
|
sy,
|
||||||
rotation=90.0, layer=dim_layer,
|
ex,
|
||||||
|
ey,
|
||||||
|
max(sx, ex) + rung,
|
||||||
|
(sy + ey) / 2.0,
|
||||||
|
rotation=90.0,
|
||||||
|
layer=dim_layer,
|
||||||
)
|
)
|
||||||
results.append(dim)
|
results.append(dim)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def entity_select_smart(
|
async def entity_select_smart(
|
||||||
self, predicate: dict,
|
self,
|
||||||
|
predicate: dict,
|
||||||
) -> list[EntityInfo]:
|
) -> list[EntityInfo]:
|
||||||
"""Semantic entity selection. Predicate keys (all optional, AND-ed):
|
"""Semantic entity selection. Predicate keys (all optional, AND-ed):
|
||||||
- type: "LINE" | "CIRCLE" | ...
|
- type: "LINE" | "CIRCLE" | ...
|
||||||
@@ -1321,13 +1573,15 @@ class AutoCADBackend(ABC):
|
|||||||
nx, ny, nr = float(pnear[0]), float(pnear[1]), float(pnear[2])
|
nx, ny, nr = float(pnear[0]), float(pnear[1]), float(pnear[2])
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
pt = (e.properties.get("start")
|
pt = (
|
||||||
|
e.properties.get("start")
|
||||||
or e.properties.get("center")
|
or e.properties.get("center")
|
||||||
or e.properties.get("insertion")
|
or e.properties.get("insertion")
|
||||||
or e.properties.get("insertion_point"))
|
or e.properties.get("insertion_point")
|
||||||
|
)
|
||||||
if pt is None:
|
if pt is None:
|
||||||
return False
|
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
|
return False
|
||||||
if plen is not None and e.type in ("LINE", "ARC"):
|
if plen is not None and e.type in ("LINE", "ARC"):
|
||||||
try:
|
try:
|
||||||
@@ -1349,14 +1603,23 @@ class AutoCADBackend(ABC):
|
|||||||
# every referenced datum is actually established on the part.
|
# every referenced datum is actually established on the part.
|
||||||
|
|
||||||
async def _place_boxed_text(
|
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:
|
) -> str:
|
||||||
"""Create a TEXT entity roughly centred on (cx, cy). Returns its handle.
|
"""Create a TEXT entity roughly centred on (cx, cy). Returns its handle.
|
||||||
entity_create_text has no alignment arg, so approximate the centring with
|
entity_create_text has no alignment arg, so approximate the centring with
|
||||||
the same width heuristic the title block uses."""
|
the same width heuristic the title block uses."""
|
||||||
approx_w = len(text) * text_h * 0.7
|
approx_w = len(text) * text_h * 0.7
|
||||||
t = await self.entity_create_text(
|
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
|
return t.handle
|
||||||
|
|
||||||
@@ -1386,8 +1649,13 @@ class AutoCADBackend(ABC):
|
|||||||
critique focus.
|
critique focus.
|
||||||
"""
|
"""
|
||||||
from engineering.gdt import fcf_compartments, fcf_layout
|
from engineering.gdt import fcf_compartments, fcf_layout
|
||||||
|
|
||||||
comps = fcf_compartments(
|
comps = fcf_compartments(
|
||||||
symbol, tolerance, datums, diameter=diameter, modifier=modifier,
|
symbol,
|
||||||
|
tolerance,
|
||||||
|
datums,
|
||||||
|
diameter=diameter,
|
||||||
|
modifier=modifier,
|
||||||
)
|
)
|
||||||
layer = layer or self._role_layer("dim")
|
layer = layer or self._role_layer("dim")
|
||||||
await self._ensure_layer(layer, color=2)
|
await self._ensure_layer(layer, color=2)
|
||||||
@@ -1396,7 +1664,9 @@ class AutoCADBackend(ABC):
|
|||||||
|
|
||||||
box = lay["box"]
|
box = lay["box"]
|
||||||
box_poly = await self.entity_create_polyline(
|
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)
|
handles.append(box_poly.handle)
|
||||||
for (sx, sy), (ex, ey) in lay["dividers"]:
|
for (sx, sy), (ex, ey) in lay["dividers"]:
|
||||||
@@ -1410,7 +1680,7 @@ class AutoCADBackend(ABC):
|
|||||||
if refs is None:
|
if refs is None:
|
||||||
refs = set()
|
refs = set()
|
||||||
self._gdt_datums_referenced = refs
|
self._gdt_datums_referenced = refs
|
||||||
for d in (datums or []):
|
for d in datums or []:
|
||||||
d = str(d).strip().upper()
|
d = str(d).strip().upper()
|
||||||
if d:
|
if d:
|
||||||
refs.add(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
|
with its apex at (x, y). Records the datum letter so a feature control
|
||||||
frame that references it passes the `gdt` critique focus."""
|
frame that references it passes the `gdt` critique focus."""
|
||||||
from engineering.gdt import datum_triangle, fcf_layout
|
from engineering.gdt import datum_triangle, fcf_layout
|
||||||
|
|
||||||
letter = str(letter).strip().upper()
|
letter = str(letter).strip().upper()
|
||||||
if not letter:
|
if not letter:
|
||||||
raise RuntimeError("draw_datum_feature: a datum letter is required.")
|
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 = datum_triangle(float(x), float(y), size, down=True)
|
||||||
tri_poly = await self.entity_create_polyline(
|
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)
|
handles.append(tri_poly.handle)
|
||||||
|
|
||||||
box_h = size * 1.4
|
box_h = size * 1.4
|
||||||
lay = fcf_layout([letter], float(x) - box_h / 2.0, float(y) - size - box_h, box_h)
|
lay = fcf_layout([letter], float(x) - box_h / 2.0, float(y) - size - box_h, box_h)
|
||||||
box_poly = await self.entity_create_polyline(
|
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)
|
handles.append(box_poly.handle)
|
||||||
for text, tcx, tcy in lay["labels"]:
|
for text, tcx, tcy in lay["labels"]:
|
||||||
handles.append(
|
handles.append(await self._place_boxed_text(text, tcx, tcy, lay["text_height"], layer))
|
||||||
await self._place_boxed_text(text, tcx, tcy, lay["text_height"], layer)
|
|
||||||
)
|
|
||||||
|
|
||||||
defined = getattr(self, "_gdt_datums_defined", None)
|
defined = getattr(self, "_gdt_datums_defined", None)
|
||||||
if defined is None:
|
if defined is None:
|
||||||
@@ -1516,7 +1789,6 @@ class AutoCADBackend(ABC):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1538,11 +1810,22 @@ _SETTING_MAP: dict[str, tuple[str, str]] = {
|
|||||||
|
|
||||||
# INSUNITS code table (AutoCAD $INSUNITS): friendly name <-> integer code.
|
# INSUNITS code table (AutoCAD $INSUNITS): friendly name <-> integer code.
|
||||||
_UNIT_TO_CODE: dict[str, int] = {
|
_UNIT_TO_CODE: dict[str, int] = {
|
||||||
"unitless": 0, "inch": 1, "inches": 1, "in": 1,
|
"unitless": 0,
|
||||||
"feet": 2, "ft": 2, "foot": 2,
|
"inch": 1,
|
||||||
"mm": 4, "millimeter": 4, "millimeters": 4,
|
"inches": 1,
|
||||||
"cm": 5, "centimeter": 5, "centimeters": 5,
|
"in": 1,
|
||||||
"m": 6, "meter": 6, "meters": 6,
|
"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"}
|
_CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"}
|
||||||
|
|
||||||
|
|||||||
+561
-103
File diff suppressed because it is too large
Load Diff
+557
-112
File diff suppressed because it is too large
Load Diff
@@ -43,5 +43,16 @@ class Settings:
|
|||||||
)
|
)
|
||||||
self.mcp_auth_token: str = os.environ.get("MCP_AUTH_TOKEN", "").strip()
|
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()
|
settings = Settings()
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -50,12 +50,17 @@ def build_dim_override(
|
|||||||
elif mode == "deviation":
|
elif mode == "deviation":
|
||||||
if tol_upper is None or tol_lower is None:
|
if tol_upper is None or tol_lower is None:
|
||||||
raise ValueError("tol_mode='deviation' requires tol_upper and tol_lower.")
|
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(
|
override.update(
|
||||||
{
|
{
|
||||||
"dimtol": 1,
|
"dimtol": 1,
|
||||||
"dimlim": 0,
|
"dimlim": 0,
|
||||||
"dimtp": float(tol_upper),
|
"dimtp": float(tol_upper),
|
||||||
"dimtm": abs(float(tol_lower)),
|
"dimtm": float(tol_lower),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif mode == "limit":
|
elif mode == "limit":
|
||||||
@@ -66,7 +71,7 @@ def build_dim_override(
|
|||||||
"dimlim": 1,
|
"dimlim": 1,
|
||||||
"dimtol": 0,
|
"dimtol": 0,
|
||||||
"dimtp": float(tol_upper),
|
"dimtp": float(tol_upper),
|
||||||
"dimtm": abs(float(tol_lower)),
|
"dimtm": float(tol_lower),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif mode == "basic":
|
elif mode == "basic":
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from backends.base import CapabilityMap, FeatureCapability
|
from backends.base import CapabilityMap, FeatureCapability
|
||||||
|
from version import __version__
|
||||||
|
|
||||||
|
|
||||||
def test_capability_types_serialize_stably():
|
def test_capability_types_serialize_stably():
|
||||||
@@ -32,7 +33,11 @@ async def test_ezdxf_capabilities_are_machine_readable(backend):
|
|||||||
"reason": None,
|
"reason": None,
|
||||||
}
|
}
|
||||||
assert caps["features"]["mleader"]["mode"] == "composite"
|
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
|
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())
|
result = await server.system_about(ctx=Ctx())
|
||||||
|
|
||||||
assert result["version"] == "1.3.0"
|
assert result["version"] == __version__
|
||||||
assert result["capabilities"]["table"]["mode"] == "composite"
|
assert result["capabilities"]["table"]["mode"] == "composite"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import pytest
|
|||||||
import server
|
import server
|
||||||
from engineering.delivery import compare_drawing_snapshots, deliver_drawing, drawing_snapshot
|
from engineering.delivery import compare_drawing_snapshots, deliver_drawing, drawing_snapshot
|
||||||
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
|
from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes
|
||||||
|
from version import __version__
|
||||||
|
|
||||||
|
|
||||||
async def _clean_drawing(backend) -> None:
|
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["status"] == "created"
|
||||||
assert dxf["sha256"] == hashlib.sha256(artifact_path.read_bytes()).hexdigest()
|
assert dxf["sha256"] == hashlib.sha256(artifact_path.read_bytes()).hexdigest()
|
||||||
assert manifest["parity"]["ok"] is True
|
assert manifest["parity"]["ok"] is True
|
||||||
assert manifest["version"] == "1.3.0"
|
assert manifest["version"] == __version__
|
||||||
assert manifest["capabilities"]["backend"] == "ezdxf"
|
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["listed_entity_count"] == 1
|
||||||
assert source["inventory_complete"] is False
|
assert source["inventory_complete"] is False
|
||||||
assert result["ok"] is False
|
assert result["ok"] is False
|
||||||
assert "source_inventory_complete" in {
|
assert "source_inventory_complete" in {item["field"] for item in result["differences"]}
|
||||||
item["field"] for item in result["differences"]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user