From 3f042eb091cf71e7682a13389f7d2b56b7ae5176 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 11 Jul 2026 00:21:21 +0200 Subject: [PATCH] Cursor-HUD (VW-Stil) + weiches Winkel-Einrasten im Grundriss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/commands/types.ts | 1 + src/i18n/de.ts | 2 + src/i18n/en.ts | 2 + src/plan/PlanView.tsx | 142 ++++++++++++++++++++++++++++++++++-- src/styles.css | 37 ++++++++-- src/tools/snapping.test.ts | 144 ++++++++++++++++++++++++++++++++++++- src/tools/snapping.ts | 59 ++++++++++++++- src/tools/types.ts | 43 +++++++++-- src/ui/StatusBar.tsx | 23 ++++++ 9 files changed, 435 insertions(+), 18 deletions(-) diff --git a/src/commands/types.ts b/src/commands/types.ts index c68ee5c..7bb73c0 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -15,6 +15,7 @@ import type { DrawingLevel } from "../model/types"; import type { SnapResult, ToolDraft } from "../tools/types"; export type { DraftShape, SnapResult, ToolDraft } from "../tools/types"; +export { segmentHud, angleDegOf } from "../tools/types"; // ── Eingabe-Arten ──────────────────────────────────────────────────────────── diff --git a/src/i18n/de.ts b/src/i18n/de.ts index a22f5f7..db3c24a 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -165,6 +165,8 @@ export const de = { "snap.ortho": "Ortho (Shift)", "snap.gridSize": "Rasterweite (m)", "snap.angleStep": "Winkelraster", + "snap.commonAngles": "Gängige Winkel", + "snap.commonAngleTolerance": "Winkeltoleranz (°)", // ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ─────────────── "transform.bar": "Transformation", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 9df258d..ca5cf2c 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -164,6 +164,8 @@ export const en: Record = { "snap.ortho": "Ortho (Shift)", "snap.gridSize": "Grid size (m)", "snap.angleStep": "Angle step", + "snap.commonAngles": "Common angles", + "snap.commonAngleTolerance": "Angle tolerance (°)", // Transform (move/mirror/rotate) + mode bar. "transform.bar": "Transform", diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index 0cf702d..b853cc0 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -16,7 +16,7 @@ import { import type { HatchRender, Plan, Primitive } from "./generatePlan"; import { docToLines, type SvgLine } from "../text/renderHtml"; import type { EdgeGrip, Vec2 } from "../model/types"; -import type { DraftShape, SnapResult, ToolHandlers, ToolId, ToolMods } from "../tools/types"; +import type { SnapResult, ToolDraft, ToolHandlers, ToolId, ToolMods } from "../tools/types"; import { useGlPlanRenderer } from "./useGlPlanRenderer"; import { useWasmPlanRenderer } from "./useWasmPlanRenderer"; import { quantizePen } from "../export/sceneToPrintSvg"; @@ -2007,10 +2007,13 @@ export const PlanView = forwardRef( /** * Zeichnet die Werkzeug-Vorschau: Vorschau-Formen (gestrichelte Akzentlinien / - * halbtransparente Flächen), gesetzte Stützpunkte (kleine Quadrate) und den - * aktiven Snap-Marker. `vbPerPx` rechnet eine gewünschte Bildschirm-Pixelgröße in - * viewBox-Einheiten um (bildschirmkonstante Marker). Das frühere Maß-/Winkel-HUD - * am Cursor entfällt — die Werte zeigt jetzt ausschließlich die Befehlszeile. + * halbtransparente Flächen), gesetzte Stützpunkte (kleine Quadrate), den + * aktiven Snap-Marker, das Cursor-HUD (Länge/Winkel, VW-Stil) sowie — bei + * weichem Winkel-Snap (`snap.kind === "angle"`) — Winkel-Badge + verlängerte + * Führungslinie. `vbPerPx` rechnet eine gewünschte Bildschirm-Pixelgröße in + * viewBox-Einheiten um (bildschirmkonstante Marker/Schrift). Die Winkel-Info + * reitet auf `draft.snap` mit (refA/point/angleDeg) — kein eigenes Feld nötig, + * `computeSnap` liefert sie bereits vollständig. */ function DraftOverlay({ draft, @@ -2018,7 +2021,7 @@ function DraftOverlay({ vbPerPx, snapColor, }: { - draft: { preview: DraftShape[]; vertices: Vec2[]; snap?: SnapResult | null }; + draft: ToolDraft; toScreen: (v: Vec2) => Vec2; vbPerPx: number; snapColor: string; @@ -2063,10 +2066,137 @@ function DraftOverlay({ /> ); })} + {/* Weicher Winkel-Snap: verlängerte gestrichelte Führungslinie + Badge + UNTER dem Snap-Marker (der bleibt obenauf). */} + {draft.snap?.kind === "angle" && draft.snap.refA && draft.snap.angleDeg != null && ( + + )} {/* Snap-Marker + optionale Ortho-Hilfslinie. */} {draft.snap && ( )} + {/* Maß-/Winkel-HUD am Cursor (VW-Stil `L: … W: …`, oder einfacher Text). */} + {draft.hud && } + + ); +} + +/** + * Cursor-HUD im Vectorworks-Stil: weisser Kasten, blauer Rahmen, blauer + * Monospace-Text. Zeigt `L:`/`W:` (Länge 3 Nachkommastellen + „m", Winkel 3 + * Nachkommastellen + „°"), wenn beide vorhanden sind — sonst den freien + * `text` (Radius/Fläche/… bei Befehlen ohne Länge-Winkel-Paar). Leicht + * versetzt rechts-unten vom Cursorpunkt, bildschirmkonstante Größe. + */ +function DraftHud({ + hud, + toScreen, + vbPerPx, +}: { + hud: NonNullable; + toScreen: (v: Vec2) => Vec2; + vbPerPx: number; +}) { + const text = + hud.length != null && hud.angleDeg != null + ? `L: ${hud.length.toFixed(3)}m W: ${hud.angleDeg.toFixed(3)}°` + : hud.text; + if (!text) return null; + const p = toScreen(hud.at); + const fontSize = 12 * vbPerPx; + const padX = 6 * vbPerPx; + const padY = 4 * vbPerPx; + const charW = fontSize * 0.62; // Monospace-Schätzung für die Kastenbreite + const w = text.length * charW + padX * 2; + const h = fontSize + padY * 2; + const x = p.x + 14 * vbPerPx; + const y = p.y + 14 * vbPerPx; + return ( + + + + {text} + + + ); +} + +/** + * Weicher Winkel-Snap (§snapCommonAngle): gestrichelte Führungslinie vom + * letzten Punkt über den Cursor hinaus verlängert (dezent, wie in VW) + ein + * gelbliches Winkel-Badge nahe der Liniemitte. + */ +function AngleGuide({ + from, + point, + angleDeg, + toScreen, + vbPerPx, +}: { + from: Vec2; + point: Vec2; + angleDeg: number; + toScreen: (v: Vec2) => Vec2; + vbPerPx: number; +}) { + const a = toScreen(from); + const b = toScreen(point); + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.hypot(dx, dy); + const dir = len > 1e-6 ? { x: dx / len, y: dy / len } : { x: 1, y: 0 }; + const extend = 40 * vbPerPx; // Bildschirm-px, die die Führungslinie über den Cursor hinausragt + const end = { x: b.x + dir.x * extend, y: b.y + dir.y * extend }; + const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + // Senkrecht zur Linie versetzt, damit das Badge die Führungslinie nicht verdeckt. + const perp = { x: -dir.y, y: dir.x }; + const badgeAt = { x: mid.x + perp.x * 10 * vbPerPx, y: mid.y + perp.y * 10 * vbPerPx }; + const fontSize = 11 * vbPerPx; + const padX = 5 * vbPerPx; + const padY = 3 * vbPerPx; + const text = `${angleDeg.toFixed(3)}°`; + const w = text.length * fontSize * 0.62 + padX * 2; + const h = fontSize + padY * 2; + return ( + + + + + {text} + ); } diff --git a/src/styles.css b/src/styles.css index df17662..b499c6f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1825,13 +1825,38 @@ body { stroke-dasharray: 4 4; opacity: 0.8; } -.plan-svg .tool-hud { - fill: var(--accent-light); - paint-order: stroke; - stroke: rgba(0, 0, 0, 0.55); - stroke-width: 3px; +/* Cursor-HUD (Vectorworks-Stil): weisser Kasten, blauer Rahmen, blauer + Monospace-Text „L: 3.118m W: 60.000°". */ +.plan-svg .plan-hud-box { + fill: #ffffff; + fill-opacity: 0.92; + stroke: #2864ff; + stroke-width: 1.25; +} +.plan-svg .plan-hud-text { + fill: #2864ff; font-family: var(--mono, ui-monospace, monospace); - dominant-baseline: middle; + font-weight: 600; +} + +/* Weicher Winkel-Snap: gestrichelte Führungslinie (dezent) + gelbliches + Winkel-Badge nahe der Liniemitte. */ +.plan-svg .plan-angle-guide-line { + stroke: #b06a6a; + stroke-width: 1; + stroke-dasharray: 5 4; + opacity: 0.75; +} +.plan-svg .plan-angle-badge-box { + fill: #fff3c4; + fill-opacity: 0.95; + stroke: #6b5b1e; + stroke-width: 1; +} +.plan-svg .plan-angle-badge-text { + fill: #4a3f10; + font-family: var(--mono, ui-monospace, monospace); + font-weight: 600; } /* ── Modusleiste der Transformation (über der Mitte) ─────────────────────── */ diff --git a/src/tools/snapping.test.ts b/src/tools/snapping.test.ts index 469a0ef..2496524 100644 --- a/src/tools/snapping.test.ts +++ b/src/tools/snapping.test.ts @@ -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, + }; +} diff --git a/src/tools/snapping.ts b/src/tools/snapping.ts index 4bbbe9a..94d0f64 100644 --- a/src/tools/snapping.ts +++ b/src/tools/snapping.ts @@ -27,6 +27,7 @@ const PRIORITY: Record = { 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 }; +} diff --git a/src/tools/types.ts b/src/tools/types.ts index 2702355..78f220a 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -33,16 +33,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 { @@ -61,6 +64,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). */ @@ -76,6 +83,8 @@ export const DEFAULT_SNAP: SnapSettings = { ortho: false, angleStep: 90, tolerancePx: 12, + commonAngles: true, + commonAngleTolerance: 2.5, }; // ── Werkzeug-Schnittstelle ────────────────────────────────────────────────── @@ -125,8 +134,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 }; } /** Was ein Werkzeug-Schritt nach außen meldet. */ @@ -193,6 +208,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; diff --git a/src/ui/StatusBar.tsx b/src/ui/StatusBar.tsx index 0d78368..7a74031 100644 --- a/src/ui/StatusBar.tsx +++ b/src/ui/StatusBar.tsx @@ -222,6 +222,29 @@ function SnapControl({ } /> + + )}