Files
web-cad/frontend/src/plugins/builtin/core-annotate/index.ts
T

402 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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],
};