Files
autocad-mcp-pro/tests/test_mech_part.py
T
Umut CANandClaude Opus 5 04d269318a feat(task-2): the mechanical part model - RevolvedPart, PrismaticPart, build_part
engineering/mech/part.py is the model the whole track draws from: a revolved
stack of segments along +X, or a closed prismatic outline plus a thickness, each
carrying a list of typed features. Pure arithmetic - no backend, no async, no
DXF - which is what lets the view engine be tested without a drawing.

Every refusal fires before anything is drawn and names the offending path the
way pid_from_spec does: a non-finite or non-positive number as
segments[2].d_outer, a bore that is not smaller than its outside (a bore that
changes diameter is two segments), an outline with fewer than three distinct
vertices, an outline that crosses itself (engineering.measure.is_self_intersecting
- the shoelace cancels crossed lobes rather than failing, so a bowtie would
otherwise measure 0.0), an unknown material, an unknown segment key, a duplicate
feature id.

Two gates are the reason this is a model rather than a dict: a feature whose
placement is off the part is refused by its own name and by the range it missed,
and two features that would remove the same material are refused with both names
and the overlapping box. Neither is clamped.

part_to_dict round-trips through build_part, which is what lets mech_part_draw
write the model onto the drawing as ACADMCP_MECH XDATA and mech_view_add read it
back months later without the caller re-describing the part.

Two fixes to the plan's own code and test list, both recorded in deviations:
_features_from imported engineering.mech.features unconditionally, so a
featureless part - every fixture in this module, and both round-trips - died on
ModuleNotFoundError until Task 3 lands; the import now happens only when there
is a feature dict to build. And the plan's test file omitted
test_a_duplicate_feature_id_is_refused while its own gate deselects it by name,
so the duplicate-id refusal the commit message claims went unpinned; the test is
written here and Task 3 turns it green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 18:52:39 +03:00

217 lines
6.8 KiB
Python

