Web CAD - complete TypeScript source (React 18 frontend, Node.js backend, CRDT collaboration, KI Copilot)
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
import type { CADElement } from '../types/cad.types';
|
||||
|
||||
/**
|
||||
* Dimension & Annotation Service — creates dimension, text, leader, revcloud elements.
|
||||
* Supports linear, angular, radial dimensions and multi-line text.
|
||||
*/
|
||||
|
||||
export type DimensionType = 'linear' | 'angular' | 'radial';
|
||||
|
||||
export interface DimensionConfig {
|
||||
type: DimensionType;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
unit: 'm' | 'cm' | 'mm';
|
||||
precision: number;
|
||||
}
|
||||
|
||||
export interface TextConfig {
|
||||
text: string;
|
||||
fontSize: number;
|
||||
rotation: number;
|
||||
multiline: boolean;
|
||||
align: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
export const DEFAULT_TEXT: TextConfig = {
|
||||
text: '',
|
||||
fontSize: 14,
|
||||
rotation: 0,
|
||||
multiline: false,
|
||||
align: 'left',
|
||||
};
|
||||
|
||||
export interface LeaderConfig {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
text: string;
|
||||
fontSize: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_LEADER: LeaderConfig = {
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 0,
|
||||
y2: 0,
|
||||
text: '',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
export interface RevCloudConfig {
|
||||
points: Array<{ x: number; y: number }>;
|
||||
arcHeight: number;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_REVCLOUD: RevCloudConfig = {
|
||||
points: [],
|
||||
arcHeight: 8,
|
||||
fill: 'none',
|
||||
stroke: '#e0e0e0',
|
||||
};
|
||||
|
||||
export class DimensionService {
|
||||
private generateId(): string {
|
||||
return `el_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
/** Create a linear dimension between two points */
|
||||
createLinearDimension(
|
||||
x1: number, y1: number, x2: number, y2: number,
|
||||
layerId: string, config: Partial<DimensionConfig> = {},
|
||||
): CADElement {
|
||||
const cfg = { type: 'linear' as DimensionType, x1, y1, x2, y2, offsetX: 0, offsetY: -20, unit: 'm' as const, precision: 2, ...config };
|
||||
const dist = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
||||
const mx = (x1 + x2) / 2 + cfg.offsetX;
|
||||
const my = (y1 + y2) / 2 + cfg.offsetY;
|
||||
const value = this.formatDistance(dist, cfg.unit, cfg.precision);
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'dimension',
|
||||
layerId,
|
||||
x: mx, y: my,
|
||||
width: dist, height: 20,
|
||||
properties: {
|
||||
x1: cfg.x1, y1: cfg.y1, x2: cfg.x2, y2: cfg.y2,
|
||||
offsetX: cfg.offsetX, offsetY: cfg.offsetY,
|
||||
dimType: 'linear',
|
||||
value,
|
||||
unit: cfg.unit,
|
||||
stroke: '#888',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create an angular dimension between three points (vertex, p1, p2) */
|
||||
createAngularDimension(
|
||||
vx: number, vy: number, x1: number, y1: number, x2: number, y2: number,
|
||||
layerId: string, config: Partial<DimensionConfig> = {},
|
||||
): CADElement {
|
||||
const cfg = { type: 'angular' as DimensionType, x1: vx, y1: vy, x2, y2, offsetX: 0, offsetY: -30, unit: 'deg' as any, precision: 1, ...config };
|
||||
const a1 = Math.atan2(y1 - vy, x1 - vx);
|
||||
const a2 = Math.atan2(y2 - vy, x2 - vx);
|
||||
let angle = Math.abs(a2 - a1) * 180 / Math.PI;
|
||||
if (angle > 180) angle = 360 - angle;
|
||||
const r = 30;
|
||||
const midAngle = (a1 + a2) / 2;
|
||||
const mx = vx + Math.cos(midAngle) * r;
|
||||
const my = vy + Math.sin(midAngle) * r;
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'dimension',
|
||||
layerId,
|
||||
x: mx, y: my,
|
||||
width: r * 2, height: r * 2,
|
||||
properties: {
|
||||
x1: vx, y1: vy, x2, y2,
|
||||
ax1: x1, ay1: y1, ax2: x2, ay2: y2,
|
||||
dimType: 'angular',
|
||||
value: `${angle.toFixed(cfg.precision)}°`,
|
||||
radius: r,
|
||||
stroke: '#888',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a radial dimension for a circle */
|
||||
createRadialDimension(
|
||||
cx: number, cy: number, radius: number,
|
||||
layerId: string, config: Partial<DimensionConfig> = {},
|
||||
): CADElement {
|
||||
const cfg = { type: 'radial' as DimensionType, x1: cx, y1: cy, x2: cx + radius, y2: cy, offsetX: 0, offsetY: 0, unit: 'm' as const, precision: 2, ...config };
|
||||
const value = `R ${this.formatDistance(radius, cfg.unit, cfg.precision)}`;
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'dimension',
|
||||
layerId,
|
||||
x: cx + radius / 2, y: cy - 15,
|
||||
width: radius, height: 20,
|
||||
properties: {
|
||||
x1: cx, y1: cy, x2: cx + radius, y2: cy,
|
||||
dimType: 'radial',
|
||||
value,
|
||||
unit: cfg.unit,
|
||||
stroke: '#888',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a text element (single or multi-line) */
|
||||
createText(x: number, y: number, layerId: string, config: Partial<TextConfig> = {}): CADElement {
|
||||
const cfg = { ...DEFAULT_TEXT, ...config };
|
||||
const lines = cfg.text.split('\n');
|
||||
const height = lines.length * cfg.fontSize * 1.2;
|
||||
const width = Math.max(...lines.map(l => l.length * cfg.fontSize * 0.6), 50);
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'text',
|
||||
layerId,
|
||||
x, y,
|
||||
width, height,
|
||||
properties: {
|
||||
text: cfg.text,
|
||||
fontSize: cfg.fontSize,
|
||||
rotation: cfg.rotation,
|
||||
multiline: cfg.multiline,
|
||||
align: cfg.align,
|
||||
stroke: '#e0e0e0',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a leader element (arrow + line + text) */
|
||||
createLeader(x1: number, y1: number, x2: number, y2: number, layerId: string, config: Partial<LeaderConfig> = {}): CADElement {
|
||||
const cfg = { ...DEFAULT_LEADER, x1, y1, x2, y2, ...config };
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'leader',
|
||||
layerId,
|
||||
x: cfg.x2, y: cfg.y2,
|
||||
width: Math.abs(cfg.x2 - cfg.x1),
|
||||
height: Math.abs(cfg.y2 - cfg.y1),
|
||||
properties: {
|
||||
x1: cfg.x1, y1: cfg.y1, x2: cfg.x2, y2: cfg.y2,
|
||||
text: cfg.text,
|
||||
fontSize: cfg.fontSize,
|
||||
stroke: '#e0e0e0',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a revision cloud from a polyline of points */
|
||||
createRevCloud(points: Array<{ x: number; y: number }>, layerId: string, config: Partial<RevCloudConfig> = {}): CADElement {
|
||||
const cfg = { ...DEFAULT_REVCLOUD, points, ...config };
|
||||
const xs = points.map(p => p.x);
|
||||
const ys = points.map(p => p.y);
|
||||
const minX = Math.min(...xs), maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys), maxY = Math.max(...ys);
|
||||
return {
|
||||
id: this.generateId(),
|
||||
type: 'revcloud',
|
||||
layerId,
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
properties: {
|
||||
points: cfg.points,
|
||||
arcHeight: cfg.arcHeight,
|
||||
fill: cfg.fill,
|
||||
stroke: cfg.stroke,
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Format a distance value with unit and precision */
|
||||
formatDistance(dist: number, unit: string, precision: number): string {
|
||||
let val = dist;
|
||||
let suffix = '';
|
||||
switch (unit) {
|
||||
case 'm': val = dist / 100; suffix = ' m'; break;
|
||||
case 'cm': val = dist / 10; suffix = ' cm'; break;
|
||||
case 'mm': val = dist; suffix = ' mm'; break;
|
||||
default: suffix = '';
|
||||
}
|
||||
return `${val.toFixed(precision)}${suffix}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user