release: prepare v1.3.0
This commit is contained in:
@@ -71,6 +71,32 @@ class DrawingInfo:
|
||||
backend: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeatureCapability:
|
||||
supported: bool
|
||||
mode: str | None = None
|
||||
reason: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"supported": self.supported,
|
||||
"mode": self.mode,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilityMap:
|
||||
backend: str
|
||||
features: dict[str, FeatureCapability]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"backend": self.backend,
|
||||
"features": {name: value.to_dict() for name, value in self.features.items()},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -126,6 +152,9 @@ class AutoCADBackend(ABC):
|
||||
@abstractmethod
|
||||
async def disconnect(self) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def capabilities(self) -> CapabilityMap: ...
|
||||
|
||||
# ── drawing management ───────────────────────────────────────────────────
|
||||
@abstractmethod
|
||||
async def drawing_info(self) -> DrawingInfo: ...
|
||||
@@ -205,6 +234,31 @@ class AutoCADBackend(ABC):
|
||||
layer: str | None = None, color: int | None = None,
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_create_table(
|
||||
self,
|
||||
x: float,
|
||||
y: float,
|
||||
rows: list[list[str]],
|
||||
headers: list[str] | None = None,
|
||||
column_widths: list[float] | None = None,
|
||||
row_height: float = 7.0,
|
||||
text_height: float = 2.5,
|
||||
title: str | None = None,
|
||||
layer: str = "TEXT",
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def leader_create_mleader(
|
||||
self,
|
||||
points: list[list[float]],
|
||||
text: str,
|
||||
text_height: float = 2.5,
|
||||
landing_gap: float = 1.0,
|
||||
arrow_size: float = 2.5,
|
||||
layer: str = "DIM",
|
||||
) -> EntityInfo: ...
|
||||
|
||||
@abstractmethod
|
||||
async def entity_create_hatch(
|
||||
self, pattern: str, boundary_points: list[list[float]],
|
||||
@@ -594,6 +648,46 @@ class AutoCADBackend(ABC):
|
||||
# has no cross-backend equivalent among the standard entity_create_* tools.
|
||||
|
||||
_plan_spec: PlanSpec | None = None
|
||||
_preflight_result: Any = None
|
||||
|
||||
def _reset_document_state(self) -> None:
|
||||
"""Clear metadata that is valid only for the current drawing."""
|
||||
self._plan_spec = None
|
||||
self._preflight_result = None
|
||||
self._gdt_datums_defined = set()
|
||||
self._gdt_datums_referenced = set()
|
||||
|
||||
async def _ensure_document_state(self) -> None:
|
||||
"""Allow live backends to detect an out-of-band document switch."""
|
||||
return None
|
||||
|
||||
async def drawing_preflight(
|
||||
self,
|
||||
intent: str,
|
||||
requirements: dict | None = None,
|
||||
sheet_size: SheetSize = "A3",
|
||||
scale: float = 1.0,
|
||||
layer_set_id: LayerSetId = "mech",
|
||||
view_count: int = 1,
|
||||
dim_style: DimStyle = "chain",
|
||||
allow_assumptions: bool = False,
|
||||
):
|
||||
from engineering.preflight import preflight_drawing
|
||||
|
||||
await self._ensure_document_state()
|
||||
|
||||
result = preflight_drawing(
|
||||
intent,
|
||||
requirements,
|
||||
sheet_size,
|
||||
scale,
|
||||
layer_set_id,
|
||||
view_count,
|
||||
dim_style,
|
||||
allow_assumptions=allow_assumptions,
|
||||
)
|
||||
self._preflight_result = result
|
||||
return result
|
||||
|
||||
async def drawing_plan(
|
||||
self, intent: str,
|
||||
@@ -603,6 +697,8 @@ class AutoCADBackend(ABC):
|
||||
view_count: int = 1,
|
||||
dim_style: DimStyle = "chain",
|
||||
notes: list[str] | None = None,
|
||||
requirements: dict | None = None,
|
||||
spec_hash: str | None = None,
|
||||
) -> PlanSpec:
|
||||
"""Commit a drawing intent before any geometry is created.
|
||||
Returns a PlanSpec and stores it on the backend; read it back with
|
||||
@@ -610,6 +706,47 @@ class AutoCADBackend(ABC):
|
||||
replays this plan was untrue — critique runs geometric checks, not a
|
||||
plan diff.)"""
|
||||
from engineering.plan_spec import PlanSpec
|
||||
|
||||
await self._ensure_document_state()
|
||||
preflight_status = "skipped"
|
||||
normalized_plan: dict[str, Any] | None = None
|
||||
if spec_hash is not None:
|
||||
from engineering.preflight import preflight_drawing
|
||||
|
||||
preflight = getattr(self, "_preflight_result", None)
|
||||
if preflight is None or preflight.spec_hash != spec_hash:
|
||||
raise RuntimeError("drawing_plan: spec_hash does not match the latest preflight.")
|
||||
if not preflight.ready:
|
||||
raise RuntimeError(
|
||||
"drawing_plan: preflight is not ready; resolve questions/conflicts first."
|
||||
)
|
||||
candidate = preflight_drawing(
|
||||
intent,
|
||||
requirements
|
||||
if requirements is not None
|
||||
else preflight.normalized_spec["requirements"],
|
||||
sheet_size,
|
||||
scale,
|
||||
layer_set_id,
|
||||
view_count,
|
||||
dim_style,
|
||||
)
|
||||
if candidate.spec_hash != spec_hash:
|
||||
raise RuntimeError(
|
||||
"drawing_plan: supplied fields do not match the hashed preflight spec."
|
||||
)
|
||||
normalized_plan = preflight.normalized_spec
|
||||
preflight_status = "ready"
|
||||
|
||||
if normalized_plan is not None:
|
||||
intent = normalized_plan["intent"]
|
||||
sheet_size = normalized_plan["sheet_size"]
|
||||
scale = normalized_plan["scale"]
|
||||
layer_set_id = normalized_plan["layer_set_id"]
|
||||
view_count = normalized_plan["view_count"]
|
||||
dim_style = normalized_plan["dim_style"]
|
||||
requirements = normalized_plan["requirements"]
|
||||
|
||||
plan = PlanSpec(
|
||||
intent=str(intent),
|
||||
sheet_size=sheet_size,
|
||||
@@ -618,6 +755,9 @@ class AutoCADBackend(ABC):
|
||||
view_count=int(view_count),
|
||||
dim_style=dim_style,
|
||||
notes=list(notes) if notes else [],
|
||||
requirements=dict(requirements or {}),
|
||||
spec_hash=spec_hash,
|
||||
preflight_status=preflight_status,
|
||||
)
|
||||
self._plan_spec = plan
|
||||
return plan
|
||||
@@ -629,6 +769,8 @@ class AutoCADBackend(ABC):
|
||||
`drawing_finalize`. `focus=None` runs all checks. (N8: this does NOT
|
||||
replay the committed PlanSpec — it inspects the live drawing geometry.)"""
|
||||
from engineering.critique import run_critique
|
||||
|
||||
await self._ensure_document_state()
|
||||
return await run_critique(self, focus)
|
||||
|
||||
def get_plan_spec(self) -> dict | None:
|
||||
|
||||
+162
-3
@@ -21,8 +21,10 @@ import config
|
||||
from .base import (
|
||||
AutoCADBackend,
|
||||
BlockInfo,
|
||||
CapabilityMap,
|
||||
DrawingInfo,
|
||||
EntityInfo,
|
||||
FeatureCapability,
|
||||
LayerInfo,
|
||||
deg2rad,
|
||||
normalize_lineweight,
|
||||
@@ -431,6 +433,7 @@ class ComBackend(AutoCADBackend):
|
||||
self._executor: ThreadPoolExecutor | None = None
|
||||
self._connected = False
|
||||
self._transaction_active = False
|
||||
self._document_scope_key: tuple[str, str] | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -440,6 +443,26 @@ class ComBackend(AutoCADBackend):
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def capabilities(self) -> CapabilityMap:
|
||||
return CapabilityMap(
|
||||
backend="com",
|
||||
features={
|
||||
"drawing_2d": FeatureCapability(True, "native"),
|
||||
"dxf": FeatureCapability(True, "native"),
|
||||
"pdf": FeatureCapability(True, "native"),
|
||||
"png": FeatureCapability(True, "native"),
|
||||
"transactions": FeatureCapability(True, "undo_mark"),
|
||||
"table": FeatureCapability(True, "native"),
|
||||
"mleader": FeatureCapability(True, "native"),
|
||||
"preflight": FeatureCapability(True, "shared"),
|
||||
"refiner": FeatureCapability(True, "shared"),
|
||||
"delivery": FeatureCapability(True, "shared"),
|
||||
"paper_space": FeatureCapability(False, reason="planned_1.4"),
|
||||
"solid_3d": FeatureCapability(False, reason="planned_1.5"),
|
||||
"lisp": FeatureCapability(True, "sanitized"),
|
||||
},
|
||||
)
|
||||
|
||||
async def connect(self) -> None:
|
||||
if not _COM_IMPORTS_OK:
|
||||
raise RuntimeError(
|
||||
@@ -460,8 +483,28 @@ class ComBackend(AutoCADBackend):
|
||||
pass
|
||||
self._executor.shutdown(wait=False)
|
||||
self._executor = None
|
||||
self._document_scope_key = None
|
||||
self._transaction_active = False
|
||||
self._reset_document_state()
|
||||
self._connected = False
|
||||
|
||||
async def _ensure_document_state(self) -> None:
|
||||
"""Reset drawing-scoped metadata when AutoCAD's active document changes."""
|
||||
|
||||
def _sync():
|
||||
app = _acad_app()
|
||||
if app.Documents.Count == 0:
|
||||
key = None
|
||||
else:
|
||||
doc = app.ActiveDocument
|
||||
key = (str(doc.Name), str(doc.FullName))
|
||||
if key != self._document_scope_key:
|
||||
self._document_scope_key = key
|
||||
self._transaction_active = False
|
||||
self._reset_document_state()
|
||||
|
||||
await self._run(_sync)
|
||||
|
||||
def _sync_test_connection(self):
|
||||
"""Verify AutoCAD is reachable (runs in COM thread)."""
|
||||
app = _acad_app()
|
||||
@@ -572,14 +615,18 @@ class ComBackend(AutoCADBackend):
|
||||
else:
|
||||
doc = app.Documents.Add()
|
||||
return {"ok": True, "name": doc.Name}
|
||||
return await self._run(_sync)
|
||||
result = await self._run(_sync)
|
||||
await self._ensure_document_state()
|
||||
return result
|
||||
|
||||
async def drawing_open(self, path: str) -> dict:
|
||||
def _sync():
|
||||
app = _acad_app()
|
||||
doc = app.Documents.Open(path)
|
||||
return {"ok": True, "name": doc.Name, "path": doc.FullName}
|
||||
return await self._run(_sync)
|
||||
result = await self._run(_sync)
|
||||
await self._ensure_document_state()
|
||||
return result
|
||||
|
||||
async def drawing_save(self, path: str | None = None) -> dict:
|
||||
def _sync():
|
||||
@@ -633,7 +680,9 @@ class ComBackend(AutoCADBackend):
|
||||
doc = _acad_doc()
|
||||
doc.Close(save)
|
||||
return {"ok": True}
|
||||
return await self._run(_sync)
|
||||
result = await self._run(_sync)
|
||||
await self._ensure_document_state()
|
||||
return result
|
||||
|
||||
async def drawing_undo(self) -> dict:
|
||||
def _sync():
|
||||
@@ -729,6 +778,113 @@ class ComBackend(AutoCADBackend):
|
||||
return _entity_info(mt)
|
||||
return await self._run(_sync)
|
||||
|
||||
async def entity_create_table(
|
||||
self,
|
||||
x,
|
||||
y,
|
||||
rows,
|
||||
headers=None,
|
||||
column_widths=None,
|
||||
row_height=7.0,
|
||||
text_height=2.5,
|
||||
title=None,
|
||||
layer="TEXT",
|
||||
) -> EntityInfo:
|
||||
from engineering.annotation import prepare_table_layout
|
||||
|
||||
layout = prepare_table_layout(
|
||||
rows, headers, column_widths, row_height, text_height, title
|
||||
)
|
||||
|
||||
def _sync():
|
||||
mspace = _msp()
|
||||
table = mspace.AddTable(
|
||||
_apoint(x, y),
|
||||
layout.row_count,
|
||||
layout.column_count,
|
||||
layout.row_height,
|
||||
layout.column_widths[0],
|
||||
)
|
||||
for column, width in enumerate(layout.column_widths):
|
||||
table.SetColumnWidth(column, width)
|
||||
for row_index, row in enumerate(layout.cells):
|
||||
table.SetRowHeight(row_index, layout.row_height)
|
||||
for column_index, value in enumerate(row):
|
||||
table.SetText(row_index, column_index, value)
|
||||
try:
|
||||
table.TextHeight = layout.text_height
|
||||
except Exception as exc:
|
||||
log.debug("setting table TextHeight failed: %s", exc)
|
||||
_apply_entity_attrs(table, layer, None, None)
|
||||
_regen()
|
||||
info = _entity_info(table)
|
||||
info.type = "TABLE"
|
||||
info.layer = layer
|
||||
info.properties.update(
|
||||
{
|
||||
"representation": "native",
|
||||
"logical_group_id": f"table:{uuid.uuid4().hex}",
|
||||
"child_handles": [info.handle],
|
||||
"rows": layout.row_count,
|
||||
"columns": layout.column_count,
|
||||
"bounds": {
|
||||
"min": [float(x), float(y) - layout.height],
|
||||
"max": [float(x) + layout.width, float(y)],
|
||||
},
|
||||
}
|
||||
)
|
||||
return info
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
async def leader_create_mleader(
|
||||
self,
|
||||
points,
|
||||
text,
|
||||
text_height=2.5,
|
||||
landing_gap=1.0,
|
||||
arrow_size=2.5,
|
||||
layer="DIM",
|
||||
) -> EntityInfo:
|
||||
from engineering.annotation import validate_mleader
|
||||
|
||||
normalized = validate_mleader(points, text)
|
||||
if text_height <= 0 or landing_gap < 0 or arrow_size <= 0:
|
||||
raise RuntimeError(
|
||||
"leader_create_mleader: text_height/arrow_size must be positive and landing_gap non-negative"
|
||||
)
|
||||
|
||||
def _sync():
|
||||
flat = []
|
||||
for px, py in normalized:
|
||||
flat.extend([px, py, 0.0])
|
||||
leader = _msp().AddMLeader(_av(flat), 0)
|
||||
leader.TextString = str(text)
|
||||
leader.TextHeight = float(text_height)
|
||||
leader.LandingGap = float(landing_gap)
|
||||
leader.ArrowheadSize = float(arrow_size)
|
||||
_apply_entity_attrs(leader, layer, None, None)
|
||||
_regen()
|
||||
info = _entity_info(leader)
|
||||
info.type = "MLEADER"
|
||||
info.layer = layer
|
||||
info.properties.update(
|
||||
{
|
||||
"representation": "native",
|
||||
"logical_group_id": f"mleader:{uuid.uuid4().hex}",
|
||||
"child_handles": [info.handle],
|
||||
"points": [list(point) for point in normalized],
|
||||
"text": str(text),
|
||||
"bounds": {
|
||||
"min": [min(p[0] for p in normalized), min(p[1] for p in normalized)],
|
||||
"max": [max(p[0] for p in normalized), max(p[1] for p in normalized)],
|
||||
},
|
||||
}
|
||||
)
|
||||
return info
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
async def entity_create_hatch(
|
||||
self, pattern, boundary_points, scale=1.0, angle=0.0,
|
||||
layer=None, color=None,
|
||||
@@ -1649,6 +1805,9 @@ class ComBackend(AutoCADBackend):
|
||||
# ── transactions ──────────────────────────────────────────────────────────
|
||||
|
||||
async def transaction_begin(self) -> dict:
|
||||
if self._transaction_active:
|
||||
return {"ok": False, "error": "A transaction is already active"}
|
||||
|
||||
def _sync():
|
||||
doc = _acad_doc()
|
||||
doc.StartUndoMark()
|
||||
|
||||
+206
-15
@@ -13,6 +13,8 @@ import logging
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -21,8 +23,10 @@ import config
|
||||
from .base import (
|
||||
AutoCADBackend,
|
||||
BlockInfo,
|
||||
CapabilityMap,
|
||||
DrawingInfo,
|
||||
EntityInfo,
|
||||
FeatureCapability,
|
||||
LayerInfo,
|
||||
normalize_lineweight,
|
||||
shoelace_area,
|
||||
@@ -253,7 +257,8 @@ class EzdxfBackend(AutoCADBackend):
|
||||
self._doc_path: str | None = None
|
||||
self._dirty: bool = False
|
||||
self._current_layer: str = "0"
|
||||
self._undo_stack: list[Path] = [] # temp-file paths to DXF snapshots
|
||||
self._undo_stack: list[Path] = [] # user-facing undo history
|
||||
self._transaction_stack: list[Path] = [] # isolated transaction snapshots
|
||||
self._connected = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@@ -265,21 +270,49 @@ class EzdxfBackend(AutoCADBackend):
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def capabilities(self) -> CapabilityMap:
|
||||
render_available = find_spec("matplotlib") is not None
|
||||
render_capability = (
|
||||
FeatureCapability(True, "rendered")
|
||||
if render_available
|
||||
else FeatureCapability(False, reason="optional_dependency_missing:matplotlib")
|
||||
)
|
||||
return CapabilityMap(
|
||||
backend="ezdxf",
|
||||
features={
|
||||
"drawing_2d": FeatureCapability(True, "native"),
|
||||
"dxf": FeatureCapability(True, "native"),
|
||||
"pdf": render_capability,
|
||||
"png": render_capability,
|
||||
"transactions": FeatureCapability(True, "snapshot"),
|
||||
"table": FeatureCapability(True, "composite"),
|
||||
"mleader": FeatureCapability(True, "composite"),
|
||||
"preflight": FeatureCapability(True, "shared"),
|
||||
"refiner": FeatureCapability(True, "shared"),
|
||||
"delivery": FeatureCapability(True, "shared"),
|
||||
"paper_space": FeatureCapability(False, reason="planned_1.4"),
|
||||
"solid_3d": FeatureCapability(False, reason="planned_1.5"),
|
||||
"lisp": FeatureCapability(False, reason="live_com_only"),
|
||||
},
|
||||
)
|
||||
|
||||
async def connect(self) -> None:
|
||||
self._connected = True
|
||||
log.info("ezdxf backend ready")
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._cleanup_undo_stack()
|
||||
self._reset_document_state()
|
||||
self._connected = False
|
||||
|
||||
def _cleanup_undo_stack(self):
|
||||
while self._undo_stack:
|
||||
p = self._undo_stack.pop()
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
for stack in (self._undo_stack, self._transaction_stack):
|
||||
while stack:
|
||||
p = stack.pop()
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _require_doc(self):
|
||||
if self._doc is None:
|
||||
@@ -347,6 +380,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
self._doc_path = None
|
||||
self._dirty = False
|
||||
self._current_layer = "0"
|
||||
self._reset_document_state()
|
||||
return {"ok": True, "name": "untitled.dxf"}
|
||||
return await self._async(_sync)
|
||||
|
||||
@@ -371,6 +405,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
except Exception as exc:
|
||||
log.debug("reading $CLAYER from header: %s", exc)
|
||||
self._current_layer = "0"
|
||||
self._reset_document_state()
|
||||
return {"ok": True, "name": Path(path).name, "path": path}
|
||||
return await self._async(_sync)
|
||||
|
||||
@@ -511,6 +546,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
self._doc = None
|
||||
self._doc_path = None
|
||||
self._dirty = False
|
||||
self._reset_document_state()
|
||||
return {"ok": True}
|
||||
return await self._async(_sync)
|
||||
|
||||
@@ -645,6 +681,160 @@ class EzdxfBackend(AutoCADBackend):
|
||||
return _entity_info_dxf(ent)
|
||||
return await self._async(_sync)
|
||||
|
||||
async def entity_create_table(
|
||||
self,
|
||||
x,
|
||||
y,
|
||||
rows,
|
||||
headers=None,
|
||||
column_widths=None,
|
||||
row_height=7.0,
|
||||
text_height=2.5,
|
||||
title=None,
|
||||
layer="TEXT",
|
||||
) -> EntityInfo:
|
||||
from engineering.annotation import prepare_table_layout
|
||||
|
||||
layout = prepare_table_layout(
|
||||
rows, headers, column_widths, row_height, text_height, title
|
||||
)
|
||||
|
||||
def _sync():
|
||||
msp = self._msp()
|
||||
children = []
|
||||
x0, y0 = float(x), float(y)
|
||||
x_positions = [x0]
|
||||
for width in layout.column_widths:
|
||||
x_positions.append(x_positions[-1] + width)
|
||||
|
||||
for row_index in range(layout.row_count + 1):
|
||||
yy = y0 - row_index * layout.row_height
|
||||
line = msp.add_line((x0, yy), (x0 + layout.width, yy))
|
||||
self._apply_attrs(line, layer, None)
|
||||
children.append(line)
|
||||
for xx in x_positions:
|
||||
line = msp.add_line((xx, y0), (xx, y0 - layout.height))
|
||||
self._apply_attrs(line, layer, None)
|
||||
children.append(line)
|
||||
|
||||
padding = min(1.0, layout.row_height * 0.15)
|
||||
for row_index, row in enumerate(layout.cells):
|
||||
for column_index, value in enumerate(row):
|
||||
if not value:
|
||||
continue
|
||||
text_entity = msp.add_mtext(
|
||||
value,
|
||||
dxfattribs={
|
||||
"char_height": layout.text_height,
|
||||
"width": max(1.0, layout.column_widths[column_index] - 2 * padding),
|
||||
},
|
||||
)
|
||||
text_entity.dxf.insert = (
|
||||
x_positions[column_index] + padding,
|
||||
y0 - row_index * layout.row_height - padding,
|
||||
0.0,
|
||||
)
|
||||
self._apply_attrs(text_entity, layer, None)
|
||||
children.append(text_entity)
|
||||
|
||||
self._mark_dirty()
|
||||
child_handles = [str(entity.dxf.handle) for entity in children]
|
||||
group_id = f"table:{uuid.uuid4().hex}"
|
||||
return EntityInfo(
|
||||
handle=child_handles[0],
|
||||
type="TABLE",
|
||||
layer=layer,
|
||||
color=256,
|
||||
linetype="ByLayer",
|
||||
visible=True,
|
||||
properties={
|
||||
"representation": "composite",
|
||||
"logical_group_id": group_id,
|
||||
"child_handles": child_handles,
|
||||
"rows": layout.row_count,
|
||||
"columns": layout.column_count,
|
||||
"bounds": {
|
||||
"min": [x0, y0 - layout.height],
|
||||
"max": [x0 + layout.width, y0],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
async def leader_create_mleader(
|
||||
self,
|
||||
points,
|
||||
text,
|
||||
text_height=2.5,
|
||||
landing_gap=1.0,
|
||||
arrow_size=2.5,
|
||||
layer="DIM",
|
||||
) -> EntityInfo:
|
||||
from engineering.annotation import validate_mleader
|
||||
|
||||
normalized = validate_mleader(points, text)
|
||||
if text_height <= 0 or landing_gap < 0 or arrow_size <= 0:
|
||||
raise RuntimeError(
|
||||
"leader_create_mleader: text_height/arrow_size must be positive and landing_gap non-negative"
|
||||
)
|
||||
|
||||
def _sync():
|
||||
msp = self._msp()
|
||||
path = msp.add_lwpolyline(normalized)
|
||||
self._apply_attrs(path, layer, None)
|
||||
|
||||
(x0, y0), (x1, y1) = normalized[0], normalized[1]
|
||||
dx, dy = x1 - x0, y1 - y0
|
||||
length = math.hypot(dx, dy)
|
||||
if length <= 1e-12:
|
||||
raise RuntimeError("leader_create_mleader: first two points must be distinct")
|
||||
ux, uy = dx / length, dy / length
|
||||
px, py = -uy, ux
|
||||
size = float(arrow_size)
|
||||
arrow = msp.add_lwpolyline(
|
||||
[
|
||||
(x0, y0),
|
||||
(x0 + ux * size + px * size * 0.35, y0 + uy * size + py * size * 0.35),
|
||||
(x0 + ux * size - px * size * 0.35, y0 + uy * size - py * size * 0.35),
|
||||
],
|
||||
close=True,
|
||||
)
|
||||
self._apply_attrs(arrow, layer, None)
|
||||
|
||||
tx, ty = normalized[-1]
|
||||
label = msp.add_mtext(
|
||||
str(text),
|
||||
dxfattribs={"char_height": float(text_height), "width": 100.0},
|
||||
)
|
||||
label.dxf.insert = (tx + float(landing_gap), ty, 0.0)
|
||||
self._apply_attrs(label, layer, None)
|
||||
self._mark_dirty()
|
||||
|
||||
children = [path, arrow, label]
|
||||
child_handles = [str(entity.dxf.handle) for entity in children]
|
||||
return EntityInfo(
|
||||
handle=child_handles[0],
|
||||
type="MLEADER",
|
||||
layer=layer,
|
||||
color=256,
|
||||
linetype="ByLayer",
|
||||
visible=True,
|
||||
properties={
|
||||
"representation": "composite",
|
||||
"logical_group_id": f"mleader:{uuid.uuid4().hex}",
|
||||
"child_handles": child_handles,
|
||||
"points": [list(point) for point in normalized],
|
||||
"text": str(text),
|
||||
"bounds": {
|
||||
"min": [min(point[0] for point in normalized), min(point[1] for point in normalized)],
|
||||
"max": [max(point[0] for point in normalized), max(point[1] for point in normalized)],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
async def entity_create_hatch(
|
||||
self, pattern, boundary_points, scale=1.0, angle=0.0,
|
||||
layer=None, color=None,
|
||||
@@ -1632,10 +1822,10 @@ class EzdxfBackend(AutoCADBackend):
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
self._undo_stack.append(Path(tmp_path))
|
||||
self._transaction_stack.append(Path(tmp_path))
|
||||
max_stack = config.settings.max_undo_stack
|
||||
while len(self._undo_stack) > max_stack:
|
||||
old = self._undo_stack.pop(0)
|
||||
while len(self._transaction_stack) > max_stack:
|
||||
old = self._transaction_stack.pop(0)
|
||||
try:
|
||||
old.unlink()
|
||||
except OSError:
|
||||
@@ -1647,9 +1837,9 @@ class EzdxfBackend(AutoCADBackend):
|
||||
# NEW-undo-1: guard the _undo_stack read+pop with self._lock (via _async)
|
||||
# instead of touching it outside any lock.
|
||||
def _sync():
|
||||
if not self._undo_stack:
|
||||
if not self._transaction_stack:
|
||||
return {"ok": False, "error": "No active transaction"}
|
||||
p = self._undo_stack.pop()
|
||||
p = self._transaction_stack.pop()
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
@@ -1661,11 +1851,12 @@ class EzdxfBackend(AutoCADBackend):
|
||||
# NEW-undo-1: read+mutate _undo_stack under self._lock (via _async) so the
|
||||
# empty-check and the pop/readfile are serialized with other sync work.
|
||||
def _sync():
|
||||
if not self._undo_stack:
|
||||
if not self._transaction_stack:
|
||||
return {"ok": False, "error": "No active transaction to rollback"}
|
||||
p = self._undo_stack.pop()
|
||||
p = self._transaction_stack.pop()
|
||||
try:
|
||||
self._doc = ezdxf.readfile(str(p))
|
||||
self._current_layer = str(self._doc.header.get("$CLAYER", "0"))
|
||||
self._dirty = True
|
||||
finally:
|
||||
try:
|
||||
@@ -1685,7 +1876,7 @@ class EzdxfBackend(AutoCADBackend):
|
||||
"has_document": has_doc,
|
||||
"document_path": self._doc_path,
|
||||
"unsaved_changes": self._dirty,
|
||||
"transaction_depth": len(self._undo_stack),
|
||||
"transaction_depth": len(self._transaction_stack),
|
||||
"capabilities": [
|
||||
"file_read_write", "dxf_export", "pdf_export",
|
||||
"entity_creation", "layer_management", "blocks",
|
||||
|
||||
Reference in New Issue
Block a user