release: prepare v1.3.0

This commit is contained in:
Umutcan Edizsalan
2026-07-17 15:48:43 +03:00
parent 1d47408157
commit 4a2e9d658f
35 changed files with 3053 additions and 48 deletions
+142
View File
@@ -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
View File
@@ -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
View File
@@ -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",