2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand
Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung (konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text- Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback, falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform) fuer fluessige Interaktion ohne React-Re-Render je Frame. Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM (Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext- Import, Tauri-Compute-Boundary-PoC).
This commit is contained in:
+513
-75
@@ -11,23 +11,34 @@
|
||||
// dwg.dwg_read_data(buffer, Dwg_File_Type.DWG) → Dwg_Data-Pointer
|
||||
// dwg.convert(ptr) → DwgDatabase (stark typisiert)
|
||||
// dwg.dwg_free(ptr) → Speicher freigeben
|
||||
// db.entities → Modellraum-Entities (DwgEntity[])
|
||||
// db.tables.BLOCK_RECORD.entries → Block-Definitionen
|
||||
// (name → entities[]) für INSERT.
|
||||
// Der WASM-Build dieser Lib hat DXF-Schreiben/-Lesen DEAKTIVIERT (disable-dxf),
|
||||
// daher gehen wir bewusst NICHT über DWG→DXF-Text, sondern mappen die geparste
|
||||
// DwgDatabase direkt (ein Mapping, gespiegelt aus dxfParser).
|
||||
//
|
||||
// Unterstützte Entities (Modellraum):
|
||||
// Unterstützte Entities (Modellraum + expandierte Blöcke):
|
||||
// • 3DFACE → Dreiecks-Mesh (3 bzw. 4 Ecken → 1–2 Tri).
|
||||
// • POLYLINE2D (Polyface, Flag 64) → Dreiecks-Mesh (Polyface-Faces).
|
||||
// • LWPOLYLINE / POLYLINE2D / 3D → Kontur (z aus elevation/Vertex-Z).
|
||||
// • LINE → Kontur (zwei-Punkt-Linienzug).
|
||||
// • ARC → Kontur (Bogen, ~6° je Segment, offen).
|
||||
// • CIRCLE → Kontur (geschlossen, 48 Segmente).
|
||||
// • ELLIPSE → Kontur (geschlossen bei Vollellipse).
|
||||
// • SPLINE → Kontur (fitPoints, sonst controlPoints).
|
||||
// • INSERT → Block-Expansion mit Transformation
|
||||
// (Translation/Skalierung/Rotation),
|
||||
// rekursiv (Tiefen-Limit gegen Zyklen).
|
||||
// • POINT → bewusst ignoriert (kein Polylinien-Bezug).
|
||||
//
|
||||
// Lazy: Das Paket wird per dynamischem import() geladen (kein Bloat im Initial-
|
||||
// Bundle); WASM startet erst beim ersten DWG-Import.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
|
||||
import type { DxfImportResult } from "./dxfParser";
|
||||
import type { DxfImportResult, ImportDiagnostics } from "./dxfParser";
|
||||
|
||||
// Lose getippte Sicht auf die libredwg-web-Entities (wir greifen tolerant auf die
|
||||
// Felder zu, die wir brauchen; alle Punkte sind {x,y,z}).
|
||||
@@ -56,13 +67,42 @@ interface DwgEntityLike {
|
||||
corner3?: DwgPoint;
|
||||
corner4?: DwgPoint;
|
||||
vertices?: DwgVertexLike[];
|
||||
// ── ARC / CIRCLE ──
|
||||
center?: DwgPoint;
|
||||
radius?: number;
|
||||
startAngle?: number;
|
||||
endAngle?: number;
|
||||
// ── ELLIPSE ──
|
||||
majorAxisEndPoint?: DwgPoint;
|
||||
axisRatio?: number;
|
||||
// ── SPLINE ──
|
||||
fitPoints?: DwgPoint[];
|
||||
controlPoints?: DwgPoint[];
|
||||
// ── INSERT ──
|
||||
name?: string;
|
||||
insertionPoint?: DwgPoint;
|
||||
xScale?: number;
|
||||
yScale?: number;
|
||||
zScale?: number;
|
||||
rotation?: number;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
// Block-Record-Tabelleneintrag: Block-Name → Entities (siehe blockRecord.d.ts).
|
||||
interface DwgBlockRecordLike {
|
||||
name?: string;
|
||||
entities?: DwgEntityLike[];
|
||||
}
|
||||
|
||||
// Schmale Sicht auf die LibreDWG-Instanz (nur die Methoden, die wir nutzen).
|
||||
interface LibreDwgApi {
|
||||
dwg_read_data(data: ArrayBuffer, fileType: number): number | undefined;
|
||||
convert(ptr: number): { entities?: unknown[] };
|
||||
convert(ptr: number): {
|
||||
entities?: unknown[];
|
||||
tables?: {
|
||||
BLOCK_RECORD?: { entries?: DwgBlockRecordLike[] };
|
||||
};
|
||||
};
|
||||
dwg_free(ptr: number): void;
|
||||
}
|
||||
|
||||
@@ -111,10 +151,118 @@ const POLYFACE_FLAG = 64;
|
||||
// POLYLINE-Flag 1 = geschlossen.
|
||||
const CLOSED_FLAG = 1;
|
||||
|
||||
// Tessellierungs-Parameter (Bögen/Kreise/Ellipsen/Splines).
|
||||
const ARC_SEG_RAD = (8 * Math.PI) / 180; // ~8° je Bogen-Segment …
|
||||
const ARC_MIN_SEGMENTS = 6; // … aber mindestens 6 Segmente.
|
||||
const CIRCLE_SEGMENTS = 48; // Vollkreis-Auflösung.
|
||||
const ELLIPSE_SEGMENTS = 64; // Vollellipse-Auflösung.
|
||||
const SPLINE_SEGMENTS = 64; // Spline-Auflösung (controlPoints-Fall).
|
||||
|
||||
// Block-Expansions-Tiefenlimit (gegen zyklische INSERTs / Blöcke in Blöcken).
|
||||
const MAX_BLOCK_DEPTH = 10;
|
||||
|
||||
/**
|
||||
* 2D-Affin-Transform (für INSERT-Expansion). Bildet einen Block-Modellraum auf
|
||||
* den Welt-Modellraum ab: `[x', y'] = M·[x, y] + t`. Z wird separat (additiv +
|
||||
* zScale) behandelt, da unsere Konturen 2D-Punkte + eine z-Höhe führen.
|
||||
*/
|
||||
interface Transform2D {
|
||||
// Lineare 2×2-Matrix (Skalierung · Rotation), spaltenweise.
|
||||
m00: number;
|
||||
m01: number;
|
||||
m10: number;
|
||||
m11: number;
|
||||
// Translation.
|
||||
tx: number;
|
||||
ty: number;
|
||||
// Z-Anteil (additiver Versatz + Skalierung).
|
||||
zOffset: number;
|
||||
zScale: number;
|
||||
}
|
||||
|
||||
const IDENTITY: Transform2D = {
|
||||
m00: 1,
|
||||
m01: 0,
|
||||
m10: 0,
|
||||
m11: 1,
|
||||
tx: 0,
|
||||
ty: 0,
|
||||
zOffset: 0,
|
||||
zScale: 1,
|
||||
};
|
||||
|
||||
/** Wendet eine Transform2D auf einen 2D-Punkt an. */
|
||||
function applyXY(t: Transform2D, x: number, y: number): Vec2 {
|
||||
return {
|
||||
x: t.m00 * x + t.m01 * y + t.tx,
|
||||
y: t.m10 * x + t.m11 * y + t.ty,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wendet die Z-Komponente einer Transform2D auf einen z-Wert an. */
|
||||
function applyZ(t: Transform2D, z: number): number {
|
||||
return z * t.zScale + t.zOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verkettet zwei Transforms: `outer ∘ inner` (inner wird zuerst angewandt, dann
|
||||
* outer). Für rekursive Block-Expansion (Block in Block).
|
||||
*/
|
||||
function compose(outer: Transform2D, inner: Transform2D): Transform2D {
|
||||
return {
|
||||
m00: outer.m00 * inner.m00 + outer.m01 * inner.m10,
|
||||
m01: outer.m00 * inner.m01 + outer.m01 * inner.m11,
|
||||
m10: outer.m10 * inner.m00 + outer.m11 * inner.m10,
|
||||
m11: outer.m10 * inner.m01 + outer.m11 * inner.m11,
|
||||
tx: outer.m00 * inner.tx + outer.m01 * inner.ty + outer.tx,
|
||||
ty: outer.m10 * inner.tx + outer.m11 * inner.ty + outer.ty,
|
||||
zOffset: outer.zScale * inner.zOffset + outer.zOffset,
|
||||
zScale: outer.zScale * inner.zScale,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Einfüge-Transform eines INSERT: Skalierung (xScale/yScale/zScale) →
|
||||
* Rotation (um Z) → Translation (insertionPoint).
|
||||
*/
|
||||
function insertTransform(e: DwgEntityLike): Transform2D {
|
||||
const ip = e.insertionPoint ?? { x: 0, y: 0, z: 0 };
|
||||
const sx = Number.isFinite(e.xScale) ? (e.xScale as number) : 1;
|
||||
const sy = Number.isFinite(e.yScale) ? (e.yScale as number) : 1;
|
||||
const sz = Number.isFinite(e.zScale) ? (e.zScale as number) : 1;
|
||||
const rot = Number.isFinite(e.rotation) ? (e.rotation as number) : 0;
|
||||
const cos = Math.cos(rot);
|
||||
const sin = Math.sin(rot);
|
||||
// M = R · S (erst skalieren, dann rotieren).
|
||||
return {
|
||||
m00: cos * sx,
|
||||
m01: -sin * sy,
|
||||
m10: sin * sx,
|
||||
m11: cos * sy,
|
||||
tx: ip.x,
|
||||
ty: ip.y,
|
||||
zOffset: ip.z ?? 0,
|
||||
zScale: sz,
|
||||
};
|
||||
}
|
||||
|
||||
// Aufnahme-Kontext für ein Mapping-Durchlauf (Meshes + Konturen + Histogramm).
|
||||
interface Accum {
|
||||
meshTriangles: number[];
|
||||
meshIndices: number[];
|
||||
contours: Contour[];
|
||||
entityCounts: Record<string, number>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst eine DWG-Datei (als ArrayBuffer) in dasselbe Kontext-Geometrie-Format
|
||||
* wie der DXF-Parser. Lädt LibreDWG-WASM lazy. Wirft bei fatalen Fehlern (der
|
||||
* Aufrufer fängt das ab und zeigt den ODA-Fallback) — pro-Entity-Fehler nicht.
|
||||
*
|
||||
* Reale DWGs stecken Geometrie oft in BLÖCKEN, die nur per INSERT referenziert
|
||||
* werden; wir lesen die Block-Definitionen aus `db.tables.BLOCK_RECORD` und
|
||||
* expandieren jedes INSERT mit Transformation (rekursiv, Tiefen-Limit).
|
||||
*/
|
||||
export async function parseDwg(data: ArrayBuffer): Promise<DxfImportResult> {
|
||||
const libredwg = await createLibreDwg();
|
||||
@@ -131,63 +279,71 @@ export async function parseDwg(data: ArrayBuffer): Promise<DxfImportResult> {
|
||||
const db = libredwg.convert(dwgPtr);
|
||||
const entities = (db?.entities ?? []) as unknown as DwgEntityLike[];
|
||||
|
||||
const meshTriangles: number[] = [];
|
||||
const meshIndices: number[] = [];
|
||||
const contours: Contour[] = [];
|
||||
|
||||
for (const e of entities) {
|
||||
const type = (e.type ?? "").toUpperCase();
|
||||
switch (type) {
|
||||
case "3DFACE":
|
||||
addFace(meshTriangles, meshIndices, e);
|
||||
break;
|
||||
case "POLYLINE2D":
|
||||
case "POLYLINE3D":
|
||||
if (isPolyfaceMesh(e)) {
|
||||
addPolyfaceMesh(meshTriangles, meshIndices, e);
|
||||
} else {
|
||||
const ct = polylineContour(e);
|
||||
if (ct) contours.push(ct);
|
||||
}
|
||||
break;
|
||||
case "LWPOLYLINE": {
|
||||
const ct = polylineContour(e);
|
||||
if (ct) contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "LINE": {
|
||||
const ct = lineContour(e);
|
||||
if (ct) contours.push(ct);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Unbekannte/irrelevante Entity → ignorieren (tolerant).
|
||||
break;
|
||||
// Block-Definitionen (name → entities) für die INSERT-Expansion.
|
||||
const blockEntries = db?.tables?.BLOCK_RECORD?.entries ?? [];
|
||||
const blocks = new Map<string, DwgEntityLike[]>();
|
||||
for (const b of blockEntries) {
|
||||
if (b?.name && Array.isArray(b.entities)) {
|
||||
blocks.set(b.name, b.entities);
|
||||
}
|
||||
}
|
||||
|
||||
const acc: Accum = {
|
||||
meshTriangles: [],
|
||||
meshIndices: [],
|
||||
contours: [],
|
||||
entityCounts: {},
|
||||
total: 0,
|
||||
};
|
||||
|
||||
// Modellraum-Entities mappen (INSERTs expandieren Blöcke rekursiv).
|
||||
mapEntities(entities, IDENTITY, blocks, 0, acc);
|
||||
|
||||
const meshes: ImportedMesh[] = [];
|
||||
if (meshIndices.length > 0) {
|
||||
if (acc.meshIndices.length > 0) {
|
||||
meshes.push({
|
||||
id: nextId("imported"),
|
||||
type: "importedMesh",
|
||||
name: "DWG-Mesh",
|
||||
positions: meshTriangles,
|
||||
indices: meshIndices,
|
||||
positions: acc.meshTriangles,
|
||||
indices: acc.meshIndices,
|
||||
});
|
||||
}
|
||||
|
||||
const contourSets: ContourSet[] = [];
|
||||
if (contours.length > 0) {
|
||||
if (acc.contours.length > 0) {
|
||||
contourSets.push({
|
||||
id: nextId("contours"),
|
||||
type: "contourSet",
|
||||
name: "DWG-Konturen",
|
||||
contours,
|
||||
contours: acc.contours,
|
||||
});
|
||||
}
|
||||
|
||||
return { meshes, contours: contourSets };
|
||||
const diagnostics: ImportDiagnostics = {
|
||||
entityCounts: acc.entityCounts,
|
||||
total: acc.total,
|
||||
};
|
||||
|
||||
// Diagnose immer loggen; im „nichts gemappt"-Fall hervorheben (Histogramm
|
||||
// macht die Ursache sichtbar: 0 Entities = Encoding/Version vs. Abdeckungs-
|
||||
// lücke = z. B. „500 ARC, 200 INSERT").
|
||||
const mapped = meshes.length + contourSets.length;
|
||||
if (mapped === 0) {
|
||||
console.info(
|
||||
"[dwgParser] Keine Geometrie gemappt. Entity-Histogramm:",
|
||||
acc.entityCounts,
|
||||
`(${acc.total} Entities)`,
|
||||
);
|
||||
} else {
|
||||
console.info(
|
||||
"[dwgParser] Entity-Histogramm:",
|
||||
acc.entityCounts,
|
||||
`(${acc.total} Entities)`,
|
||||
);
|
||||
}
|
||||
|
||||
return { meshes, contours: contourSets, diagnostics };
|
||||
} finally {
|
||||
// Speicher der WASM-Instanz freigeben (auch im Fehlerfall).
|
||||
if (dwgPtr != null) {
|
||||
@@ -200,8 +356,106 @@ export async function parseDwg(data: ArrayBuffer): Promise<DxfImportResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappt eine Entity-Liste unter einer Transform in den Aufnahme-Kontext. INSERTs
|
||||
* werden über `blocks` aufgelöst und rekursiv (mit verketteter Transform) erneut
|
||||
* gemappt; `depth` begrenzt die Rekursion gegen zyklische Blöcke.
|
||||
*/
|
||||
function mapEntities(
|
||||
entities: DwgEntityLike[],
|
||||
xform: Transform2D,
|
||||
blocks: Map<string, DwgEntityLike[]>,
|
||||
depth: number,
|
||||
acc: Accum,
|
||||
): void {
|
||||
for (const e of entities) {
|
||||
const type = (e.type ?? "").toUpperCase();
|
||||
// Histogramm zählt JEDE betrachtete Entity (auch ignorierte/expandierte).
|
||||
acc.entityCounts[type] = (acc.entityCounts[type] ?? 0) + 1;
|
||||
acc.total += 1;
|
||||
|
||||
switch (type) {
|
||||
case "3DFACE":
|
||||
addFace(acc, e, xform);
|
||||
break;
|
||||
case "POLYLINE2D":
|
||||
case "POLYLINE3D":
|
||||
if (isPolyfaceMesh(e)) {
|
||||
addPolyfaceMesh(acc, e, xform);
|
||||
} else {
|
||||
const ct = polylineContour(e, xform);
|
||||
if (ct) acc.contours.push(ct);
|
||||
}
|
||||
break;
|
||||
case "LWPOLYLINE": {
|
||||
const ct = polylineContour(e, xform);
|
||||
if (ct) acc.contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "LINE": {
|
||||
const ct = lineContour(e, xform);
|
||||
if (ct) acc.contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "ARC": {
|
||||
const ct = arcContour(e, xform);
|
||||
if (ct) acc.contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "CIRCLE": {
|
||||
const ct = circleContour(e, xform);
|
||||
if (ct) acc.contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "ELLIPSE": {
|
||||
const ct = ellipseContour(e, xform);
|
||||
if (ct) acc.contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "SPLINE": {
|
||||
const ct = splineContour(e, xform);
|
||||
if (ct) acc.contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "INSERT": {
|
||||
if (depth >= MAX_BLOCK_DEPTH) break; // Zyklen-/Tiefenschutz.
|
||||
const blockEntities = e.name ? blocks.get(e.name) : undefined;
|
||||
if (!blockEntities || blockEntities.length === 0) break;
|
||||
const local = compose(xform, insertTransform(e));
|
||||
mapEntities(blockEntities, local, blocks, depth + 1, acc);
|
||||
break;
|
||||
}
|
||||
case "POINT":
|
||||
// POINT bewusst ignorieren (kein Polylinien-/Flächenbezug).
|
||||
break;
|
||||
default:
|
||||
// Unbekannte/irrelevante Entity → ignorieren (tolerant).
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mesh-Entities ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 3DFACE: 3 oder 4 Eckpunkte (corner1..4). Bei 4 ≠ 3 Punkten zwei Dreiecke (Fan).
|
||||
* Punkte werden mit der aktiven Transform in den Modellraum gebracht.
|
||||
*/
|
||||
function addFace(acc: Accum, e: DwgEntityLike, xform: Transform2D): void {
|
||||
const positions = acc.meshTriangles;
|
||||
const indices = acc.meshIndices;
|
||||
const corners = [e.corner1, e.corner2, e.corner3, e.corner4].filter(
|
||||
(c): c is DwgPoint => !!c && Number.isFinite(c.x) && Number.isFinite(c.y),
|
||||
);
|
||||
if (corners.length < 3) return;
|
||||
const idx = corners.map((c) => {
|
||||
const p = applyXY(xform, c.x, c.y);
|
||||
return pushVertex(positions, p.x, p.y, applyZ(xform, c.z ?? 0));
|
||||
});
|
||||
pushTri(indices, positions, idx[0], idx[1], idx[2]);
|
||||
if (idx.length >= 4) pushTri(indices, positions, idx[0], idx[2], idx[3]);
|
||||
}
|
||||
|
||||
/** Fügt einen Vertex zu positions hinzu und liefert seinen Index. */
|
||||
function pushVertex(
|
||||
positions: number[],
|
||||
@@ -214,25 +468,6 @@ function pushVertex(
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3DFACE: 3 oder 4 Eckpunkte (corner1..4). Bei 4 ≠ 3 Punkten zwei Dreiecke (Fan).
|
||||
*/
|
||||
function addFace(
|
||||
positions: number[],
|
||||
indices: number[],
|
||||
e: DwgEntityLike,
|
||||
): void {
|
||||
const corners = [e.corner1, e.corner2, e.corner3, e.corner4].filter(
|
||||
(c): c is DwgPoint => !!c && Number.isFinite(c.x) && Number.isFinite(c.y),
|
||||
);
|
||||
if (corners.length < 3) return;
|
||||
const idx = corners.map((c) =>
|
||||
pushVertex(positions, c.x, c.y, c.z ?? 0),
|
||||
);
|
||||
pushTri(indices, positions, idx[0], idx[1], idx[2]);
|
||||
if (idx.length >= 4) pushTri(indices, positions, idx[0], idx[2], idx[3]);
|
||||
}
|
||||
|
||||
/** Ob eine POLYLINE eine Polyface-Mesh-Variante ist (Flag 64 oder Polyface-Indizes). */
|
||||
function isPolyfaceMesh(e: DwgEntityLike): boolean {
|
||||
if (((e.flag ?? 0) & POLYFACE_FLAG) !== 0) return true;
|
||||
@@ -251,10 +486,12 @@ function isPolyfaceMesh(e: DwgEntityLike): boolean {
|
||||
* bare Kante Polyface-Indizes). Wir trennen beide und fan-triangulieren jede Face.
|
||||
*/
|
||||
function addPolyfaceMesh(
|
||||
positions: number[],
|
||||
indices: number[],
|
||||
acc: Accum,
|
||||
e: DwgEntityLike,
|
||||
xform: Transform2D,
|
||||
): void {
|
||||
const positions = acc.meshTriangles;
|
||||
const indices = acc.meshIndices;
|
||||
const all = e.vertices ?? [];
|
||||
const hasFaceIdx = (v: DwgVertexLike): boolean =>
|
||||
!!(
|
||||
@@ -267,7 +504,10 @@ function addPolyfaceMesh(
|
||||
const faceRecs = all.filter((v) => hasFaceIdx(v));
|
||||
if (geom.length < 3) return;
|
||||
const base = positions.length / 3;
|
||||
for (const v of geom) positions.push(v.x, v.y, v.z ?? 0);
|
||||
for (const v of geom) {
|
||||
const p = applyXY(xform, v.x, v.y);
|
||||
positions.push(p.x, p.y, applyZ(xform, v.z ?? 0));
|
||||
}
|
||||
for (const fr of faceRecs) {
|
||||
// 1-basierte Indizes; Vorzeichen markiert (un)sichtbare Kanten → abs().
|
||||
const ring = [
|
||||
@@ -308,26 +548,27 @@ function pushTri(
|
||||
/**
|
||||
* LWPOLYLINE / POLYLINE2D / POLYLINE3D → Kontur. Z-Höhe: erster gültiger
|
||||
* Vertex-Z, sonst `elevation`, sonst 0. `closed` aus Flag 1. < 2 Punkte → null.
|
||||
* Punkte werden mit der aktiven Transform abgebildet.
|
||||
*/
|
||||
function polylineContour(e: DwgEntityLike): Contour | null {
|
||||
function polylineContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
|
||||
const vs = e.vertices ?? [];
|
||||
const pts: Vec2[] = [];
|
||||
let zFromVertex: number | undefined;
|
||||
for (const v of vs) {
|
||||
if (!Number.isFinite(v.x) || !Number.isFinite(v.y)) continue;
|
||||
pts.push({ x: v.x, y: v.y });
|
||||
pts.push(applyXY(xform, v.x, v.y));
|
||||
if (zFromVertex === undefined && Number.isFinite(v.z)) zFromVertex = v.z;
|
||||
}
|
||||
if (pts.length < 2) return null;
|
||||
const z =
|
||||
const z0 =
|
||||
zFromVertex ??
|
||||
(Number.isFinite(e.elevation) ? (e.elevation as number) : 0);
|
||||
const closed = ((e.flag ?? 0) & CLOSED_FLAG) !== 0;
|
||||
return { z, pts, closed, layer: e.layer };
|
||||
return { z: applyZ(xform, z0), pts, closed, layer: e.layer };
|
||||
}
|
||||
|
||||
/** LINE → zweipunktige (offene) Kontur. Z aus dem Startpunkt (sonst 0). */
|
||||
function lineContour(e: DwgEntityLike): Contour | null {
|
||||
function lineContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
|
||||
const a = e.startPoint;
|
||||
const b = e.endPoint;
|
||||
if (
|
||||
@@ -340,14 +581,211 @@ function lineContour(e: DwgEntityLike): Contour | null {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const z = Number.isFinite(a.z) ? (a.z as number) : 0;
|
||||
const z0 = Number.isFinite(a.z) ? (a.z as number) : 0;
|
||||
return {
|
||||
z,
|
||||
pts: [
|
||||
{ x: a.x, y: a.y },
|
||||
{ x: b.x, y: b.y },
|
||||
],
|
||||
z: applyZ(xform, z0),
|
||||
pts: [applyXY(xform, a.x, a.y), applyXY(xform, b.x, b.y)],
|
||||
closed: false,
|
||||
layer: e.layer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ARC → offene Polylinien-Kontur. Wir tessellieren von startAngle nach endAngle
|
||||
* (CCW, normalisiert) in ~ARC_SEG_RAD-Schritten (min. ARC_MIN_SEGMENTS). Winkel
|
||||
* sind im Bogenmaß (libredwg liefert ARC-Winkel in Radiant).
|
||||
*/
|
||||
function arcContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
|
||||
const c = e.center;
|
||||
const r = e.radius;
|
||||
if (
|
||||
!c ||
|
||||
!Number.isFinite(c.x) ||
|
||||
!Number.isFinite(c.y) ||
|
||||
!Number.isFinite(r) ||
|
||||
(r as number) <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let a0 = Number.isFinite(e.startAngle) ? (e.startAngle as number) : 0;
|
||||
let a1 = Number.isFinite(e.endAngle) ? (e.endAngle as number) : 2 * Math.PI;
|
||||
// Sweep CCW normalisieren (a1 stets ≥ a0).
|
||||
while (a1 < a0) a1 += 2 * Math.PI;
|
||||
const sweep = a1 - a0;
|
||||
const segments = Math.max(
|
||||
ARC_MIN_SEGMENTS,
|
||||
Math.ceil(sweep / ARC_SEG_RAD),
|
||||
);
|
||||
const pts: Vec2[] = [];
|
||||
const radius = r as number;
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const ang = a0 + (sweep * i) / segments;
|
||||
const x = c.x + radius * Math.cos(ang);
|
||||
const y = c.y + radius * Math.sin(ang);
|
||||
pts.push(applyXY(xform, x, y));
|
||||
}
|
||||
const z0 = Number.isFinite(c.z) ? (c.z as number) : 0;
|
||||
return { z: applyZ(xform, z0), pts, closed: false, layer: e.layer };
|
||||
}
|
||||
|
||||
/** CIRCLE → geschlossene Polylinien-Kontur (CIRCLE_SEGMENTS Segmente). */
|
||||
function circleContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
|
||||
const c = e.center;
|
||||
const r = e.radius;
|
||||
if (
|
||||
!c ||
|
||||
!Number.isFinite(c.x) ||
|
||||
!Number.isFinite(c.y) ||
|
||||
!Number.isFinite(r) ||
|
||||
(r as number) <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const radius = r as number;
|
||||
const pts: Vec2[] = [];
|
||||
for (let i = 0; i < CIRCLE_SEGMENTS; i++) {
|
||||
const ang = (2 * Math.PI * i) / CIRCLE_SEGMENTS;
|
||||
const x = c.x + radius * Math.cos(ang);
|
||||
const y = c.y + radius * Math.sin(ang);
|
||||
pts.push(applyXY(xform, x, y));
|
||||
}
|
||||
const z0 = Number.isFinite(c.z) ? (c.z as number) : 0;
|
||||
return { z: applyZ(xform, z0), pts, closed: true, layer: e.layer };
|
||||
}
|
||||
|
||||
/**
|
||||
* ELLIPSE → Polylinien-Kontur. `majorAxisEndPoint` ist der Endpunkt der Haupt-
|
||||
* achse RELATIV zum Zentrum (WCS); `axisRatio` = Nebenachse/Hauptachse. start/
|
||||
* endAngle sind Parameter (0 … 2π bei Vollellipse). Geschlossen, wenn der Sweep
|
||||
* (nahezu) eine volle Umdrehung ist.
|
||||
*/
|
||||
function ellipseContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
|
||||
const c = e.center;
|
||||
const maj = e.majorAxisEndPoint;
|
||||
if (
|
||||
!c ||
|
||||
!maj ||
|
||||
!Number.isFinite(c.x) ||
|
||||
!Number.isFinite(c.y) ||
|
||||
!Number.isFinite(maj.x) ||
|
||||
!Number.isFinite(maj.y)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const ratio = Number.isFinite(e.axisRatio) ? (e.axisRatio as number) : 1;
|
||||
// Hauptachsen-Vektor (relativ) + dazu senkrechter Nebenachsen-Vektor.
|
||||
const ux = maj.x;
|
||||
const uy = maj.y;
|
||||
const vx = -uy * ratio;
|
||||
const vy = ux * ratio;
|
||||
let p0 = Number.isFinite(e.startAngle) ? (e.startAngle as number) : 0;
|
||||
let p1 = Number.isFinite(e.endAngle) ? (e.endAngle as number) : 2 * Math.PI;
|
||||
while (p1 < p0) p1 += 2 * Math.PI;
|
||||
const sweep = p1 - p0;
|
||||
const full = sweep >= 2 * Math.PI - 1e-6;
|
||||
const segments = Math.max(
|
||||
ARC_MIN_SEGMENTS,
|
||||
Math.ceil((ELLIPSE_SEGMENTS * sweep) / (2 * Math.PI)),
|
||||
);
|
||||
const pts: Vec2[] = [];
|
||||
// Bei Vollellipse Endpunkt = Startpunkt → letztes Segment auslassen (closed).
|
||||
const count = full ? segments : segments + 1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const param = p0 + (sweep * i) / segments;
|
||||
const ct = Math.cos(param);
|
||||
const st = Math.sin(param);
|
||||
const x = c.x + ux * ct + vx * st;
|
||||
const y = c.y + uy * ct + vy * st;
|
||||
pts.push(applyXY(xform, x, y));
|
||||
}
|
||||
if (pts.length < 2) return null;
|
||||
const z0 = Number.isFinite(c.z) ? (c.z as number) : 0;
|
||||
return { z: applyZ(xform, z0), pts, closed: full, layer: e.layer };
|
||||
}
|
||||
|
||||
/**
|
||||
* SPLINE → Polylinien-Kontur. Bevorzugt `fitPoints` (durch die der Spline läuft);
|
||||
* fehlen sie, tessellieren wir eine offene uniforme B-Spline über die
|
||||
* `controlPoints` (De-Boor mit Standard-Clamped-Knoten, Grad aus `degree`).
|
||||
* `closed` aus Flag 1 (geschlossener Spline). Z aus dem ersten Punkt.
|
||||
*/
|
||||
function splineContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
|
||||
const closed = ((e.flag ?? 0) & CLOSED_FLAG) !== 0;
|
||||
const fit = (e.fitPoints ?? []).filter(
|
||||
(p) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y),
|
||||
);
|
||||
let raw: Vec2[];
|
||||
let z0 = 0;
|
||||
if (fit.length >= 2) {
|
||||
raw = fit.map((p) => ({ x: p.x, y: p.y }));
|
||||
z0 = Number.isFinite(fit[0].z) ? (fit[0].z as number) : 0;
|
||||
} else {
|
||||
const ctrl = (e.controlPoints ?? []).filter(
|
||||
(p) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y),
|
||||
);
|
||||
if (ctrl.length < 2) return null;
|
||||
z0 = Number.isFinite(ctrl[0].z) ? (ctrl[0].z as number) : 0;
|
||||
const degree = Number.isFinite(e.degree)
|
||||
? Math.max(1, Math.min(ctrl.length - 1, e.degree as number))
|
||||
: Math.min(3, ctrl.length - 1);
|
||||
raw = tessellateBSpline(
|
||||
ctrl.map((p) => ({ x: p.x, y: p.y })),
|
||||
degree,
|
||||
SPLINE_SEGMENTS,
|
||||
);
|
||||
}
|
||||
if (raw.length < 2) return null;
|
||||
const pts = raw.map((p) => applyXY(xform, p.x, p.y));
|
||||
return { z: applyZ(xform, z0), pts, closed, layer: e.layer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Uniforme Clamped-B-Spline über `ctrl` (Grad `degree`) in `samples` gleichmäßige
|
||||
* Auswertungspunkte. Standard-Knotenvektor (clamped: Endknoten degree+1-fach).
|
||||
* Bewusst einfach; ausreichend für Kontext-/Snap-Geometrie. Liefert bei < degree+1
|
||||
* Kontrollpunkten einfach den Kontrollpolygonzug zurück.
|
||||
*/
|
||||
function tessellateBSpline(ctrl: Vec2[], degree: number, samples: number): Vec2[] {
|
||||
const n = ctrl.length - 1;
|
||||
if (n < degree) return ctrl.slice();
|
||||
// Clamped uniform knots: 0…0 (degree+1×), 1,2,…, m-degree-1, dann max (degree+1×).
|
||||
const knotCount = n + degree + 2;
|
||||
const knots: number[] = [];
|
||||
const interior = knotCount - 2 * (degree + 1);
|
||||
for (let i = 0; i < degree + 1; i++) knots.push(0);
|
||||
for (let i = 1; i <= interior; i++) knots.push(i / (interior + 1));
|
||||
for (let i = 0; i < degree + 1; i++) knots.push(1);
|
||||
const out: Vec2[] = [];
|
||||
for (let s = 0; s <= samples; s++) {
|
||||
// u im halboffenen [0,1); letztes Sample exakt 1.
|
||||
const u = s === samples ? 1 - 1e-9 : s / samples;
|
||||
out.push(deBoor(ctrl, knots, degree, u));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** De-Boor-Auswertung einer B-Spline an Parameter u (0…1). */
|
||||
function deBoor(ctrl: Vec2[], knots: number[], degree: number, u: number): Vec2 {
|
||||
// Knoten-Span k finden mit knots[k] <= u < knots[k+1].
|
||||
const n = ctrl.length - 1;
|
||||
let k = degree;
|
||||
while (k < n && knots[k + 1] <= u) k++;
|
||||
// Lokale Kontrollpunkte kopieren.
|
||||
const d: Vec2[] = [];
|
||||
for (let j = 0; j <= degree; j++) {
|
||||
const cp = ctrl[k - degree + j] ?? ctrl[ctrl.length - 1];
|
||||
d.push({ x: cp.x, y: cp.y });
|
||||
}
|
||||
for (let r = 1; r <= degree; r++) {
|
||||
for (let j = degree; j >= r; j--) {
|
||||
const i = k - degree + j;
|
||||
const denom = knots[i + degree - r + 1] - knots[i];
|
||||
const alpha = denom === 0 ? 0 : (u - knots[i]) / denom;
|
||||
d[j] = {
|
||||
x: (1 - alpha) * d[j - 1].x + alpha * d[j].x,
|
||||
y: (1 - alpha) * d[j - 1].y + alpha * d[j].y,
|
||||
};
|
||||
}
|
||||
}
|
||||
return d[degree];
|
||||
}
|
||||
|
||||
@@ -21,10 +21,25 @@
|
||||
import DxfParser from "dxf-parser";
|
||||
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
|
||||
|
||||
/**
|
||||
* Optionale Import-Diagnose: Histogramm der gelesenen Entity-Typen (Typ → Anzahl)
|
||||
* und die Gesamtzahl. Wird vor allem vom DWG-Parser gefüllt, damit beim Fall
|
||||
* „keine Geometrie gefunden" sichtbar wird, WORAN es liegt (z. B. „0 Entities" =
|
||||
* Encoding-/Versions-Problem vs. „500 ARC, 200 INSERT" = Abdeckungslücke).
|
||||
*/
|
||||
export interface ImportDiagnostics {
|
||||
/** Anzahl je (Modellraum-)Entity-Typ, inkl. expandierter Block-Entities. */
|
||||
entityCounts: Record<string, number>;
|
||||
/** Gesamtzahl betrachteter Entities. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze. */
|
||||
export interface DxfImportResult {
|
||||
meshes: ImportedMesh[];
|
||||
contours: ContourSet[];
|
||||
/** Optionale Diagnose (Entity-Typ-Histogramm); vom DWG-Parser gefüllt. */
|
||||
diagnostics?: ImportDiagnostics;
|
||||
}
|
||||
|
||||
// Lose getippte Sicht auf die dxf-parser-Ausgabe (das Paket liefert keine engen
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// `layerToCode` liefert (deterministisch) einen Code je
|
||||
// Layer-Name; unbekannte/leere Layer fallen auf `fallbackCode`.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Contour, ContourSet, Drawing2D, Drawing2DGeom } from "../model/types";
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// Gemeinsame Typen + Helfer für den Standort-Import (swisstopo & OSM).
|
||||
//
|
||||
// Beide Quellen liefern am Ende dasselbe: geschlossene/offene Polylinien in
|
||||
// LOKALEN Modell-Metern (bereits um die Origin verschoben, siehe lv95.ts),
|
||||
// kategorisiert nach Art (Gebäude/Strasse/Wasser/Grün). Die UI wandelt diese in
|
||||
// `ContourSet`-Kontext-Objekte pro Kategorie um und legt sie in `project.context`.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { GeoOrigin } from "./lv95";
|
||||
import type { ContextObject, Contour } from "../model/types";
|
||||
|
||||
/** Fachliche Kategorie einer importierten Kontext-Linie. */
|
||||
export type GeoCategory = "building" | "road" | "water" | "green";
|
||||
|
||||
/** Ein importiertes Polygon/Polylinie in lokalen Modell-Metern. */
|
||||
export interface GeoFeature {
|
||||
category: GeoCategory;
|
||||
/** Stützpunkte in lokalen Metern (x=Ost, y=Nord). */
|
||||
pts: { x: number; y: number }[];
|
||||
/** Geschlossener Ring (Gebäude/Wasser/Grün) vs. offene Linie (Strasse). */
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
/** Ergebnis eines Import-Laufs: Features + verwendete Origin. */
|
||||
export interface GeoImportResult {
|
||||
origin: GeoOrigin;
|
||||
features: GeoFeature[];
|
||||
}
|
||||
|
||||
/** Menschlicher Layer-/Objektname je Kategorie (deutsch, für die Kontext-Liste). */
|
||||
const CATEGORY_LABEL: Record<GeoCategory, string> = {
|
||||
building: "Gebäude",
|
||||
road: "Strassen",
|
||||
water: "Gewässer",
|
||||
green: "Grünflächen",
|
||||
};
|
||||
|
||||
/** Layer-Kennung (englischer Identifier) je Kategorie — für spätere Zuordnung. */
|
||||
const CATEGORY_LAYER: Record<GeoCategory, string> = {
|
||||
building: "context-buildings",
|
||||
road: "context-roads",
|
||||
water: "context-water",
|
||||
green: "context-green",
|
||||
};
|
||||
|
||||
/** Erzeugt eine (kollisionsarme) ID für ein neues Kontext-Objekt. */
|
||||
function contextId(prefix: string): string {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt importierte Features in `ContextObject`s (je Kategorie EIN
|
||||
* ContourSet). Die Konturen liegen auf z=0 (Grundriss-Kontext); geschlossene
|
||||
* Ringe (Gebäude/Wasser/Grün) werden als geschlossene Konturen markiert,
|
||||
* Strassen als offene Linienzüge. Der Aufrufer legt das Ergebnis über
|
||||
* `onAddContextObjects` in `project.context` ab.
|
||||
*/
|
||||
export function featuresToContextObjects(
|
||||
features: GeoFeature[],
|
||||
sourceLabel: string,
|
||||
): ContextObject[] {
|
||||
const byCat = new Map<GeoCategory, Contour[]>();
|
||||
for (const f of features) {
|
||||
if (f.pts.length < 2) continue;
|
||||
const list = byCat.get(f.category) ?? [];
|
||||
list.push({
|
||||
z: 0,
|
||||
pts: f.pts.map((p) => ({ x: p.x, y: p.y })),
|
||||
closed: f.closed,
|
||||
layer: CATEGORY_LAYER[f.category],
|
||||
});
|
||||
byCat.set(f.category, list);
|
||||
}
|
||||
|
||||
const objs: ContextObject[] = [];
|
||||
for (const [cat, contours] of byCat) {
|
||||
if (contours.length === 0) continue;
|
||||
objs.push({
|
||||
id: contextId(`geo-${cat}`),
|
||||
type: "contourSet",
|
||||
name: `${CATEGORY_LABEL[cat]} (${sourceLabel})`,
|
||||
layer: CATEGORY_LAYER[cat],
|
||||
contours,
|
||||
});
|
||||
}
|
||||
return objs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basis-URL des optionalen Geo-Proxys (openbureau-core `/geoproxy`). Wird für
|
||||
* Overpass zwingend benutzt (CORS unzuverlässig); für geo.admin nur als
|
||||
* Fallback. Konfiguration über `VITE_GEO_PROXY` (z. B. "/geoproxy" bei
|
||||
* gleichem Origin oder eine absolute URL auf den API-Server). Leer = kein Proxy.
|
||||
*/
|
||||
export function geoProxyBase(): string {
|
||||
const env = (import.meta as unknown as { env?: Record<string, string> }).env;
|
||||
return (env?.VITE_GEO_PROXY ?? "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verpackt eine absolute Upstream-URL für den Geo-Proxy. Ohne konfigurierten
|
||||
* Proxy wird die URL unverändert zurückgegeben (Direktabruf).
|
||||
*/
|
||||
export function viaProxy(url: string): string {
|
||||
const base = geoProxyBase();
|
||||
return base ? `${base}?u=${encodeURIComponent(url)}` : url;
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
// LV95 ↔ WGS84 Koordinaten-Transformation + Ursprungs-Verschiebung.
|
||||
//
|
||||
// Schweizer Landeskoordinaten (LV95 / EPSG:2056) liegen im Millionenbereich
|
||||
// (Ost ~2'600'000 m, Nord ~1'200'000 m). Für die numerische Genauigkeit im
|
||||
// CAD (float32 in three.js!) verschieben wir importierte Geometrie in die Nähe
|
||||
// des Modell-Ursprungs (0,0). Der einmal berechnete Offset wird mitgeführt, so
|
||||
// dass ALLE Objekte eines Imports dieselbe Verschiebung teilen und korrekt
|
||||
// zueinander liegen.
|
||||
//
|
||||
// Die Umrechnung LV95↔WGS84 verwendet die von swisstopo publizierten
|
||||
// Näherungsformeln („Bezugsrahmenwechsel und Kartenprojektionen", genauigkeit
|
||||
// im Meter-Bereich, für Kontext-Geometrie völlig ausreichend). Referenz-
|
||||
// Rundlauf (siehe Unit-Check im Report) < 1 m.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter
|
||||
// bzw. Dezimalgrad.
|
||||
|
||||
/** Geographische Koordinate (WGS84), Dezimalgrad. */
|
||||
export interface LatLon {
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
/** Schweizer Landeskoordinate LV95 (EPSG:2056), Meter. */
|
||||
export interface LV95 {
|
||||
/** Ostwert (E), ~2'600'000. */
|
||||
e: number;
|
||||
/** Nordwert (N), ~1'200'000. */
|
||||
n: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* WGS84 → LV95 (approximativ, swisstopo-Näherungsformeln).
|
||||
* Eingabe Dezimalgrad, Ausgabe Meter (E/N).
|
||||
*/
|
||||
export function wgs84ToLv95(lat: number, lon: number): LV95 {
|
||||
// Breite/Länge in Sexagesimalsekunden.
|
||||
const phi = lat * 3600;
|
||||
const lam = lon * 3600;
|
||||
// Hilfsgrößen relativ zum Bezugspunkt Bern (46°57'08.66" N, 7°26'22.50" E).
|
||||
const p = (phi - 169028.66) / 10000;
|
||||
const l = (lam - 26782.5) / 10000;
|
||||
const p2 = p * p;
|
||||
const l2 = l * l;
|
||||
|
||||
// Ostwert (Y in LV03 + 2'000'000 für LV95).
|
||||
const y =
|
||||
2600072.37 +
|
||||
211455.93 * l -
|
||||
10938.51 * l * p -
|
||||
0.36 * l * p2 -
|
||||
44.54 * l * l2;
|
||||
|
||||
// Nordwert (X in LV03 + 1'000'000 für LV95).
|
||||
const x =
|
||||
1200147.07 +
|
||||
308807.95 * p +
|
||||
3745.25 * l2 +
|
||||
76.63 * p2 -
|
||||
194.56 * l2 * p +
|
||||
119.79 * p * p2;
|
||||
|
||||
return { e: y, n: x };
|
||||
}
|
||||
|
||||
/**
|
||||
* LV95 → WGS84 (approximativ, swisstopo-Näherungsformeln).
|
||||
* Eingabe Meter (E/N), Ausgabe Dezimalgrad.
|
||||
*/
|
||||
export function lv95ToWgs84(e: number, n: number): LatLon {
|
||||
// Relativ zum Projektions-Ursprung, in 1'000'000-Einheiten.
|
||||
const y = (e - 2600000) / 1000000;
|
||||
const x = (n - 1200000) / 1000000;
|
||||
const y2 = y * y;
|
||||
const x2 = x * x;
|
||||
|
||||
// Breite/Länge in 10'000"-Einheiten (Näherungspolynome).
|
||||
const lam =
|
||||
2.6779094 +
|
||||
4.728982 * y +
|
||||
0.791484 * y * x +
|
||||
0.1306 * y * x2 -
|
||||
0.0436 * y * y2;
|
||||
|
||||
const phi =
|
||||
16.9023892 +
|
||||
3.238272 * x -
|
||||
0.270978 * y2 -
|
||||
0.002528 * x2 -
|
||||
0.0447 * y2 * x -
|
||||
0.014 * x * x2;
|
||||
|
||||
// Zurück in Dezimalgrad (Faktor 100/36).
|
||||
return {
|
||||
lat: (phi * 100) / 36,
|
||||
lon: (lam * 100) / 36,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Ursprungs-Verschiebung: der LV95-Punkt, der im Modell auf (0,0)
|
||||
* abgebildet wird. Alle importierten Objekte eines Imports teilen dieselbe
|
||||
* Origin, damit sie zueinander lagerichtig bleiben.
|
||||
*/
|
||||
export interface GeoOrigin {
|
||||
e: number;
|
||||
n: number;
|
||||
}
|
||||
|
||||
/** Erzeugt eine Origin aus einem LV95-Zentrum. */
|
||||
export function makeOrigin(center: LV95): GeoOrigin {
|
||||
return { e: center.e, n: center.n };
|
||||
}
|
||||
|
||||
/**
|
||||
* LV95 → lokale Modell-Meter (Ost→X, Nord→Y) relativ zur Origin. LV95 ist bereits
|
||||
* metrisch und (nahezu) nordorientiert, daher genügt eine reine Translation.
|
||||
*/
|
||||
export function lv95ToLocal(pt: LV95, origin: GeoOrigin): { x: number; y: number } {
|
||||
return { x: pt.e - origin.e, y: pt.n - origin.n };
|
||||
}
|
||||
|
||||
/** Lokale Modell-Meter → LV95 (Umkehrung von {@link lv95ToLocal}). */
|
||||
export function localToLv95(
|
||||
x: number,
|
||||
y: number,
|
||||
origin: GeoOrigin,
|
||||
): LV95 {
|
||||
return { e: x + origin.e, n: y + origin.n };
|
||||
}
|
||||
|
||||
/**
|
||||
* WGS84 → lokale Modell-Meter (über LV95 und die Origin). Praktisch für
|
||||
* OSM-Punkte (lon/lat), die zuerst nach LV95 projiziert und dann verschoben
|
||||
* werden — so teilen OSM- und swisstopo-Import exakt dasselbe Bezugssystem.
|
||||
*/
|
||||
export function wgs84ToLocal(
|
||||
lat: number,
|
||||
lon: number,
|
||||
origin: GeoOrigin,
|
||||
): { x: number; y: number } {
|
||||
return lv95ToLocal(wgs84ToLv95(lat, lon), origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut eine LV95-Bounding-Box (quadratisch) um ein Zentrum mit gegebenem Radius
|
||||
* (Meter). Reihenfolge [eMin, nMin, eMax, nMax].
|
||||
*/
|
||||
export function bboxAround(
|
||||
center: LV95,
|
||||
radius: number,
|
||||
): [number, number, number, number] {
|
||||
return [
|
||||
center.e - radius,
|
||||
center.n - radius,
|
||||
center.e + radius,
|
||||
center.n + radius,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt eine LV95-Bounding-Box in eine WGS84-Box (für Overpass:
|
||||
* [south, west, north, east]). Nutzt die vier Ecken und nimmt die Extremwerte,
|
||||
* damit die leichte Nord-Rotation der LV95-Projektion abgedeckt ist.
|
||||
*/
|
||||
export function lv95BboxToWgs84(
|
||||
bbox: [number, number, number, number],
|
||||
): { south: number; west: number; north: number; east: number } {
|
||||
const [eMin, nMin, eMax, nMax] = bbox;
|
||||
const corners: LatLon[] = [
|
||||
lv95ToWgs84(eMin, nMin),
|
||||
lv95ToWgs84(eMax, nMin),
|
||||
lv95ToWgs84(eMin, nMax),
|
||||
lv95ToWgs84(eMax, nMax),
|
||||
];
|
||||
return {
|
||||
south: Math.min(...corners.map((c) => c.lat)),
|
||||
west: Math.min(...corners.map((c) => c.lon)),
|
||||
north: Math.max(...corners.map((c) => c.lat)),
|
||||
east: Math.max(...corners.map((c) => c.lon)),
|
||||
};
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
// OpenStreetMap-Anbindung (Overpass API) für den Standort-Import.
|
||||
//
|
||||
// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen und wandelt
|
||||
// sie in Polylinien in LOKALEN Modell-Metern (über LV95 + Origin, siehe
|
||||
// lv95.ts). Overpass sendet je nach Mirror unzuverlässige CORS-Header, daher
|
||||
// läuft der Abruf über den openbureau-core Geo-Proxy (`viaProxy`), sofern
|
||||
// `VITE_GEO_PROXY` gesetzt ist — sonst Direktabruf gegen den CORS-fähigen
|
||||
// Mirror.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter.
|
||||
|
||||
import { lv95BboxToWgs84, makeOrigin, wgs84ToLocal } from "./lv95";
|
||||
import type { GeoOrigin, LV95 } from "./lv95";
|
||||
import { bboxAround } from "./lv95";
|
||||
import type { GeoCategory, GeoFeature } from "./geoContext";
|
||||
import { viaProxy } from "./geoContext";
|
||||
|
||||
// Direkter Mirror mit CORS `*` (Fallback, falls kein Proxy konfiguriert ist).
|
||||
const OVERPASS = "https://maps.mail.ru/osm/tools/overpass/api/interpreter";
|
||||
|
||||
/** Welche OSM-Kategorien der Aufrufer möchte. */
|
||||
export interface OsmSelection {
|
||||
buildings: boolean;
|
||||
roads: boolean;
|
||||
water: boolean;
|
||||
green: boolean;
|
||||
}
|
||||
|
||||
/** Ein Overpass-Element mit inline-Geometrie (`out geom`). */
|
||||
interface OverpassElement {
|
||||
type: "node" | "way" | "relation";
|
||||
id: number;
|
||||
tags?: Record<string, string>;
|
||||
geometry?: { lat: number; lon: number }[];
|
||||
members?: {
|
||||
type: string;
|
||||
role: string;
|
||||
geometry?: { lat: number; lon: number }[];
|
||||
}[];
|
||||
}
|
||||
|
||||
/** Baut die Overpass-QL-Abfrage aus der WGS84-Box + Auswahl. */
|
||||
function buildQuery(
|
||||
bbox: { south: number; west: number; north: number; east: number },
|
||||
sel: OsmSelection,
|
||||
): string {
|
||||
const b = `${bbox.south},${bbox.west},${bbox.north},${bbox.east}`;
|
||||
const parts: string[] = [];
|
||||
if (sel.buildings) parts.push(`way["building"](${b});`);
|
||||
if (sel.roads) parts.push(`way["highway"](${b});`);
|
||||
if (sel.water) {
|
||||
parts.push(`way["natural"="water"](${b});`);
|
||||
parts.push(`way["waterway"](${b});`);
|
||||
parts.push(`relation["natural"="water"](${b});`);
|
||||
}
|
||||
if (sel.green) {
|
||||
parts.push(`way["leisure"="park"](${b});`);
|
||||
parts.push(`way["landuse"~"grass|forest|meadow|recreation_ground"](${b});`);
|
||||
parts.push(`way["natural"="wood"](${b});`);
|
||||
}
|
||||
return `[out:json][timeout:40];(${parts.join("")});out geom;`;
|
||||
}
|
||||
|
||||
/** Ordnet ein Element anhand seiner Tags einer Kategorie zu (oder null). */
|
||||
function categorize(
|
||||
tags: Record<string, string> | undefined,
|
||||
sel: OsmSelection,
|
||||
): GeoCategory | null {
|
||||
if (!tags) return null;
|
||||
if (sel.buildings && tags.building) return "building";
|
||||
if (sel.water && (tags.natural === "water" || tags.waterway)) return "water";
|
||||
if (
|
||||
sel.green &&
|
||||
(tags.leisure === "park" ||
|
||||
tags.natural === "wood" ||
|
||||
/grass|forest|meadow|recreation_ground/.test(tags.landuse ?? ""))
|
||||
)
|
||||
return "green";
|
||||
if (sel.roads && tags.highway) return "road";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen). */
|
||||
function isClosedCategory(cat: GeoCategory): boolean {
|
||||
return cat !== "road";
|
||||
}
|
||||
|
||||
/**
|
||||
* OSM-Features für Zentrum + Radius (Meter) laden. `originIn` erlaubt es, exakt
|
||||
* dieselbe Origin wie ein vorheriger swisstopo-Import zu verwenden.
|
||||
*/
|
||||
export async function fetchOsm(
|
||||
center: LV95,
|
||||
radius: number,
|
||||
sel: OsmSelection,
|
||||
originIn?: GeoOrigin,
|
||||
): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> {
|
||||
const origin = originIn ?? makeOrigin(center);
|
||||
if (!sel.buildings && !sel.roads && !sel.water && !sel.green) {
|
||||
return { origin, features: [] };
|
||||
}
|
||||
|
||||
const bbox = lv95BboxToWgs84(bboxAround(center, radius));
|
||||
const query = buildQuery(bbox, sel);
|
||||
|
||||
const res = await fetch(viaProxy(OVERPASS), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: "data=" + encodeURIComponent(query),
|
||||
});
|
||||
if (!res.ok) throw new Error(`OSM-Abruf fehlgeschlagen (${res.status})`);
|
||||
const data = (await res.json()) as { elements?: OverpassElement[] };
|
||||
|
||||
const features: GeoFeature[] = [];
|
||||
const push = (
|
||||
geometry: { lat: number; lon: number }[] | undefined,
|
||||
cat: GeoCategory,
|
||||
) => {
|
||||
if (!geometry || geometry.length < 2) return;
|
||||
const pts = geometry.map((g) => wgs84ToLocal(g.lat, g.lon, origin));
|
||||
const closed = isClosedCategory(cat);
|
||||
if (closed && pts.length < 3) return;
|
||||
features.push({ category: cat, pts, closed });
|
||||
};
|
||||
|
||||
for (const el of data.elements ?? []) {
|
||||
const cat = categorize(el.tags, sel);
|
||||
if (!cat) continue;
|
||||
if (el.type === "way") {
|
||||
push(el.geometry, cat);
|
||||
} else if (el.type === "relation") {
|
||||
// Multipolygon: outer-Ringe als einzelne geschlossene Linien übernehmen.
|
||||
for (const m of el.members ?? []) {
|
||||
if (m.role === "outer" || m.role === "") push(m.geometry, cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { origin, features };
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// swisstopo / geo.admin-Anbindung für den Standort-Import.
|
||||
//
|
||||
// • geocode(query) — Ortssuche/Adresse → Kandidaten mit LV95-Koordinaten.
|
||||
// Nutzt api3.geo.admin.ch SearchServer (sendet CORS `*`,
|
||||
// daher Direktabruf; Proxy nur als Fallback).
|
||||
// • fetchBuildings() — Gebäude-GRUNDRISSE für eine LV95-Box → Ringe in
|
||||
// lokalen Modell-Metern. Nutzt den geo.admin
|
||||
// MapServer/identify auf `ch.swisstopo.vec25-gebaeude`
|
||||
// (liefert Polygon-`rings` in LV95, aus dem Browser
|
||||
// abrufbar). swissBUILDINGS3D-Kachel-Downloads (STAC)
|
||||
// sind für einen Live-Box-Query im Browser zu schwer und
|
||||
// werden bewusst NICHT verwendet.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter.
|
||||
|
||||
import { bboxAround, makeOrigin, wgs84ToLv95 } from "./lv95";
|
||||
import type { GeoOrigin, LV95 } from "./lv95";
|
||||
import type { GeoFeature } from "./geoContext";
|
||||
import { viaProxy } from "./geoContext";
|
||||
|
||||
const GEOADMIN = "https://api3.geo.admin.ch";
|
||||
|
||||
/** Ein Geocode-Treffer: Anzeigename + LV95-Position. */
|
||||
export interface GeocodeCandidate {
|
||||
label: string;
|
||||
lv95: LV95;
|
||||
}
|
||||
|
||||
/** Entfernt HTML-Auszeichnung aus den SearchServer-Labels. */
|
||||
function stripHtml(s: string): string {
|
||||
return s.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ortssuche (Adresse oder Ortsname) → bis zu `limit` Kandidaten mit
|
||||
* LV95-Koordinaten. Wirft bei Netzfehler.
|
||||
*/
|
||||
export async function geocode(
|
||||
query: string,
|
||||
limit = 8,
|
||||
): Promise<GeocodeCandidate[]> {
|
||||
const q = query.trim();
|
||||
if (!q) return [];
|
||||
const url =
|
||||
`${GEOADMIN}/rest/services/api/SearchServer` +
|
||||
`?searchText=${encodeURIComponent(q)}` +
|
||||
`&type=locations&sr=2056&limit=${limit}`;
|
||||
|
||||
const res = await fetch(viaProxy(url));
|
||||
if (!res.ok) throw new Error(`Geocode fehlgeschlagen (${res.status})`);
|
||||
const data = (await res.json()) as {
|
||||
results?: {
|
||||
attrs?: {
|
||||
label?: string;
|
||||
y?: number; // Ostwert (E) in sr=2056
|
||||
x?: number; // Nordwert (N) in sr=2056
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
const out: GeocodeCandidate[] = [];
|
||||
for (const r of data.results ?? []) {
|
||||
const a = r.attrs;
|
||||
if (!a) continue;
|
||||
// Bei sr=2056 liefert der SearchServer y=E, x=N (LV95). Fallback: aus lat/lon.
|
||||
let e = a.y;
|
||||
let n = a.x;
|
||||
if (
|
||||
e == null ||
|
||||
n == null ||
|
||||
!Number.isFinite(e) ||
|
||||
!Number.isFinite(n) ||
|
||||
e < 2_000_000
|
||||
) {
|
||||
if (a.lat != null && a.lon != null) {
|
||||
const p = wgs84ToLv95(a.lat, a.lon);
|
||||
e = p.e;
|
||||
n = p.n;
|
||||
}
|
||||
}
|
||||
if (e == null || n == null) continue;
|
||||
out.push({ label: stripHtml(a.label ?? q), lv95: { e, n } });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Esri-Polygon-Geometrie, wie sie identify bei returnGeometry=true liefert. */
|
||||
interface EsriPolygon {
|
||||
rings?: number[][][]; // [ring][point][x,y] in LV95
|
||||
}
|
||||
|
||||
/**
|
||||
* Gebäude-Grundrisse für ein Zentrum + Radius (Meter) laden.
|
||||
* Rückgabe: Ringe in LOKALEN Metern relativ zur (aus dem Zentrum abgeleiteten
|
||||
* oder übergebenen) Origin — plus die verwendete Origin, damit weitere Quellen
|
||||
* dasselbe Bezugssystem teilen.
|
||||
*/
|
||||
export async function fetchBuildings(
|
||||
center: LV95,
|
||||
radius: number,
|
||||
originIn?: GeoOrigin,
|
||||
): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> {
|
||||
const origin = originIn ?? makeOrigin(center);
|
||||
const [eMin, nMin, eMax, nMax] = bboxAround(center, radius);
|
||||
const geom = `${eMin},${nMin},${eMax},${nMax}`;
|
||||
|
||||
const url =
|
||||
`${GEOADMIN}/rest/services/api/MapServer/identify` +
|
||||
`?geometryType=esriGeometryEnvelope` +
|
||||
`&geometry=${geom}` +
|
||||
`&mapExtent=${geom}` +
|
||||
`&imageDisplay=500,500,96` +
|
||||
`&tolerance=0` +
|
||||
`&layers=all:ch.swisstopo.vec25-gebaeude` +
|
||||
`&returnGeometry=true&sr=2056&limit=200`;
|
||||
|
||||
const res = await fetch(viaProxy(url));
|
||||
if (!res.ok) throw new Error(`Gebäude-Abruf fehlgeschlagen (${res.status})`);
|
||||
const data = (await res.json()) as {
|
||||
results?: { geometry?: EsriPolygon }[];
|
||||
};
|
||||
|
||||
const features: GeoFeature[] = [];
|
||||
for (const r of data.results ?? []) {
|
||||
const rings = r.geometry?.rings;
|
||||
if (!rings) continue;
|
||||
for (const ring of rings) {
|
||||
if (ring.length < 3) continue;
|
||||
const pts = ring.map(([e, n]) => ({ x: e - origin.e, y: n - origin.n }));
|
||||
features.push({ category: "building", pts, closed: true });
|
||||
}
|
||||
}
|
||||
return { origin, features };
|
||||
}
|
||||
Reference in New Issue
Block a user