Files
Leantime/dev/DWGViewer/app.js

894 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = '<div class="tree-empty">没有图层</div>'
}
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 = `<img src="./icons/${m.icon}" alt=""><span>${m.name}</span>`
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 = `<img src="./icons/${m.icon}" alt=""><span>${m.name}</span>`
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 = '<div class="stats-empty">暂无测量结果</div>'; return }
measure.results.forEach((r, i) => {
const row = document.createElement('div')
row.className = 'stats-row'
row.innerHTML = `<span class="stats-type">${r.type}</span><span class="stats-value" title="${r.text}">${r.text}</span>`
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) => `<div class="kv"><b>${k}</b><span>${v}</span></div>`
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 || '—') +
`<div class="lic">解析内核:${d.format === 'DWG' ? 'LibreDWG (WebAssembly)' : 'dxf-parser'} 许可证 ${d.license}
${d.format === 'DWG' ? 'GPL-3.0 会传染到分发的前端代码,商用前请评估,或把 DWG 解析换成服务端/商业 SDK。' : ''}</div>`
}
function showProps(s) {
if (!s) return
const e = s.ent || {}
const kv = (k, v) => (v == null || v === '' ? '' : `<div class="kv"><b>${k}</b><span>${v}</span></div>`)
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 = '<div class="stats-empty">输入关键字后回车</div>'; 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 = '<div class="stats-empty">没有匹配的文字</div>'; return }
for (const s of hits) {
const row = document.createElement('div')
row.className = 'find-row'
row.innerHTML = `${escapeHtml(s.plain.slice(0, 60))}<span class="find-layer">${escapeHtml(s.layer)}</span>`
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>${escapeHtml(S.doc.name)}</title>
<style>@page{size:auto;margin:8mm}body{margin:0}img{width:100%}</style></head>
<body><img src="${url}" onload="window.focus();window.print();"></body></html>`)
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 = `<iframe src="${url}" width="100%" height="640" frameborder="0" allowfullscreen></iframe>`
}
$('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)