dsh-engineering-ui 0.0.0-stage → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +772 -3
  3. package/engineering/plugins/dsh-engineering-ui/cordis.patch.yml +5 -0
  4. package/engineering/plugins/dsh-engineering-ui/icon.svg +14 -0
  5. package/engineering/plugins/dsh-engineering-ui/lib/client.js +8259 -0
  6. package/engineering/plugins/dsh-engineering-ui/lib/defense-sign.js +1552 -0
  7. package/engineering/plugins/dsh-engineering-ui/lib/index.js +5633 -0
  8. package/engineering/plugins/dsh-engineering-ui/lib/job-wait-guard.js +236 -0
  9. package/engineering/plugins/dsh-engineering-ui/lib/mem-guard.js +331 -0
  10. package/engineering/plugins/dsh-engineering-ui/lib/webots.js +114 -0
  11. package/engineering/plugins/dsh-engineering-ui/locale/en.json +6 -0
  12. package/engineering/plugins/dsh-engineering-ui/locale/zh.json +6 -0
  13. package/engineering/plugins/dsh-engineering-ui/preset-engineering.patch.yml +601 -0
  14. package/engineering/skills/assembly-orchestration/SKILL.md +651 -0
  15. package/engineering/skills/cad-workflow/SKILL.md +132 -0
  16. package/engineering/skills/mode-selection/SKILL.md +660 -0
  17. package/engineering/skills/physics-in-loop/SKILL.md +238 -0
  18. package/engineering/skills/physics-in-loop/references/feadocs.md +142 -0
  19. package/engineering/skills/solidworks-bridge/SKILL.md +445 -0
  20. package/engineering/skills/sw-design/SKILL.md +112 -0
  21. package/engineering/skills/sw-design/references/autocad-workflow.md +81 -0
  22. package/engineering/skills/sw-design/references/gbt-drafting.md +71 -0
  23. package/engineering/skills/sw-to-cad/SKILL.md +121 -0
  24. package/engineering/skills/webots-simulation/SKILL.md +58 -0
  25. package/engineering/tools/LICENSE.Auto-SolidWorks +21 -0
  26. package/engineering/tools/_plugin_paths.py +93 -0
  27. package/engineering/tools/_root.py +330 -0
  28. package/engineering/tools/_store.py +446 -0
  29. package/engineering/tools/ac_bridge.py +459 -0
  30. package/engineering/tools/ac_rules.json +21 -0
  31. package/engineering/tools/ac_validate.py +699 -0
  32. package/engineering/tools/ask_user.py +887 -0
  33. package/engineering/tools/build_arm1.py +46 -0
  34. package/engineering/tools/build_assembly.py +94 -0
  35. package/engineering/tools/build_base.py +57 -0
  36. package/engineering/tools/build_housing.py +70 -0
  37. package/engineering/tools/build_structural.py +43 -0
  38. package/engineering/tools/build_transmission.py +69 -0
  39. package/engineering/tools/cadx.lsp +128 -0
  40. package/engineering/tools/choice_contract.py +61 -0
  41. package/engineering/tools/conn_state.py +1671 -0
  42. package/engineering/tools/defense_gate.py +3374 -0
  43. package/engineering/tools/drawing_input.py +314 -0
  44. package/engineering/tools/env_check.py +467 -0
  45. package/engineering/tools/examples/build_axis.py +142 -0
  46. package/engineering/tools/examples/build_cup.py +56 -0
  47. package/engineering/tools/examples/build_plate.py +58 -0
  48. package/engineering/tools/examples/build_sphere.py +30 -0
  49. package/engineering/tools/examples/build_transition_duct.py +381 -0
  50. package/engineering/tools/gate_integrity.py +401 -0
  51. package/engineering/tools/mode2_flow.py +3589 -0
  52. package/engineering/tools/mode_checker.py +77 -0
  53. package/engineering/tools/mode_gate.py +8013 -0
  54. package/engineering/tools/part_registry.py +4266 -0
  55. package/engineering/tools/physics/__init__.py +50 -0
  56. package/engineering/tools/physics/_compat.py +12 -0
  57. package/engineering/tools/physics/design_state.py +142 -0
  58. package/engineering/tools/physics/domain_validator.py +751 -0
  59. package/engineering/tools/physics/fatigue.py +489 -0
  60. package/engineering/tools/physics/fea_solver.py +1163 -0
  61. package/engineering/tools/physics/feapy_solver.py +314 -0
  62. package/engineering/tools/physics/gb_materials.json +1228 -0
  63. package/engineering/tools/physics/geometry_gate.py +337 -0
  64. package/engineering/tools/physics/load_case.py +951 -0
  65. package/engineering/tools/physics/material_db.py +874 -0
  66. package/engineering/tools/physics/mesh_adapter.py +321 -0
  67. package/engineering/tools/physics/refine_rules.py +272 -0
  68. package/engineering/tools/physics/rules/gb_base_rules.json +377 -0
  69. package/engineering/tools/physics/rules/gb_drafting_rules.json +57 -0
  70. package/engineering/tools/physics/rules/gb_electrical_rules.json +25 -0
  71. package/engineering/tools/physics/rules/gb_fasteners_rules.json +73 -0
  72. package/engineering/tools/physics/rules/gb_housing_rules.json +41 -0
  73. package/engineering/tools/physics/rules/gb_manufacturing_rules.json +25 -0
  74. package/engineering/tools/physics/rules/gb_material_verify_rules.json +72 -0
  75. package/engineering/tools/physics/rules/gb_materials_rules.json +377 -0
  76. package/engineering/tools/physics/rules/gb_safety_rules.json +89 -0
  77. package/engineering/tools/physics/rules/gb_springs_rules.json +41 -0
  78. package/engineering/tools/physics/rules/gb_standards_registry.json +1646 -0
  79. package/engineering/tools/physics/rules/gb_structural_rules.json +201 -0
  80. package/engineering/tools/physics/rules/gb_surface_rules.json +25 -0
  81. package/engineering/tools/physics/rules/gb_transmission_rules.json +233 -0
  82. package/engineering/tools/physics/rules/housing_rules.json +48 -0
  83. package/engineering/tools/physics/rules/manifest.json +54 -0
  84. package/engineering/tools/physics/rules/mold_rules.json +48 -0
  85. package/engineering/tools/physics/rules/structural_rules.json +56 -0
  86. package/engineering/tools/physics/rules/transmission_rules.json +48 -0
  87. package/engineering/tools/physics/simulation_report.py +829 -0
  88. package/engineering/tools/physics_bridge.py +2685 -0
  89. package/engineering/tools/profile-manifest.ps1 +72 -0
  90. package/engineering/tools/run_regression.py +109 -0
  91. package/engineering/tools/settings.py +419 -0
  92. package/engineering/tools/solidworks-modeling.md +519 -0
  93. package/engineering/tools/sw_bridge.py +8215 -0
  94. package/engineering/tools/sw_guard.py +1066 -0
  95. package/engineering/tools/sw_surface.py +87 -0
  96. package/engineering/tools/swapi.py +14766 -0
  97. package/engineering/tools/verify_sync.py +218 -0
  98. package/engineering/tools/visual_check.py +2589 -0
  99. package/engineering/tools/webots_env.py +121 -0
  100. package/engineering/tools/webots_mcp/__init__.py +3 -0
  101. package/engineering/tools/webots_mcp/__main__.py +3 -0
  102. package/engineering/tools/webots_mcp/assembly.py +391 -0
  103. package/engineering/tools/webots_mcp/bootstrap.py +55 -0
  104. package/engineering/tools/webots_mcp/controller_worker.py +124 -0
  105. package/engineering/tools/webots_mcp/evidence.py +288 -0
  106. package/engineering/tools/webots_mcp/fake_worker.py +69 -0
  107. package/engineering/tools/webots_mcp/fault_resources.py +121 -0
  108. package/engineering/tools/webots_mcp/host.py +45 -0
  109. package/engineering/tools/webots_mcp/ledger.py +73 -0
  110. package/engineering/tools/webots_mcp/owned_job.py +70 -0
  111. package/engineering/tools/webots_mcp/physical_gate.py +187 -0
  112. package/engineering/tools/webots_mcp/physics.py +152 -0
  113. package/engineering/tools/webots_mcp/pipeline.py +108 -0
  114. package/engineering/tools/webots_mcp/recipes.py +129 -0
  115. package/engineering/tools/webots_mcp/repair.py +132 -0
  116. package/engineering/tools/webots_mcp/repair_trial.py +375 -0
  117. package/engineering/tools/webots_mcp/runtime.py +311 -0
  118. package/engineering/tools/webots_mcp/server.py +217 -0
  119. package/engineering/tools/webots_mcp/sw_export.py +509 -0
  120. package/engineering/tools/webots_mcp/transport.py +156 -0
  121. package/engineering/tools/workflow_gate.py +6134 -0
  122. package/package.json +64 -4
