diff --git a/ROADMAP.md b/ROADMAP.md index d3d86cc..ced63cd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,7 @@ - [x] Contact barrier with extended direction handling - [x] Pin constraint barrier using cubic barrier formulation - [x] Wall constraint barrier for plane collisions - - [ ] Triangle strain-limiting barrier driven by deformation singular values + - [x] Triangle strain-limiting barrier driven by deformation singular values - **Integrator and solver** - [ ] Inexact Newton integrator with beta accumulation - [ ] Constraint-only line search with extended direction scaling diff --git a/docs/index.d.ts b/docs/index.d.ts index 4bee9cc..8b3f231 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -122,6 +122,7 @@ export const examples: { readonly createContactBarrier: 'examples/foldContactBarrier.ts'; readonly createPinBarrier: 'examples/foldPinBarrier.ts'; readonly createWallBarrier: 'examples/foldWallBarrier.ts'; + readonly createStrainBarrier: 'examples/foldStrainBarrier.ts'; }; readonly performance: { readonly debounce: 'examples/requestDedup.ts'; @@ -3376,6 +3377,20 @@ export interface WallBarrierOptions { } export function createWallBarrier(options?: WallBarrierOptions): FoldConstraint; +/** + * Triangle strain-limiting barrier using deformation singular values. + * Use for: preventing excessive stretch/compression in Fold primitives. + * Import: physics/fold/strainBarrier.ts + */ +export interface StrainBarrierOptions { + id?: string; + stiffnessOverride?: number; + maxStretch?: number; + minCompression?: number; + direction?: Vector3D; +} +export function createStrainBarrier(options?: StrainBarrierOptions): FoldConstraint; + export type FoldConstraintType = | 'cubic-barrier' | 'contact-barrier' diff --git a/examples/foldStrainBarrier.ts b/examples/foldStrainBarrier.ts new file mode 100644 index 0000000..c7afd7e --- /dev/null +++ b/examples/foldStrainBarrier.ts @@ -0,0 +1,24 @@ +import { createStrainBarrier } from '../src/index.js'; + +const barrier = createStrainBarrier({ maxStretch: 1.05, minCompression: 0.95 }); + +const evaluation = barrier.evaluate( + { + gap: 0, + maxGap: 0, + stiffness: 0, + direction: { x: 0, y: 0, z: 1 }, + effectiveMass: 0.4, + metadata: { + singularValues: [1.1, 0.98], + hessian: [ + [6, 0, 0], + [0, 6, 0], + [0, 0, 6], + ], + }, + }, + { deltaTime: 1 / 60 } +); + +console.log('strain energy', evaluation.energy); diff --git a/src/index.ts b/src/index.ts index 5a66690..71b97be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -120,6 +120,7 @@ export const examples = { createContactBarrier: 'examples/foldContactBarrier.ts', createPinBarrier: 'examples/foldPinBarrier.ts', createWallBarrier: 'examples/foldWallBarrier.ts', + createStrainBarrier: 'examples/foldStrainBarrier.ts', }, performance: { debounce: 'examples/requestDedup.ts', @@ -1199,6 +1200,7 @@ export { createContactBarrier, createPinBarrier, createWallBarrier, + createStrainBarrier, } from './physics/fold/index.js'; export type { @@ -1217,6 +1219,7 @@ export type { ContactBarrierOptions, PinBarrierOptions, WallBarrierOptions, + StrainBarrierOptions, } from './physics/fold/index.js'; // ============================================================================ diff --git a/src/physics/fold/index.ts b/src/physics/fold/index.ts index 35ca6b6..406e3e3 100644 --- a/src/physics/fold/index.ts +++ b/src/physics/fold/index.ts @@ -4,3 +4,4 @@ export * from './stiffness.js'; export * from './contactBarrier.js'; export * from './pinBarrier.js'; export * from './wallBarrier.js'; +export * from './strainBarrier.js'; diff --git a/src/physics/fold/strainBarrier.ts b/src/physics/fold/strainBarrier.ts new file mode 100644 index 0000000..e9fafdd --- /dev/null +++ b/src/physics/fold/strainBarrier.ts @@ -0,0 +1,119 @@ +import type { Matrix3x3, Vector3D } from '../../types.js'; +import type { + FoldComputationContext, + FoldConstraint, + FoldConstraintEvaluation, + FoldConstraintState, +} from './types.js'; +import { createCubicBarrier } from './cubicBarrier.js'; +import { computeFrozenStiffness } from './stiffness.js'; + +export interface StrainBarrierOptions { + id?: string; + stiffnessOverride?: number; + maxStretch?: number; + minCompression?: number; + direction?: Vector3D; +} + +const ZERO_GRADIENT: Vector3D = { x: 0, y: 0, z: 0 }; +const ZERO_HESSIAN: Matrix3x3 = [ + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], +]; + +const DEFAULT_MAX_STRETCH = 1.1; +const DEFAULT_MIN_COMPRESSION = 0.9; + +export function createStrainBarrier(options: StrainBarrierOptions = {}): FoldConstraint { + const baseBarrier = createCubicBarrier({ + id: options.id, + maxGap: 0, + direction: options.direction, + }); + + return { + type: 'strain-barrier', + id: options.id, + enabled: true, + evaluate(state: FoldConstraintState, context: FoldComputationContext): FoldConstraintEvaluation { + const singularValues = extractSingularValues(state.metadata?.singularValues); + if (singularValues.length === 0) { + return { energy: 0, gradient: ZERO_GRADIENT, hessian: ZERO_HESSIAN }; + } + + const maxStretch = options.maxStretch ?? DEFAULT_MAX_STRETCH; + const minCompression = options.minCompression ?? DEFAULT_MIN_COMPRESSION; + + const violation = computeViolation(singularValues, maxStretch, minCompression); + if (violation <= 0) { + return { energy: 0, gradient: ZERO_GRADIENT, hessian: ZERO_HESSIAN }; + } + + const direction = normalise(options.direction ?? state.direction); + if (!direction) { + return { energy: 0, gradient: ZERO_GRADIENT, hessian: ZERO_HESSIAN }; + } + + const stiffness = options.stiffnessOverride ?? + computeFrozenStiffness( + { + gap: -violation, + effectiveMass: state.effectiveMass ?? 0, + direction, + hessian: (state.metadata?.hessian as Matrix3x3 | undefined) ?? ZERO_HESSIAN, + }, + { min: 0 } + ); + + if (stiffness <= 0) { + return { energy: 0, gradient: ZERO_GRADIENT, hessian: ZERO_HESSIAN }; + } + + const evaluation = baseBarrier.evaluate( + { + ...state, + gap: -violation, + direction, + stiffness, + maxGap: 0, + }, + context + ); + + return evaluation; + }, + }; +} + +function computeViolation(values: ReadonlyArray, maxStretch: number, minCompression: number): number { + let violation = 0; + for (const sigma of values) { + if (!Number.isFinite(sigma)) continue; + if (sigma > maxStretch) { + violation = Math.max(violation, sigma - maxStretch); + } else if (sigma < minCompression) { + violation = Math.max(violation, minCompression - sigma); + } + } + return violation; +} + +function extractSingularValues(values: unknown): number[] { + if (!Array.isArray(values)) { + return []; + } + return values.filter((value): value is number => typeof value === 'number' && Number.isFinite(value)); +} + +function normalise(vector: Vector3D | undefined): Vector3D | null { + if (!vector) return 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, + }; +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 5782fe5..fbc2f96 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -63,6 +63,7 @@ describe('package entry point', () => { expect(examples.physics.createContactBarrier).toBe('examples/foldContactBarrier.ts'); expect(examples.physics.createPinBarrier).toBe('examples/foldPinBarrier.ts'); expect(examples.physics.createWallBarrier).toBe('examples/foldWallBarrier.ts'); + expect(examples.physics.createStrainBarrier).toBe('examples/foldStrainBarrier.ts'); }); it('provides strong typing for example categories and names', () => { @@ -209,6 +210,7 @@ describe('package entry point', () => { | 'createContactBarrier' | 'createPinBarrier' | 'createWallBarrier' + | 'createStrainBarrier' >(); expectTypeOf>().toEqualTypeOf< diff --git a/tests/strainBarrier.test.ts b/tests/strainBarrier.test.ts new file mode 100644 index 0000000..fe9d614 --- /dev/null +++ b/tests/strainBarrier.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { createStrainBarrier } from '../src/physics/fold/strainBarrier.js'; + +describe('strain barrier', () => { + it('returns zero when singular values within limits', () => { + const barrier = createStrainBarrier({ maxStretch: 1.1, minCompression: 0.9 }); + const evaluation = barrier.evaluate( + { + gap: 0, + maxGap: 0, + stiffness: 0, + direction: { x: 0, y: 0, z: 1 }, + effectiveMass: 0.3, + metadata: { singularValues: [1.05, 0.95] }, + }, + { deltaTime: 1 / 120 } + ); + + expect(evaluation.energy).toBe(0); + }); + + it('produces energy when strain exceeds limits', () => { + const barrier = createStrainBarrier({ maxStretch: 1.05, minCompression: 0.95 }); + const evaluation = barrier.evaluate( + { + gap: 0, + maxGap: 0, + stiffness: 0, + direction: { x: 0, y: 0, z: 1 }, + effectiveMass: 0.4, + metadata: { + singularValues: [1.12, 0.98], + hessian: [ + [4, 0, 0], + [0, 4, 0], + [0, 0, 4], + ], + }, + }, + { deltaTime: 1 / 60 } + ); + + expect(evaluation.energy).toBeGreaterThan(0); + expect(evaluation.gradient.z).toBeGreaterThan(0); + }); +});