diff --git a/src/App.tsx b/src/App.tsx index 945b2a5..713a4bc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -63,6 +63,7 @@ import type { } from "./plan/PlanView"; import { Viewport3D } from "./viewport/Viewport3D"; import type { DisplayResolver, ViewportContextInfo } from "./viewport/Viewport3D"; +import type { Pick3dHit } from "./viewport/Wasm3DViewport"; import { ResourceManager } from "./ui/ResourceManager"; import type { ResourceManagerHandlers } from "./ui/ResourceManager"; import { HatchSwatch } from "./ui/hatchPreview"; @@ -2933,6 +2934,38 @@ export default function App() { setSelectedRoomIds([]); } }; + // Links-KLICK im Nordstern-3D-Viewport (WASM, TS-Raycast): getroffenes Bauteil + // wählen, sodass das Attribute-/Objekt-Info-Panel es zeigt (erster Schritt zur + // 3D-Editierbarkeit). `additive` (Shift/Ctrl) = zur Auswahl DERSELBEN Art + // hinzufügen, sonst ersetzen; beim Setzen die ANDEREN Arten-Arrays leeren + // (Einzel-Selektion über Arten hinweg, konsistent mit dem 2D-Verhalten). + // Leertreffer (kein Bauteil) → alle Selektionen leeren. + const onViewport3dPick = (hit: Pick3dHit, additive: boolean) => { + if (!hit) { + setSelectedWallIds([]); + setSelectedCeilingIds([]); + setSelectedDrawingId(null); + setSelectedOpeningIds([]); + setSelectedStairIds([]); + setSelectedRoomIds([]); + return; + } + if (hit.kind === "wall") { + setSelectedWallIds( + additive ? Array.from(new Set([...selectedWallIds, hit.id])) : [hit.id], + ); + setSelectedCeilingIds([]); + } else { + setSelectedCeilingIds( + additive ? Array.from(new Set([...selectedCeilingIds, hit.id])) : [hit.id], + ); + setSelectedWallIds([]); + } + setSelectedDrawingId(null); + setSelectedOpeningIds([]); + setSelectedStairIds([]); + setSelectedRoomIds([]); + }; // Rechts-Klick im 3D: Wand-Kontextmenü an der Cursorposition. const onViewportContextMenu = (info: ViewportContextInfo) => { if (info.wallId && !selectedWallIds.includes(info.wallId)) { @@ -3136,6 +3169,7 @@ export default function App() { onViewportSelectOpening={onViewportSelectOpening} onViewportSelectStair={onViewportSelectStair} onViewportContextMenu={onViewportContextMenu} + onViewport3dPick={onViewport3dPick} commandActive={commandActive} draft={draft} onWorkplanePoint={onWorkplanePoint} @@ -4099,6 +4133,7 @@ function Content({ onViewportSelectOpening, onViewportSelectStair, onViewportContextMenu, + onViewport3dPick, commandActive, draft, onWorkplanePoint, @@ -4172,6 +4207,8 @@ function Content({ onViewportSelectOpening: (openingId: string | null) => void; onViewportSelectStair: (stairId: string | null) => void; onViewportContextMenu: (info: ViewportContextInfo) => void; + /** Links-KLICK-Auswahl im WASM-3D-Viewport (TS-Raycast, Wand/Decke). */ + onViewport3dPick: (hit: Pick3dHit, additive: boolean) => void; /** Läuft ein zeichnender Befehl? Macht den 3D-Viewport zur Erstellungs-Fläche. */ commandActive: boolean; /** Aktuelle Werkzeug-Vorschau (Rubber-Band) — auch in 3D gespiegelt. */ @@ -4329,6 +4366,7 @@ function Content({ onSelectOpening={onViewportSelectOpening} onSelectStair={onViewportSelectStair} onContextMenu={onViewportContextMenu} + onPick3d={onViewport3dPick} commandActive={commandActive} draft={draft} onWorkplanePoint={onWorkplanePoint} diff --git a/src/plan/toWalls3d.ts b/src/plan/toWalls3d.ts index 4ebcac9..cbe1c74 100644 --- a/src/plan/toWalls3d.ts +++ b/src/plan/toWalls3d.ts @@ -49,6 +49,13 @@ export interface RWall { color: RRgb; /** Aufgelöster Schichtaufbau (leer = kein WallType auflösbar, siehe emitWall). */ layers: RLayer[]; + /** + * Id der Ursprungs-Wand (`project.walls[].id`). Alle Öffnungs-Teilquader einer + * Wand tragen dieselbe Id. Rein für den TS-seitigen Pick-Raycast (raycast3d); + * der render3d/Serde-Pfad ignoriert das Feld (kein `deny_unknown_fields`, + * serde verwirft unbekannte Felder). + */ + wallId: string; } /** Eine extrudierte Deckenplatte: geschlossener Grundriss-Umriss + Z-Ausdehnung. */ @@ -57,6 +64,12 @@ export interface RSlab { zBottom: number; zTop: number; color: RRgb; + /** + * Id der Ursprungs-Decke (`project.ceilings[].id`). Nur für den TS-seitigen + * Pick-Raycast (raycast3d); der render3d/Serde-Pfad ignoriert das Feld + * (kein `deny_unknown_fields`). + */ + ceilingId: string; } /** Art eines rohen Kontext-Meshes (bestimmt in render3d die Default-Einfärbung). */ @@ -122,6 +135,7 @@ function pushSegment( zTop: number, thickness: number, layers: RLayer[], + wallId: string, ): void { if (to - from <= EPS) return; if (zTop - zBottom <= EPS) return; @@ -141,6 +155,7 @@ function pushSegment( baseElevation: zBottom, color: WALL_RGB, layers, + wallId, }); } @@ -190,7 +205,7 @@ function emitWall(out: RWall[], project: Project, wall: Wall): void { // Ohne Aussparungen: ein durchgehender Quader (wie bisher). if (cutouts.length === 0) { - pushSegment(out, wall, 0, axisLen, zBottom, zTop, thickness, layers); + pushSegment(out, wall, 0, axisLen, zBottom, zTop, thickness, layers, wall.id); return; } @@ -202,23 +217,23 @@ function emitWall(out: RWall[], project: Project, wall: Wall): void { const segFrom = Math.max(cursor, from); if (from > cursor) { // Voller Wandpfeiler bis zur Aussparung. - pushSegment(out, wall, cursor, from, zBottom, zTop, thickness, layers); + pushSegment(out, wall, cursor, from, zBottom, zTop, thickness, layers, wall.id); } if (to > segFrom) { // Brüstung unter der Öffnung (bei Türen entfällt sie, da oBottom == zBottom). if (oBottom > zBottom + EPS) { - pushSegment(out, wall, segFrom, to, zBottom, oBottom, thickness, layers); + pushSegment(out, wall, segFrom, to, zBottom, oBottom, thickness, layers, wall.id); } // Sturz über der Öffnung (bis zum Wandkopf). if (oTop < zTop - EPS) { - pushSegment(out, wall, segFrom, to, oTop, zTop, thickness, layers); + pushSegment(out, wall, segFrom, to, oTop, zTop, thickness, layers, wall.id); } } cursor = Math.max(cursor, to); } // Restlicher Wandpfeiler bis zum Achsenende. if (cursor < axisLen) { - pushSegment(out, wall, cursor, axisLen, zBottom, zTop, thickness, layers); + pushSegment(out, wall, cursor, axisLen, zBottom, zTop, thickness, layers, wall.id); } } @@ -236,6 +251,7 @@ function emitSlabs(project: Project): RSlab[] { zBottom, zTop, color: SLAB_RGB, + ceilingId: c.id, }); } return out; @@ -283,3 +299,15 @@ export function projectToModel3d(project: Project): RModel3d { meshes: emitMeshes(project), }; } + +/** + * Pick-Geometrie für den TS-seitigen 3D-Raycast (siehe viewport/raycast3d): die + * Wand-Teilquader (mit `wallId`) und Decken-Prismen (mit `ceilingId`) desselben + * geflachten Modells wie {@link projectToModel3d}, aber ohne die Kontext-Meshes + * (die sind für die Bauteil-Auswahl irrelevant). Reine TS-Nutzung — geht NICHT an + * render3d; die Records tragen dieselben Geometriefelder wie die Serde-Payload, + * plus die Ids fürs Picking. + */ +export function pickGeometry(project: Project): { walls: RWall[]; slabs: RSlab[] } { + return { walls: projectToWalls3d(project), slabs: emitSlabs(project) }; +} diff --git a/src/viewport/Viewport3D.tsx b/src/viewport/Viewport3D.tsx index 0907c6c..5c08e48 100644 --- a/src/viewport/Viewport3D.tsx +++ b/src/viewport/Viewport3D.tsx @@ -45,6 +45,7 @@ import { } from "../model/wall"; import { MaterialRuntime } from "../materials/runtime"; import { Wasm3DViewport } from "./Wasm3DViewport"; +import type { Pick3dHit } from "./Wasm3DViewport"; import type { RenderMode, View3d } from "../ui/TopBar"; import type { ToolDraft } from "../tools/types"; import { applyAngleConstraint } from "../tools/snapping"; @@ -124,9 +125,12 @@ export function Viewport3D( /** Klick auf den Grid-Umschalter-Button im WASM-Viewport — Single Source * of Truth bleibt der Aufrufer (App/viewSlice `grid3dVisible`). */ onToggleGrid?: () => void; + /** Links-Klick-Auswahl im WASM-Viewport (TS-Raycast) — wirkt NUR im WASM-Pfad; + * die three.js-Sicht nutzt weiter `onSelectWall`/`onSelectCeiling`. */ + onPick3d?: (hit: Pick3dHit, additive: boolean) => void; }, ) { - const { gridVisible, onToggleGrid, ...threeProps } = props; + const { gridVisible, onToggleGrid, onPick3d, ...threeProps } = props; if (WASM_ENGINE_ACTIVE) { return ( ); } diff --git a/src/viewport/Wasm3DViewport.tsx b/src/viewport/Wasm3DViewport.tsx index 5689bf3..4764a3d 100644 --- a/src/viewport/Wasm3DViewport.tsx +++ b/src/viewport/Wasm3DViewport.tsx @@ -44,11 +44,19 @@ import { useEffect, useRef } from "react"; import type { Project } from "../model/types"; -import { projectToModel3d } from "../plan/toWalls3d"; +import { projectToModel3d, pickGeometry } from "../plan/toWalls3d"; import type { RenderMode, View3d } from "../ui/TopBar"; import { t } from "../i18n"; import { useWasm3dRenderer } from "./useWasm3dRenderer"; import type { Camera3d } from "./useWasm3dRenderer"; +import { cameraRay, pickNearest } from "./raycast3d"; + +/** Ein per 3D-Klick getroffenes Bauteil (Wand oder Decke), oder null (Leerraum). */ +export type Pick3dHit = { kind: "wall" | "ceiling"; id: string } | null; + +/** Klick-vs-Drag-Schwelle (Pixel): bleibt die Maus zwischen pointerdown und + * pointerup darunter, gilt die Links-Geste als KLICK (Pick) statt als Orbit-Drag. */ +const CLICK_THRESHOLD_PX = 4; /** Vertikaler Öffnungswinkel (Radiant) — wie die three.js-PerspectiveCamera (50°), * auch für die FREIE Orbit-Kamera hier (die Presets selbst nutzen den Rust- @@ -167,6 +175,7 @@ export function Wasm3DViewport({ gridVisible = true, groundElevation = 0, onToggleGrid, + onPick3d, }: { project: Project; /** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */ @@ -183,8 +192,20 @@ export function Wasm3DViewport({ groundElevation?: number; /** Klick auf den Grid-Umschalter-Button; ohne Angabe kein Button. */ onToggleGrid?: () => void; + /** + * Links-KLICK (kein Drag) auf ein Bauteil: meldet das per TS-Raycast getroffene + * Bauteil (Wand/Decke) oder null (Leerraum → Auswahl leeren). `additive` = + * Shift/Ctrl/Meta gedrückt (zur Auswahl hinzufügen statt ersetzen). Der + * Aufrufer (App) setzt daraus die Store-Selektion; ohne Angabe passiert nichts. + */ + onPick3d?: (hit: Pick3dHit, additive: boolean) => void; }) { const canvasRef = useRef(null); + // Pick-Callback in einem Ref spiegeln, damit der (einmal registrierte) + // Pointer-Listener stets den aktuellen liest (additive-Logik in App liest + // die frische Store-Auswahl), ohne den Listener-Effekt neu aufzubauen. + const onPick3dRef = useRef(onPick3d); + onPick3dRef.current = onPick3d; const { render, updateModel, @@ -273,16 +294,52 @@ export function Wasm3DViewport({ }; }; - let drag: { mode: "orbit" | "pan"; x: number; y: number } | null = null; + let drag: + | { + mode: "orbit" | "pan"; + button: number; + x: number; + y: number; + downX: number; + downY: number; + moved: boolean; + } + | null = null; const redraw = () => redrawRef.current(); + // Cursor-Pixel → Kamerastrahl (aktuelle Orbit-Kamera) → nächstes Bauteil. + // Meldet den Treffer (oder null) an den Aufrufer; `additive` wird vom + // pointerup-Modifier bestimmt. Frische Pick-Geometrie je Klick (selten genug). + const doPick = (clientX: number, clientY: number, additive: boolean) => { + const cb = onPick3dRef.current; + const o = orbitRef.current; + if (!cb || !o) return; + const rect = canvas.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1; + const ndcY = 1 - ((clientY - rect.top) / rect.height) * 2; // Pixel-Y invertiert + const aspect = rect.width / rect.height; + const ray = cameraRay(orbitCamera(o), ndcX, ndcY, aspect); + const { walls, slabs } = pickGeometry(projectRef.current); + const hit = pickNearest(ray, walls, slabs); + cb(hit ? { kind: hit.kind, id: hit.id } : null, additive); + }; + const onPointerDown = (e: PointerEvent) => { // LINKS (0) = Orbit, MITTE (1)/RECHTS (2) = Pan (s. MAUS-SCHEMA oben). const mode = e.button === 0 ? "orbit" : e.button === 1 || e.button === 2 ? "pan" : null; if (!mode) return; e.preventDefault(); - drag = { mode, x: e.clientX, y: e.clientY }; + drag = { + mode, + button: e.button, + x: e.clientX, + y: e.clientY, + downX: e.clientX, + downY: e.clientY, + moved: false, + }; canvas.setPointerCapture(e.pointerId); useOrbitRedraw(); }; @@ -290,6 +347,13 @@ export function Wasm3DViewport({ const onPointerMove = (e: PointerEvent) => { const o = orbitRef.current; if (!drag || !o) return; + // Bewegung über die Schwelle ⇒ echter Drag (kein Klick beim Loslassen). + if ( + !drag.moved && + Math.hypot(e.clientX - drag.downX, e.clientY - drag.downY) >= CLICK_THRESHOLD_PX + ) { + drag.moved = true; + } const dx = e.clientX - drag.x; const dy = e.clientY - drag.y; drag.x = e.clientX; @@ -326,14 +390,30 @@ export function Wasm3DViewport({ redraw(); }; - const endDrag = (e: PointerEvent) => { - if (!drag) return; + // Cleanup einer Geste (Capture lösen, Drag-Zustand fallenlassen). + const releaseDrag = (e: PointerEvent) => { drag = null; if (canvas.hasPointerCapture(e.pointerId)) { canvas.releasePointerCapture(e.pointerId); } }; + // Loslassen: war es ein LINKS-Klick ohne nennenswerte Bewegung (kein Orbit), + // löse einen Pick aus. Sonst nur aufräumen. additive = Shift/Ctrl/Meta. + const onPointerUp = (e: PointerEvent) => { + if (!drag) return; + const wasClick = drag.button === 0 && drag.mode === "orbit" && !drag.moved; + const additive = e.shiftKey || e.ctrlKey || e.metaKey; + releaseDrag(e); + if (wasClick) doPick(e.clientX, e.clientY, additive); + }; + + // Abgebrochene Geste (z. B. Fensterwechsel) — nie als Klick werten. + const onPointerCancel = (e: PointerEvent) => { + if (!drag) return; + releaseDrag(e); + }; + const onWheel = (e: WheelEvent) => { const o = orbitRef.current; if (!o) return; @@ -383,15 +463,15 @@ export function Wasm3DViewport({ canvas.addEventListener("pointerdown", onPointerDown); canvas.addEventListener("pointermove", onPointerMove); - canvas.addEventListener("pointerup", endDrag); - canvas.addEventListener("pointercancel", endDrag); + canvas.addEventListener("pointerup", onPointerUp); + canvas.addEventListener("pointercancel", onPointerCancel); canvas.addEventListener("wheel", onWheel, { passive: false }); canvas.addEventListener("contextmenu", onContextMenu); return () => { canvas.removeEventListener("pointerdown", onPointerDown); canvas.removeEventListener("pointermove", onPointerMove); - canvas.removeEventListener("pointerup", endDrag); - canvas.removeEventListener("pointercancel", endDrag); + canvas.removeEventListener("pointerup", onPointerUp); + canvas.removeEventListener("pointercancel", onPointerCancel); canvas.removeEventListener("wheel", onWheel); canvas.removeEventListener("contextmenu", onContextMenu); }; diff --git a/src/viewport/raycast3d.test.ts b/src/viewport/raycast3d.test.ts new file mode 100644 index 0000000..011fe31 --- /dev/null +++ b/src/viewport/raycast3d.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import { + cameraRay, + rayWallBox, + raySlabPrism, + pickNearest, + type RayCamera, + type PickWall, + type PickSlab, +} from "./raycast3d"; + +// Kamera bei (0,1,10), Blick entlang -Z auf (0,1,0). Zentraler Strahl (ndc 0,0) +// zeigt in -Z. World-Konvention: Modell (x,y) → world (x, elevation, y). +const CAM: RayCamera = { + eye: [0, 1, 10], + target: [0, 1, 0], + up: [0, 1, 0], + fovY: Math.PI / 4, +}; + +describe("cameraRay", () => { + it("Zentralstrahl zeigt entlang der Blickrichtung", () => { + const ray = cameraRay(CAM, 0, 0, 16 / 9); + expect(ray.origin).toEqual([0, 1, 10]); + expect(ray.dir[0]).toBeCloseTo(0, 6); + expect(ray.dir[1]).toBeCloseTo(0, 6); + expect(ray.dir[2]).toBeCloseTo(-1, 6); + }); + + it("positives ndcX weicht nach rechts (+X), ndcY nach oben (+Y) aus", () => { + const ray = cameraRay(CAM, 0.5, 0.5, 1); + expect(ray.dir[0]).toBeGreaterThan(0); + expect(ray.dir[1]).toBeGreaterThan(0); + expect(ray.dir[2]).toBeLessThan(0); + // normalisiert + expect(Math.hypot(...ray.dir)).toBeCloseTo(1, 6); + }); +}); + +// Wand: Achse model (-1,5)→(1,5) → world x∈[-1,1], z≈5. Dicke 0.2, Höhe 2 ab 0. +const WALL: PickWall = { + start: [-1, 5], + end: [1, 5], + thickness: 0.2, + height: 2, + baseElevation: 0, + wallId: "w1", +}; + +describe("rayWallBox", () => { + it("Treffer liefert die Eintritts-Distanz (Vorderfläche)", () => { + // Strahl von (0,1,10) entlang -Z trifft die Front bei z=5.1 → t=4.9. + const t = rayWallBox({ origin: [0, 1, 10], dir: [0, 0, -1] }, WALL); + expect(t).not.toBeNull(); + expect(t!).toBeCloseTo(4.9, 6); + }); + + it("Strahl neben der Wand verfehlt", () => { + const t = rayWallBox({ origin: [5, 1, 10], dir: [0, 0, -1] }, WALL); + expect(t).toBeNull(); + }); + + it("Strahl über der Wandhöhe verfehlt", () => { + const t = rayWallBox({ origin: [0, 5, 10], dir: [0, 0, -1] }, WALL); + expect(t).toBeNull(); + }); +}); + +// Decke: Quadrat model (0,0),(4,0),(4,4),(0,4), Platte z 2.9..3.0. +const SLAB: PickSlab = { + outline: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + zBottom: 2.9, + zTop: 3.0, + ceilingId: "c1", +}; + +describe("raySlabPrism", () => { + it("Strahl von oben trifft den Deckel (kleinstes t)", () => { + // eye (2,10,2) senkrecht nach unten: Deckel y=3 bei t=7. + const t = raySlabPrism({ origin: [2, 10, 2], dir: [0, -1, 0] }, SLAB); + expect(t).not.toBeNull(); + expect(t!).toBeCloseTo(7, 6); + }); + + it("Strahl neben dem Umriss verfehlt", () => { + const t = raySlabPrism({ origin: [10, 10, 2], dir: [0, -1, 0] }, SLAB); + expect(t).toBeNull(); + }); + + it("horizontaler Strahl trifft eine Seitenfläche", () => { + // Von (-5, 2.95, 2) entlang +X: Seitenquad bei x=0 in Höhe 2.9..3 → t=5. + const t = raySlabPrism({ origin: [-5, 2.95, 2], dir: [1, 0, 0] }, SLAB); + expect(t).not.toBeNull(); + expect(t!).toBeCloseTo(5, 6); + }); +}); + +describe("pickNearest", () => { + it("das nähere Bauteil gewinnt (Wand vor Decke)", () => { + // Ray -Z: Wand (front z=5.1, t=4.9). Decke als vertikale Wand weiter hinten. + const farSlab: PickSlab = { + outline: [ + [-1, 0], + [1, 0], + [1, 0.01], + [-1, 0.01], + ], + zBottom: 0, + zTop: 2, + ceilingId: "c-far", + }; + const hit = pickNearest({ origin: [0, 1, 10], dir: [0, 0, -1] }, [WALL], [farSlab]); + expect(hit).not.toBeNull(); + expect(hit!.kind).toBe("wall"); + expect(hit!.id).toBe("w1"); + }); + + it("Leertreffer liefert null", () => { + const hit = pickNearest({ origin: [50, 50, 10], dir: [0, 0, -1] }, [WALL], [SLAB]); + expect(hit).toBeNull(); + }); +}); diff --git a/src/viewport/raycast3d.ts b/src/viewport/raycast3d.ts new file mode 100644 index 0000000..05f8afd --- /dev/null +++ b/src/viewport/raycast3d.ts @@ -0,0 +1,254 @@ +// Reine Raycast-Mathematik für die 3D-Auswahl im Nordstern-Viewport (WASM/ +// WebGPU-Pfad). Bewusst OHNE React/three.js/Engine-Abhängigkeit — nur Vektor- +// Arithmetik auf den geflachten Pick-Records (siehe `pickGeometry` in +// ../plan/toWalls3d.ts). So bleibt der Kern unit-testbar (raycast3d.test.ts) +// und der Klick-Pfad im Viewport dünn (Cursor → NDC → cameraRay → pickNearest). +// +// WELT-KONVENTION (identisch zu render3d / toWalls3d): Modell (x,y) → world +// (x, elevation, y); Höhe entlang +Y. Der Grundriss liegt also in der XZ-Ebene, +// vertikal wird über die world-Y-Achse extrudiert. +// +// KAMERA: perspektivischer Pinhole-Strahl aus eye durch das Cursor-Pixel. Für +// die freie Orbit-Kamera (iso/perspektivisch bzw. nach jeder manuellen +// Navigation) ist das exakt. Für die achsparallelen Presets (front/top/side), +// die render3d orthografisch zeichnet, ist es eine perspektivische Näherung — +// der Blickwinkel stimmt (VIEW3D_ORBIT), die Parallaxe nicht ganz. Ausreichend +// für die Bauteil-Auswahl; ein exakter Ortho-Strahl kann später ergänzt werden. + +export type Vec3 = [number, number, number]; + +/** Kamera-Parameter für den Pick-Strahl (Teilmenge von Camera3d). */ +export interface RayCamera { + eye: readonly [number, number, number]; + target: readonly [number, number, number]; + up: readonly [number, number, number]; + /** Vertikaler Öffnungswinkel in RADIANT. */ + fovY: number; +} + +/** Ein Strahl in Weltkoordinaten (dir normalisiert). */ +export interface Ray { + origin: Vec3; + dir: Vec3; +} + +/** Minimal-Form eines Wand-Quaders für den Raycast (RWall erfüllt sie strukturell). */ +export interface PickWall { + start: readonly [number, number]; + end: readonly [number, number]; + thickness: number; + height: number; + baseElevation: number; + wallId: string; +} + +/** Minimal-Form eines Decken-Prismas für den Raycast (RSlab erfüllt sie strukturell). */ +export interface PickSlab { + outline: ReadonlyArray; + zBottom: number; + zTop: number; + ceilingId: string; +} + +/** Ergebnis eines Picks: getroffenes Bauteil + Distanz entlang des Strahls. */ +export interface PickHit { + kind: "wall" | "ceiling"; + id: string; + t: number; +} + +const EPS = 1e-9; + +function sub(a: Vec3, b: Vec3): Vec3 { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +} +function cross(a: Vec3, b: Vec3): Vec3 { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; +} +function dot(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} +function norm(a: Vec3): Vec3 { + const l = Math.hypot(a[0], a[1], a[2]) || 1; + return [a[0] / l, a[1] / l, a[2] / l]; +} + +/** + * Perspektivischer Kamerastrahl durch ein NDC-Pixel. `ndcX`/`ndcY` ∈ [-1,1] + * (X links→rechts, Y unten→oben — der Aufrufer invertiert die Pixel-Y-Achse). + * `aspect` = Breite/Höhe des Canvas. Standard-Pinhole: Kamerabasis f/s/u aus + * eye/target/up, Richtung = f + s·(ndcX·aspect·tan(fovY/2)) + u·(ndcY·tan(fovY/2)). + */ +export function cameraRay( + cam: RayCamera, + ndcX: number, + ndcY: number, + aspect: number, +): Ray { + const eye = cam.eye as Vec3; + const f = norm(sub(cam.target as Vec3, eye)); // Blickrichtung + const s = norm(cross(f, cam.up as Vec3)); // rechts + const u = cross(s, f); // echtes Kamera-oben + const th = Math.tan(cam.fovY / 2); + const dir = norm([ + f[0] + s[0] * (ndcX * aspect * th) + u[0] * (ndcY * th), + f[1] + s[1] * (ndcX * aspect * th) + u[1] * (ndcY * th), + f[2] + s[2] * (ndcX * aspect * th) + u[2] * (ndcY * th), + ]); + return { origin: [eye[0], eye[1], eye[2]], dir }; +} + +/** + * Schnitt-Distanz des Strahls mit dem orientierten Quader (OBB) einer Wand, oder + * null. Der Quader ist exakt: Achse start→end in der XZ-Ebene (Länge), Normale + * dazu (Dicke), world-Y (Höhe baseElevation..baseElevation+height). Slab-Methode + * im OBB-Rahmen (Real-Time Rendering). Rückgabe = nichtnegative Eintritts-t (bzw. + * Austritts-t, falls der Ursprung im Quader liegt). + */ +export function rayWallBox(ray: Ray, wall: PickWall): number | null { + const ax = wall.end[0] - wall.start[0]; + const az = wall.end[1] - wall.start[1]; + const len = Math.hypot(ax, az); + if (len < EPS || wall.height <= EPS || wall.thickness <= EPS) return null; + const axis: [Vec3, Vec3, Vec3] = [ + [ax / len, 0, az / len], // entlang der Wandachse + [0, 1, 0], // Höhe + [az / len, 0, -ax / len], // Normale (Dicke), senkrecht in XZ + ]; + const half = [len / 2, wall.height / 2, wall.thickness / 2]; + const center: Vec3 = [ + (wall.start[0] + wall.end[0]) / 2, + wall.baseElevation + wall.height / 2, + (wall.start[1] + wall.end[1]) / 2, + ]; + const p = sub(center, ray.origin); + let tmin = -Infinity; + let tmax = Infinity; + for (let i = 0; i < 3; i++) { + const e = dot(axis[i], p); + const fdir = dot(axis[i], ray.dir); + const h = half[i]; + if (Math.abs(fdir) > EPS) { + let t1 = (e + h) / fdir; + let t2 = (e - h) / fdir; + if (t1 > t2) { + const tmp = t1; + t1 = t2; + t2 = tmp; + } + if (t1 > tmin) tmin = t1; + if (t2 < tmax) tmax = t2; + if (tmin > tmax) return null; + if (tmax < 0) return null; + } else if (-e - h > 0 || -e + h < 0) { + // Strahl parallel zu diesem Flächenpaar und ausserhalb → kein Treffer. + return null; + } + } + return tmin > 0 ? tmin : tmax; +} + +/** Punkt-in-Polygon (XZ-Ebene), klassisches Ray-Casting (ungerade Kreuzungen). */ +function pointInPolyXZ( + x: number, + z: number, + poly: ReadonlyArray, +): boolean { + let inside = false; + const n = poly.length; + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = poly[i][0]; + const zi = poly[i][1]; + const xj = poly[j][0]; + const zj = poly[j][1]; + const intersect = + zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi; + if (intersect) inside = !inside; + } + return inside; +} + +/** Ray-Dreieck (Möller–Trumbore), doppelseitig; nichtnegative t oder null. */ +function rayTriangle(ray: Ray, a: Vec3, b: Vec3, c: Vec3): number | null { + const e1 = sub(b, a); + const e2 = sub(c, a); + const pv = cross(ray.dir, e2); + const det = dot(e1, pv); + if (Math.abs(det) < EPS) return null; // parallel + const inv = 1 / det; + const tv = sub(ray.origin, a); + const uu = dot(tv, pv) * inv; + if (uu < 0 || uu > 1) return null; + const qv = cross(tv, e1); + const vv = dot(ray.dir, qv) * inv; + if (vv < 0 || uu + vv > 1) return null; + const t = dot(e2, qv) * inv; + return t >= 0 ? t : null; +} + +/** + * Schnitt-Distanz des Strahls mit dem extrudierten Umriss-Prisma einer Decke, + * oder null. GENAUIGKEIT: exakt für ein einfaches (nicht selbst-schneidendes) + * Polygon — Deckel/Boden (y=zTop/zBottom) über Punkt-in-Polygon (XZ), die + * Seitenflächen als vertikale Quads (je zwei Ray-Dreiecke). Kleinstes + * nichtnegatives t gewinnt. + */ +export function raySlabPrism(ray: Ray, slab: PickSlab): number | null { + const n = slab.outline.length; + if (n < 3 || slab.zTop - slab.zBottom <= EPS) return null; + let best = Infinity; + const consider = (t: number | null) => { + if (t !== null && t >= 0 && t < best) best = t; + }; + // Deckel + Boden (horizontale Ebenen) via Punkt-in-Polygon. + for (const y of [slab.zBottom, slab.zTop]) { + if (Math.abs(ray.dir[1]) <= EPS) continue; + const t = (y - ray.origin[1]) / ray.dir[1]; + if (t < 0) continue; + const px = ray.origin[0] + t * ray.dir[0]; + const pz = ray.origin[2] + t * ray.dir[2]; + if (pointInPolyXZ(px, pz, slab.outline)) consider(t); + } + // Seitenwände: je Kante ein vertikales Quad (zwei Dreiecke). + for (let i = 0, j = n - 1; i < n; j = i++) { + const a = slab.outline[j]; + const b = slab.outline[i]; + const A: Vec3 = [a[0], slab.zBottom, a[1]]; + const B: Vec3 = [b[0], slab.zBottom, b[1]]; + const C: Vec3 = [b[0], slab.zTop, b[1]]; + const D: Vec3 = [a[0], slab.zTop, a[1]]; + consider(rayTriangle(ray, A, B, C)); + consider(rayTriangle(ray, A, C, D)); + } + return best === Infinity ? null : best; +} + +/** + * Nächstes getroffenes Bauteil (kleinstes t) über Wände + Decken, oder null. + * Wände und Decken konkurrieren direkt über die Distanz — was näher an der + * Kamera liegt, gewinnt. + */ +export function pickNearest( + ray: Ray, + walls: readonly PickWall[], + slabs: readonly PickSlab[], +): PickHit | null { + let hit: PickHit | null = null; + for (const w of walls) { + const t = rayWallBox(ray, w); + if (t !== null && (hit === null || t < hit.t)) { + hit = { kind: "wall", id: w.wallId, t }; + } + } + for (const s of slabs) { + const t = raySlabPrism(ray, s); + if (t !== null && (hit === null || t < hit.t)) { + hit = { kind: "ceiling", id: s.ceilingId, t }; + } + } + return hit; +}