Files
autocad-mcp-pro/tests/test_hatch_depth.py
Umut CANandClaude Opus 5 b03a7615b1 release: v1.5.0 — discovery layer, honesty debt closed, Wave A
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>
2026-08-06 09:45:20 +03:00

365 lines
15 KiB
Python

"""F4 — hatch depth: gradient fill, in-place edit, curved boundary edges.
`entity_create_hatch` can do exactly one thing: stamp a pattern fill into a
closed *polyline* boundary. Everything a section drawing actually needs after
that — restyling the fill, changing the island rule, bounding the fill with an
arc — has no tool. These three fill that gap, and each carries a measured trap:
* **Gradient fill is a replacement, not a layer.** `set_gradient()` flips
`solid_fill` to 1, rewrites `pattern_name` to `SOLID` and drops `.pattern` to
``None``. A tool that reported "gradient applied" while leaving the caller
believing the ANSI31 hatching is still underneath would be describing a
drawing that does not exist. Every gradient assertion here goes through
saveas/readfile, because gradient data lives in its own DXF subrecord and a
gradient that vanishes on save is the classic failure mode.
* **`pattern_scale` is two numbers, not one.** ezdxf stores the scale twice:
as `dxf.pattern_scale` *and* baked into the pattern definition lines' offset
vectors. Setting only the DXF attribute is the perfect silent no-op — every
report says scale 4.0, every renderer draws scale 2.0. So the scale and angle
tests measure the pattern line *pitch* and *angle*, not the attribute.
Measured here: ANSI31 at scale 1.0 has a 3.175 mm pitch; scale 2.0 is 6.35.
ezdxf's `set_pattern_scale`/`set_pattern_angle` are absolute, not relative.
* **A partial edit must stay partial.** `hatch_edit` takes five optional
parameters; the data-loss bug is the implementation that rebuilds the hatch
from defaults and quietly resets the four the caller did not name. Note that
a hatch born from `entity_create_hatch` already has `hatch_style == 1`
(OUTER), because that is ezdxf's `set_pattern_fill` default — so "normal" is
a real change, not a no-op, and `changed` has to distinguish a parameter that
was *supplied* from an attribute that actually *moved*.
* **Flat vertex lists straighten curves.** This is the fidelity point of
`hatch_add_boundary`. A hatch boundary API that only accepts points turns
every fillet and bore into a chord and still reports a boundary. So the arc
test asserts the reloaded path is an `EdgePath` holding an `ArcEdge` with its
radius and sweep intact — a flattened arc would come back as a `PolylinePath`
and pass any "did a path appear?" check.
"""
from __future__ import annotations
import math
import pytest
pytestmark = pytest.mark.asyncio
SQUARE = [[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0]]
RED = [255, 0, 0]
BLUE = [0, 0, 255]
def _entity(backend, handle):
return backend._doc.entitydb.get(handle)
async def _reloaded(backend, tmp_path, handle, name="hatch.dxf"):
"""Round-trip the whole document and hand back the same entity."""
import ezdxf
path = tmp_path / name
await backend.drawing_save_as(str(path))
return ezdxf.readfile(str(path)).entitydb.get(handle)
def _pattern_pitch(hatch):
"""Distance between the hatch pattern's definition lines, in drawing units."""
offset = hatch.pattern.lines[0].offset
return math.hypot(offset[0], offset[1])
def _path_types(hatch):
return [type(path).__name__ for path in hatch.paths]
# ── gradient fill ───────────────────────────────────────────────────────────
async def test_a_gradient_survives_save_and_reload(backend, tmp_path):
"""Gradient data lives in its own subrecord; losing it on save is silent."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_set_gradient(
created.handle, RED, BLUE, rotation=30.0, tint=0.4, name="CURVED"
)
assert result["ok"] is True
assert result["gradient"]["name"] == "CURVED"
assert list(result["gradient"]["color1"]) == RED
reloaded = await _reloaded(backend, tmp_path, created.handle)
gradient = reloaded.gradient
assert gradient is not None, "the gradient did not survive the round-trip"
assert tuple(gradient.color1) == (255, 0, 0)
assert tuple(gradient.color2) == (0, 0, 255)
assert gradient.rotation == pytest.approx(30.0)
assert gradient.tint == pytest.approx(0.4)
assert gradient.name == "CURVED"
async def test_a_gradient_replaces_the_pattern_fill_it_lands_on(backend, tmp_path):
"""A hatch is gradient-filled *or* pattern-filled. Never both."""
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0)
assert _entity(backend, created.handle).dxf.pattern_name == "ANSI31"
await backend.hatch_set_gradient(created.handle, RED, BLUE)
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert reloaded.pattern is None, "the ANSI31 line definition is still there"
assert reloaded.dxf.solid_fill == 1
assert reloaded.dxf.pattern_name == "SOLID"
async def test_a_gradient_keeps_the_boundary_it_fills(backend, tmp_path):
"""A gradient with no boundary paints nothing at all."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
await backend.hatch_set_gradient(created.handle, RED, BLUE)
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert _path_types(reloaded) == ["PolylinePath"]
assert len(reloaded.paths[0].vertices) == len(SQUARE)
async def test_a_one_colour_gradient_stores_its_tint(backend, tmp_path):
"""one_color+tint is a distinct DXF mode; dropping the tint flattens it."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_set_gradient(
created.handle, [10, 20, 30], [0, 0, 0], one_color=True, tint=0.75, name="SPHERICAL"
)
assert result["ok"] is True
gradient = (await _reloaded(backend, tmp_path, created.handle)).gradient
assert int(gradient.one_color) == 1
assert gradient.tint == pytest.approx(0.75)
assert gradient.name == "SPHERICAL"
async def test_a_gradient_on_a_line_is_refused_naming_the_type(backend):
"""Only a HATCH carries gradient data; a LINE would swallow it silently."""
line = await backend.entity_create_line(0, 0, 10, 0)
result = await backend.hatch_set_gradient(line.handle, RED, BLUE)
assert result["ok"] is False
assert "LINE" in result["error"]
assert "HATCH" in result["error"]
# ── hatch edit ──────────────────────────────────────────────────────────────
@pytest.mark.parametrize("style, expected", [("normal", 0), ("outer", 1), ("ignore", 2)])
async def test_each_island_style_maps_to_its_dxf_value(backend, tmp_path, style, expected):
created = await backend.entity_create_hatch("ANSI31", SQUARE)
# Start from a different style so every case is a real move, not a no-op.
_entity(backend, created.handle).dxf.hatch_style = (expected + 1) % 3
result = await backend.hatch_edit(created.handle, style=style)
assert result["ok"] is True
assert "style" in result["changed"]
reloaded = await _reloaded(backend, tmp_path, created.handle, f"style-{style}.dxf")
assert reloaded.dxf.hatch_style == expected
async def test_editing_the_scale_leaves_every_other_attribute_alone(backend):
"""A partial edit that resets what it was not asked about is data loss."""
created = await backend.entity_create_hatch(
"ANSI31", SQUARE, scale=2.0, angle=15.0, layer="HATCH", color=3
)
style_before = _entity(backend, created.handle).dxf.hatch_style
await backend.hatch_edit(created.handle, scale=4.0)
hatch = _entity(backend, created.handle)
assert hatch.dxf.pattern_name == "ANSI31"
assert hatch.dxf.pattern_angle == pytest.approx(15.0)
assert hatch.dxf.color == 3
assert hatch.dxf.layer == "HATCH"
assert hatch.dxf.hatch_style == style_before
assert len(hatch.paths) == 1
async def test_a_new_scale_rescales_the_pattern_definition(backend, tmp_path):
"""Setting only dxf.pattern_scale is the perfect silent no-op."""
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0)
assert _pattern_pitch(_entity(backend, created.handle)) == pytest.approx(6.35)
await backend.hatch_edit(created.handle, scale=4.0)
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert reloaded.dxf.pattern_scale == pytest.approx(4.0)
assert _pattern_pitch(reloaded) == pytest.approx(12.7), "the lines did not move apart"
async def test_a_new_angle_rotates_the_pattern_definition(backend, tmp_path):
"""Same trap as scale: the angle is stored twice and must move twice."""
created = await backend.entity_create_hatch("ANSI31", SQUARE, angle=15.0)
assert _entity(backend, created.handle).pattern.lines[0].angle == pytest.approx(60.0)
await backend.hatch_edit(created.handle, angle=30.0)
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert reloaded.dxf.pattern_angle == pytest.approx(30.0)
assert reloaded.pattern.lines[0].angle == pytest.approx(75.0), "the lines did not rotate"
async def test_a_new_pattern_and_colour_round_trip(backend, tmp_path):
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0, color=3)
result = await backend.hatch_edit(created.handle, pattern="ANSI32", color=5)
assert result["ok"] is True
assert set(result["changed"]) == {"pattern", "color"}
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert reloaded.dxf.pattern_name == "ANSI32"
assert reloaded.dxf.color == 5
assert reloaded.dxf.pattern_scale == pytest.approx(2.0), "the scale was reset"
async def test_changed_lists_only_the_attributes_that_moved(backend):
""" "I was asked to set it" and "it is now different" are different answers."""
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0, angle=15.0)
result = await backend.hatch_edit(created.handle, scale=4.0, angle=15.0)
assert result["ok"] is True
assert set(result["changed"]) == {"scale"}
async def test_an_edit_that_moves_nothing_reports_nothing(backend):
created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0)
result = await backend.hatch_edit(created.handle, scale=2.0)
assert result["ok"] is True
assert result["changed"] == []
@pytest.mark.parametrize("bad", [0.0, -1.0])
async def test_a_non_positive_pattern_scale_is_refused(backend, bad):
"""Scale 0 collapses the definition offsets to zero: an infinite line count."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_edit(created.handle, scale=bad)
assert result["ok"] is False
assert "scale" in result["error"]
async def test_an_unknown_island_style_is_refused_naming_the_valid_ones(backend):
"""Silently falling back to `normal` would fill islands nobody asked to fill."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_edit(created.handle, style="outermost")
assert result["ok"] is False
for valid in ("normal", "outer", "ignore"):
assert valid in result["error"]
# ── boundary edges ──────────────────────────────────────────────────────────
ARC_EDGE = {
"type": "arc",
"center": [110.0, 0.0],
"radius": 10.0,
"start_angle": 0.0,
"end_angle": 180.0,
"ccw": True,
}
LINE_EDGE = {"type": "line", "start": [100.0, 0.0], "end": [120.0, 0.0]}
async def test_an_arc_boundary_edge_survives_as_an_arc(backend, tmp_path):
"""The fidelity point: a flattened arc comes back as a PolylinePath."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_add_boundary(created.handle, [LINE_EDGE, ARC_EDGE])
assert result["ok"] is True
reloaded = await _reloaded(backend, tmp_path, created.handle)
assert _path_types(reloaded) == ["PolylinePath", "EdgePath"]
edges = reloaded.paths[1].edges
assert [type(edge).__name__ for edge in edges] == ["LineEdge", "ArcEdge"]
arc = edges[1]
assert arc.radius == pytest.approx(10.0)
assert tuple(arc.center)[:2] == pytest.approx((110.0, 0.0))
assert arc.start_angle == pytest.approx(0.0)
assert arc.end_angle == pytest.approx(180.0)
assert arc.ccw is True
async def test_the_added_path_is_counted_alongside_the_existing_one(backend):
"""path_count is the hatch's total, so the original boundary is still there."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_add_boundary(created.handle, [LINE_EDGE, ARC_EDGE])
assert result["handle"] == created.handle
assert result["path_count"] == 2
assert result["edge_types"] == ["line", "arc"]
async def test_an_ellipse_boundary_edge_keeps_its_ratio(backend, tmp_path):
"""The minor/major ratio *is* the ellipse; drop it and you have a circle."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
edge = {
"type": "ellipse",
"center": [200.0, 100.0],
"major_axis": [30.0, 0.0],
"ratio": 0.4,
"start_angle": 0.0,
"end_angle": 360.0,
"ccw": True,
}
result = await backend.hatch_add_boundary(created.handle, [edge])
assert result["edge_types"] == ["ellipse"]
reloaded = await _reloaded(backend, tmp_path, created.handle)
stored = reloaded.paths[1].edges[0]
assert type(stored).__name__ == "EllipseEdge"
assert stored.ratio == pytest.approx(0.4)
assert tuple(stored.major_axis)[:2] == pytest.approx((30.0, 0.0))
assert tuple(stored.center)[:2] == pytest.approx((200.0, 100.0))
async def test_an_unknown_edge_type_is_refused_naming_the_valid_ones(backend):
"""Skipping the edge it cannot build would leave an open boundary."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_add_boundary(
created.handle, [{"type": "bezier", "start": [0, 0], "end": [1, 1]}]
)
assert result["ok"] is False
assert "bezier" in result["error"]
for valid in ("line", "arc", "ellipse"):
assert valid in result["error"]
async def test_an_edge_missing_a_key_is_refused_naming_the_key(backend):
"""An arc defaulted to radius 0 is a boundary that encloses nothing."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_add_boundary(
created.handle,
[{"type": "arc", "center": [0.0, 0.0], "start_angle": 0.0, "end_angle": 90.0}],
)
assert result["ok"] is False
assert "radius" in result["error"]
async def test_a_boundary_with_no_edges_is_refused(backend):
"""An empty edge path bounds nothing and renders as a hatch with a hole."""
created = await backend.entity_create_hatch("ANSI31", SQUARE)
result = await backend.hatch_add_boundary(created.handle, [])
assert result["ok"] is False