新增标注导出和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
+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;
});