fix(task-7): rotation honoured end to end, each view dimensioned once, refusals that name their path

- rotation turns the whole sheet of views about `at` as one rigid body: the
  prims, the recorded WCS `bbox` (plus the sheet-frame `frame_bbox` the next
  view is laid out against), the detail marker, and every dimension intent
  (points, text position, and the linear measuring direction + rotation).
  Measured headless: rotation=90 at (100,100) now draws (70,100)-(100,172),
  records exactly that box, and leaves only the 2 diameter chord defpoints
  off-geometry (was 4, up to 47.5 mm away).
- dimension_part dimensions only views whose record is not `dimensioned`,
  takes `views=[indices]`, refuses a view already dimensioned by name
  (ISO 129-1), writes the flag back, places the hole table once, and refuses
  a fit no target view claims before drawing anything. A view the dimension
  layer emits nothing for (a detail) is reported in `skipped` and left
  unflagged. mech_dimension_part gains the `views` argument.
- draw_prims checks hatch_add_boundary's {"ok": False}: the hatch is removed
  and the failure raised, never left solid through the bore. An engine whose
  capability map says `hatch_edge_paths` is unsupported (the live one) is
  refused before any prim of the view is drawn.
- a view scale other than 1.0 is refused by name in draw_part and add_view
  (views are drawn full size; the scale would only have been recorded).
- add_view's collision step compares real rectangles on both axes and steps
  along the projection axis layout_origin chose, so a third-angle view is
  never shoved back onto the front view and a top view keeps its alignment.
- draw_from_spec validates `at` / `rotation` into the `parts[i]:` path and a
  transaction that cannot open is a ValueError (a ToolError at the tool).

