From 4ef40a02c44b105e2bb7f505986618dc07fec653 Mon Sep 17 00:00:00 2001 From: Karim Date: Fri, 10 Jul 2026 01:02:47 +0200 Subject: [PATCH] =?UTF-8?q?Dach-3D-Griffe:=20Eckpunkt-Resize=20+=20Verschi?= =?UTF-8?q?eben=20+=20First-Griff=20f=C3=BCr=20Neigung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/App.tsx | 55 ++++++++++++++++-- src/i18n/de.ts | 1 + src/i18n/en.ts | 1 + src/state/projectSlice.ts | 46 +++++++++++++++ src/state/roofGrips.test.ts | 91 +++++++++++++++++++++++++++++ src/viewport/Viewport3D.tsx | 11 +++- src/viewport/Wasm3DViewport.tsx | 100 +++++++++++++++++++++++++++----- 7 files changed, 284 insertions(+), 21 deletions(-) create mode 100644 src/state/roofGrips.test.ts diff --git a/src/App.tsx b/src/App.tsx index cafd421..5078cce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,6 +38,7 @@ import type { Layout, OverrideRule, Project, + Roof, Stair, StairType, Vec2, @@ -51,6 +52,7 @@ import { computeSection } from "./plan/toSection"; import type { SectionOutput } from "./plan/toSection"; import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync"; import { selectionHighlightLines } from "./plan/toWalls3d"; +import { roofBaseElevation, roofGeometry } from "./geometry/roof"; import { parseDxf } from "./io/dxfParser"; import type { DxfImportResult } from "./io/dxfParser"; import { setDxfImportTrigger } from "./io/dxfImportHook"; @@ -575,6 +577,8 @@ export default function App() { const setSelectedRoofIds = useStore((s) => s.setSelectedRoofIds); const selectedRoofId = selectedRoofIds.length === 1 ? selectedRoofIds[0] : null; 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 setSelectedOpeningIds = useStore((s) => s.setSelectedOpeningIds); const selectedOpeningId = @@ -2486,6 +2490,19 @@ export default function App() { : [], [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. // Das/die gerade GEZOGENEN Element(e) werden aus den Snap-Quellen genommen // (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 // den Punkt direkt an, ohne erneut zu snappen. const onEdit3dVertex = ( - target: { wallId?: string; drawingId?: string; ceilingId?: string }, + target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string }, index: number, model: Vec2, excludeWallIds?: Set, @@ -2537,17 +2554,35 @@ export default function App() { ); 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); return pt; }; const onEdit3dBody = ( - target: { wallId?: string; drawingId?: string; ceilingId?: string }, + target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string }, 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); }; + // 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) => { moveCeilingEdge(ceilingId, aIndex, bIndex, delta); }; @@ -4759,9 +4794,11 @@ export default function App() { edit3dWalls={edit3dWalls} edit3dDrawings={edit3dDrawings} edit3dCeilings={edit3dCeilings} + edit3dRoofs={edit3dRoofs} onEdit3dVertex={onEdit3dVertex} onEdit3dBody={onEdit3dBody} onEdit3dEdge={onEdit3dEdge} + onEdit3dRoofPitch={onEdit3dRoofPitch} onEdit3dWallTop={onEdit3dWallTop} onEdit3dEnd={onEdit3dEnd} /> @@ -5876,9 +5913,11 @@ function Content({ edit3dWalls, edit3dDrawings, edit3dCeilings, + edit3dRoofs, onEdit3dVertex, onEdit3dBody, onEdit3dEdge, + onEdit3dRoofPitch, onEdit3dWallTop, onEdit3dEnd, }: { @@ -5964,18 +6003,20 @@ function Content({ edit3dDrawings: Drawing2D[]; /** Alle gewählten Decken — 3D-Eckpunkt-/Kanten-/Verschiebe-Griffe. */ edit3dCeilings: Ceiling[]; + edit3dRoofs: Roof[]; onEdit3dVertex: ( - target: { wallId?: string; drawingId?: string; ceilingId?: string }, + target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string }, index: number, model: Vec2, excludeWallIds?: Set, excludeDrawingIds?: Set, ) => Vec2; onEdit3dBody: ( - target: { wallId?: string; drawingId?: string; ceilingId?: string }, + target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string }, 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; onEdit3dEnd: () => void; }) { @@ -6156,9 +6197,11 @@ function Content({ editWalls={edit3dWalls} editDrawings={edit3dDrawings} editCeilings={edit3dCeilings} + editRoofs={edit3dRoofs} onEditVertex={onEdit3dVertex} onEditBody={onEdit3dBody} onEditEdge={onEdit3dEdge} + onEditRoofPitch={onEdit3dRoofPitch} onEditWallTop={onEdit3dWallTop} onEditEnd={onEdit3dEnd} /> diff --git a/src/i18n/de.ts b/src/i18n/de.ts index ee6c99b..dec9dac 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -1242,6 +1242,7 @@ export const de = { "viewport3d.grip.height": "Höhe ziehen", "viewport3d.grip.move": "Verschieben", "viewport3d.grip.edge": "Kante ziehen", + "viewport3d.grip.roofpitch": "Dachneigung ziehen", // ── Element-Baum (Elemente-Panel, ROADMAP §11) ────────────────────────────── "elements.title": "Elemente", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 817c59c..716d866 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1230,6 +1230,7 @@ export const en: Record = { "viewport3d.grip.edge": "Drag edge", "viewport3d.grip.height": "Drag height", "viewport3d.grip.move": "Move", + "viewport3d.grip.roofpitch": "Drag roof pitch", // ── Element tree (Elements panel, ROADMAP §11) ────────────────────────────── "elements.title": "Elements", diff --git a/src/state/projectSlice.ts b/src/state/projectSlice.ts index 3e703a8..5931cc2 100644 --- a/src/state/projectSlice.ts +++ b/src/state/projectSlice.ts @@ -240,6 +240,10 @@ export interface ProjectSlice { id: string, patch: Partial, ) => 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) => diff --git a/src/state/roofGrips.test.ts b/src/state/roofGrips.test.ts new file mode 100644 index 0000000..6a1f196 --- /dev/null +++ b/src/state/roofGrips.test.ts @@ -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 }, + ]); + }); +}); diff --git a/src/viewport/Viewport3D.tsx b/src/viewport/Viewport3D.tsx index ea3b8ab..4e0144d 100644 --- a/src/viewport/Viewport3D.tsx +++ b/src/viewport/Viewport3D.tsx @@ -15,6 +15,7 @@ import type { DrawingLevel, Opening, Project, + Roof, Stair, Vec2, Wall, @@ -154,9 +155,11 @@ export function Viewport3D( editWalls={props.editWalls} editDrawings={props.editDrawings} editCeilings={props.editCeilings} + editRoofs={props.editRoofs} onEditVertex={props.onEditVertex} onEditBody={props.onEditBody} onEditEdge={props.onEditEdge} + onEditRoofPitch={props.onEditRoofPitch} onEditWallTop={props.onEditWallTop} onEditEnd={props.onEditEnd} /> @@ -291,7 +294,7 @@ function ThreeViewport3D({ * effektiv angewandten (gesnappten) Punkt zurück (für Marker + Mitführen). */ onEditVertex?: ( - target: { wallId?: string; drawingId?: string; ceilingId?: string }, + target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string }, index: number, model: Vec2, excludeWallIds?: Set, @@ -299,11 +302,15 @@ function ThreeViewport3D({ ) => Vec2; /** Verschiebe-Griff gezogen: das `target`-Element um `delta` (XY) verschieben. */ onEditBody?: ( - target: { wallId?: string; drawingId?: string; ceilingId?: string }, + target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string }, delta: Vec2, ) => void; /** Kanten-Griff einer Decke gezogen: Kante (aIndex→bIndex) um `delta` verschieben. */ 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. */ onEditWallTop?: (wallId: string, topZ: number) => void; /** Ende eines Griff-Drags (Commit/Aufräumen). */ diff --git a/src/viewport/Wasm3DViewport.tsx b/src/viewport/Wasm3DViewport.tsx index e99dbe1..f985b29 100644 --- a/src/viewport/Wasm3DViewport.tsx +++ b/src/viewport/Wasm3DViewport.tsx @@ -43,8 +43,9 @@ // Pixel an der Viewport-Höhe normiert), damit sich beide Pfade gleich anfühlen. 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 { roofBaseElevation, roofGeometry } from "../geometry/roof"; import { projectToModel3d, pickGeometry, TRUCK_MESH_READY_EVENT } from "../plan/toWalls3d"; import type { DetailLevel, RenderMode, View3d } from "../ui/TopBar"; import { t } from "../i18n"; @@ -82,11 +83,11 @@ const CLICK_THRESHOLD_PX = 4; // GUI-Element aus. Kosten: die Bildschirm-Position muss der Kamera folgen — // `useEffect` unten pollt sie per `requestAnimationFrame`, solange Griffe // aktiv sind (billige 2D-Projektion, kein WASM/GPU-Aufruf). -type GripKind = "vertex" | "height" | "move" | "edge"; +type GripKind = "vertex" | "height" | "move" | "edge" | "roofpitch"; interface Grip { kind: GripKind; - /** Element, dessen Griff das ist (Wand-ID, Drawing-ID ODER Decken-ID). */ - target: { wallId?: string; drawingId?: string; ceilingId?: string }; + /** Element, dessen Griff das ist (Wand-/Drawing-/Decken-/Dach-ID). */ + target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string }; /** Vertex-Index (`vertex`) bzw. erster Kanten-Index (`edge`); -1 sonst. */ index: number; /** Zweiter Kanten-Index (nur `kind==="edge"`). */ @@ -106,6 +107,7 @@ function computeGrips( walls: readonly Wall[], drawings: readonly Drawing2D[], ceilings: readonly Ceiling[], + roofs: readonly Roof[], drawingElevation: number, ): 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] }); } + // 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; } @@ -297,9 +327,11 @@ export function Wasm3DViewport({ editWalls = [], editDrawings = [], editCeilings = [], + editRoofs = [], onEditVertex, onEditBody, onEditEdge, + onEditRoofPitch, onEditWallTop, onEditEnd, }: { @@ -340,19 +372,23 @@ export function Wasm3DViewport({ editDrawings?: Drawing2D[]; /** Gewählte Decken — Quelle der 3D-Eckpunkt-/Kanten-/Verschiebe-Griffe. */ 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. */ onEditVertex?: ( - target: { wallId?: string; drawingId?: string; ceilingId?: string }, + target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string }, index: number, model: Vec2, ) => Vec2; /** Verschiebe-Griff gezogen: das Element um `delta` (XY) verschieben. */ onEditBody?: ( - target: { wallId?: string; drawingId?: string; ceilingId?: string }, + target: { wallId?: string; drawingId?: string; ceilingId?: string; roofId?: string }, delta: Vec2, ) => void; /** Kanten-Griff einer Decke gezogen: Kante (aIndex→bIndex) um `delta` (XY) verschieben. */ 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. */ onEditWallTop?: (wallId: string, topZ: number) => void; /** Ende eines Griff-Drags. */ @@ -373,12 +409,16 @@ export function Wasm3DViewport({ editDrawingsRef.current = editDrawings; const editCeilingsRef = useRef(editCeilings); editCeilingsRef.current = editCeilings; + const editRoofsRef = useRef(editRoofs); + editRoofsRef.current = editRoofs; const onEditVertexRef = useRef(onEditVertex); onEditVertexRef.current = onEditVertex; const onEditBodyRef = useRef(onEditBody); onEditBodyRef.current = onEditBody; const onEditEdgeRef = useRef(onEditEdge); onEditEdgeRef.current = onEditEdge; + const onEditRoofPitchRef = useRef(onEditRoofPitch); + onEditRoofPitchRef.current = onEditRoofPitch; const onEditWallTopRef = useRef(onEditWallTop); onEditWallTopRef.current = onEditWallTop; const onEditEndRef = useRef(onEditEnd); @@ -526,7 +566,10 @@ export function Wasm3DViewport({ useEffect(() => { if ( !ready || - (editWalls.length === 0 && editDrawings.length === 0 && editCeilings.length === 0) + (editWalls.length === 0 && + editDrawings.length === 0 && + editCeilings.length === 0 && + editRoofs.length === 0) ) { setGripScreens([]); return; @@ -544,6 +587,7 @@ export function Wasm3DViewport({ editWallsRef.current, editDrawingsRef.current, editCeilingsRef.current, + editRoofsRef.current, groundElevationRef.current, ); const next: { grip: Grip; x: number; y: number }[] = []; @@ -558,7 +602,7 @@ export function Wasm3DViewport({ }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); - }, [ready, editWalls, editDrawings, editCeilings]); + }, [ready, editWalls, editDrawings, editCeilings, editRoofs]); // Canvas-Größe beobachten: bei Layout-Änderung mit aktueller Kamera neu // 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 // (analog `heightZAt` dort), damit eine überwiegend vertikale Mausbewegung // 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 = | { kind: "vertex"; target: GripTarget; index: number; planeY: number } | { kind: "move"; target: GripTarget; 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(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 => { if (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); 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; }; const startGripDrag = (grip: Grip) => (e: React.PointerEvent) => { @@ -605,6 +654,10 @@ export function Wasm3DViewport({ if (grip.kind === "height") { if (!grip.target.wallId) return; 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 { const planeY = gripPlaneY(grip.target); if (planeY == null) return; @@ -650,6 +703,22 @@ export function Wasm3DViewport({ 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); if (!hit) return; const model: Vec2 = { x: hit[0], y: hit[2] }; @@ -917,7 +986,7 @@ export function Wasm3DViewport({ Verschieben grün. */} {gripScreens.map(({ grip, x, y }) => (