← back to Dw War Room

src/showroom/TextureCrop.ts

160 lines

// TextureCrop.ts — Center-crop images to 512x768 with white-border trimming
// Loads image → trims white borders → center-crops to 2:3 aspect → returns CanvasTexture
import * as THREE from 'three';

const CANVAS_W = 512;
const CANVAS_H = 768;
const WHITE_THRESHOLD = 240;  // RGB channels above this = "white"
const SCAN_DEPTH = 80;        // Max pixels inward to scan for border

/**
 * Detect how many pixels of white border exist on each edge.
 * Scans rows/columns from outside in, stops when a non-white row/col is found.
 */
function detectWhiteBorders(
  data: Uint8ClampedArray,
  w: number,
  h: number
): { top: number; bottom: number; left: number; right: number } {
  const isWhitePixel = (x: number, y: number): boolean => {
    const i = (y * w + x) * 4;
    return data[i] > WHITE_THRESHOLD
      && data[i + 1] > WHITE_THRESHOLD
      && data[i + 2] > WHITE_THRESHOLD;
  };

  // Check if an entire row is white (sample every 4th pixel for speed)
  const isWhiteRow = (y: number): boolean => {
    for (let x = 0; x < w; x += 4) {
      if (!isWhitePixel(x, y)) return false;
    }
    return true;
  };

  // Check if an entire column is white
  const isWhiteCol = (x: number): boolean => {
    for (let y = 0; y < h; y += 4) {
      if (!isWhitePixel(x, y)) return false;
    }
    return true;
  };

  let top = 0;
  while (top < Math.min(SCAN_DEPTH, h / 3) && isWhiteRow(top)) top++;

  let bottom = 0;
  while (bottom < Math.min(SCAN_DEPTH, h / 3) && isWhiteRow(h - 1 - bottom)) bottom++;

  let left = 0;
  while (left < Math.min(SCAN_DEPTH, w / 3) && isWhiteCol(left)) left++;

  let right = 0;
  while (right < Math.min(SCAN_DEPTH, w / 3) && isWhiteCol(w - 1 - right)) right++;

  return { top, bottom, left, right };
}

/**
 * Load an image URL, trim white borders, center-crop to 512x768 canvas.
 * Returns a Promise<THREE.CanvasTexture>.
 */
export function cropTexture(imageUrl: string): Promise<THREE.CanvasTexture> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = 'anonymous';

    img.onload = () => {
      // Step 1: Draw full image to a scratch canvas for pixel analysis
      const scratch = document.createElement('canvas');
      scratch.width = img.naturalWidth;
      scratch.height = img.naturalHeight;
      const sCtx = scratch.getContext('2d')!;
      sCtx.drawImage(img, 0, 0);

      const imgData = sCtx.getImageData(0, 0, scratch.width, scratch.height);
      const borders = detectWhiteBorders(imgData.data, scratch.width, scratch.height);

      // Step 2: Compute content rect (after trimming white borders)
      const contentX = borders.left;
      const contentY = borders.top;
      const contentW = scratch.width - borders.left - borders.right;
      const contentH = scratch.height - borders.top - borders.bottom;

      if (contentW <= 0 || contentH <= 0) {
        // Entire image is white — use full image as fallback
        const fallback = createFallbackTexture(imageUrl);
        resolve(fallback);
        return;
      }

      // Step 3: Center-crop the content rect to 2:3 aspect ratio
      const targetAspect = CANVAS_W / CANVAS_H; // 0.667
      const contentAspect = contentW / contentH;

      let srcX: number, srcY: number, srcW: number, srcH: number;

      if (contentAspect > targetAspect) {
        // Content is wider than target — crop sides
        srcH = contentH;
        srcW = Math.round(contentH * targetAspect);
        srcX = contentX + Math.round((contentW - srcW) / 2);
        srcY = contentY;
      } else {
        // Content is taller than target — crop top/bottom
        srcW = contentW;
        srcH = Math.round(contentW / targetAspect);
        srcX = contentX;
        srcY = contentY + Math.round((contentH - srcH) / 2);
      }

      // Step 4: Draw cropped region onto final 512x768 canvas
      const canvas = document.createElement('canvas');
      canvas.width = CANVAS_W;
      canvas.height = CANVAS_H;
      const ctx = canvas.getContext('2d')!;

      // Fill with warm gallery bg in case of rounding gaps
      ctx.fillStyle = '#f0ebe4';
      ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);

      ctx.drawImage(
        img,
        srcX, srcY, srcW, srcH,  // source rect (cropped)
        0, 0, CANVAS_W, CANVAS_H // dest rect (fill canvas)
      );

      const texture = new THREE.CanvasTexture(canvas);
      texture.colorSpace = THREE.SRGBColorSpace;
      texture.generateMipmaps = true;
      texture.minFilter = THREE.LinearMipmapLinearFilter;
      texture.magFilter = THREE.LinearFilter;

      resolve(texture);
    };

    img.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`));
    img.src = imageUrl;
  });
}

/**
 * Fallback: warm canvas with a subtle message (for all-white or broken images).
 */
function createFallbackTexture(label: string): THREE.CanvasTexture {
  const canvas = document.createElement('canvas');
  canvas.width = CANVAS_W;
  canvas.height = CANVAS_H;
  const ctx = canvas.getContext('2d')!;

  ctx.fillStyle = '#f0ebe4';
  ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
  ctx.fillStyle = '#888888';
  ctx.font = '18px Inter, -apple-system, sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText('Image unavailable', CANVAS_W / 2, CANVAS_H / 2);

  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  return texture;
}