diff --git a/.gitignore b/.gitignore index df1bc09..ce3c3a8 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ EasyView-*.apk *.local .DS_Store Thumbs.db +tools/out/ + +# 本地测试用图纸(含客户资料,绝不入库) +testfile/ diff --git a/package.json b/package.json index 9eaac1e..0c46251 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "phone-viewer-app", "private": true, - "version": "0.5.0", + "version": "0.6.0", "type": "module", "license": "AGPL-3.0-or-later", "description": "Mobile/tablet all-in-one viewer: DWG (model & layout space) + PDF/EPUB/XPS/CBZ/images", diff --git a/scripts/build-apk.mjs b/scripts/build-apk.mjs index 0563ef2..332962b 100644 --- a/scripts/build-apk.mjs +++ b/scripts/build-apk.mjs @@ -99,7 +99,7 @@ const kotlinStdlib = fs 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 versionCode = Number(process.env.APP_VERSION_CODE || 7) +const versionCode = Number(process.env.APP_VERSION_CODE || 8) const env = { ...process.env, diff --git a/scripts/probe-app.mjs b/scripts/probe-app.mjs new file mode 100644 index 0000000..d34674f --- /dev/null +++ b/scripts/probe-app.mjs @@ -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) diff --git a/scripts/probe-dwg.mjs b/scripts/probe-dwg.mjs new file mode 100644 index 0000000..baeda0e --- /dev/null +++ b/scripts/probe-dwg.mjs @@ -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'))}`) diff --git a/scripts/profile-worker.mjs b/scripts/profile-worker.mjs new file mode 100644 index 0000000..91c9550 --- /dev/null +++ b/scripts/profile-worker.mjs @@ -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 ') + 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) +} diff --git a/src/cad/docViews.ts b/src/cad/docViews.ts new file mode 100644 index 0000000..0a11cf7 --- /dev/null +++ b/src/cad/docViews.ts @@ -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() + 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() + 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() + const referencedByAnyBlock = new Set() + 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): 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 } +} diff --git a/src/cad/flyfishViewer.ts b/src/cad/flyfishViewer.ts index 32d56cb..b2c2784 100644 --- a/src/cad/flyfishViewer.ts +++ b/src/cad/flyfishViewer.ts @@ -1,8 +1,9 @@ import './polyfills' import { CadViewer, - type CadViewerLoadResult, - type CadDocument as FlyCadDocument + type CadDocument as FlyCadDocument, + type CadEntity, + type CadViewerLoadResult } from '@flyfish-dev/cad-viewer' import '@flyfish-dev/cad-viewer/styles.css' 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 type { ViewerHost } from '../core/viewerHost' import { showAbout } from '../ui/about' +import { buildCadViews, summarizeEntities, type CadView } from './docViews' interface Callbacks { onBack: () => void @@ -33,6 +35,10 @@ export class FlyfishCadViewer { private themeBtn!: HTMLButtonElement private fullscreenBtn!: HTMLButtonElement 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 resizeObserver: ResizeObserver | null = null private destroyed = false @@ -54,8 +60,14 @@ export class FlyfishCadViewer { workerUrl: new URL('wasm/dwg-worker.js', document.baseURI).href, dwfWasmUrl: new URL('wasm/dwfv-render.wasm', document.baseURI).href, useWorker: true, - workerTimeoutMs: 120000, + workerTimeoutMs: 180000, + // File → worker 直接转移 ArrayBuffer,省掉一次几十 MB 的拷贝 + transferInputBuffer: true, + maxInsertDepth: 32, autoFit: true, + // 布局空间实体默认被丢弃,这里取回后在“布局空间”视图里单独渲染 + includePaperSpace: true, + // File → worker 直接转移 ArrayBuffer,省掉一次 23MB 级别的内存拷贝 canvasOptions: { fitMode: 'auto', background: '#0d1117', @@ -64,10 +76,15 @@ export class FlyfishCadViewer { enableSpatialIndex: true, spatialIndexCellCount: 96, maxCurveSegments: 72, - maxVisibleTextLabels: 2400 + maxVisibleTextLabels: 2400, + // 代理对象/不支持的实体画成方框,避免静默消失 + showUnsupportedMarkers: true }, 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(), onError: (error) => { @@ -77,6 +94,8 @@ export class FlyfishCadViewer { this.tools = this.buildTools() 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.sheetBackdrop.appendChild(this.buildSheet()) host.stage.append(this.tools, this.sheetBackdrop) @@ -93,9 +112,15 @@ export class FlyfishCadViewer { } async load(): Promise { + const tStart = performance.now() + const marks: Record = {} + const mark = (name: string) => { + marks[name] = Math.round(performance.now() - tStart) + } this.host.showLoader('正在加载 DWG 图纸…', this.file.name) try { const result = await this.viewer.loadFile(this.file) + mark('parsed') this.result = result this.host.hideLoader() this.host.setFileInfo( @@ -105,11 +130,32 @@ export class FlyfishCadViewer { 'cad', `${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.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.refreshViewRows() + this.refreshDiagnostics() + mark('done') + ;(window as unknown as { __loadTimings?: Record }).__loadTimings = marks } catch (error) { this.host.hideLoader() throw error @@ -149,7 +195,7 @@ export class FlyfishCadViewer { this.updateStatus() }) tools.appendChild(h('div', { class: 'tool-divider' })) - add('layers', '图层', 'layers', () => this.openSheet()) + add('layers', '视图与图层', 'layers', () => this.openSheet()) this.themeBtn = add('sun', '切换背景', 'theme', () => { toggleTheme() this.applyTheme() @@ -158,8 +204,104 @@ export class FlyfishCadViewer { 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 { + 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 { - 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 head = h('div', { class: 'sheet-head' }, title, close) const actions = h('div', { class: 'sheet-actions' }) @@ -181,8 +323,11 @@ export class FlyfishCadViewer { }) return h( 'aside', - { class: 'sheet', role: 'dialog', 'aria-label': '图层' }, + { class: 'sheet', role: 'dialog', 'aria-label': '视图与图层' }, head, + this.diagEl, + this.viewListEl, + h('div', { class: 'sheet-subtitle', text: '图层' }), actions, this.sheetList ) @@ -258,6 +403,8 @@ export class FlyfishCadViewer { private openSheet(): void { this.refreshSheetRows() + this.refreshViewRows() + this.refreshDiagnostics() this.sheetBackdrop.classList.add('open') } @@ -285,8 +432,10 @@ export class FlyfishCadViewer { if (this.destroyed || !this.result) return const zoom = Math.round(this.viewer.getZoomPercent()) const backend = (this.viewer.renderer as { backend?: string }).backend ?? 'canvas2d' - const entities = this.result.document.entities.length.toLocaleString() - this.host.setStatus(`${this.file.name} · ${zoom}% · ${entities} 实体 · ${backend}`) + const view = this.views.find((item) => item.id === this.activeViewId) + 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 = () => { diff --git a/src/core/app.ts b/src/core/app.ts index 0a7f8fb..b1c89c0 100644 --- a/src/core/app.ts +++ b/src/core/app.ts @@ -245,6 +245,8 @@ async function clearAllRecent(): Promise { } async function openFile(file: File): Promise { + ;(window as unknown as { __openAt?: number }).__openAt = performance.now() + ;(window as unknown as { __progress?: unknown }).__progress = [] if (busy) { toast('正在打开文件,请稍候…', 'info', 1600) return diff --git a/src/core/viewerHost.ts b/src/core/viewerHost.ts index ab4cb78..fe9538b 100644 --- a/src/core/viewerHost.ts +++ b/src/core/viewerHost.ts @@ -11,7 +11,7 @@ export interface ViewerHost { setSubbar: (content: Node | null) => void setStatus: (text: string, title?: string) => void showLoader: (message: string, detail?: string) => void - setLoaderMessage: (message: string, detail?: string) => void + setLoaderMessage: (message: string, detail?: string, percent?: number) => void hideLoader: () => void destroy: () => void } @@ -38,11 +38,11 @@ export function createViewerHost(onBack: () => void): ViewerHost { const body = h('div', { class: 'viewer-body' }, stage) const loaderText = h('div', { class: 'loader-text', text: '正在打开…' }) const loaderDetail = h('div', { class: 'loader-detail' }) - const loader = h( - 'div', - { class: 'viewer-loader', hidden: true }, - h('div', { class: 'loader-card' }, h('div', { class: 'spinner' }), loaderText, loaderDetail) - ) + // 解析期间用“确定进度条”代替无限旋转动画:低端设备/软件渲染下,持续动画会真的拖慢解析 + const barFill = h('div', { class: 'loader-bar-fill' }) + const bar = h('div', { class: 'loader-bar', hidden: true }, barFill) + 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 setSubbar = (content: Node | null) => { @@ -84,9 +84,14 @@ export function createViewerHost(onBack: () => void): ViewerHost { loaderText.textContent = message loaderDetail.textContent = detail ?? '' }, - setLoaderMessage: (message, detail) => { + setLoaderMessage: (message, detail, percent) => { loaderText.textContent = message 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: () => { loader.hidden = true diff --git a/src/styles.css b/src/styles.css index a52c78e..3288eb6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -824,9 +824,10 @@ a { z-index: 80; display: grid; place-items: center; - background: rgba(8, 11, 17, 0.72); - backdrop-filter: blur(8px); + /* 解析大图纸时这个遮罩会长时间停留:不要用 backdrop-filter(每帧整屏模糊非常吃 CPU/GPU) */ + background: rgba(8, 11, 17, 0.82); padding: 20px; + contain: strict; } .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 { font-size: 14px; font-weight: 650; @@ -1099,6 +1116,127 @@ a { 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 { width: 100%; display: flex; diff --git a/src/ui/about.ts b/src/ui/about.ts index 2aa94ab..31dd363 100644 --- a/src/ui/about.ts +++ b/src/ui/about.ts @@ -1,7 +1,7 @@ import { h } from '../core/dom' import { openModal } from './modal' -const VERSION = '0.5.0' +const VERSION = '0.6.0' export function showAbout(): void { const body = h( diff --git a/tools/render-probe.html b/tools/render-probe.html new file mode 100644 index 0000000..192caf7 --- /dev/null +++ b/tools/render-probe.html @@ -0,0 +1,261 @@ + + + + + EasyView · DWG render probe + + + +
+ + + diff --git a/tools/worker-node-run.mjs b/tools/worker-node-run.mjs new file mode 100644 index 0000000..9128e56 --- /dev/null +++ b/tools/worker-node-run.mjs @@ -0,0 +1,49 @@ +// 在 Node 里直接跑 DWG worker 的解析流程(带 CPU profile),用于定位热点。 +// 用法:node --cpu-prof --cpu-prof-dir=/tmp/prof tools/worker-node-run.mjs <静态资源根 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')