import type { ProjectData } from '../types/cad.types'; /** * Background Service — manages background image loading, positioning, scaling, and calibration. * Supports PNG, JPG, SVG as background images. */ export interface BackgroundConfig { src: string; // data URL or path to image name: string; // original file name format: 'png' | 'jpg' | 'svg' | 'pdf'; width: number; // natural image width in pixels height: number; // natural image height in pixels scale: number; // pixels per world-unit (e.g. 1px = 0.01m → scale=100) offsetX: number; // world X offset offsetY: number; // world Y offset rotation: number; // rotation in degrees visible: boolean; opacity: number; // 0-1 } export const DEFAULT_BACKGROUND: BackgroundConfig = { src: '', name: '', format: 'png', width: 0, height: 0, scale: 1, offsetX: 0, offsetY: 0, rotation: 0, visible: true, opacity: 0.5, }; export interface CalibrationResult { scale: number; // computed pixels per world-unit unit: string; // 'm' | 'cm' | 'mm' } export class BackgroundService { private image: HTMLImageElement | null = null; private config: BackgroundConfig = { ...DEFAULT_BACKGROUND }; /** Load an image file and return its config */ async loadFromFile(file: File): Promise { const format = this.detectFormat(file); const src = await this.fileToDataURL(file); const { width, height } = await this.getImageDimensions(src); this.config = { ...DEFAULT_BACKGROUND, src, name: file.name, format, width, height, }; this.image = new Image(); this.image.src = src; return this.config; } /** Load from a URL or data string */ async loadFromSrc(src: string, name: string = 'background'): Promise { const { width, height } = await this.getImageDimensions(src); const format = this.detectFormatFromSrc(src); this.config = { ...DEFAULT_BACKGROUND, src, name, format, width, height, }; this.image = new Image(); this.image.src = src; return this.config; } /** Get the current background config */ getConfig(): BackgroundConfig { return { ...this.config }; } /** Update background config */ updateConfig(partial: Partial): BackgroundConfig { this.config = { ...this.config, ...partial }; return this.getConfig(); } /** Set visibility */ setVisible(visible: boolean): void { this.config.visible = visible; } /** Set opacity (0-1) */ setOpacity(opacity: number): void { this.config.opacity = Math.max(0, Math.min(1, opacity)); } /** Move background by delta */ move(dx: number, dy: number): void { this.config.offsetX += dx; this.config.offsetY += dy; } /** Set position directly */ setPosition(x: number, y: number): void { this.config.offsetX = x; this.config.offsetY = y; } /** Rotate background by delta degrees */ rotate(deltaAngle: number): void { this.config.rotation += deltaAngle; } /** Set rotation directly */ setRotation(angle: number): void { this.config.rotation = angle; } /** Scale background by factor */ scaleBy(factor: number): void { this.config.scale *= factor; } /** Set scale directly */ setScale(scale: number): void { this.config.scale = Math.max(0.001, scale); } /** Calibrate scale using a reference distance * @param pixelDistance - measured distance in pixels between two points on the image * @param realDistance - known real-world distance * @param unit - unit of realDistance ('m', 'cm', 'mm') * @returns computed scale (pixels per world-unit) */ calibrateScale(pixelDistance: number, realDistance: number, unit: string = 'm'): CalibrationResult { if (realDistance <= 0 || pixelDistance <= 0) { return { scale: this.config.scale, unit }; } // Convert realDistance to base unit (mm) let realMm = realDistance; switch (unit) { case 'm': realMm = realDistance * 1000; break; case 'cm': realMm = realDistance * 10; break; case 'mm': realMm = realDistance; break; } // scale = pixels per mm const scale = pixelDistance / realMm; this.config.scale = scale; return { scale, unit }; } /** Get the loaded HTMLImageElement for rendering */ getImage(): HTMLImageElement | null { return this.image; } /** Check if a background is loaded */ isLoaded(): boolean { return this.config.src !== '' && this.image !== null; } /** Clear the background */ clear(): void { this.config = { ...DEFAULT_BACKGROUND }; this.image = null; } /** Export to ProjectData.background format */ toProjectData(): ProjectData['background'] | undefined { if (!this.isLoaded()) return undefined; return { src: this.config.src, scale: this.config.scale, offsetX: this.config.offsetX, offsetY: this.config.offsetY, rotation: this.config.rotation, }; } /** Import from ProjectData.background format */ fromProjectData(bg: NonNullable): void { this.config = { ...DEFAULT_BACKGROUND, src: bg.src, scale: bg.scale, offsetX: bg.offsetX, offsetY: bg.offsetY, rotation: bg.rotation, }; this.image = new Image(); this.image.src = bg.src; } // --- Private helpers --- private detectFormat(file: File): BackgroundConfig['format'] { const type = file.type.toLowerCase(); if (type.includes('png')) return 'png'; if (type.includes('jpeg') || type.includes('jpg')) return 'jpg'; if (type.includes('svg')) return 'svg'; if (type.includes('pdf')) return 'pdf'; const ext = file.name.split('.').pop()?.toLowerCase() ?? ''; if (ext === 'png') return 'png'; if (ext === 'jpg' || ext === 'jpeg') return 'jpg'; if (ext === 'svg') return 'svg'; if (ext === 'pdf') return 'pdf'; return 'png'; } private detectFormatFromSrc(src: string): BackgroundConfig['format'] { if (src.startsWith('data:image/png')) return 'png'; if (src.startsWith('data:image/jpeg') || src.startsWith('data:image/jpg')) return 'jpg'; if (src.startsWith('data:image/svg')) return 'svg'; if (src.startsWith('data:application/pdf')) return 'pdf'; if (src.endsWith('.png')) return 'png'; if (src.endsWith('.jpg') || src.endsWith('.jpeg')) return 'jpg'; if (src.endsWith('.svg')) return 'svg'; if (src.endsWith('.pdf')) return 'pdf'; return 'png'; } private fileToDataURL(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(file); }); } private getImageDimensions(src: string): Promise<{ width: number; height: number }> { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); img.onerror = reject; img.src = src; }); } }