Integrate flyfish CAD viewer

This commit is contained in:
2026-10-26 18:07:00 +08:00
parent e4eee1b3b7
commit e01c7eaecb
28 changed files with 560 additions and 5376 deletions
+381
View File
@@ -0,0 +1,381 @@
import './polyfills'
import {
CadViewer,
type CadViewerLoadResult,
type CadDocument as FlyCadDocument
} from '@flyfish-dev/cad-viewer'
import '@flyfish-dev/cad-viewer/styles.css'
import { clear, h, icon, iconButton } from '../core/dom'
import { formatBytes } from '../core/files'
import { getTheme, onThemeChange, toggleTheme } from '../core/theme'
import { toast } from '../core/toast'
import type { ViewerHost } from '../core/viewerHost'
import { showAbout } from '../ui/about'
interface Callbacks {
onBack: () => void
}
interface LayerRow {
name: string
refs: Array<Record<string, unknown>>
}
export class FlyfishCadViewer {
private host: ViewerHost
private file: File
private callbacks: Callbacks
private viewer: CadViewer
private container: HTMLElement
private tools: HTMLElement
private sheetBackdrop: HTMLElement
private sheetList: HTMLElement
private themeBtn!: HTMLButtonElement
private fullscreenBtn!: HTMLButtonElement
private result: CadViewerLoadResult | null = null
private themeUnsub: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private destroyed = false
constructor(host: ViewerHost, file: File, callbacks: Callbacks) {
this.host = host
this.file = file
this.callbacks = callbacks
this.container = h('div', { class: 'flyfish-cad' })
host.stage.appendChild(this.container)
this.host.setFileInfo(file.name, 'DWG 图纸', 'DWG', 'cad', formatBytes(file.size))
this.host.setSubbar(null)
this.host.setStatus('正在加载 DWG 引擎…')
this.viewer = new CadViewer({
container: this.container,
renderer: 'auto',
wasmPath: new URL('wasm/', document.baseURI).href,
workerUrl: new URL('wasm/dwg-worker.js', document.baseURI).href,
dwfWasmUrl: new URL('wasm/dwfv-render.wasm', document.baseURI).href,
useWorker: true,
workerTimeoutMs: 120000,
autoFit: true,
canvasOptions: {
fitMode: 'auto',
background: '#0d1117',
foreground: '#f4f7fb',
contrastMode: 'adaptive',
enableSpatialIndex: true,
spatialIndexCellCount: 96,
maxCurveSegments: 72,
maxVisibleTextLabels: 2400
},
onLoadProgress: (progress) => {
this.host.setLoaderMessage(progress.message || '正在解析 DWG 图纸…', this.file.name)
},
onViewChange: () => this.updateStatus(),
onError: (error) => {
this.host.setStatus(`DWG 错误:${error.message}`)
}
})
this.tools = this.buildTools()
this.sheetBackdrop = h('div', { class: 'sheet-backdrop' })
this.sheetList = h('div', { class: 'sheet-list' })
this.sheetBackdrop.appendChild(this.buildSheet())
host.stage.append(this.tools, this.sheetBackdrop)
this.buildHeaderActions()
this.applyTheme()
this.updateThemeButton()
this.themeUnsub = onThemeChange(() => {
this.applyTheme()
this.updateThemeButton()
})
this.resizeObserver = new ResizeObserver(() => this.viewer.resize())
this.resizeObserver.observe(this.host.stage)
document.addEventListener('fullscreenchange', this.onFullscreenChange)
}
async load(): Promise<void> {
this.host.showLoader('正在加载 DWG 图纸…', this.file.name)
try {
const result = await this.viewer.loadFile(this.file)
this.result = result
this.host.hideLoader()
this.host.setFileInfo(
this.file.name,
'DWG 图纸',
'DWG',
'cad',
`${result.document.entities.length.toLocaleString()} 个实体 · ${formatBytes(this.file.size)}`
)
this.viewer.fit('auto')
this.applyTheme()
this.updateStatus()
if (result.warnings.length) toast(result.warnings[0], 'info', 6000)
this.refreshSheetRows()
} catch (error) {
this.host.hideLoader()
throw error
}
}
private buildHeaderActions(): void {
const downloadBtn = iconButton('download', '保存当前视图为 PNG', () => void this.exportPng())
const fullscreenBtn = iconButton('maximize', '全屏', () => void this.toggleFullscreen())
fullscreenBtn.dataset.action = 'fullscreen'
const aboutBtn = iconButton('info', '关于', () => showAbout())
this.host.actions.append(downloadBtn, fullscreenBtn, aboutBtn)
this.fullscreenBtn = fullscreenBtn
}
private buildTools(): HTMLElement {
const tools = h('div', { class: 'cad-tools' })
const add = (name: string, label: string, action: string, onClick: () => void) => {
const btn = iconButton(name, label, (ev) => {
ev.stopPropagation()
onClick()
})
btn.dataset.action = action
tools.appendChild(btn)
return btn
}
add('plus', '放大', 'zoom-in', () => {
this.viewer.zoomIn()
this.updateStatus()
})
add('minus', '缩小', 'zoom-out', () => {
this.viewer.zoomOut()
this.updateStatus()
})
add('fit', '适配全图', 'fit', () => {
this.viewer.fit('auto')
this.updateStatus()
})
tools.appendChild(h('div', { class: 'tool-divider' }))
add('layers', '图层', 'layers', () => this.openSheet())
this.themeBtn = add('sun', '切换背景', 'theme', () => {
toggleTheme()
this.applyTheme()
this.updateThemeButton()
})
return tools
}
private buildSheet(): HTMLElement {
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' })
const showAll = h('button', { class: 'chip-btn', type: 'button', text: '全部显示' })
const hideAll = h('button', { class: 'chip-btn', type: 'button', text: '全部隐藏' })
const reset = h('button', { class: 'chip-btn', type: 'button', text: '恢复默认' })
showAll.addEventListener('click', () => this.setAllLayers(true))
hideAll.addEventListener('click', () => this.setAllLayers(false))
reset.addEventListener('click', () => {
for (const group of this.layerGroups()) {
for (const layer of group.refs) layer.isVisible = true
}
this.applyLayerChange()
this.refreshSheetRows()
})
actions.append(showAll, hideAll, reset)
this.sheetBackdrop.addEventListener('click', (event) => {
if (event.target === this.sheetBackdrop) this.closeSheet()
})
return h(
'aside',
{ class: 'sheet', role: 'dialog', 'aria-label': '图层' },
head,
actions,
this.sheetList
)
}
private layerGroups(): LayerRow[] {
const doc = this.viewer.getDocument()
if (!doc) return []
const groups = new Map<string, LayerRow>()
for (const layer of Object.values(doc.layers)) {
const name = String(layer.name ?? '').trim()
if (!name) continue
const key = name.toLowerCase()
const existing = groups.get(key)
if (existing) existing.refs.push(layer as unknown as Record<string, unknown>)
else groups.set(key, { name, refs: [layer as unknown as Record<string, unknown>] })
}
return [...groups.values()].sort((a, b) => {
if (a.name === '0') return -1
if (b.name === '0') return 1
return a.name.localeCompare(b.name, 'zh-Hans-CN')
})
}
private refreshSheetRows(): void {
if (!this.sheetList) return
clear(this.sheetList)
for (const group of this.layerGroups()) {
const visible = group.refs.some((layer) => layer.isVisible !== false)
const row = h('button', {
class: `layer-row ${visible ? 'visible' : 'hidden'}`,
type: 'button',
dataset: { name: group.name.toLowerCase() }
})
const swatch = h('span', { class: 'layer-swatch' })
const first = group.refs[0] as { color?: unknown; colorIndex?: number }
swatch.style.background = layerSwatch(first.color, first.colorIndex)
const info = h(
'span',
{ class: 'layer-info' },
h('span', { class: 'layer-name', text: group.name }),
h('span', { class: 'layer-meta', text: visible ? '显示' : '隐藏' })
)
const eye = h('span', { class: 'layer-eye', html: icon(visible ? 'eye' : 'eye-off', 18) })
row.append(swatch, info, eye)
row.addEventListener('click', () => {
for (const layer of group.refs) layer.isVisible = !visible
this.applyLayerChange()
this.refreshSheetRows()
})
this.sheetList.appendChild(row)
}
}
private setAllLayers(visible: boolean): void {
for (const group of this.layerGroups()) {
for (const layer of group.refs) layer.isVisible = visible
}
this.applyLayerChange()
this.refreshSheetRows()
}
private applyLayerChange(): void {
const doc = this.viewer.getDocument() as FlyCadDocument | undefined
if (!doc) return
const renderer = this.viewer.renderer
const state = renderer.getViewState()
renderer.setDocument(doc)
renderer.setViewState(state)
renderer.render()
this.updateStatus()
}
private openSheet(): void {
this.refreshSheetRows()
this.sheetBackdrop.classList.add('open')
}
private closeSheet(): void {
this.sheetBackdrop.classList.remove('open')
}
private applyTheme(): void {
const dark = getTheme() === 'dark'
this.container.style.background = dark ? '#0d1117' : '#f6f8fc'
this.viewer.setCanvasOptions({
background: dark ? '#0d1117' : '#f6f8fc',
foreground: dark ? '#f4f7fb' : '#111318',
contrastMode: 'adaptive'
})
}
private updateThemeButton(): void {
if (!this.themeBtn) return
this.themeBtn.innerHTML = icon(getTheme() === 'dark' ? 'sun' : 'moon', 20)
this.themeBtn.setAttribute('title', getTheme() === 'dark' ? '切换为浅色背景' : '切换为深色背景')
}
private updateStatus(): void {
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}`)
}
private onFullscreenChange = () => {
if (!this.fullscreenBtn) return
const active = !!document.fullscreenElement
this.fullscreenBtn.innerHTML = icon(active ? 'minimize' : 'maximize', 20)
this.fullscreenBtn.setAttribute('title', active ? '退出全屏' : '全屏')
window.setTimeout(() => this.viewer.resize(), 80)
}
private async toggleFullscreen(): Promise<void> {
try {
if (document.fullscreenElement) await document.exitFullscreen?.()
else await this.host.root.requestFullscreen?.()
} catch {
toast('当前浏览器不支持全屏', 'error')
}
}
private async exportPng(): Promise<void> {
try {
const source = await this.viewer.captureCanvas()
const output = document.createElement('canvas')
output.width = source.width
output.height = source.height
const ctx = output.getContext('2d')
if (!ctx) throw new Error('无法创建导出画布')
ctx.drawImage(source, 0, 0)
output.toBlob((blob) => {
if (!blob) {
toast('导出失败', 'error')
return
}
const baseName = this.file.name.replace(/\.[^.]+$/, '') || 'drawing'
const candidate = `${baseName}-当前视图.png`
const bridge = (window as unknown as { AndroidViewer?: { saveBase64?: (name: string, mime: string, data: string) => void } }).AndroidViewer
if (bridge?.saveBase64) {
const reader = new FileReader()
reader.onload = () => {
const data = String(reader.result ?? '')
bridge.saveBase64?.(candidate, 'image/png', data.includes(',') ? data.slice(data.indexOf(',') + 1) : data)
toast('已保存到 Android 下载目录', 'success')
}
reader.onerror = () => toast('保存失败', 'error')
reader.readAsDataURL(blob)
return
}
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = candidate
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
window.setTimeout(() => URL.revokeObjectURL(url), 4000)
toast('已保存当前视图', 'success')
}, 'image/png')
} catch (error) {
toast(error instanceof Error ? error.message : '导出失败', 'error')
}
}
destroy(): void {
if (this.destroyed) return
this.destroyed = true
this.resizeObserver?.disconnect()
this.themeUnsub?.()
document.removeEventListener('fullscreenchange', this.onFullscreenChange)
try {
this.viewer.destroy()
} catch {
/* ignore */
}
this.container.remove()
this.tools.remove()
this.sheetBackdrop.remove()
this.host.destroy()
this.callbacks.onBack
}
}
function layerSwatch(color: unknown, colorIndex?: number): string {
if (typeof color === 'string') return color
if (typeof color === 'number' && Number.isFinite(color)) {
const value = color & 0xffffff
return `#${value.toString(16).padStart(6, '0')}`
}
if (typeof colorIndex === 'number' && colorIndex >= 1 && colorIndex <= 255) {
return `hsl(${(colorIndex * 47) % 360} 60% 55%)`
}
return '#8a94a6'
}