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 5c24a9f4c5
commit ca60b5a9a4
6 changed files with 547 additions and 15 deletions
+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);
};