diff --git a/src/App.tsx b/src/App.tsx index 977fc6a..b8969c9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4615,6 +4615,18 @@ export default function App() { setSelectedRoofIds([]); return; } + if (hit.kind === "roof") { + setSelectedRoofIds([hit.id]); + setSelectedWallIds([]); + setSelectedCeilingIds([]); + setSelectedDrawingId(null); + setSelectedOpeningIds([]); + setSelectedStairIds([]); + setSelectedRoomIds([]); + setSelectedExtrudedSolidIds([]); + setSelectedColumnIds([]); + return; + } if (hit.kind === "wall") { setSelectedWallIds( additive ? Array.from(new Set([...selectedWallIds, hit.id])) : [hit.id], diff --git a/src/plan/toWalls3d.test.ts b/src/plan/toWalls3d.test.ts index 1759c6d..2b6148c 100644 --- a/src/plan/toWalls3d.test.ts +++ b/src/plan/toWalls3d.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect } from "vitest"; import type { Project, Roof } from "../model/types"; import { sampleProject } from "../model/sampleProject"; -import { selectionHighlightLines, projectToWalls3d, projectToModel3d } from "./toWalls3d"; +import { selectionHighlightLines, projectToWalls3d, projectToModel3d, pickGeometry } from "./toWalls3d"; import { resolveHatch } from "./generatePlan"; import { roofGeometry, roofBaseElevation } from "../geometry/roof"; @@ -1453,3 +1453,16 @@ describe("emitOpeningShading (Rollladen-/Sonnenschutzkasten als 3D-Box)", () => ).toBe(0); }); }); + +describe("pickGeometry — Dächer (Ray-Dreieck-Pick)", () => { + it("liefert je Dach World-Dreiecke (Flächen + Giebel), world=[x,Höhe,y]", () => { + const { roofs } = pickGeometry(sampleProject); + // sampleProject trägt das Demo-Satteldach RF1. + expect(roofs.length).toBe(1); + expect(roofs[0].roofId).toBe("RF1"); + // Sattel: 2 Flächen (je 4 Ecken → 2 Dreiecke) + 2 Giebel (je 1 Dreieck) = 6. + expect(roofs[0].tris.length).toBe(6); + // Höhe liegt in der WORLD-Y-Komponente (Index 1) — alle über der Traufe > 0. + for (const tri of roofs[0].tris) for (const p of tri) expect(p[1]).toBeGreaterThan(0); + }); +}); diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index c2dad63..0de7ec2 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -62,7 +62,10 @@ import type { WallCuts } from "../model/joins"; import { add, dot, leftNormal, lineIntersect, normalize, scale, sub } from "../model/geometry"; import type { Line } from "../model/geometry"; import { resolveHatch } from "./generatePlan"; -import type { PickOpening, PickStair } from "../viewport/raycast3d"; +import type { PickOpening, PickRoof, PickStair } from "../viewport/raycast3d"; + +/** World-Punkt [x, Höhe, y] für den Dach-Pick (Vec3-Konvention von raycast3d). */ +type RoofPickVec3 = [number, number, number]; /** * Achswinkel einer GESCHNITTENEN Wand im Schnitt-Koordinatensystem (Modell- @@ -2455,7 +2458,29 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R */ export function pickGeometry( project: Project, -): { walls: RWall[]; slabs: RSlab[]; openings: PickOpening[]; stairs: PickStair[] } { +): { + walls: RWall[]; + slabs: RSlab[]; + openings: PickOpening[]; + stairs: PickStair[]; + roofs: PickRoof[]; +} { + // Dächer: Dachflächen + Giebel als WORLD-Dreiecke (fan-trianguliert wie + // emitRoofs; model [x,y,z=Höhe] → world [x, z, y]) — präzise geneigte + // Flächen für den Ray-Dreieck-Pick (raycast3d.rayRoofTris). + const roofs: PickRoof[] = []; + for (const roof of project.roofs ?? []) { + const eavesZ = roofBaseElevation(project, roof); + const g = roofGeometry(roof, eavesZ); + const tris: [RoofPickVec3, RoofPickVec3, RoofPickVec3][] = []; + const toWorld = (p: RoofVec3): RoofPickVec3 => [p[0], p[2], p[1]]; + for (const poly of [...g.planes.map((pl) => pl.pts), ...g.gables]) { + for (let i = 1; i + 1 < poly.length; i++) { + tris.push([toWorld(poly[0]), toWorld(poly[i]), toWorld(poly[i + 1])]); + } + } + if (tris.length > 0) roofs.push({ tris, roofId: roof.id }); + } const openings: PickOpening[] = []; for (const o of project.openings ?? []) { const wall = project.walls.find((w) => w.id === o.hostWallId); @@ -2492,7 +2517,7 @@ export function pickGeometry( stairId: st.id, }); } - return { walls: projectToWalls3d(project), slabs: emitSlabs(project), openings, stairs }; + return { walls: projectToWalls3d(project), slabs: emitSlabs(project), openings, stairs, roofs }; } /** diff --git a/src/viewport/Wasm3DViewport.tsx b/src/viewport/Wasm3DViewport.tsx index f985b29..be0bbb5 100644 --- a/src/viewport/Wasm3DViewport.tsx +++ b/src/viewport/Wasm3DViewport.tsx @@ -55,9 +55,9 @@ import { cameraRay, pickNearest, rayPlaneY, rayPlane, worldToScreen } from "./ra import type { Vec3 } from "./raycast3d"; import { drawingGripVertices, drawingMoveAnchor } from "./drawingGrips"; -/** Ein per 3D-Klick getroffenes Bauteil (Wand oder Decke), oder null (Leerraum). */ +/** Ein per 3D-Klick getroffenes Bauteil, oder null (Leerraum). */ export type Pick3dHit = - | { kind: "wall" | "ceiling" | "opening" | "stair"; id: string } + | { kind: "wall" | "ceiling" | "opening" | "stair" | "roof"; id: string } | null; /** Klick-vs-Drag-Schwelle (Pixel): bleibt die Maus zwischen pointerdown und @@ -798,10 +798,10 @@ export function Wasm3DViewport({ const ndc = toNdc(clientX, clientY); if (!cb || !o || !ndc) return; const ray = cameraRay(orbitCamera(o), ndc.ndcX, ndc.ndcY, ndc.aspect); - const { walls, slabs, openings, stairs } = pickGeometry(projectRef.current); + const { walls, slabs, openings, stairs, roofs } = pickGeometry(projectRef.current); // Erweiterter Pick inkl. Öffnungen (Fenster/Türen) + Treppen — sonst wären // im WASM-Viewport nur Wände/Decken anwählbar. - const hit = pickNearest(ray, walls, slabs, openings, stairs); + const hit = pickNearest(ray, walls, slabs, openings, stairs, roofs); cb(hit ? { kind: hit.kind, id: hit.id } : null, additive); }; diff --git a/src/viewport/raycast3d.ts b/src/viewport/raycast3d.ts index 3427e42..eb39a9d 100644 --- a/src/viewport/raycast3d.ts +++ b/src/viewport/raycast3d.ts @@ -84,9 +84,21 @@ export interface PickStair { stairId: string; } +/** + * Minimal-Form eines Dachs für den Raycast: die Dachflächen + Giebel als + * WORLD-Dreiecke (fan-trianguliert, dieselbe Konvention wie `emitRoofs`: + * model (x,y,z=Höhe) → world (x, z, y)). Präzise geneigte Flächen statt eines + * grosszügigen Bounding-Prismas — sonst stähle das Dach Klicks auf die + * Fassade dahinter (pickNearest entscheidet über die Strahl-Distanz). + */ +export interface PickRoof { + tris: ReadonlyArray; + roofId: string; +} + /** Ergebnis eines Picks: getroffenes Bauteil + Distanz entlang des Strahls. */ export interface PickHit { - kind: "wall" | "ceiling" | "opening" | "stair"; + kind: "wall" | "ceiling" | "opening" | "stair" | "roof"; id: string; t: number; /** Bei `kind:"opening"` dieselbe Id wie `id` (ausdrucksstärkerer Zugriff). */ @@ -336,6 +348,20 @@ export function rayStairPrism(ray: Ray, stair: PickStair): number | null { return rayPrismXZ(ray, stair.outline, stair.zBottom, stair.zTop); } +/** + * Kleinstes t über alle Dreiecke eines Dachs, oder null — nutzt das bestehende + * doppelseitige {@link rayTriangle} (Dachflächen sind von oben UND unten + * anklickbar). + */ +function rayRoofTris(ray: Ray, roof: PickRoof): number | null { + let best: number | null = null; + for (const [a, b, c] of roof.tris) { + const t = rayTriangle(ray, a, b, c); + if (t !== null && (best === null || t < best)) best = t; + } + return best; +} + // Overload OHNE Öffnungen/Treppen: bleibt exakt die bisherige Signatur, sodass // der bestehende Aufrufer (Wasm3DViewport.tsx: `pickNearest(ray, walls, slabs)`) // unverändert kompiliert — inklusive des dort erwarteten engen `kind`-Typs @@ -346,13 +372,14 @@ export function pickNearest( walls: readonly PickWall[], slabs: readonly PickSlab[], ): (PickHit & { kind: "wall" | "ceiling" }) | null; -// Erweiterter Overload MIT Öffnungen/Treppen: `kind` deckt alle vier Arten ab. +// Erweiterter Overload MIT Öffnungen/Treppen/Dächern: `kind` deckt alle Arten ab. export function pickNearest( ray: Ray, walls: readonly PickWall[], slabs: readonly PickSlab[], openings: readonly PickOpening[], stairs?: readonly PickStair[], + roofs?: readonly PickRoof[], ): PickHit | null; /** * Nächstes getroffenes Bauteil (kleinstes t) über Wände, Decken, Öffnungen und @@ -369,6 +396,7 @@ export function pickNearest( slabs: readonly PickSlab[], openings: readonly PickOpening[] = [], stairs: readonly PickStair[] = [], + roofs: readonly PickRoof[] = [], ): PickHit | null { const TIE_EPS = 1e-6; let hit: PickHit | null = null; @@ -399,6 +427,12 @@ export function pickNearest( const t = rayStairPrism(ray, st); if (t !== null) consider({ kind: "stair", id: st.stairId, t, stairId: st.stairId }, 0); } + // Dächer: präzise geneigte Flächen (Dreiecke) — Rang wie Wand/Decke; die + // Distanz entscheidet, ob das Dach oder die Fassade dahinter gemeint ist. + for (const r of roofs) { + const t = rayRoofTris(ray, r); + if (t !== null) consider({ kind: "roof", id: r.roofId, t }, 1); + } return hit; }