Kontext-Objekte im 3D-Viewport anwählbar (Klick, Bounding-Box-Highlight, Löschen)
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.
This commit is contained in:
@@ -101,6 +101,48 @@ describe("selectionHighlightLines", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectionHighlightLines — Kontext-Objekte", () => {
|
||||
// Ein einzelnes Dreieck als Kontext-Mesh (importiertes Gebäude): das
|
||||
// Highlight zeichnet dessen achsenparallele Bounding-Box (12 Kanten), NICHT
|
||||
// das volle Dreiecks-Wireframe (s. Kommentar in toWalls3d.ts).
|
||||
const withContext: Project = {
|
||||
...sampleProject,
|
||||
context: [
|
||||
{
|
||||
id: "CTX1",
|
||||
type: "importedMesh",
|
||||
name: "Testgebäude",
|
||||
positions: [0, 0, 0, 2, 0, 0, 2, 3, 4],
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("leere Kontext-Auswahl -> leeres Array", () => {
|
||||
const lines = selectionHighlightLines(withContext, [], [], RGB, [], [], [], []);
|
||||
expect(lines.length).toBe(0);
|
||||
});
|
||||
|
||||
it("unbekannte Kontext-Id -> leeres Array", () => {
|
||||
const lines = selectionHighlightLines(withContext, [], [], RGB, [], [], [], ["does-not-exist"]);
|
||||
expect(lines.length).toBe(0);
|
||||
});
|
||||
|
||||
it("ein Kontext-Objekt -> Bounding-Box-Umriss (12 Kanten)", () => {
|
||||
const lines = selectionHighlightLines(withContext, [], [], RGB, [], [], [], ["CTX1"]);
|
||||
expect(lines.length).toBe(12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
|
||||
expect(lines[3]).toBeCloseTo(RGB[0], 6);
|
||||
expect(lines[5]).toBeCloseTo(RGB[2], 6);
|
||||
});
|
||||
|
||||
it("Kontext-Objekt zusammen mit einer Wand -> Summe der Einzelgeometrien", () => {
|
||||
const wallOnly = selectionHighlightLines(withContext, ["W2"], [], RGB);
|
||||
const ctxOnly = selectionHighlightLines(withContext, [], [], RGB, [], [], [], ["CTX1"]);
|
||||
const both = selectionHighlightLines(withContext, ["W2"], [], RGB, [], [], [], ["CTX1"]);
|
||||
expect(both.length).toBe(wallOnly.length + ctxOnly.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:true)", () => {
|
||||
// Wand W1 (EG, Wandtyp "aw" = 4 Schichten) mit ZWEI Fenstern, deren Achsen-
|
||||
// Intervalle sich ÜBERLAPPEN, aber auf UNTERSCHIEDLICHER Höhe liegen:
|
||||
@@ -1890,3 +1932,52 @@ describe("pickGeometry — Dächer (Ray-Dreieck-Pick)", () => {
|
||||
for (const tri of roofs[0].tris) for (const p of tri) expect(p[1]).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickGeometry — Kontext-Meshes (importierte Gebäude/Terrain)", () => {
|
||||
const withContext: Project = {
|
||||
...sampleProject,
|
||||
context: [
|
||||
{
|
||||
id: "CTX1",
|
||||
type: "importedMesh",
|
||||
name: "Testgebäude",
|
||||
// model (x,y,z=Höhe) -> world (x, z, y): ein Dreieck.
|
||||
positions: [0, 0, 0, 2, 0, 0, 2, 3, 4],
|
||||
indices: [0, 1, 2],
|
||||
},
|
||||
{
|
||||
id: "CONTOURS1",
|
||||
type: "contourSet",
|
||||
name: "Höhenlinien",
|
||||
contours: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("liefert je Kontext-Objekt World-Dreiecke, contourSet wird übersprungen", () => {
|
||||
const { contextMeshes } = pickGeometry(withContext);
|
||||
expect(contextMeshes.length).toBe(1);
|
||||
expect(contextMeshes[0].contextObjectId).toBe("CTX1");
|
||||
expect(contextMeshes[0].tris.length).toBe(1);
|
||||
// world = (x, z, y) — dasselbe Dreieck (0,0,0),(2,0,0),(2,3,4)
|
||||
// -> world (0,0,0),(2,0,0),(2,4,3).
|
||||
expect(contextMeshes[0].tris[0][2]).toEqual([2, 4, 3]);
|
||||
});
|
||||
|
||||
it("kaputte Indizes werden robust übersprungen statt abzustürzen", () => {
|
||||
const broken: Project = {
|
||||
...sampleProject,
|
||||
context: [
|
||||
{
|
||||
id: "CTX2",
|
||||
type: "importedMesh",
|
||||
name: "Kaputt",
|
||||
positions: [0, 0, 0, 1, 0, 0, 1, 1, 0],
|
||||
indices: [0, 1, 99], // Index 99 existiert nicht
|
||||
},
|
||||
],
|
||||
};
|
||||
const { contextMeshes } = pickGeometry(broken);
|
||||
expect(contextMeshes.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+57
-3
@@ -62,7 +62,7 @@ import type { WallCuts } from "../model/joins";
|
||||
import { add, dot, leftNormal, lineIntersect, normalize, scale, sub } from "../model/geometry";
|
||||
import type { Line } from "../model/geometry";
|
||||
import { resolveHatch } from "./generatePlan";
|
||||
import type { PickOpening, PickRoof, PickStair } from "../viewport/raycast3d";
|
||||
import type { PickOpening, PickRoof, PickStair, PickContextMesh } from "../viewport/raycast3d";
|
||||
|
||||
/** World-Punkt [x, Höhe, y] für den Dach-Pick (Vec3-Konvention von raycast3d). */
|
||||
type RoofPickVec3 = [number, number, number];
|
||||
@@ -2825,6 +2825,7 @@ export function pickGeometry(
|
||||
openings: PickOpening[];
|
||||
stairs: PickStair[];
|
||||
roofs: PickRoof[];
|
||||
contextMeshes: PickContextMesh[];
|
||||
} {
|
||||
// Dächer: Dachflächen + Giebel als WORLD-Dreiecke (fan-trianguliert wie
|
||||
// emitRoofs; model [x,y,z=Höhe] → world [x, z, y]) — präzise geneigte
|
||||
@@ -2878,7 +2879,27 @@ export function pickGeometry(
|
||||
stairId: st.id,
|
||||
});
|
||||
}
|
||||
return { walls: projectToWalls3d(project), slabs: emitSlabs(project), openings, stairs, roofs };
|
||||
// Kontext-Meshes (importierte Gebäude/Terrain, s. ContextObject): rohe
|
||||
// WORLD-Dreiecke wie beim Dach, direkt aus dem Indexpuffer (kein Fan nötig —
|
||||
// ImportedMesh/TerrainMesh liefern bereits ein echtes Dreiecksnetz). Kaputte
|
||||
// Indizes robust überspringen statt abzustürzen (importierte Daten).
|
||||
const contextMeshes: PickContextMesh[] = [];
|
||||
for (const obj of project.context ?? []) {
|
||||
if (obj.type === "contourSet") continue;
|
||||
const pos = obj.positions;
|
||||
const vcount = pos.length / 3;
|
||||
const tris: [RoofPickVec3, RoofPickVec3, RoofPickVec3][] = [];
|
||||
for (let i = 0; i + 2 < obj.indices.length; i += 3) {
|
||||
const ia = obj.indices[i];
|
||||
const ib = obj.indices[i + 1];
|
||||
const ic = obj.indices[i + 2];
|
||||
if (ia >= vcount || ib >= vcount || ic >= vcount) continue;
|
||||
const at = (idx: number): RoofPickVec3 => [pos[idx * 3], pos[idx * 3 + 2], pos[idx * 3 + 1]];
|
||||
tris.push([at(ia), at(ib), at(ic)]);
|
||||
}
|
||||
if (tris.length > 0) contextMeshes.push({ tris, contextObjectId: obj.id });
|
||||
}
|
||||
return { walls: projectToWalls3d(project), slabs: emitSlabs(project), openings, stairs, roofs, contextMeshes };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2915,13 +2936,15 @@ export function selectionHighlightLines(
|
||||
openingIds: string[] = [],
|
||||
stairIds: string[] = [],
|
||||
roofIds: string[] = [],
|
||||
contextObjectIds: string[] = [],
|
||||
): Float32Array {
|
||||
if (
|
||||
wallIds.length === 0 &&
|
||||
ceilingIds.length === 0 &&
|
||||
openingIds.length === 0 &&
|
||||
stairIds.length === 0 &&
|
||||
roofIds.length === 0
|
||||
roofIds.length === 0 &&
|
||||
contextObjectIds.length === 0
|
||||
) {
|
||||
return new Float32Array(0);
|
||||
}
|
||||
@@ -2931,6 +2954,7 @@ export function selectionHighlightLines(
|
||||
const openingSet = new Set(openingIds);
|
||||
const stairSet = new Set(stairIds);
|
||||
const roofSet = new Set(roofIds);
|
||||
const contextObjSet = new Set(contextObjectIds);
|
||||
const out: number[] = [];
|
||||
const pushEdge = (a: RVec3, b: RVec3) => {
|
||||
out.push(a[0], a[1], a[2], rgb[0], rgb[1], rgb[2]);
|
||||
@@ -3013,5 +3037,35 @@ export function selectionHighlightLines(
|
||||
for (const gab of g.gables) polyEdges(gab);
|
||||
}
|
||||
}
|
||||
// Kontext-Objekt (importiertes Gebäude/Terrain): Umriss als achsenparallele
|
||||
// Bounding-Box statt vollem Dreiecks-Wireframe — importierte Meshes haben oft
|
||||
// tausende Dreiecke, deren Kanten einzeln zu zeichnen wäre unbrauchbar dicht.
|
||||
if (contextObjSet.size > 0) {
|
||||
for (const obj of project.context ?? []) {
|
||||
if (obj.type === "contourSet" || !contextObjSet.has(obj.id)) continue;
|
||||
const pos = obj.positions;
|
||||
let minX = Infinity, maxX = -Infinity;
|
||||
let minY = Infinity, maxY = -Infinity;
|
||||
let minH = Infinity, maxH = -Infinity;
|
||||
for (let i = 0; i + 2 < pos.length; i += 3) {
|
||||
const x = pos[i], y = pos[i + 1], h = pos[i + 2];
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
if (h < minH) minH = h;
|
||||
if (h > maxH) maxH = h;
|
||||
}
|
||||
if (!isFinite(minX)) continue;
|
||||
const corners: RVec3[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const x = i & 1 ? maxX : minX;
|
||||
const h = i & 2 ? maxH : minH;
|
||||
const y = i & 4 ? maxY : minY;
|
||||
corners.push([x, h, y]);
|
||||
}
|
||||
for (const [i, j] of BOX_EDGES) pushEdge(corners[i], corners[j]);
|
||||
}
|
||||
}
|
||||
return new Float32Array(out);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user