diff --git a/Class1.cs b/Class1.cs index a85f9e6..5c4ff15 100644 --- a/Class1.cs +++ b/Class1.cs @@ -1,7 +1,9 @@ using Aveva.ApplicationFramework; using System; using System.Windows.Forms; +using Aveva.ApplicationFramework; using TellmePdmsPluging.Network; +using TellmePdmsPluging.Core; namespace TellmePdmsPluging { @@ -35,10 +37,14 @@ namespace TellmePdmsPluging { // 记录启动日志 LogMessage("开始启动TellmePdms插件..."); + + SafeQueue.Init(); // 启动HTTP服务器 _httpServer = new HttpServer(9001); _httpServer.Start(); + + Application.Idle += OnIdle; // 弹出成功提示 MessageBox.Show( @@ -69,6 +75,8 @@ namespace TellmePdmsPluging try { LogMessage("开始停止TellmePdms插件..."); + + Application.Idle -= OnIdle; // 停止HTTP服务器 if (_httpServer != null) @@ -86,6 +94,21 @@ namespace TellmePdmsPluging } } + private void OnIdle(object sender, EventArgs e) + { + try + { + while (SafeQueue.TryDequeue(out ICommand cmd)) + { + cmd.Execute(); + } + } + catch (Exception ex) + { + LogMessage($"Idle执行命令时出错: {ex.Message}"); + } + } + private void LogMessage(string message) { try diff --git a/Commands/OpenMdbCommand.cs b/Commands/OpenMdbCommand.cs new file mode 100644 index 0000000..9ea1228 --- /dev/null +++ b/Commands/OpenMdbCommand.cs @@ -0,0 +1,41 @@ +using System; +using TellmePdmsPluging.Core; +using TellmePdmsPluging.Models; + +namespace TellmePdmsPluging.Commands +{ + public class OpenMdbCommand : ICommand + { + public OpenMdbCommand(OpenMdbRequest request) + { + Request = request ?? new OpenMdbRequest(); + CommandId = Guid.NewGuid().ToString("N"); + } + + public string CommandId { get; } + + public string CommandType + { + get { return "OpenMDB"; } + } + + public bool CanCancel + { + get { return false; } + } + + public OpenMdbRequest Request { get; } + + public object Execute() + { + Request.ApplyDefaults(); + return PdmsManager.Instance.OpenMdb(Request); + } + + public void Cancel() + { + throw new NotSupportedException("OpenMdbCommand 不支持取消"); + } + } +} + diff --git a/Commands/OpenProjectCommand.cs b/Commands/OpenProjectCommand.cs new file mode 100644 index 0000000..16c4fa1 --- /dev/null +++ b/Commands/OpenProjectCommand.cs @@ -0,0 +1,41 @@ +using System; +using TellmePdmsPluging.Core; +using TellmePdmsPluging.Models; + +namespace TellmePdmsPluging.Commands +{ + public class OpenProjectCommand : ICommand + { + public OpenProjectCommand(OpenProjectRequest request) + { + Request = request ?? new OpenProjectRequest(); + CommandId = Guid.NewGuid().ToString("N"); + } + + public string CommandId { get; } + + public string CommandType + { + get { return "OpenProject"; } + } + + public bool CanCancel + { + get { return false; } + } + + public OpenProjectRequest Request { get; } + + public object Execute() + { + Request.ApplyDefaults(); + return PdmsManager.Instance.OpenProject(Request); + } + + public void Cancel() + { + throw new NotSupportedException("OpenProjectCommand 不支持取消"); + } + } +} + diff --git a/Commands/ShrinkwrapModelCommand.cs b/Commands/ShrinkwrapModelCommand.cs new file mode 100644 index 0000000..12cc7a0 --- /dev/null +++ b/Commands/ShrinkwrapModelCommand.cs @@ -0,0 +1,39 @@ +using TellmePdmsPluging.Core; +using TellmePdmsPluging.Models; + +namespace TellmePdmsPluging.Commands +{ + public class ShrinkwrapModelCommand : ICommand + { + public ShrinkwrapModelCommand(ShrinkwrapModelRequest request) + { + Request = request ?? new ShrinkwrapModelRequest(); + CommandId = System.Guid.NewGuid().ToString("N"); + } + + public string CommandId { get; } + + public string CommandType + { + get { return "ShrinkwrapModel"; } + } + + public bool CanCancel + { + get { return false; } + } + + public ShrinkwrapModelRequest Request { get; } + + public object Execute() + { + Request.ApplyDefaults(); + return PdmsManager.Instance.ShrinkwrapModel(Request); + } + + public void Cancel() + { + throw new System.NotSupportedException("ShrinkwrapModelCommand 不支持取消"); + } + } +} diff --git a/Core/MainThreadInvoker.cs b/Core/MainThreadInvoker.cs new file mode 100644 index 0000000..d4dda25 --- /dev/null +++ b/Core/MainThreadInvoker.cs @@ -0,0 +1,108 @@ +using System; +using System.Threading; + +namespace TellmePdmsPluging.Core +{ + public static class MainThreadInvoker + { + public static InvokeResult Invoke(ICommand command, int timeoutMs) + { + if (command == null) + { + return new InvokeResult(false, null, new ArgumentNullException("command"), "command为空"); + } + + var wrapper = new ResultCommandWrapper(command); + SafeQueue.Enqueue(wrapper); + + bool signaled = wrapper.Wait(timeoutMs); + if (!signaled) + { + return new InvokeResult(false, null, null, "等待PDMS主线程执行超时"); + } + + if (wrapper.Error != null) + { + return new InvokeResult(false, null, wrapper.Error, wrapper.Error.Message); + } + + return new InvokeResult(true, wrapper.Result, null, null); + } + + private class ResultCommandWrapper : IResultCommand + { + private readonly ICommand _inner; + private readonly ManualResetEvent _done; + + public ResultCommandWrapper(ICommand inner) + { + _inner = inner; + _done = new ManualResetEvent(false); + CommandId = inner.CommandId; + } + + public string CommandId { get; } + + public string CommandType + { + get { return _inner.CommandType; } + } + + public bool CanCancel + { + get { return _inner.CanCancel; } + } + + public object Result { get; private set; } + + public Exception Error { get; private set; } + + public bool IsCompleted { get; private set; } + + public object Execute() + { + try + { + Result = _inner.Execute(); + return Result; + } + catch (Exception ex) + { + Error = ex; + return null; + } + finally + { + IsCompleted = true; + _done.Set(); + } + } + + public void Cancel() + { + _inner.Cancel(); + } + + public bool Wait(int timeoutMs) + { + return _done.WaitOne(timeoutMs); + } + } + } + + public class InvokeResult + { + public InvokeResult(bool success, object result, Exception error, string message) + { + Success = success; + Result = result; + Error = error; + Message = message; + } + + public bool Success { get; } + public object Result { get; } + public Exception Error { get; } + public string Message { get; } + } +} diff --git a/Core/PdmsManager.cs b/Core/PdmsManager.cs index 394e43b..2438160 100644 --- a/Core/PdmsManager.cs +++ b/Core/PdmsManager.cs @@ -4,6 +4,7 @@ using TellmePdmsPluging.Models; using Aveva.ApplicationFramework; using Aveva.Pdms.Database; using System.Linq; +using System; namespace TellmePdmsPluging.Core { @@ -120,6 +121,95 @@ namespace TellmePdmsPluging.Core } } + public ShrinkwrapModelResult ShrinkwrapModel(ShrinkwrapModelRequest request) + { + var effectiveRequest = request ?? new ShrinkwrapModelRequest(); + effectiveRequest.ApplyDefaults(); + + var result = new ShrinkwrapModelResult + { + DryRun = effectiveRequest.DryRun, + Padding = effectiveRequest.Padding, + StartedAt = DateTime.Now + }; + + try + { + if (!IsPdmsConnected()) + { + result.Success = false; + result.CompletedAt = DateTime.Now; + result.Message = "PDMS 未连接"; + result.Errors.Add("PDMS 未连接"); + return result; + } + + var currentMdb = MDB.CurrentMDB; + var designDb = currentMdb?.GetFirstDB(DbType.Design); + if (designDb == null || designDb.World == null) + { + result.Success = false; + result.CompletedAt = DateTime.Now; + result.Message = "未找到有效的设计数据库"; + result.Errors.Add("未找到有效的设计数据库"); + return result; + } + + var context = new ShrinkwrapContext(effectiveRequest, result); + var sites = designDb.World.Members(); + bool processed = false; + + if (sites != null) + { + foreach (var site in sites) + { + if (!IsElementValid(site) || !string.Equals(GetElementTypeName(site), "SITE", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var zones = site.Members(); + if (zones == null) + { + continue; + } + + foreach (var zone in zones) + { + if (!IsElementValid(zone) || !string.Equals(GetElementTypeName(zone), "ZONE", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!context.ShouldProcessZone(zone)) + { + continue; + } + + processed = true; + context.EnterZone(zone); + context.ProcessZone(zone); + } + } + } + + result.Success = processed && result.Errors.Count == 0; + result.Message = processed + ? (result.DryRun ? "外壳保留干跑完成" : "外壳保留完成") + : "未找到符合过滤条件的Zone"; + result.CompletedAt = DateTime.Now; + return result; + } + catch (Exception ex) + { + result.Success = false; + result.Errors.Add(ex.Message); + result.Message = "外壳保留失败"; + result.CompletedAt = DateTime.Now; + return result; + } + } + public ModelStatusResponse GetModelStatus() { try @@ -152,6 +242,253 @@ namespace TellmePdmsPluging.Core } } + public OpenProjectResult OpenProject(OpenProjectRequest request) + { + var effectiveRequest = request ?? new OpenProjectRequest(); + effectiveRequest.ApplyDefaults(); + + var result = new OpenProjectResult + { + ProjectName = effectiveRequest.ProjectName, + CompletedAt = DateTime.Now + }; + + try + { + var currentProject = Project.CurrentProject; + if (currentProject == null) + { + result.Success = false; + result.Message = "Project.CurrentProject 为空,无法打开项目"; + return result; + } + + bool alreadyOpen = false; + try + { + alreadyOpen = currentProject.IsOpen(); + } + catch + { + alreadyOpen = false; + } + + result.WasAlreadyOpen = alreadyOpen; + + // 如果已经打开且未指定目标项目名,则直接返回成功 + if (alreadyOpen && string.IsNullOrEmpty(effectiveRequest.ProjectName)) + { + result.Success = true; + result.Message = "项目已打开"; + return result; + } + + if (string.IsNullOrEmpty(effectiveRequest.ProjectName)) + { + result.Success = false; + result.Message = "ProjectName 不能为空"; + return result; + } + + bool opened = currentProject.Open( + effectiveRequest.ProjectName, + effectiveRequest.UserName ?? string.Empty, + effectiveRequest.Password ?? string.Empty); + + result.Success = opened; + result.Message = opened ? "项目打开成功" : "项目打开失败(Open 返回 false)"; + result.CompletedAt = DateTime.Now; + return result; + } + catch (Exception ex) + { + result.Success = false; + result.Message = "项目打开异常: " + ex.Message; + result.CompletedAt = DateTime.Now; + return result; + } + } + + public OpenMdbResult OpenMdb(OpenMdbRequest request) + { + var effectiveRequest = request ?? new OpenMdbRequest(); + effectiveRequest.ApplyDefaults(); + + var result = new OpenMdbResult + { + MdbName = effectiveRequest.MdbName, + ReadOnly = effectiveRequest.ReadOnly, + DefaultType = effectiveRequest.DefaultType, + CompletedAt = DateTime.Now + }; + + try + { + if (string.IsNullOrEmpty(effectiveRequest.MdbName)) + { + result.Success = false; + result.Message = "MdbName 不能为空"; + return result; + } + + // 如果当前MDB已经是目标,则直接返回成功 + try + { + var current = MDB.CurrentMDB; + if (current != null && string.Equals(current.Name, effectiveRequest.MdbName, StringComparison.OrdinalIgnoreCase)) + { + result.Success = true; + result.WasAlreadyOpen = true; + result.Message = "MDB 已经打开"; + result.CompletedAt = DateTime.Now; + return result; + } + } + catch + { + // ignore, continue to open + } + + var setup = MDBSetup.CreateMDBSetup(effectiveRequest.MdbName); + if (setup == null) + { + result.Success = false; + result.Message = "创建 MDBSetup 失败"; + return result; + } + + setup.ReadOnly = effectiveRequest.ReadOnly; + + if (!string.IsNullOrEmpty(effectiveRequest.Stamp)) + { + setup.Stamp = effectiveRequest.Stamp; + } + + if (effectiveRequest.Subtype.HasValue) + { + setup.Subtype = effectiveRequest.Subtype.Value; + } + + if (!string.IsNullOrEmpty(effectiveRequest.DefaultType)) + { + DbType defaultType; + if (!TryParseDbType(effectiveRequest.DefaultType, out defaultType)) + { + result.Success = false; + result.Message = "DefaultType 无效: " + effectiveRequest.DefaultType; + return result; + } + setup.DefaultType = defaultType; + } + + if (effectiveRequest.ReadTypes != null && effectiveRequest.ReadTypes.Count > 0) + { + DbType[] readTypes; + string error; + if (!TryParseDbTypes(effectiveRequest.ReadTypes, out readTypes, out error)) + { + result.Success = false; + result.Message = "ReadTypes 无效: " + error; + return result; + } + setup.ReadTypes = readTypes; + } + + if (effectiveRequest.WriteTypes != null && effectiveRequest.WriteTypes.Count > 0) + { + DbType[] writeTypes; + string error; + if (!TryParseDbTypes(effectiveRequest.WriteTypes, out writeTypes, out error)) + { + result.Success = false; + result.Message = "WriteTypes 无效: " + error; + return result; + } + setup.WriteTypes = writeTypes; + } + + var openedMdb = Project.OpenMDB(setup); + if (openedMdb == null) + { + result.Success = false; + result.Message = "打开 MDB 失败(OpenMDB 返回 null)"; + result.CompletedAt = DateTime.Now; + return result; + } + + result.Success = true; + result.WasAlreadyOpen = false; + result.MdbName = openedMdb.Name; + result.Message = "MDB 打开成功"; + result.CompletedAt = DateTime.Now; + return result; + } + catch (Exception ex) + { + result.Success = false; + result.Message = "打开 MDB 异常: " + ex.Message; + result.CompletedAt = DateTime.Now; + return result; + } + } + + private static bool TryParseDbTypes(IEnumerable values, out DbType[] types, out string error) + { + var list = new List(); + var invalid = new List(); + + foreach (var raw in values) + { + if (string.IsNullOrEmpty(raw)) + { + continue; + } + + DbType parsed; + if (TryParseDbType(raw, out parsed)) + { + if (!list.Contains(parsed)) + { + list.Add(parsed); + } + } + else + { + invalid.Add(raw); + } + } + + if (invalid.Count > 0) + { + types = null; + error = string.Join(",", invalid.ToArray()); + return false; + } + + types = list.ToArray(); + error = null; + return true; + } + + private static bool TryParseDbType(string value, out DbType dbType) + { + dbType = DbType.Design; + if (string.IsNullOrEmpty(value)) + { + return false; + } + + try + { + dbType = (DbType)Enum.Parse(typeof(DbType), value.Trim(), true); + return true; + } + catch + { + return false; + } + } + private bool IsPdmsConnected() { try @@ -567,5 +904,266 @@ namespace TellmePdmsPluging.Core } } } + + private class ShrinkwrapContext + { + private const int MaxRemovedSnapshots = 200; + + public ShrinkwrapContext(ShrinkwrapModelRequest request, ShrinkwrapModelResult result) + { + Request = request; + Result = result; + _keepTypes = new HashSet(request.KeepTypes ?? new List()); + } + + public ShrinkwrapModelRequest Request { get; } + public ShrinkwrapModelResult Result { get; } + public string CurrentZoneName { get; private set; } + + private readonly HashSet _keepTypes; + private float[] _innerBox; + + public bool ShouldProcessZone(DbElement zone) + { + if (Request.ZoneFilters == null || Request.ZoneFilters.Count == 0) + { + return true; + } + + var zonePath = BuildElementPath(zone).ToUpperInvariant(); + return Request.ZoneFilters.Any(filter => zonePath.Contains(filter)); + } + + public void EnterZone(DbElement zone) + { + CurrentZoneName = BuildElementPath(zone); + if (!Result.ZoneSummaries.Contains(CurrentZoneName)) + { + Result.ZoneSummaries.Add(CurrentZoneName); + } + } + + public void ProcessZone(DbElement zone) + { + float[] zoneBox; + if (!TryGetLimitsBox(zone, out zoneBox)) + { + Result.Errors.Add($"Zone包围盒计算失败: {BuildElementPath(zone)}"); + return; + } + + _innerBox = BuildInnerBox(zoneBox, (float)Request.Padding); + ProcessChildren(zone); + } + + private void ProcessChildren(DbElement parent) + { + var members = parent.Members(); + if (members == null) + { + return; + } + + foreach (var child in members) + { + ProcessElement(child); + } + } + + private void ProcessElement(DbElement element) + { + if (!IsElementValid(element)) + { + return; + } + + Result.TotalVisited++; + + var typeName = GetElementTypeName(element); + var normalizedType = string.IsNullOrEmpty(typeName) ? string.Empty : typeName.ToUpperInvariant(); + + if (_keepTypes.Contains(normalizedType)) + { + Result.KeptCount++; + ProcessChildren(element); + return; + } + + float[] box; + if (!TryGetLimitsBox(element, out box)) + { + Result.KeptCount++; + return; + } + + if (IsInsideInnerBox(box, _innerBox, (float)Request.TouchTolerance)) + { + RemoveElement(element, normalizedType); + return; + } + + Result.ShellKeptCount++; + Result.KeptCount++; + ProcessChildren(element); + } + + private void RemoveElement(DbElement element, string typeName) + { + var elementPath = BuildElementPath(element); + + if (Request.DryRun) + { + SnapshotRemoval(elementPath, typeName, true); + return; + } + + try + { + element.Delete(); + SnapshotRemoval(elementPath, typeName, false); + } + catch (Exception ex) + { + Result.Errors.Add($"删除元素 {elementPath} 失败: {ex.Message}"); + } + } + + private void SnapshotRemoval(string elementPath, string typeName, bool dryRun) + { + Result.RemovedCount++; + + if (Result.RemovedElements.Count < MaxRemovedSnapshots) + { + var marker = dryRun ? "DRY" : "DEL"; + Result.RemovedElements.Add($"[{marker}] {elementPath} ({typeName})"); + } + } + + private static bool TryGetLimitsBox(DbElement element, out float[] box) + { + box = null; + try + { + var spatial = Spatial.Instance; + if (spatial == null) + { + return false; + } + + Aveva.Pdms.Geometry.LimitsBox limits; + var ok = spatial.LimitsBox(element, out limits); + if (!ok) + { + return false; + } + + box = ConvertLimitsBox(limits); + return box != null && box.Length >= 6; + } + catch + { + return false; + } + } + + private static float[] ConvertLimitsBox(Aveva.Pdms.Geometry.LimitsBox limits) + { + // 兼容不同版本 PDMS LimitsBox 字段/属性命名 + // 期望输出: [xmin, ymin, zmin, xmax, ymax, zmax] + try + { + var type = typeof(Aveva.Pdms.Geometry.LimitsBox); + var boxed = (object)limits; + + float xmin = ReadFloat(boxed, type, new[] { "XMIN", "XMin", "MinX", "Xmin" }); + float ymin = ReadFloat(boxed, type, new[] { "YMIN", "YMin", "MinY", "Ymin" }); + float zmin = ReadFloat(boxed, type, new[] { "ZMIN", "ZMin", "MinZ", "Zmin" }); + float xmax = ReadFloat(boxed, type, new[] { "XMAX", "XMax", "MaxX", "Xmax" }); + float ymax = ReadFloat(boxed, type, new[] { "YMAX", "YMax", "MaxY", "Ymax" }); + float zmax = ReadFloat(boxed, type, new[] { "ZMAX", "ZMax", "MaxZ", "Zmax" }); + + return new[] { xmin, ymin, zmin, xmax, ymax, zmax }; + } + catch + { + return null; + } + } + + private static float ReadFloat(object boxed, System.Type type, string[] names) + { + foreach (var name in names) + { + var prop = type.GetProperty(name); + if (prop != null) + { + var v = prop.GetValue(boxed, null); + if (v != null) + { + return Convert.ToSingle(v); + } + } + + var field = type.GetField(name); + if (field != null) + { + var v = field.GetValue(boxed); + if (v != null) + { + return Convert.ToSingle(v); + } + } + } + + throw new InvalidOperationException("LimitsBox缺少必要字段/属性"); + } + + private static float[] BuildInnerBox(float[] outerBox, float padding) + { + var inner = new float[6]; + inner[0] = outerBox[0] + padding; + inner[1] = outerBox[1] + padding; + inner[2] = outerBox[2] + padding; + inner[3] = outerBox[3] - padding; + inner[4] = outerBox[4] - padding; + inner[5] = outerBox[5] - padding; + + if (inner[3] < inner[0]) + { + inner[0] = outerBox[0]; + inner[3] = outerBox[3]; + } + + if (inner[4] < inner[1]) + { + inner[1] = outerBox[1]; + inner[4] = outerBox[4]; + } + + if (inner[5] < inner[2]) + { + inner[2] = outerBox[2]; + inner[5] = outerBox[5]; + } + + return inner; + } + + private static bool IsInsideInnerBox(float[] box, float[] innerBox, float tolerance) + { + if (box == null || innerBox == null || box.Length < 6 || innerBox.Length < 6) + { + return false; + } + + // box完全在innerBox内部(留tolerance避免贴边误删) + return (box[0] > innerBox[0] + tolerance) && + (box[1] > innerBox[1] + tolerance) && + (box[2] > innerBox[2] + tolerance) && + (box[3] < innerBox[3] - tolerance) && + (box[4] < innerBox[4] - tolerance) && + (box[5] < innerBox[5] - tolerance); + } + } } } \ No newline at end of file diff --git a/Core/SafeQueue.cs b/Core/SafeQueue.cs new file mode 100644 index 0000000..7ff7b03 --- /dev/null +++ b/Core/SafeQueue.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System; +using System.Collections.Generic; + +namespace TellmePdmsPluging.Core +{ + public static class SafeQueue + { + private static readonly object _sync = new object(); + private static Queue _queue; + + public static void Init() + { + if (_queue != null) + { + return; + } + + lock (_sync) + { + if (_queue == null) + { + _queue = new Queue(); + } + } + } + + public static void Enqueue(ICommand command) + { + if (command == null) + { + return; + } + + Init(); + + lock (_sync) + { + _queue.Enqueue(command); + } + } + + public static bool TryDequeue(out ICommand command) + { + Init(); + + lock (_sync) + { + if (_queue.Count > 0) + { + command = _queue.Dequeue(); + return true; + } + } + + command = null; + return false; + } + } + + public interface IResultCommand : ICommand + { + object Result { get; } + Exception Error { get; } + bool IsCompleted { get; } + } +} diff --git a/Documentation/ProjectArchitecture.md b/Documentation/ProjectArchitecture.md new file mode 100644 index 0000000..edde4ad --- /dev/null +++ b/Documentation/ProjectArchitecture.md @@ -0,0 +1,125 @@ + # Tellme PDMS 插件技术架构文档 + +## 1. 项目概述 +- **目标**:为单个 AVEVA PDMS 12.1 SP4 会话提供远程监控与操作能力,实现前端客户端通过 HTTP 接口安全地获取模型状态并下达任务。 +- **运行环境**:.NET Framework 3.5(x86),遵循 PDMS 插件加载约束;前端与插件运行在同一主机,通信默认局限于本地回环接口。 +- **核心原则**:主线程执行 PDMS API、命令模式分离网络与业务、无硬编码数据、兼顾大模型性能与资源占用。 + +## 2. 系统总体架构 + +```mermaid +graph LR + A[前端客户端] -->|HTTP/JSON| B[HttpServer] + B -->|入队| C[SafeQueue] + C -->|出队| D[PDMS 主线程] + D --> E[Aveva PDMS API] + D -->|结果| B + B -->|响应| A +``` + +- **入口插件 (`TellmePdmsAddin`)**:实现 `IAddin` 接口,在 PDMS 启动时加载,负责生命周期管理和日志记录。 +- **网络层 (`Network.HttpServer`)**:基于 `System.Net.HttpListener` 自托管 HTTP 服务,路由健康检查与业务接口。 +- **命令层 (`Core.ICommand` 系列)**:每个请求解析为命令对象入队,支持可取消性扩展。 +- **业务层 (`Core.PdmsManager`)**:封装对 `MDB.CurrentMDB`、`Project.CurrentProject`、`DbElement` 等 API 的访问,生成结构化数据。 +- **模型层 (`Models.*`)**:定义 `ModelStatusResponse`、`ProjectInfo`、`ModelStatistics`、`SessionInfo` 等 DTO 以便序列化。 +- **工具层**:提供线程安全队列、日志、序列化和配置扩展点(`Utils` 目录预留)。 + +## 3. 关键技术栈 + +| 分类 | 选型 | 理由 | +| --- | --- | --- | +| 插件框架 | `Aveva.ApplicationFramework.IAddin` | 满足 PDMS 扩展规范,允许在主线程注册 Idle 事件 | +| 语言与平台 | C# + .NET Framework 3.5 (x86) | 与 PDMS 12.1 SP4 兼容,仅 32 位可加载 | +| 网络通信 | `System.Net.HttpListener` | BCL 自带、无第三方依赖、支持本地自托管 | +| 序列化 | 自定义轻量 JSON 序列化 | 兼容 .NET 3.5,避免外部依赖,可替换为 `System.Web.Script.Serialization` | +| 队列实现 | `Queue` + `lock` 封装 | .NET 3.5 无 `ConcurrentQueue`,需手动保证线程安全 | +| 日志 | 文本文件 (`C:\temp\*.txt`) | 易于部署与排查,可替换为 log4net | +| 单元测试(预留) | NUnit 2.x | 支持 .NET 3.5,便于未来补充测试 | + +## 4. 主要模块说明 + +### 4.1 插件入口 `TellmePdmsAddin` +- `Start(ServiceManager)`:初始化 `HttpServer`、记录启动日志、提示监听端口,并可在此处加载配置或注册 UI 菜单。 +- `Stop()`:释放服务器资源并记录停止日志。 +- `Application.Idle` 事件在其他模块注册,用于在 PDMS 主线程执行命令队列。 + +### 4.2 网络服务 `Network.HttpServer` +- 构造函数注入端口(默认 9001),仅允许 `http://localhost:/`。 +- `Start()`/`Stop()` 控制 `HttpListener` 生命周期,利用 `BeginGetContext` 异步接受请求。 +- `ProcessRequest` 完成路由分发、CORS 头设置、异常捕获与响应写入。 +- 当前实现的路由:`/health`、`/test`、`/api/status/model`,可扩展 `switch` 和命令解析以支持更多端点。 + +### 4.3 命令抽象 `Core.ICommand` +- 统一定义 `CommandId`、`CommandType`、`Execute()`,保留 `CanCancel/Cancel()` 扩展位。 +- 推荐为每个 HTTP 接口实现对应命令类,确保执行逻辑仅运行在主线程环境。 + +### 4.4 业务管理 `Core.PdmsManager` +- 单例模式管理 PDMS 状态查询,避免重复初始化。 +- `GetModelStatus()`:集中调度连接检测、项目信息、模型统计、会话信息。 + - **连接检测**:`MDB.CurrentMDB` 是否返回非空。 + - **项目信息**:通过 `CurrentMDB.GetFirstDB(DbType.Design)` 获取设计库名称、MDS 名称、PDMS 版本。 + - **模型统计**:递归 `DbElement.Members()`,使用 `element.GetActualType().Name` 累加 SITE、ZONE、PIPE 等关键元素数量。 + - **Zone 统计**:遍历 SITE → ZONE 层级,利用 `DbElement.GetValidString(DbAttributeInstance.NAME, ref zoneName)` 采集激活区域名称。 + - **会话信息**:从 `DesignDb.CurrentSession` 读取用户、启动时间,计算持续分钟数。 +- 所有 API 调用包裹 try/catch 并输出调试日志以保证稳定性。 + +## 5. 线程模型 +- HttpListener 线程仅负责解析请求并将命令入队,禁止直接调用 PDMS API。 +- 主线程通过 `Application.Idle` 循环 `SafeQueue.TryDequeue(out ICommand cmd)`,顺序执行。 +- 可在命令执行过程中更新共享的进度字典,以供 `/task/{id}` 查询(待实现)。 +- 长任务需在执行逻辑内部分批处理,并在批次间调用 `GC.Collect()` 控制内存。 + +## 6. HTTP 接口约定 + +| 路径 | 方法 | 描述 | 响应示例 | +| --- | --- | --- | --- | +| `/health` | GET | 进程存活检测,返回时间戳与内存占用 | `{ "code":0, "message":"成功", "data":{ "status":"OK", "timestamp":"2025-09-21 12:00:00", "memoryMB":123 } }` | +| `/test` | GET | 轻量连通性测试,检测是否运行于 PDMS 环境 | `{ "success":true, "data":{ "running":true, "message":"TellmePdms 与 PDMS 连接正常" }, "error":null }` | +| `/api/status/model` | GET | 返回模型加载状态、项目信息、元素统计、会话信息 | `{ "code":0, "message":"成功", "data":{ ...ModelStatusResponse... } }` | + +- 响应以 `ApiResponse` 模式封装:`code` 为 0 表示成功,`message` 给出提示,`data` 为业务数据。 +- 自定义错误码示例:`1001` 表示 PDMS 模型未加载,`500` 表示内部异常。 +- 未来规划接口:`POST /command`、`POST /task`、`GET /task/{id}`、`GET /stats`、`POST /export/ifc` 等,可复用命令与模型层。 + +## 7. 数据模型结构 + +### 7.1 `ModelStatusResponse` +- `ModelLoaded`:PDMS 是否加载模型。 +- `ProjectInfo`:包含 `ProjectName`、`MdsName`、`PdmsVersion`。 +- `ModelStatistics`:`TotalElements`、`ElementCounts (Dictionary)`、`ZoneCount`、`ActiveZones`。 +- `SessionInfo`:会话 `UserName`、`StartTime`、`DurationMinutes`。 + +### 7.2 序列化策略 +- 对字典、列表使用定制序列化,确保 .NET 3.5 环境兼容。 +- 日期统一格式 `yyyy-MM-ddTHH:mm:ssZ`,便于前端解析。 +- 主线程执行结束后返回 JSON 字符串,由 HttpServer 写入响应流。 + +## 8. 性能与资源控制 +- **Large Address Aware**:建议为 32 位进程设置 LAA 标志,在 64 位 Windows 上提升可用内存上限至 3 GB。 +- **分批遍历**:对大模型递归时,可按逻辑域(SITE、ZONE)分批处理,减少单次内存占用。 +- **流式响应**:长列表数据可拆分分页或使用压缩(GZip + Base64)后流式传输,避免一次性加载。 +- **GC 管理**:在长任务各阶段主动调用 `GC.Collect()` 并输出当前内存占用,防止内存峰值。 +- **并发限制**:当前设计为单客户端单任务,无任务队列优先级;后续可引入任务管理表实现调度与取消。 + +## 9. 日志与错误处理 +- 插件与服务器分别记录至 `C:\temp\pdms_plugin_log.txt`、`C:\temp\pdms_http_log.txt`。 +- 网络层捕获异常后返回 500 错误并写入日志;业务层捕获异常后返回自定义错误码。 +- 建议引入滚动日志策略(log4net RollingFileAppender)以限制文件大小。 + +## 10. 部署与配置 +1. 以 x86、.NET 3.5 配置编译 `TellmePdmsPluging.sln`。 +2. 将输出 DLL 复制到 PDMS 安装目录,更新 `DesignAddin.xml` 注册插件。 +3. 确保 Windows 防火墙允许本地回环端口 9001;外部访问需额外代理。 +4. 可在 `Config` 目录引入自定义配置(端口、日志路径、批处理大小等),启动时读取。 +5. 部署后通过 `http://localhost:9001/health` 验证服务是否监听。 + +## 11. 后续扩展建议 +- **任务管理**:实现 `/task` 接口与任务表持久化,支持长任务进度查询与取消。 +- **安全加固**:加入鉴权(如基于令牌或双向证书),限制访问来源。 +- **前端工具**:提供 Web 或桌面客户端,集成状态展示与命令触发。 +- **协议升级**:考虑使用 WebSocket 推送进度、MessagePack/Protobuf 提升带宽效率。 +- **监控告警**:将健康检查与日志集成到企业监控平台,追踪内存与任务状态。 + +--- + +本文件概述了 Tellme PDMS 远程控制插件的架构设计、核心组件、关键算法与部署要点,可作为后续开发迭代、测试验证及运维交付的基础参考资料。 diff --git a/Models/OpenMdbRequest.cs b/Models/OpenMdbRequest.cs new file mode 100644 index 0000000..7c891a5 --- /dev/null +++ b/Models/OpenMdbRequest.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; + +namespace TellmePdmsPluging.Models +{ + public class OpenMdbRequest + { + /// + /// MDB name to open + /// + public string MdbName { get; set; } + + /// + /// If true then all DBs will be opened in read + /// + public bool ReadOnly { get; set; } + + /// + /// Default database type. e.g. Design + /// + public string DefaultType { get; set; } + + /// + /// DbTypes to open in read (string form, e.g. ["Design","Catalog"]) + /// + public List ReadTypes + { + get { return _readTypes; } + set { _readTypes = NormalizeList(value); } + } + + /// + /// DbTypes to open in write (string form) + /// + public List WriteTypes + { + get { return _writeTypes; } + set { _writeTypes = NormalizeList(value); } + } + + /// + /// Stamp at which to open the MDB (optional) + /// + public string Stamp { get; set; } + + /// + /// Subtype, i.e. marine or not (optional) + /// + public int? Subtype { get; set; } + + private List _readTypes; + private List _writeTypes; + + public void ApplyDefaults() + { + MdbName = Normalize(MdbName); + DefaultType = Normalize(DefaultType); + Stamp = Normalize(Stamp); + } + + private static string Normalize(string value) + { + if (IsNullOrWhiteSpace(value)) + { + return null; + } + + return value.Trim(); + } + + private static List NormalizeList(IEnumerable source) + { + if (source == null) + { + return null; + } + + var normalized = new List(); + + foreach (var item in source) + { + if (IsNullOrWhiteSpace(item)) + { + continue; + } + + var formatted = item.Trim(); + if (!normalized.Contains(formatted)) + { + normalized.Add(formatted); + } + } + + return normalized; + } + + private static bool IsNullOrWhiteSpace(string value) + { + if (value == null) + { + return true; + } + + for (int i = 0; i < value.Length; i++) + { + if (!char.IsWhiteSpace(value[i])) + { + return false; + } + } + + return true; + } + } + + public class OpenMdbResult + { + public bool Success { get; set; } + public string Message { get; set; } + public string MdbName { get; set; } + public bool WasAlreadyOpen { get; set; } + public bool ReadOnly { get; set; } + public string DefaultType { get; set; } + public DateTime CompletedAt { get; set; } + } +} + diff --git a/Models/OpenProjectRequest.cs b/Models/OpenProjectRequest.cs new file mode 100644 index 0000000..18401ae --- /dev/null +++ b/Models/OpenProjectRequest.cs @@ -0,0 +1,67 @@ +using System; + +namespace TellmePdmsPluging.Models +{ + public class OpenProjectRequest + { + /// + /// PDMS Project name + /// + public string ProjectName { get; set; } + + /// + /// PDMS login user name + /// + public string UserName { get; set; } + + /// + /// PDMS login password + /// + public string Password { get; set; } + + public void ApplyDefaults() + { + ProjectName = Normalize(ProjectName); + UserName = Normalize(UserName); + Password = Password ?? string.Empty; + } + + private static string Normalize(string value) + { + if (IsNullOrWhiteSpace(value)) + { + return null; + } + + return value.Trim(); + } + + private static bool IsNullOrWhiteSpace(string value) + { + if (value == null) + { + return true; + } + + for (int i = 0; i < value.Length; i++) + { + if (!char.IsWhiteSpace(value[i])) + { + return false; + } + } + + return true; + } + } + + public class OpenProjectResult + { + public bool Success { get; set; } + public string Message { get; set; } + public string ProjectName { get; set; } + public bool WasAlreadyOpen { get; set; } + public DateTime CompletedAt { get; set; } + } +} + diff --git a/Models/ShrinkwrapModelRequest.cs b/Models/ShrinkwrapModelRequest.cs new file mode 100644 index 0000000..2dbfd28 --- /dev/null +++ b/Models/ShrinkwrapModelRequest.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; + +namespace TellmePdmsPluging.Models +{ + public class ShrinkwrapModelRequest + { + public bool DryRun { get; set; } = true; + + public double Padding { get; set; } = 500.0; + + public double TouchTolerance { get; set; } = 1.0; + + public List ZoneFilters + { + get { return _zoneFilters; } + set { _zoneFilters = NormalizeList(value); } + } + + public List KeepTypes + { + get { return _keepTypes; } + set { _keepTypes = NormalizeList(value); } + } + + private List _zoneFilters; + private List _keepTypes; + + public void ApplyDefaults() + { + if (_keepTypes == null || _keepTypes.Count == 0) + { + _keepTypes = new List { "SITE", "ZONE", "STRU", "FRAME", "SHELL", "PLAT", "WALL" }; + } + + if (_zoneFilters == null) + { + _zoneFilters = new List(); + } + } + + private static List NormalizeList(IEnumerable source) + { + if (source == null) + { + return null; + } + + var normalized = new List(); + + foreach (var item in source) + { + if (IsNullOrWhiteSpace(item)) + { + continue; + } + + var formatted = item.Trim().ToUpperInvariant(); + if (!normalized.Contains(formatted)) + { + normalized.Add(formatted); + } + } + + return normalized; + } + + private static bool IsNullOrWhiteSpace(string value) + { + if (value == null) + { + return true; + } + + for (int i = 0; i < value.Length; i++) + { + if (!char.IsWhiteSpace(value[i])) + { + return false; + } + } + + return true; + } + } + + public class ShrinkwrapModelResult + { + public bool Success { get; set; } + public bool DryRun { get; set; } + public double Padding { get; set; } + public int TotalVisited { get; set; } + public int RemovedCount { get; set; } + public int KeptCount { get; set; } + public int ShellKeptCount { get; set; } + public List RemovedElements { get; set; } = new List(); + public List Errors { get; set; } = new List(); + public List ZoneSummaries { get; set; } = new List(); + public System.DateTime StartedAt { get; set; } + public System.DateTime CompletedAt { get; set; } + public string Message { get; set; } + } +} diff --git a/Network/HttpServer.cs b/Network/HttpServer.cs index b8b3036..ab43382 100644 --- a/Network/HttpServer.cs +++ b/Network/HttpServer.cs @@ -123,9 +123,18 @@ namespace TellmePdmsPluging.Network case "/api/status/model": responseJson = HandleModelStatus(); break; + case "/api/project/open": + responseJson = HandleProjectOpen(request); + break; + case "/api/mdb/open": + responseJson = HandleMdbOpen(request); + break; case "/api/model/simplify": responseJson = HandleModelSimplify(request); break; + case "/api/model/shrinkwrap": + responseJson = HandleModelShrinkwrap(request); + break; default: response.StatusCode = 404; responseJson = CreateErrorResponse(404, "接口不存在"); @@ -251,6 +260,142 @@ namespace TellmePdmsPluging.Network } } + private string HandleModelShrinkwrap(HttpListenerRequest request) + { + try + { + var payload = ReadRequestBody(request); + if (string.IsNullOrEmpty(payload)) + { + return CreateErrorResponse(400, "请求体不能为空"); + } + + var serializer = new JavaScriptSerializer(); + var shrinkwrapRequest = serializer.Deserialize(payload) ?? new ShrinkwrapModelRequest(); + + var command = new ShrinkwrapModelCommand(shrinkwrapRequest); + var invokeResult = MainThreadInvoker.Invoke(command, 600000); + + if (!invokeResult.Success) + { + var msg = string.IsNullOrEmpty(invokeResult.Message) ? "外壳保留失败" : invokeResult.Message; + return CreateErrorResponse(500, msg); + } + + var result = invokeResult.Result as ShrinkwrapModelResult; + if (result == null) + { + return CreateErrorResponse(500, "外壳保留结果为空"); + } + + if (!result.Success) + { + var message = string.IsNullOrEmpty(result.Message) ? "外壳保留失败" : result.Message; + return CreateErrorResponse(500, message); + } + + return CreateSuccessResponse(result); + } + catch (Exception ex) + { + return CreateErrorResponse(500, $"外壳保留失败: {ex.Message}"); + } + } + + private string HandleProjectOpen(HttpListenerRequest request) + { + try + { + if (!string.Equals(request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase)) + { + return CreateErrorResponse(405, "仅支持POST"); + } + + var payload = ReadRequestBody(request); + if (string.IsNullOrEmpty(payload)) + { + return CreateErrorResponse(400, "请求体不能为空"); + } + + var serializer = new JavaScriptSerializer(); + var openRequest = serializer.Deserialize(payload) ?? new OpenProjectRequest(); + + var command = new OpenProjectCommand(openRequest); + var invokeResult = MainThreadInvoker.Invoke(command, 600000); + + if (!invokeResult.Success) + { + var msg = string.IsNullOrEmpty(invokeResult.Message) ? "打开项目失败" : invokeResult.Message; + return CreateErrorResponse(500, msg); + } + + var result = invokeResult.Result as OpenProjectResult; + if (result == null) + { + return CreateErrorResponse(500, "打开项目结果为空"); + } + + if (!result.Success) + { + var message = string.IsNullOrEmpty(result.Message) ? "打开项目失败" : result.Message; + return CreateErrorResponse(500, message); + } + + return CreateSuccessResponse(result); + } + catch (Exception ex) + { + return CreateErrorResponse(500, $"打开项目失败: {ex.Message}"); + } + } + + private string HandleMdbOpen(HttpListenerRequest request) + { + try + { + if (!string.Equals(request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase)) + { + return CreateErrorResponse(405, "仅支持POST"); + } + + var payload = ReadRequestBody(request); + if (string.IsNullOrEmpty(payload)) + { + return CreateErrorResponse(400, "请求体不能为空"); + } + + var serializer = new JavaScriptSerializer(); + var openRequest = serializer.Deserialize(payload) ?? new OpenMdbRequest(); + + var command = new OpenMdbCommand(openRequest); + var invokeResult = MainThreadInvoker.Invoke(command, 600000); + + if (!invokeResult.Success) + { + var msg = string.IsNullOrEmpty(invokeResult.Message) ? "打开MDB失败" : invokeResult.Message; + return CreateErrorResponse(500, msg); + } + + var result = invokeResult.Result as OpenMdbResult; + if (result == null) + { + return CreateErrorResponse(500, "打开MDB结果为空"); + } + + if (!result.Success) + { + var message = string.IsNullOrEmpty(result.Message) ? "打开MDB失败" : result.Message; + return CreateErrorResponse(500, message); + } + + return CreateSuccessResponse(result); + } + catch (Exception ex) + { + return CreateErrorResponse(500, $"打开MDB失败: {ex.Message}"); + } + } + private string ReadRequestBody(HttpListenerRequest request) { if (request == null || request.InputStream == null) diff --git a/TellmePdmsPluging.csproj b/TellmePdmsPluging.csproj index 275f326..f9e7ec6 100644 --- a/TellmePdmsPluging.csproj +++ b/TellmePdmsPluging.csproj @@ -1,4 +1,4 @@ - + @@ -59,6 +59,9 @@ ..\..\..\..\..\AVEVA\Plant\PDMS12.1.SP4\Aveva.Pdms.Database.dll + + ..\..\..\..\..\AVEVA\Plant\PDMS12.1.SP4\Aveva.Pdms.Geometry.dll + ..\..\..\..\..\AVEVA\Plant\PDMS12.1.SP4\PMLNet.dll @@ -73,11 +76,19 @@ + + + + + + + +