Snap: Center-/Quadrant-Snaps für Kreise + Bögen (Bogen nur im Spannbereich) + Bogen-Endpunkte, mit Tests

This commit is contained in:
2026-07-05 20:19:34 +02:00
parent a750e87517
commit 3697b7e2d8
3 changed files with 144 additions and 2 deletions
+93 -1
View File
@@ -12,7 +12,7 @@
import { describe, it, expect } from "vitest";
import { computeSnap, wallLayerBoundarySegments } from "./snapping";
import { DEFAULT_SNAP } from "./types";
import type { Project, Vec2, Wall } from "../model/types";
import type { Drawing2D, Project, Vec2, Wall } from "../model/types";
/** Minimalprojekt mit einer dreischichtigen Wand (0.1 / 0.15 / 0.2 m). */
function wallProject(wall: Wall): Project {
@@ -134,3 +134,95 @@ describe("computeSnap — fängt auf Wand-Schichttrennlinien", () => {
expect(result!.point.y).toBeCloseTo(0.025, 6);
});
});
// ── Kreis-/Bogen-Snaps (Center/Quadrant/Bogen-Endpunkt) ──────────────────────
/** Minimalprojekt mit gegebenen 2D-Geometrien auf dem Geschoss „eg". */
function drawProject(geoms: Drawing2D["geom"][]): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
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: geoms.map((geom, i) => ({
id: `d${i}`,
type: "drawing2d" as const,
levelId: "eg",
categoryCode: "20",
geom,
})),
context: [],
};
}
function snapAt(project: Project, raw: Vec2, settings = DEFAULT_SNAP) {
return computeSnap({
raw,
project,
levelId: "eg",
visibleCodes: new Set(["20"]),
settings,
draftPoints: [],
lastPoint: null,
pxPerMeter: 100,
shift: false,
ctrl: false,
});
}
describe("computeSnap — Kreis/Bogen", () => {
const withCenter = { ...DEFAULT_SNAP, center: true };
it("fängt Mittelpunkt + Quadranten eines Kreises (center aktiv)", () => {
const p = drawProject([{ shape: "circle", center: { x: 0, y: 0 }, r: 1 }]);
const c = snapAt(p, { x: 0, y: 0 }, withCenter);
expect(c?.kind).toBe("center");
expect(c!.point.x).toBeCloseTo(0, 6);
expect(c!.point.y).toBeCloseTo(0, 6);
const q = snapAt(p, { x: 1, y: 0 }, withCenter);
expect(q?.kind).toBe("quadrant");
expect(q!.point.x).toBeCloseTo(1, 6);
expect(q!.point.y).toBeCloseTo(0, 6);
});
it("fängt Bogen-Endpunkte (endpoint per Default aktiv)", () => {
// Viertelbogen um (5,0), r=1, 0…90° → Endpunkte (6,0) und (5,1).
const p = drawProject([
{ shape: "arc", center: { x: 5, y: 0 }, r: 1, a0: 0, a1: Math.PI / 2 },
]);
const e = snapAt(p, { x: 6, y: 0 });
expect(e?.kind).toBe("endpoint");
expect(e!.point.x).toBeCloseTo(6, 6);
expect(e!.point.y).toBeCloseTo(0, 6);
});
it("bietet Quadranten AUSSERHALB der Bogen-Spanne NICHT an", () => {
// Viertelbogen 0…90°: der West-Quadrant (4,0) liegt NICHT im Bogen.
const p = drawProject([
{ shape: "arc", center: { x: 5, y: 0 }, r: 1, a0: 0, a1: Math.PI / 2 },
]);
const r = snapAt(p, { x: 4, y: 0 }, withCenter);
expect(r?.kind).not.toBe("quadrant");
});
});
+50
View File
@@ -108,6 +108,28 @@ function collectSegments(input: SnapInput): Seg[] {
return segs;
}
/** Kreis-/Bogen-Geometrie für Center-/Quadrant-/Endpunkt-Snaps. `arc=null` =
* Vollkreis (keine Endpunkte, alle vier Quadranten). */
interface CircleGeom {
center: Vec2;
r: number;
arc: { a0: number; a1: number } | null;
}
/** Sammelt sichtbare Kreise/Bögen des aktiven Geschosses (aus `drawings2d`). */
function collectCircles(input: SnapInput): CircleGeom[] {
const out: CircleGeom[] = [];
for (const d of input.project.drawings2d) {
if (d.levelId !== input.levelId) continue;
if (!input.visibleCodes.has(d.categoryCode)) continue;
const g = d.geom;
if (g.shape === "circle") out.push({ center: g.center, r: g.r, arc: null });
else if (g.shape === "arc")
out.push({ center: g.center, r: g.r, arc: { a0: g.a0, a1: g.a1 } });
}
return out;
}
/** Lotfußpunkt von p auf die Strecke a-b, auf das Segment geklemmt. */
function perpFoot(p: Vec2, a: Vec2, b: Vec2): Vec2 {
const dx = b.x - a.x, dy = b.y - a.y;
@@ -184,6 +206,34 @@ export function computeSnap(input: SnapInput): SnapResult | null {
for (const [a, b] of segs) consider(perpFoot(raw, a, b), "onEdge");
}
// Kreis-/Bogen-Snaps: Mittelpunkt + Quadranten (Ost/Nord/West/Süd); bei Bögen
// nur die Quadranten IM Bogen-Bereich, plus die beiden Bogen-Endpunkte. Center/
// Quadrant hängen an der `center`-Einstellung, die Endpunkte an `endpoint`.
if (settings.center || settings.endpoint) {
const TAU = Math.PI * 2;
const norm = (x: number) => ((x % TAU) + TAU) % TAU;
const onCircle = (c: CircleGeom, ang: number): Vec2 => ({
x: c.center.x + c.r * Math.cos(ang),
y: c.center.y + c.r * Math.sin(ang),
});
for (const c of collectCircles(input)) {
if (settings.center) {
consider(c.center, "center");
// Volle CCW-Spanne (0 ⇒ Vollkreis); Quadrant nur, wenn im Bogen liegt.
const sweep = c.arc ? norm(c.arc.a1 - c.arc.a0) || TAU : TAU;
for (let q = 0; q < 4; q++) {
const ang = q * (Math.PI / 2);
if (c.arc && norm(ang - c.arc.a0) > sweep + 1e-9) continue;
consider(onCircle(c, ang), "quadrant");
}
}
if (settings.endpoint && c.arc) {
consider(onCircle(c, c.arc.a0), "endpoint");
consider(onCircle(c, c.arc.a1), "endpoint");
}
}
}
if (best) return best;
// Ortho / Winkelraster (Projektion relativ zum letzten Punkt).