fix model-viewer placement and

master
MrPlatnum 2025-09-15 15:23:11 +02:00
parent 1bb248de1b
commit 83c4ece1e8
8 changed files with 328 additions and 312 deletions

View File

@ -108,22 +108,6 @@
<option value="prefer-not-to-say">Keine Angabe</option>
</select>
</div>
<div>
<label for="education-select" class="block text-sm font-medium text-gray-700 mb-2">Höchster Bildungsabschluss</label>
<select id="education-select" formControlName="education" (change)="logInteraction($event)" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option [ngValue]="null" disabled>Bitte auswählen</option>
<option value="high-school">Schulabschluss</option>
<option value="some-college">Berufsausbildung</option>
<option value="bachelors">Bachelor</option>
<option value="masters">Master</option>
<option value="doctorate">Promotion</option>
<option value="other">Andere</option>
</select>
</div>
<div>
<label for="occupation-input" class="block text-sm font-medium text-gray-700 mb-2">Berufsfeld/Studienfach</label>
<input id="occupation-input" type="text" formControlName="occupation" (change)="logInteraction($event)" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="z.B. Student, Ingenieur, ...">
</div>
</div>
</div>

View File

@ -4,11 +4,11 @@ import {
OnDestroy,
ViewChild,
ElementRef,
ChangeDetectorRef,
CUSTOM_ELEMENTS_SCHEMA,
EventEmitter,
Output,
inject
inject,
NgZone
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@ -30,15 +30,17 @@ export class SpatialPositionAssessmentComponent implements AfterViewInit, OnDest
@Output() redoTest = new EventEmitter<number>();
private metricsService = inject(MetricsTrackerService);
private zone = inject(NgZone);
public scale = 1;
public verticalOffset = 0;
protected isModelPlaced = false;
private initialArAnchor: { x: number; y: number; z: number } | null = null;
private hasStartedTracking = false;
constructor(private cdr: ChangeDetectorRef) {
constructor() {
this.logInteraction = this.logInteraction.bind(this);
}
ngOnDestroy() {
this.metricsService.stopTracking();
}
@ -46,10 +48,19 @@ export class SpatialPositionAssessmentComponent implements AfterViewInit, OnDest
ngAfterViewInit() {
const mv = this.modelViewerRef.nativeElement;
mv.addEventListener('ar-status', (e: any) => {
if (e.detail.status === 'session-started') {
setTimeout(() => this.captureAnchor(), 500);
this.metricsService.startTracking(mv, "position");
}
this.zone.run(() => {
const status = e.detail.status;
console.log(status)
if (status === 'session-started' && !this.hasStartedTracking) {
this.hasStartedTracking = true;
this.metricsService.startTracking(mv, "position");
}
if (status === 'object-placed' && !this.isModelPlaced) {
console.log("placed")
this.captureAnchor();
}
});
});
}
@ -57,24 +68,26 @@ export class SpatialPositionAssessmentComponent implements AfterViewInit, OnDest
this.metricsService.logInteraction(event);
}
private async captureAnchor() {
private captureAnchor() {
const mv = this.modelViewerRef.nativeElement;
const anchor = mv.getAnchor();
if (!anchor.includes('not placed')) {
if (anchor && !anchor.includes('not placed')) {
const [x, y, z] = anchor.split(' ').map(parseFloat);
this.initialArAnchor = { x, y, z };
this.isModelPlaced = true;
this.cdr.detectChanges();
console.log('Anchor captured successfully:', this.initialArAnchor);
} else {
setTimeout(() => this.captureAnchor(), 500);
console.warn('Object placed, but anchor not ready. Retrying once.');
setTimeout(() => this.captureAnchor(), 100);
}
}
onSliderInput() {
if (!this.initialArAnchor) return;
const { x, y, z } = this.initialArAnchor!;
const { x, y, z } = this.initialArAnchor;
const newY = y + this.verticalOffset;
this.modelViewerRef.nativeElement.setAttribute('ar-anchor', `${x} ${newY} ${z}`);
this.modelViewerRef.nativeElement.scale = `${this.scale} ${this.scale} ${this.scale}`;
}
resetPosition() {
@ -99,13 +112,10 @@ export class SpatialPositionAssessmentComponent implements AfterViewInit, OnDest
}));
console.log('Spatial position results captured locally.');
this.testComplete.emit();
}
retryCurrent() {
this.redoTest.emit(1);
}
}

View File

