import requests import time import os import platform import shutil import json import shlex import subprocess import sys import signal from concurrent.futures import ThreadPoolExecutor, as_completed from .oss_redis import ossClient from .logs import log from .oss_func import download_file_with_check, checkFileExists from .changeFiles import changeObjFile, changeMtlFile from .small_machine_transform import transform_save ENV = 'prod' url = 'https://mp.api.suwa3d.com' if ENV == 'dev': url = 'http://mp.api.dev.com' elif ENV == 'prod': url = 'https://mp.api.suwa3d.com' def get_resource_path(relative_path): """ 获取资源文件的绝对路径,兼容 PyInstaller 打包后的环境 参数: relative_path: 相对于脚本目录的相对路径 返回: 资源文件的绝对路径 """ try: # PyInstaller 创建临时文件夹,并将路径存储在 _MEIPASS 中 base_path = sys._MEIPASS except AttributeError: # 如果不是打包环境,使用脚本所在目录 base_path = os.path.dirname(os.path.abspath(__file__)) return os.path.join(base_path, relative_path) def get_transform_script_path(): """ 获取 small_machine_transform.py 脚本的路径 在打包成exe后,如果脚本不在可访问位置,则将其提取到临时目录 返回: transform_script_path: 脚本的绝对路径 """ # 首先尝试从资源路径获取 if getattr(sys, 'frozen', False): # 打包后的环境 # 尝试从资源路径获取 resource_path = get_resource_path('utils/small_machine_transform.py') # 如果资源路径存在,直接使用 if os.path.exists(resource_path): return resource_path # 如果不存在,尝试提取到临时目录 import tempfile temp_dir = tempfile.gettempdir() extract_path = os.path.join(temp_dir, 'factory_sliceing_utils', 'small_machine_transform.py') # 确保目录存在 os.makedirs(os.path.dirname(extract_path), exist_ok=True) # 如果提取路径不存在,尝试从资源中复制 if not os.path.exists(extract_path): # 尝试从多个可能的资源路径查找 possible_paths = [ get_resource_path('small_machine_transform.py'), get_resource_path('utils/small_machine_transform.py'), ] for src_path in possible_paths: if os.path.exists(src_path): shutil.copy2(src_path, extract_path) log(f"已将 small_machine_transform.py 提取到: {extract_path}") return extract_path # 如果提取路径存在,直接使用 if os.path.exists(extract_path): return extract_path # 如果都找不到,返回资源路径(即使不存在,让调用者处理错误) return resource_path else: # 开发环境,直接使用相对路径 script_dir = os.path.dirname(os.path.abspath(__file__)) return os.path.join(script_dir, 'small_machine_transform.py') # 根据打印ID 获取下载目录 def getDownloadDirByPrintId(printIds): # 调用接口获取下载的路径 api_url = f"{url}/api/printOrder/getInfoByPrintIds" res = requests.post(api_url, json={"print_ids": printIds}) res = res.json() print(f"根据打印ID 获取下载目录, res={res}") if res["code"] == 1000: return res["data"] else: return False # 根据批次id,进行切片中的状态变更 def requestApiToUpdateSliceStatus(versionId, downloadCounts): api_url = f"{url}/api/printTypeSettingOrder/updateBatchSliceing?batch_id={versionId}&download_counts=" + str(downloadCounts) log(f'发起状态变更请求url={api_url}, versionId={versionId}') try: # 添加超时参数,防止请求时间过长 res = requests.post(api_url, timeout=60) # 60秒超时 if res.status_code != 200: log(f'状态变更请求失败, res={res.text}') return False log(f'状态变更请求成功, res={res.text}') #判断返回的code是否是1000 if res.json()["code"] != 1000: log(f'状态变更请求失败, res={res.text}') return False log(f'状态变更请求成功, res={res.text}') return True except requests.exceptions.Timeout: log(f'状态变更请求超时, url={api_url}') return False except requests.exceptions.RequestException as e: log(f'状态变更请求异常, error={str(e)}') return False # 判断是否上传了 JSON 文件 def checkJsonFileExists(versionId): log(f"检测文件和图片是否存在, versionId={versionId}") jsonFilePath = f'batchPrint/{versionId}/{versionId}.json' # 判断oss 上是否存在 jpgFilePath = f'batchPrint/{versionId}/{versionId}.jpg' if not ossClient().object_exists(jsonFilePath): log(f'JSON文件不存在: {jsonFilePath}') return False, False if not ossClient().object_exists(jpgFilePath): log(f'JPG文件不存在: {jpgFilePath}') return False, False log(f"文件和图片检测成功,存在, versionId={versionId}") return jsonFilePath, jpgFilePath # 检测本地文件是否存在 def checkLocalFileExists(localFilePath): if not os.path.exists(localFilePath): return False return True # 读取JSON文件内容 def readJsonFile(localFilePath): with open(localFilePath, 'r', encoding='utf-8') as f: jsonData = json.load(f) return jsonData # 根据批次ID,获取批次信息 def getBatchInfo(versionId): url1 = f"{url}/api/printTypeSettingOrder/getBatchInfoAndPrintMachineInfoByBatchId?batch_id={versionId}" res = requests.get(url1) res = res.json() log(f"获取批次信息和打印机信息, url={url1}, res={res}") if res["code"] == 1000: return res["data"] else: return False # 下载文件,读取文件内容,并且文件迁移至正确的目录里,不再放在临时目录里 def downloadJsonAndJpgFileAndMoveToCorrectDir(versionId, currentDir): jsonFilePath, jpgFilePath = checkJsonFileExists(versionId) if jsonFilePath == False or jpgFilePath == False: return False, False # 将文件下载到临时目录,待会要判断是否什么类型机型 tempDir = os.path.join(currentDir, 'batchPrint', 'temp', versionId) if not os.path.exists(tempDir): os.makedirs(tempDir) localFilePath = os.path.join(tempDir, f'{versionId}.json') # 使用带完整性校验的下载方法下载JSON文件 ok = download_file_with_check(jsonFilePath, localFilePath) if not ok: log(f"JSON 文件下载失败或不完整, versionId={versionId}") return False, False # 下载JPG文件 localJpgFilePath = os.path.join(tempDir, f'{versionId}.jpg') ok = download_file_with_check(jpgFilePath, localJpgFilePath) if not ok: log(f"JPG 文件下载失败或不完整, versionId={versionId}") return False, False # 根据批次ID,获取批次信息和打印机信息 batchMachineInfo = getBatchInfo(versionId) if not batchMachineInfo: log(f"获取批次信息和打印机信息失败, versionId={versionId}") return False, False # "batch_info": batchInfo, # "print_machine_info": printMachineInfo, machineInfo = batchMachineInfo["print_machine_info"] print(f"machineInfo={machineInfo['id']}") dirNewName = "" if str(machineInfo["machine_type"]) == '1': dirNewName = os.path.join(currentDir, 'batchPrint', versionId + '_small_No' + str(machineInfo['id'])) else: dirNewName = os.path.join(currentDir, 'batchPrint', versionId + '_big_No' + str(machineInfo['id'])) # 判断目录是否存在,存在就删除 if os.path.exists(dirNewName): shutil.rmtree(dirNewName) # 创建目录 os.makedirs(dirNewName) # 创建json子目录 jsonSubDir = os.path.join(dirNewName, 'json') if not os.path.exists(jsonSubDir): os.makedirs(jsonSubDir) dataSubDir = os.path.join(dirNewName, 'data') if not os.path.exists(dataSubDir): os.makedirs(dataSubDir) # 将数据移动过来 shutil.move(localFilePath, os.path.join(jsonSubDir, '3DPrintLayout.json')) shutil.move(localJpgFilePath, os.path.join(jsonSubDir, f'{versionId}.jpg')) #如果是大机台,将 json 文件复制到 data 目录下 if str(machineInfo["machine_type"]) != '1': shutil.copy(os.path.join(jsonSubDir, '3DPrintLayout.json'), os.path.join(dataSubDir, '3DPrintLayout.json')) if not os.path.exists(os.path.join(dataSubDir, '3DPrintLayout.json')): log(f"JSON文件不存在, dataSubDir={dataSubDir}") return False, False # 检测文件是否移动成功 if not os.path.exists(os.path.join(jsonSubDir, '3DPrintLayout.json')): log(f"JSON文件不存在, versionId={versionId}") return False, False if not os.path.exists(os.path.join(jsonSubDir, f'{versionId}.jpg')): log(f"JPG文件不存在, versionId={versionId}") return False, False log(f"文件移动成功, versionId={versionId}") # 返回目录路径 return dirNewName, machineInfo # 整合json文件,读取对应的数据,返回数据结构 def getJsonData(dirNewName): jsonFilePath = os.path.join(dirNewName, 'json', '3DPrintLayout.json') if not os.path.exists(jsonFilePath): log(f"JSON文件不存在, dirNewName={dirNewName}") return False # 读取JSON文件内容 jsonData = readJsonFile(jsonFilePath) # 读取models models = jsonData.get('models', []) if not models: log(f"models不存在, dirNewName={dirNewName}") return False listData = [] # 遍历models for model in models: file_name = model.get('file_name', '') # 分割数据 arrFileName = file_name.split('_') orderId = arrFileName[0] pid = arrFileName[1] printId = arrFileName[2].replace("P", "") size = arrFileName[3] counts = arrFileName[4].replace("x", "").replace(".obj", "") # 检测这些数据 if not orderId or not pid or not printId or not size or not counts: log(f"数据不完整, orderId={orderId}, pid={pid}, printId={printId}, size={size}, counts={counts}") return False # 创建数据结构 modelInfo = { "orderId": orderId, "printId": printId, "pid": pid, "size": size, "counts": counts, "file_name": file_name, } listData.append(modelInfo) # 检测数据长度 if len(listData) == 0: log(f"数据长度为0, dirNewName={dirNewName}") return False # 返回数据结构 return listData # 读取 json 文件,获取 homo_matrix 数据,根据 file_name 获取 homo_matrix 数据 def getHomoMatrixByFileName(dirNewName, fileName): jsonFilePath = os.path.join(dirNewName, 'json', '3DPrintLayout.json') if not os.path.exists(jsonFilePath): log(f"JSON文件不存在, dirNewName={dirNewName}") return False # 读取JSON文件内容 jsonData = readJsonFile(jsonFilePath) if not jsonData: log(f"读取JSON文件内容失败, dirNewName={dirNewName}") return False for model in jsonData["models"]: #log(f"jsonData={model['file_name']} == {fileName}") if model["file_name"] == fileName: log(f"model={model['transform']}") return model["transform"]["homo_matrix"] return False # 处理单个数据项:下载文件、修改关联路径、转换数据(如果是小机台) def _process_single_item(v, arrDownloadPath, dirNewName, dirPath, isSmallMachine): """ 处理单个数据项的函数,用于多线程处理 参数: v: 单个数据项 arrDownloadPath: 下载路径数组 dirNewName: 目录名称 dirPath: 数据目录路径 isSmallMachine: 是否是小机台 返回: (success: bool, error_msg: str) """ try: # 查找匹配的下载路径信息 info = {} for tempv in arrDownloadPath: if str(tempv["print_order_id"]) == str(v["printId"]): info = tempv break if not info: return False, f"未找到匹配的下载路径信息, printId={v['printId']}" filePath = info["path"] pid = info["pid"] orderId = info["order_id"] printId = info["print_order_id"] size = info["real_size"] counts = info["quantity"] fileName = v["file_name"] # 判断文件是否存在 ossJpgFilePath = f"{filePath}/printId_{printId}Tex1.jpg" if not checkFileExists(ossJpgFilePath): ossJpgFilePath = f"{filePath}/{pid}Tex1.jpg" localJpgName = os.path.join(f"{orderId}_{pid}Tex1.jpg") loaclMtlName = os.path.join(f"{orderId}_{pid}.mtl") localObjName = fileName arrDownloadFiles = [ {"ossPath": ossJpgFilePath, "localPath": os.path.join(dirPath, localJpgName)}, {"ossPath": f"{filePath}/{pid}.obj", "localPath": os.path.join(dirPath, localObjName)}, {"ossPath": f"{filePath}/{pid}.mtl", "localPath": os.path.join(dirPath, loaclMtlName)}, ] # 遍历下载文件 beginTime = time.time() objsLocal = "" for objFiles in arrDownloadFiles: downloadBeginTime = time.time() # 判断 mtl 和 jpg 文件是否存在,存在就不在下载了 if "mtl" in objFiles["localPath"] or "jpg" in objFiles["localPath"]: if os.path.exists(objFiles["localPath"]): continue downloadOk = download_file_with_check(objFiles["ossPath"], objFiles["localPath"]) if not downloadOk: error_msg = f"下载文件失败, ossPath={objFiles['ossPath']}, localPath={objFiles['localPath']}" log(error_msg) return False, error_msg log(f"下载文件耗时: {time.time() - downloadBeginTime}秒 - {objFiles['localPath']}") # 下载成功之后要修改文件之间的关联路径 if objFiles["localPath"].endswith(".obj"): beginChangeTime = time.time() objsLocal = objFiles["localPath"] changeObjFile(objFiles["localPath"], f"{orderId}_{pid}.mtl") log(f"修改obj关联耗时: {time.time() - beginChangeTime}秒 - {objFiles['localPath']}") if objFiles["localPath"].endswith(".mtl"): changeMtlFile(objFiles["localPath"], f"{orderId}_{pid}Tex1.jpg") endTime = time.time() log(f"下载文件和修改文件之间的关联路径 : 耗时{endTime - beginTime}秒 - {fileName}") # 如果是小机台,则要转换数据 if isSmallMachine: timeBegin = time.time() homo_matrix = getHomoMatrixByFileName(dirNewName, localObjName) if not homo_matrix: error_msg = f"获取homo_matrix失败, dirNewName={dirNewName}, objsLocal={objsLocal}" log(error_msg) return False, error_msg # 通过blender 调用执行 python 文件 blender_bin_path = findBpyModule() # 获取 small_machine_transform.py 的绝对路径(兼容打包环境) transform_script_path = get_transform_script_path() # 将 homo_matrix 转换为 JSON 字符串 homo_matrix_json = json.dumps(homo_matrix) # 检查必要文件是否存在 if not blender_bin_path or not os.path.exists(blender_bin_path): error_msg = f"Blender 可执行文件不存在: {blender_bin_path}" log(error_msg) return False, error_msg if not os.path.exists(transform_script_path): error_msg = f"转换脚本文件不存在: {transform_script_path}" log(error_msg) return False, error_msg if not os.path.exists(objsLocal): error_msg = f"OBJ 文件不存在: {objsLocal}" log(error_msg) return False, error_msg # 构建命令参数列表(使用列表形式,避免 shell 转义问题) cmd = [ blender_bin_path, '--background', '--python', transform_script_path, '--', f'--objPathName={objsLocal}', f'--trans={homo_matrix_json}' ] log(f"准备执行 Blender 命令") log(f" Blender路径: {blender_bin_path}") log(f" 脚本路径: {transform_script_path}") log(f" OBJ文件: {objsLocal}") log(f" 完整命令: {' '.join(cmd[:4])} ... (参数已隐藏)") try: log(f"开始执行 Blender 转换命令...") # 使用 subprocess.Popen 以便更好地控制进程和信号处理 # 在 Windows 上,Blender 输出可能是 UTF-8,需要指定编码并处理错误 # 准备进程启动参数 startupinfo = None preexec_fn = None if platform.system() == 'Windows': # Windows 上,不设置 CREATE_NEW_PROCESS_GROUP,以便 Ctrl+C 可以传播 import subprocess as sp startupinfo = sp.STARTUPINFO() startupinfo.dwFlags |= sp.STARTF_USESHOWWINDOW startupinfo.wShowWindow = sp.SW_HIDE else: # Unix 系统,设置 preexec_fn 以确保信号处理正确 preexec_fn = os.setsid process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8', # 指定 UTF-8 编码 errors='replace', # 遇到无法解码的字符时替换为占位符,而不是抛出异常 startupinfo=startupinfo, preexec_fn=preexec_fn ) try: # 等待进程完成,捕获输出 stdout, stderr = process.communicate(timeout=300) # 5分钟超时 returncode = process.returncode except subprocess.TimeoutExpired: # 超时,终止进程 log(f"Blender 命令执行超时,正在终止进程...") if platform.system() == 'Windows': process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() else: # Unix 系统,发送 SIGTERM 到进程组 try: os.killpg(os.getpgid(process.pid), signal.SIGTERM) process.wait(timeout=5) except (subprocess.TimeoutExpired, ProcessLookupError, OSError): try: os.killpg(os.getpgid(process.pid), signal.SIGKILL) except (ProcessLookupError, OSError): pass raise except KeyboardInterrupt: # 收到中断信号,终止进程 log(f"收到中断信号,正在终止 Blender 进程...") if platform.system() == 'Windows': process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() else: # Unix 系统,发送 SIGTERM 到进程组 try: os.killpg(os.getpgid(process.pid), signal.SIGTERM) process.wait(timeout=5) except (subprocess.TimeoutExpired, ProcessLookupError, OSError): try: os.killpg(os.getpgid(process.pid), signal.SIGKILL) except (ProcessLookupError, OSError): pass raise # 重新抛出,让调用者处理 # 创建结果对象(模拟 subprocess.run 的返回值) class Result: def __init__(self, returncode, stdout, stderr): self.returncode = returncode self.stdout = stdout self.stderr = stderr result = Result(returncode, stdout, stderr) log(f"Blender 命令执行完成, 返回码: {result.returncode}") # 无论成功失败都输出详细信息 if result.stdout: log(f"Blender stdout 输出: {result.stdout}") if result.stderr: log(f"Blender stderr 输出: {result.stderr}") if result.returncode != 0: error_output = result.stderr if result.stderr else result.stdout error_msg = f"调用blender 执行 python 文件失败, 返回码={result.returncode}, fileName={fileName}" if error_output: error_msg += f", 错误信息: {error_output[:10000000]}" # 增加错误信息长度限制 log(error_msg) return False, error_msg log(f"调用blender 执行 python 文件成功, fileName={fileName}") except subprocess.TimeoutExpired: error_msg = f"调用blender 执行超时(超过5分钟), fileName={fileName}" log(error_msg) return False, error_msg except Exception as e: error_msg = f"调用blender 执行时发生异常: {str(e)}, fileName={fileName}" log(error_msg) return False, error_msg timeEnd = time.time() log(f"转换数据时间: 耗时{timeEnd - timeBegin}秒 - {objsLocal}") return True, "" except Exception as e: error_msg = f"处理数据项时发生异常, fileName={v.get('file_name', 'unknown')}, error={str(e)}" log(error_msg) return False, error_msg # json文件进行下载对应的数据 和转换数据,传递目录路径 def downloadDataByOssAndTransformSave(dirNewName, isSmallMachine=False, max_workers=1): listData = getJsonData(dirNewName) if not listData: log(f"获取数据失败, dirNewName={dirNewName}") return False # 遍历数据 arrPrintId = [] arrPrintDataInfo = [] for modelInfo in listData: arrPrintId.append(modelInfo.get('printId')) arrPrintDataInfo.append({ "printId": modelInfo.get('printId'), "file_name": modelInfo.get('file_name'), }) # 调用接口获取下载的路径 arrDownloadPath = getDownloadDirByPrintId(arrPrintId) if not arrDownloadPath: log(f"获取下载路径失败, arrPrintId={arrPrintId}") return False dirPath = os.path.join(dirNewName, 'data') if not os.path.exists(dirPath): os.makedirs(dirPath) # 使用多线程并发处理数据 log(f"开始多线程处理数据, 共{len(listData)}个项目, 线程数={max_workers}") beginTime = time.time() # 使用线程池并发处理 try: with ThreadPoolExecutor(max_workers=max_workers) as executor: # 提交所有任务 future_to_item = { executor.submit(_process_single_item, v, arrDownloadPath, dirNewName, dirPath, isSmallMachine): v for v in listData } # 收集结果 success_count = 0 fail_count = 0 try: for future in as_completed(future_to_item): v = future_to_item[future] try: success, error_msg = future.result() if success: success_count += 1 log(f"处理成功: {v.get('file_name', 'unknown')} ({success_count}/{len(listData)})") else: fail_count += 1 log(f"处理失败: {v.get('file_name', 'unknown')}, 错误: {error_msg} ({fail_count}/{len(listData)})") # 如果任何一个任务失败,记录错误但继续处理其他任务 except KeyboardInterrupt: # 收到中断信号,取消所有未完成的任务 log(f"收到中断信号,正在取消未完成的任务...") for f in future_to_item: f.cancel() raise # 重新抛出,让外层捕获 except Exception as e: fail_count += 1 error_msg = f"处理数据项时发生异常: {str(e)}" log(f"处理异常: {v.get('file_name', 'unknown')}, 错误: {error_msg} ({fail_count}/{len(listData)})") except KeyboardInterrupt: # 收到中断信号,取消所有未完成的任务 log(f"收到中断信号,正在取消未完成的任务...") for f in future_to_item: f.cancel() # 尝试等待正在执行的任务完成(非阻塞方式) import concurrent.futures for f in list(future_to_item.keys()): if not f.done(): try: # 尝试获取结果,如果任务还在执行则立即返回 f.result(timeout=0.1) except (concurrent.futures.TimeoutError, concurrent.futures.CancelledError): pass except: pass raise # 重新抛出,让外层捕获 endTime = time.time() log(f"多线程处理完成, 总耗时{endTime - beginTime}秒, 成功:{success_count}, 失败:{fail_count}, 总计:{len(listData)}") # 如果有任何失败,返回 False if fail_count > 0: log(f"部分任务处理失败,共{fail_count}个失败") return False return True except KeyboardInterrupt: log(f"多线程处理被中断") raise # 重新抛出,让调用者处理 def findBpyModule(): # 返回 Blender 可执行文件路径 blender_bin_path = '/Applications/Blender.app/Contents/MacOS/Blender' # 判断当前是 windows 还是 macOS if platform.system() == 'Windows': # Windows 上常见的 Blender 安装路径 possible_paths = [ 'C:\\Program Files\\Blender Foundation\\Blender 5.0\\blender.exe', 'C:\\Program Files\\Blender Foundation\\Blender 4.4\\blender.exe', 'C:\\Program Files\\Blender Foundation\\Blender 4.3\\blender.exe', 'C:\\Program Files\\Blender Foundation\\Blender 4.2\\blender.exe', 'C:\\Program Files\\Blender Foundation\\Blender 4.1\\blender.exe', 'C:\\Program Files\\Blender Foundation\\Blender 4.0\\blender.exe', ] # 查找存在的路径 for path in possible_paths: if os.path.exists(path): blender_bin_path = path break else: # 如果都没找到,使用默认路径 blender_bin_path = 'C:\\Program Files\\Blender Foundation\\Blender 5.0\\blender.exe' log(f"警告: 未找到 Blender 可执行文件,使用默认路径: {blender_bin_path}") else: blender_bin_path = '/Applications/Blender.app/Contents/MacOS/Blender' # 检查路径是否存在 if not os.path.exists(blender_bin_path): error_msg = f"Blender 可执行文件不存在: {blender_bin_path}" log(error_msg) raise FileNotFoundError(error_msg) return blender_bin_path