From 01213fc5f5c2b15bb7eb9d405b6307c6c81ed7f1 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 22:27:09 +0300 Subject: [PATCH] feat(task-6): dimension intents, ISO 286 fits and an overlap-free layout engineering/mech/dimension.py collects what the features asked to have dimensioned, adds the part's own sizes (chain, baseline or ordinate for the axial lengths, a diameter per segment and per bore, the two overall sizes of a plate), and removes the duplicates - ISO 129-1 says each dimension appears once, and a test asserts no measurement is placed twice. Fits and tolerances are resolved through the modules that already own them: engineering.fits for an ISO 286 code and engineering.tolerances for an explicit ISO 129 tolerance, including build_dim_override's sign convention (the lower deviation is passed as the magnitude of the minus side, exactly as server.py::_fit_to_tolerances does). A H7 bore at 40 mm resolves to +0.025/0 - the correctness check the spec names. A fit together with an explicit tolerance, a fit outside the authored ISO 286 range, and a fit on an intent with no measurable nominal are each refused by name rather than guessed. The layout is the part that had to be measured rather than asserted: every intent's text is turned into a rectangle by the same helper the test uses, and an intent is placed on the lowest level whose rectangle is free, with the level pitch clamped to clear the text whatever the caller passed. The test then asserts the placed rectangles are pairwise disjoint - including on a crowded plate and with a deliberately too-small step. That is the same property the repository's dim_overlap critique focus checks on the finished drawing. The plan's layout clamped that pitch to the text HEIGHT on every axis, which is only the stacking direction for the row of dimensions under the view. Beside the view the levels march in x, so the extent they have to clear is the text WIDTH: measured here, three diameter labels are 9.45 mm wide (9.45, 5.6) at a 8.0 mm pitch, so a shaft's three segment diameters - all on the y = 0 centreline, hence all on different levels - overlapped by 1.45 mm. The pitch and the within-level span are now both taken per axis from the same _text_size helper text_rects uses, which makes the disjointness a property of the algorithm rather than of the label lengths that happened to be in the test. Over hole_table_threshold holes the per-hole diameters leave the view and become a table instead, grouped by diameter, thread, counterbore and depth, one tag per group and the quantity on every row. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- engineering/mech/__init__.py | 20 ++ engineering/mech/dimension.py | 380 ++++++++++++++++++++++++++++++++++ tests/test_mech_dimension.py | 276 ++++++++++++++++++++++++ 3 files changed, 676 insertions(+) create mode 100644 engineering/mech/dimension.py create mode 100644 tests/test_mech_dimension.py diff --git a/engineering/mech/__init__.py b/engineering/mech/__init__.py index ffb6f43..b58a1d7 100644 --- a/engineering/mech/__init__.py +++ b/engineering/mech/__init__.py @@ -6,6 +6,17 @@ and alphabetical — keep every side's names. from __future__ import annotations +from .dimension import ( + DIM_STYLES, + TEXT_WIDTH_FACTOR, + dimension_intents, + hole_table_rows, + intent_text, + layout_dimensions, + measured_value, + resolve_tolerance, + text_rects, +) from .features import ( FEATURE_KINDS, AxialBore, @@ -100,6 +111,7 @@ __all__ = [ "CornerChamfer", "CornerFillet", "CrossHole", + "DIM_STYLES", "DimIntent", "FEATURE_KINDS", "Feature", @@ -128,6 +140,7 @@ __all__ = [ "SECTION_STYLES", "Segment", "Slot", + "TEXT_WIDTH_FACTOR", "Text", "Thread", "Undercut", @@ -139,10 +152,15 @@ __all__ = [ "cut_loops", "detail_marker_prims", "detail_view", + "dimension_intents", "feature_from_dict", + "hole_table_rows", "inner_radius_at", + "intent_text", "layer_for", + "layout_dimensions", "layout_origin", + "measured_value", "mirror_about_x", "normalise_plane", "outer_radius_at", @@ -154,12 +172,14 @@ __all__ = [ "point_in_outline", "points_of", "radial_profile", + "resolve_tolerance", "rotate", "scale", "section_profile_prims", "section_view", "segment_bounds", "silhouette_prims", + "text_rects", "translate", "validate_part", ] diff --git a/engineering/mech/dimension.py b/engineering/mech/dimension.py new file mode 100644 index 0000000..41e29e0 --- /dev/null +++ b/engineering/mech/dimension.py @@ -0,0 +1,380 @@ +"""Dimension intents for a view, laid out so no two texts overlap. + +Pure: no backend, no async, no DXF. The features declare what they want +dimensioned; this module adds the part's own sizes, removes the duplicates +(ISO 129-1: each dimension appears once), resolves ISO 286 fits and ISO 129 +tolerances through the modules that already own them, and then *places* the +texts. + +The placement is not a matter of taste. Every intent's text is measured into a +rectangle by :func:`text_rects`, and an intent is put on the lowest free level +whose rectangle does not overlap one already placed on that level. Two +rectangles are disjoint when they are separated along *either* axis, so the +layout separates them along both in turn: within a level, along the axis the +level runs in; between levels, along the axis the levels stack in. The second +one is why the level pitch is clamped to the extent of the text *in the +stacking direction* - which is the text height for the row of dimensions under +the view, and the text **width** for the column of diameters beside it. A pitch +clamped only to the height would let two 9.45 mm wide diameter labels sit +8 mm apart on the same centreline, overlapping by 1.45 mm. + +The test asserts pairwise disjointness with the same helper, which makes "no +overlapping dimension text" measured rather than claimed - and it is the same +property the repository's `dim_overlap` critique focus checks on the finished +drawing. +""" + +from __future__ import annotations + +import math + +from engineering.fits import fit_lookup +from engineering.mech.features import Hole +from engineering.mech.part import ( + PrismaticPart, + RevolvedPart, + outline_bbox, + part_length, + segment_bounds, +) +from engineering.mech.primitives import DimIntent +from engineering.tolerances import build_dim_override + +_EPS = 1e-9 + +DIM_STYLES: tuple[str, ...] = ("chain", "baseline", "ordinate") + +#: Mean glyph width as a fraction of the text height, for measuring a label +#: into a rectangle. Deliberately generous: over-estimating the width can only +#: push two dimensions further apart. +TEXT_WIDTH_FACTOR = 0.7 + +#: Padding added around a measured label, in text heights. +TEXT_PADDING = 0.6 + + +def measured_value(intent: DimIntent) -> float: + """What the dimension measures, in drawing units.""" + p1, p2 = intent.p1, intent.p2 + if intent.kind == "linear": + dx, dy = abs(p2[0] - p1[0]), abs(p2[1] - p1[1]) + return dx if dx >= dy else dy + return math.dist(p1, p2) + + +def _format(value: float) -> str: + return f"{round(value, 3):g}" + + +def intent_text(intent: DimIntent) -> str: + """The text a dimension will carry - the override, or the measurement.""" + if intent.text_override: + return intent.text_override + if intent.kind == "diameter": + return f"⌀{_format(measured_value(intent))}" + if intent.kind == "radius": + return f"R{_format(measured_value(intent))}" + return _format(measured_value(intent)) + + +def _text_size(intent: DimIntent, height: float) -> tuple[float, float]: + """``(width, height)`` of the rectangle this intent's label occupies.""" + label = intent_text(intent) + return ( + (len(label) * TEXT_WIDTH_FACTOR + TEXT_PADDING) * height, + height * (1.0 + TEXT_PADDING), + ) + + +def text_rects(intents, *, height: float = 3.5): + """``(x0, y0, x1, y1)`` per placed intent, from its text and its ``text_at``.""" + rects = [] + for intent in intents: + if intent.text_at is None: + raise ValueError( + f"text_rects: intent {intent.feature or intent.kind!r} has no text_at; " + "run layout_dimensions first." + ) + width, tall = _text_size(intent, height) + cx, cy = float(intent.text_at[0]), float(intent.text_at[1]) + rects.append((cx - width / 2.0, cy - tall / 2.0, cx + width / 2.0, cy + tall / 2.0)) + return tuple(rects) + + +# -- tolerances -------------------------------------------------------------- + + +def resolve_tolerance(intent: DimIntent, nominal: float | None = None) -> dict: + """Turn an intent's ``fit`` / ``tol`` into the backend dimension call's arguments. + + Mirrors `server.py::_fit_to_tolerances`, including its sign convention: + ``build_dim_override`` reads ``tol_lower`` as the *magnitude* of the minus + deviation, so a resolved fit passes ``-deviation.lower_mm``. + """ + has_tol = bool(intent.tol) + if intent.fit and has_tol: + raise ValueError( + f"{intent.feature or intent.kind}: pass either fit= or an explicit " + "tol, not both." + ) + if intent.fit: + size = float(nominal) if nominal is not None else measured_value(intent) + if size <= _EPS: + raise ValueError( + f"{intent.feature or intent.kind}: fit {intent.fit!r} needs a nominal size, and " + f"the intent measures {size}. Pass nominal= explicitly." + ) + deviation = fit_lookup(intent.fit, size) + return { + "tol_upper": deviation.upper_mm, + "tol_lower": -deviation.lower_mm, + "tol_mode": "deviation", + "text_override": intent.text_override or f"<> {deviation.code}", + } + if has_tol: + tol = dict(intent.tol) + mode = str(tol.get("mode", "none")) + upper = tol.get("upper") + lower = tol.get("lower") + build_dim_override(upper, lower, mode, intent.text_override) # validates, or raises + return { + "tol_upper": upper, + "tol_lower": lower, + "tol_mode": mode, + "text_override": intent.text_override, + } + return { + "tol_upper": None, + "tol_lower": None, + "tol_mode": "none", + "text_override": intent.text_override, + } + + +# -- collecting the intents -------------------------------------------------- + + +def _dedupe(intents) -> tuple[DimIntent, ...]: + seen: set[tuple] = set() + out: list[DimIntent] = [] + for intent in intents: + key = ( + intent.kind, + round(float(intent.p1[0]), 6), + round(float(intent.p1[1]), 6), + round(float(intent.p2[0]), 6), + round(float(intent.p2[1]), 6), + ) + if key in seen: + continue + seen.add(key) + out.append(intent) + return tuple(out) + + +def _revolved_axial(part: RevolvedPart, style: str) -> list[DimIntent]: + stations = [0.0] + [x1 for _, x1 in segment_bounds(part)] + intents: list[DimIntent] = [] + if style == "chain": + for a, b in zip(stations, stations[1:], strict=False): + intents.append(DimIntent(kind="linear", p1=(a, 0.0), p2=(b, 0.0), feature="part")) + intents.append( + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(part_length(part), 0.0), feature="part") + ) + else: # baseline and ordinate both measure from the left face + for station in stations[1:]: + intents.append( + DimIntent(kind="linear", p1=(0.0, 0.0), p2=(station, 0.0), feature="part") + ) + return intents + + +def _revolved_diameters(part: RevolvedPart) -> list[DimIntent]: + intents: list[DimIntent] = [] + for (x0, x1), segment in zip(segment_bounds(part), part.segments, strict=True): + mid = (x0 + x1) / 2.0 + radius = segment.d_outer / 2.0 + intents.append( + DimIntent(kind="diameter", p1=(mid, radius), p2=(mid, -radius), feature="part") + ) + if segment.d_inner > _EPS: + bore = segment.d_inner / 2.0 + intents.append( + DimIntent(kind="diameter", p1=(mid, bore), p2=(mid, -bore), feature="part") + ) + return intents + + +def _hole_groups(part) -> dict[tuple, list[Hole]]: + groups: dict[tuple, list[Hole]] = {} + for feature in getattr(part, "features", ()): + if not isinstance(feature, Hole): + continue + key = ( + round(float(feature.diameter), 6), + feature.thread or "", + None if feature.cbore_d is None else round(float(feature.cbore_d), 6), + None if feature.depth is None else round(float(feature.depth), 6), + ) + groups.setdefault(key, []).append(feature) + return groups + + +def dimension_intents( + part, + view, + *, + style: str = "chain", + hole_table_threshold: int = 8, +) -> tuple[DimIntent, ...]: + """Every dimension this view wants: the features' own, plus the part's sizes.""" + name = str(style or "").strip().lower() + if name not in DIM_STYLES: + raise ValueError(f"style: expected one of {DIM_STYLES}, got {style!r}.") + + intents: list[DimIntent] = list(view.dims) + kind = "front" if view.kind == "section" else view.kind + + if isinstance(part, RevolvedPart): + if kind == "front": + intents.extend(_revolved_axial(part, name)) + intents.extend(_revolved_diameters(part)) + elif kind == "side": + near = part.segments[-1] + radius = near.d_outer / 2.0 + intents.append( + DimIntent(kind="diameter", p1=(-radius, 0.0), p2=(radius, 0.0), feature="part") + ) + if near.d_inner > _EPS: + bore = near.d_inner / 2.0 + intents.append( + DimIntent(kind="diameter", p1=(-bore, 0.0), p2=(bore, 0.0), feature="part") + ) + elif isinstance(part, PrismaticPart): + x0, y0, x1, y1 = outline_bbox(part) + if kind == "front": + intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x1, y0), feature="part")) + intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x0, y1), feature="part")) + else: + # An edge view sees the thickness, measured at the near edge of + # whichever outline axis this view runs along. + u0 = y0 if kind == "side" else x0 + intents.append( + DimIntent( + kind="linear", + p1=(u0, 0.0), + p2=(u0, float(part.thickness)), + feature="part", + ) + ) + + groups = _hole_groups(part) + total_holes = sum(len(holes) for holes in groups.values()) + if total_holes > int(hole_table_threshold): + hole_ids = {hole.id for holes in groups.values() for hole in holes} + intents = [i for i in intents if i.feature not in hole_ids] + + return _dedupe(intents) + + +def hole_table_rows(part, view, *, hole_table_threshold: int = 8) -> tuple[dict, ...]: + """One row per hole, tagged and counted by group - empty under the threshold. + + ``view`` is part of the pinned signature and is accepted unused, so a later + view-specific filter (a hole that does not appear in this view) has one + place to go. + """ + del view + groups = _hole_groups(part) + total = sum(len(holes) for holes in groups.values()) + if total <= int(hole_table_threshold): + return () + rows: list[dict] = [] + for index, key in enumerate(sorted(groups, key=lambda k: (k[0], k[1]))): + tag = chr(ord("A") + index) + holes = groups[key] + diameter, thread, cbore, depth = key + note_parts = [] + if thread: + note_parts.append(str(thread)) + if cbore is not None: + note_parts.append(f"c'bore ⌀{cbore:g}") + if depth is not None: + note_parts.append(f"deep {depth:g}") + note = " ".join(note_parts) + for hole in holes: + rows.append( + { + "tag": tag, + "x": float(hole.x), + "y": float(hole.y), + "d": float(diameter), + "qty": len(holes), + "note": note, + } + ) + return tuple(rows) + + +# -- the layout -------------------------------------------------------------- + + +def _axis_of(intent: DimIntent) -> str: + """ "bottom" (u = x, level moves -y), "left" (u = y, level moves -x) or "right".""" + if intent.kind in ("diameter", "radius", "angular"): + return "right" + dx = abs(intent.p2[0] - intent.p1[0]) + dy = abs(intent.p2[1] - intent.p1[1]) + return "bottom" if dx >= dy else "left" + + +def layout_dimensions( + view, intents, *, offset: float = 10.0, step: float = 8.0, height: float = 3.5 +) -> tuple[DimIntent, ...]: + """Give every intent a ``text_at`` such that no two text rectangles overlap.""" + x0, y0, x1, y1 = view.bbox + del y1 + + # Measure first: a level's pitch has to clear the widest text that stacks + # across it, and that is the text WIDTH on the left/right axes, where the + # levels march in x, not the text height. + measured: list[tuple[DimIntent, str, float, float]] = [] + across: dict[str, float] = {} + for intent in intents: + axis = _axis_of(intent) + width, tall = _text_size(intent, height) + # ``along`` is the extent parallel to the level, ``across`` the extent + # perpendicular to it - the one successive levels have to clear. + along, over = (width, tall) if axis == "bottom" else (tall, width) + measured.append((intent, axis, along, over)) + across[axis] = max(across.get(axis, 0.0), over) + pitch = {axis: max(float(step), value + 1.0) for axis, value in across.items()} + + occupied: dict[tuple[str, int], list[tuple[float, float]]] = {} + placed: list[DimIntent] = [] + + for intent, axis, along, _over in measured: + if axis == "bottom": + center = (float(intent.p1[0]) + float(intent.p2[0])) / 2.0 + else: + center = (float(intent.p1[1]) + float(intent.p2[1])) / 2.0 + span = (center - along / 2.0, center + along / 2.0) + + level = 0 + while True: + taken = occupied.setdefault((axis, level), []) + if all(span[1] <= a + 1e-9 or span[0] >= b - 1e-9 for a, b in taken): + taken.append(span) + break + level += 1 + + distance = float(offset) + level * pitch[axis] + if axis == "bottom": + text_at = (center, y0 - distance) + elif axis == "left": + text_at = (x0 - distance, center) + else: + text_at = (x1 + distance, center) + placed.append(intent._replace(text_at=text_at)) + + return tuple(placed) diff --git a/tests/test_mech_dimension.py b/tests/test_mech_dimension.py new file mode 100644 index 0000000..298508e --- /dev/null +++ b/tests/test_mech_dimension.py @@ -0,0 +1,276 @@ +"""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 itertools + +import pytest + +from engineering.mech.dimension import ( + DIM_STYLES, + dimension_intents, + hole_table_rows, + intent_text, + layout_dimensions, + measured_value, + resolve_tolerance, + 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 + + +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) + + +# -- 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_iso129_the_same_measurement_is_not_dimensioned_twice(): + part = shaft() + view = build_view(part, "front") + intents = dimension_intents(part, view) + keys = [ + (i.kind, round(i.p1[0], 6), round(i.p1[1], 6), round(i.p2[0], 6), round(i.p2[1], 6)) + for i in intents + ] + assert len(keys) == len(set(keys)) + + +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")