16d32223a4
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.
255 lines
8.6 KiB
TypeScript
255 lines
8.6 KiB
TypeScript
// 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 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<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ö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;
|
||
}
|