Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add mesh-based collision detection #1231

Merged
merged 3 commits into from
Feb 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -9,8 +9,8 @@ import { SpatialEntityJsonLd } from 'ccf-body-ui';
import { GlobalConfigState, OrganInfo } from 'ccf-shared';
import { filterNulls } from 'ccf-shared/rxjs-ext/operators';
import { saveAs } from 'file-saver';
import { combineLatest, Observable } from 'rxjs';
import { map, startWith, switchMap, take, tap } from 'rxjs/operators';
import { Observable, combineLatest } from 'rxjs';
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 All @@ -36,6 +37,8 @@ export interface RegistrationStateModel {
initialRegistration?: SpatialEntityJsonLd;
}

const JSONLD_THROTTLE_DURATION = 100;


/**
* Data for model registrations
Expand Down Expand Up @@ -69,6 +72,15 @@ 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: true, trailing: true }),
distinctUntilChanged(isEqual),
map(([page, model, tags]) => this.buildJsonLd(page, model, tags))
);
}

@Computed()
get valid$(): Observable<boolean> {
return combineLatest([this.page.state$, this.model.state$]).pipe(
Expand Down
82 changes: 64 additions & 18 deletions projects/ccf-rui/src/app/core/store/scene/scene.state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
/* eslint-disable @typescript-eslint/naming-convention */
import { Computed, StateRepository } from '@angular-ru/ngxs/decorators';
import { NgxsImmutableDataRepository } from '@angular-ru/ngxs/repositories';
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 { Observable, combineLatest, of } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { GlobalConfigState, Position } from 'ccf-shared';
import { isEqual } from 'lodash';
import { Observable, combineLatest, defer, of } from 'rxjs';
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,6 +30,14 @@ export interface SceneStateModel {
showCollisions: boolean;
}

interface Collision {
id: string;
}

const NODE_COLLISION_THROTTLE_DURATION = 10;

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], []);
Expand All @@ -49,7 +60,6 @@ function getNodeBbox(model: SpatialSceneNode): AABB {
})
@Injectable()
export class SceneState extends NgxsImmutableDataRepository<SceneStateModel> implements NgxsOnInit {

@Computed()
get nodes$(): Observable<SpatialSceneNode[]> {
return combineLatest([
Expand Down Expand Up @@ -85,7 +95,7 @@ export class SceneState extends NgxsImmutableDataRepository<SceneStateModel> imp
const organ = this.getOrganSpatialEntity(organIri as string);
const originScene = organIri ? getOriginScene(organ, false, true) : [];
const organScene = this.createSceneNodes(organIri as string, [...anatomicalStructures, ...extractionSites] as VisibilityItem[]);
return [ ...originScene, ...organScene ];
return [...originScene, ...organScene];
})
);
}
Expand Down Expand Up @@ -114,18 +124,24 @@ export class SceneState extends NgxsImmutableDataRepository<SceneStateModel> imp
}
})
.reduce<SpatialSceneNode[]>((acc, nodes) => acc.concat(nodes), [])
)
),
distinctUntilChanged(isEqual)
);
}

@Computed()
get nodeCollisions$(): Observable<SpatialSceneNode[]> {
return combineLatest([this.referenceOrganSimpleNodes$, this.placementCube$]).pipe(
filter(([_nodes, placement]) => placement.length > 0),
map(([nodes, placement]) => {
const bbox = getNodeBbox(placement[0]);
return nodes.filter((model) => bbox.overlaps(getNodeBbox(model)));
})
return combineLatest([this.referenceOrganSimpleNodes$, this.collisions$, this.placementCube$]).pipe(
throttleTime(NODE_COLLISION_THROTTLE_DURATION, undefined, { leading: true, trailing: true }),
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 @@ -158,8 +174,8 @@ export class SceneState extends NgxsImmutableDataRepository<SceneStateModel> imp
}

@Computed()
get spatialKeyBoardAxis$(): Observable<SpatialSceneNode[]>{
return combineLatest([this.model.organIri$.pipe(filter(organIri=>organIri!=='')), this.model.position$]).pipe(map(([organIri, position]: [string, Position]) => {
get spatialKeyBoardAxis$(): Observable<SpatialSceneNode[]> {
return combineLatest([this.model.organIri$.pipe(filter(organIri => organIri !== '')), this.model.position$]).pipe(map(([organIri, position]: [string, Position]) => {
const organEntity = this.getOrganSpatialEntity(organIri);
const blockSize = this.model.snapshot.blockSize;
const rotation = this.model.snapshot.rotation;
Expand All @@ -177,14 +193,15 @@ export class SceneState extends NgxsImmutableDataRepository<SceneStateModel> imp
z_rotation: rotation.z,

x_scaling: 1, y_scaling: 1, z_scaling: 1,
} as SpatialPlacement): [];
} as SpatialPlacement) : [];
}));
}

@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 @@ -249,13 +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 injector: Injector,
private readonly http: HttpClient,
private readonly globalConfig: GlobalConfigState<GlobalConfig>,
) {
super();
}
Expand Down Expand Up @@ -302,5 +329,24 @@ export class SceneState extends NgxsImmutableDataRepository<SceneStateModel> imp
return db.organSpatialEntities[organIri] as SpatialEntity;
}

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: 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)));
}
}
Loading