diff --git a/src/mechcomp/geom/__init__.py b/src/mechcomp/geom/__init__.py index 37a3e0a..f3e02e7 100644 --- a/src/mechcomp/geom/__init__.py +++ b/src/mechcomp/geom/__init__.py @@ -1 +1,49 @@ -"""Mechanical Compiler - geom.""" +"""Geometry primitives ported from ``legacy/openscad/lib/sb-geom.scad``.""" + +from .primitives import ( # noqa: F401 + SB_EPS, + SB_FACE_BOTH_OUT, + SB_FACE_MINUS_IN, + SB_FACE_PLUS_IN, + SB_SQRT3, + Path, + Point, + atan2_d, + cos_d, + sb_ccw, + sb_centroid, + sb_corner_radii, + sb_cross2, + sb_dist, + sb_line_isect, + sb_mid, + sb_path_gap, + sb_path_max_round, + sb_paths_cross, + sb_pt_path_dist, + sb_pt_seg_dist, + sb_region_min_gap, + sb_rot2, + sb_segs_cross, + sb_signed_area, + sb_solvable, + sb_solve, + sin_d, + tan_d, + vector_angle, +) +from .records import ( # noqa: F401 + Geo, + Member, + cavity_path, + face_toward, + local_rect, + member_on_edge, + member_radial, + place, + sleeve_path, + sleeve_span, + sleeve_to_line, + strap_layer_paths, + strap_path, +) diff --git a/src/mechcomp/geom/primitives.py b/src/mechcomp/geom/primitives.py new file mode 100644 index 0000000..f116331 --- /dev/null +++ b/src/mechcomp/geom/primitives.py @@ -0,0 +1,290 @@ +""" +Port of ``legacy/openscad/lib/sb-geom.scad`` -- the pure-geometry half. + +Everything here is a direct translation of the rev-8.0.0 reference. Nothing in +this module knows how many straps a profile has, so it is reused unchanged by +the 3x, 4x and any later N-strap arrangement. + +Two conventions are carried over from the reference deliberately: + +**Angles are degrees.** OpenSCAD's ``cos``/``sin``/``tan``/``atan2`` and BOSL2's +``vector_angle`` all work in degrees. Converting to radians at the boundary would +make each formula differ from its source line, so the degree helpers below are +used instead and every expression reads the same as the ``.scad``. + +**The arbitrary constants are contract, not taste.** The 44 solver iterations, +the 0.999 and 0.98 scale factors, the 0.05/179.95 degree cutoffs and the 1e9 +sentinel all shaped the frozen oracle. They are reproduced exactly. + +Region operations -- area, connected parts, cleaning -- are not here. They need +a 2D boolean kernel and live in the Shapely-backed module. +""" + +from __future__ import annotations + +import math +from typing import Callable, Optional, Sequence, Tuple + +Point = Tuple[float, float] +Path = Sequence[Point] + +SB_SQRT3 = math.sqrt(3.0) +SB_EPS = 1e-7 + +# Face modes -- how a member's two broad faces are walled. +SB_FACE_PLUS_IN = 1 # local +Y faces an enclosed interior -> inside wall +SB_FACE_MINUS_IN = -1 # local -Y faces an enclosed interior -> inside wall +SB_FACE_BOTH_OUT = 0 # neither face encloses anything -> outside wall both + + +# ---------------------------------------------------------------------------- +# Degree-based trigonometry +# ---------------------------------------------------------------------------- + +def cos_d(a: float) -> float: + return math.cos(math.radians(a)) + + +def sin_d(a: float) -> float: + return math.sin(math.radians(a)) + + +def tan_d(a: float) -> float: + return math.tan(math.radians(a)) + + +def atan2_d(y: float, x: float) -> float: + return math.degrees(math.atan2(y, x)) + + +def vector_angle(prev: Point, here: Point, nxt: Point) -> float: + """ + Angle at ``here`` between the segments to ``prev`` and ``nxt``, in degrees. + + Matches BOSL2's three-point ``vector_angle``: always in [0, 180], never + signed. Degenerate zero-length arms return 0, which the callers' flat-vertex + cutoffs then treat as unroundable. + """ + ux, uy = prev[0] - here[0], prev[1] - here[1] + vx, vy = nxt[0] - here[0], nxt[1] - here[1] + nu = math.hypot(ux, uy) + nv = math.hypot(vx, vy) + if nu < SB_EPS or nv < SB_EPS: + return 0.0 + c = (ux * vx + uy * vy) / (nu * nv) + return math.degrees(math.acos(max(-1.0, min(1.0, c)))) + + +# ---------------------------------------------------------------------------- +# Small vector helpers +# ---------------------------------------------------------------------------- + +def sb_rot2(p: Point, a: float) -> Point: + return (p[0] * cos_d(a) - p[1] * sin_d(a), + p[0] * sin_d(a) + p[1] * cos_d(a)) + + +def sb_mid(a: Point, b: Point) -> Point: + return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0) + + +def sb_dist(a: Point, b: Point) -> float: + return math.hypot(b[0] - a[0], b[1] - a[1]) + + +def sb_cross2(a: Point, b: Point) -> float: + return a[0] * b[1] - a[1] * b[0] + + +def sb_centroid(pts: Path) -> Point: + n = len(pts) + return (sum(p[0] for p in pts) / n, sum(p[1] for p in pts) / n) + + +def sb_signed_area(path: Path) -> float: + """Signed area; positive means counter-clockwise.""" + n = len(path) + total = 0.0 + for i in range(n): + a = path[i] + b = path[(i + 1) % n] + total += a[0] * b[1] - b[0] * a[1] + return total / 2.0 + + +def sb_ccw(path: Path) -> list[Point]: + return list(path) if sb_signed_area(path) >= 0 else list(reversed(path)) + + +def sb_line_isect(p1: Point, d1: Point, p2: Point, d2: Point) -> Optional[Point]: + """Intersection of line (p1,d1) with line (p2,d2). None if parallel.""" + denom = sb_cross2(d1, d2) + if abs(denom) < SB_EPS: + return None + delta = (p2[0] - p1[0], p2[1] - p1[1]) + t = sb_cross2(delta, d2) / denom + return (p1[0] + t * d1[0], p1[1] + t * d1[1]) + + +# ---------------------------------------------------------------------------- +# Measurement -- exact distance between two closed polylines +# ---------------------------------------------------------------------------- +# +# The minimum distance between two disjoint polygons is always attained at a +# vertex of one of them, so sampling every vertex against every segment of the +# other (both ways round) is exact, not an approximation. + +def sb_pt_seg_dist(p: Point, a: Point, b: Point) -> float: + abx, aby = b[0] - a[0], b[1] - a[1] + l2 = abx * abx + aby * aby + if l2 < SB_EPS: + return sb_dist(p, a) + t = ((p[0] - a[0]) * abx + (p[1] - a[1]) * aby) / l2 + t = max(0.0, min(1.0, t)) + return sb_dist(p, (a[0] + t * abx, a[1] + t * aby)) + + +def sb_pt_path_dist(p: Point, path: Path) -> float: + n = len(path) + return min(sb_pt_seg_dist(p, path[i], path[(i + 1) % n]) for i in range(n)) + + +def sb_segs_cross(a1: Point, a2: Point, b1: Point, b2: Point) -> bool: + """Do two segments properly cross or touch?""" + d1 = (a2[0] - a1[0], a2[1] - a1[1]) + d2 = (b2[0] - b1[0], b2[1] - b1[1]) + den = sb_cross2(d1, d2) + if abs(den) < SB_EPS: + return False + w = (b1[0] - a1[0], b1[1] - a1[1]) + t = sb_cross2(w, d2) / den + u = sb_cross2(w, d1) / den + return 0.0 <= t <= 1.0 and 0.0 <= u <= 1.0 + + +def sb_paths_cross(p: Path, q: Path) -> bool: + lp, lq = len(p), len(q) + for i in range(lp): + for j in range(lq): + if sb_segs_cross(p[i], p[(i + 1) % lp], q[j], q[(j + 1) % lq]): + return True + return False + + +def sb_path_gap(p: Path, q: Path) -> float: + """ + Minimum distance between two closed paths. + + Two polygons that CROSS may have no vertex near the other's boundary at all, + and the naive vertex test then reports a comfortable clearance across an + outright overlap -- exactly the false pass that lets a solver settle on a + degenerate arrangement. Crossing is therefore tested first and reported as + zero. + + Nesting is deliberately not treated as overlap: a hole inside an outer + boundary is the normal case, and the distance between them is the wall + thickness this whole library exists to measure. + """ + if sb_paths_cross(p, q): + return 0.0 + return min(min(sb_pt_path_dist(v, q) for v in p), + min(sb_pt_path_dist(v, p) for v in q)) + + +def sb_region_min_gap(rgn: Sequence[Path]) -> float: + """ + Minimum distance between any two paths in a region. + + For a finished cross-section this is the thinnest surviving piece of PLA+. + Fewer than two paths yields the reference's 1e9 sentinel rather than + infinity, because that value reaches the report unchanged. + """ + n = len(rgn) + if n < 2: + return 1e9 + return min(sb_path_gap(rgn[i], rgn[j]) + for i in range(n - 1) for j in range(i + 1, n)) + + +# ---------------------------------------------------------------------------- +# Corner rounding limits +# ---------------------------------------------------------------------------- + +def sb_corner_radii(path: Path, r: float) -> list[float]: + """ + Per-vertex roundover radius: ``r`` at reflex corners, 0 elsewhere, clamped + so the tangent points stay on their own edges. + + Probing this by trial is not an option because BOSL2's rounding routine + raises a library-level error rather than returning a flag, so it is derived + up front. + """ + n = len(path) + cw = sb_signed_area(path) < 0 + out: list[float] = [] + for i in range(n): + prev = path[(i + n - 1) % n] + here = path[i] + nxt = path[(i + 1) % n] + turn = sb_cross2((here[0] - prev[0], here[1] - prev[1]), + (nxt[0] - here[0], nxt[1] - here[1])) + reflex = (turn > SB_EPS) if cw else (turn < -SB_EPS) + ang = vector_angle(prev, here, nxt) + if ang <= 0.05 or ang >= 179.95: + fits = 0.0 + else: + fits = (0.98 * min(sb_dist(prev, here), sb_dist(here, nxt)) + / 2.0 * tan_d(ang / 2.0)) + out.append(min(r, fits) if reflex else 0.0) + return out + + +def sb_path_max_round(path: Path) -> float: + """Largest uniform corner radius the path can physically accept.""" + n = len(path) + if n < 3: + return 0.0 + terms: list[float] = [] + for i in range(n): + prev = path[(i + n - 1) % n] + here = path[i] + nxt = path[(i + 1) % n] + l1 = sb_dist(prev, here) + l2 = sb_dist(here, nxt) + ang = vector_angle(prev, here, nxt) + if ang <= 0.05 or ang >= 179.95: + terms.append(1e9) + else: + terms.append(min(l1, l2) / 2.0 * tan_d(ang / 2.0)) + return 0.999 * min(terms) + + +# ---------------------------------------------------------------------------- +# Monotone solver +# ---------------------------------------------------------------------------- + +def sb_solve(f: Callable[[float], float], lo: float, hi: float, + target: float, iters: int = 44) -> float: + """ + Bisection for "place this member so that the resulting web is exactly W". + + Rather than deriving a closed form per profile -- the source of most of the + wrong-by-a-cosine errors in earlier revisions -- the real measured quantity + is solved numerically. ``f`` must be non-decreasing on [lo, hi]. + + Fixed iteration count with no convergence test, matching the reference: the + number of evaluations is part of what produced the frozen values. + """ + while iters > 0: + mid = (lo + hi) / 2.0 + if f(mid) < target: + lo = mid + else: + hi = mid + iters -= 1 + return (lo + hi) / 2.0 + + +def sb_solvable(f: Callable[[float], float], hi: float, target: float) -> bool: + """True when f(hi) actually reaches the target, i.e. the solve is feasible.""" + return f(hi) >= target diff --git a/src/mechcomp/geom/records.py b/src/mechcomp/geom/records.py new file mode 100644 index 0000000..1b62071 --- /dev/null +++ b/src/mechcomp/geom/records.py @@ -0,0 +1,308 @@ +""" +The GEO and MEMBER records from ``sb-geom.scad``, and the cross-section paths +for a single member. + +GEO + Everything about a single strap bundle and the PLA+ that wraps it. Built + once per build and threaded through every call. + +MEMBER + One strap bundle's cross-section placement: centre, angle, and which broad + face (if either) looks into an enclosed interior. + +Coordinate convention for a member + local +X = along the strap's WIDTH (the 15.875 mm direction) + local +Y = along the strap's THICKNESS (the 0.508 mm direction) + The member's angle rotates local +X onto the global direction given. + "normal+" is local +Y expressed globally. + +The reference stores both records as bare indexed lists. They are dataclasses +here because the field names carry the meaning and Python has no reason to +reproduce a positional layout that existed to work around OpenSCAD's lack of +structures. Field order is preserved regardless, so the two can be compared. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + +from .primitives import ( + Path, + Point, + SB_FACE_BOTH_OUT, + SB_FACE_MINUS_IN, + SB_FACE_PLUS_IN, + atan2_d, + cos_d, + sb_ccw, + sb_line_isect, + sb_mid, + sin_d, +) + + +# ---------------------------------------------------------------------------- +# GEO record +# ---------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Geo: + """A strap bundle and its surrounding PLA+.""" + + width: float # nominal strap width + strap_t: float # one strap's thickness + count: int # straps per bundle + clearance: float # fit clearance, applied to every cavity face + wall_inside: float # inside wall + wall_outside: float # outside wall + wall_edge: float # edge wall (caps the strap's narrow edges) + min_wall: float # minimum acceptable PLA thickness anywhere + + @property + def bundle_t(self) -> float: + return self.count * self.strap_t + + # Cavity = strap bundle grown by the fit clearance on all four faces. + @property + def cavity_w(self) -> float: + return self.width + 2.0 * self.clearance + + @property + def cavity_t(self) -> float: + return self.bundle_t + 2.0 * self.clearance + + def web_to_strap_gap(self, web: float) -> float: + """ + A declared "web" is the PLA+ that must survive between two neighbouring + strap CAVITIES. Because every cavity is inflated by the clearance, the + corresponding gap between the physical STRAPS is larger. Callers state + the web they want; this converts to the strap-to-strap spacing that + produces it, so a declared 1.2 mm web really is 1.2 mm of plastic. + """ + return web + 2.0 * self.clearance + + # Distance from a member centreline out to each of its four sleeve faces. + def reach_plus(self, face: int) -> float: + return self.cavity_t / 2.0 + (self.wall_inside if face > 0 + else self.wall_outside) + + def reach_minus(self, face: int) -> float: + return self.cavity_t / 2.0 + (self.wall_inside if face < 0 + else self.wall_outside) + + @property + def reach_inside(self) -> float: + """ + Distance from centreline to the enclosed-interior side of the sleeve. + Only meaningful when the member actually has an interior face. + """ + return self.cavity_t / 2.0 + self.wall_inside + + +# ---------------------------------------------------------------------------- +# MEMBER record +# ---------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Member: + cx: float + cy: float + angle: float + face: int = SB_FACE_BOTH_OUT + + @property + def centre(self) -> Point: + return (self.cx, self.cy) + + @property + def axis(self) -> Point: + """Unit vector along the strap's width.""" + return (cos_d(self.angle), sin_d(self.angle)) + + @property + def normal(self) -> Point: + """Local +Y expressed globally.""" + return (-sin_d(self.angle), cos_d(self.angle)) + + @property + def inside_dir(self) -> Optional[Point]: + """ + Unit vector pointing from the member towards the profile interior. + None for SB_FACE_BOTH_OUT, which has no interior. + """ + if self.face == 0: + return None + nx, ny = self.normal + return (self.face * nx, self.face * ny) + + def inside_wall_pt(self, g: Geo) -> Optional[Point]: + """A point on the interior-facing surface of the member's sleeve.""" + d = self.inside_dir + if d is None: + return None + return (self.cx + d[0] * g.reach_inside, + self.cy + d[1] * g.reach_inside) + + def offset_out(self, d: float) -> "Member": + """Translate along the outward normal (away from the interior).""" + nx, ny = self.normal + s = 1.0 if self.face == 0 else -self.face + return Member(self.cx + s * d * nx, self.cy + s * d * ny, + self.angle, self.face) + + +def face_toward(centre: Point, angle: float, + interior_target: Optional[Point]) -> int: + """ + Decide the face mode from a target point that lies inside the profile. + + Pass ``interior_target=None`` for members with no enclosed side, which keeps + the member symmetric and stops the walls from becoming chiral. + """ + if interior_target is None: + return SB_FACE_BOTH_OUT + nx, ny = -sin_d(angle), cos_d(angle) + vx = interior_target[0] - centre[0] + vy = interior_target[1] - centre[1] + return SB_FACE_PLUS_IN if (nx * vx + ny * vy) >= 0 else SB_FACE_MINUS_IN + + +def member_on_edge(a: Point, b: Point, interior_target: Optional[Point], + shift: float = 0.0) -> Member: + """Member lying on the segment a->b, optionally slid along its own axis.""" + mid = sb_mid(a, b) + angle = atan2_d(b[1] - a[1], b[0] - a[0]) + c = (mid[0] + shift * cos_d(angle), mid[1] + shift * sin_d(angle)) + return Member(c[0], c[1], angle, face_toward(c, angle, interior_target)) + + +def member_radial(r: float, angle: float, face: int = SB_FACE_BOTH_OUT) -> Member: + """ + Member placed radially: centre at distance r from origin along ``angle``, + with its width axis pointing outward. Used by spoke profiles. + """ + return Member(r * cos_d(angle), r * sin_d(angle), angle, face) + + +# ---------------------------------------------------------------------------- +# Cross-section paths for one member +# ---------------------------------------------------------------------------- + +def place(m: Member, path: Path) -> List[Point]: + """Place a locally-defined path into the member's frame.""" + ca, sa = cos_d(m.angle), sin_d(m.angle) + return [(p[0] * ca - p[1] * sa + m.cx, + p[0] * sa + p[1] * ca + m.cy) for p in path] + + +def local_rect(half_w_lead: float, half_w_trail: float, + up: float, down: float) -> List[Point]: + """ + Rectangle in member-local coordinates. + + half_w_lead : extent along +X (towards the member's leading end) + half_w_trail : extent along -X + up / down : extents along +Y / -Y + """ + return [(half_w_lead, -down), + (half_w_lead, up), + (-half_w_trail, up), + (-half_w_trail, -down)] + + +def strap_path(m: Member, g: Geo) -> List[Point]: + """The physical strap bundle, as one rectangle.""" + return place(m, local_rect(g.width / 2.0, g.width / 2.0, + g.bundle_t / 2.0, g.bundle_t / 2.0)) + + +def strap_layer_paths(m: Member, g: Geo) -> List[List[Point]]: + """Individual strap laminae, for display when count > 1.""" + out = [] + for i in range(g.count): + y = (i - (g.count - 1) / 2.0) * g.strap_t + t = g.strap_t / 2.0 + rect = local_rect(g.width / 2.0, g.width / 2.0, t, t) + out.append(place(m, [(p[0], p[1] + y) for p in rect])) + return out + + +def cavity_path(m: Member, g: Geo) -> List[Point]: + """The void the strap slides through.""" + return place(m, local_rect(g.cavity_w / 2.0, g.cavity_w / 2.0, + g.cavity_t / 2.0, g.cavity_t / 2.0)) + + +def sleeve_path(m: Member, g: Geo, ext_lead: float = 0.0, + ext_trail: float = 0.0) -> List[Point]: + """ + The PLA+ sleeve around one member. + + ``ext_lead`` / ``ext_trail`` extend the sleeve along its own axis beyond the + default edge wall. Junction construction uses this to make neighbouring + sleeves genuinely overlap instead of merely touching at a corner. + """ + half = g.cavity_w / 2.0 + g.wall_edge + f = m.face + return place(m, local_rect(half + ext_lead, half + ext_trail, + g.reach_plus(f), g.reach_minus(f))) + + +def sleeve_to_line(m: Member, g: Geo, line_pt: Point, line_dir: Point, + ext_lead: float = 0.0) -> List[Point]: + """ + Sleeve whose trailing end is cut by an arbitrary line rather than by a face + perpendicular to the axis. + + This produces a butt joint flush against a neighbouring member's outer face, + which is how junctions are made structural rather than decorative. The + leading end stays perpendicular as usual. Falls back to a plain sleeve if + the line is parallel to the axis. + """ + c = m.centre + u = m.axis + n = m.normal + f = m.face + up = g.reach_plus(f) + dn = g.reach_minus(f) + half = g.cavity_w / 2.0 + g.wall_edge + + p_up = (c[0] + n[0] * up, c[1] + n[1] * up) + p_dn = (c[0] - n[0] * dn, c[1] - n[1] * dn) + t_up = sb_line_isect(p_up, u, line_pt, line_dir) + t_dn = sb_line_isect(p_dn, u, line_pt, line_dir) + + if t_up is None or t_dn is None: + return sleeve_path(m, g, ext_lead, 0.0) + + reach = half + ext_lead + lead_up = (p_up[0] + u[0] * reach, p_up[1] + u[1] * reach) + lead_dn = (p_dn[0] + u[0] * reach, p_dn[1] + u[1] * reach) + return sb_ccw([lead_dn, lead_up, t_up, t_dn]) + + +def sleeve_span(m: Member, g: Geo, pt_a: Point, dir_a: Point, + pt_b: Point, dir_b: Point) -> List[Point]: + """ + Sleeve cut by a line at BOTH ends. + + A member that spans between two neighbours -- a gable crossbar, a chord + across a polygon -- butts flush against each of them instead of stopping + short or poking through. + """ + c = m.centre + u = m.axis + n = m.normal + f = m.face + p_up = (c[0] + n[0] * g.reach_plus(f), c[1] + n[1] * g.reach_plus(f)) + p_dn = (c[0] - n[0] * g.reach_minus(f), c[1] - n[1] * g.reach_minus(f)) + + a_up = sb_line_isect(p_up, u, pt_a, dir_a) + a_dn = sb_line_isect(p_dn, u, pt_a, dir_a) + b_up = sb_line_isect(p_up, u, pt_b, dir_b) + b_dn = sb_line_isect(p_dn, u, pt_b, dir_b) + + if a_up is None or a_dn is None or b_up is None or b_dn is None: + return sleeve_path(m, g) + return sb_ccw([a_dn, a_up, b_up, b_dn]) diff --git a/tests/test_geom_primitives.py b/tests/test_geom_primitives.py new file mode 100644 index 0000000..368d052 --- /dev/null +++ b/tests/test_geom_primitives.py @@ -0,0 +1,434 @@ +""" +Unit tests for the ported ``sb-geom`` primitives. + +These do not touch the oracle. Every expected value here is derived by hand or +from elementary geometry, so a test passing means the primitive agrees with +something outside itself rather than with its own implementation. The oracle +remains the acceptance criterion for the port as a whole; this file exists so +that when the oracle disagrees later, the primitives are not the first suspect. + +Sign conventions and the arbitrary scale factors are asserted explicitly, +because those are the parts most likely to be quietly "improved" during a +refactor. +""" + +from __future__ import annotations + +import math + +import pytest + +from mechcomp.geom import ( + SB_FACE_BOTH_OUT, + SB_FACE_MINUS_IN, + SB_FACE_PLUS_IN, + Geo, + Member, + cavity_path, + cos_d, + face_toward, + member_on_edge, + member_radial, + place, + sb_ccw, + sb_corner_radii, + sb_dist, + sb_line_isect, + sb_path_gap, + sb_path_max_round, + sb_paths_cross, + sb_pt_path_dist, + sb_pt_seg_dist, + sb_region_min_gap, + sb_signed_area, + sb_segs_cross, + sb_solvable, + sb_solve, + sin_d, + sleeve_path, + strap_layer_paths, + strap_path, + tan_d, + vector_angle, +) + +UNIT_SQUARE = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + + +# ---------------------------------------------------------------------------- +# Degrees, not radians +# ---------------------------------------------------------------------------- + +def test_trig_is_in_degrees(): + """The single most likely porting error. Asserted first, deliberately.""" + assert cos_d(0.0) == pytest.approx(1.0) + assert cos_d(90.0) == pytest.approx(0.0, abs=1e-12) + assert sin_d(90.0) == pytest.approx(1.0) + assert sin_d(30.0) == pytest.approx(0.5) + assert tan_d(45.0) == pytest.approx(1.0) + + +def test_vector_angle_is_degrees_and_unsigned(): + right = vector_angle((1.0, 0.0), (0.0, 0.0), (0.0, 1.0)) + assert right == pytest.approx(90.0) + # Mirroring the third point must not change the magnitude. + assert vector_angle((1.0, 0.0), (0.0, 0.0), (0.0, -1.0)) == pytest.approx(90.0) + straight = vector_angle((-1.0, 0.0), (0.0, 0.0), (1.0, 0.0)) + assert straight == pytest.approx(180.0) + + +# ---------------------------------------------------------------------------- +# Vector helpers +# ---------------------------------------------------------------------------- + +def test_signed_area_sign_convention(): + """Positive means counter-clockwise. Reversing flips the sign.""" + assert sb_signed_area(UNIT_SQUARE) == pytest.approx(1.0) + assert sb_signed_area(list(reversed(UNIT_SQUARE))) == pytest.approx(-1.0) + + +def test_ccw_normalises_winding(): + assert sb_signed_area(sb_ccw(list(reversed(UNIT_SQUARE)))) > 0 + assert sb_signed_area(sb_ccw(UNIT_SQUARE)) > 0 + + +def test_line_isect_axes(): + p = sb_line_isect((0.0, 5.0), (1.0, 0.0), (3.0, 0.0), (0.0, 1.0)) + assert p == pytest.approx((3.0, 5.0)) + + +def test_line_isect_parallel_is_none(): + assert sb_line_isect((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 0.0)) is None + + +# ---------------------------------------------------------------------------- +# Exact polyline distance +# ---------------------------------------------------------------------------- + +def test_pt_seg_dist_perpendicular_foot(): + assert sb_pt_seg_dist((0.0, 0.0), (1.0, -1.0), (1.0, 1.0)) == pytest.approx(1.0) + + +def test_pt_seg_dist_clamps_to_endpoint(): + """Beyond the segment, the nearest point is the endpoint, not the line.""" + assert sb_pt_seg_dist((0.0, 0.0), (1.0, 1.0), (2.0, 2.0)) == pytest.approx(math.sqrt(2.0)) + + +def test_pt_seg_dist_degenerate_segment(): + assert sb_pt_seg_dist((3.0, 4.0), (0.0, 0.0), (0.0, 0.0)) == pytest.approx(5.0) + + +def test_pt_path_dist_uses_nearest_edge(): + assert sb_pt_path_dist((0.5, 3.0), UNIT_SQUARE) == pytest.approx(2.0) + + +def test_segs_cross_true_and_false(): + assert sb_segs_cross((0.0, 0.0), (1.0, 1.0), (0.0, 1.0), (1.0, 0.0)) + assert not sb_segs_cross((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)) + + +def test_segs_cross_parallel_is_false(): + """Collinear overlap reports false: the reference guards on the determinant.""" + assert not sb_segs_cross((0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (3.0, 0.0)) + + +def test_path_gap_between_disjoint_squares(): + far = [(3.0, 0.0), (4.0, 0.0), (4.0, 1.0), (3.0, 1.0)] + assert sb_path_gap(UNIT_SQUARE, far) == pytest.approx(2.0) + + +def test_path_gap_reports_zero_on_crossing(): + """ + The case the naive vertex test gets wrong: a cross with no vertex of either + path near the other's boundary. + """ + horizontal = [(-5.0, 0.4), (5.0, 0.4), (5.0, 0.6), (-5.0, 0.6)] + vertical = [(0.4, -5.0), (0.6, -5.0), (0.6, 5.0), (0.4, 5.0)] + assert sb_paths_cross(horizontal, vertical) + assert sb_path_gap(horizontal, vertical) == 0.0 + + +def test_path_gap_treats_nesting_as_wall_thickness(): + """A hole inside an outer boundary is the normal case, not an overlap.""" + outer = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] + hole = [(2.0, 2.0), (8.0, 2.0), (8.0, 8.0), (2.0, 8.0)] + assert sb_path_gap(outer, hole) == pytest.approx(2.0) + + +def test_region_min_gap_sentinel_below_two_paths(): + assert sb_region_min_gap([]) == 1e9 + assert sb_region_min_gap([UNIT_SQUARE]) == 1e9 + + +def test_region_min_gap_picks_the_closest_pair(): + a = UNIT_SQUARE + b = [(3.0, 0.0), (4.0, 0.0), (4.0, 1.0), (3.0, 1.0)] + c = [(1.5, 0.0), (2.0, 0.0), (2.0, 1.0), (1.5, 1.0)] + assert sb_region_min_gap([a, b, c]) == pytest.approx(0.5) + + +# ---------------------------------------------------------------------------- +# Corner rounding +# ---------------------------------------------------------------------------- + +def test_path_max_round_on_a_square(): + """ + Square of side 10: at each vertex the angle is 90 degrees and both arms are + 10, so the limit is 10/2 * tan(45) = 5, scaled by the reference's 0.999. + """ + square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] + assert sb_path_max_round(square) == pytest.approx(4.995) + + +def test_path_max_round_ignores_flat_vertices(): + """A collinear midpoint contributes the 1e9 sentinel, not a zero.""" + with_flat = [(0.0, 0.0), (5.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)] + assert sb_path_max_round(with_flat) > 1.0 + + +def test_path_max_round_degenerate_path(): + assert sb_path_max_round([(0.0, 0.0), (1.0, 1.0)]) == 0.0 + + +def test_corner_radii_zero_on_a_convex_path(): + """No reflex vertices means no roundover anywhere, whatever r is asked for.""" + assert sb_corner_radii(UNIT_SQUARE, 10.0) == [0.0, 0.0, 0.0, 0.0] + + +def test_corner_radii_rounds_only_the_reflex_vertex(): + """ + An L, counter-clockwise. Exactly one vertex turns the wrong way, and only + that one takes a radius. + """ + 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)] + radii = sb_corner_radii(ell, 1.0) + assert sum(1 for r in radii if r > 0) == 1 + assert radii[3] == pytest.approx(1.0) + + +def test_corner_radii_clamped_by_the_shorter_arm(): + """ + At the reflex vertex the arms are 1 and 6 and the angle is 90 degrees, so + the fit limit is 0.98 * 1 / 2 * tan(45) = 0.49, below the requested 5. + """ + ell = [(0.0, 0.0), (10.0, 0.0), (10.0, 4.0), + (5.0, 4.0), (5.0, 5.0), (0.0, 5.0)] + radii = sb_corner_radii(ell, 5.0) + assert max(radii) == pytest.approx(0.49) + + +def test_corner_radii_winding_independent(): + """Reflex is a property of the shape, not of the direction it is written.""" + 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)] + assert sorted(sb_corner_radii(ell, 1.0)) == \ + sorted(sb_corner_radii(list(reversed(ell)), 1.0)) + + +# ---------------------------------------------------------------------------- +# Monotone solver +# ---------------------------------------------------------------------------- + +def test_solve_finds_the_root_of_a_linear_function(): + assert sb_solve(lambda x: x, 0.0, 10.0, 3.0) == pytest.approx(3.0, abs=1e-9) + + +def test_solve_on_a_nonlinear_monotone_function(): + assert sb_solve(lambda x: x * x, 0.0, 10.0, 2.0) == \ + pytest.approx(math.sqrt(2.0), abs=1e-9) + + +def test_solve_iteration_count_is_fixed_not_adaptive(): + """ + 44 halvings of the bracket and no convergence test. Counting the calls is + the only way to catch a well-meaning early return, which would change the + last digits of every solved placement in the oracle. + """ + calls = [] + + def f(x): + calls.append(x) + return x + + sb_solve(f, 0.0, 1.0, 0.5) + assert len(calls) == 44 + + +def test_solve_clamps_to_the_bracket_when_target_is_out_of_range(): + assert sb_solve(lambda x: x, 0.0, 1.0, 99.0) == pytest.approx(1.0, abs=1e-9) + + +def test_solvable_reports_feasibility(): + assert sb_solvable(lambda x: x, 10.0, 3.0) + assert not sb_solvable(lambda x: x, 1.0, 3.0) + + +# ---------------------------------------------------------------------------- +# GEO record +# ---------------------------------------------------------------------------- + +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 test_cavity_is_the_bundle_grown_on_all_four_faces(): + g = geo() + assert g.bundle_t == pytest.approx(0.508) + assert g.cavity_w == pytest.approx(15.875 + 0.4) + assert g.cavity_t == pytest.approx(0.508 + 0.4) + + +def test_bundle_thickness_scales_with_count(): + assert geo(count=3).bundle_t == pytest.approx(1.524) + assert geo(count=3).cavity_t == pytest.approx(1.524 + 0.4) + + +def test_declared_web_is_plastic_not_cavity_spacing(): + """A stated 1.2 mm web must be 1.2 mm of PLA, so the strap gap is larger.""" + g = geo() + assert g.web_to_strap_gap(1.2) == pytest.approx(1.2 + 0.4) + + +def test_reach_depends_on_which_face_encloses(): + g = geo() + half = g.cavity_t / 2.0 + assert g.reach_plus(SB_FACE_PLUS_IN) == pytest.approx(half + 1.0) + assert g.reach_minus(SB_FACE_PLUS_IN) == pytest.approx(half + 1.6) + assert g.reach_plus(SB_FACE_MINUS_IN) == pytest.approx(half + 1.6) + assert g.reach_minus(SB_FACE_MINUS_IN) == pytest.approx(half + 1.0) + + +def test_a_member_with_no_interior_is_symmetric(): + """ + Design rule 4: asymmetric wall settings must never make a symmetric profile + chiral, so both faces take the outside wall. + """ + g = geo() + assert g.reach_plus(SB_FACE_BOTH_OUT) == \ + pytest.approx(g.reach_minus(SB_FACE_BOTH_OUT)) + + +# ---------------------------------------------------------------------------- +# MEMBER record +# ---------------------------------------------------------------------------- + +def test_member_axis_and_normal_are_orthonormal(): + m = Member(0.0, 0.0, 37.0) + ax, ay = m.axis + nx, ny = m.normal + assert math.hypot(ax, ay) == pytest.approx(1.0) + assert ax * nx + ay * ny == pytest.approx(0.0, abs=1e-12) + + +def test_member_axis_follows_the_angle_in_degrees(): + assert Member(0.0, 0.0, 90.0).axis == pytest.approx((0.0, 1.0), abs=1e-12) + + +def test_face_toward_picks_the_side_containing_the_target(): + """Angle 0 means normal is +Y, so a target above is the plus face.""" + assert face_toward((0.0, 0.0), 0.0, (0.0, 5.0)) == SB_FACE_PLUS_IN + assert face_toward((0.0, 0.0), 0.0, (0.0, -5.0)) == SB_FACE_MINUS_IN + assert face_toward((0.0, 0.0), 0.0, None) == SB_FACE_BOTH_OUT + + +def test_inside_dir_is_none_without_an_interior(): + assert Member(0.0, 0.0, 0.0, SB_FACE_BOTH_OUT).inside_dir is None + + +def test_member_on_edge_sits_at_the_midpoint(): + m = member_on_edge((0.0, 0.0), (10.0, 0.0), None) + assert (m.cx, m.cy) == pytest.approx((5.0, 0.0)) + assert m.angle == pytest.approx(0.0) + + +def test_member_on_edge_shift_moves_along_its_own_axis(): + m = member_on_edge((0.0, 0.0), (0.0, 10.0), None, shift=2.0) + assert (m.cx, m.cy) == pytest.approx((0.0, 7.0), abs=1e-12) + + +def test_member_radial_places_the_centre_on_the_ray(): + m = member_radial(4.0, 90.0) + assert (m.cx, m.cy) == pytest.approx((0.0, 4.0), abs=1e-12) + assert m.angle == pytest.approx(90.0) + + +def test_offset_out_moves_away_from_the_interior(): + """ + Face +Y encloses the interior, so the outward move must be towards -Y. + Getting this sign backwards would push every sleeve into the cavity. + """ + m = Member(0.0, 0.0, 0.0, SB_FACE_PLUS_IN) + assert m.offset_out(2.0).cy == pytest.approx(-2.0) + assert Member(0.0, 0.0, 0.0, SB_FACE_MINUS_IN).offset_out(2.0).cy == \ + pytest.approx(2.0) + assert Member(0.0, 0.0, 0.0, SB_FACE_BOTH_OUT).offset_out(2.0).cy == \ + pytest.approx(2.0) + + +# ---------------------------------------------------------------------------- +# Member paths +# ---------------------------------------------------------------------------- + +def test_place_rotates_then_translates(): + moved = place(Member(3.0, 4.0, 90.0), [(1.0, 0.0)]) + assert moved[0] == pytest.approx((3.0, 5.0), abs=1e-12) + + +def test_strap_path_dimensions(): + g = geo() + path = strap_path(Member(0.0, 0.0, 0.0), g) + xs = [p[0] for p in path] + ys = [p[1] for p in path] + assert max(xs) - min(xs) == pytest.approx(g.width) + assert max(ys) - min(ys) == pytest.approx(g.bundle_t) + + +def test_cavity_is_larger_than_the_strap_it_holds(): + g = geo() + m = Member(0.0, 0.0, 0.0) + cav = cavity_path(m, g) + xs = [p[0] for p in cav] + assert max(xs) - min(xs) == pytest.approx(g.cavity_w) + assert g.cavity_w > g.width and g.cavity_t > g.bundle_t + + +def test_sleeve_encloses_the_cavity_on_every_face(): + g = geo() + m = Member(0.0, 0.0, 0.0, SB_FACE_BOTH_OUT) + cav = cavity_path(m, g) + sleeve = sleeve_path(m, g) + for v in cav: + assert sb_pt_path_dist(v, sleeve) > 0.0 + sx = [p[0] for p in sleeve] + assert max(sx) - min(sx) == pytest.approx(g.cavity_w + 2 * g.wall_edge) + + +def test_strap_layers_partition_the_bundle(): + g = geo(count=3) + layers = strap_layer_paths(Member(0.0, 0.0, 0.0), g) + assert len(layers) == 3 + ys = [p[1] for layer in layers for p in layer] + assert max(ys) - min(ys) == pytest.approx(g.bundle_t) + + +def test_paths_are_counter_clockwise(): + """Downstream boolean operations assume a consistent winding.""" + g = geo() + m = Member(0.0, 0.0, 30.0) + for path in (strap_path(m, g), cavity_path(m, g), sleeve_path(m, g)): + assert sb_signed_area(path) > 0 + + +def test_rotated_member_preserves_its_own_dimensions(): + """Rotation must not change the section, only where it sits.""" + g = geo() + flat = sleeve_path(Member(0.0, 0.0, 0.0), g) + tilted = sleeve_path(Member(2.0, -3.0, 37.0), g) + for i in range(4): + a_flat = sb_dist(flat[i], flat[(i + 1) % 4]) + a_tilt = sb_dist(tilted[i], tilted[(i + 1) % 4]) + assert a_flat == pytest.approx(a_tilt)