Files
mechanical-compiler/src/mechcomp/geom/records.py
T
TheRON dfd02a4fd8 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.
2026-08-19 02:58:35 -05:00

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])