Merge: Schnittfunktion ausgebaut (Schnittlinie, Editieren im Schnitt, Tiefe)

- Befehl 'sectionline'/'viewline' (Aliase schnittlinie/ansichtslinie): zwei
  Klicks setzen die Schnitt-/Ansichtslinie, neue Ebene bei Bedarf
- Schnittführungs-Symbol im Grundriss (Strichpunkt, Endmarken, Richtungs-
  pfeile, Label) + Pick-Band, Auswahl + Attribut-Sektion (Name, Richtung
  umkehren, Endpunkte, Tiefe, Löschen)
- Editieren IM Schnitt: Cut-Polygone tragen die Quell-Element-Id (durch
  Schicht-Zerlegung/Terminierung/Dominanz propagiert) → Klick wählt das
  Bauteil, Panels editieren, Schnitt rechnet neu
- Schnitt-Tiefe (DrawingLevel.depth): Ansichtskanten ferner Bauteile werden
  ausgeblendet (Owner-Distanz-Näherung, dokumentiert)

# Conflicts:
#	src/model/types.ts
This commit is contained in:
2026-07-11 00:35:20 +02:00
19 changed files with 1135 additions and 2 deletions
+75
View File
@@ -197,6 +197,12 @@ export interface PlanSelection {
* Element höherer Priorität (alle anderen gehen vor); sonst `null`.
*/
roofId: string | null;
/**
* ID der getroffenen Schnitt-/Ansichtslinie (DrawingLevel), wenn der Klick ihr
* dünnes Pick-Band traf; sonst `null`. Höchste Priorität (das schmale Band liegt
* über allem — ein Klick genau darauf meint die Linie).
*/
sectionLineId: 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
@@ -287,6 +293,11 @@ export interface PlanViewProps {
selectedRoomIds?: string[];
/** Aktuell ausgewählte Dach-IDs (von App); die Ansicht markiert die Traufe. */
selectedRoofIds?: string[];
/**
* Aktuell ausgewählte Schnitt-/Ansichtslinien-IDs (DrawingLevel; von App). Die
* Ansicht markiert das Pick-Band JEDER dieser Linien mit einer Akzentkontur.
*/
selectedSectionLineIds?: string[];
/**
* Aktuell ausgewählte IDs extrudierter Körper (truck-Integration; kontrolliert
* von App). Die Ansicht markiert den Footprint-Umriss JEDES dieser Körper mit
@@ -416,6 +427,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
selectedStairIds,
selectedRoomIds,
selectedRoofIds,
selectedSectionLineIds,
selectedExtrudedSolidIds,
selectedColumnIds,
roomStamp,
@@ -1007,6 +1019,20 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return null;
};
// Getroffene Schnitt-/Ansichtslinie: ihr schmales, unsichtbares Pick-Band
// (Polygon mit sectionLineId) per Punkt-in-Polygon.
const pickSectionLine = (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.sectionLineId && pointInPolygon(m, p.pts)) {
return p.sectionLineId;
}
}
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).
@@ -1518,6 +1544,28 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
const hit = hitIndex !== null ? plan.primitives[hitIndex] : undefined;
// Öffnung (Fenster/Tür) zuerst: ihr Symbol sitzt in der ausgesparten Lücke
// bzw. schlägt über die Wand — ein Klick nahe am Symbol wählt die Öffnung.
// Schnitt-/Ansichtslinie zuerst: ihr schmales Band liegt über allem, ein
// Klick genau darauf meint die Linie (nicht die darunterliegende Wand) —
// dann sofort melden (kein anderes Element konkurriert).
const sectionLineId = pickSectionLine(e.clientX, e.clientY);
if (sectionLineId) {
onSelect({
model: viewToModel(v.x, v.y),
hitIndex,
wallId: null,
drawingId: null,
ceilingId: null,
openingId: null,
stairId: null,
roomId: null,
extrudedSolidId: null,
columnId: null,
roofId: null,
sectionLineId,
shift: e.shiftKey,
});
return;
}
const openingId = pickOpening(e.clientX, e.clientY);
const wallId =
openingId ? null : hit && hit.kind === "polygon" ? hit.wallId ?? null : null;
@@ -1581,6 +1629,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
extrudedSolidId,
columnId,
roofId,
sectionLineId: null,
shift: e.shiftKey,
});
}
@@ -1780,6 +1829,20 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
);
}, [plan, selectedRoofIds]);
// Hervorhebung der selektierten Schnitt-/Ansichtslinie: das (unsichtbare)
// Pick-Band (Polygon mit sectionLineId) mit Akzentkontur überzeichnen.
const highlightSectionLinePolys = useMemo(() => {
const ids =
selectedSectionLineIds && selectedSectionLineIds.length
? new Set(selectedSectionLineIds)
: null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.sectionLineId != null && ids.has(p.sectionLineId),
);
}, [plan, selectedSectionLineIds]);
// Hervorhebung der selektierten extrudierten Körper (truck-Integration): der
// Footprint-Umriss (Polygon mit extrudedSolidId) mit Akzentkontur überzeichnen
// — analog zur Raum-Auswahl.
@@ -2024,6 +2087,18 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten Schnitt-/Ansichtslinie (Pick-Band). */}
{highlightSectionLinePolys.map((poly, i) => (
<polygon
key={`selsl-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten extrudierten Körper (truck-Integration). */}
{highlightExtrudedSolidPolys.map((poly, i) => (
<polygon
+98
View File
@@ -0,0 +1,98 @@
/**
* Schnittführungs-Symbol im Grundriss (generatePlan → addSectionLines): jede
* Schnitt-/Ansichtsebene MIT `linePoints` erzeugt eine Strichpunkt-Linie
* (cls "section-line"), Endmarken + ein unsichtbares Pick-Band (Polygon mit
* `sectionLineId`). Ebenen OHNE Linie erzeugen nichts.
*/
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { DrawingLevel, Project } from "../model/types";
const visible = new Set(["20", "35"]);
function baseProject(levels: DrawingLevel[]): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: levels,
layers: [{ code: "20", name: "Wände", color: "#333", lw: 0.18, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
} as unknown as Project;
}
const floor: DrawingLevel = {
id: "eg",
name: "EG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 0,
};
describe("generatePlan — Schnittführungs-Symbol", () => {
it("zeichnet Linie + Pick-Band für eine Schnittebene mit linePoints", () => {
const p = baseProject([
floor,
{
id: "s-a",
name: "Schnitt A",
kind: "section",
visible: true,
locked: false,
linePoints: [{ x: 0, y: 2 }, { x: 6, y: 2 }],
directionSign: 1,
},
]);
const prims = generatePlan(p, "eg", visible).primitives;
// Strichpunkt-Hauptlinie vorhanden.
const lines = prims.filter((pr) => pr.kind === "line" && pr.cls === "section-line");
expect(lines.length).toBeGreaterThan(0);
// Pick-Band trägt die Level-Id.
const band = prims.find((pr) => pr.kind === "polygon" && pr.sectionLineId === "s-a");
expect(band).toBeTruthy();
// Label-Text (Kurz-Tag „A") an den Enden.
const labels = prims.filter((pr) => pr.kind === "drawingText" && pr.text === "A");
expect(labels.length).toBe(2);
});
it("zeichnet NICHTS für eine Schnittebene ohne linePoints", () => {
const p = baseProject([
floor,
{ id: "s-b", name: "Schnitt B", kind: "section", visible: true, locked: false },
]);
const prims = generatePlan(p, "eg", visible).primitives;
expect(prims.some((pr) => pr.kind === "polygon" && pr.sectionLineId != null)).toBe(false);
expect(prims.some((pr) => pr.kind === "line" && pr.cls === "section-line")).toBe(false);
});
it("behandelt Ansichtsebenen (elevation) gleich", () => {
const p = baseProject([
floor,
{
id: "e-a",
name: "Ansicht Süd",
kind: "elevation",
visible: true,
locked: false,
linePoints: [{ x: 0, y: 0 }, { x: 4, y: 0 }],
directionSign: -1,
},
]);
const prims = generatePlan(p, "eg", visible).primitives;
expect(prims.some((pr) => pr.kind === "polygon" && pr.sectionLineId === "e-a")).toBe(true);
});
});
+152
View File
@@ -265,6 +265,12 @@ export type Primitive =
columnId?: string;
/** ID des Dachs (Roof), falls dieses (unsichtbare) Polygon der Traufe-Umriss ist (Auswahl). */
roofId?: string;
/**
* ID der Schnitt-/Ansichtslinie (DrawingLevel), falls dieses (unsichtbare)
* Pick-Band das Schnittführungs-Symbol umhüllt (für die Links-Klick-Auswahl
* der Schnittlinie im Grundriss).
*/
sectionLineId?: string;
}
| {
kind: "line";
@@ -892,6 +898,10 @@ export function generatePlan(
// die Reihenfolge unkritisch; wir hängen sie als Referenz an.
addContextContours(primitives, project);
// Schnittführungs-Symbole (klassische Schnittlinien) aller Schnitt-/Ansichts-
// ebenen mit gesetzter Linie — projektglobale Referenzgeometrie über dem Plan.
addSectionLines(primitives, project);
// Schwarz-Weiss (mono): nachträglich alle Farben auf reine Tinte zwingen.
// Element-/Kategorie-/Hatch-Farben werden ignoriert, Füllungen werden weiss,
// Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan.
@@ -985,6 +995,12 @@ export function generateSectionPlan(output: SectionOutput, mono = false): Plan {
const monoFill = solid ? HATCH_INK : HATCH_PAPER;
const monoHatch =
solid ? hatch : hatch.pattern !== "none" ? { ...hatch, color: SECTION_INK } : hatch;
// Auswahl im Schnitt: das QUELL-Element (via cp.sourceId + component.kind) als
// wallId/ceilingId/roofId ans Polygon hängen — so wählt ein Klick auf das
// geschnittene Bauteil im Schnitt exakt jene Wand/Decke/Dach im Modell, und
// die bestehende PlanView-Pick-/Highlight-Logik greift ohne Sonderfall.
const kind = cp.component.kind;
const sourceId = cp.sourceId;
primitives.push({
kind: "polygon",
pts,
@@ -992,6 +1008,9 @@ export function generateSectionPlan(output: SectionOutput, mono = false): Plan {
stroke: SECTION_INK,
strokeWidthMm: SECTION_CUT_OUTLINE_MM,
hatch: mono ? monoHatch : hatch,
wallId: kind === "wall" ? sourceId : undefined,
ceilingId: kind === "slab" ? sourceId : undefined,
roofId: kind === "roof" ? sourceId : undefined,
});
}
@@ -2641,6 +2660,139 @@ function addRoof(
}
}
// ── Schnittführungs-Symbol (klassische Schnittlinie im Grundriss) ───────────
/** Tinte der Schnittführungs-Linie/Marken (kräftiges Grau). */
const SECTION_LINE_INK = "#1f242c";
/** Haarlinie der Strichpunkt-Schnittlinie (mm Papier). */
const SECTION_LINE_MM = 0.18;
/** Kräftige Endmarken (Querstrich + Pfeil) (mm Papier). */
const SECTION_LINE_MARK_MM = 0.5;
/** Strichpunkt-Muster der Schnittlinie (lange Striche + Punkt, mm Papier). */
const SECTION_LINE_DASH: number[] = [6, 1.5, 0.4, 1.5];
/** Länge der Endmarken (Querstrich/Pfeil) in Modell-Metern. */
const SECTION_MARK_LEN = 0.6;
/** Halbe Breite des unsichtbaren Pick-Bands über der Linie (Modell-Meter). */
const SECTION_PICK_HALF = 0.3;
/** Schrifthöhe des Schnitt-Labels (Modell-Meter). */
const SECTION_LABEL_H = 0.5;
/**
* Kurz-Tag eines Schnitt-Labels: der letzte „Wort"-Teil des Ebenennamens (z. B.
* „Schnitt A" → „A"); enthält der Name kein Leerzeichen, der ganze Name. Klassisch
* wird der Schnitt an beiden Enden mit diesem Tag beschriftet (A–A).
*/
function sectionLabelTag(name: string): string {
const parts = name.trim().split(/\s+/);
return parts.length > 1 ? parts[parts.length - 1] : name.trim();
}
/**
* Zeichnet für jede Schnitt-/Ansichtsebene MIT gesetzter `linePoints` das
* klassische Schnittführungs-Symbol in den Grundriss: eine Strichpunkt-Haarlinie
* über die volle Länge, an beiden Enden einen kräftigen Querstrich + Richtungspfeil
* auf der Blickseite (`directionSign`) und das Schnitt-Label (Kurz-Tag) daneben.
* Zusätzlich ein unsichtbares Pick-Band (Polygon mit `sectionLineId`), damit die
* Linie im Grundriss anklickbar ist (analog dem Dach-Traufe-Pick-Polygon).
*/
function addSectionLines(out: Primitive[], project: Project): void {
for (const level of project.drawingLevels) {
if (level.kind !== "section" && level.kind !== "elevation") continue;
const line = level.linePoints;
if (!line) continue;
const [p0, p1] = line;
const axis = sub(p1, p0);
const len = Math.hypot(axis.x, axis.y);
if (len < 1e-6) continue;
const u = normalize(axis);
const n = leftNormal(u); // (-uy, ux)
const sign = level.directionSign ?? 1;
const bn = { x: n.x * sign, y: n.y * sign }; // Blicknormale
// Hauptlinie (Strichpunkt-Haarlinie).
out.push({
kind: "line",
a: p0,
b: p1,
cls: "section-line",
weightMm: SECTION_LINE_MM,
dash: SECTION_LINE_DASH,
color: SECTION_LINE_INK,
});
// Endmarken (Querstrich + Richtungspfeil + Label) an beiden Enden.
const half = SECTION_MARK_LEN / 2;
for (const end of [p0, p1]) {
// Querstrich senkrecht zur Linie, mittig am Endpunkt.
const t0 = { x: end.x - n.x * half, y: end.y - n.y * half };
const t1 = { x: end.x + n.x * half, y: end.y + n.y * half };
out.push({
kind: "line",
a: t0,
b: t1,
cls: "section-line",
weightMm: SECTION_LINE_MARK_MM,
color: SECTION_LINE_INK,
});
// Richtungspfeil: vom Querstrich-Ende auf der Blickseite entlang bn.
const base = { x: end.x + bn.x * half, y: end.y + bn.y * half };
const tip = { x: base.x + bn.x * SECTION_MARK_LEN, y: base.y + bn.y * SECTION_MARK_LEN };
const wingBack = {
x: tip.x - bn.x * SECTION_MARK_LEN * 0.4,
y: tip.y - bn.y * SECTION_MARK_LEN * 0.4,
};
const w = SECTION_MARK_LEN * 0.25;
out.push({ kind: "line", a: base, b: tip, cls: "section-line", weightMm: SECTION_LINE_MARK_MM, color: SECTION_LINE_INK });
out.push({
kind: "line",
a: tip,
b: { x: wingBack.x + u.x * w, y: wingBack.y + u.y * w },
cls: "section-line",
weightMm: SECTION_LINE_MARK_MM,
color: SECTION_LINE_INK,
});
out.push({
kind: "line",
a: tip,
b: { x: wingBack.x - u.x * w, y: wingBack.y - u.y * w },
cls: "section-line",
weightMm: SECTION_LINE_MARK_MM,
color: SECTION_LINE_INK,
});
// Label (Kurz-Tag) etwas hinter dem Pfeil, horizontal.
const labelAt = {
x: tip.x + bn.x * SECTION_MARK_LEN * 0.4 - SECTION_LABEL_H * 0.3,
y: tip.y + bn.y * SECTION_MARK_LEN * 0.4 + SECTION_LABEL_H * 0.5,
};
out.push({
kind: "drawingText",
at: labelAt,
text: sectionLabelTag(level.name),
heightM: SECTION_LABEL_H,
angle: 0,
color: SECTION_LINE_INK,
});
}
// Unsichtbares Pick-Band (Rechteck entlang der Linie) — klickbar per
// Punkt-in-Polygon in PlanView, trägt die DrawingLevel-Id.
const off = { x: n.x * SECTION_PICK_HALF, y: n.y * SECTION_PICK_HALF };
out.push({
kind: "polygon",
pts: [
{ x: p0.x + off.x, y: p0.y + off.y },
{ x: p1.x + off.x, y: p1.y + off.y },
{ x: p1.x - off.x, y: p1.y - off.y },
{ x: p0.x - off.x, y: p0.y - off.y },
],
fill: "none",
stroke: "none",
strokeWidthMm: 0,
hatch: NO_HATCH,
sectionLineId: level.id,
});
}
}
/**
* Deckkraft (0..255) der Raum-Füllung → 2-stelliges Hex-Suffix. Transluzente
* Farbfüllung entfällt — Plandarstellung schwarz/grau; Farbe bleibt späteren
@@ -0,0 +1,58 @@
// Auswahl im Schnitt-View: generateSectionPlan hängt jedem Cut-Polygon die
// Quell-Element-Id (cp.sourceId) je nach component.kind als wallId/ceilingId/
// roofId ans Plan-Polygon — damit ein Klick im Schnitt das Bauteil im Modell
// wählt (die bestehende PlanView-Pick-Logik greift dann ohne Sonderfall).
import { describe, it, expect } from "vitest";
import { generateSectionPlan } from "./generatePlan";
import type { SectionCutPolygon, SectionOutput } from "./toSection";
function cut(kind: "wall" | "slab" | "roof", sourceId: string): SectionCutPolygon {
return {
component: { kind, index: 0 },
color: [0.5, 0.5, 0.5],
pts: [
[0, 0],
[1, 0],
[1, 2],
[0, 2],
],
sourceId,
};
}
const output: SectionOutput = {
cutPolygons: [cut("wall", "W1"), cut("slab", "C1"), cut("roof", "R1")],
visibleEdges: [],
hiddenEdges: [],
};
describe("generateSectionPlan — Auswahl-Ids am Cut-Polygon", () => {
it("schreibt sourceId je component.kind als wallId/ceilingId/roofId", () => {
const polys = generateSectionPlan(output).primitives.filter(
(p): p is Extract<typeof p, { kind: "polygon" }> => p.kind === "polygon",
);
const wall = polys.find((p) => p.wallId === "W1");
const ceiling = polys.find((p) => p.ceilingId === "C1");
const roof = polys.find((p) => p.roofId === "R1");
expect(wall).toBeTruthy();
expect(ceiling).toBeTruthy();
expect(roof).toBeTruthy();
// Kanäle schließen sich je Polygon aus (eine Wand trägt keine ceilingId).
expect(wall?.ceilingId).toBeUndefined();
expect(wall?.roofId).toBeUndefined();
expect(ceiling?.wallId).toBeUndefined();
});
it("lässt die Ids weg, wenn cp.sourceId fehlt (unauflösbares Bauteil)", () => {
const noId: SectionOutput = {
cutPolygons: [{ ...cut("wall", "W1"), sourceId: undefined }],
visibleEdges: [],
hiddenEdges: [],
};
const poly = generateSectionPlan(noId).primitives.find(
(p): p is Extract<typeof p, { kind: "polygon" }> => p.kind === "polygon",
);
expect(poly?.wallId).toBeUndefined();
});
});
+72
View File
@@ -0,0 +1,72 @@
// Schnitt-Tiefe (DrawingLevel.depth): filterByDepth blendet Ansichtskanten aus,
// deren Quell-Wand weiter als `depth` hinter der Schnittebene (Blickrichtung)
// liegt. Owner-Distanz-Näherung (Wand-Mitte) — reiner TS-Datenpfad, ohne WASM.
import { describe, it, expect } from "vitest";
import { filterByDepth } from "./toSection";
import type { SectionOutput, SectionPlaneSpec } from "./toSection";
import type { Project, Wall } from "../model/types";
// Zwei Wände auf verschiedenen Tiefen: W_near bei y=1, W_far bei y=8.
function makeWall(id: string, y: number): Wall {
return {
id,
type: "wall",
floorId: "eg",
categoryCode: "20",
wallTypeId: "wt",
start: { x: 0, y },
end: { x: 4, y },
height: 2.6,
} as unknown as Wall;
}
const project = {
id: "p",
name: "T",
walls: [makeWall("W_near", 1), makeWall("W_far", 8)],
ceilings: [],
roofs: [],
openings: [],
doors: [],
components: [],
wallTypes: [],
drawingLevels: [{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false }],
} as unknown as Project;
// Schnittebene bei y=0, Blickrichtung +y (in die Szene). wallSegmentOwners
// nummeriert die Wände in project.walls-Reihenfolge: index 0 = W_near, 1 = W_far.
const plane: SectionPlaneSpec = { point: [0, 0, 0], normal: [0, 0, 1] };
function edge(index: number) {
return { component: { kind: "wall" as const, index }, a: [0, 0] as [number, number], b: [1, 1] as [number, number] };
}
describe("filterByDepth", () => {
it("behält nahe Kanten, verwirft Kanten hinter der Tiefengrenze", () => {
const out: SectionOutput = {
cutPolygons: [],
visibleEdges: [edge(0), edge(1)],
hiddenEdges: [edge(1)],
};
filterByDepth(out, project, plane, 4); // Tiefe 4 m: W_near (1) bleibt, W_far (8) fällt weg.
expect(out.visibleEdges.map((e) => e.component.index)).toEqual([0]);
expect(out.hiddenEdges).toHaveLength(0);
});
it("behält alles, wenn die Tiefe alle Bauteile umfasst", () => {
const out: SectionOutput = {
cutPolygons: [],
visibleEdges: [edge(0), edge(1)],
hiddenEdges: [],
};
filterByDepth(out, project, plane, 20);
expect(out.visibleEdges).toHaveLength(2);
});
it("depth <= 0 ist ein No-op", () => {
const out: SectionOutput = { cutPolygons: [], visibleEdges: [edge(0), edge(1)], hiddenEdges: [] };
filterByDepth(out, project, plane, 0);
expect(out.visibleEdges).toHaveLength(2);
});
});
+7
View File
@@ -112,6 +112,13 @@ describe("splitWallLayers: Aussenputz auf der Aussenseite, Wände spiegelverkehr
expect(bands[3].width).toBeCloseTo(RENDER_EXT_T, 6);
});
it("propagiert sourceId (Quell-Element) auf ALLE Schicht-Bänder", () => {
const cpWithId: SectionCutPolygon = { ...cp, sourceId: "W2" };
const bands = splitWallLayers(cpWithId, sampleProject, aw, wall("W2"), uWorld);
expect(bands.length).toBeGreaterThan(1);
expect(bands.every((b) => b.sourceId === "W2")).toBe(true);
});
it("W2 und W4 liegen spiegelverkehrt (nicht identisch)", () => {
const b2 = bandsByU(splitWallLayers(cp, sampleProject, aw, wall("W2"), uWorld));
const b4 = bandsByU(splitWallLayers(cp, sampleProject, aw, wall("W4"), uWorld));
+83
View File
@@ -76,6 +76,15 @@ export interface SectionCutPolygon {
* Wert (unauflösbares Bauteil), nimmt das Band an keiner Verschmelzung teil.
*/
componentId?: string;
/**
* ID des QUELL-Elements dieses Cut-Bands im Dokumentmodell (Wand/Decke/Dach) —
* aufgelöst in `attachCutStyles` über die Besitzer-Listen. Trägt die Auswahl im
* Schnitt-View: `generateSectionPlan` schreibt sie je nach `component.kind` als
* `wallId`/`ceilingId`/`roofId` ins Plan-Polygon, sodass ein Klick auf das
* geschnittene Bauteil im Schnitt exakt jenes Element im Grundriss-Modell wählt.
* Fehlt (unauflösbares Bauteil), bleibt das Band ohne Auswahlbezug.
*/
sourceId?: string;
}
/** Ein projiziertes Liniensegment in (u, v)-Metern. */
@@ -294,11 +303,13 @@ function attachCutStyles(output: SectionOutput, project: Project, plane: Section
// aufgelöst; keine Boolean-Dominanz (joinPriority undefined) — unverändert
// übernehmen. WICHTIG vor dem Slab-Zweig (sonst falscher owner-Lookup).
if (ref.kind === "roof") {
cp.sourceId = (project.roofs ?? [])[ref.index]?.id;
bands.push(cp);
continue;
}
if (ref.kind === "wall") {
const owner = walls[ref.index];
cp.sourceId = owner?.id;
// Wand: bei mehrschichtigem Wandtyp wird das EINE geschnittene Rechteck in
// stehende Teil-Rechtecke je Schicht quer zur Dicke (u) aufgeteilt — das
// vertikale Gegenstück zur Decken-Zerlegung (dort quer zur Höhe v), siehe
@@ -327,6 +338,7 @@ function attachCutStyles(output: SectionOutput, project: Project, plane: Section
// Schnitt sichtbar (im Grundriss bleibt die Decke bewusst flächig, siehe
// `addCeilingPoche`).
const owner = slabs[ref.index];
cp.sourceId = owner?.id;
if (owner) {
const wt = getCeilingType(project, owner);
if (wt.layers.length > 1) {
@@ -727,6 +739,7 @@ function bandFromRect(src: SectionCutPolygon, r: Rect): SectionCutPolygon {
hatch: src.hatch,
joinPriority: src.joinPriority,
componentId: src.componentId,
sourceId: src.sourceId,
};
}
@@ -779,6 +792,7 @@ function splitSlabLayers(
hatch,
joinPriority: comp.joinPriority,
componentId: comp.id,
sourceId: cp.sourceId,
});
}
return out;
@@ -865,6 +879,7 @@ export function splitWallLayers(
hatch,
joinPriority: comp.joinPriority,
componentId: comp.id,
sourceId: cp.sourceId,
});
}
return out;
@@ -1033,6 +1048,72 @@ export function appendRoofSections(
});
}
/** Mittelpunkt (Modell-2D) einer Punktliste (Outline-Schwerpunkt, naiv). */
function centroid2d(pts: Vec2[]): Vec2 | null {
if (pts.length === 0) return null;
let x = 0;
let y = 0;
for (const p of pts) {
x += p.x;
y += p.y;
}
return { x: x / pts.length, y: y / pts.length };
}
/**
* Blendet Ansichtskanten (sichtbar/verdeckt) aus, deren QUELL-Bauteil weiter als
* `depth` Meter HINTER der Schnittebene (in Blickrichtung) liegt — die
* Schnitt-Tiefe (`DrawingLevel.depth`). Die Distanz wird über den REPRÄSENTATIVEN
* Bauteilpunkt (Wand-Mitte bzw. Outline-Schwerpunkt) gegen die Ebenennormale
* gemessen (in-place).
*
* NÄHERUNG (bewusst, kein Rust-Hack): der Rust-Extraktor liefert die Kanten nur in
* (u, v) OHNE echte Tiefe je Kante — die exakte Lösung bräuchte eine Tiefen-Achse
* aus `section.rs`. Bis dahin filtert dieser Owner-Distanz-Test grob nach dem
* Bauteil-Mittelpunkt: ein Bauteil wird GANZ ein- oder ausgeblendet (kein
* Teil-Clipping an der Tiefengrenze). Cut-Polygone liegen auf der Ebene (Distanz
* ~0) und bleiben immer erhalten. TODO: exakte per-Kante-Tiefe aus render3d
* (section.rs) zurückgeben, dann hier hart nach Kanten-Tiefe clippen.
*/
export function filterByDepth(
output: SectionOutput,
project: Project,
plane: SectionPlaneSpec,
depth: number,
): void {
if (!(depth > 0)) return;
// Blickrichtung + Ebenenaufpunkt in Modell-2D (x = world.x, y = world.z).
const blick = normalize({ x: plane.normal[0], y: plane.normal[2] });
const origin = { x: plane.point[0], y: plane.point[2] };
const walls = wallSegmentOwners(project);
const slabs = slabSegmentOwners(project);
const roofs = project.roofs ?? [];
const ownerPoint = (ref: SectionComponentRef): Vec2 | null => {
if (ref.kind === "wall") {
const w = walls[ref.index];
return w ? { x: (w.start.x + w.end.x) / 2, y: (w.start.y + w.end.y) / 2 } : null;
}
if (ref.kind === "slab") {
const c = slabs[ref.index];
return c ? centroid2d(c.outline) : null;
}
const r = roofs[ref.index];
return r ? centroid2d(r.outline) : null;
};
const keep = (e: SectionEdge): boolean => {
const p = ownerPoint(e.component);
if (!p) return true; // Unauflösbar: konservativ behalten.
// Distanz HINTER der Ebene entlang der Blickrichtung; ≤ depth ⇒ behalten.
const d = dot(sub(p, origin), blick);
return d <= depth + 1e-6;
};
output.visibleEdges = output.visibleEdges.filter(keep);
output.hiddenEdges = output.hiddenEdges.filter(keep);
}
export async function computeSection(
project: Project,
level: DrawingLevel,
@@ -1063,5 +1144,7 @@ export async function computeSection(
// Dächer TS-seitig ergänzen (der Rust-Extraktor kennt nur Wände + Decken).
appendRoofSections(output, project, plane);
attachCutStyles(output, project, plane);
// Schnitt-Tiefe: Kanten weiter als `level.depth` hinter der Ebene ausblenden.
if (level.depth !== undefined) filterByDepth(output, project, plane, level.depth);
return output;
}