diff --git a/src/mechcomp/geom/__init__.py b/src/mechcomp/geom/__init__.py index fe7c563..bd4051f 100644 --- a/src/mechcomp/geom/__init__.py +++ b/src/mechcomp/geom/__init__.py @@ -80,3 +80,27 @@ from .region import ( # noqa: F401 to_shapely, union, ) +from .join import ( # noqa: F401 + bore_from_members, + bore_valid, + cavity_region, + centering_shift, + end_face, + face_line, + far_face_line, + fillet_concave, + fillet_junctions, + fillet_pair, + fit_ring, + hull_cap, + ring_fit_scale, + ring_max_corner_r, + ring_members, + ring_shell, + ring_web, + scale_about_centroid, + section, + sleeve_butt, + sleeve_shell, + strap_region, +) diff --git a/src/mechcomp/geom/join.py b/src/mechcomp/geom/join.py new file mode 100644 index 0000000..3cdacfc --- /dev/null +++ b/src/mechcomp/geom/join.py @@ -0,0 +1,401 @@ +""" +Port of ``legacy/openscad/lib/sb-join.scad`` -- junction and envelope strategies. + +Everything here is written for N members and is shared verbatim by the 3x, 4x +and any later generator. Three families of strategy live here. + +ENVELOPE + How the outer PLA+ solid is generated: *sleeve style*, the union of + per-member sleeves, for open profiles; *ring style*, one closed envelope + offset from a centreline polygon with solid rounded corners. + +BORE + The enclosed central void, derived from the members' actual inside-wall + lines rather than from a separately scaled shape. That is what makes the + declared inside wall exactly what remains between each cavity and the void. + +FIT + Placement solved against a measured web, so a declared wall thickness is the + wall thickness you get. + +The structural idea worth keeping in view while reading: junctions are made by +running one sleeve's trailing end all the way through its neighbour and cutting +it flush against that member's far surface. The two then share a full-width +overlap, so the joint carries load whether or not a fillet is applied +afterwards. Fillets here are cosmetic, applied on top of a structural butt +joint, never in place of one. +""" + +from __future__ import annotations + +import math +from typing import List, Optional, Sequence, Tuple + +from .primitives import ( + Path, + Point, + sb_ccw, + sb_centroid, + sb_corner_radii, + sb_dist, + sb_line_isect, + sb_path_gap, + sb_path_max_round, + sb_signed_area, + sb_solvable, + sb_solve, +) +from .records import Geo, Member, cavity_path, member_on_edge, sleeve_path, \ + sleeve_to_line, strap_path +from .region import ( + Region, + as_region, + difference, + hull_region, + pointlist_bounds, + offset_path, + union, +) +from .rounding import round_corners + +Line = Tuple[Point, Point] + + +# ---------------------------------------------------------------------------- +# Face lines +# ---------------------------------------------------------------------------- + +def face_line(m: Member, g: Geo, side: int) -> Line: + """ + One of a member's two long sleeve surfaces, as an infinite line. + + ``side = +1`` selects the local +Y surface, ``-1`` the local -Y surface. + Returns ``(point, direction)``. + """ + c = m.centre + n = m.normal + d = g.reach_plus(m.face) if side > 0 else g.reach_minus(m.face) + return ((c[0] + side * d * n[0], c[1] + side * d * n[1]), m.axis) + + +def far_face_line(m: Member, g: Geo, from_pt: Point) -> Line: + """ + The surface of ``m`` lying farther from ``from_pt``. + + Lets one member butt flush against the far side of another without either + needing to know which way the other is facing. + """ + a = face_line(m, g, 1) + b = face_line(m, g, -1) + return a if sb_dist(a[0], from_pt) >= sb_dist(b[0], from_pt) else b + + +# ---------------------------------------------------------------------------- +# Structural junctions +# ---------------------------------------------------------------------------- + +def sleeve_butt(m: Member, g: Geo, into: Member, + ext_lead: float = 0.0) -> List[Point]: + """ + The core junction primitive. + + Rather than letting two sleeves clip each other at a corner and relying on a + cosmetic fillet to hold the result together, the trailing end of ``m`` is + run all the way through ``into`` and cut off flush with that member's far + surface. ``ext_lead`` extends the opposite, free end, which is left + untouched. + """ + pt, direction = far_face_line(into, g, m.centre) + return sleeve_to_line(m, g, pt, direction, ext_lead) + + +def end_face(m: Member, g: Geo, end: int = 1, extra: float = 0.0) -> List[Point]: + """ + The end-face segment of a member at its leading (+1) or trailing (-1) end, + taken at the sleeve surface. ``extra`` pushes the face further along the + axis. + """ + c = m.centre + u = m.axis + n = m.normal + f = m.face + half = g.cavity_w / 2.0 + g.wall_edge + extra + p = (c[0] + end * half * u[0], c[1] + end * half * u[1]) + up = g.reach_plus(f) + dn = g.reach_minus(f) + return [(p[0] + n[0] * up, p[1] + n[1] * up), + (p[0] - n[0] * dn, p[1] - n[1] * dn)] + + +def hull_cap(segments: Sequence[Sequence[Point]]) -> List[Point]: + """ + Plug the space enclosed by a set of member end faces with their convex hull. + + Deterministic, cheap, and free of the spikes and V-notches a bare union of + crossing rectangles leaves behind. Used for spoke-style centres and gable + apexes. + """ + pts = [p for seg in segments for p in seg] + if len(pts) < 3: + return [] + return hull_region([pts]) + + +def fillet_concave(path: Path, r: float) -> List[Point]: + """ + Round only the reflex corners of a path, leaving every convex corner + bit-exact. Each radius is clamped to what its own corner can accept, so the + operation cannot fail on a tight junction. + + This replaces the morphological closing used in earlier revisions. Closing + had three problems: an inward offset on a many-vertex path is the least + reliable operation in the pipeline and raises a library-level error rather + than reporting one; its arc discretisation is not mirror-symmetric, so it + quietly made symmetric profiles chiral; and it filled every concavity within + reach rather than the junction actually being treated. + """ + pts = list(path) + if len(pts) < 3: + return pts + radii = sb_corner_radii(pts, r) + if max(radii) <= 1e-6: + return pts + return round_corners(pts, radii, closed=True) + + +def fillet_pair(path_a: Path, path_b: Path, r: float) -> Region: + """ + Fillet the junction between two sleeves. Cosmetic only. + + If the two solids do not merge into a single simple outline there is nothing + sane to round, so the pair is returned untouched rather than guessed at. + """ + if r <= 0: + return [list(path_a), list(path_b)] + u = as_region(union([[list(path_a)], [list(path_b)]])) + return [fillet_concave(u[0], r)] if len(u) == 1 else u + + +# ---------------------------------------------------------------------------- +# Bore -- the enclosed central void +# ---------------------------------------------------------------------------- + +def bore_from_members(ms: Sequence[Member], g: Geo) -> List[Point]: + """ + The polygon bounded by the members' actual inside-wall surfaces. + + Members must be supplied in cyclic order around the interior, each with a + real interior face. Because the bore is derived from those surfaces rather + than from a separately scaled shape, the declared inside wall is exactly + what remains between each cavity and the void. + + Nothing here is specific to three members; a four-sided profile produces a + quadrilateral bore from the same call. Returns ``[]`` when any pair of + consecutive inside lines is parallel, which means no closed interior. + """ + if any(m.face == 0 for m in ms): + return [] + n = len(ms) + pts = [m.inside_wall_pt(g) for m in ms] + dirs = [m.axis for m in ms] + verts: List[Point] = [] + for i in range(n): + v = sb_line_isect(pts[(i - 1) % n], dirs[(i - 1) % n], pts[i], dirs[i]) + if v is None: + return [] + verts.append(v) + return verts + + +def bore_valid(bore: Sequence[Point], ms: Sequence[Member], g: Geo) -> bool: + """ + Is the derived bore real? + + A collapsed interior does not vanish, it turns itself inside out, so area + alone proves nothing. The test that matters is that the bore's own centre + still lies on the interior side of every member's inside wall. + """ + if len(bore) < 3: + return False + if abs(sb_signed_area(bore)) <= 0.01: + return False + c = sb_centroid(bore) + for m in ms: + d = m.inside_dir + p = m.inside_wall_pt(g) + if d is None: + return False + if d[0] * (c[0] - p[0]) + d[1] * (c[1] - p[1]) <= 0.01: + return False + return True + + +# ---------------------------------------------------------------------------- +# Ring profiles -- members along the edges of a closed polygon +# ---------------------------------------------------------------------------- + +def ring_members(path: Path, g: Geo) -> List[Member]: + """ + One member per edge, each centred on its edge, interior face towards the + polygon centroid. + + Centring keeps the profile mirror-symmetric; the corner webs are then set by + the polygon's size, solved for below. + """ + c = sb_centroid(path) + n = len(path) + return [member_on_edge(path[i], path[(i + 1) % n], c) for i in range(n)] + + +def ring_web(path: Path, g: Geo) -> float: + """Smallest PLA+ web between any two neighbouring strap cavities on the ring.""" + ms = ring_members(path, g) + n = len(ms) + cv = [cavity_path(m, g) for m in ms] + return min(sb_path_gap(cv[i], cv[(i + 1) % n]) for i in range(n)) + + +def scale_about_centroid(path: Path, k: float) -> List[Point]: + c = sb_centroid(path) + return [(c[0] + k * (p[0] - c[0]), c[1] + k * (p[1] - c[1])) for p in path] + + +def ring_fit_scale(path: Path, g: Geo, web: float, + max_scale: float = 12.0) -> Optional[float]: + """ + Grow the caller's polygon about its centroid until the tightest corner web + reaches ``web``. + + Straps have a fixed width, so on a polygon of a given size the corner webs + are whatever they are -- they cannot be dialled in by sliding members along + their edges, because every edge shares its budget with two corners. The only + free variable that raises all N webs at once is the polygon's size. + + The outline is normalised first, so the caller's polygon really is shape + only: a unit square and a 200 mm square must fit to the same result. At + relative scale 1 the shortest edge is exactly one strap wide. + + The search starts at 1, never below. Once an edge is shorter than a strap, + that member overhangs both of its own corners and the corner-setback model + no longer describes the geometry -- yet the measured web can come back + positive there, which is exactly the kind of spurious lower branch a + bisection will happily settle on. + """ + n = len(path) + edges = [sb_dist(path[i], path[(i + 1) % n]) for i in range(n)] + k0 = g.width / min(edges) + + def f(k: float) -> float: + return ring_web(scale_about_centroid(path, k0 * k), g) + + if not sb_solvable(f, max_scale, web): + return None + return k0 * sb_solve(f, 1.0, max_scale, web) + + +def fit_ring(path: Path, g: Geo, web: float, + max_scale: float = 12.0) -> Optional[List[Point]]: + k = ring_fit_scale(path, g, web, max_scale) + return None if k is None else scale_about_centroid(path, k) + + +def ring_shell(path: Path, g: Geo, corner_r: float = 0.0) -> List[Point]: + """ + Outer envelope of a ring profile: the centreline polygon pushed out to the + outside-wall surface, with its corners rounded. + + Corner rounding removes material from precisely the region where a strap + cavity approaches the corner, so the caller must check the result against + the minimum wall rather than assume a radius is safe. + """ + sharp = offset_path(sb_ccw(path), g.cavity_t / 2.0 + g.wall_outside, + closed=True) + if corner_r > 0: + return round_corners(sharp, corner_r, closed=True) + return sharp + + +def ring_max_corner_r(path: Path, g: Geo) -> float: + """ + Largest corner radius that still leaves ``min_wall`` between the envelope and + every strap cavity, and that the envelope can geometrically accept. + + Reported so a catalogue entry can be tuned once and then trusted. + + The solve runs on ``hi - r`` rather than on the radius directly, because the + measured clearance falls as the radius grows and the bisection requires a + non-decreasing function. + """ + sharp = ring_shell(path, g, 0.0) + hi = sb_path_max_round(sharp) + ms = ring_members(path, g) + cav = [cavity_path(m, g) for m in ms] + + def f(r: float) -> float: + sh = ring_shell(path, g, max(0.0, hi - r)) + if len(sh) < 3: + return 0.0 + return min(sb_path_gap(sh, c) for c in cav) + + if hi <= 0: + return 0.0 + return max(0.0, hi - sb_solve(f, 0.0, hi, g.min_wall - 1e-6)) + + +# ---------------------------------------------------------------------------- +# Sleeve profiles -- union of per-member sleeves +# ---------------------------------------------------------------------------- + +def sleeve_shell(paths: Sequence[Path]) -> Region: + return as_region(union([[list(p)] for p in paths])) + + +def fillet_junctions(shell: Sequence[Path], pairs: Sequence[Tuple[int, int]], + paths: Sequence[Path], r: float) -> Region: + """ + Apply one cosmetic fillet per declared junction, each computed from only the + two members involved, then merge with the untouched shell. + + Keeping the closings pairwise stops distant parts of the profile from + bridging to each other through the middle of the section. + """ + if r <= 0: + return as_region(shell) + regions: List[Sequence[Path]] = [list(shell)] + for a, b in pairs: + regions.append(fillet_pair(paths[a], paths[b], r)) + return as_region(union(regions)) + + +# ---------------------------------------------------------------------------- +# Assembly +# ---------------------------------------------------------------------------- + +def section(shell: Sequence[Path], members: Sequence[Member], g: Geo, + bore: Sequence[Point] = ()) -> Region: + """ + The one place where solid and void meet. + + All sleeve solids are unioned first and every cavity is removed afterwards, + so no member's PLA+ can ever intrude into another member's strap channel. + """ + cavities = as_region(union([[cavity_path(m, g)] for m in members])) + if len(bore) >= 3: + cut = as_region(union([[list(bore)], cavities])) + else: + cut = cavities + return as_region(difference(shell, cut)) + + +def strap_region(members: Sequence[Member], g: Geo) -> Region: + return as_region(union([[strap_path(m, g)] for m in members])) + + +def cavity_region(members: Sequence[Member], g: Geo) -> Region: + return as_region(union([[cavity_path(m, g)] for m in members])) + + +def centering_shift(shell: Sequence[Path]) -> Point: + """Translation putting the finished envelope's bounding box on the origin.""" + b = pointlist_bounds(hull_region(shell)) + return (-(b[0][0] + b[1][0]) / 2.0, -(b[0][1] + b[1][1]) / 2.0) diff --git a/tests/test_join.py b/tests/test_join.py new file mode 100644 index 0000000..4ec4ead --- /dev/null +++ b/tests/test_join.py @@ -0,0 +1,490 @@ +""" +Unit tests for the junction, bore and ring-fit strategies. + +Two things are checked here that the primitive suites cannot reach: that the +solvers converge on the quantity they claim to solve, and that the structural +claims in the reference's own comments actually hold. Where a comment says a +unit square and a 200 mm square must fit to the same result, that is a testable +assertion and it is tested. + +The rejection paths matter as much as the acceptance paths. Ten of the oracle's +123 cases are rejections, and a port that produces good geometry while quietly +dropping the constraints that made it trustworthy is a failed port. +""" + +from __future__ import annotations + +import math + +import pytest + +from mechcomp.geom.join import ( + bore_from_members, + bore_valid, + cavity_region, + centering_shift, + end_face, + face_line, + far_face_line, + fillet_concave, + fillet_junctions, + fit_ring, + hull_cap, + ring_fit_scale, + ring_max_corner_r, + ring_members, + ring_shell, + ring_web, + scale_about_centroid, + section, + sleeve_butt, + sleeve_shell, + strap_region, + fillet_pair, +) +from mechcomp.geom.primitives import ( + SB_FACE_BOTH_OUT, + SB_FACE_PLUS_IN, + sb_dist, + sb_path_gap, + sb_signed_area, +) +from mechcomp.geom.records import (Geo, Member, cavity_path, sleeve_path, + strap_path) +from mechcomp.geom.region import area, is_region_simple, nparts, pointlist_bounds + + +def geo(**kw) -> Geo: + base = dict(width=15.875, strap_t=0.508, count=1, clearance=0.2, + wall_inside=1.0, wall_outside=1.6, wall_edge=1.2, min_wall=0.8) + base.update(kw) + return Geo(**base) + + +def ngon(n: int, r: float = 20.0, start: float = 90.0): + return [(r * math.cos(math.radians(start + i * 360.0 / n)), + r * math.sin(math.radians(start + i * 360.0 / n))) + for i in range(n)] + + +TRI = ngon(3) +QUAD = ngon(4) + + +# ---------------------------------------------------------------------------- +# Face lines +# ---------------------------------------------------------------------------- + +def test_face_lines_sit_at_the_declared_reaches(): + g = geo() + m = Member(0.0, 0.0, 0.0, SB_FACE_PLUS_IN) + up, _ = face_line(m, g, 1) + dn, _ = face_line(m, g, -1) + assert up[1] == pytest.approx(g.reach_plus(SB_FACE_PLUS_IN)) + assert dn[1] == pytest.approx(-g.reach_minus(SB_FACE_PLUS_IN)) + + +def test_face_line_direction_is_the_member_axis(): + g = geo() + m = Member(0.0, 0.0, 37.0) + assert face_line(m, g, 1)[1] == pytest.approx(m.axis) + + +def test_far_face_line_picks_the_farther_surface(): + """The point of this is not needing to know which way either member faces.""" + g = geo() + m = Member(0.0, 0.0, 0.0, SB_FACE_BOTH_OUT) + from_below = far_face_line(m, g, (0.0, -100.0)) + from_above = far_face_line(m, g, (0.0, 100.0)) + assert from_below[0][1] > 0 + assert from_above[0][1] < 0 + + +# ---------------------------------------------------------------------------- +# Junctions +# ---------------------------------------------------------------------------- + +def test_sleeve_butt_runs_through_to_the_far_surface(): + """ + The butted sleeve must reach past its neighbour's centreline and stop on the + far face, not at the near one. That full-width overlap is what carries load. + + The arriving member sits at 0 degrees, so its trailing end -- the end + sleeve_butt cuts -- faces the origin and its leading end faces away. + """ + g = geo() + target = Member(0.0, 0.0, 90.0, SB_FACE_BOTH_OUT) + arriving = Member(20.0, 0.0, 0.0, SB_FACE_BOTH_OUT) + butted = sleeve_butt(arriving, g, target) + far_face = -g.reach_minus(SB_FACE_BOTH_OUT) + assert min(p[0] for p in butted) == pytest.approx(far_face) + assert min(p[0] for p in butted) < 0.0 + + +def test_sleeve_butt_leaves_the_free_end_alone(): + """Only the trailing end is cut; the leading end keeps its edge wall.""" + g = geo() + target = Member(0.0, 0.0, 90.0) + arriving = Member(20.0, 0.0, 0.0) + plain = sleeve_path(arriving, g) + butted = sleeve_butt(arriving, g, target) + assert max(p[0] for p in butted) == pytest.approx(max(p[0] for p in plain)) + + +def test_end_face_spans_the_full_sleeve_thickness(): + g = geo() + m = Member(0.0, 0.0, 0.0, SB_FACE_PLUS_IN) + a, b = end_face(m, g, 1) + expected = g.reach_plus(SB_FACE_PLUS_IN) + g.reach_minus(SB_FACE_PLUS_IN) + assert sb_dist(a, b) == pytest.approx(expected) + + +def test_end_face_ends_are_opposite(): + g = geo() + m = Member(0.0, 0.0, 0.0) + lead = end_face(m, g, 1) + trail = end_face(m, g, -1) + assert lead[0][0] > 0 and trail[0][0] < 0 + + +def test_hull_cap_needs_three_points(): + assert hull_cap([[(0.0, 0.0), (1.0, 0.0)]]) == [] + + +def test_hull_cap_plugs_the_gap_between_end_faces(): + g = geo() + faces = [end_face(Member(0.0, 0.0, a, SB_FACE_BOTH_OUT), g, 1) + for a in (0.0, 120.0, 240.0)] + cap = hull_cap(faces) + assert len(cap) >= 3 + assert area([cap]) > 0 + + +# ---------------------------------------------------------------------------- +# Fillets -- cosmetic, never structural +# ---------------------------------------------------------------------------- + +def test_fillet_concave_leaves_a_convex_path_bit_exact(): + square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] + assert fillet_concave(square, 2.0) == square + + +def test_fillet_concave_rounds_only_the_reflex_corner(): + ell = [(0.0, 0.0), (10.0, 0.0), (10.0, 4.0), + (4.0, 4.0), (4.0, 10.0), (0.0, 10.0)] + rounded = fillet_concave(ell, 1.0) + assert len(rounded) > len(ell) + for v in (0.0, 0.0), (10.0, 0.0), (10.0, 4.0), (0.0, 10.0): + assert v in rounded + + +def test_fillet_pair_with_no_radius_returns_both_untouched(): + a = [(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 5.0)] + b = [(20.0, 0.0), (25.0, 0.0), (25.0, 5.0), (20.0, 5.0)] + assert fillet_pair(a, b, 0.0) == [a, b] + + +def test_fillet_pair_leaves_non_merging_solids_alone(): + """Nothing sane to round, so the pair comes back rather than guessed at.""" + a = [(0.0, 0.0), (5.0, 0.0), (5.0, 5.0), (0.0, 5.0)] + b = [(20.0, 0.0), (25.0, 0.0), (25.0, 5.0), (20.0, 5.0)] + assert len(fillet_pair(a, b, 1.0)) == 2 + + +def test_fillet_pair_rounds_a_merged_junction(): + a = [(0.0, 0.0), (10.0, 0.0), (10.0, 3.0), (0.0, 3.0)] + b = [(0.0, 0.0), (3.0, 0.0), (3.0, 10.0), (0.0, 10.0)] + merged = fillet_pair(a, b, 1.0) + assert len(merged) == 1 + assert len(merged[0]) > 6 + + +# ---------------------------------------------------------------------------- +# Bore +# ---------------------------------------------------------------------------- + +def test_bore_needs_every_member_to_have_an_interior_face(): + g = geo() + ms = ring_members(TRI, g) + ms[0] = Member(ms[0].cx, ms[0].cy, ms[0].angle, SB_FACE_BOTH_OUT) + assert bore_from_members(ms, g) == [] + + +def test_bore_has_one_vertex_per_member(): + g = geo() + for poly in (TRI, QUAD, ngon(5)): + ms = ring_members(poly, g) + assert len(bore_from_members(ms, g)) == len(poly) + + +def test_bore_is_inset_from_the_centreline_polygon(): + """The bore is bounded by inside-wall surfaces, so it sits inside.""" + g = geo() + ms = ring_members(TRI, g) + bore = bore_from_members(ms, g) + assert abs(sb_signed_area(bore)) < abs(sb_signed_area(TRI)) + + +def test_bore_valid_rejects_a_degenerate_interior(): + """ + A collapsed interior turns itself inside out rather than vanishing, so area + alone proves nothing -- the centre must lie inside every inside wall. + """ + g = geo() + ms = ring_members(TRI, g) + assert not bore_valid([(0.0, 0.0), (1e-3, 0.0), (0.0, 1e-3)], ms, g) + + +def test_bore_valid_accepts_a_real_interior(): + g = geo() + fitted = fit_ring(TRI, geo(), 1.2) + ms = ring_members(fitted, g) + assert bore_valid(bore_from_members(ms, g), ms, g) + + +# ---------------------------------------------------------------------------- +# Ring members and the fit solver +# ---------------------------------------------------------------------------- + +def test_ring_has_one_member_per_edge_all_facing_inward(): + g = geo() + ms = ring_members(TRI, g) + assert len(ms) == 3 + assert all(m.face != SB_FACE_BOTH_OUT for m in ms) + + +def test_ring_members_are_centred_on_their_edges(): + """Centring is what keeps the profile mirror-symmetric.""" + g = geo() + ms = ring_members(QUAD, g) + for i, m in enumerate(ms): + a, b = QUAD[i], QUAD[(i + 1) % 4] + assert (m.cx, m.cy) == pytest.approx(((a[0] + b[0]) / 2, + (a[1] + b[1]) / 2)) + + +def test_fit_ring_hits_the_requested_web(): + for poly in (TRI, QUAD): + fitted = fit_ring(poly, geo(), 1.2) + assert fitted is not None + assert ring_web(fitted, geo()) == pytest.approx(1.2, abs=1e-6) + + +def test_fit_is_shape_only_not_size(): + """ + The reference states this outright: a unit polygon and a 200 mm polygon must + fit to the same result, because the outline carries shape and the solver + supplies scale. + """ + small = fit_ring(ngon(3, 1.0), geo(), 1.2) + large = fit_ring(ngon(3, 200.0), geo(), 1.2) + assert ring_web(small, geo()) == pytest.approx(ring_web(large, geo()), + abs=1e-6) + assert abs(sb_signed_area(small)) == pytest.approx(abs(sb_signed_area(large)), + rel=1e-6) + + +def test_fit_returns_none_when_the_web_is_unreachable(): + """An infeasible request must be reported, not approximated.""" + assert fit_ring(TRI, geo(), 1e6) is None + + +def test_a_larger_web_needs_a_larger_polygon(): + a = fit_ring(TRI, geo(), 1.0) + b = fit_ring(TRI, geo(), 3.0) + assert abs(sb_signed_area(b)) > abs(sb_signed_area(a)) + + +def test_scale_about_centroid_preserves_shape(): + scaled = scale_about_centroid(TRI, 3.0) + assert abs(sb_signed_area(scaled)) == pytest.approx( + 9.0 * abs(sb_signed_area(TRI))) + + +# ---------------------------------------------------------------------------- +# Ring envelope +# ---------------------------------------------------------------------------- + +def test_ring_shell_encloses_the_centreline_polygon(): + g = geo() + shell = ring_shell(TRI, g, 0.0) + assert abs(sb_signed_area(shell)) > abs(sb_signed_area(TRI)) + + +def test_ring_shell_corner_radius_removes_material(): + g = geo() + sharp = ring_shell(TRI, g, 0.0) + rounded = ring_shell(TRI, g, 2.0) + assert abs(sb_signed_area(rounded)) < abs(sb_signed_area(sharp)) + + +def test_max_corner_r_leaves_the_minimum_wall_intact(): + """ + Corner rounding eats material exactly where a cavity approaches the corner, + so the derived radius has to be checked against min_wall, not assumed safe. + """ + g = geo() + fitted = fit_ring(TRI, g, 1.2) + r = ring_max_corner_r(fitted, g) + shell = ring_shell(fitted, g, r) + cav = [cavity_path(m, g) for m in ring_members(fitted, g)] + assert min(sb_path_gap(shell, c) for c in cav) >= g.min_wall - 1e-4 + + +def test_exceeding_max_corner_r_breaches_the_minimum_wall(): + """The derived limit is a real boundary, not a conservative guess.""" + g = geo() + fitted = fit_ring(TRI, g, 1.2) + r = ring_max_corner_r(fitted, g) + shell = ring_shell(fitted, g, r * 1.5) + cav = [cavity_path(m, g) for m in ring_members(fitted, g)] + assert min(sb_path_gap(shell, c) for c in cav) < g.min_wall + + +# ---------------------------------------------------------------------------- +# Assembly +# ---------------------------------------------------------------------------- + +def _ring_section(poly, web=1.2, g=None): + g = g or geo() + fitted = fit_ring(poly, g, web) + ms = ring_members(fitted, g) + bore = bore_from_members(ms, g) + r = ring_max_corner_r(fitted, g) + shell = [ring_shell(fitted, g, r)] + return section(shell, ms, g, bore), ms, g + + +def test_a_fitted_ring_builds_one_connected_solid(): + sec, ms, g = _ring_section(TRI) + assert nparts(sec) == 1 + + +def test_channel_count_matches_the_member_count(): + for poly in (TRI, QUAD): + sec, ms, g = _ring_section(poly) + assert nparts(cavity_region(ms, g)) == len(poly) + + +def test_the_finished_section_is_tessellatable(): + """The manifold precondition, checked on a real profile rather than a fixture.""" + sec, _, _ = _ring_section(TRI) + assert is_region_simple(sec) + + +def test_cavities_are_removed_from_the_solid(): + sec, ms, g = _ring_section(TRI) + shell_area = area(sec) + area(cavity_region(ms, g)) + assert area(sec) < shell_area + + +def test_no_member_pla_intrudes_into_another_channel(): + """ + Every sleeve is unioned before any cavity is cut, which is what guarantees + this. Each strap must sit in clear space. + """ + sec, ms, g = _ring_section(TRI) + for m in ms: + strap = strap_path(m, g) + for path in sec: + assert sb_path_gap(strap, path) > 0.0 + + +def test_bore_is_absent_when_not_supplied(): + """Without a bore the interior stays solid, so the section is heavier.""" + g = geo() + fitted = fit_ring(TRI, g, 1.2) + ms = ring_members(fitted, g) + shell = [ring_shell(fitted, g, 0.0)] + assert area(section(shell, ms, g)) > \ + area(section(shell, ms, g, bore_from_members(ms, g))) + + +def test_centering_shift_puts_the_envelope_on_the_origin(): + g = geo() + shell = [ring_shell(fit_ring(TRI, g, 1.2), g, 0.0)] + dx, dy = centering_shift(shell) + moved = [[(p[0] + dx, p[1] + dy) for p in path] for path in shell] + lo, hi = pointlist_bounds([p for path in moved for p in path]) + assert lo[0] + hi[0] == pytest.approx(0.0, abs=1e-9) + assert lo[1] + hi[1] == pytest.approx(0.0, abs=1e-9) + + +def test_sleeve_shell_merges_overlapping_sleeves(): + g = geo() + a = sleeve_path(Member(0.0, 0.0, 0.0), g) + b = sleeve_path(Member(0.0, 0.0, 90.0), g) + assert nparts(sleeve_shell([a, b])) == 1 + + +def test_fillet_junctions_with_no_radius_is_a_no_op(): + g = geo() + paths = [sleeve_path(Member(0.0, 0.0, 0.0), g), + sleeve_path(Member(0.0, 0.0, 90.0), g)] + shell = sleeve_shell(paths) + assert fillet_junctions(shell, [(0, 1)], paths, 0.0) == shell + + +def test_fillet_junctions_adds_material_at_the_junction(): + g = geo() + paths = [sleeve_path(Member(0.0, 0.0, 0.0), g), + sleeve_path(Member(0.0, 0.0, 90.0), g)] + shell = sleeve_shell(paths) + filleted = fillet_junctions(shell, [(0, 1)], paths, 1.0) + assert area(filleted) > area(shell) + + +# ---------------------------------------------------------------------------- +# Cases found by mutation testing +# ---------------------------------------------------------------------------- + +THIN_TRI = [(0.0, 40.0), (-3.0, -5.0), (3.0, -5.0)] + + +def test_bore_valid_rejects_an_inverted_interior_with_real_area(): + """ + A ring too small for its straps produces a bore that has turned itself + inside out: at R = 2 its area is over 1 mm2, far past the 0.01 guard, yet + its centre lies on the wrong side of every inside wall. + + Area alone would accept this. The interior-side test is what rejects it. + """ + g = geo() + ms = ring_members(ngon(3, 2.0), g) + bore = bore_from_members(ms, g) + assert abs(sb_signed_area(bore)) > 0.01 + assert not bore_valid(bore, ms, g) + + +def test_fit_never_settles_on_the_spurious_lower_branch(): + """ + A thin triangle measures a web of 1.385 at relative scale 0.425 -- above the + 1.2 target and below 1. There the shortest edge is under one strap width, so + that member overhangs both of its own corners and the corner-setback model + no longer describes the geometry. + + The search starts at 1 for exactly this reason. Starting at 0 finds the + lower root, and the result looks like a converged solve. + """ + g = geo() + fitted = fit_ring(THIN_TRI, g, 1.2) + assert fitted is not None + n = len(fitted) + shortest = min(sb_dist(fitted[i], fitted[(i + 1) % n]) for i in range(n)) + assert shortest >= g.width - 1e-6 + + +def test_section_cuts_the_bore_when_one_is_supplied(): + """ + The bore is unioned with the cavities before the cut, so a supplied bore + must actually open the interior rather than being carried along unused. + """ + g = geo() + fitted = fit_ring(TRI, g, 1.2) + ms = ring_members(fitted, g) + bore = bore_from_members(ms, g) + shell = [ring_shell(fitted, g, 0.0)] + with_bore = section(shell, ms, g, bore) + without = section(shell, ms, g) + assert area(without) - area(with_bore) == pytest.approx( + abs(sb_signed_area(bore)), rel=1e-6)