BlenderFDS 火灾仿真输入文件工作台的鸿蒙 PC 移植实战
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
适配开源地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_BlenderFDS
把一款深度绑定 Blender 5.2 的 Python 插件,拆成「纯 Python 领域内核 + 嵌入式 CPython 3.12 + ArkTS 原生界面」,最终在 HarmonyOS PC 上跑通「打开
.fds→ 解析 → 编辑 → 校验 → 导出」的完整闭环。本文记录从源码侦察、路线选型、P0/P1/P2 三阶段实现,到真机截图验收与两次典型构建故障排查的全过程,附带可复现代码与 QA 体系。

〇、TL;DR
- 上游 BlenderFDS(现行名 BFDS,v7.2.0,GPL-3.0-or-later)是 NIST FDS 火灾仿真软件的前处理器,109 个 Python 文件、约 1.52 万行,深度绑定 Blender ≥ 5.2。
- 真正可移植的内核是
FDSList/FDSNamelist/FDSParam文本 AST 与约 150 个声明式参数定义;Blender UI、bmesh体素化、BVHTree几何转换必须放弃。 - 实测发现:裸 CPython 下上游零个文件能原样导入(
import bpy会被链式传导),所以剥离内核是一次「导入层手术」,不是复制文件。 - 最终架构:T0 ArkUI 壳(namelist 树 / Canvas 2D / 属性表单)+ T1 嵌入式 CPython 3.12 子进程,ArkTS 与 Python 之间用「每请求独立文件对」的 JSON 协议。
- 成果:P0 领域层验收 16/16,内核协议验收 22/22,
assembleHap构建成功,签名 HAP 41.08 MB;真机跑通「解析 → 编辑 → 校验 → 2D 绘制生成几何 → 3D 等轴测查看 → 缩放平移 → 导出」全闭环(八张截图见第六章)。

一、先搞清楚要搬的到底是什么
NIST 的 FDS(Fire Dynamics Simulator) 是火灾与烟气流动的 CFD 求解器,输入是一个 Fortran namelist 文本文件(.fds),计算结果用 Smokeview 查看。手写这个文件非常痛苦:一堵墙是 XB=2.0,2.2,0,4,0,2.4 这样的六元组,一个房间要拆成若干 MESH 网格,火源是贴在 VENT 上的 SURF,外加 HEAD/TIME/MISC/REAC/DUMP 等上百个参数,坐标或网格一错,算出来的结果就是错的,而且没有图形界面能直观检查。
BlenderFDS(现行名 BFDS)就是为解决这件事而生的:它把 Blender 3D 场景中的墙体、开口、火源、计算网格自动转换成 .fds,也支持反向导入、参数面板编辑、MPI 网格分桶,以及在 Blender 里一键调用外部 fds / smokeview。

问题在于:它不是独立应用,只是 Blender 的扩展插件。要把它搬到鸿蒙 PC,第一反应是「把 Blender 一起搬过去」,而这恰好是最不可行的一条路。动手之前,先把上游源码的真实体量摸清楚(firetools/bfds,HEAD 0ad1088,109 个 .py、约 1.52 万行):
| 目录 | 文件数 | 约行数 | 职责 | 与 Blender 的耦合 |
|---|---|---|---|---|
source/lang/ | 62 | 6,870 | 约 150 个声明式 namelist / 参数定义,MESH 划分、MULT 乘子、GIS 换算 | 35 个文件顶层 from bpy.types/props import ... |
source/types/ | 5 | 1,550 | FDSList / FDSNamelist / FDSParam 文本 AST;BFParam 元数据基类 | bf_param.py、bf_namelist.py 引用 bpy |
source/bl/ + source/ui/ | 26 | 3,990 | Blender Panel / Operator / Header / UIList | 全部是 UI,无法复用 |
source/utils/ | 8 | 1,180 | GIS、IO、几何、装箱、外部命令 | geometry.py 用 bmesh,run.py 用 os.system 起 mpiexec |
source/config.py | 1 | 800 | 精度常量、导出格式、外部命令模板 | 第 3 行 import os, sys, bpy |
真正值钱的是 source/types/fds_list.py(508 行)——一套自洽的 FDS namelist 文本 AST,自带解析与格式化:
# 上游 source/types/fds_list.py:几乎零 bpy 的内核
class FDSList(list):
def to_string(self) -> str:
body = list()
body.extend(self.msgs)
body.extend(item.to_string() for item in self)
body = "\n".join(b for b in body if b)
if self.header and body:
body = "\n".join((self.header, body))
return body
def from_fds(self, f90_namelists=None, f90_params=None, f90_value=None) -> None:
self.clear()
if f90_namelists:
for match in re.finditer(self._RE_SCAN_F90_NAMELISTS, f90_namelists):
label, f90_params = match.groups()
fds_namelist = FDSNamelist(fds_label=label)
fds_namelist.from_fds(f90_params=f90_params)
self.append(fds_namelist)
由此得到两个关键判断:
- 内核值得保留:文本 AST + 约 150 个声明式参数 + 纯数值算法(MESH 划分、MPI 分桶、GIS)质量很高且基本自洽;Blender UI、
bmesh体素化(calc_voxels.py514 行)、BVHTree/KDTree几何转换(ob_to_geom.py391 行)绑死 Blender,必须放弃。 - 「纯 Python 文件」是个陷阱:文本上有 41 个文件不含
bpy,但config.py的import bpy会通过types/__init__.py、utils/__init__.py、lang/__init__.py的链式导入传导到全包——裸 CPython 下当前零个文件能原样导入。所以「抽出领域层」不是复制文件,而是一次导入层手术。
另外还有一个安全发现:上游解析外部 .fds 的参数值时用了 eval()(fds_list.py 第 272 行附近)。留在 Blender 里问题不大,但做成面向用户文件的桌面工具后,这就是一个代码执行入口,必须换掉。
二、路线选型:为什么是「T0 的壳 + T1 的核」
鸿蒙 PC 适配常按迁移深度分三级:T2 在翻译/兼容层下直接运行原始桌面二进制;T1 用嵌入式运行时(CPython / Qt / Electron / Web)承载原有领域代码,仅重写壳层与桥接;T0 用系统原生语言与 UI 框架重写业务逻辑。
Blender 桌面程序在鸿蒙沙箱里没有可承载的兼容层,T2 直接出局。剩下的问题只是「哪些部分走 T1、哪些走 T0」:
| 路线 | 做法 | 结论 |
|---|---|---|
| A 整体搬 Blender 5.2 | 移植 GHOST/OpenGL/EGL 与几百 MB 依赖链 | 否决:无已验证运行时,不可控、不可上架 |
| B Electron / ArkWeb + Pyodide | 把 150 个参数声明翻成 JS,或塞 WASM | 否决:丢掉「跟随上游」能力,包体与 JIT 风险高 |
| C 纯 ArkTS 全量重写 | 连领域层一起重写 | 否决:正则解析、值求值、__init_subclass__ 元数据注册全要重做,语义极易走样 |
| D T0 壳 + T1 核 | ArkUI 原生界面 + 嵌入式 CPython 承载 Python 领域层 | 采用 |
最终架构:

