diff --git a/README.md b/README.md index 9500230..d32d5df 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 154 tools · 6 resources · 5 prompt templates · 1369 collected tests. +> **v1.5 release snapshot:** 155 tools · 6 resources · 5 prompt templates · 1369 collected tests. > 154 is the **registered** count; a default install advertises 149 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 b8e5f2d..ee5e479 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -3136,6 +3136,128 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) + async def block_define( + self, + name, + entities, + attdefs=None, + base_x=0.0, + base_y=0.0, + overwrite=False, + ) -> dict: + """A block definition with ATTDEFs from typed specs, through ActiveX. + + ``Blocks.Add`` creates the definition; each primitive is one ``Add*`` + call on the Block object, each ATTDEF one ``AddAttribute``. Overwrite + deletes the existing definition's members and re-adds — the name and + its INSERTs survive. Unit-tested against a fake ActiveX surface; + executed live by ``scripts/smoke_pid_com.py``. + """ + from backends.block_specs import ( + solid_vertices, + validate_attdef_specs, + validate_entity_specs, + ) + + # The anonymous-name rule comes first: ``*`` is also a forbidden DXF + # symbol character, so ``sanitize_symbol_name`` would refuse ``*U9`` + # with the generic message before this one could name the real reason. + if (name or "").strip().startswith("*"): + raise ValueError("block_define: anonymous block names (*...) are refused") + clean_name = sanitize_symbol_name(name, kind="block") + ents = validate_entity_specs(entities) + atts = validate_attdef_specs(attdefs or []) + # acAlignmentLeft / Center / Right / MiddleCenter + align_map = {"left": 0, "center": 1, "right": 2, "middle_center": 10} + + def _style(obj, layer: str) -> None: + obj.Layer = layer + obj.Color = 0 # acByBlock + obj.Linetype = "ByBlock" + + def _sync(): + doc = _acad_doc() + try: + existing = doc.Blocks.Item(clean_name) + except Exception: + existing = None + if existing is not None and not overwrite: + raise ValueError( + f"block_define: block {clean_name!r} already exists; " + "pass overwrite=true to replace its contents" + ) + replaced = existing is not None + base = _apoint(float(base_x), float(base_y), 0.0) + if existing is None: + block = doc.Blocks.Add(base, clean_name) + else: + block = existing + members = [block.Item(i) for i in range(block.Count)] + for member in members: + member.Delete() + block.Origin = base + for spec in ents: + kind = spec["type"] + if kind == "line": + obj = block.AddLine( + _apoint(spec["x1"], spec["y1"]), _apoint(spec["x2"], spec["y2"]) + ) + elif kind == "circle": + obj = block.AddCircle(_apoint(spec["cx"], spec["cy"]), float(spec["r"])) + elif kind == "arc": + obj = block.AddArc( + _apoint(spec["cx"], spec["cy"]), + float(spec["r"]), + deg2rad(spec["start_deg"]), + deg2rad(spec["end_deg"]), + ) + elif kind == "polyline": + flat = [coord for pt in spec["points"] for coord in pt] + obj = block.AddLightWeightPolyline(_av(flat)) + obj.Closed = bool(spec["closed"]) + for index, bulge in enumerate(spec["bulges"] or []): + if bulge: + obj.SetBulge(index, float(bulge)) + elif kind == "text": + obj = block.AddText( + spec["text"], _apoint(spec["x"], spec["y"]), float(spec["height"]) + ) + obj.Rotation = deg2rad(spec["rotation_deg"]) + if spec["align"] != "left": + obj.Alignment = align_map[spec["align"]] + obj.TextAlignmentPoint = _apoint(spec["x"], spec["y"]) + elif kind == "solid": + verts = solid_vertices(spec["points"]) + verts = verts + [verts[-1]] * (4 - len(verts)) + obj = block.AddSolid(*[_apoint(x, y) for x, y in verts]) + _style(obj, spec["layer"]) + for att in atts: + mode = 1 if att["invisible"] else 0 # acAttributeModeInvisible / Normal + obj = block.AddAttribute( + float(att["height"]), + mode, + att["prompt"], + _apoint(att["x"], att["y"]), + att["tag"], + att["default"], + ) + obj.Rotation = deg2rad(att["rotation_deg"]) + if att["align"] != "left": + obj.Alignment = align_map[att["align"]] + obj.TextAlignmentPoint = _apoint(att["x"], att["y"]) + _style(obj, "0") + _regen() + return { + "ok": True, + "name": clean_name, + "entity_count": len(ents), + "attdef_count": len(atts), + "replaced": replaced, + "backend": "com", + } + + return await self._run(_sync) + # ── analysis / query ────────────────────────────────────────────────────── async def analysis_stats(self) -> dict: diff --git a/backends/contracts/blocks.py b/backends/contracts/blocks.py index 36c7396..bc186d7 100644 --- a/backends/contracts/blocks.py +++ b/backends/contracts/blocks.py @@ -48,3 +48,19 @@ class BlockContract(ABC): base_x: float = 0.0, base_y: float = 0.0, ) -> dict: ... + + @abstractmethod + async def block_define( + self, + name: str, + entities: list[dict], + attdefs: list[dict] | None = None, + base_x: float = 0.0, + base_y: float = 0.0, + overwrite: bool = False, + ) -> dict: + """Create (or with ``overwrite`` replace the contents of) a block + definition from typed primitive specs and ATTDEF specs — see + ``backends/block_specs.py`` for the spec vocabulary. Returns + ``{ok, name, entity_count, attdef_count, replaced, backend}``.""" + ... diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 2e49151..f0b6957 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -4560,6 +4560,128 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) + async def block_define( + self, + name, + entities, + attdefs=None, + base_x=0.0, + base_y=0.0, + overwrite=False, + ) -> dict: + """A block definition with ATTDEFs from typed specs. + + Validation runs before any write, so a malformed entry leaves no + definition behind. ``overwrite`` replaces the *contents* of an existing + definition rather than deleting it: existing INSERTs keep pointing at + the name and show the new geometry (deleting a referenced block is + refused by both engines; replacing contents works on both). + """ + from ezdxf.enums import TextEntityAlignment + + from backends.block_specs import ( + solid_vertices, + validate_attdef_specs, + validate_entity_specs, + ) + from security import sanitize_symbol_name + + # The anonymous-name rule comes first: ``*`` is also a forbidden DXF + # symbol character, so ``sanitize_symbol_name`` would refuse ``*U9`` + # with the generic message before this one could name the real reason. + if (name or "").strip().startswith("*"): + raise ValueError("block_define: anonymous block names (*...) are refused") + clean_name = sanitize_symbol_name(name, kind="block") + ents = validate_entity_specs(entities) + atts = validate_attdef_specs(attdefs or []) + align_map = { + "left": TextEntityAlignment.LEFT, + "center": TextEntityAlignment.CENTER, + "right": TextEntityAlignment.RIGHT, + "middle_center": TextEntityAlignment.MIDDLE_CENTER, + } + + def _sync(): + doc = self._require_doc() + existing = doc.blocks.get(clean_name) if clean_name in doc.blocks else None + if existing is not None and not overwrite: + raise ValueError( + f"block_define: block {clean_name!r} already exists; " + "pass overwrite=true to replace its contents" + ) + replaced = existing is not None + base = (float(base_x), float(base_y), 0.0) + if existing is None: + blk = doc.blocks.new(name=clean_name, base_point=base) + else: + blk = existing + blk.delete_all_entities() + blk.block.dxf.base_point = base + for spec in ents: + attribs = {"layer": spec["layer"], "color": 0, "linetype": "ByBlock"} + kind = spec["type"] + if kind == "line": + blk.add_line( + (spec["x1"], spec["y1"]), (spec["x2"], spec["y2"]), dxfattribs=attribs + ) + elif kind == "circle": + blk.add_circle((spec["cx"], spec["cy"]), spec["r"], dxfattribs=attribs) + elif kind == "arc": + blk.add_arc( + (spec["cx"], spec["cy"]), + spec["r"], + spec["start_deg"], + spec["end_deg"], + dxfattribs=attribs, + ) + elif kind == "polyline": + bulges = spec["bulges"] or [0.0] * len(spec["points"]) + blk.add_lwpolyline( + [(x, y, b) for (x, y), b in zip(spec["points"], bulges, strict=True)], + format="xyb", + close=spec["closed"], + dxfattribs=attribs, + ) + elif kind == "text": + txt = blk.add_text( + spec["text"], + dxfattribs={ + **attribs, + "height": spec["height"], + "rotation": spec["rotation_deg"], + }, + ) + txt.set_placement((spec["x"], spec["y"]), align=align_map[spec["align"]]) + elif kind == "solid": + blk.add_solid(solid_vertices(spec["points"]), dxfattribs=attribs) + for att in atts: + attdef = blk.add_attdef( + att["tag"], + insert=(att["x"], att["y"]), + text=att["default"], + dxfattribs={ + "layer": "0", + "color": 0, + "height": att["height"], + "rotation": att["rotation_deg"], + "prompt": att["prompt"], + }, + ) + attdef.set_placement((att["x"], att["y"]), align=align_map[att["align"]]) + if att["invisible"]: + attdef.is_invisible = True + self._mark_dirty() + return { + "ok": True, + "name": clean_name, + "entity_count": len(ents), + "attdef_count": len(atts), + "replaced": replaced, + "backend": "ezdxf", + } + + return await self._async(_sync) + # ── analysis / query ────────────────────────────────────────────────────── async def analysis_stats(self) -> dict: diff --git a/discovery/aliases.py b/discovery/aliases.py index f1f0020..9dc4db4 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -66,11 +66,15 @@ class ToolAliases: # QSELECT -- AutoCAD's Quick Select dialog is both "filter by properties" # (selection_filter) and "find things like this one" # (entity_select_smart); a drafter typing it could mean either. +# BLOCK -- one command makes a definition either from selected objects +# (block_create_from_entities) or from scratch with attribute +# definitions (block_define); a drafter typing it could mean either. # # Every other AutoCAD command must map to exactly one tool. SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( { "ARRAY", + "BLOCK", "ERASE", "INSERT", "LAYER", @@ -201,6 +205,16 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "reusable symbol", ), ), + "block_define": ToolAliases( + acad=("BLOCK", "BEDIT", "ATTDEF"), + synonyms=( + "define a block", + "make a symbol", + "block with attributes", + "attribute definition", + "create a block definition from scratch", + ), + ), "block_explode": ToolAliases( acad=("EXPLODE",), synonyms=( diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 3c5c78e..c37b1e5 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,7 +3,7 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 154, + "tools": 155, "resources": 6, "prompts": 5, "groups": 19 @@ -11,7 +11,7 @@ "groups": { "analysis": 12, "batch": 3, - "blocks": 8, + "blocks": 9, "corner_ops": 4, "dimensions": 5, "drawing": 11, @@ -187,6 +187,20 @@ "readOnlyHint": false } }, + { + "name": "block_define", + "group": "blocks", + "cost": "mutate", + "summary": "Define a block from typed primitives and attribute definitions, on both engines.", + "tags": [ + "block", + "create" + ], + "annotations": { + "title": "Define Block", + "readOnlyHint": false + } + }, { "name": "block_explode", "group": "blocks", diff --git a/server.py b/server.py index bf1099d..bba01b2 100644 --- a/server.py +++ b/server.py @@ -2855,7 +2855,7 @@ async def linetype_load( # --------------------------------------------------------------------------- -# ── SECTION 7: Block Operations (7 tools) ─────────────────────────────────── +# ── SECTION 7: Block Operations (8 tools) ─────────────────────────────────── # --------------------------------------------------------------------------- @@ -2983,6 +2983,46 @@ async def block_create_from_entities( return await _backend(ctx).block_create_from_entities(name, handles, base_x, base_y) +@cad_tool( + summary="Define a block from typed primitives and attribute definitions, on both engines.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Define Block", "readOnlyHint": False}, + tags={"block", "create"}, +) +async def block_define( + name: Annotated[str, "New block definition name"], + entities: Annotated[ + list[dict], + "Primitives in block-local coordinates. type=line {x1,y1,x2,y2} | circle {cx,cy,r} | " + "arc {cx,cy,r,start_deg,end_deg} | polyline {points,closed,bulges?} | " + "text {text,x,y,height,rotation_deg?,align?} | solid {points (3-4)}. Optional layer (default 0).", + ], + attdefs: Annotated[ + list[dict] | None, + "Attribute definitions: {tag, x, y, height, prompt?, default?, rotation_deg?, align?, invisible?}", + ] = None, + base_x: Annotated[float, "Block base point X"] = 0.0, + base_y: Annotated[float, "Block base point Y"] = 0.0, + overwrite: Annotated[ + bool, "Replace the contents of an existing definition of this name" + ] = False, + ctx: Context = None, +) -> dict: + """Create a block definition with attribute definitions from typed specs. + + The whole request is validated before anything is written: one malformed + entry refuses the call and leaves no definition behind. `overwrite=true` + replaces the *contents* of an existing block so its INSERTs keep pointing + at the name and show the new geometry; `replaced` reports it. Geometry + inside the block is ByBlock so the INSERT's layer supplies colour and + lineweight. Insert with `block_insert(attributes={TAG: value})`. + """ + await ctx.info(f"Defining block '{name}' from {len(entities)} primitives") + return await _backend(ctx).block_define(name, entities, attdefs, base_x, base_y, overwrite) + + @cad_tool(summary="Find every place a given block is inserted.", cost="read") @mcp.tool( annotations={"title": "Find Blocks By Name", "readOnlyHint": True}, diff --git a/tests/test_block_define.py b/tests/test_block_define.py new file mode 100644 index 0000000..1756e1b --- /dev/null +++ b/tests/test_block_define.py @@ -0,0 +1,201 @@ +"""block_define: a definition with ATTDEFs from typed specs, on both engines.""" + +from __future__ import annotations + +import types + +import pytest + +pytestmark = pytest.mark.asyncio + +VALVE = [ + {"type": "polyline", "points": [[-4, -2], [-4, 2], [0, 0]], "closed": True}, + {"type": "polyline", "points": [[4, -2], [4, 2], [0, 0]], "closed": True}, + {"type": "circle", "cx": 0, "cy": 0, "r": 1.5}, + {"type": "arc", "cx": 0, "cy": 2, "r": 2, "start_deg": 0, "end_deg": 180}, + {"type": "line", "x1": 0, "y1": 0, "x2": 0, "y2": 4}, + {"type": "text", "text": "M", "x": 0, "y": 6, "height": 2, "align": "middle_center"}, + {"type": "solid", "points": [[0, 0], [-3, 1.2], [-3, -1.2]]}, +] +ATTDEFS = [ + {"tag": "TAG", "x": 0, "y": 5.5, "height": 2.5, "align": "center"}, + {"tag": "LINK", "x": 0, "y": -5, "height": 2.0, "invisible": True, "default": "L-1"}, +] + + +# ── headless engine ───────────────────────────────────────────────────────── + + +async def test_defines_geometry_and_attdefs(backend): + result = await backend.block_define("PID_VALVE_TEST", VALVE, ATTDEFS) + assert result == { + "ok": True, + "name": "PID_VALVE_TEST", + "entity_count": 7, + "attdef_count": 2, + "replaced": False, + "backend": "ezdxf", + } + blk = backend._doc.blocks.get("PID_VALVE_TEST") + types_ = sorted(e.dxftype() for e in blk) + assert types_ == [ + "ARC", + "ATTDEF", + "ATTDEF", + "CIRCLE", + "LINE", + "LWPOLYLINE", + "LWPOLYLINE", + "SOLID", + "TEXT", + ] + attdefs = {e.dxf.tag: e for e in blk if e.dxftype() == "ATTDEF"} + assert attdefs["LINK"].is_invisible is True + assert attdefs["LINK"].dxf.text == "L-1" + assert attdefs["TAG"].is_invisible is False + for e in blk: + assert e.dxf.layer == "0" + assert e.dxf.color == 0, "ByBlock colour so the INSERT's layer drives it" + + +async def test_insert_after_define_carries_the_attributes(backend): + await backend.block_define("PID_VALVE_TEST", VALVE, ATTDEFS) + ref = await backend.block_insert("PID_VALVE_TEST", 10, 10, attributes={"TAG": "HV-101"}) + assert await backend.block_get_attributes(ref.handle) == {"TAG": "HV-101", "LINK": "L-1"} + + +async def test_name_clash_is_refused_unless_overwrite(backend): + await backend.block_define("PID_X", VALVE) + with pytest.raises(ValueError, match="overwrite"): + await backend.block_define("PID_X", VALVE) + result = await backend.block_define("PID_X", VALVE[:1], overwrite=True) + assert result["replaced"] is True and result["entity_count"] == 1 + assert len(list(backend._doc.blocks.get("PID_X"))) == 1 + + +async def test_overwrite_keeps_existing_inserts_pointing_at_the_name(backend): + await backend.block_define("PID_X", VALVE) + ref = await backend.block_insert("PID_X", 0, 0) + await backend.block_define( + "PID_X", [{"type": "circle", "cx": 0, "cy": 0, "r": 9}], overwrite=True + ) + info = await backend.entity_get(ref.handle) + assert info.properties["block_name"] == "PID_X" + virtual = list(backend._doc.entitydb[ref.handle].virtual_entities()) + assert [e.dxftype() for e in virtual] == ["CIRCLE"] + + +async def test_invalid_request_writes_nothing(backend): + with pytest.raises(TypeError, match=r"entities\[1\]"): + await backend.block_define("PID_BAD", [VALVE[0], {"type": "circle", "cx": 0, "cy": 0}]) + assert "PID_BAD" not in backend._doc.blocks + with pytest.raises(ValueError, match="empty"): + await backend.block_define("PID_EMPTY", []) + assert "PID_EMPTY" not in backend._doc.blocks + + +async def test_anonymous_names_are_refused(backend): + with pytest.raises(ValueError, match="anonymous"): + await backend.block_define("*U9", VALVE) + + +async def test_solid_uses_bowtie_order(backend): + await backend.block_define( + "PID_SOLID", [{"type": "solid", "points": [[0, 0], [1, 0], [1, 1], [0, 1]]}] + ) + solid = next(e for e in backend._doc.blocks.get("PID_SOLID") if e.dxftype() == "SOLID") + assert tuple(solid.dxf.vtx2)[:2] == (0.0, 1.0) + assert tuple(solid.dxf.vtx3)[:2] == (1.0, 1.0) + + +# ── live engine, against a fake ActiveX surface ────────────────────────────── + + +class _Recorder: + """Records every method call made on it, returns a styled child.""" + + def __init__(self, name): + self.Name = name + self.calls: list[tuple[str, tuple]] = [] + self.Count = 0 + + def __getattr__(self, attr): + if attr.startswith("Add"): + + def _call(*args): + self.calls.append((attr, args)) + return types.SimpleNamespace() + + return _call + raise AttributeError(attr) + + +class _FakeBlocks: + def __init__(self): + self.blocks: dict[str, _Recorder] = {} + + def Add(self, base_point, name): + self.blocks[name] = _Recorder(name) + self.blocks[name].base_point = tuple(base_point.value) + return self.blocks[name] + + def Item(self, name): + if name not in self.blocks: + raise RuntimeError(f"no block {name}") + return self.blocks[name] + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + document = types.SimpleNamespace(Blocks=_FakeBlocks()) + monkeypatch.setattr(module, "_acad_doc", lambda: document) + monkeypatch.setattr(module, "_regen", lambda: None) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + return backend, document + + +async def test_com_issues_one_activex_call_per_primitive_and_attdef(com_backend): + backend, document = com_backend + result = await backend.block_define("PID_VALVE_TEST", VALVE, ATTDEFS, 1.0, 2.0) + assert result["ok"] is True and result["backend"] == "com" and result["replaced"] is False + block = document.Blocks.blocks["PID_VALVE_TEST"] + assert block.base_point == (1.0, 2.0, 0.0) + names = [name for name, _ in block.calls] + assert names == [ + "AddLightWeightPolyline", + "AddLightWeightPolyline", + "AddCircle", + "AddArc", + "AddLine", + "AddText", + "AddSolid", + "AddAttribute", + "AddAttribute", + ] + tag_call = block.calls[7][1] + link_call = block.calls[8][1] + assert tag_call[0] == 2.5 and tag_call[1] == 0 and tag_call[4] == "TAG" + assert link_call[1] == 1, "invisible ATTDEF uses acAttributeModeInvisible" + assert link_call[5] == "L-1" + + +async def test_com_refuses_a_clash_without_overwrite(com_backend): + backend, document = com_backend + await backend.block_define("PID_X", VALVE[:1]) + with pytest.raises(ValueError, match="overwrite"): + await backend.block_define("PID_X", VALVE[:1]) + + +async def test_com_validates_before_touching_activex(com_backend): + backend, document = com_backend + with pytest.raises(TypeError): + await backend.block_define("PID_BAD", [{"type": "circle", "cx": 0}]) + assert document.Blocks.blocks == {} diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 7b0ba48..be4f96f 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -495,21 +495,23 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping(): async def test_tool_group_sizes_are_unchanged(): - """Frozen snapshot of the surface (154 tools, 19 groups). + """Frozen snapshot of the surface (155 tools, 19 groups). Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and `layouts` moved 4 -> 12 with M6's layout/viewport lifecycle. That group gained `entity_change_space` too: it is tagged `layout` *and* `modify`, and `_GROUP_TAG_PRIORITY` ranks `layout` higher, so CHSPACE files under layouts - rather than splitting a ninth tool off into entity_modification. Every other - number here has been unchanged since the snapshot was taken. + rather than splitting a ninth tool off into entity_modification. `blocks` + moved 8 -> 9 when v1.6's `block_define` (typed primitives + ATTDEFs on + both engines) joined SECTION 7. Every other number here has been unchanged + since the snapshot was taken. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { "analysis": 12, "batch": 3, - "blocks": 8, + "blocks": 9, "corner_ops": 4, "dimensions": 5, "drawing": 11, @@ -527,4 +529,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 154 + assert sum(sizes.values()) == 155