feat(plugin): add model converter that drives Blender bpy to reshape primitives

This commit is contained in:
西风逍遥游 committed 2026-08-16 03:56:07 +08:00
1 parent f8ea8a9d96
commit 0bd283e054
24 files changed
+1795

No files matched your search

+215
View File
@@ -0,0 +1,215 @@
# 模型转换器插件设计 — `modelconverter`
日期:2026-08-16
状态:设计与实现中(`modelconverter/`)
存放:`docs/dev/`
## 一句话
一个 **EVEngine 原生插件(`eve.plugins.load` 加载的共享库)**,通过调用 **Blender 的
Python 包(`bpy`)** 来完成模型转换:玩家在 Blender 里只做一个**简单原始体**(盒/球/柱),
插件运行一个**转换器**(一段 Python 脚本或一个 Geometry Node 节点组),自动把它变成
**石头、墙体、栅栏**等复杂网格,再把结果导回 EVEngine 渲染。
> 用户原话:“玩家只需要制作原始的模型(非常简单的盒子等),blender 就能应用节点,
> 自动把其转换成对应的石头、墙体等等。”
## 核心思路与分工
| 侧 | 职责 |
|----|------|
| EVEngine 原生插件(C++,本仓库 `modelconverter/plugin`) | 给 Squirrel 暴露 `ModelConverter` 模块;序列化任务;spawn 一个 Python 子进程;读回结果 |
| Blender Python 包(`bpy`) | 真正的网格变换引擎:导入原始体 → 执行转换器 → 烘焙 modifier → 导出 |
| 转换器目录(`modelconverter/converters`) | 玩家/美术可编辑的“配方”:一个转换器 = 一段 `.py` 脚本 **或** 一个 `.blend` 几何节点组 |
| Python 运行时(`modelconverter/python/eve_blender_converter`) | 把 job 分发给正确转换器,提供通用 `bpy` 辅助函数与烘焙/导出管线 |
要点:**插件不内嵌 Python/bpy,而是以子进程方式调用**。这样不需要把整个 Blender 链接进
EVEngine 进程,跨平台、崩溃隔离、bpy 版本升级互不影响,也符合“插件调用 blender 的 python
包来完成任务”。
## 架构图
```text
Squirrel 脚本 (main.nut)
│ mc <- eve.ModelConverter()
│ mc.configure(converterDir, pythonExe)
│ mc.convert("stone", inBox.obj, outStone.glb, p)
▼
EVEngine 原生插件 ModelConverter (C++, shared lib)
├─ 序列化 job.txt(converter / input / output / format / params_json)
├─ spawn: python -m eve_blender_converter convert job.txt
│ (环境变量 PYTHONPATH 指向 python/eve_blender_converter 所在目录)
└─ 读回 result.txt(ok / error / outputModel / stats)
▼
Blender Python (bpy) eve_blender_converter
├─ 按 converter=xxx 找到转换器定义(manifest.json)
├─ import 原始体(obj / stl / glb / fbx)
├─ 执行转换器:
│ ├─ kind=script → run convert(ctx),脚本可调用 mesh_ops 辅助
│ └─ kind=nodegroup → append .blend 里的几何节点组,挂到 modifier 并应用
├─ 统一 bake:应用 modifier / solidify / 三角化 / 计算法线与 UV / 可选去重
└─ export 结果(gltf / obj / fbx)→ outputModel,写 result.txt + result.json
▼
EVEngine 用 model3d 载入结果并渲染(ModelData → GPU Mesh)
```
## 通讯协议(进程边界)
任务与结果都用**简单文本行**传输,C++ 侧不需要依赖 JSON 库;Python 侧用标准库 `json`
解析参数。
`job.txt`(C++ → Python):
```text
converter=stone
input=C:/models/box.obj
output=C:/out/stone.glb
format=glb
params={"seed":"1847","size":"1.0","detail":"0.6"}
converter_dir=C:/eve/modelconverter/converters
```
`result.txt`(Python → C++,行式,逐行 `key=value`):
```text
ok=1
output=C:/out/stone.glb
vertices=428
triangles=856
```
失败时:`ok=0`、`error=<UTF-8 错误信息>`。同时写一份 `result.json` 供日志/调试。
## 转换器目录与两种形态
转换器是**用户可编辑的资产**,放在 `converters/<id>/`。每个转换器一个 `manifest.json`:
```json
{
"id": "stone",
"name": "Low-poly Stone",
"kind": "script", // script | nodegroup
"entry": "convert.py", // kind=script 时的入口(提供 convert(ctx))
"blend": "gn_displace.blend",// kind=nodegroup 时的 .blend 文件
"group": "EVE_Displace", // kind=nodegroup 时的节点组名
"description": "Box → low-poly stone via seeded displacement.",
"params": [
{"key": "seed", "type": "int", "default": "1"},
{"key": "size", "type": "float", "default": "1.0"},
{"key": "detail", "type": "float", "default": "0.6"}
]
}
```
### `kind=script`
`convert.py` 导出 `def convert(ctx)`。`ctx` 提供:
- `ctx.obj` — 当前 Blender mesh 对象(可直接操作 `ctx.obj.data`)
- `ctx.mesh_ops` — 通用辅助(见下)
- `ctx.params` — 解析后的参数 dict
- `ctx.run_py(source)` / `ctx.apply_nodegroup(blend, group)` — 可混合调用节点
- 常用 `bpy` 已导入为全局
### `kind=nodegroup`
运行时从转换器目录加载 `.blend`,`append` 命名节点组,为 `ctx.obj` 新建 Geometry Nodes
modifier 并挂上该组,然后应用。**.blend 由脚本生成**(见
`modelconverter/scripts/build_nodegroup_blend.py`,在 Blender 里
`blender --background --python ...` 运行一次即可),这样仓库里不维护二进制。
### 通用网格辅助 `mesh_ops`
为脚本转换器提供封装,保证确定性(可复现)与结果整洁:
- `remesh_quads(obj, subdivisions)` — 细分
- `displace_vertex_weights(obj, seed, strength, vertex_group_name)` — 按顶点组做种子位移
- `weighted_random_group(obj, seed, group_name, min/max)` — 给顶点打随机权重(成片区域)
- `apply_modifiers(obj)` / `triangulate(obj)` / `recalc_normals(obj)` / `unwrap_lite(obj)`
- `deselect_all()` / `select_obj(obj)` 等杂项
## EVEngine 插件 API(Squirrel)
```squirrel
mc <- eve.ModelConverter()
mc.configure(converterDir, pythonExe, tempDir?) // tempDir 默认系统临时目录
mc.check() // 校验 python/bpy 可用,返回 bool
mc.listConverters() -> [ "stone", "stone_wall", ... ]
mc.hasConverter(id) -> bool
mc.convert(id, inputModel, outputModel, params) // 成功返回 true,失败取 lastError()
mc.lastError() -> string
```
`params` 是一个 `ModelConverterParams` 对象(`mc.newParams()`),提供
`setInt / setFloat / setString / has / getInt / getFloat / getString`,风格与
`ProcgenParams` 一致。
### 示例
```squirrel
local p = mc.newParams();
p.setInt("seed", 1847);
p.setFloat("size", 1.2);
p.setFloat("detail", 0.6);
if (mc.convert("stone", "models/box.obj", "models/stone.glb", p)) {
local md = model3d.newModelDataFromFile("models/stone.glb");
// …上传到 GPU / 绑定 Renderable3D
}
```
## 目录结构
```text
modelconverter/
README.md
plugin/ # C++ 原生插件(用 EVEngine SDK 编译)
ModelConverter.h / .cpp
CMakeLists.txt # add_eve_plugin(modelconverter …)
python/eve_blender_converter/ # Python 运行时包(import bpy)
__init__.py
__main__.py # 命令行入口:convert job.txt | check | list
runtime.py # 主流程:import→执行转换器→bake→export
mesh_ops.py # 通用 bpy 网格辅助
job.py # job.txt / result.txt 读写
converters/__init__.py # 转换器分发(script / nodegroup)
python/requirements.txt # bpy(pip install -r)
converters/ # 用户可编辑转换器目录
stone/ (manifest.json + convert.py)
stone_wall/ (manifest.json + convert.py)
gn_displace/ (manifest.json + gn_displace.blend[由脚本生成] + convert.py)
scripts/
build_nodegroup_blend.py # 生成 gn_displace.blend
examples/model-converter/
main.nut # EVEngine 端到端示例
README.md
docs/dev/模型转换器插件设计.md
```
## 环境与前提
1. 安装 Blender 的 Python 包:`pip install bpy`(Blender 官方发布的可导入模块,含几何节点引擎)。
注意:新版 `bpy`(≥5.x)只针对单一 CPython 版本发布 wheel,**bpy 5.2 需要 CPython 3.13**,
例如 `py -3.13 -m pip install bpy`。配置插件时把 Python 指向装有 bpy 的解释器。
2. 首次使用(可选)生成节点组演示:`py -3.13 modelconverter/scripts/build_nodegroup_blend.py`
(或在 Blender 里 `blender --background --python ...` 运行)。
3. 在 Squirrel 里加载插件:`plugins.load("modelconverter.dll")` 后 `mc <- eve.ModelConverter()`。
> 若环境里没有 `bpy`,`mc.check()` 返回 false,`convert` 返回 false 并给出清晰错误,不影响
> 其它功能。`python -m eve_blender_converter check` 可单独自检。
## 设计要点 / 决策记录
| 决策 | 理由 |
|------|------|
| 用子进程调用 bpy,而非内嵌 | 不把 Blender 链接进 EVEngine 进程;崩溃隔离、跨版本稳定 |
| 转换器 = 脚本或节点组,两者都支持 | 脚本灵活跨版本;节点组让美术可视化编辑,满足“应用节点” |
| 行式文本协议(C++ 无 JSON 依赖) | 插件只依赖 engine 公共头,不引入 Poco/第三方链接 |
| 确定性优先(种子 + 顶点组权重) | 与 procgen 的“同 seed 可复现”哲学一致 |
| 转换器目录可插拔 | 玩家/美术不改代码即可新增/调整配方 |
## 参考
- Blender Python 包:https://pypi.org/project/bpy/
- Blender Geometry Nodes API:`bpy.types.GeometryNodeGroup` / `NodesModifier`
- 引擎既有插件约定:`src/modules/plugins/Plugins.cpp`、`examples/native-plugin/`
- 确定性生成先例:`src/modules/procgen/`(同 seed 可复现)
+2
View File
@@ -0,0 +1,2 @@
# Converted model outputs.
out/
+27
View File
@@ -0,0 +1,27 @@
# Model Converter demo
End-to-end EVEngine example for the `modelconverter` plugin: it turns a primitive
`box.obj` into a **stone** or **stone wall** by driving Blender's Python package
(`bpy`), then reloads the produced `.glb` back into EVEngine with `model3d`.
## Prerequisites
1. `pip install bpy`
2. Build the plugin against an EVEngine SDK (see `modelconverter/README.md`),
place the resulting `modelconverter.dll` at `build/modelconverter.dll`
relative to this example, or set `modelconverter_plugin` in `config.nut`.
3. If `python` on your PATH has `bpy`, leave `modelconverter_python = "python"`;
otherwise point it at the interpreter that has `bpy` installed.
## Run
Launch it like the other examples, selecting `examples/model-converter` as the game.
If `bpy` is unavailable the example still runs; the UI will show the clear
`pip install bpy` error instead of a converted model.
## Controls
- **Next converter** — switch `stone` / `stone_wall` / `gn_displace`.
- **Re-convert (new seed)** — advance the deterministic seed and rebuild.
- **Re-convert (same seed)** — rebuild with the current seed.
+16
View File
@@ -0,0 +1,16 @@
# Primitive box (unit cube centered at origin). Converters reshape this.
o Box
v -0.5 -0.5 -0.5
v 0.5 -0.5 -0.5
v 0.5 0.5 -0.5
v -0.5 0.5 -0.5
v -0.5 -0.5 0.5
v 0.5 -0.5 0.5
v 0.5 0.5 0.5
v -0.5 0.5 0.5
f 1 2 3 4
f 8 7 6 5
f 4 3 7 8
f 5 6 2 1
f 1 4 8 5
f 2 6 7 3
+12
View File
@@ -0,0 +1,12 @@
// ModelConverter demo config.
// Adjust these to your machine.
window_title <- "EVEngine Model Converter";
window_width <- 1024;
window_height <- 640;
// Python that has `bpy` installed (Blender's Python package). Try "python"
// if `pip install bpy` was done into the system interpreter.
modelconverter_python <- "python";
// Absolute or game-relative path to the compiled plugin dll/so/dylib.
modelconverter_plugin <- "build/modelconverter.dll";
+130
View File
@@ -0,0 +1,130 @@
// EVEngine <-> Blender model converter demo.
//
// Loads the native `modelconverter` plugin, asks it to convert a primitive box
// into a stone / stone wall using Blender's Python package (bpy), then reloads
// the produced .glb back into EVEngine with model3d to prove the pipeline.
if (!("mc" in getroottable())) mc <- null;
if (!("md" in getroottable())) md <- null;
if (!("seed" in getroottable())) seed <- 1847;
if (!("converter" in getroottable())) converter <- "stone";
if (!("converterNames" in getroottable())) converterNames <- [];
if (!("converterIndex" in getroottable())) converterIndex <- 0;
if (!("status" in getroottable())) status <- "loading";
if (!("uiReady" in getroottable())) uiReady <- false;
if (!("autoRun" in getroottable())) autoRun <- true;
if (!("ran" in getroottable())) ran <- false;
function repoRoot() { return "../.."; }
function converterDir() { return repoRoot() + "/modelconverter/converters"; }
function pythonRuntimeDir() { return repoRoot() + "/modelconverter/python"; }
function boxModel() { return "box.obj"; }
function outModel(ext) { return "out/result." + ext; }
function runConversion() {
status = "running…";
if (mc == null) { status = "ModelConverter plugin not loaded"; return; }
if (!mc.hasConverter(converter)) {
status = "converter '" + converter + "' not found: " + mc.lastError();
return;
}
local p = mc.newParams();
p.setInt("seed", seed);
p.setFloat("size", 1.25);
p.setFloat("detail", 0.6);
p.setFloat("length", 3.2);
p.setFloat("height", 2.0);
p.setFloat("depth", 0.6);
if (!mc.convert(converter, boxModel(), outModel("glb"), "glb", p)) {
status = "convert failed: " + mc.lastError();
return;
}
// Reload the produced model into EVEngine to confirm it decodes.
try {
md = model3d.newModelDataFromFile(outModel("glb"));
status = converter + " OK — " + md.getVertexCount() + " verts / "
+ md.getFaceCount() + " faces (from " + md.getMeshCount() + " mesh)";
} catch (e) {
status = "converted but reload failed: " + e;
}
}
function initConverter() {
local plugins = eve.Plugins();
if (!plugins.isLoaded(modelconverter_plugin)) {
try {
plugins.load(modelconverter_plugin);
} catch (e) {
status = "plugin load failed: " + e;
return;
}
}
mc = eve.ModelConverter();
mc.configure(converterDir(), modelconverter_python, pythonRuntimeDir());
converterNames = [];
local n = mc.getConverterCount();
for (local i = 0; i < n; i++) converterNames.append(mc.getConverterId(i));
if (converterNames.len() == 0) {
status = "no converters found under " + converterDir();
return;
}
if (converterIndex >= converterNames.len()) converterIndex = 0;
converter = converterNames[converterIndex];
status = "ready — converters: " + converterNames.join(", ");
if (autoRun) runConversion();
}
function buildPanel() {
ui.setTheme("dark");
ui.setNavKeyboard(true);
ui.beginBuild();
ui.beginWindow("MODEL CONVERTER / EVEngine ↔ Blender", "root");
ui.text("Primitive box in → rich mesh out (via bpy)", "eyebrow");
ui.text("Converter", "shapeLabel");
ui.text("seed: " + seed, "sample");
ui.button("Next converter", "nextConverter");
ui.button("Re-convert (new seed)", "randomize");
ui.button("Re-convert (same seed)", "rebuild");
ui.text("", "status");
ui.text("Requires bpy: pip install bpy", "hint");
ui.end();
ui.mountBuildAs("mc");
ui.select("mc");
ui.setHostOverlay(true);
ui.setHostPos(760.0, 30.0, 300.0, 420.0);
uiReady = true;
}
eve_init = function() {
gfx.setBackgroundColor(0.05, 0.05, 0.06, 1.0);
initConverter();
if (!uiReady) buildPanel();
};
eve_update = function(dt) {
local clicked = ui.consumeClick();
while (clicked != "") {
if (clicked == "mc/nextConverter") {
converterIndex = (converterIndex + 1) % converterNames.len();
converter = converterNames[converterIndex];
runConversion();
} else if (clicked == "mc/randomize") {
seed = (seed * 1664525 + 1013904223) & 0x7fffffff;
runConversion();
} else if (clicked == "mc/rebuild") {
runConversion();
}
clicked = ui.consumeClick();
}
if (uiReady) {
ui.select("mc");
ui.setText("status", status);
ui.setText("sample", "seed: " + seed + " → " + converter);
}
};
eve_render = function() {
gfx.clear();
gfx.render3D();
ui.beginFrameAndRender();
};
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
# Generated by scripts/build_nodegroup_blend.py (build in Blender).
*.blend
*.blend1
+73
View File
@@ -0,0 +1,73 @@
# Model Converter (EVEngine ↔ Blender)
A plugin feature that drives **Blender's Python package (`bpy`)** to turn a
player's **primitive model** (a simple box, sphere, etc.) into a **richer mesh**
(stone, stone wall, …) automatically. Converters are plain Python scripts or
Blender **Geometry Node** groups, so artists can tweak them without recompiling.
```
primitive box → EVEngine native plugin → python -m eve_blender_converter (bpy)
→ converter (script | geometry nodes) → bake → export .glb
→ model3d loads & renders in EVEngine
```
## Layout
```text
modelconverter/
plugin/ C++ native plugin (ModelConverter module, EVPlugins)
python/ Python runtime package (eve_blender_converter, imports bpy)
converters/ Editable converter catalog (manifest.json + script/.blend)
stone/ box → low-poly stone (script)
stone_wall/ box → stone wall (script)
gn_displace/ geometry-node demo (needs .blend, see scripts)
scripts/ build_nodegroup_blend.py (generates gn_displace.blend)
examples/model-converter/ end-to-end EVEngine example (main.nut)
docs/dev/模型转换器插件设计.md design doc
```
## Prerequisites
1. `pip install bpy` into the Python that will run the runtime
(see `python/requirements.txt`). `bpy` is Blender's official importable Python
package, including the Geometry Nodes engine. Recent `bpy` (>= 5.x) requires
CPython **3.13** (e.g. `py -3.13 -m pip install bpy`).
2. (Optional) Build the node-group demo blend once:
`py -3.13 modelconverter/scripts/build_nodegroup_blend.py`
(or `blender --background --python ...`)
3. Build the plugin against an EVEngine SDK:
`cmake -B build -DEVEngine_DIR=<sdk>/cmake && cmake --build build`
## Usage (Squirrel)
```squirrel
plugins.load("build/modelconverter.dll");
mc <- eve.ModelConverter();
mc.configure("modelconverter/converters", "python", "modelconverter/python");
local p = mc.newParams();
p.setInt("seed", 1847);
p.setFloat("size", 1.25);
p.setFloat("detail", 0.6);
if (mc.convert("stone", "box.obj", "out/stone.glb", "glb", p)) {
local md = model3d.newModelDataFromFile("out/stone.glb");
print(md.getVertexCount() + " verts\n");
}
```
Point `pythonExe` (2nd arg of `configure`) at a Python with `bpy` installed, e.g.
`py -3.13` if `bpy` 5.x is installed there. Run the interactive demo as
`examples/model-converter`. `mc.check()` reports whether `bpy` is available so
you can degrade gracefully when Blender isn't installed.
## Converters
Each folder under `converters/` is a converter:
- **script** — `convert.py` with `def convert(ctx)`; `ctx` exposes `obj`,
`params`, and `ctx.mesh_ops` helpers (subdivide, weighted displacement, solidify,
triangulate, unwrap, …). Deterministic per `seed`.
- **nodegroup** — a `.blend` holding a Geometry Node group; the runtime appends
it, binds it to the mesh, and applies it. Generate the sample via the script above.
Add a new converter by dropping a folder in `converters/` — no code changes needed.
@@ -0,0 +1,12 @@
{
"id": "gn_displace",
"name": "Geometry Node Displace (demo)",
"kind": "nodegroup",
"blend": "gn_displace.blend",
"group": "EVE_Displace",
"description": "Demonstrates node-group converters: appends the EVE_Displace geometry node group to the input and applies it. Requires building gn_displace.blend first (see scripts/build_nodegroup_blend.py).",
"params": [
{"key": "seed", "type": "int", "default": "1"},
{"key": "strength", "type": "float", "default": "0.4"}
]
}
@@ -0,0 +1,21 @@
def convert(ctx):
"""Box -> low-poly stone.
Scales the input to `size`, subdivides to `detail`, then displaces a seeded
vertex group along normals for an organic rock surface. Same seed -> same rock.
"""
p = ctx.params
seed = int(p.get("seed", 1))
size = float(p.get("size", 1.2))
detail = float(p.get("detail", 0.6))
mo = ctx.mesh_ops
mo.scale_object(ctx.obj, size, size * 0.82, size)
subdivisions = max(1, round(2 + detail * 3))
mo.remesh_quads(ctx.obj, subdivisions)
group = "deform"
mo.weighted_random_group(ctx.obj, seed, group, 0.25, 1.0)
mo.smooth_group(ctx.obj, group, 2)
strength = 0.45 + detail * 0.35
mo.displace_vertex_weights(ctx.obj, seed, strength, group)
@@ -0,0 +1,12 @@
{
"id": "stone",
"name": "Low-poly Stone",
"kind": "script",
"entry": "convert.py",
"description": "Turns a primitive box into a deterministic low-poly stone using a seeded vertex-group displacement.",
"params": [
{"key": "seed", "type": "int", "default": "1"},
{"key": "size", "type": "float", "default": "1.2"},
{"key": "detail", "type": "float", "default": "0.6"}
]
}
@@ -0,0 +1,26 @@
def convert(ctx):
"""Box -> stone wall.
Lays the input box out as a wall (`length` x `height` x `depth`), subdivides
it into horizontal stone courses, and adds a small seeded jitter so adjacent
stones read as separate blocks. Deterministic per `seed`.
"""
p = ctx.params
seed = int(p.get("seed", 1))
length = float(p.get("length", 3.0))
height = float(p.get("height", 2.0))
depth = float(p.get("depth", 0.6))
detail = float(p.get("detail", 0.5))
mo = ctx.mesh_ops
mo.scale_object(ctx.obj, length, height, depth)
# One horizontal course roughly every 0.3 m; plus lengthwise splits.
courses = max(2, round(height / 0.3))
mo.remesh_quads(ctx.obj, courses)
# Subtle jitter on the wall faces to imply individual stones.
group = "face"
mo.weighted_random_group(ctx.obj, seed, group, 0.0, 0.5)
mo.smooth_group(ctx.obj, group, 1)
mo.displace_vertex_weights(ctx.obj, seed, 0.03 + detail * 0.03, group)
@@ -0,0 +1,14 @@
{
"id": "stone_wall",
"name": "Stone Wall (seamless)",
"kind": "script",
"entry": "convert.py",
"description": "Turns a primitive box into a stone wall: laid out to length/height/depth, subdivided into courses, with subtle brick-face jitter.",
"params": [
{"key": "seed", "type": "int", "default": "1"},
{"key": "length", "type": "float", "default": "3.0"},
{"key": "height", "type": "float", "default": "2.0"},
{"key": "depth", "type": "float", "default": "0.6"},
{"key": "detail", "type": "float", "default": "0.5"}
]
}
+10
View File
@@ -0,0 +1,10 @@
cmake_minimum_required(VERSION 3.21)
project(eve_modelconverter_plugin LANGUAGES CXX)
# Build against a target-platform EVEngine SDK, e.g.:
# cmake -B build -DEVEngine_DIR=/path/to/dist/eve-sdk/win32-debug/cmake
find_package(EVEngine REQUIRED)
add_eve_plugin(modelconverter
SOURCES ModelConverter.cpp
)
+370
View File
@@ -0,0 +1,370 @@
#include "ModelConverter.h"
#include "common/Exception.h"
#include <simplesquirrel/simplesquirrel.hpp>
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <sstream>
#include <sys/stat.h>
#include <sys/types.h>
#if defined(_WIN32)
# include <direct.h>
# include <windows.h>
#else
# include <dirent.h>
# include <sys/stat.h>
#endif
namespace eve::modelconverter {
namespace {
bool fileExists(const std::string &path) {
struct stat st;
return stat(path.c_str(), &st) == 0;
}
bool makeDir(const std::string &path) {
if (path.empty()) return true;
if (fileExists(path)) return true;
#if defined(_WIN32)
return _mkdir(path.c_str()) == 0;
#else
return mkdir(path.c_str(), 0755) == 0;
#endif
}
std::string joinPath(const std::string &a, const std::string &b) {
if (a.empty()) return b;
if (a.back() == '/' || a.back() == '\\') return a + b;
return a + "/" + b;
}
std::string quote(const std::string &s) {
return "\"" + s + "\"";
}
// Escape a string for inclusion inside a JSON string literal.
std::string jsonEscape(const std::string &s) {
std::ostringstream out;
for (char c : s) {
switch (c) {
case '"': out << "\\\""; break;
case '\\': out << "\\\\"; break;
case '\n': out << "\\n"; break;
case '\r': out << "\\r"; break;
case '\t': out << "\\t"; break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
char buf[8];
snprintf(buf, sizeof(buf), "\\u%04x", c);
out << buf;
} else {
out << c;
}
}
}
return out.str();
}
} // namespace
// ---- Params ----
void ModelConverterParams::setInt(const std::string &key, int value) {
values_[key] = std::to_string(value);
}
void ModelConverterParams::setFloat(const std::string &key, float value) {
std::ostringstream ss;
ss << value;
values_[key] = ss.str();
}
void ModelConverterParams::setString(const std::string &key, const std::string &value) {
values_[key] = value;
}
bool ModelConverterParams::has(const std::string &key) const {
return values_.count(key) > 0;
}
int ModelConverterParams::getInt(const std::string &key, int defaultValue) const {
auto it = values_.find(key);
if (it == values_.end()) return defaultValue;
return std::atoi(it->second.c_str());
}
float ModelConverterParams::getFloat(const std::string &key, float defaultValue) const {
auto it = values_.find(key);
if (it == values_.end()) return defaultValue;
return static_cast<float>(std::atof(it->second.c_str()));
}
std::string ModelConverterParams::getString(const std::string &key,
const std::string &defaultValue) const {
auto it = values_.find(key);
if (it == values_.end()) return defaultValue;
return it->second;
}
std::string ModelConverterParams::toJson() const {
std::ostringstream out;
out << "{";
bool first = true;
for (const auto &kv : values_) {
if (!first) out << ",";
first = false;
out << "\"" << jsonEscape(kv.first) << "\":\"" << jsonEscape(kv.second) << "\"";
}
out << "}";
return out.str();
}
// ---- Module ----
Module_IMPL(ModelConverter, new ModelConverter());
ModelConverter::ModelConverter() {
const char *tmp = std::getenv("TEMP");
if (!tmp || !*tmp) tmp = std::getenv("TMP");
if (!tmp || !*tmp) tmp = ".";
tempDir_ = tmp;
}
ModelConverterParams *ModelConverter::newParams() { return new ModelConverterParams(); }
bool ModelConverter::configure(const std::string &converterDir, const std::string &pythonExe,
const std::string &pythonRuntimeDir, const std::string &tempDir) {
converterDir_ = converterDir;
pythonExe_ = pythonExe;
pythonRuntimeDir_ = pythonRuntimeDir;
if (!tempDir.empty()) tempDir_ = tempDir;
lastError_.clear();
return true;
}
int ModelConverter::getConverterCount() const {
converterIdsCache_.clear();
if (converterDir_.empty()) return 0;
#if defined(_WIN32)
const std::string pattern = converterDir_ + "/*";
WIN32_FIND_DATAA fd;
HANDLE h = FindFirstFileA(pattern.c_str(), &fd);
if (h == INVALID_HANDLE_VALUE) return 0;
do {
if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
const std::string name = fd.cFileName;
if (name == "." || name == "..") continue;
if (fileExists(joinPath(joinPath(converterDir_, name), "manifest.json")))
converterIdsCache_.push_back(name);
}
} while (FindNextFileA(h, &fd));
FindClose(h);
#else
// Non-Windows: directory scan via <dirent.h>.
DIR *dir = opendir(converterDir_.c_str());
if (!dir) return 0;
struct dirent *ent;
while ((ent = readdir(dir)) != nullptr) {
if (ent->d_name[0] == '.') continue;
if (fileExists(joinPath(joinPath(converterDir_, ent->d_name), "manifest.json")))
converterIdsCache_.push_back(ent->d_name);
}
closedir(dir);
#endif
std::sort(converterIdsCache_.begin(), converterIdsCache_.end());
return int(converterIdsCache_.size());
}
std::string ModelConverter::getConverterId(int index) const {
if (converterIdsCache_.empty()) getConverterCount();
if (index < 0 || index >= int(converterIdsCache_.size())) return {};
return converterIdsCache_[size_t(index)];
}
bool ModelConverter::hasConverter(const std::string &id) const {
if (converterIdsCache_.empty()) getConverterCount();
for (const auto &cid : converterIdsCache_)
if (cid == id) return true;
return false;
}
bool ModelConverter::spawn(const std::string &command) {
#if defined(_WIN32)
std::string full = "cmd /c " + command;
return std::system(full.c_str()) == 0;
#else
return std::system(command.c_str()) == 0;
#endif
}
bool ModelConverter::runPython(const std::vector<std::string> &args, std::string &capturedError) {
if (pythonExe_.empty()) {
capturedError = "modelconverter: python executable not configured (call configure)";
return false;
}
makeDir(tempDir_);
std::string prefix;
if (!pythonRuntimeDir_.empty()) {
// Make `-m eve_blender_converter` importable for the child process.
#if defined(_WIN32)
prefix = "set PYTHONPATH=" + quote(pythonRuntimeDir_) + " && ";
#else
prefix = "PYTHONPATH=" + quote(pythonRuntimeDir_) + " ";
#endif
}
std::ostringstream cmd;
cmd << prefix << quote(pythonExe_) << " -m eve_blender_converter";
for (const auto &arg : args) cmd << " " << quote(arg);
const std::string logPath = joinPath(tempDir_, "modelconverter_last.log");
cmd << " 2>&1 > " << quote(logPath);
const int rc = spawn(cmd.str());
std::string log;
{
std::ifstream in(logPath, std::ios::binary);
if (in) {
std::ostringstream ss;
ss << in.rdbuf();
log = ss.str();
}
}
// Trim trailing whitespace.
while (!log.empty() && (log.back() == '\n' || log.back() == '\r')) log.pop_back();
if (rc != 0) {
capturedError = log.empty() ? "python exited with code " + std::to_string(rc) : log;
return false;
}
capturedError = log;
return true;
}
bool ModelConverter::check() {
std::string captured;
std::vector<std::string> args = {"check"};
if (!runPython(args, captured)) {
lastError_ = captured.empty() ? "check failed" : captured;
return false;
}
if (captured.find("bpy OK") == std::string::npos) {
lastError_ = "bpy is not available: pip install bpy\n" + captured;
return false;
}
lastError_.clear();
return true;
}
bool ModelConverter::convert(const std::string &converterId, const std::string &inputModel,
const std::string &outputModel, const std::string &format,
const ModelConverterParams *params) {
lastError_.clear();
lastOutput_.clear();
if (converterDir_.empty() || pythonExe_.empty()) {
lastError_ = "modelconverter: call configure(converterDir, pythonExe, ...) first";
return false;
}
if (!hasConverter(converterId)) {
lastError_ = "modelconverter: unknown converter '" + converterId + "'";
return false;
}
if (inputModel.empty() || outputModel.empty()) {
lastError_ = "modelconverter: input and output paths are required";
return false;
}
makeDir(tempDir_);
const std::string jobPath = joinPath(tempDir_, "modelconverter_job.txt");
const std::string resultPath = joinPath(tempDir_, "modelconverter_result.txt");
{
std::ofstream job(jobPath, std::ios::trunc);
if (!job) {
lastError_ = "modelconverter: cannot write job file " + jobPath;
return false;
}
job << "converter=" << converterId << "\n";
job << "input=" << inputModel << "\n";
job << "output=" << outputModel << "\n";
job << "format=" << (format.empty() ? "glb" : format) << "\n";
job << "converter_dir=" << converterDir_ << "\n";
job << "temp_dir=" << tempDir_ << "\n";
job << "params=" << (params ? params->toJson() : "{}") << "\n";
}
std::string captured;
std::vector<std::string> args = {"convert", jobPath};
runPython(args, captured);
// The result file is authoritative; python may exit non-zero yet still
// write a useful error into it (e.g. missing bpy).
std::ifstream result(resultPath);
if (result) {
std::string line;
bool ok = false;
std::string errorFromResult;
while (std::getline(result, line)) {
const auto eq = line.find('=');
if (eq == std::string::npos) continue;
const std::string key = line.substr(0, eq);
const std::string value = line.substr(eq + 1);
if (key == "ok") ok = (value == "1");
else if (key == "error" && !value.empty()) errorFromResult = value;
else if (key == "output") lastOutput_ = value;
}
if (ok) return true;
lastError_ = errorFromResult.empty() ? "converter reported failure (see log)" : errorFromResult;
lastOutput_.clear();
return false;
}
lastError_ = "modelconverter: no result file from python runtime:\n" + captured;
return false;
}
std::string ModelConverter::lastError() const { return lastError_; }
std::string ModelConverter::lastOutput() const { return lastOutput_; }
void ModelConverter::expose(ssq::Table &table) {
auto cls = table.addClass(name, ModelConverter::create, false);
expose(cls);
auto params = table.addClass<ModelConverterParams>(
"ModelConverterParams",
std::function<ModelConverterParams *()>([]() -> ModelConverterParams * { return nullptr; }),
true);
params.addFunc("setInt", &ModelConverterParams::setInt);
params.addFunc("setFloat", &ModelConverterParams::setFloat);
params.addFunc("setString", &ModelConverterParams::setString);
params.addFunc("has", &ModelConverterParams::has);
params.addFunc("getInt", &ModelConverterParams::getInt);
params.addFunc("getFloat", &ModelConverterParams::getFloat);
params.addFunc("getString", &ModelConverterParams::getString);
}
void ModelConverter::expose(ssq::Class &cls) {
cls.addFunc("getName", &ModelConverter::getName);
cls.addFunc("newParams", &ModelConverter::newParams);
cls.addFunc("configure", &ModelConverter::configure);
cls.addFunc("check", &ModelConverter::check);
cls.addFunc("getConverterCount", &ModelConverter::getConverterCount);
cls.addFunc("getConverterId", &ModelConverter::getConverterId);
cls.addFunc("hasConverter", &ModelConverter::hasConverter);
cls.addFunc("convert", &ModelConverter::convert);
cls.addFunc("lastError", &ModelConverter::lastError);
cls.addFunc("lastOutput", &ModelConverter::lastOutput);
}
} // namespace eve::modelconverter
+88
View File
@@ -0,0 +1,88 @@
#pragma once
#include "common/Module.h"
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
namespace eve::modelconverter {
/**
* Conversion parameters, mirroring procgen::Params. Keys are strings, stored as
* strings (no overloads); typed setters/getters are provided for Squirrel.
*/
class ModelConverterParams {
public:
void setInt(const std::string &key, int value);
void setFloat(const std::string &key, float value);
void setString(const std::string &key, const std::string &value);
bool has(const std::string &key) const;
int getInt(const std::string &key, int defaultValue) const;
float getFloat(const std::string &key, float defaultValue) const;
std::string getString(const std::string &key, const std::string &defaultValue) const;
/** Serialize all values as a JSON object string (for the job file). */
std::string toJson() const;
private:
std::unordered_map<std::string, std::string> values_;
};
/**
* EVEngine native plugin that drives Blender's Python package (bpy) to convert
* a primitive model into a richer one. Loaded via eve.Plugins.load().
*
* The plugin spawns a Python subprocess (`python -m eve_blender_converter`) with
* a plain-text job file, and reads back a plain-text result file. This keeps the
* plugin dependency-free (no Poco / no JSON parser).
*/
class ModelConverter : public Module {
public:
Module_REG(ModelConverter);
ModelConverter();
~ModelConverter() override = default;
ModelConverterParams *newParams();
/**
* @param converterDir Directory containing converter folders (manifest.json).
* @param pythonExe Python executable that can `import bpy`.
* @param pythonRuntimeDir Directory containing the `eve_blender_converter` package.
* @param tempDir Scratch dir for job/result files ("" -> system temp).
*/
bool configure(const std::string &converterDir, const std::string &pythonExe,
const std::string &pythonRuntimeDir, const std::string &tempDir = "");
/** Verify python + bpy are usable. */
bool check();
int getConverterCount() const;
std::string getConverterId(int index) const;
bool hasConverter(const std::string &id) const;
/** Run a converter. inputModel/outputModel are file paths. */
bool convert(const std::string &converterId, const std::string &inputModel,
const std::string &outputModel, const std::string &format,
const ModelConverterParams *params);
std::string lastError() const;
/** Last successful output path (set by convert()). */
std::string lastOutput() const;
private:
bool runPython(const std::vector<std::string> &args, std::string &capturedError);
bool spawn(const std::string &command);
std::string converterDir_;
std::string pythonExe_;
std::string pythonRuntimeDir_;
std::string tempDir_;
mutable std::string lastError_;
std::string lastOutput_;
mutable std::vector<std::string> converterIdsCache_;
};
} // namespace eve::modelconverter
@@ -0,0 +1,10 @@
"""ModelConverter — EVEngine ↔ Blender (bpy) bridge.
This package is invoked by the EVEngine native plugin as a subprocess:
python -m eve_blender_converter convert <job.txt>
It imports Blender's Python package (`bpy`) to turn a primitive mesh into a
richer model by running a converter (a Python script or a Geometry Node group).
"""
__version__ = "0.1.0"
@@ -0,0 +1,84 @@
"""CLI entry point: `python -m eve_blender_converter <command> <job.txt>`.
Commands:
convert <job.txt> Run a converter job and write <job>.result.txt
check Verify bpy availability and print status
list <converter_dir> List converters in a directory
"""
from __future__ import annotations
import os
import sys
def _check():
if _bpy_available():
import bpy
print(f"bpy OK ({bpy.app.version_string})")
return 0
print("bpy NOT AVAILABLE (pip install bpy)")
return 1
def _list(converter_dir: str):
from .job import list_matches
for cid in list_matches(converter_dir):
print(cid)
return 0
def _bpy_available() -> bool:
try:
import bpy # noqa: F401
return True
except Exception:
return False
def _convert(job_path: str):
from .job import Job, Result
job = Job.from_file(job_path)
# The plugin expects result at <job>.result.txt
result_path = job_path + ".result.txt"
if job.temp_dir:
result_path = os.path.join(job.temp_dir, "modelconverter_result.txt")
if not _bpy_available():
result = Result()
result.ok = False
result.error = ("bpy is not installed/importable. Install it with: pip install bpy")
result.write(result_path)
if not os.path.exists(result_path):
result.write(job_path + ".result.txt")
return 1
from .runtime import run
result = run(job)
result.write(result_path)
if not os.path.exists(result_path):
# Fallback next to the job file if temp_dir was unusable.
result.write(job_path + ".result.txt")
return 0 if result.ok else 1
def main(argv) -> int:
if not argv:
print(__doc__)
return 0
command = argv[0]
if command == "check":
return _check()
if command == "list":
return _list(argv[1] if len(argv) > 1 else ".")
if command == "convert":
if len(argv) < 2:
print("usage: python -m eve_blender_converter convert <job.txt>")
return 2
return _convert(argv[1])
print(f"unknown command '{command}'\n{__doc__}")
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,102 @@
"""Converter dispatch: runs a `script` or a `nodegroup` converter against a mesh."""
from __future__ import annotations
import json
import os
from typing import Dict, Optional
from .. import mesh_ops
class ConverterError(RuntimeError):
pass
def load_manifest(converter_dir: str, converter_id: str) -> Dict[str, object]:
entry = os.path.join(converter_dir, converter_id)
manifest_path = os.path.join(entry, "manifest.json")
if not os.path.isfile(manifest_path):
raise ConverterError(f"converter '{converter_id}': manifest.json not found in '{entry}'")
with open(manifest_path, "r", encoding="utf-8") as fh:
manifest = json.load(fh)
manifest["_dir"] = entry
return manifest
def _merge_defaults(manifest: Dict[str, object], params: Dict[str, object]) -> Dict[str, object]:
merged: Dict[str, object] = dict(params)
for spec in manifest.get("params", []) or []:
key = spec.get("key", "")
if key and key not in merged:
merged[key] = spec.get("default", "")
return merged
class ConvertContext:
"""Passed to `convert(ctx)` for script converters."""
def __init__(self, obj, manifest: Dict[str, object], params: Dict[str, object]) -> None:
self.obj = obj
self.manifest = manifest
self.params = params
self.mesh_ops = mesh_ops
def run_py(self, source: str, globals_dict: Optional[Dict[str, object]] = None) -> None:
"""Execute an inline Python snippet in the converter's namespace."""
scope = {"bpy": mesh_ops.bpy, "ctx": self, "obj": self.obj, "params": self.params,
"mesh_ops": mesh_ops}
if globals_dict:
scope.update(globals_dict)
exec(source, scope) # noqa: S102 - explicitly scripting converters
def apply_nodegroup(self, blend_path: str, group_name: str) -> None:
"""Append a Geometry Node group from a .blend and bind it to self.obj."""
import bpy
if not os.path.isfile(blend_path):
raise ConverterError(f"node group blend not found: {blend_path}")
if not group_name:
raise ConverterError("apply_nodegroup: empty group_name")
if bpy.context.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.wm.append(
filename=group_name,
directory=os.path.join(blend_path, "NodeTree"),
link=False,
)
group = bpy.data.node_groups.get(group_name)
if group is None:
raise ConverterError(f"node group '{group_name}' not found in '{blend_path}'")
modifier = self.obj.modifiers.new(name="EVE_GeometryNodes", type="NODES")
modifier.node_group = group
def run_converter(manifest: Dict[str, object], obj, params: Dict[str, object]) -> None:
kind = manifest.get("kind", "script")
entry_dir = str(manifest.get("_dir", ""))
merged = _merge_defaults(manifest, params)
ctx = ConvertContext(obj, manifest, merged)
if kind == "script":
entry = str(manifest.get("entry", "convert.py"))
script_path = os.path.join(entry_dir, entry)
if not os.path.isfile(script_path):
raise ConverterError(f"converter script not found: {script_path}")
scope = {"bpy": mesh_ops.bpy, "ctx": ctx, "obj": obj,
"params": merged, "mesh_ops": mesh_ops}
with open(script_path, "r", encoding="utf-8") as fh:
source = fh.read()
exec(compile(source, script_path, "exec"), scope) # noqa: S102
convert = scope.get("convert")
if convert is None:
raise ConverterError(f"script '{script_path}' must define convert(ctx)")
convert(ctx)
elif kind == "nodegroup":
blend = str(manifest.get("blend", ""))
group = str(manifest.get("group", ""))
blend_path = os.path.join(entry_dir, blend)
ctx.apply_nodegroup(blend_path, group)
else:
raise ConverterError(f"unknown converter kind '{kind}' (use script|nodegroup)")
@@ -0,0 +1,113 @@
"""Plain-text job protocol between the C++ plugin and this Python runtime.
To keep the C++ side dependency-free we use `key=value` lines for both the job
file and the result file; params travel as an embedded JSON string which Python
parses with the standard library.
"""
from __future__ import annotations
import json
from typing import Dict, List, Optional
class Job:
def __init__(self) -> None:
self.converter: str = ""
self.input_model: str = ""
self.output_model: str = ""
self.format: str = "glb"
self.converter_dir: str = ""
self.temp_dir: str = ""
self.params: Dict[str, object] = {}
@staticmethod
def _parse_line(line: str) -> Optional[str]:
line = line.strip()
if not line or line.startswith("#"):
return None
if "=" not in line:
return None
return line
@classmethod
def from_file(cls, path: str) -> "Job":
job = cls()
with open(path, "r", encoding="utf-8") as fh:
for raw in fh:
line = cls._parse_line(raw)
if line is None:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if key == "converter":
job.converter = value
elif key == "input":
job.input_model = value
elif key == "output":
job.output_model = value
elif key == "format":
job.format = value
elif key == "converter_dir":
job.converter_dir = value
elif key == "temp_dir":
job.temp_dir = value
elif key == "params":
if value:
try:
job.params = json.loads(value)
except json.JSONDecodeError:
job.params = {}
return job
class Result:
def __init__(self) -> None:
self.ok = False
self.output_model = ""
self.error = ""
self.vertices = 0
self.triangles = 0
def write(self, path: str) -> None:
lines = [
f"ok={1 if self.ok else 0}",
f"output={self.output_model}",
f"error={self.error}",
f"vertices={self.vertices}",
f"triangles={self.triangles}",
]
with open(path, "w", encoding="utf-8") as fh:
fh.write("\n".join(lines) + "\n")
detail = {
"ok": self.ok,
"output": self.output_model,
"error": self.error,
"stats": {"vertices": self.vertices, "triangles": self.triangles},
}
try:
with open(path + ".json", "w", encoding="utf-8") as fh:
json.dump(detail, fh, indent=2, ensure_ascii=False)
except OSError:
pass
def params_to_json(params: Dict[str, object]) -> str:
return json.dumps(params, ensure_ascii=False)
def list_matches(converter_dir: str) -> List[str]:
"""Return converter ids by scanning the converter directory."""
import os
if not converter_dir or not os.path.isdir(converter_dir):
return []
out: List[str] = []
for name in sorted(os.listdir(converter_dir)):
entry = os.path.join(converter_dir, name)
manifest = os.path.join(entry, "manifest.json")
if os.path.isdir(entry) and os.path.isfile(manifest):
out.append(name)
return out
@@ -0,0 +1,226 @@
"""Common, deterministic bpy mesh helpers used by script converters."""
from __future__ import annotations
import math
import random
import bpy
import bmesh
def deselect_all() -> None:
if bpy.context.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.select_all(action="DESELECT")
def select_only(obj) -> None:
deselect_all()
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
def remesh_quads(obj, subdivisions: int) -> None:
"""Subdivide the mesh into quads (Catmull-Clark if the mesh allows)."""
select_only(obj)
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.subdivide(number_cuts=max(0, int(subdivisions)))
bpy.ops.object.mode_set(mode="OBJECT")
def weighted_random_group(obj, seed: int, group_name: str, weight_min: float = 0.0,
weight_max: float = 1.0) -> None:
"""Assign a seeded random weight to every vertex in a vertex group.
Adjacent vertices can end up with very different weights; scripts usually
smooth this afterwards (see smooth_group).
"""
group = obj.vertex_groups.get(group_name)
if group is None:
group = obj.vertex_groups.new(name=group_name)
rng = random.Random(seed)
data = obj.data
try:
bpy.ops.object.mode_set(mode="OBJECT")
for v in data.vertices:
w = rng.uniform(weight_min, weight_max)
group.add([v.index], w, "REPLACE")
except RuntimeError:
# Some Blender builds require EDIT mode for weight painting.
for v in data.vertices:
w = rng.uniform(weight_min, weight_max)
group.add([v.index], w, "REPLACE")
def smooth_group(obj, group_name: str, iterations: int = 3) -> None:
"""Blur vertex-group weights across the mesh for softer displacement."""
def _neighbors(me):
import collections
nbr: "collections.defaultdict[int, set[int]]" = collections.defaultdict(set)
for p in me.polygons:
for i in range(len(p.vertices)):
a = p.vertices[i]
b = p.vertices[(i + 1) % len(p.vertices)]
nbr[a].add(b)
nbr[b].add(a)
return nbr
group = obj.vertex_groups.get(group_name)
if group is None:
return
me = obj.data
nbr = _neighbors(me)
weights = {v.index: group.weight(v.index) for v in me.vertices}
for _ in range(max(1, int(iterations))):
new_weights = {}
for vid, val in weights.items():
n = nbr.get(vid)
if not n:
new_weights[vid] = val
continue
total = 0.0
cnt = 0
for nb in n:
total += weights.get(nb, group.weight(nb))
cnt += 1
new_weights[vid] = total / cnt if cnt else val
weights = new_weights
for vid, w in weights.items():
group.add([vid], w, "REPLACE")
def displace_vertex_weights(obj, seed: int, strength: float, group_name: str,
use_edit_mode: bool = False) -> None:
"""Displace vertices along their normals by a seeded fbm, weighted by a
vertex group. Produces an organic (e.g. rock) surface.
"""
group = obj.vertex_groups.get(group_name)
if group is None:
group = obj.vertex_groups.new(name=group_name)
strength = float(strength)
# Deterministic 3D value-noise (no global random state).
def _hash3(x, y, z, s):
return ((x * 374761393) ^ (y * 668265263) ^ (z * 1274126177) ^ (s * 2246822519)) & 0x7FFFFFFF
def _fbm(x, y, z, s, octaves=4):
total = 0.0
amp = 0.5
freq = 1.0
norm = 0.0
for _ in range(octaves):
h = _hash3(int(x * freq), int(y * freq), int(z * freq), s)
total += ((h / 0x7FFFFFFF) * 2.0 - 1.0) * amp
norm += amp
amp *= 0.5
freq *= 2.0
return total / norm if norm else 0.0
me = obj.data
if use_edit_mode:
# Operate via bmesh so history/normals stay consistent.
bm = bmesh.new()
bm.from_mesh(me)
bm.verts.ensure_lookup_table()
layer = bm.verts.layers.deform.get(group_name)
if layer is None:
layer = bm.verts.layers.deform.new(group_name)
for v in bm.verts:
w = v[layer].get(group.index, 0.0) if layer is not None else 0.0
disp = _fbm(v.co.x, v.co.y, v.co.z, seed) * strength * w
v.co.x += v.normal.x * disp
v.co.y += v.normal.y * disp
v.co.z += v.normal.z * disp
bm.normal_update()
bm.to_mesh(me)
bm.free()
else:
me.update(calc_edges=True)
for v in me.vertices:
w = group.weight(v.index)
disp = _fbm(v.co.x, v.co.y, v.co.z, seed) * strength * w
v.co.x += v.normal.x * disp
v.co.y += v.normal.y * disp
v.co.z += v.normal.z * disp
me.update()
def apply_modifiers(obj) -> None:
deps = bpy.context.evaluated_depsgraph_get()
for modifier in list(obj.modifiers):
try:
bpy.ops.object.modifier_apply(modifier=modifier.name)
except RuntimeError:
# Apply through evaluated geometry if direct apply fails.
if hasattr(obj, "evaluated_get"):
ev = obj.evaluated_get(deps)
if ev.data and ev.data.name:
new_data = ev.data.copy()
obj.data.user_clear()
obj.data = new_data
obj.modifiers.clear()
def triangulate(obj) -> None:
select_only(obj)
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.quads_convert_to_tris(quad_method="SHORTEST_DIAGONAL")
bpy.ops.object.mode_set(mode="OBJECT")
def recalc_normals(obj) -> None:
select_only(obj)
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode="OBJECT")
def shade_flat(obj, flat: bool = True) -> None:
select_only(obj)
if flat:
bpy.ops.object.shade_flat()
else:
bpy.ops.object.shade_smooth()
def unwrap_lite(obj, margin: float = 0.02) -> None:
"""Smart-projection UV unwrap; safe fallback for simple boxes."""
select_only(obj)
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
try:
bpy.ops.uv.smart_project()
except RuntimeError:
bpy.ops.uv.unwrap()
bpy.ops.object.mode_set(mode="OBJECT")
def solidify(obj, thickness: float, offset: float = -1.0) -> None:
mod = obj.modifiers.new(name="EVE_Solidify", type="SOLIDIFY")
mod.thickness = float(thickness)
mod.offset = float(offset)
def bevel(obj, width: float, segments: int = 1) -> None:
mod = obj.modifiers.new(name="EVE_Bevel", type="BEVEL")
mod.width = float(width)
mod.segments = max(1, int(segments))
mod.limit_method = "ANGLE"
mod.angle_limit = math.radians(30)
def scale_object(obj, sx: float, sy: float, sz: float) -> None:
select_only(obj)
bpy.ops.transform.resize(value=(sx, sy, sz))
bpy.ops.object.transform_apply(scale=True)
def count_stats(obj) -> "tuple[int, int]":
me = obj.data
me.calc_loop_triangles()
return len(me.vertices), len(me.loop_triangles)
@@ -0,0 +1,115 @@
"""Main bpy pipeline: import primitive → run converter → bake → export."""
from __future__ import annotations
import os
import sys
from typing import Optional
from .job import Job, Result
from .converters import load_manifest, run_converter, ConverterError
def bpy_available() -> bool:
try:
import bpy # noqa: F401
return True
except Exception:
return False
def require_bpy():
if not bpy_available():
raise ConverterError(
"bpy is not installed/importable. Install it with: pip install bpy\n"
"The model converter drives Blender through its Python package."
)
import bpy # noqa: F401
return sys.modules["bpy"]
def _import_model(bpy, path: str):
if not os.path.isfile(path):
raise ConverterError(f"input model not found: {path}")
ext = os.path.splitext(path)[1].lower()
if ext == ".obj":
bpy.ops.wm.obj_import(filepath=path)
elif ext in (".stl",):
bpy.ops.wm.stl_import(filepath=path)
elif ext in (".fbx",):
bpy.ops.wm.import_scene.fbx(filepath=path)
elif ext in (".glb", ".gltf"):
bpy.ops.wm.import_scene.gltf(filepath=path)
else:
raise ConverterError(f"unsupported input format '{ext}' (use .obj/.stl/.fbx/.glb/.gltf)")
meshes = [o for o in bpy.context.scene.objects if o.type == "MESH"]
if not meshes:
raise ConverterError("no mesh found in input model")
# Operate on the first mesh; hide the rest as a safeguard.
target = meshes[0]
for o in meshes:
o.select_set(o is target)
bpy.context.view_layer.objects.active = target
return target
def _prepare_scene(bpy) -> None:
bpy.ops.wm.read_factory_settings(use_empty=True)
if bpy.context.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
def _bake(bpy, obj) -> None:
from . import mesh_ops
mesh_ops.apply_modifiers(obj)
mesh_ops.triangulate(obj)
mesh_ops.recalc_normals(obj)
mesh_ops.unwrap_lite(obj)
mesh_ops.shade_flat(obj, flat=False)
def _export(bpy, obj, output_model: str, fmt: str) -> None:
out_dir = os.path.dirname(os.path.abspath(output_model))
if out_dir:
os.makedirs(out_dir, exist_ok=True)
# Export only the converted object.
for o in bpy.context.scene.objects:
o.select_set(o is obj)
bpy.context.view_layer.objects.active = obj
if fmt == "obj":
bpy.ops.wm.obj_export(filepath=output_model)
elif fmt == "glb":
bpy.ops.export_scene.gltf(filepath=output_model, export_format="GLB",
export_yup=True)
elif fmt == "gltf":
bpy.ops.export_scene.gltf(filepath=output_model, export_format="GLTF",
export_yup=True)
elif fmt == "fbx":
bpy.ops.export_scene.fbx(filepath=output_model)
else:
raise ConverterError(f"unsupported output format '{fmt}' (use glb/gltf/obj/fbx)")
def run(job: Job) -> Result:
result = Result()
try:
bpy = require_bpy()
_prepare_scene(bpy)
obj = _import_model(bpy, job.input_model)
manifest = load_manifest(job.converter_dir, job.converter)
run_converter(manifest, obj, job.params)
_bake(bpy, obj)
_export(bpy, obj, job.output_model, job.format or "glb")
from . import mesh_ops
result.ok = True
result.output_model = job.output_model
result.vertices, result.triangles = mesh_ops.count_stats(obj)
except Exception as exc: # noqa: BLE001 - report any failure to the plugin
result.ok = False
result.error = str(exc)
return result
+8
View File
@@ -0,0 +1,8 @@
# Blender's Python package (full Blender as an importable module, incl.
# Geometry Nodes). Install on the Python used by the EVEngine plugin:
# pip install -r modelconverter/python/requirements.txt
#
# NOTE: recent bpy releases (>= 5.x) ship wheels for a single CPython version.
# bpy 5.2.0 requires CPython == 3.13.*. Use `py -3.13` if you have multiple
# Pythons installed.
bpy>=5.0.0
@@ -0,0 +1,104 @@
"""Build the demo geometry-node converter's .blend inside Blender.
Run once with your Blender:
blender --background --python modelconverter/scripts/build_nodegroup_blend.py
Creates `modelconverter/converters/gn_displace/gn_displace.blend` containing a
Geometry Node group named `EVE_Displace` (displaces a mesh along its normals by
a seeded noise field). The `gn_displace` converter appends and applies it.
"""
from __future__ import annotations
import os
import sys
import bpy
def make_displace_group(name: str = "EVE_Displace") -> None:
if name in bpy.data.node_groups:
bpy.data.node_groups.remove(bpy.data.node_groups[name])
group = bpy.data.node_groups.new(name=name, type="GeometryNodeTree")
group.is_modifier = True
# Keep the group in the saved file even though no object references it yet.
group.use_fake_user = True
# --- node group interface (Blender 4.x) ---
try:
group.interface.new_socket(name="Geometry", in_out="INPUT",
socket_type="NodeSocketGeometry")
group.interface.new_socket(name="Strength", in_out="INPUT",
socket_type="NodeSocketFloat")
group.interface.new_socket(name="Seed", in_out="INPUT",
socket_type="NodeSocketInt")
group.interface.new_socket(name="Geometry", in_out="OUTPUT",
socket_type="NodeSocketGeometry")
except AttributeError:
# Fallback for older Blender node-tree interface.
in_geom = group.inputs.new("NodeSocketGeometry", "Geometry")
in_strength = group.inputs.new("NodeSocketFloat", "Strength")
in_seed = group.inputs.new("NodeSocketInt", "Seed")
group.outputs.new("NodeSocketGeometry", "Geometry")
in_strength.default_value = 0.4
in_seed.default_value = 1
nodes = group.nodes
group_input = nodes.new("NodeGroupInput")
group_output = nodes.new("NodeGroupOutput")
group_input.location = (-800, 0)
group_output.location = (600, 0)
position = nodes.new("GeometryNodeInputPosition")
position.location = (-500, 200)
noise = nodes.new("ShaderNodeTexNoise")
noise.location = (-500, -40)
noise.inputs["Scale"].default_value = 3.0
try:
noise.seed = 1
except AttributeError:
pass
# strength scalar = Strength * noise Fac
mult = nodes.new("ShaderNodeMath")
mult.operation = "MULTIPLY"
mult.location = (-120, 20)
# offset = position vector scaled by strength scalar
scale_vec = nodes.new("ShaderNodeVectorMath")
scale_vec.operation = "SCALE"
scale_vec.location = (140, 0)
set_pos = nodes.new("GeometryNodeSetPosition")
set_pos.location = (380, 0)
links = group.links
# Use index-based sockets: group_input.outputs order follows the interface
# (Geometry, Strength, Seed); node socket orders are stable in Blender 4.x/5.x.
links.new(group_input.outputs[0], set_pos.inputs[0]) # Geometry
links.new(position.outputs[0], scale_vec.inputs[0]) # Position -> Vector
links.new(noise.outputs[0], mult.inputs[0]) # noise Fac -> value
links.new(group_input.outputs[1], mult.inputs[1]) # Strength -> value
links.new(mult.outputs[0], scale_vec.inputs[1]) # value -> Scale
links.new(scale_vec.outputs[0], set_pos.inputs[3]) # Vector -> Offset
links.new(group_input.outputs[2], noise.inputs[1]) # Seed -> W
links.new(set_pos.outputs[0], group_output.inputs[0]) # Geometry
def main() -> int:
make_displace_group("EVE_Displace")
here = os.path.dirname(os.path.abspath(__file__))
converter_dir = os.path.abspath(
os.path.join(here, os.pardir, "converters", "gn_displace"))
os.makedirs(converter_dir, exist_ok=True)
out = os.path.join(converter_dir, "gn_displace.blend")
bpy.ops.wm.save_as_mainfile(filepath=out)
print(f"wrote {out}")
return 0
if __name__ == "__main__":
sys.exit(main())