Schnittebenen 2D↔3D Phase 1+2, native Fenster, Hell/Dunkel-Theme, SWISSIMAGE-Import

- Schnittebenen: 3D-Live-Schnitt folgt der gewählten Grundriss-Schnittlinie
  (section3dCutId/sectionPlaneFromLevel), Schalter "Im 3D schneiden" im
  Objekt-Info, Doppelklick auf Schnittlinie springt in 2D-Schnittansicht,
  unsichtbare Ebenen blenden ihre Führungslinie aus
- Eigene native Tauri-Fenster für Kontext-Import/Zeichnungsebenen/
  Ebenen-Einstellungen/Ressourcen/Einstellungen + klassische Menüleiste
  (AppMenuBar) neben der Wortmarke
- Hell/Dunkel-Umschalter (Einstellungen → Darstellung), persistiert,
  flackerfrei vor erstem Render gesetzt
- SWISSIMAGE-Luftbild-Import (swisstopo WMS) als Kontext-Hintergrundebene
- UI-Politur: Werkzeug-Panel Symbole/Liste umschaltbar, Topbar-Quick-Access-
  Icons entfernt, Zahnrad→Einstellungen in Panel-Köpfen, Footerbar/
  Snap-Marker/Maß-HUD auf helle Pillen-Sprache umgestellt
