Files
web-cad/frontend/src/services/backgroundService.ts
T

250 lines
7.1 KiB
TypeScript
Raw Normal View History

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<BackgroundConfig> {
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<BackgroundConfig> {
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>): 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<ProjectData['background']>): 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<string> {
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;
});
}
}