把 BlenderBIM / IfcOpenShell 的 IFC 世界搬上 HarmonyOS PC:筑眸移植全记录
把 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:首启后的空工作区。深色三栏布局,右侧控制台已经打印「[内核] 就绪(常驻进程)」——注意,此时 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:加载随包示例后的效果。左侧是从 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. 总体架构:一个 HAP 里的三层世界

三个关键设计决策:
- 解释器放
libs,标准库放filesDir:.so由系统加载器按 HAP 的 native lib 路径装载,纯.py运行时因为noexec无所谓,解压到应用沙箱即可; - 通信不用管道/PTY,用「socketpair fd + 文件队列」:fd 解决进程启动时的句柄传递,文件队列解决「类型是 JSON、大小不定、必须可重试」的请求/响应;
- 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#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:首启的隐私声明由用户确认后才进入主界面。应用在 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 / 图 3 | IFCPROJECT → 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 |

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 集合) |
| 5 | dlopen(libpython) 直接 SIGSEGV | 预编译库被 strip / 体积异常 | nativeLib.debugSymbol.strip=false;Native 层加体积告警与 SIGSEGV 日志 |
| 6 | posix_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 为组件内坐标;设备截图/布局坐标需按缩放比换算 |
| 12 | HAP 体积异常膨胀 | 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 已知边界与路线图
| 阶段 | 目标 | 状态 |
|---|---|---|
| P1 | IFC4 SPF 解析、空间树、属性/Pset、二维平面图纸、树与画布双向点选、零权限打开本地 IFC | 已完成,真机验收通过 |
| P2 | 交叉编译 IfcOpenShell / OCCT 的 aarch64-linux-ohos 版本,替换纯 Python 几何解析,支持布尔开洞与任意 B-Rep | 规划中 |
| P3 | 3D 视口、构件剖切、量测与图层过滤;大模型树虚拟滚动 | 规划中 |
结语
把一个 BIM 应用搬上 HarmonyOS PC,真正的工作量不在「把代码编译过去」,而在于重新回答四个平台问题:解释器怎么进包并启动、进程之间怎么可靠通信、文件怎么在零权限下被用户交到应用手里、以及界面怎样用 ArkUI 原生地画出来。筑眸给出的答案分别是:rawfile + noexec 友好的纯 Python 运行时、startNativeChildProcess + dlopen + Py_BytesMain、socketpair fd + 带序号的文件队列协议,以及 Canvas 2D 的图纸级平面视图。
结果是真机上一条完整可复现的链路:首启隐私确认 → Python 内核就绪 → 打开/加载 IFC → 266 实体解析 8 ms → 空间树、属性、Pset、平面图纸与点选全部联动。它还不等于桌面版 BlenderBIM,但已经证明:鸿蒙 PC 完全能承载一个离线、原生、带真实领域内核的专业模型浏览器,而 Python 生态也不必被重写一遍才能上岸。
更多推荐



所有评论(0)