NEMAWASHI LAB

Tube

OTF

A broad-nib calligraphy simulator: each letter is stored as lines and arcs with a cap height of 700 units, and stroke weight varies by the angle between each segment and a virtual pen nib. The weight function raises |sin(strokeAngle - penAngle)| to a contrast exponent, then lerps between a light and heavy width, producing the thick/thin modulation of a flat-cut pen held at a fixed slant. Pen angle, heavy weight, light weight, contrast, and tracking are all live sliders, and the result exports as an OTF whose outlines are the expanded strokes.

Source

TubeCanvas.tsx352 lines
"use client";

import { useRef, useMemo, useEffect, useState, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { motion } from "framer-motion";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import exportTubeFont from "./exportTubeFont";
import {
  TUBE_GLYPHS, CAP, SPACE_W,
  strokeWeight, type Stroke,
} from "./tubeAlphabet";

const VB = 1000;
const LINE_GAP = 100;

interface Composition {
  name: string;
  text: string;
  bg: string;
  strokeColor: string;
  uiColor: "light" | "dark";
}

const compositions: Composition[] = [
  { name: "EAST", text: "EAST", bg: "#f5f3ef", strokeColor: "#1a1a1a", uiColor: "dark" },
  { name: "TUBE", text: "TUBE", bg: "#eae5dc", strokeColor: "#2a1810", uiColor: "dark" },
  { name: "GIRAGIRA", text: "GIRAGIRA", bg: "#f5f3ef", strokeColor: "#1a1a1a", uiColor: "dark" },
  { name: "A — N", text: "ABCDEFG\nHIJKLMN", bg: "#f5f3ef", strokeColor: "#1a1a1a", uiColor: "dark" },
  { name: "O — Z", text: "OPQRSTU\nVWXYZ", bg: "#f5f3ef", strokeColor: "#1a1a1a", uiColor: "dark" },
  { name: "NIGHT", text: "EAST", bg: "#0e0e10", strokeColor: "#f0eeeb", uiColor: "light" },
];

// ---------------------------------------------------------------------------
// SVG path helpers
// ---------------------------------------------------------------------------

function strokeToSvg(
  s: Stroke,
  ox: number, oy: number,
  scale: number,
  svgOx: number, svgOy: number,
  totalH: number,
): string {
  const tx = (x: number) => (x + ox) * scale + svgOx;
  const ty = (y: number) => (totalH - (y + oy)) * scale + svgOy;

  if (s.t === "L") {
    return `M${tx(s.x1)} ${ty(s.y1)}L${tx(s.x2)} ${ty(s.y2)}`;
  }

  const { cx, cy, r, start, sweep } = s;
  const sr = r * scale;
  const absSweep = Math.abs(sweep);
  const startRad = (start * Math.PI) / 180;

  const sx = tx(cx + r * Math.cos(startRad));
  const sy = ty(cy + r * Math.sin(startRad));

  if (absSweep >= 359.9) {
    const midRad = ((start + sweep / 2) * Math.PI) / 180;
    const mx = tx(cx + r * Math.cos(midRad));
    const my = ty(cy + r * Math.sin(midRad));
    const sf = sweep > 0 ? 0 : 1;
    return `M${sx} ${sy}A${sr} ${sr} 0 0 ${sf} ${mx} ${my}A${sr} ${sr} 0 0 ${sf} ${sx} ${sy}`;
  }

  const endRad = ((start + sweep) * Math.PI) / 180;
  const ex = tx(cx + r * Math.cos(endRad));
  const ey = ty(cy + r * Math.sin(endRad));
  const laf = absSweep > 180 ? 1 : 0;
  const sf = sweep > 0 ? 0 : 1;

  return `M${sx} ${sy}A${sr} ${sr} 0 ${laf} ${sf} ${ex} ${ey}`;
}

// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------

interface LayoutStroke {
  stroke: Stroke;
  ox: number;
  oy: number;
}

interface Layout {
  items: LayoutStroke[];
  totalW: number;
  totalH: number;
}

function layoutText(text: string, tracking: number): Layout {
  const lines = text.toUpperCase().split("\n");
  const lineH = CAP;
  const totalH = lines.length * lineH + (lines.length - 1) * LINE_GAP;

  const lineWidths = lines.map((line) => {
    let w = 0;
    for (let i = 0; i < line.length; i++) {
      const ch = line[i];
      if (ch === " ") w += SPACE_W;
      else {
        const g = TUBE_GLYPHS[ch];
        if (g) w += g.w;
      }
      if (i < line.length - 1) w += tracking;
    }
    return w;
  });

  const maxLineW = Math.max(...lineWidths, 1);
  const items: LayoutStroke[] = [];

  for (let li = 0; li < lines.length; li++) {
    const line = lines[li];
    const lineW = lineWidths[li];
    const lineOx = (maxLineW - lineW) / 2;
    const lineOy = totalH - (li + 1) * lineH - li * LINE_GAP;

    let cursor = lineOx;
    for (let ci = 0; ci < line.length; ci++) {
      const ch = line[ci];
      if (ch === " ") {
        cursor += SPACE_W;
        if (ci < line.length - 1) cursor += tracking;
        continue;
      }
      const g = TUBE_GLYPHS[ch];
      if (!g) { if (ci < line.length - 1) cursor += tracking; continue; }

      for (const stroke of g.strokes) {
        items.push({ stroke, ox: cursor, oy: lineOy });
      }
      cursor += g.w;
      if (ci < line.length - 1) cursor += tracking;
    }
  }

  return { items, totalW: maxLineW, totalH };
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export default function TubeCanvas() {
  const searchParams = useSearchParams();
  const initialIndex = Math.abs(Number(searchParams.get("v")) || 0) % compositions.length;
  const [index, setIndex] = useState(initialIndex);
  const nextIndex = (index + 1) % compositions.length;

  const handleClick = useCallback(() => {
    setIndex(nextIndex);
    window.history.replaceState(null, "", `/lab/tube?v=${nextIndex}`);
  }, [nextIndex]);

  const [vbH, setVbH] = useState(VB);
  const stageRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const el = stageRef.current;
    if (!el) return;
    const update = () => {
      const aspect = el.clientHeight / Math.max(1, el.clientWidth);
      setVbH(Math.round(VB * aspect));
    };
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const comp = compositions[index];
  const isLight = comp.uiColor === "light";

  const [heavyW, setHeavyW] = useState(55);
  const [lightW, setLightW] = useState(18);
  const [penAngle, setPenAngle] = useState(155);
  const [contrast, setContrast] = useState(2);
  const [tracking, setTracking] = useState(30);
  const [customText, setCustomText] = useState("");
  const [previewFamily, setPreviewFamily] = useState<string | null>(null);

  const activeText = customText || comp.text;

  const layout = useMemo(() => layoutText(activeText, tracking), [activeText, tracking]);

  const margin = 40;
  const scaleX = (VB - 2 * margin) / layout.totalW;
  const scaleY = (vbH - 2 * margin) / layout.totalH;
  const scale = Math.min(scaleX, scaleY);
  const svgOx = (VB - layout.totalW * scale) / 2;
  const svgOy = (vbH - layout.totalH * scale) / 2;

  const svgStrokes = useMemo(() => {
    return layout.items.map(({ stroke, ox, oy }) => ({
      d: strokeToSvg(stroke, ox, oy, scale, svgOx, svgOy, layout.totalH),
      w: strokeWeight(stroke, penAngle, heavyW, lightW, contrast) * scale,
    }));
  }, [layout, scale, svgOx, svgOy, penAngle, heavyW, lightW, contrast]);

  return (
    <div
      ref={stageRef}
      className="fixed inset-0 select-none transition-colors duration-700 cursor-pointer"
      style={{ backgroundColor: comp.bg }}
      onClick={handleClick}
    >
      <svg
        className="absolute inset-0 w-full h-full z-[1]"
        viewBox={`0 0 ${VB} ${vbH}`}
        preserveAspectRatio="xMidYMid meet"
        overflow="hidden"
      >
        {svgStrokes.map(({ d, w }, i) => (
          <path
            key={i}
            d={d}
            fill="none"
            stroke={comp.strokeColor}
            strokeWidth={w}
            strokeLinecap="round"
          />
        ))}
      </svg>

      {/* Composition label */}
      <div
        className="fixed bottom-8 left-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
        style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
      >
        <motion.div
          key={comp.name}
          initial={{ opacity: 0, y: 10 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.4, delay: 0.2 }}
        >
          <motion.p
            className="text-[12px] tracking-[0.08em] uppercase"
            animate={{ color: isLight ? "rgba(255,255,255,0.5)" : "rgba(23,23,23,0.5)" }}
            transition={{ duration: 0.6 }}
          >
            {comp.name}
          </motion.p>
          <motion.p
            className="text-[12px] tracking-[0.08em] mt-1"
            animate={{ color: isLight ? "rgba(255,255,255,0.3)" : "rgba(23,23,23,0.3)" }}
            transition={{ duration: 0.6 }}
          >
            {index + 1} / {compositions.length}
          </motion.p>
        </motion.div>
      </div>

      <DraggablePanel
        isLight={isLight}
        controls={[
          { label: "Heavy", value: heavyW, set: setHeavyW, min: 20, max: 100, step: 1 },
          { label: "Light", value: lightW, set: setLightW, min: 5, max: 60, step: 1 },
          { label: "Pen angle", value: penAngle, set: setPenAngle, min: 0, max: 180, step: 1 },
          { label: "Contrast", value: contrast, set: setContrast, min: 0.5, max: 4, step: 0.1 },
          { label: "Tracking", value: tracking, set: setTracking, min: 0, max: 120, step: 5 },
        ]}
      >
        <div style={{ marginBottom: 8 }}>
          <div style={{
            fontSize: 10, letterSpacing: "0.06em", textTransform: "uppercase",
            color: isLight ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)",
            fontVariationSettings: "'wght' 300, 'SRIF' 100", marginBottom: 4,
          }}>
            Preview text
          </div>
          <textarea
            value={customText}
            onChange={(e) => setCustomText(e.target.value)}
            placeholder={comp.text}
            rows={2}
            style={{
              width: "100%", padding: "6px 8px", background: "transparent",
              border: isLight ? "1px solid rgba(255,255,255,0.15)" : "1px solid rgba(0,0,0,0.1)",
              borderRadius: 6, color: isLight ? "#fff" : "#1a1a2e",
              fontSize: 12, fontFamily: "var(--font-flux), sans-serif",
              boxSizing: "border-box", resize: "none",
            }}
          />
        </div>
        <button
          onClick={() => setPreviewFamily(exportTubeFont(penAngle, heavyW, lightW, contrast))}
          style={{
            width: "100%", padding: "8px 0", marginTop: 4, borderRadius: 8,
            border: isLight ? "1px solid rgba(255,255,255,0.2)" : "1px solid rgba(0,0,0,0.1)",
            background: isLight ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.04)",
            color: isLight ? "#fff" : "#1a1a2e",
            fontFamily: "var(--font-flux), sans-serif",
            fontVariationSettings: "'wght' 500, 'SRIF' 100",
            fontSize: 11, letterSpacing: "0.08em", textTransform: "uppercase",
            cursor: "pointer",
          }}
        >
          Export OTF
        </button>
      </DraggablePanel>

      <div
        className="fixed bottom-8 right-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
        style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
      >
        <p
          className="text-[12px] tracking-[0.08em] uppercase"
          style={{ color: isLight ? "rgba(255,255,255,0.3)" : "rgba(23,23,23,0.3)" }}
        >
          Click anywhere
        </p>
      </div>

      <div className="fixed top-8 left-8 z-10" onClick={(e) => e.stopPropagation()}>
        <ScrambleLink
          from="NEMAWASHI LAB"
          to="← BACK"
          href="/lab"
          className={`text-[14px] tracking-[0.08em] uppercase pointer-events-auto font-[family-name:var(--font-flux)] ${
            isLight ? "text-white/60" : "text-foreground/60"
          }`}
          style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
        />
      </div>

      {previewFamily && (
        <div
          className="fixed bottom-0 left-0 right-0 z-30 pointer-events-none"
          style={{
            background: isLight ? "rgba(0,0,0,0.85)" : "rgba(255,255,255,0.95)",
            padding: "16px 32px",
            textAlign: "center",
          }}
        >
          <p style={{
            fontFamily: `"${previewFamily}", sans-serif`,
            fontSize: "clamp(14px, 2.3vw, 32px)",
            color: isLight ? "#fff" : "#1a1a2e",
            letterSpacing: "0.05em",
            margin: 0,
            overflowWrap: "anywhere",
          }}>
            ABCDEFGHIJKLM NOPQRSTUVWXYZ
          </p>
        </div>
      )}
    </div>
  );
}
exportTubeFont.ts115 lines
import { Glyph, Path } from "opentype.js";
import { ASCENDER, createFont, downloadFont, previewFont, roundedLineToPath, normalizeWinding } from "../fontExport";
import { TUBE_GLYPHS, CAP, SPACE_W, segmentWeight } from "./tubeAlphabet";

