首次提交: OnebotCatalog 项目代码与文档(含 NX 按需生成服务二期、后台、一键启动)

This commit is contained in:
wangruiguo
2026-09-03 17:55:45 +08:00
commit fafa86d3a6
241 changed files with 78656 additions and 0 deletions

122
src/App/AdminWindow.cs Normal file
View File

@@ -0,0 +1,122 @@
#if !CUSTOMER_BUILD
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using Ounibo.Catalog.Core;
namespace Ounibo.Catalog.App
{
/// <summary>
/// 维护入口 (M1): 数据包信息 + 系列清单 + 加载/替换 + 资源完整性校验。
/// M2 将扩展 Builder 编辑功能。
/// </summary>
public class AdminWindow : Window
{
TextBlock _info;
DataGrid _seriesGrid;
ListBox _report;
MainWindow _owner;
public AdminWindow(MainWindow owner)
{
_owner = owner;
Title = "维护入口 - 数据包管理 / Admin - Package";
Width = 640; Height = 520; MinWidth = 520; MinHeight = 400;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
Owner = owner;
var panel = new DockPanel { Margin = new Thickness(12) };
_info = new TextBlock();
DockPanel.SetDock(_info, Dock.Top);
panel.Children.Add(_info);
var btns = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 8, 0, 8) };
var btnLoad = new Button { Content = "加载新数据包 / Load Package", Width = 170, Margin = new Thickness(0, 0, 8, 0) };
btnLoad.Click += (s, e) => LoadPackage();
var btnCheck = new Button { Content = "校验 / Validate", Width = 120, Margin = new Thickness(0, 0, 8, 0) };
btnCheck.Click += (s, e) => Validate();
var btnBuilder = new Button { Content = "目录制作器 / Builder", Width = 150, Margin = new Thickness(0, 0, 8, 0) };
btnBuilder.Click += (s, e) => { var w = new BuilderWindow(_owner); w.ShowDialog(); };
var btnClose = new Button { Content = "关闭 / Close", Width = 90 };
btnClose.Click += (s, e) => Close();
btns.Children.Add(btnLoad); btns.Children.Add(btnCheck); btns.Children.Add(btnBuilder); btns.Children.Add(btnClose);
DockPanel.SetDock(btns, Dock.Top);
panel.Children.Add(btns);
_seriesGrid = new DataGrid { IsReadOnly = true, AutoGenerateColumns = false, Height = 220, Margin = new Thickness(0, 4, 0, 4) };
_seriesGrid.Columns.Add(new DataGridTextColumn { Header = "系列代码", Binding = new System.Windows.Data.Binding("Code"), Width = 80 });
_seriesGrid.Columns.Add(new DataGridTextColumn { Header = "中文名", Binding = new System.Windows.Data.Binding("Zh"), Width = 210 });
_seriesGrid.Columns.Add(new DataGridTextColumn { Header = "变体数", Binding = new System.Windows.Data.Binding("Variants"), Width = 70 });
_seriesGrid.Columns.Add(new DataGridTextColumn { Header = "参数", Binding = new System.Windows.Data.Binding("Params"), Width = 70 });
_seriesGrid.Columns.Add(new DataGridTextColumn { Header = "规则", Binding = new System.Windows.Data.Binding("Rules"), Width = 60 });
_seriesGrid.Columns.Add(new DataGridTextColumn { Header = "附件", Binding = new System.Windows.Data.Binding("Atts"), Width = 60 });
panel.Children.Add(_seriesGrid);
_report = new ListBox();
panel.Children.Add(_report);
Content = panel;
RefreshInfo();
}
void RefreshInfo()
{
var p = _owner.Package;
if (p == null)
{
_info.Text = "未加载数据包";
_seriesGrid.ItemsSource = null;
return;
}
var c = p.Catalog;
_info.Text = string.Format(
"数据包: {0} 文件: {1}\n目录版本: {2} | 格式版本: {3} 语言: {4}\n系列数: {5} | 变体数: {6} (与主窗口当前加载一致)",
c.catalogName, System.IO.Path.GetFileName(p.Path), c.catalogVersion, c.schemaVersion,
string.Join(",", c.langs ?? new List<string>()),
c.series.Count, c.series.Sum(s => s.variants == null ? 0 : s.variants.Count));
var dt = new DataTable();
dt.Columns.Add("Code"); dt.Columns.Add("Zh"); dt.Columns.Add("Variants", typeof(int));
dt.Columns.Add("Params", typeof(int)); dt.Columns.Add("Rules", typeof(int)); dt.Columns.Add("Atts", typeof(int));
foreach (var s in c.series)
dt.Rows.Add(s.code, s.nameZh, s.variants == null ? 0 : s.variants.Count,
s.parameters == null ? 0 : s.parameters.Count, s.rules == null ? 0 : s.rules.Count,
s.attachments == null ? 0 : s.attachments.Count);
_seriesGrid.ItemsSource = dt.DefaultView;
}
void Validate()
{
var p = _owner.Package;
_report.Items.Clear();
if (p == null) { _report.Items.Add("未加载数据包"); return; }
var issues = PackageValidator.Validate(p);
foreach (var i in issues)
_report.Items.Add("[" + i.Severity + "] " + (string.IsNullOrEmpty(i.Target) ? "" : i.Target + " ") + i.Message);
_report.Items.Add(string.Format("校验完成: {0} 个错误, {1} 个警告, 共 {2} 个变体",
issues.Count(x => x.Severity == "ERROR"), issues.Count(x => x.Severity == "WARNING"),
p.Catalog.series.Sum(s => s.variants == null ? 0 : s.variants.Count)));
}
void LoadPackage()
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Filter = "目录数据包 (*.opc)|*.opc",
Title = "选择 .opc 数据包"
};
if (dlg.ShowDialog() == true)
{
if (_owner.ReplacePackage(dlg.FileName))
{
MessageBox.Show("数据包已替换", "维护入口", MessageBoxButton.OK, MessageBoxImage.Information);
RefreshInfo();
_report.Items.Clear();
}
}
}
}
}
#endif

284
src/App/App.cs Normal file
View File

@@ -0,0 +1,284 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using Ounibo.Catalog.Core;
namespace Ounibo.Catalog.App
{
/// <summary>嵌入资源加载 (logo 等编译进 exe 的资源)。</summary>
public static class AppResources
{
public static System.Windows.Media.Imaging.BitmapImage LoadPng(string name)
{
try
{
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(name);
if (stream == null) return null;
var bmp = new System.Windows.Media.Imaging.BitmapImage();
bmp.BeginInit();
bmp.StreamSource = stream;
bmp.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
bmp.EndInit();
return bmp;
}
catch { return null; }
}
}
/// <summary>
/// 程序入口。
/// 开发版: OnebotCatalog.exe [数据包.opc] [--admin] [--selftest 数据包.opc]
/// 客户版 (CUSTOMER_BUILD): 启动时先找 exe 旁 catalog.opc, 找不到读嵌入资源;
/// 无维护入口、无自测。
/// </summary>
public static class Program
{
[STAThread]
public static void Main(string[] args)
{
#if !CUSTOMER_BUILD
// 自测模式: 无界面运行核心逻辑, 结果写 selftest.log
if (args.Length >= 2 && args[0] == "--selftest")
{
Environment.ExitCode = SelfTest.Run(args[1]);
return;
}
// CLI 打包模式 (与向导同一 Builder 内核):
// --build <csv> <stepDir> <out.opc> [--series CODE] [--meta 元数据.json]
// 无 --meta 时参数自动识别 (无规则/附件/关键词); 结果写 <out 同目录>\build.log
if (args.Length >= 4 && args[0] == "--build")
{
var csv = args[1]; var stepDir = args[2]; var outP = args[3];
string seriesCode = "S1";
string metaPath = null;
for (int i = 4; i + 1 < args.Length; i++)
{
if (args[i] == "--series") seriesCode = args[i + 1];
if (args[i] == "--meta") metaPath = args[i + 1];
}
Series series;
var atts = new List<BuildAttachmentSpec>();
if (metaPath != null)
{
var ser = new System.Web.Script.Serialization.JavaScriptSerializer();
var meta = ser.Deserialize<SeriesMeta>(File.ReadAllText(metaPath, Encoding.UTF8));
series = meta.series ?? new Series();
if (series.parameters == null || series.parameters.Count == 0)
series.parameters = OpcBuilder.AutoDetectParameters(csv);
atts = meta.attachments ?? new List<BuildAttachmentSpec>();
}
else
{
series = new Series { parameters = OpcBuilder.AutoDetectParameters(csv) };
}
series.code = string.IsNullOrEmpty(series.code) ? seriesCode : series.code;
series.nameZh = string.IsNullOrEmpty(series.nameZh) ? seriesCode : series.nameZh;
series.nameEn = string.IsNullOrEmpty(series.nameEn) ? seriesCode : series.nameEn;
series.naming = series.naming ?? new Naming { stepNameTemplate = "{model}.step" };
series.attachments = series.attachments ?? new List<Attachment>();
series.variants = new List<Variant>();
var res = OpcBuilder.Build(outP, "欧霓博气动目录", "0.1", "ACT", "产品", "Products",
series, csv, stepDir, atts);
var sb = new StringBuilder();
sb.AppendLine(res.Ok ? "BUILD OK: " + outP : "BUILD FAILED");
foreach (var e in res.Errors) sb.AppendLine(" [错误] " + e);
foreach (var w in res.Warnings) sb.AppendLine(" [警告] " + w);
if (res.Ok)
{
try
{
using (var built = OpcPackage.Open(outP))
{
var issues = PackageValidator.Validate(built);
sb.AppendLine("变体数: " + built.Catalog.series.Sum(s2 => s2.variants.Count));
sb.AppendLine("校验: " + issues.Count(i => i.Severity == "ERROR") + " 错误, " + issues.Count(i => i.Severity == "WARNING") + " 警告");
foreach (var i in issues) sb.AppendLine(" [" + i.Severity + "] " + i.Target + " " + i.Message);
}
}
catch (Exception ex) { sb.AppendLine("产物校验失败: " + ex.Message); Environment.ExitCode = 1; return; }
}
else Environment.ExitCode = 1;
File.WriteAllText(Path.Combine(Path.GetDirectoryName(outP) ?? ".", "build.log"), sb.ToString(), Encoding.UTF8);
return;
}
// CLI 全量打包 (统一路线): --buildfull <源目录> <out.opc>
// 源目录结构见 FullCatalogBuilder; 结果写 <out 同目录>\build.log
if (args.Length >= 3 && args[0] == "--buildfull")
{
var res = FullCatalogBuilder.BuildFromSourceDir(args[1], args[2]);
var sb = new StringBuilder();
sb.AppendLine(res.Ok ? "BUILDFULL OK: " + args[2] : "BUILDFULL FAILED");
foreach (var e in res.Errors) sb.AppendLine(" [错误] " + e);
foreach (var w in res.Warnings) sb.AppendLine(" [警告] " + w);
if (res.Ok)
{
try
{
using (var built = OpcPackage.Open(args[2]))
{
sb.AppendLine("系列数: " + built.Catalog.series.Count + " | 变体数: " + built.Catalog.series.Sum(s2 => s2.variants.Count));
var issues = PackageValidator.Validate(built);
sb.AppendLine("校验: " + issues.Count(i => i.Severity == "ERROR") + " 错误, " + issues.Count(i => i.Severity == "WARNING") + " 警告");
foreach (var i in issues) sb.AppendLine(" [" + i.Severity + "] " + i.Target + " " + i.Message);
}
}
catch (Exception ex) { sb.AppendLine("产物校验失败: " + ex.Message); Environment.ExitCode = 1; }
}
else Environment.ExitCode = 1;
File.WriteAllText(Path.Combine(Path.GetDirectoryName(args[2]) ?? ".", "build.log"), sb.ToString(), Encoding.UTF8);
return;
}
#endif
string opc = null;
foreach (var a in args)
{
if (a.EndsWith(".opc", StringComparison.OrdinalIgnoreCase)) opc = a;
}
#if CUSTOMER_BUILD
OpcPackage pkg = null;
if (opc != null)
{
try { pkg = OpcPackage.Open(opc); }
catch (Exception ex) { ShowFatal(ex.Message); return; }
}
else pkg = TryOpenEmbedded();
if (pkg == null)
{
// 无嵌入数据且无外置数据包 → 让用户选择 .opc
var dlg = new Microsoft.Win32.OpenFileDialog { Filter = "目录数据包 (*.opc)|*.opc", Title = "选择 .opc 数据包" };
if (dlg.ShowDialog() == true)
{
try { pkg = OpcPackage.Open(dlg.FileName); }
catch (Exception ex) { ShowFatal(ex.Message); return; }
}
else return;
}
Run(pkg, false);
#else
bool admin = false;
foreach (var a in args) if (a == "--admin") admin = true;
if (admin && !PromptPassword())
return;
if (opc == null)
opc = FindDefaultPackage();
OpcPackage pkg = null;
if (opc != null)
{
try { pkg = OpcPackage.Open(opc); }
catch (Exception ex) { ShowFatal(ex.Message); return; }
}
else pkg = TryOpenEmbedded();
Run(pkg, admin);
#endif
}
static void Run(OpcPackage pkg, bool admin)
{
var app = new Application { ShutdownMode = ShutdownMode.OnMainWindowClose };
// 全局异常日志 (crash.log, 与 exe 同目录), UI 异常不崩溃
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "crash.log");
app.DispatcherUnhandledException += (s, e) =>
{
try { File.AppendAllText(logPath, DateTime.Now + " UI异常: " + e.Exception + "\r\n", Encoding.UTF8); } catch { }
e.Handled = true;
};
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
{
try { File.AppendAllText(logPath, DateTime.Now + " 未处理异常: " + e.ExceptionObject + "\r\n", Encoding.UTF8); } catch { }
};
app.Run(new MainWindow(pkg, admin));
}
static void ShowFatal(string msg)
{
MessageBox.Show("数据包打开失败: " + msg, "欧霓博气动目录 ONEBOT Catalog", MessageBoxButton.OK, MessageBoxImage.Error);
}
/// <summary>
/// 先找 exe 旁 catalog.opc (外置数据包, 支持增量更新), 找不到读嵌入资源。
/// </summary>
static OpcPackage TryOpenEmbedded()
{
var external = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "catalog.opc");
if (File.Exists(external))
{
try { return OpcPackage.Open(external); }
catch { /* 外置包损坏则回退嵌入资源 */ }
}
try
{
var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("catalog.opc");
if (stream != null)
return OpcPackage.OpenStream(stream, "内置数据包 (catalog.opc)");
}
catch { /* 无嵌入资源 */ }
return null;
}
#if !CUSTOMER_BUILD
static string FindDefaultPackage()
{
var dirs = new[]
{
AppDomain.CurrentDomain.BaseDirectory,
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "sample"),
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "sample")
};
foreach (var d in dirs)
{
try
{
if (!Directory.Exists(d)) continue;
var f = Directory.GetFiles(d, "*.opc").FirstOrDefault();
if (f != null) return f;
}
catch { /* 忽略目录访问失败 */ }
}
return null;
}
/// <summary>
/// 维护入口密码。TODO: 移到配置文件, 支持修改。
/// </summary>
static bool PromptPassword()
{
var win = new Window
{
Title = "维护入口 Admin",
Width = 360, Height = 180, MinWidth = 320, MinHeight = 150,
WindowStartupLocation = WindowStartupLocation.CenterScreen
};
var panel = new StackPanel { Margin = new Thickness(16) };
panel.Children.Add(new TextBlock { Text = "请输入维护密码 / Enter admin password:", Margin = new Thickness(0, 0, 0, 8) });
var box = new PasswordBox { Margin = new Thickness(0, 0, 0, 12) };
panel.Children.Add(box);
var btns = new StackPanel { Orientation = Orientation.Horizontal };
var ok = new Button { Content = "确定 OK", Width = 90, Margin = new Thickness(0, 0, 8, 0), IsDefault = true };
var cancel = new Button { Content = "取消 Cancel", Width = 90, IsCancel = true };
btns.Children.Add(ok); btns.Children.Add(cancel);
panel.Children.Add(btns);
win.Content = panel;
bool granted = false;
ok.Click += (s, e) =>
{
if (box.Password == "onebot888") { granted = true; win.Close(); }
else MessageBox.Show("密码错误 / Wrong password", "维护入口", MessageBoxButton.OK, MessageBoxImage.Warning);
};
win.ShowDialog();
return granted;
}
#endif
}
}

840
src/App/BuilderWindow.cs Normal file
View File

