382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
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'
|
|
}
|