geom.records no longer computes the section, cavity or laminae. It calls mechcomp.stock, so the shape of a piece of stock is defined once. All 123 oracle cases unmoved: 482 passed. stock.py also becomes a leaf module, ending an import cycle with geom that resolved only by accident of ordering.
Three defects in f5651d3 corrected. Provenance raised on an empty source, which made an unattributed dimension unrepresentable and blocked the ordinary use of the tool: type what the caliper reads, print, measure the print, adjust. Provenance now records and travels with the output. Fit refused negative clearance on the argument that interference is not assemblable, which is a design judgement and not the compilers to make. Interference is now computed and reported. CATALOGUE read as a whitelist and is documented as starting points, with a test asserting an entry built from nothing is as valid as one pulled from the dict.
emt_template takes the diameter, fit, designation and note from the caller. There is no standards table and no lookup. A parametric compiler cannot require its subject to be catalogued before it will run.
STOCK.md section 5 amended, since the refusals were implementing it. An entry without provenance no longer fails to ship, it ships labelled unattributed. The principle that a number must not appear from nowhere looking authoritative survives; the door does not.
Tests compare records and stock against a hand transcription of the reference rather than against each other, which would be tautological after delegation. Mutation testing found four gaps before landing: a dropped lamina stacking offset, emt_template silently ignoring its fit argument, describe discarding the note exactly when provenance was unverified, and a guard on float arithmetic that asserted a tautology.
414 lines
16 KiB
Python
414 lines
16 KiB
Python
"""
|
|
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
|
|
|
|
# Stock imports nothing from mechcomp. It is more fundamental than the
|
|
# geometry of the printed part -- geom.records depends on this module, not
|
|
# the other way round -- and a cycle between them would resolve only by
|
|
# accident of import order in geom/__init__.py.
|
|
#
|
|
# These are the identical expressions from geom.primitives, and
|
|
# test_stock.py asserts they agree bit for bit.
|
|
|
|
|
|
def cos_d(a: float) -> float:
|
|
return math.cos(math.radians(a))
|
|
|
|
|
|
def sin_d(a: float) -> float:
|
|
return math.sin(math.radians(a))
|
|
|
|
Point = Tuple[float, float]
|
|
Path = List[Point]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provenance
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True)
|
|
class Provenance:
|
|
"""
|
|
Where a dimension came from. Recorded, never required.
|
|
|
|
An earlier version of this class raised when the source was empty. That
|
|
was wrong, and wrong in a way that defeated the point of the compiler:
|
|
it made the ordinary case -- type the number your caliper shows, print,
|
|
measure the print, adjust -- impossible without first satisfying a
|
|
bureaucratic check. Parametric means the dimension is an input at every
|
|
run, not a fact to be established before the tool will start.
|
|
|
|
So an unattributed dimension is representable, and says so. What matters
|
|
is that the record TRAVELS WITH THE OUTPUT, so a part on the bench can be
|
|
traced to the numbers that produced it and a later measurement can
|
|
supersede an earlier guess. A quiet guess is the failure; a labelled one
|
|
is just an early draft.
|
|
"""
|
|
|
|
source: str = "" # standard, spec sheet, "caliper", or empty
|
|
recorded: str = "" # ISO date the value was taken or checked
|
|
note: str = ""
|
|
|
|
@property
|
|
def verified(self) -> bool:
|
|
"""True when someone stated where the number came from."""
|
|
return bool(self.source.strip())
|
|
|
|
def describe(self) -> str:
|
|
"""One line for the design record that ships with a model."""
|
|
tail = " -- %s" % self.note.strip() if self.note.strip() else ""
|
|
if not self.verified:
|
|
# The note is kept. Unverified is exactly when it matters most:
|
|
# it is the operator's own record of what they measured, and
|
|
# dropping it would discard the only trace of where the number
|
|
# came from.
|
|
return "unverified: supplied at design time" + tail
|
|
date = self.recorded.strip() or "undated"
|
|
return "%s (%s)%s" % (self.source.strip(), date, tail)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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``.
|
|
|
|
NEGATIVE CLEARANCE IS AN INTERFERENCE FIT, AND IT IS ALLOWED.
|
|
An earlier version refused it, arguing the part could not be
|
|
assembled. That is a design judgement and it is not the compiler's to
|
|
make: a conduit core that must not rattle wants interference, and PLA
|
|
deflects. The compiler computes the interference and reports it;
|
|
whether it is acceptable, and whether the print survives being pressed
|
|
together, is decided by the person holding both parts.
|
|
|
|
This is the same rule the project applies everywhere else. Measure and
|
|
attest; never adjudicate.
|
|
"""
|
|
|
|
clearance: float
|
|
note: str = ""
|
|
|
|
@property
|
|
def interference(self) -> float:
|
|
"""Interference per face, in mm. Zero for a clearance fit."""
|
|
return -self.clearance if self.clearance < 0.0 else 0.0
|
|
|
|
@property
|
|
def is_interference(self) -> bool:
|
|
return self.clearance < 0.0
|
|
|
|
def describe(self) -> str:
|
|
"""One line for the design record that ships with a model."""
|
|
if self.is_interference:
|
|
kind = "interference %.4f mm per face" % self.interference
|
|
elif self.clearance == 0.0:
|
|
kind = "line-to-line, 0 mm"
|
|
else:
|
|
kind = "clearance %.4f mm per face" % self.clearance
|
|
tail = " -- %s" % self.note.strip() if self.note.strip() else ""
|
|
return kind + tail
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_section(self.width, self.stack)
|
|
|
|
def cavity(self, fit: Fit) -> Path:
|
|
"""The void the stock occupies once the fit clearance is added."""
|
|
return rect_cavity(self.width, self.stack, fit.clearance)
|
|
|
|
def laminae(self) -> List[Path]:
|
|
"""Individual layers, for display when count > 1."""
|
|
return rect_laminae(self.width, self.thickness, self.count)
|
|
|
|
|
|
@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_section(width: float, stack: float) -> Path:
|
|
"""The solid rectangular stock, centred on the origin."""
|
|
return _rect(width / 2.0, stack / 2.0)
|
|
|
|
|
|
def rect_cavity(width: float, stack: float, clearance: float) -> Path:
|
|
"""
|
|
The void rectangular stock occupies, grown by the fit clearance.
|
|
|
|
THE ARITHMETIC IS THE REFERENCE'S, DELIBERATELY.
|
|
sb-geom.scad computes ``cavity_w = width + 2*clearance`` and then
|
|
halves it. Writing ``width/2 + clearance`` instead is algebraically
|
|
identical and NOT guaranteed identical in IEEE 754 -- the two
|
|
differ in the last bit for some operands. Since geom.records now
|
|
delegates here, any such difference would move the frozen oracle,
|
|
so the expression matches the reference character for character
|
|
rather than merely in value.
|
|
"""
|
|
return _rect((width + 2.0 * clearance) / 2.0,
|
|
(stack + 2.0 * clearance) / 2.0)
|
|
|
|
|
|
def rect_laminae(width: float, thickness: float, count: int) -> List[Path]:
|
|
"""Individual layers of a bundle, stacked along the thickness."""
|
|
out: List[Path] = []
|
|
for i in range(count):
|
|
y = (i - (count - 1) / 2.0) * thickness
|
|
rect = _rect(width / 2.0, thickness / 2.0)
|
|
out.append([(p[0], p[1] + y) for p in rect])
|
|
return out
|
|
|
|
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# STARTING POINTS, NOT A WHITELIST.
|
|
#
|
|
# Nothing is gated on being in here. Any dimension can be passed directly --
|
|
# ``RoundStock(designation="my conduit", diameter=23.4)`` is a complete and
|
|
# legitimate entry -- and these exist only so the common cases need not be
|
|
# retyped. Pull one and override it with ``dataclasses.replace``.
|
|
#
|
|
# The strap entries carry the rev 8.0.0 generator defaults, which is a source
|
|
# but not a measurement; the provenance says so, and a caliper reading on the
|
|
# strap actually in use supersedes them.
|
|
#
|
|
# The conduit entry has NO dimension from me. Trade size is not outside
|
|
# diameter and I am not going to invent one, but that is a reason to leave the
|
|
# number to the caller, not a reason to refuse to build. ``EMT_TEMPLATE`` is
|
|
# the shape of the entry with the diameter left for you.
|
|
|
|
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,
|
|
),
|
|
}
|
|
|
|
|
|
def emt_template(diameter: float, designation: str = "EMT conduit",
|
|
fit: float = 0.25, note: str = "") -> RoundStock:
|
|
"""
|
|
A round-stock entry with the diameter you supply.
|
|
|
|
Measure the conduit, pass the number. If the first print is tight, change
|
|
the number and print again -- that loop is the point of the tool, and
|
|
nothing here needs to know a standards table for it to work.
|
|
|
|
``designation`` is free text and reaches the design record unchanged, so
|
|
"EMT 1/2 in, Home Depot, 2026-08" is a perfectly good value.
|
|
"""
|
|
return RoundStock(
|
|
designation=designation,
|
|
diameter=diameter,
|
|
default_fit=fit,
|
|
provenance=Provenance(note=note),
|
|
)
|