@@ -0,0 +1,840 @@
#if !CUSTOMER_BUILD
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using Microsoft.Win32;
using Ounibo.Catalog.Core;
namespace Ounibo.Catalog.App
{
public class ParamRow
{
public string Code { get; set; }
public string NameZh { get; set; }
public string NameEn { get; set; }
public string Unit { get; set; }
public string Type { get; set; }
public string Values { get; set; }
public string Keywords { get; set; }
public string Display { get; set; } // 显示名映射, 格式 值:显示,值:显示 (如 :基本型,M:附磁石 M)
}
public class RuleRow
{
public string CondParam { get; set; }
public string CondOp { get; set; }
public string CondValue { get; set; }
public string ThenParam { get; set; }
public string ThenOp { get; set; }
public string ThenValues { get; set; }
}
public class AttachmentRow
{
public string Kind { get; set; }
public string Lang { get; set; }
public string Source { get; set; }
public string Model { get; set; }
}
public class SeriesChoice
{
public Series Series;
public override string ToString()
{
return Series.code + " - " + Series.nameZh + " (" + (Series.variants == null ? 0 : Series.variants.Count) + " 变体)";
}
}
/// <summary>
/// 维护入口内的 Builder (M2): 5 步向导
/// 导入 CSV+STEP 目录 → 参数定义 → 编码确认 → 选型规则 → 附件与生成 .opc。
/// </summary>
public class BuilderWindow : Window
{
MainWindow _owner;
TextBox _tbSeriesCode, _tbSeriesZh, _tbSeriesEn, _tbCatName, _tbVer;
TextBox _tbCsv, _tbStepDir;
TextBlock _lbPreviewCount;
DataGrid _gridPreview, _gridParams, _gridRules, _gridAttach;
TextBox _tbOut;
TextBlock _tbResult;
TextBox _tbSrcDir;
ComboBox _cbSeries;
string _loadedMetaPath;
BindingList<ParamRow> _paramRows = new BindingList<ParamRow>();
BindingList<RuleRow> _ruleRows = new BindingList<RuleRow>();
BindingList<AttachmentRow> _attRows = new BindingList<AttachmentRow>();
string[] _header;
List<string[]> _csvRows = new List<string[]>();
public BuilderWindow(MainWindow owner)
{
_owner = owner;
Title = "目录制作器 Builder (维护入口)";
Width = 900; Height = 720; MinWidth = 760; MinHeight = 560;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
Owner = owner;
var root = new DockPanel { Margin = new Thickness(10) };
// 系列与目录信息
var info = new Grid { Margin = new Thickness(0, 0, 0, 8) };
for (int i = 0; i < 4; i++) info.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
info.RowDefinitions.Add(new RowDefinition()); info.RowDefinitions.Add(new RowDefinition()); info.RowDefinitions.Add(new RowDefinition());
_tbSeriesCode = AddField(info, 0, 0, "系列代码 Series code");
_tbSeriesZh = AddField(info, 1, 0, "系列中文名 Name (zh)");
_tbSeriesEn = AddField(info, 2, 0, "系列英文名 Name (en)");
_tbCatName = AddField(info, 3, 0, "分类名 Category");
_tbVer = AddField(info, 0, 1, "目录版本 Version");
var cfgBtns = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 0) };
var btnSaveCfg = new Button { Content = "保存配置", Width = 100, Margin = new Thickness(0, 0, 8, 0) };
btnSaveCfg.Click += (s, e) => SaveConfig();
var btnLoadCfg = new Button { Content = "加载配置", Width = 100 };
btnLoadCfg.Click += (s, e) => LoadConfig();
cfgBtns.Children.Add(btnSaveCfg); cfgBtns.Children.Add(btnLoadCfg);
Grid.SetColumn(cfgBtns, 1); Grid.SetRow(cfgBtns, 1);
info.Children.Add(cfgBtns);
DockPanel.SetDock(info, Dock.Top);
root.Children.Add(info);
var tabs = new TabControl();
tabs.Items.Add(BuildStep1());
tabs.Items.Add(BuildStep2());
tabs.Items.Add(BuildStep3());
tabs.Items.Add(BuildStep4());
tabs.Items.Add(BuildStep5());
root.Children.Add(tabs);
Content = root;
ReloadSeriesList();
}
TextBox AddField(Grid g, int col, int row, string hint)
{
var tb = new TextBox { Margin = new Thickness(0, 0, 8, 4) };
Grid.SetColumn(tb, col); Grid.SetRow(tb, row);
g.Children.Add(tb);
if (!string.IsNullOrEmpty(hint)) tb.ToolTip = hint;
return tb;
}
// ---- Step1: 导入 ----
TabItem BuildStep1()
{
var panel = new DockPanel { Margin = new Thickness(6) };
// 数据源目录 (统一路线): 选目录 → 选系列 → 编辑 → 重建全量目录
var srcRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
srcRow.Children.Add(new TextBlock { Text = "数据源目录:", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 4, 0) });
_tbSrcDir = new TextBox { Width = 300, Margin = new Thickness(0, 0, 6, 0), Text = DefaultSourceDir() };
var btnSrc = new Button { Content = "选择...", Width = 60, Margin = new Thickness(0, 0, 10, 0) };
btnSrc.Click += (s, e) =>
{
var dlg = new System.Windows.Forms.FolderBrowserDialog { Description = "选择目录源目录 (含 catalog.json + series\\ + csv\\ + step\\)" };
if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
_tbSrcDir.Text = dlg.SelectedPath;
ReloadSeriesList();
}
};
_cbSeries = new ComboBox { Width = 150, Margin = new Thickness(0, 0, 10, 0) };
_cbSeries.SelectionChanged += (s, e) => OpenSeriesFromSource();
var btnRebuild = new Button { Content = "重建全量目录", Width = 120, FontWeight = System.Windows.FontWeights.Bold };
btnRebuild.Click += (s, e) => RebuildFull();
srcRow.Children.Add(_tbSrcDir); srcRow.Children.Add(btnSrc); srcRow.Children.Add(_cbSeries); srcRow.Children.Add(btnRebuild);
DockPanel.SetDock(srcRow, Dock.Top);
panel.Children.Add(srcRow);
var top = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
var btnCsv = new Button { Content = "选择参数表 CSV", Width = 130, Margin = new Thickness(0, 0, 6, 0) };
btnCsv.Click += (s, e) => LoadCsv();
_tbCsv = new TextBox { Width = 320, Margin = new Thickness(0, 0, 10, 0), IsReadOnly = true };
var btnDir = new Button { Content = "选择 STEP 目录", Width = 130, Margin = new Thickness(0, 0, 6, 0) };
btnDir.Click += (s, e) => PickStepDir();
_tbStepDir = new TextBox { Width = 240, IsReadOnly = true };
top.Children.Add(btnCsv); top.Children.Add(_tbCsv); top.Children.Add(btnDir); top.Children.Add(_tbStepDir);
var btnImport = new Button { Content = "从当前数据包导入系列", Width = 170, Margin = new Thickness(10, 0, 0, 0) };
btnImport.Click += (s, e) => ImportFromPackage();
top.Children.Add(btnImport);
DockPanel.SetDock(top, Dock.Top);
panel.Children.Add(top);
_lbPreviewCount = new TextBlock { Margin = new Thickness(0, 0, 0, 4) };
DockPanel.SetDock(_lbPreviewCount, Dock.Top);
panel.Children.Add(_lbPreviewCount);
_gridPreview = new DataGrid { IsReadOnly = true, AutoGenerateColumns = true };
panel.Children.Add(_gridPreview);
var tab = new TabItem { Header = "1. 导入 Import" };
tab.Content = panel;
return tab;
}
void LoadCsv()
{
var dlg = new OpenFileDialog { Filter = "CSV 参数表 (*.csv)|*.csv", Title = "选择参数表 (首列=型号编码, 含 step_file 列)" };
if (dlg.ShowDialog() != true) return;
try
{
_tbCsv.Text = dlg.FileName;
PreviewCsv(dlg.FileName);
AutoParams();
}
catch (Exception ex)
{
MessageBox.Show("CSV 读取失败: " + ex.Message, Title, MessageBoxButton.OK, MessageBoxImage.Error);
}
}
void PreviewCsv(string path)
{
var lines = File.ReadAllLines(path, System.Text.Encoding.UTF8).Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
_header = lines[0].Split(',').Select(x => x.Trim()).ToArray();
_csvRows = lines.Skip(1).Select(l => l.Split(',')).Where(c => c.Length >= _header.Length && !string.IsNullOrWhiteSpace(c[0])).ToList();
var dt = new DataTable();
foreach (var h in _header) dt.Columns.Add(h);
foreach (var r in _csvRows.Take(50))
{
var row = dt.NewRow();
for (int i = 0; i < _header.Length; i++) row[i] = r[i];
dt.Rows.Add(row);
}
_gridPreview.ItemsSource = dt.DefaultView;
_lbPreviewCount.Text = string.Format("共 {0} 行数据, 预览前 50 行", _csvRows.Count);
}
// ---- 从当前数据包导入系列: 向导数据与运行目录保持一致 ----
void ImportFromPackage()
{
var pkg = _owner.Package;
if (pkg == null) { MessageBox.Show("主窗口未加载数据包, 请先打开 .opc", Title); return; }
var cat = pkg.Catalog;
var picker = new Window { Title = "从当前数据包选择系列", Width = 420, Height = 420, MinWidth = 300, MinHeight = 300, WindowStartupLocation = WindowStartupLocation.CenterOwner, Owner = this };
var list = new ListBox { Margin = new Thickness(8) };
foreach (var s in cat.series) list.Items.Add(new SeriesChoice { Series = s });
var ok = new Button { Content = "确定 OK", Width = 90, IsDefault = true, Margin = new Thickness(8), HorizontalAlignment = HorizontalAlignment.Right };
var dp = new DockPanel();
ok.Click += (s2, e2) => picker.DialogResult = true;
DockPanel.SetDock(ok, Dock.Bottom);
dp.Children.Add(ok); dp.Children.Add(list);
picker.Content = dp;
if (picker.ShowDialog() != true || list.SelectedItem == null) return;
var series = ((SeriesChoice)list.SelectedItem).Series;
try
{
var tmp = Path.Combine(Path.GetTempPath(), "ounibo_builder", series.code);
Directory.CreateDirectory(Path.Combine(tmp, "step"));
// CSV: model_code + 参数列 + step_file
var sb = new StringBuilder();
var header = new List<string> { "model_code" };
foreach (var p in series.parameters ?? new List<ParameterDef>()) header.Add(p.code);
header.Add("step_file");
sb.AppendLine(string.Join(",", header));
foreach (var v in series.variants ?? new List<Variant>())
{
var row = new List<string> { v.modelCode };
foreach (var p in series.parameters ?? new List<ParameterDef>())
{
object val; string vs = v.@params != null && v.@params.TryGetValue(p.code, out val) ? Convert.ToString(val, CultureInfo.InvariantCulture) : "";
if (vs.Contains(",") || vs.Contains("\"")) vs = "\"" + vs.Replace("\"", "\"\"") + "\"";
row.Add(vs);
}
row.Add(Path.GetFileName((v.step ?? "").Replace('\\', '/')));
sb.AppendLine(string.Join(",", row));
}
var csvPath = Path.Combine(tmp, series.code + ".csv");
File.WriteAllText(csvPath, sb.ToString(), Encoding.UTF8);
// STEP 导出 (去重)
var seen = new HashSet<string>();
foreach (var v in series.variants ?? new List<Variant>())
{
if (v == null || string.IsNullOrEmpty(v.step) || !seen.Add(v.step)) continue;
try { File.WriteAllBytes(Path.Combine(tmp, "step", Path.GetFileName(v.step.Replace('\\', '/'))), pkg.ReadAsset(v.step)); }
catch { }
}
// 填向导字段
_tbSeriesCode.Text = series.code;
_tbSeriesZh.Text = series.nameZh ?? "";
_tbSeriesEn.Text = series.nameEn ?? "";
_tbVer.Text = cat.catalogVersion ?? "";
var catName = cat.categories.FirstOrDefault(c2 => c2.series != null && c2.series.Contains(series.code));
_tbCatName.Text = catName != null ? (catName.nameZh ?? "") : "";
_tbCsv.Text = csvPath;
_tbStepDir.Text = Path.Combine(tmp, "step");
PreviewCsv(csvPath);
// 参数行
_paramRows.Clear();
foreach (var p in series.parameters ?? new List<ParameterDef>())
{
_paramRows.Add(new ParamRow
{
Code = p.code,
NameZh = p.nameZh ?? "",
NameEn = p.nameEn ?? "",
Unit = p.unit ?? "",
Type = p.type == "number" ? "number" : "enum",
Values = string.Join(",", (p.values ?? new List<object>()).Select(x => Convert.ToString(x, CultureInfo.InvariantCulture))),
Keywords = p.keywords ?? ""
});
}
_gridParams.ItemsSource = _paramRows;
// 规则行
_ruleRows.Clear();
foreach (var r in series.rules ?? new List<Ounibo.Catalog.Core.Rule>())
{
_ruleRows.Add(new RuleRow
{
CondParam = r.If != null ? r.If.Param : "",
CondOp = r.If != null ? r.If.Op : "eq",
CondValue = r.If != null ? ValToStr(r.If.Value) : "",
ThenParam = r.Then != null ? r.Then.Param : "",
ThenOp = r.Then != null ? r.Then.Op : "in",
ThenValues = r.Then != null ? ValToStr(r.Then.Value) : ""
});
}
_gridRules.ItemsSource = _ruleRows;
// 附件 (manual 等包内附件导出为文件)
_attRows.Clear();
foreach (var a in series.attachments ?? new List<Attachment>())
{
if (string.IsNullOrEmpty(a.path)) continue;
var f = Path.Combine(tmp, "manual", Path.GetFileName(a.path.Replace('\\', '/')));
try
{
Directory.CreateDirectory(Path.GetDirectoryName(f));
File.WriteAllBytes(f, pkg.ReadAsset(a.path));
_attRows.Add(new AttachmentRow { Kind = a.kind, Lang = a.lang ?? "", Source = f, Model = "" });
}
catch { }
}
_gridAttach.ItemsSource = _attRows;
_tbOut.Text = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "sample", series.code + "_edited.opc");
MessageBox.Show(string.Format("已导入系列 {0}: {1} 个变体, {2} 个参数, {3} 条规则, {4} 个附件\n\n源文件在 {5}", series.code,
series.variants == null ? 0 : series.variants.Count, _paramRows.Count, _ruleRows.Count, _attRows.Count, tmp), Title);
}
catch (Exception ex)
{
MessageBox.Show("导入失败: " + ex.Message, Title, MessageBoxButton.OK, MessageBoxImage.Error);
}
}
void PickStepDir()
{
var dlg = new System.Windows.Forms.FolderBrowserDialog { Description = "选择 STEP 文件目录" };
if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
_tbStepDir.Text = dlg.SelectedPath;
}
// ---- 数据源目录 (统一路线) ----
string DefaultSourceDir()
{
// exe: <工作区>\OnebotCatalog\bin\ → 源目录 OnebotCatalog\onebot-data\catalog (自包含布局)
return Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "onebot-data", "catalog"));
}
string GetSourceDir()
{
var d = _tbSrcDir != null ? _tbSrcDir.Text.Trim() : "";
return Directory.Exists(d) ? d : null;
}
void ReloadSeriesList()
{
_cbSeries.Items.Clear();
var src = GetSourceDir();
if (src == null) return;
var dir = Path.Combine(src, "series");
if (!Directory.Exists(dir)) return;
foreach (var f in Directory.GetFiles(dir, "*.meta.json").OrderBy(x => x))
{
// KC.meta.json → KC (GetFileNameWithoutExtension 只剥 .json, 需再剥 .meta)
_cbSeries.Items.Add(Path.GetFileName(f).Replace(".meta.json", ""));
}
}
void OpenSeriesFromSource()
{
if (_cbSeries.SelectedItem == null) return;
var src = GetSourceDir();
if (src == null) return;
var code = (string)_cbSeries.SelectedItem;
try
{
LoadMetaFile(Path.Combine(src, "series", code + ".meta.json"));
var csvPath = Path.Combine(src, "csv", code + ".csv");
if (File.Exists(csvPath)) { _tbCsv.Text = csvPath; PreviewCsv(csvPath); }
_tbStepDir.Text = Path.Combine(src, "step");
_tbVer.Text = ReadCatalogVersion(src) ?? "";
_tbOut.Text = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "sample", code + "_edited.opc");
}
catch (Exception ex) { MessageBox.Show("打开系列失败: " + ex.Message, Title, MessageBoxButton.OK, MessageBoxImage.Error); }
}
string ReadCatalogVersion(string src)
{
try
{
var f = Path.Combine(src, "catalog.json");
if (!File.Exists(f)) return null;
var ser = new System.Web.Script.Serialization.JavaScriptSerializer();
var cat = ser.Deserialize<Ounibo.Catalog.Core.Catalog>(File.ReadAllText(f, Encoding.UTF8));
return cat == null ? null : cat.catalogVersion;
}
catch { return null; }
}
void RebuildFull()
{
var src = GetSourceDir();
if (src == null) { MessageBox.Show("数据源目录无效: " + (_tbSrcDir == null ? "" : _tbSrcDir.Text) + "\n(需含 catalog.json + series\\ + csv\\ + step\\)", Title); return; }
var ver = ReadCatalogVersion(src) ?? "0.1";
var outPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "sample", "OnebotCatalog_" + ver + ".opc");
var res = FullCatalogBuilder.BuildFromSourceDir(src, outPath);
var sb = new StringBuilder();
sb.AppendLine(res.Ok ? "全量重建成功: " + outPath : "全量重建失败");
foreach (var e in res.Errors) sb.AppendLine(" [错误] " + e);
foreach (var w in res.Warnings) sb.AppendLine(" [警告] " + w);
if (res.Ok)
{
try
{
using (var built = OpcPackage.Open(outPath))
{
sb.AppendLine("系列数: " + built.Catalog.series.Count + " | 变体数: " + built.Catalog.series.Sum(s2 => s2.variants.Count));
var issues = PackageValidator.Validate(built);
sb.AppendLine("校验: " + issues.Count(i => i.Severity == "ERROR") + " 错误, " + issues.Count(i => i.Severity == "WARNING") + " 警告");
}
}
catch (Exception ex) { sb.AppendLine("产物校验失败: " + ex.Message); }
}
if (res.Ok && MessageBox.Show(sb.ToString() + "\n\n是否加载到主窗口?", Title, MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes)
_owner.ReplacePackage(outPath);
else
MessageBox.Show(sb.ToString(), Title, MessageBoxButton.OK, res.Ok ? MessageBoxImage.Information : MessageBoxImage.Error);
}
// 自动识别参数 (与 OpcBuilder.AutoDetectParameters 共用逻辑): 数值列 → number, 其他 → enum
void AutoParams()
{
_paramRows.Clear();
if (string.IsNullOrWhiteSpace(_tbCsv.Text)) return;
foreach (var d in OpcBuilder.AutoDetectParameters(_tbCsv.Text))
{
_paramRows.Add(new ParamRow
{
Code = d.code,
NameZh = d.code,
NameEn = d.code,
Unit = d.unit,
Type = d.type,
Values = string.Join(",", (d.values ?? new List<object>()).Select(v => Convert.ToString(v, CultureInfo.InvariantCulture))),
Keywords = d.keywords ?? ""
});
}
_gridParams.ItemsSource = _paramRows;
}
// ---- Step2: 参数定义 ----
TabItem BuildStep2()
{
_gridParams = new DataGrid { AutoGenerateColumns = false, Margin = new Thickness(6) };
_gridParams.Columns.Add(new DataGridTextColumn { Header = "参数代码 Code", Binding = new System.Windows.Data.Binding("Code"), IsReadOnly = true, Width = 100 });
_gridParams.Columns.Add(new DataGridTextColumn { Header = "中文名 NameZh", Binding = new System.Windows.Data.Binding("NameZh"), Width = 110 });
_gridParams.Columns.Add(new DataGridTextColumn { Header = "英文名 NameEn", Binding = new System.Windows.Data.Binding("NameEn"), Width = 110 });
_gridParams.Columns.Add(new DataGridTextColumn { Header = "单位 Unit", Binding = new System.Windows.Data.Binding("Unit"), Width = 55 });
_gridParams.Columns.Add(new DataGridComboBoxColumn { Header = "类型 Type", SelectedItemBinding = new System.Windows.Data.Binding("Type"), ItemsSource = new[] { "number", "enum" }, Width = 85 });
_gridParams.Columns.Add(new DataGridTextColumn { Header = "取值 Values (逗号分隔)", Binding = new System.Windows.Data.Binding("Values"), Width = 170 });
_gridParams.Columns.Add(new DataGridTextColumn { Header = "搜索关键词 Keywords (拼音/英文)", Binding = new System.Windows.Data.Binding("Keywords"), Width = 150 });
_gridParams.Columns.Add(new DataGridTextColumn { Header = "显示名映射 Display (值:显示,值:显示)", Binding = new System.Windows.Data.Binding("Display"), Width = 200 });
_gridParams.ItemsSource = _paramRows;
var hint = new TextBlock
{
Text = "参数定义: Code 不可改; 补全中英文名; Type: number(数值, 支持范围搜索) / enum(枚举); Values 逗号分隔; Keywords 填拼音等供搜索; Display 格式 值:显示,值:显示 (如 :基本型,M:附磁石)。",
Margin = new Thickness(6), TextWrapping = TextWrapping.Wrap
};
var panel = new DockPanel();
DockPanel.SetDock(hint, Dock.Top);
panel.Children.Add(hint);
panel.Children.Add(_gridParams);
var tab = new TabItem { Header = "2. 参数 Parameters" };
tab.Content = panel;
return tab;
}
// ---- Step3: 编码确认 ----
TabItem BuildStep3()
{
var panel = new StackPanel { Margin = new Thickness(6) };
panel.Children.Add(new TextBlock
{
Text = "型号编码 = 参数表首列 (model_code), 直接沿用, 不做模板反解。\nSTEP 文件命名 = 型号编码 + .step (对应 step_file 列)。\n生成时将校验: 编码唯一、STEP 文件存在、参数完整。",
TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 8)
});
var tab = new TabItem { Header = "3. 编码 Coding" };
tab.Content = panel;
return tab;
}
// ---- Step4: 选型规则 ----
TabItem BuildStep4()
{
var panel = new DockPanel { Margin = new Thickness(6) };
var top = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
var btnAdd = new Button { Content = "+ 添加规则", Width = 100, Margin = new Thickness(0, 0, 6, 0) };
btnAdd.Click += (s, e) => _ruleRows.Add(new RuleRow { CondParam = "", CondOp = "eq", CondValue = "", ThenParam = "", ThenOp = "in", ThenValues = "" });
var btnDel = new Button { Content = "- 删除所选", Width = 100 };
btnDel.Click += (s, e) =>
{
var r = _gridRules.SelectedItem as RuleRow;
if (r != null) _ruleRows.Remove(r);
};
top.Children.Add(btnAdd); top.Children.Add(btnDel);
DockPanel.SetDock(top, Dock.Top);
panel.Children.Add(top);
var hint = new TextBlock
{
Text = "规则: 若 条件(参数 运算符 值) 满足, 则 约束(参数 运算符 值列表) 必须满足, 否则该变体无效。\n例: 条件 bore eq 16 → 约束 stroke in 25,50 (即缸径16时行程仅可选25/50)。in 的值用逗号分隔。",
TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 6)
};
DockPanel.SetDock(hint, Dock.Top);
panel.Children.Add(hint);
_gridRules = new DataGrid { AutoGenerateColumns = false };
_gridRules.Columns.Add(new DataGridTextColumn { Header = "条件参数", Binding = new System.Windows.Data.Binding("CondParam"), Width = 105 });
_gridRules.Columns.Add(new DataGridComboBoxColumn { Header = "条件运算符", SelectedItemBinding = new System.Windows.Data.Binding("CondOp"), ItemsSource = new[] { "eq", "ne", "in", "gt", "ge", "lt", "le", "between" }, Width = 95 });
_gridRules.Columns.Add(new DataGridTextColumn { Header = "条件值", Binding = new System.Windows.Data.Binding("CondValue"), Width = 85 });
_gridRules.Columns.Add(new DataGridTextColumn { Header = "约束参数", Binding = new System.Windows.Data.Binding("ThenParam"), Width = 105 });
_gridRules.Columns.Add(new DataGridComboBoxColumn { Header = "约束运算符", SelectedItemBinding = new System.Windows.Data.Binding("ThenOp"), ItemsSource = new[] { "eq", "in" }, Width = 95 });
_gridRules.Columns.Add(new DataGridTextColumn { Header = "约束值(逗号分隔)", Binding = new System.Windows.Data.Binding("ThenValues"), Width = 150 });
_gridRules.ItemsSource = _ruleRows;
panel.Children.Add(_gridRules);
var tab = new TabItem { Header = "4. 规则 Rules" };
tab.Content = panel;
return tab;
}
// ---- Step5: 附件与生成 ----
TabItem BuildStep5()
{
var panel = new DockPanel { Margin = new Thickness(6) };
var top = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
var btnAtt = new Button { Content = "+ 添加附件", Width = 100, Margin = new Thickness(0, 0, 6, 0) };
btnAtt.Click += (s, e) =>
{
var dlg = new OpenFileDialog { Filter = "附件 (*.png;*.jpg;*.pdf)|*.png;*.jpg;*.pdf", Title = "选择附件 (尺寸图/数据表)" };
if (dlg.ShowDialog() == true)
_attRows.Add(new AttachmentRow { Kind = "dimDrawing", Lang = "zh", Source = dlg.FileName, Model = "" });
};
var btnDel = new Button { Content = "- 删除所选", Width = 100, Margin = new Thickness(0, 0, 10, 0) };
btnDel.Click += (s, e) =>
{
var r = _gridAttach.SelectedItem as AttachmentRow;
if (r != null) _attRows.Remove(r);
};
_tbOut = new TextBox { Width = 340, Text = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "sample", "NewCatalog.opc") };
top.Children.Add(btnAtt); top.Children.Add(btnDel); top.Children.Add(new TextBlock { Text = "输出 .opc:", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 6, 0) }); top.Children.Add(_tbOut);
var btnBuild = new Button { Content = "生成数据包 Build", Width = 130, Margin = new Thickness(10, 0, 0, 0) };
btnBuild.Click += (s, e) => DoBuild();
top.Children.Add(btnBuild);
DockPanel.SetDock(top, Dock.Top);
panel.Children.Add(top);
_gridAttach = new DataGrid { AutoGenerateColumns = false, Height = 200 };
_gridAttach.Columns.Add(new DataGridComboBoxColumn { Header = "类型 Kind", SelectedItemBinding = new System.Windows.Data.Binding("Kind"), ItemsSource = new[] { "dimDrawing", "datasheet", "note", "manual" }, Width = 110 });
_gridAttach.Columns.Add(new DataGridComboBoxColumn { Header = "语言 Lang", SelectedItemBinding = new System.Windows.Data.Binding("Lang"), ItemsSource = new[] { "zh", "en" }, Width = 65 });
_gridAttach.Columns.Add(new DataGridTextColumn { Header = "源文件 Source", Binding = new System.Windows.Data.Binding("Source"), IsReadOnly = true, Width = 330 });
_gridAttach.Columns.Add(new DataGridTextColumn { Header = "绑定型号 Model(可选)", Binding = new System.Windows.Data.Binding("Model"), Width = 120 });
_gridAttach.ItemsSource = _attRows;
DockPanel.SetDock(_gridAttach, Dock.Top);
panel.Children.Add(_gridAttach);
_tbResult = new TextBlock { TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 8, 0, 0) };
panel.Children.Add(_tbResult);
var tab = new TabItem { Header = "5. 附件与生成 Build" };
tab.Content = panel;
return tab;
}
object ToNumOrStr(string s)
{
// 前导零的编码 (00/02/03) 保持字符串, 否则会被数值化成 0/2/3 破坏型号匹配
if (s.Length > 1 && s.StartsWith("0") && s.All(char.IsDigit)) return s;
double d;
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out d)) return d;
return s;
}
List<object> ParseValues(string s)
{
// 注意: 保留空串 (枚举值 "" 合法, 如 磁石=无磁石)
return s.Split(new[] { ',', '', ';', '' })
.Select(x => x.Trim())
.Select(x => (object)ToNumOrStr(x))
.ToList();
}
Ounibo.Catalog.Core.Rule BuildRule(RuleRow r)
{
if (string.IsNullOrWhiteSpace(r.CondParam) || string.IsNullOrWhiteSpace(r.ThenParam)) return null;
RuleCond If, Then;
If = new RuleCond { Param = r.CondParam.Trim(), Op = r.CondOp, Value = ToNumOrStr(r.CondValue.Trim()) };
if (r.ThenOp == "in")
Then = new RuleCond { Param = r.ThenParam.Trim(), Op = "in", Value = ParseValues(r.ThenValues).ToArray() };
else
Then = new RuleCond { Param = r.ThenParam.Trim(), Op = r.ThenOp, Value = ToNumOrStr(r.ThenValues.Trim()) };
return new Ounibo.Catalog.Core.Rule { If = If, Then = Then };
}
// ---- 配置保存/加载 (SeriesMeta 格式, 与源目录 series\*.meta.json 及 CLI --meta 完全通用) ----
SeriesMeta BuildMetaFromUi()
{
return new SeriesMeta
{
series = new Series
{
code = _tbSeriesCode.Text.Trim(),
nameZh = _tbSeriesZh.Text.Trim(),
nameEn = _tbSeriesEn.Text.Trim(),
keywords = "",
parameters = _paramRows.Where(p => !string.IsNullOrWhiteSpace(p.Code)).Select(p => new ParameterDef
{
code = p.Code,
nameZh = p.NameZh,
nameEn = p.NameEn,
unit = p.Unit ?? "",
type = p.Type == "number" ? "number" : "enum",
keywords = p.Keywords ?? "",
values = ParseValues(p.Values ?? ""),
display = ParseDisplay(p.Display ?? "")
}).ToList(),
rules = _ruleRows.Select(BuildRule).Where(r => r != null).ToList(),
naming = new Naming { stepNameTemplate = "{model}.step" },
attachments = new List<Attachment>(),
variants = new List<Variant>()
},
attachments = _attRows.Where(a => !string.IsNullOrWhiteSpace(a.Source)).Select(a => new BuildAttachmentSpec
{
kind = a.Kind,
lang = a.Lang,
source = a.Source,
model = string.IsNullOrWhiteSpace(a.Model) ? null : a.Model.Trim()
}).ToList()
};
}
Dictionary<string, string> ParseDisplay(string s)
{
var d = new Dictionary<string, string>();
if (string.IsNullOrWhiteSpace(s)) return d;
foreach (var part in s.Split(new[] { ',', '', ';', '' }))
{
var kv = part.Split(new[] { ':' }, 2);
if (kv.Length == 2) d[kv[0].Trim()] = kv[1].Trim();
}
return d.Count == 0 ? null : d;
}
string FormatDisplay(Dictionary<string, string> d)
{
if (d == null || d.Count == 0) return "";
return string.Join(",", d.Select(kv => kv.Key + ":" + kv.Value));
}
// 保存到源目录 (series\<code>.meta.json) 或另存为
void SaveConfig()
{
var code = _tbSeriesCode.Text.Trim();
var srcDir = GetSourceDir();
var target = Directory.Exists(srcDir) && !string.IsNullOrEmpty(code)
? Path.Combine(srcDir, "series", code + ".meta.json")
: null;
if (target == null || (!File.Exists(target) && !string.IsNullOrEmpty(_loadedMetaPath)))
{
// 系列代码改名 → 沿用已加载文件路径
if (!string.IsNullOrEmpty(_loadedMetaPath)) target = _loadedMetaPath;
}
if (target == null || !File.Exists(target))
{
var dlg = new SaveFileDialog { Filter = "Builder 配置 (*.json)|*.json", FileName = "builder-config.json", Title = "保存配置" };
if (dlg.ShowDialog() != true) return;
target = dlg.FileName;
}
try
{
var ser = new System.Web.Script.Serialization.JavaScriptSerializer();
var meta = BuildMetaFromUi();
// 覆盖已有配置时保留系列关键词 (向导无此字段, 避免清空搜索关键词)
if (File.Exists(target))
{
try
{
var old = ser.Deserialize<SeriesMeta>(File.ReadAllText(target, Encoding.UTF8));
if (old != null && old.series != null && string.Equals(old.series.code, meta.series.code, StringComparison.OrdinalIgnoreCase))
meta.series.keywords = old.series.keywords;
}
catch { }
}
Directory.CreateDirectory(Path.GetDirectoryName(target));
File.WriteAllText(target, ser.Serialize(meta), Encoding.UTF8);
_loadedMetaPath = target;
MessageBox.Show("配置已保存: " + target + (target.IndexOf("series" + Path.DirectorySeparatorChar) >= 0 ? "\n(点【重建全量目录】后生效)" : ""), Title);
}
catch (Exception ex) { MessageBox.Show("保存失败: " + ex.Message, Title, MessageBoxButton.OK, MessageBoxImage.Error); }
}
static string ValToStr(object v)
{
var arr = v as object[];
if (arr != null) return string.Join(",", arr.Select(x => Convert.ToString(x, CultureInfo.InvariantCulture)));
return v == null ? "" : Convert.ToString(v, CultureInfo.InvariantCulture);
}
void LoadConfig()
{
var dlg = new OpenFileDialog { Filter = "Builder 配置 (*.json)|*.json", Title = "加载配置" };
if (dlg.ShowDialog() != true) return;
try { LoadMetaFile(dlg.FileName); }
catch (Exception ex) { MessageBox.Show("加载失败: " + ex.Message, Title, MessageBoxButton.OK, MessageBoxImage.Error); }
}
void LoadMetaFile(string metaPath)
{
var ser = new System.Web.Script.Serialization.JavaScriptSerializer();
var meta = ser.Deserialize<SeriesMeta>(File.ReadAllText(metaPath, Encoding.UTF8));
if (meta == null || meta.series == null) { MessageBox.Show("配置格式无效", Title); return; }
_loadedMetaPath = metaPath;
_tbSeriesCode.Text = meta.series.code ?? "";
_tbSeriesZh.Text = meta.series.nameZh ?? "";
_tbSeriesEn.Text = meta.series.nameEn ?? "";
_paramRows.Clear();
foreach (var p in meta.series.parameters ?? new List<ParameterDef>())
{
_paramRows.Add(new ParamRow
{
Code = p.code,
NameZh = p.nameZh,
NameEn = p.nameEn,
Unit = p.unit ?? "",
Type = p.type,
Keywords = p.keywords ?? "",
Values = string.Join(",", (p.values ?? new List<object>()).Select(v => Convert.ToString(v, CultureInfo.InvariantCulture))),
Display = FormatDisplay(p.display)
});
}
_gridParams.ItemsSource = _paramRows;
_ruleRows.Clear();
foreach (var r in meta.series.rules ?? new List<Ounibo.Catalog.Core.Rule>())
{
_ruleRows.Add(new RuleRow
{
CondParam = r.If != null ? r.If.Param : "",
CondOp = r.If != null ? r.If.Op : "eq",
CondValue = r.If != null ? ValToStr(r.If.Value) : "",
ThenParam = r.Then != null ? r.Then.Param : "",
ThenOp = r.Then != null ? r.Then.Op : "in",
ThenValues = r.Then != null ? ValToStr(r.Then.Value) : ""
});
}
_gridRules.ItemsSource = _ruleRows;
_attRows.Clear();
foreach (var a in meta.attachments ?? new List<BuildAttachmentSpec>())
{
_attRows.Add(new AttachmentRow { Kind = a.kind, Lang = a.lang, Source = a.source, Model = a.model ?? "" });
}
_gridAttach.ItemsSource = _attRows;
}
void DoBuild()
{
_tbResult.Text = "";
var sb = new System.Text.StringBuilder();
sb.AppendLine("开始打包...");
var series = new Series
{
code = _tbSeriesCode.Text.Trim(),
nameZh = _tbSeriesZh.Text.Trim(),
nameEn = _tbSeriesEn.Text.Trim(),
keywords = "",
parameters = _paramRows.Where(p => !string.IsNullOrWhiteSpace(p.Code)).Select(p => new ParameterDef
{
code = p.Code,
nameZh = string.IsNullOrWhiteSpace(p.NameZh) ? p.Code : p.NameZh,
nameEn = string.IsNullOrWhiteSpace(p.NameEn) ? p.Code : p.NameEn,
unit = p.Unit ?? "",
type = p.Type == "number" ? "number" : "enum",
keywords = p.Keywords ?? "",
values = ParseValues(p.Values ?? "")
}).ToList(),
rules = _ruleRows.Select(BuildRule).Where(r => r != null).ToList(),
naming = new Naming { stepNameTemplate = "{model}.step" },
attachments = new List<Attachment>(),
variants = new List<Variant>()
};
if (string.IsNullOrEmpty(series.code)) { MessageBox.Show("请填写系列代码", Title); return; }
if (_paramRows.Count == 0) { MessageBox.Show("请先在 Step1 导入参数表", Title); return; }
var res = OpcBuilder.Build(
_tbOut.Text.Trim(),
"欧霓博气动目录", _tbVer.Text.Trim(),
"ACT", _tbCatName.Text.Trim(), _tbCatName.Text.Trim(),
series,
_tbCsv.Text, _tbStepDir.Text,
_attRows.Where(a => !string.IsNullOrWhiteSpace(a.Source)).Select(a => new BuildAttachmentSpec
{
kind = a.Kind, lang = a.Lang, source = a.Source, model = string.IsNullOrWhiteSpace(a.Model) ? null : a.Model.Trim()
}).ToList());
if (!res.Ok)
{
sb.AppendLine("打包失败:");
foreach (var e in res.Errors.Take(20)) sb.AppendLine(" [错误] " + e);
_tbResult.Text = sb.ToString();
return;
}
sb.AppendLine("打包成功: " + res.OutputPath);
foreach (var w in res.Warnings.Take(10)) sb.AppendLine(" [警告] " + w);
// 打包后立即校验
try
{
using (var pkg = OpcPackage.Open(res.OutputPath))
{
var issues = PackageValidator.Validate(pkg);
sb.AppendLine("校验: " + issues.Count(i => i.Severity == "ERROR") + " 错误, " + issues.Count(i => i.Severity == "WARNING") + " 警告");
foreach (var i in issues.Take(15)) sb.AppendLine(" [" + i.Severity + "] " + i.Target + " " + i.Message);
sb.AppendLine("变体数: " + pkg.Catalog.series.Sum(s2 => s2.variants.Count));
}
}
catch (Exception ex) { sb.AppendLine("校验失败: " + ex.Message); }
_tbResult.Text = sb.ToString();
if (MessageBox.Show("数据包已生成, 是否加载到主窗口?\n注意: 将替换主窗口当前数据包 (若只做了单系列, 加载后目录只剩该系列)", Title, MessageBoxButton.YesNo, MessageBoxImage.Warning) == MessageBoxResult.Yes)
_owner.ReplacePackage(res.OutputPath);
}
}
}
#endif

