diff --git a/_fonts/isocpeur.ttf b/_fonts/isocpeur.ttf new file mode 100644 index 0000000..fa9150d Binary files /dev/null and b/_fonts/isocpeur.ttf differ diff --git a/backends/com_backend.py b/backends/com_backend.py index 43a85dc..dc0d55d 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -412,6 +412,35 @@ def _block_names(doc) -> set[str]: return names + +def _activate_layout(name: str | None) -> None: + """Make the named layout the active tab, so the next COM call draws on it. + + The ezdxf backend takes ``layout`` as a per-call target; COM has one active + layout per document, so the equivalent is the same switch + ``layout_set_current`` performs — and it stays switched, because the sheet is + where the drafter carries on. Unknown names are refused instead of silently + drawing into model space. + """ + wanted = str(name or "").strip() + if not wanted: + return + doc = _acad_doc() + try: + current = str(doc.ActiveLayout.Name) + except Exception as exc: # a probe must never block a draw + log.debug("ActiveLayout.Name unavailable (%s)", exc) + current = "" + if current and current.lower() == wanted.lower(): + return + try: + doc.ActiveLayout = doc.Layouts.Item(wanted) + except Exception as exc: + raise RuntimeError( + f"layout {wanted!r} does not exist: create it with layout_create first" + ) from exc + + _BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"} #: `drawing_properties_*` field → `IAcadSummaryInfo` property. @@ -2199,7 +2228,19 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) - async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: + async def drawing_export_pdf( + self, + path: str, + layout: str | None = None, + text_layer: bool | None = None, + page_size: str | None = None, + ) -> dict: + # ``text_layer`` and ``page_size`` are accepted for signature parity with + # the ezdxf backend: AutoCAD plots real text natively, and PlotToFile + # plots the layout's own paper at its own scale (that is what a .pc3 + # device does), so neither flag has anything to switch here. + del text_layer + del page_size """Plot one layout to PDF through ``Plot.PlotToFile`` and confirm the file. ``layout=None`` means model space, as the tool advertises — the Model @@ -4677,8 +4718,12 @@ class ComBackend(AutoCADBackend): layer=None, color=None, linetype=None, + layout=None, ) -> EntityInfo: def _sync(): + # Layout ровно как у ezdxf: COM рисует в активный лист, поэтому + # вызов сначала переключает вкладку (см. _activate_layout). + _activate_layout(layout) mspace = _msp() line = mspace.AddLine(_apoint(x1, y1, z1), _apoint(x2, y2, z2)) _apply_entity_attrs(line, layer, color, linetype) @@ -4694,8 +4739,10 @@ class ComBackend(AutoCADBackend): radius, layer=None, color=None, + layout=None, ) -> EntityInfo: def _sync(): + _activate_layout(layout) mspace = _msp() circle = mspace.AddCircle(_apoint(cx, cy), float(radius)) _apply_entity_attrs(circle, layer, color, None) @@ -4713,8 +4760,10 @@ class ComBackend(AutoCADBackend): end_angle, layer=None, color=None, + layout=None, ) -> EntityInfo: def _sync(): + _activate_layout(layout) mspace = _msp() arc = mspace.AddArc( _apoint(cx, cy), @@ -4734,8 +4783,10 @@ class ComBackend(AutoCADBackend): closed=False, layer=None, color=None, + layout=None, ) -> EntityInfo: def _sync(): + _activate_layout(layout) mspace = _msp() flat = [] for pt in points: @@ -4757,11 +4808,25 @@ class ComBackend(AutoCADBackend): rotation=0.0, layer=None, color=None, + style=None, + layout=None, + inline_font=True, ) -> EntityInfo: def _sync(): + # Layout ровно как у ezdxf: COM рисует в активный лист, поэтому + # вызов сначала переключает вкладку (см. _activate_layout). + _activate_layout(layout) mspace = _msp() txt = mspace.AddText(text, _apoint(x, y), float(height)) txt.Rotation = deg2rad(rotation) + if style: + # AutoCAD refuses an unknown style name outright; a caller who + # asked for a face the drawing does not carry gets the current + # style instead of a failed call (matching ezdxf's fallback). + try: + txt.StyleName = str(style) + except Exception: + pass _apply_entity_attrs(txt, layer, color, None) _regen() return _entity_info(txt) @@ -4778,13 +4843,26 @@ class ComBackend(AutoCADBackend): rotation=0.0, layer=None, color=None, + style=None, + layout=None, + inline_font=True, ) -> EntityInfo: + from engineering.annotation import wrap_inline_font + def _sync(): + _activate_layout(layout) mspace = _msp() - mt = mspace.AddMText(_apoint(x, y), float(width), text) + mt = mspace.AddMText( + _apoint(x, y), float(width), wrap_inline_font(text, inline_font) + ) mt.Height = float(height) if rotation: mt.Rotation = deg2rad(rotation) # NEW-mtext-1: honor caller rotation + if style: + try: + mt.StyleName = str(style) + except Exception: + pass _apply_entity_attrs(mt, layer, color, None) _regen() return _entity_info(mt) @@ -4802,31 +4880,44 @@ class ComBackend(AutoCADBackend): text_height=2.5, title=None, layer="TEXT", + style=None, + layout=None, + inline_font=True, ) -> EntityInfo: - from engineering.annotation import prepare_table_layout + from engineering.annotation import prepare_table_layout, wrap_inline_font - layout = prepare_table_layout(rows, headers, column_widths, row_height, text_height, title) + table_layout = prepare_table_layout( + rows, headers, column_widths, row_height, text_height, title + ) def _sync(): + _activate_layout(layout) mspace = _msp() table = mspace.AddTable( _apoint(x, y), - layout.row_count, - layout.column_count, - layout.row_height, - layout.column_widths[0], + table_layout.row_count, + table_layout.column_count, + table_layout.row_height, + table_layout.column_widths[0], ) - for column, width in enumerate(layout.column_widths): + for column, width in enumerate(table_layout.column_widths): table.SetColumnWidth(column, width) # MEASURED on AutoCAD 2026: IAcadTable has no TextHeight property # (the old `table.TextHeight = h` raised and was swallowed, leaving # the Standard style's 6.0 / 4.5 - illegible at 1:50). The member # is SetCellTextHeight(row, col, h), set per cell. - for row_index, row in enumerate(layout.cells): - table.SetRowHeight(row_index, layout.row_height) + for row_index, row in enumerate(table_layout.cells): + table.SetRowHeight(row_index, table_layout.row_height) for column_index, value in enumerate(row): - table.SetText(row_index, column_index, value) - table.SetCellTextHeight(row_index, column_index, layout.text_height) + table.SetText(row_index, column_index, wrap_inline_font(value, inline_font)) + table.SetCellTextHeight(row_index, column_index, table_layout.text_height) + cell_style = None + if style: + try: + table.TextStyle = str(style) + cell_style = str(style) + except Exception as exc: + log.debug("setting table TextStyle failed: %s", exc) _apply_entity_attrs(table, layer, None, None) _regen() info = _entity_info(table) @@ -4837,11 +4928,13 @@ class ComBackend(AutoCADBackend): "representation": "native", "logical_group_id": f"table:{uuid.uuid4().hex}", "child_handles": [info.handle], - "rows": layout.row_count, - "columns": layout.column_count, + "rows": table_layout.row_count, + "columns": table_layout.column_count, + "text_style": cell_style, + "layout": str(layout or ""), "bounds": { - "min": [float(x), float(y) - layout.height], - "max": [float(x) + layout.width, float(y)], + "min": [float(x), float(y) - table_layout.height], + "max": [float(x) + table_layout.width, float(y)], }, } ) @@ -4857,8 +4950,10 @@ class ComBackend(AutoCADBackend): landing_gap=1.0, arrow_size=2.5, layer="DIM", + layout=None, + inline_font=True, ) -> EntityInfo: - from engineering.annotation import validate_mleader + from engineering.annotation import validate_mleader, wrap_inline_font normalized = validate_mleader(points, text) if text_height <= 0 or landing_gap < 0 or arrow_size <= 0: @@ -4867,11 +4962,12 @@ class ComBackend(AutoCADBackend): ) def _sync(): + _activate_layout(layout) flat = [] for px, py in normalized: flat.extend([px, py, 0.0]) leader = _msp().AddMLeader(_av(flat), 0) - leader.TextString = str(text) + leader.TextString = wrap_inline_font(text, inline_font) leader.TextHeight = float(text_height) leader.LandingGap = float(landing_gap) leader.ArrowheadSize = float(arrow_size) @@ -4905,8 +5001,10 @@ class ComBackend(AutoCADBackend): angle=0.0, layer=None, color=None, + layout=None, ) -> EntityInfo: def _sync(): + _activate_layout(layout) mspace = _msp() # acPatternTypePreDefined = 0 hatch = mspace.AddHatch(0, pattern, False) @@ -4933,8 +5031,10 @@ class ComBackend(AutoCADBackend): fit_points, layer=None, color=None, + layout=None, ) -> EntityInfo: def _sync(): + _activate_layout(layout) mspace = _msp() flat = [] for pt in fit_points: @@ -4959,8 +5059,10 @@ class ComBackend(AutoCADBackend): ratio=0.5, layer=None, color=None, + layout=None, ) -> EntityInfo: def _sync(): + _activate_layout(layout) mspace = _msp() ellipse = mspace.AddEllipse( _apoint(cx, cy), @@ -4979,8 +5081,10 @@ class ComBackend(AutoCADBackend): y, layer=None, color=None, + layout=None, ) -> EntityInfo: def _sync(): + _activate_layout(layout) mspace = _msp() pt = mspace.AddPoint(_apoint(x, y)) _apply_entity_attrs(pt, layer, color, None) @@ -4998,9 +5102,11 @@ class ComBackend(AutoCADBackend): scale_y=1.0, rotation=0.0, layer=None, + layout=None, ) -> EntityInfo: def _sync(): _require_block_defined(_acad_doc(), name) + _activate_layout(layout) mspace = _msp() ref = mspace.InsertBlock( _apoint(x, y), @@ -5033,8 +5139,10 @@ class ComBackend(AutoCADBackend): tol_lower=None, tol_mode="none", text_override=None, + layout=None, ) -> EntityInfo: def _sync(): + _activate_layout(layout) mspace = _msp() # The ActiveX API has no AddDimLinear — rotated linear dims are # created with AddDimRotated (GH issue #3). @@ -5061,8 +5169,10 @@ class ComBackend(AutoCADBackend): dim_x, dim_y, layer=None, + layout=None, ) -> EntityInfo: def _sync(): + _activate_layout(layout) mspace = _msp() dim = mspace.AddDimAligned(_apoint(x1, y1), _apoint(x2, y2), _apoint(dim_x, dim_y)) if layer: @@ -5083,8 +5193,10 @@ class ComBackend(AutoCADBackend): tx, ty, layer=None, + layout=None, ) -> EntityInfo: def _sync(): + _activate_layout(layout) mspace = _msp() dim = mspace.AddDimAngular( _apoint(vx, vy), @@ -5111,8 +5223,10 @@ class ComBackend(AutoCADBackend): tol_lower=None, tol_mode="none", text_override=None, + layout=None, ) -> EntityInfo: def _sync(): + _activate_layout(layout) mspace = _msp() dim = mspace.AddDimRadial( _apoint(cx, cy), _apoint(chord_x, chord_y), float(leader_length) @@ -5137,8 +5251,10 @@ class ComBackend(AutoCADBackend): tol_lower=None, tol_mode="none", text_override=None, + layout=None, ) -> EntityInfo: def _sync(): + _activate_layout(layout) mspace = _msp() dim = mspace.AddDimDiametric(_apoint(x1, y1), _apoint(x2, y2), float(leader_length)) if layer: @@ -5149,6 +5265,73 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) + async def dimstyle_ensure( + self, + name="СПДС", + text_style=None, + base=None, + set_current=True, + text_height=2.5, + arrow_size=2.5, + ) -> dict: + """Создать размерный стиль (по умолчанию СПДС) и/или сделать его текущим. + + COM-эквивалент метода ezdxf: текстовый стиль размера переводим на + ГОСТ-начертание (``text_style``), высоту текста и стрелки задаём явно + (ГОСТ 2.304: 2.5 мм при 1:1), при ``set_current`` стиль становится + активным (``ActiveDimStyle``) — следующий размер выйдет им. + """ + + def _sync(): + doc = _acad_doc() + style_name = str(name or "").strip() + if not style_name: + raise RuntimeError("dimstyle_ensure: name must not be empty") + names = [str(doc.DimStyles.Item(i).Name) for i in range(doc.DimStyles.Count)] + created = False + if style_name in names: + style = doc.DimStyles.Item(style_name) + else: + style = doc.DimStyles.Add(style_name) + created = True + source = str(base or "").strip() or "Standard" + if source in names: + try: + style.CopyFrom(doc.DimStyles.Item(source)) + except Exception as exc: + log.debug("dimstyle_ensure: CopyFrom %s failed (%s)", source, exc) + for attr, value in (("TextStyle", text_style), ("TextHeight", text_height), + ("ArrowheadSize", arrow_size)): + if value in (None, "", 0): + continue + try: + setattr(style, attr, value) + except Exception as exc: + # AutoCAD отказывает на незнакомое имя стиля — не валим вызов. + log.debug("dimstyle_ensure: %s=%s rejected (%s)", attr, value, exc) + if set_current: + try: + doc.ActiveDimStyle = style + except Exception as exc: + log.debug("dimstyle_ensure: ActiveDimStyle failed (%s)", exc) + _regen() + current = "" + try: + current = str(doc.ActiveDimStyle.Name) + except Exception: + current = "" + return { + "ok": True, + "name": style_name, + "created": created, + "text_style": str(text_style or ""), + "set_current": bool(set_current), + "current": current, + "dimstyles": names + ([] if style_name in names else [style_name]), + } + + return await self._run(_sync) + # ── entity modification ─────────────────────────────────────────────────── async def entity_move(self, handle, dx, dy, dz=0.0) -> dict: @@ -5746,6 +5929,8 @@ class ComBackend(AutoCADBackend): rotation=0.0, attributes=None, layer=None, + style=None, + layout=None, ) -> EntityInfo: from backends.block_specs import validate_attribute_values @@ -5753,6 +5938,7 @@ class ComBackend(AutoCADBackend): def _sync(): _require_block_defined(_acad_doc(), name) + _activate_layout(layout) mspace = _msp() ref = mspace.InsertBlock( _apoint(x, y), @@ -5773,6 +5959,15 @@ class ComBackend(AutoCADBackend): attr.TextString = values[tag] except Exception as exc: log.debug("Block insert: GetAttributes or attribute setting failed: %s", exc) + if style: + # The ATTRIB inherits its ATTDEF's text style; a definition made + # before the ГОСТ style existed carries ``Standard``, which plots + # the block marks in the wrong face. + try: + for attr in ref.GetAttributes(): + attr.StyleName = str(style) + except Exception as exc: + log.debug("Block insert: setting attribute style failed: %s", exc) return _entity_info(ref) return await self._run(_sync) @@ -6165,7 +6360,7 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) - async def block_set_attributes(self, handle, attributes) -> dict: + async def block_set_attributes(self, handle, attributes, style=None) -> dict: from backends.block_specs import validate_attribute_values values = validate_attribute_values(attributes) @@ -6175,12 +6370,26 @@ class ComBackend(AutoCADBackend): ref = doc.HandleToObject(handle) attrs = ref.GetAttributes() updated = [] + restyled = 0 for attr in attrs: tag = attr.TagString if tag in values: attr.TextString = values[tag] updated.append(tag) - return {"ok": True, "updated_tags": updated} + if style: + # The reference's ATTDEF inherited ``Standard`` when there was + # no ГОСТ style yet; a value written into it would still plot + # in the wrong face, so the update carries the style with it. + try: + attr.StyleName = str(style) + restyled += 1 + except Exception: + pass + result = {"ok": True, "updated_tags": updated} + if style: + result["style"] = str(style) + result["restyled_tags"] = restyled + return result return await self._run(_sync) @@ -6259,26 +6468,70 @@ class ComBackend(AutoCADBackend): handles, base_x=0.0, base_y=0.0, + layout=None, ) -> dict: """Build a block definition from entities already in the drawing. This used to refuse and point at ``system_run_command`` with ``_BLOCK``, pushing callers through the free-text escape hatch for something ActiveX does directly: ``Blocks.Add`` creates the definition and - ``Document.CopyObjects`` puts entities into it. The originals stay in - model space, matching the headless backend rather than AutoCAD's BLOCK + ``Document.CopyObjects`` puts entities into it. The originals stay where + they were, matching the headless backend rather than AutoCAD's BLOCK command (which consumes them). + + ``layout`` matches the headless backend: a definition is document-global + (it has no owner sheet), so the parameter constrains the *source* — only + handles whose ``OwnerID`` is that layout's own block are taken, and a + foreign handle is named in a refusal instead of copied silently. """ def _sync(): doc = _acad_doc() - objects, skipped = [], [] + owner_id = None + space_label = "" + if layout is not None: + wanted = self._layout_name(layout) + if wanted.lower() == "model": + space_label = "Model" + owner_id = int(doc.ModelSpace.ObjectID) + else: + resolved = self._find_layout(doc, wanted) + if resolved is None: + raise RuntimeError( + f"layout {wanted!r} does not exist: create it with " + f"layout_create first" + ) + space_label = resolved + owner_id = int(doc.Layouts.Item(resolved).Block.ObjectID) + + objects, skipped, foreign = [], [], [] for handle in handles: try: - objects.append(doc.HandleToObject(str(handle))) + obj = doc.HandleToObject(str(handle)) except Exception as exc: log.debug("resolving %s for block %s: %s", handle, name, exc) skipped.append(str(handle)) + continue + if owner_id is not None: + # An unreadable OwnerID is refused with the foreign handles + # rather than waved through: the point of layout= is to + # guarantee the definition came off that sheet. + try: + same_space = int(obj.OwnerID) == owner_id + except Exception as exc: + log.debug("OwnerID of %s unreadable (%s)", handle, exc) + same_space = False + if not same_space: + foreign.append(str(handle)) + continue + objects.append(obj) + if foreign: + raise RuntimeError( + f"block_create_from_entities: handle(s) {', '.join(foreign)} are not in " + f"space {space_label!r}; a definition is built from one sheet's entities " + f"at a time. Drop layout= to collect drawing-wide, or pass the handles " + f"of that sheet. No definition was created." + ) # Resolve before creating, so a call where every handle was a typo # does not leave an empty definition behind. if not objects: diff --git a/backends/contracts/dimensions.py b/backends/contracts/dimensions.py index 3549ac7..0024e2f 100644 --- a/backends/contracts/dimensions.py +++ b/backends/contracts/dimensions.py @@ -30,6 +30,7 @@ class DimensionContract(ABC): tol_lower: float | None = None, tol_mode: str = "none", text_override: str | None = None, + layout: str | None = None, ) -> EntityInfo: ... @abstractmethod @@ -42,6 +43,7 @@ class DimensionContract(ABC): dim_x: float, dim_y: float, layer: str | None = None, + layout: str | None = None, ) -> EntityInfo: ... @abstractmethod @@ -56,6 +58,7 @@ class DimensionContract(ABC): tx: float, ty: float, layer: str | None = None, + layout: str | None = None, ) -> EntityInfo: ... @abstractmethod @@ -71,6 +74,7 @@ class DimensionContract(ABC): tol_lower: float | None = None, tol_mode: str = "none", text_override: str | None = None, + layout: str | None = None, ) -> EntityInfo: ... @abstractmethod @@ -86,4 +90,5 @@ class DimensionContract(ABC): tol_lower: float | None = None, tol_mode: str = "none", text_override: str | None = None, + layout: str | None = None, ) -> EntityInfo: ... diff --git a/backends/contracts/entity_creation.py b/backends/contracts/entity_creation.py index 27235f8..bfb0f45 100644 --- a/backends/contracts/entity_creation.py +++ b/backends/contracts/entity_creation.py @@ -72,6 +72,9 @@ class EntityCreationContract(ABC): rotation: float = 0.0, layer: str | None = None, color: int | None = None, + style: str | None = None, + layout: str | None = None, + inline_font: bool = True, ) -> EntityInfo: ... @abstractmethod @@ -85,6 +88,9 @@ class EntityCreationContract(ABC): rotation: float = 0.0, layer: str | None = None, color: int | None = None, + style: str | None = None, + layout: str | None = None, + inline_font: bool = True, ) -> EntityInfo: ... @abstractmethod @@ -99,6 +105,9 @@ class EntityCreationContract(ABC): text_height: float = 2.5, title: str | None = None, layer: str = "TEXT", + style: str | None = None, + layout: str | None = None, + inline_font: bool = True, ) -> EntityInfo: ... @abstractmethod @@ -110,6 +119,8 @@ class EntityCreationContract(ABC): landing_gap: float = 1.0, arrow_size: float = 2.5, layer: str = "DIM", + layout: str | None = None, + inline_font: bool = True, ) -> EntityInfo: ... @abstractmethod diff --git a/backends/contracts/layouts.py b/backends/contracts/layouts.py index 1b8b017..0ec4071 100644 --- a/backends/contracts/layouts.py +++ b/backends/contracts/layouts.py @@ -28,6 +28,29 @@ class LayoutContract(ABC): """Activate a layout tab ('Model' or a paper-space layout name).""" ... + # MCP card t_b950b5c4 (01.10.2026). A sheet's format is part of the page + # setup, and every plot of the sheet reads it: without a way to state the + # format, a 210x297 frame drawn on a layout that still says A3 exports onto + # A3 with the frame in a corner. The headless backend stores it in the + # layout's paper fields; ActiveX exposes no verified equivalent. + @capability( # noqa: B027 + "paper_space", + reason=( + "ActiveX has no verified member for a layout's sheet format (the page setup lives in " + "the drawing's plot configuration, which has not been exercised against a live " + "AutoCAD). Set the format in AutoCAD's Page Setup, or use the headless backend " + "(AUTOCAD_MCP_BACKEND=ezdxf), where layout_create + layout_set_paper store it." + ), + ) + async def layout_set_paper(self, name: str, width: float, height: float) -> dict: + """Set the sheet a layout is set up for, in millimetres. + + Returns ``{ok, layout, paper_mm}``. This is what + ``drawing_export_pdf(page_size='layout')`` reads to size the page, so a + sheet exported at 1:1 is the format it was drawn for. + """ + ... + @abstractmethod async def layout_delete(self, name: str) -> dict: """Delete a paper-space layout and everything drawn on it. diff --git a/backends/contracts/premium.py b/backends/contracts/premium.py index f1f090b..739039f 100644 --- a/backends/contracts/premium.py +++ b/backends/contracts/premium.py @@ -622,11 +622,14 @@ class PremiumContract(ABC): style: DimStyle = "chain", offset: float = 10.0, layer: str | None = None, + layout: str | None = None, ) -> list[EntityInfo]: """Generate ISO 129 dimensions across `handles` in the chosen style (chain | baseline | ordinate). `offset` is the dimension-line distance from the reference geometry (mm). `layer` defaults to the - active layer set's dimension layer (DIM, or M-DIMEN-T-N for iso13567).""" + active layer set's dimension layer (DIM, or M-DIMEN-T-N for iso13567). + `layout` puts the dimensions on a named paper-space sheet instead of the + current space, so a sheet's dimensions live in paper millimetres.""" if not handles: return [] dim_layer = layer or self._role_layer("dim") @@ -668,6 +671,7 @@ class PremiumContract(ABC): my + ny * off, rotation=math.degrees(math.atan2(dy, dx)), layer=dim_layer, + layout=layout, ) results.append(dim) @@ -689,6 +693,7 @@ class PremiumContract(ABC): # baseline, not horizontal. rotation=base_rot, layer=dim_layer, + layout=layout, ) results.append(dim) @@ -704,6 +709,7 @@ class PremiumContract(ABC): sx + (ex - sx) / 2.0, max(sy, ey) + rung, layer=dim_layer, + layout=layout, ) results.append(dim) if abs(ey - sy) > 1e-9: @@ -716,6 +722,7 @@ class PremiumContract(ABC): (sy + ey) / 2.0, rotation=90.0, layer=dim_layer, + layout=layout, ) results.append(dim) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 985ba0a..c098117 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -136,6 +136,499 @@ def _new_agg_figure(*, figsize=None, dpi=100): return fig +def _layout_paper_mm(layout) -> tuple[float, float] | None: + """The sheet a paper-space layout was set up for, in millimetres. + + ``Layout.dxf.paper_width`` / ``paper_height`` are the *page* fields a plotter + reads, not the drawn content: an A3 layout carries 420 x 297 no matter how + much geometry happens to sit on it. They are what ``drawing_export_pdf`` has + to size its page from — autoscaling to the content bounding box turned every + sheet into a ~168 x 122 mm thumbnail of itself. + + Model space has no sheet (nothing to plot 1:1 onto), so ``None`` is returned + and the extents behaviour stays in force. ``$MEASUREMENT`` says whether the + stored numbers are millimetres (1, metric) or inches (0). + """ + try: + dxf = layout.dxf + width = float(dxf.get("paper_width", 0.0) or 0.0) + height = float(dxf.get("paper_height", 0.0) or 0.0) + except Exception as exc: # a malformed LAYOUT record must not kill the export + log.debug("PDF page: layout paper fields unreadable (%s)", exc) + return None + if width <= 0 or height <= 0: + return None + try: + metric = int(layout.doc.header.get("$MEASUREMENT", 1) or 1) + except Exception: + metric = 1 + if metric == 0: + return width * 25.4, height * 25.4 + return width, height + + +#: DejaVu Sans cap-height / em ratio. DXF text height is a cap height, matplotlib +#: font size is an em size, so a 5 mm DXF text is drawn at 5 / 0.72 * scale pt. +_PDF_TEXT_CAP_RATIO = 0.72 + +#: Text smaller than this (points) is not worth a text object in the PDF. +_PDF_MIN_FONT_PT = 0.4 + +#: Smallest legible cap height on a sheet, mm (ГОСТ 2.304 / ISO 3098 for +#: drawing lettering). ``drawing_export_pdf`` lists every label that plots below +#: it in ``warnings``: an illegible sheet used to leave the plotter silently. +_PDF_MIN_READABLE_MM = 1.8 + +#: Where the bundled ISOCPEUR face may live. ISOCPEUR is the GOST 2.304 +#: typeface the drawings are lettered in; AutoCAD ships it, a bare container +#: does not, so the build copies the TTF next to the package. When nothing is +#: found the text layer falls back to matplotlib's DejaVu Sans, which still +#: carries Cyrillic and a usable ToUnicode map — a missing font degrades the +#: look of the PDF, it must never fail the export. +_PDF_FONT_CANDIDATES = ( + Path(__file__).resolve().parent.parent / "_fonts" / "isocpeur.ttf", + Path("/opt/fonts/isocpeur.ttf"), + Path("/usr/share/fonts/truetype/isocpeur/isocpeur.ttf"), +) +_pdf_font_cache: dict[str, str | None] = {} + + +def _pdf_font() -> tuple[str, str | None]: + """``(matplotlib family, source path)`` for the editable text layer. + + Resolved once per process: ``addfont`` mutates matplotlib's global font + manager, and doing that on every export would grow its registry without + bound inside a long-lived MCP server. + """ + if _pdf_font_cache: + return _pdf_font_cache["family"], _pdf_font_cache["path"] + family: str = "DejaVu Sans" + path: str | None = None + try: + from matplotlib import font_manager + from matplotlib.font_manager import FontProperties + + for candidate in _PDF_FONT_CANDIDATES: + if not candidate.is_file(): + continue + font_manager.fontManager.addfont(str(candidate)) + name = str(FontProperties(fname=str(candidate)).get_name() or "").strip() + if name: + family, path = name, str(candidate) + break + except Exception as exc: # noqa: BLE001 - a bad font must not break the plot + log.debug("PDF text layer: ISOCPEUR unavailable (%s)", exc) + _pdf_font_cache["family"], _pdf_font_cache["path"] = family, path + return family, path + + +#: The text style a drawing is lettered in. ISOCPEUR is the ГОСТ 2.304 face +#: (``isocpeur.ttf``, shipped with AutoCAD); the style holding it is called +#: GOST_A by the title-block module, and one name for both keeps the stamp and +#: the model lettering identical instead of half the sheet in ``Standard``. +DEFAULT_TEXT_STYLE = "GOST_A" +DEFAULT_TEXT_STYLE_FONT = "isocpeur.ttf" + +#: Entity types whose ``style`` attribute selects a lettering style. +_TEXT_STYLE_TYPES = ("TEXT", "MTEXT", "ATTRIB", "ATTDEF") + + +def _ensure_default_text_style(doc, name: str = DEFAULT_TEXT_STYLE, + font: str = DEFAULT_TEXT_STYLE_FONT) -> dict: + """Create the ГОСТ text style and make it the drawing's ``$TEXTSTYLE``. + + Every ``entity_create_*`` that writes text takes its style from + ``$TEXTSTYLE`` when the caller names none, so a drawing that starts without + that style letters its model text in ``Standard`` — not the face a ГОСТ sheet + is drawn in, and on some DXF versions not a Unicode font either. Called on + create and on open, so the style is current before any text lands. + """ + try: + created = False + if name in doc.styles: + style = doc.styles.get(name) + if font and not str(style.dxf.get("font", "") or "").strip(): + style.dxf.font = font + else: + style = doc.styles.add(name, font=font or DEFAULT_TEXT_STYLE_FONT) + created = True + doc.header["$TEXTSTYLE"] = name + return { + "name": name, + "font": str(style.dxf.get("font", "") or ""), + "created": created, + "default": doc.header.get("$TEXTSTYLE", ""), + } + except Exception as exc: # a style table that refuses must not block a load + log.debug("default text style unavailable (%s)", exc) + return {"name": name, "error": str(exc)} + + +def _pdf_text_halign(entity) -> str: + return {0: "left", 1: "center", 2: "right", 3: "left", 4: "center", 5: "center"}.get( + int(entity.dxf.get("halign", 0) or 0), "left" + ) + + +def _pdf_text_valign(entity) -> str: + return { + 0: "baseline", + 1: "bottom", + 2: "center", + 3: "top", + }.get(int(entity.dxf.get("valign", 0) or 0), "baseline") + + +def _pdf_text_anchor(entity) -> tuple[float, float]: + """Insertion point, honouring the alignment point of aligned TEXT. + + A TEXT entity with halign/valign set ignores ``insert`` and is positioned by + ``align_point`` instead — reading only ``insert`` would put every centred or + right-aligned label in the wrong place. + """ + halign = int(entity.dxf.get("halign", 0) or 0) + valign = int(entity.dxf.get("valign", 0) or 0) + point = entity.dxf.get("align_point", None) if (halign or valign) else None + if point is None: + point = entity.dxf.get("insert", (0.0, 0.0, 0.0)) + return float(point[0]), float(point[1]) + + +def _iter_pdf_text_entities(layout) -> list: + """Every TEXT/MTEXT/ATTRIB that should reach the PDF, in WCS. + + Block references are expanded with ``virtual_entities()`` so their nested + TEXT entities arrive transformed by the insert's position, rotation and + scale instead of in the block's local frame. Their ATTRIBs are *not* part of + the virtual expansion — a DXF ATTRIB is a real entity owned by the reference + (``INSERT.attribs``), holding the value the attribute definition was filled + with — so they are collected separately. Without them the block marks + (``Т1``/``S1``/``V1``) never reached the plot even though the geometry did. + """ + found = [] + try: + entities = list(layout) + except Exception: + return found + for ent in entities: + dxftype = ent.dxftype() + if dxftype in ("TEXT", "MTEXT", "ATTRIB"): + found.append(ent) + elif dxftype == "INSERT": + try: + found.extend( + sub for sub in ent.virtual_entities() if sub.dxftype() in ("TEXT", "MTEXT") + ) + except Exception as exc: # a broken reference must not kill the plot + log.debug("PDF text layer: skipping INSERT %s (%s)", ent.dxf.get("handle", "?"), exc) + try: + found.extend(ent.attribs) + except Exception as exc: + log.debug("PDF text layer: INSERT %s attributes unreadable (%s)", + ent.dxf.get("handle", "?"), exc) + elif dxftype == "DIMENSION": + # Текст размера лежит не в самой сущности, а в её блоке геометрии + # (*D1): обход смотрел только INSERT, поэтому геометрия размера + # печаталась, а само число на листе пропадало. Блок у размера + # свой собственный, поэтому двойного учёта не возникает. + try: + block = ent.get_geometry_block() + except Exception as exc: + log.debug("PDF text layer: DIMENSION %s geometry unreadable (%s)", + ent.dxf.get("handle", "?"), exc) + block = None + if block is not None: + for sub in block: + if sub.dxftype() in ("TEXT", "MTEXT"): + found.append(sub) + return found + + +def _pdf_text_extents(entities) -> tuple[float, float, float, float] | None: + """Bounding box of the text entities, in the same space they live in. + + ``TextPolicy.IGNORE`` keeps TEXT out of the geometry pass, so the extents the + Frontend derives exclude it. On a drawing whose only vertical size comes from + its labels (a plan whose geometry is one horizontal line, a detail sheet) + that leaves a degenerate plot box: the page is scaled to the geometry alone + and the labels — placed in data coordinates — land outside the visible area + and are clipped away on save. The caller unions these extents into the axes + limits before drawing the text, so the strings survive into the PDF. + """ + if not entities: + return None + boxes = [] + if _BBOX_OK: + try: + box = ezdxf_bbox.extents(entities, fast=True) + if box.has_data: + boxes.append((float(box.extmin.x), float(box.extmin.y), + float(box.extmax.x), float(box.extmax.y))) + except Exception as exc: # a pathological entity must not kill the export + log.debug("PDF text layer: ezdxf bbox failed (%s)", exc) + if not boxes: + # Fallback estimate: anchor plus the font height, width from the glyph count. + xs: list[float] = [] + ys: list[float] = [] + for ent in entities: + try: + if ent.dxftype() == "MTEXT": + point = ent.dxf.get("insert", (0.0, 0.0, 0.0)) + height = float(ent.dxf.get("char_height", 2.5) or 2.5) + text = ent.plain_text() + else: + point = _pdf_text_anchor(ent) + height = float(ent.dxf.get("height", 2.5) or 2.5) + text = str(ent.dxf.get("text", "")) + except Exception: + continue + x, y = float(point[0]), float(point[1]) + xs.extend((x, x + max(len(text), 1) * height * 0.7)) + ys.extend((y, y + height * 1.2)) + if not xs: + return None + boxes.append((min(xs), min(ys), max(xs), max(ys))) + return ( + min(b[0] for b in boxes), + min(b[1] for b in boxes), + max(b[2] for b in boxes), + max(b[3] for b in boxes), + ) + + +def _pdf_text_item(entity) -> dict | None: + """One drawable label: text, anchor, height in drawing units, alignment. + + ``None`` for anything that must not reach the plot — an empty string, an + invisible ATTRIB, an entity whose DXF fields do not read. + """ + try: + if entity.dxftype() == "MTEXT": + text = entity.plain_text() + height = float(entity.dxf.get("char_height", 2.5) or 2.5) + point = entity.dxf.get("insert", (0.0, 0.0, 0.0)) + x, y = float(point[0]), float(point[1]) + rotation = float(entity.dxf.get("rotation", 0.0) or 0.0) + ha, va = "left", "top" + else: + # An ATTRIB flagged invisible is data the sheet deliberately hides + # (the ATTDEF "Invisible" mode); plotting it would put values on the + # drawing that the file itself says are not displayed. + if entity.dxftype() == "ATTRIB" and int(entity.dxf.get("invisible", 0) or 0): + return None + text = str(entity.dxf.get("text", "")) + height = float(entity.dxf.get("height", 2.5) or 2.5) + x, y = _pdf_text_anchor(entity) + rotation = float(entity.dxf.get("rotation", 0.0) or 0.0) + ha, va = _pdf_text_halign(entity), _pdf_text_valign(entity) + except Exception as exc: + log.debug("PDF text layer: unreadable entity (%s)", exc) + return None + text = (text or "").strip() + if not text or height <= 0: + return None + return { + "text": text, + "x": x, + "y": y, + "height": height, + "rotation": rotation, + "ha": ha, + "va": va, + "source": entity.dxftype(), + "clip": None, + "via": "layout", + } + + +def _viewport_text_items(layout) -> tuple[list[dict], list[dict]]: + """Model-space labels as a paper-space layout's viewports show them. + + The geometry pass already projects model content through each viewport, but + the text layer is assembled from entities and a viewport holds none: the + model's TEXT/MTEXT/ATTRIB were missing from every plotted sheet while the + circles and lines around them were drawn. Each label is mapped with + ``paper = centre + (model - view_center) * scale`` and clipped to the viewport + window; its height is scaled the same way, so the lettering keeps the + proportion AutoCAD gives it instead of plotting at model size over the sheet. + """ + try: + modelspace = layout.doc.modelspace() + except Exception as exc: + log.debug("PDF text layer: model space unavailable (%s)", exc) + return [], [] + model_items = [item for item in map(_pdf_text_item, _iter_pdf_text_entities(modelspace)) if item] + if not model_items: + return [], [] + items: list[dict] = [] + info: list[dict] = [] + for viewport in layout.viewports(): + try: + width = float(viewport.dxf.get("width", 0.0) or 0.0) + height = float(viewport.dxf.get("height", 0.0) or 0.0) + view_height = float(viewport.dxf.get("view_height", 0.0) or 0.0) + center = viewport.dxf.get("center", (0.0, 0.0, 0.0)) + view_center = viewport.dxf.get("view_center_point", (0.0, 0.0, 0.0)) + except Exception as exc: + log.debug("PDF text layer: viewport unreadable (%s)", exc) + continue + if width <= 0 or height <= 0 or view_height <= 0: + # The layout's main viewport carries the tab's own pan/zoom state + # rather than a drafting scale, so it has no view height to use. + continue + scale = height / view_height + cx, cy = float(center[0]), float(center[1]) + vx, vy = float(view_center[0]), float(view_center[1]) + x0, y0 = cx - width / 2.0, cy - height / 2.0 + clip = (x0, y0, x0 + width, y0 + height) + for item in model_items: + moved = dict(item) + moved["x"] = cx + (item["x"] - vx) * scale + moved["y"] = cy + (item["y"] - vy) * scale + moved["height"] = item["height"] * scale + moved["clip"] = clip + moved["via"] = "viewport" + items.append(moved) + info.append( + { + "handle": str(viewport.dxf.get("handle", "")), + "center": [cx, cy], + "size": [width, height], + "scale": round(scale, 8), + "view_height": view_height, + "clip": list(clip), + } + ) + return items, info + + +def _layout_text_items(layout, *, include_viewports: bool) -> tuple[list[dict], list[dict]]: + """Every label to plot for ``layout``: its own, plus what viewports show.""" + items = [item for item in map(_pdf_text_item, _iter_pdf_text_entities(layout)) if item] + info: list[dict] = [] + if include_viewports: + projected, info = _viewport_text_items(layout) + items.extend(projected) + return items, info + + +def _item_box(item) -> tuple[float, float, float, float]: + """Rough data-space box of a label, for the "does it reach the window" test.""" + width = max(len(item["text"]), 1) * item["height"] * 0.9 + height = item["height"] * 1.35 + x, y = item["x"], item["y"] + if item["ha"] == "center": + x -= width / 2.0 + elif item["ha"] == "right": + x -= width + if item["va"] == "top": + y -= height + elif item["va"] == "center": + y -= height / 2.0 + return x, y, x + width, y + height + + +def _draw_pdf_text_items(ax, fig, items, *, family: str = "DejaVu Sans") -> dict: + """Place real text on the matplotlib axes so the PDF keeps a text layer. + + ezdxf >= 1.1 converts TEXT/MTEXT to filled glyph paths before the backend + sees them, so ``drawing_export_pdf`` had no text in the output at all: text + extraction returned an empty string and the labels were not selectable. The + geometry pass runs with ``TextPolicy.IGNORE`` and the strings are drawn here, + in data coordinates, at a font size derived from the axes scale. + + A label carrying ``clip`` belongs to a viewport: it is cut off at the window + edge the way AutoCAD plots it, instead of spilling across the sheet. Every + label that ends up below ``_PDF_MIN_READABLE_MM`` on the sheet is named in + ``warnings``, so an illegible sheet is reported instead of plotted silently. + """ + from matplotlib.patches import Rectangle + + stats: dict = { + "entities": len(items), + "drawn": 0, + "skipped": 0, + "clipped": 0, + "font": family, + "fonttype": 42, + "min_height_mm": _PDF_MIN_READABLE_MM, + "warnings": [], + } + try: + y0, y1 = ax.get_ylim() + fig_w_in, fig_h_in = fig.get_size_inches() + except Exception as exc: + stats["error"] = f"axes limits unavailable: {exc}" + return stats + y_span = abs(y1 - y0) + if y_span <= 0 or not fig_h_in: + stats["error"] = "degenerate axes limits" + return stats + pt_per_unit = (float(fig_h_in) * 72.0) / y_span + stats["points_per_unit"] = round(pt_per_unit, 6) + + for item in items: + fontsize = item["height"] * pt_per_unit / _PDF_TEXT_CAP_RATIO + if fontsize < _PDF_MIN_FONT_PT: + stats["skipped"] += 1 + continue + clip = item.get("clip") + if clip is not None: + bx0, by0, bx1, by1 = _item_box(item) + if bx1 < clip[0] or bx0 > clip[2] or by1 < clip[1] or by0 > clip[3]: + stats["clipped"] += 1 + continue + try: + artist = ax.text( + item["x"], + item["y"], + item["text"], + fontsize=fontsize, + rotation=item["rotation"], + rotation_mode="anchor", + ha=item["ha"], + va=item["va"], + color="black", + family=family, + clip_on=clip is not None, + ) + if clip is not None: + artist.set_clip_path( + Rectangle( + (clip[0], clip[1]), + clip[2] - clip[0], + clip[3] - clip[1], + transform=ax.transData, + ) + ) + stats["drawn"] += 1 + except Exception as exc: + log.debug("PDF text layer: drawing failed (%s)", exc) + stats["skipped"] += 1 + continue + if item["height"] < _PDF_MIN_READABLE_MM: + stats["warnings"].append( + { + "text": item["text"][:60], + "height_mm": round(item["height"], 3), + "source": item["source"], + "via": item["via"], + "at": [round(item["x"], 2), round(item["y"], 2)], + } + ) + return stats + + +def _draw_pdf_text_layer(ax, layout, fig, *, include_viewports: bool = False) -> dict: + """Draw everything textual a layout shows: its own labels and viewport content.""" + family, _font_path = _pdf_font() + items, viewport_info = _layout_text_items(layout, include_viewports=include_viewports) + stats = _draw_pdf_text_items(ax, fig, items, family=family) + if viewport_info: + stats["viewports"] = viewport_info + return stats + + try: import ezdxf from ezdxf import colors, units # noqa: F401 @@ -724,6 +1217,29 @@ def _style_dimvar(doc, attribute: str, fallback, coerce): return fallback +def _current_dimstyle(doc) -> str: + """Размерный стиль, которым будет начерчен следующий размер. + + Размеры жёстко брали ``Standard``, поэтому «сделать СПДС текущим» меняло + только заголовок ``$DIMSTYLE``, а следующий размер всё равно выходил + Standard. Берём из заголовка, если он называет существующий стиль; иначе — + прежний ``Standard``, то есть на нетронутом чертеже поведение не меняется. + + Стили, которые создаёт сам ezdxf (``drawing_new`` идёт через + ``ezdxf.new(setup=True)`` и оставляет в заголовке ``$DIMSTYLE=EZDXF``), + пропускаем: сервер их не настраивал, а метрические ``EZ_*``-стили несут + коэффициент длины — прочитанный вслепую заголовок начертил бы 100 мм как + «10000». + """ + try: + name = str(doc.header.get("$DIMSTYLE", "") or "").strip() + if name and not name.upper().startswith("EZ") and name in doc.dimstyles: + return name + except Exception: # a header that will not read must not block a dimension + pass + return _DIMSTYLE_NAME + + def effective_dimvar(doc, attribute: str): """The value the next dimension will actually be rendered with. @@ -1994,6 +2510,50 @@ class EzdxfBackend(AutoCADBackend): self._current_space = "Model" return doc.modelspace() + def _space(self, layout=None): + """The space one create call writes into: current space, or a named sheet. + + ``layout`` is a per-call target, not a mode switch: without it the call + lands where it always did (``_msp`` — model space, or the current layout + after ``layout_set_current``). With it the entities go into that paper + layout's block, which is what makes annotation live in millimetres of + paper and stop depending on the viewport scale. The named layout must + exist — silently falling back to model space would put a sheet's labels + somewhere nobody looks. + """ + name = self._layout_name(layout) if layout is not None else "" + if not name: + return self._msp() + if name.lower() == "model": + return self._require_doc().modelspace() + resolved = self._find_layout(name) + if resolved is None: + raise RuntimeError( + f"layout {name!r} does not exist: create it with layout_create first" + ) + return self._require_doc().layouts.get(resolved) + + def _inline_lettering_style(self, style=None) -> str: + """The style that gives a single-line TEXT the inline font's guarantee. + + ``inline_font`` on MTEXT writes ``{\\fISOCPEUR|b0|i0|c204|p34;\\Q0;…}`` + and MTEXT decodes it. A single-line TEXT (DTEXT) does not parse control + codes at all — the group would be plotted literally, braces and all — so + for TEXT the same promise ("this label is lettered in ISOCPEUR whatever + happens to the style table") is kept by putting the entity into the ГОСТ + style that owns ``isocpeur.ttf``. Pavel's sample does exactly this: every + MTEXT carries the inline group, every TEXT simply sits in the ОСНОВНОЙ + (isocpeur.ttf) style. + """ + if style: + return str(style) + try: + if DEFAULT_TEXT_STYLE in self._require_doc().styles: + return DEFAULT_TEXT_STYLE + except Exception as exc: # a style table that will not read must not block + log.debug("inline lettering style unavailable (%s)", exc) + return self._default_text_style() + @staticmethod def _busy_error(timeout: float) -> RuntimeError: return RuntimeError( @@ -2303,6 +2863,11 @@ class EzdxfBackend(AutoCADBackend): "document": key, "open_documents": len(self._docs), } + result = {"ok": True, "name": "untitled.dxf"} + # The ГОСТ lettering style is put in place before the first entity: + # every text tool reads $TEXTSTYLE when the caller names no style, so + # an empty header is what used to letter the whole model in Standard. + result["text_style"] = _ensure_default_text_style(self._doc) if released is not None: result["quarantine_cleared"] = released.to_dict() return result @@ -2347,6 +2912,11 @@ class EzdxfBackend(AutoCADBackend): } if reloaded: result["reloaded"] = True + result = {"ok": True, "name": Path(path).name, "path": path} + # Opening a file written before the ГОСТ style existed would leave + # every later tool lettering new text in Standard; the style is put + # in place (and made current) on the way in. + result["text_style"] = _ensure_default_text_style(self._doc) if released is not None: result["quarantine_cleared"] = released.to_dict() return result @@ -2372,6 +2942,75 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) + async def drawing_open_base64(self, content_base64: str, name: str = "from_base64.dxf") -> dict: + """Open a drawing handed over inline as base64 (N4, 01.10.2026). + + The container's ``/opt/data/dxf`` is NOT the agent's ``/opt/data/dxf`` + (separate bind mount), so a file the agent writes is invisible to + ``drawing_open`` and a file this server saves is invisible to the agent. + Every exchange had to detour through a throwaway container. This opens + the drawing from the bytes themselves — no shared folder, no HTTP bridge. + """ + import base64 as _b64 + import io as _io + + def _sync(): + raw = str(content_base64 or "").strip() + if not raw: + raise RuntimeError("drawing_open_base64: content_base64 is empty") + if raw.startswith("data:"): + raw = raw.split(",", 1)[-1] + try: + data = _b64.b64decode(raw, validate=False) + except Exception as exc: + raise RuntimeError(f"drawing_open_base64: not valid base64 ({exc})") from exc + max_bytes = config.settings.max_dxf_bytes + if max_bytes > 0 and len(data) > max_bytes: + raise RuntimeError( + f"DXF payload exceeds MAX_DXF_BYTES limit ({len(data)} > {max_bytes})." + ) + text = data.decode("utf-8", errors="replace") + self._doc = ezdxf.read(_io.StringIO(text)) + _normalise_dimstyle_rounding(self._doc) + self._clear_quarantine("drawing_open_base64") + self._doc_path = None + self._dirty = False + try: + self._current_layer = self._doc.header.get("$CLAYER", "0") + except Exception: + self._current_layer = "0" + self._reset_document_state() + self._current_space = "Model" + self._reset_history_baseline() + return { + "ok": True, + "name": str(name or "from_base64.dxf"), + "bytes": len(data), + "source": "base64", + "text_style": _ensure_default_text_style(self._doc), + } + + return await self._async(_sync, quarantine_exit=True) + + async def drawing_save_base64(self) -> dict: + """Return the current drawing inline as base64 DXF (N4, 01.10.2026).""" + import base64 as _b64 + import io as _io + + def _sync(): + doc = self._require_doc() + stream = _io.StringIO() + doc.write(stream, fmt="asc") + data = stream.getvalue().encode("utf-8") + return { + "ok": True, + "name": Path(self._doc_path).name if self._doc_path else "untitled.dxf", + "bytes": len(data), + "content_base64": _b64.b64encode(data).decode("ascii"), + } + + return await self._async(_sync) + async def drawing_save_as(self, path: str, fmt: str = "dxf") -> dict: # The extension is authoritative (N2): a .dwg name is a DWG request even # when the caller passes fmt="dxf", because the bytes must match the name. @@ -2605,13 +3244,32 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) - async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: + + async def drawing_export_pdf( + self, + path: str, + layout: str | None = None, + text_layer: bool = True, + page_size: str = "layout", + ) -> dict: """Export to PDF via ezdxf's matplotlib backend. With ``layout`` set to a paper-space layout name, the layout's own entities *and* the model content each viewport looks at are rendered, at the viewport's scale and position and clipped to its window. + ``page_size`` decides what the PDF page *is*: + + * ``"layout"`` (default) — a plot of the sheet: the page is the + layout's own ``paper_width`` x ``paper_height`` and the axes span that + same sheet in drawing units, so the plot is 1:1 the way AutoCAD plots + a layout. A 185 mm ГОСТ stamp stays 185 mm on paper and the frame + lands at its margins (20/5/5/5 mm on A3). Model space has no sheet, so + this falls back to ``"extents"``. + * ``"extents"`` — the old behaviour: the page is shrunk to the bounding + box of everything drawn (a ~168 x 122 mm thumbnail of an A3 sheet), + which is fine for a draft screenshot and wrong for a drawing. + This docstring used to say model projection was COM-only. It was measured and it is not: a sheet holding nothing but a viewport renders the geometry that viewport shows, and geometry outside the window is @@ -2638,8 +3296,18 @@ class EzdxfBackend(AutoCADBackend): target = doc.layouts.get(resolved) else: target = doc.modelspace() + mode = str(page_size or "layout").strip().lower() + if mode not in ("layout", "extents"): + return { + "ok": False, + "error": ( + f"Unknown page_size {page_size!r}: use 'layout' (sheet, 1:1) " + "or 'extents' (page around the drawn content)" + ), + } try: from ezdxf.addons.drawing import Frontend, RenderContext + from ezdxf.addons.drawing.config import Configuration, TextPolicy from ezdxf.addons.drawing.matplotlib import MatplotlibBackend # Track E: for a sheet, the figure *is* the paper. Sized from @@ -2648,21 +3316,71 @@ class EzdxfBackend(AutoCADBackend): # is judged by. Measured: adjust_figure=True gave 691.2 x 345.6 # pt whatever the sheet was. paper = None - if resolved is not None and resolved != "Model": + if mode == "layout" and resolved is not None and resolved != "Model": paper = self._paper_frame(target) - fig = _new_agg_figure(figsize=paper["figsize"] if paper else None) + import matplotlib + + # N5 (01.10.2026) — matplotlib's PDF backend defaults to Type 3 + # fonts, whose glyphs carry no usable ToUnicode map: text + # extraction from such a PDF returns an empty string and the + # labels are neither searchable nor selectable. Type 42 embeds + # a real TrueType subset with the encoding table. + matplotlib.rcParams["pdf.fonttype"] = 42 + matplotlib.rcParams["pdf.use14corefonts"] = False + + # The figure is created at the sheet's size in inches *before* + # anything is drawn, and the figure is what sets the PDF page: + # 420 mm of sheet is 16.54 in of figure. ``adjust_figure`` and + # ``finalize`` — which re-fit both the data limits and the figure + # box to the content — are off in this mode. + fig = _new_agg_figure( + figsize=paper["figsize"] if paper else None, dpi=150 + ) ax = fig.add_axes([0, 0, 1, 1]) ctx = RenderContext(doc) + # ezdxf >= 1.1 renders TEXT/MTEXT as filled glyph paths (curves), + # so nothing textual reaches the PDF at all. With + # ``text_layer=True`` the pipeline skips text and it is placed as + # real matplotlib text afterwards (see ``_draw_pdf_text_layer``). + config_obj = Configuration( + text_policy=TextPolicy.IGNORE if text_layer else TextPolicy.FILLING + ) out = MatplotlibBackend(ax, adjust_figure=paper is None) - Frontend(ctx, out).draw_layout(target, finalize=True) + Frontend(ctx, out, config=config_obj).draw_layout( + target, finalize=paper is None + ) if paper is not None: ax.set_xlim(*paper["xlim"]) ax.set_ylim(*paper["ylim"]) ax.set_aspect("equal", adjustable="box") + text_stats = None + if text_layer: + if paper is None: + # Text is invisible to the geometry pass, so it does not + # contribute to the plot box the Frontend just fitted. Union + # the label extents into the axes limits first — otherwise a + # drawing sized by its geometry alone clips every label away + # (see _pdf_text_extents). + ext = _pdf_text_extents(_iter_pdf_text_entities(target)) + if ext is not None: + dx0, dx1 = ax.get_xlim() + dy0, dy1 = ax.get_ylim() + ax.set_xlim(min(dx0, ext[0]), max(dx1, ext[2])) + ax.set_ylim(min(dy0, ext[1]), max(dy1, ext[3])) + text_stats = _draw_pdf_text_layer(ax, target, fig) + else: + # A sheet plots its own entities *and* whatever its + # viewports look at. The geometry pass draws the second + # half from the viewport transform, so the text layer has + # to project it the same way — otherwise the sheet comes + # out with the circles and lines but none of the labels + # (defect found on 01.10.2026). + text_stats = _draw_pdf_text_layer(ax, target, fig, include_viewports=True) fig.savefig(path, dpi=150) - result = {"ok": True, "path": path} + result = {"ok": True, "path": path, "page_size": mode} if paper is not None: result["paper_mm"] = paper["size_mm"] + result["page_mm"] = [round(v, 2) for v in paper["size_mm"]] result["scale"] = paper["scale"] result["effective_scale"] = paper["effective_scale"] result["scale_applied"] = paper["scale_applied"] @@ -2672,13 +3390,30 @@ class EzdxfBackend(AutoCADBackend): result["plot_area_note"] = paper["plot_area_note"] if not paper["rotation_applied"]: result["rotation_applied"] = False + if text_stats is not None: + result["text_layer"] = text_stats + narrow = text_stats.get("warnings") or [] + if narrow: + limit = text_stats.get("min_height_mm", _PDF_MIN_READABLE_MM) + result["warnings"] = [ + f"надпись {item['text']!r} выходит на листе {item['height_mm']} мм " + f"(норма ≥ {limit} мм, источник {item['source']}, {item['via']})" + for item in narrow + ] + result["warnings_detail"] = narrow if resolved is not None and resolved != "Model": result["layout"] = resolved result["note"] = ( - "Paper-space entities are rendered, and model content is projected " + "Paper-space entities are rendered, and model content — geometry and " + "text alike (TEXT/MTEXT/ATTRIB, block marks included) — is projected " "through each viewport at its scale and clipped to its window. " "Viewport borders themselves are not drawn." ) + if paper is None and mode == "layout": + result["note_page_size"] = ( + "page_size='layout' fell back to the drawing extents: model space " + "has no sheet to plot 1:1 onto." + ) return result except ImportError: raise RuntimeError( @@ -2770,6 +3505,45 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) + async def layout_set_paper(self, name: str, width: float, height: float) -> dict: + """Set the sheet a layout is set up for, in millimetres. + + ``Layout.dxf.paper_width`` / ``paper_height`` are the page fields a plot + reads, and ``drawing_export_pdf(page_size="layout")`` sizes its PDF from + them. A drawing made by ``drawing_new`` starts on A3 whatever the frame + drawn on it says, so a GOST-A4 sheet would be exported onto an A3 page — + the frame would sit in a corner of a bigger sheet. The values are stored + in the document's own units ($MEASUREMENT: 1 = mm, 0 = inch). + """ + def _sync(): + doc = self._require_doc() + resolved = self._find_layout(name) + if resolved is None: + return {"ok": False, "error": f"Layout not found: {name}"} + try: + width_mm = float(width) + height_mm = float(height) + except (TypeError, ValueError): + return {"ok": False, "error": "Paper size must be two numbers"} + if width_mm <= 0 or height_mm <= 0: + return {"ok": False, "error": "Paper size must be positive"} + layout = doc.layouts.get(resolved) + try: + metric = int(doc.header.get("$MEASUREMENT", 1) or 1) + except Exception: + metric = 1 + if metric == 0: + width_mm, height_mm = width_mm / 25.4, height_mm / 25.4 + layout.dxf.paper_width = width_mm + layout.dxf.paper_height = height_mm + return { + "ok": True, + "layout": resolved, + "paper_mm": [round(width_mm, 2), round(height_mm, 2)], + } + + return await self._async(_sync) + async def layout_set_current(self, name: str) -> dict: def _sync(): doc = self._require_doc() @@ -4716,9 +5490,10 @@ class EzdxfBackend(AutoCADBackend): layer=None, color=None, linetype=None, + layout=None, ) -> EntityInfo: def _sync(): - msp = self._msp() + msp = self._space(layout) ent = msp.add_line( (float(x1), float(y1), float(z1)), (float(x2), float(y2), float(z2)), @@ -4736,9 +5511,10 @@ class EzdxfBackend(AutoCADBackend): radius, layer=None, color=None, + layout=None, ) -> EntityInfo: def _sync(): - msp = self._msp() + msp = self._space(layout) ent = msp.add_circle((float(cx), float(cy)), float(radius)) self._apply_attrs(ent, layer, color) self._mark_dirty() @@ -4755,9 +5531,10 @@ class EzdxfBackend(AutoCADBackend): end_angle, layer=None, color=None, + layout=None, ) -> EntityInfo: def _sync(): - msp = self._msp() + msp = self._space(layout) ent = msp.add_arc( (float(cx), float(cy)), float(radius), @@ -4776,9 +5553,10 @@ class EzdxfBackend(AutoCADBackend): closed=False, layer=None, color=None, + layout=None, ) -> EntityInfo: def _sync(): - msp = self._msp() + msp = self._space(layout) pts_2d = [(float(p[0]), float(p[1])) for p in points] ent = msp.add_lwpolyline(pts_2d, close=closed) self._apply_attrs(ent, layer, color) @@ -4787,6 +5565,39 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) + def _default_text_style(self) -> str: + """The document's current text style ($TEXTSTYLE), when it exists. + + ``msp.add_text`` hardcodes ``style="Standard"``, which for Cyrillic + content is exactly the style that turns into question marks in AutoCAD. + Honouring ``$TEXTSTYLE`` is what AutoCAD itself does for new text, and + it is how ``textstyle_ensure(set_default=True)`` reaches every label a + title block draws. + + When ``$TEXTSTYLE`` names nothing usable the ГОСТ style is used if the + drawing carries it — a file built before the style was made current + would otherwise silently letter its model text in ``Standard``, which is + the defect the ISOCPEUR requirement was raised about. + """ + doc = None + try: + doc = self._require_doc() + except Exception: + return "Standard" + name = "" + try: + name = str(doc.header.get("$TEXTSTYLE", "") or "").strip() + except Exception: + name = "" + try: + if name and name in doc.styles: + return name + if DEFAULT_TEXT_STYLE in doc.styles: + return DEFAULT_TEXT_STYLE + except Exception: + pass + return "Standard" + async def entity_create_text( self, text, @@ -4796,13 +5607,21 @@ class EzdxfBackend(AutoCADBackend): rotation=0.0, layer=None, color=None, + style=None, + layout=None, + inline_font=True, ) -> EntityInfo: def _sync(): - msp = self._msp() - ent = msp.add_text( + space = self._space(layout) + resolved_style = ( + self._inline_lettering_style(style) + if inline_font + else (style or self._default_text_style()) + ) + ent = space.add_text( text, dxfattribs={ - "style": _current_textstyle_name(self._require_doc()), + "style": resolved_style, "height": float(height), "rotation": float(rotation), "insert": (float(x), float(y)), @@ -4824,15 +5643,20 @@ class EzdxfBackend(AutoCADBackend): rotation=0.0, layer=None, color=None, + style=None, + layout=None, + inline_font=True, ) -> EntityInfo: + from engineering.annotation import wrap_inline_font + def _sync(): - msp = self._msp() - ent = msp.add_mtext( - text, + space = self._space(layout) + ent = space.add_mtext( + wrap_inline_font(text, inline_font), dxfattribs={ - "style": _current_textstyle_name(self._require_doc()), "char_height": float(height), "width": float(width), + "style": style or self._default_text_style(), }, ) ent.dxf.insert = (float(x), float(y), 0.0) @@ -4855,22 +5679,28 @@ class EzdxfBackend(AutoCADBackend): text_height=2.5, title=None, layer="TEXT", + style=None, + layout=None, + inline_font=True, ) -> EntityInfo: - from engineering.annotation import prepare_table_layout + from engineering.annotation import prepare_table_layout, wrap_inline_font - layout = prepare_table_layout(rows, headers, column_widths, row_height, text_height, title) + table_layout = prepare_table_layout( + rows, headers, column_widths, row_height, text_height, title + ) def _sync(): - msp = self._msp() + space = self._space(layout) + cell_style = style or self._default_text_style() children = [] x0, y0 = float(x), float(y) x_positions = [x0] - for width in layout.column_widths: + for width in table_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)) + for row_index in range(table_layout.row_count + 1): + yy = y0 - row_index * table_layout.row_height + line = space.add_line((x0, yy), (x0 + table_layout.width, yy)) self._apply_attrs(line, layer, None) children.append(line) last = len(x_positions) - 1 @@ -4878,34 +5708,39 @@ class EzdxfBackend(AutoCADBackend): # a title row is merged, as AutoCAD's TABLE draws it: only the # outer edges cross it inner = 0 < index < last - top = y0 - layout.row_height if layout.title_row and inner else y0 - line = msp.add_line((xx, top), (xx, y0 - layout.height)) + top = ( + y0 - table_layout.row_height + if table_layout.title_row and inner + else y0 + ) + line = space.add_line((xx, top), (xx, y0 - table_layout.height)) self._apply_attrs(line, layer, None) children.append(line) # the cell margin scales with the lettering: 1.0 at the default 2.5 # text, 50 on a 1:50 schedule (a flat 1.0 there is 0.02 mm on the # sheet and the text sat on the cell line) - padding = min(0.4 * layout.text_height, 0.15 * layout.row_height) - for row_index, row in enumerate(layout.cells): + padding = min(0.4 * table_layout.text_height, 0.15 * table_layout.row_height) + for row_index, row in enumerate(table_layout.cells): for column_index, value in enumerate(row): if not value: continue span = ( - layout.width - if layout.title_row and row_index == 0 - else layout.column_widths[column_index] + table_layout.width + if table_layout.title_row and row_index == 0 + else table_layout.column_widths[column_index] ) - text_entity = msp.add_mtext( - value, + text_entity = space.add_mtext( + wrap_inline_font(value, inline_font), dxfattribs={ - "char_height": layout.text_height, + "char_height": table_layout.text_height, "width": max(1.0, span - 2 * padding), + "style": cell_style, }, ) text_entity.dxf.insert = ( x_positions[column_index] + padding, - y0 - row_index * layout.row_height - padding, + y0 - row_index * table_layout.row_height - padding, 0.0, ) self._apply_attrs(text_entity, layer, None) @@ -4925,17 +5760,27 @@ class EzdxfBackend(AutoCADBackend): "representation": "composite", "logical_group_id": group_id, "child_handles": child_handles, - "rows": layout.row_count, - "columns": layout.column_count, + "rows": table_layout.row_count, + "columns": table_layout.column_count, + "space": self._reply_space_name(layout), "bounds": { - "min": [x0, y0 - layout.height], - "max": [x0 + layout.width, y0], + "min": [x0, y0 - table_layout.height], + "max": [x0 + table_layout.width, y0], }, }, ) return await self._async(_sync) + def _reply_space_name(self, layout=None) -> str: + """Where the entities of a create call ended up, for the tool reply.""" + name = self._layout_name(layout) if layout is not None else "" + if not name: + return self._current_space + if name.lower() == "model": + return "Model" + return self._find_layout(name) or name + async def leader_create_mleader( self, points, @@ -4944,8 +5789,10 @@ class EzdxfBackend(AutoCADBackend): landing_gap=1.0, arrow_size=2.5, layer="DIM", + layout=None, + inline_font=True, ) -> EntityInfo: - from engineering.annotation import validate_mleader + from engineering.annotation import validate_mleader, wrap_inline_font normalized = validate_mleader(points, text) if text_height <= 0 or landing_gap < 0 or arrow_size <= 0: @@ -4954,7 +5801,7 @@ class EzdxfBackend(AutoCADBackend): ) def _sync(): - msp = self._msp() + msp = self._space(layout) path = msp.add_lwpolyline(normalized) self._apply_attrs(path, layer, None) @@ -4978,8 +5825,12 @@ class EzdxfBackend(AutoCADBackend): tx, ty = normalized[-1] label = msp.add_mtext( - str(text), - dxfattribs={"char_height": float(text_height), "width": 100.0}, + wrap_inline_font(text, inline_font), + dxfattribs={ + "char_height": float(text_height), + "width": 100.0, + "style": self._default_text_style(), + }, ) label.dxf.insert = (tx + float(landing_gap), ty, 0.0) self._apply_attrs(label, layer, None) @@ -5023,9 +5874,10 @@ class EzdxfBackend(AutoCADBackend): angle=0.0, layer=None, color=None, + layout=None, ) -> EntityInfo: def _sync(): - msp = self._msp() + msp = self._space(layout) hatch = msp.add_hatch() hatch.set_pattern_fill(pattern, scale=float(scale), angle=float(angle)) pts = [(float(p[0]), float(p[1])) for p in boundary_points] @@ -5041,9 +5893,10 @@ class EzdxfBackend(AutoCADBackend): fit_points, layer=None, color=None, + layout=None, ) -> EntityInfo: def _sync(): - msp = self._msp() + msp = self._space(layout) pts = [(float(p[0]), float(p[1]), 0.0) for p in fit_points] ent = msp.add_spline(fit_points=pts) self._apply_attrs(ent, layer, color) @@ -5061,9 +5914,10 @@ class EzdxfBackend(AutoCADBackend): ratio=0.5, layer=None, color=None, + layout=None, ) -> EntityInfo: def _sync(): - msp = self._msp() + msp = self._space(layout) ent = msp.add_ellipse( center=(float(cx), float(cy), 0.0), major_axis=(float(major_x), float(major_y), 0.0), @@ -5081,9 +5935,10 @@ class EzdxfBackend(AutoCADBackend): y, layer=None, color=None, + layout=None, ) -> EntityInfo: def _sync(): - msp = self._msp() + msp = self._space(layout) ent = msp.add_point((float(x), float(y))) self._apply_attrs(ent, layer, color) self._mark_dirty() @@ -5100,8 +5955,10 @@ class EzdxfBackend(AutoCADBackend): scale_y=1.0, rotation=0.0, layer=None, + layout=None, ) -> EntityInfo: def _sync(): + msp = self._space(layout) msp = self._msp() self._require_block(name) ent = msp.add_blockref( @@ -5328,7 +6185,7 @@ class EzdxfBackend(AutoCADBackend): except (TypeError, ValueError, IndexError): return None - async def entity_create_wipeout(self, points, layer=None) -> dict: + async def entity_create_wipeout(self, points, layer=None, layout=None) -> dict: def _sync(): vertices = self._plane_points(points) if vertices is None: @@ -5340,7 +6197,7 @@ class EzdxfBackend(AutoCADBackend): f"A wipeout needs at least 3 points to enclose an area; got {len(vertices)}" ), } - msp = self._msp() + msp = self._space(layout) entity = msp.add_wipeout(vertices) if layer: entity.dxf.layer = layer @@ -5355,7 +6212,7 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) async def entity_create_revcloud( - self, points, segment_length, layer=None, closed: bool = True + self, points, segment_length, layer=None, closed: bool = True, layout=None ) -> dict: def _sync(): from ezdxf.math import Vec2 @@ -5389,7 +6246,7 @@ class EzdxfBackend(AutoCADBackend): # REVCLOUD_PROPS xdata and picks the bulge sign from the winding # direction, which is what makes the result recognisable as a # revision cloud rather than a wobbly polyline. - msp = self._msp() + msp = self._space(layout) entity = add_revcloud(msp, vertices, length) if layer: entity.dxf.layer = layer @@ -5420,13 +6277,14 @@ class EzdxfBackend(AutoCADBackend): tol_lower=None, tol_mode="none", text_override=None, + layout=None, ) -> EntityInfo: def _sync(): from engineering.tolerances import build_dim_override override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override) override = _with_header_dimvars(self._require_doc(), override) - msp = self._msp() + msp = self._space(layout) dim = msp.add_linear_dim( base=(float(dim_x), float(dim_y)), p1=(float(x1), float(y1)), @@ -5454,9 +6312,10 @@ class EzdxfBackend(AutoCADBackend): dim_x, dim_y, layer=None, + layout=None, ) -> EntityInfo: def _sync(): - msp = self._msp() + msp = self._space(layout) dim = msp.add_aligned_dim( p1=(float(x1), float(y1)), p2=(float(x2), float(y2)), @@ -5486,9 +6345,10 @@ class EzdxfBackend(AutoCADBackend): tx, ty, layer=None, + layout=None, ) -> EntityInfo: def _sync(): - msp = self._msp() + msp = self._space(layout) vxf, vyf = float(vx), float(vy) dim = msp.add_angular_dim_2l( base=(float(tx), float(ty)), @@ -5519,13 +6379,14 @@ class EzdxfBackend(AutoCADBackend): tol_lower=None, tol_mode="none", text_override=None, + layout=None, ) -> EntityInfo: def _sync(): from engineering.tolerances import build_dim_override override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override) override = _with_header_dimvars(self._require_doc(), override) - msp = self._msp() + msp = self._space(layout) cxf, cyf = float(cx), float(cy) radius = math.sqrt((chord_x - cxf) ** 2 + (chord_y - cyf) ** 2) angle_rad = math.atan2(chord_y - cyf, chord_x - cxf) @@ -5564,13 +6425,14 @@ class EzdxfBackend(AutoCADBackend): tol_lower=None, tol_mode="none", text_override=None, + layout=None, ) -> EntityInfo: def _sync(): from engineering.tolerances import build_dim_override override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override) override = _with_header_dimvars(self._require_doc(), override) - msp = self._msp() + msp = self._space(layout) x1f, y1f, x2f, y2f = float(x1), float(y1), float(x2), float(y2) cx = (x1f + x2f) / 2 cy = (y1f + y2f) / 2 @@ -6604,13 +7466,29 @@ class EzdxfBackend(AutoCADBackend): rotation=0.0, attributes=None, layer=None, + style=None, + layout=None, ) -> EntityInfo: from backends.block_specs import validate_attribute_values values = validate_attribute_values(attributes) def _sync(): - msp = self._msp() + doc = self._require_doc() + msp = self._space(layout) + # N1 (01.10.2026) — was ``add_auto_blockref``, which wraps the INSERT + # and its ATTRIBs in an anonymous block ``*U``: the drawing then + # carries no reference to the named definition at all (:: + # ``block_find_references`` found nothing, ``block_get_attributes`` + # answered ``{}`` and every purge/audit pass saw an unnamed block). + # The reference is created directly on the named definition and the + # ATTRIBs are attached with ``Insert.add_auto_attribs``. + if name not in doc.blocks: + raise RuntimeError( + f"block_insert: block definition {name!r} does not exist in this " + f"drawing (have: {', '.join(b.name for b in doc.blocks if not b.name.startswith('*')) or 'none'}). " + "Create it first (drawing_open with a template, or block_create_from_entities)." + ) attribs: dict = { "xscale": float(scale_x), "yscale": float(scale_y), @@ -6620,12 +7498,18 @@ class EzdxfBackend(AutoCADBackend): attribs["layer"] = layer self._require_block(name) ref = msp.add_blockref(name, (float(x), float(y)), dxfattribs=attribs) + tags = [attdef.dxf.tag for attdef in doc.blocks[name].attdefs()] + filled: list[str] = [] + unknown: list[str] = [] if values: # `add_auto_blockref` wraps the INSERT in an anonymous *U block, # so the returned handle names `*U1` and carries no ATTRIBs. # `add_auto_attribs` attaches ATTRIBs to this INSERT for every - # ATTDEF the definition has; values for tags it lacks are ignored. + # ATTDEF the definition has; values for tags it lacks are ignored + # and are reported below instead of pretending they landed. ref.add_auto_attribs(values) + filled = [tag for tag in tags if tag in values] + unknown = sorted(set(values) - set(tags)) # A multi-line ATTDEF becomes a multi-line ATTRIB through # `embed_mtext`, which mirrors only the first line of the value # into `dxf.text`; the whole value goes there (the convention @@ -6633,8 +7517,26 @@ class EzdxfBackend(AutoCADBackend): for attrib in ref.attribs: if attrib.has_embedded_mtext_entity: attrib.dxf.text = self._attrib_value(attrib) + # An ATTRIB inherits its ATTDEF's style, and a definition drawn + # before the ГОСТ style existed carries ``Standard`` — which is not + # the face a sheet is lettered in. The reference is stamped with the + # requested style (or the drawing's current one) so a block mark + # (Т1/S1/V1) comes out in the same type as everything around it. + mark_style = str(style).strip() if style else self._default_text_style() + restyled = 0 + for attrib in ref.attribs: + if mark_style and attrib.dxf.get("style", None) != mark_style: + attrib.dxf.style = mark_style + restyled += 1 self._mark_dirty() - return _entity_info_dxf(ref) + info = _entity_info_dxf(ref) + info.properties["block_name"] = name + info.properties["attribs_filled"] = filled + info.properties["attribs_unknown_tags"] = unknown + if restyled or ref.attribs: + info.properties["attribs_style"] = mark_style + info.properties["attribs_restyled"] = restyled + return info return await self._async(_sync) @@ -7000,23 +7902,473 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) - async def block_set_attributes(self, handle, attributes) -> dict: + async def block_set_attributes(self, handle, attributes, style=None) -> dict: from backends.block_specs import validate_attribute_values values = validate_attribute_values(attributes) def _sync(): + doc = self._require_doc() ent = self._get_entity(handle) if ent.dxftype() != "INSERT": raise RuntimeError(f"Entity {handle} is not a block reference") - updated = [] - for attrib in ent.attribs: - tag = attrib.dxf.tag - if tag in values: - self._write_attrib_value(attrib, values[tag]) + existing = {attrib.dxf.tag: attrib for attrib in ent.attribs} + block_name = ent.dxf.name + updated: list[str] = [] + created: list[str] = [] + unknown: list[str] = [] + attr_style = str(style).strip() if style else self._default_text_style() + # N2 (01.10.2026) — this used to walk ``ent.attribs`` only, so a + # reference inserted without attribute values (or created before the + # fix to block_insert) had nothing to update and the call answered + # ``updated_tags: []`` while the drawing stayed empty. Missing ATTRIBs + # are now materialised from the block definition's ATTDEFs. + for tag, value in values.items(): + attrib = existing.get(tag) + if attrib is not None: + self._write_attrib_value(attrib, value) updated.append(tag) + continue + attrib = self._add_missing_attrib(doc, ent, block_name, tag, value, attr_style) + if attrib is None: + unknown.append(tag) + else: + created.append(tag) + # A value written into an ATTRIB whose style is ``Standard`` still + # plots in the wrong face: the update carries the style too. + restyled = 0 + if attr_style: + for attrib in ent.attribs: + if attrib.dxf.get("style", None) != attr_style: + attrib.dxf.style = attr_style + restyled += 1 + if updated or created or restyled: + self._mark_dirty() + return { + "ok": True, + "handle": ent.dxf.get("handle", handle), + "block_name": block_name, + "updated_tags": updated, + "created_tags": created, + "unknown_tags": unknown, + "style": attr_style, + "restyled_tags": restyled, + "attributes": {attrib.dxf.tag: attrib.dxf.text for attrib in ent.attribs}, + } + + return await self._async(_sync) + + @staticmethod + def _add_missing_attrib(doc, insert, block_name, tag, value, style=None): + """Materialise one ATTRIB on ``insert`` from the block's ATTDEF ``tag``. + + Returns the new ATTRIB, or ``None`` when the block defines no such tag — + the caller reports that instead of inventing an attribute AutoCAD would + not associate with the definition. + """ + if block_name not in doc.blocks: + return None + attdef = next( + (a for a in doc.blocks[block_name].attdefs() if a.dxf.tag == tag), None + ) + if attdef is None: + return None + dxfattribs = attdef.dxfattribs(drop={"prompt", "handle"}) + location = dxfattribs.pop("insert", (0.0, 0.0)) + dxfattribs.pop("tag", None) + if style: + dxfattribs["style"] = style + return insert.add_attrib(tag, str(value), location, dxfattribs) + + async def block_get_attdefs(self, name) -> list[dict]: + """Attribute definitions (ATTDEF) carried by a block definition.""" + + def _sync(): + doc = self._require_doc() + if name not in doc.blocks: + raise RuntimeError(f"Block definition {name!r} does not exist") + out = [] + for attdef in doc.blocks[name].attdefs(): + point = attdef.dxf.get("insert", (0.0, 0.0, 0.0)) + out.append( + { + "tag": attdef.dxf.tag, + "prompt": attdef.dxf.get("prompt", ""), + "default": attdef.dxf.get("text", ""), + "height": float(attdef.dxf.get("height", 0.0)), + "x": float(point[0]), + "y": float(point[1]), + "layer": attdef.dxf.get("layer", "0"), + } + ) + return out + + return await self._async(_sync) + + async def entity_create_attdef( + self, + block_name, + tag, + x, + y, + height=2.5, + prompt="", + default="", + layer=None, + rotation=0.0, + style=None, + invisible=False, + ) -> EntityInfo: + """Create an ATTDEF inside a block definition (N3, 01.10.2026). + + Every other ``entity_create_*`` writes into the current space, so an + attribute definition — which by definition lives *inside* a block + definition — had no tool at all, and a symbol library with attributes + could only be assembled locally in ezdxf, outside this server. + """ + + def _sync(): + doc = self._require_doc() + if block_name not in doc.blocks: + raise RuntimeError( + f"entity_create_attdef: block definition {block_name!r} does not exist. " + "Create it first with block_create_from_entities." + ) + blk = doc.blocks[block_name] + tag_value = str(tag).strip() + if not tag_value: + raise RuntimeError("entity_create_attdef: tag must not be empty") + if any(a.dxf.tag == tag_value for a in blk.attdefs()): + raise RuntimeError( + f"entity_create_attdef: block {block_name!r} already defines attribute {tag_value!r}" + ) + dxfattribs: dict = { + "height": float(height), + "rotation": float(rotation), + "prompt": str(prompt or tag_value), + "text": str(default), + "style": style or self._default_text_style(), + } + if layer: + dxfattribs["layer"] = layer + if invisible: + dxfattribs["invisible"] = 1 + attdef = blk.add_attdef(tag_value, (float(x), float(y)), dxfattribs=dxfattribs) self._mark_dirty() - return {"ok": True, "updated_tags": updated} + info = _entity_info_dxf(attdef) + info.properties["tag"] = tag_value + info.properties["block_name"] = block_name + return info + + return await self._async(_sync) + + async def textstyle_ensure( + self, + name="GOST_A", + font="isocpeur.ttf", + bigfont="", + set_default=False, + height=0.0, + width_factor=1.0, + ) -> dict: + """Create the named text style if missing and optionally make it default. + + Cyrillic needs a style that points at a Unicode-capable font: the + ``Standard`` style references ``txt``/``Arial`` depending on the DXF + version, and a GOST sheet stamped with it can come out as question marks + in AutoCAD. ``ISOCPEUR`` ships with AutoCAD, hence the default font here. + """ + + def _sync(): + doc = self._require_doc() + style_name = str(name or "").strip() + if not style_name: + raise RuntimeError("textstyle_ensure: name must not be empty") + created = False + if style_name in doc.styles: + style = doc.styles.get(style_name) + if font: + style.dxf.font = font + if bigfont is not None: + style.dxf.bigfont = bigfont or "" + else: + style = doc.styles.add(style_name, font=font or "isocpeur.ttf") + style.dxf.bigfont = bigfont or "" + created = True + style.dxf.height = float(height) + style.dxf.width = float(width_factor) + if set_default: + doc.header["$TEXTSTYLE"] = style_name + self._mark_dirty() + return { + "ok": True, + "name": style_name, + "font": style.dxf.font, + "bigfont": style.dxf.get("bigfont", ""), + "created": created, + "default": doc.header.get("$TEXTSTYLE", "Standard"), + "styles": [s.dxf.name for s in doc.styles], + } + + return await self._async(_sync) + + async def dimstyle_ensure( + self, + name="СПДС", + text_style=None, + base=None, + set_current=True, + text_height=2.5, + arrow_size=2.5, + ) -> dict: + """Create the named dimension style and (optionally) make it current. + + The dimension text style is the ГОСТ ISOCPEUR face (``GOST_A`` unless the + caller names another one, and created if the drawing lacks it): a + dimension lettered in a foreign face stands out on the sheet. Making the + style current is what the header ``$DIMSTYLE`` records, and it is now what + the dimension tools read — before this, "make СПДС current" changed the + header while every later dimension still came out ``Standard``. + + ``base`` copies an existing style (its arrows, gaps, units) instead of + starting from the schema defaults, and defaults to ``Standard``. Text and + arrow sizes are then set explicitly (ГОСТ 2.304: 2.5 mm lettering for + sizes at 1:1): an entry left entirely empty makes the renderer fall back + to 1.0 mm text, which is below the 1.8 mm the plotter check calls + illegible. + """ + + def _sync(): + doc = self._require_doc() + style_name = str(name or "").strip() + if not style_name: + raise RuntimeError("dimstyle_ensure: name must not be empty") + text_style_name = self._inline_lettering_style(text_style) + if text_style_name not in doc.styles: + doc.styles.add(text_style_name, font=DEFAULT_TEXT_STYLE_FONT) + created = False + if style_name in doc.dimstyles: + style = doc.dimstyles.get(style_name) + else: + source = str(base or "").strip() or _DIMSTYLE_NAME + if source in doc.dimstyles: + style = doc.dimstyles.duplicate_entry(source, style_name) + else: + style = doc.dimstyles.add(style_name) + created = True + style.dxf.dimtxsty = text_style_name + try: + if float(text_height) > 0: + style.dxf.dimtxt = float(text_height) + if float(arrow_size) > 0: + style.dxf.dimasz = float(arrow_size) + except (TypeError, ValueError): + pass + if set_current: + doc.header["$DIMSTYLE"] = style_name + self._mark_dirty() + return { + "ok": True, + "name": style_name, + "created": created, + "text_style": text_style_name, + "dimtxsty": str(style.dxf.get("dimtxsty", "")), + "dimtxt": style.dxf.get("dimtxt", None), + "dimasz": style.dxf.get("dimasz", None), + "set_current": bool(set_current), + "current": _current_dimstyle(doc), + "dimstyles": [entry.dxf.name for entry in doc.dimstyles], + } + + return await self._async(_sync) + + def _iter_text_entities(self, *, include_block_definitions: bool = True): + """``(where, entity)`` for every TEXT/MTEXT/ATTRIB/ATTDEF in the file. + + A DXF keeps text in three places that a single walk misses: the current + space, the ATTRIB entities owned by each block reference (they are not + part of the layout's entity list) and the ATTDEF/TEXT inside block + *definitions*. Handles are deduplicated, because a layout and its block + record expose the same entities. + """ + doc = self._require_doc() + seen: set[str] = set() + out: list[tuple[str, Any]] = [] + + def _add(where: str, ent) -> None: + key = str(ent.dxf.get("handle", "") or id(ent)) + if key in seen: + return + seen.add(key) + out.append((where, ent)) + + spaces: list[tuple[str, Any]] = [("Model", doc.modelspace())] + for name in doc.layouts.names_in_taborder(): + if name != "Model": + spaces.append((name, doc.layouts.get(name))) + for label, space in spaces: + try: + entities = list(space) + except Exception as exc: + log.debug("text walk: space %s unreadable (%s)", label, exc) + continue + for ent in entities: + kind = ent.dxftype() + if kind in ("TEXT", "MTEXT", "ATTDEF"): + _add(label, ent) + elif kind == "INSERT": + try: + for attrib in list(ent.attribs): + _add(label, attrib) + except Exception as exc: + log.debug("text walk: attributes of %s unreadable (%s)", label, exc) + if include_block_definitions: + for block in doc.blocks: + try: + entities = list(block) + except Exception as exc: + log.debug("text walk: block %s unreadable (%s)", block.name, exc) + continue + for ent in entities: + if ent.dxftype() in ("ATTDEF", "TEXT", "MTEXT"): + _add(f"block:{block.name}", ent) + return out + + async def text_apply_style( + self, + style=DEFAULT_TEXT_STYLE, + font=DEFAULT_TEXT_STYLE_FONT, + include_block_definitions=True, + set_default=True, + inline_font=False, + ) -> dict: + """Reassign one text style to every TEXT/MTEXT/ATTRIB/ATTDEF in the file. + + Tools that create text take a per-call style, but a drawing assembled + before the style existed — or by a tool that never asked — is lettered + half in ``Standard`` and half in the ГОСТ face. This is the sweep that + makes the whole file one type, block definitions included: an ATTDEF that + stays in ``Standard`` hands that style to every ATTRIB materialised from + it later. + + The style is created when the drawing does not have it yet, and (by + default) made the drawing's ``$TEXTSTYLE``, so text created afterwards + lands in it without being told. + + ``inline_font=True`` additionally writes the ISOCPEUR group into every + MTEXT that does not carry it yet — the repair half of ``inline_font`` on + ``entity_create_mtext``, for a drawing that was built before the font had + to be baked in. Off by default: this rewrites existing content, and a + sweep nobody asked for must not do that. + """ + from engineering.annotation import wrap_inline_font + + def _sync(): + doc = self._require_doc() + name = str(style or "").strip() + if not name: + raise RuntimeError("text_apply_style: style name must not be empty") + created = False + if name not in doc.styles: + doc.styles.add(name, font=font or DEFAULT_TEXT_STYLE_FONT) + created = True + counts = {"TEXT": 0, "MTEXT": 0, "ATTRIB": 0, "ATTDEF": 0} + changed = 0 + failed = 0 + inline_changed = 0 + for where, ent in self._iter_text_entities( + include_block_definitions=include_block_definitions + ): + kind = ent.dxftype() + counts[kind] = counts.get(kind, 0) + 1 + try: + if str(ent.dxf.get("style", "") or "") != name: + ent.dxf.style = name + changed += 1 + if inline_font and kind == "MTEXT": + current = str(ent.dxf.get("text", "") or "") + wrapped = wrap_inline_font(current, True) + if wrapped != current: + ent.text = wrapped + inline_changed += 1 + except Exception as exc: + failed += 1 + log.debug("text_apply_style: %s %s (%s)", where, kind, exc) + if set_default: + doc.header["$TEXTSTYLE"] = name + self._mark_dirty() + try: + applied_font = str(doc.styles.get(name).dxf.get("font", "") or "") + except Exception: + applied_font = "" + return { + "ok": True, + "style": name, + "font": applied_font, + "created": created, + "counts": counts, + "total": sum(counts.values()), + "changed": changed, + "failed": failed, + "inline_font": bool(inline_font), + "inline_changed": inline_changed, + "include_block_definitions": bool(include_block_definitions), + "default": doc.header.get("$TEXTSTYLE", ""), + } + + return await self._async(_sync) + + async def text_style_report(self, style=None) -> dict: + """Which text style each text entity carries, and who disagrees. + + The audit half of ``text_apply_style``: counts per style, the entities + that are not in the expected style (handle, type, current style, text) + and the font each style in the file points at. Reads the whole file — + spaces, block references and block definitions. + """ + + def _sync(): + doc = self._require_doc() + expected = str(style or "").strip() or self._default_text_style() + by_style: dict[str, int] = {} + by_kind: dict[str, int] = {} + offenders: list[dict] = [] + total = 0 + for where, ent in self._iter_text_entities(include_block_definitions=True): + kind = ent.dxftype() + current = str(ent.dxf.get("style", "Standard") or "Standard") + total += 1 + by_style[current] = by_style.get(current, 0) + 1 + by_kind[kind] = by_kind.get(kind, 0) + 1 + if current != expected: + offenders.append( + { + "handle": str(ent.dxf.get("handle", "")), + "type": kind, + "style": current, + "where": where, + "text": str(ent.dxf.get("text", ""))[:40], + } + ) + styles = [] + for entry in doc.styles: + try: + styles.append({"name": entry.dxf.name, "font": entry.dxf.get("font", "")}) + except Exception: + continue + correct = by_style.get(expected, 0) + return { + "ok": True, + "expected": expected, + "total": total, + "correct": correct, + "wrong": total - correct, + "by_style": by_style, + "by_type": by_kind, + "offenders": offenders[:80], + "styles": styles, + "default": doc.header.get("$TEXTSTYLE", ""), + } return await self._async(_sync) @@ -7101,20 +8453,55 @@ class EzdxfBackend(AutoCADBackend): handles, base_x=0.0, base_y=0.0, + layout=None, ) -> dict: def _sync(): doc = self._require_doc() + # N2 (01.10.2026) — a block definition is document-global in DXF: it + # carries no owner sheet, so a definition can never be said to live + # "in paper space" the way an entity can. ``layout`` is therefore not + # a destination for the definition but a constraint on its source: + # with it given, only handles that actually sit in that sheet are + # taken, and a handle from model space (or another sheet) is named in + # the refusal instead of being copied silently. Without it the + # drawing-wide lookup of the old behaviour stands. + space = None + owner_handle = "" + space_label = "" + if layout is not None: + space = self._space(layout) + space_label = self._layout_name(layout) or "Model" + try: + owner_handle = str(space.block_record.dxf.handle) + except Exception as exc: # a space with no block record is a bad document + raise RuntimeError( + f"block_create_from_entities: cannot read the block record of " + f"space {space_label!r} ({exc})" + ) from exc + # Resolve first. Creating the definition and then discovering every # handle was a typo left an empty block behind, and reporting # entity_count without naming what was dropped told the caller three # of their five entities made it and nothing about the other two. - entities, skipped = [], [] + entities, skipped, foreign = [], [], [] for handle in handles: try: - entities.append(self._get_entity(handle)) + entity = self._get_entity(handle) except Exception as exc: log.debug("resolving %s for block %s: %s", handle, name, exc) skipped.append(str(handle)) + continue + if owner_handle and str(entity.dxf.get("owner", "")) != owner_handle: + foreign.append(str(handle)) + continue + entities.append(entity) + if foreign: + raise RuntimeError( + f"block_create_from_entities: handle(s) {', '.join(foreign)} are not in " + f"space {space_label!r}; a definition is built from one sheet's entities " + f"at a time. Drop layout= to collect drawing-wide, or pass the handles " + f"of that sheet. No definition was created." + ) if not entities: raise RuntimeError( f"block_create_from_entities: none of the handles resolved " @@ -7132,6 +8519,7 @@ class EzdxfBackend(AutoCADBackend): "name": name, "entity_count": len(entities), "skipped": skipped, + "space": space_label or "(drawing-wide)", "backend": "ezdxf", } diff --git a/discovery/aliases.py b/discovery/aliases.py index 83dc247..4271c23 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -1246,6 +1246,9 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "standard layers", "architectural layers", "mechanical layers", + "spds layers", + "gost layers", + "слои спдс", "translate the layers", ), ), @@ -2577,6 +2580,39 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "ведомость кабелей", ), ), + + "text_apply_style": ToolAliases( + acad=("STYLE", "TEXTSTYLE", "-STYLE"), + synonyms=( + "text style", + "drawing font", + "менять шрифт", + "isocpeur", + "gost font", + "all text in one font", + "uniform lettering", + ), + ), + "text_style_report": ToolAliases( + acad=("STYLE",), + synonyms=( + "which font is used", + "text style report", + "check the font", + "audit lettering", + "is every label isocpeur", + ), + ), + "dimstyle_ensure": ToolAliases( + acad=("DIMSTYLE", "-DIMSTYLE"), + synonyms=( + "dimension style", + "spds dimstyle", + "make spds current", + "размерный стиль", + "dimension text style", + ), + ), } diff --git a/engineering/__init__.py b/engineering/__init__.py index 94aac30..d3d5476 100644 --- a/engineering/__init__.py +++ b/engineering/__init__.py @@ -45,6 +45,11 @@ from .preflight import ( preflight_drawing, ) from .refiner import RefineResult, RefineRound, RepairAction, refine_drawing +from .gost_titleblock import ( + GostTitleBlockMetadata, + apply_gost_form3_titleblock, +) + from .sheet.frames import draw_sheet_frame, frame_metrics, frame_prims from .sheet.titleblock import apply_titleblock, titleblock_prims from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock @@ -74,6 +79,8 @@ __all__ = [ "ensure_standard_linetypes", "apply_iso_a3_titleblock", "TitleBlockMetadata", + "apply_gost_form3_titleblock", + "GostTitleBlockMetadata", "DrawingValidator", "ValidationResult", "ValidationFinding", diff --git a/engineering/annotation.py b/engineering/annotation.py index 03510b2..c9abef4 100644 --- a/engineering/annotation.py +++ b/engineering/annotation.py @@ -4,6 +4,32 @@ from __future__ import annotations from dataclasses import dataclass +#: Инлайн-группа шрифта ГОСТ — ровно та, что стоит в каждом многострочном тексте +#: образца Павла (``{\fISOCPEUR|b0|i0|c204|p34;\Q0;Вентиляционный канал}``): +#: ``c204`` — кириллический набор, ``p34`` — плотность 3/4, ``\Q0`` — прямой +#: наклон. Шрифт назван внутри самой строки, поэтому надпись выглядит одинаково +#: на любом компьютере, даже если стиль чертежа подменят или SHX не найдётся. +INLINE_FONT_CODE = r"{\fISOCPEUR|b0|i0|c204|p34;\Q0;" +INLINE_FONT_FACE = "ISOCPEUR" + +#: Признаки того, что содержимое уже размечено управляющими кодами MTEXT. +_INLINE_MARKUP_CHARS = ("\\", "{", "}") + + +def wrap_inline_font(text: str, inline_font: bool = True) -> str: + """Завернуть содержимое многострочного текста в инлайн-группу ISOCPEUR. + + Уже размеченный текст (обратные слэши, группы в фигурных скобках) не + трогаем и пишем как есть: вложенная группа ломает разбор MTEXT, а образец + показывает, что готовые коды переносятся в файл без переписывания. + """ + if not inline_font: + return str(text) + raw = str(text) + if any(char in raw for char in _INLINE_MARKUP_CHARS): + return raw + return f"{INLINE_FONT_CODE}{raw}}}" + @dataclass(frozen=True) class TableLayout: diff --git a/engineering/gost_titleblock.py b/engineering/gost_titleblock.py new file mode 100644 index 0000000..7d674d7 --- /dev/null +++ b/engineering/gost_titleblock.py @@ -0,0 +1,517 @@ +"""ГОСТ Р 21.1101-2013 форма 3 — основная надпись и рамка листа. + +Геометрия взята с официального чертежа Приложения Ж (форма 3): штамп 185 x 55 мм +из строк по 5 мм, таблица изменений 65 мм (графы 14-19), правая часть 120 мм +(70 + 50), вложенные графы 6-8 «Стадия / Лист / Листов» шириной 15 / 15 / 20 мм. +Штамп ставится в правый нижний угол рамки; поля рамки 20/5/5/5 мм. + +Смысл граф — по 5.2.7 и Приложению Ж ГОСТ Р 21.1101-2013: + 1 — обозначение документа, 2 — наименование предприятия (объекта), + 3 — наименование здания (сооружения), 4 — наименование изображений листа, + 6 — стадия (П/Р), 7 — лист, 8 — листов, 9 — организация, + 10-13 — характер работы, фамилия, подпись и дата, + 14-19 — таблица изменений, 25 — масштаб, 26 — обозначение формата. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from backends.base import AutoCADBackend + +# ── форматы листов (ГОСТ 2.301), мм ────────────────────────────────────────── +SHEETS: dict[str, tuple[float, float]] = { + "A4": (297.0, 210.0), + "A3": (420.0, 297.0), + "A2": (594.0, 420.0), + "A1": (841.0, 594.0), + "A0": (1189.0, 841.0), +} + +#: Поля рамки по ГОСТ Р 21.1101-2013 (5.2.7, приложение И). +MARGIN_LEFT = 20.0 +MARGIN_RIGHT = 5.0 +MARGIN_TOP = 5.0 +MARGIN_BOTTOM = 5.0 + +# ── геометрия штампа, мм ───────────────────────────────────────────────────── +STAMP_W = 185.0 +STAMP_H = 55.0 +ROW_H = 5.0 # 11 строк по 5 мм +LEFT_BLOCK_W = 65.0 +RIGHT_BLOCK_W = 120.0 +#: графы 14-19 таблицы изменений: (номер, подпись, ширина) +#: Ширины подобраны по метрике ISOCPEUR так, чтобы подпись графы вставала в +#: свою клетку при высоте 1.8 мм (ГОСТ 2.304): «Кол.уч.» и «№ док.» — 6 знаков, +#: то есть 9.1 мм, и в прежние 10 мм они влезали только после уменьшения до +#: 1.36/1.59 мм, что ниже нормы читаемости. Сумма остаётся 65 мм. +CHANGE_COLUMNS: tuple[tuple[int, str, float], ...] = ( + (14, "Изм.", 10.0), + (15, "Кол.уч.", 11.0), + (16, "Лист", 8.0), + (17, "№ док.", 11.0), + (18, "Подп.", 10.0), + (19, "Дата", 15.0), +) +#: графы 6-8: (номер, подпись, ширина) +SHEET_COLUMNS: tuple[tuple[int, str, float], ...] = ( + (6, "Стадия", 15.0), + (7, "Лист", 15.0), + (8, "Листов", 20.0), +) + +#: Наименьшая высота надписи на листе (ГОСТ 2.304 / ISO 3098). Ниже неё +#: подпись не читается на печати — этим полом ограничено автоуменьшение +#: значений, а `drawing_export_pdf` отдельно сообщает о том, что всё-таки +#: оказалось мельче (поле warnings). +MIN_TEXT_H = 1.8 + +LABEL_H = 1.8 # подписи граф +VALUE_H = 3.0 # значения +TITLE_H = 5.0 # обозначение документа +GRAPH_NO_H = 1.8 # номера граф в скобках — не мельче нормы читаемости +PAD = 1.0 +#: Отступ подписи графы от её линии. Меньше общего PAD: подписи «Кол.уч.» и +#: «№ док.» занимают по 9.1 мм, и при 1.0 мм с каждой стороны они не влезали в +#: 11-миллиметровую графу с первого раза. +TAG_PAD = 0.6 +#: Зазор между номером графы в скобках и значением в той же клетке. Номер и +#: значение раньше стояли в одной точке, и «(25)Масштаб 1:50» печаталось +#: наложением. +TAG_GAP = 0.6 + +#: Графы 10-14: подписи по ГОСТ Р 21.1101-2013 (приложение Ж) — «Разраб.», +#: «Пров.», «Т.контр.», «Н.контр.», «Утв.». Полные слова («Разработал.») при +#: 1.8 мм занимают 16.6 мм и выходят за 17-миллиметровую графу характера работы. +SIGN_ROWS: tuple[tuple[str, int], ...] = ( + ("Разраб.", 10), + ("Пров.", 11), + ("Т.контр.", 12), + ("Н.контр.", 13), + ("Утв.", 14), +) +#: Ширины колонок блока подписей (характер работы / фамилия / подпись / дата), +#: сумма 65 мм — ширина левой части штампа. Прежние 20/22/13/10 не вмещали +#: «01.10.2026» в 10 мм (дата ужималась до 1.2 мм), а 17 мм под фамилию +#: оставляют «Николашкин» читаемым (2.0 мм). +SIGN_LABEL_W = 17.0 +SIGN_NAME_W = 18.0 +SIGN_SIGN_W = 12.0 +SIGN_DATE_W = 18.0 + +#: Ширина знака как доля высоты текста. Измерено по рендеру: «ЭО-01» высотой +#: 5 мм занимает по листу 21 мм, то есть ~0.84 высоты на знак у ISOCPEUR / +#: DejaVu Sans. Нужна, чтобы значения граф не выезжали за свои колонки — +#: `entity_create_text` выравнивает строку по левому краю от точки вставки. +CHAR_W_RATIO = 0.84 +#: Полезная ширина графы 1, 2, 3, 4, 9 (70 мм минус отступы). +BLOCK_INNER_W = 68.0 + +LAYER_FRAME = "TITLEBLOCK" +LAYER_TEXT = "TEXT" + +DEFAULT_TEXT_STYLE = "GOST_A" +DEFAULT_FONT = "isocpeur.ttf" + + +@dataclass +class GostTitleBlockMetadata: + """Данные основной надписи по ГОСТ Р 21.1101-2013 (форма 3).""" + + drawing_no: str = "" # графа 1 — обозначение документа + enterprise: str = "" # графа 2 — предприятие / объект строительства + building: str = "" # графа 3 — здание (сооружение) + title: str = "" # графа 4 — наименование изображений листа + stage: str = "Р" # графа 6 — стадия + sheet: str = "1" # графа 7 — лист + total_sheets: str = "1" # графа 8 — листов + organization: str = "" # графа 9 — организация + scale: str = "1:100" # графа 25 — масштаб + format: str = "A3" # графа 26 — формат листа + developed_by: str = "" # графа 11 + developed_date: str = "" # графа 13 + checked_by: str = "" # Проверил + checked_date: str = "" + norm_control: str = "" # Н. контр. + approved_by: str = "" # Утв. + change_rows: list[list[str]] = field(default_factory=list) + + +def _fmt(value: str) -> str: + return "" if value is None else str(value) + + +def _fit_height(value: str, height: float, max_width: float = BLOCK_INNER_W) -> float: + """Уменьшить высоту строки, если она не влезает в графу по ширине. + + Графы 2, 3, 4 и 9 имеют фиксированную ширину 70 мм, а наименования бывают + длинными («План расположения электрооборудования» — 37 знаков). Переносить + строку внутри пятимиллиметровой строки штампа некуда, поэтому длинное + значение рисуется мельче — так, чтобы остаться в своей графе, а не + наезжать на соседние. + + Ниже ``MIN_TEXT_H`` (1.8 мм) не опускаемся: подпись мельче нормы читаемости + не читается на печати, и лучше чуть выйти за клетку, чем отдать лист с + нечитаемой основной надписью. + """ + text = _fmt(value) + if not text or not height: + return height + width = len(text) * height * CHAR_W_RATIO + if width <= max_width: + return height + return max(MIN_TEXT_H, round(max_width / (len(text) * CHAR_W_RATIO), 2)) + + +def _tag_width(number: int, height: float = GRAPH_NO_H) -> float: + """Ширина номера графы в скобках — '(14)', '(25)'.""" + return len(f"({number})") * height * CHAR_W_RATIO + + +def _value_after_tag(base_x: float, number: int, height: float = GRAPH_NO_H) -> float: + """X значения в клетке, где слева уже стоит номер графы. + + Раньше номер и значение рисовались из одной точки, и «(25)Масштаб 1:50» + печаталось наложением друг на друга (претензия Павла «надписи накладываются + друг на друга», разобрано 01.10.2026). + """ + return base_x + _tag_width(number, height) + TAG_GAP + + +async def ensure_layers(backend, names) -> dict: + """Создать слои, если их нет (не мешая уже существующим).""" + result: dict[str, bool] = {} + try: + existing = {str(entry.name) for entry in await backend.layer_list()} + except Exception: + return result + for name in names: + if name in existing: + result[name] = False + continue + try: + await backend.layer_create(name) + result[name] = True + except Exception: + result[name] = False + return result + + +async def set_layout_paper(backend, layout: str, sheet_w: float, sheet_h: float) -> dict: + """Привести размер листа (страницы) к формату, под который нарисована рамка. + + `drawing_export_pdf(page_size="layout")` берёт страницу PDF из + `Layout.dxf.paper_width/paper_height`. Документ, созданный `drawing_new`, + начинается с A3 независимо от формата, переданного в `sheet_format`, поэтому + рамка A4 уезжала в угол страницы A3. Бэкенд без `layout_set_paper` (COM) не + считается ошибкой: там страница задаётся настройкой печати AutoCAD. + """ + setter = getattr(backend, "layout_set_paper", None) + if setter is None: + return {"ok": False, "reason": "backend has no layout_set_paper"} + try: + return await setter(layout, sheet_w, sheet_h) + except Exception as exc: # размер страницы не должен валить построение рамки + return {"ok": False, "error": str(exc)} + + +async def ensure_text_style(backend, style: str, font: str, set_default: bool = True) -> dict: + """Best-effort: настроить текстовый стиль с поддержкой кириллицы.""" + ensure = getattr(backend, "textstyle_ensure", None) + if ensure is None: + return {"ok": False, "reason": "backend has no textstyle support"} + try: + return await ensure(style, font=font, set_default=set_default) + except Exception as exc: # стиль не должен валить построение штампа + return {"ok": False, "error": str(exc)} + + +async def apply_gost_form3_titleblock( + backend: "AutoCADBackend", + *, + metadata: GostTitleBlockMetadata, + sheet: str = "A3", + origin: tuple[float, float] = (0.0, 0.0), + layout: str | None = None, + text_style: str = DEFAULT_TEXT_STYLE, + text_font: str = DEFAULT_FONT, +) -> dict: + """Нарисовать рамку листа и основную надпись формы 3 в её правом нижнем углу. + + `layout` — имя листа (paper space); без него рисуется в текущем пространстве. + `sheet` — формат листа (A3, A4, ...), он же уходит в графу 26. + """ + sheet_key = str(sheet or "A3").strip().upper() + if sheet_key not in SHEETS: + raise RuntimeError( + f"apply_gost_form3_titleblock: unknown sheet {sheet!r} " + f"(known: {', '.join(sorted(SHEETS))})" + ) + sheet_w, sheet_h = SHEETS[sheet_key] + ox, oy = float(origin[0]), float(origin[1]) + + previous_space = "Model" + if layout is not None: + listing = await backend.layout_list() + names = {str(entry) for entry in (listing.get("layouts") or [])} + if layout not in names: + raise RuntimeError( + f"apply_gost_form3_titleblock: layout {layout!r} does not exist " + f"(have: {', '.join(sorted(names))}). Create it with layout_create first." + ) + previous_space = getattr(backend, "_current_space", "Model") + switched = await backend.layout_set_current(layout) + if not switched.get("ok", True): + raise RuntimeError(f"apply_gost_form3_titleblock: {switched.get('error')}") + + try: + style_status = await ensure_text_style(backend, text_style, text_font, True) + layers_status = await ensure_layers(backend, (LAYER_FRAME, LAYER_TEXT)) + paper_status = ( + await set_layout_paper(backend, layout, sheet_w, sheet_h) + if layout is not None + else {"ok": False, "reason": "no layout given"} + ) + drawn = await _draw( + backend, + metadata=metadata, + ox=ox, + oy=oy, + sheet_key=sheet_key, + sheet_w=sheet_w, + sheet_h=sheet_h, + layout=layout, + style_status=style_status, + layers_status=layers_status, + ) + if isinstance(drawn, dict): + drawn.setdefault("paper_status", paper_status) + return drawn + finally: + if layout is not None: + await backend.layout_set_current(previous_space) + + +async def _draw( + backend, + *, + metadata: GostTitleBlockMetadata, + ox: float, + oy: float, + sheet_key: str, + sheet_w: float, + sheet_h: float, + layout: str | None, + style_status: dict, + layers_status: dict, +) -> dict: + + async def line(x1: float, y1: float, x2: float, y2: float) -> str: + ent = await backend.entity_create_line(x1, y1, x2, y2, layer=LAYER_FRAME) + return ent.handle + + async def text( + value: str, + x: float, + y: float, + height: float = LABEL_H, + *, + rotation: float = 0.0, + ) -> str | None: + value = _fmt(value) + if not value: + return None + ent = await backend.entity_create_text( + text=value, x=x, y=y, height=height, rotation=rotation, layer=LAYER_TEXT + ) + return ent.handle + + handles: list[str] = [] + + # ── рамка листа: поля 20/5/5/5 ─────────────────────────────────────────── + fx0 = ox + MARGIN_LEFT + fy0 = oy + MARGIN_BOTTOM + fx1 = ox + sheet_w - MARGIN_RIGHT + fy1 = oy + sheet_h - MARGIN_TOP + frame = await backend.entity_create_polyline( + points=[[fx0, fy0], [fx1, fy0], [fx1, fy1], [fx0, fy1]], + closed=True, + layer=LAYER_FRAME, + ) + handles.append(frame.handle) + + # ── штамп: правый нижний угол рамки ────────────────────────────────────── + sx0 = fx1 - STAMP_W + sy0 = fy0 + sx1 = fx1 + sy1 = fy0 + STAMP_H + + stamp_frame = await backend.entity_create_polyline( + points=[[sx0, sy0], [sx1, sy0], [sx1, sy1], [sx0, sy1]], + closed=True, + layer=LAYER_FRAME, + ) + handles.append(stamp_frame.handle) + + left_x = sx0 + LEFT_BLOCK_W # 65 мм + split_70 = sx0 + LEFT_BLOCK_W + 70.0 # 135 мм + split_sheet = split_70 + SHEET_COLUMNS[0][2] # 150 мм + split_sheet2 = split_sheet + SHEET_COLUMNS[1][2] # 165 мм + + # горизонтальные линии: строки по 5 мм + row_lines: list[float] = [] + for i in range(1, int(STAMP_H / ROW_H)): + y = round(sy0 + i * ROW_H, 4) + row_lines.append(y) + handles.append(await line(sx0, y, sx1, y)) + # правая часть штампа: нижняя полоса граф 25/26 отделена, а строки + # граф 1-4 идут от sy0+5 вверх + handles.append(await line(left_x, sy0, left_x, sy1)) + handles.append(await line(split_70, sy0, split_70, sy1)) + handles.append(await line(split_70, sy1 - 25.0, sx1, sy1 - 25.0)) # верх блока 6-8 + handles.append(await line(split_sheet, sy1 - 25.0, split_sheet, sy1 - 10.0)) + handles.append(await line(split_sheet2, sy1 - 25.0, split_sheet2, sy1 - 10.0)) + handles.append(await line(split_sheet, sy1 - 30.0, sx1, sy1 - 30.0)) # подписи «Стадия/Лист/Листов» + + # ── таблица изменений (графы 14-19) ───────────────────────────────────── + x = sx0 + for number, label, width in CHANGE_COLUMNS: + await text(f"({number})", x + TAG_PAD, sy1 - ROW_H + 0.6, GRAPH_NO_H) + # «Кол.уч.» и «№ док.» при 1.8 мм занимают по 9.1 мм: подписи + # подгоняются по полезной ширине графы, а сами графы расширены до 11 мм, + # чтобы подпись не уменьшалась ниже ГОСТовских 1.8 мм. + await text(label, x + TAG_PAD, sy1 - 2 * ROW_H + 0.6, + _fit_height(label, LABEL_H, width - 2 * TAG_PAD)) + x += width + if x < left_x - 0.01: + handles.append(await line(x, sy1 - 2 * ROW_H, x, sy0)) + + # пустые строки таблицы изменений: 4 строки (sy1-20 .. sy1-45) + for row_index, row in enumerate(list(metadata.change_rows)[:4]): + y = sy1 - (2 + row_index + 1) * ROW_H + 0.6 + x = sx0 + for cell, (_, _, width) in zip(list(row) + [""] * 6, CHANGE_COLUMNS): + if _fmt(cell): + await text(_fit_height(_fmt(cell), LABEL_H, width - 2 * TAG_PAD), + x + TAG_PAD, y) + x += width + + # ── подписи (графы 10-14) ─────────────────────────────────────────────── + sign_values = ( + (metadata.developed_by, metadata.developed_date), + (metadata.checked_by, metadata.checked_date), + ("", ""), + (metadata.norm_control, ""), + (metadata.approved_by, ""), + ) + sign_rows = [ + (label, number, name, date) + for (label, number), (name, date) in zip(SIGN_ROWS, sign_values) + ] + # Пропорции блока подписей: характер работы 17 мм, фамилия 18 мм, подпись + # 12 мм, дата 18 мм (сумма 65). Прежние 20/22/13/10 не вмещали ни «01.10.2026» + # (дата ужималась до 1.2 мм — вдвое мельче нормы), ни полные подписи. + col_name_x = sx0 + SIGN_LABEL_W # Ф.И.О. + col_sign_x = col_name_x + SIGN_NAME_W # подпись + col_date_x = col_sign_x + SIGN_SIGN_W # дата + # Строки 7-11 (индекс 0 = верхняя): таблица изменений занимает строки 1-6 + # (две строки подписей граф 14-19 и четыре пустых), подписи 10-14 — строки + # 7-11. Смещение считалось от строки 8, поэтому пятая подпись («Утв.») + # рисовалась на 5 мм НИЖЕ низа штампа — за пределами рамки листа. + for index, (label, number, name, date) in enumerate(sign_rows): + top = sy1 - (6 + index) * ROW_H + y = top - ROW_H + 0.7 + # Номер графы — в верхнем левом углу СВОЕЙ клетки. Он стоял на 1.4 мм + # выше верхней линии, то есть в соседней строке: «(11)» печаталось в + # клетке «Разраб.», «(14)» — в строке «Масштаб». + await text(f"({number})", sx0 + TAG_PAD, top - GRAPH_NO_H - 0.3, GRAPH_NO_H) + await text(label, sx0 + 2.6, y, _fit_height(label, LABEL_H, SIGN_LABEL_W - 3.6)) + await text(name, col_name_x + TAG_PAD, y, + _fit_height(name, VALUE_H, SIGN_NAME_W - 2 * TAG_PAD)) + # Дата — графа 13: «01.10.2026» в 1.8 мм занимает 15.1 мм, поэтому + # колонка расширена до 18 мм, а высота подгоняется по её полезной ширине. + await text(date, col_date_x + TAG_PAD, y, + _fit_height(date, LABEL_H, SIGN_DATE_W - 2 * TAG_PAD)) + for gx in (col_name_x, col_sign_x, col_date_x): + handles.append(await line(gx, sy0, gx, sy1 - 6 * ROW_H)) + + # ── графа 1: обозначение документа ────────────────────────────────────── + # Значение выравнивается по правому краю графы 1: `entity_create_text` + # ставит текст по левому краю от точки вставки, поэтому обозначение, + # записанное «от правой границы», уезжало в графы 6-8. Ширина считается по + # фактической метрике знака (CHAR_W_RATIO), а не «на глаз». + await text("(1)", sx0 + LEFT_BLOCK_W + PAD, sy1 - 2 * ROW_H, GRAPH_NO_H) + await text( + metadata.drawing_no, + split_70 - 1.5 - len(_fmt(metadata.drawing_no)) * TITLE_H * CHAR_W_RATIO, + sy1 - ROW_H - 3.4, + TITLE_H, + ) + # Значения граф 2-4 рисуются ПОСЛЕ номера графы, а не из той же точки: + # раньше «(2)» и «ИП Николашкин П.В.» печатались полностью наложенными + # друг на друга (проверено разбором текстового слоя PDF 01.10.2026). + tag_x = sx0 + LEFT_BLOCK_W + PAD + await text("(2)", tag_x, sy1 - 4 * ROW_H + 0.6, GRAPH_NO_H) + await text(metadata.enterprise, _value_after_tag(tag_x, 2), sy1 - 4 * ROW_H + 1.0, + _fit_height(metadata.enterprise, VALUE_H, + BLOCK_INNER_W - _tag_width(2) - TAG_GAP - PAD)) + await text("(3)", tag_x, sy1 - 6 * ROW_H + 0.6, GRAPH_NO_H) + await text(metadata.building, _value_after_tag(tag_x, 3), sy1 - 6 * ROW_H + 1.0, + _fit_height(metadata.building, VALUE_H, + BLOCK_INNER_W - _tag_width(3) - TAG_GAP - PAD)) + # графа 4 — наименование изображений листа + await text("(4)", tag_x, sy0 + 2 * ROW_H + 0.6, GRAPH_NO_H) + await text(metadata.title, _value_after_tag(tag_x, 4), sy0 + 2 * ROW_H, + _fit_height(metadata.title, VALUE_H, + BLOCK_INNER_W - _tag_width(4) - TAG_GAP - PAD)) + # ── графа 9 — организация ─────────────────────────────────────────────── + await text("(9)", split_70 + PAD, sy0 + 2 * ROW_H + 0.6, GRAPH_NO_H) + # Ниже y = 30 правая часть штампа (365..415) не делится — это графа 9 в + # 50 мм; длинное название организации подгоняем по 47 мм полезной ширины. + await text(metadata.organization, split_70 + PAD, sy0 + ROW_H + 0.4, + _fit_height(metadata.organization, VALUE_H, 47.0)) + + # ── графы 6-8: Стадия / Лист / Листов ─────────────────────────────────── + header_y = sy1 - 25.0 - ROW_H + 1.4 + x = split_70 + for number, label, width in SHEET_COLUMNS: + await text(label, x + PAD, header_y, LABEL_H) + await text(f"({number})", x + PAD, sy1 - 25.0 - ROW_H - 1.6, GRAPH_NO_H) + x += width + await text(metadata.stage, split_70 + PAD, sy1 - 30.0 - ROW_H - 0.6, VALUE_H) + await text(metadata.sheet, split_sheet + PAD, sy1 - 30.0 - ROW_H - 0.6, VALUE_H) + await text(metadata.total_sheets, split_sheet2 + PAD, sy1 - 30.0 - ROW_H - 0.6, VALUE_H) + + # ── графы 25 (масштаб) и 26 (формат) ──────────────────────────────────── + await text("(25)", tag_x, sy0 + 1.4, GRAPH_NO_H) + await text(f"Масштаб {metadata.scale}", _value_after_tag(tag_x, 25), sy0 + 0.7, LABEL_H) + await text("(26)", split_70 + PAD, sy0 + 1.4, GRAPH_NO_H) + await text(f"Формат {sheet_key}", _value_after_tag(split_70 + PAD, 26), sy0 + 0.7, LABEL_H) + + return { + "ok": True, + "standard": "ГОСТ Р 21.1101-2013, форма 3", + "sheet": sheet_key, + "layout": layout or "Model", + "frame": frame.handle, + "stamp_frame": stamp_frame.handle, + "lines": handles, + "text_style": style_status, + "layers": layers_status, + "frame_margins": { + "left": MARGIN_LEFT, + "right": MARGIN_RIGHT, + "top": MARGIN_TOP, + "bottom": MARGIN_BOTTOM, + }, + "stamp_bbox": { + "min": [sx0, sy0], + "max": [sx1, sy1], + "width": STAMP_W, + "height": STAMP_H, + }, + "frame_bbox": {"min": [fx0, fy0], "max": [fx1, fy1]}, + "metadata": asdict(metadata), + } diff --git a/server.py b/server.py index d1bc491..d4cdce9 100644 --- a/server.py +++ b/server.py @@ -1414,6 +1414,122 @@ async def drawing_export_dxf( return await _backend(ctx).drawing_export_dxf(str(validated)) +# ── N4 (01.10.2026): inline file exchange ──────────────────────────────────── +# The container's /opt/data/dxf is a separate bind mount from the agent's +# /opt/data/dxf, so a path-based open/save cannot see the other side's files and +# every exchange needed a throwaway container. These four tools move the bytes +# themselves, inside ALLOWED_PATHS only. + + +def _allowed_root() -> str: + roots = list(config.settings.allowed_paths or []) + if not roots: + raise ToolError( + "This server has no ALLOWED_PATHS configured, so file transfer is refused " + "(set the ALLOWED_PATHS environment variable to enable it)." + ) + return str(roots[0]) + + +def _decode_payload(content_base64: str) -> bytes: + import base64 as _b64 + + raw = str(content_base64 or "").strip() + if not raw: + raise ToolError("content_base64 is empty.") + if raw.startswith("data:"): + raw = raw.split(",", 1)[-1] + try: + return _b64.b64decode(raw, validate=False) + except Exception as exc: + raise ToolError(f"content_base64 is not valid base64: {exc}") from exc + + +@cad_tool(summary="Upload a file into the server's allowed folder (base64).", cost="mutate") +@mcp.tool( + annotations={"title": "File Put (base64)", "readOnlyHint": False}, + tags={"system", "transfer"}, +) +async def file_put( + filename: Annotated[str, "File name to write into the allowed folder"], + content_base64: Annotated[str, "File content, base64-encoded"], + ctx: Context = None, +) -> dict: + """Write a file into the server's first allowed directory. + + Only the file name is used — directory components are stripped — and the + path is validated against ALLOWED_PATHS, so nothing can be written outside + the shared folder. + """ + import hashlib + from pathlib import Path as _P + + name = _P(str(filename or "")).name + if not name: + raise ToolError("file_put: filename is required.") + data = _decode_payload(content_base64) + target = validate_path(str(_P(_allowed_root()) / name), allow_write=True) + target.write_bytes(data) + await ctx.info(f"Wrote {len(data)} bytes to {target}") + return { + "ok": True, + "path": str(target), + "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + } + + +@cad_tool(summary="Download a file from the server's allowed folder (base64).", cost="read") +@mcp.tool( + annotations={"title": "File Get (base64)", "readOnlyHint": True}, + tags={"system", "transfer"}, +) +async def file_get( + path: Annotated[str, "Path of the file to read (must be inside ALLOWED_PATHS)"], + ctx: Context = None, +) -> dict: + """Read a file from the server and return it base64-encoded.""" + import base64 as _b64 + import hashlib + + resolved = validate_path(path, allow_write=False) + if not resolved.exists(): + raise ToolError(f"file_get: no such file: {resolved}") + data = resolved.read_bytes() + return { + "ok": True, + "path": str(resolved), + "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + "content_base64": _b64.b64encode(data).decode("ascii"), + } + + +@cad_tool(summary="Open a drawing from base64 bytes, no shared folder needed.", cost="mutate") +@mcp.tool( + annotations={"title": "Open Drawing (base64)", "readOnlyHint": False}, + tags={"drawing"}, +) +async def drawing_open_base64( + content_base64: Annotated[str, "DXF content, base64-encoded"], + name: Annotated[str, "Display name for the opened drawing"] = "from_base64.dxf", + ctx: Context = None, +) -> dict: + """Open a drawing handed over inline as base64 (DXF text).""" + await ctx.info(f"Opening drawing from base64 as '{name}'") + return await _backend(ctx).drawing_open_base64(content_base64, name) + + +@cad_tool(summary="Return the open drawing as base64 bytes.", cost="read") +@mcp.tool( + annotations={"title": "Save Drawing (base64)", "readOnlyHint": True}, + tags={"drawing", "export"}, +) +async def drawing_save_base64(ctx: Context = None) -> dict: + """Serialize the current drawing to DXF and return it base64-encoded.""" + return await _backend(ctx).drawing_save_base64() + + @cad_tool(summary="Plot the drawing, or one paper-space layout, to PDF.", cost="mutate") @mcp.tool( annotations={"title": "Export PDF"}, @@ -1426,13 +1542,40 @@ async def drawing_export_pdf( "Paper-space layout to plot (default: model space). COM plots the layout " "natively incl. viewport content; ezdxf renders the layout's own entities.", ] = None, + text_layer: Annotated[ + bool, + "Keep a real, selectable/searchable text layer (needs matplotlib). Text is " + "drawn with DejaVu Sans, so Cyrillic survives text extraction. Disable to " + "get the old behaviour (glyph outlines, no extractable text).", + ] = True, + page_size: Annotated[ + str, + "'layout' (default) plots the sheet: the PDF page is the layout's own " + "paper size (paper_width x paper_height, e.g. 420 x 297 mm for A3) and the " + "content is placed 1:1, exactly as AutoCAD plots a layout — print-ready. " + "'extents' shrinks the page to the bounding box of the drawn content " + "(the old behaviour, handy for draft screenshots, useless as a sheet). " + "Model space has no sheet, so 'layout' falls back to 'extents' there.", + ] = "layout", ctx: Context = None, ) -> dict: - """Export the current drawing (or a paper-space layout) to PDF.""" + """Export the current drawing (or a paper-space layout) to PDF. + + With ``text_layer=True`` (default on the ezdxf backend) the PDF carries real + text objects — `pdftotext`/pymupdf extraction returns the labels, Cyrillic + included — instead of glyph curves that extract as an empty string. + + With ``page_size="layout"`` (default) the page is the layout's paper: an A3 + sheet comes out as a 420 x 297 mm page with the frame at its margins and the + stamp in the bottom-right corner, at 1:1. ``page_size="extents"`` keeps the + old content-fitted page. + """ validated = validate_path(path, allow_write=True) - await ctx.info(f"Exporting PDF: {validated}") + await ctx.info(f"Exporting PDF: {validated} (page_size={page_size})") await ctx.report_progress(0, 100) - result = await _backend(ctx).drawing_export_pdf(str(validated), layout=layout) + result = await _backend(ctx).drawing_export_pdf( + str(validated), layout=layout, text_layer=text_layer, page_size=page_size + ) await ctx.report_progress(100, 100) return result @@ -1544,11 +1687,20 @@ async def entity_create_line( layer: Annotated[str | None, "Layer name (default: current layer)"] = None, color: Annotated[int | None, "ACI color code 1-255, 256=ByLayer, 0=ByBlock"] = None, linetype: Annotated[str | None, "Linetype name (e.g. 'DASHED', 'CENTER')"] = None, + layout: Annotated[ + str | None, + "Paper-space layout to draw into (create it with layout_create). Omit " + "to draw where the call always did — model space, or the current " + "layout. Geometry on a sheet is sized in paper millimetres, so a " + "210x297 frame is 210x297 mm however the viewport is scaled.", + ] = None, ctx: Context = None, ) -> dict: """Create a line from (x1,y1) to (x2,y2). Returns entity info with handle.""" await ctx.debug(f"Creating line ({x1},{y1}) → ({x2},{y2})") - result = await _backend(ctx).entity_create_line(x1, y1, x2, y2, z1, z2, layer, color, linetype) + result = await _backend(ctx).entity_create_line( + x1, y1, x2, y2, z1, z2, layer, color, linetype, layout + ) return _dc(result) @@ -1563,11 +1715,16 @@ async def entity_create_circle( radius: Annotated[float, Field(description="Circle radius", gt=0)], layer: Annotated[str | None, "Layer name"] = None, color: Annotated[int | None, "ACI color code"] = None, + layout: Annotated[ + str | None, + "Paper-space layout to draw into (see entity_create_line). Omit for " + "model space / the current layout.", + ] = None, ctx: Context = None, ) -> dict: """Create a circle at (cx, cy) with given radius.""" await ctx.debug(f"Creating circle center=({cx},{cy}) r={radius}") - result = await _backend(ctx).entity_create_circle(cx, cy, radius, layer, color) + result = await _backend(ctx).entity_create_circle(cx, cy, radius, layer, color, layout) return _dc(result) @@ -1584,12 +1741,17 @@ async def entity_create_arc( end_angle: Annotated[float, "End angle in degrees"], layer: Annotated[str | None, "Layer name"] = None, color: Annotated[int | None, "ACI color code"] = None, + layout: Annotated[ + str | None, + "Paper-space layout to draw into (see entity_create_line). Omit for " + "model space / the current layout.", + ] = None, ctx: Context = None, ) -> dict: """Create a circular arc. Angles are in degrees, measured counter-clockwise from the positive X axis.""" await ctx.debug(f"Creating arc center=({cx},{cy}) r={radius} {start_angle}°→{end_angle}°") result = await _backend(ctx).entity_create_arc( - cx, cy, radius, start_angle, end_angle, layer, color + cx, cy, radius, start_angle, end_angle, layer, color, layout ) return _dc(result) @@ -1607,6 +1769,11 @@ async def entity_create_polyline( closed: Annotated[bool, "Whether to close the polyline"] = False, layer: Annotated[str | None, "Layer name"] = None, color: Annotated[int | None, "ACI color code"] = None, + layout: Annotated[ + str | None, + "Paper-space layout to draw into (see entity_create_line). Omit for " + "model space / the current layout.", + ] = None, ctx: Context = None, ) -> dict: """Create a lightweight 2D polyline through the given points. @@ -1614,7 +1781,7 @@ async def entity_create_polyline( Example: points=[[0,0],[100,0],[100,100],[0,100]], closed=true → rectangle """ await ctx.debug(f"Creating polyline with {len(points)} points, closed={closed}") - result = await _backend(ctx).entity_create_polyline(points, closed, layer, color) + result = await _backend(ctx).entity_create_polyline(points, closed, layer, color, layout) return _dc(result) @@ -1630,6 +1797,11 @@ async def entity_create_rectangle( y2: Annotated[float, "Opposite corner Y"], layer: Annotated[str | None, "Layer name"] = None, color: Annotated[int | None, "ACI color code"] = None, + layout: Annotated[ + str | None, + "Paper-space layout to draw into (see entity_create_line). Omit for " + "model space / the current layout.", + ] = None, ctx: Context = None, ) -> dict: """Create a closed rectangular polyline between two corner points. @@ -1638,7 +1810,9 @@ async def entity_create_rectangle( """ await ctx.debug(f"Creating rectangle ({x1},{y1}) - ({x2},{y2})") pts = [[x1, y1], [x2, y1], [x2, y2], [x1, y2]] - result = await _backend(ctx).entity_create_polyline(pts, closed=True, layer=layer, color=color) + result = await _backend(ctx).entity_create_polyline( + pts, closed=True, layer=layer, color=color, layout=layout + ) return _dc(result) @@ -1655,11 +1829,41 @@ async def entity_create_text( rotation: Annotated[float, "Rotation angle in degrees"] = 0.0, layer: Annotated[str | None, "Layer name"] = None, color: Annotated[int | None, "ACI color code"] = None, + style: Annotated[ + str | None, + "Text style name; defaults to the drawing's $TEXTSTYLE — the ГОСТ " + "ISOCPEUR style, GOST_A.", + ] = None, + layout: Annotated[ + str | None, + "Paper-space layout to write into (create it with layout_create). Omit " + "to write where the call always did — model space, or the current layout. " + "Annotation on a sheet lives in paper millimetres and stops depending on " + "the viewport scale.", + ] = None, + inline_font: Annotated[ + bool, + "Letter the entity in ISOCPEUR whatever happens to the style table. A " + "single-line TEXT cannot carry MTEXT control codes — they would plot " + "literally, braces and all — so the guarantee is kept through the ГОСТ " + "style that owns isocpeur.ttf, exactly as the reference file does it.", + ] = True, ctx: Context = None, ) -> dict: """Create a single-line text entity (DTEXT/TEXT).""" await ctx.debug(f"Creating text: '{text[:30]}' at ({x},{y})") - result = await _backend(ctx).entity_create_text(text, x, y, height, rotation, layer, color) + result = await _backend(ctx).entity_create_text( + text, + x, + y, + height=height, + rotation=rotation, + layer=layer, + color=color, + style=style, + layout=layout, + inline_font=inline_font, + ) return _dc(result) @@ -1679,12 +1883,38 @@ async def entity_create_mtext( rotation: Annotated[float, "Rotation in degrees, CCW from +X"] = 0.0, layer: Annotated[str | None, "Layer name"] = None, color: Annotated[int | None, "ACI color code"] = None, + style: Annotated[ + str | None, "Text style name; defaults to the drawing's $TEXTSTYLE (GOST_A, ISOCPEUR)" + ] = None, + layout: Annotated[ + str | None, + "Paper-space layout to write into (create it with layout_create). Omit " + "to write where the call always did. A note placed on the sheet is sized " + "in paper millimetres, so its height no longer follows the viewport scale.", + ] = None, + inline_font: Annotated[ + bool, + "Wrap the content in the inline ISOCPEUR group, exactly as the reference " + "file does, so the lettering survives a substituted style or a missing " + "SHX. Text that already carries formatting codes (backslashes, groups in " + "braces) is written as-is and never re-wrapped.", + ] = True, ctx: Context = None, ) -> dict: """Create a multi-line text entity (MTEXT) with word-wrap at the specified width.""" await ctx.debug(f"Creating mtext at ({x},{y}) w={width}") result = await _backend(ctx).entity_create_mtext( - text, x, y, width, height, rotation, layer, color + text, + x, + y, + width=width, + height=height, + rotation=rotation, + layer=layer, + color=color, + style=style, + layout=layout, + inline_font=inline_font, ) return _dc(result) @@ -1707,11 +1937,40 @@ async def entity_create_table( text_height: Annotated[float, Field(default=2.5, gt=0)] = 2.5, title: Annotated[str | None, "Optional title row"] = None, layer: Annotated[str, "Target layer"] = "TEXT", + style: Annotated[ + str | None, + "Text style for the cell text; defaults to the drawing's $TEXTSTYLE " + "(GOST_A, ISOCPEUR) — a specification table lettered in the wrong face " + "stops matching the rest of the sheet.", + ] = None, + layout: Annotated[ + str | None, + "Paper-space layout to write into (create it with layout_create). Omit " + "to write where the call always did. A schedule on the sheet is sized in " + "paper millimetres instead of model units.", + ] = None, + inline_font: Annotated[ + bool, + "Wrap every cell in the inline ISOCPEUR group, the same way the notes " + "on the sheet carry their font. Cells that already hold formatting codes " + "are written as-is.", + ] = True, ctx: Context = None, ) -> dict: """Create a native COM table or an explicitly-labelled ezdxf composite table.""" result = await _backend(ctx).entity_create_table( - x, y, rows, headers, column_widths, row_height, text_height, title, layer + x, + y, + rows, + headers=headers, + column_widths=column_widths, + row_height=row_height, + text_height=text_height, + title=title, + layer=layer, + style=style, + layout=layout, + inline_font=inline_font, ) return _dc(result) @@ -1728,11 +1987,26 @@ async def leader_create_mleader( landing_gap: Annotated[float, Field(default=1.0, ge=0)] = 1.0, arrow_size: Annotated[float, Field(default=2.5, gt=0)] = 2.5, layer: Annotated[str, "Target layer"] = "DIM", + layout: Annotated[ + str | None, + "Paper-space layout to write into (create it with layout_create). Omit " + "to write where the call always did.", + ] = None, + inline_font: Annotated[ + bool, "Wrap the callout text in the inline ISOCPEUR group." + ] = True, ctx: Context = None, ) -> dict: """Create a native COM MLeader or an explicitly-labelled ezdxf composite.""" result = await _backend(ctx).leader_create_mleader( - points, text, text_height, landing_gap, arrow_size, layer + points, + text, + text_height=text_height, + landing_gap=landing_gap, + arrow_size=arrow_size, + layer=layer, + layout=layout, + inline_font=inline_font, ) return _dc(result) @@ -1752,12 +2026,17 @@ async def entity_create_hatch( angle: Annotated[float, "Pattern rotation angle in degrees"] = 0.0, layer: Annotated[str | None, "Layer name"] = None, color: Annotated[int | None, "ACI color code"] = None, + layout: Annotated[ + str | None, + "Paper-space layout to draw into (see entity_create_line). Omit for " + "model space / the current layout.", + ] = None, ctx: Context = None, ) -> dict: """Create a hatch fill pattern inside a closed boundary polygon.""" await ctx.debug(f"Creating hatch pattern={pattern} scale={scale}") result = await _backend(ctx).entity_create_hatch( - pattern, boundary_points, scale, angle, layer, color + pattern, boundary_points, scale, angle, layer, color, layout ) return _dc(result) @@ -1771,11 +2050,16 @@ async def entity_create_spline( fit_points: Annotated[list[list[float]], "List of [x, y] fit points the spline passes through"], layer: Annotated[str | None, "Layer name"] = None, color: Annotated[int | None, "ACI color code"] = None, + layout: Annotated[ + str | None, + "Paper-space layout to draw into (see entity_create_line). Omit for " + "model space / the current layout.", + ] = None, ctx: Context = None, ) -> dict: """Create a NURBS spline curve passing through the specified fit points.""" await ctx.debug(f"Creating spline with {len(fit_points)} fit points") - result = await _backend(ctx).entity_create_spline(fit_points, layer, color) + result = await _backend(ctx).entity_create_spline(fit_points, layer, color, layout) return _dc(result) @@ -1797,6 +2081,11 @@ async def entity_create_ellipse( ] = 0.5, layer: Annotated[str | None, "Layer name"] = None, color: Annotated[int | None, "ACI color code"] = None, + layout: Annotated[ + str | None, + "Paper-space layout to draw into (see entity_create_line). Omit for " + "model space / the current layout.", + ] = None, ctx: Context = None, ) -> dict: """Create an ellipse. major_x/major_y define the major axis vector from the center.""" @@ -1804,7 +2093,7 @@ async def entity_create_ellipse( f"Creating ellipse center=({cx},{cy}) major=({major_x},{major_y}) ratio={ratio}" ) result = await _backend(ctx).entity_create_ellipse( - cx, cy, major_x, major_y, ratio, layer, color + cx, cy, major_x, major_y, ratio, layer, color, layout ) return _dc(result) @@ -1819,10 +2108,15 @@ async def entity_create_point( y: Annotated[float, "Point Y coordinate"], layer: Annotated[str | None, "Layer name"] = None, color: Annotated[int | None, "ACI color code"] = None, + layout: Annotated[ + str | None, + "Paper-space layout to draw into (see entity_create_line). Omit for " + "model space / the current layout.", + ] = None, ctx: Context = None, ) -> dict: """Create a point marker entity at (x, y).""" - result = await _backend(ctx).entity_create_point(x, y, layer, color) + result = await _backend(ctx).entity_create_point(x, y, layer, color, layout) return _dc(result) @@ -1842,6 +2136,11 @@ async def entity_create_block_ref( scale_y: Annotated[float, "Y scale factor"] = 1.0, rotation: Annotated[float, "Rotation angle in degrees"] = 0.0, layer: Annotated[str | None, "Layer name"] = None, + layout: Annotated[ + str | None, + "Paper-space layout to draw into (see entity_create_line). Omit for " + "model space / the current layout.", + ] = None, ctx: Context = None, ) -> dict: """Insert a block reference (instance of an existing block definition). @@ -1854,7 +2153,7 @@ async def entity_create_block_ref( """ await ctx.debug(f"Inserting block '{name}' at ({x},{y})") result = await _backend(ctx).entity_create_block_ref( - name, x, y, scale_x, scale_y, rotation, layer + name, x, y, scale_x, scale_y, rotation, layer, layout ) return _dc(result) @@ -1943,6 +2242,11 @@ async def hatch_add_boundary( async def entity_create_wipeout( points: Annotated[list[list[float]], "Closed polygon as [[x, y], ...]; at least 3 points."], layer: Annotated[str, "Target layer. Empty uses the current layer."] = "", + layout: Annotated[ + str | None, + "Paper-space layout to draw into (see entity_create_line). Omit for " + "model space / the current layout.", + ] = None, ctx: Context = None, ) -> dict: """Create a WIPEOUT that hides drawing content behind its outline. @@ -1950,7 +2254,7 @@ async def entity_create_wipeout( Refuses fewer than three points: a zero-area mask hides nothing while reporting success. """ - return await _backend(ctx).entity_create_wipeout(points, layer or None) + return await _backend(ctx).entity_create_wipeout(points, layer or None, layout) @cad_tool(summary="Draw a revision cloud around an area.", cost="mutate") @@ -1966,6 +2270,11 @@ async def entity_create_revcloud( ], layer: Annotated[str, "Target layer. Empty uses the current layer."] = "", closed: Annotated[bool, "Close the path back to the first point."] = True, + layout: Annotated[ + str | None, + "Paper-space layout to draw into (see entity_create_line). Omit for " + "model space / the current layout.", + ] = None, ctx: Context = None, ) -> dict: """Draw a revision cloud: a polyline whose every segment carries an arc. @@ -1974,7 +2283,9 @@ async def entity_create_revcloud( would carry no arcs at all and would be a plain polyline reported as a cloud. """ - return await _backend(ctx).entity_create_revcloud(points, segment_length, layer or None, closed) + return await _backend(ctx).entity_create_revcloud( + points, segment_length, layer or None, closed, layout + ) # --------------------------------------------------------------------------- @@ -2047,6 +2358,11 @@ async def dimension_linear( "ISO 286 fit code (e.g. 'H7', 'g6', 'js9'); resolves deviations from the " "authored tables for the measured nominal. Mutually exclusive with tol_*.", ] = None, + layout: Annotated[ + str | None, + "Paper-space layout to dimension into (create it with layout_create). " + "Omit to dimension where the call always did.", + ] = None, ctx: Context = None, ) -> dict: """Create a linear dimension, optionally toleranced (ISO 129 or ISO 286 fit).""" @@ -2062,12 +2378,13 @@ async def dimension_linear( y2, dim_x, dim_y, - rotation, - layer, - tol_upper, - tol_lower, - tol_mode, - text_override, + rotation=rotation, + layer=layer, + tol_upper=tol_upper, + tol_lower=tol_lower, + tol_mode=tol_mode, + text_override=text_override, + layout=layout, ) return _dc(result) @@ -2085,10 +2402,16 @@ async def dimension_aligned( dim_x: Annotated[float, "Dimension line position X"], dim_y: Annotated[float, "Dimension line position Y"], layer: Annotated[str | None, "Layer name"] = None, + layout: Annotated[ + str | None, + "Paper-space layout to dimension into (create it with layout_create).", + ] = None, ctx: Context = None, ) -> dict: """Create an aligned dimension that measures the true distance between two points.""" - result = await _backend(ctx).dimension_aligned(x1, y1, x2, y2, dim_x, dim_y, layer) + result = await _backend(ctx).dimension_aligned( + x1, y1, x2, y2, dim_x, dim_y, layer=layer, layout=layout + ) return _dc(result) @@ -2107,11 +2430,24 @@ async def dimension_angular( text_x: Annotated[float, "Dimension text position X"], text_y: Annotated[float, "Dimension text position Y"], layer: Annotated[str | None, "Layer name"] = None, + layout: Annotated[ + str | None, + "Paper-space layout to dimension into (create it with layout_create).", + ] = None, ctx: Context = None, ) -> dict: """Create an angular dimension measuring the angle between two lines from a vertex.""" result = await _backend(ctx).dimension_angular( - vertex_x, vertex_y, x1, y1, x2, y2, text_x, text_y, layer + vertex_x, + vertex_y, + x1, + y1, + x2, + y2, + text_x, + text_y, + layer=layer, + layout=layout, ) return _dc(result) @@ -2144,6 +2480,10 @@ async def dimension_radius( str | None, "ISO 286 fit code resolved for the measured radius value. Mutually exclusive with tol_*.", ] = None, + layout: Annotated[ + str | None, + "Paper-space layout to dimension into (create it with layout_create).", + ] = None, ctx: Context = None, ) -> dict: """Create a radius dimension for a circle or arc, optionally toleranced.""" @@ -2156,12 +2496,13 @@ async def dimension_radius( center_y, chord_x, chord_y, - leader_length, - layer, - tol_upper, - tol_lower, - tol_mode, - text_override, + leader_length=leader_length, + layer=layer, + tol_upper=tol_upper, + tol_lower=tol_lower, + tol_mode=tol_mode, + text_override=text_override, + layout=layout, ) return _dc(result) @@ -2198,6 +2539,10 @@ async def dimension_diameter( "ISO 286 fit code (e.g. 'H7' hole / 'g6' shaft) resolved for the measured " "diameter. Mutually exclusive with tol_*.", ] = None, + layout: Annotated[ + str | None, + "Paper-space layout to dimension into (create it with layout_create).", + ] = None, ctx: Context = None, ) -> dict: """Create a diameter dimension for a circle, optionally toleranced (e.g. ⌀20 H7).""" @@ -2210,12 +2555,13 @@ async def dimension_diameter( y1, x2, y2, - leader_length, - layer, - tol_upper, - tol_lower, - tol_mode, - text_override, + leader_length=leader_length, + layer=layer, + tol_upper=tol_upper, + tol_lower=tol_lower, + tol_mode=tol_mode, + text_override=text_override, + layout=layout, ) return _dc(result) @@ -3203,6 +3549,18 @@ async def block_insert( rotation: Annotated[float, "Rotation angle in degrees"] = 0.0, attributes: Annotated[dict | None, "Attribute values: {TAG: value}"] = None, layer: Annotated[str | None, "Layer name"] = None, + style: Annotated[ + str | None, + "Text style for the ATTRIBs; defaults to the drawing's $TEXTSTYLE " + "(GOST_A, ISOCPEUR). The attribute definition's own style is overridden, " + "so block marks do not come out in Standard.", + ] = None, + layout: Annotated[ + str | None, + "Paper-space layout to insert into (see entity_create_line). Omit for " + "model space / the current layout. A definition is document-global, so " + "the sheet only decides where the reference is placed.", + ] = None, ctx: Context = None, ) -> dict: """Insert a block and optionally set attribute values. @@ -3216,7 +3574,7 @@ async def block_insert( """ await ctx.info(f"Inserting block '{name}' at ({x},{y})") result = await _backend(ctx).block_insert( - name, x, y, scale_x, scale_y, rotation, attributes, layer + name, x, y, scale_x, scale_y, rotation, attributes, layer, style, layout ) return _dc(result) @@ -3283,20 +3641,274 @@ async def block_get_attributes( async def block_set_attributes( handle: Annotated[str, "Block reference (INSERT) entity handle"], attributes: Annotated[dict, "Attribute values to update: {TAG: new_value}"], + style: Annotated[ + str | None, + "Text style to stamp on the ATTRIBs; defaults to the drawing's " + "$TEXTSTYLE (GOST_A, ISOCPEUR). A value written into an ATTRIB left in " + "Standard still plots in the wrong face.", + ] = None, ctx: Context = None, ) -> dict: """Update attribute values in a block reference. + ATTRIBs the reference does not carry yet are created from the block + definition's ATTDEFs, so a reference inserted before attributes existed (or + inserted without values) can still be filled. The reply separates + ``updated_tags`` from ``created_tags`` and lists ``unknown_tags`` — values + whose tag the block definition does not declare. + Same value rule as `block_insert`: strings and numbers are written, `null` / booleans / lists / objects are refused naming the tag before - anything changes. `updated_tags` lists the tags that exist on the - reference and were written; unknown tags are ignored. + anything changes. A multi-line attribute takes `Line1\\PLine2` and keeps every line; the value written is the one `block_get_attributes` reads back and `block_explode` bursts, on both engines. """ - return await _backend(ctx).block_set_attributes(handle, attributes) + return await _backend(ctx).block_set_attributes(handle, attributes, style) + + + +@cad_tool( + summary="Define an attribute (ATTDEF) inside a block definition.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Create Attribute Definition", "readOnlyHint": False}, + tags={"block", "create"}, +) +async def entity_create_attdef( + block_name: Annotated[str, "Existing block definition name"], + tag: Annotated[str, "Attribute tag, e.g. 'POS' or 'NAME'"], + x: Annotated[float, "Attribute position X (block coordinate system)"], + y: Annotated[float, "Attribute position Y (block coordinate system)"], + height: Annotated[float, Field(description="Text height in drawing units", gt=0)] = 2.5, + prompt: Annotated[str, "Prompt shown on insertion (defaults to the tag)"] = "", + default: Annotated[str, "Default value"] = "", + rotation: Annotated[float, "Rotation in degrees"] = 0.0, + layer: Annotated[str | None, "Layer name"] = None, + style: Annotated[str | None, "Text style name (defaults to $TEXTSTYLE)"] = None, + invisible: Annotated[bool, "Store the attribute without displaying it"] = False, + ctx: Context = None, +) -> dict: + """Create an ATTDEF — an attribute definition — inside a block definition. + + This is what makes a block a symbol with data: `block_insert` fills these + definitions, `block_get_attributes` reads them back. Without it a symbol + library with attributes could only be assembled outside this server. + """ + await ctx.info(f"Creating ATTDEF '{tag}' in block '{block_name}'") + result = await _backend(ctx).entity_create_attdef( + block_name, tag, x, y, height, prompt, default, layer, rotation, style, invisible + ) + return _dc(result) + + +@cad_tool(summary="List the attribute definitions a block declares.", cost="read") +@mcp.tool( + annotations={"title": "Block Attribute Definitions", "readOnlyHint": True}, + tags={"block", "query"}, +) +async def block_get_attdefs( + name: Annotated[ + str | None, + "Block definition name. Preferred spelling; 'block_name' is accepted as " + "an alias (same value) for parity with entity_create_attdef.", + ] = None, + block_name: Annotated[ + str | None, + "Block definition name — alias of 'name'. The block tools used to disagree: " + "entity_create_attdef took 'block_name' while this reader demanded 'name', " + "so a call written for one failed validation on the other. Both are now " + "accepted; if both are given, 'name' wins.", + ] = None, + ctx: Context = None, +) -> list[dict]: + """List every ATTDEF a block definition carries (tag, prompt, default, position). + + The block is named by ``name``, or by ``block_name`` — the two are the same + argument under different spellings, and supplying neither is reported as a + readable error instead of a validation failure. + """ + wanted = (name or block_name or "").strip() + if not wanted: + return [ + { + "ok": False, + "error": ( + "block_get_attdefs needs the block definition name: pass " + "'name' (or its alias 'block_name')." + ), + } + ] + return await _backend(ctx).block_get_attdefs(wanted) + + +@cad_tool( + summary="Set the current text style, creating it if the drawing lacks it.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Ensure Text Style", "readOnlyHint": False}, + tags={"system", "settings"}, +) +async def textstyle_ensure( + name: Annotated[str, "Text style name, e.g. 'GOST_A'"] = "GOST_A", + font: Annotated[str, "Font file referenced by the style"] = "isocpeur.ttf", + bigfont: Annotated[str, "Big font (Asian charsets); empty for none"] = "", + set_default: Annotated[bool, "Also make it the drawing's $TEXTSTYLE"] = True, + height: Annotated[float, "Fixed height (0 = variable)"] = 0.0, + width_factor: Annotated[float, "Width factor"] = 1.0, + ctx: Context = None, +) -> dict: + """Create/refresh a text style and optionally make it the default. + + Cyrillic text in AutoCAD needs a style pointing at a Unicode font; + ISOCPEUR ships with AutoCAD and is the default here. + """ + result = await _backend(ctx).textstyle_ensure( + name, font=font, bigfont=bigfont, set_default=set_default, + height=height, width_factor=width_factor, + ) + return result + + +@cad_tool( + summary="Put every label in one text style (ГОСТ ISOCPEUR) — model, stamp and blocks.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Apply Text Style Everywhere", "readOnlyHint": False}, + tags={"system", "settings", "annotation"}, +) +async def text_apply_style( + style: Annotated[str, "Text style to apply, e.g. 'GOST_A'"] = "GOST_A", + font: Annotated[ + str, "Font file for the style if the drawing does not have it yet" + ] = "isocpeur.ttf", + include_block_definitions: Annotated[ + bool, + "Also restyle the ATTDEF/TEXT inside block definitions — without it, " + "attributes materialised from those definitions come back in the old style", + ] = True, + set_default: Annotated[ + bool, "Also make it the drawing's $TEXTSTYLE, so later text lands in it" + ] = True, + inline_font: Annotated[ + bool, + "Also write the inline ISOCPEUR group into every MTEXT that does not " + "carry it yet — the repair pass for a drawing assembled before the font " + "was baked into the content. Off by default: it rewrites existing text.", + ] = False, + ctx: Context = None, +) -> dict: + """Reassign one text style to every TEXT, MTEXT, ATTRIB and ATTDEF in the drawing. + + Text tools take a style per call, so a drawing assembled in parts — or built + before the ГОСТ style existed — ends up half in ``Standard`` and half in + ISOCPEUR. This sweeps the whole file: model space, every paper-space layout, + the ATTRIBs owned by block references **and** the definitions inside blocks. + + The style is created if missing and (by default) made current. The reply + counts what it found per entity type and how many it had to change; use + ``text_style_report`` to list the leftovers. + + ``inline_font=True`` additionally bakes the ISOCPEUR group into existing + MTEXT, so a sheet built before inline fonts were the default can be repaired + without rebuilding it. + """ + backend = _backend(ctx) + applier = getattr(backend, "text_apply_style", None) + if applier is None: + raise ToolError( + "text_apply_style is implemented by the ezdxf backend; the active " + f"backend ({backend.name}) has no equivalent. Set AUTOCAD_MCP_BACKEND=ezdxf." + ) + await ctx.info(f"Applying text style '{style}' to the whole drawing") + return await applier( + style, + font=font, + include_block_definitions=include_block_definitions, + set_default=set_default, + inline_font=inline_font, + ) + + +@cad_tool( + summary="Report the text style of every label, and who is not in the expected one.", + cost="read", +) +@mcp.tool( + annotations={"title": "Text Style Report", "readOnlyHint": True}, + tags={"system", "query", "annotation"}, +) +async def text_style_report( + style: Annotated[ + str | None, + "Style the labels are expected to carry. Default: the drawing's $TEXTSTYLE.", + ] = None, + ctx: Context = None, +) -> dict: + """List every TEXT/MTEXT/ATTRIB/ATTDEF with the style it carries. + + Returns the totals per style and per entity type, the styles the file + defines with the font each points at, and up to 80 entities that are *not* + in the expected style (handle, type, current style, text, where it lives). + Counts include the ATTDEF/TEXT inside block definitions and the ATTRIBs held + by block references. + """ + backend = _backend(ctx) + reporter = getattr(backend, "text_style_report", None) + if reporter is None: + raise ToolError( + "text_style_report is implemented by the ezdxf backend; the active " + f"backend ({backend.name}) has no equivalent. Set AUTOCAD_MCP_BACKEND=ezdxf." + ) + return await reporter(style) + + +@cad_tool( + summary="Create the СПДС dimension style (ГОСТ lettering) and make it current.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Ensure Dimension Style", "readOnlyHint": False}, + tags={"system", "settings", "dimension"}, +) +async def dimstyle_ensure( + name: Annotated[str, "Dimension style name, e.g. 'СПДС'"] = "СПДС", + text_style: Annotated[ + str | None, + "Text style for the dimension text; defaults to the ГОСТ ISOCPEUR style " + "(GOST_A), created if the drawing lacks it, so the sizes match the sheet.", + ] = None, + base: Annotated[ + str | None, + "Existing dimension style to copy arrows, gaps and units from; empty " + "starts from the schema defaults.", + ] = None, + set_current: Annotated[ + bool, "Also make it the drawing's current dimension style ($DIMSTYLE)" + ] = True, + ctx: Context = None, +) -> dict: + """Create (or refresh) the СПДС dimension style and make it current. + + The dimension text is pointed at the ГОСТ ISOCPEUR face: a size lettered in a + foreign font stands out on a СПДС sheet. Making the style current is what the + dimension tools read — ``dimension_linear``, ``_aligned``, ``_angular``, + ``_radius``, ``_diameter`` and ``dimension_auto`` draw with the header's + ``$DIMSTYLE``, so after this call the next dimension comes out СПДС instead + of ``Standard``. + """ + backend = _backend(ctx) + ensurer = getattr(backend, "dimstyle_ensure", None) + if ensurer is None: + raise ToolError( + f"dimstyle_ensure is not implemented by the active backend ({backend.name})." + ) + await ctx.info(f"Ensuring dimension style '{name}'") + return await ensurer(name, text_style=text_style, base=base, set_current=set_current) @cad_tool( @@ -3312,11 +3924,19 @@ async def block_create_from_entities( handles: Annotated[list[str], "List of entity handles to include in the block"], base_x: Annotated[float, "Block base point X"] = 0.0, base_y: Annotated[float, "Block base point Y"] = 0.0, + layout: Annotated[ + str | None, + "Restrict the source handles to one space: a layout name (paper space) " + "or 'Model'. A block definition is document-global — it has no owner " + "sheet — so this constrains where the entities are read from, and a " + "handle that lives elsewhere is refused by name instead of copied " + "silently. Omit for the old drawing-wide lookup.", + ] = None, ctx: Context = None, ) -> dict: """Create a new block definition from existing entities in the drawing. - Works on both engines. The originals stay in model space — this defines a + Works on both engines. The originals stay where they are — this defines a reusable block from them rather than consuming them the way AutoCAD's BLOCK command does; use `block_insert` to place copies, and delete the originals yourself if you want the command's behaviour. @@ -3326,7 +3946,9 @@ async def block_create_from_entities( definition behind. """ await ctx.info(f"Creating block '{name}' from {len(handles)} 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, layout + ) @cad_tool( @@ -4886,6 +5508,25 @@ _LAYER_TEMPLATES = { {"name": "ANNOTATIONS", "color": 8, "linetype": "Continuous", "lineweight": 13}, {"name": "BORDER", "color": 7, "linetype": "Continuous", "lineweight": 100}, ], + # Набор СПДС/ГОСТ — имена, цвета и типы линий как в образце Павла + # («пример листа.dwg»): рамка и штамп белые, блоки аннотаций синие, + # стены цветом 54. Слой 0 и «По умолчанию» уже есть в любом чертеже, + # поэтому шаблон их не пересоздаёт, а приводит свойства к образцу. + "spds": [ + {"name": "RAMKA", "color": 7, "linetype": "Continuous", "lineweight": 35}, + {"name": "STAMPS", "color": 7, "linetype": "Continuous", "lineweight": 25}, + {"name": "00 БЛОКИ АННОТАЦИЙ", "color": 5, "linetype": "Continuous", "lineweight": 25}, + {"name": "Стены", "color": 54, "linetype": "Continuous", "lineweight": 50}, + {"name": "0", "color": 7, "linetype": "Continuous", "lineweight": 13}, + {"name": "По умолчанию", "color": 7, "linetype": "Continuous", "lineweight": 13}, + ], +} + +#: Имена, которыми шаблон СПДС зовут на стройке и в СПДС-чертежах. +_LAYER_TEMPLATE_ALIASES = { + "gost": "spds", + "гост": "spds", + "спдс": "spds", } @@ -4898,15 +5539,21 @@ _LAYER_TEMPLATES = { tags={"template", "layer"}, ) async def template_apply_layers( - template: Annotated[str, "Template name: architectural, mechanical, electrical, piping"], + template: Annotated[ + str, + "Template name: architectural, mechanical, electrical, piping, spds (alias gost)", + ], ctx: Context = None, ) -> dict: """Apply a standard layer set from a predefined template. - Available templates: architectural, mechanical, electrical, piping. - Creates all layers defined in the template with standard colors and lineweights. + Available templates: architectural, mechanical, electrical, piping, spds + (the ГОСТ/СПДС set of the reference sheet: RAMKA, STAMPS, 00 БЛОКИ + АННОТАЦИЙ, Стены, 0, По умолчанию). Creates the layers defined in the + template with standard colors and lineweights; layers the drawing already + has (0 and По умолчанию always exist) are updated instead of re-created. """ - template_key = template.lower().strip() + template_key = _LAYER_TEMPLATE_ALIASES.get(template.lower().strip(), template.lower().strip()) if template_key not in _LAYER_TEMPLATES: available = ", ".join(_LAYER_TEMPLATES.keys()) raise ToolError(f"Unknown template '{template}'. Available: {available}") @@ -4914,18 +5561,51 @@ async def template_apply_layers( b = _backend(ctx) layers_def = _LAYER_TEMPLATES[template_key] created = [] + updated = [] + failed = [] await ctx.info(f"Applying '{template_key}' layer template ({len(layers_def)} layers)") - for ldef in layers_def: - await b.layer_create( - ldef["name"], - color=ldef["color"], - linetype=ldef["linetype"], - lineweight=ldef["lineweight"], - ) - created.append(ldef["name"]) + existing: set[str] = set() + try: + for entry in await b.layer_list() or []: + name = getattr(entry, "name", None) + if name is None and isinstance(entry, dict): + name = entry.get("name") + if name: + existing.add(str(name).lower()) + except Exception: # a listing that will not read must not block the template + existing = set() - return {"ok": True, "template": template_key, "layers_created": created, "count": len(created)} + for ldef in layers_def: + name = ldef["name"] + try: + if name.lower() in existing: + await b.layer_modify( + name, + color=ldef["color"], + linetype=ldef["linetype"], + lineweight=ldef["lineweight"], + ) + updated.append(name) + else: + await b.layer_create( + name, + color=ldef["color"], + linetype=ldef["linetype"], + lineweight=ldef["lineweight"], + ) + created.append(name) + except Exception as exc: # слой «0» в AutoCAD частично защищён — не валим набор + failed.append({"layer": name, "error": str(exc)}) + + return { + "ok": True, + "template": template_key, + "layers_created": created, + "layers_updated": updated, + "layers_failed": failed, + "count": len(created) + len(updated), + } @cad_tool(summary="Show the available layer templates and the layers in each.", cost="read") @@ -5729,6 +6409,79 @@ async def titleblock_apply_iso_a3( ) +@cad_tool( + summary="Stamp a ГОСТ Р 21.1101-2013 form 3 title block on A3/A4.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "TitleBlock: ГОСТ форма 3", "destructiveHint": False}, + tags={"engineering", "titleblock"}, +) +async def titleblock_apply_gost_form3( + drawing_no: Annotated[str, Field(description="Графа 1 — обозначение документа, напр. 'ЭО-01'.")], + title: Annotated[str, Field(default="", description="Графа 4 — наименование изображений листа.")] = "", + building: Annotated[str, Field(default="", description="Графа 3 — наименование здания (сооружения).")] = "", + enterprise: Annotated[str, Field(default="", description="Графа 2 — предприятие / объект строительства.")] = "", + organization: Annotated[str, Field(default="", description="Графа 9 — организация.")] = "", + stage: Annotated[str, Field(default="Р", description="Графа 6 — стадия: П или Р.")] = "Р", + sheet: Annotated[str, Field(default="1", description="Графа 7 — номер листа.")] = "1", + total_sheets: Annotated[str, Field(default="1", description="Графа 8 — всего листов.")] = "1", + scale: Annotated[str, Field(default="1:100", description="Графа 25 — масштаб.")] = "1:100", + developed_by: Annotated[str, Field(default="", description="Разработал — фамилия.")] = "", + developed_date: Annotated[str, Field(default="", description="Дата подписи разработчика.")] = "", + checked_by: Annotated[str, Field(default="", description="Проверил — фамилия.")] = "", + checked_date: Annotated[str, Field(default="", description="Дата подписи проверяющего.")] = "", + norm_control: Annotated[str, Field(default="", description="Н. контр. — фамилия.")] = "", + approved_by: Annotated[str, Field(default="", description="Утв. — фамилия.")] = "", + sheet_format: Annotated[str, Field(default="A3", description="Формат листа: A3, A4, A2, A1, A0.")] = "A3", + origin_x: Annotated[float, Field(default=0.0, description="X левого нижнего угла листа.")] = 0.0, + origin_y: Annotated[float, Field(default=0.0, description="Y левого нижнего угла листа.")] = 0.0, + layout: Annotated[ + str, + "Paper-space layout to draw the sheet on (create it with layout_create). " + "Empty draws in the current space.", + ] = "", + text_style: Annotated[str, Field(default="GOST_A", description="Имя текстового стиля для надписей.")] = "GOST_A", + ctx: Context = None, +) -> dict: + """Основная надпись по ГОСТ Р 21.1101-2013, форма 3 (185x55 мм) + рамка листа. + + Поля рамки 20/5/5/5 мм, штамп — в правом нижнем углу рамки: таблица + изменений (графы 14-19, 65 мм), графы 1-4 и 9, вложенные графы 6-8 + «Стадия / Лист / Листов» (15/15/20 мм), графы 25 «Масштаб» и 26 «Формат». + Текстовый стиль создаётся автоматически (ISOCPEUR — поддержка кириллицы). + """ + from engineering import GostTitleBlockMetadata, apply_gost_form3_titleblock + + backend = _backend(ctx) + metadata = GostTitleBlockMetadata( + drawing_no=drawing_no, + enterprise=enterprise, + building=building, + title=title, + stage=stage, + sheet=sheet, + total_sheets=total_sheets, + organization=organization, + scale=scale, + format=sheet_format, + developed_by=developed_by, + developed_date=developed_date, + checked_by=checked_by, + checked_date=checked_date, + norm_control=norm_control, + approved_by=approved_by, + ) + return await apply_gost_form3_titleblock( + backend, + metadata=metadata, + sheet=sheet_format, + origin=(origin_x, origin_y), + layout=layout or None, + text_style=text_style or "GOST_A", + ) + + @cad_tool( summary="Finish the drawing: validate, critique, save, screenshot and score it.", cost="destructive", @@ -6245,12 +6998,16 @@ async def dimension_auto( offset: Annotated[ float, Field(default=10.0, gt=0, description="Dimension-line offset from the geometry (mm)") ] = 10.0, + layout: Annotated[ + str | None, + "Paper-space layout to dimension into (create it with layout_create).", + ] = None, ctx: Context = None, ) -> list[dict]: """Generate ISO 129 dimensions across the listed entities in the chosen style. V1 supports LINE entities only. """ - result = await _backend(ctx).dimension_auto(handles, style, offset) + result = await _backend(ctx).dimension_auto(handles, style, offset, layout=layout) return [_dc(e) for e in result] @@ -6417,6 +7174,31 @@ async def layout_create( return await _backend(ctx).layout_create(name) +@cad_tool( + summary="State the sheet format of a layout, so PDF export comes out 1:1 in mm.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Set Layout Paper Size", "destructiveHint": False}, + tags={"layout"}, +) +async def layout_set_paper( + name: Annotated[str, "Paper-space layout whose format is being set (not 'Model')."], + width: Annotated[float, Field(gt=0, description="Sheet width in millimetres (A4: 210).")], + height: Annotated[float, Field(gt=0, description="Sheet height in millimetres (A4: 297).")], + ctx: Context = None, +) -> dict: + """Set a layout's sheet format in millimetres (A4 210x297, A3 420x297...). + + ``drawing_export_pdf(page_size='layout')`` sizes its page from this, so a + 210x297 frame comes out as a 210x297 mm PDF sheet at 1:1 instead of sitting + in the corner of a default A3 page. The sheet itself is not drawn — draw the + frame with entity_create_polyline(layout=...). + """ + await ctx.info(f"Setting layout {name} paper to {width}x{height} mm") + return await _backend(ctx).layout_set_paper(name, width, height) + + @cad_tool(summary="Switch to a sheet tab, or back to model space.", cost="safe") @mcp.tool( annotations={"title": "Set Current Layout"},