Merge: Ansicht (Elevation) als echte 2D-Darstellung + Schatten-Toggle
toElevation.ts: Painter-Projektion des 3D-Modells auf die Ansichtsebene (Fassaden-/Deckenflächen fern→nah, Rückseiten-Culling, Fenster/Türen als Rahmen+Glas, Bodenlinie) + klassischer 45°-Schlagschatten auskragender Bauteile (Sutherland–Hodgman auf die Empfängerfläche geclippt). Schatten- Umschalter in der Ansichts-Leiste (DrawingLevel.shadows), App rendert elevation-Ebenen über den neuen Generator (synchron, ohne WASM).
This commit is contained in:
+32
-7
@@ -51,6 +51,7 @@ import { generatePlan, generateSectionPlan } from "./plan/generatePlan";
|
|||||||
import type { CategoryDisplayResolver } from "./plan/generatePlan";
|
import type { CategoryDisplayResolver } from "./plan/generatePlan";
|
||||||
import { computeSection } from "./plan/toSection";
|
import { computeSection } from "./plan/toSection";
|
||||||
import type { SectionOutput } from "./plan/toSection";
|
import type { SectionOutput } from "./plan/toSection";
|
||||||
|
import { generateElevationPlan } from "./plan/toElevation";
|
||||||
import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync";
|
import { pushNativeScene, pushNativeWalls } from "./plan/nativeSync";
|
||||||
import { selectionHighlightLines } from "./plan/toWalls3d";
|
import { selectionHighlightLines } from "./plan/toWalls3d";
|
||||||
import { roofBaseElevation, roofGeometry } from "./geometry/roof";
|
import { roofBaseElevation, roofGeometry } from "./geometry/roof";
|
||||||
@@ -4786,6 +4787,16 @@ export default function App() {
|
|||||||
renderModeEnabled={viewType === "perspektive"}
|
renderModeEnabled={viewType === "perspektive"}
|
||||||
planColorMode={planColorMode}
|
planColorMode={planColorMode}
|
||||||
onPlanColorMode={setPlanColorMode}
|
onPlanColorMode={setPlanColorMode}
|
||||||
|
shadowsAvailable={activeLevel.kind === "elevation"}
|
||||||
|
shadows={activeLevel.shadows ?? false}
|
||||||
|
onShadows={(v) =>
|
||||||
|
setProject((p) => ({
|
||||||
|
...p,
|
||||||
|
drawingLevels: p.drawingLevels.map((z) =>
|
||||||
|
z.id === activeLevel.id ? { ...z, shadows: v } : z,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
combos={{
|
combos={{
|
||||||
// Ebenen-Kombinationen: Speichern = Snapshot des Layer-Baums →
|
// Ebenen-Kombinationen: Speichern = Snapshot des Layer-Baums →
|
||||||
// localStorage; Laden = Map lesen → Sichtbarkeits-Flags anwenden.
|
// localStorage; Laden = Map lesen → Sichtbarkeits-Flags anwenden.
|
||||||
@@ -6672,12 +6683,17 @@ function SectionPlanView({
|
|||||||
selectedDrawingIds?: string[];
|
selectedDrawingIds?: string[];
|
||||||
gripHandlers: GripHandlers;
|
gripHandlers: GripHandlers;
|
||||||
}) {
|
}) {
|
||||||
|
// Die Ansicht (kind "elevation") läuft NICHT über den WASM-Schnitt, sondern
|
||||||
|
// über den rein TS-seitigen Flächen-Generator (Painter, toElevation.ts) —
|
||||||
|
// synchron, kein Lazy-Load. Nur der echte Schnitt braucht die WASM-Pipeline.
|
||||||
|
const isElevation = level.kind === "elevation";
|
||||||
const [output, setOutput] = useState<SectionOutput | null>(null);
|
const [output, setOutput] = useState<SectionOutput | null>(null);
|
||||||
const [status, setStatus] = useState<"loading" | "ready" | "empty" | "error">(
|
const [status, setStatus] = useState<"loading" | "ready" | "empty" | "error">(
|
||||||
"loading",
|
"loading",
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isElevation) return; // Ansicht: kein WASM-Schnitt (synchroner Generator).
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
setOutput(null);
|
setOutput(null);
|
||||||
@@ -6699,20 +6715,29 @@ function SectionPlanView({
|
|||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
};
|
};
|
||||||
}, [project, level]);
|
}, [project, level, isElevation]);
|
||||||
|
|
||||||
const plan = useMemo(
|
// Ansichts-Plan: gefüllte Fassadenansicht inkl. optionalem Schatten
|
||||||
() => (output ? generateSectionPlan(output, mono) : null),
|
// (level.shadows). Liefert null, wenn die Ebene keine Ansichtslinie trägt.
|
||||||
[output, mono],
|
const elevationPlan = useMemo(
|
||||||
|
() => (isElevation ? generateElevationPlan(project, level, { mono }) : null),
|
||||||
|
[isElevation, project, level, mono],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sectionPlan = useMemo(
|
||||||
|
() => (!isElevation && output ? generateSectionPlan(output, mono) : null),
|
||||||
|
[isElevation, output, mono],
|
||||||
|
);
|
||||||
|
const plan = isElevation ? elevationPlan : sectionPlan;
|
||||||
|
const effStatus = isElevation ? (elevationPlan ? "ready" : "empty") : status;
|
||||||
|
|
||||||
const kindLabel = level.kind === "section" ? t("stub.section") : t("stub.elevation");
|
const kindLabel = level.kind === "section" ? t("stub.section") : t("stub.elevation");
|
||||||
|
|
||||||
if (!plan || status !== "ready") {
|
if (!plan || effStatus !== "ready") {
|
||||||
const note =
|
const note =
|
||||||
status === "empty"
|
effStatus === "empty"
|
||||||
? t("section.empty", { kind: kindLabel })
|
? t("section.empty", { kind: kindLabel })
|
||||||
: status === "error"
|
: effStatus === "error"
|
||||||
? t("section.error", { kind: kindLabel })
|
? t("section.error", { kind: kindLabel })
|
||||||
: t("section.loading", { kind: kindLabel });
|
: t("section.loading", { kind: kindLabel });
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -615,6 +615,8 @@ export const de = {
|
|||||||
"section.empty": "{kind} — keine Schnittlinie im Grundriss gesetzt",
|
"section.empty": "{kind} — keine Schnittlinie im Grundriss gesetzt",
|
||||||
"section.error": "{kind} — Berechnung fehlgeschlagen (render3d-WASM neu bauen?)",
|
"section.error": "{kind} — Berechnung fehlgeschlagen (render3d-WASM neu bauen?)",
|
||||||
"section.legend": "{kind} · abgeleitete Projektion (render3d)",
|
"section.legend": "{kind} · abgeleitete Projektion (render3d)",
|
||||||
|
"elevation.shadows": "Schatten",
|
||||||
|
"elevation.shadows.hint": "Schlagschatten auskragender Bauteile (45°) in der Ansicht ein-/ausblenden",
|
||||||
"props.floorHeight": "Geschosshöhe",
|
"props.floorHeight": "Geschosshöhe",
|
||||||
"props.cutHeight": "Schnitthöhe",
|
"props.cutHeight": "Schnitthöhe",
|
||||||
"props.okff": "OKFF",
|
"props.okff": "OKFF",
|
||||||
|
|||||||
@@ -610,6 +610,8 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"section.empty": "{kind} — no section line set in the plan",
|
"section.empty": "{kind} — no section line set in the plan",
|
||||||
"section.error": "{kind} — computation failed (rebuild render3d WASM?)",
|
"section.error": "{kind} — computation failed (rebuild render3d WASM?)",
|
||||||
"section.legend": "{kind} · derived projection (render3d)",
|
"section.legend": "{kind} · derived projection (render3d)",
|
||||||
|
"elevation.shadows": "Shadows",
|
||||||
|
"elevation.shadows.hint": "Toggle 45° cast shadows of overhanging elements in the elevation",
|
||||||
"props.floorHeight": "Floor height",
|
"props.floorHeight": "Floor height",
|
||||||
"props.cutHeight": "Cut height",
|
"props.cutHeight": "Cut height",
|
||||||
"props.okff": "FFL",
|
"props.okff": "FFL",
|
||||||
|
|||||||
@@ -763,6 +763,13 @@ export interface DrawingLevel {
|
|||||||
linePoints?: [Vec2, Vec2];
|
linePoints?: [Vec2, Vec2];
|
||||||
/** Blickrichtung relativ zur Schnittlinie (nur Schnitt/Ansicht). */
|
/** Blickrichtung relativ zur Schnittlinie (nur Schnitt/Ansicht). */
|
||||||
directionSign?: 1 | -1;
|
directionSign?: 1 | -1;
|
||||||
|
/**
|
||||||
|
* Schlagschatten in der Ansicht (nur kind "elevation"). Additiv; fehlt/`false`
|
||||||
|
* ⇒ keine Schatten (heutiges Verhalten). Steuert `generateElevationPlan`
|
||||||
|
* (toElevation.ts): auskragende Bauteile werfen einen 45°-Schlagschatten auf
|
||||||
|
* die dahinterliegende Fassade.
|
||||||
|
*/
|
||||||
|
shadows?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* Unit-Tests für den Ansichts-Generator (toElevation.ts): Projektion einer
|
||||||
|
* bekannten Fläche auf (u, v)+Tiefe, Rückseiten-Culling, Painter-Reihenfolge
|
||||||
|
* (nähere Fassade NACH der ferneren) und Schlagschatten (nur mit shadows=true).
|
||||||
|
* Alles rein geometrisch auf synthetischen RWall/RSlab-Fixtures bzw. einem
|
||||||
|
* Minimalprojekt — KEIN WASM.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { Project, Wall } from "../model/types";
|
||||||
|
import type { RModel3d, RSlab, RWall } from "./toWalls3d";
|
||||||
|
import type { SectionPlaneSpec } from "./toSection";
|
||||||
|
import {
|
||||||
|
buildShadows,
|
||||||
|
collectElevationFaces,
|
||||||
|
elevationFrame,
|
||||||
|
generateElevationPlan,
|
||||||
|
projectFace,
|
||||||
|
projectPoint,
|
||||||
|
wallWorldFaces,
|
||||||
|
} from "./toElevation";
|
||||||
|
|
||||||
|
// Betrachter im Süden (world z = −2), Blick nach +Z (in die Szene). Damit liegt
|
||||||
|
// eine Wand bei z ≈ 0 HINTER der Ebene (depth > 0) und ihre Süd-Langseite zeigt
|
||||||
|
// zum Betrachter (front-facing).
|
||||||
|
const PLANE_SOUTH: SectionPlaneSpec = { point: [-1, 0, -2], normal: [0, 0, 1] };
|
||||||
|
|
||||||
|
function wall(over: Partial<RWall> = {}): RWall {
|
||||||
|
return {
|
||||||
|
start: [0, 0],
|
||||||
|
end: [4, 0],
|
||||||
|
thickness: 0.2,
|
||||||
|
height: 3,
|
||||||
|
baseElevation: 0,
|
||||||
|
color: [0.8, 0.8, 0.8],
|
||||||
|
layers: [],
|
||||||
|
wallId: "W",
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("toElevation — Projektion + Culling", () => {
|
||||||
|
it("projiziert einen Weltpunkt auf (u, v, depth)", () => {
|
||||||
|
const frame = elevationFrame(PLANE_SOUTH);
|
||||||
|
// Punkt auf der Süd-Fassade (z ≈ −0.1), x = 4, Höhe 3.
|
||||||
|
const p = projectPoint(frame, [4, 3, -0.1]);
|
||||||
|
expect(p.v).toBeCloseTo(3, 6); // v = absolute Höhe
|
||||||
|
expect(p.depth).toBeCloseTo(1.9, 6); // −0.1 − (−2) = 1.9 hinter der Ebene
|
||||||
|
// u = dot(rel, U); U = [−1, 0, 0], P.x = −1 → u = −(4 − (−1)) = −5.
|
||||||
|
expect(p.u).toBeCloseTo(-5, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Süd-Langseite einer Wand: erwartetes (u, v)-Rechteck + Tiefe, Rückseite gecullt", () => {
|
||||||
|
const frame = elevationFrame(PLANE_SOUTH);
|
||||||
|
const faces = wallWorldFaces(wall());
|
||||||
|
const projected = faces.map((f) => projectFace(frame, f));
|
||||||
|
// Genau EINE sichtbare Fläche (die Süd-Langseite); alle anderen sind
|
||||||
|
// Rückseiten oder streifend (Deckel/Boden/Stirnkappen).
|
||||||
|
const visible = projected.filter((p) => p !== null);
|
||||||
|
expect(visible.length).toBe(1);
|
||||||
|
const face = visible[0]!;
|
||||||
|
expect(face.wallId).toBe("W");
|
||||||
|
const us = face.pts.map((p) => p[0]);
|
||||||
|
const vs = face.pts.map((p) => p[1]);
|
||||||
|
expect(Math.min(...us)).toBeCloseTo(-5, 6); // x=4 → u=−5 (P.x=−1)
|
||||||
|
expect(Math.max(...us)).toBeCloseTo(-1, 6); // x=0 → u=−1
|
||||||
|
expect(Math.min(...vs)).toBeCloseTo(0, 6);
|
||||||
|
expect(Math.max(...vs)).toBeCloseTo(3, 6);
|
||||||
|
expect(face.depth).toBeCloseTo(1.9, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Rückseiten-Culling: eine Fläche mit Normale VON der Ebene weg erscheint nicht", () => {
|
||||||
|
const frame = elevationFrame(PLANE_SOUTH);
|
||||||
|
const faces = wallWorldFaces(wall());
|
||||||
|
// Die +Quer-Fläche (Nord-Langseite, Normale [0,0,−1]·N = −1 … eigentlich die
|
||||||
|
// Süd-Seite ist sichtbar) — hier prüfen wir explizit die Rückseite:
|
||||||
|
const north = faces.find((f) => f.normal[2] > 0.5); // Normale zeigt +Z (= mit N)
|
||||||
|
expect(north).toBeDefined();
|
||||||
|
expect(projectFace(frame, north!)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toElevation — Painter-Reihenfolge", () => {
|
||||||
|
it("nähere Fassade wird NACH der ferneren emittiert", () => {
|
||||||
|
const frame = elevationFrame(PLANE_SOUTH);
|
||||||
|
const model: RModel3d = {
|
||||||
|
walls: [
|
||||||
|
wall({ wallId: "NEAR", start: [0, 0], end: [4, 0] }), // z ≈ 0 → näher
|
||||||
|
wall({ wallId: "FAR", start: [0, 3], end: [4, 3] }), // z ≈ 3 → ferner
|
||||||
|
],
|
||||||
|
slabs: [],
|
||||||
|
meshes: [],
|
||||||
|
};
|
||||||
|
const faces = collectElevationFaces(model, frame);
|
||||||
|
const near = faces.find((f) => f.wallId === "NEAR")!;
|
||||||
|
const far = faces.find((f) => f.wallId === "FAR")!;
|
||||||
|
expect(near.depth).toBeLessThan(far.depth);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("im Plan: nähere Wand-Fläche hat einen höheren Primitiv-Index", () => {
|
||||||
|
const project = elevationProject({ overhang: false });
|
||||||
|
const plan = generateElevationPlan(project, project.drawingLevels[1])!;
|
||||||
|
expect(plan).not.toBeNull();
|
||||||
|
const idxNear = plan.primitives.findIndex(
|
||||||
|
(p) => p.kind === "polygon" && p.wallId === "NEAR",
|
||||||
|
);
|
||||||
|
const idxFar = plan.primitives.findIndex(
|
||||||
|
(p) => p.kind === "polygon" && p.wallId === "FAR",
|
||||||
|
);
|
||||||
|
expect(idxNear).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(idxFar).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(idxNear).toBeGreaterThan(idxFar); // fern zuerst, nah zuletzt
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toElevation — Schlagschatten", () => {
|
||||||
|
it("buildShadows: Überstand vor einer Fassade wirft ein Schatten-Polygon", () => {
|
||||||
|
const frame = elevationFrame(PLANE_SOUTH);
|
||||||
|
// Wandfläche (Fassade) hinter dem Überstand.
|
||||||
|
const wallFaces = collectElevationFaces(
|
||||||
|
{ walls: [wall()], slabs: [], meshes: [] },
|
||||||
|
frame,
|
||||||
|
);
|
||||||
|
// Überstand-Slab, der VOR der Wand (näher, z < −0.1) liegt und sie u-seitig
|
||||||
|
// überdeckt.
|
||||||
|
const slab: RSlab = {
|
||||||
|
outline: [
|
||||||
|
[0, -0.6],
|
||||||
|
[4, -0.6],
|
||||||
|
[4, 0.2],
|
||||||
|
[0, 0.2],
|
||||||
|
],
|
||||||
|
zBottom: 2.8,
|
||||||
|
zTop: 3,
|
||||||
|
color: [0.9, 0.9, 0.9],
|
||||||
|
ceilingId: "R",
|
||||||
|
};
|
||||||
|
const slabFaces = collectElevationFaces(
|
||||||
|
{ walls: [], slabs: [slab], meshes: [] },
|
||||||
|
frame,
|
||||||
|
);
|
||||||
|
const shadows = buildShadows(wallFaces, slabFaces);
|
||||||
|
expect(shadows.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buildShadows: kein Überlapp → keine Schatten", () => {
|
||||||
|
const frame = elevationFrame(PLANE_SOUTH);
|
||||||
|
const wallFaces = collectElevationFaces(
|
||||||
|
{ walls: [wall({ start: [0, 0], end: [4, 0] })], slabs: [], meshes: [] },
|
||||||
|
frame,
|
||||||
|
);
|
||||||
|
// Slab weit seitlich versetzt (u außerhalb der Wand).
|
||||||
|
const slab: RSlab = {
|
||||||
|
outline: [
|
||||||
|
[20, -0.6],
|
||||||
|
[24, -0.6],
|
||||||
|
[24, 0.2],
|
||||||
|
[20, 0.2],
|
||||||
|
],
|
||||||
|
zBottom: 2.8,
|
||||||
|
zTop: 3,
|
||||||
|
color: [0.9, 0.9, 0.9],
|
||||||
|
ceilingId: "R",
|
||||||
|
};
|
||||||
|
const slabFaces = collectElevationFaces(
|
||||||
|
{ walls: [], slabs: [slab], meshes: [] },
|
||||||
|
frame,
|
||||||
|
);
|
||||||
|
expect(buildShadows(wallFaces, slabFaces).length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("im Plan: shadows=true erzeugt zusätzliche Schatten-Primitive, false exakt keine", () => {
|
||||||
|
const project = elevationProject({ overhang: true });
|
||||||
|
const level = project.drawingLevels[1];
|
||||||
|
const withOff = generateElevationPlan(project, level, { shadows: false })!;
|
||||||
|
const withOn = generateElevationPlan(project, level, { shadows: true })!;
|
||||||
|
// Schatten sind randlose (stroke "none") Polygone im festen Schattengrau.
|
||||||
|
const shadowCount = (plan: typeof withOn) =>
|
||||||
|
plan.primitives.filter(
|
||||||
|
(p) => p.kind === "polygon" && p.stroke === "none" && p.fill === "#8f959e",
|
||||||
|
).length;
|
||||||
|
expect(shadowCount(withOff)).toBe(0);
|
||||||
|
expect(shadowCount(withOn)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toElevation — Fallback", () => {
|
||||||
|
it("ohne linePoints liefert generateElevationPlan null", () => {
|
||||||
|
const project = elevationProject({ overhang: false });
|
||||||
|
const bare = { ...project.drawingLevels[1], linePoints: undefined };
|
||||||
|
expect(generateElevationPlan(project, bare)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Minimalprojekt-Factory ──────────────────────────────────────────────────
|
||||||
|
// Zwei parallele Wände (nah/fern) + optional eine auskragende Dach-Decke.
|
||||||
|
function elevationProject(opts: { overhang: boolean }): Project {
|
||||||
|
const w = (id: string, y: number): Wall => ({
|
||||||
|
id,
|
||||||
|
type: "wall",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
start: { x: 0, y },
|
||||||
|
end: { x: 4, y },
|
||||||
|
wallTypeId: "aw",
|
||||||
|
height: 3,
|
||||||
|
});
|
||||||
|
const project: Project = {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }],
|
||||||
|
hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }],
|
||||||
|
components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }],
|
||||||
|
wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.2 }] }],
|
||||||
|
ceilingTypes: [{ id: "dc", name: "Dach", layers: [{ componentId: "a", thickness: 0.2 }] }],
|
||||||
|
drawingLevels: [
|
||||||
|
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 3, cutHeight: 1, baseElevation: 0 },
|
||||||
|
{
|
||||||
|
id: "ans",
|
||||||
|
name: "Ansicht Süd",
|
||||||
|
kind: "elevation",
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
linePoints: [{ x: -1, y: -2 }, { x: 5, y: -2 }],
|
||||||
|
directionSign: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||||
|
walls: [w("NEAR", 0), w("FAR", 3)],
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings: opts.overhang
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "R",
|
||||||
|
type: "ceiling",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
wallTypeId: "aw",
|
||||||
|
ceilingTypeId: "dc",
|
||||||
|
// Umriss ragt nach Süden (y < 0) über die Süd-Wand (y=0) hinaus.
|
||||||
|
outline: [
|
||||||
|
{ x: 0, y: -0.6 },
|
||||||
|
{ x: 4, y: -0.6 },
|
||||||
|
{ x: 4, y: 0.2 },
|
||||||
|
{ x: 0, y: 0.2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: [],
|
||||||
|
context: [],
|
||||||
|
};
|
||||||
|
return project;
|
||||||
|
}
|
||||||
@@ -0,0 +1,679 @@
|
|||||||
|
// Ansichts-Generator (Elevation/Fassadenansicht): erzeugt aus dem Modell eine
|
||||||
|
// echte 2D-Architektur-Ansicht mit gefüllten Flächen statt eines blossen
|
||||||
|
// Kanten-Durchlaufs durch die Schnitt-Pipeline.
|
||||||
|
//
|
||||||
|
// Ablauf (rein TS, KEIN WASM):
|
||||||
|
// 1. Aus der Ansichts-Ebene (DrawingLevel, kind "elevation") wird über
|
||||||
|
// `sectionPlaneFromLevel`/`sectionUAxisModel` (toSection.ts, lesend) eine
|
||||||
|
// Welt-Ansichtsebene abgeleitet: Punkt P, Blicknormale N (vom Betrachter ins
|
||||||
|
// Modell) und die horizontale In-Plane-u-Achse U (Y-up-Welt).
|
||||||
|
// 2. Das Projekt wird über `projectToModel3d({ layeredWalls:false })` geflacht
|
||||||
|
// (eine Voll-Box je Wand, Öffnungen als Segment-Aussparungen; plus Decken-
|
||||||
|
// Prismen). Jede relevante Fläche (Wand-Aussenseiten, Decken-Stirn-/Deck-
|
||||||
|
// flächen) wird auf (u, v) projiziert: u = dot(W−P, U), v = Welt-Höhe (W.y,
|
||||||
|
// identisch zur Schnitt-v-Konvention), depth = dot(W−P, N).
|
||||||
|
// 3. SICHTBARKEIT über den PAINTER-ALGORITHMUS (Näherung, siehe unten): nur
|
||||||
|
// Flächen, die zum Betrachter zeigen (Normale gegen N) UND hinter der Ebene
|
||||||
|
// liegen (depth > 0), werden von HINTEN nach VORNE (grosse depth zuerst) als
|
||||||
|
// gefüllte Polygone emittiert. Nähere Flächen überdecken so fernere.
|
||||||
|
// 4. Fenster/Türen: als Rahmen+Glas-Flächen auf der Aussenseite der Wirtswand.
|
||||||
|
// 5. Optionaler Schlagschatten (`level.shadows`): auskragende Decken/Balkone
|
||||||
|
// (Slabs) werfen einen 45°-Schatten auf die dahinterliegende Fassade.
|
||||||
|
//
|
||||||
|
// NÄHERUNG (bewusst, dokumentiert): Der Painter-Algorithmus sortiert GANZE Flächen
|
||||||
|
// nach ihrer mittleren Tiefe und lässt nähere Flächen fernere überdecken — er ist
|
||||||
|
// KEIN exakter Hidden-Line-/Hidden-Surface-Algorithmus. Sich gegenseitig
|
||||||
|
// durchdringende oder in der Tiefe verschränkte Flächen (selten bei orthogonalen
|
||||||
|
// Fassaden) können falsch geordnet werden. Für Architektur-Ansichten (überwiegend
|
||||||
|
// planparallele, gestaffelte Flächen) genügt das — vgl. Auftragsvorgabe.
|
||||||
|
//
|
||||||
|
// Einheiten: METER. Ausgabe wie der Schnitt: u = horizontal entlang der Ebene,
|
||||||
|
// v = absolute Höhe (Welt-Y). Die PlanView zeichnet Y nach oben → Ansicht steht
|
||||||
|
// aufrecht.
|
||||||
|
|
||||||
|
import type { DrawingLevel, Project, Vec2, Wall } from "../model/types";
|
||||||
|
import {
|
||||||
|
getWallType,
|
||||||
|
openingsOfWall,
|
||||||
|
wallTypeThickness,
|
||||||
|
} from "../model/types";
|
||||||
|
import { wallVerticalExtent } from "../model/wall";
|
||||||
|
import { openingInterval, openingVerticalExtent } from "../geometry/opening";
|
||||||
|
import { projectToModel3d } from "./toWalls3d";
|
||||||
|
import type { RModel3d, RSlab, RWall } from "./toWalls3d";
|
||||||
|
import { sectionPlaneFromLevel, sectionUAxisModel } from "./toSection";
|
||||||
|
import type { SectionPlaneSpec } from "./toSection";
|
||||||
|
import type { HatchRender, Plan, Primitive } from "./generatePlan";
|
||||||
|
|
||||||
|
/** Welt-3D-Punkt (x, Höhe/y, z) — Y-up, identisch zur render3d-Weltkonvention. */
|
||||||
|
export type V3 = [number, number, number];
|
||||||
|
|
||||||
|
/** Leere Schraffur (Ansichtsflächen tragen keine Musterlinien, nur Fläche+Umriss). */
|
||||||
|
const NO_HATCH: HatchRender = {
|
||||||
|
pattern: "none",
|
||||||
|
scale: 1,
|
||||||
|
angle: 0,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
lineWeight: 0.02,
|
||||||
|
dash: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Stil (monochrome Architektur-Ansicht) ──────────────────────────────────
|
||||||
|
/** Tinte der Ansichts-Umrisskanten (Haarlinie um jede Fläche). */
|
||||||
|
const EDGE_INK = "#2b3039";
|
||||||
|
/** Strichstärke der Flächen-Umrisskanten (mm Papier, Haarlinie). */
|
||||||
|
const EDGE_MM = 0.13;
|
||||||
|
/** Fenster/Tür-Rahmen: etwas kräftigere Umrisslinie. */
|
||||||
|
const FRAME_MM = 0.18;
|
||||||
|
/** Blaugraues Fenster-Glas (dunkler als die Fassade → als Öffnung lesbar). */
|
||||||
|
const GLASS_FILL = "#7f8b99";
|
||||||
|
/** Glas im Mono-Modus (neutrales Grau statt Blaugrau). */
|
||||||
|
const GLASS_FILL_MONO = "#c4c8cd";
|
||||||
|
/** Festes Schattengrau (über der Fassade, unter den Kanten). */
|
||||||
|
const SHADOW_FILL = "#8f959e";
|
||||||
|
/** Bodenlinie (Terrain-/Nulllinie): kräftige Tinte über die volle Breite. */
|
||||||
|
const GROUND_INK = "#1f242c";
|
||||||
|
/** Strichstärke der Bodenlinie (mm Papier). */
|
||||||
|
const GROUND_MM = 0.5;
|
||||||
|
/** Fassaden-Fläche: hellste (nächste) und dunkelste (fernste) Graustufe. */
|
||||||
|
const FILL_NEAR_L = 0.96;
|
||||||
|
const FILL_FAR_L = 0.82;
|
||||||
|
|
||||||
|
/** Sonnenrichtung als (u, v)-Schattenversatz je Tiefeneinheit: 45° von links
|
||||||
|
* oben ⇒ Schatten fällt nach rechts unten (+u, −v). */
|
||||||
|
const SUN_U = 1;
|
||||||
|
const SUN_V = -1;
|
||||||
|
|
||||||
|
/** Numerisches Rauschen (Meter) für Tiefen-/Flächenvergleiche. */
|
||||||
|
const EPS = 1e-6;
|
||||||
|
/** Grazing-Schwelle: Flächen fast parallel zur Blickrichtung gelten als nicht
|
||||||
|
* sichtbar (Kanten-an, keine Fläche) — vermeidet Z-Rauschen an Laibungen. */
|
||||||
|
const FACING_EPS = 1e-4;
|
||||||
|
|
||||||
|
// ── Ebenen-Frame ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Der orthonormale Ansichts-Frame in Weltkoordinaten. */
|
||||||
|
export interface ElevationFrame {
|
||||||
|
/** Punkt auf der Ebene (Welt). */
|
||||||
|
P: V3;
|
||||||
|
/** Blicknormale (Einheit, vom Betrachter ins Modell). */
|
||||||
|
N: V3;
|
||||||
|
/** Horizontale In-Plane-u-Achse (Einheit). */
|
||||||
|
U: V3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut den Ansichts-Frame aus einer Welt-Ebenen-Spezifikation. `N` (aus
|
||||||
|
* `sectionPlaneFromLevel`) ist bereits horizontal und Einheit; `U` folgt exakt
|
||||||
|
* `sectionUAxisModel` (Modell-2D (x, z)) in die Welt gehoben — dieselbe u-Achse
|
||||||
|
* wie der Schnitt-Extraktor, damit Ansicht und Schnitt deckungsgleich messen.
|
||||||
|
*/
|
||||||
|
export function elevationFrame(plane: SectionPlaneSpec): ElevationFrame {
|
||||||
|
const [nx, ny, nz] = plane.normal;
|
||||||
|
const nLen = Math.hypot(nx, ny, nz) || 1;
|
||||||
|
const N: V3 = [nx / nLen, ny / nLen, nz / nLen];
|
||||||
|
const uModel = sectionUAxisModel(plane); // Modell-2D (x, z)
|
||||||
|
const ux = uModel.x;
|
||||||
|
const uz = uModel.y;
|
||||||
|
const uLen = Math.hypot(ux, uz) || 1;
|
||||||
|
const U: V3 = [ux / uLen, 0, uz / uLen];
|
||||||
|
return { P: plane.point, N, U };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Projektion eines Weltpunkts in die Ansicht: u, v (Höhe) und Tiefe hinter der Ebene. */
|
||||||
|
export function projectPoint(
|
||||||
|
frame: ElevationFrame,
|
||||||
|
w: V3,
|
||||||
|
): { u: number; v: number; depth: number } {
|
||||||
|
const rx = w[0] - frame.P[0];
|
||||||
|
const ry = w[1] - frame.P[1];
|
||||||
|
const rz = w[2] - frame.P[2];
|
||||||
|
return {
|
||||||
|
u: rx * frame.U[0] + ry * frame.U[1] + rz * frame.U[2],
|
||||||
|
v: w[1], // absolute Höhe, wie die Schnitt-v-Konvention
|
||||||
|
depth: rx * frame.N[0] + ry * frame.N[1] + rz * frame.N[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Welt-Flächen aus dem geflachten Modell ─────────────────────────────────
|
||||||
|
|
||||||
|
/** Rolle einer Fläche (steuert Füllung/Stil in der Ansicht). */
|
||||||
|
export type FaceRole = "wall" | "slab";
|
||||||
|
|
||||||
|
/** Eine orientierte Welt-Fläche (konvexes Polygon + Aussennormale). */
|
||||||
|
export interface WorldFace {
|
||||||
|
poly: V3[];
|
||||||
|
normal: V3;
|
||||||
|
role: FaceRole;
|
||||||
|
/** Ursprungs-Wand (nur role "wall") — für Auswahl/Painter-Identität. */
|
||||||
|
wallId?: string;
|
||||||
|
/** Ursprungs-Decke (nur role "slab"). */
|
||||||
|
ceilingId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize3(a: V3): V3 {
|
||||||
|
const l = Math.hypot(a[0], a[1], a[2]) || 1;
|
||||||
|
return [a[0] / l, a[1] / l, a[2] / l];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die sechs Quader-Flächen EINER Wand-Box (RWall) in Weltkoordinaten mit
|
||||||
|
* Aussennormalen: zwei Langseiten (Fassaden-Aussenflächen, Normale horizontal
|
||||||
|
* quer zur Wand), zwei Stirnkappen (Normale entlang der Achse), Deckel/Boden.
|
||||||
|
*/
|
||||||
|
export function wallWorldFaces(w: RWall): WorldFace[] {
|
||||||
|
const sx = w.start[0];
|
||||||
|
const sz = w.start[1];
|
||||||
|
const ex = w.end[0];
|
||||||
|
const ez = w.end[1];
|
||||||
|
const ax = ex - sx;
|
||||||
|
const az = ez - sz;
|
||||||
|
const len = Math.hypot(ax, az);
|
||||||
|
if (len < 1e-9 || w.height <= EPS) return [];
|
||||||
|
const aHat: V3 = [ax / len, 0, az / len];
|
||||||
|
const nHat: V3 = [az / len, 0, -ax / len]; // horizontale Quer-Normale
|
||||||
|
const ht = w.thickness / 2;
|
||||||
|
const zb = w.baseElevation;
|
||||||
|
const zt = w.baseElevation + w.height;
|
||||||
|
// Ecke bei (Achsen-Meter sAlong, Quer-Versatz tOff, Höhe y).
|
||||||
|
const corner = (sAlong: number, tOff: number, y: number): V3 => [
|
||||||
|
sx + aHat[0] * sAlong + nHat[0] * tOff,
|
||||||
|
y,
|
||||||
|
sz + aHat[2] * sAlong + nHat[2] * tOff,
|
||||||
|
];
|
||||||
|
const faces: WorldFace[] = [];
|
||||||
|
// +Quer (Aussenfläche A)
|
||||||
|
faces.push({
|
||||||
|
role: "wall",
|
||||||
|
normal: nHat,
|
||||||
|
poly: [corner(0, ht, zb), corner(len, ht, zb), corner(len, ht, zt), corner(0, ht, zt)],
|
||||||
|
});
|
||||||
|
// −Quer (Aussenfläche B)
|
||||||
|
faces.push({
|
||||||
|
role: "wall",
|
||||||
|
normal: [-nHat[0], -nHat[1], -nHat[2]],
|
||||||
|
poly: [corner(0, -ht, zb), corner(0, -ht, zt), corner(len, -ht, zt), corner(len, -ht, zb)],
|
||||||
|
});
|
||||||
|
// Stirn Start (−Achse)
|
||||||
|
faces.push({
|
||||||
|
role: "wall",
|
||||||
|
normal: [-aHat[0], -aHat[1], -aHat[2]],
|
||||||
|
poly: [corner(0, -ht, zb), corner(0, ht, zb), corner(0, ht, zt), corner(0, -ht, zt)],
|
||||||
|
});
|
||||||
|
// Stirn Ende (+Achse)
|
||||||
|
faces.push({
|
||||||
|
role: "wall",
|
||||||
|
normal: aHat,
|
||||||
|
poly: [corner(len, -ht, zb), corner(len, -ht, zt), corner(len, ht, zt), corner(len, ht, zb)],
|
||||||
|
});
|
||||||
|
// Deckel (+Höhe)
|
||||||
|
faces.push({
|
||||||
|
role: "wall",
|
||||||
|
normal: [0, 1, 0],
|
||||||
|
poly: [corner(0, -ht, zt), corner(0, ht, zt), corner(len, ht, zt), corner(len, -ht, zt)],
|
||||||
|
});
|
||||||
|
// Boden (−Höhe)
|
||||||
|
faces.push({
|
||||||
|
role: "wall",
|
||||||
|
normal: [0, -1, 0],
|
||||||
|
poly: [corner(0, -ht, zb), corner(len, -ht, zb), corner(len, ht, zb), corner(0, ht, zb)],
|
||||||
|
});
|
||||||
|
for (const f of faces) f.wallId = w.wallId;
|
||||||
|
return faces;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Flächen EINER Deckenplatte (RSlab): Deckel + Boden (Umriss bei zTop/zBottom)
|
||||||
|
* und je Umrisskante eine Seitenfläche (Stirn). Die Seitennormalen werden über
|
||||||
|
* den Grundriss-Schwerpunkt nach AUSSEN orientiert (unabhängig von der Umriss-
|
||||||
|
* Windung). Der Umriss (Modell x,y) wird als Welt (x, y=Höhe, z=y) gehoben.
|
||||||
|
*/
|
||||||
|
export function slabWorldFaces(s: RSlab): WorldFace[] {
|
||||||
|
const n = s.outline.length;
|
||||||
|
if (n < 3 || s.zTop - s.zBottom <= EPS) return [];
|
||||||
|
// Grundriss-Schwerpunkt (für die Aussen-Orientierung der Seitenflächen).
|
||||||
|
let cx = 0;
|
||||||
|
let cz = 0;
|
||||||
|
for (const p of s.outline) {
|
||||||
|
cx += p[0];
|
||||||
|
cz += p[1];
|
||||||
|
}
|
||||||
|
cx /= n;
|
||||||
|
cz /= n;
|
||||||
|
const bottom: V3[] = s.outline.map((p) => [p[0], s.zBottom, p[1]]);
|
||||||
|
const top: V3[] = s.outline.map((p) => [p[0], s.zTop, p[1]]);
|
||||||
|
const faces: WorldFace[] = [];
|
||||||
|
faces.push({ role: "slab", normal: [0, 1, 0], poly: top.slice() });
|
||||||
|
faces.push({ role: "slab", normal: [0, -1, 0], poly: bottom.slice().reverse() });
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const j = (i + 1) % n;
|
||||||
|
const ex = s.outline[j][0] - s.outline[i][0];
|
||||||
|
const ez = s.outline[j][1] - s.outline[i][1];
|
||||||
|
// Kandidat-Normale horizontal quer zur Kante; nach aussen orientieren.
|
||||||
|
let m: V3 = normalize3([ez, 0, -ex]);
|
||||||
|
const midX = (s.outline[i][0] + s.outline[j][0]) / 2;
|
||||||
|
const midZ = (s.outline[i][1] + s.outline[j][1]) / 2;
|
||||||
|
if (m[0] * (midX - cx) + m[2] * (midZ - cz) < 0) m = [-m[0], -m[1], -m[2]];
|
||||||
|
faces.push({
|
||||||
|
role: "slab",
|
||||||
|
normal: m,
|
||||||
|
poly: [bottom[i], bottom[j], top[j], top[i]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const f of faces) f.ceilingId = s.ceilingId;
|
||||||
|
return faces;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Projektion + Sichtbarkeit ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Eine in die Ansicht projizierte, sichtbare Fläche. */
|
||||||
|
export interface ProjectedFace {
|
||||||
|
pts: Array<[number, number]>;
|
||||||
|
/** Mittlere Tiefe hinter der Ebene (Painter-Schlüssel). */
|
||||||
|
depth: number;
|
||||||
|
role: FaceRole;
|
||||||
|
wallId?: string;
|
||||||
|
ceilingId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projiziert eine Welt-Fläche und entscheidet die Sichtbarkeit:
|
||||||
|
* • Rückseiten-Culling: Normale muss GEGEN die Blickrichtung zeigen (N·Nf < 0).
|
||||||
|
* • Vor/Hinter: die mittlere Tiefe muss hinter der Ebene liegen (depth > 0).
|
||||||
|
* Sonst `null` (Fläche erscheint nicht).
|
||||||
|
*/
|
||||||
|
export function projectFace(frame: ElevationFrame, face: WorldFace): ProjectedFace | null {
|
||||||
|
const facing = frame.N[0] * face.normal[0] + frame.N[1] * face.normal[1] + frame.N[2] * face.normal[2];
|
||||||
|
if (facing >= -FACING_EPS) return null; // Rückseite oder streifend → unsichtbar
|
||||||
|
const pts: Array<[number, number]> = [];
|
||||||
|
let depthSum = 0;
|
||||||
|
for (const w of face.poly) {
|
||||||
|
const p = projectPoint(frame, w);
|
||||||
|
pts.push([p.u, p.v]);
|
||||||
|
depthSum += p.depth;
|
||||||
|
}
|
||||||
|
const depth = depthSum / face.poly.length;
|
||||||
|
if (depth <= EPS) return null; // vor der Ebene → ignorieren
|
||||||
|
return { pts, depth, role: face.role, wallId: face.wallId, ceilingId: face.ceilingId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sammelt alle sichtbaren, projizierten Flächen des Modells (Wände + Decken),
|
||||||
|
* bereits rückseiten-gecullt und vor der Ebene verworfen. Reihenfolge = Emissions-
|
||||||
|
* reihenfolge des Modells (NICHT tiefensortiert) — die Painter-Sortierung macht
|
||||||
|
* der Aufrufer.
|
||||||
|
*/
|
||||||
|
export function collectElevationFaces(model: RModel3d, frame: ElevationFrame): ProjectedFace[] {
|
||||||
|
const out: ProjectedFace[] = [];
|
||||||
|
for (const w of model.walls) {
|
||||||
|
for (const f of wallWorldFaces(w)) {
|
||||||
|
const pf = projectFace(frame, f);
|
||||||
|
if (pf) out.push(pf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const s of model.slabs) {
|
||||||
|
for (const f of slabWorldFaces(s)) {
|
||||||
|
const pf = projectFace(frame, f);
|
||||||
|
if (pf) out.push(pf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fenster/Türen ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Eine projizierte Öffnungs-Fläche (Rahmen+Glas) auf der Wand-Aussenseite. */
|
||||||
|
interface ProjectedOpening {
|
||||||
|
pts: Array<[number, number]>;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projiziert die Aussenfläche EINER Öffnung (Fenster/Tür) der Wirtswand: das
|
||||||
|
* Rechteck [from..to] × [zBottom..zTop] auf der zum Betrachter zeigenden
|
||||||
|
* Wand-Langseite (±T/2). Näherung: die Öffnung wird als planare Glasfläche BÜNDIG
|
||||||
|
* in der Aussenwandebene dargestellt (keine Laibungstiefe) — genügt für die
|
||||||
|
* Fassaden-Lesbarkeit. `null`, wenn keine Seite front-/hinter-Ebene liegt.
|
||||||
|
*/
|
||||||
|
function projectOpeningRect(
|
||||||
|
frame: ElevationFrame,
|
||||||
|
wall: Wall,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
zBottom: number,
|
||||||
|
zTop: number,
|
||||||
|
thickness: number,
|
||||||
|
): ProjectedOpening | null {
|
||||||
|
const sx = wall.start.x;
|
||||||
|
const sz = wall.start.y;
|
||||||
|
const ax = wall.end.x - wall.start.x;
|
||||||
|
const az = wall.end.y - wall.start.y;
|
||||||
|
const len = Math.hypot(ax, az);
|
||||||
|
if (len < 1e-9 || to - from <= EPS || zTop - zBottom <= EPS) return null;
|
||||||
|
const aHat: V3 = [ax / len, 0, az / len];
|
||||||
|
const nHat: V3 = [az / len, 0, -ax / len];
|
||||||
|
const ht = thickness / 2;
|
||||||
|
// Kandidaten-Seiten (±T/2): die front-facing + hinter der Ebene liegende nehmen.
|
||||||
|
for (const side of [ht, -ht]) {
|
||||||
|
const normal: V3 = side > 0 ? nHat : [-nHat[0], -nHat[1], -nHat[2]];
|
||||||
|
const facing = frame.N[0] * normal[0] + frame.N[1] * normal[1] + frame.N[2] * normal[2];
|
||||||
|
if (facing >= -FACING_EPS) continue;
|
||||||
|
const corner = (sAlong: number, y: number): V3 => [
|
||||||
|
sx + aHat[0] * sAlong + nHat[0] * side,
|
||||||
|
y,
|
||||||
|
sz + aHat[2] * sAlong + nHat[2] * side,
|
||||||
|
];
|
||||||
|
const world = [corner(from, zBottom), corner(to, zBottom), corner(to, zTop), corner(from, zTop)];
|
||||||
|
const pts: Array<[number, number]> = [];
|
||||||
|
let depthSum = 0;
|
||||||
|
for (const w of world) {
|
||||||
|
const p = projectPoint(frame, w);
|
||||||
|
pts.push([p.u, p.v]);
|
||||||
|
depthSum += p.depth;
|
||||||
|
}
|
||||||
|
const depth = depthSum / world.length;
|
||||||
|
if (depth <= EPS) continue;
|
||||||
|
return { pts, depth };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Öffnungs-Flächen (Fenster/Türen inkl. Legacy-Türen) des Projekts, auf die
|
||||||
|
* jeweilige Wand-Aussenseite projiziert. Thickness = WallType-Dicke (Fallback
|
||||||
|
* 0.2 m). Rein aus dem Projekt (nicht aus dem geflachten Modell), damit Rahmen+Glas
|
||||||
|
* unabhängig von der Segment-Aussparung der Wand-Box gezeichnet werden können.
|
||||||
|
*/
|
||||||
|
function collectOpenings(project: Project, frame: ElevationFrame): ProjectedOpening[] {
|
||||||
|
const out: ProjectedOpening[] = [];
|
||||||
|
for (const wall of project.walls) {
|
||||||
|
let thickness = 0.2;
|
||||||
|
try {
|
||||||
|
thickness = wallTypeThickness(getWallType(project, wall));
|
||||||
|
} catch {
|
||||||
|
thickness = 0.2;
|
||||||
|
}
|
||||||
|
const { zBottom, zTop } = wallVerticalExtent(project, wall);
|
||||||
|
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
|
||||||
|
for (const op of openingsOfWall(project, wall.id)) {
|
||||||
|
const iv = openingInterval(wall, op);
|
||||||
|
if (!iv) continue;
|
||||||
|
const v = openingVerticalExtent(project, wall, op);
|
||||||
|
const pr = projectOpeningRect(frame, wall, iv.from, iv.to, v.zBottom, v.zTop, thickness);
|
||||||
|
if (pr) out.push(pr);
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
const oTop = Math.min(zTop, zBottom + d.height);
|
||||||
|
const pr = projectOpeningRect(frame, wall, from, to, zBottom, oTop, thickness);
|
||||||
|
if (pr) out.push(pr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schlagschatten (45°, klassische Architektur-Konvention) ─────────────────
|
||||||
|
|
||||||
|
/** Achsparalleles (u, v)-Rechteck. */
|
||||||
|
interface UVRect {
|
||||||
|
uMin: number;
|
||||||
|
uMax: number;
|
||||||
|
vMin: number;
|
||||||
|
vMax: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bboxOf(pts: Array<[number, number]>): UVRect {
|
||||||
|
let uMin = Infinity, uMax = -Infinity, vMin = Infinity, vMax = -Infinity;
|
||||||
|
for (const [u, v] of pts) {
|
||||||
|
if (u < uMin) uMin = u;
|
||||||
|
if (u > uMax) uMax = u;
|
||||||
|
if (v < vMin) vMin = v;
|
||||||
|
if (v > vMax) vMax = v;
|
||||||
|
}
|
||||||
|
return { uMin, uMax, vMin, vMax };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clippt ein Polygon an ein achsparalleles Rechteck (Sutherland–Hodgman, vier
|
||||||
|
* Halbebenen). Liefert das (konvexe) Restpolygon oder [] bei leerem Schnitt.
|
||||||
|
*/
|
||||||
|
function clipPolygonToRect(poly: Array<[number, number]>, r: UVRect): Array<[number, number]> {
|
||||||
|
type Pt = [number, number];
|
||||||
|
const clip = (
|
||||||
|
input: Pt[],
|
||||||
|
inside: (p: Pt) => boolean,
|
||||||
|
intersect: (a: Pt, b: Pt) => Pt,
|
||||||
|
): Pt[] => {
|
||||||
|
const out: Pt[] = [];
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const a = input[i];
|
||||||
|
const b = input[(i + 1) % input.length];
|
||||||
|
const ain = inside(a);
|
||||||
|
const bin = inside(b);
|
||||||
|
if (ain) out.push(a);
|
||||||
|
if (ain !== bin) out.push(intersect(a, b));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
let p: Pt[] = poly.slice();
|
||||||
|
const lerp = (a: Pt, b: Pt, t: number): Pt => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
||||||
|
// links (u >= uMin)
|
||||||
|
p = clip(p, (q) => q[0] >= r.uMin, (a, b) => lerp(a, b, (r.uMin - a[0]) / (b[0] - a[0])));
|
||||||
|
if (p.length < 3) return [];
|
||||||
|
// rechts (u <= uMax)
|
||||||
|
p = clip(p, (q) => q[0] <= r.uMax, (a, b) => lerp(a, b, (r.uMax - a[0]) / (b[0] - a[0])));
|
||||||
|
if (p.length < 3) return [];
|
||||||
|
// unten (v >= vMin)
|
||||||
|
p = clip(p, (q) => q[1] >= r.vMin, (a, b) => lerp(a, b, (r.vMin - a[1]) / (b[1] - a[1])));
|
||||||
|
if (p.length < 3) return [];
|
||||||
|
// oben (v <= vMax)
|
||||||
|
p = clip(p, (q) => q[1] <= r.vMax, (a, b) => lerp(a, b, (r.vMax - a[1]) / (b[1] - a[1])));
|
||||||
|
if (p.length < 3) return [];
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Schatten-Polygon mit der Tiefe der Empfängerfläche (für die Painter-Ordnung). */
|
||||||
|
interface ShadowPoly {
|
||||||
|
pts: Array<[number, number]>;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die Schlagschatten auskragender Decken/Balkone (Slabs) auf die dahinter
|
||||||
|
* liegenden Fassaden (Wände). KLASSISCHE ARCHITEKTUR-KONVENTION: Sonne 45° von
|
||||||
|
* links oben ⇒ die Unterkante des Überstands wirft einen Schatten, dessen
|
||||||
|
* horizontaler Versatz UND vertikaler Abfall der Auskragungstiefe entspricht
|
||||||
|
* (Δ = Tiefendifferenz Empfänger − Überstand). Der Schatten ist ein Parallelo-
|
||||||
|
* gramm unter der Überstand-Unterkante (Höhe/Breite ∝ Δ), geclippt auf die
|
||||||
|
* Empfänger-Fassade.
|
||||||
|
*
|
||||||
|
* NÄHERUNG (dokumentiert): Überstand-Silhouette = (u, v)-Bounding-Box der Slab-
|
||||||
|
* Fläche; Empfänger = Bounding-Box der Wandfläche. Nur Slab→Wand-Paare mit
|
||||||
|
* echtem Tiefen-Vorsprung (Slab näher) und u-Überlapp erzeugen Schatten. Deckt
|
||||||
|
* die geforderten Fälle (Dachüberstand/Traufe, auskragende Decke/Balkon) ab;
|
||||||
|
* schräge Auskragungen werden als achsparalleles Band angenähert.
|
||||||
|
*/
|
||||||
|
export function buildShadows(walls: ProjectedFace[], slabs: ProjectedFace[]): ShadowPoly[] {
|
||||||
|
const out: ShadowPoly[] = [];
|
||||||
|
for (const slab of slabs) {
|
||||||
|
const so = bboxOf(slab.pts);
|
||||||
|
for (const wall of walls) {
|
||||||
|
const wo = bboxOf(wall.pts);
|
||||||
|
const delta = wall.depth - slab.depth; // Wand hinter dem Überstand?
|
||||||
|
if (delta <= EPS) continue;
|
||||||
|
// u-Überlapp Überstand/Wand?
|
||||||
|
if (so.uMax <= wo.uMin + EPS || so.uMin >= wo.uMax - EPS) continue;
|
||||||
|
// Der Schatten hängt an der Slab-Unterkante (so.vMin) und fällt um Δ nach
|
||||||
|
// unten, versetzt um Δ nach rechts (Sonne von links oben).
|
||||||
|
const vTop = so.vMin;
|
||||||
|
const parallelogram: Array<[number, number]> = [
|
||||||
|
[so.uMin, vTop],
|
||||||
|
[so.uMax, vTop],
|
||||||
|
[so.uMax + delta * SUN_U, vTop + delta * SUN_V],
|
||||||
|
[so.uMin + delta * SUN_U, vTop + delta * SUN_V],
|
||||||
|
];
|
||||||
|
const clipped = clipPolygonToRect(parallelogram, wo);
|
||||||
|
if (clipped.length >= 3) out.push({ pts: clipped, depth: wall.depth });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plan-Zusammenbau ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Optionen des Ansichts-Generators. */
|
||||||
|
export interface ElevationOptions {
|
||||||
|
/** Schlagschatten zeichnen (Default aus `level.shadows`). */
|
||||||
|
shadows?: boolean;
|
||||||
|
/** Schwarz-Weiss-Modus (neutralisierte Füllungen). */
|
||||||
|
mono?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Graustufen-Hex aus einer Helligkeit 0..1. */
|
||||||
|
function greyHex(l: number): string {
|
||||||
|
const c = Math.max(0, Math.min(255, Math.round(l * 255)));
|
||||||
|
const h = c.toString(16).padStart(2, "0");
|
||||||
|
return `#${h}${h}${h}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt den Ansichts-Plan einer Ebene (kind "elevation"). `null`, wenn die
|
||||||
|
* Ebene (noch) keine Ansichtslinie trägt — der Aufrufer zeigt dann den bestehenden
|
||||||
|
* Hinweis. Rein synchron/TS.
|
||||||
|
*/
|
||||||
|
export function generateElevationPlan(
|
||||||
|
project: Project,
|
||||||
|
level: DrawingLevel,
|
||||||
|
opts: ElevationOptions = {},
|
||||||
|
): Plan | null {
|
||||||
|
const plane = sectionPlaneFromLevel(level);
|
||||||
|
if (!plane) return null;
|
||||||
|
const shadows = opts.shadows ?? level.shadows ?? false;
|
||||||
|
const mono = opts.mono ?? false;
|
||||||
|
const frame = elevationFrame(plane);
|
||||||
|
|
||||||
|
// Eine Voll-Box je Wand (Öffnungen als Segment-Aussparungen) + Decken-Prismen.
|
||||||
|
const model = projectToModel3d(project, { layeredWalls: false });
|
||||||
|
const faces = collectElevationFaces(model, frame);
|
||||||
|
const wallFaces = faces.filter((f) => f.role === "wall");
|
||||||
|
const slabFaces = faces.filter((f) => f.role === "slab");
|
||||||
|
|
||||||
|
const primitives: Primitive[] = [];
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
const acc = (x: number, y: number) => {
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tiefenbereich für die Graustufen-Staffelung (nah hell, fern dunkel).
|
||||||
|
let dMin = Infinity, dMax = -Infinity;
|
||||||
|
for (const f of faces) {
|
||||||
|
if (f.depth < dMin) dMin = f.depth;
|
||||||
|
if (f.depth > dMax) dMax = f.depth;
|
||||||
|
}
|
||||||
|
const dSpan = dMax - dMin;
|
||||||
|
const fillFor = (depth: number): string => {
|
||||||
|
if (mono) return "#ffffff";
|
||||||
|
const t = dSpan > EPS ? (depth - dMin) / dSpan : 0; // 0 = nah, 1 = fern
|
||||||
|
return greyHex(FILL_NEAR_L + (FILL_FAR_L - FILL_NEAR_L) * t);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Painter-Sammelstrom: Flächen + Schatten + Öffnungen, fern→nah sortiert.
|
||||||
|
// Jede Fläche trägt Füllung + eigene Umrisslinie (nähere Füllung überdeckt
|
||||||
|
// fernere Kanten → korrekte Painter-Verdeckung ohne Extra-Kantenpass).
|
||||||
|
interface Item {
|
||||||
|
depth: number;
|
||||||
|
seq: number;
|
||||||
|
prim: Primitive;
|
||||||
|
}
|
||||||
|
const items: Item[] = [];
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
for (const f of faces) {
|
||||||
|
const pts: Vec2[] = f.pts.map(([u, v]) => {
|
||||||
|
acc(u, v);
|
||||||
|
return { x: u, y: v };
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
depth: f.depth,
|
||||||
|
seq: seq++,
|
||||||
|
prim: {
|
||||||
|
kind: "polygon",
|
||||||
|
pts,
|
||||||
|
fill: fillFor(f.depth),
|
||||||
|
stroke: EDGE_INK,
|
||||||
|
strokeWidthMm: EDGE_MM,
|
||||||
|
hatch: NO_HATCH,
|
||||||
|
...(f.wallId ? { wallId: f.wallId } : {}),
|
||||||
|
...(f.ceilingId ? { ceilingId: f.ceilingId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schatten (leicht VOR der Empfängertiefe, damit sie direkt nach der Fassade,
|
||||||
|
// aber unter den Öffnungen/Kanten näherer Flächen liegen).
|
||||||
|
if (shadows) {
|
||||||
|
for (const sh of buildShadows(wallFaces, slabFaces)) {
|
||||||
|
const pts: Vec2[] = sh.pts.map(([u, v]) => {
|
||||||
|
acc(u, v);
|
||||||
|
return { x: u, y: v };
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
depth: sh.depth - EPS * 10,
|
||||||
|
seq: seq++,
|
||||||
|
prim: {
|
||||||
|
kind: "polygon",
|
||||||
|
pts,
|
||||||
|
fill: SHADOW_FILL,
|
||||||
|
stroke: "none",
|
||||||
|
strokeWidthMm: 0,
|
||||||
|
hatch: NO_HATCH,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fenster/Türen: Rahmen+Glas, knapp VOR der Wirtsfläche (auf der Fassade).
|
||||||
|
for (const op of collectOpenings(project, frame)) {
|
||||||
|
const pts: Vec2[] = op.pts.map(([u, v]) => {
|
||||||
|
acc(u, v);
|
||||||
|
return { x: u, y: v };
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
depth: op.depth - EPS * 100,
|
||||||
|
seq: seq++,
|
||||||
|
prim: {
|
||||||
|
kind: "polygon",
|
||||||
|
pts,
|
||||||
|
fill: mono ? GLASS_FILL_MONO : GLASS_FILL,
|
||||||
|
stroke: EDGE_INK,
|
||||||
|
strokeWidthMm: FRAME_MM,
|
||||||
|
hatch: NO_HATCH,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Painter: fern (grosse Tiefe) zuerst, nah zuletzt. Stabiler Tiebreak über seq.
|
||||||
|
items.sort((a, b) => (b.depth - a.depth) || (a.seq - b.seq));
|
||||||
|
for (const it of items) primitives.push(it.prim);
|
||||||
|
|
||||||
|
// Bodenlinie (Terrain-/Nulllinie): kräftige Linie über die volle Breite an der
|
||||||
|
// untersten sichtbaren Fassadenkante.
|
||||||
|
if (isFinite(minX) && isFinite(minY)) {
|
||||||
|
primitives.push({
|
||||||
|
kind: "line",
|
||||||
|
a: { x: minX, y: minY },
|
||||||
|
b: { x: maxX, y: minY },
|
||||||
|
cls: "elevation-ground",
|
||||||
|
weightMm: GROUND_MM,
|
||||||
|
color: GROUND_INK,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isFinite(minX)) return { primitives, bounds: { minX: 0, minY: 0, maxX: 1, maxY: 1 } };
|
||||||
|
return { primitives, bounds: { minX, minY, maxX, maxY } };
|
||||||
|
}
|
||||||
@@ -1393,6 +1393,12 @@ export interface ViewRibbonTabProps {
|
|||||||
renderModeEnabled: boolean;
|
renderModeEnabled: boolean;
|
||||||
planColorMode: PlanColorMode;
|
planColorMode: PlanColorMode;
|
||||||
onPlanColorMode: (m: PlanColorMode) => void;
|
onPlanColorMode: (m: PlanColorMode) => void;
|
||||||
|
/** Ob die aktive Ebene eine Ansicht (kind "elevation") ist — nur dann ist der
|
||||||
|
* Schatten-Umschalter sichtbar/aktiv. */
|
||||||
|
shadowsAvailable: boolean;
|
||||||
|
/** Schlagschatten der aktiven Ansicht ein/aus (DrawingLevel.shadows). */
|
||||||
|
shadows: boolean;
|
||||||
|
onShadows: (v: boolean) => void;
|
||||||
combos: ComboMenuHandlers;
|
combos: ComboMenuHandlers;
|
||||||
/** Text-/Font-Formatierung auf DERSELBEN Leiste (Raumstempel-Ziel; `null` =
|
/** Text-/Font-Formatierung auf DERSELBEN Leiste (Raumstempel-Ziel; `null` =
|
||||||
* nur Defaults). Liegt bewusst im „Ansichten"-Tab (Nutzer-Wunsch). */
|
* nur Defaults). Liegt bewusst im „Ansichten"-Tab (Nutzer-Wunsch). */
|
||||||
@@ -1428,6 +1434,9 @@ export function ViewRibbonTab({
|
|||||||
renderModeEnabled,
|
renderModeEnabled,
|
||||||
planColorMode,
|
planColorMode,
|
||||||
onPlanColorMode,
|
onPlanColorMode,
|
||||||
|
shadowsAvailable,
|
||||||
|
shadows,
|
||||||
|
onShadows,
|
||||||
combos,
|
combos,
|
||||||
textTarget,
|
textTarget,
|
||||||
}: ViewRibbonTabProps) {
|
}: ViewRibbonTabProps) {
|
||||||
@@ -1662,6 +1671,25 @@ export function ViewRibbonTab({
|
|||||||
<Sep />
|
<Sep />
|
||||||
|
|
||||||
|
|
||||||
|
{/* Schatten ein/aus — NUR für eine aktive Ansicht (kind "elevation").
|
||||||
|
Schreibt DrawingLevel.shadows; der Ansichts-Generator (toElevation)
|
||||||
|
zeichnet dann den 45°-Schlagschatten auskragender Bauteile. */}
|
||||||
|
{shadowsAvailable && (
|
||||||
|
<>
|
||||||
|
<div className="tb-group" role="group" aria-label={t("elevation.shadows")}>
|
||||||
|
<button
|
||||||
|
className={`tb-toggle${shadows ? " active" : ""}`}
|
||||||
|
onClick={() => onShadows(!shadows)}
|
||||||
|
title={t("elevation.shadows.hint")}
|
||||||
|
aria-pressed={shadows}
|
||||||
|
>
|
||||||
|
{t("elevation.shadows")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Sep />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Text-/Font-Formatierung auf DERSELBEN Leiste (Nutzer-Wunsch: Text und
|
{/* Text-/Font-Formatierung auf DERSELBEN Leiste (Nutzer-Wunsch: Text und
|
||||||
Ansichten zusammen) — Stil/Schrift/Grösse + B/I/U · L/C/R + Zeilenhöhe,
|
Ansichten zusammen) — Stil/Schrift/Grösse + B/I/U · L/C/R + Zeilenhöhe,
|
||||||
formatiert das aktuelle Text-Ziel (Raumstempel) live. */}
|
formatiert das aktuelle Text-Ziel (Raumstempel) live. */}
|
||||||
|
|||||||
Reference in New Issue
Block a user