port: the 3x and 4x profile catalogues; build() now exists

Completes the port. mechcomp.profiles.build and ProfileRejected are live,
so all 123 oracle cases execute rather than skip.

  _common.py   assembly pipeline shared by both families, and the eight
               base checks both generators declare identically
  four_x.py    five profiles, all direct library calls with N=4
  three_x.py   six profiles; four are library calls with N=3, while
               A Frame and T are built from join-layer primitives
               because they are specific arrangements rather than
               instances of a family

466 tests: 436 pass, 30 fail. THE 30 FAILURES ARE EXPECTED. Do not treat a
red `make test` here as a broken port.

All 30 are test_accepted_case_matches_oracle, and all 30 breach on
SECTION_AREA_MM2 alone -- VOLUME_MM3 and MASS_G are that value times 100
and times density, so each case has one underlying discrepancy reported
three times. Worst relative error 2.24e-05.

Everything that positions material is exact. ENVELOPE_X_MM, ENVELOPE_Y_MM,
MIN_WALL_ACTUAL_MM and every profile extra (AF_*, FIN_*, SPOKE_*, RING_*,
T_*) pass at 1e-4 mm in all 123 cases. Every count is exact. All ten
rejections fire correctly, including the bespoke A Frame and T paths.

Cause is F-034, now characterised precisely: the port is exact and the
reference is noisy. _circlecorner computes (90-angle)/180*segs(), which at
$fn=48 on a 90-degree corner is exactly 12. The port lands on 12.0 every
time and takes ceil 12; OpenSCAD's arithmetic lands a hair under 45 degrees
at some corners, pushing the value fractionally above 12 and the ceiling to
13. Measured on Rectangle: reference envelope 50 vertices, port 48.

There is nothing to correct on this side. Both boundaries sit within
0.0027 mm (4x) and 0.0043 mm (3x) of a true arc and within ~0.4 um of each
other -- far inside the project's 0.01 mm criterion. The tests fail because
VOLUME_MM3 is compared at 1e-4 absolute against a magnitude near 20000,
demanding 5e-9 relative agreement from discretised geometry.

Resolving that means changing the comparison policy in test_oracle.py, not
the oracle: the tolerance block is inside the hashed document and editing
it would break test_integrity_hash by design. Deferred pending a decision.

