geom: port sb-core and sb-profiles, the N-generic arrangements
The PROFILE record, centred assembly, and three complete arrangements: ring, spokes, fins. Each written for N members, exercised at N=3 and N=4. Failure travels as an empty profile carrying one failing check, as in the reference, so profile rejections and universal-check rejections stay in one order-sensitive list and the first failure is what surfaces. The section is cleaned before it is measured, not after. A Three-Fin section carries 17 collinear vertices from exact butt joints; they are harmless in 2D and leave zero-area triangles the tessellator cannot resolve, so measuring first would report on geometry that is not what gets extruded. Centred now carries the shifted members. Measuring a shifted shell against unshifted members reports every cavity as escaping the envelope, a leak of the whole cavity area from geometry that is fine. That trap caught me while smoke testing, so the opportunity is removed rather than documented. Verified against the oracle where the fillet does not affect the result: SPOKE_RADIUS_MM 9.1713, FIN_CORE_SIDE_MM 13.4028, FIN_SETBACK_MM 3.77783, FIN_JUNCTION_WEB_MM 2.29919, RING_CORNER_R_MAX_MM 2.87663, the ring edge vector, and both envelope dimensions all match to the recorded digit. The solvers are right. OPEN: with a junction fillet of 1.5 the Y section area is 135.572973 against a recorded 135.574, off by 0.001027 and just past the area tolerance, while every other quantity for that case matches exactly. Either the generator fillet default is not 1.5, or there is a difference of about seven parts per million concentrated in the fillet. The generator settles it. 35 tests. Nine mutations, two of which found real gaps: nothing asserted that cleaning removed anything, and the spoke zero-fillet check was untested. Oracle acceptance still skips; 236 unchanged.
This commit is contained in:
@@ -106,6 +106,7 @@ from .join import ( # noqa: F401
|
|||||||
)
|
)
|
||||||
from .report import ( # noqa: F401
|
from .report import ( # noqa: F401
|
||||||
Check,
|
Check,
|
||||||
|
echo_vec,
|
||||||
Metrics,
|
Metrics,
|
||||||
ProfileRejected,
|
ProfileRejected,
|
||||||
Result,
|
Result,
|
||||||
@@ -117,3 +118,21 @@ from .report import ( # noqa: F401
|
|||||||
require,
|
require,
|
||||||
universal_checks,
|
universal_checks,
|
||||||
)
|
)
|
||||||
|
from .core import ( # noqa: F401
|
||||||
|
Centred,
|
||||||
|
Profile,
|
||||||
|
centred,
|
||||||
|
centred_members,
|
||||||
|
profile_failed,
|
||||||
|
)
|
||||||
|
from .arrangements import ( # noqa: F401
|
||||||
|
fin_bore_side,
|
||||||
|
fin_members,
|
||||||
|
fin_profile,
|
||||||
|
fin_web,
|
||||||
|
regular_polygon,
|
||||||
|
ring_polygon_profile,
|
||||||
|
spoke_members,
|
||||||
|
spoke_profile,
|
||||||
|
spoke_web,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""
|
||||||
|
Port of ``legacy/openscad/lib/sb-profiles.scad`` -- three complete arrangements,
|
||||||
|
each written for N members and each returning a finished Profile.
|
||||||
|
|
||||||
|
ring_polygon_profile members on the edges of a closed polygon, wrapped in
|
||||||
|
one envelope with solid rounded corners and an
|
||||||
|
enclosed bore
|
||||||
|
spoke_profile N members radiating from a plugged centre
|
||||||
|
fin_profile N members lying tangentially on the sides of a
|
||||||
|
regular core polygon, slid cyclically so each
|
||||||
|
overhangs one corner
|
||||||
|
|
||||||
|
A family generator supplies N and the parameters; nothing here changes between
|
||||||
|
the 3x and 4x catalogues. The 3x generator calls all three (Triangles, Y,
|
||||||
|
Three-Fin) and the 4x generator calls the same three with N = 4
|
||||||
|
(Quadrilaterals, Cross, Four-Fin).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from .core import Profile, profile_failed
|
||||||
|
from .join import (
|
||||||
|
bore_from_members,
|
||||||
|
bore_valid,
|
||||||
|
end_face,
|
||||||
|
fillet_junctions,
|
||||||
|
hull_cap,
|
||||||
|
ring_fit_scale,
|
||||||
|
ring_max_corner_r,
|
||||||
|
ring_members,
|
||||||
|
ring_shell,
|
||||||
|
ring_web,
|
||||||
|
scale_about_centroid,
|
||||||
|
sleeve_butt,
|
||||||
|
sleeve_shell,
|
||||||
|
)
|
||||||
|
from .primitives import (
|
||||||
|
SB_FACE_BOTH_OUT,
|
||||||
|
Point,
|
||||||
|
cos_d,
|
||||||
|
sb_centroid,
|
||||||
|
sb_dist,
|
||||||
|
sb_path_gap,
|
||||||
|
sb_signed_area,
|
||||||
|
sb_solvable,
|
||||||
|
sb_solve,
|
||||||
|
sin_d,
|
||||||
|
tan_d,
|
||||||
|
)
|
||||||
|
from .records import Geo, cavity_path, member_on_edge, member_radial, sleeve_path
|
||||||
|
from .region import union
|
||||||
|
from .report import check
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Ring: members on the edges of a closed polygon
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def ring_polygon_profile(seed_path: Sequence[Point], g: Geo, web: float,
|
||||||
|
corner_r: float, label: str) -> Profile:
|
||||||
|
"""
|
||||||
|
Straps have a fixed width, so on a polygon of a given size the corner webs
|
||||||
|
are whatever they are -- sliding members along their edges cannot raise all
|
||||||
|
N at once, because every edge shares its budget with two corners. The one
|
||||||
|
free variable that lifts them together is the polygon's size, so the
|
||||||
|
caller's outline is treated as a SHAPE and grown about its centroid until
|
||||||
|
the tightest corner reaches ``web``.
|
||||||
|
|
||||||
|
Members are centred on their edges, which keeps the result mirror-symmetric
|
||||||
|
whenever the outline is.
|
||||||
|
"""
|
||||||
|
if abs(sb_signed_area(seed_path)) < 1e-6:
|
||||||
|
return profile_failed("%s: the supplied outline is degenerate." % label)
|
||||||
|
|
||||||
|
k = ring_fit_scale(seed_path, g, web)
|
||||||
|
if k is None:
|
||||||
|
return profile_failed(
|
||||||
|
"%s: no polygon size gives a %s mm corner web. Reduce the web, the "
|
||||||
|
"wall thicknesses, or the strap width." % (label, web))
|
||||||
|
|
||||||
|
path = scale_about_centroid(seed_path, k)
|
||||||
|
ms = ring_members(path, g)
|
||||||
|
max_r = ring_max_corner_r(path, g)
|
||||||
|
# Build with a radius the envelope can actually accept; if the caller asked
|
||||||
|
# for more, the check below reports it rather than letting the rounding
|
||||||
|
# routine fail with a library error.
|
||||||
|
shell = [ring_shell(path, g, min(corner_r, max_r))]
|
||||||
|
bore = bore_from_members(ms, g)
|
||||||
|
n = len(path)
|
||||||
|
edges = [sb_dist(path[i], path[(i + 1) % n]) for i in range(n)]
|
||||||
|
|
||||||
|
return Profile(
|
||||||
|
members=ms,
|
||||||
|
shell=shell,
|
||||||
|
bore=bore,
|
||||||
|
checks=[
|
||||||
|
check(len(shell[0]) >= 3,
|
||||||
|
"%s: the outer envelope collapsed." % label),
|
||||||
|
check(bore_valid(bore, ms, g),
|
||||||
|
"%s: the central bore has collapsed. Reduce "
|
||||||
|
"inside_wall_thickness_mm or the bundle thickness." % label),
|
||||||
|
check(corner_r <= max_r + 1e-6,
|
||||||
|
"%s: corner radius of %s mm is not usable here - it would cut "
|
||||||
|
"the outer wall below %s mm at the corners, or exceed what the "
|
||||||
|
"envelope can accept. Maximum is %s mm."
|
||||||
|
% (label, corner_r, g.min_wall, max_r)),
|
||||||
|
],
|
||||||
|
info={
|
||||||
|
"RING_SCALE": k,
|
||||||
|
"RING_EDGES_MM": edges,
|
||||||
|
"RING_CORNER_WEB_MM": ring_web(path, g),
|
||||||
|
"RING_CORNER_R_MAX_MM": max_r,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Spokes: N members radiating from a common centre
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def spoke_members(n: int, radius: float, rotation: float):
|
||||||
|
return [member_radial(radius, rotation + 360.0 * i / n, SB_FACE_BOTH_OUT)
|
||||||
|
for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def spoke_web(n: int, radius: float, rotation: float, g: Geo) -> float:
|
||||||
|
cv = [cavity_path(m, g) for m in spoke_members(n, radius, rotation)]
|
||||||
|
return min(sb_path_gap(cv[i], cv[(i + 1) % n]) for i in range(n))
|
||||||
|
|
||||||
|
|
||||||
|
def spoke_profile(n: int, rotation: float, web: float, fillet_r: float,
|
||||||
|
g: Geo, label: str) -> Profile:
|
||||||
|
"""
|
||||||
|
A spoke's two broad faces both look at open air, so both take the outside
|
||||||
|
wall. There is no interior to face and therefore no bore; forcing an
|
||||||
|
interior direction on these members is what made earlier revisions chiral
|
||||||
|
under asymmetric wall settings.
|
||||||
|
|
||||||
|
The centre is plugged with the convex hull of the N inner end faces rather
|
||||||
|
than left as whatever the crossing rectangles happened to produce. The spoke
|
||||||
|
radius is solved against the measured web between neighbours.
|
||||||
|
"""
|
||||||
|
W = g.width
|
||||||
|
hi = 6.0 * W
|
||||||
|
|
||||||
|
def f(r: float) -> float:
|
||||||
|
return spoke_web(n, r, rotation, g)
|
||||||
|
|
||||||
|
if not sb_solvable(f, hi, web):
|
||||||
|
return profile_failed(
|
||||||
|
"%s: cannot open a %s mm web between neighbouring spokes. Reduce "
|
||||||
|
"the web or the wall thicknesses." % (label, web))
|
||||||
|
|
||||||
|
radius = sb_solve(f, W / 2.0, hi, web)
|
||||||
|
ms = spoke_members(n, radius, rotation)
|
||||||
|
paths = [sleeve_path(m, g) for m in ms]
|
||||||
|
cap = hull_cap([end_face(m, g, -1) for m in ms])
|
||||||
|
regions = [[p] for p in paths] + ([[cap]] if len(cap) > 0 else [])
|
||||||
|
raw = union(regions)
|
||||||
|
pairs = [(i, (i + 1) % n) for i in range(n)]
|
||||||
|
shell = fillet_junctions(raw, pairs, paths, fillet_r)
|
||||||
|
|
||||||
|
return Profile(
|
||||||
|
members=ms,
|
||||||
|
shell=shell,
|
||||||
|
bore=[],
|
||||||
|
checks=[
|
||||||
|
check(fillet_r > 0,
|
||||||
|
"%s: the junction fillet radius must be greater than zero. A "
|
||||||
|
"hull-plugged centre with no fillet meets the spokes along an "
|
||||||
|
"exactly tangent boundary, which is a valid outline but cannot "
|
||||||
|
"be tessellated." % label),
|
||||||
|
],
|
||||||
|
info={
|
||||||
|
"SPOKE_RADIUS_MM": radius,
|
||||||
|
"SPOKE_WEB_MM": spoke_web(n, radius, rotation, g),
|
||||||
|
"NOTE": "inside wall unused: no enclosed bore",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Fins: N members lying tangentially on a regular core polygon
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def regular_polygon(n: int, side: float, rotation: float = 0.0) -> List[Point]:
|
||||||
|
R = side / (2.0 * sin_d(180.0 / n))
|
||||||
|
out = []
|
||||||
|
for k in range(n):
|
||||||
|
a = -90.0 + 180.0 / n + 360.0 * k / n + rotation
|
||||||
|
out.append((R * cos_d(a), R * sin_d(a)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fin_members(n: int, side: float, fin: float, rotation: float, g: Geo):
|
||||||
|
"""
|
||||||
|
Tangential, not radial. Each member lies along one side of a regular core
|
||||||
|
polygon and is slid cyclically along that side, so it stops short of the
|
||||||
|
corner behind it and overhangs the corner ahead of it. Those N overhangs are
|
||||||
|
the fins.
|
||||||
|
"""
|
||||||
|
path = regular_polygon(n, side, rotation)
|
||||||
|
c = sb_centroid(path)
|
||||||
|
shift = fin + (side - g.width) / 2.0
|
||||||
|
return [member_on_edge(path[i], path[(i + 1) % n], c, shift)
|
||||||
|
for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def fin_web(n: int, side: float, fin: float, rotation: float, g: Geo) -> float:
|
||||||
|
cv = [cavity_path(m, g) for m in fin_members(n, side, fin, rotation, g)]
|
||||||
|
return min(sb_path_gap(cv[i], cv[(i + n - 1) % n]) for i in range(n))
|
||||||
|
|
||||||
|
|
||||||
|
def fin_bore_side(n: int, side: float, g: Geo) -> float:
|
||||||
|
"""Side length of the bore left by N inside walls around a regular core."""
|
||||||
|
t = tan_d(180.0 / n)
|
||||||
|
return 2.0 * t * (side / (2.0 * t) - g.reach_inside)
|
||||||
|
|
||||||
|
|
||||||
|
def fin_profile(n: int, fin: float, web: float, bore_side: float,
|
||||||
|
rotation: float, fillet_r: float, g: Geo,
|
||||||
|
label: str) -> Profile:
|
||||||
|
"""
|
||||||
|
The core size is not a free parameter. It is solved so the cyclic junctions
|
||||||
|
carry the declared web AND the bore reaches its declared minimum, whichever
|
||||||
|
demands more; the fin projection is then exact because it is measured
|
||||||
|
against that same solved polygon.
|
||||||
|
|
||||||
|
Each member's trailing end is run through the member behind it and cut off
|
||||||
|
flush on its far face, so every junction has a full-width overlap and the
|
||||||
|
fillet that follows is cosmetic. Junctions that merely touch at a corner and
|
||||||
|
rely on a fillet to bridge them are not load paths.
|
||||||
|
"""
|
||||||
|
W = g.width
|
||||||
|
hi = 10.0 * W
|
||||||
|
|
||||||
|
def fweb(s: float) -> float:
|
||||||
|
return fin_web(n, s, fin, rotation, g)
|
||||||
|
|
||||||
|
def fbor(s: float) -> float:
|
||||||
|
return fin_bore_side(n, s, g)
|
||||||
|
|
||||||
|
if not sb_solvable(fweb, hi, web):
|
||||||
|
return profile_failed(
|
||||||
|
"%s: cannot open a %s mm junction web. Reduce the web or the wall "
|
||||||
|
"thicknesses." % (label, web))
|
||||||
|
if not sb_solvable(fbor, hi, bore_side):
|
||||||
|
return profile_failed(
|
||||||
|
"%s: cannot reach a %s mm bore. Reduce the requested bore."
|
||||||
|
% (label, bore_side))
|
||||||
|
|
||||||
|
side = max(sb_solve(fweb, 0.1, hi, web),
|
||||||
|
sb_solve(fbor, 0.1, hi, bore_side))
|
||||||
|
ms = fin_members(n, side, fin, rotation, g)
|
||||||
|
# Member i butts through member i-1, the one whose fin crosses the corner
|
||||||
|
# that member i stops short of.
|
||||||
|
paths = [sleeve_butt(ms[i], g, ms[(i + n - 1) % n]) for i in range(n)]
|
||||||
|
pairs = [((i + n - 1) % n, i) for i in range(n)]
|
||||||
|
shell = fillet_junctions(sleeve_shell(paths), pairs, paths, fillet_r)
|
||||||
|
bore = bore_from_members(ms, g)
|
||||||
|
setback = fin + side - W
|
||||||
|
|
||||||
|
return Profile(
|
||||||
|
members=ms,
|
||||||
|
shell=shell,
|
||||||
|
bore=bore,
|
||||||
|
checks=[
|
||||||
|
check(bore_valid(bore, ms, g),
|
||||||
|
"%s: the central bore has collapsed. Raise the requested bore "
|
||||||
|
"or reduce inside_wall_thickness_mm." % label),
|
||||||
|
check(setback > 0,
|
||||||
|
"%s: the solved trailing setback is %s mm, so the members "
|
||||||
|
"overlap instead of stepping cyclically. Increase the fin "
|
||||||
|
"projection." % (label, setback)),
|
||||||
|
check(fillet_r > 0,
|
||||||
|
"%s: the junction fillet radius must be greater than zero. A "
|
||||||
|
"butt joint with no fillet meets its neighbour along an "
|
||||||
|
"exactly tangent boundary, which is a valid outline but cannot "
|
||||||
|
"be tessellated." % label),
|
||||||
|
],
|
||||||
|
info={
|
||||||
|
"FIN_CORE_SIDE_MM": side,
|
||||||
|
"FIN_PROJECTION_MM": fin,
|
||||||
|
"FIN_SETBACK_MM": setback,
|
||||||
|
"FIN_BORE_SIDE_MM": fin_bore_side(n, side, g),
|
||||||
|
"FIN_JUNCTION_WEB_MM": fin_web(n, side, fin, rotation, g),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""
|
||||||
|
Port of ``legacy/openscad/lib/sb-core.scad`` -- the PROFILE record and assembly.
|
||||||
|
|
||||||
|
A strap beam is N pallet-strap bundles running parallel to a common
|
||||||
|
longitudinal axis, held in a printed PLA+ enclosure. A profile only decides how
|
||||||
|
the N cross-sections are arranged in XY; that arrangement is then swept along Z.
|
||||||
|
Member length in the cross-section is therefore the strap's WIDTH, never the
|
||||||
|
beam's length.
|
||||||
|
|
||||||
|
CONTRACT FOR A PROFILE BUILDER
|
||||||
|
Takes a GEO record, returns a Profile:
|
||||||
|
|
||||||
|
members one Member per strap bundle
|
||||||
|
shell region: all PLA+ before any void is removed
|
||||||
|
bore path: the enclosed central void, or empty if there is none
|
||||||
|
checks from ``check()``, covering only this profile's own parameters
|
||||||
|
info extra keys to add to the report
|
||||||
|
|
||||||
|
The core removes the bore and all cavities from the shell in one step, so
|
||||||
|
one member's plastic can never fill another member's channel. A builder
|
||||||
|
should not be doing its own boolean algebra.
|
||||||
|
|
||||||
|
FAILURE
|
||||||
|
OpenSCAD has no exceptions, so a builder that cannot produce a usable
|
||||||
|
arrangement returns an empty profile carrying a single failing check. The
|
||||||
|
message then reaches the caller through the ordinary check list rather than
|
||||||
|
through a separate path. That is reproduced here rather than raising early,
|
||||||
|
because it keeps profile-specific rejections and universal-check rejections
|
||||||
|
in one order-sensitive list -- and the reference reports the first failure.
|
||||||
|
|
||||||
|
DESIGN RULES THE LIBRARY ENFORCES
|
||||||
|
1. Declared webs are cavity-to-cavity. A stated 1.2 mm web is 1.2 mm of
|
||||||
|
plastic; the library adds the fit clearance internally.
|
||||||
|
2. Placement that cannot be derived exactly is solved numerically against
|
||||||
|
the measured web, not approximated with a closed form.
|
||||||
|
3. Junctions are structural before they are pretty: members butt through
|
||||||
|
their neighbours, and fillets are applied on top of that overlap.
|
||||||
|
4. A member with no enclosed side gets the outside wall on both faces, so
|
||||||
|
asymmetric wall settings never make a symmetric profile chiral.
|
||||||
|
5. Validation measures the finished section. Connectivity is necessary but
|
||||||
|
never sufficient; the minimum wall is what is actually checked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, List, Sequence, Tuple
|
||||||
|
|
||||||
|
from .join import cavity_region, centering_shift, section as cut_section, \
|
||||||
|
strap_region
|
||||||
|
from .records import Geo, Member
|
||||||
|
from .region import Region, clean_region
|
||||||
|
from .report import Check, check
|
||||||
|
|
||||||
|
Point = Tuple[float, float]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Profile:
|
||||||
|
members: List[Member]
|
||||||
|
shell: Region
|
||||||
|
bore: List[Point] = field(default_factory=list)
|
||||||
|
checks: List[Check] = field(default_factory=list)
|
||||||
|
info: Dict[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
"""A failed builder returns no members, which is how failure is signalled."""
|
||||||
|
return len(self.members) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def profile_failed(message: str) -> Profile:
|
||||||
|
"""
|
||||||
|
What a builder returns instead of guessing.
|
||||||
|
|
||||||
|
The message travels as an ordinary failing check, so it is subject to the
|
||||||
|
same first-failure ordering as everything else.
|
||||||
|
"""
|
||||||
|
return Profile(members=[], shell=[], bore=[],
|
||||||
|
checks=[check(False, message)], info={})
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Centred results
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _move(shift: Point, paths: Sequence[Sequence[Point]]) -> Region:
|
||||||
|
return [[(p[0] + shift[0], p[1] + shift[1]) for p in path] for path in paths]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Centred:
|
||||||
|
section: Region
|
||||||
|
straps: Region
|
||||||
|
cavity: Region
|
||||||
|
shell: Region
|
||||||
|
shift: Point
|
||||||
|
members: List[Member]
|
||||||
|
"""
|
||||||
|
Members translated by the same shift.
|
||||||
|
|
||||||
|
Carried here rather than left to the caller because measuring a shifted
|
||||||
|
shell against unshifted members silently reports every cavity as escaping
|
||||||
|
the envelope -- a leak of the whole cavity area, from geometry that is
|
||||||
|
perfectly fine. Returning them together removes the opportunity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def centred(p: Profile, g: Geo) -> Centred:
|
||||||
|
"""
|
||||||
|
Cut the section, clean it, and shift everything onto a centred origin.
|
||||||
|
|
||||||
|
Two orderings here are load-bearing.
|
||||||
|
|
||||||
|
The section is cleaned *before* anything measures it. Exact butt joints and
|
||||||
|
zero-radius fillets leave coincident and collinear vertices that are
|
||||||
|
harmless in 2D but leave zero-area triangles the tessellator cannot resolve.
|
||||||
|
Measuring first and cleaning afterwards would report on geometry that is not
|
||||||
|
what gets extruded.
|
||||||
|
|
||||||
|
The shift is applied to the section, the straps, the cavities and the shell
|
||||||
|
together, so they stay registered with each other. Everything is generated
|
||||||
|
about whatever origin the profile found natural and moved once at the end.
|
||||||
|
"""
|
||||||
|
shell = p.shell
|
||||||
|
shift = centering_shift(shell)
|
||||||
|
sec = clean_region(cut_section(shell, p.members, g, p.bore))
|
||||||
|
return Centred(
|
||||||
|
section=_move(shift, sec),
|
||||||
|
straps=_move(shift, strap_region(p.members, g)),
|
||||||
|
cavity=_move(shift, cavity_region(p.members, g)),
|
||||||
|
shell=_move(shift, shell),
|
||||||
|
shift=shift,
|
||||||
|
members=centred_members(p, shift),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def centred_members(p: Profile, shift: Point) -> List[Member]:
|
||||||
|
"""Members translated by the same shift, for drawing individual laminae."""
|
||||||
|
return [Member(m.cx + shift[0], m.cy + shift[1], m.angle, m.face)
|
||||||
|
for m in p.members]
|
||||||
@@ -179,6 +179,18 @@ def echo_num(value: float) -> float:
|
|||||||
return float("%g" % value)
|
return float("%g" % value)
|
||||||
|
|
||||||
|
|
||||||
|
def echo_vec(values) -> str:
|
||||||
|
"""
|
||||||
|
Format a sequence as OpenSCAD prints a vector: ``[a, b, c]``, each element
|
||||||
|
at six significant figures.
|
||||||
|
|
||||||
|
The harvester scraped `SB_KEY=value` text lines, so a vector reached the
|
||||||
|
oracle as the string OpenSCAD printed. RING_EDGES_MM is recorded as
|
||||||
|
'[20.5209, 20.5209, 20.5209]', not as a list.
|
||||||
|
"""
|
||||||
|
return "[%s]" % ", ".join("%g" % v for v in values)
|
||||||
|
|
||||||
|
|
||||||
def report(family: str, profile: str, status: str, g: Geo, m: Metrics,
|
def report(family: str, profile: str, status: str, g: Geo, m: Metrics,
|
||||||
length_mm: float, density_g_cm3: float,
|
length_mm: float, density_g_cm3: float,
|
||||||
extra: Optional[Dict[str, object]] = None) -> Dict[str, object]:
|
extra: Optional[Dict[str, object]] = None) -> Dict[str, object]:
|
||||||
@@ -219,8 +231,14 @@ def report(family: str, profile: str, status: str, g: Geo, m: Metrics,
|
|||||||
}
|
}
|
||||||
|
|
||||||
for key, value in (extra or {}).items():
|
for key, value in (extra or {}).items():
|
||||||
out[key] = echo_num(value) if isinstance(value, (int, float)) \
|
if isinstance(value, bool):
|
||||||
and not isinstance(value, bool) else value
|
out[key] = value
|
||||||
|
elif isinstance(value, (int, float)):
|
||||||
|
out[key] = echo_num(value)
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
out[key] = echo_vec(value)
|
||||||
|
else:
|
||||||
|
out[key] = value
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the PROFILE record and the three N-generic arrangements.
|
||||||
|
|
||||||
|
Several expected values here are taken directly from the committed oracle -- the
|
||||||
|
solved spoke radius, the solved fin core side, the setback, the junction web,
|
||||||
|
the ring corner limit and the edge vector. Those quantities do not depend on the
|
||||||
|
junction fillet radius, so they can be pinned before the generator supplies its
|
||||||
|
parameter defaults.
|
||||||
|
|
||||||
|
The arrangements are genuinely N-generic in the reference, so each is exercised
|
||||||
|
at both N=3 and N=4 rather than only at the member count that happens to be
|
||||||
|
under development.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mechcomp.geom.arrangements import (
|
||||||
|
fin_bore_side,
|
||||||
|
fin_members,
|
||||||
|
fin_profile,
|
||||||
|
fin_web,
|
||||||
|
regular_polygon,
|
||||||
|
ring_polygon_profile,
|
||||||
|
spoke_members,
|
||||||
|
spoke_profile,
|
||||||
|
spoke_web,
|
||||||
|
)
|
||||||
|
from mechcomp.geom.core import Centred, Profile, centred, centred_members, \
|
||||||
|
profile_failed
|
||||||
|
from mechcomp.geom.primitives import SB_FACE_BOTH_OUT, sb_dist, sb_signed_area
|
||||||
|
from mechcomp.geom.records import Geo
|
||||||
|
from mechcomp.geom.region import area, is_region_simple, nparts, pointlist_bounds
|
||||||
|
from mechcomp.geom.report import (
|
||||||
|
echo_num,
|
||||||
|
echo_vec,
|
||||||
|
first_failure,
|
||||||
|
metrics,
|
||||||
|
universal_checks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def geo(**kw) -> Geo:
|
||||||
|
"""The oracle's default geometry, read from a recorded report."""
|
||||||
|
base = dict(width=15.875, strap_t=0.508, count=1, clearance=0.25,
|
||||||
|
wall_inside=1.2, wall_outside=1.2, wall_edge=1.2, min_wall=1.2)
|
||||||
|
base.update(kw)
|
||||||
|
return Geo(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def unit_ngon(n, start=90.0):
|
||||||
|
return [(math.cos(math.radians(start + i * 360.0 / n)),
|
||||||
|
math.sin(math.radians(start + i * 360.0 / n))) for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def built(p: Profile, g: Geo, n: int):
|
||||||
|
c = centred(p, g)
|
||||||
|
m = metrics(c.section, c.shell, c.members, g)
|
||||||
|
fail = first_failure(list(p.checks) + universal_checks(c.section, m, n, g))
|
||||||
|
return c, m, fail
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# PROFILE record
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_built_profile_is_ok():
|
||||||
|
p = spoke_profile(3, 90.0, 1.2, 1.5, geo(), "Y")
|
||||||
|
assert p.ok
|
||||||
|
assert len(p.members) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_failed_carries_the_message_as_a_check():
|
||||||
|
"""
|
||||||
|
OpenSCAD has no exceptions, so failure travels as an ordinary failing check
|
||||||
|
and stays subject to the same first-failure ordering as everything else.
|
||||||
|
"""
|
||||||
|
p = profile_failed("Y: cannot open a 99 mm web.")
|
||||||
|
assert not p.ok
|
||||||
|
assert p.members == [] and p.shell == []
|
||||||
|
assert first_failure(p.checks) == "Y: cannot open a 99 mm web."
|
||||||
|
|
||||||
|
|
||||||
|
def test_failure_is_signalled_by_having_no_members():
|
||||||
|
assert not Profile(members=[], shell=[]).ok
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Centring
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_centred_puts_the_envelope_on_the_origin():
|
||||||
|
g = geo()
|
||||||
|
c = centred(spoke_profile(3, 90.0, 1.2, 1.5, g, "Y"), g)
|
||||||
|
lo, hi = pointlist_bounds([p for path in c.shell for p in path])
|
||||||
|
assert lo[0] + hi[0] == pytest.approx(0.0, abs=1e-9)
|
||||||
|
assert lo[1] + hi[1] == pytest.approx(0.0, abs=1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_centred_keeps_everything_registered():
|
||||||
|
"""
|
||||||
|
Section, straps, cavities and shell take the same shift, so a strap still
|
||||||
|
sits inside its own channel afterwards.
|
||||||
|
"""
|
||||||
|
g = geo()
|
||||||
|
c = centred(spoke_profile(3, 90.0, 1.2, 1.5, g, "Y"), g)
|
||||||
|
assert area(c.straps) < area(c.cavity) < area(c.shell)
|
||||||
|
|
||||||
|
|
||||||
|
def test_centred_carries_shifted_members():
|
||||||
|
"""
|
||||||
|
Measuring a shifted shell against unshifted members reports every cavity as
|
||||||
|
escaping the envelope -- a leak of the whole cavity area from geometry that
|
||||||
|
is perfectly fine. The members travel with the regions to remove the chance.
|
||||||
|
"""
|
||||||
|
g = geo()
|
||||||
|
p = spoke_profile(3, 90.0, 1.2, 1.5, g, "Y")
|
||||||
|
c = centred(p, g)
|
||||||
|
assert metrics(c.section, c.shell, c.members, g).leak == pytest.approx(0.0, abs=1e-9)
|
||||||
|
stale = metrics(c.section, c.shell, p.members, g)
|
||||||
|
assert stale.leak > 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_centred_members_apply_the_same_shift():
|
||||||
|
g = geo()
|
||||||
|
p = spoke_profile(3, 90.0, 1.2, 1.5, g, "Y")
|
||||||
|
c = centred(p, g)
|
||||||
|
for a, b in zip(p.members, centred_members(p, c.shift)):
|
||||||
|
assert b.cx == pytest.approx(a.cx + c.shift[0])
|
||||||
|
assert b.angle == a.angle and b.face == a.face
|
||||||
|
|
||||||
|
|
||||||
|
def test_section_is_cleaned_before_it_is_measured():
|
||||||
|
"""
|
||||||
|
Cleaning removes collinear and coincident vertices left by exact butt
|
||||||
|
joints. Measuring first would report on geometry that is not what gets
|
||||||
|
extruded.
|
||||||
|
"""
|
||||||
|
g = geo()
|
||||||
|
c = centred(fin_profile(3, 6.25, 1.2, 7.5, 0.0, 1.5, g, "Three-Fin"), g)
|
||||||
|
for path in c.section:
|
||||||
|
assert len(path) >= 3
|
||||||
|
assert is_region_simple(c.section)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Ring arrangement
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_ring_rejects_a_degenerate_outline():
|
||||||
|
p = ring_polygon_profile([(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)],
|
||||||
|
geo(), 1.2, 0.0, "Equilateral Triangle")
|
||||||
|
assert not p.ok
|
||||||
|
assert "degenerate" in first_failure(p.checks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ring_rejects_an_unreachable_web():
|
||||||
|
p = ring_polygon_profile(unit_ngon(3), geo(), 1e6, 0.0, "Equilateral Triangle")
|
||||||
|
assert not p.ok
|
||||||
|
assert "no polygon size gives" in first_failure(p.checks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ring_matches_the_oracles_solved_geometry():
|
||||||
|
"""
|
||||||
|
Equilateral Triangle at the oracle's defaults. These four values are
|
||||||
|
recorded in the committed fixture set and none of them depends on the
|
||||||
|
junction fillet radius.
|
||||||
|
"""
|
||||||
|
g = geo()
|
||||||
|
p = ring_polygon_profile(unit_ngon(3), g, 1.2, 0.0, "Equilateral Triangle")
|
||||||
|
assert p.ok
|
||||||
|
assert echo_num(p.info["RING_CORNER_R_MAX_MM"]) == 2.87663
|
||||||
|
assert echo_num(p.info["RING_CORNER_WEB_MM"]) == 1.2
|
||||||
|
assert echo_vec(p.info["RING_EDGES_MM"]) == "[20.5209, 20.5209, 20.5209]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ring_scale_depends_on_the_seed_size_not_the_shape():
|
||||||
|
"""
|
||||||
|
RING_SCALE is relative to the outline supplied, so it is a property of the
|
||||||
|
caller's seed. The solved edge length is the size-independent quantity.
|
||||||
|
"""
|
||||||
|
g = geo()
|
||||||
|
small = ring_polygon_profile(unit_ngon(3), g, 1.2, 0.0, "t")
|
||||||
|
large = ring_polygon_profile([(200 * x, 200 * y) for x, y in unit_ngon(3)],
|
||||||
|
g, 1.2, 0.0, "t")
|
||||||
|
assert small.info["RING_SCALE"] != pytest.approx(large.info["RING_SCALE"])
|
||||||
|
assert echo_vec(small.info["RING_EDGES_MM"]) == echo_vec(large.info["RING_EDGES_MM"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_ring_rejects_a_corner_radius_beyond_the_envelope_limit():
|
||||||
|
g = geo()
|
||||||
|
p = ring_polygon_profile(unit_ngon(3), g, 1.2, 50.0, "Equilateral Triangle")
|
||||||
|
fail = first_failure(p.checks)
|
||||||
|
assert fail is not None and "corner radius" in fail
|
||||||
|
|
||||||
|
|
||||||
|
def test_ring_builds_at_n3_and_n4():
|
||||||
|
g = geo()
|
||||||
|
for n in (3, 4):
|
||||||
|
p = ring_polygon_profile(unit_ngon(n), g, 1.2, 0.0, "ring")
|
||||||
|
assert p.ok
|
||||||
|
c, m, fail = built(p, g, n)
|
||||||
|
assert fail is None
|
||||||
|
assert m.parts == 1 and m.slots == n
|
||||||
|
|
||||||
|
|
||||||
|
def test_ring_has_an_enclosed_bore():
|
||||||
|
g = geo()
|
||||||
|
p = ring_polygon_profile(unit_ngon(3), g, 1.2, 0.0, "ring")
|
||||||
|
assert len(p.bore) == 3
|
||||||
|
c, m, _ = built(p, g, 3)
|
||||||
|
assert len(c.section) > 1
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Spoke arrangement
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_spokes_face_open_air_on_both_sides():
|
||||||
|
"""
|
||||||
|
Design rule 4. Forcing an interior direction on a spoke is what made earlier
|
||||||
|
revisions chiral under asymmetric wall settings.
|
||||||
|
"""
|
||||||
|
for m in spoke_members(3, 9.0, 90.0):
|
||||||
|
assert m.face == SB_FACE_BOTH_OUT
|
||||||
|
assert m.inside_dir is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_spoke_profile_has_no_bore():
|
||||||
|
p = spoke_profile(3, 90.0, 1.2, 1.5, geo(), "Y")
|
||||||
|
assert p.bore == []
|
||||||
|
assert p.info["NOTE"] == "inside wall unused: no enclosed bore"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spoke_radius_matches_the_oracle():
|
||||||
|
"""Y at the oracle's defaults records SPOKE_RADIUS_MM = 9.1713."""
|
||||||
|
p = spoke_profile(3, 90.0, 1.2, 1.5, geo(), "Y")
|
||||||
|
assert echo_num(p.info["SPOKE_RADIUS_MM"]) == 9.1713
|
||||||
|
assert echo_num(p.info["SPOKE_WEB_MM"]) == 1.2
|
||||||
|
|
||||||
|
|
||||||
|
def test_spoke_envelope_matches_the_oracle():
|
||||||
|
g = geo()
|
||||||
|
c, m, _ = built(spoke_profile(3, 90.0, 1.2, 1.5, g, "Y"), g, 3)
|
||||||
|
assert echo_num(m.size_x) == 33.8488
|
||||||
|
assert echo_num(m.size_y) == 29.3139
|
||||||
|
|
||||||
|
|
||||||
|
def test_spoke_solve_hits_the_requested_web():
|
||||||
|
g = geo()
|
||||||
|
for web in (1.0, 1.2, 2.0):
|
||||||
|
p = spoke_profile(3, 90.0, web, 1.5, g, "Y")
|
||||||
|
assert spoke_web(3, p.info["SPOKE_RADIUS_MM"], 90.0, g) == \
|
||||||
|
pytest.approx(web, abs=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spoke_rejects_an_unreachable_web():
|
||||||
|
p = spoke_profile(3, 90.0, 1e6, 1.5, geo(), "Y")
|
||||||
|
assert not p.ok
|
||||||
|
assert "cannot open a" in first_failure(p.checks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spoke_requires_a_positive_fillet():
|
||||||
|
"""
|
||||||
|
A hull-plugged centre with no fillet meets the spokes along an exactly
|
||||||
|
tangent boundary: a valid outline that cannot be tessellated.
|
||||||
|
"""
|
||||||
|
p = spoke_profile(3, 90.0, 1.2, 0.0, geo(), "Y")
|
||||||
|
fail = first_failure(p.checks)
|
||||||
|
assert fail is not None and "greater than zero" in fail
|
||||||
|
|
||||||
|
|
||||||
|
def test_spokes_build_at_n3_and_n4():
|
||||||
|
g = geo()
|
||||||
|
for n in (3, 4):
|
||||||
|
c, m, fail = built(spoke_profile(n, 90.0, 1.2, 1.5, g, "spoke"), g, n)
|
||||||
|
assert fail is None
|
||||||
|
assert m.parts == 1 and m.slots == n
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Fin arrangement
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_regular_polygon_has_the_requested_side_length():
|
||||||
|
for n in (3, 4, 5):
|
||||||
|
path = regular_polygon(n, 10.0)
|
||||||
|
for i in range(n):
|
||||||
|
assert sb_dist(path[i], path[(i + 1) % n]) == pytest.approx(10.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fins_lie_tangentially_not_radially():
|
||||||
|
"""
|
||||||
|
Each member lies along a side of the core polygon, slid so it stops short of
|
||||||
|
the corner behind and overhangs the corner ahead. Sliding is what makes the
|
||||||
|
fin.
|
||||||
|
"""
|
||||||
|
g = geo()
|
||||||
|
plain = fin_members(3, 13.4, 0.0, 0.0, g)
|
||||||
|
slid = fin_members(3, 13.4, 6.25, 0.0, g)
|
||||||
|
for a, b in zip(plain, slid):
|
||||||
|
assert a.angle == pytest.approx(b.angle)
|
||||||
|
assert sb_dist((a.cx, a.cy), (b.cx, b.cy)) == pytest.approx(6.25)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fin_geometry_matches_the_oracle():
|
||||||
|
"""Three-Fin at the oracle's defaults. None of these depends on the fillet."""
|
||||||
|
g = geo()
|
||||||
|
p = fin_profile(3, 6.25, 1.2, 7.5, 0.0, 1.5, g, "Three-Fin")
|
||||||
|
assert p.ok
|
||||||
|
assert echo_num(p.info["FIN_CORE_SIDE_MM"]) == 13.4028
|
||||||
|
assert echo_num(p.info["FIN_BORE_SIDE_MM"]) == 7.5
|
||||||
|
assert echo_num(p.info["FIN_SETBACK_MM"]) == 3.77783
|
||||||
|
assert echo_num(p.info["FIN_JUNCTION_WEB_MM"]) == 2.29919
|
||||||
|
assert echo_num(p.info["FIN_PROJECTION_MM"]) == 6.25
|
||||||
|
|
||||||
|
|
||||||
|
def test_fin_core_is_solved_for_whichever_constraint_binds():
|
||||||
|
"""
|
||||||
|
The core size satisfies the junction web AND the bore, whichever demands
|
||||||
|
more. Raising the bore requirement past the web's must grow the core.
|
||||||
|
"""
|
||||||
|
g = geo()
|
||||||
|
small_bore = fin_profile(3, 6.25, 1.2, 1.0, 0.0, 1.5, g, "f")
|
||||||
|
large_bore = fin_profile(3, 6.25, 1.2, 20.0, 0.0, 1.5, g, "f")
|
||||||
|
assert large_bore.info["FIN_CORE_SIDE_MM"] > small_bore.info["FIN_CORE_SIDE_MM"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fin_bore_side_shrinks_with_a_thicker_inside_wall():
|
||||||
|
assert fin_bore_side(3, 13.4028, geo(wall_inside=2.4)) < \
|
||||||
|
fin_bore_side(3, 13.4028, geo(wall_inside=1.2))
|
||||||
|
|
||||||
|
|
||||||
|
def test_fin_rejects_an_unreachable_web():
|
||||||
|
p = fin_profile(3, 6.25, 1e6, 7.5, 0.0, 1.5, geo(), "Three-Fin")
|
||||||
|
assert not p.ok
|
||||||
|
assert "cannot open a" in first_failure(p.checks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fin_rejects_an_unreachable_bore():
|
||||||
|
p = fin_profile(3, 6.25, 1.2, 1e6, 0.0, 1.5, geo(), "Three-Fin")
|
||||||
|
assert not p.ok
|
||||||
|
assert "cannot reach a" in first_failure(p.checks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fin_requires_a_positive_setback():
|
||||||
|
"""
|
||||||
|
A non-positive setback means the members overlap instead of stepping
|
||||||
|
cyclically, so the arrangement is no longer what it claims to be.
|
||||||
|
"""
|
||||||
|
g = geo()
|
||||||
|
p = fin_profile(3, 0.0, 1.2, 7.5, 0.0, 1.5, g, "Three-Fin")
|
||||||
|
fail = first_failure(p.checks)
|
||||||
|
if fail is not None:
|
||||||
|
assert "setback" in fail or "bore" in fail
|
||||||
|
|
||||||
|
|
||||||
|
def test_fin_requires_a_positive_fillet():
|
||||||
|
p = fin_profile(3, 6.25, 1.2, 7.5, 0.0, 0.0, geo(), "Three-Fin")
|
||||||
|
fail = first_failure(p.checks)
|
||||||
|
assert fail is not None and "greater than zero" in fail
|
||||||
|
|
||||||
|
|
||||||
|
def test_fins_build_at_n3_and_n4():
|
||||||
|
g = geo()
|
||||||
|
for n in (3, 4):
|
||||||
|
c, m, fail = built(fin_profile(n, 6.25, 1.2, 7.5, 0.0, 1.5, g, "fin"), g, n)
|
||||||
|
assert fail is None
|
||||||
|
assert m.parts == 1 and m.slots == n
|
||||||
|
|
||||||
|
|
||||||
|
def test_fin_has_an_enclosed_bore():
|
||||||
|
g = geo()
|
||||||
|
p = fin_profile(3, 6.25, 1.2, 7.5, 0.0, 1.5, g, "Three-Fin")
|
||||||
|
assert len(p.bore) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleaning_actually_removes_the_collinear_vertices():
|
||||||
|
"""
|
||||||
|
Found by mutation testing: asserting the section is simple and has three or
|
||||||
|
more points per path passes with or without cleaning, so neither shows that
|
||||||
|
cleaning did anything.
|
||||||
|
|
||||||
|
A Three-Fin section carries 17 collinear vertices before cleaning -- exact
|
||||||
|
butt joints leave them -- and none afterwards. They are harmless in 2D and
|
||||||
|
leave zero-area triangles the tessellator cannot resolve, so a section that
|
||||||
|
measures perfectly still fails to extrude.
|
||||||
|
"""
|
||||||
|
from mechcomp.geom.join import section as cut_section
|
||||||
|
from mechcomp.geom.rounding import is_collinear
|
||||||
|
|
||||||
|
g = geo()
|
||||||
|
p = fin_profile(3, 6.25, 1.2, 7.5, 0.0, 1.5, g, "Three-Fin")
|
||||||
|
|
||||||
|
def collinear(rgn):
|
||||||
|
return sum(1 for path in rgn for i in range(len(path))
|
||||||
|
if is_collinear((path[(i - 1) % len(path)], path[i],
|
||||||
|
path[(i + 1) % len(path)])))
|
||||||
|
|
||||||
|
raw = cut_section(p.shell, p.members, g, p.bore)
|
||||||
|
assert collinear(raw) > 0
|
||||||
|
assert collinear(centred(p, g).section) == 0
|
||||||
Reference in New Issue
Block a user