# 拖拽吸附功能 - 潜在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` 语句(444、452、483行) - 当返回原位置时,会 `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 }); // 预期:返回原位置 // 实际:可能映射丢失 ``` ### 场景4:Y轴拖拽 ```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,这些会导致明显的功能失效。