Seed repository: rev-8.0.0 reference, frozen oracle, toolchain, test harness

Reference implementation of the strap-beam generators at revision 8.0.0, kept
so the acceptance oracle can be regenerated. Not a live target; the running
application has no OpenSCAD dependency.

The oracle holds 123 frozen cases, 113 accepted and 10 rejected, produced by
OpenSCAD 2021.01 with BOSL2 at 92d697c2. The ten rejections are part of the
contract: a port that accepts them is wrong.

tests/test_oracle.py specifies the port API and was written before the port,
so the interface follows from what must be verified rather than what is
convenient to implement. Proven by adversarial stub: a build() that rejects
everything passes all 10 rejection tests and fails all 226 acceptance tests.
This commit is contained in:
2026-08-18 07:30:17 -05:00
parent eb0d533d86
commit c7e32d8e07
28 changed files with 7822 additions and 1 deletions
+30
View File
@@ -0,0 +1,30 @@
# Virtual environment — a legitimate artifact inside install_dir, never tracked.
venv/
.venv/
# Python
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.coverage
htmlcov/
# Instance configuration — never committed. See docs/ENVIRONMENT.md section 13.
deploy/site.env
# Generated output
/out/
*.stl
*.step
# Shell history from interactive sessions
.lesshst
# Service user's HOME is install_dir, so caches land here
.cache/
.local/
# Service user HOME is install_dir, so SSH keys land here
.ssh/
.gitconfig
+47
View File
@@ -0,0 +1,47 @@
# Entry points. Every target is safe to run more than once.
PY ?= venv/bin/python
PIP ?= venv/bin/pip
.PHONY: help venv deps deps-nocad toolchain verify-oracle regenerate-oracle test test-nocad clean
help:
@echo "venv create the virtualenv (Python 3.11)"
@echo "deps install base + CAD requirements"
@echo "deps-nocad install base requirements only"
@echo "toolchain build the pinned reference toolchain image"
@echo "verify-oracle check the committed oracle is intact"
@echo "regenerate-oracle rebuild the oracle inside the pinned image"
@echo "test pytest -n auto"
@echo "test-nocad the separability check: suite must pass without CAD"
venv:
test -d venv || python3 -m venv venv
$(PIP) install --upgrade pip
deps: venv
$(PIP) install -r requirements-base.txt -r requirements-cad.txt
$(PIP) install -e .
deps-nocad: venv
$(PIP) install -r requirements-base.txt
$(PIP) install -e .
toolchain:
docker build -t mechcomp/reference-toolchain:8.0.0 tools/reference-toolchain
verify-oracle:
python3 fixtures/strap-beam-8.0.0/make_fixtures.py --verify
regenerate-oracle: toolchain
./tools/reference-toolchain/verify.sh --full
test:
$(PY) -m pytest -n auto
test-nocad:
$(PY) -m pytest -n auto -k "not cad"
clean:
rm -rf .pytest_cache src/*.egg-info
find . -name __pycache__ -type d -prune -exec rm -rf {} +
+84 -1
View File
@@ -1,2 +1,85 @@
# mechanical-compiler
# Mechanical Compiler
Build the capacity to construct real structures from reclaimed and commodity
materials, using whatever fabrication is actually to hand — 3D printing,
tabletop CNC, welding, cement casting, COTS stock.
The reference case is a faceted timber shell: planar panels meeting along
straight fold lines, converging on nodes, sitting on a platform. Buildable
without a factory, provided someone has worked out what the pieces are and how
they meet. **That last clause is the project.** The Mechanical Compiler exists
to make the pieces computable, qualifiable, and reproducible by someone who was
not present when they were designed.
## Scope
**In scope:** geometry, qualification, and reproducibility of structural
members and their interfaces.
**Not in scope:** building codes, permitting, jurisdictional approval.
Qualification and compliance are different things. A qualification says *this
member is what it claims to be, made this way, from this stock*. Compliance is
a jurisdiction-specific argument someone else may build on top.
The project records **physical claims, never verdicts** — section modulus,
moment of inertia, material provenance, process parameters. Those are what any
future argument would need. A pass/fail verdict would bake in a jurisdiction we
do not want to be bound to. Measure and attest; never adjudicate.
## Repository layout
```
docs/ specification, state, failure log, roadmap
legacy/openscad/ rev 8.0.0 generators — reference, not a live target
fixtures/ frozen acceptance oracles
tools/reference-toolchain/ pinned OpenSCAD + BOSL2, build-time only
src/mechcomp/ the application
tests/ acceptance against the oracle
```
Read `docs/ROADMAP.md` first for what this is, then `docs/ENVIRONMENT.md` for
how an instance is built. If you are writing provisioning automation, read
`docs/FAILURES.md` **before** the specification — every entry is something a
script written from the specification alone would have got wrong.
## Getting started
```bash
make deps # virtualenv, base + CAD requirements
make verify-oracle # confirm the frozen oracle is intact
make test # pytest -n auto
```
`make verify-oracle` needs nothing but Python. It should pass on any machine at
any time; if it does not, stop.
## The oracle
`fixtures/strap-beam-8.0.0/` holds 123 frozen cases — 113 accepted, 10
rejected — produced by OpenSCAD 2021.01 with BOSL2 at `92d697c2`. It is the
acceptance criterion for any reimplementation of the generators.
**The ten rejected cases are part of the contract.** A port that accepts them is
wrong, however good its numbers look elsewhere. It is easy to reproduce the
geometry and quietly lose the constraint that made it trustworthy.
The generators under `legacy/openscad/` are the reference implementation, kept
so the oracle can be regenerated. They are not a live target and the running
application has no OpenSCAD dependency.
## Provenance
**This project's documents, code and roadmap are LLM-generated under human
direction.** That is stated plainly here and in any downstream submission. We
do not obscure it.
The maintainer reviews and owns every line. Anything that could not be defended
in a review thread does not ship.
## Licence
AGPL-3.0-or-later. See `LICENSE`.
Section 13 obliges us to offer source to users interacting over a network, so
any deployed web tier carries a visible link back to this repository. That is a
licence obligation, not a courtesy.
+68
View File
@@ -0,0 +1,68 @@
# Acceptance oracle — strap-beam revision 8.0.0
123 cases. 113 accepted, 10 rejected. `sha256 ddd0f154…`
## What it is for
This is the acceptance criterion for any reimplementation of the generators. A
port is correct when it reproduces these values — not when it looks right, and
not when it renders something plausible.
## The rejected cases are part of the contract
Ten cases were rejected by the reference, each with a message naming the
parameter and the limit. **A port that accepts them is wrong**, however good its
numbers are elsewhere.
This is the part a naive reimplementation loses. Reproducing geometry is
straightforward; keeping the constraint that made the geometry trustworthy is
the actual work. `tests/test_oracle.py` asserts both directions.
## What the fields mean
| Key | Role |
|---|---|
| `MIN_WALL_ACTUAL_MM` | thinnest surviving material anywhere in the section |
| `SECTION_PARTS` | connected solids; must be 1 |
| `STRAP_CHANNELS` | separate strap slots; must equal the member count |
| `RING_*`, `FIN_*`, `AF_*`, `SPOKE_*` | solved placement values — these pin the solvers |
The first three are invariants. The solved values are what catch a port that
reaches the right shape by a different route, which will diverge later.
`tolerance` in the document governs comparison: lengths to 1e-4 mm, areas to
1e-3 mm², counts exact.
## The hash
`fixtures_sha256` covers the document **without that field**, serialised with
`indent=2` and `sort_keys=True`. Hashing the file will not reproduce it.
```bash
python3 make_fixtures.py --verify
```
That needs nothing but Python and should pass anywhere, any time.
## Regenerating
Only inside the pinned toolchain, never on a host whose OpenSCAD merely looks
similar:
```bash
./tools/reference-toolchain/verify.sh --full
```
The `frozen` date changes on every regeneration, so a one-line diff there is
expected. **Any other difference means the toolchain has drifted** — stop and
investigate. A drifting oracle is worse than no oracle.
## Coverage
Beyond the eleven default profiles: bundle counts 2 and 3; conditioned
intermediate stock at 13.4 mm and steel strap at 0.79 mm on every profile;
asymmetric walls as a chirality regression; parameter sweeps across corner
radius, corner web, leg angle, fin projection, fillet radius, bore size,
rotation, junction webs, triangle shape and rectangle aspect; and four
cross-profile isolation cases proving one profile's parameters cannot affect
another's.
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""
Regenerate the frozen rev-8.0.0 acceptance oracle.
WHAT THIS IS
------------
The oracle is the acceptance criterion for any reimplementation of the rev-8
generators. A port is correct when it reproduces these values -- not when it
looks right. The ten rejected cases are part of the contract: a port that
accepts them is wrong.
WHY IT IS PINNED
----------------
Every case here was produced by OpenSCAD 2021.01 with BOSL2 at commit
92d697c2856de2fed93a33e858068589cefc2898. Those versions live in a container
image (tools/reference-toolchain) and nowhere else. The running application has
no OpenSCAD dependency at all, so the host's OpenSCAD version cannot drift into
this file.
HOW THE HASH WORKS
------------------
`fixtures_sha256` covers the document WITHOUT that field, serialised with
indent=2 and sort_keys=True. Hashing the file itself will not reproduce it.
`--verify` performs the correct comparison.
USAGE
-----
python3 make_fixtures.py --verify # check the committed oracle
python3 make_fixtures.py --regenerate # rebuild it (needs OpenSCAD + BOSL2)
Run --regenerate inside the reference toolchain image, never on a host whose
OpenSCAD version is merely similar.
"""
from __future__ import annotations
import argparse
import datetime
import hashlib
import json
import subprocess
import sys
from pathlib import Path
REVISION = "8.0.0"
OPENSCAD_VERSION = "2021.01"
BOSL2_COMMIT = "92d697c2856de2fed93a33e858068589cefc2898"
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[1]
SCAD_DIR = REPO / "legacy" / "openscad"
OUT = HERE / f"strap-beam-fixtures-{REVISION}.json"
GEN_3X = "strap-beam-3x.scad"
GEN_4X = "strap-beam-4x.scad"
P3 = ["Equilateral Triangle", "General Triangle", "A Frame", "Y", "T", "Three-Fin"]
P4 = ["Square", "Rectangle", "Diamond", "Cross", "Four-Fin"]
# Report keys carrying numeric values. Everything else stays a string.
NUM = set(
"""STRAP_WIDTH_MM STRAP_THICK_MM BUNDLE_COUNT BUNDLE_THICK_MM CLEARANCE_MM
WALL_INSIDE_MM WALL_OUTSIDE_MM WALL_EDGE_MM MIN_WALL_SPEC_MM MIN_WALL_ACTUAL_MM
SECTION_AREA_MM2 SECTION_PARTS STRAP_CHANNELS ENVELOPE_X_MM ENVELOPE_Y_MM
LENGTH_MM VOLUME_MM3 MASS_G LENGTH_FT RING_SCALE RING_CORNER_WEB_MM
RING_CORNER_R_MAX_MM SPOKE_RADIUS_MM SPOKE_WEB_MM FIN_CORE_SIDE_MM
FIN_PROJECTION_MM FIN_SETBACK_MM FIN_BORE_SIDE_MM FIN_JUNCTION_WEB_MM
AF_APEX_SETBACK_MM AF_CROSSBAR_DEPTH_MM AF_APEX_WEB_MM AF_CROSSBAR_WEB_MM
T_FLANGE_SPAN_MM""".split()
)
# ---------------------------------------------------------------------------
# Case matrix
# ---------------------------------------------------------------------------
def build_cases() -> list[dict]:
cases: list[dict] = []
def case(gen: str, profile: str, label: str, defs: list[str]) -> None:
cases.append({"generator": gen, "profile": profile, "label": label, "defs": defs})
for p in P3:
case(GEN_3X, p, "default", [])
for p in P4:
case(GEN_4X, p, "default", [])
# A bundle is a stack of laminae, not a thicker strap.
for p in P3:
for b in (2, 3):
case(GEN_3X, p, f"bundle{b}", [f"-Dbundle_count={b}"])
for p in P4:
for b in (2, 3):
case(GEN_4X, p, f"bundle{b}", [f"-Dbundle_count={b}"])
# Conditioned intermediate stock (Utility Two) is a first-class input, not
# an edge case. Steel strap thickness likewise.
for p in P3 + P4:
g = GEN_3X if p in P3 else GEN_4X
case(g, p, "width13.4", ["-Dstrap_width_mm=13.4"])
case(g, p, "steel0.79", ["-Dstrap_thickness_mm=0.79"])
# The chirality regression.
for p in P3 + P4:
g = GEN_3X if p in P3 else GEN_4X
case(g, p, "wall_asym", [
"-Dinside_wall_thickness_mm=0.8",
"-Doutside_wall_thickness_mm=2.4",
"-Dmin_wall_mm=0.8",
])
# Profile ranges, including boundaries that MUST be rejected.
for a in (20, 30, 45, 53, 55, 70):
case(GEN_3X, "A Frame", f"leg{a}", [f"-Da_frame_leg_angle_deg={a}"])
for r in (0, 1, 2, 2.8, 2.9, 5):
case(GEN_3X, "Equilateral Triangle", f"cornerR{r}", [f"-Dring_corner_radius_mm={r}"])
for w in (0.4, 1.2, 2.0, 4.0):
case(GEN_3X, "Equilateral Triangle", f"web{w}",
[f"-Dring_corner_web_mm={w}", f"-Dmin_wall_mm={min(w, 1.2)}"])
for f in (0, 2, 6.25, 12, 25):
case(GEN_3X, "Three-Fin", f"fin{f}", [f"-Dthree_fin_fin_mm={f}"])
for r in (0, 1, 2, 8):
case(GEN_3X, "Three-Fin", f"fillet{r}", [f"-Dthree_fin_junction_round_mm={r}"])
for b in (1, 7.5, 15, 30):
case(GEN_3X, "Three-Fin", f"bore{b}", [f"-Dthree_fin_bore_side_mm={b}"])
for a in (0, 17, 33, 90):
case(GEN_3X, "Three-Fin", f"rot{a}", [f"-Dthree_fin_rotation_deg={a}"])
for w in (0, 0.5, 1.2, 4.0):
case(GEN_3X, "Y", f"ywebb{w}",
[f"-Dy_junction_web_mm={w}", f"-Dmin_wall_mm={max(0.01, min(w, 1.2))}"])
for w in (0, 0.25, 1.2, 3.0):
case(GEN_3X, "T", f"tweb{w}",
[f"-Dt_stem_web_mm={w}", f"-Dmin_wall_mm={max(0.01, min(w, 1.2))}"])
for h in (4, 8, 15.5, 30):
case(GEN_3X, "General Triangle", f"h{h}", [f"-Dgeneral_triangle_height={h}"])
for a in (1.0, 1.6, 2.5, 4.0):
case(GEN_4X, "Rectangle", f"aspect{a}", [f"-Drectangle_aspect={a}"])
for f in (0, 2, 6.25, 15):
case(GEN_4X, "Four-Fin", f"fin{f}", [f"-Dfour_fin_fin_mm={f}"])
# Isolation: a foreign parameter must never affect this profile.
case(GEN_3X, "Y", "isolate_aframe", ["-Da_frame_leg_angle_deg=89"])
case(GEN_3X, "Y", "isolate_threefin", ["-Dthree_fin_fin_mm=0"])
case(GEN_3X, "T", "isolate_gentri", ["-Dgeneral_triangle_height=0.001"])
case(GEN_3X, "Equilateral Triangle", "isolate_t", ["-Dt_stem_web_mm=0"])
return cases
# ---------------------------------------------------------------------------
# Execution
# ---------------------------------------------------------------------------
def run_case(generator: str, defs: list[str]) -> tuple[dict, str | None]:
"""Run one generator invocation; return (report, rejection_message)."""
cmd = ["openscad", "-o", "/dev/null", "--export-format=asciistl", *defs, generator]
proc = subprocess.run(cmd, cwd=SCAD_DIR, capture_output=True, text=True, timeout=900)
text = proc.stdout + proc.stderr
report: dict = {}
rejection: str | None = None
for line in text.splitlines():
if line.startswith('ECHO: "SB_') and "=" in line:
key, _, value = line[len('ECHO: "SB_'):].rstrip('"').partition("=")
if key == "END":
continue
if key in NUM:
try:
value = float(value)
except ValueError:
pass
report[key] = value
elif "[strap-beam]" in line:
rejection = line.split("] ", 1)[-1].strip()
return report, rejection
def regenerate() -> int:
cases = build_cases()
results = []
for i, c in enumerate(cases, 1):
defs = [f'-Dprofile_type="{c["profile"]}"'] + c["defs"]
report, rejection = run_case(c["generator"], defs)
results.append({
**c,
"defs": defs,
"outcome": "ok" if report else "rejected",
"report": report or None,
"rejection": rejection,
})
print(f" [{i:3d}/{len(cases)}] {c['profile']:22s} {c['label']:16s} "
f"{'ok' if report else 'REJECTED'}", file=sys.stderr)
accepted = sum(1 for r in results if r["outcome"] == "ok")
doc = {
"schema": "strap-beam.fixtures/1",
"generator_revision": REVISION,
"frozen": datetime.date.today().isoformat(),
"toolchain": {"openscad": OPENSCAD_VERSION, "bosl2_commit": BOSL2_COMMIT},
"note": (
"Acceptance oracle for any reimplementation of the rev-8 generators. "
"MIN_WALL_ACTUAL_MM, SECTION_PARTS and STRAP_CHANNELS are the invariants; "
"the solved values (RING_*, FIN_*, AF_*, SPOKE_*) pin the placement solvers. "
"Rejected cases are part of the contract: a port that accepts them is wrong."
),
"tolerance": {"lengths_mm": 1e-4, "areas_mm2": 1e-3, "note": "counts are exact"},
"summary": {"cases": len(results), "accepted": accepted,
"rejected": len(results) - accepted},
"cases": results,
}
doc["fixtures_sha256"] = hashlib.sha256(
json.dumps(doc, indent=2, sort_keys=True).encode()
).hexdigest()
OUT.write_text(json.dumps(doc, indent=2, sort_keys=True))
print(f"\n{len(results)} cases: {accepted} accepted, {len(results) - accepted} rejected")
print("sha256:", doc["fixtures_sha256"])
return 0
def verify() -> int:
if not OUT.exists():
print(f"MISSING: {OUT}", file=sys.stderr)
return 2
doc = json.loads(OUT.read_text())
recorded = doc.pop("fixtures_sha256", None)
actual = hashlib.sha256(json.dumps(doc, indent=2, sort_keys=True).encode()).hexdigest()
print(f"file: {OUT.name}")
print(f"revision: {doc.get('generator_revision')}")
print(f"toolchain: openscad {doc['toolchain']['openscad']}, "
f"bosl2 {doc['toolchain']['bosl2_commit'][:10]}")
print(f"summary: {doc['summary']}")
print(f"recorded: {recorded}")
print(f"computed: {actual}")
if recorded == actual:
print("\nOK - oracle is intact.")
return 0
print("\nFAIL - the oracle has been modified. A drifting oracle is worse "
"than no oracle. Investigate before proceeding.", file=sys.stderr)
return 1
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--verify", action="store_true", help="check the committed oracle")
g.add_argument("--regenerate", action="store_true",
help="rebuild it; requires OpenSCAD and BOSL2")
args = ap.parse_args()
return regenerate() if args.regenerate else verify()
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
# Reference implementation — OpenSCAD, revision 8.0.0
**This is not a live target.** It is kept so the acceptance oracle in
`fixtures/strap-beam-8.0.0/` can be regenerated, and so the port has something
to be checked against. The running application has no OpenSCAD dependency, and
none of this is installed on an instance host.
## What it produces
Eleven profiles across two generators. A profile is a cross-section swept along
a longitudinal axis; a "member" is the end view of one strap bundle, so its
length in the cross-section is the strap's **width**, never the beam's length.
| Generator | Profiles |
|---|---|
| `strap-beam-3x.scad` | Equilateral Triangle, General Triangle, A Frame, Y, T, Three-Fin |
| `strap-beam-4x.scad` | Square, Rectangle, Diamond, Cross, Four-Fin |
4x is the end of the N-strap family. Higher polygon counts compose from
triangles and squares rather than getting their own generators.
## The shared library
Everything reusable is in `lib/`, written for N members and used unchanged by
both generators:
| File | Contents |
|---|---|
| `sb-geom.scad` | GEO and MEMBER records, path construction, exact polyline distance, the monotone solver |
| `sb-join.scad` | butt joints, hull caps, concave-corner fillets, ring envelopes and bores, polygon fitting |
| `sb-report.scad` | value-based checks, section metrics, the `SB_KEY=value` report |
| `sb-profiles.scad` | three complete N-generic arrangements: ring, spokes, fins |
| `sb-core.scad` | umbrella include and the PROFILE record |
`strap-beam-4x.scad` is 210 lines and contains **no geometry** — five one-line
calls into the shared library. The same three calls with N=3 produce the
triangles, the Y and the Three-Fin. That is the evidence the split is real.
## Design rules the library enforces
1. Declared webs are cavity-to-cavity. A stated 1.2 mm web is 1.2 mm of plastic.
2. Placement that cannot be derived exactly is solved numerically against the
measured quantity, not approximated with a closed form.
3. Junctions are structural before they are decorative: members butt through
their neighbours, and fillets are applied on top of that overlap.
4. A member with no enclosed side takes 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 and
never sufficient — revision 7 passed cross-sections joined by 0.13 mm.
## Running it
Requires OpenSCAD 2021.01 and BOSL2 at `92d697c2`. Use the pinned image:
```bash
docker run --rm -v "$PWD:/repo" mechcomp/reference-toolchain:8.0.0 \
openscad -o /dev/null --export-format=asciistl \
-Dprofile_type='"Three-Fin"' /repo/legacy/openscad/strap-beam-3x.scad
```
Every render emits a flat `SB_KEY=value` block on stderr, terminated by
`SB_END=1`. `render_mode="Section"` emits the 2D region instead of the swept
solid, which is what `--export-format=svg` consumes.
## Three BOSL2 behaviours worth knowing
Found the hard way; the library works around all three:
- Boolean functions return a bare `[]` rather than an empty region.
- `offset()` on a single-part region returns a bare path rather than a region.
- `round_corners()` raises rather than reporting when a radius will not fit.
`sb_as_region()`, `sb_area()`/`sb_nparts()` and `sb_path_max_round()` exist for
exactly these.
+133
View File
@@ -0,0 +1,133 @@
/*
sb-core.scad — Strap-Beam library, umbrella include
===================================================
A "strap beam" is N pallet-strap bundles running parallel to a common
longitudinal axis (Z), 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.
include <lib/sb-core.scad>
brings in BOSL2 and the three library files. Generators for a particular
member count (strap-beam-3x.scad, strap-beam-4x.scad, ...) include this
and supply only their own profile catalogue.
---------------------------------------------------------------------
What lives where
---------------------------------------------------------------------
sb-geom.scad GEO and MEMBER records; strap, cavity and sleeve paths;
exact polyline distance; the monotone solver.
sb-join.scad Butt joints, hull caps, fillets; ring envelopes and
bores; polygon fitting. All written for N members.
sb-report.scad Value-based checks, section metrics, the SB_KEY=value
report, and the 2D/3D output modules.
sb-profiles.scad Three complete N-generic arrangements - ring, spokes,
fins - each returning a finished PROFILE record.
---------------------------------------------------------------------
Contract for a profile builder
---------------------------------------------------------------------
A generator supplies one function per profile that takes a GEO record and
returns a PROFILE record built with sb_profile():
members list of MEMBER records, one per strap bundle
shell region: all PLA+ before any void is removed
bore path: the enclosed central void, or [] if there is none
checks list from sb_check(), covering only this profile's own
parameters — never another profile's
info list of [key, value] pairs 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. Everything
a builder needs to construct `shell` is in sb-join.scad; a builder should
not be doing its own boolean algebra.
---------------------------------------------------------------------
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.
*/
include <BOSL2/std.scad>
include <sb-geom.scad>
include <sb-join.scad>
include <sb-report.scad>
// ---------------------------------------------------------------------------
// PROFILE record
// ---------------------------------------------------------------------------
SB_P_MEMBERS = 0;
SB_P_SHELL = 1;
SB_P_BORE = 2;
SB_P_CHECKS = 3;
SB_P_INFO = 4;
function sb_profile(members, shell, bore = [], checks = [], info = []) =
[members, shell, bore, checks, info];
function sb_p_members(p) = p[SB_P_MEMBERS];
function sb_p_shell(p) = p[SB_P_SHELL];
function sb_p_bore(p) = p[SB_P_BORE];
function sb_p_checks(p) = p[SB_P_CHECKS];
function sb_p_info(p) = p[SB_P_INFO];
// A builder that could not produce a usable arrangement returns this instead
// of guessing. The message reaches the user through the normal check list.
function sb_profile_failed(message) =
sb_profile([], [], [], [sb_check(false, message)], []);
function sb_profile_ok(p) = len(sb_p_members(p)) > 0;
// Shared arrangements. Included last because they build PROFILE records.
include <sb-profiles.scad>
// ---------------------------------------------------------------------------
// Centred results
// ---------------------------------------------------------------------------
/*
Everything is generated about whatever origin the profile found natural,
then shifted once so the finished envelope's bounding box is centred. The
shift is applied to the section, the straps and the cavities together, so
they stay registered with each other.
*/
function sb_centred(p, g) =
let(
shell = sb_p_shell(p),
shift = sb_centering_shift(shell)
)
[
move(shift, p = sb_clean_region(sb_section(shell, sb_p_members(p), g, sb_p_bore(p)))),
move(shift, p = sb_strap_region(sb_p_members(p), g)),
move(shift, p = sb_cavity_region(sb_p_members(p), g)),
move(shift, p = shell),
shift
];
SB_C_SECTION = 0;
SB_C_STRAPS = 1;
SB_C_CAVITY = 2;
SB_C_SHELL = 3;
SB_C_SHIFT = 4;
// Members translated by the same shift, for drawing individual laminae.
function sb_centred_members(p, shift) =
[for (m = sb_p_members(p))
sb_member(m[SB_M_CX] + shift.x, m[SB_M_CY] + shift.y,
sb_mang(m), sb_mface(m))];
+488
View File
@@ -0,0 +1,488 @@
/*
sb-geom.scad — Strap-Beam shared geometry primitives
====================================================
Part of the Strap-Beam library. Nothing in this file knows how many
straps a profile has, so it is reused unchanged by the 3x, 4x and any
later N-strap generator.
Two record types are defined here.
GEO record
Everything about a single strap bundle and the PLA+ that wraps it.
Built once per render by sb_geo() and threaded through every call.
MEMBER record
One strap bundle's cross-section placement: centre, angle, and which
broad face (if either) looks into an enclosed interior.
Coordinate convention for a member
local +X = along the strap's WIDTH (the 15.875 mm direction)
local +Y = along the strap's THICKNESS (the 0.508 mm direction)
The member's angle rotates local +X onto the global direction given.
"normal+" is local +Y expressed globally.
Requires BOSL2 (std.scad) to be included by the caller.
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
SB_SQRT3 = sqrt(3);
SB_EPS = 1e-7;
// Face modes — how a member's two broad faces are walled.
SB_FACE_PLUS_IN = 1; // local +Y faces an enclosed interior -> inside wall
SB_FACE_MINUS_IN = -1; // local -Y faces an enclosed interior -> inside wall
SB_FACE_BOTH_OUT = 0; // neither face encloses anything -> outside wall both
// GEO field indices.
SB_G_WIDTH = 0; // nominal strap width
SB_G_STRAP_T = 1; // one strap's thickness
SB_G_COUNT = 2; // straps per bundle
SB_G_CLEAR = 3; // fit clearance, applied to every cavity face
SB_G_WIN = 4; // inside wall
SB_G_WOUT = 5; // outside wall
SB_G_WEDGE = 6; // edge wall (caps the strap's narrow edges)
SB_G_MINWALL = 7; // minimum acceptable PLA thickness anywhere
// MEMBER field indices.
SB_M_CX = 0;
SB_M_CY = 1;
SB_M_ANG = 2;
SB_M_FACE = 3;
// ---------------------------------------------------------------------------
// GEO record
// ---------------------------------------------------------------------------
function sb_geo(
strap_width,
strap_thickness,
bundle_count,
clearance,
wall_inside,
wall_outside,
wall_edge,
min_wall
) = [
strap_width, strap_thickness, bundle_count, clearance,
wall_inside, wall_outside, wall_edge, min_wall
];
function sb_width(g) = g[SB_G_WIDTH];
function sb_strap_t(g) = g[SB_G_STRAP_T];
function sb_count(g) = g[SB_G_COUNT];
function sb_clear(g) = g[SB_G_CLEAR];
function sb_wall_in(g) = g[SB_G_WIN];
function sb_wall_out(g) = g[SB_G_WOUT];
function sb_wall_edge(g) = g[SB_G_WEDGE];
function sb_min_wall(g) = g[SB_G_MINWALL];
function sb_bundle_t(g) = sb_count(g) * sb_strap_t(g);
// Cavity = strap bundle grown by the fit clearance on all four faces.
function sb_cavity_w(g) = sb_width(g) + 2 * sb_clear(g);
function sb_cavity_t(g) = sb_bundle_t(g) + 2 * sb_clear(g);
/*
A declared "web" is the PLA+ that must survive between two neighbouring
strap CAVITIES. Because every cavity is inflated by the clearance, the
corresponding gap between the physical STRAPS is larger. Callers state
the web they want; this converts to the strap-to-strap spacing that
produces it, so a declared 1.2 mm web really is 1.2 mm of plastic.
*/
function sb_web_to_strap_gap(g, web) = web + 2 * sb_clear(g);
// Distance from a member centreline out to each of its four sleeve faces.
function sb_reach_plus(g, face) =
sb_cavity_t(g) / 2 + (face > 0 ? sb_wall_in(g) : sb_wall_out(g));
function sb_reach_minus(g, face) =
sb_cavity_t(g) / 2 + (face < 0 ? sb_wall_in(g) : sb_wall_out(g));
// Distance from centreline to the enclosed-interior side of the sleeve.
// Only meaningful when the member actually has an interior face.
function sb_reach_inside(g) = sb_cavity_t(g) / 2 + sb_wall_in(g);
// ---------------------------------------------------------------------------
// Small vector helpers
// ---------------------------------------------------------------------------
function sb_rot2(p, a) = [
p.x * cos(a) - p.y * sin(a),
p.x * sin(a) + p.y * cos(a)
];
function sb_mid(a, b) = [(a.x + b.x) / 2, (a.y + b.y) / 2];
function sb_dist(a, b) = norm([b.x - a.x, b.y - a.y]);
function sb_cross2(a, b) = a.x * b.y - a.y * b.x;
function sb_centroid(pts) = [
sum([for (p = pts) p.x]) / len(pts),
sum([for (p = pts) p.y]) / len(pts)
];
// Signed area; positive means counter-clockwise.
function sb_signed_area(path) =
sum([for (i = [0 : len(path) - 1])
let(a = path[i], b = path[(i + 1) % len(path)])
(a.x * b.y - b.x * a.y)]) / 2;
function sb_ccw(path) = sb_signed_area(path) >= 0 ? path : reverse(path);
// Intersection of line (p1,d1) with line (p2,d2). Returns undef if parallel.
function sb_line_isect(p1, d1, p2, d2) =
let(denom = sb_cross2(d1, d2))
abs(denom) < SB_EPS
? undef
: let(
delta = [p2.x - p1.x, p2.y - p1.y],
t = sb_cross2(delta, d2) / denom
)
[p1.x + t * d1.x, p1.y + t * d1.y];
// ---------------------------------------------------------------------------
// MEMBER record
// ---------------------------------------------------------------------------
function sb_member(cx, cy, angle, face = SB_FACE_BOTH_OUT) = [cx, cy, angle, face];
function sb_mc(m) = [m[SB_M_CX], m[SB_M_CY]];
function sb_mang(m) = m[SB_M_ANG];
function sb_mface(m) = m[SB_M_FACE];
function sb_maxis(m) = [cos(sb_mang(m)), sin(sb_mang(m))]; // along width
function sb_mnormal(m) = [-sin(sb_mang(m)), cos(sb_mang(m))]; // local +Y
// Unit vector pointing from the member towards the profile interior.
// Returns undef for SB_FACE_BOTH_OUT, which has no interior.
function sb_minside_dir(m) =
let(n = sb_mnormal(m), f = sb_mface(m))
f == 0 ? undef : [f * n.x, f * n.y];
// A point on the interior-facing surface of the member's sleeve.
function sb_minside_wall_pt(m, g) =
let(d = sb_minside_dir(m), c = sb_mc(m))
is_undef(d) ? undef
: [c.x + d.x * sb_reach_inside(g),
c.y + d.y * sb_reach_inside(g)];
/*
Decide the face mode from a target point that lies inside the profile.
Pass interior_target = undef for members with no enclosed side, which
keeps the member symmetric and stops the walls from becoming chiral.
*/
function sb_face_toward(centre, angle, interior_target) =
is_undef(interior_target) ? SB_FACE_BOTH_OUT
: let(
n = [-sin(angle), cos(angle)],
v = [interior_target.x - centre.x, interior_target.y - centre.y]
)
(n.x * v.x + n.y * v.y) >= 0 ? SB_FACE_PLUS_IN : SB_FACE_MINUS_IN;
// Member lying on the segment a->b, optionally slid along its own axis.
function sb_member_on_edge(a, b, interior_target, shift = 0) =
let(
mid = sb_mid(a, b),
angle = atan2(b.y - a.y, b.x - a.x),
c = [mid.x + shift * cos(angle), mid.y + shift * sin(angle)],
face = sb_face_toward(c, angle, interior_target)
)
sb_member(c.x, c.y, angle, face);
// Member placed radially: centre sits at distance r from origin along angle a,
// with its width axis pointing outward. Used by spoke profiles.
function sb_member_radial(r, angle, face = SB_FACE_BOTH_OUT) =
sb_member(r * cos(angle), r * sin(angle), angle, face);
// Translate a member along its outward normal (away from the interior).
function sb_member_offset_out(m, d) =
let(
n = sb_mnormal(m),
s = sb_mface(m) == 0 ? 1 : -sb_mface(m)
)
sb_member(m[SB_M_CX] + s * d * n.x,
m[SB_M_CY] + s * d * n.y,
sb_mang(m), sb_mface(m));
// ---------------------------------------------------------------------------
// Cross-section paths for one member
// ---------------------------------------------------------------------------
// Place a locally-defined path into the member's frame.
function sb_place(m, path) =
move(sb_mc(m), p = zrot(sb_mang(m), p = path));
/*
Rectangle in member-local coordinates.
half_w_lead : extent along +X (towards the member's leading end)
half_w_trail : extent along -X
up / down : extents along +Y / -Y
*/
function sb_local_rect(half_w_lead, half_w_trail, up, down) = [
[ half_w_lead, -down],
[ half_w_lead, up ],
[-half_w_trail, up ],
[-half_w_trail, -down]
];
// The physical strap bundle, as one rectangle.
function sb_strap_path(m, g) =
sb_place(m, sb_local_rect(sb_width(g) / 2, sb_width(g) / 2,
sb_bundle_t(g) / 2, sb_bundle_t(g) / 2));
// Individual strap laminae, for display when bundle_count > 1.
function sb_strap_layer_paths(m, g) = [
for (i = [0 : sb_count(g) - 1])
let(
y = (i - (sb_count(g) - 1) / 2) * sb_strap_t(g),
t = sb_strap_t(g) / 2
)
sb_place(m, move([0, y], p = sb_local_rect(sb_width(g) / 2,
sb_width(g) / 2, t, t)))
];
// The void the strap slides through.
function sb_cavity_path(m, g) =
sb_place(m, sb_local_rect(sb_cavity_w(g) / 2, sb_cavity_w(g) / 2,
sb_cavity_t(g) / 2, sb_cavity_t(g) / 2));
/*
The PLA+ sleeve around one member.
ext_lead / ext_trail extend the sleeve along its own axis beyond the
default edge wall. Junction construction uses this to make neighbouring
sleeves genuinely overlap instead of merely touching at a corner.
*/
function sb_sleeve_path(m, g, ext_lead = 0, ext_trail = 0) =
let(
half = sb_cavity_w(g) / 2 + sb_wall_edge(g),
f = sb_mface(m)
)
sb_place(m, sb_local_rect(half + ext_lead, half + ext_trail,
sb_reach_plus(g, f), sb_reach_minus(g, f)));
/*
Sleeve whose trailing end is cut by an arbitrary line rather than by a
face perpendicular to the axis. This produces a butt joint flush against
a neighbouring member's outer face, which is how junctions are made
structural rather than decorative.
line_pt / line_dir describe the cutting line. The trailing end face is
placed on that line; the leading end stays perpendicular as usual.
Falls back to a plain sleeve if the line is parallel to the axis.
*/
function sb_sleeve_to_line(m, g, line_pt, line_dir, ext_lead = 0) =
let(
c = sb_mc(m),
u = sb_maxis(m),
n = sb_mnormal(m),
f = sb_mface(m),
up = sb_reach_plus(g, f),
dn = sb_reach_minus(g, f),
half = sb_cavity_w(g) / 2 + sb_wall_edge(g),
// The two long edges of the sleeve, as lines.
p_up = [c.x + n.x * up, c.y + n.y * up],
p_dn = [c.x - n.x * dn, c.y - n.y * dn],
t_up = sb_line_isect(p_up, u, line_pt, line_dir),
t_dn = sb_line_isect(p_dn, u, line_pt, line_dir),
lead_up = [p_up.x + u.x * (half + ext_lead),
p_up.y + u.y * (half + ext_lead)],
lead_dn = [p_dn.x + u.x * (half + ext_lead),
p_dn.y + u.y * (half + ext_lead)]
)
(is_undef(t_up) || is_undef(t_dn))
? sb_sleeve_path(m, g, ext_lead, 0)
: sb_ccw([lead_dn, lead_up, t_up, t_dn]);
/*
Sleeve cut by a line at BOTH ends. A member that spans between two
neighbours - a gable crossbar, a chord across a polygon - butts flush
against each of them instead of stopping short or poking through.
*/
function sb_sleeve_span(m, g, pt_a, dir_a, pt_b, dir_b) =
let(
c = sb_mc(m),
u = sb_maxis(m),
n = sb_mnormal(m),
f = sb_mface(m),
p_up = [c.x + n.x * sb_reach_plus(g, f), c.y + n.y * sb_reach_plus(g, f)],
p_dn = [c.x - n.x * sb_reach_minus(g, f), c.y - n.y * sb_reach_minus(g, f)],
a_up = sb_line_isect(p_up, u, pt_a, dir_a),
a_dn = sb_line_isect(p_dn, u, pt_a, dir_a),
b_up = sb_line_isect(p_up, u, pt_b, dir_b),
b_dn = sb_line_isect(p_dn, u, pt_b, dir_b)
)
(is_undef(a_up) || is_undef(a_dn) || is_undef(b_up) || is_undef(b_dn))
? sb_sleeve_path(m, g)
: sb_ccw([a_dn, a_up, b_up, b_dn]);
// ---------------------------------------------------------------------------
// Measurement — exact distance between two closed polylines
// ---------------------------------------------------------------------------
/*
The minimum distance between two disjoint polygons is always attained at
a vertex of one of them, so sampling every vertex against every segment of
the other (both ways round) is exact, not an approximation.
*/
function sb_pt_seg_dist(p, a, b) =
let(
ab = [b.x - a.x, b.y - a.y],
L2 = ab.x * ab.x + ab.y * ab.y
)
L2 < SB_EPS
? sb_dist(p, a)
: let(
t = max(0, min(1, ((p.x - a.x) * ab.x + (p.y - a.y) * ab.y) / L2))
)
sb_dist(p, [a.x + t * ab.x, a.y + t * ab.y]);
function sb_pt_path_dist(p, path) =
min([for (i = [0 : len(path) - 1])
sb_pt_seg_dist(p, path[i], path[(i + 1) % len(path)])]);
// Do two segments properly cross or touch?
function sb_segs_cross(a1, a2, b1, b2) =
let(
d1 = [a2.x - a1.x, a2.y - a1.y],
d2 = [b2.x - b1.x, b2.y - b1.y],
den = sb_cross2(d1, d2),
w = [b1.x - a1.x, b1.y - a1.y]
)
abs(den) < SB_EPS
? false
: let(t = sb_cross2(w, d2) / den, u = sb_cross2(w, d1) / den)
t >= 0 && t <= 1 && u >= 0 && u <= 1;
function sb_paths_cross(p, q) =
len([for (i = [0 : len(p) - 1], j = [0 : len(q) - 1])
if (sb_segs_cross(p[i], p[(i + 1) % len(p)],
q[j], q[(j + 1) % len(q)])) 1]) > 0;
/*
Minimum distance between two closed paths.
Two disjoint polygons always attain their minimum at a vertex of one of
them, so vertex-against-segment both ways round is exact. Two polygons
that CROSS may have no vertex near the other's boundary at all, and the
naive vertex test then reports a comfortable clearance across an outright
overlap - which is exactly the kind of false pass that lets a solver
settle on a degenerate arrangement. Crossing is therefore tested first
and reported as zero.
Nesting is deliberately not treated as overlap: a hole inside an outer
boundary is the normal case, and the distance between them is the wall
thickness that this whole library exists to measure.
*/
function sb_path_gap(p, q) =
sb_paths_cross(p, q)
? 0
: min(min([for (v = p) sb_pt_path_dist(v, q)]),
min([for (v = q) sb_pt_path_dist(v, p)]));
// Minimum distance between any two paths in a region. For a finished
// cross-section this is the thinnest surviving piece of PLA+.
function sb_region_min_gap(rgn) =
len(rgn) < 2
? 1e9
: min([for (i = [0 : len(rgn) - 2], j = [i + 1 : len(rgn) - 1])
sb_path_gap(rgn[i], rgn[j])]);
/*
Largest corner radius a path can physically accept: at every vertex the
roundover's tangent points must stay on their own edges. Probing this by
trial is not an option because the rounding routine raises a library-level
error rather than returning a flag, so it is derived up front.
*/
function sb_corner_radii(path, r) =
let(n = len(path), cw = sb_signed_area(path) < 0)
[for (i = [0 : n - 1])
let(
prev = path[(i + n - 1) % n],
here = path[i],
next = path[(i + 1) % n],
turn = sb_cross2([here.x - prev.x, here.y - prev.y],
[next.x - here.x, next.y - here.y]),
reflex = cw ? (turn > SB_EPS) : (turn < -SB_EPS),
ang = vector_angle(prev, here, next),
fits = (ang <= 0.05 || ang >= 179.95)
? 0
: 0.98 * min(sb_dist(prev, here), sb_dist(here, next))
/ 2 * tan(ang / 2)
)
reflex ? min(r, fits) : 0];
function sb_path_max_round(path) =
let(n = len(path))
n < 3 ? 0 :
0.999 * min([for (i = [0 : n - 1])
let(
prev = path[(i + n - 1) % n],
here = path[i],
next = path[(i + 1) % n],
l1 = sb_dist(prev, here),
l2 = sb_dist(here, next),
ang = vector_angle(prev, here, next)
)
(ang <= 0.05 || ang >= 179.95) ? 1e9 : min(l1, l2) / 2 * tan(ang / 2)
]);
// ---------------------------------------------------------------------------
// Empty-safe wrappers
// ---------------------------------------------------------------------------
// BOSL2's boolean functions return a bare [] when a result is empty, which is
// not a valid region. Every measurement goes through these so a legitimately
// empty result reads as zero instead of raising a library error.
/*
Remove duplicate and collinear vertices from every path in a region.
Exact butt joints and zero-radius fillets produce coincident or perfectly
collinear vertices. They are harmless in 2D but leave zero-area triangles
that the tessellator cannot resolve, so a section that measures perfectly
can still fail to extrude. Cleaning once, at the end, removes that entire
class of failure.
*/
function sb_clean_region(rgn) = [
for (path = rgn)
let(d = deduplicate(path, closed = true))
if (len(d) >= 3)
let(m = path_merge_collinear(d, closed = true))
if (len(m) >= 3) m
];
function sb_area(rgn) = len(rgn) == 0 ? 0 : region_area(rgn);
function sb_nparts(rgn) = len(rgn) == 0 ? 0 : len(region_parts(rgn));
function sb_as_region(x) = is_path(x) ? [x] : x;
// ---------------------------------------------------------------------------
// Monotone solver
// ---------------------------------------------------------------------------
/*
Several profiles need "place this member so that the resulting web is
exactly W". Rather than deriving a closed form per profile — the source
of most of the wrong-by-a-cosine errors in earlier revisions — solve the
real measured quantity numerically. f must be non-decreasing on [lo,hi].
*/
function sb_solve(f, lo, hi, target, iters = 44) =
iters <= 0
? (lo + hi) / 2
: let(mid = (lo + hi) / 2)
f(mid) < target ? sb_solve(f, mid, hi, target, iters - 1)
: sb_solve(f, lo, mid, target, iters - 1);
// True when f(hi) actually reaches the target, i.e. the solve is feasible.
function sb_solvable(f, hi, target) = f(hi) >= target;
+311
View File
@@ -0,0 +1,311 @@
/*
sb-join.scad — Strap-Beam shared junction and envelope strategies
=================================================================
Part of the Strap-Beam library. Everything here is written for N members
and is shared verbatim by the 3x, 4x and later generators.
Three families of strategy live here.
ENVELOPE how the outer PLA+ solid is generated:
* sleeve style — union of per-member sleeves (open profiles)
* ring style — one closed envelope offset from a centreline
polygon, with solid rounded corners
BORE the enclosed central void, derived from the members' actual
inside-wall lines rather than from a separately scaled shape
FIT placement solved against a measured web, so a declared wall
thickness is the wall thickness you get
Requires sb-geom.scad and BOSL2.
*/
// ---------------------------------------------------------------------------
// Face lines
// ---------------------------------------------------------------------------
/*
A member's sleeve has two long surfaces. Junction construction needs to
talk about them as infinite lines. side = +1 selects the local +Y
surface, side = -1 the local -Y surface. Returns [point, direction].
*/
function sb_face_line(m, g, side) =
let(
c = sb_mc(m),
n = sb_mnormal(m),
d = side > 0 ? sb_reach_plus(g, sb_mface(m))
: sb_reach_minus(g, sb_mface(m))
)
[[c.x + side * d * n.x, c.y + side * d * n.y], sb_maxis(m)];
// The surface of m that lies farther from the given point. Used to butt one
// member flush against the far side of another without needing to know which
// way either of them is facing.
function sb_far_face_line(m, g, from_pt) =
let(
a = sb_face_line(m, g, 1),
b = sb_face_line(m, g, -1)
)
sb_dist(a[0], from_pt) >= sb_dist(b[0], from_pt) ? a : b;
// ---------------------------------------------------------------------------
// Structural junctions
// ---------------------------------------------------------------------------
/*
sb_sleeve_butt
The core junction primitive. Rather than letting two sleeves clip each
other at a corner and relying on a cosmetic fillet to hold the result
together, the trailing end of `m` is run all the way through `into` and
cut off flush with that member's far surface. The two sleeves then share
a full-width overlap, so the joint carries load whether or not a fillet is
applied afterwards.
ext_lead extends the opposite (free) end, which is left untouched.
*/
function sb_sleeve_butt(m, g, into, ext_lead = 0) =
let(
line = sb_far_face_line(into, g, sb_mc(m))
)
sb_sleeve_to_line(m, g, line[0], line[1], ext_lead);
/*
sb_hull_cap
Plugs the space enclosed by a set of member end faces with their convex
hull. Deterministic, cheap, and free of the spikes and V-notches that a
bare union of crossing rectangles leaves behind. Used for spoke-style
centres and for gable apexes.
Pass the end-face segments (two points each); the hull of all of them is
the plug.
*/
function sb_hull_cap(segments) =
let(pts = [for (s = segments) each s])
len(pts) < 3 ? [] : hull_region([pts]);
// The end-face segment of a member, at its leading (+1) or trailing (-1) end,
// taken at the sleeve surface. extra pushes the face further along the axis.
function sb_end_face(m, g, end = 1, extra = 0) =
let(
c = sb_mc(m),
u = sb_maxis(m),
n = sb_mnormal(m),
f = sb_mface(m),
half = sb_cavity_w(g) / 2 + sb_wall_edge(g) + extra,
p = [c.x + end * half * u.x, c.y + end * half * u.y],
up = sb_reach_plus(g, f),
dn = sb_reach_minus(g, f)
)
[[p.x + n.x * up, p.y + n.y * up],
[p.x - n.x * dn, p.y - n.y * dn]];
/*
sb_fillet_concave
Rounds only the reflex corners of a path, leaving every convex corner
bit-exact. Each radius is clamped to what its own corner can accept, so
the operation cannot fail on a tight junction.
This replaces the morphological closing (grow by r, shrink by r) used in
earlier revisions. Closing had three problems: an inward offset on a
many-vertex path is the least reliable operation in the pipeline and
raises a library-level error rather than reporting one; the arc
discretisation it introduces is not mirror-symmetric, so it quietly made
symmetric profiles chiral; and it filled every concavity within reach
rather than the junction actually being treated. Rounding named corners
has none of those failure modes and is considerably faster.
*/
function sb_fillet_concave(path, r) =
len(path) < 3 ? path
: let(radii = sb_corner_radii(path, r))
max(radii) <= 1e-6 ? path
: round_corners(path, radius = radii, closed = true);
// Fillet the junction between two sleeves. Cosmetic only: it is applied on
// top of a structural butt joint, never in place of one. If the two solids
// do not merge into a single simple outline there is nothing sane to round,
// so the pair is returned untouched rather than guessed at.
function sb_fillet_pair(path_a, path_b, r) =
r <= 0 ? [path_a, path_b]
: let(u = sb_as_region(union([[path_a], [path_b]])))
len(u) == 1 ? [sb_fillet_concave(u[0], r)] : u;
// ---------------------------------------------------------------------------
// Bore — the enclosed central void
// ---------------------------------------------------------------------------
/*
sb_bore_from_members
Members must be supplied in cyclic order around the interior, each with a
real interior face. The bore is the polygon bounded by their actual
inside-wall surfaces, so the declared inside wall is exactly what remains
between each cavity and the void. Nothing here is specific to three
members; a four-sided profile produces a quadrilateral bore from the same
call.
Returns [] when any pair of consecutive inside lines is parallel, which
means the profile has no closed interior.
*/
function sb_bore_from_members(ms, g) =
len([for (m = ms) if (sb_mface(m) == 0) 1]) > 0 ? []
: let(
n = len(ms),
pts = [for (m = ms) sb_minside_wall_pt(m, g)],
dirs = [for (m = ms) sb_maxis(m)],
verts = [for (i = [0 : n - 1])
sb_line_isect(pts[(i + n - 1) % n], dirs[(i + n - 1) % n],
pts[i], dirs[i])]
)
(len([for (v = verts) if (is_undef(v)) 1]) > 0) ? [] : verts;
/*
Is the derived bore real? A collapsed interior does not vanish, it turns
itself inside out, so area alone proves nothing. The test that matters is
that the bore's own centre still lies on the interior side of every
member's inside wall.
*/
function sb_bore_valid(bore, ms, g) =
len(bore) < 3 ? false
: abs(sb_signed_area(bore)) <= 0.01 ? false
: let(c = sb_centroid(bore))
len([for (m = ms)
let(d = sb_minside_dir(m), p = sb_minside_wall_pt(m, g))
if (is_undef(d) ||
(d.x * (c.x - p.x) + d.y * (c.y - p.y)) <= 0.01) 1]) == 0;
// ---------------------------------------------------------------------------
// Ring profiles — members laid along the edges of a closed polygon
// ---------------------------------------------------------------------------
// One member per edge, each centred on its edge, interior face towards the
// polygon centroid. Centring keeps the profile mirror-symmetric; the corner
// webs are then set by the polygon's size, solved for below.
function sb_ring_members(path, g) =
let(c = sb_centroid(path), n = len(path))
[for (i = [0 : n - 1]) sb_member_on_edge(path[i], path[(i + 1) % n], c)];
// Smallest PLA+ web between any two neighbouring strap cavities on the ring.
function sb_ring_web(path, g) =
let(
ms = sb_ring_members(path, g),
n = len(ms),
cv = [for (m = ms) sb_cavity_path(m, g)]
)
min([for (i = [0 : n - 1]) sb_path_gap(cv[i], cv[(i + 1) % n])]);
function sb_scale_about_centroid(path, k) =
let(c = sb_centroid(path))
[for (p = path) [c.x + k * (p.x - c.x), c.y + k * (p.y - c.y)]];
/*
sb_fit_ring
Straps have a fixed width, so on a polygon of a given size the corner webs
are whatever they are — they cannot be dialled in by sliding members along
their edges, because every edge shares its budget with two corners. The
only free variable that raises all N webs at once is the polygon's size.
This grows the caller's polygon about its centroid, preserving its shape
and proportions exactly, until the tightest corner web reaches `web`.
The same call fits a triangle, a quadrilateral, or any N-gon.
*/
function sb_ring_fit_scale(path, g, web, max_scale = 12) =
let(
n = len(path),
edges = [for (i = [0 : n - 1]) sb_dist(path[i], path[(i + 1) % n])],
// Normalise first, so the caller's outline really is shape-only: a
// unit square and a 200 mm square must fit to the same result. At
// relative scale 1 the shortest edge is exactly one strap wide.
k0 = sb_width(g) / min(edges),
f = function(k) sb_ring_web(sb_scale_about_centroid(path, k0 * k), g)
)
!sb_solvable(f, max_scale, web)
? undef
// The search starts at 1, never below. Once an edge is shorter than
// a strap, that member overhangs both of its own corners and the
// corner-setback model no longer describes the geometry - yet the
// measured web can come back positive there, which is exactly the
// kind of spurious lower branch a bisection will happily settle on.
: k0 * sb_solve(f, 1, max_scale, web);
function sb_fit_ring(path, g, web, max_scale = 12) =
let(k = sb_ring_fit_scale(path, g, web, max_scale))
is_undef(k) ? undef : sb_scale_about_centroid(path, k);
/*
Outer envelope of a ring profile: the centreline polygon pushed out to the
outside-wall surface, with its corners rounded. Corner rounding removes
material from precisely the region where a strap cavity approaches the
corner, so the caller must check the result against the minimum wall
rather than assume a radius is safe.
*/
function sb_ring_shell(path, g, corner_r = 0) =
let(
sharp = offset(sb_ccw(path),
delta = sb_cavity_t(g) / 2 + sb_wall_out(g),
closed = true)
)
corner_r > 0 ? round_corners(sharp, radius = corner_r, closed = true)
: sharp;
// Largest corner radius that still leaves min_wall between the envelope and
// every strap cavity, and that the envelope can geometrically accept.
// Reported so a catalogue entry can be tuned once and then trusted.
function sb_ring_max_corner_r(path, g) =
let(
sharp = sb_ring_shell(path, g, 0),
hi = sb_path_max_round(sharp),
ms = sb_ring_members(path, g),
cav = [for (m = ms) sb_cavity_path(m, g)],
f = function(r)
let(sh = sb_ring_shell(path, g, max(0, hi - r)))
len(sh) < 3 ? 0 : min([for (c = cav) sb_path_gap(sh, c)])
)
hi <= 0 ? 0 : max(0, hi - sb_solve(f, 0, hi, sb_min_wall(g) - 1e-6));
// ---------------------------------------------------------------------------
// Sleeve profiles — union of per-member sleeves
// ---------------------------------------------------------------------------
function sb_sleeve_shell(paths) = sb_as_region(union([for (p = paths) [p]]));
// Apply one cosmetic fillet per declared junction, each computed from only
// the two members involved, then merge with the untouched shell. Keeping the
// closings pairwise stops distant parts of the profile from bridging to each
// other through the middle of the section.
function sb_fillet_junctions(shell, pairs, paths, r) =
r <= 0 ? shell
: sb_as_region(union(concat([shell],
[for (p = pairs) sb_fillet_pair(paths[p[0]], paths[p[1]], r)])));
// ---------------------------------------------------------------------------
// Assembly
// ---------------------------------------------------------------------------
/*
sb_section
The one place where solid and void meet. All sleeve solids are unioned
first and every cavity is removed afterwards, so no member's PLA+ can ever
intrude into another member's strap channel.
*/
function sb_section(shell, members, g, bore = []) =
let(
cavities = sb_as_region(union([for (m = members) [sb_cavity_path(m, g)]])),
cut = sb_as_region(len(bore) >= 3 ? union([[bore], cavities]) : cavities)
)
sb_as_region(difference(shell, cut));
function sb_strap_region(members, g) =
sb_as_region(union([for (m = members) [sb_strap_path(m, g)]]));
function sb_cavity_region(members, g) =
sb_as_region(union([for (m = members) [sb_cavity_path(m, g)]]));
// Translation that puts the finished envelope's bounding box on the origin.
function sb_centering_shift(shell) =
let(b = pointlist_bounds(hull_region(shell)))
[-(b[0].x + b[1].x) / 2, -(b[0].y + b[1].y) / 2];
+221
View File
@@ -0,0 +1,221 @@
/*
sb-profiles.scad — Strap-Beam shared profile constructions
==========================================================
Part of the Strap-Beam library. Three complete arrangements, each written
for N members and each returning a finished PROFILE record.
sb_ring_polygon_profile members on the edges of a closed polygon,
wrapped in one envelope with solid rounded
corners and an enclosed bore
sb_spoke_profile N members radiating from a plugged centre
sb_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 below changes
between the 3x and 4x files. strap-beam-3x.scad calls all three
(Triangles, Y, Three-Fin) and strap-beam-4x.scad calls the same three
(Quadrilaterals, Cross, Four-Fin) with N = 4.
Requires sb-geom.scad, sb-join.scad and sb-report.scad.
*/
// ---------------------------------------------------------------------------
// Ring: members on the edges of a closed polygon
// ---------------------------------------------------------------------------
/*
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.
*/
function sb_ring_polygon_profile(seed_path, g, web, corner_r, label) =
abs(sb_signed_area(seed_path)) < 1e-6
? sb_profile_failed(str(label, ": the supplied outline is degenerate."))
: let(
k = sb_ring_fit_scale(seed_path, g, web),
path = is_undef(k) ? undef : sb_scale_about_centroid(seed_path, k)
)
is_undef(path)
? sb_profile_failed(str(label, ": no polygon size gives a ", web,
" mm corner web. Reduce the web, the wall thicknesses, or the strap width."))
: let(
ms = sb_ring_members(path, g),
max_r = sb_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 = [sb_ring_shell(path, g, min(corner_r, max_r))],
bore = sb_bore_from_members(ms, g),
edges = [for (i = [0 : len(path) - 1])
sb_dist(path[i], path[(i + 1) % len(path)])]
)
sb_profile(ms, shell, bore,
[
sb_check(len(shell[0]) >= 3,
str(label, ": the outer envelope collapsed.")),
sb_check(sb_bore_valid(bore, ms, g),
str(label, ": the central bore has collapsed. Reduce inside_wall_thickness_mm or the bundle thickness.")),
sb_check(corner_r <= max_r + 1e-6,
str(label, ": corner radius of ", corner_r,
" mm is not usable here - it would cut the outer wall below ",
sb_min_wall(g), " mm at the corners, or exceed what the envelope can accept. Maximum is ", max_r, " mm."))
],
[
sb_kv("RING_SCALE", k),
sb_kv("RING_EDGES_MM", edges),
sb_kv("RING_CORNER_WEB_MM", sb_ring_web(path, g)),
sb_kv("RING_CORNER_R_MAX_MM", max_r)
]);
// ---------------------------------------------------------------------------
// Spokes: N members radiating from a common centre
// ---------------------------------------------------------------------------
/*
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.
*/
function sb_spoke_members(n, radius, rotation) = [
for (i = [0 : n - 1])
sb_member_radial(radius, rotation + 360 * i / n, SB_FACE_BOTH_OUT)
];
function sb_spoke_web(n, radius, rotation, g) =
let(cv = [for (m = sb_spoke_members(n, radius, rotation))
sb_cavity_path(m, g)])
min([for (i = [0 : n - 1]) sb_path_gap(cv[i], cv[(i + 1) % n])]);
function sb_spoke_profile(n, rotation, web, fillet_r, g, label) =
let(
W = sb_width(g),
f = function(r) sb_spoke_web(n, r, rotation, g),
hi = 6 * W
)
!sb_solvable(f, hi, web)
? sb_profile_failed(str(label, ": cannot open a ", web,
" mm web between neighbouring spokes. Reduce the web or the wall thicknesses."))
: let(
radius = sb_solve(f, W / 2, hi, web),
ms = sb_spoke_members(n, radius, rotation),
paths = [for (m = ms) sb_sleeve_path(m, g)],
cap = sb_hull_cap([for (m = ms) sb_end_face(m, g, -1)]),
raw = union(concat([for (p = paths) [p]],
len(cap) > 0 ? [cap] : [])),
pairs = [for (i = [0 : n - 1]) [i, (i + 1) % n]],
shell = sb_fillet_junctions(raw, pairs, paths, fillet_r)
)
sb_profile(ms, shell, [],
[
sb_check(fillet_r > 0,
str(label, ": 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."))
],
[
sb_kv("SPOKE_RADIUS_MM", radius),
sb_kv("SPOKE_WEB_MM", sb_spoke_web(n, radius, rotation, g)),
sb_kv("NOTE", "inside wall unused: no enclosed bore")
]);
// ---------------------------------------------------------------------------
// Fins: N members lying tangentially on a regular core polygon
// ---------------------------------------------------------------------------
/*
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.
spokes N members leaving a common centre, ends pointing outward
fins N members wrapping a core, each with one cyclic overhang
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.
*/
function sb_regular_polygon(n, side, rotation = 0) =
let(R = side / (2 * sin(180 / n)))
[for (k = [0 : n - 1])
let(a = -90 + 180 / n + 360 * k / n + rotation)
[R * cos(a), R * sin(a)]];
function sb_fin_members(n, side, fin, rotation, g) =
let(
path = sb_regular_polygon(n, side, rotation),
c = sb_centroid(path),
shift = fin + (side - sb_width(g)) / 2
)
[for (i = [0 : n - 1])
sb_member_on_edge(path[i], path[(i + 1) % n], c, shift)];
function sb_fin_web(n, side, fin, rotation, g) =
let(cv = [for (m = sb_fin_members(n, side, fin, rotation, g))
sb_cavity_path(m, g)])
min([for (i = [0 : n - 1]) sb_path_gap(cv[i], cv[(i + n - 1) % n])]);
// Side length of the bore left by N inside walls around a regular core.
function sb_fin_bore_side(n, side, g) =
let(t = tan(180 / n))
2 * t * (side / (2 * t) - sb_reach_inside(g));
function sb_fin_profile(n, fin, web, bore_side, rotation, fillet_r, g, label) =
let(
W = sb_width(g),
fweb = function(s) sb_fin_web(n, s, fin, rotation, g),
fbor = function(s) sb_fin_bore_side(n, s, g),
hi = 10 * W
)
!sb_solvable(fweb, hi, web)
? sb_profile_failed(str(label, ": cannot open a ", web,
" mm junction web. Reduce the web or the wall thicknesses."))
: !sb_solvable(fbor, hi, bore_side)
? sb_profile_failed(str(label, ": cannot reach a ", bore_side,
" mm bore. Reduce the requested bore."))
: let(
side = max(sb_solve(fweb, 0.1, hi, web),
sb_solve(fbor, 0.1, hi, bore_side)),
ms = sb_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 = [for (i = [0 : n - 1])
sb_sleeve_butt(ms[i], g, ms[(i + n - 1) % n])],
pairs = [for (i = [0 : n - 1]) [(i + n - 1) % n, i]],
shell = sb_fillet_junctions(sb_sleeve_shell(paths), pairs, paths, fillet_r),
bore = sb_bore_from_members(ms, g),
setback = fin + side - W
)
sb_profile(ms, shell, bore,
[
sb_check(sb_bore_valid(bore, ms, g),
str(label, ": the central bore has collapsed. Raise the requested bore or reduce inside_wall_thickness_mm.")),
sb_check(setback > 0,
str(label, ": the solved trailing setback is ", setback,
" mm, so the members overlap instead of stepping cyclically. Increase the fin projection.")),
sb_check(fillet_r > 0,
str(label, ": 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."))
],
[
sb_kv("FIN_CORE_SIDE_MM", side),
sb_kv("FIN_PROJECTION_MM", fin),
sb_kv("FIN_SETBACK_MM", setback),
sb_kv("FIN_BORE_SIDE_MM", sb_fin_bore_side(n, side, g)),
sb_kv("FIN_JUNCTION_WEB_MM", sb_fin_web(n, side, fin, rotation, g))
]);
+175
View File
@@ -0,0 +1,175 @@
/*
sb-report.scad — Strap-Beam shared validation and reporting
===========================================================
Part of the Strap-Beam library.
VALIDATION
Checks are values, not statements. A profile builder returns a list
of [condition, message] pairs and the core asserts over that list.
Because the list is built inside the selected profile's own function,
no other profile's parameters are ever touched — a slider that belongs
to one catalogue entry cannot break a different one.
REPORTING
Every render emits a block of SB_KEY=value lines on stderr. These are
stable, flat, and trivially scraped by the catalogue front end, which
needs dimensions and a pass/fail without parsing geometry.
Requires sb-geom.scad, sb-join.scad and BOSL2.
*/
// ---------------------------------------------------------------------------
// Checks
// ---------------------------------------------------------------------------
function sb_check(condition, message) = [condition ? true : false, message];
function sb_first_failure(checks, i = 0) =
i >= len(checks) ? undef
: checks[i][0] ? sb_first_failure(checks, i + 1)
: checks[i][1];
/*
Assert the whole list and return a status string. Assign the result to a
variable at file scope so the assertion runs before any geometry does.
*/
function sb_require(checks) =
let(fail = sb_first_failure(checks))
assert(is_undef(fail), str("\n[strap-beam] ", fail, "\n"))
"ok";
// ---------------------------------------------------------------------------
// Metrics
// ---------------------------------------------------------------------------
SB_R_AREA = 0;
SB_R_PARTS = 1;
SB_R_SLOTS = 2;
SB_R_MINW = 3;
SB_R_SIZE = 4;
SB_R_LEAK = 5;
function sb_metrics(section, shell, members, g) =
let(
cav = sb_cavity_region(members, g),
hull = hull_region(shell),
b = pointlist_bounds(hull)
)
[
sb_area(section), // PLA+ per unit length
sb_nparts(section), // connected solids
sb_nparts(cav), // separate strap channels
sb_region_min_gap(section), // thinnest surviving wall
[b[1].x - b[0].x, b[1].y - b[0].y], // envelope size
sb_area(difference(cav, shell)) // cavity outside envelope
];
/*
Checks every profile must pass, whatever its shape or member count.
The connectivity test alone is not enough: a cross-section joined by a
0.14 mm knife edge is topologically connected and physically useless. The
minimum-wall test is what actually catches over-large corner radii,
swallowed junction gaps and fillets that have stopped bridging.
*/
function sb_universal_checks(section, metrics, expected_members, g) = [
sb_check(metrics[SB_R_PARTS] == 1,
str("Cross-section is not one connected solid (", metrics[SB_R_PARTS],
" separate pieces). Widen the junctions or thicken the walls.")),
sb_check(metrics[SB_R_SLOTS] == expected_members,
str("Expected ", expected_members, " separate strap channels but found ",
metrics[SB_R_SLOTS],
". Neighbouring channels have merged, so those straps share one slot and are not retained. Increase the relevant web.")),
sb_check(metrics[SB_R_MINW] >= sb_min_wall(g) - 1e-4,
str("Thinnest PLA+ wall is ", metrics[SB_R_MINW],
" mm, below the required minimum of ", sb_min_wall(g),
" mm. Reduce the corner radius, increase the web, or lower min_wall_mm if this really is acceptable.")),
sb_check(is_region_simple(section),
str("The cross-section touches itself at a point rather than ",
"crossing cleanly. Such an outline is valid but cannot be ",
"tessellated, so it would fail on extrusion. Nudge the junction ",
"fillet radius away from zero, or change the web slightly.")),
sb_check(metrics[SB_R_LEAK] < 1e-4,
str("A strap cavity breaks out of the outer envelope (",
metrics[SB_R_LEAK], " mm^2 outside). The straps would not be enclosed."))
];
// ---------------------------------------------------------------------------
// Report
// ---------------------------------------------------------------------------
function sb_kv(key, value) = [key, value];
module sb_emit(key, value) { echo(str("SB_", key, "=", value)); }
module sb_emit_all(pairs) { for (p = pairs) sb_emit(p[0], p[1]); }
/*
Standard report. `extra` carries whatever the individual profile wants to
publish — solved sizes, effective projections, headroom on a radius — as
a list of [key, value] pairs.
*/
module sb_report(
family, profile, status, g, metrics, length_mm, density_g_cm3, extra = []
) {
section_area = metrics[SB_R_AREA];
volume_mm3 = section_area * length_mm;
sb_emit("STATUS", status);
sb_emit("FAMILY", family);
sb_emit("PROFILE", profile);
sb_emit("STRAP_WIDTH_MM", sb_width(g));
sb_emit("STRAP_THICK_MM", sb_strap_t(g));
sb_emit("BUNDLE_COUNT", sb_count(g));
sb_emit("BUNDLE_THICK_MM", sb_bundle_t(g));
sb_emit("CLEARANCE_MM", sb_clear(g));
sb_emit("WALL_INSIDE_MM", sb_wall_in(g));
sb_emit("WALL_OUTSIDE_MM", sb_wall_out(g));
sb_emit("WALL_EDGE_MM", sb_wall_edge(g));
sb_emit("MIN_WALL_SPEC_MM", sb_min_wall(g));
sb_emit("MIN_WALL_ACTUAL_MM", metrics[SB_R_MINW]);
sb_emit("SECTION_AREA_MM2", section_area);
sb_emit("SECTION_PARTS", metrics[SB_R_PARTS]);
sb_emit("STRAP_CHANNELS", metrics[SB_R_SLOTS]);
sb_emit("ENVELOPE_X_MM", metrics[SB_R_SIZE].x);
sb_emit("ENVELOPE_Y_MM", metrics[SB_R_SIZE].y);
sb_emit("LENGTH_MM", length_mm);
sb_emit("VOLUME_MM3", volume_mm3);
sb_emit("MASS_G", volume_mm3 * density_g_cm3 / 1000);
sb_emit_all(extra);
sb_emit("END", 1);
}
// ---------------------------------------------------------------------------
// 3D output
// ---------------------------------------------------------------------------
/*
A profile is a cross-section swept along Z. The section is what every
consumer actually cares about — the catalogue renderer projects it, the
slicer extrudes it — so it is generated once and reused for both.
*/
module sb_extrude_section(rgn, length) {
linear_sweep(rgn, height = length, center = true);
}
module sb_extrude_straps(members, g, length) {
for (m = members)
for (p = sb_strap_layer_paths(m, g))
linear_sweep([p], height = length, center = true);
}
// Flat 2D output, for the catalogue's SVG pipeline.
module sb_draw_section(rgn) { region(rgn); }
+471
View File
@@ -0,0 +1,471 @@
/*
strap-beam-3x.scad — Three-strap longitudinal beam enclosures
=============================================================
Revision 8. Replaces triangle-strap-enclosure_v7.scad.
Three pallet-strap bundles run parallel to one longitudinal axis (Z).
A profile decides only how the three cross-sections are arranged in XY;
that arrangement is swept along Z. A "member" here is the end view of
one 10-100 ft strap bundle, so its length in the cross-section is the
strap's WIDTH.
Profiles
1. Equilateral Triangle ring, three equal sides
2. General Triangle ring, caller-shaped triangle
3. A Frame two legs meeting at a gable apex, plus a
crossbar butted between them
4. Y three radial spokes about a plugged centre
5. T two-strap flange with a perpendicular stem
6. Three-Fin tangential triangle whose members are slid
cyclically so each projects one fin
Everything reusable lives in lib/sb-core.scad and is shared unchanged
with strap-beam-4x.scad. This file contains only the six arrangements
and their parameters.
---------------------------------------------------------------------
Changes from v7 that alter dimensions
---------------------------------------------------------------------
* Every gap parameter is now a WEB: the PLA+ that survives between two
strap cavities. v7 stated gaps strap-to-strap and then inflated the
cavities by the fit clearance afterwards, which quietly removed
2 x clearance from every junction. A declared 1.2 mm web is now
1.2 mm of plastic.
* Ring profiles size themselves. 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 three at once because each
edge shares its budget with two corners. The polygon is therefore
grown about its centroid until the tightest corner reaches the
declared web. Caller-supplied triangle dimensions set the SHAPE; the
size follows from the strap width.
* Ring members are centred on their edges rather than shifted
cyclically, so the triangle profiles are now mirror-symmetric.
* Junctions are structural. Members butt through their neighbours and
are cut flush on the far face; fillets are applied on top of that
overlap rather than in place of it. In v7 the Three-Fin was held
together by a 0.4 mm corner touch that the fillet happened to bridge.
* Members with no enclosed side (Y spokes, T stem and flange) take the
outside wall on both faces. v7 hard-coded an interior direction for
these, which made the Y a pinwheel whenever the two wall thicknesses
differed.
* Validation measures the finished section. Connectivity is necessary
but not sufficient - v7 passed cross-sections joined by 0.13 mm.
* Checks are scoped to the selected profile. In v7 every assert was
unconditional, so an A Frame slider could kill the Y profile.
*/
include <lib/sb-core.scad>
// ---------------------------------------------------------------------------
// Parameters
// ---------------------------------------------------------------------------
/* [Profile] */
profile_type = "Equilateral Triangle"; // ["Equilateral Triangle","General Triangle","A Frame","Y","T","Three-Fin"]
/* [Pallet strap] */
strap_width_mm = 15.875; // ULINE 5/8 in nominal width
strap_thickness_mm = 0.508; // ULINE .020 in nominal thickness
bundle_count = 1; // [1:1:3]
/* [Longitudinal axis] */
member_length_ft = 10; // [10:1:100]
length_view = "Preview"; // ["Preview","Full Length"]
preview_length_mm = 100;
/* [PLA+ enclosure] */
// Clearance is added to every cavity face. Declared webs are unaffected by
// it; the library compensates internally.
fit_clearance_mm = 0.25;
inside_wall_thickness_mm = 1.20; // faces that look into an enclosed bore
outside_wall_thickness_mm = 1.20; // faces exposed to the outside world
edge_wall_thickness_mm = 1.20; // caps over a strap's narrow edges
// Nothing anywhere in the section is allowed to be thinner than this.
min_wall_mm = 1.20;
/* [Ring profiles] */
// Applies to both triangle profiles. The polygon grows until its tightest
// corner reaches this web.
ring_corner_web_mm = 1.20;
// Rounding of the complete outer envelope, never of individual sleeves.
ring_corner_radius_mm = 2.00;
/* [General Triangle shape] */
// These set proportions only; absolute size is solved from the strap width.
general_triangle_base = 17.50;
general_triangle_height = 15.50;
general_triangle_apex_offset = 1.50;
/* [A Frame] */
a_frame_leg_angle_deg = 45; // leg angle measured from horizontal
a_frame_apex_web_mm = 1.20; // PLA+ between the two leg cavities
a_frame_crossbar_web_mm = 1.20; // PLA+ between crossbar and leg cavities
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; // PLA+ between neighbouring spoke cavities
y_junction_round_mm = 1.50;
/* [T Profile] */
t_stem_web_mm = 1.20; // PLA+ between stem and flange cavities
t_flange_web_mm = 1.20; // PLA+ between the two flange cavities
t_junction_round_mm = 1.50;
/* [Three-Fin Profile] */
// Fin projection past the corner it overhangs. The core triangle is solved
// from this plus the web and bore minimums below.
three_fin_fin_mm = 6.25;
three_fin_web_mm = 1.20; // PLA+ at each cyclic junction
three_fin_bore_side_mm = 7.50; // minimum central opening
three_fin_rotation_deg = 0;
three_fin_junction_round_mm = 2.00;
/* [Display] */
render_mode = "3D"; // ["3D","Section"]
show_straps = true;
show_enclosure = true;
/* [Quality] */
facets = 48; // [12:4:128]
/* [Reporting] */
material_density_g_cm3 = 1.24; // PLA+ nominal
$fn = facets;
// ---------------------------------------------------------------------------
// Derived
// ---------------------------------------------------------------------------
SB_FAMILY = "3x";
// Generator revision. Qualification attestations bind to this, so any change
// that alters emitted geometry MUST bump it. Cosmetic or comment-only edits
// must not. See sb-report.scad for what is published.
SB_REVISION = "8.0.0";
SB_MEMBER_COUNT = 3;
geo = sb_geo(
strap_width_mm, strap_thickness_mm, bundle_count, fit_clearance_mm,
inside_wall_thickness_mm, outside_wall_thickness_mm,
edge_wall_thickness_mm, min_wall_mm
);
model_length_mm = length_view == "Full Length"
? member_length_ft * 304.8
: preview_length_mm;
// ---------------------------------------------------------------------------
// 1-2. Ring profiles
// ---------------------------------------------------------------------------
/*
Both triangle profiles 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.
The 4x generator gets these for free by handing sb_ring_profile() a
quadrilateral instead.
*/
function sb3_equilateral(g) =
let(s = sb_width(g), a = s / (2 * SB_SQRT3))
sb_ring_polygon_profile([[-s / 2, -a], [s / 2, -a], [0, 2 * a]],
g, ring_corner_web_mm, ring_corner_radius_mm,
"Equilateral Triangle");
function sb3_general_triangle(g) =
sb_ring_polygon_profile(
[[-general_triangle_base / 2, -general_triangle_height / 2],
[ general_triangle_base / 2, -general_triangle_height / 2],
[ general_triangle_apex_offset, general_triangle_height / 2]],
g, ring_corner_web_mm, 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. v7 instead offset both legs from the origin by
a fixed radius, which delivered apex_gap x cos(leg_angle) and collapsed to
nothing as the legs approached vertical.
The crossbar is likewise solved by depth against the measured web and then
butted flush between the two legs' outer faces. v7 measured to the leg
CENTRELINE, so its declared 1.40 mm arrived as 0.16 mm.
*/
function sb3_a_frame_legs(g, setback) =
let(
a = a_frame_leg_angle_deg,
target = [0, -sb_width(g)], // deep inside the counter
d = sb_cavity_w(g) / 2 + setback,
angL = 180 + a,
angR = -a,
cL = [d * cos(angL), d * sin(angL)],
cR = [d * cos(angR), d * sin(angR)]
)
[
sb_member(cL.x, cL.y, angL, sb_face_toward(cL, angL, target)),
sb_member(cR.x, cR.y, angR, sb_face_toward(cR, angR, target))
];
function sb3_a_frame_apex_web(g, setback) =
let(l = sb3_a_frame_legs(g, setback))
sb_path_gap(sb_cavity_path(l[0], g), sb_cavity_path(l[1], g));
function sb3_a_frame_crossbar(g, depth) =
let(c = [a_frame_crossbar_offset_mm, -depth])
sb_member(c.x, c.y, 0, sb_face_toward(c, 0, [0, 0]));
function sb3_a_frame_bar_web(g, legs, depth) =
let(cb = sb_cavity_path(sb3_a_frame_crossbar(g, depth), g))
min(sb_path_gap(cb, sb_cavity_path(legs[0], g)),
sb_path_gap(cb, sb_cavity_path(legs[1], g)));
function sb3_a_frame(g) =
let(
W = sb_width(g),
f_apex = function(s) sb3_a_frame_apex_web(g, s),
feasible = sb_solvable(f_apex, 6 * W, a_frame_apex_web_mm)
)
!feasible
? sb_profile_failed(str(
"A Frame: at ", a_frame_leg_angle_deg,
" degrees the legs are too close to parallel to open a ",
a_frame_apex_web_mm,
" mm apex web. Reduce a_frame_leg_angle_deg."))
: let(
setback = sb_solve(f_apex, 0, 6 * W, a_frame_apex_web_mm),
legs = sb3_a_frame_legs(g, setback),
f_bar = function(d) sb3_a_frame_bar_web(g, legs, d),
bar_ok = sb_solvable(f_bar, 8 * W, a_frame_crossbar_web_mm)
)
!bar_ok
? sb_profile_failed(
"A Frame: the crossbar cannot reach a legal web against the legs. Reduce a_frame_crossbar_web_mm or the crossbar offset.")
: let(
depth = sb_solve(f_bar, 0, 8 * W, a_frame_crossbar_web_mm),
bar = sb3_a_frame_crossbar(g, 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 - sb_wall_edge(g)),
legL = sb_sleeve_path(legs[0], g, 0, ext),
legR = sb_sleeve_path(legs[1], g, 0, ext),
cap = sb_hull_cap([sb_end_face(legs[0], g, -1, ext),
sb_end_face(legs[1], g, -1, ext)]),
// Crossbar spans flush between the legs' outer faces.
lineL = sb_far_face_line(legs[0], g, sb_mc(bar)),
lineR = sb_far_face_line(legs[1], g, sb_mc(bar)),
barP = sb_sleeve_span(bar, g, lineL[0], lineL[1],
lineR[0], lineR[1]),
paths = [legL, barP, legR],
raw = union(concat([[legL], [legR], [barP]],
len(cap) > 0 ? [cap] : [])),
shell = sb_fillet_junctions(raw, [[0, 1], [1, 2], [0, 2]],
paths, a_frame_junction_round_mm),
bore = sb_bore_from_members(ms, g),
leg_end_depth = (setback + sb_cavity_w(g)) * sin(a_frame_leg_angle_deg)
)
sb_profile(
ms, shell, bore,
[
sb_check(depth < leg_end_depth,
str("A Frame: the crossbar sits at ", depth,
" mm below the apex but the legs only reach ",
leg_end_depth,
" mm. Reduce a_frame_crossbar_web_mm or the leg angle.")),
sb_check(sb_bore_valid(bore, ms, g),
"A Frame: the enclosed counter has collapsed. Reduce inside_wall_thickness_mm or open the frame out."),
sb_check(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."),
sb_check(abs(a_frame_crossbar_offset_mm) < sb_width(g) / 2,
"A Frame: a_frame_crossbar_offset_mm has pushed the crossbar off the frame.")
],
[
sb_kv("AF_APEX_SETBACK_MM", setback),
sb_kv("AF_CROSSBAR_DEPTH_MM", depth),
sb_kv("AF_APEX_WEB_MM", sb3_a_frame_apex_web(g, setback)),
sb_kv("AF_CROSSBAR_WEB_MM", sb3_a_frame_bar_web(g, legs, depth))
]
);
// ---------------------------------------------------------------------------
// 4. Y
// ---------------------------------------------------------------------------
/*
Three spokes pointing away from a common centre. A spoke's broad faces
both look at open air, so both get the outside wall - the profile stays
three-fold symmetric and non-chiral under any wall settings. The centre
is plugged with the convex hull of the three inner end faces rather than
left to whatever the crossing rectangles happened to produce.
*/
function sb3_y(g) =
sb_spoke_profile(3, y_rotation_deg, y_junction_web_mm,
y_junction_round_mm, g, "Y");
// ---------------------------------------------------------------------------
// 5. T
// ---------------------------------------------------------------------------
/*
Two straps form the flange, separated by their own web so each keeps a
private channel - in v7 they touched edge to edge and shared one slot.
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.
*/
function sb3_t_members(g) =
let(
half = t_flange_web_mm / 2 + sb_cavity_w(g) / 2,
top = -sb_cavity_t(g) / 2 - t_stem_web_mm,
stemy = top - sb_cavity_w(g) / 2
)
[
sb_member(-half, 0, 0, SB_FACE_BOTH_OUT),
sb_member( half, 0, 0, SB_FACE_BOTH_OUT),
sb_member(0, stemy, -90, SB_FACE_BOTH_OUT) // leading end points down
];
function sb3_t(g) =
let(
ms = sb3_t_members(g),
flL = sb_sleeve_path(ms[0], g),
flR = sb_sleeve_path(ms[1], g),
stem = sb_sleeve_butt(ms[2], g, ms[0]),
paths = [flL, flR, stem],
raw = union([[flL], [flR], [stem]]),
shell = sb_fillet_junctions(raw, [[0, 2], [1, 2]], paths,
t_junction_round_mm),
flange_overlap = 2 * sb_wall_edge(g) - t_flange_web_mm
)
sb_profile(ms, shell, [], [
sb_check(flange_overlap > 0,
str("T: t_flange_web_mm of ", t_flange_web_mm,
" mm exceeds twice the edge wall, so the two flange sleeves no longer meet. Reduce it or raise edge_wall_thickness_mm.")),
sb_check(t_stem_web_mm >= sb_min_wall(g) - 1e-9,
str("T: t_stem_web_mm of ", t_stem_web_mm, " mm is below min_wall_mm.")),
sb_check(t_flange_web_mm >= sb_min_wall(g) - 1e-9,
str("T: t_flange_web_mm of ", t_flange_web_mm, " mm is below min_wall_mm.")),
sb_check(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.")
], [
sb_kv("T_FLANGE_SPAN_MM", 2 * sb_cavity_w(g) + t_flange_web_mm
+ 2 * sb_wall_edge(g)),
sb_kv("T_NOTE", "inside_wall unused: no enclosed bore")
]);
// ---------------------------------------------------------------------------
// 6. Three-Fin
// ---------------------------------------------------------------------------
/*
Tangential, not radial. Three straight members lie on the sides of a core
triangle and are slid cyclically along those sides, so each stops short of
the corner behind it and overhangs the corner ahead of it. The three
overhangs are the fins.
Y three radial arms leaving a common centre
Three-Fin three tangential sides around a core, one fin each
The core triangle is not a free parameter. It is solved so that the
cyclic junctions carry the declared web AND the bore reaches its declared
minimum, whichever demands more. The fin projection stays exactly as
asked, because it is measured against the same solved triangle.
Each member's trailing end is run through the member behind it and cut off
flush on its far face. That gives every junction a full-width overlap;
the fillet that follows is cosmetic. In v7 the junctions were a 0.4 mm
corner touch and the fillet was the only thing holding the part together.
*/
function sb3_three_fin(g) =
sb_fin_profile(3, three_fin_fin_mm, three_fin_web_mm,
three_fin_bore_side_mm, three_fin_rotation_deg,
three_fin_junction_round_mm, g, "Three-Fin");
// ---------------------------------------------------------------------------
// Catalogue
// ---------------------------------------------------------------------------
/*
Only the selected builder runs, so a parameter belonging to one entry can
never invalidate another. A 4x generator supplies its own table here and
nothing else changes.
*/
function sb3_build(name, g) =
name == "Equilateral Triangle" ? sb3_equilateral(g)
: name == "General Triangle" ? sb3_general_triangle(g)
: name == "A Frame" ? sb3_a_frame(g)
: name == "Y" ? sb3_y(g)
: name == "T" ? sb3_t(g)
: name == "Three-Fin" ? sb3_three_fin(g)
: sb_profile_failed(str("Unknown profile_type: ", name));
profile = sb3_build(profile_type, geo);
// Parameters that apply to every profile, so they are checked once here.
base_checks = [
sb_check(strap_width_mm > 0, "strap_width_mm must be greater than zero."),
sb_check(strap_thickness_mm > 0, "strap_thickness_mm must be greater than zero."),
sb_check(bundle_count >= 1 && bundle_count == floor(bundle_count),
"bundle_count must be a whole number of at least 1."),
sb_check(fit_clearance_mm >= 0, "fit_clearance_mm cannot be negative."),
sb_check(inside_wall_thickness_mm > 0, "inside_wall_thickness_mm must be positive."),
sb_check(outside_wall_thickness_mm > 0, "outside_wall_thickness_mm must be positive."),
sb_check(edge_wall_thickness_mm > 0, "edge_wall_thickness_mm must be positive."),
sb_check(min_wall_mm > 0, "min_wall_mm must be positive."),
sb_check(preview_length_mm > 0, "preview_length_mm must be greater than zero.")
];
// Fail on the profile's own checks before touching geometry, so an
// unbuildable arrangement reports its cause instead of a library error.
build_status = sb_require(concat(base_checks, sb_p_checks(profile)));
centred = sb_centred(profile, geo);
section = centred[SB_C_SECTION];
members = sb_centred_members(profile, centred[SB_C_SHIFT]);
metrics = sb_metrics(section, centred[SB_C_SHELL], members, geo);
status = sb_require(sb_universal_checks(section, metrics, SB_MEMBER_COUNT, geo));
// ---------------------------------------------------------------------------
// Report
// ---------------------------------------------------------------------------
sb_report(SB_FAMILY, profile_type, status, geo, metrics,
model_length_mm, material_density_g_cm3,
concat([sb_kv("REVISION", SB_REVISION),
sb_kv("LENGTH_FT", member_length_ft),
sb_kv("LENGTH_VIEW", length_view)],
sb_p_info(profile)));
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
if (render_mode == "Section") {
if (show_enclosure)
color([0.20, 0.55, 0.95]) sb_draw_section(section);
if (show_straps)
color([0.96, 0.72, 0.05]) sb_draw_section(centred[SB_C_STRAPS]);
} else {
if (show_enclosure)
color([0.20, 0.55, 0.95, 0.55]) sb_extrude_section(section, model_length_mm);
if (show_straps)
color([0.96, 0.72, 0.05, 0.82]) sb_extrude_straps(members, geo, model_length_mm);
}
+215
View File
@@ -0,0 +1,215 @@
/*
strap-beam-4x.scad — Four-strap longitudinal beam enclosures
============================================================
Companion to strap-beam-3x.scad. Four pallet-strap bundles run parallel
to one longitudinal axis (Z); a profile decides only how the four
cross-sections are arranged in XY.
Profiles
1. Square ring, four equal sides
2. Rectangle ring, caller-shaped by aspect ratio
3. Diamond ring, square rotated 45 degrees
4. Cross four spokes about a plugged centre
5. Four-Fin tangential square, one cyclic fin per side
---------------------------------------------------------------------
How much of this file is new
---------------------------------------------------------------------
None of the geometry. All five profiles are one call each into
lib/sb-profiles.scad with N = 4:
Square / Rectangle / Diamond -> sb_ring_polygon_profile()
Cross -> sb_spoke_profile(4, ...)
Four-Fin -> sb_fin_profile(4, ...)
The same three calls with N = 3 produce the Triangles, the Y and the
Three-Fin in strap-beam-3x.scad. The polygon fitting, the bore
derivation, the butt joints, the fillets, the validation and the report
are all member-count agnostic, so this file contains only parameters, a
catalogue table and the outline shapes.
A profile that genuinely does not generalise - the 3x A Frame and T, which
are specific arrangements rather than instances of a family - stays in its
own generator. That is the intended split: shapes that are an N-instance
live in the library, one-off arrangements live with their family.
*/
include <lib/sb-core.scad>
// ---------------------------------------------------------------------------
// Parameters
// ---------------------------------------------------------------------------
/* [Profile] */
profile_type = "Square"; // ["Square","Rectangle","Diamond","Cross","Four-Fin"]
/* [Pallet strap] */
strap_width_mm = 15.875;
strap_thickness_mm = 0.508;
bundle_count = 1; // [1:1:3]
/* [Longitudinal axis] */
member_length_ft = 10; // [10:1:100]
length_view = "Preview"; // ["Preview","Full Length"]
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;
// 1.25 mm is usable by all three ring outlines. The report publishes
// RING_CORNER_R_MAX_MM per profile if you want to push a specific entry.
ring_corner_radius_mm = 1.25;
// Proportions only; absolute size is solved from the strap width.
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;
/* [Display] */
render_mode = "3D"; // ["3D","Section"]
show_straps = true;
show_enclosure = true;
/* [Quality] */
facets = 48; // [12:4:128]
/* [Reporting] */
material_density_g_cm3 = 1.24;
$fn = facets;
// ---------------------------------------------------------------------------
// Derived
// ---------------------------------------------------------------------------
SB_FAMILY = "4x";
// Generator revision. Qualification attestations bind to this, so any change
// that alters emitted geometry MUST bump it. Cosmetic or comment-only edits
// must not. See sb-report.scad for what is published.
SB_REVISION = "8.0.0";
SB_MEMBER_COUNT = 4;
geo = sb_geo(
strap_width_mm, strap_thickness_mm, bundle_count, fit_clearance_mm,
inside_wall_thickness_mm, outside_wall_thickness_mm,
edge_wall_thickness_mm, min_wall_mm
);
model_length_mm = length_view == "Full Length"
? member_length_ft * 304.8
: preview_length_mm;
// ---------------------------------------------------------------------------
// 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.
function sb4_rect_outline(w, h) =
[[-w / 2, -h / 2], [w / 2, -h / 2], [w / 2, h / 2], [-w / 2, h / 2]];
function sb4_square(g) =
sb_ring_polygon_profile(sb4_rect_outline(1, 1), g,
ring_corner_web_mm, ring_corner_radius_mm, "Square");
function sb4_rectangle(g) =
sb_ring_polygon_profile(sb4_rect_outline(rectangle_aspect, 1), g,
ring_corner_web_mm, ring_corner_radius_mm, "Rectangle");
function sb4_diamond(g) =
sb_ring_polygon_profile([for (p = sb4_rect_outline(1, 1)) sb_rot2(p, 45)], g,
ring_corner_web_mm, ring_corner_radius_mm, "Diamond");
function sb4_cross(g) =
sb_spoke_profile(4, cross_rotation_deg, cross_junction_web_mm,
cross_junction_round_mm, g, "Cross");
function sb4_four_fin(g) =
sb_fin_profile(4, four_fin_fin_mm, four_fin_web_mm, four_fin_bore_side_mm,
four_fin_rotation_deg, four_fin_junction_round_mm, g,
"Four-Fin");
// ---------------------------------------------------------------------------
// Catalogue
// ---------------------------------------------------------------------------
function sb4_build(name, g) =
name == "Square" ? sb4_square(g)
: name == "Rectangle" ? sb4_rectangle(g)
: name == "Diamond" ? sb4_diamond(g)
: name == "Cross" ? sb4_cross(g)
: name == "Four-Fin" ? sb4_four_fin(g)
: sb_profile_failed(str("Unknown profile_type: ", name));
profile = sb4_build(profile_type, geo);
base_checks = [
sb_check(strap_width_mm > 0, "strap_width_mm must be greater than zero."),
sb_check(strap_thickness_mm > 0, "strap_thickness_mm must be greater than zero."),
sb_check(bundle_count >= 1 && bundle_count == floor(bundle_count),
"bundle_count must be a whole number of at least 1."),
sb_check(fit_clearance_mm >= 0, "fit_clearance_mm cannot be negative."),
sb_check(inside_wall_thickness_mm > 0, "inside_wall_thickness_mm must be positive."),
sb_check(outside_wall_thickness_mm > 0, "outside_wall_thickness_mm must be positive."),
sb_check(edge_wall_thickness_mm > 0, "edge_wall_thickness_mm must be positive."),
sb_check(min_wall_mm > 0, "min_wall_mm must be positive."),
sb_check(rectangle_aspect > 0, "rectangle_aspect must be greater than zero."),
sb_check(preview_length_mm > 0, "preview_length_mm must be greater than zero.")
];
build_status = sb_require(concat(base_checks, sb_p_checks(profile)));
centred = sb_centred(profile, geo);
section = centred[SB_C_SECTION];
members = sb_centred_members(profile, centred[SB_C_SHIFT]);
metrics = sb_metrics(section, centred[SB_C_SHELL], members, geo);
status = sb_require(sb_universal_checks(section, metrics, SB_MEMBER_COUNT, geo));
// ---------------------------------------------------------------------------
// Report
// ---------------------------------------------------------------------------
sb_report(SB_FAMILY, profile_type, status, geo, metrics,
model_length_mm, material_density_g_cm3,
concat([sb_kv("REVISION", SB_REVISION),
sb_kv("LENGTH_FT", member_length_ft),
sb_kv("LENGTH_VIEW", length_view)],
sb_p_info(profile)));
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
if (render_mode == "Section") {
if (show_enclosure)
color([0.20, 0.55, 0.95]) sb_draw_section(section);
if (show_straps)
color([0.96, 0.72, 0.05]) sb_draw_section(centred[SB_C_STRAPS]);
} else {
if (show_enclosure)
color([0.20, 0.55, 0.95, 0.55]) sb_extrude_section(section, model_length_mm);
if (show_straps)
color([0.96, 0.72, 0.05, 0.82]) sb_extrude_straps(members, geo, model_length_mm);
}
+23
View File
@@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "mechcomp"
version = "0.1.0.dev0"
description = "Mechanical Compiler — structural member generation from reclaimed and commodity stock"
requires-python = ">=3.11"
license = { text = "AGPL-3.0-or-later" }
[project.urls]
Source = "https://gitea.barternetwork.us/TheRON/mechanical-compiler"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"oracle: acceptance against the frozen rev-8 fixture set",
"cad: requires requirements-cad.txt",
]
+15
View File
@@ -0,0 +1,15 @@
# Base dependencies. The catalogue's 2D path runs on these alone.
#
# Constraints, not pins. The lock file is generated ON THE TARGET HOST against
# its own Python 3.11, because resolving here would pin wheels for a different
# interpreter. See docs/ENVIRONMENT.md section 8.3.
shapely>=2.0,<3
fastapi>=0.110
uvicorn[standard]>=0.29
pydantic>=2.6
sqlalchemy>=2.0
jinja2>=3.1
pytest>=8.0
pytest-xdist>=3.5
+8
View File
@@ -0,0 +1,8 @@
# 3D export only: STEP and mesh. Installed by default, but the test suite must
# pass with this file absent — that is the mechanism keeping the 2D and 3D paths
# separable. See docs/ENVIRONMENT.md section 1.1.
#
# build123d is a viable alternative on the same OCCT kernel. If it is chosen,
# MECHCOMP_CAD_BACKEND changes with it.
cadquery>=2.4
+1
View File
@@ -0,0 +1 @@
"""Mechanical Compiler."""
+1
View File
@@ -0,0 +1 @@
"""Mechanical Compiler - cad."""
+1
View File
@@ -0,0 +1 @@
"""Mechanical Compiler - geom."""
+1
View File
@@ -0,0 +1 @@
"""Mechanical Compiler - profiles."""
+1
View File
@@ -0,0 +1 @@
"""Mechanical Compiler - web."""
+1
View File
@@ -0,0 +1 @@
"""Mechanical Compiler - worker."""
View File
+153
View File
@@ -0,0 +1,153 @@
"""
Shared fixtures.
The main job here is translating an oracle case into a call the port can make.
The oracle records OpenSCAD command-line overrides, because that is what
produced it; the port takes a plain dictionary. That translation lives in one
place so the acceptance tests stay readable and the mapping is auditable.
The parameter names are deliberately unchanged. `bundle_count` in the OpenSCAD
generator is `bundle_count` in the port. A renaming layer would be one more
thing to get wrong for no benefit.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
ORACLE = (
Path(__file__).resolve().parents[1]
/ "fixtures" / "strap-beam-8.0.0" / "strap-beam-fixtures-8.0.0.json"
)
PORT_MISSING = (
"the Shapely port does not exist yet: mechcomp.profiles.build is not "
"importable. This is expected until the first work item in ROADMAP.md is "
"done. The integrity tests still run."
)
# ---------------------------------------------------------------------------
# Oracle loading
# ---------------------------------------------------------------------------
def _parse_define(token: str) -> tuple[str, object]:
"""
Turn one OpenSCAD -D override into a (name, value) pair.
-Dprofile_type="Y" -> ("profile_type", "Y")
-Dbundle_count=2 -> ("bundle_count", 2)
-Dstrap_width_mm=13.4-> ("strap_width_mm", 13.4)
"""
assert token.startswith("-D"), f"not an override: {token!r}"
name, _, raw = token[2:].partition("=")
raw = raw.strip()
if raw.startswith('"') and raw.endswith('"'):
return name, raw[1:-1]
try:
value = float(raw)
except ValueError:
return name, raw
return name, int(value) if value.is_integer() and "." not in raw else value
def _case_params(case: dict) -> dict:
"""Every override except profile_type, which is passed separately."""
params = dict(_parse_define(t) for t in case["defs"])
params.pop("profile_type", None)
return params
def _enrich(case: dict) -> dict:
return {
**case,
"family": "3x" if "3x" in case["generator"] else "4x",
"params": _case_params(case),
}
@pytest.fixture(scope="session")
def oracle_raw() -> dict:
if not ORACLE.exists():
pytest.fail(f"oracle missing: {ORACLE}")
return json.loads(ORACLE.read_text())
@pytest.fixture(scope="session")
def tolerance(oracle_raw) -> dict:
return oracle_raw["tolerance"]
@pytest.fixture(scope="session")
def all_cases(oracle_raw) -> list[dict]:
return [_enrich(c) for c in oracle_raw["cases"]]
@pytest.fixture(scope="session")
def accepted_cases(all_cases) -> list[dict]:
return [c for c in all_cases if c["outcome"] == "ok"]
@pytest.fixture(scope="session")
def rejected_cases(all_cases) -> list[dict]:
return [c for c in all_cases if c["outcome"] == "rejected"]
# ---------------------------------------------------------------------------
# The port under test
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def port():
"""
The module implementing the generators.
Required surface:
build(family: str, profile: str, params: dict) -> Result
Result.report -> dict, the SB_* keys the oracle records
raises ProfileRejected for an unbuildable arrangement
ProfileRejected(Exception)
str() must name the parameter and the limit, as the reference does.
"""
profiles = pytest.importorskip("mechcomp.profiles", reason=PORT_MISSING)
if not hasattr(profiles, "build"):
pytest.skip(PORT_MISSING)
return profiles
# ---------------------------------------------------------------------------
# Per-case parametrisation
# ---------------------------------------------------------------------------
def _load_cases_eagerly() -> list[dict]:
"""
Read the oracle at collection time.
Parametrisation happens before fixtures resolve, so this cannot use them.
A missing oracle yields an empty list, and the integrity tests report the
real problem rather than every acceptance test failing obscurely.
"""
if not ORACLE.exists():
return []
return [_enrich(c) for c in json.loads(ORACLE.read_text())["cases"]]
_CASES = _load_cases_eagerly()
def _ident(case: dict) -> str:
return f"{case['family']}-{case['profile'].replace(' ', '_')}-{case['label']}"
def pytest_generate_tests(metafunc):
if "accepted_case" in metafunc.fixturenames:
cases = [c for c in _CASES if c["outcome"] == "ok"]
metafunc.parametrize("accepted_case", cases, ids=[_ident(c) for c in cases])
if "rejected_case" in metafunc.fixturenames:
cases = [c for c in _CASES if c["outcome"] == "rejected"]
metafunc.parametrize("rejected_case", cases, ids=[_ident(c) for c in cases])
+157
View File
@@ -0,0 +1,157 @@
"""
Acceptance of any reimplementation against the frozen rev-8.0.0 oracle.
This file is the specification of the port's public API. It was written before
the port existed, deliberately: the shape of the interface should be decided by
what has to be verified, not by what happens to be convenient to implement.
Until `mechcomp.profiles.build` exists, everything except the integrity checks
skips with a clear reason. Those integrity checks always run -- an oracle that
has been edited is worse than no oracle, and that should fail loudly on any
machine, at any time, with no dependencies.
pytest -n auto # all of it
pytest -m oracle # acceptance only
pytest -k integrity # oracle checks alone, always runnable
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.oracle
# ---------------------------------------------------------------------------
# Integrity - no dependency on the port
# ---------------------------------------------------------------------------
def test_integrity_hash(oracle_raw):
"""The committed oracle has not been modified."""
import hashlib
import json
doc = dict(oracle_raw)
recorded = doc.pop("fixtures_sha256")
actual = hashlib.sha256(json.dumps(doc, indent=2, sort_keys=True).encode()).hexdigest()
assert actual == recorded, (
"The oracle has been edited. Its hash covers the document without the "
"hash field, serialised with indent=2 and sort_keys=True. If this was "
"intentional, regenerate it inside the reference toolchain image and "
"record why in FAILURES.md."
)
def test_integrity_shape(oracle_raw):
"""The case matrix is the one the documents describe."""
assert oracle_raw["generator_revision"] == "8.0.0"
assert oracle_raw["toolchain"]["openscad"] == "2021.01"
assert oracle_raw["toolchain"]["bosl2_commit"].startswith("92d697c2")
summary = oracle_raw["summary"]
assert summary["cases"] == 123
assert summary["accepted"] == 113
assert summary["rejected"] == 10
assert len(oracle_raw["cases"]) == summary["cases"]
def test_integrity_invariants_hold_in_the_oracle(accepted_cases):
"""
Every accepted case in the oracle satisfies the invariants.
This validates the oracle itself rather than the port. If it ever fails,
the fixture set is describing geometry that should never have been
accepted, and no port should be measured against it.
"""
for case in accepted_cases:
r = case["report"]
where = f"{case['profile']}/{case['label']}"
expected_members = 3 if "3x" in case["generator"] else 4
assert r["SECTION_PARTS"] == 1, f"{where}: not one connected solid"
assert r["STRAP_CHANNELS"] == expected_members, f"{where}: channels merged"
assert r["MIN_WALL_ACTUAL_MM"] >= r["MIN_WALL_SPEC_MM"] - 1e-4, \
f"{where}: wall below its declared minimum"
# ---------------------------------------------------------------------------
# Acceptance - requires the port
# ---------------------------------------------------------------------------
def test_accepted_case_matches_oracle(port, accepted_case, tolerance):
"""
A case the reference accepted must be accepted, with matching geometry.
Both halves matter. Reproducing the measured values while accepting a case
the reference rejected is not a passing port.
"""
expected = accepted_case["report"]
where = f"{accepted_case['profile']}/{accepted_case['label']}"
result = port.build(
family=accepted_case["family"],
profile=accepted_case["profile"],
params=accepted_case["params"],
)
got = result.report
# Counts are exact.
for key in ("SECTION_PARTS", "STRAP_CHANNELS", "BUNDLE_COUNT"):
assert got[key] == expected[key], f"{where}: {key}"
# Lengths and areas carry the tolerance the oracle declares.
for key, want in expected.items():
if not isinstance(want, float) or key in ("SECTION_PARTS", "STRAP_CHANNELS"):
continue
tol = tolerance["areas_mm2"] if key.endswith("_MM2") else tolerance["lengths_mm"]
assert key in got, f"{where}: port did not report {key}"
assert abs(got[key] - want) <= tol, (
f"{where}: {key} is {got[key]}, oracle says {want}"
)
def test_rejected_case_is_rejected(port, rejected_case):
"""
A case the reference rejected must be rejected.
These ten are the part of the contract a naive reimplementation loses: it
is easy to reproduce the geometry and quietly drop the constraint that made
it trustworthy. Accepting any of them is a failure, however good the
numbers look elsewhere.
"""
where = f"{rejected_case['profile']}/{rejected_case['label']}"
with pytest.raises(port.ProfileRejected) as excinfo:
port.build(
family=rejected_case["family"],
profile=rejected_case["profile"],
params=rejected_case["params"],
)
assert str(excinfo.value).strip(), (
f"{where}: rejected without a message. A rejection must name the "
f"parameter and the limit, as the reference does:\n"
f" {rejected_case['rejection']}"
)
def test_no_cad_dependency_on_the_2d_path(port, accepted_case):
"""
Building a cross-section must not import the 3D kernel.
ENVIRONMENT.md section 1.1: a slow OCCT import must never land in the
request path for a page that only draws a cross-section. This test also
runs in the CI job where requirements-cad.txt is absent, where an accidental
import fails outright rather than merely being slow.
"""
import sys
for module in ("cadquery", "OCP", "build123d"):
sys.modules.pop(module, None)
port.build(
family=accepted_case["family"],
profile=accepted_case["profile"],
params=accepted_case["params"],
)
leaked = [m for m in ("cadquery", "OCP", "build123d") if m in sys.modules]
assert not leaked, f"the 2D path imported {leaked}"
+31
View File
@@ -0,0 +1,31 @@
# Pinned reference toolchain for the rev-8.0.0 acceptance oracle.
#
# This image exists for one purpose: regenerating and verifying
# fixtures/strap-beam-8.0.0/. The running application has no OpenSCAD
# dependency at all -- see docs/ENVIRONMENT.md section 8.2. Confining OpenSCAD
# here is why the host's package version cannot drift into the oracle.
#
# Do not add anything to this image. Its value is that it contains nothing else.
FROM debian:12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
openscad \
python3 \
git \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# BOSL2 at the exact commit the oracle was frozen against.
ARG BOSL2_COMMIT=92d697c2856de2fed93a33e858068589cefc2898
RUN git clone https://github.com/BelfrySCAD/BOSL2.git /BOSL2 \
&& git -C /BOSL2 checkout "${BOSL2_COMMIT}" \
&& rm -rf /BOSL2/.git
# The generators use `include <BOSL2/std.scad>`; OpenSCAD resolves that against
# OPENSCADPATH after trying the including file's own directory.
ENV OPENSCADPATH=/
WORKDIR /repo
CMD ["openscad", "--version"]
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
#
# Build the pinned toolchain and prove the committed oracle still reproduces.
#
# ./tools/reference-toolchain/verify.sh # verify the hash only
# ./tools/reference-toolchain/verify.sh --full # regenerate and compare
#
# --full runs all 123 cases through OpenSCAD. On the staging host that takes a
# while: the solvers are single-threaded and the CPU is old. That is expected.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
IMAGE="mechcomp/reference-toolchain:8.0.0"
EXPECTED="ddd0f1548379205dd0c652ec07285b0dae331e52ff0a0437005dfc6cddcc2cb2"
echo "==> building ${IMAGE}"
docker build -t "${IMAGE}" "${REPO}/tools/reference-toolchain"
echo
echo "==> toolchain versions"
docker run --rm "${IMAGE}" openscad --version
docker run --rm "${IMAGE}" git -C /BOSL2 rev-parse HEAD 2>/dev/null \
|| echo "(BOSL2 .git removed at build time; commit is pinned by ARG)"
echo
echo "==> verifying the committed oracle"
python3 "${REPO}/fixtures/strap-beam-8.0.0/make_fixtures.py" --verify
if [[ "${1:-}" == "--full" ]]; then
echo
echo "==> regenerating all 123 cases inside the pinned toolchain"
tmp="$(mktemp -d)"
trap 'rm -rf "${tmp}"' EXIT
cp "${REPO}/fixtures/strap-beam-8.0.0/strap-beam-fixtures-8.0.0.json" "${tmp}/before.json"
docker run --rm -v "${REPO}:/repo" "${IMAGE}" \
python3 /repo/fixtures/strap-beam-8.0.0/make_fixtures.py --regenerate
echo
echo "==> comparing against the committed oracle"
if diff -q "${tmp}/before.json" \
"${REPO}/fixtures/strap-beam-8.0.0/strap-beam-fixtures-8.0.0.json" >/dev/null; then
echo "OK - byte-identical."
else
echo "DIFFERS from the committed oracle." >&2
echo "The 'frozen' date changes on every regeneration, so a one-line" >&2
echo "diff there is expected. Any other difference means the toolchain" >&2
echo "has drifted; investigate before proceeding." >&2
diff "${tmp}/before.json" \
"${REPO}/fixtures/strap-beam-8.0.0/strap-beam-fixtures-8.0.0.json" | head -40 >&2
cp "${tmp}/before.json" \
"${REPO}/fixtures/strap-beam-8.0.0/strap-beam-fixtures-8.0.0.json"
echo >&2
echo "The committed oracle has been restored. Nothing was overwritten." >&2
exit 1
fi
fi
echo
echo "expected sha256: ${EXPECTED}"