把 BlenderBIM / IfcOpenShell 的 IFC 世界搬上 HarmonyOS PC:筑眸移植全记录

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

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

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

一句话:在 HarmonyOS PC(2in1 / Tablet)上做一个能真正打开 .ifc 文件的原生浏览器——不是网页套壳,不是截图 Demo,而是在应用沙箱里跑起 CPython 3.12 常驻进程、用 ArkTS + ArkUI 画空间结构树与平面图纸、用 系统文件选择器 零权限读取用户模型的完整链路。

项目参数
应用名 / 包名筑眸 · ZhuMou BIM / org.zhumu.bim
目标平台HarmonyOS PC(2in1、tablet),真机 HarmonyOS NEXT · API 26
编译配置compatibleSdkVersion 6.0.1(21) / targetSdkVersion 6.0.2(22)
架构ArkTS 宿主 + Native 桥 + 常驻 Python 内核(T1 引擎迁移级)
运行时交叉编译 CPython 3.12 · aarch64-linux-ohos,HAP 内置 libpython3.12.so
内核以 IfcOpenShell / BlenderBIM 的 IFC 数据模型为语义基准,P1 用零 C 依赖的纯 Python SPF 解析 + 平面几何内核
权限requestPermissions: [],文件访问走 DocumentViewPicker 用户授权
开源许可GPL-3.0
版本 / 构建1.0.0 / bonsai-1.0.0-p1

图 1 筑眸在 HarmonyOS PC 上的主界面:工具栏、空间结构树、平面视图、属性面板与控制台

图 1:首启后的空工作区。深色三栏布局,右侧控制台已经打印「[内核] 就绪(常驻进程)」——注意,此时 Python 解释器已经在后台跑起来了,界面只是还没有模型。

在这里插入图片描述

1. 为什么 BIM 软件是鸿蒙 PC 的硬骨头

BlenderBIM 是建筑行业里少见的开源全栈:Blender 提供交互与视口,Python 提供业务逻辑,IfcOpenShell 的 C++ 核心(OpenCASCADE、CGAL 等)负责把 IFC/STEP 文件解析成真实的 B-Rep 几何。把一个 Python 写的桌面应用搬上桌面系统,通常的做法是「装个解释器、pip install 一下」。但在 HarmonyOS PC 上,这条路几乎是封死的:

  • 沙箱不可执行:应用 filesDir 挂载为 noexec,把 python3 二进制解压进去也 exec 不起来;
  • 链接器命名空间:应用沙箱内不允许随意 dlopen 自己解压出来的 .so,C 扩展不能按常规 lib-dynload 目录加载;
  • 没有自由 fork/exec:常规「起一个子进程跑脚本」要换成系统提供的 childProcessManager.startNativeChildProcess;
  • PTY 不可用:posix_openpt 在沙箱里直接 EPERM,交互式终端方案失效;
  • 原生几何库缺失:OCCT / CGAL / IfcOpenShell 的 C++ 内核没有 aarch64-linux-ohos 现成产物,交叉编译成本远高于应用本身;
  • 权限模型不同:桌面上「随便读个路径」在鸿蒙上要换成用户授权的 DocumentViewPicker,申请一堆存储权限既过不了审也没必要;
  • UI 必须重写:Blender 的窗口系统不可能移植,界面要 ArkTS/ArkUI 原生实现。

所以这不是「打包 Python 应用」,而是重新设计一条从进程、文件、通信到渲染的完整链路。筑眸的目标是:用户点「打开 IFC…」,选一个真实的 .ifc,就能在鸿蒙 PC 上看到空间结构树、平面图纸、构件属性与 Pset,并能在画布上点选构件。

图 2 示例模型 sample-house.ifc 加载后的平面图纸与模型树

图 2:加载随包示例后的效果。左侧是从 IFCPROJECT → IFCSITE → IFCBUILDING → IFCBUILDINGSTOREY → IFCWALL/IFCSPACE/… 展开的空间结构树,中间是按米制网格、尺寸线、房间名称标注绘制的平面图,右侧控制台逐条打印内核返回。


2. 三条移植路线与取舍

路线做法在鸿蒙 PC 上的结果
T0 套壳Electron / WebView 里塞一个 JS 版 IFC 查看器能看,但 IFC 生态的 Python 资产全部作废,性能与离线能力受限,且不像原生 PC 应用
T1 引擎迁移(本作)HAP 内置 CPython 3.12,Native 子进程装载解释器,ArkTS 重写 UI,Python 侧保留 IFC 语义与算法复用 Python 生态、离线可跑、界面原生;代价是要自己解决进程/通信/打包链路
T2 全量重写用 ArkTS/C++ 重写 IFC 解析与几何内核性能上限最高,但 IFC4 语义庞大、几何内核不可短周期完成

最终选择 T1 + 纯 Python 内核:把 IfcOpenShell 的 C++ 几何部分暂时换成自研的、零 C 依赖的 ISO-10303-21(STEP/SPF)解析器 + 平面轮廓投影,先让「打开 → 解析 → 结构树 → 属性 → 平面图 → 点选」这条主链路在真机上完整跑通;交叉编译 IfcOpenShell 真 B-Rep 内核留到 P2。这一点必须说清楚:界面上写「IfcOpenShell / BlenderBIM 内核 · Python」是语义与数据模型的致敬,P1 实际跑的是 bonsai_engine.py,不谎称已经移植了 OCCT。


图 3 在画布/模型树中选中「南墙」,右侧同步显示 IFCWALL 的 GlobalId、ObjectPlacement、Representation 等属性

3. 总体架构:一个 HAP 里的三层世界

在这里插入图片描述

三个关键设计决策:

  1. 解释器放 libs,标准库放 filesDir:.so 由系统加载器按 HAP 的 native lib 路径装载,纯 .py 运行时因为 noexec 无所谓,解压到应用沙箱即可;
  2. 通信不用管道/PTY,用「socketpair fd + 文件队列」:fd 解决进程启动时的句柄传递,文件队列解决「类型是 JSON、大小不定、必须可重试」的请求/响应;
  3. Python 内核常驻:IFC 文件解析一次后 IfcDoc 常驻内存,后续 get_tree / get_entity / get_geometry 都是毫秒级调用,界面不会每次点击都重新加载模型。

4. 工程骨架:HarmonyOS 侧的配置

AppScope/app.json5 定义身份:

{
  "app": {
    "bundleName": "org.zhumu.bim",
    "vendor": "zhumu",
    "versionCode": 1000000,
    "versionName": "1.0.0",
    "icon": "$media:app_icon",
    "label": "$string:app_name"
  }
}

entry/src/main/module.json5——这里最能体现「PC 应用」与「零权限」两件事:

{
  "module": {
    "name": "entry",
    "type": "entry",
    "description": "$string:module_desc",
    "mainElement": "EntryAbility",
    "deviceTypes": ["2in1", "tablet"],
    "deliveryWithInstall": true,
    "installationFree": false,
    "pages": "$profile:main_pages",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:app_icon",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "skills": [
          { "entities": ["entity.system.home"], "actions": ["action.system.home"] }
        ]
      }
    ],
    "requestPermissions": []
  }
}

deviceTypes 明确写了 2in1(二合一 PC)和 tablet,窗口才按 PC 的大屏三栏布局来做;requestPermissions 是空数组——没有申请任何系统权限,读文件完全靠用户授权的选择器。

build-profile.json5 里两条与移植强相关的配置(签名材料已隐去,切勿把 p12 密码写进公开仓库):

{
  "app": {
    "products": [
      {
        "name": "default",
        "compatibleSdkVersion": "6.0.1(21)",
        "targetSdkVersion": "6.0.2(22)",
        "runtimeOS": "HarmonyOS",
        "signingConfig": "default",
        "buildOption": {
          "nativeLib": {
            "collectAllLibs": true,
            "debugSymbol": { "strip": false }
          }
        }
      }
    ]
  }
}

collectAllLibs: true 让 entry/libs/arm64-v8a 里的 libpython3.12.so 与所有 .cpython-312-aarch64-linux-ohos.so 扩展被打进 HAP;strip: false 是红线:strip 过的预编译 libpython 在 musl dlopen 阶段会直接 SIGSEGV,Native 层为此专门写了崩溃信号处理与体积告警(见第 6 节)。

