feat(blocks): block_define — typed primitives + ATTDEFs on both engines
Co-Authored-By: Claude Opus 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
parent
94840042a5
commit
a84cae2641
@@ -19,7 +19,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **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`,
|
> 154 is the **registered** count; a default install advertises 149 over `tools/list`,
|
||||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||||
|
|
||||||
|
|||||||
@@ -3136,6 +3136,128 @@ class ComBackend(AutoCADBackend):
|
|||||||
|
|
||||||
return await self._run(_sync)
|
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 ──────────────────────────────────────────────────────
|
# ── analysis / query ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def analysis_stats(self) -> dict:
|
async def analysis_stats(self) -> dict:
|
||||||
|
|||||||
@@ -48,3 +48,19 @@ class BlockContract(ABC):
|
|||||||
base_x: float = 0.0,
|
base_x: float = 0.0,
|
||||||
base_y: float = 0.0,
|
base_y: float = 0.0,
|
||||||
) -> dict: ...
|
) -> 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}``."""
|
||||||
|
...
|
||||||
|
|||||||
@@ -4560,6 +4560,128 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
|
|
||||||
return await self._async(_sync)
|
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 ──────────────────────────────────────────────────────
|
# ── analysis / query ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def analysis_stats(self) -> dict:
|
async def analysis_stats(self) -> dict:
|
||||||
|
|||||||
@@ -66,11 +66,15 @@ class ToolAliases:
|
|||||||
# QSELECT -- AutoCAD's Quick Select dialog is both "filter by properties"
|
# QSELECT -- AutoCAD's Quick Select dialog is both "filter by properties"
|
||||||
# (selection_filter) and "find things like this one"
|
# (selection_filter) and "find things like this one"
|
||||||
# (entity_select_smart); a drafter typing it could mean either.
|
# (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.
|
# Every other AutoCAD command must map to exactly one tool.
|
||||||
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
||||||
{
|
{
|
||||||
"ARRAY",
|
"ARRAY",
|
||||||
|
"BLOCK",
|
||||||
"ERASE",
|
"ERASE",
|
||||||
"INSERT",
|
"INSERT",
|
||||||
"LAYER",
|
"LAYER",
|
||||||
@@ -201,6 +205,16 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"reusable symbol",
|
"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(
|
"block_explode": ToolAliases(
|
||||||
acad=("EXPLODE",),
|
acad=("EXPLODE",),
|
||||||
synonyms=(
|
synonyms=(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"package_version": "1.5.1",
|
"package_version": "1.5.1",
|
||||||
"totals": {
|
"totals": {
|
||||||
"tools": 154,
|
"tools": 155,
|
||||||
"resources": 6,
|
"resources": 6,
|
||||||
"prompts": 5,
|
"prompts": 5,
|
||||||
"groups": 19
|
"groups": 19
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"groups": {
|
"groups": {
|
||||||
"analysis": 12,
|
"analysis": 12,
|
||||||
"batch": 3,
|
"batch": 3,
|
||||||
"blocks": 8,
|
"blocks": 9,
|
||||||
"corner_ops": 4,
|
"corner_ops": 4,
|
||||||
"dimensions": 5,
|
"dimensions": 5,
|
||||||
"drawing": 11,
|
"drawing": 11,
|
||||||
@@ -187,6 +187,20 @@
|
|||||||
"readOnlyHint": false
|
"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",
|
"name": "block_explode",
|
||||||
"group": "blocks",
|
"group": "blocks",
|
||||||
|
|||||||
@@ -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)
|
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")
|
@cad_tool(summary="Find every place a given block is inserted.", cost="read")
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
annotations={"title": "Find Blocks By Name", "readOnlyHint": True},
|
annotations={"title": "Find Blocks By Name", "readOnlyHint": True},
|
||||||
|
|||||||
@@ -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 == {}
|
||||||
@@ -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():
|
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
|
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
|
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
|
`layouts` moved 4 -> 12 with M6's layout/viewport lifecycle. That group
|
||||||
gained `entity_change_space` too: it is tagged `layout` *and* `modify`, and
|
gained `entity_change_space` too: it is tagged `layout` *and* `modify`, and
|
||||||
`_GROUP_TAG_PRIORITY` ranks `layout` higher, so CHSPACE files under layouts
|
`_GROUP_TAG_PRIORITY` ranks `layout` higher, so CHSPACE files under layouts
|
||||||
rather than splitting a ninth tool off into entity_modification. Every other
|
rather than splitting a ninth tool off into entity_modification. `blocks`
|
||||||
number here has been unchanged since the snapshot was taken.
|
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()}
|
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||||
assert sizes == {
|
assert sizes == {
|
||||||
"analysis": 12,
|
"analysis": 12,
|
||||||
"batch": 3,
|
"batch": 3,
|
||||||
"blocks": 8,
|
"blocks": 9,
|
||||||
"corner_ops": 4,
|
"corner_ops": 4,
|
||||||
"dimensions": 5,
|
"dimensions": 5,
|
||||||
"drawing": 11,
|
"drawing": 11,
|
||||||
@@ -527,4 +529,4 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"validation": 1,
|
"validation": 1,
|
||||||
"view": 4,
|
"view": 4,
|
||||||
}
|
}
|
||||||
assert sum(sizes.values()) == 154
|
assert sum(sizes.values()) == 155
|
||||||
|
|||||||
Reference in New Issue
Block a user