From 86c5d6ad656260e4f8ed6fb54a4d68e4ded88abd Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 21:47:05 +0300 Subject: [PATCH] fix(task-18): the headless Model plot is doc.modelspace(), not the current tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 12 ++++++- engineering/standards/plot.py | 5 ++- tests/test_batch_plot.py | 68 +++++++++++++++++++++++++++++------ 3 files changed, 73 insertions(+), 12 deletions(-) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 3c5352d..66119df 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -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 diff --git a/engineering/standards/plot.py b/engineering/standards/plot.py index bec01ef..3fe29cd 100644 --- a/engineering/standards/plot.py +++ b/engineering/standards/plot.py @@ -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 diff --git a/tests/test_batch_plot.py b/tests/test_batch_plot.py index 7af9a84..7092e69 100644 --- a/tests/test_batch_plot.py +++ b/tests/test_batch_plot.py @@ -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):