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";
|
||||
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
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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