119
src/App/Cart.cs Normal file
View File

@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
namespace Ounibo.Catalog.App
{
/// <summary>
/// 购物车条目 (清单式批量导出; 2026-08-24 与网页版语义一致)。
/// Qty 仅体现在 BOM 数量列, zip 中每型号只打包一份。
/// </summary>
public class CartItem : INotifyPropertyChanged
{
public string ModelCode; // 型号编码
public string SeriesCode; // 系列代码
public string SeriesName; // 系列名
public string StepPath; // 包内 STEP 路径 (非标档位为 null, 打包时跳过并注明)
public string ParamsSummary; // 参数摘要 "key=value; ..."
public bool IsStandard = true;// 非标档位标记 (参数化引擎, 2026-08-25)
public Dictionary<string, object> Params; // 参数原始值 (非标批量在线生成提交用, 二期)
int _qty = 1;
public int Qty { get { return _qty; } set { _qty = Math.Max(1, value); OnChanged("Qty"); } }
public event PropertyChangedEventHandler PropertyChanged;
void OnChanged(string n) { if (PropertyChanged != null) PropertyChanged(this, new PropertyChangedEventArgs(n)); }
}
/// <summary>清单窗口 (非模态; 购物车数据在主窗口, 本窗口只是视图)。</summary>
public class CartWindow : Window
{
List<CartItem> _cart;
Action _onChanged; // 数量/删除/清空后回写角标
Func<System.Threading.Tasks.Task<string>> _onDownload; // 打包回调 (异步, 含在线生成), 返回 null 成功 / 错误提示
DataGrid _grid;
TextBlock _countText;
public CartWindow(List<CartItem> cart, Action onChanged, Func<System.Threading.Tasks.Task<string>> onDownload)
{
_cart = cart;
_onChanged = onChanged;
_onDownload = onDownload;
Title = "我的清单 My Cart";
Width = 800; Height = 500; MinWidth = 580; MinHeight = 320;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
Owner = Application.Current != null ? Application.Current.MainWindow : null;
var root = new DockPanel { Margin = new Thickness(10) };
var top = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 8) };
_countText = new TextBlock { VerticalAlignment = VerticalAlignment.Center, FontWeight = FontWeights.Bold, FontSize = 14 };
top.Children.Add(_countText);
DockPanel.SetDock(top, Dock.Top);
root.Children.Add(top);
var bottom = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 8, 0, 0) };
var btnClear = new Button { Content = "清空 Clear All", Width = 100, Margin = new Thickness(0, 0, 8, 0) };
btnClear.Click += (s, e) => { _cart.Clear(); Refresh(); };
var btnDownload = new Button { Content = "下载全部 (zip + BOM)", Width = 190, Padding = new Thickness(12, 6, 12, 6), FontSize = 14 };
btnDownload.Click += async (s, e) =>
{
if (_cart.Count == 0) { MessageBox.Show("清单为空, 请先加入型号", Title); return; }
btnDownload.IsEnabled = false;
try
{
var err = await _onDownload();
if (err != null) MessageBox.Show(err, Title, MessageBoxButton.OK, MessageBoxImage.Warning);
}
finally { btnDownload.IsEnabled = true; }
};
bottom.Children.Add(btnClear); bottom.Children.Add(btnDownload);
DockPanel.SetDock(bottom, Dock.Bottom);
root.Children.Add(bottom);
_grid = new DataGrid { AutoGenerateColumns = false, IsReadOnly = false, CanUserAddRows = false, CanUserDeleteRows = false };
_grid.Columns.Add(new DataGridTextColumn { Header = "型号 Model", Binding = new System.Windows.Data.Binding("ModelCode"), Width = 150, IsReadOnly = true });
_grid.Columns.Add(new DataGridTextColumn { Header = "系列 Series", Binding = new System.Windows.Data.Binding("SeriesName"), Width = new System.Windows.Controls.DataGridLength(1, System.Windows.Controls.DataGridLengthUnitType.Star), IsReadOnly = true });
_grid.Columns.Add(new DataGridTextColumn { Header = "规格 Params", Binding = new System.Windows.Data.Binding("ParamsSummary"), Width = 200, IsReadOnly = true });
_grid.Columns.Add(new DataGridTextColumn { Header = "数量 Qty", Binding = new System.Windows.Data.Binding("Qty") { UpdateSourceTrigger = System.Windows.Data.UpdateSourceTrigger.LostFocus }, Width = 70 });
var delCol = new DataGridTemplateColumn { Header = "操作", Width = 80 };
delCol.CellTemplate = MakeTemplate(item => { _cart.Remove(item); Refresh(); });
_grid.Columns.Add(delCol);
_grid.CellEditEnding += (s, e) => { Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background, new Action(Refresh)); };
root.Children.Add(_grid);
Content = root;
Refresh();
}
System.Windows.DataTemplate MakeTemplate(Action<CartItem> del)
{
var fact = new System.Windows.FrameworkElementFactory(typeof(Button));
fact.SetValue(Button.ContentProperty, "删除");
fact.SetValue(Button.PaddingProperty, new Thickness(6, 2, 6, 2));
fact.SetValue(Button.FontSizeProperty, 12.0);
fact.SetValue(Button.FocusableProperty, false); // 不抢焦点, 避免干扰网格行交互
fact.AddHandler(Button.ClickEvent, new RoutedEventHandler((s, e) =>
{
var dc = ((FrameworkElement)s).DataContext as CartItem; // 模板列取 DataContext 删行
if (dc != null) del(dc);
e.Handled = true;
}));
var tpl = new System.Windows.DataTemplate();
tpl.VisualTree = fact;
return tpl;
}
public void Refresh()
{
_countText.Text = "共 " + _cart.Count + " 项 | 数量只在 BOM 中体现, zip 每型号一份";
_grid.ItemsSource = null;
_grid.ItemsSource = _cart;
if (_onChanged != null) _onChanged();
}
}
}

