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
+173 -27
View File
@@ -213,6 +213,12 @@ export interface PlanSelection {
* über allem — ein Klick genau darauf meint die Linie).
*/
sectionLineId: string | null;
/**
* ID des getroffenen Kontext-Objekts (importiertes Gebäude/Terrain), wenn der
* Klick sein Grundriss-Pick-Band traf und KEIN Element höherer Priorität
* (alle anderen gehen vor, analog `roofId`); sonst `null`.
*/
contextObjectId: string | null;
/**
* War beim Klick Shift gedrückt? Dann ERWEITERT der Aufrufer (App) die
* Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu
@@ -303,6 +309,12 @@ export interface PlanViewProps {
selectedRoomIds?: string[];
/** Aktuell ausgewählte Dach-IDs (von App); die Ansicht markiert die Traufe. */
selectedRoofIds?: string[];
/**
* Aktuell ausgewählte Kontext-Objekt-IDs (importiertes Gebäude/Terrain; von
* App). Die Ansicht markiert das Grundriss-Pick-Band JEDES dieser Objekte mit
* einer Akzentkontur.
*/
selectedContextObjectIds?: string[];
/**
* Aktuell ausgewählte Schnitt-/Ansichtslinien-IDs (DrawingLevel; von App). Die
* Ansicht markiert das Pick-Band JEDER dieser Linien mit einer Akzentkontur.
@@ -331,6 +343,9 @@ export interface PlanViewProps {
onStampEdit?: (roomId: string) => void;
/** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */
onEditDrawingText?: (drawingId: string) => void;
/** Doppelklick auf eine Schnittlinie: in deren 2D-Schnittansicht springen
* (Zeichnungsebene aktivieren). */
onOpenSectionLevel?: (levelId: string) => void;
/**
* Links-Drag (Marquee): meldet die im Auswahl-Rechteck getroffenen Wände nach
* der CAD-Konvention (siehe {@link MarqueeSelection}). Optional.
@@ -447,6 +462,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
selectedStairIds,
selectedRoomIds,
selectedRoofIds,
selectedContextObjectIds,
selectedSectionLineIds,
selectedExtrudedSolidIds,
selectedColumnIds,
@@ -454,6 +470,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
onStampMove,
onStampEdit,
onEditDrawingText,
onOpenSectionLevel,
activeTool,
toolInputActive,
toolHandlers,
@@ -537,9 +554,15 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
// „GPU-Canvas montieren?" — beide GPU-Pfade brauchen das Canvas (an der ABSICHT
// montiert, nicht am ready, sonst könnte die Engine mangels Canvas nie starten).
const wantGpu = wantGl || wantWasm;
// Georeferenzierte Hintergrund-Rasterbilder (Luftbild) vorhanden? Der GPU-
// Canvas löscht das Blatt UNDURCHSICHTIG (clearColor …,1) und würde ein
// dahinterliegendes Bild verdecken. Daher bei aktivem Luftbild bewusst den
// vollen SVG-Pfad nutzen — dort liegt das Bild als eigene Ebene ZWISCHEN
// Blatt-Hintergrund und Geometrie (sichtbar, ohne den GL-Renderer zu ändern).
const hasBgImages = (plan.backgroundImages?.length ?? 0) > 0;
// Engine WIRKLICH aktiv? Steuert das Umschalten SVG↔GPU: bei false rendert der
// volle SVG-Pfad (Fallback), bei true übernimmt die GPU-Ebene + Text-Overlay.
const useGpuRenderer = wantGpu && glReady;
const useGpuRenderer = wantGpu && glReady && !hasBgImages;
// Stabiler Papier-Massstab-Nenner N (1:N) als REFERENZ für die Print-Strich-
// breiten. BEWUSST NICHT der live aus dem Zoom abgeleitete N (der sich beim
@@ -1060,6 +1083,22 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return null;
};
// Getroffenes Kontext-Objekt (importiertes Gebäude/Terrain): sein unsichtbares
// Grundriss-Pick-Band (Bounding-Box) per Punkt-in-Polygon — macht es wie eine
// Wand irgendwo innerhalb seiner Fläche anklickbar, nicht nur exakt auf der
// dünnen Umrisslinie.
const pickContextObject = (clientX: number, clientY: number): string | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind === "polygon" && p.contextObjectId && pointInPolygon(m, p.pts)) {
return p.contextObjectId;
}
}
return null;
};
// Getroffener extrudierter Körper (truck-Integration): der Footprint-Umriss
// ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon
// trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie).
@@ -1589,6 +1628,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
columnId: null,
roofId: null,
sectionLineId,
contextObjectId: null,
shift: e.shiftKey,
});
return;
@@ -1644,6 +1684,13 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId || roomId
? null
: pickRoof(e.clientX, e.clientY);
// Kontext-Objekt (Gebäude/Terrain) als ALLERLETZTE Priorität — seine
// Bbox-Pick-Fläche kann grosszügig überlappen, daher erst wenn wirklich
// nichts anderes getroffen wurde.
const contextObjectId =
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId || roomId || roofId
? null
: pickContextObject(e.clientX, e.clientY);
onSelect({
model: viewToModel(v.x, v.y),
hitIndex,
@@ -1657,6 +1704,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
columnId,
roofId,
sectionLineId: null,
contextObjectId,
shift: e.shiftKey,
});
}
@@ -1729,6 +1777,14 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return;
}
}
// Doppelklick auf eine Schnittlinie → in deren 2D-Schnittansicht springen.
if (onOpenSectionLevel) {
const sid = pickSectionLine(e.clientX, e.clientY);
if (sid) {
onOpenSectionLevel(sid);
return;
}
}
setView(defaultView);
};
@@ -1856,6 +1912,21 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
);
}, [plan, selectedRoofIds]);
// Hervorhebung der selektierten Kontext-Objekte (Gebäude/Terrain): das
// (unsichtbare) Bbox-Pick-Band mit contextObjectId als Akzentkontur
// überzeichnen — analog zur Dach-/Raum-Auswahl.
const highlightContextObjectPolys = useMemo(() => {
const ids =
selectedContextObjectIds && selectedContextObjectIds.length
? new Set(selectedContextObjectIds)
: null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.contextObjectId != null && ids.has(p.contextObjectId),
);
}, [plan, selectedContextObjectIds]);
// Hervorhebung der selektierten Schnitt-/Ansichtslinie: das (unsichtbare)
// Pick-Band (Polygon mit sectionLineId) mit Akzentkontur überzeichnen.
const highlightSectionLinePolys = useMemo(() => {
@@ -1988,8 +2059,9 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
darüber erreichen. An `wantGpu` (Absicht) montiert — NICHT an `useGpuRenderer`
(=wantGpu&&glReady), sonst könnte GL nie initialisieren (Canvas fehlt →
glReady bleibt false → Deadlock). Bei WebGL-Fehler bleibt es leer hinten
und der volle SVG-Pfad übernimmt sichtbar. */}
{wantGpu && (
und der volle SVG-Pfad übernimmt sichtbar. NICHT montiert, wenn ein
Luftbild aktiv ist — dann rendert der SVG-Pfad alles inkl. Bild-Ebene. */}
{wantGpu && !hasBgImages && (
<canvas
ref={canvasRef}
style={{
@@ -2058,6 +2130,28 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
className={useGpuRenderer ? undefined : "plan-bg"}
fill={useGpuRenderer ? "transparent" : undefined}
/>
{/* Georeferenzierte Luftbilder ZWISCHEN Blatt-Hintergrund und Geometrie:
Modell-Meter × PX_PER_M mit negierter y-Achse (s. toScreen) → Bild-Norden
(max.y) oben bei y=−max.y·PX; das Bild zeichnet natürlich von oben (kein
Flip). Nur im SVG-Pfad relevant (bei Luftbild ist GPU deaktiviert). */}
{hasBgImages && plan.backgroundImages!.map((img) => {
const w = (img.max.x - img.min.x) * PX_PER_M;
const h = (img.max.y - img.min.y) * PX_PER_M;
if (w <= 0 || h <= 0) return null;
return (
<image
key={img.id}
href={img.src}
x={img.min.x * PX_PER_M}
y={-img.max.y * PX_PER_M}
width={w}
height={h}
opacity={img.opacity}
preserveAspectRatio="none"
pointerEvents="none"
/>
);
})}
{/* 2D-Zeichen-Linien mit drawingId werden gemergt (drawingRuns) als EIN Zug
gezeichnet → gehrte Ecken statt Butt-Cap-Stufe; im Memo übersprungen. */}
{primitiveEls}
@@ -2114,6 +2208,18 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten Kontext-Objekte (Bbox-Pick-Band). */}
{highlightContextObjectPolys.map((poly, i) => (
<polygon
key={`selctx-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten Schnitt-/Ansichtslinie (Pick-Band). */}
{highlightSectionLinePolys.map((poly, i) => (
<polygon
@@ -2332,8 +2438,16 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
hudFields={hudFields ?? null}
/>
)}
</svg>
{/* Lineale (oben + links, VW-Stil): Modell-Meter mit Ticks, Beschriftung
und Cursor-Marker — screen-fix über den sichtbaren Ausschnitt gelegt. */}
und Cursor-Marker — screen-fix über den sichtbaren Ausschnitt gelegt.
BEWUSST ein eigenes Sibling-SVG (nicht mehr Kind von `svgRef`): beim
imperativen GL-Pan (`applySvgTransform`, s.o.) bekommt NUR `svgRef`
während der Geste einen CSS-transform verpasst — läge das Lineal
darin, würde es sichtbar mit dem Inhalt mitwandern statt am Rand
fixiert zu bleiben. Als eigenständiges Element bleibt es während der
Geste stehen (aktualisiert erst beim Loslassen, wenn `view` neu
gesetzt wird) — exakt das gewünschte „statische" Verhalten. */}
{showRulers && (() => {
const m = measure(view);
if (!m.rect) return null;
@@ -2345,15 +2459,28 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
h: (m.rect.height || 1) / scale,
};
return (
<PlanRulers
vb={vb}
vbPerPx={1 / scale}
pxPerMeter={scale * PX_PER_M}
cursor={rulerCursor}
/>
<svg
className="plan-ruler-overlay"
viewBox={`${vb.x0} ${vb.y0} ${vb.w} ${vb.h}`}
preserveAspectRatio="none"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
zIndex: 2,
pointerEvents: "none",
}}
>
<PlanRulers
vb={vb}
vbPerPx={1 / scale}
pxPerMeter={scale * PX_PER_M}
cursor={rulerCursor}
/>
</svg>
);
})()}
</svg>
</div>
);
});
@@ -2385,7 +2512,7 @@ function DraftOverlay({
} | null;
}) {
const vertSize = 4.5 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats (klein + einheitlich)
const markSize = 6.5 * vbPerPx; // halbe Kantenlänge des Snap-Markers (grösser = besser sichtbar)
const markSize = 4.75 * vbPerPx; // halbe Kantenlänge des Snap-Markers (klein + fein)
return (
<g className="tool-overlay" pointerEvents="none">
{/* Vorschau-Formen. */}
@@ -3271,8 +3398,29 @@ function buildDrawingRuns(prims: Primitive[]): DrawingRun[] {
return runs;
}
/** Opazität gedimmter („grau") Primitive im Darstellungsmodus „andere grau". */
const GREYED_OPACITY = 0.3;
/**
* Fester Grauton gedimmter Primitive im Darstellungsmodus „andere grau" —
* ECHTE Umfärbung (nicht nur Deckkraft), da dünne Haarlinien auf dem hellen
* Zeichenblatt (--sheet: #f0f0f0) bei reiner Transparenz kaum erkennbar
* gedimmt wirken. Gleicher Ton in allen drei Render-Pfaden (SVG/WebGL/nativ,
* siehe glPlanCompile.ts `GREY_RGB` und toRenderScene.ts `GREY_RGB`).
*/
const GREYED_COLOR = "#8c8c8c";
/** Ersetzt Fill-/Stroke-/Farbfelder (inkl. Schraffur) eines Primitivs durch den festen Grauton. */
function greyColor(c: string | undefined): string | undefined {
return !c || c === "none" || c === "transparent" ? c : GREYED_COLOR;
}
/** Flache Kopie eines Primitivs mit allen Farbfeldern auf `GREYED_COLOR` umgefärbt. */
function greyedCopy<T extends Primitive>(p: T): T {
const q: any = { ...p };
if ("fill" in q) q.fill = greyColor(q.fill);
if ("stroke" in q) q.stroke = greyColor(q.stroke);
if ("color" in q) q.color = greyColor(q.color);
if ("hatch" in q && q.hatch) q.hatch = { ...q.hatch, color: greyColor(q.hatch.color) };
return q;
}
/** Zeichnet einen gebündelten 2D-Zeichen-Zug als EIN Element (gehrte Ecken). */
function DrawingRunShape({
@@ -3315,12 +3463,13 @@ function DrawingRunShape({
// Punktmuster (dash enthält eine 0): runde Kappe, damit die 0-Längen-Segmente
// als Dots erscheinen (Punktlinie / Strich-Punkt).
const cap = dashHasDot(run.dash) ? ("round" as const) : undefined;
const stroke = run.greyed ? greyColor(run.color) : run.color;
const shape = run.closed ? (
<polygon
points={pts}
className={run.cls}
fill="none"
stroke={run.color}
stroke={stroke}
strokeWidth={sw}
strokeDasharray={dashStr}
strokeLinecap={cap}
@@ -3331,14 +3480,14 @@ function DrawingRunShape({
points={pts}
className={run.cls}
fill="none"
stroke={run.color}
stroke={stroke}
strokeWidth={sw}
strokeDasharray={dashStr}
strokeLinecap={cap}
vectorEffect={vfx}
/>
);
return run.greyed ? <g opacity={GREYED_OPACITY}>{shape}</g> : shape;
return shape;
}
function PrimitiveShape({
@@ -3354,12 +3503,9 @@ function PrimitiveShape({
hairline: boolean;
paperScale: number | null;
}) {
// Gedimmte Elemente bekommen eine niedrige Gruppen-Opazität, sodass Strich
// UND Füllung gleichmäßig zurücktreten (best-effort, ohne Farbumrechnung).
const shape = renderPrimitive(p, index, toScreen, hairline, paperScale);
if (p.greyed) {
return <g opacity={GREYED_OPACITY}>{shape}</g>;
}
// Gedimmte Elemente werden auf den festen Grauton umgefärbt (Strich, Füllung
// UND Schraffur) — deutlich erkennbar unabhängig von Papierfarbe/Linienstärke.
const shape = renderPrimitive(p.greyed ? greyedCopy(p) : p, index, toScreen, hairline, paperScale);
return shape;
}
@@ -3887,17 +4033,17 @@ function PlanRulers({
<>
<rect
className="plan-ruler-cursor"
x={cursor.x * PX_PER_M - 1.5 * vbPerPx}
x={cursor.x * PX_PER_M - 1 * vbPerPx}
y={y0}
width={3 * vbPerPx}
width={2 * vbPerPx}
height={TH}
/>
<rect
className="plan-ruler-cursor"
x={x0}
y={-cursor.y * PX_PER_M - 1.5 * vbPerPx}
y={-cursor.y * PX_PER_M - 1 * vbPerPx}
width={TH}
height={3 * vbPerPx}
height={2 * vbPerPx}
/>
</>
)}