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
+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