From e8c91f74e4a09ff85515760d4710aa914ef25ee4 Mon Sep 17 00:00:00 2001 From: Francois Date: Sat, 18 Oct 2025 19:47:40 +0900 Subject: [PATCH] feat(physics): add friction potential --- ROADMAP.md | 2 +- docs/index.d.ts | 13 ++++ examples/foldFriction.ts | 20 ++++++ src/index.ts | 3 + src/physics/fold/frictionPotential.ts | 92 +++++++++++++++++++++++++++ src/physics/fold/index.ts | 1 + tests/frictionPotential.test.ts | 43 +++++++++++++ tests/index.test.ts | 2 + 8 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 examples/foldFriction.ts create mode 100644 src/physics/fold/frictionPotential.ts create mode 100644 tests/frictionPotential.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index ced63cd..70e52a7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,7 +131,7 @@ - [ ] Error-reduction pass leveraging beta-delta time refinement - [ ] Linear solver pipeline (PCG with 3x3 block-Jacobi preconditioner) - **Contact and friction infrastructure** - - [ ] Friction potential tied to contact force magnitude + - [x] Friction potential tied to contact force magnitude - [ ] Matrix assembly with cached contact index tables - [ ] Gap evaluators for point/triangle, edge/edge, and wall constraints - [ ] SPD enforcement pass for elasticity Hessian blocks diff --git a/docs/index.d.ts b/docs/index.d.ts index 8b3f231..630c62f 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -123,6 +123,7 @@ export const examples: { readonly createPinBarrier: 'examples/foldPinBarrier.ts'; readonly createWallBarrier: 'examples/foldWallBarrier.ts'; readonly createStrainBarrier: 'examples/foldStrainBarrier.ts'; + readonly createFrictionPotential: 'examples/foldFriction.ts'; }; readonly performance: { readonly debounce: 'examples/requestDedup.ts'; @@ -3391,6 +3392,18 @@ export interface StrainBarrierOptions { } export function createStrainBarrier(options?: StrainBarrierOptions): FoldConstraint; +/** + * Friction potential tied to contact force magnitude. + * Use for: tangential friction response in Fold contact solver. + * Import: physics/fold/frictionPotential.ts + */ +export interface FrictionOptions { + id?: string; + coefficient?: number; + epsilon?: number; +} +export function createFrictionPotential(options?: FrictionOptions): FoldConstraint; + export type FoldConstraintType = | 'cubic-barrier' | 'contact-barrier' diff --git a/examples/foldFriction.ts b/examples/foldFriction.ts new file mode 100644 index 0000000..5707bd3 --- /dev/null +++ b/examples/foldFriction.ts @@ -0,0 +1,20 @@ +import { createFrictionPotential } from '../src/index.js'; + +const friction = createFrictionPotential({ coefficient: 0.6 }); + +const evaluation = friction.evaluate( + { + gap: 0, + maxGap: 0, + stiffness: 0, + direction: { x: 0, y: 0, z: 1 }, + effectiveMass: 0.1, + metadata: { + contactForce: 5, + tangentDisplacement: { x: 0.02, y: 0.01, z: 0 }, + }, + }, + { deltaTime: 1 / 120 } +); + +console.log('friction energy', evaluation.energy); diff --git a/src/index.ts b/src/index.ts index 71b97be..9a7ee83 100644 --- a/src/index.ts +++ b/src/index.ts @@ -121,6 +121,7 @@ export const examples = { createPinBarrier: 'examples/foldPinBarrier.ts', createWallBarrier: 'examples/foldWallBarrier.ts', createStrainBarrier: 'examples/foldStrainBarrier.ts', + createFrictionPotential: 'examples/foldFriction.ts', }, performance: { debounce: 'examples/requestDedup.ts', @@ -1201,6 +1202,7 @@ export { createPinBarrier, createWallBarrier, createStrainBarrier, + createFrictionPotential, } from './physics/fold/index.js'; export type { @@ -1220,6 +1222,7 @@ export type { PinBarrierOptions, WallBarrierOptions, StrainBarrierOptions, + FrictionOptions, } from './physics/fold/index.js'; // ============================================================================ diff --git a/src/physics/fold/frictionPotential.ts b/src/physics/fold/frictionPotential.ts new file mode 100644 index 0000000..67ed10f --- /dev/null +++ b/src/physics/fold/frictionPotential.ts @@ -0,0 +1,92 @@ +import type { Matrix3x3, Vector3D } from '../../types.js'; +import type { + FoldComputationContext, + FoldConstraint, + FoldConstraintEvaluation, + FoldConstraintState, +} from './types.js'; + +export interface FrictionOptions { + id?: string; + coefficient?: number; + epsilon?: number; +} + +const ZERO_GRADIENT: Vector3D = { x: 0, y: 0, z: 0 }; +const ZERO_HESSIAN: Matrix3x3 = [ + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], +]; + +export function createFrictionPotential(options: FrictionOptions = {}): FoldConstraint { + const coefficient = options.coefficient ?? 0.5; + const epsilon = options.epsilon ?? 1e-6; + + return { + type: 'friction', + id: options.id, + enabled: true, + evaluate(state: FoldConstraintState, context: FoldComputationContext): FoldConstraintEvaluation { + void context; + const contactForce = getContactForce(state.metadata?.contactForce); + const tangent = getVector(state.metadata?.tangentDisplacement); + + if (!tangent || contactForce <= 0 || coefficient <= 0) { + return { energy: 0, gradient: ZERO_GRADIENT, hessian: ZERO_HESSIAN }; + } + + const rawMagnitude = Math.hypot(tangent.x, tangent.y, tangent.z); + if (rawMagnitude <= epsilon) { + return { energy: 0, gradient: ZERO_GRADIENT, hessian: ZERO_HESSIAN }; + } + + const tangentMagnitude = rawMagnitude; + const normalizedTangent = { + x: tangent.x / tangentMagnitude, + y: tangent.y / tangentMagnitude, + z: tangent.z / tangentMagnitude, + }; + + const stiffness = coefficient * contactForce / tangentMagnitude; + const energy = 0.5 * stiffness * tangentMagnitude * tangentMagnitude; + const gradient = scaleVector(normalizedTangent, stiffness * tangentMagnitude); + const hessian = outerProduct(normalizedTangent, stiffness); + + return { energy, gradient, hessian }; + }, + }; +} + +function getContactForce(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +function getVector(value: unknown): Vector3D | null { + if (!value) return null; + const vector = value as Partial; + if ( + typeof vector.x === 'number' && Number.isFinite(vector.x) && + typeof vector.y === 'number' && Number.isFinite(vector.y) && + typeof vector.z === 'number' && Number.isFinite(vector.z) + ) { + return { x: vector.x, y: vector.y, z: vector.z }; + } + return null; +} + +function scaleVector(vector: Vector3D, scalar: number): Vector3D { + return { + x: vector.x * scalar, + y: vector.y * scalar, + z: vector.z * scalar, + }; +} + +function outerProduct(vector: Vector3D, scalar: number): Matrix3x3 { + return [ + [vector.x * vector.x * scalar, vector.x * vector.y * scalar, vector.x * vector.z * scalar], + [vector.y * vector.x * scalar, vector.y * vector.y * scalar, vector.y * vector.z * scalar], + [vector.z * vector.x * scalar, vector.z * vector.y * scalar, vector.z * vector.z * scalar], + ]; +} diff --git a/src/physics/fold/index.ts b/src/physics/fold/index.ts index 406e3e3..24db815 100644 --- a/src/physics/fold/index.ts +++ b/src/physics/fold/index.ts @@ -5,3 +5,4 @@ export * from './contactBarrier.js'; export * from './pinBarrier.js'; export * from './wallBarrier.js'; export * from './strainBarrier.js'; +export * from './frictionPotential.js'; diff --git a/tests/frictionPotential.test.ts b/tests/frictionPotential.test.ts new file mode 100644 index 0000000..ad32f9f --- /dev/null +++ b/tests/frictionPotential.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { createFrictionPotential } from '../src/physics/fold/frictionPotential.js'; + +describe('friction potential', () => { + it('returns zero when no tangential displacement', () => { + const friction = createFrictionPotential({ coefficient: 0.5 }); + const evaluation = friction.evaluate( + { + gap: 0, + maxGap: 0, + stiffness: 0, + direction: { x: 0, y: 0, z: 1 }, + effectiveMass: 0.1, + metadata: { contactForce: 2, tangentDisplacement: { x: 0, y: 0, z: 0 } }, + }, + { deltaTime: 1 } + ); + + expect(evaluation.energy).toBe(0); + }); + + it('produces energy proportional to tangent displacement and force', () => { + const friction = createFrictionPotential({ coefficient: 0.8 }); + const evaluation = friction.evaluate( + { + gap: 0, + maxGap: 0, + stiffness: 0, + direction: { x: 0, y: 0, z: 1 }, + effectiveMass: 0.1, + metadata: { + contactForce: 5, + tangentDisplacement: { x: 0.02, y: 0.01, z: 0 }, + }, + }, + { deltaTime: 1 / 60 } + ); + + expect(evaluation.energy).toBeGreaterThan(0); + expect(evaluation.gradient.x ** 2 + evaluation.gradient.y ** 2).toBeGreaterThan(0); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index fbc2f96..f1b38e9 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -64,6 +64,7 @@ describe('package entry point', () => { expect(examples.physics.createPinBarrier).toBe('examples/foldPinBarrier.ts'); expect(examples.physics.createWallBarrier).toBe('examples/foldWallBarrier.ts'); expect(examples.physics.createStrainBarrier).toBe('examples/foldStrainBarrier.ts'); + expect(examples.physics.createFrictionPotential).toBe('examples/foldFriction.ts'); }); it('provides strong typing for example categories and names', () => { @@ -211,6 +212,7 @@ describe('package entry point', () => { | 'createPinBarrier' | 'createWallBarrier' | 'createStrainBarrier' + | 'createFrictionPotential' >(); expectTypeOf>().toEqualTypeOf<