修改图片预览、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}` : '数据库中没有模型');
@@ -27,6 +27,9 @@ public class JsonDatasetImportProperties {
/** 是否由 Java 导入器上传对象;外部已上传时关闭。 */
private boolean uploadEnabled = true;
/** 是否清空现有标注数据;关闭时仅允许追加不存在的新模型。 */
private boolean replaceExisting = true;
/** 导入模型归属的分类 ID。 */
private long categoryId = 1L;
}
@@ -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,
@@ -32,6 +32,9 @@ public class CadModel {
/** 模型视图资源路径。 */
private String viewPath;
/** 模型预览图路径。 */
private String thumbnailPath;
/** 应用场景。 */
private String applicationScenario;
@@ -12,7 +12,7 @@ import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* 显式启用后,全量替换标注后台中的模型数据
* 显式启用后,将处理完成的 JSON 数据集导入标注后台
*/
@Slf4j
@Component
@@ -33,13 +33,14 @@ public class JsonDatasetImportRunner implements ApplicationRunner {
}
Path sourceDirectory = Path.of(properties.getSourceDirectory()).toAbsolutePath().normalize();
Path artifactsDirectory = Path.of(properties.getArtifactsDirectory()).toAbsolutePath().normalize();
log.warn("Starting destructive CAD JSON dataset import. sourceDirectory={}, artifactsDirectory={}, categoryId={}",
sourceDirectory, artifactsDirectory, properties.getCategoryId());
JsonDatasetImportService.ImportSummary summary = jsonDatasetImportService.replaceDataset(
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={}",
@@ -3,12 +3,12 @@ package com.cadlabel.service;
import java.nio.file.Path;
/**
* 将 SolidWorks 特征树 JSON 数据集替换导入标注后台。
* 将 SolidWorks 特征树 JSON 数据集导入标注后台。
*/
public interface JsonDatasetImportService {
ImportSummary replaceDataset(Path sourceDirectory, Path artifactsDirectory, String cosPrefix,
boolean uploadEnabled, long categoryId);
ImportSummary importDataset(Path sourceDirectory, Path artifactsDirectory, String cosPrefix,
boolean uploadEnabled, boolean replaceExisting, long categoryId);
record ImportSummary(int modelCount, int historyCount) {
}
@@ -194,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);
@@ -229,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());
}
@@ -266,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());
@@ -314,6 +319,7 @@ public class CadModelServiceImpl implements CadModelService {
model.getSourceSwPath(),
model.getFeatureTreeJsonPath(),
model.getViewPath(),
model.getThumbnailPath(),
model.getApplicationScenario(),
model.getMaterial(),
model.getManufacturingMethod(),
@@ -1,5 +1,6 @@
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;
@@ -29,7 +30,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
/** 将 Node 后端完成重建的 SolidWorks 数据集上传 COS 并替换远程标注数据。 */
/** 将 Node 后端完成重建的 SolidWorks 数据集上传对象存储并导入远程标注数据。 */
@Slf4j
@Service
@RequiredArgsConstructor
@@ -47,8 +48,8 @@ public class SolidWorksJsonDatasetImportService implements JsonDatasetImportServ
@Override
@Transactional
public ImportSummary replaceDataset(Path sourceDirectory, Path artifactsDirectory, String cosPrefix,
boolean uploadEnabled, long categoryId) {
public ImportSummary importDataset(Path sourceDirectory, Path artifactsDirectory, String cosPrefix,
boolean uploadEnabled, boolean replaceExisting, long categoryId) {
if (categoryId <= 0) {
throw new IllegalArgumentException("categoryId 必须为正数");
}
@@ -57,6 +58,9 @@ public class SolidWorksJsonDatasetImportService implements JsonDatasetImportServ
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) {
@@ -64,7 +68,9 @@ public class SolidWorksJsonDatasetImportService implements JsonDatasetImportServ
}
}
if (replaceExisting) {
clearExistingAnnotationData();
}
int historyCount = 0;
for (ImportedModel importedModel : models) {
CadModel model = createModel(importedModel, cosPrefix, categoryId);
@@ -121,7 +127,10 @@ public class SolidWorksJsonDatasetImportService implements JsonDatasetImportServ
Path versionRoot = manifestPath.getParent().getParent();
JsonNode sourceRoot = readJson(versionRoot.resolve("source.feature-tree.json"));
String partName = requireText(text(sourceRoot, "part_name"), "转换产物缺少 part_name: " + versionRoot);
if (!sourceFiles.containsKey(partName) || !partNames.add(partName)) {
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",
@@ -156,6 +165,20 @@ public class SolidWorksJsonDatasetImportService implements JsonDatasetImportServ
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())) {
@@ -194,6 +217,7 @@ public class SolidWorksJsonDatasetImportService implements JsonDatasetImportServ
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("");
+1
View File
@@ -53,6 +53,7 @@ app:
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:
@@ -60,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", "轴承"));
}
@@ -74,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"));
}
@@ -119,6 +121,7 @@ class CadModelControllerTest {
null,
null,
null,
null,
null
));
}
@@ -174,6 +177,7 @@ class CadModelControllerTest {
"sw/015133.sldprt",
"features/015133.json",
"views/015133",
"thumbnails/015133.svg",
"工业夹具",
"铝合金",
"CNC",
@@ -56,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 '',
@@ -47,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 '',
@@ -160,6 +161,7 @@ class CadModelServiceIntegrationTest {
null,
null,
null,
null,
"partial_failed",
null
);
@@ -194,6 +196,7 @@ class CadModelServiceIntegrationTest {
null,
null,
null,
null,
"done",
null
)))
@@ -214,6 +217,7 @@ class CadModelServiceIntegrationTest {
null,
null,
null,
null,
null
)))
.isInstanceOf(BizException.class)
@@ -104,8 +104,8 @@ class SolidWorksJsonDatasetImportServiceTest {
return 1;
}).when(cadModelMapper).insert(any(CadModel.class));
JsonDatasetImportService.ImportSummary summary = service.replaceDataset(
sourceDirectory, artifactsDirectory, "dataset-test", true, 7L);
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);
@@ -115,6 +115,7 @@ class SolidWorksJsonDatasetImportServiceTest {
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());