diff --git a/src/mechcomp/profiles/__init__.py b/src/mechcomp/profiles/__init__.py index c07c8b9..dd1a8bf 100644 --- a/src/mechcomp/profiles/__init__.py +++ b/src/mechcomp/profiles/__init__.py @@ -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 {}) diff --git a/src/mechcomp/profiles/_common.py b/src/mechcomp/profiles/_common.py new file mode 100644 index 0000000..8d15d68 --- /dev/null +++ b/src/mechcomp/profiles/_common.py @@ -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."), + ] diff --git a/src/mechcomp/profiles/four_x.py b/src/mechcomp/profiles/four_x.py new file mode 100644 index 0000000..e912dba --- /dev/null +++ b/src/mechcomp/profiles/four_x.py @@ -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, +) diff --git a/src/mechcomp/profiles/three_x.py b/src/mechcomp/profiles/three_x.py new file mode 100644 index 0000000..5245f8c --- /dev/null +++ b/src/mechcomp/profiles/three_x.py @@ -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, +)