Files
zhengte.babylonjs-sdk/DRAG_SNAP_BUG_ANALYSIS.md
2026-06-05 20:09:55 +08:00

395 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 拖拽吸附功能 - 潜在Bug分析报告
## 🔴 严重问题(可能导致间歇性失败)
### Bug #1: **竞态条件 - dragStartPosition 可能为 null**
**位置:** `AppModelDrag.ts:156-159`
```typescript
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` 可能还未设置就被读取
**触发条件:** 快速拖拽或高延迟场景下事件顺序错乱
**修复方案:**
```typescript
// 在 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`
```typescript
if (hasMoved) {
if (dragInfo.config.snapToZone && hasShownZones) {
this.hideZonesForModel(modelId);
this.snapModelToZone(modelId); // ← 这里会 return不更新映射
} else {
this.updateModelZoneMapping(modelId);
}
}
```
**问题:**
- `snapModelToZone()` 内部有多个 `return` 语句444452483行
- 当返回原位置时 `return` **注释说"不更新映射保持原映射"**
- 但如果 `zoneModelMap` 在此之前被意外清空如替换模式交换映射就丢失了
**触发条件:**
1. 拖拽到已占用区域
2. 配置为返回原位置
3. 但原映射已被其他操作修改
**修复方案:**
```typescript
// 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`
```typescript
// 计算墙面的边界
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 轴拖拽配件可以无限上下移动而不会触发"超出边界"
- 配件可能飞到天上或地下但系统认为"在边界内"
**触发条件:**
1. 启用 Y 轴拖拽 (`axis: 'y'` `'xyz'`)
2. 向上或向下拖拽
3. 边界检测失效
**修复方案:**
```typescript
// 添加 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`
```typescript
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
**修复方案:**
```typescript
// 保存并恢复其他轴的旋转
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()`
**修复方案:**
```typescript
// 在 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`
```typescript
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距离
**修复方案:**
```typescript
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`
```typescript
let dragStartPosition: Vector3 | null = null;
let hasShownZones = false;
// onDragEndObservable 结束时清除
dragStartPosition = null;
hasShownZones = false;
```
**问题:**
- 如果 `onDragEndObservable` 因异常未触发这些变量永远不会清除
- 下次拖拽会使用**上次的脏数据**
**修复方案:**
```typescript
// 在 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 - 立即修复
1. **Bug #1** - 竞态条件可能导致吸附完全失效
2. **Bug #2** - 映射丢失配件消失
3. **Bug #5** - 替换冲突配件重复删除
### P1 - 本周修复
4. **Bug #3** - Y轴边界检测安全问题
5. **Bug #6** - 吸附不准确用户体验
### P2 - 下周修复
6. **Bug #4** - 旋转丢失视觉问题
7. **Bug #7** - 状态泄漏稳定性
---
## 🧪 测试场景复现间歇性Bug
### 场景1快速连续拖拽
```javascript
// 模拟用户快速拖拽
for (let i = 0; i < 10; i++) {
setTimeout(() => {
// 快速拖动配件A
dragModel('accessory_A', randomPosition());
}, i * 100); // 每100ms一次
}
```
### 场景2替换模式压力测试
```javascript
// 两个配件互相替换
setInterval(() => {
dragModel('accessory_A', positionB);
setTimeout(() => {
dragModel('accessory_B', positionA);
}, 50);
}, 500);
```
### 场景3边界外拖拽
```javascript
// 拖到墙外
dragModel('accessory_A', { x: 1000, y: 0, z: 0 });
// 预期:返回原位置
// 实际:可能映射丢失
```
### 场景4Y轴拖拽
```javascript
// 启用Y轴拖拽
kernel.drag.configure('accessory_A', {
enable: true,
axis: 'xyz',
snapToZone: true
});
// 向上拖100单位
dragModel('accessory_A', { x: 0, y: 100, z: 0 });
// 预期:触发边界检测
// 实际:边界检测失效
```
---
## 💡 总结
拖拽吸附功能的间歇性问题主要来自
1. **异步状态管理不当**Bug #1, #7
2. **映射更新时序冲突**Bug #2, #5
3. **边界检测不完整**Bug #3
4. **算法不够精确**Bug #6
建议优先修复 Bug #1#2#5这些会导致明显的功能失效