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
+277
View File
@@ -0,0 +1,277 @@
// Editier-Operationen Split / Join / Segment-Löschen für 2D-Zeichenelemente
// (Drawing2D). Wandeln Drawing2D ↔ {pts, closed}, rufen den reinen Geometrie-
// Kernel (`../geometry/kernel2d`) und liefern neue Drawing2D-Listen. Stil-,
// Kategorie- und Level-Attribute der Quelle werden geerbt.
//
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
import type { Drawing2D, Drawing2DGeom, Vec2 } from "../model/types";
import {
joinChains,
removeSegment,
splitAtIntersections,
splitClosedByChord,
splitPolylineAtParam,
vecEqual,
} from "../geometry/kernel2d";
/** Eine Polylinien-Darstellung eines Drawing2D (für die Kernel-Funktionen). */
export interface PolyForm {
pts: Vec2[];
closed: boolean;
}
/**
* Wandelt die Geometrie eines Drawing2D in eine Polylinien-Form `{pts, closed}`.
* Nur line/polyline/rect werden unterstützt (circle/arc/text → `null`).
*/
export function toPolyForm(geom: Drawing2DGeom): PolyForm | null {
switch (geom.shape) {
case "line":
return { pts: [geom.a, geom.b], closed: false };
case "polyline":
return { pts: geom.pts.slice(), closed: geom.closed };
case "rect":
return {
pts: [
geom.min,
{ x: geom.max.x, y: geom.min.y },
geom.max,
{ x: geom.min.x, y: geom.max.y },
],
closed: true,
};
default:
return null;
}
}
/**
* Baut aus einer Polylinien-Form die passende Drawing2D-Geometrie. Zwei Punkte +
* offen → `line`; sonst `polyline` (geschlossen oder offen). Rechtecke werden
* NICHT rekonstruiert (nach dem Schneiden sind die Teile beliebige Polygone).
*/
export function fromPolyForm(form: PolyForm): Drawing2DGeom {
const pts = form.pts;
if (pts.length === 2 && !form.closed) {
return { shape: "line", a: pts[0], b: pts[1] };
}
return { shape: "polyline", pts, closed: form.closed };
}
let counter = 0;
/** Eindeutige Drawing2D-ID (zeitbasiert + Zähler, damit ein Split nicht kollidiert). */
function newId(prefix: string): string {
counter += 1;
return `${prefix}-${Date.now()}-${counter}`;
}
/** Übernimmt Stil-/Kategorie-/Level-Attribute der Quelle auf ein neues Drawing2D. */
function inherit(src: Drawing2D, geom: Drawing2DGeom): Drawing2D {
const out: Drawing2D = {
id: newId("d2d"),
type: "drawing2d",
levelId: src.levelId,
categoryCode: src.categoryCode,
geom,
};
if (src.lineStyleId !== undefined) out.lineStyleId = src.lineStyleId;
if (src.color !== undefined) out.color = src.color;
if (src.weightMm !== undefined) out.weightMm = src.weightMm;
// Füllung/Schraffur nur an geschlossene Ergebnisse weitergeben.
const closed =
geom.shape === "polyline" ? geom.closed : geom.shape === "rect";
if (closed) {
if (src.hatchId !== undefined) out.hatchId = src.hatchId;
if (src.fillColor !== undefined) out.fillColor = src.fillColor;
}
return out;
}
/**
* Split eines Drawing2D gegen alle ANDEREN Elemente (`others`, als PolyForm).
* • Trifft das Ziel andere Elemente, wird an den Schnittpunkten geteilt
* (geschlossen + 2 Schnitte → zwei GESCHLOSSENE Polygone via Sehne).
* • Ohne Schnitt: Fallback — an der Kanten-MITTE der „längsten" Kante teilen
* (offen → zwei Stücke; geschlossen → an einer Kantenmitte + der gegenüber-
* liegenden Kantenmitte zu zwei geschlossenen Hälften).
* Liefert ≥2 neue Drawing2D, oder `[src]` (unverändert), wenn keine Teilung
* möglich war (z. B. nicht-polyline Form). Stil/Kategorie/Level werden geerbt.
*/
export function splitDrawing(
src: Drawing2D,
others: PolyForm[],
): Drawing2D[] {
const form = toPolyForm(src.geom);
if (!form) return [src];
const otherPts = others.map((o) =>
// Geschlossene als Ring (erster==letzter) übergeben, damit der Kernel die
// Schlusskante als Kante erkennt.
o.closed && o.pts.length > 2 ? [...o.pts, o.pts[0]] : o.pts,
);
let pieces = splitAtIntersections(form.pts, form.closed, otherPts);
// Kein Schnitt-Ergebnis (eine Liste, identisch zur Quelle) → Fallback.
const noCut = pieces.length < 2;
if (noCut) {
pieces = fallbackSplit(form);
}
if (pieces.length < 2) return [src];
// Jedes Stück: geschlossen bleibt geschlossen NUR, wenn das Original
// geschlossen war UND der Schnitt zwei geschlossene Hälften erzeugte
// (Sehnen-Split / Fallback-geschlossen). Bei offenem Aufschneiden sind die
// Stücke offen.
const piecesClosed = form.closed && isClosedSplit(form, pieces);
return pieces.map((pts) =>
inherit(src, fromPolyForm({ pts, closed: piecesClosed })),
);
}
/**
* Heuristik: erzeugte der Schnitt zwei GESCHLOSSENE Hälften (Sehnen-Split)?
* Wahr, wenn das Original geschlossen war und genau zwei Stücke entstanden
* (Chord-Split) — diese sollen wieder geschlossen sein. Bei mehr Stücken
* (geschlossen an >2 Schnitten aufgetrennt) bleiben sie offen.
*/
function isClosedSplit(form: PolyForm, pieces: Vec2[][]): boolean {
return form.closed && pieces.length === 2;
}
/**
* Fallback ohne Schnitt mit anderen Elementen: an der Mitte teilen.
* • Offen: längste Kante an t=0.5 in zwei offene Stücke.
* • Geschlossen: an den Mitten zweier „gegenüberliegender" Kanten in zwei
* geschlossene Polygone (Sehne zwischen den beiden Kantenmitten).
*/
function fallbackSplit(form: PolyForm): Vec2[][] {
const n = form.pts.length;
if (n < 2) return [form.pts];
if (!form.closed) {
// Längste Kante suchen.
let best = 0;
let bestLen = -1;
for (let i = 0; i < n - 1; i++) {
const d = dist(form.pts[i], form.pts[i + 1]);
if (d > bestLen) {
bestLen = d;
best = i;
}
}
return splitPolylineAtParam(form.pts, false, best, 0.5);
}
// Geschlossen: Kante 0 und die „gegenüberliegende" Kante.
const i = 0;
const j = Math.max(1, Math.floor(n / 2)) % n;
if (i === j) return [form.pts];
// Direkt den Kernel-Chord-Split nutzen.
const res = splitClosedHalf(form.pts, i, j);
return res ?? [form.pts];
}
/** Geschlossenes Polygon an den Mitten der Kanten i und j halbieren. */
function splitClosedHalf(pts: Vec2[], i: number, j: number): Vec2[][] | null {
// Reuse: splitClosedByChord ist im Kernel; hier über splitAtIntersections-Form
// nachzubauen wäre umständlich — direkt importieren.
const res = chordSplit(pts, i, 0.5, j, 0.5);
return res;
}
function chordSplit(
pts: Vec2[],
i: number,
ti: number,
j: number,
tj: number,
): Vec2[][] | null {
const r = splitClosedByChord(pts, i, ti, j, tj);
return r ? [r[0], r[1]] : null;
}
const dist = (a: Vec2, b: Vec2): number =>
Math.hypot(a.x - b.x, a.y - b.y);
/**
* Join: verbindet mehrere Drawing2D an koinzidenten Endpunkten zu Polylinien
* (geschlossen, wenn sich eine Kette schließt). Nicht-polyline Elemente
* (circle/arc/text) werden unverändert durchgereicht. Liefert die NEUE Menge
* der Drawing2D, die die Eingabe-Elemente ersetzt. Attribute werden vom ersten
* verbundenen Quell-Element geerbt.
*/
export function joinDrawings(srcs: Drawing2D[]): Drawing2D[] {
const joinable: { form: PolyForm; src: Drawing2D }[] = [];
const passthrough: Drawing2D[] = [];
for (const d of srcs) {
const form = toPolyForm(d.geom);
if (form) joinable.push({ form, src: d });
else passthrough.push(d);
}
if (joinable.length === 0) return srcs;
const chains = joinChains(joinable.map((j) => j.form));
const template = joinable[0].src;
const joined = chains.map((c) =>
inherit(template, fromPolyForm({ pts: c.pts, closed: c.closed })),
);
return [...joined, ...passthrough];
}
/**
* Segment-Löschen (Schere): entfernt das Segment `edgeIndex` aus einem
* Drawing2D. Geschlossene Form → offene Polylinie. Liefert das geänderte
* Drawing2D, oder `null`, wenn weniger als 2 Punkte übrig blieben (Element
* komplett löschen) bzw. die Form nicht unterstützt wird.
*/
export function cutSegment(src: Drawing2D, edgeIndex: number): Drawing2D | null {
const form = toPolyForm(src.geom);
if (!form) return src; // nicht unterstützte Form unverändert lassen
const res = removeSegment(form.pts, form.closed, edgeIndex);
if (res.pts.length < 2) return null;
return inherit(src, fromPolyForm(res));
}
/**
* Liefert den Index der Kante eines Drawing2D, die dem Modellpunkt `p` am
* nächsten liegt (für Alt+Klick = Segment-Löschen). `null`, wenn die Form keine
* Kanten hat (circle/arc/text) oder zu wenige Punkte. Reuse: Polyline-Form +
* Punkt-Strecke-Distanz.
*/
export function nearestEdgeIndex(geom: Drawing2DGeom, p: Vec2): number | null {
const form = toPolyForm(geom);
if (!form || form.pts.length < 2) return null;
const edges: [number, Vec2, Vec2][] = [];
for (let i = 0; i < form.pts.length - 1; i++) {
edges.push([i, form.pts[i], form.pts[i + 1]]);
}
if (form.closed && form.pts.length > 2) {
edges.push([form.pts.length - 1, form.pts[form.pts.length - 1], form.pts[0]]);
}
let best: number | null = null;
let bestD = Infinity;
for (const [i, a, b] of edges) {
const d = segDist(p, a, b);
if (d < bestD) {
bestD = d;
best = i;
}
}
return best;
}
/** Abstand Punkt → Strecke (lokal, klein gehalten). */
function segDist(p: Vec2, a: Vec2, b: Vec2): number {
const abx = b.x - a.x;
const aby = b.y - a.y;
const l2 = abx * abx + aby * aby;
let t = l2 > 0 ? ((p.x - a.x) * abx + (p.y - a.y) * aby) / l2 : 0;
t = Math.max(0, Math.min(1, t));
const cx = a.x + abx * t;
const cy = a.y + aby * t;
return Math.hypot(p.x - cx, p.y - cy);
}
// `vecEqual` re-export für etwaige Aufrufer (Auswahl-Vergleich).
export { vecEqual };