From b603555df7ea1486a6292baa059b041dd3cf25fc Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 15 May 2026 14:39:45 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E4=B8=AD=E5=BF=83=E7=B3=BB=E5=88=97=E4=BC=98=E5=85=88?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 144 ++++++ furniture-3d-system/prisma/schema.prisma | 87 ++-- .../app/__tests__/draft-groups-page.test.tsx | 4 +- .../app/__tests__/home-client-delete.test.tsx | 2 +- .../src/app/__tests__/intake-page.test.tsx | 12 +- .../src/app/__tests__/page-nav.test.tsx | 14 +- .../app/api/asset-center/families/route.ts | 2 + .../app/api/asset-center/import-cad/route.ts | 2 + .../series}/route.ts | 6 +- .../src/app/api/platform/schemas.ts | 13 +- .../AssetCenterClient.preview-gizmo.test.tsx | 24 +- .../asset-center/AssetCenterClient.test.tsx | 43 +- .../app/asset-center/AssetCenterClient.tsx | 418 ++++++++++++++++-- .../AssetCenterClient.workflow.test.tsx | 82 ++-- .../MaterialLibraryClient.tsx | 230 ++++++---- .../PackagingMaterialsClient.test.tsx | 2 +- ...PackagingMaterialsClient.workflow.test.tsx | 2 +- .../src/app/asset-center/page.tsx | 6 +- .../AssistedAssemblyClient.test.tsx | 2 +- .../AssistedAssemblyClient.tsx | 3 + .../src/app/assisted-assembly/page.tsx | 3 +- .../app/home/components/HomeDraftsList.tsx | 2 +- .../src/app/home/home-client.types.ts | 2 +- .../src/app/intake/IntakeClient.test.tsx | 10 +- .../src/app/intake/IntakeClient.tsx | 8 +- .../integratedDraftGroupAdapter.test.ts | 6 +- .../integrated/integratedDraftGroupAdapter.ts | 2 +- .../integrated/integratedSeriesLoader.test.ts | 6 +- furniture-3d-system/src/app/intake/page.tsx | 2 +- .../lib/open-product/assembly-workspace.ts | 6 + .../open-product/manual-assembly-launch.ts | 7 + .../src/lib/open-product/platform-api.ts | 2 + .../src/lib/open-product/types.ts | 1 - .../client-helpers.test.ts | 2 +- .../repositories/asset-center.repository.ts | 96 +++- .../repositories/draft-group.repository.ts | 8 +- .../src/server/repositories/index.ts | 5 +- .../series-template.repository.ts | 53 --- .../asset-cad-import.service.test.ts | 5 +- .../__tests__/asset-center.service.test.ts | 2 - .../__tests__/asset-review.service.test.ts | 2 - .../__tests__/combination.service.test.ts | 258 ----------- .../draft-group.manual-assembly.test.ts | 4 +- .../__tests__/draft-group.service.test.ts | 57 +-- .../open-platform-demo-seed.service.test.ts | 147 +----- .../services/asset-cad-import.service.ts | 2 + .../server/services/asset-center.service.ts | 105 ++++- .../services/asset-center.service.types.ts | 23 +- .../server/services/combination.service.ts | 216 --------- .../services/combination.service.types.ts | 44 -- .../src/server/services/draft-group.mapper.ts | 8 +- .../server/services/draft-group.service.ts | 3 + .../services/draft-group.service.types.ts | 7 +- .../src/server/services/index.ts | 13 +- .../open-platform-demo-seed.service.ts | 116 +++-- .../services/series-template.service.ts | 108 ----- .../single-board-desk-materialization.ts | 39 -- 57 files changed, 1221 insertions(+), 1257 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20260515143000_asset_series_first/migration.sql rename furniture-3d-system/src/app/api/{series-templates => asset-center/series}/route.ts (73%) delete mode 100644 furniture-3d-system/src/server/repositories/series-template.repository.ts delete mode 100644 furniture-3d-system/src/server/services/__tests__/combination.service.test.ts delete mode 100644 furniture-3d-system/src/server/services/combination.service.ts delete mode 100644 furniture-3d-system/src/server/services/combination.service.types.ts delete mode 100644 furniture-3d-system/src/server/services/series-template.service.ts diff --git a/furniture-3d-system/prisma/migrations/20260515143000_asset_series_first/migration.sql b/furniture-3d-system/prisma/migrations/20260515143000_asset_series_first/migration.sql new file mode 100644 index 00000000..de1d55fd --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260515143000_asset_series_first/migration.sql @@ -0,0 +1,144 @@ +-- Asset Center: replace legacy SeriesTemplate with AssetSeries compatibility scope. + +CREATE TABLE "AssetSeries" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "productCategoryId" TEXT, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "metadata" JSONB, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AssetSeries_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AssetSeries_code_key" ON "AssetSeries"("code"); +CREATE INDEX "AssetSeries_productCategoryId_idx" ON "AssetSeries"("productCategoryId"); +CREATE INDEX "AssetSeries_createdBy_idx" ON "AssetSeries"("createdBy"); +CREATE INDEX "AssetSeries_sortOrder_idx" ON "AssetSeries"("sortOrder"); +CREATE INDEX "AssetSeries_isActive_idx" ON "AssetSeries"("isActive"); + +ALTER TABLE "AssetSeries" + ADD CONSTRAINT "AssetSeries_productCategoryId_fkey" + FOREIGN KEY ("productCategoryId") REFERENCES "ProductCategory"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "AssetSeries" + ADD CONSTRAINT "AssetSeries_createdBy_fkey" + FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +INSERT INTO "AssetSeries" ( + "id", + "code", + "name", + "description", + "productCategoryId", + "sortOrder", + "isActive", + "metadata", + "createdBy", + "createdAt", + "updatedAt" +) +SELECT + "id", + "code", + "name", + "description", + "categoryId", + ROW_NUMBER() OVER (ORDER BY "createdAt", "code") - 1, + "isActive", + jsonb_build_object('migratedFrom', 'SeriesTemplate', 'legacySeriesName', "seriesName"), + "createdBy", + "createdAt", + "updatedAt" +FROM "SeriesTemplate" +ON CONFLICT ("code") DO NOTHING; + +INSERT INTO "AssetSeries" ( + "id", + "code", + "name", + "sortOrder", + "isActive", + "metadata" +) +SELECT + 'asset-series-' || md5(series_code), + series_code, + series_code, + 1000 + ROW_NUMBER() OVER (ORDER BY series_code), + true, + jsonb_build_object('migratedFrom', 'AssetFamily.applicableSeriesCodes') +FROM ( + SELECT DISTINCT unnest("applicableSeriesCodes") AS series_code + FROM "AssetFamily" + WHERE array_length("applicableSeriesCodes", 1) > 0 +) source +WHERE series_code IS NOT NULL + AND btrim(series_code) <> '' +ON CONFLICT ("code") DO NOTHING; + +CREATE TABLE "AssetFamilySeriesCompatibility" ( + "id" TEXT NOT NULL, + "assetFamilyId" TEXT NOT NULL, + "assetSeriesId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AssetFamilySeriesCompatibility_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AssetFamilySeriesCompatibility_assetFamilyId_assetSeriesId_key" + ON "AssetFamilySeriesCompatibility"("assetFamilyId", "assetSeriesId"); +CREATE INDEX "AssetFamilySeriesCompatibility_assetFamilyId_idx" + ON "AssetFamilySeriesCompatibility"("assetFamilyId"); +CREATE INDEX "AssetFamilySeriesCompatibility_assetSeriesId_idx" + ON "AssetFamilySeriesCompatibility"("assetSeriesId"); + +ALTER TABLE "AssetFamilySeriesCompatibility" + ADD CONSTRAINT "AssetFamilySeriesCompatibility_assetFamilyId_fkey" + FOREIGN KEY ("assetFamilyId") REFERENCES "AssetFamily"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "AssetFamilySeriesCompatibility" + ADD CONSTRAINT "AssetFamilySeriesCompatibility_assetSeriesId_fkey" + FOREIGN KEY ("assetSeriesId") REFERENCES "AssetSeries"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +INSERT INTO "AssetFamilySeriesCompatibility" ( + "id", + "assetFamilyId", + "assetSeriesId", + "createdAt" +) +SELECT + 'afsc-' || md5(f."id" || ':' || s."id"), + f."id", + s."id", + COALESCE(f."createdAt", CURRENT_TIMESTAMP) +FROM "AssetFamily" f +CROSS JOIN LATERAL unnest(f."applicableSeriesCodes") AS series_code +JOIN "AssetSeries" s ON s."code" = series_code +WHERE btrim(series_code) <> '' +ON CONFLICT ("assetFamilyId", "assetSeriesId") DO NOTHING; + +ALTER TABLE "DraftGroup" ADD COLUMN "assetSeriesId" TEXT; +UPDATE "DraftGroup" +SET "assetSeriesId" = "seriesTemplateId" +WHERE "seriesTemplateId" IS NOT NULL + AND EXISTS ( + SELECT 1 FROM "AssetSeries" s WHERE s."id" = "DraftGroup"."seriesTemplateId" + ); + +DROP INDEX IF EXISTS "DraftGroup_seriesTemplateId_idx"; +ALTER TABLE "DraftGroup" DROP CONSTRAINT IF EXISTS "DraftGroup_seriesTemplateId_fkey"; +ALTER TABLE "DraftGroup" DROP COLUMN IF EXISTS "seriesTemplateId"; +CREATE INDEX "DraftGroup_assetSeriesId_idx" ON "DraftGroup"("assetSeriesId"); +ALTER TABLE "DraftGroup" + ADD CONSTRAINT "DraftGroup_assetSeriesId_fkey" + FOREIGN KEY ("assetSeriesId") REFERENCES "AssetSeries"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "AssetFamily" DROP COLUMN IF EXISTS "applicableSeriesCodes"; + +DROP TABLE IF EXISTS "SeriesTemplate" CASCADE; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 63515117..f4cc1d6e 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -27,7 +27,7 @@ model ProductCategory { planningTemplates PlanningTemplate[] @relation("PlanningTemplateCategory") planningWorkspaces PlanningWorkspace[] @relation("PlanningWorkspaceCategory") assetFamilies AssetFamily[] @relation("AssetFamilyCategory") - seriesTemplates SeriesTemplate[] @relation("SeriesTemplateCategory") + assetSeries AssetSeries[] @relation("AssetSeriesCategory") draftGroups DraftGroup[] @relation("DraftGroupCategory") createdAt DateTime @default(now()) @@ -599,7 +599,6 @@ model AssetFamily { description String? @db.Text productCategoryId String? productCategory ProductCategory? @relation("AssetFamilyCategory", fields: [productCategoryId], references: [id]) - applicableSeriesCodes String[] @default([]) connectionRoles String[] @default([]) metadata Json? status AssetStatus @default(DRAFT) @@ -623,6 +622,7 @@ model AssetFamily { deleter User? @relation("AssetFamilyDeleter", fields: [deletedById], references: [id], onDelete: SetNull) variants AssetVariant[] + compatibleSeries AssetFamilySeriesCompatibility[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -639,6 +639,48 @@ model AssetFamily { @@index([deletedById]) } +model AssetSeries { + id String @id @default(cuid()) + code String @unique + name String + description String? @db.Text + + productCategoryId String? + category ProductCategory? @relation("AssetSeriesCategory", fields: [productCategoryId], references: [id]) + + sortOrder Int @default(0) + isActive Boolean @default(true) + metadata Json? + + createdBy String? + creator User? @relation("AssetSeriesCreator", fields: [createdBy], references: [id], onDelete: SetNull) + + compatibleFamilies AssetFamilySeriesCompatibility[] + draftGroups DraftGroup[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([productCategoryId]) + @@index([createdBy]) + @@index([sortOrder]) + @@index([isActive]) +} + +model AssetFamilySeriesCompatibility { + id String @id @default(cuid()) + assetFamilyId String + assetFamily AssetFamily @relation(fields: [assetFamilyId], references: [id], onDelete: Cascade) + assetSeriesId String + assetSeries AssetSeries @relation(fields: [assetSeriesId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([assetFamilyId, assetSeriesId]) + @@index([assetFamilyId]) + @@index([assetSeriesId]) +} + model AssetVariant { id String @id @default(cuid()) assetFamilyId String @@ -707,39 +749,6 @@ model AssetVariant { @@index([deletedById]) } -model SeriesTemplate { - id String @id @default(cuid()) - code String @unique - name String - seriesName String - description String? @db.Text - - categoryId String? - category ProductCategory? @relation("SeriesTemplateCategory", fields: [categoryId], references: [id]) - - slotSchema Json - namingRule Json? - recommendationRule Json? - previewRule Json? - intakeRule Json? - - isPreset Boolean @default(false) - isActive Boolean @default(true) - - createdBy String? - creator User? @relation("SeriesTemplateCreator", fields: [createdBy], references: [id], onDelete: SetNull) - - draftGroups DraftGroup[] - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([categoryId]) - @@index([createdBy]) - @@index([isPreset]) - @@index([isActive]) -} - model DraftGroup { id String @id @default(cuid()) code String @unique @@ -749,8 +758,8 @@ model DraftGroup { categoryId String? category ProductCategory? @relation("DraftGroupCategory", fields: [categoryId], references: [id]) - seriesTemplateId String? - seriesTemplate SeriesTemplate? @relation(fields: [seriesTemplateId], references: [id], onDelete: SetNull) + assetSeriesId String? + assetSeries AssetSeries? @relation(fields: [assetSeriesId], references: [id], onDelete: SetNull) status DraftGroupStatus @default(DRAFT) summary Json @@ -765,7 +774,7 @@ model DraftGroup { updatedAt DateTime @updatedAt @@index([categoryId]) - @@index([seriesTemplateId]) + @@index([assetSeriesId]) @@index([createdBy]) @@index([status]) } @@ -1060,7 +1069,7 @@ model User { rejectedAssetVariants AssetVariant[] @relation("AssetVariantRejecter") modifiedAssetVariants AssetVariant[] @relation("AssetVariantLastModifier") deletedAssetVariants AssetVariant[] @relation("AssetVariantDeleter") - createdSeriesTemplates SeriesTemplate[] @relation("SeriesTemplateCreator") + createdAssetSeries AssetSeries[] @relation("AssetSeriesCreator") createdDraftGroups DraftGroup[] @relation("DraftGroupCreator") collaboratedProducts ProductCollaborator[] @relation("ProductCollaboratorUser") addedProductCollaborators ProductCollaborator[] @relation("ProductCollaboratorAddedBy") diff --git a/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx b/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx index 359a8a85..52ffc5ed 100644 --- a/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx +++ b/furniture-3d-system/src/app/__tests__/draft-groups-page.test.tsx @@ -52,7 +52,7 @@ jest.mock('@/server/services', () => ({ name: '进行中的草稿', description: null, categoryName: '单板桌', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', status: 'DRAFT', summary: { selectedCount: 2 }, creatorName: '测试用户', @@ -64,7 +64,7 @@ jest.mock('@/server/services', () => ({ name: '已归档的草稿', description: null, categoryName: '单板桌', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', status: 'ARCHIVED', summary: null, creatorName: '测试用户', diff --git a/furniture-3d-system/src/app/__tests__/home-client-delete.test.tsx b/furniture-3d-system/src/app/__tests__/home-client-delete.test.tsx index a479225c..c8605b8e 100644 --- a/furniture-3d-system/src/app/__tests__/home-client-delete.test.tsx +++ b/furniture-3d-system/src/app/__tests__/home-client-delete.test.tsx @@ -167,7 +167,7 @@ const mockDraftGroup: DraftGroupSummary = { name: '我的草稿', description: '桌类方案', categoryName: '单板桌', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', status: 'DRAFT', summary: { selectedCount: 2 }, creatorName: '测试用户', diff --git a/furniture-3d-system/src/app/__tests__/intake-page.test.tsx b/furniture-3d-system/src/app/__tests__/intake-page.test.tsx index 9ae74b8d..2fcd72a3 100644 --- a/furniture-3d-system/src/app/__tests__/intake-page.test.tsx +++ b/furniture-3d-system/src/app/__tests__/intake-page.test.tsx @@ -44,35 +44,35 @@ jest.mock('@/server/services', () => ({ code: 'DG-001', name: '包装中草稿', status: 'PACKAGING', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', }, { id: 'draft-2', code: 'DG-002', name: '待入库草稿', status: 'READY_FOR_INTAKE', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', }, { id: 'draft-3', code: 'DG-002', name: '未包装草稿', status: 'DRAFT', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', }, { id: 'draft-4', code: 'DG-003', name: '已入库草稿', status: 'FORMALIZED', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', }, { id: 'draft-5', code: 'DG-004', name: '已归档草稿', status: 'ARCHIVED', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', }, ]), getById: jest.fn().mockResolvedValue({ @@ -81,7 +81,7 @@ jest.mock('@/server/services', () => ({ code: 'DG-001', name: '包装中草稿', status: 'PACKAGING', - seriesTemplateName: '单板桌', + assetSeriesName: '单板桌', }, preSkus: [], }), diff --git a/furniture-3d-system/src/app/__tests__/page-nav.test.tsx b/furniture-3d-system/src/app/__tests__/page-nav.test.tsx index 0dd055c4..e1230448 100644 --- a/furniture-3d-system/src/app/__tests__/page-nav.test.tsx +++ b/furniture-3d-system/src/app/__tests__/page-nav.test.tsx @@ -70,9 +70,9 @@ jest.mock('@/server/services/draft-group.service', () => ({ description: null, categoryId: null, categoryName: '单板桌', - seriesTemplateId: 'template-1', - seriesTemplateCode: 'single-board-desk', - seriesTemplateName: '单板桌', + assetSeriesId: 'template-1', + assetSeriesCode: 'single-board-desk', + assetSeriesName: '单板桌', status: 'DRAFT', summary: { selectedCount: 2 }, notes: null, @@ -88,9 +88,9 @@ jest.mock('@/server/services/draft-group.service', () => ({ description: null, categoryId: null, categoryName: '单板桌', - seriesTemplateId: 'template-1', - seriesTemplateCode: 'single-board-desk', - seriesTemplateName: '单板桌', + assetSeriesId: 'template-1', + assetSeriesCode: 'single-board-desk', + assetSeriesName: '单板桌', status: 'DRAFT', summary: null, notes: null, @@ -112,7 +112,7 @@ describe('首页工作台数据注入', () => { }) it('应只把当前账号的草稿和正式产品注入首页工作台', async () => { - render(await HomePage()) + render(await HomePage({ searchParams: Promise.resolve({}) })) expect(screen.getByText('HomeClient')).toBeInTheDocument() expect(mockedGetProducts).toHaveBeenCalledWith( diff --git a/furniture-3d-system/src/app/api/asset-center/families/route.ts b/furniture-3d-system/src/app/api/asset-center/families/route.ts index c902038b..8d1c7421 100644 --- a/furniture-3d-system/src/app/api/asset-center/families/route.ts +++ b/furniture-3d-system/src/app/api/asset-center/families/route.ts @@ -11,6 +11,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const type = searchParams.get('type') const status = searchParams.get('status') + const assetSeriesId = searchParams.get('assetSeriesId')?.trim() || undefined try { const families = await assetCenterService.listFamilies({ @@ -22,6 +23,7 @@ export async function GET(request: NextRequest) { status && ['DRAFT', 'PENDING_REVIEW', 'REJECTED', 'AVAILABLE', 'DISABLED'].includes(status) ? (status as 'DRAFT' | 'PENDING_REVIEW' | 'REJECTED' | 'AVAILABLE' | 'DISABLED') : undefined, + assetSeriesId, }) return jsonSuccess({ families }, 200, ctx.requestId) } catch (error) { diff --git a/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts b/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts index 831f57a4..aebe79d6 100644 --- a/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts +++ b/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts @@ -57,6 +57,7 @@ export async function POST(request: NextRequest) { familyCode?: string | null familyName?: string | null familyDescription?: string | null + assetSeriesId?: string | null connectionRoles?: string[] variantCode?: string | null variantName?: string | null @@ -75,6 +76,7 @@ export async function POST(request: NextRequest) { familyCode: formData.get('familyCode'), familyName: formData.get('familyName'), familyDescription: formData.get('familyDescription'), + assetSeriesId: formData.get('assetSeriesId'), connectionRoles, variantCode: formData.get('variantCode'), variantName: formData.get('variantName'), diff --git a/furniture-3d-system/src/app/api/series-templates/route.ts b/furniture-3d-system/src/app/api/asset-center/series/route.ts similarity index 73% rename from furniture-3d-system/src/app/api/series-templates/route.ts rename to furniture-3d-system/src/app/api/asset-center/series/route.ts index 86511c31..4382492f 100644 --- a/furniture-3d-system/src/app/api/series-templates/route.ts +++ b/furniture-3d-system/src/app/api/asset-center/series/route.ts @@ -1,15 +1,15 @@ import { NextRequest } from 'next/server' import { AppError } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' -import { seriesTemplateService } from '@/server/services' +import { assetCenterService } from '@/server/services' export async function GET(request: NextRequest) { return handleRequest( request, async (ctx) => { try { - const templates = await seriesTemplateService.listTemplates() - return jsonSuccess({ templates }, 200, ctx.requestId) + const series = await assetCenterService.listSeries() + return jsonSuccess({ series }, 200, ctx.requestId) } catch (error) { if (error instanceof AppError) { return jsonError(error.code, error.message, error.statusCode, ctx.requestId) diff --git a/furniture-3d-system/src/app/api/platform/schemas.ts b/furniture-3d-system/src/app/api/platform/schemas.ts index d40cf78e..605efb58 100644 --- a/furniture-3d-system/src/app/api/platform/schemas.ts +++ b/furniture-3d-system/src/app/api/platform/schemas.ts @@ -45,7 +45,7 @@ export const assetFamilyCreateSchema = z.object({ name: z.string().trim().min(1), description: z.string().optional().nullable(), productCategoryId: z.string().trim().min(1).optional().nullable(), - applicableSeriesCodes: z.array(z.string().trim().min(1)).optional(), + compatibleSeriesIds: z.array(z.string().trim().min(1)).optional(), connectionRoles: z.array(z.string().trim().min(1)).optional(), metadata: z.record(z.string(), z.unknown()).optional().nullable(), status: z.enum(['DRAFT']).optional(), @@ -56,7 +56,7 @@ export const assetFamilyUpdateSchema = z.object({ name: z.string().trim().min(1).optional(), description: z.string().optional().nullable(), productCategoryId: z.string().trim().min(1).optional().nullable(), - applicableSeriesCodes: z.array(z.string().trim().min(1)).optional(), + compatibleSeriesIds: z.array(z.string().trim().min(1)).optional(), connectionRoles: z.array(z.string().trim().min(1)).optional(), metadata: z.record(z.string(), z.unknown()).optional().nullable(), status: assetStatusSchema.optional(), @@ -189,6 +189,7 @@ export const assetCadImportPayloadSchema = z.object({ familyCode: z.string().trim().min(1).optional().nullable(), familyName: z.string().trim().min(1).optional().nullable(), familyDescription: z.string().optional().nullable(), + assetSeriesId: z.string().trim().min(1).optional().nullable(), connectionRoles: z.array(z.string().trim().min(1)).optional(), variantCode: z.string().trim().min(1).optional().nullable(), variantName: z.string().trim().min(1).optional().nullable(), @@ -232,17 +233,11 @@ export const assetCadImportPayloadSchema = z.object({ } }) -export const generateCombinationSchema = z.object({ - seriesTemplateId: z.string().trim().min(1), - desktopFamilyIds: z.array(z.string().trim().min(1)).min(1), - legFamilyIds: z.array(z.string().trim().min(1)).optional(), - connectorFamilyIds: z.array(z.string().trim().min(1)).optional(), -}) - export const createDraftGroupSchema = z.object({ name: z.string().trim().min(1), description: z.string().optional().nullable(), categoryId: z.string().trim().min(1).optional().nullable(), + assetSeriesId: z.string().trim().min(1).optional().nullable(), notes: z.string().optional().nullable(), assemblies: z.array( z.object({ diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.preview-gizmo.test.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.preview-gizmo.test.tsx index 3970a9af..7b017610 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.preview-gizmo.test.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.preview-gizmo.test.tsx @@ -6,7 +6,7 @@ import type { AssetFamilyDTO, AssetVariantDTO, MaterialDTO, - SeriesTemplateDTO, + AssetSeriesDTO, SubstrateDTO, } from '@/server/services' @@ -265,7 +265,7 @@ function createFamily(overrides?: Partial): AssetFamilyDTO { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: [], metadata: null, status: 'DRAFT', @@ -333,14 +333,18 @@ const user = { department: null, } -const seriesTemplates = [ +const assetSeries = [ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, -] as SeriesTemplateDTO[] +] as AssetSeriesDTO[] describe('AssetCenterClient 预览操作区', () => { it('加载模型节点后不会默认选中第一个节点', async () => { @@ -351,7 +355,7 @@ describe('AssetCenterClient 预览操作区', () => { initialFamilies={[createFamily()]} initialVariants={[createVariant()]} initialSubstrates={[] as SubstrateDTO[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[] as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -413,7 +417,7 @@ describe('AssetCenterClient 预览操作区', () => { }), ]} initialSubstrates={[] as SubstrateDTO[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[] as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -498,7 +502,7 @@ describe('AssetCenterClient 预览操作区', () => { updatedAt: new Date('2026-04-09T10:00:00.000Z'), }, ] as SubstrateDTO[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[] as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -523,7 +527,7 @@ describe('AssetCenterClient 预览操作区', () => { initialFamilies={[createFamily()]} initialVariants={[createVariant()]} initialSubstrates={[] as SubstrateDTO[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[] as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -567,7 +571,7 @@ describe('AssetCenterClient 预览操作区', () => { initialFamilies={[createFamily()]} initialVariants={[createVariant()]} initialSubstrates={[] as SubstrateDTO[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[] as MaterialDTO[]} materialCategories={[]} metrics={[]} diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx index 3d5a3b30..cb54a4f6 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx @@ -6,7 +6,7 @@ import type { AssetFamilyDTO, AssetVariantDTO, MaterialDTO, - SeriesTemplateDTO, + AssetSeriesDTO, SubstrateDTO, } from '@/server/services' import { @@ -237,7 +237,7 @@ function createFamily(overrides?: Partial): AssetFamilyDTO { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: [], metadata: null, status: 'DRAFT', @@ -354,14 +354,18 @@ const user = { department: null, } -const seriesTemplates = [ +const assetSeries = [ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, -] as SeriesTemplateDTO[] +] as AssetSeriesDTO[] describe('AssetCenterClient', () => { beforeEach(() => { @@ -379,7 +383,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-001', name: '桌面板资产族', - applicableSeriesCodes: ['single-board-desk'], }), ]} initialVariants={[ @@ -433,7 +436,7 @@ describe('AssetCenterClient', () => { }, ]} initialSubstrates={[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[]} materialCategories={[]} metrics={[]} @@ -456,7 +459,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-010', name: '可用桌面板资产族', - applicableSeriesCodes: ['single-board-desk'], status: 'AVAILABLE', }), ]} @@ -511,7 +513,7 @@ describe('AssetCenterClient', () => { }, ]} initialSubstrates={[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[]} materialCategories={[]} metrics={[]} @@ -549,7 +551,7 @@ describe('AssetCenterClient', () => { ]} initialVariants={[] as AssetVariantDTO[]} initialSubstrates={[] as SubstrateDTO[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[] as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -575,7 +577,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-002', name: '待审核桌面板', - applicableSeriesCodes: ['single-board-desk'], status: 'PENDING_REVIEW', }), createFamily({ @@ -583,12 +584,11 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-003', name: '草稿桌面板', - applicableSeriesCodes: ['single-board-desk'], }), ]} initialVariants={[] as AssetVariantDTO[]} initialSubstrates={[] as SubstrateDTO[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[] as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -662,7 +662,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-020', name: '缩略图桌面板', - applicableSeriesCodes: ['single-board-desk'], status: 'AVAILABLE', }), ]} @@ -717,7 +716,7 @@ describe('AssetCenterClient', () => { }, ]} initialSubstrates={[]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[]} materialCategories={[]} metrics={[]} @@ -780,7 +779,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-030', name: 'UV 桌面板', - applicableSeriesCodes: ['single-board-desk'], status: 'AVAILABLE', }), ]} @@ -888,7 +886,7 @@ describe('AssetCenterClient', () => { ], }), ]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[ { id: 'finish-1', @@ -1114,7 +1112,7 @@ describe('AssetCenterClient', () => { ], }), ]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[ { id: 'finish-1', @@ -1206,7 +1204,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-DRAFT-UV', name: 'UV 草稿桌面板', - applicableSeriesCodes: ['single-board-desk'], status: 'AVAILABLE', }), ]} @@ -1234,7 +1231,7 @@ describe('AssetCenterClient', () => { name: '25mm 颗粒板', }), ]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[ { id: 'finish-1', @@ -1305,7 +1302,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-040', name: '右键桌面板', - applicableSeriesCodes: ['single-board-desk'], status: 'AVAILABLE', }), ]} @@ -1371,7 +1367,7 @@ describe('AssetCenterClient', () => { name: '25mm 颗粒板', }), ]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[]} materialCategories={[]} metrics={[]} @@ -1403,7 +1399,6 @@ describe('AssetCenterClient', () => { type: 'BOARD', code: 'BOARD-SUBSTRATE', name: '基材搜索桌面板', - applicableSeriesCodes: ['single-board-desk'], }), ]} initialVariants={[ @@ -1428,7 +1423,7 @@ describe('AssetCenterClient', () => { name: '胡桃木基材', }), ]} - initialSeriesTemplates={seriesTemplates} + initialAssetSeries={assetSeries} finishes={[]} materialCategories={[]} metrics={[]} diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx index 99f0fe33..be4af06a 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx @@ -89,9 +89,9 @@ import type { AssetCadImportResult, AssetFamilyDTO, AssetOperationProgressDTO, + AssetSeriesDTO, SimilarAssetVariantCandidateDTO, AssetVariantDTO, - SeriesTemplateDTO, SubstrateDTO, SubstrateDictionaryBundleDTO, } from '@/server/services' @@ -103,7 +103,7 @@ interface AssetCenterClientProps { initialVariants: AssetVariantDTO[] initialSubstrates: SubstrateDTO[] initialSubstrateDictionaries?: SubstrateDictionaryBundleDTO - initialSeriesTemplates: SeriesTemplateDTO[] + initialAssetSeries: AssetSeriesDTO[] finishes: MaterialDTO[] materialCategories: MaterialCategoryDTO[] metrics: PlatformMetricItem[] @@ -708,7 +708,10 @@ function buildAssetVariantHoleOverlayState(variant: AssetVariantDTO | null): { } } -// ---- 分类 Tab 定义 ---- +// ---- 系列与分类筛选 ---- + +const ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY = 'asset-center:selected-series-id' +type AssetTypeFilter = AssetFamilyDTO['type'] function isPackagingMaterialLibraryFamily(family: AssetFamilyDTO): boolean { return family.type === 'PACKAGING' && getLevel1FromFamily(family.metadata ?? null) !== null @@ -716,7 +719,7 @@ function isPackagingMaterialLibraryFamily(family: AssetFamilyDTO): boolean { function getVisibleFamiliesForTab( families: AssetFamilyDTO[], - activeTab: AssetFamilyDTO['type'], + activeTab: AssetTypeFilter, ): AssetFamilyDTO[] { return families.filter((family) => { if (family.type !== activeTab) return false @@ -725,7 +728,19 @@ function getVisibleFamiliesForTab( }) } -const TYPE_TABS: { type: AssetFamilyDTO['type']; label: string; icon: React.ReactNode }[] = [ +function familyBelongsToSeries(family: AssetFamilyDTO, series: AssetSeriesDTO | null): boolean { + if (!series) return false + const compatibleSeries = family.compatibleSeries ?? [] + return compatibleSeries.some((item) => item.id === series.id || item.code === series.code) +} + +function familyVisibleInSeriesContext(family: AssetFamilyDTO, series: AssetSeriesDTO | null): boolean { + if (isPackagingMaterialLibraryFamily(family)) return false + if (family.type === 'HARDWARE') return true + return familyBelongsToSeries(family, series) +} + +const TYPE_TABS: { type: AssetTypeFilter; label: string; icon: React.ReactNode }[] = [ { type: 'BOARD', label: '板材', icon: }, { type: 'COMPONENT', label: '组件', icon: }, { type: 'HARDWARE', label: '五金', icon: }, @@ -942,7 +957,7 @@ export default function AssetCenterClient({ initialVariants, initialSubstrates, initialSubstrateDictionaries: _initialSubstrateDictionaries, - initialSeriesTemplates, + initialAssetSeries, finishes: initialFinishes, materialCategories: _initialMaterialCategories, metrics, @@ -959,8 +974,14 @@ export default function AssetCenterClient({ const [selectedVariantId, setSelectedVariantId] = useState(null) const [submitting, setSubmitting] = useState(false) - // Tab 状态 - const [activeTab, setActiveTab] = useState('BOARD') + // 系列与分类筛选状态 + const [selectedSeriesId, setSelectedSeriesId] = useState( + initialAssetSeries[0]?.id ?? null, + ) + const [seriesPickerOpen, setSeriesPickerOpen] = useState(false) + const [seriesSearchText, setSeriesSearchText] = useState('') + const seriesPickerRef = useRef(null) + const [activeTab, setActiveTab] = useState('BOARD') const [assemblySelectedFamilyIds, setAssemblySelectedFamilyIds] = useState>(new Set()) // 搜索 @@ -978,7 +999,7 @@ export default function AssetCenterClient({ code: '', name: '', description: '', - applicableSeriesCode: initialSeriesTemplates[0]?.code ?? '', + compatibleSeriesIds: initialAssetSeries[0]?.id ? [initialAssetSeries[0].id] : [], selectedRoles: [] as string[], }) const [familyRoleDefinitions, setFamilyRoleDefinitions] = useState([]) @@ -1220,6 +1241,62 @@ export default function AssetCenterClient({ setVariantUvEditorForm(nextForm) }, [activeVariantUvNodeName]) + useEffect(() => { + const availableIds = new Set(initialAssetSeries.map((series) => series.id)) + const savedSeriesId = window.localStorage.getItem(ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY) + if (savedSeriesId && availableIds.has(savedSeriesId)) { + setSelectedSeriesId(savedSeriesId) + return + } + + setSelectedSeriesId((current) => + current && availableIds.has(current) ? current : initialAssetSeries[0]?.id ?? null, + ) + }, [initialAssetSeries]) + + useEffect(() => { + if (selectedSeriesId) { + window.localStorage.setItem(ASSET_CENTER_SELECTED_SERIES_STORAGE_KEY, selectedSeriesId) + } + }, [selectedSeriesId]) + + useEffect(() => { + if (!seriesPickerOpen) return + + function handlePointerDown(event: PointerEvent) { + const target = event.target + if ( + target instanceof Node && + seriesPickerRef.current && + !seriesPickerRef.current.contains(target) + ) { + setSeriesPickerOpen(false) + } + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + setSeriesPickerOpen(false) + } + } + + document.addEventListener('pointerdown', handlePointerDown) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('pointerdown', handlePointerDown) + document.removeEventListener('keydown', handleKeyDown) + } + }, [seriesPickerOpen]) + + const handleSelectSeries = useCallback((seriesId: string) => { + setSelectedSeriesId(seriesId) + setSeriesPickerOpen(false) + setSeriesSearchText('') + setActiveTab('BOARD') + setSearchText('') + setReviewFilter('ALL') + }, []) + const variantsByFamilyId = useMemo(() => { const map = new Map() variants.forEach((variant) => { @@ -1230,8 +1307,56 @@ export default function AssetCenterClient({ return map }, [variants]) + const selectedSeries = useMemo( + () => initialAssetSeries.find((series) => series.id === selectedSeriesId) ?? null, + [initialAssetSeries, selectedSeriesId], + ) + + const seriesAssetCounts = useMemo(() => { + const map = new Map() + initialAssetSeries.forEach((series) => { + const count = families.filter( + (family) => + familyBelongsToSeries(family, series) && + family.type !== 'HARDWARE' && + !isPackagingMaterialLibraryFamily(family), + ).length + map.set(series.id, count) + }) + return map + }, [families, initialAssetSeries]) + + const filteredAssetSeries = useMemo(() => { + const keyword = seriesSearchText.trim().toLowerCase() + if (!keyword) return initialAssetSeries + return initialAssetSeries.filter((series) => { + return ( + series.name.toLowerCase().includes(keyword) || + series.code.toLowerCase().includes(keyword) || + (series.description ?? '').toLowerCase().includes(keyword) + ) + }) + }, [initialAssetSeries, seriesSearchText]) + + const selectedSeriesAssetCount = selectedSeries + ? seriesAssetCounts.get(selectedSeries.id) ?? 0 + : 0 + + const currentSeriesFamilies = useMemo(() => { + if (!selectedSeries) return [] + return families.filter((family) => familyVisibleInSeriesContext(family, selectedSeries)) + }, [families, selectedSeries]) + + useEffect(() => { + const visibleFamilyIds = new Set(currentSeriesFamilies.map((family) => family.id)) + setAssemblySelectedFamilyIds((current) => { + const next = new Set(Array.from(current).filter((id) => visibleFamilyIds.has(id))) + return next.size === current.size ? current : next + }) + }, [currentSeriesFamilies]) + const searchedFamilies = useMemo(() => { - const filtered = getVisibleFamiliesForTab(families, activeTab) + const filtered = getVisibleFamiliesForTab(currentSeriesFamilies, activeTab) if (!searchText.trim()) return filtered const keyword = searchText.trim().toLowerCase() return filtered.filter( @@ -1239,7 +1364,7 @@ export default function AssetCenterClient({ family.name.toLowerCase().includes(keyword) || family.code.toLowerCase().includes(keyword), ) - }, [families, activeTab, searchText]) + }, [currentSeriesFamilies, activeTab, searchText]) const currentFamilies = useMemo( () => @@ -1289,6 +1414,7 @@ export default function AssetCenterClient({ return buildManualAssemblyLaunchHref({ entryMode: 'asset-center', source: 'asset-center', + assetSeriesId: selectedSeriesId, selectedFamilyIdsByType: { desktopFamilyIds: selectedFamiliesForAssembly .filter((family) => family.type === 'BOARD') @@ -1301,7 +1427,7 @@ export default function AssetCenterClient({ .map((family) => family.id), }, }) - }, [selectedFamiliesForAssembly]) + }, [selectedFamiliesForAssembly, selectedSeriesId]) const selectedCadImportFamily = useMemo( () => families.find((family) => family.id === cadImportForm.familyId) ?? null, @@ -1558,27 +1684,35 @@ export default function AssetCenterClient({ // ---- 资产族操作 ---- function openCreateFamilyModal() { - const defaultSeriesCode = initialSeriesTemplates[0]?.code ?? '' + if (!selectedSeries) { + toast.warning('请先选择系列') + return + } + const defaultSeriesIds = selectedSeriesId ? [selectedSeriesId] : [] + const defaultSeriesCode = selectedSeries?.code ?? null + const defaultAssetType = activeTab setFamilyModalMode('create') setFamilyForm({ - type: activeTab, + type: defaultAssetType, code: '', name: '', description: '', - applicableSeriesCode: defaultSeriesCode, + compatibleSeriesIds: defaultAssetType === 'HARDWARE' ? [] : defaultSeriesIds, selectedRoles: [], }) setFamilyRoleDefinitions( mergeConnectionRoleDefinitions({ - assetType: activeTab, - seriesCode: defaultSeriesCode, + assetType: defaultAssetType, + seriesCode: defaultAssetType === 'HARDWARE' ? null : defaultSeriesCode, }), ) setFamilyModalOpen(true) } function openEditFamilyModal(family: AssetFamilyDTO) { - const applicableSeriesCode = family.applicableSeriesCodes[0] ?? '' + const compatibleSeries = family.compatibleSeries ?? [] + const compatibleSeriesIds = compatibleSeries.map((series) => series.id) + const primarySeriesCode = compatibleSeries[0]?.code ?? null const normalizedRoles = normalizeConnectionRoles(family.connectionRoles) setFamilyModalMode('edit') setEditingFamilyId(family.id) @@ -1587,13 +1721,13 @@ export default function AssetCenterClient({ code: family.code, name: family.name, description: family.description ?? '', - applicableSeriesCode, + compatibleSeriesIds, selectedRoles: normalizedRoles, }) setFamilyRoleDefinitions( mergeConnectionRoleDefinitions({ assetType: family.type, - seriesCode: applicableSeriesCode, + seriesCode: primarySeriesCode, selectedRoles: normalizedRoles, metadata: family.metadata, }), @@ -1641,7 +1775,7 @@ export default function AssetCenterClient({ value, label, assetType: familyForm.type, - seriesCode: familyForm.applicableSeriesCode || null, + seriesCode: initialAssetSeries.find((series) => familyForm.compatibleSeriesIds.includes(series.id))?.code ?? null, builtIn: false, }, ]) @@ -1653,7 +1787,11 @@ export default function AssetCenterClient({ setFamilyRoleDefinitions((current) => current.map((definition) => definition.value === editingRoleValue - ? { ...definition, label, seriesCode: familyForm.applicableSeriesCode || null } + ? { + ...definition, + label, + seriesCode: initialAssetSeries.find((series) => familyForm.compatibleSeriesIds.includes(series.id))?.code ?? null, + } : definition, ), ) @@ -1673,10 +1811,21 @@ export default function AssetCenterClient({ } async function handleSaveFamily() { + if (familyForm.type !== 'HARDWARE' && familyForm.compatibleSeriesIds.length === 0) { + toast.error('请选择至少一个兼容系列') + return + } + const normalizedSelectedRoles = normalizeConnectionRoles(familyForm.selectedRoles) + const submittedCompatibleSeriesIds = + familyForm.type === 'HARDWARE' ? [] : familyForm.compatibleSeriesIds + const primarySeriesCode = + familyForm.type === 'HARDWARE' + ? null + : initialAssetSeries.find((series) => submittedCompatibleSeriesIds.includes(series.id))?.code ?? null const hiddenRecommendedRoleValues = getStandardConnectionRoleDefinitions({ assetType: familyForm.type, - seriesCode: familyForm.applicableSeriesCode || null, + seriesCode: primarySeriesCode, }) .map((definition) => definition.value) .filter((value) => !familyRoleDefinitions.some((definition) => definition.value === value)) @@ -1695,9 +1844,7 @@ export default function AssetCenterClient({ code: familyForm.code, name: familyForm.name, description: familyForm.description || null, - applicableSeriesCodes: familyForm.applicableSeriesCode - ? [familyForm.applicableSeriesCode] - : [], + compatibleSeriesIds: submittedCompatibleSeriesIds, connectionRoles: normalizedSelectedRoles, metadata, status: 'DRAFT', @@ -1713,9 +1860,7 @@ export default function AssetCenterClient({ code: familyForm.code, name: familyForm.name, description: familyForm.description || null, - applicableSeriesCodes: familyForm.applicableSeriesCode - ? [familyForm.applicableSeriesCode] - : [], + compatibleSeriesIds: submittedCompatibleSeriesIds, connectionRoles: normalizedSelectedRoles, metadata, }) @@ -2495,9 +2640,12 @@ export default function AssetCenterClient({ } setFamilyRoleDefinitions((current) => { + const primarySeriesCode = initialAssetSeries.find( + (series) => familyForm.compatibleSeriesIds.includes(series.id), + )?.code ?? null const merged = mergeConnectionRoleDefinitions({ assetType: familyForm.type, - seriesCode: familyForm.applicableSeriesCode || null, + seriesCode: primarySeriesCode, selectedRoles: familyForm.selectedRoles, metadata: familyModalMode === 'edit' ? editingFamily?.metadata : null, }) @@ -2523,11 +2671,12 @@ export default function AssetCenterClient({ }) }, [ editingFamily?.metadata, - familyForm.applicableSeriesCode, + familyForm.compatibleSeriesIds, familyForm.selectedRoles, familyForm.type, familyModalMode, familyModalOpen, + initialAssetSeries, ]) useEffect(() => { @@ -2577,6 +2726,7 @@ export default function AssetCenterClient({ familyName: cadImportForm.familyMode === 'create' ? cadImportForm.familyName : null, familyDescription: cadImportForm.familyMode === 'create' ? cadImportForm.familyDescription : null, + assetSeriesId: selectedSeriesId, connectionRoles: cadImportForm.familyMode === 'create' ? cadImportConnectionRoles @@ -3001,10 +3151,112 @@ export default function AssetCenterClient({ V2.6.4 + {/* 系列入口 */} +
+
+ + 系列 + + + 共 {initialAssetSeries.length} 个 + +
+ + + {seriesPickerOpen && ( +
+
+
+ + setSeriesSearchText(event.target.value)} + placeholder="搜索系列名称或编码" + className="h-8 w-full rounded-[6px] bg-black/[0.025] pl-8 pr-2 text-xs text-[#2d3335] outline-none ring-1 ring-black/[0.04] placeholder:text-[#5a6062]/38 focus:bg-white focus:ring-black/[0.12]" + /> +
+
+
+ {filteredAssetSeries.length > 0 ? ( + filteredAssetSeries.map((series) => { + const active = selectedSeriesId === series.id + const count = seriesAssetCounts.get(series.id) ?? 0 + return ( + + ) + }) + ) : ( +
+ 没有匹配的系列 +
+ )} +
+
+ )} +
+ {/* 分类 Tab */}
{TYPE_TABS.map((tab) => { - const count = getVisibleFamiliesForTab(families, tab.type).length + const count = getVisibleFamiliesForTab(currentSeriesFamilies, tab.type).length const isActive = activeTab === tab.type return ( -

+

先建资产族,再在资产族里面新建或导入带模型的规格款。

@@ -3167,6 +3424,7 @@ export default function AssetCenterClient({
toggleFamilyExpand(family.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -4105,12 +4363,50 @@ export default function AssetCenterClient({ } >
+
+

资产类型

+
+ {TYPE_TABS.map((tab) => { + const type = tab.type as AssetFamilyDTO['type'] + const active = familyForm.type === type + return ( + + ) + })} +
+
setFamilyForm((c) => ({ ...c, code: e.target.value }))} - placeholder={`例如 ${activeTab}-001`} + placeholder={`例如 ${familyForm.type}-001`} />
+ {familyForm.type !== 'HARDWARE' ? ( +
+
+

兼容系列

+ + 已选 {familyForm.compatibleSeriesIds.length} 个 + +
+
+ {initialAssetSeries.map((series) => { + const checked = familyForm.compatibleSeriesIds.includes(series.id) + return ( + + ) + })} +
+
+ ) : null}
diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.workflow.test.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.workflow.test.tsx index ea0090b4..c34e1a21 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.workflow.test.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.workflow.test.tsx @@ -10,7 +10,7 @@ import { updateAssetFamily, } from '@/lib/open-product/platform-api' import { parseZipFile } from '@/lib/zip-parser' -import type { AssetFamilyDTO, AssetVariantDTO, MaterialDTO, SeriesTemplateDTO, SubstrateDTO } from '@/server/services' +import type { AssetFamilyDTO, AssetVariantDTO, MaterialDTO, AssetSeriesDTO, SubstrateDTO } from '@/server/services' jest.mock('next/link', () => ({ __esModule: true, @@ -138,14 +138,18 @@ function renderClient() { initialFamilies={[]} initialVariants={[] as AssetVariantDTO[]} initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as SubstrateDTO[]} - initialSeriesTemplates={[ + initialAssetSeries={[ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, - ] as SeriesTemplateDTO[]} + ] as AssetSeriesDTO[]} finishes={[ { id: 'finish-1', @@ -215,7 +219,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: ['desktop_surface'], metadata: null, status: 'DRAFT', @@ -247,7 +251,6 @@ describe('AssetCenterClient workflow', () => { type: 'BOARD', code: 'BOARD-001', name: '桌面板', - applicableSeriesCodes: ['single-board-desk'], connectionRoles: ['desktop_surface'], })) }) @@ -267,7 +270,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: ['single-board-desk'], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: ['desktop_surface'], metadata: { connectionRoleDefinitions: [ @@ -296,14 +299,18 @@ describe('AssetCenterClient workflow', () => { finishes: [{ id: 'finish-1', name: '白橡木纹', code: 'FIN-1', thumbnailUrl: '' }], }, ] as SubstrateDTO[]} - initialSeriesTemplates={[ + initialAssetSeries={[ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, - ] as SeriesTemplateDTO[]} + ] as AssetSeriesDTO[]} finishes={[ { id: 'finish-1', @@ -370,7 +377,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: ['single-board-desk'], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: [], metadata: null, status: 'DRAFT', @@ -390,14 +397,18 @@ describe('AssetCenterClient workflow', () => { finishes: [{ id: 'finish-1', name: '白橡木纹', code: 'FIN-1', thumbnailUrl: '' }], }, ] as SubstrateDTO[]} - initialSeriesTemplates={[ + initialAssetSeries={[ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, - ] as SeriesTemplateDTO[]} + ] as AssetSeriesDTO[]} finishes={[] as unknown as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -430,7 +441,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: ['single-board-desk'], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: ['cross_support'], metadata: { connectionRoleDefinitions: [ @@ -462,7 +473,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: ['single-board-desk'], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: ['cross_support'], metadata: { connectionRoleDefinitions: [ @@ -492,14 +503,18 @@ describe('AssetCenterClient workflow', () => { finishes: [{ id: 'finish-1', name: '白橡木纹', code: 'FIN-1', thumbnailUrl: '' }], }, ] as SubstrateDTO[]} - initialSeriesTemplates={[ + initialAssetSeries={[ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, - ] as SeriesTemplateDTO[]} + ] as AssetSeriesDTO[]} finishes={[] as unknown as MaterialDTO[]} materialCategories={[]} metrics={[]} @@ -533,7 +548,6 @@ describe('AssetCenterClient workflow', () => { expect(updateAssetFamily).toHaveBeenCalledWith( 'family-board-1', expect.objectContaining({ - applicableSeriesCodes: ['single-board-desk'], connectionRoles: ['cross_support'], metadata: expect.objectContaining({ connectionRoleDefinitions: expect.arrayContaining([ @@ -559,7 +573,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: [], metadata: null, status: 'DRAFT', @@ -625,7 +639,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: ['single-board-desk'], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: ['desktop_surface'], metadata: { connectionRoleDefinitions: [ @@ -654,14 +668,18 @@ describe('AssetCenterClient workflow', () => { finishes: [{ id: 'finish-1', name: '白橡木纹', code: 'FIN-1', thumbnailUrl: '' }], }, ] as SubstrateDTO[]} - initialSeriesTemplates={[ + initialAssetSeries={[ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, - ] as SeriesTemplateDTO[]} + ] as AssetSeriesDTO[]} finishes={[ { id: 'finish-1', @@ -747,7 +765,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: ['single-board-desk'], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: ['desktop_surface'], metadata: null, status: 'AVAILABLE', @@ -817,7 +835,7 @@ describe('AssetCenterClient workflow', () => { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: ['single-board-desk'], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: ['desktop_surface'], metadata: null, status: 'PENDING_REVIEW', @@ -892,14 +910,18 @@ describe('AssetCenterClient workflow', () => { }, ] as AssetVariantDTO[]} initialSubstrates={[{ id: 'substrate-1', code: 'PB18', name: '18mm 颗粒板' }] as SubstrateDTO[]} - initialSeriesTemplates={[ + initialAssetSeries={[ { id: 'template-1', code: 'single-board-desk', name: '单板桌', - seriesName: '云序', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), }, - ] as SeriesTemplateDTO[]} + ] as AssetSeriesDTO[]} finishes={[ { id: 'finish-1', diff --git a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx index e676e00c..5ae0d4ac 100644 --- a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx +++ b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx @@ -21,6 +21,7 @@ import { cn } from '@/lib/utils' import { MaterialCategoryDTO, + MaterialLikeDTO, MaterialDTO, SubstrateDTO, SubstrateDictionaryBundleDTO, @@ -221,6 +222,10 @@ const mlUploadButtonClassName = 'cursor-pointer rounded-md border border-[#101719]/20 bg-white px-2.5 py-1.5 text-[10px] font-apple-black text-[#101719] ring-1 ring-black/[0.04] transition hover:bg-black/[0.045]' const mlTagClassName = 'inline-flex items-center gap-1 rounded-md border border-black/10 bg-black/[0.035] px-2.5 py-1 text-xs font-apple-bold text-[#263033]' +const mlIconActionButtonClassName = + 'inline-flex h-8 w-8 items-center justify-center rounded-md border border-black/[0.06] bg-white text-[#5f6d70] transition hover:border-black/[0.12] hover:bg-black/[0.035] hover:text-[#101719] focus:outline-none focus:ring-2 focus:ring-black/[0.08]' +const mlDangerIconActionButtonClassName = + 'inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent bg-white text-[#a44640] transition hover:border-[#a44640]/20 hover:bg-[#a44640]/8 focus:outline-none focus:ring-2 focus:ring-[#a44640]/15' function formatFinishOptionLabel(finish: { name: string; code: string; categoryName?: string | null }): string { return [finish.name, finish.code, finish.categoryName].filter(Boolean).join(' · ') @@ -230,7 +235,7 @@ function firstUsableMaterialImageUrl(...urls: Array) return urls.find((url) => url && !url.includes('/materials/thumbnails/default.svg')) ?? null } -function MaterialSurfacePreview({ item, className }: { item: MaterialDTO, className?: string }) { +function MaterialSurfacePreview({ item, className }: { item: MaterialDTO | MaterialLikeDTO, className?: string }) { const imageUrl = firstUsableMaterialImageUrl(item.textureUrl, item.thumbnailUrl) return ( @@ -305,6 +310,37 @@ function FinishWorkbenchPreviewCard({ form }: { form: FinishForm }) { ) } +function SubstrateSlabPreview({ substrate }: { substrate: SubstrateDTO }) { + const finish = substrate.finishes[0] ?? null + const surfaceUrl = finish ? firstUsableMaterialImageUrl(finish.textureUrl, finish.thumbnailUrl) : null + + return ( +
+
+
+
+
+

板材基材

+

{substrate.name}

+
+ + {substrate.thicknessSpecName} + +
+
+ ) +} + function FinishParameterSliderField({ id, label, value, min = 0, max = 1, step = 0.01, disabled, onChange }: { @@ -1192,18 +1228,24 @@ export default function MaterialLibraryClient({
{isView && activeSubstrate ? ( <> - - + + + ) : null} {isCreate || isEdit ? ( @@ -1426,10 +1468,10 @@ export default function MaterialLibraryClient({
) : activeSubstrate ? ( -
-
+
+

基础信息

-
+
{[ { label: '编码', value: activeSubstrate.code }, { label: '材质主类', value: activeSubstrate.categoryName }, @@ -1439,7 +1481,7 @@ export default function MaterialLibraryClient({ { label: '板尺寸', value: `${activeSubstrate.sheetLengthMm} × ${activeSubstrate.sheetWidthMm}` }, { label: '采购价', - value: activeSubstrate.suggestedPrice ?? '缺价格', + value: activeSubstrate.suggestedPrice != null ? activeSubstrate.suggestedPrice : '缺价格', missing: activeSubstrate.suggestedPrice == null, }, { @@ -1447,14 +1489,15 @@ export default function MaterialLibraryClient({ value: activeSubstrate.weightKg != null ? `${activeSubstrate.weightKg} kg` : '缺重量', missing: activeSubstrate.weightKg == null, }, + { label: '系统名称', value: activeSubstrate.systemNameOptionName ?? '--' }, ].map(({ label, value, missing }) => ( -
- {label} +
+ {label} @@ -1464,19 +1507,29 @@ export default function MaterialLibraryClient({ ))}
-
-

关联花色

-
+ +
+

绑定花色

+
{activeSubstrate.finishes.length === 0 ? ( - 暂无关联花色 +
+ 暂无关联花色 +
) : ( activeSubstrate.finishes.slice(0, 1).map((finish) => ( - +
{finish.textureUrl || finish.thumbnailUrl ? ( - - ) : null} - {formatFinishOptionLabel(finish)} - + + ) : ( + + )} + + {finish.name} + + {finish.code} · {finish.categoryName ?? '未分类'} + + +
)) )}
@@ -1705,12 +1758,15 @@ export default function MaterialLibraryClient({ className="h-10 w-full rounded-lg border border-black/10 bg-white pl-10 pr-3 text-sm font-apple-medium text-[#263033] outline-none transition placeholder:text-[#7a878a]/70 focus:border-[#101719]/30 focus:ring-4 focus:ring-black/[0.04]" />
- {metrics.slice(0, 2).map((metric) => ( -
-

{metric.label}

-

{metric.value}

-
- ))} +
+ {metrics.slice(0, 2).map((metric, index) => ( + + {index > 0 ? : null} + {metric.label} + {metric.value} + + ))} +
) : ( -
-
- {activeWorkbenchSubstrate ? ( -
+
+ {activeWorkbenchSubstrate ? ( +
+ +

当前基材

-

+

{activeWorkbenchSubstrate.name}

+

+ {[activeWorkbenchSubstrate.categoryName, activeWorkbenchSubstrate.environmentGradeName, activeWorkbenchSubstrate.surfaceOptionName] + .filter(Boolean) + .join(' / ')} +

-
+
{[ ['编码', activeWorkbenchSubstrate.code], ['尺寸', `${activeWorkbenchSubstrate.sheetLengthMm} × ${activeWorkbenchSubstrate.sheetWidthMm}`], ['厚度', activeWorkbenchSubstrate.thicknessSpecName], ['花色', activeWorkbenchSubstrate.finishes[0]?.name ?? '未绑定'], ].map(([label, value]) => ( -
+

{label}

{value}

))}
- ) : ( -
- -

暂无基材数据

-
- )} -
-
-
-
-

基材列表

-

共 {filteredSubstrates.length}

-
-
+ ) : ( +
+ +

暂无基材数据

+
+ )} + +
+
+ 基材 + 类别 + 厚度 + 成本 + 状态 +
+
{filteredSubstrates.map((substrate) => { const missingPrice = substrate.suggestedPrice == null const missingWeight = substrate.weightKg == null - const missingRequiredData = missingPrice || missingWeight const selected = (panel.mode === 'view-substrate' || panel.mode === 'edit-substrate') && panel.id === substrate.id + const finish = substrate.finishes[0] ?? null return ( ) })} {filteredSubstrates.length === 0 ? ( -

当前没有匹配的基材。

+

当前没有匹配的基材。

) : null}
diff --git a/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.test.tsx b/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.test.tsx index a451be9a..9a37eb3a 100644 --- a/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.test.tsx +++ b/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.test.tsx @@ -97,7 +97,7 @@ function createFamily(overrides?: Partial): AssetFamilyDTO { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: [], metadata: { categoryLevel1: 'outer', diff --git a/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.workflow.test.tsx b/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.workflow.test.tsx index 93705694..4c649790 100644 --- a/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.workflow.test.tsx +++ b/furniture-3d-system/src/app/asset-center/packaging-materials/PackagingMaterialsClient.workflow.test.tsx @@ -69,7 +69,7 @@ function createFamily(overrides?: Partial): AssetFamilyDTO { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: [], metadata: { categoryLevel1: 'outer', diff --git a/furniture-3d-system/src/app/asset-center/page.tsx b/furniture-3d-system/src/app/asset-center/page.tsx index d2a05683..a2a8fbc4 100644 --- a/furniture-3d-system/src/app/asset-center/page.tsx +++ b/furniture-3d-system/src/app/asset-center/page.tsx @@ -1,6 +1,6 @@ import { Layers3, Package, Palette, Ruler } from 'lucide-react' import { requireSession } from '@/lib/server-session' -import { assetCenterService, materialService, seriesTemplateService } from '@/server/services' +import { assetCenterService, materialService } from '@/server/services' import AssetCenterClient from './AssetCenterClient' export const dynamic = 'force-dynamic' @@ -19,7 +19,7 @@ export default async function AssetCenterPage() { ]) const finishes = await materialService.findAll({ limit: 200 }) const materialCategories = await materialService.findAllCategories() - const seriesTemplates = await seriesTemplateService.listTemplates() + const assetSeries = await assetCenterService.listSeries() return ( { }), ) }) - expect(jest.mocked(createDraftGroup).mock.calls[0]?.[0]).not.toHaveProperty('seriesTemplateId') + expect(jest.mocked(createDraftGroup).mock.calls[0]?.[0]).toHaveProperty('assetSeriesId', null) }) it('资产中心带料进入时,会优先保留来源资产族的草稿规格款作为默认选择', async () => { diff --git a/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx b/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx index a920571f..0799950d 100644 --- a/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx +++ b/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.tsx @@ -198,6 +198,7 @@ export default function AssistedAssemblyClient({ const firstCategoryFamily = selectedFamilies.find((family) => family.productCategoryId) return syncManualAssemblyWorkspace(snapshot, { + assetSeriesId: launchParams?.assetSeriesId ?? initialWorkspace?.assetSeriesId ?? null, productCategoryId: initialWorkspace?.productCategoryId ?? firstCategoryFamily?.productCategoryId ?? null, productCategoryName: initialWorkspace?.productCategoryName ?? firstCategoryFamily?.productCategoryName ?? null, @@ -215,6 +216,7 @@ export default function AssistedAssemblyClient({ entryMode, families, initialWorkspace, + launchParams?.assetSeriesId, launchParams?.sourceProductId, launchParams?.sourceSkuId, legIds, @@ -263,6 +265,7 @@ export default function AssistedAssemblyClient({ const result = await createDraftGroup({ name: draftGroupName, categoryId: workspace.productCategoryId, + assetSeriesId: workspace.assetSeriesId ?? null, assemblies: [ { key: `${entryMode}:${desktopIds.join(',')}`, diff --git a/furniture-3d-system/src/app/assisted-assembly/page.tsx b/furniture-3d-system/src/app/assisted-assembly/page.tsx index d893653f..c62204da 100644 --- a/furniture-3d-system/src/app/assisted-assembly/page.tsx +++ b/furniture-3d-system/src/app/assisted-assembly/page.tsx @@ -223,6 +223,7 @@ function buildSourceSkuWorkspaceFallback(input: { }) const baseSnapshot = buildManualAssemblyWorkspace({ + assetSeriesId: null, productCategoryId: null, productCategoryName: null, source: getAssemblyWorkspaceSourceFromEntryMode('source-sku'), @@ -237,7 +238,6 @@ function buildSourceSkuWorkspaceFallback(input: { assetType: family.type, connectionRoles: family.connectionRoles, productCategoryId: family.productCategoryId, - applicableSeriesCodes: family.applicableSeriesCodes, })), defaultVariantIdsByFamilyId, ), @@ -462,6 +462,7 @@ async function resolveSourceSkuContext(input: { launchParams: { entryMode: 'source-sku' as const, source: 'assisted-assembly' as const, + assetSeriesId: null, sourceSkuId: sku.id, sourceProductId: sku.productId, redirectedFrom: input.redirectedFrom ?? null, diff --git a/furniture-3d-system/src/app/home/components/HomeDraftsList.tsx b/furniture-3d-system/src/app/home/components/HomeDraftsList.tsx index c256f051..041768e8 100644 --- a/furniture-3d-system/src/app/home/components/HomeDraftsList.tsx +++ b/furniture-3d-system/src/app/home/components/HomeDraftsList.tsx @@ -95,7 +95,7 @@ export default function HomeDraftsList({ {getDraftStatusLabel(draftGroup.status)} - {draftGroup.categoryName || draftGroup.seriesTemplateName} + {draftGroup.categoryName || draftGroup.assetSeriesName}

diff --git a/furniture-3d-system/src/app/home/home-client.types.ts b/furniture-3d-system/src/app/home/home-client.types.ts index fd8b26df..876fcdff 100644 --- a/furniture-3d-system/src/app/home/home-client.types.ts +++ b/furniture-3d-system/src/app/home/home-client.types.ts @@ -44,7 +44,7 @@ export interface DraftGroupSummary { name: string description?: string | null categoryName?: string | null - seriesTemplateName: string + assetSeriesName: string status: string summary?: Record | null creatorName: string diff --git a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx index bd888448..7574f2ce 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx @@ -249,7 +249,7 @@ function renderSelector(props?: { { id: 'draft-group-1', name: 'Draft Group', - seriesTemplateName: 'Single board desk', + assetSeriesName: 'Single board desk', code: 'DG-001', }, ] @@ -332,7 +332,7 @@ function renderEditor() { id: 'draft-group-1', name: 'Draft Group', categoryId: 'category-desk', - seriesTemplateName: 'Single board desk', + assetSeriesName: 'Single board desk', categoryName: 'Desk', code: 'DG-001', }, @@ -1698,13 +1698,13 @@ describe('IntakeClient', () => { { id: 'draft-group-1', name: 'Nordic Draft Group', - seriesTemplateName: 'Nordic Desk', + assetSeriesName: 'Nordic Desk', code: 'DG-001', }, { id: 'draft-group-2', name: 'Cloud Draft Group', - seriesTemplateName: 'Cloud Sofa', + assetSeriesName: 'Cloud Sofa', code: 'DG-002', }, ], @@ -1761,7 +1761,7 @@ describe('IntakeClient', () => { const eligibleDraftGroups = Array.from({ length: 26 }, (_, index) => ({ id: `draft-group-${index + 1}`, name: `Draft Group ${index + 1}`, - seriesTemplateName: `Series ${index + 1}`, + assetSeriesName: `Series ${index + 1}`, code: `DG-${String(index + 1).padStart(3, '0')}`, })) diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index dd7c6be9..785a838c 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -120,7 +120,7 @@ interface IntakeClientSelectorProps { eligibleDraftGroups: { id: string; name: string; - seriesTemplateName: string; + assetSeriesName: string; code: string; }[]; eligibleSkuList?: WorkbenchSku[]; @@ -780,7 +780,7 @@ function buildIntegratedSeriesRows(params: { readPreSkuTextField(firstPreSku, "spu"))) || draftGroup.code || "SPU-001", - seriesName: draftGroup.seriesTemplateName || draftGroup.name, + seriesName: draftGroup.assetSeriesName || draftGroup.name, draftGroupName: draftGroup.name, designer: "", operator: "", @@ -803,7 +803,7 @@ function buildIntegratedSeriesRows(params: { const rows: IntegratedPendingSeriesRow[] = eligibleDraftGroups.map( (draftGroup, index) => { - const bucketKey = `${draftGroup.name}::${draftGroup.seriesTemplateName}`; + const bucketKey = `${draftGroup.name}::${draftGroup.assetSeriesName}`; const matchedSkus = skuBuckets.get(bucketKey) ?? []; const firstSku = matchedSkus[0]; @@ -814,7 +814,7 @@ function buildIntegratedSeriesRows(params: { firstSku?.spuCode || draftGroup.code || `SPU-${String(index + 1).padStart(3, "0")}`, - seriesName: draftGroup.seriesTemplateName || draftGroup.name, + seriesName: draftGroup.assetSeriesName || draftGroup.name, draftGroupName: draftGroup.name, designer: "", operator: "", diff --git a/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.test.ts b/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.test.ts index ed44b310..7e263dee 100644 --- a/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.test.ts +++ b/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.test.ts @@ -15,9 +15,9 @@ function buildDraftGroupDetail( description: null, categoryId: 'category-1', categoryName: '书桌', - seriesTemplateId: 'series-template-1', - seriesTemplateCode: 'SERIES-001', - seriesTemplateName: '集成书桌系列', + assetSeriesId: 'series-template-1', + assetSeriesCode: 'SERIES-001', + assetSeriesName: '集成书桌系列', status: 'DRAFT', summary: null, notes: null, diff --git a/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.ts b/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.ts index 2921032e..82046946 100644 --- a/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.ts +++ b/furniture-3d-system/src/app/intake/integrated/integratedDraftGroupAdapter.ts @@ -59,7 +59,7 @@ function readSeriesName(detail: DraftGroupDetailDTO) { return familySeriesName.trim() } - return detail.draftGroup.seriesTemplateName?.trim() || detail.draftGroup.name.trim() + return detail.draftGroup.assetSeriesName?.trim() || detail.draftGroup.name.trim() } function pickBaseSpu(detail: DraftGroupDetailDTO) { diff --git a/furniture-3d-system/src/app/intake/integrated/integratedSeriesLoader.test.ts b/furniture-3d-system/src/app/intake/integrated/integratedSeriesLoader.test.ts index 706e268b..f6b74288 100644 --- a/furniture-3d-system/src/app/intake/integrated/integratedSeriesLoader.test.ts +++ b/furniture-3d-system/src/app/intake/integrated/integratedSeriesLoader.test.ts @@ -13,9 +13,9 @@ function buildDraftGroup(id: string, status: DraftGroupDTO['status']): DraftGrou description: null, categoryId: 'category-1', categoryName: '桌类', - seriesTemplateId: `series-template-${id}`, - seriesTemplateCode: `SERIES-${id}`, - seriesTemplateName: `系列-${id}`, + assetSeriesId: `series-template-${id}`, + assetSeriesCode: `SERIES-${id}`, + assetSeriesName: `系列-${id}`, status, summary: null, notes: null, diff --git a/furniture-3d-system/src/app/intake/page.tsx b/furniture-3d-system/src/app/intake/page.tsx index 0fdc34a3..df121ae4 100644 --- a/furniture-3d-system/src/app/intake/page.tsx +++ b/furniture-3d-system/src/app/intake/page.tsx @@ -114,7 +114,7 @@ export default async function IntakePage({ name: preSku.customName || preSku.defaultName, seriesName: readPreSkuTextField(preSku, 'seriesName') || - detail.draftGroup.seriesTemplateName || + detail.draftGroup.assetSeriesName || detail.draftGroup.name, draftGroupName: detail.draftGroup.name, status: mapWorkbenchStatus(preSku.status), 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 d8d98db2..5f1d25b1 100644 --- a/furniture-3d-system/src/lib/open-product/assembly-workspace.ts +++ b/furniture-3d-system/src/lib/open-product/assembly-workspace.ts @@ -163,6 +163,7 @@ export interface AssemblyAutoSnapCandidate { export interface AssemblyWorkspaceSnapshot { version: '2026-04-13' | '2026-04-09' | '2026-04-08' + assetSeriesId?: string | null productCategoryId?: string | null productCategoryName?: string | null workflowMode: 'assisted-manual' @@ -199,6 +200,7 @@ export interface AssemblyWorkspaceFamilyInput { } interface BuildManualAssemblyWorkspaceInput { + assetSeriesId?: string | null productCategoryId: string | null productCategoryName: string | null source: AssemblyWorkspaceSource @@ -1817,6 +1819,7 @@ export function buildManualAssemblyWorkspace( const snapshot: AssemblyWorkspaceSnapshot = { version: '2026-04-13', + assetSeriesId: input.assetSeriesId ?? null, productCategoryId: input.productCategoryId, productCategoryName: input.productCategoryName, workflowMode: 'assisted-manual', @@ -1848,6 +1851,7 @@ export function syncManualAssemblyWorkspace( if ( snapshot.productCategoryId !== input.productCategoryId || + (snapshot.assetSeriesId ?? null) !== (input.assetSeriesId ?? null) || snapshot.source !== input.source || snapshot.entryMode !== input.entryMode || (snapshot.sourceSkuId ?? null) !== (input.sourceSkuId ?? null) || @@ -1884,6 +1888,7 @@ export function syncManualAssemblyWorkspace( { ...snapshot, productCategoryId: input.productCategoryId, + assetSeriesId: input.assetSeriesId ?? null, productCategoryName: input.productCategoryName, workflowMode: 'assisted-manual', entryMode: input.entryMode, @@ -1926,6 +1931,7 @@ export function buildSingleBoardDeskAssemblyWorkspace( }) const snapshot = buildManualAssemblyWorkspace({ + assetSeriesId: null, productCategoryId: null, productCategoryName: null, source: 'assisted-assembly', diff --git a/furniture-3d-system/src/lib/open-product/manual-assembly-launch.ts b/furniture-3d-system/src/lib/open-product/manual-assembly-launch.ts index d088dea9..2618af0c 100644 --- a/furniture-3d-system/src/lib/open-product/manual-assembly-launch.ts +++ b/furniture-3d-system/src/lib/open-product/manual-assembly-launch.ts @@ -17,6 +17,7 @@ export type ManualAssemblyProductRecovery = export interface ManualAssemblyLaunchParams { entryMode: AssemblyWorkspaceEntryMode source: AssemblyWorkspaceSource + assetSeriesId?: string | null sourceSkuId?: string | null sourceProductId?: string | null selectedFamilyIdsByType: ManualAssemblySelectedFamilyIdsByType @@ -84,6 +85,10 @@ export function buildManualAssemblyLaunchHref( searchParams.set('sourceProductId', params.sourceProductId) } + if (params.assetSeriesId) { + searchParams.set('assetSeriesId', params.assetSeriesId) + } + if (params.redirectedFrom) { searchParams.set('redirectedFrom', params.redirectedFrom) } @@ -131,6 +136,7 @@ export function parseManualAssemblyLaunchParams( const entryModeValue = searchParams.get('entryMode')?.trim() const sourceSkuId = searchParams.get('sourceSkuId')?.trim() ?? null const sourceProductId = searchParams.get('sourceProductId')?.trim() ?? null + const assetSeriesId = searchParams.get('assetSeriesId')?.trim() ?? null const redirectedFrom = normalizeLegacyEntry(searchParams.get('redirectedFrom')?.trim()) const productRecovery = normalizeProductRecovery(searchParams.get('productRecovery')?.trim()) const recoverableSourceSkuCount = normalizePositiveInteger( @@ -154,6 +160,7 @@ export function parseManualAssemblyLaunchParams( return { entryMode, source, + assetSeriesId, sourceSkuId, sourceProductId, selectedFamilyIdsByType, diff --git a/furniture-3d-system/src/lib/open-product/platform-api.ts b/furniture-3d-system/src/lib/open-product/platform-api.ts index 4f0a2fff..5ba519db 100644 --- a/furniture-3d-system/src/lib/open-product/platform-api.ts +++ b/furniture-3d-system/src/lib/open-product/platform-api.ts @@ -240,6 +240,7 @@ export interface ImportAssetCadPayload { familyCode?: string | null familyName?: string | null familyDescription?: string | null + assetSeriesId?: string | null connectionRoles?: string[] variantCode: string variantName: string @@ -258,6 +259,7 @@ export async function importAssetCad(input: ImportAssetCadPayload): Promise 0) { formData.append('connectionRoles', JSON.stringify(input.connectionRoles)) } diff --git a/furniture-3d-system/src/lib/open-product/types.ts b/furniture-3d-system/src/lib/open-product/types.ts index b7798023..5a1e3eb7 100644 --- a/furniture-3d-system/src/lib/open-product/types.ts +++ b/furniture-3d-system/src/lib/open-product/types.ts @@ -23,7 +23,6 @@ export interface OpenProductAssetFamily { assetType: OpenProductAssetType connectionRoles: string[] productCategoryId?: string | null - applicableSeriesCodes?: string[] } export interface OpenProductAssetVariant { diff --git a/furniture-3d-system/src/lib/packaging-materials/client-helpers.test.ts b/furniture-3d-system/src/lib/packaging-materials/client-helpers.test.ts index bdbbf819..33255368 100644 --- a/furniture-3d-system/src/lib/packaging-materials/client-helpers.test.ts +++ b/furniture-3d-system/src/lib/packaging-materials/client-helpers.test.ts @@ -19,7 +19,7 @@ function createFamily(overrides?: Partial): AssetFamilyDTO { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], + compatibleSeries: [{ id: 'template-1', code: 'single-board-desk', name: '单板桌' }], connectionRoles: [], metadata: { categoryLevel1: 'outer', diff --git a/furniture-3d-system/src/server/repositories/asset-center.repository.ts b/furniture-3d-system/src/server/repositories/asset-center.repository.ts index 772b1b72..ca4977df 100644 --- a/furniture-3d-system/src/server/repositories/asset-center.repository.ts +++ b/furniture-3d-system/src/server/repositories/asset-center.repository.ts @@ -1,4 +1,4 @@ -import { AssetFamily, AssetVariant, Prisma, Substrate } from '@prisma/client' +import { AssetFamily, AssetSeries, AssetVariant, Prisma, Substrate } from '@prisma/client' import { BaseRepository, FindManyOptions } from './base.repository' import { buildDeletedAssetVariantUniqueValue, @@ -32,6 +32,7 @@ export interface AssetFamilyFilter { type?: AssetFamily['type'] status?: AssetFamily['status'] productCategoryId?: string + assetSeriesId?: string search?: string } @@ -42,6 +43,16 @@ export interface AssetFamilyWithRelations extends AssetFamily { reviewer: { id: string; name: string } | null rejecter: { id: string; name: string } | null lastModifier: { id: string; name: string } | null + compatibleSeries: Array<{ + assetSeries: { + id: string + code: string + name: string + description: string | null + sortOrder: number + isActive: boolean + } + }> } export interface AssetVariantFilter { @@ -80,6 +91,21 @@ export interface SubstrateFilter { export const assetFamilyRelationInclude = { productCategory: { select: { id: true, name: true } }, + compatibleSeries: { + select: { + assetSeries: { + select: { + id: true, + code: true, + name: true, + description: true, + sortOrder: true, + isActive: true, + }, + }, + }, + orderBy: { assetSeries: { sortOrder: 'asc' } }, + }, creator: { select: { id: true, name: true } }, submitter: { select: { id: true, name: true } }, reviewer: { select: { id: true, name: true } }, @@ -249,21 +275,75 @@ class AssetFamilyRepositoryClass extends BaseRepository< return { deletedAt: null } } + const and: Prisma.AssetFamilyWhereInput[] = [] + if (filter.assetSeriesId && filter.type !== 'HARDWARE') { + const seriesScope: Prisma.AssetFamilyWhereInput = { + compatibleSeries: { + some: { + assetSeriesId: filter.assetSeriesId, + assetSeries: { isActive: true }, + }, + }, + } + and.push( + filter.type + ? seriesScope + : { + OR: [ + { type: 'HARDWARE' }, + seriesScope, + ], + }, + ) + } + + if (filter.search) { + and.push({ + OR: [ + { name: { contains: filter.search, mode: 'insensitive' } }, + { code: { contains: filter.search, mode: 'insensitive' } }, + ], + }) + } + return { deletedAt: null, type: filter.type, status: filter.status, productCategoryId: filter.productCategoryId, - OR: filter.search - ? [ - { name: { contains: filter.search, mode: 'insensitive' } }, - { code: { contains: filter.search, mode: 'insensitive' } }, - ] - : undefined, + AND: and.length > 0 ? and : undefined, } } } +class AssetSeriesRepositoryClass extends BaseRepository< + AssetSeries, + Prisma.AssetSeriesCreateInput, + Prisma.AssetSeriesUpdateInput +> { + protected modelName = 'assetSeries' + + async findAllActive(): Promise { + return this.prisma.assetSeries.findMany({ + where: { isActive: true }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + }) + } + + async findByIds(ids: string[]): Promise { + if (ids.length === 0) return [] + + return this.prisma.assetSeries.findMany({ + where: { id: { in: ids }, isActive: true }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + }) + } + + async createSeries(data: Prisma.AssetSeriesCreateInput): Promise { + return this.prisma.assetSeries.create({ data }) + } +} + class AssetVariantRepositoryClass extends BaseRepository< AssetVariant, Prisma.AssetVariantCreateInput, @@ -530,11 +610,13 @@ class SubstrateRepositoryClass extends BaseRepository< } export const assetFamilyRepository = new AssetFamilyRepositoryClass() +export const assetSeriesRepository = new AssetSeriesRepositoryClass() export const assetVariantRepository = new AssetVariantRepositoryClass() export const substrateRepository = new SubstrateRepositoryClass() export { AssetFamilyRepositoryClass, + AssetSeriesRepositoryClass, AssetVariantRepositoryClass, SubstrateRepositoryClass, } diff --git a/furniture-3d-system/src/server/repositories/draft-group.repository.ts b/furniture-3d-system/src/server/repositories/draft-group.repository.ts index fabcce72..94e7d980 100644 --- a/furniture-3d-system/src/server/repositories/draft-group.repository.ts +++ b/furniture-3d-system/src/server/repositories/draft-group.repository.ts @@ -3,7 +3,7 @@ import { BaseRepository } from './base.repository' export interface DraftGroupWithRelations extends DraftGroup { category: { id: string; name: string } | null - seriesTemplate: { id: string; code: string; name: string; seriesName: string } | null + assetSeries: { id: string; code: string; name: string } | null creator: { id: string; name: string } } @@ -23,7 +23,7 @@ class DraftGroupRepositoryClass extends BaseRepository< where: { id }, include: { category: { select: { id: true, name: true } }, - seriesTemplate: { select: { id: true, code: true, name: true, seriesName: true } }, + assetSeries: { select: { id: true, code: true, name: true } }, creator: { select: { id: true, name: true } }, }, }) as Promise @@ -34,7 +34,7 @@ class DraftGroupRepositoryClass extends BaseRepository< orderBy: { updatedAt: 'desc' }, include: { category: { select: { id: true, name: true } }, - seriesTemplate: { select: { id: true, code: true, name: true, seriesName: true } }, + assetSeries: { select: { id: true, code: true, name: true } }, creator: { select: { id: true, name: true } }, }, }) as Promise @@ -45,7 +45,7 @@ class DraftGroupRepositoryClass extends BaseRepository< data, include: { category: { select: { id: true, name: true } }, - seriesTemplate: { select: { id: true, code: true, name: true, seriesName: true } }, + assetSeries: { select: { id: true, code: true, name: true } }, creator: { select: { id: true, name: true } }, }, }) as Promise diff --git a/furniture-3d-system/src/server/repositories/index.ts b/furniture-3d-system/src/server/repositories/index.ts index 7b0d0ece..315c3f97 100644 --- a/furniture-3d-system/src/server/repositories/index.ts +++ b/furniture-3d-system/src/server/repositories/index.ts @@ -114,11 +114,13 @@ export type { export { assetFamilyRelationInclude, assetFamilyRepository, + assetSeriesRepository, assetVariantRelationInclude, assetVariantRepository, substrateRelationInclude, substrateRepository, AssetFamilyRepositoryClass, + AssetSeriesRepositoryClass, AssetVariantRepositoryClass, SubstrateRepositoryClass, } from './asset-center.repository' @@ -132,9 +134,6 @@ export type { SubstrateWithRelations, } from './asset-center.repository' -export { seriesTemplateRepository, SeriesTemplateRepositoryClass } from './series-template.repository' -export type { SeriesTemplateWithRelations } from './series-template.repository' - export { draftGroupRepository, preSkuRepository, diff --git a/furniture-3d-system/src/server/repositories/series-template.repository.ts b/furniture-3d-system/src/server/repositories/series-template.repository.ts deleted file mode 100644 index 07011a4b..00000000 --- a/furniture-3d-system/src/server/repositories/series-template.repository.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Prisma, SeriesTemplate } from '@prisma/client' -import { BaseRepository } from './base.repository' - -export interface SeriesTemplateWithRelations extends SeriesTemplate { - category: { id: string; name: string } | null - creator: { id: string; name: string } | null -} - -class SeriesTemplateRepositoryClass extends BaseRepository< - SeriesTemplate, - Prisma.SeriesTemplateCreateInput, - Prisma.SeriesTemplateUpdateInput -> { - protected modelName = 'seriesTemplate' - - async findByCode(code: string): Promise { - return this.prisma.seriesTemplate.findUnique({ where: { code } }) - } - - async findByIdWithRelations(id: string): Promise { - return this.prisma.seriesTemplate.findUnique({ - where: { id }, - include: { - category: { select: { id: true, name: true } }, - creator: { select: { id: true, name: true } }, - }, - }) as Promise - } - - async findAllActiveWithRelations(): Promise { - return this.prisma.seriesTemplate.findMany({ - where: { isActive: true }, - orderBy: [{ isPreset: 'desc' }, { updatedAt: 'desc' }], - include: { - category: { select: { id: true, name: true } }, - creator: { select: { id: true, name: true } }, - }, - }) as Promise - } - - async createWithRelations(data: Prisma.SeriesTemplateCreateInput): Promise { - return this.prisma.seriesTemplate.create({ - data, - include: { - category: { select: { id: true, name: true } }, - creator: { select: { id: true, name: true } }, - }, - }) as Promise - } -} - -export const seriesTemplateRepository = new SeriesTemplateRepositoryClass() -export { SeriesTemplateRepositoryClass } diff --git a/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts index 620a307d..ecfe1b68 100644 --- a/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts @@ -54,7 +54,6 @@ function createFamilyDTO(overrides: Record = {}) { description: null, productCategoryId: null, productCategoryName: null, - applicableSeriesCodes: [], connectionRoles: [], metadata: null, status: 'DRAFT', @@ -245,6 +244,7 @@ describe('assetCadImportService', () => { familyCode: 'BOARD-TOP', familyName: '桌面板', familyDescription: '单板桌桌面板', + assetSeriesId: 'series-single-board-desk', connectionRoles: ['desktop'], variantCode: 'BOARD-1200', variantName: '桌面板 1200', @@ -259,6 +259,7 @@ describe('assetCadImportService', () => { code: 'BOARD-TOP', name: '桌面板', description: '单板桌桌面板', + compatibleSeriesIds: ['series-single-board-desk'], connectionRoles: ['desktop'], createdBy: 'user-1', }) @@ -483,6 +484,7 @@ describe('assetCadImportService', () => { familyName: '', variantCode: '', variantName: '', + assetSeriesId: 'series-single-board-desk', substrateId: 'substrate-board', finishId: 'finish-oak', zipFile: createZipFile('140台面.zip'), @@ -495,6 +497,7 @@ describe('assetCadImportService', () => { code: 'BOARD-TOP_PANEL', name: '顶板', description: null, + compatibleSeriesIds: ['series-single-board-desk'], connectionRoles: [], createdBy: 'user-1', }) diff --git a/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts index 94f27394..683cea68 100644 --- a/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts @@ -1861,7 +1861,6 @@ describe('assetCenterService', () => { description: '单板桌桌面板', productCategoryId: 'category-table', productCategory: { id: 'category-table', name: '餐桌' }, - applicableSeriesCodes: ['single-board-desk'], connectionRoles: ['desktop'], metadata: { source: 'asset-center' }, status: 'AVAILABLE', @@ -1903,7 +1902,6 @@ describe('assetCenterService', () => { code: 'BOARD-TOP', name: '桌面板资产族', status: 'AVAILABLE', - applicableSeriesCodes: ['single-board-desk'], connectionRoles: ['desktop'], }), ]) diff --git a/furniture-3d-system/src/server/services/__tests__/asset-review.service.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-review.service.test.ts index 3bb73c67..4751b630 100644 --- a/furniture-3d-system/src/server/services/__tests__/asset-review.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/asset-review.service.test.ts @@ -67,7 +67,6 @@ describe('assetCenterService review workflow', () => { name: '桌腿资产族', description: null, productCategoryId: null, - applicableSeriesCodes: ['single-board-desk'], connectionRoles: ['desk_leg'], metadata: null, status: 'DRAFT', @@ -147,7 +146,6 @@ describe('assetCenterService review workflow', () => { name: '桌腿资产族', description: null, productCategoryId: null, - applicableSeriesCodes: ['single-board-desk'], connectionRoles: ['desk_leg'], metadata: null, status: data.status ?? 'DRAFT', diff --git a/furniture-3d-system/src/server/services/__tests__/combination.service.test.ts b/furniture-3d-system/src/server/services/__tests__/combination.service.test.ts deleted file mode 100644 index 448f62ae..00000000 --- a/furniture-3d-system/src/server/services/__tests__/combination.service.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { combinationService } from '../combination.service' -import { - assetFamilyRepository, - assetVariantRepository, - seriesTemplateRepository, -} from '../../repositories' - -jest.mock('../../repositories', () => ({ - assetFamilyRepository: { - findByIdsWithRelations: jest.fn(), - }, - assetVariantRepository: { - findAvailableByFamilyIds: jest.fn(), - }, - seriesTemplateRepository: { - findByIdWithRelations: jest.fn(), - }, -})) - -describe('combinationService', () => { - const mockedAssetFamilyRepository = jest.mocked(assetFamilyRepository) - const mockedAssetVariantRepository = jest.mocked(assetVariantRepository) - const mockedSeriesTemplateRepository = jest.mocked(seriesTemplateRepository) - - beforeEach(() => { - jest.clearAllMocks() - - mockedSeriesTemplateRepository.findByIdWithRelations.mockResolvedValue({ - id: 'template-single-board-desk', - code: 'single-board-desk', - name: '单板桌', - seriesName: '云序', - previewRule: { - desktopHeight: 720, - desktopPosition: [0, 720, 0], - legOffsets: { - frontLeft: [-450, 360, 250], - frontRight: [450, 360, 250], - rearLeft: [-450, 360, -250], - rearRight: [450, 360, -250], - }, - }, - } as never) - - mockedAssetFamilyRepository.findByIdsWithRelations.mockResolvedValue([ - { - id: 'family-desktop-a', - code: 'TOP-A', - name: '桌面A', - type: 'BOARD', - connectionRoles: ['desktop'], - }, - { - id: 'family-leg-a', - code: 'LEG-A', - name: '桌腿A', - type: 'COMPONENT', - connectionRoles: ['desk_leg'], - }, - { - id: 'family-connector-a', - code: 'CT-A', - name: '连接件A', - type: 'HARDWARE', - connectionRoles: ['desk_leg_connector'], - }, - ] as never) - - mockedAssetVariantRepository.findAvailableByFamilyIds.mockResolvedValue([ - { - id: 'variant-desktop-a', - assetFamilyId: 'family-desktop-a', - code: 'TOP-A-1200', - name: '桌面A 1200', - assetType: 'BOARD', - status: 'AVAILABLE', - dimensions: { length: 1200, width: 600, thickness: 25 }, - modelUrl: '/models/top-a.glb', - mountPoints: [{ name: 'origin', position: [0, 0, 0] }], - substrateId: null, - finishId: null, - weightKg: 18, - costPrice: 200, - assemblyBasis: null, - }, - { - id: 'variant-leg-a', - assetFamilyId: 'family-leg-a', - code: 'LEG-A-720', - name: '桌腿A 720', - assetType: 'COMPONENT', - status: 'AVAILABLE', - dimensions: { length: 60, width: 60, height: 720 }, - modelUrl: '/models/leg-a.glb', - mountPoints: [{ name: 'origin', position: [0, 360, 0] }], - substrateId: null, - finishId: null, - weightKg: 2.5, - costPrice: 38, - assemblyBasis: null, - }, - { - id: 'variant-connector-a', - assetFamilyId: 'family-connector-a', - code: 'CT-A-001', - name: '连接件A', - assetType: 'HARDWARE', - status: 'AVAILABLE', - dimensions: { length: 80, width: 40, thickness: 3 }, - modelUrl: '/models/connector-a.glb', - mountPoints: [{ name: 'origin', position: [0, 0, 0] }], - substrateId: null, - finishId: null, - weightKg: 0.3, - costPrice: 5, - assemblyBasis: null, - }, - ] as never) - }) - - it('返回可直接驱动预览的候选结果', async () => { - const result = await combinationService.generateCandidates({ - seriesTemplateId: 'template-single-board-desk', - desktopFamilyIds: ['family-desktop-a'], - legFamilyIds: ['family-leg-a'], - connectorFamilyIds: ['family-connector-a'], - }) - - expect(result.totalCount).toBe(1) - expect(result.candidates[0]).toEqual( - expect.objectContaining({ - previewMode: 'composite-ready', - assemblyResultStatus: 'ASSEMBLABLE', - statusTag: '可直接预览', - componentCodes: ['TOP-A-1200', 'LEG-A-720', 'CT-A-001'], - combinationItems: expect.arrayContaining([ - expect.objectContaining({ label: '桌面板', value: '桌面A', quantity: 1 }), - expect.objectContaining({ label: '桌腿', value: '桌腿A', quantity: 4 }), - ]), - previewNodes: expect.arrayContaining([ - expect.objectContaining({ role: 'desktop', modelUrl: '/models/top-a.glb' }), - expect.objectContaining({ role: 'frontLeft', modelUrl: '/models/leg-a.glb' }), - ]), - candidateSnapshot: expect.objectContaining({ - key: 'family-desktop-a::family-leg-a', - templateCode: 'single-board-desk', - }), - assemblyWorkspace: expect.objectContaining({ - workflowMode: 'assisted-manual', - nodes: expect.arrayContaining([ - expect.objectContaining({ role: 'desktop', zone: 'main' }), - expect.objectContaining({ role: 'frontLeft', zone: 'staging' }), - ]), - }), - }), - ) - }) - - it('同一资产族存在多个可用规格款时返回警告而不是崩溃', async () => { - mockedAssetVariantRepository.findAvailableByFamilyIds.mockResolvedValue([ - { - id: 'variant-desktop-a', - assetFamilyId: 'family-desktop-a', - code: 'TOP-A-1200', - name: '桌面A 1200', - assetType: 'BOARD', - status: 'AVAILABLE', - dimensions: { length: 1200, width: 600, thickness: 25 }, - modelUrl: '/models/top-a.glb', - mountPoints: [{ name: 'origin', position: [0, 0, 0] }], - substrateId: null, - finishId: null, - weightKg: 18, - costPrice: 200, - assemblyBasis: null, - }, - { - id: 'variant-desktop-a-v2', - assetFamilyId: 'family-desktop-a', - code: 'TOP-A-1400', - name: '桌面A 1400', - assetType: 'BOARD', - status: 'AVAILABLE', - dimensions: { length: 1400, width: 700, thickness: 25 }, - modelUrl: '/models/top-a-1400.glb', - mountPoints: [{ name: 'origin', position: [0, 0, 0] }], - substrateId: null, - finishId: null, - weightKg: 20, - costPrice: 220, - assemblyBasis: null, - }, - { - id: 'variant-leg-a', - assetFamilyId: 'family-leg-a', - code: 'LEG-A-720', - name: '桌腿A 720', - assetType: 'COMPONENT', - status: 'AVAILABLE', - dimensions: { length: 60, width: 60, height: 720 }, - modelUrl: '/models/leg-a.glb', - mountPoints: [{ name: 'origin', position: [0, 360, 0] }], - substrateId: null, - finishId: null, - weightKg: 2.5, - costPrice: 38, - assemblyBasis: null, - }, - ] as never) - - const result = await combinationService.generateCandidates({ - seriesTemplateId: 'template-single-board-desk', - desktopFamilyIds: ['family-desktop-a'], - legFamilyIds: ['family-leg-a'], - }) - - expect(result.candidates[0].assemblyResultStatus).toBe('ASSEMBLABLE_WITH_WARNINGS') - expect(result.candidates[0].assemblyWarnings).toContain( - '桌面A 存在多个可用规格款,当前按最新规格款预览', - ) - expect(result.candidates[0].previewNodes.length).toBeGreaterThan(0) - }) - - it('缺少必选桌面规格款时返回阻断状态和警告', async () => { - mockedAssetVariantRepository.findAvailableByFamilyIds.mockResolvedValue([ - { - id: 'variant-leg-a', - assetFamilyId: 'family-leg-a', - code: 'LEG-A-720', - name: '桌腿A 720', - assetType: 'COMPONENT', - status: 'AVAILABLE', - dimensions: { length: 60, width: 60, height: 720 }, - modelUrl: '/models/leg-a.glb', - mountPoints: [{ name: 'origin', position: [0, 360, 0] }], - substrateId: null, - finishId: null, - weightKg: 2.5, - costPrice: 38, - assemblyBasis: null, - }, - ] as never) - - const result = await combinationService.generateCandidates({ - seriesTemplateId: 'template-single-board-desk', - desktopFamilyIds: ['family-desktop-a'], - legFamilyIds: ['family-leg-a'], - }) - - expect(result.candidates[0]).toEqual( - expect.objectContaining({ - assemblyResultStatus: 'BLOCKED', - statusTag: '缺少规格款', - }), - ) - expect(result.candidates[0].assemblyWarnings).toContain('桌面A 缺少可用规格款') - }) -}) diff --git a/furniture-3d-system/src/server/services/__tests__/draft-group.manual-assembly.test.ts b/furniture-3d-system/src/server/services/__tests__/draft-group.manual-assembly.test.ts index 55ba16b1..9072e147 100644 --- a/furniture-3d-system/src/server/services/__tests__/draft-group.manual-assembly.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/draft-group.manual-assembly.test.ts @@ -90,8 +90,8 @@ describe('draftGroupService manual assembly', () => { status: 'DRAFT', summary: null, notes: null, - seriesTemplateId: 'template-1', - seriesTemplate: { id: 'template-1', code: 'single-board-desk', name: '单板桌' }, + assetSeriesId: 'template-1', + assetSeries: { id: 'template-1', code: 'single-board-desk', name: '单板桌' }, creator: { id: 'user-1', name: '测试用户' }, createdBy: 'user-1', createdAt: new Date('2026-04-09T12:00:00Z'), diff --git a/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts b/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts index e029074b..1f77616b 100644 --- a/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts @@ -5,7 +5,6 @@ import { draftGroupRepository, preSkuRepository, preSkuPackagingPlanRepository, - seriesTemplateRepository, } from '../../repositories' import type { AssemblyWorkspaceSnapshot } from '@/lib/open-product/assembly-workspace' @@ -32,9 +31,6 @@ jest.mock('../../repositories', () => ({ preSkuPackagingPlanRepository: { upsertByPreSkuId: jest.fn(), }, - seriesTemplateRepository: { - findByIdWithRelations: jest.fn(), - }, })) function buildLegacyCandidateWorkspace(input?: { @@ -209,7 +205,6 @@ describe('draftGroupService', () => { const mockedDraftGroupRepository = jest.mocked(draftGroupRepository) const mockedPreSkuRepository = jest.mocked(preSkuRepository) const mockedPreSkuPackagingPlanRepository = jest.mocked(preSkuPackagingPlanRepository) - const mockedSeriesTemplateRepository = jest.mocked(seriesTemplateRepository) const authUser = { id: 'user-1', @@ -256,7 +251,6 @@ describe('draftGroupService', () => { name: '纸箱', description: null, productCategoryId: null, - applicableSeriesCodes: [], connectionRoles: [], metadata: null, status: 'AVAILABLE', @@ -273,7 +267,6 @@ describe('draftGroupService', () => { name: '泡沫垫', description: null, productCategoryId: null, - applicableSeriesCodes: [], connectionRoles: [], metadata: null, status: 'AVAILABLE', @@ -287,32 +280,6 @@ describe('draftGroupService', () => { total: 2, } as never) - mockedSeriesTemplateRepository.findByIdWithRelations.mockResolvedValue({ - id: 'series-template-1', - code: 'single-board-desk', - name: '单板桌', - seriesName: '云序', - categoryId: 'category-desk', - slotSchema: {}, - namingRule: {}, - recommendationRule: {}, - previewRule: { - desktopHeight: 720, - desktopPosition: [0, 720, 0], - legOffsets: { - frontLeft: [-450, 360, 250], - frontRight: [450, 360, 250], - rearLeft: [-450, 360, -250], - rearRight: [450, 360, -250], - }, - }, - intakeRule: {}, - isPreset: true, - isActive: true, - createdAt: new Date('2026-03-30T08:00:00.000Z'), - updatedAt: new Date('2026-03-30T08:00:00.000Z'), - } as never) - mockedAssetVariantRepository.findAvailableByFamilyIds.mockResolvedValue([ { id: 'variant-desktop-a-white', @@ -506,14 +473,14 @@ describe('draftGroupService', () => { id: 'draft-group-1', name: '云序桌系第一组', categoryId: 'category-desk', - seriesTemplateId: 'series-template-1', + assetSeriesId: 'series-template-1', status: 'DRAFT', createdBy: 'user-1', summary: { selectedCount: 2 }, createdAt: new Date('2026-03-30T12:00:00.000Z'), updatedAt: new Date('2026-03-30T12:00:00.000Z'), category: { id: 'category-desk', name: '单板桌' }, - seriesTemplate: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌' }, + assetSeries: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌' }, creator: { id: 'user-1', name: '规划员' }, } as never) @@ -531,7 +498,7 @@ describe('draftGroupService', () => { name: '云序桌系第一组', description: null, categoryId: 'category-desk', - seriesTemplateId: 'series-template-1', + assetSeriesId: 'series-template-1', status: 'DRAFT', summary: { selectedCount: 1 }, notes: null, @@ -539,7 +506,7 @@ describe('draftGroupService', () => { createdAt: new Date('2026-03-30T12:00:00.000Z'), updatedAt: new Date('2026-03-30T12:00:00.000Z'), category: { id: 'category-desk', name: '单板桌' }, - seriesTemplate: { + assetSeries: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌', @@ -1306,7 +1273,7 @@ describe('draftGroupService', () => { name: '云序桌系第一组', description: null, categoryId: 'category-desk', - seriesTemplateId: 'series-template-1', + assetSeriesId: 'series-template-1', status: 'PACKAGING', summary: { selectedCount: 2 }, notes: null, @@ -1314,7 +1281,7 @@ describe('draftGroupService', () => { createdAt: new Date('2026-03-30T12:00:00.000Z'), updatedAt: new Date('2026-03-30T12:00:00.000Z'), category: { id: 'category-desk', name: '单板桌' }, - seriesTemplate: { + assetSeries: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌', @@ -1767,7 +1734,7 @@ describe('draftGroupService', () => { name: '云序桌系第一组', description: null, categoryId: 'category-desk', - seriesTemplateId: 'series-template-1', + assetSeriesId: 'series-template-1', status: 'READY_FOR_INTAKE', summary: { selectedCount: 2 }, notes: null, @@ -1775,7 +1742,7 @@ describe('draftGroupService', () => { createdAt: new Date('2026-03-30T12:00:00.000Z'), updatedAt: new Date('2026-03-30T12:00:00.000Z'), category: { id: 'category-desk', name: '单板桌' }, - seriesTemplate: { + assetSeries: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌', @@ -1861,7 +1828,7 @@ describe('draftGroupService', () => { name: '云序桌系第一组', description: null, categoryId: 'category-desk', - seriesTemplateId: 'series-template-1', + assetSeriesId: 'series-template-1', status: 'PACKAGING', summary: { selectedCount: 2 }, notes: null, @@ -1869,7 +1836,7 @@ describe('draftGroupService', () => { createdAt: new Date('2026-03-30T12:00:00.000Z'), updatedAt: new Date('2026-03-30T12:00:00.000Z'), category: { id: 'category-desk', name: '单板桌' }, - seriesTemplate: { + assetSeries: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌', @@ -1969,7 +1936,7 @@ describe('draftGroupService', () => { name: '云序桌系第一组', description: null, categoryId: 'category-desk', - seriesTemplateId: 'series-template-1', + assetSeriesId: 'series-template-1', status: 'FORMALIZED', summary: { selectedCount: 2 }, notes: null, @@ -1977,7 +1944,7 @@ describe('draftGroupService', () => { createdAt: new Date('2026-03-30T12:00:00.000Z'), updatedAt: new Date('2026-03-30T12:00:00.000Z'), category: { id: 'category-desk', name: '单板桌' }, - seriesTemplate: { + assetSeries: { id: 'series-template-1', code: 'single-board-desk', name: '单板桌', diff --git a/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts b/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts index f86808b8..f1fe84b9 100644 --- a/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts @@ -2,15 +2,12 @@ import type { PreSkuPackagingInputDTO } from '../draft-group.service.types' import { buildSeedPackagingPlanInput, openPlatformDemoSeedService, - pickSeedCandidates, } from '../open-platform-demo-seed.service' import { DEFAULT_STANDARD_FINISH } from '@/lib/standard-finish-catalog' import { prisma } from '@/lib/prisma' import { assetCenterService } from '../asset-center.service' -import { combinationService } from '../combination.service' import { draftGroupService } from '../draft-group.service' import { intakeService } from '../intake.service' -import { seriesTemplateService } from '../series-template.service' import { warehouseService } from '../warehouse.service' jest.mock('bcryptjs', () => ({ @@ -76,6 +73,7 @@ jest.mock('@/lib/prisma', () => ({ jest.mock('../asset-center.service', () => ({ assetCenterService: { + listSeries: jest.fn(), createFamily: jest.fn(), updateFamily: jest.fn(), submitFamilyForReview: jest.fn(), @@ -88,21 +86,9 @@ jest.mock('../asset-center.service', () => ({ }, })) -jest.mock('../series-template.service', () => ({ - seriesTemplateService: { - listTemplates: jest.fn(), - }, -})) - -jest.mock('../combination.service', () => ({ - combinationService: { - generateCandidates: jest.fn(), - }, -})) - jest.mock('../draft-group.service', () => ({ draftGroupService: { - createFromAutoCombination: jest.fn(), + createFromManualAssembly: jest.fn(), savePackagingPlan: jest.fn(), getById: jest.fn(), }, @@ -126,8 +112,6 @@ describe('openPlatformDemoSeedService', () => { const defaultFinishCategoryName = DEFAULT_STANDARD_FINISH.category.name const mockedPrisma = jest.mocked(prisma) const mockedAssetCenterService = jest.mocked(assetCenterService) - const mockedSeriesTemplateService = jest.mocked(seriesTemplateService) - const mockedCombinationService = jest.mocked(combinationService) const mockedDraftGroupService = jest.mocked(draftGroupService) const mockedIntakeService = jest.mocked(intakeService) const mockedWarehouseService = jest.mocked(warehouseService) @@ -136,20 +120,6 @@ describe('openPlatformDemoSeedService', () => { jest.clearAllMocks() }) - it('会优先挑首尾两个候选,避免种子数据只落同一类组合', () => { - const candidates = [ - { key: 'candidate-1' }, - { key: 'candidate-2' }, - { key: 'candidate-3' }, - { key: 'candidate-4' }, - ] as Array<{ key: string }> - - expect(pickSeedCandidates(candidates).map((item) => item.key)).toEqual([ - 'candidate-1', - 'candidate-4', - ]) - }) - it('生成包装计划输入时会保留逐件 slotKey / role / selectionKey', () => { const packagingInput: PreSkuPackagingInputDTO = { preSkuId: 'pre-sku-1', @@ -336,6 +306,18 @@ describe('openPlatformDemoSeedService', () => { code: 'PB18', name: '18E1级颗粒板', } as never) + mockedAssetCenterService.listSeries.mockResolvedValue([ + { + id: 'asset-series-1', + code: 'single-board-desk', + name: '单板桌', + description: null, + sortOrder: 0, + isActive: true, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), + }, + ] as never) mockedAssetCenterService.createFamily.mockImplementation(async (input) => ({ id: `${input.code}-id`, @@ -345,7 +327,6 @@ describe('openPlatformDemoSeedService', () => { description: input.description ?? null, productCategoryId: input.productCategoryId ?? null, productCategoryName: '单板桌', - applicableSeriesCodes: input.applicableSeriesCodes ?? [], connectionRoles: input.connectionRoles ?? [], metadata: input.metadata ?? null, status: 'DRAFT', @@ -376,7 +357,6 @@ describe('openPlatformDemoSeedService', () => { description: input.description ?? null, productCategoryId: input.productCategoryId ?? null, productCategoryName: '单板桌', - applicableSeriesCodes: input.applicableSeriesCodes ?? [], connectionRoles: input.connectionRoles ?? [], metadata: input.metadata ?? null, status: 'DRAFT', @@ -407,7 +387,6 @@ describe('openPlatformDemoSeedService', () => { description: null, productCategoryId: 'category-desk', productCategoryName: '单板桌', - applicableSeriesCodes: ['single-board-desk'], connectionRoles: [], metadata: null, status: 'PENDING_REVIEW', @@ -438,7 +417,6 @@ describe('openPlatformDemoSeedService', () => { description: null, productCategoryId: 'category-desk', productCategoryName: '单板桌', - applicableSeriesCodes: ['single-board-desk'], connectionRoles: [], metadata: null, status: 'AVAILABLE', @@ -655,67 +633,6 @@ describe('openPlatformDemoSeedService', () => { updatedAt: new Date('2026-04-01T09:20:00.000Z'), })) - mockedSeriesTemplateService.listTemplates.mockResolvedValue([ - { - id: 'series-template-1', - code: 'single-board-desk', - name: '单板桌', - seriesName: '云序', - }, - ] as never) - - mockedCombinationService.generateCandidates.mockResolvedValue({ - seriesTemplateId: 'series-template-1', - totalCount: 4, - candidates: [ - { - key: 'candidate-desktop-a-slim', - defaultName: '云序-桌面 A-细腿', - familySelection: { - desktopFamilyId: 'family-desktop-a', - legGroupFamilyId: 'family-leg-slim', - legRoles: { - frontLeft: 'family-leg-slim', - frontRight: 'family-leg-slim', - rearLeft: 'family-leg-slim', - rearRight: 'family-leg-slim', - }, - connectorFamilyIds: ['family-connector'], - }, - }, - { - key: 'candidate-desktop-a-round', - defaultName: '云序-桌面 A-圆腿', - familySelection: { - desktopFamilyId: 'family-desktop-a', - legGroupFamilyId: 'family-leg-round', - legRoles: { - frontLeft: 'family-leg-round', - frontRight: 'family-leg-round', - rearLeft: 'family-leg-round', - rearRight: 'family-leg-round', - }, - connectorFamilyIds: ['family-connector'], - }, - }, - { - key: 'candidate-desktop-b-round', - defaultName: '云序-桌面 B-圆腿', - familySelection: { - desktopFamilyId: 'family-desktop-b', - legGroupFamilyId: 'family-leg-round', - legRoles: { - frontLeft: 'family-leg-round', - frontRight: 'family-leg-round', - rearLeft: 'family-leg-round', - rearRight: 'family-leg-round', - }, - connectorFamilyIds: ['family-connector'], - }, - }, - ], - } as never) - const packagingInputA: PreSkuPackagingInputDTO = { preSkuId: 'pre-sku-1', defaultName: '云序-桌面 A-细腿', @@ -743,14 +660,7 @@ describe('openPlatformDemoSeedService', () => { }, ], } - const packagingInputB: PreSkuPackagingInputDTO = { - ...packagingInputA, - preSkuId: 'pre-sku-2', - defaultName: '云序-桌面 B-圆腿', - sourceCandidateKey: 'candidate-desktop-b-round', - } - - mockedDraftGroupService.createFromAutoCombination.mockResolvedValue({ + mockedDraftGroupService.createFromManualAssembly.mockResolvedValue({ draftGroup: { id: 'draft-group-1', code: 'DG-SEED-001', @@ -763,12 +673,6 @@ describe('openPlatformDemoSeedService', () => { customName: null, packagingInput: packagingInputA, }, - { - id: 'pre-sku-2', - defaultName: '云序-桌面 B-圆腿', - customName: null, - packagingInput: packagingInputB, - }, ], } as never) mockedDraftGroupService.savePackagingPlan.mockResolvedValue({ @@ -793,17 +697,6 @@ describe('openPlatformDemoSeedService', () => { boxes: [{ boxNumber: 1, name: '标准包装箱 1' }], }, }, - { - id: 'pre-sku-2', - defaultName: '云序-桌面 B-圆腿', - customName: null, - status: 'DRAFT', - packagingInput: packagingInputB, - packagingPlan: { - id: 'packaging-plan-2', - boxes: [{ boxNumber: 1, name: '标准包装箱 1' }], - }, - }, ], } as never) mockedIntakeService.formalizeDraftGroup.mockResolvedValue({ @@ -825,18 +718,18 @@ describe('openPlatformDemoSeedService', () => { const result = await openPlatformDemoSeedService.seed() - expect(mockedDraftGroupService.createFromAutoCombination).toHaveBeenCalledWith( + expect(mockedDraftGroupService.createFromManualAssembly).toHaveBeenCalledWith( expect.objectContaining({ name: '开放平台演示草稿组', categoryId: 'category-desk', - candidates: expect.arrayContaining([ - expect.objectContaining({ key: 'candidate-desktop-a-slim' }), - expect.objectContaining({ key: 'candidate-desktop-b-round' }), + assetSeriesId: 'asset-series-1', + assemblies: expect.arrayContaining([ + expect.objectContaining({ key: 'seed-desktop-a' }), ]), }), expect.objectContaining({ id: 'user-planner' }), ) - expect(mockedDraftGroupService.savePackagingPlan).toHaveBeenCalledTimes(2) + expect(mockedDraftGroupService.savePackagingPlan).toHaveBeenCalledTimes(1) expect(mockedIntakeService.formalizeDraftGroup).toHaveBeenCalledWith( expect.objectContaining({ draftGroupId: 'draft-group-1', 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 ebca0c9b..98aafbe2 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 @@ -23,6 +23,7 @@ interface ImportAssetCadInput { familyCode?: string | null familyName?: string | null familyDescription?: string | null + assetSeriesId?: string | null connectionRoles?: string[] variantCode?: string | null variantName?: string | null @@ -522,6 +523,7 @@ class AssetCadImportService { code: familyCode, name: familyName, description: trimOrNull(input.familyDescription), + compatibleSeriesIds: input.assetSeriesId ? [input.assetSeriesId] : [], connectionRoles: input.connectionRoles ?? [], createdBy: input.createdBy, }) diff --git a/furniture-3d-system/src/server/services/asset-center.service.ts b/furniture-3d-system/src/server/services/asset-center.service.ts index 7b31cb6c..f7a9a205 100644 --- a/furniture-3d-system/src/server/services/asset-center.service.ts +++ b/furniture-3d-system/src/server/services/asset-center.service.ts @@ -50,6 +50,7 @@ import type { AuthUser } from '@/server/core/types' import { assetFamilyRepository, assetFamilyRelationInclude, + assetSeriesRepository, assetVariantRepository, assetVariantRelationInclude, categoryRepository, @@ -67,6 +68,7 @@ import type { AssetFamilyDTO, AssetTrashCleanupResultDTO, AssetTrashDataDTO, + AssetSeriesDTO, AssetReviewActionInput, AssetVariantDTO, AssetWorkflowAuditDTO, @@ -269,6 +271,16 @@ function toWorkflowDTO(record: { } function toAssetFamilyDTO(record: AssetFamilyWithRelations): AssetFamilyDTO { + const compatibleSeries = (record.compatibleSeries ?? []) + .map((item) => item.assetSeries) + .filter((series) => series.isActive) + .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name, 'zh-CN')) + .map((series) => ({ + id: series.id, + code: series.code, + name: series.name, + })) + return { id: record.id, type: record.type, @@ -277,7 +289,7 @@ function toAssetFamilyDTO(record: AssetFamilyWithRelations): AssetFamilyDTO { description: record.description, productCategoryId: record.productCategoryId, productCategoryName: record.productCategory?.name ?? null, - applicableSeriesCodes: record.applicableSeriesCodes, + compatibleSeries, connectionRoles: record.connectionRoles, metadata: isRecord(record.metadata) ? record.metadata : null, status: record.status, @@ -289,6 +301,28 @@ function toAssetFamilyDTO(record: AssetFamilyWithRelations): AssetFamilyDTO { } } +function toAssetSeriesDTO(record: { + id: string + code: string + name: string + description: string | null + sortOrder: number + isActive: boolean + createdAt: Date + updatedAt: Date +}): AssetSeriesDTO { + return { + id: record.id, + code: record.code, + name: record.name, + description: record.description, + sortOrder: record.sortOrder, + isActive: record.isActive, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } +} + function decimalToNumber(value: Prisma.Decimal | number | null | undefined): number | null { return value === null || value === undefined ? null : Number(value) } @@ -666,6 +700,26 @@ async function ensureFamilyCodeAvailable(code: string, currentId?: string): Prom } } +async function ensureActiveSeriesIds( + ids: string[] | undefined, + options?: { required?: boolean }, +): Promise { + const normalizedIds = Array.from(new Set((ids ?? []).map((id) => id.trim()).filter(Boolean))) + if (normalizedIds.length === 0) { + if (options?.required) { + throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择至少一个兼容系列') + } + return [] + } + + const existing = await assetSeriesRepository.findByIds(normalizedIds) + if (existing.length !== normalizedIds.length) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '兼容系列不存在或已停用') + } + + return normalizedIds +} + async function ensureVariantCodeAvailable(code: string, currentId?: string): Promise { const existing = await assetVariantRepository.findByCode(code) if (existing && existing.id !== currentId) { @@ -880,7 +934,24 @@ class AssetCenterService { } } - async listFamilies(filter?: Pick) { + async listSeries(): Promise { + const records = await assetSeriesRepository.findAllActive() + if (records.length > 0) { + return records.map((item) => toAssetSeriesDTO(item)) + } + + const created = await assetSeriesRepository.createSeries({ + code: 'single-board-desk', + name: '单板桌', + description: '资产中心默认系列,可在后续系列管理中调整。', + sortOrder: 0, + isActive: true, + metadata: { source: 'default-seed' }, + }) + return [toAssetSeriesDTO(created)] + } + + async listFamilies(filter?: Pick) { const result = await assetFamilyRepository.findManyWithRelations(filter ? { filter } : undefined) return result.items.map((item) => toAssetFamilyDTO(item)) } @@ -942,13 +1013,23 @@ class AssetCenterService { await ensureFamilyCodeAvailable(code) await ensureCategoryExists(input.productCategoryId ?? null) + const compatibleSeriesIds = + input.type === 'HARDWARE' + ? [] + : await ensureActiveSeriesIds(input.compatibleSeriesIds, { required: true }) const created = await assetFamilyRepository.createWithRelations({ type: input.type, code, name, description: trimOrNull(input.description), - applicableSeriesCodes: input.applicableSeriesCodes ?? [], + compatibleSeries: compatibleSeriesIds.length > 0 + ? { + create: compatibleSeriesIds.map((assetSeriesId) => ({ + assetSeries: { connect: { id: assetSeriesId } }, + })), + } + : undefined, connectionRoles: input.connectionRoles ?? [], metadata: toJsonValue(input.metadata), status: ensureDraftCreationStatus(input.status), @@ -977,13 +1058,26 @@ class AssetCenterService { await ensureFamilyCodeAvailable(nextCode, id) await ensureCategoryExists(input.productCategoryId ?? existing.productCategoryId) + const compatibleSeriesIds = + existing.type === 'HARDWARE' + ? [] + : input.compatibleSeriesIds === undefined + ? undefined + : await ensureActiveSeriesIds(input.compatibleSeriesIds, { required: true }) const updated = await assetFamilyRepository.updateWithRelations(id, { code: nextCode, name: nextName, description: input.description !== undefined ? trimOrNull(input.description) : undefined, - applicableSeriesCodes: input.applicableSeriesCodes ?? undefined, + compatibleSeries: compatibleSeriesIds === undefined + ? undefined + : { + deleteMany: {}, + create: compatibleSeriesIds.map((assetSeriesId) => ({ + assetSeries: { connect: { id: assetSeriesId } }, + })), + }, connectionRoles: input.connectionRoles ?? undefined, metadata: toJsonValue(input.metadata), productCategory: @@ -2387,10 +2481,13 @@ export { AssetCenterService } export type { AssetFamilyDTO, AssetReviewActionInput, + AssetSeriesDTO, + AssetSeriesSummaryDTO, AssetVariantDTO, CreateAssetVariantResultDTO, CreateAssetFamilyInput, CreateAssetVariantInput, + MaterialLikeDTO, SimilarAssetVariantCandidateDTO, CreateSubstrateDictionaryInput, CreateSubstrateInput, diff --git a/furniture-3d-system/src/server/services/asset-center.service.types.ts b/furniture-3d-system/src/server/services/asset-center.service.types.ts index a9c673d6..c42cf1a2 100644 --- a/furniture-3d-system/src/server/services/asset-center.service.types.ts +++ b/furniture-3d-system/src/server/services/asset-center.service.types.ts @@ -17,6 +17,23 @@ export interface AssetWorkflowAuditDTO { lastModifiedByName: string | null } +export interface AssetSeriesDTO { + id: string + code: string + name: string + description: string | null + sortOrder: number + isActive: boolean + createdAt: Date + updatedAt: Date +} + +export interface AssetSeriesSummaryDTO { + id: string + code: string + name: string +} + export interface AssetFamilyDTO { id: string type: AssetType @@ -25,7 +42,7 @@ export interface AssetFamilyDTO { description: string | null productCategoryId: string | null productCategoryName: string | null - applicableSeriesCodes: string[] + compatibleSeries: AssetSeriesSummaryDTO[] connectionRoles: string[] metadata: Record | null status: AssetStatus @@ -214,7 +231,7 @@ export interface CreateAssetFamilyInput { name: string description?: string | null productCategoryId?: string | null - applicableSeriesCodes?: string[] + compatibleSeriesIds?: string[] connectionRoles?: string[] metadata?: Record | null status?: AssetStatus @@ -226,7 +243,7 @@ export interface UpdateAssetFamilyInput { name?: string description?: string | null productCategoryId?: string | null - applicableSeriesCodes?: string[] + compatibleSeriesIds?: string[] connectionRoles?: string[] metadata?: Record | null status?: AssetStatus diff --git a/furniture-3d-system/src/server/services/combination.service.ts b/furniture-3d-system/src/server/services/combination.service.ts deleted file mode 100644 index 85bd0c88..00000000 --- a/furniture-3d-system/src/server/services/combination.service.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { generateSingleBoardDeskCandidates } from '@/lib/open-product/single-board-desk' -import { - buildSingleBoardDeskAssemblyWorkspace, - type AssemblyFastenerRecommendation, -} from '@/lib/open-product/assembly-workspace' -import { AppError, ErrorCode } from '@/server/core/errors' -import { - assetFamilyRepository, - assetVariantRepository, - seriesTemplateRepository, -} from '../repositories' -import { - buildAvailableVariantCounts, - buildSingleBoardDeskTemplate, - materializeSingleBoardDeskCandidate, - toOpenProductVariants, -} from './single-board-desk-materialization' -import type { - CombinationCandidateItemDTO, - CombinationCandidateDTO, - CombinationGenerationResult, - GenerateCombinationInput, -} from './combination.service.types' - -function buildCombinationItems(candidate: ReturnType['candidates'][number]) { - const items: CombinationCandidateItemDTO[] = [ - { - label: '桌面板', - value: candidate.slotSummary.find((item) => item.label === '桌面板')?.value ?? '未配置', - quantity: 1, - role: 'desktop', - }, - ] - - const legFamilyIds = Object.values(candidate.familySelection.legRoles).filter( - (value): value is string => Boolean(value), - ) - const uniqueLegFamilyIds = Array.from(new Set(legFamilyIds)) - - if (uniqueLegFamilyIds.length === 0) { - items.push({ - label: '桌腿', - value: '未启用', - quantity: 0, - }) - } else { - uniqueLegFamilyIds.forEach((legFamilyId) => { - const quantity = legFamilyIds.filter((value) => value === legFamilyId).length - const legSummary = candidate.slotSummary.find((item) => item.label === '桌腿组')?.value ?? legFamilyId - items.push({ - label: '桌腿', - value: legSummary, - quantity, - }) - }) - } - - if (candidate.familySelection.connectorFamilyIds.length > 0) { - items.push({ - label: '连接件', - value: '系统推荐', - quantity: legFamilyIds.length, - }) - } - - return items -} - -function buildFastenerRecommendations( - candidate: ReturnType['candidates'][number], - componentCodes: string[], -): AssemblyFastenerRecommendation[] { - const connectorFamilyIds = candidate.familySelection.connectorFamilyIds - if (connectorFamilyIds.length === 0) { - return [] - } - - const quantity = Object.values(candidate.familySelection.legRoles).filter(Boolean).length - - return connectorFamilyIds.map((assetFamilyId, index) => ({ - selectionKey: `connector:${assetFamilyId}:${index + 1}`, - assetFamilyId, - quantity, - label: - componentCodes.find((code) => code.includes('CT-')) ?? - `推荐连接件 ${index + 1}`, - })) -} - -class CombinationService { - async generateCandidates(input: GenerateCombinationInput): Promise { - const template = await seriesTemplateRepository.findByIdWithRelations(input.seriesTemplateId) - if (!template) { - throw new AppError(ErrorCode.SERIES_TEMPLATE_NOT_FOUND, '系列模板不存在') - } - if (template.code !== 'single-board-desk') { - throw new AppError(ErrorCode.VALIDATION_FAILED, '当前版本只支持单板桌辅助装配') - } - if (input.desktopFamilyIds.length === 0) { - throw new AppError(ErrorCode.VALIDATION_FAILED, '至少选择一个桌面板资产族') - } - - const familyIds = Array.from( - new Set([ - ...input.desktopFamilyIds, - ...(input.legFamilyIds ?? []), - ...(input.connectorFamilyIds ?? []), - ]), - ) - const families = await assetFamilyRepository.findByIdsWithRelations(familyIds) - const familyById = new Map(families.map((item) => [item.id, item])) - const familyNameById = new Map(families.map((item) => [item.id, item.name])) - const templateConfig = buildSingleBoardDeskTemplate(template) - - const result = generateSingleBoardDeskCandidates({ - template: templateConfig, - desktopFamilies: input.desktopFamilyIds - .map((id) => familyById.get(id)) - .filter((item): item is NonNullable => Boolean(item)) - .map((item) => ({ - id: item.id, - code: item.code, - name: item.name, - assetType: item.type, - connectionRoles: item.connectionRoles, - })), - legFamilies: (input.legFamilyIds ?? []) - .map((id) => familyById.get(id)) - .filter((item): item is NonNullable => Boolean(item)) - .map((item) => ({ - id: item.id, - code: item.code, - name: item.name, - assetType: item.type, - connectionRoles: item.connectionRoles, - })), - connectorFamilies: (input.connectorFamilyIds ?? []) - .map((id) => familyById.get(id)) - .filter((item): item is NonNullable => Boolean(item)) - .map((item) => ({ - id: item.id, - code: item.code, - name: item.name, - assetType: item.type, - connectionRoles: item.connectionRoles, - })), - }) - - const availableVariants = await assetVariantRepository.findAvailableByFamilyIds(familyIds) - const openProductVariants = toOpenProductVariants(availableVariants) - const availableVariantCounts = buildAvailableVariantCounts(openProductVariants) - - const candidates: CombinationCandidateDTO[] = result.candidates.map((candidate) => { - const { materialized, assemblyWarnings, assemblyResultStatus } = - materializeSingleBoardDeskCandidate({ - template: templateConfig, - candidate, - variants: openProductVariants, - availableVariantCounts, - familyNameById, - }) - const statusTag = - assemblyResultStatus === 'BLOCKED' - ? '缺少规格款' - : assemblyWarnings.length > 0 - ? '可预览但需确认' - : '可直接预览' - const combinationItems = buildCombinationItems(candidate) - const fastenerRecommendations = buildFastenerRecommendations( - candidate, - materialized.componentCodes, - ) - - return { - ...candidate, - previewMode: 'composite-ready', - statusTag, - estimatedCost: materialized.costPrice, - previewNodes: materialized.previewNodes, - assemblyResultStatus, - assemblyWarnings, - componentCodes: materialized.componentCodes, - bomPreview: materialized.bom, - installStepPreview: materialized.installStepPreview, - combinationItems, - candidateSnapshot: { - key: candidate.key, - defaultName: candidate.defaultName, - templateCode: templateConfig.code, - combinationItems, - }, - assemblyWorkspace: buildSingleBoardDeskAssemblyWorkspace({ - candidateKey: candidate.key, - defaultName: candidate.defaultName, - previewNodes: materialized.previewNodes, - slotSelections: materialized.selectedSlots, - connectorRecommendations: fastenerRecommendations, - }), - } - }) - - return { - seriesTemplateId: template.id, - totalCount: candidates.length, - candidates, - } - } -} - -export const combinationService = new CombinationService() -export { CombinationService } -export type { - CombinationCandidateDTO, - CombinationGenerationResult, - GenerateCombinationInput, -} from './combination.service.types' diff --git a/furniture-3d-system/src/server/services/combination.service.types.ts b/furniture-3d-system/src/server/services/combination.service.types.ts deleted file mode 100644 index 60551259..00000000 --- a/furniture-3d-system/src/server/services/combination.service.types.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { SingleBoardDeskCandidate } from '@/lib/open-product/single-board-desk' -import type { AssemblyResultStatus } from '@/lib/open-product/assembly-contract' -import type { - AssemblyWorkspaceSnapshot, - CombinationCandidateSnapshot, -} from '@/lib/open-product/assembly-workspace' -import type { - InstallPreviewStep, - PreSkuBomItem, - PreSkuPreviewNode, -} from '@/lib/open-product/types' - -export interface GenerateCombinationInput { - seriesTemplateId: string - desktopFamilyIds: string[] - legFamilyIds?: string[] - connectorFamilyIds?: string[] -} - -export interface CombinationCandidateItemDTO { - label: string - value: string - quantity: number - role?: string -} - -export interface CombinationCandidateDTO extends SingleBoardDeskCandidate { - previewMode: 'composite-ready' - previewNodes: PreSkuPreviewNode[] - assemblyResultStatus: AssemblyResultStatus - assemblyWarnings: string[] - componentCodes: string[] - bomPreview: PreSkuBomItem[] - installStepPreview: InstallPreviewStep[] - combinationItems: CombinationCandidateItemDTO[] - candidateSnapshot: CombinationCandidateSnapshot - assemblyWorkspace: AssemblyWorkspaceSnapshot -} - -export interface CombinationGenerationResult { - seriesTemplateId: string - totalCount: number - candidates: CombinationCandidateDTO[] -} diff --git a/furniture-3d-system/src/server/services/draft-group.mapper.ts b/furniture-3d-system/src/server/services/draft-group.mapper.ts index 6a0a2060..925c5119 100644 --- a/furniture-3d-system/src/server/services/draft-group.mapper.ts +++ b/furniture-3d-system/src/server/services/draft-group.mapper.ts @@ -174,6 +174,8 @@ export function normalizeAssemblyWorkspace(value: unknown): AssemblyWorkspaceSna return { version: '2026-04-13', + assetSeriesId: + typeof value.assetSeriesId === 'string' ? value.assetSeriesId : null, productCategoryId: typeof value.productCategoryId === 'string' ? value.productCategoryId : null, productCategoryName: @@ -226,9 +228,9 @@ export function toDraftGroupDTO(record: DraftGroupWithRelations): DraftGroupDTO description: record.description, categoryId: record.categoryId, categoryName: record.category?.name ?? null, - seriesTemplateId: record.seriesTemplateId ?? '', - seriesTemplateCode: record.seriesTemplate?.code ?? '', - seriesTemplateName: record.seriesTemplate?.name ?? '', + assetSeriesId: record.assetSeriesId ?? '', + assetSeriesCode: record.assetSeries?.code ?? '', + assetSeriesName: record.assetSeries?.name ?? '', status: record.status, summary: isRecord(record.summary) ? record.summary : null, notes: record.notes, diff --git a/furniture-3d-system/src/server/services/draft-group.service.ts b/furniture-3d-system/src/server/services/draft-group.service.ts index 4bfcc27a..735fc8af 100644 --- a/furniture-3d-system/src/server/services/draft-group.service.ts +++ b/furniture-3d-system/src/server/services/draft-group.service.ts @@ -151,6 +151,8 @@ class DraftGroupService { const assemblies = normalizeAssemblies(input) const resolvedCategoryId = input.categoryId ?? assemblies[0]?.assemblyWorkspace.productCategoryId ?? null + const resolvedAssetSeriesId = + input.assetSeriesId ?? assemblies[0]?.assemblyWorkspace.assetSeriesId ?? null if (!input.name.trim()) { throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '草稿组名称不能为空') @@ -170,6 +172,7 @@ class DraftGroupService { } as Prisma.InputJsonValue, notes: input.notes?.trim() || null, category: resolvedCategoryId ? { connect: { id: resolvedCategoryId } } : undefined, + assetSeries: resolvedAssetSeriesId ? { connect: { id: resolvedAssetSeriesId } } : undefined, creator: { connect: { id: user.id } }, }) diff --git a/furniture-3d-system/src/server/services/draft-group.service.types.ts b/furniture-3d-system/src/server/services/draft-group.service.types.ts index a1cd7b97..197d0f3c 100644 --- a/furniture-3d-system/src/server/services/draft-group.service.types.ts +++ b/furniture-3d-system/src/server/services/draft-group.service.types.ts @@ -41,6 +41,7 @@ export interface CreateDraftGroupFromManualAssemblyInput { name: string description?: string | null categoryId?: string | null + assetSeriesId?: string | null assemblies?: DraftGroupCandidateInput[] candidates?: DraftGroupCandidateInput[] notes?: string | null @@ -55,9 +56,9 @@ export interface DraftGroupDTO { description: string | null categoryId: string | null categoryName: string | null - seriesTemplateId: string - seriesTemplateCode: string - seriesTemplateName: string + assetSeriesId: string + assetSeriesCode: string + assetSeriesName: string status: DraftGroupStatus summary: Record | null notes: string | null diff --git a/furniture-3d-system/src/server/services/index.ts b/furniture-3d-system/src/server/services/index.ts index 9d61d6d2..90482cb8 100644 --- a/furniture-3d-system/src/server/services/index.ts +++ b/furniture-3d-system/src/server/services/index.ts @@ -162,10 +162,13 @@ export type { export type { AssetFamilyDTO, AssetReviewActionInput, + AssetSeriesDTO, + AssetSeriesSummaryDTO, AssetVariantDTO, CreateAssetVariantResultDTO, CreateAssetFamilyInput, CreateAssetVariantInput, + MaterialLikeDTO, SimilarAssetVariantCandidateDTO, CreateSubstrateDictionaryInput, CreateSubstrateInput, @@ -186,16 +189,6 @@ export type { SupportedAssetImportType, } from './asset-cad-import.service' -export { seriesTemplateService, SeriesTemplateService } from './series-template.service' -export type { SeriesTemplateDTO } from './series-template.service' - -export { combinationService, CombinationService } from './combination.service' -export type { - CombinationCandidateDTO, - CombinationGenerationResult, - GenerateCombinationInput, -} from './combination.service' - export { draftGroupService, DraftGroupService } from './draft-group.service' export type { CreateDraftGroupFromAutoCombinationInput, diff --git a/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts b/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts index 990bf2c9..297ca29b 100644 --- a/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts +++ b/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts @@ -10,6 +10,11 @@ import { buildStandardFinishMaterialData } from '@/lib/standard-finish-sync' import type { OpenProductMountPoint } from '@/lib/open-product/types' import { AppError, ErrorCode } from '@/server/core/errors' import type { AuthUser } from '@/server/core/types' +import { + buildAssemblyWorkspaceFamilies, + buildManualAssemblyWorkspace, + instantiateAssemblyNode, +} from '@/lib/open-product/assembly-workspace' import { assetCenterService } from './asset-center.service' import type { AssetFamilyDTO, @@ -17,15 +22,13 @@ import type { CreateAssetFamilyInput, CreateAssetVariantInput, } from './asset-center.service.types' -import { combinationService } from './combination.service' import { draftGroupService } from './draft-group.service' import type { - DraftGroupCandidateInput, + DraftGroupAssemblyInput, PreSkuPackagingInputDTO, SavePreSkuPackagingPlanInput, } from './draft-group.service.types' import { intakeService } from './intake.service' -import { seriesTemplateService } from './series-template.service' import { warehouseService } from './warehouse.service' const OPEN_PLATFORM_DEMO_SEED_MARKER = '[seed:open-platform-demo/v1]' @@ -148,15 +151,6 @@ function toAuthUser(user: { } } -export function pickSeedCandidates(candidates: T[]): T[] { - if (candidates.length <= 2) { - return candidates - } - - const picked = [candidates[0], candidates[candidates.length - 1]] - return picked[0].key === picked[1].key ? [picked[0]] : picked -} - export function buildSeedPackagingPlanInput( packagingInput: PreSkuPackagingInputDTO, materials: { @@ -397,11 +391,11 @@ async function ensureFamilyAvailable( const family = existing ? await assetCenterService.updateFamily(existing.id, { code: definition.code, - name: definition.name, - description: definition.description ?? null, - productCategoryId: definition.productCategoryId ?? null, - applicableSeriesCodes: definition.applicableSeriesCodes ?? [], - connectionRoles: definition.connectionRoles ?? [], + name: definition.name, + description: definition.description ?? null, + productCategoryId: definition.productCategoryId ?? null, + compatibleSeriesIds: definition.compatibleSeriesIds ?? [], + connectionRoles: definition.connectionRoles ?? [], metadata: definition.metadata ?? null, updatedBy: creator.id, }) @@ -534,7 +528,10 @@ async function cleanupPreviousSeedArtifacts(): Promise { } } -function buildDemoFamilyDefinitions(categoryId: string): Record { +function buildDemoFamilyDefinitions( + categoryId: string, + assetSeriesId: string, +): Record { return { desktopA: { type: 'BOARD', @@ -542,7 +539,7 @@ function buildDemoFamilyDefinitions(categoryId: string): Record { - const familyDefinitions = buildDemoFamilyDefinitions(input.categoryId) + const familyDefinitions = buildDemoFamilyDefinitions(input.categoryId, input.assetSeriesId) const desktopAFamily = await ensureFamilyAvailable( familyDefinitions.desktopA, @@ -1018,8 +1016,16 @@ class OpenPlatformDemoSeedService { await cleanupPreviousSeedArtifacts() + const assetSeries = (await assetCenterService.listSeries()).find( + (item) => item.code === 'single-board-desk', + ) + if (!assetSeries) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '未找到单板桌资产系列') + } + const assets = await ensureDemoAssets({ categoryId: category.id, + assetSeriesId: assetSeries.id, substrateId: substrateRecord.id, finish: { id: finishBundle.material.id, @@ -1029,30 +1035,52 @@ class OpenPlatformDemoSeedService { reviewer, }) - const templates = await seriesTemplateService.listTemplates() - const template = templates.find((item) => item.code === 'single-board-desk') - if (!template) { - throw new AppError(ErrorCode.SERIES_TEMPLATE_NOT_FOUND, '未找到单板桌系列模板') - } - - const generated = await combinationService.generateCandidates({ - seriesTemplateId: template.id, - desktopFamilyIds: [assets.desktopA.family.id, assets.desktopB.family.id], - legFamilyIds: [assets.legSlim.family.id, assets.legRound.family.id], - connectorFamilyIds: [assets.connector.family.id], + const defaultVariantIdsByFamilyId = new Map([ + [assets.desktopA.family.id, assets.desktopA.variant.id], + ]) + const baseWorkspace = buildManualAssemblyWorkspace({ + assetSeriesId: assetSeries.id, + productCategoryId: category.id, + productCategoryName: category.name, + source: 'assisted-assembly', + entryMode: 'direct', + families: buildAssemblyWorkspaceFamilies( + [ + { ...assets.desktopA.family, assetType: assets.desktopA.family.type }, + ], + defaultVariantIdsByFamilyId, + ), }) + const desktopInventory = baseWorkspace.inventory.find( + (item) => item.familyId === assets.desktopA.family.id, + ) + const assemblyWorkspace = desktopInventory + ? instantiateAssemblyNode(baseWorkspace, { + inventoryId: desktopInventory.inventoryId, + variant: assets.desktopA.variant, + }) + : baseWorkspace + const selectedAssemblies: DraftGroupAssemblyInput[] = [ + { + key: 'seed-desktop-a', + defaultName: assets.desktopA.variant.name, + source: 'assisted-assembly', + assemblyWorkspace, + selectedFamilyIdsByType: { + desktopFamilyIds: [assets.desktopA.family.id], + legFamilyIds: [], + connectorFamilyIds: [], + }, + }, + ] - const selectedCandidates = pickSeedCandidates(generated.candidates as DraftGroupCandidateInput[]) - if (selectedCandidates.length === 0) { - throw new AppError(ErrorCode.VALIDATION_FAILED, '辅助装配没有生成候选,无法创建演示草稿组') - } - - const draftGroupDetail = await draftGroupService.createFromAutoCombination( + const draftGroupDetail = await draftGroupService.createFromManualAssembly( { name: OPEN_PLATFORM_DEMO_DRAFT_GROUP_NAME, description: '开放平台主链联调用演示草稿组', categoryId: category.id, - candidates: selectedCandidates, + assetSeriesId: assetSeries.id, + assemblies: selectedAssemblies, notes: `${OPEN_PLATFORM_DEMO_SEED_MARKER} 自动创建`, }, planner, diff --git a/furniture-3d-system/src/server/services/series-template.service.ts b/furniture-3d-system/src/server/services/series-template.service.ts deleted file mode 100644 index 5a0bdc24..00000000 --- a/furniture-3d-system/src/server/services/series-template.service.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Prisma } from '@prisma/client' -import { AppError, ErrorCode } from '@/server/core/errors' -import { SINGLE_BOARD_DESK_SLOT_SCHEMA } from '@/lib/open-product/assembly-contract' -import { seriesTemplateRepository } from '../repositories' - -export interface SeriesTemplateDTO { - id: string - code: string - name: string - seriesName: string - description: string | null - categoryId: string | null - categoryName: string | null - slotSchema: Record - namingRule: Record | null - recommendationRule: Record | null - previewRule: Record | null - intakeRule: Record | null - isPreset: boolean - isActive: boolean - createdAt: Date - updatedAt: Date -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function toDTO( - record: NonNullable>>, -): SeriesTemplateDTO { - return { - id: record.id, - code: record.code, - name: record.name, - seriesName: record.seriesName, - description: record.description, - categoryId: record.categoryId, - categoryName: record.category?.name ?? null, - slotSchema: isRecord(record.slotSchema) ? record.slotSchema : {}, - namingRule: isRecord(record.namingRule) ? record.namingRule : null, - recommendationRule: isRecord(record.recommendationRule) ? record.recommendationRule : null, - previewRule: isRecord(record.previewRule) ? record.previewRule : null, - intakeRule: isRecord(record.intakeRule) ? record.intakeRule : null, - isPreset: record.isPreset, - isActive: record.isActive, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - } -} - -class SeriesTemplateService { - private async ensurePresetSingleBoardDeskTemplate(): Promise { - const existing = await seriesTemplateRepository.findByCode('single-board-desk') - if (existing) return - - await seriesTemplateRepository.createWithRelations({ - code: 'single-board-desk', - name: '单板桌', - seriesName: '云序', - description: '一期主链固定模板:桌面板 + 四个独立腿位 + 自动连接件推荐', - slotSchema: { - slots: SINGLE_BOARD_DESK_SLOT_SCHEMA, - } as unknown as Prisma.InputJsonValue, - namingRule: { - defaultPattern: '{seriesName}-{desktopFamilyName}-{legFamilyName}', - }, - recommendationRule: { - autoConnectorRoles: ['desk_leg_connector'], - }, - previewRule: { - desktopHeight: 720, - desktopPosition: [0, 720, 0], - legOffsets: { - frontLeft: [-450, 360, 250], - frontRight: [450, 360, 250], - rearLeft: [-450, 360, -250], - rearRight: [450, 360, -250], - }, - }, - intakeRule: { - allowCreateProduct: true, - allowAttachExistingProduct: true, - }, - isPreset: true, - isActive: true, - }) - } - - async listTemplates(): Promise { - await this.ensurePresetSingleBoardDeskTemplate() - const records = await seriesTemplateRepository.findAllActiveWithRelations() - return records.map((record) => toDTO(record)) - } - - async getById(id: string): Promise { - await this.ensurePresetSingleBoardDeskTemplate() - const record = await seriesTemplateRepository.findByIdWithRelations(id) - if (!record) { - throw new AppError(ErrorCode.SERIES_TEMPLATE_NOT_FOUND, '系列模板不存在') - } - - return toDTO(record) - } -} - -export const seriesTemplateService = new SeriesTemplateService() -export { SeriesTemplateService } diff --git a/furniture-3d-system/src/server/services/single-board-desk-materialization.ts b/furniture-3d-system/src/server/services/single-board-desk-materialization.ts index f8d7e7df..27db6437 100644 --- a/furniture-3d-system/src/server/services/single-board-desk-materialization.ts +++ b/furniture-3d-system/src/server/services/single-board-desk-materialization.ts @@ -5,50 +5,11 @@ import { } from '@/lib/open-product/single-board-desk' import type { OpenProductAssetVariant } from '@/lib/open-product/types' import type { AssemblyResultStatus } from '@/lib/open-product/assembly-contract' -import { seriesTemplateRepository } from '../repositories' function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -export function buildSingleBoardDeskTemplate( - record: NonNullable>>, -): SingleBoardDeskTemplate { - const previewRule = isRecord(record.previewRule) ? record.previewRule : {} - const legOffsets = isRecord(previewRule.legOffsets) ? previewRule.legOffsets : {} - - return { - code: record.code, - name: record.name, - displayName: record.name, - naming: { - seriesName: record.seriesName, - }, - previewLayout: { - desktopHeight: - typeof previewRule.desktopHeight === 'number' ? previewRule.desktopHeight : 720, - desktopPosition: Array.isArray(previewRule.desktopPosition) - ? (previewRule.desktopPosition as [number, number, number]) - : [0, 720, 0], - legOffsets: { - frontLeft: Array.isArray(legOffsets.frontLeft) - ? (legOffsets.frontLeft as [number, number, number]) - : [-450, 360, 250], - frontRight: Array.isArray(legOffsets.frontRight) - ? (legOffsets.frontRight as [number, number, number]) - : [450, 360, 250], - rearLeft: Array.isArray(legOffsets.rearLeft) - ? (legOffsets.rearLeft as [number, number, number]) - : [-450, 360, -250], - rearRight: Array.isArray(legOffsets.rearRight) - ? (legOffsets.rearRight as [number, number, number]) - : [450, 360, -250], - }, - }, - autoConnectorRoles: ['desk_leg_connector'], - } -} - export function toOpenProductVariants( records: Awaited>, ): OpenProductAssetVariant[] { From 331aac6961e50ac22f89fa6f7cfb360d44b24b50 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Sat, 16 May 2026 11:05:15 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(assisted-assembly):=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E7=B3=BB=E5=88=97=E7=89=A9=E6=96=99=E5=8C=BA=E4=B8=8E?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=AD=94=E4=BD=8D=E9=AB=98=E4=BA=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/assisted-assembly-page.test.tsx | 160 +++- .../AssistedAssemblyClient.test.tsx | 96 ++- .../AssistedAssemblyClient.tsx | 231 +++++- .../src/app/assisted-assembly/page.tsx | 95 ++- .../platform/AssemblyWorkbenchV2.tsx | 709 ++++++++++++++---- .../__tests__/AssemblyWorkbenchV2.test.tsx | 87 ++- .../assisted-assembly-variant.test.ts | 25 +- .../lib/open-product/assembly-workspace.ts | 22 + .../open-product/assisted-assembly-variant.ts | 5 +- .../src/lib/open-product/platform-api.ts | 43 ++ 10 files changed, 1288 insertions(+), 185 deletions(-) diff --git a/furniture-3d-system/src/app/__tests__/assisted-assembly-page.test.tsx b/furniture-3d-system/src/app/__tests__/assisted-assembly-page.test.tsx index ca0c00d6..99c2e221 100644 --- a/furniture-3d-system/src/app/__tests__/assisted-assembly-page.test.tsx +++ b/furniture-3d-system/src/app/__tests__/assisted-assembly-page.test.tsx @@ -47,6 +47,16 @@ jest.mock('next/headers', () => ({ jest.mock('@/server/services', () => ({ assetCenterService: { + listSeries: jest.fn().mockResolvedValue([ + { + id: 'series-single-board-desk', + code: 'single-board-desk', + name: '单板桌', + description: null, + sortOrder: 0, + isActive: true, + }, + ]), listFamilies: jest.fn().mockResolvedValue([ { id: 'family-board', @@ -149,6 +159,17 @@ jest.mock('../assisted-assembly/AssistedAssemblyClient', () => ({ describe('AssistedAssemblyPage', () => { beforeEach(() => { + jest.clearAllMocks() + jest.mocked(assetCenterService.listSeries).mockResolvedValue([ + { + id: 'series-single-board-desk', + code: 'single-board-desk', + name: '单板桌', + description: null, + sortOrder: 0, + isActive: true, + } as never, + ]) jest.mocked(assetCenterService.listFamilies).mockResolvedValue([ { id: 'family-board', @@ -195,24 +216,26 @@ describe('AssistedAssemblyPage', () => { jest.mocked(canViewProduct).mockReturnValue(true as never) }) - it('组件 / 五金统计不应把包装材料一起算进去', async () => { + it('直接进入时会等待用户先选择系列,不预加载全量组件和五金', async () => { render(await AssistedAssemblyPage()) const metricLabel = screen.getByText('组件 / 五金') const metricCard = metricLabel.closest('article') expect(metricCard).not.toBeNull() - expect(within(metricCard as HTMLElement).getByText('2')).toBeInTheDocument() + expect(within(metricCard as HTMLElement).getByText('0')).toBeInTheDocument() + expect(assetCenterService.listFamilies).not.toHaveBeenCalled() + expect(assetCenterService.listVariantsByFamilyIds).not.toHaveBeenCalled() }) - it('板材资产族统计会按辅助装配实际可用资产族计算', async () => { + it('直接进入时板材物料池为空,等系列选择后再加载', async () => { render(await AssistedAssemblyPage()) const metricLabel = screen.getByText('板材资产族') const metricCard = metricLabel.closest('article') expect(metricCard).not.toBeNull() - expect(within(metricCard as HTMLElement).getByText('2')).toBeInTheDocument() + expect(within(metricCard as HTMLElement).getByText('0')).toBeInTheDocument() }) it('资产中心带草稿资产族进入时,会把来源规格款一并带入拼装页', async () => { @@ -231,6 +254,124 @@ describe('AssistedAssemblyPage', () => { expect(screen.getByTestId('launch-entry-mode')).toHaveTextContent('asset-center') }) + it('资产中心带系列进入时,会按当前系列收窄物料池并保留五金', async () => { + jest.mocked(assetCenterService.listFamilies).mockResolvedValueOnce([ + { + id: 'family-board-series', + type: 'BOARD', + status: 'AVAILABLE', + productCategoryId: 'category-desk', + productCategoryName: '书桌', + } as never, + { + id: 'family-hardware-global', + type: 'HARDWARE', + status: 'AVAILABLE', + productCategoryId: null, + productCategoryName: null, + } as never, + ]) + jest.mocked(assetCenterService.listVariantsByFamilyIds).mockResolvedValueOnce([ + { + id: 'variant-board-series', + assetFamilyId: 'family-board-series', + status: 'AVAILABLE', + } as never, + ]) + + render( + await AssistedAssemblyPage({ + searchParams: Promise.resolve({ + entryMode: 'asset-center', + source: 'asset-center', + assetSeriesId: 'series-single-board-desk', + desktopFamilyIds: 'family-board-series', + }), + }), + ) + + expect(assetCenterService.listFamilies).toHaveBeenNthCalledWith(1, { + assetSeriesId: 'series-single-board-desk', + }) + expect(screen.getByTestId('family-ids')).toHaveTextContent( + 'family-board-series,family-hardware-global', + ) + expect(assetCenterService.listVariantsByFamilyIds).toHaveBeenCalledWith([ + 'family-board-series', + 'family-hardware-global', + ]) + }) + + it('资产中心带系列进入时,会补回 URL 指定的来源资产族,避免来源物料丢失', async () => { + jest.mocked(assetCenterService.listFamilies) + .mockResolvedValueOnce([ + { + id: 'family-board-series', + type: 'BOARD', + status: 'AVAILABLE', + productCategoryId: 'category-desk', + productCategoryName: '书桌', + } as never, + { + id: 'family-hardware-global', + type: 'HARDWARE', + status: 'AVAILABLE', + productCategoryId: null, + productCategoryName: null, + } as never, + ]) + .mockResolvedValueOnce([ + { + id: 'family-board-series', + type: 'BOARD', + status: 'AVAILABLE', + productCategoryId: 'category-desk', + productCategoryName: '书桌', + } as never, + { + id: 'family-board-source', + type: 'BOARD', + status: 'DISABLED', + productCategoryId: 'category-desk', + productCategoryName: '书桌', + } as never, + { + id: 'family-hardware-global', + type: 'HARDWARE', + status: 'AVAILABLE', + productCategoryId: null, + productCategoryName: null, + } as never, + ]) + jest.mocked(assetCenterService.listVariantsByFamilyIds).mockResolvedValueOnce([ + { + id: 'variant-board-source', + assetFamilyId: 'family-board-source', + status: 'DRAFT', + } as never, + ]) + + render( + await AssistedAssemblyPage({ + searchParams: Promise.resolve({ + entryMode: 'asset-center', + source: 'asset-center', + assetSeriesId: 'series-single-board-desk', + desktopFamilyIds: 'family-board-source', + }), + }), + ) + + expect(assetCenterService.listFamilies).toHaveBeenNthCalledWith(1, { + assetSeriesId: 'series-single-board-desk', + }) + expect(assetCenterService.listFamilies).toHaveBeenNthCalledWith(2) + expect(screen.getByTestId('family-ids')).toHaveTextContent( + 'family-board-series,family-hardware-global,family-board-source', + ) + expect(screen.getByTestId('variant-ids')).toHaveTextContent('variant-board-source') + }) + it('源 SKU 新增时,会优先恢复 displaySummary 里的 assemblyWorkspace,而不是退回 previewNodes fallback', async () => { const sourceWorkspace = buildManualAssemblyWorkspace({ productCategoryId: 'category-desk', @@ -474,7 +615,16 @@ describe('AssistedAssemblyPage', () => { } as never, ]) - render(await AssistedAssemblyPage()) + render( + await AssistedAssemblyPage({ + searchParams: Promise.resolve({ + entryMode: 'asset-center', + source: 'asset-center', + assetSeriesId: 'series-single-board-desk', + desktopFamilyIds: 'family-board', + }), + }), + ) expect(screen.getByTestId('variant-uv-repeat')).toHaveTextContent('2') expect(screen.getByTestId('variant-material-repeat')).toHaveTextContent('2') diff --git a/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.test.tsx b/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.test.tsx index 1ebf7e4f..c9cfd69c 100644 --- a/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.test.tsx +++ b/furniture-3d-system/src/app/assisted-assembly/AssistedAssemblyClient.test.tsx @@ -2,7 +2,11 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { ReactNode } from 'react' import AssistedAssemblyClient from './AssistedAssemblyClient' -import { createDraftGroup } from '@/lib/open-product/platform-api' +import { + createDraftGroup, + listAssetFamilies, + listAssetVariantsByFamilyIds, +} from '@/lib/open-product/platform-api' import type { AssemblyWorkspaceSnapshot } from '@/lib/open-product/assembly-workspace' let mockSearchParams = new URLSearchParams() @@ -23,10 +27,16 @@ jest.mock('@/components/platform/AssemblyWorkbenchV2', () => ({ __esModule: true, AssemblyWorkbenchV2: ({ value, + availableVariants, + selectedAssetSeriesId, + onAssetSeriesSelect, onChange, rightDrawerContent, }: { value: AssemblyWorkspaceSnapshot + availableVariants?: Array<{ id: string }> + selectedAssetSeriesId?: string | null + onAssetSeriesSelect?: (assetSeriesId: string) => void onChange: (next: AssemblyWorkspaceSnapshot) => void rightDrawerContent?: ReactNode }) => ( @@ -35,7 +45,12 @@ jest.mock('@/components/platform/AssemblyWorkbenchV2', () => ({

辅助拼装工作台
库存:{value.inventory.length}
节点:{value.nodes.length}
+
当前系列:{selectedAssetSeriesId ?? 'none'}
+
可用规格:{availableVariants?.length ?? 0}
首个规格款:{value.inventory[0]?.selectedVariantId ?? 'none'}
+ + ) + }) + )} + + {isAssetSeriesLoading ? ( +
+ 正在加载物料 +
+ ) : null} +
+
+
+ ) : activeAssetBrowserSource ? ( +
+
+ {activeAssetBrowserItems.length === 0 ? ( +
+ 暂无资产族 +
+ ) : ( + activeAssetBrowserItems.map((item) => { + const previewVariant = getInventoryPreviewVariant(item, availableVariantMap) + const selected = selectedBrowserInventoryItem?.inventoryId === item.inventoryId + const itemVariants = availableVariantMap.get(item.familyId) ?? [] + const SourceIcon = + ASSET_BROWSER_TABS.find((tab) => tab.source === item.source)?.icon ?? Layers3 + return ( +
+ } + /> + ) : ( +
+ +
+ )} +
+ {selected ? ( +
+ ) : null} +
+

+ {item.familyCode} +

+

+ {item.familyName} +

+

+ {itemVariants.length} 款 · 入场 {item.instanceCount} +

+
+ + ) + }) + )} +
+ + {selectedBrowserInventoryItem ? ( +
+
+ {(availableVariantMap.get(selectedBrowserInventoryItem.familyId) ?? []).length > 0 ? ( + (availableVariantMap.get(selectedBrowserInventoryItem.familyId) ?? []).map((variant) => ( +
+ } + /> +
+
+

+ {getVariantMeta(variant)} +

+

+ {variant.name} +

+
+ + )) + ) : ( +
+ 当前资产族暂无可加载规格款 +
+ )} +
+
+ ) : null} +
+ ) : null + + const SeriesIcon = SERIES_BROWSER_TAB.icon const assetBrowserToolbar = ( -
+
{ + const nextTarget = event.relatedTarget + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + return + } + scheduleAssetBrowserClose() + }} + className="relative flex flex-col items-center gap-1.5" + > + {ASSET_BROWSER_TABS.map((tab) => { const Icon = tab.icon const isActive = activeAssetBrowserSource === tab.source const count = presentWorkspace.inventory.filter((item) => item.source === tab.source).length return ( ) })} + {assetBrowserPanel}
) - const assetBrowserPanel = activeAssetBrowserSource ? ( -
-
- {activeAssetBrowserItems.length === 0 ? ( -
- 当前分类暂无可装配资产族 -
- ) : ( -
- {activeAssetBrowserItems.map((item) => { - const previewVariant = getInventoryPreviewVariant(item, availableVariantMap) - const selected = selectedBrowserInventoryItem?.inventoryId === item.inventoryId - const itemVariants = availableVariantMap.get(item.familyId) ?? [] - const SourceIcon = - ASSET_BROWSER_TABS.find((tab) => tab.source === item.source)?.icon ?? Layers3 - return ( -
-
- } - /> - ) : ( -
- -
- )} -
- {selected ? ( -
- ) : null} -
-

- {item.familyCode} -

-

- {item.familyName} -

-

- {itemVariants.length} 款 · 入场 {item.instanceCount} -

-
- - - {selected ? ( -
- {itemVariants.length > 0 ? ( - itemVariants.map((variant) => ( -
- } - /> -
-
-

- {getVariantMeta(variant)} -

-

- {variant.name} -

-
- - )) - ) : ( -
- 当前资产族暂无可加载规格款 -
- )} -
- ) : null} -
- )} - )} -
- )} -
-
- ) : null - const rightDrawer = rightDrawerContent ? (
setIsRightDrawerOpen(true)} @@ -1000,8 +1439,6 @@ export function AssemblyWorkbenchV2({ panelLabelByPanelId={holeOverlayState.panelLabelByPanelId} highlightedNodeNames={highlightedNodeNames} onHoleHover={handleHoleHover} - overlayLeft={assetBrowserPanel} - overlayLeftClassName="left-[92px] top-1/2 h-auto -translate-y-1/2" overlayBottomCenter={rightDrawer} extraSidebarActions={ <> 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 8764a902..c559b721 100644 --- a/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx +++ b/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx @@ -121,6 +121,7 @@ jest.mock('../ModelPreviewPanel', () => {
{holes[0] ? `${holes[0].diameter ?? 'none'}/${holes[0].depth ?? 'none'}` : 'none'}
+
{holes[0]?.holeType ?? 'none'}
激活孔位面板:{activeHolePanelIds.length}
高亮节点:{highlightedNodeNames.length}
{highlightedNodeNames.join(',') || 'none'}
@@ -421,6 +422,51 @@ function buildHoleSnapWorkspace() { }) } +function buildSingleBoardHoleWorkspace() { + return instantiateAssemblyNode(buildWorkspace(), { + inventoryId: 'desktop:family-board-1:1', + variant: { + id: 'variant-board-1', + assetFamilyId: 'family-board-1', + assetType: 'BOARD', + code: 'BOARD-A-001', + name: '桌面A-001', + status: 'AVAILABLE', + modelUrl: '/board.glb', + thumbnailUrl: null, + dimensions: { length: 1200, width: 600, thickness: 25 }, + mountPoints: [], + assemblyBasis: { + anchor: 'desktop-center', + holes: [ + { + id: 'board-hole-1', + panelId: 'board-panel', + position: [0, 0, 0], + normal: [0, -1, 0], + diameter: 8, + depth: 12, + holeType: 'assembly-active', + holeGroupId: 'board-hole-group', + surfaceFaces: ['top'], + }, + ], + holeGroups: [ + { + id: 'board-hole-group', + panelId: 'board-panel', + groupType: 'pair', + mountFace: 'top', + holeIds: ['board-hole-1'], + surfaceFaces: ['top'], + }, + ], + }, + materialConfig: AVAILABLE_VARIANTS[0]?.materialConfig ?? null, + } as never, + }) +} + function buildMissingSnapSemanticWorkspace() { const baseWorkspace = buildWorkspace() const withDesktop = instantiateAssemblyNode(baseWorkspace, { @@ -529,14 +575,16 @@ describe('AssemblyWorkbenchV2', () => { expect(screen.getByTestId('world-node-count')).toHaveTextContent('世界节点:0') 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: '打开组件物料' })).toBeInTheDocument() expect(screen.getByRole('button', { name: '打开五金物料' })).toBeDisabled() expect(screen.queryByTestId('asset-browser-panel')).not.toBeInTheDocument() - await actor.click(screen.getByRole('button', { name: '打开板材物料' })) + await actor.hover(screen.getByRole('button', { name: '打开板材物料' })) expect(screen.getByTestId('asset-browser-panel')).toBeInTheDocument() + expect(screen.getByTestId('asset-family-rail')).toBeInTheDocument() expect(screen.queryByText('板材资产族')).not.toBeInTheDocument() expect(screen.queryByText('MATERIAL BROWSER')).not.toBeInTheDocument() expect(screen.queryByText('VARIANTS')).not.toBeInTheDocument() @@ -544,6 +592,22 @@ describe('AssemblyWorkbenchV2', () => { expect(screen.getByRole('button', { name: '加载桌面A-001到 3D 世界' })).toBeInTheDocument() }) + it('鼠标离开物料交互区域后会收起浮层', async () => { + const actor = userEvent.setup() + + render(右侧业务区
} />) + + await actor.hover(screen.getByRole('button', { name: '打开板材物料' })) + + expect(screen.getByTestId('asset-browser-panel')).toBeInTheDocument() + + fireEvent.pointerMove(document.body) + + await waitFor(() => { + expect(screen.queryByTestId('asset-browser-panel')).not.toBeInTheDocument() + }) + }) + it('点击规格款会直接加入 3D 世界,并保持统一选中态', async () => { const actor = userEvent.setup() const onChange = jest.fn() @@ -673,6 +737,27 @@ describe('AssemblyWorkbenchV2', () => { }) }) + it('选中只有自身孔位语义的节点后,也会显示未占用孔位高亮', async () => { + const actor = userEvent.setup() + + render( + 右侧业务区
} + />, + ) + + await actor.click(screen.getByRole('button', { name: '模拟选中首节点' })) + + await waitFor(() => { + expect(screen.getByTestId('selected-node')).not.toHaveTextContent('none') + expect(screen.getByTestId('hole-overlay-count')).toHaveTextContent('孔位覆盖:1') + expect(screen.getByTestId('hole-overlay-first-metrics')).toHaveTextContent('8/12') + expect(screen.getByTestId('hole-overlay-first-type')).toHaveTextContent('assembly-active') + expect(screen.getByTestId('active-hole-panel-count')).toHaveTextContent('激活孔位面板:0') + }) + }) + it('hover 已使用孔位时,会高亮占用该孔位的节点', async () => { const actor = userEvent.setup() const preview = applyAutomaticAssemblySnapPreview(buildHoleSnapWorkspace(), 'node-2') 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 index bc7bc3b6..c1b8baca 100644 --- 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 @@ -5,10 +5,10 @@ import { } from '../assisted-assembly-variant' describe('assisted assembly variant thumbnails', () => { - it('falls back from generated thumbnails to material preview assets', () => { + it('uses generated model thumbnails before persisted model thumbnails', () => { const variant = { runtimeThumbnailUrl: '/internal/runtime.png', - thumbnailUrl: null, + thumbnailUrl: '/asset-thumbnails/board.png', materialConfig: { id: 'finish-oak', name: '暖白橡木', @@ -21,12 +21,11 @@ describe('assisted assembly variant thumbnails', () => { expect(getAssistedAssemblyVariantThumbnailUrl(variant)).toBe('/internal/runtime.png') expect(getAssistedAssemblyVariantThumbnailSources(variant)).toEqual([ '/internal/runtime.png', - '/materials/thumbnails/oak.jpg', - '/materials/pbr/oak/oak_col.jpg', + '/asset-thumbnails/board.png', ]) }) - it('uses material thumbnails when the variant has no generated thumbnail', () => { + it('does not use material textures as assisted assembly model thumbnails', () => { const variant = { thumbnailUrl: null, runtimeThumbnailUrl: null, @@ -38,9 +37,17 @@ describe('assisted assembly variant thumbnails', () => { }, } as AssistedAssemblyVariant - expect(getAssistedAssemblyVariantThumbnailUrl(variant)).toBe('/materials/thumbnails/walnut.jpg') - expect(getAssistedAssemblyVariantThumbnailSources(variant)).toEqual([ - '/materials/thumbnails/walnut.jpg', - ]) + expect(getAssistedAssemblyVariantThumbnailUrl(variant)).toBeNull() + expect(getAssistedAssemblyVariantThumbnailSources(variant)).toEqual([]) + }) + + it('skips asset placeholder thumbnails so the workbench can render a clearer fallback', () => { + const variant = { + thumbnailUrl: '/asset-thumbnails/placeholder.svg', + runtimeThumbnailUrl: null, + materialConfig: null, + } as AssistedAssemblyVariant + + expect(getAssistedAssemblyVariantThumbnailSources(variant)).toEqual([]) }) }) 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 5f1d25b1..44445cab 100644 --- a/furniture-3d-system/src/lib/open-product/assembly-workspace.ts +++ b/furniture-3d-system/src/lib/open-product/assembly-workspace.ts @@ -269,6 +269,16 @@ interface AssemblyBasisHoleRecord { surfaceFaces: string[] } +export interface AssemblyNodeHoleMarker { + id: string + panelId: string | null + position: [number, number, number] + normal: [number, number, number] + diameter: number | null + depth: number | null + holeType: string | null +} + interface AssemblyBasisMountPatternRecord { id: string panelId: string | null @@ -716,6 +726,18 @@ function getNodeAssemblyHoles(node: AssemblyWorkspaceNode): AssemblyBasisHoleRec .filter((item): item is AssemblyBasisHoleRecord => item !== null) } +export function getAssemblyNodeHoleMarkers(node: AssemblyWorkspaceNode): AssemblyNodeHoleMarker[] { + return getNodeAssemblyHoles(node).map((hole) => ({ + id: hole.id, + panelId: hole.panelId, + position: tupleFromVector(getNodeWorldPoint(node, hole.position)), + normal: tupleFromVector(getNodeWorldDirection(node, hole.normal)), + diameter: hole.diameter, + depth: hole.depth, + holeType: hole.holeType, + })) +} + export interface AssemblyAutoSnapSupportStatus { canAutoSnap: boolean missingHoles: boolean 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 699cbd54..d0553b8a 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 @@ -29,8 +29,6 @@ export function getAssistedAssemblyVariantThumbnailUrl( return ( variant?.runtimeThumbnailUrl ?? variant?.thumbnailUrl ?? - variant?.materialConfig?.thumbnailUrl ?? - variant?.materialConfig?.textureUrl ?? null ) } @@ -41,11 +39,10 @@ export function getAssistedAssemblyVariantThumbnailSources( 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)) + .filter((value) => !value.includes('/asset-thumbnails/placeholder.svg')) return Array.from(new Set(sources)) } diff --git a/furniture-3d-system/src/lib/open-product/platform-api.ts b/furniture-3d-system/src/lib/open-product/platform-api.ts index 5ba519db..c16ed17d 100644 --- a/furniture-3d-system/src/lib/open-product/platform-api.ts +++ b/furniture-3d-system/src/lib/open-product/platform-api.ts @@ -3,6 +3,7 @@ import type { AssetFamilyDTO, AssetCadImportResult, + AssetSeriesDTO, AssetVariantDTO, AssetVariantBindingDTO, PreSkuBindingDTO, @@ -67,6 +68,48 @@ export async function createAssetFamily(input: CreateAssetFamilyInput): Promise< return data.family } +export async function listAssetSeries(): Promise { + const data = await requestPlatform<{ series: AssetSeriesDTO[] }>('/api/asset-center/series') + return data.series +} + +export async function listAssetFamilies(input?: { + assetSeriesId?: string | null + type?: 'BOARD' | 'COMPONENT' | 'HARDWARE' | 'PACKAGING' | null + status?: 'DRAFT' | 'PENDING_REVIEW' | 'REJECTED' | 'AVAILABLE' | 'DISABLED' | null +}): Promise { + const searchParams = new URLSearchParams() + if (input?.assetSeriesId) searchParams.set('assetSeriesId', input.assetSeriesId) + if (input?.type) searchParams.set('type', input.type) + if (input?.status) searchParams.set('status', input.status) + const query = searchParams.toString() + const data = await requestPlatform<{ families: AssetFamilyDTO[] }>( + `/api/asset-center/families${query ? `?${query}` : ''}`, + ) + return data.families +} + +export async function listAssetVariantsByFamilyIds( + familyIds: string[], + input?: { + status?: 'DRAFT' | 'PENDING_REVIEW' | 'REJECTED' | 'AVAILABLE' | 'DISABLED' | null + }, +): Promise { + if (familyIds.length === 0) { + return [] + } + + const searchParams = new URLSearchParams() + familyIds.forEach((familyId) => { + searchParams.append('familyId', familyId) + }) + if (input?.status) searchParams.set('status', input.status) + const data = await requestPlatform<{ variants: AssetVariantDTO[] }>( + `/api/asset-center/variants?${searchParams.toString()}`, + ) + return data.variants +} + export async function updateAssetFamily( id: string, input: Record, From d661273b23e5f87fd274737e2832717617a52baf Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 18 May 2026 14:04:29 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(assisted-assembly):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=A4=9A=E5=AD=94=E5=90=B8=E9=99=84=E4=B8=8E=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/3d/HoleOverlay2D.tsx | 222 +++- .../src/components/3d/ModelViewer.tsx | 154 ++- .../3d/__tests__/ModelViewer.test.tsx | 127 +- .../3d/__tests__/hole-overlay-binding.test.ts | 124 ++ .../src/components/3d/hole-viewer.types.ts | 10 + .../platform/AssemblyWorkbenchV2.tsx | 318 +++-- .../AssetVariantThumbnailRenderer.tsx | 21 +- .../components/platform/ModelPreviewPanel.tsx | 7 +- .../__tests__/AssemblyWorkbenchV2.test.tsx | 273 +++- .../AssetVariantThumbnailRenderer.test.tsx | 36 + .../src/components/ui/ContextMenu.tsx | 2 +- .../ui/__tests__/ContextMenu.test.tsx | 52 + furniture-3d-system/src/lib/3d/engine.ts | 138 +- .../__tests__/assembly-workspace.test.ts | 951 +++++++++++++- .../lib/open-product/assembly-workspace.ts | 1154 ++++++++++++++--- .../__tests__/asset-center.service.test.ts | 120 ++ .../asset-variant-thumbnail.service.test.ts | 25 + .../server/services/asset-center.service.ts | 17 +- .../asset-variant-thumbnail.service.ts | 48 + 19 files changed, 3388 insertions(+), 411 deletions(-) create mode 100644 furniture-3d-system/src/components/platform/__tests__/AssetVariantThumbnailRenderer.test.tsx create mode 100644 furniture-3d-system/src/components/ui/__tests__/ContextMenu.test.tsx create mode 100644 furniture-3d-system/src/server/services/__tests__/asset-variant-thumbnail.service.test.ts diff --git a/furniture-3d-system/src/components/3d/HoleOverlay2D.tsx b/furniture-3d-system/src/components/3d/HoleOverlay2D.tsx index 424aaaf1..25a488c3 100644 --- a/furniture-3d-system/src/components/3d/HoleOverlay2D.tsx +++ b/furniture-3d-system/src/components/3d/HoleOverlay2D.tsx @@ -48,6 +48,7 @@ interface ResolvedHoleMarker { localPosition: THREE.Vector3; holeType: string | null; hardwareMatched?: boolean; + snapLink?: HoleData["snapLink"]; } interface ResolveStrictHoleMarkersOptions { @@ -72,6 +73,7 @@ interface ScreenHoleMarker { holeId: string; holeType: string | null; hardwareMatched?: boolean; + snapLink?: HoleData["snapLink"]; x: number; y: number; } @@ -142,27 +144,27 @@ function getHoleMarkerPalette(marker: Pick>> 0) / 4294967295; +} + +function getSnapLinkPalette(state: NonNullable["state"]) { + if (state === "used") { + return { + base: "rgba(18, 89, 142, 0.32)", + pulse: "rgba(18, 89, 142, 0.72)", + glow: "rgba(35, 116, 166, 0.14)", + }; + } + + return { + base: "rgba(186, 124, 18, 0.34)", + pulse: "rgba(245, 184, 52, 0.94)", + glow: "rgba(245, 184, 52, 0.18)", + }; +} + function registerPanelAlias( aliasMap: Map, alias: string, @@ -450,7 +477,14 @@ function resolvePanelObjectByHoles(options: { let totalDistance = 0; holes.forEach((hole) => { const holeLocal = new THREE.Vector3(...hole.position); - const world = modelRoot.localToWorld(holeLocal.clone()); + let world: THREE.Vector3; + if (hole.coordinateSpace === "object-local") { + world = anchor.localToWorld(holeLocal.clone()); + } else if (hole.coordinateSpace === "node-local") { + world = candidate.localToWorld(holeLocal.clone()); + } else { + world = modelRoot.localToWorld(holeLocal.clone()); + } totalDistance += originalBox.distanceToPoint(world); }); @@ -805,6 +839,7 @@ export function resolveStrictHoleMarkers({ const panelStateCache = new Map< string, | { + boundObject: THREE.Object3D; targetObject: THREE.Object3D; currentBox: THREE.Box3; originalBox: THREE.Box3; @@ -819,6 +854,7 @@ export function resolveStrictHoleMarkers({ resolvedHolePanelId: string, ): | { + boundObject: THREE.Object3D; targetObject: THREE.Object3D; currentBox: THREE.Box3; originalBox: THREE.Box3; @@ -837,14 +873,15 @@ export function resolveStrictHoleMarkers({ return null; } - const boundPanelObject = resolveAnimatedAnchorObject(rawBoundPanelObject); - const boundPanelBox = new THREE.Box3().setFromObject(boundPanelObject); + const boundPanelObject = rawBoundPanelObject; + const anchorPanelObject = resolveAnimatedAnchorObject(rawBoundPanelObject); + const boundPanelBox = new THREE.Box3().setFromObject(anchorPanelObject); if (boundPanelBox.isEmpty()) { panelStateCache.set(resolvedHolePanelId, null); return null; } - const explosionOffset = engine.getObjectExplosionWorldOffset(boundPanelObject); + const explosionOffset = engine.getObjectExplosionWorldOffset(anchorPanelObject); const originalBoundPanelBox = boundPanelBox .clone() .translate(explosionOffset.clone().negate()); @@ -855,7 +892,8 @@ export function resolveStrictHoleMarkers({ : null; const state = { - targetObject: boundPanelObject, + boundObject: boundPanelObject, + targetObject: anchorPanelObject, currentBox: boundPanelBox, originalBox: originalBoundPanelBox, cadBox, @@ -883,10 +921,31 @@ export function resolveStrictHoleMarkers({ if (!panelState) { return null; } - let finalWorld = modelRoot - .localToWorld(holeLocal.clone()) - .clone() - .add(panelState.explosionOffset); + if (hole.coordinateSpace === "object-local") { + return { + holeId: hole.id, + panelId: resolvedHolePanelId, + targetObject: panelState.targetObject, + localPosition: holeLocal, + holeType: hole.holeType ?? null, + ...(hole.hardwareMatched !== undefined ? { hardwareMatched: hole.hardwareMatched } : {}), + ...(hole.snapLink ? { snapLink: hole.snapLink } : {}), + }; + } + if (hole.coordinateSpace === "node-local") { + return { + holeId: hole.id, + panelId: resolvedHolePanelId, + targetObject: panelState.boundObject, + localPosition: holeLocal, + holeType: hole.holeType ?? null, + ...(hole.hardwareMatched !== undefined ? { hardwareMatched: hole.hardwareMatched } : {}), + ...(hole.snapLink ? { snapLink: hole.snapLink } : {}), + }; + } + let finalWorld = + modelRoot.localToWorld(holeLocal.clone()); + finalWorld = finalWorld.clone().add(panelState.explosionOffset); const distanceToPanel = panelState.currentBox.distanceToPoint(finalWorld); if (distanceToPanel > panelState.threshold) { const normal = resolveWorldNormal( @@ -932,6 +991,7 @@ export function resolveStrictHoleMarkers({ localPosition: panelState.targetObject.worldToLocal(finalWorld.clone()), holeType: hole.holeType ?? null, ...(hole.hardwareMatched !== undefined ? { hardwareMatched: hole.hardwareMatched } : {}), + ...(hole.snapLink ? { snapLink: hole.snapLink } : {}), }; }) .filter((value): value is ResolvedHoleMarker => value !== null); @@ -1466,11 +1526,42 @@ export function HoleOverlay2D({ holeId: marker.holeId, holeType: marker.holeType, hardwareMatched: marker.hardwareMatched, + snapLink: marker.snapLink, x: Number(projected.x.toFixed(2)), y: Number(projected.y.toFixed(2)), }); }); screenHoleMarkersRef.current = projectedHoleMarkers; + const snapLinkMarkersById = new Map(); + projectedHoleMarkers.forEach((marker) => { + if (!marker.snapLink) return; + const items = snapLinkMarkersById.get(marker.snapLink.id) ?? []; + items.push(marker); + snapLinkMarkersById.set(marker.snapLink.id, items); + }); + const snapLinkPairs = Array.from(snapLinkMarkersById.entries()) + .map(([linkId, markers]) => { + const sourceMarker = markers.find((marker) => marker.snapLink?.role === "source") ?? markers[0]; + const targetMarker = markers.find((marker) => marker.snapLink?.role === "target") ?? markers[1]; + if (!sourceMarker || !targetMarker || sourceMarker === targetMarker) return null; + return { + linkId, + state: sourceMarker.snapLink?.state ?? targetMarker.snapLink?.state ?? "candidate", + sourceMarker, + targetMarker, + }; + }) + .filter( + ( + value, + ): value is { + linkId: string; + state: NonNullable["state"]; + sourceMarker: ScreenHoleMarker; + targetMarker: ScreenHoleMarker; + } => Boolean(value), + ); + const hasAnimatedSnapLinks = snapLinkPairs.some((pair) => pair.state === "candidate"); const panelMarkersForDraw = staticPanelLeaderMarkers .map((marker): PanelMarkerForDraw | null => { @@ -1586,7 +1677,7 @@ export function HoleOverlay2D({ holeMarkers: projectedHoleMarkers, }); - if (frameSignature === lastFrameSignature) { + if (frameSignature === lastFrameSignature && !hasAnimatedSnapLinks) { frameId = window.requestAnimationFrame(drawFrame); return; } @@ -1711,8 +1802,81 @@ export function HoleOverlay2D({ leftMarkers.forEach(drawLeader); rightMarkers.forEach(drawLeader); + const drawSnapLink = (pair: (typeof snapLinkPairs)[number]) => { + const palette = getSnapLinkPalette(pair.state); + const startX = pair.sourceMarker.x; + const startY = pair.sourceMarker.y; + const endX = pair.targetMarker.x; + const endY = pair.targetMarker.y; + const dx = endX - startX; + const dy = endY - startY; + const length = Math.sqrt(dx * dx + dy * dy); + if (length <= 1) return; + + const seed = hashStringToUnit(pair.linkId); + const now = + (typeof performance !== "undefined" ? performance.now() : Date.now()) / 1000; + const phase = + now * (1.15 + seed * 0.85) + + Math.sin(now * (0.77 + seed) + seed * 11) * 0.18; + const pulseCenter = ((phase % 1) + 1) % 1; + const pulseWidth = pair.state === "candidate" ? 0.16 + seed * 0.12 : 0.1; + const alphaJitter = 0.68 + Math.sin(now * (2.1 + seed) + seed * 17) * 0.18; + const midX = startX + dx * 0.5; + const midY = startY + dy * 0.5; + const normalX = -dy / length; + const normalY = dx / length; + const bend = pair.state === "candidate" ? (seed - 0.5) * 10 : 0; + const controlX = midX + normalX * bend; + const controlY = midY + normalY * bend; + + context.save(); + context.lineCap = "round"; + context.lineJoin = "round"; + + context.beginPath(); + context.moveTo(startX, startY); + context.quadraticCurveTo(controlX, controlY, endX, endY); + context.strokeStyle = palette.glow; + context.lineWidth = pair.state === "candidate" ? 7 : 5; + context.stroke(); + + context.beginPath(); + context.moveTo(startX, startY); + context.quadraticCurveTo(controlX, controlY, endX, endY); + context.strokeStyle = palette.base; + context.lineWidth = pair.state === "candidate" ? 1.6 : 1.2; + context.setLineDash(pair.state === "candidate" ? [5 + seed * 5, 8 + seed * 6] : []); + context.lineDashOffset = -phase * 22; + context.stroke(); + + if (pair.state === "candidate") { + const pulseStart = Math.max(0, pulseCenter - pulseWidth / 2); + const pulseEnd = Math.min(1, pulseCenter + pulseWidth / 2); + const pulseStartX = startX + dx * pulseStart; + const pulseStartY = startY + dy * pulseStart; + const pulseEndX = startX + dx * pulseEnd; + const pulseEndY = startY + dy * pulseEnd; + const pulseControlX = controlX + (pulseCenter - 0.5) * dx * 0.12; + const pulseControlY = controlY + (pulseCenter - 0.5) * dy * 0.12; + + context.beginPath(); + context.moveTo(pulseStartX, pulseStartY); + context.quadraticCurveTo(pulseControlX, pulseControlY, pulseEndX, pulseEndY); + context.setLineDash([]); + context.strokeStyle = palette.pulse.replace(/[\d.]+\)$/u, `${Math.min(alphaJitter, 0.95).toFixed(2)})`); + context.lineWidth = 2.4 + seed * 1.2; + context.stroke(); + } + + context.restore(); + }; + + snapLinkPairs.forEach(drawSnapLink); + projectedHoleMarkers.forEach((marker) => { const palette = getHoleMarkerPalette(marker); + const isLinkedMarker = marker.holeType === "assembly-used"; context.beginPath(); context.fillStyle = palette.inner; @@ -1721,14 +1885,22 @@ export function HoleOverlay2D({ context.beginPath(); context.fillStyle = palette.fill; - context.arc(marker.x, marker.y, 4.6, 0, Math.PI * 2); + context.arc(marker.x, marker.y, isLinkedMarker ? 5.6 : 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.8, 0, Math.PI * 2); + context.lineWidth = isLinkedMarker ? 1.6 : 1.2; + context.arc(marker.x, marker.y, isLinkedMarker ? 5.9 : 4.8, 0, Math.PI * 2); context.stroke(); + + if (isLinkedMarker) { + context.beginPath(); + context.strokeStyle = "rgba(18, 89, 142, 0.42)"; + context.lineWidth = 1; + context.arc(marker.x, marker.y, 8, 0, Math.PI * 2); + context.stroke(); + } }); frameId = window.requestAnimationFrame(drawFrame); diff --git a/furniture-3d-system/src/components/3d/ModelViewer.tsx b/furniture-3d-system/src/components/3d/ModelViewer.tsx index 0b369ed8..4eb27311 100644 --- a/furniture-3d-system/src/components/3d/ModelViewer.tsx +++ b/furniture-3d-system/src/components/3d/ModelViewer.tsx @@ -108,7 +108,12 @@ interface ModelViewerProps { nodeName: string; position: [number, number, number]; rotation: [number, number, number]; - }) => void; + }) => + | void + | { + position?: [number, number, number]; + rotation?: [number, number, number]; + }; } export type ModelViewerWorldNode = EngineWorldNode; @@ -626,14 +631,13 @@ function ModelViewerComponent({ const currentSceneIdentity = useMemo( () => isWorldMode - ? `world:${worldNodeIdentitySignature}:${worldFrameMode}` + ? `world:${worldFrameMode}` : `model:${modelUrl.trim()}:${modelDimensionsSignature}`, [ isWorldMode, modelDimensionsSignature, modelUrl, worldFrameMode, - worldNodeIdentitySignature, ], ); @@ -1148,7 +1152,58 @@ function ModelViewerComponent({ return () => { window.cancelAnimationFrame(animationFrameId); }; - }, [isWorldMode, loadScene, retryCount, worldNodeIdentitySignature]); + }, [isWorldMode, loadScene, retryCount]); + + useEffect(() => { + const engine = engineRef.current; + if ( + !engine || + !isWorldMode || + !worldNodes || + loadedSceneIdentity !== currentSceneIdentity + ) { + return; + } + + let cancelled = false; + void engine + .syncWorldNodes(worldNodes) + .then(() => { + if (cancelled || engineRef.current !== engine) { + return; + } + const nodeNames = worldNodes.map((node) => node.nodeName); + captureOriginalNodeRotations(nodeNames); + onModelNodesChangeRef.current?.(nodeNames); + setEngineInstance(engine); + onEngineReadyRef.current?.({ + engine, + modelRoot: engine.getModelRoot(), + }); + syncTransformControlsRef.current?.(); + setSceneLoadVersion((current) => current + 1); + }) + .catch((caught) => { + if (cancelled || engineRef.current !== engine) { + return; + } + const errorInstance = + caught instanceof Error ? caught : new Error("世界节点同步失败"); + setError(getErrorMessage(errorInstance)); + onErrorRef.current?.(errorInstance); + }); + + return () => { + cancelled = true; + }; + }, [ + captureOriginalNodeRotations, + currentSceneIdentity, + isWorldMode, + loadedSceneIdentity, + worldNodeIdentitySignature, + worldNodes, + ]); useEffect(() => { return () => { @@ -1254,7 +1309,9 @@ function ModelViewerComponent({ object.updateMatrixWorld(true); }); - syncTransformControlsRef.current?.(); + if (!transformInteractionActiveRef.current) { + syncTransformControlsRef.current?.(); + } }, [engineInstance, isWorldMode, worldNodeTransformSignature, worldNodes]); useEffect(() => { @@ -1469,38 +1526,65 @@ function ModelViewerComponent({ ) { return; } + let effectiveTransform: + | void + | { + position?: [number, number, number]; + rotation?: [number, number, number]; + } = undefined; + if (isWorldMode && onWorldNodeTransformChange) { + effectiveTransform = onWorldNodeTransformChange({ + nodeName: activeNodeName, + position: [ + targetObject.position.x, + targetObject.position.y, + targetObject.position.z, + ], + rotation: [ + targetObject.rotation.x, + targetObject.rotation.y, + targetObject.rotation.z, + ], + }); + } + if (effectiveTransform?.position) { + targetObject.position.set( + effectiveTransform.position[0], + effectiveTransform.position[1], + effectiveTransform.position[2], + ); + } + if (effectiveTransform?.rotation) { + targetObject.rotation.set( + effectiveTransform.rotation[0], + effectiveTransform.rotation[1], + effectiveTransform.rotation[2], + ); + } + if (effectiveTransform?.position || effectiveTransform?.rotation) { + targetObject.updateMatrixWorld(true); + ( + transformControlsRef.current as unknown as { + updateMatrixWorld?: (force?: boolean) => void; + } | null + )?.updateMatrixWorld?.(true); + transformHelperRef.current?.updateMatrixWorld(true); + } const feedbackLabels = buildTransformFeedbackLabels(activeNodeName); - flushSync(() => { - if (isWorldMode && onWorldNodeTransformChange) { - onWorldNodeTransformChange({ - nodeName: activeNodeName, - position: [ - targetObject.position.x, - targetObject.position.y, - targetObject.position.z, - ], - rotation: [ - targetObject.rotation.x, - targetObject.rotation.y, - targetObject.rotation.z, - ], - }); - } - if (offset && onNodePositionOffsetChange) { - onNodePositionOffsetChange({ - nodeName: activeNodeName, - offset, - }); - } - if (rotation && onNodeRotationChange) { - onNodeRotationChange({ - nodeName: activeNodeName, - rotation, - }); - } - setTransformFeedbackLabels(feedbackLabels); - }); + if (offset && onNodePositionOffsetChange) { + onNodePositionOffsetChange({ + nodeName: activeNodeName, + offset, + }); + } + if (rotation && onNodeRotationChange) { + onNodeRotationChange({ + nodeName: activeNodeName, + rotation, + }); + } + setTransformFeedbackLabels(feedbackLabels); }; syncTransformControlsRef.current = () => { diff --git a/furniture-3d-system/src/components/3d/__tests__/ModelViewer.test.tsx b/furniture-3d-system/src/components/3d/__tests__/ModelViewer.test.tsx index ffc4aa43..7e675a09 100644 --- a/furniture-3d-system/src/components/3d/__tests__/ModelViewer.test.tsx +++ b/furniture-3d-system/src/components/3d/__tests__/ModelViewer.test.tsx @@ -1,10 +1,11 @@ import * as THREE from 'three' -import { render, waitFor } from '@testing-library/react' +import { act, render, waitFor } from '@testing-library/react' import type { ButtonHTMLAttributes } from 'react' import { ModelViewer } from '../ModelViewer' const mockEngineInstances: Array<{ loadWorldNodes: jest.Mock + syncWorldNodes: jest.Mock applyMaterialToMesh: jest.Mock resolveObjectByNodeName: jest.Mock }> = [] @@ -83,6 +84,30 @@ jest.mock('@/lib/3d/engine', () => ({ } }, ), + syncWorldNodes: jest.fn(async (nodes: Array<{ nodeName: string }>) => { + await Promise.resolve() + + const nextNodeNames = new Set(nodes.map((node) => node.nodeName)) + Array.from(instance.nodeObjects.keys()).forEach((nodeName) => { + if (!nextNodeNames.has(nodeName)) { + instance.nodeObjects.delete(nodeName) + } + }) + nodes.forEach((node) => { + if (!instance.nodeObjects.has(node.nodeName)) { + const object = new THREE.Group() + object.name = node.nodeName + instance.nodeObjects.set(node.nodeName, object) + } + }) + + instance.modelRoot ??= new THREE.Group() + instance.modelRoot.clear() + instance.nodeObjects.forEach((object) => { + instance.modelRoot?.add(object) + }) + return instance.modelRoot + }), loadModel: jest.fn(async () => new THREE.Group()), setHiddenNodes: jest.fn(), setSceneGridVisible: jest.fn(), @@ -214,7 +239,57 @@ describe('ModelViewer', () => { }) }) - it('世界节点重载后即使材质配置没变也会重新应用材质', async () => { + it('世界节点删除时走增量同步,不重新加载整个 3D 世界', async () => { + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockEngineInstances[0]?.loadWorldNodes).toHaveBeenCalledTimes(1) + }) + + rerender( + , + ) + + await waitFor(() => { + expect(mockEngineInstances[0]?.syncWorldNodes).toHaveBeenCalledWith([ + { + nodeName: 'node-1', + modelUrl: '/desktop.glb', + source: 'desktop', + }, + ]) + expect(mockEngineInstances[0]?.loadWorldNodes).toHaveBeenCalledTimes(1) + }) + }) + + it('世界节点增量同步后即使材质配置没变也会重新应用材质', async () => { const nodeMaterial = { id: 'finish-oak', name: '橡木', @@ -279,7 +354,8 @@ describe('ModelViewer', () => { ) await waitFor(() => { - expect(mockEngineInstances[0]?.loadWorldNodes).toHaveBeenCalledTimes(2) + expect(mockEngineInstances[0]?.syncWorldNodes).toHaveBeenCalled() + expect(mockEngineInstances[0]?.loadWorldNodes).toHaveBeenCalledTimes(1) }) await waitFor(() => { @@ -331,4 +407,49 @@ describe('ModelViewer', () => { }) }) }) + + it('世界节点拖动时会应用业务层返回的吸附后坐标到正在拖拽的模型本体', async () => { + const onWorldNodeTransformChange = jest.fn(() => ({ + position: [10, 20, 30] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + })) + + render( + , + ) + + await waitFor(() => { + expect(mockTransformControlsInstances[0]?.attach).toHaveBeenCalled() + }) + + const object = mockEngineInstances[0]?.resolveObjectByNodeName('node-1') as THREE.Object3D + object.position.set(1, 2, 3) + + const transformControls = mockTransformControlsInstances[0] as unknown as { + listeners: Map void> + } + await act(async () => { + transformControls.listeners.get('objectChange')?.({}) + }) + + expect(onWorldNodeTransformChange).toHaveBeenCalledWith({ + nodeName: 'node-1', + position: [1, 2, 3], + rotation: [0, 0, 0], + }) + expect(object.position.toArray()).toEqual([10, 20, 30]) + }) }) diff --git a/furniture-3d-system/src/components/3d/__tests__/hole-overlay-binding.test.ts b/furniture-3d-system/src/components/3d/__tests__/hole-overlay-binding.test.ts index b62922d7..48404ded 100644 --- a/furniture-3d-system/src/components/3d/__tests__/hole-overlay-binding.test.ts +++ b/furniture-3d-system/src/components/3d/__tests__/hole-overlay-binding.test.ts @@ -112,6 +112,130 @@ describe("resolveStrictHoleMarkers", () => { ); }); + it("辅助装配传入节点本地孔位时,孔位应绑定到被拖拽模型节点而不是上一帧世界坐标", () => { + const modelRoot = new THREE.Group(); + const panelA = createPanelMesh("assembly-node-a", new THREE.Vector3(0, 708, 0)); + modelRoot.add(panelA); + modelRoot.updateMatrixWorld(true); + + const markers = resolveStrictHoleMarkers({ + engine: { + getObjectExplosionWorldOffset: () => new THREE.Vector3(), + } as any, + holes: [ + createHole({ + panelId: "node-a::hole::hole-1", + coordinateSpace: "node-local", + position: [0, 0, 0], + normal: [0, 1, 0], + }), + ], + modelRoot, + panelObjectByPanelId: { + "node-a::hole::hole-1": panelA, + }, + panelIdAliasMap: new Map([ + ["nodea::hole::hole1", "node-a::hole::hole-1"], + ]), + }); + + expect(markers).toHaveLength(1); + expect(resolveMarkerWorldPosition(markers[0]!).y).toBeCloseTo(708, 5); + + panelA.position.y = 760; + panelA.updateMatrixWorld(true); + expect(resolveMarkerWorldPosition(markers[0]!).y).toBeCloseTo(760, 5); + }); + + it("辅助装配节点本地孔位不应再被投影到模型包围盒最近面", () => { + const modelRoot = new THREE.Group(); + const panelA = createPanelMesh("assembly-node-a", new THREE.Vector3(0, 0, 0)); + modelRoot.add(panelA); + modelRoot.updateMatrixWorld(true); + + const markers = resolveStrictHoleMarkers({ + engine: { + getObjectExplosionWorldOffset: () => new THREE.Vector3(), + } as any, + holes: [ + createHole({ + panelId: "node-a::hole::hole-1", + coordinateSpace: "node-local", + position: [0, 0, 0], + normal: [0, 1, 0], + }), + ], + modelRoot, + panelObjectByPanelId: { + "node-a::hole::hole-1": panelA, + }, + panelIdAliasMap: new Map([ + ["nodea::hole::hole1", "node-a::hole::hole-1"], + ]), + }); + + expect(markers).toHaveLength(1); + expect(markers[0]?.localPosition).toEqual( + expect.objectContaining({ + x: expect.closeTo(0, 5), + y: expect.closeTo(0, 5), + z: expect.closeTo(0, 5), + }), + ); + }); + + it("辅助装配节点本地孔位应绑定世界节点 wrapper,避免 OBJ 内部旋转再次转换", () => { + const modelRoot = new THREE.Group(); + const wrapper = new THREE.Group(); + wrapper.name = "assembly-node-board"; + wrapper.position.set(10, 20, 30); + + const objRoot = new THREE.Group(); + objRoot.rotation.x = -Math.PI / 2; + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(1400, 600, 25), + new THREE.MeshBasicMaterial(), + ); + mesh.name = "asset_000"; + objRoot.add(mesh); + wrapper.add(objRoot); + modelRoot.add(wrapper); + modelRoot.updateMatrixWorld(true); + + const nodeLocalHole: [number, number, number] = [285, 2.5, -171.5]; + const markers = resolveStrictHoleMarkers({ + engine: { + getObjectExplosionWorldOffset: () => new THREE.Vector3(), + } as any, + holes: [ + createHole({ + id: "cad-hole-after-up-axis-transform", + panelId: "node-board::hole::panel_000_hole_000", + coordinateSpace: "node-local", + position: nodeLocalHole, + normal: [0, 1, 0], + }), + ], + modelRoot, + panelObjectByPanelId: { + "node-board::hole::panel_000_hole_000": wrapper, + }, + panelIdAliasMap: new Map([ + ["nodeboard::hole::panel000hole000", "node-board::hole::panel_000_hole_000"], + ]), + }); + + expect(markers).toHaveLength(1); + expect(markers[0]?.targetObject).toBe(wrapper); + expect(resolveMarkerWorldPosition(markers[0]!)).toEqual( + expect.objectContaining({ + x: expect.closeTo(295, 5), + y: expect.closeTo(22.5, 5), + z: expect.closeTo(-141.5, 5), + }), + ); + }); + it("插件导出坐标已与模型对齐时,不应再做额外坐标转换,但应贴到板面", () => { const modelRoot = new THREE.Group(); const panelA = createPanelMesh("cad_panel_a", new THREE.Vector3(0, 1, 2)); diff --git a/furniture-3d-system/src/components/3d/hole-viewer.types.ts b/furniture-3d-system/src/components/3d/hole-viewer.types.ts index 80aa21c1..2a909c4a 100644 --- a/furniture-3d-system/src/components/3d/hole-viewer.types.ts +++ b/furniture-3d-system/src/components/3d/hole-viewer.types.ts @@ -1,8 +1,17 @@ import type * as THREE from 'three' +export interface HoleSnapLinkData { + id: string + state: 'candidate' | 'used' + role: 'source' | 'target' + peerPanelId: string + peerHoleId: string +} + export interface HoleData { id: string position: [number, number, number] + coordinateSpace?: 'model-root' | 'object-local' | 'node-local' panelId?: string diameter: number depth: number @@ -16,6 +25,7 @@ export interface HoleData { width?: number length?: number groupId?: string + snapLink?: HoleSnapLinkData } export interface HoleGroupData { diff --git a/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx b/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx index ac9ae7bf..e10d5a7a 100644 --- a/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx +++ b/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx @@ -40,6 +40,18 @@ import type { AssetSeriesDTO } from '@/server/services' type AssemblyWorkbenchTransformMode = 'translate' | 'rotate' type AssemblyAssetBrowserSource = AssemblyWorkspaceInventoryItem['source'] type AssemblyAssetBrowserPanelKey = 'series' | AssemblyAssetBrowserSource +type AssemblyHoleVisualType = + | 'assembly-unused' + | 'assembly-used' + | 'assembly-candidate' + | 'assembly-active' + +const ASSEMBLY_HOLE_STATE_PRIORITY: Record = { + 'assembly-unused': 0, + 'assembly-used': 1, + 'assembly-candidate': 2, + 'assembly-active': 3, +} const ASSET_BROWSER_TABS: Array<{ source: AssemblyAssetBrowserSource @@ -66,6 +78,32 @@ const ASSET_BROWSER_PANEL_TOP_CLASS: Record> + +function getCandidateHolePairs(candidate: AssemblySnapDisplayCandidate) { + if (candidate.matchedHolePairs.length > 0) { + return candidate.matchedHolePairs + } + + return [ + { + sourceHoleId: candidate.sourceHoleId, + targetHoleId: candidate.targetHoleId, + sourceHolePanelId: candidate.sourceHolePanelId, + targetHolePanelId: candidate.targetHolePanelId, + sourceHoleDiameter: candidate.sourceHoleDiameter, + targetHoleDiameter: candidate.targetHoleDiameter, + sourceHoleDepth: candidate.sourceHoleDepth, + targetHoleDepth: candidate.targetHoleDepth, + sourceFace: candidate.sourceFace, + targetFace: candidate.targetFace, + sourceHoleWorldPosition: candidate.sourceHoleWorldPosition, + targetHoleWorldPosition: candidate.targetHoleWorldPosition, + sourceHoleNormal: candidate.sourceHoleNormal, + targetHoleNormal: candidate.targetHoleNormal, + distanceMm: candidate.distanceMm, + }, + ] +} + function toOpenProductVariant(variant: AssistedAssemblyVariant) { return { id: variant.id, @@ -207,6 +273,11 @@ export function AssemblyWorkbenchV2({ const pendingInteractiveSnapshotRef = useRef(null) const pendingInteractiveNodeIdRef = useRef(null) const pendingInteractiveModeRef = useRef(null) + const snapPreviewHoldRef = useRef<{ + nodeId: string + bindingKey: string + rawPosition: [number, number, number] + } | null>(null) const suppressEmptySelectionUntilRef = useRef(0) const assetBrowserCloseTimerRef = useRef | null>(null) const assetBrowserRegionRef = useRef(null) @@ -234,6 +305,7 @@ export function AssemblyWorkbenchV2({ useEffect(() => { setPresentWorkspace(value) workspaceRef.current = value + snapPreviewHoldRef.current = null }, [value]) useEffect(() => { @@ -253,6 +325,7 @@ export function AssemblyWorkbenchV2({ pendingInteractiveSnapshotRef.current = null pendingInteractiveNodeIdRef.current = null pendingInteractiveModeRef.current = null + snapPreviewHoldRef.current = null suppressEmptySelectionUntilRef.current = 0 }, [availableVariantMap, requireAssetSeriesSelection, workspaceKey]) @@ -287,15 +360,14 @@ export function AssemblyWorkbenchV2({ const occupiedNode = presentWorkspace.nodes.find((node) => { if (!node.snapBinding) return false - const sourcePanelId = node.snapBinding.sourceHoleId - ? buildSnapHolePanelId(node.nodeId, node.snapBinding.sourceHoleId) - : null - const targetPanelId = - node.snapBinding.targetNodeId && node.snapBinding.targetHoleId - ? buildSnapHolePanelId(node.snapBinding.targetNodeId, node.snapBinding.targetHoleId) - : null + const displayCandidate = deriveAssemblyActiveSnapCandidate(presentWorkspace, node.nodeId) + if (!displayCandidate || displayCandidate.snapMode === 'surface') return false - return hoveredUsedHolePanelId === sourcePanelId || hoveredUsedHolePanelId === targetPanelId + return getCandidateHolePairs(displayCandidate).some((pair) => { + const sourcePanelId = buildSnapHolePanelId(displayCandidate.sourceNodeId, pair.sourceHoleId) + const targetPanelId = buildSnapHolePanelId(displayCandidate.targetNodeId, pair.targetHoleId) + return hoveredUsedHolePanelId === sourcePanelId || hoveredUsedHolePanelId === targetPanelId + }) }) if (occupiedNode) { @@ -312,8 +384,18 @@ export function AssemblyWorkbenchV2({ const holeOverlayState = useMemo(() => { const holesById = new Map() const activeHolePanelIdSet = new Set() + const suppressedHolePanelIdSet = new Set() const panelNodeNameByPanelId: Record = {} const panelLabelByPanelId: Record = {} + const holeStateByPanelId = new Map< + string, + { + holeType: AssemblyHoleVisualType + confidence: number + hardwareMatched: boolean + } + >() + const snapLinkByPanelId = new Map>() const registerHole = ( hole: HoleOverlayData, @@ -327,102 +409,123 @@ export function AssemblyWorkbenchV2({ } } - const appendCandidateHoles = ( + const markHoleState = ( + panelId: string | null, + holeType: AssemblyHoleVisualType, + confidence: number, + hardwareMatched: boolean, + ) => { + if (!panelId) return + const current = holeStateByPanelId.get(panelId) + if ( + !current || + ASSEMBLY_HOLE_STATE_PRIORITY[holeType] >= ASSEMBLY_HOLE_STATE_PRIORITY[current.holeType] + ) { + holeStateByPanelId.set(panelId, { + holeType, + confidence, + hardwareMatched, + }) + } + if (holeType === 'assembly-active' || holeType === 'assembly-candidate') { + activeHolePanelIdSet.add(panelId) + } + } + + const markCandidateHoleState = ( candidate: NonNullable, - sourceNode: AssemblyWorkspaceNode, - targetNode: AssemblyWorkspaceNode | null, - holeType: string, + holeType: AssemblyHoleVisualType, confidence = 1, ) => { if (candidate.snapMode === 'surface') return - 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) - } + getCandidateHolePairs(candidate).forEach((pair) => { + const sourcePanelId = buildSnapHolePanelId(candidate.sourceNodeId, pair.sourceHoleId) + const targetPanelId = buildSnapHolePanelId(candidate.targetNodeId, pair.targetHoleId) + const linkState = holeType === 'assembly-used' ? 'used' : 'candidate' + const linkId = `assembly-snap-link::${sourcePanelId}::${targetPanelId}` - registerHole( - { - id: sourcePanelId, - panelId: sourcePanelId, - 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: candidate.targetHoleWorldPosition, - diameter: candidate.targetHoleDiameter ?? 8, - depth: candidate.targetHoleDepth ?? 12, - normal: candidate.targetHoleNormal, - holeType, - confidence, - hardwareMatched: holeType === 'assembly-used', - }, - candidate.targetNodeId, - targetNode?.name ?? candidate.targetNodeId, - ) - } - - if (selectedSingleNode) { - getAssemblyNodeHoleMarkers(selectedSingleNode).forEach((hole) => { - const panelId = buildSnapHolePanelId(selectedSingleNode.nodeId, hole.id) - registerHole( - { - id: panelId, - panelId, - position: hole.position, - diameter: hole.diameter ?? 8, - depth: hole.depth ?? 12, - normal: hole.normal, - holeType: hole.holeType, - confidence: 0.46, - hardwareMatched: false, - }, - selectedSingleNode.nodeId, - selectedSingleNode.name, - ) + markHoleState(sourcePanelId, holeType, confidence, holeType === 'assembly-used') + markHoleState(targetPanelId, holeType, confidence, holeType === 'assembly-used') + snapLinkByPanelId.set(sourcePanelId, { + id: linkId, + state: linkState, + role: 'source', + peerPanelId: targetPanelId, + peerHoleId: pair.targetHoleId, + }) + snapLinkByPanelId.set(targetPanelId, { + id: linkId, + state: linkState, + role: 'target', + peerPanelId: sourcePanelId, + peerHoleId: pair.sourceHoleId, + }) }) } 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) + const displayHoleType = + node.isSnapped || node.snapStatus === 'hole-snap' + ? 'assembly-used' + : 'assembly-candidate' + markCandidateHoleState(displayCandidate, displayHoleType, displayHoleType === 'assembly-used' ? 0.82 : 1) } }) if (selectedSingleNode && activeSnapCandidate) { - const targetNode = - presentWorkspace.nodes.find((node) => node.nodeId === activeSnapCandidate.targetNodeId) ?? null - appendCandidateHoles( + markCandidateHoleState( activeSnapCandidate, - selectedSingleNode, - targetNode, - selectedSingleNode.isSnapped ? 'assembly-active' : 'assembly-candidate', + selectedSingleNode.isSnapped ? 'assembly-used' : 'assembly-candidate', 1, ) } + presentWorkspace.nodes.forEach((node) => { + getAssemblyNodeHoleMarkers(node).forEach((hole) => { + const panelId = buildSnapHolePanelId(node.nodeId, hole.id) + if (suppressedHolePanelIdSet.has(panelId)) { + return + } + const state = holeStateByPanelId.get(panelId) + const snapLink = snapLinkByPanelId.get(panelId) + registerHole( + { + id: panelId, + panelId, + position: hole.localPosition, + coordinateSpace: 'node-local', + diameter: hole.diameter ?? 8, + depth: hole.depth ?? 12, + normal: hole.localNormal, + holeType: state?.holeType ?? 'assembly-unused', + confidence: state?.confidence ?? 0.46, + hardwareMatched: state?.hardwareMatched ?? false, + ...(snapLink ? { snapLink } : {}), + }, + node.nodeId, + node.name, + ) + }) + }) + + const activeHolePanelIds = + activeHolePanelIdSet.size > 0 + ? Array.from( + new Set( + Array.from(holesById.values()) + .map((hole) => hole.panelId) + .filter((panelId): panelId is string => Boolean(panelId)), + ), + ) + : [] + return { holes: Array.from(holesById.values()), - activeHolePanelIds: Array.from(activeHolePanelIdSet), + activeHolePanelIds, panelNodeNameByPanelId, panelLabelByPanelId, } @@ -598,6 +701,7 @@ export function AssemblyWorkbenchV2({ pendingInteractiveSnapshotRef.current = workspaceRef.current pendingInteractiveNodeIdRef.current = nodeId pendingInteractiveModeRef.current = gizmoMode + snapPreviewHoldRef.current = null }, [gizmoMode], ) @@ -608,22 +712,60 @@ export function AssemblyWorkbenchV2({ position: [number, number, number] rotation: [number, number, number] }) => { + const isTranslateInteraction = pendingInteractiveModeRef.current === 'translate' let next = updateAssemblyNodeTransform(workspaceRef.current, payload.nodeName, { position: payload.position, rotation: payload.rotation, + preserveSnapPreview: isTranslateInteraction, }) - if (pendingInteractiveModeRef.current === 'translate') { + if (isTranslateInteraction) { const baselineNode = pendingInteractiveSnapshotRef.current?.nodes.find( (node) => node.nodeId === payload.nodeName, ) + const currentHold = + snapPreviewHoldRef.current?.nodeId === payload.nodeName + ? snapPreviewHoldRef.current + : null + const heldReleaseDistanceMm = currentHold + ? getTransformDistanceMm(payload.position, currentHold.rawPosition) + : null next = applyAutomaticAssemblySnapPreview(next, payload.nodeName, { fallbackZone: baselineNode?.zone, + heldReleaseDistanceMm, }) } if (!areSnapshotsEqual(workspaceRef.current, next)) { setPresentWorkspace(next) workspaceRef.current = next } + const effectiveNode = next.nodes.find((node) => node.nodeId === payload.nodeName) + if (!effectiveNode) { + if (snapPreviewHoldRef.current?.nodeId === payload.nodeName) { + snapPreviewHoldRef.current = null + } + return undefined + } + const bindingKey = getSnapBindingKey(effectiveNode) + if (isTranslateInteraction && effectiveNode.snapStatus === 'hole-preview' && bindingKey) { + const currentHold = snapPreviewHoldRef.current + if ( + !currentHold || + currentHold.nodeId !== payload.nodeName || + currentHold.bindingKey !== bindingKey + ) { + snapPreviewHoldRef.current = { + nodeId: payload.nodeName, + bindingKey, + rawPosition: [payload.position[0], payload.position[1], payload.position[2]], + } + } + } else if (snapPreviewHoldRef.current?.nodeId === payload.nodeName) { + snapPreviewHoldRef.current = null + } + return { + position: effectiveNode.position, + rotation: effectiveNode.rotation, + } }, [], ) @@ -636,6 +778,7 @@ export function AssemblyWorkbenchV2({ pendingInteractiveSnapshotRef.current = null pendingInteractiveNodeIdRef.current = null pendingInteractiveModeRef.current = null + snapPreviewHoldRef.current = null if (!baseline || baselineNodeId !== nodeId) return let next = workspaceRef.current @@ -775,9 +918,15 @@ export function AssemblyWorkbenchV2({ ] }, [contextMenuNode]) const contextMenuPosition = useMemo( - () => calculateMenuPosition(contextMenuState.x, contextMenuState.y, 196, 116), + () => calculateMenuPosition(contextMenuState.x, contextMenuState.y, 220, 224), [contextMenuState.x, contextMenuState.y], ) + const contextMenuBodyMaxHeight = useMemo(() => { + const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 900 + const menuChromeHeight = 24 + const availableHeight = viewportHeight - contextMenuPosition.top - menuChromeHeight + return Math.max(240, Math.min(360, availableHeight)) + }, [contextMenuPosition.top]) const handleContextMenuItemClick = useCallback( (item: ContextMenuItem) => { @@ -1452,12 +1601,11 @@ export function AssemblyWorkbenchV2({ ) : null}
diff --git a/furniture-3d-system/src/components/platform/AssetVariantThumbnailRenderer.tsx b/furniture-3d-system/src/components/platform/AssetVariantThumbnailRenderer.tsx index f92d9000..ec54324f 100644 --- a/furniture-3d-system/src/components/platform/AssetVariantThumbnailRenderer.tsx +++ b/furniture-3d-system/src/components/platform/AssetVariantThumbnailRenderer.tsx @@ -112,6 +112,7 @@ function setThumbnailRenderState( } document.body.dataset.thumbnailState = state + document.body.dataset.assetThumbnailRenderer = 'true' if (errorMessage) { document.body.dataset.thumbnailError = errorMessage } else { @@ -152,6 +153,7 @@ export function AssetVariantThumbnailRenderer({ ) useEffect(() => { + document.body.dataset.assetThumbnailRenderer = 'true' setThumbnailRenderState('loading') setNodeNames([]) setModelLoaded(false) @@ -161,6 +163,9 @@ export function AssetVariantThumbnailRenderer({ controllerRef.current?.dispose() controllerRef.current = null modelRootRef.current = null + delete document.body.dataset.assetThumbnailRenderer + delete document.body.dataset.thumbnailState + delete document.body.dataset.thumbnailError } }, [assetType, materialConfig, modelUrl]) @@ -274,7 +279,6 @@ export function AssetVariantThumbnailRenderer({ data-thumbnail-capture="true" className="relative h-[360px] w-[360px] overflow-hidden bg-[#f6f7f8]" > -
+
) } diff --git a/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx b/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx index 60dbd699..3d492af1 100644 --- a/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx +++ b/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx @@ -69,7 +69,12 @@ interface ModelPreviewPanelProps { onGizmoInteractionEnd?: (nodeName: string) => void onNodePositionOffsetChange?: (payload: { nodeName: string; offset: [number, number, number] }) => void onNodeRotationChange?: (payload: { nodeName: string; rotation: [number, number, number, number] }) => void - onWorldNodeTransformChange?: (payload: { nodeName: string; position: [number, number, number]; rotation: [number, number, number] }) => void + onWorldNodeTransformChange?: (payload: { nodeName: string; position: [number, number, number]; rotation: [number, number, number] }) => + | void + | { + position?: [number, number, number] + rotation?: [number, number, number] + } onEngineReady?: (payload: { engine: ThreeDEngine; modelRoot: any }) => void onModelNodesChange?: (nodes: string[]) => void onNodeSelect?: (payload: { 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 c559b721..48d62cec 100644 --- a/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx +++ b/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx @@ -17,17 +17,29 @@ jest.mock('@/components/ui/ContextMenu', () => ({ ContextMenu: ({ visible, title, + subtitle, + width, + bodyMaxHeight, groups, onItemClick, }: { visible: boolean title?: string + subtitle?: string + width?: number + bodyMaxHeight?: number groups: Array<{ id: string; items: Array<{ id: string; label: string }> }> onItemClick?: (item: { id: string; label: string }) => void }) => visible ? ( -
- {title ?
{title}
: null} +
{groups.flatMap((group) => group.items.map((item) => ( + +
) }, @@ -422,6 +497,14 @@ function buildHoleSnapWorkspace() { }) } +function buildUnsnappedHoleWorkspace() { + const preview = buildHoleSnapWorkspace() + const legNode = preview.nodes[1]! + return updateAssemblyNodeTransform(preview, legNode.nodeId, { + position: [-1500, 320, 900], + }) +} + function buildSingleBoardHoleWorkspace() { return instantiateAssemblyNode(buildWorkspace(), { inventoryId: 'desktop:family-board-1:1', @@ -467,6 +550,90 @@ function buildSingleBoardHoleWorkspace() { }) } +function buildMultiHoleSnapWorkspace() { + const workspace = buildHoleSnapWorkspace() + const targetNode = workspace.nodes[0] + const movingNode = workspace.nodes[1] + if (!targetNode || !movingNode) return workspace + + const targetBasis = targetNode.assemblyBasis ?? {} + const targetHoles = Array.isArray(targetBasis.holes) ? [...targetBasis.holes] : [] + const targetPatterns = Array.isArray(targetBasis.mountPatterns) ? [...targetBasis.mountPatterns] : [] + const sourceBasis = movingNode.assemblyBasis ?? {} + const sourceHoles = Array.isArray(sourceBasis.holes) ? [...sourceBasis.holes] : [] + const sourcePatterns = Array.isArray(sourceBasis.mountPatterns) ? [...sourceBasis.mountPatterns] : [] + + return { + ...workspace, + nodes: workspace.nodes.map((node) => + node.nodeId === targetNode.nodeId + ? { + ...node, + assemblyBasis: { + ...targetBasis, + holes: [ + ...targetHoles, + { + id: 'board-hole-2', + panelId: 'board-panel', + position: [10, 0, 0], + normal: [0, -1, 0], + diameter: 8, + depth: 12, + holeGroupId: 'board-hole-group', + surfaceFaces: ['top'], + }, + ], + mountPatterns: targetPatterns.map((pattern) => + pattern.id === 'board-pattern-1' + ? { + ...pattern, + holeIds: ['board-hole-1', 'board-hole-2'], + requiredHoleIds: ['board-hole-1', 'board-hole-2'], + minRequiredHoles: 2, + preferredHoleCount: 2, + } + : pattern, + ), + }, + } + : node.nodeId === movingNode.nodeId + ? { + ...node, + assemblyBasis: { + ...sourceBasis, + holes: [ + ...sourceHoles, + { + id: 'leg-hole-2', + panelId: 'leg-panel', + position: [10, 0, 0], + normal: [0, 1, 0], + diameter: 8, + depth: 12, + holeType: 'assembly-active', + holeGroupId: 'leg-hole-group', + surfaceFaces: ['bottom'], + }, + ], + mountPatterns: sourcePatterns.map((pattern) => + pattern.id === 'leg-pattern-1' + ? { + ...pattern, + holeIds: ['leg-hole-1', 'leg-hole-2'], + requiredHoleIds: ['leg-hole-1', 'leg-hole-2'], + minRequiredHoles: 2, + preferredHoleCount: 2, + } + : pattern, + ), + }, + } + : node, + ), + } satisfies AssemblyWorkspaceSnapshot +} + function buildMissingSnapSemanticWorkspace() { const baseWorkspace = buildWorkspace() const withDesktop = instantiateAssemblyNode(baseWorkspace, { @@ -731,12 +898,32 @@ describe('AssemblyWorkbenchV2', () => { await waitFor(() => { expect(screen.getByTestId('selected-node')).not.toHaveTextContent('none') expect(screen.getByTestId('hole-overlay-count')).toHaveTextContent('孔位覆盖:2') + expect(screen.getByTestId('hole-overlay-link-count')).toHaveTextContent('连接孔位:2') expect(screen.getByTestId('hole-overlay-first-metrics')).toHaveTextContent('8/12') + expect(screen.getByTestId('hole-overlay-first-type')).toHaveTextContent('assembly-candidate') expect(screen.getByTestId('active-hole-panel-count')).toHaveTextContent('激活孔位面板:2') expect(screen.getByTestId('highlighted-node-count')).toHaveTextContent('高亮节点:1') }) }) + it('加载多个模型但未选中节点时,也会保留基础孔位高亮', async () => { + render( + 右侧业务区
} + />, + ) + + await waitFor(() => { + expect(screen.getByTestId('world-node-count')).toHaveTextContent('世界节点:2') + expect(screen.getByTestId('selected-node')).toHaveTextContent('none') + expect(screen.getByTestId('hole-overlay-count')).toHaveTextContent('孔位覆盖:2') + expect(screen.getByTestId('hole-overlay-link-count')).toHaveTextContent('连接孔位:2') + expect(screen.getByTestId('hole-overlay-first-type')).toHaveTextContent('assembly-candidate') + expect(screen.getByTestId('active-hole-panel-count')).toHaveTextContent('激活孔位面板:2') + }) + }) + it('选中只有自身孔位语义的节点后,也会显示未占用孔位高亮', async () => { const actor = userEvent.setup() @@ -753,18 +940,41 @@ describe('AssemblyWorkbenchV2', () => { expect(screen.getByTestId('selected-node')).not.toHaveTextContent('none') expect(screen.getByTestId('hole-overlay-count')).toHaveTextContent('孔位覆盖:1') expect(screen.getByTestId('hole-overlay-first-metrics')).toHaveTextContent('8/12') - expect(screen.getByTestId('hole-overlay-first-type')).toHaveTextContent('assembly-active') + expect(screen.getByTestId('hole-overlay-first-type')).toHaveTextContent('assembly-unused') expect(screen.getByTestId('active-hole-panel-count')).toHaveTextContent('激活孔位面板:0') }) }) + it('选中多孔节点并出现吸附候选时,会保留选中节点全部自身孔位可见', async () => { + const actor = userEvent.setup() + + render( + 右侧业务区
} + />, + ) + + await actor.click(screen.getByRole('button', { name: '模拟选中第二节点' })) + + await waitFor(() => { + expect(screen.getByTestId('selected-node')).not.toHaveTextContent('none') + expect(screen.getByTestId('hole-overlay-count')).toHaveTextContent('孔位覆盖:4') + expect(screen.getByTestId('hole-overlay-link-count')).toHaveTextContent('连接孔位:4') + expect(screen.getByTestId('active-hole-panel-count')).toHaveTextContent('激活孔位面板:4') + expect(screen.getByTestId('active-hole-panel-ids')).toHaveTextContent('leg-hole-2') + }) + }) + it('hover 已使用孔位时,会高亮占用该孔位的节点', async () => { const actor = userEvent.setup() - const preview = applyAutomaticAssemblySnapPreview(buildHoleSnapWorkspace(), 'node-2') - const snapped = attemptSnapNode(preview, 'node-2') + const preview = buildHoleSnapWorkspace() + const previewNodeId = preview.nodes.find((node) => node.snapBinding)?.nodeId + const snapped = previewNodeId ? attemptSnapNode(preview, previewNodeId) : preview const occupiedNodeId = snapped.nodes.find((node) => node.snapBinding)?.nodeId expect(occupiedNodeId).toBeTruthy() + expect(snapped.nodes.find((node) => node.nodeId === occupiedNodeId)?.isSnapped).toBe(true) render( { await waitFor(() => { expect(screen.getByTestId('hole-overlay-count')).toHaveTextContent('孔位覆盖:2') + expect(screen.getByTestId('hole-overlay-link-count')).toHaveTextContent('连接孔位:2') + expect(screen.getByTestId('hole-overlay-first-type')).toHaveTextContent('assembly-used') expect(screen.getByTestId('highlighted-node-names')).toHaveTextContent('none') }) @@ -805,6 +1017,40 @@ describe('AssemblyWorkbenchV2', () => { }) }) + it('拖动中预吸附会把吸附后的坐标回传给 3D 模型本体', async () => { + const actor = userEvent.setup() + + render( + 右侧业务区
} + />, + ) + + await actor.click(screen.getByRole('button', { name: '模拟小幅拖动第二节点' })) + + await waitFor(() => { + expect(screen.getByTestId('last-effective-transform')).toHaveTextContent('[110,708,0]') + }) + }) + + it('拖很远才进入吸附范围后,小幅拖动仍按进入吸附的位置保持吸住', async () => { + const actor = userEvent.setup() + + render( + 右侧业务区
} + />, + ) + + await actor.click(screen.getByRole('button', { name: '模拟远距离进入吸附后小幅拖动第二节点' })) + + await waitFor(() => { + expect(screen.getByTestId('last-effective-transform')).toHaveTextContent('[110,708,0]') + }) + }) + it('选中缺少吸附图样语义的节点时,会明确提示无法自动吸附', async () => { const actor = userEvent.setup() @@ -861,4 +1107,19 @@ describe('AssemblyWorkbenchV2', () => { expect(screen.getByTestId('context-menu')).toBeInTheDocument() expect(screen.getByRole('button', { name: '移除 3D 世界' })).toBeInTheDocument() }) + + it('右键菜单使用资产中心预览菜单的简洁浮层口径', async () => { + const actor = userEvent.setup() + + render() + + await openBoardBrowserAndLoadFirstVariant(actor) + await actor.click(screen.getByRole('button', { name: '模拟右键首节点' })) + + const menu = screen.getByTestId('context-menu') + expect(menu).toHaveAttribute('data-title', '') + expect(menu).toHaveAttribute('data-subtitle', '') + expect(menu).toHaveAttribute('data-width', '220') + expect(Number(menu.getAttribute('data-body-max-height'))).toBeGreaterThanOrEqual(240) + }) }) diff --git a/furniture-3d-system/src/components/platform/__tests__/AssetVariantThumbnailRenderer.test.tsx b/furniture-3d-system/src/components/platform/__tests__/AssetVariantThumbnailRenderer.test.tsx new file mode 100644 index 00000000..351d8677 --- /dev/null +++ b/furniture-3d-system/src/components/platform/__tests__/AssetVariantThumbnailRenderer.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react' +import { AssetVariantThumbnailRenderer } from '../AssetVariantThumbnailRenderer' + +jest.mock('next/dynamic', () => { + return function dynamic() { + return function MockModelViewer() { + return
+ } + } +}) + +describe('AssetVariantThumbnailRenderer', () => { + afterEach(() => { + delete document.body.dataset.assetThumbnailRenderer + delete document.body.dataset.thumbnailState + delete document.body.dataset.thumbnailError + }) + + it('marks the page as a thumbnail renderer so dev chrome is excluded from captures', () => { + const { unmount } = render( + , + ) + + expect(screen.getByTestId('thumbnail-model-viewer')).toBeInTheDocument() + expect(document.body.dataset.assetThumbnailRenderer).toBe('true') + expect(document.body.dataset.thumbnailState).toBe('loading') + + unmount() + + expect(document.body.dataset.assetThumbnailRenderer).toBeUndefined() + expect(document.body.dataset.thumbnailState).toBeUndefined() + }) +}) diff --git a/furniture-3d-system/src/components/ui/ContextMenu.tsx b/furniture-3d-system/src/components/ui/ContextMenu.tsx index 1cb8e178..a6eb14d8 100644 --- a/furniture-3d-system/src/components/ui/ContextMenu.tsx +++ b/furniture-3d-system/src/components/ui/ContextMenu.tsx @@ -248,7 +248,7 @@ export function ContextMenu({ onClick={() => onItemClick?.(item)} onMouseEnter={() => onItemMouseEnter?.(item)} onMouseLeave={onItemMouseLeave} - isActive={item.submenu === activeSubmenu} + isActive={Boolean(item.submenu && activeSubmenu && item.submenu === activeSubmenu)} /> ); })} diff --git a/furniture-3d-system/src/components/ui/__tests__/ContextMenu.test.tsx b/furniture-3d-system/src/components/ui/__tests__/ContextMenu.test.tsx new file mode 100644 index 00000000..5cf29ff4 --- /dev/null +++ b/furniture-3d-system/src/components/ui/__tests__/ContextMenu.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react' +import { ContextMenu } from '../ContextMenu' + +describe('ContextMenu', () => { + it('未传 activeSubmenu 时,普通菜单项不应该误用激活态样式', () => { + render( + , + ) + + const moveItem = screen.getByRole('button', { name: '移到待装区' }) + const removeItem = screen.getByRole('button', { name: '移除 3D 世界' }) + + expect(moveItem).toHaveClass('bg-transparent') + expect(removeItem).toHaveClass('bg-transparent') + expect(moveItem).not.toHaveClass('bg-white/95') + expect(removeItem).not.toHaveClass('bg-white/95') + }) + + it('只有命中 activeSubmenu 的子菜单项才使用激活态样式', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: '快捷更改基材 >' })).toHaveClass('bg-white/95') + expect(screen.getByRole('button', { name: '去辅助装配' })).toHaveClass('bg-transparent') + }) +}) diff --git a/furniture-3d-system/src/lib/3d/engine.ts b/furniture-3d-system/src/lib/3d/engine.ts index d49432cd..08111065 100644 --- a/furniture-3d-system/src/lib/3d/engine.ts +++ b/furniture-3d-system/src/lib/3d/engine.ts @@ -658,7 +658,35 @@ export class ThreeDEngine { this.scene.add(model); model.updateMatrixWorld(true); - const positionedBox = new THREE.Box3().setFromObject(model); + this.refreshMountedModelTracking(); + const positionedBox = new THREE.Box3().setFromObject(this.modelRoot); + if (!positionedBox.isEmpty()) { + if (autoFrame) { + this.frameCameraToBounds( + positionedBox, + cameraTarget ?? this.modelOriginalCenter.clone(), + ); + } else if (preservedCameraState) { + this.restoreCameraState(preservedCameraState); + } + } else if (preservedCameraState) { + this.restoreCameraState(preservedCameraState); + } + + return model; + } + + private refreshMountedModelTracking(): void { + if (!this.modelRoot) return; + + this.meshOriginalPositions.clear(); + this.meshOriginalWorldCenters.clear(); + this.modelOriginalCenter.set(0, 0, 0); + this.modelOriginalMaxDim = 0; + this.unresolvedPartWarnings.clear(); + + this.modelRoot.updateMatrixWorld(true); + const positionedBox = new THREE.Box3().setFromObject(this.modelRoot); if (!positionedBox.isEmpty()) { this.updateSceneGridForBounds(positionedBox); this.modelOriginalCenter.copy( @@ -672,7 +700,7 @@ export class ThreeDEngine { ); } - model.traverse((child) => { + this.modelRoot.traverse((child) => { if ((child as THREE.Mesh).isMesh) { const mesh = child as THREE.Mesh; ensureMeshUv(mesh); @@ -691,21 +719,6 @@ export class ThreeDEngine { } } }); - - if (!positionedBox.isEmpty()) { - if (autoFrame) { - this.frameCameraToBounds( - positionedBox, - cameraTarget ?? this.modelOriginalCenter.clone(), - ); - } else if (preservedCameraState) { - this.restoreCameraState(preservedCameraState); - } - } else if (preservedCameraState) { - this.restoreCameraState(preservedCameraState); - } - - return model; } private async loadModelAsset( @@ -826,6 +839,33 @@ export class ThreeDEngine { return groundedRoot; } + private async createWorldNodeWrapper( + node: EngineWorldNode, + ): Promise { + const wrapper = new THREE.Group(); + wrapper.name = node.nodeName; + const position = node.position ?? [0, 0, 0]; + const rotation = node.rotation ?? [0, 0, 0]; + const scale = node.scale ?? [1, 1, 1]; + const source = node.source ?? "unknown"; + + wrapper.position.set(position[0], position[1], position[2]); + wrapper.rotation.set(rotation[0], rotation[1], rotation[2]); + wrapper.scale.set(scale[0], scale[1], scale[2]); + + 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); + } + + wrapper.add(content); + return wrapper; + } + private applyExpectedDimensionsScale( model: THREE.Object3D, expectedDimensions?: ModelExpectedDimensions | null, @@ -912,27 +952,7 @@ export class ThreeDEngine { } for (const node of nodes) { - const wrapper = new THREE.Group(); - wrapper.name = node.nodeName; - const position = node.position ?? [0, 0, 0]; - const rotation = node.rotation ?? [0, 0, 0]; - const scale = node.scale ?? [1, 1, 1]; - const source = node.source ?? "unknown"; - - wrapper.position.set(position[0], position[1], position[2]); - wrapper.rotation.set(rotation[0], rotation[1], rotation[2]); - wrapper.scale.set(scale[0], scale[1], scale[2]); - - 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); - } - - wrapper.add(content); + const wrapper = await this.createWorldNodeWrapper(node); worldRoot.add(wrapper); } @@ -943,6 +963,48 @@ export class ThreeDEngine { }); } + async syncWorldNodes(nodes: EngineWorldNode[]): Promise { + if (!this.modelRoot || this.modelRoot.name !== "workspace-world") { + return this.loadWorldNodes(nodes, { frame: "initial" }); + } + + const nextNodeNames = new Set(nodes.map((node) => node.nodeName)); + const existingByName = new Map(); + this.modelRoot.children.forEach((child) => { + if (child.name) { + existingByName.set(child.name, child); + } + }); + + for (const child of [...this.modelRoot.children]) { + if (!nextNodeNames.has(child.name)) { + this.modelRoot.remove(child); + this.disposeSceneObjects(child); + } + } + + for (const node of nodes) { + let wrapper = existingByName.get(node.nodeName) as THREE.Group | undefined; + if (!wrapper) { + wrapper = await this.createWorldNodeWrapper(node); + this.modelRoot.add(wrapper); + } + + const position = node.position ?? [0, 0, 0]; + const rotation = node.rotation ?? [0, 0, 0]; + const scale = node.scale ?? [1, 1, 1]; + wrapper.position.set(position[0], position[1], position[2]); + wrapper.rotation.set(rotation[0], rotation[1], rotation[2]); + wrapper.scale.set(scale[0], scale[1], scale[2]); + wrapper.updateMatrix(); + wrapper.updateMatrixWorld(true); + } + + this.modelRoot.updateMatrixWorld(true); + this.refreshMountedModelTracking(); + return this.modelRoot; + } + /** * 璁剧疆鐖嗙偢鍥炬晥鏋? * @param progress 鐖嗙偢杩涘害锛?-1 * @param spread 鐖嗙偢鎵╂暎寮哄害 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 c6b9a46d..d1260d46 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 @@ -7,6 +7,7 @@ import { deriveAssemblyAutoSnapCandidate, deriveAssemblySaveValidation, deriveAssemblyValidation, + getAssemblyNodeHoleMarkers, instantiateAssemblyNode, moveAssemblyNodes, snapNodesIntoMainAssembly, @@ -166,7 +167,7 @@ describe('assembly-workspace', () => { id: 'leg-hole-cad-1', panelId: 'leg-panel', position: [0, 15, 0], - normal: [0, 0, -1], + normal: [0, 0, 1], diameter: 8, depth: 12, holeGroupId: 'leg-hole-group', @@ -204,6 +205,217 @@ describe('assembly-workspace', () => { }, } + const zmb005TeakDesktopVariant: OpenProductAssetVariant = { + ...desktopVariant, + id: 'variant-zmb005-teak', + assetFamilyId: 'family-desktop', + code: 'ZMB005-04', + name: 'ZMB005-桌面版-1200-柚木色', + dimensions: { width: 600, length: 1400, thickness: 25 }, + assemblyBasis: { + source: 'CAD_SINGLE_ASSET_IMPORT', + coordinateSystem: 'AUTOCAD_WORLD_Z_UP', + holes: [ + { + id: 'board-hole-leg-back', + panelId: 'panel_000', + position: [645, 208, 2.5], + normal: [0, 0, 1], + diameter: 12, + depth: 5, + surfaceFaces: ['bottom'], + }, + { + id: 'board-hole-leg-front', + panelId: 'panel_000', + position: [645, -208, 2.5], + normal: [0, 0, 1], + diameter: 12, + depth: 5, + surfaceFaces: ['bottom'], + }, + { + id: 'board-hole-leg-mid-back', + panelId: 'panel_000', + position: [645, 80, 2.5], + normal: [0, 0, 1], + diameter: 12, + depth: 5, + surfaceFaces: ['bottom'], + }, + { + id: 'board-hole-leg-mid-front', + panelId: 'panel_000', + position: [645, -80, 2.5], + normal: [0, 0, 1], + diameter: 12, + depth: 5, + surfaceFaces: ['bottom'], + }, + { + id: 'board-hole-small-a', + panelId: 'panel_000', + position: [285, 171.5, 2.5], + normal: [0, 0, 1], + diameter: 5.6, + depth: 5, + surfaceFaces: ['bottom'], + }, + { + id: 'board-hole-small-b', + panelId: 'panel_000', + position: [285, 253.5, 2.5], + normal: [0, 0, 1], + diameter: 5.6, + depth: 5, + surfaceFaces: ['bottom'], + }, + ], + mountPatterns: [ + { + id: 'board-leg-pattern', + panelId: 'panel_000', + mountFace: 'bottom', + snapFace: 'bottom', + normal: [0, 0, -1], + holeIds: [ + 'board-hole-leg-back', + 'board-hole-leg-front', + 'board-hole-leg-mid-back', + 'board-hole-leg-mid-front', + ], + requiredHoleIds: [ + 'board-hole-leg-back', + 'board-hole-leg-front', + 'board-hole-leg-mid-back', + 'board-hole-leg-mid-front', + ], + minRequiredHoles: 4, + preferredHoleCount: 4, + confidence: 0.64, + }, + { + id: 'board-small-pattern', + panelId: 'panel_000', + mountFace: 'bottom', + snapFace: 'bottom', + normal: [0, 0, -1], + holeIds: ['board-hole-small-a', 'board-hole-small-b'], + requiredHoleIds: ['board-hole-small-a', 'board-hole-small-b'], + minRequiredHoles: 2, + preferredHoleCount: 2, + confidence: 1, + }, + ], + snapSurfaces: [ + { + id: 'board-bottom-surface', + panelId: 'panel_000', + face: 'bottom', + center: [0, 0, 0], + normal: [0, 0, -1], + bounds: { min: [-700, -300, 0], max: [700, 300, 0] }, + confidence: 1, + }, + { + id: 'board-top-surface', + panelId: 'panel_000', + face: 'top', + center: [0, 0, 25], + normal: [0, 0, 1], + bounds: { min: [-700, -300, 25], max: [700, 300, 25] }, + confidence: 1, + }, + ], + }, + } + + const zt005LeftLegVariant: OpenProductAssetVariant = { + ...legVariant, + id: 'variant-zt005-left', + assetFamilyId: 'family-leg', + code: 'ZT005-005-2', + name: 'ZT005-桌腿-左-黑色金属', + assetType: 'BOARD', + dimensions: { width: 590, length: 700, thickness: 60 }, + assemblyBasis: { + source: 'CAD_SINGLE_ASSET_IMPORT', + coordinateSystem: 'AUTOCAD_WORLD_Z_UP', + holes: [ + { + id: 'leg-hole-front-mid', + panelId: 'panel_000', + position: [0, -80, 685], + normal: [0, 0, -1], + diameter: 6, + depth: 30, + surfaceFaces: ['top'], + }, + { + id: 'leg-hole-back', + panelId: 'panel_000', + position: [0, 208, 685], + normal: [0, 0, -1], + diameter: 6, + depth: 30, + surfaceFaces: ['top'], + }, + { + id: 'leg-hole-back-mid', + panelId: 'panel_000', + position: [0, 80, 685], + normal: [0, 0, -1], + diameter: 6, + depth: 30, + surfaceFaces: ['top'], + }, + { + id: 'leg-hole-front', + panelId: 'panel_000', + position: [0, -208, 685], + normal: [0, 0, -1], + diameter: 6, + depth: 30, + surfaceFaces: ['top'], + }, + ], + mountPatterns: [ + { + id: 'leg-top-pattern', + panelId: 'panel_000', + mountFace: 'top', + snapFace: 'top', + normal: [0, 0, 1], + holeIds: ['leg-hole-front', 'leg-hole-front-mid', 'leg-hole-back-mid', 'leg-hole-back'], + requiredHoleIds: ['leg-hole-front', 'leg-hole-front-mid', 'leg-hole-back-mid', 'leg-hole-back'], + minRequiredHoles: 4, + preferredHoleCount: 4, + confidence: 0.64, + }, + ], + snapSurfaces: [ + { + id: 'leg-bottom-surface', + panelId: 'panel_000', + face: 'bottom', + center: [0, 0, 0], + normal: [0, 0, -1], + bounds: { min: [-30, -295, 0], max: [30, 295, 0] }, + confidence: 1, + }, + { + id: 'leg-top-surface', + panelId: 'panel_000', + face: 'top', + center: [0, 0, 700], + normal: [0, 0, 1], + bounds: { min: [-30, -295, 700], max: [30, 295, 700] }, + confidence: 1, + }, + ], + }, + } + const convertMountPatternsToSnapPatterns = ( variant: OpenProductAssetVariant, ): OpenProductAssetVariant => { @@ -350,8 +562,12 @@ describe('assembly-workspace', () => { position: [0, 720, 0], }) const snapped = attemptSnapNode(moved, withLeg.nodes[1]!.nodeId) + const candidate = deriveAssemblyAutoSnapCandidate(moved, withLeg.nodes[1]!.nodeId) - expect(snapped.nodes[1]?.position).toEqual([0, 720, 0]) + expect(candidate?.previewPosition).toEqual([0, 708, 0]) + expect(candidate?.sourceHoleWorldPosition).toEqual([0, 708, 0]) + expect(candidate?.targetHoleWorldPosition).toEqual([0, 720, 0]) + expect(snapped.nodes[1]?.position).toEqual([0, 708, 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({ @@ -461,6 +677,7 @@ describe('assembly-workspace', () => { variant: { ...legVariant, id: 'variant-leg-no-hole', + mountPoints: [], assemblyBasis: { attachTo: 'desktop' }, }, }) @@ -479,6 +696,108 @@ describe('assembly-workspace', () => { }) }) + it('组件缺少孔位语义但有装配点时,会用装配点补齐孔位高亮与孔位吸附语义', () => { + const withDesktop = instantiateAssemblyNode(baseWorkspace, { + inventoryId: 'desktop:family-desktop:1', + variant: desktopVariant, + }) + const withMountPointLeg = instantiateAssemblyNode(withDesktop, { + inventoryId: 'leg:family-leg:2', + variant: { + ...legVariant, + id: 'variant-leg-mount-point-only', + mountPoints: [{ name: 'leg-top', position: [0, 360, 0] }], + assemblyBasis: { attachTo: 'desktop' }, + }, + }) + + const legNode = withMountPointLeg.nodes[1]! + const markers = getAssemblyNodeHoleMarkers(legNode) + expect(markers).toEqual([ + expect.objectContaining({ + id: 'mount-point:leg-top-1', + holeType: 'mount-point', + position: [legNode.position[0], legNode.position[1] + 360, legNode.position[2]], + }), + ]) + + const moved = updateAssemblyNodeTransform(withMountPointLeg, legNode.nodeId, { + position: [0, 360, 0], + }) + const candidate = deriveAssemblyAutoSnapCandidate(moved, legNode.nodeId) + + expect(candidate).toMatchObject({ + sourceHoleId: 'mount-point:leg-top-1', + targetHoleId: 'board-hole-1', + sourceMountPatternId: 'mount-point-pattern:mount-point:leg-top-1', + targetMountPatternId: 'board-pattern-1', + }) + + const snapped = attemptSnapNode(moved, legNode.nodeId) + expect(snapped.nodes[1]?.isSnapped).toBe(true) + expect(snapped.nodes[1]?.snapStatus).toBe('hole-snap') + expect(snapped.nodes[1]?.position).toEqual([0, 357.5, 0]) + }) + + it('CAD 板件孔 normal 与接触面 normal 不一致时,孔位吸附优先使用图样法线', () => { + const cadDesktopWithPatternNormal: OpenProductAssetVariant = { + ...desktopVariant, + id: 'variant-desktop-cad-pattern-normal', + assemblyBasis: { + source: 'CAD_SINGLE_ASSET_IMPORT', + coordinateSystem: 'AUTOCAD_WORLD_Z_UP', + holes: [ + { + id: 'cad-board-hole-1', + panelId: 'cad-board-panel', + position: [0, 0, 2.5], + normal: [0, 0, 1], + diameter: 8, + depth: 5, + surfaceFaces: ['bottom'], + }, + ], + snapPatterns: [ + { + id: 'cad-board-bottom-pattern', + panelId: 'cad-board-panel', + mountFace: 'bottom', + snapFace: 'bottom', + normal: [0, 0, -1], + holeIds: ['cad-board-hole-1'], + minRequiredHoles: 1, + }, + ], + }, + } + const withDesktop = instantiateAssemblyNode(baseWorkspace, { + inventoryId: 'desktop:family-desktop:1', + variant: cadDesktopWithPatternNormal, + }) + const withMountPointLeg = instantiateAssemblyNode(withDesktop, { + inventoryId: 'leg:family-leg:2', + variant: { + ...legVariant, + id: 'variant-leg-mount-point-to-cad-board', + mountPoints: [{ name: 'leg-top', position: [0, 360, 0] }], + assemblyBasis: { attachTo: 'desktop' }, + }, + }) + const legNode = withMountPointLeg.nodes[1]! + const moved = updateAssemblyNodeTransform(withMountPointLeg, legNode.nodeId, { + position: [0, 362.5, 0], + }) + + const candidate = deriveAssemblyAutoSnapCandidate(moved, legNode.nodeId) + + expect(candidate).toMatchObject({ + sourceHoleId: 'mount-point:leg-top-1', + targetHoleId: 'cad-board-hole-1', + targetMountPatternId: 'cad-board-bottom-pattern', + targetHoleNormal: [0, -1, 0], + }) + }) + it('缺少孔位或缺少吸附图样时会给出明确状态且不会崩溃', () => { const withDesktop = instantiateAssemblyNode(baseWorkspace, { inventoryId: 'desktop:family-desktop:1', @@ -695,6 +1014,532 @@ describe('assembly-workspace', () => { expect(activeCandidate?.targetNodeId).toBe(withDesktop.nodes[0]?.nodeId) }) + it('孔位进入 6cm 范围才触发自动吸附候选', () => { + const withDesktop = instantiateAssemblyNode(baseWorkspace, { + inventoryId: 'desktop:family-desktop:1', + variant: desktopVariant, + }) + const withLeg = instantiateAssemblyNode(withDesktop, { + inventoryId: 'leg:family-leg:2', + variant: legVariant, + }) + const legNodeId = withLeg.nodes[1]!.nodeId + + const withinSixCentimeters = updateAssemblyNodeTransform(withLeg, legNodeId, { + position: [60, 720, 0], + }) + const outsideSixCentimeters = updateAssemblyNodeTransform(withLeg, legNodeId, { + position: [61, 720, 0], + }) + + expect(deriveAssemblyAutoSnapCandidate(withinSixCentimeters, legNodeId)).toMatchObject({ + sourceHoleId: 'leg-hole-1', + targetHoleId: 'board-hole-1', + }) + expect(deriveAssemblyAutoSnapCandidate(outsideSixCentimeters, legNodeId)).toBeNull() + }) + + it('真实 ZMB005 桌面板与 ZT005 左桌腿会按 6mm/12mm 接收孔吸附,并用接触面避免穿模', () => { + const withDesktop = instantiateAssemblyNode(baseWorkspace, { + inventoryId: 'desktop:family-desktop:1', + variant: zmb005TeakDesktopVariant, + }) + const withLeg = instantiateAssemblyNode(withDesktop, { + inventoryId: 'leg:family-leg:2', + variant: zt005LeftLegVariant, + }) + const legNodeId = withLeg.nodes[1]!.nodeId + + const moved = updateAssemblyNodeTransform(withLeg, legNodeId, { + position: [645, 37.5, 0], + }) + const candidate = deriveAssemblyAutoSnapCandidate(moved, legNodeId) + const preview = applyAutomaticAssemblySnapPreview(moved, legNodeId, { + fallbackZone: 'staging', + }) + const snapped = attemptSnapNode(preview, legNodeId) + + expect(candidate).toMatchObject({ + snapMode: 'hole', + sourceMountPatternId: 'leg-top-pattern', + targetMountPatternId: 'board-leg-pattern', + matchedHoleCount: 4, + requiredHoleCount: 4, + previewPosition: [645, 20, 0], + }) + expect(candidate?.matchedHolePairs).toHaveLength(4) + expect(candidate?.matchedHolePairs.map((pair) => pair.sourceHoleId)).toEqual( + expect.arrayContaining([ + 'leg-hole-front', + 'leg-hole-front-mid', + 'leg-hole-back-mid', + 'leg-hole-back', + ]), + ) + expect(preview.nodes[1]?.position).toEqual([645, 20, 0]) + expect(preview.nodes[1]?.snapStatus).toBe('hole-preview') + expect(snapped.nodes[1]?.snapStatus).toBe('hole-snap') + }) + + it('真实 CAD 面吸附按面平面和切线重叠判断,不会把桌腿拉到桌面中心', () => { + const withDesktop = instantiateAssemblyNode(baseWorkspace, { + inventoryId: 'desktop:family-desktop:1', + variant: zmb005TeakDesktopVariant, + }) + const withLeg = instantiateAssemblyNode(withDesktop, { + inventoryId: 'leg:family-leg:2', + variant: zt005LeftLegVariant, + }) + const legNodeId = withLeg.nodes[1]!.nodeId + + const moved = updateAssemblyNodeTransform(withLeg, legNodeId, { + position: [100, 78, 100], + }) + const candidate = deriveAssemblyAutoSnapCandidate(moved, legNodeId) + + expect(candidate).toMatchObject({ + snapMode: 'surface', + sourceSurfaceId: 'leg-top-surface', + targetSurfaceId: 'board-bottom-surface', + previewPosition: [100, 20, 100], + }) + }) + + it('拖动中预吸附会按用户拖动幅度释放,避免控制器和吸附位置互相抢回', () => { + const withDesktop = instantiateAssemblyNode(baseWorkspace, { + inventoryId: 'desktop:family-desktop:1', + variant: zmb005TeakDesktopVariant, + }) + const withLeg = instantiateAssemblyNode(withDesktop, { + inventoryId: 'leg:family-leg:2', + variant: zt005LeftLegVariant, + }) + const legNodeId = withLeg.nodes[1]!.nodeId + const preview = applyAutomaticAssemblySnapPreview( + updateAssemblyNodeTransform(withLeg, legNodeId, { + position: [645, 37.5, 0], + }), + legNodeId, + { fallbackZone: 'staging' }, + ) + + const smallDrag = updateAssemblyNodeTransform(preview, legNodeId, { + position: [675, 20, 0], + preserveSnapPreview: true, + }) + const held = applyAutomaticAssemblySnapPreview(smallDrag, legNodeId, { + fallbackZone: 'staging', + heldReleaseDistanceMm: 30, + }) + const largeDrag = updateAssemblyNodeTransform(held, legNodeId, { + position: [800, 20, 0], + preserveSnapPreview: true, + }) + const released = applyAutomaticAssemblySnapPreview(largeDrag, legNodeId, { + fallbackZone: 'staging', + heldReleaseDistanceMm: 155, + }) + + expect(held.nodes[1]?.position).toEqual([675, 20, 0]) + expect(held.nodes[1]?.snapStatus).toBe('hole-preview') + expect(released.nodes[1]?.zone).toBe('staging') + expect(released.nodes[1]?.snapBinding ?? null).toBeNull() + expect(released.nodes[1]?.snapStatus ?? null).toBeNull() + }) + + it('拖动中的预吸附会吸住模型本体,并在小幅拖动时保持吸附', () => { + const withDesktop = instantiateAssemblyNode(baseWorkspace, { + inventoryId: 'desktop:family-desktop:1', + variant: desktopVariant, + }) + const withLeg = instantiateAssemblyNode(withDesktop, { + inventoryId: 'leg:family-leg:2', + variant: legVariant, + }) + const legNodeId = withLeg.nodes[1]!.nodeId + + const preview = applyAutomaticAssemblySnapPreview( + updateAssemblyNodeTransform(withLeg, legNodeId, { + position: [0, 720, 0], + }), + legNodeId, + { fallbackZone: 'staging' }, + ) + const slightlyDragged = updateAssemblyNodeTransform(preview, legNodeId, { + position: [110, 720, 0], + preserveSnapPreview: true, + }) + const heldPreview = applyAutomaticAssemblySnapPreview(slightlyDragged, legNodeId, { + fallbackZone: 'staging', + }) + const pulledAway = updateAssemblyNodeTransform(heldPreview, legNodeId, { + position: [121, 720, 0], + preserveSnapPreview: true, + }) + const releasedPreview = applyAutomaticAssemblySnapPreview(pulledAway, legNodeId, { + fallbackZone: 'staging', + }) + + expect(preview.nodes[1]?.position).toEqual([0, 708, 0]) + expect(preview.nodes[1]?.snapStatus).toBe('hole-preview') + expect(heldPreview.nodes[1]?.position).toEqual([110, 708, 0]) + expect(heldPreview.nodes[1]?.snapStatus).toBe('hole-preview') + expect(heldPreview.nodes[1]?.snapBinding).toMatchObject({ + targetHoleId: 'board-hole-1', + }) + expect(releasedPreview.nodes[1]?.zone).toBe('staging') + expect(releasedPreview.nodes[1]?.snapBinding ?? null).toBeNull() + expect(releasedPreview.nodes[1]?.snapStatus ?? null).toBeNull() + }) + + it('已经进入预吸附后,小幅双轴拖动优先保持当前孔位绑定,不在相邻候选之间跳动', () => { + const twoHoleDesktopVariant: OpenProductAssetVariant = { + ...desktopVariant, + id: 'variant-desktop-two-close-holes', + assemblyBasis: { + ...desktopVariant.assemblyBasis, + holes: [ + { + id: 'board-hole-1', + panelId: 'board-panel', + position: [0, 0, 0], + normal: [0, -1, 0], + diameter: 8, + depth: 12, + holeGroupId: 'board-hole-group-1', + surfaceFaces: ['top'], + }, + { + id: 'board-hole-2', + panelId: 'board-panel', + position: [100, 0, 0], + normal: [0, -1, 0], + diameter: 8, + depth: 12, + holeGroupId: 'board-hole-group-2', + surfaceFaces: ['top'], + }, + ], + holeGroups: [ + { + id: 'board-hole-group-1', + panelId: 'board-panel', + groupType: 'single', + mountFace: 'top', + holeIds: ['board-hole-1'], + surfaceFaces: ['top'], + }, + { + id: 'board-hole-group-2', + panelId: 'board-panel', + groupType: 'single', + mountFace: 'top', + holeIds: ['board-hole-2'], + surfaceFaces: ['top'], + }, + ], + mountPatterns: [ + { + id: 'board-pattern-1', + panelId: 'board-panel', + mountFace: 'top', + patternType: 'single', + holeIds: ['board-hole-1'], + requiredHoleIds: ['board-hole-1'], + minRequiredHoles: 1, + preferredHoleCount: 1, + confidence: 0.98, + }, + { + id: 'board-pattern-2', + panelId: 'board-panel', + mountFace: 'top', + patternType: 'single', + holeIds: ['board-hole-2'], + requiredHoleIds: ['board-hole-2'], + minRequiredHoles: 1, + preferredHoleCount: 1, + confidence: 0.98, + }, + ], + }, + } + const withDesktop = instantiateAssemblyNode(baseWorkspace, { + inventoryId: 'desktop:family-desktop:1', + variant: twoHoleDesktopVariant, + }) + const withLeg = instantiateAssemblyNode(withDesktop, { + inventoryId: 'leg:family-leg:2', + variant: legVariant, + }) + const legNodeId = withLeg.nodes[1]!.nodeId + const preview = applyAutomaticAssemblySnapPreview( + updateAssemblyNodeTransform(withLeg, legNodeId, { + position: [0, 720, 0], + }), + legNodeId, + { fallbackZone: 'staging' }, + ) + const draggedNearSecondHole = updateAssemblyNodeTransform(preview, legNodeId, { + position: [100, 720, 0], + preserveSnapPreview: true, + }) + const held = applyAutomaticAssemblySnapPreview(draggedNearSecondHole, legNodeId, { + fallbackZone: 'staging', + heldReleaseDistanceMm: 100, + }) + + expect(deriveAssemblyAutoSnapCandidate(draggedNearSecondHole, legNodeId)).toMatchObject({ + targetHoleId: 'board-hole-2', + previewPosition: [100, 708, 0], + }) + expect(held.nodes[1]?.snapBinding).toMatchObject({ + targetHoleId: 'board-hole-1', + }) + expect(held.nodes[1]?.position).toEqual([100, 708, 0]) + }) + + it('目标节点还在待装区时,也能实时孔位预吸附并在松手后一起进入主装配', () => { + const targetVariant: OpenProductAssetVariant = { + ...legVariant, + id: 'variant-floating-target', + code: 'FLOATING-TARGET-001', + name: '待装目标板', + assemblyBasis: { + attachTo: 'component', + holes: [ + { + id: 'target-hole-1', + panelId: 'target-panel', + position: [0, 0, 0], + normal: [0, -1, 0], + diameter: 8, + depth: 12, + holeGroupId: 'target-hole-group', + surfaceFaces: ['top'], + }, + ], + holeGroups: [ + { + id: 'target-hole-group', + panelId: 'target-panel', + groupType: 'pair', + mountFace: 'top', + holeIds: ['target-hole-1'], + surfaceFaces: ['top'], + }, + ], + mountPatterns: [ + { + id: 'target-pattern-1', + panelId: 'target-panel', + mountFace: 'top', + patternType: 'single', + holeIds: ['target-hole-1'], + requiredHoleIds: ['target-hole-1'], + optionalHoleIds: [], + minRequiredHoles: 1, + preferredHoleCount: 1, + confidence: 0.98, + }, + ], + }, + } + const withTarget = instantiateAssemblyNode(baseWorkspace, { + inventoryId: 'leg:family-leg:2', + variant: targetVariant, + }) + const withSource = instantiateAssemblyNode(withTarget, { + inventoryId: 'leg:family-leg:2', + variant: legVariant, + }) + const targetNode = withSource.nodes[0]! + const sourceNode = withSource.nodes[1]! + + expect(targetNode.zone).toBe('staging') + expect(sourceNode.zone).toBe('staging') + + const moved = updateAssemblyNodeTransform(withSource, sourceNode.nodeId, { + position: targetNode.position, + }) + const candidate = deriveAssemblyAutoSnapCandidate(moved, sourceNode.nodeId) + const preview = applyAutomaticAssemblySnapPreview(moved, sourceNode.nodeId, { + fallbackZone: 'staging', + }) + const snapped = attemptSnapNode(preview, sourceNode.nodeId) + + expect(candidate).toMatchObject({ + sourceNodeId: sourceNode.nodeId, + targetNodeId: targetNode.nodeId, + sourceHoleId: 'leg-hole-1', + targetHoleId: 'target-hole-1', + }) + expect(preview.nodes[0]?.zone).toBe('staging') + expect(preview.nodes[1]?.zone).toBe('main') + expect(preview.nodes[1]?.snapStatus).toBe('hole-preview') + expect(snapped.nodes[0]?.zone).toBe('main') + expect(snapped.nodes[0]?.isSnapped).toBe(true) + expect(snapped.nodes[1]?.zone).toBe('main') + expect(snapped.nodes[1]?.snapStatus).toBe('hole-snap') + expect(snapped.nodes[1]?.snapBinding).toMatchObject({ + targetNodeId: targetNode.nodeId, + targetHoleId: 'target-hole-1', + }) + }) + + it('面吸附也不要求目标节点预先在主装配区', () => { + const targetVariant: OpenProductAssetVariant = { + ...legVariant, + id: 'variant-surface-target', + code: 'SURFACE-TARGET-001', + name: '待装面目标', + mountPoints: [], + assemblyBasis: { + attachTo: 'component', + snapSurfaces: [ + { + id: 'target-surface-1', + panelId: 'target-surface-panel', + face: 'top', + center: [0, 0, 0], + normal: [0, -1, 0], + confidence: 0.98, + }, + ], + }, + } + const sourceVariant: OpenProductAssetVariant = { + ...legVariant, + id: 'variant-surface-source', + code: 'SURFACE-SOURCE-001', + name: '待装面源', + mountPoints: [], + assemblyBasis: { + attachTo: 'component', + snapSurfaces: [ + { + id: 'source-surface-1', + panelId: 'source-surface-panel', + face: 'bottom', + center: [0, 0, 0], + normal: [0, 1, 0], + confidence: 0.98, + }, + ], + }, + } + const withTarget = instantiateAssemblyNode(baseWorkspace, { + inventoryId: 'leg:family-leg:2', + variant: targetVariant, + }) + const withSource = instantiateAssemblyNode(withTarget, { + inventoryId: 'leg:family-leg:2', + variant: sourceVariant, + }) + const targetNode = withSource.nodes[0]! + const sourceNode = withSource.nodes[1]! + const moved = updateAssemblyNodeTransform(withSource, sourceNode.nodeId, { + position: targetNode.position, + }) + const candidate = deriveAssemblyAutoSnapCandidate(moved, sourceNode.nodeId) + const snapped = attemptSnapNode(moved, sourceNode.nodeId) + + expect(candidate).toMatchObject({ + snapMode: 'surface', + sourceNodeId: sourceNode.nodeId, + targetNodeId: targetNode.nodeId, + sourceSurfaceId: 'source-surface-1', + targetSurfaceId: 'target-surface-1', + }) + expect(snapped.nodes[0]?.zone).toBe('main') + expect(snapped.nodes[1]?.zone).toBe('main') + expect(snapped.nodes[1]?.snapBinding).toMatchObject({ + snapMode: 'surface', + targetSurfaceId: 'target-surface-1', + }) + }) + + it('面吸附粗筛会使用 surface bounds,允许零件吸附到大板件角落而不是只看面中心', () => { + const targetVariant: OpenProductAssetVariant = { + ...legVariant, + id: 'variant-wide-surface-target', + code: 'SURFACE-TARGET-WIDE-001', + name: '大面目标板', + mountPoints: [], + assemblyBasis: { + attachTo: 'component', + snapSurfaces: [ + { + id: 'target-surface-wide', + panelId: 'target-surface-panel', + face: 'top', + center: [0, 0, 0], + normal: [0, -1, 0], + bounds: { + min: [-600, 0, -300], + max: [600, 0, 300], + }, + confidence: 0.98, + }, + ], + }, + } + const sourceVariant: OpenProductAssetVariant = { + ...legVariant, + id: 'variant-small-surface-source', + code: 'SURFACE-SOURCE-SMALL-001', + name: '小面源零件', + mountPoints: [], + assemblyBasis: { + attachTo: 'component', + snapSurfaces: [ + { + id: 'source-surface-small', + panelId: 'source-surface-panel', + face: 'bottom', + center: [0, 0, 0], + normal: [0, 1, 0], + bounds: { + min: [-40, 0, -40], + max: [40, 0, 40], + }, + confidence: 0.98, + }, + ], + }, + } + const withTarget = instantiateAssemblyNode(baseWorkspace, { + inventoryId: 'leg:family-leg:2', + variant: targetVariant, + }) + const withSource = instantiateAssemblyNode(withTarget, { + inventoryId: 'leg:family-leg:2', + variant: sourceVariant, + }) + const targetNode = withSource.nodes[0]! + const sourceNode = withSource.nodes[1]! + const sourceNearCornerPosition: [number, number, number] = [ + targetNode.position[0] + 520, + targetNode.position[1] + 58, + targetNode.position[2] + 250, + ] + const moved = updateAssemblyNodeTransform(withSource, sourceNode.nodeId, { + position: sourceNearCornerPosition, + }) + const candidate = deriveAssemblyAutoSnapCandidate(moved, sourceNode.nodeId) + + expect(candidate).toMatchObject({ + snapMode: 'surface', + sourceSurfaceId: 'source-surface-small', + targetSurfaceId: 'target-surface-wide', + previewPosition: [ + sourceNearCornerPosition[0], + targetNode.position[1], + sourceNearCornerPosition[2], + ], + }) + }) + it('assemblyBasis.snapPatterns 新字段可以替代旧 mountPatterns 参与孔位吸附', () => { const withDesktop = instantiateAssemblyNode(baseWorkspace, { inventoryId: 'desktop:family-desktop:1', @@ -717,6 +1562,106 @@ describe('assembly-workspace', () => { expect(candidate?.targetHoleId).toBe('board-hole-1') }) + it('任意板材节点都可以按孔位互相吸附,不依赖主锚点角色', () => { + const withDesktop = instantiateAssemblyNode(baseWorkspace, { + inventoryId: 'desktop:family-desktop:1', + variant: desktopVariant, + }) + const boardSupportVariant: OpenProductAssetVariant = { + ...legVariant, + id: 'variant-board-support', + assetFamilyId: 'family-desktop', + code: 'BOARD-SUPPORT-001', + name: '板材桌腿', + assetType: 'BOARD', + assemblyBasis: { + holes: [ + { + id: 'board-support-hole-1', + panelId: 'board-support-panel', + position: [0, 0, 0], + normal: [0, 1, 0], + diameter: 8, + depth: 12, + holeGroupId: 'board-support-hole-group', + holeGroupType: 'pair', + holeGroupRole: 'mount', + surfaceFaces: ['bottom'], + }, + ], + holeGroups: [ + { + id: 'board-support-hole-group', + panelId: 'board-support-panel', + groupType: 'pair', + groupRole: 'mount', + mountFace: 'bottom', + holeIds: ['board-support-hole-1'], + surfaceFaces: ['bottom'], + }, + ], + mountPatterns: [ + { + id: 'board-support-pattern-1', + panelId: 'board-support-panel', + mountFace: 'bottom', + patternType: 'single', + holeIds: ['board-support-hole-1'], + requiredHoleIds: ['board-support-hole-1'], + optionalHoleIds: [], + minRequiredHoles: 1, + preferredHoleCount: 1, + symmetry: false, + confidence: 0.98, + }, + ], + }, + compatibilityRules: null, + } + const withBoardSupport = instantiateAssemblyNode(withDesktop, { + inventoryId: 'desktop:family-desktop:1', + variant: boardSupportVariant, + }) + const moved = updateAssemblyNodeTransform( + withBoardSupport, + withBoardSupport.nodes[1]!.nodeId, + { position: [0, 720, 0] }, + ) + const candidate = deriveAssemblyAutoSnapCandidate(moved, moved.nodes[1]!.nodeId) + const snapped = attemptSnapNode(moved, moved.nodes[1]!.nodeId) + + expect(withBoardSupport.nodes[1]?.source).toBe('desktop') + expect(candidate).toMatchObject({ + sourceNodeId: moved.nodes[1]?.nodeId, + targetNodeId: moved.nodes[0]?.nodeId, + sourceHoleId: 'board-support-hole-1', + targetHoleId: 'board-hole-1', + }) + expect(snapped.nodes[1]?.isSnapped).toBe(true) + expect(snapped.validation.warnings).not.toEqual( + expect.arrayContaining([expect.stringContaining('仍处于待连接状态')]), + ) + + const bothInMain = updateAssemblyNodeTransform( + moveAssemblyNodes(withBoardSupport, [withBoardSupport.nodes[1]!.nodeId], 'main'), + withBoardSupport.nodes[1]!.nodeId, + { position: [0, 720, 0] }, + ) + const primaryMoved = updateAssemblyNodeTransform( + bothInMain, + bothInMain.nodes[0]!.nodeId, + { position: [0, 720, 0] }, + ) + const primaryCandidate = deriveAssemblyAutoSnapCandidate(primaryMoved, primaryMoved.nodes[0]!.nodeId) + + expect(primaryCandidate).toMatchObject({ + sourceNodeId: primaryMoved.nodes[0]?.nodeId, + targetNodeId: primaryMoved.nodes[1]?.nodeId, + sourceHoleId: 'board-hole-1', + targetHoleId: 'board-support-hole-1', + }) + }) + it('CAD 坐标系孔位会先转成 Three.js 坐标,再参与孔位吸附候选', () => { const withDesktop = instantiateAssemblyNode(baseWorkspace, { inventoryId: 'desktop:family-desktop:1', @@ -728,7 +1673,7 @@ describe('assembly-workspace', () => { }) const moved = updateAssemblyNodeTransform(withCadLeg, withCadLeg.nodes[1]!.nodeId, { - position: [0, 720, -15], + position: [0, 720, 15], }) const candidate = deriveAssemblyAutoSnapCandidate(moved, moved.nodes[1]!.nodeId) 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 44445cab..d7218852 100644 --- a/furniture-3d-system/src/lib/open-product/assembly-workspace.ts +++ b/furniture-3d-system/src/lib/open-product/assembly-workspace.ts @@ -156,6 +156,7 @@ export interface AssemblyAutoSnapCandidate { targetHoleWorldPosition: [number, number, number] sourceHoleNormal: [number, number, number] targetHoleNormal: [number, number, number] + matchedHolePairs: AssemblyAutoSnapHolePair[] distanceMm: number isValid: boolean rejectReason: string | null @@ -227,12 +228,19 @@ interface BuildSingleBoardDeskAssemblyWorkspaceInput { } const SNAP_DISTANCE_THRESHOLD = 180 -const HOLE_SNAP_DISTANCE_THRESHOLD_MM = 120 +const HOLE_SNAP_DISTANCE_THRESHOLD_MM = 60 const HOLE_DIAMETER_TOLERANCE_MM = 2 +const HOLE_RECEIVER_DIAMETER_RATIO_MAX = 2.25 const HOLE_NORMAL_ALIGNMENT_DOT_MAX = -0.45 -const SURFACE_SNAP_DISTANCE_THRESHOLD_MM = 96 +const SURFACE_SNAP_DISTANCE_THRESHOLD_MM = 60 const SURFACE_NORMAL_ALIGNMENT_DOT_MAX = -0.55 const SPACE_CONFLICT_DISTANCE_THRESHOLD_MM = 60 +const SNAP_CONTACT_CORRECTION_MAX_MM = 80 +const AUTO_SNAP_BROAD_PHASE_PADDING_MM = Math.max( + HOLE_SNAP_DISTANCE_THRESHOLD_MM, + SURFACE_SNAP_DISTANCE_THRESHOLD_MM, +) +const AUTO_SNAP_RELEASE_DISTANCE_MM = 120 const DEFAULT_DESKTOP_POSITION: [number, number, number] = [0, 720, 0] @@ -272,7 +280,9 @@ interface AssemblyBasisHoleRecord { export interface AssemblyNodeHoleMarker { id: string panelId: string | null + localPosition: [number, number, number] position: [number, number, number] + localNormal: [number, number, number] normal: [number, number, number] diameter: number | null depth: number | null @@ -283,6 +293,7 @@ interface AssemblyBasisMountPatternRecord { id: string panelId: string | null mountFace: string | null + normal: [number, number, number] | null holeIds: string[] minRequiredHoles: number } @@ -312,6 +323,29 @@ interface AssemblyMatchedHolePair { targetFace: string | null } +export interface AssemblyAutoSnapHolePair { + sourceHoleId: string + targetHoleId: string + sourceHolePanelId: string | null + targetHolePanelId: string | null + sourceHoleDiameter: number | null + targetHoleDiameter: number | null + sourceHoleDepth: number | null + targetHoleDepth: number | null + sourceFace: string | null + targetFace: string | null + sourceHoleWorldPosition: [number, number, number] + targetHoleWorldPosition: [number, number, number] + sourceHoleNormal: [number, number, number] + targetHoleNormal: [number, number, number] + distanceMm: number +} + +interface WorldBounds { + min: THREE.Vector3 + max: THREE.Vector3 +} + function cloneVector3( input: [number, number, number] | undefined | null, ): [number, number, number] { @@ -404,24 +438,29 @@ function readBoundsTuple(value: unknown): { min: [number, number, number]; max: return min && max ? { min, max } : null } +function getLocalBoundsCorners( + bounds: { min: [number, number, number]; max: [number, number, number] }, +) { + const { min, max } = bounds + return [ + [min[0], min[1], min[2]], + [min[0], min[1], max[2]], + [min[0], max[1], min[2]], + [min[0], max[1], max[2]], + [max[0], min[1], min[2]], + [max[0], min[1], max[2]], + [max[0], max[1], min[2]], + [max[0], max[1], max[2]], + ] as Array<[number, number, number]> +} + function getWorldBounds( node: Pick, localBounds: { min: [number, number, number]; max: [number, number, number] }, -): { min: THREE.Vector3; max: THREE.Vector3 } | null { - const worldMin = getNodeWorldPoint(node, localBounds.min) - const worldMax = getNodeWorldPoint(node, localBounds.max) - return { - min: new THREE.Vector3( - Math.min(worldMin.x, worldMax.x), - Math.min(worldMin.y, worldMax.y), - Math.min(worldMin.z, worldMax.z), - ), - max: new THREE.Vector3( - Math.max(worldMin.x, worldMax.x), - Math.max(worldMin.y, worldMax.y), - Math.max(worldMin.z, worldMax.z), - ), - } +): WorldBounds | null { + return buildWorldPointBounds( + getLocalBoundsCorners(localBounds).map((corner) => getNodeWorldPoint(node, corner)), + ) } function getSnapAxisFromFace(face: string | null): number { @@ -429,12 +468,12 @@ function getSnapAxisFromFace(face: string | null): number { case 'left': case 'right': return 0 // X 轴 - case 'front': - case 'back': - return 1 // Y 轴(Three.js 中 front/back 对应 Z-up 模型的 Y 方向) case 'top': case 'bottom': - return 2 // Z 轴 + return 1 // Y 轴(Three.js 工作区竖向) + case 'front': + case 'back': + return 2 // Z 轴(工作台前后向) default: return -1 } @@ -462,17 +501,19 @@ function checkTangentAxesOverlap( function getNodeWorldAABB( node: AssemblyWorkspaceNode, -): { min: THREE.Vector3; max: THREE.Vector3 } | null { +): WorldBounds | null { const panels = readRecordArray(node.assemblyBasis, 'panels') if (panels.length === 0) return null + const applyCadTransform = shouldApplyCadHoleTransform(node) let worldMin = new THREE.Vector3(Infinity, Infinity, Infinity) let worldMax = new THREE.Vector3(-Infinity, -Infinity, -Infinity) let hasBounds = false for (const panel of panels) { - const bbox = readBoundsTuple(panel.boundingBox) - if (!bbox) continue + const rawBounds = readBoundsTuple(panel.boundingBox) + if (!rawBounds) continue + const bbox = applyCadTransform ? transformCadBoundsTuple(rawBounds) : rawBounds const wb = getWorldBounds(node, bbox) if (!wb) continue worldMin.min(wb.min) @@ -483,9 +524,222 @@ function getNodeWorldAABB( return hasBounds ? { min: worldMin, max: worldMax } : null } +function readDimensionValue( + dimensions: Record | null | undefined, + keys: string[], +) { + if (!dimensions) return null + + for (const key of keys) { + const value = dimensions[key] + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value + } + } + + return null +} + +function getNodeApproximateLocalBounds(node: AssemblyWorkspaceNode) { + const x = readDimensionValue(node.dimensions, ['length', 'diameter', 'width']) + const y = readDimensionValue(node.dimensions, ['height', 'thickness', 'depth']) + const z = readDimensionValue(node.dimensions, ['width', 'depth', 'thickness']) + + if (x === null || y === null || z === null) { + return null + } + + return { + min: [-x / 2, -y / 2, -z / 2] as [number, number, number], + max: [x / 2, y / 2, z / 2] as [number, number, number], + } +} + +function getNodeContactWorldBounds(node: AssemblyWorkspaceNode): WorldBounds | null { + const explicitBounds = getNodeWorldAABB(node) + if (explicitBounds) { + return explicitBounds + } + + const snapSurfaceBounds = buildWorldPointBounds( + getNodeAssemblySnapSurfaces(node).flatMap((surface) => + surface.bounds + ? getLocalBoundsCorners(surface.bounds).map((corner) => getNodeWorldPoint(node, corner)) + : [], + ), + ) + if (snapSurfaceBounds) { + return snapSurfaceBounds + } + + const approximateBounds = getNodeApproximateLocalBounds(node) + return approximateBounds ? getWorldBounds(node, approximateBounds) : null +} + +function translateWorldBounds(bounds: WorldBounds, translation: THREE.Vector3): WorldBounds { + return { + min: bounds.min.clone().add(translation), + max: bounds.max.clone().add(translation), + } +} + +function projectBoundsAlongDirection(bounds: WorldBounds, direction: THREE.Vector3) { + const corners = [ + new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.min.z), + new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.max.z), + new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.min.z), + new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.max.z), + new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.min.z), + new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.max.z), + new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.min.z), + new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.max.z), + ] + let min = Infinity + let max = -Infinity + + corners.forEach((corner) => { + const value = corner.dot(direction) + min = Math.min(min, value) + max = Math.max(max, value) + }) + + return { min, max } +} + +function buildPreviewPositionFromTranslation( + node: AssemblyWorkspaceNode, + translation: THREE.Vector3, +): [number, number, number] { + return tupleFromVector( + new THREE.Vector3(node.position[0], node.position[1], node.position[2]).add(translation), + ) +} + +function getPositiveNumber(value: number | null | undefined) { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 +} + +function canUseContactCorrection(correctionMm: number) { + return ( + Number.isFinite(correctionMm) && + Math.abs(correctionMm) <= SNAP_CONTACT_CORRECTION_MAX_MM + ) +} + +function resolveContactAwarePreviewTranslation(params: { + sourceNode: AssemblyWorkspaceNode + targetNode: AssemblyWorkspaceNode + sourceAnchorWorld: THREE.Vector3 + targetAnchorWorld: THREE.Vector3 + sourceNormalWorld: THREE.Vector3 + targetNormalWorld: THREE.Vector3 + sourceDepth?: number | null + targetDepth?: number | null +}) { + const baseTranslation = params.targetAnchorWorld.clone().sub(params.sourceAnchorWorld) + const sourceNormal = params.sourceNormalWorld.clone().normalize() + const targetNormal = params.targetNormalWorld.clone().normalize() + + if ( + sourceNormal.lengthSq() <= 1e-6 || + targetNormal.lengthSq() <= 1e-6 || + sourceNormal.dot(targetNormal) > -0.2 + ) { + return baseTranslation + } + + const sourceBounds = getNodeContactWorldBounds(params.sourceNode) + const targetBounds = getNodeContactWorldBounds(params.targetNode) + if (sourceBounds && targetBounds) { + const translatedSourceBounds = translateWorldBounds(sourceBounds, baseTranslation) + const sourceProjection = projectBoundsAlongDirection(translatedSourceBounds, sourceNormal) + const targetProjection = projectBoundsAlongDirection(targetBounds, sourceNormal) + const targetPlane = targetNormal.dot(sourceNormal) < 0 ? targetProjection.min : targetProjection.max + const boundsCorrection = targetPlane - sourceProjection.max + + if (canUseContactCorrection(boundsCorrection)) { + return baseTranslation.clone().add(sourceNormal.multiplyScalar(boundsCorrection)) + } + } + + const sourceDepth = getPositiveNumber(params.sourceDepth) + const targetDepth = getPositiveNumber(params.targetDepth) + if (sourceDepth > 0 || targetDepth > 0) { + const sourceContactAfterBase = params.sourceAnchorWorld + .clone() + .add(baseTranslation) + .add(sourceNormal.clone().multiplyScalar(sourceDepth / 2)) + const targetContact = params.targetAnchorWorld + .clone() + .add(targetNormal.clone().multiplyScalar(targetDepth / 2)) + const depthCorrection = targetContact.sub(sourceContactAfterBase).dot(sourceNormal) + + if (canUseContactCorrection(depthCorrection)) { + return baseTranslation.clone().add(sourceNormal.multiplyScalar(depthCorrection)) + } + } + + return baseTranslation +} + +function resolveSurfaceSnapPreview(params: { + sourceNode: AssemblyWorkspaceNode + targetNode: AssemblyWorkspaceNode + sourceSurface: AssemblyBasisSnapSurfaceRecord + targetSurface: AssemblyBasisSnapSurfaceRecord + sourceCenterWorld: THREE.Vector3 + targetCenterWorld: THREE.Vector3 + sourceNormalWorld: THREE.Vector3 + targetNormalWorld: THREE.Vector3 +}) { + const sourceNormal = params.sourceNormalWorld.clone().normalize() + const targetNormal = params.targetNormalWorld.clone().normalize() + if ( + sourceNormal.lengthSq() <= 1e-6 || + targetNormal.lengthSq() <= 1e-6 || + sourceNormal.dot(targetNormal) > -0.2 + ) { + return null + } + + const sourceBoundsWorld = params.sourceSurface.bounds + ? getWorldBounds(params.sourceNode, params.sourceSurface.bounds) + : null + const targetBoundsWorld = params.targetSurface.bounds + ? getWorldBounds(params.targetNode, params.targetSurface.bounds) + : null + + if (sourceBoundsWorld && targetBoundsWorld) { + const sourceProjection = projectBoundsAlongDirection(sourceBoundsWorld, sourceNormal) + const targetProjection = projectBoundsAlongDirection(targetBoundsWorld, sourceNormal) + const sourcePlane = targetNormal.dot(sourceNormal) < 0 ? sourceProjection.max : sourceProjection.min + const targetPlane = targetNormal.dot(sourceNormal) < 0 ? targetProjection.min : targetProjection.max + const correctionMm = targetPlane - sourcePlane + + if (!Number.isFinite(correctionMm)) { + return null + } + + return { + translation: sourceNormal.multiplyScalar(correctionMm), + distanceMm: Math.abs(correctionMm), + sourceBoundsWorld, + targetBoundsWorld, + } + } + + const signedDistanceMm = params.targetCenterWorld.clone().sub(params.sourceCenterWorld).dot(sourceNormal) + return { + translation: sourceNormal.multiplyScalar(signedDistanceMm), + distanceMm: Math.abs(signedDistanceMm), + sourceBoundsWorld, + targetBoundsWorld, + } +} + function aabbOverlap( - a: { min: THREE.Vector3; max: THREE.Vector3 }, - b: { min: THREE.Vector3; max: THREE.Vector3 }, + a: WorldBounds, + b: WorldBounds, ): boolean { return ( a.min.x < b.max.x && a.max.x > b.min.x && @@ -508,7 +762,55 @@ function normalizeVector3Tuple( } function transformCadVector3Tuple(value: [number, number, number]): [number, number, number] { - return [value[0], -value[2], value[1]] + return [value[0], value[2], -value[1]] +} + +function transformCadBoundsTuple( + bounds: { min: [number, number, number]; max: [number, number, number] }, +) { + const transformedBounds = buildWorldPointBounds( + getLocalBoundsCorners(bounds).map((corner) => { + const transformed = transformCadVector3Tuple(corner) + return new THREE.Vector3(transformed[0], transformed[1], transformed[2]) + }), + ) + + return transformedBounds + ? { + min: tupleFromVector(transformedBounds.min), + max: tupleFromVector(transformedBounds.max), + } + : bounds +} + +function buildWorldPointBounds(points: THREE.Vector3[]): WorldBounds | null { + if (points.length === 0) { + return null + } + + const worldMin = new THREE.Vector3(Infinity, Infinity, Infinity) + const worldMax = new THREE.Vector3(-Infinity, -Infinity, -Infinity) + points.forEach((point) => { + worldMin.min(point) + worldMax.max(point) + }) + + return { min: worldMin, max: worldMax } +} + +function boundsOverlapWithinPadding( + sourceBounds: WorldBounds, + targetBounds: WorldBounds, + paddingMm: number, +) { + return !( + sourceBounds.max.x + paddingMm < targetBounds.min.x || + targetBounds.max.x + paddingMm < sourceBounds.min.x || + sourceBounds.max.y + paddingMm < targetBounds.min.y || + targetBounds.max.y + paddingMm < sourceBounds.min.y || + sourceBounds.max.z + paddingMm < targetBounds.min.z || + targetBounds.max.z + paddingMm < sourceBounds.min.z + ) } function shouldApplyCadHoleTransform(node: AssemblyWorkspaceNode) { @@ -629,6 +931,7 @@ function buildAssemblyHoleGroups(node: AssemblyWorkspaceNode) { } function buildAssemblyMountPatterns(node: AssemblyWorkspaceNode): AssemblyBasisMountPatternRecord[] { + const applyCadTransform = shouldApplyCadHoleTransform(node) const snapPatternRecords = readRecordArray(node.assemblyBasis, 'snapPatterns') const patternRecords = snapPatternRecords.length > 0 @@ -652,6 +955,14 @@ function buildAssemblyMountPatterns(node: AssemblyWorkspaceNode): AssemblyBasisM id, panelId: readStringValue(record, 'panelId'), mountFace: readSnapFaceValue(record), + normal: (() => { + const rawNormal = readVector3Tuple(record.normal) + if (!rawNormal) return null + return normalizeVector3Tuple( + applyCadTransform ? transformCadVector3Tuple(rawNormal) : rawNormal, + [0, 1, 0], + ) + })(), holeIds, minRequiredHoles: Math.max(1, Math.min(holeIds.length, Math.round(requestedMinRequired))), } @@ -659,8 +970,69 @@ function buildAssemblyMountPatterns(node: AssemblyWorkspaceNode): AssemblyBasisM .filter((item): item is AssemblyBasisMountPatternRecord => item !== null) } +function hasAssemblyBasisField(node: AssemblyWorkspaceNode, key: string) { + return Object.prototype.hasOwnProperty.call(node.assemblyBasis ?? {}, key) +} + +function buildMountPointFallbackHoles(node: AssemblyWorkspaceNode): AssemblyBasisHoleRecord[] { + if (node.source === 'desktop' || node.mountPoints.length === 0 || hasAssemblyBasisField(node, 'holes')) { + return [] + } + + return node.mountPoints + .map((mountPoint, index) => { + const position = readVector3Tuple(mountPoint.position) + if (!position) { + return null + } + + const rawName = mountPoint.name.trim() || `mount-${index + 1}` + const safeName = rawName.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') + const stableKey = `${safeName || 'mount'}-${index + 1}` + const id = `mount-point:${stableKey}` + + return { + id, + panelId: `mount-point-panel:${stableKey}`, + position, + normal: [0, 1, 0], + diameter: null, + depth: null, + holeType: 'mount-point', + groupId: null, + surfaceFaces: [], + } + }) + .filter((item): item is AssemblyBasisHoleRecord => item !== null) +} + function getNodeAssemblyMountPatterns(node: AssemblyWorkspaceNode) { - return buildAssemblyMountPatterns(node) + const explicitPatterns = buildAssemblyMountPatterns(node) + if (explicitPatterns.length > 0) { + return explicitPatterns + } + + if (hasAssemblyBasisField(node, 'holes')) { + return [] + } + + const fallbackHoles = buildMountPointFallbackHoles(node) + return fallbackHoles.map((hole) => ({ + id: `mount-point-pattern:${hole.id}`, + panelId: hole.panelId, + mountFace: null, + normal: null, + holeIds: [hole.id], + minRequiredHoles: 1, + })) +} + +function getHoleSnapNormalWorld( + node: AssemblyWorkspaceNode, + hole: AssemblyBasisHoleRecord, + pattern: AssemblyBasisMountPatternRecord | null | undefined, +): THREE.Vector3 { + return getNodeWorldDirection(node, pattern?.normal ?? hole.normal) } function getNodeAssemblySnapSurfaces(node: AssemblyWorkspaceNode): AssemblyBasisSnapSurfaceRecord[] { @@ -677,6 +1049,7 @@ function getNodeAssemblySnapSurfaces(node: AssemblyWorkspaceNode): AssemblyBasis const center = applyCadTransform ? transformCadVector3Tuple(rawCenter) : rawCenter const normal = applyCadTransform ? transformCadVector3Tuple(rawNormal) : rawNormal + const rawBounds = readBoundsTuple(record.bounds) return { id, @@ -684,7 +1057,7 @@ function getNodeAssemblySnapSurfaces(node: AssemblyWorkspaceNode): AssemblyBasis face: readSnapFaceValue(record), center, normal: normalizeVector3Tuple(normal, [0, 1, 0]), - bounds: readBoundsTuple(record.bounds), + bounds: rawBounds && applyCadTransform ? transformCadBoundsTuple(rawBounds) : rawBounds, confidence: readNumberValue(record, 'confidence'), } }) @@ -695,7 +1068,7 @@ function getNodeAssemblyHoles(node: AssemblyWorkspaceNode): AssemblyBasisHoleRec const groupById = buildAssemblyHoleGroups(node) const applyCadTransform = shouldApplyCadHoleTransform(node) - return readRecordArray(node.assemblyBasis, 'holes') + const explicitHoles = readRecordArray(node.assemblyBasis, 'holes') .map((record) => { const id = readStringValue(record, 'id') const rawPosition = readVector3Tuple(record.position) @@ -724,13 +1097,17 @@ function getNodeAssemblyHoles(node: AssemblyWorkspaceNode): AssemblyBasisHoleRec } }) .filter((item): item is AssemblyBasisHoleRecord => item !== null) + + return explicitHoles.length > 0 ? explicitHoles : buildMountPointFallbackHoles(node) } export function getAssemblyNodeHoleMarkers(node: AssemblyWorkspaceNode): AssemblyNodeHoleMarker[] { return getNodeAssemblyHoles(node).map((hole) => ({ id: hole.id, panelId: hole.panelId, + localPosition: hole.position, position: tupleFromVector(getNodeWorldPoint(node, hole.position)), + localNormal: hole.normal, normal: tupleFromVector(getNodeWorldDirection(node, hole.normal)), diameter: hole.diameter, depth: hole.depth, @@ -749,11 +1126,14 @@ export function getAssemblyAutoSnapSupportStatus( ): AssemblyAutoSnapSupportStatus { const holeCount = getNodeAssemblyHoles(node).length const mountPatternCount = getNodeAssemblyMountPatterns(node).length + const snapSurfaceCount = getNodeAssemblySnapSurfaces(node).length + const canUseHoleSnap = holeCount > 0 && mountPatternCount > 0 + const canUseSurfaceSnap = snapSurfaceCount > 0 return { - canAutoSnap: holeCount > 0 && mountPatternCount > 0, - missingHoles: holeCount === 0, - missingMountPatterns: mountPatternCount === 0, + canAutoSnap: canUseHoleSnap || canUseSurfaceSnap, + missingHoles: !canUseSurfaceSnap && holeCount === 0, + missingMountPatterns: !canUseSurfaceSnap && mountPatternCount === 0, } } @@ -761,12 +1141,67 @@ function canUseHoleAutoSnap(node: AssemblyWorkspaceNode) { return getAssemblyAutoSnapSupportStatus(node).canAutoSnap } -function getHoleSnapThreshold( - sourceHole: AssemblyBasisHoleRecord, - targetHole: AssemblyBasisHoleRecord, +function buildAutoSnapAnchorWorldBounds(node: AssemblyWorkspaceNode): WorldBounds { + const surfaceBoundsPoints = getNodeAssemblySnapSurfaces(node).flatMap((surface) => { + if (!surface.bounds) { + return [getNodeWorldPoint(node, surface.center)] + } + + const bounds = getWorldBounds(node, surface.bounds) + return bounds ? [bounds.min, bounds.max] : [getNodeWorldPoint(node, surface.center)] + }) + const snapAnchorPoints = [ + ...getNodeAssemblyHoles(node).map((hole) => getNodeWorldPoint(node, hole.position)), + ...surfaceBoundsPoints, + ] + const snapAnchorBounds = buildWorldPointBounds(snapAnchorPoints) + if (snapAnchorBounds) { + return snapAnchorBounds + } + + const modelBounds = getNodeWorldAABB(node) + if (modelBounds) { + return modelBounds + } + + const position = new THREE.Vector3(node.position[0], node.position[1], node.position[2]) + return { + min: position.clone(), + max: position.clone(), + } +} + +function getCachedAutoSnapAnchorWorldBounds( + boundsByNodeId: Map, + node: AssemblyWorkspaceNode, ) { - const diameter = Math.max(sourceHole.diameter ?? 0, targetHole.diameter ?? 0) - return Math.max(32, Math.min(HOLE_SNAP_DISTANCE_THRESHOLD_MM, diameter > 0 ? diameter * 6 : 72)) + const cachedBounds = boundsByNodeId.get(node.nodeId) + if (cachedBounds) { + return cachedBounds + } + + const bounds = buildAutoSnapAnchorWorldBounds(node) + boundsByNodeId.set(node.nodeId, bounds) + return bounds +} + +function isWithinAutoSnapBroadPhase( + sourceBounds: WorldBounds, + targetNode: AssemblyWorkspaceNode, + boundsByNodeId: Map, +) { + return boundsOverlapWithinPadding( + sourceBounds, + getCachedAutoSnapAnchorWorldBounds(boundsByNodeId, targetNode), + AUTO_SNAP_BROAD_PHASE_PADDING_MM, + ) +} + +function getHoleSnapThreshold( + _sourceHole: AssemblyBasisHoleRecord, + _targetHole: AssemblyBasisHoleRecord, +) { + return HOLE_SNAP_DISTANCE_THRESHOLD_MM } function areHoleDiametersCompatible( @@ -777,7 +1212,16 @@ function areHoleDiametersCompatible( return true } - return Math.abs(sourceHole.diameter - targetHole.diameter) <= HOLE_DIAMETER_TOLERANCE_MM + if (Math.abs(sourceHole.diameter - targetHole.diameter) <= HOLE_DIAMETER_TOLERANCE_MM) { + return true + } + + const smallerDiameter = Math.min(sourceHole.diameter, targetHole.diameter) + const largerDiameter = Math.max(sourceHole.diameter, targetHole.diameter) + return ( + smallerDiameter > 0 && + largerDiameter / smallerDiameter <= HOLE_RECEIVER_DIAMETER_RATIO_MAX + ) } function resolveHoleFacePair( @@ -821,6 +1265,130 @@ function buildSnapBindingFromCandidate(candidate: AssemblyAutoSnapCandidate): As } } +function buildAutoSnapHolePair(params: { + sourceNode: AssemblyWorkspaceNode + targetNode: AssemblyWorkspaceNode + sourceHole: AssemblyBasisHoleRecord + targetHole: AssemblyBasisHoleRecord + sourcePattern: AssemblyBasisMountPatternRecord + targetPattern: AssemblyBasisMountPatternRecord + translation: THREE.Vector3 + sourceFace: string | null + targetFace: string | null +}): AssemblyAutoSnapHolePair { + const sourceHoleWorldPosition = getNodeWorldPoint(params.sourceNode, params.sourceHole.position) + .add(params.translation) + const targetHoleWorldPosition = getNodeWorldPoint(params.targetNode, params.targetHole.position) + const sourceHoleNormal = getHoleSnapNormalWorld(params.sourceNode, params.sourceHole, params.sourcePattern) + const targetHoleNormal = getHoleSnapNormalWorld(params.targetNode, params.targetHole, params.targetPattern) + + return { + sourceHoleId: params.sourceHole.id, + targetHoleId: params.targetHole.id, + sourceHolePanelId: params.sourceHole.panelId, + targetHolePanelId: params.targetHole.panelId, + sourceHoleDiameter: params.sourceHole.diameter, + targetHoleDiameter: params.targetHole.diameter, + sourceHoleDepth: params.sourceHole.depth, + targetHoleDepth: params.targetHole.depth, + sourceFace: params.sourceFace, + targetFace: params.targetFace, + sourceHoleWorldPosition: tupleFromVector(sourceHoleWorldPosition), + targetHoleWorldPosition: tupleFromVector(targetHoleWorldPosition), + sourceHoleNormal: tupleFromVector(sourceHoleNormal), + targetHoleNormal: tupleFromVector(targetHoleNormal), + distanceMm: sourceHoleWorldPosition.distanceTo(targetHoleWorldPosition), + } +} + +function buildMatchedAutoSnapHolePairs(params: { + sourceNode: AssemblyWorkspaceNode + targetNode: AssemblyWorkspaceNode + sourcePattern: AssemblyBasisMountPatternRecord | null + targetPattern: AssemblyBasisMountPatternRecord | null + sourceHoles: AssemblyBasisHoleRecord[] + targetHoles: AssemblyBasisHoleRecord[] + translation: THREE.Vector3 +}): AssemblyAutoSnapHolePair[] { + const { sourcePattern, targetPattern } = params + if (!sourcePattern || !targetPattern) { + return [] + } + + const matchedPairs: AssemblyAutoSnapHolePair[] = [] + const usedTargetHoleIds = new Set() + + params.sourceHoles.forEach((sourceHole) => { + let bestPair: AssemblyAutoSnapHolePair | null = null + + params.targetHoles.forEach((targetHole) => { + if (usedTargetHoleIds.has(targetHole.id) || !areHoleDiametersCompatible(sourceHole, targetHole)) { + return + } + + const facePair = resolveHoleFacePair(sourceHole, targetHole, sourcePattern, targetPattern) + if (!facePair) { + return + } + + const candidatePair = buildAutoSnapHolePair({ + sourceNode: params.sourceNode, + targetNode: params.targetNode, + sourceHole, + targetHole, + sourcePattern, + targetPattern, + translation: params.translation, + sourceFace: facePair.sourceFace, + targetFace: facePair.targetFace, + }) + + if (candidatePair.distanceMm > getHoleSnapThreshold(sourceHole, targetHole)) { + return + } + + const normalDot = new THREE.Vector3(...candidatePair.sourceHoleNormal) + .dot(new THREE.Vector3(...candidatePair.targetHoleNormal)) + if (normalDot > HOLE_NORMAL_ALIGNMENT_DOT_MAX) { + return + } + + if (!bestPair || candidatePair.distanceMm < bestPair.distanceMm) { + bestPair = candidatePair + } + }) + + const resolvedBestPair = bestPair as AssemblyAutoSnapHolePair | null + if (resolvedBestPair) { + usedTargetHoleIds.add(resolvedBestPair.targetHoleId) + matchedPairs.push(resolvedBestPair) + } + }) + + return matchedPairs +} + +function resolveHeldSnapPreviewPosition( + sourceNode: AssemblyWorkspaceNode, + candidate: AssemblyAutoSnapCandidate, +): [number, number, number] { + const rawPosition = new THREE.Vector3(...sourceNode.position) + const previewPosition = new THREE.Vector3(...candidate.previewPosition) + const correction = previewPosition.sub(rawPosition) + if (correction.lengthSq() <= 1e-6) { + return cloneVector3(sourceNode.position) + } + + const normal = new THREE.Vector3(...candidate.sourceHoleNormal) + if (normal.lengthSq() <= 1e-6) { + return tupleFromVector(rawPosition.add(correction)) + } + normal.normalize() + + const normalCorrection = normal.multiplyScalar(correction.dot(normal)) + return tupleFromVector(rawPosition.add(normalCorrection)) +} + interface OccupiedSnapTargets { holeIds: Set patternIds: Set @@ -905,6 +1473,70 @@ function buildSnapBindingDisplayCandidate( return null } + if (binding.snapMode === 'surface') { + const sourceSurfaceId = binding.sourceSurfaceId ?? binding.sourceHoleId.replace(/^surface:/, '') + const targetSurfaceId = binding.targetSurfaceId ?? binding.targetHoleId.replace(/^surface:/, '') + const sourceSurface = + getNodeAssemblySnapSurfaces(sourceNode).find((surface) => surface.id === sourceSurfaceId) ?? null + const targetSurface = + getNodeAssemblySnapSurfaces(targetNode).find((surface) => surface.id === targetSurfaceId) ?? null + if (!sourceSurface || !targetSurface) { + return null + } + + const sourceCenterWorld = getNodeWorldPoint(sourceNode, sourceSurface.center) + const targetCenterWorld = getNodeWorldPoint(targetNode, targetSurface.center) + const sourceNormalWorld = getNodeWorldDirection(sourceNode, sourceSurface.normal) + const targetNormalWorld = getNodeWorldDirection(targetNode, targetSurface.normal) + const resolvedSurfaceSnap = resolveSurfaceSnapPreview({ + sourceNode, + targetNode, + sourceSurface, + targetSurface, + sourceCenterWorld, + targetCenterWorld, + sourceNormalWorld, + targetNormalWorld, + }) + if (!resolvedSurfaceSnap) { + return null + } + + return { + 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: binding.sourceFace ?? sourceSurface.face, + targetFace: binding.targetFace ?? targetSurface.face, + matchedHoleCount: 0, + requiredHoleCount: 0, + previewPosition: buildPreviewPositionFromTranslation(sourceNode, resolvedSurfaceSnap.translation), + previewRotation: cloneVector3(sourceNode.rotation), + sourceHoleWorldPosition: tupleFromVector(sourceCenterWorld.clone().add(resolvedSurfaceSnap.translation)), + targetHoleWorldPosition: tupleFromVector(targetCenterWorld), + sourceHoleNormal: tupleFromVector(sourceNormalWorld), + targetHoleNormal: tupleFromVector(targetNormalWorld), + matchedHolePairs: [], + distanceMm: resolvedSurfaceSnap.distanceMm, + isValid: true, + rejectReason: null, + } + } + const sourceHole = getNodeAssemblyHoles(sourceNode).find((hole) => hole.id === binding.sourceHoleId) ?? null const targetHole = getNodeAssemblyHoles(targetNode).find((hole) => hole.id === binding.targetHoleId) ?? null if (!sourceHole || !targetHole) { @@ -923,6 +1555,63 @@ function buildSnapBindingDisplayCandidate( ) const sourceHoleWorldPosition = getNodeWorldPoint(sourceNode, sourceHole.position) const targetHoleWorldPosition = getNodeWorldPoint(targetNode, targetHole.position) + const sourceHoleNormal = getHoleSnapNormalWorld(sourceNode, sourceHole, sourcePattern) + const targetHoleNormal = getHoleSnapNormalWorld(targetNode, targetHole, targetPattern) + const translation = resolveContactAwarePreviewTranslation({ + sourceNode, + targetNode, + sourceAnchorWorld: sourceHoleWorldPosition, + targetAnchorWorld: targetHoleWorldPosition, + sourceNormalWorld: sourceHoleNormal, + targetNormalWorld: targetHoleNormal, + sourceDepth: sourceHole.depth, + targetDepth: targetHole.depth, + }) + const sourcePatternHoles = sourcePattern + ? sourcePattern.holeIds + .map((holeId) => getNodeAssemblyHoles(sourceNode).find((hole) => hole.id === holeId) ?? null) + .filter((hole): hole is AssemblyBasisHoleRecord => hole !== null) + : [sourceHole] + const targetPatternHoles = targetPattern + ? targetPattern.holeIds + .map((holeId) => getNodeAssemblyHoles(targetNode).find((hole) => hole.id === holeId) ?? null) + .filter((hole): hole is AssemblyBasisHoleRecord => hole !== null) + : [targetHole] + const matchedHolePairs = buildMatchedAutoSnapHolePairs({ + sourceNode, + targetNode, + sourcePattern, + targetPattern, + sourceHoles: sourcePatternHoles, + targetHoles: targetPatternHoles, + translation, + }) + const fallbackHolePair = buildAutoSnapHolePair({ + sourceNode, + targetNode, + sourceHole, + targetHole, + sourcePattern: sourcePattern ?? { + id: sourceHole.id, + panelId: sourceHole.panelId, + mountFace: null, + normal: null, + holeIds: [sourceHole.id], + minRequiredHoles: 1, + }, + targetPattern: targetPattern ?? { + id: targetHole.id, + panelId: targetHole.panelId, + mountFace: null, + normal: null, + holeIds: [targetHole.id], + minRequiredHoles: 1, + }, + translation, + sourceFace: binding.sourceFace ?? null, + targetFace: binding.targetFace ?? null, + }) + const resolvedMatchedHolePairs = matchedHolePairs.length > 0 ? matchedHolePairs : [fallbackHolePair] return { sourceNodeId: sourceNode.nodeId, @@ -949,12 +1638,13 @@ function buildSnapBindingDisplayCandidate( sourcePattern?.minRequiredHoles ?? 1, targetPattern?.minRequiredHoles ?? 1, ), - previewPosition: cloneVector3(sourceNode.position), + previewPosition: buildPreviewPositionFromTranslation(sourceNode, translation), previewRotation: cloneVector3(sourceNode.rotation), - sourceHoleWorldPosition: tupleFromVector(sourceHoleWorldPosition), + sourceHoleWorldPosition: tupleFromVector(sourceHoleWorldPosition.clone().add(translation)), targetHoleWorldPosition: tupleFromVector(targetHoleWorldPosition), - sourceHoleNormal: tupleFromVector(getNodeWorldDirection(sourceNode, sourceHole.normal)), - targetHoleNormal: tupleFromVector(getNodeWorldDirection(targetNode, targetHole.normal)), + sourceHoleNormal: tupleFromVector(sourceHoleNormal), + targetHoleNormal: tupleFromVector(targetHoleNormal), + matchedHolePairs: resolvedMatchedHolePairs, distanceMm: sourceHoleWorldPosition.distanceTo(targetHoleWorldPosition), isValid: true, rejectReason: null, @@ -983,12 +1673,15 @@ export function deriveAssemblyAssistFeedback( nodeId: string, ): AssemblyAssistFeedback | null { const targetNode = snapshot.nodes.find((node) => node.nodeId === nodeId) ?? null - if (!targetNode || targetNode.source === 'desktop') { + if (!targetNode) { return null } const primaryAnchorNode = resolvePrimaryAnchorNode(snapshot.nodes) - const targetLabel = primaryAnchorNode?.name ?? null + const fallbackTargetNode = snapshot.nodes.find( + (node) => node.nodeId !== targetNode.nodeId, + ) ?? null + const targetLabel = primaryAnchorNode?.name ?? fallbackTargetNode?.name ?? null if (targetNode.zone === 'main' && targetNode.snapStatus === 'hole-snap') { return { @@ -1006,28 +1699,14 @@ export function deriveAssemblyAssistFeedback( } } - if (!primaryAnchorNode) { - return { - nodeId, - title: '还不能吸附', - description: '先放一个桌面板作为主锚点,再拖动组件或五金靠近兼容孔位。', - tone: 'warning', - targetNodeId: null, - targetLabel: null, - canAutoSnap: false, - withinSnapRange: false, - remainingDistanceMm: null, - } - } - if (!hasNodeAssemblyRule(targetNode)) { return { nodeId, title: '缺少装配规则', description: '当前资产缺少装配规则,只能自由摆放。', tone: 'warning', - targetNodeId: primaryAnchorNode.nodeId, - targetLabel: primaryAnchorNode.name, + targetNodeId: primaryAnchorNode?.nodeId ?? fallbackTargetNode?.nodeId ?? null, + targetLabel, canAutoSnap: false, withinSnapRange: false, remainingDistanceMm: null, @@ -1042,8 +1721,8 @@ export function deriveAssemblyAssistFeedback( title: '缺少孔位与吸附图样语义', description: '当前资产缺少孔位与吸附图样语义,无法自动吸附。', tone: 'warning', - targetNodeId: primaryAnchorNode.nodeId, - targetLabel: primaryAnchorNode.name, + targetNodeId: primaryAnchorNode?.nodeId ?? fallbackTargetNode?.nodeId ?? null, + targetLabel, canAutoSnap: false, withinSnapRange: false, remainingDistanceMm: null, @@ -1056,8 +1735,8 @@ export function deriveAssemblyAssistFeedback( title: '缺少吸附图样语义', description: '当前资产缺少吸附图样语义,无法自动吸附。', tone: 'warning', - targetNodeId: primaryAnchorNode.nodeId, - targetLabel: primaryAnchorNode.name, + targetNodeId: primaryAnchorNode?.nodeId ?? fallbackTargetNode?.nodeId ?? null, + targetLabel, canAutoSnap: false, withinSnapRange: false, remainingDistanceMm: null, @@ -1069,22 +1748,8 @@ export function deriveAssemblyAssistFeedback( title: '缺少孔位语义', description: '当前资产缺少孔位语义,无法自动吸附。', tone: 'warning', - targetNodeId: primaryAnchorNode.nodeId, - targetLabel: primaryAnchorNode.name, - canAutoSnap: false, - withinSnapRange: false, - remainingDistanceMm: null, - } - } - - if (!matchesPrimaryAnchorRole(targetNode, primaryAnchorNode)) { - return { - nodeId, - title: '当前目标不兼容', - description: `${targetNode.name} 不能装到 ${primaryAnchorNode.name},请换兼容件或确认装配规则。`, - tone: 'warning', - targetNodeId: primaryAnchorNode.nodeId, - targetLabel: primaryAnchorNode.name, + targetNodeId: primaryAnchorNode?.nodeId ?? fallbackTargetNode?.nodeId ?? null, + targetLabel, canAutoSnap: false, withinSnapRange: false, remainingDistanceMm: null, @@ -1094,14 +1759,30 @@ export function deriveAssemblyAssistFeedback( const liveCandidate = deriveAssemblyAutoSnapCandidate(snapshot, nodeId) const activeCandidate = liveCandidate ?? deriveAssemblyActiveSnapCandidate(snapshot, nodeId) + if (!primaryAnchorNode && !fallbackTargetNode && !activeCandidate) { + return { + nodeId, + title: '还不能吸附', + description: '先添加另一个带孔位或吸附面的模型,再拖动当前模型靠近兼容位置。', + tone: 'warning', + targetNodeId: null, + targetLabel: null, + canAutoSnap: false, + withinSnapRange: false, + remainingDistanceMm: null, + } + } + if (!activeCandidate) { return { nodeId, title: '拖近兼容孔位即可吸附', - description: `${targetNode.name} 需要和 ${primaryAnchorNode.name} 的兼容孔位对齐后,才会自动吸附。`, + description: targetLabel + ? `${targetNode.name} 需要和 ${targetLabel} 的兼容孔位对齐后,才会自动吸附。` + : `${targetNode.name} 需要和另一个模型的兼容孔位对齐后,才会自动吸附。`, tone: 'info', - targetNodeId: primaryAnchorNode.nodeId, - targetLabel: primaryAnchorNode.name, + targetNodeId: primaryAnchorNode?.nodeId ?? fallbackTargetNode?.nodeId ?? null, + targetLabel, canAutoSnap: true, withinSnapRange: false, remainingDistanceMm: null, @@ -1109,8 +1790,10 @@ export function deriveAssemblyAssistFeedback( } const candidateTargetNode = - snapshot.nodes.find((node) => node.nodeId === activeCandidate.targetNodeId) ?? primaryAnchorNode - const candidateTargetLabel = candidateTargetNode?.name ?? primaryAnchorNode.name + snapshot.nodes.find((node) => node.nodeId === activeCandidate.targetNodeId) ?? + primaryAnchorNode ?? + fallbackTargetNode + const candidateTargetLabel = candidateTargetNode?.name ?? '目标模型' const remainingDistanceMm = liveCandidate ? Math.max(Math.ceil(liveCandidate.distanceMm - HOLE_SNAP_DISTANCE_THRESHOLD_MM), 0) : 0 @@ -1135,19 +1818,20 @@ function deriveAssemblySurfaceSnapCandidate( snapshot: AssemblyWorkspaceSnapshot, sourceNode: AssemblyWorkspaceNode, occupiedTargetsByNodeId: Map, + boundsByNodeId = new Map(), ): AssemblyAutoSnapCandidate | null { const sourceSurfaces = getNodeAssemblySnapSurfaces(sourceNode) if (sourceSurfaces.length === 0) { return null } + const sourceBroadPhaseBounds = getCachedAutoSnapAnchorWorldBounds(boundsByNodeId, sourceNode) let bestCandidate: AssemblyAutoSnapCandidate | null = null snapshot.nodes.forEach((targetNode) => { if ( targetNode.nodeId === sourceNode.nodeId || - targetNode.zone !== 'main' || - !matchesPrimaryAnchorRole(sourceNode, targetNode) + !isWithinAutoSnapBroadPhase(sourceBroadPhaseBounds, targetNode, boundsByNodeId) ) { return } @@ -1182,31 +1866,41 @@ function deriveAssemblySurfaceSnapCandidate( return } - const distanceMm = sourceCenterWorld.distanceTo(targetCenterWorld) - if (distanceMm > SURFACE_SNAP_DISTANCE_THRESHOLD_MM) { + const resolvedSurfaceSnap = resolveSurfaceSnapPreview({ + sourceNode, + targetNode, + sourceSurface, + targetSurface, + sourceCenterWorld, + targetCenterWorld, + sourceNormalWorld, + targetNormalWorld, + }) + if (!resolvedSurfaceSnap || resolvedSurfaceSnap.distanceMm > SURFACE_SNAP_DISTANCE_THRESHOLD_MM) { return } // 利用 bounds 做面面贴合校验:两个 surface 在吸附面的切线轴上必须有重叠 - if (sourceSurface.bounds && targetSurface.bounds) { - const sourceBoundsWorld = getWorldBounds(sourceNode, sourceSurface.bounds) - const targetBoundsWorld = getWorldBounds(targetNode, targetSurface.bounds) - if (sourceBoundsWorld && targetBoundsWorld) { + if (resolvedSurfaceSnap.sourceBoundsWorld && resolvedSurfaceSnap.targetBoundsWorld) { + const translatedSourceBounds = translateWorldBounds( + resolvedSurfaceSnap.sourceBoundsWorld, + resolvedSurfaceSnap.translation, + ) // 吸附面法线方向是贴合轴,检查另两个轴(切线轴)是否有重叠 - const snapAxis = getSnapAxisFromFace(facePair.sourceFace) - const tangentOverlap = checkTangentAxesOverlap(sourceBoundsWorld, targetBoundsWorld, snapAxis) + const snapAxis = getSnapAxisFromFace(facePair.sourceFace) + if (snapAxis >= 0) { + const tangentOverlap = checkTangentAxesOverlap( + translatedSourceBounds, + resolvedSurfaceSnap.targetBoundsWorld, + snapAxis, + ) if (!tangentOverlap) { 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 previewPosition = buildPreviewPositionFromTranslation(sourceNode, resolvedSurfaceSnap.translation) const confidence = Math.min( sourceSurface.confidence ?? 1, targetSurface.confidence ?? 1, @@ -1235,11 +1929,14 @@ function deriveAssemblySurfaceSnapCandidate( requiredHoleCount: 0, previewPosition, previewRotation: cloneVector3(sourceNode.rotation), - sourceHoleWorldPosition: tupleFromVector(sourceCenterWorld.clone().add(translation)), + sourceHoleWorldPosition: tupleFromVector( + sourceCenterWorld.clone().add(resolvedSurfaceSnap.translation), + ), targetHoleWorldPosition: tupleFromVector(targetCenterWorld), sourceHoleNormal: tupleFromVector(sourceNormalWorld), targetHoleNormal: tupleFromVector(targetNormalWorld), - distanceMm, + matchedHolePairs: [], + distanceMm: resolvedSurfaceSnap.distanceMm, isValid: confidence >= 0.5, rejectReason: confidence >= 0.5 ? null : 'surface_confidence_low', } @@ -1259,22 +1956,23 @@ export function deriveAssemblyAutoSnapCandidate( nodeId: string, ): AssemblyAutoSnapCandidate | null { const sourceNode = snapshot.nodes.find((node) => node.nodeId === nodeId) ?? null - if (!sourceNode || sourceNode.source === 'desktop') { + if (!sourceNode) { return null } const occupiedTargetsByNodeId = buildOccupiedSnapTargetsByNodeId(snapshot, sourceNode.nodeId) + const broadPhaseBoundsByNodeId = new Map() const sourcePatterns = getNodeAssemblyMountPatterns(sourceNode) const sourceHoleMap = new Map(getNodeAssemblyHoles(sourceNode).map((hole) => [hole.id, hole])) + const sourceBroadPhaseBounds = getCachedAutoSnapAnchorWorldBounds(broadPhaseBoundsByNodeId, sourceNode) 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) + !isWithinAutoSnapBroadPhase(sourceBroadPhaseBounds, targetNode, broadPhaseBoundsByNodeId) ) { return } @@ -1324,8 +2022,8 @@ export function deriveAssemblyAutoSnapCandidate( const anchorSourceWorld = getNodeWorldPoint(sourceNode, anchorSourceHole.position) const anchorTargetWorld = getNodeWorldPoint(targetNode, anchorTargetHole.position) - const anchorSourceNormal = getNodeWorldDirection(sourceNode, anchorSourceHole.normal) - const anchorTargetNormal = getNodeWorldDirection(targetNode, anchorTargetHole.normal) + const anchorSourceNormal = getHoleSnapNormalWorld(sourceNode, anchorSourceHole, sourcePattern) + const anchorTargetNormal = getHoleSnapNormalWorld(targetNode, anchorTargetHole, targetPattern) const anchorNormalDot = anchorSourceNormal.dot(anchorTargetNormal) if (anchorNormalDot > HOLE_NORMAL_ALIGNMENT_DOT_MAX) { return @@ -1355,20 +2053,17 @@ export function deriveAssemblyAutoSnapCandidate( const translatedSourceWorld = getNodeWorldPoint(sourceNode, sourceHole.position).add(translation) const targetHoleWorld = getNodeWorldPoint(targetNode, targetHole.position) - const currentDistanceMm = getNodeWorldPoint(sourceNode, sourceHole.position).distanceTo( - targetHoleWorld, - ) - if (currentDistanceMm > getHoleSnapThreshold(sourceHole, targetHole)) { + const distanceMm = translatedSourceWorld.distanceTo(targetHoleWorld) + if (distanceMm > getHoleSnapThreshold(sourceHole, targetHole)) { return } - const sourceHoleNormal = getNodeWorldDirection(sourceNode, sourceHole.normal) - const targetHoleNormal = getNodeWorldDirection(targetNode, targetHole.normal) + const sourceHoleNormal = getHoleSnapNormalWorld(sourceNode, sourceHole, sourcePattern) + const targetHoleNormal = getHoleSnapNormalWorld(targetNode, targetHole, targetPattern) const normalDot = sourceHoleNormal.dot(targetHoleNormal) if (normalDot > HOLE_NORMAL_ALIGNMENT_DOT_MAX) { return } - const distanceMm = translatedSourceWorld.distanceTo(targetHoleWorld) const candidatePair: AssemblyMatchedHolePair = { sourceHole, targetHole, @@ -1376,7 +2071,7 @@ export function deriveAssemblyAutoSnapCandidate( targetHoleWorldPosition: tupleFromVector(targetHoleWorld), sourceHoleNormal: tupleFromVector(sourceHoleNormal), targetHoleNormal: tupleFromVector(targetHoleNormal), - distanceMm: currentDistanceMm, + distanceMm, sourceFace: facePair.sourceFace, targetFace: facePair.targetFace, } @@ -1394,23 +2089,42 @@ export function deriveAssemblyAutoSnapCandidate( } }) - const requiredHoleCount = Math.max( + const patternRequiredHoleCount = Math.max( 1, - sourcePattern.minRequiredHoles, - targetPattern.minRequiredHoles, + Math.min(sourcePattern.minRequiredHoles, targetPattern.minRequiredHoles), ) + const requiredHoleCount = patternRequiredHoleCount if (matchedPairs.length < requiredHoleCount) { return } const averageDistance = matchedPairs.reduce((sum, pair) => sum + pair.distanceMm, 0) / matchedPairs.length - const previewPosition = tupleFromVector( - new THREE.Vector3(sourceNode.position[0], sourceNode.position[1], sourceNode.position[2]).add( - translation, - ), - ) const firstPair = matchedPairs[0]! + const finalTranslation = resolveContactAwarePreviewTranslation({ + sourceNode, + targetNode, + sourceAnchorWorld: anchorSourceWorld, + targetAnchorWorld: anchorTargetWorld, + sourceNormalWorld: anchorSourceNormal, + targetNormalWorld: anchorTargetNormal, + sourceDepth: anchorSourceHole.depth, + targetDepth: anchorTargetHole.depth, + }) + const previewPosition = buildPreviewPositionFromTranslation(sourceNode, finalTranslation) + const matchedHolePairDisplays = matchedPairs.map((pair) => + buildAutoSnapHolePair({ + sourceNode, + targetNode, + sourceHole: pair.sourceHole, + targetHole: pair.targetHole, + sourcePattern, + targetPattern, + translation: finalTranslation, + sourceFace: pair.sourceFace, + targetFace: pair.targetFace, + }), + ) const candidate: AssemblyAutoSnapCandidate = { sourceNodeId: sourceNode.nodeId, targetNodeId: targetNode.nodeId, @@ -1433,10 +2147,13 @@ export function deriveAssemblyAutoSnapCandidate( requiredHoleCount, previewPosition, previewRotation: cloneVector3(sourceNode.rotation), - sourceHoleWorldPosition: firstPair.sourceHoleWorldPosition, + sourceHoleWorldPosition: tupleFromVector( + getNodeWorldPoint(sourceNode, firstPair.sourceHole.position).add(finalTranslation), + ), targetHoleWorldPosition: firstPair.targetHoleWorldPosition, sourceHoleNormal: firstPair.sourceHoleNormal, targetHoleNormal: firstPair.targetHoleNormal, + matchedHolePairs: matchedHolePairDisplays, distanceMm: averageDistance, isValid: true, rejectReason: null, @@ -1457,7 +2174,12 @@ export function deriveAssemblyAutoSnapCandidate( }) } - return bestCandidate ?? deriveAssemblySurfaceSnapCandidate(snapshot, sourceNode, occupiedTargetsByNodeId) + return bestCandidate ?? deriveAssemblySurfaceSnapCandidate( + snapshot, + sourceNode, + occupiedTargetsByNodeId, + broadPhaseBoundsByNodeId, + ) } function buildInventoryId(source: Exclude, familyId: string, index: number) { @@ -1577,7 +2299,10 @@ function getRelationType(source: AssemblyNodeSource): AssemblyRelationType { } function deriveAssemblySpaceConflictIssues(nodes: AssemblyWorkspaceNode[]) { - const mainNodes = nodes.filter((node) => node.zone === 'main' && node.source !== 'desktop') + const primaryAnchorNode = resolvePrimaryAnchorNode(nodes) + const mainNodes = nodes.filter( + (node) => node.zone === 'main' && node.nodeId !== primaryAnchorNode?.nodeId, + ) const issues = new Set() for (let index = 0; index < mainNodes.length; index += 1) { @@ -1694,45 +2419,11 @@ function hasNodeAssemblyRule(node: AssemblyWorkspaceNode) { return allowedSlots.length > 0 || allowedRoles.length > 0 } -function matchesPrimaryAnchorRole( - node: AssemblyWorkspaceNode, - primaryAnchorNode: AssemblyWorkspaceNode, -) { - const attachTo = - readStringValue(node.assemblyBasis, 'attachTo') ?? - readStringValue(node.assemblyBasis, 'attach') ?? - readStringValue(node.assemblyBasis, 'target') - - const anchorRoleCandidates = new Set( - [ - primaryAnchorNode.roleConfirmed, - primaryAnchorNode.roleRecommendation, - primaryAnchorNode.role, - primaryAnchorNode.source, - 'desktop', - ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0), - ) - - if (attachTo && !anchorRoleCandidates.has(attachTo)) { - return false - } - - const allowedRoles = readStringList(node.compatibilityRules?.allowedRoles) - if ( - allowedRoles.length > 0 && - !allowedRoles.some((role) => anchorRoleCandidates.has(role)) - ) { - return false - } - - return true -} - function rebuildRelations(nodes: AssemblyWorkspaceNode[]): AssemblyRelation[] { - const mainDesktop = nodes.find((node) => node.zone === 'main' && node.source === 'desktop') ?? null + const mainDesktop = resolvePrimaryAnchorNode(nodes) return nodes - .filter((node) => node.source !== 'desktop') + .filter((node) => node.nodeId !== mainDesktop?.nodeId) .map((node) => { const minMatchCount = getNodeConnectionRequirement(node.source) const matchedCount = computeHoleGroupMatchCount(node, minMatchCount) @@ -2014,23 +2705,14 @@ export function deriveAssemblyValidation(input: { }): AssemblyValidationResult { const warnings: string[] = [] const mainNodes = input.nodes.filter((node) => node.zone === 'main') - const primaryAnchorNode = resolvePrimaryAnchorNode(input.nodes) - const explicitPrimaryAnchors = mainNodes.filter(hasExplicitPrimaryAnchor) + const referenceNode = resolvePrimaryAnchorNode(input.nodes) ?? mainNodes[0] ?? null if (input.nodes.length === 0) { warnings.push('还没有实例化任何规格款') } - if (!primaryAnchorNode) { - warnings.push('主装配区还没有主锚点') - } - - if (explicitPrimaryAnchors.length > 1) { - warnings.push('主装配区存在多个显式主锚点,保存前只能保留 1 个') - } - - if (primaryAnchorNode && explicitPrimaryAnchors.length === 0 && primaryAnchorNode.source === 'desktop') { - warnings.push('当前主锚点由首个板材回退决定,建议补充显式装配锚点规则') + if (mainNodes.length === 0) { + warnings.push('主装配区还没有任何已装配零件') } const connectedRelations = new Map( @@ -2040,7 +2722,7 @@ export function deriveAssemblyValidation(input: { ) mainNodes - .filter((node) => node.nodeId !== primaryAnchorNode?.nodeId) + .filter((node) => node.nodeId !== referenceNode?.nodeId) .forEach((node) => { if (!hasNodeAssemblyRule(node)) { warnings.push(`${node.name} 缺少装配规则,当前只能预览,不能稳定保存`) @@ -2076,13 +2758,10 @@ export function deriveAssemblySaveValidation( const blockingIssues: string[] = [] const warnings: string[] = [] const mainNodes = snapshot.nodes.filter((node) => node.zone === 'main') - const primaryAnchorNode = resolvePrimaryAnchorNode(snapshot.nodes) - const explicitPrimaryAnchors = mainNodes.filter(hasExplicitPrimaryAnchor) + const referenceNode = resolvePrimaryAnchorNode(snapshot.nodes) ?? mainNodes[0] ?? null - if (!primaryAnchorNode) { - blockingIssues.push('保存前必须在主装配区保留 1 个主锚点') - } else if (explicitPrimaryAnchors.length > 1) { - blockingIssues.push('保存前主装配区只能保留 1 个主锚点') + if (mainNodes.length === 0) { + blockingIssues.push('保存前主装配区至少需要 1 个已装配零件') } const connectedRelations = new Map( @@ -2092,7 +2771,7 @@ export function deriveAssemblySaveValidation( ) mainNodes - .filter((node) => node.nodeId !== primaryAnchorNode?.nodeId) + .filter((node) => node.nodeId !== referenceNode?.nodeId) .forEach((node) => { if (!hasNodeAssemblyRule(node)) { blockingIssues.push(`${node.name} 缺少装配规则,不能保存到草稿组`) @@ -2108,10 +2787,6 @@ export function deriveAssemblySaveValidation( blockingIssues.push(...deriveAssemblySpaceConflictIssues(snapshot.nodes)) - if (mainNodes.length === 0) { - warnings.push('主装配区还没有任何已装配零件') - } - return { level: blockingIssues.length > 0 ? 'BLOCKED' : warnings.length > 0 ? 'WARNING' : 'PASSED', @@ -2254,25 +2929,45 @@ export function updateAssemblyNodeTransform( transform: { position?: [number, number, number] rotation?: [number, number, number] + preserveSnapPreview?: boolean }, ): AssemblyWorkspaceSnapshot { - const nextNodes = snapshot.nodes.map((node) => - node.nodeId === nodeId - ? { - ...node, - position: transform.position ? cloneVector3(transform.position) : cloneVector3(node.position), - rotation: transform.rotation ? cloneVector3(transform.rotation) : cloneVector3(node.rotation), - isSnapped: node.zone === 'main' && node.source !== 'desktop' ? false : node.isSnapped, - snapStatus: - node.source === 'desktop' - ? 'hole-snap' - : node.zone === 'main' - ? 'manual-unverified' - : null, - snapBinding: node.source === 'desktop' ? node.snapBinding ?? null : null, - } - : node, - ) + const nextNodes = snapshot.nodes.map((node) => { + if (node.nodeId !== nodeId) { + return node + } + + const shouldPreserveSnapPreview = + transform.preserveSnapPreview === true && + Boolean(node.snapBinding) && + (node.snapStatus === 'hole-preview' || node.snapStatus === 'hole-snap') + + return { + ...node, + position: transform.position ? cloneVector3(transform.position) : cloneVector3(node.position), + rotation: transform.rotation ? cloneVector3(transform.rotation) : cloneVector3(node.rotation), + isSnapped: + shouldPreserveSnapPreview + ? false + : node.zone === 'main' && node.source !== 'desktop' + ? false + : node.isSnapped, + snapStatus: + shouldPreserveSnapPreview + ? 'hole-preview' + : node.source === 'desktop' + ? 'hole-snap' + : node.zone === 'main' + ? 'manual-unverified' + : null, + snapBinding: + shouldPreserveSnapPreview + ? node.snapBinding + : node.source === 'desktop' + ? node.snapBinding ?? null + : null, + } + }) return rebuildWorkspaceSnapshot(snapshot, nextNodes) } @@ -2306,6 +3001,21 @@ export function snapNodesIntoMainAssembly( return rebuildWorkspaceSnapshot(snapshot, nextNodes) } +function activateAutoSnapTargetNode(node: AssemblyWorkspaceNode): AssemblyWorkspaceNode { + if (node.zone === 'main') { + return node + } + + return { + ...node, + zone: 'main', + subassemblyId: null, + isSnapped: true, + snapStatus: 'hole-snap', + roleConfirmed: node.roleConfirmed ?? node.roleRecommendation, + } +} + export function attemptSnapNode( snapshot: AssemblyWorkspaceSnapshot, nodeId: string, @@ -2320,7 +3030,9 @@ export function attemptSnapNode( snapStatus: 'hole-snap', roleConfirmed: node.roleConfirmed ?? node.roleRecommendation, } - : node, + : node.nodeId === currentNode.snapBinding?.targetNodeId + ? activateAutoSnapTargetNode(node) + : node, ) return rebuildWorkspaceSnapshot(snapshot, nextNodes) @@ -2343,7 +3055,9 @@ export function attemptSnapNode( snapBinding: buildSnapBindingFromCandidate(candidate), roleConfirmed: node.roleConfirmed ?? node.roleRecommendation, } - : node, + : node.nodeId === candidate.targetNodeId + ? activateAutoSnapTargetNode(node) + : node, ) return rebuildWorkspaceSnapshot(snapshot, nextNodes) @@ -2354,14 +3068,38 @@ export function applyAutomaticAssemblySnapPreview( nodeId: string, options: { fallbackZone?: AssemblyZone + heldReleaseDistanceMm?: number | null } = {}, ): AssemblyWorkspaceSnapshot { const targetNode = snapshot.nodes.find((node) => node.nodeId === nodeId) - if (!targetNode || targetNode.source === 'desktop') { + if (!targetNode) { return snapshot } const fallbackZone = options.fallbackZone ?? targetNode.zone + const heldCandidate = + targetNode.snapStatus === 'hole-preview' && targetNode.snapBinding + ? buildSnapBindingDisplayCandidate(snapshot, targetNode) + : null + const heldReleaseDistanceMm = options.heldReleaseDistanceMm ?? heldCandidate?.distanceMm ?? Infinity + if (heldCandidate?.isValid && heldReleaseDistanceMm <= AUTO_SNAP_RELEASE_DISTANCE_MM) { + const nextNodes = snapshot.nodes.map((node) => + node.nodeId === nodeId + ? { + ...node, + zone: 'main', + position: resolveHeldSnapPreviewPosition(node, heldCandidate), + rotation: cloneVector3(heldCandidate.previewRotation), + isSnapped: false, + snapStatus: 'hole-preview', + snapBinding: buildSnapBindingFromCandidate(heldCandidate), + } + : node, + ) + + return rebuildWorkspaceSnapshot(snapshot, nextNodes) + } + const candidate = deriveAssemblyAutoSnapCandidate(snapshot, nodeId) if (candidate?.isValid) { diff --git a/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts index 683cea68..5023a543 100644 --- a/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts @@ -48,6 +48,7 @@ jest.mock('../../repositories', () => ({ })) jest.mock('../asset-variant-thumbnail.service', () => ({ + ASSET_VARIANT_THUMBNAIL_PLACEHOLDER_URL: '/asset-thumbnails/placeholder.svg', assetVariantThumbnailService: { generateForVariant: jest.fn(), getPlaceholderUrl: jest.fn(() => '/asset-thumbnails/placeholder.svg'), @@ -711,6 +712,125 @@ describe('assetCenterService', () => { expect(result.variant.thumbnailUrl).toBe('https://cdn.example.com/generated-thumbnail.png') }) + it('创建规格款传入占位缩略图时仍会尝试生成真实缩略图', async () => { + mockedAssetVariantRepository.createWithRelations.mockResolvedValue({ + id: 'variant-board-top-placeholder', + assetFamilyId: 'family-board-top', + code: 'BOARD-TOP-PLACEHOLDER', + name: '桌面板 占位图', + identityKey: + 'BOARD|code=BOARD-TOP-PLACEHOLDER|length=1200|thickness=25|width=600|substrate=substrate-particle-board', + assetType: 'BOARD', + status: 'DRAFT', + sizeLabel: null, + substrateId: 'substrate-particle-board', + finishId: 'finish-white-oak', + dimensions: { length: 1200, width: 600, thickness: 25 }, + modelUrl: '/models/board-placeholder.glb', + thumbnailUrl: '/asset-thumbnails/placeholder.svg', + mountPoints: [{ name: 'origin', position: [0, 0, 0] }], + assemblyBasis: null, + compatibilityRules: null, + namingFields: null, + businessData: null, + weightKg: null, + costPrice: null, + lossFactor: 1, + estimatedCostPrice: 21.79, + inheritedFromVariantId: null, + createdBy: 'user-1', + creator: { id: 'user-1', name: '测试用户' }, + assetFamily: { + id: 'family-board-top', + code: 'BOARD-TOP', + name: '桌面板资产族', + type: 'BOARD', + }, + substrate: { + id: 'substrate-particle-board', + code: 'PB18', + name: '颗粒板', + }, + finish: { + id: 'finish-white-oak', + name: '白橡', + }, + createdAt: new Date('2026-04-11T09:05:00.000Z'), + updatedAt: new Date('2026-04-11T09:05:00.000Z'), + } as never) + mockedAssetVariantRepository.updateWithRelations.mockResolvedValue({ + id: 'variant-board-top-placeholder', + assetFamilyId: 'family-board-top', + code: 'BOARD-TOP-PLACEHOLDER', + name: '桌面板 占位图', + identityKey: + 'BOARD|code=BOARD-TOP-PLACEHOLDER|length=1200|thickness=25|width=600|substrate=substrate-particle-board', + assetType: 'BOARD', + status: 'DRAFT', + sizeLabel: null, + substrateId: 'substrate-particle-board', + finishId: 'finish-white-oak', + dimensions: { length: 1200, width: 600, thickness: 25 }, + modelUrl: '/models/board-placeholder.glb', + thumbnailUrl: 'https://cdn.example.com/generated-placeholder-retry.png', + mountPoints: [{ name: 'origin', position: [0, 0, 0] }], + assemblyBasis: null, + compatibilityRules: null, + namingFields: null, + businessData: null, + weightKg: null, + costPrice: null, + lossFactor: 1, + estimatedCostPrice: 21.79, + inheritedFromVariantId: null, + createdBy: 'user-1', + creator: { id: 'user-1', name: '测试用户' }, + assetFamily: { + id: 'family-board-top', + code: 'BOARD-TOP', + name: '桌面板资产族', + type: 'BOARD', + }, + substrate: { + id: 'substrate-particle-board', + code: 'PB18', + name: '颗粒板', + }, + finish: { + id: 'finish-white-oak', + name: '白橡', + }, + createdAt: new Date('2026-04-11T09:05:00.000Z'), + updatedAt: new Date('2026-04-11T09:05:00.000Z'), + } as never) + + const result = await assetCenterService.createVariant( + { + assetFamilyId: 'family-board-top', + code: 'BOARD-TOP-PLACEHOLDER', + name: '桌面板 占位图', + status: 'DRAFT', + dimensions: { length: 1200, width: 600, thickness: 25 }, + substrateId: 'substrate-particle-board', + finishId: 'finish-white-oak', + modelUrl: '/models/board-placeholder.glb', + thumbnailUrl: '/asset-thumbnails/placeholder.svg', + mountPoints: [{ name: 'origin', position: [0, 0, 0] }], + lossFactor: 1, + createdBy: 'user-1', + }, + { renderBaseUrl: 'http://localhost:3000' }, + ) + + expect(mockedAssetVariantThumbnailService.generateForVariant).toHaveBeenCalledWith( + expect.objectContaining({ + variantId: 'variant-board-top-placeholder', + modelUrl: '/models/board-placeholder.glb', + }), + ) + expect(result.thumbnailUrl).toBe('https://cdn.example.com/generated-placeholder-retry.png') + }) + it('更新模型地址后如果渲染失败,会回退统一占位图但不阻塞保存', async () => { mockedAssetVariantRepository.findByIdWithRelations.mockResolvedValue({ id: 'variant-board-top-update', diff --git a/furniture-3d-system/src/server/services/__tests__/asset-variant-thumbnail.service.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-variant-thumbnail.service.test.ts new file mode 100644 index 00000000..ec8e7b5d --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/asset-variant-thumbnail.service.test.ts @@ -0,0 +1,25 @@ +import { hideDevelopmentChrome } from '../asset-variant-thumbnail.service' + +describe('assetVariantThumbnailService development chrome guard', () => { + it('injects a cleanup rule and hides Next.js development overlays before capture', async () => { + const addStyleTag = jest.fn().mockResolvedValue(undefined) + const evaluate = jest.fn().mockResolvedValue(undefined) + + await hideDevelopmentChrome({ + addStyleTag, + evaluate, + } as never) + + expect(addStyleTag).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('nextjs-portal'), + }), + ) + expect(addStyleTag).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('[data-nextjs-dev-tools-button]'), + }), + ) + expect(evaluate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/furniture-3d-system/src/server/services/asset-center.service.ts b/furniture-3d-system/src/server/services/asset-center.service.ts index f7a9a205..39b9e4bd 100644 --- a/furniture-3d-system/src/server/services/asset-center.service.ts +++ b/furniture-3d-system/src/server/services/asset-center.service.ts @@ -91,6 +91,7 @@ import type { UpdateSubstrateInput, } from './asset-center.service.types' import { + ASSET_VARIANT_THUMBNAIL_PLACEHOLDER_URL, assetVariantThumbnailService, } from './asset-variant-thumbnail.service' import { materialService } from './material.service' @@ -133,6 +134,11 @@ function trimOrNull(value: string | null | undefined): string | null { return next ? next : null } +function hasUsableVariantThumbnail(value: string | null | undefined): boolean { + const next = trimOrNull(value) + return Boolean(next && next !== ASSET_VARIANT_THUMBNAIL_PLACEHOLDER_URL) +} + function normalizeBusinessDataRecord( value: Record | null | undefined, ): Record | null { @@ -902,7 +908,7 @@ class AssetCenterService { return variant } - if (!force && trimOrNull(variant.thumbnailUrl)) { + if (!force && hasUsableVariantThumbnail(variant.thumbnailUrl)) { return variant } @@ -1483,7 +1489,7 @@ class AssetCenterService { variant, renderBaseUrl: options?.renderBaseUrl, updatedBy: input.createdBy ?? null, - skip: options?.skipThumbnail || Boolean(trimOrNull(input.thumbnailUrl)), + skip: options?.skipThumbnail || hasUsableVariantThumbnail(input.thumbnailUrl), }) } @@ -1498,7 +1504,8 @@ class AssetCenterService { detail: '校验编码、资产族、基材和身份重复,并继承来源规格款数据', }) const createdVariant = await this.createVariantInternal(input) - const shouldSkipThumbnail = options?.skipThumbnail || Boolean(trimOrNull(input.thumbnailUrl)) + const shouldSkipThumbnail = + options?.skipThumbnail || hasUsableVariantThumbnail(input.thumbnailUrl) let variant = createdVariant if (!shouldSkipThumbnail) { options?.onProgress?.({ @@ -1924,7 +1931,7 @@ class AssetCenterService { renderBaseUrl: options?.renderBaseUrl, updatedBy: input.updatedBy ?? null, force: true, - skip: options?.skipThumbnail || Boolean(trimOrNull(input.thumbnailUrl)), + skip: options?.skipThumbnail || hasUsableVariantThumbnail(input.thumbnailUrl), }) } @@ -1934,7 +1941,7 @@ class AssetCenterService { renderBaseUrl: options?.renderBaseUrl, updatedBy: input.updatedBy ?? null, force: true, - skip: options?.skipThumbnail || Boolean(trimOrNull(input.thumbnailUrl)), + skip: options?.skipThumbnail || hasUsableVariantThumbnail(input.thumbnailUrl), }) } diff --git a/furniture-3d-system/src/server/services/asset-variant-thumbnail.service.ts b/furniture-3d-system/src/server/services/asset-variant-thumbnail.service.ts index 650fa194..30100a57 100644 --- a/furniture-3d-system/src/server/services/asset-variant-thumbnail.service.ts +++ b/furniture-3d-system/src/server/services/asset-variant-thumbnail.service.ts @@ -84,6 +84,9 @@ function collectAllowedOrigins(renderBaseUrl: string): Set { pushOrigin(process.env.S3_PUBLIC_ENDPOINT) pushOrigin(process.env.S3_PUBLIC_URL) pushOrigin(process.env.S3_ENDPOINT) + process.env.ASSET_THUMBNAIL_ALLOWED_MODEL_ORIGINS + ?.split(',') + .forEach((origin) => pushOrigin(origin)) return origins } @@ -359,6 +362,49 @@ async function waitForRenderState(page: Page): Promise<{ })) } +export async function hideDevelopmentChrome(page: Page): Promise { + await page.addStyleTag({ + content: ` + nextjs-portal, + [data-nextjs-dev-tools-button], + [data-nextjs-toast], + [data-nextjs-dialog-overlay], + [data-nextjs-dialog], + [data-nextjs-terminal], + [data-nextjs-errors], + [data-nextjs-dev-tools-indicator] { + display: none !important; + visibility: hidden !important; + opacity: 0 !important; + pointer-events: none !important; + } + `, + }) + + await page.evaluate(() => { + document.body.dataset.assetThumbnailRenderer = 'true' + document + .querySelectorAll( + [ + 'nextjs-portal', + '[data-nextjs-dev-tools-button]', + '[data-nextjs-toast]', + '[data-nextjs-dialog-overlay]', + '[data-nextjs-dialog]', + '[data-nextjs-terminal]', + '[data-nextjs-errors]', + '[data-nextjs-dev-tools-indicator]', + ].join(','), + ) + .forEach((element) => { + element.style.setProperty('display', 'none', 'important') + element.style.setProperty('visibility', 'hidden', 'important') + element.style.setProperty('opacity', '0', 'important') + element.style.setProperty('pointer-events', 'none', 'important') + }) + }) +} + class AssetVariantThumbnailService { getPlaceholderUrl(): string { return ASSET_VARIANT_THUMBNAIL_PLACEHOLDER_URL @@ -380,11 +426,13 @@ class AssetVariantThumbnailService { await page.goto(renderUrl, { waitUntil: 'domcontentloaded', }) + await hideDevelopmentChrome(page) const renderState = await waitForRenderState(page) if (renderState.state !== 'ready') { throw new Error(renderState.errorMessage || '缩略图渲染失败') } + await hideDevelopmentChrome(page) const captureHandle = await page.$('[data-thumbnail-capture="true"]') if (!captureHandle) {