See docs/PRECISION.md for what the 0.01 mm criterion means and what this
compiler does not do.
This commit is contained in:
2026-08-20 06:41:59 -05:00
parent af196a26f5
commit 7b3182c0b9
4 changed files with 734 additions and 1 deletions
+51 -1
View File
@@ -1 +1,51 @@
"""Mechanical Compiler - profiles."""
"""
The port's public entry point.
``tests/conftest.py`` imports this module, not ``mechcomp.geom``, and requires a
``build`` attribute on it. The geometry lives under ``mechcomp.geom``; what the
oracle exercises is the pair below.
build(family, profile, params) -> Result
ProfileRejected
Parameter names are the OpenSCAD ones, unchanged. ``params`` carries only the
overrides for a case; everything else comes from the family's declared defaults,
which is why those defaults are part of the port rather than part of the test
harness.
"""
from __future__ import annotations
from typing import Dict, Mapping, Optional
from mechcomp.geom import ProfileRejected, Result # noqa: F401
from . import four_x, three_x
from ._common import Family, assemble
__all__ = ["build", "ProfileRejected", "Result", "FAMILIES"]
FAMILIES: Dict[str, Family] = {
three_x.FAMILY.name: three_x.FAMILY,
four_x.FAMILY.name: four_x.FAMILY,
}
def build(family: str, profile: str,
params: Optional[Mapping[str, object]] = None) -> Result:
"""
Build one cross-section and return its report.
Raises ``ProfileRejected`` when the arrangement is one the reference refused
to build -- the message names the parameter and the limit, as the reference
does. An unknown *family* is a caller error rather than a rejection, so it
raises ``ValueError``: silently rejecting it would make a missing generator
indistinguishable from a case the reference declined.
"""
fam = FAMILIES.get(family)
if fam is None:
raise ValueError(
"unknown family %r; this port implements %s"
% (family, ", ".join(sorted(FAMILIES)))
)
return assemble(fam, profile, params or {})
+161
View File
@@ -0,0 +1,161 @@
"""
The assembly pipeline shared by every family generator.
``strap-beam-3x.scad`` and ``strap-beam-4x.scad`` differ only in their parameter
defaults, their catalogue, and their member count. Everything from ``sb_geo()``
down to ``sb_report()`` is identical in both files, so it lives here once and the
family modules supply the parts that actually differ.
ORDER IS LOAD-BEARING
The reference evaluates, in this order: the profile builder, then the base
and profile checks, then the centred section, then the universal checks. The
first failing check is the one reported.
That ordering is not merely cosmetic. A builder that could not produce a
usable arrangement returns an empty profile (``profile_failed``), and
``centred()`` on an empty profile has no members to measure. The checks must
run first or the rejection surfaces as an exception from the geometry rather
than as the message the reference recorded.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Dict, List, Mapping, Sequence
from mechcomp.geom import (
Check,
Geo,
Profile,
Result,
centred,
metrics as compute_metrics,
profile_failed,
report,
require,
universal_checks,
)
# Generator revision. Qualification attestations bind to this, so any change
# that alters emitted geometry must bump it. See geom/report.py for what is
# published.
REVISION = "8.0.0"
Params = Mapping[str, object]
Builder = Callable[[Geo, Params], Profile]
@dataclass(frozen=True)
class Family:
"""One generator file's worth of difference."""
name: str # "3x" / "4x", as recorded in the oracle
member_count: int
defaults: Dict[str, object]
catalogue: Dict[str, Builder]
base_checks: Callable[[Params], List[Check]]
# ---------------------------------------------------------------------------
# Pieces every family shares
# ---------------------------------------------------------------------------
def make_geo(p: Params) -> Geo:
"""``sb_geo()`` -- argument order preserved from the reference."""
return Geo(
width=p["strap_width_mm"],
strap_t=p["strap_thickness_mm"],
count=p["bundle_count"],
clearance=p["fit_clearance_mm"],
wall_inside=p["inside_wall_thickness_mm"],
wall_outside=p["outside_wall_thickness_mm"],
wall_edge=p["edge_wall_thickness_mm"],
min_wall=p["min_wall_mm"],
)
def model_length_mm(p: Params) -> float:
"""
The length the report measures against.
All 123 oracle cases run in Preview at 100 mm; the Full Length branch is
reproduced because it is one line and its absence would be a silent
divergence the moment a case exercised it.
"""
if p["length_view"] == "Full Length":
return p["member_length_ft"] * 304.8
return p["preview_length_mm"]
def rect_outline(w: float, h: float) -> List[tuple]:
"""A centred rectangle. Shape only -- the ring fitter decides the size."""
return [(-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2), (-w / 2, h / 2)]
# ---------------------------------------------------------------------------
# The build
# ---------------------------------------------------------------------------
def assemble(family: Family, profile_type: str, params: Params) -> Result:
p = {**family.defaults, **dict(params or {})}
geo = make_geo(p)
builder = family.catalogue.get(profile_type)
if builder is None:
prof = profile_failed("Unknown profile_type: %s" % profile_type)
else:
prof = builder(geo, p)
# sb_require(concat(base_checks, sb_p_checks(profile)))
require(list(family.base_checks(p)) + list(prof.checks))
c = centred(prof, geo)
m = compute_metrics(c.section, c.shell, c.members, geo)
status = require(universal_checks(c.section, m, family.member_count, geo))
extra: Dict[str, object] = {
"REVISION": REVISION,
"LENGTH_FT": p["member_length_ft"],
"LENGTH_VIEW": p["length_view"],
}
extra.update(prof.info)
rep = report(
family.name, profile_type, status, geo, m,
model_length_mm(p), p["material_density_g_cm3"], extra,
)
return Result(report=rep, section=c.section, members=c.members, geo=geo)
def geometry_checks(p: Params) -> List[Check]:
"""
The eight checks both generators declare identically, in reference order.
A family's own ``base_checks`` appends its profile-specific parameters after
these. Order matters because the first failure is the one reported.
"""
import math
from mechcomp.geom import check
return [
check(p["strap_width_mm"] > 0,
"strap_width_mm must be greater than zero."),
check(p["strap_thickness_mm"] > 0,
"strap_thickness_mm must be greater than zero."),
check(p["bundle_count"] >= 1 and p["bundle_count"] == math.floor(p["bundle_count"]),
"bundle_count must be a whole number of at least 1."),
check(p["fit_clearance_mm"] >= 0,
"fit_clearance_mm cannot be negative."),
check(p["inside_wall_thickness_mm"] > 0,
"inside_wall_thickness_mm must be positive."),
check(p["outside_wall_thickness_mm"] > 0,
"outside_wall_thickness_mm must be positive."),
check(p["edge_wall_thickness_mm"] > 0,
"edge_wall_thickness_mm must be positive."),
check(p["min_wall_mm"] > 0,
"min_wall_mm must be positive."),
]
+151
View File
@@ -0,0 +1,151 @@
"""
Port of ``legacy/openscad/strap-beam-4x.scad`` -- the four-strap catalogue.
None of the geometry is new. All five profiles are one call each into
``mechcomp.geom.arrangements`` with N = 4:
Square / Rectangle / Diamond -> ring_polygon_profile()
Cross -> spoke_profile(4, ...)
Four-Fin -> fin_profile(4, ...)
The same three calls with N = 3 produce the Triangles, the Y and the Three-Fin
in the 3x catalogue. This module therefore contains only parameters, a catalogue
table, and the outline shapes.
Note that ``ring_corner_radius_mm`` defaults to 1.25 here, not the 2.00 the 3x
generator declares. The ring defaults are per-family, not shared.
"""
from __future__ import annotations
from typing import Dict, List
from mechcomp.geom import (
Check,
Geo,
Profile,
check,
fin_profile,
ring_polygon_profile,
sb_rot2,
spoke_profile,
)
from ._common import Family, Params, geometry_checks, rect_outline
MEMBER_COUNT = 4
# ---------------------------------------------------------------------------
# Parameters -- strap-beam-4x.scad
# ---------------------------------------------------------------------------
DEFAULTS: Dict[str, object] = {
# Pallet strap
"strap_width_mm": 15.875,
"strap_thickness_mm": 0.508,
"bundle_count": 1,
# Longitudinal axis
"member_length_ft": 10,
"length_view": "Preview",
"preview_length_mm": 100,
# PLA+ enclosure
"fit_clearance_mm": 0.25,
"inside_wall_thickness_mm": 1.20,
"outside_wall_thickness_mm": 1.20,
"edge_wall_thickness_mm": 1.20,
"min_wall_mm": 1.20,
# Ring profiles
"ring_corner_web_mm": 1.20,
"ring_corner_radius_mm": 1.25,
"rectangle_aspect": 1.60,
# Cross profile
"cross_rotation_deg": 0,
"cross_junction_web_mm": 1.20,
"cross_junction_round_mm": 1.50,
# Four-Fin profile
"four_fin_fin_mm": 6.25,
"four_fin_web_mm": 1.20,
"four_fin_bore_side_mm": 7.50,
"four_fin_rotation_deg": 0,
"four_fin_junction_round_mm": 2.00,
# Quality
"facets": 48,
# Reporting
"material_density_g_cm3": 1.24,
}
# ---------------------------------------------------------------------------
# Profiles
# ---------------------------------------------------------------------------
# Each is an outline plus a library call. The outlines set shape only; the ring
# fitter grows them until every corner carries ring_corner_web_mm.
def _square(g: Geo, p: Params) -> Profile:
return ring_polygon_profile(
rect_outline(1, 1), g,
p["ring_corner_web_mm"], p["ring_corner_radius_mm"], "Square")
def _rectangle(g: Geo, p: Params) -> Profile:
return ring_polygon_profile(
rect_outline(p["rectangle_aspect"], 1), g,
p["ring_corner_web_mm"], p["ring_corner_radius_mm"], "Rectangle")
def _diamond(g: Geo, p: Params) -> Profile:
return ring_polygon_profile(
[sb_rot2(q, 45) for q in rect_outline(1, 1)], g,
p["ring_corner_web_mm"], p["ring_corner_radius_mm"], "Diamond")
def _cross(g: Geo, p: Params) -> Profile:
return spoke_profile(
4, p["cross_rotation_deg"], p["cross_junction_web_mm"],
p["cross_junction_round_mm"], g, "Cross")
def _four_fin(g: Geo, p: Params) -> Profile:
return fin_profile(
4, p["four_fin_fin_mm"], p["four_fin_web_mm"],
p["four_fin_bore_side_mm"], p["four_fin_rotation_deg"],
p["four_fin_junction_round_mm"], g, "Four-Fin")
CATALOGUE = {
"Square": _square,
"Rectangle": _rectangle,
"Diamond": _diamond,
"Cross": _cross,
"Four-Fin": _four_fin,
}
# ---------------------------------------------------------------------------
# Base checks -- declaration order is the reporting order
# ---------------------------------------------------------------------------
def base_checks(p: Params) -> List[Check]:
return geometry_checks(p) + [
check(p["rectangle_aspect"] > 0,
"rectangle_aspect must be greater than zero."),
check(p["preview_length_mm"] > 0,
"preview_length_mm must be greater than zero."),
]
FAMILY = Family(
name="4x",
member_count=MEMBER_COUNT,
defaults=DEFAULTS,
catalogue=CATALOGUE,
base_checks=base_checks,
)
+371
View File
@@ -0,0 +1,371 @@
"""
Port of ``legacy/openscad/strap-beam-3x.scad`` -- the three-strap catalogue.
Four of the six profiles are one call each into ``mechcomp.geom.arrangements``
with N = 3, exactly as the 4x catalogue calls the same three with N = 4:
Equilateral / General Triangle -> ring_polygon_profile()
Y -> spoke_profile(3, ...)
Three-Fin -> fin_profile(3, ...)
A Frame and T are the two that genuinely do not generalise. They are specific
arrangements rather than instances of a family, so they are built here from the
join-layer primitives directly. That is the intended split: shapes that are an
N-instance live in the library, one-off arrangements live with their family.
``ring_corner_radius_mm`` is 2.00 here against the 4x file's 1.25 -- the ring
defaults are per-family.
"""
from __future__ import annotations
from typing import Dict, List, Sequence
from mechcomp.geom import (
SB_FACE_BOTH_OUT,
SB_SQRT3,
Check,
Geo,
Member,
Profile,
bore_from_members,
bore_valid,
cavity_path,
check,
cos_d,
end_face,
face_toward,
far_face_line,
fillet_junctions,
fin_profile,
hull_cap,
profile_failed,
ring_polygon_profile,
sb_path_gap,
sb_solvable,
sb_solve,
sin_d,
sleeve_butt,
sleeve_path,
sleeve_span,
spoke_profile,
union,
)
from ._common import Family, Params, geometry_checks
MEMBER_COUNT = 3
# ---------------------------------------------------------------------------
# Parameters -- strap-beam-3x.scad
# ---------------------------------------------------------------------------
DEFAULTS: Dict[str, object] = {
# Pallet strap
"strap_width_mm": 15.875,
"strap_thickness_mm": 0.508,
"bundle_count": 1,
# Longitudinal axis
"member_length_ft": 10,
"length_view": "Preview",
"preview_length_mm": 100,
# PLA+ enclosure
"fit_clearance_mm": 0.25,
"inside_wall_thickness_mm": 1.20,
"outside_wall_thickness_mm": 1.20,
"edge_wall_thickness_mm": 1.20,
"min_wall_mm": 1.20,
# Ring profiles
"ring_corner_web_mm": 1.20,
"ring_corner_radius_mm": 2.00,
# General Triangle shape
"general_triangle_base": 17.50,
"general_triangle_height": 15.50,
"general_triangle_apex_offset": 1.50,
# A Frame
"a_frame_leg_angle_deg": 45,
"a_frame_apex_web_mm": 1.20,
"a_frame_crossbar_web_mm": 1.20,
"a_frame_crossbar_offset_mm": 0,
"a_frame_junction_round_mm": 1.50,
# Y profile
"y_rotation_deg": 90,
"y_junction_web_mm": 1.20,
"y_junction_round_mm": 1.50,
# T profile
"t_stem_web_mm": 1.20,
"t_flange_web_mm": 1.20,
"t_junction_round_mm": 1.50,
# Three-Fin profile
"three_fin_fin_mm": 6.25,
"three_fin_web_mm": 1.20,
"three_fin_bore_side_mm": 7.50,
"three_fin_rotation_deg": 0,
"three_fin_junction_round_mm": 2.00,
# Quality
"facets": 48,
# Reporting
"material_density_g_cm3": 1.24,
}
# ---------------------------------------------------------------------------
# 1-2. Ring profiles
# ---------------------------------------------------------------------------
# Both triangles are the same construction: a centreline polygon, grown until
# its corners are legal, wrapped in one envelope with solid rounded corners,
# with the bore taken from the members' real inside walls.
def _equilateral(g: Geo, p: Params) -> Profile:
s = g.width
a = s / (2 * SB_SQRT3)
return ring_polygon_profile(
[(-s / 2, -a), (s / 2, -a), (0, 2 * a)], g,
p["ring_corner_web_mm"], p["ring_corner_radius_mm"],
"Equilateral Triangle")
def _general_triangle(g: Geo, p: Params) -> Profile:
base = p["general_triangle_base"]
height = p["general_triangle_height"]
return ring_polygon_profile(
[(-base / 2, -height / 2),
(base / 2, -height / 2),
(p["general_triangle_apex_offset"], height / 2)], g,
p["ring_corner_web_mm"], p["ring_corner_radius_mm"], "General Triangle")
# ---------------------------------------------------------------------------
# 3. A Frame
# ---------------------------------------------------------------------------
# The apex is a real meeting point: both leg centrelines pass through it and each
# leg is set back along its own axis until the two cavities are exactly
# a_frame_apex_web_mm apart. The crossbar is likewise solved by depth against the
# measured web and then butted flush between the two legs' outer faces.
def _a_frame_legs(g: Geo, p: Params, setback: float) -> List[Member]:
a = p["a_frame_leg_angle_deg"]
target = (0, -g.width) # deep inside the counter
d = g.cavity_w / 2 + setback
ang_l = 180 + a
ang_r = -a
c_l = (d * cos_d(ang_l), d * sin_d(ang_l))
c_r = (d * cos_d(ang_r), d * sin_d(ang_r))
return [
Member(c_l[0], c_l[1], ang_l, face_toward(c_l, ang_l, target)),
Member(c_r[0], c_r[1], ang_r, face_toward(c_r, ang_r, target)),
]
def _a_frame_apex_web(g: Geo, p: Params, setback: float) -> float:
legs = _a_frame_legs(g, p, setback)
return sb_path_gap(cavity_path(legs[0], g), cavity_path(legs[1], g))
def _a_frame_crossbar(g: Geo, p: Params, depth: float) -> Member:
c = (p["a_frame_crossbar_offset_mm"], -depth)
return Member(c[0], c[1], 0, face_toward(c, 0, (0, 0)))
def _a_frame_bar_web(g: Geo, p: Params, legs: Sequence[Member],
depth: float) -> float:
cb = cavity_path(_a_frame_crossbar(g, p, depth), g)
return min(sb_path_gap(cb, cavity_path(legs[0], g)),
sb_path_gap(cb, cavity_path(legs[1], g)))
def _a_frame(g: Geo, p: Params) -> Profile:
W = g.width
def f_apex(s: float) -> float:
return _a_frame_apex_web(g, p, s)
if not sb_solvable(f_apex, 6 * W, p["a_frame_apex_web_mm"]):
return profile_failed(
"A Frame: at %s degrees the legs are too close to parallel to open "
"a %s mm apex web. Reduce a_frame_leg_angle_deg."
% (p["a_frame_leg_angle_deg"], p["a_frame_apex_web_mm"]))
setback = sb_solve(f_apex, 0, 6 * W, p["a_frame_apex_web_mm"])
legs = _a_frame_legs(g, p, setback)
def f_bar(d: float) -> float:
return _a_frame_bar_web(g, p, legs, d)
if not sb_solvable(f_bar, 8 * W, p["a_frame_crossbar_web_mm"]):
return profile_failed(
"A Frame: the crossbar cannot reach a legal web against the legs. "
"Reduce a_frame_crossbar_web_mm or the crossbar offset.")
depth = sb_solve(f_bar, 0, 8 * W, p["a_frame_crossbar_web_mm"])
bar = _a_frame_crossbar(g, p, depth)
ms = [legs[0], bar, legs[1]]
# Legs run up to the apex; the wedge between their end faces is plugged with
# their convex hull, which leaves no spike and no V-notch.
ext = max(0, setback - g.wall_edge)
leg_l = sleeve_path(legs[0], g, 0, ext)
leg_r = sleeve_path(legs[1], g, 0, ext)
cap = hull_cap([end_face(legs[0], g, -1, ext),
end_face(legs[1], g, -1, ext)])
# Crossbar spans flush between the legs' outer faces.
line_l = far_face_line(legs[0], g, bar.centre)
line_r = far_face_line(legs[1], g, bar.centre)
bar_p = sleeve_span(bar, g, line_l[0], line_l[1], line_r[0], line_r[1])
paths = [leg_l, bar_p, leg_r]
raw = union([[leg_l], [leg_r], [bar_p]] + ([[cap]] if len(cap) > 0 else []))
shell = fillet_junctions(raw, [(0, 1), (1, 2), (0, 2)], paths,
p["a_frame_junction_round_mm"])
bore = bore_from_members(ms, g)
leg_end_depth = ((setback + g.cavity_w)
* sin_d(p["a_frame_leg_angle_deg"]))
return Profile(
members=ms,
shell=shell,
bore=bore,
checks=[
check(depth < leg_end_depth,
"A Frame: the crossbar sits at %s mm below the apex but the "
"legs only reach %s mm. Reduce a_frame_crossbar_web_mm or the "
"leg angle." % (depth, leg_end_depth)),
check(bore_valid(bore, ms, g),
"A Frame: the enclosed counter has collapsed. Reduce "
"inside_wall_thickness_mm or open the frame out."),
check(p["a_frame_junction_round_mm"] > 0,
"A Frame: a_frame_junction_round_mm 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."),
check(abs(p["a_frame_crossbar_offset_mm"]) < g.width / 2,
"A Frame: a_frame_crossbar_offset_mm has pushed the "
"crossbar off the frame."),
],
info={
"AF_APEX_SETBACK_MM": setback,
"AF_CROSSBAR_DEPTH_MM": depth,
"AF_APEX_WEB_MM": _a_frame_apex_web(g, p, setback),
"AF_CROSSBAR_WEB_MM": _a_frame_bar_web(g, p, legs, depth),
},
)
# ---------------------------------------------------------------------------
# 4. Y
# ---------------------------------------------------------------------------
def _y(g: Geo, p: Params) -> Profile:
return spoke_profile(3, p["y_rotation_deg"], p["y_junction_web_mm"],
p["y_junction_round_mm"], g, "Y")
# ---------------------------------------------------------------------------
# 5. T
# ---------------------------------------------------------------------------
# Two straps form the flange, separated by their own web so each keeps a private
# channel. The stem is placed so its cavity clears the flange cavities by exactly
# t_stem_web_mm and is then butted flush into the flange's far face.
def _t_members(g: Geo, p: Params) -> List[Member]:
half = p["t_flange_web_mm"] / 2 + g.cavity_w / 2
top = -g.cavity_t / 2 - p["t_stem_web_mm"]
stem_y = top - g.cavity_w / 2
return [
Member(-half, 0, 0, SB_FACE_BOTH_OUT),
Member(half, 0, 0, SB_FACE_BOTH_OUT),
Member(0, stem_y, -90, SB_FACE_BOTH_OUT), # leading end points down
]
def _t(g: Geo, p: Params) -> Profile:
ms = _t_members(g, p)
fl_l = sleeve_path(ms[0], g)
fl_r = sleeve_path(ms[1], g)
stem = sleeve_butt(ms[2], g, ms[0])
paths = [fl_l, fl_r, stem]
raw = union([[fl_l], [fl_r], [stem]])
shell = fillet_junctions(raw, [(0, 2), (1, 2)], paths,
p["t_junction_round_mm"])
flange_overlap = 2 * g.wall_edge - p["t_flange_web_mm"]
return Profile(
members=ms,
shell=shell,
bore=[],
checks=[
check(flange_overlap > 0,
"T: t_flange_web_mm of %s mm exceeds twice the edge wall, so "
"the two flange sleeves no longer meet. Reduce it or raise "
"edge_wall_thickness_mm." % p["t_flange_web_mm"]),
check(p["t_stem_web_mm"] >= g.min_wall - 1e-9,
"T: t_stem_web_mm of %s mm is below min_wall_mm."
% p["t_stem_web_mm"]),
check(p["t_flange_web_mm"] >= g.min_wall - 1e-9,
"T: t_flange_web_mm of %s mm is below min_wall_mm."
% p["t_flange_web_mm"]),
check(p["t_junction_round_mm"] > 0,
"T: t_junction_round_mm must be greater than zero. A butt "
"joint with no fillet meets the flange along an exactly "
"tangent boundary, which is a valid outline but cannot be "
"tessellated."),
],
info={
"T_FLANGE_SPAN_MM": (2 * g.cavity_w + p["t_flange_web_mm"]
+ 2 * g.wall_edge),
"T_NOTE": "inside_wall unused: no enclosed bore",
},
)
# ---------------------------------------------------------------------------
# 6. Three-Fin
# ---------------------------------------------------------------------------
def _three_fin(g: Geo, p: Params) -> Profile:
return fin_profile(
3, p["three_fin_fin_mm"], p["three_fin_web_mm"],
p["three_fin_bore_side_mm"], p["three_fin_rotation_deg"],
p["three_fin_junction_round_mm"], g, "Three-Fin")
CATALOGUE = {
"Equilateral Triangle": _equilateral,
"General Triangle": _general_triangle,
"A Frame": _a_frame,
"Y": _y,
"T": _t,
"Three-Fin": _three_fin,
}
# ---------------------------------------------------------------------------
# Base checks -- declaration order is the reporting order
# ---------------------------------------------------------------------------
def base_checks(p: Params) -> List[Check]:
return geometry_checks(p) + [
check(p["preview_length_mm"] > 0,
"preview_length_mm must be greater than zero."),
]
FAMILY = Family(
name="3x",
member_count=MEMBER_COUNT,
defaults=DEFAULTS,
catalogue=CATALOGUE,
base_checks=base_checks,
)