View File

@@ -0,0 +1,133 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
namespace Ounibo.Catalog.App
{
/// <summary>
/// 本机 3D 预览微型服务器 (真实数模用, 完全离线):
/// 在 127.0.0.1 随机端口服务 新迪查看器本地副本 + 临时 STEP 文件,
/// 桌面版点【在浏览器中打开 3D 预览】即用系统浏览器打开, 无外部依赖。
/// </summary>
public class LocalViewerServer : IDisposable
{
readonly TcpListener _listener;
readonly Thread _thread;
readonly string _viewerRoot;
readonly string _stepDir;
volatile bool _running = true;
public int Port { get; private set; }
static readonly Dictionary<string, string> Mime = new Dictionary<string, string>
{
{ ".html", "text/html; charset=utf-8" }, { ".js", "application/javascript; charset=utf-8" },
{ ".css", "text/css; charset=utf-8" }, { ".png", "image/png" }, { ".jpg", "image/jpeg" },
{ ".wasm", "application/wasm" }, { ".ico", "image/x-icon" }, { ".json", "application/json; charset=utf-8" },
{ ".step", "application/octet-stream" }, { ".stp", "application/octet-stream" }
};
public LocalViewerServer(string viewerRoot)
{
_viewerRoot = Path.GetFullPath(viewerRoot);
_stepDir = Path.Combine(Path.GetTempPath(), "onebot_preview");
Directory.CreateDirectory(_stepDir);
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
_thread = new Thread(Loop) { IsBackground = true, Name = "LocalViewerServer" };
_thread.Start();
}
void Loop()
{
while (_running)
{
TcpClient client = null;
try { client = _listener.AcceptTcpClient(); }
catch { if (!_running) break; Thread.Sleep(500); continue; }
try { Handle(client); }
catch { }
try { client.Close(); } catch { }
}
}
void Handle(TcpClient client)
{
var stream = client.GetStream();
stream.ReadTimeout = 3000;
stream.WriteTimeout = 15000;
var reader = new StreamReader(stream, Encoding.ASCII, false, 4096, true);
var reqLine = reader.ReadLine();
if (string.IsNullOrEmpty(reqLine)) return;
var line = reader.ReadLine();
var guard = 0;
while (line != null && line != "")
{
line = reader.ReadLine();
if (++guard > 100) break;
}
var path = "/";
var parts = reqLine.Split(' ');
if (parts.Length >= 2) path = parts[1];
var qi = path.IndexOf('?');
if (qi >= 0) path = path.Substring(0, qi);
if (path == "/") path = "/STEPViewer/index.html";
// 双根: /STEPViewer/... → 查看器副本; /step/... → 临时 STEP 目录
string root, rel;
if (path.StartsWith("/step/", StringComparison.OrdinalIgnoreCase))
{
root = _stepDir;
rel = path.Substring("/step/".Length).Replace('/', '\\');
}
else
{
root = _viewerRoot;
rel = path.Substring(1).Replace('/', '\\');
}
byte[] bytes; string status = "200 OK"; string ctype = "application/octet-stream";
var full = Path.GetFullPath(Path.Combine(root, rel));
if (full.StartsWith(Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase) && File.Exists(full))
{
bytes = File.ReadAllBytes(full);
var ext = Path.GetExtension(full).ToLowerInvariant();
if (Mime.ContainsKey(ext)) ctype = Mime[ext];
}
else
{
status = "404 Not Found";
bytes = Encoding.UTF8.GetBytes("404 Not Found");
ctype = "text/plain; charset=utf-8";
}
var head = Encoding.ASCII.GetBytes(
"HTTP/1.1 " + status + "\r\nContent-Type: " + ctype +
"\r\nCache-Control: no-cache\r\nContent-Length: " + bytes.Length +
"\r\nConnection: close\r\n\r\n");
stream.Write(head, 0, head.Length);
stream.Write(bytes, 0, bytes.Length);
stream.Flush();
}
/// <summary>把 STEP 内容写入临时服务目录, 返回浏览器可打开的查看器 URL (嵌入+精简菜单+无模型树)。</summary>
public string PublishStep(string fileName, byte[] content)
{
var safe = Path.GetFileName(fileName.Replace('\\', '/'));
File.WriteAllBytes(Path.Combine(_stepDir, safe), content);
return string.Format(
"http://127.0.0.1:{0}/STEPViewer/index.html?file=/step/{1}&embed=1&bg=grey&menu=min",
Port, Uri.EscapeDataString(safe));
}
public void Dispose()
{
_running = false;
try { _listener.Stop(); } catch { }
}
}
}

1576
src/App/MainWindow.cs Normal file

File diff suppressed because it is too large Load Diff

108
src/App/ViewerControl.cs Normal file
View File

@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Media3D;
using Ounibo.Catalog.Viewer3D;
namespace Ounibo.Catalog.App
{
/// <summary>
/// 3D 预览控件: Viewport3D + 鼠标左键拖拽旋转 + 滚轮缩放。
/// </summary>
public class ViewerControl : UserControl
{
Viewport3D _vp;
PerspectiveCamera _cam;
Model3DGroup _root;
Point _last;
double _theta = 0.6, _phi = 0.9, _dist = 300;
bool _dragging;
public ViewerControl()
{
_root = new Model3DGroup();
_root.Children.Add(new DirectionalLight(Colors.White, new Vector3D(-1, -1, -2)));
_root.Children.Add(new DirectionalLight(Colors.White, new Vector3D(1, 0.5, 1)));
_cam = new PerspectiveCamera
{
Position = new Point3D(0, 0, 300),
LookDirection = new Vector3D(0, 0, -1),
UpDirection = new Vector3D(0, 1, 0),
FieldOfView = 45
};
_vp = new Viewport3D { Camera = _cam };
_vp.Children.Add(new ModelVisual3D { Content = _root });
Content = new Border { Background = Brushes.White, Child = _vp };
_vp.MouseLeftButtonDown += (s, e) => { _dragging = true; _last = e.GetPosition(_vp); _vp.CaptureMouse(); };
_vp.MouseMove += (s, e) =>
{
if (!_dragging) return;
var p = e.GetPosition(_vp);
var dx = p.X - _last.X;
var dy = p.Y - _last.Y;
_last = p;
_theta -= dx * 0.01;
_phi = Math.Max(-1.45, Math.Min(1.45, _phi + dy * 0.01));
UpdateCamera();
};
_vp.MouseLeftButtonUp += (s, e) => { _dragging = false; _vp.ReleaseMouseCapture(); };
_vp.MouseWheel += (s, e) =>
{
_dist *= (e.Delta > 0) ? 0.88 : 1.13;
_dist = Math.Max(20, Math.Min(4000, _dist));
UpdateCamera();
};
}
public void Clear()
{
var lights = new List<Light>();
foreach (var m in _root.Children)
if (m is Light) lights.Add((Light)m);
_root.Children.Clear();
foreach (var l in lights) _root.Children.Add(l);
}
/// <summary>加载圆柱列表并自动取景。</summary>
public void Load(List<Cyl> cyls)
{
Clear();
if (cyls == null || cyls.Count == 0) return;
// 颜色: 0 后盖 / 1 缸体 / 2 前盖 / 3 活塞杆
var brushes = new[]
{
new SolidColorBrush(Color.FromRgb(0x8C, 0x8C, 0x8C)),
new SolidColorBrush(Color.FromRgb(0x2F, 0x6F, 0xB3)),
new SolidColorBrush(Color.FromRgb(0x8C, 0x8C, 0x8C)),
new SolidColorBrush(Color.FromRgb(0xC8, 0xC8, 0xC8))
};
for (int i = 0; i < cyls.Count; i++)
{
var mesh = StepMesh.BuildMesh(cyls[i]);
var mat = new DiffuseMaterial(brushes[i % brushes.Length]);
_root.Children.Add(new GeometryModel3D(mesh, mat) { BackMaterial = mat });
}
double cx, cy, cz, r, h;
StepMesh.Bounds(cyls, out cx, out cy, out cz, out r, out h);
_dist = Math.Max(120, Math.Max(r * 5.0, h * 1.6));
_theta = 0.55; _phi = 0.85;
UpdateCamera();
}
void UpdateCamera()
{
double x = _dist * Math.Sin(_theta) * Math.Cos(_phi);
double y = _dist * Math.Sin(_phi);
double z = _dist * Math.Cos(_theta) * Math.Cos(_phi);
_cam.Position = new Point3D(x, y, z);
_cam.LookDirection = new Vector3D(-x, -y, -z);
}
}
}

View File

