/** * 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'); }, }, }; // ───────────────────────────────────────────────────────────── // Task D3: Point/XLine/Ray — Konstruktions-Elemente auf defpoints. // ───────────────────────────────────────────────────────────── /** Auto-Anlegen des defpoints-Layers (Task D3) falls fehlt. */ function ensureDefpointsLayer(ctx: FullToolContext): void { const doc = ctx.doc as unknown as { getLayer(id: string): unknown; addLayer(l: unknown): void; } | undefined; if (!doc || doc.getLayer('defpoints')) return; doc.addLayer({ id: 'defpoints', name: 'defpoints', visible: true, locked: false, color: '#8a8f98', lineType: 'dotted', transparency: 0.5, sortOrder: 999, parentId: null, }); } /** Konstruktions-Element-Basis-Props (construction-Tags, Task D3). */ function constructionProps(): Record { return { stroke: '#8a8f98', strokeWidth: 1, construction: true }; } export const pointTool: ToolExtensionV2 = { manifest: { id: 'point', label: 'Punkt', icon: '\u2022', ribbonTab: 'canvas', tags: ['basic'], description: 'Konstruktionspunkt platzieren (defpoints-Layer)', }, optionsSchema: [], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc) return; ensureDefpointsLayer(c); const r = 1.5; const el: CADElement = { id: uid('point'), type: 'circle', layerId: 'defpoints', x: e.world.x, y: e.world.y, width: r * 2, height: r * 2, properties: { ...constructionProps(), radius: r, point: true }, } as unknown as CADElement; c.doc.transact(() => c.doc!.addElement(el)); ctx.setStatus(`Punkt platziert (${el.id})`); }, }, }; /** XLine: sehr lange Linie in BEIDE Richtungen um den Basispunkt. */ let xlineBase: Pt | null = null; export const xlineTool: ToolExtensionV2 = { manifest: { id: 'xline', label: 'Konstruktionslinie', icon: '\u2194', ribbonTab: 'canvas', tags: ['basic'], description: 'Unendliche Konstruktionslinie: Basispunkt + Richtung klicken', }, optionsSchema: [], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc) return; if (!xlineBase) { xlineBase = e.world; ctx.setStatus('Konstruktionslinie: Basis gesetzt – Richtung klicken'); return; } ensureDefpointsLayer(c); const dx = e.world.x - xlineBase.x; const dy = e.world.y - xlineBase.y; const len = Math.hypot(dx, dy); if (len < 0.001) { xlineBase = null; return; } const ux = dx / len, uy = dy / len; const L = 100000; const el: CADElement = { id: uid('xline'), type: 'line', layerId: 'defpoints', x: xlineBase.x, y: xlineBase.y, width: 0, height: 0, properties: { ...constructionProps(), x1: xlineBase.x - ux * L, y1: xlineBase.y - uy * L, x2: xlineBase.x + ux * L, y2: xlineBase.y + uy * L, }, } as unknown as CADElement; c.doc.transact(() => c.doc!.addElement(el)); xlineBase = null; ctx.setPreview?.(null); ctx.setStatus(`Konstruktionslinie erstellt (${el.id})`); }, move(e, ctx) { const c = ctx as FullToolContext; if (!xlineBase) return; const dx = e.world.x - xlineBase.x; const dy = e.world.y - xlineBase.y; const len = Math.hypot(dx, dy); if (len < 0.001) return; const ux = dx / len, uy = dy / len; const L = 100000; ctx.setPreview?.({ id: '__preview_xline__', type: 'line', layerId: 'defpoints', x: xlineBase.x, y: xlineBase.y, width: 0, height: 0, properties: { ...constructionProps(), x1: xlineBase.x - ux * L, y1: xlineBase.y - uy * L, x2: xlineBase.x + ux * L, y2: xlineBase.y + uy * L, }, } as unknown as CADElement); }, cancel(ctx) { xlineBase = null; ctx.setPreview?.(null); ctx.setStatus('Konstruktionslinie abgebrochen'); }, }, }; /** Ray: Linie ab Basispunkt NUR in Klickrichtung. */ let rayBase: Pt | null = null; export const rayTool: ToolExtensionV2 = { manifest: { id: 'ray', label: 'Strahl', icon: '\u2192', ribbonTab: 'canvas', tags: ['basic'], description: 'Strahl ab Basispunkt in Klickrichtung', }, optionsSchema: [], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc) return; if (!rayBase) { rayBase = e.world; ctx.setStatus('Strahl: Basis gesetzt – Richtung klicken'); return; } ensureDefpointsLayer(c); const dx = e.world.x - rayBase.x; const dy = e.world.y - rayBase.y; const len = Math.hypot(dx, dy); if (len < 0.001) { rayBase = null; return; } const ux = dx / len, uy = dy / len; const L = 100000; const el: CADElement = { id: uid('ray'), type: 'line', layerId: 'defpoints', x: rayBase.x, y: rayBase.y, width: 0, height: 0, properties: { ...constructionProps(), x1: rayBase.x, y1: rayBase.y, x2: rayBase.x + ux * L, y2: rayBase.y + uy * L, }, } as unknown as CADElement; c.doc.transact(() => c.doc!.addElement(el)); rayBase = null; ctx.setPreview?.(null); ctx.setStatus(`Strahl erstellt (${el.id})`); }, move(e, ctx) { const c = ctx as FullToolContext; if (!rayBase) return; const dx = e.world.x - rayBase.x; const dy = e.world.y - rayBase.y; const len = Math.hypot(dx, dy); if (len < 0.001) return; const ux = dx / len, uy = dy / len; const L = 100000; ctx.setPreview?.({ id: '__preview_ray__', type: 'line', layerId: 'defpoints', x: rayBase.x, y: rayBase.y, width: 0, height: 0, properties: { ...constructionProps(), x1: rayBase.x, y1: rayBase.y, x2: rayBase.x + ux * L, y2: rayBase.y + uy * L, }, } as unknown as CADElement); }, cancel(ctx) { rayBase = null; ctx.setPreview?.(null); ctx.setStatus('Strahl abgebrochen'); }, }, }; // ───────────────────────────────────────────────────────────── // Task CAD-5: MLINE (Mehrfachlinie) — zwei parallele Linien // (Wand/Rohr) mit gap-Offset ±gap/2 entlang der Normalen. // Optionale Endkappen verbinden die beiden Linien an Start/Ende. // Klick-Sequenz wie polyline; ENTER committet ab 2 Punkten; // ALLE Teile in EINER Transaktion = 1 Undo-Schritt. // ───────────────────────────────────────────────────────────── let mlinePoints: Pt[] = []; /** * Normalen-Offset-Punkte für eine Punktliste ±d (CAD-5, rein). * An inneren Punkten wird die Richtung aus dem Mittel der * angrenzenden Segment-Normalen bestimmt (Miter wie bei Wänden). */ function offsetPolyline(pts: Pt[], d: number): Pt[] { if (pts.length < 2) return [...pts]; const out: Pt[] = []; for (let i = 0; i < pts.length; i++) { let nx = 0, ny = 0; if (i > 0) { const dx = pts[i].x - pts[i - 1].x; const dy = pts[i].y - pts[i - 1].y; const len = Math.hypot(dx, dy) || 1; nx += -dy / len; ny += dx / len; } if (i < pts.length - 1) { const dx = pts[i + 1].x - pts[i].x; const dy = pts[i + 1].y - pts[i].y; const len = Math.hypot(dx, dy) || 1; nx += -dy / len; ny += dx / len; } const nLen = Math.hypot(nx, ny) || 1; out.push({ x: pts[i].x + (nx / nLen) * d, y: pts[i].y + (ny / nLen) * d }); } return out; } export const mlineTool: ToolExtensionV2 = { manifest: { id: 'mline', label: 'Mehrfachlinie', icon: '\u2551', ribbonTab: 'canvas', tags: ['basic'], description: 'Doppel-Linie (Wand): Punkte klicken, ENTER beendet (CAD-5)', }, optionsSchema: [ { key: 'gap', label: 'Gesamtstaerke', type: 'number', min: 1 }, { key: 'cap', label: 'Endkappen', type: 'checkbox' }, { key: 'strokeColor', label: 'Farbe', type: 'color' }, ], handlers: { down(e, ctx) { mlinePoints.push(e.world); ctx.setStatus(`Mehrfachlinie: ${mlinePoints.length} Punkte — ENTER beendet`); }, move(e, ctx) { const c = ctx as FullToolContext; if (mlinePoints.length === 0) return; const gap = (c.options.gap as number | undefined) ?? 20; const pts = [...mlinePoints, e.world]; const a = offsetPolyline(pts, gap / 2); const b = offsetPolyline(pts, -gap / 2); ctx.setPreview?.({ id: '__preview_mline__', type: 'polyline', layerId: 'layer-0', x: e.world.x, y: e.world.y, width: 0, height: 0, properties: { points: a, stroke: '#e0e0f0', strokeWidth: 1, mline: true }, } as unknown as CADElement); }, up() { /* Commit nur via ENTER */ }, key(e, ctx) { if (e.key !== 'Enter' && e.key !== 'ENTER') return false; const c = ctx as FullToolContext; if (mlinePoints.length < 2) { ctx.setStatus(`Mehrfachlinie benötigt mindestens 2 Punkte (aktuell ${mlinePoints.length})`); return true; } if (!c.doc) return false; const gap = Math.max(1, (c.options.gap as number | undefined) ?? 20); const cap = (c.options.cap as boolean | undefined) ?? false; const stroke = (c.options.strokeColor as string | undefined) ?? '#e0e0f0'; const layerId = (c.options.layerId as string | undefined) ?? 'layer-0'; const a = offsetPolyline(mlinePoints, gap / 2); const b = offsetPolyline(mlinePoints, -gap / 2); const mkLine = (pts: Pt[]): CADElement => ({ id: uid('mline'), type: 'polyline', layerId, x: pts.reduce((s, p) => s + p.x, 0) / pts.length, y: pts.reduce((s, p) => s + p.y, 0) / pts.length, width: Math.max(...pts.map((p) => p.x)) - Math.min(...pts.map((p) => p.x)), height: Math.max(...pts.map((p) => p.y)) - Math.min(...pts.map((p) => p.y)), properties: { points: pts, stroke, strokeWidth: 1, mline: true, gap }, } as unknown as CADElement); const els: CADElement[] = [mkLine(a), mkLine(b)]; if (cap) { const capAt = (i: number): CADElement => ({ id: uid('mlinecap'), type: 'line', layerId, x: (a[i].x + b[i].x) / 2, y: (a[i].y + b[i].y) / 2, width: Math.abs(b[i].x - a[i].x), height: Math.abs(b[i].y - a[i].y), properties: { x1: a[i].x, y1: a[i].y, x2: b[i].x, y2: b[i].y, stroke, strokeWidth: 1, mlineCap: true, mline: true, }, } as unknown as CADElement); els.push(capAt(0), capAt(a.length - 1)); } c.doc.transact(() => { for (const el of els) c.doc!.addElement(el); }); const count = mlinePoints.length; mlinePoints = []; ctx.setPreview?.(null); ctx.setStatus(`Mehrfachlinie erstellt (${count} Punkte${cap ? ', mit Endkappen' : ''})`); return true; }, cancel(ctx) { mlinePoints = []; ctx.setPreview?.(null); ctx.setStatus('Mehrfachlinie abgebrochen'); }, }, }; let boxStart: Pt | null = null; export const selectTool: ToolExtensionV2 = { manifest: { id: 'select', label: 'Auswahl', icon: '\u2196', ribbonTab: 'canvas', tags: ['basic'], description: 'Auswahl: Klick wählt, Drag = Box-Select, Shift additiv (CAD-14: direkt auf V2-Dokument)', }, optionsSchema: [], handlers: { down(e, ctx) { const c = ctx as FullToolContext; const sel = c.selection; const hit = sel?.hitTest(e.world.x, e.world.y, 5) ?? null; if (hit) { if (e.shift) { // additiv: bestehende IDs + neue ID const current = sel?.getIds() ?? []; const next = current.includes(hit.id) ? current.filter((id) => id !== hit.id) : [...current, hit.id]; sel?.setIds(next); } else { sel?.setIds([hit.id]); } sel?.onChange?.(sel?.getIds()); c.setStatus(`Ausgewählt: ${hit.id}`); boxStart = null; } else { if (!e.shift) sel?.setIds([]); sel?.onChange?.(sel?.getIds() ?? []); boxStart = e.world; c.setStatus('Box-Select: aufziehen'); } }, move(e, ctx) { if (!boxStart) return; const c = ctx as FullToolContext; // Live-Vorschau der Box als gestricheltes Rechteck ctx.setPreview?.({ id: '__boxselect__', type: 'rect', layerId: '', x: (boxStart.x + e.world.x) / 2, y: (boxStart.y + e.world.y) / 2, width: Math.abs(e.world.x - boxStart.x), height: Math.abs(e.world.y - boxStart.y), properties: { stroke: '#00aaff', strokeWidth: 1, visible: true }, } as unknown as CADElement); }, up(e, ctx) { const c = ctx as FullToolContext; if (!boxStart) return; const start = boxStart; boxStart = null; ctx.setPreview?.(null); // War es ein Drag (Box) oder nur ein Klick? const dx = Math.abs(e.world.x - start.x); const dy = Math.abs(e.world.y - start.y); if (dx < 3 && dy < 3) return; // Klick bereits in down behandelt const minX = Math.min(start.x, e.world.x); const maxX = Math.max(start.x, e.world.x); const minY = Math.min(start.y, e.world.y); const maxY = Math.max(start.y, e.world.y); const all = c.doc?.getAllElements() ?? []; const hits = all.filter((el) => el.x >= minX && el.x <= maxX && el.y >= minY && el.y <= maxY).map((el) => el.id); const sel = c.selection; if (e.shift) { const current = sel?.getIds() ?? []; const merged = Array.from(new Set([...current, ...hits])); sel?.setIds(merged); } else { sel?.setIds(hits); } sel?.onChange?.(sel?.getIds() ?? []); c.setStatus(`Box-Select: ${hits.length} Element(e)`); }, cancel(ctx) { boxStart = null; ctx.setPreview?.(null); ctx.setStatus('Auswahl abgebrochen'); }, }, }; export const coreDrawingPlugin: PluginV2 = { manifest: { id: 'core-drawing', name: 'Zeichnen (Kern)', version: '1.6.0', author: 'web-cad team', description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud, Auswahl.', category: 'tools', enabledByDefault: true, }, tools: [selectTool, lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool, mlineTool], };