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 51e57c368b
commit b9e330ecb7
89 changed files with 7723 additions and 1848 deletions
+47 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import type { Column, ExtrudedSolid, Project } from "./types";
import { sampleProject } from "./sampleProject";
import { translateProject } from "./geoRebase";
import { translateProject, projectToWorldCoords, projectGeoOrigin } from "./geoRebase";
const DX = 100;
const DY = -50;
@@ -201,3 +201,49 @@ describe("translateProject", () => {
expect(out.geoAnchor).toBeUndefined();
});
});
describe("projectGeoOrigin / projectToWorldCoords", () => {
it("ohne geoAnchor -> Origin null, projectToWorldCoords ist Identität", () => {
expect(projectGeoOrigin(sampleProject)).toBeNull();
expect(projectToWorldCoords(sampleProject)).toBe(sampleProject);
});
it("Origin = lv95 − model", () => {
const p: Project = {
...sampleProject,
geoAnchor: { lv95: { e: 2600500, n: 1200300 }, model: { x: 500, y: 300 } },
};
expect(projectGeoOrigin(p)).toEqual({ e: 2600000, n: 1200000 });
});
it("Round-Trip: lokal bearbeitetes Modell exportiert an echten LV95-Koordinaten", () => {
// Rohimport (model == LV95): eine Wand steht an LV95 (2600010, 1200020).
// Danach per georef um (−2600000, −1200000) lokalisiert -> Wand bei (10,20),
// geoAnchor.model wandert mit. Der Welt-Export muss (2600010,1200020) liefern.
const raw: Project = {
...sampleProject,
walls: [
{
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 2600010, y: 1200020 },
end: { x: 2600015, y: 1200020 },
wallTypeId: "aw",
height: 2.6,
},
],
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 2600000, y: 1200000 } },
};
// georef: klick auf (2600000,1200000) -> translate um (−2600000,−1200000).
const local = translateProject(raw, -2600000, -1200000);
expect(local.walls[0].start).toEqual({ x: 10, y: 20 });
// geoAnchor.model ist mitgewandert, lv95 fix.
expect(local.geoAnchor).toEqual({ lv95: { e: 2600000, n: 1200000 }, model: { x: 0, y: 0 } });
// Welt-Export rechnet exakt auf die ursprünglichen LV95-Koordinaten zurück.
const world = projectToWorldCoords(local);
expect(world.walls[0].start).toEqual({ x: 2600010, y: 1200020 });
expect(world.walls[0].end).toEqual({ x: 2600015, y: 1200020 });
});
});
+28
View File
@@ -42,6 +42,9 @@ function shiftContextObject(obj: ContextObject, dx: number, dy: number): Context
if (obj.type === "contourSet") {
return { ...obj, contours: obj.contours.map((c) => shiftContour(c, dx, dy)) };
}
if (obj.type === "image") {
return { ...obj, min: shift(obj.min, dx, dy), max: shift(obj.max, dx, dy) };
}
const positions = obj.positions.slice();
for (let i = 0; i + 2 < positions.length; i += 3) {
positions[i] += dx;
@@ -118,3 +121,28 @@ export function translateProject(project: Project, dx: number, dy: number): Proj
: undefined,
};
}
/**
* Der LV95-Ursprung eines Projekts: die reale Landeskoordinate, die auf den
* Modell-Nullpunkt (0,0) abgebildet wird. Aus `geoAnchor` abgeleitet
* (`origin = lv95 − model`), oder `null`, wenn KEIN Standort-Bezug existiert.
*/
export function projectGeoOrigin(project: Project): { e: number; n: number } | null {
const a = project.geoAnchor;
if (!a) return null;
return { e: a.lv95.e - a.model.x, n: a.lv95.n - a.model.y };
}
/**
* Kopie des Projekts mit SÄMTLICHER Geometrie auf die REALEN Weltkoordinaten
* (LV95) verschoben — für georeferenzierte MODELL-Exporte (IFC/DXF): das Modell
* wird lokal nahe (0,0) bearbeitet, der Export schreibt aber die wahren
* Landeskoordinaten, damit die Datei in anderer CAD-/BIM-Software lagerichtig
* sitzt. Ohne `geoAnchor` (kein Bezug) bleibt das Projekt unverändert (Identität).
* NICHT für Papier-/Layout-Exporte (die bleiben in lokalen Koordinaten).
*/
export function projectToWorldCoords(project: Project): Project {
const origin = projectGeoOrigin(project);
if (!origin) return project;
return translateProject(project, origin.e, origin.n);
}
+36 -1
View File
@@ -10,6 +10,7 @@ import { materialFromAsset } from "../materials/runtime";
const PLASTER_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Plaster001")!;
const BRICK_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Bricks104")!;
const CONCRETE_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Concrete048")!;
const ROOF_TILE_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "RoofingTiles013A")!;
// Demo-Haus mit zwei Geschossen:
// EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand.
@@ -148,6 +149,19 @@ export const sampleProject: Project = {
joinPriority: 10,
material: materialFromAsset(PLASTER_ASSET),
},
// Dacheindeckung (Tonziegel) — eigenes Bauteil statt des früheren
// "brick"-Platzhalters, damit Dächer eine ECHTE Ziegel-Textur zeigen
// (Stil „Texturiert") statt der Backstein-Fassadentextur. `color` (Flach-
// Fallback für „Schattiert") entspricht dem bisherigen `ROOF_RGB` in
// toWalls3d.ts, damit der Nicht-Textur-Look unverändert warm/terracotta bleibt.
{
id: "roof-tiles",
name: "Dachziegel",
color: "#b8735c",
hatchId: "sia-brick",
joinPriority: 15,
material: materialFromAsset(ROOF_TILE_ASSET),
},
// Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität).
{
id: "concrete",
@@ -208,7 +222,7 @@ export const sampleProject: Project = {
name: "Steildach gedämmt",
// Aussen (Eindeckung) → innen (Verkleidung); Bauteile aus der Bibliothek.
layers: [
{ componentId: "brick", thickness: 0.04 }, // Eindeckung (Ziegel, Platzhalter)
{ componentId: "roof-tiles", thickness: 0.04 }, // Eindeckung (Tonziegel)
{ componentId: "insulation", thickness: 0.22, jointLineStyleId: "thin" }, // Sparren/Dämmung
{ componentId: "render-int", thickness: 0.0125 }, // Innenverkleidung (GK)
],
@@ -546,3 +560,24 @@ export const sampleProject: Project = {
// über den DXF-Import bzw. die Gelände-Generierung gefüllt.
context: [],
};
/**
* Leeres Projekt (nur Bibliotheken, keine Geometrie) — für manuelle Tests, die
* ohne das Demo-Haus starten wollen (z. B. Standort-Import auf leerer Fläche).
*/
export const emptyProject: Project = {
...sampleProject,
id: "empty",
name: "Leeres Projekt",
walls: [],
doors: [],
openings: [],
ceilings: [],
roofs: [],
stairs: [],
rooms: [],
columns: [],
extrudedSolids: [],
drawings2d: [],
context: [],
};
+5 -2
View File
@@ -13,6 +13,9 @@ import type { Contour, TerrainMesh } from "./types";
let terrainSeq = 0;
/** Sichtbarer Ebenen-/Gruppierungsname des Geländes (SitePanel). */
export const TERRAIN_LAYER = "Terrain";
/**
* Erzeugt ein TIN aus Konturen.
*
@@ -96,7 +99,7 @@ export function generateTerrainFromContours(contours: Contour[]): TerrainMesh {
indices.push(a, b, c);
}
return { id, type: "terrainMesh", name, positions, indices };
return { id, type: "terrainMesh", name, layer: TERRAIN_LAYER, positions, indices };
}
/**
@@ -153,5 +156,5 @@ export function terrainMeshFromGrid(grid: TerrainGrid): TerrainMesh {
if (positions.length < 9 || indices.length < 3) {
return { id, type: "terrainMesh", name, positions: [], indices: [] };
}
return { id, type: "terrainMesh", name, positions, indices };
return { id, type: "terrainMesh", name, layer: TERRAIN_LAYER, positions, indices };
}
+60 -2
View File
@@ -453,6 +453,14 @@ export interface WindowType {
insetFromFace?: number;
/** Von welcher Wandfläche der {@link insetFromFace} gemessen wird; Default "aussen". */
insetFace?: "aussen" | "innen";
/**
* Schicht-Rücksprung an der Laibung: weicht die INNERSTE Wandschicht (meist
* Gipsplatte/Holzverkleidung) an diesem Fenster um ihre eigene Dicke von der
* Öffnung zurück, statt bündig mit den übrigen Schichten abzuschliessen —
* anders als {@link insetFromFace} (der Rahmen-Position), ändert dies die
* WAND-GEOMETRIE selbst an der Laibung. Default false (bündig, Alt-Verhalten).
*/
innerLayerRecess?: boolean;
/**
* Oberlicht: Höhe eines festen, verglasten Oberlichts über dem Hauptflügel
* (Meter), abgeteilt durch einen Kämpfer. 0/fehlt ⇒ kein Oberlicht.
@@ -1356,6 +1364,12 @@ export interface Stair {
* Kategorie-Farbe.
*/
color?: string;
/**
* Darstellung des Auf-/Abpfeils an der Lauflinie: "line" (offener
* Winkelpfeil, Default = heutiges Verhalten) oder "filled" (gefülltes
* Dreieck als SIA-Vollpfeil). Fehlt sie, gilt "line".
*/
arrowStyle?: "line" | "filled";
}
/**
@@ -1761,12 +1775,49 @@ export interface TerrainMesh {
id: string;
type: "terrainMesh";
name: string;
/** Ebenen-/Gruppierungsname (SitePanel), z. B. „Terrain". Optional. */
layer?: string;
positions: number[];
indices: number[];
/**
* Optionale Pro-Vertex-Albedo (flach `[r,g,b, …]`, 0..1) — ein Wert je
* Positions-Vertex, beim Import aus einem SWISSIMAGE-Luftbild gesampelt
* („Luftbild aufs Gelände drapieren"). render3d färbt das TIN dann pro Vertex
* statt mit der Grundfarbe. Fehlt es, bleibt das Gelände einfarbig.
*/
vertexColors?: number[];
}
/** Ein Kontext-Objekt: importiertes Mesh, Konturen-Satz oder abgeleitetes TIN. */
export type ContextObject = ImportedMesh | ContourSet | TerrainMesh;
/**
* Ein georeferenziertes Rasterbild (Luftbild/SWISSIMAGE, eingefügtes Bild) —
* planar von oben auf die Modell-Ebene gelegt. `src` = Data-URL (PNG/JPEG),
* `min`/`max` = Modell-Meter-Bounding-Box, die das Bild ausfüllt (Bild-Oben =
* +Y). Wird im Grundriss als Hintergrund-Ebene gezeichnet (unter der Geometrie).
* Gelände-Drapierung im 3D ist Folge-Arbeit.
*/
export interface ContextImage {
id: string;
type: "image";
name: string;
/** Ebenen-/Gruppierungsname (SitePanel), z. B. „Luftbild". Optional. */
layer?: string;
/** Bildquelle als Data-URL (PNG/JPEG). */
src: string;
/** Modell-Meter Bounding-Box (das Bild füllt [min..max] planar von oben). */
min: Vec2;
max: Vec2;
/** Deckkraft 0..1 (Default 1). */
opacity?: number;
/**
* Aufs Gelände-TIN „drapieren": im 3D-Viewer wird das Bild als Textur über das
* Terrain gelegt (pixelscharf, s. render3d `set_aerial_texture`). Unabhängig von
* der 2D-Hintergrund-Darstellung. Default false.
*/
drape?: boolean;
}
/** Ein Kontext-Objekt: importiertes Mesh, Konturen-Satz, TIN oder Rasterbild. */
export type ContextObject = ImportedMesh | ContourSet | TerrainMesh | ContextImage;
/** Das gesamte Projekt. */
/**
@@ -2196,6 +2247,13 @@ export interface Project {
geoAnchor?: {
/** Reale Landeskoordinate (LV95/EPSG:2056, Meter). */
lv95: { e: number; n: number };
/**
* Reale Höhe des Bezugspunkts in Metern über Meer (m ü. M.). Wie ArchiCADs
* „Vermessungspunkt" (Ost/Nord/Höhe): rein informativ verankert, VERSCHIEBT
* das Modell NICHT. Wird beim IFC-Export als `IfcSite.RefElevation`
* (Standort-Datumshöhe) geschrieben. Optional.
*/
height?: number;
/** Zugehöriger Punkt im Modell (lokale Meter), Default (0,0). */
model: Vec2;
/** Anzeige-Adresse/Bezeichnung des Bezugspunkts. */