#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Parasolid (.x_t / .x_b) → STEP 批量转换工具（GUI 版）
=====================================================

适用场景：
  小程序 3D 查看器不支持直接上传 .x_t/.x_b（需要 Parasolid 商业授权组件）。
  本工具利用本机 SolidWorks（内核即 Parasolid，可无损打开 XT）批量转换为 STEP，
  转换后的 STEP 可直接上传小程序。

前置条件：
  1. 本机已安装 SolidWorks（任意版本 2020+）
  2. Python 3.8+（已装 pywin32：`pip install pywin32`）

🔴 会话安全策略（2026-09-17 修订）：
  · SolidWorks 已开着 → 复用你的现有实例，**绝不隐藏界面、绝不退出 SW、绝不关闭你的文档**，
    转换结束只关闭「本工具自己打开的那一个」文档，你的工作原样不动。
  · SolidWorks 没开 → 由本工具启动，转换结束后自动回收。
  · 若检测到你有未保存的文档，日志里会明确提醒。

用法：
  双击运行，或命令行：python xt_to_step_gui.py

  界面：
    1. 点「选择文件/文件夹」→ 选中 XT 文件或包含 XT 的目录
    2. 点「开始转换」→ 自动调用 SolidWorks 批量转 STEP
    3. 转换完成后，输出目录里就是 STEP 文件，可直接上传小程序
"""

import os
import sys
import time
import threading
from pathlib import Path
from datetime import datetime

try:
    import pythoncom
    import win32com.client
    from win32com.client import Dispatch
    HAS_WIN32 = True
except ImportError:
    HAS_WIN32 = False

try:
    import tkinter as tk
    from tkinter import ttk, filedialog, messagebox, scrolledtext
    HAS_TK = True
except ImportError:
    HAS_TK = False


# ---------------------------------------------------------------------------
# SolidWorks 枚举常量
# 2026-09-17 依据官方帮助逐条核实，**不要再凭记忆改动**：
# https://help.solidworks.com/2027/english/api/swconst/SolidWorks.Interop.swconst
#     ~SolidWorks.Interop.swconst.swSaveAsOptions_e.html
#   swSaveAsOptions_Silent         = 1   ← 静默导出用这个
#   swSaveAsOptions_Copy           = 2
#   swSaveAsOptions_SaveReferenced = 4   ← ⚠️ 是「连带保存全部组件」，不是 Silent！
# 旧版脚本误写 SaveAs3(..., 0, 4, ...) 并注释成 4=Silent，属实质性错误。
# ---------------------------------------------------------------------------
swDocPART = 1
swDocASSEMBLY = 2
swOpenDocOptions_Silent = 1          # swOpenDocOptions_Silent
swSaveAsCurrentVersion = 0           # swSaveAsVersion_e.swSaveAsCurrentVersion
swSaveAsOptions_Silent = 1           # =1，绝不是 4
swSaveAsOptions_Copy = 2


def _vref():
    """构造 by-ref 输出参数（Errors / Warnings）。
    必须用 VT_BYREF|VT_I4，写 VARIANT('int', 0) 拿不到回填值。"""
    return win32com.client.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_I4, 0)


def _val(v):
    """安全取 VARIANT 的 .value（部分版本直接是 int）"""
    return getattr(v, 'value', v)


def _truthy(v):
    """SaveAs3/OpenDoc6 返回值可能是 bool，也可能是 int/VARIANT_BOOL(-1)。
    统一成 Python bool —— 旧代码 `if ok != 0` 对 bool 恒真，把成功判成失败。"""
    if isinstance(v, bool):
        return v
    try:
        return int(v) != 0
    except Exception:
        return bool(v)



# Parasolid 扩展名
PARASOLID_EXTS = {'.x_t', '.x_b', '.xmt_txt', '.xmt_bin', '.p_t', '.p_b'}


def _g(obj, name):
    """兼容取成员：SW 各版本/各绑定方式下，同名成员有时是方法、有时是属性。
    例：SW2025 + pywin32 动态绑定下 ActiveDoc.GetTitle 是**属性**，
        直接写 GetTitle() 会抛 `'str' object is not callable`。"""
    v = getattr(obj, name)
    return v() if callable(v) else v


class SwSession(object):
    """与 SolidWorks 的**安全**连接。

    🔴 核心设计（2026-09-17 修订，起因：本工具曾无条件 `Visible=False` + `ExitApp()`，
       而 Dispatch('SldWorks.Application') 会附加到**用户正在运行的实例**上 ——
       等于隐藏并关闭用户的 SolidWorks，有丢失未保存工作的风险）：

      · 已在运行 → 复用，**绝不改 Visible、绝不 ExitApp、绝不 CloseAllDocuments**
      · 原本没运行 → 由本工具启动，结束时 ExitApp 回收
      · 转换前后快照「用户原有文档」，**只关闭自己打开的那一个**
    """

    def __init__(self, log=None):
        self.app = None
        self.started_by_us = False
        self.preexisting = set()      # 转换前已打开的文档路径（小写）
        self.preexisting_dirty = 0    # 其中有未保存修改的个数
        self.preexisting_count = 0
        self._log = log or (lambda *a, **k: None)

    def connect(self):
        try:
            self.app = win32com.client.GetActiveObject('SldWorks.Application')
            self.started_by_us = False
        except Exception:
            self.app = Dispatch('SldWorks.Application')
            self.started_by_us = True
            time.sleep(2.0)
        self._snapshot()
        if self.started_by_us:
            self._log('已启动新的 SolidWorks 实例（转换结束后会自动回收）')
        else:
            self._log('检测到 SolidWorks 已在运行 → 复用你的现有实例')
            self._log('你的界面不会被隐藏，转换结束后 SolidWorks 保持原样不关闭')
            if self.preexisting_dirty:
                self._log('⚠️ 注意：你当前有 %d 个文档存在未保存修改 —— '
                          '本工具只关闭它自己打开的文档，不会动你的文档'
                          % self.preexisting_dirty, 'WARN')
        return self.app

    def _snapshot(self):
        try:
            docs = _g(self.app, 'GetDocuments')
            docs = list(docs) if docs else []
        except Exception:
            docs = []
        self.preexisting_count = len(docs)
        for d in docs:
            try:
                p = _g(d, 'GetPathName')
                if p:
                    self.preexisting.add(str(p).lower())
            except Exception:
                pass
            try:
                if _g(d, 'GetSaveFlag'):
                    self.preexisting_dirty += 1
            except Exception:
                pass

    def close_own(self, doc):
        """只关闭本工具打开的那个文档；**任何情况下都不回退到 CloseAllDocuments**"""
        try:
            title = _g(doc, 'GetTitle')
        except Exception:
            return False
        if not title:
            return False
        try:
            self.app.CloseDoc(title)
            return True
        except Exception:
            return False

    def finish(self):
        """收尾：只回收自己启动的实例；用户的实例保持原样"""
        if self.started_by_us:
            try:
                self.app.ExitApp()
                return 'exited'
            except Exception:
                return 'exit_failed'
        return 'kept'


def find_xt_files(input_path: Path) -> list:
    """递归查找所有 XT 文件"""
    if input_path.is_file():
        if input_path.suffix.lower() in PARASOLID_EXTS:
            return [input_path]
        return []

    if not input_path.is_dir():
        return []

    files = []
    for ext in PARASOLID_EXTS:
        files.extend(input_path.rglob(f'*{ext}'))
        files.extend(input_path.rglob(f'*{ext.upper()}'))

    return sorted(set(files))


class XTConverterApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Parasolid XT → STEP 批量转换工具（奇点软件）")
        self.root.geometry("720x560")
        self.root.resizable(True, True)
        
        # 变量
        self.input_path = tk.StringVar()
        self.output_path = tk.StringVar()
        self.is_converting = False
        self.sw_app = None
        self.session = None          # SwSession：与 SolidWorks 的安全连接

        self._build_ui()
    
    def _build_ui(self):
        # 标题
        title_frame = ttk.Frame(self.root, padding="10")
        title_frame.pack(fill=tk.X)
        
        title_label = ttk.Label(
            title_frame,
            text="Parasolid XT → STEP 批量转换",
            font=("Microsoft YaHei", 16, "bold")
        )
        title_label.pack()
        
        subtitle_label = ttk.Label(
            title_frame,
            text="利用本机 SolidWorks 将 .x_t/.x_b 转为 STEP，转换后可直接上传小程序 3D 查看器",
            font=("Microsoft YaHei", 9)
        )
        subtitle_label.pack()
        
        # 输入路径
        input_frame = ttk.LabelFrame(self.root, text="输入", padding="10")
        input_frame.pack(fill=tk.X, padx=10, pady=5)
        
        ttk.Entry(input_frame, textvariable=self.input_path, width=60).pack(side=tk.LEFT, fill=tk.X, expand=True)
        ttk.Button(input_frame, text="选择文件/文件夹", command=self._choose_input).pack(side=tk.LEFT, padx=5)
        
        # 输出路径
        output_frame = ttk.LabelFrame(self.root, text="输出（可选，默认在输入目录下创建 STEP_converted）", padding="10")
        output_frame.pack(fill=tk.X, padx=10, pady=5)
        
        ttk.Entry(output_frame, textvariable=self.output_path, width=60).pack(side=tk.LEFT, fill=tk.X, expand=True)
        ttk.Button(output_frame, text="选择目录", command=self._choose_output).pack(side=tk.LEFT, padx=5)
        
        # 转换按钮
        btn_frame = ttk.Frame(self.root, padding="5")
        btn_frame.pack(fill=tk.X, padx=10)
        
        self.convert_btn = ttk.Button(btn_frame, text="开始转换", command=self._start_conversion)
        self.convert_btn.pack(side=tk.LEFT)
        
        self.progress_label = ttk.Label(btn_frame, text="就绪", foreground="green")
        self.progress_label.pack(side=tk.LEFT, padx=20)
        
        # 日志
        log_frame = ttk.LabelFrame(self.root, text="转换日志", padding="5")
        log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
        
        self.log_text = scrolledtext.ScrolledText(log_frame, height=15, font=("Consolas", 9))
        self.log_text.pack(fill=tk.BOTH, expand=True)
        
        # 底部提示
        tip_frame = ttk.Frame(self.root, padding="10")
        tip_frame.pack(fill=tk.X)
        
        ttk.Label(
            tip_frame,
            text="提示：转换期间请勿操作 SolidWorks。大装配体可能较慢，请耐心等待。",
            font=("Microsoft YaHei", 8),
            foreground="gray"
        ).pack()
        
        ttk.Label(
            tip_frame,
            text="前置条件：已安装 SolidWorks + pywin32（pip install pywin32）",
            font=("Microsoft YaHei", 8),
            foreground="gray"
        ).pack()
    
    def _choose_input(self):
        # 先问是文件还是文件夹
        result = messagebox.askyesnocancel(
            "选择输入",
            "点是 = 选择单个 XT 文件\n"
            "点否 = 选择包含 XT 的文件夹\n"
            "点取消 = 不选"
        )
        
        if result is None:
            return
        elif result:
            path = filedialog.askopenfilename(
                title="选择 Parasolid 文件",
                filetypes=[
                    ("Parasolid 文件", "*.x_t *.x_b *.xmt_txt *.xmt_bin *.p_t *.p_b"),
                    ("所有文件", "*.*")
                ]
            )
        else:
            path = filedialog.askdirectory(title="选择包含 XT 文件的文件夹")
        
        if path:
            self.input_path.set(path)
    
    def _choose_output(self):
        path = filedialog.askdirectory(title="选择输出目录")
        if path:
            self.output_path.set(path)
    
    def _log(self, msg, level="INFO"):
        timestamp = datetime.now().strftime("%H:%M:%S")
        line = f"[{timestamp}] [{level}] {msg}\n"
        self.log_text.insert(tk.END, line)
        self.log_text.see(tk.END)
        self.root.update_idletasks()
    
    def _start_conversion(self):
        if self.is_converting:
            return
        
        if not HAS_WIN32:
            messagebox.showerror(
                "缺少依赖",
                "未安装 pywin32 库。\n\n"
                "请先运行：\n"
                "pip install pywin32\n\n"
                "然后重启本工具。"
            )
            return
        
        input_str = self.input_path.get().strip()
        if not input_str:
            messagebox.showwarning("提示", "请先选择输入文件或文件夹")
            return
        
        input_path = Path(input_str)
        if not input_path.exists():
            messagebox.showerror("错误", f"路径不存在：{input_path}")
            return
        
        # 查找 XT 文件
        xt_files = find_xt_files(input_path)
        if not xt_files:
            messagebox.showwarning("提示", "未找到 Parasolid 文件（.x_t / .x_b）")
            return
        
        # 确定输出目录
        output_str = self.output_path.get().strip()
        if output_str:
            output_dir = Path(output_str)
        else:
            if input_path.is_file():
                output_dir = input_path.parent / 'STEP_converted'
            else:
                output_dir = input_path / 'STEP_converted'
        
        output_dir.mkdir(parents=True, exist_ok=True)
        
        # 确认
        msg = (
            f"找到 {len(xt_files)} 个 Parasolid 文件\n"
            f"输出目录：{output_dir}\n\n"
            f"开始转换？\n"
            f"（转换期间请勿操作 SolidWorks）"
        )
        if not messagebox.askyesno("确认", msg):
            return
        
        # 开始转换（在后台线程）
        self.is_converting = True
        self.convert_btn.config(state=tk.DISABLED)
        self.progress_label.config(text="转换中...", foreground="orange")
        self.log_text.delete(1.0, tk.END)
        
        thread = threading.Thread(
            target=self._do_conversion,
            args=(xt_files, output_dir),
            daemon=True
        )
        thread.start()
    
    def _do_conversion(self, xt_files, output_dir):
        try:
            self._log(f"共 {len(xt_files)} 个文件待转换")
            self._log("正在连接 SolidWorks...")

            # 安全连接（复用现有实例，绝不动用户的会话）
            self.session = SwSession(log=self._log)
            try:
                self.sw_app = self.session.connect()
                self._log("SolidWorks 已就绪", "OK")
            except Exception as e:
                self._log(f"无法连接 SolidWorks：{e}", "ERROR")
                self._log("请确认：1) SolidWorks 已安装  2) pywin32 已安装", "ERROR")
                self._finish(False)
                return
            
            # 批量转换
            success_count = 0
            fail_count = 0
            total_start = time.time()
            
            for i, xt_file in enumerate(xt_files, 1):
                self._log(f"[{i}/{len(xt_files)}] 转换中：{xt_file.name}")
                self.progress_label.config(text=f"转换中 {i}/{len(xt_files)}...")
                
                result = self._convert_one(xt_file, output_dir)
                
                if result['success']:
                    self._log(f"  ✓ 成功（{result['elapsed']:.1f}s）→ {result['step_path'].name}", "OK")
                    success_count += 1
                else:
                    self._log(f"  ✗ 失败：{result['error']}", "ERROR")
                    fail_count += 1
            
            total_elapsed = time.time() - total_start

            # 收尾：只回收本工具启动的实例；用户原有的实例保持原样
            how = self.session.finish()
            if how == 'exited':
                self._log("SolidWorks 已关闭（由本工具启动）", "OK")
            else:
                self._log("SolidWorks 是你原本打开的 → 保持原样，未关闭", "OK")
                if self.session.preexisting_dirty:
                    self._log("提示：你仍有 %d 个文档未保存，记得自己保存"
                              % self.session.preexisting_dirty, "WARN")

            # 生成报告
            report_path = output_dir / 'conversion_report.txt'
            with open(report_path, 'w', encoding='utf-8') as f:
                f.write(f"Parasolid → STEP 转换报告\n")
                f.write(f"生成时间：{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
                f.write(f"总耗时：{total_elapsed:.1f}s\n")
                f.write(f"成功：{success_count}  失败：{fail_count}\n")
                f.write("=" * 60 + "\n\n")
                f.write("转换后的 STEP 文件可直接上传小程序 3D 查看器。\n")
            
            self._log(f"\n{'='*60}", "OK")
            self._log(f"转换完成：成功 {success_count} / 失败 {fail_count} / 总耗时 {total_elapsed:.1f}s", "OK")
            self._log(f"输出目录：{output_dir}", "OK")
            self._log(f"转换报告：{report_path}", "OK")
            
            if success_count > 0:
                self._log(f"\n✓ 转换成功的 STEP 文件可直接上传小程序 3D 查看器", "OK")
            
            # 弹窗提示
            self.root.after(0, lambda: messagebox.showinfo(
                "转换完成",
                f"成功：{success_count}\n"
                f"失败：{fail_count}\n"
                f"总耗时：{total_elapsed:.1f}s\n\n"
                f"输出目录：{output_dir}"
            ))
            
            self._finish(fail_count == 0)
            
        except Exception as e:
            self._log(f"未知错误：{e}", "ERROR")
            self._finish(False)
    
    def _convert_one(self, xt_file: Path, output_dir: Path) -> dict:
        """用 SolidWorks 将单个 XT 转换为 STEP"""
        start = time.time()
        step_file = output_dir / (xt_file.stem + '.step')
        model = None

        try:
            # 先按「装配体」打开（XT 常为整机装配；按零件开会丢掉零部件结构），
            # 失败再退回「零件」。Silent 选项抑制导入向导弹窗。
            last_err = None
            for dt, dtname in ((swDocASSEMBLY, '装配体'), (swDocPART, '零件')):
                errs, warns = _vref(), _vref()
                model = self.sw_app.OpenDoc6(
                    str(xt_file), dt, swOpenDocOptions_Silent, "", errs, warns
                )
                if model is not None:
                    self._log(f"  以「{dtname}」方式载入成功")
                    break
                last_err = f'{dtname}: errs={_val(errs)} warns={_val(warns)}'

            if model is None:
                return {
                    'success': False,
                    'step_path': None,
                    'error': f'SolidWorks 无法打开该 XT（{last_err}）',
                    'elapsed': time.time() - start
                }

            # 大装配体载入后给内核一点时间稳定拓扑
            time.sleep(1.0)

            # 导出 STEP（AP214/AP203 由扩展名与系统导出选项决定）
            serr, swarn = _vref(), _vref()
            ret = model.SaveAs3(
                str(step_file), swSaveAsCurrentVersion,
                swSaveAsOptions_Silent, serr, swarn
            )

            # 成败以「文件是否真的落盘」为判据，不依赖返回值类型
            produced = step_file.exists() and step_file.stat().st_size > 0
            if not produced:
                return {
                    'success': False,
                    'step_path': None,
                    'error': '导出 STEP 未生成文件'
                             f'（返回={ret!r} errs={_val(serr)} warns={_val(swarn)}）',
                    'elapsed': time.time() - start
                }

            if not _truthy(ret):
                self._log(f"  提示：SaveAs3 返回 {ret!r}，但 STEP 已生成（{step_file.stat().st_size} B）",
                          "WARN")

            return {
                'success': True,
                'step_path': step_file,
                'error': None,
                'elapsed': time.time() - start
            }

        except Exception as e:
            return {
                'success': False,
                'step_path': None,
                'error': str(e),
                'elapsed': time.time() - start
            }
        finally:
            # 只关闭本次打开的这一个文档；绝不 CloseAllDocuments、绝不 ExitApp
            if model is not None:
                if not self.session.close_own(model):
                    self._log("提示：无法自动关闭该文档，请在 SolidWorks 中手动关闭", "WARN")
    
    def _finish(self, success):
        self.is_converting = False
        self.convert_btn.config(state=tk.NORMAL)
        if success:
            self.progress_label.config(text="转换完成 ✓", foreground="green")
        else:
            self.progress_label.config(text="转换完成（有失败）", foreground="orange")


def main():
    # 自检模式：验证打包后的 exe 能正常加载依赖（不启动 GUI、不碰 SolidWorks）
    # 注意：--windowed 打包的 exe 没有 stdout，所以结果同时写入临时文件。
    if '--check' in sys.argv:
        import tempfile, json as _json
        frozen = getattr(sys, 'frozen', False)
        # 常量自检：swSaveAsOptions_Silent 必须是 1（历史 bug 曾误用 4=SaveReferenced）
        const_ok = (swSaveAsOptions_Silent == 1 and swOpenDocOptions_Silent == 1
                    and swDocPART == 1 and swDocASSEMBLY == 2)
        info = {
            'ok': bool(HAS_TK and HAS_WIN32 and const_ok),
            'tkinter': bool(HAS_TK),
            'pywin32': bool(HAS_WIN32),
            'const_ok': bool(const_ok),
            'frozen': bool(frozen),
            'python': sys.version.split()[0],
        }
        lines = ['XT转STEP批量转换工具 自检',
                 '  tkinter : %s' % ('OK' if HAS_TK else '缺失'),
                 '  pywin32 : %s' % ('OK' if HAS_WIN32 else '缺失'),
                 '  运行方式: %s' % ('打包EXE' if frozen else 'Python源码'),
                 '  Python  : %s' % sys.version.split()[0],
                 '  枚举常量: swSaveAsOptions_Silent=%d swOpenDocOptions_Silent=%d %s'
                 % (swSaveAsOptions_Silent, swOpenDocOptions_Silent,
                    'OK' if const_ok else '异常(期望 1/1)'),
                 '  会话安全: 复用现有SolidWorks实例, 不隐藏界面, 不关闭SW, 只关自己打开的文档',
                 '  自检结果: %s' % ('PASS' if info['ok'] else 'FAIL')]
        out = os.path.join(tempfile.gettempdir(), 'xt2step_check.txt')
        try:
            with open(out, 'w', encoding='utf-8') as f:
                f.write('\n'.join(lines) + '\n')
        except Exception:
            pass
        try:
            print('\n'.join(lines))
        except Exception:
            pass
        sys.exit(0 if info['ok'] else 1)

    if not HAS_TK:
        print("错误：缺少 tkinter（Python 标准库，通常自带）")
        sys.exit(1)
    
    if not HAS_WIN32:
        print("警告：未安装 pywin32，无法调用 SolidWorks")
        print("请先运行：pip install pywin32")
        input("按回车键退出...")
        sys.exit(1)
    
    root = tk.Tk()
    app = XTConverterApp(root)
    root.mainloop()


if __name__ == '__main__':
    main()
