From baca2fde9d8d74641a447b65abb1efdd5593ac9b Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 28 Apr 2026 13:48:32 +0800 Subject: [PATCH 1/2] feat(assembly): add snap basis workflow --- autocad-plugin/FurnitureExporter/Commands.cs | 24 +- .../MountPatternAnalyzerTests.cs | 2 + .../SnapSurfaceBuilderTests.cs | 103 +++++ .../FurnitureExporter.csproj | 2 +- autocad-plugin/FurnitureExporter/Models.cs | 72 +++- .../FurnitureExporter/MountPatternAnalyzer.cs | 6 +- .../FurnitureExporter/SnapSurfaceBuilder.cs | 135 +++++++ autocad-plugin/build-installer.ps1 | 2 +- .../PackageContents.xml | 10 +- autocad-plugin/installer/使用说明.txt | 4 +- .../src/app/assisted-assembly/page.tsx | 6 +- furniture-3d-system/src/app/page.tsx | 2 +- .../src/components/3d/HoleOverlay2D.tsx | 75 +++- .../src/components/3d/hole-viewer-tokens.ts | 12 + .../components/3d/hole-viewer.constants.ts | 6 + .../platform/AssemblyWorkbenchV2.tsx | 127 ++++--- .../__tests__/AssemblyWorkbenchV2.test.tsx | 65 +++- furniture-3d-system/src/lib/3d/engine.ts | 57 ++- .../src/lib/__tests__/sku-cad-data.test.ts | 60 +++ .../src/lib/cad-export-processor.ts | 27 +- .../__tests__/assembly-workspace.test.ts | 255 ++++++++++++- .../assisted-assembly-variant.test.ts | 46 +++ .../lib/open-product/assembly-workspace.ts | 353 ++++++++++++++++-- .../open-product/assisted-assembly-variant.ts | 15 +- furniture-3d-system/src/lib/sku-cad-data.ts | 100 ++++- furniture-3d-system/src/lib/storage.ts | 3 +- .../services/asset-cad-import.service.ts | 18 +- 27 files changed, 1423 insertions(+), 164 deletions(-) create mode 100644 autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/SnapSurfaceBuilderTests.cs create mode 100644 autocad-plugin/FurnitureExporter/SnapSurfaceBuilder.cs create mode 100644 furniture-3d-system/src/lib/open-product/__tests__/assisted-assembly-variant.test.ts diff --git a/autocad-plugin/FurnitureExporter/Commands.cs b/autocad-plugin/FurnitureExporter/Commands.cs index a5b97b74..39ece423 100644 --- a/autocad-plugin/FurnitureExporter/Commands.cs +++ b/autocad-plugin/FurnitureExporter/Commands.cs @@ -72,6 +72,8 @@ public class Commands public List Holes { get; } = new(); public List HoleGroups { get; } = new(); public List MountPatterns { get; } = new(); + public List SnapPatterns { get; } = new(); + public List SnapSurfaces { get; } = new(); public int LogicalHoleCount { get; set; } public List EdgeFeatures { get; } = new(); public List 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] 打包文件..."); diff --git a/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/MountPatternAnalyzerTests.cs b/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/MountPatternAnalyzerTests.cs index b3ad97a4..0f33131f 100644 --- a/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/MountPatternAnalyzerTests.cs +++ b/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/MountPatternAnalyzerTests.cs @@ -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] diff --git a/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/SnapSurfaceBuilderTests.cs b/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/SnapSurfaceBuilderTests.cs new file mode 100644 index 00000000..92043dbf --- /dev/null +++ b/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/SnapSurfaceBuilderTests.cs @@ -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 { pattern }; + panel.SnapPatterns = panel.MountPatterns; + + var exportData = new ExportData + { + ExportMode = "single_asset", + Panels = new List { panel }, + MountPatterns = new List { pattern }, + SnapPatterns = new List { 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(root["mountPatterns"])); + Assert.Single(Assert.IsType(root["snapPatterns"])); + Assert.Equal(6, Assert.IsType(root["snapSurfaces"]).Count); + var panels = Assert.IsType(root["panels"]); + Assert.Equal(6, Assert.IsType(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 }, + }; + } + } +} diff --git a/autocad-plugin/FurnitureExporter/FurnitureExporter.csproj b/autocad-plugin/FurnitureExporter/FurnitureExporter.csproj index 912893eb..d7c50877 100644 --- a/autocad-plugin/FurnitureExporter/FurnitureExporter.csproj +++ b/autocad-plugin/FurnitureExporter/FurnitureExporter.csproj @@ -11,7 +11,7 @@ FurnitureExporter FurnitureExporter - 2.5.0 + 2.6.0 Furniture 3D System 家具 3D 模型导出插件 - 导出 GLB 模型和五金件位置数据 diff --git a/autocad-plugin/FurnitureExporter/Models.cs b/autocad-plugin/FurnitureExporter/Models.cs index 15580f2c..27f9e7b1 100644 --- a/autocad-plugin/FurnitureExporter/Models.cs +++ b/autocad-plugin/FurnitureExporter/Models.cs @@ -111,14 +111,23 @@ public class ExportData public List HoleGroups { get; set; } = new(); /// - /// 安装图样(v2.5 新增) - /// 说明: - /// - 吸附面先缩小范围; - /// - HoleGroup 负责把几何孔组织成业务孔位; - /// - MountPattern 负责定义“至少命中几个孔才允许吸附成立”。 + /// 安装图样(v2.5 兼容字段)。 + /// 新方案主字段是 SnapPatterns;这里继续保留给旧 Web 端兼容。 /// public List MountPatterns { get; set; } = new(); + /// + /// 吸附图样(v2.6 新主字段)。 + /// 内容与 MountPatterns 保持一致,Web 端后续优先读取 snapPatterns。 + /// + public List SnapPatterns { get; set; } = new(); + + /// + /// 吸附面(v2.6 新增)。 + /// 每块板件至少提供 6 个轴向外表面,用于辅助装配阶段做面级吸附候选。 + /// + public List SnapSurfaces { get; set; } = new(); + /// /// 逻辑孔位数量(v2.2.4 新增) /// 计算方式:以 HoleGroupId 合并计数(HoleGroupId 为空则按 Hole.Id 计数)。 @@ -331,9 +340,19 @@ public class PanelInfo public List HoleGroups { get; set; } = new(); /// - /// 该板件上的安装图样(v2.5 新增) + /// 该板件上的安装图样(v2.5 兼容字段) /// public List MountPatterns { get; set; } = new(); + + /// + /// 该板件上的吸附图样(v2.6 新主字段) + /// + public List SnapPatterns { get; set; } = new(); + + /// + /// 该板件的吸附面(v2.6 新增) + /// + public List SnapSurfaces { get; set; } = new(); } /// @@ -647,6 +666,47 @@ public class MountPatternInfo public List Warnings { get; set; } = new(); } +/// +/// 吸附面信息(v2.6 新增) +/// +public class SnapSurfaceInfo +{ + /// + /// 吸附面 ID(如 panel_001_snap_top) + /// + public string Id { get; set; } = ""; + + /// + /// 面标签:left / right / front / back / bottom / top + /// + public string Face { get; set; } = ""; + + /// + /// 所属板件 ID + /// + public string PanelId { get; set; } = ""; + + /// + /// 外法向量 [X, Y, Z] + /// + public double[] Normal { get; set; } = new double[3]; + + /// + /// 面中心 [X, Y, Z] (mm) + /// + public double[] Center { get; set; } = new double[3]; + + /// + /// 面边界框。薄轴最小值和最大值相同,表示该外表面所在平面。 + /// + public BoundingBox3D Bounds { get; set; } = new(); + + /// + /// 推导置信度(0-1) + /// + public double Confidence { get; set; } +} + /// /// 3D 边界框 /// diff --git a/autocad-plugin/FurnitureExporter/MountPatternAnalyzer.cs b/autocad-plugin/FurnitureExporter/MountPatternAnalyzer.cs index 3612634d..73be0879 100644 --- a/autocad-plugin/FurnitureExporter/MountPatternAnalyzer.cs +++ b/autocad-plugin/FurnitureExporter/MountPatternAnalyzer.cs @@ -50,6 +50,7 @@ public static class MountPatternAnalyzer if (units.Count == 0) { panel.MountPatterns = new List(); + panel.SnapPatterns = new List(); 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(); + panel.MountPatterns = panelPatterns; + panel.SnapPatterns = panelPatterns; } } diff --git a/autocad-plugin/FurnitureExporter/SnapSurfaceBuilder.cs b/autocad-plugin/FurnitureExporter/SnapSurfaceBuilder.cs new file mode 100644 index 00000000..65107276 --- /dev/null +++ b/autocad-plugin/FurnitureExporter/SnapSurfaceBuilder.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; + +namespace FurnitureExporter; + +/// +/// 从板件包围盒推导辅助装配吸附面。 +/// +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 BuildSnapSurfaces(IReadOnlyList panels) + { + var allSurfaces = new List(); + + foreach (var panel in panels) + { + var panelSurfaces = BuildPanelSurfaces(panel); + panel.SnapSurfaces = panelSurfaces; + allSurfaces.AddRange(panelSurfaces); + } + + return allSurfaces; + } + + private static List BuildPanelSurfaces(PanelInfo panel) + { + var (min, max, confidence) = ResolveBounds(panel); + var surfaces = new List(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 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; } + } +} diff --git a/autocad-plugin/build-installer.ps1 b/autocad-plugin/build-installer.ps1 index 4f65e023..8bb9be84 100644 --- a/autocad-plugin/build-installer.ps1 +++ b/autocad-plugin/build-installer.ps1 @@ -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" diff --git a/autocad-plugin/installer/FurnitureExporter.bundle/PackageContents.xml b/autocad-plugin/installer/FurnitureExporter.bundle/PackageContents.xml index a4a35657..2bdec4dd 100644 --- a/autocad-plugin/installer/FurnitureExporter.bundle/PackageContents.xml +++ b/autocad-plugin/installer/FurnitureExporter.bundle/PackageContents.xml @@ -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}"> > -}) { +} = {}) { 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 diff --git a/furniture-3d-system/src/app/page.tsx b/furniture-3d-system/src/app/page.tsx index 9a1396e4..74aa3640 100644 --- a/furniture-3d-system/src/app/page.tsx +++ b/furniture-3d-system/src/app/page.tsx @@ -13,7 +13,7 @@ export default async function HomePage({ searchParams, }: { searchParams?: Promise> -}) { +} = {}) { const { user, authUser } = await requireSession('/login') const resolvedSearchParams = searchParams ? await searchParams : undefined const initialTab = parseHomeWorkspaceTab(resolvedSearchParams?.tab) diff --git a/furniture-3d-system/src/components/3d/HoleOverlay2D.tsx b/furniture-3d-system/src/components/3d/HoleOverlay2D.tsx index c394489b..833c12e4 100644 --- a/furniture-3d-system/src/components/3d/HoleOverlay2D.tsx +++ b/furniture-3d-system/src/components/3d/HoleOverlay2D.tsx @@ -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) { + 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, 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(); }); diff --git a/furniture-3d-system/src/components/3d/hole-viewer-tokens.ts b/furniture-3d-system/src/components/3d/hole-viewer-tokens.ts index 06f35b02..76d422a0 100644 --- a/furniture-3d-system/src/components/3d/hole-viewer-tokens.ts +++ b/furniture-3d-system/src/components/3d/hole-viewer-tokens.ts @@ -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 = { 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), } diff --git a/furniture-3d-system/src/components/3d/hole-viewer.constants.ts b/furniture-3d-system/src/components/3d/hole-viewer.constants.ts index 71f86215..b9e9c3ee 100644 --- a/furniture-3d-system/src/components/3d/hole-viewer.constants.ts +++ b/furniture-3d-system/src/components/3d/hole-viewer.constants.ts @@ -5,6 +5,12 @@ export const HOLE_TYPE_COLORS: Record = { 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, } diff --git a/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx b/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx index 7b7ec223..4da09667 100644 --- a/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx +++ b/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx @@ -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, - panelLabelByPanelId: {} as Record, + const holesById = new Map() + const activeHolePanelIdSet = new Set() + const panelNodeNameByPanelId: Record = {} + const panelLabelByPanelId: Record = {} + + 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, + 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 ? : undefined} customHasContent={stageWorldNodes.length === 0} diff --git a/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx b/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx index b6a23461..2c06bf5a 100644 --- a/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx +++ b/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx @@ -73,7 +73,10 @@ jest.mock('../ModelPreviewPanel', () => { onNodeSelect, onNodeContextMenu, }: { - worldNodes?: Array<{ nodeName: string }> + worldNodes?: Array<{ + nodeName: string + expectedDimensions?: Record | null + }> worldFrameMode?: 'always' | 'initial' | 'never' selectedNodeName?: string | null materialAssignments?: Array<{ nodeName: string }> @@ -105,6 +108,9 @@ jest.mock('../ModelPreviewPanel', () => { return (
世界节点:{worldNodes.length}
+
+ {JSON.stringify(worldNodes.map((node) => node.expectedDimensions ?? null))} +
{worldFrameMode ?? 'none'}
{selectedNodeName ?? 'none'}
材质指派:{materialAssignments.length}
@@ -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) { + 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(右侧业务区
} />) 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( + 右侧业务区} + />, + ) + + 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() }) }) diff --git a/furniture-3d-system/src/lib/3d/engine.ts b/furniture-3d-system/src/lib/3d/engine.ts index 28567e0e..254cb7c7 100644 --- a/furniture-3d-system/src/lib/3d/engine.ts +++ b/furniture-3d-system/src/lib/3d/engine.ts @@ -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 { 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); diff --git a/furniture-3d-system/src/lib/__tests__/sku-cad-data.test.ts b/furniture-3d-system/src/lib/__tests__/sku-cad-data.test.ts index 2fc27204..70c0bbe0 100644 --- a/furniture-3d-system/src/lib/__tests__/sku-cad-data.test.ts +++ b/furniture-3d-system/src/lib/__tests__/sku-cad-data.test.ts @@ -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 底稿和装配语义快照', () => { diff --git a/furniture-3d-system/src/lib/cad-export-processor.ts b/furniture-3d-system/src/lib/cad-export-processor.ts index e73e5a94..dd26bef3 100644 --- a/furniture-3d-system/src/lib/cad-export-processor.ts +++ b/furniture-3d-system/src/lib/cad-export-processor.ts @@ -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, diff --git a/furniture-3d-system/src/lib/open-product/__tests__/assembly-workspace.test.ts b/furniture-3d-system/src/lib/open-product/__tests__/assembly-workspace.test.ts index 055943a8..c6b9a46d 100644 --- a/furniture-3d-system/src/lib/open-product/__tests__/assembly-workspace.test.ts +++ b/furniture-3d-system/src/lib/open-product/__tests__/assembly-workspace.test.ts @@ -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() + }) }) diff --git a/furniture-3d-system/src/lib/open-product/__tests__/assisted-assembly-variant.test.ts b/furniture-3d-system/src/lib/open-product/__tests__/assisted-assembly-variant.test.ts new file mode 100644 index 00000000..bc7bc3b6 --- /dev/null +++ b/furniture-3d-system/src/lib/open-product/__tests__/assisted-assembly-variant.test.ts @@ -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', + ]) + }) +}) diff --git a/furniture-3d-system/src/lib/open-product/assembly-workspace.ts b/furniture-3d-system/src/lib/open-product/assembly-workspace.ts index ced8ce08..c688a5ad 100644 --- a/furniture-3d-system/src/lib/open-product/assembly-workspace.ts +++ b/furniture-3d-system/src/lib/open-product/assembly-workspace.ts @@ -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 | null | undefined, key : [] } +function readSnapFaceValue(record: Record | 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 + 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((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((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 + patternIds: Set + surfaceIds: Set +} + +function createOccupiedSnapTargets(): OccupiedSnapTargets { + return { + holeIds: new Set(), + patternIds: new Set(), + surfaceIds: new Set(), + } +} + +function getOccupiedSnapTargets( + occupiedTargetsByNodeId: Map, + targetNodeId: string, +) { + return occupiedTargetsByNodeId.get(targetNodeId) ?? createOccupiedSnapTargets() +} + +function buildOccupiedSnapTargetsByNodeId( + snapshot: AssemblyWorkspaceSnapshot, + movingNodeId: string, +): Map { + const result = new Map() + + 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, ): 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, 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, diff --git a/furniture-3d-system/src/lib/open-product/assisted-assembly-variant.ts b/furniture-3d-system/src/lib/open-product/assisted-assembly-variant.ts index b7cd4de5..699cbd54 100644 --- a/furniture-3d-system/src/lib/open-product/assisted-assembly-variant.ts +++ b/furniture-3d-system/src/lib/open-product/assisted-assembly-variant.ts @@ -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)) diff --git a/furniture-3d-system/src/lib/sku-cad-data.ts b/furniture-3d-system/src/lib/sku-cad-data.ts index 07146677..0f348de0 100644 --- a/furniture-3d-system/src/lib/sku-cad-data.ts +++ b/furniture-3d-system/src/lib/sku-cad-data.ts @@ -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() + + rawSurfaces.forEach((entry, index) => { + if (!entry || typeof entry !== 'object') { + return + } + + const record = entry as Record + 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, diff --git a/furniture-3d-system/src/lib/storage.ts b/furniture-3d-system/src/lib/storage.ts index 33fdcd66..6454b3af 100644 --- a/furniture-3d-system/src/lib/storage.ts +++ b/furniture-3d-system/src/lib/storage.ts @@ -179,7 +179,8 @@ export async function doesS3ObjectExist(key: string): Promise { ? (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 } diff --git a/furniture-3d-system/src/server/services/asset-cad-import.service.ts b/furniture-3d-system/src/server/services/asset-cad-import.service.ts index 5f6d132b..ce7cf2d3 100644 --- a/furniture-3d-system/src/server/services/asset-cad-import.service.ts +++ b/furniture-3d-system/src/server/services/asset-cad-import.service.ts @@ -235,7 +235,17 @@ function buildAssemblyBasis(params: { }): Record { 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, From 1740036d240b9d90ec8c161a6dae92a903089133 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 28 Apr 2026 14:48:55 +0800 Subject: [PATCH 2/2] feat: add non-integrated project review flow --- .../NonIntegratedDetailClient.test.tsx | 222 ++- .../NonIntegratedDetailClient.tsx | 1432 ++++++++++++++++- .../nonIntegratedStorage.test.ts | 39 + .../non-integrated/nonIntegratedStorage.ts | 175 +- 4 files changed, 1747 insertions(+), 121 deletions(-) diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx index 181d33c0..9bf048cf 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx @@ -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(); - 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( @@ -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( + '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( + , + ); + + const sidebar = screen.getByTestId("document-workspace-sidebar"); + const draftTriggerIds = Array.from( + sidebar.querySelectorAll( + '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( + '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( + , + ); + + 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( - '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( - '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( - '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(); diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx index 4ef63f2b..bf208871 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -96,6 +96,8 @@ import { buildNonIntegratedSkuId, buildNonIntegratedUploadFileId, createDefaultDocumentWorkspaceConfirmations, + createEmptyNonIntegratedOpeningReference, + createEmptyNonIntegratedProjectReview, listNonIntegratedProducts, NON_INTEGRATED_BRAND_PREFIX_MAP, NON_INTEGRATED_DUPLICATE_SPU_MESSAGE, @@ -103,6 +105,9 @@ import { getNonIntegratedDocumentWorkspaceCategoryLabel, getNonIntegratedProductById, hasPendingDocumentWorkspaceConfirmation, + normalizeNonIntegratedOpeningReference, + normalizeNonIntegratedProjectReview, + normalizeNonIntegratedProjectStatus, replaceNonIntegratedProducts, saveNonIntegratedProduct, stripNonIntegratedBrandPrefix, @@ -112,9 +117,12 @@ import { type NonIntegratedDocumentWorkspaceCategory, type NonIntegratedDocumentWorkspaceConfirmationMap, type NonIntegratedFactoryRow, + type NonIntegratedOpeningReference, type NonIntegratedPricingRow, type NonIntegratedPackagingInnerItem, type NonIntegratedProductRecord, + type NonIntegratedProjectReview, + type NonIntegratedProjectStatus, type NonIntegratedQualityInspectionSheet, type NonIntegratedReviewIssue, type NonIntegratedReviewRound, @@ -142,6 +150,8 @@ type SkuTabKey = "skuInfo" | "fileUpload"; type ProductFactoryTabKey = "factoryInfo" | "skuInfo"; type UploadCategoryKey = | "未分类" + | "开品参考" + | "立项评审" | "评审资料" | "产品模型" | "产品实拍图" @@ -368,6 +378,14 @@ type ReviewSheetAutoFieldKey = | "size" | "material" | "color"; +type ProjectReviewFieldKey = Exclude< + keyof NonIntegratedProjectReview, + "decidedAt" +>; +type OpeningReferenceFieldKey = Exclude< + keyof NonIntegratedOpeningReference, + "productImage" | "productImageName" | "adaptableBrands" +>; const REVIEW_SHEET_AUTO_FIELD_KEYS: ReviewSheetAutoFieldKey[] = [ "factory", "productName", @@ -752,6 +770,65 @@ const DOCUMENT_WORKSPACE_CATEGORY_ORDER: DocumentWorkspaceCategory[] = [ ...NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES, ]; +const OPENING_REFERENCE_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = + "开品参考"; +const PROJECT_REVIEW_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = + "立项评审"; +const PROJECT_SETUP_WORKSPACE_CATEGORIES: DocumentWorkspaceCategory[] = [ + OPENING_REFERENCE_WORKSPACE_CATEGORY, + PRODUCT_DATA_WORKSPACE_CATEGORY, + "设计卖点", + PROJECT_REVIEW_WORKSPACE_CATEGORY, +]; +const PROJECT_SETUP_NODE_COUNT = PROJECT_SETUP_WORKSPACE_CATEGORIES.length; +const PROJECT_STATUS_COPY: Record< + NonIntegratedProjectStatus, + { + label: string; + description: string; + badgeClassName: string; + panelClassName: string; + saveMessage: string; + } +> = { + draft: { + label: "待立项", + description: "先整理开品参考、产品资料和设计卖点,再由负责人判断是否进入开发。", + badgeClassName: "bg-[#fff2d9] text-[#8a5a00]", + panelClassName: "border-[#efd49d] bg-[#fff9ec]", + saveMessage: "已标记为待立项", + }, + revision: { + label: "退回修改", + description: "资料需要回到产品资料或设计卖点继续补充,补完后再重新评审。", + badgeClassName: "bg-[#e8f1fb] text-[#23659b]", + panelClassName: "border-[#c7ddec] bg-[#f4f9fd]", + saveMessage: "已退回修改,后续节点保持收起", + }, + rejected: { + label: "废弃不开", + description: "这个品暂不进入开发,只保留前期资料和立项结论。", + badgeClassName: "bg-[#ffe6e9] text-[#a32135]", + panelClassName: "border-[#f4c7ce] bg-[#fff7f8]", + saveMessage: "已标记为废弃不开,后续节点保持收起", + }, + approved: { + label: "已立项", + description: "立项通过后展开基础信息和后续节点,采购可基于资料推进打样。", + badgeClassName: "bg-[#dff7eb] text-[#156b3f]", + panelClassName: "border-[#bde8ce] bg-[#f4fbf7]", + saveMessage: "立项已通过,已展开基础信息和后续节点", + }, +}; +const OPENING_REFERENCE_STAGE_OPTIONS = [ + "立项前", + "资料补充中", + "待立项评审", + "已立项", + "退回修改", + "废弃不开", +]; + const UPLOAD_CATEGORY_OPTIONS: UploadCategoryKey[] = [ "未分类", ...DOCUMENT_WORKSPACE_CATEGORY_ORDER, @@ -771,7 +848,7 @@ const SUPPLEMENT_WORKSPACE_SHORTCUT_LAYOUT: DocumentWorkspaceCategory[] = [ ]; const DEFAULT_DOCUMENT_WORKSPACE_CATEGORY: DocumentWorkspaceCategory = - "产品实拍图"; + OPENING_REFERENCE_WORKSPACE_CATEGORY; const PRODUCT_PHOTO_CATEGORY: DocumentWorkspaceCategory = "产品实拍图"; const AUTO_SAVE_DELAY_MS = 500; @@ -975,8 +1052,10 @@ const EMPTY_DOCUMENT_WORKSPACE_NOTES: Record< DocumentWorkspaceCategory, string > = { + 开品参考: "", 产品实拍图: "", 评审资料: "", + 立项评审: "", 设计卖点: "", 安装方式: "", 包装细节: "", @@ -998,6 +1077,13 @@ const DOCUMENT_WORKSPACE_COPY: Record< emptyDescription: string; } > = { + 开品参考: { + description: "维护开品前的参考资料、竞品链接和方向说明。", + noteLabel: "参考备注", + notePlaceholder: "可填写竞品链接、参考方向、价格段或开品理由。", + emptyTitle: "当前还没有开品参考", + emptyDescription: "可先上传竞品截图、参考文件,或在备注里记录竞品链接。", + }, 产品模型: { description: "按四个大类集中维护打样资料、线稿爆炸图、孔位图和模型资料。", noteLabel: "资料备注", @@ -1015,12 +1101,19 @@ const DOCUMENT_WORKSPACE_COPY: Record< "可先上传九视图、细节图、质检照片或竞品照片,再在这里集中查看。", }, 评审资料: { - description: "按评审记录单模板在本地填写评审信息,并支持一键导出 Excel。", - noteLabel: "评审备注", - notePlaceholder: "可填写评审用途、版本说明或交接备注。", - emptyTitle: "当前还没有评审资料", + description: "按样品评审记录单模板在本地填写评审信息,并支持一键导出 Excel。", + noteLabel: "样品评审备注", + notePlaceholder: "可填写样品评审用途、版本说明或交接备注。", + emptyTitle: "当前还没有样品评审资料", emptyDescription: "可直接在当前工作区填写评审记录,并导出成 Excel。", }, + 立项评审: { + description: "记录立项会议资料和评审结论;确认通过后会展开基础信息及后续节点。", + noteLabel: "立项备注", + notePlaceholder: "可填写会议结论、负责人意见、待补资料或废弃原因。", + emptyTitle: "当前还没有立项评审资料", + emptyDescription: "可上传会议记录、立项判断材料,确认通过后继续维护基础信息。", + }, 设计卖点: { description: "集中查看设计卖点图、卖点文案和展示素材。", noteLabel: "卖点备注", @@ -3163,6 +3256,145 @@ function createFormValuesFromProduct( }; } +function getOpeningReferenceStageFromStatus( + status: NonIntegratedProjectStatus, +) { + if (status === "approved") return "已立项"; + if (status === "revision") return "退回修改"; + if (status === "rejected") return "废弃不开"; + return "立项前"; +} + +function createInitialOpeningReference( + product: NonIntegratedProductRecord | null, +): NonIntegratedOpeningReference { + if (!product) { + return { + ...createEmptyNonIntegratedOpeningReference(), + brand: "基本盘", + adaptableBrands: ["基本盘"], + currentStage: "立项前", + urgent: "否", + }; + } + + const normalized = normalizeNonIntegratedOpeningReference( + product.openingReference, + ); + const normalizedBrand = normalized.brand || product.brand || "基本盘"; + const fallbackAdaptableBrands = Array.from( + new Set([ + normalizedBrand, + ...(product.adaptableBrands?.filter((brand) => + BRAND_OPTIONS.includes(brand), + ) ?? []), + ]), + ); + const normalizedAdaptableBrands = normalized.adaptableBrands.length + ? Array.from( + new Set([ + normalizedBrand, + ...normalized.adaptableBrands.filter((brand) => + BRAND_OPTIONS.includes(brand), + ), + ]), + ) + : fallbackAdaptableBrands; + const projectStatus = normalizeNonIntegratedProjectStatus( + product?.projectStatus, + ); + + return { + ...normalized, + productImage: normalized.productImage || product.previewImage || "", + productImageName: + normalized.productImageName || product.previewImageName || "", + productName: normalized.productName || product.spu || "", + temporaryCode: normalized.temporaryCode || product.spu || product.id || "", + brand: normalizedBrand, + adaptableBrands: normalizedAdaptableBrands, + productCategory: + normalized.productCategory || product.productCategory || "", + salesCategory: normalized.salesCategory || product.salesCategory || "", + designer: normalized.designer || product.designer || "", + planningOwner: normalized.planningOwner || product.productPlanning || "", + currentStage: + normalized.currentStage || getOpeningReferenceStageFromStatus(projectStatus), + year: normalized.year || product.year || "", + targetPriceBand: normalized.targetPriceBand || product.priceBand || "", + size: normalized.size || product.size || "", + material: + normalized.material || product.materialType || product.tagMaterial || "", + urgent: normalized.urgent || "否", + }; +} + +function hasOpeningReferenceContent(reference: NonIntegratedOpeningReference) { + return ( + [ + reference.productImage, + reference.productName, + reference.temporaryCode, + reference.productCategory, + reference.salesCategory, + reference.designer, + reference.planningOwner, + reference.year, + reference.competitorLink, + reference.targetPriceBand, + reference.size, + reference.material, + reference.reason, + ].some((value) => value.trim()) || + reference.adaptableBrands.length > 1 || + (reference.brand.trim() && reference.brand.trim() !== "基本盘") || + (reference.urgent.trim() && reference.urgent.trim() !== "否") + ); +} + +function buildOpeningReferenceFormValues( + baseValues: FormValues, + reference: NonIntegratedOpeningReference, + spu: string, +): FormValues { + const normalizedSpu = spu.trim(); + const normalizedBrand = reference.brand.trim() || baseValues.brand || "基本盘"; + const normalizedAdaptableBrands = Array.from( + new Set([ + normalizedBrand, + ...reference.adaptableBrands.filter((brand) => + BRAND_OPTIONS.includes(brand), + ), + ...baseValues.adaptableBrands.filter((brand) => + BRAND_OPTIONS.includes(brand), + ), + ]), + ); + + return { + ...baseValues, + spu: normalizedSpu, + productCode: baseValues.productCode || normalizedSpu, + previewImage: reference.productImage.trim() || baseValues.previewImage, + previewImageName: + reference.productImageName.trim() || baseValues.previewImageName, + year: reference.year.trim() || baseValues.year, + brand: normalizedBrand, + adaptableBrands: normalizedAdaptableBrands, + productCategory: + reference.productCategory.trim() || baseValues.productCategory, + salesCategory: reference.salesCategory.trim() || baseValues.salesCategory, + productPlanning: + reference.planningOwner.trim() || baseValues.productPlanning, + spuOwner: baseValues.spuOwner || reference.designer.trim(), + designer: reference.designer.trim() || baseValues.designer, + priceBand: reference.targetPriceBand.trim() || baseValues.priceBand, + size: reference.size.trim() || baseValues.size, + materialType: reference.material.trim() || baseValues.materialType, + tagMaterial: reference.material.trim() || baseValues.tagMaterial, + }; +} + function createInitialDocumentWorkspaceNotes( product: NonIntegratedProductRecord | null, ) { @@ -3687,32 +3919,45 @@ function SectionCard({ bodyClassName = "", headerActions, }: { - title: string; + title?: string; description?: string; children: React.ReactNode; className?: string; bodyClassName?: string; headerActions?: React.ReactNode; }) { + const hasHeaderText = Boolean(title || description); + const hasHeader = hasHeaderText || Boolean(headerActions); + return (
-
-
-

- {title} -

- {description ? ( -

- {description} -

+ {hasHeader ? ( +
+ {hasHeaderText ? ( +
+ {title ? ( +

+ {title} +

+ ) : null} + {description ? ( +

+ {description} +

+ ) : null} +
+ ) : null} + {headerActions ? ( +
+ {headerActions} +
) : null}
- {headerActions ?
{headerActions}
: null} -
+ ) : null}
{children}
); @@ -4115,6 +4360,7 @@ function productHasLocalAssets(product: NonIntegratedProductRecord) { if ( isDataUrlSource(product.previewImage) || + isDataUrlSource(product.openingReference.productImage) || isDataUrlSource(product.model3dDataUrl ?? "") ) { return true; @@ -5170,6 +5416,9 @@ function buildProductPayload( uploadFiles: NonIntegratedUploadFile[], documentWorkspaceNotes: Record, documentWorkspaceConfirmations: NonIntegratedDocumentWorkspaceConfirmationMap, + openingReference: NonIntegratedOpeningReference, + projectReview: NonIntegratedProjectReview, + projectStatus: NonIntegratedProjectStatus, createdAt?: string, copiedFromProductId?: string, copiedFromSpu?: string, @@ -5360,6 +5609,9 @@ function buildProductPayload( return { id, + projectStatus, + projectReview: normalizeNonIntegratedProjectReview(projectReview), + openingReference: normalizeNonIntegratedOpeningReference(openingReference), spu: formValues.spu.trim(), year: formValues.year.trim(), demandFlowNo: formValues.demandFlowNo.trim(), @@ -5478,6 +5730,7 @@ export default function NonIntegratedDetailClient({ }) { const router = useRouter(); const previewFileInputRef = useRef(null); + const openingReferenceImageInputRef = useRef(null); const batchSkuImageInputRef = useRef(null); const fileUploadInputRef = useRef(null); const documentWorkspaceUploadInputRef = useRef(null); @@ -5507,6 +5760,10 @@ export default function NonIntegratedDetailClient({ ? initialProduct : getNonIntegratedProductById(productId) : null; + const initialProjectStatus: NonIntegratedProjectStatus = + mode === "create" + ? "draft" + : normalizeNonIntegratedProjectStatus(existingProduct?.projectStatus); const initialBomDetailsResultRef = useRef(null); if (!initialBomDetailsResultRef.current) { initialBomDetailsResultRef.current = createInitialBomDetails(existingProduct); @@ -5543,8 +5800,25 @@ export default function NonIntegratedDetailClient({ const [uploadFiles, setUploadFiles] = useState( existingProduct?.uploadFiles ?? [], ); + const [projectStatus, setProjectStatus] = + useState(initialProjectStatus); + const [projectReview, setProjectReview] = useState( + () => + existingProduct?.projectReview + ? normalizeNonIntegratedProjectReview(existingProduct.projectReview) + : createEmptyNonIntegratedProjectReview(), + ); + const [openingReference, setOpeningReference] = + useState(() => + createInitialOpeningReference(existingProduct), + ); + const [projectApprovalDialogOpen, setProjectApprovalDialogOpen] = + useState(false); + const [projectApprovalSpu, setProjectApprovalSpu] = useState(""); const [documentWorkspaceView, setDocumentWorkspaceView] = - useState("detail"); + useState( + initialProjectStatus === "approved" ? "detail" : "document", + ); const [activeDocumentWorkspaceCategory, setActiveDocumentWorkspaceCategory] = useState(DEFAULT_DOCUMENT_WORKSPACE_CATEGORY); const [documentWorkspaceNotes, setDocumentWorkspaceNotes] = useState< @@ -5860,8 +6134,8 @@ export default function NonIntegratedDetailClient({ } : undefined; const isCopiedBrandProduct = Boolean(existingProduct?.copiedFromProductId); - const isDocumentWorkspaceMode = - mode !== "create" && documentWorkspaceView === "document"; + const isProjectApproved = projectStatus === "approved"; + const isDocumentWorkspaceMode = documentWorkspaceView === "document"; const derivedSkuItems = useMemo( () => skuItems.map((item, index) => @@ -6192,6 +6466,23 @@ export default function NonIntegratedDetailClient({ })), [uploadFiles], ); + const visibleDocumentWorkspaceCategories = useMemo( + () => + isProjectApproved + ? UPLOAD_CATEGORY_PANEL_ORDER + : PROJECT_SETUP_WORKSPACE_CATEGORIES, + [isProjectApproved], + ); + const visibleGroupedUploadFiles = useMemo( + () => + visibleDocumentWorkspaceCategories.map((category) => ({ + category, + files: + groupedUploadFiles.find((group) => group.category === category) + ?.files ?? [], + })), + [groupedUploadFiles, visibleDocumentWorkspaceCategories], + ); const uploadFileCountByCategory = useMemo( () => Object.fromEntries( @@ -6236,6 +6527,12 @@ export default function NonIntegratedDetailClient({ return { ...uploadFileCountByCategory, + [OPENING_REFERENCE_WORKSPACE_CATEGORY]: + uploadFileCountByCategory[OPENING_REFERENCE_WORKSPACE_CATEGORY] + + (hasOpeningReferenceContent(openingReference) ? 1 : 0), + [PROJECT_REVIEW_WORKSPACE_CATEGORY]: + uploadFileCountByCategory[PROJECT_REVIEW_WORKSPACE_CATEGORY] + + (isProjectApproved ? 1 : 0), [REVIEW_WORKSPACE_CATEGORY]: uploadFileCountByCategory[REVIEW_WORKSPACE_CATEGORY] + populatedReviewRoundCount, @@ -6252,6 +6549,8 @@ export default function NonIntegratedDetailClient({ }; }, [ confirmedProductFactoryRowCount, + isProjectApproved, + openingReference, populatedReviewRoundCount, populatedProductPricingRowCount, populatedSampleMeasurementRowCount, @@ -6346,6 +6645,10 @@ export default function NonIntegratedDetailClient({ activeDocumentWorkspaceCategory === FACTORY_QUALITY_WORKSPACE_CATEGORY; const isReviewWorkspace = activeDocumentWorkspaceCategory === REVIEW_WORKSPACE_CATEGORY; + const isOpeningReferenceWorkspace = + activeDocumentWorkspaceCategory === OPENING_REFERENCE_WORKSPACE_CATEGORY; + const isProjectReviewWorkspace = + activeDocumentWorkspaceCategory === PROJECT_REVIEW_WORKSPACE_CATEGORY; const isDetectionWorkspace = activeDocumentWorkspaceCategory === DETECTION_WORKSPACE_CATEGORY; const isInstallationWorkspace = @@ -6943,6 +7246,8 @@ export default function NonIntegratedDetailClient({ ? `按款式分栏维护产品实拍。当前款式:${currentProductPhotoStyleLabel};九视图支持按文件名自动匹配,其他照片分组支持批量上传和替换。` : isProductDataWorkspace ? "左侧按四个大类切换,中间查看当前分类文件明细,右侧只预览 PDF 和图片;其他资料会展开文件明细。" + : isProjectReviewWorkspace + ? "承接开品参考、产品资料和设计卖点,记录立项会议结论;只有立项通过后才展开基础信息和后续节点。" : isReviewWorkspace ? activeDocumentWorkspaceCopy.description : isDetectionWorkspace @@ -7055,8 +7360,10 @@ export default function NonIntegratedDetailClient({ ? `当前还没有${selectedFactoryQualityStyleEntry?.label ?? "该款式"}的${activeFactoryQualityAssetLabel},可点击上方“上传到当前栏”补充。` : activeDocumentWorkspaceCopy.emptyDescription; const canSubmitCreate = - formValues.spu.trim().length > 0 && - formValues.previewImage.length > 0; + mode === "create" + ? openingReference.productName.trim().length > 0 || + openingReference.temporaryCode.trim().length > 0 + : formValues.spu.trim().length > 0 && formValues.previewImage.length > 0; const existingProductCreatedAt = existingProduct?.createdAt ?? ""; const uploadAssetSourceToServer = useCallback( @@ -7118,6 +7425,7 @@ export default function NonIntegratedDetailClient({ ]; const [ previewImage, + openingReferenceProductImage, model3dDataUrl, styleDetailsWithAssets, skuItemsWithAssets, @@ -7131,6 +7439,13 @@ export default function NonIntegratedDetailClient({ `${product.spu || product.id}-preview.png`, "image/png", ), + uploadAssetSourceToServer( + product.id, + product.openingReference.productImage, + product.openingReference.productImageName || + `${product.spu || product.id}-opening-reference.png`, + "image/png", + ), uploadAssetSourceToServer( product.id, product.model3dDataUrl ?? "", @@ -7223,6 +7538,10 @@ export default function NonIntegratedDetailClient({ return { ...product, previewImage, + openingReference: { + ...product.openingReference, + productImage: openingReferenceProductImage, + }, model3dDataUrl, styleDetails: styleDetailsWithAssets, skuItems: skuItemsWithAssets, @@ -7294,6 +7613,9 @@ export default function NonIntegratedDetailClient({ uploadFiles, documentWorkspaceNotes, documentWorkspaceConfirmations, + openingReference, + projectReview, + projectStatus, existingProductCreatedAt, existingProduct?.copiedFromProductId, existingProduct?.copiedFromSpu, @@ -7333,6 +7655,9 @@ export default function NonIntegratedDetailClient({ productFactoryRows, productPricingRows, productSampleMeasurementRows, + openingReference, + projectReview, + projectStatus, skuItems, styleDetails, uploadFiles, @@ -7346,6 +7671,23 @@ export default function NonIntegratedDetailClient({ void compactStoredProducts().catch(() => undefined); }, [compactStoredProducts]); + useEffect(() => { + if (isProjectApproved) return; + if ( + documentWorkspaceView === "document" && + PROJECT_SETUP_WORKSPACE_CATEGORIES.includes(activeDocumentWorkspaceCategory) + ) { + return; + } + + setDocumentWorkspaceView("document"); + setActiveDocumentWorkspaceCategory(OPENING_REFERENCE_WORKSPACE_CATEGORY); + }, [ + activeDocumentWorkspaceCategory, + documentWorkspaceView, + isProjectApproved, + ]); + useEffect(() => { if (!isInstallationWorkspace) return; @@ -7605,6 +7947,8 @@ export default function NonIntegratedDetailClient({ mode, persistEditedProduct, productId, + projectReview, + projectStatus, reviewRounds, skuItems, styleDetails, @@ -9078,6 +9422,81 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("success"); } + function handleOpeningReferenceFieldChange( + field: OpeningReferenceFieldKey, + value: string, + ) { + if (isReadOnly) return; + + setOpeningReference((current) => { + if (field !== "brand") { + return { + ...current, + [field]: value, + }; + } + + const nextBrand = value.trim() || "基本盘"; + const nextAdaptableBrands = Array.from( + new Set([ + nextBrand, + ...current.adaptableBrands.filter((brand) => + BRAND_OPTIONS.includes(brand), + ), + ]), + ); + + return { + ...current, + brand: nextBrand, + adaptableBrands: nextAdaptableBrands, + }; + }); + setSaveMessage(""); + setSaveMessageType("success"); + } + + function handleOpeningReferenceAdaptableBrandsChange(value: string[]) { + if (isReadOnly) return; + + setOpeningReference((current) => { + const nextBrand = current.brand.trim() || "基本盘"; + + return { + ...current, + adaptableBrands: Array.from( + new Set([ + nextBrand, + ...BRAND_OPTIONS.filter((brand) => value.includes(brand)), + ]), + ), + }; + }); + setSaveMessage(""); + setSaveMessageType("success"); + } + + async function handleOpeningReferenceImageChange( + event: ChangeEvent, + ) { + if (isReadOnly) return; + const file = event.target.files?.[0]; + if (!file) return; + + try { + const uploadedAsset = await uploadFileToServer(file, detachedUploadOptions); + setOpeningReference((current) => ({ + ...current, + productImage: uploadedAsset.url, + productImageName: uploadedAsset.fileName, + })); + setSaveMessage(""); + setSaveMessageType("success"); + } finally { + event.target.value = ""; + } + } + function updateSelectedPackagingStyleDetail( updater: (detail: StyleDetailForm) => StyleDetailForm, ) { @@ -9146,6 +9565,73 @@ export default function NonIntegratedDetailClient({ setSaveMessageType("success"); } + function handleProjectReviewFieldChange( + field: ProjectReviewFieldKey, + value: string, + ) { + if (isReadOnly) return; + + setProjectReview((current) => ({ + ...current, + [field]: value, + })); + } + + function handleSetProjectStatus(nextStatus: NonIntegratedProjectStatus) { + if (isReadOnly || projectStatus === nextStatus) return; + + if (nextStatus === "approved") { + setProjectApprovalSpu(""); + setProjectApprovalDialogOpen(true); + return; + } + + setProjectStatus(nextStatus); + setProjectReview((current) => ({ + ...current, + decidedAt: new Date().toISOString(), + })); + setOpeningReference((current) => ({ + ...current, + currentStage: getOpeningReferenceStageFromStatus(nextStatus), + })); + + setDocumentWorkspaceView("document"); + setActiveDocumentWorkspaceCategory(PROJECT_REVIEW_WORKSPACE_CATEGORY); + + setSaveMessage(PROJECT_STATUS_COPY[nextStatus].saveMessage); + setSaveMessageType("success"); + } + + function handleConfirmProjectApproval() { + if (isReadOnly) return; + + const normalizedSpu = projectApprovalSpu.trim().toUpperCase(); + if (!normalizedSpu) { + setSaveMessage("请先填写正式 SPU"); + setSaveMessageType("error"); + return; + } + + setFormValues((current) => + buildOpeningReferenceFormValues(current, openingReference, normalizedSpu), + ); + setProjectStatus("approved"); + setProjectReview((current) => ({ + ...current, + decidedAt: new Date().toISOString(), + })); + setOpeningReference((current) => ({ + ...current, + temporaryCode: current.temporaryCode.trim() || normalizedSpu, + currentStage: getOpeningReferenceStageFromStatus("approved"), + })); + setDocumentWorkspaceView("detail"); + setProjectApprovalDialogOpen(false); + setSaveMessage(PROJECT_STATUS_COPY.approved.saveMessage); + setSaveMessageType("success"); + } + function renderDocumentWorkspaceConfirmationAction() { if (isReadOnly || !activeDocumentWorkspaceNeedsConfirmation) { return null; @@ -10349,12 +10835,27 @@ export default function NonIntegratedDetailClient({ setIsSubmitting(true); try { - const nextProductId = buildNonIntegratedProductId(formValues.spu); + const draftSpu = + openingReference.temporaryCode.trim() || + openingReference.productName.trim() || + `KP-${Date.now()}`; + const nextOpeningReference = normalizeNonIntegratedOpeningReference({ + ...openingReference, + productName: openingReference.productName.trim() || draftSpu, + temporaryCode: draftSpu, + currentStage: getOpeningReferenceStageFromStatus(projectStatus), + }); + const nextFormValues = buildOpeningReferenceFormValues( + formValues, + nextOpeningReference, + draftSpu, + ); + const nextProductId = buildNonIntegratedProductId(nextFormValues.spu); await compactStoredProducts(); const normalizedProduct = await normalizeProductAssetsForStorage( buildProductPayload( nextProductId, - formValues, + nextFormValues, skuItems, styleDetails, bomDetails, @@ -10366,6 +10867,9 @@ export default function NonIntegratedDetailClient({ uploadFiles, documentWorkspaceNotes, documentWorkspaceConfirmations, + nextOpeningReference, + projectReview, + projectStatus, ), ); const savedProduct = shouldUseRemoteAssetStorage() @@ -10385,6 +10889,32 @@ export default function NonIntegratedDetailClient({ } } + function renderCreateFooter() { + if (isReadOnly || mode !== "create") return null; + + return ( +
+
+ + +
+
+ ); + } + if (mode !== "create" && productId && !existingProduct) { return (
@@ -11978,6 +12508,611 @@ export default function NonIntegratedDetailClient({ ); } + function renderOpeningReferenceWorkspace() { + const topFields: { + key: OpeningReferenceFieldKey; + label: string; + type?: BasicFieldType; + options?: string[]; + placeholder?: string; + className?: string; + }[] = [ + { + key: "productName", + label: "开品名称", + placeholder: "如:胡桃木色窄边书桌", + }, + { + key: "temporaryCode", + label: "临时编号", + placeholder: "可先保留,立项后再生成正式 SPU", + }, + { + key: "brand", + label: "品牌", + type: "select", + options: availableBrandOptions.length ? availableBrandOptions : BRAND_OPTIONS, + }, + { + key: "productCategory", + label: "商品类目", + placeholder: "如:书桌", + }, + { + key: "salesCategory", + label: "销售类目", + placeholder: "如:书桌", + }, + { + key: "designer", + label: "设计师", + placeholder: "请输入设计师", + }, + { + key: "planningOwner", + label: "企划负责人", + placeholder: "请输入企划负责人", + }, + { + key: "currentStage", + label: "当前阶段", + type: "select", + options: OPENING_REFERENCE_STAGE_OPTIONS, + }, + { + key: "year", + label: "年份", + placeholder: "如:2026", + }, + ]; + const demandFields: { + key: OpeningReferenceFieldKey; + label: string; + type?: BasicFieldType; + options?: string[]; + placeholder?: string; + className?: string; + }[] = [ + { + key: "competitorLink", + label: "竞品链接", + placeholder: "粘贴竞品链接,可多个链接用换行分隔", + type: "textarea", + className: "xl:col-span-2", + }, + { + key: "targetPriceBand", + label: "目标价格带", + placeholder: "如:399-499", + }, + { + key: "size", + label: "尺寸", + placeholder: "如:140/60/78", + }, + { + key: "material", + label: "材质", + placeholder: "如:18ENF级橡胶木", + }, + { + key: "urgent", + label: "是否急开", + type: "select", + options: ["否", "是"], + }, + { + key: "reason", + label: "开品理由", + placeholder: "说明为什么要开这个品,目标机会点是什么。", + type: "textarea", + className: "xl:col-span-6", + }, + ]; + const openingReferenceFiles = activeDocumentWorkspaceFiles; + const renderCompactOpeningReferenceField = (field: { + key: OpeningReferenceFieldKey; + label: string; + type?: BasicFieldType; + options?: string[]; + placeholder?: string; + className?: string; + }) => ( +
+ + {field.type === "textarea" ? ( +