修改图片预览、json导出

This commit is contained in:
2026-07-15 12:28:51 +08:00
parent 17964d4ee7
commit 5aaabd4270
14 changed files with 273 additions and 37 deletions
+208 -22
View File
@@ -3,11 +3,15 @@ import path from 'node:path';
import process from 'node:process';
const API_BASE = process.env.CADLABEL_API_BASE ?? 'http://127.0.0.1:8891/api';
const SOURCE_ROOT = path.resolve(process.cwd(), process.env.CADLABEL_SOURCE_ROOT ?? '../output_json 2');
const DEFAULT_SOURCE_ROOT = fs.existsSync(path.resolve(process.cwd(), '../json_data/original'))
? '../json_data/original'
: '../output_json 2';
const SOURCE_ROOT = path.resolve(process.cwd(), process.env.CADLABEL_SOURCE_ROOT ?? DEFAULT_SOURCE_ROOT);
const APPLY = process.argv.includes('--apply');
const FORCE = process.argv.includes('--force');
const onlyModel = valueAfter('--model');
const MARKER = '[AI批量分析:v1]';
const API_TIMEOUT_MS = Number(process.env.CADLABEL_API_TIMEOUT_MS ?? 120_000);
const CLASS_DEFINITIONS = [
{
@@ -141,7 +145,7 @@ async function api(relativePath, init) {
'Content-Type': 'application/json',
...(init?.headers ?? {})
},
signal: AbortSignal.timeout(30_000)
signal: AbortSignal.timeout(API_TIMEOUT_MS)
});
const payload = await response.json();
if (!response.ok || payload.code !== 'OK') {
@@ -154,8 +158,78 @@ function post(relativePath, body) {
return api(relativePath, { method: 'POST', body: JSON.stringify(body) });
}
function classFor(modelName) {
return CLASS_DEFINITIONS.find((item) => item.ids.includes(modelName)) ?? {
function classFor(modelName, geometry) {
const configured = CLASS_DEFINITIONS.find((item) => item.ids.includes(modelName));
if (configured) return configured;
if (modelName.includes('方板+均布圆周孔')) {
return {
label: '圆周孔方形安装板',
scene: '方形安装基面上的圆周均布紧固、定位和载荷分散',
primaryFunction: '方板承载与圆周孔系连接'
};
}
if (modelName.includes('方板+四角孔')) {
return {
label: '多孔方形安装板',
scene: '方形安装基面上的四角紧固、中心定位和附加接口连接',
primaryFunction: '方板承载与多点安装'
};
}
if (modelName.includes('方台')) {
return {
label: geometry.interfaceCount ? '多孔方形安装台' : '方形承载台',
scene: '小型机械组件的平面承载、垫高、定位和紧固安装',
primaryFunction: '方台承载与安装定位'
};
}
if (modelName.includes('圆盘+均布孔')) {
return {
label: '圆周孔圆形安装盘',
scene: '圆形安装面上的周向均布紧固和同轴定位',
primaryFunction: '圆盘承载与圆周孔系连接'
};
}
if (modelName.includes('圆盘')) {
return {
label: '圆形承载盘',
scene: '圆形端面的承载、间隔和同轴安装',
primaryFunction: '圆盘承载与端面定位'
};
}
const sequenceNumber = /^\d+$/.test(modelName) ? Number(modelName) : null;
if (sequenceNumber !== null && sequenceNumber >= 40 && sequenceNumber <= 51) {
return {
label: '多孔圆形法兰盘',
scene: '中心接口与周向孔系组合的法兰连接和同轴定位',
primaryFunction: '圆形法兰连接与孔系定位'
};
}
if (sequenceNumber !== null && sequenceNumber >= 52 && sequenceNumber <= 58) {
return {
label: '双耳异形连接板',
scene: '双圆头或长圆形接口之间的跨距连接、铰接和定位',
primaryFunction: '双接口跨距连接与载荷传递'
};
}
if (sequenceNumber !== null && sequenceNumber >= 59 && sequenceNumber <= 66) {
return {
label: '多孔异形安装板',
scene: '多规格孔位的组合安装、定位和多点紧固',
primaryFunction: '异形板承载与多孔连接'
};
}
if (sequenceNumber !== null && sequenceNumber >= 67) {
return {
label: '复合孔系圆形法兰盘',
scene: '多圈、多规格孔系的法兰连接、精密定位和装配适配',
primaryFunction: '复合孔系法兰连接'
};
}
return geometry.outerCircular ? {
label: '多孔圆形安装盘',
scene: '圆形安装面上的承载、周向紧固和同轴定位',
primaryFunction: '圆盘承载与多孔连接'
} : {
label: '机械安装连接件',
scene: '机械组件的承载、定位和紧固连接',
primaryFunction: '机械承载与装配连接'
@@ -177,6 +251,54 @@ function boundingBox(source) {
return dimensions.map(formatNumber);
}
function geometryAnalysis(source) {
const box = source.validation_hints?.part_box_m;
const size = Array.isArray(box) && box.length === 6
? [box[3] - box[0], box[4] - box[1], box[5] - box[2]].map((value) => value * 1000)
: [];
const maxSpan = size.length ? Math.max(...size) : 0;
const faces = (source.features ?? []).flatMap((feature) => feature.owned_faces ?? []);
const cylinders = faces
.filter((face) => face.surface?.is_cylinder && Array.isArray(face.surface.cylinder_params))
.map((face) => {
const params = face.surface.cylinder_params;
const center = params.slice(0, 3).map((value) => value * 1000);
const axis = params.slice(3, 6);
const axisIndex = axis.reduce(
(best, value, index) => Math.abs(value) > Math.abs(axis[best] ?? 0) ? index : best,
0
);
const radialDistance = Math.hypot(...center.filter((_, index) => index !== axisIndex));
return { radius: Math.abs(params[6] * 1000), center, radialDistance };
});
const outerCircular = cylinders.some((item) =>
maxSpan > 0 && item.radius >= maxSpan * 0.4 && item.radialDistance <= maxSpan * 0.08
);
const interfaces = cylinders.filter((item) =>
maxSpan > 0 && item.radius <= maxSpan * 0.2
);
const uniqueInterfaces = new Map();
for (const item of interfaces) {
const key = `${formatNumber(item.radius)}:${item.center.map((value) => formatNumber(value)).join(',')}`;
uniqueInterfaces.set(key, item);
}
const diameterGroups = new Map();
for (const item of uniqueInterfaces.values()) {
const diameter = formatNumber(item.radius * 2);
diameterGroups.set(diameter, (diameterGroups.get(diameter) ?? 0) + 1);
}
const interfaceSummary = [...diameterGroups.entries()]
.sort((left, right) => Number(right[0]) - Number(left[0]))
.map(([diameter, count]) => `${count}ר${diameter} mm`)
.join('、');
return {
size,
outerCircular,
interfaceCount: uniqueInterfaces.size,
interfaceSummary
};
}
function formatNumber(value) {
if (!Number.isFinite(value)) return String(value);
return Number(value.toFixed(Math.abs(value) < 10 ? 3 : 2)).toString();
@@ -240,17 +362,22 @@ function operationIntent(step) {
return `执行建模步骤“${step.name}”,在前序几何基础上形成该步骤对应的有效几何。`;
}
function stepAnnotation(modelName, step, feature) {
function stepAnnotation(modelName, step, feature, geometry) {
const evidence = dimensionEvidence(feature, step, modelName);
const faceCount = feature?.owned_faces?.length ?? 0;
const group = GROUP_DEFINITIONS[stepGroup(step)];
const compoundProfile = step.type === 'extrude_add' && geometry.interfaceCount > 0
? `该复合草图一次拉伸同时形成主体外轮廓与 ${geometry.interfaceCount} 个圆形内轮廓。`
: '';
return {
modelingDescription: `${operationIntent(step)}该步骤在本零件中归入“${group.name}”。`,
modelingDescription: `${operationIntent(step)}${compoundProfile}该步骤在本零件中归入“${group.name}”。`,
supplementDescription: [
`重建顺序第 ${step.order} 步,绑定源特征 ${step.sourceFeatureId}`,
geometry.size.length ? `最终几何包络约为 ${geometry.size.map(formatNumber).join(' × ')} mm。` : null,
compoundProfile ? `圆形接口证据:${geometry.interfaceSummary}` : null,
evidence.length ? `关键参数证据:${evidence.join('')}` : '源特征未提供可直接解释的标量尺寸,语义依据操作类型与几何结果确定。',
faceCount ? `源特征记录 ${faceCount} 个归属面,可用于后续拓扑复核。` : '源特征未记录独占面,需结合前后步骤或最终拓扑复核。'
].join(''),
].filter(Boolean).join(''),
remark: `${MARKER} 参数来自 SolidWorks 提取结果;角度、阵列计数及孔向导深度可能采用插件编码或构造深度,不应在未核对原模型前作为公差或有效加工深度。`
};
}
@@ -279,9 +406,21 @@ function structurePayload(groupKey, steps, classInfo) {
};
}
function geometryInterfaceStructurePayload(step, geometry, classInfo) {
return {
structureName: '复合草图孔系与圆弧接口',
structureType: '孔系/圆弧轮廓结构',
purpose: '在主体成形步骤中同步建立圆形内轮廓,用于紧固、定位、穿轴或装配避让。',
solution: `由建模步骤 ${step.order}.${step.name} 的复合草图一次拉伸形成;检测到 ${geometry.interfaceCount} 个圆形接口,直径分组为 ${geometry.interfaceSummary}`,
reason: `复合草图将主体外轮廓与接口轮廓置于同一成形步骤,可保持相对位置一致;该判断同时结合“${classInfo.label}”的典型用途。`,
remark: `${MARKER} 圆形接口依据源特征归属面的圆柱几何识别;孔的用途、配合等级与加工公差需结合装配对象复核。`,
historyIds: [step.historyId]
};
}
function functionPayloads(classInfo, structures) {
const allIds = structures.map((item) => item.id);
const interfaceStructures = structures.filter((item) => ['cut', 'hole', 'thread', 'counterbore', 'pattern'].includes(item.groupKey));
const interfaceStructures = structures.filter((item) => ['cut', 'hole', 'thread', 'counterbore', 'pattern', 'geometry_interface'].includes(item.groupKey));
const edgeStructures = structures.filter((item) => item.groupKey === 'edge');
const payloads = [{
functionName: classInfo.primaryFunction,
@@ -311,12 +450,15 @@ function functionPayloads(classInfo, structures) {
return payloads;
}
function modelPayload(model, source, histories, classInfo) {
function modelPayload(model, source, histories, classInfo, geometry) {
const box = boundingBox(source);
const boxText = box ? `零件几何包络约为 ${box.join(' × ')} mm。` : '源数据未提供可解析的零件包络。';
const interfaceText = geometry.interfaceCount > 0
? `复合草图形成 ${geometry.interfaceCount} 个圆形接口,直径分组为 ${geometry.interfaceSummary}`
: '未从源特征归属面中识别出独立的小直径圆形接口。';
const sequence = histories.map((step) => `${step.order}.${step.name}(${step.type})`).join('');
const hasRevolve = histories.some((step) => step.type.includes('revolve'));
const hasHole = histories.some((step) => step.type === 'hole');
const hasHole = histories.some((step) => step.type === 'hole') || geometry.interfaceCount > 0;
const manufacturing = [
hasRevolve ? '回转表面车削或车铣复合加工' : 'CNC 铣削或轮廓加工',
hasHole ? '钻孔/扩孔/攻丝等孔系加工' : null,
@@ -326,11 +468,27 @@ function modelPayload(model, source, histories, classInfo) {
applicationScenario: classInfo.scene,
material: 'SolidWorks 源模型未指定材料;数据集建议铝合金或钢制测试件,需按载荷与环境确认',
manufacturingMethod: `${manufacturing}(依据建模特征推定,待工艺确认)`,
userRequirement: `设计一件${classInfo.label},用于${classInfo.scene}${boxText}需保留建模历史中定义的主体、孔槽、连接接口和边缘处理,并保证各接口的相对位置与重建顺序一致。`,
designDescription: `${model.modelName} 被识别为${classInfo.label}${boxText}有效建模序列为:${sequence}。主体加料特征建立承载包络,切除孔向导特征形成装配接口,阵列保证重复结构一致,倒角/圆角改善边缘装配性。材料、载荷、配合公差和制造基准未由源 JSON 完整给出,相关结论均需工程复核。`
userRequirement: `设计一件${classInfo.label},用于${classInfo.scene}${boxText}${interfaceText}需保留建模历史中定义的主体、孔槽、连接接口和边缘处理,并保证各接口的相对位置与重建顺序一致。`,
designDescription: `${model.modelName} 被识别为${classInfo.label}${boxText}${interfaceText}有效建模序列为:${sequence}。主体加料特征建立承载包络,独立切除孔向导或复合草图内轮廓形成装配接口,阵列保证重复结构一致,倒角/圆角改善边缘装配性。材料、载荷、配合公差和制造基准未由源 JSON 完整给出,相关结论均需工程复核。`
};
}
function hasText(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function isCompleteAnnotation(exportData, historyCount) {
const annotatedCount = (exportData.featureIndex ?? []).filter((item) =>
hasText(item.annotation?.modelingIntent)
&& hasText(item.annotation?.description)
&& hasText(item.annotation?.notes)
).length;
return annotatedCount === historyCount
&& (exportData.structures?.length ?? 0) > 0
&& (exportData.functions?.length ?? 0) > 0
&& (exportData.unassignedHistoryIds?.length ?? 0) === 0;
}
function hasUnmanagedAnnotation(exportData) {
const annotations = exportData.featureIndex ?? [];
const existingNotes = annotations.map((item) => item.annotation?.notes).filter(Boolean);
@@ -366,12 +524,16 @@ async function annotateModel(model) {
api(`/cad-models/${model.id}/export/annotation`)
]);
if (!histories.length) return { model: model.modelName, status: 'skipped:no_histories' };
if (!FORCE && isCompleteAnnotation(exportData, histories.length)) {
return { model: model.modelName, status: 'skipped:already_complete' };
}
if (!FORCE && hasUnmanagedAnnotation(exportData)) {
return { model: model.modelName, status: 'skipped:existing_manual_annotation' };
}
const source = sourceFor(model.modelName);
const classInfo = classFor(model.modelName);
const geometry = geometryAnalysis(source);
const classInfo = classFor(model.modelName, geometry);
const featuresById = new Map((source.features ?? []).map((feature) => [feature.id, feature]));
const normalizedHistories = histories.map((step) => ({
historyId: step.id,
@@ -381,15 +543,22 @@ async function annotateModel(model) {
type: step.operationType
}));
const groups = groupSteps(normalizedHistories);
const modelUpdate = modelPayload(model, source, normalizedHistories, classInfo);
const geometryStep = geometry.interfaceCount > 0
? normalizedHistories.find((step) => step.type === 'extrude_add')
: null;
const modelUpdate = modelPayload(model, source, normalizedHistories, classInfo, geometry);
const dryRunStructures = [...groups.keys()].map((groupKey, index) => ({ id: index + 1, groupKey }));
if (geometryStep) dryRunStructures.push({ id: dryRunStructures.length + 1, groupKey: 'geometry_interface' });
if (!APPLY) {
return {
model: model.modelName,
status: 'dry-run',
histories: histories.length,
structures: groups.size,
functions: functionPayloads(classInfo, [...groups.keys()].map((groupKey, index) => ({ id: index + 1, groupKey }))).length
structures: dryRunStructures.length,
functions: functionPayloads(classInfo, dryRunStructures).length,
circularInterfaces: geometry.interfaceCount,
interfaceSummary: geometry.interfaceSummary || undefined
};
}
@@ -397,7 +566,7 @@ async function annotateModel(model) {
for (const step of normalizedHistories) {
await post(
`/cad-models/${model.id}/histories/${step.historyId}/annotation`,
stepAnnotation(model.modelName, step, featuresById.get(step.sourceFeatureId))
stepAnnotation(model.modelName, step, featuresById.get(step.sourceFeatureId), geometry)
);
}
@@ -407,6 +576,14 @@ async function annotateModel(model) {
const saved = await ensureStructure(model.id, structurePayload(groupKey, steps, classInfo), existingStructures);
structures.push({ ...saved, groupKey });
}
if (geometryStep) {
const saved = await ensureStructure(
model.id,
geometryInterfaceStructurePayload(geometryStep, geometry, classInfo),
existingStructures
);
structures.push({ ...saved, groupKey: 'geometry_interface' });
}
const existingFunctions = new Map((exportData.functions ?? []).map((item) => [item.name, { id: item.id }]));
for (const payload of functionPayloads(classInfo, structures)) {
@@ -414,9 +591,13 @@ async function annotateModel(model) {
}
const verified = await api(`/cad-models/${model.id}/export/annotation`);
const annotatedCount = (verified.featureIndex ?? []).filter((item) => item.annotation?.modelingIntent).length;
if (annotatedCount !== histories.length || verified.unassignedHistoryIds?.length) {
throw new Error(`${model.modelName} 校验失败: annotated=${annotatedCount}/${histories.length}, unassigned=${verified.unassignedHistoryIds}`);
const annotatedCount = (verified.featureIndex ?? []).filter((item) =>
hasText(item.annotation?.modelingIntent)
&& hasText(item.annotation?.description)
&& hasText(item.annotation?.notes)
).length;
if (!isCompleteAnnotation(verified, histories.length)) {
throw new Error(`${model.modelName} 校验失败: annotated=${annotatedCount}/${histories.length}, structures=${verified.structures?.length ?? 0}, functions=${verified.functions?.length ?? 0}, unassigned=${verified.unassignedHistoryIds}`);
}
return {
model: model.modelName,
@@ -429,8 +610,13 @@ async function annotateModel(model) {
}
async function main() {
const page = await api('/cad-models?page=1&size=100');
let models = [...page.items].sort((left, right) => left.modelName.localeCompare(right.modelName));
const modelsByPage = [];
for (let pageNumber = 1; ; pageNumber += 1) {
const page = await api(`/cad-models?page=${pageNumber}&size=100`);
modelsByPage.push(...page.items);
if (modelsByPage.length >= page.total || page.items.length === 0) break;
}
let models = modelsByPage.sort((left, right) => left.modelName.localeCompare(right.modelName));
if (onlyModel) models = models.filter((model) => model.modelName === onlyModel);
if (!models.length) throw new Error(onlyModel ? `未找到模型 ${onlyModel}` : '数据库中没有模型');