- Neues Dachziegel-Material (RoofingTiles013A)
This commit is contained in:
2026-07-20 10:51:37 +02:00
parent 0ab0361e3d
commit 68a0459d0e
90 changed files with 7730 additions and 1849 deletions
+206
View File
@@ -0,0 +1,206 @@
// Kontext-Schicht im Grundriss (generatePlan → addContextContours):
// • ImportedMesh (Gebäude) -> Sockelkanten-Fussabdruck (context-line).
// • TerrainMesh -> nur 2D-Bounding-Box-Umriss (4 Linien).
// • Nutzer-Report: importierte Gebäude waren im Plan UNSICHTBAR (nur 3D).
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { Project } from "../model/types";
function projectWith(context: Project["context"]): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context,
} as unknown as Project;
}
const visible = new Set<string>();
function contextLines(p: Project) {
return generatePlan(p, "eg", visible).primitives.filter(
(pr) => pr.kind === "line" && (pr as { cls?: string }).cls === "context-line",
);
}
describe("generatePlan — Kontext-Fussabdruck (ImportedMesh)", () => {
it("zeichnet die Sockelkanten eines importierten Gebäudes als context-line", () => {
// Ein simpler Quader-„Bau": Grundfläche 0..2 × 0..3 bei z=0 (Sockel), Dach
// bei z=5. Zwei Sockel-Dreiecke bilden das Boden-Rechteck.
const p = projectWith([
{
id: "B1",
type: "importedMesh",
name: "Haus",
positions: [
0, 0, 0, 2, 0, 0, 2, 3, 0, 0, 3, 0, // 0..3: Boden (z=0)
1, 1, 5, // 4: Dachspitze (z=5, über der Toleranz)
],
indices: [
0, 1, 2, 0, 2, 3, // Boden (Sockel)
0, 1, 4, // Dachdreieck (nur eine Kante 0-1 am Sockel, 4 ist hoch)
],
},
]);
const lines = contextLines(p);
// Sockelkanten: Rechteck-Umriss 0-1,1-2,2-3,3-0 + Diagonale 0-2 = 5 eindeutige.
// Die hohen Dach-Kanten (0-4, 1-4) fallen weg (z=5 > Toleranz).
expect(lines.length).toBe(5);
// Keine Linie berührt die Dachspitze (1,1) auf z-Höhe — aber XY (1,1) könnte
// von der Diagonale getroffen werden; hier prüfen wir, dass keine Kante zu
// einem Punkt führt, dessen Original-z über der Toleranz lag: alle Endpunkte
// liegen im Boden-Rechteck [0..2]×[0..3].
for (const l of lines) {
if (l.kind !== "line") continue;
for (const pt of [l.a, l.b]) {
expect(pt.x).toBeGreaterThanOrEqual(0);
expect(pt.x).toBeLessThanOrEqual(2);
expect(pt.y).toBeGreaterThanOrEqual(0);
expect(pt.y).toBeLessThanOrEqual(3);
}
}
});
it("entdoppelt geteilte Kanten (kein doppeltes Zeichnen)", () => {
// Zwei Sockel-Dreiecke teilen sich die Kante 0-2 -> darf nur EINMAL erscheinen.
const p = projectWith([
{
id: "B1",
type: "importedMesh",
name: "Haus",
positions: [0, 0, 0, 2, 0, 0, 2, 3, 0, 0, 3, 0],
indices: [0, 1, 2, 0, 2, 3],
},
]);
const lines = contextLines(p);
// 4 Rand + 1 geteilte Diagonale = 5 (nicht 6).
expect(lines.length).toBe(5);
});
});
describe("generatePlan — Kontext-Objekte anklickbar (Pick-Region)", () => {
it("importedMesh bekommt ein unsichtbares Bbox-Pick-Band mit contextObjectId", () => {
const p = projectWith([
{
id: "B1",
type: "importedMesh",
name: "Haus",
positions: [0, 0, 0, 2, 0, 0, 2, 3, 0, 0, 3, 0],
indices: [0, 1, 2, 0, 2, 3],
},
]);
const plan = generatePlan(p, "eg", visible);
const pick = plan.primitives.find(
(pr) => pr.kind === "polygon" && pr.contextObjectId === "B1",
);
expect(pick).toBeDefined();
if (pick?.kind === "polygon") {
expect(pick.fill).toBe("none");
expect(pick.stroke).toBe("none");
// Bbox der Grundfläche: [0..2]×[0..3].
const xs = pick.pts.map((p) => p.x);
const ys = pick.pts.map((p) => p.y);
expect(Math.min(...xs)).toBe(0);
expect(Math.max(...xs)).toBe(2);
expect(Math.min(...ys)).toBe(0);
expect(Math.max(...ys)).toBe(3);
}
});
it("terrainMesh bekommt ebenfalls ein Pick-Band mit contextObjectId", () => {
const p = projectWith([
{
id: "T1",
type: "terrainMesh",
name: "Gelände",
positions: [0, 0, 400, 10, 0, 401, 5, 8, 402],
indices: [0, 1, 2],
},
]);
const plan = generatePlan(p, "eg", visible);
const pick = plan.primitives.find(
(pr) => pr.kind === "polygon" && pr.contextObjectId === "T1",
);
expect(pick).toBeDefined();
});
});
describe("generatePlan — Kontext-Umriss (TerrainMesh)", () => {
it("zeichnet nur die 2D-Bounding-Box (4 Linien), nicht das ganze TIN", () => {
// 3 Vertices spannen ein Dreieck; die Bbox-Ausdehnung ist [0..10]×[0..8].
const p = projectWith([
{
id: "T1",
type: "terrainMesh",
name: "Gelände",
positions: [0, 0, 400, 10, 0, 401, 5, 8, 402],
indices: [0, 1, 2],
},
]);
const lines = contextLines(p);
expect(lines.length).toBe(4);
const xs = lines.flatMap((l) => (l.kind === "line" ? [l.a.x, l.b.x] : []));
const ys = lines.flatMap((l) => (l.kind === "line" ? [l.a.y, l.b.y] : []));
expect(Math.min(...xs)).toBe(0);
expect(Math.max(...xs)).toBe(10);
expect(Math.min(...ys)).toBe(0);
expect(Math.max(...ys)).toBe(8);
});
});
describe("generatePlan — Luftbild (ContextImage)", () => {
it("gibt georeferenzierte Bilder als backgroundImages aus (nicht als Primitive)", () => {
const p = projectWith([
{
id: "IMG1",
type: "image",
name: "Luftbild",
layer: "Luftbild",
src: "data:image/jpeg;base64,AAAA",
min: { x: -100, y: -80 },
max: { x: 100, y: 80 },
},
]);
const plan = generatePlan(p, "eg", visible);
expect(plan.backgroundImages).toHaveLength(1);
expect(plan.backgroundImages![0]).toMatchObject({
id: "IMG1",
src: "data:image/jpeg;base64,AAAA",
min: { x: -100, y: -80 },
max: { x: 100, y: 80 },
opacity: 1,
});
// Kein Bild-Primitiv in den Vektor-Primitiven (DXF/PDF lassen es aus).
expect(plan.primitives.some((pr) => (pr as { kind?: string }).kind === "image")).toBe(false);
});
it("im Mono-Modus wird das Luftbild ausgeblendet (reiner S/W-Plan)", () => {
const p = projectWith([
{
id: "IMG1",
type: "image",
name: "Luftbild",
src: "data:image/jpeg;base64,AAAA",
min: { x: 0, y: 0 },
max: { x: 10, y: 10 },
},
]);
const plan = generatePlan(p, "eg", visible, undefined, "mittel", false, true);
expect(plan.backgroundImages).toHaveLength(0);
});
});