const SCALE = ASCENDER / CAP;
const ARC_STEP_DEG = 4;
const CAP_SEGS = 10;

// A line is already a single closed stadium contour — no overlap, no holes.
function addLineStroke(path: Path, x1: number, y1: number, x2: number, y2: number, w: number) {
  roundedLineToPath(path, x1 * SCALE, y1 * SCALE, x2 * SCALE, y2 * SCALE, w * SCALE);
}

// Trace a semicircular end cap (CAP_SEGS line segments) around (ccx,ccy).
function capTo(path: Path, ccx: number, ccy: number, rad: number, fromA: number, deltaA: number) {
  for (let i = 1; i <= CAP_SEGS; i++) {
    const a = fromA + (deltaA * i) / CAP_SEGS;
    path.lineTo(ccx + rad * Math.cos(a), ccy + rad * Math.sin(a));
  }
}

// Build the WHOLE arc as one variable-width ribbon contour: outer edge forward,
// round end cap, inner edge backward, round start cap. A single contour avoids
// the overlapping-segment-stadium holes the previous approach produced at joints.
function addArcRibbon(
  path: Path,
  cx: number, cy: number, r: number,
  startDeg: number, sweepDeg: number,
  penAngle: number, heavyW: number, lightW: number, contrast: number,
  wm?: number,
) {
  const n = Math.max(2, Math.ceil(Math.abs(sweepDeg) / ARC_STEP_DEG));
  const da = sweepDeg / n;
  const sign = sweepDeg > 0 ? 1 : -1;

  const outer: [number, number][] = [];
  const inner: [number, number][] = [];
  const pts: [number, number][] = [];
  const halfs: number[] = [];
  for (let i = 0; i <= n; i++) {
    const aRad = ((startDeg + i * da) * Math.PI) / 180;
    const ca = Math.cos(aRad);
    const sa = Math.sin(aRad);
    const px = cx + r * ca;
    const py = cy + r * sa;
    const tangent = aRad + (sign > 0 ? Math.PI / 2 : -Math.PI / 2);
    const hw = segmentWeight(tangent, penAngle, heavyW, lightW, contrast, wm) / 2;
    pts.push([px * SCALE, py * SCALE]);
    halfs.push(hw * SCALE);
    outer.push([(px + ca * hw) * SCALE, (py + sa * hw) * SCALE]);
    inner.push([(px - ca * hw) * SCALE, (py - sa * hw) * SCALE]);
  }

  const aStart = (startDeg * Math.PI) / 180;
  const aEnd = ((startDeg + sweepDeg) * Math.PI) / 180;

  path.moveTo(outer[0][0], outer[0][1]);
  for (let i = 1; i <= n; i++) path.lineTo(outer[i][0], outer[i][1]);
  capTo(path, pts[n][0], pts[n][1], halfs[n], aEnd, sign * Math.PI); // end cap → inner[n]
  for (let i = n - 1; i >= 0; i--) path.lineTo(inner[i][0], inner[i][1]);
  capTo(path, pts[0][0], pts[0][1], halfs[0], aStart + Math.PI, sign * Math.PI); // start cap → outer[0]
  path.closePath();
}

