/** * DWG / DXF 图纸查看器 —— 界面与交互装配。 * * 数据流:文件 → source.js(解析) → flatten.js(展平成显示列表) → render.js(画) * 这里只负责把 UI 事件接到上面三层,以及图层面板、测量/批注工具、导出等外围功能。 */ import ACI from './libs/dxf-parser/AutoCadColorIndex.js' import { flatten } from './js/flatten.js' import { BACKGROUNDS, Renderer } from './js/render.js' import { extOf, loadDrawing } from './js/source.js' import { ANNO_MODES, AnnoTool, MEASURE_MODES, MeasureTool, Snapper } from './js/tools.js' const $ = (id) => document.getElementById(id) const params = new URLSearchParams(location.search) // ---------------------------------------------------------------- 状态 const S = { doc: null, shapes: [], layerStats: new Map(), // 图层名 → 图元数 tool: 'select', // select | measure | anno | zoomBox embed: params.get('embed') === '1', } const wrap = $('canvasWrap') const renderer = new Renderer(wrap) const snapper = new Snapper(renderer) const measure = new MeasureTool(renderer, snapper, () => { renderStats(); renderer.requestDraw() }) const anno = new AnnoTool(renderer, () => renderer.requestDraw()) renderer.overlay = (ctx, r) => { anno.draw(ctx, r) if (S.tool === 'measure' || measure.results.length) measure.draw(ctx, r) drawRubber(ctx, r) } if (S.embed) document.body.classList.add('embed') // ---------------------------------------------------------------- 加载 function setLoading(on, text) { $('loading').classList.toggle('hidden', !on) if (text) $('loadingText').textContent = text } function showError(msg) { let box = $('errBox') if (!box) { box = document.createElement('div') box.id = 'errBox' $('viewport').appendChild(box) } box.innerHTML = '' box.append(msg) const b = document.createElement('button') b.className = 'small-btn' b.textContent = '关闭' b.style.marginLeft = '12px' b.onclick = () => box.remove() box.appendChild(b) } async function openBuffer(name, buffer) { setLoading(true, '正在解析图纸...') const t0 = performance.now() try { const doc = await loadDrawing(name, buffer, (m) => setLoading(true, m)) setLoading(true, '正在生成图形...') await nextFrame() const { shapes, bbox, stats } = flatten(doc) if (!shapes.length) throw new Error('图纸里没有可显示的图元(可能是空图或全部实体类型暂不支持)') S.doc = doc S.buffer = buffer // 留着给「上传并生成嵌入链接」用 applyShapes(doc, shapes, bbox) measure.clear() anno.clear() setUnit(unitLabel(doc), true) buildLayoutSelect(doc) $('fileName').textContent = `${name} · ${doc.format} · ${stats.entities} 实体 / ${shapes.length} 图元` renderInfo(stats, performance.now() - t0) setStatus(`已加载 ${name}`) $('modelInfo').textContent = `${shapes.length} 图元 · ${(performance.now() - t0).toFixed(0)}ms` } catch (e) { console.error(e) showError(`打开失败:${e && e.message ? e.message : e}`) } finally { setLoading(false) } } const nextFrame = () => new Promise((r) => requestAnimationFrame(() => r())) /** 把一份显示列表装进渲染器并刷新左侧图层面板 */ function applyShapes(doc, shapes, bbox) { S.shapes = shapes S.layerStats = new Map() for (const s of shapes) S.layerStats.set(s.layer, (S.layerStats.get(s.layer) || 0) + 1) renderer.setDocument(doc, shapes, bbox) renderer.zoomExtents() isolated = null buildLayerPanel() renderer.draw() } /** * 模型空间 / 图纸空间布局切换。 * 国内机械图多半把图框画在模型空间,所以只有布局里真有实体时才显示这个下拉。 */ function buildLayoutSelect(doc) { const sel = $('layoutSelect') const used = (doc.layouts || []).filter((l) => l.entities && l.entities.length) sel.innerHTML = '' const add = (val, text) => { const o = document.createElement('option'); o.value = val; o.textContent = text; sel.appendChild(o) } add('model', '模型空间') used.forEach((l, i) => add(String(i), l.name.replace(/^\*/, '') || `布局 ${i + 1}`)) $('layoutBox').classList.toggle('hidden', used.length === 0) sel.value = 'model' sel.onchange = () => { const ents = sel.value === 'model' ? doc.modelEntities : used[+sel.value].entities const { shapes, bbox } = flatten(doc, { entities: ents }) if (!shapes.length) { showError('该布局里没有可显示的图元'); sel.value = 'model'; return } applyShapes(doc, shapes, bbox) $('modelInfo').textContent = `${shapes.length} 图元` } } /** 单位:DWG 的 $INSUNITS 常年是默认值,所以给用户留了手动覆盖 */ function setUnit(u, fromFile) { measure.unit = u $('optUnit').value = u $('optUnitSrc').textContent = fromFile ? '自动' : '手动' measure.refresh() renderStats() renderer.requestDraw() } function openFile(file) { const ext = extOf(file.name) if (ext !== 'dwg' && ext !== 'dxf') { showError('只支持 .dwg 和 .dxf 文件'); return } S.loadPath = null // 本地文件没有服务器路径,嵌入前要先上传 const fr = new FileReader() fr.onload = () => openBuffer(file.name, fr.result) fr.onerror = () => showError('文件读取失败') setLoading(true, '正在读取文件...') fr.readAsArrayBuffer(file) } async function openUrl(url, loadPath) { setLoading(true, '正在读取图纸...') try { const { buffer, name } = await fetchDrawing(url) S.loadPath = loadPath || url // 嵌入链接沿用真正打开的路径,别用裸文件名 await openBuffer(name || url.split('/').pop() || url, buffer) } catch (e) { setLoading(false) showError(`读取失败:${e.message}(${url})`) } } /** * 取图纸字节。 * * 站内路径优先走「令牌 → 解密接口」:服务端用 .htaccess 封掉了 .dwg/.dxf/.enc 的直接 GET, * 只有拿到 5 分钟有效的 HMAC 令牌才能通过 api/model.php 读到内容,挡住直链下载和爬取。 * 接口不存在时(比如本地 python 开发服务器没有 PHP)自动退回直连,不影响开发。 */ async function fetchDrawing(url) { if (!/^https?:\/\//i.test(url)) { const via = await tryTokenFetch(url) if (via) return via } const res = await fetch(url) if (!res.ok) throw new Error(`HTTP ${res.status}`) return { buffer: await res.arrayBuffer(), name: '' } } /** * 走令牌接口取图纸;接口没部署就返回 null 让调用方直连。 * * 判断「接口有没有部署」看的是响应内容而不是异常文本:本地开发用的 python * 静态服务器不会执行 PHP,会把 token.php 的源码当普通文件 200 发回来, * 只看状态码会误判成接口可用,然后 json() 直接炸掉。 */ async function tryTokenFetch(url) { let tok try { const tr = await fetch(`api/token.php?f=${encodeURIComponent(url)}`, { cache: 'no-store' }) if (!tr.ok) return null if (!/application\/json/i.test(tr.headers.get('content-type') || '')) return null tok = await tr.json() } catch { return null // 网络层失败:当作没部署 } if (!tok || !tok.token) return null // 到这里说明接口确实在跑,它再拒绝就该明确报错,不能悄悄退回直连 const mr = await fetch( `api/model.php?f=${encodeURIComponent(url)}&t=${tok.token}&e=${tok.expires}`, { cache: 'no-store' }) if (!mr.ok) { const j = await mr.json().catch(() => ({})) throw new Error(j.error || `读取接口返回 ${mr.status}`) } // 落盘名是随机的,真实文件名由接口通过头带回 const hdr = mr.headers.get('X-Filename') return { buffer: await mr.arrayBuffer(), name: hdr ? decodeURIComponent(hdr) : '' } } /** 把当前图纸上传到服务器(加密落盘 + 随机文件名),返回可用于嵌入的路径 */ async function uploadCurrent() { if (!S.buffer) throw new Error('还没有打开图纸') const res = await fetch('api/upload.php', { method: 'POST', headers: { 'X-Filename': encodeURIComponent(S.doc.name), 'Content-Type': 'application/octet-stream' }, body: S.buffer, }) const j = await res.json().catch(() => ({})) if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`) return j.path } /** * $INSUNITS → 单位标签。 * 只认公制的显式取值:0(未设)和 1(英寸)多半是 CAD 的默认值没改过, * 国内机械图实际都是毫米,直接按英寸显示会差 25.4 倍。用户可在设置里改。 */ function unitLabel(doc) { const u = doc.header && doc.header.INSUNITS return ({ 2: 'ft', 4: 'mm', 5: 'cm', 6: 'm', 13: 'µm' })[u] || 'mm' } // ---------------------------------------------------------------- 图层面板 function buildLayerPanel() { const box = $('tree') box.innerHTML = '' if (!S.doc) return const names = [...S.doc.layers.keys()].sort((a, b) => a.localeCompare(b, 'zh')) // 没有任何图元的图层排到后面 names.sort((a, b) => (S.layerStats.get(b) || 0) - (S.layerStats.get(a) || 0)) $('layerCount').textContent = `(${names.length})` for (const name of names) { const layer = S.doc.layers.get(name) const n = S.layerStats.get(name) || 0 const row = document.createElement('div') row.className = 'tree-row' row.dataset.layer = name const cb = document.createElement('input') cb.type = 'checkbox' cb.className = 'tree-check' cb.checked = !renderer.hidden.has(name) cb.onchange = () => { if (cb.checked) renderer.hidden.delete(name); else renderer.hidden.add(name) row.classList.toggle('off', !cb.checked) renderer.requestDraw() } const sw = document.createElement('span') sw.className = 'layer-swatch' sw.style.background = swatchColor(layer) const lb = document.createElement('span') lb.className = 'tree-label' lb.textContent = name lb.title = `${name}(${n} 个图元)` const num = document.createElement('span') num.className = 'tree-num' num.textContent = n || '' row.append(cb, sw, lb, num) // 点行(不含勾选框)= 只显示这一层 row.onclick = (e) => { if (e.target === cb) return isolateLayer(name) } box.appendChild(row) } if (!names.length) box.innerHTML = '
没有图层
' } function swatchColor(layer) { if (!layer) return '#000' let c = 0 if (typeof layer.trueColor === 'number') c = layer.trueColor else if (layer.colorIndex != null) { const idx = Math.abs(layer.colorIndex) c = idx === 7 ? 0x000000 : ACI_CACHE[idx] || 0x000000 } return `#${(c >>> 0).toString(16).padStart(6, '0')}` } const ACI_CACHE = ACI let isolated = null function isolateLayer(name) { if (isolated === name) { renderer.hidden.clear() isolated = null } else { renderer.hidden.clear() for (const k of S.doc.layers.keys()) if (k !== name) renderer.hidden.add(k) isolated = name } syncLayerChecks() renderer.requestDraw() setStatus(isolated ? `只显示图层「${name}」,再点一次恢复` : '已显示全部图层') } function syncLayerChecks() { for (const row of $('tree').children) { const cb = row.querySelector('input') if (!cb) continue cb.checked = !renderer.hidden.has(row.dataset.layer) row.classList.toggle('off', !cb.checked) } } $('treeSearch').addEventListener('input', () => { const q = $('treeSearch').value.trim().toLowerCase() for (const row of $('tree').children) { const name = (row.dataset.layer || '').toLowerCase() const hit = !q || name.includes(q) row.style.display = hit ? '' : 'none' row.classList.toggle('matched', !!q && hit) } }) $('btnLayerAll').onclick = () => { renderer.hidden.clear(); isolated = null; syncLayerChecks(); renderer.requestDraw() } $('btnLayerNone').onclick = () => { for (const k of S.doc ? S.doc.layers.keys() : []) renderer.hidden.add(k) isolated = null; syncLayerChecks(); renderer.requestDraw() } $('btnLayerInvert').onclick = () => { for (const k of S.doc ? S.doc.layers.keys() : []) { if (renderer.hidden.has(k)) renderer.hidden.delete(k); else renderer.hidden.add(k) } isolated = null; syncLayerChecks(); renderer.requestDraw() } // ---------------------------------------------------------------- 工具栏 function setTool(t) { S.tool = t for (const b of document.querySelectorAll('#toolbar .tool-btn[data-tool]')) { b.classList.toggle('active', b.dataset.tool === t) } const isMeasure = t === 'measure' const isAnno = t === 'anno' $('toolbar').classList.toggle('hidden', isMeasure || isAnno) $('measurePanel').classList.toggle('hidden', !isMeasure) $('annoPanel').classList.toggle('hidden', !isAnno) if (!isMeasure) measure.setMode(null) if (!isAnno) anno.setMode(null) wrap.classList.toggle('crosshair', isMeasure || isAnno || t === 'zoomBox') setStatus(TOOL_HINT[t] || '') renderer.requestDraw() } const TOOL_HINT = { select: '选择:点击图元查看属性 · 拖动平移 · 滚轮缩放 · 双击全图', measure: '测量:先选测量方式,再在图上点取(自动捕捉端点/中点/圆心)', anno: '批注:先选批注方式,在图上拖动绘制', zoomBox: '框选放大:按住左键拖出矩形', } for (const b of document.querySelectorAll('#toolbar .tool-btn[data-tool]')) { b.onclick = () => setTool(b.dataset.tool) } // 测量子面板 const mBox = $('measureModes') for (const m of MEASURE_MODES) { const b = document.createElement('button') b.className = 'measure-btn' b.title = m.name b.innerHTML = `${m.name}` b.onclick = () => { const on = measure.mode === m.id measure.setMode(on ? null : m.id) for (const x of mBox.children) x.classList.remove('active') if (!on) b.classList.add('active') setStatus(on ? '' : `${m.name}:在图上点取`) } mBox.appendChild(b) } $('btnMeasureClose').onclick = () => setTool('select') $('btnMeasureClear').onclick = () => measure.clear() $('btnStats').onclick = () => togglePanel('stats') // 批注子面板 const aBox = $('annoModes') for (const m of ANNO_MODES) { const b = document.createElement('button') b.className = 'measure-btn' b.title = m.name b.innerHTML = `${m.name}` b.onclick = () => { const on = anno.mode === m.id anno.setMode(on ? null : m.id) for (const x of aBox.children) x.classList.remove('active') if (!on) b.classList.add('active') } aBox.appendChild(b) } $('btnAnnoClose').onclick = () => setTool('select') $('btnAnnoClear').onclick = () => anno.clear() $('btnAnnoUndo').onclick = () => anno.undo() $('annoColor').oninput = (e) => { anno.color = e.target.value } $('annoWidth').onchange = (e) => { anno.width = +e.target.value } // 其它工具按钮 $('btnLayerPanel').onclick = (e) => { if (e) e.stopPropagation() // 别让「点击外部收起抽屉」把这次打开又关掉 if (window.innerWidth <= 768) { // 手机:图层面板是抽屉,点按钮开合(默认隐藏) document.body.classList.toggle('tree-open') } else { // 桌面:默认显示,点击隐藏/显示侧栏 document.body.classList.toggle('no-tree') } renderer.resize() } // 手机抽屉:✕ 按钮关闭 + 点击抽屉外部自动收起 const btnTreeClose = $('btnTreeClose') if (btnTreeClose) btnTreeClose.onclick = () => document.body.classList.remove('tree-open') document.addEventListener('click', (e) => { if (window.innerWidth > 768 || !document.body.classList.contains('tree-open')) return if (!$('treePanel').contains(e.target)) document.body.classList.remove('tree-open') }) $('btnFind').onclick = () => togglePanel('find') $('btnSettings').onclick = () => togglePanel('settings') $('btnInfo').onclick = () => togglePanel('info') $('btnReset').onclick = () => { setRotation(0); renderer.zoomExtents(); renderer.requestDraw() } $('btnLineWeight').onclick = () => { renderer.opts.lineWeight = !renderer.opts.lineWeight $('btnLineWeight').classList.toggle('active', renderer.opts.lineWeight) $('optLineWeight').checked = renderer.opts.lineWeight renderer.requestDraw() } $('btnSnapshot').onclick = snapshot $('btnPrint').onclick = printDrawing $('btnOpen').onclick = () => $('fileInput').click() $('fileInput').onchange = (e) => { if (e.target.files[0]) openFile(e.target.files[0]) } $('btnFullscreen').onclick = () => toggleFullscreen() $('btnEmbed').onclick = () => { togglePanel('embed'); updateEmbed() } for (const el of document.querySelectorAll('.panel-close')) { el.onclick = () => $(`${el.dataset.panel}Panel`).classList.add('hidden') } function togglePanel(name) { const p = $(`${name}Panel`) const willShow = p.classList.contains('hidden') for (const n of ['stats', 'find', 'settings', 'info', 'embed']) { if (n !== name) $(`${n}Panel`).classList.add('hidden') } p.classList.toggle('hidden', !willShow) if (willShow && name === 'find') $('findInput').focus() } // ---------------------------------------------------------------- 显示设置 // ---- 背景(浅灰 / 米白 / 黑,与新迪 2D 一致)---- const bgBox = $('bgSwatches') for (const b of BACKGROUNDS) { const el = document.createElement('button') el.className = 'bg-swatch' el.dataset.bg = b.id el.title = b.name el.style.background = b.color el.onclick = () => setBackground(b.id) bgBox.appendChild(el) } /** 背景偏好存本地,下次打开还是上次那个底 */ function setBackground(id) { const b = BACKGROUNDS.find((x) => x.id === id) || BACKGROUNDS[0] renderer.opts.bg = b.color for (const el of bgBox.children) el.classList.toggle('active', el.dataset.bg === b.id) $('btnBg').classList.toggle('active', renderer.isDarkBg) try { localStorage.setItem('dwgviewer.bg', b.id) } catch { /* 隐私模式下写不了,忽略 */ } renderer.requestDraw() } /** 工具栏那个按钮循环切三种底 */ $('btnBg').onclick = () => { const i = BACKGROUNDS.findIndex((b) => b.color === renderer.opts.bg) const next = BACKGROUNDS[(i + 1) % BACKGROUNDS.length] setBackground(next.id) setStatus(`背景:${next.name}`) } $('optContrast').onchange = (e) => { renderer.opts.autoContrast = e.target.checked; renderer.requestDraw() } // ---- 视图旋转 ---- function setRotation(rad, fromSlider) { renderer.setRotation(rad) const deg = Math.round((renderer.view.rot * 180) / Math.PI) if (!fromSlider) $('optRot').value = deg $('optRotVal').textContent = `${deg}°` renderer.requestDraw() } /** 转 90°:原本铺满视口的话转完继续铺满,已经放大过就保持倍数不动 */ function rotate90(dir) { const wasFitted = renderer.isFitted() setRotation(renderer.view.rot + dir * Math.PI / 2) if (wasFitted) renderer.zoomExtents() renderer.requestDraw() } $('btnRotL2').onclick = () => rotate90(1) $('btnRotR2').onclick = () => rotate90(-1) $('btnRot0').onclick = () => setRotation(0) $('optRot').oninput = (e) => setRotation((+e.target.value * Math.PI) / 180, true) // ---- 全屏(底部工具栏那个在嵌入模式下也能点到)---- function toggleFullscreen() { if (document.fullscreenElement) document.exitFullscreen() else document.documentElement.requestFullscreen() } $('btnFull').onclick = toggleFullscreen $('optText').onchange = (e) => { renderer.opts.showText = e.target.checked; renderer.requestDraw() } $('optHatch').onchange = (e) => { renderer.opts.showHatch = e.target.checked; renderer.requestDraw() } $('optLineWeight').onchange = (e) => { renderer.opts.lineWeight = e.target.checked $('btnLineWeight').classList.toggle('active', e.target.checked) renderer.requestDraw() } $('optLwScale').oninput = (e) => { renderer.opts.lineWeightScale = e.target.value / 100 $('optLwVal').textContent = `${(e.target.value / 100).toFixed(1)}x` if (renderer.opts.lineWeight) renderer.requestDraw() } $('optUnit').onchange = (e) => setUnit(e.target.value, false) $('optPrec').oninput = (e) => { measure.precision = +e.target.value $('optPrecVal').textContent = e.target.value measure.refresh() renderStats() renderer.requestDraw() } // ---------------------------------------------------------------- 鼠标交互 let drag = null let rubber = null // 框选/框放大的临时矩形(屏幕 CSS 像素) wrap.addEventListener('contextmenu', (e) => e.preventDefault()) wrap.addEventListener('pointerdown', (e) => { wrap.setPointerCapture(e.pointerId) const pos = local(e) // 中键/右键始终是平移;左键看当前工具 if (e.button === 1 || e.button === 2) { drag = { mode: 'pan', x: e.clientX, y: e.clientY } wrap.classList.add('panning') return } if (e.button !== 0) return if (S.tool === 'zoomBox') { rubber = { x0: pos.x, y0: pos.y, x1: pos.x, y1: pos.y, kind: 'zoom' }; return } if (S.tool === 'anno' && anno.mode) { if (anno.down(pos.x, pos.y)) { drag = { mode: 'anno' }; return } } if (S.tool === 'measure' && measure.mode) return // 测量在 pointerup 里按点击处理 drag = { mode: 'maybe-pan', x: e.clientX, y: e.clientY, sx: pos.x, sy: pos.y, moved: false } }) wrap.addEventListener('pointermove', (e) => { const pos = local(e) updateCoord(pos) if (rubber) { rubber.x1 = pos.x; rubber.y1 = pos.y; renderer.requestDraw(); return } if (drag && drag.mode === 'anno') { anno.move(pos.x, pos.y); renderer.requestDraw(); return } if (drag && (drag.mode === 'pan' || drag.mode === 'maybe-pan')) { const dx = e.clientX - drag.x, dy = e.clientY - drag.y if (drag.mode === 'maybe-pan' && Math.hypot(dx, dy) < 3) return if (drag.mode === 'maybe-pan') { drag.mode = 'pan'; drag.moved = true; wrap.classList.add('panning') } renderer.panByPixels(dx, dy) drag.x = e.clientX; drag.y = e.clientY renderer.requestDraw() return } if (S.tool === 'measure' && measure.mode) { measure.hover(pos.x, pos.y); renderer.requestDraw(); return } if (S.tool === 'select') { const idx = renderer.pick(pos.x, pos.y, 6) if (idx !== renderer.highlight) { renderer.highlight = idx; renderer.requestDraw() } } }) wrap.addEventListener('pointerup', (e) => { const pos = local(e) wrap.classList.remove('panning') try { wrap.releasePointerCapture(e.pointerId) } catch { /* 指针已释放 */ } if (rubber) { const r = rubber; rubber = null if (Math.abs(r.x1 - r.x0) > 6 && Math.abs(r.y1 - r.y0) > 6) { const a = renderer.toWorld(r.x0, r.y0), b = renderer.toWorld(r.x1, r.y1) renderer.zoomToBox([Math.min(a.x, b.x), Math.min(a.y, b.y), Math.max(a.x, b.x), Math.max(a.y, b.y)]) setTool('select') } renderer.requestDraw() return } if (drag && drag.mode === 'anno') { anno.up(); drag = null; return } const wasPan = drag && drag.mode === 'pan' const wasClick = drag && drag.mode === 'maybe-pan' drag = null if (wasPan) return if (e.button !== 0) return if (S.tool === 'measure' && measure.mode) { measure.click(pos.x, pos.y); renderer.requestDraw(); return } if (S.tool === 'select' && wasClick) selectAt(pos) }) wrap.addEventListener('dblclick', () => { if (S.tool === 'measure' && measure.mode) { measure.finish(); return } renderer.zoomExtents() renderer.requestDraw() }) wrap.addEventListener('wheel', (e) => { e.preventDefault() const pos = local(e) const f = e.deltaY < 0 ? 1.18 : 1 / 1.18 renderer.zoomAt(pos.x, pos.y, f) renderer.requestDraw() updateCoord(pos) }, { passive: false }) function local(e) { const r = wrap.getBoundingClientRect() return { x: e.clientX - r.left, y: e.clientY - r.top } } function updateCoord(pos) { const w = renderer.toWorld(pos.x, pos.y) $('coordText').textContent = S.doc ? `X ${w.x.toFixed(2)} Y ${w.y.toFixed(2)} 1:${(1 / renderer.view.scale * renderer.dpr).toFixed(2)}` : '' } function selectAt(pos) { const idx = renderer.pick(pos.x, pos.y, 6) renderer.selection.clear() if (idx != null) { renderer.selection.add(idx) showProps(renderer.shapes[idx]) } else { $('propPanel').classList.add('hidden') } renderer.requestDraw() } function drawRubber(ctx, r) { if (!rubber) return const d = r.dpr ctx.save() // 框选矩形是屏幕上的物件,不跟着图纸转,所以撤掉 overlay 的视图旋转 ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.strokeStyle = '#2b9ae8' ctx.fillStyle = 'rgba(43,154,232,.12)' ctx.lineWidth = 1.5 * d ctx.setLineDash([5 * d, 4 * d]) const x = Math.min(rubber.x0, rubber.x1) * d, y = Math.min(rubber.y0, rubber.y1) * d const w = Math.abs(rubber.x1 - rubber.x0) * d, h = Math.abs(rubber.y1 - rubber.y0) * d ctx.fillRect(x, y, w, h) ctx.strokeRect(x, y, w, h) ctx.restore() } document.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return if (e.key === 'Escape') { measure.cancel(); anno.setMode(anno.mode); rubber = null renderer.selection.clear(); $('propPanel').classList.add('hidden') renderer.requestDraw() } else if (e.key === 'Enter') { measure.finish(); renderer.requestDraw() } else if (e.key === 'e' || e.key === 'E') { renderer.zoomExtents(); renderer.requestDraw() } }) // ---------------------------------------------------------------- 面板内容 function renderStats() { const box = $('statsList') box.innerHTML = '' if (!measure.results.length) { box.innerHTML = '
暂无测量结果
'; return } measure.results.forEach((r, i) => { const row = document.createElement('div') row.className = 'stats-row' row.innerHTML = `${r.type}${r.text}` const del = document.createElement('span') del.className = 'stats-del' del.textContent = '×' del.onclick = () => measure.remove(i) row.appendChild(del) box.appendChild(row) }) } function renderInfo(stats, ms) { const d = S.doc const b = renderer.bbox const kv = (k, v) => `
${k}${v}
` const types = Object.entries(stats.byType).sort((a, b2) => b2[1] - a[1]) .map(([k, v]) => `${k} ${v}`).join('、') $('infoBody').innerHTML = kv('文件', d.name) + kv('格式', d.format) + kv('图层', d.layers.size) + kv('块', d.blocks.size) + kv('实体', stats.entities) + kv('图元', S.shapes.length) + kv('范围', `${(b[2] - b[0]).toFixed(1)} × ${(b[3] - b[1]).toFixed(1)} ${measure.unit}`) + kv('解析耗时', `${ms.toFixed(0)} ms`) + kv('实体分布', types || '—') + `
解析内核:${d.format === 'DWG' ? 'LibreDWG (WebAssembly)' : 'dxf-parser'} 许可证 ${d.license}。 ${d.format === 'DWG' ? 'GPL-3.0 会传染到分发的前端代码,商用前请评估,或把 DWG 解析换成服务端/商业 SDK。' : ''}
` } function showProps(s) { if (!s) return const e = s.ent || {} const kv = (k, v) => (v == null || v === '' ? '' : `
${k}${v}
`) const b = s.bbox $('propBody').innerHTML = kv('类型', e.type || s.kind) + kv('图层', s.layer) + kv('句柄', e.handle) + kv('颜色', `#${(s.color >>> 0).toString(16).padStart(6, '0')}`) + kv('线型', s.lt || 'Continuous') + kv('线宽', s.lw >= 0 ? `${(s.lw / 100).toFixed(2)} mm` : '随层') + kv('文字', s.plain ? s.plain.slice(0, 200) : '') + kv('半径', s.kind === 'arc' ? s.rx.toFixed(3) : '') + kv('包围盒', `${(b[2] - b[0]).toFixed(2)} × ${(b[3] - b[1]).toFixed(2)}`) $('propPanel').classList.remove('hidden') } // ---------------------------------------------------------------- 文字查找 $('findInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') doFind() }) function doFind() { const q = $('findInput').value.trim().toLowerCase() const box = $('findResult') box.innerHTML = '' if (!q) { box.innerHTML = '
输入关键字后回车
'; return } const hits = [] for (const s of S.shapes) { if (!s.plain) continue if (s.plain.toLowerCase().includes(q)) hits.push(s) if (hits.length >= 300) break } if (!hits.length) { box.innerHTML = '
没有匹配的文字
'; return } for (const s of hits) { const row = document.createElement('div') row.className = 'find-row' row.innerHTML = `${escapeHtml(s.plain.slice(0, 60))}${escapeHtml(s.layer)}` row.onclick = () => { const b = s.bbox const pad = Math.max((b[2] - b[0]), (b[3] - b[1])) * 6 + 1 renderer.zoomToBox([b[0] - pad, b[1] - pad, b[2] + pad, b[3] + pad]) renderer.selection.clear() renderer.selection.add(s.i) renderer.requestDraw() } box.appendChild(row) } setStatus(`找到 ${hits.length} 处文字`) } const escapeHtml = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) // ---------------------------------------------------------------- 导出 function snapshot() { renderer.draw() renderer.canvas.toBlob((blob) => { const a = document.createElement('a') a.href = URL.createObjectURL(blob) a.download = `${(S.doc && S.doc.name || 'drawing').replace(/\.[^.]+$/, '')}.png` a.click() setTimeout(() => URL.revokeObjectURL(a.href), 5000) }, 'image/png') } function printDrawing() { if (!S.doc) return renderer.draw() const url = renderer.canvas.toDataURL('image/png') const w = window.open('', '_blank') if (!w) { showError('浏览器拦截了打印窗口,请允许弹出窗口'); return } w.document.write(`${escapeHtml(S.doc.name)} `) w.document.close() } // ---------------------------------------------------------------- 嵌入 function updateEmbed() { // 默认沿用图纸真正打开时的路径(相对查看器目录),避免生成打不开的裸文件名链接; // 本地拖进来的图纸没有服务器路径,得先「上传当前图纸」。 // 用 ?file= 打开时把真实路径回填到输入框(与 STEPViewer 行为一致), // 否则「图纸路径」框空着、只有地址生成,容易误以为坏了。 if (S.loadPath && !$('embedPath').value.trim()) $('embedPath').value = S.loadPath const path = $('embedPath').value.trim() const base = location.origin + location.pathname if (!path) { $('embedUrl').value = '' $('embedCode').value = '' return } const url = `${base}?file=${encodeURIComponent(path)}&embed=1` $('embedUrl').value = url $('embedCode').value = `` } $('embedPath').addEventListener('input', updateEmbed) $('btnUpload').onclick = async () => { const btn = $('btnUpload') btn.disabled = true btn.textContent = '上传中...' try { const path = await uploadCurrent() if (!path) throw new Error('上传接口返回异常(接口未部署)') $('embedPath').value = path updateEmbed() setStatus(`已加密上传:${path}`) // 嵌在入口页里时通知它刷新首页数模库 try { window.parent.postMessage({ type: 'library-updated' }, '*') } catch { /* 独立打开 */ } } catch (e) { showError(`上传失败:${e.message}(本地开发服务器没有 PHP,需要在正式站点上试)`) } finally { btn.disabled = false btn.textContent = '上传当前图纸' } } $('btnCopyUrl').onclick = () => copy($('embedUrl').value) $('btnCopyCode').onclick = () => copy($('embedCode').value) function copy(t) { navigator.clipboard.writeText(t).then(() => setStatus('已复制到剪贴板'), () => setStatus('复制失败,请手动选择')) } // ---------------------------------------------------------------- 杂项 function setStatus(t) { $('statusText').textContent = t || TOOL_HINT[S.tool] || '就绪' } new ResizeObserver(() => renderer.resize()).observe(wrap) // 拖拽打开 let dragDepth = 0 document.addEventListener('dragenter', (e) => { e.preventDefault(); dragDepth++; $('dropOverlay').classList.remove('hidden') }) document.addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; $('dropOverlay').classList.add('hidden') } }) document.addEventListener('dragover', (e) => e.preventDefault()) document.addEventListener('drop', (e) => { e.preventDefault() dragDepth = 0 $('dropOverlay').classList.add('hidden') const f = e.dataTransfer.files[0] if (f) openFile(f) }) // 调试用:控制台里可以直接摸到渲染器、文档和工具 window.__R = renderer window.__S = S window.__ANNO = anno window.__MEASURE = measure // 恢复上次的背景偏好;嵌入时可用 ?bg=black|beige|grey 直接指定 let savedBg = BACKGROUNDS[0].id try { savedBg = params.get('bg') || localStorage.getItem('dwgviewer.bg') || savedBg } catch { /* 隐私模式读不到 */ } setBackground(savedBg === 'dark' ? 'black' : savedBg) // 先画一次底色,否则 alpha:false 的画布初始是黑的 renderer.draw() setStatus() // 统一入口(../index.html)在 iframe 里把用户选的图纸传进来 window.addEventListener('message', (e) => { const data = e.data if (!data || data.type !== 'viewer-file' || data.kind !== 'dwg') return try { if (e.origin !== location.origin) return } catch { return } openBuffer(data.name || 'drawing.dwg', data.buffer) }) try { window.parent.postMessage({ type: 'viewer-ready' }, '*') } catch { /* 没嵌在入口里 */ } // URL 参数直接打开 const fileParam = params.get('file') if (fileParam) openUrl(fileParam, fileParam)