Compare commits
2 Commits
64c25f2019
...
7331f5fb88
| Author | SHA1 | Date | |
|---|---|---|---|
| 7331f5fb88 | |||
| 74c47d7e77 |
+4
-3
@@ -667,6 +667,7 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
||||
|
||||
| Datum | Task | Zusammenfassung | Tests | Commit | Notizen |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-30 | CAD-4 | OTRACK + DIVIDE/MEASURE: (1) computeTrackPoint NEU in SnapEngine (reine Funktion, Task CAD-4) — Objekt-Fang-Tracking: projiziert den Cursor auf Tracking-Linien durch den Basispunkt (Default-Winkel 0/90/180/270°, optional erweiterte Winkelliste z.B. 45°), Dominant-Achsen-Projection je Winkel (cos/sin-Basiert), liefert den NÄCHSTGELEGENEN Track-Punkt, Degenerations-Guard Cursor=Basis → null; (2) pointsAlongPath NEU als reine Funktion — DIVIDE (segments>0): n+1 gleichmäßige Punkte über die Gesamtlänge inkl. beider Enden; MEASURE (fixedSpacing>0): Punkte bei 0, spacing, 2·spacing … solange ≤ Gesamtlänge, Rest verworfen; gebogene Pfade folgen via pointAlongPolyline (Wiederverwendung aus D10); (3) divideTool + measureTool NEU in core-modify (25 Tools): Klick auf Polyline-Pfad (Typ-Validierung über requirePathHit mit Status-Erklärung), count-/spacing-Optionen, Konstruktions-Marker-Kreise (r=1.5, point:true, construction:true) auf Auto-angelegtem defpoints-Layer, ALLE Punkte in EINER doc.transact = 1 Undo (Undo-Test verifiziert nur Pfad bleibt); Test-Zwischenfälle selbst korrigiert: L-Pfad-Test-Parameter inkonsistent mit eigener Spezifikation (2 → 3 Segmente) + Floating-Point-Rauschen in OTRACK-Erwartungen (toEqual → toBeCloseTo) | Frontend: 763/763 Tests (+16 otrackDivide.test.ts), tsc 0, Build EXIT 0 | 74c47d7 | CAD-4 COMPLETE; nächster Task CAD-5 (MLINE + Block-Editor) — letzter Post-2.0-Punkt |
|
||||
| 2026-08-30 | CAD-3 | Box-Selektion: (1) boxModeFor NEU — Zugrichtung bestimmt den Modus: links-nach-rechts = window (Element muss VOLLSTÄNDIG in der Box liegen), rechts-nach-links = crossing (Berührung reicht) — AutoCAD-Standard; (2) boxSelectElements NEU als reine Funktion — window über BBox-Kontainment (minX/maxX-Checks), crossing über AABB-Überschneidung (Berührung inklusive), elementBBox-Helfer nutzt line-Koordinaten sonst x/y±w/2; (3) selectTool-Integration: Leerraum-down startet jetzt Box-Drag (statt sofort zu leeren — das CAD-Grundgefühl), move zeichnet Preview-Rechteck (blau=fenster, grün=kreuzend mit transparenter Füllung), up schließt ab: echte Box selektiert (≥3 Welt-Einheiten Zug), winzige Box = Klick ohne Zug → Legacy-Leeren, Shift an down UND up = additiv zur bestehenden Selektion (Merge-Set); Status meldet Anzahl + Modus; Klick AUF ein Element startet KEINE Box (normale Einzelselektion); (4) Defensive State-Hygiene: boxDrag wird am down-Start abgeräumt (schützt vor verlorenen up-Events); Zwischenfall selbst gelöst: Module-State-Leak zwischen Tests (Leerklick-Test setzte boxDrag ohne up → nachfolgender Handle-Drag-Test lief in den Box-Zweig) + Leerklick-Test an CAD-3-Verhalten angepasst (Leeren jetzt bei up) | Frontend: 747/747 Tests (+13 boxSelect.test.ts), tsc 0, Build EXIT 0 | 099f654 | CAD-3 COMPLETE — alle drei kritischen Features (Bemassung, Hatch, Box-Select) fertig; nächster Task CAD-4 (OTRACK + DIVIDE/MEASURE) |
|
||||
| 2026-08-29 | CAD-2 | Echte Hatch-Muster: (1) hatchPatternService NEU (154 Zeilen) — 9 Muster als HatchPattern-Definitionen (id/label/description/defaultSpacing/angles/dots/solid): ANSI31 (45°), ANSI32 (Eisen 45/-45), ANSI37 (Beton dicht), NET (H+V), GRASS (75°), DOTS (Punktraster), SOLID (Vollfläche) + legacy lines/cross bleiben erhalten; generateHatchLines: Linienfamilien je Winkel über BBox-Diagonale mit Liang-Barsky-Clipping an der BBox, DOTS als Punktraster, SOLID als ein Segment mit fill-Flag; computeBoundary: BBox aus Kontur-Punkten (≥2 Punkte, sonst null); (2) hatchTool optionsSchema auf 9 Muster erweitert; (3) elementDrawers: hatchPrimitives-Helfer (hatch-Flag + Muster + Spacing → Segment-Primitives) in rect-, circle- und polyline/polygon-Cases integriert — Basis-Primitive + Hatch-Linien als kombiniertes Array; Typ-Fix: explizite ShapePrimitive[]-Annotationen gegen Literal-Widening | Frontend: 734/734 Tests (+11 hatchPatterns.test.ts), tsc 0, Build EXIT 0 | 11a0dcb | CAD-2 COMPLETE — beide priorisierten Features (Bemassung + Hatch) fertig; nächster Task CAD-3 (Box-Select) |
|
||||
| 2026-08-29 | CAD-1 | Assoziative Bemassung: (1) dimStyleService NEU — DimStyle-Interface (textHeight/arrowSize/unit/decimals/arrowStyle/extOffset/overshoot), 4 Standard-Stile (ISO-25 ISO-50 Architectural Engineering), getDimStyle mit Default-Fallback, computeDimValue assoziativ aus Geometrie (linear H/V mit dominanter-Achse-Default, aligned Distanz, angular Winkel zwischen Schenkeln, radius/diameter aus Kreis-element), formatDimValue mit Typ-Praefixen (R25, ⌀ 50, 45°) und Dezimalstellen; (2) 5 Dimension-Tools NEU in core-annotate: dimLinearTool (dimSubtype-Option horizontal/vertical, Preview bei move), dimAlignedTool, dimAngularTool (3-Punkt: Scheitel+Schenkel1+Schenkel2 mit Vektor-Winkelberechnung), dimRadiusTool + dimDiameterTool (Klick auf Kreis via selection.hitTest mit Status-Fehlermeldung bei Nicht-Kreis), alle committen dimension-Elemente mit dimType/value/dimStyle-Metadaten in EINER Transaktion; (3) elementToPrimitives dimension-Case komplett erweitert: Extension lines (grau 0.5px), Masslinie, 2 Pfeilspitzen als geschlossene Polygone (arrowPrimitives-Helfer mit Richtung + Halbbreite), Wert-Text nach DimStyle-Hoehe, radius/diameter mit Zentrum-Linie + Pfeil, angular mit Schenkel-Darstellung; Legacy-Test auf 6 Primitives aktualisiert | Frontend: 723/723 Tests (+26 dimensionTools.test.ts), tsc 0 (nach Pt-Import-Fix), Build EXIT 0 | 44a60fa | CAD-1 COMPLETE; nächster Task CAD-2 (Hatch-Muster) |
|
||||
@@ -756,14 +757,14 @@ Format: `YYYY-MM-DD | TASK-ID | was getan | Tests Ergebnis | commit | Notizen/of
|
||||
- `frontend/src/utils/__tests__/format.test.ts` liegt in src statt tests/ — vereinheitlichen (kleiner Task in I2)
|
||||
|
||||
---
|
||||
*Post-2.0 CAD-Ausbau: CAD-1 (Bemassung) + CAD-2 (Hatch) + CAD-3 (Box-Select) fertig — alle drei kritischen Features komplett. Naechster Task: **CAD-4** (OTRACK + DIVIDE/MEASURE). Danach: CAD-5 MLINE/Block-Editor.*
|
||||
*Status 2026-08-30: RELEASE 2.0.0 LIVE + Post-2.0: CAD-1 (Bemassung) + CAD-2 (Hatch) + CAD-3 (Box-Select) fertig. FE 747/747 Tests, BE 287/287, tsc/Build grün, Prod-Deploy verifiziert. Tag v2.0.0 auf Forgejo main.*
|
||||
*Post-2.0 CAD-Ausbau: CAD-1 bis CAD-4 fertig (Bemassung, Hatch, Box-Select, OTRACK+DIVIDE/MEASURE). Letzter Task: **CAD-5** (MLINE + Block-Editor).*
|
||||
*Status 2026-08-30: RELEASE 2.0.0 LIVE + Post-2.0: CAD-1 bis CAD-4 fertig. FE 763/763 Tests, BE 287/287, tsc/Build grün, Prod-Deploy verifiziert. Tag v2.0.0 auf Forgejo main.*
|
||||
|
||||
## Post-2.0 CAD-Ausbau (neue Task-Reihe)
|
||||
|
||||
- [x] CAD-1 Assoziative Bemassung: DimStyles (ISO-25/ISO-50/Architectural/Engineering mit textHeight/arrowSize/unit/decimals/arrowStyle), 5 Dimension-Typen (linear H/V, aligned, angular 3-Punkt, radius, diameter), assoziative Wert-Berechnung aus Geometrie, formatDimValue mit Typ-Praefixen (R/⌀/°), komplette Primitives mit Extension lines + Masslinie + Pfeilspitzen als geschlossene Polygone *(2026-08-29; Log CAD-1)*
|
||||
- [x] CAD-2 Echte Hatch-Muster: ANSI31/NET/GRASS etc. + Boundary-Erkennung aus umschlossener Flache + assoziative Schraffur *(2026-08-29: 9 Muster (ANSI31/32/37, NET, GRASS, DOTS, SOLID + legacy lines/cross), generateHatchLines mit Winkel-Familien + Liang-Barsky-Clipping, computeBoundary, RenderAdapter zeichnet Hatch in rect/poly/circle; Log CAD-2)*
|
||||
- [x] CAD-3 Fenster-/Kreuz-Selektion (Box-Select) *(2026-08-30: boxModeFor (Richtung = window/crossing wie AutoCAD), boxSelectElements (window = vollständig enthalten, crossing = Berührung reicht), Box-Drag-Session mit Preview-Rechteck (blau Fenster / grün Kreuzend), Shift-additiv, winzige Box = Klick → Legacy-Leeren bei up; Log CAD-3)*
|
||||
- [ ] CAD-4 Objekt-Fang-Tracking (OTRACK) + DIVIDE/MEASURE
|
||||
- [x] CAD-4 Objekt-Fang-Tracking (OTRACK) + DIVIDE/MEASURE *(2026-08-30: computeTrackPoint (Winkel-Projektion auf Tracking-Linien, Default 0/90/180/270°, erweiterte Winkel optional, nächstgelegener Track-Punkt) als reine Funktion in SnapEngine; pointsAlongPath (DIVIDE n+1 gleichmäßig inkl. Enden / MEASURE fester Abstand, Rest verworfen) unter Wiederverwendung von pointAlongPolyline; divideTool + measureTool mit defpoints-Markern in EINER Transaktion; Log CAD-4)*
|
||||
- [ ] CAD-5 Multiline (MLINE) + Block-Editor
|
||||
|
||||
|
||||
@@ -494,3 +494,44 @@ export class SnapEngine {
|
||||
export function fromOffset(base: { x: number; y: number }, dx: number, dy: number): { x: number; y: number } {
|
||||
return { x: base.x + dx, y: base.y + dy };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// Task CAD-4: OTRACK — Objekt-Fang-Tracking. computeTrackPoint
|
||||
// projiziert den Cursor auf Tracking-Linien durch den Basispunkt
|
||||
// (Default 0/90/180/270°, optional erweiterte Winkel) und liefert
|
||||
// den nächstgelegenen Track-Punkt. Reine Funktion (UI-testbar).
|
||||
// ───────────────────────────────────────────────────
|
||||
|
||||
export function computeTrackPoint(
|
||||
base: { x: number; y: number },
|
||||
cursor: { x: number; y: number },
|
||||
angles?: number[],
|
||||
): { x: number; y: number } | null {
|
||||
const dx = cursor.x - base.x;
|
||||
const dy = cursor.y - base.y;
|
||||
if (dx === 0 && dy === 0) return null;
|
||||
const angs = angles ?? [0, 90, 180, 270];
|
||||
let best: { x: number; y: number } | null = null;
|
||||
let bestDist = Infinity;
|
||||
for (const a of angs) {
|
||||
const rad = (a * Math.PI) / 180;
|
||||
const dxc = Math.cos(rad);
|
||||
const dyc = Math.sin(rad);
|
||||
let pt: { x: number; y: number };
|
||||
if (Math.abs(dxc) >= Math.abs(dyc)) {
|
||||
if (dxc === 0) continue;
|
||||
const t = dx / dxc;
|
||||
pt = { x: cursor.x, y: base.y + t * dyc };
|
||||
} else {
|
||||
if (dyc === 0) continue;
|
||||
const t = dy / dyc;
|
||||
pt = { x: base.x + t * dxc, y: cursor.y };
|
||||
}
|
||||
const dist = Math.hypot(pt.x - cursor.x, pt.y - cursor.y);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = pt;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
@@ -1715,6 +1715,152 @@ export const offsetTool: ToolExtensionV2 = {
|
||||
};
|
||||
|
||||
/** V2-Plugin: Kern-Bearbeitungswerkzeuge. */
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Task CAD-4: DIVIDE/MEASURE — Punkte entlang eines Pfads
|
||||
// (Polyline) verteilen. DIVIDE: n gleiche Segmente → n+1 Punkte
|
||||
// inkl. Enden. MEASURE: fester Abstand inkl. Start, Rest verworfen.
|
||||
// Punkte = Konstruktionsmarker auf defpoints (1 Transaktion).
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Punkte entlang eines Polylinien-Pfads (Task CAD-4, rein).
|
||||
* segments > 0: DIVIDE — n+1 gleichmäßige Punkte inkl. Enden.
|
||||
* fixedSpacing > 0: MEASURE — Punkte bei 0, spacing, 2*spacing, …
|
||||
* solange s ≤ Gesamtlänge; Rest am Ende wird verworfen.
|
||||
*/
|
||||
export function pointsAlongPath(pts: Pt[], segments: number, fixedSpacing?: number): Pt[] {
|
||||
if (!Array.isArray(pts) || pts.length < 2) return [];
|
||||
let total = 0;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
total += Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
|
||||
}
|
||||
if (total <= 0) return [...pts];
|
||||
const out: Pt[] = [];
|
||||
if (segments > 0) {
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
out.push(pointAlongPolyline(pts, (total * i) / segments));
|
||||
}
|
||||
} else if (fixedSpacing && fixedSpacing > 0) {
|
||||
for (let s = 0; s <= total + 1e-9; s += fixedSpacing) {
|
||||
out.push(pointAlongPolyline(pts, s));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** defpoints-Layer sicherstellen (CAD-4, lokal im core-modify). */
|
||||
function ensureDefpoints(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,
|
||||
});
|
||||
}
|
||||
|
||||
/** Pfad-Treffer validieren: muss polyline/polygon mit ≥2 Punkten sein. */
|
||||
function requirePathHit(
|
||||
c: FullToolContext,
|
||||
wx: number,
|
||||
wy: number,
|
||||
): { pts: Pt[]; path: CADElement } | null {
|
||||
if (!c.selection) return null;
|
||||
const hit = c.selection.hitTest(wx, wy, 8);
|
||||
if (!hit || (hit.type !== 'polyline' && hit.type !== 'polygon')) return null;
|
||||
const pts = (hit.properties as Record<string, unknown>).points as Pt[] | undefined;
|
||||
if (!pts || pts.length < 2) return null;
|
||||
return { pts, path: hit };
|
||||
}
|
||||
|
||||
/** Konstruktions-Marker-Punkt auf defpoints platzieren. */
|
||||
function makePathMarker(p: Pt): CADElement {
|
||||
return {
|
||||
id: uid('pathmark'),
|
||||
type: 'circle',
|
||||
layerId: 'defpoints',
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
width: 3,
|
||||
height: 3,
|
||||
properties: { radius: 1.5, point: true, construction: true, stroke: '#8a8f98', strokeWidth: 1 },
|
||||
} as unknown as CADElement;
|
||||
}
|
||||
|
||||
export const divideTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'divide',
|
||||
label: 'Teilen',
|
||||
icon: '÷',
|
||||
ribbonTab: 'canvas',
|
||||
tags: ['pro'],
|
||||
description: 'Pfad in n gleiche Segmente teilen (DIVIDE, Task CAD-4)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'count', label: 'Anzahl Segmente', type: 'number', min: 1, max: 100 },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc) return;
|
||||
const found = requirePathHit(c, e.world.x, e.world.y);
|
||||
if (!found) {
|
||||
ctx.setStatus('Teilen: Polyline als Pfad anklicken');
|
||||
return;
|
||||
}
|
||||
const segments = Math.max(1, (c.options.count as number | undefined) ?? 4);
|
||||
ensureDefpoints(c);
|
||||
const marks = pointsAlongPath(found.pts, segments);
|
||||
c.doc.transact(() => {
|
||||
for (const p of marks) c.doc!.addElement(makePathMarker(p));
|
||||
});
|
||||
ctx.setStatus(`Geteilt: ${marks.length} Punkte auf ${segments} Segmente`);
|
||||
},
|
||||
cancel(ctx) {
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Teilen abgebrochen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const measureTool: ToolExtensionV2 = {
|
||||
manifest: {
|
||||
id: 'measure',
|
||||
label: 'Abmessen (Punkte)',
|
||||
icon: '⋮',
|
||||
ribbonTab: 'canvas',
|
||||
tags: ['pro'],
|
||||
description: 'Punkte im festen Abstand auf Pfad (MEASURE, Task CAD-4)',
|
||||
},
|
||||
optionsSchema: [
|
||||
{ key: 'spacing', label: 'Abstand', type: 'number', min: 1 },
|
||||
],
|
||||
handlers: {
|
||||
down(e, ctx) {
|
||||
const c = ctx as FullToolContext;
|
||||
if (!c.doc) return;
|
||||
const found = requirePathHit(c, e.world.x, e.world.y);
|
||||
if (!found) {
|
||||
ctx.setStatus('Abmessen: Polyline als Pfad anklicken');
|
||||
return;
|
||||
}
|
||||
const spacing = Math.max(1, (c.options.spacing as number | undefined) ?? 20);
|
||||
ensureDefpoints(c);
|
||||
const marks = pointsAlongPath(found.pts, 0, spacing);
|
||||
c.doc.transact(() => {
|
||||
for (const p of marks) c.doc!.addElement(makePathMarker(p));
|
||||
});
|
||||
ctx.setStatus(`Abgemessen: ${marks.length} Punkte alle ${spacing} Einheiten`);
|
||||
},
|
||||
cancel(ctx) {
|
||||
ctx.setPreview?.(null);
|
||||
ctx.setStatus('Abmessen abgebrochen');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const coreModifyPlugin: PluginV2 = {
|
||||
manifest: {
|
||||
id: 'core-modify',
|
||||
@@ -1725,5 +1871,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, arrayPathTool],
|
||||
tools: [selectTool, hatchTool, moveTool, copyTool, rotateTool, scaleTool, mirrorTool, deleteTool, offsetTool, trimTool, extendTool, filletTool, arrayRectTool, arrayPolarTool, chamferTool, lengthenTool, joinTool, explodeTool, alignTool, polylineEditTool, stretchTool, breakTool, arrayPathTool, divideTool, measureTool],
|
||||
};
|
||||
|
||||
@@ -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