← back to Dw War Room

src/showroom/Showroom.ts

207 lines

// Showroom.ts — Main photorealistic showroom compositor
// Ties together: FigmaTransformer + Materials + Environment + Layout + PostProcess
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { createMaterialLibrary, disposeMaterialLibrary } from './ShowroomMaterials';
import { createShowroomEnvMap } from './ShowroomEnvironment';
import { ShowroomLayout } from './ShowroomLayout';
import { ShowroomPostProcess } from './ShowroomPostProcess';
import { transformFigmaToScene } from './FigmaTransformer';
import type { SceneConfig } from './types';
import type { MaterialLibrary } from './ShowroomMaterials';

export class Showroom {
  scene: THREE.Scene;
  camera: THREE.PerspectiveCamera;
  renderer: THREE.WebGLRenderer;
  controls: OrbitControls;

  private clock: THREE.Clock;
  private _elapsed = 0;
  private _running = false;
  private _resizeHandler: (() => void) | null = null;
  private materials: MaterialLibrary;
  private layout: ShowroomLayout;
  private postProcess: ShowroomPostProcess;
  private dustParticles: THREE.Points;

  constructor(container: HTMLElement) {
    // Scene
    this.scene = new THREE.Scene();
    this.scene.background = new THREE.Color(0x0d0c0a);
    this.scene.fog = new THREE.Fog(0x0d0c0a, 20, 60);

    // Camera — human eye level
    this.camera = new THREE.PerspectiveCamera(
      55, container.clientWidth / container.clientHeight, 0.1, 100
    );
    this.camera.position.set(0, 1.7, 8);
    this.camera.lookAt(0, 1.5, 0);

    // Renderer — photorealistic pipeline
    this.renderer = new THREE.WebGLRenderer({ antialias: true });
    this.renderer.setSize(container.clientWidth, container.clientHeight);
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
    this.renderer.toneMappingExposure = 1.4;
    this.renderer.outputColorSpace = THREE.SRGBColorSpace;
    this.renderer.shadowMap.enabled = true;
    this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    container.appendChild(this.renderer.domElement);

    // Controls — walking speed
    this.controls = new OrbitControls(this.camera, this.renderer.domElement);
    this.controls.enableDamping = true;
    this.controls.dampingFactor = 0.08;
    this.controls.maxPolarAngle = Math.PI / 2;
    this.controls.minDistance = 1;
    this.controls.maxDistance = 50;

    // Clock
    this.clock = new THREE.Clock();

    // Materials
    this.materials = createMaterialLibrary();

    // Environment map (warm gallery IBL)
    this.scene.environment = createShowroomEnvMap(this.renderer);

    // Ambient + key lighting
    this.addBaseLighting();

    // Layout builder
    this.layout = new ShowroomLayout(this.scene, this.materials);

    // Atmospheric dust particles
    this.dustParticles = this.createDustMotes();
    this.scene.add(this.dustParticles);

    // Post-processing
    this.postProcess = new ShowroomPostProcess(this.renderer, this.scene, this.camera);

    // Resize — store reference for cleanup
    this._resizeHandler = () => this.onResize(container);
    window.addEventListener('resize', this._resizeHandler);
  }

  /**
   * Load showroom from Figma design data.
   */
  loadFromFigma(figmaData: any): void {
    const config = transformFigmaToScene(figmaData);
    this.layout.buildFromConfig(config);
  }

  /**
   * Load showroom from a pre-built SceneConfig.
   */
  loadFromConfig(config: SceneConfig): void {
    this.layout.buildFromConfig(config);
  }

  private addBaseLighting(): void {
    // Ambient — warm neutral
    const ambient = new THREE.AmbientLight(0x2a2520, 0.4);
    this.scene.add(ambient);

    // Hemisphere — sky/ground color contrast
    const hemi = new THREE.HemisphereLight(0xf5e6d0, 0x1a1510, 0.3);
    this.scene.add(hemi);

    // Main key light — warm directional
    const key = new THREE.DirectionalLight(0xf5e6d0, 0.8);
    key.position.set(5, 15, 10);
    key.castShadow = true;
    key.shadow.mapSize.set(2048, 2048);
    key.shadow.camera.near = 0.5;
    key.shadow.camera.far = 50;
    key.shadow.camera.left = -25;
    key.shadow.camera.right = 25;
    key.shadow.camera.top = 15;
    key.shadow.camera.bottom = -15;
    key.shadow.bias = -0.0003;
    this.scene.add(key);
  }

  private createDustMotes(): THREE.Points {
    const count = 200;
    const positions = new Float32Array(count * 3);
    for (let i = 0; i < count * 3; i += 3) {
      positions[i] = (Math.random() - 0.5) * 40;
      positions[i + 1] = Math.random() * 12;
      positions[i + 2] = (Math.random() - 0.5) * 20;
    }
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    const mat = new THREE.PointsMaterial({
      color: 0xf5e6d0,
      size: 0.02,
      transparent: true,
      opacity: 0.3,
      blending: THREE.AdditiveBlending,
      depthWrite: false,
    });
    return new THREE.Points(geo, mat);
  }

  animate(): void {
    if (!this._running) return;
    requestAnimationFrame(() => this.animate());
    // Use only getDelta() — getElapsedTime() internally calls getDelta() again,
    // which double-advances the clock and yields a near-zero second delta
    const dt = Math.min(this.clock.getDelta(), 0.1);
    this._elapsed += dt;
    const elapsed = this._elapsed;

    this.controls.update();

    // Animate dust motes
    const dustPos = this.dustParticles.geometry.attributes.position;
    for (let i = 0; i < dustPos.count; i++) {
      const y = dustPos.getY(i) + Math.sin(elapsed * 0.3 + i) * 0.001;
      dustPos.setY(i, y > 12 ? 0 : y);
    }
    dustPos.needsUpdate = true;

    // Update wing visibility based on camera distance
    this.layout.updateVisibility(this.camera.position);

    // Update DOF focus to orbit target distance
    const focusDist = this.camera.position.distanceTo(this.controls.target);
    this.postProcess.setFocusDistance(focusDist);

    // Render through post-processing pipeline
    this.postProcess.render();
  }

  private onResize(container: HTMLElement): void {
    const w = container.clientWidth;
    const h = container.clientHeight;
    this.camera.aspect = w / h;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(w, h);
    this.postProcess.resize(w, h);
  }

  start(): void {
    this._running = true;
    this.clock.start();
    this.animate();
  }

  dispose(): void {
    this._running = false;
    if (this._resizeHandler) {
      window.removeEventListener('resize', this._resizeHandler);
      this._resizeHandler = null;
    }
    this.postProcess.dispose();
    this.layout.dispose();
    disposeMaterialLibrary(this.materials);
    this.dustParticles.geometry.dispose();
    (this.dustParticles.material as THREE.PointsMaterial).dispose();
    this.controls.dispose();
    this.renderer.dispose();
  }
}