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:
2026-06-30 20:52:27 +02:00
commit ca859c4aa4
157 changed files with 37921 additions and 0 deletions
+110
View File
@@ -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 Geh­rungs­linien
* (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)];
}
+141
View File
@@ -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 };
}
+182
View File
@@ -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: [],
};
+100
View File
@@ -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 };
}
+469
View File
@@ -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,
];
+87
View File
@@ -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;
}