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
+15
View File
@@ -183,6 +183,21 @@ model and one view engine instead of a zoo of per-part generators, a new
### Fixed
- **Hatch islands on a live seat.** `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. Each typed edge now
becomes a temporary LINE, true ARC or ELLIPSE appended with
`AppendInnerLoop` and deleted after `Evaluate`; a chain of lines is one
polyline. Measured on AutoCAD 2026: a two-arc island leaves exactly
10000 - 100*pi of a 100 x 100 hatch, the OD40/ID20 sleeve's section B-B hatches
942.2864 mm2 (the view engine's declared 180-gon), and an open loop is
refused with nothing left behind.
- **`block_find_references` found nothing on a live seat.** ActiveX names a
block reference `AcDbBlockReference`, so `entity_list(type_filter="INSERT")`
matched nothing. The COM type filter now lets the DXF name find it (`INSERT`,
`LWPOLYLINE`, and every `*DIMENSION` class for `DIMENSION`); the reported type
is unchanged.
- **Found by the tracks B + G evidence** (each with a test that fails on the
old code):
- `dimension_diameter` put its text beyond the *second* chord point
+1 -1
View File
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
</div>
> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 3995 collected tests.
> **v1.5 release snapshot:** 229 tools · 8 resources · 5 prompt templates · 4005 collected tests.
> 229 is the **registered** count; a default install advertises 224 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
+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():
+6 -4
View File
@@ -216,10 +216,12 @@ async def _ensure_layers(backend, names) -> tuple[str, ...]:
def _refuse_islands_without_edge_paths(backend, prims) -> None:
"""Refuse a hatch with islands on an engine that cannot attach them - before drawing.
Islands go through `hatch_add_boundary`, which the live engine refuses
(capability ``hatch_edge_paths``: ActiveX appends loops as objects, not
typed edges). Finding that out at the hatch would leave the rest of the
view already drawn; asking the capability map first leaves nothing behind.
Islands go through `hatch_add_boundary` (capability ``hatch_edge_paths``).
Both shipped engines support it - the live one turns each typed edge into a
temporary LINE/ARC/ELLIPSE and appends it with ``AppendInnerLoop`` - but a
backend that declares it unsupported must say so before anything is drawn:
finding out at the hatch would leave the rest of the view already on the
sheet, while asking the capability map first leaves nothing behind.
"""
if not any(isinstance(p, HatchArea) and len(p.loops) > 1 for p in prims):
return
+5 -2
View File
@@ -1859,12 +1859,15 @@ async def hatch_add_boundary(
],
ctx: Context = None,
) -> dict:
"""Add one boundary path built from typed edges.
"""Add one boundary path built from typed edges - an island inside the hatch.
Typed edges exist because a boundary that only accepts vertex lists
silently straightens every curve it is given. Every edge is validated
before any is written, so a malformed list refuses instead of leaving a
half-built path.
half-built path. Both engines: headlessly the edges become an edge path;
on a live seat each becomes a temporary LINE, true ARC or ELLIPSE appended
as an inner loop and then deleted (a chain of lines becomes one polyline).
A loop that does not close is refused by AutoCAD and nothing is added.
"""
return await _backend(ctx).hatch_add_boundary(handle, edges)
+176
View File
@@ -10,6 +10,7 @@ Strategy:
from __future__ import annotations
import math
import sys
from types import SimpleNamespace
from typing import Any
@@ -1168,3 +1169,178 @@ async def test_the_activex_type_names_still_filter_as_before(monkeypatch):
assert len(await backend.entity_list(type_filter="BLOCKREFERENCE")) == 1
assert len(await backend.entity_list(type_filter="POLYLINE")) == 1
assert len(await backend.entity_list(type_filter="ROTATEDDIMENSION")) == 1
# ── hatch islands on the live engine: typed edges as temporary entities ─────
class _LoopEntity:
def __init__(self, log, kind):
self._log = log
self.kind = kind
self.Closed = False
self.StartAngle = None
self.EndAngle = None
def Delete(self):
self._log.append(("Delete", self.kind))
class _HatchSpace:
"""The hatch's owner block: records every temporary entity it creates."""
def __init__(self, log):
self._log = log
def AddLightWeightPolyline(self, flat):
values = list(getattr(flat, "value", flat))
self._log.append(("AddLightWeightPolyline", len(values) // 2))
return _LoopEntity(self._log, "pline")
def AddLine(self, a, b):
self._log.append(("AddLine",))
return _LoopEntity(self._log, "line")
def AddArc(self, center, radius, start, end):
self._log.append(("AddArc", radius, round(start, 9), round(end, 9)))
return _LoopEntity(self._log, "arc")
def AddEllipse(self, center, major, ratio):
self._log.append(("AddEllipse", ratio))
return _LoopEntity(self._log, "ellipse")
class _IslandHatch:
ObjectName = "AcDbHatch"
Handle = "2A"
OwnerID = 7
def __init__(self, log, refuse=False):
self._log = log
self._refuse = refuse
self.NumberOfLoops = 1
def AppendInnerLoop(self, objects):
items = list(getattr(objects, "value", objects))
if self._refuse:
raise RuntimeError("E_FAIL") # what AutoCAD 2026 answers an open loop with
self._log.append(("AppendInnerLoop", [e.kind for e in items]))
self.NumberOfLoops += 1
def Evaluate(self):
self._log.append(("Evaluate",))
@pytest.fixture
def island_seat(monkeypatch):
import backends.com_backend as cb
log: list = []
hatch = _IslandHatch(log)
space = _HatchSpace(log)
doc = SimpleNamespace(HandleToObject=lambda h: hatch, ObjectIdToObject=lambda oid: space)
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
monkeypatch.setattr(cb, "_regen", lambda: None)
backend = ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, hatch, log
def _square(x0, y0, size):
corners = [(x0, y0), (x0 + size, y0), (x0 + size, y0 + size), (x0, y0 + size)]
return [
{"type": "line", "start": list(corners[i]), "end": list(corners[(i + 1) % 4])}
for i in range(4)
]
@pytest.mark.asyncio
async def test_a_chain_of_lines_becomes_one_closed_polyline_loop(island_seat):
"""A flattened bore is 180 line edges; one temporary polyline carries them
in a single COM call and is deleted once the hatch has evaluated it."""
backend, _, log = island_seat
result = await backend.hatch_add_boundary("2A", _square(10, 10, 20))
assert result == {"ok": True, "handle": "2A", "path_count": 2, "edge_types": ["line"] * 4}
assert log == [
("AddLightWeightPolyline", 4),
("AppendInnerLoop", ["pline"]),
("Evaluate",),
("Delete", "pline"),
]
@pytest.mark.asyncio
async def test_arcs_stay_true_arcs_in_radians_and_clockwise_is_swapped(island_seat):
"""Curved-edge fidelity is the reason this tool exists: an arc edge becomes
an AcDbArc, never a chord chain. ActiveX arcs run counter-clockwise, so a
clockwise edge is the counter-clockwise arc from its end to its start."""
backend, _, log = island_seat
edges = [
{"type": "arc", "center": [0, 0], "radius": 10, "start_angle": 0, "end_angle": 180},
{
"type": "arc",
"center": [0, 0],
"radius": 10,
"start_angle": 0,
"end_angle": 180,
"ccw": False,
},
]
result = await backend.hatch_add_boundary("2A", edges)
assert result["ok"] is True and result["edge_types"] == ["arc", "arc"]
assert log[0] == ("AddArc", 10.0, 0.0, round(math.pi, 9))
assert log[1] == ("AddArc", 10.0, round(math.pi, 9), 0.0)
assert log[2] == ("AppendInnerLoop", ["arc", "arc"])
assert [entry for entry in log if entry[0] == "Delete"] == [
("Delete", "arc"),
("Delete", "arc"),
]
@pytest.mark.asyncio
async def test_a_loop_autocad_refuses_is_taken_back_out(island_seat):
"""AutoCAD answers an open loop with E_FAIL (measured); the temporary
entities are deleted and the call says nothing was added."""
backend, hatch, log = island_seat
hatch._refuse = True
open_chain = _square(0, 0, 5)[:3]
result = await backend.hatch_add_boundary("2A", open_chain)
assert result["ok"] is False and "closed loop" in result["error"]
assert ("Evaluate",) not in log
created = [e for e in log if e[0].startswith("Add")]
deleted = [e for e in log if e[0] == "Delete"]
assert len(created) == len(deleted) == 3, "every temporary line is deleted"
assert hatch.NumberOfLoops == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
("edges", "message"),
[
([], "edges must not be empty"),
(["line"], "edge 0 is not an object"),
([{"type": "spline"}], "unknown type 'spline'"),
([{"type": "arc", "center": [0, 0], "radius": 1, "start_angle": 0}], "missing 'end_angle'"),
([{"type": "line", "start": [0, float("nan")], "end": [1, 1]}], "non-finite"),
(
[{"type": "arc", "center": [0, 0], "radius": 0, "start_angle": 0, "end_angle": 90}],
"radius must be > 0",
),
],
)
async def test_a_malformed_edge_list_is_refused_before_anything_is_created(
island_seat, edges, message
):
backend, _, log = island_seat
result = await backend.hatch_add_boundary("2A", edges)
assert result["ok"] is False and message in result["error"]
assert log == [], "nothing reached AutoCAD"
def test_the_live_engine_now_advertises_typed_hatch_edge_paths():
feature = ComBackend().capabilities().features["hatch_edge_paths"]
assert feature.supported is True