diff --git a/frontend/src/plugins/builtin/core-annotate/index.ts b/frontend/src/plugins/builtin/core-annotate/index.ts index b303456..34a403f 100644 --- a/frontend/src/plugins/builtin/core-annotate/index.ts +++ b/frontend/src/plugins/builtin/core-annotate/index.ts @@ -6,6 +6,9 @@ * (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'; @@ -193,6 +196,197 @@ export const mtextTool: ToolExtensionV2 = { }, }; +// ───────────────────────────────────────────────────────────── +// 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', @@ -203,5 +397,5 @@ export const coreAnnotatePlugin: PluginV2 = { category: 'tools', enabledByDefault: true, }, - tools: [textTool, dimensionTool, leaderTool, mtextTool], + tools: [textTool, dimensionTool, leaderTool, mtextTool, dimLinearTool, dimAlignedTool, dimAngularTool, dimRadiusTool, dimDiameterTool], }; diff --git a/frontend/src/render/pixi/elementDrawers.ts b/frontend/src/render/pixi/elementDrawers.ts index 2015128..269b18b 100644 --- a/frontend/src/render/pixi/elementDrawers.ts +++ b/frontend/src/render/pixi/elementDrawers.ts @@ -15,6 +15,7 @@ * - arc-Winkel in Grad, 0° = 3 Uhr, gegen den Uhrzeigersinn */ import type { CADElement } from '../../types/cad.types'; +import { getDimStyle } from '../../services/dimStyleService'; import type { Pt } from '../../tools/modification/geometry'; /** Zeichenprimitiv — kleinste darstellbare Einheit. */ @@ -42,6 +43,29 @@ function fillOrNull(raw: unknown): string | null { * Gibt ein leeres Array zurück für unbekannte Typen bzw. Elemente, * die nichts Sichtbares beitragen (z.B. leerer Text). */ +/** + * Pfeilspitze als geschlossenes Polygon (Task CAD-1). + * Spitze bei (x,y), Richtung ang (rad), Laenge size. + */ +function arrowPrimitives(x: number, y: number, ang: number, size: number, color: string): ShapePrimitive[] { + const halfW = size * 0.35; + const bx = x - size * Math.cos(ang); + const by = y - size * Math.sin(ang); + const px = -Math.sin(ang) * halfW; + const py = Math.cos(ang) * halfW; + return [{ + kind: 'polyline', + points: [ + { x, y }, + { x: bx + px, y: by + py }, + { x: bx - px, y: by - py }, + ], + close: true, + stroke: color, + strokeWidth: 1, + }]; +} + export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: string; prompt: string; defaultValue?: string }>): ShapePrimitive[] { const props = (el.properties ?? {}) as Record; const stroke = (props.stroke as string | undefined) ?? FALLBACK_STROKE; @@ -133,25 +157,75 @@ export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: stri // ── Task C3: bisher im Pixi-Pfad fehlende Typen ── case 'dimension': { - // Legacy-Konvention: x1..y2 in properties, BBox-Zentrum als x/y, - // Extension lines + Hauptlinie; Pfeilspitzen sind Detail (C3+) + // Task CAD-1: komplette Bemassung mit Extension lines, Masslinie, + // Pfeilspitzen (geschlossene Polygone) und Wert-Text nach DimStyle. + const dimType = (props.dimType as string | undefined) ?? 'linear'; + const style = getDimStyle(props.dimStyle as string | undefined); + const dimColor = (props.dimColor as string | undefined) ?? (props.color as string | undefined) ?? '#aaaaaa'; + const label = String(props.value ?? props.text ?? ''); + + if (dimType === 'radius' || dimType === 'diameter') { + const cx = (props.centerX as number | undefined) ?? el.x; + const cy = (props.centerY as number | undefined) ?? el.y; + const ax = (props.arrowX as number | undefined) ?? cx + el.width / 2; + const ay = (props.arrowY as number | undefined) ?? cy; + const out: ShapePrimitive[] = [ + { kind: 'line', from: { x: cx, y: cy }, to: { x: ax, y: ay }, stroke: dimColor, strokeWidth: 1 }, + ]; + out.push(...arrowPrimitives(ax, ay, Math.atan2(ay - cy, ax - cx), style.arrowSize, dimColor)); + if (label) { + out.push({ kind: 'text', at: { x: (cx + ax) / 2, y: (cy + ay) / 2 - style.textHeight * 0.6 }, text: label, fontSize: style.textHeight * 4, color: dimColor }); + } + return out; + } + + if (dimType === 'angular') { + const vx = (props.vertexX as number | undefined) ?? el.x; + const vy = (props.vertexY as number | undefined) ?? el.y; + const l1x = (props.leg1X as number | undefined) ?? vx + 100; + const l1y = (props.leg1Y as number | undefined) ?? vy; + const l2x = (props.leg2X as number | undefined) ?? vx; + const l2y = (props.leg2Y as number | undefined) ?? vy + 100; + const out: ShapePrimitive[] = [ + { kind: 'line', from: { x: vx, y: vy }, to: { x: l1x, y: l1y }, stroke: dimColor, strokeWidth: 1 }, + { kind: 'line', from: { x: vx, y: vy }, to: { x: l2x, y: l2y }, stroke: dimColor, strokeWidth: 1 }, + ]; + if (label) { + out.push({ kind: 'text', at: { x: vx + 30, y: vy - 10 }, text: label, fontSize: style.textHeight * 4, color: dimColor }); + } + return out; + } + + // linear / aligned const x1 = (props.x1 as number | undefined) ?? el.x - el.width / 2; const y1 = (props.y1 as number | undefined) ?? el.y; const x2 = (props.x2 as number | undefined) ?? el.x + el.width / 2; const y2 = (props.y2 as number | undefined) ?? el.y; - const color = (props.color as string | undefined) ?? '#aaaaaa'; - const dimColor = (props.dimColor as string | undefined) ?? color; - const label = String(props.value ?? props.text ?? ''); + const subtype = (props.dimSubtype as string | undefined) ?? 'horizontal'; + + // Masslinie: bei horizontal bei y = min(y1,y2) - offset; vertical bei x analog + const off = 15; + let mx1 = x1, my1 = y1, mx2 = x2, my2 = y2; + if (subtype === 'horizontal') { my1 = Math.min(y1, y2) - off; my2 = my1; } + else if (subtype === 'vertical') { mx1 = Math.min(x1, x2) - off; mx2 = mx1; } + else { /* aligned: Masslinie = Punkte-Verbindung */ } + const out: ShapePrimitive[] = [ - { kind: 'line', from: { x: x1, y: y1 }, to: { x: x2, y: y2 }, stroke: dimColor, strokeWidth: 1 }, + // Extension lines + { kind: 'line', from: { x: x1, y: y1 }, to: { x: x1, y: my1 }, stroke: '#888888', strokeWidth: 0.5 }, + { kind: 'line', from: { x: x2, y: y2 }, to: { x: x2, y: my2 }, stroke: '#888888', strokeWidth: 0.5 }, + // Masslinie + { kind: 'line', from: { x: mx1, y: my1 }, to: { x: mx2, y: my2 }, stroke: dimColor, strokeWidth: 1 }, ]; + // Pfeilspitzen an beiden Enden der Masslinie + const ang1 = Math.atan2(my2 - my1, mx2 - mx1); + out.push(...arrowPrimitives(mx1, my1, ang1, style.arrowSize, dimColor)); + out.push(...arrowPrimitives(mx2, my2, ang1 + Math.PI, style.arrowSize, dimColor)); if (label) { out.push({ kind: 'text', - at: { x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 12 }, - text: label, - fontSize: 10, - color: dimColor, + at: { x: (mx1 + mx2) / 2, y: (my1 + my2) / 2 - style.textHeight * 0.6 }, + text: label, fontSize: style.textHeight * 4, color: dimColor, }); } return out; diff --git a/frontend/src/services/dimStyleService.ts b/frontend/src/services/dimStyleService.ts new file mode 100644 index 0000000..f884c95 --- /dev/null +++ b/frontend/src/services/dimStyleService.ts @@ -0,0 +1,110 @@ +/** + * Task CAD-1 – Bemassungsstile (DimStyles) + assoziative Wert-Berechnung. + * + * DimStyle: ISO-artige Stile (Text-/Pfeilhoehe, Einheit, Dezimalstellen, + * Pfeilstil, Linien-Offsets). computeDimValue berechnet den Masswert + * aus der Geometrie (assoziativ), formatDimValue erzeugt den Text. + */ +import type { CADElement } from '../types/cad.types'; +import type { Pt } from '../tools/modification/geometry'; + +export type DimTypeName = 'linear' | 'aligned' | 'angular' | 'radius' | 'diameter'; + +export interface DimStyle { + name: string; + /** Text-Hoehe in Welt-Einheiten. */ + textHeight: number; + /** Pfeil-Laenge in Welt-Einheiten. */ + arrowSize: number; + unit: 'mm' | 'cm' | 'm'; + decimals: number; + arrowStyle: 'closed' | 'open' | 'dot' | 'tick'; + /** Offset Extension-Linie zu Messpunkt. */ + extOffset: number; + /** Ueberstand der Masslinie ueber Pfeile. */ + overshoot: number; +} + +export const DEFAULT_DIM_STYLE: DimStyle = { + name: 'ISO-25', + textHeight: 2.5, + arrowSize: 2.5, + unit: 'mm', + decimals: 0, + arrowStyle: 'closed', + extOffset: 0.625, + overshoot: 1.25, +}; + +export const DIM_STYLES: DimStyle[] = [ + DEFAULT_DIM_STYLE, + { ...DEFAULT_DIM_STYLE, name: 'ISO-50', textHeight: 5, arrowSize: 5 }, + { ...DEFAULT_DIM_STYLE, name: 'Architectural', textHeight: 3, arrowSize: 2, arrowStyle: 'tick' }, + { ...DEFAULT_DIM_STYLE, name: 'Engineering', textHeight: 2.5, arrowSize: 2.5, decimals: 2 }, +]; + +/** Stil per Name (Fallback: Default). */ +export function getDimStyle(name: string | undefined): DimStyle { + if (!name) return DEFAULT_DIM_STYLE; + return DIM_STYLES.find((s) => s.name === name) ?? DEFAULT_DIM_STYLE; +} + +/** + * Assoziative Masswert-Berechnung aus der Geometrie. + * linear: |dx| (horizontal) bzw. |dy| (vertikal) — der Aufrufer waehlt + * die Achse; hier wird die Gesamt-Projektion geliefert und das + * Tool waehlt horizontal/vertikal anhand dimSubtype. + * aligned: Distanz zwischen p1 und p2. + * angular: Winkel zwischen zwei Schenkeln am Scheitel (Grad). + * radius/diameter: aus einem Kreis-Element. + */ +export function computeDimValue( + type: DimTypeName, + p1?: Pt, + p2?: Pt, + element?: CADElement, + subtype?: string, +): number { + if (type === 'linear' && p1 && p2) { + if (subtype === 'vertical') return Math.abs(p2.y - p1.y); + if (subtype === 'horizontal') return Math.abs(p2.x - p1.x); + // Ohne explizites Subtype: dominante Achse (CAD-Standardverhalten) + return Math.abs(p2.x - p1.x) >= Math.abs(p2.y - p1.y) + ? Math.abs(p2.x - p1.x) + : Math.abs(p2.y - p1.y); + } + if (type === 'aligned' && p1 && p2) { + return Math.hypot(p2.x - p1.x, p2.y - p1.y); + } + if (type === 'radius' && element) { + const r = (element.properties as Record).radius as number | undefined; + return r ?? element.width / 2; + } + if (type === 'diameter' && element) { + const r = (element.properties as Record).radius as number | undefined; + return (r ?? element.width / 2) * 2; + } + if (type === 'angular' && p1 && p2) { + const ang1 = Math.atan2(p1.y, p1.x); + const ang2 = Math.atan2(p2.y, p2.x); + let diff = Math.abs(ang2 - ang1) * 180 / Math.PI; + if (diff > 180) diff = 360 - diff; + return diff; + } + return 0; +} + +/** Formatiert einen Masswert nach DimStyle (mit Typ-Praefix). */ +export function formatDimValue( + value: number, + style: DimStyle, + type?: DimTypeName, +): string { + const num = value.toFixed(style.decimals); + switch (type) { + case 'radius': return `R${num}`; + case 'diameter': return `\u2300 ${num}`; + case 'angular': return `${num}\u00b0`; + default: return num; + } +} diff --git a/frontend/tests/dimensionTools.test.ts b/frontend/tests/dimensionTools.test.ts new file mode 100644 index 0000000..1430b20 --- /dev/null +++ b/frontend/tests/dimensionTools.test.ts @@ -0,0 +1,313 @@ +/** + * Task CAD-1 - Assoziative Bemaessung: DimStyles + 5 Typen. + * + * DimStyle: textHeight/arrowSize/unit/decimals/arrowStyle/offset. + * computeDimValue: berechnet Masswert aus Geometrie (assoziativ). + * Tools: dimLinear (H/V/aligned), dimAngular, dimRadius, dimDiameter. + * RenderAdapter: komplette Primitives mit Extension lines, Masslinien, + * Pfeilen und Text. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + DEFAULT_DIM_STYLE, + DIM_STYLES, + getDimStyle, + computeDimValue, + formatDimValue, + type DimStyle, +} from '../src/services/dimStyleService'; +import { + dimLinearTool, + dimAlignedTool, + dimAngularTool, + dimRadiusTool, + dimDiameterTool, +} from '../src/plugins/builtin/core-annotate'; +import { CADDocument } from '../src/kernel/document/CADDocument'; +import { createInMemoryDoc } from './helpers/inMemoryDoc'; +import { elementToPrimitives } from '../src/render/pixi/elementDrawers'; +import type { CADElement, Pt } from '../src/types/cad.types'; + +function mkCtx(options: Record = {}) { + const { ydoc } = createInMemoryDoc(); + const doc = new CADDocument(ydoc); + const statuses: string[] = []; + const previews: (CADElement | null)[] = []; + const ctx: any = { + doc, + options, + setStatus: (m: string) => statuses.push(m), + setPreview: (el: CADElement | null) => previews.push(el), + }; + return { ctx, doc, statuses, previews }; +} + +function pe(x: number, y: number): { world: Pt } { + return { world: { x, y } } as never; +} + +beforeEach(() => { + for (const t of [dimLinearTool, dimAlignedTool, dimAngularTool, dimRadiusTool, dimDiameterTool]) { + t.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never); + } +}); + +// ─── DimStyle-System ───────────────────────────────────────── + +describe('CAD-1: DimStyles', () => { + it('DEFAULT_DIM_STYLE ist ISO-konform', () => { + expect(DEFAULT_DIM_STYLE.name).toBe('ISO-25'); + expect(DEFAULT_DIM_STYLE.textHeight).toBe(2.5); + expect(DEFAULT_DIM_STYLE.arrowSize).toBe(2.5); + expect(DEFAULT_DIM_STYLE.unit).toBe('mm'); + expect(DEFAULT_DIM_STYLE.decimals).toBe(0); + expect(DEFAULT_DIM_STYLE.arrowStyle).toBe('closed'); + }); + + it('DIM_STYLES enthaelt Standard-Set (ISO-25, ISO-50, Architectural)', () => { + expect(DIM_STYLES.map((s) => s.name)).toContain('ISO-25'); + expect(DIM_STYLES.map((s) => s.name)).toContain('ISO-50'); + expect(DIM_STYLES.map((s) => s.name)).toContain('Architectural'); + }); + + it('getDimStyle liefert Default fuer unbekannten Namen', () => { + expect(getDimStyle('gibts-nicht')).toBe(DEFAULT_DIM_STYLE); + expect(getDimStyle('ISO-50')!.textHeight).toBe(5); + }); +}); + +// ─── computeDimValue: Assoziative Wert-Berechnung ─────────── + +describe('CAD-1: computeDimValue', () => { + it('linear horizontal: |x2-x1|', () => { + expect(computeDimValue('linear', { x: 0, y: 0 }, { x: 100, y: 50 })).toBe(100); + }); + + it('linear vertikal: |y2-y1|', () => { + expect(computeDimValue('linear', { x: 0, y: 0 }, { x: 50, y: 200 })).toBe(200); + }); + + it('aligned: Distanz zwischen Punkten', () => { + expect(computeDimValue('aligned', { x: 0, y: 0 }, { x: 30, y: 40 })).toBe(50); + }); + + it('radius: r aus Kreis-Element', () => { + const circle = { x: 0, y: 0, properties: { radius: 25 } } as unknown as CADElement; + expect(computeDimValue('radius', undefined, undefined, circle)).toBe(25); + }); + + it('diameter: 2*r aus Kreis-Element', () => { + const circle = { x: 0, y: 0, properties: { radius: 25 } } as unknown as CADElement; + expect(computeDimValue('diameter', undefined, undefined, circle)).toBe(50); + }); +}); + +// ─── formatDimValue ────────────────────────────────────────── + +describe('CAD-1: formatDimValue', () => { + it('mm ohne Dezimalstellen bei decimals=0', () => { + expect(formatDimValue(123.7, DEFAULT_DIM_STYLE)).toBe('124'); + }); + + it('mm mit 2 Dezimalstellen', () => { + const s = { ...DEFAULT_DIM_STYLE, decimals: 2 }; + expect(formatDimValue(123.456, s)).toBe('123.46'); + }); + + it('Radius-Präfix bei radius-Typ', () => { + expect(formatDimValue(25, DEFAULT_DIM_STYLE, 'radius')).toBe('R25'); + }); + + it('Durchmesser-Symbol bei diameter-Typ', () => { + expect(formatDimValue(50, DEFAULT_DIM_STYLE, 'diameter')).toBe('\u2300 50'); + }); + + it('Grad bei angular', () => { + expect(formatDimValue(45, DEFAULT_DIM_STYLE, 'angular')).toBe('45\u00b0'); + }); +}); + +// ─── dimLinearTool ───────────────────────────────────────── + +describe('CAD-1: dimLinearTool', () => { + it('2 Klicks erzeugen dimension mit dimType, berechnetem value und Style', () => { + const { ctx, doc } = mkCtx({ dimStyle: 'ISO-25', dimSubtype: 'horizontal' }); + dimLinearTool.handlers.down!(pe(0, 0), ctx); + dimLinearTool.handlers.down!(pe(100, 50), ctx); + const els = doc.getAllElements(); + expect(els).toHaveLength(1); + const el = els[0]; + expect(el.type).toBe('dimension'); + expect(el.properties.dimType).toBe('linear'); + expect(el.properties.dimSubtype).toBe('horizontal'); + expect(el.properties.value).toBe('100'); // |dx| = 100 + expect(el.properties.dimStyle).toBe('ISO-25'); + }); + + it('vertikal: |dy| wird gemessen', () => { + const { ctx, doc } = mkCtx({ dimSubtype: 'vertical' }); + dimLinearTool.handlers.down!(pe(10, 0), ctx); + dimLinearTool.handlers.down!(pe(20, 200), ctx); + expect(doc.getAllElements()[0].properties.value).toBe('200'); + }); + + it('horizontal missachtet y-Differenz', () => { + const { ctx, doc } = mkCtx({ dimSubtype: 'horizontal' }); + dimLinearTool.handlers.down!(pe(0, 0), ctx); + dimLinearTool.handlers.down!(pe(100, 500), ctx); + expect(doc.getAllElements()[0].properties.value).toBe('100'); + }); + + it('Preview zeigt Bemassung waehrend Drag', () => { + const { ctx, previews } = mkCtx(); + dimLinearTool.handlers.down!(pe(0, 0), ctx); + dimLinearTool.handlers.move!(pe(80, 30), ctx); + expect(previews.length).toBeGreaterThanOrEqual(1); + const last = previews[previews.length - 1]; + expect(last!.type).toBe('dimension'); + }); +}); + +// ─── dimAlignedTool ──────────────────────────────────────── + +describe('CAD-1: dimAlignedTool', () => { + it('misst Distanz entlang der Punkte-Verbindung (3-4-5)', () => { + const { ctx, doc } = mkCtx(); + dimAlignedTool.handlers.down!(pe(0, 0), ctx); + dimAlignedTool.handlers.down!(pe(30, 40), ctx); + const el = doc.getAllElements()[0]; + expect(el.properties.dimType).toBe('aligned'); + expect(el.properties.value).toBe('50'); + }); +}); + +// ─── dimAngularTool (3 Punkte: Scheitel + 2 Punkte) ─────── + +describe('CAD-1: dimAngularTool', () => { + it('3 Klicks: 90 Grad zwischen zwei Schenkeln', () => { + const { ctx, doc } = mkCtx(); + dimAngularTool.handlers.down!(pe(0, 0), ctx); // Scheitel + dimAngularTool.handlers.down!(pe(100, 0), ctx); // Schenkel 1 + dimAngularTool.handlers.down!(pe(0, 100), ctx); // Schenkel 2 + const el = doc.getAllElements()[0]; + expect(el.properties.dimType).toBe('angular'); + expect(el.properties.value).toBe('90\u00b0'); + }); + + it('45 Grad wird korrekt gemessen', () => { + const { ctx, doc } = mkCtx(); + dimAngularTool.handlers.down!(pe(0, 0), ctx); + dimAngularTool.handlers.down!(pe(100, 0), ctx); + dimAngularTool.handlers.down!(pe(70.7, 70.7), ctx); // ~45 + const el = doc.getAllElements()[0]; + expect(el.properties.value).toContain('45'); + }); +}); + +// ─── dimRadiusTool / dimDiameterTool (Klick auf Kreis) ──── + +describe('CAD-1: dimRadiusTool + dimDiameterTool', () => { + it('radius: Klick auf Kreis erzeugt R-Wert', () => { + const { ctx, doc } = mkCtx(); + const circle = { + id: 'c1', type: 'circle', layerId: 'l', + x: 100, y: 100, width: 50, height: 50, + properties: { radius: 25 }, + } as unknown as CADElement; + doc.addElement(circle); + // ctx.selection mocken (hitTest liefert den Kreis) + ctx.selection = { + hitTest: () => circle, + getIds: () => [], + setIds: () => {}, + }; + dimRadiusTool.handlers.down!(pe(100, 100), ctx); + const el = doc.getAllElements().find((e) => e.type === 'dimension'); + expect(el).toBeDefined(); + expect(el!.properties.dimType).toBe('radius'); + expect(el!.properties.value).toBe('R25'); + }); + + it('diameter: Klick auf Kreis erzeugt Durchmesser-Wert', () => { + const { ctx, doc } = mkCtx(); + const circle = { + id: 'c1', type: 'circle', layerId: 'l', + x: 0, y: 0, width: 50, height: 50, + properties: { radius: 25 }, + } as unknown as CADElement; + doc.addElement(circle); + ctx.selection = { + hitTest: () => circle, + getIds: () => [], + setIds: () => {}, + }; + dimDiameterTool.handlers.down!(pe(0, 0), ctx); + const el = doc.getAllElements().find((e) => e.type === 'dimension'); + expect(el!.properties.dimType).toBe('diameter'); + expect(el!.properties.value).toContain('50'); + }); + + it('Klick ohne Kreis-Treffer: kein Element, Status erklaert', () => { + const { ctx, doc, statuses } = mkCtx(); + ctx.selection = { hitTest: () => null, getIds: () => [], setIds: () => {} }; + dimRadiusTool.handlers.down!(pe(0, 0), ctx); + expect(doc.getAllElements()).toHaveLength(0); + expect(statuses[statuses.length - 1]).toContain('Kreis'); + }); +}); + +// ─── RenderAdapter: komplette Bemassungs-Primitives ──────── + +describe('CAD-1: RenderAdapter mit Pfeilen', () => { + it('dimension erzeugt jetzt MEHR Primitives (Extension+Masslinie+Text+Pfeile)', () => { + const el = { + id: 'd1', type: 'dimension', layerId: 'l', + x: 50, y: -10, width: 100, height: 20, + properties: { + x1: 0, y1: 0, x2: 100, y2: 0, + dimType: 'linear', dimSubtype: 'horizontal', + value: '100', dimStyle: 'ISO-25', + }, + } as unknown as CADElement; + const prims = elementToPrimitives(el); + // Vorher: 2 Primitives (Linie + Text). Jetzt mit Extension lines + Pfeilen: mehr + expect(prims.length).toBeGreaterThanOrEqual(5); + const lines = prims.filter((p) => p.kind === 'line'); + const texts = prims.filter((p) => p.kind === 'text'); + const polys = prims.filter((p) => p.kind === 'polyline'); + expect(lines.length).toBeGreaterThanOrEqual(3); // Masslinie + 2 Extension lines + expect(texts.length).toBe(1); // Wert-Text + expect(polys.length).toBeGreaterThanOrEqual(2); // 2 Pfeilspitzen + }); + + it('Pfeilspitzen sind geschlossene Polygone an den Enden der Masslinie', () => { + const el = { + id: 'd1', type: 'dimension', layerId: 'l', + x: 50, y: -10, width: 100, height: 20, + properties: { + x1: 0, y1: 0, x2: 100, y2: 0, + dimType: 'linear', value: '100', + }, + } as unknown as CADElement; + const prims = elementToPrimitives(el); + const arrows = prims.filter( + (p) => p.kind === 'polyline' && (p as { close?: boolean }).close === true, + ); + expect(arrows.length).toBe(2); + }); + + it('radius-dimension rendert Linie vom Zentrum nach aussen + Pfeil', () => { + const el = { + id: 'd2', type: 'dimension', layerId: 'l', + x: 100, y: 100, width: 0, height: 0, + properties: { + dimType: 'radius', value: 'R25', + centerX: 100, centerY: 100, + arrowX: 120, arrowY: 100, + }, + } as unknown as CADElement; + const prims = elementToPrimitives(el); + expect(prims.length).toBeGreaterThanOrEqual(2); + expect(prims.some((p) => p.kind === 'line')).toBe(true); + }); +}); diff --git a/frontend/tests/elementDrawers.test.ts b/frontend/tests/elementDrawers.test.ts index 12dfde2..bfc465b 100644 --- a/frontend/tests/elementDrawers.test.ts +++ b/frontend/tests/elementDrawers.test.ts @@ -113,14 +113,16 @@ describe('elementToPrimitives', () => { expect(elementToPrimitives(el('x', 'definitely-unknown'))).toEqual([]); }); - it('dimension: Linie + Label aus value/text', () => { + it('dimension: komplette Bemassung (Extension+Masslinie+Pfeile+Text, CAD-1)', () => { const prims = elementToPrimitives(el('d', 'dimension', { x1: 0, y1: 0, x2: 100, y2: 0, value: '250 cm', }, { x: 50, y: 0 })); - expect(prims.length).toBe(2); - expect(prims[0].kind).toBe('line'); - const txt = prims[1]; - if (txt.kind !== 'text') throw new Error('erwartete text'); + // CAD-1: 2 Extension lines + 1 Masslinie + 2 Pfeilspitzen + 1 Text = 6 + expect(prims.length).toBe(6); + expect(prims.filter((p) => p.kind === 'line').length).toBe(3); + expect(prims.filter((p) => p.kind === 'polyline').length).toBe(2); + const txt = prims.find((p) => p.kind === 'text'); + if (!txt || txt.kind !== 'text') throw new Error('erwartete text'); expect(txt.text).toBe('250 cm'); });