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