化学绘图软件有个反直觉的bug:画分子结构时,点中原子很容易,点中那根细细的化学键却像抽奖。ChemDraw卖了30年许可证,这个交互难题被一位用领域驱动设计(Domain-Driven Design,DDD)重写克隆版的开发者用向量投影+钳制(clamping)算法解决了——核心代码不到20行。
原子检测:欧氏距离的"偷懒"艺术
检测鼠标是否悬停在原子上,是整件事里最仁慈的部分。每个原子在领域模型里存着精确的(x, y)坐标,开发者直接用欧氏距离公式:√[(x₂-x₁)² + (y₂-y₁)²]。鼠标位置与所有原子算一遍距离,过滤掉超出搜索半径的,再按距离排序取最小值。
代码里藏着个产品经理都懂的细节:当多个原子挤在搜索半径内时,系统永远选最近的那个。这不是什么高深算法,是对用户意图的尊重——你点下去的那一刻,软件猜的是"我想要离光标最近的东西",而不是"随便给我一个范围内的"。
实现代码很直白:遍历原子集合,map计算距离,filter筛候选,sort取首位。没有空间索引,没有R树优化,因为分子结构的原子数量级通常在几十到几百,暴力遍历的成本可以忽略。这种"先解决正确性,再考虑性能"的取舍,是DDD分层架构给的底气——领域层只关心业务规则,基础设施的优化可以后面再插。
化学键的"无限直线"陷阱:为什么经典公式会骗人
化学键的检测才是噩梦的开始。一根键是两个原子之间的线段,不是无限延伸的直线。开发者的第一直觉是用点到直线的距离公式 |Ax+By+C|/√(A²+B²),这公式在高中数学课本里躺了几十年,却藏着个致命的假设:它把线段当成无限长的直线来处理。
想象一下:画布上有上百个化学键,你点击某个空旷区域,但只要这个位置恰好和某根键的延长线重合,公式就会报"命中"。这种"幽灵碰撞"让用户体验崩塌——你以为点中了A键,软件却认为你点中了远在十万八千里外的B键的延长线。
问题的本质是对几何实体的建模错误。化学键在业务语义里是"有边界的连接",数学公式却把它抽象成"无边界的方向"。DDD在这里的价值凸显:领域模型必须忠实反映业务概念,而不是迁就数学便利。
向量投影+钳制:把数学拉回业务现实
解决方案需要把鼠标位置投影到线段上,然后做一件关键的事:钳制(clamping)。算法计算一个投影因子t,代表鼠标在AB线段方向上的相对位置。t=0对应A点,t=1对应B点,t=0.5对应中点。
核心就一行代码:t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / abLengthSq))。这行代码把t强行锁死在[0,1]区间内——如果投影落在线段之外,就取最近的端点。换句话说,光标在线段延长线方向的任何位置,都被"拽"回线段的物理边界内。
钳制后的距离计算,才是真正有意义的"点到线段距离"。开发者用这个距离和搜索半径比较,决定是否命中。代码里还处理了退化情况:当两个原子重叠(abLengthSq为0)时,直接退化为点到点距离,避免除以零。
这个实现和经典游戏引擎的线段碰撞检测同源,但放在化学绘图这个垂直领域,它解决的是特定的问题:用户想选中的是"这个化学键",不是"这条直线上的某个抽象位置"。
从像素到意图:交互设计的最后一公里
技术实现只是 half the battle。搜索半径设多大?太小会让用户抓狂地瞄准,太大会导致误触。开发者没有透露具体数值,但留下了线索:这是一个"定义的搜索半径"(defined search radius),暗示这是可配置的领域规则,不是写死的魔法数字。
另一个隐藏细节是候选排序。原子检测按距离排序取最近,键的检测同样如此。当用户意图模糊时(比如光标刚好在两个键的交叉点附近),系统用距离做仲裁,而不是随机或按绘制顺序。这种一致性降低了用户的学习成本。
ChemDraw作为行业标准,其交互细节经过三十年打磨。这个DDD克隆版的意义不在于"做得更好",而在于证明:当领域模型足够清晰时,复杂交互可以被优雅地分解——原子是点,键是线段,命中检测是距离计算,边界处理是钳制。每一层都只做一件事,且做对一件事。
项目目前开源在GitHub,开发者还在迭代。下一个待解的问题可能是:当用户框选多个元素时,领域事件如何传播?或者,撤销操作在DDD的聚合根(Aggregate Root)边界内如何设计?
如果你用过ChemDraw,有没有被选中化学键的精度折磨过?这个3行核心代码的解决方案,和你的肌肉记忆匹配吗?
热门跟贴