← back to Dw War Room

src/showroom/ShowroomLayout.ts

344 lines

// ShowroomLayout.ts — Builds the photorealistic showroom corridor and wing bays
import * as THREE from 'three';
import type { SceneConfig, WingConfig, WallConfig, LightConfig } from './types';
import type { MaterialLibrary } from './ShowroomMaterials';

export class ShowroomLayout {
  private scene: THREE.Scene;
  private materials: MaterialLibrary;
  private corridorGroup: THREE.Group;
  private wingGroups: THREE.Group[] = [];
  private spotLights: THREE.SpotLight[] = [];

  constructor(scene: THREE.Scene, materials: MaterialLibrary) {
    this.scene = scene;
    this.materials = materials;
    this.corridorGroup = new THREE.Group();
    this.corridorGroup.name = 'showroom-corridor';
    scene.add(this.corridorGroup);
  }

  /**
   * Build the showroom from a SceneConfig (output of FigmaTransformer).
   */
  buildFromConfig(config: SceneConfig): void {
    this.buildCorridor(config.corridor);
    this.buildWings(config.wings);
    this.buildWallDisplays(config.walls);
    this.buildLighting(config.lights);
    this.buildFurniture(config.furniture);
    this.buildEntry(config.entry);
  }

  private buildCorridor(corridor: SceneConfig['corridor']): void {
    const { width, depth, height } = corridor;
    const halfW = width / 2;
    const halfD = depth / 2;

    // Floor — polished concrete with normal map
    const floorGeo = new THREE.PlaneGeometry(width, depth, 1, 1);
    const floor = new THREE.Mesh(floorGeo, this.materials.concrete);
    floor.rotation.x = -Math.PI / 2;
    floor.receiveShadow = true;
    this.corridorGroup.add(floor);

    // Left wall — exposed brick
    const leftWallGeo = new THREE.PlaneGeometry(depth, height);
    const leftWall = new THREE.Mesh(leftWallGeo, this.materials.brick);
    leftWall.position.set(-halfW, height / 2, 0);
    leftWall.rotation.y = Math.PI / 2;
    leftWall.receiveShadow = true;
    this.corridorGroup.add(leftWall);

    // Right wall — exposed brick
    const rightWall = new THREE.Mesh(leftWallGeo, this.materials.brick);
    rightWall.position.set(halfW, height / 2, 0);
    rightWall.rotation.y = -Math.PI / 2;
    rightWall.receiveShadow = true;
    this.corridorGroup.add(rightWall);

    // Back wall — dark charcoal
    const backWallGeo = new THREE.PlaneGeometry(width, height);
    const backWallMat = this.materials.steel.clone();
    backWallMat.color.setHex(0x1a1a1a);
    const backWall = new THREE.Mesh(backWallGeo, backWallMat);
    backWall.position.set(0, height / 2, -halfD);
    backWall.receiveShadow = true;
    this.corridorGroup.add(backWall);

    // Ceiling — steel with exposed duct geometry
    const ceilGeo = new THREE.PlaneGeometry(width, depth);
    const ceiling = new THREE.Mesh(ceilGeo, this.materials.steel);
    ceiling.rotation.x = Math.PI / 2;
    ceiling.position.y = height;
    this.corridorGroup.add(ceiling);

    // Exposed ducts — cylindrical pipes along ceiling
    const ductMat = this.materials.steel.clone();
    ductMat.metalness = 0.5;
    for (let i = 0; i < 3; i++) {
      const ductGeo = new THREE.CylinderGeometry(0.15, 0.15, depth, 8);
      const duct = new THREE.Mesh(ductGeo, ductMat);
      duct.rotation.x = Math.PI / 2;
      duct.position.set(-halfW / 2 + i * (halfW / 2), height - 0.3, 0);
      this.corridorGroup.add(duct);
    }

    // Baseboards — warm wood trim
    const baseGeo = new THREE.BoxGeometry(depth, 0.15, 0.04);
    for (const xPos of [-halfW + 0.02, halfW - 0.02]) {
      const base = new THREE.Mesh(baseGeo, this.materials.wood);
      base.position.set(xPos, 0.075, 0);
      base.rotation.y = Math.PI / 2;
      this.corridorGroup.add(base);
    }
  }

  private buildWings(wings: WingConfig[]): void {
    wings.forEach((wingConfig, i) => {
      const group = new THREE.Group();
      group.name = `wing-${i}-${wingConfig.vendor}`;
      group.userData = { vendor: wingConfig.vendor, index: i, loaded: false };

      // Wing entrance frame — brushed aluminum
      const frameGeo = new THREE.BoxGeometry(0.08, 3.5, 0.08);
      const leftFrame = new THREE.Mesh(frameGeo, this.materials.aluminum);
      leftFrame.position.set(-1.5, 1.75, 0);
      group.add(leftFrame);

      const rightFrame = new THREE.Mesh(frameGeo, this.materials.aluminum);
      rightFrame.position.set(1.5, 1.75, 0);
      group.add(rightFrame);

      // Header bar
      const headerGeo = new THREE.BoxGeometry(3.08, 0.08, 0.08);
      const header = new THREE.Mesh(headerGeo, this.materials.aluminum);
      header.position.set(0, 3.5, 0);
      group.add(header);

      // Vendor label (canvas texture)
      const labelCanvas = document.createElement('canvas');
      labelCanvas.width = 512;
      labelCanvas.height = 64;
      const ctx = labelCanvas.getContext('2d');
      if (ctx) {
        ctx.fillStyle = '#1a1a1a';
        ctx.fillRect(0, 0, 512, 64);
        ctx.fillStyle = '#f0ebe4';
        ctx.font = 'bold 28px Inter, -apple-system, sans-serif';
        ctx.textAlign = 'center';
        ctx.fillText(wingConfig.vendor.toUpperCase(), 256, 42);
      }
      const labelTex = new THREE.CanvasTexture(labelCanvas);
      const labelGeo = new THREE.PlaneGeometry(2.5, 0.3);
      const labelMat = new THREE.MeshBasicMaterial({ map: labelTex });
      const label = new THREE.Mesh(labelGeo, labelMat);
      label.position.set(0, 3.7, 0.01);
      group.add(label);

      // Position the wing in the corridor
      group.position.set(
        wingConfig.position[0],
        wingConfig.position[1],
        wingConfig.position[2]
      );
      group.rotation.y = wingConfig.rotation;

      this.wingGroups.push(group);
      this.corridorGroup.add(group);
    });
  }

  private buildWallDisplays(walls: WallConfig[]): void {
    walls.forEach(wall => {
      if (wall.hasSamplePanels) {
        // Large mounted sample panel
        const panelGeo = new THREE.PlaneGeometry(wall.dimensions[0], wall.dimensions[1]);
        const panel = new THREE.Mesh(panelGeo, this.materials.fabric);
        panel.position.set(wall.position[0], wall.position[1], wall.position[2]);
        this.corridorGroup.add(panel);

        // Aluminum frame around panel
        const frameThickness = 0.03;
        const frameDepth = 0.04;
        const frameMat = this.materials.aluminum;

        // Top frame
        const topGeo = new THREE.BoxGeometry(wall.dimensions[0] + frameThickness * 2, frameThickness, frameDepth);
        const top = new THREE.Mesh(topGeo, frameMat);
        top.position.set(wall.position[0], wall.position[1] + wall.dimensions[1] / 2, wall.position[2] + 0.02);
        this.corridorGroup.add(top);

        // Bottom frame
        const bottom = top.clone();
        bottom.position.y = wall.position[1] - wall.dimensions[1] / 2;
        this.corridorGroup.add(bottom);
      }

      if (wall.hasBookshelf) {
        // Glass-front bookshelf
        const shelfGeo = new THREE.BoxGeometry(wall.dimensions[0], wall.dimensions[1], 0.3);
        const shelf = new THREE.Mesh(shelfGeo, this.materials.wood);
        shelf.position.set(wall.position[0], wall.position[1], wall.position[2]);
        this.corridorGroup.add(shelf);

        // Glass front
        const glassGeo = new THREE.PlaneGeometry(wall.dimensions[0] - 0.05, wall.dimensions[1] - 0.05);
        const glass = new THREE.Mesh(glassGeo, this.materials.glass);
        glass.position.set(wall.position[0], wall.position[1], wall.position[2] + 0.16);
        this.corridorGroup.add(glass);
      }
    });
  }

