新增标注导出和JSON数据集导入

This commit is contained in:
2026-07-14 19:22:28 +08:00
parent c0a5fb8e26
commit 17964d4ee7
22 changed files with 1490 additions and 10 deletions
+16
View File
@@ -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<T>` 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.
+12 -1
View File
@@ -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"
}
+453
View File
@@ -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;
});
@@ -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,
@@ -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;
}
@@ -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<CadAnnotationExportResponse> exportCurrentAnnotation(@PathVariable @Positive Long modelId) {
return ApiResponse.success(cadAnnotationService.exportCurrentAnnotation(modelId));
}
/**
* 保存建模历史步骤标注。
*
@@ -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
) {
}
@@ -0,0 +1,114 @@
package com.cadlabel.dto.cad.annotation;
import java.time.LocalDateTime;
import java.util.List;
/**
* 当前 CAD 语义标注导出包。
*
* <p>本文件需与 {@code source.featureTreeJsonPath} 指向的原始特征树 JSON 一起使用。
* 每个 {@code featureIndex} 条目通过 {@code sourceFeature.sourceFeatureId} 绑定原始特征。</p>
*/
public record CadAnnotationExportResponse(
String schemaVersion,
LocalDateTime exportedAt,
String purpose,
Model model,
Source source,
Requirement requirement,
String designSummary,
List<Function> functions,
List<Structure> structures,
List<Feature> featureIndex,
List<Long> unassignedHistoryIds,
List<Relation> 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<Long> structureIds
) {
}
public record Structure(
Long id,
String name,
String type,
String purpose,
String solution,
String reason,
String notes,
List<Long> functionIds,
List<FeatureStep> 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
) {
}
}
@@ -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());
}
}
@@ -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);
/**
* 保存建模历史步骤标注。
*
@@ -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) {
}
}
@@ -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))
);
}
@@ -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;
/**
* 导出当前模型的完整语义标注视图。
*
* <p>原始 JSON 不在此接口中复制;每个历史步骤保留 sourceFeatureId
* 训练程序可据此在 featureTreeJsonPath 指向的原始 JSON 中定位对应特征。</p>
*/
@Override
@Transactional(readOnly = true)
public CadAnnotationExportResponse exportCurrentAnnotation(Long modelId) {
CadModel model = requireModel(modelId);
List<CadModelHistory> histories = cadModelHistoryMapper.selectList(Wrappers.lambdaQuery(CadModelHistory.class)
.eq(CadModelHistory::getModelId, modelId)
.orderByAsc(CadModelHistory::getHistoryIndex)
.orderByAsc(CadModelHistory::getId));
Map<Long, CadModelHistoryAnnotation> annotationsByHistoryId = annotationMapper.selectList(
Wrappers.lambdaQuery(CadModelHistoryAnnotation.class)
.eq(CadModelHistoryAnnotation::getModelId, modelId)
)
.stream()
.collect(Collectors.toMap(CadModelHistoryAnnotation::getHistoryId, annotation -> annotation));
List<CadAnnotationExportResponse.Feature> features = histories.stream()
.map(history -> toExportFeature(history, annotationsByHistoryId.get(history.getId())))
.toList();
Map<Long, CadAnnotationExportResponse.Feature> featureByHistoryId = features.stream()
.collect(Collectors.toMap(CadAnnotationExportResponse.Feature::historyId, feature -> feature));
List<CadStructureResponse> structureResponses = listStructures(modelId);
List<CadFunctionResponse> functionResponses = listFunctions(modelId);
Map<Long, List<Long>> functionIdsByStructureId = new HashMap<>();
List<CadAnnotationExportResponse.Relation> 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<Long> assignedHistoryIds = new LinkedHashSet<>();
List<CadAnnotationExportResponse.Structure> structures = new ArrayList<>();
for (CadStructureResponse structure : structureResponses) {
List<CadAnnotationExportResponse.FeatureStep> 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<CadAnnotationExportResponse.Function> 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<Long> 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()
)
);
}
/**
* 保存建模历史步骤标注。
*
@@ -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<CadModelHistoryResponse> listHistories(Long modelId) {
requireModel(modelId);
return cadModelHistoryMapper.selectList(Wrappers.lambdaQuery(CadModelHistory.class)
List<CadModelHistory> histories = cadModelHistoryMapper.selectList(Wrappers.lambdaQuery(CadModelHistory.class)
.eq(CadModelHistory::getModelId, modelId)
.orderByAsc(CadModelHistory::getHistoryIndex)
.orderByAsc(CadModelHistory::getId))
.orderByAsc(CadModelHistory::getId));
Map<Long, CadModelHistoryAnnotation> 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()
)
);
}
}
@@ -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<String, Path> sourceFiles = readSourceFiles(sourceDirectory);
List<ImportedModel> 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<String, Path> readSourceFiles(Path sourceDirectory) {
requireDirectory(sourceDirectory, "JSON 数据集目录");
Map<String, Path> result = new HashMap<>();
try (Stream<Path> 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<ImportedModel> readArtifacts(Path artifactsDirectory, Map<String, Path> sourceFiles) {
requireDirectory(artifactsDirectory, "转换产物目录");
List<Path> manifests;
try (Stream<Path> 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<ImportedModel> models = new ArrayList<>();
Set<String> 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<ImportedHistory> 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<Path> files;
try (Stream<Path> 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<ImportedHistory> histories) {
}
private record ImportedHistory(String featureId, int historyIndex, String name, String operationType, String directory) {
}
}
+8
View File
@@ -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:
@@ -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,
@@ -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) {
@@ -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(
@@ -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<CadModelHistoryResponse> 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)
@@ -22,6 +22,7 @@ class CosServiceTest {
private final CosProperties cosProperties = new CosProperties(
"sid",
"skey",
"",
"cadlabel-1250000000",
"ap-beijing",
"https://cdn.example.com/assets",
@@ -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<CadModel> 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<CadModelHistory> 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");
}
}