← back to Watches

public/ar-viewer.js

535 lines

/**
 * AR Try-On Viewer for Watches
 * Uses device camera and hand tracking for virtual watch try-on
 * Supports both marker-based and markerless AR
 */

class ARViewer {
  constructor() {
    this.isSupported = false;
    this.isActive = false;
    this.videoElement = null;
    this.canvasElement = null;
    this.stream = null;
    this.watchModel = null;
    this.handPosition = { x: 0, y: 0, rotation: 0, detected: false };
    this.animationFrame = null;
    this.checkSupport();
  }

  /**
   * Check if AR is supported
   */
  async checkSupport() {
    // Check for getUserMedia
    const hasCamera = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);

    // Check for WebGL (needed for 3D rendering)
    const canvas = document.createElement('canvas');
    const hasWebGL = !!(
      canvas.getContext('webgl') ||
      canvas.getContext('experimental-webgl')
    );

    // Check for WebXR (future support)
    const hasWebXR = !!(navigator.xr);

    this.isSupported = hasCamera && hasWebGL;

    console.log('[ARViewer] Support check:', {
      camera: hasCamera,
      webGL: hasWebGL,
      webXR: hasWebXR,
      supported: this.isSupported
    });

    return this.isSupported;
  }

  /**
   * Initialize AR viewer
   */
  async init(videoId, canvasId) {
    if (!this.isSupported) {
      throw new Error('AR not supported on this device');
    }

    this.videoElement = document.getElementById(videoId);
    this.canvasElement = document.getElementById(canvasId);

    if (!this.videoElement || !this.canvasElement) {
      throw new Error('Video or canvas element not found');
    }

    console.log('[ARViewer] Initialized');
    return this;
  }

  /**
   * Start AR session
   */
  async start(watchId) {
    try {
      // Load watch 3D model
      await this.loadWatchModel(watchId);

      // Start camera
      this.stream = await navigator.mediaDevices.getUserMedia({
        video: {
          facingMode: 'user', // Front camera for wrist view
          width: { ideal: 1280 },
          height: { ideal: 720 }
        }
      });

      this.videoElement.srcObject = this.stream;
      await this.videoElement.play();

      // Set canvas size
      this.canvasElement.width = this.videoElement.videoWidth;
      this.canvasElement.height = this.videoElement.videoHeight;

      this.isActive = true;

      // Start rendering loop
      this.renderLoop();

      console.log('[ARViewer] AR session started');

      return { success: true };

    } catch (error) {
      console.error('[ARViewer] Failed to start:', error);
      return { success: false, error: error.message };
    }
  }

  /**
   * Stop AR session
   */
  stop() {
    this.isActive = false;

    if (this.animationFrame) {
      cancelAnimationFrame(this.animationFrame);
      this.animationFrame = null;
    }

    if (this.stream) {
      this.stream.getTracks().forEach(track => track.stop());
      this.stream = null;
    }

    console.log('[ARViewer] AR session stopped');
  }

  /**
   * Load watch 3D model
   */
  async loadWatchModel(watchId) {
    try {
      // In production, load actual GLTF/GLB model
      // For now, create a simple representation
      const response = await fetch(`/api/watches/${watchId}`);
      const watchData = await response.json();

      this.watchModel = {
        id: watchId,
        name: watchData.watch?.model || 'Omega Watch',
        series: watchData.watch?.series,
        // In production, this would be the 3D model data
        modelUrl: `/models/watches/${watchId}.glb`,
        texture: `/textures/watches/${watchId}.jpg`,
        size: 42, // mm diameter
        thickness: 12 // mm
      };

      console.log('[ARViewer] Watch model loaded:', this.watchModel.name);

      return this.watchModel;

    } catch (error) {
      console.error('[ARViewer] Failed to load model:', error);

      // Create fallback model
      this.watchModel = {
        id: watchId,
        name: 'Watch',
        size: 42,
        thickness: 12
      };
    }
  }

  /**
   * Main rendering loop
   */
  renderLoop() {
    if (!this.isActive) return;

    const ctx = this.canvasElement.getContext('2d');

    // Draw video frame
    ctx.drawImage(
      this.videoElement,
      0, 0,
      this.canvasElement.width,
      this.canvasElement.height
    );

    // Detect hand/wrist
    this.detectHand(ctx);

    // Render watch if hand detected
    if (this.handPosition.detected) {
      this.renderWatch(ctx);
    } else {
      // Show instructions
      this.showInstructions(ctx);
    }

    // Continue loop
    this.animationFrame = requestAnimationFrame(() => this.renderLoop());
  }

  /**
   * Detect hand/wrist in frame
   * In production, use TensorFlow.js HandPose or MediaPipe
   */
  detectHand(ctx) {
    // Simplified hand detection (in production, use ML model)
    // For now, use center of frame as default position

    const imageData = ctx.getImageData(0, 0, this.canvasElement.width, this.canvasElement.height);

    // Simple skin tone detection (very basic)
    const wristPosition = this.findWrist(imageData);

    if (wristPosition) {
      this.handPosition = {
        x: wristPosition.x,
        y: wristPosition.y,
        rotation: wristPosition.rotation || 0,
        detected: true,
        confidence: wristPosition.confidence || 0.5
      };
    } else {
      // Default to center if no detection
      this.handPosition = {
        x: this.canvasElement.width / 2,
        y: this.canvasElement.height / 2,
        rotation: 0,
        detected: false
      };
    }
  }

  /**
   * Find wrist position (simplified)
   */
  findWrist(imageData) {
    // In production, use proper ML model
    // For now, return null to show instructions
    return null;
  }

  /**
   * Render watch on wrist
   */
  renderWatch(ctx) {
    const { x, y, rotation } = this.handPosition;

    // Calculate watch size based on distance (simplified)
    const watchRadius = 80; // pixels (in production, calculate from hand size)

    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(rotation);

    // Draw watch case (circular)
    ctx.beginPath();
    ctx.arc(0, 0, watchRadius, 0, Math.PI * 2);
    ctx.fillStyle = '#333';
    ctx.fill();
    ctx.strokeStyle = '#888';
    ctx.lineWidth = 3;
    ctx.stroke();

    // Draw watch face
    ctx.beginPath();
    ctx.arc(0, 0, watchRadius - 8, 0, Math.PI * 2);
    ctx.fillStyle = '#000';
    ctx.fill();

    // Draw Omega logo (simplified)
    ctx.fillStyle = '#fff';
    ctx.font = '16px serif';
    ctx.textAlign = 'center';
    ctx.fillText('Ω', 0, -20);

    // Draw model name
    ctx.font = '10px Arial';
    ctx.fillText(this.watchModel.name.substring(0, 20), 0, 20);

    // Draw hour markers
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 2;
    for (let i = 0; i < 12; i++) {
      const angle = (i * 30 - 90) * Math.PI / 180;
      const innerRadius = watchRadius - 20;
      const outerRadius = watchRadius - 12;

      ctx.beginPath();
      ctx.moveTo(
        Math.cos(angle) * innerRadius,
        Math.sin(angle) * innerRadius
      );
      ctx.lineTo(
        Math.cos(angle) * outerRadius,
        Math.sin(angle) * outerRadius
      );
      ctx.stroke();
    }

    // Draw hands (showing current time)
    const now = new Date();
    const hours = now.getHours() % 12;
    const minutes = now.getMinutes();
    const seconds = now.getSeconds();

    // Hour hand
    const hourAngle = ((hours + minutes / 60) * 30 - 90) * Math.PI / 180;
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(
      Math.cos(hourAngle) * (watchRadius - 40),
      Math.sin(hourAngle) * (watchRadius - 40)
    );
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 4;
    ctx.stroke();

    // Minute hand
    const minuteAngle = ((minutes + seconds / 60) * 6 - 90) * Math.PI / 180;
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(
      Math.cos(minuteAngle) * (watchRadius - 25),
      Math.sin(minuteAngle) * (watchRadius - 25)
    );
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 3;
    ctx.stroke();

    // Second hand
    const secondAngle = (seconds * 6 - 90) * Math.PI / 180;
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(
      Math.cos(secondAngle) * (watchRadius - 15),
      Math.sin(secondAngle) * (watchRadius - 15)
    );
    ctx.strokeStyle = '#c41e3a'; // Omega red
    ctx.lineWidth = 1;
    ctx.stroke();

    // Center dot
    ctx.beginPath();
    ctx.arc(0, 0, 4, 0, Math.PI * 2);
    ctx.fillStyle = '#c41e3a';
    ctx.fill();

    // Draw watch band
    this.drawWatchBand(ctx, watchRadius);

    ctx.restore();

    // Show confidence indicator
    if (this.handPosition.confidence) {
      ctx.fillStyle = 'rgba(0, 255, 0, 0.5)';
      ctx.fillRect(10, 10, 200 * this.handPosition.confidence, 10);
      ctx.strokeStyle = '#0f0';
      ctx.strokeRect(10, 10, 200, 10);
    }
  }

  /**
   * Draw watch band
   */
  drawWatchBand(ctx, watchRadius) {
    const bandWidth = 40;
    const bandLength = 120;

    // Top band
    ctx.fillStyle = '#222';
    ctx.fillRect(-bandWidth / 2, -watchRadius - bandLength, bandWidth, bandLength);

    // Bottom band
    ctx.fillRect(-bandWidth / 2, watchRadius, bandWidth, bandLength);

    // Band details (stitching)
    ctx.strokeStyle = '#444';
    ctx.lineWidth = 1;
    for (let i = 0; i < bandLength; i += 10) {
      ctx.beginPath();
      ctx.moveTo(-bandWidth / 2 + 5, -watchRadius - i);
      ctx.lineTo(bandWidth / 2 - 5, -watchRadius - i);
      ctx.stroke();

      ctx.beginPath();
      ctx.moveTo(-bandWidth / 2 + 5, watchRadius + i);
      ctx.lineTo(bandWidth / 2 - 5, watchRadius + i);
      ctx.stroke();
    }
  }

  /**
   * Show instructions overlay
   */
  showInstructions(ctx) {
    ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
    ctx.fillRect(0, this.canvasElement.height - 100, this.canvasElement.width, 100);

    ctx.fillStyle = '#fff';
    ctx.font = 'bold 20px Arial';
    ctx.textAlign = 'center';
    ctx.fillText(
      'Position your wrist in front of the camera',
      this.canvasElement.width / 2,
      this.canvasElement.height - 60
    );

    ctx.font = '16px Arial';
    ctx.fillText(
      'The watch will appear on your wrist automatically',
      this.canvasElement.width / 2,
      this.canvasElement.height - 30
    );
  }

  /**
   * Take screenshot of AR view
   */
  async takeScreenshot() {
    if (!this.isActive) {
      return null;
    }

    const imageData = this.canvasElement.toDataURL('image/jpeg', 0.9);

    // Save to IndexedDB
    const db = await this.openDB();
    const tx = db.transaction('arScreenshots', 'readwrite');
    const store = tx.objectStore('arScreenshots');

    const screenshot = {
      timestamp: Date.now(),
      watchId: this.watchModel.id,
      watchName: this.watchModel.name,
      imageData: imageData
    };

    return new Promise((resolve, reject) => {
      const request = store.add(screenshot);
      request.onsuccess = () => resolve(imageData);
      request.onerror = () => reject(request.error);
    });
  }

  /**
   * Get AR screenshots
   */
  async getScreenshots() {
    const db = await this.openDB();
    const tx = db.transaction('arScreenshots', 'readonly');
    const store = tx.objectStore('arScreenshots');

    return new Promise((resolve, reject) => {
      const request = store.getAll();
      request.onsuccess = () => resolve(request.result.reverse());
      request.onerror = () => reject(request.error);
    });
  }

  /**
   * Enable manual positioning mode
   */
  enableManualMode() {
    this.handPosition.detected = true;
    console.log('[ARViewer] Manual mode enabled');
  }

  /**
   * Update manual position
   */
  updatePosition(x, y, rotation = 0) {
    this.handPosition = {
      x: x,
      y: y,
      rotation: rotation,
      detected: true,
      confidence: 1.0
    };
  }

  /**
   * Open IndexedDB
   */
  openDB() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open('OmegaWatchesPWA', 2);

      request.onerror = () => reject(request.error);
      request.onsuccess = () => resolve(request.result);

      request.onupgradeneeded = (event) => {
        const db = event.target.result;

        if (!db.objectStoreNames.contains('arScreenshots')) {
          const store = db.createObjectStore('arScreenshots', {
            keyPath: 'id',
            autoIncrement: true
          });
          store.createIndex('timestamp', 'timestamp', { unique: false });
          store.createIndex('watchId', 'watchId', { unique: false });
        }
      };
    });
  }

  /**
   * Switch to WebXR mode (if available)
   */
  async startWebXR() {
    if (!navigator.xr) {
      console.warn('[ARViewer] WebXR not supported');
      return false;
    }

    try {
      const supported = await navigator.xr.isSessionSupported('immersive-ar');

      if (supported) {
        const session = await navigator.xr.requestSession('immersive-ar', {
          requiredFeatures: ['hit-test'],
          optionalFeatures: ['dom-overlay']
        });

        console.log('[ARViewer] WebXR session started');
        return true;
      } else {
        console.warn('[ARViewer] Immersive AR not supported');
        return false;
      }
    } catch (error) {
      console.error('[ARViewer] WebXR error:', error);
      return false;
    }
  }
}

// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
  module.exports = ARViewer;
}