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
+109
View File
@@ -0,0 +1,109 @@
// Kontext-Objekt-Auswahl: deriveSelection liefert für ein selektiertes
// Kontext-Objekt (importiertes Mesh/Terrain/Konturen) Bbox + reale LV95-
// Koordinate (falls project.geoAnchor gesetzt) — s. Nutzer-Wunsch, direkt im
// Object-Info-Panel prüfen zu können, ob/wohin georeferenziert importiert wurde.
import { describe, it, expect } from "vitest";
import { deriveSelection } from "./selectionInfo";
import type { Project } from "../model/types";
const baseProject = {
id: "p",
name: "T",
layers: [],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, baseElevation: 0 },
],
walls: [],
drawings2d: [],
};
describe("deriveSelection — Kontext-Objekt", () => {
it("importedMesh: Bbox aus x/y (Höhe z ignoriert), ohne geoAnchor kein lv95Center", () => {
const project = {
...baseProject,
context: [
{
id: "CTX1",
type: "importedMesh",
name: "Test",
positions: [0, 0, 5, 10, 6, 8, 4, 3, 2],
indices: [0, 1, 2],
},
],
} as unknown as Project;
const sel = deriveSelection(project, [], null, null, null, null, null, null, null, null, "CTX1");
expect(sel?.kind).toBe("contextObject");
expect(sel?.id).toBe("CTX1");
expect(sel?.bbox).toEqual({ minX: 0, minY: 0, maxX: 10, maxY: 6 });
expect(sel?.contextObject?.objType).toBe("importedMesh");
expect(sel?.contextObject?.lv95Center).toBeUndefined();
});
it("mit geoAnchor: lv95Center = Bbox-Mittelpunkt zurückgerechnet auf LV95", () => {
const project = {
...baseProject,
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 0, y: 0 } },
context: [
{
id: "CTX1",
type: "importedMesh",
name: "Test",
positions: [0, 0, 0, 100, 50, 0],
indices: [0, 1, 0],
},
],
} as unknown as Project;
const sel = deriveSelection(project, [], null, null, null, null, null, null, null, null, "CTX1");
// Bbox-Mittelpunkt (50,25) -> LV95 = origin + Mittelpunkt (origin = lv95 - model = lv95, model (0,0)).
expect(sel?.contextObject?.lv95Center).toEqual({ e: 2600050, n: 1200025 });
});
it("mit geoAnchor.model != (0,0): Origin korrekt berücksichtigt", () => {
const project = {
...baseProject,
geoAnchor: { lv95: { e: 2600000, n: 1200000 }, model: { x: 500, y: 300 } },
context: [
{
id: "CTX1",
type: "importedMesh",
name: "Test",
positions: [500, 300, 0],
indices: [0, 0, 0],
},
],
} as unknown as Project;
const sel = deriveSelection(project, [], null, null, null, null, null, null, null, null, "CTX1");
// Der Modellpunkt (500,300) IST der Anker -> muss exakt auf lv95 (2600000,1200000) zurückrechnen.
expect(sel?.contextObject?.lv95Center).toEqual({ e: 2600000, n: 1200000 });
});
it("contourSet: Bbox aus contours[].pts statt positions", () => {
const project = {
...baseProject,
context: [
{
id: "CS1",
type: "contourSet",
name: "Höhenlinien",
contours: [
{ z: 400, pts: [{ x: -5, y: 2 }, { x: 8, y: -3 }], closed: false },
],
},
],
} as unknown as Project;
const sel = deriveSelection(project, [], null, null, null, null, null, null, null, null, "CS1");
expect(sel?.contextObject?.objType).toBe("contourSet");
expect(sel?.bbox).toEqual({ minX: -5, minY: -3, maxX: 8, maxY: 2 });
});
it("unbekannte Id -> null", () => {
const project = { ...baseProject, context: [] } as unknown as Project;
expect(deriveSelection(project, [], null, null, null, null, null, null, null, null, "does-not-exist")).toBeNull();
});
it("keine Kontext-Auswahl (null) -> fällt durch zu anderen Kanälen (hier auch leer -> null)", () => {
const project = { ...baseProject, context: [] } as unknown as Project;
expect(deriveSelection(project, [], null)).toBeNull();
});
});
+5 -2
View File
@@ -8,7 +8,7 @@
// laufen. Die Slice tippt daher nur ihre eigenen Felder; `get()` liefert
// RootState (siehe appStore.ts), wir greifen punktuell auf View-Felder zu.
import { sampleProject } from "../model/sampleProject";
import { emptyProject } from "../model/sampleProject";
import {
flattenCategories,
recomputeFloorElevations,
@@ -363,7 +363,10 @@ export function createProjectSlice(
return {
...history,
project: sampleProject,
// TEMPORÄR für manuellen Test (Nutzer-Wunsch): leeres Projekt statt Demo-
// Haus. NICHT committen — vor dem nächsten echten Commit zurück auf
// `sampleProject` stellen.
project: emptyProject,
setProject,
+73 -1
View File
@@ -24,6 +24,7 @@ import type {
Ceiling,
Column,
ColumnProfile,
ContextObject,
Drawing2D,
Drawing2DGeom,
ExtrudedSolid,
@@ -41,6 +42,7 @@ import type {
Wall,
WallReferenceLine,
} from "../model/types";
import { localToLv95 } from "../io/lv95";
import { columnVerticalExtent } from "../model/wall";
import { columnFootprint } from "../geometry/column";
import { evaluateRoom, polygonArea } from "../geometry/roomArea";
@@ -258,6 +260,8 @@ export interface StairInfo {
treadDepth: number;
/** Laufrichtung aufwärts (Auf-/Abpfeil-Richtung). */
up: boolean;
/** Pfeil-Darstellung (offener Winkel- vs. gefüllter Vollpfeil). Fehlt → "line". */
arrowStyle?: "line" | "filled";
/** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */
referenz?: "links" | "mitte" | "rechts";
/** Zugewiesener Treppentyp (Bibliothek, `Stair.typeId`) oder undefined. */
@@ -348,7 +352,8 @@ export interface Selection {
| "door"
| "extrudedSolid"
| "column"
| "roof";
| "roof"
| "contextObject";
id: string;
/** Grafik-Kategorie (Ebene) des Elements. */
categoryCode: string;
@@ -424,6 +429,20 @@ export interface Selection {
extrudedSolid?: ExtrudedSolidInfo;
/** Stützen-Attribute (nur bei `kind === "column"`). */
column?: ColumnInfo;
/** Kontext-Objekt-Attribute (nur bei `kind === "contextObject"`). */
contextObject?: ContextObjectInfo;
}
/**
* Kontext-Objekt-spezifische Attribute (importiertes Mesh/Terrain/Konturen-
* Satz) für das Object-Info-Panel — v. a. zum Prüfen, OB/WOHIN georeferenziert
* importiert wurde (Nutzer-Wunsch: reale Koordinaten direkt sichtbar).
*/
export interface ContextObjectInfo {
objType: ContextObject["type"];
/** Reale LV95-Koordinate des Bbox-Mittelpunkts, falls `Project.geoAnchor`
* gesetzt ist (sonst `undefined` — kein Bezug bekannt). */
lv95Center?: { e: number; n: number };
}
const WALL_FALLBACK_MM = 0.18;
@@ -765,6 +784,7 @@ function stairSelection(project: Project, s: Stair): Selection {
riserHeight: geo.riserHeight,
treadDepth: geo.treadDepth,
up: s.up !== false,
arrowStyle: s.arrowStyle,
referenz: s.referenz,
typeId: s.typeId,
floorId: s.floorId,
@@ -908,6 +928,53 @@ function columnSelection(project: Project, col: Column): Selection {
};
}
/**
* Selektion für ein Kontext-Objekt (importiertes Mesh/Terrain/Konturen-Satz)
* ableiten. Bbox aus den rohen x/y-Koordinaten (Meshes: flaches [x,y,z…]-
* Array, nur x/y je Vertex; Konturen: `pts` je Kontur) — die Höhe (z) bleibt
* unberücksichtigt (2D-Bbox wie bei allen anderen Elementarten). `lv95Center`
* nur, wenn `project.geoAnchor` gesetzt ist (sonst kein Rückrechnungs-Bezug).
*/
function contextObjectSelection(project: Project, obj: ContextObject): Selection {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
const consider = (x: number, y: number) => {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
};
if (obj.type === "contourSet") {
for (const c of obj.contours) for (const p of c.pts) consider(p.x, p.y);
} else if (obj.type === "image") {
consider(obj.min.x, obj.min.y);
consider(obj.max.x, obj.max.y);
} else {
const pos = obj.positions;
for (let i = 0; i + 2 < pos.length; i += 3) consider(pos[i], pos[i + 1]);
}
if (!isFinite(minX)) minX = minY = maxX = maxY = 0;
const anchor = project.geoAnchor;
const lv95Center = anchor
? localToLv95(
(minX + maxX) / 2,
(minY + maxY) / 2,
{ e: anchor.lv95.e - anchor.model.x, n: anchor.lv95.n - anchor.model.y },
)
: undefined;
return {
kind: "contextObject",
id: obj.id,
categoryCode: "",
color: "#888888",
weightMm: WALL_FALLBACK_MM,
bbox: { minX, minY, maxX, maxY },
contextObject: { objType: obj.type, lv95Center },
};
}
/** Selektion für ein 2D-Zeichenelement ableiten (gleiche Auflösung wie generatePlan). */
function drawingSelection(project: Project, d: Drawing2D): Selection {
const ls = d.lineStyleId
@@ -963,7 +1030,12 @@ export function deriveSelection(
selectedExtrudedSolidId: string | null = null,
selectedColumnId: string | null = null,
selectedRoofId: string | null = null,
selectedContextObjectId: string | null = null,
): Selection | null {
if (selectedContextObjectId) {
const o = (project.context ?? []).find((x) => x.id === selectedContextObjectId);
if (o) return contextObjectSelection(project, o);
}
if (selectedDrawingId) {
const d = project.drawings2d.find((x) => x.id === selectedDrawingId);
return d ? drawingSelection(project, d) : null;
+9
View File
@@ -22,6 +22,9 @@ export interface SiteSlice {
addContextObjects: (objs: ContextObject[]) => void;
/** Entfernt ein Kontext-Objekt per ID (No-op bei unbekannter ID). */
removeContextObject: (id: string) => void;
/** Entfernt mehrere Kontext-Objekte in EINEM Schritt (z. B. eine ganze
* Ebene/Gruppe importierter Gebäude) — ein einziger Undo-Eintrag. */
removeContextObjects: (ids: string[]) => void;
/**
* Erzeugt aus einem ContourSet (per ID) ein Gelände-TIN und fügt es als
* weiteres Kontext-Objekt hinzu. Liefert die ID des neuen TerrainMesh, oder
@@ -64,6 +67,12 @@ export function createSiteSlice(
removeContextObject: (id) =>
setContext((ctx) => ctx.filter((o) => o.id !== id)),
removeContextObjects: (ids) => {
if (ids.length === 0) return;
const drop = new Set(ids);
setContext((ctx) => ctx.filter((o) => !drop.has(o.id)));
},
generateTerrain: (contourSetId) => {
const ctx = get().project.context ?? [];
const set = ctx.find(