task(D3): construction tools - point placer, xline (bidirectional), ray (unidirectional) on auto-created defpoints layer with construction tags
This commit is contained in:
@@ -112,6 +112,11 @@ export class CADDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Fügt einen Layer hinzu (überschreibt gleiche ID, kein Undo-Schritt). */
|
||||||
|
addLayer(layer: CADLayer): void {
|
||||||
|
this.ydoc.data.layers.set(layer.id, layer);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ersetzt das komplette Layer-Set (Task H2: Projekt-Templates).
|
* Ersetzt das komplette Layer-Set (Task H2: Projekt-Templates).
|
||||||
* Bestehende Layer werden entfernt, Template-Layer übernommen.
|
* Bestehende Layer werden entfernt, Template-Layer übernommen.
|
||||||
|
|||||||
@@ -662,15 +662,244 @@ export const splineTool: ToolExtensionV2 = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Task D3: Point/XLine/Ray — Konstruktions-Elemente auf defpoints.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Auto-Anlegen des defpoints-Layers (Task D3) falls fehlt. */
|
||||||
|
function ensureDefpointsLayer(ctx: FullToolContext): void {
|
||||||
|
const doc = ctx.doc as unknown as {
|
||||||
|
getLayer(id: string): unknown;
|
||||||
|
addLayer(l: unknown): void;
|
||||||
|
} | undefined;
|
||||||
|
if (!doc || doc.getLayer('defpoints')) return;
|
||||||
|
doc.addLayer({
|
||||||
|
id: 'defpoints',
|
||||||
|
name: 'defpoints',
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
color: '#8a8f98',
|
||||||
|
lineType: 'dotted',
|
||||||
|
transparency: 0.5,
|
||||||
|
sortOrder: 999,
|
||||||
|
parentId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Konstruktions-Element-Basis-Props (construction-Tags, Task D3). */
|
||||||
|
function constructionProps(): Record<string, unknown> {
|
||||||
|
return { stroke: '#8a8f98', strokeWidth: 1, construction: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pointTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'point',
|
||||||
|
label: 'Punkt',
|
||||||
|
icon: '\u2022',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Konstruktionspunkt platzieren (defpoints-Layer)',
|
||||||
|
},
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc) return;
|
||||||
|
ensureDefpointsLayer(c);
|
||||||
|
const r = 1.5;
|
||||||
|
const el: CADElement = {
|
||||||
|
id: uid('point'),
|
||||||
|
type: 'circle',
|
||||||
|
layerId: 'defpoints',
|
||||||
|
x: e.world.x,
|
||||||
|
y: e.world.y,
|
||||||
|
width: r * 2,
|
||||||
|
height: r * 2,
|
||||||
|
properties: { ...constructionProps(), radius: r, point: true },
|
||||||
|
} as unknown as CADElement;
|
||||||
|
c.doc.transact(() => c.doc!.addElement(el));
|
||||||
|
ctx.setStatus(`Punkt platziert (${el.id})`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** XLine: sehr lange Linie in BEIDE Richtungen um den Basispunkt. */
|
||||||
|
let xlineBase: Pt | null = null;
|
||||||
|
|
||||||
|
export const xlineTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'xline',
|
||||||
|
label: 'Konstruktionslinie',
|
||||||
|
icon: '\u2194',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Unendliche Konstruktionslinie: Basispunkt + Richtung klicken',
|
||||||
|
},
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc) return;
|
||||||
|
if (!xlineBase) {
|
||||||
|
xlineBase = e.world;
|
||||||
|
ctx.setStatus('Konstruktionslinie: Basis gesetzt – Richtung klicken');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureDefpointsLayer(c);
|
||||||
|
const dx = e.world.x - xlineBase.x;
|
||||||
|
const dy = e.world.y - xlineBase.y;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len < 0.001) { xlineBase = null; return; }
|
||||||
|
const ux = dx / len, uy = dy / len;
|
||||||
|
const L = 100000;
|
||||||
|
const el: CADElement = {
|
||||||
|
id: uid('xline'),
|
||||||
|
type: 'line',
|
||||||
|
layerId: 'defpoints',
|
||||||
|
x: xlineBase.x,
|
||||||
|
y: xlineBase.y,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
properties: {
|
||||||
|
...constructionProps(),
|
||||||
|
x1: xlineBase.x - ux * L,
|
||||||
|
y1: xlineBase.y - uy * L,
|
||||||
|
x2: xlineBase.x + ux * L,
|
||||||
|
y2: xlineBase.y + uy * L,
|
||||||
|
},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
c.doc.transact(() => c.doc!.addElement(el));
|
||||||
|
xlineBase = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`Konstruktionslinie erstellt (${el.id})`);
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!xlineBase) return;
|
||||||
|
const dx = e.world.x - xlineBase.x;
|
||||||
|
const dy = e.world.y - xlineBase.y;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len < 0.001) return;
|
||||||
|
const ux = dx / len, uy = dy / len;
|
||||||
|
const L = 100000;
|
||||||
|
ctx.setPreview?.({
|
||||||
|
id: '__preview_xline__',
|
||||||
|
type: 'line',
|
||||||
|
layerId: 'defpoints',
|
||||||
|
x: xlineBase.x,
|
||||||
|
y: xlineBase.y,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
properties: {
|
||||||
|
...constructionProps(),
|
||||||
|
x1: xlineBase.x - ux * L,
|
||||||
|
y1: xlineBase.y - uy * L,
|
||||||
|
x2: xlineBase.x + ux * L,
|
||||||
|
y2: xlineBase.y + uy * L,
|
||||||
|
},
|
||||||
|
} as unknown as CADElement);
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
xlineBase = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('Konstruktionslinie abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ray: Linie ab Basispunkt NUR in Klickrichtung. */
|
||||||
|
let rayBase: Pt | null = null;
|
||||||
|
|
||||||
|
export const rayTool: ToolExtensionV2 = {
|
||||||
|
manifest: {
|
||||||
|
id: 'ray',
|
||||||
|
label: 'Strahl',
|
||||||
|
icon: '\u2192',
|
||||||
|
ribbonTab: 'canvas',
|
||||||
|
tags: ['basic'],
|
||||||
|
description: 'Strahl ab Basispunkt in Klickrichtung',
|
||||||
|
},
|
||||||
|
optionsSchema: [],
|
||||||
|
handlers: {
|
||||||
|
down(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!c.doc) return;
|
||||||
|
if (!rayBase) {
|
||||||
|
rayBase = e.world;
|
||||||
|
ctx.setStatus('Strahl: Basis gesetzt – Richtung klicken');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureDefpointsLayer(c);
|
||||||
|
const dx = e.world.x - rayBase.x;
|
||||||
|
const dy = e.world.y - rayBase.y;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len < 0.001) { rayBase = null; return; }
|
||||||
|
const ux = dx / len, uy = dy / len;
|
||||||
|
const L = 100000;
|
||||||
|
const el: CADElement = {
|
||||||
|
id: uid('ray'),
|
||||||
|
type: 'line',
|
||||||
|
layerId: 'defpoints',
|
||||||
|
x: rayBase.x,
|
||||||
|
y: rayBase.y,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
properties: {
|
||||||
|
...constructionProps(),
|
||||||
|
x1: rayBase.x,
|
||||||
|
y1: rayBase.y,
|
||||||
|
x2: rayBase.x + ux * L,
|
||||||
|
y2: rayBase.y + uy * L,
|
||||||
|
},
|
||||||
|
} as unknown as CADElement;
|
||||||
|
c.doc.transact(() => c.doc!.addElement(el));
|
||||||
|
rayBase = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus(`Strahl erstellt (${el.id})`);
|
||||||
|
},
|
||||||
|
move(e, ctx) {
|
||||||
|
const c = ctx as FullToolContext;
|
||||||
|
if (!rayBase) return;
|
||||||
|
const dx = e.world.x - rayBase.x;
|
||||||
|
const dy = e.world.y - rayBase.y;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (len < 0.001) return;
|
||||||
|
const ux = dx / len, uy = dy / len;
|
||||||
|
const L = 100000;
|
||||||
|
ctx.setPreview?.({
|
||||||
|
id: '__preview_ray__',
|
||||||
|
type: 'line',
|
||||||
|
layerId: 'defpoints',
|
||||||
|
x: rayBase.x,
|
||||||
|
y: rayBase.y,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
properties: {
|
||||||
|
...constructionProps(),
|
||||||
|
x1: rayBase.x,
|
||||||
|
y1: rayBase.y,
|
||||||
|
x2: rayBase.x + ux * L,
|
||||||
|
y2: rayBase.y + uy * L,
|
||||||
|
},
|
||||||
|
} as unknown as CADElement);
|
||||||
|
},
|
||||||
|
cancel(ctx) {
|
||||||
|
rayBase = null;
|
||||||
|
ctx.setPreview?.(null);
|
||||||
|
ctx.setStatus('Strahl abgebrochen');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const coreDrawingPlugin: PluginV2 = {
|
export const coreDrawingPlugin: PluginV2 = {
|
||||||
manifest: {
|
manifest: {
|
||||||
id: 'core-drawing',
|
id: 'core-drawing',
|
||||||
name: 'Zeichnen (Kern)',
|
name: 'Zeichnen (Kern)',
|
||||||
version: '1.4.0',
|
version: '1.5.0',
|
||||||
author: 'web-cad team',
|
author: 'web-cad team',
|
||||||
description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud.',
|
description: 'Zeichenwerkzeuge V2: Linie, Rechteck, Kreis, Bogen, Polylinie, Polygon, RevCloud.',
|
||||||
category: 'tools',
|
category: 'tools',
|
||||||
enabledByDefault: true,
|
enabledByDefault: true,
|
||||||
},
|
},
|
||||||
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool],
|
tools: [lineTool, rectTool, circleTool, arcTool, polylineTool, polygonTool, revcloudTool, ellipseTool, splineTool, pointTool, xlineTool, rayTool],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Task D3 – Point/XLine/Ray: Konstruktions-Elemente.
|
||||||
|
*
|
||||||
|
* pointTool: Klick-Platzierer (kleiner Kreis mit point:true-Marker,
|
||||||
|
* r=1.5 wie DXF-POINT-Import). xlineTool: 2 Klicks (Basis+Richtung),
|
||||||
|
* sehr lange Linie in BEIDE Richtungen. rayTool: halblang (nur eine
|
||||||
|
* Richtung). Alle mit construction:true und auf defpoints-Layer
|
||||||
|
* (auto-angelegt falls fehlt).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { pointTool, xlineTool, rayTool } from '../src/plugins/builtin/core-drawing';
|
||||||
|
import { CADDocument } from '../src/kernel/document/CADDocument';
|
||||||
|
import { createInMemoryDoc } from './helpers/inMemoryDoc';
|
||||||
|
import type { CADElement, Pt } from '../src/types/cad.types';
|
||||||
|
|
||||||
|
function mkCtx() {
|
||||||
|
const { ydoc } = createInMemoryDoc();
|
||||||
|
const doc = new CADDocument(ydoc);
|
||||||
|
const previews: (CADElement | null)[] = [];
|
||||||
|
const statuses: string[] = [];
|
||||||
|
const ctx: any = {
|
||||||
|
doc,
|
||||||
|
options: {},
|
||||||
|
setStatus: (m: string) => statuses.push(m),
|
||||||
|
setPreview: (el: CADElement | null) => previews.push(el),
|
||||||
|
};
|
||||||
|
return { ctx, doc, previews, statuses };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pe(x: number, y: number): { world: Pt } {
|
||||||
|
return { world: { x, y } } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pointTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||||
|
xlineTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||||
|
rayTool.handlers.cancel?.({ setStatus: () => {}, setPreview: () => {} } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('D3: pointTool', () => {
|
||||||
|
it('Klick platziert Kreis mit point-Marker (r=1.5, wie DXF POINT)', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
pointTool.handlers.down!(pe(55, 66), ctx);
|
||||||
|
const els = doc.getAllElements();
|
||||||
|
expect(els).toHaveLength(1);
|
||||||
|
const el = els[0];
|
||||||
|
expect(el.type).toBe('circle');
|
||||||
|
expect(el.x).toBe(55);
|
||||||
|
expect(el.y).toBe(66);
|
||||||
|
expect(el.properties.radius).toBe(1.5);
|
||||||
|
expect(el.properties.point).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defpoints-Layer wird automatisch angelegt falls fehlt', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
expect(doc.getLayer('defpoints')).toBeUndefined();
|
||||||
|
pointTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
expect(doc.getLayer('defpoints')).toBeDefined();
|
||||||
|
expect(doc.getLayer('defpoints')!.name).toBe('defpoints');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Element landet auf defpoints-Layer mit construction:true', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
pointTool.handlers.down!(pe(10, 10), ctx);
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
expect(el.layerId).toBe('defpoints');
|
||||||
|
expect(el.properties.construction).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('D3: xlineTool (Konstruktionslinie, beide Richtungen)', () => {
|
||||||
|
it('2 Klicks erzeugen sehr lange Linie durch beide Punkte in BEIDE Richtungen', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
xlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
xlineTool.handlers.down!(pe(10, 10), ctx);
|
||||||
|
const els = doc.getAllElements();
|
||||||
|
expect(els).toHaveLength(1);
|
||||||
|
const el = els[0];
|
||||||
|
expect(el.type).toBe('line');
|
||||||
|
const dx = (el.properties.x2 as number) - (el.properties.x1 as number);
|
||||||
|
const dy = (el.properties.y2 as number) - (el.properties.y1 as number);
|
||||||
|
// Sehr lange: Länge >> Distanz der beiden Klicks (14.14)
|
||||||
|
expect(Math.hypot(dx, dy)).toBeGreaterThan(100000);
|
||||||
|
// Mittelpunkt der Linie = Basispunkt
|
||||||
|
expect((el.properties.x1 as number) + dx / 2).toBeCloseTo(0, 3);
|
||||||
|
expect((el.properties.y1 as number) + dy / 2).toBeCloseTo(0, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Element auf defpoints-Layer mit construction:true', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
xlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
xlineTool.handlers.down!(pe(5, 0), ctx);
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
expect(el.layerId).toBe('defpoints');
|
||||||
|
expect(el.properties.construction).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Preview bei move nach erstem Klick', () => {
|
||||||
|
const { ctx, previews } = mkCtx();
|
||||||
|
xlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
xlineTool.handlers.move!(pe(50, 50), ctx);
|
||||||
|
expect(previews.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Erster Klick ohne zweiten erzeugt NICHTS (Session-State)', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
xlineTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
expect(doc.getAllElements()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('D3: rayTool (Strahl, nur EINE Richtung)', () => {
|
||||||
|
it('2 Klicks erzeugen Linie ab Basispunkt NUR in Zugrichtung', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
rayTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
rayTool.handlers.down!(pe(10, 0), ctx);
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
expect(el.type).toBe('line');
|
||||||
|
expect(el.properties.x1).toBe(0);
|
||||||
|
expect(el.properties.y1).toBe(0);
|
||||||
|
// Endpunkt weit in +x-Richtung
|
||||||
|
expect((el.properties.x2 as number)).toBeGreaterThanOrEqual(100000);
|
||||||
|
// NICHT in Gegenrichtung: x1 ist exakt der Basispunkt
|
||||||
|
expect(el.properties.x1).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('construction:true + defpoints-Layer', () => {
|
||||||
|
const { ctx, doc } = mkCtx();
|
||||||
|
rayTool.handlers.down!(pe(0, 0), ctx);
|
||||||
|
rayTool.handlers.down!(pe(0, 10), ctx);
|
||||||
|
const el = doc.getAllElements()[0];
|
||||||
|
expect(el.layerId).toBe('defpoints');
|
||||||
|
expect(el.properties.construction).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('D3: Manifeste', () => {
|
||||||
|
it('ids/ribbonTab/tags korrekt', () => {
|
||||||
|
expect(pointTool.manifest.id).toBe('point');
|
||||||
|
expect(xlineTool.manifest.id).toBe('xline');
|
||||||
|
expect(rayTool.manifest.id).toBe('ray');
|
||||||
|
for (const t of [pointTool, xlineTool, rayTool]) {
|
||||||
|
expect(t.manifest.ribbonTab).toBe('canvas');
|
||||||
|
expect((t.manifest.tags ?? [])).toContain('basic');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user