diff --git a/apps/kitchen-sink/public/bendy.glb b/apps/kitchen-sink/public/bendy.glb new file mode 100644 index 00000000..cbf07d83 Binary files /dev/null and b/apps/kitchen-sink/public/bendy.glb differ diff --git a/apps/kitchen-sink/public/suzanne.glb b/apps/kitchen-sink/public/suzanne.glb new file mode 100644 index 00000000..587df6df Binary files /dev/null and b/apps/kitchen-sink/public/suzanne.glb differ diff --git a/apps/kitchen-sink/src/app/app.component.ts b/apps/kitchen-sink/src/app/app.component.ts index 2d3affde..8eec46f3 100644 --- a/apps/kitchen-sink/src/app/app.component.ts +++ b/apps/kitchen-sink/src/app/app.component.ts @@ -11,6 +11,7 @@ import { filter, map, tap } from 'rxjs'; + `, imports: [RouterOutlet], diff --git a/apps/kitchen-sink/src/app/app.config.ts b/apps/kitchen-sink/src/app/app.config.ts index a1310a51..f906bfe9 100644 --- a/apps/kitchen-sink/src/app/app.config.ts +++ b/apps/kitchen-sink/src/app/app.config.ts @@ -1,11 +1,11 @@ import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core'; -import { provideRouter } from '@angular/router'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; import { appRoutes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideExperimentalZonelessChangeDetection(), // provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true }), - provideRouter(appRoutes), + provideRouter(appRoutes, withComponentInputBinding()), ], }; diff --git a/apps/kitchen-sink/src/app/app.routes.ts b/apps/kitchen-sink/src/app/app.routes.ts index 4cd97d69..8a0a89f5 100644 --- a/apps/kitchen-sink/src/app/app.routes.ts +++ b/apps/kitchen-sink/src/app/app.routes.ts @@ -16,6 +16,11 @@ export const appRoutes: Route[] = [ loadComponent: () => import('./soba/soba'), loadChildren: () => import('./soba/soba.routes'), }, + { + path: 'rapier', + loadComponent: () => import('./rapier/rapier'), + loadChildren: () => import('./rapier/rapier.routes'), + }, { path: '', // redirectTo: 'cannon', diff --git a/apps/kitchen-sink/src/app/rapier/basic/basic.ts b/apps/kitchen-sink/src/app/rapier/basic/basic.ts new file mode 100644 index 00000000..facf69a2 --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/basic/basic.ts @@ -0,0 +1,64 @@ +import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core'; +import { injectBeforeRender, NON_ROOT } from 'angular-three'; +import { NgtrCuboidCollider, NgtrPhysics, NgtrRigidBody } from 'angular-three-rapier'; +import { NgtsPerspectiveCamera } from 'angular-three-soba/cameras'; +import { NgtsOrbitControls } from 'angular-three-soba/controls'; + +@Component({ + standalone: true, + template: ` + + + + + + + + + + @if (currentCollider() === 1) { + + } @else if (currentCollider() === 2) { + + } @else if (currentCollider() === 3) { + + } @else { + + } + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'experience-basic-rapier' }, + imports: [NgtrPhysics, NgtrRigidBody, NgtrCuboidCollider, NgtsOrbitControls, NgtsPerspectiveCamera], +}) +export class Basic { + static [NON_ROOT] = true; + + protected currentCollider = signal(1); + + constructor() { + injectBeforeRender(({ camera }) => { + const currentCollider = this.currentCollider(); + if (currentCollider === 2) { + camera.position.lerp({ x: 10, y: 10, z: 10 }, 0.1); + } else if (currentCollider === 3) { + camera.position.lerp({ x: 15, y: 15, z: 15 }, 0.1); + } else if (currentCollider === 4) { + camera.position.lerp({ x: 20, y: 40, z: 40 }, 0.1); + } + }); + } +} diff --git a/apps/kitchen-sink/src/app/rapier/cluster/cluster.ts b/apps/kitchen-sink/src/app/rapier/cluster/cluster.ts new file mode 100644 index 00000000..2e04e4eb --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/cluster/cluster.ts @@ -0,0 +1,76 @@ +import { + ChangeDetectionStrategy, + Component, + CUSTOM_ELEMENTS_SCHEMA, + effect, + ElementRef, + inject, + viewChild, +} from '@angular/core'; +import { injectBeforeRender, NgtArgs, NON_ROOT } from 'angular-three'; +import { NgtrInstancedRigidBodies, NgtrPhysics } from 'angular-three-rapier'; +import { Color, InstancedMesh, Vector3 } from 'three'; + +const BALLS = 1000; + +@Component({ + standalone: true, + template: ` + + + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'cluster-rapier' }, + imports: [NgtrInstancedRigidBodies, NgtArgs], +}) +export class ClusterExample { + static [NON_ROOT] = true; + + protected readonly BALLS = BALLS; + protected bodies = Array.from({ length: BALLS }, (_, index) => { + return { + key: index, + position: [Math.floor(index / 30), (index % 30) * 0.5, 0] as [number, number, number], + }; + }); + + private rigidBodiesRef = viewChild.required(NgtrInstancedRigidBodies); + private instancedMeshRef = viewChild>('instancedMesh'); + + private physics = inject(NgtrPhysics); + + constructor() { + injectBeforeRender(() => { + const paused = this.physics.paused(); + if (paused) return; + + const rigidBodies = this.rigidBodiesRef().rigidBodyRefs(); + rigidBodies.forEach((body) => { + const rigidBody = body.rigidBody(); + if (rigidBody) { + const { x, y, z } = rigidBody.translation(); + const p = new Vector3(x, y, z); + p.normalize().multiplyScalar(-0.01); + rigidBody.applyImpulse(p, true); + } + }); + }); + + effect(() => { + const instancedMesh = this.instancedMeshRef()?.nativeElement; + if (!instancedMesh) return; + + for (let i = 0; i < BALLS; i++) { + instancedMesh.setColorAt(i, new Color(Math.random() * 0xffffff)); + } + if (instancedMesh.instanceColor) instancedMesh.instanceColor.needsUpdate = true; + }); + } +} diff --git a/apps/kitchen-sink/src/app/rapier/constants.ts b/apps/kitchen-sink/src/app/rapier/constants.ts new file mode 100644 index 00000000..c43b79bc --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/constants.ts @@ -0,0 +1,17 @@ +import { Basic } from './basic/basic'; +import { ClusterExample } from './cluster/cluster'; +import { InstancedMeshExample } from './instanced-mesh/instanced-mesh'; +import { JointsExample } from './joints/joints'; +import { PerformanceExample } from './performance/performance'; +import { RopeJointExample } from './rope-joint/rope-joint'; +import { SpringExample } from './spring/spring'; + +export const SCENES_MAP = { + basic: Basic, + instancedMesh: InstancedMeshExample, + performance: PerformanceExample, + joints: JointsExample, + cluster: ClusterExample, + ropeJoint: RopeJointExample, + spring: SpringExample, +} as const; diff --git a/apps/kitchen-sink/src/app/rapier/instanced-mesh/instanced-mesh.ts b/apps/kitchen-sink/src/app/rapier/instanced-mesh/instanced-mesh.ts new file mode 100644 index 00000000..76023f8a --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/instanced-mesh/instanced-mesh.ts @@ -0,0 +1,96 @@ +import { + ChangeDetectionStrategy, + Component, + CUSTOM_ELEMENTS_SCHEMA, + effect, + ElementRef, + signal, + viewChild, +} from '@angular/core'; +import { injectStore, NgtArgs, NgtThreeEvent, NON_ROOT } from 'angular-three'; +import { NgtrInstancedRigidBodies, NgtrInstancedRigidBodyOptions } from 'angular-three-rapier'; +import { Color, InstancedMesh } from 'three'; +import { injectSuzanne } from '../suzanne'; + +const MAX_COUNT = 2000; + +@Component({ + standalone: true, + template: ` + + @if (gltf(); as gltf) { + + + + + + } + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'instanced-mesh-rapier' }, + imports: [NgtrInstancedRigidBodies, NgtArgs], +}) +export class InstancedMeshExample { + static [NON_ROOT] = true; + + protected readonly MAX_COUNT = MAX_COUNT; + + private instancedMeshRef = viewChild>('instancedMesh'); + + protected gltf = injectSuzanne(); + private store = injectStore(); + + protected bodies = signal(Array.from({ length: 100 }, () => this.createBody())); + + constructor() { + effect(() => { + const instancedMesh = this.instancedMeshRef()?.nativeElement; + if (!instancedMesh) return; + + for (let i = 0; i < MAX_COUNT; i++) { + instancedMesh.setColorAt(i, new Color(Math.random() * 0xffffff)); + } + if (instancedMesh.instanceColor) { + instancedMesh.instanceColor.needsUpdate = true; + } + }); + + effect((onCleanup) => { + const sub = this.store.snapshot.pointerMissed$.subscribe(() => { + this.bodies.update((prev) => [...prev, this.createBody()]); + }); + onCleanup(() => sub.unsubscribe()); + }); + } + + private createBody(): NgtrInstancedRigidBodyOptions { + return { + key: Math.random(), + position: [Math.random() * 20, Math.random() * 20, Math.random() * 20], + rotation: [Math.random() * Math.PI * 2, Math.random() * Math.PI * 2, Math.random() * Math.PI * 2], + scale: [0.5 + Math.random(), 0.5 + Math.random(), 0.5 + Math.random()], + }; + } + + onClick(instancedRigidBodies: NgtrInstancedRigidBodies, event: NgtThreeEvent) { + if (event.instanceId !== undefined) { + instancedRigidBodies + .rigidBodyRefs() + .at(event.instanceId) + ?.rigidBody() + ?.applyTorqueImpulse({ x: 0, y: 50, z: 0 }, true); + } + } +} diff --git a/apps/kitchen-sink/src/app/rapier/joints/joints.ts b/apps/kitchen-sink/src/app/rapier/joints/joints.ts new file mode 100644 index 00000000..8e5641ed --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/joints/joints.ts @@ -0,0 +1,150 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + Directive, + input, + viewChild, + viewChildren, +} from '@angular/core'; +import { injectBeforeRender, NgtArgs, NgtVector3, NON_ROOT } from 'angular-three'; +import { injectPrismaticJoint, injectSphericalJoint, NgtrRigidBody, NgtrRigidBodyType } from 'angular-three-rapier'; +import { Quaternion, Vector3 } from 'three'; + +@Component({ + selector: 'app-rope-segment', + standalone: true, + imports: [NgtrRigidBody], + template: ` + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RopeSegment { + type = input.required(); + position = input([0, 0, 0]); + + rigidBodyRef = viewChild.required(NgtrRigidBody); +} + +@Directive({ selector: 'ng-container[ropeJoint]', standalone: true }) +export class RopeJoint { + bodyA = input.required(); + bodyB = input.required(); + + constructor() { + const bodyA = computed(() => this.bodyA().rigidBody()); + const bodyB = computed(() => this.bodyB().rigidBody()); + injectSphericalJoint(bodyA, bodyB, { data: { body1Anchor: [-0.5, 0, 0], body2Anchor: [0.5, 0, 0] } }); + } +} + +@Component({ + selector: 'app-rope', + standalone: true, + template: ` + + @for (i of count(); track $index) { + + + + + + + + } + + @for (segment of ropeSegments(); track $index) { + @if (!$first) { + + } + } + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RopeSegment, NgtArgs, RopeJoint, NgtrRigidBody], +}) +export class Rope { + length = input.required(); + protected count = computed(() => Array.from({ length: this.length() })); + protected ropeSegments = viewChildren(RopeSegment); + + constructor() { + const q = new Quaternion(); + const v = new Vector3(); + + injectBeforeRender(() => { + const now = performance.now(); + const ropeSegments = this.ropeSegments(); + const firstRope = ropeSegments[0]?.rigidBodyRef()?.rigidBody(); + + if (firstRope) { + q.set(0, Math.sin(now / 500) * 3, 0, q.w); + v.set(0, Math.sin(now / 500) * 3, 0); + + firstRope.setNextKinematicRotation(q); + firstRope.setNextKinematicTranslation(v); + } + }); + } +} + +@Component({ + selector: 'app-prismatic', + standalone: true, + template: ` + + + + + + + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody], +}) +export class Prismatic { + bodyA = viewChild.required('bodyA', { read: NgtrRigidBody }); + bodyB = viewChild.required('bodyB', { read: NgtrRigidBody }); + + constructor() { + const bodyA = computed(() => this.bodyA().rigidBody()); + const bodyB = computed(() => this.bodyB().rigidBody()); + injectPrismaticJoint(bodyA, bodyB, { + data: { body1Anchor: [-4, 0, 0], body2Anchor: [0, 4, 0], axis: [1, 0, 0], limits: [-2, 2] }, + }); + } +} + +@Component({ + standalone: true, + template: ` + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'joints-rapier' }, + imports: [Rope, Prismatic], +}) +export class JointsExample { + static [NON_ROOT] = true; +} diff --git a/apps/kitchen-sink/src/app/rapier/performance/performance.ts b/apps/kitchen-sink/src/app/rapier/performance/performance.ts new file mode 100644 index 00000000..c00f869e --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/performance/performance.ts @@ -0,0 +1,140 @@ +import { + ChangeDetectionStrategy, + Component, + CUSTOM_ELEMENTS_SCHEMA, + effect, + input, + output, + signal, + Signal, + viewChild, +} from '@angular/core'; +import { injectBeforeRender, NgtVector3, NON_ROOT } from 'angular-three'; +import { NgtrRigidBody } from 'angular-three-rapier'; +import { injectGLTF } from 'angular-three-soba/loaders'; +import { Mesh, Vector3Like } from 'three'; +import { GLTF } from 'three-stdlib'; +import { injectSuzanne } from '../suzanne'; + +@Component({ + selector: 'app-monkey', + standalone: true, + template: ` + @if (gltf(); as gltf) { + + + + + + } + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody], +}) +export class Monkey { + position = input([0, 0, 0]); + + dead = output(); + + protected gltf = injectSuzanne(); + private rigidBody = viewChild(NgtrRigidBody); + + constructor() { + injectBeforeRender(() => { + const rigidBody = this.rigidBody()?.rigidBody(); + if (!rigidBody) return; + if (rigidBody.translation().y < -10) { + this.dead.emit(rigidBody.translation()); + } + }); + } +} + +@Component({ + selector: 'app-monkey-swarm', + standalone: true, + template: ` + + @for (monkey of monkeys(); track monkey.key) { + + } + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [Monkey], +}) +export class MonkeySwarm { + protected monkeys = signal>([]); + + constructor() { + effect((onCleanup) => { + const id = setInterval(() => { + this.monkeys.update((prev) => [ + ...prev, + { + key: Math.random() + Date.now(), + position: [Math.random() * 10 - 5, Math.random(), Math.random() * 10 - 5] as [number, number, number], + }, + ]); + }, 50); + onCleanup(() => { + clearInterval(id); + }); + }); + } + + onDead(dead: number) { + this.monkeys.update((prev) => prev.filter((monkey) => monkey.key !== dead)); + } +} + +type BendyGLTF = GLTF & { + nodes: { BezierCurve: Mesh }; +}; + +@Component({ + selector: 'app-bendy', + standalone: true, + template: ` + + @if (gltf(); as gltf) { + + + + + + } + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody], +}) +export class Bendy { + position = input([0, 0, 0]); + scale = input([1, 1, 1]); + + protected gltf = injectGLTF(() => './bendy.glb') as Signal; +} + +@Component({ + standalone: true, + template: ` + + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'performance-rapier' }, + imports: [Bendy, MonkeySwarm], +}) +export class PerformanceExample { + static [NON_ROOT] = true; +} diff --git a/apps/kitchen-sink/src/app/rapier/rapier.routes.ts b/apps/kitchen-sink/src/app/rapier/rapier.routes.ts new file mode 100644 index 00000000..6b9b1959 --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/rapier.routes.ts @@ -0,0 +1,15 @@ +import { Routes } from '@angular/router'; + +const routes: Routes = [ + { + path: ':scene', + loadComponent: () => import('./wrapper'), + }, + { + path: '', + redirectTo: 'basic', + pathMatch: 'full', + }, +]; + +export default routes; diff --git a/apps/kitchen-sink/src/app/rapier/rapier.ts b/apps/kitchen-sink/src/app/rapier/rapier.ts new file mode 100644 index 00000000..d5330ccf --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/rapier.ts @@ -0,0 +1,39 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { extend } from 'angular-three'; +import * as THREE from 'three'; + +import { SCENES_MAP } from './constants'; + +extend(THREE); + +@Component({ + standalone: true, + template: ` +
+ +
+ +
    + @for (example of examples; track example) { +
  • + +
  • + } +
+ `, + imports: [RouterOutlet, RouterLink, RouterLinkActive], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'rapier' }, +}) +export default class Rapier { + protected examples = Object.keys(SCENES_MAP); +} diff --git a/apps/kitchen-sink/src/app/rapier/rope-joint/rope-joint.ts b/apps/kitchen-sink/src/app/rapier/rope-joint/rope-joint.ts new file mode 100644 index 00000000..3abded6b --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/rope-joint/rope-joint.ts @@ -0,0 +1,132 @@ +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + inject, + Injector, + input, + viewChild, +} from '@angular/core'; +import { NgtArgs, NgtVector3, NON_ROOT } from 'angular-three'; +import { injectRopeJoint, NgtrBallCollider, NgtrRigidBody } from 'angular-three-rapier'; + +const WALL_COLORS = ['#50514F', '#CBD4C2', '#FFFCFF', '#247BA0', '#C3B299']; + +@Component({ + selector: 'app-floor', + standalone: true, + template: ` + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody, NgtArgs], +}) +export class Floor {} + +@Component({ + selector: 'app-box-wall', + standalone: true, + template: ` + + @for (row of rows(); track row) { + @for (column of columns(); track column) { + + + + + + + } + } + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody], +}) +export class BoxWall { + protected readonly WALL_COLORS = WALL_COLORS; + + height = input.required(); + width = input.required(); + + protected rows = computed(() => Array.from({ length: this.height() }, (_, i) => i)); + protected columns = computed(() => Array.from({ length: this.width() }, (_, i) => i)); +} + +@Component({ + selector: 'app-rope-joint', + standalone: true, + template: ` + + + + + + + + + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody, NgtArgs, NgtrBallCollider], +}) +export class RopeJoint { + length = input.required(); + anchorPosition = input.required(); + ballPosition = input.required(); + + private anchorBody = viewChild.required('anchor', { read: NgtrRigidBody }); + private ballBody = viewChild.required('ball', { read: NgtrRigidBody }); + + constructor() { + const injector = inject(Injector); + + afterNextRender(() => { + const anchorBody = computed(() => this.anchorBody().rigidBody()); + const ballBody = computed(() => this.ballBody().rigidBody()); + + injectRopeJoint(anchorBody, ballBody, { + injector, + data: { body1Anchor: [0, 0, 0], body2Anchor: [0, 0, 0], length: this.length() }, + }); + }); + } +} + +@Component({ + standalone: true, + template: ` + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'rope-joint-rapier' }, + imports: [Floor, BoxWall, RopeJoint], +}) +export class RopeJointExample { + static [NON_ROOT] = true; +} diff --git a/apps/kitchen-sink/src/app/rapier/spring/spring.ts b/apps/kitchen-sink/src/app/rapier/spring/spring.ts new file mode 100644 index 00000000..29049a25 --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/spring/spring.ts @@ -0,0 +1,125 @@ +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + inject, + Injector, + input, + viewChild, +} from '@angular/core'; +import { NgtArgs, NgtVector3, NON_ROOT, vector3 } from 'angular-three'; +import { injectSpringJoint, NgtrBallCollider, NgtrRigidBody } from 'angular-three-rapier'; +import { ColorRepresentation } from 'three'; + +@Component({ + selector: 'app-box', + standalone: true, + template: ` + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody], +}) +export class Box { + position = input([0, 0, 0]); + color = input('white'); +} + +@Component({ + selector: 'app-ball-spring', + standalone: true, + template: ` + + + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody, NgtArgs, NgtrBallCollider], +}) +export class BallSpring { + floorRigidBody = input.required(); + position = input.required(); + jointNum = input.required(); + mass = input(1); + total = input(30); + + private ballBody = viewChild.required(NgtrRigidBody); + private stiffness = 1.0e3; + + constructor() { + const injector = inject(Injector); + + afterNextRender(() => { + const floorBody = computed(() => this.floorRigidBody().rigidBody()); + const ballBody = computed(() => this.ballBody().rigidBody()); + + const criticalDamping = computed(() => 2 * Math.sqrt(this.stiffness * this.mass())); + const dampingRatio = computed(() => this.jointNum() / (this.total() / 2)); + const damping = computed(() => dampingRatio() * criticalDamping()); + const positionVector = vector3(this.position); + + injectSpringJoint(ballBody, floorBody, { + injector, + data: { + body1Anchor: [0, 0, 0], + body2Anchor: [positionVector().x, positionVector().y - 3, positionVector().z], + restLength: 0, + stiffness: this.stiffness, + damping: damping(), + }, + }); + }); + } +} + +@Component({ + standalone: true, + template: ` + + + @for (ballPosition of balls; track $index) { + + + + + } + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'spring-rapier' }, + imports: [NgtrRigidBody, BallSpring, Box], +}) +export class SpringExample { + static [NON_ROOT] = true; + + protected readonly COLORS_ARR = ['#335C67', '#FFF3B0', '#E09F3E', '#9E2A2B', '#540B0E']; + protected balls = Array.from({ length: 30 }, (_, i) => [-20 + 1.5 * (i + 1), 7.5, -30] as const); +} diff --git a/apps/kitchen-sink/src/app/rapier/suzanne.ts b/apps/kitchen-sink/src/app/rapier/suzanne.ts new file mode 100644 index 00000000..b433f0f8 --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/suzanne.ts @@ -0,0 +1,12 @@ +import { Signal } from '@angular/core'; +import { injectGLTF } from 'angular-three-soba/loaders'; +import { Mesh } from 'three'; +import { GLTF } from 'three-stdlib'; + +type SuzanneGLTF = GLTF & { + nodes: { Suzanne: Mesh }; +}; + +export function injectSuzanne() { + return injectGLTF(() => './suzanne.glb') as Signal; +} diff --git a/apps/kitchen-sink/src/app/rapier/wrapper-default.ts b/apps/kitchen-sink/src/app/rapier/wrapper-default.ts new file mode 100644 index 00000000..81467847 --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/wrapper-default.ts @@ -0,0 +1,76 @@ +import { NgComponentOutlet } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core'; +import { NgtrPhysics, NgtrRigidBody } from 'angular-three-rapier'; +import { NgtsOrbitControls } from 'angular-three-soba/controls'; +import { NgtsEnvironment } from 'angular-three-soba/staging'; +import { injectParams } from 'ngxtension/inject-params'; +import { SCENES_MAP } from './constants'; + +export const debug = signal(false); +export const interpolate = signal(true); +export const paused = signal(false); + +@Component({ + selector: 'app-floor', + standalone: true, + template: ` + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody], +}) +export class Floor {} + +@Component({ + selector: 'app-rapier-wrapper-default', + standalone: true, + template: ` + @if (scene() === 'basic') { + + } @else { + + + + + + + + + + + + + + + + + + } + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrPhysics, NgtsEnvironment, NgtsOrbitControls, NgComponentOutlet, Floor], + host: { class: 'rapier-wrapper-default' }, +}) +export class RapierWrapperDefault { + private params = injectParams(); + protected scene = computed(() => this.params()['scene'] as keyof typeof SCENES_MAP); + protected component = computed(() => SCENES_MAP[this.scene()]); + + protected debug = debug; + protected interpolate = interpolate; + protected paused = paused; +} diff --git a/apps/kitchen-sink/src/app/rapier/wrapper.ts b/apps/kitchen-sink/src/app/rapier/wrapper.ts new file mode 100644 index 00000000..73b4ecc0 --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/wrapper.ts @@ -0,0 +1,45 @@ +import { ChangeDetectionStrategy, Component, computed, Directive, model } from '@angular/core'; +import { NgtCanvas } from 'angular-three'; +import { debug, interpolate, paused, RapierWrapperDefault } from './wrapper-default'; + +@Directive({ + selector: 'button[toggleButton]', + standalone: true, + host: { + class: 'border rounded px-2 py-1', + '(click)': 'onClick()', + '[class]': 'hbClass()', + }, +}) +export class ToggleButton { + value = model.required({ alias: 'toggleButton' }); + + hbClass = computed(() => { + return this.value() ? ['text-white', 'bg-red-600', 'border-red-400'] : ['text-black', 'border-black']; + }); + + onClick() { + this.value.update((prev) => !prev); + } +} + +@Component({ + standalone: true, + template: ` + +
+ + + +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtCanvas, ToggleButton], +}) +export default class RapierWrapper { + protected sceneGraph = RapierWrapperDefault; + + protected debug = debug; + protected interpolate = interpolate; + protected paused = paused; +} diff --git a/libs/cannon/tsconfig.lib.json b/libs/cannon/tsconfig.lib.json index bd73ceb0..808d25d9 100644 --- a/libs/cannon/tsconfig.lib.json +++ b/libs/cannon/tsconfig.lib.json @@ -7,6 +7,6 @@ "inlineSources": true, "types": ["node"] }, - "exclude": ["**/*.spec.ts", "test-setup.ts", "**/*.test.ts"], + "exclude": ["**/*.spec.ts", "src/test-setup.ts", "**/*.test.ts"], "include": ["**/*.ts"] } diff --git a/libs/core/src/lib/instance.ts b/libs/core/src/lib/instance.ts index 3bbb2077..c8fca570 100644 --- a/libs/core/src/lib/instance.ts +++ b/libs/core/src/lib/instance.ts @@ -58,11 +58,11 @@ export function prepare( instance.__ngt__.instanceStore.update((prev) => ({ [type]: [...prev[type], object] })); } - notifyAncestors(instance.__ngt__.instanceStore.snapshot.parent); + notifyAncestors(instance.__ngt__.instanceStore.snapshot.parent, type); }, remove(object, type) { instance.__ngt__.instanceStore.update((prev) => ({ [type]: prev[type].filter((node) => node !== object) })); - notifyAncestors(instance.__ngt__.instanceStore.snapshot.parent); + notifyAncestors(instance.__ngt__.instanceStore.snapshot.parent, type); }, setParent(parent) { instance.__ngt__.instanceStore.update({ parent }); @@ -74,11 +74,11 @@ export function prepare( return instance; } -function notifyAncestors(instance: NgtInstanceNode | null) { +function notifyAncestors(instance: NgtInstanceNode | null, type: 'objects' | 'nonObjects') { if (!instance) return; const localState = getLocalState(instance); if (!localState) return; - const { parent, objects, nonObjects } = localState.instanceStore.snapshot; - localState.instanceStore.update({ objects: (objects || []).slice(), nonObjects: (nonObjects || []).slice() }); - notifyAncestors(parent); + const { parent } = localState.instanceStore.snapshot; + localState.instanceStore.update({ [type]: (localState.instanceStore.snapshot[type] || []).slice() }); + notifyAncestors(parent, type); } diff --git a/libs/core/src/lib/renderer/constants.ts b/libs/core/src/lib/renderer/constants.ts index bd35dbf7..6217fee7 100644 --- a/libs/core/src/lib/renderer/constants.ts +++ b/libs/core/src/lib/renderer/constants.ts @@ -1,5 +1,6 @@ export const ROUTED_SCENE = '__ngt_renderer_is_routed_scene__'; export const HTML = '__ngt_renderer_is_html'; +export const NON_ROOT = '__ngt_renderer_is_non_root__'; export const SPECIAL_INTERNAL_ADD_COMMENT = '__ngt_renderer_add_comment__'; export const SPECIAL_DOM_TAG = { diff --git a/libs/core/src/lib/renderer/index.ts b/libs/core/src/lib/renderer/index.ts index 5c536510..f4881dd0 100644 --- a/libs/core/src/lib/renderer/index.ts +++ b/libs/core/src/lib/renderer/index.ts @@ -17,7 +17,14 @@ import { applyProps } from '../utils/apply-props'; import { is } from '../utils/is'; import { NgtSignalStore, signalStore } from '../utils/signal-store'; import { NgtAnyConstructor, injectCatalogue } from './catalogue'; -import { HTML, ROUTED_SCENE, SPECIAL_DOM_TAG, SPECIAL_INTERNAL_ADD_COMMENT, SPECIAL_PROPERTIES } from './constants'; +import { + HTML, + NON_ROOT, + ROUTED_SCENE, + SPECIAL_DOM_TAG, + SPECIAL_INTERNAL_ADD_COMMENT, + SPECIAL_PROPERTIES, +} from './constants'; import { NgtRendererNode, NgtRendererState, @@ -56,6 +63,8 @@ export class NgtRendererFactory implements RendererFactory2 { this.routedSet.add(type.id); } + const isNonRoot = (type as NgtAnyRecord)['type'][NON_ROOT]; + let renderer = this.rendererMap.get(type.id); if (!renderer) { this.rendererMap.set( @@ -67,7 +76,7 @@ export class NgtRendererFactory implements RendererFactory2 { this.portalCommentsNodes, this.catalogue, // setting root scene if there's no routed scene OR this component is the routed Scene - !hostElement && (this.routedSet.size === 0 || this.routedSet.has(type.id)), + !hostElement && !isNonRoot && (this.routedSet.size === 0 || this.routedSet.has(type.id)), )), ); } @@ -662,4 +671,4 @@ export function provideNgtRenderer(store: NgtSignalStore) { } export { extend } from './catalogue'; -export { HTML, ROUTED_SCENE } from './constants'; +export { HTML, NON_ROOT, ROUTED_SCENE } from './constants'; diff --git a/libs/rapier/.eslintrc.json b/libs/rapier/.eslintrc.json new file mode 100644 index 00000000..971cfea2 --- /dev/null +++ b/libs/rapier/.eslintrc.json @@ -0,0 +1,40 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts"], + "extends": ["plugin:@nx/angular", "plugin:@angular-eslint/template/process-inline-templates"], + "rules": { + "@angular-eslint/directive-selector": [ + "error", + { + "type": "attribute", + "prefix": "lib", + "style": "camelCase" + } + ], + "@angular-eslint/component-selector": [ + "error", + { + "type": "element", + "prefix": "lib", + "style": "kebab-case" + } + ] + } + }, + { + "files": ["*.html"], + "extends": ["plugin:@nx/angular-template"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": "error" + } + } + ] +} diff --git a/libs/rapier/README.md b/libs/rapier/README.md new file mode 100644 index 00000000..38ffe7b7 --- /dev/null +++ b/libs/rapier/README.md @@ -0,0 +1,7 @@ +# rapier + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test rapier` to execute the unit tests. diff --git a/libs/rapier/ng-package.json b/libs/rapier/ng-package.json new file mode 100644 index 00000000..c5a3c1cf --- /dev/null +++ b/libs/rapier/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/libs/rapier", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/rapier/package.json b/libs/rapier/package.json new file mode 100644 index 00000000..3c498336 --- /dev/null +++ b/libs/rapier/package.json @@ -0,0 +1,38 @@ +{ + "name": "angular-three-rapier", + "version": "0.0.0-replace", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/angular-threejs/angular-three/tree/main/libs/rapier" + }, + "author": { + "name": "Chau Tran", + "email": "nartc7789@gmail.com", + "url": "https://nartc.me" + }, + "description": "Physics Rapier for Angular Three", + "keywords": [ + "angular", + "threejs", + "renderer", + "rapier", + "physics" + ], + "license": "MIT", + "peerDependencies": { + "@angular/common": ">=18.0.0 <19.0.0", + "@angular/core": ">=18.0.0 <19.0.0", + "@dimforge/rapier3d-compat": "~0.14.0", + "three": ">=0.148.0 <0.169.0" + }, + "dependencies": { + "tslib": "^2.7.0" + }, + "sideEffects": false, + "web-types": [ + "../../node_modules/angular-three/web-types.json" + ] +} diff --git a/libs/rapier/project.json b/libs/rapier/project.json new file mode 100644 index 00000000..ead494d4 --- /dev/null +++ b/libs/rapier/project.json @@ -0,0 +1,48 @@ +{ + "name": "rapier", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/rapier/src", + "prefix": "lib", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/angular:package", + "outputs": ["{workspaceRoot}/dist/{projectRoot}"], + "options": { + "project": "libs/rapier/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "libs/rapier/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "libs/rapier/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "publish": { + "command": "npm publish", + "options": { + "cwd": "dist/libs/cannon" + } + }, + "publish-beta": { + "command": "npm publish --tag=beta", + "options": { + "cwd": "dist/libs/cannon" + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/rapier/jest.config.ts" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/rapier/src/index.ts b/libs/rapier/src/index.ts new file mode 100644 index 00000000..f6a00663 --- /dev/null +++ b/libs/rapier/src/index.ts @@ -0,0 +1,8 @@ +export * from './lib/colliders'; +export * from './lib/instanced-rigid-bodies'; +export * from './lib/joints'; +export * from './lib/mesh-collider'; +export * from './lib/physics'; +export * from './lib/rigid-body'; + +export type * from './lib/types'; diff --git a/libs/rapier/src/lib/colliders.ts b/libs/rapier/src/lib/colliders.ts new file mode 100644 index 00000000..293592cb --- /dev/null +++ b/libs/rapier/src/lib/colliders.ts @@ -0,0 +1,325 @@ +import { Directive, effect, inject, input, untracked } from '@angular/core'; +import { NgtrAnyCollider } from './rigid-body'; +import { + NgtrBallArgs, + NgtrCapsuleArgs, + NgtrConeArgs, + NgtrConvexHullArgs, + NgtrConvexMeshArgs, + NgtrCuboidArgs, + NgtrCylinderArgs, + NgtrHeightfieldArgs, + NgtrPolylineArgs, + NgtrRoundConeArgs, + NgtrRoundConvexHullArgs, + NgtrRoundConvexMeshArgs, + NgtrRoundCuboidArgs, + NgtrRoundCylinderArgs, + NgtrTrimeshArgs, +} from './types'; + +const ANY_COLLIDER_HOST_DIRECTIVE = { + directive: NgtrAnyCollider, + inputs: ['options', 'name', 'scale', 'position', 'quaternion', 'rotation', 'userData'], + outputs: ['collisionEnter', 'collisionExit', 'intersectionEnter', 'intersectionExit', 'contactForce'], +}; + +@Directive({ + selector: 'ngt-object3D[ngtrCuboidCollider]', + standalone: true, + hostDirectives: [ANY_COLLIDER_HOST_DIRECTIVE], +}) +export class NgtrCuboidCollider { + args = input.required(); + + constructor() { + const anyCollider = inject(NgtrAnyCollider, { host: true }); + anyCollider.setShape('cuboid'); + effect(() => { + const args = this.args(); + untracked(() => { + anyCollider.setArgs(args); + }); + }); + } +} + +@Directive({ + selector: 'ngt-object3D[ngtrCapsuleCollider]', + standalone: true, + hostDirectives: [ANY_COLLIDER_HOST_DIRECTIVE], +}) +export class NgtrCapsuleCollider { + args = input.required(); + + constructor() { + const anyCollider = inject(NgtrAnyCollider, { host: true }); + anyCollider.setShape('capsule'); + effect(() => { + const args = this.args(); + untracked(() => { + anyCollider.setArgs(args); + }); + }); + } +} + +@Directive({ + selector: 'ngt-object3D[ngtrBallCollider]', + standalone: true, + hostDirectives: [ANY_COLLIDER_HOST_DIRECTIVE], +}) +export class NgtrBallCollider { + args = input.required(); + + constructor() { + const anyCollider = inject(NgtrAnyCollider, { host: true }); + anyCollider.setShape('ball'); + effect(() => { + const args = this.args(); + untracked(() => { + anyCollider.setArgs(args); + }); + }); + } +} + +@Directive({ + selector: 'ngt-object3D[ngtrConvexHullCollider]', + standalone: true, + hostDirectives: [ANY_COLLIDER_HOST_DIRECTIVE], +}) +export class NgtrConvexHullCollider { + args = input.required(); + + constructor() { + const anyCollider = inject(NgtrAnyCollider, { host: true }); + anyCollider.setShape('roundConvexHull'); + effect(() => { + const args = this.args(); + untracked(() => { + anyCollider.setArgs(args); + }); + }); + } +} + +@Directive({ + selector: 'ngt-object3D[ngtrHeightfieldCollider]', + standalone: true, + hostDirectives: [ANY_COLLIDER_HOST_DIRECTIVE], +}) +export class NgtrHeightfieldCollider { + args = input.required(); + + constructor() { + const anyCollider = inject(NgtrAnyCollider, { host: true }); + anyCollider.setShape('heightfield'); + effect(() => { + const args = this.args(); + untracked(() => { + anyCollider.setArgs(args); + }); + }); + } +} + +@Directive({ + selector: 'ngt-object3D[ngtrTrimeshCollider]', + standalone: true, + hostDirectives: [ANY_COLLIDER_HOST_DIRECTIVE], +}) +export class NgtrTrimeshCollider { + args = input.required(); + + constructor() { + const anyCollider = inject(NgtrAnyCollider, { host: true }); + anyCollider.setShape('trimesh'); + effect(() => { + const args = this.args(); + untracked(() => { + anyCollider.setArgs(args); + }); + }); + } +} + +@Directive({ + selector: 'ngt-object3D[ngtrPolylineCollider]', + standalone: true, + hostDirectives: [ANY_COLLIDER_HOST_DIRECTIVE], +}) +export class NgtrPolylineCollider { + args = input.required(); + + constructor() { + const anyCollider = inject(NgtrAnyCollider, { host: true }); + anyCollider.setShape('polyline'); + effect(() => { + const args = this.args(); + untracked(() => { + anyCollider.setArgs(args); + }); + }); + } +} + +@Directive({ + selector: 'ngt-object3D[ngtrRoundCuboidCollider]', + standalone: true, + hostDirectives: [ANY_COLLIDER_HOST_DIRECTIVE], +}) +export class NgtrRoundCuboidCollider { + args = input.required(); + + constructor() { + const anyCollider = inject(NgtrAnyCollider, { host: true }); + anyCollider.setShape('roundCuboid'); + effect(() => { + const args = this.args(); + untracked(() => { + anyCollider.setArgs(args); + }); + }); + } +} + +@Directive({ + selector: 'ngt-object3D[ngtrCylinderCollider]', + standalone: true, + hostDirectives: [ANY_COLLIDER_HOST_DIRECTIVE], +}) +export class NgtrCylinderCollider { + args = input.required(); + + constructor() { + const anyCollider = inject(NgtrAnyCollider, { host: true }); + anyCollider.setShape('cylinder'); + effect(() => { + const args = this.args(); + untracked(() => { + anyCollider.setArgs(args); + }); + }); + } +} + +@Directive({ + selector: 'ngt-object3D[ngtrRoundCylinderCollider]', + standalone: true, + hostDirectives: [ANY_COLLIDER_HOST_DIRECTIVE], +}) +export class NgtrRoundCylinderCollider { + args = input.required(); + + constructor() { + const anyCollider = inject(NgtrAnyCollider, { host: true }); + anyCollider.setShape('roundCylinder'); + effect(() => { + const args = this.args(); + untracked(() => { + anyCollider.setArgs(args); + }); + }); + } +} + +@Directive({ + selector: 'ngt-object3D[ngtrConeCollider]', + standalone: true, + hostDirectives: [ANY_COLLIDER_HOST_DIRECTIVE], +}) +export class NgtrConeCollider { + args = input.required(); + + constructor() { + const anyCollider = inject(NgtrAnyCollider, { host: true }); + anyCollider.setShape('cone'); + effect(() => { + const args = this.args(); + untracked(() => { + anyCollider.setArgs(args); + }); + }); + } +} + +@Directive({ + selector: 'ngt-object3D[ngtrRoundConeCollider]', + standalone: true, + hostDirectives: [ANY_COLLIDER_HOST_DIRECTIVE], +}) +export class NgtrRoundConeCollider { + args = input.required(); + + constructor() { + const anyCollider = inject(NgtrAnyCollider, { host: true }); + anyCollider.setShape('roundCone'); + effect(() => { + const args = this.args(); + untracked(() => { + anyCollider.setArgs(args); + }); + }); + } +} + +@Directive({ + selector: 'ngt-object3D[ngtrConvexMeshCollider]', + standalone: true, + hostDirectives: [ANY_COLLIDER_HOST_DIRECTIVE], +}) +export class NgtrConvexMeshCollider { + args = input.required(); + + constructor() { + const anyCollider = inject(NgtrAnyCollider, { host: true }); + anyCollider.setShape('convexMesh'); + effect(() => { + const args = this.args(); + untracked(() => { + anyCollider.setArgs(args); + }); + }); + } +} + +@Directive({ + selector: 'ngt-object3D[ngtrRoundConvexHullCollider]', + standalone: true, + hostDirectives: [ANY_COLLIDER_HOST_DIRECTIVE], +}) +export class NgtrRoundConvexHullCollider { + args = input.required(); + + constructor() { + const anyCollider = inject(NgtrAnyCollider, { host: true }); + anyCollider.setShape('roundConvexHull'); + effect(() => { + const args = this.args(); + untracked(() => { + anyCollider.setArgs(args); + }); + }); + } +} + +@Directive({ + selector: 'ngt-object3D[ngtrRoundConvexMeshCollider]', + standalone: true, + hostDirectives: [ANY_COLLIDER_HOST_DIRECTIVE], +}) +export class NgtrRoundConvexMeshCollider { + args = input.required(); + + constructor() { + const anyCollider = inject(NgtrAnyCollider, { host: true }); + anyCollider.setShape('roundConvexMesh'); + effect(() => { + const args = this.args(); + untracked(() => { + anyCollider.setArgs(args); + }); + }); + } +} diff --git a/libs/rapier/src/lib/debug.ts b/libs/rapier/src/lib/debug.ts new file mode 100644 index 00000000..b3499cb7 --- /dev/null +++ b/libs/rapier/src/lib/debug.ts @@ -0,0 +1,45 @@ +import { + ChangeDetectionStrategy, + Component, + CUSTOM_ELEMENTS_SCHEMA, + ElementRef, + input, + viewChild, +} from '@angular/core'; +import { World } from '@dimforge/rapier3d-compat'; +import { extend, injectBeforeRender } from 'angular-three'; +import { BufferAttribute, Group, LineBasicMaterial, LineSegments } from 'three'; + +@Component({ + selector: 'ngtr-debug', + standalone: true, + template: ` + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NgtrDebug { + world = input.required(); + + private lineSegmentsRef = viewChild.required>('lineSegments'); + + constructor() { + extend({ Group, LineSegments, LineBasicMaterial, BufferAttribute }); + + injectBeforeRender(() => { + const [world, lineSegments] = [this.world(), this.lineSegmentsRef().nativeElement]; + if (!world || !lineSegments) return; + + const buffers = world.debugRender(); + + lineSegments.geometry.setAttribute('position', new BufferAttribute(buffers.vertices, 3)); + lineSegments.geometry.setAttribute('color', new BufferAttribute(buffers.colors, 4)); + }); + } +} diff --git a/libs/rapier/src/lib/frame-stepper.ts b/libs/rapier/src/lib/frame-stepper.ts new file mode 100644 index 00000000..4946832e --- /dev/null +++ b/libs/rapier/src/lib/frame-stepper.ts @@ -0,0 +1,49 @@ +import { afterNextRender, Directive, input } from '@angular/core'; +import { injectBeforeRender } from 'angular-three'; +import { injectAutoEffect } from 'ngxtension/auto-effect'; +import { NgtrPhysicsOptions } from './types'; + +@Directive({ standalone: true, selector: 'ngtr-frame-stepper' }) +export class NgtrFrameStepper { + ready = input(false); + updatePriority = input(0); + stepFn = input.required<(delta: number) => void>(); + type = input.required(); + + constructor() { + const autoEffect = injectAutoEffect(); + + afterNextRender(() => { + autoEffect((injector) => { + const ready = this.ready(); + if (!ready) return; + + const [type, updatePriority, stepFn] = [this.type(), this.updatePriority(), this.stepFn()]; + if (type === 'follow') { + return injectBeforeRender( + ({ delta }) => { + stepFn(delta); + }, + { priority: updatePriority, injector }, + ); + } + + let lastFrame = 0; + let raf: ReturnType = 0; + const loop = () => { + const now = performance.now(); + const delta = now - lastFrame; + raf = requestAnimationFrame(loop); + stepFn(delta); + lastFrame = now; + }; + + raf = requestAnimationFrame(loop); + + return () => { + cancelAnimationFrame(raf); + }; + }); + }); + } +} diff --git a/libs/rapier/src/lib/instanced-rigid-bodies.ts b/libs/rapier/src/lib/instanced-rigid-bodies.ts new file mode 100644 index 00000000..5310f95a --- /dev/null +++ b/libs/rapier/src/lib/instanced-rigid-bodies.ts @@ -0,0 +1,182 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + effect, + ElementRef, + inject, + input, + untracked, + viewChild, + viewChildren, +} from '@angular/core'; +import { extend, getLocalState, NgtEuler, NgtObject3D, NgtQuaternion, NgtVector3, pick } from 'angular-three'; +import { mergeInputs } from 'ngxtension/inject-inputs'; +import { DynamicDrawUsage, InstancedMesh, Object3D } from 'three'; +import { NgtrPhysics } from './physics'; +import { NgtrAnyCollider, NgtrRigidBody, rigidBodyDefaultOptions } from './rigid-body'; +import { NgtrRigidBodyOptions, NgtrRigidBodyState, NgtrRigidBodyType } from './types'; +import { createColliderOptions } from './utils'; + +export interface NgtrInstancedRigidBodyOptions { + key: string | number; + type?: NgtrRigidBodyType; + position?: NgtVector3; + rotation?: NgtEuler; + scale?: NgtVector3; + quaternion?: NgtQuaternion; + userData?: NgtObject3D['userData']; + options?: NgtrRigidBodyOptions; +} + +const defaultOptions: NgtrRigidBodyOptions = rigidBodyDefaultOptions; + +@Component({ + selector: 'ngt-object3D[ngtrInstancedRigidBodies]', + exportAs: 'instancedRigidBodies', + standalone: true, + template: ` + + + + + @for (instance of instancesOptions(); track instance.key) { + + + + @for (childColliderOption of childColliderOptions(); track $index) { + + } + + } + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[position]': 'position()', + '[rotation]': 'rotation()', + '[scale]': 'scale()', + '[quaternion]': 'quaternion()', + '[userData]': 'userData()', + }, + imports: [NgtrRigidBody, NgtrRigidBody, NgtrAnyCollider], +}) +export class NgtrInstancedRigidBodies { + position = input([0, 0, 0]); + rotation = input([0, 0, 0]); + scale = input([1, 1, 1]); + quaternion = input([0, 0, 0, 1]); + userData = input({}); + instances = input([], { + alias: 'ngtrInstancedRigidBodies', + transform: (value: Array | '') => { + if (value === '') return []; + return value; + }, + }); + options = input(defaultOptions, { transform: mergeInputs(defaultOptions) }); + + instanceWrapperRef = viewChild.required>('instanceWrapper'); + rigidBodyRefs = viewChildren(NgtrRigidBody); + + private physics = inject(NgtrPhysics); + objectRef = inject>(ElementRef); + + private colliders = pick(this.options, 'colliders'); + + private instancedMesh = computed(() => { + const instanceWrapper = this.instanceWrapperRef().nativeElement; + if (!instanceWrapper) return null; + + const localState = getLocalState(instanceWrapper); + if (!localState) return null; + + // track object's children + localState.objects(); + const firstChild = instanceWrapper.children[0]; + if (!firstChild || !(firstChild as InstancedMesh).isInstancedMesh) return null; + + return firstChild as InstancedMesh; + }); + + protected instancesOptions = computed(() => { + const [instances, options, instancedMesh] = [this.instances(), untracked(this.options), this.instancedMesh()]; + if (!instancedMesh) return []; + return instances.map( + (instance, index) => + ({ + ...instance, + options: { + ...options, + ...(instance.options || {}), + transformState: (state) => { + return { + ...state, + getMatrix: (matrix) => { + instancedMesh.getMatrixAt(index, matrix); + return matrix; + }, + setMatrix: (matrix) => { + instancedMesh.setMatrixAt(index, matrix); + instancedMesh.instanceMatrix.needsUpdate = true; + }, + meshType: 'instancedMesh', + } as NgtrRigidBodyState; + }, + }, + key: `${instance.key}-${index}` + `${instancedMesh?.uuid || ''}`, + }) as Omit & { options: Partial }, + ); + }); + + protected childColliderOptions = computed(() => { + const colliders = this.colliders(); + // if self colliders is false explicitly, disable auto colliders for this object entirely. + if (colliders === false) return []; + + const physicsColliders = this.physics.colliders(); + // if physics colliders is false explicitly, disable auto colliders for this object entirely. + if (physicsColliders === false) return []; + + const options = this.options(); + // if colliders on object is not set, use physics colliders + if (!options.colliders) options.colliders = physicsColliders; + + const objectLocalState = getLocalState(this.objectRef.nativeElement); + // track object's children + objectLocalState?.nonObjects(); + + return createColliderOptions(this.objectRef.nativeElement, options); + }); + + constructor() { + extend({ Object3D }); + effect(() => { + this.setInstancedMeshMatrixEffect(); + }); + } + + private setInstancedMeshMatrixEffect() { + const instancedMesh = this.instancedMesh(); + if (!instancedMesh) return; + instancedMesh.instanceMatrix.setUsage(DynamicDrawUsage); + } +} diff --git a/libs/rapier/src/lib/joints.ts b/libs/rapier/src/lib/joints.ts new file mode 100644 index 00000000..a474d10d --- /dev/null +++ b/libs/rapier/src/lib/joints.ts @@ -0,0 +1,180 @@ +import { computed, effect, ElementRef, inject, Injector } from '@angular/core'; +import { + FixedImpulseJoint, + ImpulseJoint, + JointData, + PrismaticImpulseJoint, + RevoluteImpulseJoint, + RigidBody, + RopeImpulseJoint, + SphericalImpulseJoint, + SpringImpulseJoint, +} from '@dimforge/rapier3d-compat'; +import { resolveRef } from 'angular-three'; +import { assertInjector } from 'ngxtension/assert-injector'; +import { NgtrPhysics } from './physics'; +import { + NgtrFixedJointParams, + NgtrPrismaticJointParams, + NgtrRevoluteJointParams, + NgtrRopeJointParams, + NgtrSphericalJointParams, + NgtrSpringJointParams, +} from './types'; +import { quaternionToRapierQuaternion, vector3ToRapierVector } from './utils'; + +function injectImpulseJoint( + bodyA: ElementRef | RigidBody | (() => ElementRef | RigidBody | undefined | null), + bodyB: ElementRef | RigidBody | (() => ElementRef | RigidBody | undefined | null), + { injector, data }: { injector?: Injector; data: JointData | (() => JointData | null) }, +) { + return assertInjector(injectImpulseJoint, injector, () => { + const physics = inject(NgtrPhysics); + + const newJoint = computed(() => { + const worldSingleton = physics.worldSingleton(); + if (!worldSingleton) return null; + + const a = typeof bodyA === 'function' ? resolveRef(bodyA()) : resolveRef(bodyA); + const b = typeof bodyB === 'function' ? resolveRef(bodyB()) : resolveRef(bodyB); + if (!a || !b) return null; + + const jointData = typeof data === 'function' ? data() : data; + if (!jointData) return null; + + return worldSingleton.proxy.createImpulseJoint(jointData, a, b, true) as TJoinType; + }); + + effect((onCleanup) => { + const worldSingleton = physics.worldSingleton(); + if (!worldSingleton) return; + + const joint = newJoint(); + if (!joint) return; + + onCleanup(() => { + if (worldSingleton.proxy.getImpulseJoint(joint.handle)) { + worldSingleton.proxy.removeImpulseJoint(joint, true); + } + }); + }); + + return newJoint; + }); +} + +function createJoint( + jointDataFn: (rapier: NonNullable>, data: TJointParams) => JointData, +) { + return function _injectJoint( + bodyA: ElementRef | RigidBody | (() => ElementRef | RigidBody | undefined | null), + bodyB: ElementRef | RigidBody | (() => ElementRef | RigidBody | undefined | null), + { injector, data }: { injector?: Injector; data: TJointParams }, + ) { + return assertInjector(_injectJoint, injector, () => { + const physics = inject(NgtrPhysics); + + const jointData = computed(() => { + const rapier = physics.rapier(); + if (!rapier) return null; + return jointDataFn(rapier, data); + }); + + return injectImpulseJoint(bodyA, bodyB, { injector, data: jointData }); + }); + }; +} + +/** + * A fixed joint ensures that two rigid-bodies don't move relative to each other. + * Fixed joints are characterized by one local frame (represented by an isometry) on each rigid-body. + * The fixed-joint makes these frames coincide in world-space. + * + * @category Hooks - Joints + */ +export const injectFixedJoint = createJoint((rapier, data) => + rapier.JointData.fixed( + vector3ToRapierVector(data.body1Anchor), + quaternionToRapierQuaternion(data.body1LocalFrame), + vector3ToRapierVector(data.body2Anchor), + quaternionToRapierQuaternion(data.body2LocalFrame), + ), +); + +/** + * The spherical joint ensures that two points on the local-spaces of two rigid-bodies always coincide (it prevents any relative + * translational motion at this points). This is typically used to simulate ragdolls arms, pendulums, etc. + * They are characterized by one local anchor on each rigid-body. Each anchor represents the location of the + * points that need to coincide on the local-space of each rigid-body. + * + * @category Hooks - Joints + */ +export const injectSphericalJoint = createJoint((rapier, data) => + rapier.JointData.spherical(vector3ToRapierVector(data.body1Anchor), vector3ToRapierVector(data.body2Anchor)), +); + +/** + * The revolute joint prevents any relative movement between two rigid-bodies, except for relative + * rotations along one axis. This is typically used to simulate wheels, fans, etc. + * They are characterized by one local anchor as well as one local axis on each rigid-body. + * + * @category Hooks - Joints + */ +export const injectRevoluteJoint = createJoint((rapier, data) => { + const jointData = rapier.JointData.revolute( + vector3ToRapierVector(data.body1Anchor), + vector3ToRapierVector(data.body2Anchor), + vector3ToRapierVector(data.axis), + ); + + if (data.limits) { + jointData.limitsEnabled = true; + jointData.limits = data.limits; + } + + return jointData; +}); + +/** + * The prismatic joint prevents any relative movement between two rigid-bodies, except for relative translations along one axis. + * It is characterized by one local anchor as well as one local axis on each rigid-body. In 3D, an optional + * local tangent axis can be specified for each rigid-body. + * + * @category Hooks - Joints + */ +export const injectPrismaticJoint = createJoint((rapier, data) => { + const jointData = rapier.JointData.prismatic( + vector3ToRapierVector(data.body1Anchor), + vector3ToRapierVector(data.body2Anchor), + vector3ToRapierVector(data.axis), + ); + + if (data.limits) { + jointData.limitsEnabled = true; + jointData.limits = data.limits; + } + + return jointData; +}); + +/** + * The rope joint limits the max distance between two bodies. + * @category Hooks - Joints + */ +export const injectRopeJoint = createJoint((rapier, data) => + rapier.JointData.rope(data.length, vector3ToRapierVector(data.body1Anchor), vector3ToRapierVector(data.body2Anchor)), +); + +/** + * The spring joint applies a force proportional to the distance between two objects. + * @category Hooks - Joints + */ +export const injectSpringJoint = createJoint((rapier, data) => { + return rapier.JointData.spring( + data.restLength, + data.stiffness, + data.damping, + vector3ToRapierVector(data.body1Anchor), + vector3ToRapierVector(data.body2Anchor), + ); +}); diff --git a/libs/rapier/src/lib/mesh-collider.ts b/libs/rapier/src/lib/mesh-collider.ts new file mode 100644 index 00000000..6f10d64f --- /dev/null +++ b/libs/rapier/src/lib/mesh-collider.ts @@ -0,0 +1,64 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + ElementRef, + inject, + input, +} from '@angular/core'; +import { extend, getLocalState } from 'angular-three'; +import { Object3D } from 'three'; +import { NgtrPhysics } from './physics'; +import { NgtrAnyCollider, NgtrRigidBody } from './rigid-body'; +import { NgtrRigidBodyAutoCollider } from './types'; +import { createColliderOptions } from './utils'; + +@Component({ + selector: 'ngt-object3D[ngtrMeshCollider]', + standalone: true, + template: ` + + @for (childColliderOption of childColliderOptions(); track $index) { + + } + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrAnyCollider], +}) +export class NgtrMeshCollider { + colliders = input.required({ alias: 'ngtrMeshCollider' }); + + objectRef = inject>(ElementRef); + rigidBody = inject(NgtrRigidBody); + physics = inject(NgtrPhysics); + + protected childColliderOptions = computed(() => { + const rigidBodyOptions = this.rigidBody.options(); + rigidBodyOptions.colliders = this.colliders(); + + const objectLocalState = getLocalState(this.objectRef.nativeElement); + // track object's children + objectLocalState?.nonObjects(); + objectLocalState?.objects(); + + return createColliderOptions(this.objectRef.nativeElement, rigidBodyOptions, false); + }); + + constructor() { + extend({ Object3D }); + if (!this.objectRef.nativeElement.userData) { + this.objectRef.nativeElement.userData = {}; + } + this.objectRef.nativeElement.userData['ngtrRapierType'] = 'MeshCollider'; + } +} diff --git a/libs/rapier/src/lib/physics.ts b/libs/rapier/src/lib/physics.ts new file mode 100644 index 00000000..f0f5163e --- /dev/null +++ b/libs/rapier/src/lib/physics.ts @@ -0,0 +1,413 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + input, + signal, + untracked, +} from '@angular/core'; +import RAPIER, { ColliderHandle, EventQueue, Rotation, Vector, World } from '@dimforge/rapier3d-compat'; +import { injectStore, pick, vector3 } from 'angular-three'; +import { mergeInputs } from 'ngxtension/inject-inputs'; +import { MathUtils, Quaternion, Vector3 } from 'three'; +import { NgtrDebug } from './debug'; +import { NgtrFrameStepper } from './frame-stepper'; +import { _matrix4, _position, _rotation, _scale } from './shared'; +import { + NgtrColliderStateMap, + NgtrCollisionPayload, + NgtrCollisionSource, + NgtrEventMap, + NgtrPhysicsOptions, + NgtrRigidBodyStateMap, + NgtrWorldStepCallbackSet, +} from './types'; +import { createSingletonProxy, rapierQuaternionToQuaternion } from './utils'; + +const defaultOptions: NgtrPhysicsOptions = { + gravity: [0, -9.81, 0], + allowedLinearError: 0.001, + numSolverIterations: 4, + numAdditionalFrictionIterations: 4, + numInternalPgsIterations: 1, + predictionDistance: 0.002, + minIslandSize: 128, + maxCcdSubsteps: 1, + erp: 0.8, + lengthUnit: 1, + colliders: 'cuboid', + updateLoop: 'follow', + interpolate: true, + paused: false, + timeStep: 1 / 60, + debug: false, +}; + +@Component({ + selector: 'ngtr-physics', + standalone: true, + template: ` + @if (debug()) { + + } + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrDebug, NgtrFrameStepper], +}) +export class NgtrPhysics { + options = input(defaultOptions, { transform: mergeInputs(defaultOptions) }); + + protected updatePriority = pick(this.options, 'updatePriority'); + protected updateLoop = pick(this.options, 'updateLoop'); + + private numSolverIterations = pick(this.options, 'numSolverIterations'); + private numAdditionalFrictionIterations = pick(this.options, 'numAdditionalFrictionIterations'); + private numInternalPgsIterations = pick(this.options, 'numInternalPgsIterations'); + private allowedLinearError = pick(this.options, 'allowedLinearError'); + private minIslandSize = pick(this.options, 'minIslandSize'); + private maxCcdSubsteps = pick(this.options, 'maxCcdSubsteps'); + private predictionDistance = pick(this.options, 'predictionDistance'); + private erp = pick(this.options, 'erp'); + private lengthUnit = pick(this.options, 'lengthUnit'); + private timeStep = pick(this.options, 'timeStep'); + private interpolate = pick(this.options, 'interpolate'); + + paused = pick(this.options, 'paused'); + debug = pick(this.options, 'debug'); + colliders = pick(this.options, 'colliders'); + gravity = vector3(this.options, 'gravity'); + + private store = injectStore(); + private destroyRef = inject(DestroyRef); + + private rapierConstruct = signal(null); + rapier = this.rapierConstruct.asReadonly(); + + ready = computed(() => !!this.rapier()); + worldSingleton = computed(() => { + const rapier = this.rapier(); + if (!rapier) return null; + return createSingletonProxy(() => new rapier.World(untracked(this.gravity))); + }); + + rigidBodyStates: NgtrRigidBodyStateMap = new Map(); + colliderStates: NgtrColliderStateMap = new Map(); + rigidBodyEvents: NgtrEventMap = new Map(); + colliderEvents: NgtrEventMap = new Map(); + beforeStepCallbacks: NgtrWorldStepCallbackSet = new Set(); + afterStepCallbacks: NgtrWorldStepCallbackSet = new Set(); + + private eventQueue = computed(() => { + const rapier = this.rapier(); + if (!rapier) return null; + return new EventQueue(false); + }); + + private steppingState: { + accumulator: number; + previousState: Record; + } = { accumulator: 0, previousState: {} }; + + constructor() { + import('@dimforge/rapier3d-compat') + .then((rapier) => rapier.init().then(() => rapier)) + .then(this.rapierConstruct.set.bind(this.rapierConstruct)) + .catch((err) => { + console.error(`[NGT] Failed to load rapier3d-compat`, err); + return Promise.reject(err); + }); + + effect(() => { + this.updateWorldEffect(); + }); + + this.destroyRef.onDestroy(() => { + const world = this.worldSingleton(); + if (world) { + world.proxy.free(); + world.reset(); + } + }); + } + + step(delta: number) { + if (!this.paused()) { + this.internalStep(delta); + } + } + + private updateWorldEffect() { + const world = this.worldSingleton(); + if (!world) return; + + world.proxy.gravity = this.gravity(); + world.proxy.integrationParameters.numSolverIterations = this.numSolverIterations(); + world.proxy.integrationParameters.numAdditionalFrictionIterations = this.numAdditionalFrictionIterations(); + world.proxy.integrationParameters.numInternalPgsIterations = this.numInternalPgsIterations(); + world.proxy.integrationParameters.normalizedAllowedLinearError = this.allowedLinearError(); + world.proxy.integrationParameters.minIslandSize = this.minIslandSize(); + world.proxy.integrationParameters.maxCcdSubsteps = this.maxCcdSubsteps(); + world.proxy.integrationParameters.normalizedPredictionDistance = this.predictionDistance(); + /** + * NOTE: we don't know if this is the correct way to set for contact_natural_frequency or not. + * but at least, it gets the `contact_erp` value to be very close with setting `erp` + */ + world.proxy.integrationParameters.contact_natural_frequency = this.erp() * 1_000; + world.proxy.lengthUnit = this.lengthUnit(); + } + + private internalStep(delta: number) { + const worldSingleton = this.worldSingleton(); + if (!worldSingleton) return; + + const eventQueue = this.eventQueue(); + if (!eventQueue) return; + + const world = worldSingleton.proxy; + const [timeStep, interpolate, paused] = [this.timeStep(), this.interpolate(), this.paused()]; + + /* Check if the timestep is supposed to be variable. We'll do this here + once so we don't have to string-check every frame. */ + const timeStepVariable = timeStep === 'vary'; + + /** + * Fixed timeStep simulation progression + * @see https://gafferongames.com/post/fix_your_timestep/ + */ + const clampedDelta = MathUtils.clamp(delta, 0, 0.5); + + const stepWorld = (innerDelta: number) => { + // Trigger beforeStep callbacks + this.beforeStepCallbacks.forEach((callback) => { + callback(world); + }); + + world.timestep = innerDelta; + world.step(eventQueue); + + // Trigger afterStep callbacks + this.afterStepCallbacks.forEach((callback) => { + callback(world); + }); + }; + + if (timeStepVariable) { + stepWorld(clampedDelta); + } else { + // don't step time forwards if paused + // Increase accumulator + this.steppingState.accumulator += clampedDelta; + + while (this.steppingState.accumulator >= timeStep) { + // Set up previous state + // needed for accurate interpolations if the world steps more than once + if (interpolate) { + this.steppingState.previousState = {}; + world.forEachRigidBody((body) => { + this.steppingState.previousState[body.handle] = { + position: body.translation(), + rotation: body.rotation(), + }; + }); + } + + stepWorld(timeStep); + this.steppingState.accumulator -= timeStep; + } + } + + const interpolationAlpha = + timeStepVariable || !interpolate || paused ? 1 : this.steppingState.accumulator / timeStep; + + // Update meshes + this.rigidBodyStates.forEach((state, handle) => { + const rigidBody = world.getRigidBody(handle); + + const events = this.rigidBodyEvents.get(handle); + if (events?.onSleep || events?.onWake) { + if (rigidBody.isSleeping() && !state.isSleeping) events?.onSleep?.(); + if (!rigidBody.isSleeping() && state.isSleeping) events?.onWake?.(); + state.isSleeping = rigidBody.isSleeping(); + } + + if (!rigidBody || (rigidBody.isSleeping() && !('isInstancedMesh' in state.object)) || !state.setMatrix) { + return; + } + + // New states + let t = rigidBody.translation() as Vector3; + let r = rigidBody.rotation() as Quaternion; + + let previousState = this.steppingState.previousState[handle]; + + if (previousState) { + // Get previous simulated world position + _matrix4 + .compose(previousState.position as Vector3, rapierQuaternionToQuaternion(previousState.rotation), state.scale) + .premultiply(state.invertedWorldMatrix) + .decompose(_position, _rotation, _scale); + + // Apply previous tick position + if (state.meshType == 'mesh') { + state.object.position.copy(_position); + state.object.quaternion.copy(_rotation); + } + } + + // Get new position + _matrix4 + .compose(t, rapierQuaternionToQuaternion(r), state.scale) + .premultiply(state.invertedWorldMatrix) + .decompose(_position, _rotation, _scale); + + if (state.meshType == 'instancedMesh') { + state.setMatrix(_matrix4); + } else { + // Interpolate to new position + state.object.position.lerp(_position, interpolationAlpha); + state.object.quaternion.slerp(_rotation, interpolationAlpha); + } + }); + + eventQueue.drainCollisionEvents((handle1, handle2, started) => { + const source1 = this.getSourceFromColliderHandle(handle1); + const source2 = this.getSourceFromColliderHandle(handle2); + + // Collision Events + if (!source1?.collider.object || !source2?.collider.object) { + return; + } + + const collisionPayload1 = this.getCollisionPayloadFromSource(source1, source2); + const collisionPayload2 = this.getCollisionPayloadFromSource(source2, source1); + + if (started) { + world.contactPair(source1.collider.object, source2.collider.object, (manifold, flipped) => { + /* RigidBody events */ + source1.rigidBody.events?.onCollisionEnter?.({ ...collisionPayload1, manifold, flipped }); + source2.rigidBody.events?.onCollisionEnter?.({ ...collisionPayload2, manifold, flipped }); + + /* Collider events */ + source1.collider.events?.onCollisionEnter?.({ ...collisionPayload1, manifold, flipped }); + source2.collider.events?.onCollisionEnter?.({ ...collisionPayload2, manifold, flipped }); + }); + } else { + source1.rigidBody.events?.onCollisionExit?.(collisionPayload1); + source2.rigidBody.events?.onCollisionExit?.(collisionPayload2); + source1.collider.events?.onCollisionExit?.(collisionPayload1); + source2.collider.events?.onCollisionExit?.(collisionPayload2); + } + + // Sensor Intersections + if (started) { + if (world.intersectionPair(source1.collider.object, source2.collider.object)) { + source1.rigidBody.events?.onIntersectionEnter?.(collisionPayload1); + source2.rigidBody.events?.onIntersectionEnter?.(collisionPayload2); + source1.collider.events?.onIntersectionEnter?.(collisionPayload1); + source2.collider.events?.onIntersectionEnter?.(collisionPayload2); + } + } else { + source1.rigidBody.events?.onIntersectionExit?.(collisionPayload1); + source2.rigidBody.events?.onIntersectionExit?.(collisionPayload2); + source1.collider.events?.onIntersectionExit?.(collisionPayload1); + source2.collider.events?.onIntersectionExit?.(collisionPayload2); + } + }); + + eventQueue.drainContactForceEvents((event) => { + const source1 = this.getSourceFromColliderHandle(event.collider1()); + const source2 = this.getSourceFromColliderHandle(event.collider2()); + + // Collision Events + if (!source1?.collider.object || !source2?.collider.object) { + return; + } + + const collisionPayload1 = this.getCollisionPayloadFromSource(source1, source2); + const collisionPayload2 = this.getCollisionPayloadFromSource(source2, source1); + + source1.rigidBody.events?.onContactForce?.({ + ...collisionPayload1, + totalForce: event.totalForce(), + totalForceMagnitude: event.totalForceMagnitude(), + maxForceDirection: event.maxForceDirection(), + maxForceMagnitude: event.maxForceMagnitude(), + }); + + source2.rigidBody.events?.onContactForce?.({ + ...collisionPayload2, + totalForce: event.totalForce(), + totalForceMagnitude: event.totalForceMagnitude(), + maxForceDirection: event.maxForceDirection(), + maxForceMagnitude: event.maxForceMagnitude(), + }); + + source1.collider.events?.onContactForce?.({ + ...collisionPayload1, + totalForce: event.totalForce(), + totalForceMagnitude: event.totalForceMagnitude(), + maxForceDirection: event.maxForceDirection(), + maxForceMagnitude: event.maxForceMagnitude(), + }); + + source2.collider.events?.onContactForce?.({ + ...collisionPayload2, + totalForce: event.totalForce(), + totalForceMagnitude: event.totalForceMagnitude(), + maxForceDirection: event.maxForceDirection(), + maxForceMagnitude: event.maxForceMagnitude(), + }); + }); + + world.forEachActiveRigidBody(() => { + this.store.snapshot.invalidate(); + }); + } + + private getSourceFromColliderHandle(handle: ColliderHandle) { + const world = this.worldSingleton(); + if (!world) return; + + const collider = world.proxy.getCollider(handle); + const colEvents = this.colliderEvents.get(handle); + const colliderState = this.colliderStates.get(handle); + + const rigidBodyHandle = collider.parent()?.handle; + const rigidBody = rigidBodyHandle !== undefined ? world.proxy.getRigidBody(rigidBodyHandle) : undefined; + const rigidBodyEvents = + rigidBody && rigidBodyHandle !== undefined ? this.rigidBodyEvents.get(rigidBodyHandle) : undefined; + const rigidBodyState = rigidBodyHandle !== undefined ? this.rigidBodyStates.get(rigidBodyHandle) : undefined; + + return { + collider: { object: collider, events: colEvents, state: colliderState }, + rigidBody: { object: rigidBody, events: rigidBodyEvents, state: rigidBodyState }, + } as NgtrCollisionSource; + } + + private getCollisionPayloadFromSource(target: NgtrCollisionSource, other: NgtrCollisionSource): NgtrCollisionPayload { + return { + target: { + rigidBody: target.rigidBody.object, + collider: target.collider.object, + colliderObject: target.collider.state?.object, + rigidBodyObject: target.rigidBody.state?.object, + }, + other: { + rigidBody: other.rigidBody.object, + collider: other.collider.object, + colliderObject: other.collider.state?.object, + rigidBodyObject: other.rigidBody.state?.object, + }, + }; + } +} diff --git a/libs/rapier/src/lib/rigid-body.ts b/libs/rapier/src/lib/rigid-body.ts new file mode 100644 index 00000000..42c0db23 --- /dev/null +++ b/libs/rapier/src/lib/rigid-body.ts @@ -0,0 +1,636 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + Directive, + effect, + ElementRef, + inject, + input, + model, + output, + untracked, +} from '@angular/core'; +import { ActiveEvents, Collider, ColliderDesc, RigidBody, RigidBodyDesc } from '@dimforge/rapier3d-compat'; +import { extend, getLocalState, NgtEuler, NgtObject3D, NgtQuaternion, NgtVector3, pick } from 'angular-three'; +import { mergeInputs } from 'ngxtension/inject-inputs'; +import { Matrix4, Object3D, Vector3 } from 'three'; +import { NgtrPhysics } from './physics'; +import { _matrix4, _position, _rotation, _scale, _vector3 } from './shared'; +import { + NgtrColliderOptions, + NgtrColliderShape, + NgtrColliderState, + NgtrCollisionEnterPayload, + NgtrCollisionExitPayload, + NgtrContactForcePayload, + NgtrIntersectionEnterPayload, + NgtrIntersectionExitPayload, + NgtrRigidBodyOptions, + NgtrRigidBodyState, + NgtrRigidBodyType, +} from './types'; +import { createColliderOptions, getEmitter, hasListener } from './utils'; + +const colliderDefaultOptions: NgtrColliderOptions = { + contactSkin: 0, +}; + +@Directive({ + selector: 'ngt-object3D[ngtrCollider]', + standalone: true, + host: { + '[position]': 'position()', + '[rotation]': 'rotation()', + '[scale]': 'scale()', + '[quaternion]': 'quaternion()', + '[userData]': 'userData()', + '[name]': 'name()', + }, +}) +export class NgtrAnyCollider { + position = input([0, 0, 0]); + rotation = input([0, 0, 0]); + scale = input([1, 1, 1]); + quaternion = input([0, 0, 0, 1]); + userData = input({}); + name = input(); + options = input(colliderDefaultOptions, { transform: mergeInputs(rigidBodyDefaultOptions) }); + + // TODO: change this to input required when Angular allows setting hostDirective input + shape = model(undefined, { alias: 'ngtrCollider' }); + args = model([]); + + collisionEnter = output(); + collisionExit = output(); + intersectionEnter = output(); + intersectionExit = output(); + contactForce = output(); + + private sensor = pick(this.options, 'sensor'); + private collisionGroups = pick(this.options, 'collisionGroups'); + private solverGroups = pick(this.options, 'solverGroups'); + private friction = pick(this.options, 'friction'); + private frictionCombineRule = pick(this.options, 'frictionCombineRule'); + private restitution = pick(this.options, 'restitution'); + private restitutionCombineRule = pick(this.options, 'restitutionCombineRule'); + private activeCollisionTypes = pick(this.options, 'activeCollisionTypes'); + private contactSkin = pick(this.options, 'contactSkin'); + private mass = pick(this.options, 'mass'); + private massProperties = pick(this.options, 'massProperties'); + private density = pick(this.options, 'density'); + + private rigidBody = inject(NgtrRigidBody, { optional: true }); + private physics = inject(NgtrPhysics); + objectRef = inject>(ElementRef); + + private scaledArgs = computed(() => { + const [shape, args] = [ + this.shape(), + this.args() as (number | ArrayLike | { x: number; y: number; z: number })[], + ]; + + const cloned = args.slice(); + + // Heightfield uses a vector + if (shape === 'heightfield') { + const s = cloned[3] as { x: number; y: number; z: number }; + s.x *= this.worldScale.x; + s.y *= this.worldScale.y; + s.z *= this.worldScale.z; + + return cloned; + } + + // Trimesh and convex scale the vertices + if (shape === 'trimesh' || shape === 'convexHull') { + cloned[0] = this.scaleVertices(cloned[0] as ArrayLike, this.worldScale); + return cloned; + } + + // prefill with some extra + const scaleArray = [this.worldScale.x, this.worldScale.y, this.worldScale.z, this.worldScale.x, this.worldScale.x]; + return cloned.map((arg, index) => scaleArray[index] * (arg as number)); + }); + + private collider = computed(() => { + const worldSingleton = this.physics.worldSingleton(); + if (!worldSingleton) return null; + + const [shape, args, rigidBody] = [this.shape(), this.scaledArgs(), this.rigidBody?.rigidBody()]; + + // @ts-expect-error - we know the type of the data + const desc = ColliderDesc[shape](...args); + if (!desc) return null; + + return worldSingleton.proxy.createCollider(desc, rigidBody ?? undefined); + }); + + constructor() { + extend({ Object3D }); + + effect((onCleanup) => { + const cleanup = this.createColliderStateEffect(); + onCleanup(() => cleanup?.()); + }); + + effect((onCleanup) => { + const cleanup = this.createColliderEventsEffect(); + onCleanup(() => cleanup?.()); + }); + + effect(() => { + this.updateColliderEffect(); + this.updateMassPropertiesEffect(); + }); + } + + get worldScale() { + return this.objectRef.nativeElement.getWorldScale(new Vector3()); + } + + setShape(shape: NgtrColliderShape) { + this.shape.set(shape); + } + + setArgs(args: unknown[]) { + this.args.set(args); + } + + private createColliderStateEffect() { + const collider = this.collider(); + if (!collider) return; + + const worldSingleton = this.physics.worldSingleton(); + if (!worldSingleton) return; + + const state = this.createColliderState( + collider, + this.objectRef.nativeElement, + this.rigidBody?.objectRef.nativeElement, + ); + this.physics.colliderStates.set(collider.handle, state); + + return () => { + this.physics.colliderStates.delete(collider.handle); + if (worldSingleton.proxy.getCollider(collider.handle)) { + worldSingleton.proxy.removeCollider(collider, true); + } + }; + } + + private createColliderEventsEffect() { + const collider = this.collider(); + if (!collider) return; + + const worldSingleton = this.physics.worldSingleton(); + if (!worldSingleton) return; + + const collisionEnter = getEmitter(this.collisionEnter); + const collisionExit = getEmitter(this.collisionExit); + const intersectionEnter = getEmitter(this.intersectionEnter); + const intersectionExit = getEmitter(this.intersectionExit); + const contactForce = getEmitter(this.contactForce); + + const hasCollisionEvent = hasListener( + this.collisionEnter, + this.collisionExit, + this.intersectionEnter, + this.intersectionExit, + this.rigidBody?.collisionEnter, + this.rigidBody?.collisionExit, + this.rigidBody?.intersectionEnter, + this.rigidBody?.intersectionExit, + ); + const hasContactForceEvent = hasListener(this.contactForce, this.rigidBody?.contactForce); + + if (hasCollisionEvent && hasContactForceEvent) { + collider.setActiveEvents(ActiveEvents.COLLISION_EVENTS | ActiveEvents.CONTACT_FORCE_EVENTS); + } else if (hasCollisionEvent) { + collider.setActiveEvents(ActiveEvents.COLLISION_EVENTS); + } else if (hasContactForceEvent) { + collider.setActiveEvents(ActiveEvents.CONTACT_FORCE_EVENTS); + } + + this.physics.colliderEvents.set(collider.handle, { + onCollisionEnter: collisionEnter, + onCollisionExit: collisionExit, + onIntersectionEnter: intersectionEnter, + onIntersectionExit: intersectionExit, + onContactForce: contactForce, + }); + return () => { + this.physics.colliderEvents.delete(collider.handle); + }; + } + + private updateColliderEffect() { + const collider = this.collider(); + if (!collider) return; + + const worldSingleton = this.physics.worldSingleton(); + if (!worldSingleton) return; + + const state = this.physics.colliderStates.get(collider.handle); + if (!state) return; + + // Update collider position based on the object's position + const parentWorldScale = state.object.parent!.getWorldScale(_vector3); + const parentInvertedWorldMatrix = state.worldParent?.matrixWorld.clone().invert(); + + state.object.updateWorldMatrix(true, false); + + _matrix4.copy(state.object.matrixWorld); + + if (parentInvertedWorldMatrix) { + _matrix4.premultiply(parentInvertedWorldMatrix); + } + + _matrix4.decompose(_position, _rotation, _scale); + + if (collider.parent()) { + collider.setTranslationWrtParent({ + x: _position.x * parentWorldScale.x, + y: _position.y * parentWorldScale.y, + z: _position.z * parentWorldScale.z, + }); + collider.setRotationWrtParent(_rotation); + } else { + collider.setTranslation({ + x: _position.x * parentWorldScale.x, + y: _position.y * parentWorldScale.y, + z: _position.z * parentWorldScale.z, + }); + collider.setRotation(_rotation); + } + + const [ + sensor, + collisionGroups, + solverGroups, + friction, + frictionCombineRule, + restitution, + restitutionCombineRule, + activeCollisionTypes, + contactSkin, + ] = [ + this.sensor(), + this.collisionGroups(), + this.solverGroups(), + this.friction(), + this.frictionCombineRule(), + this.restitution(), + this.restitutionCombineRule(), + this.activeCollisionTypes(), + this.contactSkin(), + ]; + + if (sensor !== undefined) collider.setSensor(sensor); + if (collisionGroups !== undefined) collider.setCollisionGroups(collisionGroups); + if (solverGroups !== undefined) collider.setSolverGroups(solverGroups); + if (friction !== undefined) collider.setFriction(friction); + if (frictionCombineRule !== undefined) collider.setFrictionCombineRule(frictionCombineRule); + if (restitution !== undefined) collider.setRestitution(restitution); + if (restitutionCombineRule !== undefined) collider.setRestitutionCombineRule(restitutionCombineRule); + if (activeCollisionTypes !== undefined) collider.setActiveCollisionTypes(activeCollisionTypes); + if (contactSkin !== undefined) collider.setContactSkin(contactSkin); + } + + private updateMassPropertiesEffect() { + const collider = this.collider(); + if (!collider) return; + + const [mass, massProperties, density] = [this.mass(), this.massProperties(), this.density()]; + + if (density !== undefined) { + if (mass !== undefined || massProperties !== undefined) { + throw new Error('[NGT Rapier] Cannot set mass and massProperties along with density'); + } + + collider.setDensity(density); + return; + } + + if (mass !== undefined) { + if (massProperties !== undefined) { + throw new Error('[NGT Rapier] Cannot set massProperties along with mass'); + } + collider.setMass(mass); + return; + } + + if (massProperties !== undefined) { + collider.setMassProperties( + massProperties.mass, + massProperties.centerOfMass, + massProperties.principalAngularInertia, + massProperties.angularInertiaLocalFrame, + ); + return; + } + } + + private createColliderState( + collider: Collider, + object: Object3D, + rigidBodyObject?: Object3D | null, + ): NgtrColliderState { + return { collider, worldParent: rigidBodyObject || undefined, object }; + } + + private scaleVertices(vertices: ArrayLike, scale: Vector3) { + const scaledVerts = Array.from(vertices); + + for (let i = 0; i < vertices.length / 3; i++) { + scaledVerts[i * 3] *= scale.x; + scaledVerts[i * 3 + 1] *= scale.y; + scaledVerts[i * 3 + 2] *= scale.z; + } + + return scaledVerts; + } +} + +const RIGID_BODY_TYPE_MAP: Record = { + fixed: 1, + dynamic: 0, + kinematicPosition: 2, + kinematicVelocity: 3, +}; + +export const rigidBodyDefaultOptions: NgtrRigidBodyOptions = { + canSleep: true, + linearVelocity: [0, 0, 0], + angularVelocity: [0, 0, 0], + gravityScale: 1, + dominanceGroup: 0, + ccd: false, + softCcdPrediction: 0, + contactSkin: 0, +}; + +@Component({ + selector: 'ngt-object3D[ngtrRigidBody]', + exportAs: 'rigidBody', + standalone: true, + template: ` + + @for (childColliderOption of childColliderOptions(); track $index) { + + } + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[position]': 'position()', + '[rotation]': 'rotation()', + '[scale]': 'scale()', + '[quaternion]': 'quaternion()', + '[userData]': 'userData()', + }, + imports: [NgtrAnyCollider], +}) +export class NgtrRigidBody { + type = input('dynamic', { + alias: 'ngtrRigidBody', + transform: (value: NgtrRigidBodyType | '' | undefined) => { + if (value === '' || value === undefined) return 'dynamic' as NgtrRigidBodyType; + return value; + }, + }); + position = input([0, 0, 0]); + rotation = input([0, 0, 0]); + scale = input([1, 1, 1]); + quaternion = input([0, 0, 0, 1]); + userData = input({}); + options = input(rigidBodyDefaultOptions, { transform: mergeInputs(rigidBodyDefaultOptions) }); + + wake = output(); + sleep = output(); + collisionEnter = output(); + collisionExit = output(); + intersectionEnter = output(); + intersectionExit = output(); + contactForce = output(); + + private canSleep = pick(this.options, 'canSleep'); + private colliders = pick(this.options, 'colliders'); + private transformState = pick(this.options, 'transformState'); + private gravityScale = pick(this.options, 'gravityScale'); + private dominanceGroup = pick(this.options, 'dominanceGroup'); + private ccd = pick(this.options, 'ccd'); + private softCcdPrediction = pick(this.options, 'softCcdPrediction'); + private additionalSolverIterations = pick(this.options, 'additionalSolverIterations'); + private linearDamping = pick(this.options, 'linearDamping'); + private angularDamping = pick(this.options, 'angularDamping'); + private lockRotations = pick(this.options, 'lockRotations'); + private lockTranslations = pick(this.options, 'lockTranslations'); + private enabledRotations = pick(this.options, 'enabledRotations'); + private enabledTranslations = pick(this.options, 'enabledTranslations'); + private angularVelocity = pick(this.options, 'angularVelocity'); + private linearVelocity = pick(this.options, 'linearVelocity'); + + objectRef = inject>(ElementRef); + private physics = inject(NgtrPhysics); + + private bodyType = computed(() => RIGID_BODY_TYPE_MAP[this.type()]); + private bodyDesc = computed(() => { + const [canSleep, bodyType] = [this.canSleep(), untracked(this.bodyType), this.colliders()]; + return new RigidBodyDesc(bodyType).setCanSleep(canSleep); + }); + rigidBody = computed(() => { + const worldSingleton = this.physics.worldSingleton(); + if (!worldSingleton) return null; + return worldSingleton.proxy.createRigidBody(this.bodyDesc()); + }); + + protected childColliderOptions = computed(() => { + const colliders = this.colliders(); + // if self colliders is false explicitly, disable auto colliders for this object entirely. + if (colliders === false) return []; + + const physicsColliders = this.physics.colliders(); + // if physics colliders is false explicitly, disable auto colliders for this object entirely. + if (physicsColliders === false) return []; + + const options = this.options(); + // if colliders on object is not set, use physics colliders + if (!options.colliders) options.colliders = physicsColliders; + + const objectLocalState = getLocalState(this.objectRef.nativeElement); + // track object's children + objectLocalState?.nonObjects(); + + return createColliderOptions(this.objectRef.nativeElement, options, true); + }); + + constructor() { + extend({ Object3D }); + + effect((onCleanup) => { + const cleanup = this.createRigidBodyStateEffect(); + onCleanup(() => cleanup?.()); + }); + + effect((onCleanup) => { + const cleanup = this.createRigidBodyEventsEffect(); + onCleanup(() => cleanup?.()); + }); + + effect(() => { + this.updateRigidBodyEffect(); + }); + } + + private createRigidBodyStateEffect() { + const worldSingleton = this.physics.worldSingleton(); + if (!worldSingleton) return; + + const body = this.rigidBody(); + if (!body) return; + + const transformState = untracked(this.transformState); + + const state = this.createRigidBodyState(body, this.objectRef.nativeElement); + this.physics.rigidBodyStates.set(body.handle, transformState ? transformState(state) : state); + + return () => { + this.physics.rigidBodyStates.delete(body.handle); + if (worldSingleton.proxy.getRigidBody(body.handle)) { + worldSingleton.proxy.removeRigidBody(body); + } + }; + } + + private createRigidBodyEventsEffect() { + const worldSingleton = this.physics.worldSingleton(); + if (!worldSingleton) return; + + const body = this.rigidBody(); + if (!body) return; + + const wake = getEmitter(this.wake); + const sleep = getEmitter(this.sleep); + const collisionEnter = getEmitter(this.collisionEnter); + const collisionExit = getEmitter(this.collisionExit); + const intersectionEnter = getEmitter(this.intersectionEnter); + const intersectionExit = getEmitter(this.intersectionExit); + const contactForce = getEmitter(this.contactForce); + + this.physics.rigidBodyEvents.set(body.handle, { + onWake: wake, + onSleep: sleep, + onCollisionEnter: collisionEnter, + onCollisionExit: collisionExit, + onIntersectionEnter: intersectionEnter, + onIntersectionExit: intersectionExit, + onContactForce: contactForce, + }); + + return () => { + this.physics.rigidBodyEvents.delete(body.handle); + }; + } + + private updateRigidBodyEffect() { + const worldSingleton = this.physics.worldSingleton(); + if (!worldSingleton) return; + + const body = this.rigidBody(); + if (!body) return; + + const state = this.physics.rigidBodyStates.get(body.handle); + if (!state) return; + + state.object.updateWorldMatrix(true, false); + _matrix4.copy(state.object.matrixWorld).decompose(_position, _rotation, _scale); + body.setTranslation(_position, true); + body.setRotation(_rotation, true); + + const [ + gravityScale, + additionalSolverIterations, + linearDamping, + angularDamping, + lockRotations, + lockTranslations, + enabledRotations, + enabledTranslations, + angularVelocity, + linearVelocity, + ccd, + softCcdPrediction, + dominanceGroup, + userData, + bodyType, + ] = [ + this.gravityScale(), + this.additionalSolverIterations(), + this.linearDamping(), + this.angularDamping(), + this.lockRotations(), + this.lockTranslations(), + this.enabledRotations(), + this.enabledTranslations(), + this.angularVelocity(), + this.linearVelocity(), + this.ccd(), + this.softCcdPrediction(), + this.dominanceGroup(), + this.userData(), + this.bodyType(), + ]; + + body.setGravityScale(gravityScale, true); + if (additionalSolverIterations !== undefined) body.setAdditionalSolverIterations(additionalSolverIterations); + if (linearDamping !== undefined) body.setLinearDamping(linearDamping); + if (angularDamping !== undefined) body.setAngularDamping(angularDamping); + body.setDominanceGroup(dominanceGroup); + if (enabledRotations !== undefined) body.setEnabledRotations(...enabledRotations, true); + if (enabledTranslations !== undefined) body.setEnabledTranslations(...enabledTranslations, true); + if (lockRotations !== undefined) body.lockRotations(lockRotations, true); + if (lockTranslations !== undefined) body.lockTranslations(lockTranslations, true); + body.setAngvel({ x: angularVelocity[0], y: angularVelocity[1], z: angularVelocity[2] }, true); + body.setLinvel({ x: linearVelocity[0], y: linearVelocity[1], z: linearVelocity[2] }, true); + body.enableCcd(ccd); + body.setSoftCcdPrediction(softCcdPrediction); + if (userData !== undefined) body.userData = userData; + if (bodyType !== body.bodyType()) body.setBodyType(bodyType, true); + } + + private createRigidBodyState( + rigidBody: RigidBody, + object: Object3D, + setMatrix?: (matrix: Matrix4) => void, + getMatrix?: (matrix: Matrix4) => Matrix4, + worldScale?: Vector3, + meshType: NgtrRigidBodyState['meshType'] = 'mesh', + ) { + object.updateWorldMatrix(true, false); + const invertedWorldMatrix = object.parent!.matrixWorld.clone().invert(); + return { + object, + rigidBody, + invertedWorldMatrix, + setMatrix: setMatrix + ? setMatrix + : (matrix: Matrix4) => { + object.matrix.copy(matrix); + }, + getMatrix: getMatrix ? getMatrix : (matrix: Matrix4) => matrix.copy(object.matrix), + scale: worldScale || object.getWorldScale(_scale).clone(), + isSleeping: false, + meshType, + }; + } +} diff --git a/libs/rapier/src/lib/shared.ts b/libs/rapier/src/lib/shared.ts new file mode 100644 index 00000000..001fc1e0 --- /dev/null +++ b/libs/rapier/src/lib/shared.ts @@ -0,0 +1,10 @@ +import { Euler, Matrix4, Object3D, Quaternion, Vector3 } from 'three'; + +export const _quaternion = new Quaternion(); +export const _euler = new Euler(); +export const _vector3 = new Vector3(); +export const _object3d = new Object3D(); +export const _matrix4 = new Matrix4(); +export const _position = new Vector3(); +export const _rotation = new Quaternion(); +export const _scale = new Vector3(); diff --git a/libs/rapier/src/lib/types.ts b/libs/rapier/src/lib/types.ts new file mode 100644 index 00000000..031b996c --- /dev/null +++ b/libs/rapier/src/lib/types.ts @@ -0,0 +1,598 @@ +import { + ActiveCollisionTypes, + CoefficientCombineRule, + Collider, + ColliderHandle, + InteractionGroups, + RigidBody, + RigidBodyHandle, + Rotation, + TempContactManifold, + Vector, + World, +} from '@dimforge/rapier3d-compat'; +import { NgtObject3D, NgtQuaternion, NgtVector3 } from 'angular-three'; +import { Matrix4, Object3D, Vector3, Vector3Tuple } from 'three'; + +export type NgtrRigidBodyAutoCollider = 'ball' | 'cuboid' | 'hull' | 'trimesh' | false; + +export interface NgtrPhysicsOptions { + /** + * Set the gravity of the physics world + * @defaultValue [0, -9.81, 0] + */ + gravity: Vector3Tuple; + + /** + * Amount of penetration the engine wont attempt to correct + * @defaultValue 0.001 + */ + allowedLinearError: number; + + /** + * The number of solver iterations run by the constraints solver for calculating forces. + * The greater this value is, the most rigid and realistic the physics simulation will be. + * However a greater number of iterations is more computationally intensive. + * + * @defaultValue 4 + */ + numSolverIterations: number; + + /** + * Number of addition friction resolution iteration run during the last solver sub-step. + * The greater this value is, the most realistic friction will be. + * However a greater number of iterations is more computationally intensive. + * + * @defaultValue 4 + */ + numAdditionalFrictionIterations: number; + + /** + * Number of internal Project Gauss Seidel (PGS) iterations run at each solver iteration. + * Increasing this parameter will improve stability of the simulation. It will have a lesser effect than + * increasing `numSolverIterations` but is also less computationally expensive. + * + * @defaultValue 1 + */ + numInternalPgsIterations: number; + + /** + * The maximal distance separating two objects that will generate predictive contacts + * + * @defaultValue 0.002 + * + */ + predictionDistance: number; + + /** + * Minimum number of dynamic bodies in each active island + * + * @defaultValue 128 + */ + minIslandSize: number; + + /** + * Maximum number of substeps performed by the solver + * + * @defaultValue 1 + */ + maxCcdSubsteps: number; + + /** + * The Error Reduction Parameter in between 0 and 1, is the proportion of the positional error to be corrected at each time step. + * + * @defaultValue 0.8 + */ + erp: number; + + /** + * The approximate size of most dynamic objects in the scene. + * + * This value is used internally to estimate some length-based tolerance. + * This value can be understood as the number of units-per-meter in your physical world compared to a human-sized world in meter. + * + * @defaultValue 1 + */ + lengthUnit: number; + + /** + * Set the base automatic colliders for this physics world + * All Meshes inside RigidBodies will generate a collider + * based on this value, if not overridden. + */ + colliders?: NgtrRigidBodyAutoCollider; + + /** + * Set the timestep for the simulation. + * Setting this to a number (eg. 1/60) will run the + * simulation at that framerate. Alternatively, you can set this to + * "vary", which will cause the simulation to always synchronize with + * the current frame delta times. + * + * @defaultValue 1/60 + */ + timeStep: number | 'vary'; + + /** + * Pause the physics simulation + * + * @defaultValue false + */ + paused: boolean; + + /** + * Interpolate the world transform using the frame delta times. + * Has no effect if timeStep is set to "vary". + * + * @defaultValue true + **/ + interpolate: boolean; + + /** + * The update priority at which the physics simulation should run. + * Only used when `updateLoop` is set to "follow". + * + * @see https://docs.pmnd.rs/react-three-fiber/api/hooks#taking-over-the-render-loop + * @defaultValue undefined + */ + updatePriority?: number; + + /** + * Set the update loop strategy for the physics world. + * + * If set to "follow", the physics world will be stepped + * in a `useFrame` callback, managed by @react-three/fiber. + * You can use `updatePriority` prop to manage the scheduling. + * + * If set to "independent", the physics world will be stepped + * in a separate loop, not tied to the render loop. + * This is useful when using the "demand" `frameloop` strategy for the + * @react-three/fiber ``. + * + * @see https://docs.pmnd.rs/react-three-fiber/advanced/scaling-performance#on-demand-rendering + * @defaultValue "follow" + */ + updateLoop: 'follow' | 'independent'; + + /** + * Enable debug rendering of the physics world. + * @defaultValue false + */ + debug: boolean; +} + +export interface NgtrRigidBodyState { + meshType: 'instancedMesh' | 'mesh'; + rigidBody: RigidBody; + object: Object3D; + invertedWorldMatrix: Matrix4; + setMatrix: (matrix: Matrix4) => void; + getMatrix: (matrix: Matrix4) => Matrix4; + /** + * Required for instanced rigid bodies. + */ + scale: Vector3; + isSleeping: boolean; +} +export type NgtrRigidBodyStateMap = Map; + +export interface NgtrColliderState { + collider: Collider; + object: Object3D; + + /** + * The parent of which this collider needs to base its + * world position on, can be empty + */ + worldParent?: Object3D; +} +export type NgtrColliderStateMap = Map; + +export interface NgtrCollisionTarget { + rigidBody?: RigidBody; + collider: Collider; + rigidBodyObject?: Object3D; + colliderObject?: Object3D; +} + +export interface NgtrCollisionPayload { + /** the object firing the event */ + target: NgtrCollisionTarget; + /** the other object involved in the event */ + other: NgtrCollisionTarget; +} + +export interface NgtrCollisionEnterPayload extends NgtrCollisionPayload { + manifold: TempContactManifold; + flipped: boolean; +} + +export interface NgtrCollisionExitPayload extends NgtrCollisionPayload {} +export interface NgtrIntersectionEnterPayload extends NgtrCollisionPayload {} +export interface NgtrIntersectionExitPayload extends NgtrCollisionPayload {} +export interface NgtrContactForcePayload extends NgtrCollisionPayload { + totalForce: Vector; + totalForceMagnitude: number; + maxForceDirection: Vector; + maxForceMagnitude: number; +} + +export type NgtrCollisionEnterHandler = (payload: NgtrCollisionEnterPayload) => void; +export type NgtrCollisionExitHandler = (payload: NgtrCollisionExitPayload) => void; +export type NgtrIntersectionEnterHandler = (payload: NgtrIntersectionEnterPayload) => void; +export type NgtrIntersectionExitHandler = (payload: NgtrIntersectionExitPayload) => void; +export type NgtrContactForceHandler = (payload: NgtrContactForcePayload) => void; + +export interface NgtrEventMapValue { + onSleep?(): void; + onWake?(): void; + onCollisionEnter?: NgtrCollisionEnterHandler; + onCollisionExit?: NgtrCollisionExitHandler; + onIntersectionEnter?: NgtrIntersectionEnterHandler; + onIntersectionExit?: NgtrIntersectionExitHandler; + onContactForce?: NgtrContactForceHandler; +} +export type NgtrEventMap = Map; + +export type NgtrWorldStepCallback = (world: World) => void; +export type NgtrWorldStepCallbackSet = Set; + +export interface NgtrCollisionSource { + collider: { + object: Collider; + events?: NgtrEventMapValue; + state?: NgtrColliderState; + }; + rigidBody: { + object?: RigidBody; + events?: NgtrEventMapValue; + state?: NgtrRigidBodyState; + }; +} + +export type NgtrColliderShape = + | 'cuboid' + | 'trimesh' + | 'ball' + | 'capsule' + | 'convexHull' + | 'heightfield' + | 'polyline' + | 'roundCuboid' + | 'cylinder' + | 'roundCylinder' + | 'cone' + | 'roundCone' + | 'convexMesh' + | 'roundConvexHull' + | 'roundConvexMesh'; + +export interface NgtrColliderOptions { + /** + * The optional name passed to THREE's Object3D + */ + name?: string; + + /** + * Principal angular inertia of this rigid body + */ + principalAngularInertia?: Vector3Tuple; + + /** + * Restitution controls how elastic (aka. bouncy) a contact is. Le elasticity of a contact is controlled by the restitution coefficient + */ + restitution?: number; + + /** + * What happens when two bodies meet. See https://rapier.rs/docs/user_guides/javascript/colliders#friction. + */ + restitutionCombineRule?: CoefficientCombineRule; + + /** + * Friction is a force that opposes the relative tangential motion between two rigid-bodies with colliders in contact. + * A friction coefficient of 0 implies no friction at all (completely sliding contact) and a coefficient + * greater or equal to 1 implies a very strong friction. Values greater than 1 are allowed. + */ + friction?: number; + + /** + * What happens when two bodies meet. See https://rapier.rs/docs/user_guides/javascript/colliders#friction. + */ + frictionCombineRule?: CoefficientCombineRule; + + /** + * The bit mask configuring the groups and mask for collision handling. + */ + collisionGroups?: InteractionGroups; + + /** + * The bit mask configuring the groups and mask for solver handling. + */ + solverGroups?: InteractionGroups; + + /** + * The collision types active for this collider. + * + * Use `ActiveCollisionTypes` to specify which collision types should be active for this collider. + * + * @see https://rapier.rs/javascript3d/classes/Collider.html#setActiveCollisionTypes + * @see https://rapier.rs/javascript3d/enums/ActiveCollisionTypes.html + */ + activeCollisionTypes?: ActiveCollisionTypes; + + /** + * Sets the uniform density of this collider. + * If this is set, other mass-properties like the angular inertia tensor are computed + * automatically from the collider's shape. + * Cannot be used at the same time as the mass or massProperties values. + * More info https://rapier.rs/docs/user_guides/javascript/colliders#mass-properties + */ + density?: number; + + /** + * The mass of this collider. + * Generally, it's not recommended to adjust the mass properties as it could lead to + * unexpected behaviors. + * Cannot be used at the same time as the density or massProperties values. + * More info https://rapier.rs/docs/user_guides/javascript/colliders#mass-properties + */ + mass?: number; + + /** + * The mass properties of this rigid body. + * Cannot be used at the same time as the density or mass values. + */ + massProperties?: { + mass: number; + centerOfMass: Vector; + principalAngularInertia: Vector; + angularInertiaLocalFrame: Rotation; + }; + + /** + * The contact skin of the collider. + * + * The contact skin acts as if the collider was enlarged with a skin of width contactSkin around it, keeping objects further apart when colliding. + * + * A non-zero contact skin can increase performance, and in some cases, stability. + * However it creates a small gap between colliding object (equal to the sum of their skin). + * If the skin is sufficiently small, this might not be visually significant or can be hidden by the rendering assets. + * + * @defaultValue 0 + */ + contactSkin: number; + + /** + * Sets whether or not this collider is a sensor. + */ + sensor?: boolean; +} + +export type NgtrRigidBodyType = 'fixed' | 'dynamic' | 'kinematicPosition' | 'kinematicVelocity'; + +export interface NgtrRigidBodyOptions extends NgtrColliderOptions { + /** + * Whether or not this body can sleep. + * @defaultValue true + */ + canSleep: boolean; + + /** The linear damping coefficient of this rigid-body.*/ + linearDamping?: number; + + /** The angular damping coefficient of this rigid-body.*/ + angularDamping?: number; + + /** + * The initial linear velocity of this body. + * @defaultValue [0,0,0] + */ + linearVelocity: Vector3Tuple; + + /** + * The initial angular velocity of this body. + * @defaultValue [0,0,0] + */ + angularVelocity: Vector3Tuple; + + /** + * The scaling factor applied to the gravity affecting the rigid-body. + * @defaultValue 1.0 + */ + gravityScale: number; + + /** + * The dominance group of this RigidBody. If a rigid body has a higher domiance group, + * on collision it will be immune to forces originating from the other bodies. + * https://rapier.rs/docs/user_guides/javascript/rigid_bodies#dominance + * Default: 0 + */ + dominanceGroup: number; + + /** + * Whether or not Continous Collision Detection is enabled for this rigid-body. + * https://rapier.rs/docs/user_guides/javascript/rigid_bodies#continuous-collision-detection + * @defaultValue false + */ + ccd: boolean; + + /** + * The maximum prediction distance Soft Continuous Collision-Detection. + * + * When set to 0, soft-CCD is disabled. + * + * Soft-CCD helps prevent tunneling especially of slow-but-thin to moderately fast objects. + * The soft CCD prediction distance indicates how far in the object’s path the CCD algorithm is allowed to inspect. + * Large values can impact performance badly by increasing the work needed from the broad-phase. + * + * It is a generally cheaper variant of regular CCD since it relies on predictive constraints instead of shape-cast and substeps. + * + * @defaultValue 0 + */ + softCcdPrediction: number; + + /** + * Initial position of the RigidBody + */ + position?: NgtObject3D['position']; + + /** + * Initial rotation of the RigidBody + */ + rotation?: NgtObject3D['rotation']; + + /** + * Automatically generate colliders based on meshes inside this + * rigid body. + * + * You can change the default setting globally by setting the colliders + * prop on the component. + * + * Setting this to false will disable automatic colliders. + */ + colliders?: NgtrRigidBodyAutoCollider | false; + + /** + * Set the friction of auto-generated colliders. + * This does not affect any non-automatic child collider-components. + */ + friction?: number; + + /** + * Set the restitution (bounciness) of auto-generated colliders. + * This does not affect any non-automatic child collider-components. + */ + restitution?: number; + + /** + * Sets the number of additional solver iterations that will be run for this + * rigid-body and everything that interacts with it directly or indirectly + * through contacts or joints. + * + * Compared to increasing the global `World.numSolverIteration`, setting this + * value lets you increase accuracy on only a subset of the scene, resulting in reduced + * performance loss. + */ + additionalSolverIterations?: number; + + /** + * The default collision groups bitmask for all colliders in this rigid body. + * Can be customized per-collider. + */ + collisionGroups?: InteractionGroups; + + /** + * The default solver groups bitmask for all colliders in this rigid body. + * Can be customized per-collider. + */ + solverGroups?: InteractionGroups; + + /** + * The default active collision types for all colliders in this rigid body. + * Can be customized per-collider. + * + * Use `ActiveCollisionTypes` to specify which collision types should be active for this collider. + * + * @see https://rapier.rs/javascript3d/classes/Collider.html#setActiveCollisionTypes + * @see https://rapier.rs/javascript3d/enums/ActiveCollisionTypes.html + */ + activeCollisionTypes?: ActiveCollisionTypes; + + /** + * Locks all rotations that would have resulted from forces on the created rigid-body. + */ + lockRotations?: boolean; + + /** + * Locks all translations that would have resulted from forces on the created rigid-body. + */ + lockTranslations?: boolean; + + /** + * Allow rotation of this rigid-body only along specific axes. + */ + enabledRotations?: [x: boolean, y: boolean, z: boolean]; + + /** + * Allow translation of this rigid-body only along specific axes. + */ + enabledTranslations?: [x: boolean, y: boolean, z: boolean]; + + /** + * Passed down to the object3d representing this collider. + */ + userData?: NgtObject3D['userData']; + + /** + * Include invisible objects on the collider creation estimation. + */ + includeInvisible?: boolean; + + /** + * Transform the RigidBodyState + * @internal Do not use. Used internally by the InstancedRigidBodies to alter the RigidBody State + */ + transformState?: (state: NgtrRigidBodyState) => NgtrRigidBodyState; +} + +export type NgtrCuboidArgs = [halfWidth: number, halfHeight: number, halfDepth: number]; +export type NgtrBallArgs = [radius: number]; +export type NgtrCapsuleArgs = [halfHeight: number, radius: number]; +export type NgtrConvexHullArgs = [vertices: ArrayLike]; +export type NgtrHeightfieldArgs = [ + width: number, + height: number, + heights: number[], + scale: { x: number; y: number; z: number }, +]; +export type NgtrTrimeshArgs = [vertices: ArrayLike, indices: ArrayLike]; +export type NgtrPolylineArgs = [vertices: Float32Array, indices: Uint32Array]; +export type NgtrRoundCuboidArgs = [halfWidth: number, halfHeight: number, halfDepth: number, borderRadius: number]; +export type NgtrCylinderArgs = [halfHeight: number, radius: number]; +export type NgtrRoundCylinderArgs = [halfHeight: number, radius: number, borderRadius: number]; +export type NgtrConeArgs = [halfHeight: number, radius: number]; +export type NgtrRoundConeArgs = [halfHeight: number, radius: number, borderRadius: number]; +export type NgtrConvexMeshArgs = [vertices: ArrayLike, indices: ArrayLike]; +export type NgtrRoundConvexHullArgs = [vertices: ArrayLike, indices: ArrayLike, borderRadius: number]; +export type NgtrRoundConvexMeshArgs = [vertices: ArrayLike, indices: ArrayLike, borderRadius: number]; + +// Joints +export interface NgtrSphericalJointParams { + body1Anchor: NgtVector3; + body2Anchor: NgtVector3; +} + +export interface NgtrFixedJointParams { + body1Anchor: NgtVector3; + body1LocalFrame: NgtQuaternion; + body2Anchor: NgtVector3; + body2LocalFrame: NgtQuaternion; +} + +export interface NgtrPrismaticJointParams { + body1Anchor: NgtVector3; + body2Anchor: NgtVector3; + axis: NgtVector3; + limits?: [min: number, max: number]; +} + +export interface NgtrRevoluteJointParams { + body1Anchor: NgtVector3; + body2Anchor: NgtVector3; + axis: NgtVector3; + limits?: [min: number, max: number]; +} + +export interface NgtrRopeJointParams { + body1Anchor: NgtVector3; + body2Anchor: NgtVector3; + length: number; +} + +export interface NgtrSpringJointParams { + body1Anchor: NgtVector3; + body2Anchor: NgtVector3; + restLength: number; + stiffness: number; + damping: number; +} diff --git a/libs/rapier/src/lib/utils.ts b/libs/rapier/src/lib/utils.ts new file mode 100644 index 00000000..82537206 --- /dev/null +++ b/libs/rapier/src/lib/utils.ts @@ -0,0 +1,205 @@ +import { OutputEmitterRef } from '@angular/core'; +import { Quaternion as RapierQuaternion, Vector3 as RapierVector3 } from '@dimforge/rapier3d-compat'; +import { NgtEuler, NgtQuaternion, NgtVector3 } from 'angular-three'; +import { BufferGeometry, Euler, Mesh, Object3D, Vector3 } from 'three'; +import { mergeVertices } from 'three-stdlib'; +import { _matrix4, _position, _quaternion, _rotation, _scale } from './shared'; +import { NgtrColliderOptions, NgtrColliderShape, NgtrRigidBodyAutoCollider, NgtrRigidBodyOptions } from './types'; + +/** + * Creates a proxy that will create a singleton instance of the given class + * when a property is accessed, and not before. + * + * @returns A proxy and a reset function, so that the instance can created again + */ +export const createSingletonProxy = < + SingletonClass extends object, + CreationFn extends () => SingletonClass = () => SingletonClass, +>( + /** + * A function that returns a new instance of the class + */ + createInstance: CreationFn, +): { + proxy: SingletonClass; + reset: () => void; + set: (newInstance: SingletonClass) => void; +} => { + let instance: SingletonClass | undefined; + + const handler: ProxyHandler = { + get(target, prop) { + if (!instance) { + instance = createInstance(); + } + return Reflect.get(instance!, prop); + }, + set(target, prop, value) { + if (!instance) { + instance = createInstance(); + } + return Reflect.set(instance!, prop, value); + }, + }; + + const proxy = new Proxy({} as SingletonClass, handler) as SingletonClass; + + const reset = () => { + instance = undefined; + }; + + const set = (newInstance: SingletonClass) => { + instance = newInstance; + }; + + /** + * Return the proxy and a reset function + */ + return { proxy, reset, set }; +}; + +export function rapierQuaternionToQuaternion({ x, y, z, w }: RapierQuaternion) { + return _quaternion.set(x, y, z, w); +} + +export function vector3ToRapierVector(v: NgtVector3) { + if (Array.isArray(v)) { + return new RapierVector3(v[0], v[1], v[2]); + } + + if (typeof v === 'number') { + return new RapierVector3(v, v, v); + } + const vector = v as Vector3; + return new RapierVector3(vector.x, vector.y, vector.z); +} + +export function quaternionToRapierQuaternion(v: NgtQuaternion) { + if (Array.isArray(v)) { + return new RapierQuaternion(v[0], v[1], v[2], v[3]); + } + return new RapierQuaternion(v.x, v.y, v.z, v.w); +} + +export function getEmitter(emitterRef: OutputEmitterRef | undefined) { + if (!emitterRef || !emitterRef['listeners']) return undefined; + return emitterRef.emit.bind(emitterRef); +} + +export function hasListener(...emitterRefs: (OutputEmitterRef | undefined)[]) { + return emitterRefs.some((emitterRef) => emitterRef && emitterRef['listeners'] && emitterRef['listeners'].length > 0); +} + +function isChildOfMeshCollider(child: Mesh) { + let flag = false; + child.traverseAncestors((a) => { + if (a.userData['ngtRapierType'] === 'MeshCollider') flag = true; + }); + return flag; +} + +const autoColliderMap = { + cuboid: 'cuboid', + ball: 'ball', + hull: 'convexHull', + trimesh: 'trimesh', +}; + +function getColliderArgsFromGeometry( + geometry: BufferGeometry, + colliders: NgtrRigidBodyAutoCollider, +): { args: unknown[]; offset: Vector3 } { + switch (colliders) { + case 'cuboid': { + geometry.computeBoundingBox(); + const { boundingBox } = geometry; + const size = boundingBox!.getSize(new Vector3()); + return { + args: [size.x / 2, size.y / 2, size.z / 2], + offset: boundingBox!.getCenter(new Vector3()), + }; + } + + case 'ball': { + geometry.computeBoundingSphere(); + const { boundingSphere } = geometry; + + const radius = boundingSphere!.radius; + + return { + args: [radius], + offset: boundingSphere!.center, + }; + } + + case 'trimesh': { + const clonedGeometry = geometry.index ? geometry.clone() : mergeVertices(geometry); + + return { + args: [clonedGeometry.attributes['position'].array as Float32Array, clonedGeometry.index?.array as Uint32Array], + offset: new Vector3(), + }; + } + + case 'hull': { + const clonedGeometry = geometry.clone(); + return { + args: [clonedGeometry.attributes['position'].array as Float32Array], + offset: new Vector3(), + }; + } + } + + return { args: [], offset: new Vector3() }; +} + +export function createColliderOptions(object: Object3D, options: NgtrRigidBodyOptions, ignoreMeshColliders = true) { + const childColliderOptions: { + colliderOptions: NgtrColliderOptions; + args: unknown[]; + shape: NgtrColliderShape; + rotation: NgtEuler; + position: NgtVector3; + scale: NgtVector3; + }[] = []; + object.updateWorldMatrix(true, false); + const invertedParentMatrixWorld = object.matrixWorld.clone().invert(); + + const colliderFromChild = (child: Object3D) => { + if ((child as Mesh).isMesh) { + if (ignoreMeshColliders && isChildOfMeshCollider(child as Mesh)) return; + + const worldScale = child.getWorldScale(_scale); + const shape = autoColliderMap[options.colliders || 'cuboid'] as NgtrColliderShape; + child.updateWorldMatrix(true, false); + _matrix4.copy(child.matrixWorld).premultiply(invertedParentMatrixWorld).decompose(_position, _rotation, _scale); + + const rotationEuler = new Euler().setFromQuaternion(_rotation, 'XYZ'); + + const { geometry } = child as Mesh; + const { args, offset } = getColliderArgsFromGeometry(geometry, options.colliders || 'cuboid'); + const { mass, linearDamping, angularDamping, canSleep, ccd, gravityScale, softCcdPrediction, ...rest } = options; + + childColliderOptions.push({ + colliderOptions: rest, + args, + shape, + rotation: [rotationEuler.x, rotationEuler.y, rotationEuler.z], + position: [ + _position.x + offset.x * worldScale.x, + _position.y + offset.y * worldScale.y, + _position.z + offset.z * worldScale.z, + ], + scale: [worldScale.x, worldScale.y, worldScale.z], + }); + } + }; + + if (options.includeInvisible) { + object.traverse(colliderFromChild); + } else { + object.traverseVisible(colliderFromChild); + } + + return childColliderOptions; +} diff --git a/libs/rapier/src/test-setup.ts b/libs/rapier/src/test-setup.ts new file mode 100644 index 00000000..d4e2943f --- /dev/null +++ b/libs/rapier/src/test-setup.ts @@ -0,0 +1,6 @@ +import '@analogjs/vitest-angular/setup-zone'; + +import { getTestBed } from '@angular/core/testing'; +import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; + +getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); diff --git a/libs/rapier/tsconfig.json b/libs/rapier/tsconfig.json new file mode 100644 index 00000000..a28bf590 --- /dev/null +++ b/libs/rapier/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es2022", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json", + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/rapier/tsconfig.lib.json b/libs/rapier/tsconfig.lib.json new file mode 100644 index 00000000..808d25d9 --- /dev/null +++ b/libs/rapier/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": ["node"] + }, + "exclude": ["**/*.spec.ts", "src/test-setup.ts", "**/*.test.ts"], + "include": ["**/*.ts"] +} diff --git a/libs/rapier/tsconfig.lib.prod.json b/libs/rapier/tsconfig.lib.prod.json new file mode 100644 index 00000000..7b29b93f --- /dev/null +++ b/libs/rapier/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/libs/rapier/tsconfig.spec.json b/libs/rapier/tsconfig.spec.json new file mode 100644 index 00000000..c1ea8d93 --- /dev/null +++ b/libs/rapier/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "target": "es2016", + "types": ["node", "vitest/globals"] + }, + "files": ["src/test-setup.ts"], + "include": ["**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] +} diff --git a/libs/rapier/vite.config.mts b/libs/rapier/vite.config.mts new file mode 100644 index 00000000..4580e2c0 --- /dev/null +++ b/libs/rapier/vite.config.mts @@ -0,0 +1,24 @@ +/// + +import angular from '@analogjs/vite-plugin-angular'; + +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +import { defineConfig } from 'vite'; + +// https://vitejs.dev/config/ +export default defineConfig(({ mode }) => { + return { + plugins: [angular(), nxViteTsPaths()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['src/test-setup.ts'], + include: ['**/*.spec.ts'], + reporters: ['default'], + }, + define: { + 'import.meta.vitest': mode !== 'production', + }, + }; +}); diff --git a/libs/soba/tsconfig.lib.json b/libs/soba/tsconfig.lib.json index 147650c1..c33c4c7a 100644 --- a/libs/soba/tsconfig.lib.json +++ b/libs/soba/tsconfig.lib.json @@ -7,6 +7,6 @@ "inlineSources": true, "types": [] }, - "exclude": ["**/*.spec.ts", "test-setup.ts", "**/*.test.ts", "**/*.stories.ts", "**/*.stories.js"], + "exclude": ["**/*.spec.ts", "src/test-setup.ts", "**/*.test.ts", "**/*.stories.ts", "**/*.stories.js"], "include": ["**/*.ts"] } diff --git a/tsconfig.base.json b/tsconfig.base.json index 55922408..69ee88a2 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -21,6 +21,7 @@ "angular-three-cannon/constraint": ["libs/cannon/constraint/src/index.ts"], "angular-three-cannon/debug": ["libs/cannon/debug/src/index.ts"], "angular-three-postprocessing": ["libs/postprocessing/src/index.ts"], + "angular-three-rapier": ["libs/rapier/src/index.ts"], "angular-three-soba": ["libs/soba/src/index.ts"], "angular-three-soba/abstractions": ["libs/soba/abstractions/src/index.ts"], "angular-three-soba/cameras": ["libs/soba/cameras/src/index.ts"],