diff --git a/src/App.tsx b/src/App.tsx index 9fb0b6e..e6e16ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3534,6 +3534,9 @@ export default function App() { onAddContextObjects: addContextObjectsAndFit, onRemoveContextObject: removeContextObject, onGenerateTerrain: generateTerrain, + onSetGeoAnchor: (anchor) => { + setProject((p) => ({ ...p, geoAnchor: anchor })); + }, onImportDxf: () => dxfInputRef.current?.click(), // ── Element-Baum (Elemente-Panel) ──────────────────────────────────── onSelectScheduleRow: ( diff --git a/src/i18n/de.ts b/src/i18n/de.ts index bd4893f..c467e29 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -1275,6 +1275,9 @@ export const de = { "site.importLocation": "Standort importieren", "ctxImport.title": "Standort importieren", "ctxImport.close": "Schließen", + "ctxImport.geoAnchor": "Georeferenziert ab: {label}", + "ctxImport.geoAnchor.reset": "Zurücksetzen", + "ctxImport.geoAnchor.reset.hint": "Löscht den Standort-Bezug — der nächste Import setzt ihn neu.", "ctxImport.location": "Ort / Adresse", "ctxImport.searchPlaceholder": "z. B. Bundesplatz Bern", "ctxImport.search": "Suchen", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 5b6b776..fe1626a 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1263,6 +1263,9 @@ export const en: Record = { "site.importLocation": "Import location", "ctxImport.title": "Import location", "ctxImport.close": "Close", + "ctxImport.geoAnchor": "Georeferenced from: {label}", + "ctxImport.geoAnchor.reset": "Reset", + "ctxImport.geoAnchor.reset.hint": "Clears the location reference — the next import sets it again.", "ctxImport.location": "Place / address", "ctxImport.searchPlaceholder": "e.g. Bundesplatz Bern", "ctxImport.search": "Search", diff --git a/src/model/types.ts b/src/model/types.ts index 680ca84..bca1c13 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -2170,6 +2170,25 @@ export interface Project { * Feld noch nicht. */ referenceElevationMasl?: number; + /** + * Georeferenzierung: verbindet EINEN Modell-Punkt (lokale Meter) mit seiner + * realen LV95-Koordinate. Wird beim ERSTEN Standort-Import automatisch + * gesetzt (Modell-Position per Default (0,0), editierbar) und danach für + * ALLE weiteren Importe (Gebäude/Terrain/OSM) wiederverwendet — vorher + * berechnete jeder Import unabhängig einen neuen Bezug aus der jeweils + * gesuchten Adresse, wodurch mehrere Importe lagefalsch zueinander landen + * konnten, sobald nicht exakt derselbe Suchbegriff verwendet wurde. + * `label` ist die Anzeige-Adresse (rein informativ). Optional, damit + * bestehende Projekte ohne Bezug gültig bleiben. + */ + geoAnchor?: { + /** Reale Landeskoordinate (LV95/EPSG:2056, Meter). */ + lv95: { e: number; n: number }; + /** Zugehöriger Punkt im Modell (lokale Meter), Default (0,0). */ + model: Vec2; + /** Anzeige-Adresse/Bezeichnung des Bezugspunkts. */ + label?: string; + }; } // ── Helfer ─────────────────────────────────────────────────────────────── diff --git a/src/panels/SitePanel.tsx b/src/panels/SitePanel.tsx index 89c9e26..172e9da 100644 --- a/src/panels/SitePanel.tsx +++ b/src/panels/SitePanel.tsx @@ -82,6 +82,8 @@ export function SitePanel() { host.onAddContextObjects(objs)} onClose={() => setCtxImportOpen(false)} + geoAnchor={host.project.geoAnchor} + onSetGeoAnchor={host.onSetGeoAnchor} /> )} diff --git a/src/panels/host.ts b/src/panels/host.ts index abb51b5..806e682 100644 --- a/src/panels/host.ts +++ b/src/panels/host.ts @@ -411,6 +411,14 @@ export interface PanelHostValue { * TerrainMesh-ID oder null (unbekannte ID / kein sinnvolles TIN). */ onGenerateTerrain: (contourSetId: string) => string | null; + /** + * Setzt/ersetzt den Georeferenzierungs-Bezug (`Project.geoAnchor`) — welcher + * Modell-Punkt welcher realen LV95-Koordinate entspricht. `undefined` löscht + * den Bezug (nächster Import setzt ihn automatisch neu). + */ + onSetGeoAnchor: ( + anchor: { lv95: { e: number; n: number }; model: { x: number; y: number }; label?: string } | undefined, + ) => void; /** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */ onImportDxf: () => void; diff --git a/src/styles.css b/src/styles.css index 77d5d23..bb88601 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4630,6 +4630,31 @@ body { text-overflow: ellipsis; white-space: nowrap; } +.cim-geo-anchor { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + border-radius: 6px; + background: var(--panel-2); + border: 1px solid var(--line); + font-size: 11px; + color: var(--ink-2); +} +.imp-linklike { + background: none; + border: none; + padding: 0; + color: var(--accent); + cursor: pointer; + font-size: 11px; + text-decoration: underline; + flex: 0 0 auto; +} +.imp-linklike:hover { + opacity: 0.8; +} .imp-section { display: flex; flex-direction: column; diff --git a/src/ui/ContextImportDialog.tsx b/src/ui/ContextImportDialog.tsx index 8d3d736..c127fb5 100644 --- a/src/ui/ContextImportDialog.tsx +++ b/src/ui/ContextImportDialog.tsx @@ -29,10 +29,22 @@ import type { ContextObject } from "../model/types"; * swissBUILDINGS3D-Geometrie (Wände+Dach) in einer der zwei Generationen. */ export type SwissBuildingsMode = "off" | "flach" | "v2" | "v3"; +/** Georeferenzierungs-Bezug: welcher Modell-Punkt entspricht welcher realen + * LV95-Koordinate (s. `Project.geoAnchor`). */ +export interface GeoAnchor { + lv95: { e: number; n: number }; + model: { x: number; y: number }; + label?: string; +} + export interface ContextImportDialogProps { /** Übernimmt die fertigen Kontext-Objekte (nach erfolgreichem Import). */ onImport: (objs: ContextObject[]) => void; onClose: () => void; + /** Aktueller Georeferenzierungs-Bezug des Projekts (falls schon gesetzt). */ + geoAnchor?: GeoAnchor; + /** Setzt/ersetzt (`undefined` löscht) den Georeferenzierungs-Bezug. */ + onSetGeoAnchor: (anchor: GeoAnchor | undefined) => void; } type Sources = { @@ -64,6 +76,8 @@ const DEFAULT_SOURCES: Sources = { export function ContextImportDialog({ onImport, onClose, + geoAnchor, + onSetGeoAnchor, }: ContextImportDialogProps) { useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -130,7 +144,13 @@ export function ContextImportDialog({ setStatus(t("ctxImport.working")); try { const center = selected.lv95; - let origin: GeoOrigin | undefined; + // Bestehenden Georeferenzierungs-Bezug wiederverwenden, statt bei jedem + // Import einen neuen (inkonsistenten) zu berechnen — der Bezugspunkt + // bleibt fix im Modell, nur die GESUCHTE Stelle (center/radius) steuert, + // WOVON Daten geladen werden (s. Project.geoAnchor-Doc). + let origin: GeoOrigin | undefined = geoAnchor + ? { e: geoAnchor.lv95.e - geoAnchor.model.x, n: geoAnchor.lv95.n - geoAnchor.model.y } + : undefined; const all: GeoFeature[] = []; // Echte swissBUILDINGS3D-Meshes (v2/v3) landen direkt als ContextObject // (kein Footprint+Box-Umweg über GeoFeature/featuresToContextObjects). @@ -200,6 +220,13 @@ export function ContextImportDialog({ return; } + // Erster Import in diesem Projekt (noch kein Bezug) -> DIESEN Lauf als + // Georeferenzierungs-Bezug speichern (Modell-(0,0) = der gesuchte Ort), + // damit künftige Importe lagerichtig zueinander bleiben. + if (!geoAnchor && origin) { + onSetGeoAnchor({ lv95: { e: origin.e, n: origin.n }, model: { x: 0, y: 0 }, label: selected.label }); + } + const objs = featuresToContextObjects(all, selected.label); objs.push(...extraObjs); if (terrain) objs.push(terrain); @@ -246,6 +273,21 @@ export function ContextImportDialog({
+ {/* Georeferenzierungs-Bezug: sobald gesetzt, teilen sich ALLE Importe + dieses Projekts denselben Modell↔LV95-Bezug (s. Project.geoAnchor). */} + {geoAnchor && ( +
+ {t("ctxImport.geoAnchor", { label: geoAnchor.label ?? "?" })} + +
+ )} {/* Ortssuche */}
{t("ctxImport.location")}