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:
@@ -73,14 +73,14 @@ function circlePts(center: Vec2, r: number): Vec2[] {
|
|||||||
return pts;
|
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 {
|
function arcDraft(center: Vec2, r: number, a0: number, a1: number, at: Vec2 | null): ToolDraft {
|
||||||
if (r < EPS) return { preview: [], vertices: [center] };
|
if (r < EPS) return { preview: [], vertices: [center] };
|
||||||
const preview: DraftShape[] = [{ kind: "poly", pts: arcPts(center, r, a0, a1), closed: false }];
|
const preview: DraftShape[] = [{ kind: "poly", pts: arcPts(center, r, a0, a1), closed: false }];
|
||||||
const draft: ToolDraft = { preview, vertices: [center] };
|
const draft: ToolDraft = { preview, vertices: [center] };
|
||||||
if (at) {
|
if (at) {
|
||||||
const deg = (sweepOf(a0, a1) * 180) / Math.PI;
|
const deg = (sweepOf(a0, a1) * 180) / Math.PI;
|
||||||
draft.hud = { at, text: `${deg.toFixed(0)}°` };
|
draft.hud = { at, length: r, angleDeg: deg };
|
||||||
}
|
}
|
||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ export const arcCommand: Command = {
|
|||||||
draft: {
|
draft: {
|
||||||
preview: [{ kind: "poly", pts: circlePts(s.center, r), closed: true }],
|
preview: [{ kind: "poly", pts: circlePts(s.center, r), closed: true }],
|
||||||
vertices: [s.center],
|
vertices: [s.center],
|
||||||
hud: { at: point, text: `r ${r.toFixed(2)} m` },
|
hud: { at: point, text: `R: ${r.toFixed(3)}m` },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function circleDraft(center: Vec2, r: number, at: Vec2 | null): ToolDraft {
|
|||||||
if (r < EPS) return { preview: [], vertices: [center] };
|
if (r < EPS) return { preview: [], vertices: [center] };
|
||||||
const preview: DraftShape[] = [{ kind: "poly", pts: circlePts(center, r), closed: true }];
|
const preview: DraftShape[] = [{ kind: "poly", pts: circlePts(center, r), closed: true }];
|
||||||
const draft: ToolDraft = { preview, vertices: [center] };
|
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;
|
return draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
// Tool. Live-Vorschau + HUD (Länge/Winkel) wie die bestehenden Tools.
|
// Tool. Live-Vorschau + HUD (Länge/Winkel) wie die bestehenden Tools.
|
||||||
|
|
||||||
import type { Drawing2D } from "../../model/types";
|
import type { Drawing2D } from "../../model/types";
|
||||||
import { uniqueId } from "../../tools/types";
|
import { segmentHud, uniqueId } from "../../tools/types";
|
||||||
import type {
|
import type {
|
||||||
Command,
|
Command,
|
||||||
CommandContext,
|
CommandContext,
|
||||||
@@ -61,14 +61,7 @@ function lineDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
|||||||
const preview: DraftShape[] = [];
|
const preview: DraftShape[] = [];
|
||||||
if (cursor && segLen(a, cursor) >= EPS) preview.push({ kind: "line", a, b: cursor });
|
if (cursor && segLen(a, cursor) >= EPS) preview.push({ kind: "line", a, b: cursor });
|
||||||
const draft: ToolDraft = { preview, vertices: [a] };
|
const draft: ToolDraft = { preview, vertices: [a] };
|
||||||
if (cursor && segLen(a, cursor) >= EPS) {
|
if (cursor && segLen(a, cursor) >= EPS) draft.hud = segmentHud(a, cursor);
|
||||||
draft.hud = {
|
|
||||||
at: cursor,
|
|
||||||
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs(
|
|
||||||
(segAngleDeg(a, cursor) + 360) % 360,
|
|
||||||
).toFixed(0)}°`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45).
|
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45).
|
||||||
|
|
||||||
import type { Drawing2D } from "../../model/types";
|
import type { Drawing2D } from "../../model/types";
|
||||||
import { uniqueId } from "../../tools/types";
|
import { segmentHud, uniqueId } from "../../tools/types";
|
||||||
import type {
|
import type {
|
||||||
Command,
|
Command,
|
||||||
CommandContext,
|
CommandContext,
|
||||||
@@ -74,14 +74,7 @@ function polyDraft(points: Vec2[], cursor: Vec2 | null, closed = false): ToolDra
|
|||||||
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
|
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
|
||||||
const draft: ToolDraft = { preview, vertices: points };
|
const draft: ToolDraft = { preview, vertices: points };
|
||||||
const last = points[points.length - 1];
|
const last = points[points.length - 1];
|
||||||
if (cursor && last && segLen(last, cursor) >= EPS) {
|
if (cursor && last && segLen(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor);
|
||||||
draft.hud = {
|
|
||||||
at: cursor,
|
|
||||||
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
|
|
||||||
(segAngleDeg(last, cursor) + 360) % 360,
|
|
||||||
).toFixed(0)}°`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-19
@@ -17,7 +17,7 @@
|
|||||||
// werden (die Engine löst `r…` relativ zu lastPoint auf).
|
// werden (die Engine löst `r…` relativ zu lastPoint auf).
|
||||||
|
|
||||||
import type { Drawing2D } from "../../model/types";
|
import type { Drawing2D } from "../../model/types";
|
||||||
import { uniqueId } from "../../tools/types";
|
import { segmentHud, uniqueId } from "../../tools/types";
|
||||||
import type {
|
import type {
|
||||||
Command,
|
Command,
|
||||||
CommandContext,
|
CommandContext,
|
||||||
@@ -196,51 +196,42 @@ function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
|
|||||||
|
|
||||||
// ── Vorschau ─────────────────────────────────────────────────────────────────
|
// ── 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 preview: DraftShape[] = [{ kind: "poly", pts, closed: true }];
|
||||||
const draft: ToolDraft = { preview, vertices: [pts[0]] };
|
const draft: ToolDraft = { preview, vertices: [pts[0]] };
|
||||||
if (hudAt) draft.hud = { at: hudAt, text: hudText };
|
if (hud) draft.hud = hud;
|
||||||
return draft;
|
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 {
|
function cornerDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||||
if (!cursor) return { preview: [], vertices: [a] };
|
if (!cursor) return { preview: [], vertices: [a] };
|
||||||
const g = rectGeom(a, cursor);
|
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 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;
|
return corners4Draft(pts, segLen(a, cursor) >= EPS ? segmentHud(a, cursor) : null);
|
||||||
const h = g.max.y - g.min.y;
|
|
||||||
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vorschau des Zentrum-Rechtecks (achsparallel). */
|
/** Vorschau des Zentrum-Rechtecks (achsparallel) — HUD zeigt die Diagonale center→cursor. */
|
||||||
function centerDraft(center: Vec2, cursor: Vec2 | null): ToolDraft {
|
function centerDraft(center: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||||
if (!cursor) return { preview: [], vertices: [center] };
|
if (!cursor) return { preview: [], vertices: [center] };
|
||||||
const g = centerGeom(center, cursor);
|
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 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;
|
return corners4Draft(pts, segLen(center, cursor) >= EPS ? segmentHud(center, cursor) : null);
|
||||||
const h = g.max.y - g.min.y;
|
|
||||||
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vorschau der Basiskante (3-Punkt, erster Teil). */
|
/** Vorschau der Basiskante (3-Punkt, erster Teil). */
|
||||||
function baseDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
function baseDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||||
if (!cursor || segLen(a, cursor) < EPS) return { preview: [{ kind: "line", a, b: a }], vertices: [a] };
|
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] };
|
return { preview: [{ kind: "line", a, b: cursor }], vertices: [a], hud: segmentHud(a, cursor) };
|
||||||
draft.hud = {
|
|
||||||
at: cursor,
|
|
||||||
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs((segAngleDeg(a, cursor) + 360) % 360).toFixed(0)}°`,
|
|
||||||
};
|
|
||||||
return draft;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 {
|
function riseDraft(a: Vec2, b: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||||
if (!cursor) return { preview: [{ kind: "line", a, b }], vertices: [a] };
|
if (!cursor) return { preview: [{ kind: "line", a, b }], vertices: [a] };
|
||||||
const pts = rotatedCorners(a, b, cursor);
|
const pts = rotatedCorners(a, b, cursor);
|
||||||
const w = segLen(a, b);
|
const w = segLen(a, b);
|
||||||
const h = Math.abs(riseFrom(a, b, cursor));
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Commit ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
import type { Wall, WallType } from "../../model/types";
|
import type { Wall, WallType } from "../../model/types";
|
||||||
import { wallTypeThickness } from "../../model/types";
|
import { wallTypeThickness } from "../../model/types";
|
||||||
import { wallCorners } from "../../model/geometry";
|
import { wallCorners } from "../../model/geometry";
|
||||||
import { uniqueId } from "../../tools/types";
|
import { segmentHud, uniqueId } from "../../tools/types";
|
||||||
import type {
|
import type {
|
||||||
Command,
|
Command,
|
||||||
CommandContext,
|
CommandContext,
|
||||||
@@ -164,14 +164,7 @@ function wallDraft(points: Vec2[], cursor: Vec2 | null, thickness: number): Tool
|
|||||||
}
|
}
|
||||||
const draft: ToolDraft = { preview, vertices: points };
|
const draft: ToolDraft = { preview, vertices: points };
|
||||||
const last = points[points.length - 1];
|
const last = points[points.length - 1];
|
||||||
if (cursor && last && segLen(last, cursor) >= EPS) {
|
if (cursor && last && segLen(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor);
|
||||||
draft.hud = {
|
|
||||||
at: cursor,
|
|
||||||
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
|
|
||||||
(segAngleDeg(last, cursor) + 360) % 360,
|
|
||||||
).toFixed(0)}°`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,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",
|
||||||
|
|||||||
@@ -180,6 +180,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
@@ -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";
|
||||||
@@ -2236,10 +2236,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,
|
||||||
@@ -2247,7 +2250,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;
|
||||||
@@ -2292,10 +2295,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
@@ -2028,13 +2028,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
@@ -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
@@ -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
@@ -35,16 +35,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 {
|
||||||
@@ -63,6 +66,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). */
|
||||||
@@ -78,6 +85,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 ──────────────────────────────────────────────────
|
||||||
@@ -148,8 +157,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 };
|
||||||
/** Live-Messwerte (nur beim Mess-Werkzeug gesetzt) fürs Objekt-Info-Panel. */
|
/** Live-Messwerte (nur beim Mess-Werkzeug gesetzt) fürs Objekt-Info-Panel. */
|
||||||
measure?: MeasurementReadout;
|
measure?: MeasurementReadout;
|
||||||
}
|
}
|
||||||
@@ -218,6 +233,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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user