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
+16 -1
View File
@@ -135,9 +135,22 @@ export function Viewport3D(
* Wasm3DViewport); die three.js-Sicht hebt die Auswahl weiterhin über ihr
* eigenes Material hervor (highlightMat in ThreeViewport3D). */
highlightLines?: Float32Array | null;
/** Aktive 3D-Schnittebene (nur WASM-Pfad) — s. Wasm3DViewport `section3d`.
* Bindet den Live-Schnitt an eine Grundriss-Schnittlinie. */
section3d?: { point: [number, number, number]; normal: [number, number, number] } | null;
/** Klick auf den gebundenen Schnitt-Knopf im WASM-Viewport → Bindung lösen. */
onClearSection3d?: () => void;
},
) {
const { gridVisible, onToggleGrid, onPick3d, highlightLines, ...threeProps } = props;
const {
gridVisible,
onToggleGrid,
onPick3d,
highlightLines,
section3d,
onClearSection3d,
...threeProps
} = props;
if (WASM_ENGINE_ACTIVE) {
return (
<Wasm3DViewport
@@ -162,6 +175,8 @@ export function Viewport3D(
onEditRoofPitch={props.onEditRoofPitch}
onEditWallTop={props.onEditWallTop}
onEditEnd={props.onEditEnd}
section3d={section3d}
onClearSection3d={onClearSection3d}
/>
);
}
+67 -54
View File
@@ -274,6 +274,14 @@ function fitTargetDist(project: Project): {
grow(p[0], s.zTop, p[1]);
}
}
// Meshes (Dächer, Extrusionen, Stützen UND importierte Gebäude/Terrain) —
// ohne die kann „Alles einpassen" den (evtl. weit entfernten) Import nicht
// rahmen: model (x,y,z=Höhe) → world (x, z, y). Nutzer-Report: „im 3D
// funktioniert das Fokussieren / Alles-anzeigen nicht".
for (const m of model.meshes) {
const pos = m.positions;
for (let i = 0; i + 2 < pos.length; i += 3) grow(pos[i], pos[i + 2], pos[i + 1]);
}
// Leeres Modell: neutraler Standpunkt (entspricht dem three.js-Start und dem
// Rust-Fallback in `set_view_preset` bei fehlender Bounding-Box).
if (!Number.isFinite(minX)) {
@@ -334,6 +342,8 @@ export function Wasm3DViewport({
onEditRoofPitch,
onEditWallTop,
onEditEnd,
section3d = null,
onClearSection3d,
}: {
project: Project;
/** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */
@@ -393,6 +403,12 @@ export function Wasm3DViewport({
onEditWallTop?: (wallId: string, topZ: number) => void;
/** Ende eines Griff-Drags. */
onEditEnd?: () => void;
/** Aktive 3D-Schnittebene (world point+normal) aus der gebundenen Schnittlinie
* (App-State, `sectionPlaneFromLevel`), oder `null` = kein Live-Schnitt.
* Ersetzt den früheren lokalen Hardcode-Toggle. */
section3d?: { point: [number, number, number]; normal: [number, number, number] } | null;
/** Klick auf den Viewport-Schnitt-Knopf: hebt die Bindung auf (App löscht die Id). */
onClearSection3d?: () => void;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// Pick-Callback in einem Ref spiegeln, damit der (einmal registrierte)
@@ -438,11 +454,9 @@ export function Wasm3DViewport({
ready,
failed,
} = useWasm3dRenderer(canvasRef, true);
// Live-Schnitt-Zustand (lokaler Overlay-Umschalter, MVP): vertikale
// Schnittebene durch die Modellmitte, Blick +Y (Grundriss-Y = world Z, s.
// section.rs `looking_plus_y`: normal = [0,0,1]). Kein Store-/App-Prop —
// reine Viewport-Ansichts-Option analog zum Grid-Overlay.
const [sectionActive, setSectionActive] = useState(false);
// Live-Schnitt: die aktive Ebene kommt jetzt aus `section3d` (App-State, an
// eine Schnittlinie gebunden — `sectionPlaneFromLevel`) statt aus einem
// lokalen Hardcode-Toggle. `null` = kein Schnitt.
// Orbit-Zustand in einem Ref (kein React-State): Mausbewegungen zeichnen
// direkt neu, ohne die Komponente je Frame zu re-rendern. Dient NUR der
// freien Navigation NACH einem Praeset-Sprung (s. Datei-Kommentar).
@@ -515,36 +529,33 @@ export function Wasm3DViewport({
}, [ready, renderMode, setRenderStyle]);
// Boden-Referenzraster: bei Bereitschaft oder Wechsel von Sichtbarkeit/
// Geschoss-Hoehe neu setzen + zeichnen. Fixe Ausdehnung/Abstand (40 m /
// 1 m) — ausreichend fuer die ueblichen Grundriss-Groessen dieses Spikes.
// Geschoss-Hoehe neu setzen + zeichnen. Fixe Ausdehnung/Abstand (300 m /
// 1 m) — bei den bisherigen 40 m lief man bei flachem Blickwinkel (Iso/
// Perspektive) schnell auf den harten Gitterrand (wirkte wie ein Clipping-
// Bug, war aber nur das Ende der Flaeche). 300 m ist bei gleicher
// Liniendichte immer noch sehr billig (nur ein LineList-Puffer).
useEffect(() => {
if (!ready) return;
setGroundGrid(gridVisible, groundElevation, 40, 1);
setGroundGrid(gridVisible, groundElevation, 300, 1);
redrawRef.current();
}, [ready, gridVisible, groundElevation, setGroundGrid]);
// Live-Schnitt: bei Bereitschaft, Umschalten (`sectionActive`) oder
// Modelländerung neu setzen. MVP-Ebene = vertikal durch die Modellmitte
// (`fitTargetDist`-Ziel), Blick nach +Y -> Normale [0,0,1] (Grundriss-Y =
// world Z, s. section.rs `looking_plus_y`). Bei aktivem Schnitt baut Rust die
// Schnittflaechen-Schraffur (Caps) aus den gecachten Waenden/Decken.
// Live-Schnitt: die gebundene Schnittlinie liefert `section3d` = world
// point+normal (vertikale Ebene entlang der Grundriss-Linie, Blickrichtung
// aus `directionSign` — s. sectionPlaneFromLevel). Ohne Bindung (`null`) kein
// Schnitt. Bei aktivem Schnitt baut Rust die Schnittflaechen-Schraffur (Caps)
// aus den gecachten Waenden/Decken. Neu setzen bei Bereitschaft, Wechsel der
// Ebene oder Modelländerung (Caps folgen dem aktuellen Modell).
useEffect(() => {
if (!ready) return;
// Bei inaktivem Schnitt braucht es kein Ziel (Ebene ist ohnehin unsichtbar)
// — `fitTargetDist` NICHT aufrufen, das würde sonst bei JEDER Projekt-
// Änderung (auch waehrend eines Griff-Drags) unnoetig die komplette
// Wand-/Oeffnungs-/Decken-Geometrie erneut flatten, nur um eine Bounding-
// Box zu ziehen, die `updateModel` fuer denselben `project`-Stand gerade
// erst berechnet hat.
if (!sectionActive) {
if (!section3d) {
setSectionPlane(false, [0, 0, 0], [0, 0, 1]);
redrawRef.current();
return;
}
const { target } = fitTargetDist(project);
setSectionPlane(true, target, [0, 0, 1]);
setSectionPlane(true, section3d.point, section3d.normal);
redrawRef.current();
}, [ready, sectionActive, project, setSectionPlane]);
}, [ready, section3d, project, setSectionPlane]);
// Auswahl-Hervorhebung: bei Bereitschaft oder Änderung der Highlight-Linien
// (Auswahl gewechselt/aufgehoben) neu setzen + zeichnen. Die Engine hält das
@@ -1030,37 +1041,39 @@ export function Wasm3DViewport({
}}
/>
))}
{/* Live-Schnitt-Umschalter (eigener Overlay-Button, MVP): vertikale
Schnittebene durch die Modellmitte, Blick +Y. Sitzt ueber dem
Grid-Button (falls vorhanden). Reine Ansichts-Option des Viewports. */}
<button
type="button"
aria-label={t("viewport3d.section.label")}
onClick={() => setSectionActive((v) => !v)}
title={t(sectionActive ? "viewport3d.section.hide" : "viewport3d.section.show")}
aria-pressed={sectionActive}
style={{
position: "absolute",
right: 10,
bottom: onToggleGrid ? 48 : 10,
width: 30,
height: 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
borderRadius: 7,
border: "1px solid rgba(255,255,255,0.14)",
background: sectionActive ? "rgba(255,255,255,0.14)" : "rgba(20,20,24,0.65)",
color: sectionActive ? "#f2f2f2" : "#9a9a9a",
cursor: "pointer",
backdropFilter: "blur(4px)",
}}
>
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: 18 }}>
content_cut
</span>
</button>
{/* Live-Schnitt-Knopf: nur sichtbar, wenn eine Schnittlinie den 3D-Schnitt
treibt (`section3d` gebunden, Bindung im Objekt-Info gesetzt). Klick
hebt die Bindung auf. Sitzt ueber dem Grid-Button (falls vorhanden). */}
{section3d && (
<button
type="button"
aria-label={t("viewport3d.section.label")}
onClick={() => onClearSection3d?.()}
title={t("viewport3d.section.bound")}
aria-pressed
style={{
position: "absolute",
right: 10,
bottom: onToggleGrid ? 48 : 10,
width: 30,
height: 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
borderRadius: 7,
border: "1px solid rgba(255,255,255,0.14)",
background: "rgba(255,255,255,0.14)",
color: "#f2f2f2",
cursor: "pointer",
backdropFilter: "blur(4px)",
}}
>
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: 18 }}>
content_cut
</span>
</button>
)}
{onToggleGrid && (
// Dezenter Overlay-Button (eigener DOM-Knoten neben dem Canvas, `.viewport`
// ist `position: relative` — s. styles.css), stört die Maus-Navigation
+62 -1
View File
@@ -17,11 +17,12 @@
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { Project } from "../model/types";
import type { ContextImage, Project } from "../model/types";
import { projectToModel3d } from "../plan/toWalls3d";
import type { DetailLevel } from "../ui/TopBar";
import { loadEngine3d } from "../engine/engine3d";
import { decodeMaterialTextures } from "./materialTextures";
import { decodeImageToRgba } from "../io/swissImage";
/** Fertige Kamera für einen Frame (world-Meter, Y-up — wie render3d::types::Camera). */
export interface Camera3d {
@@ -82,6 +83,22 @@ interface WebModelRendererLike {
* die Karten belegen Ebenen 1..N (siehe `WallInput.materialIndex`). Optional —
* ältere WASM-Builds ohne diesen Export lassen den Aufrufer still zurückfallen. */
set_material_textures?(rgba: Uint8Array, layerCount: number): void;
/** Luftbild aufs Gelände drapieren (`web::set_aerial_texture`): `rgba` =
* gepackte RGBA8-Bytes (`width*height*4`, zeilenweise von oben = Nord), plus
* Modell-Meter-Bbox (min/max, x=Ost/y=Nord). render3d legt es pixelscharf als
* Textur über das Terrain-TIN. Optional — ältere WASM-Builds ohne diesen
* Export lassen den Aufrufer still zurückfallen. */
set_aerial_texture?(
rgba: Uint8Array,
width: number,
height: number,
minX: number,
minY: number,
maxX: number,
maxY: number,
): void;
/** Drapiertes Luftbild löschen (Gelände wieder einfarbig/shaded). Optional. */
clear_aerial?(): void;
set_camera(
eyeX: number,
eyeY: number,
@@ -166,6 +183,9 @@ export function useWasm3dRenderer(
// erneutes (teures) Dekodieren/Hochladen, wenn sich die Materialien nicht
// geändert haben (updateModel läuft bei JEDER Projektänderung).
const materialSigRef = useRef<string | null>(null);
// Signatur des zuletzt hochgeladenen Drape-Luftbilds (src + Bbox) — verhindert
// ein erneutes (teures) Dekodieren/Hochladen bei unveränderter Lage.
const aerialSigRef = useRef<string | null>(null);
/** Modell bei Projektänderung neu setzen (re-extrudiert in Rust). `detail`
* steuert den Öffnungs-Detailgrad (grob/mittel/fein) im 3D wie im Grundriss. */
@@ -197,6 +217,47 @@ export function useWasm3dRenderer(
console.warn("render3d set_material_textures fehlgeschlagen:", e);
}
})();
// Luftbild aufs Gelände drapieren: ein ContextImage mit `drape:true` wird als
// GPU-Textur hochgeladen (render3d legt es pixelscharf über das Terrain).
// Wie die Materialien asynchron + signatur-gecacht (Bild ändert sich selten).
if (typeof r.set_aerial_texture !== "function") return;
void (async () => {
try {
const drape = project.context?.find(
(o): o is ContextImage => o.type === "image" && o.drape === true,
);
const sig = drape
? `${drape.src.length}:${drape.src.slice(0, 64)}:${drape.min.x},${drape.min.y},${drape.max.x},${drape.max.y}`
: "";
if (sig === aerialSigRef.current) return; // unverändert
aerialSigRef.current = sig;
const cur = rendererRef.current;
if (!cur || typeof cur.set_aerial_texture !== "function") return;
if (!drape) {
cur.clear_aerial?.();
return;
}
const dec = await decodeImageToRgba(drape.src);
const cur2 = rendererRef.current;
if (!cur2 || typeof cur2.set_aerial_texture !== "function") return;
if (dec) {
cur2.set_aerial_texture(
dec.data,
dec.width,
dec.height,
drape.min.x,
drape.min.y,
drape.max.x,
drape.max.y,
);
} else {
cur2.clear_aerial?.();
}
} catch (e) {
console.warn("render3d set_aerial_texture fehlgeschlagen:", e);
}
})();
}, []);
/**