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:
+30
@@ -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
|
||||||
@@ -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 {} +
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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))];
|
||||||
@@ -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;
|
||||||
@@ -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];
|
||||||
@@ -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))
|
||||||
|
]);
|
||||||
@@ -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); }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Mechanical Compiler."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Mechanical Compiler - cad."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Mechanical Compiler - geom."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Mechanical Compiler - profiles."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Mechanical Compiler - web."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Mechanical Compiler - worker."""
|
||||||
@@ -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])
|
||||||
@@ -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}"
|
||||||
@@ -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"]
|
||||||
@@ -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}"
|
||||||
Reference in New Issue
Block a user