如果你在刀具磨削行业待过,大概率见过 NUMROTO 这个名字。它做 3D 仿真时会丢给你一个叫NUM_STLViewer.exe的小工具,让你看看 STL 或 PLY 模型长什么样。这个程序不大,5.19 MB,2019 年 10 月 7 日构建,典型的 Delphi / C++Builder VCL 应用,PE32 i386,跑在 Windows GUI 上。它内部同时挂着 OpenGL 和 Direct3D 两套渲染后端,3D 层用 T3dStl、T3dLdiSolidPlus、TSolidOperationModel、TTriangle 这些库,支持*.stl、*.stpack、*.ply,甚至能把 STL 塞进 exe 里当自包含导出件。菜单树是 File / View / Mesh / Light / Measurement / Extras / Help,十五种语言,旋转动画和宏录制一应俱全。
但这些东西都在一个二进制文件里。你想改一行代码,加一个测量方式,或者只是想知道它到底怎么画三角形的,都无从下手。于是有了这个 Python 复现版。
逆向笔记:从字符串里挖出一棵菜单树
逆向一个 Delphi 程序,最直接的办法是翻字符串。miFile、miView、miMesh、miLight、miMeasurement、miExtras、miSpin*、miMovieMacro*、miLang*、miSTL*——这些菜单项名字就这么躺在二进制里。旋转动画、宏录制、语言切换、STL 格式相关命令,一个都没少。原程序有 15 种语言:EN、CH、CS、FR、GR、HU、IT、NL、PL、PO、RO、RU、SP、SW、TU。构建时间 2019 年 10 月 7 日,体积 5.19 MB,PE32 i386,子系统 Windows GUI。渲染后端是 OpenGL / Direct3D 双套,外面包了一层 NIRenderer3d 缓存。3D 库的名字也很具体:T3dStl、T3dLdiSolidPlus、TSolidOperationModel、TTriangle。文件格式除了*.stl、*.stpack、*.ply,还有*.exe——自包含导出件,内嵌 STL。
这些信息不是摆设。复现版的功能映射就是照着这棵菜单树来的,File、View、Mesh、Light、Measurement、Extras、Help,一个菜单对一个模块。逆向结论也不是一次性的,写代码时遇到“原程序到底怎么处理这个操作”的疑问,回头翻翻字符串和结构,往往能找到线索。
为什么不用 OpenGL,也不碰 Pillow
复现版最核心的技术选择,是放弃 OpenGL、Direct3D、Pillow 和 Qt 渲染,转而用纯 Python 加 numpy 实现软件光栅化。听起来像用自行车上高速,但这么做有它的道理。OpenGL 和 Direct3D 是黑盒,你把三角形丢进去,它画出来,中间发生了什么你看不见。Pillow 能读写 PNG,但 PNG 的 zlib 和滤波细节被封装在 C 层。Qt 的渲染更不用说了,整个图形栈都在背后。
这个项目要的是透明。渲染管线每一行都能读、能改、能测。所以它自带 PNG 编解码器,自己实现 zlib 和逐行滤波,支持 8 位非隔行和全部滤波器。字体也是自带的,font5x7.py里塞了一套 5×7 位图字形,用来画标注和 HUD 文字。图形界面用 PyQt5,但只是可选;没有它,命令行模式照样跑。整个项目只依赖 numpy,版本要求numpy>=1.22。
目录结构也反映了这种模块化的意图。num_stl_viewer主包有 13 个模块加一个入口。mathutil管向量、矩阵、几何工具;stl_io负责 STL、PLY 和内嵌模型读写;mesh做修复、统计、着色、剖切;camera实现透视、正交和标准视图;lighting管光照预设和着色模型;renderer是软件光栅化核心;measure提供测量工具;scene组织多对象场景和图元;i18n覆盖 15 种语言;settings持久化 INI 设置;macro处理旋转动画和宏录制;png和font5x7解决图片和文字输出;cli和viewer构成命令行和图形界面两条入口。配套的tests里,核心测试 103 项,图形界面测试 63 项,还有拖拽对齐可视验证和示例生成脚本。samples下既有二进制和 ASCII 钻头毛坯,也有实体、线框、分量着色、GUI 截图和拖拽降分辨率对比图。
渲染器里的那些脏活
软件光栅化在 Python 里最怕的就是慢。三角形填充用规范化重心坐标,edge和深度都以c + d·j的线性增量方式计算,深度只在真正命中的像素上求值,不再构造整幅中间数组。一批三角形共用一块矩形缓冲,如果直接取批内最大宽高,一个 31×170 的三角形会把同批全部撑成 31×170,实测空洞率可达 8.0 倍;改成按 bbox 边长的 2 次幂分桶后降到 2.0 倍,光栅化耗时随之下降一半以上。不透明路径的遮挡由 Z 缓冲保证,分桶不影响正确性;透明混合路径仍保持全局“远到近”分批。
命中像素解算用flatnonzero加divmod由线性下标反解批次、行、列,再求像素下标,只在少量元素里随机取值,比按整块取快约 3 倍。颜色、深度、透明三块全尺寸数组按渲染尺寸缓存复用,超采样时不再每帧分配释放数十 MB;降采样直接对 uint8 视图分两步求和,省掉全尺寸 float32 副本。这些优化单独看都不复杂,但叠在一起,才让 Python 软件渲染勉强跑进了可交互的范围。
性能数据也说明了一些事情。在 932×707 画布、Windows、CPython 3.13 下,1.4k 面模型拖拽 16 到 19 毫秒,约 55fps,静止精修 186 毫秒;21k 面拖拽 26 到 31 毫秒,精修 140 到 235 毫秒;101k 面拖拽 110 到 130 毫秒,精修 220 到 240 毫秒。大幅面模型的拖拽代价主要落在三角形数量而非分辨率上,自适应降分辨率收益有限,建议调低拖拽画质依赖,或先用分割连通分量拆开看。
STL 的三角汤问题
STL 文件有个老毛病:同一个几何点按三角形重复存储,形成三角汤。不焊接时每个三角形都是独立连通分量,体积算不出,边界边满天飞。示例件从 4212 顶点、4212 条边界边,焊接后变成 1027 顶点、640 条。载入时默认自动焊接重合顶点,容差取包围盒对角线的1e-6,可以用--no-weld或视图选项关闭。焊接之后,平滑着色或设置里的插值法向才能让曲面呈现平滑过渡。
坐标约定上,Z 是刀具和旋转轴,朝上,和原程序 NUMROTO 一致。测量支持point3d、line3d、line2d、circle2p、circle3p、angle2d、rzpoint七种类型,其中rzpoint输出柱坐标 (R, Z, θ),服务磨削场景。stl_io.extract_embedded_mesh还能从.exe或.stpack里扫描并解出内嵌 STL 或 zlib 流,复原原程序自包含导出件的行为。
命令行与图形界面
命令行接口分成若干子命令。info统计三角形与顶点数量、包围盒、表面积、体积、连通分量、退化面、边界边、非流形边以及水密性,支持 JSON 输出。render能把模型渲染成 PNG,参数多到能覆盖尺寸、标准视图、偏航与俯仰、正交、线框、整件同色、单色平面、按分量着色、按曲率着色、法向彩虹、平滑着色、边线、网格、十字线、透明背景、透明对象、背景色、渐变、超采样、抗锯齿、双倍分辨率、焊接开关和 JSON 输出。convert用于转换与修复,可在 ASCII 与二进制之间切换,也可焊接顶点、修复法线、批量输出。components按连通分量拆分模型,并可按最小三角形数过滤。measure覆盖两点距离、三点圆、两点圆、三点夹角、单点、RZ 点、指定 Z 高度最大回转半径等计算,支持英寸单位和 JSON 输出。无图形环境或批处理场景,python -m num_stl_viewer --cli --help能看全部用法。
图形界面更贴近原程序的菜单树。文件菜单负责打开、追加、关闭、导出 STL、保存图片、剪贴板与透明背景版、最近文件与退出;视图菜单提供标准视图、透视与正交切换、重置、适配窗口,以及显示坐标轴、网格、十字线、边线、背景渐变、鼠标灵敏度、拖拽画质、抗锯齿和载入时焊接顶点等选项;网格菜单处理焊接、移除退化面、修复法线、翻转法线、着色模式、统计、分割连通分量与删除选中;光照菜单提供预设与环境光、漫反射、高光调节;测量菜单可创建点、3D 直线、2D 直线、两点圆、三点圆、角度与 RZ 点,标注显示在画布上并可清除;附加菜单包含旋转动画、宏录制与播放、语言切换和单位切换;帮助菜单提供关于对话框。
着色模式有个小插曲。实体视图默认是整件同色加光照明暗,也就是零件本来的样子。单色平面关闭逐顶点插值,直接看到三角面片;按分量让每个连通分量一种颜色;按曲率显示曲率热力图;按法向显示法向彩虹图。早期版本把默认的auto接到了法向彩虹图上,实体视图看起来像花花的贴图,这正是“模型显示失真”反馈的来源。后来拆出了独立的orientation模式,默认回到整件同色,要按分量、按曲率、按法向,得自己切。
交互层面也做了细致设计。左键拖拽轨道旋转,Ctrl 加左键拖拽或开启 Rotate around Z 后绕 Z 轴旋转,中键与右键拖拽平移,Shift 加左键拖拽平移,Alt 加左键拖拽缩放,滚轮与触控板缩放,左键单击在测量模式下取点并自动吸附最近顶点或边,双击适配窗口,方向键旋转,Shift 加方向键平移,Ctrl 加方向键绕 Z 或俯仰,加号与减号缩放,F 与 Home 适配窗口,R 复位旋转中心,数字 1 到 8 切换标准视图,空格播放或暂停旋转动画,Esc 取消当前测量。光标会随操作模式变化,拖拽时状态栏同步显示当前操作与帧率。
性能与画质的拉扯
软件光栅化在 Python 里,性能与画质的平衡是个绕不开的问题。复现版采用渐进式渲染:任何一次视图变化都先立刻画出一张低分辨率快帧,空闲 160 毫秒后再把画面精修成全分辨率加抗锯齿。整幅ss=2渲染典型模型要一二百毫秒,若同步画完会阻塞事件循环,拖拽期间鼠标事件只能排队,松手后模型会“跳”一大截。自适应分辨率则在拖拽旋转、平移、缩放以及旋转动画与宏播放期间降低画质,默认降到视图选项设定的拖拽画质上限以下,通常为 50%,并按实测帧耗时向 40fps 收敛,慢了降到 75%,快了提到 115%,范围在 15% 到 100% 之间,上下阈值带有 1.35 倍与 0.6 倍迟滞,避免画面忽清忽糊。
鼠标灵敏度可调,旋转默认 0.20 度每像素,平移默认 1.0,即模型严格跟随光标。之前硬编码 0.35,拖 300 像素就转 105 度,随手一拖模型就跑掉了。降分辨率位图由高度反推宽度,保证放大铺满视口时不会把模型拉扁或拉长;渲染视口在每次渲染时与目标位图尺寸同步,因此降分辨率与全分辨率画面完全对齐,不会偏移或裁切。
测试与边界
核心测试 103 项,图形界面测试 63 项,全部通过。覆盖数学、IO、网格、相机、渲染、视口、测量、宏、命令行,以及界面构造、鼠标交互、渲染与导出。tests/make_sample.py能重新生成 samples 下的示例与预览图,tests/check_drag_visual.py生成拖拽与静止对齐对比图与界面截图。这个项目是教学与研究性质的重实现,不包含原程序任何二进制代码。原程序及相关商标归 NUM 所有。
它的意义在于,把逆向结论变成一套可运行、可验证、可扩展的 Python 实现,让一个封闭的 Windows 可执行文件,变成可以被阅读、测试和继续改造的工程样本。你可以用python run_viewer.py model.stl打开模型,也可以用python -m num_stl_viewer --cli render model.stl -o out.png --double --antialias出图,还可以钻进renderer.py改一改分桶策略,看看帧率会怎么变。那个 5.19 MB 的 exe 不再是黑盒,它变成了一堆可以阅读的 Python 代码,和一堆可以复现的性能数字。