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 @@
{{ annotation.ranking }} {{ annotation.body.content.title || ('No title' | translate) }} - {{ (annotation.validated ? 'validated' : 'not validated') | translate }} + {{ (isAnnotationUnsaved() ? 'not saved' : 'saved') | translate }}
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;