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:
+38
@@ -63,6 +63,7 @@ import type {
|
|||||||
} from "./plan/PlanView";
|
} from "./plan/PlanView";
|
||||||
import { Viewport3D } from "./viewport/Viewport3D";
|
import { Viewport3D } from "./viewport/Viewport3D";
|
||||||
import type { DisplayResolver, ViewportContextInfo } from "./viewport/Viewport3D";
|
import type { DisplayResolver, ViewportContextInfo } from "./viewport/Viewport3D";
|
||||||
|
import type { Pick3dHit } from "./viewport/Wasm3DViewport";
|
||||||
import { ResourceManager } from "./ui/ResourceManager";
|
import { ResourceManager } from "./ui/ResourceManager";
|
||||||
import type { ResourceManagerHandlers } from "./ui/ResourceManager";
|
import type { ResourceManagerHandlers } from "./ui/ResourceManager";
|
||||||
import { HatchSwatch } from "./ui/hatchPreview";
|
import { HatchSwatch } from "./ui/hatchPreview";
|
||||||
@@ -2933,6 +2934,38 @@ export default function App() {
|
|||||||
setSelectedRoomIds([]);
|
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.
|
// Rechts-Klick im 3D: Wand-Kontextmenü an der Cursorposition.
|
||||||
const onViewportContextMenu = (info: ViewportContextInfo) => {
|
const onViewportContextMenu = (info: ViewportContextInfo) => {
|
||||||
if (info.wallId && !selectedWallIds.includes(info.wallId)) {
|
if (info.wallId && !selectedWallIds.includes(info.wallId)) {
|
||||||
@@ -3136,6 +3169,7 @@ export default function App() {
|
|||||||
onViewportSelectOpening={onViewportSelectOpening}
|
onViewportSelectOpening={onViewportSelectOpening}
|
||||||
onViewportSelectStair={onViewportSelectStair}
|
onViewportSelectStair={onViewportSelectStair}
|
||||||
onViewportContextMenu={onViewportContextMenu}
|
onViewportContextMenu={onViewportContextMenu}
|
||||||
|
onViewport3dPick={onViewport3dPick}
|
||||||
commandActive={commandActive}
|
commandActive={commandActive}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
onWorkplanePoint={onWorkplanePoint}
|
onWorkplanePoint={onWorkplanePoint}
|
||||||
@@ -4099,6 +4133,7 @@ function Content({
|
|||||||
onViewportSelectOpening,
|
onViewportSelectOpening,
|
||||||
onViewportSelectStair,
|
onViewportSelectStair,
|
||||||
onViewportContextMenu,
|
onViewportContextMenu,
|
||||||
|
onViewport3dPick,
|
||||||
commandActive,
|
commandActive,
|
||||||
draft,
|
draft,
|
||||||
onWorkplanePoint,
|
onWorkplanePoint,
|
||||||
@@ -4172,6 +4207,8 @@ function Content({
|
|||||||
onViewportSelectOpening: (openingId: string | null) => void;
|
onViewportSelectOpening: (openingId: string | null) => void;
|
||||||
onViewportSelectStair: (stairId: string | null) => void;
|
onViewportSelectStair: (stairId: string | null) => void;
|
||||||
onViewportContextMenu: (info: ViewportContextInfo) => 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. */
|
/** Läuft ein zeichnender Befehl? Macht den 3D-Viewport zur Erstellungs-Fläche. */
|
||||||
commandActive: boolean;
|
commandActive: boolean;
|
||||||
/** Aktuelle Werkzeug-Vorschau (Rubber-Band) — auch in 3D gespiegelt. */
|
/** Aktuelle Werkzeug-Vorschau (Rubber-Band) — auch in 3D gespiegelt. */
|
||||||
@@ -4329,6 +4366,7 @@ function Content({
|
|||||||
onSelectOpening={onViewportSelectOpening}
|
onSelectOpening={onViewportSelectOpening}
|
||||||
onSelectStair={onViewportSelectStair}
|
onSelectStair={onViewportSelectStair}
|
||||||
onContextMenu={onViewportContextMenu}
|
onContextMenu={onViewportContextMenu}
|
||||||
|
onPick3d={onViewport3dPick}
|
||||||
commandActive={commandActive}
|
commandActive={commandActive}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
onWorkplanePoint={onWorkplanePoint}
|
onWorkplanePoint={onWorkplanePoint}
|
||||||
|
|||||||
+33
-5
@@ -49,6 +49,13 @@ export interface RWall {
|
|||||||
color: RRgb;
|
color: RRgb;
|
||||||
/** Aufgelöster Schichtaufbau (leer = kein WallType auflösbar, siehe emitWall). */
|
/** Aufgelöster Schichtaufbau (leer = kein WallType auflösbar, siehe emitWall). */
|
||||||
layers: RLayer[];
|
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. */
|
/** Eine extrudierte Deckenplatte: geschlossener Grundriss-Umriss + Z-Ausdehnung. */
|
||||||
@@ -57,6 +64,12 @@ export interface RSlab {
|
|||||||
zBottom: number;
|
zBottom: number;
|
||||||
zTop: number;
|
zTop: number;
|
||||||
color: RRgb;
|
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). */
|
/** Art eines rohen Kontext-Meshes (bestimmt in render3d die Default-Einfärbung). */
|
||||||
@@ -122,6 +135,7 @@ function pushSegment(
|
|||||||
zTop: number,
|
zTop: number,
|
||||||
thickness: number,
|
thickness: number,
|
||||||
layers: RLayer[],
|
layers: RLayer[],
|
||||||
|
wallId: string,
|
||||||
): void {
|
): void {
|
||||||
if (to - from <= EPS) return;
|
if (to - from <= EPS) return;
|
||||||
if (zTop - zBottom <= EPS) return;
|
if (zTop - zBottom <= EPS) return;
|
||||||
@@ -141,6 +155,7 @@ function pushSegment(
|
|||||||
baseElevation: zBottom,
|
baseElevation: zBottom,
|
||||||
color: WALL_RGB,
|
color: WALL_RGB,
|
||||||
layers,
|
layers,
|
||||||
|
wallId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +205,7 @@ function emitWall(out: RWall[], project: Project, wall: Wall): void {
|
|||||||
|
|
||||||
// Ohne Aussparungen: ein durchgehender Quader (wie bisher).
|
// Ohne Aussparungen: ein durchgehender Quader (wie bisher).
|
||||||
if (cutouts.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,23 +217,23 @@ function emitWall(out: RWall[], project: Project, wall: Wall): void {
|
|||||||
const segFrom = Math.max(cursor, from);
|
const segFrom = Math.max(cursor, from);
|
||||||
if (from > cursor) {
|
if (from > cursor) {
|
||||||
// Voller Wandpfeiler bis zur Aussparung.
|
// 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) {
|
if (to > segFrom) {
|
||||||
// Brüstung unter der Öffnung (bei Türen entfällt sie, da oBottom == zBottom).
|
// Brüstung unter der Öffnung (bei Türen entfällt sie, da oBottom == zBottom).
|
||||||
if (oBottom > zBottom + EPS) {
|
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).
|
// Sturz über der Öffnung (bis zum Wandkopf).
|
||||||
if (oTop < zTop - EPS) {
|
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);
|
cursor = Math.max(cursor, to);
|
||||||
}
|
}
|
||||||
// Restlicher Wandpfeiler bis zum Achsenende.
|
// Restlicher Wandpfeiler bis zum Achsenende.
|
||||||
if (cursor < axisLen) {
|
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,
|
zBottom,
|
||||||
zTop,
|
zTop,
|
||||||
color: SLAB_RGB,
|
color: SLAB_RGB,
|
||||||
|
ceilingId: c.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -283,3 +299,15 @@ export function projectToModel3d(project: Project): RModel3d {
|
|||||||
meshes: emitMeshes(project),
|
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) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
} from "../model/wall";
|
} from "../model/wall";
|
||||||
import { MaterialRuntime } from "../materials/runtime";
|
import { MaterialRuntime } from "../materials/runtime";
|
||||||
import { Wasm3DViewport } from "./Wasm3DViewport";
|
import { Wasm3DViewport } from "./Wasm3DViewport";
|
||||||
|
import type { Pick3dHit } from "./Wasm3DViewport";
|
||||||
import type { RenderMode, View3d } from "../ui/TopBar";
|
import type { RenderMode, View3d } from "../ui/TopBar";
|
||||||
import type { ToolDraft } from "../tools/types";
|
import type { ToolDraft } from "../tools/types";
|
||||||
import { applyAngleConstraint } from "../tools/snapping";
|
import { applyAngleConstraint } from "../tools/snapping";
|
||||||
@@ -124,9 +125,12 @@ export function Viewport3D(
|
|||||||
/** Klick auf den Grid-Umschalter-Button im WASM-Viewport — Single Source
|
/** Klick auf den Grid-Umschalter-Button im WASM-Viewport — Single Source
|
||||||
* of Truth bleibt der Aufrufer (App/viewSlice `grid3dVisible`). */
|
* of Truth bleibt der Aufrufer (App/viewSlice `grid3dVisible`). */
|
||||||
onToggleGrid?: () => void;
|
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) {
|
if (WASM_ENGINE_ACTIVE) {
|
||||||
return (
|
return (
|
||||||
<Wasm3DViewport
|
<Wasm3DViewport
|
||||||
@@ -136,6 +140,7 @@ export function Viewport3D(
|
|||||||
gridVisible={gridVisible}
|
gridVisible={gridVisible}
|
||||||
groundElevation={props.gridElevation}
|
groundElevation={props.gridElevation}
|
||||||
onToggleGrid={onToggleGrid}
|
onToggleGrid={onToggleGrid}
|
||||||
|
onPick3d={onPick3d}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,11 +44,19 @@
|
|||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { Project } from "../model/types";
|
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 type { RenderMode, View3d } from "../ui/TopBar";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { useWasm3dRenderer } from "./useWasm3dRenderer";
|
import { useWasm3dRenderer } from "./useWasm3dRenderer";
|
||||||
import type { Camera3d } 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°),
|
/** Vertikaler Öffnungswinkel (Radiant) — wie die three.js-PerspectiveCamera (50°),
|
||||||
* auch für die FREIE Orbit-Kamera hier (die Presets selbst nutzen den Rust-
|
* auch für die FREIE Orbit-Kamera hier (die Presets selbst nutzen den Rust-
|
||||||
@@ -167,6 +175,7 @@ export function Wasm3DViewport({
|
|||||||
gridVisible = true,
|
gridVisible = true,
|
||||||
groundElevation = 0,
|
groundElevation = 0,
|
||||||
onToggleGrid,
|
onToggleGrid,
|
||||||
|
onPick3d,
|
||||||
}: {
|
}: {
|
||||||
project: Project;
|
project: Project;
|
||||||
/** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */
|
/** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */
|
||||||
@@ -183,8 +192,20 @@ export function Wasm3DViewport({
|
|||||||
groundElevation?: number;
|
groundElevation?: number;
|
||||||
/** Klick auf den Grid-Umschalter-Button; ohne Angabe kein Button. */
|
/** Klick auf den Grid-Umschalter-Button; ohne Angabe kein Button. */
|
||||||
onToggleGrid?: () => void;
|
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);
|
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 {
|
const {
|
||||||
render,
|
render,
|
||||||
updateModel,
|
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();
|
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) => {
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
// LINKS (0) = Orbit, MITTE (1)/RECHTS (2) = Pan (s. MAUS-SCHEMA oben).
|
// 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;
|
const mode = e.button === 0 ? "orbit" : e.button === 1 || e.button === 2 ? "pan" : null;
|
||||||
if (!mode) return;
|
if (!mode) return;
|
||||||
e.preventDefault();
|
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);
|
canvas.setPointerCapture(e.pointerId);
|
||||||
useOrbitRedraw();
|
useOrbitRedraw();
|
||||||
};
|
};
|
||||||
@@ -290,6 +347,13 @@ export function Wasm3DViewport({
|
|||||||
const onPointerMove = (e: PointerEvent) => {
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
const o = orbitRef.current;
|
const o = orbitRef.current;
|
||||||
if (!drag || !o) return;
|
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 dx = e.clientX - drag.x;
|
||||||
const dy = e.clientY - drag.y;
|
const dy = e.clientY - drag.y;
|
||||||
drag.x = e.clientX;
|
drag.x = e.clientX;
|
||||||
@@ -326,14 +390,30 @@ export function Wasm3DViewport({
|
|||||||
redraw();
|
redraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
const endDrag = (e: PointerEvent) => {
|
// Cleanup einer Geste (Capture lösen, Drag-Zustand fallenlassen).
|
||||||
if (!drag) return;
|
const releaseDrag = (e: PointerEvent) => {
|
||||||
drag = null;
|
drag = null;
|
||||||
if (canvas.hasPointerCapture(e.pointerId)) {
|
if (canvas.hasPointerCapture(e.pointerId)) {
|
||||||
canvas.releasePointerCapture(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 onWheel = (e: WheelEvent) => {
|
||||||
const o = orbitRef.current;
|
const o = orbitRef.current;
|
||||||
if (!o) return;
|
if (!o) return;
|
||||||
@@ -383,15 +463,15 @@ export function Wasm3DViewport({
|
|||||||
|
|
||||||
canvas.addEventListener("pointerdown", onPointerDown);
|
canvas.addEventListener("pointerdown", onPointerDown);
|
||||||
canvas.addEventListener("pointermove", onPointerMove);
|
canvas.addEventListener("pointermove", onPointerMove);
|
||||||
canvas.addEventListener("pointerup", endDrag);
|
canvas.addEventListener("pointerup", onPointerUp);
|
||||||
canvas.addEventListener("pointercancel", endDrag);
|
canvas.addEventListener("pointercancel", onPointerCancel);
|
||||||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||||
canvas.addEventListener("contextmenu", onContextMenu);
|
canvas.addEventListener("contextmenu", onContextMenu);
|
||||||
return () => {
|
return () => {
|
||||||
canvas.removeEventListener("pointerdown", onPointerDown);
|
canvas.removeEventListener("pointerdown", onPointerDown);
|
||||||
canvas.removeEventListener("pointermove", onPointerMove);
|
canvas.removeEventListener("pointermove", onPointerMove);
|
||||||
canvas.removeEventListener("pointerup", endDrag);
|
canvas.removeEventListener("pointerup", onPointerUp);
|
||||||
canvas.removeEventListener("pointercancel", endDrag);
|
canvas.removeEventListener("pointercancel", onPointerCancel);
|
||||||
canvas.removeEventListener("wheel", onWheel);
|
canvas.removeEventListener("wheel", onWheel);
|
||||||
canvas.removeEventListener("contextmenu", onContextMenu);
|
canvas.removeEventListener("contextmenu", onContextMenu);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user