@code3d/core 0.0.1-alpha.4 → 0.0.1-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +42 -5
  2. package/bld/chunks/{chunk-J77FMYFY.js → chunk-4WLVDYUJ.js} +992 -545
  3. package/bld/chunks/chunk-4WLVDYUJ.js.map +7 -0
  4. package/bld/chunks/{chunk-PAIFBNU4.js → chunk-C3AJ6WNB.js} +2 -2
  5. package/bld/chunks/{chunk-OQLJDJIF.js → chunk-TCMEEKWL.js} +2 -2
  6. package/bld/chunks/{chunk-ZMT6EFHL.js → chunk-VHSPHJ5F.js} +3 -3
  7. package/bld/library/bound-solver.d.ts +29 -5
  8. package/bld/library/bound-solver.d.ts.map +1 -1
  9. package/bld/library/cached.d.ts +5 -3
  10. package/bld/library/cached.d.ts.map +1 -1
  11. package/bld/library/index.d.ts +4 -4
  12. package/bld/library/index.d.ts.map +1 -1
  13. package/bld/library/index.js +17 -3
  14. package/bld/library/kernel-cache.d.ts +6 -3
  15. package/bld/library/kernel-cache.d.ts.map +1 -1
  16. package/bld/library/relation-solver.d.ts +12 -7
  17. package/bld/library/relation-solver.d.ts.map +1 -1
  18. package/bld/library/replicad.js +2 -2
  19. package/bld/library/runtime.d.ts +218 -82
  20. package/bld/library/runtime.d.ts.map +1 -1
  21. package/bld/library/sketch.d.ts +2 -2
  22. package/bld/library/sketch.d.ts.map +1 -1
  23. package/bld/library/topology.d.ts +2 -0
  24. package/bld/library/topology.d.ts.map +1 -1
  25. package/bld/node/index.js +19 -5
  26. package/bld/node/replicad.js +4 -4
  27. package/bld/tooling/index.d.ts +3 -3
  28. package/bld/tooling/index.d.ts.map +1 -1
  29. package/bld/tooling/index.js +18 -10
  30. package/package.json +1 -1
  31. package/src/library/bound-solver.ts +103 -73
  32. package/src/library/cached.ts +29 -8
  33. package/src/library/index.ts +15 -4
  34. package/src/library/kernel-cache.ts +37 -5
  35. package/src/library/relation-solver.ts +194 -110
  36. package/src/library/runtime.ts +1046 -442
  37. package/src/library/sketch.ts +3 -3
  38. package/src/library/topology.ts +13 -2
  39. package/src/tooling/index.ts +15 -7
  40. package/bld/chunks/chunk-J77FMYFY.js.map +0 -7
  41. /package/bld/chunks/{chunk-PAIFBNU4.js.map → chunk-C3AJ6WNB.js.map} +0 -0
  42. /package/bld/chunks/{chunk-OQLJDJIF.js.map → chunk-TCMEEKWL.js.map} +0 -0
  43. /package/bld/chunks/{chunk-ZMT6EFHL.js.map → chunk-VHSPHJ5F.js.map} +0 -0
@@ -1,5 +1,5 @@
1
- export {cached} from './cached.js';
2
- export type {CachedOptions} from './cached.js';
1
+ export {cache} from './cached.js';
2
+ export type {CacheOptions} from './cached.js';
3
3
  export {font, googleFont} from './font.js';
4
4
  export type {Font} from './font.js';
5
5
  export type {GoogleFontOptions} from './google-font.js';
@@ -16,6 +16,13 @@ export type {
16
16
  } from './sketch.js';
17
17
 
