From 5aaabd42704b49b0a7c1adfe62e1624686ce05e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BA=B7?= Date: Wed, 15 Jul 2026 12:28:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=9B=BE=E7=89=87=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E3=80=81json=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/annotate-all-models.mjs | 230 ++++++++++++++++-- .../config/JsonDatasetImportProperties.java | 3 + .../cadlabel/dto/cad/CadModelResponse.java | 1 + .../dto/cad/UpdateCadModelRequest.java | 1 + .../java/com/cadlabel/entity/CadModel.java | 3 + .../importer/JsonDatasetImportRunner.java | 9 +- .../service/JsonDatasetImportService.java | 6 +- .../service/impl/CadModelServiceImpl.java | 6 + .../SolidWorksJsonDatasetImportService.java | 34 ++- src/main/resources/application.yml | 1 + .../controller/CadModelControllerTest.java | 6 +- .../CadAnnotationServiceIntegrationTest.java | 1 + .../impl/CadModelServiceIntegrationTest.java | 4 + ...olidWorksJsonDatasetImportServiceTest.java | 5 +- 14 files changed, 273 insertions(+), 37 deletions(-) diff --git a/scripts/annotate-all-models.mjs b/scripts/annotate-all-models.mjs index 00c520a..f6cb69e 100644 --- a/scripts/annotate-all-models.mjs +++ b/scripts/annotate-all-models.mjs @@ -3,11 +3,15 @@ import path from 'node:path'; import process from 'node:process'; const API_BASE = process.env.CADLABEL_API_BASE ?? 'http://127.0.0.1:8891/api'; -const SOURCE_ROOT = path.resolve(process.cwd(), process.env.CADLABEL_SOURCE_ROOT ?? '../output_json 2'); +const DEFAULT_SOURCE_ROOT = fs.existsSync(path.resolve(process.cwd(), '../json_data/original')) + ? '../json_data/original' + : '../output_json 2'; +const SOURCE_ROOT = path.resolve(process.cwd(), process.env.CADLABEL_SOURCE_ROOT ?? DEFAULT_SOURCE_ROOT); const APPLY = process.argv.includes('--apply'); const FORCE = process.argv.includes('--force'); const onlyModel = valueAfter('--model'); const MARKER = '[AI批量分析:v1]'; +const API_TIMEOUT_MS = Number(process.env.CADLABEL_API_TIMEOUT_MS ?? 120_000); const CLASS_DEFINITIONS = [ { @@ -141,7 +145,7 @@ async function api(relativePath, init) { 'Content-Type': 'application/json', ...(init?.headers ?? {}) }, - signal: AbortSignal.timeout(30_000) + signal: AbortSignal.timeout(API_TIMEOUT_MS) }); const payload = await response.json(); if (!response.ok || payload.code !== 'OK') { @@ -154,8 +158,78 @@ function post(relativePath, body) { return api(relativePath, { method: 'POST', body: JSON.stringify(body) }); } -function classFor(modelName) { - return CLASS_DEFINITIONS.find((item) => item.ids.includes(modelName)) ?? { +function classFor(modelName, geometry) { + const configured = CLASS_DEFINITIONS.find((item) => item.ids.includes(modelName)); + if (configured) return configured; + if (modelName.includes('方板+均布圆周孔')) { + return { + label: '圆周孔方形安装板', + scene: '方形安装基面上的圆周均布紧固、定位和载荷分散', + primaryFunction: '方板承载与圆周孔系连接' + }; + } + if (modelName.includes('方板+四角孔')) { + return { + label: '多孔方形安装板', + scene: '方形安装基面上的四角紧固、中心定位和附加接口连接', + primaryFunction: '方板承载与多点安装' + }; + } + if (modelName.includes('方台')) { + return { + label: geometry.interfaceCount ? '多孔方形安装台' : '方形承载台', + scene: '小型机械组件的平面承载、垫高、定位和紧固安装', + primaryFunction: '方台承载与安装定位' + }; + } + if (modelName.includes('圆盘+均布孔')) { + return { + label: '圆周孔圆形安装盘', + scene: '圆形安装面上的周向均布紧固和同轴定位', + primaryFunction: '圆盘承载与圆周孔系连接' + }; + } + if (modelName.includes('圆盘')) { + return { + label: '圆形承载盘', + scene: '圆形端面的承载、间隔和同轴安装', + primaryFunction: '圆盘承载与端面定位' + }; + } + const sequenceNumber = /^\d+$/.test(modelName) ? Number(modelName) : null; + if (sequenceNumber !== null && sequenceNumber >= 40 && sequenceNumber <= 51) { + return { + label: '多孔圆形法兰盘', + scene: '中心接口与周向孔系组合的法兰连接和同轴定位', + primaryFunction: '圆形法兰连接与孔系定位' + }; + } + if (sequenceNumber !== null && sequenceNumber >= 52 && sequenceNumber <= 58) { + return { + label: '双耳异形连接板', + scene: '双圆头或长圆形接口之间的跨距连接、铰接和定位', + primaryFunction: '双接口跨距连接与载荷传递' + }; + } + if (sequenceNumber !== null && sequenceNumber >= 59 && sequenceNumber <= 66) { + return { + label: '多孔异形安装板', + scene: '多规格孔位的组合安装、定位和多点紧固', + primaryFunction: '异形板承载与多孔连接' + }; + } + if (sequenceNumber !== null && sequenceNumber >= 67) { + return { + label: '复合孔系圆形法兰盘', + scene: '多圈、多规格孔系的法兰连接、精密定位和装配适配', + primaryFunction: '复合孔系法兰连接' + }; + } + return geometry.outerCircular ? { + label: '多孔圆形安装盘', + scene: '圆形安装面上的承载、周向紧固和同轴定位', + primaryFunction: '圆盘承载与多孔连接' + } : { label: '机械安装连接件', scene: '机械组件的承载、定位和紧固连接', primaryFunction: '机械承载与装配连接' @@ -177,6 +251,54 @@ function boundingBox(source) { return dimensions.map(formatNumber); } +function geometryAnalysis(source) { + const box = source.validation_hints?.part_box_m; + const size = Array.isArray(box) && box.length === 6 + ? [box[3] - box[0], box[4] - box[1], box[5] - box[2]].map((value) => value * 1000) + : []; + const maxSpan = size.length ? Math.max(...size) : 0; + const faces = (source.features ?? []).flatMap((feature) => feature.owned_faces ?? []); + const cylinders = faces + .filter((face) => face.surface?.is_cylinder && Array.isArray(face.surface.cylinder_params)) + .map((face) => { + const params = face.surface.cylinder_params; + const center = params.slice(0, 3).map((value) => value * 1000); + const axis = params.slice(3, 6); + const axisIndex = axis.reduce( + (best, value, index) => Math.abs(value) > Math.abs(axis[best] ?? 0) ? index : best, + 0 + ); + const radialDistance = Math.hypot(...center.filter((_, index) => index !== axisIndex)); + return { radius: Math.abs(params[6] * 1000), center, radialDistance }; + }); + const outerCircular = cylinders.some((item) => + maxSpan > 0 && item.radius >= maxSpan * 0.4 && item.radialDistance <= maxSpan * 0.08 + ); + const interfaces = cylinders.filter((item) => + maxSpan > 0 && item.radius <= maxSpan * 0.2 + ); + const uniqueInterfaces = new Map(); + for (const item of interfaces) { + const key = `${formatNumber(item.radius)}:${item.center.map((value) => formatNumber(value)).join(',')}`; + uniqueInterfaces.set(key, item); + } + const diameterGroups = new Map(); + for (const item of uniqueInterfaces.values()) { + const diameter = formatNumber(item.radius * 2); + diameterGroups.set(diameter, (diameterGroups.get(diameter) ?? 0) + 1); + } + const interfaceSummary = [...diameterGroups.entries()] + .sort((left, right) => Number(right[0]) - Number(left[0])) + .map(([diameter, count]) => `${count}ר${diameter} mm`) + .join('、'); + return { + size, + outerCircular, + interfaceCount: uniqueInterfaces.size, + interfaceSummary + }; +} + function formatNumber(value) { if (!Number.isFinite(value)) return String(value); return Number(value.toFixed(Math.abs(value) < 10 ? 3 : 2)).toString(); @@ -240,17 +362,22 @@ function operationIntent(step) { return `执行建模步骤“${step.name}”,在前序几何基础上形成该步骤对应的有效几何。`; } -function stepAnnotation(modelName, step, feature) { +function stepAnnotation(modelName, step, feature, geometry) { const evidence = dimensionEvidence(feature, step, modelName); const faceCount = feature?.owned_faces?.length ?? 0; const group = GROUP_DEFINITIONS[stepGroup(step)]; + const compoundProfile = step.type === 'extrude_add' && geometry.interfaceCount > 0 + ? `该复合草图一次拉伸同时形成主体外轮廓与 ${geometry.interfaceCount} 个圆形内轮廓。` + : ''; return { - modelingDescription: `${operationIntent(step)}该步骤在本零件中归入“${group.name}”。`, + modelingDescription: `${operationIntent(step)}${compoundProfile}该步骤在本零件中归入“${group.name}”。`, supplementDescription: [ `重建顺序第 ${step.order} 步,绑定源特征 ${step.sourceFeatureId}。`, + geometry.size.length ? `最终几何包络约为 ${geometry.size.map(formatNumber).join(' × ')} mm。` : null, + compoundProfile ? `圆形接口证据:${geometry.interfaceSummary}。` : null, evidence.length ? `关键参数证据:${evidence.join(';')}。` : '源特征未提供可直接解释的标量尺寸,语义依据操作类型与几何结果确定。', faceCount ? `源特征记录 ${faceCount} 个归属面,可用于后续拓扑复核。` : '源特征未记录独占面,需结合前后步骤或最终拓扑复核。' - ].join(''), + ].filter(Boolean).join(''), remark: `${MARKER} 参数来自 SolidWorks 提取结果;角度、阵列计数及孔向导深度可能采用插件编码或构造深度,不应在未核对原模型前作为公差或有效加工深度。` }; } @@ -279,9 +406,21 @@ function structurePayload(groupKey, steps, classInfo) { }; } +function geometryInterfaceStructurePayload(step, geometry, classInfo) { + return { + structureName: '复合草图孔系与圆弧接口', + structureType: '孔系/圆弧轮廓结构', + purpose: '在主体成形步骤中同步建立圆形内轮廓,用于紧固、定位、穿轴或装配避让。', + solution: `由建模步骤 ${step.order}.${step.name} 的复合草图一次拉伸形成;检测到 ${geometry.interfaceCount} 个圆形接口,直径分组为 ${geometry.interfaceSummary}。`, + reason: `复合草图将主体外轮廓与接口轮廓置于同一成形步骤,可保持相对位置一致;该判断同时结合“${classInfo.label}”的典型用途。`, + remark: `${MARKER} 圆形接口依据源特征归属面的圆柱几何识别;孔的用途、配合等级与加工公差需结合装配对象复核。`, + historyIds: [step.historyId] + }; +} + function functionPayloads(classInfo, structures) { const allIds = structures.map((item) => item.id); - const interfaceStructures = structures.filter((item) => ['cut', 'hole', 'thread', 'counterbore', 'pattern'].includes(item.groupKey)); + const interfaceStructures = structures.filter((item) => ['cut', 'hole', 'thread', 'counterbore', 'pattern', 'geometry_interface'].includes(item.groupKey)); const edgeStructures = structures.filter((item) => item.groupKey === 'edge'); const payloads = [{ functionName: classInfo.primaryFunction, @@ -311,12 +450,15 @@ function functionPayloads(classInfo, structures) { return payloads; } -function modelPayload(model, source, histories, classInfo) { +function modelPayload(model, source, histories, classInfo, geometry) { const box = boundingBox(source); const boxText = box ? `零件几何包络约为 ${box.join(' × ')} mm。` : '源数据未提供可解析的零件包络。'; + const interfaceText = geometry.interfaceCount > 0 + ? `复合草图形成 ${geometry.interfaceCount} 个圆形接口,直径分组为 ${geometry.interfaceSummary}。` + : '未从源特征归属面中识别出独立的小直径圆形接口。'; const sequence = histories.map((step) => `${step.order}.${step.name}(${step.type})`).join(';'); const hasRevolve = histories.some((step) => step.type.includes('revolve')); - const hasHole = histories.some((step) => step.type === 'hole'); + const hasHole = histories.some((step) => step.type === 'hole') || geometry.interfaceCount > 0; const manufacturing = [ hasRevolve ? '回转表面车削或车铣复合加工' : 'CNC 铣削或轮廓加工', hasHole ? '钻孔/扩孔/攻丝等孔系加工' : null, @@ -326,11 +468,27 @@ function modelPayload(model, source, histories, classInfo) { applicationScenario: classInfo.scene, material: 'SolidWorks 源模型未指定材料;数据集建议铝合金或钢制测试件,需按载荷与环境确认', manufacturingMethod: `${manufacturing}(依据建模特征推定,待工艺确认)`, - userRequirement: `设计一件${classInfo.label},用于${classInfo.scene}。${boxText}需保留建模历史中定义的主体、孔槽、连接接口和边缘处理,并保证各接口的相对位置与重建顺序一致。`, - designDescription: `${model.modelName} 被识别为${classInfo.label}。${boxText}有效建模序列为:${sequence}。主体加料特征建立承载包络,切除与孔向导特征形成装配接口,阵列保证重复结构一致,倒角/圆角改善边缘装配性。材料、载荷、配合公差和制造基准未由源 JSON 完整给出,相关结论均需工程复核。` + userRequirement: `设计一件${classInfo.label},用于${classInfo.scene}。${boxText}${interfaceText}需保留建模历史中定义的主体、孔槽、连接接口和边缘处理,并保证各接口的相对位置与重建顺序一致。`, + designDescription: `${model.modelName} 被识别为${classInfo.label}。${boxText}${interfaceText}有效建模序列为:${sequence}。主体加料特征建立承载包络,独立切除、孔向导或复合草图内轮廓形成装配接口,阵列保证重复结构一致,倒角/圆角改善边缘装配性。材料、载荷、配合公差和制造基准未由源 JSON 完整给出,相关结论均需工程复核。` }; } +function hasText(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function isCompleteAnnotation(exportData, historyCount) { + const annotatedCount = (exportData.featureIndex ?? []).filter((item) => + hasText(item.annotation?.modelingIntent) + && hasText(item.annotation?.description) + && hasText(item.annotation?.notes) + ).length; + return annotatedCount === historyCount + && (exportData.structures?.length ?? 0) > 0 + && (exportData.functions?.length ?? 0) > 0 + && (exportData.unassignedHistoryIds?.length ?? 0) === 0; +} + function hasUnmanagedAnnotation(exportData) { const annotations = exportData.featureIndex ?? []; const existingNotes = annotations.map((item) => item.annotation?.notes).filter(Boolean); @@ -366,12 +524,16 @@ async function annotateModel(model) { api(`/cad-models/${model.id}/export/annotation`) ]); if (!histories.length) return { model: model.modelName, status: 'skipped:no_histories' }; + if (!FORCE && isCompleteAnnotation(exportData, histories.length)) { + return { model: model.modelName, status: 'skipped:already_complete' }; + } if (!FORCE && hasUnmanagedAnnotation(exportData)) { return { model: model.modelName, status: 'skipped:existing_manual_annotation' }; } const source = sourceFor(model.modelName); - const classInfo = classFor(model.modelName); + const geometry = geometryAnalysis(source); + const classInfo = classFor(model.modelName, geometry); const featuresById = new Map((source.features ?? []).map((feature) => [feature.id, feature])); const normalizedHistories = histories.map((step) => ({ historyId: step.id, @@ -381,15 +543,22 @@ async function annotateModel(model) { type: step.operationType })); const groups = groupSteps(normalizedHistories); - const modelUpdate = modelPayload(model, source, normalizedHistories, classInfo); + const geometryStep = geometry.interfaceCount > 0 + ? normalizedHistories.find((step) => step.type === 'extrude_add') + : null; + const modelUpdate = modelPayload(model, source, normalizedHistories, classInfo, geometry); + const dryRunStructures = [...groups.keys()].map((groupKey, index) => ({ id: index + 1, groupKey })); + if (geometryStep) dryRunStructures.push({ id: dryRunStructures.length + 1, groupKey: 'geometry_interface' }); if (!APPLY) { return { model: model.modelName, status: 'dry-run', histories: histories.length, - structures: groups.size, - functions: functionPayloads(classInfo, [...groups.keys()].map((groupKey, index) => ({ id: index + 1, groupKey }))).length + structures: dryRunStructures.length, + functions: functionPayloads(classInfo, dryRunStructures).length, + circularInterfaces: geometry.interfaceCount, + interfaceSummary: geometry.interfaceSummary || undefined }; } @@ -397,7 +566,7 @@ async function annotateModel(model) { for (const step of normalizedHistories) { await post( `/cad-models/${model.id}/histories/${step.historyId}/annotation`, - stepAnnotation(model.modelName, step, featuresById.get(step.sourceFeatureId)) + stepAnnotation(model.modelName, step, featuresById.get(step.sourceFeatureId), geometry) ); } @@ -407,6 +576,14 @@ async function annotateModel(model) { const saved = await ensureStructure(model.id, structurePayload(groupKey, steps, classInfo), existingStructures); structures.push({ ...saved, groupKey }); } + if (geometryStep) { + const saved = await ensureStructure( + model.id, + geometryInterfaceStructurePayload(geometryStep, geometry, classInfo), + existingStructures + ); + structures.push({ ...saved, groupKey: 'geometry_interface' }); + } const existingFunctions = new Map((exportData.functions ?? []).map((item) => [item.name, { id: item.id }])); for (const payload of functionPayloads(classInfo, structures)) { @@ -414,9 +591,13 @@ async function annotateModel(model) { } const verified = await api(`/cad-models/${model.id}/export/annotation`); - const annotatedCount = (verified.featureIndex ?? []).filter((item) => item.annotation?.modelingIntent).length; - if (annotatedCount !== histories.length || verified.unassignedHistoryIds?.length) { - throw new Error(`${model.modelName} 校验失败: annotated=${annotatedCount}/${histories.length}, unassigned=${verified.unassignedHistoryIds}`); + const annotatedCount = (verified.featureIndex ?? []).filter((item) => + hasText(item.annotation?.modelingIntent) + && hasText(item.annotation?.description) + && hasText(item.annotation?.notes) + ).length; + if (!isCompleteAnnotation(verified, histories.length)) { + throw new Error(`${model.modelName} 校验失败: annotated=${annotatedCount}/${histories.length}, structures=${verified.structures?.length ?? 0}, functions=${verified.functions?.length ?? 0}, unassigned=${verified.unassignedHistoryIds}`); } return { model: model.modelName, @@ -429,8 +610,13 @@ async function annotateModel(model) { } async function main() { - const page = await api('/cad-models?page=1&size=100'); - let models = [...page.items].sort((left, right) => left.modelName.localeCompare(right.modelName)); + const modelsByPage = []; + for (let pageNumber = 1; ; pageNumber += 1) { + const page = await api(`/cad-models?page=${pageNumber}&size=100`); + modelsByPage.push(...page.items); + if (modelsByPage.length >= page.total || page.items.length === 0) break; + } + let models = modelsByPage.sort((left, right) => left.modelName.localeCompare(right.modelName)); if (onlyModel) models = models.filter((model) => model.modelName === onlyModel); if (!models.length) throw new Error(onlyModel ? `未找到模型 ${onlyModel}` : '数据库中没有模型'); diff --git a/src/main/java/com/cadlabel/config/JsonDatasetImportProperties.java b/src/main/java/com/cadlabel/config/JsonDatasetImportProperties.java index a37e56f..3624da4 100644 --- a/src/main/java/com/cadlabel/config/JsonDatasetImportProperties.java +++ b/src/main/java/com/cadlabel/config/JsonDatasetImportProperties.java @@ -27,6 +27,9 @@ public class JsonDatasetImportProperties { /** 是否由 Java 导入器上传对象;外部已上传时关闭。 */ private boolean uploadEnabled = true; + /** 是否清空现有标注数据;关闭时仅允许追加不存在的新模型。 */ + private boolean replaceExisting = true; + /** 导入模型归属的分类 ID。 */ private long categoryId = 1L; } diff --git a/src/main/java/com/cadlabel/dto/cad/CadModelResponse.java b/src/main/java/com/cadlabel/dto/cad/CadModelResponse.java index 949b059..e0701d1 100644 --- a/src/main/java/com/cadlabel/dto/cad/CadModelResponse.java +++ b/src/main/java/com/cadlabel/dto/cad/CadModelResponse.java @@ -10,6 +10,7 @@ public record CadModelResponse( String sourceSwPath, String featureTreeJsonPath, String viewPath, + String thumbnailPath, String applicationScenario, String material, String manufacturingMethod, diff --git a/src/main/java/com/cadlabel/dto/cad/UpdateCadModelRequest.java b/src/main/java/com/cadlabel/dto/cad/UpdateCadModelRequest.java index 7069e87..ef54f14 100644 --- a/src/main/java/com/cadlabel/dto/cad/UpdateCadModelRequest.java +++ b/src/main/java/com/cadlabel/dto/cad/UpdateCadModelRequest.java @@ -10,6 +10,7 @@ public record UpdateCadModelRequest( @Size(max = 1024) String sourceSwPath, @Size(max = 1024) String featureTreeJsonPath, @Size(max = 1024) String viewPath, + @Size(max = 1024) String thumbnailPath, @Size(max = 255) String applicationScenario, @Size(max = 255) String material, @Size(max = 255) String manufacturingMethod, diff --git a/src/main/java/com/cadlabel/entity/CadModel.java b/src/main/java/com/cadlabel/entity/CadModel.java index fdbc494..c482a51 100644 --- a/src/main/java/com/cadlabel/entity/CadModel.java +++ b/src/main/java/com/cadlabel/entity/CadModel.java @@ -32,6 +32,9 @@ public class CadModel { /** 模型视图资源路径。 */ private String viewPath; + /** 模型预览图路径。 */ + private String thumbnailPath; + /** 应用场景。 */ private String applicationScenario; diff --git a/src/main/java/com/cadlabel/importer/JsonDatasetImportRunner.java b/src/main/java/com/cadlabel/importer/JsonDatasetImportRunner.java index 5b5b954..8795069 100644 --- a/src/main/java/com/cadlabel/importer/JsonDatasetImportRunner.java +++ b/src/main/java/com/cadlabel/importer/JsonDatasetImportRunner.java @@ -12,7 +12,7 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; /** - * 显式启用后,全量替换标注后台中的模型数据。 + * 显式启用后,将处理完成的 JSON 数据集导入标注后台。 */ @Slf4j @Component @@ -33,13 +33,14 @@ public class JsonDatasetImportRunner implements ApplicationRunner { } Path sourceDirectory = Path.of(properties.getSourceDirectory()).toAbsolutePath().normalize(); Path artifactsDirectory = Path.of(properties.getArtifactsDirectory()).toAbsolutePath().normalize(); - log.warn("Starting destructive CAD JSON dataset import. sourceDirectory={}, artifactsDirectory={}, categoryId={}", - sourceDirectory, artifactsDirectory, properties.getCategoryId()); - JsonDatasetImportService.ImportSummary summary = jsonDatasetImportService.replaceDataset( + log.warn("Starting CAD JSON dataset import. sourceDirectory={}, artifactsDirectory={}, replaceExisting={}, categoryId={}", + sourceDirectory, artifactsDirectory, properties.isReplaceExisting(), properties.getCategoryId()); + JsonDatasetImportService.ImportSummary summary = jsonDatasetImportService.importDataset( sourceDirectory, artifactsDirectory, properties.getCosPrefix(), properties.isUploadEnabled(), + properties.isReplaceExisting(), properties.getCategoryId() ); log.info("CAD JSON dataset import finished. modelCount={}, historyCount={}", diff --git a/src/main/java/com/cadlabel/service/JsonDatasetImportService.java b/src/main/java/com/cadlabel/service/JsonDatasetImportService.java index 2090b68..08101a3 100644 --- a/src/main/java/com/cadlabel/service/JsonDatasetImportService.java +++ b/src/main/java/com/cadlabel/service/JsonDatasetImportService.java @@ -3,12 +3,12 @@ package com.cadlabel.service; import java.nio.file.Path; /** - * 将 SolidWorks 特征树 JSON 数据集替换导入标注后台。 + * 将 SolidWorks 特征树 JSON 数据集导入标注后台。 */ public interface JsonDatasetImportService { - ImportSummary replaceDataset(Path sourceDirectory, Path artifactsDirectory, String cosPrefix, - boolean uploadEnabled, long categoryId); + ImportSummary importDataset(Path sourceDirectory, Path artifactsDirectory, String cosPrefix, + boolean uploadEnabled, boolean replaceExisting, long categoryId); record ImportSummary(int modelCount, int historyCount) { } diff --git a/src/main/java/com/cadlabel/service/impl/CadModelServiceImpl.java b/src/main/java/com/cadlabel/service/impl/CadModelServiceImpl.java index 087894b..378e6d7 100644 --- a/src/main/java/com/cadlabel/service/impl/CadModelServiceImpl.java +++ b/src/main/java/com/cadlabel/service/impl/CadModelServiceImpl.java @@ -194,6 +194,7 @@ public class CadModelServiceImpl implements CadModelService { validateMaxLength("sourceSwPath", request.sourceSwPath(), 1024); validateMaxLength("featureTreeJsonPath", request.featureTreeJsonPath(), 1024); validateMaxLength("viewPath", request.viewPath(), 1024); + validateMaxLength("thumbnailPath", request.thumbnailPath(), 1024); validateMaxLength("applicationScenario", request.applicationScenario(), 255); validateMaxLength("material", request.material(), 255); validateMaxLength("manufacturingMethod", request.manufacturingMethod(), 255); @@ -229,6 +230,9 @@ public class CadModelServiceImpl implements CadModelService { if (request.viewPath() != null) { model.setViewPath(request.viewPath()); } + if (request.thumbnailPath() != null) { + model.setThumbnailPath(request.thumbnailPath()); + } if (request.applicationScenario() != null) { model.setApplicationScenario(request.applicationScenario()); } @@ -266,6 +270,7 @@ public class CadModelServiceImpl implements CadModelService { addField(fields, "sourceSwPath", request.sourceSwPath()); addField(fields, "featureTreeJsonPath", request.featureTreeJsonPath()); addField(fields, "viewPath", request.viewPath()); + addField(fields, "thumbnailPath", request.thumbnailPath()); addField(fields, "applicationScenario", request.applicationScenario()); addField(fields, "material", request.material()); addField(fields, "manufacturingMethod", request.manufacturingMethod()); @@ -314,6 +319,7 @@ public class CadModelServiceImpl implements CadModelService { model.getSourceSwPath(), model.getFeatureTreeJsonPath(), model.getViewPath(), + model.getThumbnailPath(), model.getApplicationScenario(), model.getMaterial(), model.getManufacturingMethod(), diff --git a/src/main/java/com/cadlabel/service/impl/SolidWorksJsonDatasetImportService.java b/src/main/java/com/cadlabel/service/impl/SolidWorksJsonDatasetImportService.java index 793c854..534e72b 100644 --- a/src/main/java/com/cadlabel/service/impl/SolidWorksJsonDatasetImportService.java +++ b/src/main/java/com/cadlabel/service/impl/SolidWorksJsonDatasetImportService.java @@ -1,5 +1,6 @@ package com.cadlabel.service.impl; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.cadlabel.entity.CadModel; import com.cadlabel.entity.CadModelHistory; import com.cadlabel.mapper.CadModelHistoryMapper; @@ -29,7 +30,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -/** 将 Node 后端完成重建的 SolidWorks 数据集上传 COS 并替换远程标注数据。 */ +/** 将 Node 后端完成重建的 SolidWorks 数据集上传对象存储并导入远程标注数据。 */ @Slf4j @Service @RequiredArgsConstructor @@ -47,8 +48,8 @@ public class SolidWorksJsonDatasetImportService implements JsonDatasetImportServ @Override @Transactional - public ImportSummary replaceDataset(Path sourceDirectory, Path artifactsDirectory, String cosPrefix, - boolean uploadEnabled, long categoryId) { + public ImportSummary importDataset(Path sourceDirectory, Path artifactsDirectory, String cosPrefix, + boolean uploadEnabled, boolean replaceExisting, long categoryId) { if (categoryId <= 0) { throw new IllegalArgumentException("categoryId 必须为正数"); } @@ -57,6 +58,9 @@ public class SolidWorksJsonDatasetImportService implements JsonDatasetImportServ if (models.size() != sourceFiles.size()) { throw new IllegalArgumentException("JSON 与完整产物数量不一致: json=" + sourceFiles.size() + ", artifacts=" + models.size()); } + if (!replaceExisting) { + ensureModelsDoNotExist(models); + } if (uploadEnabled) { for (ImportedModel model : models) { @@ -64,7 +68,9 @@ public class SolidWorksJsonDatasetImportService implements JsonDatasetImportServ } } - clearExistingAnnotationData(); + if (replaceExisting) { + clearExistingAnnotationData(); + } int historyCount = 0; for (ImportedModel importedModel : models) { CadModel model = createModel(importedModel, cosPrefix, categoryId); @@ -121,7 +127,10 @@ public class SolidWorksJsonDatasetImportService implements JsonDatasetImportServ Path versionRoot = manifestPath.getParent().getParent(); JsonNode sourceRoot = readJson(versionRoot.resolve("source.feature-tree.json")); String partName = requireText(text(sourceRoot, "part_name"), "转换产物缺少 part_name: " + versionRoot); - if (!sourceFiles.containsKey(partName) || !partNames.add(partName)) { + if (!sourceFiles.containsKey(partName)) { + continue; + } + if (!partNames.add(partName)) { throw new IllegalArgumentException("转换产物无法唯一匹配 JSON: " + partName); } requireFiles(versionRoot, "source-json-rebuilt.step", "scene.glb", "topology.json", "thumbnail.svg", @@ -156,6 +165,20 @@ public class SolidWorksJsonDatasetImportService implements JsonDatasetImportServ return models; } + private void ensureModelsDoNotExist(List models) { + Set modelNames = models.stream().map(ImportedModel::partName).collect(java.util.stream.Collectors.toSet()); + Set sourceFilenames = models.stream() + .map(model -> model.partName() + ".SLDPRT") + .collect(java.util.stream.Collectors.toSet()); + Long count = cadModelMapper.selectCount(Wrappers.lambdaQuery(CadModel.class) + .in(CadModel::getModelName, modelNames) + .or() + .in(CadModel::getSourceSwFilename, sourceFilenames)); + if (count != null && count > 0) { + throw new IllegalArgumentException("追加导入包含远程已存在模型,拒绝写入: duplicateCount=" + count); + } + } + private void uploadModelAssets(ImportedModel model, String cosPrefix) { List files; try (Stream paths = Files.walk(model.versionRoot())) { @@ -194,6 +217,7 @@ public class SolidWorksJsonDatasetImportService implements JsonDatasetImportServ model.setSourceSwPath(trimTo(objectKey(cosPrefix, source, "source-json-rebuilt.step"), 1024)); model.setFeatureTreeJsonPath(trimTo(objectKey(cosPrefix, source, "source.feature-tree.json"), 1024)); model.setViewPath(trimTo(objectKey(cosPrefix, source, "scene.glb"), 1024)); + model.setThumbnailPath(trimTo(objectKey(cosPrefix, source, "thumbnail.svg"), 1024)); model.setApplicationScenario(""); model.setMaterial(""); model.setManufacturingMethod(""); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3ccafdd..52afed5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -53,6 +53,7 @@ app: artifacts-directory: ${APP_JSON_DATASET_IMPORT_ARTIFACTS_DIRECTORY:} cos-prefix: ${APP_JSON_DATASET_IMPORT_COS_PREFIX:datasets/solidworks-rebuild-v1} upload-enabled: ${APP_JSON_DATASET_IMPORT_UPLOAD_ENABLED:true} + replace-existing: ${APP_JSON_DATASET_IMPORT_REPLACE_EXISTING:true} category-id: ${APP_JSON_DATASET_IMPORT_CATEGORY_ID:1} logging: diff --git a/src/test/java/com/cadlabel/controller/CadModelControllerTest.java b/src/test/java/com/cadlabel/controller/CadModelControllerTest.java index 76686b4..835f7ed 100644 --- a/src/test/java/com/cadlabel/controller/CadModelControllerTest.java +++ b/src/test/java/com/cadlabel/controller/CadModelControllerTest.java @@ -60,7 +60,8 @@ class CadModelControllerTest { .andExpect(jsonPath("$.data.size").value(5)) .andExpect(jsonPath("$.data.total").value(11)) .andExpect(jsonPath("$.data.items[0].id").value(1)) - .andExpect(jsonPath("$.data.items[0].modelName").value("轴承座")); + .andExpect(jsonPath("$.data.items[0].modelName").value("轴承座")) + .andExpect(jsonPath("$.data.items[0].thumbnailPath").value("thumbnails/015133.svg")); verify(cadModelService).listModels(new CadModelListQuery(2, 5, 7L, "success", "轴承")); } @@ -74,6 +75,7 @@ class CadModelControllerTest { .andExpect(jsonPath("$.code").value("OK")) .andExpect(jsonPath("$.data.id").value(1)) .andExpect(jsonPath("$.data.modelName").value("连接板")) + .andExpect(jsonPath("$.data.thumbnailPath").value("thumbnails/015133.svg")) .andExpect(jsonPath("$.data.createdAt").value("2026-07-08 10:00:00")); } @@ -119,6 +121,7 @@ class CadModelControllerTest { null, null, null, + null, null )); } @@ -174,6 +177,7 @@ class CadModelControllerTest { "sw/015133.sldprt", "features/015133.json", "views/015133", + "thumbnails/015133.svg", "工业夹具", "铝合金", "CNC", diff --git a/src/test/java/com/cadlabel/service/impl/CadAnnotationServiceIntegrationTest.java b/src/test/java/com/cadlabel/service/impl/CadAnnotationServiceIntegrationTest.java index 7c19d92..dc50f48 100644 --- a/src/test/java/com/cadlabel/service/impl/CadAnnotationServiceIntegrationTest.java +++ b/src/test/java/com/cadlabel/service/impl/CadAnnotationServiceIntegrationTest.java @@ -56,6 +56,7 @@ class CadAnnotationServiceIntegrationTest { source_sw_path VARCHAR(1024) NOT NULL, feature_tree_json_path VARCHAR(1024) NOT NULL DEFAULT '', view_path VARCHAR(1024) NOT NULL DEFAULT '', + thumbnail_path VARCHAR(1024) NOT NULL DEFAULT '', application_scenario VARCHAR(255) NOT NULL DEFAULT '', material VARCHAR(255) NOT NULL DEFAULT '', manufacturing_method VARCHAR(255) NOT NULL DEFAULT '', diff --git a/src/test/java/com/cadlabel/service/impl/CadModelServiceIntegrationTest.java b/src/test/java/com/cadlabel/service/impl/CadModelServiceIntegrationTest.java index 40b7f13..f1b5efa 100644 --- a/src/test/java/com/cadlabel/service/impl/CadModelServiceIntegrationTest.java +++ b/src/test/java/com/cadlabel/service/impl/CadModelServiceIntegrationTest.java @@ -47,6 +47,7 @@ class CadModelServiceIntegrationTest { source_sw_path VARCHAR(1024) NOT NULL, feature_tree_json_path VARCHAR(1024) NOT NULL DEFAULT '', view_path VARCHAR(1024) NOT NULL DEFAULT '', + thumbnail_path VARCHAR(1024) NOT NULL DEFAULT '', application_scenario VARCHAR(255) NOT NULL DEFAULT '', material VARCHAR(255) NOT NULL DEFAULT '', manufacturing_method VARCHAR(255) NOT NULL DEFAULT '', @@ -160,6 +161,7 @@ class CadModelServiceIntegrationTest { null, null, null, + null, "partial_failed", null ); @@ -194,6 +196,7 @@ class CadModelServiceIntegrationTest { null, null, null, + null, "done", null ))) @@ -214,6 +217,7 @@ class CadModelServiceIntegrationTest { null, null, null, + null, null ))) .isInstanceOf(BizException.class) diff --git a/src/test/java/com/cadlabel/service/impl/SolidWorksJsonDatasetImportServiceTest.java b/src/test/java/com/cadlabel/service/impl/SolidWorksJsonDatasetImportServiceTest.java index 7000c86..29b4294 100644 --- a/src/test/java/com/cadlabel/service/impl/SolidWorksJsonDatasetImportServiceTest.java +++ b/src/test/java/com/cadlabel/service/impl/SolidWorksJsonDatasetImportServiceTest.java @@ -104,8 +104,8 @@ class SolidWorksJsonDatasetImportServiceTest { return 1; }).when(cadModelMapper).insert(any(CadModel.class)); - JsonDatasetImportService.ImportSummary summary = service.replaceDataset( - sourceDirectory, artifactsDirectory, "dataset-test", true, 7L); + JsonDatasetImportService.ImportSummary summary = service.importDataset( + sourceDirectory, artifactsDirectory, "dataset-test", true, true, 7L); assertThat(summary).isEqualTo(new JsonDatasetImportService.ImportSummary(1, 2)); ArgumentCaptor modelCaptor = ArgumentCaptor.forClass(CadModel.class); @@ -115,6 +115,7 @@ class SolidWorksJsonDatasetImportServiceTest { assertThat(modelCaptor.getValue().getFeatureTreeJsonPath()).isEqualTo("dataset-test/demo/source.feature-tree.json"); assertThat(modelCaptor.getValue().getSourceSwPath()).isEqualTo("dataset-test/demo/source-json-rebuilt.step"); assertThat(modelCaptor.getValue().getViewPath()).isEqualTo("dataset-test/demo/scene.glb"); + assertThat(modelCaptor.getValue().getThumbnailPath()).isEqualTo("dataset-test/demo/thumbnail.svg"); ArgumentCaptor historyCaptor = ArgumentCaptor.forClass(CadModelHistory.class); verify(cadModelHistoryMapper, org.mockito.Mockito.times(2)).insert(historyCaptor.capture());