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;
+}