应用名资源 entry/src/main/resources/base/element/string.json:

{"string": [
  {"name": "module_desc", "value": "Bonsai native entry"},
  {"name": "EntryAbility_desc", "value": "Bonsai ability"},
  {"name": "EntryAbility_label", "value": "筑眸"}
]}

EntryAbility.ets 只做一件事:loadContent('pages/Index'),把舞台交给 ArkUI 单页面。

import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';

const DOMAIN = 'org.zhumu.bim';

export default class EntryAbility extends UIAbility {
  onWindowStageCreate(windowStage: window.WindowStage): void {
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        hilog.error(0x0000, DOMAIN, 'loadContent failed: %{public}s', JSON.stringify(err));
        return;
      }
      hilog.info(0x0000, DOMAIN, 'loadContent succeeded');
    });
  }
}

5. 第一步:把 CPython 3.12 塞进 HAP 并跑起来

5.1 rawfile 运行时与幂等解压

bonsai_python_runtime.zip 放在 entry/src/main/resources/rawfile/,里面是完整的 ohos_python/lib/python3.12/ 标准库、site-packages/bonsai_engine.py、site-packages/samples/*.ifc 和一个关键的 sitecustomize.py。首启时 ArkTS 把它解压到 filesDir,并写一个 marker,之后重启直接复用:

// entry/src/main/ets/pages/Index.ets
const RUNTIME_ZIP = 'bonsai_python_runtime.zip';
const RUNTIME_MARKER = 'READY_B1';

private async ensurePythonRuntime(): Promise<string> {
  const home = this.pythonHome;                       // filesDir/ohos_python
  const marker = `${home}/.${RUNTIME_MARKER}`;
  const osPy = `${home}/lib/python3.12/os.py`;
  const engine = `${home}/lib/python3.12/site-packages/bonsai_engine.py`;

  const reusable =
    pathOpenable(osPy) && pathOpenable(engine) && pathExists(marker) &&
    readTextFile(marker).indexOf(RUNTIME_MARKER) >= 0;
  if (reusable) {
    return home;                                     // 二次启动零解压
  }
  this.statusText = '正在解压 Python 运行时…';
  mkdirp(home);
  const zipPath = `${this.filesDir}/${RUNTIME_ZIP}`;
  const content: Uint8Array = await this.context.resourceManager.getRawFileContent(RUNTIME_ZIP);
  const zipFile = fileIo.openSync(
    zipPath,
    fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC
  );
  try {
    fileIo.writeSync(zipFile.fd, content.buffer as ArrayBuffer);
  } finally {
    fileIo.closeSync(zipFile);
  }
  await zlib.decompressFile(zipPath, this.filesDir);
  if (!pathOpenable(osPy)) {
    throw new Error(`stdlib missing: ${osPy}`);
  }
  writeTextFile(marker, `${RUNTIME_MARKER}\n`);
  return home;
}

改过运行时内容时,只要递增 RUNTIME_MARKER(READY_B1 → READY_B2),所有设备下次启动都会强制重解压——这比「删应用数据」可靠得多。

5.2 C 扩展为什么是「平铺」的

鸿蒙链接器命名空间禁止从沙箱路径 dlopen,于是 CPython 扩展不能放在 lib-dynload/ 里按常规查找,而是全部平铺进 HAP 的 libs/arm64。build_runtime.py 自动生成 sitecustomize.py,为每个扩展模块打一张重定向表:

# 由 build_runtime.py 生成,写进 site-packages/sitecustomize.py
import os, sys, _imp
from importlib.machinery import ExtensionFileLoader
from importlib._bootstrap import ModuleSpec

try:
    sys.setdlopenflags(0x102)          # RTLD_NOW | RTLD_GLOBAL
except Exception:
    pass

_LIBS = '/data/storage/el1/bundle/libs/arm64'
REDIRECT = {
    'math': 'math.cpython-312-aarch64-linux-ohos.so',
    '_struct': '_struct.cpython-312-aarch64-linux-ohos.so',
    # ……按 libs 目录实际文件名自动生成
}

def _redirect(fullname):
    target = REDIRECT.get(fullname)
    if target is None:
        return None
    path = os.path.join(_LIBS, target)
    if not os.path.exists(path):
        return None
    spec = ModuleSpec(fullname, None, origin=path)
    spec._set_fileattr = True
    return _imp.create_dynamic(spec, path)

RTLD_GLOBAL 很关键:扩展模块里的 Py* 符号要能解析到全局那一份 libpython3.12.so,否则会出现「能 dlopen、一 import 就 undefined symbol」。


6. 第二步:Native 子进程与 Python 解释器装载

6.1 CMake:两个 .so,职责分开

# entry/src/main/cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.5.0)
project(bonsai_bridge)
set(CMAKE_CXX_STANDARD 17)

# ArkTS-facing N-API: createChannel builds a socketpair
add_library(bonsai_bridge SHARED bonsai_bridge.cpp)
target_compile_definitions(bonsai_bridge PRIVATE _GNU_SOURCE)
target_link_libraries(bonsai_bridge PUBLIC libace_napi.z.so libhilog_ndk.z.so)

# ChildProcess entry: socketpair contract + dlopen libpython3.12.so + Py_BytesMain
add_library(bonsai_child SHARED bonsai_child.cpp)
target_compile_definitions(bonsai_child PRIVATE _GNU_SOURCE)
target_link_libraries(bonsai_child PUBLIC libhilog_ndk.z.so libdl.so)

libbonsai_bridge.so 注册进 ArkTS 的 N-API 模块,libbonsai_child.so 只导出一个 Main(NativeChildProcess_Args) 给 startNativeChildProcess 当入口。

6.2 socketpair 代替 PTY

沙箱里 posix_openpt 返回 EPERM,所以通道用 AF_UNIX socketpair,fd 契约与终端版保持一致(parentFd / childFd):

// bonsai_bridge.cpp
napi_value CreateChannel(napi_env env, napi_callback_info info)
{
    (void)info;
    int fds[2] = {-1, -1};
    if (socketpair(AF_UNIX, SOCK_STREAM, 0, fds) != 0) {
        char message[160] = {0};
        std::snprintf(message, sizeof(message), "socketpair failed: %s", std::strerror(errno));
        napi_throw_error(env, nullptr, message);
        return nullptr;
    }
    napi_value result = nullptr;
    napi_create_object(env, &result);
    napi_value parentFd = nullptr;
    napi_value childFd = nullptr;
    napi_create_int32(env, fds[0], &parentFd);
    napi_create_int32(env, fds[1], &childFd);
    napi_set_named_property(env, result, "parentFd", parentFd);
    napi_set_named_property(env, result, "childFd", childFd);
    return result;
}

N-API 还导出 readFd / writeFd / readUntilEof / closeFd,其中 readFd 必须非阻塞——常驻内核的轮询循环跑在 UI 线程上,一次阻塞 read 就会把整个界面冻住:

int oldFlags = fcntl(fd, F_GETFL, 0);
if (oldFlags >= 0) {
    fcntl(fd, F_SETFL, oldFlags | O_NONBLOCK);
}
std::vector<char> buf(static_cast<size_t>(maxBytes));
ssize_t n = ::read(fd, buf.data(), static_cast<size_t>(maxBytes));
if (oldFlags >= 0) {
    fcntl(fd, F_SETFL, oldFlags);
}
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
    setStr("status", "again");   // 没数据,返回状态而不是等待
    setInt("bytes", 0);
    return result;
}

6.3 子进程:解参数 → 装环境 → dlopen → Py_BytesMain

ArkTS 侧把 [pythonHome, '-u', serverPath] 按 UTF-8 逐字节 hex 编码成 entryParams(避免 NativeChildProcess_Args 对 NUL/中文路径的解析问题),子进程再 DecodeHex + 按 \0 切回 argv:

// bonsai_child.cpp
void SetupPythonEnv(const std::string &home, const std::string &soDir)
{
    setenv("PYTHONUNBUFFERED", "1", 1);
    setenv("PYTHONDONTWRITEBYTECODE", "1", 1);
    if (!home.empty()) {
        setenv("PYTHONHOME", home.c_str(), 1);
        const std::string lib = home + "/lib/python3.12";
        const std::string site = lib + "/site-packages";
        const std::string homeLibs = home + "/lib:" + lib + "/lib-dynload:" + site;
        std::string libPath = soDir.empty() ? homeLibs : soDir + ":" + homeLibs;
        setenv("PYTHONPATH", (lib + ":" + lib + "/lib-dynload:" + site).c_str(), 1);
        setenv("LD_LIBRARY_PATH", libPath.c_str(), 1);
    }
}

libpython 的查找顺序是「HAP libs 优先,再退到系统/兜底 soname」,并且对「被 strip 或截断」的库做体积告警:

void *OpenLibPython(int outFd, int bootFd, const std::string &home)
{
    const std::string soDir = ThisSoDir();      // dladdr 取本 .so 所在目录
    std::vector<std::string> cands;
    if (!soDir.empty()) {
        cands.push_back(soDir + "/libpython3.12.so.1.0");
        cands.push_back(soDir + "/libpython3.12.so");
    }
    cands.push_back("/data/storage/el1/bundle/libs/arm64/libpython3.12.so.1.0");
    cands.push_back(home + "/lib/libpython3.12.so.1.0");
    cands.push_back("libpython3.12.so.1.0");
    for (const std::string &path : cands) {
        void *h = TryDlopenOne(path, outFd, bootFd);
        if (h != nullptr) return h;
    }
    return nullptr;
}

装载成功后不是 exec,而是取符号直接调用解释器入口:

using Py_SetPythonHome_fn = void (*)(const wchar_t *);
using Py_BytesMain_fn     = int (*)(int, char **);
using Py_DecodeLocale_fn  = wchar_t *(*)(const char *, size_t *);

int RunEmbeddedPython(void *lib, const std::string &home,
                      std::vector<std::string> &command, int outFd, int bootFd)
{
    auto setHome   = reinterpret_cast<Py_SetPythonHome_fn>(dlsym(lib, "Py_SetPythonHome"));
    auto bytesMain = reinterpret_cast<Py_BytesMain_fn>(dlsym(lib, "Py_BytesMain"));
    auto decode    = reinterpret_cast<Py_DecodeLocale_fn>(dlsym(lib, "Py_DecodeLocale"));
    if (!home.empty() && setHome && decode) {
        wchar_t *w = decode(home.c_str(), nullptr);
        if (w) setHome(w);
    }
    std::vector<char *> argv;
    for (std::string &item : command) argv.push_back(item.data());
    argv.push_back(nullptr);
    if (bytesMain != nullptr) {
        return bytesMain(static_cast<int>(command.size()), argv.data());
    }
    return 121;   // no_python_entry
}

Main() 里还注册了 SIGSEGV/SIGABRT 处理,把崩溃原因直接写进 bim_srv_boot.txt,并提示「rebuild with nativeLib.debugSymbol.strip=false」——在无法 adb 实时看堆栈的设备上,这类自诊断日志救过很多次。

6.4 ArkTS 拉起子进程

// Index.ets
const NATIVE_CHILD_ENTRY = 'libbonsai_child.so:Main';

private async spawnKernel(): Promise<boolean> {
  const workDir = this.filesDir;
  const home = await this.ensurePythonRuntime();
  writeTextFile(`${workDir}/bim_server.py`, PY_SERVER);   // 服务器脚本由 ArkTS 写出
  // 清理上一轮残留的 req/resp/ready/stop/error 文件
  const bootPath = `${workDir}/bim_srv_boot.txt`;
  const outPath  = `${workDir}/bim_srv_out.txt`;
  const exitPath = `${workDir}/bim_srv_exit.txt`;
  this.kernelBootFd = openCaptureFd(bootPath);
  this.kernelOutFd  = openCaptureFd(outPath);
  this.kernelExitFd = openCaptureFd(exitPath);

  const spawnFds: Record<string, number> = {
    'boot': this.kernelBootFd, 'out': this.kernelOutFd, 'exit': this.kernelExitFd
  };
  const channel = bonsaiBridge.createChannel();
  this.parentFd = channel.parentFd;
  this.childFd  = channel.childFd;
  spawnFds['terminal'] = this.childFd;

  const params = encodeArguments([home, '-u', `${workDir}/bim_server.py`]);
  const pid = await childProcessManager.startNativeChildProcess(
    NATIVE_CHILD_ENTRY,
    { entryParams: params, fds: spawnFds },
    { isolationMode: false }
  );
  this.kernelPid = pid;
  // 父进程立刻关掉自己不再使用的 child/boot/out/exit 端
  const deadline = Date.now() + 90000;
  while (Date.now() < deadline) {
    if (pathExists(`${workDir}/bim_srv_ready.txt`)) return true;
    await sleepMs(50);
  }
  this.killKernel();
  throw new Error('kernel boot timeout');
}

ready 文件由 Python 服务器在初始化完成后写出,父进程轮询到它才算启动成功;90 秒没等到就写 bim_srv_stop.txt 让内核自杀,避免留下僵尸进程。


7. 第三步:跨语言通信协议(含完整代码)

这是整个移植最「反直觉」的部分。鸿蒙上的 Native 子进程与 ArkTS 之间,最可靠的双向通道不是 socket 流,而是应用沙箱里的一组约定文件:

请求:UI 写  bim_req_<seq>.json   {"seq": "...", "fn": "...", "args": [...]}
响应:内核写 bim_resp_<seq>.json  {"seq": "...", "result": {...}}

为什么每请求一个独立文件对? 早期版本用共享的 bim_req.json,结果 load_sample 与随后的 get_tree 排队时,后一个请求把前一个还没被读走的文件覆盖掉,界面表现为「点了示例模型,树和几何偶尔不刷新」,而且没有任何报错。改成「一请求一文件对 + 序号」后问题彻底消失。

7.1 Python 常驻服务器(ArkTS 中以字符串数组内嵌,以下是其 Python 原文形态)

# bim_server.py —— 由 Index.ets 的 PY_SERVER 常量在启动时写入 filesDir
import os, sys, json, time, io, contextlib, traceback
import bonsai_engine as _eng

BASE  = os.path.dirname(os.path.abspath(__file__))
READY = os.path.join(BASE, "bim_srv_ready.txt")
STOP  = os.path.join(BASE, "bim_srv_stop.txt")
ERR   = os.path.join(BASE, "bim_srv_error.txt")

def _log_err(tb):
    try:
        with open(ERR, "a") as f:
            f.write(tb + "\n")
    except OSError:
        pass

def _pending():
    """Path of the oldest outstanding bim_req_<seq>.json, or None."""
    try:
        names = [n for n in os.listdir(BASE)
                 if n.startswith("bim_req_") and n.endswith(".json")]
    except OSError:
        return None
    if not names:
        return None
    names.sort()
    return os.path.join(BASE, names[0])

def _load(path):
    """Parse a request file; retries while the writer is still flushing."""
    for _ in range(20):
        try:
            with open(path) as f:
                return json.loads(f.read())
        except ValueError:
            time.sleep(0.01)          # 写端还没 rename 完 / 半包
        except OSError:
            return None
    return None

def _dispatch(req):
    fn = req.get("fn", "")
    args = req.get("args") or []
    try:
        with contextlib.redirect_stdout(io.StringIO()):
            out = _eng.dispatch(fn, args)
        return json.loads(out)
    except Exception:
        tb = traceback.format_exc(limit=5)
        _log_err(tb)
        return {"ok": False, "error": tb}

def _emit(seq, result):
    path = os.path.join(BASE, "bim_resp_" + seq + ".json")
    tmp = path + ".tmp"
    with open(tmp, "w") as f:
        json.dump({"seq": seq, "result": result}, f)
        f.flush()
        os.fsync(f.fileno())          # 必须先落盘再 rename
    os.replace(tmp, path)

def _serve_one():
    path = _pending()
    if not path:
        return False
    req = _load(path)
    try:
        os.remove(path)
    except OSError:
        pass
    if req is None:
        return True
    seq = req.get("seq", "")
    if seq:
        _emit(seq, _dispatch(req))
    return True

with open(READY, "w") as _f:
    _f.write("ok")
try:
    while not os.path.exists(STOP):
        try:
            if not _serve_one():
                time.sleep(0.02)       # 空转 20ms,避免烧 CPU
        except Exception:
            _log_err(traceback.format_exc(limit=3))
            time.sleep(0.05)
finally:
    try:
        os.remove(READY)
    except OSError:
        pass

四个细节:redirect_stdout 防止内核里的调试 print 污染 stdout 通道;os.fsync + os.replace 保证读端永远看不到半截 JSON;_load 对 ValueError 重试 20 次;finally 删 READY,让 UI 能立刻判断「内核已死」。

7.2 ArkTS 请求端:temp→rename、轮询、串行队列

// Index.ets
private async kernelRequest(fn: string, argsJson: string, timeoutMs: number): Promise<string> {
  const task = async (): Promise<string> => {
    const ok = await this.startKernel();
    if (!ok) throw new Error('kernel not running');

    let args: Array<Object> = [];
    try {
      const parsed: Object = JSON.parse(argsJson);
      if (Array.isArray(parsed)) args = parsed as Array<Object>;
    } catch (_) { /* 非法 JSON 视为空参数 */ }

    const readyPath = this.kernelFile('bim_srv_ready.txt');
    const seq = `${Date.now()}_${++this.requestSerial}_${fn}`;
    const reqPath  = this.kernelFile(`bim_req_${seq}.json`);
    const respPath = this.kernelFile(`bim_resp_${seq}.json`);
    const tmpPath  = reqPath + '.tmp';

    // temp-then-rename:半写的 JSON 会让内核读到解析失败
    writeTextFile(tmpPath, JSON.stringify({ seq: seq, fn: fn, args: args }));
    try {
      fileIo.moveFileSync(tmpPath, reqPath);
    } catch (_) {
      writeTextFile(reqPath, JSON.stringify({ seq: seq, fn: fn, args: args }));
    }

    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
      if (!pathExists(readyPath)) throw new Error('kernel died');
      if (pathExists(respPath)) {
        const env = JSON.parse(readTextFile(respPath)) as Record<string, Object>;
        const out = JSON.stringify(env['result'] ?? { ok: false, error: 'empty result' });
        try { fileIo.unlinkSync(respPath); } catch (_) {}
        try { fileIo.unlinkSync(reqPath);  } catch (_) {}
        return out;
      }
      await sleepMs(15);
    }
    throw new Error(`kernel request timeout: ${fn}`);
  };
  // 所有请求串行化:内核是单进程单线程,且文件协议不接受并发写同一 seq 空间
  const run = this.kernelQueue.then(task, task);
  this.kernelQueue = run.catch((_e: Error) => '');
  return run;
}

