task(CAD-4): otrack computeTrackPoint with angle projection, divide/measure path point tools on defpoints in one undo transaction
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Task CAD-4 – OTRACK + DIVIDE/MEASURE.
|
||||
*
|
||||
* OTRACK: Tracking-Vektoren von Fang-Punkten (horizontal/vertikal/
|
||||
* Winkelmultiples), SnapEngine-Erweiterung.
|
||||
* DIVIDE: n gleichmaeßige Punkte auf einem Pfad.
|
||||
* MEASURE: Punkte im festen Abstand auf einem Pfad.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { SnapEngine, computeTrackPoint } from '../src/canvas/SnapEngine';
|
||||
import { divideTool, measureTool, pointsAlongPath } from '../src/plugins/builtin/core-modify';
|
||||
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||
|
||||
function mkCtx(elements: CADElement[], options: Record<string, unknown> = {}) {
|
||||
const { ydoc } = createInMemoryDoc();
|
||||
const doc = new CADDocument(ydoc);
|
||||
for (const el of elements) doc.addElement(el);
|
||||
const selectedIds = new Set<string>();
|
||||
const statuses: string[] = [];
|
||||
const ctx: any = {
|
||||
doc,
|
||||
options,
|
||||
setStatus: (m: string) => statuses.push(m),
|
||||
setPreview: (_el: CADElement | null) => {},
|
||||
selection: {
|
||||
getIds: () => Array.from(selectedIds),
|
||||
setIds: (ids: string[]) => {
|
||||
selectedIds.clear();
|
||||
for (const id of ids) selectedIds.add(id);
|
||||
},
|
||||
hitTest: (x: number, y: number, _tol: number) => {
|
||||
for (const el of elements) {
|
||||
const p = el.properties as Record<string, unknown>;
|
||||
if (Array.isArray(p.points)) {
|
||||
const pts = p.points as Pt[];
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const dx = pts[i + 1].x - pts[i].x;
|
||||
const dy = pts[i + 1].y - pts[i].y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const t = Math.max(0, Math.min(1, ((x - pts[i].x) * dx + (y - pts[i].y) * dy) / (len * len)));
|
||||
const px = pts[i].x + t * dx;
|
||||
const py = pts[i].y + t * dy;
|
||||
if (Math.hypot(x - px, y - py) < 10) return el;
|
||||
}
|
||||
}
|
||||
if (
|
||||
x >= el.x - el.width / 2 - 5 && x <= el.x + el.width / 2 + 5 &&
|
||||
y >= el.y - el.height / 2 - 5 && y <= el.y + el.height / 2 + 5
|
||||
) return el;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
};
|
||||
return { ctx, doc, selectedIds, statuses };
|
||||
}
|
||||
|
||||
function pe(x: number, y: number, mods: { shift?: boolean } = {}): { world: Pt; shift: boolean; ctrl: boolean } {
|
||||
return { world: { x, y }, shift: mods.shift ?? false, ctrl: false } as never;
|
||||
}
|
||||
|
||||
function path(pts: Array<[number, number]>): CADElement {
|
||||
return {
|
||||
id: 'path-1', type: 'polyline', layerId: 'l',
|
||||
x: pts.reduce((s, p) => s + p[0], 0) / pts.length,
|
||||
y: pts.reduce((s, p) => s + p[1], 0) / pts.length,
|
||||
width: Math.max(...pts.map(p => p[0])) - Math.min(...pts.map(p => p[0])),
|
||||
height: Math.max(...pts.map(p => p[1])) - Math.min(...pts.map(p => p[1])),
|
||||
properties: { points: pts.map(([x, y]) => ({ x, y })) },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
divideTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
measureTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
});
|
||||
|
||||
// ─── OTRACK: computeTrackPoint ────────────────────────────────
|
||||
|
||||
describe('CAD-4: computeTrackPoint (OTRACK)', () => {
|
||||
it('horizontal von Basis (100,100) bei Cursor (200,90): Track-Punkt (200,100)', () => {
|
||||
const result = computeTrackPoint({ x: 100, y: 100 }, { x: 200, y: 90 });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.x).toBeCloseTo(200, 6);
|
||||
expect(result!.y).toBeCloseTo(100, 6);
|
||||
});
|
||||
|
||||
it('vertikal von Basis (100,100) bei Cursor (90,200): Track-Punkt (100,200)', () => {
|
||||
const result = computeTrackPoint({ x: 100, y: 100 }, { x: 90, y: 200 });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.x).toBeCloseTo(100, 6);
|
||||
expect(result!.y).toBeCloseTo(200, 6);
|
||||
});
|
||||
|
||||
it('45-Grad-Richtung wird verfolgt', () => {
|
||||
// Basis (0,0), Cursor (100,80): 45 Grad heißt y=x → Track (100,100)
|
||||
const result = computeTrackPoint({ x: 0, y: 0 }, { x: 100, y: 80 }, [0, 45, 90, 135, 180, 225, 270, 315]);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.x).toBeCloseTo(100, 6);
|
||||
expect(result!.y).toBeCloseTo(100, 6);
|
||||
});
|
||||
|
||||
it('Cursor genau auf der Basis: kein Track (null)', () => {
|
||||
expect(computeTrackPoint({ x: 100, y: 100 }, { x: 100, y: 100 })).toBeNull();
|
||||
});
|
||||
|
||||
it('ohne Winkel-Option: nur 0/90/180/270 (Standard-Tracking)', () => {
|
||||
// 30-Grad-Cursor nahe horizontal: snappt auf horizontal
|
||||
const result = computeTrackPoint({ x: 0, y: 0 }, { x: 100, y: 15 });
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.x).toBeCloseTo(100, 6);
|
||||
expect(result!.y).toBeCloseTo(0, 6);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── pointsAlongPath (reine Funktion) ─────────────────────────
|
||||
|
||||
describe('CAD-4: pointsAlongPath', () => {
|
||||
it('DIVIDE: 4 Segmente auf 100-Einheiten-Pfad → 5 Punkte (inkl. Enden)', () => {
|
||||
const pts = [{ x: 0, y: 0 }, { x: 100, y: 0 }];
|
||||
const result = pointsAlongPath(pts, 4);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[0]).toEqual({ x: 0, y: 0 });
|
||||
expect(result[2]).toEqual({ x: 50, y: 0 });
|
||||
expect(result[4]).toEqual({ x: 100, y: 0 });
|
||||
});
|
||||
|
||||
it('MEASURE: Abstand 25 auf 100-Einheiten-Pfad → 5 Punkte', () => {
|
||||
const pts = [{ x: 0, y: 0 }, { x: 100, y: 0 }];
|
||||
const result = pointsAlongPath(pts, 0, 25);
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[3]).toEqual({ x: 75, y: 0 });
|
||||
});
|
||||
|
||||
it('MEASURE mit Rest: 30 auf 100 → 4 Punkte (Rest 10 verworfen)', () => {
|
||||
const pts = [{ x: 0, y: 0 }, { x: 100, y: 0 }];
|
||||
const result = pointsAlongPath(pts, 0, 30);
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[3]).toEqual({ x: 90, y: 0 });
|
||||
});
|
||||
|
||||
it('gebogener L-Pfad: Punkte folgen der Geometrie', () => {
|
||||
const pts = [{ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 50, y: 50 }];
|
||||
const result = pointsAlongPath(pts, 3); // 3 Segmente auf 100 Gesamtlänge → 4 Punkte
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[0]).toEqual({ x: 0, y: 0 });
|
||||
expect(result[2].x).toBeCloseTo(50, 0);
|
||||
expect(result[3]).toEqual({ x: 50, y: 50 });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── divideTool ────────────────────────────────────────────
|
||||
|
||||
describe('CAD-4: divideTool', () => {
|
||||
it('Klick auf Pfad + count=4 platziert 5 Punkte', () => {
|
||||
const p = path([[0, 0], [100, 0]]);
|
||||
const { ctx, doc } = mkCtx([p], { count: 4 });
|
||||
divideTool.handlers.down!(pe(50, 0), ctx);
|
||||
const points = doc.getAllElements().filter((e) => e.type === 'circle' && e.properties.point);
|
||||
expect(points).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('Punkte liegen auf defpoints-Layer mit construction:true', () => {
|
||||
const p = path([[0, 0], [100, 0]]);
|
||||
const { ctx, doc } = mkCtx([p], { count: 2 });
|
||||
divideTool.handlers.down!(pe(50, 0), ctx);
|
||||
const points = doc.getAllElements().filter((e) => e.properties.construction);
|
||||
expect(points.length).toBeGreaterThanOrEqual(3);
|
||||
expect(points.every((e) => e.layerId === 'defpoints')).toBe(true);
|
||||
});
|
||||
|
||||
it('Klick ohne Pfad-Treffer: kein Element, Status erklaert', () => {
|
||||
const { ctx, doc, statuses } = mkCtx([], { count: 2 });
|
||||
divideTool.handlers.down!(pe(500, 500), ctx);
|
||||
expect(doc.getAllElements()).toHaveLength(0);
|
||||
expect(statuses[statuses.length - 1]).toContain('Polyline');
|
||||
});
|
||||
|
||||
it('ALLES in EINER Transaktion (1 Undo entfernt alle Punkte)', () => {
|
||||
const p = path([[0, 0], [100, 0]]);
|
||||
const { ctx, doc } = mkCtx([p], { count: 4 });
|
||||
divideTool.handlers.down!(pe(50, 0), ctx);
|
||||
expect(doc.getAllElements().length).toBeGreaterThan(1);
|
||||
doc.undo();
|
||||
expect(doc.getAllElements()).toHaveLength(1); // Nur der Pfad bleibt
|
||||
});
|
||||
});
|
||||
|
||||
// ─── measureTool ────────────────────────────────────────────
|
||||
|
||||
describe('CAD-4: measureTool', () => {
|
||||
it('Klick auf Pfad + Abstand 25 platziert 5 Punkte', () => {
|
||||
const p = path([[0, 0], [100, 0]]);
|
||||
const { ctx, doc } = mkCtx([p], { spacing: 25 });
|
||||
measureTool.handlers.down!(pe(50, 0), ctx);
|
||||
const points = doc.getAllElements().filter((e) => e.type === 'circle' && e.properties.point);
|
||||
expect(points).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('count=0 (keine Segmente) mit spacing nutzt Abstand', () => {
|
||||
const p = path([[0, 0], [100, 0]]);
|
||||
const { ctx, doc } = mkCtx([p], { spacing: 20 });
|
||||
measureTool.handlers.down!(pe(50, 0), ctx);
|
||||
const points = doc.getAllElements().filter((e) => e.type === 'circle' && e.properties.point);
|
||||
expect(points).toHaveLength(6); // 0, 20, 40, 60, 80, 100
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Manifeste ──────────────────────────────────────────────
|
||||
|
||||
describe('CAD-4: Manifeste', () => {
|
||||
it('ids und Optionen korrekt', () => {
|
||||
expect(divideTool.manifest.id).toBe('divide');
|
||||
expect(measureTool.manifest.id).toBe('measure');
|
||||
const divKeys = (divideTool.optionsSchema ?? []).map((f) => f.key);
|
||||
expect(divKeys).toContain('count');
|
||||
const meaKeys = (measureTool.optionsSchema ?? []).map((f) => f.key);
|
||||
expect(meaKeys).toContain('spacing');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user