@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace Ounibo.Catalog.Core
{
/// <summary>
/// 选型引擎: 规则求值 + 允许值过滤 + 变体解析。
/// 规则语义: 若某变体满足规则 If 条件, 则必须同时满足 Then 条件, 否则该变体无效。
/// </summary>
public class Configurator
{
public Series Series { get; private set; }
private readonly List<Variant> _all;
public Configurator(Series s)
{
Series = s;
_all = s.variants ?? new List<Variant>();
}
/// <summary>参数化模式 (mode=parametric): 选型可超出 CSV 网格, 由 ParametricEngine 生成非标组合。</summary>
public bool IsParametric { get { return ParametricEngine.IsParametric(Series); } }
// 值比较统一走字符串 (枚举值如 16 / "16.0" / "S" 均可比)
static string S(object o) { return o == null ? "" : Convert.ToString(o, CultureInfo.InvariantCulture); }
static bool Eq(object a, object b) { return string.Equals(S(a), S(b), StringComparison.OrdinalIgnoreCase); }
static double D(object o) { return Convert.ToDouble(o, CultureInfo.InvariantCulture); }
public bool CondMatches(RuleCond c, Dictionary<string, object> p)
{
if (c == null) return true;
object val;
if (!p.TryGetValue(c.Param, out val)) return false;
switch (c.Op)
{
case "eq": return Eq(val, c.Value);
case "ne": return !Eq(val, c.Value);
case "in":
var arr = c.Value as object[];
return arr != null && arr.Any(x => Eq(val, x));
case "gt": return D(val) > D(c.Value);
case "ge": return D(val) >= D(c.Value);
case "lt": return D(val) < D(c.Value);
case "le": return D(val) <= D(c.Value);
case "between":
var r = c.Value as object[];
return r != null && r.Length == 2 && D(val) >= D(r[0]) && D(val) <= D(r[1]);
default: return false;
}
}
public bool RuleViolated(Variant v)
{
foreach (var rule in Series.rules ?? Enumerable.Empty<Rule>())
if (CondMatches(rule.If, v.@params) && !CondMatches(rule.Then, v.@params))
return true;
return false;
}
/// <summary>
/// 部分选择下的规则违背判定 (参数化联动用): Then 参数未选时视为"尚可满足", 不判违背 —
/// 否则选 mount=YB 会瞬间清空其他未选参数的选项 (参数化引擎一期, 与网页同语义)。
/// </summary>
public bool RuleViolatedPartial(Dictionary<string, object> p)
{
foreach (var rule in Series.rules ?? Enumerable.Empty<Rule>())
if (CondMatches(rule.If, p) && p.ContainsKey(rule.Then.Param) && !CondMatches(rule.Then, p))
return true;
return false;
}
public List<Variant> ValidVariants()
{
return _all.Where(v => !RuleViolated(v)).ToList();
}
public bool Matches(Variant v, Dictionary<string, object> sel)
{
foreach (var kv in sel)
{
object val;
if (!v.@params.TryGetValue(kv.Key, out val) || !Eq(val, kv.Value))
return false;
}
return true;
}
/// <summary>
/// 每个参数在"当前已选 + 规则"约束下的允许值 (保持参数定义顺序)。
/// 已选参数按"除自身外的其他已选"计算可改值 (选完也能调整),
/// 未选参数按全部已选计算; 均用于 UI 自动清除被置为非法的选择。
/// </summary>
public Dictionary<string, List<object>> AllowedOptions(Dictionary<string, object> sel)
{
var result = new Dictionary<string, List<object>>();
foreach (var p in Series.parameters ?? new List<ParameterDef>())
{
// 候选值: 枚举/数值参数取定义值; range 参数取 gridValues 快捷值
var cands = p.type == "range"
? (p.gridValues ?? p.values ?? new List<object>())
: (p.values ?? new List<object>());
var selForP = sel.Where(kv => kv.Key != p.code).ToDictionary(kv => kv.Key, kv => kv.Value);
var allowed = new List<object>();
// 联动按"域+规则"判合法 (参数化引擎一期): 自由 range 值没有对应网格行,
// 若按网格行存在性过滤会把其他参数选项瞬间清空 — 与网页同语义
foreach (var val in cands)
{
var combo = new Dictionary<string, object>(selForP) { [p.code] = val };
if (ParametricEngine.ValidatePartial(Series, combo) == null)
allowed.Add(val);
}
// range 参数: 保留当前已选的自由输入值 (域内合法) — 防止联动刷新时清掉自定义值
if (p.type == "range" && sel.ContainsKey(p.code) &&
!allowed.Any(x => Eq(x, sel[p.code])) && ParametricEngine.DomainValid(p, sel[p.code]))
allowed.Add(sel[p.code]);
result[p.code] = allowed;
}
return result;
}
/// <summary>参数选满且合法时返回变体, 否则 null。</summary>
public Variant Resolve(Dictionary<string, object> sel)
{
if (sel.Count < (Series.parameters?.Count ?? 0)) return null;
return ValidVariants().FirstOrDefault(v => Matches(v, sel));
}
}
}

View File

@@ -0,0 +1,235 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Web.Script.Serialization;
namespace Ounibo.Catalog.Core
{
/// <summary>
/// 系列配置 (源目录 series\&lt;CODE&gt;.meta.json 的格式, 与向导 保存配置 完全同构)。
/// </summary>
public class SeriesMeta
{
public Series series { get; set; }
public List<BuildAttachmentSpec> attachments { get; set; }
}
/// <summary>
/// 全量目录构建器 (统一路线, 2026-08-22):
/// 唯一数据源 = 源目录 (默认 onebot-data\catalog\), 结构:
/// catalog.json 目录定义 (名称/版本/分类/系列顺序, 与包内 catalog.json 同构, series 部分留空)
/// series\&lt;CODE&gt;.meta.json 每系列定义 (参数/规则/附件, 向导"保存配置"直接写这里)
/// csv\&lt;CODE&gt;.csv 参数表 (model_code + 参数列 + step_file 列)
/// step\*.step STEP 文件 (几何生成器或 NX 导出)
/// manual\... 等附件源文件 (meta 里 attachments.source 为相对源目录的路径)
/// 构建: 全部系列 → 一个 .opc (单一路线, 不再需要 PowerShell 打包)。
/// </summary>
public static class FullCatalogBuilder
{
public static BuildResult BuildFromSourceDir(string sourceDir, string outPath)
{
var r = new BuildResult { Ok = true };
try
{
var catFile = Path.Combine(sourceDir, "catalog.json");
if (!File.Exists(catFile)) { r.Errors.Add("源目录缺少 catalog.json: " + sourceDir); r.Ok = false; return r; }
var ser = new JavaScriptSerializer { MaxJsonLength = int.MaxValue };
var cat = ser.Deserialize<Catalog>(File.ReadAllText(catFile, Encoding.UTF8));
if (cat == null || cat.categories == null || cat.categories.Count == 0)
{ r.Errors.Add("catalog.json 无效 (缺 categories)"); r.Ok = false; return r; }
var stepDir = Path.Combine(sourceDir, "step");
if (!Directory.Exists(stepDir)) { r.Errors.Add("STEP 目录不存在: " + stepDir); r.Ok = false; return r; }
// 系列代码按分类顺序收集
var codes = new List<string>();
foreach (var c in cat.categories)
foreach (var sc in c.series ?? new List<string>())
if (!codes.Contains(sc)) codes.Add(sc);
// 每系列: meta + CSV → 变体 + 附件
var builtSeries = new List<Series>();
var filesToCopy = new List<Tuple<string, string>>(); // (源绝对路径, 包内相对路径)
foreach (var code in codes)
{
var metaPath = Path.Combine(sourceDir, "series", code + ".meta.json");
if (!File.Exists(metaPath)) { r.Errors.Add("系列配置缺失: " + metaPath); continue; }
SeriesMeta meta;
try { meta = ser.Deserialize<SeriesMeta>(File.ReadAllText(metaPath, Encoding.UTF8)); }
catch (Exception ex) { r.Errors.Add("系列配置解析失败 " + code + ": " + ex.Message); continue; }
var s = meta == null ? null : meta.series;
if (s == null) { r.Errors.Add("系列配置无效: " + metaPath); continue; }
var csvPath = Path.Combine(sourceDir, "csv", code + ".csv");
if (!File.Exists(csvPath)) { r.Errors.Add("CSV 缺失: " + csvPath); continue; }
var vr = new BuildResult { Ok = true };
var variants = OpcBuilder.ReadCsvVariants(s, csvPath, stepDir, vr);
foreach (var e in vr.Errors) r.Errors.Add("[" + code + "] " + e);
if (variants == null || variants.Count == 0) { r.Errors.Add("系列 " + code + " 无有效变体"); continue; }
s.variants = variants;
// 参数化系列 (mode=parametric): 域校验 + 模板对拍门禁 (CSV 全部编码必须能被模板重新生成且一一对应)
if (ParametricEngine.IsParametric(s))
{
if (string.IsNullOrEmpty(s.modelCodeTemplate))
r.Errors.Add("[" + code + "] 参数化系列缺少 modelCodeTemplate");
foreach (var p in s.parameters ?? new List<ParameterDef>())
{
if (p.type != "range") continue;
if (p.min == null || p.max == null || p.min.Value > p.max.Value)
{ r.Errors.Add("[" + code + "] 参数 " + p.code + " range 域非法 (min/max)"); continue; }
if ((p.step ?? 1) <= 0) { r.Errors.Add("[" + code + "] 参数 " + p.code + " step 非法"); continue; }
foreach (var gv in p.gridValues ?? new List<object>())
if (!ParametricEngine.DomainValid(p, gv))
r.Errors.Add("[" + code + "] 参数 " + p.code + " gridValues 含域外值: " + gv);
}
if (!string.IsNullOrEmpty(s.modelCodeTemplate))
{
var codeToSig = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var v in variants)
{
var regen = ParametricEngine.RenderCode(s.modelCodeTemplate, v.@params);
if (regen == null || !string.Equals(regen, v.modelCode, StringComparison.OrdinalIgnoreCase))
r.Errors.Add("[" + code + "] 对拍门禁失败: " + v.modelCode + " 模板重生成=" + (regen ?? "null"));
else
{
var sig = string.Join("|", (s.parameters ?? new List<ParameterDef>())
.Select(x => x.code + "=" + (v.@params.ContainsKey(x.code) ? v.@params[x.code].ToString() : "")));
string prev;
if (codeToSig.TryGetValue(regen, out prev))
{ if (prev != sig) r.Errors.Add("[" + code + "] 模板多对一: " + regen + " 对应两个不同组合"); }
else codeToSig[regen] = sig;
}
}
if (r.Errors.Count == 0)
r.Warnings.Add("[" + code + "] 参数化对拍门禁通过: " + variants.Count + " 个编码全部一致");
}
}
// 附件: 源文件(相对源目录) → 包内路径 (manual 走 manual/<系列>/ 布局)
s.attachments = new List<Attachment>();
foreach (var a in meta.attachments ?? new List<BuildAttachmentSpec>())
{
if (string.IsNullOrEmpty(a.source)) continue;
var src = Path.Combine(sourceDir, a.source.Replace('/', '\\'));
if (!File.Exists(src)) { r.Warnings.Add("附件缺失, 跳过: " + a.source); continue; }
string rel;
if (a.kind == "manual")
rel = "manual/" + code + "/" + Path.GetFileName(a.source);
else
{
var folder = a.kind == "dimDrawing" ? "dim-drawing" : a.kind;
rel = "docs/" + folder + "/" + Path.GetFileName(a.source);
}
s.attachments.Add(new Attachment { kind = a.kind, lang = a.lang, path = rel, model = a.model });
filesToCopy.Add(new Tuple<string, string>(src, rel));
}
builtSeries.Add(s);
}
if (builtSeries.Count == 0) { r.Errors.Add("无有效系列"); r.Ok = false; return r; }
if (r.Errors.Count > 0) { r.Ok = false; return r; }
cat.series = builtSeries;
// 平替对照表 (可选): catalog\crossref.csv → 校验我方型号存在 → 嵌入 catalog.json
var crossRefPath = Path.Combine(sourceDir, "crossref.csv");
if (File.Exists(crossRefPath))
{
var validCodes = new HashSet<string>(
builtSeries.SelectMany(s => s.variants ?? new List<Variant>()).Select(v => v.modelCode));
var rows = new List<CrossRefRow>();
var crLines = File.ReadAllLines(crossRefPath, Encoding.UTF8)
.Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
if (crLines.Length > 1)
{
var crHeader = crLines[0].Split(',').Select(x => x.Trim()).ToArray();
var colBrand = Array.IndexOf(crHeader, "brand");
var colForeign = Array.IndexOf(crHeader, "foreign_model");
var colOur = Array.IndexOf(crHeader, "our_model");
var colNote = Array.IndexOf(crHeader, "note");
if (colBrand < 0 || colForeign < 0 || colOur < 0)
r.Errors.Add("crossref.csv 表头需含 brand,foreign_model,our_model 列");
else
{
for (int i = 1; i < crLines.Length; i++)
{
var c = crLines[i].Split(',');
if (c.Length < 4) { r.Errors.Add("crossref.csv 第 " + (i + 1) + " 行字段不足"); continue; }
var brand = c[colBrand].Trim();
var foreignModel = c[colForeign].Trim();
var ourModel = c[colOur].Trim();
if (string.IsNullOrEmpty(brand) || string.IsNullOrEmpty(foreignModel) || string.IsNullOrEmpty(ourModel))
{ r.Errors.Add("crossref.csv 第 " + (i + 1) + " 行有空字段"); continue; }
if (!validCodes.Contains(ourModel))
{ r.Errors.Add("crossref.csv: 我方型号不存在 " + ourModel + " (" + brand + " " + foreignModel + ")"); continue; }
rows.Add(new CrossRefRow
{
brand = brand,
foreignModel = foreignModel,
ourModel = ourModel,
note = (colNote >= 0 && colNote < c.Length) ? c[colNote].Trim() : ""
});
}
}
}
if (r.Errors.Count > 0) { r.Ok = false; return r; }
cat.crossRefs = rows;
}
// 参数化测试向量 (双端对拍, 2026-08-25): 源目录根 parametric-vectors.json → 包内根
// 桌面 selftest 与网页无头 E2E 读同一份文件对拍, 保证 C#/JS 解释器语义一致
var vecPath = Path.Combine(sourceDir, "parametric-vectors.json");
if (File.Exists(vecPath))
{
try
{
ser.DeserializeObject(File.ReadAllText(vecPath, Encoding.UTF8));
filesToCopy.Add(new Tuple<string, string>(vecPath, "parametric-vectors.json"));
}
catch (Exception ex) { r.Errors.Add("parametric-vectors.json 解析失败: " + ex.Message); }
}
// 组装临时目录 → zip
var tmp = Path.Combine(Path.GetTempPath(), "opcbuild_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tmp);
try
{
foreach (var s in builtSeries)
foreach (var v in s.variants ?? new List<Variant>())
{
var dest = Path.Combine(tmp, v.step.Replace('/', '\\'));
Directory.CreateDirectory(Path.GetDirectoryName(dest));
var srcName = OpcBuilder.ResolveStepFile(stepDir, Path.GetFileName(v.step.Replace('/', '\\')));
File.Copy(Path.Combine(stepDir, srcName), dest, true);
}
foreach (var f in filesToCopy)
{
var dest = Path.Combine(tmp, f.Item2.Replace('/', '\\'));
Directory.CreateDirectory(Path.GetDirectoryName(dest));
File.Copy(f.Item1, dest, true);
}
File.WriteAllText(Path.Combine(tmp, "catalog.json"), ser.Serialize(cat), new UTF8Encoding(true));
Directory.CreateDirectory(Path.GetDirectoryName(outPath));
if (File.Exists(outPath)) File.Delete(outPath);
ZipFile.CreateFromDirectory(tmp, outPath, CompressionLevel.Optimal, false);
r.OutputPath = outPath;
}
finally
{
Directory.Delete(tmp, true);
}
}
catch (Exception ex)
{
r.Errors.Add("全量打包异常: " + ex.Message);
r.Ok = false;
}
return r;
}
}
}

103
src/CatalogCore/Model.cs Normal file
View File

@@ -0,0 +1,103 @@
using System.Collections.Generic;
namespace Ounibo.Catalog.Core
{
// ---- .opc 数据包模型 (字段名与 catalog.json 键一一对应) ----
public class Catalog
{
public string schemaVersion { get; set; }
public string catalogName { get; set; }
public string catalogNameEn { get; set; }
public string catalogVersion { get; set; }
public string defaultLang { get; set; }
public string genApi { get; set; } // NX 按需生成服务地址 (空 = 非标下载维持置灰; 二期 2026-08-25)
public List<string> langs { get; set; }
public List<Category> categories { get; set; }
public List<Series> series { get; set; }
public List<CrossRefRow> crossRefs { get; set; } // 平替对照 (可空)
}
public class Category
{
public string code { get; set; }
public string nameZh { get; set; }
public string nameEn { get; set; }
public string keywords { get; set; } // 搜索关键词 (英文/拼音等, 空格分隔)
public List<string> series { get; set; }
}
public class Series
{
public string code { get; set; }
public string nameZh { get; set; }
public string nameEn { get; set; }
public string keywords { get; set; } // 搜索关键词
public string mode { get; set; } // "parametric"=参数化引擎; 缺失/其他=枚举模式 (现行为)
public string modelCodeTemplate { get; set; } // 参数化: 型号编码模板, 如 "KC{type}{bore}-{stroke}{magnet}{mount}" ({field[:padN]})
public List<ParameterDef> parameters { get; set; }
public List<Rule> rules { get; set; }
public Naming naming { get; set; }
public List<Attachment> attachments { get; set; }
public List<Variant> variants { get; set; }
}
public class ParameterDef
{
public string code { get; set; }
public string nameZh { get; set; }
public string nameEn { get; set; }
public string keywords { get; set; } // 搜索关键词 (如拼音 gangjing)
public string unit { get; set; }
public string type { get; set; } // enum / number / range (range=连续参数化域)
public List<object> values { get; set; }
public Dictionary<string, string> display { get; set; }
// ---- range 域 (type=range 时生效) ----
public double? min { get; set; } // 域下限 (含)
public double? max { get; set; } // 域上限 (含)
public double? step { get; set; } // 步长 (缺省 1)
public List<object> gridValues { get; set; } // 标准档位快捷值 (仅 UI 快捷选择, 网格真源仍是 CSV)
}
public class Rule
{
public RuleCond If { get; set; }
public RuleCond Then { get; set; }
}
public class RuleCond
{
public string Param { get; set; }
public string Op { get; set; } // eq/ne/in/gt/ge/lt/le/between
public object Value { get; set; } // 标量或数组 (op=in/between)
}
public class Naming
{
public string stepNameTemplate { get; set; }
}
public class Attachment
{
public string kind { get; set; } // dimDrawing / datasheet / note
public string lang { get; set; }
public string path { get; set; }
public string model { get; set; } // dimDrawing 可绑定具体型号
}
public class Variant
{
public string modelCode { get; set; }
public Dictionary<string, object> @params { get; set; } // C# 保留字用 @ 前缀
public string step { get; set; }
}
/// <summary>平替对照行 (竞品型号 → 我方型号, 源: catalog\crossref.csv)。</summary>
public class CrossRefRow
{
public string brand { get; set; } // 竞品品牌 (SMC/亚德客/FESTO/...)
public string foreignModel { get; set; } // 竞品型号
public string ourModel { get; set; } // 我方型号 (必须存在于目录变体)
public string note { get; set; } // 备注 (完全互换/近似平替/需确认)
}
}

View File

