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:
Umut CAN
2026-09-15 13:58:09 +03:00
co-authored by Claude Opus 5
parent 94840042a5
commit a84cae2641
9 changed files with 540 additions and 9 deletions
+122
View File
@@ -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:
+16
View File
@@ -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}``."""
...
+122
View File
@@ -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: