NEMAWASHI LAB

Bricks

OTF

Each glyph column from the shared bitmap font is subdivided into configurable sub-rows, then span-detected horizontally so continuous runs of filled cells become a single brick. A running-bond stagger offsets alternate sub-rows by half a cell width, and mortar gaps are enforced by insetting each brick's left and right edges. Corner radius and per-brick randomised radius variation (seeded PRNG) let it range from a clean pixel grid to a rough masonry wall. The whole thing is pure SVG rects -- no canvas, no WebGL.

Source

BricksCanvas.tsx579 lines
"use client";

import { useMemo, useState, useCallback, useEffect, useRef } from "react";
import { useSearchParams } from "next/navigation";
import { motion } from "framer-motion";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import exportBricksFont from "./exportBricksFont";
import { FONT, ROWS, SPACE_COLS, LETTER_GAP_COLS } from "../alphabet";

const VB = 1000;

function srand(seed: number): number {
  const s = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
  return s - Math.floor(s);
}

interface Composition {
  name: string;
  text: string;
  bg: string;
  brickColor: string;
  uiColor: "light" | "dark";
  cellW: number;
  mortar: number;
  brickH: number;
  subRows: number;
  cornerRadius: number;
  stagger: number;
  vary: number;
}

const compositions: Composition[] = [
  {
    name: "GIRAGIRA",
    text: "GIRAGIRA",
    bg: "#f0eeeb",
    brickColor: "#0a1a6b",
    uiColor: "dark",
    cellW: 22,
    mortar: 3,
    brickH: 9,
    subRows: 3,
    cornerRadius: 0,
    stagger: 0,
    vary: 0,
  },
  {
    name: "BRICKS",
    text: "BRICKS",
    bg: "#f5ede0",
    brickColor: "#b5432a",
    uiColor: "dark",
    cellW: 28,
    mortar: 3.5,
    brickH: 7.5,
    subRows: 4,
    cornerRadius: 3,
    stagger: 0.5,
    vary: 1,
  },
  {
    name: "GIRAGIRA LAB",
    text: "GIRAGIRA LAB",
    bg: "#e8e6e2",
    brickColor: "#2a2a2a",
    uiColor: "dark",
    cellW: 14,
    mortar: 2,
    brickH: 10,
    subRows: 2,
    cornerRadius: 0,
    stagger: 0,
    vary: 0,
  },
  {
    name: "GIRAGIRA / NIGHT",
    text: "GIRAGIRA",
    bg: "#0e0e10",
    brickColor: "#f0eeeb",
    uiColor: "light",
    cellW: 22,
    mortar: 3,
    brickH: 9,
    subRows: 3,
    cornerRadius: 0,
    stagger: 0,
    vary: 0,
  },
  {
    name: "A — R",
    text: "ABCDEFGHI\nJKLMNOPQR",
    bg: "#f0eeeb",
    brickColor: "#0a1a6b",
    uiColor: "dark",
    cellW: 14,
    mortar: 2,
    brickH: 9,
    subRows: 2,
    cornerRadius: 0,
    stagger: 0,
    vary: 0,
  },
  {
    name: "S — Z",
    text: "STUVWXYZ",
    bg: "#f0eeeb",
    brickColor: "#0a1a6b",
    uiColor: "dark",
    cellW: 14,
    mortar: 2,
    brickH: 9,
    subRows: 2,
    cornerRadius: 0,
    stagger: 0,
    vary: 0,
  },
  {
    name: "a — r",
    text: "abcdefghi\njklmnopqr",
    bg: "#f0eeeb",
    brickColor: "#0a1a6b",
    uiColor: "dark",
    cellW: 14,
    mortar: 2,
    brickH: 9,
    subRows: 2,
    cornerRadius: 0,
    stagger: 0,
    vary: 0,
  },
  {
    name: "s — z",
    text: "stuvwxyz",
    bg: "#f0eeeb",
    brickColor: "#0a1a6b",
    uiColor: "dark",
    cellW: 14,
    mortar: 2,
    brickH: 9,
    subRows: 2,
    cornerRadius: 0,
    stagger: 0,
    vary: 0,
  },
  {
    name: "0123456789",
    text: "0123456789\n.,!?-':;",
    bg: "#f0eeeb",
    brickColor: "#0a1a6b",
    uiColor: "dark",
    cellW: 14,
    mortar: 2,
    brickH: 9,
    subRows: 2,
    cornerRadius: 0,
    stagger: 0,
    vary: 0,
  },
];

// ---------------------------------------------------------------------------
// Layout — span-based brick laying with running bond stagger
// ---------------------------------------------------------------------------

function lineCols(lineText: string): number {
  let cols = 0;
  for (let i = 0; i < lineText.length; i++) {
    const ch = lineText[i];
    if (ch === " ") cols += SPACE_COLS;
    else {
      const glyph = FONT[ch];
      if (glyph) cols += glyph[0].length;
    }
    if (i < lineText.length - 1) cols += LETTER_GAP_COLS;
  }
  return cols;
}

interface Brick {
  x: number;
  y: number;
  w: number;
  h: number;
  rx: number;
}

function layout(comp: Composition, vbH: number): Brick[] {
  const sr = Math.max(1, Math.round(comp.subRows));
  const vPitch = comp.brickH + comp.mortar;
  const rowPitch = sr * vPitch;
  const staggerOff = comp.stagger * comp.cellW;

  const lines = comp.text.split("\n");
  const lineH = ROWS * rowPitch;
  const lineGap = rowPitch;
  const totalH = lines.length * lineH + (lines.length - 1) * lineGap;
  const baseOy = (vbH - totalH) / 2;

  const bricks: Brick[] = [];
  let brickIdx = 0;

  for (let li = 0; li < lines.length; li++) {
    const lineText = lines[li];
    const totalW = lineCols(lineText) * comp.cellW;
    const ox = (VB - totalW) / 2;
    const oy = baseOy + li * (lineH + lineGap);

    let colCursor = 0;
    for (let i = 0; i < lineText.length; i++) {
      const ch = lineText[i];
      if (ch === " ") {
        colCursor += SPACE_COLS;
        if (i < lineText.length - 1) colCursor += LETTER_GAP_COLS;
        continue;
      }
      const glyph = FONT[ch];
      if (!glyph) {
        colCursor += LETTER_GAP_COLS;
        continue;
      }
      const gW = glyph[0].length;

      for (let r = 0; r < ROWS; r++) {
        const row = glyph[r];
        let spanStart = -1;
        for (let c = 0; c <= gW; c++) {
          const filled = c < gW && row[c] === "#";
          if (filled && spanStart < 0) spanStart = c;
          if (!filled && spanStart >= 0) {
            const spanLeft = ox + (colCursor + spanStart) * comp.cellW;
            const spanRight = ox + (colCursor + c) * comp.cellW;

            for (let s = 0; s < sr; s++) {
              const globalRow = r * sr + s;
              const y = oy + globalRow * vPitch;
              const origin = ox + (globalRow % 2 === 1 ? staggerOff : 0);
              const firstN = Math.floor((spanLeft - origin) / comp.cellW) - 1;
              const lastN = Math.ceil((spanRight - origin) / comp.cellW);

              for (let n = firstN; n <= lastN; n++) {
                const bLeft = origin + n * comp.cellW + comp.mortar / 2;
                const bRight =
                  origin + (n + 1) * comp.cellW - comp.mortar / 2;
                const left = Math.max(bLeft, spanLeft + comp.mortar / 2);
                const right = Math.min(bRight, spanRight - comp.mortar / 2);

                if (right - left > 0.5) {
                  const rxVar =
                    comp.cornerRadius > 0
                      ? comp.cornerRadius *
                        (1 + comp.vary * (srand(brickIdx) - 0.5) * 1.2)
                      : 0;
                  bricks.push({
                    x: left,
                    y,
                    w: right - left,
                    h: comp.brickH,
                    rx: rxVar,
                  });
                  brickIdx++;
                }
              }
            }
            spanStart = -1;
          }
        }
      }

      colCursor += gW;
      if (i < lineText.length - 1) colCursor += LETTER_GAP_COLS;
    }
  }

  return bricks;
}

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

