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:
2026-07-11 00:25:49 +02:00
17 changed files with 629 additions and 68 deletions
+3 -3
View File
@@ -73,14 +73,14 @@ function circlePts(center: Vec2, r: number): Vec2[] {
return pts;
}
/** Vorschau (offener Bogen) + HUD (Spanne in Grad). */
/** Vorschau (offener Bogen) + HUD (Radius als L, Spanne als W — L/W-Kästchen). */
function arcDraft(center: Vec2, r: number, a0: number, a1: number, at: Vec2 | null): ToolDraft {
if (r < EPS) return { preview: [], vertices: [center] };
const preview: DraftShape[] = [{ kind: "poly", pts: arcPts(center, r, a0, a1), closed: false }];
const draft: ToolDraft = { preview, vertices: [center] };
if (at) {
const deg = (sweepOf(a0, a1) * 180) / Math.PI;
draft.hud = { at, text: `${deg.toFixed(0)}°` };
draft.hud = { at, length: r, angleDeg: deg };
}
return draft;
}
@@ -148,7 +148,7 @@ export const arcCommand: Command = {
draft: {
preview: [{ kind: "poly", pts: circlePts(s.center, r), closed: true }],
vertices: [s.center],
hud: { at: point, text: `r ${r.toFixed(2)} m` },
hud: { at: point, text: `R: ${r.toFixed(3)}m` },
},
},
];
+1 -1
View File
@@ -68,7 +68,7 @@ function circleDraft(center: Vec2, r: number, at: Vec2 | null): ToolDraft {
if (r < EPS) return { preview: [], vertices: [center] };
const preview: DraftShape[] = [{ kind: "poly", pts: circlePts(center, r), closed: true }];
const draft: ToolDraft = { preview, vertices: [center] };
if (at) draft.hud = { at, text: `r ${r.toFixed(2)} m` };
if (at) draft.hud = { at, text: `R: ${r.toFixed(3)}m` };
return draft;
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Unit-Tests für das Cursor-HUD (Länge/Winkel) des `line`-Befehls: nach dem
* Startpunkt muss `onMove` im Draft `hud.length`/`hud.angleDeg` mit den
* korrekten Werten liefern (VW-Stil L/W-Kästchen, PlanView rendert es).
*/
import { describe, it, expect } from "vitest";
import { lineCommand } from "./line";
import type { CommandContext, Project, Vec2 } from "../types";
/** Minimalprojekt — nur die Felder, die der Befehl/Context anfassen. */
function project(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [
{
id: "eg",
name: "EG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 0,
},
],
layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
function makeCtx(lastPoint: Vec2 | null): CommandContext {
const p = project();
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint,
selection: { wallIds: [], drawingId: null },
};
}
describe("lineCommand — Cursor-HUD (Länge/Winkel)", () => {
it("liefert hud.length/angleDeg im onMove nach dem Startpunkt", () => {
const start: Vec2 = { x: 0, y: 0 };
const [afterStart] = lineCommand.onInput(
lineCommand.init(),
{ kind: "point", point: start },
makeCtx(null),
);
const [, res] = lineCommand.onMove(afterStart, { x: 3, y: 4 }, null, makeCtx(start));
expect(res.draft?.hud?.length).toBeCloseTo(5, 9);
expect(res.draft?.hud?.angleDeg).toBeCloseTo((Math.atan2(4, 3) * 180) / Math.PI, 9);
});
it("liefert einen negativen Winkel (Bereich (180,180]) für Segmente nach unten-links", () => {
const start: Vec2 = { x: 0, y: 0 };
const [afterStart] = lineCommand.onInput(
lineCommand.init(),
{ kind: "point", point: start },
makeCtx(null),
);
// Richtung (-1,-1) → -135° (wie im VW-Screenshot).
const [, res] = lineCommand.onMove(afterStart, { x: -2, y: -2 }, null, makeCtx(start));
expect(res.draft?.hud?.angleDeg).toBeCloseTo(-135, 9);
expect(res.draft?.hud?.length).toBeCloseTo(Math.hypot(2, 2), 9);
});
it("zeigt KEIN HUD vor dem Startpunkt (phase=start)", () => {
const [, res] = lineCommand.onMove(lineCommand.init(), { x: 1, y: 1 }, null, makeCtx(null));
expect(res.draft).toBeNull();
});
});
+2 -9
View File
@@ -10,7 +10,7 @@
// Tool. Live-Vorschau + HUD (Länge/Winkel) wie die bestehenden Tools.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { segmentHud, uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
@@ -61,14 +61,7 @@ function lineDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
const preview: DraftShape[] = [];
if (cursor && segLen(a, cursor) >= EPS) preview.push({ kind: "line", a, b: cursor });
const draft: ToolDraft = { preview, vertices: [a] };
if (cursor && segLen(a, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(a, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
if (cursor && segLen(a, cursor) >= EPS) draft.hud = segmentHud(a, cursor);
return draft;
}
+2 -9
View File
@@ -6,7 +6,7 @@
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { segmentHud, uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
@@ -74,14 +74,7 @@ function polyDraft(points: Vec2[], cursor: Vec2 | null, closed = false): ToolDra
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(last, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
if (cursor && last && segLen(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor);
return draft;
}
+10 -19
View File
@@ -17,7 +17,7 @@
// werden (die Engine löst `r…` relativ zu lastPoint auf).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { segmentHud, uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
@@ -196,51 +196,42 @@ function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
// ── Vorschau ─────────────────────────────────────────────────────────────────
function corners4Draft(pts: Vec2[], hudAt: Vec2 | null, hudText: string): ToolDraft {
function corners4Draft(pts: Vec2[], hud: ToolDraft["hud"] | null): ToolDraft {
const preview: DraftShape[] = [{ kind: "poly", pts, closed: true }];
const draft: ToolDraft = { preview, vertices: [pts[0]] };
if (hudAt) draft.hud = { at: hudAt, text: hudText };
if (hud) draft.hud = hud;
return draft;
}
/** Vorschau des 2-Punkt-Rechtecks (achsparallel). */
/** Vorschau des 2-Punkt-Rechtecks (achsparallel) — HUD zeigt die Diagonale a→cursor. */
function cornerDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [a] };
const g = rectGeom(a, cursor);
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
const w = g.max.x - g.min.x;
const h = g.max.y - g.min.y;
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
return corners4Draft(pts, segLen(a, cursor) >= EPS ? segmentHud(a, cursor) : null);
}
/** Vorschau des Zentrum-Rechtecks (achsparallel). */
/** Vorschau des Zentrum-Rechtecks (achsparallel) — HUD zeigt die Diagonale center→cursor. */
function centerDraft(center: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [center] };
const g = centerGeom(center, cursor);
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
const w = g.max.x - g.min.x;
const h = g.max.y - g.min.y;
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
return corners4Draft(pts, segLen(center, cursor) >= EPS ? segmentHud(center, cursor) : null);
}
/** Vorschau der Basiskante (3-Punkt, erster Teil). */
function baseDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor || segLen(a, cursor) < EPS) return { preview: [{ kind: "line", a, b: a }], vertices: [a] };
const draft: ToolDraft = { preview: [{ kind: "line", a, b: cursor }], vertices: [a] };
draft.hud = {
at: cursor,
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs((segAngleDeg(a, cursor) + 360) % 360).toFixed(0)}°`,
};
return draft;
return { preview: [{ kind: "line", a, b: cursor }], vertices: [a], hud: segmentHud(a, cursor) };
}
/** Vorschau des gedrehten Rechtecks (3-Punkt, zweiter Teil). */
/** Vorschau des gedrehten Rechtecks (3-Punkt, zweiter Teil) — HUD: Basisbreite + Höhe am Cursor. */
function riseDraft(a: Vec2, b: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [{ kind: "line", a, b }], vertices: [a] };
const pts = rotatedCorners(a, b, cursor);
const w = segLen(a, b);
const h = Math.abs(riseFrom(a, b, cursor));
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
return corners4Draft(pts, { at: cursor, text: `B: ${w.toFixed(3)}m H: ${h.toFixed(3)}m` });
}
// ── Commit ───────────────────────────────────────────────────────────────────
+87
View File
@@ -0,0 +1,87 @@
/**
* Unit-Tests für das Cursor-HUD (Länge/Winkel) des `wall`-Befehls: nach dem
* Startpunkt muss `onMove` im Draft `hud.length`/`hud.angleDeg` relativ zum
* ZULETZT gesetzten Achspunkt liefern — auch nach mehreren Segmenten
* (Chaining), damit ein mehrteiliger Wandzug je Segment ein frisches HUD zeigt.
*/
import { describe, it, expect } from "vitest";
import { wallCommand } from "./wall";
import type { CommandContext, Project, Vec2 } from "../types";
function project(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
wallTypes: [
{ id: "aw", name: "AW", layers: [{ componentId: "c", thickness: 0.2 }] },
],
drawingLevels: [
{
id: "eg",
name: "EG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 0,
},
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
};
}
function makeCtx(lastPoint: Vec2 | null): CommandContext {
const p = project();
return {
project: p,
level: p.drawingLevels[0],
defaultCategoryCode: "20",
activeLineStyleId: "solid",
activeWallTypeId: "aw",
lastPoint,
selection: { wallIds: [], drawingId: null },
};
}
describe("wallCommand — Cursor-HUD (Länge/Winkel)", () => {
it("liefert hud.length/angleDeg relativ zum Startpunkt", () => {
const start: Vec2 = { x: 0, y: 0 };
const [afterStart] = wallCommand.onInput(
wallCommand.init(),
{ kind: "point", point: start },
makeCtx(null),
);
const [, res] = wallCommand.onMove(afterStart, { x: 4, y: 0 }, null, makeCtx(start));
expect(res.draft?.hud?.length).toBeCloseTo(4, 9);
expect(res.draft?.hud?.angleDeg).toBeCloseTo(0, 9);
});
it("HUD bezieht sich nach dem zweiten Punkt auf das NEUE (letzte) Segment", () => {
const p0: Vec2 = { x: 0, y: 0 };
const p1: Vec2 = { x: 4, y: 0 };
const [s1] = wallCommand.onInput(wallCommand.init(), { kind: "point", point: p0 }, makeCtx(null));
const [s2] = wallCommand.onInput(s1, { kind: "point", point: p1 }, makeCtx(p0));
// Nächstes Segment ab p1 nach oben (0,3) → Länge 3, Winkel 90°.
const [, res] = wallCommand.onMove(s2, { x: 4, y: 3 }, null, makeCtx(p1));
expect(res.draft?.hud?.length).toBeCloseTo(3, 9);
expect(res.draft?.hud?.angleDeg).toBeCloseTo(90, 9);
});
it("zeigt KEIN HUD vor dem Startpunkt (phase=start)", () => {
const [, res] = wallCommand.onMove(wallCommand.init(), { x: 1, y: 1 }, null, makeCtx(null));
expect(res.draft).toBeNull();
});
});
+2 -9
View File
@@ -18,7 +18,7 @@
import type { Wall, WallType } from "../../model/types";
import { wallTypeThickness } from "../../model/types";
import { wallCorners } from "../../model/geometry";
import { uniqueId } from "../../tools/types";
import { segmentHud, uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
@@ -164,14 +164,7 @@ function wallDraft(points: Vec2[], cursor: Vec2 | null, thickness: number): Tool
}
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(last, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
if (cursor && last && segLen(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor);
return draft;
}
+1
View File
@@ -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 ────────────────────────────────────────────────────────────