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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user