feat: initial commit web-cad-neu with docker-compose, frontend and backend
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user