diff --git a/docs/STOCK.md b/docs/STOCK.md new file mode 100644 index 0000000..d988bcb --- /dev/null +++ b/docs/STOCK.md @@ -0,0 +1,138 @@ +# STOCK.md + +**What the compiler is for, and what every catalogue entry has to declare.** + +Written 2026-08-22, from CIVICVS's statement of the project's subject. + +--- + +## 1. The subject of this library + +This is not a 3D-printing geometry library. BOSL2 is that, and it is vendored in +`legacy/` because the reference was built on it. + +This library **describes commercial off-the-shelf hardware, and then generates +the printed part that encloses it, interfaces with it, or augments it.** + +The pallet strap is not the subject. It is the **first stock entry**, and it was +described so thoroughly that the whole of rev 8.0.0 looks like a strap library. +Electrical conduit is the second. Stock can be anything you can buy or make on +site: pipe, sheet, net, poured, drilled, bent. Each gets described, one at a +time, and added to the catalogue. + +The printed part is the *adapter*. The stock is what it adapts to. + +### Why this framing changes the code + +Three things in the current codebase are the same object seen three times, and +none of them knows about the others: + +| Where | What it is | How it is expressed today | +|---|---|---| +| `Geo.cavity_w` / `cavity_t` | a strap's occupied void | strap width and thickness, plus `fit_clearance_mm`, times `bundle_count` | +| `three_fin_bore_side_mm` | a polygonal void through the centre | a bare side length in millimetres | +| `bore_from_members()` | the void left over between members | derived, not declared | + +The first two are stock occupying space. The third is not stock at all — it is a +residual cavity, and the name collision between it and a declared bore is +already a source of confusion. **A declared stock void and a derived residual +void are different things and should stop sharing a word.** + +## 2. What a stock entry declares + +Every entry in the catalogue answers the same questions. An entry that cannot +answer one of them is not described well enough to generate a part. + +**Designation.** What you ask for at the counter. `EMT 1/2"`, `PET strap +15.875 x 0.508`, `#3 rebar`. This is a label, never a dimension — see §5. + +**Section.** The cross-section the stock presents where the printed part meets +it, as a closed path in millimetres: a rectangle for strap, a circle for +conduit, a hexagon for a bolt head, a deformed circle for rebar. Prismatic stock +has one section along its whole length; non-prismatic stock declares the section +at the interface and nothing else, because that is all this compiler can hold +(see `PRECISION.md`). + +**Nominal versus actual.** These differ, and the difference is where parts +fail. Trade size is not outside diameter. Nominal lumber is not actual lumber. +**The catalogue stores actual, measured, with its source.** The designation is +looked up to reach it, never computed from it. + +**Fit.** How much room the printed part must leave, and why. A strap that slides +through a channel, a conduit that is meant to be a press fit, and a bolt that +must clear a hole are three different numbers even at the same diameter. Fit is +a property of the *joint*, not of the stock, so an entry declares its default +and every placement may override it. + +**Tolerance of the stock itself.** Extruded and rolled stock varies. A part +designed to the nominal section jams on the fat end of the run. Where a +manufacturing tolerance is known it is recorded; where it is not, that is +recorded too, and it is not silently assumed to be zero. + +**Provenance.** Where the numbers came from — a standard, a spec sheet, or a +caliper. A measured value with a date beats a remembered one, and a remembered +one does not go in. + +## 3. The three roles a printed part plays + +Naming these keeps profiles honest about what they are doing. + +**Enclose.** The part surrounds the stock and holds it. The strap channels are +this. Failure mode: the stock does not go in, or rattles once it is in. + +**Interface.** The part mates two pieces of stock that were not made to meet, or +mates stock to a fastener. The Y's conduit core is this — the conduit carries +load or routes cable, and the printed body is what lets three straps meet it. +Failure mode: the mating surface is thinner than the load through it. + +**Augment.** The part adds a feature the stock does not have: a mounting boss, a +cable exit, a label surface, a keyed orientation. Failure mode: the addition +compromises the enclosure or interface it is attached to. + +A profile may do all three. Most useful ones do. + +## 4. Adding an entry + +The order matters, and it is the same order that produced the strap: + +1. **Describe the stock**, per §2, with provenance. No geometry yet. +2. **State the fit**, and what happens at both ends of its tolerance. +3. **Generate the section**, and check it against a real sample if one exists. +4. **Only then** write the profile that uses it. + +Steps 1 and 2 are where the errors are, and they cost nothing to correct. +Step 4 is where they become expensive. + +**Every entry lands additively.** The 123-case oracle is frozen at rev 8.0.0 and +is the only evidence the geometry is right. A new stock entry, a new parameter, +or a new profile must leave all 123 cases building byte-identically, which means +new parameters default to *absent*. If adding a capability perturbs one recorded +value, the implementation is wrong — not the oracle. See `ACCEPTANCE.md`. + +## 5. What stays out + +**No standards tables in the geometry layer.** The map from `EMT 1/2"` to an +outside diameter is data about the world. It gets revised, it varies by region +and by decade, and a wrong entry in it is a wrong part. It belongs where it can +be corrected and cited without touching geometry, and where a person can see +which number was used. Geometry takes millimetres. + +**No structural claims.** `PRECISION.md` §7 governs and is scope-locked. That a +part encloses a conduit says nothing about what the assembly carries. Measure and +attest; never adjudicate. + +**No inferred stock.** If a dimension is not measured or cited, the entry is +incomplete and does not ship. A plausible number is worse than a missing one, +because a missing one stops the build. + +## 6. Consequence for what exists + +`Geo` currently derives its cavity from `strap_width_mm`, `strap_thickness_mm`, +`bundle_count` and `fit_clearance_mm` — a rectangular stock entry, inlined. +Expressing it *through* the stock descriptor rather than beside it is the first +migration, and it is the one that proves the abstraction: if all 123 cases stay +byte-identical with the strap expressed as a catalogue entry, the descriptor is +faithful. If they do not, it is not, and the second entry would have inherited +the flaw. + +That migration comes before the conduit core, not after it. diff --git a/src/mechcomp/stock.py b/src/mechcomp/stock.py new file mode 100644 index 0000000..96b86c9 --- /dev/null +++ b/src/mechcomp/stock.py @@ -0,0 +1,310 @@ +""" +COTS stock: what you buy, and the void it needs in the printed part. + +``docs/STOCK.md`` is the specification; this is its first code. + +WHAT THIS MODULE IS FOR + The compiler describes commercial off-the-shelf hardware and generates the + printed part that encloses, interfaces with, or augments it. The pallet + strap is not the subject of the library -- it is the first stock entry, and + it is currently inlined into ``geom.records.Geo`` rather than described. + + ``Geo`` conflates three unrelated things: + + width, strap_t, count the stock <- belongs here + clearance the fit <- belongs here + wall_*, min_wall the wall policy <- stays in Geo + + This module names the first two. It does not yet change ``Geo``: the point + of this step is to prove the descriptor reproduces the existing geometry + exactly, before anything depends on it. ``tests/test_stock.py`` is that + proof. Nothing in the build path imports this module yet, so the frozen + oracle cannot move. + +WHY FIT IS NOT A PROPERTY OF THE STOCK + A strap that slides through a channel, a conduit meant as a press fit, and + a bolt that must clear a hole are three different clearances at the same + nominal size. Fit belongs to the joint. An entry carries a sensible default + and every placement may override it. + +WHY NOMINAL IS NOT ACTUAL + Trade size is not outside diameter. Nominal lumber is not actual lumber. + Every entry stores the measured section with a source, and refuses to be + constructed without one -- see ``Provenance``. A plausible wrong dimension + is worse than a missing one, because the missing one stops the build. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import List, Sequence, Tuple + +from .geom.primitives import cos_d, sin_d + +Point = Tuple[float, float] +Path = List[Point] + + +# --------------------------------------------------------------------------- +# Provenance +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Provenance: + """ + Where a dimension came from. + + Required on every entry, and deliberately awkward to fake. STOCK.md section + 5: if a dimension is not measured or cited, the entry is incomplete and does + not ship. + """ + + source: str # standard, spec sheet, or "caliper" + recorded: str # ISO date the value was taken or checked + note: str = "" + + def __post_init__(self) -> None: + if not self.source.strip(): + raise ValueError( + "Provenance.source is required. A stock dimension with no " + "stated origin is a guess, and a guess that reaches geometry " + "produces a part that does not fit." + ) + if not self.recorded.strip(): + raise ValueError("Provenance.recorded is required (ISO date).") + + +# --------------------------------------------------------------------------- +# Fit +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Fit: + """ + How much room the printed part leaves around the stock, per face. + + ``clearance`` is applied to every face of the section, which is what the + reference does for straps: ``cavity_w = width + 2 * clearance``. + """ + + clearance: float + note: str = "" + + def __post_init__(self) -> None: + if self.clearance < 0.0: + raise ValueError( + "Fit.clearance is negative (%r). An interference fit is not " + "expressible as a negative clearance here -- the cavity would " + "be smaller than the stock and the part could not be " + "assembled. Model interference explicitly when it is needed." + % self.clearance + ) + + +# --------------------------------------------------------------------------- +# Stock entries +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class RectStock: + """ + Rectangular stock: strap, flat bar, sheet edge, lumber. + + This is the strap entry. ``width`` runs along the member's local +X and + ``thickness`` along local +Y, matching ``geom.records``' convention, and a + bundle of ``count`` laminae stacks along the thickness. + """ + + designation: str + width: float + thickness: float + provenance: Provenance + count: int = 1 + default_fit: float = 0.25 + + def __post_init__(self) -> None: + if self.width <= 0 or self.thickness <= 0: + raise ValueError("RectStock dimensions must be positive.") + if self.count < 1: + raise ValueError("RectStock.count must be at least 1.") + + @property + def stack(self) -> float: + """Total thickness of the bundle.""" + return self.count * self.thickness + + def section(self) -> Path: + """The physical stock, in local coordinates, centred on the origin.""" + return _rect(self.width / 2.0, self.stack / 2.0) + + def cavity(self, fit: Fit) -> Path: + """The void the stock occupies once the fit clearance is added.""" + return _rect(self.width / 2.0 + fit.clearance, + self.stack / 2.0 + fit.clearance) + + def laminae(self) -> List[Path]: + """Individual layers, for display when count > 1.""" + out: List[Path] = [] + for i in range(self.count): + y = (i - (self.count - 1) / 2.0) * self.thickness + rect = _rect(self.width / 2.0, self.thickness / 2.0) + out.append([(p[0], p[1] + y) for p in rect]) + return out + + +@dataclass(frozen=True) +class RoundStock: + """ + Round stock: conduit, pipe, rod, rebar, dowel. + + ``diameter`` is the ACTUAL outside diameter in millimetres, never the trade + size. EMT 1/2" is not 12.7 mm. The designation carries the trade name; the + diameter carries the measurement; the provenance says which standard or + caliper produced it. + + THE APPROXIMATION GOES OUTWARD, AND THAT IS NOT A DETAIL. + A polygon with its vertices on the nominal circle -- which is what + OpenSCAD's ``circle()`` and BOSL2 produce -- lies entirely INSIDE that + circle. Used as a hole, its flats bite into the nominal diameter by + ``r(1 - cos(180/n))`` and the conduit does not go in. + + So a cavity here is circumscribed: the polygon's INSCRIBED circle equals + the required diameter, and the flats sit outside it. The hole is never + smaller than asked for. For the stock's own section -- the solid, not + the void -- the vertices sit on the circle as usual, because there the + conservative direction is inward. + """ + + designation: str + diameter: float + provenance: Provenance + default_fit: float = 0.25 + facets: int = 48 + + def __post_init__(self) -> None: + if self.diameter <= 0: + raise ValueError("RoundStock.diameter must be positive.") + if self.facets < 3: + raise ValueError("RoundStock.facets must be at least 3.") + + def section(self) -> Path: + """The physical stock: vertices on the true circle, so it under-claims.""" + return _polygon(self.diameter / 2.0, self.facets) + + def cavity(self, fit: Fit) -> Path: + """ + The void, circumscribed about the required circle. + + The required radius is the stock radius plus the clearance; the polygon + is grown by ``1 / cos(180/n)`` so that its inscribed circle -- the + tightest point of the hole -- is exactly that radius. + """ + required = self.diameter / 2.0 + fit.clearance + return _polygon(required / cos_d(180.0 / self.facets), self.facets) + + def cavity_tight_radius(self, fit: Fit) -> float: + """The smallest radius anywhere in the cavity. Equals the required radius.""" + return self.diameter / 2.0 + fit.clearance + + +# --------------------------------------------------------------------------- +# Local section helpers +# --------------------------------------------------------------------------- + +def _rect(half_w: float, half_t: float) -> Path: + """ + Rectangle centred on the origin. + + Vertex order matches ``geom.records.local_rect(half_w, half_w, half_t, + half_t)`` exactly, so a placed section is identical to the path the + reference produces. The equality is asserted in ``tests/test_stock.py`` + rather than assumed. + """ + return [(half_w, -half_t), + (half_w, half_t), + (-half_w, half_t), + (-half_w, -half_t)] + + +def _polygon(r: float, n: int) -> Path: + """Regular n-gon of circumradius r, first vertex on +X, counter-clockwise.""" + return [(r * cos_d(360.0 * i / n), r * sin_d(360.0 * i / n)) + for i in range(n)] + + +def placed(path: Sequence[Point], cx: float, cy: float, angle: float) -> Path: + """ + Place a local section into a global frame. + + Same rotate-then-translate as ``geom.records.place``, expressed against a + bare placement rather than a ``Member``, because stock has no opinion about + faces or walls. + """ + ca, sa = cos_d(angle), sin_d(angle) + return [(p[0] * ca - p[1] * sa + cx, + p[0] * sa + p[1] * ca + cy) for p in path] + + +def inscribed_radius(path: Sequence[Point]) -> float: + """ + Distance from the origin to the nearest point on the path's boundary. + + For a cavity this is the tightest dimension of the hole -- the number that + decides whether the stock goes in. Used to verify the outward + approximation rather than trusting the algebra. + """ + n = len(path) + best = float("inf") + for i in range(n): + ax, ay = path[i] + bx, by = path[(i + 1) % n] + dx, dy = bx - ax, by - ay + l2 = dx * dx + dy * dy + if l2 <= 0.0: + best = min(best, math.hypot(ax, ay)) + continue + t = max(0.0, min(1.0, -(ax * dx + ay * dy) / l2)) + best = min(best, math.hypot(ax + t * dx, ay + t * dy)) + return best + + +# --------------------------------------------------------------------------- +# The catalogue, as it stands +# --------------------------------------------------------------------------- +# +# Two entries. The strap is the one rev 8.0.0 was built around; its dimensions +# are the reference's own defaults, so its provenance is the generator, not a +# measurement. That is recorded honestly rather than dressed up -- a caliper +# reading on real strap would supersede it. +# +# Conduit is NOT here yet, and must not be added from recollection. Trade size +# is not outside diameter, the value has to come from a standards source, and +# STOCK.md section 5 says an entry without provenance does not ship. It lands +# when CIVICVS supplies a measurement or a citation. + +CATALOGUE = { + "PET strap 15.875 x 0.508": RectStock( + designation="PET strap 15.875 x 0.508", + width=15.875, + thickness=0.508, + provenance=Provenance( + source="strap-beam-3x.scad rev 8.0.0 defaults", + recorded="2026-08-22", + note="Generator default, not a measured sample. Supersede with a " + "caliper reading on the strap actually in use.", + ), + default_fit=0.25, + ), + "Steel strap 15.875 x 0.79": RectStock( + designation="Steel strap 15.875 x 0.79", + width=15.875, + thickness=0.79, + provenance=Provenance( + source="strap-beam-3x.scad rev 8.0.0 steel0.79 oracle case", + recorded="2026-08-22", + note="Generator parameter override, not a measured sample.", + ), + default_fit=0.25, + ), +} diff --git a/tests/test_stock.py b/tests/test_stock.py new file mode 100644 index 0000000..5882df5 --- /dev/null +++ b/tests/test_stock.py @@ -0,0 +1,201 @@ +""" +Proof that the stock descriptor reproduces the existing geometry exactly. + +STOCK.md section 6: expressing the strap through the descriptor rather than +beside it is the migration that proves the abstraction. If the descriptor +produces the same paths ``geom.records`` already produces, it is faithful and +the geometry layer can be rewired onto it. If it does not, it is wrong, and the +second catalogue entry would have inherited the flaw. + +The comparison is EXACT -- ``==`` on floats, not ``approx``. The point is +byte-identical output, because the next step replaces one with the other and the +frozen oracle must not move by so much as a last-place unit. + +Nothing here imports the build path. ``mechcomp.stock`` is not yet used by any +profile, so these tests cannot disturb the oracle even if they fail. +""" + +from __future__ import annotations + +import math + +import pytest + +from mechcomp.geom.records import Geo, Member, cavity_path, strap_layer_paths, strap_path +from mechcomp.stock import ( + CATALOGUE, + Fit, + Provenance, + RectStock, + RoundStock, + inscribed_radius, + placed, +) + +# The oracle's own parameter space: defaults plus every override that appears in +# a case label -- width13.4, steel0.79, bundle2, bundle3. +WIDTHS = [15.875, 13.4] +THICKNESSES = [0.508, 0.79] +COUNTS = [1, 2, 3] +CLEARANCES = [0.25, 0.0, 0.4] + +# Placements chosen to exercise rotation, translation and every face mode. +PLACEMENTS = [ + (0.0, 0.0, 0.0), + (3.5, -2.25, 90.0), + (-7.125, 11.0, 33.0), + (0.0, -9.5, -45.0), + (12.75, 12.75, 137.0), # the dihedral angle ROADMAP calls the real gap +] +FACES = [0, 1, -1] + + +def _geo(width, thickness, count, clearance): + return Geo(width=width, strap_t=thickness, count=count, clearance=clearance, + wall_inside=1.20, wall_outside=1.20, wall_edge=1.20, min_wall=1.20) + + +def _stock(width, thickness, count): + return RectStock(designation="test", width=width, thickness=thickness, + count=count, + provenance=Provenance(source="test", recorded="2026-08-22")) + + +def _cases(): + for w in WIDTHS: + for t in THICKNESSES: + for n in COUNTS: + for c in CLEARANCES: + yield w, t, n, c + + +# --------------------------------------------------------------------------- +# Faithfulness +# --------------------------------------------------------------------------- + +def test_cavity_matches_records_exactly(): + """RectStock.cavity, placed, equals geom.records.cavity_path bit for bit.""" + for w, t, n, c in _cases(): + g = _geo(w, t, n, c) + s = _stock(w, t, n) + fit = Fit(clearance=c) + for cx, cy, angle in PLACEMENTS: + for face in FACES: + want = cavity_path(Member(cx, cy, angle, face), g) + got = placed(s.cavity(fit), cx, cy, angle) + assert got == want, ( + "cavity diverged at width=%s thickness=%s count=%s " + "clearance=%s placement=(%s,%s,%s)\n records: %r\n stock: %r" + % (w, t, n, c, cx, cy, angle, want, got)) + + +def test_section_matches_records_exactly(): + """RectStock.section, placed, equals geom.records.strap_path bit for bit.""" + for w, t, n, c in _cases(): + g = _geo(w, t, n, c) + s = _stock(w, t, n) + for cx, cy, angle in PLACEMENTS: + want = strap_path(Member(cx, cy, angle, 0), g) + got = placed(s.section(), cx, cy, angle) + assert got == want, ( + "section diverged at width=%s thickness=%s count=%s " + "placement=(%s,%s,%s)" % (w, t, n, cx, cy, angle)) + + +def test_laminae_match_records_exactly(): + """Per-layer paths match, including the count>1 stacking offset.""" + for w, t, n, c in _cases(): + g = _geo(w, t, n, c) + s = _stock(w, t, n) + for cx, cy, angle in PLACEMENTS: + want = strap_layer_paths(Member(cx, cy, angle, 0), g) + got = [placed(p, cx, cy, angle) for p in s.laminae()] + assert got == want, ( + "laminae diverged at width=%s thickness=%s count=%s" % (w, t, n)) + + +def test_stack_matches_geo_bundle_t(): + for w, t, n, c in _cases(): + assert _stock(w, t, n).stack == _geo(w, t, n, c).bundle_t + + +# --------------------------------------------------------------------------- +# Round stock -- the outward approximation +# --------------------------------------------------------------------------- + +def test_round_cavity_is_never_smaller_than_required(): + """ + The tightest point of a round cavity is at least the required radius. + + This is the property that decides whether conduit goes in. A vertices-on- + circle polygon -- what OpenSCAD's circle() gives -- fails it, so the failure + is asserted too, to show the test can distinguish the two. + """ + for diameter in (17.93, 23.42, 6.0, 50.0): + for facets in (12, 24, 48, 96): + for clearance in (0.0, 0.25, 0.5): + stock = RoundStock( + designation="test", diameter=diameter, facets=facets, + provenance=Provenance(source="test", recorded="2026-08-22")) + fit = Fit(clearance=clearance) + required = stock.cavity_tight_radius(fit) + tight = inscribed_radius(stock.cavity(fit)) + + assert tight >= required - 1e-9, ( + "cavity is tighter than required at d=%s n=%s clr=%s: " + "%.9f < %.9f -- the stock would not go in" + % (diameter, facets, clearance, tight, required)) + assert tight == pytest.approx(required, abs=1e-9), ( + "cavity is looser than it needs to be at d=%s n=%s: " + "%.9f vs %.9f" % (diameter, facets, tight, required)) + + +def test_inscribed_polygon_would_fail_the_same_check(): + """ + The naive approximation really is too small, by the expected amount. + + Guards against the outward-growth test passing vacuously. At 48 facets a + 9 mm radius hole drawn the naive way is ~9.6 um undersized -- small, and + entirely capable of stopping a press fit. + """ + from mechcomp.stock import _polygon + + for r in (3.0, 9.0, 25.0): + for n in (12, 48): + tight = inscribed_radius(_polygon(r, n)) + expected = r * math.cos(math.radians(180.0 / n)) + assert tight == pytest.approx(expected, rel=1e-12) + assert tight < r + + +# --------------------------------------------------------------------------- +# Provenance and fit are enforced, not decorative +# --------------------------------------------------------------------------- + +def test_entry_cannot_be_built_without_provenance(): + with pytest.raises(ValueError, match="source is required"): + Provenance(source="", recorded="2026-08-22") + with pytest.raises(ValueError, match="recorded is required"): + Provenance(source="caliper", recorded="") + + +def test_negative_clearance_is_refused(): + with pytest.raises(ValueError, match="negative"): + Fit(clearance=-0.1) + + +def test_catalogue_entries_all_carry_provenance(): + assert CATALOGUE, "the catalogue is empty" + for name, entry in CATALOGUE.items(): + assert entry.designation == name + assert entry.provenance.source.strip() + assert entry.provenance.recorded.strip() + + +def test_catalogue_strap_matches_the_reference_defaults(): + """The first entry is the strap rev 8.0.0 was built around.""" + strap = CATALOGUE["PET strap 15.875 x 0.508"] + assert strap.width == 15.875 + assert strap.thickness == 0.508 + assert strap.count == 1 + assert strap.default_fit == 0.25