Dach im 3D anwählbar: Ray-Dreieck-Pick über die Dachflächen
Der 3D-Pick kannte nur Wand/Decke/Öffnung/Treppe — Dächer waren im
wgpu-Viewport nicht anwählbar (Nutzer-Report). Neu: pickGeometry liefert je
Dach die Dachflächen + Giebel als World-Dreiecke (fan-trianguliert wie
emitRoofs); pickNearest testet sie über das bestehende doppelseitige
rayTriangle (präzise geneigte Flächen statt Bounding-Prisma — kein Klick-
Diebstahl vor der Fassade dahinter). App-Routing: kind 'roof' →
setSelectedRoofIds (exklusiv, wie Treppe/Öffnung); damit greifen Highlight
und die Dach-3D-Griffe (4ef40a0) jetzt auch per 3D-Klick. +1 Test. 690/690.
This commit is contained in:
+12
@@ -4615,6 +4615,18 @@ export default function App() {
|
|||||||
setSelectedRoofIds([]);
|
setSelectedRoofIds([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (hit.kind === "roof") {
|
||||||
|
setSelectedRoofIds([hit.id]);
|
||||||
|
setSelectedWallIds([]);
|
||||||
|
setSelectedCeilingIds([]);
|
||||||
|
setSelectedDrawingId(null);
|
||||||
|
setSelectedOpeningIds([]);
|
||||||
|
setSelectedStairIds([]);
|
||||||
|
setSelectedRoomIds([]);
|
||||||
|
setSelectedExtrudedSolidIds([]);
|
||||||
|
setSelectedColumnIds([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (hit.kind === "wall") {
|
if (hit.kind === "wall") {
|
||||||
setSelectedWallIds(
|
setSelectedWallIds(
|
||||||
additive ? Array.from(new Set([...selectedWallIds, hit.id])) : [hit.id],
|
additive ? Array.from(new Set([...selectedWallIds, hit.id])) : [hit.id],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import type { Project, Roof } from "../model/types";
|
import type { Project, Roof } from "../model/types";
|
||||||
import { sampleProject } from "../model/sampleProject";
|
import { sampleProject } from "../model/sampleProject";
|
||||||
import { selectionHighlightLines, projectToWalls3d, projectToModel3d } from "./toWalls3d";
|
import { selectionHighlightLines, projectToWalls3d, projectToModel3d, pickGeometry } from "./toWalls3d";
|
||||||
import { resolveHatch } from "./generatePlan";
|
import { resolveHatch } from "./generatePlan";
|
||||||
import { roofGeometry, roofBaseElevation } from "../geometry/roof";
|
import { roofGeometry, roofBaseElevation } from "../geometry/roof";
|
||||||
|
|
||||||
@@ -1453,3 +1453,16 @@ describe("emitOpeningShading (Rollladen-/Sonnenschutzkasten als 3D-Box)", () =>
|
|||||||
).toBe(0);
|
).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+28
-3
@@ -62,7 +62,10 @@ import type { WallCuts } from "../model/joins";
|
|||||||
import { add, dot, leftNormal, lineIntersect, normalize, scale, sub } from "../model/geometry";
|
import { add, dot, leftNormal, lineIntersect, normalize, scale, sub } from "../model/geometry";
|
||||||
import type { Line } from "../model/geometry";
|
import type { Line } from "../model/geometry";
|
||||||
import { resolveHatch } from "./generatePlan";
|
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-
|
* Achswinkel einer GESCHNITTENEN Wand im Schnitt-Koordinatensystem (Modell-
|
||||||
@@ -2455,7 +2458,29 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R
|
|||||||
*/
|
*/
|
||||||
export function pickGeometry(
|
export function pickGeometry(
|
||||||
project: Project,
|
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[] = [];
|
const openings: PickOpening[] = [];
|
||||||
for (const o of project.openings ?? []) {
|
for (const o of project.openings ?? []) {
|
||||||
const wall = project.walls.find((w) => w.id === o.hostWallId);
|
const wall = project.walls.find((w) => w.id === o.hostWallId);
|
||||||
@@ -2492,7 +2517,7 @@ export function pickGeometry(
|
|||||||
stairId: st.id,
|
stairId: st.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { walls: projectToWalls3d(project), slabs: emitSlabs(project), openings, stairs };
|
return { walls: projectToWalls3d(project), slabs: emitSlabs(project), openings, stairs, roofs };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ import { cameraRay, pickNearest, rayPlaneY, rayPlane, worldToScreen } from "./ra
|
|||||||
import type { Vec3 } from "./raycast3d";
|
import type { Vec3 } from "./raycast3d";
|
||||||
import { drawingGripVertices, drawingMoveAnchor } from "./drawingGrips";
|
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 =
|
export type Pick3dHit =
|
||||||
| { kind: "wall" | "ceiling" | "opening" | "stair"; id: string }
|
| { kind: "wall" | "ceiling" | "opening" | "stair" | "roof"; id: string }
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
/** Klick-vs-Drag-Schwelle (Pixel): bleibt die Maus zwischen pointerdown und
|
/** Klick-vs-Drag-Schwelle (Pixel): bleibt die Maus zwischen pointerdown und
|
||||||
@@ -798,10 +798,10 @@ export function Wasm3DViewport({
|
|||||||
const ndc = toNdc(clientX, clientY);
|
const ndc = toNdc(clientX, clientY);
|
||||||
if (!cb || !o || !ndc) return;
|
if (!cb || !o || !ndc) return;
|
||||||
const ray = cameraRay(orbitCamera(o), ndc.ndcX, ndc.ndcY, ndc.aspect);
|
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
|
// Erweiterter Pick inkl. Öffnungen (Fenster/Türen) + Treppen — sonst wären
|
||||||
// im WASM-Viewport nur Wände/Decken anwählbar.
|
// 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);
|
cb(hit ? { kind: hit.kind, id: hit.id } : null, additive);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -84,9 +84,21 @@ export interface PickStair {
|
|||||||
stairId: string;
|
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<readonly [Vec3, Vec3, Vec3]>;
|
||||||
|
roofId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Ergebnis eines Picks: getroffenes Bauteil + Distanz entlang des Strahls. */
|
/** Ergebnis eines Picks: getroffenes Bauteil + Distanz entlang des Strahls. */
|
||||||
export interface PickHit {
|
export interface PickHit {
|
||||||
kind: "wall" | "ceiling" | "opening" | "stair";
|
kind: "wall" | "ceiling" | "opening" | "stair" | "roof";
|
||||||
id: string;
|
id: string;
|
||||||
t: number;
|
t: number;
|
||||||
/** Bei `kind:"opening"` dieselbe Id wie `id` (ausdrucksstärkerer Zugriff). */
|
/** 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);
|
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
|
// Overload OHNE Öffnungen/Treppen: bleibt exakt die bisherige Signatur, sodass
|
||||||
// der bestehende Aufrufer (Wasm3DViewport.tsx: `pickNearest(ray, walls, slabs)`)
|
// der bestehende Aufrufer (Wasm3DViewport.tsx: `pickNearest(ray, walls, slabs)`)
|
||||||
// unverändert kompiliert — inklusive des dort erwarteten engen `kind`-Typs
|
// unverändert kompiliert — inklusive des dort erwarteten engen `kind`-Typs
|
||||||
@@ -346,13 +372,14 @@ export function pickNearest(
|
|||||||
walls: readonly PickWall[],
|
walls: readonly PickWall[],
|
||||||
slabs: readonly PickSlab[],
|
slabs: readonly PickSlab[],
|
||||||
): (PickHit & { kind: "wall" | "ceiling" }) | null;
|
): (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(
|
export function pickNearest(
|
||||||
ray: Ray,
|
ray: Ray,
|
||||||
walls: readonly PickWall[],
|
walls: readonly PickWall[],
|
||||||
slabs: readonly PickSlab[],
|
slabs: readonly PickSlab[],
|
||||||
openings: readonly PickOpening[],
|
openings: readonly PickOpening[],
|
||||||
stairs?: readonly PickStair[],
|
stairs?: readonly PickStair[],
|
||||||
|
roofs?: readonly PickRoof[],
|
||||||
): PickHit | null;
|
): PickHit | null;
|
||||||
/**
|
/**
|
||||||
* Nächstes getroffenes Bauteil (kleinstes t) über Wände, Decken, Öffnungen und
|
* Nächstes getroffenes Bauteil (kleinstes t) über Wände, Decken, Öffnungen und
|
||||||
@@ -369,6 +396,7 @@ export function pickNearest(
|
|||||||
slabs: readonly PickSlab[],
|
slabs: readonly PickSlab[],
|
||||||
openings: readonly PickOpening[] = [],
|
openings: readonly PickOpening[] = [],
|
||||||
stairs: readonly PickStair[] = [],
|
stairs: readonly PickStair[] = [],
|
||||||
|
roofs: readonly PickRoof[] = [],
|
||||||
): PickHit | null {
|
): PickHit | null {
|
||||||
const TIE_EPS = 1e-6;
|
const TIE_EPS = 1e-6;
|
||||||
let hit: PickHit | null = null;
|
let hit: PickHit | null = null;
|
||||||
@@ -399,6 +427,12 @@ export function pickNearest(
|
|||||||
const t = rayStairPrism(ray, st);
|
const t = rayStairPrism(ray, st);
|
||||||
if (t !== null) consider({ kind: "stair", id: st.stairId, t, stairId: st.stairId }, 0);
|
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;
|
return hit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user