18
18
  export {
19
+ offset,
20
+ rotate,
21
+ pivot,
22
+ pivotVertex,
23
+ pivotPoint,
24
+ axisEdge,
25
+ axisLine,
19
26
  arc,
20
27
  bezier,
21
28
  box,
@@ -45,8 +52,12 @@ export type {
45
52
  Anchor,
46
53
  Bound,
47
54
  DirectionalBounds,
48
- ConstraintPivotChain,
49
- ConstraintAroundChain,
55
+ PivotChain,
56
+ PivotRotation,
57
+ AxisRotation,
58
+ AxisChain,
59
+ Transformation,
60
+ Relation,
50
61
  CanonicalElements,
51
62
  Constraint,
52
63
  CurveElements,
@@ -40,6 +40,7 @@ export type KernelValueLifecycle<Value> = Readonly<{
40
40
  }>;
41
41
 
42
42
  type CacheEntry<Value> = Readonly<{
43
+ persistenceEligible: boolean;
43
44
  estimatedBytes: number;
44
45
  signature: string;
45
46
  value: Value;
@@ -49,9 +50,11 @@ type CacheEntry<Value> = Readonly<{
49
50
 
50
51
  export function createComputationCache({
51
52
  maximumBytes = 2 * 1024 ** 3,
53
+ minimumPersistenceMilliseconds = 1,
52
54
  nativeAllocatedBytes,
53
55
  }: {
54
56
  maximumBytes?: number;
57
+ minimumPersistenceMilliseconds?: number;
55
58
  nativeAllocatedBytes: () => number;
56
59
  }) {
57
60
  const entries = new Map<string, CacheEntry<unknown>>();
@@ -71,6 +74,17 @@ export function createComputationCache({
71
74
  const pendingPersistence = new Map<string, () => Uint8Array>();
72
75
  let externalBytes = 0;
73
76
 
77
+ /** Adjust historical retention without invalidating the current model. */
78
+ function setKernelCacheBudget(bytes: number): void {
79
+ maximumBytes = bytes;
80
+ evictHistoricalEntries();
81
+ }
82
+
83
+ /** Apply to future computations without changing existing entries' admission. */
84
+ function setKernelCachePersistenceThreshold(milliseconds: number): void {
85
+ minimumPersistenceMilliseconds = milliseconds;
86
+ }
87
+
74
88
  /** The host accounts for in-flight inputs and all auxiliary native heaps. */
75
89
  function setKernelExternalBytes(bytes: number): void {
76
90
  externalBytes = bytes;
@@ -128,8 +142,10 @@ export function createComputationCache({
128
142
  ): KernelArtifact<Value> {
129
143
  const hit = findKernelOperation(key, lifecycle, codec);
130
144
  if (hit) return hit;
145
+ const started = performance.now();
131
146
  const value = compute();
132
- acceptKernelOperation(key, lifecycle, value, codec);
147
+ const milliseconds = performance.now() - started;
148
+ acceptKernelOperation(key, lifecycle, value, milliseconds, codec);
133
149
  return {id: key.id, value};
134
150
  }
135
151
 
@@ -185,7 +201,9 @@ export function createComputationCache({
185
201
  misses += 1;
186
202
  return undefined;
187
203
  }
188
- cached = retainEntry(key, lifecycle, restored);
204
+ // Existing disk records have already been admitted; reading one must
205
+ // not reinterpret decode time as its original computation cost.
206
+ cached = retainEntry(key, lifecycle, restored, true);
189
207
  persistentHits += 1;
190
208
  persisted.add(id);
191
209
  }
@@ -198,7 +216,8 @@ export function createComputationCache({
198
216
  throw new Error(`Kernel operation cache identity collision: ${id}`);
199
217
  hits += 1;
200
218
  const value = cached.instantiate(cached.value);
201
- if (codec !== false) persist(key, cached.value, codec, true);
219
+ if (cached.persistenceEligible && codec !== false)
220
+ persist(key, cached.value, codec, true);
202
221
  touchEntry(id, cached as CacheEntry<unknown>);
203
222
  return {id, value};
204
223
  }
@@ -208,6 +227,7 @@ export function createComputationCache({
208
227
  key: KernelOperationKey,
209
228
  lifecycle: KernelValueLifecycle<Value>,
210
229
  value: Value,
230
+ milliseconds: number,
211
231
  codec?: CacheCodec<Value> | false,
212
232
  ): void {
213
233
  const existing = entries.get(key.id);
@@ -224,8 +244,14 @@ export function createComputationCache({
224
244
  lifecycle.release(value);
225
245
  throw error;
226
246
  }
227
- const entry = retainEntry(key, lifecycle, retained);
228
- if (codec !== false) persist(key, retained, codec);
247
+ const entry = retainEntry(
248
+ key,
249
+ lifecycle,
250
+ retained,
251
+ milliseconds >= minimumPersistenceMilliseconds,
252
+ );
253
+ if (entry.persistenceEligible && codec !== false)
254
+ persist(key, retained, codec);
229
255
  touchEntry(key.id, entry as CacheEntry<unknown>);
230
256
  }
231
257
 
@@ -282,8 +308,10 @@ export function createComputationCache({
282
308
  key: KernelOperationKey,
283
309
  lifecycle: KernelValueLifecycle<Value>,
284
310
  retained: Value,
311
+ persistenceEligible: boolean,
285
312
  ): CacheEntry<Value> {
286
313
  const entry: CacheEntry<Value> = {
314
+ persistenceEligible,
287
315
  estimatedBytes:
288
316
  256 +
289
317
  estimateRetainedBytes(key.signature) +
@@ -368,6 +396,8 @@ export function createComputationCache({
368
396
  beginKernelOperationEvaluation,
369
397
  clearKernelOperationCache,
370
398
  kernelOperationCacheStats,
399
+ setKernelCacheBudget,
400
+ setKernelCachePersistenceThreshold,
371
401
  setKernelArtifactStore,
372
402
  findKernelOperation,
373
403
  findKernelOperations,
@@ -388,6 +418,8 @@ export const {
388
418
  beginKernelOperationEvaluation,
389
419
  clearKernelOperationCache,
390
420
  kernelOperationCacheStats,
421
+ setKernelCacheBudget,
422
+ setKernelCachePersistenceThreshold,
391
423
  setKernelArtifactStore,
392
424
  findKernelOperation,
393
425
  findKernelOperations,
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  axisRotation,
3
+ externalRotationFrame,
3
4
  solveBodies as solveBoundBodies,
4
5
  type BodyRelation as BoundRelation,
5
- type BodyRotation,
6
+ type BodyAction,
7
+ solveTranslations,
6
8
  } from './bound-solver.js';
7
9
  import {
8
10
  alignmentResidual,
@@ -24,16 +26,14 @@ import {
24
26
  composeTransforms,
25
27
  identityRigidTransform,
26
28
  invertTransform,
27
- origin,
28
29
  rotateVector,
29
30
  rotation,
30
- translation,
31
31
  type Quaternion,
32
32
  type RigidTransform,
33
33
  type Vec3,
34
34
  } from './spatial.js';
35
35
 
36
- export {axisRotation, type BodyRotation};
36
+ export {axisRotation, type BodyAction};
37
37
  type AlignEndpoint = Readonly<{
38
38
  body: number;
39
39
  geometry: AlignmentGeometry;
@@ -44,76 +44,70 @@ type AlignRelation = Readonly<{
44
44
  id: string;
45
45
  source: AlignEndpoint;
46
46
  target: AlignEndpoint;
47
- offset: Vec3 | undefined;
48
- rotations: readonly BodyRotation[];
49
47
  }>;
50
48
  type Relation = (BoundRelation & {kind: 'on'}) | AlignRelation;
51
- export type Body = Readonly<{name: string; relations: readonly Relation[]}>;
49
+ export type Body = Readonly<{
50
+ name: string;
51
+ relations: readonly Relation[];
52
+ initial?: RigidTransform;
53
+ transformations?: readonly BodyAction[];
54
+ }>;
52
55
 
53
- /** Authored actions apply after satisfying the relation, about self or an external axis. */
54
- export function beforeRelation(
56
+ function applyExternalRotation(
55
57
  pose: RigidTransform,
56
- relation: Pick<Relation, 'kind' | 'rotations' | 'offset' | 'target'>,
58
+ action: Exclude<BodyAction, {offset: Vec3} | {local: RigidTransform}>,
59
+ reference: RigidTransform,
60
+ inverse = false,
61
+ ): RigidTransform {
62
+ const {center, quaternion} = externalRotationFrame(
63
+ action,
64
+ pose.quaternion,
65
+ reference.quaternion,
66
+ inverse,
67
+ );
68
+ const point = addVectors(reference.position, center);
69
+ return composeTransforms(
70
+ {quaternion, position: subtract(point, rotateVector(point, quaternion))},
71
+ pose,
72
+ );
73
+ }
74
+
75
+ /** Undo exactly the authored prefix, in reverse call order. */
76
+ export function beforeTransformations(
77
+ pose: RigidTransform,
78
+ relation: {
79
+ actions: readonly BodyAction[];
80
+ },
57
81
  poses: readonly RigidTransform[],
58
- owner: number,
59
82
  ): RigidTransform {
60
- for (const action of [...relation.rotations].reverse())
61
- pose =
62
- 'local' in action
63
- ? composeTransforms(pose, invertTransform(action.local))
64
- : composeTransforms(
65
- axisRotation(
66
- composeTransforms(poses[action.body], action.axis),
67
- -action.angle,
68
- ),
69
- pose,
70
- );
71
- if (relation.kind === 'align' && relation.offset) {
72
- const target =
73
- relation.target.body === owner ? pose : poses[relation.target.body];
74
- const frame = composeTransforms(target, relation.target.transform);
75
- pose = {
76
- ...pose,
77
- position: subtract(
78
- pose.position,
79
- rotateVector(relation.offset, frame.quaternion),
80
- ),
81
- };
83
+ for (const action of [...relation.actions].reverse()) {
84
+ if ('offset' in action) {
85
+ pose = {...pose, position: subtract(pose.position, action.offset)};
86
+ } else
87
+ pose =
88
+ 'local' in action
89
+ ? composeTransforms(pose, invertTransform(action.local))
90
+ : applyExternalRotation(pose, action, poses[action.body], true);
82
91
  }
83
92
  return pose;
84
93
  }
85
94
 
86
- function afterRelation(
95
+ export function afterTransformations(
87
96
  pose: RigidTransform,
88
- relation: Relation,
97
+ relation: {
98
+ actions: readonly BodyAction[];
99
+ },
89
100
  poses: readonly RigidTransform[],
90
- owner: number,
91
101
  ): RigidTransform {
92
- if (relation.kind === 'align' && relation.offset) {
93
- const target =
94
- relation.target.body === owner ? pose : poses[relation.target.body];
95
- pose = {
96
- ...pose,
97
- position: addVectors(
98
- pose.position,
99
- rotateVector(
100
- relation.offset,
101
- composeTransforms(target, relation.target.transform).quaternion,
102
- ),
103
- ),
104
- };
102
+ for (const action of relation.actions) {
103
+ if ('offset' in action) {
104
+ pose = {...pose, position: addVectors(pose.position, action.offset)};
105
+ } else
106
+ pose =
107
+ 'local' in action
108
+ ? composeTransforms(pose, action.local)
109
+ : applyExternalRotation(pose, action, poses[action.body]);
105
110
  }
106
- for (const action of relation.rotations)
107
- pose =
108
- 'local' in action
109
- ? composeTransforms(pose, action.local)
110
- : composeTransforms(
111
- axisRotation(
112
- composeTransforms(poses[action.body], action.axis),
113
- action.angle,
114
- ),
115
- pose,
116
- );
117
111
  return pose;
118
112
  }
119
113
 
@@ -271,8 +265,33 @@ export function solveBodies(
271
265
  if (relation.kind === 'align')
272
266
  validateAlignment(relation.source.geometry, relation.target.geometry);
273
267
  const active = bodies.flatMap((body, i) =>
274
- body.relations.length ? [i] : [],
268
+ body.relations.length || body.transformations?.length ? [i] : [],
275
269
  );
270
+ const unplace = (
271
+ pose: RigidTransform,
272
+ owner: number,
273
+ poses: readonly RigidTransform[],
274
+ ) =>
275
+ beforeTransformations(
276
+ pose,
277
+ {actions: bodies[owner].transformations ?? []},
278
+ poses,
279
+ );
280
+ const place = (
281
+ pose: RigidTransform,
282
+ owner: number,
283
+ poses: readonly RigidTransform[],
284
+ ) =>
285
+ afterTransformations(
286
+ pose,
287
+ {actions: bodies[owner].transformations ?? []},
288
+ poses,
289
+ );
290
+ const baselinePose = (
291
+ pose: RigidTransform,
292
+ poses: readonly RigidTransform[],
293
+ owner: number,
294
+ ) => unplace(pose, owner, poses);
276
295
  const flexible = bodies.map(body =>
277
296
  body.relations.some(r => r.kind === 'align'),
278
297
  );
@@ -287,31 +306,13 @@ export function solveBodies(
287
306
  ]
288
307
  .sort(([a], [b]) => a.localeCompare(b))
289
308
  .map(([, entry]) => entry);
290
- let poses = bodies.map(() => identityRigidTransform);
291
- for (const owner of active) {
292
- const authored =
293
- unique.find(
294
- entry => entry.owner === owner && entry.relation.rotations.length,
295
- )?.relation ??
296
- unique.find(
297
- entry =>
298
- entry.owner === owner &&
299
- entry.relation.kind === 'align' &&
300
- entry.relation.offset,
301
- )?.relation;
302
- if (authored)
303
- poses[owner] = afterRelation(
304
- identityRigidTransform,
305
- authored,
306
- poses,
307
- owner,
308
- );
309
- }
309
+ let poses = bodies.map(body => body.initial ?? identityRigidTransform);
310
+ for (const owner of active) poses[owner] = place(poses[owner], owner, poses);
310
311
  for (const {owner, relation} of unique) {
311
312
  if (relation.kind !== 'align') continue;
312
313
  const sourceSelf = relation.source.body === owner;
313
314
  if (relation.target.body === relation.source.body) continue;
314
- const baseline = beforeRelation(poses[owner], relation, poses, owner);
315
+ const baseline = baselinePose(poses[owner], poses, owner);
315
316
  const source = transformGeometry(
316
317
  relation.source.geometry,
317
318
  relation.source.body === owner ? baseline : poses[relation.source.body],
@@ -323,12 +324,7 @@ export function solveBodies(
323
324
  const seed = sourceSelf
324
325
  ? alignmentSeed(source, target)
325
326
  : alignmentSeed(target, source);
326
- poses[owner] = afterRelation(
327
- composeTransforms(seed, baseline),
328
- relation,
329
- poses,
330
- owner,
331
- );
327
+ poses[owner] = place(composeTransforms(seed, baseline), owner, poses);
332
328
  }
333
329
  const geometryScale = Math.max(
334
330
  1,
@@ -340,12 +336,25 @@ export function solveBodies(
340
336
  : [],
341
337
  ),
342
338
  );
343
- const variables = active.flatMap(body =>
344
- Array.from({length: 6}, (_, axis) => ({body, axis})),
339
+ const fullVariables = active.flatMap(body =>
340
+ // Bound-only bodies without authored rotations have exactly identity
341
+ // orientation. Do not numerically perturb those fixed axes: native bound
342
+ // queries can introduce noise that prevents convergence at the identity.
343
+ Array.from(
344
+ {
345
+ length:
346
+ flexible[body] ||
347
+ bodies[body].transformations?.some(action => !('offset' in action))
348
+ ? 6
349
+ : 3,
350
+ },
351
+ (_, axis) => ({body, axis}),
352
+ ),
345
353
  );
354
+ let variables = fullVariables.filter(variable => variable.axis < 3);
346
355
  const residual = (candidate: readonly RigidTransform[]): number[] => [
347
356
  ...unique.flatMap(({owner, relation: r}) => {
348
- const baseline = beforeRelation(candidate[owner], r, candidate, owner);
357
+ const baseline = baselinePose(candidate[owner], candidate, owner);
349
358
  const source =
350
359
  r.source.body === owner ? baseline : candidate[r.source.body],
351
360
  target = r.target.body === owner ? baseline : candidate[r.target.body];
@@ -369,29 +378,28 @@ export function solveBodies(
369
378
  source.position,
370
379
  rotateVector(center, frame.quaternion),
371
380
  );
372
- const b = composeTransforms(
373
- frame,
374
- translation(r.offset ?? origin),
375
- ).position;
381
+ const b = frame.position;
376
382
  const delta = rotateVector(
377
383
  subtract(a, b),
378
384
  invertTransform(frame).quaternion,
379
385
  );
380
- return r.offset ? [...delta] : [delta[1]];
386
+ return [delta[1]];
381
387
  }),
382
388
  ...active.flatMap(owner => {
383
389
  if (flexible[owner]) return [];
384
- const rotations = bodies[owner].relations.filter(r => r.rotations.length);
385
- return rotations.length
386
- ? rotations.flatMap(r =>
387
- beforeRelation(
388
- candidate[owner],
389
- r,
390
- candidate,
391
- owner,
392
- ).quaternion.slice(0, 3),
393
- )
394
- : candidate[owner].quaternion.slice(0, 3);
390
+ const initial = bodies[owner].initial ?? identityRigidTransform;
391
+ const unplaced = unplace(candidate[owner], owner, candidate);
392
+ const orientationResidual = (pose: RigidTransform) =>
393
+ composeTransforms(
394
+ invertTransform(rotation(initial.quaternion)),
395
+ rotation(pose.quaternion),
396
+ ).quaternion.slice(0, 3);
397
+ return [
398
+ ...(bodies[owner].relations.length
399
+ ? []
400
+ : subtract(unplaced.position, initial.position)),
401
+ ...orientationResidual(unplaced),
402
+ ];
395
403
  }),
396
404
  ];
397
405
  const perturb = (
@@ -420,12 +428,80 @@ export function solveBodies(
420
428
  };
421
429
  });
422
430
  };
431
+ // Restore bound-only bodies' free translations without breaking alignments to
432
+ // dependent bodies. Every active translation participates in the nullspace;
433
+ // only bound-only bodies contribute preferences for their incoming pose.
434
+ const settleBoundTranslations = (): readonly RigidTransform[] => {
435
+ if (!active.some(owner => !flexible[owner])) return poses;
436
+ const free = active.flatMap(body => [0, 1, 2].map(axis => ({body, axis})));
437
+ if (!free.length) return poses;
438
+ const shift = (steps: readonly number[]) => {
439
+ const result = poses.map(pose => ({
440
+ ...pose,
441
+ position: [...pose.position] as [number, number, number],
442
+ }));
443
+ free.forEach(({body, axis}, i) => {
444
+ result[body].position[axis] += steps[i];
445
+ });
446
+ return result;
447
+ };
448
+ const preferences = (candidate: readonly RigidTransform[]) =>
449
+ unique
450
+ .filter(({owner}) => !flexible[owner])
451
+ .flatMap(({owner}) =>
452
+ subtract(
453
+ baselinePose(candidate[owner], candidate, owner).position,
454
+ bodies[owner].initial?.position ?? [0, 0, 0],
455
+ ),
456
+ );
457
+ const h = 1e-5;
458
+ const initial = residual(poses),
459
+ preference = preferences(poses);
460
+ const columns = free.map((_, i) => {
461
+ const plus = shift(free.map((_, j) => (i === j ? h : 0)));
462
+ const minus = shift(free.map((_, j) => (i === j ? -h : 0)));
463
+ const a = residual(plus),
464
+ b = residual(minus),
465
+ p = preferences(plus),
466
+ q = preferences(minus);
467
+ return {
468
+ constraints: a.map((v, j) => (v - b[j]) / (2 * h)),
469
+ preferences: p.map((v, j) => (v - q[j]) / (2 * h)),
470
+ };
471
+ });
472
+ const step = solveTranslations(
473
+ initial.map((value, i) => ({
474
+ id: 'bound translation',
475
+ value: -value,
476
+ coefficients: columns.map(column => column.constraints[i]),
477
+ })),
478
+ preference.map((value, i) => ({
479
+ id: 'pre-action position',
480
+ value: -value,
481
+ coefficients: columns.map(column => column.preferences[i]),
482
+ })),
483
+ free.length,
484
+ );
485
+ const candidate = shift(step);
486
+ // A nonlinear geometric locus need not admit its linearized free step.
487
+ // Preserve the valid geometric solution when that direction is not free.
488
+ return residual(candidate).every(value => Math.abs(value) < 1e-8)
489
+ ? candidate
490
+ : poses;
491
+ };
423
492
  let values = residual(poses),
424
493
  damping = 1e-5;
425
494
  const norm = (values: readonly number[]) =>
426
495
  values.reduce((sum, x) => sum + x * x, 0);
427
- for (let iteration = 0; iteration < 160; iteration++) {
428
- if (values.every(v => Math.abs(v) < 1e-8)) return poses;
496
+ for (let iteration = 0; iteration < 168; iteration++) {
497
+ // First settle translation along the seeded orientations. This prevents
498
+ // unconstrained rotations from tilting a contact chain merely to move it.
499
+ // Relations that genuinely require rotation then use the full rigid solve.
500
+ if (iteration === 8) {
501
+ variables = fullVariables;
502
+ damping = 1e-5;
503
+ }
504
+ if (values.every(v => Math.abs(v) < 1e-8)) return settleBoundTranslations();
429
505
  const h = 1e-6;
430
506
  const columns = variables.map((_, i) => {
431
507
  const shifted = residual(
@@ -434,7 +510,15 @@ export function solveBodies(
434
510
  variables.map((_, j) => (i === j ? h : 0)),
435
511
  ),
436
512
  );
437
- return values.map((v, j) => (shifted[j] - v) / h);
513
+ const opposite = residual(
514
+ perturb(
515
+ poses,
516
+ variables.map((_, j) => (i === j ? -h : 0)),
517
+ ),
518
+ );
519
+ // A directional bound has a cusp at an axis-aligned pose. A forward
520
+ // difference invents a rotation gradient there; use both sides.
521
+ return values.map((_, j) => (shifted[j] - opposite[j]) / (2 * h));
438
522
  });
439
523
  const multiply = (a: readonly number[], b: readonly number[]) =>
440
524
  a.reduce((sum, x, i) => sum + x * b[i], 0);