当化学结构编辑器遇上鸿蒙 PC:我把 JChemPaint 移植到了鸿蒙

欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_JChemPaint

一、为什么做这件事

做化学、学化学的人对这类场景一定不陌生:写实验报告要画反应式,备课要画分子结构,写论文要导出一张干净的结构图。这类图有个共同要求——键角要标准、原子标注要符合规范、导出要干净,手画或者用通用绘图软件凑合,出来的东西一眼假。

桌面端的化学结构编辑器并不少,老牌的 JChemPaint 从上世纪末迭代至今,功能成熟、开源免费,背后还有 CDK(Chemistry Development Kit)这个化学信息学库撑腰。但它是 Java/Swing 应用——在 Windows 上要装 JRE,在 ARM 架构的国产 PC 生态上更是基本跑不起来。

鸿蒙 PC 生态起来之后,我检索了一圈:办公、笔记、视频这些大众品类已经有人补位,但垂直领域的专业工具,恰恰是生态里最缺的。大型跨平台软件可以等容器方案,但一个化学结构编辑器,容器里拖着整套 JVM 只为了画几根化学键——包体数百 MB、启动慢、跟系统窗口管理完全脱节,这不是正路。

我的选择是第三条路:把 JChemPaint 移植到鸿蒙 PC,用 ArkTS 原生实现。数据模型、渲染引擎、文件格式处理、交互逻辑整体迁移,最终整个应用只有一个 entry 模块、十个源文件,HAP 包体不到 1 MB,启动即用。

主界面:在鸿蒙 PC 桌面上绘制咖啡因结构

二、迁移路线的取舍:为什么是 T0

三方软件迁移到鸿蒙 PC,业内一般分三个等级:

等级方式代价体验
T2 兼容迁移容器/兼容层跑原平台二进制携带完整原运行时,包体巨大系统集成度低,窗口管理脱节
T1 框架迁移跨平台框架编译过来,共享业务代码仍需框架适配层与原生有差距
T0 原生迁移原生技术栈移植实现,全量适配移植成本最高包体、启动、集成度全部拉满

对化学结构编辑器这个体量的应用,功能边界清晰、领域逻辑自洽、不依赖庞大的既有代码库——恰好是 T0 移植的甜点区:成本可控,收益极致。按 T0 清单逐项核对:

  • 窗口与页面:Stage 模型 UIAbility + ArkUI 页面路由,跟随系统窗口管理与焦点模型;
  • 键盘:完整的 Ctrl+Z/Y/C/V/A/S 修饰键组合 + 单键工具切换 + Del/Esc,基于 KeyEvent 与修饰键状态判定;
  • 鼠标:onMouse 悬停追踪 + 命中测试,原子和键悬停高亮,符合桌面交互习惯;
  • 触控:双指捏合缩放(0.2x–5x)、双指平移,全部走原生手势接口;
  • 文件:全部走系统文件选择器,用户逐次授权,应用自身零文件权限;
  • 无私有运行时:没有 JVM、没有浏览器内核、没有 GUI 框架副本。

对照一下:T2 方案要背着 JVM 和整套图形栈,包体轻松破几百 MB;T1 方案也得带框架运行时。这个项目最终 HAP 只有约 0.5 MB——这就是 T0 的意义。

三、总体架构:三层解耦

在这里插入图片描述

分层的铁律是:模型层零 ArkUI 依赖(渲染器只依赖 CanvasRenderingContext2D 这个抽象接口),控制器持有全部编辑逻辑,视图层只做事件转发和重绘。这个约束带来的好处马上就能兑现——后面讲子结构搜索的时候,算法直接跑在纯数据结构上,不需要 mock 任何 UI。

数据模型的核心是四个类,对应化学结构图(molecular graph)的四个要素:

export class Atom {
  id: number;
  symbol: string;      // 元素符号
  x: number;           // 画布坐标
  y: number;
  charge: number = 0;          // 形式电荷
  isotope: number = 0;         // 同位素质量数,0 = 自然丰度
  radical: boolean = false;    // 自由基
  pseudo: string = '';         // R-基团标签
}

export class Bond {
  id: number;
  a: number;           // 原子 id
  b: number;           // 原子 id
  order: number;       // 1..4 键级
  stereo: number = 0;  // 0无 1实楔 2虚楔 3交叉 4波浪
  aromatic: boolean = false;
  ez: number = 0;      // 0无 1=E(反式) 2=Z(顺式)
}

