Skip to content

Commit

Permalink
Add mesh-based collision detection (#1231)
Browse files Browse the repository at this point in the history
* Add mesh-based collision detection

* Fix test

* Restore bounding box logic and improve performance
  • Loading branch information
edlu77 authored Feb 5, 2024
1 parent 08236d3 commit 19e1740
Show file tree
Hide file tree
Showing 6 changed files with 88 additions and 41 deletions.
1 change: 1 addition & 0 deletions projects/ccf-rui/src/app/app-web-component.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export class AppWebComponent extends BaseWebComponent {
@Input() homeUrl: string;
@Input() logoTooltip: string;
@Input() organOptions: string | string[];
@Input() collisionsEndpoint: string;

initialized: boolean;

Expand Down
2 changes: 2 additions & 0 deletions projects/ccf-rui/src/app/core/services/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface GlobalConfig {
homeUrl?: string;
logoTooltip?: string;
organOptions?: string[];

collisionsEndpoint?: string;
}

export interface OrganConfig {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { NgxsDataPluginModule } from '@angular-ru/ngxs';
import { TestBed } from '@angular/core/testing';
import { NgxsModule, Store } from '@ngxs/store';
import { GlobalConfigState } from 'ccf-shared';
import { lastValueFrom, Observable, of } from 'rxjs';
import { Observable, lastValueFrom, of } from 'rxjs';
import { take } from 'rxjs/operators';

import { HttpClientTestingModule } from '@angular/common/http/testing';
import { PageState } from '../page/page.state';
import { ReferenceDataState } from '../reference-data/reference-data.state';
import { RegistrationState } from '../registration/registration.state';
Expand All @@ -24,6 +25,7 @@ describe('AnatomicalStructureTagsState', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
HttpClientTestingModule,
NgxsDataPluginModule.forRoot(),
NgxsModule.forRoot([AnatomicalStructureTagState, SceneState, ModelState, GlobalConfigState])
],
Expand Down
34 changes: 17 additions & 17 deletions projects/ccf-rui/src/app/core/store/model/model.state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -114,37 +114,37 @@ export const RUI_ORGANS = ALL_ORGANS;
@Injectable()
export class ModelState extends NgxsImmutableDataRepository<ModelStateModel> {
/** 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<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';


/**
Expand Down Expand Up @@ -74,7 +75,8 @@ export class RegistrationState extends NgxsImmutableDataRepository<RegistrationS
@Computed()
get throttledJsonld$(): Observable<Record<string, unknown>> {
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))
);
}
Expand Down
82 changes: 61 additions & 21 deletions projects/ccf-rui/src/app/core/store/scene/scene.state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -107,20 +124,23 @@ export class SceneState extends NgxsImmutableDataRepository<SceneStateModel> imp
}
})
.reduce<SpatialSceneNode[]>((acc, nodes) => acc.concat(nodes), [])
)
),
distinctUntilChanged(isEqual)
);
}

@Computed()
get nodeCollisions$(): Observable<SpatialSceneNode[]> {
const collisions$ = defer(() => this.registration.throttledJsonld$).pipe(
switchMap((jsonld) => this.getCollisions(jsonld)),
startWith([])
) as Observable<object[]>;

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()
);
}
Expand Down Expand Up @@ -178,9 +198,10 @@ export class SceneState extends NgxsImmutableDataRepository<SceneStateModel> imp
}

@Computed()
get placementCube$(): Observable<SpatialSceneNode[]> | [] {
get placementCube$(): Observable<SpatialSceneNode[]> {
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)
);
}

Expand Down Expand Up @@ -245,14 +266,23 @@ export class SceneState extends NgxsImmutableDataRepository<SceneStateModel> imp
private registration: RegistrationState;
private referenceData: ReferenceDataState;

@Computed()
private get collisions$(): Observable<Collision[] | undefined> {
return defer(() => this.registration.throttledJsonld$).pipe(
concatMap((jsonld) => this.getCollisions(jsonld)),
startWith([])
);
}

/**
* Creates an instance of scene state.
*
* @param injector Injector service used to lazy load page and model state
*/
constructor(
private readonly injector: Injector,
private readonly http: HttpClient
private readonly http: HttpClient,
private readonly globalConfig: GlobalConfigState<GlobalConfig>,
) {
super();
}
Expand Down Expand Up @@ -299,14 +329,24 @@ export class SceneState extends NgxsImmutableDataRepository<SceneStateModel> imp
return db.organSpatialEntities[organIri] as SpatialEntity;
}

private getCollisions(jsonld: unknown): Observable<unknown> {
return this.http.post(DEFAULT_ENDPOINT, JSON.stringify(jsonld), {
headers: { 'Content-Type': 'application/json' }
});
private getCollisions(jsonld: unknown): Observable<Collision[] | undefined> {
return this.globalConfig.getOption('collisionsEndpoint').pipe(
switchMap((endpoint = DEFAULT_COLLISIONS_ENDPOINT) => this.http.post<Collision[]>(
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)));
}
}

0 comments on commit 19e1740

Please sign in to comment.