@ -17,60 +17,65 @@
WebXR Umgebung laden
</button>
<!-- Adjustment Phase Controls -->
<div *ngIf="currentPhase === 1 && isModelPlaced"
class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-6 bg-black bg-opacity-70 p-6 rounded-xl">
<div class="text-white text-center mb-4">
<h4 id="adjust-phase-title" class="text-lg font-semibold">Finden sie ihre Optionale Betrachtungsposition</h4>
</div>
<div *ngIf="isArActive"
class="absolute bottom-24 left-1/2 -translate-x-1/2 transition-opacity duration-300"
[class.opacity-50]="!isModelPlaced"
[class.pointer-events-none]="!isModelPlaced">
<div class="flex flex-col items-center text-white w-64">
<label for="stability-offset-slider" class="text-sm font-medium mb-2">Höhe verschieben</label>
<input
id="stability-offset-slider"
type="range"
min="-1.5"
max="1.5"
step="0.01"
[(ngModel)]="verticalOffset"
(input)="onSliderInput(); logInteraction($event)"
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" />
<span class="text-xs mt-1">{{ verticalOffset >= 0 ? '+' : '' }}{{ verticalOffset.toFixed(2) }}m</span>
</div>
<!-- Adjustment Phase Controls -->
<div *ngIf="currentPhase === 1"
class="ar-controls flex flex-col items-center space-y-6 bg-black bg-opacity-70 p-6 rounded-xl">
<div class="text-white text-center mb-4">
<h4 id="adjust-phase-title" class="text-lg font-semibold">Finden Sie Ihren Optionale Betrachtungsposition</h4>
</div>
<button
id="lock-position-btn"
(click)="lockPosition(); logInteraction($event)"
class="bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-lg text-lg">
🔒 Diese Position fixieren.
</button>
</div>
<div class="flex flex-col items-center text-white w-64">
<label for="stability-offset-slider" class="text-sm font-medium mb-2">Höhe verschieben</label>
<input
id="stability-offset-slider"
type="range"
min="-1.5"
max="1.5"
step="0.01"
[(ngModel)]="verticalOffset"
(input)="onSliderInput(); logInteraction($event)"
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" />
<span class="text-xs mt-1">{{ verticalOffset >= 0 ? '+' : '' }}{{ verticalOffset.toFixed(2) }}m</span>
</div>
<!-- Lock Confirmation Phase -->
<div *ngIf="currentPhase === 2"
class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-4 bg-yellow-600 bg-opacity-90 p-6 rounded-xl">
<div class="text-white text-center">
<h4 id="lock-confirm-title" class="text-xl font-bold">Position fixiert!</h4>
<p class="text-xs mt-2 opacity-80">Halten Sie das Modell für die gesamte Dauer in möglichst genau dieser Position.</p>
</div>
<div class="flex space-x-4">
<button
id="adjust-more-btn"
(click)="adjustMore(); logInteraction($event)"
class="bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-lg">
Anpassen
</button>
<button
id="start-countdown-btn"
(click)="startCountdown(); logInteraction($event)"
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-6 rounded-lg">
20s Timer starten
id="lock-position-btn"
(click)="lockPosition(); logInteraction($event)"
class="bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-lg text-lg">
🔒 Diese Position fixieren.
</button>
</div>
</div>
<!-- Lock Confirmation Phase -->
<div *ngIf="currentPhase === 2"
class="ar-controls flex flex-col items-center space-y-4 bg-yellow-600 bg-opacity-90 p-6 rounded-xl">
<div class="text-white text-center">
<h4 id="lock-confirm-title" class="text-xl font-bold">Position fixiert!</h4>
<p class="text-xs mt-2 opacity-80">Halten Sie das Modell für die gesamte Dauer in möglichst genau dieser Position.</p>
</div>
<div class="flex space-x-4">
<button
id="adjust-more-btn"
(click)="adjustMore(); logInteraction($event)"
class="bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-lg">
Anpassen
</button>
<button
id="start-countdown-btn"
(click)="startCountdown(); logInteraction($event)"
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-6 rounded-lg">
20s Timer starten
</button>
</div>
</div>
<!-- Countdown Phase -->
<div *ngIf="currentPhase === 3"
@ -117,5 +122,6 @@
</button>
</div>
</div>
</div>
</model-viewer>
</div>

View File