用 id 而不是对象引用来连键,好处是序列化(撤销快照、mol 文件读写、剪贴板)时不需要处理循环引用,坏处是每次取原子都是一次线性查找——下面性能那一节会讲怎么权衡。

应用介绍页:功能总览一目了然

四、核心实现拆解

4.1 整模型快照式撤销/重做

撤销/重做是编辑器的生命线。教科书做法是 Command 模式:每种操作实现 execute()/revert()。但对化学编辑器,"把苯环拖歪了再撤销"要精确还原六个原子的坐标、"合并两个原子再撤销"要还原被删的键——逆向命令的实现量会爆炸,而且每加一个新功能都要配对写一个逆操作,漏一个就是脏状态 bug。

我选择了整模型 JSON 快照:每次变更前 checkpoint(),恢复时重建对象:

private snapshot(): string {
  return JSON.stringify([this.atoms, this.bonds, this.nextId, this.arrows]);
}

private restore(s: string): void {
  const raw = JSON.parse(s) as Object[];
  this.atoms = (raw[0] as Atom[]).map((o: Atom) => {
    const a = new Atom(o.id, o.symbol, o.x, o.y);
    a.charge = o.charge;
    a.isotope = o.isotope;
    a.radical = o.radical;
    a.pseudo = o.pseudo;
    return a;
  });
  // bonds / arrows 同理重建……
  this.nextId = raw[2] as number;
}

checkpoint(): void {
  this.undoStack.push(this.snapshot());
  if (this.undoStack.length > 100) {
    this.undoStack.shift();
  }
  this.redoStack = [];
}

两个关键细节:

  1. 快照必须包含 nextId 计数器——否则撤销后新建原子会分配到历史 ID,键的端点、选中集合全部错乱。这是自测时踩出来的真实 bug;
  2. 恢复时重建对象而不是直接赋值——JSON.parse 出来的是裸对象,没有类方法,直接赋给 this.atoms 后续调用会崩。ArkTS 严格模式下没有 as any 可用,老实写映射代码反而最稳。

4.2 Canvas 渲染:IUPAC 标签规则与芳环内切圆

渲染层最见功力的不是画线,而是何时画、画什么。IUPAC 惯例是链中间的碳原子不写字母,端点碳也不写,读者从键的折线端点自动脑补 CH₃;而 N、O、S 这些杂原子必须标注,并带上隐式氢计数。这一套规则决定了一张结构图是"像专业软件画的"还是"像随手画的":

/** IUPAC 规则:非端点碳隐藏标签 */
private labelVisible(model: ChemModel, a: Atom): boolean {
  if (a.symbol === 'C' && a.pseudo.length === 0) {
    const nb = model.bondsOf(a.id);
    if (nb.length === 0) {
      return true;   // 孤立碳按 CH4 处理
    }
    if (!this.params.showEndCarbons && nb.length === 1) {
      return false;  // 端点碳隐藏
    }
    if (nb.length >= 2) {
      return false;  // 链中间碳隐藏
    }
  }
  return true;
}

配套的隐式氢计算按默认价态表减去键级和,带电原子再修正(N⁺ 价态 4、O⁻ 价态 1、C± 价态 3):

private implicitH(model: ChemModel, a: Atom): number {
  if (a.symbol === 'H' || a.pseudo.length > 0) {
    return 0;
  }
  let sum = 0;
  for (const k of model.bondsOf(a.id)) {
    sum += k.order;          // 键级求和:双键算 2
  }
  const defaults: Record<string, number> = {
    'B': 3, 'C': 4, 'N': 3, 'O': 2, 'P': 3, 'S': 2,
    'F': 1, 'Cl': 1, 'Br': 1, 'I': 1
  };
  let val = defaults[a.symbol] ?? 0;
  if (a.charge !== 0) {
    if (a.symbol === 'N') {
      val = a.charge > 0 ? 4 : 2;
    } else if (a.symbol === 'O' || a.symbol === 'S') {
      val = a.charge > 0 ? 3 : 1;
    } else if (a.symbol === 'C') {
      val = 3;
    }
  }
  const h = val - sum;
  return h > 0 ? h : 0;
}

