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")