Commit 5ef2e19f authored by fenghen777's avatar fenghen777

feat: 导出OpenModelica .mo模型 + 模型映射管理面板 + 旋转连线修复

- 新增 modelicaExporter.js: JSON转.mo模型(工程记号解析/connect生成)
- 新增 modelMapping.js: 符号类型到Modelica模型名的映射模块
- 新增 docs/openmodelica-export-analysis.md: 可行性分析文档
- Sidebar: 增加'模型映射'Tab, 支持内置+自定义符号映射编辑
- Toolbar: 增加'.mo'导出按钮, 未映射符号报错阻止导出
- FlowCanvas: 旋转后调用updateNodeInternals修复连线脱离
- useFlowStore: templateData增加type字段用于模型映射
- Toolbar: 移除无效的删除按钮
parent 3881f74d
# EPLAN Visualizer 导出文件转 OpenModelica .mo 模型 — 可行性分析
## 1. 背景
EPLAN Visualizer 当前以 JSON 格式导出原理图数据(nodes + edges),包含符号类型、端口、模型参数和连接关系。OpenModelica 使用 Modelica 语言的 `.mo` 文件描述物理系统模型,支持多领域仿真。本文分析两者数据的映射关系和转换可行性。
## 2. 数据结构对比
### 2.1 EPLAN Visualizer 导出格式
```json
{
"nodes": [
{
"id": "node-1",
"type": "customDeviceNode",
"position": { "x": 100, "y": 200 },
"data": {
"label": "电阻",
"deviceId": "node-1",
"color": "#FF9800",
"templateData": {
"name": "电阻",
"icon": "R",
"ports": [
{ "id": "p1", "name": "1", "side": "left", "position": 0.5, "type": "power" },
{ "id": "p2", "name": "2", "side": "right", "position": 0.5, "type": "power" }
],
"params": [
{ "key": "R", "label": "阻值", "unit": "Ω", "defaultValue": "10k" }
]
},
"paramValues": { "R": "4.7k" }
}
}
],
"edges": [
{
"id": "edge-1",
"source": "node-1",
"sourceHandle": "p2",
"target": "node-2",
"targetHandle": "p1"
}
]
}
```
### 2.2 OpenModelica .mo 格式
```modelica
model Circuit
Modelica.Electrical.Analog.Basic.Resistor R1(R=4700);
Modelica.Electrical.Analog.Basic.Capacitor C1(C=100e-6);
Modelica.Electrical.Analog.Sources.ConstantVoltage V1(V=24);
Modelica.Electrical.Analog.Basic.Ground GND;
equation
connect(V1.p, R1.p);
connect(R1.n, C1.p);
connect(C1.n, GND.p);
connect(V1.n, GND.p);
annotation(Diagram(coordinateSystem(extent={{-100,-100},{100,100}})));
end Circuit;
```
## 3. 映射关系
### 3.1 符号 → Modelica 组件
| EPLAN 字段 | Modelica 对应 | 说明 |
|-----------|-------------|------|
| `templateData.type` | Modelica 标准库类路径 | 需要维护映射表 |
| `data.label` | 组件实例名 | `Resistor R1` |
| `paramValues` | 构造参数 | `R1(R=4700)` |
| `params[].key` | Modelica 参数名 | 需命名一致 |
| `params[].unit` | Modelica SI 单位 | 需要单位换算 (10k → 10000) |
| `position` | annotation 坐标 | 布局信息 |
### 3.2 端口 → Connector
| EPLAN 端口类型 | Modelica Connector | 域 |
|-------------|-------------------|-----|
| `power` | `Modelica.Electrical.Analog.Interfaces.Pin` | 电气 (V, I) |
| `digital` | `Modelica.Electrical.Digital.Interfaces.DigitalPort` | 数字信号 |
| `analog` | `Modelica.Blocks.Interfaces.RealInput/Output` | 信号 |
| `generic` | 自定义 connector | 通用 |
### 3.3 连接 → connect 语句
```
edge: { source: "node-1", sourceHandle: "p2", target: "node-2", targetHandle: "p1" }
connect(R1.n, C1.p);
```
映射规则:`connect(源实例名.端口名, 目标实例名.端口名)`
## 4. 转换流程
```
JSON 导出
├─ 1. 解析 nodes,按 type 查映射表获取 Modelica 类路径
│ type: "resistor" → Modelica.Electrical.Analog.Basic.Resistor
├─ 2. 转换参数值(工程记号 → 数值: "10k" → 10000)
├─ 3. 生成组件声明
│ Modelica.Electrical.Analog.Basic.Resistor R1(R=10000);
├─ 4. 遍历 edges,生成 connect 语句
│ connect(R1.n, C1.p);
├─ 5. 转换坐标为 annotation(可选,用于 OMEdit 布局)
└─ 输出 .mo 文件
```
## 5. 类型映射表(核心)
需要维护的 `type → Modelica 类路径` 映射表:
| EPLAN type | Modelica 类路径 | 端口映射 |
|-----------|--------------|---------|
| `resistor` | `Modelica.Electrical.Analog.Basic.Resistor` | p1→p, p2→n |
| `capacitor` | `Modelica.Electrical.Analog.Basic.Capacitor` | p-pos→p, p-neg→n |
| `inductor` | `Modelica.Electrical.Analog.Basic.Inductor` | p1→p, p2→n |
| `diode` | `Modelica.Electrical.Analog.Semiconductors.Diode` | p-a→p, p-k→n |
| `voltage_source` | `Modelica.Electrical.Analog.Sources.ConstantVoltage` | p-pos→p, p-neg→n |
| `ground` | `Modelica.Electrical.Analog.Basic.Ground` | p-gnd→p |
| `motor` | `Modelica.Electrical.Machines.BasicMachines.AsynchronousInductionMachines.AIM_SquirrelCage` | 多端口复杂映射 |
| `breaker` | 自定义 model(标准库无直接对应) | — |
| `plc_cpu` | 不可映射 | 控制逻辑无标准模型 |
## 6. 挑战与限制
### 可直接映射 (高可行性)
- **基本电子元件**:电阻/电容/电感/二极管/电压源/接地 → Modelica 标准电气库完全覆盖
- **连接关系**:edges → connect 语句,一对一映射
- **参数传递**:paramValues → 构造参数,需单位换算
### 需要额外工作 (中等可行性)
- **电气控制元件**:接触器/继电器/断路器 → 需自定义 Modelica model 或使用第三方库
- **变压器/传感器**:标准库有类但端口映射较复杂
- **工程记号解析**`10k``10000``100u``0.0001` 需要解析器
### 当前不可映射 (低可行性)
- **PLC 模块**:控制逻辑在 Modelica 中需另外实现
- **水利/机械元件**:需要 Modelica 流体/机械库(非标准库核心)
- **自定义符号**:用户自建的符号没有对应的物理方程,需要用户手动补充
- **行为方程**:EPLAN 是结构图工具,不包含物理方程;Modelica 核心是方程求解
## 7. 结论与建议
### 可行性评级:中等偏高(基本电路场景)
对于**基本电子元件电路**(电阻/电容/电感/电压源组成的电路),转换完全可行。所需工作:
1. 编写 `type → Modelica` 映射表(约 10~15 个条目)
2. 实现工程记号解析器(`10k``10000`
3. 端口名映射(`p1``p``p2``n`
4. 生成 `.mo` 文本文件
### 建议实现路径
#### 第一阶段:基本电路支持
- 支持 6 个基础元件 → Modelica 标准电气库
- 实现导出为 `.mo` 文件按钮
- 可用 OpenModelica 打开并仿真
#### 第二阶段:扩展映射
- 添加电气控制元件的自定义 Modelica model
- 支持 annotation 坐标转换(保持布局)
- 参数验证和单位标准化
#### 第三阶段(可选):深度集成
- 对接 OpenModelica 的 OMC API,直接调用仿真
- 将仿真结果回显到原理图界面
### 预估工作量
| 阶段 | 工作量 | 输出 |
|------|-------|------|
| 第一阶段 | 2~3 天 | 导出 .mo 文件,支持 6 个基本元件 |
| 第二阶段 | 3~5 天 | 扩展 15+ 元件,坐标映射 |
| 第三阶段 | 5~10 天 | OMC API 集成,仿真结果回显 |
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
BackgroundVariant, BackgroundVariant,
reconnectEdge, reconnectEdge,
useReactFlow, useReactFlow,
useUpdateNodeInternals,
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import DeviceNode from '../Nodes/DeviceNode'; import DeviceNode from '../Nodes/DeviceNode';
...@@ -32,6 +33,8 @@ const defaultEdgeOptions = { ...@@ -32,6 +33,8 @@ const defaultEdgeOptions = {
export default function FlowCanvas() { export default function FlowCanvas() {
const reactFlowWrapper = useRef(null); const reactFlowWrapper = useRef(null);
const { screenToFlowPosition } = useReactFlow(); const { screenToFlowPosition } = useReactFlow();
const updateNodeInternals = useUpdateNodeInternals();
const updateNodeInternalsRef = useRef(updateNodeInternals);
const { const {
nodes, nodes,
edges, edges,
...@@ -90,6 +93,11 @@ export default function FlowCanvas() { ...@@ -90,6 +93,11 @@ export default function FlowCanvas() {
), ),
selectedNode: { ...selectedNode, data: { ...selectedNode.data, rotation: newRotation } }, selectedNode: { ...selectedNode, data: { ...selectedNode.data, rotation: newRotation } },
}); });
// 强制 React Flow 重新计算 Handle 位置,解决旋转后连线脱离
requestAnimationFrame(() => {
updateNodeInternalsRef.current?.(selectedNode.id);
});
} }
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
......
...@@ -9,11 +9,17 @@ import { useState, useCallback, useRef } from 'react'; ...@@ -9,11 +9,17 @@ import { useState, useCallback, useRef } from 'react';
import useFlowStore from '../../hooks/useFlowStore'; import useFlowStore from '../../hooks/useFlowStore';
import useComponentLibrary from '../../hooks/useComponentLibrary'; import useComponentLibrary from '../../hooks/useComponentLibrary';
import { FUNCTION_CODE_MAP, DEVICE_CATEGORIES } from '../../utils/constants'; import { FUNCTION_CODE_MAP, DEVICE_CATEGORIES } from '../../utils/constants';
import MODEL_MAP_DEFAULT, { getAllMappings } from '../../utils/modelMapping';
import styles from './Sidebar.module.css'; import styles from './Sidebar.module.css';
export default function Sidebar() { export default function Sidebar() {
const [connectionFile, setConnectionFile] = useState(null); const [connectionFile, setConnectionFile] = useState(null);
const [activeTab, setActiveTab] = useState('library'); // library | import const [activeTab, setActiveTab] = useState('library'); // library | import | mapping
const [mappingOverrides, setMappingOverrides] = useState(() => {
try {
return JSON.parse(localStorage.getItem('eplan_model_mapping_overrides') || '{}');
} catch { return {}; }
});
// 内置符号展开,子分类默认折叠 // 内置符号展开,子分类默认折叠
const [collapsedCats, setCollapsedCats] = useState(() => { const [collapsedCats, setCollapsedCats] = useState(() => {
const init = {}; const init = {};
...@@ -86,6 +92,12 @@ export default function Sidebar() { ...@@ -86,6 +92,12 @@ export default function Sidebar() {
> >
创建原理图 创建原理图
</button> </button>
<button
className={`${styles.tab} ${activeTab === 'mapping' ? styles.activeTab : ''}`}
onClick={() => setActiveTab('mapping')}
>
模型映射
</button>
<button <button
className={`${styles.tab} ${activeTab === 'import' ? styles.activeTab : ''}`} className={`${styles.tab} ${activeTab === 'import' ? styles.activeTab : ''}`}
onClick={() => setActiveTab('import')} onClick={() => setActiveTab('import')}
...@@ -136,6 +148,107 @@ export default function Sidebar() { ...@@ -136,6 +148,107 @@ export default function Sidebar() {
</div> </div>
)} )}
{/* ==================== 模型映射面板 ==================== */}
{activeTab === 'mapping' && (() => {
const allMappings = getAllMappings();
const categories = DEVICE_CATEGORIES;
const saveMappingOverride = (type, field, value) => {
setMappingOverrides(prev => {
const next = { ...prev, [type]: { ...(prev[type] || {}), [field]: value } };
localStorage.setItem('eplan_model_mapping_overrides', JSON.stringify(next));
return next;
});
};
return (
<div className={styles.panel}>
<div className={styles.sectionTitle}>模型映射配置</div>
<div style={{ fontSize: 10, color: '#666', padding: '0 0 8px 0' }}>
符号类型 → Modelica 模型名称。修改后导出 .mo 时自动生效。
</div>
{categories.map(cat => (
<div key={cat.category} style={{ marginBottom: 12 }}>
<div style={{ fontSize: 10, fontWeight: 600, color: '#888', padding: '4px 0', borderBottom: '1px solid #1a1a2a' }}>
{cat.category}
</div>
{cat.items.map(item => {
const mapping = allMappings[item.type];
const override = mappingOverrides[item.type] || {};
const currentModel = override.modelName || mapping?.modelName || '';
return (
<div key={item.type} style={{ padding: '6px 0', borderBottom: '1px solid #111' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}>
<span style={{
display: 'inline-block', width: 6, height: 6, borderRadius: '50%',
background: item.color, flexShrink: 0,
}} />
<span style={{ fontSize: 11, color: '#ccc', minWidth: 60 }}>{item.label}</span>
<span style={{ color: '#444', fontSize: 9 }}></span>
<input
value={currentModel}
placeholder="Modelica 模型名"
onChange={(e) => saveMappingOverride(item.type, 'modelName', e.target.value)}
style={{
flex: 1, background: '#0d0d15', border: '1px solid #222',
borderRadius: 3, color: '#6366f1', fontSize: 10,
padding: '2px 6px', fontFamily: 'monospace',
}}
/>
</div>
{/* 端口映射摘要 */}
{mapping && Object.keys(mapping.portMap).length > 0 && (
<div style={{ fontSize: 9, color: '#555', paddingLeft: 14 }}>
端口: {Object.entries(mapping.portMap).map(([k, v]) => `${k}→${v}`).join(', ')}
</div>
)}
</div>
);
})}
</div>
))}
{/* 自定义符号 */}
{templates.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 10, fontWeight: 600, color: '#888', padding: '4px 0', borderBottom: '1px solid #1a1a2a' }}>
自定义符号
</div>
{templates.map(tpl => {
const override = mappingOverrides[`custom_${tpl.id}`] || {};
const currentModel = override.modelName || '';
return (
<div key={tpl.id} style={{ padding: '6px 0', borderBottom: '1px solid #111' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}>
<span style={{
display: 'inline-block', width: 6, height: 6, borderRadius: '50%',
background: tpl.color || '#666', flexShrink: 0,
}} />
<span style={{ fontSize: 11, color: '#ccc', minWidth: 60 }}>{tpl.name}</span>
<span style={{ color: '#444', fontSize: 9 }}>{'\u2192'}</span>
<input
value={currentModel}
placeholder="Modelica 模型名"
onChange={(e) => saveMappingOverride(`custom_${tpl.id}`, 'modelName', e.target.value)}
style={{
flex: 1, background: '#0d0d15', border: '1px solid #222',
borderRadius: 3, color: '#6366f1', fontSize: 10,
padding: '2px 6px', fontFamily: 'monospace',
}}
/>
</div>
{tpl.ports && tpl.ports.length > 0 && (
<div style={{ fontSize: 9, color: '#555', paddingLeft: 14 }}>
端口: {tpl.ports.map(p => p.name || p.id).join(', ')}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})()}
{/* ==================== 符号库 ==================== */} {/* ==================== 符号库 ==================== */}
{activeTab === 'library' && ( {activeTab === 'library' && (
<div className={styles.panel}> <div className={styles.panel}>
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import useFlowStore from '../../hooks/useFlowStore'; import useFlowStore from '../../hooks/useFlowStore';
import { LAYOUT_DIRECTION } from '../../utils/constants'; import { LAYOUT_DIRECTION } from '../../utils/constants';
import { downloadModelicaFile } from '../../utils/modelicaExporter';
import styles from './Toolbar.module.css'; import styles from './Toolbar.module.css';
import useComponentLibrary from '../../hooks/useComponentLibrary'; import useComponentLibrary from '../../hooks/useComponentLibrary';
...@@ -42,6 +43,24 @@ export default function Toolbar() { ...@@ -42,6 +43,24 @@ export default function Toolbar() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [exportToJSON]); }, [exportToJSON]);
const handleExportMo = useCallback(() => {
const { nodes, edges } = useFlowStore.getState();
if (nodes.length === 0) {
alert('画布为空,无法导出');
return;
}
const name = prompt('请输入 Modelica 模型名称', 'Circuit');
if (!name) return;
const { errors, warnings } = downloadModelicaFile({ nodes, edges }, name);
if (errors && errors.length > 0) {
alert(`导出失败,以下符号未建立模型映射:\n\n` + errors.join('\n') + '\n\n请先在侧边栏“模型映射”中配置对应关系。');
return;
}
if (warnings.length > 0) {
alert(`导出完成,但有 ${warnings.length} 个警告:\n\n` + warnings.join('\n'));
}
}, []);
const handleImportJSON = useCallback(() => { const handleImportJSON = useCallback(() => {
jsonInputRef.current?.click(); jsonInputRef.current?.click();
}, []); }, []);
...@@ -110,24 +129,14 @@ export default function Toolbar() { ...@@ -110,24 +129,14 @@ export default function Toolbar() {
</button> </button>
</div> </div>
<div className={styles.divider} />
<div className={styles.group}>
<button
className={styles.btn}
onClick={handleDelete}
disabled={!selectedNode && !selectedEdge}
title="删除选中项"
>
&#10005; 删除
</button>
</div>
<div className={styles.spacer} /> <div className={styles.spacer} />
<div className={styles.group}> <div className={styles.group}>
<button className={styles.btn} onClick={handleExportJSON} title="导出 JSON 文件"> <button className={styles.btn} onClick={handleExportJSON} title="导出 JSON 文件">
&#8615; 导出 &#8615; JSON
</button>
<button className={styles.btn} onClick={handleExportMo} title="导出 OpenModelica .mo 模型">
&#8615; .mo
</button> </button>
<button className={styles.btn} onClick={handleImportJSON} title="导入 JSON 文件"> <button className={styles.btn} onClick={handleImportJSON} title="导入 JSON 文件">
&#8613; 导入 &#8613; 导入
......
...@@ -98,6 +98,7 @@ const useFlowStore = create((set, get) => ({ ...@@ -98,6 +98,7 @@ const useFlowStore = create((set, get) => ({
const templateData = { const templateData = {
name: deviceType.label, name: deviceType.label,
type: deviceType.type, // 符号类型标识,用于模型映射
icon: deviceType.icon, icon: deviceType.icon,
width: defW, width: defW,
height: defH, height: defH,
......
/**
* modelMapping.js - 符号到 Modelica 模型的映射模块
*
* 将 EPLAN Visualizer 中的符号类型映射为 Modelica 模型名称和端口名称。
* 导出 .mo 时使用此模块确定实例类型和端口标识。
*/
/**
* 模型映射表
* type : EPLAN 符号类型(constants.js 中的 type 字段)
* modelName : Modelica 模型名称(用于 .mo 声明)
* portMap : EPLAN 端口 id → Modelica 端口名
* paramMap : EPLAN 参数 key → Modelica 参数名
*/
const MODEL_MAP = {
// ===== 基本电子元件 =====
resistor: {
modelName: 'Resistor',
portMap: { 'p1': 'p', 'p2': 'n' },
paramMap: { R: 'R', P: 'P_nominal' },
},
capacitor: {
modelName: 'Capacitor',
portMap: { 'p-pos': 'p', 'p-neg': 'n' },
paramMap: { C: 'C', V: 'V_nominal' },
},
inductor: {
modelName: 'Inductor',
portMap: { 'p1': 'p', 'p2': 'n' },
paramMap: { L: 'L' },
},
diode: {
modelName: 'Diode',
portMap: { 'p-a': 'p', 'p-k': 'n' },
paramMap: {},
},
voltage_source: {
modelName: 'VoltageSource',
portMap: { 'p-pos': 'p', 'p-neg': 'n' },
paramMap: { V: 'V' },
},
ground: {
modelName: 'Ground',
portMap: { 'p-gnd': 'p' },
paramMap: {},
},
// ===== 电气控制 =====
terminal: {
modelName: 'Terminal',
portMap: { 'p-in-1': 'p_in', 'p-out-1': 'p_out' },
paramMap: {},
},
contactor: {
modelName: 'Contactor',
portMap: { 'p-l1': 'L1', 'p-l2': 'L2', 'p-l3': 'L3', 'p-t1': 'T1', 'p-t2': 'T2', 'p-t3': 'T3', 'p-a1': 'A1', 'p-a2': 'A2' },
paramMap: {},
},
relay: {
modelName: 'Relay',
portMap: { 'p-a1': 'A1', 'p-a2': 'A2', 'p-11': 'p11', 'p-14': 'p14' },
paramMap: {},
},
breaker: {
modelName: 'CircuitBreaker',
portMap: { 'p-in': 'p_in', 'p-out': 'p_out' },
paramMap: { In: 'I_nominal' },
},
switch: {
modelName: 'Switch',
portMap: { 'p-com': 'COM', 'p-no': 'NO', 'p-nc': 'NC' },
paramMap: {},
},
motor: {
modelName: 'Motor',
portMap: { 'p-u': 'U', 'p-v': 'V', 'p-w': 'W', 'p-pe': 'PE' },
paramMap: { Pw: 'P_nominal', Rpm: 'n_nominal' },
},
transformer: {
modelName: 'Transformer',
portMap: { 'p-pri1': 'p1', 'p-pri2': 'n1', 'p-sec1': 'p2', 'p-sec2': 'n2' },
paramMap: {},
},
sensor: {
modelName: 'Sensor',
portMap: { 'p-vcc': 'VCC', 'p-gnd': 'GND', 'p-sig': 'SIG' },
paramMap: {},
},
cable: {
modelName: 'Cable',
portMap: { 'p-in': 'p_in', 'p-out': 'p_out' },
paramMap: {},
},
// ===== PLC =====
plc_cpu: {
modelName: 'PLC_CPU',
portMap: { 'p-24v': 'V24', 'p-0v': 'V0', 'p-di1': 'DI1', 'p-di2': 'DI2', 'p-di3': 'DI3', 'p-do1': 'DO1', 'p-do2': 'DO2', 'p-do3': 'DO3' },
paramMap: {},
},
plc_di: {
modelName: 'PLC_DI',
portMap: { 'p-di1': 'DI1', 'p-di2': 'DI2', 'p-di3': 'DI3', 'p-di4': 'DI4', 'p-com': 'COM' },
paramMap: {},
},
plc_do: {
modelName: 'PLC_DO',
portMap: { 'p-do1': 'DO1', 'p-do2': 'DO2', 'p-do3': 'DO3', 'p-do4': 'DO4', 'p-com': 'COM' },
paramMap: {},
},
plc_ai: {
modelName: 'PLC_AI',
portMap: { 'p-ai1': 'AI1', 'p-ai2': 'AI2', 'p-com': 'COM' },
paramMap: {},
},
plc_ao: {
modelName: 'PLC_AO',
portMap: { 'p-ao1': 'AO1', 'p-ao2': 'AO2', 'p-com': 'COM' },
paramMap: {},
},
};
/**
* 获取符号类型对应的模型映射
* @param {string} type - 符号类型
* @returns {Object|null} - { modelName, portMap, paramMap } 或 null
*/
export function getModelMapping(type) {
return MODEL_MAP[type] || null;
}
/**
* 获取所有可用的模型映射
* @returns {Object} - 完整映射表
*/
export function getAllMappings() {
return { ...MODEL_MAP };
}
/**
* 将 EPLAN 端口 id (可能带 counter 后缀) 还原为原始 portId,
* 然后通过映射表转为 Modelica 端口名
* @param {string} handleId - React Flow handle id (如 "p-neg-3")
* @param {string} type - 符号类型
* @param {Array} ports - 节点的端口列表
* @returns {string} - Modelica 端口名
*/
export function resolvePortName(handleId, type, ports) {
const mapping = MODEL_MAP[type];
// 先尝试从端口列表中找到匹配的 portId
if (ports && ports.length > 0) {
const port = ports.find(p => p.id === handleId);
if (port) {
const originalId = port.portId || port.id;
// 查映射表
if (mapping && mapping.portMap[originalId]) {
return mapping.portMap[originalId];
}
// 用端口 name 作为回退
return port.name || originalId;
}
}
// 回退:尝试从 handleId 中提取原始 portId(去掉 -counter 后缀)
const match = handleId?.match(/^(.+?)-\d+$/);
const rawId = match ? match[1] : handleId;
if (mapping && mapping.portMap[rawId]) {
return mapping.portMap[rawId];
}
return rawId || handleId;
}
export default MODEL_MAP;
/**
* modelicaExporter.js - 将原理图 JSON 导出为 OpenModelica .mo 模型
*
* 使用 modelMapping.js 中的映射表进行类型和端口转换。
*/
import { getModelMapping, resolvePortName } from './modelMapping';
// ===== 工程记号解析 =====
const SI_PREFIXES = {
'T': 1e12, 'G': 1e9, 'M': 1e6, 'k': 1e3, 'K': 1e3,
'm': 1e-3, 'u': 1e-6, 'μ': 1e-6, 'n': 1e-9, 'p': 1e-12,
};
/**
* 解析工程记号字符串为数值
* "10k" → 10000, "4.7k" → 4700, "100u" → 0.0001, "24" → 24
*/
function parseEngValue(str) {
if (str == null || str === '') return null;
str = String(str).trim();
const num = Number(str);
if (!isNaN(num)) return num;
const match = str.match(/^([+-]?\d*\.?\d+)\s*([a-zA-Zμ])/);
if (match) {
const val = parseFloat(match[1]);
const prefix = match[2];
const multiplier = SI_PREFIXES[prefix];
if (multiplier != null) return val * multiplier;
}
return null;
}
/** 数值转 Modelica 格式 */
function formatMoValue(num) {
if (num === 0) return '0';
const abs = Math.abs(num);
if (abs >= 1e6) return num.toExponential();
if (abs >= 0.01) return String(Number(num.toPrecision(6)));
return num.toExponential();
}
/** 生成合法 Modelica 标识符 */
function toMoId(str) {
let id = str.replace(/[^a-zA-Z0-9_]/g, '_');
if (/^[0-9]/.test(id)) id = '_' + id;
return id;
}
// ===== 核心导出函数 =====
/**
* 将 { nodes, edges } 转为 OpenModelica .mo 模型字符串
* @param {Object} data - { nodes: [], edges: [] }
* @param {string} modelName - 模型名称
* @returns {{ code: string, warnings: string[] }}
*/
export function exportToModelica(data, modelName = 'Circuit') {
const { nodes = [], edges = [] } = data;
const warnings = [];
const errors = [];
const lines = [];
const instanceMap = {}; // nodeId → { instanceName, type, ports }
// 读取用户自定义的映射覆盖
let mappingOverrides = {};
try {
mappingOverrides = JSON.parse(localStorage.getItem('eplan_model_mapping_overrides') || '{}');
} catch { /* ignore */ }
lines.push(`model ${toMoId(modelName)}`);
lines.push('');
// ===== 1. 组件声明 =====
nodes.forEach((node, idx) => {
const td = node.data?.templateData;
const type = td?.type;
const label = node.data?.label || `comp_${idx}`;
const instanceName = toMoId(label) + '_' + (idx + 1);
instanceMap[node.id] = {
instanceName,
type,
ports: td?.ports || [],
};
const mapping = getModelMapping(type);
if (!mapping) {
// 检查用户自定义映射(自定义符号用 custom_${id} 作为 key)
const customKey = td?.id ? `custom_${td.id}` : type;
const customOverride = customKey ? mappingOverrides[customKey] : null;
if (customOverride && customOverride.modelName) {
// 有自定义映射:使用用户配置的模型名
const paramParts = [];
(td?.params || []).forEach(p => {
const val = node.data?.paramValues?.[p.key] ?? p.defaultValue;
if (val != null && val !== '') {
const numVal = parseEngValue(val);
paramParts.push(`${p.key}=${numVal != null ? formatMoValue(numVal) : val}`);
}
});
const paramStr = paramParts.length > 0 ? `(${paramParts.join(', ')})` : '';
lines.push(` ${customOverride.modelName} ${instanceName}${paramStr};`);
return;
}
errors.push(`"${label}" (type: ${type || 'custom'}) 未建立模型映射,请先在“模型映射”中配置`);
return;
}
// 有映射:使用模型名称声明
const paramParts = [];
const pv = node.data?.paramValues || {};
Object.entries(mapping.paramMap).forEach(([eplanKey, moName]) => {
const rawVal = pv[eplanKey];
if (rawVal != null && rawVal !== '') {
const numVal = parseEngValue(rawVal);
if (numVal != null) {
paramParts.push(`${moName}=${formatMoValue(numVal)}`);
} else {
paramParts.push(`${moName}=${rawVal}`);
warnings.push(`"${label}".${eplanKey} = "${rawVal}" 无法解析为数值`);
}
}
});
const paramStr = paramParts.length > 0 ? `(${paramParts.join(', ')})` : '';
// 优先使用用户覆盖的模型名
const finalModelName = mappingOverrides[type]?.modelName || mapping.modelName;
lines.push(` ${finalModelName} ${instanceName}${paramStr};`);
});
lines.push('');
// ===== 2. connect 方程 =====
lines.push('equation');
edges.forEach(edge => {
const srcInfo = instanceMap[edge.source];
const tgtInfo = instanceMap[edge.target];
if (!srcInfo || !tgtInfo) {
warnings.push(`连接 ${edge.id} 的源或目标节点未找到`);
return;
}
// 通过映射模块解析端口名
const srcPort = resolvePortName(edge.sourceHandle, srcInfo.type, srcInfo.ports);
const tgtPort = resolvePortName(edge.targetHandle, tgtInfo.type, tgtInfo.ports);
lines.push(` connect(${srcInfo.instanceName}.${srcPort}, ${tgtInfo.instanceName}.${tgtPort});`);
});
lines.push('');
// ===== 3. annotation =====
if (nodes.length > 0) {
lines.push(' annotation(Diagram(coordinateSystem(preserveAspectRatio=false,');
lines.push(' extent={{-200,-200},{800,800}})));');
}
lines.push(`end ${toMoId(modelName)};`);
return { code: lines.join('\n'), warnings, errors };
}
/**
* 导出并下载 .mo 文件
*/
export function downloadModelicaFile(data, modelName = 'Circuit') {
const { code, warnings, errors } = exportToModelica(data, modelName);
if (errors.length > 0) {
return { errors, warnings, downloaded: false };
}
if (warnings.length > 0) {
console.warn('[Modelica Export] 警告:', warnings);
}
const blob = new Blob([code], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${toMoId(modelName)}.mo`;
a.click();
URL.revokeObjectURL(url);
return { errors: [], warnings, downloaded: true };
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment