894 lines
33 KiB
JavaScript
894 lines
33 KiB
JavaScript
/**
|
||
* 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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)
|