fix(com): hatch islands on a live seat — typed edges become temporary entities

hatch_add_boundary was refused on COM (hatch_edge_paths), so a transverse
section of any hollow part — a bushing, a flange bore — could not be drawn in
AutoCAD at all: draw_prims asked the capability map and refused up front.

ActiveX takes a loop as whole objects, which is not the same as "cannot take a
typed edge": each edge now becomes a real temporary LINE, true ARC or ELLIPSE
in the hatch's own space, appended with AppendInnerLoop, evaluated and
deleted. A loop that is only chained lines becomes one closed polyline — one
COM call instead of 180 for a flattened bore. The edge list is validated in
full first with the headless engine's messages; a loop AutoCAD refuses is
taken back out with its temporaries.

Measured on AutoCAD 2026 (25.1s), in scratch documents closed unsaved:
- a two-arc island leaves exactly 10000 - 100*pi (9685.8407) of a 100 x 100 hatch;
- the OD40/ID20 sleeve's transverse section B-B, drawn through the view engine
  and draw_prims, hatches 942.2864 mm2 — the engine's declared 180-gon;
- an open chain is refused (E_FAIL) and the entity count is unchanged.

hatch_edge_paths is now supported on both engines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-23 13:51:35 +03:00
co-authored by Claude Opus 5.5
parent e6f685643d
commit 2bba18d0f7
6 changed files with 368 additions and 18 deletions
+165 -11
View File
@@ -1265,6 +1265,99 @@ def _type_filter_matches(type_filter: str, ent_type: str) -> bool:
return wanted == "DIMENSION" and ent_type.endswith("DIMENSION")
def _prepare_hatch_edges(edges):
"""Validate a typed edge list the way the headless engine does.
Returns ``(prepared, None)`` or ``(None, refusal)``; nothing is created
until every edge has passed.
"""
if not edges:
return None, {"ok": False, "error": "edges must not be empty"}
required = {
"line": ("start", "end"),
"arc": ("center", "radius", "start_angle", "end_angle"),
"ellipse": ("center", "major_axis", "ratio"),
}
prepared = []
for index, edge in enumerate(edges):
if not isinstance(edge, dict):
return None, {"ok": False, "error": f"edge {index} is not an object"}
kind = str(edge.get("type", "")).strip().lower()
if kind not in required:
return None, {
"ok": False,
"error": (
f"edge {index}: unknown type {edge.get('type')!r}; valid types are "
"line, arc, ellipse"
),
}
missing = [key for key in required[kind] if key not in edge]
if missing:
return None, {"ok": False, "error": f"edge {index} ({kind}) is missing {missing[0]!r}"}
keys = list(required[kind])
if kind == "ellipse":
keys += [k for k in ("start_angle", "end_angle") if k in edge]
numbers = []
try:
for key in keys:
value = edge[key]
if isinstance(value, (list, tuple)):
numbers.extend(float(v) for v in value[:2])
else:
numbers.append(float(value))
except (TypeError, ValueError):
return None, {"ok": False, "error": f"edge {index} ({kind}) has a non-numeric value"}
if not all(math.isfinite(v) for v in numbers):
return None, {"ok": False, "error": f"edge {index} ({kind}) has a non-finite value"}
if kind == "arc" and float(edge["radius"]) <= 0:
return None, {"ok": False, "error": f"edge {index} (arc) radius must be > 0"}
if kind == "ellipse" and not 0 < float(edge["ratio"]) <= 1:
return None, {"ok": False, "error": f"edge {index} (ellipse) ratio must be in (0, 1]"}
prepared.append((kind, edge))
return prepared, None
def _closed_line_chain(prepared, tol: float = 1e-6):
"""The loop's vertices when it is only lines, each starting where the last
ended and the last closing on the first; otherwise ``None``."""
if any(kind != "line" for kind, _ in prepared):
return None
points = [(float(e["start"][0]), float(e["start"][1])) for _, e in prepared]
ends = [(float(e["end"][0]), float(e["end"][1])) for _, e in prepared]
for i, end in enumerate(ends):
nxt = points[(i + 1) % len(points)]
if abs(end[0] - nxt[0]) > tol or abs(end[1] - nxt[1]) > tol:
return None
return points if len(points) >= 3 else None
def _temporary_edge_entity(space, kind: str, edge: dict):
"""One typed edge as a real ActiveX entity: LINE, ARC or ELLIPSE."""
if kind == "line":
(x1, y1), (x2, y2) = edge["start"][:2], edge["end"][:2]
return space.AddLine(_apoint(x1, y1), _apoint(x2, y2))
cx, cy = edge["center"][:2]
ccw = bool(edge.get("ccw", True))
if kind == "arc":
start = math.radians(float(edge["start_angle"]))
end = math.radians(float(edge["end_angle"]))
if not ccw: # an ActiveX arc runs counter-clockwise from start to end
start, end = end, start
return space.AddArc(_apoint(cx, cy), float(edge["radius"]), start, end)
mx, my = edge["major_axis"][:2]
ellipse = space.AddEllipse(_apoint(cx, cy), _apoint(mx, my), float(edge["ratio"]))
start = float(edge.get("start_angle", 0.0))
end = float(edge.get("end_angle", 360.0))
if abs((end - start) % 360.0) > 1e-9:
if not ccw:
start, end = end, start
# AcadEllipse.StartAngle/EndAngle are angles from the major axis, the
# quantity the headless engine's ellipse edge carries in degrees.
ellipse.StartAngle = math.radians(start)
ellipse.EndAngle = math.radians(end)
return ellipse
def _entity_info(entity) -> EntityInfo:
"""Convert a COM entity object to EntityInfo dataclass."""
try:
@@ -1560,7 +1653,9 @@ class ComBackend(AutoCADBackend):
),
"boundary_trace": FeatureCapability(False, reason="no_activex_member_command_only"),
"hatch_edge_paths": FeatureCapability(
False, reason="activex_appends_loops_as_objects_not_typed_edges"
True,
"native",
reason="typed_edges_become_temporary_line_arc_ellipse;append_inner_loop",
),
"handle_overlay": FeatureCapability(
False, reason="window_capture_has_no_render_to_label"
@@ -3523,16 +3618,75 @@ class ComBackend(AutoCADBackend):
return await self._run(_sync)
async def hatch_add_boundary(self, handle, edges) -> dict:
"""Refused on the live backend: ActiveX takes boundary loops as whole
objects (AppendOuterLoop / AppendInnerLoop), not as typed edges, so the
curved-edge fidelity this tool exists for cannot be expressed."""
raise UnsupportedCapabilityError(
"hatch_edge_paths",
"hatch_add_boundary: ActiveX appends boundary loops as existing objects rather "
"than typed edges, so an arc edge cannot be given directly. Draw the boundary "
"entities and use entity_create_hatch, or switch to the headless backend "
"(AUTOCAD_MCP_BACKEND=ezdxf).",
)
"""Add one boundary path to a HATCH from typed edges, as an inner loop.
ActiveX takes a loop as whole objects, so every typed edge becomes a
real temporary entity in the hatch's own space - a LINE, a true ARC or
an ELLIPSE, never a chord chain - appended with ``AppendInnerLoop``,
evaluated, then deleted. A loop made only of chained lines becomes one
closed lightweight polyline: one COM call instead of one per edge (a
flattened bore is 180 of them). Measured on AutoCAD 2026 (25.1s): a
20 mm island built from two arcs leaves exactly 10000 - 100*pi of a
100 x 100 hatch, and an open loop is refused by AutoCAD with E_FAIL.
The edge list is validated in full before anything is created, with the
headless engine's messages, and a malformed list answers
``{"ok": False}``; a loop AutoCAD refuses is taken back out, temporary
entities included.
"""
prepared, refusal = _prepare_hatch_edges(edges)
if refusal:
return refusal
def _sync():
doc = _acad_doc()
hatch, refused = self._resolve_com_hatch(doc, handle)
if refused:
return refused
try:
space = _com_unnarrow(doc.ObjectIdToObject(hatch.OwnerID))
except Exception as exc:
log.debug("hatch owner lookup failed, using model space: %s", exc)
space = _msp()
temporary = []
try:
chain = _closed_line_chain(prepared)
if chain is not None:
flat = [c for point in chain for c in point]
loop = space.AddLightWeightPolyline(_av(flat))
loop.Closed = True
temporary.append(loop)
else:
for kind, edge in prepared:
temporary.append(_temporary_edge_entity(space, kind, edge))
objects = win32com.client.VARIANT(
pythoncom.VT_ARRAY | pythoncom.VT_DISPATCH, temporary
)
hatch.AppendInnerLoop(objects)
hatch.Evaluate()
except Exception as exc:
return {
"ok": False,
"error": (
f"AutoCAD did not accept the boundary as a closed loop ({exc}); "
"nothing was added to the hatch."
),
}
finally:
for entity in temporary:
try:
entity.Delete()
except Exception as exc:
log.debug("temporary hatch edge not deleted: %s", exc)
_regen()
return {
"ok": True,
"handle": hatch.Handle,
"path_count": int(hatch.NumberOfLoops),
"edge_types": [kind for kind, _ in prepared],
}
return await self._run(_sync)
async def analysis_list_properties(self, handle: str) -> dict:
def _sync():