402 lines
16 KiB
TypeScript
402 lines
16 KiB
TypeScript
/**
|
||
* 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<string, unknown>).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],
|
||
};
|