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:
2026-10-26 20:28:30 +08:00
parent f70913481e
commit 9dfed7957a
15 changed files with 1287 additions and 24 deletions
+143
View File
@@ -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
View File
@@ -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<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)
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<string, number> }).__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<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 {
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 = () => {
+2
View File
@@ -245,6 +245,8 @@ async function clearAllRecent(): 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) {
toast('正在打开文件,请稍候…', 'info', 1600)
return
+12 -7
View File
@@ -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
+140 -2
View File
@@ -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;
+1 -1
View File
@@ -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(