From 2bba18d0f710d8a33a7e31ca43d017393b1cf87b Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 23 Sep 2026 13:51:35 +0300 Subject: [PATCH] =?UTF-8?q?fix(com):=20hatch=20islands=20on=20a=20live=20s?= =?UTF-8?q?eat=20=E2=80=94=20typed=20edges=20become=20temporary=20entities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- CHANGELOG.md | 15 ++++ README.md | 2 +- backends/com_backend.py | 176 +++++++++++++++++++++++++++++++++++--- engineering/mech/draw.py | 10 ++- server.py | 7 +- tests/test_com_backend.py | 176 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 368 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8769e3a..cda0d2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 6a028eb..6320f44 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **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. diff --git a/backends/com_backend.py b/backends/com_backend.py index 61f98df..07b5a1f 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -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(): diff --git a/engineering/mech/draw.py b/engineering/mech/draw.py index f56e9e7..7687234 100644 --- a/engineering/mech/draw.py +++ b/engineering/mech/draw.py @@ -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 diff --git a/server.py b/server.py index 40d5fc9..2778a5e 100644 --- a/server.py +++ b/server.py @@ -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) diff --git a/tests/test_com_backend.py b/tests/test_com_backend.py index 0b258cc..a993cda 100644 --- a/tests/test_com_backend.py +++ b/tests/test_com_backend.py @@ -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