Georeferenzierung: persistenter Standort-Bezug statt Neuberechnung je Import

Neues Project.geoAnchor: verbindet EINEN Modell-Punkt mit seiner realen
LV95-Koordinate. Bisher berechnete jeder Standort-Import (Gebäude/Terrain/
OSM) unabhängig einen neuen Bezug aus der jeweils gesuchten Adresse — bei
mehreren Importen mit leicht unterschiedlichen Suchbegriffen landete
importierter Kontext lagefalsch zueinander.

Der erste Import in einem Projekt setzt den Bezug automatisch (Modell-(0,0)
= gesuchter Ort) und speichert ihn; alle weiteren Importe verwenden densel-
ben Bezug, unabhängig vom neu gesuchten Ort (der bestimmt nur noch WOVON
Daten geladen werden, nicht mehr WOHIN sie im Modell platziert werden).
UI: Anzeige des aktiven Bezugs im Import-Dialog mit Zurücksetzen-Option.

Löst das strukturelle Problem noch nicht vollständig (der Bezug sitzt immer
bei Modell-(0,0) — passt nur, wenn das eigene Gebäude dort gezeichnet ist),
aber behebt die akute Inkonsistenz zwischen mehreren Importen.
This commit is contained in:
2026-07-12 19:33:16 +02:00
parent ee0f1dfb07
commit ae47b4f024
8 changed files with 106 additions and 1 deletions
+3
View File
@@ -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: (
+3
View File
@@ -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",
+3
View File
@@ -1263,6 +1263,9 @@ export const en: Record<TranslationKey, string> = {
"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",
+19
View File
@@ -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 ───────────────────────────────────────────────────────────────
+2
View File
@@ -82,6 +82,8 @@ export function SitePanel() {
<ContextImportDialog
onImport={(objs) => host.onAddContextObjects(objs)}
onClose={() => setCtxImportOpen(false)}
geoAnchor={host.project.geoAnchor}
onSetGeoAnchor={host.onSetGeoAnchor}
/>
)}
+8
View File
@@ -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;
+25
View File
@@ -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;
+43 -1
View File
@@ -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({
</header>
<div className="imp-body">
{/* Georeferenzierungs-Bezug: sobald gesetzt, teilen sich ALLE Importe
dieses Projekts denselben Modell↔LV95-Bezug (s. Project.geoAnchor). */}
{geoAnchor && (
<div className="cim-geo-anchor">
<span>{t("ctxImport.geoAnchor", { label: geoAnchor.label ?? "?" })}</span>
<button
type="button"
className="imp-linklike"
onClick={() => onSetGeoAnchor(undefined)}
title={t("ctxImport.geoAnchor.reset.hint")}
>
{t("ctxImport.geoAnchor.reset")}
</button>
</div>
)}
{/* Ortssuche */}
<section className="imp-section">
<div className="imp-section-title">{t("ctxImport.location")}</div>