欢迎加入开源鸿蒙 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 等轴测查看 → 缩放平移 → 导出」全闭环(八张截图见第六章)。

图 6 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/626,870约 150 个声明式 namelist / 参数定义,MESH 划分、MULT 乘子、GIS 换算35 个文件顶层 from bpy.types/props import ...
source/types/51,550FDSList / FDSNamelist / FDSParam 文本 AST;BFParam 元数据基类bf_param.py、bf_namelist.py 引用 bpy
source/bl/ + source/ui/263,990Blender Panel / Operator / Header / UIList全部是 UI,无法复用
source/utils/81,180GIS、IO、几何、装箱、外部命令geometry.py 用 bmesh,run.py 用 os.system 起 mpiexec
source/config.py1800精度常量、导出格式、外部命令模板第 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)

由此得到两个关键判断:

  1. 内核值得保留:文本 AST + 约 150 个声明式参数 + 纯数值算法(MESH 划分、MPI 分桶、GIS)质量很高且基本自洽;Blender UI、bmesh 体素化(calc_voxels.py 514 行)、BVHTree/KDTree 几何转换(ob_to_geom.py 391 行)绑死 Blender,必须放弃。
  2. 「纯 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.py800 行精度/格式常量表保留
删 f90nml 残留 import;eval → ast.literal_eval + 类型白名单types/fds_list.py安全红线(见下)
切断链式导入;bpy.path 的 // 语义换成沙箱绝对路径utils/__init__.py、utils/io.py鸿蒙 HAP 没有 .blend 基准路径
numpy → array + structlang/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 读写与安全解析:

#检查项结果
1bfds_core 可在裸 CPython 导入PASS
2bpy 已替换为兼容垫片PASS
3内核未导入 numpy(零 C 扩展)PASS
4领域层元数据加载(BFParam=135,BFNamelist=30)PASS
5声明式 namelist 可检索(ON_OBST / OP_XB)PASS
6-812 个 namelist 解析 / 生成 / 解析→生成→解析幂等PASS
9-12字符串 / 布尔 / 多值浮点坐标 / 整数 / 多行 namelist 保持PASS
13MPI 分桶 binpack 可用PASS
14bingeom 读写往返(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 冷启动:常驻 Python 内核就绪,三栏空态

图 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 入树,画布出现网格与盒体

图 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 最大化:三栏比例稳定,文本与结构树双向同步

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

图 4 切换选中:属性表单与画布高亮联动

图 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 放大平移:156% 下查看贯穿整层的 OBST 板

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

图 6 3D 视图下的选中联动

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

图 7 小窗口与方位 180°:三栏布局不依赖最大化

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

图 8 缩小总览:64% 下完整看清计算域

图 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 目录混入非 socollectAllLibs 全打包,包体暴涨复制时过滤 .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 安全红线

  1. 无 eval:外部 .fds 值一律 ast.literal_eval + (bool, int, float, str) 白名单,恶意 payload 实测被 BFException 拒绝。
  2. 零权限:module.json5 的 requestPermissions 为空数组,用户文件全部经 DocumentViewPicker 授权,模型文件落在应用沙箱 filesDir。
  3. 无子进程执行:内核不发子进程、不 execv,FDS 求解器明确不在端侧。
  4. 凭据不入库:签名材料的绝对路径与加密密码只存在于本地 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 项)
HAPentry-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」这种一旦走错就废掉整个项目的方向性错误。剩下的,就是把已验证的链路一层层搬上真机,每一步都留验收。
*

Logo

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

更多推荐