NEMAWASHI LAB

Extrude

Canvas

Each character is drawn to a canvas multiple times at increasing offsets along a user-chosen angle, building a pseudo-3D extrusion. The depth layers split at a midpoint -- the back half gets the shadow colour, the front half gets the face colour -- and the topmost copy is filled with the background colour plus an optional outline stroke, creating a hollow-face-on-solid-body look. A per-character pseudo-random rotation (seeded from the character index via sin hashing) gives the block letters a tumbled, hand-set feel.

Extrude

depth 24 · 105°

Controls
Font size120
Depth24
Angle105
Char rotation4
Letter spacing2
Text
Font
Face
Shadow
Outline
Background

Source

Extrude.tsx145 lines
"use client";

import { useEffect, useRef, useState } from "react";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelColor, PanelSelect, PanelToggle, PanelText } from "../_kansei/controls";
import { resolveFont, type FontChoice } from "../_kansei/fonts";

export default function Extrude() {
  const [text, setText] = useState("BREAD");
  const [fontSize, setFontSize] = useState(120);
  const [font, setFont] = useState<FontChoice>("sans");
  const [depth, setDepth] = useState(24);
  const [angle, setAngle] = useState(105);
  const [charRotation, setCharRotation] = useState(4);
  const [letterSpacing, setLetterSpacing] = useState(2);
  const [showOutline, setShowOutline] = useState(true);
  const [textColor, setTextColor] = useState("#f0a030");
  const [shadowColor, setShadowColor] = useState("#b05808");
  const [outlineColor, setOutlineColor] = useState("#884400");
  const [bgColor, setBgColor] = useState("#f4efe6");
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    function draw() {
      if (!canvas) return;
      const ctx = canvas.getContext("2d");
      if (!ctx) return;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const W = 560;
      const H = 300;
      canvas.width = W * dpr;
      canvas.height = H * dpr;
      canvas.style.width = `${W}px`;
      canvas.style.height = `${H}px`;
      ctx.scale(dpr, dpr);
      ctx.clearRect(0, 0, W, H);

      const fontFamily = resolveFont(font);
      const fontStyle = font === "display" ? "italic " : "";
      ctx.font = `${fontStyle}900 ${fontSize}px ${fontFamily}`;
      ctx.textBaseline = "middle";

      const rad = (angle * Math.PI) / 180;
      const dx = Math.cos(rad);
      const dy = Math.sin(rad);
      const cx = W / 2;
      const cy = H / 2;
      const maxRotRad = (charRotation * Math.PI) / 180;

      ctx.textAlign = "left";
      const chars = text.split("");
      let charWidths = chars.map((ch) => ctx.measureText(ch).width);
      let totalW = charWidths.reduce((s, w) => s + w, 0) + letterSpacing * Math.max(0, chars.length - 1);
      if (totalW > W * 0.9) {
        const shrunk = Math.floor(fontSize * ((W * 0.9) / totalW));
        ctx.font = `${fontStyle}900 ${shrunk}px ${fontFamily}`;
        charWidths = chars.map((ch) => ctx.measureText(ch).width);
        totalW = charWidths.reduce((s, w) => s + w, 0) + letterSpacing * Math.max(0, chars.length - 1);
      }
      let x = cx - totalW / 2;

      chars.forEach((ch, i) => {
        const cw = charWidths[i];
        const charCx = x + cw / 2;
        const rot = Math.sin(i * 127.1 + 311.7) * maxRotRad;
        ctx.save();
        ctx.translate(charCx, cy);
        ctx.rotate(rot);
        ctx.textAlign = "center";

        const splitAt = Math.floor(depth * 0.5);
        ctx.fillStyle = shadowColor;
        for (let j = depth; j > splitAt; j--) ctx.fillText(ch, Math.round(dx * j), Math.round(dy * j));
        ctx.fillStyle = textColor;
        for (let j = splitAt; j >= 1; j--) ctx.fillText(ch, Math.round(dx * j), Math.round(dy * j));

        ctx.fillStyle = bgColor;
        ctx.fillText(ch, 0, 0);
        ctx.strokeStyle = bgColor;
        ctx.lineWidth = 2;
        ctx.lineJoin = "round";
        ctx.strokeText(ch, 0, 0);

        if (showOutline) {
          ctx.strokeStyle = outlineColor;
          ctx.lineWidth = 1.5;
          ctx.lineJoin = "round";
          ctx.strokeText(ch, 0, 0);
        }
        ctx.restore();
        x += cw + letterSpacing;
      });
    }

    document.fonts.ready.then(draw);
  }, [text, fontSize, font, depth, angle, charRotation, letterSpacing, showOutline, textColor, shadowColor, outlineColor, bgColor]);

  return (
    <div className="fixed inset-0 flex items-center justify-center select-none" style={{ background: bgColor }}>
      <canvas ref={canvasRef} className="z-[1]" />

      <div
        className="fixed bottom-8 left-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 text-foreground/50">Extrude</p>
        <p className="text-[12px] tracking-[0.08em] mt-1 text-foreground/30">depth {depth} · {angle}°</p>
      </div>

      <DraggablePanel
        isLight={false}
        controls={[
          { label: "Font size", value: fontSize, set: (v) => setFontSize(Math.round(v)), min: 48, max: 200, step: 1 },
          { label: "Depth", value: depth, set: (v) => setDepth(Math.round(v)), min: 2, max: 60, step: 1 },
          { label: "Angle", value: angle, set: (v) => setAngle(Math.round(v)), min: 0, max: 360, step: 1 },
          { label: "Char rotation", value: charRotation, set: (v) => setCharRotation(Math.round(v)), min: 0, max: 30, step: 1 },
          { label: "Letter spacing", value: letterSpacing, set: (v) => setLetterSpacing(Math.round(v)), min: -10, max: 30, step: 1 },
        ]}
      >
        <PanelText label="Text" value={text} set={setText} placeholder="BREAD" isLight={false} />
        <PanelSelect label="Font" value={font} options={["display", "sans", "noto"] as const} set={setFont} isLight={false} />
        <PanelToggle label="Outline" value={showOutline} set={setShowOutline} isLight={false} />
        <PanelColor label="Face" value={textColor} set={setTextColor} isLight={false} />
        <PanelColor label="Shadow" value={shadowColor} set={setShadowColor} isLight={false} />
        <PanelColor label="Outline" value={outlineColor} set={setOutlineColor} isLight={false} />
        <PanelColor label="Background" value={bgColor} set={setBgColor} isLight={false} />
      </DraggablePanel>

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

NEMAWASHI — Kotaro Abe