task(CAD-1): associative dimensioning - DimStyles (ISO-25/50/Arch/Engineering), 5 dimension types (linear H/V, aligned, angular, radius, diameter), computed values, full primitives with arrows + extension lines

This commit is contained in:
Agent Zero
2026-08-29 09:58:45 +02:00
parent 17b62ee36a
commit 44a60fa718
5 changed files with 709 additions and 16 deletions
@@ -6,6 +6,9 @@
* (2-Punkt Pfeil+Label). * (2-Punkt Pfeil+Label).
*/ */
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types'; 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 { CADElement } from '../../../types/cad.types';
import type { Pt } from '../../../tools/modification/geometry'; 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<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 = { export const coreAnnotatePlugin: PluginV2 = {
manifest: { manifest: {
id: 'core-annotate', id: 'core-annotate',
@@ -203,5 +397,5 @@ export const coreAnnotatePlugin: PluginV2 = {
category: 'tools', category: 'tools',
enabledByDefault: true, enabledByDefault: true,
}, },
tools: [textTool, dimensionTool, leaderTool, mtextTool], tools: [textTool, dimensionTool, leaderTool, mtextTool, dimLinearTool, dimAlignedTool, dimAngularTool, dimRadiusTool, dimDiameterTool],
}; };
+84 -10
View File
@@ -15,6 +15,7 @@
* - arc-Winkel in Grad, 0° = 3 Uhr, gegen den Uhrzeigersinn * - arc-Winkel in Grad, 0° = 3 Uhr, gegen den Uhrzeigersinn
*/ */
import type { CADElement } from '../../types/cad.types'; import type { CADElement } from '../../types/cad.types';
import { getDimStyle } from '../../services/dimStyleService';
import type { Pt } from '../../tools/modification/geometry'; import type { Pt } from '../../tools/modification/geometry';
/** Zeichenprimitiv — kleinste darstellbare Einheit. */ /** 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, * Gibt ein leeres Array zurück für unbekannte Typen bzw. Elemente,
* die nichts Sichtbares beitragen (z.B. leerer Text). * 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[] { export function elementToPrimitives(el: CADElement, attrDefs?: Array<{ tag: string; prompt: string; defaultValue?: string }>): ShapePrimitive[] {
const props = (el.properties ?? {}) as Record<string, unknown>; const props = (el.properties ?? {}) as Record<string, unknown>;
const stroke = (props.stroke as string | undefined) ?? FALLBACK_STROKE; 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 ── // ── Task C3: bisher im Pixi-Pfad fehlende Typen ──
case 'dimension': { case 'dimension': {
// Legacy-Konvention: x1..y2 in properties, BBox-Zentrum als x/y, // Task CAD-1: komplette Bemassung mit Extension lines, Masslinie,
// Extension lines + Hauptlinie; Pfeilspitzen sind Detail (C3+) // 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 x1 = (props.x1 as number | undefined) ?? el.x - el.width / 2;
const y1 = (props.y1 as number | undefined) ?? el.y; const y1 = (props.y1 as number | undefined) ?? el.y;
const x2 = (props.x2 as number | undefined) ?? el.x + el.width / 2; const x2 = (props.x2 as number | undefined) ?? el.x + el.width / 2;
const y2 = (props.y2 as number | undefined) ?? el.y; const y2 = (props.y2 as number | undefined) ?? el.y;
const color = (props.color as string | undefined) ?? '#aaaaaa'; const subtype = (props.dimSubtype as string | undefined) ?? 'horizontal';
const dimColor = (props.dimColor as string | undefined) ?? color;
const label = String(props.value ?? props.text ?? ''); // 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[] = [ 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) { if (label) {
out.push({ out.push({
kind: 'text', kind: 'text',
at: { x: (x1 + x2) / 2, y: (y1 + y2) / 2 - 12 }, at: { x: (mx1 + mx2) / 2, y: (my1 + my2) / 2 - style.textHeight * 0.6 },
text: label, text: label, fontSize: style.textHeight * 4, color: dimColor,
fontSize: 10,
color: dimColor,
}); });
} }
return out; return out;
+110
View File
@@ -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<string, unknown>).radius as number | undefined;
return r ?? element.width / 2;
}
if (type === 'diameter' && element) {
const r = (element.properties as Record<string, unknown>).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;
}
}
+313
View File
@@ -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<string, unknown> = {}) {
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);
});
});
+7 -5
View File
@@ -113,14 +113,16 @@ describe('elementToPrimitives', () => {
expect(elementToPrimitives(el('x', 'definitely-unknown'))).toEqual([]); 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', { const prims = elementToPrimitives(el('d', 'dimension', {
x1: 0, y1: 0, x2: 100, y2: 0, value: '250 cm', x1: 0, y1: 0, x2: 100, y2: 0, value: '250 cm',
}, { x: 50, y: 0 })); }, { x: 50, y: 0 }));
expect(prims.length).toBe(2); // CAD-1: 2 Extension lines + 1 Masslinie + 2 Pfeilspitzen + 1 Text = 6
expect(prims[0].kind).toBe('line'); expect(prims.length).toBe(6);
const txt = prims[1]; expect(prims.filter((p) => p.kind === 'line').length).toBe(3);
if (txt.kind !== 'text') throw new Error('erwartete text'); 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'); expect(txt.text).toBe('250 cm');
}); });