From 2e2a2b96a97f12b0355463900e8db62b8d30fb50 Mon Sep 17 00:00:00 2001 From: grigory Date: Mon, 5 Oct 2026 18:04:55 +0300 Subject: [PATCH] =?UTF-8?q?ours:=20=D0=BD=D0=B0=D1=88=20=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BB=D0=B5=D0=B9=20=D0=BA=20=D0=B0=D0=BF=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D0=BC=D1=83=201.5.1=20(=D0=B4=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D0=B0=20=D0=B2=201.6.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10 изменённых файлов + 2 новых (engineering/gost_titleblock.py, _fonts/isocpeur.ttf). Ветка-донор для трёхстороннего переноса в ours-1.6. --- _fonts/isocpeur.ttf | Bin 0 -> 149932 bytes backends/com_backend.py | 304 ++++- backends/contracts/dimensions.py | 5 + backends/contracts/entity_creation.py | 11 + backends/contracts/layouts.py | 23 + backends/contracts/premium.py | 9 +- backends/ezdxf_backend.py | 1556 +++++++++++++++++++++++-- discovery/aliases.py | 35 + engineering/__init__.py | 6 + engineering/annotation.py | 26 + engineering/gost_titleblock.py | 517 ++++++++ server.py | 899 +++++++++++++- 12 files changed, 3232 insertions(+), 159 deletions(-) create mode 100644 _fonts/isocpeur.ttf create mode 100644 engineering/gost_titleblock.py diff --git a/_fonts/isocpeur.ttf b/_fonts/isocpeur.ttf new file mode 100644 index 0000000000000000000000000000000000000000..fa9150d562c93f4ee97b258faa63777869a44f7b GIT binary patch literal 149932 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

;3NQ)L?Fx0~?Xd^+AaKSE~8EWEkVBCV2< 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 literal 0 HcmV?d00001 diff --git a/backends/com_backend.py b/backends/com_backend.py index b8e5f2d..9adcd08 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -193,6 +193,34 @@ def _msp(): return doc.ModelSpace +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"} @@ -815,7 +843,21 @@ class ComBackend(AutoCADBackend): async def drawing_export_dxf(self, path: str) -> dict: return await self.drawing_save_as(path, "dxf") - 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`` is accepted for signature parity with the ezdxf backend: + # AutoCAD plots real text natively, so the flag has nothing to switch. + # ``page_size`` likewise: PlotToFile plots the layout's own paper at its + # own scale (that is what a .pc3 device does), so there is nothing to + # choose between "layout" and "extents" here. + del text_layer + del page_size + def _sync(): doc = _acad_doc() previous = None @@ -1934,8 +1976,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) @@ -1951,8 +1997,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) @@ -1970,8 +2018,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), @@ -1991,8 +2041,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: @@ -2014,11 +2066,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) @@ -2035,13 +2101,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) @@ -2059,30 +2138,43 @@ 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) - 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.SetText(row_index, column_index, wrap_inline_font(value, inline_font)) try: - table.TextHeight = layout.text_height + table.TextHeight = table_layout.text_height except Exception as exc: log.debug("setting table TextHeight failed: %s", exc) + 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) @@ -2093,11 +2185,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)], }, } ) @@ -2113,8 +2207,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: @@ -2123,11 +2219,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) @@ -2161,8 +2258,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) @@ -2189,8 +2288,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: @@ -2215,8 +2316,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), @@ -2235,8 +2338,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) @@ -2254,8 +2359,10 @@ class ComBackend(AutoCADBackend): scale_y=1.0, rotation=0.0, layer=None, + layout=None, ) -> EntityInfo: def _sync(): + _activate_layout(layout) mspace = _msp() ref = mspace.InsertBlock( _apoint(x, y), @@ -2288,8 +2395,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). @@ -2316,8 +2425,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: @@ -2338,8 +2449,10 @@ class ComBackend(AutoCADBackend): tx, ty, layer=None, + layout=None, ) -> EntityInfo: def _sync(): + _activate_layout(layout) mspace = _msp() dim = mspace.AddDimAngular( _apoint(vx, vy), @@ -2366,8 +2479,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) @@ -2392,8 +2507,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: @@ -2404,6 +2521,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: @@ -3023,8 +3207,11 @@ class ComBackend(AutoCADBackend): rotation=0.0, attributes=None, layer=None, + style=None, + layout=None, ) -> EntityInfo: def _sync(): + _activate_layout(layout) mspace = _msp() ref = mspace.InsertBlock( _apoint(x, y), @@ -3045,6 +3232,15 @@ class ComBackend(AutoCADBackend): attr.TextString = str(attributes[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) @@ -3070,18 +3266,32 @@ 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: def _sync(): doc = _acad_doc() ref = doc.HandleToObject(handle) attrs = ref.GetAttributes() updated = [] + restyled = 0 for attr in attrs: tag = attr.TagString if tag in attributes: attr.TextString = str(attributes[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) @@ -3091,26 +3301,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 a33679b..72ff5fe 100644 --- a/backends/contracts/premium.py +++ b/backends/contracts/premium.py @@ -615,11 +615,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") @@ -661,6 +664,7 @@ class PremiumContract(ABC): my + ny * off, rotation=math.degrees(math.atan2(dy, dx)), layer=dim_layer, + layout=layout, ) results.append(dim) @@ -682,6 +686,7 @@ class PremiumContract(ABC): # baseline, not horizontal. rotation=base_rot, layer=dim_layer, + layout=layout, ) results.append(dim) @@ -697,6 +702,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: @@ -709,6 +715,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 c36ad56..7af92b5 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -80,6 +80,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 @@ -601,6 +1094,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. @@ -1080,6 +1596,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( @@ -1387,6 +1947,10 @@ class EzdxfBackend(AutoCADBackend): self._current_space = "Model" self._reset_history_baseline() 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 @@ -1424,6 +1988,10 @@ class EzdxfBackend(AutoCADBackend): self._current_space = "Model" self._reset_history_baseline() 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 @@ -1448,6 +2016,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. @@ -1466,13 +2103,31 @@ class EzdxfBackend(AutoCADBackend): async def drawing_export_dxf(self, path: str) -> dict: return await self.drawing_save_as(path, "dxf") - 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 @@ -1489,24 +2144,116 @@ class EzdxfBackend(AutoCADBackend): target = doc.layouts.get(resolved) else: target = self._msp() + 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)" + ), + } + # Only a paper-space layout has a sheet; model space always plots + # its extents. + paper = _layout_paper_mm(target) if mode == "layout" else None try: from ezdxf.addons.drawing import Frontend, RenderContext + from ezdxf.addons.drawing.config import Configuration, TextPolicy from ezdxf.addons.drawing.matplotlib import MatplotlibBackend - fig = _new_agg_figure() + 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 + + # 420 mm of sheet is 16.54 in of figure: with the axes spanning + # the same 420 units, one drawing unit (mm) is one millimetre on + # the page. The figure is created at that size *before* anything + # is drawn, and ``adjust_figure``/``finalize`` — which re-fit + # both the data limits and the figure box to the content — are + # switched off in this mode. + fig_size = (paper[0] / 25.4, paper[1] / 25.4) if paper else None + fig = _new_agg_figure(figsize=fig_size, dpi=150) ax = fig.add_axes([0, 0, 1, 1]) ctx = RenderContext(doc) - out = MatplotlibBackend(ax) - Frontend(ctx, out).draw_layout(target, finalize=True) - fig.savefig(path, dpi=150) - result = {"ok": True, "path": path} + # 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, config=config_obj).draw_layout( + target, finalize=paper is None + ) + if paper is not None: + # Aspect "auto" here is deliberate: with equal data scales + # (the figure ratio is built from the paper size) it is the + # same picture as "equal", but it leaves the limits exactly + # as set. "equal"/"datalim" — what the ezdxf backend installs + # — is free to nudge the data limits while satisfying the box + # aspect, and a page that is 0.2 % off a sheet is a page that + # prints with a white stripe. + ax.set_aspect("auto") + ax.set_xlim(0.0, paper[0]) + ax.set_ylim(0.0, paper[1]) + 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) + result = {"ok": True, "path": path, "page_size": mode} + if paper is not None: + result["page_mm"] = [round(paper[0], 2), round(paper[1], 2)] + 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( @@ -1597,6 +2344,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() @@ -2601,9 +3387,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)), @@ -2621,9 +3408,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() @@ -2640,9 +3428,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), @@ -2661,9 +3450,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) @@ -2672,6 +3462,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, @@ -2681,15 +3504,24 @@ 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={ "height": float(height), "rotation": float(rotation), "insert": (float(x), float(y)), + "style": resolved_style, }, ) self._apply_attrs(ent, layer, color) @@ -2708,12 +3540,21 @@ 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, - dxfattribs={"char_height": float(height), "width": float(width)}, + space = self._space(layout) + ent = space.add_mtext( + wrap_inline_font(text, inline_font), + dxfattribs={ + "char_height": float(height), + "width": float(width), + "style": style or self._default_text_style(), + }, ) ent.dxf.insert = (float(x), float(y), 0.0) if rotation: @@ -2735,44 +3576,53 @@ 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) for xx in x_positions: - line = msp.add_line((xx, y0), (xx, y0 - layout.height)) + line = space.add_line((xx, y0), (xx, y0 - table_layout.height)) self._apply_attrs(line, layer, None) children.append(line) - padding = min(1.0, layout.row_height * 0.15) - for row_index, row in enumerate(layout.cells): + padding = min(1.0, table_layout.row_height * 0.15) + for row_index, row in enumerate(table_layout.cells): for column_index, value in enumerate(row): if not value: continue - text_entity = msp.add_mtext( - value, + text_entity = space.add_mtext( + wrap_inline_font(value, inline_font), dxfattribs={ - "char_height": layout.text_height, - "width": max(1.0, layout.column_widths[column_index] - 2 * padding), + "char_height": table_layout.text_height, + "width": max( + 1.0, table_layout.column_widths[column_index] - 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) @@ -2792,17 +3642,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._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 _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, @@ -2811,8 +3671,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: @@ -2821,7 +3683,7 @@ class EzdxfBackend(AutoCADBackend): ) def _sync(): - msp = self._msp() + msp = self._space(layout) path = msp.add_lwpolyline(normalized) self._apply_attrs(path, layer, None) @@ -2845,8 +3707,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) @@ -2890,9 +3756,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] @@ -2908,9 +3775,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) @@ -2928,9 +3796,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), @@ -2948,9 +3817,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() @@ -2967,9 +3837,10 @@ class EzdxfBackend(AutoCADBackend): scale_y=1.0, rotation=0.0, layer=None, + layout=None, ) -> EntityInfo: def _sync(): - msp = self._msp() + msp = self._space(layout) ent = msp.add_blockref( name, (float(x), float(y)), @@ -3194,7 +4065,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: @@ -3206,7 +4077,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 @@ -3221,7 +4092,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 @@ -3255,7 +4126,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 @@ -3286,20 +4157,21 @@ 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)), p2=(float(x2), float(y2)), angle=float(rotation), text=text if text is not None else "<>", - dimstyle=_DIMSTYLE_NAME, + dimstyle=_current_dimstyle(self._require_doc()), override=override or None, ) dim.render() @@ -3320,16 +4192,17 @@ 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)), distance=math.sqrt( (float(dim_x) - float(x1)) ** 2 + (float(dim_y) - float(y1)) ** 2 ), - dimstyle=_DIMSTYLE_NAME, + dimstyle=_current_dimstyle(self._require_doc()), override=_with_header_dimvars(self._require_doc(), None), ) dim.render() @@ -3352,16 +4225,17 @@ 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)), line1=((vxf, vyf), (float(x1), float(y1))), line2=((vxf, vyf), (float(x2), float(y2))), location=(float(tx), float(ty)), - dimstyle=_DIMSTYLE_NAME, + dimstyle=_current_dimstyle(self._require_doc()), override=_with_header_dimvars(self._require_doc(), None), ) dim.render() @@ -3385,13 +4259,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) @@ -3406,7 +4281,7 @@ class EzdxfBackend(AutoCADBackend): cyf + (radius + leader) * math.sin(angle_rad), ), text=text if text is not None else "<>", - dimstyle=_DIMSTYLE_NAME, + dimstyle=_current_dimstyle(self._require_doc()), override=override or None, ) dim.render() @@ -3430,13 +4305,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 @@ -3456,7 +4332,7 @@ class EzdxfBackend(AutoCADBackend): cy + (radius + leader) * math.sin(angle_rad), ), text=text if text is not None else "<>", - dimstyle=_DIMSTYLE_NAME, + dimstyle=_current_dimstyle(self._require_doc()), override=override or None, ) dim.render() @@ -4443,9 +5319,25 @@ class EzdxfBackend(AutoCADBackend): rotation=0.0, attributes=None, layer=None, + style=None, + layout=None, ) -> EntityInfo: 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), @@ -4453,14 +5345,41 @@ class EzdxfBackend(AutoCADBackend): } if layer: attribs["layer"] = layer + ref = msp.add_blockref(name, (float(x), float(y)), dxfattribs=attribs) + filled: list[str] = [] + unknown: list[str] = [] + tags = [attdef.dxf.tag for attdef in doc.blocks[name].attdefs()] if attributes: - ref = msp.add_auto_blockref( - name, (float(x), float(y)), attributes, dxfattribs=attribs - ) - else: - ref = msp.add_blockref(name, (float(x), float(y)), dxfattribs=attribs) + values = {str(k): str(v) for k, v in dict(attributes).items()} + if tags: + # add_auto_attribs fills ATTRIBs from the block's ATTDEFs and + # silently ignores values whose tag has no ATTDEF; report the + # leftovers 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)) + else: + unknown = sorted(values) + # 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) @@ -4494,19 +5413,470 @@ 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: 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 attributes: - attrib.dxf.text = str(attributes[tag]) + values = {str(k): str(v) for k, v in dict(attributes or {}).items()} + 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: + attrib.dxf.text = 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) @@ -4516,20 +5886,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 " @@ -4547,6 +5952,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 f1f0020..6902ba9 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -908,6 +908,9 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "standard layers", "architectural layers", "mechanical layers", + "spds layers", + "gost layers", + "слои спдс", "translate the layers", ), ), @@ -1322,6 +1325,38 @@ TOOL_ALIASES: dict[str, ToolAliases] = { acad=("ZOOM",), synonyms=("zoom into a region", "close up of an area", "magnify a rectangle"), ), + "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 7d384f2..9c8f765 100644 --- a/engineering/__init__.py +++ b/engineering/__init__.py @@ -45,6 +45,10 @@ from .preflight import ( preflight_drawing, ) from .refiner import RefineResult, RefineRound, RepairAction, refine_drawing +from .gost_titleblock import ( + GostTitleBlockMetadata, + apply_gost_form3_titleblock, +) from .titleblock import TitleBlockMetadata, apply_iso_a3_titleblock from .validator import DrawingValidator, ValidationFinding, ValidationResult @@ -72,6 +76,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 5dac767..c2175ad 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 bf1099d..71cabe6 100644 --- a/server.py +++ b/server.py @@ -1164,6 +1164,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"}, @@ -1176,13 +1292,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 @@ -1291,11 +1434,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) @@ -1310,11 +1462,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) @@ -1331,12 +1488,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) @@ -1354,6 +1516,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. @@ -1361,7 +1528,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) @@ -1377,6 +1544,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. @@ -1385,7 +1557,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) @@ -1402,11 +1576,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) @@ -1426,12 +1630,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) @@ -1454,11 +1684,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) @@ -1475,11 +1734,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) @@ -1499,12 +1773,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) @@ -1518,11 +1797,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) @@ -1544,6 +1828,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.""" @@ -1551,7 +1840,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) @@ -1566,10 +1855,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) @@ -1589,12 +1883,17 @@ 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).""" 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) @@ -1680,6 +1979,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. @@ -1687,7 +1991,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") @@ -1703,6 +2007,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. @@ -1711,7 +2020,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 + ) # --------------------------------------------------------------------------- @@ -1784,6 +2095,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).""" @@ -1799,12 +2115,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) @@ -1822,10 +2139,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) @@ -1844,11 +2167,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) @@ -1881,6 +2217,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.""" @@ -1893,12 +2233,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) @@ -1935,6 +2276,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).""" @@ -1947,12 +2292,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) @@ -2899,12 +3245,24 @@ 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.""" 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) @@ -2947,10 +3305,265 @@ 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.""" - return await _backend(ctx).block_set_attributes(handle, attributes) + """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. + """ + 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( @@ -2966,11 +3579,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. @@ -2980,7 +3601,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(summary="Find every place a given block is inserted.", cost="read") @@ -4488,6 +5111,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", } @@ -4500,15 +5142,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}") @@ -4516,18 +5164,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") @@ -5282,6 +5963,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", @@ -5765,12 +6519,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] @@ -5937,6 +6695,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"},