Schnitt: Cut-Polygone nutzen die echte Bauteil-Schraffur statt Albedo

Die geschnittenen Flächen erhielten bisher die rohe Albedo-Farbe der Engine plus
eine generische Diagonalschraffur. Jetzt löst attachCutStyles() jede Cut-Fläche
über den index-parallelen ComponentRef auf ihr Quell-Bauteil auf:
- toSection.ts: wallSegmentOwners()/slabSegmentOwners() bilden die exakte
  Segment-Zerlegung aus projectToModel3d nach (gleiche EPS-/Öffnungs-Logik) und
  liefern je emittiertem Segment das Quell-Wall/Ceiling — index-gleich zur
  WASM-Elementreihenfolge (per Test bewiesen: W1,W1,W1,W2).
- generatePlan.ts: resolveWallSectionStyle/resolveCeilingSectionStyle wählen die
  Komponente mit höchster joinPriority (wie die Grundriss-Poché) bzw. die erste
  Deckenschicht und liefern fill+HatchRender über resolveHatch. generateSectionPlan
  nutzt diese, im Mono-Modus weiss/SECTION_INK. Fallback bei fehlendem Bauteil =
  bisheriges Verhalten (Albedo + generische Schraffur).

Gates: tsc -b 0, build grün.
This commit is contained in:
2026-07-03 18:50:31 +02:00
parent 330c01bbbd
commit 094d8b4504
2 changed files with 192 additions and 8 deletions
+63 -5
View File
@@ -7,6 +7,7 @@
import type { import type {
Ceiling, Ceiling,
Component,
Drawing2D, Drawing2D,
HatchPattern, HatchPattern,
LayerCategory, LayerCategory,
@@ -254,6 +255,54 @@ function resolveHatch(project: Project, hatchId: string): HatchRender {
}; };
} }
/** Füllfarbe + Schraffur eines aufgelösten Bauteils, fertig für eine Cut-Poché. */
export interface SectionCutStyle {
fill: string;
hatch: HatchRender;
}
/**
* Löst das tragende Bauteil (höchste `joinPriority`) einer Wand auf — dieselbe
* Regel wie {@link backboneColor} für die grobe Grundriss-Poché — und liefert
* dessen Füllfarbe + echte Schraffur für die Schnitt-Poché. `null`, wenn Wandtyp
* oder Bauteil nicht auflösbar sind (z. B. verwaistes `wallTypeId`); der
* Aufrufer fällt dann auf die generische Schnitt-Schraffur zurück.
*/
export function resolveWallSectionStyle(project: Project, wall: Wall): SectionCutStyle | null {
try {
const wt = getWallType(project, wall);
let best: Component | null = null;
for (const layer of wt.layers) {
const comp = getComponent(project, layer.componentId);
if (!best || comp.joinPriority > best.joinPriority) best = comp;
}
if (!best) return null;
return { fill: best.color, hatch: resolveHatch(project, best.hatchId) };
} catch {
return null;
}
}
/**
* Löst das erste Bauteil des Deckentyps einer Decke auf — dieselbe Regel wie
* {@link addCeilingPoche} für die normale Deckenpoché — und liefert dessen
* Füllfarbe + echte Schraffur für die Schnitt-Poché. `null`, wenn Deckentyp
* oder Bauteil nicht auflösbar sind bzw. der Deckentyp keine Schicht trägt.
*/
export function resolveCeilingSectionStyle(
project: Project,
ceiling: Ceiling,
): SectionCutStyle | null {
try {
const wt = getCeilingType(project, ceiling);
if (wt.layers.length === 0) return null;
const comp = getComponent(project, wt.layers[0].componentId);
return { fill: comp.color, hatch: resolveHatch(project, comp.hatchId) };
} catch {
return null;
}
}
export interface Plan { export interface Plan {
primitives: Primitive[]; primitives: Primitive[];
bounds: { minX: number; minY: number; maxX: number; maxY: number }; bounds: { minX: number; minY: number; maxX: number; maxY: number };
@@ -465,22 +514,31 @@ export function generateSectionPlan(output: SectionOutput, mono = false): Plan {
if (y > maxY) maxY = y; if (y > maxY) maxY = y;
}; };
// Cut-Polygone: gefüllte Poché-Fläche (Bauteilfarbe) + Schnitt-Schraffur + // Cut-Polygone: gefüllte Poché-Fläche + Schnitt-Schraffur + kräftige
// kräftige Umrisslinie — wie eine geschnittene Wand im Grundriss. // Umrisslinie — wie eine geschnittene Wand im Grundriss. `toSection.ts`
// (computeSection) löst je Cut-Polygon bereits das Quell-Bauteil auf und
// hängt dessen echte Füllfarbe/Schraffur an (`cp.fill`/`cp.hatch`); fehlt
// diese Auflösung (Fallback), greift die generische Schnitt-Schraffur auf
// Basis der rohen Albedo-Farbe aus dem 3D-Modell.
for (const cp of output.cutPolygons) { for (const cp of output.cutPolygons) {
if (cp.pts.length < 3) continue; if (cp.pts.length < 3) continue;
const pts: Vec2[] = cp.pts.map(([u, v]) => { const pts: Vec2[] = cp.pts.map(([u, v]) => {
acc(u, v); acc(u, v);
return { x: u, y: v }; return { x: u, y: v };
}); });
// Mono: reines Weiss statt Bauteilfarbe (nur Umriss + Schraffur tragen). const fill = cp.fill ?? rgbToHex(cp.color);
const hatch = cp.hatch ?? SECTION_HATCH;
// Mono: reines Weiss statt Bauteilfarbe, Schraffurfarbe → Tinte (nur
// Umriss + Schraffur-Musterlinien tragen; wie der toMono()-Postpass für
// die normale Poché, hier direkt inline, da Cut-Polygone kein "solid"
// führen).
primitives.push({ primitives.push({
kind: "polygon", kind: "polygon",
pts, pts,
fill: mono ? "#ffffff" : rgbToHex(cp.color), fill: mono ? "#ffffff" : fill,
stroke: SECTION_INK, stroke: SECTION_INK,
strokeWidthMm: SECTION_CUT_OUTLINE_MM, strokeWidthMm: SECTION_CUT_OUTLINE_MM,
hatch: SECTION_HATCH, hatch: mono && hatch.pattern !== "none" ? { ...hatch, color: SECTION_INK } : hatch,
}); });
} }
+129 -3
View File
@@ -15,9 +15,15 @@
// Einheiten: METER. Koordinaten der Ausgabe: u = horizontal entlang der Ebene, // Einheiten: METER. Koordinaten der Ausgabe: u = horizontal entlang der Ebene,
// v = absolute Höhe (world.y). Siehe src-tauri/render3d/src/section.rs. // v = absolute Höhe (world.y). Siehe src-tauri/render3d/src/section.rs.
import type { DrawingLevel, Project } from "../model/types"; import type { Ceiling, DrawingLevel, Project, Wall } from "../model/types";
import { openingsOfWall } from "../model/types";
import { ceilingVerticalExtent, wallVerticalExtent } from "../model/wall";
import { openingInterval, openingVerticalExtent } from "../geometry/opening";
import { projectToModel3d } from "./toWalls3d"; import { projectToModel3d } from "./toWalls3d";
import { loadEngine3d } from "../engine/engine3d"; import { loadEngine3d } from "../engine/engine3d";
import type { SectionCutStyle } from "./generatePlan";
import { resolveCeilingSectionStyle, resolveWallSectionStyle } from "./generatePlan";
import type { HatchRender } from "./generatePlan";
/** Bauteil-Referenz eines Cut-Polygons/einer Kante (Art + Eingabe-Index). */ /** Bauteil-Referenz eines Cut-Polygons/einer Kante (Art + Eingabe-Index). */
export interface SectionComponentRef { export interface SectionComponentRef {
@@ -28,10 +34,19 @@ export interface SectionComponentRef {
/** Ein geschnittenes Bauteil-Rechteck in (u, v)-Metern (Ring, implizit geschlossen). */ /** Ein geschnittenes Bauteil-Rechteck in (u, v)-Metern (Ring, implizit geschlossen). */
export interface SectionCutPolygon { export interface SectionCutPolygon {
component: SectionComponentRef; component: SectionComponentRef;
/** Albedo-Farbe des Bauteils (RGB 0..1). */ /** Albedo-Farbe des Bauteils (RGB 0..1) — Rohwert aus dem 3D-Modell (Fallback). */
color: [number, number, number]; color: [number, number, number];
/** Ring-Ecken (u, v). */ /** Ring-Ecken (u, v). */
pts: Array<[number, number]>; pts: Array<[number, number]>;
/**
* Echte Poché-Füllfarbe des geschnittenen Bauteils (tragende Wandschicht
* bzw. erste Deckenschicht), von `computeSection` aufgelöst. Fehlt, wenn das
* Quell-Bauteil nicht auflösbar war — der Aufrufer (generateSectionPlan)
* fällt dann auf `color` zurück.
*/
fill?: string;
/** Echte Schnitt-Schraffur des geschnittenen Bauteils, analog zu `fill`. */
hatch?: HatchRender;
} }
/** Ein projiziertes Liniensegment in (u, v)-Metern. */ /** Ein projiziertes Liniensegment in (u, v)-Metern. */
@@ -102,6 +117,115 @@ export function sectionPlaneFromLevel(level: DrawingLevel): SectionPlaneSpec | n
}; };
} }
// ── Cut-Polygon → Quell-Bauteil (für die echte Hatch-Auflösung) ────────────
// `cut_section_json` (section.rs) nummeriert jedes Cut-Polygon per
// `ComponentRef { kind, index }`, wobei `index` exakt die Position im
// `walls`/`slabs`-Array ist, das ihm als Modell-JSON übergeben wurde
// (`walls.iter().enumerate()` in section.rs) — also dieselbe Reihenfolge wie
// `projectToModel3d(project).walls`/`.slabs` (toWalls3d.ts).
//
// `projectToWalls3d` zerlegt EINE Wand mit Öffnungen in mehrere Teilquader
// (Pfeiler/Brüstung/Sturz) — die Emission ist also nicht 1:1 Wand→Element.
// Um einen `index` wieder auf seine Quellwand zurückzuführen, wird hier exakt
// dieselbe Segmentierungs-/Skip-Logik wie `emitWall`/`pushSegment`
// (toWalls3d.ts) repliziert — NICHT die Geometrie, nur welches Quellelement
// pro emittiertem Segment "besitzt". Bleibt diese Zählung nicht exakt parallel
// zu `emitWall`, verschieben sich die Indizes und die Hatch-Auflösung wird
// falsch (harmlos: der Aufrufer fällt dann pro Cut-Polygon auf die generische
// Schnitt-Schraffur zurück, siehe `resolveWallSectionStyle`/`generatePlan.ts`).
const OWNER_EPS = 1e-4;
/** Liefert je emittiertem Wand-Teilquader (in Emissions-Reihenfolge) die Quellwand. */
export function wallSegmentOwners(project: Project): Wall[] {
const owners: Wall[] = [];
const pushOwner = (wall: Wall, from: number, to: number, zBottom: number, zTop: number) => {
if (to - from <= OWNER_EPS) return;
if (zTop - zBottom <= OWNER_EPS) return;
owners.push(wall);
};
for (const wall of project.walls) {
const { zBottom, zTop } = wallVerticalExtent(project, wall);
const axisLen = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
if (axisLen < 1e-9 || zTop - zBottom <= OWNER_EPS) continue;
const cutouts: Array<{ from: number; to: number; oBottom: number; oTop: number }> = [];
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 });
}
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 < OWNER_EPS) continue;
const oTop = Math.min(zTop, zBottom + d.height);
cutouts.push({ from, to, oBottom: zBottom, oTop });
}
cutouts.sort((a, b) => a.from - b.from);
if (cutouts.length === 0) {
pushOwner(wall, 0, axisLen, zBottom, zTop);
continue;
}
let cursor = 0;
for (const { from, to, oBottom, oTop } of cutouts) {
const segFrom = Math.max(cursor, from);
if (from > cursor) pushOwner(wall, cursor, from, zBottom, zTop);
if (to > segFrom) {
if (oBottom > zBottom + OWNER_EPS) pushOwner(wall, segFrom, to, zBottom, oBottom);
if (oTop < zTop - OWNER_EPS) pushOwner(wall, segFrom, to, oTop, zTop);
}
cursor = Math.max(cursor, to);
}
if (cursor < axisLen) pushOwner(wall, cursor, axisLen, zBottom, zTop);
}
return owners;
}
/** Liefert je emittierter Deckenplatte (in Emissions-Reihenfolge) die Quelldecke (1:1, keine Zerlegung). */
export function slabSegmentOwners(project: Project): Ceiling[] {
const owners: Ceiling[] = [];
for (const c of project.ceilings ?? []) {
if (!c.outline || c.outline.length < 3) continue;
const { zBottom, zTop } = ceilingVerticalExtent(project, c);
if (zTop - zBottom <= OWNER_EPS) continue;
owners.push(c);
}
return owners;
}
/**
* Hängt an jedes Cut-Polygon von `output` (in-place) die echte Poché-Füllfarbe
* + Schraffur des geschnittenen Bauteils an (`fill`/`hatch`), aufgelöst über
* die Wand-/Decken-Besitzer-Listen. Nicht auflösbare Referenzen (Index
* außerhalb der Besitzer-Liste, verwaistes `wallTypeId`/Bauteil) bleiben ohne
* `fill`/`hatch` — `generateSectionPlan` fällt dafür auf die generische
* Schnitt-Schraffur zurück.
*/
function attachCutStyles(output: SectionOutput, project: Project): void {
if (output.cutPolygons.length === 0) return;
const walls = wallSegmentOwners(project);
const slabs = slabSegmentOwners(project);
for (const cp of output.cutPolygons) {
const ref = cp.component;
let style: SectionCutStyle | null = null;
if (ref.kind === "wall") {
const owner = walls[ref.index];
if (owner) style = resolveWallSectionStyle(project, owner);
} else {
const owner = slabs[ref.index];
if (owner) style = resolveCeilingSectionStyle(project, owner);
}
if (style) {
cp.fill = style.fill;
cp.hatch = style.hatch;
}
}
}
/** /**
* Berechnet den Schnitt/die Ansicht der Ebene `level` gegen das Modell `project` * Berechnet den Schnitt/die Ansicht der Ebene `level` gegen das Modell `project`
* über den WASM-Extraktor. Asynchron (das render3d-WASM-Modul wird lazy geladen). * über den WASM-Extraktor. Asynchron (das render3d-WASM-Modul wird lazy geladen).
@@ -133,5 +257,7 @@ export async function computeSection(
plane.normal[1], plane.normal[1],
plane.normal[2], plane.normal[2],
); );
return JSON.parse(json) as SectionOutput; const output = JSON.parse(json) as SectionOutput;
attachCutStyles(output, project);
return output;
} }