← 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;
}