Merge: VW-Zeichen-Feedback (Cursor-HUD L/W + weiches Winkel-Einrasten)
Beim Zeichnen erscheinen Länge + Winkel direkt am Cursor (L:/W:-Kästchen), gängige Winkel (15°-Vielfache) rasten weich ein — mit Winkel-Badge und gestrichelter Führungslinie. Vorrang: Objekt-Snap > Shift/Ortho > Winkelraster > Raster. Toggle + Toleranz in der Fang-Leiste. Konflikt tools/types.ts: erweitertes hud-Feld + measure-Feld koexistieren. # Conflicts: # src/tools/types.ts
This commit is contained in:
+143
-1
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeSnap, wallLayerBoundarySegments } from "./snapping";
|
||||
import { computeSnap, snapCommonAngle, wallLayerBoundarySegments } from "./snapping";
|
||||
import { DEFAULT_SNAP } from "./types";
|
||||
import type { Drawing2D, Project, Vec2, Wall } from "../model/types";
|
||||
|
||||
@@ -226,3 +226,145 @@ describe("computeSnap — Kreis/Bogen", () => {
|
||||
expect(r?.kind).not.toBe("quadrant");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Weiches Winkel-Einrasten (snapCommonAngle) ──────────────────────────────
|
||||
|
||||
describe("snapCommonAngle", () => {
|
||||
const O: Vec2 = { x: 0, y: 0 };
|
||||
|
||||
it("lässt einen exakten 45°-Winkel unverändert", () => {
|
||||
const r = snapCommonAngle(O, { x: 2, y: 2 });
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.angleDeg).toBeCloseTo(45, 9);
|
||||
expect(r!.point.x).toBeCloseTo(2, 9);
|
||||
expect(r!.point.y).toBeCloseTo(2, 9);
|
||||
});
|
||||
|
||||
it("rastet 43.8° (innerhalb 2.5° Toleranz) auf 45°", () => {
|
||||
const rad = (43.8 * Math.PI) / 180;
|
||||
const raw: Vec2 = { x: Math.cos(rad) * 5, y: Math.sin(rad) * 5 };
|
||||
const r = snapCommonAngle(O, raw, 2.5);
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.angleDeg).toBeCloseTo(45, 9);
|
||||
});
|
||||
|
||||
it("rastet NICHT bei 40° (5° Abweichung > 2.5° Toleranz)", () => {
|
||||
const rad = (40 * Math.PI) / 180;
|
||||
const raw: Vec2 = { x: Math.cos(rad) * 5, y: Math.sin(rad) * 5 };
|
||||
const r = snapCommonAngle(O, raw, 2.5);
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
|
||||
it("Länge = Projektion der Roh-Distanz auf den gerasterten Strahl", () => {
|
||||
const rad = (43.8 * Math.PI) / 180;
|
||||
const rawLen = 5;
|
||||
const raw: Vec2 = { x: Math.cos(rad) * rawLen, y: Math.sin(rad) * rawLen };
|
||||
const r = snapCommonAngle(O, raw, 2.5);
|
||||
expect(r).not.toBeNull();
|
||||
const expectedLen = rawLen * Math.cos(((45 - 43.8) * Math.PI) / 180);
|
||||
const len = Math.hypot(r!.point.x, r!.point.y);
|
||||
expect(len).toBeCloseTo(expectedLen, 9);
|
||||
// Der gerastete Punkt liegt exakt auf dem 45°-Strahl.
|
||||
expect(r!.point.x).toBeCloseTo(r!.point.y, 9);
|
||||
});
|
||||
|
||||
it("normalisiert auf den Bereich (−180,180] — z. B. −135°", () => {
|
||||
const rad = (-133 * Math.PI) / 180;
|
||||
const raw: Vec2 = { x: Math.cos(rad) * 3, y: Math.sin(rad) * 3 };
|
||||
const r = snapCommonAngle(O, raw, 2.5);
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.angleDeg).toBeCloseTo(-135, 9);
|
||||
});
|
||||
|
||||
it("deckt alle 15°-Vielfachen ab (0/30/45/60/90/135/…)", () => {
|
||||
for (const deg of [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, -15, -90, -135]) {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const raw: Vec2 = { x: Math.cos(rad) * 4, y: Math.sin(rad) * 4 };
|
||||
const r = snapCommonAngle(O, raw, 2.5);
|
||||
expect(r, `deg=${deg}`).not.toBeNull();
|
||||
expect(r!.angleDeg, `deg=${deg}`).toBeCloseTo(deg === -180 ? 180 : deg, 9);
|
||||
}
|
||||
});
|
||||
|
||||
it("liefert null bei Roh-Distanz ≈ 0 (kein Winkel bestimmbar)", () => {
|
||||
expect(snapCommonAngle(O, { x: 1e-10, y: 0 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSnap — Objekt-Snap hat Vorrang vor dem weichen Winkelraster", () => {
|
||||
it("fängt den Endpunkt statt auf das Winkelraster einzurasten, wenn beide zutreffen", () => {
|
||||
// Wand-Endpunkt bei (5,5) liegt exakt auf dem 45°-Strahl ab (0,0) — beide
|
||||
// Kandidaten (Endpunkt UND Winkelraster) träfen zu; der Endpunkt muss gewinnen.
|
||||
const w = wall2d({ x: 5, y: 5 }, { x: 8, y: 5 });
|
||||
const project = drawProject([]);
|
||||
project.walls = [w];
|
||||
const result = computeSnap({
|
||||
raw: { x: 5.02, y: 5.02 },
|
||||
project,
|
||||
levelId: "eg",
|
||||
visibleCodes: new Set(["20"]),
|
||||
settings: DEFAULT_SNAP,
|
||||
draftPoints: [],
|
||||
lastPoint: { x: 0, y: 0 },
|
||||
pxPerMeter: 100,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
});
|
||||
expect(result?.kind).toBe("endpoint");
|
||||
expect(result!.point.x).toBeCloseTo(5, 6);
|
||||
expect(result!.point.y).toBeCloseTo(5, 6);
|
||||
});
|
||||
|
||||
it("rastet auf das Winkelraster, wenn KEIN Objekt-Snap in Reichweite ist", () => {
|
||||
const project = drawProject([]);
|
||||
const rad = (43.8 * Math.PI) / 180;
|
||||
const raw: Vec2 = { x: Math.cos(rad) * 5, y: Math.sin(rad) * 5 };
|
||||
const result = computeSnap({
|
||||
raw,
|
||||
project,
|
||||
levelId: "eg",
|
||||
visibleCodes: new Set(["20"]),
|
||||
settings: DEFAULT_SNAP,
|
||||
draftPoints: [],
|
||||
lastPoint: { x: 0, y: 0 },
|
||||
pxPerMeter: 100,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
});
|
||||
expect(result?.kind).toBe("angle");
|
||||
expect(result?.angleDeg).toBeCloseTo(45, 9);
|
||||
});
|
||||
|
||||
it("respektiert settings.commonAngles=false (kein Winkelraster-Fallback)", () => {
|
||||
const project = drawProject([]);
|
||||
const rad = (45 * Math.PI) / 180;
|
||||
const raw: Vec2 = { x: Math.cos(rad) * 5, y: Math.sin(rad) * 5 };
|
||||
const result = computeSnap({
|
||||
raw,
|
||||
project,
|
||||
levelId: "eg",
|
||||
visibleCodes: new Set(["20"]),
|
||||
settings: { ...DEFAULT_SNAP, commonAngles: false, grid: false },
|
||||
draftPoints: [],
|
||||
lastPoint: { x: 0, y: 0 },
|
||||
pxPerMeter: 100,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/** Minimal-Wand (nur für Endpunkt-Snap-Vorrang-Tests) — kein Wandtyp nötig. */
|
||||
function wall2d(start: Vec2, end: Vec2): Wall {
|
||||
return {
|
||||
id: "w-angle-test",
|
||||
type: "wall",
|
||||
floorId: "eg",
|
||||
categoryCode: "20",
|
||||
start,
|
||||
end,
|
||||
wallTypeId: "unknown",
|
||||
height: 2.6,
|
||||
};
|
||||
}
|
||||
|
||||
+58
-1
@@ -27,6 +27,7 @@ const PRIORITY: Record<SnapKind, number> = {
|
||||
grid: -2,
|
||||
ortho: 0,
|
||||
extension: 0,
|
||||
angle: 0,
|
||||
};
|
||||
|
||||
export interface SnapInput {
|
||||
@@ -236,12 +237,29 @@ export function computeSnap(input: SnapInput): SnapResult | null {
|
||||
|
||||
if (best) return best;
|
||||
|
||||
// Ortho / Winkelraster (Projektion relativ zum letzten Punkt).
|
||||
// Ortho / Winkelraster (hart, Projektion relativ zum letzten Punkt). Shift
|
||||
// erzwingt zusätzlich — beides UNVERÄNDERT gegenüber dem weichen Winkelraster
|
||||
// unten, das nur greift, wenn weder Shift noch die Ortho-Einstellung aktiv ist.
|
||||
if ((settings.ortho || shift) && input.lastPoint) {
|
||||
const point = applyAngleConstraint(input.lastPoint, raw, settings.angleStep);
|
||||
return { point, kind: "ortho", refA: input.lastPoint, distPx: 0 };
|
||||
}
|
||||
|
||||
// Weiches Winkelraster (gängige Winkel, 15°-Vielfache) — nur wenn kein
|
||||
// Objekt-Snap UND kein harter Ortho-Zwang gegriffen hat (Vorrang-Reihenfolge).
|
||||
if (settings.commonAngles && input.lastPoint) {
|
||||
const snapped = snapCommonAngle(input.lastPoint, raw, settings.commonAngleTolerance);
|
||||
if (snapped) {
|
||||
return {
|
||||
point: snapped.point,
|
||||
kind: "angle",
|
||||
refA: input.lastPoint,
|
||||
angleDeg: snapped.angleDeg,
|
||||
distPx: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Raster.
|
||||
if (settings.grid) {
|
||||
const g = snapToGrid(raw, settings.gridSize);
|
||||
@@ -269,3 +287,42 @@ function snapToGrid(p: Vec2, size: number): Vec2 {
|
||||
if (!(size > 0)) return p;
|
||||
return { x: Math.round(p.x / size) * size, y: Math.round(p.y / size) * size };
|
||||
}
|
||||
|
||||
/** Winkel (Grad) auf den Bereich (−180,180] normalisieren (VW-Konvention). */
|
||||
function normalizeAngleDeg(deg: number): number {
|
||||
let a = deg % 360;
|
||||
if (a <= -180) a += 360;
|
||||
if (a > 180) a -= 360;
|
||||
return a;
|
||||
}
|
||||
|
||||
/** Schrittweite des weichen Winkelrasters: deckt 0/15/30/45/60/75/90/… ab. */
|
||||
const COMMON_ANGLE_STEP_DEG = 15;
|
||||
|
||||
/**
|
||||
* Weiches Winkel-Einrasten (Vectorworks-Stil): rastet den Roh-Winkel von `from`
|
||||
* nach `raw` auf den NÄCHSTEN gängigen Winkel (15°-Vielfache), wenn die
|
||||
* Abweichung ≤ `toleranceDeg` — sonst `null`. Die LÄNGE bleibt die Roh-Distanz,
|
||||
* projiziert auf den gerasterten Strahl (nicht die rohe Luftlinie). Reine
|
||||
* Geometrie, unabhängig von Objekt-Snaps/Settings (Integration in
|
||||
* {@link computeSnap} entscheidet über den Vorrang).
|
||||
*/
|
||||
export function snapCommonAngle(
|
||||
from: Vec2,
|
||||
raw: Vec2,
|
||||
toleranceDeg = 2.5,
|
||||
): { point: Vec2; angleDeg: number } | null {
|
||||
const dx = raw.x - from.x;
|
||||
const dy = raw.y - from.y;
|
||||
const rawLen = Math.hypot(dx, dy);
|
||||
if (rawLen < 1e-9) return null;
|
||||
const rawAngleDeg = (Math.atan2(dy, dx) * 180) / Math.PI; // bereits (−180,180]
|
||||
const snappedRaw = Math.round(rawAngleDeg / COMMON_ANGLE_STEP_DEG) * COMMON_ANGLE_STEP_DEG;
|
||||
const delta = snappedRaw - rawAngleDeg; // klein (≤ halbe Schrittweite), keine Wraparound-Sorgen
|
||||
if (Math.abs(delta) > toleranceDeg) return null;
|
||||
const angleDeg = normalizeAngleDeg(snappedRaw);
|
||||
const rad = (angleDeg * Math.PI) / 180;
|
||||
const length = rawLen * Math.cos((delta * Math.PI) / 180); // Projektion auf den Strahl
|
||||
const point = { x: from.x + Math.cos(rad) * length, y: from.y + Math.sin(rad) * length };
|
||||
return { point, angleDeg };
|
||||
}
|
||||
|
||||
+39
-4
@@ -35,16 +35,19 @@ export type SnapKind =
|
||||
| "onEdge"
|
||||
| "grid"
|
||||
| "ortho"
|
||||
| "extension";
|
||||
| "extension"
|
||||
| "angle";
|
||||
|
||||
export interface SnapResult {
|
||||
/** Gefangener Punkt (Meter). */
|
||||
point: Vec2;
|
||||
kind: SnapKind;
|
||||
/** Quell-Bezugspunkt (für Hilfslinien bei ortho/extension), optional. */
|
||||
/** Quell-Bezugspunkt (für Hilfslinien bei ortho/extension/angle), optional. */
|
||||
refA?: Vec2;
|
||||
/** Bildschirm-Distanz Cursor→Snap (px) — für die Auswahl des Besten. */
|
||||
distPx: number;
|
||||
/** Eingerasteter Winkel in Grad, Bereich (−180,180] — nur bei kind "angle". */
|
||||
angleDeg?: number;
|
||||
}
|
||||
|
||||
export interface SnapSettings {
|
||||
@@ -63,6 +66,10 @@ export interface SnapSettings {
|
||||
angleStep: number;
|
||||
/** Fangradius am Bildschirm in Pixeln. */
|
||||
tolerancePx: number;
|
||||
/** Weiches Einrasten auf gängige Winkel (15°-Vielfache), wenn kein Objekt-Snap greift. */
|
||||
commonAngles: boolean;
|
||||
/** Toleranz (Grad) für das weiche Winkel-Einrasten. */
|
||||
commonAngleTolerance: number;
|
||||
}
|
||||
|
||||
/** Default-Snap-Einstellungen (endpoint/midpoint/intersection/onEdge + grid). */
|
||||
@@ -78,6 +85,8 @@ export const DEFAULT_SNAP: SnapSettings = {
|
||||
ortho: false,
|
||||
angleStep: 90,
|
||||
tolerancePx: 12,
|
||||
commonAngles: true,
|
||||
commonAngleTolerance: 2.5,
|
||||
};
|
||||
|
||||
// ── Werkzeug-Schnittstelle ──────────────────────────────────────────────────
|
||||
@@ -148,8 +157,14 @@ export interface ToolDraft {
|
||||
vertices: Vec2[];
|
||||
/** Aktiver Snap (für den Bildschirm-Marker), optional. */
|
||||
snap?: SnapResult | null;
|
||||
/** Optionaler Maß-/Winkel-Text am Cursor (z. B. „3.20 m · 90°"). */
|
||||
hud?: { at: Vec2; text: string };
|
||||
/**
|
||||
* Maß-/Winkel-Anzeige am Cursor (Vectorworks-Stil: `L: 3.118m` + `W: 60.000°`
|
||||
* in einem blau umrandeten Kästchen). `length`/`angleDeg` speisen die VW-Box;
|
||||
* `text` bleibt für Befehle ohne Länge/Winkel-Paar (z. B. Radius, Fläche) als
|
||||
* einfaches Text-Label. Beide Formen sind additiv kombinierbar (text kann als
|
||||
* Zusatzzeile neben length/angleDeg stehen).
|
||||
*/
|
||||
hud?: { at: Vec2; text?: string; length?: number; angleDeg?: number };
|
||||
/** Live-Messwerte (nur beim Mess-Werkzeug gesetzt) fürs Objekt-Info-Panel. */
|
||||
measure?: MeasurementReadout;
|
||||
}
|
||||
@@ -218,6 +233,26 @@ export interface ToolHandlers {
|
||||
draft: ToolDraft | null;
|
||||
}
|
||||
|
||||
// ── Cursor-HUD (VW-Stil L/W-Kästchen) ────────────────────────────────────────
|
||||
|
||||
/** Modell-Winkel atan2(dy,dx) in Grad — bereits im Bereich (−180,180] (VW-Konvention). */
|
||||
export function angleDegOf(from: Vec2, to: Vec2): number {
|
||||
return (Math.atan2(to.y - from.y, to.x - from.x) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Länge+Winkel-HUD für das aktive Segment `from → to` (VW-Stil `L: … W: …`).
|
||||
* Gemeinsamer Helper für alle Zeichenbefehle (line/polyline/wall/rect/…), damit
|
||||
* die Formatierung/Konvention EINMAL lebt statt in jedem Befehl neu.
|
||||
*/
|
||||
export function segmentHud(from: Vec2, to: Vec2): { at: Vec2; length: number; angleDeg: number } {
|
||||
return {
|
||||
at: to,
|
||||
length: Math.hypot(to.x - from.x, to.y - from.y),
|
||||
angleDeg: angleDegOf(from, to),
|
||||
};
|
||||
}
|
||||
|
||||
// ── ID-Vergabe ───────────────────────────────────────────────────────────────
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
Reference in New Issue
Block a user