Commit c9a3a559 authored by Alfakhori's avatar Alfakhori
Browse files

XR_DIAGNOSTICS_ENABLED

parent 78b89f59
Pipeline #12493 passed with stage
in 24 seconds
# Android Chrome / WebXR device validation
## Current implementation audit
The active app uses the Three.js renderer.xr session path in public/ar_main.js: it requests immersive-ar with required hit-test and optional dom-overlay, sets the session on the renderer, requests local and viewer reference spaces, creates a hit-test source, and processes each XR frame from one renderer.setAnimationLoop. The start screen and app bootstrap are in ar_start.js and ar_app.js. The existing debug console captures JavaScript errors and unhandled promise rejections, but there is no structured XR readiness/status panel.
## Diagnostic-mode plan
- Add public/ar_diagnostics.js with one switch, XR_DIAGNOSTICS_ENABLED = false.
- When enabled, show a small, non-interactive status panel and collect XR/session/hit-test/reticle/model/FPS/camera/geolocation/error state.
- Update the panel at most once per second. Count frames inside the XR loop, but do not log per frame.
- Call the diagnostics hooks from existing XR lifecycle points. Hooks are no-ops while the flag is false.
- Preserve the existing session, render loop, UI controls, scene data, model catalog/URLs, coordinate conversion, and Leaflet behavior.
- The diagnostic switch is the only switch required to turn this panel and its listeners/timer on or off.
## Android Chrome test setup
Use a physical ARCore-capable Android phone in Chrome with network access, install/update Google Play Services for AR if the device requires it, and open the deployed site over HTTPS. Grant the browser camera permission for the AR session and location permission when prompted. Use a well-lit area with a textured horizontal surface; glossy, featureless, or very dark surfaces can make surface discovery difficult. Avoid testing from a desktop browser or a normal file:// URL.
Turn diagnostics on temporarily by changing XR_DIAGNOSTICS_ENABLED to true in public/ar_diagnostics.js, deploy that change, and reload the page. Restore it to false after testing.
## Manual checklist
| Test | Steps | Expected behavior |
| --- | --- | --- |
| 1. Secure page and feature detection | Open the HTTPS site in Android Chrome, reload, and inspect the diagnostic panel before starting AR. | WEBXR: AVAILABLE; IMMERSIVE-AR: READY when the device/browser reports support. The session line is IDLE. If unsupported, the panel says UNAVAILABLE or UNSUPPORTED without a JavaScript exception. |
| 2. Start screen | Confirm the start screen appears. Select each predefined scene and verify its selection state, then reload to test the empty-scene start path. | Existing buttons and labels remain unchanged. Selecting a predefined scene marks only that scene selected; no XR session starts until Start AR is tapped. |
| 3. Start AR and permissions | Tap Start AR, accept the camera/session prompt, and accept location permission when prompted. | Camera passthrough appears, the menu overlay remains visible, diagnostic state becomes XR: SESSION, and no duplicate renderer or canvas appears. Geolocation changes from WAITING to READY (lat, lng) or reports an error. |
| 4. Reference spaces and hit source | Wait after the session begins and inspect the panel. | LOCAL: READY, VIEWER: READY, and HIT SOURCE: READY appear only after each corresponding promise succeeds. Failure remains visible as ERROR with details in the diagnostic error list. |
| 5. Hit-test search and reticle | Slowly move the phone over a textured floor/table, then point it at empty space. | A detected surface shows HIT TEST: FOUND and RETICLE: VISIBLE; empty space shows HIT TEST: SEARCHING and RETICLE: HIDDEN. The reticle tracks the detected position without creating models. |
| 6. Frame loop and FPS | Keep the session open and watch the indicator while moving the device gently. | ANIMATION: ACTIVE and a changing, nonzero FPS value appear. FPS and status refresh at about one-second intervals; the console does not receive a line for every XR frame. |
| 7. Place one model | Wait until the reticle is visible, open Add, and tap one model item once. | Selecting the model item places it immediately at the current reticle position. The count becomes MODELS: 1 after loading; it remains anchored as the phone moves. No second tap on the scene is needed and no extra copy appears. |
| 8. Place multiple models | Place another model and then a second copy of the first model. | Counts advance once per placement (for example, 1, 2, 3). Each copy renders independently and the earlier copies remain in place. |
| 9. Select and transform | Tap a placed model, test Move on X and Z, Rotate, and Scale controls, then finish editing. | The selected model is highlighted and only that model changes. The selected model returns to its prior appearance when editing finishes. Other models remain unchanged. |
| 10. Delete confirmation | Select a model, choose Delete, test No, then repeat and choose Yes. | No keeps the model and its count unchanged. Yes removes only the selected model and decrements the count by one. |
| 11. Overview map and coordinates | With geolocation ready, open the map and walk several steps while it is visible. | Leaflet tiles, the plan overlay, user marker, and placed-model markers appear at their existing locations. The user marker follows movement at the existing roughly 500 ms cadence. Camera and geolocation diagnostic lines remain populated. |
| 12. Save scene | Place and transform at least one model, open Options, and download the scene. Inspect the JSON file. | A file downloads. It retains top-level lat, lng, and models; each model has name, position, rotation, scale, lat, and lng. |
| 13. Reload saved and predefined scenes | Reload the downloaded file through the start screen. Repeat with each predefined scene. | Each supported model loads once with its saved geographic placement, vertical position, rotation, and scale. Both predefined scenes still load. Model count matches the placed models. |
| 14. Error reporting | If an error or rejected promise occurs during the above steps, inspect the diagnostic panel and existing debug console. | JavaScript errors and unhandled rejections are shown with their message. They are not emitted repeatedly per frame. A clean run shows no such entries. |
| 15. Exit AR | Open Options, choose Exit AR, confirm Yes, and wait for the app reload. | The session ends and the current app reload behavior remains intact. In diagnostic mode the panel records XR: ENDED across that reload. Confirm No once; the session continues. |
| 16. Disable diagnostics | Set XR_DIAGNOSTICS_ENABLED back to false, redeploy, and reload. | No diagnostic panel, timer, or diagnostic error listeners are created. The existing page/debug console and app behavior remain as before. |
## Result recording
For each test, record pass/fail, device model, Android version, Chrome version, site URL, and the final diagnostic lines. A failed hit-test should include whether the source was created, whether results were empty or found, and whether the reticle became visible.
import "./ar_debug_console.js"; import "./ar_debug_console.js";
import { initializeXRDiagnostics } from "./ar_diagnostics.js";
import { initializeMainUI, installLegacyUI } from "./ar_main.js"; import { initializeMainUI, installLegacyUI } from "./ar_main.js";
import { downloadScene } from "./ar_download.js"; import { downloadScene } from "./ar_download.js";
import { showStartScreen } from "./ar_start.js"; import { showStartScreen } from "./ar_start.js";
initializeXRDiagnostics();
if (navigator.xr) { if (navigator.xr) {
showStartScreen(); showStartScreen();
} else { } else {
......
import * as THREE from "three";
export const XR_DIAGNOSTICS_ENABLED = true; // Set to false to disable the diagnostics panel
const SESSION_END_KEY = "gengenbach.xrDiagnostics.sessionEnded";
const REFRESH_INTERVAL_MS = 1000;
const MAX_EVENTS = 5;
const state = {
webxr: "CHECKING",
immersiveAr: "CHECKING",
xr: "CHECKING",
session: "IDLE",
localSpace: "WAITING",
viewerSpace: "WAITING",
hitSource: "WAITING",
hitTest: "WAITING",
hitResultCount: null,
reticle: "HIDDEN",
animation: "IDLE",
fps: null,
modelCount: 0,
camera: "NOT INITIALIZED",
geolocation: "WAITING",
events: [],
};
let panel = null;
let refreshTimer = null;
let frameCount = 0;
let fpsWindowStarted = 0;
let lastFrameAt = 0;
let cameraWorldPosition = null;
function addEvent(type, detail) {
if (!XR_DIAGNOSTICS_ENABLED) return;
const message = String(detail || "Unknown error")
.replace(/\s+/g, " ")
.slice(0, 180);
state.events.unshift(type + ": " + message);
state.events.length = Math.min(state.events.length, MAX_EVENTS);
renderPanel();
}
function renderPanel(now = performance.now()) {
if (!panel) return;
const animationIsActive = lastFrameAt > 0 && now - lastFrameAt < 1500;
state.animation = animationIsActive ? "ACTIVE" : "IDLE";
const eventText = state.events.length
? state.events.map((event) => "- " + event).join("\n")
: "none";
const fpsText = state.fps === null ? "--" : String(state.fps);
const hitCountText =
state.hitResultCount === null ? "--" : String(state.hitResultCount);
panel.textContent = [
"XR DEVICE DIAGNOSTICS",
"XR: " + state.xr,
"WEBXR: " + state.webxr,
"IMMERSIVE-AR: " + state.immersiveAr,
"SESSION: " + state.session,
"LOCAL SPACE: " + state.localSpace,
"VIEWER SPACE: " + state.viewerSpace,
"HIT SOURCE: " + state.hitSource,
"HIT TEST: " + state.hitTest + " (results: " + hitCountText + ")",
"RETICLE: " + state.reticle,
"ANIMATION: " + state.animation + " | FPS: " + fpsText,
"MODELS: " + state.modelCount,
"CAMERA: " + state.camera,
"GEOLOCATION: " + state.geolocation,
"ERRORS / REJECTIONS:",
eventText,
].join("\n");
}
function formatError(error) {
if (error && error.stack) return error.stack;
if (error && error.message) return error.name + ": " + error.message;
return String(error);
}
function getSessionStorage() {
try {
return window.sessionStorage;
} catch {
return null;
}
}
export function initializeXRDiagnostics() {
if (!XR_DIAGNOSTICS_ENABLED) return;
panel = document.createElement("pre");
panel.id = "xr-diagnostic-panel";
panel.setAttribute("role", "status");
panel.setAttribute("aria-live", "polite");
panel.style.cssText = [
"position:fixed",
"top:38px",
"left:8px",
"z-index:9997",
"box-sizing:border-box",
"width:min(360px, calc(100vw - 16px))",
"max-height:45vh",
"overflow:auto",
"margin:0",
"padding:8px",
"border:1px solid rgba(0,255,0,.55)",
"border-radius:6px",
"background:rgba(0,0,0,.82)",
"color:#00ff00",
"font:11px/1.35 monospace",
"white-space:pre-wrap",
"pointer-events:none",
].join(";");
document.body.appendChild(panel);
cameraWorldPosition = new THREE.Vector3();
const storage = getSessionStorage();
if (storage && storage.getItem(SESSION_END_KEY) === "ended") {
state.session = "ENDED";
state.xr = "ENDED";
storage.removeItem(SESSION_END_KEY);
}
window.addEventListener("error", (event) => {
const location =
event.filename + ":" + event.lineno + ":" + event.colno;
addEvent("ERROR", event.message + " (" + location + ")");
});
window.addEventListener("unhandledrejection", (event) => {
addEvent("REJECTION", formatError(event.reason));
});
if (!navigator.xr) {
state.webxr = "UNAVAILABLE";
state.immersiveAr = "UNAVAILABLE";
state.xr = state.session === "ENDED" ? "ENDED" : "UNAVAILABLE";
renderPanel();
refreshTimer = window.setInterval(renderPanel, REFRESH_INTERVAL_MS);
return;
}
state.webxr = "AVAILABLE";
state.immersiveAr = "CHECKING";
renderPanel();
if (typeof navigator.xr.isSessionSupported !== "function") {
state.immersiveAr = "CHECK UNSUPPORTED";
state.xr = state.session === "ENDED" ? "ENDED" : "UNKNOWN";
} else {
navigator.xr
.isSessionSupported("immersive-ar")
.then((supported) => {
state.immersiveAr = supported ? "READY" : "UNSUPPORTED";
if (state.session !== "STARTED" && state.session !== "ENDED") {
state.xr = supported ? "READY" : "UNSUPPORTED";
}
renderPanel();
})
.catch((error) => {
state.immersiveAr = "ERROR";
if (state.session !== "STARTED" && state.session !== "ENDED") {
state.xr = "ERROR";
}
addEvent("XR SUPPORT CHECK", formatError(error));
});
}
refreshTimer = window.setInterval(renderPanel, REFRESH_INTERVAL_MS);
}
export function reportXRSessionStarted() {
if (!XR_DIAGNOSTICS_ENABLED) return;
state.session = "STARTED";
state.xr = "SESSION";
renderPanel();
}
export function reportXRSessionEnded() {
if (!XR_DIAGNOSTICS_ENABLED) return;
state.session = "ENDED";
state.xr = "ENDED";
const storage = getSessionStorage();
if (storage) storage.setItem(SESSION_END_KEY, "ended");
renderPanel();
}
export function reportReferenceSpaceCreated(name) {
if (!XR_DIAGNOSTICS_ENABLED) return;
if (name === "local") state.localSpace = "READY";
if (name === "viewer") state.viewerSpace = "READY";
renderPanel();
}
export function reportHitTestSourceCreated() {
if (!XR_DIAGNOSTICS_ENABLED) return;
state.hitSource = "READY";
renderPanel();
}
export function reportGeolocationReady(location) {
if (!XR_DIAGNOSTICS_ENABLED) return;
state.geolocation =
location.latitude.toFixed(5) + ", " + location.longitude.toFixed(5);
renderPanel();
}
export function reportGeolocationError(error) {
if (!XR_DIAGNOSTICS_ENABLED) return;
state.geolocation = "ERROR";
addEvent("GEOLOCATION", formatError(error));
}
export function reportDiagnosticError(type, error) {
addEvent(type, formatError(error));
}
export function recordXRFrame({
hitResultCount,
reticleVisible,
modelCount,
camera,
}) {
if (!XR_DIAGNOSTICS_ENABLED || !panel) return;
const now = performance.now();
frameCount += 1;
if (fpsWindowStarted === 0) fpsWindowStarted = now;
const elapsed = now - fpsWindowStarted;
if (elapsed >= REFRESH_INTERVAL_MS) {
state.fps = Math.round((frameCount * 1000) / elapsed);
frameCount = 0;
fpsWindowStarted = now;
}
lastFrameAt = now;
state.hitResultCount = hitResultCount;
state.hitTest =
hitResultCount === null
? "NO FRAME POSE"
: hitResultCount > 0
? "FOUND"
: state.hitSource === "READY"
? "SEARCHING"
: "WAITING";
state.reticle = reticleVisible ? "VISIBLE" : "HIDDEN";
state.modelCount = modelCount;
if (camera) {
camera.getWorldPosition(cameraWorldPosition);
state.camera =
"READY (" +
cameraWorldPosition.x.toFixed(1) +
", " +
cameraWorldPosition.y.toFixed(1) +
", " +
cameraWorldPosition.z.toFixed(1) +
")";
} else {
state.camera = "NOT INITIALIZED";
}
}
import * as THREE from "three"; import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { clone as cloneSkinned } from "three/addons/utils/SkeletonUtils.js"; import { clone as cloneSkinned } from "three/addons/utils/SkeletonUtils.js";
import {
recordXRFrame,
reportDiagnosticError,
reportGeolocationError,
reportGeolocationReady,
reportHitTestSourceCreated,
reportReferenceSpaceCreated,
reportXRSessionEnded,
reportXRSessionStarted,
XR_DIAGNOSTICS_ENABLED,
} from "./ar_diagnostics.js";
import { import {
centerOnPlan, centerOnPlan,
configureMap, configureMap,
...@@ -466,6 +477,7 @@ function placeModel( ...@@ -466,6 +477,7 @@ function placeModel(
(error) => { (error) => {
reportDiagnosticError("MODEL LOAD", error);
console.error( console.error(
"Fehler beim Laden des Modells:", "Fehler beim Laden des Modells:",
error error
...@@ -1158,6 +1170,7 @@ export async function activateXR(sceneData = null) { ...@@ -1158,6 +1170,7 @@ export async function activateXR(sceneData = null) {
}, },
undefined, undefined,
(error) => { (error) => {
reportDiagnosticError("RETICLE LOAD", error);
console.error("[GLTF] Failed to load reticle:", error); console.error("[GLTF] Failed to load reticle:", error);
}, },
); );
...@@ -1172,7 +1185,9 @@ export async function activateXR(sceneData = null) { ...@@ -1172,7 +1185,9 @@ export async function activateXR(sceneData = null) {
}); });
await renderer.xr.setSession(currentSession); await renderer.xr.setSession(currentSession);
reportXRSessionStarted();
} catch (error) { } catch (error) {
reportDiagnosticError("XR START", error);
console.error("[XR] Failed to start session:", error); console.error("[XR] Failed to start session:", error);
renderer.dispose(); renderer.dispose();
canvas.remove(); canvas.remove();
...@@ -1182,14 +1197,18 @@ export async function activateXR(sceneData = null) { ...@@ -1182,14 +1197,18 @@ export async function activateXR(sceneData = null) {
} }
const referenceSpace = await currentSession.requestReferenceSpace("local"); const referenceSpace = await currentSession.requestReferenceSpace("local");
reportReferenceSpaceCreated("local");
const viewerSpace = await currentSession.requestReferenceSpace("viewer"); const viewerSpace = await currentSession.requestReferenceSpace("viewer");
reportReferenceSpaceCreated("viewer");
const hitTestSource = await currentSession.requestHitTestSource({ const hitTestSource = await currentSession.requestHitTestSource({
space: viewerSpace, space: viewerSpace,
}); });
reportHitTestSourceCreated();
document.getElementById("menu-bar").style.display = "flex"; document.getElementById("menu-bar").style.display = "flex";
currentSession.addEventListener("end", () => { currentSession.addEventListener("end", () => {
reportXRSessionEnded();
console.log("AR session ended"); console.log("AR session ended");
window.location.reload(); window.location.reload();
}); });
...@@ -1204,6 +1223,7 @@ export async function activateXR(sceneData = null) { ...@@ -1204,6 +1223,7 @@ export async function activateXR(sceneData = null) {
longitude: roundTo(position.coords.longitude, 5), longitude: roundTo(position.coords.longitude, 5),
}; };
reportGeolocationReady(geoLocation);
console.log("GeoLocation: " + JSON.stringify(geoLocation)); console.log("GeoLocation: " + JSON.stringify(geoLocation));
if (sceneData) { if (sceneData) {
...@@ -1244,6 +1264,7 @@ export async function activateXR(sceneData = null) { ...@@ -1244,6 +1264,7 @@ export async function activateXR(sceneData = null) {
} }
}, },
(error) => { (error) => {
reportGeolocationError(error);
console.error("[Geo] Failed to get location:", error); console.error("[Geo] Failed to get location:", error);
}, },
{ {
...@@ -1252,14 +1273,19 @@ export async function activateXR(sceneData = null) { ...@@ -1252,14 +1273,19 @@ export async function activateXR(sceneData = null) {
timeout: 5000, timeout: 5000,
}, },
); );
} else if (XR_DIAGNOSTICS_ENABLED) {
reportGeolocationError("Geolocation API unavailable");
} }
renderer.setAnimationLoop((time, frame) => { renderer.setAnimationLoop((time, frame) => {
let hitResultCount = null;
if (frame) { if (frame) {
const pose = frame.getViewerPose(referenceSpace); const pose = frame.getViewerPose(referenceSpace);
if (pose) { if (pose) {
const hitTestResults = frame.getHitTestResults(hitTestSource); const hitTestResults = frame.getHitTestResults(hitTestSource);
hitResultCount = hitTestResults.length;
if (hitTestResults.length > 0) { if (hitTestResults.length > 0) {
const hitPose = hitTestResults[0].getPose(referenceSpace); const hitPose = hitTestResults[0].getPose(referenceSpace);
...@@ -1282,6 +1308,15 @@ export async function activateXR(sceneData = null) { ...@@ -1282,6 +1308,15 @@ export async function activateXR(sceneData = null) {
} }
renderer.render(scene, camera); renderer.render(scene, camera);
if (XR_DIAGNOSTICS_ENABLED) {
recordXRFrame({
hitResultCount,
reticleVisible: Boolean(reticle && reticle.visible),
modelCount: getAllPlacedModels().length,
camera,
});
}
}); });
} }
/* ========================= */ /* ========================= */
...@@ -1350,3 +1385,4 @@ function playButtonSound() { ...@@ -1350,3 +1385,4 @@ function playButtonSound() {
}, 200); }, 200);
} }
} }
...@@ -305,7 +305,7 @@ ...@@ -305,7 +305,7 @@
============================================================ --> ============================================================ -->
<div id="app-version"> <div id="app-version">
v0.3.0 v0.3.1
</div> </div>
......
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