diff --git a/src/state/projectSlice.moveEdge.test.ts b/src/state/projectSlice.moveEdge.test.ts new file mode 100644 index 0000000..96da975 --- /dev/null +++ b/src/state/projectSlice.moveEdge.test.ts @@ -0,0 +1,139 @@ +/** + * Kanten-Griff ziehen (`moveEdgeOf`/`moveRoomEdge` in `projectSlice.ts`) bei + * Vielecken mit nicht rechtwinkligen Anschlüssen. Nutzer-Report: die + * Nachbarkanten der verschobenen Seite kippten bisher im WINKEL statt sich + * nur zu verlängern/verkürzen — der Eckpunkt wurde stur um `delta` versetzt, + * statt entlang seiner unveränderten Nachbarkante zu wandern. Fix: + * `movePolygonEdge`/`edgeMoveVertex` schneiden die verschobene Kanten-Linie + * mit der (unendlich verlängerten) Nachbarkanten-Linie durch den fixen + * Nachbarpunkt — Nachbarkante behält ihre Richtung, nur ihre Länge ändert + * sich. + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { getState, setState } from "./appStore"; +import { sampleProject } from "../model/sampleProject"; +import type { Drawing2D, Room } from "../model/types"; + +// Fünfeck (Haus-Form): P0(0,0) P1(4,0) P2(4,3) P3(2,5) P4(0,3), geschlossen. +const pentagon: Drawing2D = { + id: "d-pentagon", + type: "drawing2d", + levelId: "eg", + categoryCode: "00", + geom: { + shape: "polyline", + closed: true, + pts: [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 2, y: 5 }, + { x: 0, y: 3 }, + ], + }, +}; + +function makeRoom(over: Partial = {}): Room { + return { + id: "r-pentagon", + type: "room", + floorId: "eg", + categoryCode: "45", + siaCategory: "HNF", + name: "Wohnen", + boundary: [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 2, y: 5 }, + { x: 0, y: 3 }, + ], + color: "#123456", + ...over, + }; +} + +beforeEach(() => { + setState({ + project: { ...sampleProject, drawings2d: [pentagon], rooms: [makeRoom()] }, + undoStack: [], + redoStack: [], + canUndo: false, + canRedo: false, + }); +}); + +describe("moveEdgeOf — Nachbarkanten winkeltreu (nicht rechtwinklige Ecke)", () => { + it("verschiebt die slanted Kante P2-P3 und erhält die Richtung der Nachbarkanten", () => { + // Kante (Index 2,3) = P2(4,3)→P3(2,5) um (1,1) verschieben. + getState().moveEdgeOf("d-pentagon", null, 2, 3, { x: 1, y: 1 }); + const g = getState().project.drawings2d.find((d) => d.id === "d-pentagon")!.geom; + expect(g.shape).toBe("polyline"); + if (g.shape !== "polyline") return; + // P0/P1/P4 unverändert (nicht Teil der gezogenen Kante). + expect(g.pts[0]).toEqual({ x: 0, y: 0 }); + expect(g.pts[1]).toEqual({ x: 4, y: 0 }); + expect(g.pts[4]).toEqual({ x: 0, y: 3 }); + // P2/P3 wandern entlang ihrer FIXEN Nachbarkanten (P1-P2 senkrecht, + // P4-P3 diagonal 45°), nicht stur um (1,1) versetzt. + expect(g.pts[2].x).toBeCloseTo(4, 9); + expect(g.pts[2].y).toBeCloseTo(5, 9); + expect(g.pts[3].x).toBeCloseTo(3, 9); + expect(g.pts[3].y).toBeCloseTo(6, 9); + // Nachbarkante P1-P2' behält ihre Richtung (senkrecht, Δx=0) — nur die + // Länge ändert sich (3 → 5). + const p1 = g.pts[1]; + const p2 = g.pts[2]; + expect(p2.x - p1.x).toBeCloseTo(0, 9); + expect(p2.y - p1.y).toBeCloseTo(5, 9); + // Nachbarkante P4-P3' behält ihre Richtung (Steigung 1:1) — nur die + // Länge ändert sich (√8 → √18). + const p4 = g.pts[4]; + const p3 = g.pts[3]; + const dir = { x: p3.x - p4.x, y: p3.y - p4.y }; + expect(dir.x).toBeCloseTo(dir.y, 9); // 45°-Steigung erhalten + expect(Math.hypot(dir.x, dir.y)).toBeGreaterThan(Math.hypot(2, 2)); // länger als vorher + }); + + it("Rechteck (4 rechtwinklige Ecken) bleibt reine Parallel-Verschiebung (Regressionsschutz)", () => { + const rect: Drawing2D = { + id: "d-rect", + type: "drawing2d", + levelId: "eg", + categoryCode: "00", + geom: { + shape: "polyline", + closed: true, + pts: [ + { x: 0, y: 0 }, + { x: 4, y: 0 }, + { x: 4, y: 3 }, + { x: 0, y: 3 }, + ], + }, + }; + setState({ project: { ...getState().project, drawings2d: [rect] } }); + // Rechte Kante (Index 1,2) um (2,0) nach außen verschieben. + getState().moveEdgeOf("d-rect", null, 1, 2, { x: 2, y: 0 }); + const g = getState().project.drawings2d.find((d) => d.id === "d-rect")!.geom; + expect(g.shape).toBe("polyline"); + if (g.shape !== "polyline") return; + expect(g.pts[0]).toEqual({ x: 0, y: 0 }); + expect(g.pts[1]).toEqual({ x: 6, y: 0 }); + expect(g.pts[2]).toEqual({ x: 6, y: 3 }); + expect(g.pts[3]).toEqual({ x: 0, y: 3 }); + }); +}); + +describe("moveRoomEdge — dieselbe Winkeltreue über movePolygonEdge", () => { + it("verschiebt die slanted Kante eines Raum-Umrisses winkeltreu", () => { + getState().moveRoomEdge("r-pentagon", 2, 3, { x: 1, y: 1 }); + const r = getState().project.rooms!.find((x) => x.id === "r-pentagon")!; + expect(r.boundary[0]).toEqual({ x: 0, y: 0 }); + expect(r.boundary[2].x).toBeCloseTo(4, 9); + expect(r.boundary[2].y).toBeCloseTo(5, 9); + expect(r.boundary[3].x).toBeCloseTo(3, 9); + expect(r.boundary[3].y).toBeCloseTo(6, 9); + }); +}); diff --git a/src/state/projectSlice.ts b/src/state/projectSlice.ts index 1b065a9..d7613a6 100644 --- a/src/state/projectSlice.ts +++ b/src/state/projectSlice.ts @@ -23,6 +23,7 @@ import type { Project, Vec2, } from "../model/types"; +import { lineIntersect } from "../model/geometry"; import { commitTransform } from "../tools/transform"; import type { CopyMode, TransformOp, TransformSelection } from "../tools/transform"; import { uniqueId } from "../tools/types"; @@ -908,9 +909,7 @@ export function createProjectSlice( setProject( (p) => mapCeiling(p, ceilingId, (c) => ({ ...c, - outline: c.outline.map((v, i) => - i === aIndex || i === bIndex ? { x: v.x + delta.x, y: v.y + delta.y } : v, - ), + outline: movePolygonEdge(c.outline, aIndex, bIndex, delta, true), })), `moveCeilingEdge:${ceilingId}:${aIndex}:${bIndex}`, ), @@ -1030,11 +1029,7 @@ export function createProjectSlice( (p) => mapRoom(p, roomId, (r) => ({ ...r, - boundary: r.boundary.map((v, i) => - i === aIndex || i === bIndex - ? { x: v.x + delta.x, y: v.y + delta.y } - : v, - ), + boundary: movePolygonEdge(r.boundary, aIndex, bIndex, delta, true), })), `moveRoomEdge:${roomId}:${aIndex}:${bIndex}`, ), @@ -1615,12 +1610,97 @@ function moveElementBy( return project; } +/** + * Neue Position eines Eckpunkts, dessen EINE Kante parallel um `delta` + * verschoben wird: der Punkt bleibt NICHT einfach delta-verschoben, sondern + * wandert entlang seiner ANDEREN (unverschobenen) Nachbarkante — deren + * Richtung bleibt exakt erhalten (nur ihre Länge ändert sich), statt sich mit + * zu drehen. Nutzer-Report: bei Vielecken mit mehr als 4/5 Ecken (nicht + * rechtwinklige Anschlüsse) kippten die Nachbarkanten bisher im Winkel, weil + * der Eckpunkt stur um `delta` versetzt wurde (korrekt nur bei rechtwinkligen + * Anschlüssen — Zufall bei Rechtecken, da rect ohnehin nur achsparallele + * Ecken kennt). Berechnet als Schnittpunkt der verschobenen Kanten-Linie mit + * der (unendlich verlängerten) Nachbarkanten-Linie durch den fixen + * Nachbarpunkt — dasselbe Prinzip wie die Wandstoß-Gehrung in `model/ + * joins.ts`. Fällt bei fehlendem Nachbarn (offenes Kettenende) oder (fast) + * paralleler Nachbarkante (kein/instabiler Schnittpunkt) auf die einfache + * Delta-Verschiebung zurück. + */ +function edgeMoveVertex( + vertexOrig: Vec2, + neighborFixed: Vec2 | null, + shiftedLinePoint: Vec2, + edgeDir: Vec2, + delta: Vec2, +): Vec2 { + const fallback: Vec2 = { x: vertexOrig.x + delta.x, y: vertexOrig.y + delta.y }; + if (!neighborFixed) return fallback; + const neighborDir: Vec2 = { + x: vertexOrig.x - neighborFixed.x, + y: vertexOrig.y - neighborFixed.y, + }; + if (Math.hypot(neighborDir.x, neighborDir.y) < 1e-9) return fallback; // entartete Nachbarkante + return lineIntersect(neighborFixed, neighborDir, shiftedLinePoint, edgeDir) ?? fallback; +} + +/** + * Nachbar-Index in einem Vertex-Ring/einer -Kette in Richtung `dir`. `null`, + * wenn `i` am offenen Ende liegt und `closed` falsch ist (kein Nachbar dort) + * ODER der Nachbar auf die andere Kanten-Ecke selbst fiele (entartetes + * 2-Punkt-„Polygon" — kein sinnvoller dritter Bezugspunkt). + */ +function ringNeighbor( + i: number, + n: number, + closed: boolean, + dir: 1 | -1, + excluding: number, +): number | null { + const j = i + dir; + const k = j >= 0 && j < n ? j : closed ? ((j % n) + n) % n : null; + return k !== null && k !== excluding ? k : null; +} + +/** + * Verschiebt die Kante (aIndex,bIndex) eines Vertex-Rings/einer -Kette um + * `delta`, winkeltreu an den beiden Nachbar-Ecken (s. `edgeMoveVertex`). + * `closed`: Ring (Raum/Decke/geschlossene Polylinie) oder offene Kette + * (offene Polylinie). + */ +function movePolygonEdge( + pts: Vec2[], + aIndex: number, + bIndex: number, + delta: Vec2, + closed: boolean, +): Vec2[] { + const n = pts.length; + const A = pts[aIndex]; + const B = pts[bIndex]; + const shiftedA: Vec2 = { x: A.x + delta.x, y: A.y + delta.y }; + const edgeDir: Vec2 = { x: B.x - A.x, y: B.y - A.y }; + const prevOfA = ringNeighbor(aIndex, n, closed, -1, bIndex); + const nextOfB = ringNeighbor(bIndex, n, closed, 1, aIndex); + return pts.map((p, i) => { + if (i === aIndex) { + return edgeMoveVertex(A, prevOfA != null ? pts[prevOfA] : null, shiftedA, edgeDir, delta); + } + if (i === bIndex) { + return edgeMoveVertex(B, nextOfB != null ? pts[nextOfB] : null, shiftedA, edgeDir, delta); + } + return p; + }); +} + /** * Verschiebt eine SEITE (Kante) des selektierten Elements: beide Vertices der - * Kante um `delta` (immutabel). Für 2D-Elemente: + * Kante um `delta` (immutabel), winkeltreu an den Nachbarkanten (s. o.). Für + * 2D-Elemente: * • rect: die zwei betroffenen Ecken (per Index) verschieben und min/max neu - * normalisieren — so wächst/schrumpft das Rechteck an genau dieser Seite. - * • polyline: die zwei Vertices `aIndex`/`bIndex` um `delta` verschieben. + * normalisieren — so wächst/schrumpft das Rechteck an genau dieser Seite + * (immer rechtwinklig, `movePolygonEdge` nicht nötig). + * • polyline: die zwei Vertices `aIndex`/`bIndex` um `delta` verschieben, + * winkeltreu über `movePolygonEdge`. * • line: beide Endpunkte verschieben (das gesamte Segment senkrecht). * Für die Wand: start+end gemeinsam (die ganze Wand senkrecht zur Achse). */ @@ -1644,7 +1724,7 @@ function moveEdge( return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } }; } if (g.shape === "polyline") { - const pts = g.pts.map((p, i) => (i === aIndex || i === bIndex ? mv(p) : p)); + const pts = movePolygonEdge(g.pts, aIndex, bIndex, delta, !!g.closed); return { ...d, geom: { ...g, pts } }; } if (g.shape === "rect" || g.shape === "image") {