键本身的绘制按立体语义分派:实楔是从窄到宽的实心三角,虚楔是七根渐宽的横杠,波浪键是锯齿折线,交叉键是两根交叉斜线。普通多键则沿法向平行偏移——双键左右各一条,三键中间一条加两侧各一条:

const nx = -dy / len;   // 键向量的单位法向
const ny = dx / len;
let offs: number[] = [0];
if (k.order === 2) {
  offs = [-sep, sep];
} else if (k.order === 3) {
  offs = [-2 * sep, 0, 2 * sep];
} else if (k.order === 4) {
  offs = [-3 * sep, -sep, sep, 3 * sep];
}
for (let i = 0; i < offs.length; i++) {
  c.beginPath();
  c.moveTo(x1 + nx * offs[i], y1 + ny * offs[i]);
  c.lineTo(x2 + nx * offs[i], y2 + ny * offs[i]);
  c.stroke();
}

还有两个容易被忽略但对成图质量影响巨大的细节:

  • 端点回缩:键要向两端原子标签方向各回缩 10px,并垫一块白色背景框,让键"看起来停在字母边缘"而不是穿过字母;
  • 芳环内切圆:芳香环的键全部画单线,环中心画一个表示离域大 π 键的圆,半径按环上原子到质心的平均距离乘以缩放系数计算。相比 Kekulé 交替双键,它对萘、蒽、芘这类稠环的视觉表现更干净,也不用处理交替双键在共享边上的冲突分配。

4.3 命中测试:编辑器手感的根基

编辑器的手感好不好,八成取决于命中测试。原子用圆形距离判定,键用点到线段距离——注意不是点到直线,端点外的情况要截断:

hitBond(x: number, y: number, tol: number): Bond | undefined {
  let best: Bond | undefined = undefined;
  let bestD = tol;
  for (const k of this.bonds) {
    const p = this.getAtom(k.a);
    const q = this.getAtom(k.b);
    if (p === undefined || q === undefined) {
      continue;
    }
    const dx = q.x - p.x;
    const dy = q.y - p.y;
    const l2 = dx * dx + dy * dy;
    let t = l2 === 0 ? 0 : ((x - p.x) * dx + (y - p.y) * dy) / l2;
    t = Math.max(0, Math.min(1, t));   // 截断到线段范围
    const d = Math.hypot(x - (p.x + t * dx), y - (p.y + t * dy));
    if (d <= bestD) {
      bestD = d;
      best = k;
    }
  }
  return best;
}

命中容差也有讲究:原子 12px、键 8px——原子判定更宽松,因为用户 intent 上"点到原子附近"就是想选中原子;键更严格,避免稠密区域抢命中。每次鼠标移动都跑一遍命中测试驱动悬停高亮,鼠标手势和最终点击行为完全一致,所见即所得。

4.4 指针状态机:30° 吸附与幻影预览

绘制交互的状态机分按下/移动/抬起三态。最有意思的是"从已有原子拖出新键"这条路径:移动超过 6px 才确认为拖拽(区分点击升级键级),拖拽过程画灰色幻影预览,抬起时目标角度吸附到 30° 网格:

// 抬起阶段:从已有原子拖出
const from = this.dragAtom;
let target = this.model.hitAtom(x, y, 12);
if (target === undefined || target.id === from.id) {
  // 没有落在其他原子上:按 30° 吸附角生成新原子
  const ang = Math.round(Math.atan2(y - from.y, x - from.x) / (Math.PI / 6)) * (Math.PI / 6);
  target = this.model.addAtom(this.element,
    from.x + BOND_LEN * Math.cos(ang),
    from.y + BOND_LEN * Math.sin(ang));
}
this.model.addBond(from.id, target.id, spec[0], spec[1]);

这一条规则同时解决了三个问题:键长恒定 40px(吸附距离钳制)、键角永远落在 30° 的整数倍上(Math.PI / 6 的整数倍)、拖到已有原子上自动成环键而不是叠一个新原子。链工具复用同一套吸附逻辑,按拖拽距离除以键长算段数,之字形交替 ±30° 生成。

4.5 原子合并:拖拽落点的拓扑修复

把一个原子拖到另一个原子上时触发合并,这不是删一个加一个的事——要把被合并原子的所有键改接到目标原子上,然后清理由此产生的自环和重复键:

