diff --git a/src/app/components/dialogs/dialog-reposition-annotation/dialog-reposition-annotation.component.html b/src/app/components/dialogs/dialog-reposition-annotation/dialog-reposition-annotation.component.html
new file mode 100644
index 00000000..bfcab0ce
--- /dev/null
+++ b/src/app/components/dialogs/dialog-reposition-annotation/dialog-reposition-annotation.component.html
@@ -0,0 +1,29 @@
+
{{ 'Reposition annotation' | translate }}
+
+@if (hasChanged()) {
+
+ {{
+ 'You have moved the annotation. Choose whether you want to apply the new position below, or reset to the original position.'
+ | translate
+ }}
+
+
+
+ {{
+ 'Applying the position will also generate a new preview image using the updated position.'
+ | translate
+ }}
+
+} @else {
+
+ {{
+ 'Move the annotation by clicking and dragging the annotation on the desired axis.' | translate
+ }}
+
+}
+
+ {{ 'Reset' | translate }}
+ {{
+ 'Apply' | translate
+ }}
+
diff --git a/src/app/components/dialogs/dialog-reposition-annotation/dialog-reposition-annotation.component.scss b/src/app/components/dialogs/dialog-reposition-annotation/dialog-reposition-annotation.component.scss
new file mode 100644
index 00000000..d35d8b51
--- /dev/null
+++ b/src/app/components/dialogs/dialog-reposition-annotation/dialog-reposition-annotation.component.scss
@@ -0,0 +1,19 @@
+:host {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 32px;
+ max-width: 480px;
+
+ color: white;
+ h1 {
+ font-size: var(--font-size-large);
+ font-weight: var(--font-weight-bold);
+ }
+
+ h1,
+ p,
+ ul {
+ margin: 0;
+ }
+}
diff --git a/src/app/components/dialogs/dialog-reposition-annotation/dialog-reposition-annotation.component.ts b/src/app/components/dialogs/dialog-reposition-annotation/dialog-reposition-annotation.component.ts
new file mode 100644
index 00000000..5db0b5c8
--- /dev/null
+++ b/src/app/components/dialogs/dialog-reposition-annotation/dialog-reposition-annotation.component.ts
@@ -0,0 +1,37 @@
+import { Component, computed, inject, signal } from '@angular/core';
+import { MatDialogRef } from '@angular/material/dialog';
+import { ButtonComponent, ButtonRowComponent } from '@kompakkt/komponents';
+import { TranslatePipe } from '../../../pipes/translate.pipe';
+import { Vector3 } from '@babylonjs/core';
+
+export type RepositionAnnotationDialogChoice = 'apply' | 'reset';
+
+type ChangedPositions = {
+ prev: Vector3;
+ curr: Vector3;
+};
+
+@Component({
+ selector: 'app-dialog-reposition-annotation',
+ imports: [ButtonComponent, ButtonRowComponent, TranslatePipe],
+ templateUrl: './dialog-reposition-annotation.component.html',
+ styleUrl: './dialog-reposition-annotation.component.scss',
+})
+export class DialogRepositionAnnotationComponent {
+ #ref = inject(MatDialogRef);
+ #positions = signal(undefined);
+ hasChanged = computed(() => {
+ const positions = this.#positions();
+ if (!positions) return false;
+ const distance = Vector3.Distance(positions.prev, positions.curr);
+ return distance > 0;
+ });
+
+ public close(choice: RepositionAnnotationDialogChoice) {
+ this.#ref.close(choice);
+ }
+
+ public updatePositions(positions: ChangedPositions) {
+ this.#positions.set(positions);
+ }
+}
diff --git a/src/app/components/entity-feature-annotations/annotation/annotation-for-editor.component.html b/src/app/components/entity-feature-annotations/annotation/annotation-for-editor.component.html
index 18140951..d8f44b1b 100644
--- a/src/app/components/entity-feature-annotations/annotation/annotation-for-editor.component.html
+++ b/src/app/components/entity-feature-annotations/annotation/annotation-for-editor.component.html
@@ -6,7 +6,7 @@
edit
+ open_with
}
@if (canUserReorder$ | async) {
diff --git a/src/app/components/entity-feature-annotations/annotation/annotation.component.html b/src/app/components/entity-feature-annotations/annotation/annotation.component.html
index aeb5b741..73e07ac9 100644
--- a/src/app/components/entity-feature-annotations/annotation/annotation.component.html
+++ b/src/app/components/entity-feature-annotations/annotation/annotation.component.html
@@ -33,6 +33,13 @@ {{ annotation.body.content.title || ('No title' | translate) }}
(click)="deleteAnnotation()"
>delete
+ open_with
(1);
public annotation$$ = this.annotation$.asObservable();
+ public annotationSignal = toSignal(this.annotation$$);
public positionTop = 0;
public positionLeft = 0;
@@ -115,6 +117,19 @@ export class AnnotationComponent {
map(([annotation, hiddenAnnotations]) => hiddenAnnotations.includes(annotation._id.toString())),
);
+ public isAnnotationUnsaved = computed(() => {
+ const unsaved = this.annotationService.unsavedAnnotations();
+ const annotation = this.annotationSignal();
+ if (!annotation) return false;
+ return unsaved.has(annotation._id.toString());
+ });
+
+ public async toggleRepositionMode() {
+ const annotation = await firstValueFrom(this.annotation$);
+ if (!annotation) return;
+ this.annotationService.initializeRepositionMode(annotation);
+ }
+
constructor() {
combineLatest([interval(15), this.annotation$])
.pipe(map(([_, annotation]) => annotation))
diff --git a/src/app/components/entity-feature-annotations/annotationwalkthrough/annotationwalkthrough.component.ts b/src/app/components/entity-feature-annotations/annotationwalkthrough/annotationwalkthrough.component.ts
index 981e7a35..8ee918f8 100644
--- a/src/app/components/entity-feature-annotations/annotationwalkthrough/annotationwalkthrough.component.ts
+++ b/src/app/components/entity-feature-annotations/annotationwalkthrough/annotationwalkthrough.component.ts
@@ -1,6 +1,6 @@
import { AsyncPipe } from '@angular/common';
import { Component, HostBinding, inject } from '@angular/core';
-import { toSignal } from '@angular/core/rxjs-interop';
+import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { MatIcon } from '@angular/material/icon';
import { ButtonComponent, TooltipDirective } from '@kompakkt/komponents';
import { BehaviorSubject, combineLatest, firstValueFrom, map } from 'rxjs';
@@ -15,6 +15,7 @@ import { AnnotationService } from '../../../services/annotation/annotation.servi
})
export class AnnotationwalkthroughComponent {
public annotationService = inject(AnnotationService);
+ #isRepositioning = toObservable(this.annotationService.isRepositioning);
public ranking$ = new BehaviorSubject(-1);
public selectedAnnotation$ = combineLatest([
@@ -27,11 +28,14 @@ export class AnnotationwalkthroughComponent {
);
public title$ = this.selectedAnnotation$.pipe(
- map(annotation => annotation?.body.content.title ?? 'Annotation Walkthrough'),
- );
- public showWalkthrough$ = this.annotationService.currentAnnotations$.pipe(
- map(annotations => annotations.length > 1),
+ map(annotation =>
+ annotation ? annotation.body.content.title || 'No title' : 'Annotation Walkthrough',
+ ),
);
+ public showWalkthrough$ = combineLatest([
+ this.annotationService.currentAnnotations$,
+ this.#isRepositioning,
+ ]).pipe(map(([annotations, isRepositioning]) => annotations.length > 1 && !isRepositioning));
public showWalkthrough = toSignal(this.showWalkthrough$);
diff --git a/src/app/components/sidenav-menu/sidenav-menu.component.scss b/src/app/components/sidenav-menu/sidenav-menu.component.scss
index 1639757c..bc7b27ec 100644
--- a/src/app/components/sidenav-menu/sidenav-menu.component.scss
+++ b/src/app/components/sidenav-menu/sidenav-menu.component.scss
@@ -33,6 +33,11 @@
left: var(--sidebar-width);
}
+ &.disabled {
+ pointer-events: none;
+ opacity: 0.5;
+ }
+
&:not(.is-open) div.button-close {
transition: var(--transition-duration);
transform: translateX(-100%);
diff --git a/src/app/components/sidenav-menu/sidenav-menu.component.ts b/src/app/components/sidenav-menu/sidenav-menu.component.ts
index 35f6ce72..8d6a3ada 100644
--- a/src/app/components/sidenav-menu/sidenav-menu.component.ts
+++ b/src/app/components/sidenav-menu/sidenav-menu.component.ts
@@ -7,25 +7,30 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
import { OverlayService } from '../../services/overlay/overlay.service';
import { ProcessingService } from '../../services/processing/processing.service';
import { toSignal } from '@angular/core/rxjs-interop';
+import { AnnotationService } from 'src/app/services/annotation/annotation.service';
@Component({
selector: 'app-sidenav-menu',
templateUrl: './sidenav-menu.component.html',
styleUrls: ['./sidenav-menu.component.scss'],
imports: [MatIcon, AsyncPipe, TranslatePipe, TooltipDirective, ButtonComponent],
+ host: {
+ '[class.is-open]': 'isOpen()',
+ '[class.disabled]': 'isDisabled()',
+ },
})
export class SidenavMenuComponent {
constructor(
public overlay: OverlayService,
public processing: ProcessingService,
+ public annotation: AnnotationService,
) {}
private sidenav = toSignal(this.overlay.sidenav$);
public mode = computed(() => this.sidenav()?.mode);
public isOpen = computed(() => this.sidenav()?.open && this.mode() !== '');
-
- @HostBinding('class.is-open')
- get isSidenavOpen() {
- return this.isOpen();
- }
+ public isDisabled = computed(() => {
+ const isRepositioning = this.annotation.isRepositioning();
+ return isRepositioning;
+ });
}
diff --git a/src/app/services/annotation/annotation.service.ts b/src/app/services/annotation/annotation.service.ts
index a7c03990..7b0b8458 100644
--- a/src/app/services/annotation/annotation.service.ts
+++ b/src/app/services/annotation/annotation.service.ts
@@ -1,17 +1,19 @@
import { moveItemInArray } from '@angular/cdk/drag-drop';
import { HttpErrorResponse } from '@angular/common/http';
-import { Injectable, inject } from '@angular/core';
+import { Injectable, signal } from '@angular/core';
import { MatDialog, MatDialogConfig } from '@angular/material/dialog';
-import { ActionManager, ExecuteCodeAction, PickingInfo, Tags, Vector3 } from '@babylonjs/core';
+import {
+ ActionManager,
+ ExecuteCodeAction,
+ MeshBuilder,
+ PositionGizmo,
+ Tags,
+ UtilityLayerRenderer,
+ Vector3,
+} from '@babylonjs/core';
import { BehaviorSubject, ReplaySubject, combineLatest, firstValueFrom, fromEvent } from 'rxjs';
import { distinct, filter, map, switchMap } from 'rxjs/operators';
-import {
- IAnnotation,
- IVector3,
- IAmbiguousVector3,
- asVector3,
- isAnnotation,
-} from '@kompakkt/common';
+import { IAnnotation, asVector3, isAnnotation } from '@kompakkt/common';
import { annotationFallback, annotationLogo } from '../../../assets/annotations/annotations';
import {
AuthConcern,
@@ -24,9 +26,16 @@ import { BackendService } from '../backend/backend.service';
import { MessageService } from '../message/message.service';
import { ProcessingService } from '../processing/processing.service';
import { UserdataService } from '../userdata/userdata.service';
-import { createMarker } from './visual3DElements';
+import { createMarker, createMarkerName } from './visual3DElements';
import { ReorderMovement } from 'src/app/components/entity-feature-annotations/annotation/annotation.component';
import { ExtenderTransformer } from '@kompakkt/plugins/extender';
+import { findClosestSplatPoint } from './helpers/find-closest-splat';
+import ObjectID from 'bson-objectid';
+import {
+ DialogRepositionAnnotationComponent,
+ RepositionAnnotationDialogChoice,
+} from 'src/app/components/dialogs/dialog-reposition-annotation/dialog-reposition-annotation.component';
+import { OverlayService } from '../overlay/overlay.service';
const isDefaultAnnotation = (annotation: IAnnotation) =>
!annotation.target.source.relatedCompilation ||
@@ -39,6 +48,9 @@ const isCompilationAnnotation = (annotation: IAnnotation) =>
const sortByRanking = (a: IAnnotation, b: IAnnotation): number =>
+a.ranking === +b.ranking ? 0 : +a.ranking < +b.ranking ? -1 : 1;
+// Gaussian splatting and point clouds require more complex picking logic, and for meshes we only need the point and the normal anyways, so we define a type for the picking result that is relevant for annotations in our use case
+type PartialPick = { pickedPoint: Vector3; pickedNormal: Vector3 };
+
@Injectable({
providedIn: 'root',
})
@@ -53,7 +65,7 @@ export class AnnotationService {
private defaultOffset = 0;
- public picked$ = new ReplaySubject();
+ public picked$ = new ReplaySubject();
public debouncedPicked$ = this.isAnnotationMode$.pipe(
filter(isAnnotationMode => isAnnotationMode),
switchMap(() => this.picked$),
@@ -67,6 +79,7 @@ export class AnnotationService {
private processing: ProcessingService,
private dialog: MatDialog,
private userdata: UserdataService,
+ private overlay: OverlayService,
) {
this.debouncedPicked$.subscribe(result => {
this.createNewAnnotation(result);
@@ -219,19 +232,147 @@ export class AnnotationService {
await this.changedRankingPositions();
}
- // Die Annotationsfunktionalität wird zue aktuellen Entity hinzugefügt
- public initializeAnnotationMode() {
- fromEvent(this.babylon.getCanvas(), 'dblclick').subscribe(() => {
- const scene = this.babylon.getScene();
- const result = scene.pick(scene.pointerX, scene.pointerY, mesh => mesh.isPickable);
- if (!result?.pickedPoint) return;
- this.picked$.next(result);
+ isRepositioning = signal(false);
+ public async initializeRepositionMode(annotation: IAnnotation) {
+ const markerName = createMarkerName(annotation._id.toString());
+ const marker = this.babylon.getScene().getMeshByName(markerName);
+ if (!marker) {
+ console.warn('Marker not found for annotation', annotation);
+ return;
+ }
+ console.log('Initializing reposition mode for annotation', { annotation, marker });
+
+ const allAnnotations = await firstValueFrom(this.currentAnnotations$);
+ const hiddenAnnotations = await firstValueFrom(this.hiddenAnnotations$);
+ // Hide all markers except the one we want to reposition, to avoid confusion during repositioning
+ this.hiddenAnnotations$.next([
+ ...allAnnotations
+ .filter(anno => anno._id.toString() !== annotation._id.toString())
+ .map(anno => anno._id.toString()),
+ ]);
+
+ const originalPosition = marker.position.clone();
+ const transformMesh = MeshBuilder.CreateBox(
+ `${markerName}_reposition_transform`,
+ { size: 0 },
+ this.babylon.getScene(),
+ );
+ transformMesh.visibility = 0;
+ transformMesh.position = marker.position.clone();
+ marker.setParent(transformMesh);
+ marker.setPositionWithLocalVector(Vector3.Zero());
+
+ const scene = this.babylon.getScene();
+ const utilLayer = new UtilityLayerRenderer(scene);
+ const gizmo = new PositionGizmo(utilLayer, 2);
+ gizmo.updateGizmoPositionToMatchAttachedMesh = true;
+ gizmo.updateGizmoRotationToMatchAttachedMesh = true;
+ gizmo.attachedMesh = transformMesh;
+
+ this.isRepositioning.set(true);
+ void this.setSelectedAnnotation('');
+ this.overlay.toggleSidenav('annotation', false);
+
+ const dialogRef = this.dialog.open<
+ DialogRepositionAnnotationComponent,
+ void,
+ RepositionAnnotationDialogChoice
+ >(DialogRepositionAnnotationComponent, {
+ disableClose: true,
+ hasBackdrop: false,
+ panelClass: 'reposition-dialog-panel',
+ });
+
+ const dragRef = gizmo.onDragEndObservable.add(() => {
+ // Update camera to current position after drag, which is the position of the transformMesh
+ this.babylon.cameraManager.setActiveCameraTarget(transformMesh.position, 2);
+ dialogRef.componentInstance.updatePositions({
+ prev: originalPosition,
+ curr: transformMesh.getAbsolutePosition(),
+ });
+ });
+
+ const result = await firstValueFrom(dialogRef.afterClosed());
+
+ gizmo.onDragEndObservable.remove(dragRef);
+ gizmo.dispose();
+ utilLayer.dispose();
+ const nodePosition = transformMesh.position.clone();
+ marker.setParent(null);
+ transformMesh.dispose();
+
+ if (result !== 'apply') {
+ marker.position = originalPosition.clone();
+ } else {
+ marker.position = nodePosition.clone();
+ }
+
+ const screenshot = await this.babylon.createPreviewScreenshot();
+ annotation.body.content.relatedPerspective.preview = screenshot;
+
+ this.hiddenAnnotations$.next([...hiddenAnnotations]);
+ this.isRepositioning.set(false);
+ void this.setSelectedAnnotation(annotation._id.toString());
+ this.overlay.toggleSidenav('annotation', true);
+ }
+
+ public async initializeAnnotationMode() {
+ const scene = this.babylon.getScene();
+ const mediaType = await firstValueFrom(this.processing.mediaType$);
+ if (!mediaType) {
+ console.warn('Media type is undefined, cannot initialize annotation mode');
+ return;
+ }
+
+ const canvas = this.babylon.getCanvas();
+ fromEvent(canvas, 'dblclick').subscribe(() => {
+ const pickResult = (() => {
+ switch (mediaType) {
+ case 'splat': {
+ const closestSplat = findClosestSplatPoint(scene);
+ console.log('closestSplat', closestSplat);
+ if (!closestSplat) return null;
+ // Get normal vector from current camera to the splat point
+ const camera = this.babylon.getActiveCamera();
+ if (!camera) return;
+ const normalVector = closestSplat.subtract(camera.position).normalize();
+ return { pickedPoint: closestSplat, pickedNormal: normalVector };
+ }
+ default: {
+ const result = scene.pick(scene.pointerX, scene.pointerY, mesh => mesh.isPickable);
+ if (!result?.pickedPoint) return;
+ const pickedNormal = (() => {
+ const trueTrue = result.getNormal(true, true);
+ if (trueTrue) return trueTrue;
+ const trueFalse = result.getNormal(true, false);
+ if (trueFalse) return trueFalse;
+ console.warn(
+ 'Could not get normal from picking result, using fallback normal',
+ result,
+ );
+ // Fallback normal pointing towards the camera
+ const camera = this.babylon.getActiveCamera();
+ if (!camera) return;
+ return camera.position.subtract(result.pickedPoint).normalize();
+ })();
+ if (!pickedNormal) {
+ console.warn('Could not get normal from picking result after fallback');
+ return null;
+ }
+ return { pickedPoint: result.pickedPoint, pickedNormal };
+ }
+ }
+ })();
+ if (!pickResult) {
+ console.warn('No valid picking result, not creating annotation');
+ return;
+ }
+ this.picked$.next(pickResult);
});
this.setAnnotationMode(false);
}
- // Das aktuelle Entityl wird anklickbar und damit annotierbar
private async setAnnotationMode(value: boolean) {
const mediaType = await firstValueFrom(this.processing.mediaType$);
if (mediaType === 'video' || mediaType === 'audio') return;
@@ -243,7 +384,7 @@ export class AnnotationService {
}
}
- public async createNewAnnotation(result: PickingInfo) {
+ public async createNewAnnotation(result: PartialPick) {
const camera = this.babylon.cameraManager.getInitialPosition();
const currentAnnotations = await firstValueFrom(this.currentAnnotations$);
const entity = await firstValueFrom(this.processing.entity$);
@@ -255,19 +396,13 @@ export class AnnotationService {
if (!entity) {
throw new Error(`this.entity not defined: ${entity}`);
}
- const generatedId = this.backend.generateEntityId();
+ const generatedId = new ObjectID().toString();
const personName = userdata ? userdata.fullname : 'guest';
const personID = userdata ? userdata._id : 'guest';
const referencePoint = result.pickedPoint;
- const referenceNormal = (() => {
- const trueTrue = result.getNormal(true, true);
- if (trueTrue) return trueTrue;
- const trueFalse = result.getNormal(true, false);
- return trueFalse;
- })();
-
+ const referenceNormal = result.pickedNormal;
if (!referencePoint || !referenceNormal) {
console.warn('Could not get normal from picking result, using fallback normal', result);
return;
@@ -336,6 +471,15 @@ export class AnnotationService {
this.add(transformedAnnotation);
}
+ unsavedAnnotations = signal(new Set());
+ #addUnsavedAnnotation(id: string) {
+ this.unsavedAnnotations.update(set => {
+ const newSet = new Set(set);
+ newSet.add(id);
+ return newSet;
+ });
+ }
+
private async add(_annotation: IAnnotation) {
let newAnnotation = _annotation;
newAnnotation.lastModificationDate = new Date().toISOString();
@@ -344,14 +488,23 @@ export class AnnotationService {
const isFallback = await firstValueFrom(this.processing.fallbackEntityLoaded$);
const updateBackend = !isStandalone && !isDefault && !isFallback;
if (updateBackend) {
- this.backend
- .updateAnnotation(_annotation)
- .then((resultAnnotation: IAnnotation) => {
- newAnnotation = resultAnnotation;
- })
- .catch((errorMessage: any) => {
- console.log(errorMessage);
- });
+ const hasContent =
+ (newAnnotation.body.content.title + newAnnotation.body.content.description).trim().length >
+ 0;
+ if (hasContent) {
+ this.backend
+ .updateAnnotation(_annotation)
+ .then((resultAnnotation: IAnnotation) => {
+ newAnnotation = resultAnnotation;
+ })
+ .catch((errorMessage: any) => {
+ this.#addUnsavedAnnotation(newAnnotation._id.toString());
+ console.log(errorMessage);
+ });
+ } else {
+ console.warn('Not saving annotation with empty content to backend');
+ this.#addUnsavedAnnotation(newAnnotation._id.toString());
+ }
}
this.drawMarker(newAnnotation);
this.annotations$.next(this.annotations$.getValue().concat(newAnnotation));
@@ -520,12 +673,17 @@ export class AnnotationService {
const perspective = selectedAnnotation.body.content.relatedPerspective;
if (perspective !== undefined) {
this.babylon.cameraManager.setCameraType('ArcRotateCamera');
+ // Saved position is { x: alpha, y: beta, z: radius } (spherical ArcRotate)
const position = asVector3(perspective.position);
const target = asVector3(perspective.target);
- this.babylon.cameraManager.moveActiveCameraToPosition(
- Vector3.FromArray(Object.values(position)),
- );
- this.babylon.cameraManager.setActiveCameraTarget(Vector3.FromArray(Object.values(target)));
+ this.babylon.cameraManager.smoothCameraTransitionFromSpherical({
+ camera: this.babylon.cameraManager.getActiveCamera(),
+ scene: this.babylon.getScene(),
+ alpha: position.x,
+ beta: position.y,
+ radius: position.z,
+ target: Vector3.FromArray(Object.values(target)),
+ });
}
this.babylon.hideMesh(selectedAnnotation._id.toString(), true);
}
@@ -577,7 +735,7 @@ export class AnnotationService {
collectionId: string,
annotationLength: number,
) {
- const generatedId = this.backend.generateEntityId();
+ const generatedId = new ObjectID().toString();
const entity = await firstValueFrom(this.processing.entity$);
const userdata = await firstValueFrom(this.userdata.userData$);
diff --git a/src/app/services/annotation/helpers/find-closest-splat.ts b/src/app/services/annotation/helpers/find-closest-splat.ts
new file mode 100644
index 00000000..056b0eb8
--- /dev/null
+++ b/src/app/services/annotation/helpers/find-closest-splat.ts
@@ -0,0 +1,99 @@
+import { GaussianSplattingMesh, Matrix, Nullable, Scene, Vector3, Viewport } from '@babylonjs/core';
+
+export const findClosestSplatPoint = (scene: Scene): Nullable => {
+ const gsMesh = scene.getMeshByName('GaussianSplatting') as Nullable;
+ if (!gsMesh) return null;
+ // ArrayBuffer: postions (3 floats), size (3 floats), color (4 bytes), orientation quaternion (4 bytes)
+ const splatsData = gsMesh.splatsData;
+ if (!splatsData) return null;
+
+ const { pointerX, pointerY } = scene;
+ const camera = scene.activeCamera;
+ if (!camera) return null;
+
+ const engine = scene.getEngine();
+ const screenWidth = engine.getRenderWidth();
+ const screenHeight = engine.getRenderHeight();
+
+ // Constants for splat data structure
+ const SIZEOF_FLOAT = 4;
+ const SIZEOF_BYTE = 1; // For clarity
+
+ // Stride calculation based on your comment:
+ // Position (3 floats), Size (3 floats), Color (4 bytes), Orientation (4 bytes)
+ const POSITION_OFFSET = 0;
+ const POSITION_COMPONENTS = 3;
+
+ // If your splat data format is different, adjust these:
+ const SIZE_COMPONENTS = 3;
+ const COLOR_COMPONENTS_BYTE = 4;
+ const ORIENTATION_COMPONENTS_BYTE = 4;
+
+ const STRIDE =
+ POSITION_COMPONENTS * SIZEOF_FLOAT +
+ SIZE_COMPONENTS * SIZEOF_FLOAT +
+ COLOR_COMPONENTS_BYTE * SIZEOF_BYTE +
+ ORIENTATION_COMPONENTS_BYTE * SIZEOF_BYTE; // Should be 32 based on your description
+
+ if (STRIDE <= 0 || splatsData.byteLength % STRIDE !== 0) {
+ console.error('Invalid STRIDE or splatsData length.', STRIDE, splatsData.byteLength);
+ return null;
+ }
+
+ const numSplats = splatsData.byteLength / STRIDE;
+ const dataView = new DataView(splatsData);
+
+ let closestSplatWorldPosition: Nullable = null;
+ let minScreenDistanceSq = Infinity;
+ let closestSplatDepth = Infinity;
+
+ const worldMatrix = gsMesh.getWorldMatrix();
+ const transformMatrix = scene.getTransformMatrix(); // View-Projection matrix
+ const viewport = new Viewport(0, 0, screenWidth, screenHeight);
+
+ // Define a maximum distance in screen pixels for a splat to be considered "close"
+ // Adjust this threshold as needed.
+ const MAX_PICKING_DISTANCE_SCREEN_SQ = 50 * 50; // e.g., 50 pixels radius
+
+ for (let i = 0; i < numSplats; ++i) {
+ const currentOffset = i * STRIDE;
+
+ // Read position (assuming Little Endian, common for .splat files)
+ const localX = dataView.getFloat32(currentOffset + POSITION_OFFSET, true);
+ const localY = dataView.getFloat32(currentOffset + POSITION_OFFSET + SIZEOF_FLOAT, true);
+ const localZ = dataView.getFloat32(currentOffset + POSITION_OFFSET + 2 * SIZEOF_FLOAT, true);
+ const splatLocalPosition = new Vector3(localX, localY, localZ);
+
+ // Transform splat position from local mesh space to world space
+ const splatWorldPosition = Vector3.TransformCoordinates(splatLocalPosition, worldMatrix);
+
+ // Project world position to 2D screen coordinates
+ // Note: The first matrix argument to Vector3.Project is the world matrix of the object
+ // whose vertices (in local space) are being projected. Since splatWorldPosition is already
+ // in world space, we use Matrix.IdentityReadOnly.
+ const screenPosition = Vector3.Project(
+ splatWorldPosition,
+ Matrix.IdentityReadOnly, // Object's world matrix (already applied)
+ transformMatrix, // Combined ViewProjection matrix
+ viewport,
+ );
+
+ const dx = screenPosition.x - pointerX;
+ const dy = screenPosition.y - pointerY;
+ const distSq = dx * dx + dy * dy;
+
+ if (distSq < MAX_PICKING_DISTANCE_SCREEN_SQ) {
+ // If this splat is closer on screen OR
+ // if it's at a similar screen distance but closer to the camera (smaller depth value)
+ if (
+ distSq < minScreenDistanceSq ||
+ (Math.abs(distSq - minScreenDistanceSq) < 1e-5 && screenPosition.z < closestSplatDepth)
+ ) {
+ minScreenDistanceSq = distSq;
+ closestSplatWorldPosition = splatWorldPosition;
+ closestSplatDepth = screenPosition.z;
+ }
+ }
+ }
+ return closestSplatWorldPosition;
+};
diff --git a/src/app/services/annotation/visual3DElements.ts b/src/app/services/annotation/visual3DElements.ts
index 008a9dc1..5391718c 100644
--- a/src/app/services/annotation/visual3DElements.ts
+++ b/src/app/services/annotation/visual3DElements.ts
@@ -1,10 +1,12 @@
import { Mesh, MeshBuilder, Scene, Space, StandardMaterial, Tags, Vector3 } from '@babylonjs/core';
+export const createMarkerName = (id: string) => `${id}_marker`;
+
export const createMarker = (scene: Scene, id: string, position?: Vector3, normal?: Vector3) => {
const radius = 0.1;
const mat = new StandardMaterial(`${id}_material`, scene);
mat.alpha = 0;
- const markerName = `${id}_marker`;
+ const markerName = createMarkerName(id);
const marker = MeshBuilder.CreateDisc(markerName, { radius }, scene);
Tags.AddTagsTo(marker, 'marker');
Tags.AddTagsTo(marker, 'solid_marker');
diff --git a/src/app/services/babylon/babylon.service.ts b/src/app/services/babylon/babylon.service.ts
index 6a7db092..49b704e2 100644
--- a/src/app/services/babylon/babylon.service.ts
+++ b/src/app/services/babylon/babylon.service.ts
@@ -31,7 +31,6 @@ import {
Color3,
DirectionalLight,
ShadowGenerator,
- Mesh,
} from '@babylonjs/core';
import '@babylonjs/core/Debug/debugLayer';
import '@babylonjs/inspector';
@@ -43,9 +42,9 @@ import {
cameraDefaults$,
createDefaultCamera,
createUniversalCamera,
- moveCameraToTarget,
- setCameraTarget,
setUpCamera,
+ smoothCameraTransition,
+ smoothCameraTransitionFromSpherical,
} from './camera-handler';
import { IAudioContainer, IImageContainer, IVideoContainer } from './container.interfaces';
import {
@@ -61,6 +60,7 @@ import { EptImporter } from './importers/ept/ept-importer';
import { CopcImporter } from './importers/copc/copc-importer';
import { LoadingScreenService } from './loadingscreen';
import { InspectorToken, ShowInspector } from '@babylonjs/inspector';
+import { createOrbitGizmo } from './orbit-gizmo';
RegisterSceneLoaderPlugin(new CopcImporter());
RegisterSceneLoaderPlugin(new EptImporter());
@@ -72,15 +72,42 @@ export class BabylonService {
private environmentInjector = inject(EnvironmentInjector);
private loadingScreen = inject(LoadingScreenService);
- // Create an instance of RenderCanvasComponent
- // and use this for the Engine
- private canvasRef = createComponent(RenderCanvasComponent, {
- environmentInjector: this.environmentInjector,
- });
- private canvas = this.canvasRef.location.nativeElement.childNodes[0] as HTMLCanvasElement;
+ // Create instances of RenderCanvasComponent, which will be injected into SceneComponent
+ // One for the main canvas, and one for the OrbitGizmo
+ // Getters for quick-access to main canvas element and ref
+ private canvasRefs = {
+ main: createComponent(RenderCanvasComponent, { environmentInjector: this.environmentInjector }),
+ gizmo: createComponent(RenderCanvasComponent, {
+ environmentInjector: this.environmentInjector,
+ }),
+ };
+ get canvasRef() {
+ return this.canvasRefs.main;
+ }
+ private canvases = {
+ main: this.canvasRefs.main.location.nativeElement.childNodes[0] as HTMLCanvasElement,
+ gizmo: this.canvasRefs.gizmo.location.nativeElement.childNodes[0] as HTMLCanvasElement,
+ };
+ get canvas() {
+ return this.canvases.main;
+ }
+
+ private engines: Record = {};
+ get engine() {
+ return this.engines.main as Engine;
+ }
+ set engine(engine: Engine) {
+ this.engines.main = engine;
+ }
+
+ private scenes: Record = {};
+ get scene() {
+ return this.scenes.main as Scene;
+ }
+ set scene(scene: Scene) {
+ this.scenes.main = scene;
+ }
- private engine: Engine;
- private scene: Scene;
private effects: PostProcess[] = [];
public containers = {
@@ -91,16 +118,24 @@ export class BabylonService {
};
public cameraManager = {
- getActiveCamera: this.getActiveCamera,
- moveActiveCameraToPosition: (positionVector: Vector3) => {
- moveCameraToTarget(this.getActiveCamera(), this.scene, positionVector);
- },
+ getActiveCamera: () => this.getActiveCamera(),
+ moveActiveCameraToPosition: (position: Vector3) =>
+ smoothCameraTransition({
+ camera: this.getActiveCamera(),
+ scene: this.scene,
+ position,
+ }),
resetCamera: () => {
- this.cameraManager.setCameraType('ArcRotateCamera');
- const camera = this.getActiveCamera();
- const { position, target } = cameraDefaults$.getValue();
- setCameraTarget(camera, target);
- moveCameraToTarget(camera, this.scene, position);
+ const camera = this.cameraManager.setCameraType('ArcRotateCamera');
+ const { alpha, beta, radius, target } = cameraDefaults$.getValue();
+ smoothCameraTransitionFromSpherical({
+ camera,
+ scene: this.scene,
+ alpha,
+ beta,
+ radius,
+ target,
+ });
},
getInitialPosition: () => ({
cameraType: 'arcRotateCam',
@@ -115,14 +150,25 @@ export class BabylonService {
z: this.getActiveCamera().target.z,
},
}),
- setActiveCameraTarget: (targetVector: Vector3) =>
- setCameraTarget(this.getActiveCamera(), targetVector),
+ smoothCameraTransition,
+ smoothCameraTransitionFromSpherical,
+ setActiveCameraTarget: (target: Vector3, speed?: number) =>
+ smoothCameraTransition(
+ {
+ camera: this.getActiveCamera(),
+ scene: this.scene,
+ target,
+ },
+ speed,
+ ),
setUpActiveCamera: (maxSize: number, mediaType: string) =>
setUpCamera(this.getActiveCamera(), maxSize, mediaType),
- setCameraType: (type: 'ArcRotateCamera' | 'UniversalCamera') => {
+ setCameraType: (
+ type: 'ArcRotateCamera' | 'UniversalCamera',
+ ) => {
const cameras = this.scene.cameras;
const currentType = this.cameraManager.cameraType$.getValue();
- if (type === currentType) return;
+ if (type === currentType) return this.scene.activeCamera as T;
this.cameraManager.cameraType$.next(type);
const rotateCamera = cameras.find(
@@ -132,7 +178,7 @@ export class BabylonService {
camera => camera instanceof UniversalCamera,
)! as UniversalCamera;
- const { position, target } = cameraDefaults$.getValue();
+ const { alpha, beta, radius, target } = cameraDefaults$.getValue();
if (type === 'UniversalCamera') {
universalCamera.position = rotateCamera.position.clone();
universalCamera.setTarget(target);
@@ -140,9 +186,16 @@ export class BabylonService {
} else {
rotateCamera.position = universalCamera.position.clone();
this.scene.activeCamera = rotateCamera;
- setCameraTarget(rotateCamera, target);
- moveCameraToTarget(rotateCamera, this.scene, position);
+ smoothCameraTransitionFromSpherical({
+ camera: rotateCamera,
+ scene: this.scene,
+ alpha,
+ beta,
+ radius,
+ target,
+ });
}
+ return this.scene.activeCamera as T;
},
cameraSpeed: 1.0,
cameraType$: new BehaviorSubject<'ArcRotateCamera' | 'UniversalCamera'>('ArcRotateCamera'),
@@ -161,6 +214,7 @@ export class BabylonService {
constructor() {
this.canvas.id = 'renderCanvas';
+
this.engine = new Engine(this.canvas, true, {
audioEngine: true,
preserveDrawingBuffer: true,
@@ -218,23 +272,47 @@ export class BabylonService {
camera.speed = this.cameraManager.cameraSpeed;
});
+ const orbitGizmoResult = this.initializeOrbitGizmo();
+
this.engine.runRenderLoop(() => {
this.scene.render();
+ orbitGizmoResult.render();
});
// Global - for debugging
- (window as any)['enableInspector'] = () => this.enableInspector();
- (window as any)['disableInspector'] = () => this.disableInspector();
- (window as any)['scene'] = () => this.getScene();
+ (window as any)['enableInspector'] = (name?: string) => this.enableInspector(name);
+ (window as any)['disableInspector'] = (name?: string) => this.disableInspector(name);
+ (window as any)['scene'] = (name?: string) => this.getScene(name);
+ }
+
+ private inspectorTokens: Record = {};
+ public enableInspector(name = 'main') {
+ if (this.inspectorTokens[name]) return;
+ const scene = this.scenes[name];
+ if (!scene) {
+ console.warn(`No scene found with name ${name}`);
+ return;
+ }
+ this.inspectorTokens[name] = ShowInspector(scene);
}
- private inspectorToken?: InspectorToken;
- public enableInspector() {
- this.inspectorToken = ShowInspector(this.scene);
+ public disableInspector(name = 'main') {
+ const token = this.inspectorTokens[name];
+ if (token) {
+ token.dispose();
+ delete this.inspectorTokens[name];
+ }
}
- public disableInspector() {
- this.inspectorToken?.dispose();
+ private initializeOrbitGizmo() {
+ const { render, scene, engine } = createOrbitGizmo({
+ canvas: this.canvases.gizmo,
+ getArcRotateCamera: () => this.getActiveCamera(),
+ mainEngine: this.engine,
+ });
+ this.engines.gizmo = engine;
+ this.scenes.gizmo = scene;
+ return { render, scene, engine };
}
private defaultShadowVariables = {
@@ -256,7 +334,8 @@ export class BabylonService {
this.scene,
);
ground.setAbsolutePosition(new Vector3(20, 0, 20));
- const mat = new StandardMaterial('shadowGroundMat');
+
+ const mat = new StandardMaterial('shadowGroundMat', this.scene);
mat.diffuseColor = this.defaultShadowVariables.groundDiffuseColor;
mat.specularColor = this.defaultShadowVariables.groundSpecularColor;
mat.specularPower = this.defaultShadowVariables.groundSpecularPower;
@@ -283,12 +362,16 @@ export class BabylonService {
return shadowGenerator;
}
- public getScene(): Scene {
- return this.scene;
+ public getScene(name = 'main'): Scene {
+ return this.scenes[name];
}
public attachCanvas(viewContainerRef: ViewContainerRef) {
- viewContainerRef.insert(this.canvasRef.hostView);
+ for (const canvasRef of Object.values(this.canvasRefs)) {
+ if (!canvasRef.hostView.destroyed) {
+ viewContainerRef.insert(canvasRef.hostView);
+ }
+ }
}
public getCanvas(): HTMLCanvasElement {
@@ -303,8 +386,20 @@ export class BabylonService {
}
public resize(): void {
- this.engine.resize();
- this.scene.cameras.forEach(camera => camera.attachControl(this.canvas, false));
+ for (const engine of Object.values(this.engines)) {
+ engine.resize();
+ }
+ for (const [name, scene] of Object.entries(this.scenes)) {
+ const canvas = this.canvases[name as keyof typeof this.canvases];
+ if (!canvas) {
+ throw new Error(`No canvas found for scene ${name}`);
+ }
+ scene.cameras.forEach(camera => camera.attachControl(canvas, false));
+ }
+ }
+
+ public setOrbitGizmoVisible(visible: boolean): void {
+ this.canvases.gizmo.style.setProperty('display', visible ? 'block' : 'none');
}
public getEngine(): Engine {
diff --git a/src/app/services/babylon/camera-handler.ts b/src/app/services/babylon/camera-handler.ts
index 3143ac69..0e53493f 100644
--- a/src/app/services/babylon/camera-handler.ts
+++ b/src/app/services/babylon/camera-handler.ts
@@ -11,9 +11,16 @@ import { BehaviorSubject } from 'rxjs';
const halfPi = Math.PI / 180;
-export type CameraDefaults = { position: Vector3; target: Vector3 };
+export type CameraDefaults = {
+ alpha: number;
+ beta: number;
+ radius: number;
+ target: Vector3;
+};
export const cameraDefaults$ = new BehaviorSubject({
- position: new Vector3(0, 10, 100),
+ alpha: -(Math.PI / 2),
+ beta: Math.PI / 2,
+ radius: 100,
target: Vector3.Zero(),
});
cameraDefaults$.subscribe(defaults => {
@@ -21,9 +28,15 @@ cameraDefaults$.subscribe(defaults => {
});
export const resetCamera = (camera: ArcRotateCamera, scene: Scene) => {
- const { position, target } = cameraDefaults$.getValue();
- setCameraTarget(camera, target);
- moveCameraToTarget(camera, scene, position);
+ const { alpha, beta, radius, target } = cameraDefaults$.getValue();
+ smoothCameraTransitionFromSpherical({
+ camera,
+ scene,
+ alpha,
+ beta,
+ radius,
+ target,
+ });
return camera;
};
@@ -103,58 +116,189 @@ export const setUpCamera = (camera: ArcRotateCamera, maxSize: number, mediaType:
return camera;
};
-const createAnimationsForCamera = (
- camera: ArcRotateCamera,
- positionVector: Vector3,
- cameraAxis = ['x', 'y', 'z'],
- positionAxis = ['x', 'y', 'z'],
- frames = 30,
+const FPS = 24;
+
+export const smoothCameraTransition = (
+ {
+ camera,
+ scene,
+ position,
+ target,
+ }: {
+ camera: ArcRotateCamera;
+ scene: Scene;
+ position?: Vector3;
+ target?: Vector3;
+ },
+ speed = 1,
) => {
- const creatAnimCam = (camAxis: string, posAxis: string) => {
- const anim = new Animation(
- 'animCam',
- camAxis,
- frames,
- Animation.ANIMATIONTYPE_FLOAT,
- Animation.ANIMATIONLOOPMODE_CYCLE,
- );
- const multipleProperties = camAxis.indexOf('.') !== -1;
- let value = (camera as any)[camAxis];
- if (multipleProperties) {
- const props = camAxis.split('.');
- value = (camera as any)[props[0]][props[1]];
- }
- anim.setKeys([
- { frame: 0, value },
- { frame: frames, value: (positionVector as any)[posAxis] },
- ]);
- return anim;
- };
-
- const arr: Animation[] = [];
- for (let i = 0; i < 3; i++) {
- const anim = creatAnimCam(cameraAxis[i], positionAxis[i]);
- const ease = new QuarticEase();
- ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT);
- anim.setEasingFunction(ease);
- arr.push(anim);
- }
- return arr;
+ console.log(
+ 'SmoothCameraTransition from',
+ JSON.stringify([camera.position, camera.target]),
+ 'to',
+ JSON.stringify([position, target]),
+ );
+
+ if (!position && !target) return;
+ camera.position = new Vector3(camera.position.x, camera.position.y, camera.position.z);
+ camera.target = new Vector3(camera.target.x, camera.target.y, camera.target.z);
+
+ position ??= camera.position;
+ target ??= camera.target;
+
+ const animAlpha = new Animation(
+ 'camera_alpha_animation',
+ 'alpha',
+ FPS,
+ Animation.ANIMATIONTYPE_FLOAT,
+ Animation.ANIMATIONLOOPMODE_CYCLE,
+ );
+ const animBeta = new Animation(
+ 'camera_beta_animation',
+ 'beta',
+ FPS,
+ Animation.ANIMATIONTYPE_FLOAT,
+ Animation.ANIMATIONLOOPMODE_CYCLE,
+ );
+ const animRadius = new Animation(
+ 'camera_radius_animation',
+ 'radius',
+ FPS,
+ Animation.ANIMATIONTYPE_FLOAT,
+ Animation.ANIMATIONLOOPMODE_CYCLE,
+ );
+ const animTarget = new Animation(
+ 'camera_target_animation',
+ 'target',
+ FPS,
+ Animation.ANIMATIONTYPE_VECTOR3,
+ Animation.ANIMATIONLOOPMODE_CYCLE,
+ );
+
+ const tempCamera = camera.clone('tempCamera') as ArcRotateCamera;
+ tempCamera.setTarget(target);
+ tempCamera.setPosition(position);
+ const tempAlpha = tempCamera.alpha;
+ const tempBeta = tempCamera.beta;
+ const tempRadius = tempCamera.radius;
+ tempCamera.dispose();
+
+ animAlpha.setKeys([
+ { frame: 0, value: camera.alpha },
+ { frame: FPS, value: tempAlpha },
+ ]);
+
+ animBeta.setKeys([
+ { frame: 0, value: camera.beta },
+ { frame: FPS, value: tempBeta },
+ ]);
+
+ animRadius.setKeys([
+ { frame: 0, value: camera.radius },
+ { frame: FPS, value: tempRadius },
+ ]);
+
+ animTarget.setKeys([
+ { frame: 0, value: camera.target.clone() },
+ { frame: FPS, value: target.clone() },
+ ]);
+
+ const ease = new QuarticEase();
+ ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT);
+ animTarget.setEasingFunction(ease);
+ animAlpha.setEasingFunction(ease);
+ animBeta.setEasingFunction(ease);
+ animRadius.setEasingFunction(ease);
+
+ const animations = [animTarget, animAlpha, animBeta, animRadius];
+
+ return scene.beginDirectAnimation(camera, animations, 0, FPS, false, speed);
};
-export const moveCameraToTarget = (
- camera: ArcRotateCamera,
- scene: Scene,
- positionVector: Vector3,
+export const smoothCameraTransitionFromSpherical = (
+ {
+ camera,
+ scene,
+ alpha,
+ beta,
+ radius,
+ target,
+ }: {
+ camera: ArcRotateCamera;
+ scene: Scene;
+ alpha: number;
+ beta: number;
+ radius: number;
+ target: Vector3;
+ },
+ speed = 1,
) => {
- console.log('move Cam to', positionVector);
+ console.log(
+ 'SmoothCameraTransitionFromSpherical from',
+ JSON.stringify([camera.alpha, camera.beta, camera.radius, camera.target]),
+ 'to',
+ JSON.stringify([alpha, beta, radius, target]),
+ );
+
+ camera.target = new Vector3(target.x, target.y, target.z);
- camera.animations.push(
- ...createAnimationsForCamera(camera, positionVector, ['alpha', 'beta', 'radius']),
+ const animAlpha = new Animation(
+ 'camera_alpha_animation',
+ 'alpha',
+ FPS,
+ Animation.ANIMATIONTYPE_FLOAT,
+ Animation.ANIMATIONLOOPMODE_CYCLE,
);
- scene.beginAnimation(camera, 0, 30, false, 1, () => {});
-};
+ const animBeta = new Animation(
+ 'camera_beta_animation',
+ 'beta',
+ FPS,
+ Animation.ANIMATIONTYPE_FLOAT,
+ Animation.ANIMATIONLOOPMODE_CYCLE,
+ );
+ const animRadius = new Animation(
+ 'camera_radius_animation',
+ 'radius',
+ FPS,
+ Animation.ANIMATIONTYPE_FLOAT,
+ Animation.ANIMATIONLOOPMODE_CYCLE,
+ );
+ const animTarget = new Animation(
+ 'camera_target_animation',
+ 'target',
+ FPS,
+ Animation.ANIMATIONTYPE_VECTOR3,
+ Animation.ANIMATIONLOOPMODE_CYCLE,
+ );
+
+ animAlpha.setKeys([
+ { frame: 0, value: camera.alpha },
+ { frame: FPS, value: alpha },
+ ]);
+
+ animBeta.setKeys([
+ { frame: 0, value: camera.beta },
+ { frame: FPS, value: beta },
+ ]);
+
+ animRadius.setKeys([
+ { frame: 0, value: camera.radius },
+ { frame: FPS, value: radius },
+ ]);
+
+ animTarget.setKeys([
+ { frame: 0, value: camera.target.clone() },
+ { frame: FPS, value: target.clone() },
+ ]);
+
+ const ease = new QuarticEase();
+ ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT);
+ animTarget.setEasingFunction(ease);
+ animAlpha.setEasingFunction(ease);
+ animBeta.setEasingFunction(ease);
+ animRadius.setEasingFunction(ease);
+
+ const animations = [animTarget, animAlpha, animBeta, animRadius];
-export const setCameraTarget = (camera: ArcRotateCamera, target: Vector3) => {
- camera.setTarget(target, true);
+ return scene.beginDirectAnimation(camera, animations, 0, FPS, false, speed);
};
diff --git a/src/app/services/babylon/orbit-gizmo.ts b/src/app/services/babylon/orbit-gizmo.ts
new file mode 100644
index 00000000..61ac087d
--- /dev/null
+++ b/src/app/services/babylon/orbit-gizmo.ts
@@ -0,0 +1,250 @@
+import {
+ ActionManager,
+ Angle,
+ ArcRotateCamera,
+ Color3,
+ Color4,
+ CreateGreasedLine,
+ DynamicTexture,
+ Engine,
+ ExecuteCodeAction,
+ InterpolateValueAction,
+ Mesh,
+ MeshBuilder,
+ Scene,
+ StandardMaterial,
+ Vector3,
+} from '@babylonjs/core';
+import { smoothCameraTransition } from './camera-handler';
+
+export const createOrbitGizmo = ({
+ canvas,
+ getArcRotateCamera,
+ mainEngine,
+}: {
+ canvas: HTMLCanvasElement;
+ getArcRotateCamera: () => ArcRotateCamera;
+ mainEngine: Engine;
+}) => {
+ canvas.id = 'gizmoCanvas';
+ canvas.style.setProperty('position', 'fixed');
+ canvas.style.setProperty('top', '0');
+ canvas.style.setProperty('right', '0');
+ canvas.style.setProperty('background', 'rgba(0, 0, 0, 0.2)');
+ canvas.style.setProperty('border-radius', '50%');
+ canvas.style.setProperty('margin', '12px');
+ const viewportMinSize = 10;
+ canvas.style.setProperty('width', `min(${viewportMinSize}vw, ${viewportMinSize}vh)`);
+ canvas.style.setProperty('height', `min(${viewportMinSize}vw, ${viewportMinSize}vh)`);
+
+ const engine = new Engine(canvas, true, {
+ audioEngine: false,
+ preserveDrawingBuffer: true,
+ stencil: true,
+ });
+ engine.maxFPS = mainEngine.maxFPS;
+ const scene = new Scene(engine);
+ scene.createDefaultEnvironment({ createGround: false, createSkybox: false });
+ scene.clearColor = new Color4(0, 0, 0, 0);
+
+ const arcRotateCamera = new ArcRotateCamera(
+ 'gizmoCam',
+ -Math.PI / 2,
+ Math.PI / 2,
+ 10,
+ Vector3.Zero(),
+ scene,
+ );
+ const defaultRadius = 5;
+ arcRotateCamera.radius = defaultRadius;
+ arcRotateCamera.fov = Angle.FromDegrees(37).radians();
+ arcRotateCamera.panningSensibility = 0;
+ arcRotateCamera.speed = 10;
+ // Large values effectively disable these
+ arcRotateCamera.wheelPrecision = 10_000_000;
+ arcRotateCamera.pinchPrecision = 10_000_000;
+
+ const sphere = MeshBuilder.CreateSphere('gizmoSphere', { diameter: 2 }, scene);
+ const mat = new StandardMaterial('gizmoMat', scene);
+ mat.emissiveColor = new Color3(0.75, 0.75, 0.75);
+ sphere.material = mat;
+ sphere.visibility = 0.01;
+
+ // Create 6 smaller discs to indicate orientation axes
+ const lineTarget: Record = {
+ x: new Vector3(1, 0, 0),
+ y: new Vector3(0, 1, 0),
+ z: new Vector3(0, 0, 1),
+ };
+ const _discs = ['x', 'y', 'z'].flatMap((axis, i) => {
+ const axisUpper = axis.toUpperCase();
+ const positive = MeshBuilder.CreateDisc(`gizmo${axisUpper}+`, { radius: 0.5 }, scene);
+ const negative = MeshBuilder.CreateDisc(`gizmo${axisUpper}-`, { radius: 0.5 }, scene);
+
+ const color = [new Color3(1, 0, 0), new Color3(0, 1, 0), new Color3(0, 0, 1)][i];
+
+ const _positiveLine = CreateGreasedLine(
+ `gizmo${axisUpper}+Line`,
+ { points: [Vector3.Zero(), lineTarget[axis]], updatable: true, widths: [2, 2] },
+ { color },
+ scene,
+ );
+ const negativeLine = CreateGreasedLine(
+ `gizmo${axisUpper}-Line`,
+ { points: [Vector3.Zero(), lineTarget[axis].negate()], updatable: true, widths: [2, 2] },
+ { color },
+ scene,
+ );
+ negativeLine.visibility = 0.75;
+
+ [positive, negative].forEach((disc, j) => {
+ const textLabel = new DynamicTexture(
+ `gizmoLabel${axisUpper}${j}`,
+ { width: 64, height: 64 },
+ scene,
+ );
+ textLabel.drawText(
+ j === 0 ? axisUpper : `-${axisUpper}`,
+ null,
+ 48,
+ 'bold 36px monospace',
+ 'white',
+ 'transparent',
+ false,
+ );
+ const labelMat = new StandardMaterial(`gizmoLabelMat${axisUpper}${j}`, scene);
+ labelMat.emissiveTexture = j === 0 ? textLabel : null;
+ labelMat.emissiveColor = color;
+ labelMat.disableLighting = true;
+
+ disc.billboardMode = Mesh.BILLBOARDMODE_ALL;
+ disc.visibility = j === 0 ? 1 : 0.75; // Dim negative
+ disc.material = labelMat;
+ disc.position[axis as 'x' | 'y' | 'z'] = j === 0 ? 1 : -1;
+ disc.renderingGroupId = 1;
+ disc.actionManager = new ActionManager(scene);
+
+ if (j === 1) {
+ disc.scaling = new Vector3(0.75, 0.75, 0.75);
+ const actions = [
+ new InterpolateValueAction(
+ ActionManager.OnPointerOverTrigger,
+ disc,
+ 'scaling',
+ new Vector3(1, 1, 1),
+ 100,
+ ),
+ new InterpolateValueAction(
+ ActionManager.OnPointerOverTrigger,
+ disc,
+ 'visibility',
+ 1,
+ 100,
+ ),
+ new InterpolateValueAction(
+ ActionManager.OnPointerOutTrigger,
+ disc,
+ 'scaling',
+ new Vector3(0.75, 0.75, 0.75),
+ 100,
+ ),
+ new InterpolateValueAction(
+ ActionManager.OnPointerOutTrigger,
+ disc,
+ 'visibility',
+ 0.75,
+ 100,
+ ),
+ new ExecuteCodeAction(ActionManager.OnPointerOverTrigger, () => {
+ labelMat.emissiveTexture = textLabel;
+ }),
+ new ExecuteCodeAction(ActionManager.OnPointerOutTrigger, () => {
+ labelMat.emissiveTexture = null;
+ }),
+ ];
+ actions.forEach(action => disc.actionManager!.registerAction(action));
+ } else {
+ const actions = [
+ new InterpolateValueAction(
+ ActionManager.OnPointerOverTrigger,
+ labelMat,
+ 'emissiveColor',
+ color.scale(0.5),
+ 100,
+ ),
+ new InterpolateValueAction(
+ ActionManager.OnPointerOutTrigger,
+ labelMat,
+ 'emissiveColor',
+ color,
+ 100,
+ ),
+ ];
+ actions.forEach(action => disc.actionManager!.registerAction(action));
+ }
+ });
+ return [positive, negative];
+ });
+
+ let isMouseInCanvas = false;
+ let isAnimating = false;
+
+ canvas.addEventListener('mouseenter', () => {
+ isMouseInCanvas = true;
+ });
+ canvas.addEventListener('mouseleave', () => {
+ isMouseInCanvas = false;
+ });
+
+ canvas.addEventListener('click', () => {
+ if (isAnimating) return;
+ const { pointerX, pointerY } = scene;
+ // Shoot ray from camera to click position
+ const ray = scene.createPickingRay(pointerX, pointerY, null, arcRotateCamera);
+ const hit = ray.intersectsMeshes(_discs);
+ const firstHit = hit.at(0);
+ if (!firstHit) return;
+ const name = firstHit.pickedMesh?.name;
+ if (!name || !name.startsWith('gizmo')) return;
+ const [axis, sign] = name.replace('gizmo', '').toLowerCase().split('');
+ if (!['x', 'y', 'z'].includes(axis) || !['+', '-'].includes(sign)) return;
+ const isPositive = sign === '+';
+
+ // Move the camera to look at the target from the direction of the clicked axis, with the same distance to the target as it currently has
+ // Needs to be perfectly aligned with the axis afterwards
+ const camera = getArcRotateCamera();
+ if (!camera) return;
+ const target = camera.target;
+ const radius = camera.radius;
+
+ const direction = new Vector3(
+ axis === 'x' ? (isPositive ? radius : -radius) : 0,
+ axis === 'y' ? (isPositive ? radius : -radius) : 0,
+ axis === 'z' ? (isPositive ? radius : -radius) : 0,
+ );
+ const position = target.add(direction);
+ isAnimating = true;
+
+ const ref = smoothCameraTransition({ camera, scene, position, target }, 2);
+ ref?.onAnimationEndObservable.addOnce(() => {
+ isAnimating = false;
+ });
+ });
+
+ const render = () => {
+ const activeCamera = getArcRotateCamera();
+ if (!activeCamera) return;
+
+ if (!isMouseInCanvas || isAnimating) {
+ arcRotateCamera.alpha = activeCamera.alpha;
+ arcRotateCamera.beta = activeCamera.beta;
+ } else {
+ activeCamera.alpha = arcRotateCamera.alpha;
+ activeCamera.beta = arcRotateCamera.beta;
+ }
+
+ arcRotateCamera.radius = defaultRadius;
+ scene.render();
+ };
+ return { render, engine, scene };
+};
diff --git a/src/app/services/babylon/strategies/loading-strategies.ts b/src/app/services/babylon/strategies/loading-strategies.ts
index 386c0001..7946d866 100644
--- a/src/app/services/babylon/strategies/loading-strategies.ts
+++ b/src/app/services/babylon/strategies/loading-strategies.ts
@@ -2,8 +2,10 @@ import {
AbstractMesh,
ActionManager,
Analyser,
+ Color3,
Engine,
ExecuteCodeAction,
+ GaussianSplattingMesh,
ImportMeshAsync,
ISceneLoaderProgressEvent,
Material,
@@ -16,7 +18,10 @@ import {
Tags,
Texture,
Tools,
+ TransformNode,
Vector3,
+ VertexBuffer,
+ VertexData,
VideoTexture,
} from '@babylonjs/core';
import '@babylonjs/loaders';
@@ -94,7 +99,19 @@ export const loadSplat = async (
scene: Scene,
onProgress?: (progress: ISceneLoaderProgressEvent) => void,
) => {
- return ImportMeshAsync(rootUrl, scene, { onProgress });
+ return ImportMeshAsync(rootUrl, scene, {
+ onProgress,
+ pluginOptions: { splat: { keepInRam: true } },
+ }).then(result => {
+ const gsMesh = result.meshes.at(0)! as GaussianSplattingMesh;
+ gsMesh.isPickable = true;
+
+ // gsMesh.material!.wireframe = false;
+
+ console.log('loadSplat', gsMesh.splatsData);
+
+ return result;
+ });
};
export const load3DEntity = async (
diff --git a/src/app/services/entitysettings/entitysettings.service.ts b/src/app/services/entitysettings/entitysettings.service.ts
index ca5805a5..072a9fcf 100644
--- a/src/app/services/entitysettings/entitysettings.service.ts
+++ b/src/app/services/entitysettings/entitysettings.service.ts
@@ -497,12 +497,36 @@ export class EntitySettingsService {
if (!this.entitySettings) {
throw new Error('Settings missing');
}
- const camera = this.entitySettings.cameraPositionInitial;
- const position = new Vector3(camera.position.x, camera.position.y, camera.position.z);
- const target = new Vector3(camera.target.x, camera.target.y, camera.target.z);
- this.babylon.cameraManager.cameraDefaults$.next({ position, target });
- this.babylon.cameraManager.moveActiveCameraToPosition(position);
- this.babylon.cameraManager.setActiveCameraTarget(target);
+ const cameraSettings = this.entitySettings.cameraPositionInitial;
+ if (!cameraSettings) {
+ throw new Error('Camera settings missing');
+ }
+
+ // Saved position is { x: alpha, y: beta, z: radius } (spherical ArcRotate)
+ const alpha = cameraSettings.position.x;
+ const beta = cameraSettings.position.y;
+ const radius = cameraSettings.position.z;
+ const target = new Vector3(
+ cameraSettings.target.x,
+ cameraSettings.target.y,
+ cameraSettings.target.z,
+ );
+
+ this.babylon.cameraManager.cameraDefaults$.next({
+ alpha,
+ beta,
+ radius,
+ target: target.clone(),
+ });
+
+ this.babylon.cameraManager.smoothCameraTransitionFromSpherical({
+ camera: this.babylon.cameraManager.getActiveCamera(),
+ scene: this.babylon.getScene(),
+ alpha,
+ beta,
+ radius,
+ target,
+ });
}
// background: color, effect
diff --git a/src/app/services/processing/processing.service.ts b/src/app/services/processing/processing.service.ts
index 1124c8d1..c7f9f8d1 100644
--- a/src/app/services/processing/processing.service.ts
+++ b/src/app/services/processing/processing.service.ts
@@ -162,10 +162,12 @@ export class ProcessingService {
if (!entity) return false;
if (args.isStandalone) return true;
+ // TODO: Implement annotation for every type, then we don't need this check
const isAnnotatable =
entity.mediaType === 'image' ||
entity.mediaType === 'entity' ||
entity.mediaType === 'cloud' ||
+ entity.mediaType === 'splat' ||
entity.mediaType === 'model';
const hideEditor = !args.showEditor || !isAnnotatable || args.isFallback;
@@ -216,6 +218,9 @@ export class ProcessingService {
constructor() {
this.entity$.pipe(filter(isEntity)).subscribe(entity => this.handleEntitySettings(entity));
+ this.defaultEntityLoaded$.subscribe(isDefault => {
+ this.babylon.setOrbitGizmoVisible(!isDefault);
+ });
}
private handleEntitySettings(entity: IEntity) {
diff --git a/src/styles.scss b/src/styles.scss
index a88ec841..50cbdad7 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -237,6 +237,13 @@ mat-card-content {
width: 100% !important;
}
+div.mat-mdc-dialog-panel.reposition-dialog-panel {
+ position: fixed !important;
+ top: 0;
+ left: 0;
+ margin: 12px;
+}
+
/* Remove background from detail component */
k-details {
background-color: transparent !important;