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
+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}
/>
</>
)}
+206
View File
@@ -0,0 +1,206 @@
// Kontext-Schicht im Grundriss (generatePlan → addContextContours):
// • ImportedMesh (Gebäude) -> Sockelkanten-Fussabdruck (context-line).
// • TerrainMesh -> nur 2D-Bounding-Box-Umriss (4 Linien).
// • Nutzer-Report: importierte Gebäude waren im Plan UNSICHTBAR (nur 3D).
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { Project } from "../model/types";
function projectWith(context: Project["context"]): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context,
} as unknown as Project;
}
const visible = new Set<string>();
function contextLines(p: Project) {
return generatePlan(p, "eg", visible).primitives.filter(
(pr) => pr.kind === "line" && (pr as { cls?: string }).cls === "context-line",
);
}
describe("generatePlan — Kontext-Fussabdruck (ImportedMesh)", () => {
it("zeichnet die Sockelkanten eines importierten Gebäudes als context-line", () => {
// Ein simpler Quader-„Bau": Grundfläche 0..2 × 0..3 bei z=0 (Sockel), Dach
// bei z=5. Zwei Sockel-Dreiecke bilden das Boden-Rechteck.
const p = projectWith([
{
id: "B1",
type: "importedMesh",
name: "Haus",
positions: [
0, 0, 0, 2, 0, 0, 2, 3, 0, 0, 3, 0, // 0..3: Boden (z=0)
1, 1, 5, // 4: Dachspitze (z=5, über der Toleranz)
],
indices: [
0, 1, 2, 0, 2, 3, // Boden (Sockel)
0, 1, 4, // Dachdreieck (nur eine Kante 0-1 am Sockel, 4 ist hoch)
],
},
]);
const lines = contextLines(p);
// Sockelkanten: Rechteck-Umriss 0-1,1-2,2-3,3-0 + Diagonale 0-2 = 5 eindeutige.
// Die hohen Dach-Kanten (0-4, 1-4) fallen weg (z=5 > Toleranz).
expect(lines.length).toBe(5);
// Keine Linie berührt die Dachspitze (1,1) auf z-Höhe — aber XY (1,1) könnte
// von der Diagonale getroffen werden; hier prüfen wir, dass keine Kante zu
// einem Punkt führt, dessen Original-z über der Toleranz lag: alle Endpunkte
// liegen im Boden-Rechteck [0..2]×[0..3].
for (const l of lines) {
if (l.kind !== "line") continue;
for (const pt of [l.a, l.b]) {
expect(pt.x).toBeGreaterThanOrEqual(0);
expect(pt.x).toBeLessThanOrEqual(2);
expect(pt.y).toBeGreaterThanOrEqual(0);
expect(pt.y).toBeLessThanOrEqual(3);
}
}
});
it("entdoppelt geteilte Kanten (kein doppeltes Zeichnen)", () => {
// Zwei Sockel-Dreiecke teilen sich die Kante 0-2 -> darf nur EINMAL erscheinen.
const p = projectWith([
{
id: "B1",
type: "importedMesh",
name: "Haus",
positions: [0, 0, 0, 2, 0, 0, 2, 3, 0, 0, 3, 0],
indices: [0, 1, 2, 0, 2, 3],
},
]);
const lines = contextLines(p);
// 4 Rand + 1 geteilte Diagonale = 5 (nicht 6).
expect(lines.length).toBe(5);
});
});
describe("generatePlan — Kontext-Objekte anklickbar (Pick-Region)", () => {
it("importedMesh bekommt ein unsichtbares Bbox-Pick-Band mit contextObjectId", () => {
const p = projectWith([
{
id: "B1",
type: "importedMesh",
name: "Haus",
positions: [0, 0, 0, 2, 0, 0, 2, 3, 0, 0, 3, 0],
indices: [0, 1, 2, 0, 2, 3],
},
]);
const plan = generatePlan(p, "eg", visible);
const pick = plan.primitives.find(
(pr) => pr.kind === "polygon" && pr.contextObjectId === "B1",
);
expect(pick).toBeDefined();
if (pick?.kind === "polygon") {
expect(pick.fill).toBe("none");
expect(pick.stroke).toBe("none");
// Bbox der Grundfläche: [0..2]×[0..3].
const xs = pick.pts.map((p) => p.x);
const ys = pick.pts.map((p) => p.y);
expect(Math.min(...xs)).toBe(0);
expect(Math.max(...xs)).toBe(2);
expect(Math.min(...ys)).toBe(0);
expect(Math.max(...ys)).toBe(3);
}
});
it("terrainMesh bekommt ebenfalls ein Pick-Band mit contextObjectId", () => {
const p = projectWith([
{
id: "T1",
type: "terrainMesh",
name: "Gelände",
positions: [0, 0, 400, 10, 0, 401, 5, 8, 402],
indices: [0, 1, 2],
},
]);
const plan = generatePlan(p, "eg", visible);
const pick = plan.primitives.find(
(pr) => pr.kind === "polygon" && pr.contextObjectId === "T1",
);
expect(pick).toBeDefined();
});
});
describe("generatePlan — Kontext-Umriss (TerrainMesh)", () => {
it("zeichnet nur die 2D-Bounding-Box (4 Linien), nicht das ganze TIN", () => {
// 3 Vertices spannen ein Dreieck; die Bbox-Ausdehnung ist [0..10]×[0..8].
const p = projectWith([
{
id: "T1",
type: "terrainMesh",
name: "Gelände",
positions: [0, 0, 400, 10, 0, 401, 5, 8, 402],
indices: [0, 1, 2],
},
]);
const lines = contextLines(p);
expect(lines.length).toBe(4);
const xs = lines.flatMap((l) => (l.kind === "line" ? [l.a.x, l.b.x] : []));
const ys = lines.flatMap((l) => (l.kind === "line" ? [l.a.y, l.b.y] : []));
expect(Math.min(...xs)).toBe(0);
expect(Math.max(...xs)).toBe(10);
expect(Math.min(...ys)).toBe(0);
expect(Math.max(...ys)).toBe(8);
});
});
describe("generatePlan — Luftbild (ContextImage)", () => {
it("gibt georeferenzierte Bilder als backgroundImages aus (nicht als Primitive)", () => {
const p = projectWith([
{
id: "IMG1",
type: "image",
name: "Luftbild",
layer: "Luftbild",
src: "data:image/jpeg;base64,AAAA",
min: { x: -100, y: -80 },
max: { x: 100, y: 80 },
},
]);
const plan = generatePlan(p, "eg", visible);
expect(plan.backgroundImages).toHaveLength(1);
expect(plan.backgroundImages![0]).toMatchObject({
id: "IMG1",
src: "data:image/jpeg;base64,AAAA",
min: { x: -100, y: -80 },
max: { x: 100, y: 80 },
opacity: 1,
});
// Kein Bild-Primitiv in den Vektor-Primitiven (DXF/PDF lassen es aus).
expect(plan.primitives.some((pr) => (pr as { kind?: string }).kind === "image")).toBe(false);
});
it("im Mono-Modus wird das Luftbild ausgeblendet (reiner S/W-Plan)", () => {
const p = projectWith([
{
id: "IMG1",
type: "image",
name: "Luftbild",
src: "data:image/jpeg;base64,AAAA",
min: { x: 0, y: 0 },
max: { x: 10, y: 10 },
},
]);
const plan = generatePlan(p, "eg", visible, undefined, "mittel", false, true);
expect(plan.backgroundImages).toHaveLength(0);
});
});
+18
View File
@@ -79,6 +79,24 @@ describe("generatePlan — Schnittführungs-Symbol", () => {
expect(prims.some((pr) => pr.kind === "line" && pr.cls === "section-line")).toBe(false);
});
it("zeichnet NICHTS für eine unsichtbare Schnittebene (visible=false)", () => {
const p = baseProject([
floor,
{
id: "s-hidden",
name: "Schnitt C",
kind: "section",
visible: false,
locked: false,
linePoints: [{ x: 0, y: 2 }, { x: 6, y: 2 }],
directionSign: 1,
},
]);
const prims = generatePlan(p, "eg", visible).primitives;
expect(prims.some((pr) => pr.kind === "polygon" && pr.sectionLineId === "s-hidden")).toBe(false);
expect(prims.some((pr) => pr.kind === "line" && pr.cls === "section-line")).toBe(false);
});
it("behandelt Ansichtsebenen (elevation) gleich", () => {
const p = baseProject([
floor,
+85
View File
@@ -0,0 +1,85 @@
/**
* Treppen-Pfeil-Darstellung (generatePlan → addStairSymbol): Default "line"
* zeichnet den Auf-/Abpfeil als zwei offene Linien (cls "stair-arrow");
* `arrowStyle:"filled"` ersetzt sie durch EIN gefülltes Dreieck-Polygon
* (SIA-Vollpfeil).
*/
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { DrawingLevel, Project, Stair } from "../model/types";
const visible = new Set(["40"]);
const floor: DrawingLevel = {
id: "eg",
name: "EG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 0,
};
function baseStair(extra: Partial<Stair> = {}): Stair {
return {
id: "st-1",
type: "stair",
floorId: "eg",
categoryCode: "40",
shape: "gerade",
start: { x: 0, y: 0 },
dir: { x: 1, y: 0 },
runLength: 4,
width: 1.2,
stepCount: 16,
totalRise: 2.6,
...extra,
} as Stair;
}
function project(stair: Stair): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [floor],
layers: [{ code: "40", name: "Treppen", color: "#333", lw: 0.18, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [stair],
rooms: [],
drawings2d: [],
context: [],
} as unknown as Project;
}
describe("generatePlan — Treppen-Pfeil", () => {
it("Default: offener Winkelpfeil = zwei stair-arrow-Linien, kein gefülltes Polygon", () => {
const prims = generatePlan(project(baseStair()), "eg", visible).primitives;
const arrowLines = prims.filter((p) => p.kind === "line" && p.cls === "stair-arrow");
expect(arrowLines.length).toBe(2);
// Keine gefüllte Pfeil-Fläche (Tritt-Polygone tragen fill:"none").
const filled = prims.filter(
(p) => p.kind === "polygon" && p.stairId === "st-1" && p.fill !== "none",
);
expect(filled.length).toBe(0);
});
it("filled: ein gefülltes Dreieck-Polygon, keine stair-arrow-Linien", () => {
const prims = generatePlan(project(baseStair({ arrowStyle: "filled" })), "eg", visible)
.primitives;
expect(prims.some((p) => p.kind === "line" && p.cls === "stair-arrow")).toBe(false);
const filled = prims.filter(
(p) => p.kind === "polygon" && p.stairId === "st-1" && p.fill !== "none",
);
expect(filled.length).toBe(1);
expect(filled[0].kind === "polygon" && filled[0].pts.length).toBe(3);
});
});
+218 -27
View File
@@ -10,6 +10,7 @@ import type {
Ceiling,
Column,
Component,
ContextObject,
Drawing2D,
ExtrudedSolid,
HatchPattern,
@@ -191,19 +192,22 @@ export interface HatchRender {
/**
* Render-Entscheidung je Kategorie-Code für den Grundriss (vgl. ItemDisplay aus
* dem Panel-Darstellungsmodus). `render`=false blendet die Kategorie ganz aus,
* `greyed`=true zeichnet sie gedimmt. Wird kein Resolver übergeben, gilt für
* jede sichtbare Kategorie {render:true, greyed:false} (bisheriges Verhalten).
* `greyed`=true zeichnet sie gedimmt, `locked`=true (nur Modus „grey_locked")
* verbietet die Anwahl (siehe App.tsx onPlanSelect). Wird kein Resolver
* übergeben, gilt für jede sichtbare Kategorie {render:true, greyed:false,
* locked:false} (bisheriges Verhalten).
*/
export interface CategoryDisplay {
render: boolean;
greyed: boolean;
locked: boolean;
}
/** Funktion, die je Kategorie-Code die Render-Entscheidung liefert. */
export type CategoryDisplayResolver = (code: string) => CategoryDisplay;
/** Standard-Resolver: jede (bereits gefilterte) Kategorie normal zeichnen. */
const SHOW_ALL: CategoryDisplayResolver = () => ({ render: true, greyed: false });
const SHOW_ALL: CategoryDisplayResolver = () => ({ render: true, greyed: false, locked: false });
export type Primitive =
| {
@@ -257,6 +261,12 @@ export type Primitive =
* Auswahl — heute nur zur Nachvollziehbarkeit getaggt, noch nicht verdrahtet).
*/
extrudedSolidId?: string;
/**
* ID des Kontext-Objekts (importiertes Gebäude/Terrain, `project.context`),
* falls dieses (unsichtbare) Pick-Band dessen Grundriss-Bounding-Box
* umhüllt (Links-Klick-Auswahl — analog `sectionLineId`, s. u.).
*/
contextObjectId?: string;
/**
* ID der Stütze (Column), falls dieses Polygon der Grundriss-Querschnitt
* (Poché) einer Stütze ist (für die Links-Klick-Auswahl der Fläche).
@@ -628,9 +638,23 @@ export function resolveCeilingSectionStyle(
}
}
/** Ein georeferenziertes Hintergrund-Rasterbild (Luftbild) im Grundriss —
* Modell-Meter-Rechteck [min..max], das die interaktive Ansicht UNTER der
* Geometrie zeichnet (Kontext-Schicht, `ContextImage`). NICHT Teil der
* Vektor-`primitives` (DXF/PDF-Export lassen es aus). */
export interface PlanImage {
id: string;
src: string;
min: Vec2;
max: Vec2;
opacity: number;
}
export interface Plan {
primitives: Primitive[];
bounds: { minX: number; minY: number; maxX: number; maxY: number };
/** Hintergrund-Rasterbilder (Luftbild) — nur für die interaktive Ansicht. */
backgroundImages?: PlanImage[];
}
/**
@@ -908,9 +932,18 @@ export function generatePlan(
// Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan.
if (mono) toMono(primitives);
// Georeferenzierte Luftbilder als Hintergrund-Ebenen (nur interaktive Ansicht,
// nicht Teil der Vektor-Primitive) — im Mono-Modus ausgeblendet (reiner S/W-Plan).
const backgroundImages: PlanImage[] = mono
? []
: (project.context ?? [])
.filter((o): o is Extract<ContextObject, { type: "image" }> => o.type === "image")
.map((o) => ({ id: o.id, src: o.src, min: o.min, max: o.max, opacity: o.opacity ?? 1 }));
return {
primitives,
bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms, roofs),
backgroundImages,
};
}
@@ -1059,34 +1092,164 @@ const CONTEXT_LINE_COLOR = "#9aa3ad";
/** Strichstärke der Kontext-Konturen (mm Papier, dünn). */
const CONTEXT_LINE_MM = 0.1;
/** Höhen-Toleranz (Meter): Dreieckskanten, deren beide Endpunkte höchstens so
* weit über der Objekt-Unterkante (minZ) liegen, gelten als „Sockelkante" und
* bilden den Grundriss-Fussabdruck eines importierten Gebäudes. */
const FOOTPRINT_Z_TOL = 0.6;
/**
* Zeichnet die Konturen (Höhenlinien) der Kontext-Schicht `project.context` als
* dünne, gedämpfte Referenz-Linien in den Grundriss. Die Z-Höhe wird ignoriert
* (reine 2D-Projektion). TerrainMeshes/ImportedMeshes erscheinen im Plan NICHT
* (3D genügt). Rein additiv — Wand-/Drawing-Darstellung bleibt unberührt.
* Zeichnet die Kontext-Schicht `project.context` in den Grundriss:
* • ContourSet (Höhenlinien) → dünne Referenz-Linien (Z ignoriert).
* • ImportedMesh (Gebäude) → GRUNDRISS-FUSSABDRUCK: die Dreieckskanten nahe
* der Objekt-Unterkante (Sockel), auf die XY-Ebene projiziert und über
* quantisierte Endpunkte entdoppelt — so erscheint das importierte Haus als
* erkennbarer Umriss im Plan (Nutzer-Wunsch: „ich sehe es sonst nicht"),
* ohne das gesamte Dach-Drahtgitter dicht darüberzulegen.
* • TerrainMesh → nur der 2D-Umriss (Bounding-Box), da ein volles TIN-Gitter
* im Plan unbrauchbar dicht wäre.
* Rein additiv — Wand-/Drawing-Darstellung bleibt unberührt.
*/
function addContextContours(out: Primitive[], project: Project): void {
const context = project.context ?? [];
for (const obj of context) {
if (obj.type !== "contourSet") continue;
for (const c of obj.contours) {
const pts = c.pts;
if (pts.length < 2) continue;
const segEnd = c.closed ? pts.length : pts.length - 1;
for (let i = 0; i < segEnd; i++) {
out.push({
kind: "line",
a: pts[i],
b: pts[(i + 1) % pts.length],
cls: "context-line",
weightMm: CONTEXT_LINE_MM,
color: CONTEXT_LINE_COLOR,
});
if (obj.type === "contourSet") {
for (const c of obj.contours) {
const pts = c.pts;
if (pts.length < 2) continue;
const segEnd = c.closed ? pts.length : pts.length - 1;
for (let i = 0; i < segEnd; i++) {
out.push({
kind: "line",
a: pts[i],
b: pts[(i + 1) % pts.length],
cls: "context-line",
weightMm: CONTEXT_LINE_MM,
color: CONTEXT_LINE_COLOR,
});
}
}
} else if (obj.type === "importedMesh") {
addMeshFootprint(out, obj.positions, obj.indices);
addContextPickRegion(out, obj.positions, obj.id);
} else if (obj.type === "terrainMesh") {
addMeshBboxOutline(out, obj.positions);
addContextPickRegion(out, obj.positions, obj.id);
}
}
}
/**
* Zeichnet den Grundriss-Fussabdruck eines Meshes: Dreieckskanten nahe der
* Unterkante (minZ + {@link FOOTPRINT_Z_TOL}), auf XY projiziert, über
* quantisierte Endpunkt-Schlüssel entdoppelt. `positions` = flaches [x,y,z…]-
* Array (Modell-Meter, z = Höhe), `indices` = Dreiecks-Indizes.
*/
function addMeshFootprint(out: Primitive[], positions: number[], indices: number[]): void {
const vcount = Math.floor(positions.length / 3);
if (vcount < 3 || indices.length < 3) return;
let minZ = Infinity;
for (let i = 2; i < positions.length; i += 3) if (positions[i] < minZ) minZ = positions[i];
if (!Number.isFinite(minZ)) return;
const zLimit = minZ + FOOTPRINT_Z_TOL;
const q = (v: number) => Math.round(v * 100); // 1-cm-Quantisierung (Entdopplung)
const seen = new Set<string>();
const edge = (ia: number, ib: number) => {
if (ia >= vcount || ib >= vcount) return;
const za = positions[ia * 3 + 2];
const zb = positions[ib * 3 + 2];
if (za > zLimit || zb > zLimit) return; // nur Sockelkanten
const ax = positions[ia * 3], ay = positions[ia * 3 + 1];
const bx = positions[ib * 3], by = positions[ib * 3 + 1];
// Kanten-Schlüssel richtungsunabhängig (kleinerer Endpunkt zuerst).
const ka = `${q(ax)},${q(ay)}`;
const kb = `${q(bx)},${q(by)}`;
if (ka === kb) return;
const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
if (seen.has(key)) return;
seen.add(key);
out.push({
kind: "line",
a: { x: ax, y: ay },
b: { x: bx, y: by },
cls: "context-line",
weightMm: CONTEXT_LINE_MM,
color: CONTEXT_LINE_COLOR,
});
};
for (let i = 0; i + 2 < indices.length; i += 3) {
const a = indices[i], b = indices[i + 1], c = indices[i + 2];
edge(a, b);
edge(b, c);
edge(c, a);
}
}
/** Zeichnet nur den achsenparallelen 2D-Umriss (Bounding-Box) eines Meshes —
* für Terrain, dessen volles TIN-Gitter im Plan zu dicht wäre. */
function addMeshBboxOutline(out: Primitive[], positions: number[]): void {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let i = 0; i + 2 < positions.length; i += 3) {
const x = positions[i], y = positions[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
if (!Number.isFinite(minX)) return;
const corners = [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
];
for (let i = 0; i < 4; i++) {
out.push({
kind: "line",
a: corners[i],
b: corners[(i + 1) % 4],
cls: "context-line",
weightMm: CONTEXT_LINE_MM,
color: CONTEXT_LINE_COLOR,
});
}
}
/**
* Unsichtbares Pick-Band (Grundriss-Bounding-Box) für ein Kontext-Objekt
* (importiertes Gebäude/Terrain) — klickbar per Punkt-in-Polygon in PlanView,
* trägt die `ContextObject.id` (analog `sectionLineId`, s. dort). Macht das
* Objekt IRGENDWO innerhalb seiner Grundfläche anklickbar (nicht nur exakt auf
* der dünnen Umriss-/Fussabdrucklinie) — wie bei Wänden, deren Poché-Fläche
* ebenfalls als Ganzes klickbar ist (Nutzer-Wunsch: „eigene Elemente wie Wände").
* `positions` = flaches [x,y,z…]-Array (Modell-Meter); die Bbox nutzt NUR x/y,
* unabhängig von der Höhe.
*/
function addContextPickRegion(out: Primitive[], positions: number[], id: string): void {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let i = 0; i + 2 < positions.length; i += 3) {
const x = positions[i], y = positions[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
if (!Number.isFinite(minX)) return;
out.push({
kind: "polygon",
pts: [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
],
fill: "none",
stroke: "none",
strokeWidthMm: 0,
hatch: NO_HATCH,
contextObjectId: id,
});
}
/**
* Post-Pass für den Schwarz-Weiss-Modus: ersetzt alle Farb-Informationen der
* Plan-Primitive durch einheitliche Tinte/Weiss. Wirkt in-place.
@@ -2736,6 +2899,10 @@ function sectionLabelTag(name: string): string {
function addSectionLines(out: Primitive[], project: Project): void {
for (const level of project.drawingLevels) {
if (level.kind !== "section" && level.kind !== "elevation") continue;
// Schnittebene ist ein-/ausblendbar wie andere Ebenen: eine unsichtbare
// Schnitt-/Ansichtsebene zeigt ihre Linie im Grundriss nicht (Nutzer-Vision
// „eigene Ebene Schnittebenen"). `visible` fehlt = sichtbar (Alt-Projekte).
if (level.visible === false) continue;
const line = level.linePoints;
if (!line) continue;
const [p0, p1] = line;
@@ -3053,10 +3220,24 @@ function addStairSymbol(
stairId,
});
}
// Auf-/Abpfeil an der Spitze der Lauflinie.
// Auf-/Abpfeil an der Spitze der Lauflinie. "filled" = gefülltes Dreieck
// (SIA-Vollpfeil), sonst der offene Winkelpfeil (zwei Linien, Default).
const [h1, tip, h2] = geo.arrow.head;
out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
if (stair.arrowStyle === "filled") {
out.push({
kind: "polygon",
pts: [h1, tip, h2],
fill: stroke,
stroke,
strokeWidthMm: lwMm,
hatch: NO_HATCH,
greyed,
stairId,
});
} else {
out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
}
// Aussenlinie (Outline) der Treppe — schliessendes Umriss-Polygon (gerade/L)
// bzw. Innen-/Aussenbogen + radiale Schliesslinien (Wendel).
@@ -3121,10 +3302,20 @@ function computeBounds(
acc({ x: g.center.x + g.r, y: g.center.y + g.r });
} else if (g.shape === "text") acc(g.at);
}
// Kontext-Konturen mit einrahmen, damit „Einpassen" sie ebenfalls zeigt.
// GESAMTE Kontext-Schicht (Konturen, importierte Gebäude/Terrain, Luftbild)
// mit einrahmen, damit „Einpassen"/die Standard-Ansicht sie zeigt — sonst
// sässe die Ansicht bei einem reinen Kontext-Import (keine Wände) leer am
// Ursprung und der Import wäre „unsichtbar" (Nutzer-Report).
for (const obj of project.context ?? []) {
if (obj.type !== "contourSet") continue;
for (const c of obj.contours) for (const p of c.pts) acc(p);
if (obj.type === "contourSet") {
for (const c of obj.contours) for (const p of c.pts) acc(p);
} else if (obj.type === "image") {
acc(obj.min);
acc(obj.max);
} else {
const pos = obj.positions;
for (let i = 0; i + 1 < pos.length; i += 3) acc({ x: pos[i], y: pos[i + 1] });
}
}
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 1, maxY: 1 };
return { minX, minY, maxX, maxY };
+22 -7
View File
@@ -129,6 +129,18 @@ function parseColor(hex: string | undefined): Rgba | null {
return [r / 255, g / 255, b / 255, a];
}
/**
* Fester Grauton gedimmter (greyed) Primitive — ECHTE Umfärbung statt
* Deckkraft (dünne Haarlinien auf dem hellen Zeichenblatt wären bei reiner
* Transparenz kaum erkennbar gedimmt). Gleicher Ton wie GREYED_COLOR in
* PlanView.tsx / GREY_RGB in toRenderScene.ts (#8c8c8c ≈ 0.549).
*/
const GREY_RGB = 0.549;
/** Färbt auf den festen Grauton um, wenn das Primitiv im Darstellungsmodus „grau" ist. */
function dim(color: Rgba, greyed: boolean | undefined): Rgba {
return greyed ? [GREY_RGB, GREY_RGB, GREY_RGB, color[3]] : color;
}
/**
* Tessellliert Plan-Primitive zu GPU-Geometrie (MVP: gefüllte Polygone +
@@ -387,7 +399,7 @@ export function compilePrimitivesToGpu(
track(pt.x, pt.y);
}
for (const t of tris) fillIdx.push(base + t);
addFillBatch(tris.length, fill);
addFillBatch(tris.length, dim(fill, prim.greyed));
}
}
// Schraffur (nach der Füllung, vor/passend zum Umriss — wie der SVG stapelt):
@@ -414,7 +426,7 @@ export function compilePrimitivesToGpu(
prim.hatch.pattern !== 'none' &&
prim.hatch.pattern !== 'solid'
) {
const hatchColor = parseColor(prim.hatch.color) ?? [0.1, 0.1, 0.1, 1];
const hatchColor = dim(parseColor(prim.hatch.color) ?? [0.1, 0.1, 0.1, 1], prim.greyed);
const hatchMm = prim.hatch.lineWeight > 0 ? prim.hatch.lineWeight : 0.13;
const runs = applyDashRuns(buildHatchRuns(prim.pts, prim.hatch), prim.hatch.dash);
for (const run of runs) strokePolyline(run, false, hatchColor, hatchMm, false);
@@ -424,10 +436,10 @@ export function compilePrimitivesToGpu(
// zoomrichtig in px (wie SVG-printStrokeVb → GL == SVG).
const stroke = parseColor(prim.stroke);
if (stroke && prim.strokeWidthMm > 0 && prim.pts.length >= 2) {
strokePolygonOutline(prim.pts, stroke, prim.strokeWidthMm, prim.noStrokeEdges);
strokePolygonOutline(prim.pts, dim(stroke, prim.greyed), prim.strokeWidthMm, prim.noStrokeEdges);
}
} else if (prim.kind === 'line') {
const color = parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1];
const color = dim(parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1], prim.greyed);
if (prim.zigzag) {
// Zickzack-Linie → gehrter Polylinienzug (amplitude/wavelength Papier-mm
// → Modell-Meter über dieselbe Kopplung wie Strichmuster).
@@ -444,7 +456,10 @@ export function compilePrimitivesToGpu(
}
} else if (prim.kind === 'arc') {
// Bogen → ein gehrter Polylinienzug (glatt, keine Segment-Stufen).
const color = parseColor((prim as { color?: string }).color) ?? [0.1, 0.1, 0.1, 1];
const color = dim(
parseColor((prim as { color?: string }).color) ?? [0.1, 0.1, 0.1, 1],
prim.greyed,
);
const strokeMm = prim.weightMm || 0.18;
const c = prim.center;
const a0 = Math.atan2(prim.from.y - c.y, prim.from.x - c.x);
@@ -482,7 +497,7 @@ export function compilePrimitivesToGpu(
// kein Kreis-Primitiv → hier bildschirm-adaptiv tessellieren (wie 'arc').
// Kreis = voller Umlauf (geschlossen, optional gefüllt), Bogen = a0..a1 (CCW,
// offen). OHNE diesen Zweig blieben gezeichnete Kreise/Bögen im GL unsichtbar.
const stroke = parseColor(prim.stroke) ?? [0.1, 0.1, 0.1, 1];
const stroke = dim(parseColor(prim.stroke) ?? [0.1, 0.1, 0.1, 1], prim.greyed);
const strokeMm = prim.weightMm || 0.18;
const c = prim.center;
const TAU = Math.PI * 2;
@@ -519,7 +534,7 @@ export function compilePrimitivesToGpu(
track(pt.x, pt.y);
}
for (const t of tris) fillIdx.push(base + t);
addFillBatch(tris.length, fill);
addFillBatch(tris.length, dim(fill, prim.greyed));
}
}
}
+12 -6
View File
@@ -132,20 +132,26 @@ const CLS_STROKE: Record<string, string> = {
"door-swing": "#1a1a1a",
"door-frame": "#1a1a1a",
"window-glass": "#1a1a1a",
"wall-axis": "#2f5d54", // var(--accent) des hellen Standard-Themes
"wall-axis": "#4a453e", // var(--accent) des hellen Standard-Themes
};
const CLS_OPACITY: Record<string, number> = {
"wall-axis": 0.85,
"context-line": 0.7,
};
/** Deckkraft gedimmter (greyed) Primitive — wie GREYED_OPACITY in PlanView. */
const GREYED_ALPHA = 0.3;
/**
* Fester Grauton gedimmter (greyed) Primitive — ECHTE Umfärbung statt
* Deckkraft (dünne Haarlinien auf dem hellen Zeichenblatt wären bei reiner
* Transparenz kaum erkennbar gedimmt). Gleicher Ton wie GREYED_COLOR in
* PlanView.tsx / GREY_RGB in glPlanCompile.ts (#8c8c8c ≈ 0.549).
*/
const GREY_RGB = 0.549;
/** Farbe mit Klassen-Deckkraft und Greyed-Dimmung multiplizieren. */
/** Farbe mit Klassen-Deckkraft multiplizieren, bei Greyed-Dimmung auf den festen Grauton umfärben. */
function withOpacity(col: RRgba, cls: string | undefined, greyed: boolean | undefined): RRgba {
const o = (cls ? CLS_OPACITY[cls] ?? 1 : 1) * (greyed ? GREYED_ALPHA : 1);
return o === 1 ? col : [col[0], col[1], col[2], col[3] * o];
const o = cls ? CLS_OPACITY[cls] ?? 1 : 1;
const rgb: RRgba = greyed ? [GREY_RGB, GREY_RGB, GREY_RGB, col[3]] : col;
return o === 1 ? rgb : [rgb[0], rgb[1], rgb[2], rgb[3] * o];
}
/** Strichfarbe einer line/arc-Primitive: CSS-Klassen-Farbe > Modell-Farbe > Tinte. */
+110
View File
@@ -216,6 +216,69 @@ describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:t
}
});
it("innerLayerRecess: nur die INNERSTE Schicht weicht rundum um ihre Dicke zurück, andere Schichten bündig", () => {
// Isolierte Wand W1 (wallTypeId "aw" = 4 Schichten aussen->innen: render-ext,
// insulation, brick, render-int) ohne Nachbarn (keine Gehrung), EIN Fenster
// mit `innerLayerRecess: true`. Die innerste Schicht (render-int, 0.015 m) muss
// an DIESEM Fenster ein rundum um 0.015 m grösseres Loch bekommen; die drei
// äusseren Schichten bleiben bei der bündigen (unveränderten) Öffnungsgrösse.
const w1 = sampleProject.walls.find((w) => w.id === "W1")!;
const p: Project = {
...sampleProject,
walls: [w1],
doors: [],
ceilings: [],
context: [],
windowTypes: [
...(sampleProject.windowTypes ?? []),
{
id: "win-recess-test",
name: "Test Schichteinzug",
kind: "fest",
wingCount: 1,
glazing: "einfach",
frameThickness: 0.06,
frameWidth: 0.06,
defaultSillHeight: 0.9,
defaultWidth: 1.0,
defaultHeight: 1.2,
innerLayerRecess: true,
},
],
openings: [
{
id: "OR",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "window",
typeId: "win-recess-test",
position: 2.0,
width: 1.0,
height: 1.2,
sillHeight: 0.9,
},
],
};
const bodies = projectToWalls3d(p, true).filter((w) => w.wallId === "W1");
expect(bodies.length).toBe(4); // 4 Schicht-Bänder, keine Gehrung/Span-Brüche
const withHoles = bodies.filter((b) => b.holes && b.holes.length === 1);
expect(withHoles.length).toBe(4);
// Innerste Schicht = kleinster |offset| Richtung Innenseite -> hier einfach
// die mit der GRÖSSTEN Loch-Fläche identifizieren (der Rücksprung macht ihr
// Loch strikt grösser als die der anderen drei, bündigen Schichten).
const area = (h: { from: number; to: number; zBottom: number; zTop: number }) =>
(h.to - h.from) * (h.zTop - h.zBottom);
const holeAreas = withHoles.map((b) => area(b.holes![0]));
const sorted = [...holeAreas].sort((a, b) => a - b);
// 3 bündige Schichten: identische Lochfläche (1.0 × 1.2 = 1.2 m²).
expect(sorted[0]).toBeCloseTo(1.2, 6);
expect(sorted[1]).toBeCloseTo(1.2, 6);
expect(sorted[2]).toBeCloseTo(1.2, 6);
// Innerste Schicht: rundum +0.015 m (render-int-Dicke) -> (1.0+0.03)×(1.2+0.03).
expect(sorted[3]).toBeCloseTo(1.03 * 1.23, 6);
});
it("layered=false (Schnitt-Pfad): unverändert segmentiert, KEINE holes", () => {
const w1 = projectToWalls3d(twoOffsetWindows, false).filter((w) => w.wallId === "W1");
// Der Schnitt-Pfad zerlegt weiter in Pfeiler/Brüstung/Sturz-Segmente (mehr
@@ -354,6 +417,26 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
expect(a.max).toBeLessThanOrEqual(b.min + 1e-9);
});
it("Legacy-Tür (project.doors, ohne Öffnungs-Typsystem) bekommt ein Blatt-Panel statt eines blanken Lochs", () => {
// D1 ist eine LEGACY-Tür (project.doors, kein typeId/Öffnungs-Typsystem) —
// vor der Behebung durchliefen emitOpeningGlass/emitOpeningFrames nur
// project.openings, D1 blieb im 3D ein reines Loch in der Wand (Nutzer-
// Report: "Türen fehlen im 3D komplett"). DOOR_LEAF_RGB = [0.93,0.9,0.85].
const { meshes } = projectToModel3d(sampleProject);
const leafMeshes = meshes.filter(
(m) => m.color[0] === 0.93 && m.color[1] === 0.9 && m.color[2] === 0.85,
);
expect(leafMeshes.length).toBe(1);
const leaf = leafMeshes[0];
expect(leaf.positions.length).toBe(24); // Quader: 8 Ecken
expect(leaf.indices.length).toBe(36); // 12 Dreiecke
// D1: height 2.1, sitzt am Wand-UK (0) -> Blatt UK 0 .. OK 2.1.
const zs: number[] = [];
for (let i = 2; i < leaf.positions.length; i += 3) zs.push(leaf.positions[i]);
expect(Math.min(...zs)).toBeCloseTo(0, 6);
expect(Math.max(...zs)).toBeCloseTo(2.1, 6);
});
it("Türen bekommen keine Scheibe; je Fenster genau 2 (window-standard = zweifach verglast)", () => {
// Volles Sample-Projekt: 2 Fenster (O1, O2, beide Typ "window-standard" =
// Zweifachverglasung) + 1 Tür (D1), Kontext leer. Neben den Glasscheiben
@@ -1825,6 +1908,33 @@ describe("emitRoofs (Dachflächen + Giebel als Meshes)", () => {
expect(Math.max(...allHeights)).toBeCloseTo(expectedRidgeZ, 6);
});
it("Dach mit Bauteil-Material (roofTypeId) bekommt eine Textur-Ebene (Stil 'Textured')", () => {
// Vorher waren Dächer im Textured-Modus IMMER auf das Fallback-Schachbrett
// geklemmt (Nutzer-Report: "Dach soll wie Dachziegel aussehen, nicht so
// billig"). Mehrschichtiges Dach über `roofTypeId: "roof-warm"` (sample-
// project), äusserste Schicht = "roof-tiles" (echtes Bibliotheks-Material).
const roof: Roof = { ...baseRoof, roofTypeId: "roof-warm" };
const project: Project = { ...sampleProject, roofs: [roof] };
const { meshes } = projectToModel3d(project);
// Die äusserste (Eindeckungs-)Schicht hat KEINE Roof.color-Übersteuerung ->
// trägt eine aufgelöste materialIndex (nicht mehr die ROOF_RGB-Testfarbe,
// deshalb hier nicht über isRoofMesh gefiltert, sondern über materialIndex).
const withMaterial = meshes.filter((m) => typeof m.materialIndex === "number");
expect(withMaterial.length).toBeGreaterThan(0);
expect(withMaterial.every((m) => (m.materialIndex ?? 0) >= 1)).toBe(true);
});
it("Roof.color-Übersteuerung unterdrückt die Material-Textur (bewusst einheitliche Farbe)", () => {
const roof: Roof = { ...baseRoof, roofTypeId: "roof-warm", color: "#ff0000" };
const project: Project = { ...sampleProject, roofs: [roof] };
const { meshes } = projectToModel3d(project);
const roofRedMeshes = meshes.filter(
(m) => m.color[0] === 1 && m.color[1] === 0 && m.color[2] === 0,
);
expect(roofRedMeshes.length).toBeGreaterThan(0);
expect(roofRedMeshes.every((m) => m.materialIndex === undefined)).toBe(true);
});
it("Walmdach: vier Dachflächen (2 Quader + 2 Dreiecke) korrekt trianguliert", () => {
const walmRoof: Roof = { ...baseRoof, id: "R2", shape: "walm" };
const project: Project = { ...sampleProject, roofs: [walmRoof] };
+107 -9
View File
@@ -222,6 +222,11 @@ export function wallMaterialColorMaps(project: Project): string[] {
// Kein auflösbarer Deckentyp.
}
}
for (const r of project.roofs ?? []) {
for (const l of roofLayers(project, r)) {
if (l.componentId) consider(l.componentId);
}
}
materialMapCache.set(project, list);
return list;
}
@@ -367,6 +372,16 @@ export interface RMesh {
indices: number[];
kind: RMeshKind;
color: RRgb;
/** Optionale Pro-Vertex-Albedo (flach `[r,g,b, …]`, 0..1) — ein Wert je
* Positions-Vertex. Damit wird z. B. ein SWISSIMAGE-Luftbild aufs Gelände-TIN
* „drapiert" (jeder Vertex trägt seinen Bildpunkt). Fehlt/zu kurz -> `color`
* fürs ganze Mesh. Matcht `render3d::types::MeshInput.vertexColors`. */
vertexColors?: number[];
/** Material-Textur-Ebene (1-basiert, siehe {@link wallMaterialColorMaps}) für
* den Stil „Textured" — bislang nur von Dach-Schichten gesetzt. Fehlt sie,
* zeigt render3d das Fallback-Schachbrett. Matcht
* `render3d::types::MeshInput.materialIndex`. */
materialIndex?: number;
}
/** Modell-Bündel für den nativen 3D-Renderer (Wände + Decken + Kontext-Meshes). */
@@ -1138,12 +1153,22 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu
// • Öffnungen (project.openings, Fenster mit Brüstung/Sturz),
// • Legacy-Türen (project.doors, sitzen am Boden, nur Sturz).
// Jeweils [from..to] auf die Achse geklemmt + vertikale Öffnungs-Ausdehnung.
const cutouts: Array<{ from: number; to: number; oBottom: number; oTop: number }> = [];
const cutouts: Array<{
from: number;
to: number;
oBottom: number;
oTop: number;
/** Schicht-Rücksprung der innersten Schicht an DIESER Öffnung (nur Fenster
* mit `WindowType.innerLayerRecess`), s. {@link emitWall}-Moduldoc unten. */
recessInnerLayer: boolean;
}> = [];
for (const op of openingsOfWall(project, wall.id)) {
const iv = openingInterval(wall, op);
if (!iv) continue;
const v = openingVerticalExtent(project, wall, op);
cutouts.push({ from: iv.from, to: iv.to, oBottom: v.zBottom, oTop: v.zTop });
const recessInnerLayer =
op.kind === "window" && getWindowType(project, op)?.innerLayerRecess === true;
cutouts.push({ from: iv.from, to: iv.to, oBottom: v.zBottom, oTop: v.zTop, recessInnerLayer });
}
for (const d of project.doors ?? []) {
if (d.hostWallId !== wall.id) continue;
@@ -1152,7 +1177,7 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu
if (to - from < EPS) continue;
// Tür sitzt am Boden (UK der Wand), reicht bis zur lichten Türhöhe.
const oTop = Math.min(zTop, zBottom + d.height);
cutouts.push({ from, to, oBottom: zBottom, oTop });
cutouts.push({ from, to, oBottom: zBottom, oTop, recessInnerLayer: false });
}
cutouts.sort((a, b) => a.from - b.from);
@@ -1174,11 +1199,12 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu
// auf dessen Achsen-Start umgerechnet (rebased), damit render3d sie relativ zum
// jeweiligen Box-Start korrekt stanzt.
if (layered) {
const holes: RHole[] = cutouts.map((c) => ({
const holes: (RHole & { recessInnerLayer: boolean })[] = cutouts.map((c) => ({
from: c.from,
to: c.to,
zBottom: c.oBottom,
zTop: c.oTop,
recessInnerLayer: c.recessInnerLayer,
}));
const startCut = cuts?.startCut ?? null;
const endCut = cuts?.endCut ?? null;
@@ -1240,11 +1266,26 @@ function emitWall(out: RWall[], project: Project, wall: Wall, layered = true, cu
}
// Öffnungs-Löcher, die ein z-Teilstück vertikal berühren, auf den Box-Start
// `a` umrechnen (render3d klemmt u/z selbst auf die Box).
// `a` umrechnen (render3d klemmt u/z selbst auf die Box). An der INNERSTEN
// Schicht (li = letztes Band) weicht das Loch bei Fenstern mit
// `WindowType.innerLayerRecess` rundum um die eigene Schichtdicke zurück
// (Schicht-Rücksprung an der Laibung) — die anderen Schichten bleiben bündig.
const isInnermostBand = li === bands.length - 1;
const recessDepth = b.thickness;
const emitPiece = (a: number, bb: number, zb: number, zt: number): void => {
const segHoles = holes
.filter((h) => h.zTop > zb + EPS && h.zBottom < zt - EPS)
.map((h) => ({ from: h.from - a, to: h.to - a, zBottom: h.zBottom, zTop: h.zTop }));
.map((h) => {
const widen = isInnermostBand && h.recessInnerLayer;
return widen
? {
from: h.from - a - recessDepth,
to: h.to - a + recessDepth,
zBottom: h.zBottom - recessDepth,
zTop: h.zTop + recessDepth,
}
: { from: h.from - a, to: h.to - a, zBottom: h.zBottom, zTop: h.zTop };
});
pushSegment(out, wall, a, bb, zb, zt, b.thickness, b.offset, b.color, wall.id, b.hatch, b.materialIndex, segHoles, bandCutMeta(b));
};
@@ -1868,6 +1909,13 @@ function emitRoofs(project: Project): RMesh[] {
const color =
overrideColor ??
(layer.componentId ? hexToRgb(getComponent(project, layer.componentId).color, ROOF_RGB) : ROOF_RGB);
// Material-Textur-Ebene fürs Textured-Rendering (z. B. echte Dachziegel-
// Textur der Eindeckung) — nur wenn KEIN Farb-Override greift (Override
// = bewusst eine einheitliche Farbe für alle Schichten, kein Bauteil-Bezug).
const materialIndex =
!overrideColor && layer.componentId
? resolveComponentMaterialLayer(project, layer.componentId)
: undefined;
const positions: number[] = [];
const indices: number[] = [];
for (const plane of g.planes) {
@@ -1881,7 +1929,7 @@ function emitRoofs(project: Project): RMesh[] {
]);
pushRoofSlab(positions, indices, top, t);
}
if (indices.length > 0) out.push({ positions, indices, kind: "extrusion", color });
if (indices.length > 0) out.push({ positions, indices, kind: "extrusion", color, materialIndex });
return offsetAbove + t;
}, 0);
// Giebel (Endfüllung) als dünne Fläche, äusserste Schichtfarbe/Override.
@@ -1919,6 +1967,13 @@ function emitMeshes(project: Project): RMesh[] {
indices: obj.indices,
kind: obj.type === "terrainMesh" ? "terrain" : "imported",
color: obj.type === "terrainMesh" ? TERRAIN_RGB : IMPORTED_RGB,
// Luftbild-Drape: das Gelände-TIN kann Pro-Vertex-Farben tragen (beim
// Import aus dem Luftbild gesampelt) — dann zeigt render3d das Ortho
// statt der Flächen-Grundfarbe.
vertexColors:
obj.type === "terrainMesh" && obj.vertexColors?.length
? obj.vertexColors
: undefined,
});
}
}
@@ -2645,6 +2700,48 @@ function emitOpeningGlass(project: Project): RMesh[] {
return out;
}
/**
* Emittiert je LEGACY-Tür (`project.doors` — das alte flache Türfeld VOR dem
* Öffnungs-Typsystem, z. B. `Door.hostWallId/position/width/height` ohne
* `typeId`) EIN Blatt-Panel als rohen Quader. `emitWall` schneidet für diese
* Türen zwar bereits ein Loch in die Wand, aber `emitOpeningGlass`/
* `emitOpeningFrames` durchlaufen NUR `project.openings` (das neue Typsystem) —
* ohne diese Funktion blieb eine Legacy-Tür ein blankes Loch, durch das man
* hindurchsieht (Nutzer-Report: "Türen fehlen im 3D komplett"). Analog zum
* Alt-Verhalten typloser Fenster ({@link emitOpeningGlass}: EINE Vollscheibe
* ohne Rahmen) — hier EIN Vollblatt ohne Rahmen/Zarge (das Rahmen-System gehört
* exklusiv zu `project.openings`, s. {@link resolveOpeningFrame}). `positions`
* in MODELL-Metern wie {@link emitMeshes}.
*/
function emitLegacyDoorLeaves(project: Project): RMesh[] {
const out: RMesh[] = [];
for (const wall of project.walls) {
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9) continue;
const { zBottom } = wallVerticalExtent(project, wall);
for (const d of project.doors ?? []) {
if (d.hostWallId !== wall.id) continue;
const from = Math.max(0, Math.min(d.position, axisLen));
const to = Math.max(from, Math.min(d.position + d.width, axisLen));
if (to - from <= EPS) continue;
const zTop = zBottom + d.height;
if (zTop - zBottom <= EPS) continue;
const mesh = openingAxisBox(
wall,
from,
to,
zBottom,
zTop,
-DOOR_LEAF_THICK / 2,
DOOR_LEAF_THICK / 2,
DOOR_LEAF_RGB,
);
if (mesh) out.push(mesh);
}
}
return out;
}
/**
* Emittiert je Öffnung MIT auflösbarem Typ ({@link getDoorType}/
* {@link getWindowType}) die Rahmen-/Sprossen-Riegel (Zarge/Blendrahmen, Sturz,
@@ -2803,6 +2900,7 @@ export function projectToModel3d(project: Project, opts: Model3dOptions = {}): R
meshes: [
...emitMeshes(project),
...emitOpeningGlass(project),
...emitLegacyDoorLeaves(project),
...emitOpeningFrames(project, detail),
...emitOpeningShading(project, detail),
...emitStairMeshes(project),
@@ -2888,7 +2986,7 @@ export function pickGeometry(
// Indizes robust überspringen statt abzustürzen (importierte Daten).
const contextMeshes: PickContextMesh[] = [];
for (const obj of project.context ?? []) {
if (obj.type === "contourSet") continue;
if (obj.type === "contourSet" || obj.type === "image") continue;
const pos = obj.positions;
const vcount = pos.length / 3;
const tris: [RoofPickVec3, RoofPickVec3, RoofPickVec3][] = [];
@@ -3045,7 +3143,7 @@ export function selectionHighlightLines(
// tausende Dreiecke, deren Kanten einzeln zu zeichnen wäre unbrauchbar dicht.
if (contextObjSet.size > 0) {
for (const obj of project.context ?? []) {
if (obj.type === "contourSet" || !contextObjSet.has(obj.id)) continue;
if (obj.type === "contourSet" || obj.type === "image" || !contextObjSet.has(obj.id)) continue;
const pos = obj.positions;
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;