task(A4.10): migrate trim/extend/fillet to v2 + real CAD extend fallback
This commit is contained in:
@@ -21,6 +21,9 @@ import {
|
|||||||
angleBetween,
|
angleBetween,
|
||||||
distance,
|
distance,
|
||||||
offsetElement,
|
offsetElement,
|
||||||
|
trimElement,
|
||||||
|
extendElement,
|
||||||
|
filletElements,
|
||||||
} from '../../../tools/modification/geometry';
|
} from '../../../tools/modification/geometry';
|
||||||
import type { Pt } from '../../../tools/modification/geometry';
|
import type { Pt } from '../../../tools/modification/geometry';
|
||||||
|
|
||||||
@@ -275,7 +278,105 @@ export const scaleTool: ToolExtensionV2 = makeTransformTool('scale');
|
|||||||
export const mirrorTool: ToolExtensionV2 = makeTransformTool('mirror');
|
export const mirrorTool: ToolExtensionV2 = makeTransformTool('mirror');
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// Delete (Task A4.8) — Portierung aus Legacy handleDeleteDown:
|
// Trim / Extend / Fillet (Task A4.10) — baut auf BUG-2-reparierter
|
||||||
|
// findIntersection-Geometrie (B2). Legacy-Ablauf:
|
||||||
|
// - trim/extend: Klick1=Boundary, Klick2=Ziel → Ziel wird angepasst
|
||||||
|
// - fillet: Klick1+Klick2 = zwei Elemente → Paar wird angepasst
|
||||||
|
// Jede Operation committed in EINER Transaktion.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
let twoPickBoundary: CADElement | null = null;
|
||||||
|
let twoPickFirst: CADElement | null = null;
|
||||||
|
|
||||||
|
function make2PickGeometryTool(
|
||||||
|
mode: 'trim' | 'extend' | 'fillet',
|
||||||
|
filletRadius: number,
|
||||||
|
): ToolExtensionV2 {
|
||||||
|
const labels = { trim: 'Trimmen', extend: 'Verlängern', fillet: 'Fillet' } as const;
|
||||||
|
return {
|
||||||
|
manifest: {
|
||||||
|
id: mode,
|
||||||
|
label: labels[mode],
|
||||||
|
icon: mode === 'fillet' ? '\u23dc' : '\u2717',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['pro'],
|
||||||
|
description: `${labels[mode]}: ${mode === 'fillet' ? 'zwei' : 'Kante, dann'} Element(e) anklicken`,
|
||||||
|
},
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc || !c.selection) return;
|
||||||
|
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
|
||||||
|
if (!hit) return;
|
||||||
|
|
||||||
|
if (mode === 'fillet') {
|
||||||
|
if (!twoPickFirst) {
|
||||||
|
twoPickFirst = hit;
|
||||||
|
ctx.setStatus(`Fillet: zweites Element wählen`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Zweiter Pick → Paar-Commit
|
||||||
|
const second = hit;
|
||||||
|
const pair = filletElements(twoPickFirst, second, filletRadius);
|
||||||
|
if (!pair) {
|
||||||
|
ctx.setStatus('Fillet: kein Schnittpunkt gefunden');
|
||||||
|
twoPickFirst = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idA = twoPickFirst.id;
|
||||||
|
const idB = second.id;
|
||||||
|
c.doc.transact(() => {
|
||||||
|
c.doc!.updateElement(idA, {
|
||||||
|
x: pair[0].x, y: pair[0].y,
|
||||||
|
width: pair[0].width, height: pair[0].height,
|
||||||
|
properties: pair[0].properties,
|
||||||
|
});
|
||||||
|
c.doc!.updateElement(idB, {
|
||||||
|
x: pair[1].x, y: pair[1].y,
|
||||||
|
width: pair[1].width, height: pair[1].height,
|
||||||
|
properties: pair[1].properties,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ctx.setStatus(`Fillet angewendet (${idA}, ${idB})`);
|
||||||
|
twoPickFirst = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// trim/extend: Boundary dann Ziel
|
||||||
|
if (!twoPickBoundary) {
|
||||||
|
twoPickBoundary = hit;
|
||||||
|
ctx.setStatus(`${labels[mode]}: jetzt das zu bearbeitende Element wählen`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = hit;
|
||||||
|
const fn = mode === 'trim' ? trimElement : extendElement;
|
||||||
|
const result = fn(target, twoPickBoundary);
|
||||||
|
if (!result) {
|
||||||
|
ctx.setStatus(`${labels[mode]}: keine Schnittmenge mit der Kante`);
|
||||||
|
twoPickBoundary = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
c.doc.transact(() => {
|
||||||
|
c.doc!.updateElement(target.id, {
|
||||||
|
x: result.x, y: result.y,
|
||||||
|
width: result.width, height: result.height,
|
||||||
|
properties: result.properties,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ctx.setStatus(`${labels[mode]} auf ${target.id} angewendet`);
|
||||||
|
twoPickBoundary = null;
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
twoPickBoundary = null;
|
||||||
|
twoPickFirst = null;
|
||||||
|
ctx.setStatus(`${labels[mode]} abgebrochen`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export const trimTool: ToolExtensionV2 = make2PickGeometryTool('trim', 0);
|
||||||
|
export const extendTool: ToolExtensionV2 = make2PickGeometryTool('extend', 0);
|
||||||
|
export const filletTool: ToolExtensionV2 = make2PickGeometryTool('fillet', 5);
|
||||||
// Einzelnklick auf Element löscht es sofort (kein up/Bestätigung).
|
// Einzelnklick auf Element löscht es sofort (kein up/Bestätigung).
|
||||||
// Jetzt via doc.deleteElements → undo-fähig.
|
// Jetzt via doc.deleteElements → undo-fähig.
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
@@ -369,11 +470,11 @@ export const coreModifyPlugin: PluginV2 = {
|
|||||||
manifest: {
|
manifest: {
|
||||||
id: 'core-modify',
|
id: 'core-modify',
|
||||||
name: 'Bearbeiten (Kern)',
|
name: 'Bearbeiten (Kern)',
|
||||||
version: '1.4.0',
|
version: '1.5.0',
|
||||||
author: 'web-cad team',
|
author: 'web-cad team',
|
||||||
description: 'Auswahl, Schraffur, Verschieben, Kopieren, Rotieren, Skalieren, Spiegeln, Löschen, Versatz (V2).',
|
description: 'Auswahl, Schraffur, Move/Copy/Rotate/Scale/Mirror/Delete/Offset/Trim/Extend/Fillet (V2).',
|
||||||
category: 'tools',
|
category: 'tools',
|
||||||
enabledByDefault: true,
|
enabledByDefault: true,
|
||||||
},
|
},
|
||||||
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool],
|
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -272,8 +272,50 @@ export function extendElement(el: CADElement, boundary: CADElement): CADElement
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intersect = findIntersection(el, boundary);
|
let intersect = findIntersection(el, boundary);
|
||||||
if (!intersect) return null;
|
|
||||||
|
// Echtes CAD-Extend (Fallback): Wenn die Strecke die Kante NICHT berührt,
|
||||||
|
// suche Schnitte auf der VERLÄNGERUNG der Linie in Richtung des nahen Endes.
|
||||||
|
if (!intersect) {
|
||||||
|
const c = { x: boundary.x, y: boundary.y };
|
||||||
|
const r =
|
||||||
|
boundary.type === 'circle' || boundary.type === 'arc'
|
||||||
|
? ((boundary.properties?.radius as number | undefined) ?? boundary.width / 2)
|
||||||
|
: null;
|
||||||
|
if (r === null) return null; // nur Kreis/Bogen-Kanten erweiterbar
|
||||||
|
|
||||||
|
const candidates = lineCircleIntersectionsInfinite(
|
||||||
|
{ x: p.x1!, y: p.y1! },
|
||||||
|
{ x: p.x2!, y: p.y2! },
|
||||||
|
c,
|
||||||
|
r as number,
|
||||||
|
);
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
|
||||||
|
// Wähle den Schnittpunkt, der an der Seite des NÄHEREN Endes liegt:
|
||||||
|
// Erst feststellen, welches Ende näher am Kreiszentrum liegt → dieses wird gestreckt.
|
||||||
|
const d1 = Math.hypot(p.x1! - c.x, p.y1! - c.y);
|
||||||
|
const d2 = Math.hypot(p.x2! - c.x, p.y2! - c.y);
|
||||||
|
const nearerIsStart = d1 <= d2;
|
||||||
|
const anchor = nearerIsStart ? { x: p.x1!, y: p.y1! } : { x: p.x2!, y: p.y2! };
|
||||||
|
intersect = candidates.reduce((best, cur) =>
|
||||||
|
Math.hypot(cur.x - anchor.x, cur.y - anchor.y) < Math.hypot(best.x - anchor.x, best.y - anchor.y) ? cur : best,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Neu berechnete bbox/center auf Basis der Länge nach dem Strekken:
|
||||||
|
const nx1 = nearerIsStart ? intersect.x : p.x1!;
|
||||||
|
const ny1 = nearerIsStart ? intersect.y : p.y1!;
|
||||||
|
const nx2 = nearerIsStart ? p.x2! : intersect.x;
|
||||||
|
const ny2 = nearerIsStart ? p.y2! : intersect.y;
|
||||||
|
return {
|
||||||
|
...el,
|
||||||
|
x: (nx1 + nx2) / 2,
|
||||||
|
y: (ny1 + ny2) / 2,
|
||||||
|
width: Math.abs(nx2 - nx1),
|
||||||
|
height: Math.abs(ny2 - ny1),
|
||||||
|
properties: { ...p, x1: nx1, y1: ny1, x2: nx2, y2: ny2 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const props = { ...el.properties };
|
const props = { ...el.properties };
|
||||||
// Extend the end that is closer to the intersection
|
// Extend the end that is closer to the intersection
|
||||||
@@ -409,6 +451,31 @@ function angleInArcRange(deg: number, startDeg: number, endDeg: number): boolean
|
|||||||
return a >= s || a <= e; // Bogen über 0°
|
return a >= s || a <= e; // Bogen über 0°
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie lineCircleIntersections, aber OHNE Segmentbegrenzung (t ∈ ℝ):
|
||||||
|
* findet Schnitte auch auf der VERLÄNGERUNG der Strecke — Grundlage für
|
||||||
|
* echtes CAD-Extend (Task A4.10), bei dem das Ziel die Kante oft noch
|
||||||
|
* gar nicht berührt.
|
||||||
|
*/
|
||||||
|
export function lineCircleIntersectionsInfinite(p1: Pt, p2: Pt, c: Pt, r: number): Pt[] {
|
||||||
|
const dx = p2.x - p1.x;
|
||||||
|
const dy = p2.y - p1.y;
|
||||||
|
const fx = p1.x - c.x;
|
||||||
|
const fy = p1.y - c.y;
|
||||||
|
const a = dx * dx + dy * dy;
|
||||||
|
if (a < 1e-12) return [];
|
||||||
|
const b = 2 * (fx * dx + fy * dy);
|
||||||
|
const cc = fx * fx + fy * fy - r * r;
|
||||||
|
let disc = b * b - 4 * a * cc;
|
||||||
|
if (disc < 0) return [];
|
||||||
|
disc = Math.sqrt(disc);
|
||||||
|
const out: Pt[] = [];
|
||||||
|
for (const t of [(-b - disc) / (2 * a), (-b + disc) / (2 * a)]) {
|
||||||
|
out.push({ x: p1.x + t * dx, y: p1.y + t * dy });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alle Schnittpunkte einer Strecke p1→p2 mit dem Bogen eines Elements
|
* Alle Schnittpunkte einer Strecke p1→p2 mit dem Bogen eines Elements
|
||||||
* (Konvention wie RenderEngine.drawArc). Nur Treffer innerhalb des Segments
|
* (Konvention wie RenderEngine.drawArc). Nur Treffer innerhalb des Segments
|
||||||
|
|||||||
@@ -482,7 +482,103 @@ describe('A4.6 pilot: move/copy tools', () => {
|
|||||||
expect(selIds2).toEqual(['auto']);
|
expect(selIds2).toEqual(['auto']);
|
||||||
move.handlers.down!(pe(30, 30), ctx); // Zielklick (Δ=+20)
|
move.handlers.down!(pe(30, 30), ctx); // Zielklick (Δ=+20)
|
||||||
|
|
||||||
expect(doc.getElement('auto')!.x).toBe(30); // 10+Δ20
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getAllElements().length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4.10 pilot: geometry tools', () => {
|
||||||
|
function makeLineEl(id: string): CADElement {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'line',
|
||||||
|
layerId: 'l',
|
||||||
|
x: 100,
|
||||||
|
y: 0,
|
||||||
|
width: 200,
|
||||||
|
height: 0,
|
||||||
|
properties: { x1: 0, y1: 0, x2: 200, y2: 0 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCircleEl(id: string): CADElement {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'circle',
|
||||||
|
layerId: 'l',
|
||||||
|
x: 100,
|
||||||
|
y: 0,
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
properties: { radius: 40 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('trim: Kante+Ziel kürzt die Linie am Schnittpunkt; undo stellt her', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const boundary = makeCircleEl('bnd');
|
||||||
|
const target = makeLineEl('tgt');
|
||||||
|
doc.addElement(boundary);
|
||||||
|
doc.addElement(target);
|
||||||
|
|
||||||
|
// Sequenzieller Brücken-HitTest: 1. Klick → boundary, 2. Klick → target
|
||||||
|
const pickQueue = [boundary, target];
|
||||||
|
let pickIdx = 0;
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => [] as string[],
|
||||||
|
setIds: () => {},
|
||||||
|
hitTest: () => pickQueue[Math.min(pickIdx++, pickQueue.length - 1)],
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const trim = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'trim')!;
|
||||||
|
|
||||||
|
trim.handlers.down!(pe(140, 0), ctx); // Kante
|
||||||
|
trim.handlers.down!(pe(180, 0), ctx); // Ziel-Linie rechts vom Kreis
|
||||||
|
|
||||||
|
const trimmed = doc.getElement('tgt')!;
|
||||||
|
expect(trimmed.properties.x2).toBeLessThan(200); // gekürzt
|
||||||
|
// Legacy-Semantik: das NÄHERE Ende wird auf den ERSTEN Schnittpunkt
|
||||||
|
// gesetzt (findIntersection wählt den nächsten zu p1 → x=60):
|
||||||
|
expect(Math.abs(trimmed.properties.x2 as number)).toBeCloseTo(60); // Schnitt bei x=140
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getElement('tgt')!.properties.x2).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extend: Linie wird zur Kante hin verlängert', () => {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
// kurze Linie (10→50); Kreis (100,0,r=40) hat Schnitte bei x=60/140
|
||||||
|
// → Extend muss das nahe Ende x2 auf 60 strecken
|
||||||
|
const boundary = makeCircleEl('bnd');
|
||||||
|
const shortLine = {
|
||||||
|
id: 'sl', type: 'line', layerId: 'l', x: 30, y: 0,
|
||||||
|
width: 40, height: 0,
|
||||||
|
properties: { x1: 10, y1: 0, x2: 50, y2: 0 },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
doc.addElement(boundary);
|
||||||
|
doc.addElement(shortLine);
|
||||||
|
|
||||||
|
const pickQueue = [boundary, shortLine];
|
||||||
|
let pickIdx = 0;
|
||||||
|
const bridge = {
|
||||||
|
getIds: () => [] as string[],
|
||||||
|
setIds: () => {},
|
||||||
|
hitTest: () => pickQueue[Math.min(pickIdx++, pickQueue.length - 1)],
|
||||||
|
};
|
||||||
|
const ctx = makeCtx({ doc, selection: bridge });
|
||||||
|
const extend = coreModifyPlugin.tools!.find((t) => t.manifest.id === 'extend')!;
|
||||||
|
|
||||||
|
extend.handlers.down!(pe(100, 40), ctx);
|
||||||
|
extend.handlers.down!(pe(45, 0), ctx);
|
||||||
|
|
||||||
|
const ext = doc.getElement('sl')!;
|
||||||
|
// Extend richtet das nahe Ende exakt auf den Schnittpunkt aus:
|
||||||
|
expect(ext.properties.x2).toBeCloseTo(60);
|
||||||
|
|
||||||
|
expect(doc.undo()).toBe(true);
|
||||||
|
expect(doc.getElement('sl')!.properties.x2).toBe(50); // Original-Länge
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user