WuYongJie 5821d73291 初始提交:3D家具安装展示系统
包含:
- furniture-3d-system:Next.js 16 全栈应用(资产中心、产品管理、3D装配动画)
- autocad-plugin:AutoCAD 家具导出插件源码
- 资产中心改造:编辑弹窗化、删除功能、Toast反馈、自定义Select组件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:00:19 +08:00

251 lines
11 KiB
C#
Raw 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;
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;
}
}