Browser-BIM (cad): semantisches Modell, abgeleitete 2D/3D-Sichten, Zeichenwerkzeuge
Standalone-Browser-Port von DOSSIER. Enthaelt das semantische Modell mit Plan-/3D-Ableitung, Zeichen- und Editierwerkzeuge, Rhino-artiges Befehlssystem, dockbares Panel-System, Resource-Manager, DXF/.lin/.pat-Import, i18n (de/en) sowie Projektdokumentation und Probe-Harness.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
// Geometrie-Helfer für das Gebäudemodell. Reine 2D/3D-Mathematik,
|
||||
// kein externer Kernel nötig (Phase 0).
|
||||
|
||||
import type { Vec2 } from "./types";
|
||||
|
||||
export const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
|
||||
export const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y });
|
||||
export const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s });
|
||||
export const len = (a: Vec2): number => Math.hypot(a.x, a.y);
|
||||
|
||||
export const normalize = (a: Vec2): Vec2 => {
|
||||
const l = len(a) || 1;
|
||||
return { x: a.x / l, y: a.y / l };
|
||||
};
|
||||
|
||||
/** Linke Normale (90° gegen den Uhrzeigersinn gedreht). */
|
||||
export const leftNormal = (a: Vec2): Vec2 => ({ x: -a.y, y: a.x });
|
||||
|
||||
/** Kreuzprodukt (Z-Komponente) zweier 2D-Vektoren. */
|
||||
export const cross = (p: Vec2, q: Vec2): number => p.x * q.y - p.y * q.x;
|
||||
|
||||
/** Eine unendliche Gerade als Stützpunkt + Richtung. */
|
||||
export interface Line {
|
||||
point: Vec2;
|
||||
dir: Vec2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schnittpunkt der Geraden (a + t·da) mit (b + s·db).
|
||||
* Liefert null bei (nahezu) parallelen Richtungen.
|
||||
*/
|
||||
export function lineIntersect(
|
||||
a: Vec2,
|
||||
da: Vec2,
|
||||
b: Vec2,
|
||||
db: Vec2,
|
||||
): Vec2 | null {
|
||||
const denom = cross(da, db);
|
||||
if (Math.abs(denom) < 1e-9) return null; // parallel → kein Schnitt
|
||||
const t = cross(sub(b, a), db) / denom;
|
||||
return add(a, scale(da, t));
|
||||
}
|
||||
|
||||
/** Punkt entlang einer Strecke p1→p2 im Abstand d vom Start. */
|
||||
export const along = (p1: Vec2, p2: Vec2, d: number): Vec2 =>
|
||||
add(p1, scale(normalize(sub(p2, p1)), d));
|
||||
|
||||
/**
|
||||
* Die vier Eckpunkte eines Bandes, das quer zur Normalen zwischen den
|
||||
* Offsets offA und offB liegt (gemessen entlang der linken Normalen n).
|
||||
* Reihenfolge: start+n*offA, end+n*offA, end+n*offB, start+n*offB.
|
||||
*/
|
||||
export function wallBand(
|
||||
start: Vec2,
|
||||
end: Vec2,
|
||||
offA: number,
|
||||
offB: number,
|
||||
): [Vec2, Vec2, Vec2, Vec2] {
|
||||
const u = normalize(sub(end, start));
|
||||
const n = leftNormal(u);
|
||||
return [
|
||||
add(start, scale(n, offA)),
|
||||
add(end, scale(n, offA)),
|
||||
add(end, scale(n, offB)),
|
||||
add(start, scale(n, offB)),
|
||||
];
|
||||
}
|
||||
|
||||
/** Die vier Eckpunkte einer Wand als Polygon (gegen den Uhrzeigersinn). */
|
||||
export function wallCorners(
|
||||
start: Vec2,
|
||||
end: Vec2,
|
||||
thickness: number,
|
||||
): [Vec2, Vec2, Vec2, Vec2] {
|
||||
const h = thickness / 2;
|
||||
return wallBand(start, end, h, -h);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie {@link wallBand}, aber die Bandenden werden bei Bedarf an Gehrungslinien
|
||||
* (startCut / endCut) abgeschnitten statt rechtwinklig gekappt. Jede der beiden
|
||||
* Bandkanten (bei Offset offA bzw. offB, Richtung u) wird mit der jeweiligen
|
||||
* Schnittlinie verschnitten; fehlt eine Schnittlinie, bleibt das Ende
|
||||
* rechtwinklig (Punkt auf der Achssenkrechten).
|
||||
* Reihenfolge bleibt konsistent zu wallBand: [A.start, A.end, B.end, B.start].
|
||||
*/
|
||||
export function clippedBand(
|
||||
start: Vec2,
|
||||
end: Vec2,
|
||||
offA: number,
|
||||
offB: number,
|
||||
startCut: Line | null,
|
||||
endCut: Line | null,
|
||||
): [Vec2, Vec2, Vec2, Vec2] {
|
||||
const u = normalize(sub(end, start));
|
||||
const n = leftNormal(u);
|
||||
|
||||
const edgeStart = (off: number): Vec2 => {
|
||||
const base = add(start, scale(n, off));
|
||||
if (!startCut) return base;
|
||||
return lineIntersect(base, u, startCut.point, startCut.dir) ?? base;
|
||||
};
|
||||
const edgeEnd = (off: number): Vec2 => {
|
||||
const base = add(end, scale(n, off));
|
||||
if (!endCut) return base;
|
||||
return lineIntersect(base, u, endCut.point, endCut.dir) ?? base;
|
||||
};
|
||||
|
||||
return [edgeStart(offA), edgeEnd(offA), edgeEnd(offB), edgeStart(offB)];
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Wand-Verschneidung (Gehrung): an einer Ecke, wo zwei Wände aufeinander
|
||||
// treffen, sollen sich die Schicht-Bänder nicht überlappen, sondern an einer
|
||||
// gemeinsamen Gehrungslinie sauber stoßen. Dieses Modul berechnet pro Wand
|
||||
// die optionalen Schnittlinien an Start- und Endpunkt.
|
||||
|
||||
import type { Project, Vec2, Wall } from "./types";
|
||||
import { getWallType, wallTypeThickness } from "./types";
|
||||
import { wallReferenceOffset } from "./wall";
|
||||
import {
|
||||
add,
|
||||
leftNormal,
|
||||
len,
|
||||
lineIntersect,
|
||||
normalize,
|
||||
scale,
|
||||
sub,
|
||||
} from "./geometry";
|
||||
import type { Line } from "./geometry";
|
||||
|
||||
/** Schnittlinien einer Wand an ihren beiden Achsenden. */
|
||||
export interface WallCuts {
|
||||
startCut: Line | null;
|
||||
endCut: Line | null;
|
||||
}
|
||||
|
||||
/** Rundet eine Koordinate auf ein Gitter, um Endpunkte robust zu gruppieren. */
|
||||
const roundKey = (p: Vec2): string => {
|
||||
const r = (v: number) => Math.round(v * 1e4) / 1e4;
|
||||
return `${r(p.x)},${r(p.y)}`;
|
||||
};
|
||||
|
||||
interface WallEnd {
|
||||
wallId: string;
|
||||
end: "start" | "end";
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet für jede Wand die Gehrungs-Schnittlinien.
|
||||
* Nur L-Ecken (genau zwei Wandenden treffen sich) werden behandelt; freie
|
||||
* Enden und T-/X-Stöße bleiben rechtwinklig (siehe Kommentare unten).
|
||||
*
|
||||
* `walls` ist die bereits gefilterte Wandmenge (z. B. ein Geschoss); die
|
||||
* Gehrung wird nur innerhalb dieser Menge gebildet.
|
||||
*/
|
||||
export function computeJoins(
|
||||
project: Project,
|
||||
walls: Wall[],
|
||||
): Map<string, WallCuts> {
|
||||
const byId = new Map(walls.map((w) => [w.id, w]));
|
||||
|
||||
const result = new Map<string, WallCuts>();
|
||||
for (const w of walls) result.set(w.id, { startCut: null, endCut: null });
|
||||
|
||||
// Knotenkarte: gerundeter Endpunkt → Liste der dort endenden Wandenden.
|
||||
const junctions = new Map<string, WallEnd[]>();
|
||||
const push = (p: Vec2, we: WallEnd) => {
|
||||
const key = roundKey(p);
|
||||
const list = junctions.get(key);
|
||||
if (list) list.push(we);
|
||||
else junctions.set(key, [we]);
|
||||
};
|
||||
for (const w of walls) {
|
||||
push(w.start, { wallId: w.id, end: "start" });
|
||||
push(w.end, { wallId: w.id, end: "end" });
|
||||
}
|
||||
|
||||
for (const [, ends] of junctions) {
|
||||
// Freies Ende → kein Schnitt.
|
||||
if (ends.length === 1) continue;
|
||||
// T-/X-Stöße (>2 Enden): vorerst rechtwinklig lassen (Folge-Arbeit).
|
||||
if (ends.length !== 2) continue;
|
||||
|
||||
const a = byId.get(ends[0].wallId)!;
|
||||
const b = byId.get(ends[1].wallId)!;
|
||||
const cut = miterLine(project, a, ends[0].end, b);
|
||||
if (!cut) continue; // kollinear → kein Schnitt
|
||||
|
||||
setCut(result, ends[0], cut);
|
||||
setCut(result, ends[1], cut);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Trägt eine Schnittlinie am passenden Ende einer Wand ein. */
|
||||
function setCut(result: Map<string, WallCuts>, we: WallEnd, cut: Line): void {
|
||||
const cuts = result.get(we.wallId)!;
|
||||
if (we.end === "start") cuts.startCut = cut;
|
||||
else cuts.endCut = cut;
|
||||
}
|
||||
|
||||
/** Achsrichtung start→end, normalisiert. */
|
||||
const dirOf = (w: Wall): Vec2 => normalize(sub(w.end, w.start));
|
||||
|
||||
/**
|
||||
* Gemeinsame Gehrungslinie zweier Wände A, B, die sich im Knoten J treffen.
|
||||
* Robust gegen beliebige Wicklung und ungleiche Dicken: A's Außenfläche wird
|
||||
* mit der NÄCHSTGELEGENEN Fläche von B verschnitten, A's Innenfläche mit der
|
||||
* jeweils anderen. Die Gerade durch beide Eckpunkte ist die Gehrung.
|
||||
*/
|
||||
function miterLine(
|
||||
project: Project,
|
||||
a: Wall,
|
||||
aEnd: "start" | "end",
|
||||
b: Wall,
|
||||
): Line | null {
|
||||
const j = aEnd === "start" ? a.start : a.end;
|
||||
|
||||
const tA = wallTypeThickness(getWallType(project, a));
|
||||
const tB = wallTypeThickness(getWallType(project, b));
|
||||
const uA = dirOf(a);
|
||||
const uB = dirOf(b);
|
||||
const nA = leftNormal(uA);
|
||||
const nB = leftNormal(uB);
|
||||
|
||||
// Referenzlinien-Versatz: liegt die Achse nicht mittig, sind die beiden
|
||||
// Wandflächen um diesen Betrag entlang +n verschoben (außen −T/2+off, innen
|
||||
// +T/2+off). Für „center" (Default) ist off=0 → unverändert.
|
||||
const offA = wallReferenceOffset(a, tA);
|
||||
const offB = wallReferenceOffset(b, tB);
|
||||
|
||||
// Flächen-Stützpunkte am Knoten (linke/rechte Wandseite), inkl. Referenzversatz.
|
||||
const pLA = add(j, scale(nA, offA + tA / 2));
|
||||
const pRA = add(j, scale(nA, offA - tA / 2));
|
||||
const pLB = add(j, scale(nB, offB + tB / 2));
|
||||
const pRB = add(j, scale(nB, offB - tB / 2));
|
||||
|
||||
// Für A's linke Fläche die nähere B-Fläche wählen; A's rechte bekommt die andere.
|
||||
const lbCloser =
|
||||
len(sub(pLA, pLB)) <= len(sub(pLA, pRB));
|
||||
const bForLeft = lbCloser ? pLB : pRB;
|
||||
const bForRight = lbCloser ? pRB : pLB;
|
||||
|
||||
const c1 = lineIntersect(pLA, uA, bForLeft, uB);
|
||||
const c2 = lineIntersect(pRA, uA, bForRight, uB);
|
||||
if (!c1 || !c2) return null;
|
||||
|
||||
const dir = sub(c2, c1);
|
||||
if (len(dir) < 1e-9) return null;
|
||||
return { point: c1, dir };
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { Project } from "./types";
|
||||
import { recomputeFloorElevations } from "./types";
|
||||
|
||||
// Demo-Haus mit zwei Geschossen:
|
||||
// EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand.
|
||||
// OG — rechteckiger Raum (5 × 3 m) ohne Tür.
|
||||
// Maße in Metern. Die Außenwand ist ein realistischer SIA-naher
|
||||
// Schichtaufbau (außen → innen).
|
||||
//
|
||||
// Zwei UNABHÄNGIGE Achsen (Dokumentmodell nach DOSSIER):
|
||||
// • drawingLevels — Geschosse (EG, OG) + Platzhalter Schnitt/Ansicht.
|
||||
// • layers — Grafik-Kategorie-Baum (Codes/Namen/Farben/lw 1:1 aus DOSSIER,
|
||||
// DEFAULT_LAYER_SCHEMA in launcher/src/App.jsx). 21 Türen/Fenster ist hier
|
||||
// als Kind von 20 Wände gehängt.
|
||||
export const sampleProject: Project = {
|
||||
id: "demo",
|
||||
name: "Demo-Haus",
|
||||
// ── Ressourcen-Bibliotheken (zentral verwiesen per id) ──────────────────
|
||||
// Linienstile: Strichstärken aus DEFAULT_LAYER_SCHEMA (lw in mm). "hatch-line"
|
||||
// ist der dünne Strich für Schraffur-Muster.
|
||||
lineStyles: [
|
||||
{ id: "thin", name: "Dünn 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
|
||||
{ id: "medium", name: "Mittel 0.25", weight: 0.25, color: "#1a1a1a", dash: null },
|
||||
{ id: "thick", name: "Stark 0.5", weight: 0.5, color: "#0a0a0a", dash: null },
|
||||
// Schraffurlinie = schwarze Haarlinie (Grundeinstellung für alle Schraffuren).
|
||||
{ id: "hatch-line", name: "Schraffurlinie", weight: 0.13, color: "#1a1a1a", dash: null },
|
||||
],
|
||||
// Schraffuren: color = Linien-/Vollfüllfarbe des Musters; angle in Grad.
|
||||
// "none" = keine Schraffur (nur Component-Füllung).
|
||||
// Grundeinstellung aller Linien-Schraffuren: schwarze Haarlinie (color) auf
|
||||
// weißem Grund (die zugehörigen Bauteile tragen weiße Füllfarbe).
|
||||
hatches: [
|
||||
{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#1a1a1a" },
|
||||
{
|
||||
id: "insulation",
|
||||
name: "Wärmedämmung",
|
||||
pattern: "insulation",
|
||||
scale: 1,
|
||||
angle: 0,
|
||||
color: "#1a1a1a",
|
||||
lineStyleId: "hatch-line",
|
||||
},
|
||||
{
|
||||
id: "diagonal",
|
||||
name: "Diagonal",
|
||||
pattern: "diagonal",
|
||||
scale: 1,
|
||||
angle: 45,
|
||||
color: "#1a1a1a",
|
||||
lineStyleId: "hatch-line",
|
||||
},
|
||||
{
|
||||
id: "crosshatch",
|
||||
name: "Kreuzschraffur",
|
||||
pattern: "crosshatch",
|
||||
scale: 1,
|
||||
angle: 0,
|
||||
color: "#1a1a1a",
|
||||
lineStyleId: "hatch-line",
|
||||
},
|
||||
{
|
||||
id: "solid-concrete",
|
||||
name: "Beton (voll)",
|
||||
pattern: "solid",
|
||||
scale: 1,
|
||||
angle: 0,
|
||||
color: "#9aa0a6",
|
||||
lineStyleId: "thin",
|
||||
},
|
||||
],
|
||||
// Bauteil-Materialien: color = Poché-Füllung im Grundriss UND 3D-Diffusfarbe;
|
||||
// joinPriority höher = läuft am Stoß durch (für spätere T-Stöße).
|
||||
components: [
|
||||
{ id: "render-ext", name: "Aussenputz", color: "#d8d2c7", hatchId: "none", joinPriority: 10 },
|
||||
// Weißer Grund unter der schwarzen Dämmungs-Haarlinie (Schraffur-Grundeinstellung).
|
||||
{ id: "insulation", name: "Wärmedämmung", color: "#ffffff", hatchId: "insulation", joinPriority: 20 },
|
||||
{ id: "brick", name: "Backstein", color: "#8c5544", hatchId: "none", joinPriority: 50 },
|
||||
{ id: "render-int", name: "Innenputz", color: "#efece6", hatchId: "none", joinPriority: 10 },
|
||||
// Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität).
|
||||
{ id: "concrete", name: "Beton", color: "#9aa0a6", hatchId: "solid-concrete", joinPriority: 100 },
|
||||
],
|
||||
wallTypes: [
|
||||
{
|
||||
id: "aw",
|
||||
name: "Aussenwand 34.5 cm",
|
||||
// Schichten außen → innen; Priorität lebt jetzt am Component.
|
||||
layers: [
|
||||
{ componentId: "render-ext", thickness: 0.02 },
|
||||
{ componentId: "insulation", thickness: 0.16 },
|
||||
{ componentId: "brick", thickness: 0.15 },
|
||||
{ componentId: "render-int", thickness: 0.015 },
|
||||
],
|
||||
},
|
||||
],
|
||||
// Oberste Schnitte: Geschosse + Schnitt/Ansicht + freie Zeichnung.
|
||||
// baseElevation wird über recomputeFloorElevations gestapelt: EG=0, OG=2.6.
|
||||
drawingLevels: recomputeFloorElevations([
|
||||
{ id: "eg", name: "Erdgeschoss", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0 },
|
||||
{ id: "og", name: "Obergeschoss", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0 },
|
||||
{
|
||||
id: "schnitt-a",
|
||||
name: "Schnitt A",
|
||||
kind: "section",
|
||||
visible: true,
|
||||
locked: false,
|
||||
linePoints: [{ x: -1, y: 2 }, { x: 6, y: 2 }],
|
||||
directionSign: 1,
|
||||
},
|
||||
{ id: "ansicht-sued", name: "Ansicht Süd", kind: "elevation", visible: true, locked: false },
|
||||
{ id: "detail-1", name: "Detail 1", kind: "drawing", visible: true, locked: false },
|
||||
]),
|
||||
// Grafik-Kategorie-Baum (DOSSIER-Codes 1:1).
|
||||
layers: [
|
||||
{ code: "00", name: "Raster", color: "#484850", lw: 0.13, visible: true, locked: false },
|
||||
{ code: "01", name: "Vermessung", color: "#707078", lw: 0.18, visible: true, locked: false },
|
||||
{ code: "10", name: "Situation", color: "#909090", lw: 0.18, visible: true, locked: false },
|
||||
{ code: "11", name: "Strasse", color: "#a89070", lw: 0.18, visible: true, locked: false },
|
||||
{ code: "12", name: "Gebäude", color: "#888888", lw: 0.25, visible: true, locked: false },
|
||||
{ code: "13", name: "Bäume", color: "#50a050", lw: 0.13, visible: true, locked: false },
|
||||
{ code: "14", name: "Höhenlinien", color: "#909050", lw: 0.18, visible: true, locked: false },
|
||||
{
|
||||
code: "20",
|
||||
name: "Wände",
|
||||
color: "#0a0a0a",
|
||||
lw: 0.5,
|
||||
visible: true,
|
||||
locked: false,
|
||||
children: [
|
||||
{ code: "21", name: "Türen/Fenster", color: "#5080c8", lw: 0.25, visible: true, locked: false },
|
||||
{ code: "22", name: "Möbel", color: "#909090", lw: 0.13, visible: true, locked: false },
|
||||
{ code: "25", name: "Stützen", color: "#c87050", lw: 0.5, visible: true, locked: false },
|
||||
],
|
||||
},
|
||||
{ code: "30", name: "Decken", color: "#605850", lw: 0.35, visible: true, locked: false },
|
||||
{ code: "31", name: "Dächer", color: "#7a4a3a", lw: 0.35, visible: true, locked: false },
|
||||
{ code: "35", name: "Träger", color: "#a87858", lw: 0.5, visible: true, locked: false },
|
||||
{ code: "50", name: "Text", color: "#d0d0d0", lw: 0.13, visible: true, locked: false },
|
||||
{ code: "60", name: "Plangrafik", color: "#c0a040", lw: 0.13, visible: true, locked: false },
|
||||
{ code: "90", name: "Referenzen", color: "#585860", lw: 0.13, visible: true, locked: false },
|
||||
{ code: "99", name: "Konstruktion", color: "#404048", lw: 0.13, visible: true, locked: false },
|
||||
],
|
||||
walls: [
|
||||
// ── EG: 5 × 4 m Raum (CCW) ──
|
||||
// Süd (unten)
|
||||
{ id: "W1", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 0, y: 0 }, end: { x: 5, y: 0 }, wallTypeId: "aw", height: 2.6 },
|
||||
// Ost (rechts)
|
||||
{ id: "W2", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 5, y: 0 }, end: { x: 5, y: 4 }, wallTypeId: "aw", height: 2.6 },
|
||||
// Nord (oben)
|
||||
{ id: "W3", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 5, y: 4 }, end: { x: 0, y: 4 }, wallTypeId: "aw", height: 2.6 },
|
||||
// West (links)
|
||||
{ id: "W4", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 0, y: 4 }, end: { x: 0, y: 0 }, wallTypeId: "aw", height: 2.6 },
|
||||
|
||||
// ── OG: 5 × 3 m Raum (CCW), keine Tür ──
|
||||
// Süd (unten)
|
||||
{ id: "W5", type: "wall", floorId: "og", categoryCode: "20", start: { x: 0, y: 0 }, end: { x: 5, y: 0 }, wallTypeId: "aw", height: 2.6 },
|
||||
// Ost (rechts)
|
||||
{ id: "W6", type: "wall", floorId: "og", categoryCode: "20", start: { x: 5, y: 0 }, end: { x: 5, y: 3 }, wallTypeId: "aw", height: 2.6 },
|
||||
// Nord (oben)
|
||||
{ id: "W7", type: "wall", floorId: "og", categoryCode: "20", start: { x: 5, y: 3 }, end: { x: 0, y: 3 }, wallTypeId: "aw", height: 2.6 },
|
||||
// West (links)
|
||||
{ id: "W8", type: "wall", floorId: "og", categoryCode: "20", start: { x: 0, y: 3 }, end: { x: 0, y: 0 }, wallTypeId: "aw", height: 2.6 },
|
||||
],
|
||||
doors: [
|
||||
{
|
||||
id: "D1",
|
||||
type: "door",
|
||||
hostWallId: "W1",
|
||||
categoryCode: "21",
|
||||
position: 2.0, // 2 m vom Wand-Start (Ecke unten links)
|
||||
width: 0.9,
|
||||
height: 2.1,
|
||||
swing: "left", // schlägt nach innen (in den Raum) auf
|
||||
hinge: "start",
|
||||
},
|
||||
],
|
||||
// Freie 2D-Zeichengeometrie — anfangs leer; wird mit den Zeichenwerkzeugen
|
||||
// gefüllt (docs/design/drawing-tools.md).
|
||||
drawings2d: [],
|
||||
// Kontext-Schicht (importierte/abgeleitete Geometrie) — anfangs leer; wird
|
||||
// über den DXF-Import bzw. die Gelände-Generierung gefüllt.
|
||||
context: [],
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
// Gelände-Generierung (TIN) aus Konturen (Höhenlinien).
|
||||
//
|
||||
// Prinzip: Die Stütz-Vertices der Konturen werden als Punktmenge genommen
|
||||
// (sie liegen direkt auf den Höhenlinien = bevorzugte „Breaklines"). Über deren
|
||||
// (x,y)-Projektion wird eine 2D-Delaunay-Triangulation gelegt; die Z-Höhe je
|
||||
// Vertex stammt aus der jeweiligen Kontur. Daraus entsteht ein TIN (Triangulated
|
||||
// Irregular Network) als rohes positions/indices-Mesh (three-frei).
|
||||
//
|
||||
// Gelände ist NICHT semantisch — es ist Kontext-Geometrie (Project.context).
|
||||
|
||||
import Delaunator from "delaunator";
|
||||
import type { Contour, TerrainMesh } from "./types";
|
||||
|
||||
let terrainSeq = 0;
|
||||
|
||||
/**
|
||||
* Erzeugt ein TIN aus Konturen.
|
||||
*
|
||||
* Algorithmus:
|
||||
* 1. Sampling: jeder Stützpunkt jeder Kontur wird ein 3D-Vertex (x, y, z) mit
|
||||
* z = Kontur-Höhe. Es wird KEIN zusätzliches Resampling betrieben — die
|
||||
* Kontur-Vertices selbst sind die Breaklines.
|
||||
* 2. Delaunay: 2D-Triangulation über die (x,y)-Projektion aller Vertices
|
||||
* (Delaunator). Die Z-Höhe je Vertex bleibt erhalten → echtes Gelände-TIN.
|
||||
* 3. Degenerate-Filter: Dreiecke mit (nahezu) null Fläche in der (x,y)-Ebene
|
||||
* bzw. mit nicht-endlichen Koordinaten werden verworfen.
|
||||
*
|
||||
* Robustheit:
|
||||
* • offene UND geschlossene Konturen werden gleich behandelt (nur die Punkte
|
||||
* zählen; closed/offen beeinflusst die TIN-Fläche nicht).
|
||||
* • leere/zu kleine Eingabe (< 3 Punkte) → leeres Mesh (kein Crash).
|
||||
* • NaN/Infinity-Punkte werden vor der Triangulation aussortiert.
|
||||
*/
|
||||
export function generateTerrainFromContours(contours: Contour[]): TerrainMesh {
|
||||
const id = `terrain-${Date.now()}-${terrainSeq++}`;
|
||||
const name = "Gelände";
|
||||
|
||||
// 1. Sampling: alle Kontur-Stützpunkte als (x,y,z)-Vertices sammeln, ungültige
|
||||
// (nicht-endliche) Punkte überspringen.
|
||||
const xs: number[] = [];
|
||||
const ys: number[] = [];
|
||||
const zs: number[] = [];
|
||||
for (const c of contours) {
|
||||
const z = Number.isFinite(c.z) ? c.z : 0;
|
||||
for (const p of c.pts) {
|
||||
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue;
|
||||
xs.push(p.x);
|
||||
ys.push(p.y);
|
||||
zs.push(z);
|
||||
}
|
||||
}
|
||||
|
||||
// Zu wenige Punkte für ein Dreieck → triviales leeres Mesh.
|
||||
if (xs.length < 3) {
|
||||
return { id, type: "terrainMesh", name, positions: [], indices: [] };
|
||||
}
|
||||
|
||||
// 2. Delaunay über die (x,y)-Projektion. Delaunator erwartet ein flaches
|
||||
// [x0,y0, x1,y1, …]-Koordinatenarray.
|
||||
const coords2d = new Float64Array(xs.length * 2);
|
||||
for (let i = 0; i < xs.length; i++) {
|
||||
coords2d[i * 2] = xs[i];
|
||||
coords2d[i * 2 + 1] = ys[i];
|
||||
}
|
||||
// Bei kollinearen Punkten liefert Delaunator eine leere Triangulation — das ist
|
||||
// korrekt (kein Crash). Trotzdem schützen wir gegen einen möglichen Wurf.
|
||||
let triangles: Uint32Array;
|
||||
try {
|
||||
triangles = new Delaunator(coords2d).triangles;
|
||||
} catch {
|
||||
return { id, type: "terrainMesh", name, positions: [], indices: [] };
|
||||
}
|
||||
|
||||
// 3. positions (x,y,z) aufbauen; Z aus zs je Vertex.
|
||||
const positions: number[] = new Array(xs.length * 3);
|
||||
for (let i = 0; i < xs.length; i++) {
|
||||
positions[i * 3] = xs[i];
|
||||
positions[i * 3 + 1] = ys[i];
|
||||
positions[i * 3 + 2] = zs[i];
|
||||
}
|
||||
|
||||
// Degenerierte Dreiecke (Null-Fläche in (x,y)) herausfiltern, damit das TIN
|
||||
// keine entarteten Faces enthält.
|
||||
const indices: number[] = [];
|
||||
const EPS_AREA = 1e-9;
|
||||
for (let t = 0; t < triangles.length; t += 3) {
|
||||
const a = triangles[t];
|
||||
const b = triangles[t + 1];
|
||||
const c = triangles[t + 2];
|
||||
const ax = xs[a], ay = ys[a];
|
||||
const bx = xs[b], by = ys[b];
|
||||
const cx = xs[c], cy = ys[c];
|
||||
// doppelte Dreiecksfläche (Kreuzprodukt) in der (x,y)-Ebene.
|
||||
const area2 = Math.abs((bx - ax) * (cy - ay) - (cx - ax) * (by - ay));
|
||||
if (area2 <= EPS_AREA) continue;
|
||||
indices.push(a, b, c);
|
||||
}
|
||||
|
||||
return { id, type: "terrainMesh", name, positions, indices };
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
// Das semantische Gebäudemodell — der Kern von BIM.
|
||||
// Bauteile haben Bedeutung, nicht nur Form. Eine Tür "kennt" ihre Wand,
|
||||
// eine Wand "kennt" ihren mehrschichtigen Aufbau (WallType).
|
||||
//
|
||||
// Dokumentmodell (nach DOSSIER): zwei UNABHÄNGIGE Achsen.
|
||||
// 1. Zeichnungsebenen (DrawingLevel) = oberste Schnitte: Geschosse +
|
||||
// Schnitte/Ansichten.
|
||||
// 2. Ebenen (LayerCategory) = Grafik-Kategorie-Schema, das für jedes
|
||||
// Geschoss gilt. Ein Element lebt auf einer Kategorie (code) UND einem
|
||||
// Geschoss.
|
||||
|
||||
export type Vec2 = { x: number; y: number };
|
||||
|
||||
// ── Ressourcen-Bibliotheken (Vectorworks-/DOSSIER-Stil) ────────────────────
|
||||
// Verwaltete, per id verwiesene Stil-Ressourcen. Verweis-Kette:
|
||||
// Component → Hatch → LineStyle.
|
||||
// Darstellung wird beim Rendern aus diesen Ressourcen aufgelöst, nie in die
|
||||
// Geometrie eingebacken (siehe docs/design/resources-graphics.md).
|
||||
|
||||
/** Ein wiederverwendbarer Linienstil (Line Manager). */
|
||||
export interface LineStyle {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Strichstärke in Millimetern (≙ Rhino PlotWeight). */
|
||||
weight: number;
|
||||
/** Farbe (hex). */
|
||||
color: string;
|
||||
/** Strichmuster in Millimetern; `null` = durchgezogen. */
|
||||
dash: number[] | null;
|
||||
}
|
||||
|
||||
/** Mögliche Schraffur-Muster im Plan/Schnitt. */
|
||||
export type HatchPattern =
|
||||
| "none"
|
||||
| "solid"
|
||||
| "insulation"
|
||||
| "diagonal"
|
||||
| "crosshatch";
|
||||
|
||||
/** Eine wiederverwendbare Schraffur (Hatch Manager). */
|
||||
export interface HatchStyle {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Muster-Typ. */
|
||||
pattern: HatchPattern;
|
||||
/** Grundmaßstab des Musters (1 = Standardteilung). */
|
||||
scale: number;
|
||||
/** Drehung des Musters in Grad. */
|
||||
angle: number;
|
||||
/**
|
||||
* Farbe der Musterlinien bzw. der Vollfüllung (`pattern==="solid"`). Bei
|
||||
* `pattern==="none"` ungenutzt.
|
||||
*/
|
||||
color: string;
|
||||
/** Optionaler Linienstil für die Musterlinien (Line Manager). */
|
||||
lineStyleId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Bauteil-Material (Component Manager) — vereint Plan-Schnittdarstellung
|
||||
* (Schraffur) und 3D-Erscheinung. Ersetzt das frühere `Material`.
|
||||
*/
|
||||
export interface Component {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Füllfarbe im Grundriss (Poché) und 3D-Diffusfarbe. */
|
||||
color: string;
|
||||
/** Schnitt-Schraffur → Hatch Manager. */
|
||||
hatchId: string;
|
||||
/** Optionale 3D-Textur (vorerst ignoriert). */
|
||||
texture3d?: string;
|
||||
/** Verschneidungs-Rang: höher läuft am Stoß durch (Backbone). */
|
||||
joinPriority: number;
|
||||
}
|
||||
|
||||
/** Eine Schicht eines mehrschichtigen Bauteils. */
|
||||
export interface Layer {
|
||||
/** Verweis auf das Bauteil-Material (Component Manager). */
|
||||
componentId: string;
|
||||
/** Schichtdicke in Metern. */
|
||||
thickness: number;
|
||||
}
|
||||
|
||||
/** Ein Wandtyp = geordneter Schichtaufbau (außen → innen). */
|
||||
export interface WallType {
|
||||
id: string;
|
||||
name: string;
|
||||
layers: Layer[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Art einer Zeichnungsebene.
|
||||
* • "floor" — Geschoss, trägt Bauteile.
|
||||
* • "section" — Schnitt (abgeleitete Projektion entlang einer Linie).
|
||||
* • "elevation" — Ansicht (abgeleitete Projektion entlang einer Linie).
|
||||
* • "drawing" — freie 2D-Zeichnung, nicht an ein Geschoss gebunden.
|
||||
*/
|
||||
export type DrawingLevelKind = "floor" | "section" | "elevation" | "drawing";
|
||||
|
||||
/**
|
||||
* Eine Zeichnungsebene (DrawingLevel) — eine oberste Schnittebene des
|
||||
* Dokuments. Ein Geschoss trägt die Bauteile (über `floorId`); ein
|
||||
* Schnitt/Ansicht ist eine abgeleitete Projektion (vorerst Platzhalter); eine
|
||||
* reine Zeichnung ist eine freie 2D-Ebene ohne Geschossbezug.
|
||||
*
|
||||
* Geschoss nutzt floorHeight/cutHeight/baseElevation; Schnitt/Ansicht nutzen
|
||||
* linePoints/directionSign; "drawing" nutzt keines dieser Felder.
|
||||
*/
|
||||
export interface DrawingLevel {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: DrawingLevelKind;
|
||||
/** Sichtbarkeit der Zeichnungsebene im Navigator. */
|
||||
visible: boolean;
|
||||
/** Gesperrt (keine Bearbeitung). */
|
||||
locked: boolean;
|
||||
/** Lichte Geschosshöhe in Metern (nur Geschoss). */
|
||||
floorHeight?: number;
|
||||
/** Schnitthöhe über OKFF in Metern (nur Geschoss, für den Grundriss). */
|
||||
cutHeight?: number;
|
||||
/** Oberkante Fertigfußboden in Metern (nur Geschoss). */
|
||||
baseElevation?: number;
|
||||
/** Schnitt-/Ansichtslinie im Grundriss (nur Schnitt/Ansicht). */
|
||||
linePoints?: [Vec2, Vec2];
|
||||
/** Blickrichtung relativ zur Schnittlinie (nur Schnitt/Ansicht). */
|
||||
directionSign?: 1 | -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Ebene (LayerCategory) — ein Knoten im Grafik-Kategorie-Baum. Das
|
||||
* Schema gilt geschossübergreifend; Elemente verweisen über `code` darauf.
|
||||
*/
|
||||
export interface LayerCategory {
|
||||
/** Eindeutiger Kategorie-Code, z. B. "20" für Wände. */
|
||||
code: string;
|
||||
name: string;
|
||||
/** Darstellungsfarbe (hex). */
|
||||
color: string;
|
||||
/** Linienstärke in Millimetern. */
|
||||
lw: number;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
/** Optionale Standard-Schraffur der Kategorie. */
|
||||
hatch?: string;
|
||||
/** Unterkategorien (Baum). */
|
||||
children?: LayerCategory[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lage der Wandachse (Referenzlinie) über die Dicke der Wand — analog
|
||||
* Vectorworks. Gemessen relativ zur Laufrichtung (start→end) mit der
|
||||
* leftNormal-Konvention `n = (-u.y, u.x)`:
|
||||
* • "center" — Achse mittig (Default = heutiges Verhalten).
|
||||
* • "left" — Achse liegt auf der linken Wandfläche (+n-Seite, „außen").
|
||||
* • "right" — Achse liegt auf der rechten Wandfläche (−n-Seite, „innen").
|
||||
*/
|
||||
export type WallReferenceLine = "left" | "center" | "right";
|
||||
|
||||
/**
|
||||
* Vertikale Bindung einer Wandkante (UK/OK).
|
||||
* • "floor" — an ein Geschoss gebunden: Z = baseElevation des Geschosses
|
||||
* (+ optionalem `offset`). Stapelt automatisch mit dem Geschoss.
|
||||
* • "custom" — fester absoluter Z-Wert (Meter).
|
||||
*/
|
||||
export type VerticalAnchor =
|
||||
| { mode: "floor"; floorId: string; offset?: number }
|
||||
| { mode: "custom"; z: number };
|
||||
|
||||
/** Eine Wand, definiert über ihre Achse (Centerline) und ihren Typ. */
|
||||
export interface Wall {
|
||||
id: string;
|
||||
type: "wall";
|
||||
/** Zugehörige Zeichnungsebene (Geschoss). */
|
||||
floorId: string;
|
||||
/** Grafik-Kategorie (Ebene), z. B. "20" für Wände. */
|
||||
categoryCode: string;
|
||||
/** Achs-Startpunkt im Grundriss (Meter). */
|
||||
start: Vec2;
|
||||
/** Achs-Endpunkt im Grundriss (Meter). */
|
||||
end: Vec2;
|
||||
/** Verweis auf den (mehrschichtigen) Wandtyp. */
|
||||
wallTypeId: string;
|
||||
/** Wandhöhe in Metern. */
|
||||
height: number;
|
||||
/**
|
||||
* Optionale Übersteuerung der Strich-/Umrandungsfarbe der Wand; sonst gilt
|
||||
* die Kategorie-Farbe. Übersteuert NUR die Linienfarbe (Umriss/Schichtfugen),
|
||||
* nicht die Schicht-Füllfarben/Schraffuren.
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Lage der Wandachse über die Dicke. Fehlt sie, gilt "center" (= heutiges
|
||||
* Verhalten: Schichten symmetrisch −T/2 … +T/2 um die Achse).
|
||||
*/
|
||||
referenceLine?: WallReferenceLine;
|
||||
/**
|
||||
* Vertikale Bindung der Unterkante (UK). Fehlt sie, sitzt die UK auf der
|
||||
* baseElevation des zugehörigen Geschosses (= heutiges Verhalten).
|
||||
*/
|
||||
bottom?: VerticalAnchor;
|
||||
/**
|
||||
* Vertikale Bindung der Oberkante (OK). Fehlt sie, ergibt sich die OK aus
|
||||
* UK + `height` (= heutiges Verhalten). „custom" setzt einen absoluten
|
||||
* Z-Wert; „floor" bindet die OK an ein (z. B. nächsthöheres) Geschoss.
|
||||
*/
|
||||
top?: VerticalAnchor;
|
||||
}
|
||||
|
||||
/** Schwenkrichtung der Tür relativ zur Wandachse. */
|
||||
export type SwingSide = "left" | "right";
|
||||
|
||||
/** Eine Tür, gehostet in einer Wand. Ihr Geschoss ergibt sich aus der Wand. */
|
||||
export interface Door {
|
||||
id: string;
|
||||
type: "door";
|
||||
hostWallId: string;
|
||||
/** Grafik-Kategorie (Ebene), z. B. "21" für Türen/Fenster. */
|
||||
categoryCode: string;
|
||||
/** Abstand des Türanschlags (erster Pfosten) vom Wand-Startpunkt, in Metern. */
|
||||
position: number;
|
||||
/** Türbreite (lichte Öffnung) in Metern. */
|
||||
width: number;
|
||||
/** Türhöhe in Metern. */
|
||||
height: number;
|
||||
/** Auf welche Seite der Wandachse die Tür aufschlägt. */
|
||||
swing: SwingSide;
|
||||
/** An welchem Pfosten das Scharnier sitzt. */
|
||||
hinge: "start" | "end";
|
||||
}
|
||||
|
||||
// ── Freie 2D-Zeichengeometrie (Drawing2D) ──────────────────────────────────
|
||||
// Ein semantisches 2D-Element (wie Wall/Door), das beim Rendern abgeleitet wird
|
||||
// (keine vorab erzeugten Plan-Primitive). Siehe docs/design/drawing-tools.md §7.
|
||||
|
||||
/** Geometrie-Form eines 2D-Zeichenelements. */
|
||||
export type Drawing2DGeom =
|
||||
| { shape: "line"; a: Vec2; b: Vec2 }
|
||||
| { shape: "polyline"; pts: Vec2[]; closed: boolean }
|
||||
| { shape: "rect"; min: Vec2; max: Vec2 }
|
||||
| { shape: "circle"; center: Vec2; r: number }
|
||||
| { shape: "arc"; center: Vec2; r: number; a0: number; a1: number }
|
||||
| { shape: "text"; at: Vec2; text: string; height: number; angle: number };
|
||||
|
||||
/** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */
|
||||
export interface Drawing2D {
|
||||
id: string;
|
||||
type: "drawing2d";
|
||||
/** Zeichnungsebene (Geschoss ODER freie 2D-Ebene). */
|
||||
levelId: string;
|
||||
/** Grafik-Kategorie (Ebene) — liefert Farbe/Strichstärke als Default. */
|
||||
categoryCode: string;
|
||||
geom: Drawing2DGeom;
|
||||
/** Optionaler Linienstil (Line Manager); sonst Kategorie-Default. */
|
||||
lineStyleId?: string;
|
||||
/** Optionale Schraffur für geschlossene Formen (Hatch Manager). */
|
||||
hatchId?: string;
|
||||
/** Optionale explizite Strichfarbe; sonst Kategorie-Farbe. */
|
||||
color?: string;
|
||||
/**
|
||||
* Optionale Vollton-Füllfarbe für geschlossene Formen (getrennt von der
|
||||
* Strichfarbe `color`). Fehlt sie, ist die Fläche transparent (nur Schraffur
|
||||
* bzw. ungefüllt).
|
||||
*/
|
||||
fillColor?: string;
|
||||
/**
|
||||
* Optionale direkte Strichstärke-Übersteuerung in Millimetern; hat Vorrang
|
||||
* vor dem LineStyle-Gewicht und der Kategorie-Strichstärke.
|
||||
*/
|
||||
weightMm?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Kanten-/Seiten-Griff eines selektierten Elements (zusätzlich zu den
|
||||
* Eckpunkt-Griffen). Liegt am Mittelpunkt einer Seite (Modell-Meter) und zeigt
|
||||
* mit `normal` als Einheitsvektor nach AUSSEN (vom Element weg). `aIndex`/
|
||||
* `bIndex` sind die beiden Vertex-Indizes der Kante — passend zur Indizierung
|
||||
* von `drawingVertices`/`moveGripOf`. Ziehen verschiebt BEIDE Vertices senkrecht
|
||||
* zur Kante (Form wächst/schrumpft an dieser Seite).
|
||||
*/
|
||||
export interface EdgeGrip {
|
||||
mid: Vec2;
|
||||
normal: Vec2;
|
||||
aIndex: number;
|
||||
bIndex: number;
|
||||
}
|
||||
|
||||
export type Element = Wall | Door | Drawing2D;
|
||||
|
||||
// ── Kontext-Geometrie (importiert / abgeleitet, NICHT semantisch) ───────────
|
||||
// Importierte Geometrie ist „dumme" KONTEXT-Geometrie (Anzeige + späteres
|
||||
// Snap-Ziel), KEIN Teil des semantischen BIM-Modells. Sie lebt in einer eigenen
|
||||
// Schicht `Project.context` und wird beim Rendern wie eine Referenz behandelt.
|
||||
// Bewusst three-frei: nur rohe Buffer-Daten (positions/indices), three-Objekte
|
||||
// entstehen erst im Viewport.
|
||||
|
||||
/**
|
||||
* Ein importiertes Dreiecks-Mesh (z. B. aus DXF 3DFACE/POLYFACE/MESH). Rohe
|
||||
* BufferGeometry-Daten: `positions` = flaches Array (x,y,z, x,y,z, …) in Metern,
|
||||
* `indices` = Dreiecks-Indizes (je 3 ein Dreieck). Keine three-Objekte.
|
||||
*/
|
||||
export interface ImportedMesh {
|
||||
id: string;
|
||||
type: "importedMesh";
|
||||
name: string;
|
||||
/** Ursprünglicher DXF-Layer-Name (für spätere Kategorisierung). */
|
||||
layer?: string;
|
||||
positions: number[];
|
||||
indices: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine einzelne Kontur (Höhenlinie / Polylinie) auf konstanter Höhe `z`. `pts`
|
||||
* sind 2D-Stützpunkte (x,y) in Metern; `closed` schließt den Linienzug.
|
||||
*/
|
||||
export interface Contour {
|
||||
z: number;
|
||||
pts: Vec2[];
|
||||
closed: boolean;
|
||||
/** Ursprünglicher DXF-Layer-Name (für die Kategorisierung beim 2D-Import). */
|
||||
layer?: string;
|
||||
}
|
||||
|
||||
/** Ein Satz Konturen (z. B. alle Höhenlinien eines DXF-Imports). */
|
||||
export interface ContourSet {
|
||||
id: string;
|
||||
type: "contourSet";
|
||||
name: string;
|
||||
/** Ursprünglicher DXF-Layer-Name (für spätere Kategorisierung). */
|
||||
layer?: string;
|
||||
contours: Contour[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein TIN-Geländemodell, abgeleitet aus Konturen (Delaunay über (x,y), Z aus der
|
||||
* jeweiligen Kontur-Höhe). `positions` = flaches (x,y,z…)-Array in Metern,
|
||||
* `indices` = Dreiecks-Indizes. Gelände ist NICHT semantisch (Kontext-Schicht).
|
||||
*/
|
||||
export interface TerrainMesh {
|
||||
id: string;
|
||||
type: "terrainMesh";
|
||||
name: string;
|
||||
positions: number[];
|
||||
indices: number[];
|
||||
}
|
||||
|
||||
/** Ein Kontext-Objekt: importiertes Mesh, Konturen-Satz oder abgeleitetes TIN. */
|
||||
export type ContextObject = ImportedMesh | ContourSet | TerrainMesh;
|
||||
|
||||
/** Das gesamte Projekt. */
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Linienstil-Bibliothek (Line Manager). */
|
||||
lineStyles: LineStyle[];
|
||||
/** Schraffur-Bibliothek (Hatch Manager). */
|
||||
hatches: HatchStyle[];
|
||||
/** Bauteil-Material-Bibliothek (Component Manager). */
|
||||
components: Component[];
|
||||
wallTypes: WallType[];
|
||||
/** Oberste Schnitte: Geschosse + Schnitte/Ansichten. */
|
||||
drawingLevels: DrawingLevel[];
|
||||
/** Grafik-Kategorie-Baum (geschossübergreifend). */
|
||||
layers: LayerCategory[];
|
||||
walls: Wall[];
|
||||
doors: Door[];
|
||||
/** Freie 2D-Zeichengeometrie (Line/Polyline/Rect/Circle/Arc/Text). */
|
||||
drawings2d: Drawing2D[];
|
||||
/**
|
||||
* Kontext-Schicht: importierte/abgeleitete „dumme" Geometrie (Meshes,
|
||||
* Konturen, Gelände-TIN) — NICHT semantisch. Optional, damit bestehende
|
||||
* Projekte/Tests ohne `context` gültig bleiben (Default: leer behandeln).
|
||||
*/
|
||||
context?: ContextObject[];
|
||||
}
|
||||
|
||||
// ── Helfer ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const getWallType = (project: Project, wall: Wall): WallType => {
|
||||
const wt = project.wallTypes.find((t) => t.id === wall.wallTypeId);
|
||||
if (!wt) throw new Error(`Unbekannter Wandtyp: ${wall.wallTypeId}`);
|
||||
return wt;
|
||||
};
|
||||
|
||||
/** Liefert ein Bauteil-Material (Component) per ID oder wirft. */
|
||||
export const getComponent = (project: Project, id: string): Component => {
|
||||
const c = project.components.find((co) => co.id === id);
|
||||
if (!c) throw new Error(`Unbekanntes Bauteil-Material: ${id}`);
|
||||
return c;
|
||||
};
|
||||
|
||||
/** Liefert eine Schraffur (HatchStyle) per ID oder wirft. */
|
||||
export const getHatch = (project: Project, id: string): HatchStyle => {
|
||||
const h = project.hatches.find((ht) => ht.id === id);
|
||||
if (!h) throw new Error(`Unbekannte Schraffur: ${id}`);
|
||||
return h;
|
||||
};
|
||||
|
||||
/** Liefert einen Linienstil (LineStyle) per ID oder wirft. */
|
||||
export const getLineStyle = (project: Project, id: string): LineStyle => {
|
||||
const l = project.lineStyles.find((ls) => ls.id === id);
|
||||
if (!l) throw new Error(`Unbekannter Linienstil: ${id}`);
|
||||
return l;
|
||||
};
|
||||
|
||||
/** Liefert eine Zeichnungsebene (Geschoss) per ID oder wirft. */
|
||||
export const getFloor = (project: Project, id: string): DrawingLevel => {
|
||||
const g = project.drawingLevels.find((z) => z.id === id);
|
||||
if (!g) throw new Error(`Unbekanntes Geschoss: ${id}`);
|
||||
return g;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stapelt baseElevation der Geschosse in Dokumentreihenfolge: Das erste
|
||||
* Geschoss beginnt bei 0, jedes weitere bei baseElevation + floorHeight des
|
||||
* vorigen Geschosses. Nicht-Geschoss-Ebenen behalten baseElevation undefined.
|
||||
* Liefert eine neue Liste (mit neuen Geschoss-Objekten); die Eingabe bleibt
|
||||
* unverändert.
|
||||
*/
|
||||
export const recomputeFloorElevations = (
|
||||
levels: DrawingLevel[],
|
||||
): DrawingLevel[] => {
|
||||
let nextBase = 0;
|
||||
return levels.map((level) => {
|
||||
if (level.kind !== "floor") {
|
||||
return { ...level, baseElevation: undefined };
|
||||
}
|
||||
const baseElevation = nextBase;
|
||||
nextBase = baseElevation + (level.floorHeight ?? 0);
|
||||
return { ...level, baseElevation };
|
||||
});
|
||||
};
|
||||
|
||||
/** Flacht den Kategorie-Baum (Tiefensuche) in eine Liste ab. */
|
||||
export const flattenCategories = (cats: LayerCategory[]): LayerCategory[] => {
|
||||
const out: LayerCategory[] = [];
|
||||
const walk = (list: LayerCategory[]) => {
|
||||
for (const c of list) {
|
||||
out.push(c);
|
||||
if (c.children) walk(c.children);
|
||||
}
|
||||
};
|
||||
walk(cats);
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Menge aller Codes sichtbarer Kategorien (Baum berücksichtigt). */
|
||||
export const collectVisibleCodes = (layers: LayerCategory[]): Set<string> => {
|
||||
const codes = new Set<string>();
|
||||
for (const c of flattenCategories(layers)) {
|
||||
if (c.visible) codes.add(c.code);
|
||||
}
|
||||
return codes;
|
||||
};
|
||||
|
||||
/** Gesamtdicke eines Wandtyps = Summe der Schichtdicken. */
|
||||
export const wallTypeThickness = (wt: WallType): number =>
|
||||
wt.layers.reduce((sum, l) => sum + l.thickness, 0);
|
||||
|
||||
/** Formatiert Meter mit zwei Nachkommastellen, z. B. "0.35 m". */
|
||||
export const formatM = (meters: number): string => meters.toFixed(2) + " m";
|
||||
|
||||
/**
|
||||
* Standard-Stiftstärken (mm Papier bei 100 %), Vorgabeliste für Linienstil-/
|
||||
* Strichstärke-Eingaben. Bedeutung: Breite auf dem Papier — die Linien skalieren
|
||||
* mit dem Massstab (non-scaling-stroke). Werte sind Vorschläge; man darf abweichen.
|
||||
*/
|
||||
export const PEN_WEIGHTS: number[] = [
|
||||
0.02, 0.1, 0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0, 1.4, 2.0,
|
||||
];
|
||||
@@ -0,0 +1,87 @@
|
||||
// Wand-Resolver: leitet aus den (optionalen) Wand-Attributen die konkreten
|
||||
// geometrischen Größen ab — Versatz der Achse über die Dicke (Referenzlinie)
|
||||
// und die vertikale Ausdehnung (UK..OK). Alle Defaults entsprechen exakt dem
|
||||
// bisherigen Verhalten, sodass bestehende Wände unverändert rendern.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
|
||||
|
||||
import type { Project, VerticalAnchor, Wall } from "./types";
|
||||
import { getFloor } from "./types";
|
||||
|
||||
/**
|
||||
* Versatz der Schichtstapelung entlang der linken Normalen `+n` (leftNormal),
|
||||
* sodass die Achse auf der gewünschten Wandfläche liegt. Die Schichten werden
|
||||
* normalerweise symmetrisch von `−T/2` (außen, +n-Seite) bis `+T/2` (innen)
|
||||
* gestapelt; dieser Versatz wird ZUSÄTZLICH auf jeden Schicht-Offset addiert.
|
||||
*
|
||||
* • "center" → 0 (Schichten symmetrisch um die Achse — Default).
|
||||
* • "left" → +T/2 (Achse auf der +n-/„außen"-Fläche; Wand liegt auf −n).
|
||||
* • "right" → −T/2 (Achse auf der −n-/„innen"-Fläche; Wand liegt auf +n).
|
||||
*
|
||||
* Die Schicht-Offsets laufen von −T/2 (= +n außen) nach +T/2 (= −n innen).
|
||||
* Für "left" soll die −T/2-Kante (außen) auf der Achse liegen → +T/2 addieren,
|
||||
* sodass die Schichten von 0…T (alle auf der −n-Seite) liegen.
|
||||
*/
|
||||
export function wallReferenceOffset(
|
||||
wall: Wall,
|
||||
totalThickness: number,
|
||||
): number {
|
||||
const ref = wall.referenceLine ?? "center";
|
||||
const half = totalThickness / 2;
|
||||
if (ref === "left") return +half;
|
||||
if (ref === "right") return -half;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Löst einen VerticalAnchor zu einem absoluten Z (Meter) auf oder `null`. */
|
||||
function resolveAnchor(
|
||||
project: Project,
|
||||
anchor: VerticalAnchor | undefined,
|
||||
): number | null {
|
||||
if (!anchor) return null;
|
||||
if (anchor.mode === "custom") return anchor.z;
|
||||
// "floor": baseElevation des gebundenen Geschosses (+ optionalem Offset).
|
||||
const floor = project.drawingLevels.find((z) => z.id === anchor.floorId);
|
||||
if (!floor) return null;
|
||||
return (floor.baseElevation ?? 0) + (anchor.offset ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertikale Ausdehnung der Wand (absolute Z-Werte in Metern).
|
||||
* • zBottom: aus `wall.bottom`, sonst baseElevation des Wand-Geschosses
|
||||
* (= heutiges Verhalten).
|
||||
* • zTop: aus `wall.top`, sonst `zBottom + wall.height` (= heute).
|
||||
* Sind beide UK/OK ungesetzt, liefert dies exakt `[baseElevation, base+height]`.
|
||||
*/
|
||||
export function wallVerticalExtent(
|
||||
project: Project,
|
||||
wall: Wall,
|
||||
): { zBottom: number; zTop: number } {
|
||||
// Geschoss-baseElevation als Default-UK (heutiges Verhalten). Fehlt das
|
||||
// Geschoss, 0 als sicherer Rückfall.
|
||||
let floorBase = 0;
|
||||
try {
|
||||
floorBase = getFloor(project, wall.floorId).baseElevation ?? 0;
|
||||
} catch {
|
||||
floorBase = 0;
|
||||
}
|
||||
const zBottom = resolveAnchor(project, wall.bottom) ?? floorBase;
|
||||
const zTop = resolveAnchor(project, wall.top) ?? zBottom + wall.height;
|
||||
return { zBottom, zTop };
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Geschoss DIREKT ÜBER dem Wand-Geschoss (in Dokumentreihenfolge der
|
||||
* "floor"-Ebenen) oder `undefined`, wenn die Wand im obersten Geschoss liegt.
|
||||
* Dient der „Verknüpfung zum oberen Geschoss" (OK an nächstes Geschoss binden).
|
||||
*/
|
||||
export function nextFloorAbove(
|
||||
project: Project,
|
||||
wall: Wall,
|
||||
): { id: string; name: string } | undefined {
|
||||
const floors = project.drawingLevels.filter((z) => z.kind === "floor");
|
||||
const idx = floors.findIndex((z) => z.id === wall.floorId);
|
||||
if (idx < 0) return undefined;
|
||||
const above = floors[idx + 1];
|
||||
return above ? { id: above.id, name: above.name } : undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user