diff --git a/src/tools/snapping.test.ts b/src/tools/snapping.test.ts index e154ec8..469a0ef 100644 --- a/src/tools/snapping.test.ts +++ b/src/tools/snapping.test.ts @@ -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"); + }); +}); diff --git a/src/tools/snapping.ts b/src/tools/snapping.ts index 3321b29..4bbbe9a 100644 --- a/src/tools/snapping.ts +++ b/src/tools/snapping.ts @@ -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).