250 lines
7.1 KiB
TypeScript
250 lines
7.1 KiB
TypeScript
|
|
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;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|