2D: Kanten-Griff bei Vielecken winkeltreu (Nachbarkanten kippten sonst)
Nutzer-Report: bei einem Vieleck mit mehr als 5 Ecken kippten beim Parallel-Verschieben einer Seite die ANGRENZENDEN Kanten im Winkel, statt sich nur zu verlängern/verkürzen -- sichtbar bei nicht rechtwinkligen Anschlüssen (bei Rechtecken fiel es nicht auf, weil deren Nachbarkanten zufällig immer senkrecht zur bewegten Seite stehen). Ursache: moveEdge/moveRoomEdge/moveCeilingEdge versetzten die beiden Eckpunkte der gezogenen Kante stur um dasselbe Delta -- korrekt nur, wenn die Nachbarkante zufällig senkrecht zur Zugrichtung steht. Bei schrägen Anschlüssen wandert der gemeinsame Eckpunkt dadurch von der Nachbarkanten-Linie weg, die Nachbarkante dreht sich mit. Fix: neue movePolygonEdge/edgeMoveVertex-Helfer (projectSlice.ts) berechnen die neue Eckpunkt-Position stattdessen als Schnittpunkt der VERSCHOBENEN Kanten-Linie mit der unendlich verlängerten Nachbarkanten- Linie durch den FIXEN Nachbarpunkt (lineIntersect, dasselbe Prinzip wie die Wandstoß-Gehrung in model/joins.ts) -- die Nachbarkante behält ihre Richtung, nur ihre Länge ändert sich. Fällt bei fehlendem Nachbarn (offenes Kettenende) oder (fast) paralleler Nachbarkante auf die bisherige einfache Delta-Verschiebung zurück. Rechtecke bleiben unverändert (eigener min/max-Pfad, ohnehin immer rechtwinklig). +3 Tests (Fünfeck-Kante winkeltreu über Drawing2D UND Room, Rechteck- Regressionsschutz). tsc/vitest 922/922 grün.
This commit is contained in:
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
+92
-12
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user