Commit a38c00b7 authored by Alfakhori's avatar Alfakhori
Browse files

geolocation fixes

parent c9a3a559
Pipeline #12494 passed with stage
in 26 seconds
# GPS localization and calibration implementation plan
## Audited baseline
- The active app is bootstrapped from `public/ar_app.js`; XR startup and the renderer loop live in `public/ar_main.js`.
- `activateXR()` requests the existing `immersive-ar` session with required `hit-test`, creates `local` and `viewer` reference spaces, creates a hit-test source, then starts the renderer animation loop.
- Geolocation currently uses one `getCurrentPosition()` call with high accuracy, a 2-second cache age, and a 5-second timeout. Its rounded latitude/longitude become `geoLocation`.
- `public/ar_overviewmap.js` owns the local tangent-plane equations used by `leafletToThree()` and `threeToLeaflet()`. Those equations are to remain unchanged.
- Loaded models use each saved model's `lat`/`lng` through `leafletToThree()`, preserving its saved Y, rotation, and scale. The predefined scene JSON files use the existing `{lat, lng, models}` schema.
- Models are currently direct `scene` children, alongside lights and the reticle. The reticle is positioned by the existing XR hit-test loop.
- `public/ar_download.js` serializes each model position through `threeToLeaflet()` and preserves the current scene JSON fields.
- `public/ar_diagnostics.js` already contains an `XR_DIAGNOSTICS_ENABLED` switch, error and unhandled-rejection listeners, FPS sampling, and a one-second panel refresh. In the audited working tree the flag is currently `true`; the requested default-off behavior will be restored while extending its data.
- The current app menu and edit controls are built in `public/index.html` and `public/ar_main.js`; no scene-specific calibration persistence exists.
## Implementation steps
1. Add a small GPS localization module that samples with `watchPosition()`, rejects fixes outside configurable accuracy bounds, evaluates spatial consistency, and resolves to a stable fix or a time-bounded best available fix. Record the chosen fix's timestamp, accuracy, count, spread, and selection source. Clear the watch on completion. Never use later fixes to reposition the scene.
2. Integrate the bounded localization step after the existing XR session/reference-space/hit-source setup. Keep the current XR startup gesture and render loop. Show short-lived localization progress and outcomes in a small transient status element; do not repurpose or replace the existing app menus.
3. Add one model-only group with an absolute X/Z calibration offset initialized to zero. Keep the reticle and lights in the existing scene. Adapt model enumeration, ray selection, deletion, and saving so existing model edit behavior continues to work. Do not modify model source positions when applying calibration.
4. Add manual calibration controls in the existing Options/dynamic-menu flow. Adjust the model-group offset in small X/Z increments, support reset and confirm, and keep the setting session-only. The source and predefined scene JSON remain unchanged. For downloads, derive geographic locations from each model's final world position, while retaining the current schema and conversion equations.
5. Extend optional diagnostics through the existing single flag. Show GPS acquisition/quality and calibration/base/final offsets alongside current XR, frame, camera, model, and error state. Preserve throttled panel updates and avoid per-frame console output.
6. Write `GPS_LOCALIZATION.md` describing the implemented sampling/fallback behavior, tuning configuration, calibration controls, and Android device-validation steps.
7. Run syntax/static checks and inspect the final diff. Confirm scene JSON, model URLs/catalog, coordinate equations, Leaflet code/behavior, and WebXR session/render architecture remain unchanged except for the localized hooks described above.
## Guardrails
- No continuous GPS-driven scene movement after initial localization.
- No schema, model URL/catalog, coordinate-equation, Leaflet, or XR architecture changes.
- No calibration baked into the original model position or JSON files.
- No per-frame diagnostic console logging.
- Manual Android/iOS device testing will be reported as unperformed unless actually carried out.
## Rotation calibration addendum (audited 2026-10-06)
### Current transform and pivot
- `public/ar_main.js` creates `placedModelsRoot` as a child of the Three.js scene. It has no explicit transform, so its pivot and position are `(0, 0, 0)`.
- Scene model X/Z coordinates are produced by `leafletToThree()` relative to `geoLocation`; that makes the root origin the geographic tangent-plane origin.
- The reticle, lights, camera, and WebXR render loop remain outside `placedModelsRoot`. All catalog models are placed beneath it.
- Root translation is assigned absolutely from `calibrationOffset`, and the map/download paths already use model world positions to account for that translation.
- Newly placed models currently compensate only for root translation before parenting, so adding rotation also requires inverse rotation of the reticle position.
- Scene downloads currently write world position but local Euler rotation. To retain the visible scene after reload, downloads must serialize each model's world-space Euler orientation as well as world position; the existing JSON field names stay the same.
### Rotation implementation steps
1. Extend the session calibration state with `rotationY` in radians and a configurable 5-degree button increment. Every adjustment assigns the root's Y rotation from state; never rotate models individually or accumulate edits in their coordinates.
2. Preserve the pivot at the existing geographic root origin. Apply the root transform deterministically as translation `(x, 0, z)` and Y rotation `rotationY`; keep the reticle, lights, camera, and XR session unchanged.
3. Add accessible, touch-sized left/right rotation controls to the current dynamic calibration menu. Keep existing translation, confirm, and reset controls. Reset assigns all calibration state and root transforms to zero; confirm remains session-only.
4. For new hit-test placements, inverse-transform the reticle's scene position through the full root transform before parenting so the model's resulting world position still equals the reticle.
5. Keep map and download positions based on world coordinates. Serialize world orientation for download using a world quaternion converted to an Euler using the existing model order, preserving the scene JSON schema and leaving calibration out of JSON.
6. Extend the current diagnostic calibration section with X/Y/Z and Y rotation in degrees. Keep the existing diagnostics flag and one-second display refresh.
7. Run JavaScript syntax checks and `git diff --check`; review that scene JSON, coordinate equations, model URLs/catalog, and WebXR session/render code remain untouched.
# GPS localization and manual calibration
## Runtime behavior
The app starts GPS sampling after the user starts the existing WebXR session. It uses `watchPosition()` to collect fixes and uses `coords.accuracy` to weight the candidate location. A fix must have finite coordinates and positive accuracy; samples worse than the configured maximum are excluded from the stable cluster. The app selects the largest cluster whose samples are within the configured horizontal radius, rejecting isolated outliers.
The current settings are in `public/ar_geolocation.js`:
| Setting | Current value | Meaning |
| --- | ---: | --- |
| `targetAccuracyMeters` | 15 m | Maximum weighted cluster accuracy for a stable result |
| `maximumAccuracyMeters` | 35 m | Worst accuracy admitted to the cluster |
| `stableClusterRadiusMeters` | 12 m | Maximum distance between clustered fixes; also caps reported spread |
| `requiredStableSamples` | 4 | Minimum consistent fixes before accepting a stable result |
| `maximumWaitMs` | 18 s | Maximum acquisition time before using a fallback or reporting failure |
| `maximumAgeMs` | 1.5 s | Browser cache age requested for each watch reading |
Each accepted sample retains its coordinates, accuracy, and browser timestamp during selection. The chosen `geoLocation` includes rounded latitude/longitude plus accuracy, newest sample timestamp, sample count, spread, source (`stable cluster` or `timeout fallback`), and status (`STABLE` or `APPROXIMATE`). The existing five-decimal origin precision and coordinate conversion functions are unchanged.
At timeout, the app uses the best available in-range cluster (or the best usable fix) and labels it approximate. With no usable fix, the app leaves the scene unloaded, reports the failure, and offers **Erneut versuchen**. Once a location is selected, the watch is cleared. Later GPS updates never move models or change the map origin; movement is then owned by WebXR local tracking.
## Manual calibration
Open **Optionen → Position kalibrieren**. Choose a translation step from 0.10 m to 1.00 m, then adjust the model group along X or Z. Rotate the group around vertical Y with the left/right rotation buttons in 5° increments. Translation and rotation can be interleaved; each action reapplies the current complete transform to the model root. The root pivot is the geographic tangent-plane origin at local `(0, 0, 0)`. Models move and rotate together; the reticle, lights, and XR tracking remain in their existing coordinate space. **Kalibrierung zurücksetzen** restores zero translation and rotation. **Kalibrierung übernehmen** marks the current session transform as accepted. Calibration remains in memory for this XR session and is not written into source or predefined scene JSON.
When a scene is downloaded, each model's final world-space position is converted through the existing `threeToLeaflet()` function, and its world-space orientation is saved in the existing rotation fields. The saved model `lat`/`lng` therefore reflect the calibrated placement. The JSON fields and coordinate equations are unchanged. Reloading places the calibrated positions and orientations without reapplying the session transform. The overview map also uses final world-space model positions.
## Optional device diagnostics
In `public/ar_diagnostics.js`, set `XR_DIAGNOSTICS_ENABLED` to `true` for a temporary validation build and return it to `false` afterward. With the flag enabled, the panel refreshes once per second and includes XR/WebXR support, session and reference-space state, hit-test and reticle state, renderer activity/FPS, model count, camera, GPS status/accuracy/sample count/spread/source/timestamp/base coordinates, calibration base/final X/Z, JavaScript errors, and unhandled promise rejections. It does not write a console entry for each XR frame. With the flag false, the panel, timer, and diagnostic event listeners are not created.
## Android Chrome validation
Use an ARCore-capable Android phone, current Chrome, an HTTPS deployment, and a textured horizontal surface. Grant camera and location access. Keep the phone stationary while GPS is acquiring, then test:
1. **Stable acquisition:** Start AR outdoors with good satellite reception. Expected: the status shows acquisition progress, then `Standort stabil` after at least four consistent fixes. Diagnostics show `GPS STATUS: STABLE`, a sample count of at least four, finite accuracy/spread, source `stable cluster`, and a timestamp.
2. **Outlier rejection:** While acquiring, briefly move between open sky and a location-obstructed area, or compare runs with inconsistent readings. Expected: isolated fixes outside the 12 m cluster do not count toward the accepted cluster; the app waits for four consistent samples or uses the labeled fallback at 18 seconds.
3. **Timeout fallback:** Test where GPS readings are slow but at least one usable fix arrives. Expected: acquisition ends by 18 seconds, the status says `Standort angenähert`, diagnostics report `APPROXIMATE` and `timeout fallback`, and scene models load once.
4. **Permission denied / unavailable:** Deny location permission and start AR. Expected: no scene is loaded from an unknown origin, the user sees a localized error and retry button, and diagnostics show GPS error without stopping the XR render loop.
5. **No GPS-driven drift:** After localization, walk several meters while viewing an existing model. Expected: the model remains fixed in the room as the phone moves; `GPS BASE` and saved origin do not change during that session.
6. **Translation and rotation controls:** Open calibration and adjust X+, X−, Z+, and Z− independently at 0.25 m; then rotate left and right in 5° steps. Expected: all placed models translate together and rotate together around the geographic root origin on Y only; the reticle behavior is unchanged. A newly placed model remains at the reticle. Diagnostics show signed X/Y/Z and Y rotation in degrees. Reset returns translation and rotation to zero; confirm retains the current transform for the session.
7. **Map and saved scene:** Open the overview map after applying translation and rotation, then download and reload the scene. Expected: the marker matches the displayed calibrated model; downloaded `lat`/`lng` and model rotations represent final world positions/orientations, and reloading does not apply the previous session transform a second time.
8. **Diagnostics off:** Set the flag to `false` and reload. Expected: no diagnostic panel, polling timer, or diagnostic listeners; localization, calibration, and the existing UI still work.
Record the phone model, Android and Chrome versions, site URL, GPS status/accuracy/spread, and pass/fail for each run. This checklist is preparation only; no physical-device result should be inferred from desktop/static checks.
import * as THREE from "three"; import * as THREE from "three";
export const XR_DIAGNOSTICS_ENABLED = true; // Set to false to disable the diagnostics panel export const XR_DIAGNOSTICS_ENABLED = true; // Set to true temporarily for device validation
const SESSION_END_KEY = "gengenbach.xrDiagnostics.sessionEnded"; const SESSION_END_KEY = "gengenbach.xrDiagnostics.sessionEnded";
const REFRESH_INTERVAL_MS = 1000; const REFRESH_INTERVAL_MS = 1000;
...@@ -22,6 +22,14 @@ const state = { ...@@ -22,6 +22,14 @@ const state = {
modelCount: 0, modelCount: 0,
camera: "NOT INITIALIZED", camera: "NOT INITIALIZED",
geolocation: "WAITING", geolocation: "WAITING",
gpsStatus: "WAITING",
gpsBase: "--",
gpsAccuracy: null,
gpsSampleCount: 0,
gpsSpread: null,
gpsSource: "--",
gpsTimestamp: null,
calibration: { x: 0, y: 0, z: 0, rotationY: 0, confirmed: false },
events: [], events: [],
}; };
...@@ -43,6 +51,11 @@ function addEvent(type, detail) { ...@@ -43,6 +51,11 @@ function addEvent(type, detail) {
renderPanel(); renderPanel();
} }
function signed(value, digits) {
const rounded = Number(value).toFixed(digits);
return Number(value) >= 0 ? "+" + rounded : rounded;
}
function renderPanel(now = performance.now()) { function renderPanel(now = performance.now()) {
if (!panel) return; if (!panel) return;
...@@ -56,6 +69,13 @@ function renderPanel(now = performance.now()) { ...@@ -56,6 +69,13 @@ function renderPanel(now = performance.now()) {
const fpsText = state.fps === null ? "--" : String(state.fps); const fpsText = state.fps === null ? "--" : String(state.fps);
const hitCountText = const hitCountText =
state.hitResultCount === null ? "--" : String(state.hitResultCount); state.hitResultCount === null ? "--" : String(state.hitResultCount);
const gpsAccuracyText =
state.gpsAccuracy === null ? "--" : state.gpsAccuracy.toFixed(1) + " m";
const gpsSpreadText =
state.gpsSpread === null ? "--" : state.gpsSpread.toFixed(1) + " m";
const gpsTimestampText = state.gpsTimestamp
? new Date(state.gpsTimestamp).toISOString()
: "--";
panel.textContent = [ panel.textContent = [
"XR DEVICE DIAGNOSTICS", "XR DEVICE DIAGNOSTICS",
...@@ -72,6 +92,24 @@ function renderPanel(now = performance.now()) { ...@@ -72,6 +92,24 @@ function renderPanel(now = performance.now()) {
"MODELS: " + state.modelCount, "MODELS: " + state.modelCount,
"CAMERA: " + state.camera, "CAMERA: " + state.camera,
"GEOLOCATION: " + state.geolocation, "GEOLOCATION: " + state.geolocation,
"GPS STATUS: " + state.gpsStatus,
"GPS ACCURACY: " + gpsAccuracyText,
"GPS SAMPLES: " + state.gpsSampleCount + " | SPREAD: " + gpsSpreadText,
"GPS SOURCE / TIME: " + state.gpsSource + " / " + gpsTimestampText,
"GPS BASE: " + state.gpsBase,
"CALIBRATION BASE: X +0.00 m | Y +0.00 m | Z +0.00 m",
"CALIBRATION FINAL: X " +
signed(state.calibration.x, 2) +
" m | Y " +
signed(state.calibration.y, 2) +
" m | Z " +
signed(state.calibration.z, 2) +
" m",
"CALIBRATION ROTATION: " +
signed(THREE.MathUtils.radToDeg(state.calibration.rotationY), 1) +
"° (" +
(state.calibration.confirmed ? "CONFIRMED" : "SESSION") +
")",
"ERRORS / REJECTIONS:", "ERRORS / REJECTIONS:",
eventText, eventText,
].join("\n"); ].join("\n");
...@@ -207,17 +245,72 @@ export function reportHitTestSourceCreated() { ...@@ -207,17 +245,72 @@ export function reportHitTestSourceCreated() {
export function reportGeolocationReady(location) { export function reportGeolocationReady(location) {
if (!XR_DIAGNOSTICS_ENABLED) return; if (!XR_DIAGNOSTICS_ENABLED) return;
state.gpsStatus = location.status || "READY";
state.geolocation = state.geolocation =
location.latitude.toFixed(5) + ", " + location.longitude.toFixed(5); location.latitude.toFixed(5) + ", " + location.longitude.toFixed(5);
renderPanel(); state.gpsBase = state.geolocation;
state.gpsAccuracy = Number.isFinite(location.accuracy)
? location.accuracy
: null;
state.gpsSampleCount = Number(location.sampleCount) || 0;
state.gpsSpread = Number.isFinite(location.spreadMeters)
? location.spreadMeters
: null;
state.gpsSource = location.source || "single fix";
state.gpsTimestamp = Number(location.timestamp) || null;
} }
export function reportGeolocationError(error) { export function reportGeolocationError(error) {
if (!XR_DIAGNOSTICS_ENABLED) return; if (!XR_DIAGNOSTICS_ENABLED) return;
state.geolocation = "ERROR"; state.geolocation = "ERROR";
state.gpsStatus = "ERROR";
addEvent("GEOLOCATION", formatError(error)); addEvent("GEOLOCATION", formatError(error));
} }
export function reportGeolocationStatus(status, location = null) {
if (!XR_DIAGNOSTICS_ENABLED) return;
state.geolocation = status;
state.gpsStatus = status;
if (location) {
state.gpsBase =
location.latitude.toFixed(5) + ", " + location.longitude.toFixed(5);
state.gpsAccuracy = Number.isFinite(location.accuracy)
? location.accuracy
: null;
state.gpsSampleCount = Number(location.sampleCount) || 0;
state.gpsSpread = Number.isFinite(location.spreadMeters)
? location.spreadMeters
: null;
state.gpsSource = location.source || "--";
state.gpsTimestamp = Number(location.timestamp) || null;
}
}
export function reportGeolocationSample({ accuracy, sampleCount, spreadMeters }) {
if (!XR_DIAGNOSTICS_ENABLED) return;
state.gpsStatus = "ACQUIRING";
state.gpsAccuracy = Number.isFinite(accuracy) ? accuracy : null;
state.gpsSampleCount = Number(sampleCount) || 0;
state.gpsSpread = Number.isFinite(spreadMeters) ? spreadMeters : null;
}
export function reportCalibrationState({
x = 0,
y = 0,
z = 0,
rotationY = 0,
confirmed = false,
}) {
if (!XR_DIAGNOSTICS_ENABLED) return;
state.calibration = {
x: Number(x) || 0,
y: Number(y) || 0,
z: Number(z) || 0,
rotationY: Number(rotationY) || 0,
confirmed,
};
}
export function reportDiagnosticError(type, error) { export function reportDiagnosticError(type, error) {
addEvent(type, formatError(error)); addEvent(type, formatError(error));
} }
......
import * as THREE from "three";
import { getAllPlacedModels, geoLocation, showInfoDialog } from "./ar_main.js"; import { getAllPlacedModels, geoLocation, showInfoDialog } from "./ar_main.js";
import { threeToLeaflet } from "./ar_overviewmap.js"; import { threeToLeaflet } from "./ar_overviewmap.js";
export function downloadScene() { export function downloadScene() {
const placedModels = getAllPlacedModels().map(model => { const placedModels = getAllPlacedModels().map(model => {
const { lat, lng } = threeToLeaflet(model.position.x, model.position.z); const finalPosition = model.getWorldPosition(new THREE.Vector3());
const finalQuaternion = model.getWorldQuaternion(new THREE.Quaternion());
const finalRotation = new THREE.Euler().setFromQuaternion(
finalQuaternion,
model.rotation.order,
);
const { lat, lng } = threeToLeaflet(finalPosition.x, finalPosition.z);
return { return {
name: model.modelConfig.name, name: model.modelConfig.name,
position: model.position, position: {
rotation: model.rotation, x: finalPosition.x,
y: finalPosition.y,
z: finalPosition.z,
},
rotation: {
_x: finalRotation.x,
_y: finalRotation.y,
_z: finalRotation.z,
_order: finalRotation.order,
},
scale: model.scale, scale: model.scale,
lat: lat, lat: lat,
lng: lng, lng: lng,
......
import {
reportGeolocationError,
reportGeolocationSample,
reportGeolocationStatus,
} from "./ar_diagnostics.js";
// Tuning values for initial GPS localization. GPS is stopped after a fix is
// selected and is never used to move the scene during the XR session.
export const GPS_LOCALIZATION_CONFIG = Object.freeze({
targetAccuracyMeters: 15,
maximumAccuracyMeters: 35,
stableClusterRadiusMeters: 12,
requiredStableSamples: 4,
maximumWaitMs: 18000,
maximumAgeMs: 1500,
});
let activeWatchId = null;
let activeTimeoutId = null;
function stopWatch() {
if (activeWatchId !== null && navigator.geolocation) {
navigator.geolocation.clearWatch(activeWatchId);
}
activeWatchId = null;
if (activeTimeoutId !== null) {
window.clearTimeout(activeTimeoutId);
}
activeTimeoutId = null;
}
function distanceMeters(first, second) {
const meanLatitude = ((first.latitude + second.latitude) / 2) * Math.PI / 180;
const northMeters = (first.latitude - second.latitude) * 111320;
const eastMeters =
(first.longitude - second.longitude) * 111320 * Math.cos(meanLatitude);
return Math.hypot(northMeters, eastMeters);
}
function bestConsistentCluster(samples) {
let bestCluster = [];
for (const pivot of samples) {
const cluster = samples.filter(
(sample) =>
distanceMeters(sample, pivot) <=
GPS_LOCALIZATION_CONFIG.stableClusterRadiusMeters,
);
if (
cluster.length > bestCluster.length ||
(cluster.length === bestCluster.length &&
cluster.reduce((sum, sample) => sum + sample.accuracy, 0) <
bestCluster.reduce((sum, sample) => sum + sample.accuracy, 0))
) {
bestCluster = cluster;
}
}
return bestCluster;
}
function weightedLocation(samples) {
const weights = samples.map((sample) => 1 / Math.max(sample.accuracy, 1) ** 2);
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
const latitude = samples.reduce(
(sum, sample, index) => sum + sample.latitude * weights[index],
0,
) / totalWeight;
const longitude = samples.reduce(
(sum, sample, index) => sum + sample.longitude * weights[index],
0,
) / totalWeight;
const spreadMeters = Math.max(
...samples.map((sample) =>
distanceMeters(sample, { latitude, longitude }),
),
);
const accuracy = Math.sqrt(1 / totalWeight);
return { latitude, longitude, accuracy, spreadMeters };
}
function makeLocation(samples, cluster, source) {
const selectedSamples = cluster.length ? cluster : samples;
const estimate = weightedLocation(selectedSamples);
const newestSample = selectedSamples.reduce((newest, sample) =>
sample.timestamp > newest.timestamp ? sample : newest,
);
return {
// Keep the existing five-decimal origin precision used by the app.
latitude: Number(estimate.latitude.toFixed(5)),
longitude: Number(estimate.longitude.toFixed(5)),
accuracy: estimate.accuracy,
timestamp: newestSample.timestamp,
sampleCount: selectedSamples.length,
spreadMeters: estimate.spreadMeters,
source,
status: source === "stable cluster" ? "STABLE" : "APPROXIMATE",
};
}
export function acquireStableLocation(onStatus = () => {}) {
stopWatch();
if (!navigator.geolocation) {
const error = new Error("Geolocation API is unavailable");
reportGeolocationError(error);
reportGeolocationStatus("UNAVAILABLE");
onStatus({ status: "UNAVAILABLE", goodSampleCount: 0 });
return Promise.reject(error);
}
return new Promise((resolve, reject) => {
const samples = [];
const observations = [];
let settled = false;
const settle = (location) => {
if (settled) return;
settled = true;
stopWatch();
reportGeolocationStatus(location.status, location);
onStatus({ status: location.status, location });
resolve(location);
};
const fail = (error) => {
if (settled) return;
settled = true;
stopWatch();
reportGeolocationStatus("ERROR");
reportGeolocationError(error);
onStatus({ status: "ERROR", error });
reject(error);
};
reportGeolocationStatus("ACQUIRING");
onStatus({ status: "ACQUIRING", goodSampleCount: 0 });
try {
activeWatchId = navigator.geolocation.watchPosition(
(position) => {
const coords = position.coords;
const accuracy = Number(coords.accuracy);
const latitude = Number(coords.latitude);
const longitude = Number(coords.longitude);
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
!Number.isFinite(accuracy) ||
accuracy <= 0
) {
return;
}
const sample = {
latitude,
longitude,
accuracy,
timestamp: Number(position.timestamp) || Date.now(),
};
observations.push(sample);
observations.splice(0, Math.max(0, observations.length - 12));
if (accuracy <= GPS_LOCALIZATION_CONFIG.maximumAccuracyMeters) {
samples.push(sample);
// Bound memory and make the consistency window recent.
samples.splice(0, Math.max(0, samples.length - 12));
}
const cluster = bestConsistentCluster(samples);
const preview = cluster.length
? weightedLocation(cluster)
: null;
reportGeolocationSample({
accuracy,
sampleCount: samples.length,
spreadMeters: preview ? preview.spreadMeters : null,
});
onStatus({
status: "ACQUIRING",
goodSampleCount: cluster.length,
sampleCount: samples.length,
accuracy,
spreadMeters: preview ? preview.spreadMeters : null,
});
if (
cluster.length >=
GPS_LOCALIZATION_CONFIG.requiredStableSamples &&
preview.accuracy <=
GPS_LOCALIZATION_CONFIG.targetAccuracyMeters &&
preview.spreadMeters <=
GPS_LOCALIZATION_CONFIG.stableClusterRadiusMeters
) {
settle(makeLocation(samples, cluster, "stable cluster"));
}
},
(error) => {
if (error.code === error.PERMISSION_DENIED) {
fail(error);
} else {
reportGeolocationError(error);
onStatus({
status: "ACQUIRING",
goodSampleCount: samples.length,
warning: error.message,
});
}
},
{
enableHighAccuracy: true,
maximumAge: GPS_LOCALIZATION_CONFIG.maximumAgeMs,
timeout: GPS_LOCALIZATION_CONFIG.maximumWaitMs,
},
);
} catch (error) {
fail(error);
return;
}
activeTimeoutId = window.setTimeout(() => {
if (!observations.length) {
fail(new Error("No usable GPS fix received before timeout"));
return;
}
const cluster = bestConsistentCluster(samples);
const usableCluster = cluster.length
? cluster
: [observations.reduce((best, sample) =>
sample.accuracy < best.accuracy ? sample : best,
)];
const approximate = makeLocation(observations, usableCluster, "timeout fallback");
settle(approximate);
}, GPS_LOCALIZATION_CONFIG.maximumWaitMs);
});
}
...@@ -4,8 +4,8 @@ import { clone as cloneSkinned } from "three/addons/utils/SkeletonUtils.js"; ...@@ -4,8 +4,8 @@ import { clone as cloneSkinned } from "three/addons/utils/SkeletonUtils.js";
import { import {
recordXRFrame, recordXRFrame,
reportDiagnosticError, reportDiagnosticError,
reportGeolocationError,
reportGeolocationReady, reportGeolocationReady,
reportCalibrationState,
reportHitTestSourceCreated, reportHitTestSourceCreated,
reportReferenceSpaceCreated, reportReferenceSpaceCreated,
reportXRSessionEnded, reportXRSessionEnded,
...@@ -17,9 +17,9 @@ import { ...@@ -17,9 +17,9 @@ import {
configureMap, configureMap,
init_map, init_map,
leafletToThree, leafletToThree,
roundTo,
stopMapUpdates, stopMapUpdates,
} from "./ar_overviewmap.js"; } from "./ar_overviewmap.js";
import { acquireStableLocation } from "./ar_geolocation.js";
/* ========================= */ /* ========================= */
/* SHARED AR STATE */ /* SHARED AR STATE */
...@@ -31,6 +31,15 @@ export let reticle = null; ...@@ -31,6 +31,15 @@ export let reticle = null;
export let scene; export let scene;
export let camera; export let camera;
export let geoLocation; export let geoLocation;
let placedModelsRoot = null;
let localizationStatusElement = null;
let calibrationOffset = { x: 0, y: 0, z: 0, rotationY: 0 };
let calibrationStep = 0.25;
let calibrationConfirmed = false;
const CALIBRATION_ROTATION_STEP_DEGREES = 5;
const CALIBRATION_ROTATION_STEP_RADIANS = THREE.MathUtils.degToRad(
CALIBRATION_ROTATION_STEP_DEGREES,
);
const modelCache = new Map(); const modelCache = new Map();
const gltfLoader = new GLTFLoader(); const gltfLoader = new GLTFLoader();
...@@ -341,19 +350,21 @@ export function closeInfoDialog() { ...@@ -341,19 +350,21 @@ export function closeInfoDialog() {
/* ========================= */ /* ========================= */
export function getAllPlacedModels() { export function getAllPlacedModels() {
return placedModelsRoot ? placedModelsRoot.children.filter(
return scene.children.filter(
child => child =>
child.isPlacedModel === true child.isPlacedModel === true
); ) : [];
} }
function loadModel(filePath) { function loadModel(filePath) {
const placementPosition = reticle.position.clone();
placedModelsRoot.updateMatrixWorld(true);
placedModelsRoot.worldToLocal(placementPosition);
placeModel( placeModel(
filePath, filePath,
reticle.position, placementPosition,
(model) => { (model) => {
selectedPlacedModel = selectedPlacedModel =
...@@ -416,7 +427,7 @@ function placeModel( ...@@ -416,7 +427,7 @@ function placeModel(
model.modelConfig = model.modelConfig =
modelConfig; modelConfig;
scene.add(model); placedModelsRoot.add(model);
if (onPlace) { if (onPlace) {
...@@ -465,7 +476,7 @@ function placeModel( ...@@ -465,7 +476,7 @@ function placeModel(
model.modelConfig = model.modelConfig =
modelConfig; modelConfig;
scene.add(model); placedModelsRoot.add(model);
if (onPlace) { if (onPlace) {
...@@ -537,7 +548,7 @@ function selectModelFromScene(event) { ...@@ -537,7 +548,7 @@ function selectModelFromScene(event) {
const intersects = const intersects =
raycaster.intersectObjects( raycaster.intersectObjects(
scene.children, getAllPlacedModels(),
true true
); );
...@@ -552,7 +563,7 @@ function selectModelFromScene(event) { ...@@ -552,7 +563,7 @@ function selectModelFromScene(event) {
while ( while (
selectedObject.parent && selectedObject.parent &&
selectedObject.parent !== scene selectedObject.parent !== placedModelsRoot
) { ) {
selectedObject = selectedObject =
...@@ -716,7 +727,7 @@ export function confirmDelete( ...@@ -716,7 +727,7 @@ export function confirmDelete(
selectedPlacedModel selectedPlacedModel
) { ) {
scene.remove( placedModelsRoot.remove(
selectedPlacedModel selectedPlacedModel
); );
...@@ -1088,9 +1099,12 @@ export function moveModelDynamic( ...@@ -1088,9 +1099,12 @@ export function moveModelDynamic(
) { ) {
if (selectedPlacedModel) { if (selectedPlacedModel) {
const worldDelta = new THREE.Vector3();
selectedPlacedModel.position[axis] += worldDelta[axis] = value;
value; worldDelta.applyQuaternion(
placedModelsRoot.quaternion.clone().invert(),
);
selectedPlacedModel.position.add(worldDelta);
} }
} }
...@@ -1122,12 +1136,214 @@ function closeMapDialog() { ...@@ -1122,12 +1136,214 @@ function closeMapDialog() {
'none'; 'none';
} }
function updateLocalizationStatus(message, retry = false) {
if (!localizationStatusElement) {
localizationStatusElement = document.createElement("div");
localizationStatusElement.id = "gps-localization-status";
localizationStatusElement.style.cssText = [
"position:fixed",
"top:42px",
"left:50%",
"transform:translateX(-50%)",
"z-index:10000",
"max-width:calc(100vw - 24px)",
"padding:10px 14px",
"border-radius:8px",
"background:rgba(0,0,0,.8)",
"color:#fff",
"font:14px/1.4 sans-serif",
"text-align:center",
"pointer-events:auto",
].join(";");
document.body.appendChild(localizationStatusElement);
}
window.clearTimeout(localizationStatusElement.hideTimer);
localizationStatusElement.replaceChildren(document.createTextNode(message));
if (retry) {
const button = document.createElement("button");
button.type = "button";
button.textContent = "Erneut versuchen";
button.style.cssText =
"display:block;margin:8px auto 0;padding:8px 12px;border:0;border-radius:5px";
button.addEventListener("click", () => startLocationLocalization());
localizationStatusElement.appendChild(button);
} else if (message.startsWith("Standort stabil") || message.startsWith("Standort angenähert")) {
localizationStatusElement.hideTimer = window.setTimeout(() => {
localizationStatusElement?.remove();
localizationStatusElement = null;
}, 5000);
}
}
let pendingSceneData = null;
let sceneModelsLoaded = false;
async function startLocationLocalization() {
updateLocalizationStatus("Standort wird stabilisiert …");
let location;
try {
location = await acquireStableLocation((progress) => {
if (progress.status !== "ACQUIRING") return;
const count = progress.goodSampleCount || 0;
const accuracy = Number.isFinite(progress.accuracy)
? ` · letzte Genauigkeit ${progress.accuracy.toFixed(0)} m`
: "";
updateLocalizationStatus(
`Standort wird stabilisiert · ${count}/4 konsistente Messungen${accuracy}`,
);
});
} catch (error) {
console.error("[Geo] Failed to stabilize location:", error);
updateLocalizationStatus(
"Standort konnte nicht abgerufen werden. Bitte Standortzugriff prüfen und erneut versuchen.",
true,
);
return;
}
geoLocation = location;
reportGeolocationReady(geoLocation);
const message = geoLocation.status === "STABLE"
? `Standort stabil · Genauigkeit ca. ${geoLocation.accuracy.toFixed(1)} m`
: `Standort angenähert · Genauigkeit ca. ${geoLocation.accuracy.toFixed(1)} m`;
updateLocalizationStatus(message);
if (!sceneModelsLoaded && pendingSceneData && Array.isArray(pendingSceneData.models)) {
sceneModelsLoaded = true;
pendingSceneData.models.forEach((model) => {
if (!model.name) return;
const modelDefinition = Object.values(models).find(
(candidate) => model.name === candidate.name,
);
if (!modelDefinition) {
console.error("[Scene] Model not found:", model.name);
return;
}
const { x, z } = leafletToThree(model.lat, model.lng);
const positionVector = new THREE.Vector3(x, model.position.y, z);
placeModel(modelDefinition.file, positionVector, (placed) => {
if (model.rotation) {
placed.rotation._x = model.rotation._x;
placed.rotation._y = model.rotation._y;
placed.rotation._z = model.rotation._z;
}
if (model.scale) {
placed.scale.x = model.scale.x;
placed.scale.y = model.scale.y;
placed.scale.z = model.scale.z;
}
});
});
}
}
function renderCalibrationMenu() {
const dynamicMenu = document.getElementById("dynamic-menu");
dynamicMenu.style.display = "flex";
dynamicMenu.innerHTML = `
<h3>Szene kalibrieren</h3>
<p>X = seitlich · Z = vor/zurück</p>
<p>Schrittweite: ${calibrationStep.toFixed(2)} m · X ${calibrationOffset.x.toFixed(2)} m · Y ${calibrationOffset.y.toFixed(2)} m · Z ${calibrationOffset.z.toFixed(2)} m</p>
<div class="button-row calibration-rotation-controls">
<span>Drehung Y · ${CALIBRATION_ROTATION_STEP_DEGREES}°</span>
<button class="calibration-rotation-button" type="button" aria-label="Rotate scene left" title="Rotate scene left" onclick="adjustCalibrationRotation(-1)">↺</button>
<button class="calibration-rotation-button" type="button" aria-label="Rotate scene right" title="Rotate scene right" onclick="adjustCalibrationRotation(1)">↻</button>
</div>
<label>Schrittweite
<select onchange="setCalibrationStep(this.value)">
<option value="0.1" ${calibrationStep === 0.1 ? "selected" : ""}>0,10 m</option>
<option value="0.25" ${calibrationStep === 0.25 ? "selected" : ""}>0,25 m</option>
<option value="0.5" ${calibrationStep === 0.5 ? "selected" : ""}>0,50 m</option>
<option value="1" ${calibrationStep === 1 ? "selected" : ""}>1,00 m</option>
</select>
</label>
<div class="button-row">
<button onclick="adjustCalibrationOffset('x', -1)">X −</button>
<button onclick="adjustCalibrationOffset('x', 1)">X +</button>
<button onclick="adjustCalibrationOffset('z', -1)">Z −</button>
<button onclick="adjustCalibrationOffset('z', 1)">Z +</button>
</div>
<div class="button-row">
<button onclick="resetCalibration()">Kalibrierung zurücksetzen</button>
<button onclick="confirmCalibration()">${calibrationConfirmed ? "Übernommen ✓" : "Kalibrierung übernehmen"}</button>
</div>
<button onclick="closeDynamicMenu()">Zurück</button>
`;
}
export function openCalibrationMenu() {
renderCalibrationMenu();
}
export function setCalibrationStep(value) {
calibrationStep = Number(value);
renderCalibrationMenu();
}
export function adjustCalibrationOffset(axis, direction) {
calibrationOffset[axis] = Number(
(calibrationOffset[axis] + direction * calibrationStep).toFixed(2),
);
applyCalibrationTransform();
calibrationConfirmed = false;
reportCalibrationState({ ...calibrationOffset, confirmed: calibrationConfirmed });
renderCalibrationMenu();
}
export function adjustCalibrationRotation(direction) {
const fullTurn = 2 * Math.PI;
const nextRotation =
calibrationOffset.rotationY +
direction * CALIBRATION_ROTATION_STEP_RADIANS;
const normalizedRotation =
((nextRotation + Math.PI) % fullTurn + fullTurn) % fullTurn - Math.PI;
calibrationOffset.rotationY =
Math.abs(normalizedRotation) < 1e-12 ? 0 : normalizedRotation;
applyCalibrationTransform();
calibrationConfirmed = false;
reportCalibrationState({ ...calibrationOffset, confirmed: calibrationConfirmed });
renderCalibrationMenu();
}
function applyCalibrationTransform() {
placedModelsRoot.position.set(
calibrationOffset.x,
calibrationOffset.y,
calibrationOffset.z,
);
placedModelsRoot.rotation.set(0, calibrationOffset.rotationY, 0);
}
export function resetCalibration() {
calibrationOffset = { x: 0, y: 0, z: 0, rotationY: 0 };
calibrationConfirmed = false;
applyCalibrationTransform();
reportCalibrationState({ ...calibrationOffset, confirmed: calibrationConfirmed });
renderCalibrationMenu();
}
export function confirmCalibration() {
calibrationConfirmed = true;
reportCalibrationState({ ...calibrationOffset, confirmed: calibrationConfirmed });
renderCalibrationMenu();
}
/* ========================= */ /* ========================= */
/* AR-HANDLING */ /* AR-HANDLING */
/* ========================= */ /* ========================= */
export async function activateXR(sceneData = null) { export async function activateXR(sceneData = null) {
pendingSceneData = sceneData;
sceneModelsLoaded = false;
calibrationOffset = { x: 0, y: 0, z: 0, rotationY: 0 };
calibrationConfirmed = false;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const renderer = new THREE.WebGLRenderer({ const renderer = new THREE.WebGLRenderer({
alpha: true, alpha: true,
...@@ -1142,6 +1358,10 @@ export async function activateXR(sceneData = null) { ...@@ -1142,6 +1358,10 @@ export async function activateXR(sceneData = null) {
renderer.xr.setReferenceSpaceType("local"); renderer.xr.setReferenceSpaceType("local");
scene = new THREE.Scene(); scene = new THREE.Scene();
placedModelsRoot = new THREE.Group();
placedModelsRoot.name = "placed-models-root";
scene.add(placedModelsRoot);
reportCalibrationState({ ...calibrationOffset, confirmed: false });
camera = new THREE.PerspectiveCamera( camera = new THREE.PerspectiveCamera(
70, 70,
window.innerWidth / window.innerHeight, window.innerWidth / window.innerHeight,
...@@ -1215,68 +1435,6 @@ export async function activateXR(sceneData = null) { ...@@ -1215,68 +1435,6 @@ export async function activateXR(sceneData = null) {
canvas.addEventListener("pointerdown", selectModelFromScene); canvas.addEventListener("pointerdown", selectModelFromScene);
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
geoLocation = {
latitude: roundTo(position.coords.latitude, 5),
longitude: roundTo(position.coords.longitude, 5),
};
reportGeolocationReady(geoLocation);
console.log("GeoLocation: " + JSON.stringify(geoLocation));
if (sceneData) {
sceneData.models.forEach((model) => {
if (model.name) {
const modelDefinition = Object.values(models).find(
(candidate) => model.name === candidate.name,
);
if (!modelDefinition) {
console.error("[Scene] Model not found:", model.name);
return;
}
const filePath = modelDefinition.file;
const { x, z } = leafletToThree(model.lat, model.lng);
const positionVector = new THREE.Vector3(
x,
model.position.y,
z,
);
placeModel(filePath, positionVector, (placed) => {
if (model.rotation) {
placed.rotation._x = model.rotation._x;
placed.rotation._y = model.rotation._y;
placed.rotation._z = model.rotation._z;
}
if (model.scale) {
placed.scale.x = model.scale.x;
placed.scale.y = model.scale.y;
placed.scale.z = model.scale.z;
}
});
}
});
}
},
(error) => {
reportGeolocationError(error);
console.error("[Geo] Failed to get location:", error);
},
{
enableHighAccuracy: true,
maximumAge: 2000,
timeout: 5000,
},
);
} else if (XR_DIAGNOSTICS_ENABLED) {
reportGeolocationError("Geolocation API unavailable");
}
renderer.setAnimationLoop((time, frame) => { renderer.setAnimationLoop((time, frame) => {
let hitResultCount = null; let hitResultCount = null;
...@@ -1318,6 +1476,8 @@ export async function activateXR(sceneData = null) { ...@@ -1318,6 +1476,8 @@ export async function activateXR(sceneData = null) {
}); });
} }
}); });
void startLocationLocalization();
} }
/* ========================= */ /* ========================= */
/* AR EXIT */ /* AR EXIT */
...@@ -1356,6 +1516,12 @@ export function installLegacyUI(downloadScene) { ...@@ -1356,6 +1516,12 @@ export function installLegacyUI(downloadScene) {
updateScale, updateScale,
updateMoveDelta, updateMoveDelta,
moveModelDynamic, moveModelDynamic,
openCalibrationMenu,
setCalibrationStep,
adjustCalibrationOffset,
adjustCalibrationRotation,
resetCalibration,
confirmCalibration,
}); });
Object.defineProperty(window, "moveDelta", { Object.defineProperty(window, "moveDelta", {
......
...@@ -12,6 +12,7 @@ let getPlacedModels = () => []; ...@@ -12,6 +12,7 @@ let getPlacedModels = () => [];
// PERFORMANCE: Reuse one Vector3 instead of allocating a new // PERFORMANCE: Reuse one Vector3 instead of allocating a new
// Vector3 every time updateMap() runs. // Vector3 every time updateMap() runs.
let cameraPosition = new THREE.Vector3(); let cameraPosition = new THREE.Vector3();
let modelWorldPosition = new THREE.Vector3();
// PERFORMANCE: Cache the map window element once. // PERFORMANCE: Cache the map window element once.
let mapWindow = document.getElementById("map-window"); let mapWindow = document.getElementById("map-window");
...@@ -113,7 +114,11 @@ export function init_map() { ...@@ -113,7 +114,11 @@ export function init_map() {
const models = getPlacedModels(); const models = getPlacedModels();
models.forEach((model) => { models.forEach((model) => {
const { lat, lng } = threeToLeaflet(model.position.x, model.position.z); model.getWorldPosition(modelWorldPosition);
const { lat, lng } = threeToLeaflet(
modelWorldPosition.x,
modelWorldPosition.z,
);
const modelMarker = L.marker([lat, lng], { const modelMarker = L.marker([lat, lng], {
icon: L.icon({ icon: L.icon({
......
...@@ -179,6 +179,15 @@ ...@@ -179,6 +179,15 @@
</div> </div>
<div
class="menu-item calibration-menu-item"
role="button"
tabindex="0"
onclick="openCalibrationMenu()">
Position<br>kalibrieren
</div>
<div <div
class="menu-item" class="menu-item"
onclick="showMenu('menu-bar')"> onclick="showMenu('menu-bar')">
...@@ -305,7 +314,7 @@ ...@@ -305,7 +314,7 @@
============================================================ --> ============================================================ -->
<div id="app-version"> <div id="app-version">
v0.3.1 v0.3.2
</div> </div>
......
...@@ -489,6 +489,19 @@ button:active { ...@@ -489,6 +489,19 @@ button:active {
height: 50px; height: 50px;
} }
.calibration-menu-item {
display: flex;
align-items: center;
justify-content: center;
width: 66px;
height: 66px;
color: #fff;
font-size: 11px;
line-height: 1.2;
text-align: center;
cursor: pointer;
}
.menu-placeholder .menu-item img { .menu-placeholder .menu-item img {
width: 50px; width: 50px;
height: 50px; height: 50px;
...@@ -838,6 +851,18 @@ button:active { ...@@ -838,6 +851,18 @@ button:active {
align-items: center; align-items: center;
} }
.calibration-rotation-controls {
gap: 8px;
}
.calibration-rotation-button {
min-width: 52px;
min-height: 48px;
margin: 4px;
padding: 6px 12px;
font-size: 24px;
}
.control-button { .control-button {
flex: 0 0 auto; flex: 0 0 auto;
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment