diff --git a/projects/ccf-rui/src/app/app-web-component.component.ts b/projects/ccf-rui/src/app/app-web-component.component.ts index 878312a1f..ae6f0297f 100644 --- a/projects/ccf-rui/src/app/app-web-component.component.ts +++ b/projects/ccf-rui/src/app/app-web-component.component.ts @@ -45,6 +45,7 @@ export class AppWebComponent extends BaseWebComponent { @Input() homeUrl: string; @Input() logoTooltip: string; @Input() organOptions: string | string[]; + @Input() collisionsEndpoint: string; initialized: boolean; diff --git a/projects/ccf-rui/src/app/core/services/config/config.ts b/projects/ccf-rui/src/app/core/services/config/config.ts index 5fdba46ca..7e2f9507d 100644 --- a/projects/ccf-rui/src/app/core/services/config/config.ts +++ b/projects/ccf-rui/src/app/core/services/config/config.ts @@ -27,6 +27,8 @@ export interface GlobalConfig { homeUrl?: string; logoTooltip?: string; organOptions?: string[]; + + collisionsEndpoint?: string; } export interface OrganConfig { diff --git a/projects/ccf-rui/src/app/core/store/model/model.state.ts b/projects/ccf-rui/src/app/core/store/model/model.state.ts index a7af9eeaf..180ced7c4 100644 --- a/projects/ccf-rui/src/app/core/store/model/model.state.ts +++ b/projects/ccf-rui/src/app/core/store/model/model.state.ts @@ -7,7 +7,7 @@ import { filterNulls } from 'ccf-shared/rxjs-ext/operators'; import { sortBy } from 'lodash'; import { GoogleAnalyticsService } from 'ngx-google-analytics'; import { EMPTY, Observable } from 'rxjs'; -import { delay, distinctUntilChanged, filter, map, skipUntil, switchMap, tap, throttleTime } from 'rxjs/operators'; +import { delay, distinct, distinctUntilChanged, filter, map, skipUntil, switchMap, tap, throttleTime } from 'rxjs/operators'; import { ExtractionSet } from '../../models/extraction-set'; import { VisibilityItem } from '../../models/visibility-item'; @@ -114,37 +114,37 @@ export const RUI_ORGANS = ALL_ORGANS; @Injectable() export class ModelState extends NgxsImmutableDataRepository { /** Identifier observable */ - readonly id$ = this.state$.pipe(map(x => x?.id)); + readonly id$ = this.state$.pipe(map(x => x?.id), distinct()); /** Block size observable */ - readonly blockSize$ = this.state$.pipe(map(x => x?.blockSize)); + readonly blockSize$ = this.state$.pipe(map(x => x?.blockSize), distinct()); /** Rotation observable */ - readonly rotation$ = this.state$.pipe(map(x => x?.rotation)); + readonly rotation$ = this.state$.pipe(map(x => x?.rotation), distinct()); /** Position observable */ - readonly position$ = this.state$.pipe(map(x => x?.position)); + readonly position$ = this.state$.pipe(map(x => x?.position), distinct()); /** Slice configuration observable */ - readonly slicesConfig$ = this.state$.pipe(map(x => x?.slicesConfig)); + readonly slicesConfig$ = this.state$.pipe(map(x => x?.slicesConfig), distinct()); /** View type observable */ - readonly viewType$ = this.state$.pipe(map(x => x?.viewType)); + readonly viewType$ = this.state$.pipe(map(x => x?.viewType), distinct()); /** View side observable */ - readonly viewSide$ = this.state$.pipe(map(x => x?.viewSide)); + readonly viewSide$ = this.state$.pipe(map(x => x?.viewSide), distinct()); /** Organ observable */ - readonly organ$ = this.state$.pipe(map(x => x?.organ)); + readonly organ$ = this.state$.pipe(map(x => x?.organ), distinct()); /** Organ IRI observable */ - readonly organIri$ = this.state$.pipe(map(x => x?.organIri)); + readonly organIri$ = this.state$.pipe(map(x => x?.organIri), distinct()); /** Organ IRI observable */ - readonly organDimensions$ = this.state$.pipe(map(x => x?.organDimensions)); + readonly organDimensions$ = this.state$.pipe(map(x => x?.organDimensions), distinct()); /** Sex observable */ - readonly sex$ = this.state$.pipe(map(x => x?.sex)); + readonly sex$ = this.state$.pipe(map(x => x?.sex), distinct()); /** Side observable */ - readonly side$ = this.state$.pipe(map(x => x?.side)); + readonly side$ = this.state$.pipe(map(x => x?.side), distinct()); /** Show previous observable */ - readonly showPrevious$ = this.state$.pipe(map(x => x?.showPrevious)); + readonly showPrevious$ = this.state$.pipe(map(x => x?.showPrevious), distinct()); /** Extraction sites observable */ - readonly extractionSites$ = this.state$.pipe(map(x => x?.extractionSites)); + readonly extractionSites$ = this.state$.pipe(map(x => x?.extractionSites), distinct()); /** Anatomical structures observable */ - readonly anatomicalStructures$ = this.state$.pipe(map(x => x?.anatomicalStructures)); + readonly anatomicalStructures$ = this.state$.pipe(map(x => x?.anatomicalStructures), distinct()); /** Extraction sets observable */ - readonly extractionSets$ = this.state$.pipe(map(x => x?.extractionSets)); + readonly extractionSets$ = this.state$.pipe(map(x => x?.extractionSets), distinct()); @Computed() get modelChanged$(): Observable { diff --git a/projects/ccf-rui/src/app/core/store/registration/registration.state.ts b/projects/ccf-rui/src/app/core/store/registration/registration.state.ts index 997e0f35e..c9fee5563 100644 --- a/projects/ccf-rui/src/app/core/store/registration/registration.state.ts +++ b/projects/ccf-rui/src/app/core/store/registration/registration.state.ts @@ -10,7 +10,7 @@ import { GlobalConfigState, OrganInfo } from 'ccf-shared'; import { filterNulls } from 'ccf-shared/rxjs-ext/operators'; import { saveAs } from 'file-saver'; import { Observable, combineLatest } from 'rxjs'; -import { map, startWith, switchMap, take, tap, throttleTime } from 'rxjs/operators'; +import { distinctUntilChanged, map, startWith, switchMap, take, tap, throttleTime } from 'rxjs/operators'; import { v4 as uuidV4 } from 'uuid'; import { Tag } from '../../models/anatomical-structure-tag'; @@ -20,6 +20,7 @@ import { AnatomicalStructureTagState } from '../anatomical-structure-tags/anatom import { ModelState, ModelStateModel, RUI_ORGANS, XYZTriplet } from '../model/model.state'; import { PageState, PageStateModel } from '../page/page.state'; import { ReferenceDataState } from '../reference-data/reference-data.state'; +import { isEqual } from 'lodash'; /** @@ -74,7 +75,8 @@ export class RegistrationState extends NgxsImmutableDataRepository> { return combineLatest([this.page.state$, this.model.state$, this.tags.tags$]).pipe( - throttleTime(JSONLD_THROTTLE_DURATION, undefined, { leading: false, trailing: true }), + throttleTime(JSONLD_THROTTLE_DURATION, undefined, { leading: true, trailing: true }), + distinctUntilChanged(isEqual), map(([page, model, tags]) => this.buildJsonLd(page, model, tags)) ); } diff --git a/projects/ccf-rui/src/app/core/store/scene/scene.state.ts b/projects/ccf-rui/src/app/core/store/scene/scene.state.ts index 474f2b1de..68ac07f13 100644 --- a/projects/ccf-rui/src/app/core/store/scene/scene.state.ts +++ b/projects/ccf-rui/src/app/core/store/scene/scene.state.ts @@ -7,12 +7,15 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, Injector } from '@angular/core'; import { Matrix4, toRadians } from '@math.gl/core'; import { NgxsOnInit, State } from '@ngxs/store'; +import { AABB, Vec3 } from 'cannon-es'; import { SpatialEntityJsonLd, SpatialSceneNode } from 'ccf-body-ui'; import { SpatialEntity, SpatialPlacement, getOriginScene, getTissueBlockScene } from 'ccf-database'; -import { Position } from 'ccf-shared'; +import { GlobalConfigState, Position } from 'ccf-shared'; +import { isEqual } from 'lodash'; import { Observable, combineLatest, defer, of } from 'rxjs'; -import { filter, map, share, startWith, switchMap, throttleTime } from 'rxjs/operators'; +import { catchError, concatMap, distinctUntilChanged, filter, map, share, startWith, switchMap, take, throttleTime } from 'rxjs/operators'; import { environment } from '../../../../environments/environment'; +import { GlobalConfig } from '../../services/config/config'; import { ModelState } from '../model/model.state'; import { RegistrationState } from '../registration/registration.state'; import { VisibilityItem } from './../../models/visibility-item'; @@ -27,9 +30,23 @@ export interface SceneStateModel { showCollisions: boolean; } -const NODE_COLLISION_THROTTLE_DURATION = 100; +interface Collision { + id: string; +} + +const NODE_COLLISION_THROTTLE_DURATION = 10; -const DEFAULT_ENDPOINT = 'https://pfn8zf2gtu.us-east-2.awsapprunner.com/get-collisions'; +const DEFAULT_COLLISIONS_ENDPOINT = 'https://pfn8zf2gtu.us-east-2.awsapprunner.com/get-collisions'; + +function getNodeBbox(model: SpatialSceneNode): AABB { + const mat = new Matrix4(model.transformMatrix); + const lowerBound = mat.transformAsPoint([-1, -1, -1], []); + const upperBound = mat.transformAsPoint([1, 1, 1], []); + return new AABB({ + lowerBound: new Vec3(...lowerBound.map((n, i) => Math.min(n, upperBound[i]))), + upperBound: new Vec3(...upperBound.map((n, i) => Math.max(n, lowerBound[i]))) + }); +} /** * 3d Scene state @@ -107,20 +124,23 @@ export class SceneState extends NgxsImmutableDataRepository imp } }) .reduce((acc, nodes) => acc.concat(nodes), []) - ) + ), + distinctUntilChanged(isEqual) ); } @Computed() get nodeCollisions$(): Observable { - const collisions$ = defer(() => this.registration.throttledJsonld$).pipe( - switchMap((jsonld) => this.getCollisions(jsonld)), - startWith([]) - ) as Observable; - - return combineLatest([this.referenceOrganSimpleNodes$, collisions$]).pipe( + return combineLatest([this.referenceOrganSimpleNodes$, this.collisions$, this.placementCube$]).pipe( throttleTime(NODE_COLLISION_THROTTLE_DURATION, undefined, { leading: true, trailing: true }), - map(([nodes, collisions]) => this.filterNodeCollisions(nodes, collisions)), + map(([nodes, collisions, placement]) => { + if (collisions !== undefined) { + return this.filterNodeCollisions(nodes, collisions); + } else if (placement.length > 0) { + return this.filterNodeBBox(nodes, placement[0]); + } + return []; + }), share() ); } @@ -178,9 +198,10 @@ export class SceneState extends NgxsImmutableDataRepository imp } @Computed() - get placementCube$(): Observable | [] { + get placementCube$(): Observable { return combineLatest([this.model.viewType$, this.model.blockSize$, this.model.rotation$, this.model.position$, this.model.organ$]).pipe( - map(([_viewType, _blockSize, _rotation, _position, organ]) => organ.src === '' ? [] : [this.placementCube]) + map(([_viewType, _blockSize, _rotation, _position, organ]) => organ.src === '' ? [] : [this.placementCube]), + distinctUntilChanged(isEqual) ); } @@ -245,6 +266,14 @@ export class SceneState extends NgxsImmutableDataRepository imp private registration: RegistrationState; private referenceData: ReferenceDataState; + @Computed() + private get collisions$(): Observable { + return defer(() => this.registration.throttledJsonld$).pipe( + concatMap((jsonld) => this.getCollisions(jsonld)), + startWith([]) + ); + } + /** * Creates an instance of scene state. * @@ -252,7 +281,8 @@ export class SceneState extends NgxsImmutableDataRepository imp */ constructor( private readonly injector: Injector, - private readonly http: HttpClient + private readonly http: HttpClient, + private readonly globalConfig: GlobalConfigState, ) { super(); } @@ -299,14 +329,24 @@ export class SceneState extends NgxsImmutableDataRepository imp return db.organSpatialEntities[organIri] as SpatialEntity; } - private getCollisions(jsonld: unknown): Observable { - return this.http.post(DEFAULT_ENDPOINT, JSON.stringify(jsonld), { - headers: { 'Content-Type': 'application/json' } - }); + private getCollisions(jsonld: unknown): Observable { + return this.globalConfig.getOption('collisionsEndpoint').pipe( + switchMap((endpoint = DEFAULT_COLLISIONS_ENDPOINT) => this.http.post( + endpoint, JSON.stringify(jsonld), + { headers: { 'Content-Type': 'application/json' } } + )), + catchError(() => of(undefined)), + take(1) + ); } - private filterNodeCollisions(nodes: SpatialSceneNode[], collisions: object[]): SpatialSceneNode[] { - const collidedIds = new Set(collisions.map(node => node['id'])); + private filterNodeCollisions(nodes: SpatialSceneNode[], collisions: Collision[]): SpatialSceneNode[] { + const collidedIds = new Set(collisions.map(node => node.id)); return nodes.filter(node => collidedIds.has(node['@id'])); } + + private filterNodeBBox(nodes: SpatialSceneNode[], placement: SpatialSceneNode): SpatialSceneNode[] { + const bbox = getNodeBbox(placement); + return nodes.filter((model) => bbox.overlaps(getNodeBbox(model))); + } }