包含: - furniture-3d-system:Next.js 16 全栈应用(资产中心、产品管理、3D装配动画) - autocad-plugin:AutoCAD 家具导出插件源码 - 资产中心改造:编辑弹窗化、删除功能、Toast反馈、自定义Select组件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
251 lines
11 KiB
C#
251 lines
11 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
|
||
namespace FurnitureExporter;
|
||
|
||
/// <summary>
|
||
/// 紧固件推断器(v2.3)
|
||
///
|
||
/// 目标:
|
||
/// - 不增加设计师操作:导出时自动给出“更像哪种螺丝直径”的候选(Top1/Top2);
|
||
/// - 避免“4/5/6 框死”的硬匹配:采用区间+评分+先验权重;
|
||
/// - 先验来自五金标准库(此处先用表格统计的出现频次做近似权重)。
|
||
///
|
||
/// 注意:
|
||
/// - 孔的几何通常无法唯一确定螺丝长度,因此这里输出的是“直径候选 + 常见规格示例”;
|
||
/// - 后续如果导出能拿到更多特征(例如沉孔/沉头、预埋件类型、板厚/孔深约束),再逐步提高确定性。
|
||
/// </summary>
|
||
public static class FastenerGuesser
|
||
{
|
||
/// <summary>
|
||
/// 内置“常见螺丝规格库(示例)”
|
||
/// 来自:截图/新配件明细表-2023815.xlsx 的条目统计(出现次数,不代表实际用量)
|
||
/// 说明:
|
||
/// - 这里只保留常见直径段(6/4/5/8/3.5)和高频长度,避免导出 JSON 过大;
|
||
/// - 如果你们后续要做成可配置,可把这里迁移成随插件发布的 JSON 配置。
|
||
/// </summary>
|
||
private static readonly IReadOnlyDictionary<double, (double PriorWeight, string[] CommonSpecs)> ScrewCatalog =
|
||
new Dictionary<double, (double, string[])>
|
||
{
|
||
// 先验权重近似取:该直径在表格出现次数 / 总出现次数(只统计能解析规格的螺丝项)
|
||
// 6mm:58, 4mm:30, 5mm:21, 8mm:15, 3.5mm:14 => 总计 138(其余直径更分散,这里先忽略)
|
||
[6.0] = (58.0 / 138.0, new[] { "6*15", "6*20", "6*35", "6*12", "6*23", "6*25", "6*28", "6*45", "6*60", "6*65" }),
|
||
[4.0] = (30.0 / 138.0, new[] { "4*25", "4*10", "4*16", "4*20", "4*30", "4*14" }),
|
||
[5.0] = (21.0 / 138.0, new[] { "5*40", "5*45", "5*50", "5*60", "5*30", "5*70" }),
|
||
[8.0] = (15.0 / 138.0, new[] { "8*40", "8*10", "8*15", "8*25", "8*30" }),
|
||
[3.5] = (14.0 / 138.0, new[] { "3.5*12", "3.5*8", "3.5*20" }),
|
||
};
|
||
|
||
// 经验参数(可后续抽到配置里)
|
||
// v2.3.2:通孔“间隙孔”的区间不要设太宽。
|
||
// 之前 0.2~1.2 会导致 4.8mm 孔被错误解释为 M4 通孔(实际上更常见是 M6 的导孔/预钻孔,或 5mm 层板托孔体系)。
|
||
// 这里把上限收紧,避免“过宽区间”吞掉大量 4.8mm 的孔。
|
||
private const double ThroughClearanceMin = 0.1; // 通孔间隙下限(mm)
|
||
private const double ThroughClearanceMax = 0.6; // 通孔间隙上限(mm)
|
||
private const double PilotRatioMin = 0.65; // 盲孔导孔直径 = 螺丝直径 * ratio
|
||
private const double PilotRatioMax = 0.85;
|
||
|
||
/// <summary>
|
||
/// 给单个孔位生成螺丝候选(Top N)
|
||
/// </summary>
|
||
public static List<FastenerSuggestion> SuggestForHole(HoleInfo hole, int topN = 2)
|
||
{
|
||
// v2.2.3:让 suggestedFasteners 覆盖更多“非螺丝孔”(例如木榫/过线孔),
|
||
// 这样导出数据更完整,也更贴近用户“我数了多少孔就应该有多少条建议/识别结果”的直觉。
|
||
// 这里仍然保持“螺丝候选”只在螺丝孔上输出多候选;对木榫/过线孔输出确定性建议(confidence=1)。
|
||
var usage = (hole.InferredUsage ?? "").Trim();
|
||
if (usage.StartsWith("dowel_", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var dia = ParseDowelDiameter(usage);
|
||
if (dia > 0)
|
||
{
|
||
return new List<FastenerSuggestion>
|
||
{
|
||
new FastenerSuggestion
|
||
{
|
||
Category = "dowel",
|
||
NominalDiameter = dia,
|
||
Confidence = 1.0,
|
||
Reason = $"用途推断为 {usage},按孔径 {hole.Diameter:F1}mm 输出木榫建议"
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
if (usage.Equals("cable", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return new List<FastenerSuggestion>
|
||
{
|
||
new FastenerSuggestion
|
||
{
|
||
Category = "cable_hole",
|
||
NominalDiameter = Math.Round(hole.Diameter, 2),
|
||
Confidence = 1.0,
|
||
Reason = $"用途推断为过线孔(孔径 {hole.Diameter:F1}mm)"
|
||
}
|
||
};
|
||
}
|
||
|
||
if (usage.Equals("hinge", StringComparison.OrdinalIgnoreCase) || usage.StartsWith("connector", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return new List<FastenerSuggestion>
|
||
{
|
||
new FastenerSuggestion
|
||
{
|
||
Category = usage,
|
||
NominalDiameter = Math.Round(hole.Diameter, 2),
|
||
Confidence = 1.0,
|
||
Reason = $"用途推断为 {usage}(孔径 {hole.Diameter:F1}mm),不输出螺丝候选"
|
||
}
|
||
};
|
||
}
|
||
|
||
// 只在“看起来像螺丝相关”的孔上给建议:
|
||
// - 连接件(偏心轮孔 15mm、木榫 8/10/6)等会污染候选
|
||
// - 这里使用直径阈值做第一层过滤(后续可结合 InferredUsage 更精细)
|
||
if (hole.Diameter >= 7.5 && hole.Diameter <= 16.5)
|
||
{
|
||
// 8/10/12/15 一带,往往是木榫/偏心件/三合一体系,先不输出螺丝候选
|
||
return new List<FastenerSuggestion>();
|
||
}
|
||
|
||
if (hole.Diameter < 2.0 || hole.Diameter > 10.0)
|
||
{
|
||
// 太小/太大都不像常规螺丝孔(大孔可能是过线孔/脚孔等)
|
||
return new List<FastenerSuggestion>();
|
||
}
|
||
|
||
var candidates = new List<(double Dia, double Score, string Reason)>();
|
||
foreach (var kvp in ScrewCatalog)
|
||
{
|
||
var dia = kvp.Key;
|
||
var prior = kvp.Value.PriorWeight;
|
||
|
||
// v2.3.1:不要把 hole.Type 当成“绝对真相”。
|
||
// 实际图纸/建模中,设计师可能把导孔也画成“贯穿”,导致 through/blind 在几何上不稳定。
|
||
// 因此这里同时计算“通孔间隙孔”和“盲孔导孔”两套评分,再根据 hole.Type 做轻量倾斜。
|
||
var scored = ScoreCandidate(hole, dia, prior);
|
||
|
||
if (scored.Score <= 0.001)
|
||
continue;
|
||
|
||
candidates.Add((dia, scored.Score, scored.Reason));
|
||
}
|
||
|
||
if (candidates.Count == 0)
|
||
return new List<FastenerSuggestion>();
|
||
|
||
// 排序 + 置信度归一化
|
||
var top = candidates
|
||
.OrderByDescending(c => c.Score)
|
||
.Take(Math.Max(1, topN))
|
||
.ToList();
|
||
|
||
var sum = top.Sum(t => t.Score);
|
||
if (sum <= 1e-9)
|
||
sum = 1;
|
||
|
||
return top.Select(t =>
|
||
{
|
||
var catalog = ScrewCatalog[t.Dia];
|
||
return new FastenerSuggestion
|
||
{
|
||
Category = "screw",
|
||
NominalDiameter = t.Dia,
|
||
CommonSpecs = catalog.CommonSpecs.ToList(),
|
||
Confidence = Math.Round(t.Score / sum, 4),
|
||
Reason = t.Reason
|
||
};
|
||
}).ToList();
|
||
}
|
||
|
||
private static double ParseDowelDiameter(string usage)
|
||
{
|
||
// 约定:InferHoleUsage 对木榫输出 dowel_6 / dowel_8 / dowel_10
|
||
var parts = usage.Split('_');
|
||
if (parts.Length != 2)
|
||
return 0;
|
||
|
||
if (double.TryParse(parts[1], out var dia))
|
||
return dia;
|
||
|
||
return 0;
|
||
}
|
||
|
||
private static (double Score, string ModeName, double Min, double Max, double Center, string Reason) ScoreCandidate(
|
||
HoleInfo hole,
|
||
double screwDia,
|
||
double prior)
|
||
{
|
||
var isThrough = string.Equals(hole.Type, "through", StringComparison.OrdinalIgnoreCase);
|
||
var isBlind = string.Equals(hole.Type, "blind", StringComparison.OrdinalIgnoreCase);
|
||
|
||
// 1) 通孔间隙孔模型
|
||
var throughMin = screwDia + ThroughClearanceMin;
|
||
var throughMax = screwDia + ThroughClearanceMax;
|
||
var throughCenter = (throughMin + throughMax) / 2.0;
|
||
var throughScore = ScoreByDistance(hole.Diameter, throughMin, throughMax, throughCenter);
|
||
// 与 hole.Type 倾斜:类型一致加权,不一致也保留一定分值(避免误分类导致“全错”)
|
||
throughScore *= isThrough ? 1.0 : 0.6;
|
||
|
||
// 2) 盲孔导孔模型
|
||
var pilotMin = screwDia * PilotRatioMin;
|
||
var pilotMax = screwDia * PilotRatioMax;
|
||
var pilotCenter = (pilotMin + pilotMax) / 2.0;
|
||
var pilotScore = ScoreByDistance(hole.Diameter, pilotMin, pilotMax, pilotCenter);
|
||
pilotScore *= isBlind ? 1.0 : 0.6;
|
||
|
||
// 选择更高的一种解释
|
||
if (pilotScore >= throughScore)
|
||
{
|
||
var score = pilotScore * (0.5 + prior); // prior 做温和加权,避免先验过度压制几何
|
||
var reason = $"盲孔导孔: 孔径 {hole.Diameter:F1}mm 匹配 {screwDia:0.#}mm(期望 {pilotMin:0.#}~{pilotMax:0.#},先验 {prior:P0})";
|
||
return (score, "盲孔导孔", pilotMin, pilotMax, pilotCenter, reason);
|
||
}
|
||
else
|
||
{
|
||
var score = throughScore * (0.5 + prior);
|
||
var reason = $"通孔间隙孔: 孔径 {hole.Diameter:F1}mm 匹配 {screwDia:0.#}mm(期望 {throughMin:0.#}~{throughMax:0.#},先验 {prior:P0})";
|
||
return (score, "通孔间隙孔", throughMin, throughMax, throughCenter, reason);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 根据“孔径离期望区间的距离”给分
|
||
/// - 在区间内:线性衰减(越靠近中心分越高)
|
||
/// - 在区间外:指数衰减(离得越远越低)
|
||
/// </summary>
|
||
private static double ScoreByDistance(double holeDia, double min, double max, double center)
|
||
{
|
||
if (max <= min)
|
||
return 0;
|
||
|
||
var halfWidth = (max - min) / 2.0;
|
||
if (halfWidth <= 1e-9)
|
||
halfWidth = 0.1;
|
||
|
||
if (holeDia >= min && holeDia <= max)
|
||
{
|
||
var d = Math.Abs(holeDia - center);
|
||
var score = 1.0 - (d / halfWidth); // center=1, edge=0
|
||
return Clamp(score, 0.0, 1.0);
|
||
}
|
||
|
||
// 区间外:按距离做快速衰减(单位 mm)
|
||
var dist = holeDia < min ? (min - holeDia) : (holeDia - max);
|
||
var decay = Math.Exp(-1.2 * dist);
|
||
return Clamp(0.25 * decay, 0.0, 0.25); // 区间外最多给 0.25,避免“硬猜”
|
||
}
|
||
|
||
/// <summary>
|
||
/// .NET Framework 4.8 没有 Math.Clamp,这里做一个简单实现。
|
||
/// </summary>
|
||
private static double Clamp(double value, double min, double max)
|
||
{
|
||
if (value < min) return min;
|
||
if (value > max) return max;
|
||
return value;
|
||
}
|
||
}
|