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:
co-authored by
Claude Opus 5.5
parent
e6f685643d
commit
2bba18d0f7
@@ -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
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user