From 973ac6d04fcaa715e79243e27da930317ebdd0aa Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 9 Jul 2026 01:39:17 +0200 Subject: [PATCH] Decken-Griffe im 3D: Eckpunkte + Kanten-Mittelpunkte (wgpu-Viewport) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gewählte Decken bekommen im wgpu-3D-Viewport editierbare Griffe — analog Wand/2D-Element: - Eckpunkt-Griffe (Umriss-Vertices) → moveCeilingGrip. - Je Seite ein Kanten-Mittelpunkt-Griff (rautenförmig, eigene Farbe) → moveCeilingEdge (Nutzer-Wunsch „an allen seiten auch ein punkt"). - Verschiebe-Griff im Schwerpunkt → moveCeilingBy. Alle drei Store-Actions existierten bereits (2D-Griffe); neu ist nur die 3D-Griffgeometrie (computeGrips), das Kanten-Drag (GripDrag „edge") und die Prop-Verdrahtung Wasm3DViewport ← Viewport3D ← App (editCeilings/onEditEdge, Griff-Ziel um ceilingId erweitert). Die three.js-Sicht bleibt unverändert (Default ist wgpu). tsc + vitest 620 grün. --- src/App.tsx | 44 +++++++++-- src/i18n/de.ts | 1 + src/i18n/en.ts | 1 + src/viewport/Viewport3D.tsx | 18 +++-- src/viewport/Wasm3DViewport.tsx | 136 ++++++++++++++++++++++++++------ 5 files changed, 164 insertions(+), 36 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3f90775..0adb4ba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ import { import { wallCorners } from "./model/geometry"; import { add, dot, leftNormal, normalize, scale, sub } from "./model/geometry"; import type { + Ceiling, CeilingType, ContextObject, DoorType, @@ -2458,6 +2459,17 @@ export default function App() { : [], [selectedWallIds, selectedDrawingIds, project.drawings2d], ); + // Gewählte Decken → 3D-Eckpunkt-/Kanten-/Verschiebe-Griffe. Nur wenn keine + // Wände/2D-Elemente mitselektiert sind (eindeutiger Editierfokus). + const edit3dCeilings = useMemo( + () => + selectedWallIds.length === 0 && selectedDrawingIds.length === 0 + ? selectedCeilingIds + .map((id) => (project.ceilings ?? []).find((c) => c.id === id)) + .filter((c): c is Ceiling => !!c) + : [], + [selectedWallIds, selectedDrawingIds, selectedCeilingIds, project.ceilings], + ); // 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` @@ -2494,7 +2506,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 }, + target: { wallId?: string; drawingId?: string; ceilingId?: string }, index: number, model: Vec2, excludeWallIds?: Set, @@ -2509,11 +2521,19 @@ export default function App() { ); pt = snapResult?.point ?? model; } - moveGripOf(target.drawingId ?? null, target.wallId ?? null, index, pt); + 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 }, delta: Vec2) => { - moveElementByOf(target.drawingId ?? null, target.wallId ?? null, delta); + const onEdit3dBody = ( + target: { wallId?: string; drawingId?: string; ceilingId?: string }, + delta: Vec2, + ) => { + if (target.ceilingId) moveCeilingBy(target.ceilingId, delta); + else moveElementByOf(target.drawingId ?? null, target.wallId ?? null, delta); + }; + const onEdit3dEdge = (ceilingId: string, aIndex: number, bIndex: number, delta: Vec2) => { + moveCeilingEdge(ceilingId, aIndex, bIndex, delta); }; const onEdit3dWallTop = (wallId: string, topZ: number) => { updateWall(wallId, { top: { mode: "custom", z: topZ } }); @@ -4731,8 +4751,10 @@ export default function App() { gripHandlers={gripHandlers} edit3dWalls={edit3dWalls} edit3dDrawings={edit3dDrawings} + edit3dCeilings={edit3dCeilings} onEdit3dVertex={onEdit3dVertex} onEdit3dBody={onEdit3dBody} + onEdit3dEdge={onEdit3dEdge} onEdit3dWallTop={onEdit3dWallTop} onEdit3dEnd={onEdit3dEnd} /> @@ -5839,8 +5861,10 @@ function Content({ gripHandlers, edit3dWalls, edit3dDrawings, + edit3dCeilings, onEdit3dVertex, onEdit3dBody, + onEdit3dEdge, onEdit3dWallTop, onEdit3dEnd, }: { @@ -5923,14 +5947,20 @@ function Content({ edit3dWalls: Wall[]; /** Alle gewählten 2D-Elemente — 3D-Vertex-Griffe (Mehrfachauswahl ⇒ alle). */ edit3dDrawings: Drawing2D[]; + /** Alle gewählten Decken — 3D-Eckpunkt-/Kanten-/Verschiebe-Griffe. */ + edit3dCeilings: Ceiling[]; onEdit3dVertex: ( - target: { wallId?: string; drawingId?: string }, + target: { wallId?: string; drawingId?: string; ceilingId?: string }, index: number, model: Vec2, excludeWallIds?: Set, excludeDrawingIds?: Set, ) => Vec2; - onEdit3dBody: (target: { wallId?: string; drawingId?: string }, delta: Vec2) => void; + onEdit3dBody: ( + target: { wallId?: string; drawingId?: string; ceilingId?: string }, + delta: Vec2, + ) => void; + onEdit3dEdge: (ceilingId: string, aIndex: number, bIndex: number, delta: Vec2) => void; onEdit3dWallTop: (wallId: string, topZ: number) => void; onEdit3dEnd: () => void; }) { @@ -6104,8 +6134,10 @@ function Content({ onWorkplaneConfirm={onWorkplaneConfirm} editWalls={edit3dWalls} editDrawings={edit3dDrawings} + editCeilings={edit3dCeilings} onEditVertex={onEdit3dVertex} onEditBody={onEdit3dBody} + onEditEdge={onEdit3dEdge} onEditWallTop={onEdit3dWallTop} onEditEnd={onEdit3dEnd} /> diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 3d91741..2c092fa 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -1209,6 +1209,7 @@ export const de = { "viewport3d.grip.vertex": "Wandende ziehen", "viewport3d.grip.height": "Höhe ziehen", "viewport3d.grip.move": "Verschieben", + "viewport3d.grip.edge": "Kante ziehen", // ── Element-Baum (Elemente-Panel, ROADMAP §11) ────────────────────────────── "elements.title": "Elemente", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 029b8b3..00ff0e8 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1195,6 +1195,7 @@ export const en: Record = { "viewport3d.section.show": "Section plane on", "viewport3d.section.hide": "Section plane off", "viewport3d.grip.vertex": "Drag wall endpoint", + "viewport3d.grip.edge": "Drag edge", "viewport3d.grip.height": "Drag height", "viewport3d.grip.move": "Move", diff --git a/src/viewport/Viewport3D.tsx b/src/viewport/Viewport3D.tsx index 878279d..4e08a60 100644 --- a/src/viewport/Viewport3D.tsx +++ b/src/viewport/Viewport3D.tsx @@ -152,8 +152,10 @@ export function Viewport3D( // wie die three.js-Sicht (renderer-agnostisch). editWalls={props.editWalls} editDrawings={props.editDrawings} + editCeilings={props.editCeilings} onEditVertex={props.onEditVertex} onEditBody={props.onEditBody} + onEditEdge={props.onEditEdge} onEditWallTop={props.onEditWallTop} onEditEnd={props.onEditEnd} /> @@ -276,23 +278,29 @@ function ThreeViewport3D({ editWalls?: Wall[]; /** Alle gewählten 2D-Elemente — Vertex-Griffe in 3D (Mehrfachauswahl ⇒ alle). */ editDrawings?: Drawing2D[]; + /** Alle gewählten Decken — Eckpunkt-/Kanten-/Verschiebe-Griffe (nur wgpu-Sicht). */ + editCeilings?: Ceiling[]; /** * Endpunkt-/Vertex-Griff gezogen: neuer XY-Modellpunkt für den Vertex `index` - * des durch `target` benannten Elements (Wand-ID ODER Drawing-ID). Optional - * `excludeWallIds`/`excludeDrawingIds` = Koinzidenz-Gruppe, die aus den + * des durch `target` benannten Elements (Wand-ID, Drawing-ID ODER Decken-ID). + * Optional `excludeWallIds`/`excludeDrawingIds` = Koinzidenz-Gruppe, die aus den * Snap-Quellen genommen wird; nur dann snappt App den Punkt. Liefert den * effektiv angewandten (gesnappten) Punkt zurück (für Marker + Mitführen). - * Wird auf dieselbe Store-Action (`moveGripOf`) gelegt wie die Plan-Griffe. */ onEditVertex?: ( - target: { wallId?: string; drawingId?: string }, + target: { wallId?: string; drawingId?: string; ceilingId?: string }, index: number, model: Vec2, excludeWallIds?: Set, excludeDrawingIds?: Set, ) => Vec2; /** Verschiebe-Griff gezogen: das `target`-Element um `delta` (XY) verschieben. */ - onEditBody?: (target: { wallId?: string; drawingId?: string }, delta: Vec2) => void; + onEditBody?: ( + target: { wallId?: string; drawingId?: string; ceilingId?: 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; /** 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 2243c9a..e939aa7 100644 --- a/src/viewport/Wasm3DViewport.tsx +++ b/src/viewport/Wasm3DViewport.tsx @@ -43,8 +43,8 @@ // 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, Vec2 } from "../model/types"; -import { wallVerticalExtent } from "../model/wall"; +import type { Project, Wall, Drawing2D, Ceiling, Vec2 } from "../model/types"; +import { ceilingVerticalExtent, wallVerticalExtent } from "../model/wall"; import { projectToModel3d, pickGeometry, TRUCK_MESH_READY_EVENT } from "../plan/toWalls3d"; import type { RenderMode, View3d } from "../ui/TopBar"; import { t } from "../i18n"; @@ -82,13 +82,15 @@ 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"; +type GripKind = "vertex" | "height" | "move" | "edge"; interface Grip { kind: GripKind; - /** Element, dessen Griff das ist (Wand-ID ODER Drawing-ID). */ - target: { wallId?: string; drawingId?: string }; - /** Vertex-Index (nur `kind==="vertex"`); -1 sonst. */ + /** Element, dessen Griff das ist (Wand-ID, Drawing-ID ODER Decken-ID). */ + target: { wallId?: string; drawingId?: string; ceilingId?: string }; + /** Vertex-Index (`vertex`) bzw. erster Kanten-Index (`edge`); -1 sonst. */ index: number; + /** Zweiter Kanten-Index (nur `kind==="edge"`). */ + index2?: number; pos: Vec3; } @@ -103,6 +105,7 @@ function computeGrips( project: Project, walls: readonly Wall[], drawings: readonly Drawing2D[], + ceilings: readonly Ceiling[], drawingElevation: number, ): Grip[] { const out: Grip[] = []; @@ -124,6 +127,32 @@ function computeGrips( const anchor = drawingMoveAnchor(d, verts); if (anchor) out.push({ kind: "move", target, index: -1, pos: [anchor.x, y + 0.03, anchor.y] }); } + // Decken: Eckpunkt-Griffe (Umriss) + je Seite ein Kanten-Mittelpunkt-Griff + // (Nutzer-Wunsch „an allen seiten auch ein punkt … wie an den eckpunkten") + + // ein Verschiebe-Griff im Schwerpunkt. Griffe sitzen auf der Deckenoberkante. + for (const c of ceilings) { + const pts = c.outline; + const n = pts.length; + if (n < 2) continue; + const target = { ceilingId: c.id }; + const y = ceilingVerticalExtent(project, c).zTop + 0.02; + let sx = 0; + let sy = 0; + pts.forEach((p, i) => { + out.push({ kind: "vertex", target, index: i, pos: [p.x, y, p.y] }); + const q = pts[(i + 1) % n]; + out.push({ + kind: "edge", + target, + index: i, + index2: (i + 1) % n, + pos: [(p.x + q.x) / 2, y, (p.y + q.y) / 2], + }); + sx += p.x; + sy += p.y; + }); + out.push({ kind: "move", target, index: -1, pos: [sx / n, y + 0.03, sy / n] }); + } return out; } @@ -266,8 +295,10 @@ export function Wasm3DViewport({ highlightLines = null, editWalls = [], editDrawings = [], + editCeilings = [], onEditVertex, onEditBody, + onEditEdge, onEditWallTop, onEditEnd, }: { @@ -304,14 +335,21 @@ export function Wasm3DViewport({ editWalls?: Wall[]; /** Gewählte 2D-Zeichnungen — Quelle der 3D-Vertex-/Verschiebe-Griffe. */ editDrawings?: Drawing2D[]; + /** Gewählte Decken — Quelle der 3D-Eckpunkt-/Kanten-/Verschiebe-Griffe. */ + editCeilings?: Ceiling[]; /** Endpunkt-Griff gezogen: neuer XY-Modellpunkt für den Vertex `index` des Elements. */ onEditVertex?: ( - target: { wallId?: string; drawingId?: string }, + target: { wallId?: string; drawingId?: string; ceilingId?: string }, index: number, model: Vec2, ) => Vec2; /** Verschiebe-Griff gezogen: das Element um `delta` (XY) verschieben. */ - onEditBody?: (target: { wallId?: string; drawingId?: string }, delta: Vec2) => void; + onEditBody?: ( + target: { wallId?: string; drawingId?: string; ceilingId?: 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; /** Höhen-Griff gezogen: neue absolute Oberkanten-Z der Wand. */ onEditWallTop?: (wallId: string, topZ: number) => void; /** Ende eines Griff-Drags. */ @@ -330,10 +368,14 @@ export function Wasm3DViewport({ editWallsRef.current = editWalls; const editDrawingsRef = useRef(editDrawings); editDrawingsRef.current = editDrawings; + const editCeilingsRef = useRef(editCeilings); + editCeilingsRef.current = editCeilings; const onEditVertexRef = useRef(onEditVertex); onEditVertexRef.current = onEditVertex; const onEditBodyRef = useRef(onEditBody); onEditBodyRef.current = onEditBody; + const onEditEdgeRef = useRef(onEditEdge); + onEditEdgeRef.current = onEditEdge; const onEditWallTopRef = useRef(onEditWallTop); onEditWallTopRef.current = onEditWallTop; const onEditEndRef = useRef(onEditEnd); @@ -477,7 +519,10 @@ export function Wasm3DViewport({ // einen WASM-Push auslösen (reine 2D-Projektionsmathe, kein GPU-Aufruf). const [gripScreens, setGripScreens] = useState<{ grip: Grip; x: number; y: number }[]>([]); useEffect(() => { - if (!ready || (editWalls.length === 0 && editDrawings.length === 0)) { + if ( + !ready || + (editWalls.length === 0 && editDrawings.length === 0 && editCeilings.length === 0) + ) { setGripScreens([]); return; } @@ -493,6 +538,7 @@ export function Wasm3DViewport({ projectRef.current, editWallsRef.current, editDrawingsRef.current, + editCeilingsRef.current, groundElevationRef.current, ); const next: { grip: Grip; x: number; y: number }[] = []; @@ -507,7 +553,7 @@ export function Wasm3DViewport({ }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); - }, [ready, editWalls, editDrawings]); + }, [ready, editWalls, editDrawings, editCeilings]); // Canvas-Größe beobachten: bei Layout-Änderung mit aktueller Kamera neu // zeichnen (die Surface-Anpassung übernimmt der Hook im render()). @@ -528,11 +574,25 @@ 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 GripDrag = - | { kind: "vertex"; target: { wallId?: string; drawingId?: string }; index: number; planeY: number } - | { kind: "move"; target: { wallId?: string; drawingId?: string }; planeY: number; lastModel: Vec2 | null } + | { 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 }; const gripDragRef = useRef(null); + // Arbeitsebene (world-Y) eines Griffs: Wand-UK, Decken-OK bzw. Geschossebene. + const gripPlaneY = (target: GripTarget): number | null => { + if (target.wallId) { + const wall = editWallsRef.current.find((w) => w.id === target.wallId); + return wall ? wallVerticalExtent(projectRef.current, wall).zBottom : null; + } + if (target.ceilingId) { + const c = editCeilingsRef.current.find((x) => x.id === target.ceilingId); + return c ? ceilingVerticalExtent(projectRef.current, c).zTop : null; + } + return groundElevationRef.current; + }; const startGripDrag = (grip: Grip) => (e: React.PointerEvent) => { if (e.button !== 0) return; e.preventDefault(); @@ -541,18 +601,22 @@ export function Wasm3DViewport({ if (!grip.target.wallId) return; gripDragRef.current = { kind: "height", wallId: grip.target.wallId }; } else { - let planeY: number; - if (grip.target.wallId) { - const wall = editWallsRef.current.find((w) => w.id === grip.target.wallId); - if (!wall) return; - planeY = wallVerticalExtent(projectRef.current, wall).zBottom; + const planeY = gripPlaneY(grip.target); + if (planeY == null) return; + if (grip.kind === "vertex") { + gripDragRef.current = { kind: "vertex", target: grip.target, index: grip.index, planeY }; + } else if (grip.kind === "edge" && grip.target.ceilingId && grip.index2 != null) { + gripDragRef.current = { + kind: "edge", + ceilingId: grip.target.ceilingId, + a: grip.index, + b: grip.index2, + planeY, + lastModel: null, + }; } else { - planeY = groundElevationRef.current; + gripDragRef.current = { kind: "move", target: grip.target, planeY, lastModel: null }; } - gripDragRef.current = - grip.kind === "vertex" - ? { kind: "vertex", target: grip.target, index: grip.index, planeY } - : { kind: "move", target: grip.target, planeY, lastModel: null }; } (e.target as Element).setPointerCapture(e.pointerId); }; @@ -588,6 +652,16 @@ export function Wasm3DViewport({ onEditVertexRef.current?.(drag.target, drag.index, model); return; } + if (drag.kind === "edge") { + // Kanten-Griff (Decke): inkrementelles Delta auf beide Kanten-Endpunkte. + if (drag.lastModel) { + const delta: Vec2 = { x: model.x - drag.lastModel.x, y: model.y - drag.lastModel.y }; + if (delta.x !== 0 || delta.y !== 0) + onEditEdgeRef.current?.(drag.ceilingId, drag.a, drag.b, delta); + } + drag.lastModel = model; + return; + } // "move": inkrementelles Delta wie im three.js-Pendant (workplaneModel + lastModel). if (drag.lastModel) { const delta: Vec2 = { x: model.x - drag.lastModel.x, y: model.y - drag.lastModel.y }; @@ -838,7 +912,7 @@ export function Wasm3DViewport({ Verschieben grün. */} {gripScreens.map(({ grip, x, y }) => (