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
+49 -6
View File
@@ -38,6 +38,7 @@ import type {
Layout, Layout,
OverrideRule, OverrideRule,
Project, Project,
Roof,
Stair, Stair,
StairType, StairType,
Vec2, Vec2,
@@ -51,6 +52,7 @@ import { computeSection } from "./plan/toSection";
import type { SectionOutput } from "./plan/toSection"; import type { SectionOutput } from "./plan/toSection";
import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync"; import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync";
import { selectionHighlightLines } from "./plan/toWalls3d"; import { selectionHighlightLines } from "./plan/toWalls3d";
import { roofBaseElevation, roofGeometry } from "./geometry/roof";
import { parseDxf } from "./io/dxfParser"; import { parseDxf } from "./io/dxfParser";
import type { DxfImportResult } from "./io/dxfParser"; import type { DxfImportResult } from "./io/dxfParser";
import { setDxfImportTrigger } from "./io/dxfImportHook"; import { setDxfImportTrigger } from "./io/dxfImportHook";
@@ -575,6 +577,8 @@ export default function App() {
const setSelectedRoofIds = useStore((s) => s.setSelectedRoofIds); const setSelectedRoofIds = useStore((s) => s.setSelectedRoofIds);
const selectedRoofId = selectedRoofIds.length === 1 ? selectedRoofIds[0] : null; const selectedRoofId = selectedRoofIds.length === 1 ? selectedRoofIds[0] : null;
const updateRoof = useStore((s) => s.updateRoof); const updateRoof = useStore((s) => s.updateRoof);
const moveRoofGrip = useStore((s) => s.moveRoofGrip);
const moveRoofBy = useStore((s) => s.moveRoofBy);
const selectedOpeningIds = useStore((s) => s.selectedOpeningIds); const selectedOpeningIds = useStore((s) => s.selectedOpeningIds);
const setSelectedOpeningIds = useStore((s) => s.setSelectedOpeningIds); const setSelectedOpeningIds = useStore((s) => s.setSelectedOpeningIds);
const selectedOpeningId = const selectedOpeningId =
@@ -2486,6 +2490,19 @@ export default function App() {
: [], : [],
[selectedWallIds, selectedDrawingIds, selectedCeilingIds, project.ceilings], [selectedWallIds, selectedDrawingIds, selectedCeilingIds, project.ceilings],
); );
// Dächer mit 3D-Griffen: nur wenn ausschliesslich Dächer gewählt sind
// (Eck-Griffe zum Resizen, Move-Griff, First-Griff für die Neigung).
const edit3dRoofs = useMemo(
() =>
selectedWallIds.length === 0 &&
selectedDrawingIds.length === 0 &&
selectedCeilingIds.length === 0
? selectedRoofIds
.map((id) => (project.roofs ?? []).find((r) => r.id === id))
.filter((r): r is Roof => !!r)
: [],
[selectedWallIds, selectedDrawingIds, selectedCeilingIds, selectedRoofIds, project.roofs],
);
// Snap beim 3D-Vertex-Drag: dieselbe computeSnap-Logik wie die Plan-Griffe. // Snap beim 3D-Vertex-Drag: dieselbe computeSnap-Logik wie die Plan-Griffe.
// Das/die gerade GEZOGENEN Element(e) werden aus den Snap-Quellen genommen // Das/die gerade GEZOGENEN Element(e) werden aus den Snap-Quellen genommen
// (sonst fängt der Punkt an sich selbst). `excludeWallIds`/`excludeDrawingIds` // (sonst fängt der Punkt an sich selbst). `excludeWallIds`/`excludeDrawingIds`
@@ -2522,7 +2539,7 @@ export default function App() {
// Snap-Marker zu zeigen. `snapped=false` (für mitgeführte Geschwister) wendet // Snap-Marker zu zeigen. `snapped=false` (für mitgeführte Geschwister) wendet
// den Punkt direkt an, ohne erneut zu snappen. // den Punkt direkt an, ohne erneut zu snappen.
const onEdit3dVertex = ( const onEdit3dVertex = (
target: { wallId?: string; drawingId?: string; ceilingId?: string }, target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string },
index: number, index: number,
model: Vec2, model: Vec2,
excludeWallIds?: Set<string>, excludeWallIds?: Set<string>,
@@ -2537,17 +2554,35 @@ export default function App() {
); );
pt = snapResult?.point ?? model; pt = snapResult?.point ?? model;
} }
if (target.ceilingId) moveCeilingGrip(target.ceilingId, index, pt); if (target.roofId) moveRoofGrip(target.roofId, index, pt);
else if (target.ceilingId) moveCeilingGrip(target.ceilingId, index, pt);
else moveGripOf(target.drawingId ?? null, target.wallId ?? null, index, pt); else moveGripOf(target.drawingId ?? null, target.wallId ?? null, index, pt);
return pt; return pt;
}; };
const onEdit3dBody = ( const onEdit3dBody = (
target: { wallId?: string; drawingId?: string; ceilingId?: string }, target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string },
delta: Vec2, delta: Vec2,
) => { ) => {
if (target.ceilingId) moveCeilingBy(target.ceilingId, delta); if (target.roofId) moveRoofBy(target.roofId, delta);
else if (target.ceilingId) moveCeilingBy(target.ceilingId, delta);
else moveElementByOf(target.drawingId ?? null, target.wallId ?? null, delta); else moveElementByOf(target.drawingId ?? null, target.wallId ?? null, delta);
}; };
// First-Griff: neue First-Höhe (world-Y = Modell-Höhe) → Dachneigung. Aus der
// aktuellen Neigung die halbe Spannweite ableiten und daraus den neuen Winkel
// rechnen (behält die Firstlage, ändert nur die Steilheit).
const onEdit3dRoofPitch = (roofId: string, apexHeight: number) => {
const roof = (project.roofs ?? []).find((r) => r.id === roofId);
if (!roof) return;
const baseZ = roofBaseElevation(project, roof);
const geom = roofGeometry(roof, baseZ);
const currentRidge = geom.ridgeHeight; // Höhe First über Traufe
const newRidge = Math.max(0.05, apexHeight - baseZ);
const curPitch = ((roof.pitchDeg ?? 30) * Math.PI) / 180;
// Halbe Spannweite aus aktueller Geometrie (First/tan(Neigung)).
const halfSpan = currentRidge > 1e-4 ? currentRidge / Math.tan(curPitch || 1e-3) : 1;
const newPitch = (Math.atan(newRidge / Math.max(0.05, halfSpan)) * 180) / Math.PI;
updateRoof(roofId, { pitchDeg: Math.max(1, Math.min(85, newPitch)) });
};
const onEdit3dEdge = (ceilingId: string, aIndex: number, bIndex: number, delta: Vec2) => { const onEdit3dEdge = (ceilingId: string, aIndex: number, bIndex: number, delta: Vec2) => {
moveCeilingEdge(ceilingId, aIndex, bIndex, delta); moveCeilingEdge(ceilingId, aIndex, bIndex, delta);
}; };
@@ -4759,9 +4794,11 @@ export default function App() {
edit3dWalls={edit3dWalls} edit3dWalls={edit3dWalls}
edit3dDrawings={edit3dDrawings} edit3dDrawings={edit3dDrawings}
edit3dCeilings={edit3dCeilings} edit3dCeilings={edit3dCeilings}
edit3dRoofs={edit3dRoofs}
onEdit3dVertex={onEdit3dVertex} onEdit3dVertex={onEdit3dVertex}
onEdit3dBody={onEdit3dBody} onEdit3dBody={onEdit3dBody}
onEdit3dEdge={onEdit3dEdge} onEdit3dEdge={onEdit3dEdge}
onEdit3dRoofPitch={onEdit3dRoofPitch}
onEdit3dWallTop={onEdit3dWallTop} onEdit3dWallTop={onEdit3dWallTop}
onEdit3dEnd={onEdit3dEnd} onEdit3dEnd={onEdit3dEnd}
/> />
@@ -5876,9 +5913,11 @@ function Content({
edit3dWalls, edit3dWalls,
edit3dDrawings, edit3dDrawings,
edit3dCeilings, edit3dCeilings,
edit3dRoofs,
onEdit3dVertex, onEdit3dVertex,
onEdit3dBody, onEdit3dBody,
onEdit3dEdge, onEdit3dEdge,
onEdit3dRoofPitch,
onEdit3dWallTop, onEdit3dWallTop,
onEdit3dEnd, onEdit3dEnd,
}: { }: {
@@ -5964,18 +6003,20 @@ function Content({
edit3dDrawings: Drawing2D[]; edit3dDrawings: Drawing2D[];
/** Alle gewählten Decken — 3D-Eckpunkt-/Kanten-/Verschiebe-Griffe. */ /** Alle gewählten Decken — 3D-Eckpunkt-/Kanten-/Verschiebe-Griffe. */
edit3dCeilings: Ceiling[]; edit3dCeilings: Ceiling[];
edit3dRoofs: Roof[];
onEdit3dVertex: ( onEdit3dVertex: (
target: { wallId?: string; drawingId?: string; ceilingId?: string }, target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string },
index: number, index: number,
model: Vec2, model: Vec2,
excludeWallIds?: Set<string>, excludeWallIds?: Set<string>,
excludeDrawingIds?: Set<string>, excludeDrawingIds?: Set<string>,
) => Vec2; ) => Vec2;
onEdit3dBody: ( onEdit3dBody: (
target: { wallId?: string; drawingId?: string; ceilingId?: string }, target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string },
delta: Vec2, delta: Vec2,
) => void; ) => void;
onEdit3dEdge: (ceilingId: string, aIndex: number, bIndex: number, delta: Vec2) => void; onEdit3dEdge: (ceilingId: string, aIndex: number, bIndex: number, delta: Vec2) => void;
onEdit3dRoofPitch: (roofId: string, apexHeight: number) => void;
onEdit3dWallTop: (wallId: string, topZ: number) => void; onEdit3dWallTop: (wallId: string, topZ: number) => void;
onEdit3dEnd: () => void; onEdit3dEnd: () => void;
}) { }) {
@@ -6156,9 +6197,11 @@ function Content({
editWalls={edit3dWalls} editWalls={edit3dWalls}
editDrawings={edit3dDrawings} editDrawings={edit3dDrawings}
editCeilings={edit3dCeilings} editCeilings={edit3dCeilings}
editRoofs={edit3dRoofs}
onEditVertex={onEdit3dVertex} onEditVertex={onEdit3dVertex}
onEditBody={onEdit3dBody} onEditBody={onEdit3dBody}
onEditEdge={onEdit3dEdge} onEditEdge={onEdit3dEdge}
onEditRoofPitch={onEdit3dRoofPitch}
onEditWallTop={onEdit3dWallTop} onEditWallTop={onEdit3dWallTop}
onEditEnd={onEdit3dEnd} onEditEnd={onEdit3dEnd}
/> />
+1
View File
@@ -1242,6 +1242,7 @@ export const de = {
"viewport3d.grip.height": "Höhe ziehen", "viewport3d.grip.height": "Höhe ziehen",
"viewport3d.grip.move": "Verschieben", "viewport3d.grip.move": "Verschieben",
"viewport3d.grip.edge": "Kante ziehen", "viewport3d.grip.edge": "Kante ziehen",
"viewport3d.grip.roofpitch": "Dachneigung ziehen",
// ── Element-Baum (Elemente-Panel, ROADMAP §11) ────────────────────────────── // ── Element-Baum (Elemente-Panel, ROADMAP §11) ──────────────────────────────
"elements.title": "Elemente", "elements.title": "Elemente",
+1
View File
@@ -1230,6 +1230,7 @@ export const en: Record<TranslationKey, string> = {
"viewport3d.grip.edge": "Drag edge", "viewport3d.grip.edge": "Drag edge",
"viewport3d.grip.height": "Drag height", "viewport3d.grip.height": "Drag height",
"viewport3d.grip.move": "Move", "viewport3d.grip.move": "Move",
"viewport3d.grip.roofpitch": "Drag roof pitch",
// ── Element tree (Elements panel, ROADMAP §11) ────────────────────────────── // ── Element tree (Elements panel, ROADMAP §11) ──────────────────────────────
"elements.title": "Elements", "elements.title": "Elements",
+46
View File
@@ -240,6 +240,10 @@ export interface ProjectSlice {
id: string, id: string,
patch: Partial<import("../model/types").Roof>, patch: Partial<import("../model/types").Roof>,
) => void; ) => 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 ────────────────────────────────────────────────────── // ── Treppen-Editieren ──────────────────────────────────────────────────────
/** Verschiebt eine Treppe (Antritt) um `delta`. */ /** Verschiebt eine Treppe (Antritt) um `delta`. */
@@ -926,6 +930,48 @@ export function createProjectSlice(
roofs: (p.roofs ?? []).map((r) => (r.id === id ? { ...r, ...patch } : r)), 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 ────────────────────────────────────────────────── // ── Treppen-Editieren ──────────────────────────────────────────────────
// (coalesceKey: siehe Kommentar bei moveGripOf.) // (coalesceKey: siehe Kommentar bei moveGripOf.)
moveStairBy: (stairId, delta) => 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 },
]);
});
});
+9 -2
View File
@@ -15,6 +15,7 @@ import type {
DrawingLevel, DrawingLevel,
Opening, Opening,
Project, Project,
Roof,
Stair, Stair,
Vec2, Vec2,
Wall, Wall,
@@ -154,9 +155,11 @@ export function Viewport3D(
editWalls={props.editWalls} editWalls={props.editWalls}
editDrawings={props.editDrawings} editDrawings={props.editDrawings}
editCeilings={props.editCeilings} editCeilings={props.editCeilings}
editRoofs={props.editRoofs}
onEditVertex={props.onEditVertex} onEditVertex={props.onEditVertex}
onEditBody={props.onEditBody} onEditBody={props.onEditBody}
onEditEdge={props.onEditEdge} onEditEdge={props.onEditEdge}
onEditRoofPitch={props.onEditRoofPitch}
onEditWallTop={props.onEditWallTop} onEditWallTop={props.onEditWallTop}
onEditEnd={props.onEditEnd} onEditEnd={props.onEditEnd}
/> />
@@ -291,7 +294,7 @@ function ThreeViewport3D({
* effektiv angewandten (gesnappten) Punkt zurück (für Marker + Mitführen). * effektiv angewandten (gesnappten) Punkt zurück (für Marker + Mitführen).
*/ */
onEditVertex?: ( onEditVertex?: (
target: { wallId?: string; drawingId?: string; ceilingId?: string }, target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string },
index: number, index: number,
model: Vec2, model: Vec2,
excludeWallIds?: Set<string>, excludeWallIds?: Set<string>,
@@ -299,11 +302,15 @@ function ThreeViewport3D({
) => Vec2; ) => Vec2;
/** Verschiebe-Griff gezogen: das `target`-Element um `delta` (XY) verschieben. */ /** Verschiebe-Griff gezogen: das `target`-Element um `delta` (XY) verschieben. */
onEditBody?: ( onEditBody?: (
target: { wallId?: string; drawingId?: string; ceilingId?: string }, target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string },
delta: Vec2, delta: Vec2,
) => void; ) => void;
/** Kanten-Griff einer Decke gezogen: Kante (aIndex→bIndex) um `delta` verschieben. */ /** Kanten-Griff einer Decke gezogen: Kante (aIndex→bIndex) um `delta` verschieben. */
onEditEdge?: (ceilingId: string, aIndex: number, bIndex: number, delta: Vec2) => void; onEditEdge?: (ceilingId: string, aIndex: number, bIndex: number, delta: Vec2) => void;
/** Alle gewählten Dächer — Eck-/Verschiebe-/First-Griffe (nur wgpu-Sicht). */
editRoofs?: Roof[];
/** First-Griff gezogen: neue absolute First-Höhe (Modell-Z) → Dachneigung. */
onEditRoofPitch?: (roofId: string, apexHeight: number) => void;
/** Höhen-Griff gezogen: neue absolute Oberkanten-Z der Wand. */ /** Höhen-Griff gezogen: neue absolute Oberkanten-Z der Wand. */
onEditWallTop?: (wallId: string, topZ: number) => void; onEditWallTop?: (wallId: string, topZ: number) => void;
/** Ende eines Griff-Drags (Commit/Aufräumen). */ /** Ende eines Griff-Drags (Commit/Aufräumen). */
+87 -13
View File
@@ -43,8 +43,9 @@
// Pixel an der Viewport-Höhe normiert), damit sich beide Pfade gleich anfühlen. // Pixel an der Viewport-Höhe normiert), damit sich beide Pfade gleich anfühlen.
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { Project, Wall, Drawing2D, Ceiling, Vec2 } from "../model/types"; import type { Project, Wall, Drawing2D, Ceiling, Roof, Vec2 } from "../model/types";
import { ceilingVerticalExtent, wallVerticalExtent } from "../model/wall"; import { ceilingVerticalExtent, wallVerticalExtent } from "../model/wall";
import { roofBaseElevation, roofGeometry } from "../geometry/roof";
import { projectToModel3d, pickGeometry, TRUCK_MESH_READY_EVENT } from "../plan/toWalls3d"; import { projectToModel3d, pickGeometry, TRUCK_MESH_READY_EVENT } from "../plan/toWalls3d";
import type { DetailLevel, RenderMode, View3d } from "../ui/TopBar"; import type { DetailLevel, RenderMode, View3d } from "../ui/TopBar";
import { t } from "../i18n"; import { t } from "../i18n";
@@ -82,11 +83,11 @@ const CLICK_THRESHOLD_PX = 4;
// GUI-Element aus. Kosten: die Bildschirm-Position muss der Kamera folgen — // GUI-Element aus. Kosten: die Bildschirm-Position muss der Kamera folgen —
// `useEffect` unten pollt sie per `requestAnimationFrame`, solange Griffe // `useEffect` unten pollt sie per `requestAnimationFrame`, solange Griffe
// aktiv sind (billige 2D-Projektion, kein WASM/GPU-Aufruf). // aktiv sind (billige 2D-Projektion, kein WASM/GPU-Aufruf).
type GripKind = "vertex" | "height" | "move" | "edge"; type GripKind = "vertex" | "height" | "move" | "edge" | "roofpitch";
interface Grip { interface Grip {
kind: GripKind; kind: GripKind;
/** Element, dessen Griff das ist (Wand-ID, Drawing-ID ODER Decken-ID). */ /** Element, dessen Griff das ist (Wand-/Drawing-/Decken-/Dach-ID). */
target: { wallId?: string; drawingId?: string; ceilingId?: string }; target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string };
/** Vertex-Index (`vertex`) bzw. erster Kanten-Index (`edge`); -1 sonst. */ /** Vertex-Index (`vertex`) bzw. erster Kanten-Index (`edge`); -1 sonst. */
index: number; index: number;
/** Zweiter Kanten-Index (nur `kind==="edge"`). */ /** Zweiter Kanten-Index (nur `kind==="edge"`). */
@@ -106,6 +107,7 @@ function computeGrips(
walls: readonly Wall[], walls: readonly Wall[],
drawings: readonly Drawing2D[], drawings: readonly Drawing2D[],
ceilings: readonly Ceiling[], ceilings: readonly Ceiling[],
roofs: readonly Roof[],
drawingElevation: number, drawingElevation: number,
): Grip[] { ): Grip[] {
const out: Grip[] = []; const out: Grip[] = [];
@@ -153,6 +155,34 @@ function computeGrips(
}); });
out.push({ kind: "move", target, index: -1, pos: [sx / n, y + 0.03, sy / n] }); out.push({ kind: "move", target, index: -1, pos: [sx / n, y + 0.03, sy / n] });
} }
// Dächer: Eckpunkt-Griffe (Umriss = Traufe-Grundriss, resize) + Verschiebe-Griff
// + ein FIRST-/Neigungs-Griff am höchsten Punkt (vertikal ziehen ändert die
// Neigung). Eckpunkte auf Traufhöhe.
for (const roof of roofs) {
const pts = roof.outline;
const rn = pts.length;
if (rn < 4) continue;
const target = { roofId: roof.id };
const eavesZ = roofBaseElevation(project, roof);
let sx = 0;
let sy = 0;
pts.forEach((p, i) => {
out.push({ kind: "vertex", target, index: i, pos: [p.x, eavesZ + 0.02, p.y] });
sx += p.x;
sy += p.y;
});
out.push({ kind: "move", target, index: -1, pos: [sx / rn, eavesZ + 0.05, sy / rn] });
// First-/Neigungs-Griff am höchsten Dachpunkt (aus der 3D-Geometrie).
const g = roofGeometry(roof, eavesZ);
if (g.ridgeHeight > 0.01) {
let apex: readonly [number, number, number] | null = null;
for (const pl of g.planes) {
for (const v of pl.pts) if (!apex || v[2] > apex[2]) apex = v;
}
// g-Punkte sind MODELL [x, y, z=Höhe] → world [x, Höhe, y].
if (apex) out.push({ kind: "roofpitch", target, index: -1, pos: [apex[0], apex[2], apex[1]] });
}
}
return out; return out;
} }
@@ -297,9 +327,11 @@ export function Wasm3DViewport({
editWalls = [], editWalls = [],
editDrawings = [], editDrawings = [],
editCeilings = [], editCeilings = [],
editRoofs = [],
onEditVertex, onEditVertex,
onEditBody, onEditBody,
onEditEdge, onEditEdge,
onEditRoofPitch,
onEditWallTop, onEditWallTop,
onEditEnd, onEditEnd,
}: { }: {
@@ -340,19 +372,23 @@ export function Wasm3DViewport({
editDrawings?: Drawing2D[]; editDrawings?: Drawing2D[];
/** Gewählte Decken — Quelle der 3D-Eckpunkt-/Kanten-/Verschiebe-Griffe. */ /** Gewählte Decken — Quelle der 3D-Eckpunkt-/Kanten-/Verschiebe-Griffe. */
editCeilings?: Ceiling[]; editCeilings?: Ceiling[];
/** Gewählte Dächer — Quelle der 3D-Eckpunkt-/Verschiebe-/First-Griffe. */
editRoofs?: Roof[];
/** Endpunkt-Griff gezogen: neuer XY-Modellpunkt für den Vertex `index` des Elements. */ /** Endpunkt-Griff gezogen: neuer XY-Modellpunkt für den Vertex `index` des Elements. */
onEditVertex?: ( onEditVertex?: (
target: { wallId?: string; drawingId?: string; ceilingId?: string }, target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string },
index: number, index: number,
model: Vec2, model: Vec2,
) => Vec2; ) => Vec2;
/** Verschiebe-Griff gezogen: das Element um `delta` (XY) verschieben. */ /** Verschiebe-Griff gezogen: das Element um `delta` (XY) verschieben. */
onEditBody?: ( onEditBody?: (
target: { wallId?: string; drawingId?: string; ceilingId?: string }, target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string },
delta: Vec2, delta: Vec2,
) => void; ) => void;
/** Kanten-Griff einer Decke gezogen: Kante (aIndex→bIndex) um `delta` (XY) verschieben. */ /** Kanten-Griff einer Decke gezogen: Kante (aIndex→bIndex) um `delta` (XY) verschieben. */
onEditEdge?: (ceilingId: string, aIndex: number, bIndex: number, delta: Vec2) => void; onEditEdge?: (ceilingId: string, aIndex: number, bIndex: number, delta: Vec2) => void;
/** First-/Neigungs-Griff eines Dachs gezogen: neue absolute Firsthöhe (Z). */
onEditRoofPitch?: (roofId: string, apexZ: number) => void;
/** Höhen-Griff gezogen: neue absolute Oberkanten-Z der Wand. */ /** Höhen-Griff gezogen: neue absolute Oberkanten-Z der Wand. */
onEditWallTop?: (wallId: string, topZ: number) => void; onEditWallTop?: (wallId: string, topZ: number) => void;
/** Ende eines Griff-Drags. */ /** Ende eines Griff-Drags. */
@@ -373,12 +409,16 @@ export function Wasm3DViewport({
editDrawingsRef.current = editDrawings; editDrawingsRef.current = editDrawings;
const editCeilingsRef = useRef(editCeilings); const editCeilingsRef = useRef(editCeilings);
editCeilingsRef.current = editCeilings; editCeilingsRef.current = editCeilings;
const editRoofsRef = useRef(editRoofs);
editRoofsRef.current = editRoofs;
const onEditVertexRef = useRef(onEditVertex); const onEditVertexRef = useRef(onEditVertex);
onEditVertexRef.current = onEditVertex; onEditVertexRef.current = onEditVertex;
const onEditBodyRef = useRef(onEditBody); const onEditBodyRef = useRef(onEditBody);
onEditBodyRef.current = onEditBody; onEditBodyRef.current = onEditBody;
const onEditEdgeRef = useRef(onEditEdge); const onEditEdgeRef = useRef(onEditEdge);
onEditEdgeRef.current = onEditEdge; onEditEdgeRef.current = onEditEdge;
const onEditRoofPitchRef = useRef(onEditRoofPitch);
onEditRoofPitchRef.current = onEditRoofPitch;
const onEditWallTopRef = useRef(onEditWallTop); const onEditWallTopRef = useRef(onEditWallTop);
onEditWallTopRef.current = onEditWallTop; onEditWallTopRef.current = onEditWallTop;
const onEditEndRef = useRef(onEditEnd); const onEditEndRef = useRef(onEditEnd);
@@ -526,7 +566,10 @@ export function Wasm3DViewport({
useEffect(() => { useEffect(() => {
if ( if (
!ready || !ready ||
(editWalls.length === 0 && editDrawings.length === 0 && editCeilings.length === 0) (editWalls.length === 0 &&
editDrawings.length === 0 &&
editCeilings.length === 0 &&
editRoofs.length === 0)
) { ) {
setGripScreens([]); setGripScreens([]);
return; return;
@@ -544,6 +587,7 @@ export function Wasm3DViewport({
editWallsRef.current, editWallsRef.current,
editDrawingsRef.current, editDrawingsRef.current,
editCeilingsRef.current, editCeilingsRef.current,
editRoofsRef.current,
groundElevationRef.current, groundElevationRef.current,
); );
const next: { grip: Grip; x: number; y: number }[] = []; const next: { grip: Grip; x: number; y: number }[] = [];
@@ -558,7 +602,7 @@ export function Wasm3DViewport({
}; };
raf = requestAnimationFrame(tick); raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf);
}, [ready, editWalls, editDrawings, editCeilings]); }, [ready, editWalls, editDrawings, editCeilings, editRoofs]);
// Canvas-Größe beobachten: bei Layout-Änderung mit aktueller Kamera neu // Canvas-Größe beobachten: bei Layout-Änderung mit aktueller Kamera neu
// zeichnen (die Surface-Anpassung übernimmt der Hook im render()). // zeichnen (die Surface-Anpassung übernimmt der Hook im render()).
@@ -579,14 +623,15 @@ export function Wasm3DViewport({
// Pfad; "height" zieht auf einer VERTIKALEN Ebene entlang der Wandachse // Pfad; "height" zieht auf einer VERTIKALEN Ebene entlang der Wandachse
// (analog `heightZAt` dort), damit eine überwiegend vertikale Mausbewegung // (analog `heightZAt` dort), damit eine überwiegend vertikale Mausbewegung
// auf eine Höhenänderung abbildet. // auf eine Höhenänderung abbildet.
type GripTarget = { wallId?: string; drawingId?: string; ceilingId?: string }; type GripTarget = { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string };
type GripDrag = type GripDrag =
| { kind: "vertex"; target: GripTarget; index: number; planeY: number } | { kind: "vertex"; target: GripTarget; index: number; planeY: number }
| { kind: "move"; target: GripTarget; planeY: number; lastModel: Vec2 | null } | { kind: "move"; target: GripTarget; planeY: number; lastModel: Vec2 | null }
| { kind: "edge"; ceilingId: string; a: number; b: number; planeY: number; lastModel: Vec2 | null } | { kind: "edge"; ceilingId: string; a: number; b: number; planeY: number; lastModel: Vec2 | null }
| { kind: "height"; wallId: string }; | { kind: "height"; wallId: string }
| { kind: "roofpitch"; roofId: string; apexXZ: readonly [number, number] };
const gripDragRef = useRef<GripDrag | null>(null); const gripDragRef = useRef<GripDrag | null>(null);
// Arbeitsebene (world-Y) eines Griffs: Wand-UK, Decken-OK bzw. Geschossebene. // Arbeitsebene (world-Y) eines Griffs: Wand-UK, Decken-OK, Dach-Traufe bzw. Geschossebene.
const gripPlaneY = (target: GripTarget): number | null => { const gripPlaneY = (target: GripTarget): number | null => {
if (target.wallId) { if (target.wallId) {
const wall = editWallsRef.current.find((w) => w.id === target.wallId); const wall = editWallsRef.current.find((w) => w.id === target.wallId);
@@ -596,6 +641,10 @@ export function Wasm3DViewport({
const c = editCeilingsRef.current.find((x) => x.id === target.ceilingId); const c = editCeilingsRef.current.find((x) => x.id === target.ceilingId);
return c ? ceilingVerticalExtent(projectRef.current, c).zTop : null; return c ? ceilingVerticalExtent(projectRef.current, c).zTop : null;
} }
if (target.roofId) {
const r = editRoofsRef.current.find((x) => x.id === target.roofId);
return r ? roofBaseElevation(projectRef.current, r) : null;
}
return groundElevationRef.current; return groundElevationRef.current;
}; };
const startGripDrag = (grip: Grip) => (e: React.PointerEvent) => { const startGripDrag = (grip: Grip) => (e: React.PointerEvent) => {
@@ -605,6 +654,10 @@ export function Wasm3DViewport({
if (grip.kind === "height") { if (grip.kind === "height") {
if (!grip.target.wallId) return; if (!grip.target.wallId) return;
gripDragRef.current = { kind: "height", wallId: grip.target.wallId }; gripDragRef.current = { kind: "height", wallId: grip.target.wallId };
} else if (grip.kind === "roofpitch") {
if (!grip.target.roofId) return;
// Vertikaler Drag am First; XZ-Position (world) des Apex als Achse merken.
gripDragRef.current = { kind: "roofpitch", roofId: grip.target.roofId, apexXZ: [grip.pos[0], grip.pos[2]] };
} else { } else {
const planeY = gripPlaneY(grip.target); const planeY = gripPlaneY(grip.target);
if (planeY == null) return; if (planeY == null) return;
@@ -650,6 +703,22 @@ export function Wasm3DViewport({
return; return;
} }
if (drag.kind === "roofpitch") {
// Vertikaler First-Drag: Strahl auf eine senkrechte Ebene durch die
// Apex-Achse (world-XZ) projizieren, world-Y = neue First-Höhe abgreifen.
// Ebenen-Normale horizontal zur Kamera, damit der Drag stabil folgt.
const cam = orbitCamera(o);
let nx = cam.eye[0] - drag.apexXZ[0];
let nz = cam.eye[2] - drag.apexXZ[1];
const nlen = Math.hypot(nx, nz) || 1;
nx /= nlen;
nz /= nlen;
const hitR = rayPlane(ray, [drag.apexXZ[0], 0, drag.apexXZ[1]], [nx, 0, nz]);
if (!hitR) return;
onEditRoofPitchRef.current?.(drag.roofId, hitR[1]);
return;
}
const hit = rayPlaneY(ray, drag.planeY); const hit = rayPlaneY(ray, drag.planeY);
if (!hit) return; if (!hit) return;
const model: Vec2 = { x: hit[0], y: hit[2] }; const model: Vec2 = { x: hit[0], y: hit[2] };
@@ -917,7 +986,7 @@ export function Wasm3DViewport({
Verschieben grün. */} Verschieben grün. */}
{gripScreens.map(({ grip, x, y }) => ( {gripScreens.map(({ grip, x, y }) => (
<div <div
key={`${grip.kind}:${grip.target.wallId ?? grip.target.drawingId ?? grip.target.ceilingId}:${grip.index}`} key={`${grip.kind}:${grip.target.wallId ?? grip.target.drawingId ?? grip.target.ceilingId ?? grip.target.roofId}:${grip.index}`}
onPointerDown={startGripDrag(grip)} onPointerDown={startGripDrag(grip)}
onPointerMove={onGripPointerMove} onPointerMove={onGripPointerMove}
onPointerUp={endGripDrag} onPointerUp={endGripDrag}
@@ -925,6 +994,8 @@ export function Wasm3DViewport({
title={t( title={t(
grip.kind === "height" grip.kind === "height"
? "viewport3d.grip.height" ? "viewport3d.grip.height"
: grip.kind === "roofpitch"
? "viewport3d.grip.roofpitch"
: grip.kind === "move" : grip.kind === "move"
? "viewport3d.grip.move" ? "viewport3d.grip.move"
: grip.kind === "edge" : grip.kind === "edge"
@@ -944,6 +1015,8 @@ export function Wasm3DViewport({
background: background:
grip.kind === "height" grip.kind === "height"
? "#2f8fff" ? "#2f8fff"
: grip.kind === "roofpitch"
? "#ff4d9d"
: grip.kind === "move" : grip.kind === "move"
? "#18b85a" ? "#18b85a"
: grip.kind === "edge" : grip.kind === "edge"
@@ -951,7 +1024,8 @@ export function Wasm3DViewport({
: "#ff8c1a", : "#ff8c1a",
border: "2px solid rgba(0,0,0,0.6)", border: "2px solid rgba(0,0,0,0.6)",
boxShadow: "0 0 5px rgba(0,0,0,0.55)", boxShadow: "0 0 5px rgba(0,0,0,0.55)",
cursor: grip.kind === "height" ? "ns-resize" : "grab", cursor:
grip.kind === "height" || grip.kind === "roofpitch" ? "ns-resize" : "grab",
touchAction: "none", touchAction: "none",
}} }}
/> />