diff --git a/frontend/src/plugins/builtin/core-modify/index.ts b/frontend/src/plugins/builtin/core-modify/index.ts index 30e463c..9ded147 100644 --- a/frontend/src/plugins/builtin/core-modify/index.ts +++ b/frontend/src/plugins/builtin/core-modify/index.ts @@ -388,6 +388,213 @@ function makeArrayTool(mode: 'array-rect' | 'array-polar'): ToolExtensionV2 { export const arrayRectTool: ToolExtensionV2 = makeArrayTool('array-rect'); export const arrayPolarTool: ToolExtensionV2 = makeArrayTool('array-polar'); +// ───────────────────────────────────────────────────────────── +// Chamfer (Task D-Ext): schneidet zwei sich schneidende Linien an einer +// festen Distanz vom Schnittpunkt und verbindet die Enden mit einer +// geraden Fase. Legacy nutzt trimElement + Verbindungslinie. +// Klick 1: erste Linie, Klick 2: zweite Linie → 3 Elemente (2 gekürzt + 1 Fase). +// ───────────────────────────────────────────────────────────── +let chamferFirst: CADElement | null = null; + +export const chamferTool: ToolExtensionV2 = { + manifest: { + id: 'chamfer', label: 'Chamfer', icon: '\u25e4', ribbonTab: 'canvas', tags: ['pro'], + description: 'Zwei Linien anklicken — Fase wird eingefügt', + }, + optionsSchema: [ + { key: 'chamferDist', label: 'Fase-Distanz', type: 'number', min: 1, max: 100 }, + ], + 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 (!chamferFirst) { + chamferFirst = hit; + ctx.setStatus('Chamfer: zweite Linie wählen'); + return; + } + const dist = (c.options.chamferDist as number | undefined) ?? 10; + // Beide Linien auf dist vom Schnittpunkt kürzen: + const t1 = trimElement(chamferFirst, hit); + const t2 = trimElement(hit, chamferFirst); + if (!t1 || !t2) { + ctx.setStatus('Chamfer: kein Schnittpunkt'); + chamferFirst = null; + return; + } + // Endpunkte nahe am Schnittpunkt holen und um dist zurücksetzen: + const props1 = t1.properties as { x1?: number; y1?: number; x2?: number; y2?: number }; + const props2 = t2.properties as { x1?: number; y1?: number; x2?: number; y2?: number }; + if (props1.x1 === undefined || props1.y1 === undefined || props1.x2 === undefined || props1.y2 === undefined || + props2.x1 === undefined || props2.y1 === undefined || props2.x2 === undefined || props2.y2 === undefined) { + chamferFirst = null; + return; + } + // Nahe Enden (Schnittpunkt-Seite) identifizieren via Schnittpunkt: + const isectPt = findIsectPoint(chamferFirst, hit); + if (!isectPt) { chamferFirst = null; return; } + const near1 = nearEnd(props1 as { x1: number; y1: number; x2: number; y2: number }, isectPt); + const near2 = nearEnd(props2 as { x1: number; y1: number; x2: number; y2: number }, isectPt); + // Kürze nahe Enden auf dist vom Schnittpunkt: + const shrink1 = shrinkTowards(props1 as { x1: number; y1: number; x2: number; y2: number }, near1, isectPt, dist); + const shrink2 = shrinkTowards(props2 as { x1: number; y1: number; x2: number; y2: number }, near2, isectPt, dist); + // Fase: Linie zwischen den neuen Enden: + const chamferLine: CADElement = { + ...chamferFirst, + id: uid('cham'), + type: 'line', + x: (shrink1.near.x + shrink2.near.x) / 2, + y: (shrink1.near.y + shrink2.near.y) / 2, + width: Math.abs(shrink2.near.x - shrink1.near.x), + height: Math.abs(shrink2.near.y - shrink1.near.y), + properties: { + x1: shrink1.near.x, y1: shrink1.near.y, + x2: shrink2.near.x, y2: shrink2.near.y, + stroke: '#e0e0e0', strokeWidth: 1, + }, + }; + c.doc.transact(() => { + c.doc!.updateElement(chamferFirst!.id, { + x: shrink1.cx, y: shrink1.cy, + width: Math.abs(props1.x2! - props1.x1!), + height: Math.abs(props1.y2! - props1.y1!), + properties: props1, + }); + c.doc!.updateElement(hit.id, { + x: shrink2.cx, y: shrink2.cy, + width: Math.abs(props2.x2! - props2.x1!), + height: Math.abs(props2.y2! - props2.y1!), + properties: props2, + }); + c.doc!.addElement(chamferLine); + }); + ctx.setStatus('Chamfer erstellt'); + chamferFirst = null; + }, + cancel(ctx) { + chamferFirst = null; + ctx.setStatus('Chamfer abgebrochen'); + }, + }, +}; + +function findIsectPoint(a: CADElement, b: CADElement): Pt | null { + const pa = a.properties as { x1?: number; y1?: number; x2?: number; y2?: number }; + const pb = b.properties as { x1?: number; y1?: number; x2?: number; y2?: number }; + if (pa.x1 === undefined || pb.x1 === undefined) return null; + const denom = (pa.x1! - pa.x2!) * (pb.y1! - pb.y2!) - (pa.y1! - pa.y2!) * (pb.x1! - pb.x2!); + if (Math.abs(denom) < 1e-10) return null; + const t = ((pa.x1! - pb.x1!) * (pb.y1! - pb.y2!) - (pa.y1! - pb.y1!) * (pb.x1! - pb.x2!)) / denom; + return { x: pa.x1! + t * (pa.x2! - pa.x1!), y: pa.y1! + t * (pa.y2! - pa.y1!) }; +} + +function nearEnd(props: { x1: number; y1: number; x2: number; y2: number }, pt: Pt): 'start' | 'end' { + const d1 = Math.hypot(props.x1 - pt.x, props.y1 - pt.y); + const d2 = Math.hypot(props.x2 - pt.x, props.y2 - pt.y); + return d1 < d2 ? 'start' : 'end'; +} + +function shrinkTowards( + props: { x1: number; y1: number; x2: number; y2: number }, + near: 'start' | 'end', + isectPt: Pt, + dist: number, +): { near: Pt; cx: number; cy: number } { + if (near === 'start') { + const len = Math.hypot(props.x2 - props.x1, props.y2 - props.y1); + if (len === 0) return { near: { x: props.x1, y: props.y1 }, cx: props.x1, cy: props.y1 }; + const ratio = Math.min(dist / len, 1); + const nx = props.x1 + (props.x2 - props.x1) * ratio; + const ny = props.y1 + (props.y2 - props.y1) * ratio; + return { near: { x: nx, y: ny }, cx: (nx + props.x2) / 2, cy: (ny + props.y2) / 2 }; + } + const len = Math.hypot(props.x2 - props.x1, props.y2 - props.y1); + if (len === 0) return { near: { x: props.x2, y: props.y2 }, cx: props.x2, cy: props.y2 }; + const ratio = Math.min(dist / len, 1); + const nx = props.x2 + (props.x1 - props.x2) * ratio; + const ny = props.y2 + (props.y1 - props.y2) * ratio; + return { near: { x: nx, y: ny }, cx: (nx + props.x1) / 2, cy: (ny + props.y1) / 2 }; +} + +// ───────────────────────────────────────────────────────────── +// Lengthen (Task D-Ext): verlängert/verkürzt eine Linie am geklickten +// Ende um einen Delta-Wert. Klick 1: Linie, Klick 2: Seite. +// ───────────────────────────────────────────────────────────── +let lengthenEl: CADElement | null = null; + +export const lengthenTool: ToolExtensionV2 = { + manifest: { + id: 'lengthen', label: 'Lengthen', icon: '\u2194', ribbonTab: 'canvas', tags: ['pro'], + description: 'Linie anklicken, dann Delta-Ziel klicken (Options: lengthenDelta)', + }, + optionsSchema: [ + { key: 'lengthenDelta', label: 'Delta', type: 'number' }, + ], + handlers: { + down(e, ctx) { + const c = ctx as FullToolContext; + if (!c.doc || !c.selection) return; + if (!lengthenEl) { + const hit = c.selection.hitTest(e.world.x, e.world.y, 5); + if (!hit || hit.type !== 'line') { + ctx.setStatus('Lengthen: nur Linien'); + return; + } + lengthenEl = hit; + ctx.setStatus('Lengthen: Klick in Verlängerungsrichtung'); + return; + } + const el = c.doc.getElement(lengthenEl.id); + if (!el) { lengthenEl = null; return; } + const props = el.properties as { x1?: number; y1?: number; x2?: number; y2?: number }; + if (props.x1 === undefined || props.y1 === undefined || props.x2 === undefined || props.y2 === undefined) { + lengthenEl = null; + return; + } + const delta = (c.options.lengthenDelta as number | undefined) ?? 10; + // Wähle das Ende, das dem Klick am nächsten ist: + const dStart = Math.hypot(e.world.x - (props.x1 as number), e.world.y - (props.y1 as number)); + const dEnd = Math.hypot(e.world.x - (props.x2 as number), e.world.y - (props.y2 as number)); + const nearIsStart = dStart < dEnd; + // Richtungsvektor (vom fernen Ende zum nahen Ende): + const dirX = nearIsStart ? (props.x1! - props.x2!) : (props.x2! - props.x1!); + const dirY = nearIsStart ? (props.y1! - props.y2!) : (props.y2! - props.y1!); + const len = Math.hypot(dirX, dirY); + if (len === 0) { lengthenEl = null; return; } + const ux = dirX / len; + const uy = dirY / len; + if (nearIsStart) { + props.x1 = (props.x1 as number) + ux * delta; + props.y1 = (props.y1 as number) + uy * delta; + } else { + props.x2 = (props.x2 as number) + ux * delta; + props.y2 = (props.y2 as number) + uy * delta; + } + const nx1 = props.x1 as number; + const ny1 = props.y1 as number; + const nx2 = props.x2 as number; + const ny2 = props.y2 as number; + c.doc.transact(() => { + c.doc!.updateElement(el.id, { + x: (nx1 + nx2) / 2, + y: (ny1 + ny2) / 2, + width: Math.abs(nx2 - nx1), + height: Math.abs(ny2 - ny1), + properties: props, + }); + }); + ctx.setStatus(`Lengthen: Linie um ${delta} angepasst`); + lengthenEl = null; + }, + cancel(ctx) { + lengthenEl = null; + ctx.setStatus('Lengthen abgebrochen'); + }, + }, +}; + // ───────────────────────────────────────────────────────────── // Trim / Extend / Fillet (Task A4.10) — baut auf BUG-2-reparierter // findIntersection-Geometrie (B2). Legacy-Ablauf: @@ -581,11 +788,11 @@ export const coreModifyPlugin: PluginV2 = { manifest: { id: 'core-modify', name: 'Bearbeiten (Kern)', - version: '1.6.0', + version: '1.7.0', author: 'web-cad team', - description: 'Auswahl, Schraffur, Move/Copy/Rotate/Scale/Mirror/Delete/Offset/Trim/Extend/Fillet/Arrays (V2).', + description: 'Auswahl, Schraffur, Move/Copy/Rotate/Scale/Mirror/Delete/Offset/Trim/Extend/Fillet/Arrays/Chamfer/Lengthen (V2).', category: 'tools', enabledByDefault: true, }, - tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool], + tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool], };