@flighthq/physics3d 0.4.0-edge.1908.751021c
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.
- package/dist/broadphase.d.ts +4 -0
- package/dist/broadphase.d.ts.map +1 -0
- package/dist/broadphase.js +158 -0
- package/dist/broadphase.js.map +1 -0
- package/dist/colliderCollision.d.ts +5 -0
- package/dist/colliderCollision.d.ts.map +1 -0
- package/dist/colliderCollision.js +55 -0
- package/dist/colliderCollision.js.map +1 -0
- package/dist/colliderTransform.d.ts +5 -0
- package/dist/colliderTransform.d.ts.map +1 -0
- package/dist/colliderTransform.js +424 -0
- package/dist/colliderTransform.js.map +1 -0
- package/dist/contactIntake.d.ts +5 -0
- package/dist/contactIntake.d.ts.map +1 -0
- package/dist/contactIntake.js +237 -0
- package/dist/contactIntake.js.map +1 -0
- package/dist/contacts.d.ts +4 -0
- package/dist/contacts.d.ts.map +1 -0
- package/dist/contacts.js +64 -0
- package/dist/contacts.js.map +1 -0
- package/dist/continuous.d.ts +5 -0
- package/dist/continuous.d.ts.map +1 -0
- package/dist/continuous.js +829 -0
- package/dist/continuous.js.map +1 -0
- package/dist/contract.d.ts +28 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +28 -0
- package/dist/contract.js.map +1 -0
- package/dist/debugGeometry.d.ts +4 -0
- package/dist/debugGeometry.d.ts.map +1 -0
- package/dist/debugGeometry.js +333 -0
- package/dist/debugGeometry.js.map +1 -0
- package/dist/enablePhysics3DGuards.d.ts +4 -0
- package/dist/enablePhysics3DGuards.d.ts.map +1 -0
- package/dist/enablePhysics3DGuards.js +155 -0
- package/dist/enablePhysics3DGuards.js.map +1 -0
- package/dist/explainPhysics3DCollision.d.ts +3 -0
- package/dist/explainPhysics3DCollision.d.ts.map +1 -0
- package/dist/explainPhysics3DCollision.js +30 -0
- package/dist/explainPhysics3DCollision.js.map +1 -0
- package/dist/explainPhysics3DJoints.d.ts +3 -0
- package/dist/explainPhysics3DJoints.d.ts.map +1 -0
- package/dist/explainPhysics3DJoints.js +32 -0
- package/dist/explainPhysics3DJoints.js.map +1 -0
- package/dist/explainPhysics3DStep.d.ts +3 -0
- package/dist/explainPhysics3DStep.d.ts.map +1 -0
- package/dist/explainPhysics3DStep.js +51 -0
- package/dist/explainPhysics3DStep.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/integrate.d.ts +6 -0
- package/dist/integrate.d.ts.map +1 -0
- package/dist/integrate.js +184 -0
- package/dist/integrate.js.map +1 -0
- package/dist/islands.d.ts +6 -0
- package/dist/islands.d.ts.map +1 -0
- package/dist/islands.js +333 -0
- package/dist/islands.js.map +1 -0
- package/dist/jointBreakage.d.ts +5 -0
- package/dist/jointBreakage.d.ts.map +1 -0
- package/dist/jointBreakage.js +44 -0
- package/dist/jointBreakage.js.map +1 -0
- package/dist/jointCollisionSuppression.d.ts +4 -0
- package/dist/jointCollisionSuppression.d.ts.map +1 -0
- package/dist/jointCollisionSuppression.js +40 -0
- package/dist/jointCollisionSuppression.js.map +1 -0
- package/dist/jointFactories.d.ts +9 -0
- package/dist/jointFactories.d.ts.map +1 -0
- package/dist/jointFactories.js +159 -0
- package/dist/jointFactories.js.map +1 -0
- package/dist/jointMath.d.ts +17 -0
- package/dist/jointMath.d.ts.map +1 -0
- package/dist/jointMath.js +361 -0
- package/dist/jointMath.js.map +1 -0
- package/dist/jointReaction.d.ts +8 -0
- package/dist/jointReaction.d.ts.map +1 -0
- package/dist/jointReaction.js +65 -0
- package/dist/jointReaction.js.map +1 -0
- package/dist/jointRegistry.d.ts +8 -0
- package/dist/jointRegistry.d.ts.map +1 -0
- package/dist/jointRegistry.js +144 -0
- package/dist/jointRegistry.js.map +1 -0
- package/dist/jointRows.d.ts +33 -0
- package/dist/jointRows.d.ts.map +1 -0
- package/dist/jointRows.js +352 -0
- package/dist/jointRows.js.map +1 -0
- package/dist/joints.d.ts +16 -0
- package/dist/joints.d.ts.map +1 -0
- package/dist/joints.js +1060 -0
- package/dist/joints.js.map +1 -0
- package/dist/massProperties.d.ts +13 -0
- package/dist/massProperties.d.ts.map +1 -0
- package/dist/massProperties.js +462 -0
- package/dist/massProperties.js.map +1 -0
- package/dist/material.d.ts +3 -0
- package/dist/material.d.ts.map +1 -0
- package/dist/material.js +13 -0
- package/dist/material.js.map +1 -0
- package/dist/nodeSync.d.ts +3 -0
- package/dist/nodeSync.d.ts.map +1 -0
- package/dist/nodeSync.js +16 -0
- package/dist/nodeSync.js.map +1 -0
- package/dist/ownership.d.ts +8 -0
- package/dist/ownership.d.ts.map +1 -0
- package/dist/ownership.js +26 -0
- package/dist/ownership.js.map +1 -0
- package/dist/physics3DBroadphasePublication.d.ts +5 -0
- package/dist/physics3DBroadphasePublication.d.ts.map +1 -0
- package/dist/physics3DBroadphasePublication.js +49 -0
- package/dist/physics3DBroadphasePublication.js.map +1 -0
- package/dist/physics3DSpatialIndexingGuards.d.ts +4 -0
- package/dist/physics3DSpatialIndexingGuards.d.ts.map +1 -0
- package/dist/physics3DSpatialIndexingGuards.js +11 -0
- package/dist/physics3DSpatialIndexingGuards.js.map +1 -0
- package/dist/registerBuiltInPhysics3DJointSolvers.d.ts +3 -0
- package/dist/registerBuiltInPhysics3DJointSolvers.d.ts.map +1 -0
- package/dist/registerBuiltInPhysics3DJointSolvers.js +15 -0
- package/dist/registerBuiltInPhysics3DJointSolvers.js.map +1 -0
- package/dist/solver.d.ts +8 -0
- package/dist/solver.d.ts.map +1 -0
- package/dist/solver.js +460 -0
- package/dist/solver.js.map +1 -0
- package/dist/step.d.ts +5 -0
- package/dist/step.d.ts.map +1 -0
- package/dist/step.js +302 -0
- package/dist/step.js.map +1 -0
- package/dist/stepValidation.d.ts +13 -0
- package/dist/stepValidation.d.ts.map +1 -0
- package/dist/stepValidation.js +284 -0
- package/dist/stepValidation.js.map +1 -0
- package/dist/symmetricTensor.d.ts +11 -0
- package/dist/symmetricTensor.d.ts.map +1 -0
- package/dist/symmetricTensor.js +150 -0
- package/dist/symmetricTensor.js.map +1 -0
- package/dist/world.d.ts +27 -0
- package/dist/world.d.ts.map +1 -0
- package/dist/world.js +762 -0
- package/dist/world.js.map +1 -0
- package/dist/worldQueries.d.ts +11 -0
- package/dist/worldQueries.d.ts.map +1 -0
- package/dist/worldQueries.js +409 -0
- package/dist/worldQueries.js.map +1 -0
- package/package.json +55 -0
- package/src/broadphase.test.ts +265 -0
- package/src/colliderCollision.test.ts +57 -0
- package/src/colliderTransform.test.ts +221 -0
- package/src/contactIntake.test.ts +370 -0
- package/src/contacts.test.ts +64 -0
- package/src/continuous.test.ts +679 -0
- package/src/debugGeometry.test.ts +288 -0
- package/src/enablePhysics3DGuards.test.ts +206 -0
- package/src/explainPhysics3DCollision.test.ts +78 -0
- package/src/explainPhysics3DJoints.test.ts +101 -0
- package/src/explainPhysics3DStep.test.ts +142 -0
- package/src/integrate.test.ts +279 -0
- package/src/islands.test.ts +384 -0
- package/src/jointBreakage.test.ts +143 -0
- package/src/jointCollisionSuppression.test.ts +94 -0
- package/src/jointFactories.test.ts +244 -0
- package/src/jointMath.test.ts +646 -0
- package/src/jointReaction.test.ts +241 -0
- package/src/jointRegistry.test.ts +229 -0
- package/src/jointRows.test.ts +548 -0
- package/src/joints.test.ts +1066 -0
- package/src/massProperties.test.ts +611 -0
- package/src/material.test.ts +40 -0
- package/src/nodeSync.test.ts +50 -0
- package/src/ownership.test.ts +61 -0
- package/src/physics3DBroadphasePublication.test.ts +63 -0
- package/src/physics3DSpatialIndexingGuards.test.ts +43 -0
- package/src/registerBuiltInPhysics3DJointSolvers.test.ts +51 -0
- package/src/solver.test.ts +567 -0
- package/src/step.test.ts +490 -0
- package/src/stepValidation.test.ts +346 -0
- package/src/stress.test.ts +526 -0
- package/src/symmetricTensor.test.ts +166 -0
- package/src/world.test.ts +1127 -0
- package/src/worldQueries.test.ts +565 -0
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Physics3DConeTwistJoint,
|
|
3
|
+
Physics3DGeneric6DofJoint,
|
|
4
|
+
Physics3DHingeJoint,
|
|
5
|
+
Physics3DJoint,
|
|
6
|
+
Physics3DJointSolver,
|
|
7
|
+
Physics3DSliderJoint,
|
|
8
|
+
Physics3DWorld,
|
|
9
|
+
RigidBody3D,
|
|
10
|
+
} from '@flighthq/types/contract';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { integrateRigidBody3DPose, refreshRigidBody3DWorldInertia } from './integrate';
|
|
14
|
+
import {
|
|
15
|
+
createPhysics3DConeTwistJoint,
|
|
16
|
+
createPhysics3DDistanceJoint,
|
|
17
|
+
createPhysics3DFixedJoint,
|
|
18
|
+
createPhysics3DGeneric6DofJoint,
|
|
19
|
+
createPhysics3DHingeJoint,
|
|
20
|
+
createPhysics3DSliderJoint,
|
|
21
|
+
} from './jointFactories';
|
|
22
|
+
import { writePhysics3DJointAnchorVelocity, writePhysics3DJointAnchors } from './jointMath';
|
|
23
|
+
import {
|
|
24
|
+
physics3DBallAndSocketJointSolver,
|
|
25
|
+
physics3DConeTwistJointSolver,
|
|
26
|
+
physics3DDistanceJointSolver,
|
|
27
|
+
physics3DFixedJointSolver,
|
|
28
|
+
physics3DGeneric6DofJointSolver,
|
|
29
|
+
physics3DHingeJointSolver,
|
|
30
|
+
physics3DSliderJointSolver,
|
|
31
|
+
Physics3DBallAndSocketJointKind,
|
|
32
|
+
Physics3DConeTwistJointKind,
|
|
33
|
+
Physics3DDistanceJointKind,
|
|
34
|
+
Physics3DFixedJointKind,
|
|
35
|
+
Physics3DGeneric6DofJointKind,
|
|
36
|
+
Physics3DHingeJointKind,
|
|
37
|
+
Physics3DSliderJointKind,
|
|
38
|
+
} from './joints';
|
|
39
|
+
import { createPhysics3DMassData, setRigidBody3DMassData } from './massProperties';
|
|
40
|
+
import { addPhysics3DBody, createPhysics3DWorld, createRigidBody3D, setPhysics3DBodyType } from './world';
|
|
41
|
+
|
|
42
|
+
describe('Physics3DBallAndSocketJointKind', () => {
|
|
43
|
+
it('names every built-in kind uniquely and without a vendor prefix', () => {
|
|
44
|
+
const kinds = [
|
|
45
|
+
Physics3DBallAndSocketJointKind,
|
|
46
|
+
Physics3DConeTwistJointKind,
|
|
47
|
+
Physics3DDistanceJointKind,
|
|
48
|
+
Physics3DFixedJointKind,
|
|
49
|
+
Physics3DGeneric6DofJointKind,
|
|
50
|
+
Physics3DHingeJointKind,
|
|
51
|
+
Physics3DSliderJointKind,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
expect(new Set(kinds).size).toBe(kinds.length);
|
|
55
|
+
expect(kinds.every((kind) => !kind.includes('.'))).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('physics3DBallAndSocketJointSolver', () => {
|
|
60
|
+
it('cancels the velocity separating the two anchors', () => {
|
|
61
|
+
const scene = createScene();
|
|
62
|
+
const joint = createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1, localAnchorAX: 1, localAnchorBX: -1 });
|
|
63
|
+
joint.kind = Physics3DBallAndSocketJointKind;
|
|
64
|
+
scene.bodyB.velocityX = 4;
|
|
65
|
+
scene.bodyB.velocityY = -2;
|
|
66
|
+
|
|
67
|
+
solveJoint(scene.world, joint, physics3DBallAndSocketJointSolver, 8);
|
|
68
|
+
|
|
69
|
+
expect(anchorSpeed(scene, joint)).toBeLessThan(1e-9);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('leaves relative rotation completely free', () => {
|
|
73
|
+
const scene = createScene();
|
|
74
|
+
const joint = createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1 });
|
|
75
|
+
joint.kind = Physics3DBallAndSocketJointKind;
|
|
76
|
+
scene.bodyB.angularVelocityY = 3;
|
|
77
|
+
|
|
78
|
+
solveJoint(scene.world, joint, physics3DBallAndSocketJointSolver, 8);
|
|
79
|
+
|
|
80
|
+
// Both anchors sit on the centres of mass, so a pure spin moves neither and the joint has nothing to
|
|
81
|
+
// resist. A kind that quietly locked an axis would show up here.
|
|
82
|
+
expect(scene.bodyB.angularVelocityY).toBeCloseTo(3, 9);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('pulls a separated pair back together', () => {
|
|
86
|
+
const scene = createScene();
|
|
87
|
+
const joint = createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1, localAnchorAX: 1, localAnchorBX: -1 });
|
|
88
|
+
joint.kind = Physics3DBallAndSocketJointKind;
|
|
89
|
+
scene.bodyB.x = 4;
|
|
90
|
+
|
|
91
|
+
solveJoint(scene.world, joint, physics3DBallAndSocketJointSolver, 8);
|
|
92
|
+
|
|
93
|
+
// The anchors are 2 apart along +x, so the bias drives B back toward A.
|
|
94
|
+
expect(scene.bodyB.velocityX).toBeLessThan(0);
|
|
95
|
+
expect(scene.bodyA.velocityX).toBeGreaterThan(0);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('physics3DConeTwistJointSolver', () => {
|
|
100
|
+
it('leaves a swing inside the cone alone', () => {
|
|
101
|
+
const scene = createScene();
|
|
102
|
+
const joint = createPhysics3DConeTwistJoint({ bodyA: 0, bodyB: 1, swingLimitY: 1, swingLimitZ: 1 });
|
|
103
|
+
scene.bodyB.angularVelocityZ = 0.5;
|
|
104
|
+
|
|
105
|
+
solveJoint(scene.world, joint, physics3DConeTwistJointSolver, 16);
|
|
106
|
+
|
|
107
|
+
// Both twist axes still coincide, so the joint is at the centre of its cone and has nothing to resist.
|
|
108
|
+
expect(scene.bodyB.angularVelocityZ - scene.bodyA.angularVelocityZ).toBeCloseTo(0.5, 6);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('pushes back on a swing past the cone', () => {
|
|
112
|
+
const scene = createScene();
|
|
113
|
+
// Half a radian of tilt about Z, well past a cone of 0.2.
|
|
114
|
+
setAxisAngle(scene.bodyB, 0, 0, 1, 0.5);
|
|
115
|
+
const joint = createPhysics3DConeTwistJoint({ bodyA: 0, bodyB: 1, swingLimitY: 0.2, swingLimitZ: 0.2 });
|
|
116
|
+
|
|
117
|
+
solveJoint(scene.world, joint, physics3DConeTwistJointSolver, 16);
|
|
118
|
+
|
|
119
|
+
// The swing axis for a tilt about +z is +z, so closing the cone means a negative relative spin about it.
|
|
120
|
+
expect(scene.bodyB.angularVelocityZ - scene.bodyA.angularVelocityZ).toBeLessThan(0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('takes its cone from the axis the tilt is actually in', () => {
|
|
124
|
+
const wide = createScene();
|
|
125
|
+
setAxisAngle(wide.bodyB, 0, 0, 1, 0.5);
|
|
126
|
+
// A tilt about +z carries the twist axis toward frame Y, so swingLimitY is the bound that applies.
|
|
127
|
+
const wideJoint = createPhysics3DConeTwistJoint({ bodyA: 0, bodyB: 1, swingLimitY: 1.2, swingLimitZ: 0.05 });
|
|
128
|
+
solveJoint(wide.world, wideJoint, physics3DConeTwistJointSolver, 16);
|
|
129
|
+
|
|
130
|
+
const narrow = createScene();
|
|
131
|
+
setAxisAngle(narrow.bodyB, 0, 0, 1, 0.5);
|
|
132
|
+
const narrowJoint = createPhysics3DConeTwistJoint({ bodyA: 0, bodyB: 1, swingLimitY: 0.05, swingLimitZ: 1.2 });
|
|
133
|
+
solveJoint(narrow.world, narrowJoint, physics3DConeTwistJointSolver, 16);
|
|
134
|
+
|
|
135
|
+
expect(wide.bodyB.angularVelocityZ).toBeCloseTo(0, 9);
|
|
136
|
+
expect(narrow.bodyB.angularVelocityZ).toBeLessThan(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('bounds the twist without touching the swing', () => {
|
|
140
|
+
const scene = createScene();
|
|
141
|
+
setAxisAngle(scene.bodyB, 1, 0, 0, 0.5);
|
|
142
|
+
const joint = createPhysics3DConeTwistJoint({
|
|
143
|
+
bodyA: 0,
|
|
144
|
+
bodyB: 1,
|
|
145
|
+
enableSwingLimit: false,
|
|
146
|
+
enableTwistLimit: true,
|
|
147
|
+
lowerTwistAngle: -0.1,
|
|
148
|
+
upperTwistAngle: 0.1,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
solveJoint(scene.world, joint, physics3DConeTwistJointSolver, 16);
|
|
152
|
+
|
|
153
|
+
expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeLessThan(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('refuses to exchange its ends', () => {
|
|
157
|
+
const joint = createPhysics3DConeTwistJoint({ bodyA: 1, bodyB: 0 });
|
|
158
|
+
|
|
159
|
+
expect(physics3DConeTwistJointSolver.swapEnds?.(joint)).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('physics3DDistanceJointSolver', () => {
|
|
164
|
+
it('cancels the velocity changing the separation', () => {
|
|
165
|
+
const scene = createScene();
|
|
166
|
+
const joint = createPhysics3DDistanceJoint({ bodyA: 0, bodyB: 1, length: 2 });
|
|
167
|
+
scene.bodyB.velocityX = 4;
|
|
168
|
+
|
|
169
|
+
solveJoint(scene.world, joint, physics3DDistanceJointSolver, 8);
|
|
170
|
+
|
|
171
|
+
expect(Math.abs(separationSpeed(scene))).toBeLessThan(1e-9);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('leaves motion PERPENDICULAR to the axis alone', () => {
|
|
175
|
+
// The single row is what distinguishes this from a ball-and-socket. A pair swinging sideways keeps its
|
|
176
|
+
// distance, so the joint has nothing to resist, and a kind that quietly constrained the other two axes
|
|
177
|
+
// would turn a pendulum into a rigid rod right here.
|
|
178
|
+
const scene = createScene();
|
|
179
|
+
const joint = createPhysics3DDistanceJoint({ bodyA: 0, bodyB: 1, length: 2 });
|
|
180
|
+
scene.bodyB.velocityY = 3;
|
|
181
|
+
|
|
182
|
+
solveJoint(scene.world, joint, physics3DDistanceJointSolver, 8);
|
|
183
|
+
|
|
184
|
+
expect(scene.bodyB.velocityY).toBeCloseTo(3, 9);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('leaves relative rotation free', () => {
|
|
188
|
+
const scene = createScene();
|
|
189
|
+
const joint = createPhysics3DDistanceJoint({ bodyA: 0, bodyB: 1, length: 2 });
|
|
190
|
+
scene.bodyB.angularVelocityY = 3;
|
|
191
|
+
|
|
192
|
+
solveJoint(scene.world, joint, physics3DDistanceJointSolver, 8);
|
|
193
|
+
|
|
194
|
+
expect(scene.bodyB.angularVelocityY).toBeCloseTo(3, 9);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('pulls a stretched pair together and pushes a compressed pair apart', () => {
|
|
198
|
+
const stretched = createScene();
|
|
199
|
+
stretched.bodyB.x = 4;
|
|
200
|
+
const stretchedJoint = createPhysics3DDistanceJoint({ bodyA: 0, bodyB: 1, length: 2 });
|
|
201
|
+
solveJoint(stretched.world, stretchedJoint, physics3DDistanceJointSolver, 8);
|
|
202
|
+
expect(stretched.bodyB.velocityX).toBeLessThan(0);
|
|
203
|
+
expect(stretched.bodyA.velocityX).toBeGreaterThan(0);
|
|
204
|
+
|
|
205
|
+
const compressed = createScene();
|
|
206
|
+
compressed.bodyB.x = 1;
|
|
207
|
+
const compressedJoint = createPhysics3DDistanceJoint({ bodyA: 0, bodyB: 1, length: 2 });
|
|
208
|
+
solveJoint(compressed.world, compressedJoint, physics3DDistanceJointSolver, 8);
|
|
209
|
+
expect(compressed.bodyB.velocityX).toBeGreaterThan(0);
|
|
210
|
+
expect(compressed.bodyA.velocityX).toBeLessThan(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('IS SLACK INSIDE ITS LIMIT INTERVAL, which is what makes it a rope', () => {
|
|
214
|
+
// The behaviour a cable exists for, and the reason enabling the limit drops the rest-length row: a joint
|
|
215
|
+
// that still held `length` here would be a strut wearing a rope's parameters.
|
|
216
|
+
const scene = createScene();
|
|
217
|
+
const joint = createPhysics3DDistanceJoint({ bodyA: 0, bodyB: 1, length: 2, enableLimit: true, maxLength: 5 });
|
|
218
|
+
scene.bodyB.velocityX = 4;
|
|
219
|
+
|
|
220
|
+
solveJoint(scene.world, joint, physics3DDistanceJointSolver, 8);
|
|
221
|
+
|
|
222
|
+
expect(scene.bodyB.velocityX).toBeCloseTo(4, 9);
|
|
223
|
+
expect(scene.bodyA.velocityX).toBeCloseTo(0, 9);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('catches the pair at its maximum length', () => {
|
|
227
|
+
const scene = createScene();
|
|
228
|
+
scene.bodyB.x = 6;
|
|
229
|
+
const joint = createPhysics3DDistanceJoint({ bodyA: 0, bodyB: 1, enableLimit: true, maxLength: 5 });
|
|
230
|
+
scene.bodyB.velocityX = 4;
|
|
231
|
+
|
|
232
|
+
solveJoint(scene.world, joint, physics3DDistanceJointSolver, 8);
|
|
233
|
+
|
|
234
|
+
expect(scene.bodyB.velocityX).toBeLessThan(0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('holds the pair apart at its minimum length', () => {
|
|
238
|
+
const scene = createScene();
|
|
239
|
+
const joint = createPhysics3DDistanceJoint({ bodyA: 0, bodyB: 1, enableLimit: true, minLength: 4, maxLength: 9 });
|
|
240
|
+
|
|
241
|
+
solveJoint(scene.world, joint, physics3DDistanceJointSolver, 8);
|
|
242
|
+
|
|
243
|
+
// Two units apart against a floor of four, so the lower row must push rather than pull.
|
|
244
|
+
expect(scene.bodyB.velocityX).toBeGreaterThan(0);
|
|
245
|
+
expect(scene.bodyA.velocityX).toBeLessThan(0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('never PULLS at its maximum, only catches', () => {
|
|
249
|
+
// A one-sided row that acted in both directions would read as a working joint in every test above while
|
|
250
|
+
// silently making the interval rigid at whichever bound was nearest.
|
|
251
|
+
const scene = createScene();
|
|
252
|
+
scene.bodyB.x = 3;
|
|
253
|
+
scene.bodyB.velocityX = -4;
|
|
254
|
+
const joint = createPhysics3DDistanceJoint({ bodyA: 0, bodyB: 1, enableLimit: true, minLength: 0, maxLength: 5 });
|
|
255
|
+
|
|
256
|
+
solveJoint(scene.world, joint, physics3DDistanceJointSolver, 8);
|
|
257
|
+
|
|
258
|
+
expect(scene.bodyB.velocityX).toBeCloseTo(-4, 9);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('yields under load where the rigid form does not', () => {
|
|
262
|
+
// The measurable difference a spring makes. Same displacement, same iterations: the soft row's
|
|
263
|
+
// compliance means it cannot remove the whole approach at once, and the rigid one can.
|
|
264
|
+
const rigid = createScene();
|
|
265
|
+
rigid.bodyB.x = 4;
|
|
266
|
+
const rigidJoint = createPhysics3DDistanceJoint({ bodyA: 0, bodyB: 1, length: 2 });
|
|
267
|
+
solveJoint(rigid.world, rigidJoint, physics3DDistanceJointSolver, 8);
|
|
268
|
+
|
|
269
|
+
const soft = createScene();
|
|
270
|
+
soft.bodyB.x = 4;
|
|
271
|
+
const softJoint = createPhysics3DDistanceJoint({
|
|
272
|
+
bodyA: 0,
|
|
273
|
+
bodyB: 1,
|
|
274
|
+
length: 2,
|
|
275
|
+
enableSpring: true,
|
|
276
|
+
frequencyHz: 2,
|
|
277
|
+
dampingRatio: 0.5,
|
|
278
|
+
});
|
|
279
|
+
solveJoint(soft.world, softJoint, physics3DDistanceJointSolver, 8);
|
|
280
|
+
|
|
281
|
+
expect(Math.abs(soft.bodyB.velocityX)).toBeLessThan(Math.abs(rigid.bodyB.velocityX));
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('SETTLES A SPRING AT ITS REST LENGTH over time', () => {
|
|
285
|
+
// The claim a frequency and a damping ratio actually make. A critically damped spring converges on the
|
|
286
|
+
// rest length; the assertion is convergence rather than a fixed path, because how it gets there is the
|
|
287
|
+
// spring's business and only the destination is the contract.
|
|
288
|
+
const scene = createScene();
|
|
289
|
+
makeStatic(scene.bodyA);
|
|
290
|
+
scene.bodyB.x = 5;
|
|
291
|
+
const joint = createPhysics3DDistanceJoint({
|
|
292
|
+
bodyA: 0,
|
|
293
|
+
bodyB: 1,
|
|
294
|
+
length: 2,
|
|
295
|
+
enableSpring: true,
|
|
296
|
+
frequencyHz: 4,
|
|
297
|
+
dampingRatio: 1,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
for (let step = 0; step < 240; step += 1) {
|
|
301
|
+
solveJoint(scene.world, joint, physics3DDistanceJointSolver, 8);
|
|
302
|
+
integrateRigidBody3DPose(scene.bodyB, 1 / 60);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
expect(scene.bodyB.x).toBeCloseTo(2, 2);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('settles a stiffer spring faster than a looser one', () => {
|
|
309
|
+
// Frequency has to be monotonic in stiffness, or it is a tuning knob rather than a unit.
|
|
310
|
+
const looseScene = createScene();
|
|
311
|
+
makeStatic(looseScene.bodyA);
|
|
312
|
+
looseScene.bodyB.x = 5;
|
|
313
|
+
const loose = createPhysics3DDistanceJoint({
|
|
314
|
+
bodyA: 0,
|
|
315
|
+
bodyB: 1,
|
|
316
|
+
length: 2,
|
|
317
|
+
enableSpring: true,
|
|
318
|
+
frequencyHz: 1,
|
|
319
|
+
dampingRatio: 1,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const stiffScene = createScene();
|
|
323
|
+
makeStatic(stiffScene.bodyA);
|
|
324
|
+
stiffScene.bodyB.x = 5;
|
|
325
|
+
const stiff = createPhysics3DDistanceJoint({
|
|
326
|
+
bodyA: 0,
|
|
327
|
+
bodyB: 1,
|
|
328
|
+
length: 2,
|
|
329
|
+
enableSpring: true,
|
|
330
|
+
frequencyHz: 6,
|
|
331
|
+
dampingRatio: 1,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
for (let step = 0; step < 30; step += 1) {
|
|
335
|
+
solveJoint(looseScene.world, loose, physics3DDistanceJointSolver, 8);
|
|
336
|
+
integrateRigidBody3DPose(looseScene.bodyB, 1 / 60);
|
|
337
|
+
solveJoint(stiffScene.world, stiff, physics3DDistanceJointSolver, 8);
|
|
338
|
+
integrateRigidBody3DPose(stiffScene.bodyB, 1 / 60);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
expect(Math.abs(stiffScene.bodyB.x - 2)).toBeLessThan(Math.abs(looseScene.bodyB.x - 2));
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('BOUNDS A SPRING BY ITS LIMITS rather than letting it stretch through them', () => {
|
|
345
|
+
// Spring and limit together is a suspension with travel stops, and the stop has to win. This is why the
|
|
346
|
+
// limit rows read the unsoftened effective mass: given the spring's compliance they would yield too.
|
|
347
|
+
const scene = createScene();
|
|
348
|
+
makeStatic(scene.bodyA);
|
|
349
|
+
scene.bodyB.x = 2;
|
|
350
|
+
const joint = createPhysics3DDistanceJoint({
|
|
351
|
+
bodyA: 0,
|
|
352
|
+
bodyB: 1,
|
|
353
|
+
length: 20,
|
|
354
|
+
enableSpring: true,
|
|
355
|
+
frequencyHz: 6,
|
|
356
|
+
dampingRatio: 0.1,
|
|
357
|
+
enableLimit: true,
|
|
358
|
+
minLength: 0,
|
|
359
|
+
maxLength: 4,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
for (let step = 0; step < 240; step += 1) {
|
|
363
|
+
solveJoint(scene.world, joint, physics3DDistanceJointSolver, 8);
|
|
364
|
+
integrateRigidBody3DPose(scene.bodyB, 1 / 60);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// The spring pulls toward 20 and the stop sits at 4, so the stop is what holds.
|
|
368
|
+
expect(scene.bodyB.x).toBeLessThan(4.1);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('clears its limit accumulators on request', () => {
|
|
372
|
+
const joint = createPhysics3DDistanceJoint({ bodyA: 0, bodyB: 1, enableLimit: true, maxLength: 1 });
|
|
373
|
+
joint.impulse0 = 5;
|
|
374
|
+
joint.lowerLimitImpulse = 3;
|
|
375
|
+
joint.upperLimitImpulse = 7;
|
|
376
|
+
|
|
377
|
+
physics3DDistanceJointSolver.clearAccumulatedImpulses?.(joint);
|
|
378
|
+
|
|
379
|
+
expect(joint.impulse0).toBe(0);
|
|
380
|
+
expect(joint.lowerLimitImpulse).toBe(0);
|
|
381
|
+
expect(joint.upperLimitImpulse).toBe(0);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('accepts an exchange of ends, having nothing signed to reverse', () => {
|
|
385
|
+
// A length reads the same from either end, which is why this kind's `swapEnds` reverses nothing. If it
|
|
386
|
+
// ever grows a signed quantity, this is the test that should stop being true.
|
|
387
|
+
const joint = createPhysics3DDistanceJoint({ bodyA: 1, bodyB: 0, length: 2 });
|
|
388
|
+
|
|
389
|
+
expect(physics3DDistanceJointSolver.swapEnds?.(joint)).toBe(true);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('survives coincident anchors without producing NaN', () => {
|
|
393
|
+
// A zero separation leaves the axis undefined. Normalizing by it would put NaN into both bodies, and NaN
|
|
394
|
+
// in a solver never leaves — it spreads to every body the island touches.
|
|
395
|
+
const scene = createScene();
|
|
396
|
+
scene.bodyB.x = 0;
|
|
397
|
+
const joint = createPhysics3DDistanceJoint({ bodyA: 0, bodyB: 1, length: 2 });
|
|
398
|
+
|
|
399
|
+
solveJoint(scene.world, joint, physics3DDistanceJointSolver, 8);
|
|
400
|
+
|
|
401
|
+
expect(Number.isFinite(scene.bodyB.velocityX)).toBe(true);
|
|
402
|
+
expect(Number.isFinite(scene.bodyB.velocityY)).toBe(true);
|
|
403
|
+
expect(Number.isFinite(scene.bodyB.velocityZ)).toBe(true);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe('physics3DFixedJointSolver', () => {
|
|
408
|
+
it('cancels relative rotation about every axis', () => {
|
|
409
|
+
const scene = createScene();
|
|
410
|
+
const joint = createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1 });
|
|
411
|
+
scene.bodyB.angularVelocityX = 1.5;
|
|
412
|
+
scene.bodyB.angularVelocityY = -0.8;
|
|
413
|
+
scene.bodyB.angularVelocityZ = 2.1;
|
|
414
|
+
|
|
415
|
+
solveJoint(scene.world, joint, physics3DFixedJointSolver, 8);
|
|
416
|
+
|
|
417
|
+
expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeCloseTo(0, 9);
|
|
418
|
+
expect(scene.bodyB.angularVelocityY - scene.bodyA.angularVelocityY).toBeCloseTo(0, 9);
|
|
419
|
+
expect(scene.bodyB.angularVelocityZ - scene.bodyA.angularVelocityZ).toBeCloseTo(0, 9);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('cancels relative anchor motion as well as rotation', () => {
|
|
423
|
+
const scene = createScene();
|
|
424
|
+
const joint = createPhysics3DFixedJoint({ bodyA: 0, bodyB: 1, localAnchorAX: 1, localAnchorBX: -1 });
|
|
425
|
+
scene.bodyB.velocityZ = 3;
|
|
426
|
+
scene.bodyB.angularVelocityY = 1;
|
|
427
|
+
|
|
428
|
+
solveJoint(scene.world, joint, physics3DFixedJointSolver, 16);
|
|
429
|
+
|
|
430
|
+
expect(anchorSpeed(scene, joint)).toBeLessThan(1e-6);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('exchanges the frames when its ends are swapped', () => {
|
|
434
|
+
const joint = createPhysics3DFixedJoint({
|
|
435
|
+
bodyA: 0,
|
|
436
|
+
bodyB: 1,
|
|
437
|
+
localRotationAX: 1,
|
|
438
|
+
localRotationAW: 0,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
expect(physics3DFixedJointSolver.swapEnds?.(joint)).toBe(true);
|
|
442
|
+
expect(joint.localRotationAX).toBe(0);
|
|
443
|
+
expect(joint.localRotationAW).toBe(1);
|
|
444
|
+
expect(joint.localRotationBX).toBe(1);
|
|
445
|
+
expect(joint.localRotationBW).toBe(0);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe('physics3DGeneric6DofJointSolver', () => {
|
|
450
|
+
it('constrains nothing when every axis is free', () => {
|
|
451
|
+
const scene = createScene();
|
|
452
|
+
const joint = createPhysics3DGeneric6DofJoint({ bodyA: 0, bodyB: 1 });
|
|
453
|
+
scene.bodyB.velocityX = 1;
|
|
454
|
+
scene.bodyB.velocityY = 2;
|
|
455
|
+
scene.bodyB.angularVelocityZ = 3;
|
|
456
|
+
|
|
457
|
+
solveJoint(scene.world, joint, physics3DGeneric6DofJointSolver, 16);
|
|
458
|
+
|
|
459
|
+
expect(scene.bodyB.velocityX).toBeCloseTo(1, 9);
|
|
460
|
+
expect(scene.bodyB.velocityY).toBeCloseTo(2, 9);
|
|
461
|
+
expect(scene.bodyB.angularVelocityZ).toBeCloseTo(3, 9);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('locks the one axis whose bounds are equal and leaves the rest free', () => {
|
|
465
|
+
const scene = createScene();
|
|
466
|
+
// A is the anchored end, so the row's coordinate is B's own displacement along the axis and reads
|
|
467
|
+
// directly off B's velocity. With both ends dynamic the coordinate also carries A's rotation about its
|
|
468
|
+
// lever arm, and a plain velocity difference is then measuring something the joint never constrained.
|
|
469
|
+
makeStatic(scene.bodyA);
|
|
470
|
+
const joint = createPhysics3DGeneric6DofJoint({
|
|
471
|
+
bodyA: 0,
|
|
472
|
+
bodyB: 1,
|
|
473
|
+
lowerLinearY: 0,
|
|
474
|
+
upperLinearY: 0,
|
|
475
|
+
});
|
|
476
|
+
scene.bodyB.velocityX = 1;
|
|
477
|
+
scene.bodyB.velocityY = 2;
|
|
478
|
+
|
|
479
|
+
solveJoint(scene.world, joint, physics3DGeneric6DofJointSolver, 16);
|
|
480
|
+
|
|
481
|
+
expect(scene.bodyB.velocityY - scene.bodyA.velocityY).toBeCloseTo(0, 6);
|
|
482
|
+
expect(scene.bodyB.velocityX).toBeCloseTo(1, 9);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('lets a limited axis move within its interval', () => {
|
|
486
|
+
const scene = createScene();
|
|
487
|
+
// The anchors are 2 apart along +x, and the interval admits that.
|
|
488
|
+
const joint = createPhysics3DGeneric6DofJoint({
|
|
489
|
+
bodyA: 0,
|
|
490
|
+
bodyB: 1,
|
|
491
|
+
lowerLinearX: 1,
|
|
492
|
+
upperLinearX: 3,
|
|
493
|
+
});
|
|
494
|
+
scene.bodyB.velocityX = 0.5;
|
|
495
|
+
|
|
496
|
+
solveJoint(scene.world, joint, physics3DGeneric6DofJointSolver, 16);
|
|
497
|
+
|
|
498
|
+
expect(scene.bodyB.velocityX - scene.bodyA.velocityX).toBeCloseTo(0.5, 6);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('stops a limited axis at the end of its interval', () => {
|
|
502
|
+
const scene = createScene();
|
|
503
|
+
const joint = createPhysics3DGeneric6DofJoint({
|
|
504
|
+
bodyA: 0,
|
|
505
|
+
bodyB: 1,
|
|
506
|
+
lowerLinearX: -1,
|
|
507
|
+
upperLinearX: 1,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
solveJoint(scene.world, joint, physics3DGeneric6DofJointSolver, 16);
|
|
511
|
+
|
|
512
|
+
// The separation is 2, past the upper bound of 1, so the pair is drawn back together.
|
|
513
|
+
expect(scene.bodyB.velocityX - scene.bodyA.velocityX).toBeLessThan(0);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('locks an angular axis without disturbing the other two', () => {
|
|
517
|
+
const scene = createScene();
|
|
518
|
+
const joint = createPhysics3DGeneric6DofJoint({
|
|
519
|
+
bodyA: 0,
|
|
520
|
+
bodyB: 1,
|
|
521
|
+
lowerAngularZ: 0,
|
|
522
|
+
upperAngularZ: 0,
|
|
523
|
+
});
|
|
524
|
+
scene.bodyB.angularVelocityY = 1;
|
|
525
|
+
scene.bodyB.angularVelocityZ = 2;
|
|
526
|
+
|
|
527
|
+
solveJoint(scene.world, joint, physics3DGeneric6DofJointSolver, 16);
|
|
528
|
+
|
|
529
|
+
expect(scene.bodyB.angularVelocityZ - scene.bodyA.angularVelocityZ).toBeCloseTo(0, 6);
|
|
530
|
+
expect(scene.bodyB.angularVelocityY - scene.bodyA.angularVelocityY).toBeCloseTo(1, 6);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('refuses to exchange its ends', () => {
|
|
534
|
+
const joint = createPhysics3DGeneric6DofJoint({ bodyA: 1, bodyB: 0 });
|
|
535
|
+
|
|
536
|
+
expect(physics3DGeneric6DofJointSolver.swapEnds?.(joint)).toBe(false);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('carries a limited axis accumulator but drops it when the axis stops being limited', () => {
|
|
540
|
+
const scene = createScene();
|
|
541
|
+
makeStatic(scene.bodyA);
|
|
542
|
+
const joint = createPhysics3DGeneric6DofJoint({
|
|
543
|
+
bodyA: 0,
|
|
544
|
+
bodyB: 1,
|
|
545
|
+
lowerAngularX: -0.05,
|
|
546
|
+
upperAngularX: 0.05,
|
|
547
|
+
});
|
|
548
|
+
setAxisAngle(scene.bodyB, 1, 0, 0, 0.2);
|
|
549
|
+
|
|
550
|
+
scene.bodyB.angularVelocityX = 1;
|
|
551
|
+
solveJoint(scene.world, joint, physics3DGeneric6DofJointSolver, 4);
|
|
552
|
+
expect(joint.upperLimitImpulses[3]).toBeGreaterThan(0);
|
|
553
|
+
|
|
554
|
+
// Widening the bounds past the pose makes the axis FREE, and an accumulator describing a bound the
|
|
555
|
+
// joint is no longer anywhere near must not survive into the next prepare.
|
|
556
|
+
joint.lowerAngularX = 1;
|
|
557
|
+
joint.upperAngularX = -1;
|
|
558
|
+
solveJoint(scene.world, joint, physics3DGeneric6DofJointSolver, 1);
|
|
559
|
+
expect(joint.upperLimitImpulses[3]).toBe(0);
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
describe('physics3DHingeJointSolver', () => {
|
|
564
|
+
it('leaves rotation about its own axis free while cancelling the other two', () => {
|
|
565
|
+
const scene = createScene();
|
|
566
|
+
const joint = createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1 });
|
|
567
|
+
scene.bodyB.angularVelocityX = 2;
|
|
568
|
+
scene.bodyB.angularVelocityY = 1.3;
|
|
569
|
+
scene.bodyB.angularVelocityZ = -0.9;
|
|
570
|
+
|
|
571
|
+
solveJoint(scene.world, joint, physics3DHingeJointSolver, 16);
|
|
572
|
+
|
|
573
|
+
// The frame's X axis is the hinge line, and with identity frames it is world +x.
|
|
574
|
+
expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeCloseTo(2, 6);
|
|
575
|
+
expect(scene.bodyB.angularVelocityY - scene.bodyA.angularVelocityY).toBeCloseTo(0, 6);
|
|
576
|
+
expect(scene.bodyB.angularVelocityZ - scene.bodyA.angularVelocityZ).toBeCloseTo(0, 6);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('holds the hinge line even when the frames rotate it off the body axes', () => {
|
|
580
|
+
const scene = createScene();
|
|
581
|
+
const half = Math.SQRT1_2;
|
|
582
|
+
// A quarter turn about Z carries the frame's X axis onto world +y for both bodies.
|
|
583
|
+
const joint = createPhysics3DHingeJoint({
|
|
584
|
+
bodyA: 0,
|
|
585
|
+
bodyB: 1,
|
|
586
|
+
localRotationAZ: half,
|
|
587
|
+
localRotationAW: half,
|
|
588
|
+
localRotationBZ: half,
|
|
589
|
+
localRotationBW: half,
|
|
590
|
+
});
|
|
591
|
+
scene.bodyB.angularVelocityX = 1.1;
|
|
592
|
+
scene.bodyB.angularVelocityY = 2;
|
|
593
|
+
|
|
594
|
+
solveJoint(scene.world, joint, physics3DHingeJointSolver, 16);
|
|
595
|
+
|
|
596
|
+
expect(scene.bodyB.angularVelocityY - scene.bodyA.angularVelocityY).toBeCloseTo(2, 6);
|
|
597
|
+
expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeCloseTo(0, 6);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('drives the relative spin toward the motor speed', () => {
|
|
601
|
+
const scene = createScene();
|
|
602
|
+
const joint = createPhysics3DHingeJoint({
|
|
603
|
+
bodyA: 0,
|
|
604
|
+
bodyB: 1,
|
|
605
|
+
enableMotor: true,
|
|
606
|
+
motorSpeed: 3,
|
|
607
|
+
maxMotorTorque: 1000,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
solveJoint(scene.world, joint, physics3DHingeJointSolver, 16);
|
|
611
|
+
|
|
612
|
+
expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeCloseTo(3, 6);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('bounds the motor by its torque budget rather than by the iteration count', () => {
|
|
616
|
+
const fewer = createScene();
|
|
617
|
+
const jointFewer = createPhysics3DHingeJoint({
|
|
618
|
+
bodyA: 0,
|
|
619
|
+
bodyB: 1,
|
|
620
|
+
enableMotor: true,
|
|
621
|
+
motorSpeed: 50,
|
|
622
|
+
maxMotorTorque: 1,
|
|
623
|
+
});
|
|
624
|
+
solveJoint(fewer.world, jointFewer, physics3DHingeJointSolver, 4);
|
|
625
|
+
|
|
626
|
+
const more = createScene();
|
|
627
|
+
const jointMore = createPhysics3DHingeJoint({
|
|
628
|
+
bodyA: 0,
|
|
629
|
+
bodyB: 1,
|
|
630
|
+
enableMotor: true,
|
|
631
|
+
motorSpeed: 50,
|
|
632
|
+
maxMotorTorque: 1,
|
|
633
|
+
});
|
|
634
|
+
solveJoint(more.world, jointMore, physics3DHingeJointSolver, 32);
|
|
635
|
+
|
|
636
|
+
// A per-iteration clamp would make the motor eight times stronger in the second run — a motor that gets
|
|
637
|
+
// stronger when the solver is tuned for accuracy.
|
|
638
|
+
expect(more.bodyB.angularVelocityX).toBeCloseTo(fewer.bodyB.angularVelocityX, 9);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it('pushes back when the hinge angle is past its upper limit', () => {
|
|
642
|
+
const scene = createScene();
|
|
643
|
+
// A tenth of a radian about the hinge line, well past a limit of zero.
|
|
644
|
+
setAxisAngle(scene.bodyB, 1, 0, 0, 0.1);
|
|
645
|
+
const joint = createPhysics3DHingeJoint({
|
|
646
|
+
bodyA: 0,
|
|
647
|
+
bodyB: 1,
|
|
648
|
+
enableLimit: true,
|
|
649
|
+
lowerAngle: -0.05,
|
|
650
|
+
upperAngle: 0.05,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
solveJoint(scene.world, joint, physics3DHingeJointSolver, 16);
|
|
654
|
+
|
|
655
|
+
expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeLessThan(0);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('carries a limit impulse across steps and reapplies it before the first iteration', () => {
|
|
659
|
+
const { scene, joint } = createLoadedHingeStop();
|
|
660
|
+
scene.bodyB.angularVelocityX = 1;
|
|
661
|
+
solveJoint(scene.world, joint, physics3DHingeJointSolver, 4);
|
|
662
|
+
expect(joint.upperLimitImpulse).toBeGreaterThan(0);
|
|
663
|
+
|
|
664
|
+
// A whole step with ZERO velocity iterations, so nothing but warm starting can move the body. This is
|
|
665
|
+
// the assertion that the carried value is actually reapplied rather than merely stored — deliberately
|
|
666
|
+
// not a "converges in fewer iterations" comparison, because an isolated one-sided row has an exact
|
|
667
|
+
// effective mass and reaches the same answer from any starting point in a single iteration. Warm
|
|
668
|
+
// starting pays off where the limit competes with the point and lock rows, not here.
|
|
669
|
+
const before = scene.bodyB.angularVelocityX;
|
|
670
|
+
solveJoint(scene.world, joint, physics3DHingeJointSolver, 0);
|
|
671
|
+
|
|
672
|
+
expect(scene.bodyB.angularVelocityX).toBeLessThan(before);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it('drops a carried limit impulse when the limit is turned off', () => {
|
|
676
|
+
const { scene, joint } = createLoadedHingeStop();
|
|
677
|
+
scene.bodyB.angularVelocityX = 1;
|
|
678
|
+
solveJoint(scene.world, joint, physics3DHingeJointSolver, 4);
|
|
679
|
+
expect(joint.upperLimitImpulse).toBeGreaterThan(0);
|
|
680
|
+
|
|
681
|
+
// Same rule the motor already followed: a cached impulse is valid only while the row that produced it is
|
|
682
|
+
// still solved, or a disabled stop keeps pushing forever with nothing to cancel it.
|
|
683
|
+
joint.enableLimit = false;
|
|
684
|
+
solveJoint(scene.world, joint, physics3DHingeJointSolver, 1);
|
|
685
|
+
expect(joint.upperLimitImpulse).toBe(0);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it('exchanges rather than negates the limit accumulators when its ends are swapped', () => {
|
|
689
|
+
const joint = createPhysics3DHingeJoint({ bodyA: 1, bodyB: 0, enableLimit: true, lowerAngle: -1, upperAngle: 1 });
|
|
690
|
+
joint.lowerLimitImpulse = 3;
|
|
691
|
+
joint.upperLimitImpulse = 7;
|
|
692
|
+
|
|
693
|
+
physics3DHingeJointSolver.swapEnds?.(joint);
|
|
694
|
+
|
|
695
|
+
// Both stay non-negative: each is a magnitude whose direction is carried by the row that owns it, so the
|
|
696
|
+
// push that held the lower bound is the one that now holds the upper.
|
|
697
|
+
expect(joint.lowerLimitImpulse).toBe(7);
|
|
698
|
+
expect(joint.upperLimitImpulse).toBe(3);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it('rescales carried limit accumulators with the timestep', () => {
|
|
702
|
+
const joint = createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1, enableLimit: true });
|
|
703
|
+
joint.lowerLimitImpulse = 2;
|
|
704
|
+
joint.upperLimitImpulse = 5;
|
|
705
|
+
|
|
706
|
+
physics3DHingeJointSolver.scaleAccumulatedImpulses?.(joint, 0.5);
|
|
707
|
+
|
|
708
|
+
// An accumulated impulse has force-times-time units, so reusing one across a different interval applies
|
|
709
|
+
// the wrong force before the first iteration can correct it.
|
|
710
|
+
expect(joint.lowerLimitImpulse).toBe(1);
|
|
711
|
+
expect(joint.upperLimitImpulse).toBe(2.5);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('does not brake a hinge that is nowhere near its limits', () => {
|
|
715
|
+
const scene = createScene();
|
|
716
|
+
const joint = createPhysics3DHingeJoint({
|
|
717
|
+
bodyA: 0,
|
|
718
|
+
bodyB: 1,
|
|
719
|
+
enableLimit: true,
|
|
720
|
+
lowerAngle: -1,
|
|
721
|
+
upperAngle: 1,
|
|
722
|
+
});
|
|
723
|
+
scene.bodyB.angularVelocityX = 0.5;
|
|
724
|
+
|
|
725
|
+
solveJoint(scene.world, joint, physics3DHingeJointSolver, 16);
|
|
726
|
+
|
|
727
|
+
// A limit whose bias clamped the signed error to zero on the inside would brake this to a standstill.
|
|
728
|
+
expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeCloseTo(0.5, 6);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('reverses and exchanges the limit interval when its ends are swapped', () => {
|
|
732
|
+
const joint = createPhysics3DHingeJoint({
|
|
733
|
+
bodyA: 1,
|
|
734
|
+
bodyB: 0,
|
|
735
|
+
lowerAngle: -0.25,
|
|
736
|
+
upperAngle: 1.5,
|
|
737
|
+
motorSpeed: 4,
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
expect(physics3DHingeJointSolver.swapEnds?.(joint)).toBe(true);
|
|
741
|
+
expect(joint.lowerAngle).toBe(-1.5);
|
|
742
|
+
expect(joint.upperAngle).toBe(0.25);
|
|
743
|
+
expect(joint.motorSpeed).toBe(-4);
|
|
744
|
+
// Negating an absent accumulator has to produce zero, not NaN, which is what this catches.
|
|
745
|
+
expect(joint.motorImpulse).toBeCloseTo(0, 12);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('scales the motor accumulator with a changed timestep', () => {
|
|
749
|
+
const joint = createPhysics3DHingeJoint({ bodyA: 0, bodyB: 1 });
|
|
750
|
+
joint.motorImpulse = 6;
|
|
751
|
+
|
|
752
|
+
physics3DHingeJointSolver.scaleAccumulatedImpulses?.(joint, 0.5);
|
|
753
|
+
|
|
754
|
+
expect(joint.motorImpulse).toBe(3);
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
describe('physics3DJointSoftLimits', () => {
|
|
759
|
+
// A loaded hinge stop, solved once as a hard limit and once as a spring. Everything else is identical,
|
|
760
|
+
// so any difference is the compliance.
|
|
761
|
+
function solveLoadedStop(soft: boolean, frequencyHz = 4, dampingRatio = 0.7): Physics3DHingeJoint {
|
|
762
|
+
const { scene, joint } = createLoadedHingeStop();
|
|
763
|
+
joint.enableLimitSpring = soft;
|
|
764
|
+
joint.limitFrequencyHz = frequencyHz;
|
|
765
|
+
joint.limitDampingRatio = dampingRatio;
|
|
766
|
+
solveJoint(scene.world, joint, physics3DHingeJointSolver, 8);
|
|
767
|
+
return joint;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
it('yields more than a hard stop under the same load', () => {
|
|
771
|
+
// The whole point of a spring stop: it resists, but it gives. A compliant row reaching for the same
|
|
772
|
+
// impulse as a hard one would mean the softening never took effect.
|
|
773
|
+
const hard = solveLoadedStop(false);
|
|
774
|
+
const soft = solveLoadedStop(true);
|
|
775
|
+
expect(soft.upperLimitImpulse).toBeGreaterThan(0);
|
|
776
|
+
expect(soft.upperLimitImpulse).toBeLessThan(hard.upperLimitImpulse);
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it('stiffens toward the hard stop as frequency rises', () => {
|
|
780
|
+
// Monotonic in frequency, which is what makes the parameter tunable rather than merely present.
|
|
781
|
+
const slack = solveLoadedStop(true, 2);
|
|
782
|
+
const stiff = solveLoadedStop(true, 30);
|
|
783
|
+
const hard = solveLoadedStop(false);
|
|
784
|
+
expect(slack.upperLimitImpulse).toBeLessThan(stiff.upperLimitImpulse);
|
|
785
|
+
expect(stiff.upperLimitImpulse).toBeLessThanOrEqual(hard.upperLimitImpulse + 1e-9);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('degrades EXACTLY to a hard stop when the frequency is zero', () => {
|
|
789
|
+
// "Spring enabled but never configured" must not be a third behaviour. Exact equality rather than a
|
|
790
|
+
// tolerance: the code path is supposed to be the same one, not merely a close one.
|
|
791
|
+
const hard = solveLoadedStop(false);
|
|
792
|
+
const unconfigured = solveLoadedStop(true, 0);
|
|
793
|
+
expect(unconfigured.upperLimitImpulse).toBe(hard.upperLimitImpulse);
|
|
794
|
+
expect(unconfigured.lowerLimitImpulse).toBe(hard.lowerLimitImpulse);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('stays ONE-SIDED, never pulling the coordinate back through the bound', () => {
|
|
798
|
+
// Softening changes how hard a stop resists, never whether it may reverse. A spring that could pull
|
|
799
|
+
// would be a rest length, which is a different joint.
|
|
800
|
+
const soft = solveLoadedStop(true);
|
|
801
|
+
expect(soft.lowerLimitImpulse).toBe(0);
|
|
802
|
+
expect(soft.upperLimitImpulse).toBeGreaterThanOrEqual(0);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it('softens a slider travel stop the same way', () => {
|
|
806
|
+
function solveSlider(soft: boolean): Physics3DSliderJoint {
|
|
807
|
+
const scene = createScene();
|
|
808
|
+
makeStatic(scene.bodyA);
|
|
809
|
+
scene.bodyB.x = 3;
|
|
810
|
+
const joint = createPhysics3DSliderJoint({
|
|
811
|
+
bodyA: 0,
|
|
812
|
+
bodyB: 1,
|
|
813
|
+
enableLimit: true,
|
|
814
|
+
lowerTranslation: -0.5,
|
|
815
|
+
upperTranslation: 0.5,
|
|
816
|
+
});
|
|
817
|
+
joint.enableLimitSpring = soft;
|
|
818
|
+
joint.limitFrequencyHz = 4;
|
|
819
|
+
joint.limitDampingRatio = 0.7;
|
|
820
|
+
solveJoint(scene.world, joint, physics3DSliderJointSolver, 8);
|
|
821
|
+
return joint;
|
|
822
|
+
}
|
|
823
|
+
const hard = solveSlider(false);
|
|
824
|
+
const soft = solveSlider(true);
|
|
825
|
+
expect(Math.abs(soft.upperLimitImpulse)).toBeLessThan(Math.abs(hard.upperLimitImpulse));
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it('softens a cone-twist twist stop, whose bias slot it shares with the swing stop', () => {
|
|
829
|
+
// Swing and twist write the SAME bias slot from two different row masses. That is only sound because
|
|
830
|
+
// the soft bias factor is mass-independent; if it were not, the second write would corrupt the first.
|
|
831
|
+
function solveCone(soft: boolean): Physics3DConeTwistJoint {
|
|
832
|
+
const scene = createScene();
|
|
833
|
+
makeStatic(scene.bodyA);
|
|
834
|
+
setAxisAngle(scene.bodyB, 1, 0, 0, 0.4);
|
|
835
|
+
const joint = createPhysics3DConeTwistJoint({
|
|
836
|
+
bodyA: 0,
|
|
837
|
+
bodyB: 1,
|
|
838
|
+
enableTwistLimit: true,
|
|
839
|
+
lowerTwistAngle: -0.05,
|
|
840
|
+
upperTwistAngle: 0.05,
|
|
841
|
+
});
|
|
842
|
+
joint.enableLimitSpring = soft;
|
|
843
|
+
joint.limitFrequencyHz = 4;
|
|
844
|
+
joint.limitDampingRatio = 0.7;
|
|
845
|
+
solveJoint(scene.world, joint, physics3DConeTwistJointSolver, 8);
|
|
846
|
+
return joint;
|
|
847
|
+
}
|
|
848
|
+
const hard = solveCone(false);
|
|
849
|
+
const soft = solveCone(true);
|
|
850
|
+
const hardTotal = hard.lowerTwistImpulse + hard.upperTwistImpulse;
|
|
851
|
+
const softTotal = soft.lowerTwistImpulse + soft.upperTwistImpulse;
|
|
852
|
+
expect(softTotal).toBeGreaterThan(0);
|
|
853
|
+
expect(softTotal).toBeLessThan(hardTotal);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it('softens a 6-DOF axis limit', () => {
|
|
857
|
+
function solveDof(soft: boolean): Physics3DGeneric6DofJoint {
|
|
858
|
+
const scene = createScene();
|
|
859
|
+
makeStatic(scene.bodyA);
|
|
860
|
+
scene.bodyB.x = 3;
|
|
861
|
+
const joint = createPhysics3DGeneric6DofJoint({
|
|
862
|
+
bodyA: 0,
|
|
863
|
+
bodyB: 1,
|
|
864
|
+
lowerLinearX: -0.5,
|
|
865
|
+
upperLinearX: 0.5,
|
|
866
|
+
});
|
|
867
|
+
joint.enableLimitSpring = soft;
|
|
868
|
+
joint.limitFrequencyHz = 4;
|
|
869
|
+
joint.limitDampingRatio = 0.7;
|
|
870
|
+
solveJoint(scene.world, joint, physics3DGeneric6DofJointSolver, 8);
|
|
871
|
+
return joint;
|
|
872
|
+
}
|
|
873
|
+
const hard = solveDof(false);
|
|
874
|
+
const soft = solveDof(true);
|
|
875
|
+
expect(soft.upperLimitImpulses[0]).toBeGreaterThan(0);
|
|
876
|
+
expect(soft.upperLimitImpulses[0]).toBeLessThan(hard.upperLimitImpulses[0]);
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
describe('physics3DSliderJointSolver', () => {
|
|
881
|
+
it('leaves travel along its axis free while cancelling the other two', () => {
|
|
882
|
+
const scene = createScene();
|
|
883
|
+
// The rail is the anchored end — the ordinary way a slider is authored — which is also what makes the
|
|
884
|
+
// row's coordinate equal to B's velocity along it rather than to a difference that carries A's spin.
|
|
885
|
+
makeStatic(scene.bodyA);
|
|
886
|
+
const joint = createPhysics3DSliderJoint({ bodyA: 0, bodyB: 1 });
|
|
887
|
+
scene.bodyB.velocityX = 2;
|
|
888
|
+
scene.bodyB.velocityY = 1.5;
|
|
889
|
+
scene.bodyB.velocityZ = -1;
|
|
890
|
+
|
|
891
|
+
solveJoint(scene.world, joint, physics3DSliderJointSolver, 24);
|
|
892
|
+
|
|
893
|
+
expect(scene.bodyB.velocityX - scene.bodyA.velocityX).toBeCloseTo(2, 6);
|
|
894
|
+
expect(scene.bodyB.velocityY - scene.bodyA.velocityY).toBeCloseTo(0, 6);
|
|
895
|
+
expect(scene.bodyB.velocityZ - scene.bodyA.velocityZ).toBeCloseTo(0, 6);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it('locks relative rotation about every axis', () => {
|
|
899
|
+
const scene = createScene();
|
|
900
|
+
const joint = createPhysics3DSliderJoint({ bodyA: 0, bodyB: 1 });
|
|
901
|
+
scene.bodyB.angularVelocityX = 1;
|
|
902
|
+
scene.bodyB.angularVelocityY = -2;
|
|
903
|
+
|
|
904
|
+
solveJoint(scene.world, joint, physics3DSliderJointSolver, 24);
|
|
905
|
+
|
|
906
|
+
expect(scene.bodyB.angularVelocityX - scene.bodyA.angularVelocityX).toBeCloseTo(0, 6);
|
|
907
|
+
expect(scene.bodyB.angularVelocityY - scene.bodyA.angularVelocityY).toBeCloseTo(0, 6);
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
it('drives the travel toward the motor speed', () => {
|
|
911
|
+
const scene = createScene();
|
|
912
|
+
const joint = createPhysics3DSliderJoint({
|
|
913
|
+
bodyA: 0,
|
|
914
|
+
bodyB: 1,
|
|
915
|
+
enableMotor: true,
|
|
916
|
+
motorSpeed: 4,
|
|
917
|
+
maxMotorForce: 1000,
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
solveJoint(scene.world, joint, physics3DSliderJointSolver, 24);
|
|
921
|
+
|
|
922
|
+
expect(scene.bodyB.velocityX - scene.bodyA.velocityX).toBeCloseTo(4, 6);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it('stops travel at the end of its interval', () => {
|
|
926
|
+
const scene = createScene();
|
|
927
|
+
const joint = createPhysics3DSliderJoint({
|
|
928
|
+
bodyA: 0,
|
|
929
|
+
bodyB: 1,
|
|
930
|
+
enableLimit: true,
|
|
931
|
+
lowerTranslation: -0.5,
|
|
932
|
+
upperTranslation: 0.5,
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
solveJoint(scene.world, joint, physics3DSliderJointSolver, 24);
|
|
936
|
+
|
|
937
|
+
// The anchors are 2 apart along the axis, past the upper bound.
|
|
938
|
+
expect(scene.bodyB.velocityX - scene.bodyA.velocityX).toBeLessThan(0);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it('does not brake travel that is nowhere near its limits', () => {
|
|
942
|
+
const scene = createScene();
|
|
943
|
+
const joint = createPhysics3DSliderJoint({
|
|
944
|
+
bodyA: 0,
|
|
945
|
+
bodyB: 1,
|
|
946
|
+
enableLimit: true,
|
|
947
|
+
lowerTranslation: -5,
|
|
948
|
+
upperTranslation: 5,
|
|
949
|
+
});
|
|
950
|
+
scene.bodyB.velocityX = 1;
|
|
951
|
+
|
|
952
|
+
solveJoint(scene.world, joint, physics3DSliderJointSolver, 24);
|
|
953
|
+
|
|
954
|
+
expect(scene.bodyB.velocityX - scene.bodyA.velocityX).toBeCloseTo(1, 6);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
it('reverses and exchanges the travel interval when its ends are swapped', () => {
|
|
958
|
+
const joint = createPhysics3DSliderJoint({
|
|
959
|
+
bodyA: 1,
|
|
960
|
+
bodyB: 0,
|
|
961
|
+
lowerTranslation: -0.25,
|
|
962
|
+
upperTranslation: 1.5,
|
|
963
|
+
motorSpeed: 4,
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
expect(physics3DSliderJointSolver.swapEnds?.(joint)).toBe(true);
|
|
967
|
+
expect(joint.lowerTranslation).toBe(-1.5);
|
|
968
|
+
expect(joint.upperTranslation).toBe(0.25);
|
|
969
|
+
expect(joint.motorSpeed).toBe(-4);
|
|
970
|
+
});
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
function anchorSpeed(scene: Readonly<Scene>, joint: Physics3DJoint): number {
|
|
974
|
+
writePhysics3DJointAnchors(scene.bodyA, scene.bodyB, joint);
|
|
975
|
+
const velocity = [0, 0, 0];
|
|
976
|
+
writePhysics3DJointAnchorVelocity(scene.bodyA, scene.bodyB, joint, velocity);
|
|
977
|
+
return Math.sqrt(velocity[0] * velocity[0] + velocity[1] * velocity[1] + velocity[2] * velocity[2]);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// The rate the two bodies' separation is changing, positive while they part. The distance joint constrains
|
|
981
|
+
// exactly this one scalar, so it is what a solved joint has to have driven to zero.
|
|
982
|
+
function separationSpeed(scene: Readonly<Scene>): number {
|
|
983
|
+
const axisX = scene.bodyB.x - scene.bodyA.x;
|
|
984
|
+
const axisY = scene.bodyB.y - scene.bodyA.y;
|
|
985
|
+
const axisZ = scene.bodyB.z - scene.bodyA.z;
|
|
986
|
+
const length = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ);
|
|
987
|
+
if (length === 0) return 0;
|
|
988
|
+
return (
|
|
989
|
+
((scene.bodyB.velocityX - scene.bodyA.velocityX) * axisX +
|
|
990
|
+
(scene.bodyB.velocityY - scene.bodyA.velocityY) * axisY +
|
|
991
|
+
(scene.bodyB.velocityZ - scene.bodyA.velocityZ) * axisZ) /
|
|
992
|
+
length
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
interface Scene {
|
|
997
|
+
world: Physics3DWorld;
|
|
998
|
+
bodyA: RigidBody3D;
|
|
999
|
+
bodyB: RigidBody3D;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function createScene(): Scene {
|
|
1003
|
+
const world = createPhysics3DWorld();
|
|
1004
|
+
const bodyA = createUnitBody();
|
|
1005
|
+
const bodyB = createUnitBody();
|
|
1006
|
+
bodyB.x = 2;
|
|
1007
|
+
addPhysics3DBody(world, bodyA);
|
|
1008
|
+
addPhysics3DBody(world, bodyB);
|
|
1009
|
+
return { world, bodyA, bodyB };
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// A hinge held past its upper stop against a static end — the pose a warm-started limit is for. The angle
|
|
1013
|
+
// never changes here because nothing integrates the pose, so the stop stays loaded step after step.
|
|
1014
|
+
function createLoadedHingeStop(): { scene: Scene; joint: Physics3DHingeJoint } {
|
|
1015
|
+
const scene = createScene();
|
|
1016
|
+
makeStatic(scene.bodyA);
|
|
1017
|
+
setAxisAngle(scene.bodyB, 1, 0, 0, 0.2);
|
|
1018
|
+
const joint = createPhysics3DHingeJoint({
|
|
1019
|
+
bodyA: 0,
|
|
1020
|
+
bodyB: 1,
|
|
1021
|
+
enableLimit: true,
|
|
1022
|
+
lowerAngle: -0.05,
|
|
1023
|
+
upperAngle: 0.05,
|
|
1024
|
+
});
|
|
1025
|
+
return { scene, joint };
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function createUnitBody(): RigidBody3D {
|
|
1029
|
+
const body = createRigidBody3D('dynamic');
|
|
1030
|
+
const data = createPhysics3DMassData();
|
|
1031
|
+
data.mass = 1;
|
|
1032
|
+
data.inertiaXX = 1;
|
|
1033
|
+
data.inertiaYY = 1;
|
|
1034
|
+
data.inertiaZZ = 1;
|
|
1035
|
+
setRigidBody3DMassData(body, data);
|
|
1036
|
+
refreshRigidBody3DWorldInertia(body);
|
|
1037
|
+
return body;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function makeStatic(body: RigidBody3D): void {
|
|
1041
|
+
setPhysics3DBodyType(body, 'static');
|
|
1042
|
+
refreshRigidBody3DWorldInertia(body);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function setAxisAngle(body: RigidBody3D, x: number, y: number, z: number, angle: number): void {
|
|
1046
|
+
const sine = Math.sin(angle / 2);
|
|
1047
|
+
body.orientationX = x * sine;
|
|
1048
|
+
body.orientationY = y * sine;
|
|
1049
|
+
body.orientationZ = z * sine;
|
|
1050
|
+
body.orientationW = Math.cos(angle / 2);
|
|
1051
|
+
refreshRigidBody3DWorldInertia(body);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// One substep of the sequential-impulse loop, in the order the step will call it: prepare, warm start, then
|
|
1055
|
+
// the velocity iterations.
|
|
1056
|
+
function solveJoint(
|
|
1057
|
+
world: Physics3DWorld,
|
|
1058
|
+
joint: Physics3DJoint,
|
|
1059
|
+
solver: Readonly<Physics3DJointSolver>,
|
|
1060
|
+
iterations: number,
|
|
1061
|
+
dt = 1 / 60,
|
|
1062
|
+
): void {
|
|
1063
|
+
solver.prepare(world, joint, dt);
|
|
1064
|
+
solver.warmStart?.(world, joint);
|
|
1065
|
+
for (let i = 0; i < iterations; i += 1) solver.solve(world, joint, dt);
|
|
1066
|
+
}
|