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.
309 lines
11 KiB
Python
309 lines
11 KiB
Python
"""
|
|
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])
|