Nordstern-3D: Klick-Auswahl von Bauteilen (Raycast)

Links-Klick auf eine Wand/Decke im 3D-Viewport selektiert das Bauteil
(TS-Raycast gegen OBB der Wände + extrudierte Deckenprismen), setzt die
Store-Selektion (setSelectedWallIds/setSelectedCeilingIds, Shift/Ctrl =
additiv) → Attribute- und Objekt-Info-Panel zeigen das Bauteil. Erster
Schritt zur 3D-Editierbarkeit.

- raycast3d.ts: reine Kamera-Strahl-/Schnitt-Mathematik (10 Unit-Tests).
- toWalls3d.ts: wallId/ceilingId in die 3D-Records + pickGeometry();
  Serde-Pfad unberührt (keine deny_unknown_fields, Extrafelder werden
  Rust-seitig ignoriert).
- Klick-vs-Drag-Schwelle (4px) trennt Auswahl von Orbit; Leertreffer
  leert die Auswahl.
This commit is contained in:
2026-07-04 06:17:45 +02:00
parent 8f4fac633f
commit 16d32223a4
6 changed files with 547 additions and 15 deletions
+38
View File
@@ -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}
+33 -5
View File
@@ -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) };
}
+6 -1
View File
@@ -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 (
<Wasm3DViewport
@@ -136,6 +140,7 @@ export function Viewport3D(
gridVisible={gridVisible}
groundElevation={props.gridElevation}
onToggleGrid={onToggleGrid}
onPick3d={onPick3d}
/>
);
}
+89 -9
View File
@@ -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<HTMLCanvasElement | null>(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);
};
+127
View File
@@ -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();
});
});
+254
View File
@@ -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<readonly [number, number]>;
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 linksrechts, Y untenoben 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 startend 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<readonly [number, number]>,
): 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öllerTrumbore), 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;
}