({ 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"],