export default function exportTubeFont(
  penAngle: number,
  heavyW: number,
  lightW: number,
  contrast: number,
): string {
  const otGlyphs: Glyph[] = [];

  for (const [char, glyph] of Object.entries(TUBE_GLYPHS)) {
    const code = char.charCodeAt(0);
    const path = new Path();

    for (const s of glyph.strokes) {
      if (s.t === "L") {
        const angle = Math.atan2(s.y2 - s.y1, s.x2 - s.x1);
        const w = segmentWeight(angle, penAngle, heavyW, lightW, contrast, s.wm);
        addLineStroke(path, s.x1, s.y1, s.x2, s.y2, w);
      } else {
        addArcRibbon(path, s.cx, s.cy, s.r, s.start, s.sweep, penAngle, heavyW, lightW, contrast, s.wm);
      }
    }

    // All strokes are single contours; force one winding so junction overlaps fill solid.
    normalizeWinding(path);

    const maxStroke = heavyW * SCALE;
    otGlyphs.push(new Glyph({
      name: char,
      unicode: code,
      advanceWidth: glyph.w * SCALE + maxStroke,
      path,
    }));
  }

  otGlyphs.push(new Glyph({
    name: "space",
    unicode: 32,
    advanceWidth: SPACE_W * SCALE,
    path: new Path(),
  }));

  const h = Math.round(heavyW);
  const l = Math.round(lightW);
  const a = Math.round(penAngle);
  const filename = `GRGR_Tube_h${h}_l${l}_a${a}.otf`;

  const font = createFont("GRGR Tube", otGlyphs);
  downloadFont(font, filename);
  return previewFont(font, "GRGR-Tube-Preview");
}
tubeAlphabet.ts91 lines
export const CAP = 700;
export const SPACE_W = 200;

