/** * core-annotate – Built-in V2-Plugin (Task A4.4). * * Werkzeuge: text (Platzierung — Inhalt via InlineTextEditor/PropertiesPanel, * Bestandskanal onTextEdit bleibt), dimension (2-Punkt linear), leader * (2-Punkt Pfeil+Label). */ import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types'; import { DEFAULT_DIM_STYLE, getDimStyle, computeDimValue, formatDimValue, } from '../../../services/dimStyleService'; 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)}`; /** Gemeinsamer 2-Punkt-Sitzungsstart für dimension/leader. */ let twoPointStart: Pt | null = null; function optsOf(ctx: FullToolContext): { stroke: string; layerId: string } { return { stroke: (ctx.options.strokeColor as string | undefined) ?? '#e0e0e0', layerId: (ctx.options.layerId as string | undefined) ?? 'layer-0', }; } function buildDimension(start: Pt, end: Pt, ctx: FullToolContext): CADElement { const o = optsOf(ctx); return { id: uid('dim'), type: 'dimension', layerId: o.layerId, x: (start.x + end.x) / 2, y: (start.y + end.y) / 2, width: Math.hypot(end.x - start.x, end.y - start.y), // Legacy: dist als Breite height: 20, properties: { x1: start.x, y1: start.y, x2: end.x, y2: end.y }, } as unknown as CADElement; } function buildLeader(start: Pt, end: Pt, ctx: FullToolContext): CADElement { const o = optsOf(ctx); return { id: uid('leader'), type: 'leader', layerId: o.layerId, x: end.x, y: end.y, 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, text: '', fontSize: 12, stroke: o.stroke, strokeWidth: 1 }, } as unknown as CADElement; } /** Generischer 2-Punkt-Annotate-Fluss (down→Preview→up commit). */ function make2PointAnnotate( id: 'dimension' | 'leader', label: string, icon: string, build: (a: Pt, b: Pt, ctx: FullToolContext) => CADElement, ): ToolExtensionV2 { return { manifest: { id, label, icon, ribbonTab: 'format', tags: ['basic'] }, optionsSchema: [{ key: 'strokeColor', label: 'Farbe', type: 'color' }], handlers: { down(e, ctx) { twoPointStart = e.world; ctx.setStatus(`${label}: Startpunkt gesetzt – Ende wählen`); }, move(e, ctx) { if (!twoPointStart) return; ctx.setPreview?.(build(twoPointStart, e.world, ctx as FullToolContext)); }, up(e, ctx) { const c = ctx as FullToolContext; if (!twoPointStart || !c.doc) { twoPointStart = null; return; } const el = build(twoPointStart, e.world, c); c.doc.transact(() => c.doc!.addElement(el)); twoPointStart = null; ctx.setPreview?.(null); ctx.setStatus(`${label} erstellt (${el.id})`); }, cancel(ctx) { twoPointStart = null; ctx.setPreview?.(null); ctx.setStatus(`${label} abgebrochen`); }, }, }; } /** * text-Werkzeug (A4.4): Klick platziert Textelement mit leerem Inhalt; * Bearbeitung läuft über den bestehenden InlineTextEditor/onTextEdit-Kanal. */ export const textTool: ToolExtensionV2 = { manifest: { id: 'text', label: 'Text', icon: 'T', ribbonTab: 'format', tags: ['basic'], description: 'Text platzieren und über Editor beschriften', }, optionsSchema: [{ key: 'fontSize', label: 'Größe', type: 'number', min: 6, max: 72 }], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc) return; const layerId = (c.options.layerId as string | undefined) ?? 'layer-0'; const fontSize = (c.options.fontSize as number | undefined) ?? 12; const el = { id: uid('text'), type: 'text', layerId, x: e.world.x, y: e.world.y, width: 100, height: 20, properties: { text: '', fontSize }, } as unknown as CADElement; c.doc.transact(() => c.doc!.addElement(el)); ctx.setStatus(`Text platziert (${el.id}) – now via Editor`); }, }, }; export const dimensionTool: ToolExtensionV2 = make2PointAnnotate('dimension', 'Bemaßung', '\u2194', buildDimension); export const leaderTool: ToolExtensionV2 = make2PointAnnotate('leader', 'Leader', '\u21b1', buildLeader); /** V2-Plugin: Beschriftung/Bemaßung (A4.4). */ // ───────────────────────────────────────────────────────────── // Task D4: MTEXT — Mehrzeiliger Text mit optionalem Rahmen. // Klick platziert Text (Inhalt aus Options, Zeilenumbrüche erhalten), // frame-Option erzeugt ein rect in derselben Transaktion (1 Undo). // ───────────────────────────────────────────────────────────── export const mtextTool: ToolExtensionV2 = { manifest: { id: 'mtext', label: 'Mehrzeilentext', icon: '¶', ribbonTab: 'format', tags: ['basic'], description: 'Mehrzeiligen Text platzieren (Rahmen optional, Task D4)', }, optionsSchema: [ { key: 'text', label: 'Text', type: 'text' }, { key: 'fontSize', label: 'Größe', type: 'number', min: 6, max: 72 }, { key: 'frame', label: 'Rahmen', type: 'checkbox' }, ], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc) return; const layerId = (c.options.layerId as string | undefined) ?? 'layer-0'; const text = (c.options.text as string | undefined) ?? ''; const fontSize = (c.options.fontSize as number | undefined) ?? 12; const frame = (c.options.frame as boolean | undefined) ?? false; // Newline ohne Escape-Probleme: charCode 10 const NL = String.fromCharCode(10); const lines = text.split(NL); const lineCount = Math.max(1, lines.length); const maxLen = lines.reduce((m, l) => Math.max(m, l.length), 0); const textW = maxLen * fontSize * 0.6; const textH = lineCount * fontSize * 1.4; const els: CADElement[] = [{ id: uid('mtext'), type: 'text', layerId, x: e.world.x, y: e.world.y, width: textW, height: textH, properties: { text, fontSize, mtext: true }, } as unknown as CADElement]; if (frame) { const pad = fontSize * 0.4; els.push({ id: uid('mtextframe'), type: 'rect', layerId, x: e.world.x, y: e.world.y, width: textW + pad * 2, height: textH + pad * 2, properties: { stroke: '#9aa0a6', strokeWidth: 1, fill: 'none' }, } as unknown as CADElement); } c.doc.transact(() => { for (const el of els) c.doc!.addElement(el); }); ctx.setStatus(`Mehrzeilentext platziert (${lineCount} Zeile(n)${frame ? ', mit Rahmen' : ''})`); }, }, }; // ───────────────────────────────────────────────────────────── // Task CAD-1: Assoziative Bemassung — 5 Typen mit DimStyles. // linear (H/V), aligned, angular (3 Klicks), radius, diameter. // Alle berechnen den Masswert aus der Geometrie (assoziativ). // ───────────────────────────────────────────────────────────── let dimStart: Pt | null = null; let dimVertex: Pt | null = null; // angular: Scheitel function dimStyleOf(ctx: FullToolContext) { const name = (ctx.options.dimStyle as string | undefined) ?? 'ISO-25'; return getDimStyle(name); } function commitDim(el: CADElement, ctx: FullToolContext, noun: string): void { const c = ctx as FullToolContext; if (!c.doc) return; c.doc.transact(() => c.doc!.addElement(el)); ctx.setPreview?.(null); ctx.setStatus(`${noun} erstellt: ${String(el.properties.value)}`); } export const dimLinearTool: ToolExtensionV2 = { manifest: { id: 'dim-linear', label: 'Linear-Bemassung', icon: '\u2194', ribbonTab: 'format', tags: ['basic'], description: 'Lineare Bemassung: 2 Punkte, horizontal/vertikal (Option)', }, optionsSchema: [ { key: 'dimStyle', label: 'Stil', type: 'select', options: [{ value: 'ISO-25', label: 'ISO-25' }, { value: 'ISO-50', label: 'ISO-50' }, { value: 'Architectural', label: 'Architektur' }, { value: 'Engineering', label: 'Engineering' }] }, { key: 'dimSubtype', label: 'Ausrichtung', type: 'select', options: [{ value: 'horizontal', label: 'Horizontal' }, { value: 'vertical', label: 'Vertikal' }] }, ], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc) return; if (!dimStart) { dimStart = e.world; ctx.setStatus('Bemassung: zweiten Punkt wählen'); return; } const subtype = (c.options.dimSubtype as string | undefined) ?? 'horizontal'; const style = dimStyleOf(c); const raw = computeDimValue('linear', dimStart, e.world, undefined, subtype); const el: CADElement = { id: uid('dim'), type: 'dimension', layerId: (c.options.layerId as string | undefined) ?? 'layer-0', x: (dimStart.x + e.world.x) / 2, y: (dimStart.y + e.world.y) / 2, width: Math.abs(e.world.x - dimStart.x), height: Math.abs(e.world.y - dimStart.y), properties: { x1: dimStart.x, y1: dimStart.y, x2: e.world.x, y2: e.world.y, dimType: 'linear', dimSubtype: subtype, value: formatDimValue(raw, style), dimStyle: style.name, }, } as unknown as CADElement; dimStart = null; commitDim(el, ctx, 'Linearbemassung'); }, move(e, ctx) { const c = ctx as FullToolContext; if (!dimStart) return; const subtype = (c.options.dimSubtype as string | undefined) ?? 'horizontal'; const style = dimStyleOf(c); const raw = computeDimValue('linear', dimStart, e.world, undefined, subtype); ctx.setPreview?.({ id: '__preview_dim__', type: 'dimension', layerId: 'layer-0', x: (dimStart.x + e.world.x) / 2, y: (dimStart.y + e.world.y) / 2, width: Math.abs(e.world.x - dimStart.x), height: Math.abs(e.world.y - dimStart.y), properties: { x1: dimStart.x, y1: dimStart.y, x2: e.world.x, y2: e.world.y, dimType: 'linear', dimSubtype: subtype, value: formatDimValue(raw, style), dimStyle: style.name, }, } as unknown as CADElement); }, cancel(ctx) { dimStart = null; ctx.setPreview?.(null); ctx.setStatus('Bemassung abgebrochen'); }, }, }; export const dimAlignedTool: ToolExtensionV2 = { manifest: { id: 'dim-aligned', label: 'Ausgerichtet', icon: '\u2197', ribbonTab: 'format', tags: ['basic'], description: 'Bemassung entlang der Punkte-Verbindung', }, optionsSchema: [ { key: 'dimStyle', label: 'Stil', type: 'select', options: [{ value: 'ISO-25', label: 'ISO-25' }, { value: 'ISO-50', label: 'ISO-50' }] }, ], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc) return; if (!dimStart) { dimStart = e.world; ctx.setStatus('Ausgerichtet: zweiten Punkt wählen'); return; } const style = dimStyleOf(c); const raw = computeDimValue('aligned', dimStart, e.world); const el: CADElement = { id: uid('dim'), type: 'dimension', layerId: (c.options.layerId as string | undefined) ?? 'layer-0', x: (dimStart.x + e.world.x) / 2, y: (dimStart.y + e.world.y) / 2, width: Math.abs(e.world.x - dimStart.x), height: Math.abs(e.world.y - dimStart.y), properties: { x1: dimStart.x, y1: dimStart.y, x2: e.world.x, y2: e.world.y, dimType: 'aligned', value: formatDimValue(raw, style), dimStyle: style.name, }, } as unknown as CADElement; dimStart = null; commitDim(el, ctx, 'Ausgerichtete Bemassung'); }, cancel(ctx) { dimStart = null; ctx.setPreview?.(null); ctx.setStatus('Abgebrochen'); }, }, }; export const dimAngularTool: ToolExtensionV2 = { manifest: { id: 'dim-angular', label: 'Winkel', icon: '\u2220', ribbonTab: 'format', tags: ['pro'], description: 'Winkelbemassung: Scheitel, Schenkel 1, Schenkel 2', }, optionsSchema: [ { key: 'dimStyle', label: 'Stil', type: 'select', options: [{ value: 'ISO-25', label: 'ISO-25' }] }, ], handlers: { down(e, ctx) { const c = ctx as FullToolContext; if (!c.doc) return; if (!dimVertex) { dimVertex = e.world; ctx.setStatus('Winkel: Schenkel 1 klicken'); return; } if (!dimStart) { dimStart = e.world; ctx.setStatus('Winkel: Schenkel 2 klicken'); return; } const style = dimStyleOf(c); const leg1 = { x: dimStart.x - dimVertex.x, y: dimStart.y - dimVertex.y }; const leg2 = { x: e.world.x - dimVertex.x, y: e.world.y - dimVertex.y }; const raw = computeDimValue('angular', leg1, leg2); const el: CADElement = { id: uid('dim'), type: 'dimension', layerId: (c.options.layerId as string | undefined) ?? 'layer-0', x: dimVertex.x, y: dimVertex.y, width: 0, height: 0, properties: { dimType: 'angular', value: formatDimValue(raw, style, 'angular'), vertexX: dimVertex.x, vertexY: dimVertex.y, leg1X: dimStart.x, leg1Y: dimStart.y, leg2X: e.world.x, leg2Y: e.world.y, dimStyle: style.name, }, } as unknown as CADElement; dimVertex = null; dimStart = null; commitDim(el, ctx, 'Winkelbemassung'); }, cancel(ctx) { dimVertex = null; dimStart = null; ctx.setPreview?.(null); ctx.setStatus('Abgebrochen'); }, }, }; function makeRadialDim(mode: 'radius' | 'diameter'): ToolExtensionV2 { return { manifest: { id: mode === 'radius' ? 'dim-radius' : 'dim-diameter', label: mode === 'radius' ? 'Radius' : 'Durchmesser', icon: mode === 'radius' ? '\u2199' : '\u2300', ribbonTab: 'format', tags: ['basic'], description: mode === 'radius' ? 'Radius bemassen (Kreis anklicken)' : 'Durchmesser bemassen (Kreis anklicken)', }, optionsSchema: [ { key: 'dimStyle', label: 'Stil', type: 'select', options: [{ value: 'ISO-25', label: 'ISO-25' }] }, ], 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, 8); if (!hit || hit.type !== 'circle') { ctx.setStatus(`${mode === 'radius' ? 'Radius' : 'Durchmesser'}: Kreis anklicken`); return; } const style = dimStyleOf(c); const raw = computeDimValue(mode, undefined, undefined, hit); const el: CADElement = { id: uid('dim'), type: 'dimension', layerId: hit.layerId, x: hit.x, y: hit.y, width: hit.width, height: hit.height, properties: { dimType: mode, value: formatDimValue(raw, style, mode), centerX: hit.x, centerY: hit.y, arrowX: hit.x + ((hit.properties as Record).radius as number ?? hit.width / 2), arrowY: hit.y, dimStyle: style.name, }, } as unknown as CADElement; commitDim(el, ctx, mode === 'radius' ? 'Radiusbemassung' : 'Durchmesserbemassung'); }, }, }; } export const dimRadiusTool: ToolExtensionV2 = makeRadialDim('radius'); export const dimDiameterTool: ToolExtensionV2 = makeRadialDim('diameter'); export const coreAnnotatePlugin: PluginV2 = { manifest: { id: 'core-annotate', name: 'Beschriftung (Kern)', version: '1.1.0', author: 'web-cad team', description: 'Text, Bemaßung, Leader (V2).', category: 'tools', enabledByDefault: true, }, tools: [textTool, dimensionTool, leaderTool, mtextTool, dimLinearTool, dimAlignedTool, dimAngularTool, dimRadiusTool, dimDiameterTool], };