@@ -0,0 +1,72 @@
1
+ function Read-DshProfileManifest {
2
+ param([string]$Path)
3
+ $text = [System.IO.File]::ReadAllText($Path)
4
+ $package = $text | ConvertFrom-Json
5
+ if ($package -isnot [pscustomobject] -or -not $text.TrimStart().StartsWith('{')) {
6
+ throw ("profile manifest must contain a JSON object: " + $Path)
7
+ }
8
+ return $package
9
+ }
10
+
11
+ # Update only plugin registration fields; preserve every other profile setting.
12
+ function Set-DshProfilePluginEntries {
13
+ param($Package, $Dependencies, [object[]]$Bundles)
14
+ $Package | Add-Member NoteProperty dependencies $Dependencies -Force
15
+ if ($null -eq $Package.dsh) { $Package | Add-Member NoteProperty dsh ([pscustomobject]@{}) -Force }
16
+ if ($null -eq $Package.dsh.profile) { $Package.dsh | Add-Member NoteProperty profile ([pscustomobject]@{}) -Force }
17
+ $Package.dsh.profile | Add-Member NoteProperty bundles @($Bundles) -Force
18
+ }
19
+
20
+ # Migrate the known permission.config.presets layout; preserve custom YAML paths.
21
+ function Remove-DshLegacyPermissionPreset {
22
+ param([string]$Path)
23
+ if (-not (Test-Path -LiteralPath $Path)) { return $false }
24
+ $raw = [System.IO.File]::ReadAllText($Path)
25
+ $output = New-Object System.Text.StringBuilder
26
+ $skipIndent = -1
27
+ $rowIndent = -1
28
+ $configIndent = -1
29
+ $presetsIndent = -1
30
+ $scalarIndent = -1
31
+ $changed = $false
32
+ foreach ($match in [regex]::Matches($raw, '[^\r\n]*(?:\r\n|\n|\r|$)')) {
33
+ $line = $match.Value
34
+ if ($line.Length -eq 0) { continue }
35
+ $content = $line.TrimEnd([char[]]"`r`n")
36
+ [void]($content -match '^([ \t]*)')
37
+ $indent = $Matches[1].Length
38
+ if ($scalarIndent -ge 0) {
39
+ if ($content.Trim().Length -eq 0 -or $indent -gt $scalarIndent) {
40
+ [void]$output.Append($line)
41
+ continue
42
+ }
43
+ $scalarIndent = -1
44
+ }
45
+ if ($content.Trim().Length -gt 0 -and -not $content.TrimStart().StartsWith('#')) {
46
+ if ($skipIndent -ge 0) {
47
+ if ($indent -gt $skipIndent) { continue }
48
+ $skipIndent = -1
49
+ }
50
+ if ($indent -le $rowIndent) { $rowIndent = -1; $configIndent = -1; $presetsIndent = -1 }
51
+ if ($indent -le $configIndent) { $configIndent = -1; $presetsIndent = -1 }
52
+ if ($indent -le $presetsIndent) { $presetsIndent = -1 }
53
+ if ($content -match '^-[ \t]+id:[ \t]*(?:permission|''permission''|"permission")[ \t]*(?:#.*)?$') {
54
+ $rowIndent = $indent
55
+ } elseif ($rowIndent -ge 0 -and $indent -eq $rowIndent + 2 -and $content -match '^[ \t]*config:[ \t]*(?:#.*)?$') {
56
+ $configIndent = $indent
57
+ } elseif ($configIndent -ge 0 -and $indent -eq $configIndent + 2 -and $content -match '^[ \t]*presets:[ \t]*(?:#.*)?$') {
58
+ $presetsIndent = $indent
59
+ } elseif ($presetsIndent -ge 0 -and $indent -eq $presetsIndent + 2 -and $content -match '^[ \t]*sw-single-line:[ \t]*(?:#.*)?$') {
60
+ $skipIndent = $indent
61
+ $changed = $true
62
+ continue
63
+ }
64
+ if ($content -match ':[ \t]*[|>][0-9+-]*[ \t]*(?:#.*)?$') { $scalarIndent = $indent }
65
+ }
66
+ [void]$output.Append($line)
67
+ }
68
+ if (-not $changed) { return $false }
69
+ Copy-Item -LiteralPath $Path -Destination ($Path + '.bak-' + (Get-Date -Format 'yyyyMMdd-HHmmss-fffffff')) -ErrorAction Stop
70
+ [System.IO.File]::WriteAllText($Path, $output.ToString(), (New-Object System.Text.UTF8Encoding($false)))
71
+ return $true
72
+ }
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """run_regression.py — 工程模式回归测试总入口(第二轮 Bug-34~46 修复验收)
4
+
5
+ 用法:
6
+ python run_regression.py # 跑全部
7
+ python run_regression.py 36 39 # 只跑指定 Bug 号
8
+
9
+ 每个 test_regression_*.py 都是独立可执行脚本,退出码 0 = 通过。
10
+ 本 runner 逐个调用它们并汇总,便于"每次改动后回归"。
11
+ """
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import sys
16
+
17
+ # ── 【BUG-10/11 同类修复】本 runner 自己也会因 GBK 控制台崩溃 ──────────────
18
+ # 实测:子测试输出里出现 "⚠"(U+26A0)等非 GBK 字符时,
19
+ # print() 直接抛 UnicodeEncodeError,整个 runner 崩在中途 ——
20
+ # 表现为"回归跑了一半就没了",而且堆栈指向 runner 自己的 print,
21
+ # 很容易被误读成"某个测试带崩了框架"。
22
+ # 修复:输出统一走 UTF-8(失败则用 backslashreplace 兜底,保证绝不抛)。
23
+ try:
24
+ sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace")
25
+ sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace")
26
+ except Exception:
27
+ pass
28
+
29
+ HERE = os.path.dirname(os.path.abspath(__file__))
30
+
31
+ # ★ 部分测试脚本把 tools 目录作为 argv[1] 读取(写成 `TOOLS = sys.argv[1]`)。
32
+ # 原先 runner 一律不传参 → 它们在 __main__ 处 IndexError 直接崩,
33
+ # 表现为"单独跑通过、汇总里却 ❌"的假失败(question_contract 就是这种)。
34
+ _NEEDS_TOOLS_ARG = ("test_regression_question_contract.py",)
35
+
36
+
37
+ def _needs_tools_arg(filename):
38
+ return filename in _NEEDS_TOOLS_ARG
39
+
40
+
41
+ def discover(only=None):
42
+ out = []
43
+ # ── 【2026-10-30】把 node 侧测试(test_*.mjs)也纳入回归 ──────────
44
+ # 插件 Host/Client 半是 JS,纯 Python 断言覆盖不到;
45
+ # 那些用例用 node:test 写,这里一并收进来跑。
46
+ for n in sorted(os.listdir(HERE)):
47
+ _is_py = n.startswith("test_regression_") and n.endswith(".py")
48
+ _is_mjs = n.startswith("test_") and n.endswith(".mjs")
49
+ if not (_is_py or _is_mjs):
50
+ continue
51
+ if only:
52
+ if _is_mjs:
53
+ continue
54
+ if not any(("bug%s" % b) in n for b in only):
55
+ continue
56
+ out.append(n)
57
+ return out
58
+
59
+
60
+ def main():
61
+ only = [a for a in sys.argv[1:] if a.isdigit()]
62
+ files = discover(only)
63
+ if not files:
64
+ print("未找到回归测试文件")
65
+ return 1
66
+ results = []
67
+ for f in files:
68
+ print("\n" + "=" * 72)
69
+ print("RUN " + f)
70
+ print("=" * 72)
71
+ try:
72
+ # ★ 部分测试需要 tools 目录作为 argv[1](见 _NEEDS_TOOLS_ARG)。
73
+ # 原先 runner 一律不传参 → 那些测试在 __main__ 处 IndexError 直接崩,
74
+ # 表现为"汇总里 ❌ 但单独跑却通过"的假失败。这里按需补上。
75
+ if f.endswith(".mjs"):
76
+ # node 侧测试用 node --test 跑
77
+ cmd = ["node", "--test", os.path.join(HERE, f)]
78
+ else:
79
+ cmd = [sys.executable, os.path.join(HERE, f)]
80
+ if _needs_tools_arg(f):
81
+ cmd.append(HERE)
82
+ p = subprocess.run(cmd, capture_output=True, text=True, timeout=600,
83
+ encoding="utf-8", errors="replace")
84
+ tail = (p.stdout or "").strip().splitlines()
85
+ for line in tail:
86
+ if "PASS=" in line or "[FAIL]" in line:
87
+ print(" " + line.strip())
88
+ if p.returncode != 0 and not any("PASS=" in l for l in tail):
89
+ # 崩了且没有结论行 → 打印尾部便于定位
90
+ for line in (tail or (p.stderr or "").strip().splitlines())[-6:]:
91
+ print(" ! " + line.strip())
92
+ results.append((f, p.returncode == 0))
93
+ except Exception as e:
94
+ print(" 异常: %r" % (e,))
95
+ results.append((f, False))
96
+
97
+ print("\n" + "=" * 72)
98
+ print("汇总")
99
+ print("=" * 72)
100
+ ok = 0
101
+ for f, passed in results:
102
+ print(" %s %s" % ("✅" if passed else "❌", f))
103
+ ok += 1 if passed else 0
104
+ print("\n通过 %d / %d" % (ok, len(results)))
105
+ return 0 if ok == len(results) else 1
106
+
107
+
108
+ if __name__ == "__main__":
109
+ sys.exit(main())
@@ -0,0 +1,419 @@
1
+ # -*- coding: utf-8 -*-
2
+ r"""settings.py —— 工程模式【本机设置】单一事实源(跨机器安全)
3
+
4
+ ═══════════════════════════════════════════════════════════════════════════
5
+ 【为什么需要这个模块】
6
+ 用户在学校微机房实测到两个"换台电脑就出问题"的现象:
7
+
8
+ ① 交付目录被写死成"桌面\test"
9
+ workflow_gate._default_delivery_root() 与 mode_gate._default_work_dir()
10
+ 都无条件回落到 %USERPROFILE%\Desktop\test。于是换一台电脑(甚至只是换
11
+ 一个用户名),第一次跑任务就会在那台电脑的桌面上【凭空造出】一个 test
12
+ 文件夹 —— 用户原话:"在学校的电脑的桌面上,也莫名其妙就出现了一个 test
13
+ 文件"。而且用户没有任何途径把它改到别处。
14
+
15
+ ② 连接区的 SW/CAD 路径"记住"了作者本机的盘符
16
+ connection_state.json 里存着 Z:\Program Files\SW\SLDWORKS.exe。
17
+ 这份状态随仓库/安装副本一起被拷到另一台电脑后,那台电脑上根本没有 Z 盘,
18
+ 于是每次"连接"都先撞一次不存在的路径,排查方向被带偏。
19
+
20
+ 【本模块的解法】
21
+ 把"跟【这台电脑】有关的设置"收敛到一个**只有一份**的文件里:
22
+ <state_dir>/settings.json
23
+
24
+ · 交付目录 → paths.delivery_root
25
+ · SW / CAD / Python 可执行文件 → paths.sw_exe / cad_exe / python_exe
26
+ · 全部设置都带【机器指纹】;指纹不匹配 = 这条设置来自别的电脑
27
+ → 自动忽略(并在报告里明说"已忽略,因为来自另一台电脑"),
28
+ 绝不让别人的盘符在本机生效。
29
+
30
+ 【优先级(读)】
31
+ delivery_root: 环境变量 DSH_DELIVERY_ROOT > settings.json > 兜底
32
+ sw_exe/cad_exe: 环境变量 DSH_SW_EXE / DSH_ACAD_EXE > settings.json > 自动探测
33
+
34
+ 【为什么不用 localStorage】
35
+ 设置页在浏览器里,而真正干活的 Python / 宿主守卫读不到浏览器存储。
36
+ UI、AI、Python 三方必须读同一份文件(与 connection_state.json 同一套理由)。
37
+ ═══════════════════════════════════════════════════════════════════════════
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import hashlib
42
+ import json
43
+ import os
44
+ import socket
45
+ import sys
46
+ import time
47
+
48
+ SCHEMA = "dsh-engineering-settings/1"
49
+ FILE_NAME = "settings.json"
50
+
51
+ # 机器级设置的键(值只在本机有意义)
52
+ MACHINE_KEYS = ("delivery_root", "sw_exe", "cad_exe", "python_exe",
53
+ "abaqus_exe", "webots_exe")
54
+
55
+ ENV_DELIVERY = "DSH_DELIVERY_ROOT"
56
+ ENV_SW = "DSH_SW_EXE"
57
+ ENV_CAD = "DSH_ACAD_EXE"
58
+ ENV_PY = "DSH_PYTHON"
59
+
60
+
61
+ # ══════════════════════════════════════════════════════════════════════════
62
+ # 路径 / 机器指纹
63
+ # ══════════════════════════════════════════════════════════════════════════
64
+
65
+ def settings_path():
66
+ """settings.json 的绝对路径(与 mode_state / workflow_state 同目录)。"""
67
+ try:
68
+ import _store
69
+ return _store.state_path(FILE_NAME)
70
+ except Exception:
71
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), FILE_NAME)
72
+
73
+
74
+ def _drives():
75
+ """枚举真实存在的盘符(Windows)—— 用来构成指纹的一部分。"""
76
+ out = []
77
+ try:
78
+ if sys.platform == "win32":
79
+ import string
80
+ for letter in string.ascii_uppercase:
81
+ root = letter + ":\\"
82
+ if os.path.exists(root):
83
+ out.append(letter)
84
+ else:
85
+ out.append("/")
86
+ except Exception:
87
+ pass
88
+ return sorted(out)
89
+
90
+
91
+ def machine_fingerprint():
92
+ """本机的稳定指纹(主机名 + 用户名 + 盘符集合)。
93
+
94
+ ── 为什么用这三项 ────────────────────────────────────────────────────
95
+ 要判"这条设置是不是这台电脑的",判据必须:
96
+ · 换电脑一定变(主机名/用户名);换盘符也要变(Z 盘没了);
97
+ · 同机重启、改名日期、升级都不变(不含时间戳、不含进程号)。
98
+ 注意:不把 CPU/网卡信息算进去 —— 虚拟机快照被克隆时那些会重复,
99
+ 反而判不出"其实是另一台机器"。
100
+ """
101
+ parts = []
102
+ try:
103
+ parts.append(socket.gethostname() or "")
104
+ except Exception:
105
+ parts.append("")
106
+ parts.append(os.environ.get("USERNAME") or os.environ.get("USER") or "")
107
+ parts.append(",".join(_drives()))
108
+ raw = "|".join(parts).lower()
109
+ return hashlib.sha1(raw.encode("utf-8", "ignore")).hexdigest()[:16]
110
+
111
+
112
+ def machine_info():
113
+ return {
114
+ "fingerprint": machine_fingerprint(),
115
+ "hostname": (socket.gethostname() if True else ""),
116
+ "user": os.environ.get("USERNAME") or os.environ.get("USER") or "",
117
+ "drives": _drives(),
118
+ "platform": sys.platform,
119
+ "python": sys.version.split()[0],
120
+ "python_exe": sys.executable,
121
+ }
122
+
123
+
124
+ # ══════════════════════════════════════════════════════════════════════════
125
+ # 读写
126
+ # ══════════════════════════════════════════════════════════════════════════
127
+
128
+ def _empty():
129
+ return {"schema": SCHEMA, "updated_at": None, "updated_at_str": None,
130
+ "paths": {}}
131
+
132
+
133
+ def load():
134
+ """读取设置(不存在/损坏/跨机器一律安全返回空结构,绝不抛异常)。"""
135
+ p = settings_path()
136
+ try:
137
+ if os.path.exists(p):
138
+ with open(p, "r", encoding="utf-8-sig") as f:
139
+ d = json.load(f)
140
+ if isinstance(d, dict):
141
+ d.setdefault("paths", {})
142
+ if not isinstance(d["paths"], dict):
143
+ d["paths"] = {}
144
+ return d
145
+ except Exception:
146
+ pass
147
+ return _empty()
148
+
149
+
150
+ def save(state):
151
+ """原子写入(先写临时文件再替换)。"""
152
+ p = settings_path()
153
+ d = os.path.dirname(p)
154
+ try:
155
+ os.makedirs(d, exist_ok=True)
156
+ except Exception:
157
+ pass
158
+ state["updated_at"] = time.time()
159
+ state["updated_at_str"] = time.strftime("%Y-%m-%d %H:%M:%S")
160
+ state.setdefault("schema", SCHEMA)
161
+ tmp = p + ".tmp"
162
+ try:
163
+ with open(tmp, "w", encoding="utf-8") as f:
164
+ json.dump(state, f, ensure_ascii=False, indent=2)
165
+ os.replace(tmp, p)
166
+ return p
167
+ except Exception:
168
+ try:
169
+ if os.path.exists(tmp):
170
+ os.remove(tmp)
171
+ except Exception:
172
+ pass
173
+ return None
174
+
175
+
176
+ # ══════════════════════════════════════════════════════════════════════════
177
+ # 单条设置的读 / 写(核心:跨机器自动忽略)
178
+ # ══════════════════════════════════════════════════════════════════════════
179
+
180
+ def _entry(state, key):
181
+ v = (state.get("paths") or {}).get(key)
182
+ if isinstance(v, dict):
183
+ return v
184
+ if isinstance(v, str):
185
+ return {"value": v, "machine": None}
186
+ return None
187
+
188
+
189
+ def _usable(value, key):
190
+ """这条值在本机是否可信。
191
+
192
+ · exe 类:必须文件真的存在;
193
+ · delivery_root:允许暂时不存在(首次使用会创建),
194
+ 但它所在的【盘符】必须存在 —— 否则就是别人电脑上的 D:/E:。
195
+ """
196
+ if not value:
197
+ return False, "值为空"
198
+ if key.endswith("_exe"):
199
+ if os.path.isfile(value):
200
+ return True, ""
201
+ return False, "文件不存在(可能来自另一台电脑或软件已卸载)"
202
+ # 目录类
203
+ drive = os.path.splitdrive(os.path.abspath(value))[0]
204
+ if drive and not os.path.exists(drive + os.sep):
205
+ return False, "盘符 %s 不存在(这条设置来自另一台电脑)" % drive
206
+ return True, ""
207
+
208
+
209
+ def get(key, default=None, validate=True):
210
+ """读一条设置。跨机器 / 已失效 → 返回 default,并在 note 里说明原因。
211
+
212
+ 返回 (value, note)。note 为空串表示"正常取到"。
213
+ """
214
+ state = load()
215
+ e = _entry(state, key)
216
+ if not e:
217
+ return default, ""
218
+ value = str(e.get("value") or "").strip().strip('"')
219
+ if not value:
220
+ return default, ""
221
+ owner = e.get("machine")
222
+ mine = machine_fingerprint()
223
+ if owner and owner != mine:
224
+ return default, ("该设置记录于另一台电脑(指纹 %s,本机 %s),"
225
+ "已忽略 —— 请在本机重新指定。" % (owner, mine))
226
+ if validate:
227
+ ok, why = _usable(value, key)
228
+ if not ok:
229
+ return default, "已失效:%s" % why
230
+ return value, ""
231
+
232
+
233
+ def set_path(key, value):
234
+ """写入一条【本机】设置(自动打上本机指纹)。value 为空 = 清除该条。"""
235
+ if key not in MACHINE_KEYS:
236
+ return {"ok": False, "error": "未知设置项 %r(可用:%s)"
237
+ % (key, " / ".join(MACHINE_KEYS))}
238
+ state = load()
239
+ paths = state.setdefault("paths", {})
240
+ v = str(value or "").strip().strip('"')
241
+ if not v:
242
+ paths.pop(key, None)
243
+ p = save(state)
244
+ return {"ok": True, "key": key, "value": None, "cleared": True,
245
+ "file": p, "note": "已清除,回落到自动探测/默认值"}
246
+ if key.endswith("_exe"):
247
+ cand = os.path.abspath(v)
248
+ if os.path.isdir(cand):
249
+ # 允许用户给"安装目录",自动拼可执行文件名
250
+ names = {"sw_exe": ("SLDWORKS.exe", "sldworks.exe"),
251
+ "cad_exe": ("acad.exe", "acadlt.exe"),
252
+ "python_exe": ("python.exe",),
253
+ "abaqus_exe": ("abaqus.exe", "abqlauncher.exe"),
254
+ "webots_exe": ("webots.exe",)}
255
+ hit = None
256
+ for n in names.get(key, ()):
257
+ c = os.path.join(cand, n)
258
+ if os.path.isfile(c):
259
+ hit = c
260
+ break
261
+ if not hit:
262
+ return {"ok": False, "error": "目录里找不到可执行文件 %s"
263
+ % (names.get(key) or "")}
264
+ v = hit
265
+ elif not os.path.isfile(cand):
266
+ return {"ok": False, "error": "路径不存在:%s" % cand}
267
+ else:
268
+ v = cand
269
+ else:
270
+ v = os.path.abspath(v)
271
+ drive = os.path.splitdrive(v)[0]
272
+ if drive and not os.path.exists(drive + os.sep):
273
+ return {"ok": False, "error": "盘符 %s 在本机不存在" % drive}
274
+ paths[key] = {"value": v, "machine": machine_fingerprint(),
275
+ "host": socket.gethostname(), "at": time.strftime("%Y-%m-%d %H:%M:%S")}
276
+ p = save(state)
277
+ return {"ok": True, "key": key, "value": v, "file": p,
278
+ "note": "已保存为本机设置(换电脑会自动忽略,不会误用别机的路径)"}
279
+
280
+
281
+ # ══════════════════════════════════════════════════════════════════════════
282
+ # 语义化读取(各模块统一调用这些,不要再各写各的默认值)
283
+ # ══════════════════════════════════════════════════════════════════════════
284
+
285
+ def _tools_output_dir():
286
+ return os.path.abspath(os.path.join(
287
+ os.path.dirname(os.path.abspath(__file__)), "..", "output"))
288
+
289
+
290
+ def delivery_root():
291
+ """交付根目录(所有任务的【上层】)。
292
+
293
+ 优先级:
294
+ ① 环境变量 DSH_DELIVERY_ROOT(显式指定,最高)
295
+ ② settings.json 的 delivery_root(设置页可改,本机有效)
296
+ ③ 历史兼容:若 <用户桌面>\\test 已经存在(老用户目录里有旧任务),
297
+ 继续用它,避免"升级后找不到上次的成果";
298
+ ④ 兜底:<工程模式根>/output —— ★ 不再凭空在桌面创建 test ★
299
+
300
+ ── 关键行为变更(本次修复)────────────────────────────────────────────
301
+ 旧行为:桌面存在就直接返回 桌面\\test 并【在第一次建任务时创建它】。
302
+ 于是任何人第一次用工程模式,桌面都会莫名其妙多一个 test 文件夹
303
+ (用户在学校微机房实测到这一点)。
304
+ 新行为:只有当 desktop\\test 【本来就存在】时才沿用;否则落到
305
+ 工程模式自己的 output 目录。要改就改设置页里的「交付目录」。
306
+ """
307
+ env = (os.environ.get(ENV_DELIVERY) or "").strip().strip('"')
308
+ if env:
309
+ try:
310
+ return os.path.abspath(env), "env:%s" % ENV_DELIVERY
311
+ except Exception:
312
+ pass
313
+ v, _note = get("delivery_root", None, validate=False)
314
+ if v:
315
+ try:
316
+ return os.path.abspath(v), "settings.json"
317
+ except Exception:
318
+ pass
319
+ home = os.environ.get("USERPROFILE") or os.environ.get("HOME") or ""
320
+ if home:
321
+ legacy = os.path.join(home, "Desktop", "test")
322
+ try:
323
+ if os.path.isdir(legacy):
324
+ return legacy, "legacy-desktop-test(已存在,沿用)"
325
+ except Exception:
326
+ pass
327
+ return _tools_output_dir(), "default:<root>/output"
328
+
329
+
330
+ def sw_exe():
331
+ """SolidWorks 可执行文件(本机设置 > 环境变量 > None 交给自动探测)。"""
332
+ env = (os.environ.get(ENV_SW) or "").strip().strip('"')
333
+ if env and os.path.isfile(os.path.join(env, "SLDWORKS.exe")
334
+ if os.path.isdir(env) else env):
335
+ return env
336
+ v, _note = get("sw_exe", None)
337
+ return v
338
+
339
+
340
+ def cad_exe():
341
+ env = (os.environ.get(ENV_CAD) or "").strip().strip('"')
342
+ if env:
343
+ for c in (env, os.path.join(env, "acad.exe")):
344
+ try:
345
+ if os.path.isfile(c):
346
+ return c
347
+ except Exception:
348
+ pass
349
+ v, _note = get("cad_exe", None)
350
+ return v
351
+
352
+
353
+ def python_exe():
354
+ env = (os.environ.get(ENV_PY) or "").strip().strip('"')
355
+ if env and os.path.isfile(env):
356
+ return env
357
+ v, _note = get("python_exe", None)
358
+ return v
359
+
360
+
361
+ def report():
362
+ """诊断用:把每条设置在本机的生效状态列出来(含被忽略的原因)。"""
363
+ state = load()
364
+ mine = machine_fingerprint()
365
+ rows = []
366
+ for key in MACHINE_KEYS:
367
+ e = _entry(state, key)
368
+ if not e:
369
+ rows.append({"key": key, "value": None, "usable": None,
370
+ "source": "未设置(走自动探测/默认)", "note": ""})
371
+ continue
372
+ value = str(e.get("value") or "")
373
+ owner = e.get("machine")
374
+ if owner and owner != mine:
375
+ rows.append({"key": key, "value": value, "usable": False,
376
+ "source": "另一台电脑",
377
+ "note": "指纹不符,已忽略(本机指纹 %s)" % mine})
378
+ continue
379
+ ok, why = _usable(value, key)
380
+ rows.append({"key": key, "value": value, "usable": bool(ok),
381
+ "source": "本机设置", "note": why})
382
+ root, root_src = delivery_root()
383
+ return {
384
+ "ok": True,
385
+ "file": settings_path(),
386
+ "machine": machine_info(),
387
+ "delivery_root": root,
388
+ "delivery_root_source": root_src,
389
+ "paths": rows,
390
+ "env": {ENV_DELIVERY: os.environ.get(ENV_DELIVERY),
391
+ ENV_SW: os.environ.get(ENV_SW),
392
+ ENV_CAD: os.environ.get(ENV_CAD),
393
+ ENV_PY: os.environ.get(ENV_PY)},
394
+ "note": ("机器级设置(交付目录 / 各软件路径)只在本机生效;"
395
+ "换电脑会自动忽略并重新探测,不会沿用别人的盘符。"),
396
+ }
397
+
398
+
399
+ def _main(argv):
400
+ cmd = argv[0] if argv else "report"
401
+ if cmd == "report":
402
+ print(json.dumps(report(), ensure_ascii=False, indent=2))
403
+ return 0
404
+ if cmd == "set" and len(argv) >= 3:
405
+ r = set_path(argv[1], argv[2])
406
+ print(json.dumps(r, ensure_ascii=False, indent=2))
407
+ return 0 if r.get("ok") else 1
408
+ if cmd in ("delivery-root", "delivery_root"):
409
+ root, src = delivery_root()
410
+ print(json.dumps({"ok": True, "delivery_root": root, "source": src},
411
+ ensure_ascii=False))
412
+ return 0
413
+ print(json.dumps({"ok": False, "error": "用法: settings.py report | set <key> <path> | delivery-root"},
414
+ ensure_ascii=False))
415
+ return 2
416
+
417
+
418
+ if __name__ == "__main__":
419
+ sys.exit(_main(sys.argv[1:]))