task(D10): array-path tool - distribute element copies evenly along polyline path in one undo transaction, count option
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Task D10-Rest – array-path: Elemente entlang einer Polyline verteilen.
|
||||
*
|
||||
* Klick1 = Quelle (Auto-HitSelect bei leerer Selektion), Klick2 auf
|
||||
* Pfad-Polyline → Element-Kopien gleichmaessig entlang der Pfad-
|
||||
* laenge verteilt (count aus Options, Default 5), Original bleibt,
|
||||
* ALLES in EINER Transaktion = 1 Undo-Schritt.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { arrayPathTool } 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) => {
|
||||
// trifft das Element, dessen BBox den Punkt enthaelt (erstes Gewinnt)
|
||||
for (const el of elements) {
|
||||
if (
|
||||
x >= el.x - el.width / 2 && x <= el.x + el.width / 2 &&
|
||||
y >= el.y - el.height / 2 && y <= el.y + el.height / 2
|
||||
) return el;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
};
|
||||
return { ctx, doc, selectedIds, statuses };
|
||||
}
|
||||
|
||||
function pe(x: number, y: number): { world: Pt } {
|
||||
return { world: { x, y } } as never;
|
||||
}
|
||||
|
||||
const CHAIR = {
|
||||
id: 'chair-1', type: 'chair', layerId: 'layer-0',
|
||||
x: 0, y: 0, width: 40, height: 40, properties: {},
|
||||
} as unknown as CADElement;
|
||||
|
||||
const PATH = {
|
||||
id: 'path-1', type: 'polyline', layerId: 'layer-0',
|
||||
x: 250, y: 0, width: 500, height: 0,
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 500, y: 0 }] },
|
||||
} as unknown as CADElement;
|
||||
|
||||
beforeEach(() => {
|
||||
arrayPathTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||
});
|
||||
|
||||
describe('D10: arrayPathTool', () => {
|
||||
it('verteilt count Kopien gleichmaessig entlang der Pfadlaenge (Original bleibt)', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||
selectedIds.add('chair-1');
|
||||
// Klick1: Quelle ist schon selektiert -> Basis direkt setzen
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
// Klick2: auf den Pfad klicken (Pfad-Mitte 250,0)
|
||||
arrayPathTool.handlers.down!(pe(250, 0), ctx);
|
||||
|
||||
const els = doc.getAllElements();
|
||||
// Original + Pfad + 5 Kopien
|
||||
const chairs = els.filter((e) => e.type === 'chair');
|
||||
expect(chairs.length).toBe(1 + 5);
|
||||
// Kopien getrennt vom Original (Original bleibt bei x=0 unangetastet):
|
||||
const copies = chairs.filter((c) => c.id !== 'chair-1');
|
||||
expect(copies).toHaveLength(5);
|
||||
const copyXs = copies.map((c) => c.x).sort((a, b) => a - b);
|
||||
// count=5 gleichmaessig ueber Pfadlaenge 500: s = i/(count-1)*total
|
||||
expect(copyXs[0]).toBeCloseTo(0, 0);
|
||||
expect(copyXs[1]).toBeCloseTo(125, 0);
|
||||
expect(copyXs[2]).toBeCloseTo(250, 0);
|
||||
expect(copyXs[3]).toBeCloseTo(375, 0);
|
||||
expect(copyXs[4]).toBeCloseTo(500, 0);
|
||||
});
|
||||
|
||||
it('Auto-HitSelect bei leerer Selektion (Klick1 auf Element)', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||
// Selektion ist leer: Klick auf den Stuhl waehlt ihn automatisch
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
expect(selectedIds.has('chair-1')).toBe(true);
|
||||
// Klick2 auf Pfad -> Commit
|
||||
arrayPathTool.handlers.down!(pe(100, 0), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair').length).toBe(6);
|
||||
});
|
||||
|
||||
it('Klick2 trifft KEINEN Pfad -> kein Commit, Status erklaert', () => {
|
||||
const { ctx, doc, selectedIds, statuses } = mkCtx([CHAIR, PATH]);
|
||||
selectedIds.add('chair-1');
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
// Klick weit weg vom Pfad (kein Element getroffen)
|
||||
arrayPathTool.handlers.down!(pe(9999, 9999), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(1);
|
||||
expect(statuses[statuses.length - 1]).toContain('Pfad');
|
||||
});
|
||||
|
||||
it('Kopien in EINER Transaktion = 1 Undo entfernt alle', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||
selectedIds.add('chair-1');
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
arrayPathTool.handlers.down!(pe(250, 0), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(6);
|
||||
doc.undo();
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('count-Option steuert die Anzahl (3 statt 5)', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH], { count: 3 });
|
||||
selectedIds.add('chair-1');
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
arrayPathTool.handlers.down!(pe(250, 0), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(4); // Original + 3
|
||||
// Kopien getrennt vom Original: count=3 -> s = 0, 250, 500
|
||||
const copies3 = doc.getAllElements().filter((e) => e.type === 'chair' && e.id !== 'chair-1');
|
||||
const copyXs3 = copies3.map((c) => c.x).sort((a, b) => a - b);
|
||||
expect(copyXs3[0]).toBeCloseTo(0, 0);
|
||||
expect(copyXs3[1]).toBeCloseTo(250, 0);
|
||||
expect(copyXs3[2]).toBeCloseTo(500, 0);
|
||||
});
|
||||
|
||||
it('gebogener Pfad (3 Punkte): Kopien folgen der Geometrie', () => {
|
||||
const cornerPath = {
|
||||
id: 'path-2', type: 'polyline', layerId: 'layer-0',
|
||||
x: 100, y: 100, width: 200, height: 200,
|
||||
properties: { points: [{ x: 0, y: 0 }, { x: 200, y: 0 }, { x: 200, y: 200 }] },
|
||||
} as unknown as CADElement;
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, cornerPath], { count: 4 });
|
||||
selectedIds.add('chair-1');
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
arrayPathTool.handlers.down!(pe(100, 10), ctx);
|
||||
const chairs = doc.getAllElements().filter((e) => e.type === 'chair');
|
||||
expect(chairs.length).toBe(5); // Original + 4
|
||||
// Kopien muessen auf dem L-foermigen Pfad liegen: alle x<=200, y>=0
|
||||
for (const c of chairs.slice(1)) {
|
||||
expect(c.x).toBeLessThanOrEqual(200.5);
|
||||
expect(c.y).toBeGreaterThanOrEqual(-0.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('cancel resettet die Sitzung', () => {
|
||||
const { ctx, doc, selectedIds } = mkCtx([CHAIR, PATH]);
|
||||
selectedIds.add('chair-1');
|
||||
arrayPathTool.handlers.down!(pe(0, 0), ctx);
|
||||
arrayPathTool.handlers.cancel?.(ctx);
|
||||
// Nach cancel: Klick startet frisch (Auto-HitSelect-Pfad wieder aktiv)
|
||||
arrayPathTool.handlers.down!(pe(9999, 9999), ctx);
|
||||
expect(doc.getAllElements().filter((e) => e.type === 'chair')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Manifest: id=array-path, tags pro', () => {
|
||||
expect(arrayPathTool.manifest.id).toBe('array-path');
|
||||
expect((arrayPathTool.manifest.tags ?? [])).toContain('pro');
|
||||
const keys = (arrayPathTool.optionsSchema ?? []).map((f) => f.key);
|
||||
expect(keys).toContain('count');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user