diff --git a/README.md b/README.md index 67e7aca..028df54 100644 --- a/README.md +++ b/README.md @@ -40,3 +40,19 @@ export APP_COS_PUBLIC_BASE_URL='' - `POST /api/cos/objects` accepts multipart file upload and stores the file through the backend. Both endpoints return the standard `ApiResponse` shape. + +## Replace The Annotation Dataset + +The import runner accepts only `solidworks.rebuild_extract.v1` JSON files. It clears existing +models, histories, annotations, structures, functions, and their relations before importing the +new dataset. It is disabled by default and should run as a non-web process: + +```bash +APP_JSON_DATASET_IMPORT_ENABLED=true \ +APP_JSON_DATASET_IMPORT_SOURCE_DIRECTORY='/absolute/path/to/output_json 2' \ +APP_JSON_DATASET_IMPORT_CATEGORY_ID=1 \ +mvn spring-boot:run -Dspring-boot.run.arguments=--spring.main.web-application-type=none +``` + +The source files contain feature trees only. The importer creates annotation histories but does +not fabricate STEP, GLB, or topology assets. diff --git a/docs/cad-model-annotation-api.md b/docs/cad-model-annotation-api.md index c822884..a147efa 100644 --- a/docs/cad-model-annotation-api.md +++ b/docs/cad-model-annotation-api.md @@ -186,7 +186,8 @@ `GET /api/cad-models/{modelId}/histories` -按 `historyIndex` 升序、ID 升序返回。 +按 `historyIndex` 升序、ID 升序返回。若步骤已有自身标注,响应中的 `annotation` +会携带建模说明、补充描述和备注;未标注的步骤不返回该字段。 #### Path 参数 @@ -214,6 +215,16 @@ "glbPath": "uploads/history/101.glb", "topologyPath": "uploads/history/101.topology.json", "convertStatus": "success", + "annotation": { + "id": 1001, + "modelId": 1, + "historyId": 101, + "modelingDescription": "通过草图轮廓拉伸形成主体", + "supplementDescription": "该步骤决定主体厚度", + "remark": "已复核", + "createdAt": "2026-07-08 16:21:00", + "updatedAt": "2026-07-08 16:22:00" + }, "createdAt": "2026-07-08 16:20:00", "updatedAt": "2026-07-08 16:20:00" } diff --git a/scripts/annotate-all-models.mjs b/scripts/annotate-all-models.mjs new file mode 100644 index 0000000..00c520a --- /dev/null +++ b/scripts/annotate-all-models.mjs @@ -0,0 +1,453 @@ +import fs from 'node:fs'; +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 APPLY = process.argv.includes('--apply'); +const FORCE = process.argv.includes('--force'); +const onlyModel = valueAfter('--model'); +const MARKER = '[AI批量分析:v1]'; + +const CLASS_DEFINITIONS = [ + { + ids: ['061735', '144358', '240860'], + label: '圆形平板法兰盘', + scene: '通用圆形安装连接面,适用于螺栓固定、端面贴合和中心定位', + primaryFunction: '圆形法兰安装连接' + }, + { + ids: ['020543', '080683', '202578', '029447'], + label: '带凸台轴颈法兰', + scene: '轴、套筒或设备端面的定位连接,利用凸台或轴颈建立同轴约束', + primaryFunction: '法兰连接与同轴导向' + }, + { + ids: ['080632', '086232', '191643'], + label: '多孔工业圆法兰', + scene: '多螺栓圆周连接、载荷分散和精密安装定位', + primaryFunction: '多螺栓法兰连接' + }, + { + ids: ['001563', '002935'], + label: '筒体接头转接法兰', + scene: '筒体、轴套或管状接口之间的同轴转接与支承', + primaryFunction: '筒体接口转接与支承' + }, + { + ids: ['001942', '218172'], + label: '薄板环形/异形盲板', + scene: '薄板封盖、垫片式隔离或异形基板固定', + primaryFunction: '封盖隔离与异形安装' + }, + { + ids: ['006947', '016033', '108524'], + label: '矩形/方形安装法兰板', + scene: '设备矩形安装面、轴孔定位和底板连接', + primaryFunction: '设备安装与定位' + }, + { + ids: ['026613', '177126', '214404', '221476'], + label: '支架底座/耳板法兰', + scene: '支架、底座、耳板或设备主体的承载安装连接', + primaryFunction: '支架承载与底座固定' + }, + { + ids: ['052729', '054798', '122225'], + label: '轮毂衬套/厚环', + scene: '轴孔导向、间隔定位、轮毂或厚壁环形连接', + primaryFunction: '轴孔导向与间隔定位' + }, + { + ids: ['015133'], + label: '复杂多工位安装座', + scene: '多凸台、多孔位机械组件的承载、定位与多方向装配', + primaryFunction: '多工位承载与装配连接' + }, + { + ids: ['083235'], + label: '多层凸台圆形安装座', + scene: '圆形基座、轴颈和多方向接口的同轴支承与紧固', + primaryFunction: '圆形基座支承与多接口连接' + } +]; + +const GROUP_DEFINITIONS = { + base: { + name: '主体承载基体', + type: '主体结构', + purpose: '建立零件的基本实体、总体包络和主要载荷传递路径。', + reason: '首个加料特征决定零件的基础形态,也是后续孔、槽、凸台和边缘特征的宿主。' + }, + boss: { + name: '凸台、轴颈与加强结构', + type: '附加实体结构', + purpose: '形成局部凸台、轴颈、台阶或加强区域,提供定位、支承或局部连接界面。', + reason: '分层加料能够在控制总体重量的同时,把材料布置到定位和受力需要的位置。' + }, + cut: { + name: '孔槽、内腔与避让结构', + type: '切除结构', + purpose: '形成通孔、内腔、槽口、轮廓减重或装配避让空间。', + reason: '切除特征用于建立配合空间、功能通道和局部间隙,并避免与配合件干涉。' + }, + hole: { + name: '安装与定位孔组', + type: '孔系结构', + purpose: '提供螺栓、销或轴类零件的安装、定位和穿过接口。', + reason: '标准孔特征便于采用通用紧固件和加工刀具,并提高装配互换性。' + }, + thread: { + name: '螺纹连接孔组', + type: '螺纹接口结构', + purpose: '提供内螺纹紧固接口,实现可拆卸连接和轴向夹紧。', + reason: '孔向导定义的标准螺纹便于紧固件选型,并使连接规格可追溯。' + }, + counterbore: { + name: '沉头紧固孔组', + type: '沉头孔结构', + purpose: '容纳螺钉杆部及头部,使紧固件可靠定位并控制头部突出量。', + reason: '沉头或柱形沉头可提供稳定支承面,减少螺钉头与相邻零件的干涉。' + }, + pattern: { + name: '阵列重复结构', + type: '规则阵列结构', + purpose: '按规则间距复制孔、槽或凸台,使重复结构保持一致。', + reason: '参数化阵列保证重复特征的间距、数量和方向一致,便于均匀载荷或多工位装配。' + }, + edge: { + name: '边缘过渡与装配处理', + type: '边缘处理结构', + purpose: '通过倒角或圆角改善装配导入、去除锐边并降低局部应力集中。', + reason: '受控边缘过渡可提升装配性、加工安全性和边缘耐久性。' + }, + auxiliary: { + name: '辅助几何结构', + type: '辅助结构', + purpose: '承载不能归入主体、孔槽或边缘处理的其他有效建模结果。', + reason: '保留辅助步骤与结构的对应关系,避免建模历史在语义层丢失。' + } +}; + +function valueAfter(flag) { + const index = process.argv.indexOf(flag); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +async function api(relativePath, init) { + const response = await fetch(`${API_BASE}${relativePath}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...(init?.headers ?? {}) + }, + signal: AbortSignal.timeout(30_000) + }); + const payload = await response.json(); + if (!response.ok || payload.code !== 'OK') { + throw new Error(`${init?.method ?? 'GET'} ${relativePath}: ${payload.message ?? response.status}`); + } + return payload.data; +} + +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)) ?? { + label: '机械安装连接件', + scene: '机械组件的承载、定位和紧固连接', + primaryFunction: '机械承载与装配连接' + }; +} + +function sourceFor(modelName) { + const filename = path.join(SOURCE_ROOT, `${modelName}.solidworks_rebuild_extract.json`); + if (!fs.existsSync(filename)) { + throw new Error(`缺少源 JSON: ${filename}`); + } + return JSON.parse(fs.readFileSync(filename, 'utf8')); +} + +function boundingBox(source) { + const box = source.validation_hints?.part_box_m; + if (!Array.isArray(box) || box.length !== 6) return null; + const dimensions = [box[3] - box[0], box[4] - box[1], box[5] - box[2]].map((value) => value * 1000); + return dimensions.map(formatNumber); +} + +function formatNumber(value) { + if (!Number.isFinite(value)) return String(value); + return Number(value.toFixed(Math.abs(value) < 10 ? 3 : 2)).toString(); +} + +function cleanDimensionName(name, modelName) { + return String(name ?? '') + .replace(new RegExp(`@${modelName}\\.Part$`, 'i'), '') + .replace(/@[^@]+$/, ''); +} + +function dimensionEvidence(feature, step, modelName) { + const dimensions = feature?.dimensions ?? []; + const evidence = []; + for (const item of dimensions) { + const value = Number(item.value); + if (!Number.isFinite(value)) continue; + const name = cleanDimensionName(item.name, modelName); + const angleDimension = /角度|angle/i.test(item.name) + || (step.type.includes('revolve') && item.name?.startsWith(`D1@${step.name}`)) + || (step.type === 'chamfer' && item.name?.startsWith(`D2@${step.name}`)); + if (angleDimension && Math.abs(value) > 20) { + evidence.push(`${name}=${formatNumber(value / 1000 * 180 / Math.PI)}°`); + continue; + } + if (step.type.includes('pattern') && Math.abs(value) >= 1000 && Math.abs(value) <= 20_000) { + evidence.push(`${name}=${formatNumber(value / 1000)}(源阵列计数编码)`); + continue; + } + if (Math.abs(value) > 1000) continue; + evidence.push(`${name}=${formatNumber(value)} mm`); + } + return [...new Set(evidence)].slice(0, 10); +} + +function stepGroup(step) { + const type = String(step.type ?? step.operationType ?? '').toLowerCase(); + const name = String(step.name ?? step.historyName ?? '').toLowerCase(); + if (type === 'chamfer' || type === 'fillet') return 'edge'; + if (type.includes('pattern')) return 'pattern'; + if (type === 'hole') { + if (/螺纹|thread|tapped/.test(name)) return 'thread'; + if (/沉头|凹头|counter|螺钉/.test(name)) return 'counterbore'; + return 'hole'; + } + if (type.includes('cut')) return 'cut'; + if (type.includes('add')) return (step.order ?? step.historyIndex) === 1 ? 'base' : 'boss'; + return 'auxiliary'; +} + +function operationIntent(step) { + const type = step.type.toLowerCase(); + if (type === 'extrude_add') return `通过“${step.name}”执行加料拉伸,形成或扩展实体轮廓。`; + if (type === 'revolve_add') return `通过“${step.name}”将截面绕中心轴旋转成形,建立回转主体、轴颈或环形台阶。`; + if (type === 'extrude_cut') return `通过“${step.name}”执行拉伸切除,形成孔、槽、内腔或装配避让区域。`; + if (type === 'revolve_cut') return `通过“${step.name}”执行旋转切除,形成同轴内孔、环槽、锥面或回转台阶。`; + if (type === 'hole') return `通过孔向导特征“${step.name}”建立标准化孔接口,服务于紧固、定位或穿轴装配。`; + if (type === 'linear_pattern') return `通过“${step.name}”按设定方向、间距和数量复制种子特征,形成规则重复结构。`; + if (type === 'chamfer') return `通过“${step.name}”处理选定锐边,形成装配导入面并去除尖锐边缘。`; + if (type === 'fillet') return `通过“${step.name}”对选定边进行圆角过渡,改善应力分布和边缘触感。`; + return `执行建模步骤“${step.name}”,在前序几何基础上形成该步骤对应的有效几何。`; +} + +function stepAnnotation(modelName, step, feature) { + const evidence = dimensionEvidence(feature, step, modelName); + const faceCount = feature?.owned_faces?.length ?? 0; + const group = GROUP_DEFINITIONS[stepGroup(step)]; + return { + modelingDescription: `${operationIntent(step)}该步骤在本零件中归入“${group.name}”。`, + supplementDescription: [ + `重建顺序第 ${step.order} 步,绑定源特征 ${step.sourceFeatureId}。`, + evidence.length ? `关键参数证据:${evidence.join(';')}。` : '源特征未提供可直接解释的标量尺寸,语义依据操作类型与几何结果确定。', + faceCount ? `源特征记录 ${faceCount} 个归属面,可用于后续拓扑复核。` : '源特征未记录独占面,需结合前后步骤或最终拓扑复核。' + ].join(''), + remark: `${MARKER} 参数来自 SolidWorks 提取结果;角度、阵列计数及孔向导深度可能采用插件编码或构造深度,不应在未核对原模型前作为公差或有效加工深度。` + }; +} + +function groupSteps(histories) { + const result = new Map(); + for (const step of histories) { + const key = stepGroup(step); + if (!result.has(key)) result.set(key, []); + result.get(key).push(step); + } + return result; +} + +function structurePayload(groupKey, steps, classInfo) { + const definition = GROUP_DEFINITIONS[groupKey]; + const names = steps.map((step) => `${step.order}.${step.name}`).join('、'); + return { + structureName: definition.name, + structureType: definition.type, + purpose: definition.purpose, + solution: `由建模步骤 ${names} 共同实现;具体尺寸和几何范围见各步骤标注。`, + reason: `${definition.reason} 该判断同时结合“${classInfo.label}”的典型用途。`, + remark: `${MARKER} 结构由实际建模步骤按几何作用归组;若后续获得装配图,应复核其主/辅助结构属性。`, + historyIds: steps.map((step) => 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 edgeStructures = structures.filter((item) => item.groupKey === 'edge'); + const payloads = [{ + functionName: classInfo.primaryFunction, + functionType: '主功能', + goalDescription: `使该${classInfo.label}在“${classInfo.scene}”场景下完成主体承载、几何定位和连接载荷传递。`, + remark: `${MARKER} 主功能依据数据集分类、零件包络和完整建模历史综合推定,额定载荷与配合关系需结合装配数据确认。`, + structureIds: allIds + }]; + if (interfaceStructures.length) { + payloads.push({ + functionName: '紧固、定位与装配适配', + functionType: '装配功能', + goalDescription: '利用孔、槽、螺纹、沉头或阵列接口约束配合件位置,容纳标准紧固件并提供必要装配间隙。', + remark: `${MARKER} 具体紧固件规格、孔公差、配合等级和主辅接口关系需结合装配对象复核。`, + structureIds: interfaceStructures.map((item) => item.id) + }); + } + if (edgeStructures.length) { + payloads.push({ + functionName: '装配导入与边缘耐久', + functionType: '辅助功能', + goalDescription: '通过圆角和倒角减少锐边干涉,改善装配导入,并降低局部磕碰或应力集中风险。', + remark: `${MARKER} 边缘处理功能依据特征类型确定;是否属于关键疲劳区域需结合载荷工况判断。`, + structureIds: edgeStructures.map((item) => item.id) + }); + } + return payloads; +} + +function modelPayload(model, source, histories, classInfo) { + const box = boundingBox(source); + const boxText = box ? `零件几何包络约为 ${box.join(' × ')} mm。` : '源数据未提供可解析的零件包络。'; + 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 manufacturing = [ + hasRevolve ? '回转表面车削或车铣复合加工' : 'CNC 铣削或轮廓加工', + hasHole ? '钻孔/扩孔/攻丝等孔系加工' : null, + histories.some((step) => step.type === 'chamfer' || step.type === 'fillet') ? '边缘倒角/圆角处理' : null + ].filter(Boolean).join(';'); + return { + applicationScenario: classInfo.scene, + material: 'SolidWorks 源模型未指定材料;数据集建议铝合金或钢制测试件,需按载荷与环境确认', + manufacturingMethod: `${manufacturing}(依据建模特征推定,待工艺确认)`, + userRequirement: `设计一件${classInfo.label},用于${classInfo.scene}。${boxText}需保留建模历史中定义的主体、孔槽、连接接口和边缘处理,并保证各接口的相对位置与重建顺序一致。`, + designDescription: `${model.modelName} 被识别为${classInfo.label}。${boxText}有效建模序列为:${sequence}。主体加料特征建立承载包络,切除与孔向导特征形成装配接口,阵列保证重复结构一致,倒角/圆角改善边缘装配性。材料、载荷、配合公差和制造基准未由源 JSON 完整给出,相关结论均需工程复核。` + }; +} + +function hasUnmanagedAnnotation(exportData) { + const annotations = exportData.featureIndex ?? []; + const existingNotes = annotations.map((item) => item.annotation?.notes).filter(Boolean); + const structureNotes = (exportData.structures ?? []).map((item) => item.notes).filter(Boolean); + const functionNotes = (exportData.functions ?? []).map((item) => item.notes).filter(Boolean); + const notes = [...existingNotes, ...structureNotes, ...functionNotes]; + return notes.some((note) => !String(note).startsWith(MARKER)); +} + +async function ensureStructure(modelId, payload, existingByName) { + const existing = existingByName.get(payload.structureName); + if (!APPLY) return { id: `dry:${payload.structureName}`, groupKey: null }; + const saved = existing + ? await post(`/cad-models/${modelId}/structures/${existing.id}`, payload) + : await post(`/cad-models/${modelId}/structures`, payload); + existingByName.set(saved.structureName, saved); + return saved; +} + +async function ensureFunction(modelId, payload, existingByName) { + const existing = existingByName.get(payload.functionName); + if (!APPLY) return { id: `dry:${payload.functionName}` }; + const saved = existing + ? await post(`/cad-models/${modelId}/functions/${existing.id}`, payload) + : await post(`/cad-models/${modelId}/functions`, payload); + existingByName.set(saved.functionName, saved); + return saved; +} + +async function annotateModel(model) { + const [histories, exportData] = await Promise.all([ + api(`/cad-models/${model.id}/histories`), + api(`/cad-models/${model.id}/export/annotation`) + ]); + if (!histories.length) return { model: model.modelName, status: 'skipped:no_histories' }; + if (!FORCE && hasUnmanagedAnnotation(exportData)) { + return { model: model.modelName, status: 'skipped:existing_manual_annotation' }; + } + + const source = sourceFor(model.modelName); + const classInfo = classFor(model.modelName); + const featuresById = new Map((source.features ?? []).map((feature) => [feature.id, feature])); + const normalizedHistories = histories.map((step) => ({ + historyId: step.id, + sourceFeatureId: step.sourceFeatureId, + order: step.historyIndex, + name: step.historyName, + type: step.operationType + })); + const groups = groupSteps(normalizedHistories); + const modelUpdate = modelPayload(model, source, normalizedHistories, classInfo); + + 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 + }; + } + + await post(`/cad-models/${model.id}`, modelUpdate); + for (const step of normalizedHistories) { + await post( + `/cad-models/${model.id}/histories/${step.historyId}/annotation`, + stepAnnotation(model.modelName, step, featuresById.get(step.sourceFeatureId)) + ); + } + + const existingStructures = new Map((exportData.structures ?? []).map((item) => [item.name, { id: item.id }])); + const structures = []; + for (const [groupKey, steps] of groups) { + const saved = await ensureStructure(model.id, structurePayload(groupKey, steps, classInfo), existingStructures); + structures.push({ ...saved, groupKey }); + } + + const existingFunctions = new Map((exportData.functions ?? []).map((item) => [item.name, { id: item.id }])); + for (const payload of functionPayloads(classInfo, structures)) { + await ensureFunction(model.id, payload, existingFunctions); + } + + 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}`); + } + return { + model: model.modelName, + status: 'applied', + histories: histories.length, + structures: verified.structures.length, + functions: verified.functions.length, + unassigned: verified.unassignedHistoryIds.length + }; +} + +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)); + if (onlyModel) models = models.filter((model) => model.modelName === onlyModel); + if (!models.length) throw new Error(onlyModel ? `未找到模型 ${onlyModel}` : '数据库中没有模型'); + + console.log(`${APPLY ? 'APPLY' : 'DRY-RUN'} models=${models.length} sourceRoot=${SOURCE_ROOT}`); + const results = []; + for (const model of models) { + const result = await annotateModel(model); + results.push(result); + console.log(JSON.stringify(result)); + } + const applied = results.filter((item) => item.status === 'applied').length; + const dryRun = results.filter((item) => item.status === 'dry-run').length; + const skipped = results.length - applied - dryRun; + console.log(JSON.stringify({ total: results.length, applied, dryRun, skipped })); +} + +main().catch((error) => { + console.error(error.stack ?? error.message); + process.exitCode = 1; +}); diff --git a/src/main/java/com/cadlabel/config/CosProperties.java b/src/main/java/com/cadlabel/config/CosProperties.java index 63f851d..daf8f2d 100644 --- a/src/main/java/com/cadlabel/config/CosProperties.java +++ b/src/main/java/com/cadlabel/config/CosProperties.java @@ -7,6 +7,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; public record CosProperties( String secretId, String secretKey, + String sessionToken, String bucket, String region, String publicBaseUrl, diff --git a/src/main/java/com/cadlabel/config/JsonDatasetImportProperties.java b/src/main/java/com/cadlabel/config/JsonDatasetImportProperties.java new file mode 100644 index 0000000..a37e56f --- /dev/null +++ b/src/main/java/com/cadlabel/config/JsonDatasetImportProperties.java @@ -0,0 +1,32 @@ +package com.cadlabel.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 本地 SolidWorks 特征树 JSON 数据集导入配置。 + */ +@Getter +@Setter +@ConfigurationProperties(prefix = "app.json-dataset-import") +public class JsonDatasetImportProperties { + + /** 仅在显式启用时执行全量替换,避免服务启动时误清数据。 */ + private boolean enabled; + + /** JSON 数据集目录。 */ + private String sourceDirectory; + + /** Node 后端完整转换产物根目录。 */ + private String artifactsDirectory; + + /** COS 中的数据集对象前缀。 */ + private String cosPrefix = "datasets/solidworks-rebuild-v1"; + + /** 是否由 Java 导入器上传对象;外部已上传时关闭。 */ + private boolean uploadEnabled = true; + + /** 导入模型归属的分类 ID。 */ + private long categoryId = 1L; +} diff --git a/src/main/java/com/cadlabel/controller/CadAnnotationController.java b/src/main/java/com/cadlabel/controller/CadAnnotationController.java index d9fa94f..ea03d15 100644 --- a/src/main/java/com/cadlabel/controller/CadAnnotationController.java +++ b/src/main/java/com/cadlabel/controller/CadAnnotationController.java @@ -2,6 +2,7 @@ package com.cadlabel.controller; import com.cadlabel.common.api.ApiResponse; import com.cadlabel.dto.cad.annotation.CadFunctionResponse; +import com.cadlabel.dto.cad.annotation.CadAnnotationExportResponse; import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse; import com.cadlabel.dto.cad.annotation.CadStructureResponse; import com.cadlabel.dto.cad.annotation.CreateCadFunctionRequest; @@ -31,6 +32,14 @@ public class CadAnnotationController { private final CadAnnotationService cadAnnotationService; + /** + * 导出当前功能、结构和建模历史步骤标注,供 AI 与原始特征树 JSON 配对使用。 + */ + @GetMapping("/export/annotation") + public ApiResponse exportCurrentAnnotation(@PathVariable @Positive Long modelId) { + return ApiResponse.success(cadAnnotationService.exportCurrentAnnotation(modelId)); + } + /** * 保存建模历史步骤标注。 * diff --git a/src/main/java/com/cadlabel/dto/cad/CadModelHistoryResponse.java b/src/main/java/com/cadlabel/dto/cad/CadModelHistoryResponse.java index 58677e1..5e88a4e 100644 --- a/src/main/java/com/cadlabel/dto/cad/CadModelHistoryResponse.java +++ b/src/main/java/com/cadlabel/dto/cad/CadModelHistoryResponse.java @@ -1,5 +1,6 @@ package com.cadlabel.dto.cad; +import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse; import java.time.LocalDateTime; public record CadModelHistoryResponse( @@ -15,6 +16,7 @@ public record CadModelHistoryResponse( String convertStatus, String errorMessage, LocalDateTime createdAt, - LocalDateTime updatedAt + LocalDateTime updatedAt, + CadModelHistoryAnnotationResponse annotation ) { } diff --git a/src/main/java/com/cadlabel/dto/cad/annotation/CadAnnotationExportResponse.java b/src/main/java/com/cadlabel/dto/cad/annotation/CadAnnotationExportResponse.java new file mode 100644 index 0000000..37d1336 --- /dev/null +++ b/src/main/java/com/cadlabel/dto/cad/annotation/CadAnnotationExportResponse.java @@ -0,0 +1,114 @@ +package com.cadlabel.dto.cad.annotation; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 当前 CAD 语义标注导出包。 + * + *

