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 9133c0961d
commit 3f042eb091
9 changed files with 435 additions and 18 deletions
+1
View File
@@ -15,6 +15,7 @@ import type { DrawingLevel } from "../model/types";
import type { SnapResult, ToolDraft } from "../tools/types"; import type { SnapResult, ToolDraft } from "../tools/types";
export type { DraftShape, SnapResult, ToolDraft } from "../tools/types"; export type { DraftShape, SnapResult, ToolDraft } from "../tools/types";
export { segmentHud, angleDegOf } from "../tools/types";
// ── Eingabe-Arten ──────────────────────────────────────────────────────────── // ── Eingabe-Arten ────────────────────────────────────────────────────────────
+2
View File
@@ -165,6 +165,8 @@ export const de = {
"snap.ortho": "Ortho (Shift)", "snap.ortho": "Ortho (Shift)",
"snap.gridSize": "Rasterweite (m)", "snap.gridSize": "Rasterweite (m)",
"snap.angleStep": "Winkelraster", "snap.angleStep": "Winkelraster",
"snap.commonAngles": "Gängige Winkel",
"snap.commonAngleTolerance": "Winkeltoleranz (°)",
// ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ─────────────── // ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ───────────────
"transform.bar": "Transformation", "transform.bar": "Transformation",
+2
View File
@@ -164,6 +164,8 @@ export const en: Record<TranslationKey, string> = {
"snap.ortho": "Ortho (Shift)", "snap.ortho": "Ortho (Shift)",
"snap.gridSize": "Grid size (m)", "snap.gridSize": "Grid size (m)",
"snap.angleStep": "Angle step", "snap.angleStep": "Angle step",
"snap.commonAngles": "Common angles",
"snap.commonAngleTolerance": "Angle tolerance (°)",
// Transform (move/mirror/rotate) + mode bar. // Transform (move/mirror/rotate) + mode bar.
"transform.bar": "Transform", "transform.bar": "Transform",
+136 -6
View File
@@ -16,7 +16,7 @@ import {
import type { HatchRender, Plan, Primitive } from "./generatePlan"; import type { HatchRender, Plan, Primitive } from "./generatePlan";
import { docToLines, type SvgLine } from "../text/renderHtml"; import { docToLines, type SvgLine } from "../text/renderHtml";
import type { EdgeGrip, Vec2 } from "../model/types"; 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 { useGlPlanRenderer } from "./useGlPlanRenderer";
import { useWasmPlanRenderer } from "./useWasmPlanRenderer"; import { useWasmPlanRenderer } from "./useWasmPlanRenderer";
import { quantizePen } from "../export/sceneToPrintSvg"; import { quantizePen } from "../export/sceneToPrintSvg";
@@ -2007,10 +2007,13 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
/** /**
* Zeichnet die Werkzeug-Vorschau: Vorschau-Formen (gestrichelte Akzentlinien / * Zeichnet die Werkzeug-Vorschau: Vorschau-Formen (gestrichelte Akzentlinien /
* halbtransparente Flächen), gesetzte Stützpunkte (kleine Quadrate) und den * halbtransparente Flächen), gesetzte Stützpunkte (kleine Quadrate), den
* aktiven Snap-Marker. `vbPerPx` rechnet eine gewünschte Bildschirm-Pixelgröße in * aktiven Snap-Marker, das Cursor-HUD (Länge/Winkel, VW-Stil) sowie — bei
* viewBox-Einheiten um (bildschirmkonstante Marker). Das frühere Maß-/Winkel-HUD * weichem Winkel-Snap (`snap.kind === "angle"`) — Winkel-Badge + verlängerte
* am Cursor entfällt — die Werte zeigt jetzt ausschließlich die Befehlszeile. * 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({ function DraftOverlay({
draft, draft,
@@ -2018,7 +2021,7 @@ function DraftOverlay({
vbPerPx, vbPerPx,
snapColor, snapColor,
}: { }: {
draft: { preview: DraftShape[]; vertices: Vec2[]; snap?: SnapResult | null }; draft: ToolDraft;
toScreen: (v: Vec2) => Vec2; toScreen: (v: Vec2) => Vec2;
vbPerPx: number; vbPerPx: number;
snapColor: string; 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 && (
<AngleGuide
from={draft.snap.refA}
point={draft.snap.point}
angleDeg={draft.snap.angleDeg}
toScreen={toScreen}
vbPerPx={vbPerPx}
/>
)}
{/* Snap-Marker + optionale Ortho-Hilfslinie. */} {/* Snap-Marker + optionale Ortho-Hilfslinie. */}
{draft.snap && ( {draft.snap && (
<SnapMarker snap={draft.snap} toScreen={toScreen} size={markSize} color={snapColor} /> <SnapMarker snap={draft.snap} toScreen={toScreen} size={markSize} color={snapColor} />
)} )}
{/* Maß-/Winkel-HUD am Cursor (VW-Stil `L: … W: …`, oder einfacher Text). */}
{draft.hud && <DraftHud hud={draft.hud} toScreen={toScreen} vbPerPx={vbPerPx} />}
</g>
);
}
/**
* 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<ToolDraft["hud"]>;
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 (
<g className="plan-hud" pointerEvents="none">
<rect className="plan-hud-box" x={x} y={y} width={w} height={h} rx={2 * vbPerPx} />
<text
className="plan-hud-text"
x={x + padX}
y={y + h / 2}
fontSize={fontSize}
dominantBaseline="middle"
>
{text}
</text>
</g>
);
}
/**
* 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 (
<g className="plan-angle-guide" pointerEvents="none">
<line
className="plan-angle-guide-line"
x1={a.x}
y1={a.y}
x2={end.x}
y2={end.y}
vectorEffect="non-scaling-stroke"
/>
<rect
className="plan-angle-badge-box"
x={badgeAt.x - w / 2}
y={badgeAt.y - h / 2}
width={w}
height={h}
rx={2 * vbPerPx}
/>
<text
className="plan-angle-badge-text"
x={badgeAt.x}
y={badgeAt.y}
fontSize={fontSize}
textAnchor="middle"
dominantBaseline="middle"
>
{text}
</text>
</g> </g>
); );
} }
+31 -6
View File
@@ -1825,13 +1825,38 @@ body {
stroke-dasharray: 4 4; stroke-dasharray: 4 4;
opacity: 0.8; opacity: 0.8;
} }
.plan-svg .tool-hud { /* Cursor-HUD (Vectorworks-Stil): weisser Kasten, blauer Rahmen, blauer
fill: var(--accent-light); Monospace-Text L: 3.118m W: 60.000°". */
paint-order: stroke; .plan-svg .plan-hud-box {
stroke: rgba(0, 0, 0, 0.55); fill: #ffffff;
stroke-width: 3px; fill-opacity: 0.92;
stroke: #2864ff;
stroke-width: 1.25;
}
.plan-svg .plan-hud-text {
fill: #2864ff;
font-family: var(--mono, ui-monospace, monospace); 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) ─────────────────────── */ /* ── Modusleiste der Transformation (über der Mitte) ─────────────────────── */
+143 -1
View File
@@ -10,7 +10,7 @@
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { computeSnap, wallLayerBoundarySegments } from "./snapping"; import { computeSnap, snapCommonAngle, wallLayerBoundarySegments } from "./snapping";
import { DEFAULT_SNAP } from "./types"; import { DEFAULT_SNAP } from "./types";
import type { Drawing2D, Project, Vec2, Wall } from "../model/types"; import type { Drawing2D, Project, Vec2, Wall } from "../model/types";
@@ -226,3 +226,145 @@ describe("computeSnap — Kreis/Bogen", () => {
expect(r?.kind).not.toBe("quadrant"); 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
View File
@@ -27,6 +27,7 @@ const PRIORITY: Record<SnapKind, number> = {
grid: -2, grid: -2,
ortho: 0, ortho: 0,
extension: 0, extension: 0,
angle: 0,
}; };
export interface SnapInput { export interface SnapInput {
@@ -236,12 +237,29 @@ export function computeSnap(input: SnapInput): SnapResult | null {
if (best) return best; 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) { if ((settings.ortho || shift) && input.lastPoint) {
const point = applyAngleConstraint(input.lastPoint, raw, settings.angleStep); const point = applyAngleConstraint(input.lastPoint, raw, settings.angleStep);
return { point, kind: "ortho", refA: input.lastPoint, distPx: 0 }; 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. // Raster.
if (settings.grid) { if (settings.grid) {
const g = snapToGrid(raw, settings.gridSize); const g = snapToGrid(raw, settings.gridSize);
@@ -269,3 +287,42 @@ function snapToGrid(p: Vec2, size: number): Vec2 {
if (!(size > 0)) return p; if (!(size > 0)) return p;
return { x: Math.round(p.x / size) * size, y: Math.round(p.y / size) * size }; 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
View File
@@ -33,16 +33,19 @@ export type SnapKind =
| "onEdge" | "onEdge"
| "grid" | "grid"
| "ortho" | "ortho"
| "extension"; | "extension"
| "angle";
export interface SnapResult { export interface SnapResult {
/** Gefangener Punkt (Meter). */ /** Gefangener Punkt (Meter). */
point: Vec2; point: Vec2;
kind: SnapKind; kind: SnapKind;
/** Quell-Bezugspunkt (für Hilfslinien bei ortho/extension), optional. */ /** Quell-Bezugspunkt (für Hilfslinien bei ortho/extension/angle), optional. */
refA?: Vec2; refA?: Vec2;
/** Bildschirm-Distanz Cursor→Snap (px) — für die Auswahl des Besten. */ /** Bildschirm-Distanz Cursor→Snap (px) — für die Auswahl des Besten. */
distPx: number; distPx: number;
/** Eingerasteter Winkel in Grad, Bereich (180,180] — nur bei kind "angle". */
angleDeg?: number;
} }
export interface SnapSettings { export interface SnapSettings {
@@ -61,6 +64,10 @@ export interface SnapSettings {
angleStep: number; angleStep: number;
/** Fangradius am Bildschirm in Pixeln. */ /** Fangradius am Bildschirm in Pixeln. */
tolerancePx: number; 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). */ /** Default-Snap-Einstellungen (endpoint/midpoint/intersection/onEdge + grid). */
@@ -76,6 +83,8 @@ export const DEFAULT_SNAP: SnapSettings = {
ortho: false, ortho: false,
angleStep: 90, angleStep: 90,
tolerancePx: 12, tolerancePx: 12,
commonAngles: true,
commonAngleTolerance: 2.5,
}; };
// ── Werkzeug-Schnittstelle ────────────────────────────────────────────────── // ── Werkzeug-Schnittstelle ──────────────────────────────────────────────────
@@ -125,8 +134,14 @@ export interface ToolDraft {
vertices: Vec2[]; vertices: Vec2[];
/** Aktiver Snap (für den Bildschirm-Marker), optional. */ /** Aktiver Snap (für den Bildschirm-Marker), optional. */
snap?: SnapResult | null; 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. */ /** Was ein Werkzeug-Schritt nach außen meldet. */
@@ -193,6 +208,26 @@ export interface ToolHandlers {
draft: ToolDraft | null; 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 ─────────────────────────────────────────────────────────────── // ── ID-Vergabe ───────────────────────────────────────────────────────────────
let idCounter = 0; let idCounter = 0;
+23
View File
@@ -222,6 +222,29 @@ function SnapControl({
} }
/> />
</label> </label>
<label className="sb-snap-row">
<input
type="checkbox"
checked={snap.commonAngles}
disabled={!snap.enabled}
onChange={(e) => onSnapChange({ ...snap, commonAngles: e.target.checked })}
/>
<span>{t("snap.commonAngles")}</span>
</label>
<label className="sb-snap-row sb-snap-row-num">
<span>{t("snap.commonAngleTolerance")}</span>
<input
type="number"
step={0.5}
min={0.5}
max={10}
disabled={!snap.enabled || !snap.commonAngles}
value={snap.commonAngleTolerance}
onChange={(e) =>
onSnapChange({ ...snap, commonAngleTolerance: Number(e.target.value) })
}
/>
</label>
</div> </div>
)} )}
</span> </span>