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