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:
+49
-6
@@ -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<string>,
|
||||
@@ -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<string>,
|
||||
excludeDrawingIds?: Set<string>,
|
||||
) => 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}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1230,6 +1230,7 @@ export const en: Record<TranslationKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string>,
|
||||
@@ -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). */
|
||||
|
||||
@@ -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<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 => {
|
||||
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 }) => (
|
||||
<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)}
|
||||
onPointerMove={onGripPointerMove}
|
||||
onPointerUp={endGripDrag}
|
||||
@@ -925,6 +994,8 @@ export function Wasm3DViewport({
|
||||
title={t(
|
||||
grip.kind === "height"
|
||||
? "viewport3d.grip.height"
|
||||
: grip.kind === "roofpitch"
|
||||
? "viewport3d.grip.roofpitch"
|
||||
: grip.kind === "move"
|
||||
? "viewport3d.grip.move"
|
||||
: grip.kind === "edge"
|
||||
@@ -944,6 +1015,8 @@ export function Wasm3DViewport({
|
||||
background:
|
||||
grip.kind === "height"
|
||||
? "#2f8fff"
|
||||
: grip.kind === "roofpitch"
|
||||
? "#ff4d9d"
|
||||
: grip.kind === "move"
|
||||
? "#18b85a"
|
||||
: grip.kind === "edge"
|
||||
@@ -951,7 +1024,8 @@ export function Wasm3DViewport({
|
||||
: "#ff8c1a",
|
||||
border: "2px solid rgba(0,0,0,0.6)",
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user