export type Stroke =
  | { t: "L"; x1: number; y1: number; x2: number; y2: number; wm?: number }
  | { t: "A"; cx: number; cy: number; r: number; start: number; sweep: number; wm?: number };

export interface TubeGlyph {
  w: number;
  strokes: Stroke[];
}

const L = (x1: number, y1: number, x2: number, y2: number, wm?: number): Stroke => ({
  t: "L", x1, y1, x2, y2, wm,
});
const A = (cx: number, cy: number, r: number, start: number, sweep: number, wm?: number): Stroke => ({
  t: "A", cx, cy, r, start, sweep, wm,
});

export const TUBE_GLYPHS: Record<string, TubeGlyph> = {
  A: { w: 600, strokes: [L(20, 0, 300, CAP), L(300, CAP, 580, 0), L(130, 260, 470, 260)] },
  B: { w: 340, strokes: [L(70, 0, 70, CAP), A(70, 550, 150, -90, 180, 0.4), A(70, 200, 200, -90, 180, 0.45)] },
  C: { w: 540, strokes: [A(300, 350, 330, 45, 270)] },
  D: { w: 490, strokes: [L(70, 0, 70, CAP), A(70, 350, 350, -90, 180, 0.45)] },
  E: { w: 420, strokes: [L(70, 0, 70, CAP), L(70, CAP, 400, CAP), L(70, 370, 340, 370), L(70, 0, 400, 0)] },
  F: { w: 380, strokes: [L(70, 0, 70, CAP), L(70, CAP, 360, CAP), L(70, 400, 310, 400)] },
  G: { w: 570, strokes: [A(300, 350, 330, 45, 270), L(530, 350, 530, 60)] },
  H: { w: 520, strokes: [L(70, 0, 70, CAP), L(450, 0, 450, CAP), L(70, 350, 450, 350)] },
  I: { w: 70, strokes: [L(35, 0, 35, CAP)] },
  J: { w: 340, strokes: [L(280, CAP, 280, 200), A(150, 200, 130, 0, -180, 0.5)] },
  K: { w: 500, strokes: [L(70, 0, 70, CAP), L(460, CAP, 70, 350), L(70, 350, 490, 0)] },
  L: { w: 380, strokes: [L(70, 0, 70, CAP), L(70, 0, 360, 0)] },
  M: { w: 680, strokes: [L(70, 0, 70, CAP), L(70, CAP, 340, 180), L(340, 180, 610, CAP), L(610, 0, 610, CAP)] },
  N: { w: 540, strokes: [L(70, 0, 70, CAP), L(70, CAP, 470, 0), L(470, 0, 470, CAP)] },
  O: { w: 600, strokes: [A(300, 350, 330, 0, 360)] },
  P: { w: 290, strokes: [L(70, 0, 70, CAP), A(70, 550, 150, -90, 180, 0.4)] },
  Q: { w: 600, strokes: [A(300, 350, 330, 0, 360), L(380, 100, 540, -50)] },
  R: { w: 480, strokes: [L(70, 0, 70, CAP), A(70, 550, 150, -90, 180, 0.4), L(180, 400, 440, 0)] },
  S: { w: 400, strokes: [A(260, 545, 155, 55, 210, 0.6), A(200, 195, 195, 235, 210)] },
  T: { w: 440, strokes: [L(220, 0, 220, CAP), L(20, CAP, 420, CAP)] },
  U: { w: 500, strokes: [L(70, CAP, 70, 200), A(250, 200, 180, 180, 180, 0.45), L(430, 200, 430, CAP)] },
  V: { w: 600, strokes: [L(20, CAP, 300, 0), L(300, 0, 580, CAP)] },
  W: { w: 750, strokes: [L(20, CAP, 175, 0), L(175, 0, 375, 480), L(375, 480, 575, 0), L(575, 0, 730, CAP)] },
  X: { w: 500, strokes: [L(40, CAP, 460, 0), L(40, 0, 460, CAP)] },
  Y: { w: 500, strokes: [L(20, CAP, 250, 350), L(480, CAP, 250, 350), L(250, 0, 250, 350)] },
  Z: { w: 460, strokes: [L(50, CAP, 410, CAP), L(410, CAP, 50, 0), L(50, 0, 410, 0)] },
};

