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:
@@ -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<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',
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user