task(D10): array-path tool - distribute element copies evenly along polyline path in one undo transaction, count option

This commit is contained in:
Agent Zero
2026-08-29 03:29:05 +02:00
parent eeaa7806c6
commit 227d617bb1
2 changed files with 275 additions and 1 deletions
@@ -1077,6 +1077,110 @@ function makeArrayTool(mode: 'array-rect' | 'array-polar'): ToolExtensionV2 {
export const arrayRectTool: ToolExtensionV2 = makeArrayTool('array-rect');
export const arrayPolarTool: ToolExtensionV2 = makeArrayTool('array-polar');
// ─────────────────────────────────────────────────────────────
// Task D10-Rest: array-path — Elemente entlang einer Polyline
// (Pfad) verteilen. Klick1: Quelle (Auto-HitSelect), Klick2: auf
// die Pfad-Polyline klicken. count Kopien gleichmäßig über die
// Gesamtlänge (s = i/(count-1)·total), ALLES in 1 Transaktion.
// ─────────────────────────────────────────────────────────────
let arrayPathBase: Pt | null = null;
/** Punkt auf einer Polyline bei kumulierter Länge s (0..total). */
function pointAlongPolyline(pts: Pt[], s: number): Pt {
if (pts.length === 0) return { x: 0, y: 0 };
if (pts.length === 1 || s <= 0) return { ...pts[0] };
let remaining = s;
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 segLen = Math.hypot(dx, dy);
if (segLen <= 0) continue;
if (remaining <= segLen) {
const t = remaining / segLen;
return { x: pts[i].x + dx * t, y: pts[i].y + dy * t };
}
remaining -= segLen;
}
return { ...pts[pts.length - 1] };
}
export const arrayPathTool: ToolExtensionV2 = {
manifest: {
id: 'array-path',
label: 'Array Pfad',
icon: '∿',
ribbonTab: 'canvas',
tags: ['pro'],
description: 'Array entlang Pfad: Auswahl treffen, dann auf Pfad-Polyline klicken (Task D10)',
},
optionsSchema: [
{ key: 'count', label: 'Anzahl', type: 'number', min: 2, max: 100 },
],
handlers: {
down(e, ctx) {
const c = ctx as FullToolContext;
if (!c.doc || !c.selection) return;
if (!arrayPathBase) {
if (c.selection.getIds().length === 0) {
const hit = c.selection.hitTest(e.world.x, e.world.y, 5);
if (!hit) {
ctx.setStatus('Array Pfad: zuerst Element(e) auswählen');
return;
}
c.selection.setIds([hit.id]);
}
arrayPathBase = e.world;
ctx.setStatus(`Array Pfad: auf Pfad-Polyline klicken (${c.selection.getIds().length} Element(e))`);
return;
}
// Zweiter Klick: muss eine Polyline als Pfad treffen
const pathHit = c.selection.hitTest(e.world.x, e.world.y, 8);
if (!pathHit || (pathHit.type !== 'polyline' && pathHit.type !== 'polygon')) {
ctx.setStatus('Array Pfad: zweiter Klick muss eine Polyline als Pfad treffen');
arrayPathBase = null;
return;
}
const pathPts = (pathHit.properties as Record<string, unknown>).points as Pt[] | undefined;
if (!pathPts || pathPts.length < 2) {
ctx.setStatus('Array Pfad: Pfad-Polyline hat zu wenige Punkte');
arrayPathBase = null;
return;
}
const count = Math.max(2, (c.options.count as number | undefined) ?? 5);
const ids = c.selection.getIds().filter((id) => id !== pathHit.id);
// Gesamtlänge des Pfads
let total = 0;
for (let i = 0; i < pathPts.length - 1; i++) {
total += Math.hypot(pathPts[i + 1].x - pathPts[i].x, pathPts[i + 1].y - pathPts[i].y);
}
c.doc.transact(() => {
for (const id of ids) {
const el = c.doc!.getElement(id);
if (!el) continue;
for (let i = 0; i < count; i++) {
const s = total * (i / Math.max(1, count - 1));
const pos = pointAlongPolyline(pathPts, s);
c.doc!.addElement({
...el,
id: uid('arrp'),
x: pos.x,
y: pos.y,
} as CADElement);
}
}
});
arrayPathBase = null;
ctx.setStatus(`Array Pfad: ${ids.length * count} Kopien entlang Pfad verteilt`);
},
cancel(ctx) {
arrayPathBase = null;
ctx.setPreview?.(null);
ctx.setStatus('Array Pfad abgebrochen');
},
},
};
// ─────────────────────────────────────────────────────────────
// Chamfer (Task D-Ext): schneidet zwei sich schneidende Linien an einer
// festen Distanz vom Schnittpunkt und verbindet die Enden mit einer
@@ -1483,5 +1587,5 @@ export const coreModifyPlugin: PluginV2 = {
category: 'tools',
enabledByDefault: true,
},
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool, polylineEditTool, stretchTool, breakTool],
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool, polylineEditTool, stretchTool, breakTool, arrayPathTool],
};
+170
View File
@@ -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');
});
});