Tool count 131 -> 154; 474 -> 1242 collected tests. The release exists for two measured reasons. A 149-tool catalog costs a client ~40,300 tokens before it has asked for anything, and a drafter searching the way drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock index, because those words were simply not in the data. Discovery and batching - DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool, ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised. - cad_batch runs a step list in one round trip; fields= projects result-heavy responses. The `core` profile is gone -- discovery replaced it. Honesty debt (Tier 0) - OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and rotated geometry reported coordinates that were quietly wrong. backends/ocs.py translates at the backend boundary; every tool coordinate is WCS on both engines, and a tilted plane refuses rather than answering with a number that looks fine. - drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never repaired. analysis_measure_area shoelaced points the caller typed in. - ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a server whose document lock is a single asyncio.Lock. Architecture - The 1854-line backend ABC is 19 contract modules; @capability(key, reason=) makes optional methods declarative and a gate fails on undeclared holes. - 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch. - Wave A: selection filters, boundary tracing, hatch depth, annotation objects, measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann substitute loses 28.2% of the area on a square with one semicircular edge. Verified against a live AutoCAD 2026, which found four COM defects no amount of reading would have caught -- AcadPViewport has no ViewCenter, and the line that set it had been swallowed by a bare except since v1.4. Benchmark v3 (15 tasks) and what it caught - v2 scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five tasks were added because they can fail, and three did while being written -- a hatch could not be measured though the refusal listed HATCH as measurable, the boundary tools and the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a circle stored as two bulged vertices measured 0.0. - Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and were never asked these five questions; the chart shows those rows `not run`. - Correctness A/B: 21/24 -> 24/24, zero regressions. Performance, stated plainly The published v1.4 perf report was recorded on CPython 3.14 and the new one on 3.11.15, so the apparent 2-6x speedup is the interpreter, not this release. Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22% slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is documented, and winning it back is on the 1.6 roadmap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
394 lines
16 KiB
Python
394 lines
16 KiB
Python
"""M6 — inspecting and editing the viewports on a sheet.
|
|
|
|
`viewport_create` shipped in v1.4 and there was no way to see, rescale, lock or
|
|
remove what it made. Four things in the DXF viewport model make these more than
|
|
attribute pokes, and all four fail silently if ignored:
|
|
|
|
* **The main viewport is not a drafting viewport.** Every page-set-up layout
|
|
carries an `id == 1` entity representing the tab's own pan/zoom state. Its
|
|
`view_height` is not a scale, so writing one reports success and changes
|
|
nothing on the sheet; deleting it leaves `layout.dxf.viewport_handle` pointing
|
|
at a dead handle, which ezdxf's Auditor reports as zero errors — only AutoCAD
|
|
notices. It is reported with `is_main`, never hidden: "I deleted every
|
|
viewport and one is still there" has to be explainable.
|
|
* **R12 has no slot for either attribute.** `view_height` and `flags` are
|
|
dropped on export, and `get_scale()` then returns a fabricated number (150.0
|
|
measured, for a viewport that stores no scale at all). So the writers refuse
|
|
on R12 and the reader reports `None` rather than a number it invented.
|
|
* **`flags` is a shared bitfield.** A bare `flags = VSF_LOCK_ZOOM` wipes the
|
|
UCS-icon and grid bits the main viewport carries (`0x88020`) and still saves
|
|
cleanly. Read-modify-write only.
|
|
* **A deleted entity stays in `entitydb`** with `dxftype()` still answering
|
|
`"VIEWPORT"`, so a resolver that type-checks before checking liveness passes
|
|
on a corpse.
|
|
|
|
Viewports are addressed by handle. `dxf.id` is exposed as metadata only: ezdxf's
|
|
first user viewport gets id 3 rather than 2, and ids are never renumbered.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
SHEET = "A3-Sheet"
|
|
LOCK_BIT = 0x4000
|
|
|
|
|
|
async def _sheet(backend, name: str = SHEET):
|
|
"""A page-set-up A3 layout, which is what gives it a main viewport."""
|
|
await backend.layout_create(name)
|
|
layout = backend._doc.layouts.get(name)
|
|
layout.page_setup(size=(420, 297), margins=(0, 0, 0, 0), units="mm")
|
|
return layout
|
|
|
|
|
|
async def _r12_backend(tmp_path):
|
|
"""An R12 document opened through the backend, with one viewport on a sheet."""
|
|
import ezdxf
|
|
|
|
from backends.ezdxf_backend import EzdxfBackend
|
|
|
|
doc = ezdxf.new("R12")
|
|
doc.layouts.get("Layout1").add_viewport(
|
|
center=(210, 148.5), size=(200, 150), view_center_point=(50, 25), view_height=300
|
|
)
|
|
path = tmp_path / "r12.dxf"
|
|
doc.saveas(str(path))
|
|
|
|
backend = EzdxfBackend()
|
|
await backend.connect()
|
|
await backend.drawing_open(str(path))
|
|
return backend
|
|
|
|
|
|
# ── viewport_list ───────────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_listing_reports_the_viewport_that_was_created(backend):
|
|
await _sheet(backend)
|
|
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25, scale=0.5)
|
|
|
|
result = await backend.viewport_list(SHEET)
|
|
|
|
assert result["ok"] is True
|
|
row = next(vp for vp in result["viewports"] if vp["handle"] == created["handle"])
|
|
assert row["layout"] == SHEET
|
|
assert row["is_main"] is False
|
|
assert row["scale"] == pytest.approx(0.5)
|
|
assert row["view_height"] == pytest.approx(300.0)
|
|
assert row["width"] == pytest.approx(200.0)
|
|
assert row["locked"] is False
|
|
|
|
|
|
async def test_the_main_viewport_is_reported_and_labelled_not_hidden(backend):
|
|
"""Hiding it makes "I deleted them all and one remains" unexplainable."""
|
|
layout = await _sheet(backend)
|
|
main = layout.main_viewport()
|
|
assert main is not None, "fixture assumption: page_setup creates a main viewport"
|
|
|
|
result = await backend.viewport_list(SHEET)
|
|
|
|
rows = {vp["handle"]: vp for vp in result["viewports"]}
|
|
assert rows[main.dxf.handle]["is_main"] is True
|
|
assert sum(1 for vp in rows.values() if vp["is_main"]) == 1
|
|
|
|
|
|
async def test_a_fresh_layout_has_no_phantom_viewport(backend):
|
|
"""The main viewport appears at page_setup, not at layout_create."""
|
|
await backend.layout_create(SHEET)
|
|
result = await backend.viewport_list(SHEET)
|
|
assert result["viewports"] == []
|
|
assert result["count"] == 0
|
|
|
|
|
|
async def test_listing_without_a_layout_covers_every_sheet(backend):
|
|
await _sheet(backend, "S1")
|
|
await _sheet(backend, "S2")
|
|
await backend.viewport_create("S1", 100, 100, 50, 50, 0, 0)
|
|
await backend.viewport_create("S2", 100, 100, 50, 50, 0, 0)
|
|
|
|
result = await backend.viewport_list()
|
|
|
|
assert {vp["layout"] for vp in result["viewports"]} >= {"S1", "S2"}
|
|
|
|
|
|
async def test_listing_a_missing_or_blank_layout_refuses(backend):
|
|
"""ezdxf's `layouts.get("")` quietly returns the active paper space."""
|
|
for name in ("NOSUCH", "", " "):
|
|
result = await backend.viewport_list(name)
|
|
assert result["ok"] is False, f"viewport_list({name!r}) must refuse"
|
|
|
|
|
|
async def test_listing_model_space_refuses(backend):
|
|
result = await backend.viewport_list("Model")
|
|
assert result["ok"] is False
|
|
|
|
|
|
async def test_r12_reports_no_scale_rather_than_inventing_one(tmp_path):
|
|
"""`get_scale()` returns a fabricated number when view_height is absent."""
|
|
backend = await _r12_backend(tmp_path)
|
|
try:
|
|
result = await backend.viewport_list("Layout1")
|
|
assert result["ok"] is True
|
|
row = result["viewports"][0]
|
|
assert row["scale"] is None
|
|
assert row["locked"] is None
|
|
assert "R12" in result["note"]
|
|
finally:
|
|
await backend.disconnect()
|
|
|
|
|
|
# ── viewport_set_scale ──────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_setting_the_scale_changes_the_view_height_and_nothing_else(backend):
|
|
await _sheet(backend)
|
|
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25, scale=1.0)
|
|
before = backend._doc.entitydb.get(created["handle"])
|
|
center, width, height = before.dxf.center, before.dxf.width, before.dxf.height
|
|
view_center = before.dxf.view_center_point
|
|
|
|
result = await backend.viewport_set_scale(created["handle"], 0.5)
|
|
|
|
assert result["ok"] is True
|
|
assert result["scale"] == pytest.approx(0.5)
|
|
assert result["view_height"] == pytest.approx(300.0)
|
|
after = backend._doc.entitydb.get(created["handle"])
|
|
assert after.dxf.center == center
|
|
assert (after.dxf.width, after.dxf.height) == (width, height)
|
|
assert after.dxf.view_center_point == view_center
|
|
|
|
|
|
async def test_the_reported_scale_is_read_back_not_echoed(backend):
|
|
await _sheet(backend)
|
|
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25)
|
|
|
|
result = await backend.viewport_set_scale(created["handle"], 0.25)
|
|
|
|
stored = backend._doc.entitydb.get(created["handle"]).get_scale()
|
|
assert result["scale"] == pytest.approx(stored)
|
|
|
|
|
|
@pytest.mark.parametrize("bad", [0, -1, -0.5])
|
|
async def test_a_non_positive_scale_is_refused(backend, bad):
|
|
await _sheet(backend)
|
|
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25)
|
|
result = await backend.viewport_set_scale(created["handle"], bad)
|
|
assert result["ok"] is False
|
|
|
|
|
|
async def test_setting_the_scale_of_the_main_viewport_is_refused(backend):
|
|
"""Its view_height is the tab's pan/zoom state, not a drafting scale."""
|
|
layout = await _sheet(backend)
|
|
result = await backend.viewport_set_scale(layout.main_viewport().dxf.handle, 0.5)
|
|
assert result["ok"] is False
|
|
assert "main" in result["error"].lower()
|
|
|
|
|
|
async def test_setting_the_scale_on_r12_is_refused_rather_than_lost(tmp_path):
|
|
backend = await _r12_backend(tmp_path)
|
|
try:
|
|
handle = (await backend.viewport_list("Layout1"))["viewports"][0]["handle"]
|
|
result = await backend.viewport_set_scale(handle, 0.5)
|
|
assert result["ok"] is False
|
|
assert "R12" in result["error"]
|
|
finally:
|
|
await backend.disconnect()
|
|
|
|
|
|
async def test_a_handle_that_is_not_a_viewport_is_refused(backend):
|
|
line = await backend.entity_create_line(0, 0, 10, 0)
|
|
result = await backend.viewport_set_scale(line.handle, 0.5)
|
|
assert result["ok"] is False
|
|
assert "LINE" in result["error"]
|
|
|
|
|
|
async def test_an_unknown_handle_is_refused(backend):
|
|
result = await backend.viewport_set_scale("DEADBEEF", 0.5)
|
|
assert result["ok"] is False
|
|
|
|
|
|
# ── viewport_lock ───────────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_locking_and_unlocking_round_trips(backend, tmp_path):
|
|
import ezdxf
|
|
|
|
await _sheet(backend)
|
|
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25)
|
|
|
|
assert (await backend.viewport_lock(created["handle"], True))["locked"] is True
|
|
path = tmp_path / "locked.dxf"
|
|
await backend.drawing_save_as(str(path))
|
|
reloaded = ezdxf.readfile(str(path)).entitydb.get(created["handle"])
|
|
assert reloaded.dxf.flags & LOCK_BIT
|
|
|
|
assert (await backend.viewport_lock(created["handle"], False))["locked"] is False
|
|
assert not (backend._doc.entitydb.get(created["handle"]).dxf.flags & LOCK_BIT)
|
|
|
|
|
|
async def test_locking_preserves_the_other_flag_bits(backend):
|
|
"""A bare assignment wipes the UCS-icon and grid bits and still saves."""
|
|
await _sheet(backend)
|
|
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25)
|
|
entity = backend._doc.entitydb.get(created["handle"])
|
|
entity.dxf.flags = 0x88020
|
|
|
|
await backend.viewport_lock(created["handle"], True)
|
|
|
|
assert backend._doc.entitydb.get(created["handle"]).dxf.flags == 0x88020 | LOCK_BIT
|
|
|
|
|
|
async def test_the_reported_lock_state_is_read_back_from_the_flags(backend):
|
|
await _sheet(backend)
|
|
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25)
|
|
await backend.viewport_lock(created["handle"], True)
|
|
|
|
row = next(
|
|
vp
|
|
for vp in (await backend.viewport_list(SHEET))["viewports"]
|
|
if vp["handle"] == created["handle"]
|
|
)
|
|
assert row["locked"] is True
|
|
|
|
|
|
async def test_locking_on_r12_is_refused_rather_than_lost(tmp_path):
|
|
backend = await _r12_backend(tmp_path)
|
|
try:
|
|
handle = (await backend.viewport_list("Layout1"))["viewports"][0]["handle"]
|
|
result = await backend.viewport_lock(handle, True)
|
|
assert result["ok"] is False
|
|
assert "R12" in result["error"]
|
|
finally:
|
|
await backend.disconnect()
|
|
|
|
|
|
# ── viewport_delete ─────────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_deleting_a_viewport_removes_it_from_the_layout(backend):
|
|
await _sheet(backend)
|
|
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25)
|
|
|
|
result = await backend.viewport_delete(created["handle"])
|
|
|
|
assert result["ok"] is True
|
|
assert result["layout"] == SHEET
|
|
assert result["was_main"] is False
|
|
handles = {vp["handle"] for vp in (await backend.viewport_list(SHEET))["viewports"]}
|
|
assert created["handle"] not in handles
|
|
|
|
|
|
async def test_deleting_the_main_viewport_needs_force(backend):
|
|
layout = await _sheet(backend)
|
|
main = layout.main_viewport()
|
|
|
|
result = await backend.viewport_delete(main.dxf.handle)
|
|
|
|
assert result["ok"] is False
|
|
assert layout.main_viewport() is not None
|
|
|
|
|
|
async def test_forcing_the_main_viewport_away_does_not_leave_a_dangling_pointer(backend):
|
|
"""No auditor reports this; only AutoCAD does."""
|
|
layout = await _sheet(backend)
|
|
main = layout.main_viewport()
|
|
|
|
result = await backend.viewport_delete(main.dxf.handle, force=True)
|
|
|
|
assert result["ok"] is True
|
|
assert result["was_main"] is True
|
|
pointer = layout.dxf.get("viewport_handle", None)
|
|
if pointer is not None:
|
|
target = backend._doc.entitydb.get(pointer)
|
|
assert target is not None and target.is_alive
|
|
|
|
|
|
async def test_a_deleted_viewport_handle_is_refused_afterwards(backend):
|
|
"""The corpse stays in entitydb and still answers dxftype() == VIEWPORT."""
|
|
await _sheet(backend)
|
|
created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25)
|
|
await backend.viewport_delete(created["handle"])
|
|
|
|
result = await backend.viewport_set_scale(created["handle"], 0.5)
|
|
assert result["ok"] is False
|
|
|
|
|
|
async def test_deleting_a_handle_that_is_not_a_viewport_is_refused(backend):
|
|
line = await backend.entity_create_line(0, 0, 10, 0)
|
|
result = await backend.viewport_delete(line.handle)
|
|
assert result["ok"] is False
|
|
assert line.handle in {e.dxf.handle for e in backend._doc.modelspace()}
|
|
|
|
|
|
# ── what the headless renderer can actually do ──────────────────────────────
|
|
|
|
|
|
async def _ink(backend, layout: str, path) -> int:
|
|
"""Dark pixels in a rendered layout — a proxy for "something was drawn"."""
|
|
from PIL import Image
|
|
|
|
result = await backend.drawing_export_pdf(str(path), layout=layout)
|
|
assert result["ok"] is True, result
|
|
image = Image.open(str(path)).convert("L")
|
|
pixels = image.load()
|
|
width, height = image.size
|
|
return sum(1 for y in range(height) for x in range(width) if pixels[x, y] < 200)
|
|
|
|
|
|
async def test_the_headless_renderer_does_project_model_content_through_viewports(
|
|
backend, tmp_path
|
|
):
|
|
"""v1.4 declared this COM-only. It is not, and has not been since ezdxf 1.4.
|
|
|
|
The claim appeared in three places at once — the `viewport_render`
|
|
capability, the note in every `drawing_export_pdf` response, and the README
|
|
table — and it sent callers to the live backend for something the headless
|
|
one already does. Understating a capability is the same class of untruth as
|
|
overstating one: the response says something about the server that is not
|
|
so.
|
|
|
|
Proven by subtraction rather than by pixel-peeping: a sheet whose only
|
|
contents are viewports renders *less* ink once the model geometry it looks
|
|
at is gone.
|
|
"""
|
|
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
|
|
pytest.importorskip("PIL", reason="rendering needs the [pdf] extra")
|
|
|
|
circle = await backend.entity_create_circle(100, 50, 40)
|
|
await _sheet(backend)
|
|
await backend.viewport_create(SHEET, 210, 148.5, 260, 180, 100, 50, scale=1.0)
|
|
assert not [e for e in backend._doc.layouts.get(SHEET) if e.dxftype() != "VIEWPORT"], (
|
|
"the sheet must hold nothing but viewports, or the ink could be its own"
|
|
)
|
|
|
|
with_model = await _ink(backend, SHEET, tmp_path / "with.png")
|
|
await backend.entity_delete(circle.handle)
|
|
without_model = await _ink(backend, SHEET, tmp_path / "without.png")
|
|
|
|
assert with_model > without_model, (
|
|
"the sheet renders the same with and without the model geometry its "
|
|
"viewport looks at — projection really is not happening"
|
|
)
|
|
|
|
|
|
async def test_the_capability_map_no_longer_calls_viewport_rendering_com_only(backend):
|
|
features = backend.capabilities().to_dict()["features"]
|
|
assert features["viewport_render"]["supported"] is True
|
|
|
|
|
|
async def test_the_export_note_does_not_send_callers_to_com_for_this(backend, tmp_path):
|
|
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
|
|
await _sheet(backend)
|
|
result = await backend.drawing_export_pdf(str(tmp_path / "sheet.png"), layout=SHEET)
|
|
assert "COM-only" not in (result.get("note") or "")
|
|
|
|
|
|
async def test_exporting_a_layout_finds_it_whatever_case_it_is_typed_in(backend, tmp_path):
|
|
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
|
|
await _sheet(backend)
|
|
result = await backend.drawing_export_pdf(str(tmp_path / "sheet.png"), layout=SHEET.upper())
|
|
assert result["ok"] is True
|