From c0ca51630bfdd4c7e0441ae05291dfd3f524631e Mon Sep 17 00:00:00 2001 From: Francois Date: Sat, 18 Oct 2025 08:29:01 +0900 Subject: [PATCH] feat(physics): add fold stiffness design helper --- docs/index.d.ts | 19 ++++++ examples/foldStiffness.ts | 14 +++++ src/index.ts | 5 +- src/physics/fold/index.ts | 1 + src/physics/fold/stiffness.ts | 114 ++++++++++++++++++++++++++++++++++ tests/index.test.ts | 2 + tests/stiffness.test.ts | 39 ++++++++++++ 7 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 examples/foldStiffness.ts create mode 100644 src/physics/fold/stiffness.ts create mode 100644 tests/stiffness.test.ts diff --git a/docs/index.d.ts b/docs/index.d.ts index 89a91a0..35d9433 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -118,6 +118,7 @@ export const examples: { readonly physics: { readonly createFoldConstraintRegistry: 'examples/foldSetup.ts'; readonly createCubicBarrier: 'examples/foldCubicBarrier.ts'; + readonly computeFrozenStiffness: 'examples/foldStiffness.ts'; }; readonly performance: { readonly debounce: 'examples/requestDedup.ts'; @@ -3313,6 +3314,24 @@ export interface CubicBarrierOptions { } export function createCubicBarrier(options?: CubicBarrierOptions): FoldConstraint; +/** + * Stiffness design principle helper for frozen Fold barriers. + * Use for: computing \bar\kappa using mass and Hessian terms. + * Import: physics/fold/stiffness.ts + */ +export interface StiffnessDesignInput { + gap: number; + effectiveMass: number; + direction: Vector3D; + hessian: Matrix3x3; +} +export interface StiffnessDesignOptions { + epsilon?: number; + min?: number; + max?: number; +} +export function computeFrozenStiffness(input: StiffnessDesignInput, options?: StiffnessDesignOptions): number; + export type FoldConstraintType = | 'cubic-barrier' | 'contact-barrier' diff --git a/examples/foldStiffness.ts b/examples/foldStiffness.ts new file mode 100644 index 0000000..bec2a21 --- /dev/null +++ b/examples/foldStiffness.ts @@ -0,0 +1,14 @@ +import { computeFrozenStiffness } from '../src/index.js'; + +const stiffness = computeFrozenStiffness({ + gap: -0.02, + effectiveMass: 0.5, + direction: { x: 0, y: 0, z: 1 }, + hessian: [ + [2, 0, 0], + [0, 2, 0], + [0, 0, 2], + ], +}); + +console.log('stiffness', stiffness); diff --git a/src/index.ts b/src/index.ts index f0455a9..7e8d1ac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -116,6 +116,7 @@ export const examples = { physics: { createFoldConstraintRegistry: 'examples/foldSetup.ts', createCubicBarrier: 'examples/foldCubicBarrier.ts', + computeFrozenStiffness: 'examples/foldStiffness.ts', }, performance: { debounce: 'examples/requestDedup.ts', @@ -1188,7 +1189,7 @@ export type { ClosestPairResult } from './geometry/closestPair.js'; // ⚙️ PHYSICS & FOLD BARRIERS // ============================================================================ -export { createFoldConstraintRegistry, createCubicBarrier } from './physics/fold/index.js'; +export { createFoldConstraintRegistry, createCubicBarrier, computeFrozenStiffness } from './physics/fold/index.js'; export type { CubicBarrierOptions, @@ -1201,6 +1202,8 @@ export type { FoldConstraintRegistry, FoldSolverSettings, FoldSystemState, + StiffnessDesignInput, + StiffnessDesignOptions, } from './physics/fold/index.js'; // ============================================================================ diff --git a/src/physics/fold/index.ts b/src/physics/fold/index.ts index 02aa10a..18315f0 100644 --- a/src/physics/fold/index.ts +++ b/src/physics/fold/index.ts @@ -1,2 +1,3 @@ export * from './types.js'; export * from './cubicBarrier.js'; +export * from './stiffness.js'; diff --git a/src/physics/fold/stiffness.ts b/src/physics/fold/stiffness.ts new file mode 100644 index 0000000..393ddd8 --- /dev/null +++ b/src/physics/fold/stiffness.ts @@ -0,0 +1,114 @@ +import type { Matrix3x3, Vector3D } from '../../types.js'; + +const DEFAULT_EPSILON = 1e-8; + +export interface StiffnessDesignInput { + gap: number; + effectiveMass: number; + direction: Vector3D; + hessian: Matrix3x3; +} + +export interface StiffnessDesignOptions { + epsilon?: number; + min?: number; + max?: number; +} + +export function computeFrozenStiffness( + input: StiffnessDesignInput, + options: StiffnessDesignOptions = {} +): number { + validateInput(input); + const epsilon = options.epsilon ?? DEFAULT_EPSILON; + + const gapMagnitude = Math.max(Math.abs(input.gap), epsilon); + const unit = normalise(input.direction); + if (!unit) { + return clamp(input.effectiveMass / (gapMagnitude * gapMagnitude), options.min, options.max); + } + + const massContribution = input.effectiveMass / (gapMagnitude * gapMagnitude); + const projected = projectHessian(unit, input.hessian); + const stiffness = massContribution + projected; + return clamp(stiffness, options.min, options.max); +} + +function validateInput(input: StiffnessDesignInput): void { + if (!isFiniteNumber(input.gap)) { + throw new TypeError('gap must be a finite number.'); + } + if (!isFiniteNumber(input.effectiveMass)) { + throw new TypeError('effectiveMass must be a finite number.'); + } + if (!isVector(input.direction)) { + throw new TypeError('direction must be a finite 3D vector.'); + } + if (!isMatrix(input.hessian)) { + throw new TypeError('hessian must be a 3x3 matrix.'); + } +} + +function projectHessian(direction: Vector3D, hessian: Matrix3x3): number { + const hx = hessian[0]; + const hy = hessian[1]; + const hz = hessian[2]; + if (!hx || !hy || !hz) { + throw new TypeError('hessian must be a 3x3 matrix.'); + } + const vx = direction.x; + const vy = direction.y; + const vz = direction.z; + const hvx = hx[0] * vx + hx[1] * vy + hx[2] * vz; + const hvy = hy[0] * vx + hy[1] * vy + hy[2] * vz; + const hvz = hz[0] * vx + hz[1] * vy + hz[2] * vz; + return hvx * vx + hvy * vy + hvz * vz; +} + +function normalise(vector: Vector3D): Vector3D | null { + const length = Math.hypot(vector.x, vector.y, vector.z); + if (length <= 0) { + return null; + } + return { + x: vector.x / length, + y: vector.y / length, + z: vector.z / length, + }; +} + +function clamp(value: number, min?: number, max?: number): number { + let result = value; + if (typeof min === 'number') { + result = Math.max(result, min); + } + if (typeof max === 'number') { + result = Math.min(result, max); + } + return result; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isVector(vector: Vector3D | undefined): vector is Vector3D { + return ( + typeof vector?.x === 'number' && Number.isFinite(vector.x) && + typeof vector?.y === 'number' && Number.isFinite(vector.y) && + typeof vector?.z === 'number' && Number.isFinite(vector.z) + ); +} + +function isMatrix(matrix: Matrix3x3 | undefined): matrix is Matrix3x3 { + return ( + Array.isArray(matrix) && + matrix.length === 3 && + matrix.every( + (row) => + Array.isArray(row) && + row.length === 3 && + row.every((value) => typeof value === 'number' && Number.isFinite(value)) + ) + ); +} diff --git a/tests/index.test.ts b/tests/index.test.ts index c60ab84..e827470 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -59,6 +59,7 @@ describe('package entry point', () => { expect(examples.spatial.raycastBvh).toBe('examples/bvh.ts'); expect(examples.physics.createFoldConstraintRegistry).toBe('examples/foldSetup.ts'); expect(examples.physics.createCubicBarrier).toBe('examples/foldCubicBarrier.ts'); + expect(examples.physics.computeFrozenStiffness).toBe('examples/foldStiffness.ts'); }); it('provides strong typing for example categories and names', () => { @@ -201,6 +202,7 @@ describe('package entry point', () => { expectTypeOf>().toEqualTypeOf< | 'createFoldConstraintRegistry' | 'createCubicBarrier' + | 'computeFrozenStiffness' >(); expectTypeOf>().toEqualTypeOf< diff --git a/tests/stiffness.test.ts b/tests/stiffness.test.ts new file mode 100644 index 0000000..65e4807 --- /dev/null +++ b/tests/stiffness.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { computeFrozenStiffness } from '../src/physics/fold/stiffness.js'; + +describe('computeFrozenStiffness', () => { + it('matches design principle formula for basic inputs', () => { + const result = computeFrozenStiffness({ + gap: 0.1, + effectiveMass: 2, + direction: { x: 0, y: 0, z: 1 }, + hessian: [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 3], + ], + }); + + const expected = 2 / (0.1 * 0.1) + 3; + expect(result).toBeCloseTo(expected, 6); + }); + + it('clamps values when bounds provided', () => { + const result = computeFrozenStiffness( + { + gap: 0, + effectiveMass: 1, + direction: { x: 0, y: 0, z: 0 }, + hessian: [ + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ], + }, + { max: 1e5 } + ); + + expect(result).toBeLessThanOrEqual(1e5); + }); +});