Fix blank drawings, drop parse-time animation cost, add render diagnostics
Accuracy / completeness - docViews: split parsed scene into model space / paper space / free-standing blocks (geometry hidden in uninserted blocks no longer shows as a blank canvas); auto-select the first block view when model space is empty - viewer: view switcher panel listing every detected view with expanded entity counts; paper space is now parsed (includePaperSpace) and kept apart - SHX: missing .shx references are surfaced with an import control (addReferenceFile), linetypes/text redraw afterwards - unsupported/proxy entities: markers on canvas + diagnostics block showing proxy / wipeout counts and missing SHX names Speed - the loading overlay used an infinite CSS spinner + backdrop blur while the worker parsed; on software rendering that starved the parse thread (TELECOM 18MB: 71s -> 30s wall clock, normalize 33s -> 14s). Replaced with a determinate progress bar fed by loader progress events - transferInputBuffer for worker loads, maxInsertDepth 32, longer worker timeout Tooling - scripts/probe-dwg.mjs / probe-app.mjs: headless render + end-to-end probes (entity/layer/block/layout/proxy stats, timings, screenshots) - tools/render-probe.html, scripts/profile-worker.mjs - version 0.6.0 (versionCode 8)
This commit is contained in:
@@ -14,3 +14,4 @@ EasyView-*.apk
|
|||||||
*.local
|
*.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
tools/out/
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "phone-viewer-app",
|
"name": "phone-viewer-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"description": "Mobile/tablet all-in-one viewer: DWG (model & layout space) + PDF/EPUB/XPS/CBZ/images",
|
"description": "Mobile/tablet all-in-one viewer: DWG (model & layout space) + PDF/EPUB/XPS/CBZ/images",
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ const kotlinStdlib = fs
|
|||||||
if (!kotlinStdlib) throw new Error('kotlin-stdlib not found in Gradle lib')
|
if (!kotlinStdlib) throw new Error('kotlin-stdlib not found in Gradle lib')
|
||||||
|
|
||||||
const appVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version
|
const appVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version
|
||||||
const versionCode = Number(process.env.APP_VERSION_CODE || 7)
|
const versionCode = Number(process.env.APP_VERSION_CODE || 8)
|
||||||
|
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// 端到端检查真实 App(index.html → 打开文件 → 渲染 → 视图面板),输出状态文本与截图。
|
||||||
|
// 用法:node scripts/probe-app.mjs [文件 ...]
|
||||||
|
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)
|
||||||
|
fs.mkdirSync(outDir, { recursive: true })
|
||||||
|
|
||||||
|
const SUPPORTED = /\.(dwg|dxf|pdf|epub|xps|oxps|cbz|png|jpe?g|gif|bmp)$/i
|
||||||
|
let files = process.argv.slice(2).filter((a) => SUPPORTED.test(a))
|
||||||
|
if (!files.length) {
|
||||||
|
const dir = path.join(root, 'testfile')
|
||||||
|
files = fs.readdirSync(dir).filter((n) => SUPPORTED.test(n)).map((n) => path.join('testfile', n))
|
||||||
|
}
|
||||||
|
|
||||||
|
const chrome = [process.env.CHROME_PATH, path.join(ven, 'chrome-for-testing', 'chrome-linux64', 'chrome')].find(
|
||||||
|
(p) => p && fs.existsSync(p)
|
||||||
|
)
|
||||||
|
|
||||||
|
const vite = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[path.join(root, 'node_modules', 'vite', 'bin', 'vite.js'), '--port', String(port), '--strictPort', '--host', '127.0.0.1'],
|
||||||
|
{ cwd: root, stdio: 'ignore' }
|
||||||
|
)
|
||||||
|
await new Promise((r) => setTimeout(r, 3000))
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath: chrome,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--use-gl=angle',
|
||||||
|
'--use-angle=swiftshader',
|
||||||
|
'--enable-unsafe-swiftshader',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--js-flags=--max-old-space-size=4096'
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const report = []
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
const abs = path.resolve(root, file)
|
||||||
|
const label = path.basename(abs)
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1100, height: 860 }, deviceScaleFactor: 1 })
|
||||||
|
const errors = []
|
||||||
|
page.on('pageerror', (e) => errors.push(String(e.message).slice(0, 200)))
|
||||||
|
await page.goto(`http://127.0.0.1:${port}/index.html`, { waitUntil: 'load' })
|
||||||
|
if (process.env.PROBE_NO_ANIM === '1') {
|
||||||
|
await page.addStyleTag({ content: '*{animation:none!important;transition:none!important;backdrop-filter:none!important}' })
|
||||||
|
}
|
||||||
|
const t0 = Date.now()
|
||||||
|
|
||||||
|
await page.setInputFiles('input[type=file]', abs)
|
||||||
|
let status = ''
|
||||||
|
try {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const el = document.querySelector('.viewer-status')
|
||||||
|
const text = el?.textContent || ''
|
||||||
|
return text.includes('实体')
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ timeout: 180000 }
|
||||||
|
)
|
||||||
|
status = (await page.textContent('.viewer-status')) || ''
|
||||||
|
} catch {
|
||||||
|
status = `(超时) ${(await page.textContent('.viewer-status').catch(() => '')) || ''}`
|
||||||
|
}
|
||||||
|
const elapsed = Date.now() - t0
|
||||||
|
await page.waitForTimeout(800)
|
||||||
|
|
||||||
|
const progress = await page.evaluate(() => window.__progress || null)
|
||||||
|
if (progress) console.log(' worker 进度:', progress.map((p) => `${p.ms}ms/${p.phase}`).join(' '))
|
||||||
|
const timings = await page.evaluate(() => window.__loadTimings || null)
|
||||||
|
if (timings) console.log(' 阶段耗时:', JSON.stringify(timings))
|
||||||
|
const shot = path.join(outDir, `app-${label.replace(/[^\w.\u4e00-\u9fa5-]/g, '_')}.png`)
|
||||||
|
await page.screenshot({ path: shot })
|
||||||
|
|
||||||
|
// 打开“视图与图层”面板,检查视图列表与诊断信息
|
||||||
|
let panel = ''
|
||||||
|
try {
|
||||||
|
await page.click('[data-action="layers"]', { timeout: 5000 })
|
||||||
|
await page.waitForTimeout(500)
|
||||||
|
const sheetShot = shot.replace(/\.png$/, '-panel.png')
|
||||||
|
await page.screenshot({ path: sheetShot })
|
||||||
|
panel = (await page.textContent('.sheet').catch(() => '')) || ''
|
||||||
|
} catch (error) {
|
||||||
|
panel = `面板打开失败: ${error.message.slice(0, 80)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点第一个“游离块”视图,确认它确实是一张能看的图纸
|
||||||
|
let viewShot = ''
|
||||||
|
try {
|
||||||
|
const rows = await page.$$('.view-row')
|
||||||
|
if (rows.length > 1) {
|
||||||
|
await rows[1].click()
|
||||||
|
await page.waitForTimeout(1200)
|
||||||
|
viewShot = shot.replace(/\.png$/, '-view1.png')
|
||||||
|
await page.screenshot({ path: viewShot })
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
console.log(`\n=== ${label} ===`)
|
||||||
|
console.log(` 打开+渲染墙钟 ${elapsed}ms`)
|
||||||
|
console.log(` 状态栏: ${status.trim()}`)
|
||||||
|
const views = panel.match(/[MLB][^\n]{0,60}实体/g)?.slice(0, 6) ?? []
|
||||||
|
if (views.length) console.log(` 视图列表: ${views.map((v) => v.replace(/\s+/g, ' ')).join(' | ')}`)
|
||||||
|
const diag = panel.match(/渲染提示[^\n]{0,160}/)?.[0]
|
||||||
|
if (diag) console.log(` 诊断: ${diag.replace(/\s+/g, ' ')}`)
|
||||||
|
if (errors.length) console.log(` 页面错误: ${errors.slice(0, 2).join(' | ')}`)
|
||||||
|
report.push({
|
||||||
|
file: label,
|
||||||
|
elapsed,
|
||||||
|
status: status.trim(),
|
||||||
|
views,
|
||||||
|
diag,
|
||||||
|
errors,
|
||||||
|
screenshot: path.relative(root, shot),
|
||||||
|
viewScreenshot: viewShot ? path.relative(root, viewShot) : undefined
|
||||||
|
})
|
||||||
|
await page.close()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close()
|
||||||
|
vite.kill('SIGTERM')
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(outDir, 'app-report.json'), JSON.stringify(report, null, 2))
|
||||||
|
setTimeout(() => process.exit(0), 200)
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
#!/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'))}`)
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// 用 CDP 对 DWG worker 内部做采样剖析,定位解析耗时的热点函数。
|
||||||
|
// 用法:node scripts/profile-worker.mjs "testfile/xxx.dwg"
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
|
||||||
|
const root = process.cwd()
|
||||||
|
const ven = path.resolve(root, '..', 'ven')
|
||||||
|
const file = process.argv[2]
|
||||||
|
if (!file) {
|
||||||
|
console.error('用法: node scripts/profile-worker.mjs <dwg 文件>')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const PORT = Number(process.env.CDP_PORT || 9333)
|
||||||
|
const HTTP_PORT = Number(process.env.PROBE_PORT || 5199)
|
||||||
|
|
||||||
|
const chrome = [
|
||||||
|
process.env.CHROME_PATH,
|
||||||
|
path.join(ven, 'chrome-for-testing', 'chrome-linux64', 'chrome'),
|
||||||
|
'/usr/bin/google-chrome'
|
||||||
|
].find((p) => p && fs.existsSync(p))
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||||
|
|
||||||
|
const vite = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[path.join(root, 'node_modules', 'vite', 'bin', 'vite.js'), '--port', String(HTTP_PORT), '--strictPort', '--host', '127.0.0.1'],
|
||||||
|
{ cwd: root, stdio: 'ignore' }
|
||||||
|
)
|
||||||
|
const chromeProc = spawn(
|
||||||
|
chrome,
|
||||||
|
[
|
||||||
|
'--headless=new',
|
||||||
|
'--no-sandbox',
|
||||||
|
`--remote-debugging-port=${PORT}`,
|
||||||
|
'--remote-allow-origins=*',
|
||||||
|
'--user-data-dir=/tmp/probe-cdp-profile',
|
||||||
|
'--use-gl=angle',
|
||||||
|
'--use-angle=swiftshader',
|
||||||
|
'--enable-unsafe-swiftshader',
|
||||||
|
'about:blank'
|
||||||
|
],
|
||||||
|
{ stdio: 'ignore' }
|
||||||
|
)
|
||||||
|
|
||||||
|
async function getWsUrl() {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${PORT}/json/version`)
|
||||||
|
const json = await res.json()
|
||||||
|
if (json.webSocketDebuggerUrl) return json.webSocketDebuggerUrl
|
||||||
|
} catch {}
|
||||||
|
await sleep(300)
|
||||||
|
}
|
||||||
|
throw new Error('Chrome CDP 未就绪')
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 1
|
||||||
|
const pending = new Map()
|
||||||
|
const listeners = []
|
||||||
|
|
||||||
|
function makeClient(ws) {
|
||||||
|
ws.addEventListener('message', (event) => {
|
||||||
|
const msg = JSON.parse(event.data)
|
||||||
|
if (msg.id && pending.has(msg.id)) {
|
||||||
|
const { resolve, reject } = pending.get(msg.id)
|
||||||
|
pending.delete(msg.id)
|
||||||
|
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result)
|
||||||
|
} else if (msg.method) {
|
||||||
|
for (const l of listeners) l(msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return (method, params = {}, sessionId) => {
|
||||||
|
const id = nextId++
|
||||||
|
const payload = { id, method, params }
|
||||||
|
if (sessionId) payload.sessionId = sessionId
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pending.set(id, { resolve, reject })
|
||||||
|
ws.send(JSON.stringify(payload))
|
||||||
|
setTimeout(() => {
|
||||||
|
if (pending.has(id)) {
|
||||||
|
pending.delete(id)
|
||||||
|
reject(new Error(`CDP 超时: ${method}`))
|
||||||
|
}
|
||||||
|
}, 600000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregate(profile) {
|
||||||
|
const byId = new Map(profile.nodes.map((n) => [n.id, n]))
|
||||||
|
const self = new Map()
|
||||||
|
const total = profile.samples?.length || 0
|
||||||
|
const counts = new Map()
|
||||||
|
for (const s of profile.samples || []) counts.set(s, (counts.get(s) || 0) + 1)
|
||||||
|
const interval = (profile.endTime - profile.startTime) / (total || 1)
|
||||||
|
for (const [nodeId, count] of counts) {
|
||||||
|
const node = byId.get(nodeId)
|
||||||
|
if (!node) continue
|
||||||
|
const cf = node.callFrame
|
||||||
|
const url = (cf.url || '').replace(/^.*\/wasm\//, 'wasm/')
|
||||||
|
const key = `${cf.functionName || '(anonymous)'} @ ${url.split('/').pop()}:${cf.lineNumber + 1}`
|
||||||
|
self.set(key, (self.get(key) || 0) + count * interval)
|
||||||
|
}
|
||||||
|
return [...self.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
let exitCode = 0
|
||||||
|
try {
|
||||||
|
await sleep(1500)
|
||||||
|
const wsUrl = await getWsUrl()
|
||||||
|
const ws = new WebSocket(wsUrl)
|
||||||
|
await new Promise((r) => ws.addEventListener('open', r))
|
||||||
|
const send = makeClient(ws)
|
||||||
|
|
||||||
|
const workerTargets = []
|
||||||
|
listeners.push((msg) => {
|
||||||
|
if (msg.method === 'Target.targetCreated' && msg.params.targetInfo.type === 'worker') {
|
||||||
|
workerTargets.push(msg.params.targetInfo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await send('Target.setDiscoverTargets', { discover: true })
|
||||||
|
await send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true })
|
||||||
|
|
||||||
|
const page = await send('Target.createTarget', { url: `http://127.0.0.1:${HTTP_PORT}/tools/render-probe.html` })
|
||||||
|
const pageSession = (await send('Target.attachToTarget', { targetId: page.targetId, flatten: true })).sessionId
|
||||||
|
await send('Runtime.enable', {}, pageSession)
|
||||||
|
await sleep(2500)
|
||||||
|
|
||||||
|
const label = path.basename(file)
|
||||||
|
const url = `http://127.0.0.1:${HTTP_PORT}/samples/${encodeURIComponent(label)}`
|
||||||
|
|
||||||
|
// 1) 先建 viewer 并预编译 wasm —— 让 worker 线程提前存在,便于挂上剖析器
|
||||||
|
const prepared = await send(
|
||||||
|
'Runtime.evaluate',
|
||||||
|
{
|
||||||
|
expression: `window.__prepare(${JSON.stringify(url)}).then(r => JSON.stringify(r))`,
|
||||||
|
awaitPromise: true,
|
||||||
|
returnByValue: true
|
||||||
|
},
|
||||||
|
pageSession
|
||||||
|
)
|
||||||
|
console.log('预编译:', prepared.result?.value)
|
||||||
|
|
||||||
|
let workerSession = null
|
||||||
|
for (let i = 0; i < 80 && !workerSession; i++) {
|
||||||
|
const targets = (await send('Target.getTargets')).targetInfos || []
|
||||||
|
const target = targets.find((t) => /dwg-worker/.test(t.url || ''))
|
||||||
|
if (target) {
|
||||||
|
const attached = await send('Target.attachToTarget', { targetId: target.targetId, flatten: true })
|
||||||
|
workerSession = attached.sessionId
|
||||||
|
} else {
|
||||||
|
await sleep(250)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!workerSession) {
|
||||||
|
const targets = (await send('Target.getTargets')).targetInfos || []
|
||||||
|
console.log('可见 targets:', targets.map((t) => `${t.type}:${t.url}`).join('\n '))
|
||||||
|
throw new Error('未发现 DWG worker target')
|
||||||
|
}
|
||||||
|
|
||||||
|
await send('Profiler.enable', {}, workerSession)
|
||||||
|
await send('Profiler.setSamplingInterval', { interval: 200 }, workerSession)
|
||||||
|
|
||||||
|
const started = Date.now()
|
||||||
|
await send('Profiler.start', {}, workerSession)
|
||||||
|
const result = await send(
|
||||||
|
'Runtime.evaluate',
|
||||||
|
{
|
||||||
|
expression: `window.__loadPrepared().then(r => JSON.stringify(r))`,
|
||||||
|
awaitPromise: true,
|
||||||
|
returnByValue: true
|
||||||
|
},
|
||||||
|
pageSession
|
||||||
|
)
|
||||||
|
console.log('页面结果:', result.result?.value || result, `(墙钟 ${Date.now() - started}ms)`)
|
||||||
|
|
||||||
|
const { profile } = await send('Profiler.stop', {}, workerSession)
|
||||||
|
const rows = aggregate(profile)
|
||||||
|
const totalMs = (profile.endTime - profile.startTime) / 1000
|
||||||
|
console.log(`\nworker 采样总时长 ${Math.round(totalMs)}ms,自耗时 Top:`)
|
||||||
|
for (const [key, us] of rows) {
|
||||||
|
if (us < 20000) continue
|
||||||
|
console.log(` ${(us / 1000).toFixed(0).padStart(6)}ms ${key}`)
|
||||||
|
}
|
||||||
|
fs.writeFileSync('/tmp/worker-profile.json', JSON.stringify(profile))
|
||||||
|
console.log('\n原始 profile: /tmp/worker-profile.json')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('剖析失败:', error.message)
|
||||||
|
exitCode = 1
|
||||||
|
} finally {
|
||||||
|
vite.kill('SIGTERM')
|
||||||
|
chromeProc.kill('SIGTERM')
|
||||||
|
setTimeout(() => process.exit(exitCode), 300)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { CadDocument, CadEntity } from '@flyfish-dev/cad-viewer'
|
||||||
|
|
||||||
|
export interface CadView {
|
||||||
|
id: string
|
||||||
|
/** 面板里显示的名字 */
|
||||||
|
name: string
|
||||||
|
/** 面板里的次要说明,如“12,340 实体” */
|
||||||
|
hint: string
|
||||||
|
/** 该视图要渲染的实体集合 */
|
||||||
|
entities: CadEntity[]
|
||||||
|
kind: 'model' | 'paper' | 'block'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPACE_BLOCK = /^\*?(model|paper)_space/i
|
||||||
|
const MAX_BLOCK_VIEWS = 40
|
||||||
|
|
||||||
|
function blockNameOf(entity: CadEntity): string | undefined {
|
||||||
|
if (entity.type !== 'INSERT' && entity.kind !== 'insert') return undefined
|
||||||
|
const record = entity as unknown as { blockName?: string; effectiveBlockName?: string; name?: string }
|
||||||
|
const name = record.effectiveBlockName || record.blockName || record.name
|
||||||
|
return typeof name === 'string' && name.trim() ? name.trim().toLowerCase() : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function childNames(entities: CadEntity[]): string[] {
|
||||||
|
const names = new Set<string>()
|
||||||
|
for (const entity of entities) {
|
||||||
|
const name = blockNameOf(entity)
|
||||||
|
if (name) names.add(name)
|
||||||
|
}
|
||||||
|
return [...names]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把解析结果拆成可切换的视图:
|
||||||
|
* - 模型空间 / 布局空间(按 isInPaperSpace 拆分)
|
||||||
|
* - 顶层“游离块”:既没被模型/布局空间引用、也没被其它块引用的块。
|
||||||
|
* 很多图纸(尤其带“图纸视图”的施工图)几何全放在这种块里,不列出来就是一片空白。
|
||||||
|
*/
|
||||||
|
export function buildCadViews(doc: CadDocument): CadView[] {
|
||||||
|
const views: CadView[] = []
|
||||||
|
const model = doc.entities.filter((entity) => entity.isInPaperSpace !== true)
|
||||||
|
const paper = doc.entities.filter((entity) => entity.isInPaperSpace === true)
|
||||||
|
|
||||||
|
if (model.length) {
|
||||||
|
views.push({
|
||||||
|
id: 'model',
|
||||||
|
name: '模型空间',
|
||||||
|
hint: `${model.length.toLocaleString()} 实体`,
|
||||||
|
entities: model,
|
||||||
|
kind: 'model'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (paper.length) {
|
||||||
|
views.push({
|
||||||
|
id: 'paper',
|
||||||
|
name: '布局空间',
|
||||||
|
hint: `${paper.length.toLocaleString()} 实体`,
|
||||||
|
entities: paper,
|
||||||
|
kind: 'paper'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同名块常因大小写各存一份,按小写名去重并保留实体更多的那份
|
||||||
|
const byName = new Map<string, { name: string; entities: CadEntity[] }>()
|
||||||
|
for (const block of Object.values(doc.blocks || {})) {
|
||||||
|
const name = String(block.name ?? '').trim()
|
||||||
|
if (!name) continue
|
||||||
|
const key = name.toLowerCase()
|
||||||
|
const entities = block.entities || []
|
||||||
|
const existing = byName.get(key)
|
||||||
|
if (!existing || entities.length > existing.entities.length) byName.set(key, { name, entities })
|
||||||
|
}
|
||||||
|
|
||||||
|
const childRefs = new Map<string, string[]>()
|
||||||
|
const referencedByAnyBlock = new Set<string>()
|
||||||
|
for (const [key, block] of byName) {
|
||||||
|
const children = childNames(block.entities)
|
||||||
|
childRefs.set(key, children)
|
||||||
|
for (const child of children) referencedByAnyBlock.add(child)
|
||||||
|
}
|
||||||
|
const sceneRefs = new Set(childNames([...model, ...paper]))
|
||||||
|
|
||||||
|
const isFreeStanding = (key: string): boolean =>
|
||||||
|
!sceneRefs.has(key) && !referencedByAnyBlock.has(key) && !SPACE_BLOCK.test(byName.get(key)!.name)
|
||||||
|
|
||||||
|
let roots = [...byName.keys()].filter((key) => byName.get(key)!.entities.length > 0 && isFreeStanding(key))
|
||||||
|
|
||||||
|
// 没有“完全游离”的块时,退而求其次:只要没被模型/布局空间直接引用的块都算候选
|
||||||
|
if (!roots.length) {
|
||||||
|
roots = [...byName.keys()].filter(
|
||||||
|
(key) => byName.get(key)!.entities.length > 0 && !sceneRefs.has(key) && !SPACE_BLOCK.test(byName.get(key)!.name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 递归统计展开后的实体总数(含嵌套块),带环保护
|
||||||
|
const expanded = (key: string, stack: Set<string>): number => {
|
||||||
|
const block = byName.get(key)
|
||||||
|
if (!block || stack.has(key)) return 0
|
||||||
|
stack.add(key)
|
||||||
|
let total = block.entities.length
|
||||||
|
for (const child of childRefs.get(key) ?? []) total += expanded(child, stack)
|
||||||
|
stack.delete(key)
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranked = roots
|
||||||
|
.map((key) => {
|
||||||
|
const block = byName.get(key)!
|
||||||
|
const total = expanded(key, new Set())
|
||||||
|
return { key, block, total }
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.slice(0, MAX_BLOCK_VIEWS)
|
||||||
|
|
||||||
|
for (const [index, entry] of ranked.entries()) {
|
||||||
|
const nested = (childRefs.get(entry.key) ?? []).length
|
||||||
|
views.push({
|
||||||
|
id: `block:${index}`,
|
||||||
|
name: entry.block.name,
|
||||||
|
hint: `${entry.total.toLocaleString()} 实体${nested ? ` · 含 ${nested} 个子块` : ''}(图纸里的游离块)`,
|
||||||
|
entities: entry.block.entities,
|
||||||
|
kind: 'block'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return views
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计代理对象 / 遮挡区域,供界面提示 */
|
||||||
|
export function summarizeEntities(entities: CadEntity[]): {
|
||||||
|
proxies: number
|
||||||
|
wipeouts: number
|
||||||
|
total: number
|
||||||
|
} {
|
||||||
|
let proxies = 0
|
||||||
|
let wipeouts = 0
|
||||||
|
for (const entity of entities) {
|
||||||
|
const type = String(entity.type || '').toUpperCase()
|
||||||
|
if (type.includes('PROXY')) proxies += 1
|
||||||
|
else if (type === 'WIPEOUT') wipeouts += 1
|
||||||
|
}
|
||||||
|
return { proxies, wipeouts, total: entities.length }
|
||||||
|
}
|
||||||
+161
-12
@@ -1,8 +1,9 @@
|
|||||||
import './polyfills'
|
import './polyfills'
|
||||||
import {
|
import {
|
||||||
CadViewer,
|
CadViewer,
|
||||||
type CadViewerLoadResult,
|
type CadDocument as FlyCadDocument,
|
||||||
type CadDocument as FlyCadDocument
|
type CadEntity,
|
||||||
|
type CadViewerLoadResult
|
||||||
} from '@flyfish-dev/cad-viewer'
|
} from '@flyfish-dev/cad-viewer'
|
||||||
import '@flyfish-dev/cad-viewer/styles.css'
|
import '@flyfish-dev/cad-viewer/styles.css'
|
||||||
import { clear, h, icon, iconButton } from '../core/dom'
|
import { clear, h, icon, iconButton } from '../core/dom'
|
||||||
@@ -11,6 +12,7 @@ import { getTheme, onThemeChange, toggleTheme } from '../core/theme'
|
|||||||
import { toast } from '../core/toast'
|
import { toast } from '../core/toast'
|
||||||
import type { ViewerHost } from '../core/viewerHost'
|
import type { ViewerHost } from '../core/viewerHost'
|
||||||
import { showAbout } from '../ui/about'
|
import { showAbout } from '../ui/about'
|
||||||
|
import { buildCadViews, summarizeEntities, type CadView } from './docViews'
|
||||||
|
|
||||||
interface Callbacks {
|
interface Callbacks {
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
@@ -33,6 +35,10 @@ export class FlyfishCadViewer {
|
|||||||
private themeBtn!: HTMLButtonElement
|
private themeBtn!: HTMLButtonElement
|
||||||
private fullscreenBtn!: HTMLButtonElement
|
private fullscreenBtn!: HTMLButtonElement
|
||||||
private result: CadViewerLoadResult | null = null
|
private result: CadViewerLoadResult | null = null
|
||||||
|
private views: CadView[] = []
|
||||||
|
private activeViewId = 'model'
|
||||||
|
private viewListEl: HTMLElement | null = null
|
||||||
|
private diagEl: HTMLElement | null = null
|
||||||
private themeUnsub: (() => void) | null = null
|
private themeUnsub: (() => void) | null = null
|
||||||
private resizeObserver: ResizeObserver | null = null
|
private resizeObserver: ResizeObserver | null = null
|
||||||
private destroyed = false
|
private destroyed = false
|
||||||
@@ -54,8 +60,14 @@ export class FlyfishCadViewer {
|
|||||||
workerUrl: new URL('wasm/dwg-worker.js', document.baseURI).href,
|
workerUrl: new URL('wasm/dwg-worker.js', document.baseURI).href,
|
||||||
dwfWasmUrl: new URL('wasm/dwfv-render.wasm', document.baseURI).href,
|
dwfWasmUrl: new URL('wasm/dwfv-render.wasm', document.baseURI).href,
|
||||||
useWorker: true,
|
useWorker: true,
|
||||||
workerTimeoutMs: 120000,
|
workerTimeoutMs: 180000,
|
||||||
|
// File → worker 直接转移 ArrayBuffer,省掉一次几十 MB 的拷贝
|
||||||
|
transferInputBuffer: true,
|
||||||
|
maxInsertDepth: 32,
|
||||||
autoFit: true,
|
autoFit: true,
|
||||||
|
// 布局空间实体默认被丢弃,这里取回后在“布局空间”视图里单独渲染
|
||||||
|
includePaperSpace: true,
|
||||||
|
// File → worker 直接转移 ArrayBuffer,省掉一次 23MB 级别的内存拷贝
|
||||||
canvasOptions: {
|
canvasOptions: {
|
||||||
fitMode: 'auto',
|
fitMode: 'auto',
|
||||||
background: '#0d1117',
|
background: '#0d1117',
|
||||||
@@ -64,10 +76,15 @@ export class FlyfishCadViewer {
|
|||||||
enableSpatialIndex: true,
|
enableSpatialIndex: true,
|
||||||
spatialIndexCellCount: 96,
|
spatialIndexCellCount: 96,
|
||||||
maxCurveSegments: 72,
|
maxCurveSegments: 72,
|
||||||
maxVisibleTextLabels: 2400
|
maxVisibleTextLabels: 2400,
|
||||||
|
// 代理对象/不支持的实体画成方框,避免静默消失
|
||||||
|
showUnsupportedMarkers: true
|
||||||
},
|
},
|
||||||
onLoadProgress: (progress) => {
|
onLoadProgress: (progress) => {
|
||||||
this.host.setLoaderMessage(progress.message || '正在解析 DWG 图纸…', this.file.name)
|
this.host.setLoaderMessage(progress.message || '正在解析 DWG 图纸…', this.file.name, progress.percent)
|
||||||
|
const w = window as unknown as { __progress?: Array<{ ms: number; phase: string }> }
|
||||||
|
if (!w.__progress) w.__progress = []
|
||||||
|
w.__progress.push({ ms: Math.round(performance.now() - ((window as unknown as { __openAt?: number }).__openAt ?? performance.now())), phase: progress.phase })
|
||||||
},
|
},
|
||||||
onViewChange: () => this.updateStatus(),
|
onViewChange: () => this.updateStatus(),
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -77,6 +94,8 @@ export class FlyfishCadViewer {
|
|||||||
|
|
||||||
this.tools = this.buildTools()
|
this.tools = this.buildTools()
|
||||||
this.sheetBackdrop = h('div', { class: 'sheet-backdrop' })
|
this.sheetBackdrop = h('div', { class: 'sheet-backdrop' })
|
||||||
|
this.viewListEl = h('div', { class: 'view-list' })
|
||||||
|
this.diagEl = h('div', { class: 'diag-box' })
|
||||||
this.sheetList = h('div', { class: 'sheet-list' })
|
this.sheetList = h('div', { class: 'sheet-list' })
|
||||||
this.sheetBackdrop.appendChild(this.buildSheet())
|
this.sheetBackdrop.appendChild(this.buildSheet())
|
||||||
host.stage.append(this.tools, this.sheetBackdrop)
|
host.stage.append(this.tools, this.sheetBackdrop)
|
||||||
@@ -93,9 +112,15 @@ export class FlyfishCadViewer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async load(): Promise<void> {
|
async load(): Promise<void> {
|
||||||
|
const tStart = performance.now()
|
||||||
|
const marks: Record<string, number> = {}
|
||||||
|
const mark = (name: string) => {
|
||||||
|
marks[name] = Math.round(performance.now() - tStart)
|
||||||
|
}
|
||||||
this.host.showLoader('正在加载 DWG 图纸…', this.file.name)
|
this.host.showLoader('正在加载 DWG 图纸…', this.file.name)
|
||||||
try {
|
try {
|
||||||
const result = await this.viewer.loadFile(this.file)
|
const result = await this.viewer.loadFile(this.file)
|
||||||
|
mark('parsed')
|
||||||
this.result = result
|
this.result = result
|
||||||
this.host.hideLoader()
|
this.host.hideLoader()
|
||||||
this.host.setFileInfo(
|
this.host.setFileInfo(
|
||||||
@@ -105,11 +130,32 @@ export class FlyfishCadViewer {
|
|||||||
'cad',
|
'cad',
|
||||||
`${result.document.entities.length.toLocaleString()} 个实体 · ${formatBytes(this.file.size)}`
|
`${result.document.entities.length.toLocaleString()} 个实体 · ${formatBytes(this.file.size)}`
|
||||||
)
|
)
|
||||||
this.viewer.fit('auto')
|
this.views = buildCadViews(result.document)
|
||||||
|
mark('views')
|
||||||
|
const hasModel = this.views.some((view) => view.kind === 'model')
|
||||||
|
const firstBlock = this.views.find((view) => view.kind === 'block')
|
||||||
|
if (!hasModel && firstBlock) {
|
||||||
|
// 模型空间为空:内容全在“未插入的块”里,直接显示第一个,别给用户一片黑屏
|
||||||
|
this.activeViewId = firstBlock.id
|
||||||
|
this.rendererSetEntities(firstBlock.entities)
|
||||||
|
} else {
|
||||||
|
this.activeViewId = this.views[0]?.id ?? 'model'
|
||||||
|
}
|
||||||
|
mark('entities-set')
|
||||||
|
this.viewer.fit(this.views.length > 1 ? 'extents' : 'auto')
|
||||||
|
mark('fitted')
|
||||||
this.applyTheme()
|
this.applyTheme()
|
||||||
this.updateStatus()
|
this.updateStatus()
|
||||||
if (result.warnings.length) toast(result.warnings[0], 'info', 6000)
|
if (!hasModel && firstBlock) {
|
||||||
|
toast(`模型空间为空,已显示图纸中的“${firstBlock.name}”块(${this.views.length - 1} 个可切换视图)`, 'info', 7000)
|
||||||
|
} else if (result.warnings.length) {
|
||||||
|
toast(result.warnings[0], 'info', 6000)
|
||||||
|
}
|
||||||
this.refreshSheetRows()
|
this.refreshSheetRows()
|
||||||
|
this.refreshViewRows()
|
||||||
|
this.refreshDiagnostics()
|
||||||
|
mark('done')
|
||||||
|
;(window as unknown as { __loadTimings?: Record<string, number> }).__loadTimings = marks
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.host.hideLoader()
|
this.host.hideLoader()
|
||||||
throw error
|
throw error
|
||||||
@@ -149,7 +195,7 @@ export class FlyfishCadViewer {
|
|||||||
this.updateStatus()
|
this.updateStatus()
|
||||||
})
|
})
|
||||||
tools.appendChild(h('div', { class: 'tool-divider' }))
|
tools.appendChild(h('div', { class: 'tool-divider' }))
|
||||||
add('layers', '图层', 'layers', () => this.openSheet())
|
add('layers', '视图与图层', 'layers', () => this.openSheet())
|
||||||
this.themeBtn = add('sun', '切换背景', 'theme', () => {
|
this.themeBtn = add('sun', '切换背景', 'theme', () => {
|
||||||
toggleTheme()
|
toggleTheme()
|
||||||
this.applyTheme()
|
this.applyTheme()
|
||||||
@@ -158,8 +204,104 @@ export class FlyfishCadViewer {
|
|||||||
return tools
|
return tools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private rendererSetEntities(entities: CadEntity[]): void {
|
||||||
|
const doc = this.result?.document
|
||||||
|
if (!doc) return
|
||||||
|
const renderer = this.viewer.renderer
|
||||||
|
const state = renderer.getViewState()
|
||||||
|
renderer.setDocument({ ...doc, entities })
|
||||||
|
renderer.setViewState(state)
|
||||||
|
renderer.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
private switchView(id: string): void {
|
||||||
|
const view = this.views.find((item) => item.id === id)
|
||||||
|
if (!view || id === this.activeViewId) return
|
||||||
|
this.activeViewId = id
|
||||||
|
this.rendererSetEntities(view.entities)
|
||||||
|
this.viewer.fit(view.kind === 'model' ? 'auto' : 'extents')
|
||||||
|
this.updateStatus()
|
||||||
|
this.refreshViewRows()
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshViewRows(): void {
|
||||||
|
const list = this.viewListEl
|
||||||
|
if (!list) return
|
||||||
|
clear(list)
|
||||||
|
if (this.views.length <= 1) return
|
||||||
|
for (const view of this.views) {
|
||||||
|
const active = view.id === this.activeViewId
|
||||||
|
const row = h('button', {
|
||||||
|
class: `view-row ${active ? 'active' : ''}`,
|
||||||
|
type: 'button',
|
||||||
|
title: view.name
|
||||||
|
})
|
||||||
|
row.append(
|
||||||
|
h('span', { class: 'view-kind', text: view.kind === 'model' ? 'M' : view.kind === 'paper' ? 'L' : 'B' }),
|
||||||
|
h(
|
||||||
|
'span',
|
||||||
|
{ class: 'view-info' },
|
||||||
|
h('span', { class: 'view-name', text: view.name }),
|
||||||
|
h('span', { class: 'view-hint', text: view.hint })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row.addEventListener('click', () => this.switchView(view.id))
|
||||||
|
list.appendChild(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshDiagnostics(): void {
|
||||||
|
const box = this.diagEl
|
||||||
|
const doc = this.result?.document
|
||||||
|
if (!box || !doc) return
|
||||||
|
clear(box)
|
||||||
|
const stats = summarizeEntities(doc.entities)
|
||||||
|
const missing = this.viewer.getMissingReferences?.() ?? []
|
||||||
|
const lines: string[] = []
|
||||||
|
if (stats.proxies) lines.push(`${stats.proxies} 个代理对象(ACAD_PROXY_ENTITY)`)
|
||||||
|
if (stats.wipeouts) lines.push(`${stats.wipeouts} 个遮挡区域(WIPEOUT)`)
|
||||||
|
if (missing.length) lines.push(`缺少 ${missing.length} 个 SHX 字体/线型文件`)
|
||||||
|
if (!lines.length) return
|
||||||
|
box.appendChild(h('div', { class: 'diag-title', text: '渲染提示' }))
|
||||||
|
for (const line of lines) box.appendChild(h('div', { class: 'diag-line', text: `· ${line}` }))
|
||||||
|
if (missing.length) {
|
||||||
|
const names = missing
|
||||||
|
.map((ref) => (ref as { fileName?: string; name?: string }).fileName ?? (ref as { name?: string }).name ?? '')
|
||||||
|
.filter(Boolean)
|
||||||
|
if (names.length) box.appendChild(h('div', { class: 'diag-line diag-muted', text: names.join('、') }))
|
||||||
|
const picker = h('input', { type: 'file', accept: '.shx,.SHX', multiple: true })
|
||||||
|
picker.style.display = 'none'
|
||||||
|
const importBtn = h('button', { class: 'chip-btn', type: 'button', text: '导入 SHX 字体' })
|
||||||
|
importBtn.addEventListener('click', () => picker.click())
|
||||||
|
picker.addEventListener('change', () => void this.importShx(picker))
|
||||||
|
box.append(importBtn, picker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async importShx(picker: HTMLInputElement): Promise<void> {
|
||||||
|
const files = [...(picker.files ?? [])]
|
||||||
|
if (!files.length) return
|
||||||
|
let loaded = 0
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
await this.viewer.addReferenceFile(file)
|
||||||
|
loaded += 1
|
||||||
|
} catch {
|
||||||
|
/* 单个字体失败不影响其它 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
picker.value = ''
|
||||||
|
if (loaded) {
|
||||||
|
toast(`已载入 ${loaded} 个 SHX 文件,线型/文字已重绘`, 'success', 3200)
|
||||||
|
this.rendererSetEntities(this.views.find((view) => view.id === this.activeViewId)?.entities ?? [])
|
||||||
|
this.refreshDiagnostics()
|
||||||
|
} else {
|
||||||
|
toast('SHX 载入失败', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private buildSheet(): HTMLElement {
|
private buildSheet(): HTMLElement {
|
||||||
const title = h('div', { class: 'sheet-title' }, h('span', { text: '图层' }))
|
const title = h('div', { class: 'sheet-title' }, h('span', { text: '视图与图层' }))
|
||||||
const close = iconButton('x', '关闭', () => this.closeSheet())
|
const close = iconButton('x', '关闭', () => this.closeSheet())
|
||||||
const head = h('div', { class: 'sheet-head' }, title, close)
|
const head = h('div', { class: 'sheet-head' }, title, close)
|
||||||
const actions = h('div', { class: 'sheet-actions' })
|
const actions = h('div', { class: 'sheet-actions' })
|
||||||
@@ -181,8 +323,11 @@ export class FlyfishCadViewer {
|
|||||||
})
|
})
|
||||||
return h(
|
return h(
|
||||||
'aside',
|
'aside',
|
||||||
{ class: 'sheet', role: 'dialog', 'aria-label': '图层' },
|
{ class: 'sheet', role: 'dialog', 'aria-label': '视图与图层' },
|
||||||
head,
|
head,
|
||||||
|
this.diagEl,
|
||||||
|
this.viewListEl,
|
||||||
|
h('div', { class: 'sheet-subtitle', text: '图层' }),
|
||||||
actions,
|
actions,
|
||||||
this.sheetList
|
this.sheetList
|
||||||
)
|
)
|
||||||
@@ -258,6 +403,8 @@ export class FlyfishCadViewer {
|
|||||||
|
|
||||||
private openSheet(): void {
|
private openSheet(): void {
|
||||||
this.refreshSheetRows()
|
this.refreshSheetRows()
|
||||||
|
this.refreshViewRows()
|
||||||
|
this.refreshDiagnostics()
|
||||||
this.sheetBackdrop.classList.add('open')
|
this.sheetBackdrop.classList.add('open')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,8 +432,10 @@ export class FlyfishCadViewer {
|
|||||||
if (this.destroyed || !this.result) return
|
if (this.destroyed || !this.result) return
|
||||||
const zoom = Math.round(this.viewer.getZoomPercent())
|
const zoom = Math.round(this.viewer.getZoomPercent())
|
||||||
const backend = (this.viewer.renderer as { backend?: string }).backend ?? 'canvas2d'
|
const backend = (this.viewer.renderer as { backend?: string }).backend ?? 'canvas2d'
|
||||||
const entities = this.result.document.entities.length.toLocaleString()
|
const view = this.views.find((item) => item.id === this.activeViewId)
|
||||||
this.host.setStatus(`${this.file.name} · ${zoom}% · ${entities} 实体 · ${backend}`)
|
const entities = (view?.entities.length ?? this.result.document.entities.length).toLocaleString()
|
||||||
|
const viewLabel = this.views.length > 1 && view ? `${view.name} · ` : ''
|
||||||
|
this.host.setStatus(`${this.file.name} · ${viewLabel}${zoom}% · ${entities} 实体 · ${backend}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
private onFullscreenChange = () => {
|
private onFullscreenChange = () => {
|
||||||
|
|||||||
@@ -245,6 +245,8 @@ async function clearAllRecent(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openFile(file: File): Promise<void> {
|
async function openFile(file: File): Promise<void> {
|
||||||
|
;(window as unknown as { __openAt?: number }).__openAt = performance.now()
|
||||||
|
;(window as unknown as { __progress?: unknown }).__progress = []
|
||||||
if (busy) {
|
if (busy) {
|
||||||
toast('正在打开文件,请稍候…', 'info', 1600)
|
toast('正在打开文件,请稍候…', 'info', 1600)
|
||||||
return
|
return
|
||||||
|
|||||||
+12
-7
@@ -11,7 +11,7 @@ export interface ViewerHost {
|
|||||||
setSubbar: (content: Node | null) => void
|
setSubbar: (content: Node | null) => void
|
||||||
setStatus: (text: string, title?: string) => void
|
setStatus: (text: string, title?: string) => void
|
||||||
showLoader: (message: string, detail?: string) => void
|
showLoader: (message: string, detail?: string) => void
|
||||||
setLoaderMessage: (message: string, detail?: string) => void
|
setLoaderMessage: (message: string, detail?: string, percent?: number) => void
|
||||||
hideLoader: () => void
|
hideLoader: () => void
|
||||||
destroy: () => void
|
destroy: () => void
|
||||||
}
|
}
|
||||||
@@ -38,11 +38,11 @@ export function createViewerHost(onBack: () => void): ViewerHost {
|
|||||||
const body = h('div', { class: 'viewer-body' }, stage)
|
const body = h('div', { class: 'viewer-body' }, stage)
|
||||||
const loaderText = h('div', { class: 'loader-text', text: '正在打开…' })
|
const loaderText = h('div', { class: 'loader-text', text: '正在打开…' })
|
||||||
const loaderDetail = h('div', { class: 'loader-detail' })
|
const loaderDetail = h('div', { class: 'loader-detail' })
|
||||||
const loader = h(
|
// 解析期间用“确定进度条”代替无限旋转动画:低端设备/软件渲染下,持续动画会真的拖慢解析
|
||||||
'div',
|
const barFill = h('div', { class: 'loader-bar-fill' })
|
||||||
{ class: 'viewer-loader', hidden: true },
|
const bar = h('div', { class: 'loader-bar', hidden: true }, barFill)
|
||||||
h('div', { class: 'loader-card' }, h('div', { class: 'spinner' }), loaderText, loaderDetail)
|
const spinner = h('div', { class: 'spinner' })
|
||||||
)
|
const loader = h('div', { class: 'viewer-loader', hidden: true }, h('div', { class: 'loader-card' }, spinner, loaderText, loaderDetail, bar))
|
||||||
const root = h('section', { class: 'viewer' }, header, subbar, body, loader)
|
const root = h('section', { class: 'viewer' }, header, subbar, body, loader)
|
||||||
|
|
||||||
const setSubbar = (content: Node | null) => {
|
const setSubbar = (content: Node | null) => {
|
||||||
@@ -84,9 +84,14 @@ export function createViewerHost(onBack: () => void): ViewerHost {
|
|||||||
loaderText.textContent = message
|
loaderText.textContent = message
|
||||||
loaderDetail.textContent = detail ?? ''
|
loaderDetail.textContent = detail ?? ''
|
||||||
},
|
},
|
||||||
setLoaderMessage: (message, detail) => {
|
setLoaderMessage: (message, detail, percent) => {
|
||||||
loaderText.textContent = message
|
loaderText.textContent = message
|
||||||
loaderDetail.textContent = detail ?? ''
|
loaderDetail.textContent = detail ?? ''
|
||||||
|
const determinate = typeof percent === 'number' && Number.isFinite(percent)
|
||||||
|
// 知道百分比时隐藏旋转动画(display:none 会停止动画),只更新进度条宽度
|
||||||
|
spinner.style.display = determinate ? 'none' : ''
|
||||||
|
bar.hidden = !determinate
|
||||||
|
if (determinate) barFill.style.width = `${Math.max(0, Math.min(100, percent))}%`
|
||||||
},
|
},
|
||||||
hideLoader: () => {
|
hideLoader: () => {
|
||||||
loader.hidden = true
|
loader.hidden = true
|
||||||
|
|||||||
+140
-2
@@ -824,9 +824,10 @@ a {
|
|||||||
z-index: 80;
|
z-index: 80;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: rgba(8, 11, 17, 0.72);
|
/* 解析大图纸时这个遮罩会长时间停留:不要用 backdrop-filter(每帧整屏模糊非常吃 CPU/GPU) */
|
||||||
backdrop-filter: blur(8px);
|
background: rgba(8, 11, 17, 0.82);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
contain: strict;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loader-card {
|
.loader-card {
|
||||||
@@ -859,6 +860,22 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loader-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--line-strong);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
.loader-text {
|
.loader-text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
@@ -1099,6 +1116,127 @@ a {
|
|||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sheet-subtitle {
|
||||||
|
padding: 10px 14px 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-list {
|
||||||
|
flex: none;
|
||||||
|
max-height: 42vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-row {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-row:hover {
|
||||||
|
background: var(--bg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-row.active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-kind {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--bg-3, rgba(255, 255, 255, 0.08));
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-row.active .view-kind {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 550;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-hint {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diag-box {
|
||||||
|
flex: none;
|
||||||
|
margin: 0 14px 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diag-box:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diag-title {
|
||||||
|
font-weight: 650;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diag-line {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diag-muted {
|
||||||
|
opacity: 0.75;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diag-box .chip-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.layer-row {
|
.layer-row {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { h } from '../core/dom'
|
import { h } from '../core/dom'
|
||||||
import { openModal } from './modal'
|
import { openModal } from './modal'
|
||||||
|
|
||||||
const VERSION = '0.5.0'
|
const VERSION = '0.6.0'
|
||||||
|
|
||||||
export function showAbout(): void {
|
export function showAbout(): void {
|
||||||
const body = h(
|
const body = h(
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,261 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>EasyView · DWG render probe</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: #0d1117;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#stage {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="stage"></div>
|
||||||
|
<script type="module">
|
||||||
|
import { CadViewer } from '@flyfish-dev/cad-viewer'
|
||||||
|
|
||||||
|
const stage = document.getElementById('stage')
|
||||||
|
let current = null
|
||||||
|
|
||||||
|
function makeViewer(canvasOptions = {}, extra = {}) {
|
||||||
|
return new CadViewer({
|
||||||
|
container: stage,
|
||||||
|
renderer: 'auto',
|
||||||
|
wasmPath: new URL('/wasm/', location.href).href,
|
||||||
|
workerUrl: new URL('/wasm/dwg-worker.js', location.href).href,
|
||||||
|
dwfWasmUrl: new URL('/wasm/dwfv-render.wasm', location.href).href,
|
||||||
|
useWorker: true,
|
||||||
|
autoFit: true,
|
||||||
|
...extra,
|
||||||
|
canvasOptions: {
|
||||||
|
background: '#0d1117',
|
||||||
|
foreground: '#f4f7fb',
|
||||||
|
contrastMode: 'adaptive',
|
||||||
|
enableSpatialIndex: true,
|
||||||
|
spatialIndexCellCount: 96,
|
||||||
|
maxCurveSegments: 72,
|
||||||
|
maxVisibleTextLabels: 2400,
|
||||||
|
...canvasOptions
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeTypes(entities) {
|
||||||
|
const byType = {}
|
||||||
|
for (const e of entities) {
|
||||||
|
const t = e.type || 'UNKNOWN'
|
||||||
|
byType[t] = (byType[t] || 0) + 1
|
||||||
|
}
|
||||||
|
return byType
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyReport(entities) {
|
||||||
|
const proxies = entities.filter(
|
||||||
|
(e) => /proxy/i.test(e.type || '') || /proxy/i.test(String(e.subType || ''))
|
||||||
|
)
|
||||||
|
const byType = {}
|
||||||
|
for (const e of proxies) {
|
||||||
|
const t = e.type || 'PROXY'
|
||||||
|
byType[t] = (byType[t] || 0) + 1
|
||||||
|
}
|
||||||
|
return { total: proxies.length, byType }
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__probe = async (url, options = {}) => {
|
||||||
|
if (current) {
|
||||||
|
try {
|
||||||
|
current.destroy()
|
||||||
|
} catch {}
|
||||||
|
current = null
|
||||||
|
}
|
||||||
|
stage.innerHTML = ''
|
||||||
|
const viewer = makeViewer(options.canvasOptions || {}, options.viewerOptions || {})
|
||||||
|
current = viewer
|
||||||
|
|
||||||
|
const stats = []
|
||||||
|
let loadProgress = []
|
||||||
|
viewer.onRenderStats?.(() => {})
|
||||||
|
const res = await fetch(url)
|
||||||
|
const buf = await res.arrayBuffer()
|
||||||
|
const name = decodeURIComponent(url.split('/').pop())
|
||||||
|
const file = new File([buf], name)
|
||||||
|
|
||||||
|
let tPreload = 0
|
||||||
|
if (options.preload) {
|
||||||
|
const tp = performance.now()
|
||||||
|
await viewer.preloadDwg()
|
||||||
|
tPreload = performance.now() - tp
|
||||||
|
}
|
||||||
|
const t0 = performance.now()
|
||||||
|
const progressTimeline = []
|
||||||
|
const result = await viewer.loadFile(file, {
|
||||||
|
onProgress: (p) => progressTimeline.push({ ms: Math.round(performance.now() - t0), phase: p.phase, message: p.message })
|
||||||
|
})
|
||||||
|
const tLoad = performance.now() - t0
|
||||||
|
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
||||||
|
|
||||||
|
const doc = result.document
|
||||||
|
const firstView = viewer.renderer.render()
|
||||||
|
|
||||||
|
// pan/zoom interaction timing (操作速度)
|
||||||
|
const frameTimes = []
|
||||||
|
const renderer = viewer.renderer
|
||||||
|
let prev = performance.now()
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
renderer.panByScreenDelta(8, 6)
|
||||||
|
renderer.render()
|
||||||
|
const now = performance.now()
|
||||||
|
frameTimes.push(now - prev)
|
||||||
|
prev = now
|
||||||
|
}
|
||||||
|
const zoomTimes = []
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const t = performance.now()
|
||||||
|
viewer.zoomIn()
|
||||||
|
zoomTimes.push(performance.now() - t)
|
||||||
|
}
|
||||||
|
viewer.fit('auto')
|
||||||
|
|
||||||
|
const perf = (arr) => {
|
||||||
|
const s = [...arr].sort((a, b) => a - b)
|
||||||
|
return {
|
||||||
|
avg: +(arr.reduce((a, b) => a + b, 0) / arr.length).toFixed(2),
|
||||||
|
p50: +s[Math.floor(s.length * 0.5)].toFixed(2),
|
||||||
|
p95: +s[Math.floor(s.length * 0.95)].toFixed(2),
|
||||||
|
max: +s[s.length - 1].toFixed(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxySample = doc.entities
|
||||||
|
.filter((e) => /proxy/i.test(e.type || ''))
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((e) => ({
|
||||||
|
keys: Object.keys(e),
|
||||||
|
originalDxfName: e.originalDxfName,
|
||||||
|
graphicsDataSize: e.graphicsDataSize,
|
||||||
|
entityDataSize: e.entityDataSize,
|
||||||
|
hasPreview: !!e.graphicsData,
|
||||||
|
classId: e.applicationEntityClassId
|
||||||
|
}))
|
||||||
|
const proxyWithGraphics = doc.entities.filter(
|
||||||
|
(e) => /proxy/i.test(e.type || '') && (e.graphicsDataSize > 0 || e.entityDataSize > 0)
|
||||||
|
).length
|
||||||
|
|
||||||
|
// 复现 buildCadViews 的判定,便于对比 App 面板
|
||||||
|
const nameOf = (e) => (e.type === 'INSERT' ? e.effectiveBlockName || e.blockName || e.name : undefined)
|
||||||
|
const byName = new Map()
|
||||||
|
for (const b of Object.values(doc.blocks || {})) {
|
||||||
|
const k = String(b.name || '').trim().toLowerCase()
|
||||||
|
if (!k) continue
|
||||||
|
const cur = byName.get(k)
|
||||||
|
if (!cur || (b.entities || []).length > cur.entities.length) byName.set(k, b)
|
||||||
|
}
|
||||||
|
const sceneRefs = new Set(doc.entities.map(nameOf).filter(Boolean).map((n) => n.toLowerCase()))
|
||||||
|
const candidates = [...byName.entries()].filter(([k, b]) => !sceneRefs.has(k) && !/^\*?(model|paper)_space/i.test(b.name) && (b.entities || []).length)
|
||||||
|
const candRefs = new Map()
|
||||||
|
for (const [k, b] of candidates) {
|
||||||
|
for (const e of b.entities || []) {
|
||||||
|
const n = nameOf(e)
|
||||||
|
if (n) candRefs.set(n.toLowerCase(), (candRefs.get(n.toLowerCase()) || 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const viewCandidates = candidates
|
||||||
|
.filter(([k]) => !candRefs.has(k))
|
||||||
|
.sort((a, b) => b[1].entities.length - a[1].entities.length)
|
||||||
|
.map(([k, b]) => `${b.name}:${b.entities.length}`)
|
||||||
|
const viewBlockParents = [...candRefs.entries()]
|
||||||
|
.filter(([k]) => /视图/.test(k))
|
||||||
|
.map(([k, c]) => `${k}<-${c}`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: name,
|
||||||
|
blockGraph: Object.values(doc.blocks || {})
|
||||||
|
.filter((b) => (b.entities || []).length >= 15)
|
||||||
|
.map((b) => ({ n: b.name, size: b.entities.length, ins: [...new Set((b.entities || []).map(nameOf).filter(Boolean))].slice(0, 8) })),
|
||||||
|
viewCandidates: viewCandidates.slice(0, 12),
|
||||||
|
viewBlockParents: viewBlockParents.slice(0, 12),
|
||||||
|
proxySample,
|
||||||
|
proxyWithGraphics,
|
||||||
|
bytes: result.bytes,
|
||||||
|
parseElapsedMs: Math.round(result.elapsedMs),
|
||||||
|
preloadMs: Math.round(tPreload),
|
||||||
|
progressTimeline,
|
||||||
|
paperSpaceEntities: doc.entities.filter((e) => e.isInPaperSpace === true).length,
|
||||||
|
metadataKeys: Object.keys(doc.metadata || {}),
|
||||||
|
headerKeys: Object.keys(doc.header || {}).slice(0, 40),
|
||||||
|
loadElapsedMs: Math.round(tLoad),
|
||||||
|
backend: firstView.backend || 'unknown',
|
||||||
|
entityCount: doc.entities.length,
|
||||||
|
entityTypes: summarizeTypes(doc.entities),
|
||||||
|
layerCount: Object.keys(doc.layers || {}).length,
|
||||||
|
blockCount: Object.keys(doc.blocks || {}).length,
|
||||||
|
spaceBlocks: Object.entries(doc.blocks || {})
|
||||||
|
.filter(([name]) => /space/i.test(name))
|
||||||
|
.map(([name, b]) => ({ name, entities: (b.entities || []).length })),
|
||||||
|
blockNames: Object.keys(doc.blocks || {}),
|
||||||
|
sceneInserts: [...new Set(doc.entities.filter((e) => e.type === 'INSERT').map((e) => e.effectiveBlockName || e.blockName || e.name).filter(Boolean))],
|
||||||
|
blockEntityTotal: Object.values(doc.blocks || {}).reduce((n, b) => n + (b.entities || []).length, 0),
|
||||||
|
topBlocks: Object.entries(doc.blocks || {})
|
||||||
|
.sort((a, b) => (b[1].entities || []).length - (a[1].entities || []).length)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([name, b]) => `${name}:${(b.entities || []).length}`),
|
||||||
|
pages: (doc.pages || []).map((p) => ({ name: p.name, type: p.type })),
|
||||||
|
pageCount: (doc.pages || []).length,
|
||||||
|
proxies: proxyReport(doc.entities),
|
||||||
|
missingReferences: (viewer.getMissingReferences?.() || []).map((r) => r.name || r.fileName || r),
|
||||||
|
loadedReferences: (viewer.getLoadedReferences?.() || []).length,
|
||||||
|
warnings: [...(result.warnings || []), ...(doc.warnings || [])].slice(0, 40),
|
||||||
|
render: {
|
||||||
|
total: firstView.total,
|
||||||
|
drawn: firstView.drawn,
|
||||||
|
skipped: firstView.skipped,
|
||||||
|
unsupported: firstView.unsupported,
|
||||||
|
renderElapsedMs: +firstView.renderElapsedMs.toFixed(2),
|
||||||
|
primitiveCount: firstView.primitiveCount,
|
||||||
|
visiblePrimitiveCount: firstView.visiblePrimitiveCount,
|
||||||
|
culledPrimitiveCount: firstView.culledPrimitiveCount,
|
||||||
|
gpuMemoryMB: firstView.gpuMemoryBytes ? +(firstView.gpuMemoryBytes / 1048576).toFixed(1) : undefined,
|
||||||
|
buildElapsedMs: firstView.buildElapsedMs ? Math.round(firstView.buildElapsedMs) : undefined
|
||||||
|
},
|
||||||
|
interaction: { panFrameMs: perf(frameTimes), zoomCallMs: perf(zoomTimes) },
|
||||||
|
zoomPercent: viewer.getZoomPercent(),
|
||||||
|
loadProgressCount: loadProgress.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 供 CDP 剖析用:先建 viewer + 预编译 wasm(不解析),再单独触发解析
|
||||||
|
window.__prepare = async (url) => {
|
||||||
|
if (current) {
|
||||||
|
try { current.destroy() } catch {}
|
||||||
|
current = null
|
||||||
|
}
|
||||||
|
stage.innerHTML = ''
|
||||||
|
const viewer = makeViewer()
|
||||||
|
current = viewer
|
||||||
|
const res = await fetch(url)
|
||||||
|
const buf = await res.arrayBuffer()
|
||||||
|
const name = decodeURIComponent(url.split('/').pop())
|
||||||
|
const file = new File([buf], name)
|
||||||
|
const t = performance.now()
|
||||||
|
await viewer.preloadDwg()
|
||||||
|
window.__pendingFile = file
|
||||||
|
return { preloadMs: Math.round(performance.now() - t) }
|
||||||
|
}
|
||||||
|
window.__loadPrepared = async () => {
|
||||||
|
const t = performance.now()
|
||||||
|
const result = await current.loadFile(window.__pendingFile)
|
||||||
|
return { parseMs: Math.round(performance.now() - t), entities: result.document.entities.length }
|
||||||
|
}
|
||||||
|
window.__capture = async (url) => url
|
||||||
|
window.__ready = true
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// 在 Node 里直接跑 DWG worker 的解析流程(带 CPU profile),用于定位热点。
|
||||||
|
// 用法:node --cpu-prof --cpu-prof-dir=/tmp/prof tools/worker-node-run.mjs <dwg> <静态资源根 URL>
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
|
||||||
|
const root = process.cwd()
|
||||||
|
const file = process.argv[2]
|
||||||
|
const wasmBase = process.argv[3] || 'http://127.0.0.1:8123/wasm/'
|
||||||
|
if (!file) throw new Error('需要 DWG 路径')
|
||||||
|
|
||||||
|
const messages = []
|
||||||
|
globalThis.self = {
|
||||||
|
postMessage(msg) {
|
||||||
|
messages.push(msg)
|
||||||
|
if (msg.type === 'result') {
|
||||||
|
const doc = msg.result?.document
|
||||||
|
console.log('结果: 实体', doc?.entities?.length, '耗时', Math.round(msg.result.elapsedMs), 'ms')
|
||||||
|
for (const m of messages.filter((x) => x.type === 'progress')) {
|
||||||
|
console.log(' progress', m.progress.phase, '|', m.progress.message)
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
if (msg.type === 'error') {
|
||||||
|
console.error('worker 错误:', msg.error)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {}
|
||||||
|
}
|
||||||
|
globalThis.performance = performance
|
||||||
|
globalThis.window = globalThis // 让 Emscripten 走 Web 分支(用 fetch 而不是 node:module)
|
||||||
|
|
||||||
|
await import(pathToFileURL(path.join(root, 'public/wasm/dwg-worker.js')).href)
|
||||||
|
|
||||||
|
const bytes = fs.readFileSync(file)
|
||||||
|
const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
|
||||||
|
const started = Date.now()
|
||||||
|
await globalThis.self.onmessage({
|
||||||
|
data: {
|
||||||
|
requestId: 1,
|
||||||
|
type: 'load',
|
||||||
|
bytes: buf,
|
||||||
|
fileName: path.basename(file),
|
||||||
|
options: { wasmPath: wasmBase }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('总耗时', Date.now() - started, 'ms')
|
||||||
Reference in New Issue
Block a user