← back to Dw War Room

src/showroom/FigmaTransformer.ts

129 lines

// FigmaTransformer.ts — Converts Figma design context into Three.js scene graph config
// Maps Figma frames → positions, rectangles → geometry, fills → materials
import type { SceneConfig, WingConfig, WallConfig, LightConfig, FurnitureConfig } from './types';

/**
 * Transform Figma design JSON into Three.js scene configuration.
 * Accepts output from Figma MCP `get_design_context` or raw Figma REST API.
 */
export function transformFigmaToScene(figmaData: any): SceneConfig {
  const root = figmaData.document || figmaData;
  const config: SceneConfig = {
    corridor: { width: 40, depth: 20, height: 12, wallMaterial: 'brick', floorMaterial: 'polished-concrete' },
    wings: [],
    walls: [],
    lights: [],
    furniture: [],
    entry: { position: [0, 0, 10], signText: 'DESIGNER WALLCOVERINGS' },
  };

  // Traverse Figma node tree
  function traverse(node: any, depth = 0) {
    if (!node) return;

    // Map FRAME nodes to wings or rooms based on name
    if (node.type === 'FRAME' && node.name?.toLowerCase().includes('wing')) {
      const bbox = node.absoluteBoundingBox || {};
      config.wings.push({
        index: config.wings.length,
        vendor: node.name.replace(/wing[-_\s]*/i, '').trim(),
        position: [bbox.x / 10 || config.wings.length * 4, 0, bbox.y / 10 || 0],
        rotation: 0,
        panelCount: (node.children || []).filter((c: any) => c.type === 'RECTANGLE').length || 10,
        accentColor: extractFillColor(node) || '#c0c0c0',
      });
    }

    // Map RECTANGLE nodes with IMAGE fills to wall sample panels
    if (node.type === 'RECTANGLE' && node.fills?.some((f: any) => f.type === 'IMAGE')) {
      const bbox = node.absoluteBoundingBox || {};
      config.walls.push({
        position: [bbox.x / 10 || 0, bbox.height / 20 || 2, bbox.y / 10 || 0],
        dimensions: [bbox.width / 10 || 4, bbox.height / 10 || 8],
        material: 'brick',
        hasBookshelf: false,
        hasSamplePanels: true,
        sampleCount: 1,
      });
    }

    // Map circles/ellipses to spotlights
    if (node.type === 'ELLIPSE' && node.name?.toLowerCase().includes('light')) {
      const bbox = node.absoluteBoundingBox || {};
      config.lights.push({
        type: 'spot',
        position: [bbox.x / 10 || 0, 10, bbox.y / 10 || 0],
        target: [bbox.x / 10 || 0, 0, bbox.y / 10 || 0],
        color: extractFillColor(node) || '#ffffff',
        intensity: 1.5,
        angle: Math.PI / 6,
        penumbra: 0.5,
      });
    }

    // Map FRAME named 'furniture' or 'table' to furniture
    if (node.type === 'FRAME' && /table|desk|furniture/i.test(node.name || '')) {
      const bbox = node.absoluteBoundingBox || {};
      config.furniture.push({
        type: 'display-table',
        position: [bbox.x / 10 || 0, 0.45, bbox.y / 10 || 0],
        dimensions: [bbox.width / 10 || 3, 0.9, (bbox.height || 150) / 10],
      });
    }

    // Map FRAME named 'entry' or 'entrance' to entry
    if (node.type === 'FRAME' && /entry|entrance|door/i.test(node.name || '')) {
      const bbox = node.absoluteBoundingBox || {};
      config.entry.position = [bbox.x / 10 || 0, 0, bbox.y / 10 || 10];
      // Look for text child as sign text
      const textChild = (node.children || []).find((c: any) => c.type === 'TEXT');
      if (textChild?.characters) {
        config.entry.signText = textChild.characters;
      }
    }

    // Map RECTANGLE with SOLID fills named 'bookshelf' or 'shelf'
    if (node.type === 'RECTANGLE' && /bookshelf|shelf|book/i.test(node.name || '')) {
      const bbox = node.absoluteBoundingBox || {};
      config.walls.push({
        position: [bbox.x / 10 || 0, bbox.height / 20 || 2, bbox.y / 10 || 0],
        dimensions: [bbox.width / 10 || 4, bbox.height / 10 || 6],
        material: 'wood',
        hasBookshelf: true,
        hasSamplePanels: false,
        sampleCount: 0,
      });
    }

    if (node.children) node.children.forEach((c: any) => traverse(c, depth + 1));
  }

  traverse(root);

  // Add default track lighting if none extracted
  if (config.lights.length === 0) {
    for (let i = 0; i < 8; i++) {
      config.lights.push({
        type: 'spot',
        position: [i * 5 - 17.5, 11, 0],
        target: [i * 5 - 17.5, 0, 0],
        color: '#f5e6d0',
        intensity: 1.2,
        angle: Math.PI / 5,
        penumbra: 0.4,
      });
    }
  }

  return config;
}

function extractFillColor(node: any): string | null {
  const fill = (node.fills || []).find((f: any) => f.type === 'SOLID' && f.visible !== false);
  if (!fill?.color) return null;
  const r = Math.round(fill.color.r * 255);
  const g = Math.round(fill.color.g * 255);
  const b = Math.round(fill.color.b * 255);
  return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}