From ae49764051e669446867a003d09d70c013d0fadf Mon Sep 17 00:00:00 2001 From: nartc Date: Fri, 6 Sep 2024 21:27:23 -0500 Subject: [PATCH 01/16] feat(rapier): gen rapier --- libs/rapier/.eslintrc.json | 40 +++++++++++++++++++ libs/rapier/README.md | 7 ++++ libs/rapier/jest.config.ts | 22 ++++++++++ libs/rapier/ng-package.json | 7 ++++ libs/rapier/package.json | 9 +++++ libs/rapier/project.json | 36 +++++++++++++++++ libs/rapier/src/index.ts | 1 + .../src/lib/rapier/rapier.component.css | 0 .../src/lib/rapier/rapier.component.html | 1 + .../src/lib/rapier/rapier.component.spec.ts | 21 ++++++++++ .../rapier/src/lib/rapier/rapier.component.ts | 11 +++++ libs/rapier/src/test-setup.ts | 8 ++++ libs/rapier/tsconfig.json | 28 +++++++++++++ libs/rapier/tsconfig.lib.json | 12 ++++++ libs/rapier/tsconfig.lib.prod.json | 9 +++++ libs/rapier/tsconfig.spec.json | 11 +++++ tsconfig.base.json | 1 + 17 files changed, 224 insertions(+) create mode 100644 libs/rapier/.eslintrc.json create mode 100644 libs/rapier/README.md create mode 100644 libs/rapier/jest.config.ts create mode 100644 libs/rapier/ng-package.json create mode 100644 libs/rapier/package.json create mode 100644 libs/rapier/project.json create mode 100644 libs/rapier/src/index.ts create mode 100644 libs/rapier/src/lib/rapier/rapier.component.css create mode 100644 libs/rapier/src/lib/rapier/rapier.component.html create mode 100644 libs/rapier/src/lib/rapier/rapier.component.spec.ts create mode 100644 libs/rapier/src/lib/rapier/rapier.component.ts create mode 100644 libs/rapier/src/test-setup.ts create mode 100644 libs/rapier/tsconfig.json create mode 100644 libs/rapier/tsconfig.lib.json create mode 100644 libs/rapier/tsconfig.lib.prod.json create mode 100644 libs/rapier/tsconfig.spec.json 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/jest.config.ts b/libs/rapier/jest.config.ts new file mode 100644 index 00000000..78742ae7 --- /dev/null +++ b/libs/rapier/jest.config.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +export default { + displayName: 'rapier', + preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../coverage/libs/rapier', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], +}; 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..eb862c65 --- /dev/null +++ b/libs/rapier/package.json @@ -0,0 +1,9 @@ +{ + "name": "angular-three-rapier", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "^18.2.0", + "@angular/core": "^18.2.0" + }, + "sideEffects": false +} diff --git a/libs/rapier/project.json b/libs/rapier/project.json new file mode 100644 index 00000000..95c36fda --- /dev/null +++ b/libs/rapier/project.json @@ -0,0 +1,36 @@ +{ + "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" + }, + "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..f1260f48 --- /dev/null +++ b/libs/rapier/src/index.ts @@ -0,0 +1 @@ +export * from './lib/rapier/rapier.component'; diff --git a/libs/rapier/src/lib/rapier/rapier.component.css b/libs/rapier/src/lib/rapier/rapier.component.css new file mode 100644 index 00000000..e69de29b diff --git a/libs/rapier/src/lib/rapier/rapier.component.html b/libs/rapier/src/lib/rapier/rapier.component.html new file mode 100644 index 00000000..90cbfe85 --- /dev/null +++ b/libs/rapier/src/lib/rapier/rapier.component.html @@ -0,0 +1 @@ +

rapier works!

diff --git a/libs/rapier/src/lib/rapier/rapier.component.spec.ts b/libs/rapier/src/lib/rapier/rapier.component.spec.ts new file mode 100644 index 00000000..1ca35c94 --- /dev/null +++ b/libs/rapier/src/lib/rapier/rapier.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RapierComponent } from './rapier.component'; + +describe('RapierComponent', () => { + let component: RapierComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RapierComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(RapierComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/rapier/src/lib/rapier/rapier.component.ts b/libs/rapier/src/lib/rapier/rapier.component.ts new file mode 100644 index 00000000..e02d9041 --- /dev/null +++ b/libs/rapier/src/lib/rapier/rapier.component.ts @@ -0,0 +1,11 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; + +@Component({ + selector: 'lib-rapier', + standalone: true, + imports: [CommonModule], + templateUrl: './rapier.component.html', + styleUrl: './rapier.component.css', +}) +export class RapierComponent {} diff --git a/libs/rapier/src/test-setup.ts b/libs/rapier/src/test-setup.ts new file mode 100644 index 00000000..b2dd6e93 --- /dev/null +++ b/libs/rapier/src/test-setup.ts @@ -0,0 +1,8 @@ +// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment +globalThis.ngJest = { + testEnvironmentOptions: { + errorOnUnknownElements: true, + errorOnUnknownProperties: true, + }, +}; +import 'jest-preset-angular/setup-jest'; 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..3d5a9aa4 --- /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": [] + }, + "exclude": ["src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", "src/**/*.test.ts"], + "include": ["src/**/*.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..457941b3 --- /dev/null +++ b/libs/rapier/tsconfig.spec.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "target": "es2016", + "types": ["jest", "node"] + }, + "files": ["src/test-setup.ts"], + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.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"], From 448de80a70609a7aef182b357a21915a6307fe27 Mon Sep 17 00:00:00 2001 From: nartc Date: Sat, 7 Sep 2024 23:07:54 -0500 Subject: [PATCH 02/16] feat(rapier): add angular-three-rapier wip wip - clean up effect code - only set events if the output ref is listened wip instanced --- apps/kitchen-sink/src/app/app.component.ts | 1 + apps/kitchen-sink/src/app/app.routes.ts | 5 + .../src/app/rapier/basic/basic.ts | 16 + .../src/app/rapier/basic/experience.ts | 64 ++ .../src/app/rapier/rapier.routes.ts | 15 + apps/kitchen-sink/src/app/rapier/rapier.ts | 38 ++ libs/cannon/tsconfig.lib.json | 2 +- libs/rapier/jest.config.ts | 22 - libs/rapier/package.json | 37 +- libs/rapier/project.json | 12 + libs/rapier/src/index.ts | 8 +- libs/rapier/src/lib/colliders.ts | 325 ++++++++++ libs/rapier/src/lib/debug.ts | 45 ++ libs/rapier/src/lib/frame-stepper.ts | 49 ++ libs/rapier/src/lib/instanced-rigid-bodies.ts | 157 +++++ libs/rapier/src/lib/mesh-collider.ts | 64 ++ libs/rapier/src/lib/physics.ts | 431 +++++++++++++ .../src/lib/rapier/rapier.component.css | 0 .../src/lib/rapier/rapier.component.html | 1 - .../src/lib/rapier/rapier.component.spec.ts | 21 - .../rapier/src/lib/rapier/rapier.component.ts | 11 - libs/rapier/src/lib/rigid-body.ts | 598 ++++++++++++++++++ libs/rapier/src/lib/shared.ts | 10 + libs/rapier/src/lib/types.ts | 557 ++++++++++++++++ libs/rapier/src/lib/utils.ts | 186 ++++++ libs/rapier/src/test-setup.ts | 14 +- libs/rapier/tsconfig.lib.json | 6 +- libs/rapier/tsconfig.spec.json | 5 +- libs/rapier/vite.config.mts | 24 + libs/soba/tsconfig.lib.json | 2 +- pnpm-lock.yaml | 17 +- 31 files changed, 2651 insertions(+), 92 deletions(-) create mode 100644 apps/kitchen-sink/src/app/rapier/basic/basic.ts create mode 100644 apps/kitchen-sink/src/app/rapier/basic/experience.ts create mode 100644 apps/kitchen-sink/src/app/rapier/rapier.routes.ts create mode 100644 apps/kitchen-sink/src/app/rapier/rapier.ts delete mode 100644 libs/rapier/jest.config.ts create mode 100644 libs/rapier/src/lib/colliders.ts create mode 100644 libs/rapier/src/lib/debug.ts create mode 100644 libs/rapier/src/lib/frame-stepper.ts create mode 100644 libs/rapier/src/lib/instanced-rigid-bodies.ts create mode 100644 libs/rapier/src/lib/mesh-collider.ts create mode 100644 libs/rapier/src/lib/physics.ts delete mode 100644 libs/rapier/src/lib/rapier/rapier.component.css delete mode 100644 libs/rapier/src/lib/rapier/rapier.component.html delete mode 100644 libs/rapier/src/lib/rapier/rapier.component.spec.ts delete mode 100644 libs/rapier/src/lib/rapier/rapier.component.ts create mode 100644 libs/rapier/src/lib/rigid-body.ts create mode 100644 libs/rapier/src/lib/shared.ts create mode 100644 libs/rapier/src/lib/types.ts create mode 100644 libs/rapier/src/lib/utils.ts create mode 100644 libs/rapier/vite.config.mts diff --git a/apps/kitchen-sink/src/app/app.component.ts b/apps/kitchen-sink/src/app/app.component.ts index 2d3affde..8eec46f3 100644 --- a/apps/kitchen-sink/src/app/app.component.ts +++ b/apps/kitchen-sink/src/app/app.component.ts @@ -11,6 +11,7 @@ import { filter, map, tap } from 'rxjs'; + `, imports: [RouterOutlet], diff --git a/apps/kitchen-sink/src/app/app.routes.ts b/apps/kitchen-sink/src/app/app.routes.ts index 4cd97d69..8a0a89f5 100644 --- a/apps/kitchen-sink/src/app/app.routes.ts +++ b/apps/kitchen-sink/src/app/app.routes.ts @@ -16,6 +16,11 @@ export const appRoutes: Route[] = [ loadComponent: () => import('./soba/soba'), loadChildren: () => import('./soba/soba.routes'), }, + { + path: 'rapier', + loadComponent: () => import('./rapier/rapier'), + loadChildren: () => import('./rapier/rapier.routes'), + }, { path: '', // redirectTo: 'cannon', diff --git a/apps/kitchen-sink/src/app/rapier/basic/basic.ts b/apps/kitchen-sink/src/app/rapier/basic/basic.ts new file mode 100644 index 00000000..8316d4ae --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/basic/basic.ts @@ -0,0 +1,16 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { NgtCanvas } from 'angular-three'; +import { Experience } from './experience'; + +@Component({ + standalone: true, + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtCanvas], + host: { class: 'basic-rapier' }, +}) +export default class Basic { + protected scene = Experience; +} diff --git a/apps/kitchen-sink/src/app/rapier/basic/experience.ts b/apps/kitchen-sink/src/app/rapier/basic/experience.ts new file mode 100644 index 00000000..c6ec4d0f --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/basic/experience.ts @@ -0,0 +1,64 @@ +import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core'; +import { injectBeforeRender, NgtArgs } from 'angular-three'; +import { NgtrCuboidCollider, NgtrPhysics, NgtrRigidBody } from 'angular-three-rapier'; +import { NgtsPerspectiveCamera } from 'angular-three-soba/cameras'; +import { NgtsOrbitControls } from 'angular-three-soba/controls'; + +@Component({ + standalone: true, + template: ` + + + + + + + + + + + + @if (currentCollider() === 1) { + + } @else if (currentCollider() === 2) { + + } @else if (currentCollider() === 3) { + + } @else { + + } + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'experience-basic-rapier' }, + imports: [NgtrPhysics, NgtrRigidBody, NgtrCuboidCollider, NgtArgs, NgtsOrbitControls, NgtsPerspectiveCamera], +}) +export class Experience { + protected currentCollider = signal(1); + + constructor() { + injectBeforeRender(({ camera }) => { + const currentCollider = this.currentCollider(); + if (currentCollider === 2) { + camera.position.lerp({ x: 10, y: 10, z: 10 }, 0.1); + } else if (currentCollider === 3) { + camera.position.lerp({ x: 15, y: 15, z: 15 }, 0.1); + } else if (currentCollider === 4) { + camera.position.lerp({ x: 20, y: 40, z: 40 }, 0.1); + } + }); + } +} diff --git a/apps/kitchen-sink/src/app/rapier/rapier.routes.ts b/apps/kitchen-sink/src/app/rapier/rapier.routes.ts new file mode 100644 index 00000000..3a539845 --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/rapier.routes.ts @@ -0,0 +1,15 @@ +import { Routes } from '@angular/router'; + +const routes: Routes = [ + { + path: 'basic', + loadComponent: () => import('./basic/basic'), + }, + { + path: '', + redirectTo: 'basic', + pathMatch: 'full', + }, +]; + +export default routes; diff --git a/apps/kitchen-sink/src/app/rapier/rapier.ts b/apps/kitchen-sink/src/app/rapier/rapier.ts new file mode 100644 index 00000000..1eda46e9 --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/rapier.ts @@ -0,0 +1,38 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { extend } from 'angular-three'; +import * as THREE from 'three'; +import routes from './rapier.routes'; + +extend(THREE); + +@Component({ + standalone: true, + template: ` +
+ +
+ +
    + @for (example of examples; track example) { +
  • + +
  • + } +
+ `, + imports: [RouterOutlet, RouterLink, RouterLinkActive], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'rapier' }, +}) +export default class Rapier { + protected examples = routes.filter((route) => !!route.path).map((route) => route.path); +} 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/rapier/jest.config.ts b/libs/rapier/jest.config.ts deleted file mode 100644 index 78742ae7..00000000 --- a/libs/rapier/jest.config.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable */ -export default { - displayName: 'rapier', - preset: '../../jest.preset.js', - setupFilesAfterEnv: ['/src/test-setup.ts'], - coverageDirectory: '../../coverage/libs/rapier', - transform: { - '^.+\\.(ts|mjs|js|html)$': [ - 'jest-preset-angular', - { - tsconfig: '/tsconfig.spec.json', - stringifyContentPathRegex: '\\.(html|svg)$', - }, - ], - }, - transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], - snapshotSerializers: [ - 'jest-preset-angular/build/serializers/no-ng-attributes', - 'jest-preset-angular/build/serializers/ng-snapshot', - 'jest-preset-angular/build/serializers/html-comment', - ], -}; diff --git a/libs/rapier/package.json b/libs/rapier/package.json index eb862c65..3c498336 100644 --- a/libs/rapier/package.json +++ b/libs/rapier/package.json @@ -1,9 +1,38 @@ { "name": "angular-three-rapier", - "version": "0.0.1", + "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.2.0", - "@angular/core": "^18.2.0" + "@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 + "sideEffects": false, + "web-types": [ + "../../node_modules/angular-three/web-types.json" + ] } diff --git a/libs/rapier/project.json b/libs/rapier/project.json index 95c36fda..ead494d4 100644 --- a/libs/rapier/project.json +++ b/libs/rapier/project.json @@ -22,6 +22,18 @@ }, "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}"], diff --git a/libs/rapier/src/index.ts b/libs/rapier/src/index.ts index f1260f48..8dd83eb1 100644 --- a/libs/rapier/src/index.ts +++ b/libs/rapier/src/index.ts @@ -1 +1,7 @@ -export * from './lib/rapier/rapier.component'; +export * from './lib/colliders'; +export * from './lib/instanced-rigid-bodies'; +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..75776e95 --- /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('roundConvexMesh'); + 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('roundConvexMesh'); + 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('roundConvexMesh'); + 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('roundConvexMesh'); + 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('roundConvexMesh'); + 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('roundConvexMesh'); + 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('roundConvexMesh'); + 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('roundConvexMesh'); + 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('roundConvexMesh'); + 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('roundConvexMesh'); + 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('roundConvexMesh'); + 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('roundConvexMesh'); + 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('roundConvexMesh'); + 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..cd2135a2 --- /dev/null +++ b/libs/rapier/src/lib/instanced-rigid-bodies.ts @@ -0,0 +1,157 @@ +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'; + +const defaultOptions: NgtrRigidBodyOptions = rigidBodyDefaultOptions; + +@Component({ + selector: 'ngt-object3D[ngtrInstancedRigidBodies]', + 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>([]); + 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] = [this.instances(), untracked(this.options)]; + return instances.map( + (instance, index) => + ({ + ...options, + ...instance, + key: `${instance.key}-${index}`, + transformState: (state) => { + const instancedMesh = untracked(this.instancedMesh); + + if (!instancedMesh) return 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; + }, + }) as NgtrRigidBodyOptions & { key: string | number; type: NgtrRigidBodyType }, + ); + }); + + 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(); + objectLocalState?.objects(); + + 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/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..26a6b8c8 --- /dev/null +++ b/libs/rapier/src/lib/physics.ts @@ -0,0 +1,431 @@ +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, +}; + +// timeStep = 1 / 60, +// paused = false, +// interpolate = true, +// updatePriority, +// updateLoop = "follow", +// debug = false, +// +// gravity = [0, -9.81, 0], +// allowedLinearError = 0.001, +// predictionDistance = 0.002, +// numSolverIterations = 4, +// numAdditionalFrictionIterations = 4, +// numInternalPgsIterations = 1, +// minIslandSize = 128, +// maxCcdSubsteps = 1, +// erp = 0.8, +// lengthUnit = 1 + +@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/rapier/rapier.component.css b/libs/rapier/src/lib/rapier/rapier.component.css deleted file mode 100644 index e69de29b..00000000 diff --git a/libs/rapier/src/lib/rapier/rapier.component.html b/libs/rapier/src/lib/rapier/rapier.component.html deleted file mode 100644 index 90cbfe85..00000000 --- a/libs/rapier/src/lib/rapier/rapier.component.html +++ /dev/null @@ -1 +0,0 @@ -

rapier works!

diff --git a/libs/rapier/src/lib/rapier/rapier.component.spec.ts b/libs/rapier/src/lib/rapier/rapier.component.spec.ts deleted file mode 100644 index 1ca35c94..00000000 --- a/libs/rapier/src/lib/rapier/rapier.component.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RapierComponent } from './rapier.component'; - -describe('RapierComponent', () => { - let component: RapierComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [RapierComponent], - }).compileComponents(); - - fixture = TestBed.createComponent(RapierComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/libs/rapier/src/lib/rapier/rapier.component.ts b/libs/rapier/src/lib/rapier/rapier.component.ts deleted file mode 100644 index e02d9041..00000000 --- a/libs/rapier/src/lib/rapier/rapier.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; - -@Component({ - selector: 'lib-rapier', - standalone: true, - imports: [CommonModule], - templateUrl: './rapier.component.html', - styleUrl: './rapier.component.css', -}) -export class RapierComponent {} diff --git a/libs/rapier/src/lib/rigid-body.ts b/libs/rapier/src/lib/rigid-body.ts new file mode 100644 index 00000000..e5ad8ef2 --- /dev/null +++ b/libs/rapier/src/lib/rigid-body.ts @@ -0,0 +1,598 @@ +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 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(); + }); + } + + 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 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]', + 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 | '') => { + if (value === '') 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(); + objectLocalState?.objects(); + + 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, false); + body.setRotation(_rotation, false); + + 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..15c6c28f --- /dev/null +++ b/libs/rapier/src/lib/types.ts @@ -0,0 +1,557 @@ +import { + ActiveCollisionTypes, + CoefficientCombineRule, + Collider, + ColliderHandle, + InteractionGroups, + RigidBody, + RigidBodyHandle, + Rotation, + TempContactManifold, + Vector, + World, +} from '@dimforge/rapier3d-compat'; +import { NgtObject3D } 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]; diff --git a/libs/rapier/src/lib/utils.ts b/libs/rapier/src/lib/utils.ts new file mode 100644 index 00000000..e4566da7 --- /dev/null +++ b/libs/rapier/src/lib/utils.ts @@ -0,0 +1,186 @@ +import { OutputEmitterRef } from '@angular/core'; +import { Quaternion } from '@dimforge/rapier3d-compat'; +import { NgtEuler, 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 }: Quaternion) { + return _quaternion.set(x, y, z, 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 index b2dd6e93..d4e2943f 100644 --- a/libs/rapier/src/test-setup.ts +++ b/libs/rapier/src/test-setup.ts @@ -1,8 +1,6 @@ -// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment -globalThis.ngJest = { - testEnvironmentOptions: { - errorOnUnknownElements: true, - errorOnUnknownProperties: true, - }, -}; -import 'jest-preset-angular/setup-jest'; +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.lib.json b/libs/rapier/tsconfig.lib.json index 3d5a9aa4..808d25d9 100644 --- a/libs/rapier/tsconfig.lib.json +++ b/libs/rapier/tsconfig.lib.json @@ -5,8 +5,8 @@ "declaration": true, "declarationMap": true, "inlineSources": true, - "types": [] + "types": ["node"] }, - "exclude": ["src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", "src/**/*.test.ts"], - "include": ["src/**/*.ts"] + "exclude": ["**/*.spec.ts", "src/test-setup.ts", "**/*.test.ts"], + "include": ["**/*.ts"] } diff --git a/libs/rapier/tsconfig.spec.json b/libs/rapier/tsconfig.spec.json index 457941b3..c1ea8d93 100644 --- a/libs/rapier/tsconfig.spec.json +++ b/libs/rapier/tsconfig.spec.json @@ -2,10 +2,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "module": "commonjs", "target": "es2016", - "types": ["jest", "node"] + "types": ["node", "vitest/globals"] }, "files": ["src/test-setup.ts"], - "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.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/pnpm-lock.yaml b/pnpm-lock.yaml index 52675467..b5cb0adc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16682,21 +16682,6 @@ snapshots: - typescript - verdaccio - '@nrwl/js@19.6.4(@babel/traverse@7.25.3)(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12))(@types/node@20.14.12)(nx@19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12)))(typescript@5.4.5)': - dependencies: - '@nx/js': 19.6.4(@babel/traverse@7.25.3)(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12))(@types/node@20.14.12)(nx@19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12)))(typescript@5.4.5) - transitivePeerDependencies: - - '@babel/traverse' - - '@swc-node/register' - - '@swc/core' - - '@swc/wasm' - - '@types/node' - - debug - - nx - - supports-color - - typescript - - verdaccio - '@nrwl/js@19.6.4(@babel/traverse@7.25.3)(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12))(@types/node@20.14.12)(nx@19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12)))(typescript@5.5.4)': dependencies: '@nx/js': 19.6.4(@babel/traverse@7.25.3)(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12))(@types/node@20.14.12)(nx@19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12)))(typescript@5.5.4) @@ -17285,7 +17270,7 @@ snapshots: '@babel/preset-env': 7.25.3(@babel/core@7.25.2) '@babel/preset-typescript': 7.24.7(@babel/core@7.25.2) '@babel/runtime': 7.25.0 - '@nrwl/js': 19.6.4(@babel/traverse@7.25.3)(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12))(@types/node@20.14.12)(nx@19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12)))(typescript@5.4.5) + '@nrwl/js': 19.6.4(@babel/traverse@7.25.3)(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12))(@types/node@20.14.12)(nx@19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12)))(typescript@5.5.4) '@nx/devkit': 19.6.4(nx@19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12))) '@nx/workspace': 19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12)) babel-plugin-const-enum: 1.2.0(@babel/core@7.25.2) From ef688300a77d060956e76eee73f8c70634b2d813 Mon Sep 17 00:00:00 2001 From: nartc Date: Mon, 9 Sep 2024 22:58:21 -0500 Subject: [PATCH 03/16] fix(rapier): instanced --- libs/rapier/src/lib/instanced-rigid-bodies.ts | 85 ++++++++++++------- libs/rapier/src/lib/physics.ts | 18 ---- libs/rapier/src/lib/rigid-body.ts | 27 +++--- 3 files changed, 68 insertions(+), 62 deletions(-) diff --git a/libs/rapier/src/lib/instanced-rigid-bodies.ts b/libs/rapier/src/lib/instanced-rigid-bodies.ts index cd2135a2..5310f95a 100644 --- a/libs/rapier/src/lib/instanced-rigid-bodies.ts +++ b/libs/rapier/src/lib/instanced-rigid-bodies.ts @@ -19,10 +19,22 @@ import { NgtrAnyCollider, NgtrRigidBody, rigidBodyDefaultOptions } from './rigid 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: ` @@ -30,7 +42,16 @@ const defaultOptions: NgtrRigidBodyOptions = rigidBodyDefaultOptions; @for (instance of instancesOptions(); track instance.key) { - + @for (childColliderOption of childColliderOptions(); track $index) { @@ -59,12 +80,18 @@ const defaultOptions: NgtrRigidBodyOptions = rigidBodyDefaultOptions; 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>([]); + 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'); @@ -84,7 +111,6 @@ export class NgtrInstancedRigidBodies { // track object's children localState.objects(); - const firstChild = instanceWrapper.children[0]; if (!firstChild || !(firstChild as InstancedMesh).isInstancedMesh) return null; @@ -92,32 +118,32 @@ export class NgtrInstancedRigidBodies { }); protected instancesOptions = computed(() => { - const [instances, options] = [this.instances(), untracked(this.options)]; + const [instances, options, instancedMesh] = [this.instances(), untracked(this.options), this.instancedMesh()]; + if (!instancedMesh) return []; return instances.map( (instance, index) => ({ - ...options, ...instance, - key: `${instance.key}-${index}`, - transformState: (state) => { - const instancedMesh = untracked(this.instancedMesh); - - if (!instancedMesh) return 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; + 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; + }, }, - }) as NgtrRigidBodyOptions & { key: string | number; type: NgtrRigidBodyType }, + key: `${instance.key}-${index}` + `${instancedMesh?.uuid || ''}`, + }) as Omit & { options: Partial }, ); }); @@ -137,7 +163,6 @@ export class NgtrInstancedRigidBodies { const objectLocalState = getLocalState(this.objectRef.nativeElement); // track object's children objectLocalState?.nonObjects(); - objectLocalState?.objects(); return createColliderOptions(this.objectRef.nativeElement, options); }); diff --git a/libs/rapier/src/lib/physics.ts b/libs/rapier/src/lib/physics.ts index 26a6b8c8..f0f5163e 100644 --- a/libs/rapier/src/lib/physics.ts +++ b/libs/rapier/src/lib/physics.ts @@ -46,24 +46,6 @@ const defaultOptions: NgtrPhysicsOptions = { debug: false, }; -// timeStep = 1 / 60, -// paused = false, -// interpolate = true, -// updatePriority, -// updateLoop = "follow", -// debug = false, -// -// gravity = [0, -9.81, 0], -// allowedLinearError = 0.001, -// predictionDistance = 0.002, -// numSolverIterations = 4, -// numAdditionalFrictionIterations = 4, -// numInternalPgsIterations = 1, -// minIslandSize = 128, -// maxCcdSubsteps = 1, -// erp = 0.8, -// lengthUnit = 1 - @Component({ selector: 'ngtr-physics', standalone: true, diff --git a/libs/rapier/src/lib/rigid-body.ts b/libs/rapier/src/lib/rigid-body.ts index e5ad8ef2..84317de3 100644 --- a/libs/rapier/src/lib/rigid-body.ts +++ b/libs/rapier/src/lib/rigid-body.ts @@ -50,12 +50,12 @@ const colliderDefaultOptions: NgtrColliderOptions = { }, }) 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(); + 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 @@ -364,16 +364,16 @@ export const rigidBodyDefaultOptions: NgtrRigidBodyOptions = { export class NgtrRigidBody { type = input('dynamic', { alias: 'ngtrRigidBody', - transform: (value: NgtrRigidBodyType | '') => { - if (value === '') return 'dynamic' as NgtrRigidBodyType; + 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({}); + 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(); @@ -431,7 +431,6 @@ export class NgtrRigidBody { const objectLocalState = getLocalState(this.objectRef.nativeElement); // track object's children objectLocalState?.nonObjects(); - objectLocalState?.objects(); return createColliderOptions(this.objectRef.nativeElement, options, true); }); From 97b7a9d21c2c377e4aebb77bff6da84b62118280 Mon Sep 17 00:00:00 2001 From: nartc Date: Mon, 9 Sep 2024 22:58:43 -0500 Subject: [PATCH 04/16] fix(core): add NON_ROOT static field for multiple scenes --- libs/core/src/lib/renderer/constants.ts | 1 + libs/core/src/lib/renderer/index.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) 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'; From 653a87e173cec0cb296d7df311f8f46a4cd700f4 Mon Sep 17 00:00:00 2001 From: nartc Date: Mon, 9 Sep 2024 22:58:58 -0500 Subject: [PATCH 05/16] fix(core): add equality fn for objects and nonObjects on localState --- libs/core/src/lib/instance.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/core/src/lib/instance.ts b/libs/core/src/lib/instance.ts index 3bbb2077..0a46fa15 100644 --- a/libs/core/src/lib/instance.ts +++ b/libs/core/src/lib/instance.ts @@ -46,8 +46,8 @@ export function prepare( handlers: {}, instanceStore, parent: instanceStore.select('parent'), - objects: instanceStore.select('objects'), - nonObjects: instanceStore.select('nonObjects'), + objects: instanceStore.select('objects', { equal: (a, b) => a.length === b.length }), + nonObjects: instanceStore.select('nonObjects', { equal: (a, b) => a.length === b.length }), add(object, type) { const current = instance.__ngt__.instanceStore.snapshot[type]; const foundIndex = current.indexOf((node: NgtInstanceNode) => object === node); From a6e19dfe196bddfddebe0fe35d58a43da6f02a6c Mon Sep 17 00:00:00 2001 From: nartc Date: Mon, 9 Sep 2024 22:59:05 -0500 Subject: [PATCH 06/16] docs: add rapier example --- apps/kitchen-sink/public/bendy.glb | Bin 0 -> 20636 bytes apps/kitchen-sink/public/suzanne.glb | Bin 0 -> 14232 bytes apps/kitchen-sink/src/app/app.config.ts | 4 +- .../src/app/rapier/basic/basic.ts | 66 ++++++++- .../src/app/rapier/basic/experience.ts | 64 -------- apps/kitchen-sink/src/app/rapier/constants.ts | 9 ++ .../rapier/instanced-mesh/instanced-mesh.ts | 96 ++++++++++++ .../src/app/rapier/performance/performance.ts | 140 ++++++++++++++++++ .../src/app/rapier/rapier.routes.ts | 4 +- apps/kitchen-sink/src/app/rapier/rapier.ts | 5 +- apps/kitchen-sink/src/app/rapier/suzanne.ts | 12 ++ .../src/app/rapier/wrapper-default.ts | 79 ++++++++++ apps/kitchen-sink/src/app/rapier/wrapper.ts | 45 ++++++ 13 files changed, 446 insertions(+), 78 deletions(-) create mode 100644 apps/kitchen-sink/public/bendy.glb create mode 100644 apps/kitchen-sink/public/suzanne.glb delete mode 100644 apps/kitchen-sink/src/app/rapier/basic/experience.ts create mode 100644 apps/kitchen-sink/src/app/rapier/constants.ts create mode 100644 apps/kitchen-sink/src/app/rapier/instanced-mesh/instanced-mesh.ts create mode 100644 apps/kitchen-sink/src/app/rapier/performance/performance.ts create mode 100644 apps/kitchen-sink/src/app/rapier/suzanne.ts create mode 100644 apps/kitchen-sink/src/app/rapier/wrapper-default.ts create mode 100644 apps/kitchen-sink/src/app/rapier/wrapper.ts diff --git a/apps/kitchen-sink/public/bendy.glb b/apps/kitchen-sink/public/bendy.glb new file mode 100644 index 0000000000000000000000000000000000000000..cbf07d837279ce0df1800bb590886ae9d7cc4661 GIT binary patch literal 20636 zcmeFY1yo$$w(4fJChCmWz zD!>1G-Sc{SdU|@+tX{olPH~Uyv-dvVxA!^suBvr;;ixT#3IG7|)Byk=AON7GrKal7 zWZ~gq*!r8;t#oY<)&Bx8l4QGNfdE2;q*t<9* zHTbw;Oo31)3o9!d4-Xf24<->KeH*uxcLS7U`Q0m z8qUYZFUSw&Lh1{{VS)k(0YPDYegR>A1Qe+UehQ;rs%8 zy!;4cu#s7War5ybgaifQ!n|-^eqLTdxFD1l$=UtiUH`i`Bs-^nTlPPR0{_x;vhlF9 z`47;gZ2atP+@-zTy={;bT;1)R>^<$h|4qlj)6?DF(#!Mj(4R?NO-oT*QB4&YE+~_# zn&xv!6($irD3i9VzOYkNrXQTxrmE3GAM9l=ig%WPxS4bt?iN9{-3^$kEf0E z-!t)#Uu6YNGi!GXD;KlB@XY@1e|Kb@{y8$pQ2nQb{$u)o1_?>|-ytE<{qG_11tN>d zU-r&OVE+yBe^*DY@mJHs3VE*ogR0hF2{Jg{4ZG>8UBwUFE9UJlkfkeOpTY8|BuD_|6MC1q;xdG15g0i zXaE56VTx=}&`{8@Q2~D+ttbFOw8e518&p}~b95@)VZt#2M$B#^OyD}!`1kt-mf9lF@ijVm7DOGAKjx=fyEhlnKpRvAO?|G# z%3@7ol)JJEDZIgvw2vU2SjNW-i(vYgx2s_ks~?~9G=G3| zuhg3S@PHrV8#~aY`PuJHMyqd^$>EtM*AEbCfz+T<%o8C<<(Q4%&AlARluY zV`di?NDScXxxIU2Q=xbJ`h;MI#DA9ZOl6;J6exg&lhV?$z2*f2Jt6XxLmouj(YvNQ0-NO0osPy6f8 z=QR(Sp%5}vCKlmtZpu&5Pv%b{x3^v9*h&4aM?B;KI?b7@zXzjO+|jAy(QQl}qfPa~ z!Y`!ctT^UBDY-mPS^(FD=PH7xcgt0uaDHO>mMFix@B8f;G!vSvtHCZ zcWH%?J0Xlc(d9V((5Gfs?Iqb$IyxLR`TpR7ds?`{2N}W-#LwTg8|R}uK5Z{6-_$O_ zwoRzP@d6BJ=7fZ)jg7R|8L6e5)9^4{b2c5flE^PqGUYC%349lkoX3zQo1OZBZ%0m+bk2&U-J#rd0;dfhg{I|A>Ui*5XL+M%6<#kBt|nBV#isd zl4sXllO^B%?l|X22DAk$(&%o|4E5bKrlOeQhZ`OvL{_XLmfH2*^wiFe`+gddpRpOc zZE%qlmX)*iNW;?{_=#@mXHpxCo&8S2-Klt}{6DaqF<~{oplQ=(6Guwo%ezSo~ z7R<$eh_Jfe&=-p@#GO9*5fJM=0MK)zuG9GK6)=5D;(fON%F~pr87qchEI^6vQ{H05?49zRsJa>pdzrvg$JiQE_bgYdQjKC0upU{at5tBj$T z2fyFb+8IBRU>HS1<5<7WR{K_;q9^0IWQj9Jsl*{0YguG`YoMK>*LtnWgkfkkvX!=B z+meQH3Ove9jmO$;>JsB=aH@POa;#cX6#67EOIW1Yjw%H|dg>veV<)#{AO@J|*~$@- z8)xopq=U9{O;J0ZY#V=dkxC}3%dRquXF|-DBP>$y$v8?9rmsCcJX7+-`c1^^Nm$&r z6iB-YCxm3glbrB7o~rn}NMK@wrhte=)DG41^Fv1pr;($J5(3GVljC7w%a$Jint2quBLK}%2nO};@nIPnTDjRkYryX)#$sDfd2n%=Vg*b5`{ka3%jF42 zBXektPb0qKw$;EJdu95mNjX1uk9`517{cdOIabDHn6GaZK@&4SuM;+SX!O-ff5;0) zJmp@J2CQR&PXI}iShu*rM88BU(M@m3E0c;$e>ua)3Vng5KfVZ*RnQz>Sdw!?1_0aJ zEts4H)d!RY;;d%*f#2MePfNY|Tx_kpd}hPCw^uO2$I<0F`=7YXVm7}uZzG38Vts^U zJR?Dmc%P^fWg{5^(G2->YOesA1)K9i=W8@9C~Qe6oFphE1Sp5tO?bVvp;Lt+3nih0 zv@F6B^5e1s`OKIP_7v11ny8-#QFCNazl8Ny1S^Cy6F1S2PqC7A(8YKG$ARNWZ{QTNwc zSG;5Q<(}AHZ0UMM@Oo|AK$#JuBgi((N7ge6S&GE>#!5jTk(AMbX%RBi^B|a($Tey@ z#h*7dYvezTn%$pJgr1^v>UGktFkWCOuAlElPvS=C%dWnrKx|MMhs`|NPpxt;U-JE)5{Cl~5z-U1JftyWF!sE&N-@QRrN0zPY>Ap; zgE|v*e!EGpt0$#VGB8&rb@rwR^Z^pH6jxx2!|a61@B$~re=bsQ(zHTp8#-yq7TY+h z+q5@EO5eNonH;>FkpD+&TO_jSgZ}#?YG`Tl&no{06|X3#;wUwR-})A;RW&mHl<;rj zLbSe}sXH#0@nsvkqF7~+5LIiXmtK1M7EIvSIOF2kl54_8AfW>kPcd z4D8cHG<{!M0oE=l-h0FHJo&~^T{o<2n78e0Cg$`T?C)vV-%~MX--MdQ8@U|-eU-lA z%s^4pn?jyLP%1egBx$=Lv@0Rtolxqz5Ig}1!g^VI-Oth)7xKNjwr)CZIL1!+y4;FS z4>jpID3!VDp_rMzOxjwWl>cDo$W&5f6yg z6dw?yF$krWEM6G9BAvXq7A{PHKSBIj+RT~A@BA-kqFG2#R(qGi-=>J`lU0mC@TI-5k<1wHv-c|(&LJcjuJIT|SxPwCW<*5P2%|8}hTb zL0b6mx_Vw69;WiejID-Gr$g~He^!G(TJ$dRP5@xO33!RwsnEBSYHmms64i@w9}KK# zN@7FXoJM(>3e17A2_`aROXArMSk7~Aj7AMOUP zqTTcK8wSSkhBl+hu9{Mx&s6iK9BDN!<0rN*zGpT=w;^@TUzxow z$+&&5ZBM7C0u;ob+c^Ekme?T%DPw~)0q<~F22rK{pcWjX##RDwD$T7?(n#lR;Tl=6 zcu?e)A6SCbTsypcj4y+#gL9SV$c4W69pMYe5*Yk8P8 zGpiLdtL9eDSWdnJ7Ly_xo=CdD(^P&cDH){^0s%^Q^^e`V)vu4$W<fedvfKZ)4p+4NX#0ZXQdO71*k3rzzc$#(f~~4NlzsBpysWF zo9~=A5u8Phr~`g@pTkx?_!;I&jcrIbAn8>uqbG7Ne7_^PhyLZRgXEq~@AK}V+Nh=^ z>LM3!SzUfIMd|V+inPMyiPZ~@fI85u+*F=iGh7Dc)zTeN*j6OLG={&m(yU#Tr2KOt zIEDM>X!va8KAH$Q+9sMfN~9wuua7uF<6THN@77pM!K+v<3(5E~+>$XReC8;;T@@)A zT)7#1yBiXhdosNpLXM7$d^xL1xgYPxe)cLI!=mjC#2?h>oI8C9HR|wnn{Z{zaf%>K zGzMoZr3Ro{6IwrxA&4$WoVc>DJZ#*+TmYYG55OmzxJG<=0+SJ9x@7c-R*Pjp%MG6E zV<{TCV79O{=WnG-1C}GU!5z^?3Uo~JQ0pgloAZ*NpsfWF#4YCzj$?F;3-LQE7GzIi z3r4@-b0cUSzlLc)ktTDlKOM897;&KdxJ4p63}P!Vh_<7MFQAz{W&Qe_ zqj88q-Gv#NFsvHC@11rUn8q%G3sZcjNp1@^`#aDB_>o#)4 zK4ygJG6MLt0dFEvMXAwa(V1IOgcUHbEa4LPQD`ExC<^FoEdUQbwD{d?%!Wu-?HlDa zC7#x1NC{)|5`7z}ME{$Ry>wfrik0u{yrs2|zUR>vwcJ20oBp2T{*Q+>S8!4$uJCW= zMVwr1-cpol915}OBO{S;7R9b|Wf2?Y<{kOx6(xiGoF!@Bx34D$Y$|KR>?OTIq$02# z?GEvub~CaIl(j0@OL*Zn1ct~rA0@FH`>NB(DY_?*zj~H?sLrhUUVq77Kf8*J;72)) zZb{mLqz6QplbtqzLAe?%oJ3pM!gsnvIT9jx9#4#|TFFE{?TBcp9vyP0%c9rr@wpi& z$NnT?bQ#7R@zDV>hcEDc8$%$i+inZ+9Jl6ozo7Wi;%GN%_0|GYTCa5$mDnVs&M31+ z3Z>XN@Ap{m>a7+PDOTP6cOe;7yEPYgEZ>Yx;&(!cbaSK{HObdnX*ccWtu11|1urtK zwX@q1S4H#MDjR}|z0}|0_w?cR4dHnDZ}-X%@0Rot=cMBo<>KE3gcigRvdlXSvsDbT zS4z{rt#U%ci*nrQlm5UNc2Pps%G0LawLe7E4UmokOWrJ6%5XYZxhH>4j4=V@UnY*qO6ex07~e_P7>U-i**P<($f^uPeTUrt1D`X#Xf$C2C1v0m|0(Vg zH(!lxqfksqCUNsn#;ddMOmS!X)I`@b^2hHF%b)qBNComuJ^fG8=Ws)Oh4j)&hp>@Ykta8m*Adi?pTA|XJpD{pr8G70 z3{IJYFV68&az2>0EE%SC`%K(xLW=C!Q?K64Bwi~FkXaG6K-}+HV@E+!%BQWML~Fdr z8{Q_&G6ER~eWzJ{#TVa#7H2dRpC)4rC)`NM%*a~dKJ}ib^7Y@pXAm-T=#ZHIrT!4W zfq^kKSwi+nt2+LB@IA|)Ajf?ohxbj>5n)6*nGQpZZwif?rKMG6hToanGj<6Ef5$w# zlfb8>CmL9hMAv(_r6dn*3dJL{Bj|nspy4s!N8j=)dR;VfA(^9;)0#5uyrazqL!!qX|#Z1;bgHEtttMGI~RIv6i#ZQuXK&_*W7;X&AZZ zOTmzl0n{Hsh{XVltz&$Ab~Ak#m|9?w z1!1019o8$BFS0-8V+}qv_5c~{)alh-0h$yti{ViS*HQ4tMVvxbCNcrf$Y@(KQz)q& z>`(`nRc<^3<1$QAzC7m(Qv4}nzVh~MeVK6#?0Ba?z&jg%=3$T#X4%Ll59K%uI z3?4@WDwslxk$*+f+Tf-zpiut^nP~L7DP%7ZyqB1?ZkiMslcdgT zTkAH;lQPP~hwPkpmXb#-UiEhY3k`eQho9{8nr$Sk&uaq?3TP*+JoY|q^Y|ti3 zfCRhDTW}7R6zSZWC$R}O69=&>D;(8VQJo$AY_})?Xcrn+m96zSHW~`jBf8YGE-Csl zpl@LJ6#r!ZyjB8txoVb2qy{>g@l7EmJ{hMDI77XtExqahkw>SNOtGi4HujBgn$`G5 z+ybHX>}=el*tJBk4+OF~bu+?CXYop=`bwYq%OecOCogv(1sNli?X&gEmafqU{%^;^9@ysgmca(0*(A z10vYzu*d8J3t{wMV6xO+xA|#4|ZQG z2Zdl6r3d{;7T7mQO?7ZeB6(29k?nQbW>~WLqz@P~noKYaiw>V&N#PsVJXpw3Uj53l z+8aI?yUVjn0skQ(Kv`QU)v6+~n>D{;mL;cZYX3?$pK#Gi^`laPvrea1@$3}~*`V+2 zl7bCO1TI-(N}W;(X|_6x$n(Iqox)S}8+V~5+EUc1ts1wRItPvQZ~Ce*_4U3wEWyRw9d@}@|r@W3ItW{IHg4C($ESFN9yDyqoeOb0q_BG;gVb4TdLPt+~4A?yK za&6uTGrRsd5FR>9G=%%w_y`Jj%$HHk`uo=K8wlk-ZF@L)9eTMFhQqC6a#cpIp}X_m z*;o^Mlsb?vQ+t@g`3Z_4DXZ4)JPY>3Gxb`KY$Kgo4W-m)x$KXkbDTLc(#BDy;b#ir zp9?p!QL9W*%AG<5&QLmcLuz50cp)A$=JOQ>DA-?Um9r|A&KGu4U?pL#K!p=v%xnU0 zq(d~ev?7y0yQQt%wBN|`P~Okw^2;w}GhNxQ0`f!>xJuu2K}m(7sgQ#Bmr?|d1P@UM zI_F;$8hz}~PjOF8U5OOhP$WiBWG7KNU#p9#QVZ@AGWTK24xSv}zW>&V%WiLk!GTkz zkUBd=$h-E2h?xusT&4tfk2C_#(FRr!@r+a?vA5^sa#4*i&Y!Xbdg!6u2i;Njmj(J^ zmjl!(lo@5rd?Tm_4#DFUuBn=6Imw``5=OY}P%E<}Hm1St=9^}7)n>2*y)~0WJF1Xs zQ7%iPKRl@bpFx;vHe5-v4z-8=q%|o;l5+`rmkh86yu))OK>-UXu}H=6#_Mka=#k&{ zW!h+3ZPi)L!M1>I93SOI)?jA+UJQ&GZT({!k4Rht52grQg9!H;*L^C_2!fSn)W8J0 zu*F1z0>`JP5aZ;i?Kqy3WU^a}VJz+XsQhs5TNO_fZ>NFZQnv(jz@bP$00C;f23AqM zRNppHNG}>l3FE69j&-@_W?ec8c?1#sj$PoB-KABzNNLmtP{Ev(cbAS_Aa!_pvF7<# zv4z55bs6oWw3GC!`LRg_zJ{ulX>+A^*Lpe`J8s_87-)4loXF5{HBlK72?!NIh2ZmU z;>Lv-Y2n>|BT52GZcg`&Nn!tNHw2G_C=>|JI+g@W2{kPf*p1PF<9tzFYA_Kkh(02G z*EUoGH~b;K5*^n}l#j2zm)ztL)K1J(effcx`73el<_)#Wn7WWyxf7aUQd>6vI_aa! z(r>H|sBwL(jau6bCHPc{eLbr^4_5s?9G9luTy`n`{0(mQh>nPcCYolby5jU`+KGP? z9q8oQQb*F&Kmz7jR_BVo4a-POCbtM(&<5gmRs=INm+)->ex5iLb)k{?`k>b&>7{?%#ldFBrP#G*N#&w#fmct5zlU)!$G(_eO8me9}*B(`rY}0Ej zwG{UX9ni)|LI^Px6%CbCCY6jsKX^W9#mcbbVavKXIK-ZG=X%{oEXS)Gtxn>RRI>Xq=OIQFOE~^i+f0+KRe#&6 z^5ziZ`I}>548}ZH=zNZZ#@9`>eN0Amp3S7Cq7Vhz(jmp>C^{iYyqKC|IhlwA=_%XY zG*`@GN6Aed>}SJ7YLSi?1%q?fb`wm#i%gTa^L+z&cUT{=;LMw2u+(5aTB;|yj&|?j z@kb1^jkhraf|JqrFlf&E)Zj%DYnH?m%-`Q^3R%3zx@KC7fl-F=L8)kTo$RVDv@I~c z-n$-vlen-%+4>7Xn`1>>Up)}->7Ddh5)H%ax7Tkgw1cxqA>%Xwn|ENK0|qS_B^)ot zDh;9dvvG$lno&bwF?#79d^>qf8%8pXgQpaclI2gvDjxIko2S&Mg=gjO6uzypT?X-C zGeSl?{R_q1-v>5juDc0+jU|$0y7(+HnroYijg~f~WRTO@&O6{AlMv4she=`m_PO)6w7 z@KP!gw#$#33s`>YF8JoDl>tXR+A4a<{rK^QZfEvLG~FC`eGns=j^=J}|Kv3`$A*4^rNNj2UN0NFa zioo<=zO~ffzfL$B8q+M*n`$o# zZktz0v1|Hp$mm~HY$^gqDTxj3Bn}rR0}3XieGMNC8B7C8nhb7Xyg9>6+Q4|@jbb@U zOl%p2?;1^L9)&NoO?(v9Voro+yB2W-#^c$6YKBq6P`8G0eU;PtDa}TWO0f7%wG}bm zV~F^#16#0pU#$aICD@ifl%E-aUzxMItZov$twC-f7X^zi&Mwk2C)?izN{EK(qQ3gB zb6qqBn}Awrl@4PRWW`msv2gDpF~o6(O3Jg%Rebz&y?eSxLyteJ-`=vUgn`X1+{-&{Bu3yl$8*UEaP{vW&?s#`&RyG@NS2`Q zKLurmxC=~~NqARz5AwgPlMwdb{Jv?oc+9Y*sao@5USn%L@gg-w%XWRt=yeHM3CS#d zdJcApVVd+&#Nag_F)70t0Ps&UkyoLRl9&!9b*MzR=!P3wglQL}Dk9R*n)JcRc}2YW zeHwn|$+QsdVs&^l;$WiV1&Sow7FZe76+a#ybCLSyE+cU#IpN%DD&L!WZk1B_VesZUIN3Vi?BTI3PDcgfKpO8&+5`@o)FrcA2$3nY0-dnJ74|h&38Z zC^~>61W*JBE(J?a0_ls;LiU3t%q99P0qPz8ZnPL3>aJ`gYf}Bfc+Lt$Pf+J%K+RY( zi@O;zn@zJZ7@ov)^wgu=QgIG{ zCmE;ujl647NDaiesy~%cDxX)nphc3BeM<(fu^Dg#3votBzV$79k}Fx4OKIj2`K;JL z^X)6A1}~>#9krJo{6k-p9PKg#Uez#dS}EeS>>H7-kiNwYo{Vh%M2ue-ZTZ7HW^(n& zS3Y4)%|8}2<<;&ZTQRB?4$=q}f+B+CHU;(i==ILI1fy-5n3<$BiVXHvC{WU=QPQW4 zSM3dL5y>3_AA4CoeqizJh z@oSg<)Uqyz#Y`twSu}&MX-5grmOxAgWcB+*S*= zSOzL1gV07m5eQn4C>RQcKv-a4Fc}LLL;@zt0%HL~zz~=O0)~LVATU~5usRtS!UBfV zvVcHf2pI?hl7K>y0w@$p21BZYkme957z6`>K~S&^m=@^^322*g4IqW!&)U0f_&WQzvG1u`HnT;v>(6o?5# zN(ldv3IQpRpN~jQLLkrx_=qh0Kz`s8pb+r!6|zA}|0**hYb<0-hDL-2!~*AcpxMIEHH=+3?%|f(2648{6g^(eG15p68K%pQoEd-39g@Hj3Ffx`<1Q?7*3q{5iDJO!0;AF@t$Y1I(WXpnp zz>!IS!x3OI2vp`DBM=kbFg zJ(N_fDdVJijL{bcc_?SX)-fUXYB3!ojujx$1@V06*XG%;%)I8Ma!=;`yx43*LtR-O z^6Ade#YN8LPf4d&Bda(f)fF5YKadHB9Y|yA#SMiN5QU`x9<05BH13fp=p5foeakpCGzx zyvSpT?3o7inTZPHQ9u9(y63v8qZw7d&klE^XGo`s_Dp(zx?V5$MD5%qPArF0ynJeR z{!=l$98^oR<FI!6VU?X1b5ulY;3b~tFI-Or~*{@vZ!4zM)b0iAjf7l zD^udF;g)sT$&K~nD@u<(6i z_;-@u9V1J$e_X?Q{Fc3M|IGbHv{9uKskk;=yL{_$DP201ib*Be{K08-V^n*^BEVD* z%M)PRw=)Fa{yUKF6VZmN*RKAVPnFFTqhV2@(H@;b`5JFjQ5g{Y<@*HvL#pqc7ITPy zTE%MIx>G^yonxfdtY@Gh!9rH=kVaOcL%L@%;CO4wWHSKbOCrDgQ7<`q402>Wh z@%udk;z@Nq_#pp1qxfX_9m;!lPfcbX+}b(=^8s%F0pIM5TwzjwK`>93}`iQ_2; zV>vioTNnIQ`X4pdzdeKqVv8F6(NmqB2frO~rxgbetOOQHNOnECbxBZKqJ0Zw)CbUCjjd>03;9W1V?MIlmA)^_ zDKTD6X6AJJc8|lApY{l>uSLu< z+A_|?pTqN8Fmap; zNpMlXNmkhuQvCVCnuzFV(`i4-MyxXGWWh~q)yZjyd*Vr7%-v4@QdwNvd65_vdw|Pl zh1$W7QkoCkAevy0J-MdAAypbu3lI+rqqEDLZtudNhb4%Ilit;3NO!OcI-+0+-~llP zcHln|L8CarmaWjaW+^$1cjsuATwxYJQQ(qmNuU&V> zsdhyEX{C8yub;C|^>(R2;q$ZBXV;^ogVJGHtt7(IwcKjZ#stC)X>>uYftHuGKg|Qe zHatVGw&FOX1QVsWKd1N3k26rL=+9EUfC{9X(T8pRRsg z@yc@>({-gxefqwI|0`Z6KDD|H7az9Bz1jApYiLX90XP(_##YxSy=}DBL}>JcJR-WRp4NeWieVbz@1D;? zjloSpFXh7|LZ|Qa*3837QB|HmlkeWEs=jRZ7(ejiRLi~VmGd081rKl~B)DOvYgA7q z^xFQo52G4;?2Rn@=$9qTT(GFB*?tw=R4g&>1coq0F2U9LaEW8%+*X#cRe$XW&!6M7 zDyKGln3{(krCQ@!_jzqG<$Vn|%8W5rzFx3dNp)Dt&u8mp#M4)#HT9wjf&_(amZ62d zT+}qha9pYkU^f%b*dT>aKG3ar)Sh=9kHj)l!JJ^Chb_GbTJE4 zd_0^v)jka2-9&pyz|P@X0k7_1H8sFlqv+gC)u}a>NUg|jEAA{#RSb@xtrkz9j}xBr znP=|w5DnJ7rfkyA3PG#S*CVHW_XzL4p|gWrWWDkcT~O1PiA z(uumu&zF4@gF=s9CN#wuWMCmO^3bTpnWXxyZ8G5gMK*a53~z#dB92*cOZ>y|SfTw# z4@3UyH;-)mqG+#iu@wUp^6eN>Yz|ZB-&$rrPZ>6Q*(QJ6#2%#G>==dhe3|n3KKgRFS zyKw4mHUk&YYCl=7vjUqX)%#e6SS+c0zH$4+DT*bOzPjPnodUIi9_kM`P%zygwd%9r zz2IxvWkej`Nz2|v*+o{SDZhd9?cx3%o>=nh(06Zd z+WV~zILk8nsZbin0SB1sFV-b4Q zDDLe|<5_)%K}JWLds;)Y6_}X3u(h=KbGBP`V|YM%Klc|7zON=3&Edk;d|!25eQLYV zqG_^LaBE{!XnVq_rWh#w4qp$2LB8ns$0Fu#a+62rR?MqyJB7xwPi11|ap-ik)w3_b za=TVxVLb~o6TK%8OD>TF%(ai4-@G?Q|5(u;#oa=x-LUA0U@^iHl#04vXw(TGDIT(g zi72%{!B8F8vTdbAqAkKCgvKJX(q9U|6der0xK(;gDd~9I2Te`KEybHGY(nl}og>Dk zZM^l`V;ep(CZ0cTU&WSTDYOK*B%AYIU;6u@@UdoFbw$jKDFT!eGQH50Ud{#06q>;4 z;&R(M{+RwDbLGX)1^JHT^j;)gJPBalYf{m^7|cDcY!l5(ALB0o;;)6HNB$6Pjae3Z zWEH>t>BG-OlzjcMOn!*_{HKE`3SG?iJSm$b+cxgJBk@1}xHAnQ400{PTZ!gtuP)az zZkj#q@j+qE()k&ydTj)ne!1lRx3ApGec~2Q@m{`u`s52Ok49@}_@Ij63zv;nS1XZH zHqmUUKlhVgZsDvHaSw!AI2a+4AsFys1O8a=;13ZTs3Ynrq2V)tfGDX@*EQm zVvjiDEkEyc&(m)1vMhEVxD!^%rPjB1%u%GDCDi5zT zub)2hF?;B8K5wgi04GDCEffukq{<^)X;P= z(?<(V2>0RyqpEu6h#vdWEKGekYF^x)9o%~987r02Aoxh-(_Q|=ad!{1Ba9)R<(GK% zefjn%pgjiXXScLj>1eke&l&^dbZ|HOrE7s*LDK+Hf>w-|IPs#gVQv1?m-WIEB>L0Z zo##t?GuOJOZ|h%O6vMiPo%ijRL$3-Vn)hm6Wci(NNyP}N&3&1xGw3&*|09qbN2Vcq zSfBhnLvDB1C_zL2Dd_U-XXup1&mNwpIr5BX%n*El9D8}kP!eZEM+Z`{!tH!9V* z+@iF{D3t3ng$>V5w+l3iks#?|WXr=rG^&B?;eps^teU3AXM^i~=q3u*Cya!ZY@MSIc7b%FIJnxpo(=qmDQf#D0RIMC$oyUWDB@1f8HX2{Gt^GJNFA;quU09F*{v%mbk#X+>;!dmj zLl0AW$?NE{FC^!6kt#SWof~R(f0FS%Qn6T8>kk;4x5>~qj=G>|*e#Q~g`d;SW}2ud z*RzMRV5I|p0_&n!CJE>U5i$gx5hZ~%BRziv7Z7kX?(&#<~snjx;+xfn4RO0QhrK$zM04^Wi*&Pnys!lh)hsd_5PFT;|X zU#kun%l@2URK~)Hs9O&NtAJ_LpclFQ#F`38>-MklO=KTlB#Rp_yv5L(IbZHwShb{+ zQW7W{9f7$F3s!K42&Fl^jgMZ+WDq5a}d zatCX|@d(RsFPD_=o*HzCmO z9nsFNNLTi5t);5^t8BULZ3&K{bswpX!^EOy1~U<2&6Cfl8}?;Dt`6bEYEX=jI!+r{ zOyfzU2%n)866!kUmDfmR^ zQ7r?KxT}^tipO#7XnR3iEQr%Q-ipP|PQ(|Y5&G+(kI6k><+zn!t}5T^+dp|N56Hi3 zILp(Tcg|OCH;26DDbk(>Svas>tGIJ1>-sMr@L zoA5@}KM5yP)NuZLjo_C@GS_Jo_6zbNmzwRC%0ZP{z|I+YnE|zb4KO+XBwUcg2KesP zf~}6^ZjxvCo{4^WY4+xI%h@C6E}Lu~&t@s@dcG$nVngnJ_&K(@bh$Mt@09YZnTVD- zBr@jItgm|`1z<-bZ}u!E6b6oD>0%$`SA@ErDdno5pZLE=rmFUsgl?WrSTPf9kj(&3 z*B=^A(v_CZo|o{Hsi?c@q6CbEQ)Hp*%3DTW5qeI2)e(N=J*GSC0lyy)jPHx^W)nOf zZ{v8fi?2SLDK`49yZyEs_%?fh+R=j^k}d3^`i-eX>qK_p$;3@$ZqG!#HqN5&qG5Da zYI^=@db;Q-&k4U{o*&Mw4ld)&kuF}}cfw&C0tFPjl@;s5sm1%7&&&0#3n|N`wewZ1 z3+vVGtNuHQrPH%mRgz<8X+H)lHwHu(3#4Z*GfuloUw$3YVyU0bVgE8!-Tx_UE1_(> z_{o>mrk>R#PNt7XPl%=}_b_b;lx|3Owk=ObCU2|>^vR8TqnCAXK6}j@h34g?XJ;fL zG5!9|Z=YA@Jr*gb{9kY>yM3LYvEAL>gHqcX;x(IQ1l?(ze@;D(e*{L=-J5YC zN5NYC9mBIRo7U_?HkwS%U?ZL)tn=G@qKVi3*?{C~+|dm%JtUeM+qTg6ZN>{h`MhR< zh}+(J-sd2d(a$Ut(6o!GCa`uEo|+iUB~Wg^}Dq0dvQP5SB47+l^NYd?2qqczsdCD<0kW{Bz>+KE5KPEFSdq@FYAtSA%QI zU=Ha0qi9fKdz0h+g;7gW^xN6PO}9UV>u3e?CTMcwt5-u~7yIaKnGQ9h9cVq>b!<8G znT4VQ0uorCubd~*(rAuHJh8V&LiG!U^>9VP`44ht>XTY7^P%GExvw312y{!>I8_wn zlio;XvWn+0k^2?xElglIyJVj8n_ z5fh26jA(AIIq1b964y?Mc8;(qu80r+_q2(Qd(Gk~q9B6YgHImL_Zw*`8Z`#Cs ziFP&#tgHIz<(s~D*RvOmEr`a3`HQWHKl#^W$=c7AujZ0Z23E{BB_3uIPv*MMoh|v_ zT*yquPBzmG1rDEVb6__(h&d6mJy}S`%aFZn61284k?32Iy6c=cvFI#tQ_r^koa0*X zwnA7a0ln1AX2L+Rv1DF3KjV+-{@agB^Var{bAnc*nekmJi0VR8-AsdSlko3yxt6w& zgOd%CGS!|Ib=wM*)#|HVPfsTt@v77xYU%0te0iy5|N%t8*wFIUt9 zaSR4s%H3skqCKvveA%n4N>ErlOCv$FuO~ZAi?HNe+vj2X3{WOZYMs0wLCEefXUp7@ z83`@C(ZPjPPuX$jft9Q}pmpS|HnITZzB=++sJ-ar$ zmwcPT_Y*84sQ=rzFABw1y|X`N7iPlacY-&|FDK^L0fl8>OYU`>t`AZTR$Y)bIjr;E zs?^-5SIkGV5{%0gb_EunY<>UrtaXX>I(F5#h2It5#~rTBW0})`ys|^u?sD0s_IrD4 zAIC1({Av30$6qxb)m@fkc)z7%(n%v}Cgbbkw)=H!qb6KE!C8M|`_4L<@V3CcCpZu9 ze!RMF!`VC6eh9^^_w|CP8u$(cEY5#nMkNak~oO>|&QO-?{)iWlx=8C*kywAJL zL_m9wZAs19J9_sYFL&Ixsd?+$i(ebQuR9&}b)A=yLRHk;p38H)OJT!KE12^Q|F^=nwJXmPp{nKaX`WPx9y2M+wPxjag7hz z{Q`QgSbaFLvzvQWVb;3LPd6GJ)K^HHo>`yc^5kIs^+$U?Ofous%D?c1W!6u*qXH)s zx8Km8+p?`n$LqJJ#=@Kc>5^}DG3LByCzUsSntLp8y<9rC)lOy}_cpD%zCV&1xBN)e z+WI8!`aAK4B<;pntE$=MhXZX+Z4A&jowLzN@w8Go zc&-f>a0fmk^HnKMwWmBcdGc9UzJq7eKr?Fpl|=-YIlwHUh}kR$IBQm2wwwOx`KhlD zmMIHfYt3&(@Zvn<4%>!HD4zz)fp|Z;jxKcW!_>lfv-#oOSER#NG?r>UKa3#0c_t>n@|XC>uS3;KD1t<-`WA=|WT4Ga_`MC@cm!K45QL_y>@U7URA3&{u7=)30W(IsmwFDSn z>x_y|>dydYpnI)piF_1M`(N|a`!~D-i7`fqlM+Vv=#UuQjWp8IAsqsvLt3N-L{LHyP?7EykdW?1L$<)>wt<7Iff5J+0Az~-06q`^Kuup;D;R0* z?QQRaln6#T*t^?%S^Ic+AtjLNj$R(_9^MQNI28stS9^CmdoKo6K5Yg+eqI!>Fp?YT zXYb|h2s&03UnzE0Om-Z+knWgc(v@MbFaC%i7k%(#_u6(bCq#&C|=? z`&!H5zwvtZ4}G1y{{QHB+v0pgN}#xr*CTJ7m0+a1wHr=`)W2TiM!I|0*rD^YD?ds}etLNeC;OLHv?kbZu*53B=9z5GysU<--t}4dPeuMo(?9d;WZDG!*lXCkJNV!Z z62Bly1gC!`(thyZ`WAw^+Fk#^|E2o>#^cJsz`#Ht2mr{bY5^4VWaYI%ARrI`0M(EV z+yD*S006LHMLP@@9Ao%T(|-d9!n<)!4<=*`KAip*5kdd0?Y}Ng^Z!f~>`iVvo0+;M zx|sg&E-wPD|MkUdVLNm7?+c$S8F$wN0KnMm4(BcqKnTV;4FuvkAQ%WH1Ocw%z|Aoj zU^#LA0M_yzP!sqZ(ut?RjWvQmrhq)4anLs~9BT%JJ_U7xEP%d1tSy2Ov;`X8#abe` zbwN+Tj@(#F?$i^Y378g+wd|>p!fC-FSR({j9M}n*B?Dp|ZUU2li49mw2rVP<3y?tt zYX*_z4!X?DGh5EG6BCwWl?qPl)7$;RQu=@(Hl;Iv@eii#ryYYPG6=v<(*3^+Uk1c!kzKq0s>3JQndLV-fKX(2eX z5I7X4iGUz*I5+}~8$)QXJb;2}*{@ijP@I-61ddz7F+$rUg)-n+;5gU*QMbf# zT)9Guvw(wKS%Y9baJ>f{iuJxWg$=%P;>s4zAa3od#|Gm>IF8_J2d-Sj!5ttsaO>A# z90bRB<;69L<<-hR?&6GKUEl~T_Ukn%)*r`d3Bh__2kC%Y^nl>#uC3s7uM&xK5RQXG zu-J`j$(0DF3&n9>r{$XBAGdLsl`B(MQGs!*wz!uI1Pp~=wYcF`N}#y;Rn~FTIPjG> zIM9{r*Gaz00|a~>%$34*Uams-KwLln6~~qJ>*cF>acQuH-oT|60>#O$V|IYRuj0l{ zaUNV(@_K@+)s^NIFYXDxuFzGwuRObQ*ZUujaUJf7a|@@BOHJ56iMlGrKLxn9e3e*S zk^Y6ePCL$(tEt149(L!S!5|ycPL0~m6B%CK$?bWpT1?rWK)6{UweImmD@VEf1W%Bb zPwC_X9jTd`4dZSvV=87<^A``(R*qB~vtkabqy$`emj}6Yi=Q`XUCfiuvZpd>?)2tS zCobheAu*HGolm@H1+-QZ+CG?3bIL2{f3tOUBfWvd*vUcfN=>8#U(grLl4P z@L9Tieu*!CJ$)iji{X5x)1>;i3D~_9YW(G#aHGnOC%3HqHu?p;=*yz9^Nb?{focIN z<{-V$g*)4l;Oo=G)Es^Lc09;Y`3&O+9qM%5q&U42C$V>dYCTfif$tOr?4tIHUuVX@ zU;F!~p>8iL_h?mz3eb8?rYyHoriy-F{98XJuObUnqqkBhGst!Cc(#Nlzi;-=ldr-K z_6_3Umll+&;m;#uJHFS0bYHFa*-@?MOpUd;Hzi>nm*|T**Rk8!*2t;&`Uy}#XB^nK zj&5o^>7|S23Al}U>DLqBs?kr5j-TXGZ&WX)eZ|{Oo(Z@WVdN0(1APC0v!f8UDVK26 zKkY+H^})V)YeepRZtOt=Q`p@cwj+>JRl%WP!Sl1j^6A=2vT3U{X5vK?4Fce^2?%vZ zg#W=Y#^I^dr}@*fCqd)}*8Npda>GJ^dxyd%ys&A$8kY+w6_K=sE|h&5tQKCS^g8YJ zTIS8|tgmIYI$tS8rzVJ1SqF6T74KdOT4!1t-r2{>Q{`{FXHwpEKUZ+mVCY?+M%@xH z?sOsgF$?X!r)hX|tjBn+pik0cSH!F`Z_3twlTG>E*&PP3df{nc%RTwWubLr4oQKnC&I%!^({E3pSH9t+=Ke`%FuSpy>O z0~#u=yzwYz@^7CJY#)!;t@@L7Ua12&1}_EA;3b*9QGYqMgWF4L>}jU%`nlz%T;|L7DdhbHvE*`=*x(OL z*)hq~8P?~u&8Do!+!x+AEyK2^UetTW>lLJa)9)wc84kfzb7fCd0XbeCk2JdXKX%Y` zo-qyi+#{z0dPqiR+q88{7XSJ0IX*KyYkrQTwA&O-Kt zCF|s?7e$>>eeu5@D)=)P#47aDZ+&>7RQ1T1f>qRPK1c7&AYi%a;lf`aRroQ2d}}B; zOJrM)HI~+VzW=uNaCY@-w5_XBNnS$t56{Nen$C~wFQ1?LifnNcbe*8NU8#B9*r^@CeO~wV$7rYA= zOaA_oj8X+`nYJbN`s3Djwsb z)qHkOXib0K9c%O$y%%v56s7b4w%^;V5nHs7G39le$yw!H^#Lg}*JGie1)7TkYE4Fc zlOj^)X`bX)HIg0^B9)%+PeT{)Og|}#eNWO-Q^_xt@~D8P-?X^S{C>QaJzqW5u>J6; z+AgbLXnQepzu$IDCHKen5CpPgH1AL9n|EmrH}%+`ociRxPV11Xu(8W3xp~ystZ(gO z>GWscqw{ePJcgcIuwmfati6|$ zHeJ<{&z?ujR@X-OM0rHTr4xjhBV)VvBW^IXxIzw|=fgA3Oy{)8XieAnoK7gcM$BmM zJ~avcY&}>`_)!S--sAB1IrY@v{{5xTk|X|Gah5Mo6v8vUlLIUC^X$r0oS6n(&sIpE zWUisR*_eIV%`c$PcvZDTN=BQH9S!%SNT%PcGnOsgqeH8cjJ7izEDR5xI&P^^IPM`< zI~h2?*qQAyA+v8zX{S*PADij9eN=9ah{tFt}$qTrP$ zPYQeDq#_znf$x#wkK8 zdj~wo7oV|ZUhvJ({05WP+&*+Cf~_oL0UWvR?p8u5wSxHe0BChF7t(DwkvAurltI)RvA)gzXYM(Zfv8w%QZp~lQ zS3C=vDlcJE`64@Z`&RC3luabTDK|^jzEW(P;%2MRp)Gn*;G>G6W*u@jrxt2eMwv5g z=f8OSRIp+9;bd<6f@cEkYhrc>uMY*3*OYuJg*z-_RD;)G9KS*f(=ea(Z~;in`F7#w zpKAN=J5FR8OPwqB%AQM}G12S}cbhhYHiYA!(K@D%-)@2{?E~cR)TBh^1g4QGBIL|D zTsSCh|4Gy#aU1^8vR_+gW#E)Ihs~!T<_r8a#Tz5rWCEkRpB|g4)HS{a@C$U;>N2(2 z$Y_4Am`)zRbyEnBqXf=Di`eaRO|MeynmROo<0$4y_~hL<@l>wmpPIclcJ-nPOJ>$z z2Ypq3LAWi{H6}1pzhxV2z#ZiZ>k-LtMaEN4{UX{*!LJ6cj~~@0Rzl|ZK)51i#;tdU z``E|Q4IWG^zI(m>_>Euav#8V`lfj?p*X0TYhDiSqta_~@*88cXEFBINDk1pgLCrJY zcfD!83RC*Gifaof4-QS7C-H7r?-L+Hh*&} z>m{|P-ZJ5RH4=57`?HeS8=m9db+IRR(;voxZ9wReIs~aloy-r!rIMsAqaN&QFR!y% zzKo9#-J~B;@E6eRs>sU%b=gw;1PK8m&5#?KxPFTng?bHmTeqrkv)m??Q(e zvy<8G$B~MQY^D;Q2?f-!g-`SNS#9&OaCgQ>S~;9%QG9M~J(5J%j-Tc|qAQ<**VOiY zO8e=0;Hxj~Q%W=GeH;1$V(WyTMo8qh?=*1f*-`CZ8SoPy{>_7YpN>;fZtx9X6Mtf& zVDa4c4mpL6H06}J{-8v;{ z!~B9Ay$Lg%%%Cp3x7@zee`9vD?S_fQ`yAizoZfG_bx*9&u8E2Fr?_MM*LhuKS&4-1 zjNx(>8e_)>Y;OBx5+5UU#KP)(^^ugPElK49Ribi_)f7~)1*{y0Ai7L19ms^Z-5#Px z;+f}K3w@rOeR-A{vfas}P|AP!o#3#=>gy{}0fu&(JQ0nD<@_Ox%Oe~c0TO%o#TTFT zH#eKoydvqn1vfm&TJ<<~9!6T-a6dQtSZlA0Kz51MKkoCFoY)FU={u% z8wzdy-BFx;A+p@N_{Z?M*bq2=|XtqRyvLX#%|sGg1}hs#-_|UyvcTcjU7kwZE9<(K7t{y>7u1!87`E zGHnJ@0Rk_y7zOMLgS_Wdl(wC>h1A(z1ct~RCO(Q84fm!Dz4y#C>+L$rL?-4A0Hgl+ zP4TuVYpBZhhsZW`yV0h9;`or*A^hY9ZMEX(k=Ko1e(-wTd$qwX+pHC8uKV1mR+?Ol zCGfXJkco?}exYsZNL(IS-THHpRu+TX!5u$^;x>pxTHoomXu}`FgmQ*`)0Fdx=;6)S}UyLV{b1(8+%h03|EMybgO{An3SjJHvJkv^O-d&qh(OX}N~HAwKwL+d;dH zIk;+5^8W8XULlW9^%PxstB1@s@+AyoX+;D>TG%C)G6q{6-`tRk(juR{EVF&G7gEVE zFRsYLU{?CE>Mc_}nfd0BHg+tvW1g1G@#NM*WeQzFWv-dwn*%$5FZUi^+w;G*K6$%L zlB)IOecwtK+dl;Fy~#({<&k&Hhyr68ekK=GT(+wYWwjKpAH?*sFc>)S@+Tir99O&1 zO)~IP*RuaA8YJzLQ0L|N;X>5Q@^qOiJPZbBw(8Xhp0!WE%l1Y1)KET-#Fo6>ZR;#{ zOZIDRnOaO_f$?elJvtW@T}GE_Fjv$8r{=S%txtvlq8~S8k}ZyqaJj|=1nNEYk-++b z$~V^9#u(M*;wQchz_EuGgH{^9(!z#swmh!(?wp~quT|aWR@1%Dg>`d`iFxKSWYqL! zaDb61+dh|GnP(CZ@`1T_hdz{vM=I8WjYvfN0 zxmRJ&3G3qqr}wk982xI)jREc4b2Om_iI`8-7J;{wGIT=~NTk`YgD06Ys7$_G=L=5Qh;je5!ux-ewR7N(l)j^+| zlnh17bkTh%Xd%1XI!+^$sJ+k17CS$ARCx)aZoDXfGaIM=+-3{6|1i zK)J0Yop1O!$@gQd43n`iOTn}WXR!c!H*r(i#Hn+8Ul%M=W1Zu7o{@}yc)n7<-e|pB zk+Rj2&*0hZvn&q2!~X6u$E)Pw-blh<%isJ?r0eNCPKf7%QZr>-df?v9e`WY5xRZ7F z_%$Pm>;sChHso0FSWuOXr?{!h>2w93YDDx!zPvYg_~FGcs3)@h1&d+ zQcgXH&hAau@q@6^RRtHB6|FeS&mytgX9Le9_cX#zcZU&?(I@n zxKEN;@Zs?ngFZ;xbZ=%5ok#l5uAZOm3?v$L;h%E|56Ds;Coc&4V~g=A7H*r298f2V z5X-O0r-m2%((v`kv;!a_|qn$)U)#Ocp|;&AAE$sb0~{QVu%9jU@G-RfT4g-kl@cC zi*10j-hIhH!<)hGpBgiSp%b^Bn3&9X%@g+%xbltO{`PSm|7X_YZJTsShS=|mL-V5n zYeg(I$tBtqx0_gWPjm1~w7N}4C^&Qxg5~q0jqmr@D8H`|ngq9{9@xd#fA2)4uHs3Z z9xt}sn%>M4PFR@OZF$)gwA8a|9X=e&c)FZ;^;JAp=Bjcr?S`Bo>4rtUU3JUrqW<2@p33q#kcC!IBtP{nxvFP}WcTm`ar_D$y7pUTRC06N$JWr<3I;vn} zTM~m2kyO9=j$u9^LnL%I9}ghD*&(t7T8kwevlPRKZ=*N7zz&5ZoAj`vhIKSZ6#RY$ zhTy%6I$OmoJ^@D%!^C7n4==uBQnP?tS)_IdLhe?fRi_<5%OOaWf}r6mZ;>dfWA_DE zR~-f*5kf&;cl^BoFUSF`68Xp2-vcVjKq+dV0p7c*{mD4>?$9 zo|vneK(LEwHG#|1`57R`4ur787yBlZ>N5rStOIs^K@8VGjd{3qEC+)V8KH690&rGA zfbE-G_lI9_%n^@ASd60+BD07xZUXkdqb zFVN~NpvD~-H?^o$^EY%$4KRuVc19(Gv)oY;4JKEI+AUJxLPNiq13SGU$!-zB2n=Bk zBSCcj&`Suv=kW;o>^8WYl+;*~ddzPD&_oT$(1*nx@pVWZcL*(m$K**Logo2YtC)fs z(D5ko<_y>B%REe}12{znb`~OG)wqTED+bQ@h7ouQbck)Bhu8o(HUn3F&o8AN#p9q} zDq=t$7n-XI^XwS(ewygf<6Ees9t@Tie7ZuKQ7etPdw}^J0(#R%v=~Th_3Bwi=012_ znRMVl`tb4?BRC7f?a%C0p|QXnbdN0H-6RZ}B;$FuhFP@*-w!5fVCPD8tH+$DfeM0Q zPM`K3vS@|Mo^z6VRZ1X)s|z=mcJdB z$Kd5eBB5om4kY&>Rt6P<#PIh4J|}{EC`ii$xiC_{Iz(&0nP#xV197zE5r8=u+^_+Q zg0dD>RA6d)K}&wbW5K+uxO?TvTTnb3%#KOI^Vb{(FBe?D0CT02w5p#2oD_h6d?ekh zhIdF`pj`yf>+xV{ByRKZrwSZ({8|9dBRd2?b(myjc+2mCz)tH*S##e!`rVU+j9JVVAhHlKih zq>vEsNm{k$M8n}(F;=S7Gmv+2x zkCu+PIgqvtN&a_!-0=b&4uNs^k4rT=+@2|;6IY-Y=l&#a>>;&}xf{nPJ&TgYwX|fC zc7;+xpTyBH@ggbl*R3Mi#9UT2oq+zA;Fe>U4|2?Z1+)4ag#Alw70XxjrVX%m3<^0V z@!^y1xceR5`x1P$GALwKH;ArR0G}=s537se4j|w};wlr5u#&5-r+@`UKy@>0coYSH z*aKMG2U&)Z1VBU>>SxjGUqEnq=qe0_k=R2|i2`sr8oP6K`#xs}hha%xSBhD^X~n!( z1#`TD;(m6=y`AEI*(SpMhz%gQL4fc{1C`@75?H`^U{m-!cqe%Mc!S**LwM?Cn%IEgRawFqEVG;I4c6MyktA;@@#70}j4}p-0nW1#Xws2Tjn}XmE7=s^|Tb+~OkEC|D zVsm^41XatyN9&hjZ#ki(P88v6Dheq;;?Iwe9D4@D+8#}+8=;eZ(W%*<`RC*h{lgZS zD~vZe8zK!!ID_PW^c{T*=;dR}d7fYH2NT=lJNIRBU$(g+%iz4Y;2ri`2OkbUJD?T6obyDTFIVrRE( zZoAsO=9Yb|KL;bQ)TuvtuguCPXg|px>U#U9D0C&OeWq0TBMnzkf|aMQTo}4!us_Zd_F1$sjl2Rer@Av-I}E;XG*)rSaumCS0nX0u1cuz zJ*nGbE~w{Yke=l3kT0Hl_9B9!i})EctMkzUFiN~90IlYfI2&7s|Om*Cj`uK8Gog zP_LP$+-td?z(>s3ijPhJIK_mVa`*;C<{gAjh(|^|Ixusg{hb;v_Lbes+TXIUO;knU zr+J=m6ZFyg0Ie@{j#a(R!Zo8VUa`9*Q`HC?G5t03eEVTc&eWW)0qr^Gx=7Ic*2q1N zU>+hDPf9BMA);F2(y|q$=%@A{=8S!90-b2TzI!!WKg#`%TEM0*z*s*uh;dDxpB(wZ zm!ZD+>|*JKMFVW7DMhx{I~F>3VU~OG_;UPLD*UDBZ5w|_xCQE`NwZO|eB6UOqK+RM zc}_|gRO$$mwjaA%9g2IbDa3`+`xVbteA9f`?)+u?6e#3d%aAnn;zV{W!9wo7^nZ(trOj4Cb>BIxz)_jTCoHaOxapX#_Gq{}GK_iI*!0*W`qQTXtq z$f%w43d{~&aC{tn#ho~Z+f2bE3CxUyV~J_EiPQ0o9OU$ScTSb*lP={cSaIKD@aPJo zO+fPlQ%ik`1U+ff8qVs^BC%TAc3-GcdrzwqkxpABbANK7kM1!D9#QA_||_BKMSv z{JD?Ue|W+jArsVh!;MJ8`Bj_U3Fuc+&=e1N;% zZHiaoG<3HfWO&D9jH7gs!*)OXGR0B$Rz;nGDwR7mJq+){ch^T*Bdm|htCwu|CB=}1 zU*=lhmtl79 z@SCFcet)aA1l~)+PvIwjO5ZJ=HWsDU&69iy0U%3tpV>-F5A|0z*xXoRj%so+qiYV6 zC$UP+Dywnvq>?d8k3ZyXCy#t!zTx!^L`)Co(aqBJKZA8c-u$X+G3^ATQdv)8dnZ}_&wMe zFVv;+Fe^yWhJv+#f!nZww#dz(K*;W~mNzVY7CANbfRSGFbJ&cnIsTf|s2z*1>BV0c zgz{AQ#bhFj*noxr_ULB~5AO_2CwTFOB5}*?Z!O8jc-h~YoIZTpixHqF$inJ%E2_ZC$~O;00gE^23|dmx~;$s`96nn=$;||SQBTR&F4JzIeS`>JRjw7UMk6+ zt)7$QL-XM;B}lD!^QE6`&kNuK)+h2RZ}BcPbmIH>w#uGQR{#iP+J5Q?&Sq70;)ORI zbRQ7xfjGIv#zv}4-YYW>G!?h6TA7BS7sA!n^nOE;@5kTb4N1&~DF;K2e&1-qwhPo2 zsG>bdRacPG@dvU4mEC(|fuf_|!U;%Z2i~$D~S`4)BcQ{+Lw*g@a)_PX^qP*26=Q;;KXG* z^7VUm+lc5~C5ELzw)MEpR30%|uFb3t#8%lA8ttBIMV%9CqQr>CEpC zX9dm#V#9GB-3ESV^>Qwk7^SG2dKH|6HZ}|*?8`@76#}$TJD7V)m6ffpdcwi`!Cn5W z)cDqpV?Q*y^Lf@v2!N?bY&+bi+EEsc$9JF?;J?WP&|;==cC6Y z%Rv)3gf@V@Y|x7v`q)()Ti)b0v9efP8pye_n;geutiJYF_4VF{(2w9SYH6mKcHWd# zjSZuc*LzCI2h1|HsGA?(hp6zXew%&wdBOZbf>OQia8Kc7EAcLsK-(==+~=P|nfs5( z9e!h(5BcvG($V7nY$4qK)K4~hz{Vr4Gl{K1+o+1LOZogK;MWtSCfcFM29*@>DMJg; zm~JsV%v1k!K!I4&v>{OX7_eclL#H#PSCeab&W6bIlKzU^8|?qoSP`xFMD-v!!#knA zP$4;+JTvPdm0roTf{JO(grZ)INmtliGVoONfc@^IjjNLNvnsp`+vczbcP$Rye^npblTi za*{t0xWGZDEUw)k(@iy`A99=#Ea;jQf6L&FRnQF@)_%RuJPt(RgrgA<*z7D&Y2Y@} zSjSR#&{_j2^PQY2Vfx|3hq@2dI|GgHzbbJx|7P@P%@!;#KUT{Q--|6NlQYQ{M~G5p z(o`+IxKY-cYOlmaet`EOBs$<^%=2+olY5-_`^<%yDjG3Um(gv`Q8D$u-PG0em7RV; z_q&S6!(J{2yGaUpX?X6orF<9Dxl4~Fvy6PGyW7RH(I7=L+K3!P&Kwk7yuBGfG+Rx% zYedT>U-a>ka8^r$kSn>J7(d`ey&6-ykS$#xKn?2uw;n_saga#b2+5t+@pK7A=B}VTISF+{}(z9SzY8)UNqCr3Us86j9vR+`4 zTW|7lCt>Dd@_G4N0ZiRinh9b@-~>Fy3goBZ$cG=@J|+gpMAvBAsB@8i3vZWX=TB3L z#TaoF8*H6{UJ^xfG@gk=O29dqYdIpsLvNzgO`(zy0)&|-pt(0NjS>cH@TP!He zT#+OQIpi|b?8uMQhW;_p`z%$s7VzT(AlN(x`LUMM^FZ13u_o0_b0dwF$H~y|HbExC zPJe@#JQHXJYKSG)(j^AF6ylTeg*hm2=S`q>{-bAd#B}xXk_slacg=w%RPoP@-&I{G zQrH3$c~xH8k-6o>G2W;Tv!+h2K&KEL%DEX_u*hT#ZfXO5Pa*L(ZlbSZv$(!*** z#urebN9IA~%Xsa*emPm>0;`0WdXjX{5{z*(3tl1{zpNV2Yskm-l`9)V<4`7dhj!8X zu^_kkhq^Z~!kyhLe+RJEPdmqSjRI62z=MD5=}Zh8SRMy$T6 zQvE^SbgO34M`p832DJDtlu+2RPg#ydbvLf+2~ReApn{4Vje{X=4n)P3nc)Q1!$cOz z%7M_QI`{uzkQ&i|P$?z1TVh#$*TO@1meBf6Nli`xEGAwW5f>T}yFrGzla(3~Li79r z7H@n;wb8|;rdYeqM*CcYQvMuI8AUd3LfA3{u1$}2j3Qdg-UYn3mJ5g(Wd-hZtddH1 z95{J$EHhgbKM%W?DVyQc`91#&gMHJW5>(1LE_%En#(>D#6`x_lan~;{k!ehF8>Nh( z2hbRq{H_^b+&2@Rm;{ok7PRsc6N{xgxeD&*R=aY82E^DeyI!sbp?B*uUAyx{zrzkc zDseyI+xb2qVE9@SO+M1?c$7ktN}{y=QM;%V4iLfr{?}ueRFbV*8?bDi@Pp8@_LIjK zYd|vFH>c{OB&MNLlLKWyD&NU-dq7MoYR)Y4=&94&q^WdJa(#HSo$r$CFV8|+^|wE@ zoJl(Hk$F)ghpr6yk5E7HMZYIX9Od^dcH0)0-D{Z`@6)TF6?oTI@UD<~Vc@d`E#7vJ z(YfQsuB&bG`N@wJ6xAtTS+AsrHApUyq^MGoSmB4_y7!p<2lGF2aRWg257BZBqmTZA z*Eb)~+rG|>1gLzmUR}LgBri*+Lz#jnr%OQK`xiCY1mxzxQic?I%s=m`)|+rVJv z@t-{Z@JW<}zZO}ViffREQB!PimFC&aNmJP&k_OnP>3U69rN`4nnsw5;PjZO+KY5jLN*6wncpePYq8H&gFm z*EFRF0pfKW_^*E6vKu_&ETJJ<#SW9IpVO|NGMiPtzlr(!aU)6@Q_7{`FiZBM!Xh1Y zpidQ7)%KmLwz%ViPK646k(;7DM+Vz`Zcq=z8Q(VMm3#@hYsmaPZ{sT(-zh9=Rqof^FYpK*S3*Vo9C5DsyFyn#%)UB;FB2%D5t^`rD~jMb)HT6R-29a3Ci9&`Hw+%n8h zeV1YuBPtr&xFyVi-ix!BQYy))3DZOm7|onPTz608&P-@aDVaHu+-Y?B)F7bLlI}A|dMF7?{U-o7*hhWTHnNWR4dH;>jiO(m-z<8a zLc~SW%26>|IX098pwQ}BC?OvWq=H+{sl_mds#vgMRVF$M%}fBd!2d%y{K#?KX!;=! zp4kHtgFbc7;6wBQqQlE=Mlk_=3aoLDcgw^JC}Bc*4bJAPnf{>_@wy1m;7ad_v8(<; zk`RB#vsQBzuW24Lkk>Cdsm?~yBY|n1-UF6H-mSu;=~jQ^P2W!Mp66Y(>0Pte10-z74MkP7jk<4nios;$?P}(BZbeVd_Y>vhere a)qmdsu%rOM`@dA;0ZUy{4Bx_Jp#Kj(N;)b4 literal 0 HcmV?d00001 diff --git a/apps/kitchen-sink/src/app/app.config.ts b/apps/kitchen-sink/src/app/app.config.ts index a1310a51..f906bfe9 100644 --- a/apps/kitchen-sink/src/app/app.config.ts +++ b/apps/kitchen-sink/src/app/app.config.ts @@ -1,11 +1,11 @@ import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core'; -import { provideRouter } from '@angular/router'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; import { appRoutes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideExperimentalZonelessChangeDetection(), // provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true }), - provideRouter(appRoutes), + provideRouter(appRoutes, withComponentInputBinding()), ], }; diff --git a/apps/kitchen-sink/src/app/rapier/basic/basic.ts b/apps/kitchen-sink/src/app/rapier/basic/basic.ts index 8316d4ae..363c3c99 100644 --- a/apps/kitchen-sink/src/app/rapier/basic/basic.ts +++ b/apps/kitchen-sink/src/app/rapier/basic/basic.ts @@ -1,16 +1,66 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { NgtCanvas } from 'angular-three'; -import { Experience } from './experience'; +import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core'; +import { injectBeforeRender, NgtArgs, NON_ROOT } from 'angular-three'; +import { NgtrCuboidCollider, NgtrPhysics, NgtrRigidBody } from 'angular-three-rapier'; +import { NgtsPerspectiveCamera } from 'angular-three-soba/cameras'; +import { NgtsOrbitControls } from 'angular-three-soba/controls'; @Component({ standalone: true, template: ` - + + + + + + + + + + + + @if (currentCollider() === 1) { + + } @else if (currentCollider() === 2) { + + } @else if (currentCollider() === 3) { + + } @else { + + } + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgtCanvas], - host: { class: 'basic-rapier' }, + host: { class: 'experience-basic-rapier' }, + imports: [NgtrPhysics, NgtrRigidBody, NgtrCuboidCollider, NgtArgs, NgtsOrbitControls, NgtsPerspectiveCamera], }) -export default class Basic { - protected scene = Experience; +export class Basic { + static [NON_ROOT] = true; + + protected currentCollider = signal(1); + + constructor() { + injectBeforeRender(({ camera }) => { + const currentCollider = this.currentCollider(); + if (currentCollider === 2) { + camera.position.lerp({ x: 10, y: 10, z: 10 }, 0.1); + } else if (currentCollider === 3) { + camera.position.lerp({ x: 15, y: 15, z: 15 }, 0.1); + } else if (currentCollider === 4) { + camera.position.lerp({ x: 20, y: 40, z: 40 }, 0.1); + } + }); + } } diff --git a/apps/kitchen-sink/src/app/rapier/basic/experience.ts b/apps/kitchen-sink/src/app/rapier/basic/experience.ts deleted file mode 100644 index c6ec4d0f..00000000 --- a/apps/kitchen-sink/src/app/rapier/basic/experience.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core'; -import { injectBeforeRender, NgtArgs } from 'angular-three'; -import { NgtrCuboidCollider, NgtrPhysics, NgtrRigidBody } from 'angular-three-rapier'; -import { NgtsPerspectiveCamera } from 'angular-three-soba/cameras'; -import { NgtsOrbitControls } from 'angular-three-soba/controls'; - -@Component({ - standalone: true, - template: ` - - - - - - - - - - - - @if (currentCollider() === 1) { - - } @else if (currentCollider() === 2) { - - } @else if (currentCollider() === 3) { - - } @else { - - } - - - - `, - schemas: [CUSTOM_ELEMENTS_SCHEMA], - changeDetection: ChangeDetectionStrategy.OnPush, - host: { class: 'experience-basic-rapier' }, - imports: [NgtrPhysics, NgtrRigidBody, NgtrCuboidCollider, NgtArgs, NgtsOrbitControls, NgtsPerspectiveCamera], -}) -export class Experience { - protected currentCollider = signal(1); - - constructor() { - injectBeforeRender(({ camera }) => { - const currentCollider = this.currentCollider(); - if (currentCollider === 2) { - camera.position.lerp({ x: 10, y: 10, z: 10 }, 0.1); - } else if (currentCollider === 3) { - camera.position.lerp({ x: 15, y: 15, z: 15 }, 0.1); - } else if (currentCollider === 4) { - camera.position.lerp({ x: 20, y: 40, z: 40 }, 0.1); - } - }); - } -} diff --git a/apps/kitchen-sink/src/app/rapier/constants.ts b/apps/kitchen-sink/src/app/rapier/constants.ts new file mode 100644 index 00000000..8ef97efb --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/constants.ts @@ -0,0 +1,9 @@ +import { Basic } from './basic/basic'; +import { InstancedMeshExample } from './instanced-mesh/instanced-mesh'; +import { PerformanceExample } from './performance/performance'; + +export const SCENES_MAP = { + basic: Basic, + instancedMesh: InstancedMeshExample, + performance: PerformanceExample, +} as const; diff --git a/apps/kitchen-sink/src/app/rapier/instanced-mesh/instanced-mesh.ts b/apps/kitchen-sink/src/app/rapier/instanced-mesh/instanced-mesh.ts new file mode 100644 index 00000000..76023f8a --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/instanced-mesh/instanced-mesh.ts @@ -0,0 +1,96 @@ +import { + ChangeDetectionStrategy, + Component, + CUSTOM_ELEMENTS_SCHEMA, + effect, + ElementRef, + signal, + viewChild, +} from '@angular/core'; +import { injectStore, NgtArgs, NgtThreeEvent, NON_ROOT } from 'angular-three'; +import { NgtrInstancedRigidBodies, NgtrInstancedRigidBodyOptions } from 'angular-three-rapier'; +import { Color, InstancedMesh } from 'three'; +import { injectSuzanne } from '../suzanne'; + +const MAX_COUNT = 2000; + +@Component({ + standalone: true, + template: ` + + @if (gltf(); as gltf) { + + + + + + } + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'instanced-mesh-rapier' }, + imports: [NgtrInstancedRigidBodies, NgtArgs], +}) +export class InstancedMeshExample { + static [NON_ROOT] = true; + + protected readonly MAX_COUNT = MAX_COUNT; + + private instancedMeshRef = viewChild>('instancedMesh'); + + protected gltf = injectSuzanne(); + private store = injectStore(); + + protected bodies = signal(Array.from({ length: 100 }, () => this.createBody())); + + constructor() { + effect(() => { + const instancedMesh = this.instancedMeshRef()?.nativeElement; + if (!instancedMesh) return; + + for (let i = 0; i < MAX_COUNT; i++) { + instancedMesh.setColorAt(i, new Color(Math.random() * 0xffffff)); + } + if (instancedMesh.instanceColor) { + instancedMesh.instanceColor.needsUpdate = true; + } + }); + + effect((onCleanup) => { + const sub = this.store.snapshot.pointerMissed$.subscribe(() => { + this.bodies.update((prev) => [...prev, this.createBody()]); + }); + onCleanup(() => sub.unsubscribe()); + }); + } + + private createBody(): NgtrInstancedRigidBodyOptions { + return { + key: Math.random(), + position: [Math.random() * 20, Math.random() * 20, Math.random() * 20], + rotation: [Math.random() * Math.PI * 2, Math.random() * Math.PI * 2, Math.random() * Math.PI * 2], + scale: [0.5 + Math.random(), 0.5 + Math.random(), 0.5 + Math.random()], + }; + } + + onClick(instancedRigidBodies: NgtrInstancedRigidBodies, event: NgtThreeEvent) { + if (event.instanceId !== undefined) { + instancedRigidBodies + .rigidBodyRefs() + .at(event.instanceId) + ?.rigidBody() + ?.applyTorqueImpulse({ x: 0, y: 50, z: 0 }, true); + } + } +} diff --git a/apps/kitchen-sink/src/app/rapier/performance/performance.ts b/apps/kitchen-sink/src/app/rapier/performance/performance.ts new file mode 100644 index 00000000..c00f869e --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/performance/performance.ts @@ -0,0 +1,140 @@ +import { + ChangeDetectionStrategy, + Component, + CUSTOM_ELEMENTS_SCHEMA, + effect, + input, + output, + signal, + Signal, + viewChild, +} from '@angular/core'; +import { injectBeforeRender, NgtVector3, NON_ROOT } from 'angular-three'; +import { NgtrRigidBody } from 'angular-three-rapier'; +import { injectGLTF } from 'angular-three-soba/loaders'; +import { Mesh, Vector3Like } from 'three'; +import { GLTF } from 'three-stdlib'; +import { injectSuzanne } from '../suzanne'; + +@Component({ + selector: 'app-monkey', + standalone: true, + template: ` + @if (gltf(); as gltf) { + + + + + + } + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody], +}) +export class Monkey { + position = input([0, 0, 0]); + + dead = output(); + + protected gltf = injectSuzanne(); + private rigidBody = viewChild(NgtrRigidBody); + + constructor() { + injectBeforeRender(() => { + const rigidBody = this.rigidBody()?.rigidBody(); + if (!rigidBody) return; + if (rigidBody.translation().y < -10) { + this.dead.emit(rigidBody.translation()); + } + }); + } +} + +@Component({ + selector: 'app-monkey-swarm', + standalone: true, + template: ` + + @for (monkey of monkeys(); track monkey.key) { + + } + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [Monkey], +}) +export class MonkeySwarm { + protected monkeys = signal>([]); + + constructor() { + effect((onCleanup) => { + const id = setInterval(() => { + this.monkeys.update((prev) => [ + ...prev, + { + key: Math.random() + Date.now(), + position: [Math.random() * 10 - 5, Math.random(), Math.random() * 10 - 5] as [number, number, number], + }, + ]); + }, 50); + onCleanup(() => { + clearInterval(id); + }); + }); + } + + onDead(dead: number) { + this.monkeys.update((prev) => prev.filter((monkey) => monkey.key !== dead)); + } +} + +type BendyGLTF = GLTF & { + nodes: { BezierCurve: Mesh }; +}; + +@Component({ + selector: 'app-bendy', + standalone: true, + template: ` + + @if (gltf(); as gltf) { + + + + + + } + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody], +}) +export class Bendy { + position = input([0, 0, 0]); + scale = input([1, 1, 1]); + + protected gltf = injectGLTF(() => './bendy.glb') as Signal; +} + +@Component({ + standalone: true, + template: ` + + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'performance-rapier' }, + imports: [Bendy, MonkeySwarm], +}) +export class PerformanceExample { + static [NON_ROOT] = true; +} diff --git a/apps/kitchen-sink/src/app/rapier/rapier.routes.ts b/apps/kitchen-sink/src/app/rapier/rapier.routes.ts index 3a539845..6b9b1959 100644 --- a/apps/kitchen-sink/src/app/rapier/rapier.routes.ts +++ b/apps/kitchen-sink/src/app/rapier/rapier.routes.ts @@ -2,8 +2,8 @@ import { Routes } from '@angular/router'; const routes: Routes = [ { - path: 'basic', - loadComponent: () => import('./basic/basic'), + path: ':scene', + loadComponent: () => import('./wrapper'), }, { path: '', diff --git a/apps/kitchen-sink/src/app/rapier/rapier.ts b/apps/kitchen-sink/src/app/rapier/rapier.ts index 1eda46e9..c262781a 100644 --- a/apps/kitchen-sink/src/app/rapier/rapier.ts +++ b/apps/kitchen-sink/src/app/rapier/rapier.ts @@ -2,7 +2,8 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { extend } from 'angular-three'; import * as THREE from 'three'; -import routes from './rapier.routes'; + +import { SCENES_MAP } from './constants'; extend(THREE); @@ -34,5 +35,5 @@ extend(THREE); host: { class: 'rapier' }, }) export default class Rapier { - protected examples = routes.filter((route) => !!route.path).map((route) => route.path); + protected examples = Object.keys(SCENES_MAP); } diff --git a/apps/kitchen-sink/src/app/rapier/suzanne.ts b/apps/kitchen-sink/src/app/rapier/suzanne.ts new file mode 100644 index 00000000..b433f0f8 --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/suzanne.ts @@ -0,0 +1,12 @@ +import { Signal } from '@angular/core'; +import { injectGLTF } from 'angular-three-soba/loaders'; +import { Mesh } from 'three'; +import { GLTF } from 'three-stdlib'; + +type SuzanneGLTF = GLTF & { + nodes: { Suzanne: Mesh }; +}; + +export function injectSuzanne() { + return injectGLTF(() => './suzanne.glb') as Signal; +} diff --git a/apps/kitchen-sink/src/app/rapier/wrapper-default.ts b/apps/kitchen-sink/src/app/rapier/wrapper-default.ts new file mode 100644 index 00000000..496de01f --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/wrapper-default.ts @@ -0,0 +1,79 @@ +import { NgComponentOutlet } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core'; +import { NgtArgs } from 'angular-three'; +import { NgtrPhysics, NgtrRigidBody } from 'angular-three-rapier'; +import { NgtsOrbitControls } from 'angular-three-soba/controls'; +import { NgtsEnvironment } from 'angular-three-soba/staging'; +import { injectParams } from 'ngxtension/inject-params'; +import { SCENES_MAP } from './constants'; + +export const debug = signal(false); +export const interpolate = signal(true); +export const paused = signal(false); + +@Component({ + selector: 'app-floor', + standalone: true, + template: ` + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody], +}) +export class Floor {} + +@Component({ + selector: 'app-rapier-wrapper-default', + standalone: true, + template: ` + @if (scene() === 'basic') { + + } @else { + + + + + + + + + + + + + + + + + + + + } + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrPhysics, NgtsEnvironment, NgtsOrbitControls, NgComponentOutlet, Floor, NgtArgs], + host: { class: 'rapier-wrapper-default' }, +}) +export class RapierWrapperDefault { + private params = injectParams(); + protected scene = computed(() => this.params()['scene'] as keyof typeof SCENES_MAP); + protected component = computed(() => SCENES_MAP[this.scene()]); + + protected debug = debug; + protected interpolate = interpolate; + protected paused = paused; +} diff --git a/apps/kitchen-sink/src/app/rapier/wrapper.ts b/apps/kitchen-sink/src/app/rapier/wrapper.ts new file mode 100644 index 00000000..73b4ecc0 --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/wrapper.ts @@ -0,0 +1,45 @@ +import { ChangeDetectionStrategy, Component, computed, Directive, model } from '@angular/core'; +import { NgtCanvas } from 'angular-three'; +import { debug, interpolate, paused, RapierWrapperDefault } from './wrapper-default'; + +@Directive({ + selector: 'button[toggleButton]', + standalone: true, + host: { + class: 'border rounded px-2 py-1', + '(click)': 'onClick()', + '[class]': 'hbClass()', + }, +}) +export class ToggleButton { + value = model.required({ alias: 'toggleButton' }); + + hbClass = computed(() => { + return this.value() ? ['text-white', 'bg-red-600', 'border-red-400'] : ['text-black', 'border-black']; + }); + + onClick() { + this.value.update((prev) => !prev); + } +} + +@Component({ + standalone: true, + template: ` + +
+ + + +
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtCanvas, ToggleButton], +}) +export default class RapierWrapper { + protected sceneGraph = RapierWrapperDefault; + + protected debug = debug; + protected interpolate = interpolate; + protected paused = paused; +} From 81c26b07268940f77f6404158bf3c1f2358e6379 Mon Sep 17 00:00:00 2001 From: nartc Date: Tue, 10 Sep 2024 09:02:05 -0500 Subject: [PATCH 07/16] fix(rapier): auto wake up when updating rigidbody translation and rotation --- libs/rapier/src/lib/rigid-body.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/rapier/src/lib/rigid-body.ts b/libs/rapier/src/lib/rigid-body.ts index 84317de3..75aa8ed7 100644 --- a/libs/rapier/src/lib/rigid-body.ts +++ b/libs/rapier/src/lib/rigid-body.ts @@ -515,8 +515,8 @@ export class NgtrRigidBody { state.object.updateWorldMatrix(true, false); _matrix4.copy(state.object.matrixWorld).decompose(_position, _rotation, _scale); - body.setTranslation(_position, false); - body.setRotation(_rotation, false); + body.setTranslation(_position, true); + body.setRotation(_rotation, true); const [ gravityScale, From cfd78845a28269e766b40cd2f57e3af895714206 Mon Sep 17 00:00:00 2001 From: nartc Date: Tue, 10 Sep 2024 17:28:27 -0500 Subject: [PATCH 08/16] docs: beautiful gradient --- apps/kitchen-sink/src/app/rapier/basic/basic.ts | 6 ++---- apps/kitchen-sink/src/app/rapier/rapier.ts | 2 +- apps/kitchen-sink/src/app/rapier/wrapper-default.ts | 5 +---- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/kitchen-sink/src/app/rapier/basic/basic.ts b/apps/kitchen-sink/src/app/rapier/basic/basic.ts index 363c3c99..facf69a2 100644 --- a/apps/kitchen-sink/src/app/rapier/basic/basic.ts +++ b/apps/kitchen-sink/src/app/rapier/basic/basic.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core'; -import { injectBeforeRender, NgtArgs, NON_ROOT } from 'angular-three'; +import { injectBeforeRender, NON_ROOT } from 'angular-three'; import { NgtrCuboidCollider, NgtrPhysics, NgtrRigidBody } from 'angular-three-rapier'; import { NgtsPerspectiveCamera } from 'angular-three-soba/cameras'; import { NgtsOrbitControls } from 'angular-three-soba/controls'; @@ -7,8 +7,6 @@ import { NgtsOrbitControls } from 'angular-three-soba/controls'; @Component({ standalone: true, template: ` - - @@ -44,7 +42,7 @@ import { NgtsOrbitControls } from 'angular-three-soba/controls'; schemas: [CUSTOM_ELEMENTS_SCHEMA], changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'experience-basic-rapier' }, - imports: [NgtrPhysics, NgtrRigidBody, NgtrCuboidCollider, NgtArgs, NgtsOrbitControls, NgtsPerspectiveCamera], + imports: [NgtrPhysics, NgtrRigidBody, NgtrCuboidCollider, NgtsOrbitControls, NgtsPerspectiveCamera], }) export class Basic { static [NON_ROOT] = true; diff --git a/apps/kitchen-sink/src/app/rapier/rapier.ts b/apps/kitchen-sink/src/app/rapier/rapier.ts index c262781a..d5330ccf 100644 --- a/apps/kitchen-sink/src/app/rapier/rapier.ts +++ b/apps/kitchen-sink/src/app/rapier/rapier.ts @@ -10,7 +10,7 @@ extend(THREE); @Component({ standalone: true, template: ` -
+
diff --git a/apps/kitchen-sink/src/app/rapier/wrapper-default.ts b/apps/kitchen-sink/src/app/rapier/wrapper-default.ts index 496de01f..81467847 100644 --- a/apps/kitchen-sink/src/app/rapier/wrapper-default.ts +++ b/apps/kitchen-sink/src/app/rapier/wrapper-default.ts @@ -1,6 +1,5 @@ import { NgComponentOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core'; -import { NgtArgs } from 'angular-three'; import { NgtrPhysics, NgtrRigidBody } from 'angular-three-rapier'; import { NgtsOrbitControls } from 'angular-three-soba/controls'; import { NgtsEnvironment } from 'angular-three-soba/staging'; @@ -42,8 +41,6 @@ export class Floor {} @if (scene() === 'basic') { } @else { - - @@ -65,7 +62,7 @@ export class Floor {} `, schemas: [CUSTOM_ELEMENTS_SCHEMA], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgtrPhysics, NgtsEnvironment, NgtsOrbitControls, NgComponentOutlet, Floor, NgtArgs], + imports: [NgtrPhysics, NgtsEnvironment, NgtsOrbitControls, NgComponentOutlet, Floor], host: { class: 'rapier-wrapper-default' }, }) export class RapierWrapperDefault { From e800b7a5b50796b585c72fc33c3051b82ffdfb03 Mon Sep 17 00:00:00 2001 From: nartc Date: Tue, 10 Sep 2024 23:01:43 -0500 Subject: [PATCH 09/16] fix(core): adjust instance store again and notify ancestors on specific type --- libs/core/src/lib/instance.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/libs/core/src/lib/instance.ts b/libs/core/src/lib/instance.ts index 0a46fa15..c8fca570 100644 --- a/libs/core/src/lib/instance.ts +++ b/libs/core/src/lib/instance.ts @@ -46,8 +46,8 @@ export function prepare( handlers: {}, instanceStore, parent: instanceStore.select('parent'), - objects: instanceStore.select('objects', { equal: (a, b) => a.length === b.length }), - nonObjects: instanceStore.select('nonObjects', { equal: (a, b) => a.length === b.length }), + objects: instanceStore.select('objects'), + nonObjects: instanceStore.select('nonObjects'), add(object, type) { const current = instance.__ngt__.instanceStore.snapshot[type]; const foundIndex = current.indexOf((node: NgtInstanceNode) => object === node); @@ -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); } From 737eb49f3cddfe3f8e9c1d17e31df39cf2758030 Mon Sep 17 00:00:00 2001 From: nartc Date: Tue, 10 Sep 2024 23:01:50 -0500 Subject: [PATCH 10/16] feat(rapier): add joints --- libs/rapier/src/index.ts | 1 + libs/rapier/src/lib/joints.ts | 178 ++++++++++++++++++++++++++++++++++ libs/rapier/src/lib/types.ts | 43 +++++++- libs/rapier/src/lib/utils.ts | 25 ++++- 4 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 libs/rapier/src/lib/joints.ts diff --git a/libs/rapier/src/index.ts b/libs/rapier/src/index.ts index 8dd83eb1..f6a00663 100644 --- a/libs/rapier/src/index.ts +++ b/libs/rapier/src/index.ts @@ -1,5 +1,6 @@ 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'; diff --git a/libs/rapier/src/lib/joints.ts b/libs/rapier/src/lib/joints.ts new file mode 100644 index 00000000..28ec6c2a --- /dev/null +++ b/libs/rapier/src/lib/joints.ts @@ -0,0 +1,178 @@ +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 ( + bodyA: ElementRef | RigidBody | (() => ElementRef | RigidBody | undefined | null), + bodyB: ElementRef | RigidBody | (() => ElementRef | RigidBody | undefined | null), + { injector, data }: { injector?: Injector; data: TJointParams }, + ) => { + 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) => + rapier.JointData.spring( + data.restLength, + data.stiffness, + data.damping, + vector3ToRapierVector(data.body1Anchor), + vector3ToRapierVector(data.body2Anchor), + ), +); diff --git a/libs/rapier/src/lib/types.ts b/libs/rapier/src/lib/types.ts index 15c6c28f..031b996c 100644 --- a/libs/rapier/src/lib/types.ts +++ b/libs/rapier/src/lib/types.ts @@ -11,7 +11,7 @@ import { Vector, World, } from '@dimforge/rapier3d-compat'; -import { NgtObject3D } from 'angular-three'; +import { NgtObject3D, NgtQuaternion, NgtVector3 } from 'angular-three'; import { Matrix4, Object3D, Vector3, Vector3Tuple } from 'three'; export type NgtrRigidBodyAutoCollider = 'ball' | 'cuboid' | 'hull' | 'trimesh' | false; @@ -555,3 +555,44 @@ export type NgtrRoundConeArgs = [halfHeight: number, radius: number, borderRadiu 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 index e4566da7..82537206 100644 --- a/libs/rapier/src/lib/utils.ts +++ b/libs/rapier/src/lib/utils.ts @@ -1,6 +1,6 @@ import { OutputEmitterRef } from '@angular/core'; -import { Quaternion } from '@dimforge/rapier3d-compat'; -import { NgtEuler, NgtVector3 } from 'angular-three'; +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'; @@ -58,10 +58,29 @@ export const createSingletonProxy = < return { proxy, reset, set }; }; -export function rapierQuaternionToQuaternion({ x, y, z, w }: Quaternion) { +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); From 1fd78ad213c5bb76c5d8cfbbad58ee4c276975fc Mon Sep 17 00:00:00 2001 From: nartc Date: Tue, 10 Sep 2024 23:01:57 -0500 Subject: [PATCH 11/16] docs: more rapier examples --- .../src/app/rapier/cluster/cluster.ts | 76 ++++++++++ apps/kitchen-sink/src/app/rapier/constants.ts | 4 + .../src/app/rapier/joints/joints.ts | 143 ++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 apps/kitchen-sink/src/app/rapier/cluster/cluster.ts create mode 100644 apps/kitchen-sink/src/app/rapier/joints/joints.ts diff --git a/apps/kitchen-sink/src/app/rapier/cluster/cluster.ts b/apps/kitchen-sink/src/app/rapier/cluster/cluster.ts new file mode 100644 index 00000000..2e04e4eb --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/cluster/cluster.ts @@ -0,0 +1,76 @@ +import { + ChangeDetectionStrategy, + Component, + CUSTOM_ELEMENTS_SCHEMA, + effect, + ElementRef, + inject, + viewChild, +} from '@angular/core'; +import { injectBeforeRender, NgtArgs, NON_ROOT } from 'angular-three'; +import { NgtrInstancedRigidBodies, NgtrPhysics } from 'angular-three-rapier'; +import { Color, InstancedMesh, Vector3 } from 'three'; + +const BALLS = 1000; + +@Component({ + standalone: true, + template: ` + + + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'cluster-rapier' }, + imports: [NgtrInstancedRigidBodies, NgtArgs], +}) +export class ClusterExample { + static [NON_ROOT] = true; + + protected readonly BALLS = BALLS; + protected bodies = Array.from({ length: BALLS }, (_, index) => { + return { + key: index, + position: [Math.floor(index / 30), (index % 30) * 0.5, 0] as [number, number, number], + }; + }); + + private rigidBodiesRef = viewChild.required(NgtrInstancedRigidBodies); + private instancedMeshRef = viewChild>('instancedMesh'); + + private physics = inject(NgtrPhysics); + + constructor() { + injectBeforeRender(() => { + const paused = this.physics.paused(); + if (paused) return; + + const rigidBodies = this.rigidBodiesRef().rigidBodyRefs(); + rigidBodies.forEach((body) => { + const rigidBody = body.rigidBody(); + if (rigidBody) { + const { x, y, z } = rigidBody.translation(); + const p = new Vector3(x, y, z); + p.normalize().multiplyScalar(-0.01); + rigidBody.applyImpulse(p, true); + } + }); + }); + + effect(() => { + const instancedMesh = this.instancedMeshRef()?.nativeElement; + if (!instancedMesh) return; + + for (let i = 0; i < BALLS; i++) { + instancedMesh.setColorAt(i, new Color(Math.random() * 0xffffff)); + } + if (instancedMesh.instanceColor) instancedMesh.instanceColor.needsUpdate = true; + }); + } +} diff --git a/apps/kitchen-sink/src/app/rapier/constants.ts b/apps/kitchen-sink/src/app/rapier/constants.ts index 8ef97efb..9402daa9 100644 --- a/apps/kitchen-sink/src/app/rapier/constants.ts +++ b/apps/kitchen-sink/src/app/rapier/constants.ts @@ -1,9 +1,13 @@ import { Basic } from './basic/basic'; +import { ClusterExample } from './cluster/cluster'; import { InstancedMeshExample } from './instanced-mesh/instanced-mesh'; +import { JointsExample } from './joints/joints'; import { PerformanceExample } from './performance/performance'; export const SCENES_MAP = { basic: Basic, instancedMesh: InstancedMeshExample, performance: PerformanceExample, + joints: JointsExample, + cluster: ClusterExample, } as const; diff --git a/apps/kitchen-sink/src/app/rapier/joints/joints.ts b/apps/kitchen-sink/src/app/rapier/joints/joints.ts new file mode 100644 index 00000000..d0b09bcd --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/joints/joints.ts @@ -0,0 +1,143 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + Directive, + input, + viewChild, + viewChildren, +} from '@angular/core'; +import { injectBeforeRender, NgtArgs, NgtVector3, NON_ROOT } from 'angular-three'; +import { injectPrismaticJoint, injectSphericalJoint, NgtrRigidBody, NgtrRigidBodyType } from 'angular-three-rapier'; +import { Quaternion } from 'three'; + +@Component({ + selector: 'app-rope-segment', + standalone: true, + imports: [NgtrRigidBody], + template: ` + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RopeSegment { + type = input.required(); + position = input([0, 0, 0]); + + rigidBodyRef = viewChild.required(NgtrRigidBody); +} + +@Directive({ selector: 'ng-container[ropeJoint]', standalone: true }) +export class RopeJoint { + bodyA = input.required(); + bodyB = input.required(); + + constructor() { + const bodyA = computed(() => this.bodyA().rigidBody()); + const bodyB = computed(() => this.bodyB().rigidBody()); + injectSphericalJoint(bodyA, bodyB, { data: { body1Anchor: [-0.5, 0, 0], body2Anchor: [0.5, 0, 0] } }); + } +} + +@Component({ + selector: 'app-rope', + standalone: true, + template: ` + + @for (i of count(); track $index) { + + + + + + + + } + + @for (segment of ropeSegments(); track $index) { + @if (!$first) { + + } + } + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RopeSegment, NgtArgs, RopeJoint, NgtrRigidBody], +}) +export class Rope { + length = input.required(); + protected count = computed(() => Array.from({ length: this.length() })); + protected ropeSegments = viewChildren(RopeSegment); + + constructor() { + injectBeforeRender(() => { + const now = performance.now(); + const ropeSegments = this.ropeSegments(); + const firstRope = ropeSegments[0]?.rigidBodyRef()?.rigidBody(); + + if (firstRope) { + firstRope.setNextKinematicRotation(new Quaternion(0, Math.sin(now / 500) * 3, 0)); + } + }); + } +} + +@Component({ + selector: 'app-prismatic', + standalone: true, + template: ` + + + + + + + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody], +}) +export class Prismatic { + bodyA = viewChild.required('bodyA', { read: NgtrRigidBody }); + bodyB = viewChild.required('bodyB', { read: NgtrRigidBody }); + + constructor() { + const bodyA = computed(() => this.bodyA().rigidBody()); + const bodyB = computed(() => this.bodyB().rigidBody()); + injectPrismaticJoint(bodyA, bodyB, { + data: { body1Anchor: [-4, 0, 0], body2Anchor: [0, 4, 0], axis: [1, 0, 0], limits: [-2, 2] }, + }); + } +} + +@Component({ + standalone: true, + template: ` + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'joints-rapier' }, + imports: [Rope, Prismatic], +}) +export class JointsExample { + static [NON_ROOT] = true; +} From 0ac030174aa6c0dd90b095f1bad06ddd4debca81 Mon Sep 17 00:00:00 2001 From: nartc Date: Wed, 11 Sep 2024 08:53:43 -0500 Subject: [PATCH 12/16] docs: adjust joints example --- apps/kitchen-sink/src/app/rapier/joints/joints.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/kitchen-sink/src/app/rapier/joints/joints.ts b/apps/kitchen-sink/src/app/rapier/joints/joints.ts index d0b09bcd..8e5641ed 100644 --- a/apps/kitchen-sink/src/app/rapier/joints/joints.ts +++ b/apps/kitchen-sink/src/app/rapier/joints/joints.ts @@ -10,7 +10,7 @@ import { } from '@angular/core'; import { injectBeforeRender, NgtArgs, NgtVector3, NON_ROOT } from 'angular-three'; import { injectPrismaticJoint, injectSphericalJoint, NgtrRigidBody, NgtrRigidBodyType } from 'angular-three-rapier'; -import { Quaternion } from 'three'; +import { Quaternion, Vector3 } from 'three'; @Component({ selector: 'app-rope-segment', @@ -79,13 +79,20 @@ export class Rope { protected ropeSegments = viewChildren(RopeSegment); constructor() { + const q = new Quaternion(); + const v = new Vector3(); + injectBeforeRender(() => { const now = performance.now(); const ropeSegments = this.ropeSegments(); const firstRope = ropeSegments[0]?.rigidBodyRef()?.rigidBody(); if (firstRope) { - firstRope.setNextKinematicRotation(new Quaternion(0, Math.sin(now / 500) * 3, 0)); + q.set(0, Math.sin(now / 500) * 3, 0, q.w); + v.set(0, Math.sin(now / 500) * 3, 0); + + firstRope.setNextKinematicRotation(q); + firstRope.setNextKinematicTranslation(v); } }); } From 32db453e5bbc0159e15f6d4fbb1c5e6a4d8bccd7 Mon Sep 17 00:00:00 2001 From: nartc Date: Wed, 11 Sep 2024 11:55:46 -0500 Subject: [PATCH 13/16] fix(rapier): colliders setting incorrect shape --- libs/rapier/src/lib/colliders.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/libs/rapier/src/lib/colliders.ts b/libs/rapier/src/lib/colliders.ts index 75776e95..293592cb 100644 --- a/libs/rapier/src/lib/colliders.ts +++ b/libs/rapier/src/lib/colliders.ts @@ -54,7 +54,7 @@ export class NgtrCapsuleCollider { constructor() { const anyCollider = inject(NgtrAnyCollider, { host: true }); - anyCollider.setShape('roundConvexMesh'); + anyCollider.setShape('capsule'); effect(() => { const args = this.args(); untracked(() => { @@ -74,7 +74,7 @@ export class NgtrBallCollider { constructor() { const anyCollider = inject(NgtrAnyCollider, { host: true }); - anyCollider.setShape('roundConvexMesh'); + anyCollider.setShape('ball'); effect(() => { const args = this.args(); untracked(() => { @@ -94,7 +94,7 @@ export class NgtrConvexHullCollider { constructor() { const anyCollider = inject(NgtrAnyCollider, { host: true }); - anyCollider.setShape('roundConvexMesh'); + anyCollider.setShape('roundConvexHull'); effect(() => { const args = this.args(); untracked(() => { @@ -114,7 +114,7 @@ export class NgtrHeightfieldCollider { constructor() { const anyCollider = inject(NgtrAnyCollider, { host: true }); - anyCollider.setShape('roundConvexMesh'); + anyCollider.setShape('heightfield'); effect(() => { const args = this.args(); untracked(() => { @@ -134,7 +134,7 @@ export class NgtrTrimeshCollider { constructor() { const anyCollider = inject(NgtrAnyCollider, { host: true }); - anyCollider.setShape('roundConvexMesh'); + anyCollider.setShape('trimesh'); effect(() => { const args = this.args(); untracked(() => { @@ -154,7 +154,7 @@ export class NgtrPolylineCollider { constructor() { const anyCollider = inject(NgtrAnyCollider, { host: true }); - anyCollider.setShape('roundConvexMesh'); + anyCollider.setShape('polyline'); effect(() => { const args = this.args(); untracked(() => { @@ -174,7 +174,7 @@ export class NgtrRoundCuboidCollider { constructor() { const anyCollider = inject(NgtrAnyCollider, { host: true }); - anyCollider.setShape('roundConvexMesh'); + anyCollider.setShape('roundCuboid'); effect(() => { const args = this.args(); untracked(() => { @@ -194,7 +194,7 @@ export class NgtrCylinderCollider { constructor() { const anyCollider = inject(NgtrAnyCollider, { host: true }); - anyCollider.setShape('roundConvexMesh'); + anyCollider.setShape('cylinder'); effect(() => { const args = this.args(); untracked(() => { @@ -214,7 +214,7 @@ export class NgtrRoundCylinderCollider { constructor() { const anyCollider = inject(NgtrAnyCollider, { host: true }); - anyCollider.setShape('roundConvexMesh'); + anyCollider.setShape('roundCylinder'); effect(() => { const args = this.args(); untracked(() => { @@ -234,7 +234,7 @@ export class NgtrConeCollider { constructor() { const anyCollider = inject(NgtrAnyCollider, { host: true }); - anyCollider.setShape('roundConvexMesh'); + anyCollider.setShape('cone'); effect(() => { const args = this.args(); untracked(() => { @@ -254,7 +254,7 @@ export class NgtrRoundConeCollider { constructor() { const anyCollider = inject(NgtrAnyCollider, { host: true }); - anyCollider.setShape('roundConvexMesh'); + anyCollider.setShape('roundCone'); effect(() => { const args = this.args(); untracked(() => { @@ -274,7 +274,7 @@ export class NgtrConvexMeshCollider { constructor() { const anyCollider = inject(NgtrAnyCollider, { host: true }); - anyCollider.setShape('roundConvexMesh'); + anyCollider.setShape('convexMesh'); effect(() => { const args = this.args(); untracked(() => { @@ -294,7 +294,7 @@ export class NgtrRoundConvexHullCollider { constructor() { const anyCollider = inject(NgtrAnyCollider, { host: true }); - anyCollider.setShape('roundConvexMesh'); + anyCollider.setShape('roundConvexHull'); effect(() => { const args = this.args(); untracked(() => { From 2f847686daa41edc93de0094e4007e4166eacbb0 Mon Sep 17 00:00:00 2001 From: nartc Date: Wed, 11 Sep 2024 11:55:58 -0500 Subject: [PATCH 14/16] fix(rapier): call inner createJoint with assertInjector --- libs/rapier/src/lib/joints.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/libs/rapier/src/lib/joints.ts b/libs/rapier/src/lib/joints.ts index 28ec6c2a..a474d10d 100644 --- a/libs/rapier/src/lib/joints.ts +++ b/libs/rapier/src/lib/joints.ts @@ -66,20 +66,22 @@ function injectImpulseJoint( function createJoint( jointDataFn: (rapier: NonNullable>, data: TJointParams) => JointData, ) { - return ( + return function _injectJoint( bodyA: ElementRef | RigidBody | (() => ElementRef | RigidBody | undefined | null), bodyB: ElementRef | RigidBody | (() => ElementRef | RigidBody | undefined | null), { injector, data }: { injector?: Injector; data: TJointParams }, - ) => { - const physics = inject(NgtrPhysics); + ) { + return assertInjector(_injectJoint, injector, () => { + const physics = inject(NgtrPhysics); + + const jointData = computed(() => { + const rapier = physics.rapier(); + if (!rapier) return null; + return jointDataFn(rapier, data); + }); - const jointData = computed(() => { - const rapier = physics.rapier(); - if (!rapier) return null; - return jointDataFn(rapier, data); + return injectImpulseJoint(bodyA, bodyB, { injector, data: jointData }); }); - - return injectImpulseJoint(bodyA, bodyB, { injector, data: jointData }); }; } @@ -167,12 +169,12 @@ export const injectRopeJoint = createJoint((rapier, data) => - rapier.JointData.spring( +export const injectSpringJoint = createJoint((rapier, data) => { + return rapier.JointData.spring( data.restLength, data.stiffness, data.damping, vector3ToRapierVector(data.body1Anchor), vector3ToRapierVector(data.body2Anchor), - ), -); + ); +}); From fedd7ad9bb8f0c6994bc59a7cd8656adffbce3a7 Mon Sep 17 00:00:00 2001 From: nartc Date: Wed, 11 Sep 2024 11:56:07 -0500 Subject: [PATCH 15/16] fix(rapier): update mass properties on collider properly --- libs/rapier/src/lib/rigid-body.ts | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/libs/rapier/src/lib/rigid-body.ts b/libs/rapier/src/lib/rigid-body.ts index 75aa8ed7..42c0db23 100644 --- a/libs/rapier/src/lib/rigid-body.ts +++ b/libs/rapier/src/lib/rigid-body.ts @@ -77,6 +77,9 @@ export class NgtrAnyCollider { 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); @@ -139,6 +142,7 @@ export class NgtrAnyCollider { effect(() => { this.updateColliderEffect(); + this.updateMassPropertiesEffect(); }); } @@ -294,6 +298,40 @@ export class NgtrAnyCollider { 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, @@ -335,6 +373,7 @@ export const rigidBodyDefaultOptions: NgtrRigidBodyOptions = { @Component({ selector: 'ngt-object3D[ngtrRigidBody]', + exportAs: 'rigidBody', standalone: true, template: ` From 9ce74dff1336a972723c01ae672a72778eb9690e Mon Sep 17 00:00:00 2001 From: nartc Date: Wed, 11 Sep 2024 11:56:16 -0500 Subject: [PATCH 16/16] docs: more rapier examples (I think we're ready) --- apps/kitchen-sink/src/app/rapier/constants.ts | 4 + .../src/app/rapier/rope-joint/rope-joint.ts | 132 ++++++++++++++++++ .../src/app/rapier/spring/spring.ts | 125 +++++++++++++++++ pnpm-lock.yaml | 17 ++- 4 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 apps/kitchen-sink/src/app/rapier/rope-joint/rope-joint.ts create mode 100644 apps/kitchen-sink/src/app/rapier/spring/spring.ts diff --git a/apps/kitchen-sink/src/app/rapier/constants.ts b/apps/kitchen-sink/src/app/rapier/constants.ts index 9402daa9..c43b79bc 100644 --- a/apps/kitchen-sink/src/app/rapier/constants.ts +++ b/apps/kitchen-sink/src/app/rapier/constants.ts @@ -3,6 +3,8 @@ import { ClusterExample } from './cluster/cluster'; import { InstancedMeshExample } from './instanced-mesh/instanced-mesh'; import { JointsExample } from './joints/joints'; import { PerformanceExample } from './performance/performance'; +import { RopeJointExample } from './rope-joint/rope-joint'; +import { SpringExample } from './spring/spring'; export const SCENES_MAP = { basic: Basic, @@ -10,4 +12,6 @@ export const SCENES_MAP = { performance: PerformanceExample, joints: JointsExample, cluster: ClusterExample, + ropeJoint: RopeJointExample, + spring: SpringExample, } as const; diff --git a/apps/kitchen-sink/src/app/rapier/rope-joint/rope-joint.ts b/apps/kitchen-sink/src/app/rapier/rope-joint/rope-joint.ts new file mode 100644 index 00000000..3abded6b --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/rope-joint/rope-joint.ts @@ -0,0 +1,132 @@ +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + inject, + Injector, + input, + viewChild, +} from '@angular/core'; +import { NgtArgs, NgtVector3, NON_ROOT } from 'angular-three'; +import { injectRopeJoint, NgtrBallCollider, NgtrRigidBody } from 'angular-three-rapier'; + +const WALL_COLORS = ['#50514F', '#CBD4C2', '#FFFCFF', '#247BA0', '#C3B299']; + +@Component({ + selector: 'app-floor', + standalone: true, + template: ` + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody, NgtArgs], +}) +export class Floor {} + +@Component({ + selector: 'app-box-wall', + standalone: true, + template: ` + + @for (row of rows(); track row) { + @for (column of columns(); track column) { + + + + + + + } + } + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody], +}) +export class BoxWall { + protected readonly WALL_COLORS = WALL_COLORS; + + height = input.required(); + width = input.required(); + + protected rows = computed(() => Array.from({ length: this.height() }, (_, i) => i)); + protected columns = computed(() => Array.from({ length: this.width() }, (_, i) => i)); +} + +@Component({ + selector: 'app-rope-joint', + standalone: true, + template: ` + + + + + + + + + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody, NgtArgs, NgtrBallCollider], +}) +export class RopeJoint { + length = input.required(); + anchorPosition = input.required(); + ballPosition = input.required(); + + private anchorBody = viewChild.required('anchor', { read: NgtrRigidBody }); + private ballBody = viewChild.required('ball', { read: NgtrRigidBody }); + + constructor() { + const injector = inject(Injector); + + afterNextRender(() => { + const anchorBody = computed(() => this.anchorBody().rigidBody()); + const ballBody = computed(() => this.ballBody().rigidBody()); + + injectRopeJoint(anchorBody, ballBody, { + injector, + data: { body1Anchor: [0, 0, 0], body2Anchor: [0, 0, 0], length: this.length() }, + }); + }); + } +} + +@Component({ + standalone: true, + template: ` + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'rope-joint-rapier' }, + imports: [Floor, BoxWall, RopeJoint], +}) +export class RopeJointExample { + static [NON_ROOT] = true; +} diff --git a/apps/kitchen-sink/src/app/rapier/spring/spring.ts b/apps/kitchen-sink/src/app/rapier/spring/spring.ts new file mode 100644 index 00000000..29049a25 --- /dev/null +++ b/apps/kitchen-sink/src/app/rapier/spring/spring.ts @@ -0,0 +1,125 @@ +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + CUSTOM_ELEMENTS_SCHEMA, + inject, + Injector, + input, + viewChild, +} from '@angular/core'; +import { NgtArgs, NgtVector3, NON_ROOT, vector3 } from 'angular-three'; +import { injectSpringJoint, NgtrBallCollider, NgtrRigidBody } from 'angular-three-rapier'; +import { ColorRepresentation } from 'three'; + +@Component({ + selector: 'app-box', + standalone: true, + template: ` + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody], +}) +export class Box { + position = input([0, 0, 0]); + color = input('white'); +} + +@Component({ + selector: 'app-ball-spring', + standalone: true, + template: ` + + + + + + + + + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgtrRigidBody, NgtArgs, NgtrBallCollider], +}) +export class BallSpring { + floorRigidBody = input.required(); + position = input.required(); + jointNum = input.required(); + mass = input(1); + total = input(30); + + private ballBody = viewChild.required(NgtrRigidBody); + private stiffness = 1.0e3; + + constructor() { + const injector = inject(Injector); + + afterNextRender(() => { + const floorBody = computed(() => this.floorRigidBody().rigidBody()); + const ballBody = computed(() => this.ballBody().rigidBody()); + + const criticalDamping = computed(() => 2 * Math.sqrt(this.stiffness * this.mass())); + const dampingRatio = computed(() => this.jointNum() / (this.total() / 2)); + const damping = computed(() => dampingRatio() * criticalDamping()); + const positionVector = vector3(this.position); + + injectSpringJoint(ballBody, floorBody, { + injector, + data: { + body1Anchor: [0, 0, 0], + body2Anchor: [positionVector().x, positionVector().y - 3, positionVector().z], + restLength: 0, + stiffness: this.stiffness, + damping: damping(), + }, + }); + }); + } +} + +@Component({ + standalone: true, + template: ` + + + @for (ballPosition of balls; track $index) { + + + + + } + `, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'spring-rapier' }, + imports: [NgtrRigidBody, BallSpring, Box], +}) +export class SpringExample { + static [NON_ROOT] = true; + + protected readonly COLORS_ARR = ['#335C67', '#FFF3B0', '#E09F3E', '#9E2A2B', '#540B0E']; + protected balls = Array.from({ length: 30 }, (_, i) => [-20 + 1.5 * (i + 1), 7.5, -30] as const); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5cb0adc..52675467 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16682,6 +16682,21 @@ snapshots: - typescript - verdaccio + '@nrwl/js@19.6.4(@babel/traverse@7.25.3)(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12))(@types/node@20.14.12)(nx@19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12)))(typescript@5.4.5)': + dependencies: + '@nx/js': 19.6.4(@babel/traverse@7.25.3)(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12))(@types/node@20.14.12)(nx@19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12)))(typescript@5.4.5) + transitivePeerDependencies: + - '@babel/traverse' + - '@swc-node/register' + - '@swc/core' + - '@swc/wasm' + - '@types/node' + - debug + - nx + - supports-color + - typescript + - verdaccio + '@nrwl/js@19.6.4(@babel/traverse@7.25.3)(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12))(@types/node@20.14.12)(nx@19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12)))(typescript@5.5.4)': dependencies: '@nx/js': 19.6.4(@babel/traverse@7.25.3)(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12))(@types/node@20.14.12)(nx@19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12)))(typescript@5.5.4) @@ -17270,7 +17285,7 @@ snapshots: '@babel/preset-env': 7.25.3(@babel/core@7.25.2) '@babel/preset-typescript': 7.24.7(@babel/core@7.25.2) '@babel/runtime': 7.25.0 - '@nrwl/js': 19.6.4(@babel/traverse@7.25.3)(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12))(@types/node@20.14.12)(nx@19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12)))(typescript@5.5.4) + '@nrwl/js': 19.6.4(@babel/traverse@7.25.3)(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12))(@types/node@20.14.12)(nx@19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12)))(typescript@5.4.5) '@nx/devkit': 19.6.4(nx@19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12))) '@nx/workspace': 19.6.4(@swc-node/register@1.10.9(@swc/core@1.7.11(@swc/helpers@0.5.12))(@swc/types@0.1.12)(typescript@5.5.4))(@swc/core@1.7.11(@swc/helpers@0.5.12)) babel-plugin-const-enum: 1.2.0(@babel/core@7.25.2)