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

11 KiB
Raw Blame History

拖拽吸附功能 - 潜在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 触发前被清空
  • 如果用户快速点击-拖动-松开<50msdragStartPosition 可能还未设置就被读取

触发条件: 快速拖拽,或高延迟场景下事件顺序错乱

修复方案:

// 在 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 在此之前被意外清空(如替换模式交换),映射就丢失了

触发条件:

  1. 拖拽到已占用区域
  2. 配置为返回原位置
  3. 但原映射已被其他操作修改

修复方案:

// 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 轴拖拽,配件可以无限上下移动而不会触发"超出边界"
  • 配件可能飞到天上或地下,但系统认为"在边界内"

触发条件:

  1. 启用 Y 轴拖拽 (axis: 'y''xyz')
  2. 向上或向下拖拽
  3. 边界检测失效

修复方案:

// 添加 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 - 立即修复

  1. Bug #1 - 竞态条件(可能导致吸附完全失效)
  2. Bug #2 - 映射丢失(配件消失)
  3. Bug #5 - 替换冲突(配件重复删除)

P1 - 本周修复

  1. Bug #3 - Y轴边界检测安全问题
  2. Bug #6 - 吸附不准确(用户体验)

P2 - 下周修复

  1. Bug #4 - 旋转丢失(视觉问题)
  2. 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 });
// 预期:返回原位置
// 实际:可能映射丢失

场景4Y轴拖拽

// 启用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这些会导致明显的功能失效。