v2.0: 4 分类真实数据替换 + 手册彩色重做 + 网页版改版
数据 (定位销/勾销缸/翻转缸/电缸 → Univer 真实数模)
- 新增 19 系列 1124 型号 (PIN 303 / HOK 73 / ROT 741 / ELEC 9)
- 命名规则取自官方图册 CODIFICATION KEY (pdftotext 提取), 非推测
- 解析用「官方值域校验 + 左对齐 DFS 填空」, 可处理省略空位的型号
- 参数组合唯一性门禁: 1124 型号全部通过 (避免选型静默错配)
- 占位/测试文件 (如 USP50J_0___K0 销径为空) 在解析阶段丢弃
- catalog.json 分类系列表同步; crossref 失效引用清理
手册页
- 全部 42 系列改为彩色 (此前脚本误写死 csGRAY 转灰度), 346 页
- 修复 UCBT 附图只有 1 页 (筛选条件写死 UCBT40 且漏 CLAMPING ARMS)
- gen-pending-series.py 改为扫描 manual 目录自动生成附件清单,
避免重跑脚本把多页附件覆盖回单页
网页版
- 首页改为系列缩略图网格 (40 张: 27 张真实产品图 + 13 张占位图),
按大类分组; 图放 web/series/<系列码>.webp, 覆盖同名文件即可替换
- 左侧树改手风琴 (默认收起, 一次展开一个), 父级带 SVG 图标,
悬停放大动画, 按钮间距随高度均匀分布; 可收起为图标窄条
- 修复分隔条被拖出容器后无法找回 (上限改为容器感知 + resize 重夹取
+ 双击复位); 同时阻止图片原生拖拽
- 购物车打包改取 .step.gz 副档 (传输量降至约 1/4.5), 无副档自动回退
- 修复搜索到型号后点结果内容区空白 (switchTab('dim') 已失效)
- OpcPackage 超大包 (>1.5GB) 改走临时文件, 绕过 MemoryStream 2GB 上限
工具
- gen-pending-series/manual, copy-pending-step, gen-step-gz,
gen-series-thumbs, scan-pending-series 等 11 个脚本
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
57
onebot-data/tools/copy-pending-step.py
Normal file
57
onebot-data/tools/copy-pending-step.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
并行复制待补分类的 STEP 数模(X盘网络盘慢,串行不可行)。
|
||||
复用 gen-pending-series.py 的扫描缓存 + 解析器。
|
||||
用法: PYTHONUTF8=1 python copy-pending-step.py
|
||||
"""
|
||||
import os, sys, shutil
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import importlib.util
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
spec = importlib.util.spec_from_file_location('gps', os.path.join(HERE, 'gen-pending-series.py'))
|
||||
gps = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(gps)
|
||||
|
||||
DST = os.path.join(gps.DST, 'step')
|
||||
os.makedirs(DST, exist_ok=True)
|
||||
|
||||
scanned = gps.load_scan()
|
||||
todo = []
|
||||
for sp in gps.SPECS:
|
||||
records, _ = gps.collect(sp, scanned)
|
||||
for m in records.values():
|
||||
dst = os.path.join(DST, m['model'] + '.step')
|
||||
if not os.path.exists(dst):
|
||||
todo.append((m['src'], dst, m['model']))
|
||||
print(f'待复制 {len(todo)} 个(已存在则跳过)')
|
||||
|
||||
def cp(item):
|
||||
src, dst, model = item
|
||||
for attempt in range(3):
|
||||
try:
|
||||
shutil.copy2(src, dst)
|
||||
return (model, 'ok', None)
|
||||
except Exception as e:
|
||||
if attempt == 2:
|
||||
return (model, 'err', str(e))
|
||||
return (model, 'err', 'unknown')
|
||||
|
||||
ok = err = 0
|
||||
errors = []
|
||||
with ThreadPoolExecutor(max_workers=8) as ex:
|
||||
futs = [ex.submit(cp, it) for it in todo]
|
||||
for i, fut in enumerate(as_completed(futs), 1):
|
||||
model, status, e = fut.result()
|
||||
if status == 'ok':
|
||||
ok += 1
|
||||
else:
|
||||
err += 1
|
||||
errors.append(f'{model}: {e}')
|
||||
if i % 100 == 0:
|
||||
print(f' {i}/{len(todo)} (ok={ok} err={err})')
|
||||
|
||||
print(f'\n完成: 复制 {ok} 个, 失败 {err} 个')
|
||||
for e in errors[:20]:
|
||||
print(' !', e)
|
||||
print(f'step 目录现有 {len(os.listdir(DST))} 个文件')
|
||||
39
onebot-data/tools/eval-zipbundle.py
Normal file
39
onebot-data/tools/eval-zipbundle.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
评估"预生成每型号 zip"方案的收益与代价。
|
||||
扫描 web 发布目录的 step/,统计原始总体积 vs zip 后体积。
|
||||
"""
|
||||
import os, zipfile, io, random
|
||||
|
||||
WEB = r'D:/开发/OnebotCatalog/release/web_2026.08/step'
|
||||
if not os.path.isdir(WEB):
|
||||
raise SystemExit('找不到 ' + WEB)
|
||||
|
||||
files = [f for f in os.listdir(WEB) if f.lower().endswith(('.step', '.stp'))]
|
||||
print(f'step 文件数: {len(files)}')
|
||||
|
||||
tot = sum(os.path.getsize(os.path.join(WEB, f)) for f in files)
|
||||
print(f'原始总体积: {tot/1073741824:.2f} GB')
|
||||
|
||||
# 抽样测压缩率(避免全量压缩耗时)
|
||||
random.seed(42)
|
||||
sample = random.sample(files, min(40, len(files)))
|
||||
raw = zipped = 0
|
||||
for f in sample:
|
||||
p = os.path.join(WEB, f)
|
||||
n = os.path.getsize(p)
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED, compresslevel=6) as z:
|
||||
z.write(p, f[:-4] + '.step' if f.lower().endswith('.stp') else f)
|
||||
raw += n
|
||||
zipped += buf.tell()
|
||||
print(f'\n抽样 {len(sample)} 个:')
|
||||
print(f' 原始 {raw/1048576:.1f} MB -> zip {zipped/1048576:.1f} MB (省 {(1-zipped/raw)*100:.1f}%)')
|
||||
print(f'\n推算全量:')
|
||||
print(f' stepz 目录约 {(tot*(zipped/raw))/1073741824:.2f} GB')
|
||||
print(f' 部署总目录 = step {tot/1073741824:.2f}GB + stepz {(tot*(zipped/raw))/1073741824:.2f}GB'
|
||||
f' = {(tot*(1+zipped/raw))/1073741824:.2f} GB')
|
||||
print(f'\n购物车场景收益 (选 20 个型号, 平均 {tot/len(files)/1048576:.1f}MB/个):')
|
||||
per = tot / len(files)
|
||||
print(f' 现状: 传 {20*per/1048576:.0f} MB 原始文件')
|
||||
print(f' 改后: 传 {20*per*(zipped/raw)/1048576:.0f} MB 压缩文件')
|
||||
@@ -47,7 +47,8 @@ def render(pages, out_dir, start_no):
|
||||
"""pages = [(doc, page_idx), ...] 渲染为 p<NN>.png,返回下一个编号"""
|
||||
n = start_no
|
||||
for doc, i in pages:
|
||||
pix = doc[i].get_pixmap(dpi=DPI, colorspace=fitz.csGRAY)
|
||||
# 彩色输出 (官方图册本身是彩色的; 早期版本误用 csGRAY 转灰度)
|
||||
pix = doc[i].get_pixmap(dpi=DPI)
|
||||
pix.save(os.path.join(out_dir, f'p{n:02d}.png'))
|
||||
n += 1
|
||||
return n
|
||||
@@ -101,11 +102,14 @@ def main():
|
||||
os.makedirs(d, exist_ok=True)
|
||||
main_pdf = os.path.join(SRC, MAIN_CATALOG)
|
||||
doc = fitz.open(main_pdf)
|
||||
# 找 UCBT40 相关页(数据页)
|
||||
# 找 UCBT 相关页(数据页 + 夹臂页)
|
||||
# 注: 早期版本用 'UCBT40' 精确匹配 + 漏了 CLAMPING ARMS, 3 页只抓到 1 页。
|
||||
# 主目录里 UCBT 占连续 3 页 (idx 40/41/42), 前后分别是 UCBM 与新章节, 故按 UCBT 匹配即准确。
|
||||
idxs = []
|
||||
for i in range(doc.page_count):
|
||||
t = doc[i].get_text().upper()
|
||||
if 'UCBT40' in t and any(m in t for m in ('DIMENSIONS', 'CODIFICATION', 'CHARACTERISTICS')):
|
||||
if 'UCBT' in t and any(m in t for m in (
|
||||
'DIMENSIONS', 'CODIFICATION', 'CHARACTERISTICS', 'CLAMPING ARMS')):
|
||||
idxs.append(i)
|
||||
if idxs:
|
||||
n = render([(doc, i) for i in idxs], d, 1)
|
||||
|
||||
116
onebot-data/tools/gen-pending-manual.py
Normal file
116
onebot-data/tools/gen-pending-manual.py
Normal file
@@ -0,0 +1,116 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
为待补分类(定位销/勾销缸/翻转缸/电缸)生成附图。
|
||||
来源: Univer 官方图册 PDF(X盘 官方图册 / 选型表/spanner)。
|
||||
输出: manual/<系列>/p<NN>.png + <系列>_manual.pdf
|
||||
用法: PYTHONUTF8=1 python gen-pending-manual.py [--clean]
|
||||
"""
|
||||
import fitz, os, shutil, sys
|
||||
|
||||
SRC = r'X:/新开发产品-Research and Development/2_产品资料/Univer资料/官方图册'
|
||||
SPANNER = r'X:/新开发产品-Research and Development/2_产品资料/Univer资料/选型表/spanner'
|
||||
DST = r'D:/开发/OnebotCatalog/onebot-data/catalog/manual'
|
||||
DPI = 110
|
||||
CLEAN = '--clean' in sys.argv
|
||||
|
||||
# 系列 -> (目录, [pdf 名]);pdf 名不含扩展名
|
||||
SPECS = {
|
||||
# ---- 定位销 ----
|
||||
'ULA': (SRC, ['ula40cten_en419', 'ula63cten_en407']),
|
||||
'ULB': (SRC, ['ulb32cten_en423']),
|
||||
'ULL': (SRC, ['ull50cten_en411']),
|
||||
'ULM': (SRC, ['ulm50cten_en410']),
|
||||
'ULO': (SRC, ['ulo50cten_en413']),
|
||||
'ULP': (SRC, ['ulp32cten_en404', 'ulp50cten_en405', 'ulp63cten_en406']),
|
||||
'ULR': (SRC, ['ulr50nken_en476', 'ulr63cten_en477']),
|
||||
'ULT': (SRC, ['ult50cten_en408']),
|
||||
'ULX': (SRC, ['ulx50cten_en425']),
|
||||
'ULY': (SRC, ['uly40cten_en426']),
|
||||
'ULZ': (SRC, ['ulz40cten_en428']),
|
||||
# ---- 勾销缸 ----
|
||||
'USP': (SRC, ['usp32cten_en355', 'usp50cten_en437', 'usp50ucten_en168']),
|
||||
'USL': (SRC, ['usl50cten_en171']),
|
||||
'USR': (SRC, ['usr40cten_en436']),
|
||||
'USS': (SRC, ['uss50cten_en433', 'uss63cten_en434']),
|
||||
'USSL': (SRC, ['ussl50cten_en358']),
|
||||
# ---- 翻转缸 ----
|
||||
'UAGP': (SRC, ['uagp075wcten_en580', 'uagp075bcten_en181', 'uagp080wcten_en174',
|
||||
'uagp080wcten_en573', 'uagp120bcten_en175', 'uagp120wcten_en574',
|
||||
'uagp150bcten_en178', 'uagp150wcten_en577', 'uagp155wcten_en583',
|
||||
'uagp170bnken_en176', 'uagp170wcten_en575', 'uagp210bcten_en182',
|
||||
'uagp210wcten_en581', 'uagp215bcten_en373', 'uagp215wcten_en584',
|
||||
'uagp300bcten_en177', 'uagp300wcten_en576', 'uagp350bcten_en183',
|
||||
'uagp350wcten_en582', 'uagp355bcten_en374', 'uagp355wcten_en585',
|
||||
'uagp600bcten_en179', 'uagp600wcten_en578', 'uagp605bcten_en180',
|
||||
'uagp605wcten_en579']),
|
||||
# LAGP 官方已改名 UAGP,图册在选型表/spanner(老版)
|
||||
'LAGP': (SPANNER, ['lagp075_en_c181', 'lagp120_en_c175', 'lagp150_en_c178',
|
||||
'lagp155_en_c350', 'lagp170_en_c176', 'lagp210_en_c182',
|
||||
'lagp215nwrev-00-0119en_en373', 'lagp300_en_c177']),
|
||||
# ---- 电缸 ----
|
||||
'LAE': (SRC, ['lae140_en_c220', 'lae225_en_c221', 'lae350_en_c222']),
|
||||
}
|
||||
|
||||
|
||||
def classify(page):
|
||||
t = page.get_text().upper()
|
||||
if 'ACCESSORIES' in t or 'CLAMPING ARMS' in t or 'BLADE ARMS' in t:
|
||||
return 'arm'
|
||||
if 'DIMENSIONS' in t:
|
||||
return 'dim'
|
||||
if 'CHARACTERISTICS' in t or 'CODIFICATION' in t:
|
||||
return 'char'
|
||||
return 'other'
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(DST, exist_ok=True)
|
||||
missing = []
|
||||
for series, (srcdir, pdfs) in SPECS.items():
|
||||
d = os.path.join(DST, series)
|
||||
if CLEAN and os.path.isdir(d):
|
||||
shutil.rmtree(d)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
docs, all_pages = [], []
|
||||
for name in pdfs:
|
||||
p = os.path.join(srcdir, name + '.pdf')
|
||||
if not os.path.exists(p):
|
||||
missing.append(f'{series}: {name}')
|
||||
continue
|
||||
doc = fitz.open(p)
|
||||
docs.append((name, doc))
|
||||
for i in range(doc.page_count):
|
||||
all_pages.append((doc, i, classify(doc[i])))
|
||||
if not all_pages:
|
||||
print(f'[{series}] 无可用源 PDF')
|
||||
continue
|
||||
|
||||
order = {'char': 0, 'dim': 1, 'arm': 2, 'other': 3}
|
||||
all_pages.sort(key=lambda x: order[x[2]])
|
||||
n = 1
|
||||
for doc, i, _k in all_pages:
|
||||
# 彩色输出 (官方图册本身是彩色的; 早期版本误用 csGRAY 转灰度,
|
||||
# 体积约翻倍但相对整个数据包可忽略)
|
||||
doc[i].get_pixmap(dpi=DPI).save(
|
||||
os.path.join(d, f'p{n:02d}.png'))
|
||||
n += 1
|
||||
|
||||
out_pdf = fitz.open()
|
||||
for _name, doc in docs:
|
||||
out_pdf.insert_pdf(doc)
|
||||
outp = os.path.join(d, series + '_manual.pdf')
|
||||
out_pdf.save(outp, garbage=3, deflate=True)
|
||||
out_pdf.close()
|
||||
print(f'[{series}] {len(all_pages)} 页 / {len(pdfs)} PDF -> '
|
||||
f'{os.path.getsize(outp)/1024:.0f}KB')
|
||||
|
||||
if missing:
|
||||
print('\n源缺失:')
|
||||
for m in missing:
|
||||
print(' ', m)
|
||||
print('完成')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
524
onebot-data/tools/gen-pending-series.py
Normal file
524
onebot-data/tools/gen-pending-series.py
Normal file
@@ -0,0 +1,524 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
未完成分类真实数据清洗 —— 定位销(PIN)/勾销缸(HOK)/翻转缸(ROT)/电缸(ELEC)
|
||||
|
||||
命名规则来自 Univer 官方图册 CODIFICATION KEY(pdftotext 提取核对)。
|
||||
解析采用「官方值域校验 + 左对齐优先」的 DFS 填空,可正确处理省略了空位的型号
|
||||
(如 ULP50040SK0 = rod 空 + orient=S + sensor=K)。
|
||||
|
||||
用法:
|
||||
PYTHONUTF8=1 python gen-pending-series.py --scan # 只扫 X 盘并缓存
|
||||
PYTHONUTF8=1 python gen-pending-series.py --dry-run # 解析统计,不写文件
|
||||
PYTHONUTF8=1 python gen-pending-series.py --cat PIN # 只处理某分类
|
||||
PYTHONUTF8=1 python gen-pending-series.py # 全部生成 CSV/meta
|
||||
PYTHONUTF8=1 python gen-pending-series.py --copy # 同时复制 STEP(X盘慢)
|
||||
"""
|
||||
import os, re, sys, json, shutil, collections
|
||||
|
||||
BASE = r'X:/新开发产品-Research and Development/1_产品3D数模(发客户仿真)'
|
||||
DST = r'D:/开发/OnebotCatalog/onebot-data/catalog'
|
||||
CACHE = r'D:/开发/OnebotCatalog/onebot-data/tools/_scan_cache.json'
|
||||
|
||||
ARGS = sys.argv[1:]
|
||||
DRY = '--dry-run' in ARGS
|
||||
DO_COPY = '--copy' in ARGS
|
||||
DO_SCAN = '--scan' in ARGS
|
||||
ONLY_CAT = ARGS[ARGS.index('--cat') + 1].upper() if '--cat' in ARGS else None
|
||||
|
||||
# 所有待扫目录(各分类共用,一次扫完缓存)
|
||||
ALL_DIRS = ['2-伸缩销', '2019 Math Data 铁臂', '2019-NEW product', '2020UNICLAMP数模汇总',
|
||||
'3-勾销缸', '4-翻转缸', '1-夹紧缸/电缸', '5-夹臂', 'GR8_REV02']
|
||||
|
||||
# ---------------------------------------------------------------- 官方值域
|
||||
D_SENSOR_PIN = set('NKDJY') # 定位销传感器
|
||||
D_SENSOR_PIV = set('NKYJA') # 翻转缸传感器
|
||||
D_SENSOR_PC = set('NKDJY') # 勾销缸传感器
|
||||
D_ORIENT = set('XEONS')
|
||||
D_MOUNT = set('AS')
|
||||
D_ROD_ULP = set('ABCDEFHILM')
|
||||
D_ROD_ULA = set('AGNP')
|
||||
D_ROD_ULB = set('BCE')
|
||||
D_ROD_ULT = set('AG')
|
||||
D_MODEL = set('JRF')
|
||||
D_PDIA = set('123456')
|
||||
D_LOC = set('01')
|
||||
D_HOOK = set('SD')
|
||||
D_HOOKPOS = set('BC')
|
||||
D_TPOS = set('OPVZ')
|
||||
D_BRAKE = set('BMW')
|
||||
D_CONN = set('SDFPC')
|
||||
D_LAEPOS = set('NOV')
|
||||
D_LAESTY = set('CDSN')
|
||||
|
||||
# ---------------------------------------------------------------- 中文显示名
|
||||
SENSOR_PIN = {'N': '无传感器(带保护板)', 'K': '电子 PNP M12 (DF-K)',
|
||||
'D': '气动传感器 (DF-UPNW)', 'J': '电子 NPN 光学 (DF-J)',
|
||||
'Y': '电子 PNP M12 白LED (DF-Y)', '': '不适用'}
|
||||
SENSOR_PIV = {'N': '无传感器(带保护板)', 'K': '电子 PNP M12 (DF-K)',
|
||||
'Y': '电子 PNP M12 白LED (DF-Y)', 'J': '电子 NPN M12 (DF-J)',
|
||||
'A': '电子 PNP 光学 M12 + 制动传感器', '': '不适用'}
|
||||
ORIENT = {'X': '对称', 'E': '东', 'O': '西', 'N': '北', 'S': '南', '': '不适用'}
|
||||
MOUNT = {'A': '55 mm', 'S': '标准', '': '不适用'}
|
||||
ROD_ULP = {'A': '偏移销杆', 'B': '成型端杆', 'C': '十字成型端 内Ø16', 'D': '偏移销杆(加大销孔)',
|
||||
'E': '十字成型端 内Ø10', 'F': 'NAAMS 兼容 内Ø16', 'H': '平端 内Ø10',
|
||||
'I': '平端 内Ø12', 'L': '内Ø12', 'M': '内Ø20', '': '不适用'}
|
||||
ROD_ULA = {'A': '偏移销杆(2螺钉1销)', 'N': '偏移销杆反孔(1螺钉2销)',
|
||||
'G': '偏移销杆 杆面与本体中轴对齐', 'P': '偏移销杆反孔 杆面与中轴对齐', '': '不适用'}
|
||||
ROD_ULB = {'B': '成型端杆', 'C': '十字成型端 内Ø16', 'E': '十字成型端 内Ø10', '': '不适用'}
|
||||
ROD_ULT = {'A': '偏移销杆', 'G': '偏移销杆 杆面与本体中轴对齐', '': '不适用'}
|
||||
MODEL_DISP = {'J': '标准', 'R': '带机械锁紧/解锁装置', 'F': '无补偿', '': '不适用'}
|
||||
LOCATING = {'0': '不含定位销', '1': '含定位销', '': '不适用'}
|
||||
HOOK = {'S': '单钩', 'D': '双钩', '': '不适用'}
|
||||
HOOKPOS = {'B': '标准', 'C': '旋转180°', '': '不适用'}
|
||||
TABLEPOS = {'O': '水平 90°', 'P': '水平 O 镜像', 'V': '垂直 180°', 'Z': '垂直 V 镜像', '': '不适用'}
|
||||
BRAKE = {'B': '带制动', 'M': '带制动+手动解锁', 'W': '无制动', '': '不适用'}
|
||||
CONN = {'S': '左侧(标准)', 'D': '右侧', 'F': '前侧', 'P': '后侧', '': '不适用'}
|
||||
# LAGP 旧码特有值(官方图册已改名 UAGP,无记载,按数据实际取值标注)
|
||||
SENSOR_PIV_LEGACY = dict(SENSOR_PIV, U='旧码传感器位')
|
||||
LAEPOS = {'N': '无臂', 'O': '180°', 'V': '90°', '': '不适用'}
|
||||
LAESTY = {'C': '中央 15mm offset', 'D': '右侧 15mm offset', 'S': '左侧 15mm offset',
|
||||
'N': '无臂', '': '不适用'}
|
||||
|
||||
# 维度元信息: code -> (zh, en, keywords, unit, type)
|
||||
DIM_META = {
|
||||
'size': ('规格', 'Size', 'guige size', 'mm', 'number'),
|
||||
'stroke': ('行程', 'Stroke', 'xingcheng stroke', 'mm', 'number'),
|
||||
'rod': ('杆端样式', 'Rod End Style', 'ganduan rod end', '', 'enum'),
|
||||
'orient': ('杆方向', 'Rod Orientation', 'ganfangxiang orientation', '', 'enum'),
|
||||
'mount': ('侧装方式', 'Side Mounting', 'cezhuang mounting', '', 'enum'),
|
||||
'sensor': ('传感器', 'Sensor', 'chuanganqi sensor', '', 'enum'),
|
||||
'pcmodel': ('型式', 'Model', 'xingshi pin clamp model', '', 'enum'),
|
||||
'prog': ('流水号', 'Progressive No.', 'liushuihao prog special', '', 'enum'),
|
||||
'pin_dia': ('销径', 'Pin Diameter', 'xiaojing pin dia', 'mm', 'number'),
|
||||
'locating': ('定位销', 'Locating Pin', 'dingweixiao locating pin', '', 'enum'),
|
||||
'hook': ('挂钩', 'Hook', 'guagou hook', '', 'enum'),
|
||||
'hook_pos': ('挂钩位置', 'Hook Position', 'guagouweizhi hook pos', '', 'enum'),
|
||||
'table_pos': ('工作台位置', 'Table Position', 'gongzuotai table pos', '', 'enum'),
|
||||
'rotation': ('翻转角度', 'Pivot Rotation', 'fanzhuanjiaodu rotation', '°', 'number'),
|
||||
'brake': ('制动系统', 'Brake System', 'zhidong brake', '', 'enum'),
|
||||
'conn': ('接口方向', 'Connections', 'jiekou connections', '', 'enum'),
|
||||
'pos': ('臂位置', 'Arm Position', 'biweizhi arm pos', '', 'enum'),
|
||||
'style': ('臂样式', 'Arm Style', 'biyangshi arm style', '', 'enum'),
|
||||
'control': ('控制单元', 'Control Unit', 'kongzhi control unit', '', 'enum'),
|
||||
}
|
||||
|
||||
DIM_DISPLAY = {
|
||||
'sensor': None, 'orient': ORIENT, 'mount': MOUNT, 'pcmodel': MODEL_DISP,
|
||||
'locating': LOCATING, 'hook': HOOK, 'hook_pos': HOOKPOS,
|
||||
'table_pos': TABLEPOS, 'brake': BRAKE, 'conn': CONN,
|
||||
'pos': LAEPOS, 'style': LAESTY,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 工具
|
||||
def fill_dims(s, dims, domains, prefer_left=True):
|
||||
"""把字符串 s 填进 dims(允许维度为空),左对齐优先。返回 dict 或 None。"""
|
||||
def dfs(i, j):
|
||||
if i == len(s):
|
||||
return {d: '' for d in dims[j:]}
|
||||
if j >= len(dims):
|
||||
return None
|
||||
order = [True, False] if prefer_left else [False, True]
|
||||
for eat in order:
|
||||
if eat:
|
||||
if s[i] in domains.get(dims[j], set()):
|
||||
r = dfs(i + 1, j + 1)
|
||||
if r is not None:
|
||||
return dict(r, **{dims[j]: s[i]})
|
||||
else:
|
||||
# 当前维度为空
|
||||
if j + 1 <= len(dims):
|
||||
r = dfs(i, j + 1)
|
||||
if r is not None:
|
||||
return dict(r, **{dims[j]: ''})
|
||||
return None
|
||||
return dfs(0, 0)
|
||||
|
||||
|
||||
def norm(raw):
|
||||
"""去全部扩展名 / 大写 / 去空格 / 组合件取 '-' 前主码"""
|
||||
base = raw.strip()
|
||||
while True: # 处理 .stp.STP 这类多重扩展名
|
||||
stem, ext = os.path.splitext(base)
|
||||
if ext.lower() in ('.stp', '.step'):
|
||||
base = stem
|
||||
else:
|
||||
break
|
||||
s = re.sub(r'\s+', '', base.upper())
|
||||
if '-' in s: # 与定位销组合的勾销缸:取主码
|
||||
s = s.split('-')[0]
|
||||
return s
|
||||
|
||||
|
||||
def dig(s, n):
|
||||
return s[:n] if len(s) >= n else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 解析器
|
||||
def parse_pin(stem, ver, rod_dom):
|
||||
"""UL<ver><size2><stroke3><dims...><sensor1><rev1>[<atex1>]"""
|
||||
m = re.match(r'^UL' + ver + r'(\d{2})(\d{3})(.+)$', stem)
|
||||
if not m:
|
||||
return None
|
||||
size, stroke, rest = m.groups()
|
||||
if rest.endswith('X') and len(rest) > 4: # ATEX
|
||||
rest = rest[:-1]
|
||||
if len(rest) < 2:
|
||||
return None
|
||||
if not rest[-1].isdigit(): # product revision
|
||||
return None
|
||||
body = rest[:-1]
|
||||
dims = {'A': ['rod', 'mount', 'sensor'], 'B': ['rod', 'sensor'],
|
||||
'L': ['rod', 'orient', 'sensor'], 'M': ['rod', 'orient', 'sensor'],
|
||||
'O': ['rod', 'sensor'], 'P': ['rod', 'orient', 'sensor'],
|
||||
'R': ['rod', 'orient', 'sensor'], 'T': ['rod', 'sensor'],
|
||||
'X': ['rod', 'sensor'], 'Y': ['rod', 'orient', 'sensor'],
|
||||
'Z': ['rod', 'orient', 'sensor']}[ver]
|
||||
domains = {'rod': rod_dom, 'orient': D_ORIENT, 'mount': D_MOUNT,
|
||||
'sensor': D_SENSOR_PIN}
|
||||
got = fill_dims(body, dims, domains)
|
||||
if got is None:
|
||||
return None
|
||||
return dict(got, series='UL' + ver, size=int(size), stroke=int(stroke))
|
||||
|
||||
|
||||
def parse_usp(stem, series):
|
||||
"""US[P|L]<size2><model1><pdia1><loc1><prog3><sensor1><rev1>[<variant>] _ = 该位为空"""
|
||||
m = re.match(r'^US[PL](\d{2})([A-Z_])([0-9_])([0-9_])([0-9_]{3})([A-Z_])([0-9_])[A-Z]*$', stem)
|
||||
if not m:
|
||||
return None
|
||||
size, mdl, pdia, loc, pg, sen, _rev = m.groups()
|
||||
return {'series': series, 'size': int(size),
|
||||
'pcmodel': '' if mdl == '_' else mdl,
|
||||
'pin_dia': int(pdia) if pdia.isdigit() else None,
|
||||
'locating': '' if loc == '_' else loc,
|
||||
'prog': '' if set(pg) == {'_'} else pg,
|
||||
'sensor': '' if sen == '_' else sen}
|
||||
|
||||
|
||||
def parse_usr(stem):
|
||||
"""USR<size2><model1><pdia1><loc1><hook1><prog3><sensor1><rev1> _ = 该位为空"""
|
||||
m = re.match(r'^USR(\d{2})([A-Z_])([0-9_])([0-9_])([A-Z_])([0-9_]{3})([A-Z_])([0-9_])$', stem)
|
||||
if not m:
|
||||
return None
|
||||
size, mdl, pdia, loc, hook, pg, sen, _rev = m.groups()
|
||||
return {'series': 'USR', 'size': int(size),
|
||||
'pcmodel': '' if mdl == '_' else mdl,
|
||||
'pin_dia': int(pdia) if pdia.isdigit() else None,
|
||||
'locating': '' if loc == '_' else loc,
|
||||
'hook': '' if hook == '_' else hook,
|
||||
'prog': '' if set(pg) == {'_'} else pg,
|
||||
'sensor': '' if sen == '_' else sen}
|
||||
|
||||
|
||||
def parse_uss(stem, series):
|
||||
"""US[SL|S]<size2><model1><hookpos1><pdia1><loc1><prog3><sensor1><rev1>"""
|
||||
m = re.match(r'^US(?:SL|S)(\d{2})([A-Z])([A-Z])(\d)(\d)(\d{3})([A-Z])(\d)$', stem)
|
||||
if not m:
|
||||
return None
|
||||
size, mdl, hpos, pdia, loc, pg, sen, _rev = m.groups()
|
||||
return {'series': series, 'size': int(size), 'pcmodel': mdl, 'hook_pos': hpos,
|
||||
'pin_dia': int(pdia), 'locating': loc, 'prog': pg, 'sensor': sen}
|
||||
|
||||
|
||||
def parse_uagp(stem):
|
||||
"""UAGP<size3><tpos1><sensor1><rot3><brake1><conn1><rev> _ 表示该位为空"""
|
||||
m = re.match(r'^UAGP(\d{3})([A-Z_])([A-Z_])(\d{3})([A-Z])([A-Z_])[A-Z0-9_]*$', stem)
|
||||
if not m:
|
||||
return None
|
||||
size, tp, sen, rot, brk, cn = m.groups()
|
||||
return {'series': 'UAGP', 'size': int(size),
|
||||
'table_pos': '' if tp == '_' else tp,
|
||||
'sensor': '' if sen == '_' else sen,
|
||||
'rotation': int(rot), 'brake': brk,
|
||||
'conn': '' if cn == '_' else cn}
|
||||
|
||||
|
||||
def parse_lagp(stem):
|
||||
"""LAGP<size3><tpos1><sensor1><rot3><brake1>[<conn1>] (旧码,部分无 conn)"""
|
||||
m = re.match(r'^LAGP(\d{3})([A-Z_])([A-Z_])(\d{3})([A-Z])([A-Z]?)$', stem)
|
||||
if not m:
|
||||
return None
|
||||
size, tp, sen, rot, brk, cn = m.groups()
|
||||
if sen not in D_SENSOR_PIV and sen != '_':
|
||||
sen = 'U' # LAGP063 早期码用 U 表示传感器位
|
||||
return {'series': 'LAGP', 'size': int(size),
|
||||
'table_pos': '' if tp == '_' else tp,
|
||||
'sensor': '' if sen == '_' else sen,
|
||||
'rotation': int(rot), 'brake': brk, 'conn': cn or ''}
|
||||
|
||||
|
||||
def parse_lae(stem):
|
||||
"""LAE<size3><pos1><style1><controlN>"""
|
||||
m = re.match(r'^LAE(\d{3})([A-Z])([A-Z])(\d+)$', stem)
|
||||
if not m:
|
||||
return None
|
||||
size, pos, sty, ctl = m.groups()
|
||||
return {'series': 'LAE', 'size': int(size), 'pos': pos, 'style': sty, 'control': ctl}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 系列配置
|
||||
SPECS = [
|
||||
dict(cat='PIN', series='ULA', parse=lambda s: parse_pin(s, 'A', D_ROD_ULA),
|
||||
rod=ROD_ULA, zh='ULA 气动可伸缩定位销(双杆可调)',
|
||||
en='ULA Pneumatic Retractable Locating Pin Unit, double orientable rod',
|
||||
kw='ula dingweixiao locating pin'),
|
||||
dict(cat='PIN', series='ULB', parse=lambda s: parse_pin(s, 'B', D_ROD_ULB),
|
||||
rod=ROD_ULB, zh='ULB 气动可伸缩定位销(后置传感器)',
|
||||
en='ULB Pneumatic Retractable Locating Pin Unit, rear sensor, single rod',
|
||||
kw='ulb dingweixiao locating pin'),
|
||||
dict(cat='PIN', series='ULL', parse=lambda s: parse_pin(s, 'L', D_ROD_ULP),
|
||||
rod=ROD_ULP, zh='ULL 手动可伸缩定位销(手杆)',
|
||||
en='ULL Manual Retractable Locating Pin Unit with Hand Lever',
|
||||
kw='ull dingweixiao shoudong manual'),
|
||||
dict(cat='PIN', series='ULM', parse=lambda s: parse_pin(s, 'M', D_ROD_ULP),
|
||||
rod=ROD_ULP, zh='ULM 带手杆气动可伸缩定位销',
|
||||
en='ULM Pneumatic Retractable Locating Pin Unit with Hand Lever',
|
||||
kw='ulm dingweixiao shougan lever'),
|
||||
dict(cat='PIN', series='ULO', parse=lambda s: parse_pin(s, 'O', D_ROD_ULA),
|
||||
rod=ROD_ULA, zh='ULO 气动可伸缩定位销(双杆带手杆)',
|
||||
en='ULO Pneumatic Retractable Locating Pin Unit, double rod with hand lever',
|
||||
kw='ulo dingweixiao double rod'),
|
||||
dict(cat='PIN', series='ULP', parse=lambda s: parse_pin(s, 'P', D_ROD_ULP),
|
||||
rod=ROD_ULP, zh='ULP 气动可伸缩定位销(单杆)',
|
||||
en='ULP Pneumatic Retractable Locating Pin Unit, single rod',
|
||||
kw='ulp dingweixiao locating pin'),
|
||||
dict(cat='PIN', series='ULR', parse=lambda s: parse_pin(s, 'R', D_ROD_ULP),
|
||||
rod=ROD_ULP, zh='ULR 紧凑型气动可伸缩定位销',
|
||||
en='ULR Compact Pneumatic Retractable Locating Pin Unit',
|
||||
kw='ulr dingweixiao compact'),
|
||||
dict(cat='PIN', series='ULT', parse=lambda s: parse_pin(s, 'T', D_ROD_ULT),
|
||||
rod=ROD_ULT, zh='ULT 肘节机构气动可伸缩定位销(不可逆)',
|
||||
en='ULT Irreversible Pneumatic Retractable Locating Pin Unit, double rod',
|
||||
kw='ult dingweixiao toggle'),
|
||||
dict(cat='PIN', series='ULX', parse=lambda s: parse_pin(s, 'X', D_ROD_ULT),
|
||||
rod=ROD_ULT, zh='ULX 肘节机构气动可伸缩定位销(带手杆)',
|
||||
en='ULX Irreversible Pneumatic Locating Pin Unit with Hand Lever',
|
||||
kw='ulx dingweixiao toggle lever'),
|
||||
dict(cat='PIN', series='ULY', parse=lambda s: parse_pin(s, 'Y', D_ROD_ULP),
|
||||
rod=ROD_ULP, zh='ULY 肘节机构气动可伸缩定位销(单杆带手杆)',
|
||||
en='ULY Irreversible Pneumatic Locating Pin Unit, single rod with hand lever',
|
||||
kw='uly dingweixiao toggle'),
|
||||
dict(cat='PIN', series='ULZ', parse=lambda s: parse_pin(s, 'Z', D_ROD_ULP),
|
||||
rod=ROD_ULP, zh='ULZ 肘节机构气动可伸缩定位销(单杆)',
|
||||
en='ULZ Irreversible Pneumatic Retractable Locating Pin Unit, single rod',
|
||||
kw='ulz dingweixiao toggle'),
|
||||
|
||||
dict(cat='HOK', series='USP', parse=lambda s: parse_usp(s, 'USP'), rod=None,
|
||||
zh='USP 气动勾销缸', en='USP Pneumatic Pin Clamp', kw='usp gouxiao pin clamp'),
|
||||
dict(cat='HOK', series='USL', parse=lambda s: parse_usp(s, 'USL'), rod=None,
|
||||
zh='USL 手动勾销缸(手杆)', en='USL Manual Pin Clamp with Hand Lever',
|
||||
kw='usl gouxiao manual'),
|
||||
dict(cat='HOK', series='USR', parse=parse_usr, rod=None,
|
||||
zh='USR 紧凑型气动勾销缸', en='USR Pneumatic Compact Pin Clamp',
|
||||
kw='usr gouxiao compact'),
|
||||
dict(cat='HOK', series='USS', parse=lambda s: parse_uss(s, 'USS'), rod=None,
|
||||
zh='USS 带补偿气动勾销缸', en='USS Pneumatic Pin Clamp with Compensation',
|
||||
kw='uss gouxiao compensation'),
|
||||
dict(cat='HOK', series='USSL', parse=lambda s: parse_uss(s, 'USSL'), rod=None,
|
||||
zh='USSL 激光焊用气动勾销缸',
|
||||
en='USSL Pneumatic Pin Clamp for Laser Beam Applications',
|
||||
kw='ussl gouxiao laser'),
|
||||
|
||||
dict(cat='ROT', series='UAGP', parse=parse_uagp, rod=None,
|
||||
zh='UAGP 气动翻转缸', en='UAGP Pneumatic Power Pivot',
|
||||
kw='uagp fanzhuan power pivot'),
|
||||
dict(cat='ROT', series='LAGP', parse=parse_lagp, rod=None,
|
||||
zh='LAGP 气动翻转缸(旧型号)', en='LAGP Pneumatic Power Pivot (Legacy)',
|
||||
kw='lagp fanzhuan power pivot legacy'),
|
||||
|
||||
dict(cat='ELEC', series='LAE', parse=parse_lae, rod=None,
|
||||
zh='LAE 电动夹紧缸', en='LAE Electric Power Clamp',
|
||||
kw='lae diandong electric clamp'),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 扫描 / 缓存
|
||||
def do_scan():
|
||||
found = collections.defaultdict(list)
|
||||
for d in ALL_DIRS:
|
||||
root = os.path.join(BASE, d)
|
||||
if not os.path.isdir(root):
|
||||
print(f' ! 目录不存在: {d}')
|
||||
continue
|
||||
n0 = len(found)
|
||||
for dirpath, _, files in os.walk(root):
|
||||
for f in files:
|
||||
if f.lower().endswith(('.stp', '.step')):
|
||||
found[f].append(os.path.join(dirpath, f).replace('\\', '/'))
|
||||
print(f' {d}: +{len(found)-n0} 文件')
|
||||
with open(CACHE, 'w', encoding='utf-8') as fp:
|
||||
json.dump(found, fp)
|
||||
print(f'缓存写入 {CACHE} 共 {len(found)} 个唯一文件名')
|
||||
|
||||
|
||||
def load_scan():
|
||||
if not os.path.exists(CACHE):
|
||||
sys.exit(f'缺少扫描缓存,请先运行: --scan ({CACHE})')
|
||||
with open(CACHE, encoding='utf-8') as fp:
|
||||
return json.load(fp)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 生成
|
||||
def collect(spec, scanned):
|
||||
by_model, unresolved = {}, []
|
||||
for fname, paths in scanned.items():
|
||||
stem = norm(fname)
|
||||
if not stem.startswith(spec['series']):
|
||||
continue
|
||||
try:
|
||||
rec = spec['parse'](stem)
|
||||
except Exception as e:
|
||||
unresolved.append((stem, f'ERR {e}'))
|
||||
continue
|
||||
if rec is None or rec.get('series') != spec['series']:
|
||||
unresolved.append((stem, fname))
|
||||
continue
|
||||
# 占位/测试文件(如 USP50J_0___K0 销径为空)不是真实型号,丢弃
|
||||
if any(v is None for k, v in rec.items() if k not in ('series',)):
|
||||
unresolved.append((stem, fname + ' (关键参数为空, 丢弃)'))
|
||||
continue
|
||||
by_model.setdefault(stem, dict(rec, model=stem, src=paths[0]))
|
||||
return by_model, unresolved
|
||||
|
||||
|
||||
def build_meta(spec, records):
|
||||
order = ['size', 'stroke', 'rod', 'orient', 'mount', 'pos', 'style', 'pcmodel',
|
||||
'hook_pos', 'hook', 'pin_dia', 'locating', 'prog', 'table_pos', 'rotation',
|
||||
'brake', 'conn', 'control', 'sensor']
|
||||
# 主键 model=完整型号,绝不能当维度
|
||||
dims = [d for d in order if d != 'model' and any(d in r for r in records)]
|
||||
|
||||
params = []
|
||||
for d in dims:
|
||||
zh, en, kw, unit, typ = DIM_META[d]
|
||||
vals = sorted({r[d] for r in records if r.get(d) not in (None, '')})
|
||||
if typ == 'number':
|
||||
params.append({"code": d, "nameZh": zh, "nameEn": en, "keywords": kw,
|
||||
"unit": unit, "type": "number", "values": vals, "display": {}})
|
||||
continue
|
||||
has_empty = any(r.get(d, '') == '' for r in records)
|
||||
vlist = ([''] if has_empty else []) + [str(v) for v in vals]
|
||||
if d == 'sensor':
|
||||
dmap = SENSOR_PIV if spec['cat'] == 'ROT' else SENSOR_PIN
|
||||
if spec['series'] == 'LAGP':
|
||||
dmap = SENSOR_PIV_LEGACY
|
||||
elif d == 'rod':
|
||||
dmap = spec.get('rod') or ROD_ULP
|
||||
else:
|
||||
dmap = DIM_DISPLAY.get(d) or {}
|
||||
params.append({"code": d, "nameZh": zh, "nameEn": en, "keywords": kw,
|
||||
"unit": unit, "type": "enum", "values": vlist,
|
||||
"display": {k: dmap.get(k, k) for k in vlist}})
|
||||
# 剔除非信息维度: 值域为空(无可选值), 或枚举维度只有 [空串] 一种取值。
|
||||
# 保留"单一真值"维度(如单型号系列的 pcmodel=['J']) —— 否则参数全空导致
|
||||
# CSV 表头出现空列名, 构建器报"CSV 行字段不足"。
|
||||
params = [p for p in params
|
||||
if p['values'] and not (p['type'] == 'enum' and p['values'] == [''])]
|
||||
# 附件按 manual/<系列>/ 实际 png 数量生成 —— 不能硬编码 p01。
|
||||
# (曾踩坑: 先跑 gen-pending-manual 生成多页, 再跑本脚本重写 meta 时把附件
|
||||
# 覆盖回单页, 导致包内只有 1 张附图。此处扫目录自动对齐。)
|
||||
atts = []
|
||||
mdir = os.path.join(DST, 'manual', spec['series'])
|
||||
if os.path.isdir(mdir):
|
||||
for f in sorted(os.listdir(mdir)):
|
||||
if f.lower().endswith('.png'):
|
||||
atts.append({"kind": "manual", "lang": "",
|
||||
"source": f"manual/{spec['series']}/{f}", "model": None})
|
||||
for f in sorted(os.listdir(mdir)):
|
||||
if f.lower().endswith('.pdf'):
|
||||
atts.append({"kind": "datasheet", "lang": "",
|
||||
"source": f"manual/{spec['series']}/{f}", "model": None})
|
||||
if not atts:
|
||||
atts = [
|
||||
{"kind": "manual", "lang": "", "source": f"manual/{spec['series']}/p01.png", "model": None},
|
||||
{"kind": "datasheet", "lang": "", "source": f"manual/{spec['series']}/{spec['series']}_manual.pdf", "model": None},
|
||||
]
|
||||
return {
|
||||
"series": {
|
||||
"code": spec['series'], "nameZh": spec['zh'], "nameEn": spec['en'],
|
||||
"keywords": spec['kw'], "parameters": params, "rules": [],
|
||||
"naming": {"stepNameTemplate": "{model}.step"},
|
||||
"attachments": [], "variants": []
|
||||
},
|
||||
"attachments": atts
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if DO_SCAN:
|
||||
do_scan()
|
||||
return
|
||||
scanned = load_scan()
|
||||
print(f'扫描缓存: {len(scanned)} 个唯一文件名')
|
||||
|
||||
csv_dir = os.path.join(DST, 'csv')
|
||||
series_dir = os.path.join(DST, 'series')
|
||||
step_dir = os.path.join(DST, 'step')
|
||||
summary = collections.defaultdict(list)
|
||||
|
||||
for spec in SPECS:
|
||||
if ONLY_CAT and spec['cat'] != ONLY_CAT:
|
||||
continue
|
||||
records, unresolved = collect(spec, scanned)
|
||||
recs = sorted(records.values(), key=lambda r: r['model'])
|
||||
print(f"\n{'='*74}\n## {spec['cat']} / {spec['series']} 型号 {len(recs)} 个")
|
||||
summary[spec['cat']].append((spec['series'], len(recs)))
|
||||
if not recs:
|
||||
print(' (X盘无数据 —— 保留原 demo)')
|
||||
continue
|
||||
print(' 例:', {k: v for k, v in recs[0].items() if k != 'src'})
|
||||
if unresolved:
|
||||
print(f' 未解析 {len(unresolved)}:', [u[0] for u in unresolved[:8]])
|
||||
meta = build_meta(spec, recs)
|
||||
for p in meta['series']['parameters']:
|
||||
print(f" {p['code']:10s} {p['type']:6s} {p['values']}")
|
||||
|
||||
# 唯一性门禁: 参数组合必须唯一确定型号,否则选型会静默给错货
|
||||
pcs = [p['code'] for p in meta['series']['parameters']]
|
||||
seen = {}
|
||||
for r in recs:
|
||||
seen.setdefault(tuple(str(r.get(d, '')) for d in pcs), []).append(r['model'])
|
||||
dup = {k: v for k, v in seen.items() if len(v) > 1}
|
||||
if dup:
|
||||
print(f' !! 参数组合冲突 {len(dup)} 组 —— 会被静默错配:')
|
||||
for k, v in list(dup.items())[:5]:
|
||||
print(' ', dict(zip(pcs, k)), '->', v)
|
||||
else:
|
||||
print(f' [OK] 参数组合唯一 ({len(seen)} 组合 / {len(recs)} 型号)')
|
||||
|
||||
if DRY:
|
||||
continue
|
||||
|
||||
dims = [p['code'] for p in meta['series']['parameters']]
|
||||
def cv(x):
|
||||
return '' if x is None else str(x)
|
||||
rows = ['model_code,' + ','.join(dims) + ',step_file']
|
||||
for r in recs:
|
||||
rows.append(','.join([r['model']] + [cv(r.get(d, '')) for d in dims]
|
||||
+ [r['model'] + '.step']))
|
||||
with open(os.path.join(csv_dir, spec['series'] + '.csv'), 'w',
|
||||
encoding='utf-8-sig', newline='') as fp:
|
||||
fp.write('\r\n'.join(rows) + '\r\n')
|
||||
with open(os.path.join(series_dir, spec['series'] + '.meta.json'), 'w',
|
||||
encoding='utf-8') as fp:
|
||||
json.dump(meta, fp, ensure_ascii=False, indent=1)
|
||||
print(f" -> 写入 {spec['series']}.csv / .meta.json")
|
||||
|
||||
if DO_COPY:
|
||||
n = 0
|
||||
for r in recs:
|
||||
dst = os.path.join(step_dir, r['model'] + '.step')
|
||||
if not os.path.exists(dst):
|
||||
shutil.copy2(r['src'], dst)
|
||||
n += 1
|
||||
print(f' -> 复制 STEP {n} 个')
|
||||
|
||||
print(f"\n{'='*74}\n### 汇总")
|
||||
for cat, lst in summary.items():
|
||||
tot = sum(n for _, n in lst)
|
||||
print(f' {cat:5s} 共 {tot:4d} 型号 ' + ', '.join(f'{s}:{n}' for s, n in lst))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
93
onebot-data/tools/gen-series-thumbs.py
Normal file
93
onebot-data/tools/gen-series-thumbs.py
Normal file
@@ -0,0 +1,93 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
为没有产品图的系列生成统一风格占位缩略图 (web/series/<系列码>.webp)。
|
||||
|
||||
背景: 首页系列缩略图网格需要每个系列一张图。bom-viewer 项目已有 27 张真实产品图
|
||||
(来自 onebot251231 网站后台), 其余 13 个系列 (KC/M/MS/MAL/KS 等基础气缸 +
|
||||
UFL/LAGP 老码系列) 暂无 → 生成占位图, 用户后续用真图覆盖同名文件即可。
|
||||
|
||||
风格对齐卡片: 浅灰底 (--gray-50 #f9fafb) + 青色系列码大字 (--primary-700 #0f766e)
|
||||
+ "图片待补" 小字, 尺寸 500x450 (与 bom-viewer 产品图一致)。
|
||||
|
||||
用法: PYTHONUTF8=1 python gen-series-thumbs.py
|
||||
"""
|
||||
import os
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
WEB_SERIES = r'D:/开发/OnebotCatalog/web/series'
|
||||
|
||||
# 需要占位的系列 (bom-viewer 里没有对应的产品图)
|
||||
PLACEHOLDER = ['KC', 'KCB', 'KSB', 'KCS', 'KS', 'KSU',
|
||||
'M', 'MS', 'MSC', 'MAL', 'MALC', 'UFL', 'LAGP']
|
||||
|
||||
W, H = 500, 450
|
||||
BG = (249, 250, 251) # --gray-50
|
||||
CODE_COLOR = (15, 118, 110) # --primary-700
|
||||
SUB_COLOR = (156, 163, 175) # --gray-400
|
||||
BOX = (229, 231, 235) # --gray-200
|
||||
|
||||
FONT_CANDIDATES = [
|
||||
r'C:\Windows\Fonts\consolab.ttf', # Consolas Bold — 与卡片等宽字体一致
|
||||
r'C:\Windows\Fonts\consola.ttf',
|
||||
r'C:\Windows\Fonts\msyhbd.ttc',
|
||||
]
|
||||
CN_FONT_CANDIDATES = [
|
||||
r'C:\Windows\Fonts\msyh.ttc',
|
||||
r'C:\Windows\Fonts\msyhl.ttc',
|
||||
]
|
||||
|
||||
|
||||
def load_font(paths, size):
|
||||
for p in paths:
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
return ImageFont.truetype(p, size)
|
||||
except Exception:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def make_one(code, out_path):
|
||||
im = Image.new('RGB', (W, H), BG)
|
||||
d = ImageDraw.Draw(im)
|
||||
|
||||
# 虚线圆角外框 (暗示"这里是图位")
|
||||
d.rounded_rectangle([26, 26, W - 26, H - 26], radius=18, outline=BOX, width=2)
|
||||
|
||||
# 系列码大字 (居中)
|
||||
f_code = load_font(FONT_CANDIDATES, 84)
|
||||
bbox = d.textbbox((0, 0), code, font=f_code)
|
||||
d.text(((W - (bbox[2] - bbox[0])) / 2 - bbox[0],
|
||||
H / 2 - 62 - (bbox[3] - bbox[1]) / 2), code, font=f_code, fill=CODE_COLOR)
|
||||
|
||||
# 分隔短线
|
||||
d.line([(W / 2 - 34, H / 2 + 8), (W / 2 + 34, H / 2 + 8)], fill=BOX, width=2)
|
||||
|
||||
# "图片待补" (中英各一行, 居中)
|
||||
f_cn = load_font(CN_FONT_CANDIDATES, 20)
|
||||
f_en = load_font(FONT_CANDIDATES, 15)
|
||||
t1 = '图片待补'
|
||||
b1 = d.textbbox((0, 0), t1, font=f_cn)
|
||||
d.text(((W - (b1[2] - b1[0])) / 2 - b1[0], H / 2 + 30), t1, font=f_cn, fill=SUB_COLOR)
|
||||
t2 = 'IMAGE PENDING'
|
||||
b2 = d.textbbox((0, 0), t2, font=f_en)
|
||||
d.text(((W - (b2[2] - b2[0])) / 2 - b2[0], H / 2 + 62), t2, font=f_en, fill=SUB_COLOR)
|
||||
|
||||
im.save(out_path, 'WEBP', quality=88, method=6)
|
||||
return os.path.getsize(out_path)
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(WEB_SERIES, exist_ok=True)
|
||||
total = 0
|
||||
for code in PLACEHOLDER:
|
||||
p = os.path.join(WEB_SERIES, code + '.webp')
|
||||
sz = make_one(code, p)
|
||||
total += sz
|
||||
print(f' {code:6s} -> {sz/1024:5.1f} KB')
|
||||
print(f'\n生成 {len(PLACEHOLDER)} 张占位图, 共 {total/1024:.0f} KB')
|
||||
print('替换方式: 用真图覆盖 web/series/<系列码>.webp 即可 (无需改代码)')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
85
onebot-data/tools/gen-step-gz.py
Normal file
85
onebot-data/tools/gen-step-gz.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
为网页发布目录生成 STEP 压缩副档 (.step.gz),供浏览器端按需解压下载。
|
||||
|
||||
背景: 网页版购物车打包是逐个 fetch 原始 STEP (翻转缸单个 7-13MB), 选 20 个型号
|
||||
要传 40MB+。预生成 gz 副档后前端优先取 .gz 并在浏览器解压 (DecompressionStream),
|
||||
传输量降至约 1/4.5。
|
||||
|
||||
用法:
|
||||
PYTHONUTF8=1 python gen-step-gz.py <web目录> # 全量生成(跳过已存在)
|
||||
PYTHONUTF8=1 python gen-step-gz.py <web目录> --force # 强制重建
|
||||
PYTHONUTF8=1 python gen-step-gz.py <web目录> --clean # 删除已有 .gz 后重建
|
||||
"""
|
||||
import os, sys, gzip, shutil, time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
args = [a for a in sys.argv[1:] if not a.startswith('--')]
|
||||
FORCE = '--force' in sys.argv
|
||||
CLEAN = '--clean' in sys.argv
|
||||
if not args:
|
||||
sys.exit('用法: python gen-step-gz.py <web目录> [--force|--clean]')
|
||||
WEB = args[0]
|
||||
STEP = os.path.join(WEB, 'step')
|
||||
if not os.path.isdir(STEP):
|
||||
sys.exit('找不到 ' + STEP)
|
||||
|
||||
if CLEAN:
|
||||
n = 0
|
||||
for f in os.listdir(STEP):
|
||||
if f.lower().endswith('.gz'):
|
||||
os.remove(os.path.join(STEP, f))
|
||||
n += 1
|
||||
print(f'已清理 {n} 个 .gz')
|
||||
|
||||
files = [f for f in os.listdir(STEP)
|
||||
if f.lower().endswith(('.step', '.stp'))]
|
||||
todo = []
|
||||
for f in files:
|
||||
src = os.path.join(STEP, f)
|
||||
dst = src + '.gz'
|
||||
if FORCE or not os.path.exists(dst):
|
||||
todo.append((src, dst, f))
|
||||
print(f'step 文件 {len(files)} 个, 待压缩 {len(todo)} 个')
|
||||
|
||||
|
||||
def gz_one(item):
|
||||
src, dst, name = item
|
||||
tmp = dst + '.part'
|
||||
try:
|
||||
with open(src, 'rb') as fi, gzip.open(tmp, 'wb', compresslevel=6) as fo:
|
||||
shutil.copyfileobj(fi, fo, 1024 * 1024)
|
||||
os.replace(tmp, dst)
|
||||
return (name, os.path.getsize(src), os.path.getsize(dst), None)
|
||||
except Exception as e:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return (name, 0, 0, str(e))
|
||||
|
||||
|
||||
t0 = time.time()
|
||||
raw = comp = done = err = 0
|
||||
errors = []
|
||||
with ThreadPoolExecutor(max_workers=4) as ex:
|
||||
futs = [ex.submit(gz_one, it) for it in todo]
|
||||
for i, fut in enumerate(as_completed(futs), 1):
|
||||
name, r, c, e = fut.result()
|
||||
if e:
|
||||
err += 1
|
||||
errors.append(f'{name}: {e}')
|
||||
else:
|
||||
done += 1
|
||||
raw += r
|
||||
comp += c
|
||||
if i % 200 == 0:
|
||||
print(f' {i}/{len(todo)} ({time.time()-t0:.0f}s)')
|
||||
|
||||
print(f'\n完成: {done} 个, 失败 {err} 个, 用时 {time.time()-t0:.0f}s')
|
||||
if raw:
|
||||
print(f' 原始 {raw/1073741824:.2f} GB -> gz {comp/1073741824:.2f} GB '
|
||||
f'(省 {(1-comp/raw)*100:.1f}%)')
|
||||
for e in errors[:10]:
|
||||
print(' !', e)
|
||||
print(f'step 目录现有 .gz {sum(1 for f in os.listdir(STEP) if f.endswith(".gz"))} 个')
|
||||
@@ -6,8 +6,10 @@
|
||||
# 输出: OnebotCatalog\onebot-data\catalog\manual\<系列>\p<物理页码>.png (灰度 PNG 110dpi, 源目录布局)
|
||||
import fitz, os, sys
|
||||
|
||||
PDF = r"OnebotCatalog\ONEBOT - 直线气缸 - 中文.pdf"
|
||||
OUT = r"OnebotCatalog\onebot-data\catalog\manual"
|
||||
# 脚本位于 onebot-data/tools/,项目根即上两级
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
PDF = os.path.join(_ROOT, "ONEBOT - 直线气缸 - 中文.pdf")
|
||||
OUT = os.path.join(_ROOT, "onebot-data", "catalog", "manual")
|
||||
DPI = 110
|
||||
|
||||
# 系列 → 手册物理页码 (1-based, 含首尾); 依据: 手册印刷页码 = 物理页码 - 1
|
||||
@@ -38,7 +40,7 @@ def main():
|
||||
os.makedirs(d, exist_ok=True)
|
||||
for p in pages:
|
||||
out = os.path.join(d, f"p{p:02d}.png")
|
||||
pix = doc[p - 1].get_pixmap(dpi=DPI, colorspace=fitz.csGRAY)
|
||||
pix = doc[p - 1].get_pixmap(dpi=DPI)
|
||||
pix.save(out)
|
||||
kb = os.path.getsize(out) // 1024
|
||||
total += kb
|
||||
|
||||
58
onebot-data/tools/scan-pending-series.py
Normal file
58
onebot-data/tools/scan-pending-series.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
勘察未完成分类的真实数模(定位销/勾销缸/翻转缸/夹爪缸/电缸)
|
||||
用法: PYTHONUTF8=1 python scan-pending-series.py
|
||||
"""
|
||||
import os, re, collections, sys
|
||||
|
||||
BASE = r'X:/新开发产品-Research and Development/1_产品3D数模(发客户仿真)'
|
||||
|
||||
# 各分类的数据源目录
|
||||
ROOTS = {
|
||||
'PIN': ['2-伸缩销', '2019 Math Data 铁臂', '2019-NEW product', '2020UNICLAMP数模汇总'],
|
||||
'HOK': ['3-勾销缸'],
|
||||
'ROT': ['4-翻转缸'],
|
||||
'GRP': ['5-夹臂', 'GR8_REV02'],
|
||||
'ELEC': ['1-夹紧缸/电缸'],
|
||||
}
|
||||
|
||||
# 关注的系列前缀(新码 UL* / US* / UAGP / UGP / LAE)
|
||||
FOCUS = re.compile(r'^(UL[A-Z]|US[A-Z]?|UAGP|UGP|LAE|LAGP|UAG)')
|
||||
|
||||
|
||||
def scan(dirs):
|
||||
found = collections.defaultdict(list)
|
||||
for d in dirs:
|
||||
root = os.path.join(BASE, d)
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
for dirpath, _, files in os.walk(root):
|
||||
for f in files:
|
||||
if f.lower().endswith(('.stp', '.step')):
|
||||
found[f].append(os.path.join(dirpath, f))
|
||||
return found
|
||||
|
||||
|
||||
def main():
|
||||
for cat, dirs in ROOTS.items():
|
||||
print(f'\n{"="*70}\n### {cat} (源: {", ".join(dirs)})\n{"="*70}')
|
||||
files = scan(dirs)
|
||||
buckets = collections.defaultdict(set)
|
||||
for fname in files:
|
||||
stem = os.path.splitext(fname)[0].upper().strip()
|
||||
m = FOCUS.match(stem)
|
||||
if not m:
|
||||
continue
|
||||
pfx = m.group(1)
|
||||
buckets[pfx].add(stem)
|
||||
for pfx in sorted(buckets):
|
||||
names = sorted(buckets[pfx])
|
||||
print(f'\n -- {pfx} ({len(names)} 个)')
|
||||
for n in names[:200]:
|
||||
print(f' {n}')
|
||||
if len(names) > 200:
|
||||
print(f' ... 还有 {len(names)-200} 个')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
49
onebot-data/tools/test-zip-ratio.py
Normal file
49
onebot-data/tools/test-zip-ratio.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""测试 STEP 文件的 zip 压缩率 —— 判断"下载时压缩"能省多少。"""
|
||||
import zipfile, os, tempfile
|
||||
|
||||
FILES = [
|
||||
r'D:/开发/OnebotCatalog/onebot-data/catalog/step/UAGP155__045BS0.step',
|
||||
r'D:/开发/OnebotCatalog/onebot-data/catalog/step/LAGP210OA045BC.step',
|
||||
r'D:/开发/OnebotCatalog/onebot-data/catalog/step/ULP50040AEK0.step',
|
||||
r'D:/开发/OnebotCatalog/onebot-data/catalog/step/UCBP40OESK0.step',
|
||||
]
|
||||
|
||||
tmp = tempfile.mkdtemp()
|
||||
tot_raw = tot_def = tot_sto = 0
|
||||
print(f'{"文件":30s} {"原始":>9s} {"deflate":>9s} {"压缩率":>7s}')
|
||||
for f in FILES:
|
||||
if not os.path.exists(f):
|
||||
print(f' 缺失: {f}')
|
||||
continue
|
||||
raw = os.path.getsize(f)
|
||||
tot_raw += raw
|
||||
sizes = {}
|
||||
for lvl, tag in ((zipfile.ZIP_DEFLATED, 'def'), (zipfile.ZIP_STORED, 'sto')):
|
||||
p = os.path.join(tmp, tag + '.zip')
|
||||
with zipfile.ZipFile(p, 'w', lvl) as z:
|
||||
z.write(f, os.path.basename(f))
|
||||
sizes[tag] = os.path.getsize(p)
|
||||
tot_def += sizes['def']
|
||||
tot_sto += sizes['sto']
|
||||
print(f'{os.path.basename(f):30s} {raw/1048576:8.2f}M {sizes["def"]/1048576:8.2f}M '
|
||||
f'{(1-sizes["def"]/raw)*100:6.1f}%')
|
||||
|
||||
if tot_raw:
|
||||
print(f'\n合计 {len(FILES)} 个: 原始 {tot_raw/1048576:.2f}MB')
|
||||
print(f' deflate {tot_def/1048576:.2f}MB (省 {(1-tot_def/tot_raw)*100:.1f}%)')
|
||||
print(f' 不压缩 {tot_sto/1048576:.2f}MB')
|
||||
|
||||
# 同时测 .opc 整包压缩率(判断主包还有多少水分)
|
||||
opc = r'D:/开发/OnebotCatalog/sample/OnebotCatalog_2026.08.opc'
|
||||
if os.path.exists(opc):
|
||||
print(f'\n.opc 主包: {os.path.getsize(opc)/1073741824:.2f}GB')
|
||||
try:
|
||||
with zipfile.ZipFile(opc) as z:
|
||||
infos = z.infolist()
|
||||
c = sum(i.compress_size for i in infos)
|
||||
u = sum(i.file_size for i in infos)
|
||||
print(f' 内部 {len(infos)} 条目: 未压缩 {u/1073741824:.2f}GB -> 已压缩 {c/1073741824:.2f}GB '
|
||||
f'(当前压缩率 {(1-c/u)*100:.1f}%)')
|
||||
except Exception as e:
|
||||
print(' 读取失败:', e)
|
||||
35
onebot-data/tools/update-pending-meta-attach.py
Normal file
35
onebot-data/tools/update-pending-meta-attach.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
按 manual/<系列>/ 实际 png 数量重写 meta 的 attachments(多页附图)。
|
||||
用法: PYTHONUTF8=1 python update-pending-meta-attach.py
|
||||
"""
|
||||
import json, os, glob
|
||||
|
||||
CAT = r'D:/开发/OnebotCatalog/onebot-data/catalog'
|
||||
SERIES = ['ULA','ULB','ULL','ULM','ULO','ULP','ULR','ULT','ULX','ULY','ULZ',
|
||||
'USP','USL','USR','USS','USSL', 'UAGP','LAGP', 'LAE']
|
||||
|
||||
for s in SERIES:
|
||||
mp = os.path.join(CAT, 'series', s + '.meta.json')
|
||||
if not os.path.exists(mp):
|
||||
print(f'[{s}] meta 不存在,跳过')
|
||||
continue
|
||||
meta = json.load(open(mp, encoding='utf-8'))
|
||||
d = os.path.join(CAT, 'manual', s)
|
||||
pngs = sorted(glob.glob(os.path.join(d, 'p*.png')))
|
||||
if not pngs:
|
||||
print(f'[{s}] 无 png,保留原 attachments')
|
||||
continue
|
||||
atts = [{"kind": "manual", "lang": "", "source": f"manual/{s}/{os.path.basename(p)}",
|
||||
"model": None} for p in pngs]
|
||||
pdf = os.path.join(d, s + '_manual.pdf')
|
||||
if os.path.exists(pdf):
|
||||
atts.append({"kind": "datasheet", "lang": "",
|
||||
"source": f"manual/{s}/{s}_manual.pdf", "model": None})
|
||||
meta['attachments'] = atts
|
||||
with open(mp, 'w', encoding='utf-8') as fp:
|
||||
json.dump(meta, fp, ensure_ascii=False, indent=1)
|
||||
print(f'[{s}] {len(pngs)} 页附图' + (' + datasheet' if os.path.exists(pdf) else ''))
|
||||
|
||||
# catalog.json 的 GRP 分类:UGP 无 X 盘数据,保留 demo —— 提示用户
|
||||
print('\n注: GRP/UGP 无 X 盘真实数模,保留原 demo 数据')
|
||||
Reference in New Issue
Block a user