Dach-3D-Griffe: Eckpunkt-Resize + Verschieben + First-Griff für Neigung

Gewählte Dächer bekommen im wgpu-Viewport Ziehgriffe wie Wände/Decken:
Eckpunkte (achsparalleles Resize, Gegenecke fix), Verschiebe-Griff im
Schwerpunkt und ein First-Griff am höchsten Dachpunkt — vertikales Ziehen
rechnet aus der neuen First-Höhe die Dachneigung (behält die Firstlage).

- projectSlice: moveRoofGrip/moveRoofBy (coalescing pro Dach)
- Wasm3DViewport: roofpitch-Griff (Render + vertikaler Drag auf Apex-Achse)
- App: edit3dRoofs + roofId-Routing in onEdit3dVertex/Body + onEdit3dRoofPitch
- Viewport3D: editRoofs/onEditRoofPitch durchgereicht
- Tests: roofGrips.test.ts (Resize/Verschieben/Coalescing)
This commit is contained in:
2026-07-10 01:02:47 +02:00
parent 9b6dd80d39
commit 4ef40a02c4
7 changed files with 284 additions and 21 deletions
+46
View File
@@ -240,6 +240,10 @@ export interface ProjectSlice {
id: string,
patch: Partial<import("../model/types").Roof>,
) => void;
/** Dach-Eckgriff (3D): Umriss-Ecke `index` auf `pt`, Gegenecke fix (Rechteck). */
moveRoofGrip: (roofId: string, index: number, pt: import("../model/types").Vec2) => void;
/** Dach als Ganzes um `delta` verschieben (Verschiebe-Griff). */
moveRoofBy: (roofId: string, delta: import("../model/types").Vec2) => void;
// ── Treppen-Editieren ──────────────────────────────────────────────────────
/** Verschiebt eine Treppe (Antritt) um `delta`. */
@@ -926,6 +930,48 @@ export function createProjectSlice(
roofs: (p.roofs ?? []).map((r) => (r.id === id ? { ...r, ...patch } : r)),
})),
// Dach-Eckgriff (3D): der gezogene Umriss-Eckpunkt `index` wandert auf `pt`;
// die GEGENÜBERLIEGENDE Ecke bleibt fix → der Umriss bleibt ein achsparalleles
// Rechteck (die Dach-Geometrie rechnet ohnehin auf der Bounding-Box).
moveRoofGrip: (roofId, index, pt) =>
setProject(
(p) => ({
...p,
roofs: (p.roofs ?? []).map((r) => {
if (r.id !== roofId || r.outline.length < 4) return r;
const opp = r.outline[(index + 2) % r.outline.length];
const x0 = Math.min(pt.x, opp.x);
const x1 = Math.max(pt.x, opp.x);
const y0 = Math.min(pt.y, opp.y);
const y1 = Math.max(pt.y, opp.y);
return {
...r,
outline: [
{ x: x0, y: y0 },
{ x: x1, y: y0 },
{ x: x1, y: y1 },
{ x: x0, y: y1 },
],
};
}),
}),
`moveRoofGrip:${roofId}`,
),
// Dach als Ganzes verschieben (Verschiebe-Griff).
moveRoofBy: (roofId, delta) =>
setProject(
(p) => ({
...p,
roofs: (p.roofs ?? []).map((r) =>
r.id === roofId
? { ...r, outline: r.outline.map((v) => ({ x: v.x + delta.x, y: v.y + delta.y })) }
: r,
),
}),
`moveRoofBy:${roofId}`,
),
// ── Treppen-Editieren ──────────────────────────────────────────────────
// (coalesceKey: siehe Kommentar bei moveGripOf.)
moveStairBy: (stairId, delta) =>
+91
View File
@@ -0,0 +1,91 @@
// Dach-3D-Griffe: Store-Mutationen moveRoofGrip (Eckpunkt-Resize, gegenüber-
// liegende Ecke bleibt fix) und moveRoofBy (Verschieben des ganzen Umrisses).
// Beide werden bei jedem Maus-Move-Schritt aufgerufen und müssen zu EINEM
// Undo-Schritt koaleszieren (coalesceKey pro Dach).
import { describe, it, expect, beforeEach } from "vitest";
import { getState, setState } from "./appStore";
import type { Project, Roof } from "../model/types";
const roof: Roof = {
id: "RF1",
type: "roof",
floorId: "eg",
categoryCode: "31",
outline: [
{ x: 0, y: 0 },
{ x: 6, y: 0 },
{ x: 6, y: 4 },
{ x: 0, y: 4 },
],
shape: "sattel",
pitchDeg: 45,
overhang: 0,
ridgeAxis: "x",
thickness: 0.2,
};
const baseProject = {
id: "p",
name: "T",
layers: [],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, baseElevation: 0 },
],
walls: [],
drawings2d: [],
roofs: [roof],
} as unknown as Project;
beforeEach(() => {
setState({
project: JSON.parse(JSON.stringify(baseProject)),
undoStack: [],
redoStack: [],
canUndo: false,
canRedo: false,
});
});
describe("moveRoofGrip — Eckpunkt-Resize", () => {
it("zieht Ecke 0 und hält die gegenüberliegende Ecke (2) fix", () => {
// Ecke 0 (0,0) auf (-1,-1) ziehen; Gegenecke 2 = (6,4) bleibt.
getState().moveRoofGrip("RF1", 0, { x: -1, y: -1 });
const r = getState().project.roofs![0];
// Umriss neu als achsparallles Rechteck [minX,minY]..[maxX,maxY].
expect(r.outline).toEqual([
{ x: -1, y: -1 },
{ x: 6, y: -1 },
{ x: 6, y: 4 },
{ x: -1, y: 4 },
]);
});
it("hochfrequente Drags koaleszieren zu EINEM Undo-Schritt", () => {
// Saubere Koaleszenz-Grenze: eine diskrete (keyless) Aktion setzt
// lastCoalesceKey zurück, damit der erste Drag garantiert pusht (der
// Coalesce-Zustand ist ein Modul-Closure und überlebt sonst zwischen Tests).
getState().updateRoof("RF1", { overhang: 0.1 });
setState({ undoStack: [], canUndo: false });
getState().moveRoofGrip("RF1", 2, { x: 5, y: 3 });
getState().moveRoofGrip("RF1", 2, { x: 5.5, y: 3.5 });
getState().moveRoofGrip("RF1", 2, { x: 7, y: 5 });
expect(getState().undoStack.length).toBe(1);
getState().undo();
// Nach einem Undo wieder der Ausgangsumriss.
expect(getState().project.roofs![0].outline).toEqual(baseProject.roofs![0].outline);
});
});
describe("moveRoofBy — Verschieben", () => {
it("verschiebt alle Eckpunkte um delta", () => {
getState().moveRoofBy("RF1", { x: 2, y: -1 });
const r = getState().project.roofs![0];
expect(r.outline).toEqual([
{ x: 2, y: -1 },
{ x: 8, y: -1 },
{ x: 8, y: 3 },
{ x: 2, y: 3 },
]);
});
});