Files
autocad-mcp-pro/tests/test_mech_dimension.py
T
Umut CANandClaude Opus 5 bd73b3a854 fix(task-6): a reachable ISO 286 route, a tolerance schema that refuses, a reference dimension
Three review findings, all root causes in `engineering/mech/dimension.py`.

1. The ISO 286 fit path was unreachable from the module's own pipeline.
   `resolve_tolerance` resolved `intent.fit` correctly, but nothing produced a
   DimIntent carrying one: no feature and no segment has a fit, and
   `dimension_intents` constructed every intent without `fit=`. Only a
   hand-built DimIntent - which is what the tests used - reached the code, so
   the spec's `mech_iso286_on_dimension` and the `mech_assembly` benchmark's
   "ISO 286 fits on the bearing seats" had no route.

   `dimension_intents(..., fits={key: code})` now stamps the code onto the
   intent its key names, and the part's own diameters became addressable one
   at a time through the new `segment_key(i, bore=...)` -> "segment[1]",
   "segment[1].bore"; a feature's stay addressable by id. A key that names
   nothing is refused with the names that exist, a malformed code is refused
   where it is attached rather than at draw time, a fit on a reference
   dimension or on one that already carries a tol is refused, and a fit on a
   hole whose diameter moved into the hole table (which has no tolerance
   column) is refused by name instead of vanishing. Task 7's planned
   `dimension_part(..., fits=None)` passes straight through - the argument is
   additive and keyword-only, so every call written against the pinned
   signature keeps working.

   The fit also now reaches the *text*: `intent_text` expands the `<>`
   placeholder and appends the code, so `text_rects` measures the string that
   is actually drawn and a hole that already names itself ("⌀6") keeps its own
   text and gains the code.

2. An explicit tolerance was silently discarded when `tol` omitted "mode".
   `mode = tol.get("mode", "none")` and `build_dim_override(0.05, 0.05,
   "none", None)` returns `({}, None)` without raising, so a ±0.05 resolved,
   echoed its own numbers back in the payload, and drew a dimension with no
   DIMTOL/DIMTP/DIMTM at all. `DimIntent.tol` now has a schema - {"mode",
   "upper", "lower"} and nothing else - validated by `_explicit_tol`: an
   unknown key, a missing "mode", a non-dict, and deviations under a mode that
   writes none are each refused by name. The mutual-exclusion guard reads
   `tol is not None` rather than truthiness, so `tol={}` is refused too.

3. The ISO 129-1 "no measurement twice" test was vacuous and the property was
   false. The old test built the key `_dedupe` uniquifies as the last step of
   `dimension_intents`, so it could not fail; meanwhile `style="chain"` emitted
   20, 40, 15 *and* 75 as four plain DIMENSIONs. `redundant_linear` now finds
   the span that shorter ones tile end to end on the same axis and level, and
   `dimension_intents` marks it as an auxiliary (reference) dimension - the new
   `REFERENCE_FORMAT` "(<>)", which renders "(75)". The plan's own test still
   holds (the 75 is present); it is no longer a fourth plain dimension. The
   error is one-sided on purpose: a tiling that is found is real, a tiling the
   greedy walk misses leaves the dimension plain, which is the direction the
   repository's critique rule wants. Baseline and ordinate parenthesise
   nothing, and two horizontal chains at different heights are not each other's
   pieces.

Tests: the vacuous gate is replaced by four that fail against the pre-fix code
- `redundant_linear` on the shaft's plain intents returns (3,) there and ()
here; the parametrised "a tolerance is either refused or reaches the dimension
variables" gate resolves the mode-less shape to an empty DIMVAR override
there; and `fits=` is a TypeError there. `_dedupe` is now exercised through a
duplicate fed in via the view's own dims, which the collector does not
generate itself. 46 tests in tests/test_mech_dimension.py (was 23).

Live AutoCAD: no finding was measured live and no new ActiveX call was
written - this module is pure Python with no backend, no async and no DXF. A
DIMTOL probe against the running seat was attempted twice and
`GetActiveObject("AutoCAD.Application")` blocked past the ~90 s contention
window both times, so the live engine was unavailable; no scratch document was
created (the probe never got the application object) and none was left open.

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