No new ActiveX member is called: the change composes existing contract
members (dimension_linear rotation, entity_delete, capabilities()).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-23 12:20:25 +03:00
co-authored by Claude Opus 5.5
parent 8b14d4ef8d
commit 5388440c27
3 changed files with 613 additions and 65 deletions
+211
View File
@@ -281,3 +281,214 @@ async def test_draw_from_spec_rolls_back_when_a_draw_fails_midway(backend, monke
monkeypatch.undo()
assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)
assert not await entities(backend, type_filter="LINE")
# -- review round: rotation, single dimensioning, honest refusals -------------
TWO_STEP = {
"kind": "revolved",
"name": "shaft",
"material": "steel",
"segments": [{"length": 20.0, "d_outer": 25.0}, {"length": 40.0, "d_outer": 30.0}],
"features": [],
}
TUBE = {
"kind": "revolved",
"name": "tube",
"material": "steel",
"segments": [{"length": 30.0, "d_outer": 60.0, "d_inner": 40.0}],
"features": [],
}
def _geometry_bbox(backend):
xs: list[float] = []
ys: list[float] = []
for e in backend._doc.modelspace():
if e.dxftype() == "LINE":
xs += [e.dxf.start.x, e.dxf.end.x]
ys += [e.dxf.start.y, e.dxf.end.y]
return [min(xs), min(ys), max(xs), max(ys)]
def _defpoints(backend):
return sorted(
(round(e.dxf.get(name).x, 6), round(e.dxf.get(name).y, 6))
for e in backend._doc.modelspace().query("DIMENSION")
for name in ("defpoint2", "defpoint3")
if e.dxf.hasattr(name)
)
def _turn90(point, about):
return (round(about[0] - (point[1] - about[1]), 6), round(about[1] + (point[0] - about[0]), 6))
async def test_rotation_turns_geometry_record_and_dimensions_about_at(backend):
"""rotation=90 about at=(100, 100): the part, its recorded box and every
DIMENSION defpoint are the rotation=0 drawing turned about `at`."""
from backends.ezdxf_backend import EzdxfBackend
flat = EzdxfBackend()
await flat.connect()
await flat.drawing_new()
try:
await draw_part(flat, build_part(TWO_STEP), at=(100.0, 100.0), rotation=0.0)
expected = sorted(_turn90(p, (100.0, 100.0)) for p in _defpoints(flat))
finally:
await flat.disconnect()
result = await draw_part(backend, build_part(TWO_STEP), at=(100.0, 100.0), rotation=90.0)
geometry = _geometry_bbox(backend)
assert geometry == pytest.approx([70.0, 100.0, 100.0, 172.0])
assert result["views"][0]["bbox"] == pytest.approx(geometry)
read = await read_part(backend, result["part_id"])
assert read["views"][0]["bbox"] == pytest.approx(geometry)
assert read["views"][0]["frame_bbox"] == pytest.approx([100.0, 100.0, 172.0, 130.0])
assert expected
assert _defpoints(backend) == expected
async def test_a_view_added_to_a_rotated_part_turns_with_it(backend):
drawn = await draw_part(
backend, build_part(TWO_STEP), at=(0.0, 0.0), rotation=90.0, dimension=False
)
added = await add_view(backend, drawn["part_id"], "side")
# first angle: the side view sits left of the front in the part's frame,
# which is BELOW it once the sheet is turned 90 degrees CCW
front, side = drawn["views"][0]["bbox"], added["view"]["bbox"]
assert side[3] <= front[1] + 1e-6
async def test_draw_then_add_view_then_dimension_never_duplicates(backend):
drawn = await draw_part(backend, build_part(SHAFT), at=(0.0, 0.0), dimension=True)
before = len(await entities(backend, type_filter="DIMENSION"))
assert before == drawn["dimensions"] > 0
await add_view(backend, drawn["part_id"], "side")
result = await dimension_part(backend, drawn["part_id"])
assert result["views_dimensioned"] == [1]
after = len(await entities(backend, type_filter="DIMENSION"))
assert after == before + result["count"]
assert result["count"] > 0
with pytest.raises(ValueError, match="already dimensioned"):
await dimension_part(backend, drawn["part_id"])
with pytest.raises(ValueError, match=r"0 \(front\) already dimensioned"):
await dimension_part(backend, drawn["part_id"], views=[0])
assert len(await entities(backend, type_filter="DIMENSION")) == after
async def test_dimension_part_twice_is_refused_not_stacked(backend):
drawn = await draw_part(backend, build_part(PLATE), at=(0.0, 0.0), dimension=False)
first = await dimension_part(backend, drawn["part_id"])
with pytest.raises(ValueError, match="every view is already dimensioned"):
await dimension_part(backend, drawn["part_id"])
assert len(await entities(backend, type_filter="DIMENSION")) == first["count"]
async def test_a_view_the_dimension_layer_cannot_dimension_is_reported_not_flagged(backend):
drawn = await draw_part(backend, build_part(SHAFT), at=(0.0, 0.0))
await add_view(
backend,
drawn["part_id"],
"detail",
detail={"center": [20.0, 12.5], "radius": 8.0, "scale": 5.0, "label": "D"},
)
result = await dimension_part(backend, drawn["part_id"])
assert result["views_dimensioned"] == []
assert any(row.get("kind") == "detail" for row in result["skipped"])
assert result["count"] == 0
async def test_an_island_that_does_not_attach_removes_the_hatch_and_raises(backend, monkeypatch):
drawn = await draw_part(backend, build_part(TUBE), at=(0.0, 0.0), dimension=False)
real = backend.hatch_add_boundary
async def refuse(handle, edges):
return await real(handle, [])
monkeypatch.setattr(backend, "hatch_add_boundary", refuse)
with pytest.raises(ValueError, match="did not attach"):
await add_view(
backend,
drawn["part_id"],
"section",
plane={"p1": [15.0, -40.0], "p2": [15.0, 40.0], "label": "B"},
)
assert not await entities(backend, type_filter="HATCH")
async def test_an_engine_without_hatch_edge_paths_refuses_before_drawing(backend, monkeypatch):
from backends.base import CapabilityMap, FeatureCapability
from backends.capability import UnsupportedCapabilityError
drawn = await draw_part(backend, build_part(TUBE), at=(0.0, 0.0), dimension=False)
count = len(await entities(backend))
monkeypatch.setattr(
backend,
"capabilities",
lambda: CapabilityMap(
"com", {"hatch_edge_paths": FeatureCapability(False, reason="measured")}
),
)
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await add_view(
backend,
drawn["part_id"],
"section",
plane={"p1": [15.0, -40.0], "p2": [15.0, 40.0], "label": "B"},
)
assert excinfo.value.capability == "hatch_edge_paths"
assert len(await entities(backend)) == count
async def test_a_scale_that_would_only_be_recorded_is_refused(backend):
with pytest.raises(ValueError, match="full size"):
await draw_part(backend, build_part(PLATE), scale=0.5)
drawn = await draw_part(backend, build_part(PLATE), dimension=False)
with pytest.raises(ValueError, match="full size"):
await add_view(backend, drawn["part_id"], "side", scale=2.0)
async def test_a_second_added_view_steps_along_its_own_projection_axis(backend):
drawn = await draw_part(
backend, build_part(PLATE), at=(0.0, 0.0), dimension=False, projection="third"
)
side = await add_view(backend, drawn["part_id"], "side")
section = await add_view(
backend,
drawn["part_id"],
"section",
plane={"p1": [0.0, 25.0], "p2": [80.0, 25.0], "label": "A"},
)
front = drawn["views"][0]["bbox"]
# third angle puts both to the RIGHT; the second steps further right,
# never back onto the front view
assert section["view"]["bbox"][0] >= side["view"]["bbox"][2] + 20.0 - 1e-6
assert section["view"]["bbox"][0] > front[2]
async def test_a_top_view_sharing_the_x_range_is_not_shoved(backend):
drawn = await draw_part(backend, build_part(PLATE), at=(0.0, 0.0), dimension=False)
await add_view(backend, drawn["part_id"], "side")
top = await add_view(backend, drawn["part_id"], "top", gap=25.0)
front = drawn["views"][0]["bbox"]
assert top["view"]["bbox"][0] == pytest.approx(front[0])
assert top["view"]["bbox"][2] == pytest.approx(front[2])
@pytest.mark.parametrize("at", [[], [5.0], {"x": 1}, "0,0", [1.0, "a"]])
async def test_a_malformed_at_is_refused_with_its_part_path(backend, at):
with pytest.raises(ValueError, match=r"parts\[0\]: at"):
await draw_from_spec(backend, {"parts": [{"part": PLATE, "at": at}]})
assert not await entities(backend, type_filter="POINT", layer=ANCHOR_LAYER)
async def test_a_transaction_that_cannot_open_is_a_value_error(backend, monkeypatch):
async def busy():
return {"ok": False, "error": "a transaction is already open"}
monkeypatch.setattr(backend, "transaction_begin", busy)
with pytest.raises(ValueError, match="could not open a transaction"):
await draw_from_spec(backend, {"parts": [{"part": PLATE, "at": [0.0, 0.0]}]})