fix(task-18): the headless Model plot is doc.modelspace(), not the current tab

EzdxfBackend.drawing_export_pdf routed layout=None / layout="Model" through
_msp(), which follows the current tab, so with a sheet current the "Model"
PDF was that sheet's paper-space content with no refusal — the same defect
finding 3 named on COM, still live on the headless engine. Measured: circle
in model space, 390x270 rectangle on an A3 sheet, sheet current — the Model
row's /MediaBox was 176.1x121.9 mm (the rectangle) instead of 121.9x121.9
(the circle). The model target is now doc.modelspace() whichever tab is
current, on both entry points.

The test that vouched for this only asserted the row's shape (paper is None,
true by construction). It is replaced by two tests that prove content: the
Model row's mediabox with a sheet current equals the one with Model current
and carries the circle's square aspect, and drawing_export_pdf(path) with no
layout does the same. Both fail without the backend change. plot.py's
docstring now says which engine does what.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-21 21:47:05 +03:00
co-authored by Claude Opus 5
parent f6951e2597
commit 86c5d6ad65
3 changed files with 73 additions and 12 deletions
+11 -1
View File
@@ -1513,6 +1513,16 @@ class EzdxfBackend(AutoCADBackend):
the geometry that viewport shows, and geometry outside the window is
correctly left out. What is genuinely missing is the viewport *border*,
which the headless renderer does not draw.
``layout=None`` and ``layout="Model"`` both mean model space, as the
tool advertises and as the COM engine plots. This used to route them
through ``_msp()``, which follows the *current* tab, so with a sheet
current the "Model" PDF was that sheet's paper-space content — a wrong
page with no refusal. Measured: circle in model space, 390 x 270
rectangle on an A3 sheet, sheet current — the Model row's /MediaBox
was 176.1 x 121.9 mm (the rectangle's aspect) instead of the circle's
121.9 x 121.9. The target is now ``doc.modelspace()`` whichever tab
is current.
"""
def _sync():
@@ -1523,7 +1533,7 @@ class EzdxfBackend(AutoCADBackend):
return {"ok": False, "error": f"Layout not found: {layout}"}
target = doc.layouts.get(resolved)
else:
target = self._msp()
target = doc.modelspace()
try:
from ezdxf.addons.drawing import Frontend, RenderContext
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
+4 -1
View File
@@ -37,7 +37,10 @@ async def batch_plot(
``layouts=None`` plots every paper-space layout; ``Model`` is plotted only
when named, and is passed to the engine by name so the model row is model
space whichever tab is current. A sheet the engine cannot plot, or reports
space whichever tab is current — both engines honour the name (COM plots
the Model tab; ezdxf renders ``doc.modelspace()``, not the current tab;
``tests/test_batch_plot.py`` proves it by content on the headless engine).
A sheet the engine cannot plot, or reports
plotted without a file, is a per-row ``ok: False`` with its ``error``; the
rows before it stay. Refuses (``ValueError``, before any PDF is written): an empty
list, a layout that does not exist, a pattern with a path separator, and
+58 -10
View File
@@ -102,14 +102,33 @@ async def test_a_fresh_drawing_plots_its_one_default_sheet_at_the_dxf_default_si
assert row["path"].endswith("Layout1.pdf") and row["paper"] == "ISO_A3"
async def test_model_is_named_to_the_engine_even_when_a_sheet_is_current(backend, tmp_path):
"""The Model row must be model space whichever tab is current. On COM the
export used to plot ``ActiveLayout`` for ``layout=None``, so with a sheet
current the "Model" PDF was that sheet again; batch_plot now names the
layout for every row, Model included."""
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _two_sheets(backend)
async def _model_circle_and_sheet_rectangle(backend):
"""Distinct geometry per space, told apart by the PDF's aspect ratio: a
circle in model space (a square page) and a 390 x 270 rectangle on the A3
sheet (a landscape page)."""
await backend.entity_create_circle(50, 50, 25)
await backend.layout_rename("Layout1", "A3")
await backend.page_setup_apply("A3", resolve_page_setup("ISO_A3", "landscape"))
await backend.layout_set_current("A3")
await backend.entity_create_polyline([[10, 10], [400, 10], [400, 280], [10, 280]], closed=True)
def _aspect(mediabox_mm):
width, height = mediabox_mm
return width / height
async def test_the_model_row_is_model_space_even_when_a_sheet_is_current(backend, tmp_path):
"""The Model row must be model space whichever tab is current — proven by
content, not by the row's shape. The headless export used to route the
model target through ``_msp()`` (the *current* tab), so with a sheet
current the "Model" PDF was that sheet's paper-space content: measured
176.1 x 121.9 mm (the rectangle's aspect) against 121.9 x 121.9 (the
circle) with Model current. COM had the mirror defect (``ActiveLayout``
for ``layout=None``). batch_plot names the layout for every row, Model
included, and the engine honours the name."""
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _model_circle_and_sheet_rectangle(backend)
seen: list[str | None] = []
original = backend.drawing_export_pdf
@@ -118,10 +137,39 @@ async def test_model_is_named_to_the_engine_even_when_a_sheet_is_current(backend
return await original(path, layout=layout)
backend.drawing_export_pdf = _recording
result = await batch_plot(backend, ["Model", "A3"], str(tmp_path))
with_sheet_current = await batch_plot(backend, ["Model", "A3"], str(tmp_path / "sheet"))
assert seen == ["Model", "A3"]
assert result["ok"] and result["sheets"][0]["paper"] is None
assert result["sheets"][1]["paper"] == "ISO_A3"
await backend.layout_set_current("Model")
with_model_current = await batch_plot(backend, ["Model"], str(tmp_path / "model"))
model_row = with_sheet_current["sheets"][0]
assert with_sheet_current["ok"] and model_row["paper"] is None
assert model_row["mediabox_mm"] == pytest.approx(
with_model_current["sheets"][0]["mediabox_mm"], abs=0.05
), "the Model row changed with the current tab"
assert _aspect(model_row["mediabox_mm"]) == pytest.approx(1.0, abs=0.02), (
"the Model row is the circle (square page), not the sheet's rectangle"
)
a3_row = with_sheet_current["sheets"][1]
assert a3_row["paper"] == "ISO_A3"
assert _aspect(a3_row["mediabox_mm"]) == pytest.approx(420 / 297, abs=0.02)
async def test_export_pdf_without_a_layout_is_model_space_even_when_a_sheet_is_current(
backend, tmp_path
):
"""``drawing_export_pdf(path)`` advertises model space as its default; a
current sheet must not turn it into that sheet."""
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
from engineering.standards.plot import read_mediabox
await _model_circle_and_sheet_rectangle(backend)
default = tmp_path / "default.pdf"
named = tmp_path / "named.pdf"
assert (await backend.drawing_export_pdf(str(default)))["ok"]
assert (await backend.drawing_export_pdf(str(named), layout="Model"))["ok"]
assert read_mediabox(str(default)) == pytest.approx(read_mediabox(str(named)), abs=0.05)
assert _aspect(read_mediabox(str(default))) == pytest.approx(1.0, abs=0.02)
async def test_a_sheet_the_engine_cannot_plot_is_a_failed_row_not_a_lost_batch(backend, tmp_path):