task(D1): ellipse tool - 3-click center/radiusX/radiusY with rotation from drag direction, full ellipse as 64-point polygon, arc via start/end angle options

This commit is contained in:
Agent Zero
2026-08-29 03:12:12 +02:00
parent 5cfd29cf1a
commit 5db421da5e
2 changed files with 282 additions and 1 deletions
@@ -414,6 +414,150 @@ function buildRevCloud(pts: Pt[], ctx: FullToolContext): 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');
},
},
};
export const coreDrawingPlugin: PluginV2 = {
manifest: {
id: 'core-drawing',
@@ -424,5 +568,5 @@ export const coreDrawingPlugin: PluginV2 = {
category: 'tools',
enabledByDefault: true,
},
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool],
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool],
};
+137
View File
@@ -0,0 +1,137 @@
/**
* Task D1 Ellipse+Bogen-Tool (core-drawing).
*
* 3-Klick-Interaktion: Klick1=Zentrum, Klick2=RadiusX (Richtung
* definiert die Rotation), Klick3=RadiusY. Commit als geschlossene
* Polyline (64 Punkte, F5-Parser-Konvention) mit cx/cy/rx/ry/rotation
* Metadaten. Ellipsenbogen via startAngle/endAngle-Options → offene
* Polyline mit proportionalen Punkten.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ellipseTool } from '../src/plugins/builtin/core-drawing';
import { CADDocument } from '../src/kernel/document/CADDocument';
import { createInMemoryDoc } from './helpers/inMemoryDoc';
import type { CADElement, Pt } from '../src/types/cad.types';
function mkCtx(options: Record<string, unknown> = {}) {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
const previews: (CADElement | null)[] = [];
const statuses: string[] = [];
const ctx: any = {
doc,
options,
setStatus: (m: string) => statuses.push(m),
setPreview: (el: CADElement | null) => previews.push(el),
};
return { ctx, doc, previews, statuses };
}
function pe(x: number, y: number): { world: Pt } {
return { world: { x, y } } as never;
}
beforeEach(() => {
ellipseTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
});
describe('D1: ellipseTool', () => {
it('3 Klicks: Zentrum→RadiusX→RadiusY erzeugt geschlossene Ellipse (64 Punkte)', () => {
const { ctx, doc } = mkCtx();
ellipseTool.handlers.down!(pe(0, 0), ctx);
ellipseTool.handlers.down!(pe(100, 0), ctx); // RadiusX=100, Rotation=0
ellipseTool.handlers.down!(pe(0, 50), ctx); // RadiusY=50
const els = doc.getAllElements();
expect(els).toHaveLength(1);
const el = els[0];
expect(el.type).toBe('polygon');
const pts = el.properties.points as Pt[];
expect(pts.length).toBe(64);
// BBox: rx=100, ry=50
const xs = pts.map((p) => p.x);
const ys = pts.map((p) => p.y);
expect(Math.max(...xs)).toBeCloseTo(100, 0);
expect(Math.min(...xs)).toBeCloseTo(-100, 0);
expect(Math.max(...ys)).toBeCloseTo(50, 0);
expect(Math.min(...ys)).toBeCloseTo(-50, 0);
// Metadaten
expect(el.properties.cx).toBe(0);
expect(el.properties.cy).toBe(0);
expect(el.properties.rx).toBe(100);
expect(el.properties.ry).toBe(50);
expect(el.properties.rotation).toBe(0);
});
it('Zugrichtung definiert Rotation: Klick2 nach (0,100) → rotation=90°', () => {
const { ctx, doc } = mkCtx();
ellipseTool.handlers.down!(pe(0, 0), ctx);
ellipseTool.handlers.down!(pe(0, 100), ctx); // RadiusX zeigt nach unten
ellipseTool.handlers.down!(pe(50, 0), ctx); // RadiusY
const el = doc.getAllElements()[0];
expect(el.properties.rotation).toBeCloseTo(90, 1);
// Rotierte Ellipse: Hauptachse vertikal → maxY ≈ 100
const ys = (el.properties.points as Pt[]).map((p) => p.y);
expect(Math.max(...ys)).toBeCloseTo(100, 0);
});
it('Preview bei move nach Phase 2 zeigt Ellipse', () => {
const { ctx, previews } = mkCtx();
ellipseTool.handlers.down!(pe(0, 0), ctx);
ellipseTool.handlers.move!(pe(80, 0), ctx); // RadiusX-Linie
ellipseTool.handlers.down!(pe(80, 0), ctx); // RadiusX fixiert
ellipseTool.handlers.move!(pe(0, 40), ctx); // Ellipsen-Preview mit live ry
expect(previews.length).toBeGreaterThanOrEqual(2);
const last = previews[previews.length - 1];
expect(last).not.toBeNull();
expect(last!.type).toBe('polygon');
});
it('Status führt durch die Phasen', () => {
const { ctx, statuses } = mkCtx();
ellipseTool.handlers.down!(pe(0, 0), ctx);
expect(statuses[0]).toContain('Zentrum');
ellipseTool.handlers.down!(pe(100, 0), ctx);
expect(statuses[1]).toContain('Radius');
});
it('cancel resettet die Messung', () => {
const { ctx, doc } = mkCtx();
ellipseTool.handlers.down!(pe(0, 0), ctx);
ellipseTool.handlers.down!(pe(100, 0), ctx);
ellipseTool.handlers.cancel?.(ctx);
// Nach cancel startet frisch: nächster Klick = Zentrum (kein Commit)
ellipseTool.handlers.down!(pe(0, 0), ctx);
expect(doc.getAllElements()).toHaveLength(0);
});
});
describe('D1: Ellipsenbogen (startAngle/endAngle)', () => {
it('Options startAngle=0/endAngle=180 erzeugen OFFENE Polyline (32 Punkte)', () => {
const { ctx, doc } = mkCtx({ startAngle: 0, endAngle: 180 });
ellipseTool.handlers.down!(pe(0, 0), ctx);
ellipseTool.handlers.down!(pe(100, 0), ctx);
ellipseTool.handlers.down!(pe(0, 50), ctx);
const el = doc.getAllElements()[0];
expect(el.type).toBe('polyline'); // offen
const pts = el.properties.points as Pt[];
expect(pts.length).toBe(32); // halbe Ellipse = 64/2
});
it('Winkel-Metadaten in properties', () => {
const { ctx, doc } = mkCtx({ startAngle: 45, endAngle: 225 });
ellipseTool.handlers.down!(pe(0, 0), ctx);
ellipseTool.handlers.down!(pe(100, 0), ctx);
ellipseTool.handlers.down!(pe(0, 50), ctx);
const el = doc.getAllElements()[0];
expect(el.properties.startAngle).toBe(45);
expect(el.properties.endAngle).toBe(225);
});
});
describe('D1: Manifest', () => {
it('id=ellipse, ribbonTab=canvas, tags basic, Version in tools-Array', () => {
expect(ellipseTool.manifest.id).toBe('ellipse');
expect(ellipseTool.manifest.ribbonTab).toBe('canvas');
expect((ellipseTool.manifest.tags ?? [])).toContain('basic');
});
});