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:
+173
-27
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user