Native 侧复用了同工作区里已真机验证过的成熟链路:childProcessManager.startNativeChildProcess 拉起 libbfds_child.so:Main,子进程 dlopen HAP 内的 libpython3.12.so 后调 Py_BytesMain。相比「静态 .a 整链 + PyRun_SimpleString 捕获 stdout」的方案,这条链路有三个决定性优势:崩溃隔离(解析用户上传的 .fds 出异常不会带走 UI)、常驻状态自然(编辑器需要在内存里保持 namelist 树)、避开子进程 io 写盘不可靠的坑(文件协议只依赖 open/write/fsync,不依赖 stdout 管道)。
明确的功能减法也写进了 README 的边界:不做 Blender 3D 建模与体素化、不做 GEOM/BVH 转换、不内置 FDS/Smokeview 求解器(Fortran + MPI 二进制无法在 HAP 沙箱 execv,filesDir 是 noexec 挂载),产品定位是「FDS 输入文件工作台」,计算交给外部求解器。
三、P0:把领域层从 Blender 里「剥」出来
P0 的目标非常克制:不写一行鸿蒙代码,先证明 import bfds_core 能在裸 CPython 下成功。产物是 p0_core/bfds_core/,77 个 .py / 327 KB。
3.1 用兼容垫片切断 bpy 传导
上游只是把 bpy.types.Scene、bpy.props.FloatProperty 当类元数据(bpy_type = Scene),真正调用 Blender 都在方法体里。所以不必逐文件改 35 个 import,只需在包入口安装一个按需生成符号的垫片,把 bpy / bmesh / mathutils 注册进 sys.modules:
# 代码位置:p0_core/bfds_core/_compat.py(节选)
class _Sentinel:
"""可调用、属性可读写、按需缓存的哑对象。"""
def __init__(self, name):
object.__setattr__(self, "name", name)
object.__setattr__(self, "_cache", {})
def __call__(self, *args, **kwargs):
return self
def __getattr__(self, key):
if key.startswith("__"):
raise AttributeError(key)
cache = object.__getattribute__(self, "_cache")
if key not in cache:
cache[key] = _Sentinel(f"{self.name}.{key}")
return cache[key]
def _module(name, factory):
mod = _types.ModuleType(name)
setattr(mod, "_bfds_compat_stub", True)
cache = {}
def __getattr__(key):
if key.startswith("__"):
raise AttributeError(key)
if key not in cache:
cache[key] = factory(key)
return cache[key]
mod.__getattr__ = __getattr__
sys.modules[name] = mod
return mod
def install():
current = sys.modules.get("bpy")
if current is not None and not getattr(current, "_bfds_compat_stub", False):
return # 真 Blender 环境不覆盖
types_mod = _module("bpy.types", lambda k: type(k, (), {})) # 稳定唯一,供 == 比较
props_mod = _module("bpy.props", lambda k: _prop)
...
_module("bmesh", lambda k: _Sentinel(k))
mathutils = _module("mathutils", lambda k: _Sentinel(k))
mathutils.kdtree = _module("mathutils.kdtree", lambda k: _Sentinel(k))
mathutils.bvhtree = _module("mathutils.bvhtree", lambda k: _Sentinel(k))
bpy.types 用动态 type() 生成稳定唯一的类(供 n.bpy_type == Scene 这类比较),bpy.props 返回哑函数。包入口先装垫片再加载领域层:
# 代码位置:p0_core/bfds_core/__init__.py
from . import _compat
_compat.install()
from . import config, types, utils, lang
from .types import BFException, BFParam, BFNamelist, FDSList, FDSNamelist, FDSParam
3.2 四处必须动刀的地方
| 改造 | 文件 | 原因 |
|---|---|---|
去 import bpy、startup.blend 路径置空 | config.py | 800 行精度/格式常量表保留 |
删 f90nml 残留 import;eval → ast.literal_eval + 类型白名单 | types/fds_list.py | 安全红线(见下) |
切断链式导入;bpy.path 的 // 语义换成沙箱绝对路径 | utils/__init__.py、utils/io.py | 鸿蒙 HAP 没有 .blend 基准路径 |
numpy → array + struct | lang/ON_GEOM/bingeom.py | 消灭唯一的 C 扩展依赖,保持 Fortran record 字节布局不变 |
eval 补丁是全文最重要的一处安全修改——外部 .fds 是用户文件,绝不能执行其中的表达式:
# 代码位置:p0_core/bfds_core/types/fds_list.py(改造后)
else:
# 只接受字面量,禁止对外部 .fds 值执行任意表达式
try:
value = ast.literal_eval(v)
if not isinstance(value, (bool, int, float, str)):
raise ValueError(
f"unsupported value type <{type(value).__name__}>"
)
except Exception as err:
msg = f"Malformed FDS file: <{self.fds_label}={f90_value}> (value: <{v}>)\n<{err}>"
raise BFException(self, msg)
values[i] = value
bingeom.py 的去 numpy 改写保持字节级兼容(Fortran unformatted sequential record:4 字节长度 tag + 数据 + 4 字节 tag):
# 代码位置:p0_core/bfds_core/lang/ON_GEOM/bingeom.py(节选)
_FORMATS = {"int32": ("i", 4), "float64": ("d", 8)}
def _read_record(f, req_dtype, req_dlen):
typecode, itemsize = _check_format(req_dtype)
tag = struct.unpack("i", f.read(4))[0]
dlen = tag // itemsize
if dlen != req_dlen:
raise IOError(f"Different requested and declared record length: {req_dlen}, {dlen}")
data = array.array(typecode)
data.frombytes(f.read(tag))
end_tag = struct.unpack("i", f.read(4))[0]
if tag != end_tag:
raise IOError(f"Different start and end record tags: {tag}, {end_tag}")
return data
3.3 剥离要可重放,不要一次性手艺
上游在演进,手工剥离副本很快就会失联。所以 P0 的最后一步是把剥离过程固化成幂等脚本:复制领域层子集 → 重放五处补丁 → 绝不覆盖手写的垫片与沙箱 IO。
# 代码位置:tools/sync_from_upstream.py(节选)
def patch(path, replacements):
with open(path, "r", encoding="utf-8") as f:
text = f.read()
changed = False
for old, new in replacements:
if old in text:
text = text.replace(old, new, 1)
changed = True
if changed:
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
return changed
# shutil.copytree(..., ignore=shutil.ignore_patterns("bingeom.py", "__init__.py", "io.py", ...))
# print(f"synced {CORE}; re-patched: {changed or 'nothing (already clean)'}")
3.4 P0 验收:16 项全绿
验收脚本 verify_roundtrip.py 不依赖 Blender、不依赖 DevEco、不依赖任何第三方包,覆盖导入、元数据、往返幂等、数值工具、bingeom 读写与安全解析:
| # | 检查项 | 结果 |
|---|---|---|
| 1 | bfds_core 可在裸 CPython 导入 | PASS |
| 2 | bpy 已替换为兼容垫片 | PASS |
| 3 | 内核未导入 numpy(零 C 扩展) | PASS |
| 4 | 领域层元数据加载(BFParam=135,BFNamelist=30) | PASS |
| 5 | 声明式 namelist 可检索(ON_OBST / OP_XB) | PASS |
| 6-8 | 12 个 namelist 解析 / 生成 / 解析→生成→解析幂等 | PASS |
| 9-12 | 字符串 / 布尔 / 多值浮点坐标 / 整数 / 多行 namelist 保持 | PASS |
| 13 | MPI 分桶 binpack 可用 | PASS |
| 14 | bingeom 读写往返(array/struct,172 字节) | PASS |
| 15 | 恶意 .fds 值 __import__('os').system(...) 被拒绝 | PASS |
| 16 | 静态复核无 subprocess / os.system / f90nml / eval( | PASS |
这一步通过,才判定整条路线值得继续——它同时回答了「领域层能不能脱离 Blender」和「要不要一次性把 15k 行翻成 ArkTS」两个问题。
四、P1:HAP 骨架与 Native 子进程
4.1 工程骨架
照抄已验证模板的关键红线:bundleName org.blenderfds.ohos、deviceTypes ["2in1","tablet"]、compatibleSdkVersion 6.0.1(21) / targetSdkVersion 6.0.2(22)、requestPermissions: [](零权限)、nativeLib.collectAllLibs: true + 两处 debugSymbol.strip: false(被 strip 的 libpython 在 musl 上 dlopen 会 SIGSEGV)。
# 代码位置:entry/src/main/cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.5.0)
project(bfds_bridge)
set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR})
set(CMAKE_CXX_STANDARD 17)
add_library(bfds_bridge SHARED bfds_bridge.cpp)
target_compile_definitions(bfds_bridge PRIVATE _GNU_SOURCE)
target_link_libraries(bfds_bridge PUBLIC libace_napi.z.so libhilog_ndk.z.so)
add_library(bfds_child SHARED bfds_child.cpp)
target_compile_definitions(bfds_child PRIVATE _GNU_SOURCE)
target_link_libraries(bfds_child PUBLIC libhilog_ndk.z.so libdl.so)
libbfds_bridge.so 导出 createChannel/closeFd/readFd/writeFd/readUntilEof/ensureExecutable 六个 NAPI 方法,createChannel 用 socketpair(AF_UNIX, SOCK_STREAM, 0, fds) 把两个 fd 直接作为 JS 对象属性返回(沙箱里 posix_openpt 返回 EPERM,socketpair 是可行解)。libbfds_child.so 导出 Main(NativeChildProcess_Args),从 fd 表取 terminal/boot/out/exit,dup2 接管 stdout/stderr,dlopen 按 libpython3.12.so.1.0 → libpython3.12.so 顺序尝试,最后 Py_SetPythonHome + Py_BytesMain 进入解释器。entryParams 全部 UTF-8 hex 编码、以 00(NUL)拼接,规避 NUL 与中文路径问题。
4.2 每请求独立文件对:协议的核心
ArkTS 与 Python 之间不用 socket 传 JSON,而是文件队列:请求 bfds_req_<seq>.json,响应 bfds_resp_<seq>.json(临时文件 + os.replace 原子发布)。为什么必须一请求一文件对? 因为单共享请求文件会被排队请求覆盖,内核读到的是下一条请求,上一条被静默吞掉——这是真机调试时用日志埋点抓出来的问题,坑在「没有任何报错,只是请求消失了」。
# 代码位置:python_kernel/bfds_server.py(节选)
def _pending():
names = [n for n in os.listdir(BASE)
if n.startswith(REQ_PREFIX) and n.endswith(".json")]
names.sort()
return os.path.join(BASE, names[0]) if names else None
def _load(path):
for _ in range(20): # 半写 JSON 重试
try:
with open(path, "r", encoding="utf-8") as f:
return json.loads(f.read())
except ValueError:
time.sleep(0.01)
except OSError:
return None
return None
def _emit(seq, result):
tmp = os.path.join(BASE, RESP_PREFIX + seq + ".json.tmp")
target = os.path.join(BASE, RESP_PREFIX + seq + ".json")
with open(tmp, "w", encoding="utf-8") as f:
json.dump({"seq": seq, "result": result}, f)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, target) # 原子发布
ArkTS 侧对应实现:temp-then-rename 写请求、15ms 轮询响应、双删文件、Promise 链串行化所有请求:
// 代码位置:entry/src/main/ets/pages/Index.ets(节选)
const seq = `${Date.now()}_${++this.requestSerial}_${fn}`;
const reqPath = this.kernelFile(`bfds_req_${seq}.json`);
const respPath = this.kernelFile(`bfds_resp_${seq}.json`);
const tmpPath = reqPath + '.tmp';
writeTextFile(tmpPath, payload);
try {
fileIo.moveFileSync(tmpPath, reqPath);
} catch (_) {
writeTextFile(reqPath, payload);
}
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' });
fileIo.unlinkSync(respPath);
fileIo.unlinkSync(reqPath);
return out;
}
await sleepMs(15);
}
4.3 server 源码不要手工内嵌进 ArkTS
模板工程曾把 server 源码手工内嵌成 ArkTS 字符串数组,多行字符串的缩进一旦断裂,设备端只会静默 SyntaxError,排查成本极高。这里改成从真实 .py 文件生成:
# 代码位置:tools/gen_py_server.py(核心三行)
with open(SRC, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
body = ",\n".join(" " + json.dumps(line, ensure_ascii=False) for line in lines)
text = "export const PY_SERVER: string = [\n" + body + "\n].join('\\n');\n"
生成出的 PyServer.ets 再被验收脚本反向提取并 compile(),保证「ArkTS 里内嵌的 Python」永远语法正确。
4.4 运行时包与 marker
build_runtime.py 从源运行时包提取 aarch64-linux-ohos 的 CPython 3.12 stdlib,放进 bfds_core/(77 个文件)与 bfds_engine.py,用 tar -a -cf 打成 bfds_python_runtime.zip(8.78 MB / 808 条目)——不能用 PowerShell 的 Compress-Archive,它会写反斜杠路径,设备端解压后 import 全挂。首启由 ArkTS 解压到 filesDir,用 marker 文件(READY_BFDS_2)判断是否可复用;每次改 Python 必须递增 marker,否则设备端永远沿用旧解压目录。
# 代码位置:tools/build_runtime.py(节选)
shutil.copytree(CORE_SRC, os.path.join(sp, "bfds_core"),
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), dirs_exist_ok=True)
shutil.copy(ENGINE_SRC, os.path.join(sp, "bfds_engine.py"))
subprocess.run(["C:/Windows/System32/tar.exe", "-a", "-cf", OUT_ZIP, "ohos_python"],
cwd=RTMP, check=True)
P1 验收在宿主 CPython 上模拟设备端文件协议:从 PyServer.ets 提取 server 源码 compile() 通过 → 复制内核到临时目录 → 写请求文件 → 调 _serve_one() → 读响应文件。probe_state / get_meta / sample_fds / parse_fds / export_fds / open_fds / 未知 fn 结构化错误 / 零 numpy 9/9 通过。随后 assembleHap 构建成功,HAP 内核对:runtime zip、两个桥接 so、libpython3.12.so(.1.0)、64 个 libs/arm64 条目一个不少。
五、P2:三栏工作台
5.1 内核新增三个接口
编辑闭环要求「改参数值 → 重建 .fds → 校验 → 驱动画布」,所以在 bfds_engine.py 里补了 build_fds / validate_fds / get_geometry。用户在属性框里编辑的是字符串,回写时经引号保护的逗号拆分 + 字面量求值还原成 FDS 值,再走上游 AST 的 to_string(),保证格式与领域层完全一致:
# 代码位置:python_kernel/bfds_engine.py(节选)
def _split_values(raw):
"""Split a comma-separated FDS value while protecting quoted strings."""
out, buf, quote = [], "", None
for ch in raw:
if quote:
buf += ch
if ch == quote:
quote = None
elif ch in ("'", '"'):
quote, buf = ch, buf + ch
elif ch == ",":
out.append(buf); buf = ""
else:
buf += ch
out.append(buf)
return [p.strip() for p in out if p.strip()]
def _build(items):
"""Rebuild a formatted .fds from the structured namelist tree."""
tree = FDSList()
for item in items or []:
label = str(item.get("label", "")).upper()
params = []
for p in item.get("params", []) or []:
plabel = str(p.get("label", "")).upper()
if not plabel:
continue
params.append(FDSParam(fds_label=plabel, value=_coerce(p.get("value"))))
tree.append(FDSNamelist(fds_label=label, iterable=params))
return {"ok": True, "text": tree.to_string(), "count": len(tree)}
校验覆盖四类真实错误源:namelist 标签必须 4 位大写字母/数字、XB 必须是 6 个数值且满足 x0≤x1, y0≤y1, z0≤z1、IJK 必须是 3 个正整数、SURF_ID 引用必须能找到对应 &SURF:
# 代码位置:python_kernel/bfds_engine.py(节选)
if "XB" in pm:
xb = _num_list(pm["XB"])
if xb is None or len(xb) != 6:
issues.append({"level": "error", "where": where, "message": "XB 必须是 6 个数值"})
elif not (xb[0] <= xb[1] and xb[2] <= xb[3] and xb[4] <= xb[5]):
issues.append({"level": "error", "where": where,
"message": "XB 边界须满足 x0≤x1, y0≤y1, z0≤z1"})
if "SURF_ID" in pm and surf_ids:
sid = _text(pm["SURF_ID"])
if sid and sid not in surf_ids:
issues.append({"level": "warn", "where": where,
"message": f"SURF_ID='{sid}' 未找到对应 &SURF"})
5.2 ArkUI 三栏与 Canvas 2D
界面是标准 PC 三栏:左侧 namelist 树(24%)、中间 Canvas 计算域 + FDS 文本编辑区、右侧属性表单(30%)+ 校验问题列表。Canvas 把每个 MESH 的 XB 矩形按 IJK 画网格、OBST/VENT/HOLE 按类型着色、选中项橙色高亮,边界自适应缩放:
// 代码位置:entry/src/main/ets/pages/Index.ets(节选)
private drawScene(): void {
const ctx = this.canvasCtx;
const w = this.canvasW > 0 ? this.canvasW : Number(ctx.width);
const h = this.canvasH > 0 ? this.canvasH : Number(ctx.height);
if (w <= 0 || h <= 0) { return; }
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
const pad = 18;
const spanX = Math.max(this.xmax - this.xmin, 0.000001);
const spanY = Math.max(this.ymax - this.ymin, 0.000001);
const scale = Math.min((w - 2 * pad) / spanX, (h - 2 * pad) / spanY);
...
for (let i = 0; i < this.meshes.length; i++) {
const m = this.meshes[i];
const r = this.rectOf(m.xb);
ctx.strokeStyle = '#1565c0'; // MESH 外框
ctx.lineWidth = 2;
ctx.strokeRect(r.x, r.y, r.w, r.h);
ctx.strokeStyle = '#dbe6f6'; // IJK 单元网格
const ni = Math.min(m.ijk[0], 40);
for (let a = 1; a < ni; a++) {
const x = m.xb[0] + (m.xb[1] - m.xb[0]) * a / ni;
ctx.beginPath();
ctx.moveTo(this.px(x), this.py(m.xb[2]));
ctx.lineTo(this.px(x), this.py(m.xb[3]));
ctx.stroke();
}
}
}
属性编辑有个容易踩的 ArkUI 坑:TextInput 直接双向绑定数组元素会丢焦点。解法是把编辑值放进独立的 @State propValues: string[],选中项切换时整体赋值触发刷新,输入过程中只按下标静默更新(不触发重建,焦点不丢),点「应用编辑」才整体写回结构树并请求 build_fds。
另存为走零权限 picker(DocumentViewPicker.save + DocumentSaveOptions,newFileNames / fileSuffixChoices),对话框预创建空文件并返回 uri,应用随后写入:
// 代码位置:entry/src/main/ets/pages/Index.ets(节选)
const docPicker = new picker.DocumentViewPicker(this.context);
const opts = new picker.DocumentSaveOptions();
opts.newFileNames = ['case.fds'];
opts.fileSuffixChoices = ['FDS 输入文件|.fds'];
const uris: Array<string> = await docPicker.save(opts);
if (uris.length === 0) { return; }
const file = fileIo.openSync(uris[0],
fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
try {
const data = new util.TextEncoder().encode(this.fdsText);
fileIo.writeSync(file.fd, data.buffer);
} finally {
fileIo.closeSync(file);
}
六、真机验收
八张截图分两组:前四张对应 P2 的完整验收路径(冷启动 → 加载示例 → 最大化编辑 → 切换选中联动);6.2 节的四张对应 P3 的 3D 视图与交互打磨(放大细节 → 3D 选中联动 → 小窗口方位 → 缩小总览)。

图 1 · 冷启动。窗口标题「焰匣 FDS · 火灾仿真输入文件工作台」,右上角状态为「内核就绪 | T1 · CPython 3.12 · 1.0.0」。此时已经完成:runtime zip 首启解压(marker 校验)、childProcessManager 拉起 libbfds_child.so:Main、dlopen libpython、server 写出 bfds_srv_ready.txt。底部日志打印了「内核就绪(常驻进程)」以及解释器版本与 BFDS 7.2.0 的握手信息——这两行是整条 T1 链路活着的第一手证据。三栏均为空态,等待载入模型。

图 2 · 加载示例。点「加载示例」后,左栏出现 9 个 namelist(&HEAD/&TIME/&MISC/&MESH/&SURF/&OBST/&VENT/&DEVC/&TAIL),每个都带实时统计的参数个数;中间画布画出蓝色 MESH 外框、浅蓝 IJK 单元网格、灰色 OBST 盒体与火源 VENT;右栏自动选中 &OBST · #6,属性表单展示 ID / XB / SURF_ID 三个参数。注意右下角校验栏:0 错误 / 1 警告,警告内容是 &OBST 的 SURF_ID='WALL' 未找到对应 &SURF——这不是 bug,而是校验引擎在工作:内置示例故意只定义了 &SURF ID='FIRE',WALL 是一个悬空引用,正好用来演示 warn 级别语义检查。

图 3 · 最大化编辑。窗口最大化后三栏按 24% / 自适应 / 30% 稳定伸缩,Canvas 随 onReady 重新取宽高并重绘。底部 FDS 文本区显示的是规范化后的 .fds(多行 MESH 合并、精度对齐、&TAIL / 收尾),它是从结构树经 build_fds 重新生成的——也就是说,文本区、结构树、画布三份数据同源,任何一处编辑都能通过「解析文本 / 应用编辑」双向同步。日志区可见连续多次「应用编辑 → 解析 → 规范化」操作,全部走常驻内核的文件协议完成。

图 4 · 联动。点选左栏 &VENT · #7,右栏属性切换为 ID=BURNER / XB / SURF_ID=FIRE,画布上的橙色高亮框同步移动到火源盒体上。namelist 树 → 属性表单 → 画布三者联动,是这套「结构化编辑」区别于纯文本编辑器的核心价值:改一个 XB,立刻能看到它在计算域里的位置和尺寸是否合理。
6.2 P3 补记:把「3D」与「可操作」搬进画布
P2 交付后,真机反馈集中在三件事:3D 图像显示不全、中间画布不能移动放大、按钮占了两行。这一轮把三个问题一起收敛,顺带把「2D 拖拽生成几何」和「3D 等轴测视图」补成了完整闭环。下面四张截图是这一轮的真机结果。
(1) 3D 等轴测:纯 Canvas 数学,不引入 OpenGL
等轴测的关键只有两行变换:先绕 Z 轴旋转方位角,再把 (x−y) 与 (x+y)/2−z 投到屏幕坐标,最后用包围盒 8 个角点自适应缩放居中。
private isoRaw(x: number, y: number, z: number): number[] {
const a = this.isoAzimuth; // 方位角,每次点「旋转」+45°
const xr = x * Math.cos(a) - y * Math.sin(a);
const yr = x * Math.sin(a) + y * Math.cos(a);
return [(xr - yr) * 0.8660254, // cos30°
(xr + yr) * 0.5 - z]; // sin30°,z 向上
}
每个 XB 盒体算 8 个顶点 → 三个可见面按明/中/暗透明度填充 → 12 条边线框;所有盒体按 x+y+z 深度排序,近的覆盖远的。因此不需要 XComponent / OpenGL ES / 任何新运行时,真机风险几乎为零。留白从 26px 提到 52px 并再乘 0.88 安全系数,配合 onAreaChange 实时取画布宽高,旋转到任意方位都不贴边。

图 5 · 放大平移。方位 90°、缩放 156%(工具栏倍率按钮可见):灰色 OBST 是一块贯穿整层 z 范围的墙板,红色小方块是火源 VENT,蓝色线框是 MESH 计算域。放大后蓝色线框有意超出画布——这正是「可移动查看」的意义:用浏览模式拖拽把感兴趣的区域拖到视野中央,看局部细节,而不是被强行缩到一屏里。

图 6 · 3D 联动。方位 0°、100%:左栏选中 &OBST · #6,右栏显示 ID=WALL / XB / SURF_ID=WALL,画布上橙色粗框套住整块墙板,MXSH 蓝色线框与红色火源的空间关系一目了然。2D 视图里的「树 → 属性 → 画布」联动,在 3D 等轴测下完全复用同一份 get_geometry 数据。

图 7 · 小窗口与方位 180°。把窗口拖回普通尺寸、切到 180° 方位,三栏仍按 24% / 自适应 / 30% 稳定伸缩,画布上方的状态行显示「3D 等轴测 · 方位 180°」。这说明布局是靠 layoutWeight 与百分比宽度做的自适应,不是为某一个窗口尺寸写死的坐标。

图 8 · 缩小总览。方位 225°、缩放 64%:主动缩小后完整看清 MESH 外框、贯穿整层的 OBST 板与火源的相对位置。配合图 5 的 156% 放大,构成「总览 / 细节」两种观察尺度。
(2) 缩放、平移与绘制精度
2D 与 3D 两套坐标变换都纳入同一组 zoom / panX / panY,并且反算函数同步带上缩放平移——这是最容易被忽略的一处:只改正向绘制、不改反算,放大后拖拽画出来的 XB 就会落到错误位置。
private px(u: number): number {
return this.plotOx + (u - this.curUMin) * this.plotScale * this.zoom + this.panX;
}
private screenToU(sx: number): number {
return this.curUMin + (sx - this.plotOx - this.panX) / (this.plotScale * this.zoom);
}
浏览模式(drawMode === '')拖拽 = 平移;绘制模式拖拽 = 生成盒体,onTouch 的 Down/Move/Up 分两条路径互不干扰。缩放范围 0.3×–5×、步长 1.25×,点倍率按钮一键复位。
(3) 单行工具栏:用循环态按钮压缩交互
原来两行共 13 个按钮,一行放不下且拥挤。改成 12 个按钮一行后,用状态循环代替平铺:
| 按钮 | 循环态 |
|---|---|
| 画盒体 | 画 OBST → 画 VENT → 取消绘制 → …… |
| XY 平面 | XY → XZ → YZ → 3D 等轴测 → XY …… |
| 100% | 显示当前倍率,点一下复位 |
「规范化」并入「应用编辑」(两者都走 build_fds 重建文本),「旋转」在任意视图下都能直接进入 3D 并转 45°。画布内原本压在图形顶部的提示文字移到画布外的状态行,这同时解决了「文字盖住 3D 顶点」的观感问题。
(4) 2D 拖拽生成几何:从「看」到「画」
内核新增 add_box / update_xb / delete_namelist 三个接口与 get_geometry 的 z 边界。画布矩形按当前投影面组装成 XB,第三轴自动取覆盖 MESH 的整层范围,六个值再按该 MESH 的 IJK 单元尺寸对齐到网格边界——这就是上游「Voxel Size」的语义:
def _snap(value, origin, cell, lo, hi):
v = origin + round((value - origin) / cell) * cell
return min(hi, max(lo, round(v, 6)))
实测拖拽 1.23 → 1.77 在 IJK=60,40,30、XB=0..6 的模型里对齐成 1.2 → 1.8(cell=0.1),新 namelist 自动命名、自动插入 &TAIL 之前,树 / 文本 / 画布 / 校验一次刷新。
这一轮的验收:内核协议从 15 项扩到 22/22(新增 z 边界、add_box、snap 对齐、TAIL 前插入、update_xb、delete、非法平面 7 项),P0 仍 16/16,BUILD SUCCESSFUL 且 SignHap 正常执行。Python 内核改了三处接口,marker 同步递增到 READY_BFDS_3——这条流程红线在第五章已经说过,这一轮又踩实了一次。
七、踩坑实录
这一章是全文最贵的部分,每一条都是真实发生过的。
7.1 ohpm 00640001: Cannot convert undefined or null to object
DevEco 打开工程时执行 ohpm install --all 直接崩。根因不是网络也不是 SSL,而是手写的 oh-package-lock.json5 不合格:
- 根 lock 只有
lockfileVersion和packages,缺meta/specifiers字段,ohpm 对undefined做Object.keys()就崩; - 完全没有
entry/oh-package-lock.json5,而entry/oh-package.json5声明了本地file:依赖libbfds_bridge.so。
CLI 构建能成功是因为 hvigor 直接走本地源码路径,没触发 ohpm 完整解析——所以这个问题只在 IDE 里暴露。修复就是把两个 lock 文件按模板补齐:
// 代码位置:entry/oh-package-lock.json5(修复后)
{
"meta": { "stableOrder": true, "enableUnifiedLockfile": false },
"lockfileVersion": 3,
"specifiers": {
"libbfds_bridge.so@src/main/cpp/types/libbfds_bridge":
"libbfds_bridge.so@src/main/cpp/types/libbfds_bridge"
},
"packages": {
"libbfds_bridge.so@src/main/cpp/types/libbfds_bridge": {
"name": "libbfds_bridge.so",
"version": "1.0.0",
"resolved": "src/main/cpp/types/libbfds_bridge",
"registryType": "local"
}
}
}
修完复跑 install completed in 3s 433ms,而且之前「NAPI 模块未验证」的编译警告也消失了——lock 正确后 d.ts 才会被真正解析。
7.2 9568320: no signature file
自动签名做完,真机安装仍报「无签名文件」。日志里的决定性证据是 DevEco 推送的还是 entry-default-unsigned.hap。排查发现:自动签名把材料写进了 app.signingConfigs,但 app.products[0] 没有 "signingConfig": "default" 引用,SignHap 任务直接跳过。补一行引用,SignHap 实际执行 19.9 秒,产出 entry-default-signed.hap(41.08 MB,比 unsigned 多出的即签名块)。
// 代码位置:build-profile.json5(结构示意,密码切勿入库)
{
"app": {
"products": [
{
"name": "default",
"signingConfig": "default", // ← 缺了这一行,签名永远不执行
"compatibleSdkVersion": "6.0.1(21)",
...
}
],
"signingConfigs": [
{
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": "<自动签名生成的 .p12>",
"storePassword": "<加密串>",
"keyAlias": "debugKey",
"keyPassword": "<加密串>",
"profile": "<绑定 bundleName 的 .p7b>",
"certpath": "<.cer>"
}
}
]
}
}
连带教训:build-profile.json5 从此含本机材料路径与加密密码,公开仓库推送前必须清空 signingConfigs。
7.3 其余按代价排序的坑
| 坑 | 现象 | 解法 |
|---|---|---|
| 共享单个请求文件 | 排队请求被覆盖,请求静默消失,无任何报错 | 每请求独立 bfds_req_<seq>.json,响应后双删 |
| 手工内嵌 Python 到 ArkTS | 缩进断裂 → 设备端静默 SyntaxError | 从真实 .py 生成字符串常量,验收脚本反向 compile() |
| runtime marker 不递增 | 改了 Python,设备端永远用旧解压目录 | 每次重打 zip 手工递增 READY_BFDS_2 |
Compress-Archive 打 zip | 反斜杠路径,设备端 import 全挂 | tar.exe -a -cf |
strip: false 被忽略 | libpython 被 strip 后 dlopen SIGSEGV | 根/模块两处 debugSymbol.strip: false 都要写 |
| libs 目录混入非 so | collectAllLibs 全打包,包体暴涨 | 复制时过滤 .so / .so.x |
| ArkUI 属性链 | 读 @State 的宽高不生效、属性链里调私有方法被静默忽略 | 宽度用三元字面量百分比;绘制尺寸在 onReady 里读画布实际值 |
TextInput 双向绑定数组元素 | 编辑丢焦点 | 编辑值放独立 @State 数组,输入只按下标静默更新 |
画布尺寸只在 onReady 取一次 | 窗口最大化后 3D 线框贴边、被裁 | 加 onAreaChange 实时更新宽高并重绘;留白 26→52px + 0.88 安全系数 |
| 缩放只改正向绘制、不改反算 | 放大后拖拽生成的 XB 落到错误位置 | px/py 与 screenToU/screenToV 同步纳入 zoom/pan |
| 画在画布顶部的提示文字 | 压住 3D 顶点,看起来像「显示不全」 | 提示移到画布外状态行 |
| 工具栏两行 13 个按钮 | 拥挤、占掉画布高度 | 循环态按钮(绘制三态 / 视图四态)压缩为 12 个单行按钮 |
八、质量保障(QA)
8.1 三层测试金字塔
| 层 | 载体 | 数量 | 覆盖 |
|---|---|---|---|
| L1 领域层 | p0_core/verify_roundtrip.py(宿主 CPython) | 16 项 | 裸导入、垫片、零 numpy、150 个元数据、解析/生成/幂等、bingeom 往返、eval 移除 |
| L2 内核协议 | tools/test_kernel.py(宿主模拟设备文件协议) | 22 项 | 内嵌 Python compile()、文件协议读写、结构化重建、编辑生效、校验、几何提取、z 边界、add_box/update_xb/delete_namelist、snap 对齐 |
| L3 构建/产物 | CLI 构建断言 + 包内容核对 | 逐项 | BUILD SUCCESSFUL、HAP 内 runtime zip 条目数、P2/P3 接口存在性、libs 纯净度 |
| L4 真机 | DevEco 部署 + 人工验收路径 | 2 条主线 | ① 冷启动握手 → 加载示例 → 编辑回写 → 校验警告 → 联动 → 另存;② 2D 拖拽画盒体 → 切 3D 等轴测 → 旋转方位 → 缩放平移 → 单行工具栏 |
关键设计:L1/L2 不依赖 DevEco 与真机,任何一次内核改动都能在几十秒内回归;L3 用脚本核对 HAP 内容而不是靠肉眼;L4 只验收「只有真机能回答的问题」(Canvas 取宽高、触摸手势、picker、焦点、签名)。P3 这一轮的三个真机反馈——3D 贴边、不能平移缩放、按钮两行——恰恰都属于 L4 才能发现的问题,修复后再补进 L2/L3 的回归项。
8.2 安全红线
- 无
eval:外部.fds值一律ast.literal_eval+(bool, int, float, str)白名单,恶意 payload 实测被BFException拒绝。 - 零权限:
module.json5的requestPermissions为空数组,用户文件全部经DocumentViewPicker授权,模型文件落在应用沙箱filesDir。 - 无子进程执行:内核不发子进程、不
execv,FDS 求解器明确不在端侧。 - 凭据不入库:签名材料的绝对路径与加密密码只存在于本地
build-profile.json5,公开仓库前清空;.gitignore排除*.p12/*.p7b/*.cer/*.keystore/.ohos/、oh_modules/、entry/libs/、**/*.zip、**/*.so、_rtmp/、*.hap。
8.3 可复现命令
# 0) 上游同步 + 内核回归(不依赖 DevEco)
python tools/sync_from_upstream.py
python p0_core/verify_roundtrip.py # 16/16
# 1) Python 侧改动后:重生成内嵌 server → 内核协议回归 → 重打运行时包(记得递增 marker)
python tools/gen_py_server.py
python tools/test_kernel.py # 22/22
python tools/build_runtime.py
# 2) 依赖解析(DevEco 打开工程会自动执行)
& "C:\Program Files\Huawei\DevEco Studio\tools\ohpm\bin\ohpm.bat" install --all `
--registry https://repo.harmonyos.com/ohpm/ --strict_ssl true
# 3) CLI 构建(需要 jbr 前置 PATH 与 DEVECO_SDK_HOME)
$env:DEVECO_SDK_HOME = "C:\Program Files\Huawei\DevEco Studio\sdk"
$env:JAVA_HOME = "C:\Program Files\Huawei\DevEco Studio\jbr"
$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
九、Q&A
Q1:为什么不把 Blender 本体一起移植?
Blender 5.2 依赖 GHOST 窗口系统、OpenGL/EGL 与几百 MB 第三方库,鸿蒙沙箱没有能承载它的兼容层(T2 路线)。移植它的工作量与风险远超插件本身,而且 BlenderFDS 的价值在领域层(FDS 语义),不在 Blender 的 3D 视口。
Q2:为什么用 CPython 子进程而不是把 Python 静态链进 NAPI .so?
两条路都通,但子进程方案已在本工作区同类项目上真机验证:崩溃隔离(解析用户文件挂了不带 UI)、常驻状态自然、文件协议只依赖 open/write/fsync,避开了 fork 后 io 层写盘静默丢失的坑。静态整链的 stdout 捕获协议当时未经真机验证,不值得为一个未验证的方案冒险。
Q3:既然要重写 UI,为什么不连领域层一起用 ArkTS 重写?
领域层的价值恰恰在「跟随上游」:150 个参数的默认值、精度、导出顺序、校验语义都在上游持续演进。翻成 ArkTS 等于永久分叉,上游每次更新都要人肉同步。T1 内核让「跟随上游」变成跑一次 sync_from_upstream.py。
Q4:ArkTS 和 Python 之间为什么用文件而不是 socket/管道?
文件协议可以用文本编辑器直接检查,调试成本最低;os.replace 保证响应原子可见;每请求独立文件对解决排队覆盖问题。socketpair 只用于把 stdio fd 交给子进程捕获启动日志,不承载数据面。
Q5:为什么每个请求一个文件对,这么「浪费」?
因为共享单个请求文件会被排队请求覆盖——内核读到的是下一条请求,上一条被静默吞掉。文件系统的原子 rename 语义天然适合「写临时文件再改名发布」,一请求一对是最便宜的正确解。
Q6:runtime zip 为什么需要 marker 文件?
解压是幂等成本高的操作,marker 用于跳过重复解压;但 zip 内容变了而 marker 没变,设备端就会永远沿用旧解压目录——所以「改 Python 必递增 marker」是流程红线,这条坑过一次才变成规矩。
Q7:兼容垫片会不会掩盖真问题?
垫片只在导入层生效:它让 import bfds_core 成功、让类元数据可比较。任何真正调用 Blender 的方法体在运行时依然会暴露(拿到的是哑对象)。P0 验收只承诺「解析/生成/元数据」路径可用,这恰好就是产品一期需要的全部能力。
Q8:为什么不做 3D 建模和体素化?
上游的体素化依赖 Blender 的 remesh modifier 与 bmesh,等价重写需要自研网格内核(估计 4-8 人周起步)。一期的核心价值是「读懂/编辑/生成 .fds」,2D 计算域视图已经能满足参数校验需求;3D 属于 P3,且应该走自研纯 Python 几何内核,而不是模拟 Blender。
Q9:能把 FDS 求解器内置进去吗?
不能,也不应该。FDS 是 Fortran + MPI + OpenMP 的本地二进制,HAP 沙箱 filesDir 以 noexec 挂载、HNP 二进制不能被 execv。正确姿势是把「求解」留给外部/远程 FDS,端侧专注输入文件的编写与校验——这也符合工具链的分工。
Q10:GPL-3.0-or-later 对这个项目意味着什么?
内核复用了上游源码并随 HAP 分发(嵌入 CPython 进程内调用),按 GPL 需要开源相应部分:仓库保留上游 LICENSE 与版权声明(Emanuele Gissi / firetools),ArkTS 壳以同许可证发布,README 明确上游来源与本地补丁清单。这也是选择公开仓库的原因之一。
Q11:41 MB 的包体还能再小吗?
大头是 CPython 3.12 运行时(libpython + 55 个 C 扩展 so)与 stdlib zip。继续压缩的空间在于裁剪 stdlib(去掉未用模块)与按需扩展,但收益有限且增加维护成本;对 PC 形态的专业工具而言,40 MB 属于正常水位。
Q12:ArbTS/ArkUI 有哪些「静默失效」的坑?
最典型的是属性链:.width()/.height() 读普通 @State 变量在运行时不更新(要用三元字面量或预计算字段);属性链里调用私有方法会被静默忽略;Span 的 backgroundColor 无效要改 textBackgroundStyle。本文的 Canvas 尺寸全部在 onReady 里读画布实际值,就是为了绕开这一类问题。
Q13:现在这个「3D」是真 3D 视口吗?
不是,它是等轴测线框视图:用纯 Canvas 2D 数学把 XB 盒体投影到斜视角,按深度排序画三个可见面与 12 条边;视角只能按 45° 步进旋转,没有透视、没有自由相机、没有 3D 拾取编辑,也不显示任意三角网格。真正的 3D 视口(XComponent + OpenGL ES、相机控制、网格导入)属于档③,尚未投入。但 FDS 场景几乎全是轴对齐盒子,等轴测线框在这种数据上信息量反而更高——没有遮挡歧义,墙板贯穿哪一层、火源贴在哪一面,一眼就能看清,而且零 GPU 依赖、包体不变、真机风险最低。
十、成果与路线图
当前成果(全部可在仓库复现):
| 项 | 值 |
|---|---|
| 内核剥离 | p0_core/bfds_core/,77 个 .py / 327 KB,可重放同步脚本 |
| 领域层验收 | 16/16(裸 CPython,零 Blender、零第三方包) |
| 内核协议验收 | 22/22(宿主模拟设备文件协议,含 P3 绘制接口 7 项) |
| HAP | entry-default-signed.hap 41.08 MB(runtime zip 8.78 MB / 808 条目,61 个原生 .so) |
| 画布能力 | 2D 三投影面 + 拖拽生成 OBST/VENT(MESH 单元对齐)、3D 等轴测线框(8 方位)、缩放 0.3×–5× 与拖拽平移 |
| 真机 | 冷启动握手、示例加载、属性编辑回写、校验警告、2D/3D 联动、绘制生成、缩放平移、另存为全部走通(八张截图) |
| 工程红线 | 零权限、无 eval、无子进程执行、凭据不入库 |
P3 已完成(P3-a):画布拖拽生成 OBST/VENT 并自动对齐 MESH 单元边界、选中 namelist 删除、XY/XZ/YZ 三投影面、3D 等轴测线框与 8 方位旋转、缩放平移、单行循环态工具栏。
后续计划:&SURF 材料库与参数语义提示;自由相机的真 3D 视口(XComponent + OpenGL ES,含 3D 拾取编辑);任意网格导入与 GEOM 转换;跟随上游 7.2.x 迭代(跑 sync_from_upstream.py + 全量回归即可);探索把规范导出的 .fds 交给远程 FDS 求解并回读结果的协同形态。
写在最后:这次移植最核心的经验只有一句——先花两天证明「领域层能脱离宿主运行」,再动手写一行业务代码。P0 那 16 项测试拦住的不只是技术风险,更是「要不要把 1.5 万行 Python 翻成 ArkTS」这种一旦走错就废掉整个项目的方向性错误。剩下的,就是把已验证的链路一层层搬上真机,每一步都留验收。
*
更多推荐




所有评论(0)