geom: port sb-join, the junction and envelope strategies
Face lines, structural butt joints, hull caps, concave fillets, the derived bore, ring fit, ring envelope and section assembly. N-generic throughout, as the reference is. Junctions are structural, not cosmetic: one sleeve runs through its neighbour and is cut flush against that member the far surface, so the two share a full-width overlap whether or not a fillet is applied on top. The bore is derived from the members own inside-wall lines rather than a separately scaled shape, which is what makes the declared inside wall exactly what remains beside each cavity. 43 tests. Mutation testing found two of the reference own warnings to be load-bearing and untested by me. A bore that has turned inside out can carry over a square millimetre of area, so the area guard alone accepts it and only the interior-side test rejects it. And the ring fit really does have a spurious lower branch: a thin triangle meets a 1.2 mm web at relative scale 0.425, where members overhang their own corners and the solve looks converged. Both now covered. A third mutation was malformed on my part rather than a gap -- cutting the cavities twice is idempotent -- and was replaced with one that does change behaviour. Nine mutations caught. Oracle acceptance still skips; 236 unchanged.
This commit is contained in:
@@ -80,3 +80,27 @@ from .region import ( # noqa: F401
|
||||
to_shapely,
|
||||
union,
|
||||
)
|
||||
from .join import ( # noqa: F401
|
||||
bore_from_members,
|
||||
bore_valid,
|
||||
cavity_region,
|
||||
centering_shift,
|
||||
end_face,
|
||||
face_line,
|
||||
far_face_line,
|
||||
fillet_concave,
|
||||
fillet_junctions,
|
||||
fillet_pair,
|
||||
fit_ring,
|
||||
hull_cap,
|
||||
ring_fit_scale,
|
||||
ring_max_corner_r,
|
||||
ring_members,
|
||||
ring_shell,
|
||||
ring_web,
|
||||
scale_about_centroid,
|
||||
section,
|
||||
sleeve_butt,
|
||||
sleeve_shell,
|
||||
strap_region,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
Port of ``legacy/openscad/lib/sb-join.scad`` -- junction and envelope strategies.
|
||||
|
||||
Everything here is written for N members and is shared verbatim by the 3x, 4x
|
||||
and any later generator. Three families of strategy live here.
|
||||
|
||||
ENVELOPE
|
||||
How the outer PLA+ solid is generated: *sleeve style*, the union of
|
||||
per-member sleeves, for open profiles; *ring style*, one closed envelope
|
||||
offset from a centreline polygon with solid rounded corners.
|
||||
|
||||
BORE
|
||||
The enclosed central void, derived from the members' actual inside-wall
|
||||
lines rather than from a separately scaled shape. That is what makes the
|
||||
declared inside wall exactly what remains between each cavity and the void.
|
||||
|
||||
FIT
|
||||
Placement solved against a measured web, so a declared wall thickness is the
|
||||
wall thickness you get.
|
||||
|
||||
The structural idea worth keeping in view while reading: junctions are made by
|
||||
running one sleeve's trailing end all the way through its neighbour and cutting
|
||||
it flush against that member's far surface. The two then share a full-width
|
||||
overlap, so the joint carries load whether or not a fillet is applied
|
||||
afterwards. Fillets here are cosmetic, applied on top of a structural butt
|
||||
joint, never in place of one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
from .primitives import (
|
||||
Path,
|
||||
Point,
|
||||
sb_ccw,
|
||||
sb_centroid,
|
||||
sb_corner_radii,
|
||||
sb_dist,
|
||||
sb_line_isect,
|
||||
sb_path_gap,
|
||||
sb_path_max_round,
|
||||
sb_signed_area,
|
||||
sb_solvable,
|
||||
sb_solve,
|
||||
)
|
||||
from .records import Geo, Member, cavity_path, member_on_edge, sleeve_path, \
|
||||
sleeve_to_line, strap_path
|
||||
from .region import (
|
||||
Region,
|
||||
as_region,
|
||||
difference,
|
||||
hull_region,
|
||||
pointlist_bounds,
|
||||
offset_path,
|
||||
union,
|
||||
)
|
||||
from .rounding import round_corners
|
||||
|
||||
Line = Tuple[Point, Point]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Face lines
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def face_line(m: Member, g: Geo, side: int) -> Line:
|
||||
"""
|
||||
One of a member's two long sleeve surfaces, as an infinite line.
|
||||
|
||||
``side = +1`` selects the local +Y surface, ``-1`` the local -Y surface.
|
||||
Returns ``(point, direction)``.
|
||||
"""
|
||||
c = m.centre
|
||||
n = m.normal
|
||||
d = g.reach_plus(m.face) if side > 0 else g.reach_minus(m.face)
|
||||
return ((c[0] + side * d * n[0], c[1] + side * d * n[1]), m.axis)
|
||||
|
||||
|
||||
def far_face_line(m: Member, g: Geo, from_pt: Point) -> Line:
|
||||
"""
|
||||
The surface of ``m`` lying farther from ``from_pt``.
|
||||
|
||||
Lets one member butt flush against the far side of another without either
|
||||
needing to know which way the other is facing.
|
||||
"""
|
||||
a = face_line(m, g, 1)
|
||||
b = face_line(m, g, -1)
|
||||
return a if sb_dist(a[0], from_pt) >= sb_dist(b[0], from_pt) else b
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Structural junctions
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def sleeve_butt(m: Member, g: Geo, into: Member,
|
||||
ext_lead: float = 0.0) -> List[Point]:
|
||||
"""
|
||||
The core junction primitive.
|
||||
|
||||
Rather than letting two sleeves clip each other at a corner and relying on a
|
||||
cosmetic fillet to hold the result together, the trailing end of ``m`` is
|
||||
run all the way through ``into`` and cut off flush with that member's far
|
||||
surface. ``ext_lead`` extends the opposite, free end, which is left
|
||||
untouched.
|
||||
"""
|
||||
pt, direction = far_face_line(into, g, m.centre)
|
||||
return sleeve_to_line(m, g, pt, direction, ext_lead)
|
||||
|
||||
|
||||
def end_face(m: Member, g: Geo, end: int = 1, extra: float = 0.0) -> List[Point]:
|
||||
"""
|
||||
The end-face segment of a member at its leading (+1) or trailing (-1) end,
|
||||
taken at the sleeve surface. ``extra`` pushes the face further along the
|
||||
axis.
|
||||
"""
|
||||
c = m.centre
|
||||
u = m.axis
|
||||
n = m.normal
|
||||
f = m.face
|
||||
half = g.cavity_w / 2.0 + g.wall_edge + extra
|
||||
p = (c[0] + end * half * u[0], c[1] + end * half * u[1])
|
||||
up = g.reach_plus(f)
|
||||
dn = g.reach_minus(f)
|
||||
return [(p[0] + n[0] * up, p[1] + n[1] * up),
|
||||
(p[0] - n[0] * dn, p[1] - n[1] * dn)]
|
||||
|
||||
|
||||
def hull_cap(segments: Sequence[Sequence[Point]]) -> List[Point]:
|
||||
"""
|
||||
Plug the space enclosed by a set of member end faces with their convex hull.
|
||||
|
||||
Deterministic, cheap, and free of the spikes and V-notches a bare union of
|
||||
crossing rectangles leaves behind. Used for spoke-style centres and gable
|
||||
apexes.
|
||||
"""
|
||||
pts = [p for seg in segments for p in seg]
|
||||
if len(pts) < 3:
|
||||
return []
|
||||
return hull_region([pts])
|
||||
|
||||
|
||||
def fillet_concave(path: Path, r: float) -> List[Point]:
|
||||
"""
|
||||
Round only the reflex corners of a path, leaving every convex corner
|
||||
bit-exact. Each radius is clamped to what its own corner can accept, so the
|
||||
operation cannot fail on a tight junction.
|
||||
|
||||
This replaces the morphological closing used in earlier revisions. Closing
|
||||
had three problems: an inward offset on a many-vertex path is the least
|
||||
reliable operation in the pipeline and raises a library-level error rather
|
||||
than reporting one; its arc discretisation is not mirror-symmetric, so it
|
||||
quietly made symmetric profiles chiral; and it filled every concavity within
|
||||
reach rather than the junction actually being treated.
|
||||
"""
|
||||
pts = list(path)
|
||||
if len(pts) < 3:
|
||||
return pts
|
||||
radii = sb_corner_radii(pts, r)
|
||||
if max(radii) <= 1e-6:
|
||||
return pts
|
||||
return round_corners(pts, radii, closed=True)
|
||||
|
||||
|
||||
def fillet_pair(path_a: Path, path_b: Path, r: float) -> Region:
|
||||
"""
|
||||
Fillet the junction between two sleeves. Cosmetic only.
|
||||
|
||||
If the two solids do not merge into a single simple outline there is nothing
|
||||
sane to round, so the pair is returned untouched rather than guessed at.
|
||||
"""
|
||||
if r <= 0:
|
||||
return [list(path_a), list(path_b)]
|
||||
u = as_region(union([[list(path_a)], [list(path_b)]]))
|
||||
return [fillet_concave(u[0], r)] if len(u) == 1 else u
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Bore -- the enclosed central void
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def bore_from_members(ms: Sequence[Member], g: Geo) -> List[Point]:
|
||||
"""
|
||||
The polygon bounded by the members' actual inside-wall surfaces.
|
||||
|
||||
Members must be supplied in cyclic order around the interior, each with a
|
||||
real interior face. Because the bore is derived from those surfaces rather
|
||||
than from a separately scaled shape, the declared inside wall is exactly
|
||||
what remains between each cavity and the void.
|
||||
|
||||
Nothing here is specific to three members; a four-sided profile produces a
|
||||
quadrilateral bore from the same call. Returns ``[]`` when any pair of
|
||||
consecutive inside lines is parallel, which means no closed interior.
|
||||
"""
|
||||
if any(m.face == 0 for m in ms):
|
||||
return []
|
||||
n = len(ms)
|
||||
pts = [m.inside_wall_pt(g) for m in ms]
|
||||
dirs = [m.axis for m in ms]
|
||||
verts: List[Point] = []
|
||||
for i in range(n):
|
||||
v = sb_line_isect(pts[(i - 1) % n], dirs[(i - 1) % n], pts[i], dirs[i])
|
||||
if v is None:
|
||||
return []
|
||||
verts.append(v)
|
||||
return verts
|
||||
|
||||
|
||||
def bore_valid(bore: Sequence[Point], ms: Sequence[Member], g: Geo) -> bool:
|
||||
"""
|
||||
Is the derived bore real?
|
||||
|
||||
A collapsed interior does not vanish, it turns itself inside out, so area
|
||||
alone proves nothing. The test that matters is that the bore's own centre
|
||||
still lies on the interior side of every member's inside wall.
|
||||
"""
|
||||
if len(bore) < 3:
|
||||
return False
|
||||
if abs(sb_signed_area(bore)) <= 0.01:
|
||||
return False
|
||||
c = sb_centroid(bore)
|
||||
for m in ms:
|
||||
d = m.inside_dir
|
||||
p = m.inside_wall_pt(g)
|
||||
if d is None:
|
||||
return False
|
||||
if d[0] * (c[0] - p[0]) + d[1] * (c[1] - p[1]) <= 0.01:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Ring profiles -- members along the edges of a closed polygon
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def ring_members(path: Path, g: Geo) -> List[Member]:
|
||||
"""
|
||||
One member per edge, each centred on its edge, interior face towards the
|
||||
polygon centroid.
|
||||
|
||||
Centring keeps the profile mirror-symmetric; the corner webs are then set by
|
||||
the polygon's size, solved for below.
|
||||
"""
|
||||
c = sb_centroid(path)
|
||||
n = len(path)
|
||||
return [member_on_edge(path[i], path[(i + 1) % n], c) for i in range(n)]
|
||||
|
||||
|
||||
def ring_web(path: Path, g: Geo) -> float:
|
||||
"""Smallest PLA+ web between any two neighbouring strap cavities on the ring."""
|
||||
ms = ring_members(path, g)
|
||||
n = len(ms)
|
||||
cv = [cavity_path(m, g) for m in ms]
|
||||
return min(sb_path_gap(cv[i], cv[(i + 1) % n]) for i in range(n))
|
||||
|
||||
|
||||
def scale_about_centroid(path: Path, k: float) -> List[Point]:
|
||||
c = sb_centroid(path)
|
||||
return [(c[0] + k * (p[0] - c[0]), c[1] + k * (p[1] - c[1])) for p in path]
|
||||
|
||||
|
||||
def ring_fit_scale(path: Path, g: Geo, web: float,
|
||||
max_scale: float = 12.0) -> Optional[float]:
|
||||
"""
|
||||
Grow the caller's polygon about its centroid until the tightest corner web
|
||||
reaches ``web``.
|
||||
|
||||
Straps have a fixed width, so on a polygon of a given size the corner webs
|
||||
are whatever they are -- they cannot be dialled in by sliding members along
|
||||
their edges, because every edge shares its budget with two corners. The only
|
||||
free variable that raises all N webs at once is the polygon's size.
|
||||
|
||||
The outline is normalised first, so the caller's polygon really is shape
|
||||
only: a unit square and a 200 mm square must fit to the same result. At
|
||||
relative scale 1 the shortest edge is exactly one strap wide.
|
||||
|
||||
The search starts at 1, never below. Once an edge is shorter than a strap,
|
||||
that member overhangs both of its own corners and the corner-setback model
|
||||
no longer describes the geometry -- yet the measured web can come back
|
||||
positive there, which is exactly the kind of spurious lower branch a
|
||||
bisection will happily settle on.
|
||||
"""
|
||||
n = len(path)
|
||||
edges = [sb_dist(path[i], path[(i + 1) % n]) for i in range(n)]
|
||||
k0 = g.width / min(edges)
|
||||
|
||||
def f(k: float) -> float:
|
||||
return ring_web(scale_about_centroid(path, k0 * k), g)
|
||||
|
||||
if not sb_solvable(f, max_scale, web):
|
||||
return None
|
||||
return k0 * sb_solve(f, 1.0, max_scale, web)
|
||||
|
||||
|
||||
def fit_ring(path: Path, g: Geo, web: float,
|
||||
max_scale: float = 12.0) -> Optional[List[Point]]:
|
||||
k = ring_fit_scale(path, g, web, max_scale)
|
||||
return None if k is None else scale_about_centroid(path, k)
|
||||
|
||||
|
||||
def ring_shell(path: Path, g: Geo, corner_r: float = 0.0) -> List[Point]:
|
||||
"""
|
||||
Outer envelope of a ring profile: the centreline polygon pushed out to the
|
||||
outside-wall surface, with its corners rounded.
|
||||
|
||||
Corner rounding removes material from precisely the region where a strap
|
||||
cavity approaches the corner, so the caller must check the result against
|
||||
the minimum wall rather than assume a radius is safe.
|
||||
"""
|
||||
sharp = offset_path(sb_ccw(path), g.cavity_t / 2.0 + g.wall_outside,
|
||||
closed=True)
|
||||
if corner_r > 0:
|
||||
return round_corners(sharp, corner_r, closed=True)
|
||||
return sharp
|
||||
|
||||
|
||||
def ring_max_corner_r(path: Path, g: Geo) -> float:
|
||||
"""
|
||||
Largest corner radius that still leaves ``min_wall`` between the envelope and
|
||||
every strap cavity, and that the envelope can geometrically accept.
|
||||
|
||||
Reported so a catalogue entry can be tuned once and then trusted.
|
||||
|
||||
The solve runs on ``hi - r`` rather than on the radius directly, because the
|
||||
measured clearance falls as the radius grows and the bisection requires a
|
||||
non-decreasing function.
|
||||
"""
|
||||
sharp = ring_shell(path, g, 0.0)
|
||||
hi = sb_path_max_round(sharp)
|
||||
ms = ring_members(path, g)
|
||||
cav = [cavity_path(m, g) for m in ms]
|
||||
|
||||
def f(r: float) -> float:
|
||||
sh = ring_shell(path, g, max(0.0, hi - r))
|
||||
if len(sh) < 3:
|
||||
return 0.0
|
||||
return min(sb_path_gap(sh, c) for c in cav)
|
||||
|
||||
if hi <= 0:
|
||||
return 0.0
|
||||
return max(0.0, hi - sb_solve(f, 0.0, hi, g.min_wall - 1e-6))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Sleeve profiles -- union of per-member sleeves
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def sleeve_shell(paths: Sequence[Path]) -> Region:
|
||||
return as_region(union([[list(p)] for p in paths]))
|
||||
|
||||
|
||||
def fillet_junctions(shell: Sequence[Path], pairs: Sequence[Tuple[int, int]],
|
||||
paths: Sequence[Path], r: float) -> Region:
|
||||
"""
|
||||
Apply one cosmetic fillet per declared junction, each computed from only the
|
||||
two members involved, then merge with the untouched shell.
|
||||
|
||||
Keeping the closings pairwise stops distant parts of the profile from
|
||||
bridging to each other through the middle of the section.
|
||||
"""
|
||||
if r <= 0:
|
||||
return as_region(shell)
|
||||
regions: List[Sequence[Path]] = [list(shell)]
|
||||
for a, b in pairs:
|
||||
regions.append(fillet_pair(paths[a], paths[b], r))
|
||||
return as_region(union(regions))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Assembly
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
def section(shell: Sequence[Path], members: Sequence[Member], g: Geo,
|
||||
bore: Sequence[Point] = ()) -> Region:
|
||||
"""
|
||||
The one place where solid and void meet.
|
||||
|
||||
All sleeve solids are unioned first and every cavity is removed afterwards,
|
||||
so no member's PLA+ can ever intrude into another member's strap channel.
|
||||
"""
|
||||
cavities = as_region(union([[cavity_path(m, g)] for m in members]))
|
||||
if len(bore) >= 3:
|
||||
cut = as_region(union([[list(bore)], cavities]))
|
||||
else:
|
||||
cut = cavities
|
||||
return as_region(difference(shell, cut))
|
||||
|
||||
|
||||
def strap_region(members: Sequence[Member], g: Geo) -> Region:
|
||||
return as_region(union([[strap_path(m, g)] for m in members]))
|
||||
|
||||
|
||||
def cavity_region(members: Sequence[Member], g: Geo) -> Region:
|
||||
return as_region(union([[cavity_path(m, g)] for m in members]))
|
||||
|
||||
|
||||
def centering_shift(shell: Sequence[Path]) -> Point:
|
||||
"""Translation putting the finished envelope's bounding box on the origin."""
|
||||
b = pointlist_bounds(hull_region(shell))
|
||||
return (-(b[0][0] + b[1][0]) / 2.0, -(b[0][1] + b[1][1]) / 2.0)
|
||||
Reference in New Issue
Block a user