diff --git a/src/mechcomp/geom/__init__.py b/src/mechcomp/geom/__init__.py index b231955..6698f8a 100644 --- a/src/mechcomp/geom/__init__.py +++ b/src/mechcomp/geom/__init__.py @@ -106,6 +106,7 @@ from .join import ( # noqa: F401 ) from .report import ( # noqa: F401 Check, + echo_vec, Metrics, ProfileRejected, Result, @@ -117,3 +118,21 @@ from .report import ( # noqa: F401 require, universal_checks, ) +from .core import ( # noqa: F401 + Centred, + Profile, + centred, + centred_members, + profile_failed, +) +from .arrangements import ( # noqa: F401 + fin_bore_side, + fin_members, + fin_profile, + fin_web, + regular_polygon, + ring_polygon_profile, + spoke_members, + spoke_profile, + spoke_web, +) diff --git a/src/mechcomp/geom/arrangements.py b/src/mechcomp/geom/arrangements.py new file mode 100644 index 0000000..fb3ab9e --- /dev/null +++ b/src/mechcomp/geom/arrangements.py @@ -0,0 +1,290 @@ +""" +Port of ``legacy/openscad/lib/sb-profiles.scad`` -- three complete arrangements, +each written for N members and each returning a finished Profile. + + ring_polygon_profile members on the edges of a closed polygon, wrapped in + one envelope with solid rounded corners and an + enclosed bore + spoke_profile N members radiating from a plugged centre + fin_profile N members lying tangentially on the sides of a + regular core polygon, slid cyclically so each + overhangs one corner + +A family generator supplies N and the parameters; nothing here changes between +the 3x and 4x catalogues. The 3x generator calls all three (Triangles, Y, +Three-Fin) and the 4x generator calls the same three with N = 4 +(Quadrilaterals, Cross, Four-Fin). +""" + +from __future__ import annotations + +from typing import List, Optional, Sequence, Tuple + +from .core import Profile, profile_failed +from .join import ( + bore_from_members, + bore_valid, + end_face, + fillet_junctions, + hull_cap, + ring_fit_scale, + ring_max_corner_r, + ring_members, + ring_shell, + ring_web, + scale_about_centroid, + sleeve_butt, + sleeve_shell, +) +from .primitives import ( + SB_FACE_BOTH_OUT, + Point, + cos_d, + sb_centroid, + sb_dist, + sb_path_gap, + sb_signed_area, + sb_solvable, + sb_solve, + sin_d, + tan_d, +) +from .records import Geo, cavity_path, member_on_edge, member_radial, sleeve_path +from .region import union +from .report import check + + +# ---------------------------------------------------------------------------- +# Ring: members on the edges of a closed polygon +# ---------------------------------------------------------------------------- + +def ring_polygon_profile(seed_path: Sequence[Point], g: Geo, web: float, + corner_r: float, label: str) -> Profile: + """ + Straps have a fixed width, so on a polygon of a given size the corner webs + are whatever they are -- sliding members along their edges cannot raise all + N at once, because every edge shares its budget with two corners. The one + free variable that lifts them together is the polygon's size, so the + caller's outline is treated as a SHAPE and grown about its centroid until + the tightest corner reaches ``web``. + + Members are centred on their edges, which keeps the result mirror-symmetric + whenever the outline is. + """ + if abs(sb_signed_area(seed_path)) < 1e-6: + return profile_failed("%s: the supplied outline is degenerate." % label) + + k = ring_fit_scale(seed_path, g, web) + if k is None: + return profile_failed( + "%s: no polygon size gives a %s mm corner web. Reduce the web, the " + "wall thicknesses, or the strap width." % (label, web)) + + path = scale_about_centroid(seed_path, k) + ms = ring_members(path, g) + max_r = ring_max_corner_r(path, g) + # Build with a radius the envelope can actually accept; if the caller asked + # for more, the check below reports it rather than letting the rounding + # routine fail with a library error. + shell = [ring_shell(path, g, min(corner_r, max_r))] + bore = bore_from_members(ms, g) + n = len(path) + edges = [sb_dist(path[i], path[(i + 1) % n]) for i in range(n)] + + return Profile( + members=ms, + shell=shell, + bore=bore, + checks=[ + check(len(shell[0]) >= 3, + "%s: the outer envelope collapsed." % label), + check(bore_valid(bore, ms, g), + "%s: the central bore has collapsed. Reduce " + "inside_wall_thickness_mm or the bundle thickness." % label), + check(corner_r <= max_r + 1e-6, + "%s: corner radius of %s mm is not usable here - it would cut " + "the outer wall below %s mm at the corners, or exceed what the " + "envelope can accept. Maximum is %s mm." + % (label, corner_r, g.min_wall, max_r)), + ], + info={ + "RING_SCALE": k, + "RING_EDGES_MM": edges, + "RING_CORNER_WEB_MM": ring_web(path, g), + "RING_CORNER_R_MAX_MM": max_r, + }, + ) + + +# ---------------------------------------------------------------------------- +# Spokes: N members radiating from a common centre +# ---------------------------------------------------------------------------- + +def spoke_members(n: int, radius: float, rotation: float): + return [member_radial(radius, rotation + 360.0 * i / n, SB_FACE_BOTH_OUT) + for i in range(n)] + + +def spoke_web(n: int, radius: float, rotation: float, g: Geo) -> float: + cv = [cavity_path(m, g) for m in spoke_members(n, radius, rotation)] + return min(sb_path_gap(cv[i], cv[(i + 1) % n]) for i in range(n)) + + +def spoke_profile(n: int, rotation: float, web: float, fillet_r: float, + g: Geo, label: str) -> Profile: + """ + A spoke's two broad faces both look at open air, so both take the outside + wall. There is no interior to face and therefore no bore; forcing an + interior direction on these members is what made earlier revisions chiral + under asymmetric wall settings. + + The centre is plugged with the convex hull of the N inner end faces rather + than left as whatever the crossing rectangles happened to produce. The spoke + radius is solved against the measured web between neighbours. + """ + W = g.width + hi = 6.0 * W + + def f(r: float) -> float: + return spoke_web(n, r, rotation, g) + + if not sb_solvable(f, hi, web): + return profile_failed( + "%s: cannot open a %s mm web between neighbouring spokes. Reduce " + "the web or the wall thicknesses." % (label, web)) + + radius = sb_solve(f, W / 2.0, hi, web) + ms = spoke_members(n, radius, rotation) + paths = [sleeve_path(m, g) for m in ms] + cap = hull_cap([end_face(m, g, -1) for m in ms]) + regions = [[p] for p in paths] + ([[cap]] if len(cap) > 0 else []) + raw = union(regions) + pairs = [(i, (i + 1) % n) for i in range(n)] + shell = fillet_junctions(raw, pairs, paths, fillet_r) + + return Profile( + members=ms, + shell=shell, + bore=[], + checks=[ + check(fillet_r > 0, + "%s: the junction fillet radius must be greater than zero. A " + "hull-plugged centre with no fillet meets the spokes along an " + "exactly tangent boundary, which is a valid outline but cannot " + "be tessellated." % label), + ], + info={ + "SPOKE_RADIUS_MM": radius, + "SPOKE_WEB_MM": spoke_web(n, radius, rotation, g), + "NOTE": "inside wall unused: no enclosed bore", + }, + ) + + +# ---------------------------------------------------------------------------- +# Fins: N members lying tangentially on a regular core polygon +# ---------------------------------------------------------------------------- + +def regular_polygon(n: int, side: float, rotation: float = 0.0) -> List[Point]: + R = side / (2.0 * sin_d(180.0 / n)) + out = [] + for k in range(n): + a = -90.0 + 180.0 / n + 360.0 * k / n + rotation + out.append((R * cos_d(a), R * sin_d(a))) + return out + + +def fin_members(n: int, side: float, fin: float, rotation: float, g: Geo): + """ + Tangential, not radial. Each member lies along one side of a regular core + polygon and is slid cyclically along that side, so it stops short of the + corner behind it and overhangs the corner ahead of it. Those N overhangs are + the fins. + """ + path = regular_polygon(n, side, rotation) + c = sb_centroid(path) + shift = fin + (side - g.width) / 2.0 + return [member_on_edge(path[i], path[(i + 1) % n], c, shift) + for i in range(n)] + + +def fin_web(n: int, side: float, fin: float, rotation: float, g: Geo) -> float: + cv = [cavity_path(m, g) for m in fin_members(n, side, fin, rotation, g)] + return min(sb_path_gap(cv[i], cv[(i + n - 1) % n]) for i in range(n)) + + +def fin_bore_side(n: int, side: float, g: Geo) -> float: + """Side length of the bore left by N inside walls around a regular core.""" + t = tan_d(180.0 / n) + return 2.0 * t * (side / (2.0 * t) - g.reach_inside) + + +def fin_profile(n: int, fin: float, web: float, bore_side: float, + rotation: float, fillet_r: float, g: Geo, + label: str) -> Profile: + """ + The core size is not a free parameter. It is solved so the cyclic junctions + carry the declared web AND the bore reaches its declared minimum, whichever + demands more; the fin projection is then exact because it is measured + against that same solved polygon. + + Each member's trailing end is run through the member behind it and cut off + flush on its far face, so every junction has a full-width overlap and the + fillet that follows is cosmetic. Junctions that merely touch at a corner and + rely on a fillet to bridge them are not load paths. + """ + W = g.width + hi = 10.0 * W + + def fweb(s: float) -> float: + return fin_web(n, s, fin, rotation, g) + + def fbor(s: float) -> float: + return fin_bore_side(n, s, g) + + if not sb_solvable(fweb, hi, web): + return profile_failed( + "%s: cannot open a %s mm junction web. Reduce the web or the wall " + "thicknesses." % (label, web)) + if not sb_solvable(fbor, hi, bore_side): + return profile_failed( + "%s: cannot reach a %s mm bore. Reduce the requested bore." + % (label, bore_side)) + + side = max(sb_solve(fweb, 0.1, hi, web), + sb_solve(fbor, 0.1, hi, bore_side)) + ms = fin_members(n, side, fin, rotation, g) + # Member i butts through member i-1, the one whose fin crosses the corner + # that member i stops short of. + paths = [sleeve_butt(ms[i], g, ms[(i + n - 1) % n]) for i in range(n)] + pairs = [((i + n - 1) % n, i) for i in range(n)] + shell = fillet_junctions(sleeve_shell(paths), pairs, paths, fillet_r) + bore = bore_from_members(ms, g) + setback = fin + side - W + + return Profile( + members=ms, + shell=shell, + bore=bore, + checks=[ + check(bore_valid(bore, ms, g), + "%s: the central bore has collapsed. Raise the requested bore " + "or reduce inside_wall_thickness_mm." % label), + check(setback > 0, + "%s: the solved trailing setback is %s mm, so the members " + "overlap instead of stepping cyclically. Increase the fin " + "projection." % (label, setback)), + check(fillet_r > 0, + "%s: the junction fillet radius must be greater than zero. A " + "butt joint with no fillet meets its neighbour along an " + "exactly tangent boundary, which is a valid outline but cannot " + "be tessellated." % label), + ], + info={ + "FIN_CORE_SIDE_MM": side, + "FIN_PROJECTION_MM": fin, + "FIN_SETBACK_MM": setback, + "FIN_BORE_SIDE_MM": fin_bore_side(n, side, g), + "FIN_JUNCTION_WEB_MM": fin_web(n, side, fin, rotation, g), + }, + ) diff --git a/src/mechcomp/geom/core.py b/src/mechcomp/geom/core.py new file mode 100644 index 0000000..e765f2f --- /dev/null +++ b/src/mechcomp/geom/core.py @@ -0,0 +1,141 @@ +""" +Port of ``legacy/openscad/lib/sb-core.scad`` -- the PROFILE record and assembly. + +A strap beam is N pallet-strap bundles running parallel to a common +longitudinal axis, held in a printed PLA+ enclosure. A profile only decides how +the N cross-sections are arranged in XY; that arrangement is then swept along Z. +Member length in the cross-section is therefore the strap's WIDTH, never the +beam's length. + +CONTRACT FOR A PROFILE BUILDER + Takes a GEO record, returns a Profile: + + members one Member per strap bundle + shell region: all PLA+ before any void is removed + bore path: the enclosed central void, or empty if there is none + checks from ``check()``, covering only this profile's own parameters + info extra keys to add to the report + + The core removes the bore and all cavities from the shell in one step, so + one member's plastic can never fill another member's channel. A builder + should not be doing its own boolean algebra. + +FAILURE + OpenSCAD has no exceptions, so a builder that cannot produce a usable + arrangement returns an empty profile carrying a single failing check. The + message then reaches the caller through the ordinary check list rather than + through a separate path. That is reproduced here rather than raising early, + because it keeps profile-specific rejections and universal-check rejections + in one order-sensitive list -- and the reference reports the first failure. + +DESIGN RULES THE LIBRARY ENFORCES + 1. Declared webs are cavity-to-cavity. A stated 1.2 mm web is 1.2 mm of + plastic; the library adds the fit clearance internally. + 2. Placement that cannot be derived exactly is solved numerically against + the measured web, not approximated with a closed form. + 3. Junctions are structural before they are pretty: members butt through + their neighbours, and fillets are applied on top of that overlap. + 4. A member with no enclosed side gets the outside wall on both faces, so + asymmetric wall settings never make a symmetric profile chiral. + 5. Validation measures the finished section. Connectivity is necessary but + never sufficient; the minimum wall is what is actually checked. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Sequence, Tuple + +from .join import cavity_region, centering_shift, section as cut_section, \ + strap_region +from .records import Geo, Member +from .region import Region, clean_region +from .report import Check, check + +Point = Tuple[float, float] + + +@dataclass(frozen=True) +class Profile: + members: List[Member] + shell: Region + bore: List[Point] = field(default_factory=list) + checks: List[Check] = field(default_factory=list) + info: Dict[str, object] = field(default_factory=dict) + + @property + def ok(self) -> bool: + """A failed builder returns no members, which is how failure is signalled.""" + return len(self.members) > 0 + + +def profile_failed(message: str) -> Profile: + """ + What a builder returns instead of guessing. + + The message travels as an ordinary failing check, so it is subject to the + same first-failure ordering as everything else. + """ + return Profile(members=[], shell=[], bore=[], + checks=[check(False, message)], info={}) + + +# ---------------------------------------------------------------------------- +# Centred results +# ---------------------------------------------------------------------------- + +def _move(shift: Point, paths: Sequence[Sequence[Point]]) -> Region: + return [[(p[0] + shift[0], p[1] + shift[1]) for p in path] for path in paths] + + +@dataclass(frozen=True) +class Centred: + section: Region + straps: Region + cavity: Region + shell: Region + shift: Point + members: List[Member] + """ + Members translated by the same shift. + + Carried here rather than left to the caller because measuring a shifted + shell against unshifted members silently reports every cavity as escaping + the envelope -- a leak of the whole cavity area, from geometry that is + perfectly fine. Returning them together removes the opportunity. + """ + + +def centred(p: Profile, g: Geo) -> Centred: + """ + Cut the section, clean it, and shift everything onto a centred origin. + + Two orderings here are load-bearing. + + The section is cleaned *before* anything measures it. Exact butt joints and + zero-radius fillets leave coincident and collinear vertices that are + harmless in 2D but leave zero-area triangles the tessellator cannot resolve. + Measuring first and cleaning afterwards would report on geometry that is not + what gets extruded. + + The shift is applied to the section, the straps, the cavities and the shell + together, so they stay registered with each other. Everything is generated + about whatever origin the profile found natural and moved once at the end. + """ + shell = p.shell + shift = centering_shift(shell) + sec = clean_region(cut_section(shell, p.members, g, p.bore)) + return Centred( + section=_move(shift, sec), + straps=_move(shift, strap_region(p.members, g)), + cavity=_move(shift, cavity_region(p.members, g)), + shell=_move(shift, shell), + shift=shift, + members=centred_members(p, shift), + ) + + +def centred_members(p: Profile, shift: Point) -> List[Member]: + """Members translated by the same shift, for drawing individual laminae.""" + return [Member(m.cx + shift[0], m.cy + shift[1], m.angle, m.face) + for m in p.members] diff --git a/src/mechcomp/geom/report.py b/src/mechcomp/geom/report.py index 503f057..d5a59d5 100644 --- a/src/mechcomp/geom/report.py +++ b/src/mechcomp/geom/report.py @@ -179,6 +179,18 @@ def echo_num(value: float) -> float: return float("%g" % value) +def echo_vec(values) -> str: + """ + Format a sequence as OpenSCAD prints a vector: ``[a, b, c]``, each element + at six significant figures. + + The harvester scraped `SB_KEY=value` text lines, so a vector reached the + oracle as the string OpenSCAD printed. RING_EDGES_MM is recorded as + '[20.5209, 20.5209, 20.5209]', not as a list. + """ + return "[%s]" % ", ".join("%g" % v for v in values) + + def report(family: str, profile: str, status: str, g: Geo, m: Metrics, length_mm: float, density_g_cm3: float, extra: Optional[Dict[str, object]] = None) -> Dict[str, object]: @@ -219,8 +231,14 @@ def report(family: str, profile: str, status: str, g: Geo, m: Metrics, } for key, value in (extra or {}).items(): - out[key] = echo_num(value) if isinstance(value, (int, float)) \ - and not isinstance(value, bool) else value + if isinstance(value, bool): + out[key] = value + elif isinstance(value, (int, float)): + out[key] = echo_num(value) + elif isinstance(value, (list, tuple)): + out[key] = echo_vec(value) + else: + out[key] = value return out diff --git a/tests/test_arrangements.py b/tests/test_arrangements.py new file mode 100644 index 0000000..1331e1a --- /dev/null +++ b/tests/test_arrangements.py @@ -0,0 +1,405 @@ +""" +Unit tests for the PROFILE record and the three N-generic arrangements. + +Several expected values here are taken directly from the committed oracle -- the +solved spoke radius, the solved fin core side, the setback, the junction web, +the ring corner limit and the edge vector. Those quantities do not depend on the +junction fillet radius, so they can be pinned before the generator supplies its +parameter defaults. + +The arrangements are genuinely N-generic in the reference, so each is exercised +at both N=3 and N=4 rather than only at the member count that happens to be +under development. +""" + +from __future__ import annotations + +import math + +import pytest + +from mechcomp.geom.arrangements import ( + fin_bore_side, + fin_members, + fin_profile, + fin_web, + regular_polygon, + ring_polygon_profile, + spoke_members, + spoke_profile, + spoke_web, +) +from mechcomp.geom.core import Centred, Profile, centred, centred_members, \ + profile_failed +from mechcomp.geom.primitives import SB_FACE_BOTH_OUT, sb_dist, sb_signed_area +from mechcomp.geom.records import Geo +from mechcomp.geom.region import area, is_region_simple, nparts, pointlist_bounds +from mechcomp.geom.report import ( + echo_num, + echo_vec, + first_failure, + metrics, + universal_checks, +) + + +def geo(**kw) -> Geo: + """The oracle's default geometry, read from a recorded report.""" + base = dict(width=15.875, strap_t=0.508, count=1, clearance=0.25, + wall_inside=1.2, wall_outside=1.2, wall_edge=1.2, min_wall=1.2) + base.update(kw) + return Geo(**base) + + +def unit_ngon(n, start=90.0): + return [(math.cos(math.radians(start + i * 360.0 / n)), + math.sin(math.radians(start + i * 360.0 / n))) for i in range(n)] + + +def built(p: Profile, g: Geo, n: int): + c = centred(p, g) + m = metrics(c.section, c.shell, c.members, g) + fail = first_failure(list(p.checks) + universal_checks(c.section, m, n, g)) + return c, m, fail + + +# ---------------------------------------------------------------------------- +# PROFILE record +# ---------------------------------------------------------------------------- + +def test_a_built_profile_is_ok(): + p = spoke_profile(3, 90.0, 1.2, 1.5, geo(), "Y") + assert p.ok + assert len(p.members) == 3 + + +def test_profile_failed_carries_the_message_as_a_check(): + """ + OpenSCAD has no exceptions, so failure travels as an ordinary failing check + and stays subject to the same first-failure ordering as everything else. + """ + p = profile_failed("Y: cannot open a 99 mm web.") + assert not p.ok + assert p.members == [] and p.shell == [] + assert first_failure(p.checks) == "Y: cannot open a 99 mm web." + + +def test_failure_is_signalled_by_having_no_members(): + assert not Profile(members=[], shell=[]).ok + + +# ---------------------------------------------------------------------------- +# Centring +# ---------------------------------------------------------------------------- + +def test_centred_puts_the_envelope_on_the_origin(): + g = geo() + c = centred(spoke_profile(3, 90.0, 1.2, 1.5, g, "Y"), g) + lo, hi = pointlist_bounds([p for path in c.shell 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_centred_keeps_everything_registered(): + """ + Section, straps, cavities and shell take the same shift, so a strap still + sits inside its own channel afterwards. + """ + g = geo() + c = centred(spoke_profile(3, 90.0, 1.2, 1.5, g, "Y"), g) + assert area(c.straps) < area(c.cavity) < area(c.shell) + + +def test_centred_carries_shifted_members(): + """ + Measuring a shifted shell against unshifted members reports every cavity as + escaping the envelope -- a leak of the whole cavity area from geometry that + is perfectly fine. The members travel with the regions to remove the chance. + """ + g = geo() + p = spoke_profile(3, 90.0, 1.2, 1.5, g, "Y") + c = centred(p, g) + assert metrics(c.section, c.shell, c.members, g).leak == pytest.approx(0.0, abs=1e-9) + stale = metrics(c.section, c.shell, p.members, g) + assert stale.leak > 1.0 + + +def test_centred_members_apply_the_same_shift(): + g = geo() + p = spoke_profile(3, 90.0, 1.2, 1.5, g, "Y") + c = centred(p, g) + for a, b in zip(p.members, centred_members(p, c.shift)): + assert b.cx == pytest.approx(a.cx + c.shift[0]) + assert b.angle == a.angle and b.face == a.face + + +def test_section_is_cleaned_before_it_is_measured(): + """ + Cleaning removes collinear and coincident vertices left by exact butt + joints. Measuring first would report on geometry that is not what gets + extruded. + """ + g = geo() + c = centred(fin_profile(3, 6.25, 1.2, 7.5, 0.0, 1.5, g, "Three-Fin"), g) + for path in c.section: + assert len(path) >= 3 + assert is_region_simple(c.section) + + +# ---------------------------------------------------------------------------- +# Ring arrangement +# ---------------------------------------------------------------------------- + +def test_ring_rejects_a_degenerate_outline(): + p = ring_polygon_profile([(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)], + geo(), 1.2, 0.0, "Equilateral Triangle") + assert not p.ok + assert "degenerate" in first_failure(p.checks) + + +def test_ring_rejects_an_unreachable_web(): + p = ring_polygon_profile(unit_ngon(3), geo(), 1e6, 0.0, "Equilateral Triangle") + assert not p.ok + assert "no polygon size gives" in first_failure(p.checks) + + +def test_ring_matches_the_oracles_solved_geometry(): + """ + Equilateral Triangle at the oracle's defaults. These four values are + recorded in the committed fixture set and none of them depends on the + junction fillet radius. + """ + g = geo() + p = ring_polygon_profile(unit_ngon(3), g, 1.2, 0.0, "Equilateral Triangle") + assert p.ok + assert echo_num(p.info["RING_CORNER_R_MAX_MM"]) == 2.87663 + assert echo_num(p.info["RING_CORNER_WEB_MM"]) == 1.2 + assert echo_vec(p.info["RING_EDGES_MM"]) == "[20.5209, 20.5209, 20.5209]" + + +def test_ring_scale_depends_on_the_seed_size_not_the_shape(): + """ + RING_SCALE is relative to the outline supplied, so it is a property of the + caller's seed. The solved edge length is the size-independent quantity. + """ + g = geo() + small = ring_polygon_profile(unit_ngon(3), g, 1.2, 0.0, "t") + large = ring_polygon_profile([(200 * x, 200 * y) for x, y in unit_ngon(3)], + g, 1.2, 0.0, "t") + assert small.info["RING_SCALE"] != pytest.approx(large.info["RING_SCALE"]) + assert echo_vec(small.info["RING_EDGES_MM"]) == echo_vec(large.info["RING_EDGES_MM"]) + + +def test_ring_rejects_a_corner_radius_beyond_the_envelope_limit(): + g = geo() + p = ring_polygon_profile(unit_ngon(3), g, 1.2, 50.0, "Equilateral Triangle") + fail = first_failure(p.checks) + assert fail is not None and "corner radius" in fail + + +def test_ring_builds_at_n3_and_n4(): + g = geo() + for n in (3, 4): + p = ring_polygon_profile(unit_ngon(n), g, 1.2, 0.0, "ring") + assert p.ok + c, m, fail = built(p, g, n) + assert fail is None + assert m.parts == 1 and m.slots == n + + +def test_ring_has_an_enclosed_bore(): + g = geo() + p = ring_polygon_profile(unit_ngon(3), g, 1.2, 0.0, "ring") + assert len(p.bore) == 3 + c, m, _ = built(p, g, 3) + assert len(c.section) > 1 + + +# ---------------------------------------------------------------------------- +# Spoke arrangement +# ---------------------------------------------------------------------------- + +def test_spokes_face_open_air_on_both_sides(): + """ + Design rule 4. Forcing an interior direction on a spoke is what made earlier + revisions chiral under asymmetric wall settings. + """ + for m in spoke_members(3, 9.0, 90.0): + assert m.face == SB_FACE_BOTH_OUT + assert m.inside_dir is None + + +def test_spoke_profile_has_no_bore(): + p = spoke_profile(3, 90.0, 1.2, 1.5, geo(), "Y") + assert p.bore == [] + assert p.info["NOTE"] == "inside wall unused: no enclosed bore" + + +def test_spoke_radius_matches_the_oracle(): + """Y at the oracle's defaults records SPOKE_RADIUS_MM = 9.1713.""" + p = spoke_profile(3, 90.0, 1.2, 1.5, geo(), "Y") + assert echo_num(p.info["SPOKE_RADIUS_MM"]) == 9.1713 + assert echo_num(p.info["SPOKE_WEB_MM"]) == 1.2 + + +def test_spoke_envelope_matches_the_oracle(): + g = geo() + c, m, _ = built(spoke_profile(3, 90.0, 1.2, 1.5, g, "Y"), g, 3) + assert echo_num(m.size_x) == 33.8488 + assert echo_num(m.size_y) == 29.3139 + + +def test_spoke_solve_hits_the_requested_web(): + g = geo() + for web in (1.0, 1.2, 2.0): + p = spoke_profile(3, 90.0, web, 1.5, g, "Y") + assert spoke_web(3, p.info["SPOKE_RADIUS_MM"], 90.0, g) == \ + pytest.approx(web, abs=1e-6) + + +def test_spoke_rejects_an_unreachable_web(): + p = spoke_profile(3, 90.0, 1e6, 1.5, geo(), "Y") + assert not p.ok + assert "cannot open a" in first_failure(p.checks) + + +def test_spoke_requires_a_positive_fillet(): + """ + A hull-plugged centre with no fillet meets the spokes along an exactly + tangent boundary: a valid outline that cannot be tessellated. + """ + p = spoke_profile(3, 90.0, 1.2, 0.0, geo(), "Y") + fail = first_failure(p.checks) + assert fail is not None and "greater than zero" in fail + + +def test_spokes_build_at_n3_and_n4(): + g = geo() + for n in (3, 4): + c, m, fail = built(spoke_profile(n, 90.0, 1.2, 1.5, g, "spoke"), g, n) + assert fail is None + assert m.parts == 1 and m.slots == n + + +# ---------------------------------------------------------------------------- +# Fin arrangement +# ---------------------------------------------------------------------------- + +def test_regular_polygon_has_the_requested_side_length(): + for n in (3, 4, 5): + path = regular_polygon(n, 10.0) + for i in range(n): + assert sb_dist(path[i], path[(i + 1) % n]) == pytest.approx(10.0) + + +def test_fins_lie_tangentially_not_radially(): + """ + Each member lies along a side of the core polygon, slid so it stops short of + the corner behind and overhangs the corner ahead. Sliding is what makes the + fin. + """ + g = geo() + plain = fin_members(3, 13.4, 0.0, 0.0, g) + slid = fin_members(3, 13.4, 6.25, 0.0, g) + for a, b in zip(plain, slid): + assert a.angle == pytest.approx(b.angle) + assert sb_dist((a.cx, a.cy), (b.cx, b.cy)) == pytest.approx(6.25) + + +def test_fin_geometry_matches_the_oracle(): + """Three-Fin at the oracle's defaults. None of these depends on the fillet.""" + g = geo() + p = fin_profile(3, 6.25, 1.2, 7.5, 0.0, 1.5, g, "Three-Fin") + assert p.ok + assert echo_num(p.info["FIN_CORE_SIDE_MM"]) == 13.4028 + assert echo_num(p.info["FIN_BORE_SIDE_MM"]) == 7.5 + assert echo_num(p.info["FIN_SETBACK_MM"]) == 3.77783 + assert echo_num(p.info["FIN_JUNCTION_WEB_MM"]) == 2.29919 + assert echo_num(p.info["FIN_PROJECTION_MM"]) == 6.25 + + +def test_fin_core_is_solved_for_whichever_constraint_binds(): + """ + The core size satisfies the junction web AND the bore, whichever demands + more. Raising the bore requirement past the web's must grow the core. + """ + g = geo() + small_bore = fin_profile(3, 6.25, 1.2, 1.0, 0.0, 1.5, g, "f") + large_bore = fin_profile(3, 6.25, 1.2, 20.0, 0.0, 1.5, g, "f") + assert large_bore.info["FIN_CORE_SIDE_MM"] > small_bore.info["FIN_CORE_SIDE_MM"] + + +def test_fin_bore_side_shrinks_with_a_thicker_inside_wall(): + assert fin_bore_side(3, 13.4028, geo(wall_inside=2.4)) < \ + fin_bore_side(3, 13.4028, geo(wall_inside=1.2)) + + +def test_fin_rejects_an_unreachable_web(): + p = fin_profile(3, 6.25, 1e6, 7.5, 0.0, 1.5, geo(), "Three-Fin") + assert not p.ok + assert "cannot open a" in first_failure(p.checks) + + +def test_fin_rejects_an_unreachable_bore(): + p = fin_profile(3, 6.25, 1.2, 1e6, 0.0, 1.5, geo(), "Three-Fin") + assert not p.ok + assert "cannot reach a" in first_failure(p.checks) + + +def test_fin_requires_a_positive_setback(): + """ + A non-positive setback means the members overlap instead of stepping + cyclically, so the arrangement is no longer what it claims to be. + """ + g = geo() + p = fin_profile(3, 0.0, 1.2, 7.5, 0.0, 1.5, g, "Three-Fin") + fail = first_failure(p.checks) + if fail is not None: + assert "setback" in fail or "bore" in fail + + +def test_fin_requires_a_positive_fillet(): + p = fin_profile(3, 6.25, 1.2, 7.5, 0.0, 0.0, geo(), "Three-Fin") + fail = first_failure(p.checks) + assert fail is not None and "greater than zero" in fail + + +def test_fins_build_at_n3_and_n4(): + g = geo() + for n in (3, 4): + c, m, fail = built(fin_profile(n, 6.25, 1.2, 7.5, 0.0, 1.5, g, "fin"), g, n) + assert fail is None + assert m.parts == 1 and m.slots == n + + +def test_fin_has_an_enclosed_bore(): + g = geo() + p = fin_profile(3, 6.25, 1.2, 7.5, 0.0, 1.5, g, "Three-Fin") + assert len(p.bore) == 3 + + +def test_cleaning_actually_removes_the_collinear_vertices(): + """ + Found by mutation testing: asserting the section is simple and has three or + more points per path passes with or without cleaning, so neither shows that + cleaning did anything. + + A Three-Fin section carries 17 collinear vertices before cleaning -- exact + butt joints leave them -- and none afterwards. They are harmless in 2D and + leave zero-area triangles the tessellator cannot resolve, so a section that + measures perfectly still fails to extrude. + """ + from mechcomp.geom.join import section as cut_section + from mechcomp.geom.rounding import is_collinear + + g = geo() + p = fin_profile(3, 6.25, 1.2, 7.5, 0.0, 1.5, g, "Three-Fin") + + def collinear(rgn): + return sum(1 for path in rgn for i in range(len(path)) + if is_collinear((path[(i - 1) % len(path)], path[i], + path[(i + 1) % len(path)]))) + + raw = cut_section(p.shell, p.members, g, p.bore) + assert collinear(raw) > 0 + assert collinear(centred(p, g).section) == 0