Skip to content

Commit f2f297b

Browse files
committed
feat(physics): add contact matrix assembly
1 parent 24b9332 commit f2f297b

9 files changed

Lines changed: 277 additions & 2 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@
132132
- [ ] Linear solver pipeline (PCG with 3x3 block-Jacobi preconditioner)
133133
- **Contact and friction infrastructure**
134134
- [x] Friction potential tied to contact force magnitude
135-
- [ ] Matrix assembly with cached contact index tables
135+
- [x] Matrix assembly with cached contact index tables
136136
- [ ] Gap evaluators for point/triangle, edge/edge, and wall constraints
137137
- [ ] SPD enforcement pass for elasticity Hessian blocks
138138

docs/index.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export const examples: {
124124
readonly createWallBarrier: 'examples/foldWallBarrier.ts';
125125
readonly createStrainBarrier: 'examples/foldStrainBarrier.ts';
126126
readonly createFrictionPotential: 'examples/foldFriction.ts';
127+
readonly assembleContactMatrix: 'examples/foldMatrixAssembly.ts';
127128
};
128129
readonly performance: {
129130
readonly debounce: 'examples/requestDedup.ts';
@@ -3404,6 +3405,18 @@ export interface FrictionOptions {
34043405
}
34053406
export function createFrictionPotential(options?: FrictionOptions): FoldConstraint;
34063407

3408+
/**
3409+
* Assemble contact matrices with cached index tables.
3410+
* Use for: efficient Fold barrier matrix assembly with deterministic block reuse.
3411+
* Import: physics/fold/matrixAssembly.ts
3412+
*/
3413+
export interface ContactBlock { constraintId: string; matrix: Matrix3x3 }
3414+
export interface ContactAssemblyInput { contactId: string; blocks: ReadonlyArray<ContactBlock> }
3415+
export interface MatrixAssemblyOptions { size?: number; symmetry?: boolean }
3416+
export interface CachedAssembly { readonly contactId: string; readonly baseIndices: number[] }
3417+
export interface MatrixAssemblyResult { matrix: number[][]; cache: CachedAssembly[] }
3418+
export function assembleContactMatrix(entries: ReadonlyArray<ContactAssemblyInput>, options?: MatrixAssemblyOptions): MatrixAssemblyResult;
3419+
34073420
export type FoldConstraintType =
34083421
| 'cubic-barrier'
34093422
| 'contact-barrier'

examples/foldMatrixAssembly.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { assembleContactMatrix } from '../src/index.js';
2+
3+
const result = assembleContactMatrix(
4+
[
5+
{
6+
contactId: 'contact-1',
7+
blocks: [
8+
{
9+
constraintId: 'c1',
10+
matrix: [
11+
[1, 0, 0],
12+
[0, 1, 0],
13+
[0, 0, 1],
14+
],
15+
},
16+
],
17+
},
18+
{
19+
contactId: 'contact-2',
20+
blocks: [
21+
{
22+
constraintId: 'c2',
23+
matrix: [
24+
[2, 0, 0],
25+
[0, 2, 0],
26+
[0, 0, 2],
27+
],
28+
},
29+
],
30+
},
31+
],
32+
{ size: 9 }
33+
);
34+
35+
console.log(result.matrix.length, result.cache.length);

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
{
6363
"name": "bundle",
6464
"path": "dist/index.js",
65-
"limit": "46 KB"
65+
"limit": "48 KB"
6666
}
6767
]
6868
}

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export const examples = {
122122
createWallBarrier: 'examples/foldWallBarrier.ts',
123123
createStrainBarrier: 'examples/foldStrainBarrier.ts',
124124
createFrictionPotential: 'examples/foldFriction.ts',
125+
assembleContactMatrix: 'examples/foldMatrixAssembly.ts',
125126
},
126127
performance: {
127128
debounce: 'examples/requestDedup.ts',
@@ -1203,6 +1204,7 @@ export {
12031204
createWallBarrier,
12041205
createStrainBarrier,
12051206
createFrictionPotential,
1207+
assembleContactMatrix,
12061208
} from './physics/fold/index.js';
12071209

12081210
export type {
@@ -1223,6 +1225,11 @@ export type {
12231225
WallBarrierOptions,
12241226
StrainBarrierOptions,
12251227
FrictionOptions,
1228+
ContactAssemblyInput,
1229+
ContactBlock,
1230+
MatrixAssemblyOptions,
1231+
MatrixAssemblyResult,
1232+
CachedAssembly,
12261233
} from './physics/fold/index.js';
12271234

12281235
// ============================================================================

src/physics/fold/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export * from './pinBarrier.js';
66
export * from './wallBarrier.js';
77
export * from './strainBarrier.js';
88
export * from './frictionPotential.js';
9+
export * from './matrixAssembly.js';

src/physics/fold/matrixAssembly.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import type { Matrix3x3 } from '../../types.js';
2+
3+
export interface ContactBlock {
4+
constraintId: string;
5+
matrix: Matrix3x3;
6+
}
7+
8+
export interface ContactAssemblyInput {
9+
contactId: string;
10+
blocks: ReadonlyArray<ContactBlock>;
11+
}
12+
13+
export interface CachedAssembly {
14+
readonly contactId: string;
15+
readonly baseIndices: number[];
16+
}
17+
18+
export interface MatrixAssemblyOptions {
19+
size?: number;
20+
symmetry?: boolean;
21+
}
22+
23+
export interface MatrixAssemblyResult {
24+
matrix: number[][];
25+
cache: CachedAssembly[];
26+
}
27+
28+
export function assembleContactMatrix(
29+
entries: ReadonlyArray<ContactAssemblyInput>,
30+
options: MatrixAssemblyOptions = {}
31+
): MatrixAssemblyResult {
32+
const allocator = new IndexAllocator(options.size);
33+
const symmetry = options.symmetry ?? true;
34+
const cache: CachedAssembly[] = [];
35+
36+
for (const entry of entries) {
37+
if (!entry || typeof entry.contactId !== 'string') {
38+
continue;
39+
}
40+
41+
const baseIndices: number[] = [];
42+
for (const block of entry.blocks ?? []) {
43+
const baseIndex = allocator.allocate(block);
44+
baseIndices.push(baseIndex);
45+
insertBlock(allocator.matrix, block.matrix, baseIndex, symmetry);
46+
}
47+
48+
cache.push({ contactId: entry.contactId, baseIndices });
49+
}
50+
51+
const matrix = allocator.finalise();
52+
return { matrix, cache };
53+
}
54+
55+
class IndexAllocator {
56+
public readonly matrix: number[][];
57+
private readonly assignments = new Map<string, { index: number; size: number }>();
58+
private cursor = 0;
59+
private readonly size: number;
60+
61+
constructor(sizeOverride?: number) {
62+
this.size = sizeOverride ?? 0;
63+
this.matrix = createZeroMatrix(this.size);
64+
}
65+
66+
allocate(block: ContactBlock): number {
67+
if (!block || typeof block.constraintId !== 'string') {
68+
throw new TypeError('Each block must have a constraintId.');
69+
}
70+
const existing = this.assignments.get(block.constraintId);
71+
const blockSize = block.matrix.length;
72+
73+
if (existing) {
74+
if (existing.size !== blockSize) {
75+
throw new Error(`Mismatched block size for constraint ${block.constraintId}.`);
76+
}
77+
return existing.index;
78+
}
79+
80+
this.ensureCapacity(this.cursor + blockSize);
81+
const index = this.cursor;
82+
this.assignments.set(block.constraintId, { index, size: blockSize });
83+
this.cursor += blockSize;
84+
return index;
85+
}
86+
87+
private ensureCapacity(required: number): void {
88+
if (required <= this.matrix.length) {
89+
return;
90+
}
91+
const newSize = Math.max(required, this.matrix.length * 2 || required);
92+
resizeMatrix(this.matrix, newSize);
93+
}
94+
95+
finalise(): number[][] {
96+
const targetSize = this.size || this.cursor;
97+
const finalSize = Math.max(targetSize, this.cursor);
98+
resizeMatrix(this.matrix, finalSize);
99+
return this.matrix.slice(0, finalSize).map((row) => row.slice(0, finalSize));
100+
}
101+
}
102+
103+
function insertBlock(matrix: number[][], block: Matrix3x3, baseIndex: number, symmetry: boolean): void {
104+
for (let i = 0; i < block.length; i += 1) {
105+
const row = block[i];
106+
if (!row) continue;
107+
const rowIndex = baseIndex + i;
108+
const targetRow = matrix[rowIndex];
109+
if (!targetRow) continue;
110+
111+
for (let j = 0; j < row.length; j += 1) {
112+
const value = row[j] ?? 0;
113+
if (value === 0) continue;
114+
const colIndex = baseIndex + j;
115+
const targetColRow = matrix[colIndex];
116+
targetRow[colIndex] = (targetRow[colIndex] ?? 0) + value;
117+
if (symmetry && rowIndex !== colIndex && targetColRow) {
118+
targetColRow[rowIndex] = (targetColRow[rowIndex] ?? 0) + value;
119+
}
120+
}
121+
}
122+
}
123+
124+
function createZeroMatrix(size: number): number[][] {
125+
return Array.from({ length: size }, () => Array.from({ length: size }, () => 0));
126+
}
127+
128+
function resizeMatrix(matrix: number[][], newSize: number): void {
129+
const current = matrix.length;
130+
for (let i = 0; i < current; i += 1) {
131+
const row = matrix[i];
132+
row.length = newSize;
133+
for (let j = current; j < newSize; j += 1) {
134+
row[j] = 0;
135+
}
136+
}
137+
for (let i = current; i < newSize; i += 1) {
138+
matrix.push(Array.from({ length: newSize }, () => 0));
139+
}
140+
}

tests/index.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ describe('package entry point', () => {
6565
expect(examples.physics.createWallBarrier).toBe('examples/foldWallBarrier.ts');
6666
expect(examples.physics.createStrainBarrier).toBe('examples/foldStrainBarrier.ts');
6767
expect(examples.physics.createFrictionPotential).toBe('examples/foldFriction.ts');
68+
expect(examples.physics.assembleContactMatrix).toBe('examples/foldMatrixAssembly.ts');
6869
});
6970

7071
it('provides strong typing for example categories and names', () => {
@@ -213,6 +214,7 @@ describe('package entry point', () => {
213214
| 'createWallBarrier'
214215
| 'createStrainBarrier'
215216
| 'createFrictionPotential'
217+
| 'assembleContactMatrix'
216218
>();
217219

218220
expectTypeOf<ExampleName<'ai'>>().toEqualTypeOf<

tests/matrixAssembly.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { assembleContactMatrix } from '../src/physics/fold/matrixAssembly.js';
4+
5+
describe('assembleContactMatrix', () => {
6+
it('allocates sequential blocks and caches indices', () => {
7+
const result = assembleContactMatrix(
8+
[
9+
{
10+
contactId: 'contact-1',
11+
blocks: [
12+
{
13+
constraintId: 'c1',
14+
matrix: [
15+
[1, 0, 0],
16+
[0, 1, 0],
17+
[0, 0, 1],
18+
],
19+
},
20+
{
21+
constraintId: 'c2',
22+
matrix: [
23+
[2, 0, 0],
24+
[0, 2, 0],
25+
[0, 0, 2],
26+
],
27+
},
28+
],
29+
},
30+
],
31+
{ size: 12 }
32+
);
33+
34+
expect(result.matrix.length).toBe(12);
35+
expect(result.cache).toHaveLength(1);
36+
expect(result.cache[0]?.baseIndices).toHaveLength(2);
37+
expect(result.matrix[0]?.[0]).toBe(1);
38+
expect(result.matrix[3]?.[3]).toBe(2);
39+
expect(result.matrix[3]?.[0]).toBe(0);
40+
});
41+
42+
it('reuses cached indices for repeated constraints', () => {
43+
const result = assembleContactMatrix(
44+
[
45+
{
46+
contactId: 'contact-1',
47+
blocks: [
48+
{
49+
constraintId: 'shared',
50+
matrix: [
51+
[1, 0, 0],
52+
[0, 1, 0],
53+
[0, 0, 1],
54+
],
55+
},
56+
],
57+
},
58+
{
59+
contactId: 'contact-2',
60+
blocks: [
61+
{
62+
constraintId: 'shared',
63+
matrix: [
64+
[0.5, 0, 0],
65+
[0, 0.5, 0],
66+
[0, 0, 0.5],
67+
],
68+
},
69+
],
70+
},
71+
]
72+
);
73+
74+
expect(result.matrix.length).toBeGreaterThan(0);
75+
expect(result.cache[0]?.baseIndices[0]).toBe(result.cache[1]?.baseIndices[0]);
76+
});
77+
});

0 commit comments

Comments
 (0)