Merge codex/xuxiake-mainline into main

Merge codex/xuxiake-mainline into main
This commit is contained in:
WuYongJie 2026-04-28 15:01:24 +08:00
commit ee8f218712
31 changed files with 3170 additions and 285 deletions

View File

@ -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] 打包文件...");

View File

@ -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]

View File

@ -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 },
};
}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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;
}
}

View 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; }
}
}

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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();

View File

@ -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({

View File

@ -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) => ({

View File

@ -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)

View File

@ -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();
});

View File

@ -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),
}

View File

@ -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,
}

View File

@ -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,
position: candidate.targetHoleWorldPosition,
diameter: candidate.targetHoleDiameter ?? 8,
depth: candidate.targetHoleDepth ?? 12,
normal: candidate.targetHoleNormal,
holeType,
confidence,
hardwareMatched: holeType === 'assembly-used',
},
],
activeHolePanelIds: [sourcePanelId, targetPanelId],
panelNodeNameByPanelId: {
[sourcePanelId]: selectedSingleNode.nodeId,
[targetPanelId]: activeSnapCandidate.targetNodeId,
},
panelLabelByPanelId: {
[sourcePanelId]: selectedSingleNode.name,
[targetPanelId]: targetNode?.name ?? activeSnapCandidate.targetNodeId,
},
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}

View File

@ -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()
})
})

View File

@ -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);

View File

@ -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 底稿和装配语义快照', () => {

View File

@ -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,

View File

@ -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()
})
})

View File

@ -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',
])
})
})

View File

@ -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,50 +1017,169 @@ export function deriveAssemblyAssistFeedback(
}
}
export function deriveAssemblyAutoSnapCandidate(
function deriveAssemblySurfaceSnapCandidate(
snapshot: AssemblyWorkspaceSnapshot,
nodeId: string,
sourceNode: AssemblyWorkspaceNode,
occupiedTargetsByNodeId: Map<string, OccupiedSnapTargets>,
): AssemblyAutoSnapCandidate | null {
const sourceNode = snapshot.nodes.find((node) => node.nodeId === nodeId) ?? null
if (!sourceNode || sourceNode.source === 'desktop' || !canUseHoleAutoSnap(sourceNode)) {
const sourceSurfaces = getNodeAssemblySnapSurfaces(sourceNode)
if (sourceSurfaces.length === 0) {
return null
}
const sourcePatterns = getNodeAssemblyMountPatterns(sourceNode)
const sourceHoleMap = new Map(getNodeAssemblyHoles(sourceNode).map((hole) => [hole.id, hole]))
let bestCandidate: AssemblyAutoSnapCandidate | null = null
snapshot.nodes.forEach((targetNode) => {
if (
targetNode.nodeId === sourceNode.nodeId ||
targetNode.zone !== 'main' ||
!canUseHoleAutoSnap(targetNode) ||
!matchesPrimaryAnchorRole(sourceNode, targetNode)
) {
return
}
const targetPatterns = getNodeAssemblyMountPatterns(targetNode)
const targetHoleMap = new Map(getNodeAssemblyHoles(targetNode).map((hole) => [hole.id, hole]))
const targetSurfaces = getNodeAssemblySnapSurfaces(targetNode)
if (targetSurfaces.length === 0) {
return
}
sourcePatterns.forEach((sourcePattern) => {
const sourcePatternHoles = sourcePattern.holeIds
.map((holeId) => sourceHoleMap.get(holeId) ?? null)
.filter((hole): hole is AssemblyBasisHoleRecord => hole !== null)
if (sourcePatternHoles.length === 0) {
return
}
const occupiedTargets = getOccupiedSnapTargets(occupiedTargetsByNodeId, targetNode.nodeId)
targetPatterns.forEach((targetPattern) => {
const targetPatternHoles = targetPattern.holeIds
.map((holeId) => targetHoleMap.get(holeId) ?? null)
.filter((hole): hole is AssemblyBasisHoleRecord => hole !== null)
if (targetPatternHoles.length === 0) {
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
}
sourcePatternHoles.forEach((anchorSourceHole) => {
targetPatternHoles.forEach((anchorTargetHole) => {
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') {
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 ||
targetNode.zone !== 'main' ||
!canUseHoleAutoSnap(targetNode) ||
!matchesPrimaryAnchorRole(sourceNode, targetNode)
) {
return
}
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
.map((holeId) => sourceHoleMap.get(holeId) ?? null)
.filter((hole): hole is AssemblyBasisHoleRecord => hole !== null)
if (sourcePatternHoles.length === 0) {
return
}
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 && !occupiedTargets.holeIds.has(hole.id),
)
if (targetPatternHoles.length === 0) {
return
}
sourcePatternHoles.forEach((anchorSourceHole) => {
targetPatternHoles.forEach((anchorTargetHole) => {
if (!areHoleDiametersCompatible(anchorSourceHole, anchorTargetHole)) {
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,10 +2020,16 @@ 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)
: buildStagingPosition(snapshot.nodes.length, inventoryItem.source)
: !isDesktop && assemblyTargetPosition
? cloneVector3(assemblyTargetPosition)
: buildStagingPosition(snapshot.nodes.length, inventoryItem.source)
const baseRole = isDesktop ? 'desktop' : inventoryItem.source
const nextNodes: AssemblyWorkspaceNode[] = [
@ -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,

View File

@ -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))

View File

@ -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,

View File

@ -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
}

View File

@ -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,