  private buildLighting(lights: LightConfig[]): void {
    lights.forEach(lightConfig => {
      if (lightConfig.type === 'spot') {
        const spot = new THREE.SpotLight(
          new THREE.Color(lightConfig.color),
          lightConfig.intensity,
          30,
          lightConfig.angle || Math.PI / 6,
          lightConfig.penumbra || 0.4,
          1
        );
        spot.position.set(lightConfig.position[0], lightConfig.position[1], lightConfig.position[2]);
        if (lightConfig.target) {
          spot.target.position.set(lightConfig.target[0], lightConfig.target[1], lightConfig.target[2]);
          this.scene.add(spot.target);
        }
        spot.castShadow = true;
        spot.shadow.mapSize.set(1024, 1024);
        this.spotLights.push(spot);
        this.corridorGroup.add(spot);

        // Track light housing (small chrome cylinder)
        const housingGeo = new THREE.CylinderGeometry(0.08, 0.12, 0.15, 8);
        const housing = new THREE.Mesh(housingGeo, this.materials.trackLight);
        housing.position.set(lightConfig.position[0], lightConfig.position[1], lightConfig.position[2]);
        this.corridorGroup.add(housing);

        // Volumetric light cone (subtle)
        const coneH = lightConfig.position[1] - (lightConfig.target?.[1] || 0);
        const coneR = Math.tan(lightConfig.angle || Math.PI / 6) * coneH;
        const coneGeo = new THREE.ConeGeometry(coneR, coneH, 16, 1, true);
        const coneMat = new THREE.MeshBasicMaterial({
          color: lightConfig.color,
          transparent: true,
          opacity: 0.015,
          blending: THREE.AdditiveBlending,
          side: THREE.DoubleSide,
          depthWrite: false,
        });
        const cone = new THREE.Mesh(coneGeo, coneMat);
        cone.position.set(
          lightConfig.position[0],
          lightConfig.position[1] - coneH / 2,
          lightConfig.position[2]
        );
        this.corridorGroup.add(cone);
      }
    });

    // Track rails (continuous aluminum rail along ceiling)
    const railGeo = new THREE.BoxGeometry(40, 0.03, 0.05);
    const rail = new THREE.Mesh(railGeo, this.materials.aluminum);
    rail.position.set(0, 11.5, 0);
    this.corridorGroup.add(rail);
  }

  private buildFurniture(furniture: SceneConfig['furniture']): void {
    furniture.forEach(item => {
      if (item.type === 'display-table') {
        const tableGeo = new THREE.BoxGeometry(item.dimensions[0], item.dimensions[1], item.dimensions[2]);
        const table = new THREE.Mesh(tableGeo, this.materials.wood);
        table.position.set(item.position[0], item.position[1], item.position[2]);
        table.castShadow = true;
        this.corridorGroup.add(table);
      }
    });
  }

  private buildEntry(entry: SceneConfig['entry']): void {
    // Entry signage
    const signCanvas = document.createElement('canvas');
    signCanvas.width = 1024;
    signCanvas.height = 128;
    const ctx = signCanvas.getContext('2d');
    if (ctx) {
      ctx.fillStyle = '#1a1a1a';
      ctx.fillRect(0, 0, 1024, 128);
      ctx.fillStyle = '#f0ebe4';
      ctx.font = 'bold 36px Inter, -apple-system, sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText(entry.signText, 512, 70);
      ctx.font = '18px Inter, -apple-system, sans-serif';
      ctx.fillStyle = '#888888';
      ctx.fillText('ARCHITECTURAL TRADE SHOWROOM', 512, 105);
    }

    const signTex = new THREE.CanvasTexture(signCanvas);
    const signGeo = new THREE.PlaneGeometry(6, 0.75);
    const signMat = new THREE.MeshStandardMaterial({
      map: signTex,
      emissive: new THREE.Color(0xffffff),
      emissiveMap: signTex,
      emissiveIntensity: 0.3,
    });
    const sign = new THREE.Mesh(signGeo, signMat);
    sign.position.set(entry.position[0], 10, entry.position[2]);
    this.corridorGroup.add(sign);
  }

  /**
   * Get wing groups for culling/visibility management.
   */
  getWings(): THREE.Group[] {
    return this.wingGroups;
  }

  /**
   * Set visibility of wings by distance from camera for performance.
   */
  updateVisibility(cameraPosition: THREE.Vector3, maxDistance = 30): void {
    this.wingGroups.forEach(wing => {
      const dist = wing.position.distanceTo(cameraPosition);
      wing.visible = dist < maxDistance;
    });
  }

  dispose(): void {
    // Dispose spotlight targets from scene root
    this.spotLights.forEach(spot => {
      if (spot.target.parent) {
        spot.target.parent.remove(spot.target);
      }
      spot.dispose();
    });

    this.corridorGroup.traverse(child => {
      if (child instanceof THREE.Mesh) {
        child.geometry.dispose();
        // Dispose material (handles cloned materials and canvas textures)
        const mat = child.material;
        if (Array.isArray(mat)) {
          mat.forEach(m => {
            if (m.map) m.map.dispose();
            if (m.emissiveMap) m.emissiveMap.dispose();
            if (m.normalMap) m.normalMap.dispose();
            m.dispose();
          });
        } else {
          if (mat.map) mat.map.dispose();
          if ((mat as any).emissiveMap) (mat as any).emissiveMap.dispose();
          if ((mat as any).normalMap) (mat as any).normalMap.dispose();
          mat.dispose();
        }
      }
    });
    this.scene.remove(this.corridorGroup);
    this.wingGroups.length = 0;
    this.spotLights.length = 0;
  }
}