515 lines
19 KiB
Python

"""Dimension intents, ISO 286 fits, the hole table and the overlap-free layout.
The layout test is the one that matters: it asserts the placed text rectangles
are pairwise disjoint, computed by the same helper the layout uses, so "no
overlapping dimension text" is a measured property rather than a claim.
"""
from __future__ import annotations
import dataclasses
import itertools
import pytest
from engineering.mech.dimension import (
DIM_STYLES,
dimension_intents,
hole_table_rows,
intent_text,
is_reference,
layout_dimensions,
measured_value,
redundant_linear,
resolve_tolerance,
segment_key,
text_rects,
)
from engineering.mech.features import Hole, Keyway
from engineering.mech.part import PrismaticPart, RevolvedPart, Segment
from engineering.mech.primitives import DimIntent
from engineering.mech.views import build_view
from engineering.tolerances import build_dim_override
def shaft(*features) -> RevolvedPart:
return RevolvedPart(
name="shaft",
segments=(Segment(20.0, 25.0), Segment(40.0, 30.0), Segment(15.0, 20.0)),
features=tuple(features),
)
def plate(*holes) -> PrismaticPart:
return PrismaticPart(
name="plate",
outline=((0.0, 0.0), (80.0, 0.0), (80.0, 50.0), (0.0, 50.0)),
thickness=8.0,
features=tuple(holes),
)
def grid(count: int, diameter: float = 6.0):
return tuple(Hole(id=f"h{i}", x=5.0 + 7.0 * i, y=25.0, diameter=diameter) for i in range(count))
def test_the_three_styles_are_the_ones_the_spec_names():
assert DIM_STYLES == ("chain", "baseline", "ordinate")
def test_measured_value_and_text_follow_the_intent():
linear = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0))
assert measured_value(linear) == 20.0
assert intent_text(linear) == "20"
override = DimIntent(kind="diameter", p1=(-20.0, 0.0), p2=(20.0, 0.0), text_override="M20")
assert measured_value(override) == 40.0
assert intent_text(override) == "M20"
# -- ISO 286 fits and ISO 129 tolerances -------------------------------------
def test_a_h7_fit_on_a_40_mm_bore_resolves_to_plus_25_microns_over_zero():
intent = DimIntent(kind="diameter", p1=(-20.0, 0.0), p2=(20.0, 0.0), fit="H7")
resolved = resolve_tolerance(intent)
assert resolved["tol_upper"] == pytest.approx(0.025)
assert resolved["tol_lower"] == pytest.approx(0.0)
assert resolved["tol_mode"] == "deviation"
assert resolved["text_override"] == "<> H7"
def test_a_shaft_fit_keeps_the_build_dim_override_sign_convention():
intent = DimIntent(kind="diameter", p1=(-10.0, 0.0), p2=(10.0, 0.0), fit="g6")
resolved = resolve_tolerance(intent)
# ISO 286 g6 at 20 mm: es = -0.007, ei = -0.020; build_dim_override wants the
# lower deviation as a positive magnitude of the minus side.
assert resolved["tol_upper"] == pytest.approx(-0.007)
assert resolved["tol_lower"] == pytest.approx(0.020)
def test_an_explicit_tolerance_passes_through_and_is_validated():
intent = DimIntent(
kind="linear",
p1=(0.0, 0.0),
p2=(30.0, 0.0),
tol={"mode": "symmetric", "upper": 0.1},
)
resolved = resolve_tolerance(intent)
assert resolved["tol_mode"] == "symmetric"
assert resolved["tol_upper"] == pytest.approx(0.1)
bad = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={"mode": "loose"})
with pytest.raises(ValueError, match="tol_mode"):
resolve_tolerance(bad)
def test_a_fit_and_an_explicit_tolerance_together_are_refused():
intent = DimIntent(
kind="diameter",
p1=(-20.0, 0.0),
p2=(20.0, 0.0),
fit="H7",
tol={"mode": "symmetric", "upper": 0.1},
)
with pytest.raises(ValueError, match="not both"):
resolve_tolerance(intent)
def test_a_fit_outside_the_iso_286_tables_is_refused_by_name():
intent = DimIntent(kind="diameter", p1=(-400.0, 0.0), p2=(400.0, 0.0), fit="H7")
with pytest.raises(ValueError, match="ISO 286"):
resolve_tolerance(intent)
def test_a_fit_with_no_measurable_nominal_is_refused_rather_than_guessed():
intent = DimIntent(kind="diameter", p1=(5.0, 0.0), p2=(5.0, 0.0), fit="H7")
with pytest.raises(ValueError, match="nominal"):
resolve_tolerance(intent)
@pytest.mark.parametrize(
"tol",
[
{"mode": "symmetric", "upper": 0.05},
{"mode": "deviation", "upper": 0.05, "lower": 0.02},
{"mode": "limit", "upper": 30.05, "lower": 29.98},
{"upper": 0.05, "lower": 0.05}, # the shape whose tolerance used to vanish
{"mode": "none", "upper": 0.05},
{},
],
)
def test_a_tolerance_is_either_refused_or_reaches_the_dimension_variables(tol):
"""Accepted and then written as nothing is the one answer that is not allowed.
`build_dim_override` defaults a missing mode to "none" and answers with an
empty override, so a tol with no mode used to resolve, echo its own numbers
back and draw a dimension carrying no DIMTOL/DIMTP/DIMTM at all.
"""
intent = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol=tol)
try:
resolved = resolve_tolerance(intent)
except ValueError:
return # refused up front, which is the other legal answer
override, _ = build_dim_override(
resolved["tol_upper"],
resolved["tol_lower"],
resolved["tol_mode"],
resolved["text_override"],
)
assert override, f"{tol} resolved to {resolved} and wrote no dimension variable"
def test_a_tolerance_with_no_mode_is_refused_rather_than_silently_discarded():
intent = DimIntent(
kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={"upper": 0.05, "lower": 0.05}
)
with pytest.raises(ValueError, match="needs an explicit 'mode'"):
resolve_tolerance(intent)
def test_an_empty_tol_is_refused_rather_than_read_as_no_tolerance():
intent = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={})
with pytest.raises(ValueError, match="mode"):
resolve_tolerance(intent)
def test_a_tol_key_outside_the_schema_is_refused_by_name():
intent = DimIntent(
kind="linear",
p1=(0.0, 0.0),
p2=(30.0, 0.0),
tol={"mode": "symmetric", "upper": 0.05, "plus": 0.05},
)
with pytest.raises(ValueError, match="plus"):
resolve_tolerance(intent)
def test_deviations_under_a_mode_that_writes_none_are_refused():
intent = DimIntent(
kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol={"mode": "none", "upper": 0.05}
)
with pytest.raises(ValueError, match="writes no deviation"):
resolve_tolerance(intent)
def test_a_tol_that_is_not_a_dict_is_refused():
intent = DimIntent(kind="linear", p1=(0.0, 0.0), p2=(30.0, 0.0), tol=0.05)
with pytest.raises(ValueError, match="expected a dict"):
resolve_tolerance(intent)
# -- the ISO 286 route from the part to the dimension ------------------------
def bush() -> RevolvedPart:
"""A 60 mm bore housing: one segment, ⌀60 outside, ⌀40 bore."""
return RevolvedPart(name="bush", segments=(Segment(60.0, 60.0, 40.0),))
def test_a_fit_reaches_a_dimension_through_the_modules_own_pipeline():
"""`mech_iso286_on_dimension`, end to end: a ⌀40 H7 bore carries +0.025 / 0.
`resolve_tolerance` always resolved a fit; nothing produced an intent that
carried one, so the headline claim was reachable only from a hand-built
DimIntent - which is what the tests used.
"""
part = bush()
view = build_view(part, "front")
intents = dimension_intents(part, view, fits={segment_key(0, bore=True): "H7"})
fitted = [i for i in intents if i.fit]
assert len(fitted) == 1
assert measured_value(fitted[0]) == pytest.approx(40.0)
resolved = resolve_tolerance(fitted[0])
assert resolved["tol_upper"] == pytest.approx(0.025)
assert resolved["tol_lower"] == pytest.approx(0.0)
assert resolved["tol_mode"] == "deviation"
assert resolved["text_override"] == "<> H7"
# the fit has to survive the layout, or the number is right in the table
# and absent from the sheet
placed = layout_dimensions(view, intents)
assert [i.fit for i in placed if i.fit] == ["H7"]
def test_a_bearing_seat_takes_its_fit_by_segment():
part = shaft()
view = build_view(part, "front")
intents = dimension_intents(part, view, fits={segment_key(1): "k6"})
seat = [i for i in intents if i.fit]
assert [i.feature for i in seat] == ["segment[1]"]
assert measured_value(seat[0]) == pytest.approx(30.0)
assert intent_text(seat[0]) == "⌀30 k6"
def test_a_fit_on_a_feature_id_lands_on_that_features_dimension():
part = plate(*grid(3))
view = build_view(part, "front")
intents = dimension_intents(part, view, fits={"h1": "H7"})
assert [i.feature for i in intents if i.fit] == ["h1"]
# a hole already names itself; the code is appended, never swallowed
hole = next(i for i in intents if i.fit)
assert intent_text(hole) == "⌀6 H7"
assert resolve_tolerance(hole)["text_override"] == "⌀6 H7"
def test_a_fit_widens_the_measured_label_so_the_layout_reserves_room():
part = shaft()
view = build_view(part, "front")
plain = next(i for i in dimension_intents(part, view) if i.feature == segment_key(1))
fitted = next(
i
for i in dimension_intents(part, view, fits={segment_key(1): "k6"})
if i.feature == segment_key(1)
)
def width(intent):
rect = text_rects(layout_dimensions(view, [intent]))[0]
return rect[2] - rect[0]
assert width(fitted) > width(plain)
def test_a_fit_key_that_names_nothing_is_refused_with_the_names_that_exist():
part = shaft()
view = build_view(part, "front")
with pytest.raises(ValueError, match=r"segment\[0\]"):
dimension_intents(part, view, fits={"bearing_seat": "k6"})
def test_a_malformed_fit_code_is_refused_where_it_is_attached():
part = shaft()
view = build_view(part, "front")
with pytest.raises(ValueError, match="fit code"):
dimension_intents(part, view, fits={segment_key(1): "H"})
def test_a_fit_on_a_reference_dimension_is_refused():
part = shaft()
view = build_view(part, "front")
with pytest.raises(ValueError, match="reference"):
dimension_intents(part, view, fits={"part": "H7"})
def test_a_fit_on_a_hole_that_moved_into_the_table_is_refused_by_name():
part = plate(*grid(10))
view = build_view(part, "front")
with pytest.raises(ValueError, match="hole table"):
dimension_intents(part, view, fits={"h1": "H7"}, hole_table_threshold=8)
# -- collecting the intents --------------------------------------------------
def test_the_front_view_of_a_shaft_gets_its_overall_length_and_each_diameter_once():
view = build_view(shaft(), "front")
intents = dimension_intents(shaft(), view)
lengths = [i for i in intents if i.kind == "linear"]
diameters = [i for i in intents if i.kind == "diameter"]
assert any(measured_value(i) == 75.0 for i in lengths)
assert sorted({round(measured_value(i), 6) for i in diameters}) == [20.0, 25.0, 30.0]
def test_chain_and_baseline_place_the_same_stations_differently():
part = shaft()
view = build_view(part, "front")
chain = [i for i in dimension_intents(part, view, style="chain") if i.kind == "linear"]
baseline = [i for i in dimension_intents(part, view, style="baseline") if i.kind == "linear"]
assert {round(measured_value(i), 6) for i in chain} >= {20.0, 40.0, 15.0}
assert {round(measured_value(i), 6) for i in baseline} >= {20.0, 60.0, 75.0}
def test_ordinate_measures_every_station_from_the_origin():
part = shaft()
view = build_view(part, "front")
intents = [i for i in dimension_intents(part, view, style="ordinate") if i.kind == "linear"]
assert all(i.p1[0] == 0.0 for i in intents)
def test_a_feature_intent_survives_collection_and_keeps_its_feature_id():
part = shaft(Keyway(id="kw", segment=1, length=20.0))
view = build_view(part, "front")
intents = dimension_intents(part, view)
assert any(i.feature == "kw" for i in intents)
def test_a_duplicate_intent_is_collapsed_rather_than_drawn_twice():
"""`_dedupe` against an intent the collector did not generate itself.
Asserting uniqueness over what `dimension_intents` returns proves nothing -
it is the key `_dedupe` uniquifies. Feeding the duplicate in through the
view's own dims is what exercises the gate.
"""
part = shaft()
view = build_view(part, "front")
once = dimension_intents(part, view)
repeated = next(i for i in once if i.kind == "diameter")
doubled = dataclasses.replace(view, dims=view.dims + (repeated._replace(text_at=None),))
assert len(dimension_intents(part, doubled)) == len(once)
def test_iso129_a_closed_chain_marks_the_overall_as_a_reference_dimension():
"""20 + 40 + 15 and 75 state the same distance twice; ISO 129-1 parenthesises one.
The plan's own test asserts the 75 is present, which a bare fourth plain
DIMENSION satisfies. What matters is that it is drawn as an auxiliary
(reference) dimension.
"""
part = shaft()
view = build_view(part, "front")
linear = [i for i in dimension_intents(part, view, style="chain") if i.kind == "linear"]
assert sorted(round(measured_value(i), 6) for i in linear) == [15.0, 20.0, 40.0, 75.0]
overall = [i for i in linear if measured_value(i) == 75.0]
assert len(overall) == 1
assert is_reference(overall[0])
assert intent_text(overall[0]) == "(75)"
assert [i for i in linear if is_reference(i)] == overall
def test_iso129_no_plain_dimension_restates_a_measurement_the_others_give():
"""The real property, on every style and both part kinds."""
parts = (shaft(Keyway(id="kw", segment=1, length=20.0)), plate(*grid(4)))
for style in DIM_STYLES:
for part in parts:
view = build_view(part, "front")
intents = dimension_intents(part, view, style=style)
plain = [i for i in intents if not is_reference(i)]
assert redundant_linear(plain) == (), f"{part.name} / {style}"
def test_the_redundancy_check_fires_instead_of_restating_its_own_post_condition():
"""A gate that cannot fail is not a gate: hand it the un-marked chain."""
chain = [
DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0)),
DimIntent(kind="linear", p1=(20.0, 0.0), p2=(60.0, 0.0)),
DimIntent(kind="linear", p1=(60.0, 0.0), p2=(75.0, 0.0)),
DimIntent(kind="linear", p1=(0.0, 0.0), p2=(75.0, 0.0)),
]
assert redundant_linear(chain) == (3,)
baseline = [
DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0)),
DimIntent(kind="linear", p1=(0.0, 0.0), p2=(60.0, 0.0)),
DimIntent(kind="linear", p1=(0.0, 0.0), p2=(75.0, 0.0)),
]
assert redundant_linear(baseline) == ()
# two horizontal chains at different heights measure different things
stacked = [
DimIntent(kind="linear", p1=(0.0, 0.0), p2=(20.0, 0.0)),
DimIntent(kind="linear", p1=(20.0, 5.0), p2=(75.0, 5.0)),
DimIntent(kind="linear", p1=(0.0, 0.0), p2=(75.0, 0.0)),
]
assert redundant_linear(stacked) == ()
def test_baseline_states_every_station_once_so_nothing_is_parenthesised():
part = shaft()
view = build_view(part, "front")
intents = dimension_intents(part, view, style="baseline")
assert not any(is_reference(i) for i in intents)
def test_a_prismatic_front_view_gets_its_two_overall_sizes():
view = build_view(plate(), "front")
intents = dimension_intents(plate(), view)
assert sorted({round(measured_value(i), 6) for i in intents if i.kind == "linear"}) == [
50.0,
80.0,
]
# -- the hole table ----------------------------------------------------------
def test_under_the_threshold_the_holes_keep_their_own_diameters_and_there_is_no_table():
part = plate(*grid(4))
view = build_view(part, "front")
intents = dimension_intents(part, view, hole_table_threshold=8)
assert sum(1 for i in intents if i.kind == "diameter") == 4
assert hole_table_rows(part, view) == ()
def test_over_the_threshold_the_hole_diameters_move_into_a_table():
part = plate(*grid(10))
view = build_view(part, "front")
intents = dimension_intents(part, view, hole_table_threshold=8)
assert not any(i.kind == "diameter" for i in intents)
rows = hole_table_rows(part, view)
assert len(rows) == 10
assert {row["tag"] for row in rows} == {"A"}
assert rows[0]["qty"] == 10
assert rows[0]["d"] == 6.0
assert set(rows[0]) == {"tag", "x", "y", "d", "qty", "note"}
def test_two_hole_sizes_get_two_tags():
part = plate(
*grid(6), *(Hole(id=f"b{i}", x=5.0 + 9.0 * i, y=40.0, diameter=10.0) for i in range(4))
)
view = build_view(part, "front")
rows = hole_table_rows(part, view)
assert {row["tag"] for row in rows} == {"A", "B"}
by_tag = {row["tag"]: row["qty"] for row in rows}
assert by_tag == {"A": 6, "B": 4}
def test_a_tapped_hole_carries_its_designation_in_the_note():
part = plate(
*(Hole(id=f"t{i}", x=5.0 + 9.0 * i, y=25.0, diameter=10.0, thread="M10") for i in range(9))
)
view = build_view(part, "front")
rows = hole_table_rows(part, view)
assert rows[0]["note"] == "M10"
# -- the layout, and its overlap proof ---------------------------------------
def test_the_layout_places_every_intent_and_the_text_rectangles_are_disjoint():
part = shaft(Keyway(id="kw", segment=1, length=20.0))
view = build_view(part, "front")
intents = dimension_intents(part, view)
placed = layout_dimensions(view, intents)
assert len(placed) == len(intents)
assert all(intent.text_at is not None for intent in placed)
rects = text_rects(placed)
for a, b in itertools.combinations(rects, 2):
overlap_x = min(a[2], b[2]) - max(a[0], b[0])
overlap_y = min(a[3], b[3]) - max(a[1], b[1])
assert overlap_x <= 1e-9 or overlap_y <= 1e-9, f"{a} overlaps {b}"
def test_a_crowded_prismatic_view_still_lays_out_disjointly():
part = plate(*grid(6))
view = build_view(part, "front")
placed = layout_dimensions(view, dimension_intents(part, view, hole_table_threshold=100))
rects = text_rects(placed)
for a, b in itertools.combinations(rects, 2):
overlap_x = min(a[2], b[2]) - max(a[0], b[0])
overlap_y = min(a[3], b[3]) - max(a[1], b[1])
assert overlap_x <= 1e-9 or overlap_y <= 1e-9, f"{a} overlaps {b}"
def test_horizontal_dimensions_stack_below_the_view_and_vertical_ones_to_its_left():
part = shaft()
view = build_view(part, "front")
placed = layout_dimensions(view, dimension_intents(part, view), offset=10.0, step=8.0)
horizontal = [i for i in placed if i.kind == "linear" and abs(i.p1[1] - i.p2[1]) < 1e-9]
assert horizontal and all(i.text_at[1] <= view.bbox[1] - 10.0 + 1e-9 for i in horizontal)
diameters = [i for i in placed if i.kind == "diameter"]
assert diameters and all(i.text_at[0] >= view.bbox[2] + 10.0 - 1e-9 for i in diameters)
def test_the_step_is_never_smaller_than_the_text_it_has_to_clear():
part = shaft()
view = build_view(part, "front")
placed = layout_dimensions(view, dimension_intents(part, view), offset=10.0, step=1.0)
rects = text_rects(placed)
for a, b in itertools.combinations(rects, 2):
overlap_x = min(a[2], b[2]) - max(a[0], b[0])
overlap_y = min(a[3], b[3]) - max(a[1], b[1])
assert overlap_x <= 1e-9 or overlap_y <= 1e-9
def test_an_unknown_style_is_refused():
part = shaft()
view = build_view(part, "front")
with pytest.raises(ValueError, match="chain"):
dimension_intents(part, view, style="freehand")