export default function BricksCanvas() {
  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/bricks?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 [brickH, setBrickH] = useState(comp.brickH);
  const [mortar, setMortar] = useState(comp.mortar);
  const [subRows, setSubRows] = useState(comp.subRows);
  const [cornerRadius, setCornerRadius] = useState(comp.cornerRadius);
  const [stagger, setStagger] = useState(comp.stagger);
  const [vary, setVary] = useState(comp.vary);
  const [previewFamily, setPreviewFamily] = useState<string | null>(null);
  const [customText, setCustomText] = useState("");

  const activeComp = useMemo(
    () => ({
      ...comp,
      text: customText || comp.text,
      brickH,
      mortar,
      subRows: Math.round(subRows),
      cornerRadius,
      stagger,
      vary,
    }),
    [comp, customText, brickH, mortar, subRows, cornerRadius, stagger, vary],
  );

  const bricks = useMemo(() => layout(activeComp, vbH), [activeComp, vbH]);

  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"
      >
        <g fill={comp.brickColor}>
          {bricks.map((b, i) => (
            <rect
              key={i}
              x={b.x}
              y={b.y}
              width={b.w}
              height={b.h}
              rx={b.rx}
              ry={b.rx}
            />
          ))}
        </g>
      </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: "Brick H",
            value: brickH,
            set: setBrickH,
            min: 3,
            max: 20,
            step: 0.5,
          },
          {
            label: "Mortar",
            value: mortar,
            set: setMortar,
            min: 0,
            max: 10,
            step: 0.5,
          },
          {
            label: "Sub-rows",
            value: subRows,
            set: setSubRows,
            min: 1,
            max: 6,
            step: 1,
          },
          {
            label: "Radius",
            value: cornerRadius,
            set: setCornerRadius,
            min: 0,
            max: 8,
            step: 0.5,
          },
          {
            label: "Stagger",
            value: stagger,
            set: setStagger,
            min: 0,
            max: 0.5,
            step: 0.5,
          },
          {
            label: "Vary",
            value: vary,
            set: setVary,
            min: 0,
            max: 1,
            step: 0.05,
          },
        ]}
      >
        <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(
              exportBricksFont(
                comp.cellW,
                brickH,
                mortar,
                subRows,
                cornerRadius,
                stagger,
                vary,
              ),
            )
          }
          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: 32,
              color: isLight ? "#fff" : "#1a1a2e",
              letterSpacing: "0.05em",
            }}
          >
            AaBbCcDd 0123456789 .,!?
          </p>
        </div>
      )}
    </div>
  );
}
exportBricksFont.ts115 lines
import { Glyph, Path } from "opentype.js";
import { UPM, createFont, downloadFont, previewFont, roundedRectToPath } from "../fontExport";
import { FONT, ROWS, SPACE_COLS } from "../alphabet";

function srand(seed: number): number {
  const s = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
  return s - Math.floor(s);
}

export default function exportBricksFont(
  cellW: number,
  brickH: number,
  mortar: number,
  subRows: number,
  cornerRadius: number,
  stagger: number,
  vary: number,
) {
  const sr = Math.max(1, Math.round(subRows));
  const vPitch = brickH + mortar;
  const totalCanvasH = ROWS * sr * vPitch;
  const SCALE = UPM / totalCanvasH;

  const fCellW = cellW * SCALE;
  const fBrickH = brickH * SCALE;
  const fMortar = mortar * SCALE;
  const fVPitch = fBrickH + fMortar;
  const fRadius = cornerRadius * SCALE;
  const fStagger = stagger * fCellW;

  const otGlyphs: Glyph[] = [];
  let brickIdx = 0;

  for (const [char, rows] of Object.entries(FONT)) {
    const code = char.charCodeAt(0);
    const glyphW = rows[0].length;
    const advanceWidth = glyphW * fCellW + fMortar;
    const path = new Path();

    for (let r = 0; r < ROWS; r++) {
      const row = rows[r];
      let spanStart = -1;

      for (let c = 0; c <= glyphW; c++) {
        const filled = c < glyphW && row[c] === "#";
        if (filled && spanStart < 0) spanStart = c;
        if (!filled && spanStart >= 0) {
          const spanLeft = spanStart * fCellW;
          const spanRight = c * fCellW;

          for (let s = 0; s < sr; s++) {
            const globalRow = r * sr + s;
            const fontY = UPM - globalRow * fVPitch - fBrickH;
            const origin = globalRow % 2 === 1 ? fStagger : 0;
            const firstN = Math.floor((spanLeft - origin) / fCellW) - 1;
            const lastN = Math.ceil((spanRight - origin) / fCellW);

            for (let n = firstN; n <= lastN; n++) {
              const bLeft = origin + n * fCellW + fMortar / 2;
              const bRight = origin + (n + 1) * fCellW - fMortar / 2;
              const left = Math.max(bLeft, spanLeft + fMortar / 2);
              const right = Math.min(bRight, spanRight - fMortar / 2);

              if (right - left > 0.5) {
                const rx =
                  fRadius > 0
                    ? fRadius * (1 + vary * (srand(brickIdx) - 0.5) * 1.2)
                    : 0;
                roundedRectToPath(
                  path,
                  left,
                  fontY,
                  right - left,
                  fBrickH,
                  Math.max(0, rx),
                );
                brickIdx++;
              }
            }
          }
          spanStart = -1;
        }
      }
    }

    otGlyphs.push(
      new Glyph({
        name:
          char.length === 1
            ? char
            : `uni${code.toString(16).toUpperCase().padStart(4, "0")}`,
        unicode: code,
        advanceWidth,
        path,
      }),
    );
  }

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

  const s = stagger > 0 ? 1 : 0;
  const v = Math.round(vary * 10);
  const filename = `GRGR_Bricks_h${brickH}_m${mortar}_sr${sr}_r${cornerRadius}_s${s}_v${v}.otf`;
  const font = createFont("GRGR Bricks", otGlyphs);
  downloadFont(font, filename);
  return previewFont(font, "GRGR-Bricks-Preview");
}

NEMAWASHI — Kotaro Abe