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
+110
View File
@@ -216,6 +216,69 @@ describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:t
}
});
it("innerLayerRecess: nur die INNERSTE Schicht weicht rundum um ihre Dicke zurück, andere Schichten bündig", () => {
// Isolierte Wand W1 (wallTypeId "aw" = 4 Schichten aussen->innen: render-ext,
// insulation, brick, render-int) ohne Nachbarn (keine Gehrung), EIN Fenster
// mit `innerLayerRecess: true`. Die innerste Schicht (render-int, 0.015 m) muss
// an DIESEM Fenster ein rundum um 0.015 m grösseres Loch bekommen; die drei
// äusseren Schichten bleiben bei der bündigen (unveränderten) Öffnungsgrösse.
const w1 = sampleProject.walls.find((w) => w.id === "W1")!;
const p: Project = {
...sampleProject,
walls: [w1],
doors: [],
ceilings: [],
context: [],
windowTypes: [
...(sampleProject.windowTypes ?? []),
{
id: "win-recess-test",
name: "Test Schichteinzug",
kind: "fest",
wingCount: 1,
glazing: "einfach",
frameThickness: 0.06,
frameWidth: 0.06,
defaultSillHeight: 0.9,
defaultWidth: 1.0,
defaultHeight: 1.2,
innerLayerRecess: true,
},
],
openings: [
{
id: "OR",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "window",
typeId: "win-recess-test",
position: 2.0,
width: 1.0,
height: 1.2,
sillHeight: 0.9,
},
],
};
const bodies = projectToWalls3d(p, true).filter((w) => w.wallId === "W1");
expect(bodies.length).toBe(4); // 4 Schicht-Bänder, keine Gehrung/Span-Brüche
const withHoles = bodies.filter((b) => b.holes && b.holes.length === 1);
expect(withHoles.length).toBe(4);
// Innerste Schicht = kleinster |offset| Richtung Innenseite -> hier einfach
// die mit der GRÖSSTEN Loch-Fläche identifizieren (der Rücksprung macht ihr
// Loch strikt grösser als die der anderen drei, bündigen Schichten).
const area = (h: { from: number; to: number; zBottom: number; zTop: number }) =>
(h.to - h.from) * (h.zTop - h.zBottom);
const holeAreas = withHoles.map((b) => area(b.holes![0]));
const sorted = [...holeAreas].sort((a, b) => a - b);
// 3 bündige Schichten: identische Lochfläche (1.0 × 1.2 = 1.2 m²).
expect(sorted[0]).toBeCloseTo(1.2, 6);
expect(sorted[1]).toBeCloseTo(1.2, 6);
expect(sorted[2]).toBeCloseTo(1.2, 6);
// Innerste Schicht: rundum +0.015 m (render-int-Dicke) -> (1.0+0.03)×(1.2+0.03).
expect(sorted[3]).toBeCloseTo(1.03 * 1.23, 6);
});
it("layered=false (Schnitt-Pfad): unverändert segmentiert, KEINE holes", () => {
const w1 = projectToWalls3d(twoOffsetWindows, false).filter((w) => w.wallId === "W1");
// Der Schnitt-Pfad zerlegt weiter in Pfeiler/Brüstung/Sturz-Segmente (mehr
@@ -354,6 +417,26 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
expect(a.max).toBeLessThanOrEqual(b.min + 1e-9);
});
it("Legacy-Tür (project.doors, ohne Öffnungs-Typsystem) bekommt ein Blatt-Panel statt eines blanken Lochs", () => {
// D1 ist eine LEGACY-Tür (project.doors, kein typeId/Öffnungs-Typsystem) —
// vor der Behebung durchliefen emitOpeningGlass/emitOpeningFrames nur
// project.openings, D1 blieb im 3D ein reines Loch in der Wand (Nutzer-
// Report: "Türen fehlen im 3D komplett"). DOOR_LEAF_RGB = [0.93,0.9,0.85].
const { meshes } = projectToModel3d(sampleProject);
const leafMeshes = meshes.filter(
(m) => m.color[0] === 0.93 && m.color[1] === 0.9 && m.color[2] === 0.85,
);
expect(leafMeshes.length).toBe(1);
const leaf = leafMeshes[0];
expect(leaf.positions.length).toBe(24); // Quader: 8 Ecken
expect(leaf.indices.length).toBe(36); // 12 Dreiecke
// D1: height 2.1, sitzt am Wand-UK (0) -> Blatt UK 0 .. OK 2.1.
const zs: number[] = [];
for (let i = 2; i < leaf.positions.length; i += 3) zs.push(leaf.positions[i]);
expect(Math.min(...zs)).toBeCloseTo(0, 6);
expect(Math.max(...zs)).toBeCloseTo(2.1, 6);
});
it("Türen bekommen keine Scheibe; je Fenster genau 2 (window-standard = zweifach verglast)", () => {
// Volles Sample-Projekt: 2 Fenster (O1, O2, beide Typ "window-standard" =
// Zweifachverglasung) + 1 Tür (D1), Kontext leer. Neben den Glasscheiben
@@ -1825,6 +1908,33 @@ describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => {
expect(Math.max(...allHeights)).toBeCloseTo(expectedRidgeZ, 6);
});
it("Dach mit Bauteil-Material (roofTypeId) bekommt eine Textur-Ebene (Stil 'Textured')", () => {
// Vorher waren Dächer im Textured-Modus IMMER auf das Fallback-Schachbrett
// geklemmt (Nutzer-Report: "Dach soll wie Dachziegel aussehen, nicht so
// billig"). Mehrschichtiges Dach über `roofTypeId: "roof-warm"` (sample-
// project), äusserste Schicht = "roof-tiles" (echtes Bibliotheks-Material).
const roof: Roof = { ...baseRoof, roofTypeId: "roof-warm" };
const project: Project = { ...sampleProject, roofs: [roof] };
const { meshes } = projectToModel3d(project);
// Die äusserste (Eindeckungs-)Schicht hat KEINE Roof.color-Übersteuerung ->
// trägt eine aufgelöste materialIndex (nicht mehr die ROOF_RGB-Testfarbe,
// deshalb hier nicht über isRoofMesh gefiltert, sondern über materialIndex).
const withMaterial = meshes.filter((m) => typeof m.materialIndex === "number");
expect(withMaterial.length).toBeGreaterThan(0);
expect(withMaterial.every((m) => (m.materialIndex ?? 0) >= 1)).toBe(true);
});
it("Roof.color-Übersteuerung unterdrückt die Material-Textur (bewusst einheitliche Farbe)", () => {
const roof: Roof = { ...baseRoof, roofTypeId: "roof-warm", color: "#ff0000" };
const project: Project = { ...sampleProject, roofs: [roof] };
const { meshes } = projectToModel3d(project);
const roofRedMeshes = meshes.filter(
(m) => m.color[0] === 1 && m.color[1] === 0 && m.color[2] === 0,
);
expect(roofRedMeshes.length).toBeGreaterThan(0);
expect(roofRedMeshes.every((m) => m.materialIndex === undefined)).toBe(true);
});
it("Walmdach: vier Dachflächen (2 Quader + 2 Dreiecke) korrekt trianguliert", () => {
const walmRoof: Roof = { ...baseRoof, id: "R2", shape: "walm" };
const project: Project = { ...sampleProject, roofs: [walmRoof] };