@ -5,7 +5,7 @@ import {
ViewChild,
ElementRef,
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectorRef,
NgZone,
EventEmitter,
Output,
inject
@ -13,7 +13,6 @@ import {
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MetricsTrackerService } from '../../../../services/metrics-tracker.service';
import '../../../../../assets/scripts/model-viewer';
@Component({
@ -24,68 +23,99 @@ import '../../../../../assets/scripts/model-viewer';
styleUrls: ['./spatial-stability-assessment.component.css'],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class SpatialStabilityAssessmentComponent implements AfterViewInit, OnDestroy { // <-- Implement OnDestroy
export class SpatialStabilityAssessmentComponent implements AfterViewInit, OnDestroy {
@ViewChild('modelViewer') modelViewerRef!: ElementRef<any>;
@Output() testComplete = new EventEmitter<void>();
@Output() redoTest = new EventEmitter<number>();
private metricsService = inject(MetricsTrackerService);
private zone = inject(NgZone);
public isArActive = false;
public isModelPlaced = false;
public verticalOffset = 0;
public currentPhase = 0; // 0: waiting, 1: adjust, 2: lock, 3: countdown, 4: complete
public currentPhase = 0;
public remainingTime = 20;
public progressPercentage = 0;
private initialArAnchor: { x: number, y: number, z: number } | null = null;
private lockedPosition: { verticalOffset: number, anchor: string } | null = null;
private countdownInterval: any = null;
protected isModelPlaced = false;
constructor(private cdr: ChangeDetectorRef) {
private initialArAnchor: { x: number, y: number, z: number } | null = null;
private lockedPosition: { verticalOffset: number } | null = null;
private countdownInterval: any = null;
constructor() {
this.logInteraction = this.logInteraction.bind(this);
}
ngAfterViewInit() {
const mv = this.modelViewerRef.nativeElement;
mv.addEventListener('ar-status', (event: any) => {
if (event.detail.status === 'session-started' && !this.isModelPlaced) {
setTimeout(() => this.getInitialAnchor(), 500);
this.metricsService.startTracking(mv, "stability");
}
this.zone.run(() => {
const status = event.detail.status;
switch (status) {
case 'session-started':
this.isArActive = true;
this.isModelPlaced = false;
this.currentPhase = 1;
this.metricsService.startTracking(mv, "stability");
break;
case 'object-placed':
if (this.isArActive && !this.isModelPlaced) {
this.isModelPlaced = true;
this.getInitialAnchor();
}
break;
case 'not-presenting':
this.isArActive = false;
this.isModelPlaced = false;
this.currentPhase = 0;
this.resetComponentState();
break;
}
});
});
}
public logInteraction(event: Event) {
this.metricsService.logInteraction(event);
private resetComponentState() {
this.clearCountdown();
this.initialArAnchor = null;
this.lockedPosition = null;
this.verticalOffset = 0;
}
private async getInitialAnchor() {
const modelViewer = this.modelViewerRef.nativeElement;
const anchorString = modelViewer.getAnchor();
if (!anchorString.includes('not placed')) {
private getInitialAnchor() {
const mv = this.modelViewerRef.nativeElement;
const anchorString = mv.getAnchor();
if (anchorString && !anchorString.includes('not placed')) {
const coords = anchorString.split(' ').map(parseFloat);
this.initialArAnchor = { x: coords[0], y: coords[1], z: coords[2] };
this.isModelPlaced = true;
this.currentPhase = 1;
this.cdr.detectChanges();
if (coords.length === 3 && !coords.some(isNaN)) {
this.initialArAnchor = { x: coords[0], y: coords[1], z: coords[2] };
}
} else {
setTimeout(() => this.getInitialAnchor(), 500);
setTimeout(() => this.getInitialAnchor(), 100);
}
}
onSliderInput() {
if (this.initialArAnchor && this.currentPhase === 1) {
const { x, z } = this.initialArAnchor;
const newY = this.initialArAnchor.y + this.verticalOffset;
const anchor = `${this.initialArAnchor.x} ${newY} ${this.initialArAnchor.z}`;
this.modelViewerRef.nativeElement.setAttribute('ar-anchor', anchor);
this.modelViewerRef.nativeElement.setAttribute('ar-anchor', `${x} ${newY} ${z}`);
}
}
public logInteraction(event: Event) {
this.metricsService.logInteraction(event);
}
lockPosition() {
this.lockedPosition = {
verticalOffset: this.verticalOffset,
anchor: this.modelViewerRef.nativeElement.getAttribute('ar-anchor')
};
this.lockedPosition = { verticalOffset: this.verticalOffset };
this.currentPhase = 2;
}
@ -97,64 +127,53 @@ export class SpatialStabilityAssessmentComponent implements AfterViewInit, OnDes
this.currentPhase = 3;
this.remainingTime = 20;
this.progressPercentage = 0;
this.countdownInterval = setInterval(() => {
this.remainingTime--;
this.progressPercentage = ((20 - this.remainingTime) / 20) * 100;
if (this.remainingTime <= 0) {
this.completeTest(true);
}
this.cdr.detectChanges();
this.zone.run(() => {
this.remainingTime--;
this.progressPercentage = ((20 - this.remainingTime) / 20) * 100;
if (this.remainingTime <= 0) {
this.completeTest(true);
}
});
}, 1000);
}
private clearCountdown() {
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
this.countdownInterval = null;
}
}
cancelTest() {
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
this.countdownInterval = null;
}
this.clearCountdown();
this.currentPhase = 2;
this.metricsService.logInteraction(new CustomEvent('test-cancelled', { detail: { phase: 'countdown' }}));
this.metricsService.logInteraction(new CustomEvent('test-cancelled', { detail: { phase: 'countdown' } }));
}
private completeTest(wasCompleted: boolean) {
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
this.countdownInterval = null;
}
this.clearCountdown();
const finalResults = {
testCompletedSuccessfully: wasCompleted,
initialAnchor: this.initialArAnchor,
lockedPosition: this.lockedPosition,
};
this.metricsService.logInteraction(new CustomEvent('test-results', {
detail: {
testName: 'SpatialStability',
results: finalResults
}
detail: { testName: 'SpatialStability', results: finalResults }
}));
if (wasCompleted) {
this.currentPhase = 4;
}
if(wasCompleted) {
this.testComplete.emit();
this.currentPhase = 4;
this.testComplete.emit();
}
}
restartTest() {
this.clearCountdown();
this.currentPhase = 1;
this.remainingTime = 20;
this.progressPercentage = 0;
this.lockedPosition = null;
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
this.countdownInterval = null;
}
this.verticalOffset = 0;
this.onSliderInput();
}
finishAssessment() {
@ -162,9 +181,7 @@ export class SpatialStabilityAssessmentComponent implements AfterViewInit, OnDes
}
ngOnDestroy() {
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
}
this.clearCountdown();
this.metricsService.stopTracking();
}
}

View File

@ -3,13 +3,11 @@
src="https://modelviewer.dev/shared-assets/models/Astronaut.glb" ar ar-modes="webxr" ar-placement="ceiling"
reveal="manual" camera-orbit="0deg 75deg 2m">
<button slot="ar-button" id="ar-button-legibility" (click)="logInteraction($event)"
class="text-xl absolute top-4 left-1/2 -translate-x-1/2 bg-blue-500 text-white py-2 px-4 rounded-lg z-10">
WebXR Umgebung laden
</button>
<button slot="hotspot-text" class="hotspot ar-hotspot" data-position="-0.1 0.93 0.1" data-normal="0 1 0">
<div class="annotation" [ngStyle]="{'font-size.px': currentSize}"
style="background:rgba(255,255,255,0.9);padding:8px;border-radius:4px;color:black;">
@ -17,85 +15,89 @@
labore et
dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.
Stet
clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. </div>
clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.
</div>
</button>
<div
class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 bg-black bg-opacity-70 p-4 rounded-xl space-y-4 text-white z-10">
<div *ngIf="isArActive"
class="absolute bottom-24 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-4 bg-black bg-opacity-70 p-4 rounded-xl text-white z-10 transition-opacity duration-300"
[class.opacity-50]="!isModelPlaced" [class.pointer-events-none]="!isModelPlaced">
<!-- Phase: Min -->
<ng-container *ngIf="phase==='min'">
<div>Minimum setzen</div>
<div class="flex items-center space-x-4">
<button id="min-decrease-btn" (click)="decrease(); logInteraction($event)"
class="w-10 h-10 bg-gray-600 rounded-full"></button>
<span>{{ currentSize }}px</span>
<button id="min-increase-btn" (click)="increase(); logInteraction($event)"
class="w-10 h-10 bg-gray-600 rounded-full">+</button>
<div *ngIf="isDescriptionVisible" class="relative bg-black/30 p-3 pr-8 rounded-lg text-center w-[280px]">
<p>Minimum setzen. Der Text sollte gerade so noch lesbar sein. Den Arm hierfür bitte ganz austrecken.</p>
<button (click)="toggleDescription()" class="absolute top-1 right-2 text-2xl leading-none">&times;</button>
</div>
<div class="flex flex-col items-center space-y-4">
<div class="flex items-center space-x-4">
<button id="min-decrease-btn" (click)="decrease(); logInteraction($event)" class="w-10 h-10 bg-gray-600 rounded-full"></button>
<span>{{ currentSize }}px</span>
<button id="min-increase-btn" (click)="increase(); logInteraction($event)" class="w-10 h-10 bg-gray-600 rounded-full">+</button>
</div>
<button id="min-confirm-btn" (click)="nextPhase(); logInteraction($event)" class="bg-green-600 py-2 px-6 rounded-lg">Minimum bestätigen</button>
</div>
<button id="min-confirm-btn" (click)="nextPhase(); logInteraction($event)"
class="bg-green-600 py-2 px-6 rounded-lg">Minimum bestätigen</button>
</ng-container>
<!-- Phase: Confirmed Min -->
<ng-container *ngIf="phase==='confirmedMin'">
<div>Minimum bestätigt: {{ minSizeResult }}px</div>
<button id="confirmed-min-reset-btn" (click)="resetToMid(); logInteraction($event)"
class="bg-gray-600 py-2 px-4 rounded-lg">Reset</button>
<button id="confirmed-min-continue-btn" (click)="nextPhase(); logInteraction($event)"
class="bg-blue-600 py-2 px-6 rounded-lg">Weiter zu Maximum</button>
<div class="flex flex-col items-center space-y-4">
<div>Minimum bestätigt: {{ minSizeResult }}px</div>
<div class="flex space-x-2">
<button id="confirmed-min-reset-btn" (click)="resetToMid(); logInteraction($event)" class="bg-gray-600 py-2 px-4 rounded-lg">Reset</button>
<button id="confirmed-min-continue-btn" (click)="nextPhase(); logInteraction($event)" class="bg-blue-600 py-2 px-6 rounded-lg">Weiter zu Maximum</button>
</div>
</div>
</ng-container>
<!-- Phase: Max -->
<ng-container *ngIf="phase==='max'">
<div>Maximum setzen</div>
<div class="flex items-center space-x-4">
<button id="max-decrease-btn" (click)="decrease(); logInteraction($event)"
class="w-10 h-10 bg-gray-600 rounded-full"></button>
<span>{{ currentSize }}px</span>
<button id="max-increase-btn" (click)="increase(); logInteraction($event)"
class="w-10 h-10 bg-gray-600 rounded-full">+</button>
<div *ngIf="isDescriptionVisible" class="relative bg-black/30 p-3 pr-8 rounded-lg text-center w-[280px]">
<p>Maximum setzen. Der Text sollte komplett lesbar sein, ohne das Gerät zu bewegen.</p>
<button (click)="toggleDescription()" class="absolute top-1 right-2 text-2xl leading-none">&times;</button>
</div>
<div class="flex flex-col items-center space-y-4">
<div class="flex items-center space-x-4">
<button id="max-decrease-btn" (click)="decrease(); logInteraction($event)" class="w-10 h-10 bg-gray-600 rounded-full"></button>
<span>{{ currentSize }}px</span>
<button id="max-increase-btn" (click)="increase(); logInteraction($event)" class="w-10 h-10 bg-gray-600 rounded-full">+</button>
</div>
<button id="max-confirm-btn" (click)="nextPhase(); logInteraction($event)" class="bg-green-600 py-2 px-6 rounded-lg">Maximum bestätigen</button>
</div>
<button id="max-confirm-btn" (click)="nextPhase(); logInteraction($event)"
class="bg-green-600 py-2 px-6 rounded-lg">Maximum bestätigen</button>
</ng-container>
<!-- Phase: Confirmed Max -->
<ng-container *ngIf="phase==='confirmedMax'">
<div>Maximum bestätigt: {{ maxSizeResult }}px</div>
<button id="confirmed-max-continue-btn" (click)="nextPhase(); logInteraction($event)"
class="bg-blue-600 py-2 px-6 rounded-lg">Weiter zu optiomal</button>
<div class="flex flex-col items-center space-y-4">
<div>Maximum bestätigt: {{ maxSizeResult }}px</div>
<button id="confirmed-max-continue-btn" (click)="nextPhase(); logInteraction($event)" class="bg-blue-600 py-2 px-6 rounded-lg">Weiter zu Optimal</button>
</div>
</ng-container>
<!-- Phase: Comfortable -->
<ng-container *ngIf="phase==='comfortable'">
<div>Optimale Größe setzen</div>
<div class="flex items-center space-x-4">
<button id="comfort-decrease-btn" (click)="decrease(); logInteraction($event)"
class="w-10 h-10 bg-gray-600 rounded-full"></button>
<span>{{ currentSize }}px</span>
<button id="comfort-increase-btn" (click)="increase(); logInteraction($event)"
class="w-10 h-10 bg-gray-600 rounded-full">+</button>
<div *ngIf="isDescriptionVisible" class="relative bg-black/30 p-3 pr-8 rounded-lg text-center w-[280px]">
<p>Optimale Größe setzen.</p>
<button (click)="toggleDescription()" class="absolute top-1 right-2 text-2xl leading-none">&times;</button>
</div>
<div class="flex flex-col items-center space-y-4">
<div class="flex items-center space-x-4">
<button id="comfort-decrease-btn" (click)="decrease(); logInteraction($event)" class="w-10 h-10 bg-gray-600 rounded-full"></button>
<span>{{ currentSize }}px</span>
<button id="comfort-increase-btn" (click)="increase(); logInteraction($event)" class="w-10 h-10 bg-gray-600 rounded-full">+</button>
</div>
<button id="comfort-confirm-btn" (click)="nextPhase(); logInteraction($event)" class="bg-green-600 py-2 px-6 rounded-lg">Optimale Größe bestätigen</button>
</div>
<button id="comfort-confirm-btn" (click)="nextPhase(); logInteraction($event)"
class="bg-green-600 py-2 px-6 rounded-lg">Optimale Größe setzen</button>
</ng-container>
<!-- Phase: Confirmed Comfortable -->
<ng-container *ngIf="phase==='confirmedComfort'">
<div>Optimale Größe bestätigt: {{ comfortableSizeResult }}px</div>
<button id="finish-assessment-btn" (click)="finishAssessment(); logInteraction($event)"
class="bg-blue-600 py-2 px-6 rounded-lg">Test beenden</button>
<div class="flex flex-col items-center space-y-4">
<div>Optimale Größe bestätigt: {{ comfortableSizeResult }}px</div>
<button id="finish-assessment-btn" (click)="finishAssessment(); logInteraction($event)" class="bg-blue-600 py-2 px-6 rounded-lg">Test beenden</button>
</div>
</ng-container>
</div>
</model-viewer>
</div>

View File

@ -4,18 +4,17 @@ import {
OnDestroy,
ViewChild,
ElementRef,
ChangeDetectorRef,
CUSTOM_ELEMENTS_SCHEMA,
EventEmitter,
Output,
inject
inject,
NgZone
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MetricsTrackerService } from '../../../../services/metrics-tracker.service';
import '../../../../../assets/scripts/model-viewer';
@Component({
selector: 'app-text-legibility-assessment',
standalone: true,
@ -29,58 +28,79 @@ export class TextLegibilityAssessmentComponent implements AfterViewInit, OnDestr
@Output() testComplete = new EventEmitter<void>();
@Output() redoTest = new EventEmitter<number>();
private metricsService = inject(MetricsTrackerService);
private zone = inject(NgZone);
public isArActive = false;
public isModelPlaced = false;
public isDescriptionVisible = true;
minSize = 2;
maxSize = 64;
currentSize = 16;
phase: 'min' | 'confirmedMin' | 'max' | 'confirmedMax' | 'comfortable' | 'confirmedComfort' = 'min';
minSizeResult: number | null = null;
maxSizeResult: number | null = null;
comfortableSizeResult: number | null = null;
phase: 'min' | 'confirmedMin' | 'max' | 'confirmedMax' | 'comfortable' | 'confirmedComfort' = 'min';
private offsetApplied = false;
constructor(private cdr: ChangeDetectorRef) {
constructor() {
this.logInteraction = this.logInteraction.bind(this);
}
ngAfterViewInit() {
const mv = this.modelViewerRef.nativeElement;
mv.addEventListener('ar-status', (event: any) => {
if (event.detail.status === 'session-started') {
console.log('AR session started. Delaying startTracking call to ensure session is ready.');
this.metricsService.startTracking(mv, "text-legibility");
}
this.zone.run(() => {
const status = event.detail.status;
switch (status) {
case 'session-started':
this.isArActive = true;
this.isModelPlaced = false;
this.isDescriptionVisible = true;
this.metricsService.startTracking(mv, "text-legibility");
break;
case 'object-placed':
if (this.isArActive && !this.isModelPlaced) {
this.isModelPlaced = true;
}
break;
case 'not-presenting':
this.isArActive = false;
this.isModelPlaced = false;
this.resetComponentState();
break;
}
});
});
}
private resetComponentState() {
this.phase = 'min';
this.currentSize = 16;
this.minSizeResult = null;
this.maxSizeResult = null;
this.comfortableSizeResult = null;
this.isDescriptionVisible = true;
}
public logInteraction(event: Event) {
this.metricsService.logInteraction(event);
}
toggleDescription() {
this.isDescriptionVisible = !this.isDescriptionVisible;
}
decrease() {
if (this.currentSize > this.minSize) this.currentSize--;
}
increase() {
if (this.currentSize < this.maxSize) this.currentSize++;
}
nextPhase() {
this.isDescriptionVisible = true;
switch (this.phase) {
case 'min':
this.minSizeResult = this.currentSize;
@ -107,49 +127,24 @@ export class TextLegibilityAssessmentComponent implements AfterViewInit, OnDestr
break;
}
}
resetToMid() {
this.currentSize = Math.floor((this.minSize + this.maxSize) / 2);
this.phase = 'min';
this.minSizeResult = null;
this.maxSizeResult = null;
this.comfortableSizeResult = null;
this.resetComponentState();
}
retry() {
this.redoTest.emit(1);
}
finishAssessment() {
const finalResults = {
minReadableSize: this.minSizeResult,
maxReadableSize: this.maxSizeResult,
comfortableReadableSize: this.comfortableSizeResult
};
this.metricsService.logInteraction(new CustomEvent('test-results', {
detail: {
testName: 'TextLegibility',
results: finalResults
}
detail: { testName: 'TextLegibility', results: finalResults }
}));
console.log(finalResults);
this.testComplete.emit();
}
ngOnDestroy() {
this.metricsService.stopTracking();
}
}

View File

@ -63,9 +63,9 @@
<div class="text-black-600">
<h2 class="text-base mb-2">In diesem Testabschnitt untersuchen wir die ergonomisch optimale Positionierung eines virtuellen Objekts an der Decke.</h2>
<ul class="list-disc pl-4 space-y-1">
<li>Positionieren Sie das virtuelle Objekt mithilfe der Steuerung an der Decke.</li>
<li>Das Ziel ist, eine Position zu finden, die es Ihnen erlaubt, das Objekt für 20 Sekunden mit möglichst geringer körperlicher Anstrengung zu betrachten.</li>
<li>Bestätigen Sie Ihre Auswahl, um die 20-sekündige Haltephase zu starten.</li>
<li>Positionieren Sie das virtuelle Objekt mithilfe der Steuerung an der Decke.</li>
<li>Bestätigen Sie Ihre Auswahl, um den 20 Sekunden Timer zu starten.</li>
</ul>
</div>
</div>

View File

@ -70014,6 +70014,12 @@ class ARRenderer extends EventDispatcher {
get currentGoalPosition() {
return this.goalPosition;
}
get currentPosition() {
if (this.presentedScene) {
return this.presentedScene.pivot.position;
}
return this.goalPosition;
}
get isObjectPlaced() {
return this.placementComplete;
}
@ -70378,28 +70384,26 @@ class ARRenderer extends EventDispatcher {
}
}
placeInitially() {
var _a;
const scene = this.presentedScene;
const { pivot, element } = scene;
const { position } = pivot;
const xrCamera = scene.getCamera();
if (this.parsedAnchorOffset != null) {
// Set position directly from the provided anchor offset.
// This position is relative to the initial XR reference space origin.
position.copy(this.parsedAnchorOffset);
this.goalPosition.copy(this.parsedAnchorOffset);
// Set up the scene for presentation
// Mark placement as complete and set up the scene
this.placementComplete = true;
this.worldSpaceInitialPlacementDone = true;
scene.setHotspotsVisibility(true);
scene.visible = true;
scene.setShadowIntensity(AR_SHADOW_INTENSITY);
// Mark placement as complete to bypass further automatic placement.
this.placementComplete = true;
this.worldSpaceInitialPlacementDone = true;
// Hide the placement box as it's not needed.
if (this.placementBox) {
this.placementBox.show = false;
}
this.dispatchEvent({ type: 'status', status: ARStatus.OBJECT_PLACED });
// Enable user interaction controls for the appropriate mode.
// Enable user interaction controls
if (this.xrMode === XRMode.SCREEN_SPACE) {
const { session } = this.frame;
session.addEventListener('selectstart', this.onSelectStart);
@ -70409,12 +70413,8 @@ class ARRenderer extends EventDispatcher {
}
else { // WORLD_SPACE
this.enableWorldSpaceUserInteraction();
// Hide the placement box again as it's not needed for manual anchoring.
if (this.placementBox) {
this.placementBox.show = false;
}
}
return; // Skip the rest of the automatic placement logic.
return;
}
const { width, height } = this.overlay.getBoundingClientRect();
scene.setSize(width, height);
@ -70425,10 +70425,11 @@ class ARRenderer extends EventDispatcher {
const cameraDirection = xrCamera.getWorldDirection(vector3$1);
scene.yaw = Math.atan2(-cameraDirection.x, -cameraDirection.z) - theta;
this.goalYaw = scene.yaw;
// Defer placement if ceiling is the target but view is not pointing up.
if (this.placeOnCeiling && !this.isViewPointingUp()) {
scene.visible = false; // Hide until properly oriented
scene.setHotspotsVisibility(true); // Still show UI
// Set up touch interaction for screen-space mode
scene.visible = false;
scene.setHotspotsVisibility(true);
// Set up touch interaction for screen-space mode even if deferred
if (this.xrMode === XRMode.SCREEN_SPACE) {
const { session } = this.frame;
session.addEventListener('selectstart', this.onSelectStart);
@ -70436,28 +70437,21 @@ class ARRenderer extends EventDispatcher {
session.requestHitTestSourceForTransientInput({ profile: 'generic-touchscreen' })
.then(hitTestSource => { this.transientHitTestSource = hitTestSource; });
}
return; // Exit early - don't place yet
return; // Exit early, placement will be handled by checkForDeferredCeilingPlacement
}
// Use different placement logic for world-space vs screen-space
if (this.xrMode === XRMode.WORLD_SPACE && !this.worldSpaceInitialPlacementDone) {
// Use automatic optimal placement for world-space AR only on first session
// --- Main Placement Logic ---
if (this.xrMode === XRMode.WORLD_SPACE) {
const { position: optimalPosition, scale: optimalScale } = this.calculateWorldSpaceOptimalPlacement(scene, xrCamera);
this.goalPosition.copy(optimalPosition);
this.goalScale = optimalScale;
// Store the initial scale for toggle functionality
this.initialModelScale = optimalScale;
// Set initial position and scale immediately for world-space
position.copy(optimalPosition);
pivot.scale.set(optimalScale, optimalScale, optimalScale);
// Mark that initial placement is done
this.worldSpaceInitialPlacementDone = true;
// Calculate scale limits for world-space mode (SVXR logic)
this.calculateWorldSpaceScaleLimits(scene);
// Enable user interaction after initial placement
this.enableWorldSpaceUserInteraction();
}
else if (this.xrMode === XRMode.SCREEN_SPACE) {
// Use original placement logic for screen-space AR
else { // SCREEN_SPACE
const radius = Math.max(1, 2 * scene.boundingSphere.radius);
position.copy(xrCamera.position)
.add(cameraDirection.multiplyScalar(radius));
@ -70466,8 +70460,17 @@ class ARRenderer extends EventDispatcher {
position.add(target).sub(this.oldTarget);
this.goalPosition.copy(position);
}
// --- Finalize Placement ---
this.placementComplete = true;
scene.visible = true;
scene.setHotspotsVisibility(true);
scene.visible = true; // Model is properly oriented, show it
if (this.placementBox) {
this.placementBox.show = false;
}
// Set shadow and dispatch the crucial event
(_a = this.presentedScene) === null || _a === void 0 ? void 0 : _a.setShadowIntensity(AR_SHADOW_INTENSITY);
this.dispatchEvent({ type: 'status', status: ARStatus.OBJECT_PLACED });
// Setup user interaction for screen-space after placement
if (this.xrMode === XRMode.SCREEN_SPACE) {
const { session } = this.frame;
session.addEventListener('selectstart', this.onSelectStart);
@ -70518,8 +70521,10 @@ class ARRenderer extends EventDispatcher {
session.requestHitTestSourceForTransientInput({ profile: 'generic-touchscreen' })
.then(hitTestSource => { this.transientHitTestSource = hitTestSource; });
}
this.placementComplete = true;
scene.visible = true;
scene.setHotspotsVisibility(true);
scene.setShadowIntensity(AR_SHADOW_INTENSITY);
this.dispatchEvent({ type: 'status', status: ARStatus.OBJECT_PLACED });
}
getTouchLocation() {
@ -70568,40 +70573,27 @@ class ARRenderer extends EventDispatcher {
* until a ceiling hit arrives (no premature floor placement).
*/
moveToAnchor(frame) {
if (this.parsedAnchorOffset != null) {
if (this.parsedAnchorOffset != null || this.placementComplete) {
return;
}
// Handle deferred initial placement for ceiling mode
if (this.placeOnCeiling &&
this.xrMode === XRMode.WORLD_SPACE &&
!this.worldSpaceInitialPlacementDone &&
!this.presentedScene.visible) {
// Check if orientation is now sufficient
if (!this.isViewPointingUp()) {
console.log('[ARR/moveToAnchor] Still waiting for proper ceiling orientation');
return;
const hitSource = this.initialHitSource;
if (hitSource == null) {
return;
}
const hitResults = frame.getHitTestResults(hitSource);
if (hitResults.length === 0) {
return;
}
const hitResult = hitResults[0];
const hitPosition = this.getHitPoint(hitResult);
if (hitPosition != null) {
this.goalPosition.copy(hitPosition);
this.placementComplete = true;
if (this.placementBox) {
this.placementBox.show = false;
}
// Orientation is good - complete the deferred world-space placement
const scene = this.presentedScene;
const xrCamera = scene.getCamera();
const { position: optimalPosition, scale: optimalScale } = this.calculateWorldSpaceOptimalPlacement(scene, xrCamera);
this.goalPosition.copy(optimalPosition);
this.goalScale = optimalScale;
this.initialModelScale = optimalScale;
scene.pivot.position.copy(optimalPosition);
scene.pivot.scale.set(optimalScale, optimalScale, optimalScale);
this.worldSpaceInitialPlacementDone = true;
this.calculateWorldSpaceScaleLimits(scene);
this.enableWorldSpaceUserInteraction();
scene.visible = true;
this.presentedScene.setShadowIntensity(AR_SHADOW_INTENSITY);
this.dispatchEvent({ type: 'status', status: ARStatus.OBJECT_PLACED });
return;
}
// Skip for world-space mode after initial placement (unless ceiling was deferred)
if (this.xrMode === XRMode.WORLD_SPACE && this.worldSpaceInitialPlacementDone) {
this.placementBox.show = false;
this.dispatchEvent({ type: 'status', status: ARStatus.OBJECT_PLACED });
return;
}
}
isViewPointingUp(thresholdDeg = CEILING_ORIENTATION_THRESHOLD) {
@ -80252,6 +80244,7 @@ const ARMixin = (ModelViewerElement) => {
this[$arAnchor].removeEventListener('message', this[$onARTap]);
}
update(changedProperties) {
var _l;
super.update(changedProperties);
if (changedProperties.has('arScale')) {
this[$scene].canScale = this.arScale !== 'fixed';
@ -80266,6 +80259,15 @@ const ARMixin = (ModelViewerElement) => {
if (changedProperties.has('arModes')) {
this[$arModes] = deserializeARModes(this.arModes);
}
if (changedProperties.has('arAnchor') && this[$renderer].arRenderer.isPresenting) {
const arRenderer = this[$renderer].arRenderer;
const isDeferredCeiling = this.arPlacement === 'ceiling' &&
!arRenderer.isObjectPlaced &&
!((_l = arRenderer.presentedScene) === null || _l === void 0 ? void 0 : _l.visible);
if (!isDeferredCeiling) {
arRenderer.updateAnchor(this.arAnchor);
}
}
if (changedProperties.has('ar') || changedProperties.has('arModes') ||
changedProperties.has('src') || changedProperties.has('iosSrc') ||
changedProperties.has('arUsdzMaxTextureSize')) {
@ -80275,7 +80277,7 @@ const ARMixin = (ModelViewerElement) => {
getAnchor() {
const arRenderer = this[$renderer].arRenderer;
if (arRenderer.isPresenting && arRenderer.isObjectPlaced) {
const position = arRenderer.currentGoalPosition;
const position = arRenderer.currentPosition;
return `${position.x} ${position.y} ${position.z}`;
}
return 'Model not placed in AR yet.';