"""The part model: construction, round-trip and every refusal the spec names.
Pure arithmetic - no backend, no DXF. The feature surface is exercised through
a local stub so this module's gate (placement on the part, two features
removing the same material) is pinned before `engineering/mech/features.py`
exists.
"""
from __future__ import annotations
from dataclasses import dataclass
import pytest
from engineering.mech.part import (
PrismaticPart,
RevolvedPart,
Segment,
build_part,
inner_radius_at,
outer_radius_at,
outline_bbox,
part_length,
part_max_diameter,
part_to_dict,
point_in_outline,
segment_bounds,
validate_part,
)
EPS = 1e-9
SHAFT = {
"kind": "revolved",
"name": "shaft",
"material": "steel",
"segments": [
{"length": 20.0, "d_outer": 25.0},
{"length": 40.0, "d_outer": 30.0, "d_inner": 10.0},
{"length": 15.0, "d_outer": 20.0, "taper_to": 16.0},
],
}
PLATE = {
"kind": "prismatic",
"name": "plate",
"material": "aluminium",
"thickness": 8.0,
"outline": [[0.0, 0.0], [80.0, 0.0], [80.0, 50.0], [0.0, 50.0]],
}
@dataclass(frozen=True, kw_only=True)
class StubFeature:
"""The duck-typed surface `build_part` uses, and nothing else."""
id: str
segment: int = 0
box: tuple[float, float, float, float] | None = None
def validate(self, part) -> None:
if not 0 <= self.segment < len(part.segments):
raise ValueError(
f"segment {self.segment} is outside the {len(part.segments)}-segment part "
f"(segments 0-{len(part.segments) - 1})."
)
def removal_box(self, part):
return self.box
def to_dict(self) -> dict:
return {"kind": "stub", "id": self.id, "segment": self.segment}
def test_build_part_makes_a_revolved_part_with_typed_segments():
part = build_part(SHAFT)
assert isinstance(part, RevolvedPart)
assert part.name == "shaft"
assert part.material == "steel"
assert part.segments[1] == Segment(40.0, 30.0, 10.0, None)
assert part.segments[2].taper_to == 16.0
assert part.features == ()
def test_build_part_makes_a_prismatic_part_and_drops_a_repeated_closing_vertex():
closed = dict(PLATE, outline=PLATE["outline"] + [[0.0, 0.0]])
part = build_part(closed)
assert isinstance(part, PrismaticPart)
assert len(part.outline) == 4
assert part.thickness == 8.0
assert outline_bbox(part) == (0.0, 0.0, 80.0, 50.0)
@pytest.mark.parametrize("spec", [SHAFT, PLATE])
def test_part_to_dict_round_trips_through_build_part(spec):
part = build_part(spec)
assert build_part(part_to_dict(part)) == part
def test_geometry_helpers_report_the_real_numbers():
part = build_part(SHAFT)
assert part_length(part) == 75.0
assert part_max_diameter(part) == 30.0
assert segment_bounds(part) == ((0.0, 20.0), (20.0, 60.0), (60.0, 75.0))
assert outer_radius_at(part, 10.0) == 12.5
assert inner_radius_at(part, 30.0) == 5.0
assert inner_radius_at(part, 10.0) == 0.0
# the taper runs 20 -> 16 mm over the last 15 mm, so its mid-point is 18 mm
assert outer_radius_at(part, 67.5) == pytest.approx(9.0, abs=EPS)
plate = build_part(PLATE)
assert part_length(plate) == 80.0
assert point_in_outline(plate, 40.0, 25.0) is True
assert point_in_outline(plate, 90.0, 25.0) is False
@pytest.mark.parametrize("bad", [float("inf"), float("nan"), 0.0, -5.0])
def test_a_non_finite_or_non_positive_number_is_refused_by_path(bad):
spec = dict(SHAFT, segments=[dict(s) for s in SHAFT["segments"]])
spec["segments"][2]["d_outer"] = bad
with pytest.raises(ValueError) as excinfo:
build_part(spec)
assert "segments[2].d_outer" in str(excinfo.value)
def test_a_bore_that_is_not_smaller_than_the_outside_is_refused():
spec = dict(SHAFT, segments=[dict(s) for s in SHAFT["segments"]])
spec["segments"][1]["d_inner"] = 30.0
with pytest.raises(ValueError) as excinfo:
build_part(spec)
assert "segments[1].d_inner" in str(excinfo.value)
assert "two segments" in str(excinfo.value)
def test_an_outline_with_fewer_than_three_distinct_vertices_is_refused():
spec = dict(PLATE, outline=[[0.0, 0.0], [10.0, 0.0], [10.0, 0.0]])
with pytest.raises(ValueError, match="at least 3 distinct vertices"):
build_part(spec)
def test_a_self_intersecting_outline_is_refused():
spec = dict(PLATE, outline=[[0.0, 0.0], [10.0, 10.0], [10.0, 0.0], [0.0, 10.0]])
with pytest.raises(ValueError, match="crosses itself"):
build_part(spec)
def test_an_unknown_material_is_refused():
with pytest.raises(ValueError, match="material"):
build_part(dict(SHAFT, material="unobtainium"))
def test_a_feature_off_the_part_is_refused_by_its_own_name():
part = RevolvedPart(
name="shaft",
segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)),
features=(StubFeature(id="keyway_1", segment=5),),
)
with pytest.raises(ValueError) as excinfo:
validate_part(part)
message = str(excinfo.value)
assert "keyway_1" in message
assert "segment 5" in message
assert "segments 0-2" in message
def test_two_features_removing_the_same_material_are_refused_with_both_names():
part = RevolvedPart(
name="shaft",
segments=(Segment(60.0, 30.0),),
features=(
StubFeature(id="groove_a", box=(10.0, 12.0, 14.0, 15.0)),
StubFeature(id="groove_b", box=(12.0, 13.0, 16.0, 15.0)),
),
)
with pytest.raises(ValueError) as excinfo:
validate_part(part)
message = str(excinfo.value)
assert "groove_a" in message and "groove_b" in message
assert "same material" in message
def test_features_that_only_touch_are_not_an_overlap():
part = RevolvedPart(
name="shaft",
segments=(Segment(60.0, 30.0),),
features=(
StubFeature(id="groove_a", box=(10.0, 12.0, 14.0, 15.0)),
StubFeature(id="groove_b", box=(14.0, 12.0, 18.0, 15.0)),
),
)
validate_part(part)
def test_an_unknown_kind_is_refused():
with pytest.raises(ValueError, match="kind"):
build_part(dict(SHAFT, kind="lofted"))
def test_an_unknown_segment_key_is_refused_by_name():
spec = dict(SHAFT, segments=[{"length": 10.0, "d_outer": 20.0, "diameter": 20.0}])
with pytest.raises(ValueError) as excinfo:
build_part(spec)
assert "segments[0]" in str(excinfo.value)
assert "diameter" in str(excinfo.value)
def test_a_duplicate_feature_id_is_refused():
"""The only test here that reaches `feature_from_dict` (Task 3)."""
spec = dict(
SHAFT,
features=[
{"kind": "chamfer", "id": "c1", "at": "start", "size": 2.0},
{"kind": "chamfer", "id": "c1", "at": "end", "size": 2.0},
],
)
with pytest.raises(ValueError, match="duplicate feature id"):
build_part(spec)