private async bridge(fn: string, argsJson: string): Promise<string> {
  try {
    return await this.kernelRequest(fn, argsJson, 60000);
  } catch (e) {
    const msg = (e as Error).message ?? String(e);
    this.uiLog(`[kernel] 请求失败 (${fn}): ${msg}`);
    return JSON.stringify({ ok: false, error: msg });   // 失败也保持 UI 可响应
  }
}

kernelQueue 是一条 Promise 链:this.kernelQueue.then(task, task) 让所有 bridge 调用按发起顺序排队,前一个失败也不会阻断后一个(run.catch 把链恢复成 resolved)。startKernel() 里同样用 kernelStarting 做单例守卫,避免多个按钮同时触发时拉起多个 Python 进程。

一次「示例模型」点击,实际发生的内核调用序列是:

private async afterModelOpen(filename: string, entityCount: number, parseMs: number): Promise<void> {
  this.modelLoaded = true;
  this.modelTitle = filename;
  this.uiLog(`[模型] ${filename} — ${entityCount} 实体, 解析 ${parseMs} ms`);
  await this.refreshTree();       // fn=get_tree
  await this.refreshStats();      // fn=get_stats
  await this.refreshGeometry();   // fn=get_geometry
}

控制台里那几行 [示例] 内核返回 {...}、[模型] ... 解析 8 ms、[几何] 12 个构件轮廓,就是这条链路的真实日志(图 2 右下角控制台与真机布局日志中可见)。


8. 第四步:零 C 依赖的 IFC 内核

bonsai_engine.py 是本次移植的主体工作量:一个纯 Python 的 ISO-10303-21(STEP Physical File)解析器 + IFC4 语义层 + 平面几何投影,对外只暴露一个函数:

def dispatch(fn, args=None) -> str:   # 返回 JSON 字符串

8.1 SPF 词法:逗号要按「顶层」切

IFC 文件里一行实体可能是 #42=IFCCARTESIANPOINT((0.,0.,0.));,参数里嵌着括号、字符串、引用、枚举、内联实体。切错一个逗号整份模型就废了:

def _split_top_args(text):
    """Split argument list at top-level commas, string-aware, paren-aware."""
    parts, depth, in_str, cur = [], 0, False, []
    i, n = 0, len(text)
    while i < n:
        c = text[i]
        if in_str:
            if c == "\\":
                cur.append(c)
                if i + 1 < n:
                    cur.append(text[i + 1]); i += 1
            elif c == "'":
                in_str = False; cur.append(c)
            else:
                cur.append(c)
        else:
            if c == "'":
                in_str = True; cur.append(c)
            elif c == "(":
                depth += 1; cur.append(c)
            elif c == ")":
                depth -= 1; cur.append(c)
            elif c == "," and depth == 0:
                parts.append("".join(cur)); cur = []
            else:
                cur.append(c)
        i += 1
    if cur:
        parts.append("".join(cur))
    return parts

class Ref:            # #123 实体引用
    __slots__ = ("id",)
    def __init__(self, rid): self.id = rid

class Enum:           # .T. / .NOTDEFINED. 枚举
    __slots__ = ("value",)
    def __init__(self, value): self.value = value

class TypedList(list):  # IFCXXX(...) 内联构造
    pass

def _parse_value(tok):
    tok = tok.strip()
    if tok == "$" or tok == "": return None
    if tok == "*":            return Enum("*")
    if tok.startswith("'"):   return _decode_spf_string(tok)
    if tok.startswith("#"):   return Ref(int(tok[1:]))
    if tok.startswith("."):   return Enum(tok)
    if tok.startswith("("):  return TypedList(_parse_value(t) for t in _split_top_args(tok[1:-1]))
    m = re.match(r"^([A-Z0-9_]+)\((.*)\)$", tok, re.S)
    if m:
        return TypedList([m.group(1)] + [_parse_value(t) for t in _split_top_args(m.group(2))])
    try:
        return int(tok) if re.match(r"^[+-]?\d+$", tok) else float(tok)
    except ValueError:
        return tok

_decode_spf_string 还要处理 IFC 的 \X2\...\X0\ Unicode 转义与 '' 单引号转义——示例小屋里的「南墙」「起居室」等中文名就是靠它读出来的。

8.2 解析 DATA 段并建立反向索引

class Model:
    def __init__(self):
        self.entities = {}   # id -> [type, args]
        self.by_type  = {}   # TYPE -> set(ids)
        self.inverse  = {}   # id -> list of (src_id, path)
        self.schema = ""
        self.order  = []

    def load(self, path):
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            data = f.read()
        self.filename = os.path.basename(path)
        self._parse(data)

    def _parse(self, data):
        m = re.search(r"FILE_SCHEMA\s*\(\s*\(\s*'\s*([A-Z0-9_]+)", data)
        self.schema = m.group(1) if m else ""
        di = data.find("DATA;")
        body = data[di:] if di != -1 else data
        pat = re.compile(r"#(\d+)\s*=\s*([A-Z0-9_]+)\s*\(")
        pos = 0
        while True:
            m = pat.search(body, pos)
            if m is None: break
            eid, etype = int(m.group(1)), m.group(2)
            depth, i, in_str, n = 1, m.end(), False, len(body)
            while i < n and depth > 0:
                c = body[i]
                if in_str:
                    if c == "\\": i += 2; continue
                    if c == "'": in_str = False
                else:
                    if c == "'": in_str = True
                    elif c == "(": depth += 1
                    elif c == ")": depth -= 1
                i += 1
            args = [_parse_value(t) for t in _split_top_args(body[m.end():i - 1])]
            self.entities[eid] = [etype, args]
            self.order.append(eid)
            self.by_type.setdefault(etype, set()).add(eid)
            pos = i
        self._build_inverse()