@@ -0,0 +1,220 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Web.Script.Serialization;
namespace Ounibo.Catalog.Core
{
public class BuildAttachmentSpec
{
public string kind; // dimDrawing / datasheet / note
public string lang; // zh / en
public string source; // 源文件绝对路径
public string model; // 可选, 绑定型号
}
public class BuildResult
{
public bool Ok;
public List<string> Errors = new List<string>();
public List<string> Warnings = new List<string>();
public string OutputPath;
}
/// <summary>
/// .opc 打包器 (M2 Builder 内核, 也供脚本/自测使用):
/// CSV (model_code + 参数列 + step_file 列) + STEP 目录 → zip 数据包。
/// </summary>
public static class OpcBuilder
{
/// <summary>
/// 从 CSV 自动识别参数定义 (与向导 Step2 共用):
/// 数值列 → type=number (支持范围搜索), 其他 → enum (取值按出现顺序去重)。
/// </summary>
public static List<ParameterDef> AutoDetectParameters(string csvPath)
{
var result = new List<ParameterDef>();
var lines = File.ReadAllLines(csvPath, Encoding.UTF8).Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
if (lines.Length < 2) return result;
var header = lines[0].Split(',').Select(x => x.Trim()).ToArray();
var dataRows = lines.Skip(1).Select(l => l.Split(',')).Where(c => c.Length >= header.Length && !string.IsNullOrWhiteSpace(c[0])).ToList();
for (int i = 1; i < header.Length; i++)
{
var code = header[i];
if (code == "step_file") continue;
// 枚举列保留空值 (如 磁石="" 表示无磁石, 是合法取值); 数值列过滤空值
var rawVals = dataRows.Select(r => r[i].Trim()).Distinct().ToList();
var nonEmpty = rawVals.Where(v => v.Length > 0).ToList();
bool numeric = nonEmpty.Count > 0 && nonEmpty.All(v =>
{
double d;
return double.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out d);
});
var values = numeric
? nonEmpty.Select(v => double.Parse(v, CultureInfo.InvariantCulture)).OrderBy(d => d).Cast<object>().ToList()
: rawVals.Cast<object>().ToList();
result.Add(new ParameterDef
{
code = code,
nameZh = code,
nameEn = code,
unit = numeric ? "mm" : "",
type = numeric ? "number" : "enum",
values = values
});
}
return result;
}
public static BuildResult Build(
string outPath,
string catalogName, string catalogVersion,
string catCode, string catNameZh, string catNameEn,
Series series,
string csvPath, string stepDir,
List<BuildAttachmentSpec> attachments)
{
var r = new BuildResult { Ok = true };
try
{
// 1) 读 CSV: 首列 = 型号编码, step_file 列 = STEP 文件名, 其余 = 参数
if (!File.Exists(csvPath)) { r.Errors.Add("CSV 不存在: " + csvPath); r.Ok = false; return r; }
if (!Directory.Exists(stepDir)) { r.Errors.Add("STEP 目录不存在: " + stepDir); r.Ok = false; return r; }
var variants = ReadCsvVariants(series, csvPath, stepDir, r);
if (r.Errors.Count > 0 || variants == null || variants.Count == 0)
{
if (variants == null || variants.Count == 0) r.Errors.Add("无有效变体");
r.Ok = false; return r;
}
// 2) 目录与系列
var cat = new Catalog
{
schemaVersion = "1.0",
catalogName = catalogName,
catalogNameEn = catalogName,
catalogVersion = catalogVersion,
defaultLang = "zh",
langs = new List<string> { "zh", "en" },
categories = new List<Category>
{
new Category { code = catCode, nameZh = catNameZh, nameEn = catNameEn, series = new List<string> { series.code } }
},
series = new List<Series> { series }
};
series.variants = variants;
// 3) 组装临时目录 → zip
var tmp = Path.Combine(Path.GetTempPath(), "opcbuild_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tmp);
try
{
foreach (var v in variants)
{
var dest = Path.Combine(tmp, v.step.Replace('/', '\\'));
Directory.CreateDirectory(Path.GetDirectoryName(dest));
var srcName = ResolveStepFile(stepDir, Path.GetFileName(v.step.Replace('/', '\\')));
File.Copy(Path.Combine(stepDir, srcName), dest);
}
foreach (var a in attachments ?? new List<BuildAttachmentSpec>())
{
if (!File.Exists(a.source)) { r.Warnings.Add("附件源文件缺失, 跳过: " + a.source); continue; }
// 包内目录统一用 kebab-case (dimDrawing → dim-drawing), 与 sample-data 布局一致
var folder = a.kind == "dimDrawing" ? "dim-drawing" : a.kind;
var rel = "docs/" + folder + "/" + Path.GetFileName(a.source);
Directory.CreateDirectory(Path.Combine(tmp, "docs", folder));
File.Copy(a.source, Path.Combine(tmp, rel.Replace('/', '\\')), true);
series.attachments = series.attachments ?? new List<Attachment>();
series.attachments.Add(new Attachment { kind = a.kind, lang = a.lang, path = rel, model = a.model });
}
var ser = new JavaScriptSerializer();
var json = ser.Serialize(cat);
File.WriteAllText(Path.Combine(tmp, "catalog.json"), json, new UTF8Encoding(true));
Directory.CreateDirectory(Path.GetDirectoryName(outPath));
if (File.Exists(outPath)) File.Delete(outPath);
ZipFile.CreateFromDirectory(tmp, outPath, CompressionLevel.Optimal, false);
r.OutputPath = outPath;
}
finally
{
Directory.Delete(tmp, true);
}
}
catch (Exception ex)
{
r.Errors.Add("打包异常: " + ex.Message);
r.Ok = false;
}
return r;
}
/// <summary>
/// STEP 物理文件查找: 优先 CSV 写的 .step, 兼容同名 .stp (NX 导出常用后缀)。
/// 返回物理文件名, 找不到返回 null。
/// </summary>
public static string ResolveStepFile(string stepDir, string stepName)
{
if (File.Exists(Path.Combine(stepDir, stepName))) return stepName;
var alt = Path.ChangeExtension(stepName, ".stp");
return File.Exists(Path.Combine(stepDir, alt)) ? alt : null;
}
/// <summary>
/// CSV → 变体列表 (与 Build 共用内核; 错误写进 r.Errors, 返回值可能为 null)。
/// 首列 = 型号编码, step_file 列 = STEP 文件名 (需存在于 stepDir, .stp/.step 兼容), 其余 = 参数列。
/// </summary>
public static List<Variant> ReadCsvVariants(Series series, string csvPath, string stepDir, BuildResult r)
{
var lines = File.ReadAllLines(csvPath, Encoding.UTF8).Where(l => !string.IsNullOrWhiteSpace(l)).ToArray();
if (lines.Length < 2) { r.Errors.Add("CSV 无数据行: " + csvPath); return null; }
var header = lines[0].Split(',').Select(x => x.Trim()).ToArray();
if (!header.Contains("step_file")) { r.Errors.Add("CSV 缺少 step_file 列: " + csvPath); return null; }
var variants = new List<Variant>();
var seen = new HashSet<string>();
foreach (var line in lines.Skip(1))
{
var c = line.Split(',');
if (c.Length < header.Length) { r.Errors.Add("CSV 行字段不足: " + line); continue; }
var model = c[0].Trim();
if (string.IsNullOrEmpty(model)) { r.Errors.Add("CSV 行缺型号编码: " + line); continue; }
if (!seen.Add(model)) { r.Errors.Add("型号编码重复: " + model); continue; }
var p = new Dictionary<string, object>();
for (int i = 1; i < header.Length; i++)
{
var code = header[i];
if (code == "step_file") continue;
var def = (series.parameters ?? new List<ParameterDef>()).FirstOrDefault(x => x.code == code);
if (def == null) continue; // 未定义的列跳过
var raw = (i < c.Length) ? c[i].Trim() : "";
if (def.type == "number" || def.type == "range")
{
double d;
if (double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out d))
p[code] = d;
else { r.Errors.Add("参数值非数字: " + model + " / " + code + "=" + raw); continue; }
}
else p[code] = raw;
}
if (p.Count < (series.parameters ?? new List<ParameterDef>()).Count)
{ r.Errors.Add("参数不全: " + model); continue; }
var stepCol = Array.IndexOf(header, "step_file");
var stepName = (stepCol < c.Length) ? c[stepCol].Trim() : "";
if (ResolveStepFile(stepDir, stepName) == null)
{ r.Errors.Add("STEP 文件缺失: " + stepName); continue; }
variants.Add(new Variant { modelCode = model, @params = p, step = "step/" + stepName });
}
return variants.Count == 0 ? null : variants;
}
}
}

View File

@@ -0,0 +1,145 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Web.Script.Serialization;
namespace Ounibo.Catalog.Core
{
/// <summary>
/// .opc 数据包 (zip: catalog.json + assets)。读写入口。
/// </summary>
public class OpcPackage : IDisposable
{
/// <summary>软件支持的数据包格式版本。</summary>
public const string SupportedSchemaVersion = "1.0";
public Catalog Catalog { get; private set; }
public string Path { get; private set; }
private ZipArchive _zip;
private Stream _stream;
public static OpcPackage Open(string path)
{
var p = new OpcPackage { Path = path };
// 整包读入内存, 立即释放文件句柄: 软件运行中仍可重建/覆盖同一 .opc (踩坑 #19)
byte[] bytes;
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
var ms = new MemoryStream();
fs.CopyTo(ms);
bytes = ms.ToArray();
}
p._stream = new MemoryStream(bytes);
p._zip = new ZipArchive(p._stream, ZipArchiveMode.Read);
p.LoadCatalog();
return p;
}
/// <summary>从流打开 (客户版嵌入资源场景)。流的所有权转移给本对象。</summary>
public static OpcPackage OpenStream(Stream stream, string sourceName)
{
var p = new OpcPackage { Path = sourceName, _stream = stream };
p._zip = new ZipArchive(stream, ZipArchiveMode.Read);
p.LoadCatalog();
return p;
}
void LoadCatalog()
{
var entry = _zip.GetEntry("catalog.json");
if (entry == null)
throw new InvalidDataException("数据包缺少 catalog.json: " + Path);
string json;
using (var sr = new StreamReader(entry.Open(), Encoding.UTF8))
json = sr.ReadToEnd();
// 大目录 (数万变体) 的 catalog.json 可能超过默认 2M 字符限制
var ser = new JavaScriptSerializer { MaxJsonLength = int.MaxValue };
Catalog = ser.Deserialize<Catalog>(json);
if (Catalog == null || Catalog.series == null)
throw new InvalidDataException("catalog.json 解析失败: " + Path);
// 版本校验: 过新提示升级软件, 过旧/未知提示数据包损坏或过旧
var ver = Catalog.schemaVersion ?? "";
if (ver != SupportedSchemaVersion)
{
if (string.Compare(ver, SupportedSchemaVersion, StringComparison.Ordinal) > 0)
throw new InvalidDataException(
"数据包格式版本 " + ver + " 高于软件支持的 " + SupportedSchemaVersion + ", 请升级软件");
throw new InvalidDataException("数据包格式版本不兼容或数据包损坏: " + (ver.Length == 0 ? "(无版本号)" : ver));
}
}
public byte[] ReadAsset(string path)
{
var entry = FindEntry(path);
if (entry == null)
throw new FileNotFoundException("数据包内资源缺失: " + path);
using (var ms = new MemoryStream())
{
using (var s = entry.Open())
s.CopyTo(ms);
return ms.ToArray();
}
}
public string ReadAssetText(string path)
{
return Encoding.UTF8.GetString(ReadAsset(path));
}
public bool HasAsset(string path)
{
return FindEntry(path) != null;
}
// zip 条目分隔符可能是 '\' (Windows) 或 '/', 两种都尝试
private ZipArchiveEntry FindEntry(string path)
{
var e = _zip.GetEntry(path);
if (e == null && path != null && path.Contains('/'))
e = _zip.GetEntry(path.Replace('/', '\\'));
if (e == null && path != null && path.Contains('\\'))
e = _zip.GetEntry(path.Replace('\\', '/'));
return e;
}
public IEnumerable<string> EntryNames
{
get { return _zip.Entries.Select(e => e.FullName); }
}
/// <summary>把变体的 STEP 写入目标完整路径 (命名模板见 Series.Naming)。</summary>
public string ExtractStepTo(Variant v, string fullPath)
{
var bytes = ReadAsset(v.step);
File.WriteAllBytes(fullPath, bytes);
return fullPath;
}
/// <summary>把变体的 STEP 按默认命名 (型号.step) 写入目录,返回完整路径。</summary>
public string ExtractStep(Variant v, string destDir)
{
Directory.CreateDirectory(destDir);
var name = v.modelCode + ".step";
return ExtractStepTo(v, System.IO.Path.Combine(destDir, name));
}
public void Dispose()
{
if (_zip != null)
{
_zip.Dispose();
_zip = null;
}
if (_stream != null)
{
_stream.Dispose();
_stream = null;
}
}
}
}

View File

@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Ounibo.Catalog.Core
{
public class ValidationIssue
{
public string Severity; // ERROR / WARNING
public string Message;
public string Target; // 如 "SC / SC32x50S-LB"
}
/// <summary>
/// 数据包校验器 (对标 qacheck 思想): 资源缺失/编码重复/规则冲突/附件缺失。
/// 校验不通过不允许编译发布 (M3 硬门槛)。
/// </summary>
public static class PackageValidator
{
static string S(object o) { return o == null ? "" : Convert.ToString(o, System.Globalization.CultureInfo.InvariantCulture); }
public static List<ValidationIssue> Validate(OpcPackage pkg)
{
var issues = new List<ValidationIssue>();
var cat = pkg.Catalog;
if (cat == null) { issues.Add(new ValidationIssue { Severity = "ERROR", Message = "catalog.json 缺失" }); return issues; }
if (cat.series == null || cat.series.Count == 0)
{ issues.Add(new ValidationIssue { Severity = "ERROR", Message = "无系列数据" }); return issues; }
foreach (var s in cat.series)
{
// 变体资源与编码
foreach (var v in s.variants ?? new List<Variant>())
{
if (!pkg.HasAsset(v.step))
issues.Add(new ValidationIssue { Severity = "ERROR", Message = "STEP 文件缺失", Target = s.code + " / " + v.modelCode + " -> " + v.step });
var missing = (s.parameters ?? new List<ParameterDef>()).Where(p => !v.@params.ContainsKey(p.code)).Select(p => p.code).ToList();
if (missing.Count > 0)
issues.Add(new ValidationIssue { Severity = "ERROR", Message = "变体缺少参数: " + string.Join(",", missing), Target = s.code + " / " + v.modelCode });
}
var dup = (s.variants ?? new List<Variant>()).GroupBy(v => v.modelCode).Where(g => g.Count() > 1).ToList();
foreach (var d in dup)
issues.Add(new ValidationIssue { Severity = "ERROR", Message = "型号编码重复 x" + d.Count(), Target = s.code + " / " + d.Key });
// 规则引用检查
foreach (var r in s.rules ?? new List<Rule>())
{
CheckCond(r.If, s, issues, s.code);
CheckCond(r.Then, s, issues, s.code);
if (r.If != null && r.Then != null)
{
// 冲突: If 与 Then 针对同一参数且条件矛盾
if (r.If.Param == r.Then.Param && CondAlwaysFalse(r.If, r.Then))
issues.Add(new ValidationIssue { Severity = "WARNING", Message = "规则条件自相矛盾 (If/Then 同一参数互斥)", Target = s.code + " / " + r.If.Param });
}
}
// 附件
foreach (var a in s.attachments ?? new List<Attachment>())
{
if (!pkg.HasAsset(a.path))
issues.Add(new ValidationIssue { Severity = "WARNING", Message = "附件缺失 (" + a.kind + " " + a.lang + ")", Target = a.path });
}
}
return issues;
}
static void CheckCond(RuleCond c, Series s, List<ValidationIssue> issues, string seriesCode)
{
if (c == null) return;
var p = (s.parameters ?? new List<ParameterDef>()).FirstOrDefault(x => x.code == c.Param);
if (p == null)
{
issues.Add(new ValidationIssue { Severity = "ERROR", Message = "规则引用了不存在的参数: " + c.Param, Target = seriesCode });
return;
}
// 约束值必须在参数取值范围内
var vals = c.Value as object[];
if (vals != null)
{
var allowed = (p.values ?? new List<object>()).Select(x => S(x)).ToList();
foreach (var v in vals)
if (!allowed.Contains(S(v)))
issues.Add(new ValidationIssue { Severity = "WARNING", Message = "规则值不在参数取值内: " + S(v), Target = seriesCode + " / " + c.Param });
}
}
static bool CondAlwaysFalse(RuleCond a, RuleCond b)
{
// 仅判断简单互斥: eq x vs ne x / in [..] vs eq y 且 y 不在列表
if (a.Op == "eq" && b.Op == "ne" && S(a.Value) == S(b.Value)) return true;
if (a.Op == "ne" && b.Op == "eq" && S(a.Value) == S(b.Value)) return true;
if (a.Op == "eq" && b.Op == "in")
{
var arr = b.Value as object[];
if (arr != null && arr.Length > 0 && !arr.Any(x => S(x) == S(a.Value))) return true;
}
return false;
}
}
}

View File

