11 KiB
11 KiB
拖拽吸附功能 - 潜在Bug分析报告
🔴 严重问题(可能导致间歇性失败)
Bug #1: 竞态条件 - dragStartPosition 可能为 null
位置: AppModelDrag.ts:156-159
let hasMoved = false;
if (meshes && meshes.length > 0 && dragStartPosition) {
const distance = Vector3.Distance(dragStartPosition, meshes[0].position);
hasMoved = distance > 0.01;
}
问题:
dragStartPosition是闭包变量,在多次快速拖拽时可能在onDragEndObservable触发前被清空- 如果用户快速点击-拖动-松开(<50ms),
dragStartPosition可能还未设置就被读取
触发条件: 快速拖拽,或高延迟场景下事件顺序错乱
修复方案:
// 在 onDragStartObservable 中保存到 dragInfo
pointerDragBehavior.onDragStartObservable.add(() => {
const meshes = this.mainApp.appModel?.modelDic?.Get(modelId);
if (meshes && meshes.length > 0) {
if (!dragInfo.startPosition) {
dragInfo.startPosition = meshes[0].position.clone();
}
}
// ...
});
// 在 onDragEndObservable 中使用持久化的位置
if (meshes && meshes.length > 0 && dragInfo.startPosition) {
const distance = Vector3.Distance(dragInfo.startPosition, meshes[0].position);
hasMoved = distance > 0.01;
dragInfo.startPosition = null; // 清除
}
Bug #2: 映射更新时机问题 - 可能丢失映射
位置: AppModelDrag.ts:165-174
if (hasMoved) {
if (dragInfo.config.snapToZone && hasShownZones) {
this.hideZonesForModel(modelId);
this.snapModelToZone(modelId); // ← 这里会 return,不更新映射
} else {
this.updateModelZoneMapping(modelId);
}
}
问题:
snapModelToZone()内部有多个return语句(444、452、483行)- 当返回原位置时,会
return并注释说"不更新映射,保持原映射" - 但如果
zoneModelMap在此之前被意外清空(如替换模式交换),映射就丢失了
触发条件:
- 拖拽到已占用区域
- 配置为返回原位置
- 但原映射已被其他操作修改
修复方案:
// snapModelToZone 内部返回前,确保映射存在
if (originalZoneIndex !== -1) {
const originalKey = `${wallName}[${originalZoneIndex}]`;
// 强制恢复映射
appDropZone['zoneModelMap']?.set(originalKey, modelId);
console.log(`[拖拽吸附] 恢复映射: ${originalKey} -> ${modelId}`);
return;
}
Bug #3: 边界检测逻辑缺陷 - Y轴未检查
位置: AppModelDrag.ts:390-423
// 计算墙面的边界
let minX = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let minZ = Number.POSITIVE_INFINITY;
let maxZ = Number.NEGATIVE_INFINITY;
wallZones.forEach(zone => {
const halfWidth = zone.width / 2;
if (Math.abs(zone.normal.x) > 0.5) {
// 左右墙面
minZ = Math.min(minZ, zone.center.z - halfWidth);
maxZ = Math.max(maxZ, zone.center.z + halfWidth);
} else if (Math.abs(zone.normal.z) > 0.5) {
// 前后墙面
minX = Math.min(minX, zone.center.x - halfWidth);
maxX = Math.max(maxX, zone.center.x + halfWidth);
}
});
// ❌ 问题:Y轴完全未检查!
const currentPos = rootMesh.position;
if (minX !== Number.POSITIVE_INFINITY && maxX !== Number.NEGATIVE_INFINITY) {
if (currentPos.x < minX || currentPos.x > maxX) {
isOutOfBounds = true;
}
}
问题:
- 只检查 X 和 Z 轴,Y轴完全不检查
- 如果用户切换到 Y 轴拖拽,配件可以无限上下移动而不会触发"超出边界"
- 配件可能飞到天上或地下,但系统认为"在边界内"
触发条件:
- 启用 Y 轴拖拽 (
axis: 'y'或'xyz') - 向上或向下拖拽
- 边界检测失效
修复方案:
// 添加 Y 轴边界检查
let minY = Number.POSITIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
wallZones.forEach(zone => {
const halfHeight = zone.height / 2;
minY = Math.min(minY, zone.center.y - halfHeight);
maxY = Math.max(maxY, zone.center.y + halfHeight);
// ... 原有 X/Z 检查
});
// 检查 Y 轴
if (minY !== Number.POSITIVE_INFINITY && maxY !== Number.NEGATIVE_INFINITY) {
if (currentPos.y < minY || currentPos.y > maxY) {
isOutOfBounds = true;
}
}
🟡 中等问题(可能导致不一致)
Bug #4: 旋转角度计算不完整
位置: AppModelDrag.ts:439-441, 478-480, 504-506
const targetDirection = originalZone.normal.scale(-1);
const angle = Math.atan2(targetDirection.x, targetDirection.z);
rootMesh.rotation.y = angle;
问题:
- 只更新 Y 轴旋转,假设配件永远直立
- 如果配件初始有 X 或 Z 轴旋转(倾斜),吸附后会丢失这些旋转
- 对于可旋转的配件(如斜挂的装饰),会出现"吸附后变歪"的问题
触发条件: 配件有非零的 rotation.x 或 rotation.z
修复方案:
// 保存并恢复其他轴的旋转
const originalRotationX = rootMesh.rotation.x;
const originalRotationZ = rootMesh.rotation.z;
const targetDirection = originalZone.normal.scale(-1);
const angle = Math.atan2(targetDirection.x, targetDirection.z);
rootMesh.rotation.y = angle;
// 恢复其他轴
rootMesh.rotation.x = originalRotationX;
rootMesh.rotation.z = originalRotationZ;
Bug #5: 替换模式下的映射覆盖冲突
位置: AppModelDrag.ts:612-640 + AppDropZone.ts:229-234
问题:
AppModelDrag.updateModelZoneMapping() 和 AppDropZone.onModelPlaced() 都会操作 zoneModelMap,可能产生竞态:
时序问题:
1. 用户拖拽模型A到模型B所在位置
2. AppModelDrag 删除 oldKey[A]
3. AppModelDrag 检测到 newKey 已有模型B
4. AppModelDrag 设置 swapKey[B] = B (交换)
5. AppModelDrag 设置 newKey = A
6. ❌ 此时如果 AppDropZone.onModelPlaced() 也触发...
7. AppDropZone 检测到 newKey 已有A
8. AppDropZone 删除模型A(认为是旧模型!)
9. 结果:模型A和B都消失了
触发条件:
- 替换模式 + 快速连续拖拽
- 或者某个事件触发了
onModelPlaced()
修复方案:
// 在 updateModelZoneMapping 中添加锁
private isUpdatingMapping = false;
private updateModelZoneMapping(modelId: string): void {
if (this.isUpdatingMapping) {
console.warn(`[映射更新] 正在更新中,跳过 ${modelId}`);
return;
}
this.isUpdatingMapping = true;
try {
// ... 原有逻辑
} finally {
this.isUpdatingMapping = false;
}
}
Bug #6: 最近区域查找可能不准确
位置: AppModelDrag.ts:367-377, 559-568
wallZones.forEach((zone, index) => {
const distance = rootMesh.position.subtract(zone.center).length();
if (distance < minDistance) {
minDistance = distance;
closestZoneIndex = index;
}
});
问题:
- 使用欧几里得距离(3D空间直线距离)
- 但配件应该吸附到墙面上的投影点,而不是空间距离
- 如果配件被拖到离墙面很远的地方,可能吸附到错误的区域
示例:
墙面:Z=0
区域0: center=(0, 1, 0)
区域1: center=(5, 1, 0)
配件位置: (2.5, 10, 0) ← 离墙面很远,但在正中间
空间距离:
- 到区域0: √((2.5-0)² + (10-1)² + 0²) = √87.25 ≈ 9.34
- 到区域1: √((2.5-5)² + (10-1)² + 0²) = √87.25 ≈ 9.34
结果:可能选中任意一个(取决于遍历顺序)
正确做法: 应该先投影到墙面,再计算2D距离
修复方案:
wallZones.forEach((zone, index) => {
// 计算到墙面的投影点
const toModel = rootMesh.position.subtract(zone.center);
const distanceToPlane = Vector3.Dot(toModel, zone.normal);
const projectedPoint = rootMesh.position.subtract(zone.normal.scale(distanceToPlane));
// 使用投影点到区域中心的距离
const distance = projectedPoint.subtract(zone.center).length();
if (distance < minDistance) {
minDistance = distance;
closestZoneIndex = index;
}
});
🟢 轻微问题(边缘情况)
Bug #7: 闭包变量状态泄漏
位置: AppModelDrag.ts:118-119, 176-178
let dragStartPosition: Vector3 | null = null;
let hasShownZones = false;
// onDragEndObservable 结束时清除
dragStartPosition = null;
hasShownZones = false;
问题:
- 如果
onDragEndObservable因异常未触发,这些变量永远不会清除 - 下次拖拽会使用上次的脏数据
修复方案:
// 在 onDragStartObservable 开始时强制重置
pointerDragBehavior.onDragStartObservable.add(() => {
// 强制清除旧状态(防止异常导致未清除)
dragStartPosition = null;
hasShownZones = false;
// 然后记录新状态
const meshes = this.mainApp.appModel?.modelDic?.Get(modelId);
if (meshes && meshes.length > 0) {
dragStartPosition = meshes[0].position.clone();
}
// ...
});
📋 修复优先级建议
P0 - 立即修复
- Bug #1 - 竞态条件(可能导致吸附完全失效)
- Bug #2 - 映射丢失(配件消失)
- Bug #5 - 替换冲突(配件重复删除)
P1 - 本周修复
- Bug #3 - Y轴边界检测(安全问题)
- Bug #6 - 吸附不准确(用户体验)
P2 - 下周修复
- Bug #4 - 旋转丢失(视觉问题)
- Bug #7 - 状态泄漏(稳定性)
🧪 测试场景(复现间歇性Bug)
场景1:快速连续拖拽
// 模拟用户快速拖拽
for (let i = 0; i < 10; i++) {
setTimeout(() => {
// 快速拖动配件A
dragModel('accessory_A', randomPosition());
}, i * 100); // 每100ms一次
}
场景2:替换模式压力测试
// 两个配件互相替换
setInterval(() => {
dragModel('accessory_A', positionB);
setTimeout(() => {
dragModel('accessory_B', positionA);
}, 50);
}, 500);
场景3:边界外拖拽
// 拖到墙外
dragModel('accessory_A', { x: 1000, y: 0, z: 0 });
// 预期:返回原位置
// 实际:可能映射丢失
场景4:Y轴拖拽
// 启用Y轴拖拽
kernel.drag.configure('accessory_A', {
enable: true,
axis: 'xyz',
snapToZone: true
});
// 向上拖100单位
dragModel('accessory_A', { x: 0, y: 100, z: 0 });
// 预期:触发边界检测
// 实际:边界检测失效
💡 总结
拖拽吸附功能的间歇性问题主要来自:
- 异步状态管理不当(Bug #1, #7)
- 映射更新时序冲突(Bug #2, #5)
- 边界检测不完整(Bug #3)
- 算法不够精确(Bug #6)
建议优先修复 Bug #1、#2、#5,这些会导致明显的功能失效。