stock: name the COTS descriptor; the strap becomes the first catalogue entry

The compiler describes 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 entry, and it was inlined into Geo rather than described. STOCK.md states what every entry must declare: designation, section, nominal versus actual, fit, stock tolerance, provenance.

Geo conflates three things. Width, thickness and count are the stock. Clearance is the fit, a property of the joint. The wall thicknesses are the printed part policy. This commit names the first two and leaves Geo untouched, so no profile imports the new module and the frozen oracle cannot move.

test_stock.py proves faithfulness by exact float equality against geom.records across 36 parameter combinations, 5 placements and 3 face modes. Mutation tested before landing: reversed vertex order, halved clearance, dropped lamina offset and a naive round cavity are each caught.

Round cavities are circumscribed rather than inscribed. A vertices on circle polygon lies inside the nominal diameter and bites into it by r times one minus cos 180 over n, about 9.6 micron at 9 mm radius and 48 facets, which is enough to stop a press fit. Conduit is deliberately absent from the catalogue until a measurement or citation exists.
This commit is contained in:
2026-08-23 08:52:17 -05:00
parent 6836ece4ff
commit f5651d3a62
3 changed files with 649 additions and 0 deletions
+138
View File
@@ -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.
+310
View File
@@ -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,
),
}
+201
View File
@@ -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