Cursor-HUD (VW-Stil) + weiches Winkel-Einrasten im Grundriss

Beim Zeichnen erscheint jetzt direkt am Cursor ein blau umrandetes
Massband-Kästchen mit Länge/Winkel (L: 3.118m  W: 60.000°, Winkel im
Bereich (−180,180]) statt nur in der Befehlszeile. Zusätzlich rastet
der Zugwinkel weich auf gängige Winkel (15°-Vielfache, Toleranz per
Default 2.5°) ein, sobald kein Objekt-Snap greift — mit gelblichem
Winkel-Badge und einer über den Cursor hinaus verlängerten, gestrichelten
Führungslinie (Vectorworks-Vorbild).

- tools/types.ts: ToolDraft.hud um length/angleDeg erweitert (text bleibt
  für Befehle ohne L/W-Paar), neuer segmentHud()-Helper.
- tools/snapping.ts: neue reine Funktion snapCommonAngle() + Einbindung in
  computeSnap (Objekt-Snap > harter Ortho-Zwang > weiches Winkelraster >
  Raster). Neues SnapSettings-Feld commonAngles + commonAngleTolerance,
  neuer SnapKind "angle".
- ui/StatusBar.tsx: Toggle + Toleranz-Eingabe in der Fang-Popover.
- plan/PlanView.tsx: DraftHud- und AngleGuide-Overlay (screen-space,
  zoomunabhängige Größe); die Winkel-Info reitet auf draft.snap mit.
- i18n: snap.commonAngles / snap.commonAngleTolerance (de/en).
- snapping.test.ts: snapCommonAngle-Fälle (exakt/Toleranz/Bereich/
  15°-Raster/Projektion) + Objekt-Snap-Vorrang vor dem Winkelraster.
This commit is contained in:
2026-07-11 00:21:21 +02:00
parent 132622b479
commit 8377b76d98
9 changed files with 435 additions and 18 deletions
+58 -1
View File
@@ -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 };
}