1557 lines
64 KiB
C#
1557 lines
64 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.IO.Compression;
|
||
using System.Linq;
|
||
using Newtonsoft.Json;
|
||
using Autodesk.AutoCAD.DatabaseServices;
|
||
using Autodesk.AutoCAD.EditorInput;
|
||
using Autodesk.AutoCAD.Runtime;
|
||
using Autodesk.AutoCAD.Geometry;
|
||
using AcApp = Autodesk.AutoCAD.ApplicationServices.Application;
|
||
|
||
[assembly: CommandClass(typeof(FurnitureExporter.Commands))]
|
||
[assembly: ExtensionApplication(typeof(FurnitureExporter.FurnitureExporterApp))]
|
||
|
||
namespace FurnitureExporter;
|
||
|
||
/// <summary>
|
||
/// 插件初始化类
|
||
/// </summary>
|
||
public class FurnitureExporterApp : IExtensionApplication
|
||
{
|
||
private const string PluginVersion = "2.6.0";
|
||
|
||
public void Initialize()
|
||
{
|
||
var doc = AcApp.DocumentManager.MdiActiveDocument;
|
||
doc?.Editor.WriteMessage("\n");
|
||
doc?.Editor.WriteMessage("╔════════════════════════════════════════════╗\n");
|
||
doc?.Editor.WriteMessage($"║ 家具导出插件 v{PluginVersion} 已加载 ║\n");
|
||
doc?.Editor.WriteMessage("║ ║\n");
|
||
doc?.Editor.WriteMessage("║ 命令: ║\n");
|
||
doc?.Editor.WriteMessage("║ FEXPORT - 单资产导出到资产中心 ║\n");
|
||
doc?.Editor.WriteMessage("║ FEXPORTASSET - 单资产导出别名 ║\n");
|
||
doc?.Editor.WriteMessage("║ FEXPORTPRODUCT - 整套家具兼容导出 ║\n");
|
||
doc?.Editor.WriteMessage("║ FINFO - 查看选中实体信息 ║\n");
|
||
doc?.Editor.WriteMessage("║ ║\n");
|
||
doc?.Editor.WriteMessage($"║ v{PluginVersion} 新功能: ║\n");
|
||
doc?.Editor.WriteMessage("║ · 默认进入资产中心单资产导出 ║\n");
|
||
doc?.Editor.WriteMessage("║ · 新增 assembly 装配语义导出层 ║\n");
|
||
doc?.Editor.WriteMessage("║ · 语义优先用孔位/五金/配合面推导 ║\n");
|
||
doc?.Editor.WriteMessage("║ · 安装顺序仍保留给 Web 端人工设定 ║\n");
|
||
doc?.Editor.WriteMessage("║ · 导出置信度与 warnings 便于人工复核 ║\n");
|
||
doc?.Editor.WriteMessage("║ · 不破坏现有 panels/holes/hardware 数据 ║\n");
|
||
doc?.Editor.WriteMessage("╚════════════════════════════════════════════╝\n");
|
||
}
|
||
|
||
public void Terminate()
|
||
{
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// AutoCAD 命令类
|
||
/// </summary>
|
||
public class Commands
|
||
{
|
||
private const string PluginVersion = "2.6.0";
|
||
|
||
private sealed class SingleAssetExportMode
|
||
{
|
||
public string AssetType { get; set; } = "";
|
||
public string DisplayName { get; set; } = "";
|
||
public string FileSuffix { get; set; } = "";
|
||
public bool AnalyzePanels { get; set; }
|
||
public bool AnalyzeHardware { get; set; }
|
||
public string SelectionPrompt { get; set; } = "";
|
||
}
|
||
|
||
private sealed class PanelExportAnalysis
|
||
{
|
||
public List<PanelInfo> Panels { get; } = new();
|
||
public List<HoleInfo> Holes { get; } = new();
|
||
public List<HoleGroupInfo> HoleGroups { get; } = new();
|
||
public List<MountPatternInfo> MountPatterns { get; } = new();
|
||
public List<MountPatternInfo> SnapPatterns { get; } = new();
|
||
public List<SnapSurfaceInfo> SnapSurfaces { get; } = new();
|
||
public int LogicalHoleCount { get; set; }
|
||
public List<EdgeFeatureInfo> EdgeFeatures { get; } = new();
|
||
public List<ExcludedHoleInfo> ExcludedHoles { get; } = new();
|
||
}
|
||
|
||
private static readonly IReadOnlyDictionary<string, SingleAssetExportMode> SingleAssetExportModes =
|
||
new Dictionary<string, SingleAssetExportMode>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["Board"] = new()
|
||
{
|
||
AssetType = "BOARD",
|
||
DisplayName = "板材",
|
||
FileSuffix = "board_asset",
|
||
AnalyzePanels = true,
|
||
AnalyzeHardware = false,
|
||
SelectionPrompt = "请选择单个【板材资产】的实体(建议 3D Solid,可多选),然后按 Enter 确认: ",
|
||
},
|
||
["Component"] = new()
|
||
{
|
||
AssetType = "COMPONENT",
|
||
DisplayName = "组件",
|
||
FileSuffix = "component_asset",
|
||
AnalyzePanels = true,
|
||
AnalyzeHardware = false,
|
||
SelectionPrompt = "请选择单个【组件资产】的实体(可选 3D Solid / 图块,可多选),然后按 Enter 确认: ",
|
||
},
|
||
["Hardware"] = new()
|
||
{
|
||
AssetType = "HARDWARE",
|
||
DisplayName = "五金",
|
||
FileSuffix = "hardware_asset",
|
||
AnalyzePanels = false,
|
||
AnalyzeHardware = true,
|
||
SelectionPrompt = "请选择单个【五金资产】的实体(可选 3D Solid / 图块,可多选),然后按 Enter 确认: ",
|
||
},
|
||
};
|
||
|
||
// 单位转换常量(转换到毫米的系数)
|
||
private static readonly Dictionary<UnitsValue, (string Name, double ToMM)> UnitInfo = new()
|
||
{
|
||
{ UnitsValue.Undefined, ("未定义", 1.0) },
|
||
{ UnitsValue.Inches, ("英寸", 25.4) },
|
||
{ UnitsValue.Feet, ("英尺", 304.8) },
|
||
{ UnitsValue.Miles, ("英里", 1609344.0) },
|
||
{ UnitsValue.Millimeters, ("毫米", 1.0) },
|
||
{ UnitsValue.Centimeters, ("厘米", 10.0) },
|
||
{ UnitsValue.Meters, ("米", 1000.0) },
|
||
{ UnitsValue.Kilometers, ("千米", 1000000.0) },
|
||
// { UnitsValue.Microinches, ("微英寸", 0.0000254) }, // AutoCAD 2024 不支持
|
||
{ UnitsValue.Mils, ("密耳", 0.0254) },
|
||
{ UnitsValue.Yards, ("码", 914.4) },
|
||
{ UnitsValue.Angstroms, ("埃", 0.0000001) },
|
||
{ UnitsValue.Nanometers, ("纳米", 0.000001) },
|
||
{ UnitsValue.Microns, ("微米", 0.001) },
|
||
{ UnitsValue.Decimeters, ("分米", 100.0) },
|
||
{ UnitsValue.Dekameters, ("十米", 10000.0) },
|
||
{ UnitsValue.Hectometers, ("百米", 100000.0) },
|
||
{ UnitsValue.Gigameters, ("千兆米", 1e12) },
|
||
};
|
||
|
||
/// <summary>
|
||
/// 检测图纸单位,返回单位名称和转换系数
|
||
/// </summary>
|
||
private (UnitsValue Unit, string UnitName, double ConversionFactor, bool IsValid) CheckDrawingUnits(Database db, Editor ed)
|
||
{
|
||
var insunits = db.Insunits;
|
||
|
||
if (!UnitInfo.TryGetValue(insunits, out var info))
|
||
{
|
||
return (insunits, "未知", 1.0, false);
|
||
}
|
||
|
||
return (insunits, info.Name, info.ToMM, true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 计算所有实体的边界框和中心点
|
||
/// </summary>
|
||
private (Point3d Center, Point3d Min, Point3d Max, Vector3d Size) CalculateBounds(ObjectId[] ids, Database db)
|
||
{
|
||
using var tr = db.TransactionManager.StartTransaction();
|
||
|
||
Point3d? minPoint = null;
|
||
Point3d? maxPoint = null;
|
||
|
||
foreach (var id in ids)
|
||
{
|
||
var ent = tr.GetObject(id, OpenMode.ForRead) as Entity;
|
||
if (ent == null) continue;
|
||
|
||
try
|
||
{
|
||
var ext = ent.GeometricExtents;
|
||
if (minPoint == null || maxPoint == null)
|
||
{
|
||
minPoint = ext.MinPoint;
|
||
maxPoint = ext.MaxPoint;
|
||
}
|
||
else
|
||
{
|
||
var min = minPoint.Value;
|
||
var max = maxPoint.Value;
|
||
minPoint = new Point3d(
|
||
Math.Min(min.X, ext.MinPoint.X),
|
||
Math.Min(min.Y, ext.MinPoint.Y),
|
||
Math.Min(min.Z, ext.MinPoint.Z)
|
||
);
|
||
maxPoint = new Point3d(
|
||
Math.Max(max.X, ext.MaxPoint.X),
|
||
Math.Max(max.Y, ext.MaxPoint.Y),
|
||
Math.Max(max.Z, ext.MaxPoint.Z)
|
||
);
|
||
}
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
tr.Commit();
|
||
|
||
if (minPoint == null || maxPoint == null)
|
||
{
|
||
return (Point3d.Origin, Point3d.Origin, Point3d.Origin, new Vector3d(0, 0, 0));
|
||
}
|
||
|
||
var size = maxPoint.Value - minPoint.Value;
|
||
var center = new Point3d(
|
||
(minPoint.Value.X + maxPoint.Value.X) / 2,
|
||
(minPoint.Value.Y + maxPoint.Value.Y) / 2,
|
||
minPoint.Value.Z // 底部 Z 坐标,用于将模型底部放在 Z=0
|
||
);
|
||
|
||
return (center, minPoint.Value, maxPoint.Value, new Vector3d(size.X, size.Y, size.Z));
|
||
}
|
||
|
||
private bool EnsureMillimeterDrawingOrConfirm(Database db, Editor ed, out string unitName)
|
||
{
|
||
var (unit, detectedUnitName, conversionFactor, _) = CheckDrawingUnits(db, ed);
|
||
unitName = detectedUnitName;
|
||
|
||
ed.WriteMessage("\n[单位检测]\n");
|
||
ed.WriteMessage("─────────────────────────────\n");
|
||
ed.WriteMessage($" 当前图纸单位: {unitName}\n");
|
||
|
||
if (unit == UnitsValue.Millimeters)
|
||
{
|
||
ed.WriteMessage(" ✓ 单位正确\n");
|
||
return true;
|
||
}
|
||
|
||
ed.WriteMessage("\n");
|
||
ed.WriteMessage("╔════════════════════════════════════════════╗\n");
|
||
ed.WriteMessage("║ ⚠️ 单位错误 ║\n");
|
||
ed.WriteMessage("╚════════════════════════════════════════════╝\n");
|
||
ed.WriteMessage($" 当前单位: {unitName}\n");
|
||
ed.WriteMessage(" 要求单位: 毫米 (Millimeters)\n");
|
||
ed.WriteMessage("\n");
|
||
ed.WriteMessage(" 请修改图纸单位后重新导出:\n");
|
||
ed.WriteMessage(" 1. 输入命令 UNITS 打开单位设置\n");
|
||
ed.WriteMessage(" 2. 将 '插入时的缩放单位' 改为 '毫米'\n");
|
||
ed.WriteMessage(" 3. 点击确定\n");
|
||
ed.WriteMessage(" 4. 重新运行导出命令\n");
|
||
ed.WriteMessage("\n");
|
||
|
||
var continueOpt = new PromptKeywordOptions("\n是否强制继续? (可能导致比例错误) [是(Y)/否(N)]", "Yes No");
|
||
continueOpt.Keywords.Default = "No";
|
||
var continueResult = ed.GetKeywords(continueOpt);
|
||
|
||
if (continueResult.Status != PromptStatus.OK || continueResult.StringResult != "Yes")
|
||
{
|
||
ed.WriteMessage("\n已取消导出,请修改单位后重试。\n");
|
||
return false;
|
||
}
|
||
|
||
ed.WriteMessage($"\n ⚠️ 强制继续,将使用转换系数: {conversionFactor}\n");
|
||
return true;
|
||
}
|
||
|
||
private static double[] BuildModelBounds(Point3d minPt, Point3d maxPt, Point3d originOffset)
|
||
{
|
||
return new[]
|
||
{
|
||
Math.Round(minPt.X + originOffset.X, 2),
|
||
Math.Round(minPt.Y + originOffset.Y, 2),
|
||
Math.Round(minPt.Z + originOffset.Z, 2),
|
||
Math.Round(maxPt.X + originOffset.X, 2),
|
||
Math.Round(maxPt.Y + originOffset.Y, 2),
|
||
Math.Round(maxPt.Z + originOffset.Z, 2),
|
||
};
|
||
}
|
||
|
||
private SingleAssetExportMode? PromptSingleAssetExportMode(Editor ed)
|
||
{
|
||
var modeOptions = new PromptKeywordOptions(
|
||
"\n选择单资产类型 [板材(B)/组件(C)/五金(H)]",
|
||
"Board Component Hardware"
|
||
);
|
||
modeOptions.Keywords.Default = "Board";
|
||
modeOptions.AllowNone = true;
|
||
var modeResult = ed.GetKeywords(modeOptions);
|
||
|
||
if (modeResult.Status == PromptStatus.Cancel)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var keyword = modeResult.Status == PromptStatus.OK
|
||
? modeResult.StringResult
|
||
: modeOptions.Keywords.Default;
|
||
|
||
return keyword != null && SingleAssetExportModes.TryGetValue(keyword, out var mode)
|
||
? mode
|
||
: null;
|
||
}
|
||
|
||
private PanelExportAnalysis AnalyzePanelExportData(
|
||
Database db,
|
||
ObjectId[] solidIds,
|
||
Point3d originOffset,
|
||
Point3d minPt,
|
||
Point3d maxPt)
|
||
{
|
||
var result = new PanelExportAnalysis();
|
||
if (solidIds.Length == 0)
|
||
{
|
||
return result;
|
||
}
|
||
|
||
var panelAnalyzer = new PanelAnalyzer(db);
|
||
var panels = panelAnalyzer.Analyze(solidIds, originOffset);
|
||
result.Panels.AddRange(panels);
|
||
|
||
var allHoles = new List<HoleInfo>();
|
||
foreach (var panel in panels)
|
||
{
|
||
allHoles.AddRange(panel.Holes);
|
||
}
|
||
|
||
var panelsWithoutDetectedHoles = panels
|
||
.Where(panel => panel.Holes.Count == 0)
|
||
.ToList();
|
||
if (panelsWithoutDetectedHoles.Count > 0)
|
||
{
|
||
var markerDetector = new HoleMarkerDetector(db);
|
||
markerDetector.DetectAndAttach(
|
||
panels,
|
||
originOffset,
|
||
minPt,
|
||
maxPt,
|
||
new HashSet<string>(panelsWithoutDetectedHoles.Select(panel => panel.Id))
|
||
);
|
||
|
||
allHoles.Clear();
|
||
foreach (var panel in panels)
|
||
{
|
||
allHoles.AddRange(panel.Holes);
|
||
}
|
||
}
|
||
|
||
foreach (var hole in allHoles)
|
||
{
|
||
hole.InferredUsage = HoleDetector.InferHoleUsage(hole.Diameter);
|
||
}
|
||
|
||
var holeGroups = HoleGrouping.BuildEccentricScrewGroups(allHoles);
|
||
HoleGrouping.AttachGroupsToPanels(panels, holeGroups);
|
||
var mountPatterns = MountPatternAnalyzer.BuildMountPatterns(panels, allHoles, holeGroups);
|
||
MountPatternAnalyzer.AttachPatternsToPanels(panels, mountPatterns);
|
||
var snapSurfaces = SnapSurfaceBuilder.BuildSnapSurfaces(panels);
|
||
var logicalHoleCount = HoleGrouping.ComputeLogicalHoleCount(allHoles);
|
||
|
||
foreach (var hole in allHoles)
|
||
{
|
||
hole.SuggestedFasteners = FastenerGuesser.SuggestForHole(hole, topN: 2);
|
||
}
|
||
|
||
var allEdgeFeatures = new List<EdgeFeatureInfo>();
|
||
var chamferDetector = new ChamferDetector(db);
|
||
for (int i = 0; i < solidIds.Length && i < panels.Count; i++)
|
||
{
|
||
var features = chamferDetector.DetectEdgeFeatures(solidIds[i], panels[i].Id, originOffset);
|
||
allEdgeFeatures.AddRange(features);
|
||
}
|
||
|
||
result.Holes.AddRange(allHoles);
|
||
result.HoleGroups.AddRange(holeGroups);
|
||
result.MountPatterns.AddRange(mountPatterns);
|
||
result.SnapPatterns.AddRange(mountPatterns);
|
||
result.SnapSurfaces.AddRange(snapSurfaces);
|
||
result.LogicalHoleCount = logicalHoleCount;
|
||
result.EdgeFeatures.AddRange(allEdgeFeatures);
|
||
result.ExcludedHoles.AddRange(panelAnalyzer.ExcludedHoles);
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 主导出命令 - 导出板材 GLB + 五金件位置 JSON
|
||
/// </summary>
|
||
[CommandMethod("FEXPORTPRODUCT")]
|
||
public void ExportFurniture()
|
||
{
|
||
var doc = AcApp.DocumentManager.MdiActiveDocument;
|
||
if (doc == null)
|
||
{
|
||
return;
|
||
}
|
||
var db = doc.Database;
|
||
var ed = doc.Editor;
|
||
|
||
try
|
||
{
|
||
var docName = string.IsNullOrWhiteSpace(doc.Name) ? "Untitled" : doc.Name;
|
||
var docKey = docName;
|
||
|
||
ed.WriteMessage("\n");
|
||
ed.WriteMessage("╔════════════════════════════════════════════╗\n");
|
||
ed.WriteMessage("║ 家具模型导出向导 ║\n");
|
||
ed.WriteMessage("╚════════════════════════════════════════════╝\n");
|
||
|
||
// ========== 检测图纸单位 ==========
|
||
ed.WriteMessage("\n[单位检测]\n");
|
||
ed.WriteMessage("─────────────────────────────\n");
|
||
|
||
var (unit, unitName, conversionFactor, isValidUnit) = CheckDrawingUnits(db, ed);
|
||
ed.WriteMessage($" 当前图纸单位: {unitName}\n");
|
||
|
||
// 单位必须是毫米
|
||
if (unit != UnitsValue.Millimeters)
|
||
{
|
||
ed.WriteMessage("\n");
|
||
ed.WriteMessage("╔════════════════════════════════════════════╗\n");
|
||
ed.WriteMessage("║ ⚠️ 单位错误 ║\n");
|
||
ed.WriteMessage("╚════════════════════════════════════════════╝\n");
|
||
ed.WriteMessage($" 当前单位: {unitName}\n");
|
||
ed.WriteMessage($" 要求单位: 毫米 (Millimeters)\n");
|
||
ed.WriteMessage("\n");
|
||
ed.WriteMessage(" 请修改图纸单位后重新导出:\n");
|
||
ed.WriteMessage(" 1. 输入命令 UNITS 打开单位设置\n");
|
||
ed.WriteMessage(" 2. 将 '插入时的缩放单位' 改为 '毫米'\n");
|
||
ed.WriteMessage(" 3. 点击确定\n");
|
||
ed.WriteMessage(" 4. 重新运行 FEXPORTPRODUCT 命令\n");
|
||
ed.WriteMessage("\n");
|
||
|
||
// 询问是否继续(仅供高级用户)
|
||
var continueOpt = new PromptKeywordOptions("\n是否强制继续? (可能导致比例错误) [是(Y)/否(N)]", "Yes No");
|
||
continueOpt.Keywords.Default = "No";
|
||
var continueResult = ed.GetKeywords(continueOpt);
|
||
|
||
if (continueResult.Status != PromptStatus.OK || continueResult.StringResult != "Yes")
|
||
{
|
||
ed.WriteMessage("\n已取消导出,请修改单位后重试。\n");
|
||
return;
|
||
}
|
||
|
||
ed.WriteMessage($"\n ⚠️ 强制继续,将使用转换系数: {conversionFactor}\n");
|
||
}
|
||
else
|
||
{
|
||
ed.WriteMessage(" ✓ 单位正确\n");
|
||
}
|
||
|
||
// ========== 导出向导 ==========
|
||
// 默认推荐手动两步:先板件、再五金,准确率最高,兼容既有习惯。
|
||
// 自动模式仅作为提效兜底:一次框选整套家具,插件自动分组板件/五金。
|
||
ObjectId[] panelIds;
|
||
ObjectId[] hardwareIds = Array.Empty<ObjectId>();
|
||
bool usedAutoMode = false;
|
||
bool likelyMergedSingleSolid = false;
|
||
|
||
var pending = ExportSessionManager.Get(docKey);
|
||
if (pending != null)
|
||
{
|
||
ed.WriteMessage("\n[恢复导出会话]\n");
|
||
ed.WriteMessage("─────────────────────────────\n");
|
||
ed.WriteMessage($" 检测到未完成的板材选择({pending.PanelHandles.Count} 个)。\n");
|
||
|
||
panelIds = ResolveObjectIdsFromHandles(db, pending.PanelHandles);
|
||
panelIds = FilterSolids(panelIds, db);
|
||
if (panelIds.Length == 0)
|
||
{
|
||
ed.WriteMessage(" ⚠️ 无法恢复板材(可能图纸已变更/实体被删除)。已清除会话,请重新运行 FEXPORTPRODUCT。\n");
|
||
ExportSessionManager.Clear(docKey);
|
||
return;
|
||
}
|
||
|
||
if (panelIds.Length != pending.PanelHandles.Count)
|
||
{
|
||
ed.WriteMessage($" ⚠️ 部分板材无法恢复:恢复 {panelIds.Length}/{pending.PanelHandles.Count} 个,将继续导出。\n");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var modeOptions = new PromptKeywordOptions(
|
||
"\n选择导出方式 [手动(M)/自动(A)]",
|
||
"Manual Auto"
|
||
);
|
||
modeOptions.Keywords.Default = "Manual";
|
||
modeOptions.AllowNone = true;
|
||
var modeResult = ed.GetKeywords(modeOptions);
|
||
|
||
if (modeResult.Status == PromptStatus.Cancel)
|
||
{
|
||
ed.WriteMessage("\n已取消导出。\n");
|
||
return;
|
||
}
|
||
|
||
var useAutoMode = modeResult.Status == PromptStatus.OK && modeResult.StringResult == "Auto";
|
||
if (useAutoMode)
|
||
{
|
||
usedAutoMode = true;
|
||
|
||
ed.WriteMessage("\n[自动模式] 一次框选整套家具\n");
|
||
ed.WriteMessage("─────────────────────────────\n");
|
||
|
||
var allOptions = new PromptSelectionOptions
|
||
{
|
||
MessageForAdding = "请框选整套家具(板件 + 五金件),然后按 Enter 确认: "
|
||
};
|
||
|
||
var allResult = ed.GetSelection(allOptions);
|
||
if (allResult.Status != PromptStatus.OK)
|
||
{
|
||
ed.WriteMessage("\n已取消导出。\n");
|
||
return;
|
||
}
|
||
|
||
var splitResult = AutoSplitPanelsAndHardware(allResult.Value.GetObjectIds(), db);
|
||
panelIds = splitResult.PanelIds;
|
||
hardwareIds = splitResult.HardwareIds;
|
||
likelyMergedSingleSolid = splitResult.IsLikelyMergedSingleSolid;
|
||
|
||
if (panelIds.Length == 0)
|
||
{
|
||
ed.WriteMessage("\n错误: 自动模式未识别到板件,请改用手动模式选择板件。\n");
|
||
return;
|
||
}
|
||
|
||
ed.WriteMessage($" ✓ 自动识别板件: {panelIds.Length} 个\n");
|
||
ed.WriteMessage($" ✓ 自动识别五金件: {hardwareIds.Length} 个\n");
|
||
|
||
if (!string.IsNullOrWhiteSpace(splitResult.FallbackMessage))
|
||
{
|
||
ed.WriteMessage($" ⚠️ {splitResult.FallbackMessage}\n");
|
||
}
|
||
|
||
if (likelyMergedSingleSolid)
|
||
{
|
||
ed.WriteMessage(" ⚠️ 检测到疑似“单体并体模型”:Web 端无法做分件爆炸/分件材质,只能整体预览。\n");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// ========== 第 1 步:选择板材(手动模式) ==========
|
||
ed.WriteMessage("\n[第 1 步 / 共 2 步] 选择板材\n");
|
||
ed.WriteMessage("─────────────────────────────\n");
|
||
|
||
var panelOptions = new PromptSelectionOptions
|
||
{
|
||
MessageForAdding = "请框选所有【板材】实体,然后按 Enter 确认: "
|
||
};
|
||
|
||
var panelResult = ed.GetSelection(panelOptions);
|
||
if (panelResult.Status != PromptStatus.OK)
|
||
{
|
||
ed.WriteMessage("\n已取消导出。\n");
|
||
return;
|
||
}
|
||
|
||
panelIds = FilterSolids(panelResult.Value.GetObjectIds(), db);
|
||
if (panelIds.Length == 0)
|
||
{
|
||
ed.WriteMessage("\n错误: 未选中任何 3D 实体,请确保选择的是 3D Solid。\n");
|
||
return;
|
||
}
|
||
|
||
// 保存板材句柄到会话,允许用户在两步之间自由切图层/执行其他命令
|
||
var panelHandles = GetEntityHandles(db, panelIds);
|
||
ExportSessionManager.SetPanels(docKey, panelHandles);
|
||
|
||
ed.WriteMessage($" ✓ 已记录 {panelIds.Length} 个板材\n");
|
||
ed.WriteMessage("\n 你现在可以自由调整图层显示/冻结/隔离等操作。\n");
|
||
ed.WriteMessage(" 调整完成后,再次运行 FEXPORTPRODUCT 继续第 2 步(选五金并导出)。\n");
|
||
|
||
var proceedOpt = new PromptKeywordOptions(
|
||
"\n是否现在直接继续第 2 步? [继续(C)/稍后(R)]",
|
||
"Continue Later"
|
||
);
|
||
proceedOpt.Keywords.Default = "Later";
|
||
proceedOpt.AllowNone = true;
|
||
var proceedResult = ed.GetKeywords(proceedOpt);
|
||
|
||
if (proceedResult.Status != PromptStatus.OK || proceedResult.StringResult != "Continue")
|
||
{
|
||
ed.WriteMessage("\n已暂停导出。提示:再次运行 FEXPORTPRODUCT 继续;运行 FEXPORTRESET 可清除暂存。\n");
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!usedAutoMode)
|
||
{
|
||
// ========== 第 2 步:选择五金件(手动/恢复模式) ==========
|
||
ed.WriteMessage("\n[第 2 步 / 共 2 步] 选择五金件\n");
|
||
ed.WriteMessage("─────────────────────────────\n");
|
||
|
||
var hardwareOptions = new PromptSelectionOptions
|
||
{
|
||
MessageForAdding = "请框选所有【五金件】,然后按 Enter 确认 (如无五金件可直接 Enter): "
|
||
};
|
||
|
||
var hardwareResult = ed.GetSelection(hardwareOptions);
|
||
|
||
if (hardwareResult.Status == PromptStatus.OK)
|
||
{
|
||
hardwareIds = FilterSolidsAndBlocks(hardwareResult.Value.GetObjectIds(), db);
|
||
ed.WriteMessage($" ✓ 已选中 {hardwareIds.Length} 个五金件\n");
|
||
}
|
||
else
|
||
{
|
||
ed.WriteMessage(" ✓ 未选择五金件,将只导出板材模型\n");
|
||
}
|
||
}
|
||
|
||
// ========== 选择保存路径 ==========
|
||
ed.WriteMessage("\n");
|
||
var saveDialog = new Microsoft.Win32.SaveFileDialog
|
||
{
|
||
Title = "保存导出文件",
|
||
Filter = "ZIP 压缩包 (*.zip)|*.zip",
|
||
DefaultExt = ".zip",
|
||
FileName = Path.GetFileNameWithoutExtension(docName) + "_export"
|
||
};
|
||
|
||
if (saveDialog.ShowDialog() != true)
|
||
{
|
||
ed.WriteMessage("已取消导出。\n");
|
||
return;
|
||
}
|
||
|
||
var exportPath = saveDialog.FileName;
|
||
var tempDir = Path.Combine(Path.GetTempPath(), $"furniture_export_{Guid.NewGuid():N}");
|
||
Directory.CreateDirectory(tempDir);
|
||
|
||
try
|
||
{
|
||
ed.WriteMessage("正在处理...\n");
|
||
ed.WriteMessage("─────────────────────────────\n");
|
||
|
||
// 计算所有实体的边界框(板材 + 五金件)
|
||
var allIds = panelIds.Concat(hardwareIds).ToArray();
|
||
var (center, minPt, maxPt, size) = CalculateBounds(allIds, db);
|
||
|
||
ed.WriteMessage($" 模型边界: {size.X:F1} × {size.Y:F1} × {size.Z:F1} mm\n");
|
||
ed.WriteMessage($" 原始中心: ({center.X:F1}, {center.Y:F1}, {center.Z:F1})\n");
|
||
|
||
// 计算原点偏移(将模型移动到世界原点,底部放在 Z=0)
|
||
var originOffset = new Point3d(-center.X, -center.Y, -minPt.Z);
|
||
ed.WriteMessage($" 原点偏移: ({originOffset.X:F1}, {originOffset.Y:F1}, {originOffset.Z:F1})\n");
|
||
ed.WriteMessage("\n");
|
||
|
||
// 1. 导出板材 OBJ 模型(带原点偏移,使用 Brep API 提取真实几何)
|
||
ed.WriteMessage(" [1/7] 导出板材模型 (真实几何)...");
|
||
var objPath = Path.Combine(tempDir, "model.obj");
|
||
var (meshDensity, maxSubdivisions) = RecommendMeshSettings(size, panelIds.Length);
|
||
ObjExporter.Export(panelIds, objPath, originOffset, meshDensity: meshDensity, maxSubdivisions: maxSubdivisions);
|
||
var objSize = new FileInfo(objPath).Length / 1024.0;
|
||
ed.WriteMessage($" ✓ ({objSize:F1} KB)\n");
|
||
ed.WriteMessage($" 网格参数: density={meshDensity:F0}, maxSubdivisions={maxSubdivisions}\n");
|
||
|
||
// 2. 分析板材信息(v2.0 新增)
|
||
ed.WriteMessage(" [2/7] 分析板材信息...");
|
||
var panelAnalyzer = new PanelAnalyzer(db);
|
||
var panels = panelAnalyzer.Analyze(panelIds, originOffset);
|
||
ed.WriteMessage($" ✓ ({panels.Count} 个板材)\n");
|
||
|
||
// 3. 提取孔位信息(v2.0 新增)
|
||
// 优先:Brep 真孔(布尔运算形成的圆柱面)
|
||
// Fallback:图纸标注孔(Circle/Block 内 Circle)
|
||
ed.WriteMessage(" [3/7] 提取孔位信息...");
|
||
var allHoles = new List<HoleInfo>();
|
||
foreach (var panel in panels)
|
||
{
|
||
allHoles.AddRange(panel.Holes);
|
||
}
|
||
|
||
var panelsWithoutDetectedHoles = panels
|
||
.Where(panel => panel.Holes.Count == 0)
|
||
.ToList();
|
||
if (panelsWithoutDetectedHoles.Count > 0)
|
||
{
|
||
// 标注孔只作为补充路径:
|
||
// - 优先保留 Brep 真孔
|
||
// - 仅为“当前未提取到实体孔”的板件补充标注孔
|
||
var markerDetector = new HoleMarkerDetector(db);
|
||
markerDetector.DetectAndAttach(
|
||
panels,
|
||
originOffset,
|
||
minPt,
|
||
maxPt,
|
||
new HashSet<string>(panelsWithoutDetectedHoles.Select(panel => panel.Id))
|
||
);
|
||
|
||
allHoles.Clear();
|
||
foreach (var panel in panels)
|
||
{
|
||
allHoles.AddRange(panel.Holes);
|
||
}
|
||
}
|
||
|
||
// 1) 为每个孔推断用途(根据直径)
|
||
foreach (var hole in allHoles)
|
||
{
|
||
hole.InferredUsage = HoleDetector.InferHoleUsage(hole.Diameter);
|
||
}
|
||
|
||
// 2) 偏心孔位分组:把“主孔+侧孔”合并为 1 个逻辑孔位(不改变 holes 的几何数据,仅提供分组与逻辑计数)
|
||
var holeGroups = HoleGrouping.BuildEccentricScrewGroups(allHoles);
|
||
HoleGrouping.AttachGroupsToPanels(panels, holeGroups);
|
||
var mountPatterns = MountPatternAnalyzer.BuildMountPatterns(panels, allHoles, holeGroups);
|
||
MountPatternAnalyzer.AttachPatternsToPanels(panels, mountPatterns);
|
||
var snapSurfaces = SnapSurfaceBuilder.BuildSnapSurfaces(panels);
|
||
|
||
var logicalHoleCount = HoleGrouping.ComputeLogicalHoleCount(allHoles);
|
||
|
||
// 3) v2.3:根据孔的几何特征给出“更可能的螺丝直径”候选,避免硬匹配 4/5/6
|
||
foreach (var hole in allHoles)
|
||
{
|
||
hole.SuggestedFasteners = FastenerGuesser.SuggestForHole(hole, topN: 2);
|
||
}
|
||
|
||
ed.WriteMessage($" ✓ (几何孔 {allHoles.Count} 个, 逻辑孔 {logicalHoleCount} 个, 分组 {holeGroups.Count} 组)\n");
|
||
|
||
// 4. 检测倒角和圆角(v2.1 新增)
|
||
ed.WriteMessage(" [4/7] 检测边缘特征...");
|
||
var allEdgeFeatures = new List<EdgeFeatureInfo>();
|
||
var chamferDetector = new ChamferDetector(db);
|
||
for (int i = 0; i < panelIds.Length && i < panels.Count; i++)
|
||
{
|
||
var features = chamferDetector.DetectEdgeFeatures(panelIds[i], panels[i].Id, originOffset);
|
||
allEdgeFeatures.AddRange(features);
|
||
}
|
||
var filletCount = allEdgeFeatures.Count(f => f.Type == "fillet");
|
||
var chamferCount = allEdgeFeatures.Count(f => f.Type == "chamfer");
|
||
ed.WriteMessage($" ✓ ({filletCount} 个圆角, {chamferCount} 个倒角)\n");
|
||
|
||
// 5. 分析五金件并关联板材和孔位(v2.0 增强)
|
||
ed.WriteMessage(" [5/7] 分析五金件关联...");
|
||
var hardwareAnalyzer = new HardwareAnalyzer(db);
|
||
var exportData = hardwareAnalyzer.AnalyzeWithAssociation(hardwareIds, panels, allHoles, originOffset);
|
||
exportData.ModelFile = "model.obj";
|
||
exportData.ExportedAt = DateTime.UtcNow.ToString("o");
|
||
exportData.SourceFile = Path.GetFileName(doc.Name);
|
||
exportData.Software = "AutoCAD 2024";
|
||
exportData.PluginVersion = PluginVersion;
|
||
exportData.PanelCount = panelIds.Length;
|
||
exportData.ExportMode = "product_bundle";
|
||
exportData.SourceEntityCount = allIds.Length;
|
||
|
||
// 添加单位和原点信息
|
||
exportData.Units = unitName;
|
||
exportData.OriginOffset = new double[] { originOffset.X, originOffset.Y, originOffset.Z };
|
||
exportData.ModelBounds = BuildModelBounds(minPt, maxPt, originOffset);
|
||
|
||
// 添加板材和孔位信息(v2.0 新增)
|
||
exportData.Panels = panels;
|
||
exportData.Holes = allHoles;
|
||
exportData.HoleGroups = holeGroups;
|
||
exportData.MountPatterns = mountPatterns;
|
||
exportData.SnapPatterns = mountPatterns;
|
||
exportData.SnapSurfaces = snapSurfaces;
|
||
exportData.LogicalHoleCount = logicalHoleCount;
|
||
|
||
if (panelAnalyzer.ExcludedHoles.Count > 0)
|
||
{
|
||
exportData.ExcludedHoles = panelAnalyzer.ExcludedHoles;
|
||
}
|
||
|
||
// 添加边缘特征信息(v2.1 新增)
|
||
exportData.EdgeFeatures = allEdgeFeatures;
|
||
|
||
var typeCount = exportData.HardwareTypes.Count;
|
||
var totalCount = exportData.HardwareTypes.Sum(t => t.Instances.Count);
|
||
var associatedCount = exportData.HardwareTypes
|
||
.SelectMany(t => t.Instances)
|
||
.Count(i => i.NearestHoleId != null || (i.NearbyPanelIds?.Length ?? 0) > 0);
|
||
ed.WriteMessage($" ✓ ({typeCount} 种类型, {totalCount} 个位置, {associatedCount} 个已关联)\n");
|
||
|
||
// 6. 分析板件装配语义(v2.4 新增)
|
||
ed.WriteMessage(" [6/7] 分析装配语义...");
|
||
var assemblyAnalyzer = new AssemblySemanticAnalyzer();
|
||
exportData.Assembly = assemblyAnalyzer.Analyze(
|
||
panels,
|
||
allHoles,
|
||
holeGroups,
|
||
exportData.HardwareTypes);
|
||
var assemblyPanelCount = exportData.Assembly.PanelAssemblies.Count;
|
||
var highConfidenceAssemblyCount = exportData.Assembly.PanelAssemblies.Count(item =>
|
||
item.Confidence >= 0.75 && !string.Equals(item.MountMode, "unknown", StringComparison.OrdinalIgnoreCase));
|
||
ed.WriteMessage($" ✓ ({assemblyPanelCount} 个板件语义, {highConfidenceAssemblyCount} 个高置信)\n");
|
||
|
||
// 7. 保存 JSON
|
||
ed.WriteMessage(" [7/7] 打包文件...");
|
||
var jsonPath = Path.Combine(tempDir, "hardware.json");
|
||
var jsonSettings = new JsonSerializerSettings
|
||
{
|
||
Formatting = Formatting.Indented,
|
||
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(),
|
||
NullValueHandling = NullValueHandling.Ignore
|
||
};
|
||
var json = JsonConvert.SerializeObject(exportData, jsonSettings);
|
||
File.WriteAllText(jsonPath, json);
|
||
|
||
// 打包 ZIP
|
||
if (File.Exists(exportPath))
|
||
File.Delete(exportPath);
|
||
ZipFile.CreateFromDirectory(tempDir, exportPath);
|
||
|
||
var zipSize = new FileInfo(exportPath).Length / 1024.0;
|
||
ed.WriteMessage($" ✓\n");
|
||
|
||
// v2.2: 统计板件类型
|
||
var panelTypeCounts = panels
|
||
.Where(p => !string.IsNullOrEmpty(p.PanelType))
|
||
.GroupBy(p => p.PanelType)
|
||
.ToDictionary(g => g.Key!, g => g.Count());
|
||
|
||
// 完成
|
||
ed.WriteMessage("\n");
|
||
ed.WriteMessage("╔════════════════════════════════════════════╗\n");
|
||
ed.WriteMessage($"║ 导出完成 (v{PluginVersion}) ║\n");
|
||
ed.WriteMessage("╚════════════════════════════════════════════╝\n");
|
||
ed.WriteMessage($" 文件: {exportPath}\n");
|
||
ed.WriteMessage($" 大小: {zipSize:F1} KB\n");
|
||
ed.WriteMessage("\n");
|
||
ed.WriteMessage(" [板材统计]\n");
|
||
ed.WriteMessage($" 总数: {panelIds.Length} 个\n");
|
||
foreach (var kvp in panelTypeCounts.OrderByDescending(k => k.Value))
|
||
{
|
||
var typeName = GetPanelTypeName(kvp.Key);
|
||
ed.WriteMessage($" · {typeName}: {kvp.Value} 个\n");
|
||
}
|
||
ed.WriteMessage($" 孔位: {allHoles.Count} 个\n");
|
||
ed.WriteMessage($" 边缘特征: {allEdgeFeatures.Count} 个 ({filletCount} 圆角, {chamferCount} 倒角)\n");
|
||
ed.WriteMessage("\n");
|
||
ed.WriteMessage(" [五金件统计]\n");
|
||
ed.WriteMessage($" 总数: {totalCount} 个 ({typeCount} 种几何类型)\n");
|
||
|
||
// v2.2: 按图层显示五金件统计
|
||
foreach (var summary in exportData.HardwareSummary.Take(10))
|
||
{
|
||
var hwTypeName = GetHardwareTypeName(summary.HardwareType);
|
||
ed.WriteMessage($" · {summary.LayerName}: {summary.Count} 个 ({hwTypeName})\n");
|
||
}
|
||
if (exportData.HardwareSummary.Count > 10)
|
||
{
|
||
ed.WriteMessage($" ... 还有 {exportData.HardwareSummary.Count - 10} 种\n");
|
||
}
|
||
|
||
ed.WriteMessage($" 已关联: {associatedCount} 个\n");
|
||
if (likelyMergedSingleSolid)
|
||
{
|
||
ed.WriteMessage(" ⚠️ 检测到单体并体模型:Web 端无法分件爆炸/分件材质,请优先使用分件实体导出。\n");
|
||
}
|
||
ed.WriteMessage("\n");
|
||
ed.WriteMessage(" 请将此 ZIP 文件上传到系统。\n");
|
||
}
|
||
finally
|
||
{
|
||
// 清理临时目录
|
||
if (Directory.Exists(tempDir))
|
||
Directory.Delete(tempDir, true);
|
||
}
|
||
|
||
// 导出完成,清理会话
|
||
ExportSessionManager.Clear(docKey);
|
||
}
|
||
catch (System.Exception ex)
|
||
{
|
||
ed.WriteMessage($"\n导出失败: {ex.Message}\n");
|
||
ed.WriteMessage($"详细信息: {ex.StackTrace}\n");
|
||
}
|
||
}
|
||
|
||
[CommandMethod("FEXPORTBUNDLE")]
|
||
public void ExportFurnitureBundle()
|
||
{
|
||
ExportFurniture();
|
||
}
|
||
|
||
[CommandMethod("FEXPORT")]
|
||
public void ExportAssetDefault()
|
||
{
|
||
ExportSingleAssetCore();
|
||
}
|
||
|
||
[CommandMethod("FEXPORTASSET")]
|
||
public void ExportSingleAsset()
|
||
{
|
||
ExportSingleAssetCore();
|
||
}
|
||
|
||
private void ExportSingleAssetCore()
|
||
{
|
||
var doc = AcApp.DocumentManager.MdiActiveDocument;
|
||
if (doc == null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var db = doc.Database;
|
||
var ed = doc.Editor;
|
||
|
||
try
|
||
{
|
||
var docName = string.IsNullOrWhiteSpace(doc.Name) ? "Untitled" : doc.Name;
|
||
|
||
ed.WriteMessage("\n");
|
||
ed.WriteMessage("╔════════════════════════════════════════════╗\n");
|
||
ed.WriteMessage("║ 资产中心单资产导出向导 ║\n");
|
||
ed.WriteMessage("╚════════════════════════════════════════════╝\n");
|
||
|
||
if (!EnsureMillimeterDrawingOrConfirm(db, ed, out var unitName))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var mode = PromptSingleAssetExportMode(ed);
|
||
if (mode == null)
|
||
{
|
||
ed.WriteMessage("\n已取消导出。\n");
|
||
return;
|
||
}
|
||
|
||
ed.WriteMessage($"\n[单资产模式] {mode.DisplayName}\n");
|
||
ed.WriteMessage("─────────────────────────────\n");
|
||
if (mode.AssetType == "HARDWARE")
|
||
{
|
||
ed.WriteMessage(" 当前模式会优先保留五金规格和安装基准,不再按整套家具归属导出。\n");
|
||
}
|
||
else
|
||
{
|
||
ed.WriteMessage(" 当前模式会保留孔位 / 孔组语义,供后续自由装配使用。\n");
|
||
}
|
||
|
||
var selectionOptions = new PromptSelectionOptions
|
||
{
|
||
MessageForAdding = mode.SelectionPrompt
|
||
};
|
||
|
||
var selectionResult = ed.GetSelection(selectionOptions);
|
||
if (selectionResult.Status != PromptStatus.OK)
|
||
{
|
||
ed.WriteMessage("\n已取消导出。\n");
|
||
return;
|
||
}
|
||
|
||
var selectedIds = FilterSolidsAndBlocks(selectionResult.Value.GetObjectIds(), db);
|
||
if (selectedIds.Length == 0)
|
||
{
|
||
ed.WriteMessage("\n错误: 未选中可导出的 3D Solid 或图块引用。\n");
|
||
return;
|
||
}
|
||
|
||
var solidIds = FilterSolids(selectedIds, db);
|
||
if (mode.AnalyzePanels && solidIds.Length == 0)
|
||
{
|
||
ed.WriteMessage(" ⚠️ 当前选择未检测到可直接识别的 3D Solid。\n");
|
||
ed.WriteMessage(" 本次只能导出整体模型,无法提取孔位 / 孔组语义。\n");
|
||
}
|
||
else if (mode.AnalyzePanels && solidIds.Length < selectedIds.Length)
|
||
{
|
||
ed.WriteMessage(" ⚠️ 当前选择包含图块引用或非 Solid 实体。\n");
|
||
ed.WriteMessage(" 这部分会进入模型导出,但其内部孔位不会被识别。\n");
|
||
}
|
||
|
||
ed.WriteMessage("\n");
|
||
var saveDialog = new Microsoft.Win32.SaveFileDialog
|
||
{
|
||
Title = "保存单资产导出文件",
|
||
Filter = "ZIP 压缩包 (*.zip)|*.zip",
|
||
DefaultExt = ".zip",
|
||
FileName = Path.GetFileNameWithoutExtension(docName) + "_" + mode.FileSuffix
|
||
};
|
||
|
||
if (saveDialog.ShowDialog() != true)
|
||
{
|
||
ed.WriteMessage("已取消导出。\n");
|
||
return;
|
||
}
|
||
|
||
var exportPath = saveDialog.FileName;
|
||
var tempDir = Path.Combine(Path.GetTempPath(), $"furniture_asset_export_{Guid.NewGuid():N}");
|
||
Directory.CreateDirectory(tempDir);
|
||
|
||
try
|
||
{
|
||
ed.WriteMessage("正在处理...\n");
|
||
ed.WriteMessage("─────────────────────────────\n");
|
||
|
||
var (center, minPt, maxPt, size) = CalculateBounds(selectedIds, db);
|
||
var originOffset = new Point3d(-center.X, -center.Y, -minPt.Z);
|
||
|
||
ed.WriteMessage($" 模型边界: {size.X:F1} × {size.Y:F1} × {size.Z:F1} mm\n");
|
||
ed.WriteMessage($" 原点偏移: ({originOffset.X:F1}, {originOffset.Y:F1}, {originOffset.Z:F1})\n");
|
||
ed.WriteMessage("\n");
|
||
|
||
ed.WriteMessage(" [1/5] 导出资产模型...");
|
||
var objPath = Path.Combine(tempDir, "model.obj");
|
||
var (meshDensity, maxSubdivisions) = RecommendMeshSettings(size, Math.Max(selectedIds.Length, 1));
|
||
ObjExporter.ExportEntities(
|
||
selectedIds,
|
||
objPath,
|
||
db,
|
||
originOffset,
|
||
meshDensity: meshDensity,
|
||
maxSubdivisions: maxSubdivisions);
|
||
var objSize = new FileInfo(objPath).Length / 1024.0;
|
||
ed.WriteMessage($" ✓ ({objSize:F1} KB)\n");
|
||
|
||
var exportData = new ExportData
|
||
{
|
||
ExportedAt = DateTime.UtcNow.ToString("o"),
|
||
SourceFile = Path.GetFileName(doc.Name),
|
||
Software = "AutoCAD 2024",
|
||
PluginVersion = PluginVersion,
|
||
ModelFile = "model.obj",
|
||
PanelCount = 0,
|
||
Units = unitName,
|
||
OriginOffset = new[] { originOffset.X, originOffset.Y, originOffset.Z },
|
||
ModelBounds = BuildModelBounds(minPt, maxPt, originOffset),
|
||
ExportMode = "single_asset",
|
||
AssetTypeHint = mode.AssetType,
|
||
SourceEntityCount = selectedIds.Length,
|
||
};
|
||
|
||
PanelExportAnalysis panelAnalysis = new();
|
||
if (mode.AnalyzePanels && solidIds.Length > 0)
|
||
{
|
||
ed.WriteMessage(" [2/5] 提取板件与孔位语义...");
|
||
panelAnalysis = AnalyzePanelExportData(db, solidIds, originOffset, minPt, maxPt);
|
||
exportData.Panels = panelAnalysis.Panels;
|
||
exportData.Holes = panelAnalysis.Holes;
|
||
exportData.HoleGroups = panelAnalysis.HoleGroups;
|
||
exportData.MountPatterns = panelAnalysis.MountPatterns;
|
||
exportData.SnapPatterns = panelAnalysis.SnapPatterns;
|
||
exportData.SnapSurfaces = panelAnalysis.SnapSurfaces;
|
||
exportData.LogicalHoleCount = panelAnalysis.LogicalHoleCount;
|
||
exportData.EdgeFeatures = panelAnalysis.EdgeFeatures;
|
||
exportData.PanelCount = panelAnalysis.Panels.Count;
|
||
if (panelAnalysis.ExcludedHoles.Count > 0)
|
||
{
|
||
exportData.ExcludedHoles = panelAnalysis.ExcludedHoles;
|
||
}
|
||
|
||
ed.WriteMessage($" ✓ ({panelAnalysis.Panels.Count} 个板件, 几何孔 {panelAnalysis.Holes.Count} 个, 逻辑孔 {panelAnalysis.LogicalHoleCount} 个)\n");
|
||
}
|
||
else
|
||
{
|
||
ed.WriteMessage(" [2/5] 提取板件与孔位语义... ✓ (当前模式无需提取)\n");
|
||
}
|
||
|
||
if (mode.AnalyzeHardware)
|
||
{
|
||
ed.WriteMessage(" [3/5] 提取五金规格语义...");
|
||
var hardwareAnalyzer = new HardwareAnalyzer(db);
|
||
var hardwareExport = hardwareAnalyzer.AnalyzeWithAssociation(
|
||
selectedIds,
|
||
panelAnalysis.Panels,
|
||
panelAnalysis.Holes,
|
||
originOffset);
|
||
exportData.HardwareTypes = hardwareExport.HardwareTypes;
|
||
exportData.Layers = hardwareExport.Layers;
|
||
exportData.HardwareSummary = hardwareExport.HardwareSummary;
|
||
|
||
var hardwareTypeCount = exportData.HardwareTypes.Count;
|
||
var hardwareInstanceCount = exportData.HardwareTypes.Sum(item => item.Instances.Count);
|
||
ed.WriteMessage($" ✓ ({hardwareTypeCount} 种类型, {hardwareInstanceCount} 个实例)\n");
|
||
}
|
||
else
|
||
{
|
||
ed.WriteMessage(" [3/5] 提取五金规格语义... ✓ (当前模式无需提取)\n");
|
||
}
|
||
|
||
if (mode.AnalyzePanels && panelAnalysis.Panels.Count > 0)
|
||
{
|
||
ed.WriteMessage(" [4/5] 跳过旧装配语义... ✓ (single_asset 使用 snapPatterns/snapSurfaces)\n");
|
||
}
|
||
else
|
||
{
|
||
ed.WriteMessage(" [4/5] 跳过旧装配语义... ✓ (当前模式无需生成)\n");
|
||
}
|
||
|
||
ed.WriteMessage(" [5/5] 打包文件...");
|
||
var jsonPath = Path.Combine(tempDir, "hardware.json");
|
||
var jsonSettings = new JsonSerializerSettings
|
||
{
|
||
Formatting = Formatting.Indented,
|
||
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(),
|
||
NullValueHandling = NullValueHandling.Ignore
|
||
};
|
||
var json = JsonConvert.SerializeObject(exportData, jsonSettings);
|
||
File.WriteAllText(jsonPath, json);
|
||
|
||
if (File.Exists(exportPath))
|
||
{
|
||
File.Delete(exportPath);
|
||
}
|
||
ZipFile.CreateFromDirectory(tempDir, exportPath);
|
||
var zipSize = new FileInfo(exportPath).Length / 1024.0;
|
||
ed.WriteMessage(" ✓\n");
|
||
|
||
ed.WriteMessage("\n");
|
||
ed.WriteMessage("╔════════════════════════════════════════════╗\n");
|
||
ed.WriteMessage($"║ 单资产导出完成 ({mode.DisplayName}) ║\n");
|
||
ed.WriteMessage("╚════════════════════════════════════════════╝\n");
|
||
ed.WriteMessage($" 文件: {exportPath}\n");
|
||
ed.WriteMessage($" 大小: {zipSize:F1} KB\n");
|
||
ed.WriteMessage($" 类型: {mode.AssetType}\n");
|
||
ed.WriteMessage($" 源实体: {selectedIds.Length} 个\n");
|
||
if (mode.AnalyzePanels)
|
||
{
|
||
ed.WriteMessage($" 板件: {exportData.Panels.Count} 个\n");
|
||
ed.WriteMessage($" 孔位: {exportData.Holes.Count} 个\n");
|
||
ed.WriteMessage($" 孔组: {exportData.HoleGroups.Count} 组\n");
|
||
}
|
||
if (mode.AnalyzeHardware)
|
||
{
|
||
ed.WriteMessage($" 五金类型: {exportData.HardwareTypes.Count} 种\n");
|
||
ed.WriteMessage($" 五金实例: {exportData.HardwareTypes.Sum(item => item.Instances.Count)} 个\n");
|
||
}
|
||
ed.WriteMessage("\n");
|
||
ed.WriteMessage(" 该 ZIP 可直接用于资产中心 CAD 导入。\n");
|
||
}
|
||
finally
|
||
{
|
||
if (Directory.Exists(tempDir))
|
||
{
|
||
Directory.Delete(tempDir, true);
|
||
}
|
||
}
|
||
}
|
||
catch (System.Exception ex)
|
||
{
|
||
ed.WriteMessage($"\n单资产导出失败: {ex.Message}\n");
|
||
ed.WriteMessage($"详细信息: {ex.StackTrace}\n");
|
||
}
|
||
}
|
||
|
||
[CommandMethod("FEXPORTRESET")]
|
||
public void ResetExportSession()
|
||
{
|
||
var doc = AcApp.DocumentManager.MdiActiveDocument;
|
||
if (doc == null)
|
||
{
|
||
return;
|
||
}
|
||
var ed = doc.Editor;
|
||
var docKey = string.IsNullOrWhiteSpace(doc.Name) ? "Untitled" : doc.Name;
|
||
ExportSessionManager.Clear(docKey);
|
||
ed.WriteMessage("\n已清除整套兼容导出暂存会话。请重新运行 FEXPORTPRODUCT。\n");
|
||
}
|
||
|
||
private static List<string> GetEntityHandles(Database db, ObjectId[] ids)
|
||
{
|
||
var handles = new List<string>();
|
||
using var tr = db.TransactionManager.StartTransaction();
|
||
foreach (var id in ids)
|
||
{
|
||
if (id.IsNull) continue;
|
||
var ent = tr.GetObject(id, OpenMode.ForRead) as Entity;
|
||
if (ent == null) continue;
|
||
handles.Add(ent.Handle.ToString());
|
||
}
|
||
tr.Commit();
|
||
return handles;
|
||
}
|
||
|
||
private static ObjectId[] ResolveObjectIdsFromHandles(Database db, IReadOnlyList<string> handles)
|
||
{
|
||
var ids = new List<ObjectId>();
|
||
foreach (var h in handles)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(h)) continue;
|
||
try
|
||
{
|
||
var handle = new Handle(Convert.ToInt64(h, 16));
|
||
var id = db.GetObjectId(false, handle, 0);
|
||
if (!id.IsNull) ids.Add(id);
|
||
}
|
||
catch
|
||
{
|
||
// ignore single handle failure
|
||
}
|
||
}
|
||
return ids.ToArray();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 查看实体信息命令
|
||
/// </summary>
|
||
[CommandMethod("FINFO")]
|
||
public void ShowEntityInfo()
|
||
{
|
||
var doc = AcApp.DocumentManager.MdiActiveDocument;
|
||
var db = doc.Database;
|
||
var ed = doc.Editor;
|
||
|
||
var options = new PromptSelectionOptions
|
||
{
|
||
MessageForAdding = "选择要查看信息的实体: ",
|
||
SingleOnly = true
|
||
};
|
||
|
||
var result = ed.GetSelection(options);
|
||
if (result.Status != PromptStatus.OK) return;
|
||
|
||
using var tr = db.TransactionManager.StartTransaction();
|
||
|
||
foreach (var id in result.Value.GetObjectIds())
|
||
{
|
||
var ent = tr.GetObject(id, OpenMode.ForRead) as Entity;
|
||
if (ent == null) continue;
|
||
|
||
ed.WriteMessage("\n");
|
||
ed.WriteMessage("╔════════════════════════════════════════════╗\n");
|
||
ed.WriteMessage("║ 实体信息 ║\n");
|
||
ed.WriteMessage("╚════════════════════════════════════════════╝\n");
|
||
ed.WriteMessage($" 类型: {ent.GetType().Name}\n");
|
||
ed.WriteMessage($" 图层: {ent.Layer}\n");
|
||
ed.WriteMessage($" 句柄: {ent.Handle}\n");
|
||
|
||
if (ent is Solid3d solid)
|
||
{
|
||
var ext = solid.GeometricExtents;
|
||
var size = ext.MaxPoint - ext.MinPoint;
|
||
ed.WriteMessage($" 尺寸: {size.X:F2} × {size.Y:F2} × {size.Z:F2} mm\n");
|
||
|
||
try
|
||
{
|
||
var mass = solid.MassProperties;
|
||
ed.WriteMessage($" 体积: {mass.Volume:F2} mm³\n");
|
||
}
|
||
catch { }
|
||
}
|
||
else if (ent is BlockReference blkRef)
|
||
{
|
||
ed.WriteMessage($" 图块名: {blkRef.Name}\n");
|
||
ed.WriteMessage($" 位置: ({blkRef.Position.X:F2}, {blkRef.Position.Y:F2}, {blkRef.Position.Z:F2})\n");
|
||
ed.WriteMessage($" 缩放: ({blkRef.ScaleFactors.X:F2}, {blkRef.ScaleFactors.Y:F2}, {blkRef.ScaleFactors.Z:F2})\n");
|
||
}
|
||
}
|
||
|
||
tr.Commit();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 过滤出 3D Solid 实体
|
||
/// </summary>
|
||
private ObjectId[] FilterSolids(ObjectId[] ids, Database db)
|
||
{
|
||
var solids = new List<ObjectId>();
|
||
using var tr = db.TransactionManager.StartTransaction();
|
||
|
||
foreach (var id in ids)
|
||
{
|
||
var ent = tr.GetObject(id, OpenMode.ForRead);
|
||
if (ent is Solid3d)
|
||
{
|
||
solids.Add(id);
|
||
}
|
||
}
|
||
|
||
tr.Commit();
|
||
return solids.ToArray();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 过滤出 3D Solid 和 BlockReference 实体
|
||
/// </summary>
|
||
private ObjectId[] FilterSolidsAndBlocks(ObjectId[] ids, Database db)
|
||
{
|
||
var entities = new List<ObjectId>();
|
||
using var tr = db.TransactionManager.StartTransaction();
|
||
|
||
foreach (var id in ids)
|
||
{
|
||
var ent = tr.GetObject(id, OpenMode.ForRead);
|
||
if (ent is Solid3d || ent is BlockReference)
|
||
{
|
||
entities.Add(id);
|
||
}
|
||
}
|
||
|
||
tr.Commit();
|
||
return entities.ToArray();
|
||
}
|
||
|
||
private sealed class AutoSplitResult
|
||
{
|
||
public ObjectId[] PanelIds { get; set; } = Array.Empty<ObjectId>();
|
||
public ObjectId[] HardwareIds { get; set; } = Array.Empty<ObjectId>();
|
||
public bool IsLikelyMergedSingleSolid { get; set; }
|
||
public string? FallbackMessage { get; set; }
|
||
}
|
||
|
||
private static readonly string[] PanelLayerKeywords =
|
||
{
|
||
"panel", "board", "carcass", "shelf", "divider", "side", "top", "bottom", "back",
|
||
"板", "板件", "柜体", "层板", "隔板", "侧板", "顶板", "底板", "背板"
|
||
};
|
||
|
||
private static readonly string[] HardwareLayerKeywords =
|
||
{
|
||
"hardware", "hinge", "screw", "connector", "slide", "handle", "leg", "bracket",
|
||
"五金", "铰链", "螺丝", "连接", "滑轨", "拉手", "柜脚", "角码", "合页"
|
||
};
|
||
|
||
private static (double MeshDensity, int MaxSubdivisions) RecommendMeshSettings(Vector3d size, int panelCount)
|
||
{
|
||
var longestEdge = Math.Max(size.X, Math.Max(size.Y, size.Z));
|
||
|
||
double meshDensity;
|
||
if (longestEdge >= 2400)
|
||
{
|
||
meshDensity = 28;
|
||
}
|
||
else if (longestEdge >= 1200)
|
||
{
|
||
meshDensity = 34;
|
||
}
|
||
else if (longestEdge >= 600)
|
||
{
|
||
meshDensity = 40;
|
||
}
|
||
else
|
||
{
|
||
meshDensity = 48;
|
||
}
|
||
|
||
if (panelCount <= 4)
|
||
{
|
||
meshDensity += 4;
|
||
}
|
||
else if (panelCount >= 20)
|
||
{
|
||
meshDensity -= 4;
|
||
}
|
||
|
||
meshDensity = Math.Max(24, Math.Min(60, meshDensity));
|
||
|
||
int maxSubdivisions;
|
||
if (panelCount <= 4)
|
||
{
|
||
maxSubdivisions = 12000;
|
||
}
|
||
else if (panelCount <= 12)
|
||
{
|
||
maxSubdivisions = 18000;
|
||
}
|
||
else
|
||
{
|
||
maxSubdivisions = 24000;
|
||
}
|
||
|
||
return (meshDensity, maxSubdivisions);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 自动模式:一次选择后,尽量自动分组板件与五金件,减少人工操作。
|
||
/// </summary>
|
||
private AutoSplitResult AutoSplitPanelsAndHardware(ObjectId[] ids, Database db)
|
||
{
|
||
var panelSet = new HashSet<ObjectId>();
|
||
var hardwareSet = new HashSet<ObjectId>();
|
||
var allSolidIds = new List<ObjectId>();
|
||
string? fallbackMessage = null;
|
||
|
||
double singleLength = 0;
|
||
double singleWidth = 0;
|
||
double singleThickness = 0;
|
||
|
||
using var tr = db.TransactionManager.StartTransaction();
|
||
|
||
foreach (var id in ids)
|
||
{
|
||
var ent = tr.GetObject(id, OpenMode.ForRead) as Entity;
|
||
if (ent == null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (ent is BlockReference)
|
||
{
|
||
hardwareSet.Add(id);
|
||
continue;
|
||
}
|
||
|
||
if (ent is not Solid3d solid)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
allSolidIds.Add(id);
|
||
|
||
if (!TryGetSortedDimensions(solid, out var length, out var width, out var thickness))
|
||
{
|
||
panelSet.Add(id);
|
||
continue;
|
||
}
|
||
|
||
if (allSolidIds.Count == 1)
|
||
{
|
||
singleLength = length;
|
||
singleWidth = width;
|
||
singleThickness = thickness;
|
||
}
|
||
|
||
var layerName = (ent.Layer ?? string.Empty).ToLowerInvariant();
|
||
var panelByLayer = HasAnyKeyword(layerName, PanelLayerKeywords);
|
||
var hardwareByLayer = HasAnyKeyword(layerName, HardwareLayerKeywords);
|
||
var panelByShape = IsLikelyPanelSolid(length, width, thickness);
|
||
|
||
if (hardwareByLayer && !panelByLayer)
|
||
{
|
||
hardwareSet.Add(id);
|
||
continue;
|
||
}
|
||
|
||
if (panelByLayer && !hardwareByLayer)
|
||
{
|
||
panelSet.Add(id);
|
||
continue;
|
||
}
|
||
|
||
if (panelByShape)
|
||
{
|
||
panelSet.Add(id);
|
||
}
|
||
else
|
||
{
|
||
hardwareSet.Add(id);
|
||
}
|
||
}
|
||
|
||
tr.Commit();
|
||
|
||
if (panelSet.Count == 0 && allSolidIds.Count > 0)
|
||
{
|
||
foreach (var id in allSolidIds)
|
||
{
|
||
panelSet.Add(id);
|
||
}
|
||
fallbackMessage = "未识别到典型板件形态,已自动按“全部 3D 实体=板件”继续导出。";
|
||
}
|
||
|
||
// 同一个实体不能同时属于板件和五金件
|
||
hardwareSet.RemoveWhere(panelSet.Contains);
|
||
|
||
var likelyMergedSingleSolid = false;
|
||
if (allSolidIds.Count == 1 && panelSet.Count == 1)
|
||
{
|
||
var thicknessTooLarge = singleThickness > 80;
|
||
var ratioA = singleLength / Math.Max(singleThickness, 1);
|
||
var ratioB = singleWidth / Math.Max(singleThickness, 1);
|
||
var ratioTooSmall = ratioA < 2.5 || ratioB < 1.6;
|
||
likelyMergedSingleSolid = thicknessTooLarge || ratioTooSmall;
|
||
}
|
||
|
||
return new AutoSplitResult
|
||
{
|
||
PanelIds = panelSet.ToArray(),
|
||
HardwareIds = hardwareSet.ToArray(),
|
||
IsLikelyMergedSingleSolid = likelyMergedSingleSolid,
|
||
FallbackMessage = fallbackMessage,
|
||
};
|
||
}
|
||
|
||
private static bool HasAnyKeyword(string text, IEnumerable<string> keywords)
|
||
{
|
||
foreach (var keyword in keywords)
|
||
{
|
||
if (text.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private static bool IsLikelyPanelSolid(double length, double width, double thickness)
|
||
{
|
||
if (thickness <= 0)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (thickness < 4 || thickness > 80)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
if (length < 80 || width < 60)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var ratioLength = length / thickness;
|
||
var ratioWidth = width / thickness;
|
||
return ratioLength >= 3.0 && ratioWidth >= 2.0;
|
||
}
|
||
|
||
private static bool TryGetSortedDimensions(Solid3d solid, out double length, out double width, out double thickness)
|
||
{
|
||
try
|
||
{
|
||
var ext = solid.GeometricExtents;
|
||
var size = ext.MaxPoint - ext.MinPoint;
|
||
var dims = new[]
|
||
{
|
||
Math.Abs(size.X),
|
||
Math.Abs(size.Y),
|
||
Math.Abs(size.Z),
|
||
}.OrderByDescending(d => d).ToArray();
|
||
|
||
length = dims[0];
|
||
width = dims[1];
|
||
thickness = dims[2];
|
||
return true;
|
||
}
|
||
catch
|
||
{
|
||
length = 0;
|
||
width = 0;
|
||
thickness = 0;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取板件类型的中文名称
|
||
/// </summary>
|
||
private static string GetPanelTypeName(string panelType)
|
||
{
|
||
return panelType switch
|
||
{
|
||
"side_panel" => "侧板",
|
||
"top_panel" => "顶板",
|
||
"bottom_panel" => "底板",
|
||
"back_panel" => "背板",
|
||
"shelf" => "层板",
|
||
"vertical_divider" => "竖隔板",
|
||
"horizontal_divider" => "横隔板",
|
||
"panel" => "板件",
|
||
_ => panelType
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取五金件类型的中文名称
|
||
/// </summary>
|
||
private static string GetHardwareTypeName(string hardwareType)
|
||
{
|
||
return hardwareType switch
|
||
{
|
||
"hinge" => "铰链",
|
||
"screw" => "螺丝",
|
||
"connector" => "连接件",
|
||
"shelf_pin" => "层板托",
|
||
"drawer_slide" => "滑轨",
|
||
"push_latch" => "反弹器",
|
||
"magnetic_catch" => "门吸",
|
||
"handle" => "拉手",
|
||
"vent" => "透气孔",
|
||
"cable_hole" => "过线孔",
|
||
"unknown" => "未知",
|
||
_ => hardwareType
|
||
};
|
||
}
|
||
}
|