inverse 是查询性能的关键:IFC 的关系是「IFCRELAGGREGATES 指向父与子」,没有反向索引就得为每次树展开全表扫描。建立一次 id → [(src_id, path)] 之后,structure_tree()、element_psets() 都能按引用直接跳转。

8.3 统一 dispatch 端点

def dispatch(fn, args=None):
    global _DOC, _DOC_PATH
    args = args or []
    try:
        if fn == "load_sample":
            name = args[0] if args else "decomposition.ifc"
            return _open_path(os.path.join(SAMPLE_DIR, name))
        if fn == "open_path":
            return _open_path(args[0])
        if fn == "get_tree":
            return json.dumps(_DOC.structure_tree())
        if fn == "get_entity":
            eid = int(args[0]); e = Entity(_DOC.model, eid)
            return json.dumps({"ok": True, "data": {
                "id": eid, "type": e.type, "name": e.name(), "guid": e.guid(),
                "attributes": _DOC.element_attributes(eid),
                "psets": _DOC.element_psets(eid),
            }})
        if fn == "get_stats":
            counts = {t: len(ids) for t, ids in _DOC.model.by_type.items()
                      if t.startswith("IFC") and len(ids) > 0}
            return json.dumps({"ok": True, "data": {
                "schema": _DOC.model.schema,
                "filename": _DOC.model.filename,
                "entity_count": len(_DOC.model.entities),
                "top_types": sorted(counts.items(), key=lambda kv: -kv[1])[:40],
            }})
        if fn == "get_geometry":
            return json.dumps({"ok": True, "data": _DOC.footprint_geometry(args[0] if args else None)})
        if fn == "close_model":
            _close_model(); return json.dumps({"ok": True, "data": {"closed": True}})
        return json.dumps({"ok": False, "error": "unknown fn: %s" % fn})
    except Exception:
        import traceback
        return json.dumps({"ok": False, "error": traceback.format_exc(limit=5)})

所有异常都在 Python 侧收敛成 {"ok": false, "error": traceback},ArkTS 端只要判断 ok 就能决定是刷新 UI 还是把错误打进控制台——跨语言边界上,永远不要让异常穿透成进程崩溃。


9. 第五步:从局部坐标到世界坐标的几何流水线

IFC 里一个墙体的轮廓定义在它自己的 IFCAXIS2PLACEMENT3D 局部坐标系里,还要沿 IFCLOCALPLACEMENT 链一路乘到项目坐标系。这里踩过整个项目最隐蔽的一个坑:

坑:最初按 x = cross(RefDirection, Z) 构造 X 轴,结果得到的是 -Y,所有构件在平面图上整体被转了 −90°,房间与墙完全对不上。正确做法是:RefDirection 就是期望的 X 轴,只需相对 Z 做正交化,绝不能再叉乘。

def _mat_mul(a, b):
    """a*b, both column-major 4x4."""
    out = [0.0] * 16
    for col in range(4):
        for row in range(4):
            s = 0.0
            for k in range(4):
                s += a[k * 4 + row] * b[col * 4 + k]
            out[col * 4 + row] = s
    return out

def _axis_to_mat(location, axis, refdir, scale=1.0):
    p = _xyz(location)
    lx, ly, lz = p if p else (0.0, 0.0, 0.0)
    ax = _xyz(axis)  or (0.0, 0.0, 1.0)
    rd = _xyz(refdir) or (1.0, 0.0, 0.0)
    z = _norm3(ax)
    # RefDirection is the intended X axis: orthogonalize against z, never cross.
    dot = rd[0]*z[0] + rd[1]*z[1] + rd[2]*z[2]
    x = (rd[0] - dot*z[0], rd[1] - dot*z[1], rd[2] - dot*z[2])
    if _len3(x) < 1e-9:
        x = _cross((0.0, 1.0, 0.0), z)
        if _len3(x) < 1e-9:
            x = (1.0, 0.0, 0.0)
    x = _norm3(x)
    y = _cross(z, x)
    return [x[0]*scale, y[0]*scale, z[0]*scale, 0.0,
            x[1]*scale, y[1]*scale, z[1]*scale, 0.0,
            x[2]*scale, y[2]*scale, z[2]*scale, 0.0,
            lx, ly, lz, 1.0]

轮廓提取支持 IFCEXTRUDEDAREASOLID 的 IFCRECTANGLEPROFILEDEF / IFCARBITRARYCLOSEDPROFILEDEF,输出统一的世界坐标多边形:

def footprint_geometry(self, eids=None):
    """Extract world-space footprint polygons + extrusion depth per element.
    Returns list of {id, type, name, z0, depth, zdir, polygon[[x,y],...]}."""
    model = self.model
    out = []
    targets = model.typed(
        "IFCWALL", "IFCSLAB", "IFCBEAM", "IFCCOLUMN", "IFCDOOR", "IFCWINDOW",
        "IFCPLATE", "IFCMEMBER", "IFCCOVERING", "IFCRAILING", "IFCROOF",
        "IFCSTAIR", "IFCBUILDINGELEMENTPROXY", "IFCDISTRIBUTIONELEMENT")
    for eid in targets:
        e = Entity(model, eid)
        for poly, z0, depth, zdir in self._element_extrusions(e):
            out.append({"id": eid, "type": e.type, "name": e.name(),
                        "z0": z0, "depth": depth, "zdir": list(zdir),
                        "polygon": poly})
    # 同一个 SLAB 可能既被构件扫到、又被房间轮廓扫到,exclude 去重
    out.extend(self._space_footprints(exclude=set(o['id'] for o in out)))
    return out

房间轮廓的两级策略(没有显式 IFCSPACE 时用地坪/墙中心线推导):

def _space_footprints(self, exclude=None):
    model, out, have = self.model, [], set()
    for eid in model.typed("IFCSPACE", "IFCSLAB", "IFCROOF"):
        if exclude and eid in exclude:      # 防止 SLAB 输出两次
            continue
        e = Entity(model, eid)
        for poly, z0, depth, zdir in self._element_extrusions(e):
            out.append({"id": eid,
                        "type": "IFCSPACE" if e.type == "IFCSPACE" else "IFCSLAB",
                        "name": e.name(), "z0": z0, "depth": depth,
                        "zdir": list(zdir), "polygon": poly, "derived": False})
            have.add(eid)
    if have:
        return out
    # 没有显式楼板:用墙中心线切成房间矩形,避免平面上只有悬空的墙条
    for idx, rect in enumerate(self._derived_rooms()):
        out.append({"id": -2 - idx, "type": "IFCSPACE", "name": "Room %d" % (idx + 1),
                    "z0": -0.05, "depth": 0.05, "zdir": [0.0, 0.0, -1.0],
                    "polygon": rect, "derived": True})
    return out

凸包用 Andrew monotone chain,用于把散点轮廓整理成逆时针多边形:

def _convex_hull(pts):
    """Andrew monotone chain, returns CCW hull points."""
    if len(pts) < 3:
        return list(pts)
    def cross(o, a, b):
        return (a[0]-o[0])*(b[1]-o[1]) - (a[1]-o[1])*(b[0]-o[0])
    lower, upper = [], []
    for p in pts:
        while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
            lower.pop()
        lower.append(p)
    for p in reversed(pts):
        while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
            upper.pop()
        upper.append(p)
    return lower[:-1] + upper[:-1]

10. 第六步:ArkUI 画一张「图纸」而不是一个方框

ArkUI 的 Canvas 足够画专业平面图:米制网格、图纸底色、总体尺寸线、房间填充与名称、按 IFC 类型配色、选中高亮,全部在 renderViewport() 里一遍绘制。核心的坐标变换和绘制顺序:

// Index.ets
private renderViewport(): void {
  const ctx = this.canvasCtx;
  const w = ctx.width, h = ctx.height;
  if (w <= 0 || h <= 0) {
    setTimeout(() => { this.renderViewport(); }, 80);   // Canvas 尚未 onReady
    return;
  }
  ctx.clearRect(0, 0, w, h);
  ctx.fillStyle = '#101418';
  ctx.fillRect(0, 0, w, h);
  if (this.shapes.length === 0) {
    ctx.fillStyle = '#5a6570';
    ctx.font = '16px sans-serif';
    ctx.fillText('打开 IFC 模型后在此显示平面视图', 24, h / 2);
    return;
  }

  // 1) 求世界坐标包围盒
  let xmin = 1e18, xmax = -1e18, ymin = 1e18, ymax = -1e18;
  for (const s of this.shapes) {
    for (const p of s.polygon) {
      if (p.length < 2) continue;
      xmin = Math.min(xmin, p[0]); xmax = Math.max(xmax, p[0]);
      ymin = Math.min(ymin, p[1]); ymax = Math.max(ymax, p[1]);
    }
  }

  // 2) 留出尺寸线空间后等比适配
  const padL = 56, padR = 40, padT = 46, padB = 52;
  const availW = Math.max(40, w - padL - padR);
  const availH = Math.max(40, h - padT - padB);
  const sc = Math.min(availW / (xmax - xmin), availH / (ymax - ymin));
  const ox = padL + (availW - (xmax - xmin) * sc) / 2;
  const oy = padT + (availH - (ymax - ymin) * sc) / 2;
  const px = (x: number): number => ox + (x - xmin) * sc;
  const py = (y: number): number => h - (oy + (y - ymin) * sc);  // Y 轴翻转

  // 3) 缓存变换参数,供点击拾取反算世界坐标
  this.hitXmin = xmin; this.hitYmin = ymin; this.hitSc = sc;
  this.hitOx = ox; this.hitOy = oy; this.hitH = h;

  // 4) 绘制顺序:房间/地坪 → 普通构件 → 选中构件(保证高亮在最上层)
  const order: number[] = [];
  for (let i = 0; i < this.shapes.length; i++) if (this.isFloor(this.shapes[i])) order.push(i);
  for (let i = 0; i < this.shapes.length; i++)
    if (!this.isFloor(this.shapes[i]) && this.shapes[i].id !== this.selectedId) order.push(i);
  for (let i = 0; i < this.shapes.length; i++)
    if (this.shapes[i].id === this.selectedId) order.push(i);

  for (const idx of order) {
    const s = this.shapes[idx];
    if (s.polygon.length < 3) continue;
    ctx.beginPath();
    ctx.moveTo(px(s.polygon[0][0]), py(s.polygon[0][1]));
    for (let i = 1; i < s.polygon.length; i++) {
      ctx.lineTo(px(s.polygon[i][0]), py(s.polygon[i][1]));
    }
    ctx.closePath();
    ctx.fillStyle = s.id === this.selectedId ? '#f5a623' : this.fillFor(s.type);
    ctx.fill();
    ctx.strokeStyle = s.id === this.selectedId ? '#ffe08a' : '#0d1116';
    ctx.lineWidth = 1.2;
    ctx.stroke();
  }
  this.drawDims(ctx, px, py, xmin, xmax, ymin, ymax, w, h);
}

private fillFor(t: string): string {
  if (t === 'IFCWALL' || t === 'IFCWALLSTANDARDCASE') return '#93a7b8';
  if (t === 'IFCDOOR')   return '#c9a227';
  if (t === 'IFCWINDOW') return '#4fa3c7';
  if (t === 'IFCCOLUMN') return '#8a9a5b';
  return '#6d8a70';
}

米制网格随缩放自动换挡:

const step = sc > 40 ? 1 : (sc > 16 ? 2 : 5);   // 每 1/2/5 米一根线
ctx.strokeStyle = '#1d242c';
for (let gx = Math.ceil(xmin / step) * step; gx <= xmax; gx += step) {
  ctx.beginPath();
  ctx.moveTo(px(gx), by0);
  ctx.lineTo(px(gx), by1);
  ctx.stroke();
}

10.1 画布点选:point-in-polygon 与坐标口径

private static pointInPoly(px: number, py: number, poly: number[][]): boolean {
  let inside = false;
  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
    const xi = poly[i][0], yi = poly[i][1];
    const xj = poly[j][0], yj = poly[j][1];
    if ((yi > py) !== (yj > py) &&
        px < (xj - xi) * (py - yi) / (yj - yi) + xi) {
      inside = !inside;
    }
  }
  return inside;
}

private pickShapeAt(cx: number, cy: number): void {
  if (this.hitSc <= 0) return;
  // 屏幕坐标 → 世界坐标(注意 Y 轴在屏幕上向下、在世界里向上)
  const wx = this.hitXmin + (cx - this.hitOx) / this.hitSc;
  const wy = this.hitYmin + (this.hitH - this.hitOy - cy) / this.hitSc;

  let hit: Shape2D | null = null;        // 墙/门/窗优先
  let floorHit: Shape2D | null = null;   // 房间/地坪兜底
  for (const s of this.shapes) {
    if (s.polygon.length < 3 || !Index.pointInPoly(wx, wy, s.polygon)) continue;
    if (this.isFloor(s)) {
      if (floorHit === null || s.type === 'IFCSPACE') floorHit = s;  // 房间优先于底板
    } else {
      hit = s;
    }
  }
  const chosen = hit ?? floorHit;
  this.uiLog(`[拾取] 画布(${Math.round(cx)},${Math.round(cy)}) ` +
             `世界(${wx.toFixed(2)},${wy.toFixed(2)}) → ${chosen ? chosen.type : '未命中'}`);
  if (chosen) void this.selectEntity(chosen.id, chosen.type, chosen.name);
}

Canvas.onClick 传进来的 evt.x / evt.y 是组件内坐标,而自动化测试工具 uitest dumpLayout 给的是设备物理坐标(真机上约 ×0.5 换算),两个口径千万别混用,否则「点南墙却选中了北窗」。

界面容器就是一条 Row 三栏 + layoutWeight:

build() {
  Column({ space: 8 }) {
    this.Toolbar()
    Row({ space: 8 }) {
      Column() { this.ModelTree() }.layoutWeight(2).height('100%')
      Column() { this.Viewport() }.layoutWeight(3).height('100%')
      Column() { this.PropertiesPanel() }.layoutWeight(2).height('100%')
    }.layoutWeight(1).width('100%')
  }
  .padding(8).width('100%').height('100%').backgroundColor('#171717')
}

图 3 在画布/模型树中选中「南墙」,右侧同步显示 IFCWALL 的 GlobalId、ObjectPlacement、Representation 等属性

图 3:树里选中「南墙」,属性面板显示 IFCWALL · 南墙、GlobalId、ObjectPlacement#45 (IFCLOCALPLACEMENT)、Representation#58 等;平面图上对应墙体被染成橙色高亮。这是「树 → 属性 → 画布」与「画布 → 树 → 属性」双向联动的实证。


11. 文件访问、隐私与零权限模型

鸿蒙上读用户文档的正确姿势不是申请存储权限,而是 DocumentViewPicker 由用户显式选文件,拿到 URI 后复制到应用沙箱再交给 Python 内核:

private async openIfcFile(): Promise<void> {
  if (this.busy || !this.context) return;
  const docPicker = new picker.DocumentViewPicker(this.context);
  const opts = new picker.DocumentSelectOptions();
  opts.fileSuffixFilters = ['.ifc'];
  try {
    const uris = await docPicker.select(opts);
    if (!uris || uris.length === 0) return;
    this.busy = true;
    const dest = `${this.filesDir}/opened_model.ifc`;
    const srcFile = fileIo.openSync(uris[0], fileIo.OpenMode.READ_ONLY);
    try {
      const dstFile = fileIo.openSync(
        dest, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
      try {
        const CHUNK = 1024 * 1024;              // 1MB 分块,避免大 IFC 一次性吃内存
        const buf = new ArrayBuffer(CHUNK);
        while (true) {
          const n = fileIo.readSync(srcFile.fd, buf);
          if (n <= 0) break;
          fileIo.writeSync(dstFile.fd, buf, { offset: 0, length: n });
        }
      } finally { fileIo.closeSync(dstFile); }
    } finally { fileIo.closeSync(srcFile); }

    const json = await this.bridge('open_path', JSON.stringify([dest]));
    const o = this.parseJson(json);
    if (o && o['ok'] === true) {
      const data = o['data'] as Record<string, Object>;
      this.afterModelOpen(String(data['filename'] ?? 'model.ifc'),
        Number(data['entity_count'] ?? 0), Number(data['parse_ms'] ?? 0));
    }
  } finally { this.busy = false; }
}

图 4 首次启动时系统展示的「关于筑眸与隐私的声明」授权弹窗

图 4:首启的隐私声明由用户确认后才进入主界面。应用在 module.json5 中不申请任何权限,模型文件只在用户主动选择后才进入沙箱,也不做网络上传——这是桌面专业软件上鸿蒙 PC 必须补上的合规一环。


12. 运行时打包与示例模型生成

build_runtime.py 把「交叉编译的 CPython stdlib」+「本工程内核」+「示例 IFC」打成 rawfile 包。两个平台细节:

# _bonsai_build/build_runtime.py(节选)
EXT_SUFFIX = ".cpython-312-aarch64-linux-ohos.so"

def main():
    # 本机删除大量小文件极慢:复用已解压的 stdlib,只重建 site-packages
    sp = os.path.join(RTMP, "ohos_python", "lib", "python3.12", "site-packages")
    if os.path.isdir(sp):
        shutil.rmtree(sp, ignore_errors=True)
    os.makedirs(sp, exist_ok=True)
    with zipfile.ZipFile(SRC_ZIP) as z:
        for m in z.infolist():
            if m.filename.startswith("ohos_python/lib/python3.12/") \
               and "/site-packages/" not in m.filename:
                z.extract(m, RTMP)
    shutil.copy(os.path.join(BUILD, "bonsai_engine.py"), sp)
    os.makedirs(os.path.join(sp, "samples"), exist_ok=True)
    for s in sorted(os.listdir(os.path.join(BUILD, "samples"))):
        if s.endswith(".ifc"):
            shutil.copy(os.path.join(BUILD, "samples", s), os.path.join(sp, "samples", s))
    with open(os.path.join(sp, "sitecustomize.py"), "w", encoding="utf-8", newline="\n") as f:
        f.write(gen_sitecustomize())
    # PowerShell Compress-Archive 会写入反斜杠路径,OHOS 侧解压会得到错的目录树
    subprocess.run(["C:/Windows/System32/tar.exe", "-a", "-cf", OUT_ZIP, "ohos_python"],
                   cwd=RTMP, check=True)
    shutil.copy(OUT_ZIP, os.path.join(RAWFILE, "bonsai_python_runtime.zip"))

示例模型 sample-house.ifc 是脚本生成的真实 IFC4 文件(两室小屋:闭合外墙 + 隔墙 + 2 门 + 2 窗 + 地坪板 + 2 命名空间 + Pset,中文命名),emit() 自动分配 #N:

# _bonsai_build/gen_sample.py(节选)
lines, next_id = [], [1]

def emit(text):
    """Assign #N= automatically; return the ref '#N'."""
    i = next_id[0]
    next_id[0] += 1
    lines.append('#%d=%s' % (i, text))
    return '#%d' % i

def local_placement(parent, pt):
    zd = emit('IFCDIRECTION((0.,0.,1.));')
    xd = emit('IFCDIRECTION((1.,0.,0.));')
    x, y, z = (float(v) for v in pt.split(','))
    loc = cartesian3(x, y, z)               # 必须是 IFCCARTESIANPOINT 实体
    a = axis3d(loc, zd, xd)
    return emit('IFCLOCALPLACEMENT(%s,%s);' % (parent, a))

def rect_body(xd, yd, depth, body_ctx, z=0.0):
    """Swept-solid body rep of a rect footprint centered at placement origin."""
    p2  = emit('IFCCARTESIANPOINT((0.,0.));')
    d2  = emit('IFCDIRECTION((1.,0.));')
    ax2 = emit('IFCAXIS2PLACEMENT2D(%s,%s);' % (p2, d2))
    prof = emit('IFCRECTANGLEPROFILEDEF(.AREA.,$,%s,%s,%s);' % (ax2, num(xd), num(yd)))
    pos  = axis3d(cartesian3(0, 0, z), emit('IFCDIRECTION((0.,0.,1.));'),
                  emit('IFCDIRECTION((1.,0.,0.));'))
    edir = emit('IFCDIRECTION((0.,0.,1.));')
    ext  = emit('IFCEXTRUDEDAREASOLID(%s,%s,%s,%s);' % (prof, pos, edir, num(depth)))
    return emit('IFCSHAPEREPRESENTATION(%s,\'Body\',\'SweptSolid\',(%s));' % (body_ctx, ext))

IFCLOCALPLACEMENT 里包一个真正的 IFCCARTESIANPOINT 是硬要求;写成裸坐标元组,桌面端某些查看器能容忍,但自研解析器与真实工程文件都不认。

改完 PY_SERVER 或 bonsai_engine.py 后,务必跑一次语法校验再重打包——设备端 SyntaxError 的表现是「内核就绪但点击没反应」,UI 上完全没有红色报错:

python _bonsai_build/check_server.py        # 抽取 PY_SERVER 做语法检查
python _bonsai_build/build_runtime.py      # 重打 zip 并同步到 rawfile
# 修改过运行时内容 → 递增 Index.ets 的 RUNTIME_MARKER

13. 真机验收:五张截图对应的完整链路

环节截图观察到的证据
首启 / 合规图 4(隐私声明)、图 1(空界面)用户同意后进入三栏主界面,控制台打印 [内核] 就绪(常驻进程),说明 CPython 已由 Native 子进程装载
加载示例图 2控制台逐条打印 [示例] 开始请求 sample-house.ifc → 内核返回 {"ok":true,...} → [模型] sample-house.ifc — 266 实体, 解析 8 ms → [几何] 12 个构件轮廓
结构树图 2 / 图 3IFCPROJECT → IFCSITE → IFCBUILDING → IFCBUILDINGSTOREY → SPACE/WALL/DOOR/WINDOW/SLAB,中文名正确渲染
平面图纸图 2网格、米制总体尺寸线、房间填充与「起居室 / 卧室」标注、门窗配色、统计栏 IFC4 · 266 实体 · 5 墙体 · 2 房间 · 2 门 · 2 窗
属性联动图 3选中「南墙」→ 属性面板显示 IFCWALL · 南墙、GlobalId、ObjectPlacement#45、Representation#58;画布同步橙色高亮
画布拾取图 3点选后控制台打印屏幕坐标 → 世界坐标 → 命中类型
关于图 5版本 1.0.0、构建 bonsai-1.0.0-p1、运行时 CPython 3.12 · aarch64-linux-ohos、平台 HarmonyOS NEXT · API 26、GPL-3.0

图 5 「关于」对话框:版本、构建、内核、运行时、架构、平台与开源许可一目了然

About 对话框的 ArkTS 实现(@CustomDialog + @Builder),也是「移植信息自证」的展示位:

@Builder
infoRow(label: string, value: string) {
  Row() {
    Text(label).fontSize(13).fontColor('#7f8c99').width(76)
    Text(value).fontSize(13).fontColor('#e6edf3').layoutWeight(1)
  }.width('100%')
}

// AboutDialog.build() 内部
this.infoRow('版本', APP_VERSION)
this.infoRow('构建', BUILD_TAG)
this.infoRow('内核', 'IfcOpenShell / BlenderBIM')
this.infoRow('运行时', 'CPython 3.12 · aarch64-linux-ohos')
this.infoRow('架构', 'ArkTS 宿主 + 常驻 Python 内核')
this.infoRow('平台', 'HarmonyOS NEXT · API 26')
this.infoRow('开源许可', 'GPL-3.0')

14. 踩坑清单(都是真机上换来的)

#现象根因修复
1点「示例模型」偶发树/几何不刷新,无报错共享 bim_req.json 被排队中的下一个请求覆盖每请求独立 bim_req_<seq>/bim_resp_<seq> 文件对 + 序号 + 串行 Promise 队列
2内核偶发读到半截 JSON写端未落盘就暴露文件tmp → flush → os.fsync → os.replace;读端对 ValueError 重试 20 次
3平面图上所有构件整体旋转 −90°_axis_to_mat 用 cross(RefDirection, Z) 构造 X 轴RefDirection 直接对 Z 正交化,再 y = cross(z, x)
4地坪板在树/画布出现两次SLAB 同时命中构件轮廓与房间轮廓_space_footprints(exclude=已输出 id 集合)
5dlopen(libpython) 直接 SIGSEGV预编译库被 strip / 体积异常nativeLib.debugSymbol.strip=false;Native 层加体积告警与 SIGSEGV 日志
6posix_openpt 返回 EPERM应用沙箱不允许创建 PTY改 socketpair(AF_UNIX, SOCK_STREAM),fd 契约不变
7界面点一下卡死几秒readFd 阻塞在 UI 线程O_NONBLOCK + status: 'again',轮询间隔 15ms
8改了 Python 代码但设备没变化运行时 marker 未变,直接复用旧解压目录递增 RUNTIME_MARKER 强制重解压
9运行时 zip 解压后目录结构错乱PowerShell Compress-Archive 写反斜杠路径用 tar -a -cf 打包
10设备端 SyntaxError,UI 毫无反应多行字符串里拼 Python 源码时缩进断裂重打包前跑 check_server.py 语法校验
11画布点选命中错位把 uitest 设备坐标当组件内坐标evt.x/evt.y 为组件内坐标;设备截图/布局坐标需按缩放比换算
12HAP 体积异常膨胀entry/libs/arm64-v8a 里混入非 .so 文件被 collectAllLibs 一起打包该目录只放 .so,扫描产物放别处

15. 一张 Q&A 速查表

#问题回答
Q1筑眸是不是把 Blender 搬上了鸿蒙?不是。Blender 的窗口/视口无法移植;这里复用的是 IFC 数据模型与 Python 生态,UI 用 ArkTS/ArkUI 重写,几何先做二维平面投影。
Q2为什么不用 exec 起一个 python3?filesDir 是 noexec,且沙箱不允许自由 fork/exec。方案是 HAP 内置 libpython3.12.so,Native 子进程 dlopen 后直接调 Py_BytesMain。
Q3标准库和 C 扩展分别放哪?纯 .py 在 rawfile/bonsai_python_runtime.zip 里,首启解压到 filesDir;.so 平铺在 entry/libs/arm64-v8a,由 sitecustomize.py 重定向 import。
Q4为什么用文件队列而不是 socket 传 JSON?请求/响应大小不定、需要可重试与可观察;文件名带 seq 天然支持排队、超时和排障。socketpair 只负责进程启动时的 fd 传递与子进程 stdio。
Q5为什么每个请求一个文件对?共享请求文件会被后一个排队请求覆盖,导致静默丢请求(详见踩坑 #1)。
Q6真的移植了 IfcOpenShell 的 C++ 几何内核吗?P1 没有。当前是零 C 依赖的纯 Python SPF 解析 + SweptSolid 平面轮廓,交叉编译 OCCT/IfcOpenShell 属于 P2 路线。
Q7读用户 .ifc 需要什么权限?不需要任何系统权限(requestPermissions: []),DocumentViewPicker 由用户授权选文件,1MB 分块复制进沙箱再解析。
Q8内核崩了界面会不会白屏?不会。dispatch 收敛所有异常为 {"ok":false,"error":...},bridge() 再兜一层;READY 文件消失时请求立刻抛 kernel died,可按提示重启。
Q9能打开任意真实工程的 IFC 吗?能解析 IFC4 SPF 的实体、空间结构、属性与 Pset;几何目前支持 IFCEXTRUDEDAREASOLID 的矩形/折线轮廓子集,复杂 B-Rep、布尔开洞会退化为轮廓近似。
Q10为什么界面完全离线也能跑?应用不发起任何网络请求:运行时、示例模型、内核全部随 HAP 分发,计算都在设备端 Python 进程内完成。
Q11改了 bonsai_engine.py 要重签名安装吗?若只是替换运行时内容:重跑 build_runtime.py、递增 RUNTIME_MARKER、重编 HAP 即可;改 ArkTS/C++ 则走正常 hvigor 构建流程。
Q12下一步最值得做什么?P2 交叉编译 IfcOpenShell C++ 内核替换纯 Python 解析;P3 基于真实 B-Rep 的 3D 视口与剖切;以及大模型的树虚拟滚动与属性搜索。

16. 复现、安全与后续路线

16.1 工程结构与构建

harmony-pc/
├── ohos_Bonsai/                       # HarmonyOS 主工程
│   ├── AppScope/app.json5             # org.zhumu.bim
│   ├── build-profile.json5            # SDK / nativeLib / 签名(密钥不得入库)
│   ├── entry/src/main/ets/
│   │   ├── entryability/EntryAbility.ets
│   │   └── pages/Index.ets            # ArkTS UI + 运行时 + 协议(约 1600 行)
│   ├── entry/src/main/cpp/
│   │   ├── CMakeLists.txt
│   │   ├── bonsai_bridge.cpp          # N-API:socketpair / fd 读写
│   │   └── bonsai_child.cpp           # Native 子进程:dlopen + Py_BytesMain
│   ├── entry/src/main/resources/
│   │   ├── base/element/string.json   # 「筑眸」
│   │   └── rawfile/bonsai_python_runtime.zip
│   └── entry/libs/arm64-v8a/           # libpython3.12.so + C 扩展(只放 .so)
└── _bonsai_build/                    # 内核与运行时构建工作区
    ├── bonsai_engine.py              # 纯 Python IFC 内核(43KB)
    ├── build_runtime.py               # 打 runtime zip
    ├── gen_sample.py / samples/        # 示例 IFC 生成
    ├── check_server.py / test_engine.py
    └── bonsai_python_runtime.zip

命令行构建(Windows + DevEco SDK):

$env:DEVECO_SDK_HOME = "C:\Program Files\Huawei\DevEco Studio\sdk"
$env:PATH = "C:\Program Files\Huawei\DevEco Studio\jbr\bin;$env:PATH"
& "C:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin\hvigorw.bat" `
    --mode module -p module=entry@default -p product=default assembleHap --no-daemon

把 DevEco 自带 jbr/bin 前置到 PATH 是必须的:PackageHap 阶段会 spawn java 调打包工具,缺了会报 00308018。

16.2 安全与工程卫生

  • 签名材料不入库:build-profile.json5 里的 p12 / 加密密码 / profile 路径属于本机调试凭据,公开仓库必须清空或改为本机覆盖;
  • 零权限 + 用户授权:requestPermissions: [],文件经 DocumentViewPicker 进入沙箱,不落用户原始路径、不上传;
  • 子进程最小化:Python 内核只访问 filesDir 下的请求/响应与模型文件,exec_script 端点不暴露到 UI;
  • 输入防御:所有 JSON.parse 结果在 ArkTS 侧先判 ok、判数组、判字段类型,ForEach 数据源永远给数组,避免把字典塞进 @State 触发运行时错误;
  • 资源清理:aboutToDisappear 关闭全部 fd,killKernel 写 STOP 文件让内核自行退出,finally 删除 READY;
  • 开源合规:GPL-3.0,工程根保留 LICENSE,上游 BlenderBIM / IfcOpenShell 的许可与署名在 README 中说明。

16.3 已知边界与路线图

阶段目标状态
P1IFC4 SPF 解析、空间树、属性/Pset、二维平面图纸、树与画布双向点选、零权限打开本地 IFC已完成,真机验收通过
P2交叉编译 IfcOpenShell / OCCT 的 aarch64-linux-ohos 版本,替换纯 Python 几何解析,支持布尔开洞与任意 B-Rep规划中
P33D 视口、构件剖切、量测与图层过滤;大模型树虚拟滚动规划中

结语

把一个 BIM 应用搬上 HarmonyOS PC,真正的工作量不在「把代码编译过去」,而在于重新回答四个平台问题:解释器怎么进包并启动、进程之间怎么可靠通信、文件怎么在零权限下被用户交到应用手里、以及界面怎样用 ArkUI 原生地画出来。筑眸给出的答案分别是:rawfile + noexec 友好的纯 Python 运行时、startNativeChildProcess + dlopen + Py_BytesMain、socketpair fd + 带序号的文件队列协议,以及 Canvas 2D 的图纸级平面视图。

结果是真机上一条完整可复现的链路:首启隐私确认 → Python 内核就绪 → 打开/加载 IFC → 266 实体解析 8 ms → 空间树、属性、Pset、平面图纸与点选全部联动。它还不等于桌面版 BlenderBIM,但已经证明:鸿蒙 PC 完全能承载一个离线、原生、带真实领域内核的专业模型浏览器,而 Python 生态也不必被重写一遍才能上岸。

Logo

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

更多推荐