From dfd02a4fd837cdc24b40aea0af92a56d16a59cac Mon Sep 17 00:00:00 2001 From: TheRON Date: Wed, 19 Aug 2026 02:58:35 -0500 Subject: [PATCH] geom: port the pure-geometry half of sb-geom Vectors, GEO and MEMBER records, member placement, sleeve and cavity paths, exact polyline distance, corner-radius derivation, and the monotone solver. Direct translation of legacy/openscad/lib/sb-geom.scad at rev 8.0.0. Angles stay in degrees, matching OpenSCAD, so every expression reads the same as its source line. The 44 solver iterations, the 0.999 and 0.98 scale factors, the 0.05/179.95 cutoffs and the 1e9 sentinel are reproduced exactly: they shaped the frozen oracle. Region operations are not included -- they need a 2D boolean kernel and follow with the Shapely layer. 49 unit tests, none of which touch the oracle. Harness proven by mutation: radians for degrees, a shortened solver, a dropped scale factor, a skipped crossing test and a flipped offset sign are each caught. Oracle acceptance still skips; 236 unchanged. --- src/mechcomp/geom/__init__.py | 50 +++- src/mechcomp/geom/primitives.py | 290 +++++++++++++++++++++ src/mechcomp/geom/records.py | 308 +++++++++++++++++++++++ tests/test_geom_primitives.py | 434 ++++++++++++++++++++++++++++++++ 4 files changed, 1081 insertions(+), 1 deletion(-) create mode 100644 src/mechcomp/geom/primitives.py create mode 100644 src/mechcomp/geom/records.py create mode 100644 tests/test_geom_primitives.py 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)