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:
@@ -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 {})
|
||||||
|
|||||||
@@ -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."),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user