Files
2026-07-17 17:45:56 +08:00

3468 lines
138 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using SolidWorks.Interop.sldworks;
using SolidWorks.Interop.swconst;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true);
builder.Configuration.AddJsonFile(
Path.Combine(builder.Environment.ContentRootPath, "backend-csharp", "Agent4.Api", "appsettings.Local.json"),
optional: true,
reloadOnChange: true);
builder.WebHost.UseUrls(builder.Configuration["urls"] ?? "http://127.0.0.1:7200");
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
options.SerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy
.WithOrigins(
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:5174",
"http://127.0.0.1:5174",
"http://localhost:5175",
"http://127.0.0.1:5175")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
builder.Services.AddSingleton<AppPaths>();
builder.Services.AddSingleton<KnowledgeRepository>();
builder.Services.AddSingleton<SessionStore>();
builder.Services.AddSingleton<LessonBuilder>();
builder.Services.AddSingleton<TeachingLanguageDesigner>();
builder.Services.AddSingleton<TeachingIntroductionBuilder>();
builder.Services.AddSingleton<FeatureGrader>();
builder.Services.AddSingleton<SolidWorksExtractor>();
builder.Services.AddSingleton<SolidWorksConnectionService>();
builder.Services.AddMechanicalDiagnostics();
builder.Services.AddDwgDraft();
builder.Services.AddDrawingAssemblyDiagnostics();
var app = builder.Build();
app.UseCors();
app.MapMechanicalDiagnosticApi();
app.MapDwgDraftApi();
app.MapDrawingAssemblyDiagnosticApi();
app.MapGet("/health", (KnowledgeRepository knowledge, AppPaths paths) => new
{
ok = true,
service = "Agent4 C# SolidWorks Learning API",
standards = knowledge.ListStandards().Count,
backend = "csharp",
software_paths = paths.ReadProjectPaths(),
});
app.MapGet("/api/diagnostic-flows", (AppPaths paths) => Results.Ok(DiagnosticFlowCatalog.Build(paths)));
app.MapPost("/api/knowledge/reload", (KnowledgeRepository knowledge) =>
{
knowledge.Reload();
return Results.Ok(new { ok = true, standards = knowledge.ListStandards().Count });
});
app.MapGet("/api/standards", (KnowledgeRepository knowledge, string? query, string? kind) =>
{
var items = knowledge.ListStandards(query ?? "", kind ?? "");
return Results.Ok(new { items });
});
app.MapGet("/api/standards/{standardId}", (KnowledgeRepository knowledge, string standardId) =>
{
return knowledge.TryGet(standardId, out var standard)
? Results.Ok(standard)
: Results.NotFound(new { detail = $"standard answer not found: {standardId}" });
});
app.MapGet("/api/teaching/design/{standardId}", (
KnowledgeRepository knowledge,
TeachingLanguageDesigner designer,
string standardId) =>
{
if (!knowledge.TryGet(standardId, out var standard))
return Results.NotFound(new { detail = $"standard answer not found: {standardId}" });
return Results.Ok(designer.Design(standard));
});
app.MapGet("/api/teaching/introduction/{standardId}", (
KnowledgeRepository knowledge,
TeachingIntroductionBuilder introductionBuilder,
string standardId) =>
{
if (!knowledge.TryGet(standardId, out var standard))
return Results.NotFound(new { detail = $"standard answer not found: {standardId}" });
return Results.Ok(introductionBuilder.Build(standard));
});
app.MapPost("/api/teaching/introduction/{standardId}/open-standard-part", (
KnowledgeRepository knowledge,
TeachingIntroductionBuilder introductionBuilder,
SolidWorksConnectionService solidWorks,
string standardId) =>
{
if (!knowledge.TryGet(standardId, out var standard))
return Results.NotFound(new { detail = $"standard answer not found: {standardId}" });
var introduction = introductionBuilder.Build(standard);
if (string.IsNullOrWhiteSpace(introduction.StandardPartPath) || !File.Exists(introduction.StandardPartPath))
return Results.NotFound(new { detail = $"standard part file not found: {introduction.StandardPartPath}" });
try
{
var result = solidWorks.OpenDocument(introduction.StandardPartPath);
return Results.Ok(new { ok = result.Ok, message = result.Message, part_path = introduction.StandardPartPath });
}
catch (Exception ex)
{
return Results.Problem($"打开标准零件失败:{ex.Message}", statusCode: 500);
}
});
app.MapPost("/api/teaching/introduction/{standardId}/highlight/{targetId}", (
KnowledgeRepository knowledge,
TeachingIntroductionBuilder introductionBuilder,
SolidWorksConnectionService solidWorks,
string standardId,
string targetId) =>
{
if (!knowledge.TryGet(standardId, out var standard))
return Results.NotFound(new { detail = $"standard answer not found: {standardId}" });
var introduction = introductionBuilder.Build(standard);
if (string.IsNullOrWhiteSpace(introduction.StandardPartPath) || !File.Exists(introduction.StandardPartPath))
return Results.NotFound(new { detail = $"standard part file not found: {introduction.StandardPartPath}" });
var target = introduction.HighlightTargets.FirstOrDefault(item => string.Equals(item.Id, targetId, StringComparison.OrdinalIgnoreCase));
if (target == null)
return Results.NotFound(new { detail = $"highlight target not found: {targetId}" });
try
{
return Results.Ok(solidWorks.HighlightTarget(introduction.StandardPartPath, target));
}
catch (Exception ex)
{
return Results.Problem($"标准零件高亮失败:{ex.Message}", statusCode: 500);
}
});
app.MapPost("/api/teaching/sessions", (
StartSessionRequest request,
KnowledgeRepository knowledge,
SessionStore sessions,
LessonBuilder lessonBuilder,
SolidWorksConnectionService solidWorks) =>
{
if (!knowledge.TryGet(request.StandardId, out var standard))
return Results.NotFound(new { detail = $"standard answer not found: {request.StandardId}" });
try
{
solidWorks.EnsureConnected();
return Results.Ok(sessions.Start("teaching", standard, lessonBuilder));
}
catch (Exception ex)
{
return Results.Problem($"SolidWorks 启动或连接失败:{ex.Message}", statusCode: 500);
}
});
app.MapPost("/api/testing/sessions", (
StartSessionRequest request,
KnowledgeRepository knowledge,
SessionStore sessions,
LessonBuilder lessonBuilder,
SolidWorksConnectionService solidWorks) =>
{
if (!knowledge.TryGet(request.StandardId, out var standard))
return Results.NotFound(new { detail = $"standard answer not found: {request.StandardId}" });
try
{
solidWorks.EnsureConnected();
return Results.Ok(sessions.Start("testing", standard, lessonBuilder));
}
catch (Exception ex)
{
return Results.Problem($"SolidWorks 启动或连接失败:{ex.Message}", statusCode: 500);
}
});
app.MapPost("/api/solidworks/connect", (SolidWorksConnectionService solidWorks) =>
{
try
{
return Results.Ok(solidWorks.EnsureConnected());
}
catch (Exception ex)
{
return Results.Problem($"SolidWorks 启动或连接失败:{ex.Message}", statusCode: 500);
}
});
app.MapGet("/api/solidworks/active-state", (SolidWorksConnectionService solidWorks) =>
{
return Results.Ok(solidWorks.GetActiveDocumentStatus());
});
app.MapGet("/api/sessions/{sessionId}", (SessionStore sessions, string sessionId) =>
{
return sessions.TryGet(sessionId, out var session)
? Results.Ok(session)
: Results.NotFound(new { detail = $"session not found: {sessionId}" });
});
app.MapPost("/api/teaching/sessions/{sessionId}/observations", (
string sessionId,
ObservationRequest request,
SessionStore sessions,
FeatureGrader grader) =>
{
if (!sessions.TryGet(sessionId, out var session))
return Results.NotFound(new { detail = $"session not found: {sessionId}" });
var observed = SkillFlow.NormalizeSteps(JsonSerializer.SerializeToElement(new { steps = request.Steps }));
var result = sessions.SubmitTeachingObservation(session, observed, grader);
return Results.Ok(result);
});
app.MapPost("/api/teaching/sessions/{sessionId}/audit-active-part", async (
string sessionId,
SessionStore sessions,
SolidWorksExtractor extractor,
FeatureGrader grader) =>
{
if (!sessions.TryGet(sessionId, out var session))
return Results.NotFound(new { detail = $"session not found: {sessionId}" });
try
{
var observed = await extractor.AuditActivePartAsync();
var currentIndex = session.CurrentStepIndex;
var submitted = currentIndex < observed.Count
? new List<SkillStep> { observed[currentIndex] }
: new List<SkillStep>();
var result = sessions.SubmitTeachingObservation(session, submitted, grader);
return Results.Ok(new
{
observed_steps = observed,
submitted_steps = submitted,
result,
});
}
catch (Exception ex)
{
return Results.Problem(ex.Message, statusCode: 500);
}
});
app.MapPost("/api/teaching/sessions/{sessionId}/poll-active-part", async (
string sessionId,
SessionStore sessions,
SolidWorksExtractor extractor,
FeatureGrader grader,
SolidWorksConnectionService solidWorks) =>
{
if (!sessions.TryGet(sessionId, out var session))
return Results.NotFound(new { detail = $"session not found: {sessionId}" });
try
{
var documentStatus = solidWorks.GetActiveDocumentStatus();
var documentIssue = sessions.CheckActiveDocumentForCurrentStep(session, documentStatus);
if (documentIssue != null)
{
if (documentIssue.Ok)
sessions.AcceptCurrentStepFromLiveState(session, documentIssue);
return Results.Ok(new
{
observed_step_count = 0,
submitted_steps = new List<SkillStep>(),
result = documentIssue,
session_status = session.Status,
current_step_index = session.CurrentStepIndex,
active_document = documentStatus,
});
}
var observed = await extractor.AuditActivePartAsync();
var currentIndex = session.CurrentStepIndex;
var submitted = currentIndex < observed.Count
? new List<SkillStep> { observed[currentIndex] }
: new List<SkillStep>();
var result = sessions.PollTeachingObservation(session, submitted, grader);
return Results.Ok(new
{
observed_step_count = observed.Count,
submitted_steps = submitted,
result,
session_status = session.Status,
current_step_index = session.CurrentStepIndex,
active_document = documentStatus,
});
}
catch (Exception ex)
{
return Results.Problem(ex.Message, statusCode: 500);
}
});
app.MapPost("/api/teaching/sessions/{sessionId}/audit-part", async (
string sessionId,
AuditPartRequest request,
SessionStore sessions,
SolidWorksExtractor extractor,
FeatureGrader grader) =>
{
if (!sessions.TryGet(sessionId, out var session))
return Results.NotFound(new { detail = $"session not found: {sessionId}" });
try
{
var observed = await extractor.AuditPartAsync(request.PartPath);
var submitted = observed.Count > 0 ? new List<SkillStep> { observed[^1] } : new List<SkillStep>();
var result = sessions.SubmitTeachingObservation(session, submitted, grader);
return Results.Ok(new { observed_steps = observed, result });
}
catch (Exception ex)
{
return Results.Problem(ex.Message, statusCode: 500);
}
});
app.MapPost("/api/testing/sessions/{sessionId}/submit", async (
string sessionId,
SubmitTestRequest request,
SessionStore sessions,
SolidWorksExtractor extractor,
FeatureGrader grader) =>
{
if (!sessions.TryGet(sessionId, out var session))
return Results.NotFound(new { detail = $"session not found: {sessionId}" });
try
{
var observed = string.IsNullOrWhiteSpace(request.PartPath)
? SkillFlow.NormalizeSteps(JsonSerializer.SerializeToElement(new { steps = request.Steps }))
: await extractor.AuditPartAsync(request.PartPath);
var report = sessions.SubmitTest(session, observed, grader);
return Results.Ok(report);
}
catch (Exception ex)
{
return Results.Problem(ex.Message, statusCode: 500);
}
});
app.Run();
static class DiagnosticFlowCatalog
{
public static object Build(AppPaths paths) => new
{
schema = "agent4.diagnostic_flow_catalog.v1",
boundary_doc = Doc(paths.DiagnosticFlowBoundariesDoc),
flows = new[]
{
new
{
id = "model_part",
label = "三维零件查错",
scope = "single_sldprt_part_design",
service = "mechanical-diagnostics",
frontend_target = "part",
endpoints = new[]
{
"POST /api/mechanical-diagnostics/diagnose/start",
"GET /api/mechanical-diagnostics/diagnose/status/{jobId}",
"POST /api/mechanical-diagnostics/interfaces/finalize-existing",
"POST /api/mechanical-diagnostics/diagnose/finalize-existing",
"POST /api/mechanical-diagnostics/diagnose/load-existing"
},
required_inputs = new[] { "model_path(.SLDPRT)", "part_function_profile" },
responsibilities = new[]
{
"提取单零件 B-rep、特征组和高亮图片",
"生成局部语义子图",
"检索零件设计禁忌规则",
"判断零件自身结构设计问题"
},
forbidden = new[]
{
"不得生成二维图纸公差结论",
"不得替代装配体接触/配合分析"
},
key_outputs = new[]
{
"mechanical_semantic_graph.json",
"local_subgraph_collection.json",
"final_ai_diagnostic_report.md"
},
docs = new[] { Doc(paths.ModelDiagnosticReadmeDoc), Doc(paths.MechanicalDiagnosticContractDoc), Doc(paths.MechanicalDiagnosticUsageDoc) }
},
new
{
id = "model_assembly",
label = "三维装配体查错",
scope = "sldasm_assembly_function_and_nonstandard_part_checks",
service = "mechanical-diagnostics",
frontend_target = "assembly",
endpoints = new[]
{
"POST /api/mechanical-diagnostics/diagnose/start",
"GET /api/mechanical-diagnostics/diagnose/status/{jobId}",
"POST /api/mechanical-diagnostics/interfaces/finalize-existing",
"POST /api/mechanical-diagnostics/diagnose/finalize-existing",
"POST /api/mechanical-diagnostics/diagnose/load-existing"
},
required_inputs = new[] { "model_path(.SLDASM)", "product_description", "purchased_component_hints" },
responsibilities = new[]
{
"提取装配体 B-rep、mate、有限接触面和组件图片证据",
"导出 physical_interfaces 接触高亮图片",
"VLM 结合接触高亮图、B-rep、mate 和零件功能生成接触面/接口功能描述",
"生成零件整体功能、接口功能、连接方式和功能组",
"按功能组判断装配关系问题",
"对组内非标零件继承装配上下文后执行零件自身查错"
},
forbidden = new[]
{
"不得只靠 SolidWorks mate 替代 B-rep 接触分析",
"不得把接触存在直接等价为二维公差具体代号",
"不得检查外购件/标准件内部结构,除非显式要求"
},
key_outputs = new[]
{
"diagnostic_context/component_function_introductions.json",
"diagnostic_context/interface_anchor_analysis.json",
"diagnostic_context/interface_function_descriptions.json",
"diagnostic_context/functional_group_descriptions.json",
"functional_group_evidence/manifest.json",
"mechanical_semantic_graph.json",
"final_ai_diagnostic_report.md"
},
docs = new[] { Doc(paths.ModelDiagnosticReadmeDoc), Doc(paths.MechanicalDiagnosticContractDoc), Doc(paths.MechanicalDiagnosticUsageDoc) }
},
new
{
id = "drawing_part",
label = "二维零件图查错",
scope = "single_part_dwg_format_check_currently",
service = "drawing-part-diagnostics",
frontend_target = "drawing-part",
endpoints = new[]
{
"POST /api/drawing-part-diagnostics/run-full",
"GET /api/drawing-part-diagnostics/latest",
"GET /api/drawing-part-diagnostics/image"
},
required_inputs = new[] { "student_dwg_path" },
responsibilities = new[]
{
"抽取学生零件 DWG 结构化图元",
"按程序化规则检查线型、线宽、中心线、虚线、尺寸线、引出线、剖面线等格式问题",
"生成格式问题证据图片和报告"
},
forbidden = new[]
{
"当前格式查错不走 AI",
"不得调用三维装配体接触语义",
"不得和二维装配图公差接口混用"
},
key_outputs = new[] { "format_issues.json", "final/final_report.md", "analysis_result.json" },
docs = new[] { Doc(paths.DrawingDiagnosticReadmeDoc), Doc(paths.DiagnosticFlowBoundariesDoc) }
},
new
{
id = "drawing_assembly",
label = "二维装配图查错",
scope = "assembly_dwg_annotation_representation_tolerance_binding",
service = "drawing-assembly-diagnostics",
frontend_target = "drawing-assembly",
endpoints = new[]
{
"POST /api/drawing-assembly-diagnostics/run-full",
"POST /api/drawing-assembly-diagnostics/build-interface-drawing-context",
"POST /api/drawing-assembly-diagnostics/build-tolerance-context",
"POST /api/drawing-assembly-diagnostics/assess-tolerances",
"GET /api/drawing-assembly-diagnostics/latest",
"GET /api/drawing-assembly-diagnostics/image"
},
required_inputs = new[] { "student_dwg_path", "model_path or mechanical_context_dir", "ownership_context_dir when reusing existing ownership" },
responsibilities = new[]
{
"抽取学生装配 DWG",
"复用标准视图/线条归属流水线产物",
"识别公差标注作用的二维线条/边界",
"按线条归属、视图位置和三维 B-rep 接触面绑定二维公差与三维接口",
"多视图汇总同一接口标注",
"生成多标、漏标、错标候选",
"AI 结合固定公差知识和三维接触功能语义做最终判断"
},
forbidden = new[]
{
"不得跳过二维线条归属和多视图汇总,直接用公差文本匹配三维直径",
"不得修改三维接触语义,只能作为证据使用",
"不得让三维装配体流程直接输出二维公差结论"
},
key_outputs = new[]
{
"supplement/tolerance_assessment_context.json",
"supplement/roughness_assessment_context.json",
"supplement/assembly_function_context.json",
"supplement/supplemental_facts.json",
"tolerance_assessment/tolerance_diagnostic_result.json",
"tolerance_assessment/tolerance_diagnostic_report.md",
"final/final_report.md"
},
docs = new[] { Doc(paths.DrawingDiagnosticReadmeDoc), Doc(paths.DrawingAssemblyToleranceFlowDoc), Doc(paths.DiagnosticFlowBoundariesDoc) }
}
}
};
static object Doc(string path) => new
{
path,
exists = File.Exists(path),
title = File.Exists(path)
? File.ReadLines(path).FirstOrDefault(line => line.StartsWith("#", StringComparison.Ordinal))?.TrimStart('#', ' ')
: ""
};
}
sealed class AppPaths
{
public string ApiRoot { get; } = AppContext.BaseDirectory;
public string Agent4Root { get; }
public string WorkspaceRoot { get; }
public string StandardsRoot => Path.Combine(Agent4Root, "data", "standards");
public string KnowledgeRoot => Path.Combine(Agent4Root, "data", "knowledge");
public string ReducerFrameworkPath => Path.Combine(KnowledgeRoot, "reducer_three_layer_framework.json");
public string ManifestPath => Path.Combine(StandardsRoot, "manifest.json");
public string RuntimeDir => Path.Combine(Agent4Root, "runtime");
public string PartFeatureAuditProject => Path.Combine(Agent4Root, "tools", "modeling-process", "PartFeatureAudit", "PartFeatureAudit.csproj");
public string SectionBrepExtractorProject => Path.Combine(Agent4Root, "tools", "model-diagnostics", "SectionBrepExtractor", "SectionBrepExtractor.csproj");
public string BrepFaultProbeProject => Path.Combine(Agent4Root, "tools", "model-diagnostics", "BrepFaultProbe", "BrepFaultProbe.csproj");
public string DiagnosticFlowBoundariesDoc => Path.Combine(Agent4Root, "tools", "DIAGNOSTIC_FLOW_BOUNDARIES.md");
public string DrawingDiagnosticReadmeDoc => Path.Combine(Agent4Root, "tools", "drawing-diagnostics", "README.md");
public string DrawingAssemblyToleranceFlowDoc => Path.Combine(Agent4Root, "tools", "drawing-diagnostics", "ASSEMBLY_TOLERANCE_DIAGNOSTIC_FLOW.md");
public string ModelDiagnosticReadmeDoc => Path.Combine(Agent4Root, "tools", "model-diagnostics", "README.md");
public string MechanicalDiagnosticContractDoc => Path.Combine(Agent4Root, "tools", "model-diagnostics", "MECHANICAL_DIAGNOSTIC_CONTRACT.md");
public string MechanicalDiagnosticUsageDoc => Path.Combine(Agent4Root, "tools", "model-diagnostics", "MECHANICAL_DIAGNOSTIC_USAGE.md");
public string MechanicalKnowledgeIndexRoot => Path.Combine(Agent4Root, "机械设计禁忌1000例_知识库提取", "diagnostic_index");
public string ProjectPathsFile => Path.Combine(Agent4Root, "project_paths.json");
public string ReducerModelDir => @"D:\Desktop\减速器\三维";
public string SolidWorksExe => ReadProjectPathValue("solidworks_exe");
public AppPaths()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "project_paths.json")))
dir = dir.Parent;
Agent4Root = dir?.FullName ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
WorkspaceRoot = Directory.GetParent(Agent4Root)?.FullName ?? Agent4Root;
}
public Dictionary<string, object?> ReadProjectPaths()
{
if (!File.Exists(ProjectPathsFile))
return new Dictionary<string, object?>();
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(ProjectPathsFile));
return doc.RootElement.EnumerateObject().ToDictionary(p => p.Name, p => (object?)p.Value.ToString());
}
catch
{
return new Dictionary<string, object?>();
}
}
string ReadProjectPathValue(string key)
{
if (!File.Exists(ProjectPathsFile))
return "";
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(ProjectPathsFile));
return doc.RootElement.TryGetProperty(key, out var value) ? value.ToString() : "";
}
catch
{
return "";
}
}
}
sealed class SolidWorksConnectionService
{
readonly AppPaths _paths;
readonly object _lock = new();
public SolidWorksConnectionService(AppPaths paths)
{
_paths = paths;
}
public SolidWorksConnectionResult EnsureConnected()
{
lock (_lock)
{
var alreadyRunning = TryConnectSta();
if (alreadyRunning.Ok)
return alreadyRunning;
var exe = _paths.SolidWorksExe;
if (string.IsNullOrWhiteSpace(exe))
throw new FileNotFoundException("project_paths.json 中没有配置 solidworks_exe。");
if (!File.Exists(exe))
throw new FileNotFoundException($"SolidWorks 启动路径不存在:{exe}");
Process.Start(new ProcessStartInfo
{
FileName = exe,
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(exe) ?? _paths.Agent4Root,
});
var deadline = DateTime.UtcNow.AddSeconds(45);
Exception? lastError = null;
while (DateTime.UtcNow < deadline)
{
Thread.Sleep(1500);
var connected = TryConnectSta();
if (connected.Ok)
return connected with { Started = true };
if (!string.IsNullOrWhiteSpace(connected.Message))
lastError = new InvalidOperationException(connected.Message);
}
throw new TimeoutException("已尝试启动 SolidWorks,但 45 秒内没有连接成功。" + (lastError == null ? "" : $" 最后错误:{lastError.Message}"));
}
}
public SolidWorksConnectionResult OpenDocument(string path)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("标准零件路径为空。", nameof(path));
if (!File.Exists(path))
throw new FileNotFoundException($"标准零件文件不存在:{path}");
var connected = EnsureConnected();
Exception? error = null;
SolidWorksConnectionResult result = connected;
var thread = new Thread(() =>
{
try
{
var hr = CLSIDFromProgID("SldWorks.Application", out var clsid);
if (hr < 0)
Marshal.ThrowExceptionForHR(hr);
var appObj = GetActiveObject(ref clsid, IntPtr.Zero);
var app = appObj as SldWorks;
if (app == null)
throw new InvalidOperationException("已连接 SolidWorks,但无法获取 SldWorks 应用对象。");
app.Visible = true;
var extension = Path.GetExtension(path).ToLowerInvariant();
var docType = extension == ".sldasm"
? (int)swDocumentTypes_e.swDocASSEMBLY
: extension == ".slddrw"
? (int)swDocumentTypes_e.swDocDRAWING
: (int)swDocumentTypes_e.swDocPART;
var errors = 0;
var warnings = 0;
var model = app.OpenDoc6(
path,
docType,
(int)swOpenDocOptions_e.swOpenDocOptions_Silent,
"",
ref errors,
ref warnings);
if (model == null || errors != 0)
throw new InvalidOperationException($"SolidWorks 打开文档失败,errors={errors}, warnings={warnings}");
result = connected with { Message = $"已打开标准零件:{Path.GetFileName(path)}" };
Marshal.ReleaseComObject(model);
Marshal.ReleaseComObject(appObj);
}
catch (Exception ex)
{
error = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
if (error != null)
throw error;
return result;
}
public HighlightResult HighlightTarget(string partPath, HighlightTarget target)
{
if (target == null)
throw new ArgumentNullException(nameof(target));
if (string.IsNullOrWhiteSpace(partPath))
throw new ArgumentException("标准零件路径为空。", nameof(partPath));
if (!File.Exists(partPath))
throw new FileNotFoundException($"标准零件文件不存在:{partPath}");
EnsureConnected();
HighlightResult result = new(false, "", "", $"没有找到可高亮对象:{target.Label}", 0, target.Description);
Exception? error = null;
var thread = new Thread(() =>
{
try
{
var hr = CLSIDFromProgID("SldWorks.Application", out var clsid);
if (hr < 0)
Marshal.ThrowExceptionForHR(hr);
var appObj = GetActiveObject(ref clsid, IntPtr.Zero);
var app = appObj as SldWorks;
if (app == null)
throw new InvalidOperationException("已连接 SolidWorks,但无法获取 SldWorks 应用对象。");
app.Visible = true;
var doc = GetOpenPartDocument(app, partPath);
if (doc == null)
throw new InvalidOperationException("SolidWorks 当前没有打开标准零件。");
if (doc.GetType() != (int)swDocumentTypes_e.swDocPART)
throw new InvalidOperationException("当前活动文档不是零件,无法做功能面高亮。");
doc.ClearSelection2(true);
var matched = TrySelectMatchingFace(doc, target, out var faceResult);
if (matched)
{
doc.GraphicsRedraw2();
result = faceResult;
}
else if (TrySelectSourceFeature(doc, target.SourceFeature, out var featureName))
{
doc.GraphicsRedraw2();
result = new HighlightResult(
true,
"feature",
featureName,
$"面级匹配不稳定,已回退高亮来源特征:{featureName}。讲解对象:{target.Label}。",
0,
target.Description);
}
else
{
result = new HighlightResult(
false,
"none",
"",
$"没有匹配到 {target.Label}。面级候选分数={faceResult.Score:0.###},来源特征={target.SourceFeature}。",
faceResult.Score,
target.Description);
}
Marshal.ReleaseComObject(doc);
Marshal.ReleaseComObject(appObj);
}
catch (Exception ex)
{
error = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
if (error != null)
throw error;
return result;
}
static ModelDoc2? GetOpenPartDocument(SldWorks app, string partPath)
{
var active = app.IActiveDoc2 ?? InvokeCom(app, "ActiveDoc") as ModelDoc2;
if (active != null && SamePath(ReadDocumentPath(active), partPath))
return active;
var errors = 0;
var warnings = 0;
var model = app.OpenDoc6(
partPath,
(int)swDocumentTypes_e.swDocPART,
(int)swOpenDocOptions_e.swOpenDocOptions_Silent,
"",
ref errors,
ref warnings);
if (model == null || errors != 0)
throw new InvalidOperationException($"SolidWorks 打开标准零件失败,errors={errors}, warnings={warnings}");
return model;
}
static string ReadDocumentPath(ModelDoc2 doc)
{
try
{
return doc.GetPathName() ?? "";
}
catch
{
return Convert.ToString(InvokeCom(doc, "GetPathName")) ?? "";
}
}
static bool SamePath(string left, string right)
{
if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right))
return false;
try
{
return string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase);
}
catch
{
return string.Equals(left, right, StringComparison.OrdinalIgnoreCase);
}
}
public SolidWorksDocumentStatus GetActiveDocumentStatus()
{
SolidWorksDocumentStatus result = new(false, false, 0, "none", "", "尚未连接 SolidWorks。", false, "", "", 0, "", "", new(), new(), new());
Exception? error = null;
var thread = new Thread(() =>
{
try
{
var hr = CLSIDFromProgID("SldWorks.Application", out var clsid);
if (hr < 0)
Marshal.ThrowExceptionForHR(hr);
var appObj = GetActiveObject(ref clsid, IntPtr.Zero);
var app = appObj as SldWorks;
var doc = app?.IActiveDoc2 ?? InvokeCom(appObj, "ActiveDoc") as ModelDoc2;
if (doc == null)
{
result = new SolidWorksDocumentStatus(true, false, 0, "none", "", "SolidWorks 已连接,但当前没有打开任何文档。", false, "", "", 0, "", "", new(), new(), new());
Marshal.ReleaseComObject(appObj);
return;
}
var typeCode = doc.GetType();
var title = doc.GetTitle() ?? "";
var sketchStatus = ReadActiveSketchStatus(doc);
result = new SolidWorksDocumentStatus(
true,
true,
typeCode,
DocumentTypeName(typeCode),
title,
"已读取当前 SolidWorks 文档。",
sketchStatus.Active,
sketchStatus.Name,
sketchStatus.ReferenceName,
sketchStatus.ReferenceType,
sketchStatus.ReferenceComType,
sketchStatus.ReferenceDebug,
sketchStatus.SelectedObjects,
sketchStatus.Circles,
sketchStatus.Dimensions);
Marshal.ReleaseComObject(doc);
Marshal.ReleaseComObject(appObj);
}
catch (Exception ex)
{
error = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
return error == null ? result : new SolidWorksDocumentStatus(false, false, 0, "none", "", error.Message, false, "", "", 0, "", "", new(), new(), new());
}
static ActiveSketchStatus ReadActiveSketchStatus(ModelDoc2 doc)
{
var selectedObjects = ReadSelectedObjects(doc);
try
{
var sketchManager = doc.SketchManager;
var activeSketch = sketchManager?.ActiveSketch;
if (activeSketch == null)
return new ActiveSketchStatus(false, "", "", 0, "", "SketchManager.ActiveSketch returned null.", selectedObjects, new(), new());
var sketchName = "";
var sketchFeature = InvokeCom(activeSketch, "GetFeature") as Feature;
if (sketchFeature != null)
sketchName = sketchFeature.Name ?? "";
var referenceType = 0;
var reference = activeSketch.GetReferenceEntity(ref referenceType);
var referenceInfo = DescribeSketchReference(doc, reference, referenceType, selectedObjects);
var circles = ReadActiveSketchCircles(activeSketch);
var dimensions = ReadActiveSketchDimensions(activeSketch);
return new ActiveSketchStatus(
true,
sketchName,
referenceInfo.Name,
referenceType,
referenceInfo.ComType,
referenceInfo.Debug,
selectedObjects,
circles,
dimensions);
}
catch (Exception ex)
{
return new ActiveSketchStatus(false, "", "", 0, "", ex.Message, selectedObjects, new(), new());
}
}
static List<SketchCircleInfo> ReadActiveSketchCircles(Sketch activeSketch)
{
var result = new List<SketchCircleInfo>();
foreach (var segmentObj in ToObjectList(activeSketch.GetSketchSegments()))
{
if (segmentObj is not SketchSegment segment)
continue;
var segmentType = segment.GetType();
if (segmentType != (int)swSketchSegments_e.swSketchARC || IsConstructionGeometry(segment))
continue;
if (segment is not SketchArc arc)
continue;
var centerPoint = arc.GetCenterPoint2() as SketchPoint;
var startPoint = arc.GetStartPoint2() as SketchPoint;
var endPoint = arc.GetEndPoint2() as SketchPoint;
var center = ReadSketchPointMm(centerPoint);
var start = ReadSketchPointMm(startPoint);
var end = ReadSketchPointMm(endPoint);
var radiusMm = arc.GetRadius() * 1000.0;
if (radiusMm <= 0 && center != null && start != null)
radiusMm = DistanceMm(center, start);
if (radiusMm <= 0)
continue;
var isFullCircle = start == null || end == null || DistanceMm(start, end) <= 0.001;
result.Add(new SketchCircleInfo(
Math.Round(radiusMm * 2.0, 4),
center == null ? null : Math.Round(center[0], 4),
center == null ? null : Math.Round(center[1], 4),
center == null ? null : Math.Round(center[2], 4),
isFullCircle,
segmentObj.GetType().FullName ?? segmentObj.GetType().Name));
}
return result;
}
static List<SketchDimensionInfo> ReadActiveSketchDimensions(Sketch activeSketch)
{
var result = new List<SketchDimensionInfo>();
foreach (var dimensionObj in ToObjectList(InvokeCom(activeSketch, "GetDimensions")))
{
var dimension = dimensionObj as Dimension;
if (dimension == null && dimensionObj is DisplayDimension displayDimension)
dimension = displayDimension.GetDimension() as Dimension;
if (dimension == null)
dimension = InvokeCom(dimensionObj!, "GetDimension") as Dimension;
if (dimension == null)
continue;
var valueMm = dimension.SystemValue * 1000.0;
if (valueMm <= 0)
continue;
var name = FirstNonEmpty(
dimension.GetNameForSelection(),
dimension.Name);
result.Add(new SketchDimensionInfo(name, Math.Round(valueMm, 4)));
}
return result;
}
static bool IsConstructionGeometry(SketchSegment segment)
{
try
{
return segment.ConstructionGeometry;
}
catch
{
return false;
}
}
static double[]? ReadSketchPointMm(SketchPoint? point)
{
if (point == null)
return null;
var x = point.X * 1000.0;
var y = point.Y * 1000.0;
var z = point.Z * 1000.0;
return new[] { x, y, z };
}
static SketchReferenceInfo DescribeSketchReference(
object doc,
object? reference,
int referenceType,
List<SelectedObjectInfo> selectedObjects)
{
if (reference == null)
{
var selectedPlane = selectedObjects.FirstOrDefault(x => x.IsDatumPlane);
return selectedPlane == null
? new SketchReferenceInfo("", "null", $"GetReferenceEntity returned null. reference_type={referenceType}.")
: new SketchReferenceInfo(selectedPlane.Name, "selection", $"GetReferenceEntity returned null. Fallback to selected datum plane. reference_type={referenceType}.");
}
var comType = reference.GetType().FullName ?? reference.GetType().Name;
var typeName = Convert.ToString(InvokeCom(reference, "GetTypeName2")) ?? "";
var name = FirstNonEmpty(
Convert.ToString(InvokeCom(reference, "Name")),
Convert.ToString(InvokeCom(reference, "Name2")));
if (string.Equals(typeName, "RefPlane", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(name))
return new SketchReferenceInfo(name, comType, $"GetReferenceEntity returned Feature RefPlane. reference_type={referenceType}.");
var referenceFeature = InvokeCom(reference, "GetFeature");
var referenceFeatureType = referenceFeature == null ? "" : Convert.ToString(InvokeCom(referenceFeature, "GetTypeName2")) ?? "";
var referenceFeatureName = referenceFeature == null ? "" : Convert.ToString(InvokeCom(referenceFeature, "Name")) ?? "";
if (string.Equals(referenceFeatureType, "RefPlane", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(referenceFeatureName))
return new SketchReferenceInfo(referenceFeatureName, comType, $"GetReferenceEntity returned RefPlane object; GetFeature resolved datum plane. reference_type={referenceType}.");
if (referenceType == 4)
{
var featureTreeName = FindMatchingRefPlaneFeatureName(doc, reference);
if (!string.IsNullOrWhiteSpace(featureTreeName))
return new SketchReferenceInfo(featureTreeName, comType, $"GetReferenceEntity returned swSelDATUMPLANES; matched RefPlane in feature tree. reference_type={referenceType}.");
var selectedPlane = selectedObjects.FirstOrDefault(x => x.IsDatumPlane);
if (selectedPlane != null)
return new SketchReferenceInfo(selectedPlane.Name, comType, $"GetReferenceEntity returned swSelDATUMPLANES; fallback to selected datum plane. reference_type={referenceType}.");
}
if (!string.IsNullOrWhiteSpace(name))
return new SketchReferenceInfo(name, comType, $"GetReferenceEntity returned named object. reference_type={referenceType}; type_name={typeName}.");
if (!string.IsNullOrWhiteSpace(referenceFeatureName))
return new SketchReferenceInfo(referenceFeatureName, comType, $"GetReferenceEntity feature fallback. reference_type={referenceType}; feature_type={referenceFeatureType}.");
return new SketchReferenceInfo("", comType, $"Could not resolve sketch reference name. reference_type={referenceType}; type_name={typeName}; feature_type={referenceFeatureType}.");
}
static string FindMatchingRefPlaneFeatureName(object doc, object reference)
{
var targetId = ComIdentity(reference);
if (targetId == IntPtr.Zero)
return "";
var feature = InvokeCom(doc, "FirstFeature");
var guard = 0;
while (feature != null && guard++ < 5000)
{
var typeName = Convert.ToString(InvokeCom(feature, "GetTypeName2")) ?? "";
if (string.Equals(typeName, "RefPlane", StringComparison.OrdinalIgnoreCase))
{
if (ComIdentity(feature) == targetId)
return Convert.ToString(InvokeCom(feature, "Name")) ?? "";
var specific = InvokeCom(feature, "GetSpecificFeature2");
if (specific != null && ComIdentity(specific) == targetId)
return Convert.ToString(InvokeCom(feature, "Name")) ?? "";
}
feature = InvokeCom(feature, "GetNextFeature");
}
return "";
}
static List<SelectedObjectInfo> ReadSelectedObjects(object doc)
{
var result = new List<SelectedObjectInfo>();
try
{
var selectionManager = InvokeCom(doc, "SelectionManager");
if (selectionManager == null)
return result;
var count = Convert.ToInt32(InvokeCom(selectionManager, "GetSelectedObjectCount2", -1) ?? 0);
for (var i = 1; i <= count; i++)
{
var selected = InvokeCom(selectionManager, "GetSelectedObject6", i, -1);
var selectedType = Convert.ToInt32(InvokeCom(selectionManager, "GetSelectedObjectType3", i, -1) ?? 0);
if (selected == null)
{
result.Add(new SelectedObjectInfo(i, selectedType, "", "null", "", false));
continue;
}
var typeName = Convert.ToString(InvokeCom(selected, "GetTypeName2")) ?? "";
var name = FirstNonEmpty(
Convert.ToString(InvokeCom(selected, "Name")),
Convert.ToString(InvokeCom(selected, "Name2")));
var feature = InvokeCom(selected, "GetFeature");
if (string.IsNullOrWhiteSpace(name) && feature != null)
name = Convert.ToString(InvokeCom(feature, "Name")) ?? "";
var featureType = feature == null ? "" : Convert.ToString(InvokeCom(feature, "GetTypeName2")) ?? "";
var isDatumPlane = selectedType == 4
|| string.Equals(typeName, "RefPlane", StringComparison.OrdinalIgnoreCase)
|| string.Equals(featureType, "RefPlane", StringComparison.OrdinalIgnoreCase);
result.Add(new SelectedObjectInfo(
i,
selectedType,
name,
selected.GetType().FullName ?? selected.GetType().Name,
FirstNonEmpty(typeName, featureType),
isDatumPlane));
}
}
catch
{
}
return result;
}
static bool TrySelectMatchingFace(ModelDoc2 doc, HighlightTarget target, out HighlightResult result)
{
result = new HighlightResult(false, "face", "", "没有找到匹配的功能面。", double.MaxValue, target.Description);
if (doc is not PartDoc part)
return false;
var expectedBox = target.BBoxMm;
var expectedCenter = target.CenterMm;
var expectedArea = target.AreaMm2;
Face2? bestFace = null;
var bestScore = double.MaxValue;
var bestIndex = 0;
var index = 0;
try
{
foreach (var bodyObj in ToObjectList(part.GetBodies2((int)swBodyType_e.swAllBodies, true)))
{
if (bodyObj == null)
continue;
foreach (var faceObj in ToObjectList(InvokeCom(bodyObj, "GetFaces")))
{
if (faceObj is not Face2 face)
continue;
index++;
var score = ScoreFace(face, target, expectedBox, expectedCenter, expectedArea);
if (score < bestScore)
{
bestScore = score;
bestFace = face;
bestIndex = index;
}
}
}
}
catch
{
return false;
}
result = new HighlightResult(false, "face", $"face-{bestIndex}", "找到候选面,但置信度不足。", bestScore, target.Description);
if (bestFace == null || bestScore > 18.0)
return false;
var selected = false;
try
{
if (bestFace is Entity entity)
selected = entity.Select4(false, null!);
}
catch
{
}
if (!selected)
selected = Convert.ToBoolean(InvokeCom(bestFace, "Select4", false, null!) ?? false);
if (!selected)
return false;
result = new HighlightResult(
true,
"face",
$"face-{bestIndex}",
$"已高亮 {target.Label}。{target.Description}",
bestScore,
target.Description);
return true;
}
static double ScoreFace(Face2 face, HighlightTarget target, IReadOnlyList<double> expectedBox, IReadOnlyList<double> expectedCenter, double? expectedArea)
{
var score = 0.0;
var surface = "";
try
{
if (face.GetSurface() is Surface faceSurface)
surface = SurfaceKind(faceSurface);
}
catch
{
}
if (!string.IsNullOrWhiteSpace(target.RawSurfaceType))
score += string.Equals(surface, target.RawSurfaceType, StringComparison.OrdinalIgnoreCase) ? 0 : 100;
var box = TryGetFaceBoxMm(face);
if (expectedBox.Count >= 6 && box.Count >= 6)
{
for (var i = 0; i < 6; i++)
score += Math.Min(25.0, Math.Abs(expectedBox[i] - box[i]));
}
else if (expectedCenter.Count >= 3 && box.Count >= 6)
{
var center = new[] { (box[0] + box[3]) / 2.0, (box[1] + box[4]) / 2.0, (box[2] + box[5]) / 2.0 };
score += Math.Min(50.0, DistanceMm(expectedCenter.ToArray(), center));
}
else
{
score += 30.0;
}
if (expectedArea.HasValue)
{
try
{
var area = face.GetArea() * 1_000_000.0;
score += Math.Min(15.0, Math.Abs(expectedArea.Value - area) / Math.Max(1.0, expectedArea.Value) * 15.0);
}
catch
{
}
}
return score;
}
static bool TrySelectSourceFeature(ModelDoc2 doc, string featureName, out string selectedName)
{
selectedName = "";
if (string.IsNullOrWhiteSpace(featureName))
return false;
try
{
var feature = FindFeatureByName(doc, featureName);
if (feature == null)
return false;
selectedName = feature.Name ?? featureName;
return feature.Select2(false, 0);
}
catch
{
return false;
}
}
static Feature? FindFeatureByName(ModelDoc2 doc, string featureName)
{
var featureObj = InvokeCom(doc, "FirstFeature");
var guard = 0;
while (featureObj != null && guard++ < 5000)
{
if (featureObj is not Feature feature)
break;
if (string.Equals(feature.Name, featureName, StringComparison.OrdinalIgnoreCase))
return feature;
featureObj = InvokeCom(feature, "GetNextFeature");
}
return null;
}
static string SurfaceKind(Surface surface)
{
try
{
if (surface.IsPlane()) return "plane";
if (surface.IsCylinder()) return "cylinder";
if (surface.IsCone()) return "cone";
if (surface.IsSphere()) return "sphere";
}
catch
{
}
return "unknown";
}
static List<double> TryGetFaceBoxMm(Face2 face)
{
try
{
return ToDoubleList(face.GetBox()).Take(6).Select(value => Math.Round(value * 1000.0, 6)).ToList();
}
catch
{
return new List<double>();
}
}
static SolidWorksConnectionResult TryConnectSta()
{
SolidWorksConnectionResult result = new(false, false, "", "");
Exception? error = null;
var thread = new Thread(() =>
{
try
{
var hr = CLSIDFromProgID("SldWorks.Application", out var clsid);
if (hr < 0)
Marshal.ThrowExceptionForHR(hr);
var app = GetActiveObject(ref clsid, IntPtr.Zero);
var visible = InvokeCom(app, "Visible")?.ToString() ?? "";
var revision = InvokeCom(app, "RevisionNumber")?.ToString() ?? "";
result = new SolidWorksConnectionResult(true, false, revision, $"宸茶繛鎺?SolidWorks銆俈isible={visible}");
Marshal.ReleaseComObject(app);
}
catch (Exception ex)
{
error = ex;
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join();
return result.Ok ? result : new SolidWorksConnectionResult(false, false, "", error?.Message ?? "无法连接 SolidWorks。");
}
static object? InvokeCom(object target, string name)
{
try
{
return target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.GetProperty,
null,
target,
Array.Empty<object>());
}
catch
{
try
{
return target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.InvokeMethod,
null,
target,
Array.Empty<object>());
}
catch
{
return null;
}
}
}
static object? InvokeCom(object target, string name, params object[] args)
{
try
{
return target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.InvokeMethod,
null,
target,
args);
}
catch
{
return null;
}
}
static object? InvokeComByRefInt(object target, string name, ref int value)
{
try
{
object[] args = { value };
var result = target.GetType().InvokeMember(
name,
System.Reflection.BindingFlags.InvokeMethod,
null,
target,
args);
if (args.Length > 0 && args[0] is int updated)
value = updated;
return result;
}
catch
{
return null;
}
}
static string FirstNonEmpty(params string?[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
return value;
}
return "";
}
static IntPtr ComIdentity(object? value)
{
if (value == null)
return IntPtr.Zero;
IntPtr unknown = IntPtr.Zero;
try
{
unknown = Marshal.GetIUnknownForObject(value);
return unknown;
}
catch
{
return IntPtr.Zero;
}
finally
{
if (unknown != IntPtr.Zero)
Marshal.Release(unknown);
}
}
static List<object?> ToObjectList(object? value)
{
if (value == null)
return new List<object?>();
if (value is object?[] objects)
return objects.ToList();
if (value is Array array)
{
var result = new List<object?>();
foreach (var item in array)
result.Add(item);
return result;
}
return new List<object?> { value };
}
static List<double> ToDoubleList(object? value)
{
if (value == null)
return new List<double>();
if (value is double[] doubles)
return doubles.ToList();
if (value is Array array)
{
var result = new List<double>();
foreach (var item in array)
{
try
{
result.Add(Convert.ToDouble(item));
}
catch
{
}
}
return result;
}
try
{
return new List<double> { Convert.ToDouble(value) };
}
catch
{
return new List<double>();
}
}
static int ToInt(object? value, int fallback = 0)
{
if (value == null)
return fallback;
try
{
return Convert.ToInt32(value);
}
catch
{
return fallback;
}
}
static double ToDouble(object? value, double fallback = 0.0)
{
if (value == null)
return fallback;
try
{
return Convert.ToDouble(value);
}
catch
{
return fallback;
}
}
static bool HasComMember(object target, string name)
{
return InvokeCom(target, name) != null;
}
static double DistanceMm(double[] a, double[] b)
{
if (a.Length < 3 || b.Length < 3)
return double.MaxValue;
var dx = a[0] - b[0];
var dy = a[1] - b[1];
var dz = a[2] - b[2];
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
}
static string DocumentTypeName(int typeCode)
{
return typeCode switch
{
1 => "part",
2 => "assembly",
3 => "drawing",
_ => "unknown",
};
}
[DllImport("ole32.dll", CharSet = CharSet.Unicode)]
static extern int CLSIDFromProgID(string progId, out Guid clsid);
[DllImport("oleaut32.dll", PreserveSig = false)]
[return: MarshalAs(UnmanagedType.IUnknown)]
static extern object GetActiveObject(ref Guid clsid, IntPtr reserved);
}
sealed record SolidWorksConnectionResult(bool Ok, bool Started, string Revision, string Message);
sealed record SelectedObjectInfo(
int Index,
int SelectionType,
string Name,
string ComType,
string SolidWorksTypeName,
bool IsDatumPlane);
sealed record SketchReferenceInfo(string Name, string ComType, string Debug);
sealed record SketchCircleInfo(
double DiameterMm,
double? CenterXLocalMm,
double? CenterYLocalMm,
double? CenterZLocalMm,
bool IsFullCircle,
string ComType);
sealed record SketchDimensionInfo(string Name, double ValueMm);
sealed record ActiveSketchStatus(
bool Active,
string Name,
string ReferenceName,
int ReferenceType,
string ReferenceComType,
string ReferenceDebug,
List<SelectedObjectInfo> SelectedObjects,
List<SketchCircleInfo> Circles,
List<SketchDimensionInfo> Dimensions);
sealed record SolidWorksDocumentStatus(
bool Connected,
bool HasDocument,
int TypeCode,
string TypeName,
string Title,
string Message,
bool ActiveSketch,
string ActiveSketchName,
string ActiveSketchReferenceName,
int ActiveSketchReferenceType,
string ActiveSketchReferenceComType,
string ActiveSketchReferenceDebug,
List<SelectedObjectInfo> SelectedObjects,
List<SketchCircleInfo> ActiveSketchCircles,
List<SketchDimensionInfo> ActiveSketchDimensions);
sealed class KnowledgeRepository
{
readonly AppPaths _paths;
readonly object _lock = new();
Dictionary<string, StandardAnswer> _standards = new(StringComparer.OrdinalIgnoreCase);
public KnowledgeRepository(AppPaths paths)
{
_paths = paths;
Reload();
}
public void Reload()
{
var standards = new Dictionary<string, StandardAnswer>(StringComparer.OrdinalIgnoreCase);
foreach (var item in LoadManifestStandards())
standards[item.StandardId] = item;
foreach (var item in LoadLocalJsonStandards())
standards.TryAdd(item.StandardId, item);
lock (_lock)
_standards = standards;
}
public List<StandardAnswer> ListStandards(string query = "", string kind = "")
{
List<StandardAnswer> items;
lock (_lock)
items = _standards.Values.ToList();
if (!string.IsNullOrWhiteSpace(kind) && !string.Equals(kind, "unknown", StringComparison.OrdinalIgnoreCase))
items = items.Where(item => string.Equals(item.Kind, kind, StringComparison.OrdinalIgnoreCase)).ToList();
if (!string.IsNullOrWhiteSpace(query))
{
var tokens = SearchTokens(query);
items = items
.Select(item => new { Score = Score(tokens, item), Item = item })
.Where(pair => pair.Score > 0)
.OrderByDescending(pair => pair.Score)
.Select(pair => pair.Item)
.ToList();
}
return items;
}
public bool TryGet(string standardId, out StandardAnswer standard)
{
lock (_lock)
return _standards.TryGetValue(standardId, out standard!);
}
IEnumerable<StandardAnswer> LoadManifestStandards()
{
if (!File.Exists(_paths.ManifestPath))
yield break;
using var doc = JsonDocument.Parse(File.ReadAllText(_paths.ManifestPath));
if (!doc.RootElement.TryGetProperty("entries", out var entries) || entries.ValueKind != JsonValueKind.Array)
yield break;
foreach (var entry in entries.EnumerateArray())
{
var standard = StandardFromManifestEntry(entry);
if (standard != null)
yield return standard;
}
}
StandardAnswer? StandardFromManifestEntry(JsonElement entry)
{
var standardId = ReadString(entry, "standard_id");
if (string.IsNullOrWhiteSpace(standardId))
return null;
var preferred = FirstNonEmpty(
ReadString(entry, "modeling_plan_path"),
ReadString(entry, "knowledge_skillflow_path"),
ReadString(entry, "skill_flow_path"));
var path = string.IsNullOrWhiteSpace(preferred) ? "" : Path.Combine(_paths.StandardsRoot, preferred);
var loaded = !string.IsNullOrWhiteSpace(path) ? SkillFlow.LoadStandardFromFile(path) : null;
if (loaded == null)
{
return new StandardAnswer
{
StandardId = standardId,
Title = FirstNonEmpty(ReadString(entry, "title"), ReadString(entry, "source_base_name")),
Kind = FirstNonEmpty(ReadString(entry, "kind"), "unknown"),
SourcePath = path,
SourceType = "manifest_ref",
Summary = "Manifest standard answer reference without a readable local payload.",
Steps = new List<SkillStep>(),
Tags = TagsForText(ReadString(entry, "title") + " " + ReadString(entry, "category")),
};
}
loaded.StandardId = standardId;
loaded.Title = FirstNonEmpty(ReadString(entry, "title"), loaded.Title);
loaded.Kind = FirstNonEmpty(ReadString(entry, "kind"), loaded.Kind);
loaded.SourceType = "manifest";
loaded.Tags = loaded.Tags.Concat(TagsForText(loaded.Title + " " + ReadString(entry, "category"))).Distinct().ToList();
return loaded;
}
IEnumerable<StandardAnswer> LoadLocalJsonStandards()
{
if (!Directory.Exists(_paths.StandardsRoot))
yield break;
foreach (var path in Directory.EnumerateFiles(_paths.StandardsRoot, "*.json", SearchOption.AllDirectories))
{
var name = Path.GetFileName(path).ToLowerInvariant();
if (!name.Contains("skillflow") && !name.Contains("skill_flow") && !name.Contains("modeling_plan") && !name.Contains("knowledge"))
continue;
var standard = SkillFlow.LoadStandardFromFile(path);
if (standard != null)
yield return standard;
}
}
static int Score(List<string> tokens, StandardAnswer item)
{
var text = string.Join(" ", new[]
{
item.StandardId,
item.Title,
item.Summary,
item.SourcePath,
string.Join(" ", item.Tags),
string.Join(" ", item.Steps.Take(80).Select(step => $"{step.Skill} {step.Name} {step.SourceFeature}")),
}).ToLowerInvariant();
return tokens.Sum(token => CountOccurrences(text, token));
}
static int CountOccurrences(string text, string token)
{
var count = 0;
var index = 0;
while ((index = text.IndexOf(token, index, StringComparison.OrdinalIgnoreCase)) >= 0)
{
count++;
index += token.Length;
}
return count;
}
static List<string> SearchTokens(string query)
{
var lowered = query.ToLowerInvariant();
var raw = lowered.Replace("_", " ").Replace("-", " ")
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(item => item.Length >= 2)
.ToList();
for (var i = 0; i < lowered.Length - 1; i++)
{
var pair = lowered.Substring(i, 2);
if (pair.Any(ch => ch >= '\u4e00' && ch <= '\u9fff'))
raw.Add(pair);
}
return raw.Distinct().ToList();
}
static List<string> TagsForText(string text)
{
var tags = new List<string>();
var mapping = new Dictionary<string, string>
{
["齿轮"] = "gear",
["轴"] = "shaft",
["箱体"] = "housing",
["端盖"] = "cover",
["减速器"] = "reducer",
["装配"] = "assembly",
["assembly"] = "assembly",
["shell"] = "shell",
["鎶藉3"] = "shell",
["loft"] = "loft",
["鏀炬牱"] = "loft",
};
foreach (var (key, tag) in mapping)
if (text.Contains(key, StringComparison.OrdinalIgnoreCase) && !tags.Contains(tag))
tags.Add(tag);
return tags;
}
static string ReadString(JsonElement element, string name)
{
return element.TryGetProperty(name, out var value) && value.ValueKind != JsonValueKind.Null
? value.ToString()
: "";
}
static string FirstNonEmpty(params string[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "";
}
static class SkillFlow
{
public static StandardAnswer? LoadStandardFromFile(string path)
{
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
var steps = NormalizeSteps(doc.RootElement);
if (steps.Count == 0)
return null;
var root = doc.RootElement;
var schema = TryGetString(root, "schemaVersion");
var kind = schema.Contains("assembly", StringComparison.OrdinalIgnoreCase) ? "assembly" : "part";
var modelingPlan = TryGetObject(root, "modelingPlan");
var summary = modelingPlan.HasValue ? TryGetString(modelingPlan.Value, "summary") : "";
if (string.IsNullOrWhiteSpace(summary))
summary = TryGetString(root, "approvedPlanText");
var title = Path.GetFileNameWithoutExtension(path);
return new StandardAnswer
{
StandardId = MakeStandardId(title),
Title = title,
Kind = kind,
SourcePath = path,
SourceType = "local_json",
Tags = new List<string>(),
Summary = summary,
Steps = steps,
};
}
catch
{
return null;
}
}
public static List<SkillStep> NormalizeSteps(JsonElement payload)
{
JsonElement rawSteps;
if (payload.ValueKind == JsonValueKind.Array)
{
rawSteps = payload;
}
else if (payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("steps", out var steps) && steps.ValueKind == JsonValueKind.Array)
{
rawSteps = steps;
}
else if (payload.ValueKind == JsonValueKind.Object &&
payload.TryGetProperty("modelingPlan", out var modelingPlan) &&
modelingPlan.ValueKind == JsonValueKind.Object &&
modelingPlan.TryGetProperty("steps", out var modelingSteps) &&
modelingSteps.ValueKind == JsonValueKind.Array)
{
rawSteps = modelingSteps;
}
else if (payload.ValueKind == JsonValueKind.Object &&
payload.TryGetProperty("modeling_plan", out var modelingPlanSnake) &&
modelingPlanSnake.ValueKind == JsonValueKind.Object &&
modelingPlanSnake.TryGetProperty("steps", out var modelingStepsSnake) &&
modelingStepsSnake.ValueKind == JsonValueKind.Array)
{
rawSteps = modelingStepsSnake;
}
else
{
return new List<SkillStep>();
}
var result = new List<SkillStep>();
var index = 1;
foreach (var item in rawSteps.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object)
continue;
var skill = FirstNonEmpty(TryGetString(item, "skill"), TryGetString(item, "skillName"), TryGetString(item, "skill_name"));
if (string.IsNullOrWhiteSpace(skill))
continue;
var args = TryGetObject(item, "args") ?? TryGetObject(item, "arguments");
result.Add(new SkillStep
{
Id = FirstNonEmpty(TryGetString(item, "id"), $"step-{index:000}"),
Order = TryGetInt(item, "step") ?? index,
Skill = skill,
Name = FirstNonEmpty(TryGetString(item, "name"), TryGetString(item, "source_feature"), skill),
Description = FirstNonEmpty(TryGetString(item, "description"), TryGetString(item, "note")),
Arguments = args.HasValue ? JsonElementToDictionary(args.Value) : new Dictionary<string, object?>(),
SourceFeature = FirstNonEmpty(TryGetString(item, "source_feature"), TryGetString(item, "sourceFeature"), TryGetString(item, "name")),
Note = FirstNonEmpty(TryGetString(item, "note"), TryGetString(item, "description")),
});
index++;
}
return result;
}
public static string MakeStandardId(string text)
{
var chars = text.ToLowerInvariant().Select(ch =>
char.IsLetterOrDigit(ch) || ch is >= '\u4e00' and <= '\u9fff' ? ch : '-').ToArray();
var id = string.Join("-", new string(chars).Split('-', StringSplitOptions.RemoveEmptyEntries));
return string.IsNullOrWhiteSpace(id) ? "standard" : id[..Math.Min(120, id.Length)];
}
public static Dictionary<string, object?> JsonElementToDictionary(JsonElement element)
{
var result = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var prop in element.EnumerateObject())
result[prop.Name] = JsonElementToObject(prop.Value);
return result;
}
static object? JsonElementToObject(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.TryGetInt64(out var i) ? i : element.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(),
JsonValueKind.Object => JsonElementToDictionary(element),
_ => null,
};
}
static string TryGetString(JsonElement element, string name)
{
return element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(name, out var value) &&
value.ValueKind != JsonValueKind.Null
? value.ToString()
: "";
}
static int? TryGetInt(JsonElement element, string name)
{
if (element.ValueKind != JsonValueKind.Object ||
!element.TryGetProperty(name, out var value) ||
value.ValueKind != JsonValueKind.Number)
return null;
return value.TryGetInt32(out var intValue) ? intValue : null;
}
static JsonElement? TryGetObject(JsonElement element, string name)
{
return element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(name, out var value) &&
value.ValueKind == JsonValueKind.Object
? value
: null;
}
static string FirstNonEmpty(params string[] values) => values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? "";
}
sealed class LessonBuilder
{
readonly TeachingLanguageDesigner _designer;
public LessonBuilder(TeachingLanguageDesigner designer)
{
_designer = designer;
}
public List<LessonStep> Build(StandardAnswer standard)
{
var designedScript = _designer.Design(standard);
var designedByOrder = designedScript.Steps.ToDictionary(step => step.Order);
return standard.Steps.Select((step, index) =>
{
designedByOrder.TryGetValue(index + 1, out var designed);
return new LessonStep
{
StepId = $"lesson-{index + 1:000}",
Order = index + 1,
Title = designed?.Title ?? TeachingLanguageDesigner.LabelFor(step.Skill, step.Name),
VoiceText = designed?.VoiceText ?? TeachingLanguageDesigner.FallbackVoiceText(step),
ExpectedSkill = step.Skill,
ExpectedArguments = step.Arguments,
SourceFeature = step.SourceFeature,
TeachingIntent = designed?.TeachingIntent ?? "",
CommonMistake = designed?.CommonMistake ?? "",
CheckPolicy = new Dictionary<string, object?>
{
["match"] = "skill_and_core_arguments",
["method_required"] = true,
["geometry_is_secondary"] = true,
},
};
}).ToList();
}
}
sealed class TeachingLanguageDesigner
{
public TeachingScript Design(StandardAnswer standard)
{
var context = PartContext(standard);
var steps = standard.Steps.Select((step, index) => DesignStep(standard, step, index + 1, context)).ToList();
return new TeachingScript
{
StandardId = standard.StandardId,
Title = standard.Title,
Role = "design-teaching-language-agent",
Summary = $"面向学生的 SolidWorks 建模语音教学脚本。对象:{standard.Title},步骤数:{steps.Count}。",
Steps = steps,
};
}
TeachingScriptStep DesignStep(StandardAnswer standard, SkillStep step, int order, string context)
{
var title = LabelFor(step.Skill, step.Name);
var intent = IntentFor(step, context);
var command = CommandHintFor(step);
var mistake = MistakeFor(step);
var parameter = ParameterPhrase(step);
var voice = VoiceTextFor(order, title, step, intent, command, parameter, mistake);
return new TeachingScriptStep
{
Order = order,
Skill = step.Skill,
SourceFeature = step.SourceFeature,
Title = title,
VoiceText = voice,
TeachingIntent = intent.Trim(),
SolidWorksCommandHint = command.Trim(),
CommonMistake = mistake.Trim(),
KeyParameters = CoreParameters(step),
};
}
static string VoiceTextFor(int order, string title, SkillStep step, string intent, string command, string parameter, string mistake)
{
if (string.Equals(step.Skill, "draw_circle_diameter_mm", StringComparison.OrdinalIgnoreCase) &&
TryGetNumber(step.Arguments, "diameter_mm", out var diameterMm))
{
var center = CircleCenterPhrase(step);
return $"第 {order} 步,{title}。这一步先别急着随手画,直接画一个直径 {FormatMm(diameterMm)} 毫米的圆{center}。在草图工具栏选择圆,画完立刻用智能尺寸把直径标成 {FormatMm(diameterMm)} 毫米。{mistake}";
}
return $"第 {order} 步,{title}。{intent}{command}{parameter}{mistake}";
}
static string PartContext(StandardAnswer standard)
{
if (standard.StandardId == "part-gear-gear-1-high-speed-shaft" || standard.Title.Contains("齿轮1+高速轴", StringComparison.OrdinalIgnoreCase))
return "这是齿轮1与高速轴一体件,教学重点是先建立轴类回转主体,再建立齿轮轮齿、孔槽和修饰特征,学生要理解每个特征为什么选择这种建模方法。";
return $"当前零件是 {standard.Title},教学重点是按标准建模流程逐步完成特征树。";
}
static string IntentFor(SkillStep step, string context)
{
return step.Skill switch
{
"create_new_part" => $"先新建一个零件文件,作为后续所有特征的建模环境。{context}",
"create_front_plane_sketch" => "在前视基准面进入草图,因为这一步需要建立正视方向的截面或轮廓。",
"create_top_plane_sketch" => "在上视基准面进入草图,用来建立水平参考方向上的轮廓。",
"create_right_plane_sketch" => "在右视基准面进入草图,用来建立侧向参考轮廓。",
"create_face_sketch_by_signature" => "在已有实体面上新建草图,后续特征会依赖这个面的位置和方向。",
"draw_circle_diameter_mm" => "绘制圆形轮廓,这是轴段、齿轮坯或孔特征的基础几何。",
"draw_line_mm" => "绘制直线轮廓,用来限定截面边界或辅助构造。",
"draw_center_line_mm" => "绘制中心线,作为旋转、对称或定位的参考。",
"draw_arc_center_start_end_mm" => "绘制圆弧,形成齿形、过渡轮廓或局部曲线。",
"draw_spline_points_mm" => "绘制样条曲线,用来表达非直线的齿形或过渡边界。",
"exit_sketch" => "退出当前草图,让草图可以被后续特征命令调用。",
"extrude_boss_mm" => "使用凸台拉伸生成实体材料,这是增加实体体积的步骤。",
"extrude_cut_mm" => "使用切除拉伸去除材料,这是开孔、开槽或削减实体的步骤。",
"create_revolve_boss_mm" => "使用旋转凸台生成轴对称实体,这比多次拉伸更符合轴类零件建模思路。",
"create_revolve_cut_mm" => "使用旋转切除去除轴对称材料,适合环槽或回转凹陷。",
"create_reference_axis" => "创建基准轴,给圆周阵列、旋转或装配定位提供稳定参考。",
"create_helix_mm" => "创建螺旋线,作为扫描切除或齿形路径的参考曲线。",
"swept_cut_profile_path" => "使用扫描切除沿路径去除材料,适合齿形或螺旋路径类特征。",
"swept_cut_circular_profile_mm" => "使用扫描切除,用圆形截面沿路径去除材料。",
"swept_boss_circular_profile_mm" => "使用扫描凸台,用圆形截面沿路径增加材料。",
"circular_pattern_feature" => "使用圆周阵列复制种子特征,形成绕轴重复分布的结构。",
"linear_pattern_feature_mm" => "使用线性阵列复制种子特征,形成等间距重复结构。",
"mirror_feature_about_plane" => "使用镜像保证左右或前后对称,避免重复手工建模。",
"fillet_edges_radius_mm" => "添加圆角,减少尖锐边并符合零件加工和装配要求。",
"chamfer_edges_angle_distance_mm" => "添加倒角,便于装配导入并去除尖角。",
_ => "按照标准特征树继续完成本步骤,重点保持建模方法和参数一致。",
};
}
static string CommandHintFor(SkillStep step)
{
return step.Skill switch
{
"create_new_part" => "在 SolidWorks 中点击新建,选择零件。",
"create_front_plane_sketch" => "在特征树中选择前视基准面,然后点击草图。",
"create_top_plane_sketch" => "在特征树中选择上视基准面,然后点击草图。",
"create_right_plane_sketch" => "在特征树中选择右视基准面,然后点击草图。",
"create_face_sketch_by_signature" => "选择模型上对应的实体面,然后点击草图。",
"draw_circle_diameter_mm" => "在草图工具栏选择圆,并用智能尺寸标注直径。",
"draw_line_mm" => "在草图工具栏选择直线,并按标准轮廓绘制。",
"draw_center_line_mm" => "在草图工具栏选择中心线。",
"draw_arc_center_start_end_mm" => "在草图工具栏选择圆弧命令。",
"draw_spline_points_mm" => "在草图工具栏选择样条曲线。",
"exit_sketch" => "点击退出草图。",
"extrude_boss_mm" => "点击特征选项卡中的凸台/基体拉伸。",
"extrude_cut_mm" => "点击特征选项卡中的拉伸切除。",
"create_revolve_boss_mm" => "点击特征选项卡中的旋转凸台/基体。",
"create_revolve_cut_mm" => "点击特征选项卡中的旋转切除。",
"create_reference_axis" => "点击参考几何体,选择基准轴。",
"create_helix_mm" => "点击曲线,选择螺旋线/涡状线。",
"swept_cut_profile_path" or "swept_cut_circular_profile_mm" => "点击特征选项卡中的扫描切除。",
"swept_boss_circular_profile_mm" => "点击特征选项卡中的扫描凸台/基体。",
"circular_pattern_feature" => "点击特征选项卡中的圆周阵列。",
"linear_pattern_feature_mm" => "点击特征选项卡中的线性阵列。",
"mirror_feature_about_plane" => "点击特征选项卡中的镜像。",
"fillet_edges_radius_mm" => "点击特征选项卡中的圆角。",
"chamfer_edges_angle_distance_mm" => "点击特征选项卡中的倒角。",
_ => "请在 SolidWorks 中选择与本步骤匹配的特征命令。",
};
}
static string MistakeFor(SkillStep step)
{
return step.Skill switch
{
"extrude_boss_mm" => "注意这是增加材料,不能误用切除拉伸。",
"extrude_cut_mm" => "注意这是去除材料,不能用抽壳、删除面或凸台拉伸替代。",
"create_revolve_boss_mm" => "注意回转体优先使用旋转凸台,不要用多个拉伸堆出来。",
"create_revolve_cut_mm" => "注意环槽类结构应使用旋转切除,不要用普通拉伸切除替代。",
"swept_cut_profile_path" or "swept_cut_circular_profile_mm" => "注意扫描切除必须同时选对轮廓和路径,不能用放样或普通切除替代。",
"circular_pattern_feature" => "注意阵列轴和数量必须正确,不能手工复制多个特征。",
"mirror_feature_about_plane" => "注意镜像基准面要选对,否则对称方向会错。",
"fillet_edges_radius_mm" => "注意圆角半径要和标准一致,不要漏选边线。",
"chamfer_edges_angle_distance_mm" => "注意倒角距离和角度要和标准一致。",
_ => "完成后系统会读取特征树,检查你使用的方法是否符合标准答案。",
};
}
static string ParameterPhrase(SkillStep step)
{
var parts = CoreParameters(step);
return parts.Count == 0 ? "" : "关键参数是:" + string.Join("", parts.Select(pair => $"{ParameterLabel(pair.Key)} 为 {FormatParameterValue(pair.Key, pair.Value)}")) + "。";
}
static string CircleCenterPhrase(SkillStep step)
{
if (TryGetNumber(step.Arguments, "cx_mm", out var cx) && TryGetNumber(step.Arguments, "cy_mm", out var cy))
return $",圆心放在 X 等于 {FormatMm(cx)} 毫米、Y 等于 {FormatMm(cy)} 毫米的位置";
if (step.Arguments.TryGetValue("center_model_mm", out var value) && value is List<object?> list && list.Count >= 2 &&
TryGetNumber(list[0], out var x) && TryGetNumber(list[1], out var y))
return $",圆心参考坐标 X 等于 {FormatMm(x)} 毫米、Y 等于 {FormatMm(y)} 毫米";
return "";
}
static string ParameterLabel(string key)
{
return key switch
{
"diameter_mm" => "直径",
"depth_mm" => "拉伸深度",
"height_mm" => "高度",
"pitch_mm" => "螺距",
"revolution" => "圈数",
"radius_mm" => "半径",
"distance_mm" => "距离",
"angle_deg" => "角度",
"count" => "数量",
"count1" => "数量",
"spacing1_mm" => "间距",
"axis_name" => "阵列轴",
"end_condition" => "终止条件",
"end_condition_code" => "终止条件代码",
"through_all" => "完全贯穿",
_ => key,
};
}
static string FormatParameterValue(string key, object? value)
{
if (TryGetNumber(value, out var number))
{
if (key.EndsWith("_mm", StringComparison.OrdinalIgnoreCase))
return $"{FormatMm(number)} 毫米";
if (key.EndsWith("_deg", StringComparison.OrdinalIgnoreCase))
return $"{FormatMm(number)} 度";
return FormatMm(number);
}
return Convert.ToString(value) ?? "";
}
static string FormatMm(double value)
{
return Math.Abs(value - Math.Round(value)) < 0.0005
? Math.Round(value).ToString("0")
: value.ToString("0.###");
}
static bool TryGetNumber(Dictionary<string, object?> values, string key, out double number)
{
number = 0;
return values.TryGetValue(key, out var value) && TryGetNumber(value, out number);
}
static bool TryGetNumber(object? value, out double number)
{
number = 0;
try
{
switch (value)
{
case double d:
number = d;
return true;
case float f:
number = f;
return true;
case int i:
number = i;
return true;
case long l:
number = l;
return true;
case decimal m:
number = (double)m;
return true;
case string s:
return double.TryParse(s, out number);
default:
return false;
}
}
catch
{
return false;
}
}
static Dictionary<string, object?> CoreParameters(SkillStep step)
{
var keys = step.Skill switch
{
"extrude_boss_mm" or "extrude_cut_mm" => new[] { "depth_mm", "end_condition", "end_condition_code", "through_all" },
"draw_circle_diameter_mm" => new[] { "diameter_mm" },
"create_revolve_boss_mm" or "create_revolve_cut_mm" => new[] { "angle_deg" },
"create_helix_mm" => new[] { "height_mm", "pitch_mm", "revolution" },
"swept_cut_circular_profile_mm" or "swept_boss_circular_profile_mm" => new[] { "diameter_mm" },
"circular_pattern_feature" => new[] { "count", "angle_deg", "axis_name" },
"linear_pattern_feature_mm" => new[] { "count1", "spacing1_mm" },
"fillet_edges_radius_mm" => new[] { "radius_mm" },
"chamfer_edges_angle_distance_mm" => new[] { "distance_mm", "angle_deg" },
_ => Array.Empty<string>(),
};
return keys.Where(step.Arguments.ContainsKey).ToDictionary(key => key, key => step.Arguments[key]);
}
public static string LabelFor(string skill, string fallback)
{
return skill switch
{
"create_new_part" => "新建零件",
"create_front_plane_sketch" => "前视基准面草图",
"create_top_plane_sketch" => "上视基准面草图",
"create_right_plane_sketch" => "右视基准面草图",
"create_face_sketch_by_signature" => "实体面草图",
"draw_circle_diameter_mm" => "绘制圆并标注直径",
"draw_line_mm" => "绘制直线",
"draw_center_line_mm" => "绘制中心线",
"draw_arc_center_start_end_mm" => "绘制圆弧",
"draw_spline_points_mm" => "绘制样条曲线",
"exit_sketch" => "退出草图",
"extrude_boss_mm" => "凸台拉伸",
"extrude_cut_mm" => "切除拉伸",
"create_revolve_boss_mm" => "旋转凸台",
"create_revolve_cut_mm" => "旋转切除",
"create_reference_axis" => "创建基准轴",
"create_helix_mm" => "创建螺旋线",
"swept_cut_profile_path" => "扫描切除",
"swept_cut_circular_profile_mm" => "扫描切除",
"swept_boss_circular_profile_mm" => "扫描凸台",
"circular_pattern_feature" => "圆周阵列",
"linear_pattern_feature_mm" => "线性阵列",
"mirror_feature_about_plane" => "镜像特征",
"fillet_edges_radius_mm" => "圆角",
"chamfer_edges_angle_distance_mm" => "倒角",
_ => string.IsNullOrWhiteSpace(fallback) ? skill : fallback,
};
}
public static string FallbackVoiceText(SkillStep step)
{
return $"请在 SolidWorks 中完成:{LabelFor(step.Skill, step.Name)}。系统会检查特征树中的方法和关键参数。";
}
}
sealed class TeachingScript
{
public string StandardId { get; set; } = "";
public string Title { get; set; } = "";
public string Role { get; set; } = "";
public string Summary { get; set; } = "";
public List<TeachingScriptStep> Steps { get; set; } = new();
}
sealed class TeachingScriptStep
{
public int Order { get; set; }
public string Skill { get; set; } = "";
public string SourceFeature { get; set; } = "";
public string Title { get; set; } = "";
public string VoiceText { get; set; } = "";
public string TeachingIntent { get; set; } = "";
public string SolidWorksCommandHint { get; set; } = "";
public string CommonMistake { get; set; } = "";
public Dictionary<string, object?> KeyParameters { get; set; } = new();
}
sealed class TeachingIntroductionBuilder
{
readonly AppPaths _paths;
public TeachingIntroductionBuilder(AppPaths paths)
{
_paths = paths;
}
public TeachingIntroduction Build(StandardAnswer standard)
{
var partName = standard.Title;
var standardPartPath = ResolveStandardPartPath(partName);
var displayPlanPath = ResolveDisplayPlanPath(partName, standard.SourcePath);
var planFacts = ExtractPlanFacts(displayPlanPath);
var frameworkFacts = ExtractFrameworkFacts(partName);
var role = DescribeRole(partName, frameworkFacts);
var features = DescribeFeatures(partName, planFacts);
var modelingFocus = DescribeModelingFocus(standard, planFacts);
var sourceNotes = new List<string>();
if (!string.IsNullOrWhiteSpace(frameworkFacts.CategoryName))
sourceNotes.Add($"知识库三层框架:零件分类={frameworkFacts.CategoryName}");
if (frameworkFacts.BomNames.Count > 0)
sourceNotes.Add($"BOM 关联零件:{string.Join("", frameworkFacts.BomNames.Take(5))}");
if (!string.IsNullOrWhiteSpace(displayPlanPath))
sourceNotes.Add($"导入讲解依据:{Path.GetFileName(displayPlanPath)}");
if (!string.IsNullOrWhiteSpace(standardPartPath))
sourceNotes.Add($"展示模型:{Path.GetFileName(standardPartPath)}");
var highlightSummary = planFacts.HighlightTargets.Count > 0
? $"知识库识别出 {planFacts.HighlightTargets.Count} 个可讲解的功能面,我会按顺序边高亮边讲。"
: "当前知识库还没有稳定的功能面语义,我会先按零件整体讲解。";
var voice = $"先认识标准零件:{partName}。{highlightSummary}";
return new TeachingIntroduction
{
StandardId = standard.StandardId,
Title = partName,
StandardPartPath = standardPartPath,
VoiceText = voice,
Overview = $"{partName} 是二级齿轮减速器课程中的关键传动零件,本课不是直接背步骤,而是先理解零件在装配体中的功能,再学习为什么要采用这些建模特征。",
ReducerPosition = role,
FunctionalRole = BuildFunctionalRole(partName, frameworkFacts),
KeyFeatures = features,
ModelingFocus = modelingFocus,
SourceNotes = sourceNotes,
KnowledgeSnippets = BuildKnowledgeSnippets(frameworkFacts, planFacts),
HighlightTargets = planFacts.HighlightTargets,
KnowledgeCoverage = BuildKnowledgeCoverage(frameworkFacts, planFacts, standardPartPath),
};
}
string ResolveStandardPartPath(string partName)
{
if (string.IsNullOrWhiteSpace(partName) || !Directory.Exists(_paths.ReducerModelDir))
return "";
var exact = Path.Combine(_paths.ReducerModelDir, partName + ".SLDPRT");
if (File.Exists(exact))
return exact;
return Directory.EnumerateFiles(_paths.ReducerModelDir, "*.sldprt", SearchOption.TopDirectoryOnly)
.Concat(Directory.EnumerateFiles(_paths.ReducerModelDir, "*.SLDPRT", SearchOption.TopDirectoryOnly))
.FirstOrDefault(path => Path.GetFileNameWithoutExtension(path).Contains(partName, StringComparison.OrdinalIgnoreCase)) ?? "";
}
string ResolveDisplayPlanPath(string partName, string fallback)
{
if (!string.IsNullOrWhiteSpace(partName) && Directory.Exists(_paths.ReducerModelDir))
{
var exact = Path.Combine(_paths.ReducerModelDir, partName + "_modeling_plan.json");
if (File.Exists(exact))
return exact;
}
return fallback;
}
FrameworkFacts ExtractFrameworkFacts(string partName)
{
var facts = new FrameworkFacts();
if (!File.Exists(_paths.ReducerFrameworkPath))
return facts;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(_paths.ReducerFrameworkPath));
if (doc.RootElement.TryGetProperty("function_implementation_library", out var implementation))
{
facts.FunctionModule = ReadString(implementation, "function_module");
facts.Implementation = ReadString(implementation, "implementation");
}
if (!doc.RootElement.TryGetProperty("part_library", out var partLibrary) || partLibrary.ValueKind != JsonValueKind.Array)
return facts;
foreach (var category in partLibrary.EnumerateArray())
{
var categoryText = category.GetRawText();
if (!categoryText.Contains(partName, StringComparison.OrdinalIgnoreCase) &&
!categoryText.Contains("高速齿轮轴", StringComparison.OrdinalIgnoreCase))
continue;
facts.CategoryName = ReadString(category, "category_name");
if (category.TryGetProperty("bom_parts", out var bomParts) && bomParts.ValueKind == JsonValueKind.Array)
{
foreach (var part in bomParts.EnumerateArray())
{
var name = ReadString(part, "part_name");
if (!string.IsNullOrWhiteSpace(name))
facts.BomNames.Add(name);
}
}
if (category.TryGetProperty("design_knowledge_candidates", out var candidates) && candidates.ValueKind == JsonValueKind.Array)
{
foreach (var candidate in candidates.EnumerateArray().Take(8))
{
var title = ReadString(candidate, "title");
if (!string.IsNullOrWhiteSpace(title))
facts.DesignCandidateTitles.Add(title);
}
}
break;
}
}
catch
{
}
return facts;
}
static PlanFacts ExtractPlanFacts(string sourcePath)
{
var facts = new PlanFacts();
if (string.IsNullOrWhiteSpace(sourcePath) || !File.Exists(sourcePath))
return facts;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(sourcePath));
facts.PartPath = ReadString(doc.RootElement, "partPath");
if (doc.RootElement.TryGetProperty("sourceAssembly", out var sourceAssembly))
facts.SourceAssembly = ReadString(sourceAssembly, "name");
if (!doc.RootElement.TryGetProperty("modelingPlan", out var modelingPlan) ||
!modelingPlan.TryGetProperty("steps", out var steps) ||
steps.ValueKind != JsonValueKind.Array)
return facts;
foreach (var step in steps.EnumerateArray())
{
var skill = ReadString(step, "skillName");
if (string.IsNullOrWhiteSpace(skill))
continue;
facts.SkillCounts[skill] = facts.SkillCounts.TryGetValue(skill, out var count) ? count + 1 : 1;
if (!step.TryGetProperty("arguments", out var args) || args.ValueKind != JsonValueKind.Object)
continue;
if (args.TryGetProperty("diameter_mm", out var diameter) && diameter.TryGetDouble(out var diameterValue))
facts.DiametersMm.Add(Math.Round(diameterValue, 3));
if (args.TryGetProperty("depth_mm", out var depth) && depth.TryGetDouble(out var depthValue))
facts.DepthMm.Add(Math.Round(depthValue, 3));
if (args.TryGetProperty("count", out var patternCount) && patternCount.TryGetInt32(out var countValue))
facts.PatternCounts.Add(countValue);
}
if (doc.RootElement.TryGetProperty("functionalFaces", out var faces) && faces.ValueKind == JsonValueKind.Array)
{
foreach (var face in faces.EnumerateArray())
{
var role = ReadString(face, "face_role");
if (string.IsNullOrWhiteSpace(role))
continue;
var surfaceType = ReadString(face, "surface_type");
var sourceFeature = ReadString(face, "solidworks_source_feature");
var target = new HighlightTarget
{
Id = $"face-{facts.HighlightTargets.Count + 1:000}",
Kind = "functional_face",
Role = role,
Label = TranslateFaceRole(role),
SurfaceType = TranslateSurfaceType(surfaceType),
RawSurfaceType = surfaceType,
SourceFeature = sourceFeature,
HighlightStatus = "knowledge_ready_api_pending",
Description = DescribeFaceRole(role, surfaceType, sourceFeature),
RadiusMm = ReadDouble(face, "radius_mm"),
DiameterMm = ReadDouble(face, "diameter_mm"),
AreaMm2 = ReadDouble(face, "area_mm2"),
CenterMm = ReadDoubleArray(face, "center_mm"),
Axis = ReadDoubleArray(face, "axis"),
Normal = ReadDoubleArray(face, "normal"),
BBoxMm = ReadDoubleArray(face, "bbox_mm"),
};
facts.HighlightTargets.Add(target);
}
}
}
catch
{
}
return facts;
}
static string TranslateFaceRole(string role)
{
return role switch
{
"bearing_mount_face" => "轴承安装圆柱面",
"oil_ring_mount_face" => "挡油环安装圆柱面",
"oil_ring_axial_stop_face" => "挡油环轴向止挡面",
"distance_mate_face" => "距离配合基准面",
"cylindrical_mate_face" => "圆柱配合面",
"keyway_bottom_contact_face" => "键槽底部接触面",
"keyway_side_contact_face" => "键槽侧向接触面",
"keyway_end_arc_face" => "键槽端部圆弧面",
_ => role,
};
}
static string TranslateSurfaceType(string surfaceType)
{
return surfaceType switch
{
"cylinder" => "圆柱面",
"plane" => "平面",
"cone" => "圆锥面",
"sphere" => "球面",
_ => string.IsNullOrWhiteSpace(surfaceType) ? "未知面类型" : surfaceType,
};
}
static string DescribeFaceRole(string role, string surfaceType, string sourceFeature)
{
var label = TranslateFaceRole(role);
var surface = TranslateSurfaceType(surfaceType);
var source = string.IsNullOrWhiteSpace(sourceFeature) ? "" : $",来源特征是 {sourceFeature}";
return $"{label},面类型为{surface}{source}。";
}
static string DescribeRole(string partName, FrameworkFacts facts)
{
if (partName.Contains("齿轮1+高速轴", StringComparison.OrdinalIgnoreCase))
return "它位于减速器的高速级,是把输入端高速旋转传入齿轮传动链的齿轮轴类零件;在装配中它与轴承、端盖以及后续啮合齿轮共同确定高速级的传动位置。";
if (!string.IsNullOrWhiteSpace(facts.FunctionModule) || !string.IsNullOrWhiteSpace(facts.Implementation))
return $"它属于{facts.FunctionModule}中的{facts.Implementation}相关零件。";
return "它是二级齿轮减速器装配体中的一个非标准零件,位置和形状都服务于整机传动与装配关系。";
}
static string BuildFunctionalRole(string partName, FrameworkFacts facts)
{
if (partName.Contains("齿轮1+高速轴", StringComparison.OrdinalIgnoreCase))
return "这个零件同时承担“轴”和“小齿轮”的作用:轴段用于支承和定位,齿轮段用于参与高速级啮合传动。建模时要把回转轴体、齿轮轮廓、齿槽/螺旋线等结构作为一个连续零件来理解。";
return "这个零件的功能需要结合减速器装配关系理解:外形不是孤立几何,而是由传动、定位、支承、密封或连接需求共同决定。";
}
static string DescribeFeatures(string partName, PlanFacts facts)
{
var parts = new List<string>();
if (facts.SkillCounts.ContainsKey("extrude_boss_mm"))
parts.Add("包含凸台拉伸形成的基础实体");
if (facts.SkillCounts.ContainsKey("create_helix_mm"))
parts.Add("包含螺旋线作为齿形或扫描路径");
if (facts.SkillCounts.ContainsKey("swept_cut_profile_path") || facts.SkillCounts.ContainsKey("swept_cut_circular_profile_mm"))
parts.Add("包含扫描切除形成的齿槽或沟槽");
if (facts.SkillCounts.ContainsKey("circular_pattern_feature"))
parts.Add("包含圆周阵列形成的重复齿形结构");
if (facts.SkillCounts.ContainsKey("fillet_edges_radius_mm"))
parts.Add("包含圆角用于过渡和加工合理性");
var diameterText = facts.DiametersMm.Count > 0
? $"提取到的典型直径包括 {string.Join("", facts.DiametersMm.Distinct().Take(5).Select(v => $"{v:0.###}mm"))}"
: "";
var featureText = parts.Count == 0 ? "主要由草图、实体特征和修饰特征组合完成" : string.Join("", parts);
return string.IsNullOrWhiteSpace(diameterText) ? $"它的基本特征是:{featureText}。" : $"它的基本特征是:{featureText}{diameterText}。";
}
static string DescribeModelingFocus(StandardAnswer standard, PlanFacts facts)
{
var stepCount = standard.Steps.Count > 0 ? standard.Steps.Count : facts.SkillCounts.Values.Sum();
var focus = new List<string> { "先建立基准草图和基础实体" };
if (facts.SkillCounts.ContainsKey("create_helix_mm"))
focus.Add("再用螺旋线控制齿形路径");
if (facts.SkillCounts.ContainsKey("swept_cut_profile_path") || facts.SkillCounts.ContainsKey("swept_cut_circular_profile_mm"))
focus.Add("用扫描切除表达齿槽加工思想");
if (facts.SkillCounts.ContainsKey("circular_pattern_feature"))
focus.Add("最后用圆周阵列保证齿形均布");
focus.Add("修饰边角并保持特征树顺序清晰");
return $"本标准答案共有 {stepCount} 个建模步骤。建模重点是:{string.Join("", focus)}。";
}
static List<string> BuildKnowledgeSnippets(FrameworkFacts frameworkFacts, PlanFacts planFacts)
{
var snippets = new List<string>();
if (!string.IsNullOrWhiteSpace(frameworkFacts.FunctionModule))
snippets.Add($"功能模块:{frameworkFacts.FunctionModule}");
if (!string.IsNullOrWhiteSpace(frameworkFacts.Implementation))
snippets.Add($"实现对象:{frameworkFacts.Implementation}");
if (!string.IsNullOrWhiteSpace(frameworkFacts.CategoryName))
snippets.Add($"知识库零件分类:{frameworkFacts.CategoryName}");
if (!string.IsNullOrWhiteSpace(planFacts.SourceAssembly))
snippets.Add($"来源装配体:{planFacts.SourceAssembly}");
if (frameworkFacts.DesignCandidateTitles.Count > 0)
snippets.Add($"关联设计知识:{string.Join("", frameworkFacts.DesignCandidateTitles.Take(3))}");
return snippets;
}
static KnowledgeCoverage BuildKnowledgeCoverage(FrameworkFacts frameworkFacts, PlanFacts planFacts, string standardPartPath)
{
var missing = new List<string>();
if (string.IsNullOrWhiteSpace(standardPartPath))
missing.Add("没有找到可直接打开的标准零件文件");
if (string.IsNullOrWhiteSpace(frameworkFacts.CategoryName))
missing.Add("没有找到该零件在三层框架中的分类");
if (planFacts.HighlightTargets.Count == 0)
missing.Add("没有功能面语义,无法做面级讲解/高亮");
return new KnowledgeCoverage
{
HasStandardPart = !string.IsNullOrWhiteSpace(standardPartPath),
HasFrameworkCategory = !string.IsNullOrWhiteSpace(frameworkFacts.CategoryName),
HasFunctionalFaces = planFacts.HighlightTargets.Count > 0,
FunctionalFaceCount = planFacts.HighlightTargets.Count,
HighlightSupport = planFacts.HighlightTargets.Count > 0 ? "知识库有功能面语义;SolidWorks 面级高亮还需要按面签名验证。" : "当前只能做零件整体/特征级讲解。",
MissingItems = missing,
};
}
static string ReadString(JsonElement element, string name)
{
return element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty(name, out var value) &&
value.ValueKind != JsonValueKind.Null
? value.ToString()
: "";
}
static double? ReadDouble(JsonElement element, string name)
{
if (element.ValueKind != JsonValueKind.Object ||
!element.TryGetProperty(name, out var value) ||
value.ValueKind != JsonValueKind.Number)
return null;
return value.TryGetDouble(out var number) ? number : null;
}
static List<double> ReadDoubleArray(JsonElement element, string name)
{
if (element.ValueKind != JsonValueKind.Object ||
!element.TryGetProperty(name, out var value) ||
value.ValueKind != JsonValueKind.Array)
return new List<double>();
var result = new List<double>();
foreach (var item in value.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.Number && item.TryGetDouble(out var number))
result.Add(number);
}
return result;
}
sealed class FrameworkFacts
{
public string FunctionModule { get; set; } = "";
public string Implementation { get; set; } = "";
public string CategoryName { get; set; } = "";
public List<string> BomNames { get; } = new();
public List<string> DesignCandidateTitles { get; } = new();
}
sealed class PlanFacts
{
public string PartPath { get; set; } = "";
public string SourceAssembly { get; set; } = "";
public Dictionary<string, int> SkillCounts { get; } = new(StringComparer.OrdinalIgnoreCase);
public List<double> DiametersMm { get; } = new();
public List<double> DepthMm { get; } = new();
public List<int> PatternCounts { get; } = new();
public List<HighlightTarget> HighlightTargets { get; } = new();
}
}
sealed class TeachingIntroduction
{
public string StandardId { get; set; } = "";
public string Title { get; set; } = "";
public string StandardPartPath { get; set; } = "";
public string VoiceText { get; set; } = "";
public string Overview { get; set; } = "";
public string ReducerPosition { get; set; } = "";
public string FunctionalRole { get; set; } = "";
public string KeyFeatures { get; set; } = "";
public string ModelingFocus { get; set; } = "";
public List<string> SourceNotes { get; set; } = new();
public List<string> KnowledgeSnippets { get; set; } = new();
public List<HighlightTarget> HighlightTargets { get; set; } = new();
public KnowledgeCoverage KnowledgeCoverage { get; set; } = new();
}
sealed class HighlightTarget
{
public string Id { get; set; } = "";
public string Kind { get; set; } = "";
public string Role { get; set; } = "";
public string Label { get; set; } = "";
public string SurfaceType { get; set; } = "";
public string RawSurfaceType { get; set; } = "";
public string SourceFeature { get; set; } = "";
public string Description { get; set; } = "";
public string HighlightStatus { get; set; } = "";
public double? RadiusMm { get; set; }
public double? DiameterMm { get; set; }
public double? AreaMm2 { get; set; }
public List<double> CenterMm { get; set; } = new();
public List<double> Axis { get; set; } = new();
public List<double> Normal { get; set; } = new();
public List<double> BBoxMm { get; set; } = new();
}
sealed class KnowledgeCoverage
{
public bool HasStandardPart { get; set; }
public bool HasFrameworkCategory { get; set; }
public bool HasFunctionalFaces { get; set; }
public int FunctionalFaceCount { get; set; }
public string HighlightSupport { get; set; } = "";
public List<string> MissingItems { get; set; } = new();
}
sealed record HighlightResult(
bool Ok,
string Level,
string MatchedName,
string Message,
double Score,
string VoiceText);
sealed class FeatureGrader
{
readonly Dictionary<string, string[]> _coreArguments = new(StringComparer.OrdinalIgnoreCase)
{
["extrude_boss_mm"] = new[] { "depth_mm", "end_condition_code", "through_all" },
["extrude_cut_mm"] = new[] { "depth_mm", "end_condition_code", "through_all" },
["shell_remove_face_at_point_mm"] = new[] { "thickness_mm" },
["draw_circle_diameter_mm"] = new[] { "diameter_mm", "center_model_mm", "cx_mm", "cy_mm" },
["fillet_edges_radius_mm"] = new[] { "radius_mm" },
["chamfer_edges_angle_distance_mm"] = new[] { "distance_mm", "angle_deg" },
["linear_pattern_feature_mm"] = new[] { "count1", "spacing1_mm" },
["circular_pattern_feature"] = new[] { "count", "angle_deg" },
};
public StepCompareResult CompareStep(LessonStep expected, SkillStep? observed)
{
if (observed == null)
{
return new StepCompareResult
{
Ok = false,
Score = 0,
ExpectedStep = expected,
FeedbackText = $"没有检测到本步骤的操作。请执行:{expected.Title}。",
Issues = new List<CompareIssue>
{
new()
{
Code = "missing_operation",
Message = $"Expected {expected.ExpectedSkill}, but no user feature was observed.",
Expected = new Dictionary<string, object?> { ["skill"] = expected.ExpectedSkill },
},
},
};
}
var issues = new List<CompareIssue>();
if (!string.Equals(expected.ExpectedSkill, observed.Skill, StringComparison.OrdinalIgnoreCase))
{
issues.Add(new CompareIssue
{
Code = "wrong_feature_method",
Message = $"方法错误:标准答案要求 {expected.ExpectedSkill},学生实际使用 {observed.Skill}。",
Expected = new Dictionary<string, object?> { ["skill"] = expected.ExpectedSkill, ["title"] = expected.Title },
Actual = new Dictionary<string, object?> { ["skill"] = observed.Skill, ["name"] = observed.Name, ["source_feature"] = observed.SourceFeature },
});
}
issues.AddRange(CompareCoreArguments(expected.ExpectedSkill, expected.ExpectedArguments, observed.Arguments));
var ok = issues.Count == 0;
return new StepCompareResult
{
Ok = ok,
Score = ok ? 100.0 : Math.Max(0.0, 60.0 - 20.0 * issues.Count),
ExpectedStep = expected,
ObservedStep = observed,
Issues = issues,
FeedbackText = ok
? $"正确。你使用了本步骤要求的 {expected.Title},可以进入下一步。"
: $"{issues[0].Message} 本步骤要求:{expected.VoiceText}",
};
}
public GradeReport GradeSequence(List<LessonStep> expected, List<SkillStep> observed)
{
var issues = new List<CompareIssue>();
var passed = 0;
for (var i = 0; i < expected.Count; i++)
{
var result = CompareStep(expected[i], i < observed.Count ? observed[i] : null);
if (result.Ok)
passed++;
issues.AddRange(result.Issues);
}
var score = expected.Count == 0 ? 100.0 : Math.Round((double)passed / expected.Count * 100.0, 2);
return new GradeReport
{
Ok = score >= 90.0 && issues.All(issue => issue.Severity != "error"),
Score = score,
PassedSteps = passed,
TotalSteps = expected.Count,
Issues = issues,
Summary = $"Passed {passed}/{expected.Count} feature-method steps.",
};
}
IEnumerable<CompareIssue> CompareCoreArguments(string skill, Dictionary<string, object?> expected, Dictionary<string, object?> actual)
{
if (!_coreArguments.TryGetValue(skill, out var keys))
yield break;
foreach (var key in keys)
{
if (!expected.ContainsKey(key))
continue;
if (!actual.ContainsKey(key))
{
yield return new CompareIssue
{
Code = "missing_parameter",
Message = $"参数缺失:{key}。",
Expected = new Dictionary<string, object?> { [key] = expected[key] },
};
continue;
}
if (!ValuesClose(expected[key], actual[key]))
{
yield return new CompareIssue
{
Code = "wrong_parameter",
Message = $"参数错误:{key} 标准值为 {expected[key]},实际值为 {actual[key]}。",
Expected = new Dictionary<string, object?> { [key] = expected[key] },
Actual = new Dictionary<string, object?> { [key] = actual[key] },
};
}
}
}
static bool ValuesClose(object? expected, object? actual, double tolerance = 0.05)
{
if (TryDouble(expected, out var expectedDouble) && TryDouble(actual, out var actualDouble))
return Math.Abs(expectedDouble - actualDouble) <= tolerance;
if (expected is List<object?> expectedList && actual is List<object?> actualList && expectedList.Count == actualList.Count)
return expectedList.Zip(actualList).All(pair => ValuesClose(pair.First, pair.Second, tolerance));
return Convert.ToString(expected) == Convert.ToString(actual);
}
static bool TryDouble(object? value, out double result)
{
result = 0;
return value switch
{
double d => Set(out result, d),
float f => Set(out result, f),
int i => Set(out result, i),
long l => Set(out result, l),
decimal m => Set(out result, (double)m),
string s => double.TryParse(s, out result),
_ => false,
};
}
static bool Set(out double target, double value)
{
target = value;
return true;
}
}
sealed class SessionStore
{
readonly ConcurrentDictionary<string, LearningSession> _items = new();
public LearningSession Start(string mode, StandardAnswer standard, LessonBuilder lessonBuilder)
{
var session = new LearningSession
{
SessionId = "sess-" + Guid.NewGuid().ToString("N")[..10],
Mode = mode,
StandardId = standard.StandardId,
Title = standard.Title,
CreatedAt = DateTime.Now,
LessonSteps = lessonBuilder.Build(standard),
};
_items[session.SessionId] = session;
return session;
}
public bool TryGet(string sessionId, out LearningSession session) => _items.TryGetValue(sessionId, out session!);
public StepCompareResult SubmitTeachingObservation(LearningSession session, List<SkillStep> observed, FeatureGrader grader)
{
session.ObservedSteps.AddRange(observed);
if (session.CurrentStepIndex >= session.LessonSteps.Count)
{
session.Status = "completed";
var completed = new StepCompareResult { Ok = true, Score = 100, FeedbackText = "教学步骤已经完成。" };
session.Reports.Add(completed);
return completed;
}
var expected = session.LessonSteps[session.CurrentStepIndex];
var actual = observed.LastOrDefault();
var result = grader.CompareStep(expected, actual);
session.Reports.Add(result);
if (result.Ok)
{
session.CurrentStepIndex++;
if (session.CurrentStepIndex >= session.LessonSteps.Count)
session.Status = "completed";
}
return result;
}
public StepCompareResult PollTeachingObservation(LearningSession session, List<SkillStep> observed, FeatureGrader grader)
{
if (session.CurrentStepIndex >= session.LessonSteps.Count)
{
session.Status = "completed";
return new StepCompareResult { Ok = true, Score = 100, FeedbackText = "教学步骤已经完成。" };
}
var expected = session.LessonSteps[session.CurrentStepIndex];
var actual = observed.LastOrDefault();
var result = grader.CompareStep(expected, actual);
if (!result.Ok)
return result;
session.ObservedSteps.AddRange(observed);
session.Reports.Add(result);
session.CurrentStepIndex++;
if (session.CurrentStepIndex >= session.LessonSteps.Count)
session.Status = "completed";
return result;
}
public void AcceptCurrentStepFromLiveState(LearningSession session, StepCompareResult result)
{
session.Reports.Add(result);
session.CurrentStepIndex++;
if (session.CurrentStepIndex >= session.LessonSteps.Count)
session.Status = "completed";
}
public StepCompareResult? CheckActiveDocumentForCurrentStep(LearningSession session, SolidWorksDocumentStatus documentStatus)
{
if (session.CurrentStepIndex >= session.LessonSteps.Count)
return null;
var expected = session.LessonSteps[session.CurrentStepIndex];
var expectsPartModeling = session.StandardId.StartsWith("part-", StringComparison.OrdinalIgnoreCase);
if (!expectsPartModeling)
return null;
if (!documentStatus.Connected)
return WrongDocumentResult(expected, "没有连接到 SolidWorks。请先启动 SolidWorks,然后按照语音提示继续。", documentStatus);
if (!documentStatus.HasDocument)
return WrongDocumentResult(expected, "还没有检测到打开的 SolidWorks 文档。当前步骤需要新建零件,请点击新建并选择零件。", documentStatus);
if (documentStatus.TypeName == "part")
{
var sketchResult = CheckActiveSketchForCurrentStep(expected, documentStatus);
if (sketchResult != null)
return sketchResult;
var liveCircleResult = CheckActiveSketchCircleForCurrentStep(expected, documentStatus);
if (liveCircleResult != null)
return liveCircleResult;
var exitSketchResult = CheckExitSketchForCurrentStep(expected, documentStatus);
if (exitSketchResult != null)
return exitSketchResult;
return null;
}
var message = documentStatus.TypeName switch
{
"assembly" => "你点错了。当前打开的是装配体,但本步骤要求新建零件。请关闭或切回正确窗口,点击新建,然后选择零件,不要选择装配体。",
"drawing" => "你点错了。当前打开的是工程图,但本步骤要求新建零件。请点击新建,然后选择零件。",
_ => $"当前活动文档类型不是零件,检测到的是 {documentStatus.TypeName}。本步骤要求在零件环境中操作。",
};
return WrongDocumentResult(expected, message, documentStatus);
}
static StepCompareResult? CheckActiveSketchForCurrentStep(LessonStep expected, SolidWorksDocumentStatus documentStatus)
{
var expectedReference = expected.ExpectedSkill switch
{
"create_front_plane_sketch" => "front",
"create_top_plane_sketch" => "top",
"create_right_plane_sketch" => "right",
_ => "",
};
if (string.IsNullOrWhiteSpace(expectedReference))
return null;
if (!documentStatus.ActiveSketch)
return WrongDocumentResult(expected, $"当前还没有进入草图编辑。请在 SolidWorks 中选择 {PlaneDisplayName(expectedReference)},然后点击草图。", documentStatus);
if (SketchReferenceMatches(documentStatus.ActiveSketchReferenceName, expectedReference))
{
return new StepCompareResult
{
Ok = true,
Score = 100,
ExpectedStep = expected,
FeedbackText = $"正确。你已经在 {PlaneDisplayName(expectedReference)} 进入草图,可以继续下一步。",
};
}
return WrongDocumentResult(
expected,
$"你进入了草图,但草图基准面不对。当前检测到的是 {DisplayOrUnknown(documentStatus.ActiveSketchReferenceName)},本步骤要求选择 {PlaneDisplayName(expectedReference)}。",
documentStatus);
}
static StepCompareResult? CheckActiveSketchCircleForCurrentStep(LessonStep expected, SolidWorksDocumentStatus documentStatus)
{
if (!string.Equals(expected.ExpectedSkill, "draw_circle_diameter_mm", StringComparison.OrdinalIgnoreCase))
return null;
if (!documentStatus.ActiveSketch)
return WrongLiveSketchResult(expected, "当前已经不在草图编辑状态。请重新进入当前草图,系统会实时检测圆和直径尺寸。", documentStatus, "not_editing_sketch");
var circles = documentStatus.ActiveSketchCircles.Where(circle => circle.IsFullCircle).ToList();
if (circles.Count == 0)
return WrongLiveSketchResult(expected, "还没有检测到完整圆。请在当前草图中使用圆命令绘制圆,不要用圆弧或样条替代。", documentStatus, "missing_circle");
if (!TryGetExpectedDouble(expected.ExpectedArguments, "diameter_mm", out var expectedDiameterMm))
{
var firstCircle = circles[0];
return CorrectLiveSketchResult(expected, firstCircle, "正确。已经检测到完整圆,可以继续下一步。");
}
var toleranceMm = Math.Max(0.05, expectedDiameterMm * 0.005);
var matchingCircle = circles
.OrderBy(circle => Math.Abs(circle.DiameterMm - expectedDiameterMm))
.FirstOrDefault(circle => Math.Abs(circle.DiameterMm - expectedDiameterMm) <= toleranceMm);
if (matchingCircle == null)
{
var detected = string.Join("、", circles.Select(circle => $"{circle.DiameterMm:0.###}mm"));
return WrongLiveSketchResult(
expected,
$"检测到圆,但直径不对。当前圆直径为 {detected},本步骤要求直径 {expectedDiameterMm:0.###}mm。请用智能尺寸把圆标注并修改到标准值。",
documentStatus,
"wrong_circle_diameter");
}
var hasMatchingDimension = documentStatus.ActiveSketchDimensions.Any(dimension => Math.Abs(dimension.ValueMm - expectedDiameterMm) <= toleranceMm);
var message = hasMatchingDimension
? $"正确。已经检测到直径 {expectedDiameterMm:0.###}mm 的圆和尺寸标注,可以退出草图。"
: $"正确。已经检测到直径 {expectedDiameterMm:0.###}mm 的圆,可以退出草图。";
return CorrectLiveSketchResult(expected, matchingCircle, message);
}
static StepCompareResult? CheckExitSketchForCurrentStep(LessonStep expected, SolidWorksDocumentStatus documentStatus)
{
if (!string.Equals(expected.ExpectedSkill, "exit_sketch", StringComparison.OrdinalIgnoreCase))
return null;
if (documentStatus.ActiveSketch)
return WrongLiveSketchResult(expected, "圆已经完成。现在请点击左上角“退出草图”,让草图进入后续特征建模流程。", documentStatus, "still_editing_sketch");
return new StepCompareResult
{
Ok = true,
Score = 100,
ExpectedStep = expected,
ObservedStep = new SkillStep
{
Skill = "exit_sketch",
Name = expected.Title,
Description = "已退出草图编辑",
},
FeedbackText = "正确。已经退出草图,可以继续下一步特征操作。",
};
}
static StepCompareResult CorrectLiveSketchResult(LessonStep expected, SketchCircleInfo circle, string message)
{
return new StepCompareResult
{
Ok = true,
Score = 100,
ExpectedStep = expected,
ObservedStep = new SkillStep
{
Skill = "draw_circle_diameter_mm",
Name = expected.Title,
Description = "活动草图实时检测到圆",
Arguments = new Dictionary<string, object?>
{
["diameter_mm"] = circle.DiameterMm,
["cx_mm"] = circle.CenterXLocalMm,
["cy_mm"] = circle.CenterYLocalMm,
},
},
FeedbackText = message,
};
}
static StepCompareResult WrongLiveSketchResult(LessonStep expected, string message, SolidWorksDocumentStatus documentStatus, string code)
{
return new StepCompareResult
{
Ok = false,
Score = 0,
ExpectedStep = expected,
FeedbackText = message,
Issues = new List<CompareIssue>
{
new()
{
Code = code,
Message = message,
Expected = new Dictionary<string, object?>
{
["skill"] = expected.ExpectedSkill,
["arguments"] = expected.ExpectedArguments,
},
Actual = new Dictionary<string, object?>
{
["active_sketch"] = documentStatus.ActiveSketch,
["circles"] = documentStatus.ActiveSketchCircles,
["dimensions"] = documentStatus.ActiveSketchDimensions,
},
},
},
};
}
static bool TryGetExpectedDouble(Dictionary<string, object?> args, string key, out double value)
{
value = 0;
if (!args.TryGetValue(key, out var raw) || raw == null)
return false;
try
{
if (raw is JsonElement element)
{
if (element.ValueKind == JsonValueKind.Number && element.TryGetDouble(out value))
return true;
if (element.ValueKind == JsonValueKind.String && double.TryParse(element.GetString(), out value))
return true;
return false;
}
value = Convert.ToDouble(raw);
return true;
}
catch
{
return raw is string text && double.TryParse(text, out value);
}
}
static bool SketchReferenceMatches(string referenceName, string expectedReference)
{
var text = referenceName.ToLowerInvariant();
return expectedReference switch
{
"front" => text.Contains("front") || referenceName.Contains("前视"),
"top" => text.Contains("top") || referenceName.Contains("上视"),
"right" => text.Contains("right") || referenceName.Contains("右视"),
_ => false,
};
}
static string PlaneDisplayName(string reference)
{
return reference switch
{
"front" => "前视基准面",
"top" => "上视基准面",
"right" => "右视基准面",
_ => "指定基准面",
};
}
static string DisplayOrUnknown(string value) => string.IsNullOrWhiteSpace(value) ? "未识别的基准面" : value;
static StepCompareResult WrongDocumentResult(LessonStep expected, string message, SolidWorksDocumentStatus documentStatus)
{
return new StepCompareResult
{
Ok = false,
Score = 0,
ExpectedStep = expected,
FeedbackText = message,
Issues = new List<CompareIssue>
{
new()
{
Code = "wrong_solidworks_document_type",
Message = message,
Expected = new Dictionary<string, object?>
{
["document_type"] = "part",
["skill"] = expected.ExpectedSkill,
},
Actual = new Dictionary<string, object?>
{
["connected"] = documentStatus.Connected,
["has_document"] = documentStatus.HasDocument,
["document_type"] = documentStatus.TypeName,
["title"] = documentStatus.Title,
},
},
},
};
}
public GradeReport SubmitTest(LearningSession session, List<SkillStep> observed, FeatureGrader grader)
{
session.ObservedSteps = observed;
var report = grader.GradeSequence(session.LessonSteps, observed);
session.FinalReport = report;
session.Status = "completed";
return report;
}
}
sealed class SolidWorksExtractor
{
readonly AppPaths _paths;
public SolidWorksExtractor(AppPaths paths)
{
_paths = paths;
}
public Task<List<SkillStep>> AuditActivePartAsync()
{
var outputDir = Path.Combine(_paths.RuntimeDir, "audits", "active-part");
Directory.CreateDirectory(outputDir);
return RunPartAuditAsync(new[] { "--active", "--output-dir", outputDir }, outputDir);
}
public Task<List<SkillStep>> AuditPartAsync(string partPath)
{
if (!File.Exists(partPath))
throw new FileNotFoundException(partPath);
var outputDir = Path.Combine(_paths.RuntimeDir, "audits", Path.GetFileNameWithoutExtension(partPath));
Directory.CreateDirectory(outputDir);
return RunPartAuditAsync(new[] { partPath, "--output-dir", outputDir }, outputDir);
}
async Task<List<SkillStep>> RunPartAuditAsync(string[] args, string outputDir)
{
var startInfo = new ProcessStartInfo
{
FileName = "dotnet",
WorkingDirectory = _paths.Agent4Root,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
startInfo.ArgumentList.Add("run");
startInfo.ArgumentList.Add("--project");
startInfo.ArgumentList.Add(_paths.PartFeatureAuditProject);
startInfo.ArgumentList.Add("--");
foreach (var arg in args)
startInfo.ArgumentList.Add(arg);
using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start PartFeatureAudit.");
var stdout = await process.StandardOutput.ReadToEndAsync();
var stderr = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
throw new InvalidOperationException($"PartFeatureAudit failed. exit={process.ExitCode}\n{stdout}\n{stderr}");
return ReadLatestSteps(outputDir);
}
static List<SkillStep> ReadLatestSteps(string outputDir)
{
var candidates = Directory.EnumerateFiles(outputDir, "*_modeling_plan.json")
.Concat(Directory.EnumerateFiles(outputDir, "*_skill_flow.json"))
.Select(path => new FileInfo(path))
.OrderByDescending(file => file.LastWriteTimeUtc)
.ToList();
if (candidates.Count == 0)
throw new FileNotFoundException($"No audit JSON generated under {outputDir}");
using var doc = JsonDocument.Parse(File.ReadAllText(candidates[0].FullName));
return SkillFlow.NormalizeSteps(doc.RootElement);
}
}
sealed class StartSessionRequest
{
public string StandardId { get; set; } = "";
}
sealed class ObservationRequest
{
public List<Dictionary<string, object?>> Steps { get; set; } = new();
}
sealed class AuditPartRequest
{
public string PartPath { get; set; } = "";
}
sealed class SubmitTestRequest
{
public List<Dictionary<string, object?>> Steps { get; set; } = new();
public string PartPath { get; set; } = "";
}
sealed class SkillStep
{
public string Id { get; set; } = "";
public int Order { get; set; }
public string Skill { get; set; } = "";
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public Dictionary<string, object?> Arguments { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public string SourceFeature { get; set; } = "";
public string Note { get; set; } = "";
}
sealed class StandardAnswer
{
public string StandardId { get; set; } = "";
public string Title { get; set; } = "";
public string Kind { get; set; } = "unknown";
public string SourcePath { get; set; } = "";
public string SourceType { get; set; } = "skillflow";
public List<string> Tags { get; set; } = new();
public string Summary { get; set; } = "";
public List<SkillStep> Steps { get; set; } = new();
}
sealed class LessonStep
{
public string StepId { get; set; } = "";
public int Order { get; set; }
public string Title { get; set; } = "";
public string VoiceText { get; set; } = "";
public string ExpectedSkill { get; set; } = "";
public Dictionary<string, object?> ExpectedArguments { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public string SourceFeature { get; set; } = "";
public string TeachingIntent { get; set; } = "";
public string CommonMistake { get; set; } = "";
public Dictionary<string, object?> CheckPolicy { get; set; } = new();
}
sealed class CompareIssue
{
public string Severity { get; set; } = "error";
public string Code { get; set; } = "";
public string Message { get; set; } = "";
public Dictionary<string, object?> Expected { get; set; } = new();
public Dictionary<string, object?> Actual { get; set; } = new();
}
sealed class StepCompareResult
{
public bool Ok { get; set; }
public double Score { get; set; }
public LessonStep? ExpectedStep { get; set; }
public SkillStep? ObservedStep { get; set; }
public List<CompareIssue> Issues { get; set; } = new();
public string FeedbackText { get; set; } = "";
}
sealed class GradeReport
{
public bool Ok { get; set; }
public double Score { get; set; }
public double MaxScore { get; set; } = 100.0;
public int PassedSteps { get; set; }
public int TotalSteps { get; set; }
public List<CompareIssue> Issues { get; set; } = new();
public string Summary { get; set; } = "";
}
sealed class LearningSession
{
public string SessionId { get; set; } = "";
public string Mode { get; set; } = "";
public string StandardId { get; set; } = "";
public string Title { get; set; } = "";
public int CurrentStepIndex { get; set; }
public DateTime CreatedAt { get; set; }
public List<LessonStep> LessonSteps { get; set; } = new();
public List<SkillStep> ObservedSteps { get; set; } = new();
public List<StepCompareResult> Reports { get; set; } = new();
public GradeReport? FinalReport { get; set; }
public string Status { get; set; } = "active";
}