@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
namespace Ounibo.Catalog.Core
{
/// <summary>参数化选型结果: 标准档位命中 → Variant; 非标 → 模板生成的编码+参数 (无 STEP)。</summary>
public class ParamResult
{
public string ModelCode;
public Dictionary<string, object> Params;
public bool IsStandard;
public Variant Variant; // IsStandard 时非空 (即 CSV 网格行)
public string Error; // 非法原因键: param=参数不全 / domain=域外 / rule=违背规则 / template=模板错误 (上层本地化)
}
/// <summary>
/// 参数化引擎 (一期, 2026-08-25):
/// 参数域校验 + 型号编码模板渲染 + 非标组合生成 + 最近标准档位。
/// 与网页 app.js 的参数化函数语义一一对应 (同一份规则 JSON + 同一份测试向量 parametric-vectors.json 对拍)。
/// </summary>
public static class ParametricEngine
{
public static bool IsParametric(Series s) { return s != null && s.mode == "parametric"; }
static string S(object o) { return o == null ? "" : Convert.ToString(o, CultureInfo.InvariantCulture); }
static bool Eq(object a, object b) { return string.Equals(S(a), S(b), StringComparison.OrdinalIgnoreCase); }
static double D(object o) { return Convert.ToDouble(o, CultureInfo.InvariantCulture); }
/// <summary>模板渲染: {field} 或 {field:padN} (padN=前补零到 N 位)。未知字段/缺值/非法 token → null。</summary>
public static string RenderCode(string template, Dictionary<string, object> p)
{
if (string.IsNullOrEmpty(template)) return null;
var sb = new StringBuilder();
int i = 0;
while (i < template.Length)
{
var c = template[i];
if (c == '{')
{
var close = template.IndexOf('}', i + 1);
if (close < 0) return null;
var token = template.Substring(i + 1, close - i - 1);
var parts = token.Split(':');
var field = parts[0].Trim();
object val;
if (!p.TryGetValue(field, out val)) return null;
var sv = S(val);
if (parts.Length > 1)
{
int pad;
var padTok = parts[1].Trim();
if (padTok.StartsWith("pad", StringComparison.OrdinalIgnoreCase)) padTok = padTok.Substring(3);
if (!int.TryParse(padTok, out pad) || pad < 1) return null;
sv = sv.PadLeft(pad, '0');
}
sb.Append(sv);
i = close + 1;
}
else { sb.Append(c); i++; }
}
return sb.ToString();
}
/// <summary>参数取值在其域内: enum → values 命中; range → [min,max] 且按 step 对齐 (相对 min 的整数倍)。</summary>
public static bool DomainValid(ParameterDef p, object v)
{
if (v == null) return false;
if (p.type == "range")
{
double d;
if (!double.TryParse(S(v), NumberStyles.Float, CultureInfo.InvariantCulture, out d)) return false;
if (p.min != null && d < p.min.Value) return false;
if (p.max != null && d > p.max.Value) return false;
var st = p.step ?? 1;
var baseV = p.min ?? 0;
if (st > 0 && Math.Abs(d - baseV - Math.Round((d - baseV) / st) * st) > 1e-6) return false;
return true;
}
return (p.values ?? new List<object>()).Any(x => Eq(x, v));
}
/// <summary>组合校验: 参数齐全 + 域 + 规则。返回错误键 (null=合法)。</summary>
public static string ValidateCombo(Series s, Dictionary<string, object> sel)
{
foreach (var p in s.parameters ?? new List<ParameterDef>())
{
object v;
if (!sel.TryGetValue(p.code, out v)) return "param";
if (!DomainValid(p, v)) return "domain";
}
var cfg = new Configurator(s);
var fake = new Variant { modelCode = "", @params = sel };
if (cfg.RuleViolated(fake)) return "rule";
return null;
}
/// <summary>
/// 部分选择校验 (联动用): 只校验已选参数的域 + 规则 (Then 参数未选不判违背)。
/// 与 Configurator.RuleViolatedPartial 配套, 与网页 validatePartial 同语义。
/// </summary>
public static string ValidatePartial(Series s, Dictionary<string, object> sel)
{
foreach (var p in s.parameters ?? new List<ParameterDef>())
{
object v;
if (!sel.TryGetValue(p.code, out v)) continue; // 未选参数不校验
if (!DomainValid(p, v)) return "domain";
}
var cfg = new Configurator(s);
if (cfg.RuleViolatedPartial(sel)) return "rule";
return null;
}
/// <summary>生成结果: 网格命中 → Variant (IsStandard); 否则非标 (模板生成编码, 无 STEP)。不合法 → Error 非空。</summary>
public static ParamResult Generate(Series s, Configurator cfg, Dictionary<string, object> sel)
{
var r = new ParamResult { Params = sel };
if (sel.Count < (s.parameters ?? new List<ParameterDef>()).Count) { r.Error = "param"; return r; }
r.Error = ValidateCombo(s, sel);
if (r.Error != null) return r;
r.ModelCode = RenderCode(s.modelCodeTemplate, sel);
if (r.ModelCode == null) { r.Error = "template"; return r; }
var v = cfg.ValidVariants().FirstOrDefault(x => string.Equals(x.modelCode, r.ModelCode, StringComparison.OrdinalIgnoreCase));
if (v != null) { r.IsStandard = true; r.Variant = v; }
return r;
}
/// <summary>
/// 最近标准档位: 全部有效网格变体按参数距离排序 — range 参数按数值差累加, 枚举不等记大惩罚 (1e6);
/// 距离相同取编码字典序小者 (与网页同语义)。
/// </summary>
public static Variant NearestStandard(Series s, Configurator cfg, Dictionary<string, object> sel)
{
Variant best = null;
double bestDist = double.MaxValue;
foreach (var v in cfg.ValidVariants())
{
double dist = 0;
foreach (var p in s.parameters ?? new List<ParameterDef>())
{
object a, b;
if (!sel.TryGetValue(p.code, out a) || !v.@params.TryGetValue(p.code, out b)) continue;
if (p.type == "range") dist += Math.Abs(D(a) - D(b));
else if (!Eq(a, b)) dist += 1e6;
}
if (best == null || dist < bestDist - 1e-9 ||
(Math.Abs(dist - bestDist) < 1e-9 && string.CompareOrdinal(v.modelCode, best.modelCode) < 0))
{ best = v; bestDist = dist; }
}
return best;
}
}
}

View File

@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Ounibo.Catalog.Core
{
/// <summary>
/// 内存搜索引擎: 型号编码/系列名/参数名/关键词 (英文+拼音) 全文匹配,
/// 支持数值参数的区间过滤。数据量 ≤ 10 万级内存扫描足够。
/// </summary>
public class SearchEngine
{
public class SearchItem
{
public Series Series;
public Variant Variant;
public string Note; // 平替来源标注 (如 "SMC SQ32-50"), 显示在系列名后
public string ModelCode { get { return Variant.modelCode; } }
public string SeriesNameZh { get { return Note == null ? Series.nameZh : Series.nameZh + " ← 平替 " + Note; } }
public string SeriesNameEn { get { return Note == null ? Series.nameEn : Series.nameEn + " (Alt: " + Note + ")"; } }
public string Step { get { return Variant.step; } }
public double Score;
public List<string> AltCodes = new List<string>(); // 平替对照: 指向此变体的竞品型号 (品牌 型号)
}
List<SearchItem> _items = new List<SearchItem>();
public SearchEngine(Catalog cat)
{
foreach (var s in cat.series ?? new List<Series>())
foreach (var v in s.variants ?? new List<Variant>())
_items.Add(new SearchItem { Series = s, Variant = v });
// 平替对照: 竞品型号挂到对应我方变体上 (搜竞品型号 = 命中我方型号)
foreach (var cr in cat.crossRefs ?? new List<CrossRefRow>())
{
var it = _items.FirstOrDefault(x => string.Equals(x.ModelCode, cr.ourModel, StringComparison.OrdinalIgnoreCase));
if (it != null) it.AltCodes.Add(cr.brand + " " + cr.foreignModel);
}
}
public int Count { get { return _items.Count; } }
string LangName(Series s, string lang)
{
return lang == "en" ? (s.nameEn ?? s.nameZh) : (s.nameZh ?? s.nameEn);
}
string LangParamName(ParameterDef p, string lang)
{
return lang == "en" ? (p.nameEn ?? p.nameZh) : (p.nameZh ?? p.nameEn);
}
/// <summary>构建变体的可搜索文本 (不含型号编码本身)。</summary>
string BuildText(SearchItem it, string lang)
{
var parts = new List<string>();
parts.Add(LangName(it.Series, lang));
parts.Add(it.Series.code);
parts.Add(it.Series.keywords ?? "");
foreach (var kv in it.Variant.@params)
{
var p = it.Series.parameters.FirstOrDefault(x => x.code == kv.Key);
if (p != null)
{
parts.Add(LangParamName(p, lang));
parts.Add(p.keywords ?? "");
var disp = p.display != null && p.display.ContainsKey(Convert.ToString(kv.Value)) ? p.display[Convert.ToString(kv.Value)] : null;
if (disp != null) parts.Add(disp);
}
parts.Add(Convert.ToString(kv.Value, System.Globalization.CultureInfo.InvariantCulture));
}
foreach (var alt in it.AltCodes) parts.Add(alt); // 平替对照: 竞品品牌+型号可搜
return string.Join(" ", parts).ToLowerInvariant();
}
/// <summary>
/// 全文查询: 空格分词, 全部词都命中才算; 按 精确编码 &gt; 编码前缀 &gt; 其他文本 排序。
/// </summary>
public List<SearchItem> Query(string q, string lang)
{
var tokens = (q ?? "").ToLowerInvariant()
.Split(new[] { ' ', ',', '', ';', '' }, StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.Trim())
.Where(t => t.Length > 0)
.ToList();
if (tokens.Count == 0) return new List<SearchItem>();
foreach (var it in _items)
{
var code = it.ModelCode.ToLowerInvariant();
var alts = it.AltCodes.Select(a => a.ToLowerInvariant()).ToList();
var text = BuildText(it, lang);
it.Score = 0;
if (!tokens.All(t => code.Contains(t) || alts.Any(a => a.Contains(t)) || text.Contains(t)))
continue;
if (tokens.Any(t => code == t)) it.Score = 1000;
else if (tokens.Any(t => alts.Any(a => a == t))) it.Score = 900; // 竞品型号精确命中
else if (tokens.All(t => code.Contains(t))) it.Score = 800;
else if (tokens.All(t => alts.Any(a => a.Contains(t)) || text.Contains(t))) it.Score = 500;
else it.Score = 100;
}
return _items.Where(x => x.Score > 0).OrderByDescending(x => x.Score).ThenBy(x => x.ModelCode).ToList();
}
/// <summary>数值参数区间过滤 (range 的键为参数 code, 值为 min/max 数组, null 表示不限制)。</summary>
public List<SearchItem> RangeFilter(List<SearchItem> items, Dictionary<string, double?[]> ranges)
{
if (ranges == null || ranges.Count == 0) return items;
return items.Where(it =>
{
foreach (var r in ranges)
{
var p = it.Series.parameters.FirstOrDefault(x => x.code == r.Key);
if (p == null || p.type != "number") continue;
object val;
if (!it.Variant.@params.TryGetValue(r.Key, out val)) continue;
double d;
if (!double.TryParse(Convert.ToString(val, System.Globalization.CultureInfo.InvariantCulture),
System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out d))
continue;
if (r.Value[0].HasValue && d < r.Value[0].Value) return false;
if (r.Value[1].HasValue && d > r.Value[1].Value) return false;
}
return true;
}).ToList();
}
}
}

342
src/CatalogCore/SelfTest.cs Normal file
View File