export function strokeAngle(s: Stroke): number {
  if (s.t === "L") return Math.atan2(s.y2 - s.y1, s.x2 - s.x1);
  const midDeg = s.start + s.sweep / 2;
  const midRad = (midDeg * Math.PI) / 180;
  return midRad + (s.sweep > 0 ? Math.PI / 2 : -Math.PI / 2);
}

export function strokeWeight(
  s: Stroke,
  penAngleDeg: number,
  heavyW: number,
  lightW: number,
  contrast: number,
): number {
  const angle = strokeAngle(s);
  const penRad = (penAngleDeg * Math.PI) / 180;
  const factor = Math.pow(Math.abs(Math.sin(angle - penRad)), contrast);
  const w = lightW + (heavyW - lightW) * factor;
  const m = s.wm ?? 1;
  return lightW + (w - lightW) * m;
}

export function segmentAngle(
  x1: number, y1: number, x2: number, y2: number,
): number {
  return Math.atan2(y2 - y1, x2 - x1);
}

export function segmentWeight(
  angle: number,
  penAngleDeg: number,
  heavyW: number,
  lightW: number,
  contrast: number,
  wm?: number,
): number {
  const penRad = (penAngleDeg * Math.PI) / 180;
  const factor = Math.pow(Math.abs(Math.sin(angle - penRad)), contrast);
  const w = lightW + (heavyW - lightW) * factor;
  const m = wm ?? 1;
  return lightW + (w - lightW) * m;
}

NEMAWASHI — Kotaro Abe