Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5aaabd4270 | |||
| 17964d4ee7 |
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
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 DEFAULT_SOURCE_ROOT = fs.existsSync(path.resolve(process.cwd(), '../json_data/original'))
|
||||
? '../json_data/original'
|
||||
: '../output_json 2';
|
||||
const SOURCE_ROOT = path.resolve(process.cwd(), process.env.CADLABEL_SOURCE_ROOT ?? DEFAULT_SOURCE_ROOT);
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
const FORCE = process.argv.includes('--force');
|
||||
const onlyModel = valueAfter('--model');
|
||||
const MARKER = '[AI批量分析:v1]';
|
||||
const API_TIMEOUT_MS = Number(process.env.CADLABEL_API_TIMEOUT_MS ?? 120_000);
|
||||
|
||||
const CLASS_DEFINITIONS = [
|
||||
{
|
||||
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(API_TIMEOUT_MS)
|
||||
});
|
||||
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, geometry) {
|
||||
const configured = CLASS_DEFINITIONS.find((item) => item.ids.includes(modelName));
|
||||
if (configured) return configured;
|
||||
if (modelName.includes('方板+均布圆周孔')) {
|
||||
return {
|
||||
label: '圆周孔方形安装板',
|
||||
scene: '方形安装基面上的圆周均布紧固、定位和载荷分散',
|
||||
primaryFunction: '方板承载与圆周孔系连接'
|
||||
};
|
||||
}
|
||||
if (modelName.includes('方板+四角孔')) {
|
||||
return {
|
||||
label: '多孔方形安装板',
|
||||
scene: '方形安装基面上的四角紧固、中心定位和附加接口连接',
|
||||
primaryFunction: '方板承载与多点安装'
|
||||
};
|
||||
}
|
||||
if (modelName.includes('方台')) {
|
||||
return {
|
||||
label: geometry.interfaceCount ? '多孔方形安装台' : '方形承载台',
|
||||
scene: '小型机械组件的平面承载、垫高、定位和紧固安装',
|
||||
primaryFunction: '方台承载与安装定位'
|
||||
};
|
||||
}
|
||||
if (modelName.includes('圆盘+均布孔')) {
|
||||
return {
|
||||
label: '圆周孔圆形安装盘',
|
||||
scene: '圆形安装面上的周向均布紧固和同轴定位',
|
||||
primaryFunction: '圆盘承载与圆周孔系连接'
|
||||
};
|
||||
}
|
||||
if (modelName.includes('圆盘')) {
|
||||
return {
|
||||
label: '圆形承载盘',
|
||||
scene: '圆形端面的承载、间隔和同轴安装',
|
||||
primaryFunction: '圆盘承载与端面定位'
|
||||
};
|
||||
}
|
||||
const sequenceNumber = /^\d+$/.test(modelName) ? Number(modelName) : null;
|
||||
if (sequenceNumber !== null && sequenceNumber >= 40 && sequenceNumber <= 51) {
|
||||
return {
|
||||
label: '多孔圆形法兰盘',
|
||||
scene: '中心接口与周向孔系组合的法兰连接和同轴定位',
|
||||
primaryFunction: '圆形法兰连接与孔系定位'
|
||||
};
|
||||
}
|
||||
if (sequenceNumber !== null && sequenceNumber >= 52 && sequenceNumber <= 58) {
|
||||
return {
|
||||
label: '双耳异形连接板',
|
||||
scene: '双圆头或长圆形接口之间的跨距连接、铰接和定位',
|
||||
primaryFunction: '双接口跨距连接与载荷传递'
|
||||
};
|
||||
}
|
||||
if (sequenceNumber !== null && sequenceNumber >= 59 && sequenceNumber <= 66) {
|
||||
return {
|
||||
label: '多孔异形安装板',
|
||||
scene: '多规格孔位的组合安装、定位和多点紧固',
|
||||
primaryFunction: '异形板承载与多孔连接'
|
||||
};
|
||||
}
|
||||
if (sequenceNumber !== null && sequenceNumber >= 67) {
|
||||
return {
|
||||
label: '复合孔系圆形法兰盘',
|
||||
scene: '多圈、多规格孔系的法兰连接、精密定位和装配适配',
|
||||
primaryFunction: '复合孔系法兰连接'
|
||||
};
|
||||
}
|
||||
return geometry.outerCircular ? {
|
||||
label: '多孔圆形安装盘',
|
||||
scene: '圆形安装面上的承载、周向紧固和同轴定位',
|
||||
primaryFunction: '圆盘承载与多孔连接'
|
||||
} : {
|
||||
label: '机械安装连接件',
|
||||
scene: '机械组件的承载、定位和紧固连接',
|
||||
primaryFunction: '机械承载与装配连接'
|
||||
};
|
||||
}
|
||||
|
||||
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 geometryAnalysis(source) {
|
||||
const box = source.validation_hints?.part_box_m;
|
||||
const size = Array.isArray(box) && box.length === 6
|
||||
? [box[3] - box[0], box[4] - box[1], box[5] - box[2]].map((value) => value * 1000)
|
||||
: [];
|
||||
const maxSpan = size.length ? Math.max(...size) : 0;
|
||||
const faces = (source.features ?? []).flatMap((feature) => feature.owned_faces ?? []);
|
||||
const cylinders = faces
|
||||
.filter((face) => face.surface?.is_cylinder && Array.isArray(face.surface.cylinder_params))
|
||||
.map((face) => {
|
||||
const params = face.surface.cylinder_params;
|
||||
const center = params.slice(0, 3).map((value) => value * 1000);
|
||||
const axis = params.slice(3, 6);
|
||||
const axisIndex = axis.reduce(
|
||||
(best, value, index) => Math.abs(value) > Math.abs(axis[best] ?? 0) ? index : best,
|
||||
0
|
||||
);
|
||||
const radialDistance = Math.hypot(...center.filter((_, index) => index !== axisIndex));
|
||||
return { radius: Math.abs(params[6] * 1000), center, radialDistance };
|
||||
});
|
||||
const outerCircular = cylinders.some((item) =>
|
||||
maxSpan > 0 && item.radius >= maxSpan * 0.4 && item.radialDistance <= maxSpan * 0.08
|
||||
);
|
||||
const interfaces = cylinders.filter((item) =>
|
||||
maxSpan > 0 && item.radius <= maxSpan * 0.2
|
||||
);
|
||||
const uniqueInterfaces = new Map();
|
||||
for (const item of interfaces) {
|
||||
const key = `${formatNumber(item.radius)}:${item.center.map((value) => formatNumber(value)).join(',')}`;
|
||||
uniqueInterfaces.set(key, item);
|
||||
}
|
||||
const diameterGroups = new Map();
|
||||
for (const item of uniqueInterfaces.values()) {
|
||||
const diameter = formatNumber(item.radius * 2);
|
||||
diameterGroups.set(diameter, (diameterGroups.get(diameter) ?? 0) + 1);
|
||||
}
|
||||
const interfaceSummary = [...diameterGroups.entries()]
|
||||
.sort((left, right) => Number(right[0]) - Number(left[0]))
|
||||
.map(([diameter, count]) => `${count}ר${diameter} mm`)
|
||||
.join('、');
|
||||
return {
|
||||
size,
|
||||
outerCircular,
|
||||
interfaceCount: uniqueInterfaces.size,
|
||||
interfaceSummary
|
||||
};
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (!Number.isFinite(value)) return String(value);
|
||||
return Number(value.toFixed(Math.abs(value) < 10 ? 3 : 2)).toString();
|
||||
}
|
||||
|
||||
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, geometry) {
|
||||
const evidence = dimensionEvidence(feature, step, modelName);
|
||||
const faceCount = feature?.owned_faces?.length ?? 0;
|
||||
const group = GROUP_DEFINITIONS[stepGroup(step)];
|
||||
const compoundProfile = step.type === 'extrude_add' && geometry.interfaceCount > 0
|
||||
? `该复合草图一次拉伸同时形成主体外轮廓与 ${geometry.interfaceCount} 个圆形内轮廓。`
|
||||
: '';
|
||||
return {
|
||||
modelingDescription: `${operationIntent(step)}${compoundProfile}该步骤在本零件中归入“${group.name}”。`,
|
||||
supplementDescription: [
|
||||
`重建顺序第 ${step.order} 步,绑定源特征 ${step.sourceFeatureId}。`,
|
||||
geometry.size.length ? `最终几何包络约为 ${geometry.size.map(formatNumber).join(' × ')} mm。` : null,
|
||||
compoundProfile ? `圆形接口证据:${geometry.interfaceSummary}。` : null,
|
||||
evidence.length ? `关键参数证据:${evidence.join(';')}。` : '源特征未提供可直接解释的标量尺寸,语义依据操作类型与几何结果确定。',
|
||||
faceCount ? `源特征记录 ${faceCount} 个归属面,可用于后续拓扑复核。` : '源特征未记录独占面,需结合前后步骤或最终拓扑复核。'
|
||||
].filter(Boolean).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 geometryInterfaceStructurePayload(step, geometry, classInfo) {
|
||||
return {
|
||||
structureName: '复合草图孔系与圆弧接口',
|
||||
structureType: '孔系/圆弧轮廓结构',
|
||||
purpose: '在主体成形步骤中同步建立圆形内轮廓,用于紧固、定位、穿轴或装配避让。',
|
||||
solution: `由建模步骤 ${step.order}.${step.name} 的复合草图一次拉伸形成;检测到 ${geometry.interfaceCount} 个圆形接口,直径分组为 ${geometry.interfaceSummary}。`,
|
||||
reason: `复合草图将主体外轮廓与接口轮廓置于同一成形步骤,可保持相对位置一致;该判断同时结合“${classInfo.label}”的典型用途。`,
|
||||
remark: `${MARKER} 圆形接口依据源特征归属面的圆柱几何识别;孔的用途、配合等级与加工公差需结合装配对象复核。`,
|
||||
historyIds: [step.historyId]
|
||||
};
|
||||
}
|
||||
|
||||
function functionPayloads(classInfo, structures) {
|
||||
const allIds = structures.map((item) => item.id);
|
||||
const interfaceStructures = structures.filter((item) => ['cut', 'hole', 'thread', 'counterbore', 'pattern', 'geometry_interface'].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, geometry) {
|
||||
const box = boundingBox(source);
|
||||
const boxText = box ? `零件几何包络约为 ${box.join(' × ')} mm。` : '源数据未提供可解析的零件包络。';
|
||||
const interfaceText = geometry.interfaceCount > 0
|
||||
? `复合草图形成 ${geometry.interfaceCount} 个圆形接口,直径分组为 ${geometry.interfaceSummary}。`
|
||||
: '未从源特征归属面中识别出独立的小直径圆形接口。';
|
||||
const sequence = histories.map((step) => `${step.order}.${step.name}(${step.type})`).join(';');
|
||||
const hasRevolve = histories.some((step) => step.type.includes('revolve'));
|
||||
const hasHole = histories.some((step) => step.type === 'hole') || geometry.interfaceCount > 0;
|
||||
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}${interfaceText}需保留建模历史中定义的主体、孔槽、连接接口和边缘处理,并保证各接口的相对位置与重建顺序一致。`,
|
||||
designDescription: `${model.modelName} 被识别为${classInfo.label}。${boxText}${interfaceText}有效建模序列为:${sequence}。主体加料特征建立承载包络,独立切除、孔向导或复合草图内轮廓形成装配接口,阵列保证重复结构一致,倒角/圆角改善边缘装配性。材料、载荷、配合公差和制造基准未由源 JSON 完整给出,相关结论均需工程复核。`
|
||||
};
|
||||
}
|
||||
|
||||
function hasText(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isCompleteAnnotation(exportData, historyCount) {
|
||||
const annotatedCount = (exportData.featureIndex ?? []).filter((item) =>
|
||||
hasText(item.annotation?.modelingIntent)
|
||||
&& hasText(item.annotation?.description)
|
||||
&& hasText(item.annotation?.notes)
|
||||
).length;
|
||||
return annotatedCount === historyCount
|
||||
&& (exportData.structures?.length ?? 0) > 0
|
||||
&& (exportData.functions?.length ?? 0) > 0
|
||||
&& (exportData.unassignedHistoryIds?.length ?? 0) === 0;
|
||||
}
|
||||
|
||||
function hasUnmanagedAnnotation(exportData) {
|
||||
const annotations = exportData.featureIndex ?? [];
|
||||
const existingNotes = annotations.map((item) => item.annotation?.notes).filter(Boolean);
|
||||
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 && isCompleteAnnotation(exportData, histories.length)) {
|
||||
return { model: model.modelName, status: 'skipped:already_complete' };
|
||||
}
|
||||
if (!FORCE && hasUnmanagedAnnotation(exportData)) {
|
||||
return { model: model.modelName, status: 'skipped:existing_manual_annotation' };
|
||||
}
|
||||
|
||||
const source = sourceFor(model.modelName);
|
||||
const geometry = geometryAnalysis(source);
|
||||
const classInfo = classFor(model.modelName, geometry);
|
||||
const featuresById = new Map((source.features ?? []).map((feature) => [feature.id, feature]));
|
||||
const normalizedHistories = histories.map((step) => ({
|
||||
historyId: step.id,
|
||||
sourceFeatureId: step.sourceFeatureId,
|
||||
order: step.historyIndex,
|
||||
name: step.historyName,
|
||||
type: step.operationType
|
||||
}));
|
||||
const groups = groupSteps(normalizedHistories);
|
||||
const geometryStep = geometry.interfaceCount > 0
|
||||
? normalizedHistories.find((step) => step.type === 'extrude_add')
|
||||
: null;
|
||||
const modelUpdate = modelPayload(model, source, normalizedHistories, classInfo, geometry);
|
||||
const dryRunStructures = [...groups.keys()].map((groupKey, index) => ({ id: index + 1, groupKey }));
|
||||
if (geometryStep) dryRunStructures.push({ id: dryRunStructures.length + 1, groupKey: 'geometry_interface' });
|
||||
|
||||
if (!APPLY) {
|
||||
return {
|
||||
model: model.modelName,
|
||||
status: 'dry-run',
|
||||
histories: histories.length,
|
||||
structures: dryRunStructures.length,
|
||||
functions: functionPayloads(classInfo, dryRunStructures).length,
|
||||
circularInterfaces: geometry.interfaceCount,
|
||||
interfaceSummary: geometry.interfaceSummary || undefined
|
||||
};
|
||||
}
|
||||
|
||||
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), geometry)
|
||||
);
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
if (geometryStep) {
|
||||
const saved = await ensureStructure(
|
||||
model.id,
|
||||
geometryInterfaceStructurePayload(geometryStep, geometry, classInfo),
|
||||
existingStructures
|
||||
);
|
||||
structures.push({ ...saved, groupKey: 'geometry_interface' });
|
||||
}
|
||||
|
||||
const existingFunctions = new Map((exportData.functions ?? []).map((item) => [item.name, { id: item.id }]));
|
||||
for (const payload of functionPayloads(classInfo, structures)) {
|
||||
await ensureFunction(model.id, payload, existingFunctions);
|
||||
}
|
||||
|
||||
const verified = await api(`/cad-models/${model.id}/export/annotation`);
|
||||
const annotatedCount = (verified.featureIndex ?? []).filter((item) =>
|
||||
hasText(item.annotation?.modelingIntent)
|
||||
&& hasText(item.annotation?.description)
|
||||
&& hasText(item.annotation?.notes)
|
||||
).length;
|
||||
if (!isCompleteAnnotation(verified, histories.length)) {
|
||||
throw new Error(`${model.modelName} 校验失败: annotated=${annotatedCount}/${histories.length}, structures=${verified.structures?.length ?? 0}, functions=${verified.functions?.length ?? 0}, unassigned=${verified.unassignedHistoryIds}`);
|
||||
}
|
||||
return {
|
||||
model: model.modelName,
|
||||
status: 'applied',
|
||||
histories: histories.length,
|
||||
structures: verified.structures.length,
|
||||
functions: verified.functions.length,
|
||||
unassigned: verified.unassignedHistoryIds.length
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const modelsByPage = [];
|
||||
for (let pageNumber = 1; ; pageNumber += 1) {
|
||||
const page = await api(`/cad-models?page=${pageNumber}&size=100`);
|
||||
modelsByPage.push(...page.items);
|
||||
if (modelsByPage.length >= page.total || page.items.length === 0) break;
|
||||
}
|
||||
let models = modelsByPage.sort((left, right) => left.modelName.localeCompare(right.modelName));
|
||||
if (onlyModel) models = models.filter((model) => model.modelName === onlyModel);
|
||||
if (!models.length) throw new Error(onlyModel ? `未找到模型 ${onlyModel}` : '数据库中没有模型');
|
||||
|
||||
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,35 @@
|
||||
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;
|
||||
|
||||
/** 是否清空现有标注数据;关闭时仅允许追加不存在的新模型。 */
|
||||
private boolean replaceExisting = 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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ public record CadModelResponse(
|
||||
String sourceSwPath,
|
||||
String featureTreeJsonPath,
|
||||
String viewPath,
|
||||
String thumbnailPath,
|
||||
String applicationScenario,
|
||||
String material,
|
||||
String manufacturingMethod,
|
||||
|
||||
@@ -10,6 +10,7 @@ public record UpdateCadModelRequest(
|
||||
@Size(max = 1024) String sourceSwPath,
|
||||
@Size(max = 1024) String featureTreeJsonPath,
|
||||
@Size(max = 1024) String viewPath,
|
||||
@Size(max = 1024) String thumbnailPath,
|
||||
@Size(max = 255) String applicationScenario,
|
||||
@Size(max = 255) String material,
|
||||
@Size(max = 255) String manufacturingMethod,
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ public class CadModel {
|
||||
/** 模型视图资源路径。 */
|
||||
private String viewPath;
|
||||
|
||||
/** 模型预览图路径。 */
|
||||
private String thumbnailPath;
|
||||
|
||||
/** 应用场景。 */
|
||||
private String applicationScenario;
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 显式启用后,将处理完成的 JSON 数据集导入标注后台。
|
||||
*/
|
||||
@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 CAD JSON dataset import. sourceDirectory={}, artifactsDirectory={}, replaceExisting={}, categoryId={}",
|
||||
sourceDirectory, artifactsDirectory, properties.isReplaceExisting(), properties.getCategoryId());
|
||||
JsonDatasetImportService.ImportSummary summary = jsonDatasetImportService.importDataset(
|
||||
sourceDirectory,
|
||||
artifactsDirectory,
|
||||
properties.getCosPrefix(),
|
||||
properties.isUploadEnabled(),
|
||||
properties.isReplaceExisting(),
|
||||
properties.getCategoryId()
|
||||
);
|
||||
log.info("CAD JSON dataset import finished. modelCount={}, historyCount={}",
|
||||
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 importDataset(Path sourceDirectory, Path artifactsDirectory, String cosPrefix,
|
||||
boolean uploadEnabled, boolean replaceExisting, 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();
|
||||
}
|
||||
|
||||
@@ -182,6 +194,7 @@ public class CadModelServiceImpl implements CadModelService {
|
||||
validateMaxLength("sourceSwPath", request.sourceSwPath(), 1024);
|
||||
validateMaxLength("featureTreeJsonPath", request.featureTreeJsonPath(), 1024);
|
||||
validateMaxLength("viewPath", request.viewPath(), 1024);
|
||||
validateMaxLength("thumbnailPath", request.thumbnailPath(), 1024);
|
||||
validateMaxLength("applicationScenario", request.applicationScenario(), 255);
|
||||
validateMaxLength("material", request.material(), 255);
|
||||
validateMaxLength("manufacturingMethod", request.manufacturingMethod(), 255);
|
||||
@@ -217,6 +230,9 @@ public class CadModelServiceImpl implements CadModelService {
|
||||
if (request.viewPath() != null) {
|
||||
model.setViewPath(request.viewPath());
|
||||
}
|
||||
if (request.thumbnailPath() != null) {
|
||||
model.setThumbnailPath(request.thumbnailPath());
|
||||
}
|
||||
if (request.applicationScenario() != null) {
|
||||
model.setApplicationScenario(request.applicationScenario());
|
||||
}
|
||||
@@ -254,6 +270,7 @@ public class CadModelServiceImpl implements CadModelService {
|
||||
addField(fields, "sourceSwPath", request.sourceSwPath());
|
||||
addField(fields, "featureTreeJsonPath", request.featureTreeJsonPath());
|
||||
addField(fields, "viewPath", request.viewPath());
|
||||
addField(fields, "thumbnailPath", request.thumbnailPath());
|
||||
addField(fields, "applicationScenario", request.applicationScenario());
|
||||
addField(fields, "material", request.material());
|
||||
addField(fields, "manufacturingMethod", request.manufacturingMethod());
|
||||
@@ -302,6 +319,7 @@ public class CadModelServiceImpl implements CadModelService {
|
||||
model.getSourceSwPath(),
|
||||
model.getFeatureTreeJsonPath(),
|
||||
model.getViewPath(),
|
||||
model.getThumbnailPath(),
|
||||
model.getApplicationScenario(),
|
||||
model.getMaterial(),
|
||||
model.getManufacturingMethod(),
|
||||
@@ -315,7 +333,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 +350,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,311 @@
|
||||
package com.cadlabel.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.cadlabel.entity.CadModel;
|
||||
import com.cadlabel.entity.CadModelHistory;
|
||||
import com.cadlabel.mapper.CadModelHistoryMapper;
|
||||
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 数据集上传对象存储并导入远程标注数据。 */
|
||||
@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 importDataset(Path sourceDirectory, Path artifactsDirectory, String cosPrefix,
|
||||
boolean uploadEnabled, boolean replaceExisting, 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 (!replaceExisting) {
|
||||
ensureModelsDoNotExist(models);
|
||||
}
|
||||
|
||||
if (uploadEnabled) {
|
||||
for (ImportedModel model : models) {
|
||||
uploadModelAssets(model, cosPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
if (replaceExisting) {
|
||||
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)) {
|
||||
continue;
|
||||
}
|
||||
if (!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 ensureModelsDoNotExist(List<ImportedModel> models) {
|
||||
Set<String> modelNames = models.stream().map(ImportedModel::partName).collect(java.util.stream.Collectors.toSet());
|
||||
Set<String> sourceFilenames = models.stream()
|
||||
.map(model -> model.partName() + ".SLDPRT")
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
Long count = cadModelMapper.selectCount(Wrappers.lambdaQuery(CadModel.class)
|
||||
.in(CadModel::getModelName, modelNames)
|
||||
.or()
|
||||
.in(CadModel::getSourceSwFilename, sourceFilenames));
|
||||
if (count != null && count > 0) {
|
||||
throw new IllegalArgumentException("追加导入包含远程已存在模型,拒绝写入: duplicateCount=" + count);
|
||||
}
|
||||
}
|
||||
|
||||
private void uploadModelAssets(ImportedModel model, String cosPrefix) {
|
||||
List<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.setThumbnailPath(trimTo(objectKey(cosPrefix, source, "thumbnail.svg"), 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) {
|
||||
}
|
||||
}
|
||||
@@ -40,12 +40,21 @@ 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}
|
||||
replace-existing: ${APP_JSON_DATASET_IMPORT_REPLACE_EXISTING: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;
|
||||
@@ -59,7 +60,8 @@ class CadModelControllerTest {
|
||||
.andExpect(jsonPath("$.data.size").value(5))
|
||||
.andExpect(jsonPath("$.data.total").value(11))
|
||||
.andExpect(jsonPath("$.data.items[0].id").value(1))
|
||||
.andExpect(jsonPath("$.data.items[0].modelName").value("轴承座"));
|
||||
.andExpect(jsonPath("$.data.items[0].modelName").value("轴承座"))
|
||||
.andExpect(jsonPath("$.data.items[0].thumbnailPath").value("thumbnails/015133.svg"));
|
||||
|
||||
verify(cadModelService).listModels(new CadModelListQuery(2, 5, 7L, "success", "轴承"));
|
||||
}
|
||||
@@ -73,6 +75,7 @@ class CadModelControllerTest {
|
||||
.andExpect(jsonPath("$.code").value("OK"))
|
||||
.andExpect(jsonPath("$.data.id").value(1))
|
||||
.andExpect(jsonPath("$.data.modelName").value("连接板"))
|
||||
.andExpect(jsonPath("$.data.thumbnailPath").value("thumbnails/015133.svg"))
|
||||
.andExpect(jsonPath("$.data.createdAt").value("2026-07-08 10:00:00"));
|
||||
}
|
||||
|
||||
@@ -118,6 +121,7 @@ class CadModelControllerTest {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
@@ -138,7 +142,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 +162,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) {
|
||||
@@ -160,6 +177,7 @@ class CadModelControllerTest {
|
||||
"sw/015133.sldprt",
|
||||
"features/015133.json",
|
||||
"views/015133",
|
||||
"thumbnails/015133.svg",
|
||||
"工业夹具",
|
||||
"铝合金",
|
||||
"CNC",
|
||||
|
||||
@@ -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;
|
||||
@@ -55,6 +56,7 @@ class CadAnnotationServiceIntegrationTest {
|
||||
source_sw_path VARCHAR(1024) NOT NULL,
|
||||
feature_tree_json_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
view_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
thumbnail_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
application_scenario VARCHAR(255) NOT NULL DEFAULT '',
|
||||
material VARCHAR(255) NOT NULL DEFAULT '',
|
||||
manufacturing_method VARCHAR(255) NOT NULL DEFAULT '',
|
||||
@@ -181,6 +183,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("""
|
||||
@@ -46,6 +47,7 @@ class CadModelServiceIntegrationTest {
|
||||
source_sw_path VARCHAR(1024) NOT NULL,
|
||||
feature_tree_json_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
view_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
thumbnail_path VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
application_scenario VARCHAR(255) NOT NULL DEFAULT '',
|
||||
material VARCHAR(255) NOT NULL DEFAULT '',
|
||||
manufacturing_method VARCHAR(255) NOT NULL DEFAULT '',
|
||||
@@ -59,6 +61,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,
|
||||
@@ -146,6 +161,7 @@ class CadModelServiceIntegrationTest {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"partial_failed",
|
||||
null
|
||||
);
|
||||
@@ -180,6 +196,7 @@ class CadModelServiceIntegrationTest {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"done",
|
||||
null
|
||||
)))
|
||||
@@ -200,6 +217,7 @@ class CadModelServiceIntegrationTest {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)))
|
||||
.isInstanceOf(BizException.class)
|
||||
@@ -211,12 +229,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,141 @@
|
||||
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.importDataset(
|
||||
sourceDirectory, artifactsDirectory, "dataset-test", true, 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");
|
||||
assertThat(modelCaptor.getValue().getThumbnailPath()).isEqualTo("dataset-test/demo/thumbnail.svg");
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user