@@ -0,0 +1,342 @@
#if !CUSTOMER_BUILD
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Ounibo.Catalog.Viewer3D;
namespace Ounibo.Catalog.Core
{
/// <summary>
/// 自测 (--selftest): 通用目录包检查 + 样例包(SC/MAQ)专项回归。
/// 结果写入数据包同目录 selftest.log, 退出码 0=全部通过。
/// </summary>
public static class SelfTest
{
public static int Run(string opcPath)
{
var log = new StringBuilder();
int pass = 0, fail = 0;
Action<string, bool> check = (name, ok) =>
{
log.AppendLine((ok ? "PASS " : "FAIL ") + name);
if (ok) pass++; else fail++;
};
try
{
using (var pkg = OpcPackage.Open(opcPath))
{
var cat = pkg.Catalog;
check("catalog.json 读取 (schemaVersion=" + cat.schemaVersion + ")", !string.IsNullOrEmpty(cat.schemaVersion));
check("分类数 >= 1 (" + (cat.categories == null ? 0 : cat.categories.Count) + ")", cat.categories != null && cat.categories.Count >= 1);
check("系列数 >= 1 (" + (cat.series == null ? 0 : cat.series.Count) + ")", cat.series != null && cat.series.Count >= 1);
bool isSample = cat.series.Count >= 2 && cat.series[0].code == "SC";
// ============ 通用检查 (任意目录包) ============
int totalV = cat.series.Sum(x => x.variants == null ? 0 : x.variants.Count);
check("变体总数 " + totalV, totalV > 0);
bool allValid = true;
foreach (var sAny in cat.series)
if (sAny.variants != null && new Configurator(sAny).ValidVariants().Count != sAny.variants.Count)
allValid = false;
check("所有变体满足各自系列规则", allValid);
var s0 = cat.series[0];
var cfg0 = new Configurator(s0);
var v0 = cfg0.ValidVariants().FirstOrDefault();
check("首个系列可解析出有效变体 (" + s0.code + ")", v0 != null);
// 回归: AllowedOptions 必须包含"已选参数"的键 (曾导致选择被 UI 清空的 bug)
var selReg = new Dictionary<string, object> { { s0.parameters[0].code, s0.parameters[0].values[0] } };
var allowedReg = cfg0.AllowedOptions(selReg);
check("回归: AllowedOptions 含已选参数键 (" + s0.parameters[0].code + ")", allowedReg.ContainsKey(s0.parameters[0].code));
// 回归: 完整选型后已选参数仍可改 (曾因自身值锁死可选列表)
var fullSel = new Dictionary<string, object>();
foreach (var p in s0.parameters) fullSel[p.code] = v0.@params[p.code];
var allowedFull = cfg0.AllowedOptions(fullSel);
var pAlt = s0.parameters.FirstOrDefault(x => (x.values ?? new List<object>()).Count > 1);
bool changeable = pAlt != null && allowedFull[pAlt.code].Count > 1;
check("回归: 完整选型后参数可改 (" + (pAlt == null ? "?" : pAlt.code) + " 可选项 " + (pAlt == null ? 0 : allowedFull[pAlt.code].Count) + ")", changeable);
var engine0 = new SearchEngine(cat);
var q0 = engine0.Query(s0.code, "zh");
check("按系列代码搜索命中 (" + q0.Count + ")", q0.Count > 0);
var tmpG = Path.Combine(Path.GetTempPath(), "ounibo_selftest");
Directory.CreateDirectory(tmpG);
var fG = pkg.ExtractStep(v0, tmpG);
var contentG = File.ReadAllText(fG);
check("STEP 提取成功且含实体", File.Exists(fG) && contentG.Contains("MANIFOLD_SOLID_BREP"));
var cylsG = StepMesh.Parse(contentG);
var cylCount = cylsG == null ? 0 : cylsG.Count;
var realStep = contentG.Contains("MANIFOLD_SOLID_BREP") || contentG.Contains("ADVANCED_BREP_SHAPE_REPRESENTATION");
check("STEP 可解析 (" + cylCount + " 圆柱" + (realStep && cylCount < 3 ? ", 真实 B-rep → 网页走新迪查看器" : "") + ")",
cylCount >= 3 || realStep);
var issuesG = PackageValidator.Validate(pkg);
check("目录包校验无 ERROR (" + issuesG.Count(i => i.Severity == "ERROR") + " 错误, " + issuesG.Count(i => i.Severity == "WARNING") + " 警告)",
issuesG.All(i => i.Severity != "ERROR"));
// 手册页附件 (kind=manual): 有则抽查首个系列可读 (尺寸图窗口对照)
var manSeries = cat.series.FirstOrDefault(x => x.attachments != null && x.attachments.Any(a => a.kind == "manual"));
if (manSeries != null)
{
var manAtt = manSeries.attachments.First(a => a.kind == "manual");
byte[] manBytes = null;
try { manBytes = pkg.ReadAsset(manAtt.path); } catch { }
check("手册页附件可读 (" + manSeries.code + " " + manAtt.path + ")", manBytes != null && manBytes.Length > 1000);
}
// 每个系列: 初始可选值非空 + 可解析出完整选型 (UI 可用性诊断)
foreach (var sAny in cat.series)
{
var cfgAny = new Configurator(sAny);
var allowedAny = cfgAny.AllowedOptions(new Dictionary<string, object>());
bool ok = true;
foreach (var p in sAny.parameters)
{
List<object> vals;
if (!allowedAny.TryGetValue(p.code, out vals) || vals.Count == 0)
{
check("系列 " + sAny.code + " 参数 " + p.code + " 初始可选值非空", false);
ok = false;
}
}
if (ok)
{
var selAny = new Dictionary<string, object>();
foreach (var p in sAny.parameters)
selAny[p.code] = allowedAny[p.code][0];
var vAny = cfgAny.Resolve(selAny);
check("系列 " + sAny.code + " 可解析出完整选型 (" + (vAny == null ? "null" : vAny.modelCode) + ")", vAny != null);
}
}
// 规则过滤通用验证: 找第一个含 eq→in 规则的系列, 验证过滤后的允许值都在 Then 列表内
var rSeries = cat.series.FirstOrDefault(x => x.rules != null && x.rules.Count > 0);
if (rSeries != null)
{
var r = rSeries.rules[0];
if (r.If != null && r.Then != null && r.If.Op == "eq" && r.Then.Op == "in")
{
var cfgR = new Configurator(rSeries);
var selR = new Dictionary<string, object> { { r.If.Param, r.If.Value } };
var allowedR = cfgR.AllowedOptions(selR);
List<object> thenVals;
if (allowedR.TryGetValue(r.Then.Param, out thenVals))
{
var allowedStr = ((object[])r.Then.Value).Select(x => Convert.ToString(x, System.Globalization.CultureInfo.InvariantCulture)).ToList();
bool ruleOk = thenVals.Count > 0 && thenVals.All(x => allowedStr.Contains(Convert.ToString(x, System.Globalization.CultureInfo.InvariantCulture)));
check("规则过滤生效 (" + rSeries.code + ": " + r.If.Param + "=" + Convert.ToString(r.If.Value) + " → " + r.Then.Param + " 仅 " + thenVals.Count + " 值)", ruleOk);
}
}
}
// ============ 参数化引擎 (mode=parametric 系列, 一期 2026-08-25) ============
var pSeries = cat.series.FirstOrDefault(x => ParametricEngine.IsParametric(x));
if (pSeries != null)
{
var ren1 = ParametricEngine.RenderCode("KC{type}{bore}-{stroke}{magnet}{mount}",
new Dictionary<string, object> { { "type", "00" }, { "bore", 32 }, { "stroke", 10 }, { "magnet", "" }, { "mount", "" } });
check("参数化: 模板渲染 KC0032-10 (" + ren1 + ")", ren1 == "KC0032-10");
var ren2 = ParametricEngine.RenderCode("A{type:pad2}", new Dictionary<string, object> { { "type", "3" } });
check("参数化: pad 补零 A03 (" + ren2 + ")", ren2 == "A03");
var ren3 = ParametricEngine.RenderCode("A{nofield}", new Dictionary<string, object> { { "type", "3" } });
check("参数化: 未知字段 → null", ren3 == null);
// 对拍回归: 参数化系列全部变体编码可被模板重新生成 (构建门禁的运行时复核)
var cfgP = new Configurator(pSeries);
var bad = (pSeries.variants ?? new List<Variant>()).Count(v =>
ParametricEngine.RenderCode(pSeries.modelCodeTemplate, v.@params) != v.modelCode);
check("参数化: " + pSeries.code + " 全部 " + (pSeries.variants == null ? 0 : pSeries.variants.Count) +
" 编码重生成一致 (不一致 " + bad + ")", bad == 0);
// 测试向量文件 (与网页同源): 包内 parametric-vectors.json → 逐条对拍
if (pkg.EntryNames.Contains("parametric-vectors.json"))
{
var vecText = Encoding.UTF8.GetString(pkg.ReadAsset("parametric-vectors.json"));
var vec = new System.Web.Script.Serialization.JavaScriptSerializer().DeserializeObject(vecText) as Dictionary<string, object>;
var cases = vec != null && vec.ContainsKey("cases") ? (object[])vec["cases"] : new object[0];
int vecOk = 0;
var vecFails = new List<string>();
foreach (var cObj in cases)
{
var c = cObj as Dictionary<string, object>;
if (c == null) { vecFails.Add("向量格式错误"); continue; }
var sCode = Convert.ToString(c["series"]);
var cs = cat.series.FirstOrDefault(x => x.code == sCode);
if (cs == null) { vecFails.Add("系列不存在 " + sCode); continue; }
var cc = new Configurator(cs);
var pars = new Dictionary<string, object>();
foreach (var kv in (Dictionary<string, object>)c["params"]) pars[kv.Key] = kv.Value;
var res = ParametricEngine.Generate(cs, cc, pars);
var errExp = c.ContainsKey("expectError") ? Convert.ToString(c["expectError"]) : null;
bool ok;
if (errExp != null)
ok = res != null && res.Error == errExp;
else
{
ok = res != null && res.Error == null &&
Convert.ToString(c["expectCode"]) == res.ModelCode &&
(!c.ContainsKey("expectStandard") || Convert.ToBoolean(c["expectStandard"]) == res.IsStandard);
if (ok && c.ContainsKey("expectNearest"))
{
var nr = ParametricEngine.NearestStandard(cs, cc, pars);
ok = nr != null && nr.modelCode == Convert.ToString(c["expectNearest"]);
}
}
if (ok) vecOk++;
else vecFails.Add(sCode + " → " + (res == null ? "null" : (res.ModelCode ?? res.Error)));
}
check("参数化: 测试向量对拍 " + vecOk + "/" + cases.Length +
(vecFails.Count > 0 ? " (失败: " + string.Join("; ", vecFails.Take(5)) + ")" : ""),
cases.Length > 0 && vecOk == cases.Length);
}
else
check("参数化: 测试向量文件存在于包内", false);
}
// ============ 样例包专项回归 (SC/MAQ 虚构样例) ============
if (isSample)
{
var s = cat.series[0];
var cfg = new Configurator(s);
var valid = cfg.ValidVariants();
check("样例: SC 有效变体 72 个 (实际 " + valid.Count + ")", valid.Count == 72);
check("样例: 有效变体全部满足规则", valid.All(v => !cfg.RuleViolated(v)));
var sel1 = new Dictionary<string, object> { { "bore", 16 } };
var opts1 = cfg.AllowedOptions(sel1);
var strokes = opts1["stroke"].Select(o => Convert.ToInt32(o, System.Globalization.CultureInfo.InvariantCulture)).OrderBy(x => x).ToList();
check("样例: R1 缸径16→行程仅25/50", strokes.Count == 2 && strokes[0] == 25 && strokes[1] == 50);
check("样例: R2 缸径16→磁石仅空值", opts1["magnet"].Count == 1 && string.IsNullOrEmpty(Convert.ToString(opts1["magnet"][0])));
var sel2 = new Dictionary<string, object> { { "magnet", "S" } };
var bores = cfg.AllowedOptions(sel2)["bore"].Select(o => Convert.ToInt32(o, System.Globalization.CultureInfo.InvariantCulture)).ToList();
check("样例: R2 磁石S→缸径不含16", !bores.Contains(16) && bores.Count == 3);
var selCb = new Dictionary<string, object> { { "mount", "CB" } };
var strokesCb = cfg.AllowedOptions(selCb)["stroke"].Select(o => Convert.ToInt32(o, System.Globalization.CultureInfo.InvariantCulture)).ToList();
check("样例: R3 中摆CB→行程不含100", !strokesCb.Contains(100) && strokesCb.Count == 3);
var sM = cat.series[1];
check("样例: 系列2 = MAQ", sM.code == "MAQ");
var cfgM = new Configurator(sM);
var selM = new Dictionary<string, object> { { "bore", 6 } };
var strokesM = cfgM.AllowedOptions(selM)["stroke"].Select(o => Convert.ToInt32(o, System.Globalization.CultureInfo.InvariantCulture)).OrderBy(x => x).ToList();
check("样例: MAQ 缸径6→行程仅10/20", strokesM.Count == 2 && strokesM[0] == 10 && strokesM[1] == 20);
var vM = cfgM.Resolve(new Dictionary<string, object> { { "bore", 10 }, { "stroke", 40 }, { "magnet", "S" }, { "mount", "LB" } });
check("样例: MAQ 选型命中 MAQ10x40S-LB", vM != null && vM.modelCode == "MAQ10x40S-LB");
var sel3 = new Dictionary<string, object> { { "bore", 32 }, { "stroke", 50 }, { "magnet", "S" }, { "mount", "LB" } };
var v3 = cfg.Resolve(sel3);
check("样例: 完整选型命中 SC32x50S-LB", v3 != null && v3.modelCode == "SC32x50S-LB");
var tmp = Path.Combine(Path.GetTempPath(), "ounibo_selftest");
Directory.CreateDirectory(tmp);
var f = pkg.ExtractStep(v3, tmp);
var content = File.ReadAllText(f);
int solidCount = content.Split(new[] { "MANIFOLD_SOLID_BREP" }, StringSplitOptions.None).Length - 1;
check("样例: STEP 提取成功且含 4 个实体", File.Exists(f) && solidCount == 4);
var cyls = StepMesh.Parse(content);
check("样例: STEP 解析出 4 个圆柱", cyls != null && cyls.Count == 4);
if (cyls != null && cyls.Count == 4)
{
check("样例: 缸体R=18 杆R=6.4", Math.Abs(cyls[1].R - 18) < 0.01 && Math.Abs(cyls[3].R - 6.4) < 0.01);
var mesh = StepMesh.BuildMesh(cyls[0]);
check("样例: 网格生成", mesh.Positions.Count > 0 && mesh.TriangleIndices.Count % 3 == 0);
}
check("样例: 尺寸图资源存在", pkg.EntryNames.Any(n => n.Contains("dim-drawing")));
check("样例: 数据表 PDF 资源存在", pkg.EntryNames.Any(n => n.EndsWith(".pdf")));
var r1 = engine0.Query("SC32", "zh");
check("样例: 搜索 SC32 命中 SC32x50S-LB", r1.Any(x => x.ModelCode == "SC32x50S-LB"));
var r2 = engine0.Query("gangjing", "zh");
var hasKw = (s.keywords ?? "").Length > 0 ||
(s.parameters ?? new List<ParameterDef>()).Any(p => (p.keywords ?? "").Length > 0);
if (hasKw)
check("样例: 拼音关键词 gangjing 命中", r2.Count > 0);
else
log.AppendLine("SKIP 拼音关键词 (数据包未提供关键词, 属已知限制)");
var r3 = engine0.Query("magnet", "en");
check("样例: 英文关键词 magnet 命中", r3.Count > 0);
var r4 = engine0.Query("32x50", "zh");
check("样例: 编码片段 32x50 命中 SC32x50S-LB", r4.Any(x => x.ModelCode == "SC32x50S-LB"));
var allItems = engine0.Query("SC", "zh");
var ranged = engine0.RangeFilter(allItems, new Dictionary<string, double?[]> { { "bore", new double?[] { 10, 30 } } });
var rangedBores = ranged.Select(x => Convert.ToInt32(x.Variant.@params["bore"], System.Globalization.CultureInfo.InvariantCulture)).Distinct().OrderBy(x => x).ToList();
check("样例: 缸径 10~30 过滤 → 仅16/25", ranged.Count > 0 && rangedBores.Count == 2 && rangedBores[0] == 16 && rangedBores[1] == 25);
// Builder 打包回路 (样例数据 → 新 .opc)
var sdDir = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(opcPath), "..", "..", "sample-data", "onb-sc"));
var tmpOpc = Path.Combine(Path.GetTempPath(), "ounibo_selftest_build.opc");
var spec = new Series
{
code = "SC",
nameZh = "SC 系列标准气缸",
nameEn = "SC Series Standard Cylinder",
parameters = new List<ParameterDef>
{
new ParameterDef { code = "bore", nameZh = "缸径", nameEn = "Bore", unit = "mm", type = "number", values = new List<object> { 16, 25, 32, 40 } },
new ParameterDef { code = "stroke", nameZh = "行程", nameEn = "Stroke", unit = "mm", type = "number", values = new List<object> { 25, 50, 75, 100 } },
new ParameterDef { code = "magnet", nameZh = "磁石", nameEn = "Magnet", unit = "", type = "enum", values = new List<object> { "", "S" } },
new ParameterDef { code = "mount", nameZh = "安装方式", nameEn = "Mounting", unit = "", type = "enum", values = new List<object> { "FA", "LB", "CB" } }
},
rules = new List<Rule>
{
new Rule { If = new RuleCond { Param = "bore", Op = "eq", Value = 16 }, Then = new RuleCond { Param = "stroke", Op = "in", Value = new object[] { 25, 50 } } },
new Rule { If = new RuleCond { Param = "magnet", Op = "eq", Value = "S" }, Then = new RuleCond { Param = "bore", Op = "in", Value = new object[] { 25, 32, 40 } } },
new Rule { If = new RuleCond { Param = "mount", Op = "eq", Value = "CB" }, Then = new RuleCond { Param = "stroke", Op = "in", Value = new object[] { 25, 50, 75 } } }
},
naming = new Naming { stepNameTemplate = "{model}.step" },
attachments = new List<Attachment>(),
variants = new List<Variant>()
};
var buildRes = OpcBuilder.Build(tmpOpc, "欧霓博气动目录", "2026.06", "ACT", "气动执行元件", "Pneumatic Actuators",
spec, Path.Combine(sdDir, "params.csv"), Path.Combine(sdDir, "step"),
new List<BuildAttachmentSpec>
{
new BuildAttachmentSpec { kind = "datasheet", lang = "zh", source = Path.Combine(sdDir, "docs", "datasheet", "SC_datasheet_zh.pdf") },
new BuildAttachmentSpec { kind = "dimDrawing", lang = "zh", source = Path.Combine(sdDir, "docs", "dim-drawing", "SC32x50S-LB.png"), model = "SC32x50S-LB" }
});
check("样例: Builder 打包成功 (变体 " + (buildRes.Ok ? "ok" : "fail") + ")", buildRes.Ok);
foreach (var be in buildRes.Errors.Take(10)) log.AppendLine(" builder-err: " + be);
if (buildRes.Ok)
{
using (var pkg2 = OpcPackage.Open(tmpOpc))
{
check("样例: Builder 产物可打开且 72 变体", pkg2.Catalog.series[0].variants.Count == 72);
var issues2 = PackageValidator.Validate(pkg2);
check("样例: Builder 产物校验通过", issues2.All(i => i.Severity != "ERROR"));
var cfg2 = new Configurator(pkg2.Catalog.series[0]);
var selB = new Dictionary<string, object> { { "bore", 40 }, { "stroke", 100 }, { "magnet", "" }, { "mount", "FA" } };
var vb = cfg2.Resolve(selB);
check("样例: Builder 产物选型命中 SC40x100-FA", vb != null && vb.modelCode == "SC40x100-FA");
}
}
}
}
}
catch (Exception ex)
{
fail++;
log.AppendLine("EXCEPTION: " + ex);
}
log.AppendLine(string.Format("结果: {0} 通过, {1} 失败", pass, fail));
var logPath = Path.Combine(Path.GetDirectoryName(opcPath) ?? ".", "selftest.log");
File.WriteAllText(logPath, log.ToString(), Encoding.UTF8);
Console.Out.WriteLine("selftest -> " + logPath);
return fail == 0 ? 0 : 1;
}
}
}
#endif

1234
src/GenServer/GenServer.cs Normal file

File diff suppressed because it is too large Load Diff

110
src/Viewer3D/StepMesh.cs Normal file
View File

@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Windows.Media;
using System.Windows.Media.Media3D;
namespace Ounibo.Catalog.Viewer3D
{
public class Cyl
{
public double Z0, Z1, R;
public double X; // 圆心 X 偏移 (双轴活塞杆)
}
/// <summary>
/// 解析本项目生成的极简 STEP (圆柱组合), 并离散为 WPF 网格。
/// 注: 只支持自有生成器的格式; 真实 CAD 的通用 STEP 解析在 M2 由 Builder 侧处理。
/// </summary>
public static class StepMesh
{
public static List<Cyl> Parse(string step)
{
var rs = new List<double>();
foreach (Match m in Regex.Matches(step, @"CYLINDRICAL_SURFACE\('',#\d+,([\d.]+)\);"))
rs.Add(double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture));
// 圆心点 = 仅有的 y=0 点: (X, 0, Z) 每圆柱一对 (底+顶)
var xs = new List<double>();
var zs = new List<double>();
foreach (Match m in Regex.Matches(step, @"CARTESIAN_POINT\('',\((-?[\d.]+),0,(-?[\d.]+)\)\);"))
{
xs.Add(double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture));
zs.Add(double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture));
}
var cy = new List<Cyl>();
for (int i = 0; i < rs.Count && 2 * i + 1 < zs.Count; i++)
cy.Add(new Cyl
{
Z0 = Math.Min(zs[2 * i], zs[2 * i + 1]),
Z1 = Math.Max(zs[2 * i], zs[2 * i + 1]),
R = rs[i],
X = xs[2 * i]
});
return cy;
}
/// <summary>圆柱 → 三角网格 (侧面 + 顶盖 + 底盖, 带逐顶点法线)。</summary>
public static MeshGeometry3D BuildMesh(Cyl c, int segments = 32)
{
var mesh = new MeshGeometry3D();
var pts = new List<Point3D>();
var nrm = new List<Vector3D>();
// 下环 (z0) 与上环 (z1)
for (int i = 0; i < segments; i++)
{
double a = 2 * Math.PI * i / segments;
double x = Math.Cos(a) * c.R + c.X, y = Math.Sin(a) * c.R;
pts.Add(new Point3D(x, y, c.Z0));
var n0 = new Vector3D(x - c.X, y, 0); n0.Normalize(); nrm.Add(n0);
}
for (int i = 0; i < segments; i++)
{
double a = 2 * Math.PI * i / segments;
double x = Math.Cos(a) * c.R + c.X, y = Math.Sin(a) * c.R;
pts.Add(new Point3D(x, y, c.Z1));
var n1 = new Vector3D(x - c.X, y, 0); n1.Normalize(); nrm.Add(n1);
}
// 侧面
for (int i = 0; i < segments; i++)
{
int a0 = i, a1 = (i + 1) % segments;
int b0 = segments + i, b1 = segments + ((i + 1) % segments);
mesh.TriangleIndices.Add(a0); mesh.TriangleIndices.Add(b0); mesh.TriangleIndices.Add(a1);
mesh.TriangleIndices.Add(a1); mesh.TriangleIndices.Add(b0); mesh.TriangleIndices.Add(b1);
}
// 顶盖与底盖 (中心点 + 扇形)
pts.Add(new Point3D(c.X, 0, c.Z1)); nrm.Add(new Vector3D(0, 0, 1));
int topC = pts.Count - 1;
pts.Add(new Point3D(c.X, 0, c.Z0)); nrm.Add(new Vector3D(0, 0, -1));
int botC = pts.Count - 1;
for (int i = 0; i < segments; i++)
{
int b0 = segments + i, b1 = segments + ((i + 1) % segments);
mesh.TriangleIndices.Add(topC); mesh.TriangleIndices.Add(b0); mesh.TriangleIndices.Add(b1);
mesh.TriangleIndices.Add(botC); mesh.TriangleIndices.Add(i + 1 == segments ? 0 : i + 1); mesh.TriangleIndices.Add(i);
}
mesh.Positions = new Point3DCollection(pts);
mesh.Normals = new Vector3DCollection(nrm);
return mesh;
}
/// <summary>计算包围信息用于相机取景。</summary>
public static void Bounds(List<Cyl> cyls, out double cx, out double cy, out double cz, out double r, out double h)
{
cx = 0; cy = 0;
double zMin = double.MaxValue, zMax = double.MinValue, rMax = 0;
foreach (var c in cyls)
{
zMin = Math.Min(zMin, c.Z0); zMax = Math.Max(zMax, c.Z1); rMax = Math.Max(rMax, c.R);
}
cz = (zMin + zMax) / 2; h = zMax - zMin; r = rMax;
}
}
}