Integrate flyfish CAD viewer
This commit is contained in:
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user