#!/usr/bin/env node // DWG 渲染基准 / 漏渲染诊断探针 // // 用法:node scripts/probe-dwg.mjs [--out tools/out] [文件1.dwg 文件2.dwg ...] // - 默认扫描项目根目录下的 *.dwg / *.DWG // - 启动 vite 开发服务器 + 无头 Chromium(WebGL/SwiftShader) // - 输出解析耗时、首帧构建耗时、实体/图层/块/布局/代理图形统计、缺失引用、 // 平移缩放耗时,并把每个图纸的截图写到 tools/out/ import fs from 'node:fs' import path from 'node:path' import { spawn } from 'node:child_process' import { chromium } from 'playwright-core' const root = process.cwd() const ven = path.resolve(root, '..', 'ven') const outDir = path.join(root, 'tools', 'out') const port = Number(process.env.PROBE_PORT || 5199) const args = process.argv.slice(2) const outFlag = args.indexOf('--out') if (outFlag >= 0) args.splice(outFlag, 2) let files = args.filter((a) => a.toLowerCase().endsWith('.dwg')) if (!files.length) { const dirs = ['testfile', '.'].map((d) => path.join(root, d)).filter((d) => fs.existsSync(d)) for (const dir of dirs) { for (const name of fs.readdirSync(dir)) { if (name.toLowerCase().endsWith('.dwg')) files.push(path.join(path.basename(dir), name)) } } } if (!files.length) { console.error('没有找到 DWG 文件') process.exit(1) } fs.mkdirSync(outDir, { recursive: true }) function chromePath() { const candidates = [ process.env.CHROME_PATH, path.join(ven, 'chrome-for-testing', 'chrome-linux64', 'chrome'), '/usr/bin/google-chrome', '/usr/bin/chromium' ].filter(Boolean) const hit = candidates.find((p) => { try { return fs.statSync(p).isFile() } catch { return false } }) if (!hit) throw new Error('找不到 Chrome/Chromium,可设置 CHROME_PATH') return hit } function startVite() { const vite = path.join(root, 'node_modules', 'vite', 'bin', 'vite.js') const child = spawn(process.execPath, [vite, '--port', String(port), '--strictPort', '--host', '127.0.0.1'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }) const ready = new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('vite 启动超时')), 30000) const onData = (buf) => { if (/Local:.*http/.test(String(buf))) { clearTimeout(timer) resolve() } } child.stdout.on('data', onData) child.stderr.on('data', (b) => process.env.PROBE_DEBUG && process.stderr.write(b)) child.on('exit', (code) => reject(new Error(`vite 退出:${code}`))) }) return { child, ready } } const bytes = (n) => `${(n / 1048576).toFixed(1)} MB` const { child: viteChild, ready } = startVite() await ready const browser = await chromium.launch({ executablePath: chromePath(), args: [ '--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--disable-dev-shm-usage', '--js-flags=--max-old-space-size=4096' ] }) const results = [] try { for (const file of files) { const abs = path.resolve(root, file) const label = path.basename(abs) if (!fs.existsSync(abs)) { console.error(`跳过(不存在):${label}`) continue } const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 }) const logs = [] page.on('console', (msg) => { if (msg.type() === 'error' || msg.type() === 'warning') logs.push(`[${msg.type()}] ${msg.text()}`) }) page.on('pageerror', (err) => logs.push(`[pageerror] ${err.message}`)) page.on('worker', (w) => { w.on('console', (msg) => logs.push(`[worker:${msg.type()}] ${msg.text()}`)) }) // 直接把本地图纸喂给页面,避免复制到大目录 await page.route('**/samples/**', async (route) => { const url = new URL(route.request().url()) const name = decodeURIComponent(url.pathname.split('/').pop()) const target = [ path.join(root, 'samples', name), path.join(root, 'testfile', name), path.join(root, name) ].find((p) => fs.existsSync(p)) if (target) { await route.fulfill({ status: 200, body: fs.readFileSync(target), contentType: 'application/octet-stream' }) } else { await route.fulfill({ status: 404, body: 'not found' }) } }) await page.goto(`http://127.0.0.1:${port}/tools/render-probe.html`, { waitUntil: 'load' }) await page.waitForFunction(() => window.__ready === true, null, { timeout: 30000 }) console.log(`\n=== ${label} (${bytes(fs.statSync(abs).size)}) ===`) const url = `http://127.0.0.1:${port}/samples/${encodeURIComponent(label)}` let data try { const probeOpts = { preload: process.env.PROBE_PRELOAD === '1', viewerOptions: process.env.PROBE_PAPER === '1' ? { includePaperSpace: true } : {} } data = await page.evaluate( ([u, o]) => window.__probe(u, o), [url, probeOpts] ) } catch (error) { data = { file: label, fatal: String(error) } } if (!data.fatal) { const shot = path.join(outDir, `${label.replace(/[^\w.\u4e00-\u9fa5-]/g, '_')}.png`) await page.screenshot({ path: shot }) data.screenshot = path.relative(root, shot) data.consoleErrors = logs.slice(0, 20) const r = data.render console.log( `解析 ${data.parseElapsedMs}ms · 首帧构建 ${r.buildElapsedMs ?? '-'}ms · 后端 ${data.backend} · ` + `实体 ${data.entityCount} · 图层 ${data.layerCount} · 块 ${data.blockCount} · 布局 ${data.pageCount} · ` + `代理 ${data.proxies.total}` ) console.log( `绘制 ${r.drawn}/${r.total} · 跳过 ${r.skipped} · 视口裁剪 ${r.culledPrimitiveCount} · ` + `GPU ${r.gpuMemoryMB ?? '-'}MB · 平移帧 ${data.interaction.panFrameMs.p50}ms(p95 ${data.interaction.panFrameMs.p95}ms)` ) console.log( `预编译 ${data.preloadMs}ms · 布局空间实体 ${data.paperSpaceEntities} · 元数据键 ${(data.metadataKeys || []).join(',')}` ) if (data.missingReferences.length) console.log(`缺失引用(${data.missingReferences.length}): ${data.missingReferences.slice(0, 8).join(', ')}`) if (Object.keys(r.unsupported || {}).length) console.log(`不支持的实体类型: ${JSON.stringify(r.unsupported).slice(0, 300)}`) if (data.warnings.length) console.log(`警告: ${data.warnings.slice(0, 3).join(' | ')}`) if (data.consoleErrors?.length) console.log(`控制台错误: ${data.consoleErrors.slice(0, 3).join(' | ')}`) console.log(`截图: ${data.screenshot}`) } else { console.log('失败:', data.fatal) if (logs.length) console.log(logs.slice(0, 5).join('\n')) } results.push(data) await page.close() } } finally { await browser.close() viteChild.kill('SIGTERM') } fs.writeFileSync(path.join(outDir, 'probe-report.json'), JSON.stringify(results, null, 2)) console.log(`\n报告:${path.relative(root, path.join(outDir, 'probe-report.json'))}`)