implemented test suite
parent
ff12bb40ad
commit
458a1f360e
|
|
@ -1,8 +1,4 @@
|
||||||
<div class="container mx-auto p-8">
|
<div class="container mx-auto">
|
||||||
<h1 class="text-4xl font-bold text-blue-600 mb-4">
|
|
||||||
Ceiling-AR Assessment Suite
|
<router-outlet></router-outlet>
|
||||||
</h1>
|
|
||||||
<app-text-legibility-assessment>
|
|
||||||
|
|
||||||
</app-text-legibility-assessment>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
.ar-prompt-container {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 50%);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.prompt-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
border: solid white;
|
||||||
|
border-width: 0 4px 4px 0;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right { transform: rotate(-45deg); }
|
||||||
|
.left { transform: rotate(135deg); }
|
||||||
|
.up { transform: rotate(-135deg); }
|
||||||
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<div class="ar-prompt-container">
|
||||||
|
|
||||||
|
<div *ngIf="currentState === 'look-up'" class="prompt-content">
|
||||||
|
<div class="arrow up"></div>
|
||||||
|
<p>Look Up</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="currentState === 'look-around'" class="prompt-content">
|
||||||
|
<div class="arrow left"></div>
|
||||||
|
<p>Look Around</p>
|
||||||
|
<div class="arrow right"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ArPromptComponent } from './ar-prompt.component';
|
||||||
|
|
||||||
|
describe('ArPromptComponent', () => {
|
||||||
|
let component: ArPromptComponent;
|
||||||
|
let fixture: ComponentFixture<ArPromptComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ArPromptComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ArPromptComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { Component, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
type PromptState = 'look-up' | 'look-around';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-ar-prompt',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './ar-prompt.component.html',
|
||||||
|
styleUrls: ['./ar-prompt.component.css']
|
||||||
|
})
|
||||||
|
export class ArPromptComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
private readonly ANGLE_THRESHOLD = 45;
|
||||||
|
public currentState: PromptState = 'look-up';
|
||||||
|
|
||||||
|
private deviceOrientationHandler: (event: DeviceOrientationEvent) => void;
|
||||||
|
|
||||||
|
constructor(private cdr: ChangeDetectorRef) {
|
||||||
|
this.deviceOrientationHandler = this.handleDeviceOrientation.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
window.addEventListener('deviceorientation', this.deviceOrientationHandler, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
window.removeEventListener('deviceorientation', this.deviceOrientationHandler, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDeviceOrientation(event: DeviceOrientationEvent): void {
|
||||||
|
const beta = event.beta;
|
||||||
|
|
||||||
|
if (beta === null) return;
|
||||||
|
|
||||||
|
const newState = beta > this.ANGLE_THRESHOLD ? 'look-around' : 'look-up';
|
||||||
|
|
||||||
|
if (newState !== this.currentState) {
|
||||||
|
this.currentState = newState;
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
<p>demographics-assessment works!</p>
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
||||||
|
|
||||||
import { DemographicsAssessmentComponent } from './demographics-assessment.component';
|
|
||||||
|
|
||||||
describe('DemographicsAssessmentComponent', () => {
|
|
||||||
let component: DemographicsAssessmentComponent;
|
|
||||||
let fixture: ComponentFixture<DemographicsAssessmentComponent>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [DemographicsAssessmentComponent]
|
|
||||||
})
|
|
||||||
.compileComponents();
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(DemographicsAssessmentComponent);
|
|
||||||
component = fixture.componentInstance;
|
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create', () => {
|
|
||||||
expect(component).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
import { Component } from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-demographics-assessment',
|
|
||||||
standalone: true,
|
|
||||||
imports: [],
|
|
||||||
templateUrl: './demographics-assessment.component.html',
|
|
||||||
styleUrl: './demographics-assessment.component.css'
|
|
||||||
})
|
|
||||||
export class DemographicsAssessmentComponent {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
<div class="min-h-screen bg-gray-50 py-8 px-4">
|
||||||
|
<div class="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900">Abschließender Fragebogen</h2>
|
||||||
|
<p class="text-gray-600 mt-2">Ihre Angaben helfen uns bei der wissenschaftlichen Auswertung der Studie.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form [formGroup]="demographicsForm" (ngSubmit)="onSubmit()">
|
||||||
|
|
||||||
|
<!-- Demografische Daten -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||||
|
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">1</span>
|
||||||
|
Demografische Angaben
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Alter *</label>
|
||||||
|
<input type="number" formControlName="age" min="13" max="120" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Ihr Alter">
|
||||||
|
<div *ngIf="demographicsForm.get('age')?.errors?.['required'] && demographicsForm.get('age')?.touched" class="text-red-500 text-sm mt-1">Die Angabe des Alters ist erforderlich.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Geschlecht *</label>
|
||||||
|
<select formControlName="gender" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="" disabled>Bitte auswählen</option>
|
||||||
|
<option value="male">Männlich</option>
|
||||||
|
<option value="female">Weiblich</option>
|
||||||
|
<option value="non-binary">Divers</option>
|
||||||
|
<option value="prefer-not-to-say">Keine Angabe</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Höchster Bildungsabschluss</label>
|
||||||
|
<select formControlName="education" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="" 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 class="block text-sm font-medium text-gray-700 mb-2">Berufsfeld/Studienfach</label>
|
||||||
|
<input type="text" formControlName="occupation" 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>
|
||||||
|
|
||||||
|
<!-- Vorerfahrung mit AR/VR -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||||
|
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">2</span>
|
||||||
|
Vorerfahrung mit AR/VR
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Erfahrung mit Augmented Reality (AR) *<span class="text-gray-500"> (1=keine, 10=Experte)</span></label>
|
||||||
|
<div class="flex items-center space-x-2"><span class="text-sm text-gray-500">1</span><input type="range" formControlName="arExperience" min="1" max="10" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"><span class="text-sm text-gray-500">10</span></div>
|
||||||
|
<div class="text-center mt-1"><span class="text-sm font-medium text-blue-600">{{ demographicsForm.get('arExperience')?.value }}/10</span></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Erfahrung mit Virtual Reality (VR) *<span class="text-gray-500"> (1=keine, 10=Experte)</span></label>
|
||||||
|
<div class="flex items-center space-x-2"><span class="text-sm text-gray-500">1</span><input type="range" formControlName="vrExperience" min="1" max="10" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"><span class="text-sm text-gray-500">10</span></div>
|
||||||
|
<div class="text-center mt-1"><span class="text-sm font-medium text-blue-600">{{ demographicsForm.get('vrExperience')?.value }}/10</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-3">Bisher genutzte AR/VR-Geräte (Mehrfachauswahl möglich)</label>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
<label *ngFor="let device of arVrDevices" class="flex items-center space-x-2">
|
||||||
|
<input type="checkbox" [value]="device.value" (change)="onDeviceChange($event)" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||||
|
<span class="text-sm text-gray-700">{{ device.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Nutzungshäufigkeit von AR/VR</label>
|
||||||
|
<select formControlName="arVrFrequency" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="" disabled>Bitte auswählen</option>
|
||||||
|
<option value="never">Nie</option>
|
||||||
|
<option value="rarely">Selten (wenige Male pro Jahr)</option>
|
||||||
|
<option value="occasionally">Gelegentlich (monatlich)</option>
|
||||||
|
<option value="regularly">Regelmäßig (wöchentlich)</option>
|
||||||
|
<option value="frequently">Häufig (täglich)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Physische und visuelle Voraussetzungen -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||||
|
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">3</span>
|
||||||
|
Visuelle & Physische Angaben
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Benötigen Sie eine Sehhilfe?</label>
|
||||||
|
<select formControlName="visionCorrection" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="" disabled>Bitte auswählen</option>
|
||||||
|
<option value="none">Nein, keine</option>
|
||||||
|
<option value="glasses">Brille</option>
|
||||||
|
<option value="contacts">Kontaktlinsen</option>
|
||||||
|
<option value="both">Brille und Kontaktlinsen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Farbsehvermögen</label>
|
||||||
|
<select formControlName="colorVision" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="" disabled>Bitte auswählen</option>
|
||||||
|
<option value="normal">Normales Farbsehen</option>
|
||||||
|
<option value="red-green">Rot-Grün-Sehschwäche</option>
|
||||||
|
<option value="blue-yellow">Blau-Gelb-Sehschwäche</option>
|
||||||
|
<option value="monochrome">Vollständige Farbenblindheit</option>
|
||||||
|
<option value="unsure">Nicht sicher</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Händigkeit</label>
|
||||||
|
<select formControlName="dominantHand" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="" disabled>Bitte auswählen</option>
|
||||||
|
<option value="right">Rechtshänder</option>
|
||||||
|
<option value="left">Linkshänder</option>
|
||||||
|
<option value="ambidextrous">Beidhänder</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Technologiekompetenz -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||||
|
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">4</span>
|
||||||
|
Technologiekompetenz
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div *ngFor="let tech of technologyComfort" class="flex flex-col">
|
||||||
|
<label class="text-sm font-medium text-gray-700 mb-2">{{ tech.label }} (1=wenig, 10=sehr hoch)</label>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-sm text-gray-500">1</span>
|
||||||
|
<input type="range" [formControlName]="tech.formControl" min="1" max="10" class="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
|
||||||
|
<span class="text-sm text-gray-500">10</span>
|
||||||
|
<span class="text-sm font-medium text-blue-600 w-8 text-right">{{ demographicsForm.get(tech.formControl)?.value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feedback -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
|
||||||
|
<span class="bg-blue-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm mr-3">5</span>
|
||||||
|
Feedback zur Studie
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-3">Benutzerfreundlichkeit der Anwendung *</label>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span *ngFor="let star of [1,2,3,4,5]" (click)="setRating('easeOfUse', star)" class="cursor-pointer text-2xl transition-colors duration-200" [class.text-yellow-400]="star <= (demographicsForm.get('easeOfUse')?.value || 0)" [class.text-gray-300]="star > (demographicsForm.get('easeOfUse')?.value || 0)">★</span>
|
||||||
|
<span class="text-sm text-gray-600 ml-4">{{ getEaseText(demographicsForm.get('easeOfUse')?.value) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Anmerkungen und Verbesserungsvorschläge</label>
|
||||||
|
<textarea formControlName="comments" rows="4" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Ihre Gedanken, Vorschläge oder aufgetretene Probleme..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Würden Sie diese AR-Anwendung weiterempfehlen?</label>
|
||||||
|
<div class="flex space-x-6">
|
||||||
|
<label class="flex items-center space-x-2"><input type="radio" formControlName="wouldRecommend" value="yes" class="text-green-500 focus:ring-green-500"><span class="text-sm text-gray-700">Ja</span></label>
|
||||||
|
<label class="flex items-center space-x-2"><input type="radio" formControlName="wouldRecommend" value="no" class="text-red-500 focus:ring-red-500"><span class="text-sm text-gray-700">Nein</span></label>
|
||||||
|
<label class="flex items-center space-x-2"><input type="radio" formControlName="wouldRecommend" value="maybe" class="text-yellow-500 focus:ring-yellow-500"><span class="text-sm text-gray-700">Vielleicht</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Absenden-Button -->
|
||||||
|
<div class="text-center">
|
||||||
|
<button type="submit" [disabled]="!demographicsForm.valid || isSubmitting" class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-bold py-3 px-8 rounded-lg text-lg transition-colors duration-200">
|
||||||
|
<span *ngIf="!isSubmitting">Fragebogen absenden</span>
|
||||||
|
<span *ngIf="isSubmitting">Wird übermittelt...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="!demographicsForm.valid && demographicsForm.touched" class="text-center mt-2">
|
||||||
|
<p class="text-red-500 text-sm">Bitte füllen Sie alle mit * markierten Pflichtfelder aus.</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Erfolgsmeldung -->
|
||||||
|
<div *ngIf="isSubmitted" class="mt-8 p-4 bg-green-100 border border-green-400 text-green-700 rounded-md text-center">
|
||||||
|
<h4 class="font-semibold">Vielen Dank!</h4>
|
||||||
|
<p>Ihre Angaben wurden erfolgreich übermittelt. Die Studie ist hiermit abgeschlossen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { DemographicsFeedbackComponent } from './demographics-feedback.component';
|
||||||
|
|
||||||
|
describe('DemographicsFeedbackComponent', () => {
|
||||||
|
let component: DemographicsFeedbackComponent;
|
||||||
|
let fixture: ComponentFixture<DemographicsFeedbackComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [DemographicsFeedbackComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(DemographicsFeedbackComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-demographics-feedback',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule],
|
||||||
|
templateUrl: './demographics-feedback.component.html',
|
||||||
|
styleUrls: ['./demographics-feedback.component.css']
|
||||||
|
})
|
||||||
|
export class DemographicsFeedbackComponent implements OnInit {
|
||||||
|
@Output() testComplete = new EventEmitter<void>();
|
||||||
|
@Output() redoTest = new EventEmitter<number>();
|
||||||
|
|
||||||
|
demographicsForm!: FormGroup;
|
||||||
|
isSubmitting = false;
|
||||||
|
isSubmitted = false;
|
||||||
|
|
||||||
|
arVrDevices = [
|
||||||
|
{ value: 'smartphone-ar', label: 'Smartphone-AR' },
|
||||||
|
{ value: 'meta-quest', label: 'Meta Quest/Oculus' },
|
||||||
|
{ value: 'hololens', label: 'HoloLens' },
|
||||||
|
{ value: 'magic-leap', label: 'Magic Leap' },
|
||||||
|
{ value: 'playstation-vr', label: 'PlayStation VR' },
|
||||||
|
{ value: 'htc-vive', label: 'HTC Vive' },
|
||||||
|
{ value: 'google-cardboard', label: 'Google Cardboard' },
|
||||||
|
{ value: 'other', label: 'Andere' },
|
||||||
|
{ value: 'none', label: 'Keine' }
|
||||||
|
];
|
||||||
|
|
||||||
|
technologyComfort = [
|
||||||
|
{ label: 'Kompetenz im Umgang mit Smartphones', formControl: 'smartphoneComfort' },
|
||||||
|
{ label: 'Allgemeine Technologieaffinität', formControl: 'techComfort' },
|
||||||
|
{ label: 'Erfahrung mit Videospielen', formControl: 'gamingExperience' }
|
||||||
|
];
|
||||||
|
|
||||||
|
selectedDevices: string[] = [];
|
||||||
|
|
||||||
|
constructor(private formBuilder: FormBuilder) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.createForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
createForm() {
|
||||||
|
this.demographicsForm = this.formBuilder.group({
|
||||||
|
// Demografische Daten
|
||||||
|
age: ['', [Validators.required, Validators.min(13), Validators.max(120)]],
|
||||||
|
gender: ['', Validators.required],
|
||||||
|
education: [''],
|
||||||
|
occupation: [''],
|
||||||
|
|
||||||
|
// AR/VR Vorerfahrung
|
||||||
|
arExperience: [1, Validators.required],
|
||||||
|
vrExperience: [1, Validators.required],
|
||||||
|
arVrFrequency: [''],
|
||||||
|
|
||||||
|
// Physische und visuelle Einschränkungen
|
||||||
|
visionCorrection: [''],
|
||||||
|
colorVision: [''],
|
||||||
|
dominantHand: [''],
|
||||||
|
mobilityLimitations: [''],
|
||||||
|
|
||||||
|
// Technologiekompetenz
|
||||||
|
smartphoneComfort: [5],
|
||||||
|
techComfort: [5],
|
||||||
|
gamingExperience: [5],
|
||||||
|
|
||||||
|
// Feedback zur Studie
|
||||||
|
overallRating: [0, [Validators.required, Validators.min(1)]],
|
||||||
|
easeOfUse: [0, [Validators.required, Validators.min(1)]],
|
||||||
|
comments: [''],
|
||||||
|
wouldRecommend: ['', Validators.required]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeviceChange(event: any) {
|
||||||
|
const value = event.target.value;
|
||||||
|
if (event.target.checked) {
|
||||||
|
this.selectedDevices.push(value);
|
||||||
|
} else {
|
||||||
|
this.selectedDevices = this.selectedDevices.filter(device => device !== value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setRating(controlName: string, rating: number) {
|
||||||
|
this.demographicsForm.get(controlName)?.setValue(rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRatingText(rating: number): string {
|
||||||
|
const ratings = ['', 'Mangelhaft', 'Ausreichend', 'Gut', 'Sehr Gut', 'Exzellent'];
|
||||||
|
return ratings[rating] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
getEaseText(rating: number): string {
|
||||||
|
const ratings = ['', 'Sehr schwierig', 'Schwierig', 'Neutral', 'Einfach', 'Sehr einfach'];
|
||||||
|
return ratings[rating] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit() {
|
||||||
|
if (this.demographicsForm.valid) {
|
||||||
|
this.isSubmitting = true;
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
...this.demographicsForm.value,
|
||||||
|
devicesUsed: this.selectedDevices,
|
||||||
|
submittedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Demographics and Feedback Data:', formData);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isSubmitting = false;
|
||||||
|
this.isSubmitted = true;
|
||||||
|
this.testComplete.emit();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
Object.keys(this.demographicsForm.controls).forEach(key => {
|
||||||
|
this.demographicsForm.get(key)?.markAsTouched();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finishAssessment() {
|
||||||
|
this.testComplete.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
retryCurrent() {
|
||||||
|
this.redoTest.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
<p>rotation-speed-assessment works!</p>
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import { Component, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { ArLoggerService } from '../../../../services/ar-logger.service';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-rotation-speed-assessment',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, FormsModule],
|
|
||||||
template: ``})
|
|
||||||
export class RotationSpeedAssessmentComponent implements OnInit, OnDestroy {
|
|
||||||
@Output() testComplete = new EventEmitter<void>();
|
|
||||||
@Output() redoTest = new EventEmitter<void>();
|
|
||||||
|
|
||||||
rotationSpeed = 50;
|
|
||||||
showConfirmButton = false;
|
|
||||||
showRedoButton = false;
|
|
||||||
xrSession: any = null;
|
|
||||||
|
|
||||||
constructor(private logger: ArLoggerService) {}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.logger.updateStatus('Assessment 2 initialized - Ready to test rotation speed');
|
|
||||||
this.initializeWebXRTracking();
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.exitWebXRSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
private initializeWebXRTracking(): void {
|
|
||||||
const modelViewer = document.getElementById('speed-model') as any;
|
|
||||||
|
|
||||||
if (modelViewer) {
|
|
||||||
modelViewer.addEventListener('ar-status', (event: any) => {
|
|
||||||
if (event.detail.status === 'session-started') {
|
|
||||||
this.xrSession = modelViewer.model?.webXRCamera?.session;
|
|
||||||
this.logger.updateStatus('WebXR active - Adjust rotation speed with slider');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onSpeedChange(event: Event): void {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
this.rotationSpeed = parseInt(target.value);
|
|
||||||
|
|
||||||
this.logger.logParameterAdjustment('speed', 'rotationSpeed', this.rotationSpeed);
|
|
||||||
|
|
||||||
this.updateSpeedModel(this.rotationSpeed);
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateSpeedModel(speed: number): void {
|
|
||||||
const modelViewer = document.getElementById('speed-model') as any;
|
|
||||||
if (modelViewer) {
|
|
||||||
modelViewer.style.setProperty('--auto-rotate-delay', `${101 - speed}0ms`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmSpeedSetting(): void {
|
|
||||||
this.logger.markTestComplete('speedTest');
|
|
||||||
this.showConfirmButton = true;
|
|
||||||
this.showRedoButton = true;
|
|
||||||
this.logger.updateStatus(`Speed confirmed at ${this.rotationSpeed}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected completeTest(): void {
|
|
||||||
this.exitWebXRSession();
|
|
||||||
this.testComplete.emit();
|
|
||||||
}
|
|
||||||
|
|
||||||
onRedoTest(): void {
|
|
||||||
this.rotationSpeed = 50;
|
|
||||||
this.showConfirmButton = false;
|
|
||||||
this.showRedoButton = false;
|
|
||||||
this.exitWebXRSession();
|
|
||||||
this.redoTest.emit();
|
|
||||||
}
|
|
||||||
|
|
||||||
private exitWebXRSession(): void {
|
|
||||||
if (this.xrSession) {
|
|
||||||
this.xrSession.end().then(() => {
|
|
||||||
console.log('WebXR session ended');
|
|
||||||
this.xrSession = null;
|
|
||||||
}).catch((err: any) => {
|
|
||||||
console.error('Error ending WebXR session:', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
.ar-controls { display: none; }
|
||||||
|
model-viewer[ar-tracking="tracking"] .ar-controls { display: flex; }
|
||||||
|
|
@ -1,54 +1,52 @@
|
||||||
<div class="relative w-full h-screen">
|
<div class="w-full h-full relative">
|
||||||
<model-viewer
|
<model-viewer
|
||||||
#modelViewer
|
#modelViewer
|
||||||
src="https://modelviewer.dev/shared-assets/models/Astronaut.glb"
|
class="absolute inset-0 w-full h-full"
|
||||||
ar
|
src="https://modelviewer.dev/shared-assets/models/Astronaut.glb"
|
||||||
ar-modes="webxr"
|
ar
|
||||||
camera-controls
|
ar-modes="webxr"
|
||||||
interaction-prompt="none"
|
ar-placement="ceiling"
|
||||||
class="w-full h-full"
|
reveal="manual"
|
||||||
>
|
camera-orbit="0deg 75deg 2m">
|
||||||
<button slot="ar-button" class="bg-blue-500 text-white font-bold py-2 px-4 rounded-lg absolute bottom-4 right-4">
|
|
||||||
Enter WebXR
|
<button
|
||||||
</button>
|
slot="ar-button"
|
||||||
|
class="absolute top-4 left-1/2 -translate-x-1/2 bg-blue-500 text-white py-2 px-4 rounded-lg z-10">
|
||||||
<!-- UI container for sliders, visible only in WebXR mode -->
|
WebXR Umgebung laden
|
||||||
<div
|
</button>
|
||||||
*ngIf="isInWebXR"
|
|
||||||
class="absolute bottom-24 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-6 bg-black bg-opacity-60 p-5 rounded-xl"
|
<div
|
||||||
>
|
class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 bg-black bg-opacity-60 p-5 rounded-xl flex flex-col items-center space-y-4 text-white z-10"
|
||||||
<!-- Scale Slider -->
|
[class.opacity-50]="!isModelPlaced">
|
||||||
<div class="flex flex-col items-center text-white w-64">
|
<!-- Slider 1: Scale -->
|
||||||
<label for="scale-slider" class="text-sm font-medium mb-2">Scale</label>
|
<div class="flex flex-col items-start w-64">
|
||||||
<input
|
<label for="scale-slider" class="text-sm font-medium mb-1">Skalierung</label>
|
||||||
id="scale-slider"
|
<input id="scale-slider" type="range" min="0.5" max="2.5" step="0.01" [(ngModel)]="scale"
|
||||||
type="range"
|
(input)="onSliderInput()" [disabled]="!isModelPlaced"
|
||||||
min="0.5"
|
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer" />
|
||||||
max="2.5"
|
<span class="text-xs mt-1">{{ scale.toFixed(2) }}×</span>
|
||||||
step="0.01"
|
|
||||||
[(ngModel)]="scale"
|
|
||||||
(input)="updateModelTransform()"
|
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
|
||||||
/>
|
|
||||||
<span class="text-xs mt-1">{{ scale.toFixed(2) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Vertical Position Slider -->
|
|
||||||
<div class="flex flex-col items-center text-white w-64">
|
|
||||||
<label for="offset-slider" class="text-sm font-medium mb-2">Vertical Position</label>
|
|
||||||
<input
|
|
||||||
id="offset-slider"
|
|
||||||
type="range"
|
|
||||||
min="-1"
|
|
||||||
max="1"
|
|
||||||
step="0.01"
|
|
||||||
[(ngModel)]="verticalOffset"
|
|
||||||
(input)="updateModelTransform()"
|
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
|
||||||
/>
|
|
||||||
<span class="text-xs mt-1">{{ verticalOffset.toFixed(2) }}m</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</model-viewer>
|
|
||||||
</div>
|
<!-- Slider 2: Height -->
|
||||||
|
<div class="flex flex-col items-start w-64">
|
||||||
|
<label for="offset-slider" class="text-sm font-medium mb-1">Höhe verschieben</label>
|
||||||
|
<input id="offset-slider" type="range" min="-1.5" max="3.5" step="0.01" [(ngModel)]="verticalOffset"
|
||||||
|
(input)="onSliderInput()" [disabled]="!isModelPlaced"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Buttons Row -->
|
||||||
|
<div class="flex space-x-4 justify-center">
|
||||||
|
<button (click)="resetPosition()" [disabled]="!isModelPlaced"
|
||||||
|
class="bg-gray-600 text-white py-2 px-4 rounded-lg disabled:opacity-50">
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button (click)="confirmPlacement()" [disabled]="!isModelPlaced"
|
||||||
|
class="bg-green-600 text-white py-2 px-4 rounded-lg disabled:opacity-50">
|
||||||
|
Position bestätigen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</model-viewer>
|
||||||
|
</div>
|
||||||
|
|
@ -1,52 +1,79 @@
|
||||||
import { Component, AfterViewInit, ElementRef, ViewChild, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
AfterViewInit,
|
||||||
|
ViewChild,
|
||||||
|
ElementRef,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
CUSTOM_ELEMENTS_SCHEMA,
|
||||||
|
EventEmitter,
|
||||||
|
Output
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
import '../../../../../assets/scripts/model-viewer';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-spatial-position-assessment',
|
selector: 'app-spatial-position-assessment',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule],
|
imports: [CommonModule, FormsModule],
|
||||||
templateUrl: './spatial-position-assessment.component.html',
|
templateUrl: './spatial-position-assessment.component.html',
|
||||||
styleUrl: './spatial-position-assessment.component.css',
|
styleUrls: ['./spatial-position-assessment.component.css'],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class SpatialPositionAssessmentComponent implements AfterViewInit {
|
export class SpatialPositionAssessmentComponent implements AfterViewInit {
|
||||||
@ViewChild('modelViewer') modelViewerRef!: ElementRef<any>;
|
@ViewChild('modelViewer') modelViewerRef!: ElementRef<any>;
|
||||||
|
@Output() testComplete = new EventEmitter<void>();
|
||||||
|
@Output() redoTest = new EventEmitter<number>();
|
||||||
|
|
||||||
isInWebXR = false;
|
public scale = 1;
|
||||||
scale = 1;
|
public verticalOffset = 0;
|
||||||
verticalOffset = 0;
|
|
||||||
|
protected isModelPlaced = false;
|
||||||
|
private initialArAnchor: { x: number; y: number; z: number } | null = null;
|
||||||
|
|
||||||
constructor(private cdr: ChangeDetectorRef) {}
|
constructor(private cdr: ChangeDetectorRef) {}
|
||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
const modelViewerElement = this.modelViewerRef.nativeElement;
|
const mv = this.modelViewerRef.nativeElement;
|
||||||
|
mv.addEventListener('ar-status', (e: any) => {
|
||||||
modelViewerElement.addEventListener('enter-vr', () => {
|
if (e.detail.status === 'session-started') {
|
||||||
this.isInWebXR = true;
|
setTimeout(() => this.captureAnchor(), 500);
|
||||||
modelViewerElement.cameraControls = false;
|
}
|
||||||
this.cdr.detectChanges();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelViewerElement.addEventListener('exit-vr', () => {
|
|
||||||
this.isInWebXR = false;
|
|
||||||
modelViewerElement.cameraControls = true;
|
|
||||||
this.resetModelTransform();
|
|
||||||
this.cdr.detectChanges();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateModelTransform() {
|
private async captureAnchor() {
|
||||||
const model = this.modelViewerRef.nativeElement.model;
|
const mv = this.modelViewerRef.nativeElement;
|
||||||
if (model) {
|
const anchor = mv.getAnchor();
|
||||||
model.scale.set(this.scale, this.scale, this.scale);
|
if (!anchor.includes('not placed')) {
|
||||||
model.position.y = this.verticalOffset;
|
const [x, y, z] = anchor.split(' ').map(parseFloat);
|
||||||
|
this.initialArAnchor = { x, y, z };
|
||||||
|
this.isModelPlaced = true;
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
} else {
|
||||||
|
setTimeout(() => this.captureAnchor(), 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resetModelTransform() {
|
onSliderInput() {
|
||||||
|
if (!this.initialArAnchor) return;
|
||||||
|
const { x, y, z } = this.initialArAnchor!;
|
||||||
|
const newY = y + this.verticalOffset;
|
||||||
|
this.modelViewerRef.nativeElement.setAttribute('ar-anchor', `${x} ${newY} ${z}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetPosition() {
|
||||||
this.scale = 1;
|
this.scale = 1;
|
||||||
this.verticalOffset = 0;
|
this.verticalOffset = 0;
|
||||||
this.updateModelTransform();
|
this.onSliderInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmPlacement() {
|
||||||
|
this.testComplete.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
retryCurrent() {
|
||||||
|
this.redoTest.emit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
<div class="w-full h-full relative">
|
||||||
|
<model-viewer
|
||||||
|
#modelViewer
|
||||||
|
class="absolute inset-0 w-full h-full"
|
||||||
|
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"
|
||||||
|
class="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>
|
||||||
|
|
||||||
|
<!-- 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 class="text-lg font-semibold">Finden sie ihre Optionale Betrachtungsposition</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center text-white w-64">
|
||||||
|
<label for="offset-slider" class="text-sm font-medium mb-2">Höhe verschieben</label>
|
||||||
|
<input
|
||||||
|
id="offset-slider"
|
||||||
|
type="range"
|
||||||
|
min="-1.5"
|
||||||
|
max="1.5"
|
||||||
|
step="0.01"
|
||||||
|
[(ngModel)]="verticalOffset"
|
||||||
|
(input)="onSliderInput()"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<button
|
||||||
|
(click)="lockPosition()"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- 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 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
|
||||||
|
(click)="adjustMore()"
|
||||||
|
class="bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-lg">
|
||||||
|
Anpassen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="startCountdown()"
|
||||||
|
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"
|
||||||
|
class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-4 bg-red-600 bg-opacity-90 p-6 rounded-xl">
|
||||||
|
|
||||||
|
<div class="text-white text-center">
|
||||||
|
<h4 class="text-3xl font-bold">{{ remainingTime }}</h4>
|
||||||
|
<p class="text-lg">Sekunden verbleibend</p>
|
||||||
|
<p class="text-sm mt-2 opacity-80">Halten Sie das Modell in Position.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="w-64 h-4 bg-white bg-opacity-30 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full bg-white transition-all duration-1000 ease-linear"
|
||||||
|
[style.width.%]="progressPercentage">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
(click)="cancelTest()"
|
||||||
|
class="bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-lg text-sm">
|
||||||
|
Cancel Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Completion Phase -->
|
||||||
|
<div
|
||||||
|
*ngIf="currentPhase === 4"
|
||||||
|
class="ar-controls absolute bottom-24 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-4 bg-green-600 bg-opacity-90 p-6 rounded-xl">
|
||||||
|
|
||||||
|
<div class="text-white text-center">
|
||||||
|
<h4 class="text-2xl font-bold"> Test abgeschlossen</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<button
|
||||||
|
(click)="restartTest()"
|
||||||
|
class="bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg">
|
||||||
|
Test erneut ausführen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="finishAssessment()"
|
||||||
|
class="bg-gray-600 hover:bg-gray-700 text-white py-2 px-4 rounded-lg">
|
||||||
|
Beenden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</model-viewer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
import { RotationSpeedAssessmentComponent } from './rotation-speed-assessment.component';
|
import { RotationSpeedAssessmentComponent } from './spatial-stability-assessment.component';
|
||||||
|
|
||||||
describe('RotationSpeedAssessmentComponent', () => {
|
describe('RotationSpeedAssessmentComponent', () => {
|
||||||
let component: RotationSpeedAssessmentComponent;
|
let component: RotationSpeedAssessmentComponent;
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { Component, AfterViewInit, ViewChild, ElementRef, CUSTOM_ELEMENTS_SCHEMA, ChangeDetectorRef, EventEmitter, Output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
import '../../../../../assets/scripts/model-viewer';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-spatial-stability-assessment',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
templateUrl: './spatial-stability-assessment.component.html',
|
||||||
|
styleUrls: ['./spatial-stability-assessment.component.css'],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class SpatialStabilityAssessmentComponent implements AfterViewInit {
|
||||||
|
@ViewChild('modelViewer') modelViewerRef!: ElementRef<any>;
|
||||||
|
@Output() testComplete = new EventEmitter<void>();
|
||||||
|
@Output() redoTest = new EventEmitter<number>();
|
||||||
|
|
||||||
|
public scale = 1;
|
||||||
|
public verticalOffset = 0;
|
||||||
|
public currentPhase = 0; // 0: waiting, 1: adjust, 2: lock, 3: countdown, 4: complete
|
||||||
|
public remainingTime = 20;
|
||||||
|
public progressPercentage = 0;
|
||||||
|
|
||||||
|
private initialArAnchor: { x: number, y: number, z: number } | null = null;
|
||||||
|
private lockedPosition: { scale: number, verticalOffset: number } | null = null;
|
||||||
|
private countdownInterval: any = null;
|
||||||
|
protected isModelPlaced = false;
|
||||||
|
|
||||||
|
constructor(private cdr: ChangeDetectorRef) {}
|
||||||
|
|
||||||
|
ngAfterViewInit() {
|
||||||
|
const modelViewer = this.modelViewerRef.nativeElement;
|
||||||
|
|
||||||
|
modelViewer.addEventListener('ar-status', (event: any) => {
|
||||||
|
if (event.detail.status === 'session-started') {
|
||||||
|
setTimeout(() => this.getInitialAnchor(), 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getInitialAnchor() {
|
||||||
|
const modelViewer = this.modelViewerRef.nativeElement;
|
||||||
|
const anchorString = modelViewer.getAnchor();
|
||||||
|
|
||||||
|
if (!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;
|
||||||
|
console.log('Starting adjustment phase');
|
||||||
|
} else {
|
||||||
|
setTimeout(() => this.getInitialAnchor(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSliderInput() {
|
||||||
|
if (this.initialArAnchor && this.currentPhase === 1) {
|
||||||
|
const newY = this.initialArAnchor.y + this.verticalOffset;
|
||||||
|
const anchor = `${this.initialArAnchor.x} ${newY} ${this.initialArAnchor.z}`;
|
||||||
|
this.modelViewerRef.nativeElement.setAttribute('ar-anchor', anchor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lockPosition() {
|
||||||
|
this.lockedPosition = {
|
||||||
|
scale: this.scale,
|
||||||
|
verticalOffset: this.verticalOffset
|
||||||
|
};
|
||||||
|
this.currentPhase = 2;
|
||||||
|
console.log('Position locked:', this.lockedPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustMore() {
|
||||||
|
this.currentPhase = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
startCountdown() {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cdr.detectChanges();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
console.log('Countdown started');
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelTest() {
|
||||||
|
if (this.countdownInterval) {
|
||||||
|
clearInterval(this.countdownInterval);
|
||||||
|
this.countdownInterval = null;
|
||||||
|
}
|
||||||
|
this.currentPhase = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private completeTest() {
|
||||||
|
if (this.countdownInterval) {
|
||||||
|
clearInterval(this.countdownInterval);
|
||||||
|
this.countdownInterval = null;
|
||||||
|
}
|
||||||
|
this.currentPhase = 4; // Complete phase
|
||||||
|
console.log('Test completed successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
restartTest() {
|
||||||
|
this.currentPhase = 1;
|
||||||
|
this.remainingTime = 60;
|
||||||
|
this.progressPercentage = 0;
|
||||||
|
this.lockedPosition = null;
|
||||||
|
if (this.countdownInterval) {
|
||||||
|
clearInterval(this.countdownInterval);
|
||||||
|
this.countdownInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
if (this.countdownInterval) {
|
||||||
|
clearInterval(this.countdownInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finishAssessment() {
|
||||||
|
this.testComplete.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
retryCurrent() {
|
||||||
|
this.redoTest.emit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,26 @@
|
||||||
|
.ar-controls {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.annotation {
|
.ar-hotspot {
|
||||||
line-height: 1.4;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ar-prompt {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
model-viewer[ar-status="session-started"]:not([ar-tracking="tracking"]) .ar-prompt {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
model-viewer[ar-tracking="tracking"] .ar-hotspot,
|
||||||
|
model-viewer[ar-tracking="tracking"] .ar-controls {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
model-viewer[ar-tracking="tracking"] .ar-controls {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
@ -1,56 +1,73 @@
|
||||||
<div class="w-full h-screen relative">
|
<div class="w-full h-full relative">
|
||||||
<div class="absolute top-0 left-0 p-6 z-10 bg-white bg-opacity-80 rounded-br-lg">
|
<model-viewer
|
||||||
<h3 class="text-lg font-bold">Assessment: Text Legibility Optimization</h3>
|
#modelViewer
|
||||||
<p class="text-sm mt-2"><strong>Objective:</strong> Determine optimal text scaling for ceiling-AR applications.</p>
|
class="absolute inset-0 w-full h-full"
|
||||||
<p class="text-sm"><strong>Procedure:</strong> Adjust text size using the WebXR slider until it is clearly legible.</p>
|
src="https://modelviewer.dev/shared-assets/models/Astronaut.glb"
|
||||||
</div>
|
ar
|
||||||
|
ar-modes="webxr"
|
||||||
<model-viewer
|
ar-placement="ceiling"
|
||||||
#modelViewer
|
reveal="manual"
|
||||||
id="text-model"
|
camera-orbit="0deg 75deg 2m">
|
||||||
src="https://modelviewer.dev/shared-assets/models/Astronaut.glb"
|
|
||||||
ar
|
<button
|
||||||
ar-modes="webxr"
|
slot="ar-button"
|
||||||
ar-placement="ceiling"
|
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">
|
||||||
camera-orbit="0deg 75deg 2m"
|
WebXR Umgebung laden
|
||||||
reveal="manual"
|
</button>
|
||||||
class="w-full h-full"
|
<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}"
|
||||||
<button slot="ar-button" class="bg-blue-500 text-white font-bold py-2 px-4 rounded-lg absolute bottom-4 right-4 z-10">
|
style="background:rgba(255,255,255,0.9);padding:8px;border-radius:4px;color:black;">
|
||||||
Enter WebXR - Text Size Test
|
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut 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. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut 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.
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Hotspot with annotation text -->
|
|
||||||
<button class="hotspot" slot="hotspot-text" data-position="0.2 0.1 0.1" data-normal="0 1 0">
|
|
||||||
<div
|
|
||||||
class="annotation"
|
|
||||||
[ngStyle]="{'font-size.px': textSize}"
|
|
||||||
style="background: rgba(255,255,255,0.9); padding: 10px; border-radius: 5px; color: black; width: 250px;"
|
|
||||||
>
|
|
||||||
This sample text represents typical information display requirements for ceiling-AR applications.
|
|
||||||
Text must remain legible despite challenging viewing angles inherent to overhead displays.
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- UI container for the text size slider, visible only in WebXR -->
|
|
||||||
<div
|
|
||||||
*ngIf="isInWebXR"
|
|
||||||
class="absolute bottom-24 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-4 bg-black bg-opacity-60 p-5 rounded-xl"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col items-center text-white w-64">
|
|
||||||
<label for="text-size-slider" class="text-sm font-medium mb-2">Text Size: {{ textSize }}px</label>
|
|
||||||
<input
|
|
||||||
id="text-size-slider"
|
|
||||||
type="range"
|
|
||||||
min="10"
|
|
||||||
max="32"
|
|
||||||
step="1"
|
|
||||||
[(ngModel)]="textSize"
|
|
||||||
(input)="updateTextSize()"
|
|
||||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</model-viewer>
|
</button>
|
||||||
</div>
|
<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">
|
||||||
|
|
||||||
|
<!-- Phase: Min -->
|
||||||
|
<ng-container *ngIf="phase==='min'">
|
||||||
|
<div>Minimum setzen</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<button (click)="decrease()" class="w-10 h-10 bg-gray-600 rounded-full">–</button>
|
||||||
|
<span>{{ currentSize }}px</span>
|
||||||
|
<button (click)="increase()" class="w-10 h-10 bg-gray-600 rounded-full">+</button>
|
||||||
|
</div>
|
||||||
|
<button (click)="nextPhase()" 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: {{ currentSize }}px</div>
|
||||||
|
<button (click)="resetToMid()" class="bg-gray-600 py-2 px-4 rounded-lg">Reset</button>
|
||||||
|
<button (click)="nextPhase()" class="bg-blue-600 py-2 px-6 rounded-lg">Weiter zu Maximum</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Phase: Max -->
|
||||||
|
<ng-container *ngIf="phase==='max'">
|
||||||
|
<div>Maximum setzen</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<button (click)="decrease()" class="w-10 h-10 bg-gray-600 rounded-full">–</button>
|
||||||
|
<span>{{ currentSize }}px</span>
|
||||||
|
<button (click)="increase()" class="w-10 h-10 bg-gray-600 rounded-full">+</button>
|
||||||
|
</div>
|
||||||
|
<button (click)="nextPhase()" 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: {{ currentSize }}px</div>
|
||||||
|
<button (click)="nextPhase()" class="bg-blue-600 py-2 px-6 rounded-lg">Weiter zu optiomal</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Phase: Comfortable -->
|
||||||
|
<ng-container *ngIf="phase==='comfortable'">
|
||||||
|
<div>Optimale Größe setzen</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<button (click)="decrease()" class="w-10 h-10 bg-gray-600 rounded-full">–</button>
|
||||||
|
<span>{{ currentSize }}px</span>
|
||||||
|
<button (click)="increase()" class="w-10 h-10 bg-gray-600 rounded-full">+</button>
|
||||||
|
</div>
|
||||||
|
<button (click)="finishAssessment()" class="bg-green-600 py-2 px-6 rounded-lg">Optimale Größe setzen</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</model-viewer>
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,124 @@
|
||||||
import { Component, AfterViewInit, ViewChild, ElementRef, ChangeDetectorRef, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
AfterViewInit,
|
||||||
|
ViewChild,
|
||||||
|
ElementRef,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
CUSTOM_ELEMENTS_SCHEMA,
|
||||||
|
EventEmitter,
|
||||||
|
Output
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
import '../../../../../assets/scripts/model-viewer';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-text-legibility-assessment',
|
selector: 'app-text-legibility-assessment',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule],
|
imports: [CommonModule, FormsModule],
|
||||||
templateUrl: './text-legibility-assessment.component.html',
|
templateUrl: './text-legibility-assessment.component.html',
|
||||||
styleUrl: './text-legibility-assessment.component.css',
|
styleUrls: ['./text-legibility-assessment.component.css'],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class TextLegibilityAssessmentComponent implements AfterViewInit {
|
export class TextLegibilityAssessmentComponent implements AfterViewInit {
|
||||||
@ViewChild('modelViewer') modelViewerRef!: ElementRef<any>;
|
@ViewChild('modelViewer') modelViewerRef!: ElementRef<any>;
|
||||||
|
@Output() testComplete = new EventEmitter<void>();
|
||||||
|
@Output() redoTest = new EventEmitter<number>();
|
||||||
|
|
||||||
isInWebXR = false;
|
minSize = 2;
|
||||||
textSize = 16;
|
maxSize = 64;
|
||||||
|
currentSize = 16;
|
||||||
|
comfortableSize = 16;
|
||||||
|
|
||||||
|
phase:
|
||||||
|
| 'min'
|
||||||
|
| 'confirmedMin'
|
||||||
|
| 'max'
|
||||||
|
| 'confirmedMax'
|
||||||
|
| 'comfortable'
|
||||||
|
| 'confirmedComfort'
|
||||||
|
| 'finished' = 'min';
|
||||||
|
|
||||||
|
private offsetApplied = false;
|
||||||
|
|
||||||
constructor(private cdr: ChangeDetectorRef) {}
|
constructor(private cdr: ChangeDetectorRef) {}
|
||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
const modelViewerElement = this.modelViewerRef.nativeElement;
|
const mv = this.modelViewerRef.nativeElement;
|
||||||
|
mv.setAttribute('scale', '0.25 0.25 0.25');
|
||||||
|
|
||||||
modelViewerElement.addEventListener('enter-vr', () => {
|
mv.addEventListener('ar-status', async (e: any) => {
|
||||||
this.isInWebXR = true;
|
if (e.detail.status === 'session-started' && !this.offsetApplied) {
|
||||||
this.cdr.detectChanges();
|
await mv.updateComplete;
|
||||||
});
|
const anchor = mv.getAnchor();
|
||||||
|
if (!anchor.includes('not placed')) {
|
||||||
modelViewerElement.addEventListener('exit-vr', () => {
|
const [x, y, z] = anchor.split(' ').map(parseFloat);
|
||||||
this.isInWebXR = false;
|
mv.setAttribute('ar-anchor', `${x} ${y + 3.0} ${z}`);
|
||||||
this.resetTextSize();
|
this.offsetApplied = true;
|
||||||
this.cdr.detectChanges();
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTextSize() {
|
decrease() {
|
||||||
|
if (this.currentSize > this.minSize) {
|
||||||
|
this.currentSize--;
|
||||||
|
this.syncComfortable();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resetTextSize() {
|
increase() {
|
||||||
this.textSize = 16;
|
if (this.currentSize < this.maxSize) {
|
||||||
|
this.currentSize++;
|
||||||
|
this.syncComfortable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncComfortable() {
|
||||||
|
if (this.phase === 'comfortable') {
|
||||||
|
this.comfortableSize = this.currentSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPhase() {
|
||||||
|
switch (this.phase) {
|
||||||
|
case 'min':
|
||||||
|
this.phase = 'confirmedMin';
|
||||||
|
break;
|
||||||
|
case 'confirmedMin':
|
||||||
|
this.phase = 'max';
|
||||||
|
this.currentSize = this.maxSize;
|
||||||
|
break;
|
||||||
|
case 'max':
|
||||||
|
this.phase = 'confirmedMax';
|
||||||
|
break;
|
||||||
|
case 'confirmedMax':
|
||||||
|
this.phase = 'comfortable';
|
||||||
|
this.currentSize = Math.floor((this.minSize + this.maxSize) / 2);
|
||||||
|
this.comfortableSize = this.currentSize;
|
||||||
|
break;
|
||||||
|
case 'comfortable':
|
||||||
|
this.phase = 'confirmedComfort';
|
||||||
|
this.comfortableSize = this.currentSize;
|
||||||
|
break;
|
||||||
|
case 'confirmedComfort':
|
||||||
|
this.phase = 'finished';
|
||||||
|
this.testComplete.emit();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetToMid() {
|
||||||
|
this.currentSize = Math.floor((this.minSize + this.maxSize) / 2);
|
||||||
|
this.phase = 'min';
|
||||||
|
}
|
||||||
|
|
||||||
|
retry() {
|
||||||
|
this.redoTest.emit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
finishAssessment() {
|
||||||
|
this.testComplete.emit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,92 @@
|
||||||
<div class="max-w-4xl mx-auto p-5 bg-white rounded-lg shadow-lg mt-8">
|
<div class="max-w-4xl mx-auto p-2 bg-white rounded-lg shadow-lg mt-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-800 mb-6">Ceiling-AR Design Parameter Assessment Suite</h1>
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800 mb-6">Augmented Reality Umfrage</h1>
|
||||||
|
|
||||||
<!-- Progress Section -->
|
<!-- Progress Section -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="flex justify-between items-center mb-2">
|
<div class="flex justify-between items-center mb-2">
|
||||||
<span class="text-sm font-medium text-gray-700">
|
<span class="text-sm font-medium text-gray-700">Test {{ currentTest }} von {{ totalTests }}</span>
|
||||||
Assessment {{ currentTest }} of {{ totalTests }}
|
<span class="text-sm font-medium text-gray-700">{{ progress | number:'1.1-1' }}%</span>
|
||||||
</span>
|
|
||||||
<span class="text-sm font-medium text-gray-700">
|
|
||||||
{{ progress | number:'1.1-1' }}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress Bar -->
|
|
||||||
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
|
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
|
||||||
<div
|
<div class="bg-gradient-to-r from-blue-600 to-blue-400 h-full rounded-full" [style.width.%]="progress"></div>
|
||||||
class="bg-gradient-to-r from-blue-600 to-blue-400 h-full rounded-full transition-all duration-300 ease-out"
|
</div>
|
||||||
[style.width.%]="progress">
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Display -->
|
||||||
|
<div *ngIf="currentTest < 4" class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6 font-mono text-sm">
|
||||||
|
<div class="text-red-600">Bitte achten Sie darauf die Kamera auf eine Deckenfläche zu richten.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assessment Container -->
|
||||||
|
<div class="assessment-container" style="height: 70vh; min-height: 500px;">
|
||||||
|
|
||||||
|
<!-- Assessment 1 -->
|
||||||
|
<div *ngIf="currentTest === 1" class="w-full h-full flex flex-col">
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-4 font-mono text-sm">
|
||||||
|
<div class="text-black-600">
|
||||||
|
<h2 class="text-base mb-2">In diesem Test geht es darum, die Lesbarkeit von Texten in Augmented-Reality-Umgebungen (AR) im Kontext von Decken zu untersuchen.</h2>
|
||||||
|
<ul class="list-disc pl-4 space-y-1">
|
||||||
|
<li>Stellen Sie bitte zunächst die Textgröße auf die <strong>minimale</strong> Stufe ein, bei der Sie den Text gerade noch entziffern können.</li>
|
||||||
|
<li>Passen Sie als Nächstes die Textgröße auf die <strong>maximale</strong> Stufe an, bei der der gesamte Inhalt noch vollständig auf dem Bildschirm sichtbar bleibt.</li>
|
||||||
|
<li>Wählen Sie zum Abschluss die Schriftgröße, die Sie persönlich als am <strong>angenehmsten</strong> empfinden.</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status Display -->
|
|
||||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6 font-mono text-sm">
|
|
||||||
<div class="text-gray-600">
|
|
||||||
Status: {{ status }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Assessment Components Container -->
|
|
||||||
<div class="assessment-container">
|
|
||||||
|
|
||||||
<!-- Assessment 1: First Input Detection -->
|
<!-- Component container -->
|
||||||
<div *ngIf="currentTest === 1">
|
<div class="flex-1 min-h-0">
|
||||||
<app-first-input-assessment
|
<app-text-legibility-assessment (testComplete)="onTestComplete()" (redoTest)="redoTest(1)"></app-text-legibility-assessment>
|
||||||
(testComplete)="onTestComplete()"
|
|
||||||
(redoTest)="redoTest(1)">
|
|
||||||
</app-first-input-assessment>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Assessment 2: Rotation Speed Optimization -->
|
|
||||||
<div *ngIf="currentTest === 2">
|
|
||||||
<app-rotation-speed-assessment
|
|
||||||
(testComplete)="onTestComplete()"
|
|
||||||
(redoTest)="redoTest(2)">
|
|
||||||
</app-rotation-speed-assessment>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Assessment 3: Ergonomic Position Optimization -->
|
|
||||||
<div *ngIf="currentTest === 3">
|
|
||||||
<app-ergonomic-assessment
|
|
||||||
(testComplete)="onTestComplete()"
|
|
||||||
(redoTest)="redoTest(3)">
|
|
||||||
</app-ergonomic-assessment>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Assessment 4: Text Legibility Optimization -->
|
|
||||||
<div *ngIf="currentTest === 4">
|
|
||||||
<app-text-legibility-assessment
|
|
||||||
(testComplete)="onTestComplete()"
|
|
||||||
(redoTest)="redoTest(4)">
|
|
||||||
</app-text-legibility-assessment>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Assessment 5: Scale Reference Calibration -->
|
|
||||||
<div *ngIf="currentTest === 5">
|
|
||||||
<app-scale-reference-assessment
|
|
||||||
(testComplete)="onTestComplete()"
|
|
||||||
(redoTest)="redoTest(5)">
|
|
||||||
</app-scale-reference-assessment>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Assessment 6: Spatial Positioning Optimization -->
|
|
||||||
<div *ngIf="currentTest === 6">
|
|
||||||
<app-spatial-position-assessment
|
|
||||||
(testComplete)="onTestComplete()"
|
|
||||||
(redoTest)="redoTest(6)">
|
|
||||||
</app-spatial-position-assessment>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Assessment 7: Demographics and Completion -->
|
|
||||||
<div *ngIf="currentTest === 7">
|
|
||||||
<app-demographics-assessment
|
|
||||||
(testComplete)="onTestComplete()"
|
|
||||||
(redoTest)="redoTest(7)">
|
|
||||||
</app-demographics-assessment>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- Assessment 2 -->
|
||||||
|
<div *ngIf="currentTest === 2" class="w-full h-full flex flex-col">
|
||||||
|
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-4 font-mono text-sm">
|
||||||
|
<div class="text-black-600">
|
||||||
|
<h2 class="text-base mb-2">In diesem Test soll untersucht werden, welche Position eines virtuellen Objekts an der Decke als ideal empfunden wird.</h2>
|
||||||
|
<p>Stellen Sie die beiden Schieberegler bitte so ein, dass das Modell für Sie optisch ansprechend und realistisch an der Decke platziert ist.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-h-0">
|
||||||
|
<app-spatial-position-assessment (testComplete)="onTestComplete()" (redoTest)="redoTest(2)"></app-spatial-position-assessment>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assessment 3 -->
|
||||||
|
<div *ngIf="currentTest === 3" class="w-full h-full flex flex-col">
|
||||||
|
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-4 font-mono text-sm">
|
||||||
|
<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>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-h-0">
|
||||||
|
<app-spatial-stability-assessment (testComplete)="onTestComplete()" (redoTest)="redoTest(3)"></app-spatial-stability-assessment>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assessment 4 -->
|
||||||
|
<div *ngIf="currentTest === 4" class="w-full h-full overflow-auto">
|
||||||
|
<app-demographics-feedback (testComplete)="onTestComplete()" (redoTest)="redoTest(4)"></app-demographics-feedback>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Skip Button -->
|
||||||
|
<div *ngIf="currentTest !== 4" class="flex justify-center mt-6 mb-4">
|
||||||
|
<button (click)="forceNextTest()"
|
||||||
|
class="bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-6 rounded-lg shadow-lg text-sm">
|
||||||
|
Test überspringen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
@ -4,12 +4,12 @@ import { Subject, takeUntil } from 'rxjs';
|
||||||
import { TestProgressService } from '../../services/test-progress.service';
|
import { TestProgressService } from '../../services/test-progress.service';
|
||||||
import { ArLoggerService } from '../../services/ar-logger.service';
|
import { ArLoggerService } from '../../services/ar-logger.service';
|
||||||
|
|
||||||
import { RotationSpeedAssessmentComponent } from './assessments/rotation-speed-assessment/rotation-speed-assessment.component';
|
import { SpatialStabilityAssessmentComponent } from './assessments/spatial-stability-assessment/spatial-stability-assessment.component';
|
||||||
import { ErgonomicAssessmentComponent } from './assessments/ergonomic-assessment/ergonomic-assessment.component';
|
import { ErgonomicAssessmentComponent } from './assessments/ergonomic-assessment/ergonomic-assessment.component';
|
||||||
import { TextLegibilityAssessmentComponent } from './assessments/text-legibility-assessment/text-legibility-assessment.component';
|
import { TextLegibilityAssessmentComponent } from './assessments/text-legibility-assessment/text-legibility-assessment.component';
|
||||||
import { ScaleReferenceAssessmentComponent } from './assessments/scale-reference-assessment/scale-reference-assessment.component';
|
import { ScaleReferenceAssessmentComponent } from './assessments/scale-reference-assessment/scale-reference-assessment.component';
|
||||||
import { SpatialPositionAssessmentComponent } from './assessments/spatial-position-assessment/spatial-position-assessment.component';
|
import { SpatialPositionAssessmentComponent } from './assessments/spatial-position-assessment/spatial-position-assessment.component';
|
||||||
import { DemographicsAssessmentComponent } from './assessments/demographics-assessment/demographics-assessment.component';
|
import { DemographicsFeedbackComponent } from './assessments/demographics-feedback/demographics-feedback.component';
|
||||||
import { FirstInputAssessmentComponent } from './assessments/first-input-assesment/first-input-assesment.component';
|
import { FirstInputAssessmentComponent } from './assessments/first-input-assesment/first-input-assesment.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|
@ -18,21 +18,21 @@ import { FirstInputAssessmentComponent } from './assessments/first-input-assesme
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FirstInputAssessmentComponent,
|
FirstInputAssessmentComponent,
|
||||||
RotationSpeedAssessmentComponent,
|
SpatialStabilityAssessmentComponent,
|
||||||
ErgonomicAssessmentComponent,
|
ErgonomicAssessmentComponent,
|
||||||
TextLegibilityAssessmentComponent,
|
TextLegibilityAssessmentComponent,
|
||||||
ScaleReferenceAssessmentComponent,
|
ScaleReferenceAssessmentComponent,
|
||||||
SpatialPositionAssessmentComponent,
|
SpatialPositionAssessmentComponent,
|
||||||
DemographicsAssessmentComponent
|
DemographicsFeedbackComponent,
|
||||||
],
|
DemographicsFeedbackComponent
|
||||||
|
],
|
||||||
templateUrl: './test-suite.component.html',
|
templateUrl: './test-suite.component.html',
|
||||||
styleUrls: ['./test-suite.component.css']
|
styleUrls: ['./test-suite.component.css']
|
||||||
})
|
})
|
||||||
export class TestSuiteComponent implements OnInit, OnDestroy {
|
export class TestSuiteComponent implements OnInit, OnDestroy {
|
||||||
currentTest = 1;
|
currentTest = 1;
|
||||||
totalTests = 7;
|
totalTests = 4;
|
||||||
progress = 14.3;
|
progress = 25;
|
||||||
status = 'Ready to begin assessments';
|
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
|
@ -58,7 +58,6 @@ export class TestSuiteComponent implements OnInit, OnDestroy {
|
||||||
this.logger.status$
|
this.logger.status$
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(status => {
|
.subscribe(status => {
|
||||||
this.status = status;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.totalTests = this.progressService.getTotalTests();
|
this.totalTests = this.progressService.getTotalTests();
|
||||||
|
|
@ -77,4 +76,9 @@ export class TestSuiteComponent implements OnInit, OnDestroy {
|
||||||
redoTest(testNumber: number): void {
|
redoTest(testNumber: number): void {
|
||||||
this.progressService.goToTest(testNumber);
|
this.progressService.goToTest(testNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
forceNextTest(): void {
|
||||||
|
this.logger.updateStatus(`Forcefully skipped to next test from test ${this.currentTest}.`);
|
||||||
|
this.progressService.nextTest();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,9 +6,9 @@ import { Router } from '@angular/router';
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class TestProgressService {
|
export class TestProgressService {
|
||||||
private totalTests = 7;
|
private totalTests = 4;
|
||||||
private currentTestSubject = new BehaviorSubject<number>(1);
|
private currentTestSubject = new BehaviorSubject<number>(1);
|
||||||
private progressSubject = new BehaviorSubject<number>(14.3); // (1/7) * 100
|
private progressSubject = new BehaviorSubject<number>(25); // (1/totalTests) * 100
|
||||||
|
|
||||||
currentTest$ = this.currentTestSubject.asObservable();
|
currentTest$ = this.currentTestSubject.asObservable();
|
||||||
progress$ = this.progressSubject.asObservable();
|
progress$ = this.progressSubject.asObservable();
|
||||||
|
|
@ -30,7 +30,6 @@ export class TestProgressService {
|
||||||
this.currentTestSubject.next(nextTest);
|
this.currentTestSubject.next(nextTest);
|
||||||
this.updateProgress(nextTest);
|
this.updateProgress(nextTest);
|
||||||
} else {
|
} else {
|
||||||
// All tests completed, navigate to completion screen
|
|
||||||
this.router.navigate(['/completion']);
|
this.router.navigate(['/completion']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69779,6 +69779,7 @@ class ARRenderer extends EventDispatcher {
|
||||||
this.renderer = renderer;
|
this.renderer = renderer;
|
||||||
this.currentSession = null;
|
this.currentSession = null;
|
||||||
this.placementMode = 'floor';
|
this.placementMode = 'floor';
|
||||||
|
this.anchorOffset = null;
|
||||||
this.placementBox = null;
|
this.placementBox = null;
|
||||||
this.menuPanel = null;
|
this.menuPanel = null;
|
||||||
this.lastTick = null;
|
this.lastTick = null;
|
||||||
|
|
@ -69797,6 +69798,7 @@ class ARRenderer extends EventDispatcher {
|
||||||
this.xrController1 = null;
|
this.xrController1 = null;
|
||||||
this.xrController2 = null;
|
this.xrController2 = null;
|
||||||
this.selectedXRController = null;
|
this.selectedXRController = null;
|
||||||
|
this.parsedAnchorOffset = null;
|
||||||
this.tracking = true;
|
this.tracking = true;
|
||||||
this.frames = 0;
|
this.frames = 0;
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
|
|
@ -70009,6 +70011,35 @@ class ARRenderer extends EventDispatcher {
|
||||||
get presentedScene() {
|
get presentedScene() {
|
||||||
return this._presentedScene;
|
return this._presentedScene;
|
||||||
}
|
}
|
||||||
|
get currentGoalPosition() {
|
||||||
|
return this.goalPosition;
|
||||||
|
}
|
||||||
|
get isObjectPlaced() {
|
||||||
|
return this.placementComplete;
|
||||||
|
}
|
||||||
|
updateAnchor(newAnchor) {
|
||||||
|
var _a;
|
||||||
|
if (newAnchor) {
|
||||||
|
const parts = newAnchor.split(' ').map(Number);
|
||||||
|
if (parts.length === 3 && parts.every(p => !isNaN(p))) {
|
||||||
|
const newPosition = new Vector3(parts[0], parts[1], parts[2]);
|
||||||
|
this.goalPosition.copy(newPosition);
|
||||||
|
this.parsedAnchorOffset = newPosition;
|
||||||
|
if (!this.placementComplete) {
|
||||||
|
this.placementComplete = true;
|
||||||
|
this.worldSpaceInitialPlacementDone = true;
|
||||||
|
if (this.placementBox) {
|
||||||
|
this.placementBox.show = false;
|
||||||
|
}
|
||||||
|
(_a = this.presentedScene) === null || _a === void 0 ? void 0 : _a.setShadowIntensity(AR_SHADOW_INTENSITY);
|
||||||
|
this.dispatchEvent({ type: 'status', status: ARStatus.OBJECT_PLACED });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.warn(`Invalid dynamic ar-anchor value: "${newAnchor}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Resolves to true if the renderer has detected all the necessary qualities
|
* Resolves to true if the renderer has detected all the necessary qualities
|
||||||
* to support presentation in AR.
|
* to support presentation in AR.
|
||||||
|
|
@ -70032,6 +70063,16 @@ class ARRenderer extends EventDispatcher {
|
||||||
if (this.isPresenting) {
|
if (this.isPresenting) {
|
||||||
console.warn('Cannot present while a model is already presenting');
|
console.warn('Cannot present while a model is already presenting');
|
||||||
}
|
}
|
||||||
|
this.parsedAnchorOffset = null;
|
||||||
|
if (this.anchorOffset) {
|
||||||
|
const parts = this.anchorOffset.split(' ').map(Number);
|
||||||
|
if (parts.length === 3 && parts.every(p => !isNaN(p))) {
|
||||||
|
this.parsedAnchorOffset = new Vector3(parts[0], parts[1], parts[2]);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.warn(`Invalid ar-anchor value: "${this.anchorOffset}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
let waitForAnimationFrame = new Promise((resolve, _reject) => {
|
let waitForAnimationFrame = new Promise((resolve, _reject) => {
|
||||||
requestAnimationFrame(() => resolve());
|
requestAnimationFrame(() => resolve());
|
||||||
});
|
});
|
||||||
|
|
@ -70305,6 +70346,8 @@ class ARRenderer extends EventDispatcher {
|
||||||
this.inputSource = null;
|
this.inputSource = null;
|
||||||
this.overlay = null;
|
this.overlay = null;
|
||||||
this.worldSpaceInitialPlacementDone = false;
|
this.worldSpaceInitialPlacementDone = false;
|
||||||
|
this.anchorOffset = null;
|
||||||
|
this.parsedAnchorOffset = null;
|
||||||
if (this.resolveCleanup != null) {
|
if (this.resolveCleanup != null) {
|
||||||
this.resolveCleanup();
|
this.resolveCleanup();
|
||||||
}
|
}
|
||||||
|
|
@ -70339,6 +70382,40 @@ class ARRenderer extends EventDispatcher {
|
||||||
const { pivot, element } = scene;
|
const { pivot, element } = scene;
|
||||||
const { position } = pivot;
|
const { position } = pivot;
|
||||||
const xrCamera = scene.getCamera();
|
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
|
||||||
|
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.
|
||||||
|
if (this.xrMode === XRMode.SCREEN_SPACE) {
|
||||||
|
const { session } = this.frame;
|
||||||
|
session.addEventListener('selectstart', this.onSelectStart);
|
||||||
|
session.addEventListener('selectend', this.onSelectEnd);
|
||||||
|
session.requestHitTestSourceForTransientInput({ profile: 'generic-touchscreen' })
|
||||||
|
.then(hitTestSource => { this.transientHitTestSource = hitTestSource; });
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
}
|
||||||
const { width, height } = this.overlay.getBoundingClientRect();
|
const { width, height } = this.overlay.getBoundingClientRect();
|
||||||
scene.setSize(width, height);
|
scene.setSize(width, height);
|
||||||
xrCamera.projectionMatrixInverse.copy(xrCamera.projectionMatrix).invert();
|
xrCamera.projectionMatrixInverse.copy(xrCamera.projectionMatrix).invert();
|
||||||
|
|
@ -70491,6 +70568,9 @@ class ARRenderer extends EventDispatcher {
|
||||||
* until a ceiling hit arrives (no premature floor placement).
|
* until a ceiling hit arrives (no premature floor placement).
|
||||||
*/
|
*/
|
||||||
moveToAnchor(frame) {
|
moveToAnchor(frame) {
|
||||||
|
if (this.parsedAnchorOffset != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Handle deferred initial placement for ceiling mode
|
// Handle deferred initial placement for ceiling mode
|
||||||
if (this.placeOnCeiling &&
|
if (this.placeOnCeiling &&
|
||||||
this.xrMode === XRMode.WORLD_SPACE &&
|
this.xrMode === XRMode.WORLD_SPACE &&
|
||||||
|
|
@ -80116,6 +80196,7 @@ const ARMixin = (ModelViewerElement) => {
|
||||||
this.arScale = 'auto';
|
this.arScale = 'auto';
|
||||||
this.arUsdzMaxTextureSize = 'auto';
|
this.arUsdzMaxTextureSize = 'auto';
|
||||||
this.arPlacement = 'floor';
|
this.arPlacement = 'floor';
|
||||||
|
this.arAnchor = null;
|
||||||
this.arModes = DEFAULT_AR_MODES;
|
this.arModes = DEFAULT_AR_MODES;
|
||||||
this.iosSrc = null;
|
this.iosSrc = null;
|
||||||
this.xrEnvironment = false;
|
this.xrEnvironment = false;
|
||||||
|
|
@ -80179,6 +80260,9 @@ const ARMixin = (ModelViewerElement) => {
|
||||||
this[$scene].updateShadow();
|
this[$scene].updateShadow();
|
||||||
this[$needsRender]();
|
this[$needsRender]();
|
||||||
}
|
}
|
||||||
|
if (changedProperties.has('arAnchor') && this[$renderer].arRenderer.isPresenting) {
|
||||||
|
this[$renderer].arRenderer.updateAnchor(this.arAnchor);
|
||||||
|
}
|
||||||
if (changedProperties.has('arModes')) {
|
if (changedProperties.has('arModes')) {
|
||||||
this[$arModes] = deserializeARModes(this.arModes);
|
this[$arModes] = deserializeARModes(this.arModes);
|
||||||
}
|
}
|
||||||
|
|
@ -80188,6 +80272,14 @@ const ARMixin = (ModelViewerElement) => {
|
||||||
this[$selectARMode]();
|
this[$selectARMode]();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
getAnchor() {
|
||||||
|
const arRenderer = this[$renderer].arRenderer;
|
||||||
|
if (arRenderer.isPresenting && arRenderer.isObjectPlaced) {
|
||||||
|
const position = arRenderer.currentGoalPosition;
|
||||||
|
return `${position.x} ${position.y} ${position.z}`;
|
||||||
|
}
|
||||||
|
return 'Model not placed in AR yet.';
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Activates AR. Note that for any mode that is not WebXR-based, this
|
* Activates AR. Note that for any mode that is not WebXR-based, this
|
||||||
* method most likely has to be called synchronous from a user
|
* method most likely has to be called synchronous from a user
|
||||||
|
|
@ -80274,6 +80366,7 @@ configuration or device capabilities');
|
||||||
else {
|
else {
|
||||||
arRenderer.placementMode = 'floor';
|
arRenderer.placementMode = 'floor';
|
||||||
}
|
}
|
||||||
|
arRenderer.anchorOffset = this.arAnchor;
|
||||||
await arRenderer.present(this[$scene], this.xrEnvironment);
|
await arRenderer.present(this[$scene], this.xrEnvironment);
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
|
@ -80447,6 +80540,9 @@ configuration or device capabilities');
|
||||||
__decorate$2([
|
__decorate$2([
|
||||||
property({ type: String, attribute: 'ar-placement' })
|
property({ type: String, attribute: 'ar-placement' })
|
||||||
], ARModelViewerElement.prototype, "arPlacement", void 0);
|
], ARModelViewerElement.prototype, "arPlacement", void 0);
|
||||||
|
__decorate$2([
|
||||||
|
property({ type: String, attribute: 'ar-anchor' })
|
||||||
|
], ARModelViewerElement.prototype, "arAnchor", void 0);
|
||||||
__decorate$2([
|
__decorate$2([
|
||||||
property({ type: String, attribute: 'ar-modes' })
|
property({ type: String, attribute: 'ar-modes' })
|
||||||
], ARModelViewerElement.prototype, "arModes", void 0);
|
], ARModelViewerElement.prototype, "arModes", void 0);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"declaration": false,
|
"declaration": false,
|
||||||
|
"allowJs": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue