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> } 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 { 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() 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) else groups.set(key, { name, refs: [layer as unknown as Record] }) } 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 { try { if (document.fullscreenElement) await document.exitFullscreen?.() else await this.host.root.requestFullscreen?.() } catch { toast('当前浏览器不支持全屏', 'error') } } private async exportPng(): Promise { 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' }