Files
web-cad/frontend/src/plugins/builtin/core-drawing/index.ts
T

677 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* core-drawing Built-in V2-Plugin (Task A3 Pilot).
*
* Werkzeuge: line (Migrations-Pilot aus dem Legacy-Switch).
* Elemente werden über CADDocument committed → kollaborativ korrektes Undo.
*
* Sitzungszustand (Startpunkt der aktuellen Linie) lebt als Modul-Member,
* NICHT in options: Options sind geteilter UI-Store-Zustand.
*/
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
import type { CADElement } from '../../../types/cad.types';
import type { Pt } from '../../../tools/modification/geometry';
const uid = (prefix: string): string =>
`${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
/** Startpunkt der laufenden Linie (nur innerhalb einer down→up-Sitzung gesetzt). */
let lineStart: Pt | null = null;
/** Startpunkt des laufenden Rechtecks (A4.2). */
let rectStart: Pt | null = null;
/** Zentrum des laufenden Kreises (A4.3). */
let circleCenter: Pt | null = null;
/** Laufender Bogen: Phase 0=nichts, 1=Zentrum gesetzt, 2=Radius+Startwinkel gesetzt. */
let arcPhase = 0;
let arcCenter: Pt | null = null;
let arcStartPt: Pt | null = null;
/** Laufende Polyline/Polygon-Punkte; Commit per ENTER. */
let polyPoints: Pt[] = [];
/** Laufende RevCloud-Punkte (A4.5); Commit per ENTER. */
let cloudPoints: Pt[] = [];
function optsOf(ctx: FullToolContext): { stroke: string; layerId: string } {
return {
stroke: (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0',
layerId: (ctx.options.layerId as string | undefined) ?? 'layer-0',
};
}
function buildLine(start: Pt, end: Pt, ctx: FullToolContext): CADElement {
const stroke = (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0';
const layerId = (ctx.options.layerId as string | undefined) ?? 'layer-0';
return {
id: uid('line'),
type: 'line',
layerId,
x: (start.x + end.x) / 2,
y: (start.y + end.y) / 2,
width: Math.abs(end.x - start.x),
height: Math.abs(end.y - start.y),
properties: { x1: start.x, y1: start.y, x2: end.x, y2: end.y, stroke, strokeWidth: 1 },
} as unknown as CADElement;
}
function buildRect(cornerA: Pt, cornerB: Pt, ctx: FullToolContext): CADElement {
const stroke = (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0';
const layerId = (ctx.options.layerId as string | undefined) ?? 'layer-0';
const minX = Math.min(cornerA.x, cornerB.x);
const minY = Math.min(cornerA.y, cornerB.y);
const w = Math.abs(cornerB.x - cornerA.x);
const h = Math.abs(cornerB.y - cornerA.y);
return {
id: uid('rect'),
type: 'rect',
layerId,
x: minX + w / 2, // BBox-Zentrum (Legacy-Konvention)
y: minY + h / 2,
width: w,
height: h,
properties: { stroke, strokeWidth: 1 },
} as unknown as CADElement;
}
/**
* line-Werkzeug: down setzt Startpunkt, move zeigt Live-Vorschau,
* up committet via doc.transact (1 Linie = 1 Undo-Schritt).
*/
export const lineTool: ToolExtensionV2 = {
manifest: {
id: 'line',
label: 'Linie',
icon: '\u2571',
ribbonTab: 'canvas',
tags: ['basic'],
description: 'Zeichnet eine Linie (Klick Start, Klick Ende)',
},
optionsSchema: [
{ key: 'strokeColor', label: 'Farbe', type: 'color' },
],
handlers: {
down(e, ctx) {
lineStart = e.world;
ctx.setStatus(`Linie ab (${lineStart.x.toFixed(0)}, ${lineStart.y.toFixed(0)}) Ende wählen`);
},
move(e, ctx) {
if (!lineStart) return;
ctx.setPreview?.(buildLine(lineStart, e.world, ctx));
},
up(e, ctx) {
const c = ctx as FullToolContext;
if (!lineStart || !c.doc) {
lineStart = null;
return;
}
const el = buildLine(lineStart, e.world, c);
c.doc.transact(() => {
// addElement öffnet inneres transact — Yjs verschachtelt sie → EIN Undo-Schritt
c.doc!.addElement(el);
});
lineStart = null;
ctx.setPreview?.(null);
ctx.setStatus(`Linie erstellt (${el.id})`);
},
cancel(ctx) {
lineStart = null;
ctx.setPreview?.(null);
ctx.setStatus('Linie abgebrochen');
},
},
};
/**
* rect-Werkzeug (A4.2): down setzt Ecke, move zeigt BBox-Vorschau,
* up committet via doc.transact. Portiert aus Legacy case 'rect'
* (BBox-Konvention: x/y = Zentrum, width/height absolut).
*/
export const rectTool: ToolExtensionV2 = {
manifest: {
id: 'rect',
label: 'Rechteck',
icon: '\u25ad',
ribbonTab: 'canvas',
tags: ['basic'],
description: 'Zieht ein Rechteck auf (Klick Ecke, Klick Gegenecke)',
},
optionsSchema: [
{ key: 'strokeColor', label: 'Farbe', type: 'color' },
],
handlers: {
down(e, ctx) {
rectStart = e.world;
ctx.setStatus(`Rechteck ab (${rectStart.x.toFixed(0)}, ${rectStart.y.toFixed(0)}) Gegenecke wählen`);
},
move(e, ctx) {
if (!rectStart) return;
ctx.setPreview?.(buildRect(rectStart, e.world, ctx));
},
up(e, ctx) {
const c = ctx as FullToolContext;
if (!rectStart || !c.doc) {
rectStart = null;
return;
}
const el = buildRect(rectStart, e.world, c);
c.doc.transact(() => {
c.doc!.addElement(el);
});
rectStart = null;
ctx.setPreview?.(null);
ctx.setStatus(`Rechteck erstellt (${el.id})`);
},
cancel(ctx) {
rectStart = null;
ctx.setPreview?.(null);
ctx.setStatus('Rechteck abgebrochen');
},
},
};
/**
* circle-Werkzeug (A4.3): Zentrum→Radius wie Legacy (Radius = Distanz).
*/
export const circleTool: ToolExtensionV2 = {
manifest: {
id: 'circle', label: 'Kreis', icon: '\u25cb', ribbonTab: 'canvas', tags: ['basic'],
description: 'Kreis: Zentrum klicken, Radius ziehen',
},
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
handlers: {
down(e, ctx) { circleCenter = e.world; ctx.setStatus('Kreiszentrum gesetzt Radius ziehen'); },
move(e, ctx) {
if (!circleCenter) return;
const r = Math.hypot(e.world.x - circleCenter.x, e.world.y - circleCenter.y);
ctx.setPreview?.(buildCircle(circleCenter, r, ctx));
},
up(e, ctx) {
const c = ctx as FullToolContext;
if (!circleCenter || !c.doc) { circleCenter = null; return; }
const r = Math.hypot(e.world.x - circleCenter.x, e.world.y - circleCenter.y);
const el = buildCircle(circleCenter, r, c);
c.doc.transact(() => c.doc!.addElement(el));
circleCenter = null;
ctx.setPreview?.(null);
ctx.setStatus(`Kreis erstellt (${el.id})`);
},
cancel(ctx) { circleCenter = null; ctx.setPreview?.(null); ctx.setStatus('Kreis abgebrochen'); },
},
};
/**
* arc-Werkzeug (A4.3): 3-Klick wie Legacy — Zentrum, Startpunkt (Radius+
* Startwinkel), Endpunkt (Endwinkel). Winkel in Grad CCW.
*/
export const arcTool: ToolExtensionV2 = {
manifest: {
id: 'arc', label: 'Bogen', icon: '\u2322', ribbonTab: 'canvas', tags: ['basic'],
description: 'Bogen: Zentrum, Startpunkt, Endpunkt',
},
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (arcPhase === 0) {
arcCenter = e.world;
arcPhase = 1;
ctx.setStatus('Bogenzentrum gesetzt \u2013 Startpunkt w\u00e4hlen');
} else if (arcPhase === 1) {
arcStartPt = e.world;
arcPhase = 2;
ctx.setStatus('Bogen: Endpunkt w\u00e4hlen');
} else if (arcPhase === 2 && arcCenter && arcStartPt) {
// Dritter Klick committet (klick-progressiv wie Legacy — up feuert im
// echten Browser immer direkt nach down, darf deshalb NICHT committen)
if (!c.doc) { arcPhase = 0; arcCenter = null; arcStartPt = null; return; }
const el = buildArc(arcCenter, arcStartPt, e.world, c);
c.doc.transact(() => c.doc!.addElement(el));
ctx.setPreview?.(null);
ctx.setStatus(`Bogen erstellt (${el.id})`);
arcPhase = 0;
arcCenter = null;
arcStartPt = null;
}
},
move(e, ctx) {
if (arcPhase === 1 && arcCenter) {
const r = Math.hypot(e.world.x - arcCenter.x, e.world.y - arcCenter.y);
// Vollkreis-Guide in Phasen-Preview (Legacy-Konvention)
ctx.setPreview?.(buildArcFullGuide(arcCenter, r, ctx));
} else if (arcPhase === 2 && arcCenter && arcStartPt) {
ctx.setPreview?.(buildArc(arcCenter, arcStartPt, e.world, ctx));
}
},
cancel(ctx) {
arcPhase = 0; arcCenter = null; arcStartPt = null;
ctx.setPreview?.(null); ctx.setStatus('Bogen abgebrochen');
},
},
};
/**
* polyline-Werkzeug (A4.3): Mehrklick fügt Punkte; ENTER committet ab 2 Punkten.
*/
export const polylineTool: ToolExtensionV2 = makePolyTool('polyline', 'Polylinie', 2, '\u2500');
/**
* polygon-Werkzeug (A4.3): wie Polylinie, aber geschlossen; ENTER ab 3 Punkten.
*/
export const polygonTool: ToolExtensionV2 = makePolyTool('polygon', 'Polygon', 3, '\u2b21');
function makePolyTool(type: 'polyline' | 'polygon', label: string, minPoints: number, icon: string): ToolExtensionV2 {
return {
manifest: { id: type, label, icon, ribbonTab: 'canvas', tags: ['basic'], description: `${label}: Punkte klicken, ENTER beendet` },
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
handlers: {
down(e, ctx) {
polyPoints.push(e.world);
if (!isPolyType(type)) return;
ctx.setStatus(`${label}: ${polyPoints.length} Punkte ENTER beendet`);
},
move(e, ctx) {
if (polyPoints.length === 0) return;
const pts = [...polyPoints, e.world];
ctx.setPreview?.(buildPoly(type, pts, ctx));
},
up() { /* commit nur via ENTER */ },
key(e, ctx) {
if (e.key !== 'Enter') return false;
if (polyPoints.length < minPoints) {
ctx.setStatus(`${label}: mindestens ${minPoints} Punkte nötig`);
return true; // konsumiert
}
const el = buildPoly(type, polyPoints, ctx as FullToolContext);
const doc = (ctx as FullToolContext).doc;
if (!doc) { resetPoly(); return true; }
doc.transact(() => doc.addElement(el));
resetPoly();
ctx.setPreview?.(null);
ctx.setStatus(`${label} erstellt (${el.id})`);
return true;
},
cancel(ctx) {
resetPoly();
ctx.setPreview?.(null);
ctx.setStatus(`${label} abgebrochen`);
},
},
};
}
function isPolyType(t: string): boolean {
return t === 'polyline' || t === 'polygon';
}
function resetPoly(): void {
polyPoints = [];
}
// ─── Build-Helfer A4.3 ───────────────────────────────────────
function buildCircle(center: Pt, radius: number, ctx: FullToolContext): CADElement {
const o = optsOf(ctx);
return {
id: uid('circle'), type: 'circle', layerId: o.layerId,
x: center.x, y: center.y, width: radius * 2, height: radius * 2,
properties: { radius, stroke: o.stroke, strokeWidth: 1 },
} as unknown as CADElement;
}
function anglesOf(center: Pt, start: Pt, end: Pt): { radius: number; startAngle: number; endAngle: number } {
const radius = Math.hypot(start.x - center.x, start.y - center.y);
const startAngle = (Math.atan2(start.y - center.y, start.x - center.x) * 180) / Math.PI;
const endAngle = (Math.atan2(end.y - center.y, end.x - center.x) * 180) / Math.PI;
return { radius, startAngle, endAngle };
}
function buildArc(center: Pt, startPt: Pt, endPt: Pt, ctx: FullToolContext): CADElement {
const o = optsOf(ctx);
const a = anglesOf(center, startPt, endPt);
return {
id: uid('arc'), type: 'arc', layerId: o.layerId,
x: center.x, y: center.y, width: a.radius * 2, height: a.radius * 2,
properties: { radius: a.radius, startAngle: a.startAngle, endAngle: a.endAngle, stroke: o.stroke, strokeWidth: 1 },
} as unknown as CADElement;
}
function buildArcFullGuide(center: Pt, radius: number, ctx: FullToolContext): CADElement {
// Vollkreis-Vorschau als Radien-Leitfaden (Legacy-Verhalten Phase 1)
const o = optsOf(ctx);
return {
id: uid('arc'), type: 'circle', layerId: o.layerId,
x: center.x, y: center.y, width: radius * 2, height: radius * 2,
properties: { radius, stroke: o.stroke, strokeWidth: 1, guideOnly: true },
} as unknown as CADElement;
}
function buildPoly(type: 'polyline' | 'polygon', pts: Pt[], ctx: FullToolContext): CADElement {
const o = optsOf(ctx);
const xs = pts.map((p) => p.x);
const ys = pts.map((p) => p.y);
return {
id: uid(type), type, layerId: o.layerId,
x: (Math.min(...xs) + Math.max(...xs)) / 2,
y: (Math.min(...ys) + Math.max(...ys)) / 2,
width: Math.max(...xs) - Math.min(...xs),
height: Math.max(...ys) - Math.min(...ys),
properties: { points: pts.map((p) => ({ x: p.x, y: p.y })), stroke: o.stroke, strokeWidth: 1 },
} as unknown as CADElement;
}
/**
* revcloud-Werkzeug (A4.5): Mehrklick-Revisionswolke wie Poly-Muster;
* ENTER committet ab 3 Punkten. Legacy-Konvention: arcHeight 8.
*/
export const revcloudTool: ToolExtensionV2 = {
manifest: {
id: 'revcloud', label: 'RevCloud', icon: '\u2611', ribbonTab: 'format', tags: ['pro'],
description: 'Revisionswolke: Punkte klicken, ENTER beendet',
},
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
handlers: {
down(e, ctx) {
cloudPoints.push(e.world);
ctx.setStatus(`RevCloud: ${cloudPoints.length} Punkte \u2013 ENTER beendet`);
},
move(e, ctx) {
if (cloudPoints.length === 0) return;
ctx.setPreview?.(buildRevCloud([...cloudPoints, e.world], ctx));
},
up() { /* commit via ENTER */ },
key(e, ctx) {
if (e.key !== 'Enter') return false;
if (cloudPoints.length < 3) {
ctx.setStatus('RevCloud: mindestens 3 Punkte n\u00f6tig');
return true;
}
const c = ctx as FullToolContext;
if (!c.doc) { cloudPoints = []; return true; }
const el = buildRevCloud(cloudPoints, c);
c.doc.transact(() => c.doc!.addElement(el));
cloudPoints = [];
ctx.setPreview?.(null);
ctx.setStatus(`RevCloud erstellt (${el.id})`);
return true;
},
cancel(ctx) {
cloudPoints = [];
ctx.setPreview?.(null);
ctx.setStatus('RevCloud abgebrochen');
},
},
};
function buildRevCloud(pts: Pt[], ctx: FullToolContext): CADElement {
const o = optsOf(ctx);
const xs = pts.map((p) => p.x);
const ys = pts.map((p) => p.y);
return {
id: uid('revcloud'), type: 'revcloud', layerId: o.layerId,
x: (Math.min(...xs) + Math.max(...xs)) / 2,
y: (Math.min(...ys) + Math.max(...ys)) / 2,
width: Math.max(...xs) - Math.min(...xs),
height: Math.max(...ys) - Math.min(...ys),
properties: { points: pts.map((p) => ({ x: p.x, y: p.y })), arcHeight: 8, fill: 'none', stroke: '#e0e0e0', strokeWidth: 1.5 },
} as unknown as CADElement;
}
/** V2-Plugin: Kern-Zeichenwerkzeuge (A4.x schrittweise migriert). */
// ─────────────────────────────────────────────────────────────
// Task D1: Ellipse (3-Klick: Zentrum→RadiusX→RadiusY) +
// Ellipsenbogen via startAngle/endAngle-Options.
// ─────────────────────────────────────────────────────────────
/** Ellipsen-Sitzung: Phase 0=nichts, 1=Zentrum, 2=RadiusX fixiert. */
let ellipsePhase = 0;
let ellipseCenter: Pt | null = null;
let ellipseRadiusX = 0;
/** Rotation aus der Klick2-Zugrichtung (Grad, 0°=+x, CW-positiv). */
let ellipseRotationDeg = 0;
/**
* Ellipse als Polyline-Approximation (F5-Parser-Konvention):
* Voll-Ellipse → 64 Punkte geschlossen (polygon), Bogen → proportional
* offene Polyline. rotation aus der Klick2-Zugrichtung (Grad, 0°=+x).
*/
function buildEllipse(
cx: number,
cy: number,
rx: number,
ry: number,
rotationDeg: number,
ctx: FullToolContext,
startAngle = 0,
endAngle = 360,
): CADElement {
const stroke = (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0';
const layerId = (ctx.options.layerId as string | undefined) ?? 'layer-0';
const phi = (rotationDeg * Math.PI) / 180;
const cosP = Math.cos(phi), sinP = Math.sin(phi);
const FULL = 360;
const span = Math.min(endAngle, FULL) - Math.min(startAngle, FULL);
const isFull = span >= FULL - 0.001 || span <= 0.001;
const n = Math.max(2, Math.round(64 * (Math.max(span, 1) / FULL)));
const pts: Pt[] = [];
for (let i = 0; i < n; i++) {
const t = ((startAngle + (span * i) / (isFull ? n : (n - 1))) * Math.PI) / 180;
const x = rx * Math.cos(t);
const y = ry * Math.sin(t);
pts.push({
x: cx + x * cosP - y * sinP,
y: cy + x * sinP + y * cosP,
});
}
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
const minX = Math.min(...xs), minY = Math.min(...ys);
const type = isFull ? 'polygon' : 'polyline';
return {
id: uid('ellipse'),
type,
layerId,
x: minX + (Math.max(...xs) - minX) / 2,
y: minY + (Math.max(...ys) - minY) / 2,
width: Math.max(...xs) - minX,
height: Math.max(...ys) - minY,
properties: {
points: pts,
stroke,
strokeWidth: 1,
cx, cy, rx, ry,
rotation: rotationDeg,
...(isFull ? {} : { startAngle, endAngle }),
},
} as unknown as CADElement;
}
export const ellipseTool: ToolExtensionV2 = {
manifest: {
id: 'ellipse',
label: 'Ellipse',
icon: '\u2b2d',
ribbonTab: 'canvas',
tags: ['basic'],
description: 'Ellipse zeichnen: Zentrum klicken, RadiusX, RadiusY (Bogen via Optionen)',
},
optionsSchema: [
{ key: 'strokeColor', label: 'Farbe', type: 'color' },
{ key: 'startAngle', label: 'Bogen-Start (°)', type: 'number', min: 0, max: 360 },
{ key: 'endAngle', label: 'Bogen-Ende (°)', type: 'number', min: 0, max: 360 },
],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (ellipsePhase === 0) {
ellipseCenter = e.world;
ellipsePhase = 1;
ctx.setStatus('Ellipse: Zentrum gesetzt RadiusX klicken (Richtung = Rotation)');
} else if (ellipsePhase === 1) {
if (!ellipseCenter) { ellipsePhase = 0; return; }
const dx = e.world.x - ellipseCenter.x;
const dy = e.world.y - ellipseCenter.y;
ellipseRadiusX = Math.hypot(dx, dy);
ellipseRotationDeg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
ellipsePhase = 2;
ctx.setStatus('Ellipse: RadiusX gesetzt RadiusY klicken');
} else {
// Phase 2: Commit
if (!ellipseCenter || !c.doc) { ellipsePhase = 0; ellipseCenter = null; return; }
const dx = e.world.x - ellipseCenter.x;
const dy = e.world.y - ellipseCenter.y;
const ry = Math.hypot(dx, dy);
// Rotation: Winkel des Klick2-Vektors gegenüber der +x-Achse
const startAngle = (ctx.options.startAngle as number | undefined) ?? 0;
const endAngle = (ctx.options.endAngle as number | undefined) ?? 360;
const el = buildEllipse(
ellipseCenter.x, ellipseCenter.y,
ellipseRadiusX, ry,
ellipseRotationDeg,
c, startAngle, endAngle,
);
c.doc.transact(() => c.doc!.addElement(el));
ellipsePhase = 0;
ellipseCenter = null;
ctx.setPreview?.(null);
ctx.setStatus(`Ellipse erstellt (${el.id})`);
}
},
move(e, ctx) {
const c = ctx as FullToolContext;
if (ellipsePhase === 1 && ellipseCenter) {
// RadiusX-Linie als Preview
const r = Math.hypot(e.world.x - ellipseCenter.x, e.world.y - ellipseCenter.y);
const rotDeg = ((Math.atan2(e.world.y - ellipseCenter.y, e.world.x - ellipseCenter.x) * 180) / Math.PI + 360) % 360;
const startAngle = (ctx.options.startAngle as number | undefined) ?? 0;
const endAngle = (ctx.options.endAngle as number | undefined) ?? 360;
ctx.setPreview?.(buildEllipse(ellipseCenter.x, ellipseCenter.y, r, r, rotDeg, c, startAngle, endAngle));
} else if (ellipsePhase === 2 && ellipseCenter) {
const ry = Math.hypot(e.world.x - ellipseCenter.x, e.world.y - ellipseCenter.y);
const rotDeg = ellipseRotationDeg;
const startAngle = (ctx.options.startAngle as number | undefined) ?? 0;
const endAngle = (ctx.options.endAngle as number | undefined) ?? 360;
ctx.setPreview?.(buildEllipse(ellipseCenter.x, ellipseCenter.y, ellipseRadiusX, ry, rotDeg, c, startAngle, endAngle));
}
},
cancel(ctx) {
ellipsePhase = 0;
ellipseCenter = null;
ctx.setPreview?.(null);
ctx.setStatus('Ellipse abgebrochen');
},
},
};
// ─────────────────────────────────────────────────────────────
// Task D2: Spline (CV-basiert) — Kontrollpunkt-Klick-Sequenz,
// ENTER committet ab 3 CVs. Catmull-Rom-Interpolation (Kurve läuft
// DURCH alle CVs, schwingt an Ecken = echte Glättung).
// ─────────────────────────────────────────────────────────────
let splinePoints: Pt[] = [];
/**
* Catmull-Rom-Spline-Interpolation: Für n CVs werden n-1 Segmente
* mit je SEG Samples erzeugt. Tangenten aus Nachbarn (Endpunkte
* geklemmt). Ergibt eine glatte Kurve durch alle CVs.
*/
function catmullRom(cvs: Pt[], seg = 16): Pt[] {
if (cvs.length < 2) return [...cvs];
const out: Pt[] = [];
const n = cvs.length;
for (let i = 0; i < n - 1; i++) {
const p0 = cvs[Math.max(0, i - 1)];
const p1 = cvs[i];
const p2 = cvs[i + 1];
const p3 = cvs[Math.min(n - 1, i + 2)];
for (let s = (i === 0 ? 0 : 1); s <= seg; s++) {
const t = s / seg;
const t2 = t * t;
const t3 = t2 * t;
const x = 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3);
const y = 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3);
out.push({ x, y });
}
}
return out;
}
function buildSpline(cvs: Pt[], ctx: FullToolContext): CADElement {
const stroke = (ctx.options.strokeColor as string | undefined) ?? '#e0e0f0';
const layerId = (ctx.options.layerId as string | undefined) ?? 'layer-0';
const pts = catmullRom(cvs);
const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
const minX = Math.min(...xs), minY = Math.min(...ys);
const maxX = Math.max(...xs), maxY = Math.max(...ys);
return {
id: uid('spline'),
type: 'polyline',
layerId,
x: minX + (maxX - minX) / 2,
y: minY + (maxY - minY) / 2,
width: maxX - minX,
height: maxY - minY,
properties: {
points: pts,
controlPoints: [...cvs],
stroke,
strokeWidth: 1,
},
} as unknown as CADElement;
}
export const splineTool: ToolExtensionV2 = {
manifest: {
id: 'spline',
label: 'Spline',
icon: '\u2248',
ribbonTab: 'canvas',
tags: ['basic'],
description: 'Spline zeichnen: Kontrollpunkte klicken, ENTER beendet',
},
optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }],
handlers: {
down(e, ctx) {
splinePoints.push(e.world);
ctx.setStatus(`Spline: ${splinePoints.length} Kontrollpunkte ENTER beendet`);
},
move(e, ctx) {
const c = ctx as FullToolContext;
if (splinePoints.length === 0) return;
const pts = [...splinePoints, e.world];
ctx.setPreview?.(buildSpline(pts, c));
},
up() { /* commit nur via ENTER */ },
key(e, ctx) {
if (e.key !== 'Enter' && e.key !== 'ENTER') return false;
const c = ctx as FullToolContext;
if (splinePoints.length < 3) {
ctx.setStatus(`Spline benötigt mindestens 3 Kontrollpunkte (aktuell ${splinePoints.length})`);
return true;
}
if (!c.doc) return false;
const el = buildSpline(splinePoints, c);
c.doc.transact(() => c.doc!.addElement(el));
const count = splinePoints.length;
splinePoints = [];
ctx.setPreview?.(null);
ctx.setStatus(`Spline erstellt (${count} CVs, ${el.id})`);
return true;
},
cancel(ctx) {
splinePoints = [];
ctx.setPreview?.(null);
ctx.setStatus('Spline abgebrochen');
},
},
};
export const coreDrawingPlugin: PluginV2 = {
manifest: {
id: 'core-drawing',
name: 'Zeichnen (Kern)',
version: '1.4.0',
author: 'web-cad team',
description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud.',
category: 'tools',
enabledByDefault: true,
},
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool],
};