本文件需与 {@code source.featureTreeJsonPath} 指向的原始特征树 JSON 一起使用。 + * 每个 {@code featureIndex} 条目通过 {@code sourceFeature.sourceFeatureId} 绑定原始特征。

+ */ +public record CadAnnotationExportResponse( + String schemaVersion, + LocalDateTime exportedAt, + String purpose, + Model model, + Source source, + Requirement requirement, + String designSummary, + List functions, + List structures, + List featureIndex, + List unassignedHistoryIds, + List relations +) { + + public record Model( + Long id, + String name, + Long categoryId, + String applicationScenario, + String material, + String manufacturingMethod, + String unit + ) { + } + + public record Source( + String sourceSwFilename, + String sourceSwPath, + String featureTreeJsonPath, + String bindingRule + ) { + } + + public record Requirement(String text) { + } + + public record Function( + Long id, + String name, + String type, + String goalDescription, + String notes, + List structureIds + ) { + } + + public record Structure( + Long id, + String name, + String type, + String purpose, + String solution, + String reason, + String notes, + List functionIds, + List featureSteps + ) { + } + + public record FeatureStep( + String relationId, + Integer orderInStructure, + Feature feature + ) { + } + + public record Feature( + Long historyId, + String name, + String operationType, + Integer order, + SourceFeature sourceFeature, + Annotation annotation + ) { + } + + public record SourceFeature( + String sourceFeatureId, + String bindingStatus, + String stepPath, + String glbPath, + String topologyPath + ) { + } + + public record Annotation( + String modelingIntent, + String description, + String notes, + LocalDateTime updatedAt + ) { + } + + public record Relation( + String id, + String type, + String from, + String to, + Integer order + ) { + } +} diff --git a/src/main/java/com/cadlabel/importer/JsonDatasetImportRunner.java b/src/main/java/com/cadlabel/importer/JsonDatasetImportRunner.java new file mode 100644 index 0000000..5b5b954 --- /dev/null +++ b/src/main/java/com/cadlabel/importer/JsonDatasetImportRunner.java @@ -0,0 +1,48 @@ +package com.cadlabel.importer; + +import com.cadlabel.config.JsonDatasetImportProperties; +import com.cadlabel.service.JsonDatasetImportService; +import java.nio.file.Path; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * 显式启用后,全量替换标注后台中的模型数据。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "app.json-dataset-import", name = "enabled", havingValue = "true") +public class JsonDatasetImportRunner implements ApplicationRunner { + + private final JsonDatasetImportProperties properties; + private final JsonDatasetImportService jsonDatasetImportService; + + @Override + public void run(ApplicationArguments args) { + if (!StringUtils.hasText(properties.getSourceDirectory())) { + throw new IllegalStateException("启用 JSON 数据集导入时必须配置 APP_JSON_DATASET_IMPORT_SOURCE_DIRECTORY"); + } + if (!StringUtils.hasText(properties.getArtifactsDirectory())) { + throw new IllegalStateException("启用 JSON 数据集导入时必须配置 APP_JSON_DATASET_IMPORT_ARTIFACTS_DIRECTORY"); + } + 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( + sourceDirectory, + artifactsDirectory, + properties.getCosPrefix(), + properties.isUploadEnabled(), + properties.getCategoryId() + ); + log.info("CAD JSON dataset import finished. modelCount={}, historyCount={}", + summary.modelCount(), summary.historyCount()); + } +} diff --git a/src/main/java/com/cadlabel/service/CadAnnotationService.java b/src/main/java/com/cadlabel/service/CadAnnotationService.java index 38c92a4..7147d51 100644 --- a/src/main/java/com/cadlabel/service/CadAnnotationService.java +++ b/src/main/java/com/cadlabel/service/CadAnnotationService.java @@ -1,6 +1,7 @@ package com.cadlabel.service; import com.cadlabel.dto.cad.annotation.CadFunctionResponse; +import com.cadlabel.dto.cad.annotation.CadAnnotationExportResponse; import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse; import com.cadlabel.dto.cad.annotation.CadStructureResponse; import com.cadlabel.dto.cad.annotation.CreateCadFunctionRequest; @@ -12,6 +13,11 @@ import java.util.List; public interface CadAnnotationService { + /** + * 导出模型当前的功能、结构、建模历史和步骤语义标注。 + */ + CadAnnotationExportResponse exportCurrentAnnotation(Long modelId); + /** * 保存建模历史步骤标注。 * diff --git a/src/main/java/com/cadlabel/service/JsonDatasetImportService.java b/src/main/java/com/cadlabel/service/JsonDatasetImportService.java new file mode 100644 index 0000000..2090b68 --- /dev/null +++ b/src/main/java/com/cadlabel/service/JsonDatasetImportService.java @@ -0,0 +1,15 @@ +package com.cadlabel.service; + +import java.nio.file.Path; + +/** + * 将 SolidWorks 特征树 JSON 数据集替换导入标注后台。 + */ +public interface JsonDatasetImportService { + + ImportSummary replaceDataset(Path sourceDirectory, Path artifactsDirectory, String cosPrefix, + boolean uploadEnabled, long categoryId); + + record ImportSummary(int modelCount, int historyCount) { + } +} diff --git a/src/main/java/com/cadlabel/service/client/TencentCosObjectStorageClient.java b/src/main/java/com/cadlabel/service/client/TencentCosObjectStorageClient.java index 7374039..6371155 100644 --- a/src/main/java/com/cadlabel/service/client/TencentCosObjectStorageClient.java +++ b/src/main/java/com/cadlabel/service/client/TencentCosObjectStorageClient.java @@ -5,6 +5,8 @@ import com.cadlabel.config.CosProperties; import com.qcloud.cos.COSClient; import com.qcloud.cos.ClientConfig; import com.qcloud.cos.auth.BasicCOSCredentials; +import com.qcloud.cos.auth.BasicSessionCredentials; +import com.qcloud.cos.auth.COSCredentials; import com.qcloud.cos.exception.CosClientException; import com.qcloud.cos.model.ObjectMetadata; import com.qcloud.cos.model.PutObjectRequest; @@ -101,8 +103,12 @@ public class TencentCosObjectStorageClient implements CosObjectStorageClient, Di private static COSClient createCosClient(CosProperties cosProperties) { String region = StringUtils.hasText(cosProperties.region()) ? cosProperties.region().trim() : "ap-beijing"; + COSCredentials credentials = StringUtils.hasText(cosProperties.sessionToken()) + ? new BasicSessionCredentials(defaultText(cosProperties.secretId()), defaultText(cosProperties.secretKey()), + cosProperties.sessionToken().trim()) + : new BasicCOSCredentials(defaultText(cosProperties.secretId()), defaultText(cosProperties.secretKey())); return new COSClient( - new BasicCOSCredentials(defaultText(cosProperties.secretId()), defaultText(cosProperties.secretKey())), + credentials, new ClientConfig(new Region(region)) ); } diff --git a/src/main/java/com/cadlabel/service/impl/CadAnnotationServiceImpl.java b/src/main/java/com/cadlabel/service/impl/CadAnnotationServiceImpl.java index 9540392..38a943a 100644 --- a/src/main/java/com/cadlabel/service/impl/CadAnnotationServiceImpl.java +++ b/src/main/java/com/cadlabel/service/impl/CadAnnotationServiceImpl.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.cadlabel.common.api.ApiCode; import com.cadlabel.common.exception.BizException; import com.cadlabel.dto.cad.annotation.CadFunctionResponse; +import com.cadlabel.dto.cad.annotation.CadAnnotationExportResponse; import com.cadlabel.dto.cad.annotation.CadFunctionStructureItemResponse; import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse; import com.cadlabel.dto.cad.annotation.CadStructureHistoryItemResponse; @@ -30,6 +31,7 @@ import com.cadlabel.mapper.CadStructureMapper; import com.cadlabel.service.CadAnnotationService; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -60,6 +62,157 @@ public class CadAnnotationServiceImpl implements CadAnnotationService { private final CadFunctionMapper functionMapper; private final CadFunctionStructureRelMapper functionStructureRelMapper; + /** + * 导出当前模型的完整语义标注视图。 + * + *

原始 JSON 不在此接口中复制;每个历史步骤保留 sourceFeatureId, + * 训练程序可据此在 featureTreeJsonPath 指向的原始 JSON 中定位对应特征。

+ */ + @Override + @Transactional(readOnly = true) + public CadAnnotationExportResponse exportCurrentAnnotation(Long modelId) { + CadModel model = requireModel(modelId); + List histories = cadModelHistoryMapper.selectList(Wrappers.lambdaQuery(CadModelHistory.class) + .eq(CadModelHistory::getModelId, modelId) + .orderByAsc(CadModelHistory::getHistoryIndex) + .orderByAsc(CadModelHistory::getId)); + Map annotationsByHistoryId = annotationMapper.selectList( + Wrappers.lambdaQuery(CadModelHistoryAnnotation.class) + .eq(CadModelHistoryAnnotation::getModelId, modelId) + ) + .stream() + .collect(Collectors.toMap(CadModelHistoryAnnotation::getHistoryId, annotation -> annotation)); + + List features = histories.stream() + .map(history -> toExportFeature(history, annotationsByHistoryId.get(history.getId()))) + .toList(); + Map featureByHistoryId = features.stream() + .collect(Collectors.toMap(CadAnnotationExportResponse.Feature::historyId, feature -> feature)); + + List structureResponses = listStructures(modelId); + List functionResponses = listFunctions(modelId); + Map> functionIdsByStructureId = new HashMap<>(); + List relations = new ArrayList<>(); + + for (CadFunctionResponse function : functionResponses) { + for (CadFunctionStructureItemResponse item : function.structureItems()) { + functionIdsByStructureId.computeIfAbsent(item.structureId(), ignored -> new ArrayList<>()).add(function.id()); + relations.add(new CadAnnotationExportResponse.Relation( + "function_structure:" + item.relationId(), + "realized_by", + "function:" + function.id(), + "structure:" + item.structureId(), + item.sortOrder() + )); + } + } + + Set assignedHistoryIds = new LinkedHashSet<>(); + List structures = new ArrayList<>(); + for (CadStructureResponse structure : structureResponses) { + List featureSteps = new ArrayList<>(); + for (CadStructureHistoryItemResponse item : structure.historyItems()) { + CadAnnotationExportResponse.Feature feature = featureByHistoryId.get(item.historyId()); + if (feature == null) { + continue; + } + assignedHistoryIds.add(item.historyId()); + String relationId = "structure_history:" + item.relationId(); + relations.add(new CadAnnotationExportResponse.Relation( + relationId, + "implemented_by", + "structure:" + structure.id(), + "history:" + item.historyId(), + item.sortOrder() + )); + featureSteps.add(new CadAnnotationExportResponse.FeatureStep(relationId, item.sortOrder(), feature)); + } + structures.add(new CadAnnotationExportResponse.Structure( + structure.id(), + structure.structureName(), + structure.structureType(), + structure.purpose(), + structure.solution(), + structure.reason(), + structure.remark(), + functionIdsByStructureId.getOrDefault(structure.id(), List.of()), + featureSteps + )); + } + + List functions = functionResponses.stream() + .map(function -> new CadAnnotationExportResponse.Function( + function.id(), + function.functionName(), + function.functionType(), + function.goalDescription(), + function.remark(), + function.structureItems().stream() + .map(CadFunctionStructureItemResponse::structureId) + .toList() + )) + .toList(); + List unassignedHistoryIds = histories.stream() + .map(CadModelHistory::getId) + .filter(historyId -> !assignedHistoryIds.contains(historyId)) + .toList(); + + log.info("Exporting current CAD annotation. modelId={}, functionCount={}, structureCount={}, historyCount={}", + modelId, functions.size(), structures.size(), features.size()); + return new CadAnnotationExportResponse( + "cadlabel.current-annotation.v1", + LocalDateTime.now(), + "Current CAD semantic annotation for AI design-intent learning. Pair this export with source.featureTreeJsonPath.", + new CadAnnotationExportResponse.Model( + model.getId(), + model.getModelName(), + model.getCategoryId(), + model.getApplicationScenario(), + model.getMaterial(), + model.getManufacturingMethod(), + model.getUnit() + ), + new CadAnnotationExportResponse.Source( + model.getSourceSwFilename(), + model.getSourceSwPath(), + model.getFeatureTreeJsonPath(), + "Match featureIndex[].sourceFeature.sourceFeatureId to the feature ID in featureTreeJsonPath; historyId is the database record and order is the rebuild sequence." + ), + new CadAnnotationExportResponse.Requirement(model.getUserRequirement()), + model.getDesignDescription(), + functions, + structures, + features, + unassignedHistoryIds, + relations + ); + } + + private CadAnnotationExportResponse.Feature toExportFeature( + CadModelHistory history, + CadModelHistoryAnnotation annotation + ) { + return new CadAnnotationExportResponse.Feature( + history.getId(), + history.getHistoryName(), + history.getOperationType(), + history.getHistoryIndex(), + new CadAnnotationExportResponse.SourceFeature( + history.getSourceFeatureId(), + StringUtils.hasText(history.getSourceFeatureId()) ? "bound" : "missing_source_feature_id", + history.getStepPath(), + history.getGlbPath(), + history.getTopologyPath() + ), + new CadAnnotationExportResponse.Annotation( + annotation == null ? null : annotation.getModelingDescription(), + annotation == null ? null : annotation.getSupplementDescription(), + annotation == null ? null : annotation.getRemark(), + annotation == null ? null : annotation.getUpdatedAt() + ) + ); + } + /** * 保存建模历史步骤标注。 * diff --git a/src/main/java/com/cadlabel/service/impl/CadModelServiceImpl.java b/src/main/java/com/cadlabel/service/impl/CadModelServiceImpl.java index 50670ad..087894b 100644 --- a/src/main/java/com/cadlabel/service/impl/CadModelServiceImpl.java +++ b/src/main/java/com/cadlabel/service/impl/CadModelServiceImpl.java @@ -10,15 +10,21 @@ import com.cadlabel.dto.cad.CadModelHistoryResponse; import com.cadlabel.dto.cad.CadModelListQuery; import com.cadlabel.dto.cad.CadModelResponse; import com.cadlabel.dto.cad.UpdateCadModelRequest; +import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse; import com.cadlabel.entity.CadModel; import com.cadlabel.entity.CadModelHistory; +import com.cadlabel.entity.CadModelHistoryAnnotation; +import com.cadlabel.mapper.CadModelHistoryAnnotationMapper; import com.cadlabel.mapper.CadModelHistoryMapper; import com.cadlabel.mapper.CadModelMapper; import com.cadlabel.service.CadModelService; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; @@ -44,6 +50,7 @@ public class CadModelServiceImpl implements CadModelService { private final CadModelMapper cadModelMapper; private final CadModelHistoryMapper cadModelHistoryMapper; + private final CadModelHistoryAnnotationMapper cadModelHistoryAnnotationMapper; /** * 分页查询 CAD 模型列表。 @@ -117,12 +124,17 @@ public class CadModelServiceImpl implements CadModelService { @Override public List listHistories(Long modelId) { requireModel(modelId); - return cadModelHistoryMapper.selectList(Wrappers.lambdaQuery(CadModelHistory.class) + List histories = cadModelHistoryMapper.selectList(Wrappers.lambdaQuery(CadModelHistory.class) .eq(CadModelHistory::getModelId, modelId) .orderByAsc(CadModelHistory::getHistoryIndex) - .orderByAsc(CadModelHistory::getId)) + .orderByAsc(CadModelHistory::getId)); + Map annotationsByHistoryId = cadModelHistoryAnnotationMapper + .selectList(Wrappers.lambdaQuery(CadModelHistoryAnnotation.class) + .eq(CadModelHistoryAnnotation::getModelId, modelId)) .stream() - .map(this::toHistoryResponse) + .collect(Collectors.toMap(CadModelHistoryAnnotation::getHistoryId, Function.identity())); + return histories.stream() + .map(history -> toHistoryResponse(history, annotationsByHistoryId.get(history.getId()))) .toList(); } @@ -315,7 +327,10 @@ public class CadModelServiceImpl implements CadModelService { ); } - private CadModelHistoryResponse toHistoryResponse(CadModelHistory history) { + private CadModelHistoryResponse toHistoryResponse( + CadModelHistory history, + CadModelHistoryAnnotation annotation + ) { return new CadModelHistoryResponse( history.getId(), history.getModelId(), @@ -329,7 +344,17 @@ public class CadModelServiceImpl implements CadModelService { history.getConvertStatus(), history.getErrorMessage(), history.getCreatedAt(), - history.getUpdatedAt() + history.getUpdatedAt(), + annotation == null ? null : new CadModelHistoryAnnotationResponse( + annotation.getId(), + annotation.getModelId(), + annotation.getHistoryId(), + annotation.getModelingDescription(), + annotation.getSupplementDescription(), + annotation.getRemark(), + annotation.getCreatedAt(), + annotation.getUpdatedAt() + ) ); } } diff --git a/src/main/java/com/cadlabel/service/impl/SolidWorksJsonDatasetImportService.java b/src/main/java/com/cadlabel/service/impl/SolidWorksJsonDatasetImportService.java new file mode 100644 index 0000000..793c854 --- /dev/null +++ b/src/main/java/com/cadlabel/service/impl/SolidWorksJsonDatasetImportService.java @@ -0,0 +1,287 @@ +package com.cadlabel.service.impl; + +import com.cadlabel.entity.CadModel; +import com.cadlabel.entity.CadModelHistory; +import com.cadlabel.mapper.CadModelHistoryMapper; +import com.cadlabel.mapper.CadModelMapper; +import com.cadlabel.service.JsonDatasetImportService; +import com.cadlabel.service.client.CosObjectStorageClient; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +/** 将 Node 后端完成重建的 SolidWorks 数据集上传 COS 并替换远程标注数据。 */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SolidWorksJsonDatasetImportService implements JsonDatasetImportService { + + private static final String SUPPORTED_SCHEMA = "solidworks.rebuild_extract.v1"; + private static final String PREVIEW_SCHEMA = "lingmiao.source-json-step-previews.v1"; + private static final String IMPORTED_STATUS = "success"; + + private final ObjectMapper objectMapper; + private final JdbcTemplate jdbcTemplate; + private final CadModelMapper cadModelMapper; + private final CadModelHistoryMapper cadModelHistoryMapper; + private final CosObjectStorageClient cosObjectStorageClient; + + @Override + @Transactional + public ImportSummary replaceDataset(Path sourceDirectory, Path artifactsDirectory, String cosPrefix, + boolean uploadEnabled, long categoryId) { + if (categoryId <= 0) { + throw new IllegalArgumentException("categoryId 必须为正数"); + } + Map sourceFiles = readSourceFiles(sourceDirectory); + List models = readArtifacts(artifactsDirectory, sourceFiles); + if (models.size() != sourceFiles.size()) { + throw new IllegalArgumentException("JSON 与完整产物数量不一致: json=" + sourceFiles.size() + ", artifacts=" + models.size()); + } + + if (uploadEnabled) { + for (ImportedModel model : models) { + uploadModelAssets(model, cosPrefix); + } + } + + clearExistingAnnotationData(); + int historyCount = 0; + for (ImportedModel importedModel : models) { + CadModel model = createModel(importedModel, cosPrefix, categoryId); + cadModelMapper.insert(model); + if (model.getId() == null) { + throw new IllegalStateException("导入模型后未获得数据库 ID: " + importedModel.partName()); + } + for (ImportedHistory importedHistory : importedModel.histories()) { + cadModelHistoryMapper.insert(createHistory(model.getId(), importedModel, importedHistory, cosPrefix)); + historyCount++; + } + } + log.info("Processed SolidWorks dataset imported. modelCount={}, historyCount={}, artifactsDirectory={}", + models.size(), historyCount, artifactsDirectory.toAbsolutePath().normalize()); + return new ImportSummary(models.size(), historyCount); + } + + private Map readSourceFiles(Path sourceDirectory) { + requireDirectory(sourceDirectory, "JSON 数据集目录"); + Map result = new HashMap<>(); + try (Stream paths = Files.list(sourceDirectory)) { + for (Path file : paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".json")) + .sorted().toList()) { + JsonNode root = readJson(file); + if (!SUPPORTED_SCHEMA.equals(text(root, "schema"))) { + throw new IllegalArgumentException("不支持的 JSON schema: " + file.getFileName()); + } + String partName = requireText(text(root, "part_name"), "JSON 缺少 part_name: " + file.getFileName()); + if (result.put(partName, file) != null) { + throw new IllegalArgumentException("JSON 数据集包含重复 part_name: " + partName); + } + } + } catch (IOException exception) { + throw new IllegalStateException("读取 JSON 数据集目录失败: " + sourceDirectory, exception); + } + return result; + } + + private List readArtifacts(Path artifactsDirectory, Map sourceFiles) { + requireDirectory(artifactsDirectory, "转换产物目录"); + List manifests; + try (Stream paths = Files.walk(artifactsDirectory)) { + manifests = paths.filter(Files::isRegularFile) + .filter(path -> path.endsWith(Path.of("step-previews", "manifest.json"))) + .sorted().toList(); + } catch (IOException exception) { + throw new IllegalStateException("读取转换产物目录失败: " + artifactsDirectory, exception); + } + + List models = new ArrayList<>(); + Set partNames = new HashSet<>(); + for (Path manifestPath : manifests) { + 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)) { + throw new IllegalArgumentException("转换产物无法唯一匹配 JSON: " + partName); + } + requireFiles(versionRoot, "source-json-rebuilt.step", "scene.glb", "topology.json", "thumbnail.svg", + "source-json-feature-geometry-map.json", "source-json-rebuild-report.json"); + JsonNode manifest = readJson(manifestPath); + if (!PREVIEW_SCHEMA.equals(text(manifest, "schemaVersion")) || manifest.path("failedCount").asInt(-1) != 0) { + throw new IllegalArgumentException("步骤预览未全部成功: " + manifestPath); + } + List histories = new ArrayList<>(); + JsonNode steps = manifest.path("steps"); + if (!steps.isArray() || steps.size() != manifest.path("readyCount").asInt(-1)) { + throw new IllegalArgumentException("步骤预览数量不一致: " + manifestPath); + } + for (JsonNode step : steps) { + int order = step.path("order").asInt(); + if (order <= 0 || !"ready".equals(text(step, "status"))) { + throw new IllegalArgumentException("步骤预览状态非法: " + manifestPath); + } + String directory = "%04d".formatted(order); + requireFiles(versionRoot.resolve("step-previews").resolve(directory), "step.step", "scene.glb", "topology.json"); + histories.add(new ImportedHistory( + trimTo(requireText(text(step, "operationKey"), "步骤缺少 operationKey"), 128), + order, + trimTo(requireText(text(step, "name"), "步骤缺少 name"), 255), + trimTo(requireText(text(step, "type"), "步骤缺少 type"), 64), + directory + )); + } + models.add(new ImportedModel(trimTo(partName, 255), versionRoot, histories)); + } + models.sort(Comparator.comparing(ImportedModel::partName)); + return models; + } + + private void uploadModelAssets(ImportedModel model, String cosPrefix) { + List files; + try (Stream paths = Files.walk(model.versionRoot())) { + files = paths.filter(Files::isRegularFile).sorted().toList(); + } catch (IOException exception) { + throw new IllegalStateException("读取模型产物失败: " + model.partName(), exception); + } + for (Path file : files) { + String objectKey = objectKey(cosPrefix, model, model.versionRoot().relativize(file).toString()); + try (InputStream inputStream = Files.newInputStream(file)) { + cosObjectStorageClient.putObject(objectKey, inputStream, Files.size(file), contentType(file)); + } catch (IOException exception) { + throw new IllegalStateException("上传 COS 失败: " + file, exception); + } + } + log.info("Uploaded model assets to COS. partName={}, fileCount={}", model.partName(), files.size()); + } + + private void clearExistingAnnotationData() { + int functionRelations = jdbcTemplate.update("DELETE FROM cad_function_structure_rel"); + int structureRelations = jdbcTemplate.update("DELETE FROM cad_structure_history_rel"); + int historyAnnotations = jdbcTemplate.update("DELETE FROM cad_model_history_annotation"); + int functions = jdbcTemplate.update("DELETE FROM cad_function"); + int structures = jdbcTemplate.update("DELETE FROM cad_structure"); + int histories = jdbcTemplate.update("DELETE FROM cad_model_history"); + int models = jdbcTemplate.update("DELETE FROM cad_model"); + log.warn("Cleared CAD annotation data. models={}, histories={}, annotations={}, structures={}, functions={}, structureRelations={}, functionRelations={}", + models, histories, historyAnnotations, structures, functions, structureRelations, functionRelations); + } + + private CadModel createModel(ImportedModel source, String cosPrefix, long categoryId) { + CadModel model = new CadModel(); + model.setCategoryId(categoryId); + model.setModelName(source.partName()); + model.setSourceSwFilename(trimTo(source.partName() + ".SLDPRT", 255)); + 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.setApplicationScenario(""); + model.setMaterial(""); + model.setManufacturingMethod(""); + model.setUnit("mm"); + model.setDesignDescription("由 SolidWorks JSON 完整重建流程导入"); + model.setConvertStatus(IMPORTED_STATUS); + model.setCreatedAt(LocalDateTime.now()); + model.setUpdatedAt(LocalDateTime.now()); + return model; + } + + private CadModelHistory createHistory(Long modelId, ImportedModel model, ImportedHistory source, String cosPrefix) { + String base = "step-previews/" + source.directory() + "/"; + CadModelHistory history = new CadModelHistory(); + history.setModelId(modelId); + history.setSourceFeatureId(source.featureId()); + history.setHistoryIndex(source.historyIndex()); + history.setHistoryName(source.name()); + history.setOperationType(source.operationType()); + history.setStepPath(trimTo(objectKey(cosPrefix, model, base + "step.step"), 1024)); + history.setGlbPath(trimTo(objectKey(cosPrefix, model, base + "scene.glb"), 1024)); + history.setTopologyPath(trimTo(objectKey(cosPrefix, model, base + "topology.json"), 1024)); + history.setConvertStatus(IMPORTED_STATUS); + history.setCreatedAt(LocalDateTime.now()); + history.setUpdatedAt(LocalDateTime.now()); + return history; + } + + private JsonNode readJson(Path file) { + try { + JsonNode root = objectMapper.readTree(file.toFile()); + if (root == null || !root.isObject()) { + throw new IllegalArgumentException("JSON 根节点必须是对象: " + file); + } + return root; + } catch (IOException exception) { + throw new IllegalArgumentException("读取 JSON 失败: " + file, exception); + } + } + + private void requireFiles(Path root, String... relativePaths) { + for (String relativePath : relativePaths) { + if (!Files.isRegularFile(root.resolve(relativePath))) { + throw new IllegalArgumentException("转换产物缺失: " + root.resolve(relativePath)); + } + } + } + + private void requireDirectory(Path directory, String label) { + if (directory == null || !Files.isDirectory(directory)) { + throw new IllegalArgumentException(label + "不存在或不是目录: " + directory); + } + } + + private String objectKey(String cosPrefix, ImportedModel model, String relativePath) { + String prefix = StringUtils.hasText(cosPrefix) ? cosPrefix.trim() : "datasets/solidworks-rebuild-v1"; + String normalizedRelativePath = relativePath.replace('\\', '/').replaceAll("^/+|/+$", ""); + return prefix.replaceAll("/+$", "") + "/" + model.partName() + "/" + normalizedRelativePath; + } + + private String contentType(Path file) { + String name = file.getFileName().toString().toLowerCase(Locale.ROOT); + if (name.endsWith(".json")) return "application/json"; + if (name.endsWith(".glb")) return "model/gltf-binary"; + if (name.endsWith(".svg")) return "image/svg+xml"; + if (name.endsWith(".step") || name.endsWith(".stp")) return "model/step"; + if (name.endsWith(".log")) return "text/plain"; + return "application/octet-stream"; + } + + private static String text(JsonNode node, String fieldName) { + JsonNode value = node.path(fieldName); + return value.isTextual() ? value.asText().trim() : ""; + } + + private static String requireText(String value, String message) { + if (!StringUtils.hasText(value)) throw new IllegalArgumentException(message); + return value; + } + + private static String trimTo(String value, int maximumLength) { + String normalized = value == null ? "" : value.trim(); + return normalized.length() <= maximumLength ? normalized : normalized.substring(0, maximumLength); + } + + private record ImportedModel(String partName, Path versionRoot, List histories) { + } + + private record ImportedHistory(String featureId, int historyIndex, String name, String operationType, String directory) { + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0ec5eba..3ccafdd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -40,12 +40,20 @@ app: cos: secret-id: ${APP_COS_SECRET_ID:} secret-key: ${APP_COS_SECRET_KEY:} + session-token: ${APP_COS_SESSION_TOKEN:} bucket: ${APP_COS_BUCKET:} region: ${APP_COS_REGION:ap-beijing} public-base-url: ${APP_COS_PUBLIC_BASE_URL:} upload-session-ttl: ${APP_COS_UPLOAD_SESSION_TTL:PT30M} upload-prefix: ${APP_COS_UPLOAD_PREFIX:uploads} max-upload-size-bytes: ${APP_COS_MAX_UPLOAD_SIZE_BYTES:104857600} + json-dataset-import: + enabled: ${APP_JSON_DATASET_IMPORT_ENABLED:false} + source-directory: ${APP_JSON_DATASET_IMPORT_SOURCE_DIRECTORY:} + 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} + category-id: ${APP_JSON_DATASET_IMPORT_CATEGORY_ID:1} logging: file: diff --git a/src/test/java/com/cadlabel/controller/CadAnnotationControllerTest.java b/src/test/java/com/cadlabel/controller/CadAnnotationControllerTest.java index 87a75d3..a720fe4 100644 --- a/src/test/java/com/cadlabel/controller/CadAnnotationControllerTest.java +++ b/src/test/java/com/cadlabel/controller/CadAnnotationControllerTest.java @@ -4,6 +4,7 @@ import com.cadlabel.common.exception.GlobalExceptionHandler; import com.cadlabel.common.web.RequestIdFilter; import com.cadlabel.config.JacksonConfig; import com.cadlabel.dto.cad.annotation.CadFunctionResponse; +import com.cadlabel.dto.cad.annotation.CadAnnotationExportResponse; import com.cadlabel.dto.cad.annotation.CadFunctionStructureItemResponse; import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse; import com.cadlabel.dto.cad.annotation.CadStructureHistoryItemResponse; @@ -44,6 +45,21 @@ class CadAnnotationControllerTest { @Autowired private MockMvc mockMvc; + @Test + void exportCurrentAnnotationReturnsAiReadablePackage() throws Exception { + given(cadAnnotationService.exportCurrentAnnotation(1L)).willReturn(sampleExport()); + + mockMvc.perform(get("/api/cad-models/1/export/annotation")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.schemaVersion").value("cadlabel.current-annotation.v1")) + .andExpect(jsonPath("$.data.functions[0].structureIds[0]").value(100)) + .andExpect(jsonPath("$.data.structures[0].featureSteps[0].feature.sourceFeature.sourceFeatureId").value("feat_001")) + .andExpect(jsonPath("$.data.relations[0].type").value("implemented_by")); + + verify(cadAnnotationService).exportCurrentAnnotation(1L); + } + @Test void saveHistoryAnnotationReturnsUnifiedResponse() throws Exception { given(cadAnnotationService.saveHistoryAnnotation(eq(1L), eq(10L), any())) @@ -268,6 +284,53 @@ class CadAnnotationControllerTest { ); } + private static CadAnnotationExportResponse sampleExport() { + CadAnnotationExportResponse.Feature feature = new CadAnnotationExportResponse.Feature( + 10L, + "凸台-拉伸1", + "extrude_add", + 1, + new CadAnnotationExportResponse.SourceFeature( + "feat_001", + "bound", + "steps/1.step", + "glbs/1.glb", + "topologies/1.json" + ), + new CadAnnotationExportResponse.Annotation("创建底座", "覆盖底面", "人工确认", LocalDateTime.of(2026, 7, 8, 10, 5, 0)) + ); + return new CadAnnotationExportResponse( + "cadlabel.current-annotation.v1", + LocalDateTime.of(2026, 7, 14, 10, 0, 0), + "AI training annotation", + new CadAnnotationExportResponse.Model(1L, "轴承座", 7L, "工业夹具", "铝合金", "CNC", "mm"), + new CadAnnotationExportResponse.Source("轴承座.sldprt", "sw/1.sldprt", "features/1.json", "sourceFeatureId binds source JSON"), + new CadAnnotationExportResponse.Requirement("需求描述"), + "设计说明", + List.of(new CadAnnotationExportResponse.Function(200L, "固定功能", "mounting", "固定外部零件", "人工确认", List.of(100L))), + List.of(new CadAnnotationExportResponse.Structure( + 100L, + "加强筋", + "rib", + "提高强度", + "多段拉伸", + "受力集中", + "人工确认", + List.of(200L), + List.of(new CadAnnotationExportResponse.FeatureStep("structure_history:1000", 0, feature)) + )), + List.of(feature), + List.of(), + List.of(new CadAnnotationExportResponse.Relation( + "structure_history:1000", + "implemented_by", + "structure:100", + "history:10", + 0 + )) + ); + } + private static CadStructureResponse sampleStructure(Long id, String structureName) { return new CadStructureResponse( id, diff --git a/src/test/java/com/cadlabel/controller/CadModelControllerTest.java b/src/test/java/com/cadlabel/controller/CadModelControllerTest.java index fa37db2..76686b4 100644 --- a/src/test/java/com/cadlabel/controller/CadModelControllerTest.java +++ b/src/test/java/com/cadlabel/controller/CadModelControllerTest.java @@ -9,6 +9,7 @@ import com.cadlabel.dto.cad.CadModelHistoryResponse; import com.cadlabel.dto.cad.CadModelListQuery; import com.cadlabel.dto.cad.CadModelResponse; import com.cadlabel.dto.cad.UpdateCadModelRequest; +import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse; import com.cadlabel.service.CadModelService; import java.time.LocalDateTime; import java.util.List; @@ -138,7 +139,17 @@ class CadModelControllerTest { "success", null, LocalDateTime.of(2026, 7, 8, 10, 0, 0), - LocalDateTime.of(2026, 7, 8, 10, 5, 0) + LocalDateTime.of(2026, 7, 8, 10, 5, 0), + new CadModelHistoryAnnotationResponse( + 100L, + 1L, + 10L, + "拉伸形成主体", + "决定主体厚度", + "已复核", + LocalDateTime.of(2026, 7, 8, 10, 1, 0), + LocalDateTime.of(2026, 7, 8, 10, 2, 0) + ) ) )); @@ -148,7 +159,10 @@ class CadModelControllerTest { .andExpect(jsonPath("$.data[0].id").value(10)) .andExpect(jsonPath("$.data[0].historyIndex").value(1)) .andExpect(jsonPath("$.data[0].historyName").value("凸台-拉伸1")) - .andExpect(jsonPath("$.data[0].topologyPath").value("topologies/1.json")); + .andExpect(jsonPath("$.data[0].topologyPath").value("topologies/1.json")) + .andExpect(jsonPath("$.data[0].annotation.modelingDescription").value("拉伸形成主体")) + .andExpect(jsonPath("$.data[0].annotation.supplementDescription").value("决定主体厚度")) + .andExpect(jsonPath("$.data[0].annotation.remark").value("已复核")); } private static CadModelResponse sampleModel(Long id, String modelName) { diff --git a/src/test/java/com/cadlabel/service/impl/CadAnnotationServiceIntegrationTest.java b/src/test/java/com/cadlabel/service/impl/CadAnnotationServiceIntegrationTest.java index 9322eab..7c19d92 100644 --- a/src/test/java/com/cadlabel/service/impl/CadAnnotationServiceIntegrationTest.java +++ b/src/test/java/com/cadlabel/service/impl/CadAnnotationServiceIntegrationTest.java @@ -2,6 +2,7 @@ package com.cadlabel.service.impl; import com.cadlabel.common.exception.BizException; import com.cadlabel.dto.cad.annotation.CadFunctionResponse; +import com.cadlabel.dto.cad.annotation.CadAnnotationExportResponse; import com.cadlabel.dto.cad.annotation.CadModelHistoryAnnotationResponse; import com.cadlabel.dto.cad.annotation.CadStructureResponse; import com.cadlabel.dto.cad.annotation.CreateCadFunctionRequest; @@ -181,6 +182,49 @@ class CadAnnotationServiceIntegrationTest { .hasMessage("建模历史不存在"); } + @Test + void exportCurrentAnnotationKeepsFunctionStructureAndSourceFeatureBinding() { + cadAnnotationService.saveHistoryAnnotation(1L, 10L, + new SaveHistoryAnnotationRequest("创建主体基体", "作为后续孔和倒角的稳定基准", "人工复核")); + CadStructureResponse structure = cadAnnotationService.createStructure(1L, new CreateCadStructureRequest( + "主体承载结构", + "base_body", + "提供承载基体", + "先拉伸主体再加工细节", + "先稳定主基准,降低后续特征依赖", + "关键结构", + List.of(10L) + )); + CadFunctionResponse function = cadAnnotationService.createFunction(1L, new CreateCadFunctionRequest( + "安装承载功能", + "mounting", + "实现外部零件安装与承载", + "主功能", + List.of(structure.id()) + )); + + CadAnnotationExportResponse exported = cadAnnotationService.exportCurrentAnnotation(1L); + + assertThat(exported.schemaVersion()).isEqualTo("cadlabel.current-annotation.v1"); + assertThat(exported.source().featureTreeJsonPath()).isEqualTo("features/1.json"); + assertThat(exported.functions()).singleElement().satisfies(item -> { + assertThat(item.id()).isEqualTo(function.id()); + assertThat(item.structureIds()).containsExactly(structure.id()); + }); + assertThat(exported.structures()).singleElement().satisfies(item -> { + assertThat(item.id()).isEqualTo(structure.id()); + assertThat(item.functionIds()).containsExactly(function.id()); + assertThat(item.featureSteps()).singleElement().satisfies(step -> { + assertThat(step.feature().historyId()).isEqualTo(10L); + assertThat(step.feature().sourceFeature().sourceFeatureId()).isEqualTo("feat_001"); + assertThat(step.feature().annotation().modelingIntent()).isEqualTo("创建主体基体"); + }); + }); + assertThat(exported.unassignedHistoryIds()).containsExactly(11L); + assertThat(exported.relations()).extracting(CadAnnotationExportResponse.Relation::type) + .containsExactlyInAnyOrder("realized_by", "implemented_by"); + } + @Test void createStructureWritesRelationsInRequestOrder() { CadStructureResponse response = cadAnnotationService.createStructure(1L, new CreateCadStructureRequest( diff --git a/src/test/java/com/cadlabel/service/impl/CadModelServiceIntegrationTest.java b/src/test/java/com/cadlabel/service/impl/CadModelServiceIntegrationTest.java index 6539b9d..40b7f13 100644 --- a/src/test/java/com/cadlabel/service/impl/CadModelServiceIntegrationTest.java +++ b/src/test/java/com/cadlabel/service/impl/CadModelServiceIntegrationTest.java @@ -35,6 +35,7 @@ class CadModelServiceIntegrationTest { @BeforeEach void setUp() { + jdbcTemplate.execute("DROP TABLE IF EXISTS cad_model_history_annotation"); jdbcTemplate.execute("DROP TABLE IF EXISTS cad_model_history"); jdbcTemplate.execute("DROP TABLE IF EXISTS cad_model"); jdbcTemplate.execute(""" @@ -59,6 +60,19 @@ class CadModelServiceIntegrationTest { UNIQUE KEY uk_cad_model_category_filename (category_id, source_sw_filename) ) """); + jdbcTemplate.execute(""" + CREATE TABLE cad_model_history_annotation ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + model_id BIGINT NOT NULL, + history_id BIGINT NOT NULL, + modeling_description TEXT, + supplement_description TEXT, + remark TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_history_annotation (history_id) + ) + """); jdbcTemplate.execute(""" CREATE TABLE cad_model_history ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, @@ -211,12 +225,30 @@ class CadModelServiceIntegrationTest { insertModel(1L, 7L, "轴承座", "a.sldprt", "success", "2026-07-08 10:00:00"); insertHistory(11L, 1L, "feat_002", 2, "倒角1"); insertHistory(10L, 1L, "feat_001", 1, "凸台-拉伸1"); + jdbcTemplate.update(""" + INSERT INTO cad_model_history_annotation ( + id, model_id, history_id, modeling_description, + supplement_description, remark, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + 100L, + 1L, + 10L, + "拉伸形成主体", + "决定主体厚度", + "已复核", + "2026-07-08 10:01:00", + "2026-07-08 10:02:00" + ); List histories = cadModelService.listHistories(1L); assertThat(histories).extracting(CadModelHistoryResponse::id).containsExactly(10L, 11L); assertThat(histories).extracting(CadModelHistoryResponse::historyIndex).containsExactly(1, 2); assertThat(histories.getFirst()).hasFieldOrPropertyWithValue("topologyPath", "topologies/1.json"); + assertThat(histories.getFirst().annotation()).isNotNull(); + assertThat(histories.getFirst().annotation().modelingDescription()).isEqualTo("拉伸形成主体"); + assertThat(histories.get(1).annotation()).isNull(); assertThatThrownBy(() -> cadModelService.listHistories(404L)) .isInstanceOf(BizException.class) diff --git a/src/test/java/com/cadlabel/service/impl/CosServiceTest.java b/src/test/java/com/cadlabel/service/impl/CosServiceTest.java index 3f5cedc..e68b3a3 100644 --- a/src/test/java/com/cadlabel/service/impl/CosServiceTest.java +++ b/src/test/java/com/cadlabel/service/impl/CosServiceTest.java @@ -22,6 +22,7 @@ class CosServiceTest { private final CosProperties cosProperties = new CosProperties( "sid", "skey", + "", "cadlabel-1250000000", "ap-beijing", "https://cdn.example.com/assets", diff --git a/src/test/java/com/cadlabel/service/impl/SolidWorksJsonDatasetImportServiceTest.java b/src/test/java/com/cadlabel/service/impl/SolidWorksJsonDatasetImportServiceTest.java new file mode 100644 index 0000000..7000c86 --- /dev/null +++ b/src/test/java/com/cadlabel/service/impl/SolidWorksJsonDatasetImportServiceTest.java @@ -0,0 +1,140 @@ +package com.cadlabel.service.impl; + +import com.cadlabel.entity.CadModel; +import com.cadlabel.entity.CadModelHistory; +import com.cadlabel.mapper.CadModelHistoryMapper; +import com.cadlabel.mapper.CadModelMapper; +import com.cadlabel.service.JsonDatasetImportService; +import com.cadlabel.service.client.CosObjectStorageClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class SolidWorksJsonDatasetImportServiceTest { + + @Mock + private CadModelMapper cadModelMapper; + @Mock + private CadModelHistoryMapper cadModelHistoryMapper; + @Mock + private JdbcTemplate jdbcTemplate; + @Mock + private CosObjectStorageClient cosObjectStorageClient; + + private SolidWorksJsonDatasetImportService service; + + @TempDir + private Path temporaryDirectory; + + private Path sourceDirectory; + private Path artifactsDirectory; + + @BeforeEach + void setUp() { + sourceDirectory = temporaryDirectory.resolve("source"); + artifactsDirectory = temporaryDirectory.resolve("artifacts"); + service = new SolidWorksJsonDatasetImportService( + new ObjectMapper(), + jdbcTemplate, + cadModelMapper, + cadModelHistoryMapper, + cosObjectStorageClient + ); + } + + @Test + void replaceDatasetClearsOldAnnotationDataAndImportsFeatureHistories() throws Exception { + Files.createDirectories(sourceDirectory); + Files.writeString(sourceDirectory.resolve("demo.solidworks_rebuild_extract.json"), """ + { + "schema": "solidworks.rebuild_extract.v1", + "part_name": "demo", + "features": [ + {"id": "feat_000", "name": "基体拉伸", "type": "extrude", "type_name": "BossExtrude", "source_feature": {"index": 0}}, + {"id": "feat_001", "name": "倒角", "type": "chamfer", "source_feature": {"index": 1}} + ] + } + """); + Path versionRoot = artifactsDirectory.resolve("model-id/v1"); + Files.createDirectories(versionRoot.resolve("step-previews/0001")); + Files.createDirectories(versionRoot.resolve("step-previews/0002")); + Files.copy(sourceDirectory.resolve("demo.solidworks_rebuild_extract.json"), versionRoot.resolve("source.feature-tree.json")); + for (String file : new String[]{"source-json-rebuilt.step", "scene.glb", "topology.json", "thumbnail.svg", + "source-json-feature-geometry-map.json", "source-json-rebuild-report.json"}) { + Files.writeString(versionRoot.resolve(file), "asset"); + } + for (String directory : new String[]{"0001", "0002"}) { + for (String file : new String[]{"step.step", "scene.glb", "topology.json"}) { + Files.writeString(versionRoot.resolve("step-previews").resolve(directory).resolve(file), "asset"); + } + } + Files.writeString(versionRoot.resolve("step-previews/manifest.json"), """ + { + "schemaVersion": "lingmiao.source-json-step-previews.v1", + "readyCount": 2, + "failedCount": 0, + "steps": [ + {"order": 1, "operationKey": "feat_000", "name": "基体拉伸", "type": "extrude_add", "status": "ready"}, + {"order": 2, "operationKey": "feat_001", "name": "倒角", "type": "chamfer", "status": "ready"} + ] + } + """); + AtomicLong ids = new AtomicLong(100L); + doAnswer(invocation -> { + CadModel model = invocation.getArgument(0); + model.setId(ids.getAndIncrement()); + return 1; + }).when(cadModelMapper).insert(any(CadModel.class)); + + JsonDatasetImportService.ImportSummary summary = service.replaceDataset( + sourceDirectory, artifactsDirectory, "dataset-test", true, 7L); + + assertThat(summary).isEqualTo(new JsonDatasetImportService.ImportSummary(1, 2)); + ArgumentCaptor modelCaptor = ArgumentCaptor.forClass(CadModel.class); + verify(cadModelMapper).insert(modelCaptor.capture()); + assertThat(modelCaptor.getValue().getCategoryId()).isEqualTo(7L); + assertThat(modelCaptor.getValue().getModelName()).isEqualTo("demo"); + 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"); + + ArgumentCaptor historyCaptor = ArgumentCaptor.forClass(CadModelHistory.class); + verify(cadModelHistoryMapper, org.mockito.Mockito.times(2)).insert(historyCaptor.capture()); + assertThat(historyCaptor.getAllValues()) + .extracting(CadModelHistory::getSourceFeatureId, CadModelHistory::getHistoryIndex, + CadModelHistory::getHistoryName, CadModelHistory::getOperationType) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("feat_000", 1, "基体拉伸", "extrude_add"), + org.assertj.core.groups.Tuple.tuple("feat_001", 2, "倒角", "chamfer") + ); + assertThat(historyCaptor.getAllValues().getFirst().getStepPath()) + .isEqualTo("dataset-test/demo/step-previews/0001/step.step"); + verify(cosObjectStorageClient, org.mockito.Mockito.atLeastOnce()) + .putObject(anyString(), any(InputStream.class), anyLong(), anyString()); + verify(jdbcTemplate).update("DELETE FROM cad_function_structure_rel"); + verify(jdbcTemplate).update("DELETE FROM cad_structure_history_rel"); + verify(jdbcTemplate).update("DELETE FROM cad_model_history_annotation"); + verify(jdbcTemplate).update("DELETE FROM cad_function"); + verify(jdbcTemplate).update("DELETE FROM cad_structure"); + verify(jdbcTemplate).update("DELETE FROM cad_model_history"); + verify(jdbcTemplate).update("DELETE FROM cad_model"); + } +}