From ebf02d6573ae21d7a7dfc16019994bedd8aab764 Mon Sep 17 00:00:00 2001 From: TheRON Date: Wed, 19 Aug 2026 05:28:23 -0500 Subject: [PATCH] geom: port sb-join, the junction and envelope strategies Face lines, structural butt joints, hull caps, concave fillets, the derived bore, ring fit, ring envelope and section assembly. N-generic throughout, as the reference is. Junctions are structural, not cosmetic: one sleeve runs through its neighbour and is cut flush against that member the far surface, so the two share a full-width overlap whether or not a fillet is applied on top. The bore is derived from the members own inside-wall lines rather than a separately scaled shape, which is what makes the declared inside wall exactly what remains beside each cavity. 43 tests. Mutation testing found two of the reference own warnings to be load-bearing and untested by me. A bore that has turned inside out can carry over a square millimetre of area, so the area guard alone accepts it and only the interior-side test rejects it. And the ring fit really does have a spurious lower branch: a thin triangle meets a 1.2 mm web at relative scale 0.425, where members overhang their own corners and the solve looks converged. Both now covered. A third mutation was malformed on my part rather than a gap -- cutting the cavities twice is idempotent -- and was replaced with one that does change behaviour. Nine mutations caught. Oracle acceptance still skips; 236 unchanged. --- src/mechcomp/geom/__init__.py | 24 ++ src/mechcomp/geom/join.py | 401 ++++++++++++++++++++++++++++ tests/test_join.py | 490 ++++++++++++++++++++++++++++++++++ 3 files changed, 915 insertions(+) create mode 100644 src/mechcomp/geom/join.py create mode 100644 tests/test_join.py 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)