mergeNear(drag: Atom, tol: number): void {
  for (const a of this.atoms) {
    if (a.id === drag.id) {
      continue;
    }
    if (Math.hypot(a.x - drag.x, a.y - drag.y) <= tol) {
      // 改接所有键
      for (const k of this.bonds) {
        if (k.a === drag.id) {
          k.a = a.id;
        } else if (k.b === drag.id) {
          k.b = a.id;
        }
      }
      this.removeAtom(drag.id);
      // 去自环(合并后 a==b 的键)
      this.bonds = this.bonds.filter((k: Bond) => k.a !== k.b);
      // 去重复键(两个原子间出现两条平行键)
      const seen: Set<string> = new Set();
      this.bonds = this.bonds.filter((k: Bond) => {
        const key = k.a < k.b ? `${k.a}-${k.b}` : `${k.b}-${k.a}`;
        if (seen.has(key)) {
          return false;
        }
        seen.add(key);
        return true;
      });
      this.updateRings();
      return;
    }
  }
}

这两个清理步骤缺一不可:环上相邻两个原子合并必然产生自环,而如果两个原子本来就有共享邻居,合并后会出现重复边。合并完成后立刻重跑找环,保证芳香性判定跟拓扑同步。

4.6 SMILES 子集解析器

SMILES 是化学结构的线性文本表示,CC(=O)O 就是乙酸,c1ccccc1 是苯。它本质上是一个深度优先遍历的编码,所以解析器用单趟扫描 + 分支栈就够了:( ) 压栈/弹栈切换连接点,数字标记环闭合(第一次出现登记等待配对,第二次出现成环键),[Na+] 括号原子支持电荷:

if (ch >= '1' && ch <= '9') {
  const d = parseInt(ch, 10);
  i++;
  const open = ringOpen.get(d);
  if (open === undefined) {
    ringOpen.set(d, prevId);       // 环闭合号首次出现,登记原子
  } else {
    m.addBond(open, prevId, ord);  // 二次出现,成环
    ringOpen.delete(d);
  }
  pendingOrder = 1;
  continue;
}

小写字母 c n o s p 解析为芳香原子, aromatic 标记沿键传播,配合找环算法就能正确识别苯环并触发内切圆渲染。

解析完成后接一个简化版二维布局器,让从 SMILES 插入的分子落上画布就是标准构型:

// 首个环:正多边形放置
const nn = firstRing.atoms.length;
const rr = L / (2 * Math.sin(Math.PI / nn));   // 内接圆半径
for (let idx = 0; idx < nn; idx++) {
  const ang = -Math.PI / 2 + (idx * 2 * Math.PI) / nn;
  a.x = cx + rr * Math.cos(ang);
  a.y = cy + rr * Math.sin(ang);
}
// 其余原子:BFS 展开,按入射方向 120° 扇形均分
const off = (idx - (k - 1) / 2) * (Math.PI / 3);
const ang = baseAng + off;
na.x = ca.x + L * Math.cos(ang);
na.y = ca.y + L * Math.sin(ang);

L / (2·sin(π/n)) 这个式子值得念叨一句:正 n 边形边长 40px,外接圆半径就是 40 / (2·sin(π/n))——三角函数在这里保证了无论几元环,键长都严格相等。

4.7 子结构搜索:BFS 子图匹配

"搜索"对话框输入一个 SMILES 查询式,命中的原子自动高亮选中。算法是最朴素的子图匹配:对每个与查询首原子同符号的模型原子做锚点尝试,BFS 逐层扩展,邻居符号与键级同时匹配才算命中:

for (const ma of this.model.atoms) {
  if (ma.symbol !== qAtoms[0].symbol) {
    continue;                          // 锚点符号不匹配,跳过
  }
  const matchMap: Map<number, number> = new Map();  // 查询原子id -> 模型原子id
  matchMap.set(qAtoms[0].id, ma.id);
  const queue: number[] = [qAtoms[0].id];
  let ok = true;
  while (queue.length > 0 && ok) {
    const qid = queue.shift() ?? -1;
    const mid = matchMap.get(qid) ?? -1;
    for (const qb of qBonds) {
      // 对查询原子 qid 的每条键,在模型原子 mid 的键里找匹配
      if (qb.a !== qid && qb.b !== qid) {
        continue;
      }
      const qOther = qb.a === qid ? qb.b : qb.a;
      // 在 model.bondsOf(mid) 中查找
      // 符号相同且键级相同的邻居
    }
  }
  if (ok && matchMap.size === qAtoms.length) {
    matches.push(...matchMap.values());
  }
}

