Merge codex/xuxiake-mainline into main
Merge codex/xuxiake-mainline into main
This commit is contained in:
commit
ee8f218712
@ -72,6 +72,8 @@ public class Commands
|
||||
public List<HoleInfo> Holes { get; } = new();
|
||||
public List<HoleGroupInfo> HoleGroups { get; } = new();
|
||||
public List<MountPatternInfo> MountPatterns { get; } = new();
|
||||
public List<MountPatternInfo> SnapPatterns { get; } = new();
|
||||
public List<SnapSurfaceInfo> SnapSurfaces { get; } = new();
|
||||
public int LogicalHoleCount { get; set; }
|
||||
public List<EdgeFeatureInfo> EdgeFeatures { get; } = new();
|
||||
public List<ExcludedHoleInfo> ExcludedHoles { get; } = new();
|
||||
@ -339,6 +341,7 @@ public class Commands
|
||||
HoleGrouping.AttachGroupsToPanels(panels, holeGroups);
|
||||
var mountPatterns = MountPatternAnalyzer.BuildMountPatterns(panels, allHoles, holeGroups);
|
||||
MountPatternAnalyzer.AttachPatternsToPanels(panels, mountPatterns);
|
||||
var snapSurfaces = SnapSurfaceBuilder.BuildSnapSurfaces(panels);
|
||||
var logicalHoleCount = HoleGrouping.ComputeLogicalHoleCount(allHoles);
|
||||
|
||||
foreach (var hole in allHoles)
|
||||
@ -357,6 +360,8 @@ public class Commands
|
||||
result.Holes.AddRange(allHoles);
|
||||
result.HoleGroups.AddRange(holeGroups);
|
||||
result.MountPatterns.AddRange(mountPatterns);
|
||||
result.SnapPatterns.AddRange(mountPatterns);
|
||||
result.SnapSurfaces.AddRange(snapSurfaces);
|
||||
result.LogicalHoleCount = logicalHoleCount;
|
||||
result.EdgeFeatures.AddRange(allEdgeFeatures);
|
||||
result.ExcludedHoles.AddRange(panelAnalyzer.ExcludedHoles);
|
||||
@ -689,6 +694,7 @@ public class Commands
|
||||
HoleGrouping.AttachGroupsToPanels(panels, holeGroups);
|
||||
var mountPatterns = MountPatternAnalyzer.BuildMountPatterns(panels, allHoles, holeGroups);
|
||||
MountPatternAnalyzer.AttachPatternsToPanels(panels, mountPatterns);
|
||||
var snapSurfaces = SnapSurfaceBuilder.BuildSnapSurfaces(panels);
|
||||
|
||||
var logicalHoleCount = HoleGrouping.ComputeLogicalHoleCount(allHoles);
|
||||
|
||||
@ -736,6 +742,8 @@ public class Commands
|
||||
exportData.Holes = allHoles;
|
||||
exportData.HoleGroups = holeGroups;
|
||||
exportData.MountPatterns = mountPatterns;
|
||||
exportData.SnapPatterns = mountPatterns;
|
||||
exportData.SnapSurfaces = snapSurfaces;
|
||||
exportData.LogicalHoleCount = logicalHoleCount;
|
||||
|
||||
if (panelAnalyzer.ExcludedHoles.Count > 0)
|
||||
@ -993,6 +1001,8 @@ public class Commands
|
||||
exportData.Holes = panelAnalysis.Holes;
|
||||
exportData.HoleGroups = panelAnalysis.HoleGroups;
|
||||
exportData.MountPatterns = panelAnalysis.MountPatterns;
|
||||
exportData.SnapPatterns = panelAnalysis.SnapPatterns;
|
||||
exportData.SnapSurfaces = panelAnalysis.SnapSurfaces;
|
||||
exportData.LogicalHoleCount = panelAnalysis.LogicalHoleCount;
|
||||
exportData.EdgeFeatures = panelAnalysis.EdgeFeatures;
|
||||
exportData.PanelCount = panelAnalysis.Panels.Count;
|
||||
@ -1032,21 +1042,11 @@ public class Commands
|
||||
|
||||
if (mode.AnalyzePanels && panelAnalysis.Panels.Count > 0)
|
||||
{
|
||||
ed.WriteMessage(" [4/5] 生成装配语义...");
|
||||
var assemblyAnalyzer = new AssemblySemanticAnalyzer();
|
||||
exportData.Assembly = assemblyAnalyzer.Analyze(
|
||||
panelAnalysis.Panels,
|
||||
panelAnalysis.Holes,
|
||||
panelAnalysis.HoleGroups,
|
||||
exportData.HardwareTypes);
|
||||
var highConfidenceCount = exportData.Assembly.PanelAssemblies.Count(item =>
|
||||
item.Confidence >= 0.75 &&
|
||||
!string.Equals(item.MountMode, "unknown", StringComparison.OrdinalIgnoreCase));
|
||||
ed.WriteMessage($" ✓ ({exportData.Assembly.PanelAssemblies.Count} 个板件语义, {highConfidenceCount} 个高置信)\n");
|
||||
ed.WriteMessage(" [4/5] 跳过旧装配语义... ✓ (single_asset 使用 snapPatterns/snapSurfaces)\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
ed.WriteMessage(" [4/5] 生成装配语义... ✓ (当前模式无需生成)\n");
|
||||
ed.WriteMessage(" [4/5] 跳过旧装配语义... ✓ (当前模式无需生成)\n");
|
||||
}
|
||||
|
||||
ed.WriteMessage(" [5/5] 打包文件...");
|
||||
|
||||
@ -24,6 +24,8 @@ namespace FurnitureExporter.Tests
|
||||
Assert.Equal("line_2", pattern.PatternType);
|
||||
Assert.Equal(2, pattern.MinRequiredHoles);
|
||||
Assert.Equal(2, pattern.PreferredHoleCount);
|
||||
Assert.Single(panel.SnapPatterns);
|
||||
Assert.Equal(panel.MountPatterns, panel.SnapPatterns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
|
||||
namespace FurnitureExporter.Tests
|
||||
{
|
||||
public class SnapSurfaceBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildSnapSurfaces_FromBoundingBox_CreatesSixAxisFaces()
|
||||
{
|
||||
var panel = CreatePanel("panel_001");
|
||||
|
||||
var surfaces = SnapSurfaceBuilder.BuildSnapSurfaces(new[] { panel });
|
||||
|
||||
Assert.Equal(6, surfaces.Count);
|
||||
Assert.Equal(6, panel.SnapSurfaces.Count);
|
||||
|
||||
var top = Assert.Single(surfaces.Where(surface => surface.Face == "top"));
|
||||
Assert.Equal("panel_001_snap_top", top.Id);
|
||||
Assert.Equal("panel_001", top.PanelId);
|
||||
Assert.Equal(new[] { 0.0, 0.0, 1.0 }, top.Normal);
|
||||
Assert.Equal(new[] { 0.0, 0.0, 18.0 }, top.Center);
|
||||
Assert.Equal(18.0, top.Bounds.Min[2]);
|
||||
Assert.Equal(18.0, top.Bounds.Max[2]);
|
||||
Assert.Equal(1.0, top.Confidence);
|
||||
|
||||
var front = Assert.Single(surfaces.Where(surface => surface.Face == "front"));
|
||||
Assert.Equal(new[] { 0.0, -1.0, 0.0 }, front.Normal);
|
||||
Assert.Equal(-280.0, front.Center[1]);
|
||||
Assert.Equal(-280.0, front.Bounds.Min[1]);
|
||||
Assert.Equal(-280.0, front.Bounds.Max[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportData_ForSingleAsset_EmitsSnapFieldsAndOmitsAssembly()
|
||||
{
|
||||
var panel = CreatePanel("panel_001");
|
||||
var pattern = new MountPatternInfo
|
||||
{
|
||||
Id = "panel_001_mount_top_000",
|
||||
PanelId = "panel_001",
|
||||
MountFace = "top",
|
||||
PatternType = "line_2",
|
||||
HoleIds = new[] { "hole_a", "hole_b" },
|
||||
RequiredHoleIds = new[] { "hole_a", "hole_b" },
|
||||
MinRequiredHoles = 2,
|
||||
PreferredHoleCount = 2,
|
||||
Normal = new[] { 0.0, 0.0, 1.0 },
|
||||
Position = new[] { 0.0, 0.0, 18.0 },
|
||||
Confidence = 0.95,
|
||||
};
|
||||
panel.MountPatterns = new List<MountPatternInfo> { pattern };
|
||||
panel.SnapPatterns = panel.MountPatterns;
|
||||
|
||||
var exportData = new ExportData
|
||||
{
|
||||
ExportMode = "single_asset",
|
||||
Panels = new List<PanelInfo> { panel },
|
||||
MountPatterns = new List<MountPatternInfo> { pattern },
|
||||
SnapPatterns = new List<MountPatternInfo> { pattern },
|
||||
SnapSurfaces = SnapSurfaceBuilder.BuildSnapSurfaces(new[] { panel }),
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(
|
||||
exportData,
|
||||
new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
});
|
||||
var root = JObject.Parse(json);
|
||||
|
||||
Assert.Null(root["assembly"]);
|
||||
Assert.DoesNotContain("\"mates\"", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("\"connections\"", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Single(Assert.IsType<JArray>(root["mountPatterns"]));
|
||||
Assert.Single(Assert.IsType<JArray>(root["snapPatterns"]));
|
||||
Assert.Equal(6, Assert.IsType<JArray>(root["snapSurfaces"]).Count);
|
||||
var panels = Assert.IsType<JArray>(root["panels"]);
|
||||
Assert.Equal(6, Assert.IsType<JArray>(panels[0]?["snapSurfaces"]).Count);
|
||||
}
|
||||
|
||||
private static PanelInfo CreatePanel(string id)
|
||||
{
|
||||
return new PanelInfo
|
||||
{
|
||||
Id = id,
|
||||
Position = new[] { 0.0, 0.0, 9.0 },
|
||||
Normal = new[] { 0.0, 0.0, 1.0 },
|
||||
BoundingBox = new BoundingBox3D
|
||||
{
|
||||
Min = new[] { -300.0, -280.0, 0.0 },
|
||||
Max = new[] { 300.0, 280.0, 18.0 },
|
||||
},
|
||||
Size = new[] { 600.0, 560.0, 18.0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,7 +11,7 @@
|
||||
<!-- AutoCAD 2024 插件配置 -->
|
||||
<AssemblyName>FurnitureExporter</AssemblyName>
|
||||
<RootNamespace>FurnitureExporter</RootNamespace>
|
||||
<Version>2.5.0</Version>
|
||||
<Version>2.6.0</Version>
|
||||
<Authors>Furniture 3D System</Authors>
|
||||
<Description>家具 3D 模型导出插件 - 导出 GLB 模型和五金件位置数据</Description>
|
||||
|
||||
|
||||
@ -111,14 +111,23 @@ public class ExportData
|
||||
public List<HoleGroupInfo> HoleGroups { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 安装图样(v2.5 新增)
|
||||
/// 说明:
|
||||
/// - 吸附面先缩小范围;
|
||||
/// - HoleGroup 负责把几何孔组织成业务孔位;
|
||||
/// - MountPattern 负责定义“至少命中几个孔才允许吸附成立”。
|
||||
/// 安装图样(v2.5 兼容字段)。
|
||||
/// 新方案主字段是 SnapPatterns;这里继续保留给旧 Web 端兼容。
|
||||
/// </summary>
|
||||
public List<MountPatternInfo> MountPatterns { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 吸附图样(v2.6 新主字段)。
|
||||
/// 内容与 MountPatterns 保持一致,Web 端后续优先读取 snapPatterns。
|
||||
/// </summary>
|
||||
public List<MountPatternInfo> SnapPatterns { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 吸附面(v2.6 新增)。
|
||||
/// 每块板件至少提供 6 个轴向外表面,用于辅助装配阶段做面级吸附候选。
|
||||
/// </summary>
|
||||
public List<SnapSurfaceInfo> SnapSurfaces { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 逻辑孔位数量(v2.2.4 新增)
|
||||
/// 计算方式:以 HoleGroupId 合并计数(HoleGroupId 为空则按 Hole.Id 计数)。
|
||||
@ -331,9 +340,19 @@ public class PanelInfo
|
||||
public List<HoleGroupInfo> HoleGroups { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 该板件上的安装图样(v2.5 新增)
|
||||
/// 该板件上的安装图样(v2.5 兼容字段)
|
||||
/// </summary>
|
||||
public List<MountPatternInfo> MountPatterns { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 该板件上的吸附图样(v2.6 新主字段)
|
||||
/// </summary>
|
||||
public List<MountPatternInfo> SnapPatterns { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 该板件的吸附面(v2.6 新增)
|
||||
/// </summary>
|
||||
public List<SnapSurfaceInfo> SnapSurfaces { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -647,6 +666,47 @@ public class MountPatternInfo
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 吸附面信息(v2.6 新增)
|
||||
/// </summary>
|
||||
public class SnapSurfaceInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 吸附面 ID(如 panel_001_snap_top)
|
||||
/// </summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 面标签:left / right / front / back / bottom / top
|
||||
/// </summary>
|
||||
public string Face { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 所属板件 ID
|
||||
/// </summary>
|
||||
public string PanelId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 外法向量 [X, Y, Z]
|
||||
/// </summary>
|
||||
public double[] Normal { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 面中心 [X, Y, Z] (mm)
|
||||
/// </summary>
|
||||
public double[] Center { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 面边界框。薄轴最小值和最大值相同,表示该外表面所在平面。
|
||||
/// </summary>
|
||||
public BoundingBox3D Bounds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 推导置信度(0-1)
|
||||
/// </summary>
|
||||
public double Confidence { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 3D 边界框
|
||||
/// </summary>
|
||||
|
||||
@ -50,6 +50,7 @@ public static class MountPatternAnalyzer
|
||||
if (units.Count == 0)
|
||||
{
|
||||
panel.MountPatterns = new List<MountPatternInfo>();
|
||||
panel.SnapPatterns = new List<MountPatternInfo>();
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -71,6 +72,7 @@ public static class MountPatternAnalyzer
|
||||
}
|
||||
|
||||
panel.MountPatterns = panelPatterns;
|
||||
panel.SnapPatterns = panelPatterns;
|
||||
patterns.AddRange(panelPatterns);
|
||||
}
|
||||
|
||||
@ -85,9 +87,11 @@ public static class MountPatternAnalyzer
|
||||
|
||||
foreach (var panel in panels)
|
||||
{
|
||||
panel.MountPatterns = map.TryGetValue(panel.Id, out var items)
|
||||
var panelPatterns = map.TryGetValue(panel.Id, out var items)
|
||||
? items
|
||||
: new List<MountPatternInfo>();
|
||||
panel.MountPatterns = panelPatterns;
|
||||
panel.SnapPatterns = panelPatterns;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
135
autocad-plugin/FurnitureExporter/SnapSurfaceBuilder.cs
Normal file
135
autocad-plugin/FurnitureExporter/SnapSurfaceBuilder.cs
Normal file
@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace FurnitureExporter;
|
||||
|
||||
/// <summary>
|
||||
/// 从板件包围盒推导辅助装配吸附面。
|
||||
/// </summary>
|
||||
public static class SnapSurfaceBuilder
|
||||
{
|
||||
private static readonly FaceDefinition[] Faces =
|
||||
{
|
||||
new("left", 0, false, new[] { -1.0, 0.0, 0.0 }),
|
||||
new("right", 0, true, new[] { 1.0, 0.0, 0.0 }),
|
||||
new("front", 1, false, new[] { 0.0, -1.0, 0.0 }),
|
||||
new("back", 1, true, new[] { 0.0, 1.0, 0.0 }),
|
||||
new("bottom", 2, false, new[] { 0.0, 0.0, -1.0 }),
|
||||
new("top", 2, true, new[] { 0.0, 0.0, 1.0 }),
|
||||
};
|
||||
|
||||
public static List<SnapSurfaceInfo> BuildSnapSurfaces(IReadOnlyList<PanelInfo> panels)
|
||||
{
|
||||
var allSurfaces = new List<SnapSurfaceInfo>();
|
||||
|
||||
foreach (var panel in panels)
|
||||
{
|
||||
var panelSurfaces = BuildPanelSurfaces(panel);
|
||||
panel.SnapSurfaces = panelSurfaces;
|
||||
allSurfaces.AddRange(panelSurfaces);
|
||||
}
|
||||
|
||||
return allSurfaces;
|
||||
}
|
||||
|
||||
private static List<SnapSurfaceInfo> BuildPanelSurfaces(PanelInfo panel)
|
||||
{
|
||||
var (min, max, confidence) = ResolveBounds(panel);
|
||||
var surfaces = new List<SnapSurfaceInfo>(Faces.Length);
|
||||
|
||||
foreach (var face in Faces)
|
||||
{
|
||||
var center = new[]
|
||||
{
|
||||
Round((min[0] + max[0]) / 2.0),
|
||||
Round((min[1] + max[1]) / 2.0),
|
||||
Round((min[2] + max[2]) / 2.0),
|
||||
};
|
||||
|
||||
var boundsMin = new[] { min[0], min[1], min[2] };
|
||||
var boundsMax = new[] { max[0], max[1], max[2] };
|
||||
var planeValue = face.UseMax ? max[face.Axis] : min[face.Axis];
|
||||
center[face.Axis] = planeValue;
|
||||
boundsMin[face.Axis] = planeValue;
|
||||
boundsMax[face.Axis] = planeValue;
|
||||
|
||||
surfaces.Add(new SnapSurfaceInfo
|
||||
{
|
||||
Id = $"{panel.Id}_snap_{face.Name}",
|
||||
Face = face.Name,
|
||||
PanelId = panel.Id,
|
||||
Normal = face.Normal,
|
||||
Center = center,
|
||||
Bounds = new BoundingBox3D
|
||||
{
|
||||
Min = RoundAll(boundsMin),
|
||||
Max = RoundAll(boundsMax),
|
||||
},
|
||||
Confidence = confidence,
|
||||
});
|
||||
}
|
||||
|
||||
return surfaces;
|
||||
}
|
||||
|
||||
private static (double[] Min, double[] Max, double Confidence) ResolveBounds(PanelInfo panel)
|
||||
{
|
||||
if (panel.BoundingBox?.Min?.Length == 3 && panel.BoundingBox.Max?.Length == 3)
|
||||
{
|
||||
return (RoundAll(panel.BoundingBox.Min), RoundAll(panel.BoundingBox.Max), 1.0);
|
||||
}
|
||||
|
||||
var center = panel.Position?.Length == 3
|
||||
? panel.Position
|
||||
: new[] { 0.0, 0.0, 0.0 };
|
||||
var size = panel.Size?.Length == 3
|
||||
? panel.Size
|
||||
: new[] { 0.0, 0.0, 0.0 };
|
||||
|
||||
var min = new[]
|
||||
{
|
||||
center[0] - Math.Abs(size[0]) / 2.0,
|
||||
center[1] - Math.Abs(size[1]) / 2.0,
|
||||
center[2] - Math.Abs(size[2]) / 2.0,
|
||||
};
|
||||
var max = new[]
|
||||
{
|
||||
center[0] + Math.Abs(size[0]) / 2.0,
|
||||
center[1] + Math.Abs(size[1]) / 2.0,
|
||||
center[2] + Math.Abs(size[2]) / 2.0,
|
||||
};
|
||||
|
||||
return (RoundAll(min), RoundAll(max), 0.6);
|
||||
}
|
||||
|
||||
private static double[] RoundAll(IReadOnlyList<double> values)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
Round(values[0]),
|
||||
Round(values[1]),
|
||||
Round(values[2]),
|
||||
};
|
||||
}
|
||||
|
||||
private static double Round(double value)
|
||||
{
|
||||
return Math.Round(value, 2);
|
||||
}
|
||||
|
||||
private sealed class FaceDefinition
|
||||
{
|
||||
public FaceDefinition(string name, int axis, bool useMax, double[] normal)
|
||||
{
|
||||
Name = name;
|
||||
Axis = axis;
|
||||
UseMax = useMax;
|
||||
Normal = normal;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public int Axis { get; }
|
||||
public bool UseMax { get; }
|
||||
public double[] Normal { get; }
|
||||
}
|
||||
}
|
||||
@ -49,7 +49,7 @@ Write-Host ""
|
||||
|
||||
# 路径设置
|
||||
$PluginName = "FurnitureExporter"
|
||||
$Version = "2.5.0"
|
||||
$Version = "2.6.0"
|
||||
$SourceDir = Join-Path $PSScriptRoot "FurnitureExporter"
|
||||
$InstallerDir = Join-Path $PSScriptRoot "installer"
|
||||
$OutputDir = Join-Path $PSScriptRoot "dist"
|
||||
|
||||
@ -3,12 +3,12 @@
|
||||
SchemaVersion="1.0"
|
||||
AutodeskProduct="AutoCAD"
|
||||
Name="FurnitureExporter"
|
||||
Description="家具导出插件 v2.5.0 - 支持整套兼容导出与资产中心单资产导出"
|
||||
Description="家具导出插件 v2.6.0 - 支持整套兼容导出与资产中心单资产导出"
|
||||
Author="Furniture 3D System"
|
||||
AppVersion="2.5.0"
|
||||
FriendlyVersion="2.5.0"
|
||||
AppVersion="2.6.0"
|
||||
FriendlyVersion="2.6.0"
|
||||
ProductType="Application"
|
||||
Version="2.5.0"
|
||||
Version="2.6.0"
|
||||
ProductCode="{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}">
|
||||
|
||||
<CompanyDetails
|
||||
@ -30,7 +30,7 @@
|
||||
|
||||
<ComponentEntry
|
||||
AppName="FurnitureExporter"
|
||||
Version="2.5.0"
|
||||
Version="2.6.0"
|
||||
ModuleName="./Contents/FurnitureExporter.dll"
|
||||
AppDescription="家具导出插件 - 支持整套兼容导出与资产中心单资产导出"
|
||||
LoadOnAutoCADStartup="True"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
家具导出插件 v2.5.0 - 安装指南
|
||||
家具导出插件 v2.6.0 - 安装指南
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
安装步骤(只需30秒)
|
||||
@ -37,4 +37,4 @@ A: 1) 确认已重启 AutoCAD(关闭再打开)
|
||||
%AppData%\Autodesk\ApplicationPlugins\FurnitureExporter.bundle\Contents\FurnitureExporter.dll
|
||||
3) 如果 SECURELOAD=2,脚本会将其降到 1;如仍无效请截图安装日志给开发排查
|
||||
|
||||
版本: v2.5.0
|
||||
版本: v2.6.0
|
||||
@ -479,7 +479,7 @@ export default async function AssistedAssemblyPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
} = {}) {
|
||||
const { authUser } = await requireSession('/login')
|
||||
const headerStore = await headers()
|
||||
const requestOrigin = resolveRequestOrigin(headerStore)
|
||||
@ -551,12 +551,12 @@ export default async function AssistedAssemblyPage({
|
||||
variant.uvConfig,
|
||||
)
|
||||
const runtimeThumbnailUrl =
|
||||
materialConfig && variant.modelUrl
|
||||
variant.modelUrl
|
||||
? buildAssetThumbnailImageUrl(requestOrigin, {
|
||||
modelUrl: variant.modelUrl,
|
||||
assetType: variant.assetType as AssetThumbnailRenderAssetType,
|
||||
expiresAt: buildAssetThumbnailExpiryTimestamp(),
|
||||
materialConfig,
|
||||
materialConfig: materialConfig ?? null,
|
||||
})
|
||||
: variant.thumbnailUrl
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
} from "./nonIntegratedApi";
|
||||
import {
|
||||
createDefaultDocumentWorkspaceConfirmations,
|
||||
createEmptyNonIntegratedOpeningReference,
|
||||
getNonIntegratedProductById,
|
||||
NON_INTEGRATED_STORAGE_KEY,
|
||||
saveNonIntegratedProduct,
|
||||
@ -156,6 +157,15 @@ function createProductRecord(
|
||||
return {
|
||||
id: "test-product",
|
||||
spu: "DNZ0001",
|
||||
projectStatus: "approved",
|
||||
projectReview: {
|
||||
reviewDate: "",
|
||||
reviewers: "",
|
||||
conclusion: "",
|
||||
revisionNotes: "",
|
||||
decidedAt: "",
|
||||
},
|
||||
openingReference: createEmptyNonIntegratedOpeningReference(),
|
||||
year: "",
|
||||
demandFlowNo: "",
|
||||
processTemplate: "",
|
||||
@ -348,13 +358,14 @@ describe("NonIntegratedDetailClient", () => {
|
||||
const actor = userEvent.setup();
|
||||
const { container } = render(<NonIntegratedDetailClient mode="create" />);
|
||||
|
||||
await actor.type(screen.getByLabelText("SPU"), "DNZ0001");
|
||||
await actor.type(screen.getByLabelText("开品名称"), "测试开品");
|
||||
await actor.type(screen.getByLabelText("临时编号"), "DNZ0001");
|
||||
await actor.type(screen.getByLabelText("设计师"), "测试设计师");
|
||||
|
||||
const imageFile = new File(["image"], "preview.png", { type: "image/png" });
|
||||
const fileInputs = container.querySelectorAll('input[type="file"]');
|
||||
await actor.upload(fileInputs[0] as HTMLInputElement, imageFile);
|
||||
await actor.click(screen.getByRole("button", { name: "提交完成" }));
|
||||
await actor.click(screen.getByRole("button", { name: "创建开品单" }));
|
||||
|
||||
await waitFor(() => expect(push).toHaveBeenCalledTimes(1));
|
||||
|
||||
@ -364,8 +375,11 @@ describe("NonIntegratedDetailClient", () => {
|
||||
|
||||
expect(savedProducts).toHaveLength(1);
|
||||
expect(savedProducts[0]?.spu).toBe("DNZ0001");
|
||||
expect(savedProducts[0]?.projectStatus).toBe("draft");
|
||||
expect(savedProducts[0]?.brand).toBe("基本盘");
|
||||
expect(savedProducts[0]?.designer).toBe("测试设计师");
|
||||
expect(savedProducts[0]?.openingReference.productName).toBe("测试开品");
|
||||
expect(savedProducts[0]?.openingReference.temporaryCode).toBe("DNZ0001");
|
||||
expect(savedProducts[0]?.previewImage).toContain(
|
||||
"data:image/png;base64,dGVzdA==",
|
||||
);
|
||||
@ -379,7 +393,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
|
||||
expect(screen.getByRole("button", { name: "返回" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "提交完成" }),
|
||||
screen.getByRole("button", { name: "创建开品单" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
createRender.unmount();
|
||||
@ -399,7 +413,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
|
||||
expect(screen.queryByRole("button", { name: "返回" })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "提交完成" }),
|
||||
screen.queryByRole("button", { name: "创建开品单" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
it("keeps brand as a single value when adaptable brands change", async () => {
|
||||
@ -426,7 +440,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
await actor.click(adaptableBrandButton);
|
||||
await actor.click(screen.getByLabelText("基本盘"));
|
||||
expect(brandSelect).toHaveValue("顾家");
|
||||
expect(screen.getByLabelText("SPU")).toHaveValue("");
|
||||
expect(screen.getByLabelText("临时编号")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("shows the document sidebar in the new order and marks empty categories softly", () => {
|
||||
@ -471,7 +485,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
'button[data-testid^="document-workspace"]',
|
||||
),
|
||||
)[0]?.getAttribute("data-testid"),
|
||||
).toBe("document-workspace-basic-info-trigger");
|
||||
).toBe("document-workspace-trigger-开品参考");
|
||||
|
||||
const triggerIds = Array.from(
|
||||
sidebar.querySelectorAll<HTMLButtonElement>(
|
||||
@ -480,12 +494,14 @@ describe("NonIntegratedDetailClient", () => {
|
||||
).map((element) => element.dataset.testid);
|
||||
|
||||
expect(triggerIds).toEqual([
|
||||
"document-workspace-trigger-产品实拍图",
|
||||
"document-workspace-trigger-评审资料",
|
||||
"document-workspace-trigger-开品参考",
|
||||
"document-workspace-trigger-产品模型",
|
||||
"document-workspace-trigger-设计卖点",
|
||||
"document-workspace-trigger-立项评审",
|
||||
"document-workspace-trigger-评审资料",
|
||||
"document-workspace-trigger-产品实拍图",
|
||||
"document-workspace-trigger-安装方式",
|
||||
"document-workspace-trigger-包装细节",
|
||||
"document-workspace-trigger-产品模型",
|
||||
"document-workspace-trigger-产品定厂",
|
||||
"document-workspace-trigger-样包测量",
|
||||
"document-workspace-trigger-产品核价",
|
||||
@ -502,6 +518,170 @@ describe("NonIntegratedDetailClient", () => {
|
||||
expect(reportTrigger).toHaveAttribute("data-has-files", "false");
|
||||
expect(reportTrigger).toHaveClass("bg-[#e6ebf0]");
|
||||
expect(reviewTrigger).not.toHaveClass("bg-[#e6ebf0]");
|
||||
expect(
|
||||
Array.from(
|
||||
sidebar.querySelectorAll<HTMLButtonElement>(
|
||||
'button[data-testid^="document-workspace"]',
|
||||
),
|
||||
)[4]?.getAttribute("data-testid"),
|
||||
).toBe("document-workspace-basic-info-trigger");
|
||||
});
|
||||
|
||||
it("limits draft products to setup nodes until project review is approved", async () => {
|
||||
const actor = userEvent.setup();
|
||||
|
||||
window.localStorage.setItem(
|
||||
NON_INTEGRATED_STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
createProductRecord({
|
||||
id: "DNZ0001-draft-project",
|
||||
projectStatus: "draft",
|
||||
openingReference: {
|
||||
...createEmptyNonIntegratedOpeningReference(),
|
||||
productName: "测试开品",
|
||||
temporaryCode: "KP-001",
|
||||
brand: "顾家",
|
||||
adaptableBrands: ["顾家", "基本盘"],
|
||||
productCategory: "书桌",
|
||||
salesCategory: "书桌",
|
||||
designer: "测试设计师",
|
||||
planningOwner: "测试企划",
|
||||
currentStage: "待立项评审",
|
||||
year: "2026",
|
||||
targetPriceBand: "399-499",
|
||||
size: "140/60/78",
|
||||
material: "18ENF级橡胶木",
|
||||
urgent: "否",
|
||||
reason: "补齐书房价格带",
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
render(
|
||||
<NonIntegratedDetailClient mode="edit" productId="DNZ0001-draft-project" />,
|
||||
);
|
||||
|
||||
const sidebar = screen.getByTestId("document-workspace-sidebar");
|
||||
const draftTriggerIds = Array.from(
|
||||
sidebar.querySelectorAll<HTMLButtonElement>(
|
||||
'button[data-testid^="document-workspace-trigger-"]',
|
||||
),
|
||||
).map((element) => element.dataset.testid);
|
||||
|
||||
expect(draftTriggerIds).toEqual([
|
||||
"document-workspace-trigger-开品参考",
|
||||
"document-workspace-trigger-产品模型",
|
||||
"document-workspace-trigger-设计卖点",
|
||||
"document-workspace-trigger-立项评审",
|
||||
]);
|
||||
expect(
|
||||
sidebar.querySelector<HTMLButtonElement>(
|
||||
'button[data-testid="document-workspace-basic-info-trigger"]',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(screen.getByText("开品参考填写")).toBeInTheDocument();
|
||||
|
||||
await actor.click(screen.getByTestId("document-workspace-trigger-立项评审"));
|
||||
await actor.click(screen.getByRole("button", { name: "立项通过" }));
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "确认立项" })).toBeInTheDocument();
|
||||
await actor.type(screen.getByLabelText("正式 SPU"), "SZ0230");
|
||||
await actor.click(screen.getByRole("button", { name: "确认立项" }));
|
||||
|
||||
expect(
|
||||
screen.getByTestId("document-workspace-basic-info-trigger"),
|
||||
).toHaveAttribute("data-active", "true");
|
||||
expect(
|
||||
screen.getByTestId("document-workspace-trigger-产品实拍图"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("document-workspace-trigger-立项评审"),
|
||||
).toHaveTextContent("1");
|
||||
|
||||
await waitFor(() => {
|
||||
const savedProduct = getNonIntegratedProductById("DNZ0001-draft-project");
|
||||
|
||||
expect(savedProduct?.projectStatus).toBe("approved");
|
||||
expect(savedProduct?.spu).toBe("SZ0230");
|
||||
expect(savedProduct?.brand).toBe("顾家");
|
||||
expect(savedProduct?.productCategory).toBe("书桌");
|
||||
expect(savedProduct?.salesCategory).toBe("书桌");
|
||||
expect(savedProduct?.designer).toBe("测试设计师");
|
||||
expect(savedProduct?.productPlanning).toBe("测试企划");
|
||||
expect(savedProduct?.year).toBe("2026");
|
||||
expect(savedProduct?.priceBand).toBe("399-499");
|
||||
expect(savedProduct?.size).toBe("140/60/78");
|
||||
expect(savedProduct?.materialType).toBe("18ENF级橡胶木");
|
||||
expect(savedProduct?.openingReference.currentStage).toBe("已立项");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders opening reference fields and persists edits", async () => {
|
||||
const actor = userEvent.setup();
|
||||
|
||||
saveNonIntegratedProduct(
|
||||
createProductRecord({
|
||||
id: "SZ0230-opening-reference",
|
||||
spu: "SZ0230",
|
||||
openingReference: {
|
||||
...createEmptyNonIntegratedOpeningReference(),
|
||||
productName: "胡桃木色窄边书桌",
|
||||
temporaryCode: "KP-SZ0230",
|
||||
brand: "基本盘",
|
||||
adaptableBrands: ["基本盘", "顾家"],
|
||||
productCategory: "书桌",
|
||||
salesCategory: "书桌",
|
||||
designer: "徐霞客",
|
||||
planningOwner: "企划A",
|
||||
currentStage: "立项前",
|
||||
year: "2026",
|
||||
competitorLink: "https://example.com/item",
|
||||
targetPriceBand: "399-499",
|
||||
size: "140/60/78",
|
||||
material: "18ENF级橡胶木",
|
||||
urgent: "否",
|
||||
reason: "补齐书房价格带",
|
||||
},
|
||||
uploadFiles: [
|
||||
{
|
||||
id: "opening-doc-1",
|
||||
name: "竞品资料.pdf",
|
||||
category: "开品参考",
|
||||
dataUrl: "data:application/pdf;base64,AAAA",
|
||||
mimeType: "application/pdf",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<NonIntegratedDetailClient
|
||||
mode="edit"
|
||||
productId="SZ0230-opening-reference"
|
||||
/>,
|
||||
);
|
||||
|
||||
await actor.click(screen.getByTestId("document-workspace-trigger-开品参考"));
|
||||
|
||||
expect(screen.getByTestId("opening-reference-workspace")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("开品名称")).toHaveValue("胡桃木色窄边书桌");
|
||||
expect(screen.getByLabelText("临时编号")).toHaveValue("KP-SZ0230");
|
||||
expect(screen.getByLabelText("企划负责人")).toHaveValue("企划A");
|
||||
expect(screen.getByLabelText("目标价格带")).toHaveValue("399-499");
|
||||
expect(screen.getByText("竞品资料.pdf")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "下载" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "删除" })).toBeInTheDocument();
|
||||
|
||||
await actor.clear(screen.getByLabelText("开品理由"));
|
||||
await actor.type(screen.getByLabelText("开品理由"), "新的开品理由");
|
||||
|
||||
await waitFor(() => {
|
||||
const savedProduct = getNonIntegratedProductById(
|
||||
"SZ0230-opening-reference",
|
||||
);
|
||||
expect(savedProduct?.openingReference.reason).toBe("新的开品理由");
|
||||
});
|
||||
});
|
||||
|
||||
it("links the SKU material lookup to the raw material process config", () => {
|
||||
@ -1529,13 +1709,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const productPhotoTrigger = screen
|
||||
.getByTestId("document-workspace-sidebar")
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'button[data-testid^="document-workspace-trigger-"]',
|
||||
);
|
||||
expect(productPhotoTrigger).not.toBeNull();
|
||||
await actor.click(productPhotoTrigger!);
|
||||
await actor.click(screen.getByTestId("document-workspace-trigger-产品实拍图"));
|
||||
await actor.click(screen.getByTestId("product-photo-slot-back"));
|
||||
await actor.upload(
|
||||
screen.getByTestId("product-photo-current-upload-input"),
|
||||
@ -1566,13 +1740,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const productPhotoTrigger = screen
|
||||
.getByTestId("document-workspace-sidebar")
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'button[data-testid^="document-workspace-trigger-"]',
|
||||
);
|
||||
expect(productPhotoTrigger).not.toBeNull();
|
||||
await actor.click(productPhotoTrigger!);
|
||||
await actor.click(screen.getByTestId("document-workspace-trigger-产品实拍图"));
|
||||
await actor.click(screen.getByTestId("product-photo-panel-tab-detail"));
|
||||
await actor.click(screen.getByTestId("product-photo-empty-panel-upload-detail"));
|
||||
await actor.upload(
|
||||
@ -1616,13 +1784,7 @@ describe("NonIntegratedDetailClient", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const productPhotoTrigger = screen
|
||||
.getByTestId("document-workspace-sidebar")
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'button[data-testid^="document-workspace-trigger-"]',
|
||||
);
|
||||
expect(productPhotoTrigger).not.toBeNull();
|
||||
await actor.click(productPhotoTrigger!);
|
||||
await actor.click(screen.getByTestId("document-workspace-trigger-产品实拍图"));
|
||||
|
||||
expect(screen.getByTestId("product-photo-style-K01")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("product-photo-style-K02")).not.toBeInTheDocument();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
||||
import {
|
||||
createCopiedNonIntegratedProduct,
|
||||
createDefaultDocumentWorkspaceConfirmations,
|
||||
createEmptyNonIntegratedOpeningReference,
|
||||
getNonIntegratedProductById,
|
||||
deleteNonIntegratedProduct,
|
||||
NON_INTEGRATED_STORAGE_KEY,
|
||||
@ -13,6 +14,15 @@ function createProduct(overrides?: Partial<NonIntegratedProductRecord>): NonInte
|
||||
return {
|
||||
id: 'product-1',
|
||||
spu: 'YMJ0110',
|
||||
projectStatus: 'approved',
|
||||
projectReview: {
|
||||
reviewDate: '',
|
||||
reviewers: '',
|
||||
conclusion: '',
|
||||
revisionNotes: '',
|
||||
decidedAt: '',
|
||||
},
|
||||
openingReference: createEmptyNonIntegratedOpeningReference(),
|
||||
designer: '设计师A',
|
||||
operator: '运营A',
|
||||
purchaser: '采购A',
|
||||
@ -95,6 +105,35 @@ describe('nonIntegratedStorage', () => {
|
||||
expect(savedProducts[0].spu).toBe('YMJ0110')
|
||||
})
|
||||
|
||||
it('keeps opening reference fields when saving locally', () => {
|
||||
saveNonIntegratedProduct(
|
||||
createProduct({
|
||||
openingReference: {
|
||||
...createEmptyNonIntegratedOpeningReference(),
|
||||
productName: '窄边书桌开品',
|
||||
brand: '基本盘',
|
||||
adaptableBrands: ['基本盘', '顾家'],
|
||||
competitorLink: 'https://example.com/item',
|
||||
targetPriceBand: '399-499',
|
||||
material: '18ENF级橡胶木',
|
||||
urgent: '是',
|
||||
reason: '补齐书房价格带',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const savedProduct = getNonIntegratedProductById('product-1')
|
||||
expect(savedProduct?.openingReference).toMatchObject({
|
||||
productName: '窄边书桌开品',
|
||||
adaptableBrands: ['基本盘', '顾家'],
|
||||
competitorLink: 'https://example.com/item',
|
||||
targetPriceBand: '399-499',
|
||||
material: '18ENF级橡胶木',
|
||||
urgent: '是',
|
||||
reason: '补齐书房价格带',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps bom details when saving the local record', () => {
|
||||
saveNonIntegratedProduct(
|
||||
createProduct({
|
||||
|
||||
@ -31,12 +31,14 @@ export const NON_INTEGRATED_BRAND_PREFIX_MAP: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES = [
|
||||
"产品实拍图",
|
||||
"评审资料",
|
||||
"开品参考",
|
||||
"产品模型",
|
||||
"设计卖点",
|
||||
"立项评审",
|
||||
"评审资料",
|
||||
"产品实拍图",
|
||||
"安装方式",
|
||||
"包装细节",
|
||||
"产品模型",
|
||||
"产品定厂",
|
||||
"样包测量",
|
||||
"产品核价",
|
||||
@ -52,10 +54,94 @@ export function getNonIntegratedDocumentWorkspaceCategoryLabel(
|
||||
) {
|
||||
if (category === "产品实拍图") return "产品实拍";
|
||||
if (category === "产品模型") return "产品资料";
|
||||
if (category === "评审资料") return "样品评审";
|
||||
if (category === "检测报告") return "仓库抽检";
|
||||
return category;
|
||||
}
|
||||
|
||||
export type NonIntegratedProjectStatus =
|
||||
| "draft"
|
||||
| "revision"
|
||||
| "rejected"
|
||||
| "approved";
|
||||
|
||||
export type NonIntegratedProjectReview = {
|
||||
reviewDate: string;
|
||||
reviewers: string;
|
||||
conclusion: string;
|
||||
revisionNotes: string;
|
||||
decidedAt: string;
|
||||
};
|
||||
|
||||
export type NonIntegratedOpeningReference = {
|
||||
productImage: string;
|
||||
productImageName: string;
|
||||
productName: string;
|
||||
temporaryCode: string;
|
||||
brand: string;
|
||||
adaptableBrands: string[];
|
||||
productCategory: string;
|
||||
salesCategory: string;
|
||||
designer: string;
|
||||
planningOwner: string;
|
||||
currentStage: string;
|
||||
year: string;
|
||||
competitorLink: string;
|
||||
targetPriceBand: string;
|
||||
size: string;
|
||||
material: string;
|
||||
urgent: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function createEmptyNonIntegratedProjectReview(): NonIntegratedProjectReview {
|
||||
return {
|
||||
reviewDate: "",
|
||||
reviewers: "",
|
||||
conclusion: "",
|
||||
revisionNotes: "",
|
||||
decidedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyNonIntegratedOpeningReference(): NonIntegratedOpeningReference {
|
||||
return {
|
||||
productImage: "",
|
||||
productImageName: "",
|
||||
productName: "",
|
||||
temporaryCode: "",
|
||||
brand: "",
|
||||
adaptableBrands: [],
|
||||
productCategory: "",
|
||||
salesCategory: "",
|
||||
designer: "",
|
||||
planningOwner: "",
|
||||
currentStage: "",
|
||||
year: "",
|
||||
competitorLink: "",
|
||||
targetPriceBand: "",
|
||||
size: "",
|
||||
material: "",
|
||||
urgent: "",
|
||||
reason: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeNonIntegratedProjectStatus(
|
||||
value: unknown,
|
||||
): NonIntegratedProjectStatus {
|
||||
if (
|
||||
value === "draft" ||
|
||||
value === "revision" ||
|
||||
value === "rejected" ||
|
||||
value === "approved"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "approved";
|
||||
}
|
||||
|
||||
export type NonIntegratedDocumentWorkspaceConfirmationMap = Record<
|
||||
NonIntegratedDocumentWorkspaceCategory,
|
||||
boolean
|
||||
@ -74,7 +160,10 @@ export function createDefaultDocumentWorkspaceConfirmations(
|
||||
|
||||
const NON_INTEGRATED_UPLOAD_CATEGORY_MAP: Record<string, string> = {
|
||||
未分类: "未分类",
|
||||
开品参考: "开品参考",
|
||||
立项评审: "立项评审",
|
||||
评审资料: "评审资料",
|
||||
样品评审: "评审资料",
|
||||
资料信息: "评审资料",
|
||||
产品模型: "产品模型",
|
||||
产品实拍图: "产品实拍图",
|
||||
@ -286,6 +375,9 @@ export type NonIntegratedQualityInspectionSheet = {
|
||||
export type NonIntegratedProductRecord = {
|
||||
id: string;
|
||||
spu: string;
|
||||
projectStatus: NonIntegratedProjectStatus;
|
||||
projectReview: NonIntegratedProjectReview;
|
||||
openingReference: NonIntegratedOpeningReference;
|
||||
brand: string;
|
||||
adaptableBrands: string[];
|
||||
designer: string;
|
||||
@ -403,6 +495,61 @@ function normalizeString(value: unknown) {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export function normalizeNonIntegratedProjectReview(
|
||||
value: unknown,
|
||||
): NonIntegratedProjectReview {
|
||||
if (!value || typeof value !== "object") {
|
||||
return createEmptyNonIntegratedProjectReview();
|
||||
}
|
||||
|
||||
const item = value as Partial<NonIntegratedProjectReview>;
|
||||
|
||||
return {
|
||||
reviewDate: normalizeString(item.reviewDate),
|
||||
reviewers: normalizeString(item.reviewers),
|
||||
conclusion: normalizeString(item.conclusion),
|
||||
revisionNotes: normalizeString(item.revisionNotes),
|
||||
decidedAt: normalizeString(item.decidedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeNonIntegratedOpeningReference(
|
||||
value: unknown,
|
||||
): NonIntegratedOpeningReference {
|
||||
if (!value || typeof value !== "object") {
|
||||
return createEmptyNonIntegratedOpeningReference();
|
||||
}
|
||||
|
||||
const item = value as Partial<NonIntegratedOpeningReference>;
|
||||
|
||||
return {
|
||||
productImage: normalizeNonIntegratedAssetUrl(
|
||||
normalizeString(item.productImage),
|
||||
),
|
||||
productImageName: normalizeString(item.productImageName),
|
||||
productName: normalizeString(item.productName),
|
||||
temporaryCode: normalizeString(item.temporaryCode),
|
||||
brand: normalizeString(item.brand),
|
||||
adaptableBrands: Array.isArray(item.adaptableBrands)
|
||||
? item.adaptableBrands.filter(
|
||||
(brand): brand is string => typeof brand === "string",
|
||||
)
|
||||
: [],
|
||||
productCategory: normalizeString(item.productCategory),
|
||||
salesCategory: normalizeString(item.salesCategory),
|
||||
designer: normalizeString(item.designer),
|
||||
planningOwner: normalizeString(item.planningOwner),
|
||||
currentStage: normalizeString(item.currentStage),
|
||||
year: normalizeString(item.year),
|
||||
competitorLink: normalizeString(item.competitorLink),
|
||||
targetPriceBand: normalizeString(item.targetPriceBand),
|
||||
size: normalizeString(item.size),
|
||||
material: normalizeString(item.material),
|
||||
urgent: normalizeString(item.urgent),
|
||||
reason: normalizeString(item.reason),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStringRecord(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
@ -1019,6 +1166,11 @@ function toProductRecord(
|
||||
return {
|
||||
id: item.id,
|
||||
spu: item.spu,
|
||||
projectStatus: normalizeNonIntegratedProjectStatus(item.projectStatus),
|
||||
projectReview: normalizeNonIntegratedProjectReview(item.projectReview),
|
||||
openingReference: normalizeNonIntegratedOpeningReference(
|
||||
item.openingReference,
|
||||
),
|
||||
brand: normalizeString(item.brand) || "基本盘",
|
||||
adaptableBrands: Array.isArray(item.adaptableBrands)
|
||||
? item.adaptableBrands.filter(
|
||||
@ -1257,6 +1409,11 @@ export function saveNonIntegratedProduct(
|
||||
|
||||
const nextProduct: NonIntegratedProductRecord = {
|
||||
...product,
|
||||
projectStatus: normalizeNonIntegratedProjectStatus(product.projectStatus),
|
||||
projectReview: normalizeNonIntegratedProjectReview(product.projectReview),
|
||||
openingReference: normalizeNonIntegratedOpeningReference(
|
||||
product.openingReference,
|
||||
),
|
||||
documentWorkspaceConfirmations: normalizeDocumentWorkspaceConfirmations(
|
||||
product.documentWorkspaceConfirmations,
|
||||
),
|
||||
@ -1299,6 +1456,18 @@ export function createCopiedNonIntegratedProduct(
|
||||
...sourceProduct,
|
||||
id: buildNonIntegratedProductId(nextSpu),
|
||||
spu: nextSpu,
|
||||
projectStatus: normalizeNonIntegratedProjectStatus(
|
||||
sourceProduct.projectStatus,
|
||||
),
|
||||
projectReview: normalizeNonIntegratedProjectReview(
|
||||
sourceProduct.projectReview,
|
||||
),
|
||||
openingReference: {
|
||||
...normalizeNonIntegratedOpeningReference(sourceProduct.openingReference),
|
||||
temporaryCode: nextSpu,
|
||||
brand: targetBrand,
|
||||
adaptableBrands: [targetBrand],
|
||||
},
|
||||
brand: targetBrand,
|
||||
adaptableBrands: [targetBrand],
|
||||
styleDetails: sourceProduct.styleDetails.map((detail) => ({
|
||||
|
||||
@ -13,7 +13,7 @@ export default async function HomePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
} = {}) {
|
||||
const { user, authUser } = await requireSession('/login')
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined
|
||||
const initialTab = parseHomeWorkspaceTab(resolvedSearchParams?.tab)
|
||||
|
||||
@ -31,9 +31,12 @@ interface ResolvedPanelMarker {
|
||||
}
|
||||
|
||||
interface ResolvedHoleMarker {
|
||||
holeId: string;
|
||||
panelId: string;
|
||||
targetObject: THREE.Object3D;
|
||||
localPosition: THREE.Vector3;
|
||||
holeType: string | null;
|
||||
hardwareMatched?: boolean;
|
||||
}
|
||||
|
||||
interface ResolveStrictHoleMarkersOptions {
|
||||
@ -52,6 +55,15 @@ interface ScreenPanelMarker {
|
||||
labelY: number;
|
||||
}
|
||||
|
||||
interface ScreenHoleMarker {
|
||||
panelId: string;
|
||||
holeId: string;
|
||||
holeType: string | null;
|
||||
hardwareMatched?: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface PanelMarkerForDraw {
|
||||
panelId: string;
|
||||
label: string;
|
||||
@ -114,6 +126,47 @@ function normalizeNodeKey(value: string): string {
|
||||
.replace(/[\s_-]/g, "");
|
||||
}
|
||||
|
||||
function getHoleMarkerPalette(marker: Pick<ScreenHoleMarker, "holeType" | "hardwareMatched">) {
|
||||
switch (marker.holeType) {
|
||||
case "assembly-candidate":
|
||||
return {
|
||||
fill: "rgba(71, 100, 107, 0.30)",
|
||||
stroke: "rgba(71, 100, 107, 0.95)",
|
||||
inner: "rgba(245, 249, 249, 0.96)",
|
||||
};
|
||||
case "assembly-active":
|
||||
return {
|
||||
fill: "rgba(0, 141, 116, 0.34)",
|
||||
stroke: "rgba(0, 141, 116, 0.98)",
|
||||
inner: "rgba(245, 255, 252, 0.96)",
|
||||
};
|
||||
case "assembly-used":
|
||||
return {
|
||||
fill: "rgba(154, 129, 120, 0.28)",
|
||||
stroke: "rgba(154, 129, 120, 0.86)",
|
||||
inner: "rgba(255, 250, 247, 0.92)",
|
||||
};
|
||||
case "assembly-unused":
|
||||
return {
|
||||
fill: "rgba(143, 161, 166, 0.22)",
|
||||
stroke: "rgba(143, 161, 166, 0.72)",
|
||||
inner: "rgba(249, 251, 251, 0.90)",
|
||||
};
|
||||
default:
|
||||
return marker.hardwareMatched
|
||||
? {
|
||||
fill: "rgba(154, 129, 120, 0.28)",
|
||||
stroke: "rgba(154, 129, 120, 0.86)",
|
||||
inner: "rgba(255, 250, 247, 0.92)",
|
||||
}
|
||||
: {
|
||||
fill: "rgba(0, 184, 150, 0.35)",
|
||||
stroke: "rgba(0, 141, 116, 0.92)",
|
||||
inner: "rgba(255, 255, 255, 0.94)",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function registerPanelAlias(
|
||||
aliasMap: Map<string, string>,
|
||||
alias: string,
|
||||
@ -696,9 +749,12 @@ export function resolveStrictHoleMarkers({
|
||||
);
|
||||
|
||||
return {
|
||||
holeId: hole.id,
|
||||
panelId: resolvedHolePanelId,
|
||||
targetObject: panelState.targetObject,
|
||||
localPosition: panelState.targetObject.worldToLocal(finalWorld.clone()),
|
||||
holeType: hole.holeType ?? null,
|
||||
...(hole.hardwareMatched !== undefined ? { hardwareMatched: hole.hardwareMatched } : {}),
|
||||
};
|
||||
})
|
||||
.filter((value): value is ResolvedHoleMarker => value !== null);
|
||||
@ -1164,8 +1220,7 @@ export function HoleOverlay2D({
|
||||
let holeCenterSumZ = 0;
|
||||
let holeCenterCount = 0;
|
||||
|
||||
const projectedHoleMarkers: Array<{ panelId: string; x: number; y: number }> =
|
||||
[];
|
||||
const projectedHoleMarkers: ScreenHoleMarker[] = [];
|
||||
resolvedHoleMarkers.forEach((marker, markerIndex) => {
|
||||
const worldPosition = resolveWorldAnchor(
|
||||
marker.targetObject,
|
||||
@ -1201,6 +1256,9 @@ export function HoleOverlay2D({
|
||||
|
||||
projectedHoleMarkers.push({
|
||||
panelId: marker.panelId,
|
||||
holeId: marker.holeId,
|
||||
holeType: marker.holeType,
|
||||
hardwareMatched: marker.hardwareMatched,
|
||||
x: Number(projected.x.toFixed(2)),
|
||||
y: Number(projected.y.toFixed(2)),
|
||||
});
|
||||
@ -1446,15 +1504,22 @@ export function HoleOverlay2D({
|
||||
rightMarkers.forEach(drawLeader);
|
||||
|
||||
projectedHoleMarkers.forEach((marker) => {
|
||||
const palette = getHoleMarkerPalette(marker);
|
||||
|
||||
context.beginPath();
|
||||
context.fillStyle = "rgba(255, 255, 255, 0.94)";
|
||||
context.fillStyle = palette.inner;
|
||||
context.arc(marker.x, marker.y, 2, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
|
||||
context.beginPath();
|
||||
context.strokeStyle = "rgba(0, 141, 116, 0.92)";
|
||||
context.fillStyle = palette.fill;
|
||||
context.arc(marker.x, marker.y, 4.6, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
|
||||
context.beginPath();
|
||||
context.strokeStyle = palette.stroke;
|
||||
context.lineWidth = 1.2;
|
||||
context.arc(marker.x, marker.y, 4, 0, Math.PI * 2);
|
||||
context.arc(marker.x, marker.y, 4.8, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
});
|
||||
|
||||
|
||||
@ -8,6 +8,12 @@ export const HOLE_COLOR_TOKENS = {
|
||||
screw: '#9C27B0',
|
||||
dowel: '#795548',
|
||||
cable: '#607D8B',
|
||||
'assembly-unused': '#8FA1A6',
|
||||
'assembly-candidate': '#47646B',
|
||||
'assembly-active': '#008D74',
|
||||
'assembly-used': '#9A8178',
|
||||
'assembly-snap-source': '#47646B',
|
||||
'assembly-snap-target': '#008D74',
|
||||
unknown: '#E91E63',
|
||||
},
|
||||
selection: {
|
||||
@ -36,6 +42,12 @@ export const HOLE_TYPE_COLORS: Record<string, number> = {
|
||||
screw: toThreeHex(HOLE_COLOR_TOKENS.holeTypes.screw),
|
||||
dowel: toThreeHex(HOLE_COLOR_TOKENS.holeTypes.dowel),
|
||||
cable: toThreeHex(HOLE_COLOR_TOKENS.holeTypes.cable),
|
||||
'assembly-unused': toThreeHex(HOLE_COLOR_TOKENS.holeTypes['assembly-unused']),
|
||||
'assembly-candidate': toThreeHex(HOLE_COLOR_TOKENS.holeTypes['assembly-candidate']),
|
||||
'assembly-active': toThreeHex(HOLE_COLOR_TOKENS.holeTypes['assembly-active']),
|
||||
'assembly-used': toThreeHex(HOLE_COLOR_TOKENS.holeTypes['assembly-used']),
|
||||
'assembly-snap-source': toThreeHex(HOLE_COLOR_TOKENS.holeTypes['assembly-snap-source']),
|
||||
'assembly-snap-target': toThreeHex(HOLE_COLOR_TOKENS.holeTypes['assembly-snap-target']),
|
||||
unknown: toThreeHex(HOLE_COLOR_TOKENS.holeTypes.unknown),
|
||||
}
|
||||
|
||||
|
||||
@ -5,6 +5,12 @@ export const HOLE_TYPE_COLORS: Record<string, number> = {
|
||||
screw: 0x9C27B0,
|
||||
dowel: 0x795548,
|
||||
cable: 0x607D8B,
|
||||
'assembly-unused': 0x8FA1A6,
|
||||
'assembly-candidate': 0x47646B,
|
||||
'assembly-active': 0x008D74,
|
||||
'assembly-used': 0x9A8178,
|
||||
'assembly-snap-source': 0x47646B,
|
||||
'assembly-snap-target': 0x008D74,
|
||||
unknown: 0xE91E63,
|
||||
}
|
||||
|
||||
|
||||
@ -241,60 +241,102 @@ export function AssemblyWorkbenchV2({
|
||||
return [activeSnapCandidate.targetNodeId]
|
||||
}, [activeSnapCandidate])
|
||||
const holeOverlayState = useMemo(() => {
|
||||
if (!selectedSingleNode || !activeSnapCandidate) {
|
||||
return {
|
||||
holes: [] as HoleOverlayData[],
|
||||
activeHolePanelIds: [] as string[],
|
||||
panelNodeNameByPanelId: {} as Record<string, string>,
|
||||
panelLabelByPanelId: {} as Record<string, string>,
|
||||
const holesById = new Map<string, HoleOverlayData>()
|
||||
const activeHolePanelIdSet = new Set<string>()
|
||||
const panelNodeNameByPanelId: Record<string, string> = {}
|
||||
const panelLabelByPanelId: Record<string, string> = {}
|
||||
|
||||
const registerHole = (
|
||||
hole: HoleOverlayData,
|
||||
nodeId: string,
|
||||
label: string,
|
||||
) => {
|
||||
holesById.set(hole.id, hole)
|
||||
if (hole.panelId) {
|
||||
panelNodeNameByPanelId[hole.panelId] = nodeId
|
||||
panelLabelByPanelId[hole.panelId] = label
|
||||
}
|
||||
}
|
||||
|
||||
const targetNode =
|
||||
presentWorkspace.nodes.find((node) => node.nodeId === activeSnapCandidate.targetNodeId) ?? null
|
||||
const sourcePanelId = buildSnapHolePanelId(
|
||||
activeSnapCandidate.sourceNodeId,
|
||||
activeSnapCandidate.sourceHoleId,
|
||||
)
|
||||
const targetPanelId = buildSnapHolePanelId(
|
||||
activeSnapCandidate.targetNodeId,
|
||||
activeSnapCandidate.targetHoleId,
|
||||
)
|
||||
const appendCandidateHoles = (
|
||||
candidate: NonNullable<typeof activeSnapCandidate>,
|
||||
sourceNode: AssemblyWorkspaceNode,
|
||||
targetNode: AssemblyWorkspaceNode | null,
|
||||
holeType: string,
|
||||
confidence = 1,
|
||||
) => {
|
||||
if (candidate.snapMode === 'surface') return
|
||||
|
||||
return {
|
||||
holes: [
|
||||
const sourcePanelId = buildSnapHolePanelId(candidate.sourceNodeId, candidate.sourceHoleId)
|
||||
const targetPanelId = buildSnapHolePanelId(candidate.targetNodeId, candidate.targetHoleId)
|
||||
if (holeType === 'assembly-active' || holeType === 'assembly-candidate') {
|
||||
activeHolePanelIdSet.add(sourcePanelId)
|
||||
activeHolePanelIdSet.add(targetPanelId)
|
||||
}
|
||||
|
||||
registerHole(
|
||||
{
|
||||
id: sourcePanelId,
|
||||
panelId: sourcePanelId,
|
||||
position: activeSnapCandidate.sourceHoleWorldPosition,
|
||||
diameter: activeSnapCandidate.sourceHoleDiameter ?? 8,
|
||||
depth: activeSnapCandidate.sourceHoleDepth ?? 12,
|
||||
normal: activeSnapCandidate.sourceHoleNormal,
|
||||
holeType: 'assembly-snap-source',
|
||||
confidence: 1,
|
||||
position: candidate.sourceHoleWorldPosition,
|
||||
diameter: candidate.sourceHoleDiameter ?? 8,
|
||||
depth: candidate.sourceHoleDepth ?? 12,
|
||||
normal: candidate.sourceHoleNormal,
|
||||
holeType,
|
||||
confidence,
|
||||
hardwareMatched: holeType === 'assembly-used',
|
||||
},
|
||||
candidate.sourceNodeId,
|
||||
sourceNode.name,
|
||||
)
|
||||
registerHole(
|
||||
{
|
||||
id: targetPanelId,
|
||||
panelId: targetPanelId,
|
||||
position: activeSnapCandidate.targetHoleWorldPosition,
|
||||
diameter: activeSnapCandidate.targetHoleDiameter ?? 8,
|
||||
depth: activeSnapCandidate.targetHoleDepth ?? 12,
|
||||
normal: activeSnapCandidate.targetHoleNormal,
|
||||
holeType: 'assembly-snap-target',
|
||||
confidence: 1,
|
||||
},
|
||||
],
|
||||
activeHolePanelIds: [sourcePanelId, targetPanelId],
|
||||
panelNodeNameByPanelId: {
|
||||
[sourcePanelId]: selectedSingleNode.nodeId,
|
||||
[targetPanelId]: activeSnapCandidate.targetNodeId,
|
||||
},
|
||||
panelLabelByPanelId: {
|
||||
[sourcePanelId]: selectedSingleNode.name,
|
||||
[targetPanelId]: targetNode?.name ?? activeSnapCandidate.targetNodeId,
|
||||
position: candidate.targetHoleWorldPosition,
|
||||
diameter: candidate.targetHoleDiameter ?? 8,
|
||||
depth: candidate.targetHoleDepth ?? 12,
|
||||
normal: candidate.targetHoleNormal,
|
||||
holeType,
|
||||
confidence,
|
||||
hardwareMatched: holeType === 'assembly-used',
|
||||
},
|
||||
candidate.targetNodeId,
|
||||
targetNode?.name ?? candidate.targetNodeId,
|
||||
)
|
||||
}
|
||||
}, [activeSnapCandidate, presentWorkspace.nodes, selectedSingleNode])
|
||||
|
||||
presentWorkspace.nodes.forEach((node) => {
|
||||
if (!node.snapBinding) return
|
||||
const displayCandidate = deriveAssemblyActiveSnapCandidate(presentWorkspace, node.nodeId)
|
||||
const targetNode =
|
||||
displayCandidate
|
||||
? presentWorkspace.nodes.find((item) => item.nodeId === displayCandidate.targetNodeId) ?? null
|
||||
: null
|
||||
if (displayCandidate) {
|
||||
appendCandidateHoles(displayCandidate, node, targetNode, 'assembly-used', 0.72)
|
||||
}
|
||||
})
|
||||
|
||||
if (selectedSingleNode && activeSnapCandidate) {
|
||||
const targetNode =
|
||||
presentWorkspace.nodes.find((node) => node.nodeId === activeSnapCandidate.targetNodeId) ?? null
|
||||
appendCandidateHoles(
|
||||
activeSnapCandidate,
|
||||
selectedSingleNode,
|
||||
targetNode,
|
||||
selectedSingleNode.isSnapped ? 'assembly-active' : 'assembly-candidate',
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
holes: Array.from(holesById.values()),
|
||||
activeHolePanelIds: Array.from(activeHolePanelIdSet),
|
||||
panelNodeNameByPanelId,
|
||||
panelLabelByPanelId,
|
||||
}
|
||||
}, [activeSnapCandidate, presentWorkspace, selectedSingleNode])
|
||||
const activeAssetBrowserItems = useMemo(() => {
|
||||
if (!activeAssetBrowserSource) return []
|
||||
return presentWorkspace.inventory.filter((item) => item.source === activeAssetBrowserSource)
|
||||
@ -323,6 +365,7 @@ export function AssemblyWorkbenchV2({
|
||||
return presentWorkspace.nodes.map((node) => ({
|
||||
nodeName: node.nodeId,
|
||||
modelUrl: node.modelUrl,
|
||||
expectedDimensions: node.dimensions ?? null,
|
||||
position: node.position,
|
||||
rotation: node.rotation,
|
||||
scale: node.scale,
|
||||
@ -900,7 +943,7 @@ export function AssemblyWorkbenchV2({
|
||||
key={workspaceKey ?? 'assisted-assembly-workspace'}
|
||||
modelUrl={null}
|
||||
worldNodes={stageWorldNodes.length > 0 ? stageWorldNodes : undefined}
|
||||
worldFrameMode="initial"
|
||||
worldFrameMode="always"
|
||||
title={title ?? candidateName}
|
||||
customViewportContent={stageWorldNodes.length === 0 ? <AssemblyWorkbenchEmptyBackdrop /> : undefined}
|
||||
customHasContent={stageWorldNodes.length === 0}
|
||||
|
||||
@ -73,7 +73,10 @@ jest.mock('../ModelPreviewPanel', () => {
|
||||
onNodeSelect,
|
||||
onNodeContextMenu,
|
||||
}: {
|
||||
worldNodes?: Array<{ nodeName: string }>
|
||||
worldNodes?: Array<{
|
||||
nodeName: string
|
||||
expectedDimensions?: Record<string, number | string | null | undefined> | null
|
||||
}>
|
||||
worldFrameMode?: 'always' | 'initial' | 'never'
|
||||
selectedNodeName?: string | null
|
||||
materialAssignments?: Array<{ nodeName: string }>
|
||||
@ -105,6 +108,9 @@ jest.mock('../ModelPreviewPanel', () => {
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="world-node-count">世界节点:{worldNodes.length}</div>
|
||||
<div data-testid="world-node-dimensions">
|
||||
{JSON.stringify(worldNodes.map((node) => node.expectedDimensions ?? null))}
|
||||
</div>
|
||||
<div data-testid="world-frame-mode">{worldFrameMode ?? 'none'}</div>
|
||||
<div data-testid="selected-node">{selectedNodeName ?? 'none'}</div>
|
||||
<div data-testid="material-assignment-count">材质指派:{materialAssignments.length}</div>
|
||||
@ -222,7 +228,7 @@ function buildVariants(): AssistedAssemblyVariant[] {
|
||||
modelUrl: '/board.glb',
|
||||
thumbnailUrl: null,
|
||||
runtimeThumbnailUrl: '/board-material.png',
|
||||
dimensions: {},
|
||||
dimensions: { length: 1200, width: 600, thickness: 25 },
|
||||
mountPoints: [],
|
||||
assemblyBasis: null,
|
||||
materialConfig: {
|
||||
@ -246,7 +252,7 @@ function buildVariants(): AssistedAssemblyVariant[] {
|
||||
status: 'AVAILABLE',
|
||||
modelUrl: '/leg.glb',
|
||||
thumbnailUrl: null,
|
||||
dimensions: {},
|
||||
dimensions: { length: 50, width: 50, height: 720 },
|
||||
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
|
||||
assemblyBasis: {
|
||||
attachTo: 'desktop',
|
||||
@ -491,6 +497,16 @@ async function openBoardBrowserAndLoadFirstVariant(actor: ReturnType<typeof user
|
||||
await actor.click(screen.getByRole('button', { name: '加载桌面A-001到 3D 世界' }))
|
||||
}
|
||||
|
||||
async function openLegBrowserAndLoadFirstVariant(actor: ReturnType<typeof userEvent.setup>) {
|
||||
await actor.click(screen.getByRole('button', { name: '打开组件物料' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('asset-browser-panel')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '选择桌腿A资产族' })).toBeInTheDocument()
|
||||
})
|
||||
await actor.click(screen.getByRole('button', { name: '选择桌腿A资产族' }))
|
||||
await actor.click(screen.getByRole('button', { name: '加载桌腿A-001到 3D 世界' }))
|
||||
}
|
||||
|
||||
describe('AssemblyWorkbenchV2', () => {
|
||||
it('默认显示统一 3D 世界,并在工具条网格下方提供资产分类入口', async () => {
|
||||
const actor = userEvent.setup()
|
||||
@ -498,7 +514,7 @@ describe('AssemblyWorkbenchV2', () => {
|
||||
render(<ControlledWorkbench rightDrawerContent={<div>右侧业务区</div>} />)
|
||||
|
||||
expect(screen.getByTestId('world-node-count')).toHaveTextContent('世界节点:0')
|
||||
expect(screen.getByTestId('world-frame-mode')).toHaveTextContent('initial')
|
||||
expect(screen.getByTestId('world-frame-mode')).toHaveTextContent('always')
|
||||
expect(screen.getByRole('button', { name: '打开板材物料' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '打开组件物料' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '打开五金物料' })).toBeDisabled()
|
||||
@ -560,6 +576,41 @@ describe('AssemblyWorkbenchV2', () => {
|
||||
expect(latestSnapshot.nodes[0]?.materialConfig?.id).toBe('finish-oak')
|
||||
})
|
||||
|
||||
it('连续点击多个规格款会追加到同一个 3D 世界,并保持追加后重新构图', async () => {
|
||||
const actor = userEvent.setup()
|
||||
const onChange = jest.fn()
|
||||
|
||||
render(
|
||||
<ControlledWorkbench
|
||||
onSnapshotChange={onChange}
|
||||
rightDrawerContent={<div>右侧业务区</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
await openBoardBrowserAndLoadFirstVariant(actor)
|
||||
await openLegBrowserAndLoadFirstVariant(actor)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('world-node-count')).toHaveTextContent('世界节点:2')
|
||||
expect(screen.getByTestId('world-node-dimensions')).toHaveTextContent(
|
||||
JSON.stringify([
|
||||
{ length: 1200, width: 600, thickness: 25 },
|
||||
{ length: 50, width: 50, height: 720 },
|
||||
]),
|
||||
)
|
||||
expect(screen.getByTestId('world-frame-mode')).toHaveTextContent('always')
|
||||
expect(screen.getByTestId('selected-node')).not.toHaveTextContent('none')
|
||||
})
|
||||
|
||||
const latestSnapshot = onChange.mock.calls.at(-1)?.[0] as AssemblyWorkspaceSnapshot
|
||||
expect(latestSnapshot.nodes).toHaveLength(2)
|
||||
expect(latestSnapshot.nodes.map((node) => node.variantId)).toEqual([
|
||||
'variant-board-1',
|
||||
'variant-leg-1',
|
||||
])
|
||||
expect(latestSnapshot.nodes[1]?.zone).toBe('staging')
|
||||
})
|
||||
|
||||
it('右键菜单可以移除 3D 世界里的模型', async () => {
|
||||
const actor = userEvent.setup()
|
||||
const onChange = jest.fn()
|
||||
@ -628,7 +679,7 @@ describe('AssemblyWorkbenchV2', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('选中缺少安装图样语义的节点时,会明确提示无法自动吸附', async () => {
|
||||
it('选中缺少吸附图样语义的节点时,会明确提示无法自动吸附', async () => {
|
||||
const actor = userEvent.setup()
|
||||
|
||||
render(
|
||||
@ -641,8 +692,8 @@ describe('AssemblyWorkbenchV2', () => {
|
||||
await actor.click(screen.getByRole('button', { name: '模拟选中第二节点' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('缺少安装图样语义')).toBeInTheDocument()
|
||||
expect(screen.getByText('当前资产缺少安装图样语义,无法自动吸附。')).toBeInTheDocument()
|
||||
expect(screen.getByText('缺少吸附图样语义')).toBeInTheDocument()
|
||||
expect(screen.getByText('当前资产缺少吸附图样语义,无法自动吸附。')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -73,6 +73,7 @@ export type EngineWorldNodeSource =
|
||||
export interface EngineWorldNode {
|
||||
nodeName: string;
|
||||
modelUrl?: string | null;
|
||||
expectedDimensions?: ModelExpectedDimensions | null;
|
||||
position?: [number, number, number];
|
||||
rotation?: [number, number, number];
|
||||
scale?: [number, number, number];
|
||||
@ -597,8 +598,12 @@ export class ThreeDEngine {
|
||||
const fov = this.camera.fov * (Math.PI / 180);
|
||||
let distance = Math.abs(maxDim / 2 / Math.tan(fov / 2));
|
||||
distance *= 1.8;
|
||||
const cameraDistance =
|
||||
distance * Math.sqrt(0.9 * 0.9 + 0.6 * 0.6 + 1.1 * 1.1);
|
||||
const cameraOffset = new THREE.Vector3(
|
||||
distance * 1.02,
|
||||
distance * 0.74,
|
||||
distance * 1.18,
|
||||
);
|
||||
const cameraDistance = cameraOffset.length();
|
||||
this.camera.near = Math.max(0.01, cameraDistance * 0.0002);
|
||||
this.camera.far = cameraDistance * 20;
|
||||
this.camera.updateProjectionMatrix();
|
||||
@ -611,7 +616,7 @@ export class ThreeDEngine {
|
||||
});
|
||||
}
|
||||
|
||||
this.camera.position.set(distance * 0.9, distance * 0.6, distance * 1.1);
|
||||
this.camera.position.copy(cameraTarget).add(cameraOffset);
|
||||
this.camera.lookAt(cameraTarget);
|
||||
this.controls.target.copy(cameraTarget);
|
||||
const minDistance = Math.max(
|
||||
@ -820,6 +825,33 @@ export class ThreeDEngine {
|
||||
return groundedRoot;
|
||||
}
|
||||
|
||||
private applyExpectedDimensionsScale(
|
||||
model: THREE.Object3D,
|
||||
expectedDimensions?: ModelExpectedDimensions | null,
|
||||
): void {
|
||||
const box = new THREE.Box3().setFromObject(model);
|
||||
if (box.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceSize = box.getSize(new THREE.Vector3());
|
||||
const unitScale = resolveModelUnitScaleFromBounds(
|
||||
{
|
||||
x: sourceSize.x,
|
||||
y: sourceSize.y,
|
||||
z: sourceSize.z,
|
||||
},
|
||||
expectedDimensions,
|
||||
);
|
||||
|
||||
if (unitScale === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.scale.multiplyScalar(unitScale);
|
||||
model.updateMatrixWorld(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 鍔犺浇glTF妯″瀷
|
||||
* @param url 妯″瀷鏂囦欢URL
|
||||
@ -829,24 +861,8 @@ export class ThreeDEngine {
|
||||
options: EngineModelLoadOptions = {},
|
||||
): Promise<THREE.Group> {
|
||||
const { model, extension } = await this.loadModelAsset(url);
|
||||
this.applyExpectedDimensionsScale(model, options.expectedDimensions);
|
||||
let box = new THREE.Box3().setFromObject(model);
|
||||
if (!box.isEmpty()) {
|
||||
const sourceSize = box.getSize(new THREE.Vector3());
|
||||
const unitScale = resolveModelUnitScaleFromBounds(
|
||||
{
|
||||
x: sourceSize.x,
|
||||
y: sourceSize.y,
|
||||
z: sourceSize.z,
|
||||
},
|
||||
options.expectedDimensions,
|
||||
);
|
||||
|
||||
if (unitScale !== 1) {
|
||||
model.scale.multiplyScalar(unitScale);
|
||||
model.updateMatrixWorld(true);
|
||||
box = new THREE.Box3().setFromObject(model);
|
||||
}
|
||||
}
|
||||
if (SELECTION_DEBUG_ENABLED) {
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
@ -909,6 +925,7 @@ export class ThreeDEngine {
|
||||
let content: THREE.Object3D;
|
||||
if (node.modelUrl?.trim()) {
|
||||
const { model } = await this.loadModelAsset(node.modelUrl.trim());
|
||||
this.applyExpectedDimensionsScale(model, node.expectedDimensions);
|
||||
content = this.buildWorkbenchNodeContent(model);
|
||||
} else {
|
||||
content = this.createWorkbenchProxy(source);
|
||||
|
||||
@ -84,6 +84,66 @@ describe('sku-cad-data', () => {
|
||||
requiredHoleIds: ['hole-top-1'],
|
||||
}),
|
||||
)
|
||||
expect(result?.snapPatterns[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'pattern-top-1',
|
||||
snapFace: 'bottom',
|
||||
holeIds: ['hole-top-1'],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('优先保留 snapPatterns 和 snapSurfaces,并把旧 mountPatterns 映射为兼容字段', () => {
|
||||
const result = buildPersistedSkuCadData({
|
||||
pluginVersion: '2.7.0',
|
||||
units: 'mm',
|
||||
snapPatterns: [
|
||||
{
|
||||
id: 'snap-pattern-1',
|
||||
panelId: 'panel-top',
|
||||
snapFace: 'bottom',
|
||||
patternType: 'four-corner',
|
||||
holeIds: ['hole-top-1'],
|
||||
requiredHoleIds: ['hole-top-1'],
|
||||
minRequiredHoles: 1,
|
||||
preferredHoleCount: 1,
|
||||
position: [0, 0, 0],
|
||||
normal: [0, -1, 0],
|
||||
},
|
||||
],
|
||||
snapSurfaces: [
|
||||
{
|
||||
id: 'panel-top-bottom',
|
||||
panelId: 'panel-top',
|
||||
face: 'bottom',
|
||||
center: [0, -9, 0],
|
||||
normal: [0, -1, 0],
|
||||
bounds: {
|
||||
min: [-600, -9, -300],
|
||||
max: [600, -9, 300],
|
||||
},
|
||||
confidence: 0.92,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.snapPatterns[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'snap-pattern-1',
|
||||
mountFace: 'bottom',
|
||||
snapFace: 'bottom',
|
||||
}),
|
||||
)
|
||||
expect(result?.mountPatterns[0]?.id).toBe('snap-pattern-1')
|
||||
expect(result?.snapSurfaces[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'panel-top-bottom',
|
||||
panelId: 'panel-top',
|
||||
face: 'bottom',
|
||||
confidence: 0.92,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('从 SKU 属性读取时,会归一化 CAD 底稿和装配语义快照', () => {
|
||||
|
||||
@ -30,6 +30,8 @@ export interface CADExportData {
|
||||
panels?: CADPanel[]
|
||||
holes?: CADHole[]
|
||||
holeGroups?: CADHoleGroup[]
|
||||
snapPatterns?: CADMountPattern[]
|
||||
snapSurfaces?: CADSnapSurface[]
|
||||
mountPatterns?: CADMountPattern[]
|
||||
/**
|
||||
* 被过滤/排除的孔位(兜底用)。
|
||||
@ -112,6 +114,7 @@ export interface CADMountPattern {
|
||||
id: string
|
||||
panelId: string
|
||||
mountFace: string
|
||||
snapFace?: string
|
||||
patternType: string
|
||||
holeIds: string[]
|
||||
requiredHoleIds?: string[]
|
||||
@ -125,6 +128,20 @@ export interface CADMountPattern {
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface CADSnapSurface {
|
||||
id: string
|
||||
panelId: string
|
||||
face: string
|
||||
normal: number[]
|
||||
center: number[]
|
||||
bounds?: {
|
||||
min: number[]
|
||||
max: number[]
|
||||
}
|
||||
confidence?: number
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface CADSuggestedFastener {
|
||||
category: string
|
||||
nominalDiameter?: number
|
||||
@ -255,6 +272,8 @@ export interface ProcessingResult {
|
||||
coordinateSystem?: string
|
||||
panels: ProcessedPanel[]
|
||||
holeGroups: CADHoleGroup[]
|
||||
snapPatterns: CADMountPattern[]
|
||||
snapSurfaces: CADSnapSurface[]
|
||||
mountPatterns: CADMountPattern[]
|
||||
hardware: ProcessedHardware[]
|
||||
statistics: {
|
||||
@ -460,6 +479,8 @@ export function processCADExport(data: CADExportData): ProcessingResult {
|
||||
coordinateSystem: data.coordinateSystem,
|
||||
panels: [],
|
||||
holeGroups: [],
|
||||
snapPatterns: [],
|
||||
snapSurfaces: [],
|
||||
mountPatterns: [],
|
||||
hardware: [],
|
||||
statistics: {
|
||||
@ -489,6 +510,8 @@ export function processCADExport(data: CADExportData): ProcessingResult {
|
||||
|
||||
const totalHoles = panels.reduce((sum, p) => sum + p.holes.length, 0)
|
||||
const totalHardwareCount = hardware.reduce((sum, h) => sum + h.count, 0)
|
||||
const snapPatterns = data.snapPatterns ?? data.mountPatterns ?? []
|
||||
const mountPatterns = data.mountPatterns ?? data.snapPatterns ?? []
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@ -497,7 +520,9 @@ export function processCADExport(data: CADExportData): ProcessingResult {
|
||||
coordinateSystem: data.coordinateSystem,
|
||||
panels,
|
||||
holeGroups: data.holeGroups ?? [],
|
||||
mountPatterns: data.mountPatterns ?? [],
|
||||
snapPatterns,
|
||||
snapSurfaces: data.snapSurfaces ?? [],
|
||||
mountPatterns,
|
||||
hardware,
|
||||
statistics: {
|
||||
totalPanels: panels.length,
|
||||
|
||||
@ -204,6 +204,21 @@ describe('assembly-workspace', () => {
|
||||
},
|
||||
}
|
||||
|
||||
const convertMountPatternsToSnapPatterns = (
|
||||
variant: OpenProductAssetVariant,
|
||||
): OpenProductAssetVariant => {
|
||||
const assemblyBasis = variant.assemblyBasis ?? {}
|
||||
const { mountPatterns, ...basisWithoutLegacyPatterns } = assemblyBasis
|
||||
|
||||
return {
|
||||
...variant,
|
||||
assemblyBasis: {
|
||||
...basisWithoutLegacyPatterns,
|
||||
snapPatterns: mountPatterns,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
it('会先以资产族库存打开工作台,而不是直接生成候选节点', () => {
|
||||
expect(baseWorkspace.inventory).toHaveLength(2)
|
||||
expect(baseWorkspace.nodes).toHaveLength(0)
|
||||
@ -233,6 +248,51 @@ describe('assembly-workspace', () => {
|
||||
expect(snapshot.nodes[0]?.source).toBe('desktop')
|
||||
})
|
||||
|
||||
it('追加支撑件时默认落在桌面板装配目标附近,而不是远处待装区', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: desktopVariant,
|
||||
})
|
||||
const withLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: legVariant,
|
||||
})
|
||||
|
||||
expect(withLeg.nodes).toHaveLength(2)
|
||||
expect(withLeg.nodes[1]?.zone).toBe('staging')
|
||||
expect(withLeg.nodes[1]?.position).toEqual([-450, 360, 250])
|
||||
expect(withLeg.nodes[1]?.targetPosition).toEqual([-450, 360, 250])
|
||||
})
|
||||
|
||||
it('同一个工作区可以同时保留多个规格款实例', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: desktopVariant,
|
||||
})
|
||||
const withFirstLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: legVariant,
|
||||
})
|
||||
const withSecondLeg = instantiateAssemblyNode(withFirstLeg, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: {
|
||||
...legVariant,
|
||||
id: 'variant-leg-wide',
|
||||
code: 'LEG-A-WIDE-001',
|
||||
name: '宽桌腿A',
|
||||
},
|
||||
})
|
||||
|
||||
expect(withSecondLeg.nodes).toHaveLength(3)
|
||||
expect(new Set(withSecondLeg.nodes.map((node) => node.nodeId)).size).toBe(3)
|
||||
expect(withSecondLeg.nodes.map((node) => node.variantId)).toEqual([
|
||||
'variant-desktop',
|
||||
'variant-leg',
|
||||
'variant-leg-wide',
|
||||
])
|
||||
expect(withSecondLeg.inventory.find((item) => item.source === 'leg')?.instanceCount).toBe(2)
|
||||
})
|
||||
|
||||
it('主装配区只有 1 个桌面板且支撑件吸附后,才允许保存', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
@ -274,6 +334,34 @@ describe('assembly-workspace', () => {
|
||||
expect(deriveAssemblySaveValidation(snapped).level).toBe('PASSED')
|
||||
})
|
||||
|
||||
it('同时存在目标位和孔位候选时,松手吸附优先使用孔位绑定', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: desktopVariant,
|
||||
})
|
||||
const withLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: legVariant,
|
||||
})
|
||||
|
||||
expect(withLeg.nodes[1]?.targetPosition).toEqual([-450, 360, 250])
|
||||
|
||||
const moved = updateAssemblyNodeTransform(withLeg, withLeg.nodes[1]!.nodeId, {
|
||||
position: [0, 720, 0],
|
||||
})
|
||||
const snapped = attemptSnapNode(moved, withLeg.nodes[1]!.nodeId)
|
||||
|
||||
expect(snapped.nodes[1]?.position).toEqual([0, 720, 0])
|
||||
expect(snapped.nodes[1]?.position).not.toEqual(snapped.nodes[1]?.targetPosition)
|
||||
expect(snapped.nodes[1]?.snapStatus).toBe('hole-snap')
|
||||
expect(snapped.nodes[1]?.snapBinding).toMatchObject({
|
||||
sourceHoleId: 'leg-hole-1',
|
||||
targetHoleId: 'board-hole-1',
|
||||
sourceMountPatternId: 'leg-pattern-1',
|
||||
targetMountPatternId: 'board-pattern-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('拖到兼容孔位附近并松手后,会完成自动吸附', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
@ -363,7 +451,7 @@ describe('assembly-workspace', () => {
|
||||
expect(released.nodes[1]?.snapStatus ?? null).toBeNull()
|
||||
})
|
||||
|
||||
it('缺少孔位和安装图样语义的组件不会再回退到旧的中心点吸附', () => {
|
||||
it('缺少孔位和吸附图样语义的组件不会再回退到旧的中心点吸附', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: desktopVariant,
|
||||
@ -385,7 +473,65 @@ describe('assembly-workspace', () => {
|
||||
|
||||
const feedback = deriveAssemblyAssistFeedback(moved, moved.nodes[1]!.nodeId)
|
||||
expect(feedback).toMatchObject({
|
||||
title: '缺少孔位与安装图样语义',
|
||||
title: '缺少孔位与吸附图样语义',
|
||||
canAutoSnap: false,
|
||||
withinSnapRange: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('缺少孔位或缺少吸附图样时会给出明确状态且不会崩溃', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: desktopVariant,
|
||||
})
|
||||
const withMissingPatternLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: {
|
||||
...legVariant,
|
||||
id: 'variant-leg-missing-pattern',
|
||||
assemblyBasis: {
|
||||
...legVariant.assemblyBasis,
|
||||
mountPatterns: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
const missingPatternFeedback = deriveAssemblyAssistFeedback(
|
||||
withMissingPatternLeg,
|
||||
withMissingPatternLeg.nodes[1]!.nodeId,
|
||||
)
|
||||
|
||||
expect(deriveAssemblyAutoSnapCandidate(
|
||||
withMissingPatternLeg,
|
||||
withMissingPatternLeg.nodes[1]!.nodeId,
|
||||
)).toBeNull()
|
||||
expect(missingPatternFeedback).toMatchObject({
|
||||
title: '缺少吸附图样语义',
|
||||
canAutoSnap: false,
|
||||
withinSnapRange: false,
|
||||
})
|
||||
|
||||
const withMissingHoleLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: {
|
||||
...legVariant,
|
||||
id: 'variant-leg-missing-hole',
|
||||
assemblyBasis: {
|
||||
...legVariant.assemblyBasis,
|
||||
holes: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
const missingHoleFeedback = deriveAssemblyAssistFeedback(
|
||||
withMissingHoleLeg,
|
||||
withMissingHoleLeg.nodes[1]!.nodeId,
|
||||
)
|
||||
|
||||
expect(deriveAssemblyAutoSnapCandidate(
|
||||
withMissingHoleLeg,
|
||||
withMissingHoleLeg.nodes[1]!.nodeId,
|
||||
)).toBeNull()
|
||||
expect(missingHoleFeedback).toMatchObject({
|
||||
title: '缺少孔位语义',
|
||||
canAutoSnap: false,
|
||||
withinSnapRange: false,
|
||||
})
|
||||
@ -549,6 +695,28 @@ describe('assembly-workspace', () => {
|
||||
expect(activeCandidate?.targetNodeId).toBe(withDesktop.nodes[0]?.nodeId)
|
||||
})
|
||||
|
||||
it('assemblyBasis.snapPatterns 新字段可以替代旧 mountPatterns 参与孔位吸附', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: convertMountPatternsToSnapPatterns(desktopVariant),
|
||||
})
|
||||
const withLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: convertMountPatternsToSnapPatterns(legVariant),
|
||||
})
|
||||
|
||||
const moved = updateAssemblyNodeTransform(withLeg, withLeg.nodes[1]!.nodeId, {
|
||||
position: [0, 720, 0],
|
||||
})
|
||||
const candidate = deriveAssemblyAutoSnapCandidate(moved, moved.nodes[1]!.nodeId)
|
||||
|
||||
expect(candidate).not.toBeNull()
|
||||
expect(candidate?.sourceMountPatternId).toBe('leg-pattern-1')
|
||||
expect(candidate?.targetMountPatternId).toBe('board-pattern-1')
|
||||
expect(candidate?.sourceHoleId).toBe('leg-hole-1')
|
||||
expect(candidate?.targetHoleId).toBe('board-hole-1')
|
||||
})
|
||||
|
||||
it('CAD 坐标系孔位会先转成 Three.js 坐标,再参与孔位吸附候选', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
@ -571,7 +739,7 @@ describe('assembly-workspace', () => {
|
||||
expect(candidate?.targetMountPatternId).toBe('board-pattern-1')
|
||||
})
|
||||
|
||||
it('两个支撑件抢同一安装孔位 / 图样时,会判定空间冲突并阻止保存', () => {
|
||||
it('手动形成两个支撑件占用同一吸附孔位 / 图样时,会判定空间冲突并阻止保存', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: desktopVariant,
|
||||
@ -600,16 +768,35 @@ describe('assembly-workspace', () => {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: secondLegVariant,
|
||||
})
|
||||
const secondSnapped = attemptSnapNode(
|
||||
applyAutomaticAssemblySnapPreview(
|
||||
updateAssemblyNodeTransform(withSecondLeg, withSecondLeg.nodes[2]!.nodeId, {
|
||||
position: [0, 720, 0],
|
||||
}),
|
||||
withSecondLeg.nodes[2]!.nodeId,
|
||||
{ fallbackZone: 'staging' },
|
||||
const secondSnapped = {
|
||||
...withSecondLeg,
|
||||
nodes: withSecondLeg.nodes.map((node) =>
|
||||
node.nodeId === withSecondLeg.nodes[2]!.nodeId
|
||||
? {
|
||||
...node,
|
||||
zone: 'main' as const,
|
||||
isSnapped: true,
|
||||
snapStatus: 'hole-snap' as const,
|
||||
snapBinding: {
|
||||
targetNodeId: withSecondLeg.nodes[0]!.nodeId,
|
||||
sourceHoleId: 'leg-hole-1',
|
||||
targetHoleId: 'board-hole-1',
|
||||
sourceHolePanelId: 'leg-panel',
|
||||
targetHolePanelId: 'board-panel',
|
||||
sourceFace: 'top',
|
||||
targetFace: 'bottom',
|
||||
sourceMountPatternId: 'leg-pattern-1',
|
||||
targetMountPatternId: 'board-pattern-1',
|
||||
snapMode: 'hole' as const,
|
||||
sourceSnapPatternId: 'leg-pattern-1',
|
||||
targetSnapPatternId: 'board-pattern-1',
|
||||
sourceSurfaceId: null,
|
||||
targetSurfaceId: null,
|
||||
},
|
||||
}
|
||||
: node,
|
||||
),
|
||||
withSecondLeg.nodes[2]!.nodeId,
|
||||
)
|
||||
}
|
||||
const saveValidation = deriveAssemblySaveValidation(secondSnapped)
|
||||
|
||||
expect(secondSnapped.nodes[1]?.snapBinding?.targetHoleId).toBe('board-hole-1')
|
||||
@ -618,4 +805,48 @@ describe('assembly-workspace', () => {
|
||||
expect.arrayContaining([expect.stringContaining('空间冲突')]),
|
||||
)
|
||||
})
|
||||
|
||||
it('已占用的目标孔位和吸附图样不会再次生成自动吸附候选', () => {
|
||||
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
|
||||
inventoryId: 'desktop:family-desktop:1',
|
||||
variant: desktopVariant,
|
||||
})
|
||||
const firstLeg = instantiateAssemblyNode(withDesktop, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: legVariant,
|
||||
})
|
||||
const firstSnapped = attemptSnapNode(
|
||||
updateAssemblyNodeTransform(firstLeg, firstLeg.nodes[1]!.nodeId, {
|
||||
position: [0, 720, 0],
|
||||
}),
|
||||
firstLeg.nodes[1]!.nodeId,
|
||||
)
|
||||
const withSecondLeg = instantiateAssemblyNode(firstSnapped, {
|
||||
inventoryId: 'leg:family-leg:2',
|
||||
variant: {
|
||||
...legVariant,
|
||||
id: 'variant-leg-conflict',
|
||||
code: 'LEG-A-CONFLICT-001',
|
||||
name: '冲突桌腿',
|
||||
},
|
||||
})
|
||||
const secondMoved = updateAssemblyNodeTransform(withSecondLeg, withSecondLeg.nodes[2]!.nodeId, {
|
||||
position: [0, 720, 0],
|
||||
})
|
||||
const secondCandidate = deriveAssemblyAutoSnapCandidate(secondMoved, secondMoved.nodes[2]!.nodeId)
|
||||
const secondPreview = applyAutomaticAssemblySnapPreview(
|
||||
secondMoved,
|
||||
secondMoved.nodes[2]!.nodeId,
|
||||
{ fallbackZone: 'staging' },
|
||||
)
|
||||
|
||||
expect(firstSnapped.nodes[1]?.snapBinding).toMatchObject({
|
||||
targetHoleId: 'board-hole-1',
|
||||
targetMountPatternId: 'board-pattern-1',
|
||||
})
|
||||
expect(secondCandidate).toBeNull()
|
||||
expect(secondPreview.nodes[2]?.zone).toBe('staging')
|
||||
expect(secondPreview.nodes[2]?.snapBinding ?? null).toBeNull()
|
||||
expect(secondPreview.nodes[2]?.snapStatus ?? null).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
import {
|
||||
getAssistedAssemblyVariantThumbnailSources,
|
||||
getAssistedAssemblyVariantThumbnailUrl,
|
||||
type AssistedAssemblyVariant,
|
||||
} from '../assisted-assembly-variant'
|
||||
|
||||
describe('assisted assembly variant thumbnails', () => {
|
||||
it('falls back from generated thumbnails to material preview assets', () => {
|
||||
const variant = {
|
||||
runtimeThumbnailUrl: '/internal/runtime.png',
|
||||
thumbnailUrl: null,
|
||||
materialConfig: {
|
||||
id: 'finish-oak',
|
||||
name: '暖白橡木',
|
||||
categoryId: 'wood',
|
||||
thumbnailUrl: '/materials/thumbnails/oak.jpg',
|
||||
textureUrl: '/materials/pbr/oak/oak_col.jpg',
|
||||
},
|
||||
} as AssistedAssemblyVariant
|
||||
|
||||
expect(getAssistedAssemblyVariantThumbnailUrl(variant)).toBe('/internal/runtime.png')
|
||||
expect(getAssistedAssemblyVariantThumbnailSources(variant)).toEqual([
|
||||
'/internal/runtime.png',
|
||||
'/materials/thumbnails/oak.jpg',
|
||||
'/materials/pbr/oak/oak_col.jpg',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses material thumbnails when the variant has no generated thumbnail', () => {
|
||||
const variant = {
|
||||
thumbnailUrl: null,
|
||||
runtimeThumbnailUrl: null,
|
||||
materialConfig: {
|
||||
id: 'finish-walnut',
|
||||
name: '胡桃木',
|
||||
categoryId: 'wood',
|
||||
thumbnailUrl: '/materials/thumbnails/walnut.jpg',
|
||||
},
|
||||
} as AssistedAssemblyVariant
|
||||
|
||||
expect(getAssistedAssemblyVariantThumbnailUrl(variant)).toBe('/materials/thumbnails/walnut.jpg')
|
||||
expect(getAssistedAssemblyVariantThumbnailSources(variant)).toEqual([
|
||||
'/materials/thumbnails/walnut.jpg',
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -20,6 +20,7 @@ export type AssemblyRelationType = 'hole-group' | 'surface' | 'connector'
|
||||
|
||||
export type AssemblyRelationStatus = 'CONNECTED' | 'SUGGESTED' | 'PENDING'
|
||||
export type AssemblySnapStatus = 'hole-preview' | 'hole-snap' | 'manual-unverified'
|
||||
export type AssemblySnapMode = 'hole' | 'surface'
|
||||
|
||||
export interface WeakSlotAssignment {
|
||||
role: string
|
||||
@ -78,6 +79,11 @@ export interface AssemblySnapBinding {
|
||||
targetFace: string | null
|
||||
sourceMountPatternId: string | null
|
||||
targetMountPatternId: string | null
|
||||
snapMode?: AssemblySnapMode
|
||||
sourceSnapPatternId?: string | null
|
||||
targetSnapPatternId?: string | null
|
||||
sourceSurfaceId?: string | null
|
||||
targetSurfaceId?: string | null
|
||||
}
|
||||
|
||||
export interface AssemblySubassembly {
|
||||
@ -125,8 +131,13 @@ export interface AssemblyAssistFeedback {
|
||||
export interface AssemblyAutoSnapCandidate {
|
||||
sourceNodeId: string
|
||||
targetNodeId: string
|
||||
snapMode?: AssemblySnapMode
|
||||
sourceMountPatternId: string
|
||||
targetMountPatternId: string
|
||||
sourceSnapPatternId?: string | null
|
||||
targetSnapPatternId?: string | null
|
||||
sourceSurfaceId?: string | null
|
||||
targetSurfaceId?: string | null
|
||||
sourceHoleId: string
|
||||
targetHoleId: string
|
||||
sourceHolePanelId: string | null
|
||||
@ -217,6 +228,8 @@ const SNAP_DISTANCE_THRESHOLD = 180
|
||||
const HOLE_SNAP_DISTANCE_THRESHOLD_MM = 120
|
||||
const HOLE_DIAMETER_TOLERANCE_MM = 2
|
||||
const HOLE_NORMAL_ALIGNMENT_DOT_MAX = -0.45
|
||||
const SURFACE_SNAP_DISTANCE_THRESHOLD_MM = 96
|
||||
const SURFACE_NORMAL_ALIGNMENT_DOT_MAX = -0.55
|
||||
const SPACE_CONFLICT_DISTANCE_THRESHOLD_MM = 60
|
||||
|
||||
const DEFAULT_DESKTOP_POSITION: [number, number, number] = [0, 720, 0]
|
||||
@ -262,6 +275,19 @@ interface AssemblyBasisMountPatternRecord {
|
||||
minRequiredHoles: number
|
||||
}
|
||||
|
||||
interface AssemblyBasisSnapSurfaceRecord {
|
||||
id: string
|
||||
panelId: string | null
|
||||
face: string | null
|
||||
normal: [number, number, number]
|
||||
center: [number, number, number]
|
||||
bounds: {
|
||||
min: [number, number, number]
|
||||
max: [number, number, number]
|
||||
} | null
|
||||
confidence: number | null
|
||||
}
|
||||
|
||||
interface AssemblyMatchedHolePair {
|
||||
sourceHole: AssemblyBasisHoleRecord
|
||||
targetHole: AssemblyBasisHoleRecord
|
||||
@ -330,6 +356,15 @@ function readRecordArray(record: Record<string, unknown> | null | undefined, key
|
||||
: []
|
||||
}
|
||||
|
||||
function readSnapFaceValue(record: Record<string, unknown> | null | undefined) {
|
||||
return (
|
||||
readStringValue(record, 'snapFace') ??
|
||||
readStringValue(record, 'mountFace') ??
|
||||
readStringValue(record, 'face') ??
|
||||
readStringValue(record, 'surfaceFace')
|
||||
)
|
||||
}
|
||||
|
||||
function readVector3Tuple(value: unknown): [number, number, number] | null {
|
||||
if (!Array.isArray(value) || value.length < 3) {
|
||||
return null
|
||||
@ -346,6 +381,17 @@ function readVector3Tuple(value: unknown): [number, number, number] | null {
|
||||
return [x, y, z]
|
||||
}
|
||||
|
||||
function readBoundsTuple(value: unknown): { min: [number, number, number]; max: [number, number, number] } | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
const min = readVector3Tuple(record.min)
|
||||
const max = readVector3Tuple(record.max)
|
||||
return min && max ? { min, max } : null
|
||||
}
|
||||
|
||||
function normalizeVector3Tuple(
|
||||
value: [number, number, number],
|
||||
fallback: [number, number, number],
|
||||
@ -471,7 +517,7 @@ function buildAssemblyHoleGroups(node: AssemblyWorkspaceNode) {
|
||||
return {
|
||||
id,
|
||||
panelId: readStringValue(record, 'panelId'),
|
||||
mountFace: readStringValue(record, 'mountFace'),
|
||||
mountFace: readSnapFaceValue(record),
|
||||
surfaceFaces: normalizeSurfaceFaces(record.surfaceFaces),
|
||||
}
|
||||
})
|
||||
@ -481,7 +527,13 @@ function buildAssemblyHoleGroups(node: AssemblyWorkspaceNode) {
|
||||
}
|
||||
|
||||
function buildAssemblyMountPatterns(node: AssemblyWorkspaceNode): AssemblyBasisMountPatternRecord[] {
|
||||
return readRecordArray(node.assemblyBasis, 'mountPatterns')
|
||||
const snapPatternRecords = readRecordArray(node.assemblyBasis, 'snapPatterns')
|
||||
const patternRecords =
|
||||
snapPatternRecords.length > 0
|
||||
? snapPatternRecords
|
||||
: readRecordArray(node.assemblyBasis, 'mountPatterns')
|
||||
|
||||
return patternRecords
|
||||
.map<AssemblyBasisMountPatternRecord | null>((record) => {
|
||||
const id = readStringValue(record, 'id')
|
||||
const holeIds = readStringList(record.holeIds)
|
||||
@ -497,7 +549,7 @@ function buildAssemblyMountPatterns(node: AssemblyWorkspaceNode): AssemblyBasisM
|
||||
return {
|
||||
id,
|
||||
panelId: readStringValue(record, 'panelId'),
|
||||
mountFace: readStringValue(record, 'mountFace'),
|
||||
mountFace: readSnapFaceValue(record),
|
||||
holeIds,
|
||||
minRequiredHoles: Math.max(1, Math.min(holeIds.length, Math.round(requestedMinRequired))),
|
||||
}
|
||||
@ -509,6 +561,34 @@ function getNodeAssemblyMountPatterns(node: AssemblyWorkspaceNode) {
|
||||
return buildAssemblyMountPatterns(node)
|
||||
}
|
||||
|
||||
function getNodeAssemblySnapSurfaces(node: AssemblyWorkspaceNode): AssemblyBasisSnapSurfaceRecord[] {
|
||||
const applyCadTransform = shouldApplyCadHoleTransform(node)
|
||||
|
||||
return readRecordArray(node.assemblyBasis, 'snapSurfaces')
|
||||
.map<AssemblyBasisSnapSurfaceRecord | null>((record) => {
|
||||
const id = readStringValue(record, 'id')
|
||||
const rawCenter = readVector3Tuple(record.center ?? record.position)
|
||||
const rawNormal = readVector3Tuple(record.normal)
|
||||
if (!id || !rawCenter || !rawNormal) {
|
||||
return null
|
||||
}
|
||||
|
||||
const center = applyCadTransform ? transformCadVector3Tuple(rawCenter) : rawCenter
|
||||
const normal = applyCadTransform ? transformCadVector3Tuple(rawNormal) : rawNormal
|
||||
|
||||
return {
|
||||
id,
|
||||
panelId: readStringValue(record, 'panelId'),
|
||||
face: readSnapFaceValue(record),
|
||||
center,
|
||||
normal: normalizeVector3Tuple(normal, [0, 1, 0]),
|
||||
bounds: readBoundsTuple(record.bounds),
|
||||
confidence: readNumberValue(record, 'confidence'),
|
||||
}
|
||||
})
|
||||
.filter((item): item is AssemblyBasisSnapSurfaceRecord => item !== null)
|
||||
}
|
||||
|
||||
function getNodeAssemblyHoles(node: AssemblyWorkspaceNode): AssemblyBasisHoleRecord[] {
|
||||
const groupById = buildAssemblyHoleGroups(node)
|
||||
const applyCadTransform = shouldApplyCadHoleTransform(node)
|
||||
@ -619,9 +699,68 @@ function buildSnapBindingFromCandidate(candidate: AssemblyAutoSnapCandidate): As
|
||||
targetFace: candidate.targetFace,
|
||||
sourceMountPatternId: candidate.sourceMountPatternId,
|
||||
targetMountPatternId: candidate.targetMountPatternId,
|
||||
snapMode: candidate.snapMode ?? 'hole',
|
||||
sourceSnapPatternId: candidate.sourceSnapPatternId ?? candidate.sourceMountPatternId,
|
||||
targetSnapPatternId: candidate.targetSnapPatternId ?? candidate.targetMountPatternId,
|
||||
sourceSurfaceId: candidate.sourceSurfaceId ?? null,
|
||||
targetSurfaceId: candidate.targetSurfaceId ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
interface OccupiedSnapTargets {
|
||||
holeIds: Set<string>
|
||||
patternIds: Set<string>
|
||||
surfaceIds: Set<string>
|
||||
}
|
||||
|
||||
function createOccupiedSnapTargets(): OccupiedSnapTargets {
|
||||
return {
|
||||
holeIds: new Set<string>(),
|
||||
patternIds: new Set<string>(),
|
||||
surfaceIds: new Set<string>(),
|
||||
}
|
||||
}
|
||||
|
||||
function getOccupiedSnapTargets(
|
||||
occupiedTargetsByNodeId: Map<string, OccupiedSnapTargets>,
|
||||
targetNodeId: string,
|
||||
) {
|
||||
return occupiedTargetsByNodeId.get(targetNodeId) ?? createOccupiedSnapTargets()
|
||||
}
|
||||
|
||||
function buildOccupiedSnapTargetsByNodeId(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
movingNodeId: string,
|
||||
): Map<string, OccupiedSnapTargets> {
|
||||
const result = new Map<string, OccupiedSnapTargets>()
|
||||
|
||||
snapshot.nodes.forEach((node) => {
|
||||
if (node.nodeId === movingNodeId || node.zone !== 'main' || !node.snapBinding?.targetNodeId) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetNodeId = node.snapBinding.targetNodeId
|
||||
const targets = result.get(targetNodeId) ?? createOccupiedSnapTargets()
|
||||
targets.holeIds.add(node.snapBinding.targetHoleId)
|
||||
|
||||
const targetPatternId =
|
||||
node.snapBinding.targetSnapPatternId ??
|
||||
node.snapBinding.targetMountPatternId ??
|
||||
null
|
||||
if (targetPatternId) {
|
||||
targets.patternIds.add(targetPatternId)
|
||||
}
|
||||
|
||||
if (node.snapBinding.targetSurfaceId) {
|
||||
targets.surfaceIds.add(node.snapBinding.targetSurfaceId)
|
||||
}
|
||||
|
||||
result.set(targetNodeId, targets)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function resolveSnapBindingPattern(
|
||||
node: AssemblyWorkspaceNode,
|
||||
holeId: string,
|
||||
@ -674,8 +813,13 @@ function buildSnapBindingDisplayCandidate(
|
||||
return {
|
||||
sourceNodeId: sourceNode.nodeId,
|
||||
targetNodeId: targetNode.nodeId,
|
||||
snapMode: binding.snapMode ?? 'hole',
|
||||
sourceMountPatternId: sourcePattern?.id ?? binding.sourceMountPatternId ?? sourceHole.id,
|
||||
targetMountPatternId: targetPattern?.id ?? binding.targetMountPatternId ?? targetHole.id,
|
||||
sourceSnapPatternId: binding.sourceSnapPatternId ?? binding.sourceMountPatternId ?? sourcePattern?.id ?? null,
|
||||
targetSnapPatternId: binding.targetSnapPatternId ?? binding.targetMountPatternId ?? targetPattern?.id ?? null,
|
||||
sourceSurfaceId: binding.sourceSurfaceId ?? null,
|
||||
targetSurfaceId: binding.targetSurfaceId ?? null,
|
||||
sourceHoleId: sourceHole.id,
|
||||
targetHoleId: targetHole.id,
|
||||
sourceHolePanelId: binding.sourceHolePanelId ?? sourceHole.panelId,
|
||||
@ -781,8 +925,8 @@ export function deriveAssemblyAssistFeedback(
|
||||
if (snapSupport.missingMountPatterns && snapSupport.missingHoles) {
|
||||
return {
|
||||
nodeId,
|
||||
title: '缺少孔位与安装图样语义',
|
||||
description: '当前资产缺少孔位与安装图样语义,无法自动吸附。',
|
||||
title: '缺少孔位与吸附图样语义',
|
||||
description: '当前资产缺少孔位与吸附图样语义,无法自动吸附。',
|
||||
tone: 'warning',
|
||||
targetNodeId: primaryAnchorNode.nodeId,
|
||||
targetLabel: primaryAnchorNode.name,
|
||||
@ -795,8 +939,8 @@ export function deriveAssemblyAssistFeedback(
|
||||
if (snapSupport.missingMountPatterns) {
|
||||
return {
|
||||
nodeId,
|
||||
title: '缺少安装图样语义',
|
||||
description: '当前资产缺少安装图样语义,无法自动吸附。',
|
||||
title: '缺少吸附图样语义',
|
||||
description: '当前资产缺少吸附图样语义,无法自动吸附。',
|
||||
tone: 'warning',
|
||||
targetNodeId: primaryAnchorNode.nodeId,
|
||||
targetLabel: primaryAnchorNode.name,
|
||||
@ -873,19 +1017,130 @@ export function deriveAssemblyAssistFeedback(
|
||||
}
|
||||
}
|
||||
|
||||
function deriveAssemblySurfaceSnapCandidate(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
sourceNode: AssemblyWorkspaceNode,
|
||||
occupiedTargetsByNodeId: Map<string, OccupiedSnapTargets>,
|
||||
): AssemblyAutoSnapCandidate | null {
|
||||
const sourceSurfaces = getNodeAssemblySnapSurfaces(sourceNode)
|
||||
if (sourceSurfaces.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let bestCandidate: AssemblyAutoSnapCandidate | null = null
|
||||
|
||||
snapshot.nodes.forEach((targetNode) => {
|
||||
if (
|
||||
targetNode.nodeId === sourceNode.nodeId ||
|
||||
targetNode.zone !== 'main' ||
|
||||
!matchesPrimaryAnchorRole(sourceNode, targetNode)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetSurfaces = getNodeAssemblySnapSurfaces(targetNode)
|
||||
if (targetSurfaces.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const occupiedTargets = getOccupiedSnapTargets(occupiedTargetsByNodeId, targetNode.nodeId)
|
||||
|
||||
sourceSurfaces.forEach((sourceSurface) => {
|
||||
const sourceCenterWorld = getNodeWorldPoint(sourceNode, sourceSurface.center)
|
||||
const sourceNormalWorld = getNodeWorldDirection(sourceNode, sourceSurface.normal)
|
||||
|
||||
targetSurfaces.forEach((targetSurface) => {
|
||||
if (occupiedTargets.surfaceIds.has(targetSurface.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const facePair = resolveCompatibleFacePair(
|
||||
sourceSurface.face ? [sourceSurface.face] : [],
|
||||
targetSurface.face ? [targetSurface.face] : [],
|
||||
)
|
||||
if (!facePair) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetCenterWorld = getNodeWorldPoint(targetNode, targetSurface.center)
|
||||
const targetNormalWorld = getNodeWorldDirection(targetNode, targetSurface.normal)
|
||||
if (sourceNormalWorld.dot(targetNormalWorld) > SURFACE_NORMAL_ALIGNMENT_DOT_MAX) {
|
||||
return
|
||||
}
|
||||
|
||||
const distanceMm = sourceCenterWorld.distanceTo(targetCenterWorld)
|
||||
if (distanceMm > SURFACE_SNAP_DISTANCE_THRESHOLD_MM) {
|
||||
return
|
||||
}
|
||||
|
||||
const translation = targetCenterWorld.clone().sub(sourceCenterWorld)
|
||||
const previewPosition = tupleFromVector(
|
||||
new THREE.Vector3(sourceNode.position[0], sourceNode.position[1], sourceNode.position[2]).add(
|
||||
translation,
|
||||
),
|
||||
)
|
||||
const confidence = Math.min(
|
||||
sourceSurface.confidence ?? 1,
|
||||
targetSurface.confidence ?? 1,
|
||||
)
|
||||
const candidate: AssemblyAutoSnapCandidate = {
|
||||
sourceNodeId: sourceNode.nodeId,
|
||||
targetNodeId: targetNode.nodeId,
|
||||
snapMode: 'surface',
|
||||
sourceMountPatternId: sourceSurface.id,
|
||||
targetMountPatternId: targetSurface.id,
|
||||
sourceSnapPatternId: sourceSurface.id,
|
||||
targetSnapPatternId: targetSurface.id,
|
||||
sourceSurfaceId: sourceSurface.id,
|
||||
targetSurfaceId: targetSurface.id,
|
||||
sourceHoleId: `surface:${sourceSurface.id}`,
|
||||
targetHoleId: `surface:${targetSurface.id}`,
|
||||
sourceHolePanelId: sourceSurface.panelId,
|
||||
targetHolePanelId: targetSurface.panelId,
|
||||
sourceHoleDiameter: null,
|
||||
targetHoleDiameter: null,
|
||||
sourceHoleDepth: null,
|
||||
targetHoleDepth: null,
|
||||
sourceFace: facePair.sourceFace,
|
||||
targetFace: facePair.targetFace,
|
||||
matchedHoleCount: 0,
|
||||
requiredHoleCount: 0,
|
||||
previewPosition,
|
||||
previewRotation: cloneVector3(sourceNode.rotation),
|
||||
sourceHoleWorldPosition: tupleFromVector(sourceCenterWorld.clone().add(translation)),
|
||||
targetHoleWorldPosition: tupleFromVector(targetCenterWorld),
|
||||
sourceHoleNormal: tupleFromVector(sourceNormalWorld),
|
||||
targetHoleNormal: tupleFromVector(targetNormalWorld),
|
||||
distanceMm,
|
||||
isValid: confidence >= 0.5,
|
||||
rejectReason: confidence >= 0.5 ? null : 'surface_confidence_low',
|
||||
}
|
||||
|
||||
if (!bestCandidate || candidate.distanceMm < bestCandidate.distanceMm) {
|
||||
bestCandidate = candidate
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return bestCandidate
|
||||
}
|
||||
|
||||
export function deriveAssemblyAutoSnapCandidate(
|
||||
snapshot: AssemblyWorkspaceSnapshot,
|
||||
nodeId: string,
|
||||
): AssemblyAutoSnapCandidate | null {
|
||||
const sourceNode = snapshot.nodes.find((node) => node.nodeId === nodeId) ?? null
|
||||
if (!sourceNode || sourceNode.source === 'desktop' || !canUseHoleAutoSnap(sourceNode)) {
|
||||
if (!sourceNode || sourceNode.source === 'desktop') {
|
||||
return null
|
||||
}
|
||||
|
||||
const occupiedTargetsByNodeId = buildOccupiedSnapTargetsByNodeId(snapshot, sourceNode.nodeId)
|
||||
const sourcePatterns = getNodeAssemblyMountPatterns(sourceNode)
|
||||
const sourceHoleMap = new Map(getNodeAssemblyHoles(sourceNode).map((hole) => [hole.id, hole]))
|
||||
let bestCandidate: AssemblyAutoSnapCandidate | null = null
|
||||
|
||||
if (canUseHoleAutoSnap(sourceNode)) {
|
||||
snapshot.nodes.forEach((targetNode) => {
|
||||
if (
|
||||
targetNode.nodeId === sourceNode.nodeId ||
|
||||
@ -898,6 +1153,7 @@ export function deriveAssemblyAutoSnapCandidate(
|
||||
|
||||
const targetPatterns = getNodeAssemblyMountPatterns(targetNode)
|
||||
const targetHoleMap = new Map(getNodeAssemblyHoles(targetNode).map((hole) => [hole.id, hole]))
|
||||
const occupiedTargets = getOccupiedSnapTargets(occupiedTargetsByNodeId, targetNode.nodeId)
|
||||
|
||||
sourcePatterns.forEach((sourcePattern) => {
|
||||
const sourcePatternHoles = sourcePattern.holeIds
|
||||
@ -908,9 +1164,16 @@ export function deriveAssemblyAutoSnapCandidate(
|
||||
}
|
||||
|
||||
targetPatterns.forEach((targetPattern) => {
|
||||
if (occupiedTargets.patternIds.has(targetPattern.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetPatternHoles = targetPattern.holeIds
|
||||
.map((holeId) => targetHoleMap.get(holeId) ?? null)
|
||||
.filter((hole): hole is AssemblyBasisHoleRecord => hole !== null)
|
||||
.filter(
|
||||
(hole): hole is AssemblyBasisHoleRecord =>
|
||||
hole !== null && !occupiedTargets.holeIds.has(hole.id),
|
||||
)
|
||||
if (targetPatternHoles.length === 0) {
|
||||
return
|
||||
}
|
||||
@ -1023,8 +1286,11 @@ export function deriveAssemblyAutoSnapCandidate(
|
||||
const candidate: AssemblyAutoSnapCandidate = {
|
||||
sourceNodeId: sourceNode.nodeId,
|
||||
targetNodeId: targetNode.nodeId,
|
||||
snapMode: 'hole',
|
||||
sourceMountPatternId: sourcePattern.id,
|
||||
targetMountPatternId: targetPattern.id,
|
||||
sourceSnapPatternId: sourcePattern.id,
|
||||
targetSnapPatternId: targetPattern.id,
|
||||
sourceHoleId: firstPair.sourceHole.id,
|
||||
targetHoleId: firstPair.targetHole.id,
|
||||
sourceHolePanelId: firstPair.sourceHole.panelId,
|
||||
@ -1061,8 +1327,9 @@ export function deriveAssemblyAutoSnapCandidate(
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return bestCandidate
|
||||
return bestCandidate ?? deriveAssemblySurfaceSnapCandidate(snapshot, sourceNode, occupiedTargetsByNodeId)
|
||||
}
|
||||
|
||||
function buildInventoryId(source: Exclude<AssemblyNodeSource, 'unknown'>, familyId: string, index: number) {
|
||||
@ -1204,14 +1471,18 @@ function deriveAssemblySpaceConflictIssues(nodes: AssemblyWorkspaceNode[]) {
|
||||
(
|
||||
currentNode.snapBinding?.targetHoleId === otherNode.snapBinding?.targetHoleId ||
|
||||
(
|
||||
currentNode.snapBinding?.targetMountPatternId &&
|
||||
currentNode.snapBinding.targetMountPatternId ===
|
||||
otherNode.snapBinding?.targetMountPatternId
|
||||
(currentNode.snapBinding?.targetSnapPatternId ?? currentNode.snapBinding?.targetMountPatternId) &&
|
||||
(currentNode.snapBinding?.targetSnapPatternId ?? currentNode.snapBinding?.targetMountPatternId) ===
|
||||
(otherNode.snapBinding?.targetSnapPatternId ?? otherNode.snapBinding?.targetMountPatternId)
|
||||
) ||
|
||||
(
|
||||
currentNode.snapBinding?.targetSurfaceId &&
|
||||
currentNode.snapBinding.targetSurfaceId === otherNode.snapBinding?.targetSurfaceId
|
||||
)
|
||||
)
|
||||
|
||||
if (sharesSnapTarget) {
|
||||
issues.add(`${currentNode.name} 与 ${otherNode.name} 占用了同一安装孔位 / 图样,存在空间冲突`)
|
||||
issues.add(`${currentNode.name} 与 ${otherNode.name} 占用了同一吸附孔位 / 图样,存在空间冲突`)
|
||||
continue
|
||||
}
|
||||
|
||||
@ -1637,7 +1908,7 @@ export function deriveAssemblyValidation(input: {
|
||||
warnings.push(`${node.name} 缺少装配规则,当前只能预览,不能稳定保存`)
|
||||
}
|
||||
if (node.snapStatus === 'manual-unverified') {
|
||||
warnings.push(`${node.name} 仍处于手动未核验状态,需要完成孔位 / 安装图样核验`)
|
||||
warnings.push(`${node.name} 仍处于手动未核验状态,需要完成孔位 / 吸附图样核验`)
|
||||
} else if (node.snapStatus === 'hole-preview') {
|
||||
warnings.push(`${node.name} 当前只是孔位预吸附预览,松手确认后才算连接`)
|
||||
} else if (!node.isSnapped) {
|
||||
@ -1749,9 +2020,15 @@ export function instantiateAssemblyNode(
|
||||
(node) => node.zone === 'main' && node.source === 'desktop',
|
||||
)
|
||||
const nodeZone: AssemblyZone = isDesktop && !hasMainDesktop ? 'main' : 'staging'
|
||||
const anchorOffset = isDesktop ? null : buildAnchorOffset(inventoryItem.source, sourceIndex)
|
||||
const assemblyTargetPosition = anchorOffset
|
||||
? addVector3(getDesktopReferencePosition(snapshot.nodes), anchorOffset)
|
||||
: null
|
||||
const initialPosition =
|
||||
nodeZone === 'main'
|
||||
? cloneVector3(DEFAULT_DESKTOP_POSITION)
|
||||
: !isDesktop && assemblyTargetPosition
|
||||
? cloneVector3(assemblyTargetPosition)
|
||||
: buildStagingPosition(snapshot.nodes.length, inventoryItem.source)
|
||||
const baseRole = isDesktop ? 'desktop' : inventoryItem.source
|
||||
|
||||
@ -1781,7 +2058,7 @@ export function instantiateAssemblyNode(
|
||||
parentRolePath: isDesktop ? null : 'desktop',
|
||||
instanceIndex: sourceIndex,
|
||||
},
|
||||
targetPosition: isDesktop ? cloneVector3(DEFAULT_DESKTOP_POSITION) : null,
|
||||
targetPosition: isDesktop ? cloneVector3(DEFAULT_DESKTOP_POSITION) : assemblyTargetPosition,
|
||||
targetRotation: [0, 0, 0] as [number, number, number],
|
||||
isSnapped: isDesktop && nodeZone === 'main',
|
||||
snapStatus: (isDesktop && nodeZone === 'main' ? 'hole-snap' : null) as AssemblySnapStatus | null,
|
||||
|
||||
@ -26,13 +26,24 @@ export function resolveAssistedAssemblyNodeMaterialConfig(options: {
|
||||
export function getAssistedAssemblyVariantThumbnailUrl(
|
||||
variant: AssistedAssemblyVariant | null | undefined,
|
||||
): string | null {
|
||||
return variant?.runtimeThumbnailUrl ?? variant?.thumbnailUrl ?? null
|
||||
return (
|
||||
variant?.runtimeThumbnailUrl ??
|
||||
variant?.thumbnailUrl ??
|
||||
variant?.materialConfig?.thumbnailUrl ??
|
||||
variant?.materialConfig?.textureUrl ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
export function getAssistedAssemblyVariantThumbnailSources(
|
||||
variant: AssistedAssemblyVariant | null | undefined,
|
||||
): string[] {
|
||||
const sources = [variant?.runtimeThumbnailUrl, variant?.thumbnailUrl]
|
||||
const sources = [
|
||||
variant?.runtimeThumbnailUrl,
|
||||
variant?.thumbnailUrl,
|
||||
variant?.materialConfig?.thumbnailUrl,
|
||||
variant?.materialConfig?.textureUrl,
|
||||
]
|
||||
.map((value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : null))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
|
||||
|
||||
@ -61,6 +61,7 @@ export interface PersistedSkuCadMountPattern {
|
||||
id: string
|
||||
panelId: string
|
||||
mountFace: string
|
||||
snapFace?: string
|
||||
patternType: string
|
||||
holeIds: string[]
|
||||
requiredHoleIds?: string[]
|
||||
@ -74,6 +75,17 @@ export interface PersistedSkuCadMountPattern {
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface PersistedSkuCadSnapSurface {
|
||||
id: string
|
||||
panelId: string
|
||||
face: string
|
||||
normal: Vector3Tuple
|
||||
center: Vector3Tuple
|
||||
bounds?: PersistedSkuCadBoundingBox
|
||||
confidence?: number
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface PersistedSkuCadHardwareInstance {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
@ -110,6 +122,8 @@ export interface PersistedSkuCadData {
|
||||
panels: PersistedSkuCadPanel[]
|
||||
holes: PersistedSkuCadHole[]
|
||||
holeGroups: PersistedSkuCadHoleGroup[]
|
||||
snapPatterns: PersistedSkuCadMountPattern[]
|
||||
snapSurfaces: PersistedSkuCadSnapSurface[]
|
||||
mountPatterns: PersistedSkuCadMountPattern[]
|
||||
hardwareTypes: PersistedSkuCadHardwareType[]
|
||||
hardwareSummary: CADHardwareSummary[]
|
||||
@ -466,7 +480,12 @@ function normalizeCadMountPatterns(rawPatterns: unknown): PersistedSkuCadMountPa
|
||||
seen.add(id)
|
||||
|
||||
const panelId = typeof record.panelId === 'string' ? record.panelId.trim() : ''
|
||||
const mountFace = typeof record.mountFace === 'string' ? record.mountFace.trim() : ''
|
||||
const mountFace =
|
||||
typeof record.mountFace === 'string' && record.mountFace.trim().length > 0
|
||||
? record.mountFace.trim()
|
||||
: typeof record.snapFace === 'string' && record.snapFace.trim().length > 0
|
||||
? record.snapFace.trim()
|
||||
: ''
|
||||
if (!panelId || !mountFace) {
|
||||
return
|
||||
}
|
||||
@ -482,6 +501,10 @@ function normalizeCadMountPatterns(rawPatterns: unknown): PersistedSkuCadMountPa
|
||||
id,
|
||||
panelId,
|
||||
mountFace,
|
||||
snapFace:
|
||||
typeof record.snapFace === 'string' && record.snapFace.trim().length > 0
|
||||
? record.snapFace.trim()
|
||||
: mountFace,
|
||||
patternType:
|
||||
typeof record.patternType === 'string' && record.patternType.trim().length > 0
|
||||
? record.patternType.trim()
|
||||
@ -505,6 +528,66 @@ function normalizeCadMountPatterns(rawPatterns: unknown): PersistedSkuCadMountPa
|
||||
return patterns
|
||||
}
|
||||
|
||||
function normalizeCadSnapSurfaces(rawSurfaces: unknown): PersistedSkuCadSnapSurface[] {
|
||||
if (!Array.isArray(rawSurfaces)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const surfaces: PersistedSkuCadSnapSurface[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
rawSurfaces.forEach((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const record = entry as Record<string, unknown>
|
||||
const id =
|
||||
typeof record.id === 'string' && record.id.trim().length > 0
|
||||
? record.id.trim()
|
||||
: `cad-snap-surface-${index + 1}`
|
||||
if (seen.has(id)) {
|
||||
return
|
||||
}
|
||||
seen.add(id)
|
||||
|
||||
const panelId = typeof record.panelId === 'string' ? record.panelId.trim() : ''
|
||||
const face =
|
||||
typeof record.face === 'string' && record.face.trim().length > 0
|
||||
? record.face.trim()
|
||||
: typeof record.snapFace === 'string' && record.snapFace.trim().length > 0
|
||||
? record.snapFace.trim()
|
||||
: typeof record.mountFace === 'string' && record.mountFace.trim().length > 0
|
||||
? record.mountFace.trim()
|
||||
: ''
|
||||
const center = toVector3Tuple(record.center ?? record.position)
|
||||
const normal = toVector3Tuple(record.normal, [0, 0, 1])
|
||||
if (!panelId || !face) {
|
||||
return
|
||||
}
|
||||
|
||||
const bounds = toBoundingBox(record.bounds)
|
||||
surfaces.push({
|
||||
id,
|
||||
panelId,
|
||||
face,
|
||||
center,
|
||||
normal,
|
||||
...(bounds ? { bounds } : {}),
|
||||
confidence:
|
||||
typeof record.confidence === 'number' && Number.isFinite(record.confidence)
|
||||
? record.confidence
|
||||
: undefined,
|
||||
source:
|
||||
typeof record.source === 'string' && record.source.trim().length > 0
|
||||
? record.source.trim()
|
||||
: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
return surfaces
|
||||
}
|
||||
|
||||
function normalizeCadHardwareTypes(rawHardwareTypes: unknown): PersistedSkuCadHardwareType[] {
|
||||
if (!Array.isArray(rawHardwareTypes)) {
|
||||
return []
|
||||
@ -680,6 +763,8 @@ export function buildPersistedSkuCadData(params: {
|
||||
panels?: unknown
|
||||
holes?: unknown
|
||||
holeGroups?: unknown
|
||||
snapPatterns?: unknown
|
||||
snapSurfaces?: unknown
|
||||
mountPatterns?: unknown
|
||||
hardwareTypes?: unknown
|
||||
hardwareSummary?: unknown
|
||||
@ -689,6 +774,10 @@ export function buildPersistedSkuCadData(params: {
|
||||
const holes = mergeCadHoles(normalizeCadHoles(params.holes), params.panels)
|
||||
const holeGroups = normalizeCadHoleGroups(params.holeGroups)
|
||||
const mountPatterns = normalizeCadMountPatterns(params.mountPatterns)
|
||||
const explicitSnapPatterns = normalizeCadMountPatterns(params.snapPatterns)
|
||||
const snapPatterns = explicitSnapPatterns.length > 0 ? explicitSnapPatterns : mountPatterns
|
||||
const compatibilityMountPatterns = mountPatterns.length > 0 ? mountPatterns : snapPatterns
|
||||
const snapSurfaces = normalizeCadSnapSurfaces(params.snapSurfaces)
|
||||
const hardwareTypes = normalizeCadHardwareTypes(params.hardwareTypes)
|
||||
const hardwareSummary = normalizeHardwareSummary(params.hardwareSummary)
|
||||
const assembly = normalizePersistedSkuCadAssemblyData(params.assembly)
|
||||
@ -697,7 +786,8 @@ export function buildPersistedSkuCadData(params: {
|
||||
panels.length === 0 &&
|
||||
holes.length === 0 &&
|
||||
holeGroups.length === 0 &&
|
||||
mountPatterns.length === 0 &&
|
||||
compatibilityMountPatterns.length === 0 &&
|
||||
snapSurfaces.length === 0 &&
|
||||
hardwareTypes.length === 0 &&
|
||||
hardwareSummary.length === 0 &&
|
||||
!assembly
|
||||
@ -717,7 +807,9 @@ export function buildPersistedSkuCadData(params: {
|
||||
panels,
|
||||
holes,
|
||||
holeGroups,
|
||||
mountPatterns,
|
||||
snapPatterns,
|
||||
snapSurfaces,
|
||||
mountPatterns: compatibilityMountPatterns,
|
||||
hardwareTypes,
|
||||
hardwareSummary,
|
||||
...(assembly ? { assembly } : {}),
|
||||
@ -742,6 +834,8 @@ export function readSkuCadDataFromAttributes(attributes: unknown): PersistedSkuC
|
||||
panels: record.panels,
|
||||
holes: record.holes,
|
||||
holeGroups: record.holeGroups,
|
||||
snapPatterns: record.snapPatterns,
|
||||
snapSurfaces: record.snapSurfaces,
|
||||
mountPatterns: record.mountPatterns,
|
||||
hardwareTypes: record.hardwareTypes,
|
||||
hardwareSummary: record.hardwareSummary,
|
||||
|
||||
@ -179,7 +179,8 @@ export async function doesS3ObjectExist(key: string): Promise<boolean> {
|
||||
? (error as { $metadata?: { httpStatusCode?: number } }).$metadata!.httpStatusCode
|
||||
: null
|
||||
|
||||
if (statusCode === 404) {
|
||||
// Some S3-compatible stores return 403 for missing objects when ListBucket is not allowed.
|
||||
if (statusCode === 404 || statusCode === 403) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@ -235,7 +235,17 @@ function buildAssemblyBasis(params: {
|
||||
}): Record<string, unknown> {
|
||||
const holes = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holes ?? [])
|
||||
const holeGroups = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holeGroups ?? [])
|
||||
const mountPatterns = params.assetType === 'HARDWARE' ? [] : (params.cadData?.mountPatterns ?? [])
|
||||
const snapPatterns =
|
||||
params.assetType === 'HARDWARE'
|
||||
? []
|
||||
: (params.cadData?.snapPatterns?.length
|
||||
? params.cadData.snapPatterns
|
||||
: (params.cadData?.mountPatterns ?? []))
|
||||
const mountPatterns =
|
||||
params.assetType === 'HARDWARE'
|
||||
? []
|
||||
: (params.cadData?.mountPatterns?.length ? params.cadData.mountPatterns : snapPatterns)
|
||||
const snapSurfaces = params.assetType === 'HARDWARE' ? [] : (params.cadData?.snapSurfaces ?? [])
|
||||
const edgeFeatures = params.assetType === 'BOARD' ? (params.cadExportData.edgeFeatures ?? []) : []
|
||||
const suggestedFastenerCount = holes.reduce(
|
||||
(sum, hole) => sum + (hole.suggestedFasteners?.length ?? 0),
|
||||
@ -252,10 +262,14 @@ function buildAssemblyBasis(params: {
|
||||
holeCount: holes.length,
|
||||
hardwareTypeCount: params.cadData?.hardwareTypes.length ?? 0,
|
||||
coordinateSystem: params.cadData?.coordinateSystem ?? params.cadExportData.coordinateSystem ?? null,
|
||||
snapPatternCount: snapPatterns.length,
|
||||
snapSurfaceCount: snapSurfaces.length,
|
||||
mountPatternCount: mountPatterns.length,
|
||||
suggestedFastenerCount,
|
||||
holes,
|
||||
holeGroups,
|
||||
snapPatterns,
|
||||
snapSurfaces,
|
||||
mountPatterns,
|
||||
edgeFeatureCount: edgeFeatures.length,
|
||||
edgeFeatures,
|
||||
@ -376,6 +390,8 @@ class AssetCadImportService {
|
||||
panels: parseResult.cadExportData.panels,
|
||||
holes: parseResult.cadExportData.holes,
|
||||
holeGroups: (parseResult.cadExportData as unknown as { holeGroups?: unknown }).holeGroups,
|
||||
snapPatterns: parseResult.cadExportData.snapPatterns,
|
||||
snapSurfaces: parseResult.cadExportData.snapSurfaces,
|
||||
mountPatterns: (parseResult.cadExportData as unknown as { mountPatterns?: unknown }).mountPatterns,
|
||||
hardwareTypes: parseResult.cadExportData.hardwareTypes,
|
||||
hardwareSummary: parseResult.cadExportData.hardwareSummary,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user