10 изменённых файлов + 2 новых (engineering/gost_titleblock.py, _fonts/isocpeur.ttf). Ветка-донор для трёхстороннего переноса в ours-1.6.
194 lines
8.1 KiB
Python
194 lines
8.1 KiB
Python
"""Layouts, viewports and change-space.
|
|
|
|
Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
|
|
``AutoCADBackend`` composes every contract in this package, so importing
|
|
``backends.base.AutoCADBackend`` is unchanged for callers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from abc import ABC, abstractmethod
|
|
|
|
from backends.capability import capability
|
|
|
|
|
|
class LayoutContract(ABC):
|
|
@abstractmethod
|
|
async def layout_list(self) -> dict:
|
|
"""Return {ok, layouts: [names], current: name} incl. 'Model'."""
|
|
...
|
|
|
|
@abstractmethod
|
|
async def layout_create(self, name: str) -> dict:
|
|
"""Create a new paper-space layout."""
|
|
...
|
|
|
|
@abstractmethod
|
|
async def layout_set_current(self, name: str) -> dict:
|
|
"""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.
|
|
|
|
Returns ``{ok, deleted, entities_destroyed, current}``. ``current`` is
|
|
the space geometry goes into afterwards, which the implementation must
|
|
keep consistent with ``layout_list()`` — deleting the active tab
|
|
otherwise leaves the reported current layout, the DXF tile mode and the
|
|
space entities actually land in disagreeing with each other.
|
|
|
|
Refuses ``'Model'``, a blank name (which ezdxf resolves to the *first*
|
|
paper-space layout), a name that does not exist, and the last remaining
|
|
paper-space layout.
|
|
"""
|
|
...
|
|
|
|
@abstractmethod
|
|
async def layout_rename(self, old_name: str, new_name: str) -> dict:
|
|
"""Rename a paper-space layout, keeping its entities and their handles.
|
|
|
|
Returns ``{ok, old, new, current}``. Refuses ``'Model'`` on either side,
|
|
a blank or otherwise invalid new name, and a name already in use.
|
|
"""
|
|
...
|
|
|
|
@abstractmethod
|
|
async def layout_copy(self, source: str, new_name: str) -> dict:
|
|
"""Copy a paper-space layout, its page setup and its geometry.
|
|
|
|
Returns ``{ok, source, layout, entities_copied, skipped,
|
|
associativity_remapped, associativity_dropped}``. ``skipped`` names the
|
|
DXF types that could not be cloned — a copy missing geometry must never
|
|
report a bare success. Associative boundaries (hatches) are re-pointed
|
|
at the cloned entities where possible and cleared otherwise, because a
|
|
boundary still referencing the source layout dangles as soon as that
|
|
layout is deleted and no auditor reports it.
|
|
"""
|
|
...
|
|
|
|
@abstractmethod
|
|
async def viewport_create(
|
|
self,
|
|
layout: str,
|
|
center_x: float,
|
|
center_y: float,
|
|
width: float,
|
|
height: float,
|
|
view_center_x: float,
|
|
view_center_y: float,
|
|
scale: float = 1.0,
|
|
) -> dict:
|
|
"""Place a scaled model-space viewport on a paper-space layout.
|
|
|
|
``scale`` is paper:model (1.0 = 1:1, 0.5 = 1:2). The viewport shows
|
|
the model window centered at (view_center_x, view_center_y) with
|
|
view height = height / scale.
|
|
"""
|
|
...
|
|
|
|
@abstractmethod
|
|
async def viewport_list(self, layout: str | None = None) -> dict:
|
|
"""List the viewports on one paper-space layout, or on all of them.
|
|
|
|
Returns ``{ok, viewports: [...], count}``. Each row carries ``handle``,
|
|
``layout``, ``center``, ``width``, ``height``, ``view_center``,
|
|
``view_height``, ``scale``, ``locked``, ``status``, ``id`` and
|
|
``is_main``.
|
|
|
|
The layout's own main viewport is reported with ``is_main: True`` rather
|
|
than filtered out — it is what remains after deleting every drafting
|
|
viewport, and a caller who cannot see it cannot explain that. ``scale``
|
|
and ``locked`` are ``None`` on documents that cannot store them (R12),
|
|
never a fabricated number. Viewports are addressed by handle; ``id`` is
|
|
metadata only.
|
|
"""
|
|
...
|
|
|
|
@abstractmethod
|
|
async def viewport_set_scale(self, handle: str, scale: float) -> dict:
|
|
"""Set a viewport's paper:model scale. Returns ``{ok, handle, scale,
|
|
view_height}`` with ``scale`` read back from the entity.
|
|
|
|
Refuses the main viewport (whose view height is the tab's own pan/zoom
|
|
state, not a drafting scale) and documents that cannot persist it.
|
|
Geometric scale only: annotative text and dimensions do not resize.
|
|
"""
|
|
...
|
|
|
|
@abstractmethod
|
|
async def viewport_lock(self, handle: str, locked: bool = True) -> dict:
|
|
"""Lock or unlock a viewport's display scale. Returns ``{ok, handle,
|
|
locked}`` with ``locked`` read back from the entity."""
|
|
...
|
|
|
|
# B027 (empty method, no @abstractmethod) is exactly what this pattern
|
|
# looks like from the outside: the decorator replaces the `...` body with
|
|
# the refusing default, so the source really is empty on purpose.
|
|
@capability( # noqa: B027
|
|
"chspace",
|
|
reason=(
|
|
"ActiveX exposes no change-space member, so a live backend would have to recreate "
|
|
"geometry through CopyObjects or drive the CHSPACE command blind — neither has been "
|
|
"verified against a live AutoCAD. Use the headless backend "
|
|
"(AUTOCAD_MCP_BACKEND=ezdxf), or run CHSPACE in AutoCAD directly."
|
|
),
|
|
)
|
|
async def entity_change_space(
|
|
self,
|
|
handles: list[str],
|
|
viewport_handle: str,
|
|
direction: str = "to_paper",
|
|
freeze_dimensions: bool = False,
|
|
) -> dict:
|
|
"""Move entities between model space and a sheet, *through* a viewport.
|
|
|
|
Returns ``{ok, moved, refused, scale, viewport}``. Each ``moved`` row
|
|
carries ``handle``, ``from``, ``to``, ``inside_viewport`` and
|
|
``on_sheet``; each ``refused`` row carries ``handle`` and ``reason``.
|
|
|
|
The geometric transform is the whole point: a move without it leaves a
|
|
100 mm feature as 100 mm of paper inside a 1:2 viewport, produces a file
|
|
that audits clean, and is indistinguishable from success. Backends that
|
|
cannot apply the viewport transform must refuse rather than move.
|
|
|
|
Refuses per entity for dimensions (unless ``freeze_dimensions`` bakes
|
|
the pre-transform measurement into the text first), ACIS bodies, tables
|
|
and proxies, viewports, and entities already in the target space; and
|
|
refuses the whole call for a twisted or non-top-view viewport.
|
|
"""
|
|
...
|
|
|
|
@abstractmethod
|
|
async def viewport_delete(self, handle: str, force: bool = False) -> dict:
|
|
"""Delete a viewport. Returns ``{ok, handle, layout, was_main}``.
|
|
|
|
The layout's main viewport needs ``force=True`` and the implementation
|
|
must repair the layout's current-viewport pointer afterwards; a dangling
|
|
pointer is invisible to every headless audit and shows up only in CAD.
|
|
"""
|
|
...
|