因为模型层零 UI 依赖,这段算法可以直接在纯数据上开发和验证,最后在对话框里调用三行接上高亮——分层设计在这个功能上兑现了全部承诺。

示例图版:一键加载六个经典分子

4.8 模板库:SMILES 即数据

53 个模板没有一个是画出来的——除了 3~8 元环用程序化正多边形生成,其余全部用 SMILES 字符串定义,插入时走解析器 + 布局器:

export const TEMPLATES: TemplateEntry[] = [
  { name: '萘',  category: '芳环', smiles: 'c1ccc2ccccc2c1' },
  { name: '咖啡因骨架', category: '杂环', smiles: 'CN1C=NC2=C1C(=O)N(C)C(=O)N2C' },
  { name: '乙酸', category: '试剂', smiles: 'CC(=O)O' },
  { name: '胆固醇骨架', category: '甾体', smiles: 'CC(C)CCCC(C)C1CCC2C1(C)CCC1C3CCC4CC(O)CCC4(C)C3CCC12C' },
  // ……共 53 个,9 个分类
];

这个设计的好处是模板库的维护成本趋近于零:想加一个新模板,往数组里加一行 SMILES 就行,坐标是自动算的。甾体骨架这种四环稠合的复杂结构,SMILES 写对,落上画布就是标准构型。

4.9 零权限的文件读写

读 mol/SDF、保存 mol、导出 PNG,全部通过系统 DocumentViewPicker 完成——用户在系统对话框里选中哪个文件,应用才拿到哪个文件的临时访问授权。应用清单里不申请任何持久权限,也不申请网络权限,结构数据全程不出沙箱。

导出 PNG 的链路是三段式:画布快照 → 图像打包 → 保存选择器:

componentSnapshot.get('chemcanvas')
  .then((pm: image.PixelMap) => {
    const packer = image.createImagePacker();
    const opts: image.PackingOption = { format: 'image/png', quality: 100 };
    packer.packToData(pm, opts)          // SDK 6.0.1:pack() 已移除
      .then((buf: ArrayBuffer) => {
        const docSave = new picker.DocumentSaveOptions();
        docSave.newFileNames = ['structure.png'];
        new picker.DocumentViewPicker().save(docSave)
          .then((uris: Array<string>) => {
            const file = fs.openSync(uris[0],
              fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
            fs.writeSync(file.fd, buf);
            fs.closeSync(file);
          });
      });
  });

文件打开时做格式嗅探:内容含 $$$$ 按 SDF 多分子处理,含 V2000 按 mol 处理,否则尝试按 SMILES 解析——用户不用记自己存的到底是什么格式。

五、运行效果

在鸿蒙 PC 真机上,从 dock 启动到可绘制不到一秒。主界面截图里,咖啡因(五元咪唑环稠合六元嘧啶环)用模板 + 双键工具半分钟画完:30° 角度吸附让每根键角度干净利落,N/O 原子自动带 CPK 配色(氮蓝氧红),双键平行线间距均匀,羰基的 C=O 一眼可辨。

状态栏实时显示原子数、键数和 Hill 系统分子式——画完咖啡因的一瞬间,分子式: C8N4O2(加氢后即 C₈H₁₀N₄O₂)直接给你核对答案,这是画图工具和化学工具的分野。

示例图版一键加载咖啡因、阿司匹林、尼古丁、香草醛、肾上腺素、维生素 C 六个经典分子,全部经 SMILES 解析 + 自动布局落位,稠环、链状、带电荷结构都有覆盖,可以直接当素材库用。

应用市场页面

六、踩坑记录

坑 1:图像打包 API 的版本差异。 早期资料里的 imagePacker.pack(pixelMap) 在 SDK 6.0.1 已经移除,必须改用 packToData(source, PackingOption) 直接拿 ArrayBuffer,再用 fs.writeSync 写入。查 API 文档时一定要对照自己的 compileSdkVersion,别照抄旧博客。

坑 2:签名配置污染构建文件。 DevEco 自动签名会把 signingConfigs 连同本机绝对路径(C:/Users/xxx/...)、加密密码串一起写进根目录 build-profile.json5。这个文件一旦提交仓库,别人克隆后构建必然失败(材料路径不存在),签名 profile 文件名里的中文还会因编码问题变成乱码。正确做法:入库前把签名块整个删掉,签名交给每个人本地的 DevEco 自动签名;同时在 .gitignore 里把 *.p7b / *.p12 / *.cer / *.csr / *.keystore 全部拦住——p12 里可是私钥。

坑 3:命令行构建找不到 Java。 hvigor 的打包工具依赖 Java,命令行构建报 spawn java ENOENT,需要把 DevEco 自带 JBR 的 bin 目录前置到 PATH,并设 DEVECO_SDK_HOME 指向 SDK 目录。

坑 4:快照式撤销的 ID 陷阱。 前面提过,撤销快照漏掉 nextId 计数器,会让撤销后新建的原子复用历史 ID。表现为:撤销→画新原子→之前指向这个位置的其他分子键全部"接"到了新原子上。调试了半小时才发现是 ID 计数器被回退了。

坑 5:剪贴板方案的取舍。 系统剪贴板在沙箱应用间传递富文本并不可靠,最终用应用内自维护的 mol 文本缓冲做复制/粘贴。顺带的好处是剪贴板内容就是标准 MDL 格式,黏贴到其他化学软件里也能认——格式即接口。

七、复现指南

git clone https://atomgit.com/OpenHarmonyPCDeveloper/ohos_JChemPaint.git
  1. DevEco Studio 打开工程根目录(无外部依赖,同步秒完成);
  2. Build > Build Hap(s) 直接出未签名 HAP;
  3. File > Project Structure > Signing Configs 勾选自动签名;
  4. 连接设备运行。

命令行构建:

$env:DEVECO_SDK_HOME = "<DevEco 安装路径>\sdk"
$env:JAVA_HOME = "<DevEco 安装路径>\jbr"
$env:PATH = "$env:JAVA_HOME\bin;$env:PATH"
hvigorw.bat --mode module -p module=entry@default -p product=default assembleHap --no-daemon

工程要求 API 12+,目标设备 2in1 / 平板。仓库内附完整的 README.md(架构图、算法说明、快捷键表、渲染参数说明)与 README.OpenHarmony_CN.md(开源合规披露与 T0 迁移核对清单),签名材料、构建产物、IDE 缓存均已在 .gitignore 中排除。

八、性能账本

几个量级估算,给关心性能的读者:

  • 撤销快照:单次快照是一个 JSON 字符串,100 个原子的分子约 20KB,栈深 100 上限 2MB 内存——完全无感;
  • 命中测试:原子和键都是 O(n) 线性扫描,n 是单个分子的规模(通常 < 200),单次测试微秒级,鼠标移动事件驱动毫无压力;
  • 找环:DFS + 回边检测,O(V+E),只在拓扑变化后调用,不在渲染帧内;
  • 渲染:每帧一次全量重绘,Canvas 2D 画几百条线段在 PC 上远不到 1ms,无需脏矩形优化。

数据结构上的取舍:键用原子 id 而非对象引用连接,换来序列化零成本(快照/文件/剪贴板三处受益),代价是 getAtom 线性查找——在百级原子规模下,这笔账怎么算都是赚的。

九、写在最后

这个项目给我的最大感受是:垂直领域小工具是原生生态最好的填坑素材。它体量小到一个人几周能移植完,又完整覆盖了数据建模、Canvas 渲染、文件格式解析、手势交互、撤销系统这些桌面应用的核心命题。移植的过程本质上是把二十多年开源社区打磨出的交互资产,翻译成新生态的原生语言——License 合规上做的是概念性移植,只复用功能设计与交互理念,不携带上游源码,上游 LGPL-2.1 与本仓库许可证在 README 中均已明确披露。

后续规划按优先级排:手性立体标注(@/@@ 与楔形键自动推导)→ V3000 / RXN 反应式格式 → 力导向布局优化 → 分子力学优化。如果你也在给鸿蒙 PC 生态填坑,欢迎交流踩坑经验;Issue 和 PR 都开放。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