mirror of
https://github.com/overte-org/overte.git
synced 2025-08-08 16:58:09 +02:00
Merge pull request #14733 from hyperlogic/bug-fix/translation-precision
Avatar Protocol: Fix for precision issues with translation
This commit is contained in:
commit
1768d91780
7 changed files with 64 additions and 26 deletions
|
@ -1984,11 +1984,10 @@ void Rig::copyJointsIntoJointData(QVector<JointData>& jointDataVec) const {
|
||||||
data.rotation = !_sendNetworkNode ? _internalPoseSet._absolutePoses[i].rot() : _networkPoseSet._absolutePoses[i].rot();
|
data.rotation = !_sendNetworkNode ? _internalPoseSet._absolutePoses[i].rot() : _networkPoseSet._absolutePoses[i].rot();
|
||||||
data.rotationIsDefaultPose = isEqual(data.rotation, defaultAbsRot);
|
data.rotationIsDefaultPose = isEqual(data.rotation, defaultAbsRot);
|
||||||
|
|
||||||
// translations are in relative frame but scaled so that they are in meters,
|
// translations are in relative frame.
|
||||||
// instead of model units.
|
|
||||||
glm::vec3 defaultRelTrans = _animSkeleton->getRelativeDefaultPose(i).trans();
|
glm::vec3 defaultRelTrans = _animSkeleton->getRelativeDefaultPose(i).trans();
|
||||||
glm::vec3 currentRelTrans = _sendNetworkNode ? _networkPoseSet._relativePoses[i].trans() : _internalPoseSet._relativePoses[i].trans();
|
glm::vec3 currentRelTrans = _sendNetworkNode ? _networkPoseSet._relativePoses[i].trans() : _internalPoseSet._relativePoses[i].trans();
|
||||||
data.translation = geometryToRigScale * currentRelTrans;
|
data.translation = currentRelTrans;
|
||||||
data.translationIsDefaultPose = isEqual(currentRelTrans, defaultRelTrans);
|
data.translationIsDefaultPose = isEqual(currentRelTrans, defaultRelTrans);
|
||||||
} else {
|
} else {
|
||||||
data.translationIsDefaultPose = true;
|
data.translationIsDefaultPose = true;
|
||||||
|
@ -2015,7 +2014,6 @@ void Rig::copyJointsFromJointData(const QVector<JointData>& jointDataVec) {
|
||||||
std::vector<glm::quat> rotations;
|
std::vector<glm::quat> rotations;
|
||||||
rotations.reserve(numJoints);
|
rotations.reserve(numJoints);
|
||||||
const glm::quat rigToGeometryRot(glmExtractRotation(_rigToGeometryTransform));
|
const glm::quat rigToGeometryRot(glmExtractRotation(_rigToGeometryTransform));
|
||||||
const glm::vec3 rigToGeometryScale(extractScale(_rigToGeometryTransform));
|
|
||||||
|
|
||||||
for (int i = 0; i < numJoints; i++) {
|
for (int i = 0; i < numJoints; i++) {
|
||||||
const JointData& data = jointDataVec.at(i);
|
const JointData& data = jointDataVec.at(i);
|
||||||
|
@ -2041,8 +2039,8 @@ void Rig::copyJointsFromJointData(const QVector<JointData>& jointDataVec) {
|
||||||
if (data.translationIsDefaultPose) {
|
if (data.translationIsDefaultPose) {
|
||||||
_internalPoseSet._relativePoses[i].trans() = relativeDefaultPoses[i].trans();
|
_internalPoseSet._relativePoses[i].trans() = relativeDefaultPoses[i].trans();
|
||||||
} else {
|
} else {
|
||||||
// JointData translations are in scaled relative-frame so we scale back to regular relative-frame
|
// JointData translations are in relative-frame
|
||||||
_internalPoseSet._relativePoses[i].trans() = rigToGeometryScale * data.translation;
|
_internalPoseSet._relativePoses[i].trans() = data.translation;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,7 +54,8 @@ using namespace std;
|
||||||
|
|
||||||
const QString AvatarData::FRAME_NAME = "com.highfidelity.recording.AvatarData";
|
const QString AvatarData::FRAME_NAME = "com.highfidelity.recording.AvatarData";
|
||||||
|
|
||||||
static const int TRANSLATION_COMPRESSION_RADIX = 12;
|
static const int TRANSLATION_COMPRESSION_RADIX = 14;
|
||||||
|
static const int FAUX_JOINT_COMPRESSION_RADIX = 12;
|
||||||
static const int SENSOR_TO_WORLD_SCALE_RADIX = 10;
|
static const int SENSOR_TO_WORLD_SCALE_RADIX = 10;
|
||||||
static const float AUDIO_LOUDNESS_SCALE = 1024.0f;
|
static const float AUDIO_LOUDNESS_SCALE = 1024.0f;
|
||||||
static const float DEFAULT_AVATAR_DENSITY = 1000.0f; // density of water
|
static const float DEFAULT_AVATAR_DENSITY = 1000.0f; // density of water
|
||||||
|
@ -73,6 +74,7 @@ size_t AvatarDataPacket::maxJointDataSize(size_t numJoints, bool hasGrabJoints)
|
||||||
totalSize += validityBitsSize; // Orientations mask
|
totalSize += validityBitsSize; // Orientations mask
|
||||||
totalSize += numJoints * sizeof(SixByteQuat); // Orientations
|
totalSize += numJoints * sizeof(SixByteQuat); // Orientations
|
||||||
totalSize += validityBitsSize; // Translations mask
|
totalSize += validityBitsSize; // Translations mask
|
||||||
|
totalSize += sizeof(float); // maxTranslationDimension
|
||||||
totalSize += numJoints * sizeof(SixByteTrans); // Translations
|
totalSize += numJoints * sizeof(SixByteTrans); // Translations
|
||||||
|
|
||||||
size_t NUM_FAUX_JOINT = 2;
|
size_t NUM_FAUX_JOINT = 2;
|
||||||
|
@ -85,6 +87,23 @@ size_t AvatarDataPacket::maxJointDataSize(size_t numJoints, bool hasGrabJoints)
|
||||||
return totalSize;
|
return totalSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
size_t AvatarDataPacket::minJointDataSize(size_t numJoints) {
|
||||||
|
const size_t validityBitsSize = calcBitVectorSize((int)numJoints);
|
||||||
|
|
||||||
|
size_t totalSize = sizeof(uint8_t); // numJoints
|
||||||
|
|
||||||
|
totalSize += validityBitsSize; // Orientations mask
|
||||||
|
// assume no valid rotations
|
||||||
|
totalSize += validityBitsSize; // Translations mask
|
||||||
|
totalSize += sizeof(float); // maxTranslationDimension
|
||||||
|
// assume no valid translations
|
||||||
|
|
||||||
|
size_t NUM_FAUX_JOINT = 2;
|
||||||
|
totalSize += NUM_FAUX_JOINT * (sizeof(SixByteQuat) + sizeof(SixByteTrans)); // faux joints
|
||||||
|
|
||||||
|
return totalSize;
|
||||||
|
}
|
||||||
|
|
||||||
size_t AvatarDataPacket::maxJointDefaultPoseFlagsSize(size_t numJoints) {
|
size_t AvatarDataPacket::maxJointDefaultPoseFlagsSize(size_t numJoints) {
|
||||||
const size_t bitVectorSize = calcBitVectorSize((int)numJoints);
|
const size_t bitVectorSize = calcBitVectorSize((int)numJoints);
|
||||||
size_t totalSize = sizeof(uint8_t); // numJoints
|
size_t totalSize = sizeof(uint8_t); // numJoints
|
||||||
|
@ -611,13 +630,24 @@ QByteArray AvatarData::toByteArray(AvatarDataDetail dataDetail, quint64 lastSent
|
||||||
assert(numJoints <= 255);
|
assert(numJoints <= 255);
|
||||||
const int jointBitVectorSize = calcBitVectorSize(numJoints);
|
const int jointBitVectorSize = calcBitVectorSize(numJoints);
|
||||||
|
|
||||||
// Start joints if room for at least the faux joints.
|
// include jointData if there is room for the most minimal section. i.e. no translations or rotations.
|
||||||
IF_AVATAR_SPACE(PACKET_HAS_JOINT_DATA, 1 + 2 * jointBitVectorSize + AvatarDataPacket::FAUX_JOINTS_SIZE) {
|
IF_AVATAR_SPACE(PACKET_HAS_JOINT_DATA, AvatarDataPacket::minJointDataSize(numJoints)) {
|
||||||
// Allow for faux joints + translation bit-vector:
|
// Allow for faux joints + translation bit-vector:
|
||||||
const ptrdiff_t minSizeForJoint = sizeof(AvatarDataPacket::SixByteQuat)
|
const ptrdiff_t minSizeForJoint = sizeof(AvatarDataPacket::SixByteQuat)
|
||||||
+ jointBitVectorSize + AvatarDataPacket::FAUX_JOINTS_SIZE;
|
+ jointBitVectorSize + AvatarDataPacket::FAUX_JOINTS_SIZE;
|
||||||
auto startSection = destinationBuffer;
|
auto startSection = destinationBuffer;
|
||||||
|
|
||||||
|
// compute maxTranslationDimension before we send any joint data.
|
||||||
|
float maxTranslationDimension = 0.001f;
|
||||||
|
for (int i = sendStatus.rotationsSent; i < numJoints; ++i) {
|
||||||
|
const JointData& data = jointData[i];
|
||||||
|
if (!data.translationIsDefaultPose) {
|
||||||
|
maxTranslationDimension = glm::max(fabsf(data.translation.x), maxTranslationDimension);
|
||||||
|
maxTranslationDimension = glm::max(fabsf(data.translation.y), maxTranslationDimension);
|
||||||
|
maxTranslationDimension = glm::max(fabsf(data.translation.z), maxTranslationDimension);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// joint rotation data
|
// joint rotation data
|
||||||
*destinationBuffer++ = (uint8_t)numJoints;
|
*destinationBuffer++ = (uint8_t)numJoints;
|
||||||
|
|
||||||
|
@ -684,9 +714,11 @@ QByteArray AvatarData::toByteArray(AvatarDataDetail dataDetail, quint64 lastSent
|
||||||
memset(destinationBuffer, 0, jointBitVectorSize);
|
memset(destinationBuffer, 0, jointBitVectorSize);
|
||||||
destinationBuffer += jointBitVectorSize; // Move pointer past the validity bytes
|
destinationBuffer += jointBitVectorSize; // Move pointer past the validity bytes
|
||||||
|
|
||||||
|
// write maxTranslationDimension
|
||||||
|
AVATAR_MEMCPY(maxTranslationDimension);
|
||||||
|
|
||||||
float minTranslation = (distanceAdjust && cullSmallChanges) ? getDistanceBasedMinTranslationDistance(viewerPosition) : AVATAR_MIN_TRANSLATION;
|
float minTranslation = (distanceAdjust && cullSmallChanges) ? getDistanceBasedMinTranslationDistance(viewerPosition) : AVATAR_MIN_TRANSLATION;
|
||||||
|
|
||||||
float maxTranslationDimension = 0.0;
|
|
||||||
i = sendStatus.translationsSent;
|
i = sendStatus.translationsSent;
|
||||||
for (; i < numJoints; ++i) {
|
for (; i < numJoints; ++i) {
|
||||||
const JointData& data = joints[i];
|
const JointData& data = joints[i];
|
||||||
|
@ -700,12 +732,8 @@ QByteArray AvatarData::toByteArray(AvatarDataDetail dataDetail, quint64 lastSent
|
||||||
#ifdef WANT_DEBUG
|
#ifdef WANT_DEBUG
|
||||||
translationSentCount++;
|
translationSentCount++;
|
||||||
#endif
|
#endif
|
||||||
maxTranslationDimension = glm::max(fabsf(data.translation.x), maxTranslationDimension);
|
destinationBuffer += packFloatVec3ToSignedTwoByteFixed(destinationBuffer, data.translation / maxTranslationDimension,
|
||||||
maxTranslationDimension = glm::max(fabsf(data.translation.y), maxTranslationDimension);
|
TRANSLATION_COMPRESSION_RADIX);
|
||||||
maxTranslationDimension = glm::max(fabsf(data.translation.z), maxTranslationDimension);
|
|
||||||
|
|
||||||
destinationBuffer +=
|
|
||||||
packFloatVec3ToSignedTwoByteFixed(destinationBuffer, data.translation, TRANSLATION_COMPRESSION_RADIX);
|
|
||||||
|
|
||||||
if (sentJoints) {
|
if (sentJoints) {
|
||||||
sentJoints[i].translation = data.translation;
|
sentJoints[i].translation = data.translation;
|
||||||
|
@ -727,12 +755,12 @@ QByteArray AvatarData::toByteArray(AvatarDataDetail dataDetail, quint64 lastSent
|
||||||
Transform controllerLeftHandTransform = Transform(getControllerLeftHandMatrix());
|
Transform controllerLeftHandTransform = Transform(getControllerLeftHandMatrix());
|
||||||
destinationBuffer += packOrientationQuatToSixBytes(destinationBuffer, controllerLeftHandTransform.getRotation());
|
destinationBuffer += packOrientationQuatToSixBytes(destinationBuffer, controllerLeftHandTransform.getRotation());
|
||||||
destinationBuffer += packFloatVec3ToSignedTwoByteFixed(destinationBuffer, controllerLeftHandTransform.getTranslation(),
|
destinationBuffer += packFloatVec3ToSignedTwoByteFixed(destinationBuffer, controllerLeftHandTransform.getTranslation(),
|
||||||
TRANSLATION_COMPRESSION_RADIX);
|
FAUX_JOINT_COMPRESSION_RADIX);
|
||||||
|
|
||||||
Transform controllerRightHandTransform = Transform(getControllerRightHandMatrix());
|
Transform controllerRightHandTransform = Transform(getControllerRightHandMatrix());
|
||||||
destinationBuffer += packOrientationQuatToSixBytes(destinationBuffer, controllerRightHandTransform.getRotation());
|
destinationBuffer += packOrientationQuatToSixBytes(destinationBuffer, controllerRightHandTransform.getRotation());
|
||||||
destinationBuffer += packFloatVec3ToSignedTwoByteFixed(destinationBuffer, controllerRightHandTransform.getTranslation(),
|
destinationBuffer += packFloatVec3ToSignedTwoByteFixed(destinationBuffer, controllerRightHandTransform.getTranslation(),
|
||||||
TRANSLATION_COMPRESSION_RADIX);
|
FAUX_JOINT_COMPRESSION_RADIX);
|
||||||
|
|
||||||
IF_AVATAR_SPACE(PACKET_HAS_GRAB_JOINTS, sizeof (AvatarDataPacket::FarGrabJoints)) {
|
IF_AVATAR_SPACE(PACKET_HAS_GRAB_JOINTS, sizeof (AvatarDataPacket::FarGrabJoints)) {
|
||||||
// the far-grab joints may range further than 3 meters, so we can't use packFloatVec3ToSignedTwoByteFixed etc
|
// the far-grab joints may range further than 3 meters, so we can't use packFloatVec3ToSignedTwoByteFixed etc
|
||||||
|
@ -785,7 +813,7 @@ QByteArray AvatarData::toByteArray(AvatarDataDetail dataDetail, quint64 lastSent
|
||||||
outboundDataRateOut->jointDataRate.increment(numBytes);
|
outboundDataRateOut->jointDataRate.increment(numBytes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IF_AVATAR_SPACE(PACKET_HAS_JOINT_DEFAULT_POSE_FLAGS, 1 + 2 * jointBitVectorSize) {
|
IF_AVATAR_SPACE(PACKET_HAS_JOINT_DEFAULT_POSE_FLAGS, 1 + 2 * jointBitVectorSize) {
|
||||||
auto startSection = destinationBuffer;
|
auto startSection = destinationBuffer;
|
||||||
|
|
||||||
|
@ -871,7 +899,7 @@ const unsigned char* unpackFauxJoint(const unsigned char* sourceBuffer, ThreadSa
|
||||||
glm::vec3 position;
|
glm::vec3 position;
|
||||||
Transform transform;
|
Transform transform;
|
||||||
sourceBuffer += unpackOrientationQuatFromSixBytes(sourceBuffer, orientation);
|
sourceBuffer += unpackOrientationQuatFromSixBytes(sourceBuffer, orientation);
|
||||||
sourceBuffer += unpackFloatVec3FromSignedTwoByteFixed(sourceBuffer, position, TRANSLATION_COMPRESSION_RADIX);
|
sourceBuffer += unpackFloatVec3FromSignedTwoByteFixed(sourceBuffer, position, FAUX_JOINT_COMPRESSION_RADIX);
|
||||||
transform.setTranslation(position);
|
transform.setTranslation(position);
|
||||||
transform.setRotation(orientation);
|
transform.setRotation(orientation);
|
||||||
matrixCache.set(transform.getMatrix());
|
matrixCache.set(transform.getMatrix());
|
||||||
|
@ -1280,6 +1308,12 @@ int AvatarData::parseDataFromBuffer(const QByteArray& buffer) {
|
||||||
}
|
}
|
||||||
} // 1 + bytesOfValidity bytes
|
} // 1 + bytesOfValidity bytes
|
||||||
|
|
||||||
|
// read maxTranslationDimension
|
||||||
|
float maxTranslationDimension;
|
||||||
|
PACKET_READ_CHECK(JointMaxTranslationDimension, sizeof(float));
|
||||||
|
memcpy(&maxTranslationDimension, sourceBuffer, sizeof(float));
|
||||||
|
sourceBuffer += sizeof(float);
|
||||||
|
|
||||||
// each joint translation component is stored in 6 bytes.
|
// each joint translation component is stored in 6 bytes.
|
||||||
const int COMPRESSED_TRANSLATION_SIZE = 6;
|
const int COMPRESSED_TRANSLATION_SIZE = 6;
|
||||||
PACKET_READ_CHECK(JointTranslation, numValidJointTranslations * COMPRESSED_TRANSLATION_SIZE);
|
PACKET_READ_CHECK(JointTranslation, numValidJointTranslations * COMPRESSED_TRANSLATION_SIZE);
|
||||||
|
@ -1288,6 +1322,7 @@ int AvatarData::parseDataFromBuffer(const QByteArray& buffer) {
|
||||||
JointData& data = _jointData[i];
|
JointData& data = _jointData[i];
|
||||||
if (validTranslations[i]) {
|
if (validTranslations[i]) {
|
||||||
sourceBuffer += unpackFloatVec3FromSignedTwoByteFixed(sourceBuffer, data.translation, TRANSLATION_COMPRESSION_RADIX);
|
sourceBuffer += unpackFloatVec3FromSignedTwoByteFixed(sourceBuffer, data.translation, TRANSLATION_COMPRESSION_RADIX);
|
||||||
|
data.translation *= maxTranslationDimension;
|
||||||
_hasNewJointData = true;
|
_hasNewJointData = true;
|
||||||
data.translationIsDefaultPose = false;
|
data.translationIsDefaultPose = false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -277,8 +277,8 @@ namespace AvatarDataPacket {
|
||||||
uint8_t rotationValidityBits[ceil(numJoints / 8)]; // one bit per joint, if true then a compressed rotation follows.
|
uint8_t rotationValidityBits[ceil(numJoints / 8)]; // one bit per joint, if true then a compressed rotation follows.
|
||||||
SixByteQuat rotation[numValidRotations]; // encodeded and compressed by packOrientationQuatToSixBytes()
|
SixByteQuat rotation[numValidRotations]; // encodeded and compressed by packOrientationQuatToSixBytes()
|
||||||
uint8_t translationValidityBits[ceil(numJoints / 8)]; // one bit per joint, if true then a compressed translation follows.
|
uint8_t translationValidityBits[ceil(numJoints / 8)]; // one bit per joint, if true then a compressed translation follows.
|
||||||
SixByteTrans translation[numValidTranslations]; // encodeded and compressed by packFloatVec3ToSignedTwoByteFixed()
|
float maxTranslationDimension; // used to normalize fixed point translation values.
|
||||||
|
SixByteTrans translation[numValidTranslations]; // normalized and compressed by packFloatVec3ToSignedTwoByteFixed()
|
||||||
SixByteQuat leftHandControllerRotation;
|
SixByteQuat leftHandControllerRotation;
|
||||||
SixByteTrans leftHandControllerTranslation;
|
SixByteTrans leftHandControllerTranslation;
|
||||||
SixByteQuat rightHandControllerRotation;
|
SixByteQuat rightHandControllerRotation;
|
||||||
|
@ -286,6 +286,7 @@ namespace AvatarDataPacket {
|
||||||
};
|
};
|
||||||
*/
|
*/
|
||||||
size_t maxJointDataSize(size_t numJoints, bool hasGrabJoints);
|
size_t maxJointDataSize(size_t numJoints, bool hasGrabJoints);
|
||||||
|
size_t minJointDataSize(size_t numJoints);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
struct JointDefaultPoseFlags {
|
struct JointDefaultPoseFlags {
|
||||||
|
|
|
@ -38,10 +38,10 @@ PacketVersion versionForPacketType(PacketType packetType) {
|
||||||
return static_cast<PacketVersion>(EntityQueryPacketVersion::ConicalFrustums);
|
return static_cast<PacketVersion>(EntityQueryPacketVersion::ConicalFrustums);
|
||||||
case PacketType::AvatarIdentity:
|
case PacketType::AvatarIdentity:
|
||||||
case PacketType::AvatarData:
|
case PacketType::AvatarData:
|
||||||
return static_cast<PacketVersion>(AvatarMixerPacketVersion::CollisionFlag);
|
return static_cast<PacketVersion>(AvatarMixerPacketVersion::SendMaxTranslationDimension);
|
||||||
case PacketType::BulkAvatarData:
|
case PacketType::BulkAvatarData:
|
||||||
case PacketType::KillAvatar:
|
case PacketType::KillAvatar:
|
||||||
return static_cast<PacketVersion>(AvatarMixerPacketVersion::FasterAvatarEntities);
|
return static_cast<PacketVersion>(AvatarMixerPacketVersion::SendMaxTranslationDimension);
|
||||||
case PacketType::MessagesData:
|
case PacketType::MessagesData:
|
||||||
return static_cast<PacketVersion>(MessageDataVersion::TextOrBinaryData);
|
return static_cast<PacketVersion>(MessageDataVersion::TextOrBinaryData);
|
||||||
// ICE packets
|
// ICE packets
|
||||||
|
|
|
@ -312,7 +312,8 @@ enum class AvatarMixerPacketVersion : PacketVersion {
|
||||||
GrabTraits,
|
GrabTraits,
|
||||||
CollisionFlag,
|
CollisionFlag,
|
||||||
AvatarTraitsAck,
|
AvatarTraitsAck,
|
||||||
FasterAvatarEntities
|
FasterAvatarEntities,
|
||||||
|
SendMaxTranslationDimension
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class DomainConnectRequestVersion : PacketVersion {
|
enum class DomainConnectRequestVersion : PacketVersion {
|
||||||
|
|
|
@ -14,7 +14,7 @@ public:
|
||||||
};
|
};
|
||||||
|
|
||||||
// Used by the avatar mixer to describe a single joint
|
// Used by the avatar mixer to describe a single joint
|
||||||
// Translations relative to their parent and are in meters.
|
// Translations relative to their parent joint
|
||||||
// Rotations are absolute (i.e. not relative to parent) and are in rig space.
|
// Rotations are absolute (i.e. not relative to parent) and are in rig space.
|
||||||
class JointData {
|
class JointData {
|
||||||
public:
|
public:
|
||||||
|
|
|
@ -379,6 +379,9 @@ function decode_avatar_data_packet(buf)
|
||||||
i = i + num_validity_bytes
|
i = i + num_validity_bytes
|
||||||
result["valid_translations"] = "Valid Translations: " .. string.format("(%d/%d) {", #indices, num_joints) .. table.concat(indices, ", ") .. "}"
|
result["valid_translations"] = "Valid Translations: " .. string.format("(%d/%d) {", #indices, num_joints) .. table.concat(indices, ", ") .. "}"
|
||||||
|
|
||||||
|
-- TODO: skip maxTranslationDimension
|
||||||
|
i = i + 4
|
||||||
|
|
||||||
-- TODO: skip translations for now
|
-- TODO: skip translations for now
|
||||||
i = i + #indices * 6
|
i = i + #indices * 6
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue