e6a7738170
Importierte Gebäude/Terrain-Meshes waren bisher nur ein Eintrag im Geo-Panel, nicht direkt im 3D anwählbar. Raycast/Pick-Geometrie um Kontext-Meshes erweitert, Auswahl-Kanal (selectedContextObjectIds) ergänzt, Highlight als Bounding-Box-Umriss (volles Dreiecks-Wireframe wäre bei grossen Importen zu dicht), Entf-Taste löscht die Auswahl, SitePanel hebt sie in der Liste hervor.
277 lines
8.3 KiB
TypeScript
277 lines
8.3 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
cameraRay,
|
|
rayWallBox,
|
|
raySlabPrism,
|
|
pickNearest,
|
|
type RayCamera,
|
|
type PickWall,
|
|
type PickSlab,
|
|
type PickOpening,
|
|
type PickStair,
|
|
type PickContextMesh,
|
|
} 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);
|
|
});
|
|
|
|
it("orthografisch (perspective:false): alle Strahlen sind parallel zur Blickrichtung", () => {
|
|
const orthoCam: RayCamera = { ...CAM, perspective: false, orthoHalfHeight: 5 };
|
|
const center = cameraRay(orthoCam, 0, 0, 16 / 9);
|
|
const offset = cameraRay(orthoCam, 0.5, 0.5, 1);
|
|
// Beide Strahlen zeigen exakt in dieselbe Richtung (parallel, keine Konvergenz).
|
|
expect(offset.dir).toEqual(center.dir);
|
|
expect(center.dir[2]).toBeCloseTo(-1, 6);
|
|
// Der Ursprung wandert stattdessen lateral mit dem Pixel (Kehrwert von worldToScreen).
|
|
expect(offset.origin[0]).toBeCloseTo(2.5, 6); // ndcX·aspect·hh = 0.5·1·5
|
|
expect(offset.origin[1]).toBeCloseTo(3.5, 6); // eye.y(1) + ndcY·hh = 1 + 0.5·5
|
|
expect(offset.origin[2]).toBeCloseTo(10, 6); // unveraendert (Achse senkrecht zur Blickrichtung)
|
|
});
|
|
|
|
it("orthografisch ohne perspective-Feld bleibt rückwärtskompatibel perspektivisch", () => {
|
|
const ray = cameraRay(CAM, 0.5, 0.5, 1);
|
|
expect(ray.origin).toEqual([0, 1, 10]); // Ursprung bleibt eye (Perspektive)
|
|
});
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
});
|
|
|
|
// Öffnung: dieselbe Achse/Front wie WALL (model y=5 → world z≈5), aber nur über
|
|
// die Fenster-Ausdehnung (Brüstung 0.9m..1.9m) statt der vollen Wandhöhe.
|
|
const OPENING: PickOpening = {
|
|
start: [-0.5, 5],
|
|
end: [0.5, 5],
|
|
thickness: 0.2,
|
|
height: 1.0,
|
|
baseElevation: 0.9,
|
|
openingId: "op1",
|
|
};
|
|
|
|
// Treppe: Fußabdruck-Rechteck model (0,0)-(2,3), Bounding-Prisma bis 2.6m Rise.
|
|
const STAIR: PickStair = {
|
|
outline: [
|
|
[0, 0],
|
|
[2, 0],
|
|
[2, 3],
|
|
[0, 3],
|
|
],
|
|
zBottom: 0,
|
|
zTop: 2.6,
|
|
stairId: "st1",
|
|
};
|
|
|
|
describe("pickNearest — Öffnungen und Treppen", () => {
|
|
it("Strahl auf eine Öffnung liefert openingId", () => {
|
|
// Wie der Wand-Treffer (Front z=5.1 → t=4.9), aber y=1 liegt in der
|
|
// Fenster-Ausdehnung (0.9..1.9) → Öffnung statt Wand.
|
|
const hit = pickNearest({ origin: [0, 1, 10], dir: [0, 0, -1] }, [], [], [OPENING]);
|
|
expect(hit).not.toBeNull();
|
|
expect(hit!.kind).toBe("opening");
|
|
expect(hit!.openingId).toBe("op1");
|
|
expect(hit!.t).toBeCloseTo(4.9, 6);
|
|
});
|
|
|
|
it("Strahl auf eine Treppe liefert stairId", () => {
|
|
// Senkrecht von oben ins Fußabdruck-Rechteck: Deckel bei y=2.6 → t=7.4.
|
|
const hit = pickNearest(
|
|
{ origin: [1, 10, 1], dir: [0, -1, 0] },
|
|
[],
|
|
[],
|
|
[],
|
|
[STAIR],
|
|
);
|
|
expect(hit).not.toBeNull();
|
|
expect(hit!.kind).toBe("stair");
|
|
expect(hit!.stairId).toBe("st1");
|
|
expect(hit!.t).toBeCloseTo(7.4, 6);
|
|
});
|
|
|
|
it("bei näherer Wand gewinnt die Wand (kleinstes t) trotz konkurrierender Öffnung", () => {
|
|
// farOpening liegt bei model y=2 (world z=2) → Front z=2.1 → t=7.9, WEITER
|
|
// hinten als die Wand (t=4.9) → die Wand muss trotz der Öffnung-Priorität
|
|
// bei Gleichstand gewinnen, weil hier gar kein Gleichstand vorliegt.
|
|
const farOpening: PickOpening = { ...OPENING, start: [-0.5, 2], end: [0.5, 2], openingId: "op-far" };
|
|
const hit = pickNearest({ origin: [0, 1, 10], dir: [0, 0, -1] }, [WALL], [], [farOpening]);
|
|
expect(hit).not.toBeNull();
|
|
expect(hit!.kind).toBe("wall");
|
|
expect(hit!.id).toBe("w1");
|
|
});
|
|
});
|
|
|
|
// Kontext-Mesh (importiertes Gebäude/Terrain): eine flache, senkrechte
|
|
// Rechteckfläche bei model y=5 (world z=5), model x∈[-1,1], model z (Höhe)
|
|
// ∈[0,2] — dieselbe Front wie WALL, aber als rohes Dreiecksnetz statt Quader.
|
|
const CONTEXT_MESH: PickContextMesh = {
|
|
tris: [
|
|
[
|
|
[-1, 0, 5],
|
|
[1, 0, 5],
|
|
[1, 2, 5],
|
|
],
|
|
[
|
|
[-1, 0, 5],
|
|
[1, 2, 5],
|
|
[-1, 2, 5],
|
|
],
|
|
],
|
|
contextObjectId: "ctx1",
|
|
};
|
|
|
|
describe("pickNearest — Kontext-Meshes", () => {
|
|
it("Strahl auf ein Kontext-Mesh liefert contextObjectId", () => {
|
|
const hit = pickNearest(
|
|
{ origin: [0, 1, 10], dir: [0, 0, -1] },
|
|
[],
|
|
[],
|
|
[],
|
|
[],
|
|
[],
|
|
[CONTEXT_MESH],
|
|
);
|
|
expect(hit).not.toBeNull();
|
|
expect(hit!.kind).toBe("contextObject");
|
|
expect(hit!.contextObjectId).toBe("ctx1");
|
|
expect(hit!.t).toBeCloseTo(5, 6);
|
|
});
|
|
|
|
it("Strahl neben dem Mesh verfehlt", () => {
|
|
const hit = pickNearest(
|
|
{ origin: [5, 1, 10], dir: [0, 0, -1] },
|
|
[],
|
|
[],
|
|
[],
|
|
[],
|
|
[],
|
|
[CONTEXT_MESH],
|
|
);
|
|
expect(hit).toBeNull();
|
|
});
|
|
|
|
it("bei näherer Wand gewinnt die Wand vor dem weiter entfernten Kontext-Mesh", () => {
|
|
// WALL-Front bei z=5.1 → t=4.9, näher als das Mesh bei z=5 (t=5).
|
|
const hit = pickNearest(
|
|
{ origin: [0, 1, 10], dir: [0, 0, -1] },
|
|
[WALL],
|
|
[],
|
|
[],
|
|
[],
|
|
[],
|
|
[CONTEXT_MESH],
|
|
);
|
|
expect(hit).not.toBeNull();
|
|
expect(hit!.kind).toBe("wall");
|
|
expect(hit!.id).toBe("w1");
|
|
});
|
|
});
|