OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
15
dev/DWGViewer/.htaccess
Normal file
@@ -0,0 +1,15 @@
|
||||
# WebAssembly 必须带正确的 MIME,否则 instantiateStreaming 会失败
|
||||
AddType application/wasm .wasm
|
||||
AddType text/javascript .js .mjs
|
||||
|
||||
# wasm/图标体积大,允许长缓存(升级时改文件名或加 ?v=)
|
||||
<FilesMatch "\.(wasm|css|png|ico)$">
|
||||
Header set Cache-Control "public, max-age=2592000"
|
||||
</FilesMatch>
|
||||
|
||||
# 防下载:图纸源文件一律不允许直接 GET,只能走 api/model.php(校验短时令牌 + 解密)。
|
||||
# 查看器已经会自动走令牌流程,所以封掉不影响正常使用。
|
||||
# 如果你希望图纸可以直链下载,把下面这段删掉即可。
|
||||
<FilesMatch "\.(dwg|dxf|enc|DWG|DXF)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
4
dev/DWGViewer/api/.htaccess
Normal file
@@ -0,0 +1,4 @@
|
||||
# 禁止直接访问图纸文件(双保险:PHP 接口已校验令牌)
|
||||
<FilesMatch "\.(dwg|dxf|enc)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
80
dev/DWGViewer/api/model.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
/**
|
||||
* DWG/DXF 查看器 - 图纸读取接口
|
||||
* 校验令牌 → .enc 解密 / 原文件原样 → 流式返回。
|
||||
* SECRET 必须与 token.php、upload.php 一致。
|
||||
*/
|
||||
$SECRET = 'be323d1b90bd2fda624083d4d1d716b7';
|
||||
// 与 upload.php 保持一致的私有目录配置:图纸放 Web 目录外,web 里根本没有文件可下载
|
||||
$PRIVATE_DIR = '/volume1/dwgviewer_private';
|
||||
|
||||
function json_out($code, $obj) {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode($obj, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$f = $_GET['f'] ?? '';
|
||||
$t = $_GET['t'] ?? '';
|
||||
$e = (int)($_GET['e'] ?? 0);
|
||||
if (!$f || !$t || !preg_match('/\.(dwg|dxf|enc)$/i', $f)) json_out(400, ['error' => 'bad params']);
|
||||
if (strpos($f, '..') !== false) json_out(400, ['error' => 'bad path']);
|
||||
|
||||
$sig = substr(hash_hmac('sha256', $f . '|' . $e, $SECRET), 0, 16);
|
||||
if (!hash_equals($sig, $t) || time() > $e) json_out(403, ['error' => 'invalid or expired token']);
|
||||
|
||||
$root = realpath(__DIR__ . '/..');
|
||||
if ($PRIVATE_DIR !== '') {
|
||||
// 私有目录优先(嵌入链接写纯文件名即可);目录不存在时退回站内路径,
|
||||
// 让留在 web 目录里的样例图纸按原路径继续可用(这些文件本来就允许直链,
|
||||
// 退回不新增暴露面)。要保护的图纸必须移出 web 目录放进私有目录。
|
||||
$full = realpath($PRIVATE_DIR . '/' . basename($f));
|
||||
$ok = $full && strpos($full, realpath($PRIVATE_DIR)) === 0 && is_file($full);
|
||||
if (!$ok) {
|
||||
$full = realpath($root . '/' . $f);
|
||||
$ok = $full && strpos($full, $root) === 0 && is_file($full);
|
||||
}
|
||||
} else {
|
||||
$full = realpath($root . '/' . $f);
|
||||
$ok = $full && strpos($full, $root) === 0 && is_file($full);
|
||||
}
|
||||
if (!$ok) json_out(404, ['error' => 'not found']);
|
||||
|
||||
$data = file_get_contents($full);
|
||||
$origName = basename($f);
|
||||
if (preg_match('/\.enc$/i', $f)) {
|
||||
$nonce = substr($data, 0, 16);
|
||||
$key = hash('sha256', $SECRET . $nonce, true);
|
||||
$body = substr($data, 16);
|
||||
$out = '';
|
||||
for ($i = 0; $i < strlen($body); $i++) {
|
||||
$out .= $body[$i] ^ $key[$i % strlen($key)];
|
||||
}
|
||||
// 加密体头部藏着原文件名(落盘名是随机的,链接里看不出是哪张图)
|
||||
if (strlen($out) > 2) {
|
||||
$nl = unpack('n', substr($out, 0, 2))[1];
|
||||
if ($nl > 0 && $nl <= 255 && strlen($out) > 2 + $nl) {
|
||||
$origName = substr($out, 2, $nl);
|
||||
$out = substr($out, 2 + $nl);
|
||||
}
|
||||
}
|
||||
$data = $out;
|
||||
}
|
||||
header('X-Filename: ' . rawurlencode($origName));
|
||||
header('Access-Control-Expose-Headers: X-Filename');
|
||||
|
||||
// 上传时已经 gzip 过,解密后就是 gzip 流,直接标 Content-Encoding 让浏览器解;
|
||||
// 明文文件(如 dxf)在线压一次,DXF 文本能压到 1/8 左右
|
||||
$send = $data;
|
||||
if (substr($send, 0, 2) === "\x1f\x8b") {
|
||||
header('Content-Encoding: gzip');
|
||||
} elseif (!ini_get('zlib.output_compression') && strlen($data) > 1024) {
|
||||
$gz = gzencode($data, 6);
|
||||
if ($gz !== false) { $send = $gz; header('Content-Encoding: gzip'); }
|
||||
}
|
||||
header('Content-Type: application/octet-stream');
|
||||
header('Content-Length: ' . strlen($send));
|
||||
header('Cache-Control: no-store');
|
||||
header('Content-Disposition: inline');
|
||||
echo $send;
|
||||
38
dev/DWGViewer/api/token.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
/**
|
||||
* DWG/DXF 查看器 - 令牌接口
|
||||
* 同源校验 + HMAC 短时令牌(5 分钟)。与 STEPViewer 的 api/token.php 同一套设计。
|
||||
* 部署前务必改掉 SECRET。
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
$SECRET = 'be323d1b90bd2fda624083d4d1d716b7';
|
||||
$TTL = 300;
|
||||
|
||||
function json_out($code, $obj) {
|
||||
http_response_code($code);
|
||||
echo json_encode($obj, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$f = $_GET['f'] ?? '';
|
||||
if (!$f || !preg_match('/\.(dwg|dxf|enc)$/i', $f)) json_out(400, ['error' => 'bad file']);
|
||||
if (strpos($f, '..') !== false) json_out(400, ['error' => 'bad path']);
|
||||
|
||||
// 只允许本站页面来取令牌,挡掉直接爬取。
|
||||
// 优先看 Sec-Fetch-Site:由查看器页面发起的同源 fetch 恒为 same-origin(跨站 iframe
|
||||
// 嵌入也不受影响,且不会被父站点的 Referrer-Policy 剥掉);没有该头的旧浏览器
|
||||
// 退回「Referer 含本站 host」的校验。直链/curl 两种都过不了。
|
||||
$fetchSite = $_SERVER['HTTP_SEC_FETCH_SITE'] ?? '';
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
if ($fetchSite !== '') {
|
||||
if ($fetchSite !== 'same-origin' && $fetchSite !== 'same-site') json_out(403, ['error' => 'forbidden']);
|
||||
} elseif (!$host || strpos($referer, $host) === false) {
|
||||
json_out(403, ['error' => 'forbidden']);
|
||||
}
|
||||
|
||||
$exp = time() + $TTL;
|
||||
$sig = substr(hash_hmac('sha256', $f . '|' . $exp, $SECRET), 0, 16);
|
||||
json_out(200, ['token' => $sig, 'expires' => $exp]);
|
||||
109
dev/DWGViewer/api/upload.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
* DWG/DXF 查看器 - 上传接口
|
||||
* 接收原始字节流 + X-Filename 头,gzip 后异或加密存成 uploads/xxx.enc。
|
||||
* 落盘就是密文,即使目录被翻到也拿不到可用的图纸。
|
||||
* SECRET 必须与 token.php、model.php 一致。
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$SECRET = 'be323d1b90bd2fda624083d4d1d716b7';
|
||||
$MAX_SIZE = 200 * 1024 * 1024;
|
||||
// 私有目录:Web 目录外的绝对路径,图纸只存这里,web 目录里根本没有图纸文件可下载。
|
||||
// 群晖 File Station 在 web 目录外建 /volume1/dwgviewer_private 并给 http 用户组读写权限;
|
||||
// 路径不同就改成你的实际路径。
|
||||
$PRIVATE_DIR = '/volume1/dwgviewer_private';
|
||||
|
||||
function json_out($code, $obj) {
|
||||
http_response_code($code);
|
||||
echo json_encode($obj, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') json_out(405, ['error' => 'method not allowed']);
|
||||
|
||||
// 只允许本站页面发起上传(与 token.php 同一策略:Sec-Fetch-Site 优先、Referer 兜底,
|
||||
// 父站点的 Referrer-Policy 剥不掉 Sec-Fetch-Site,跨站 iframe 嵌入也能上传)
|
||||
$fetchSite = $_SERVER['HTTP_SEC_FETCH_SITE'] ?? '';
|
||||
$referer = $_SERVER['HTTP_REFERER'] ?? '';
|
||||
$host = $_SERVER['HTTP_HOST'] ?? '';
|
||||
if ($fetchSite !== '') {
|
||||
if ($fetchSite !== 'same-origin' && $fetchSite !== 'same-site') json_out(403, ['error' => 'forbidden']);
|
||||
} elseif (!$host || strpos($referer, $host) === false) {
|
||||
json_out(403, ['error' => 'forbidden']);
|
||||
}
|
||||
|
||||
$len = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
|
||||
if ($len <= 0) json_out(400, ['error' => 'empty body']);
|
||||
if ($len > $MAX_SIZE) json_out(413, ['error' => 'file too large (max 200MB)']);
|
||||
|
||||
$raw = urldecode($_SERVER['HTTP_X_FILENAME'] ?? 'drawing.dwg');
|
||||
$base = basename($raw);
|
||||
$safe = preg_replace('/[^\w.\-\x{4e00}-\x{9fff}]/u', '_', $base);
|
||||
if (!$safe || !preg_match('/\.(dwg|dxf)$/i', $safe)) json_out(400, ['error' => '只支持 .dwg / .dxf']);
|
||||
if (strlen($safe) > 200) $safe = substr($safe, 0, 200);
|
||||
|
||||
// 落盘名是纯随机的,不带原文件名也不带真实后缀:
|
||||
// 嵌入链接里就看不出这是哪张图、属于哪个项目,也没法按名字猜其它图纸。
|
||||
// 原文件名放进加密体里,读取时由 model.php 通过 X-Filename 头带回来显示。
|
||||
$name = bin2hex(random_bytes(12)) . '.enc';
|
||||
|
||||
$data = file_get_contents('php://input');
|
||||
if ($data === false || strlen($data) !== $len) json_out(400, ['error' => 'incomplete upload']);
|
||||
|
||||
// 存储层压缩:DWG 本身已压过,DXF 文本能压到 1/8
|
||||
$rawSize = strlen($data);
|
||||
$data = gzencode($data, 6);
|
||||
if ($data === false) json_out(500, ['error' => 'gzip failed']);
|
||||
// 加密体 = 2 字节名字长度 + 原文件名 + gzip 数据
|
||||
$data = pack('n', strlen($safe)) . $safe . $data;
|
||||
|
||||
$dir = $PRIVATE_DIR !== '' ? $PRIVATE_DIR : (__DIR__ . '/../uploads');
|
||||
if (!is_dir($dir)) @mkdir($dir, 0755, true);
|
||||
if (!is_dir($dir)) json_out(500, ['error' => 'upload dir not writable']);
|
||||
|
||||
$nonce = random_bytes(16);
|
||||
$key = hash('sha256', $SECRET . $nonce, true);
|
||||
$enc = '';
|
||||
for ($i = 0; $i < strlen($data); $i++) {
|
||||
$enc .= $data[$i] ^ $key[$i % strlen($key)];
|
||||
}
|
||||
|
||||
if (file_put_contents($dir . '/' . $name, $nonce . $enc) === false) {
|
||||
json_out(500, ['error' => 'write failed']);
|
||||
}
|
||||
// 私有目录模式下返回裸文件名:嵌入链接就是 ?file=xxx.enc,读取端按 basename 在私有目录找
|
||||
$entryPath = ($PRIVATE_DIR !== '' ? '' : 'uploads/') . $name;
|
||||
|
||||
// 记入首页数模库(入口目录的 library.json,2D/3D 共享);库写失败不影响上传本身
|
||||
try {
|
||||
add_to_library($entryPath, $safe, '2d');
|
||||
} catch (Throwable $e) { /* 忽略 */ }
|
||||
|
||||
json_out(200, ['path' => $entryPath, 'name' => $safe, 'size' => $rawSize]);
|
||||
|
||||
/**
|
||||
* 把刚上传的文件追加进首页数模库(../../library.json):
|
||||
* 同路径已有记录就刷新名称和时间,否则插到最前。
|
||||
*/
|
||||
function add_to_library($path, $name, $type) {
|
||||
$libFile = dirname(__DIR__, 2) . '/library.json';
|
||||
$records = [];
|
||||
if (is_file($libFile)) {
|
||||
$old = json_decode((string)file_get_contents($libFile), true);
|
||||
if (is_array($old) && isset($old['records']) && is_array($old['records'])) $records = $old['records'];
|
||||
}
|
||||
$found = false;
|
||||
foreach ($records as $i => $rec) {
|
||||
if (isset($rec['path']) && $rec['path'] === $path) {
|
||||
$records[$i]['name'] = $name;
|
||||
$records[$i]['time'] = time();
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$found) {
|
||||
array_unshift($records, ['path' => $path, 'name' => $name, 'type' => $type, 'note' => '', 'time' => time()]);
|
||||
}
|
||||
@file_put_contents($libFile, json_encode(['records' => $records], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||
}
|
||||
893
dev/DWGViewer/app.js
Normal file
@@ -0,0 +1,893 @@
|
||||
/**
|
||||
* 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)
|
||||
BIN
dev/DWGViewer/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
dev/DWGViewer/icons/all-screen.png
Normal file
|
After Width: | Height: | Size: 914 B |
BIN
dev/DWGViewer/icons/anno-close-ico.eb5648a3.png
Normal file
|
After Width: | Height: | Size: 286 B |
BIN
dev/DWGViewer/icons/anno-delete-ico.5dc4802b.png
Normal file
|
After Width: | Height: | Size: 368 B |
BIN
dev/DWGViewer/icons/anno-edit-ico.da564943.png
Normal file
|
After Width: | Height: | Size: 339 B |
BIN
dev/DWGViewer/icons/area-ico-s.778928d9.png
Normal file
|
After Width: | Height: | Size: 742 B |
BIN
dev/DWGViewer/icons/area-ico.de59f0f7.png
Normal file
|
After Width: | Height: | Size: 835 B |
BIN
dev/DWGViewer/icons/arrow-down.b6bca21e.png
Normal file
|
After Width: | Height: | Size: 295 B |
BIN
dev/DWGViewer/icons/arrow_icon.30b57c61.png
Normal file
|
After Width: | Height: | Size: 340 B |
BIN
dev/DWGViewer/icons/close-icon.cb122c82.png
Normal file
|
After Width: | Height: | Size: 242 B |
BIN
dev/DWGViewer/icons/close.0fb8dc6e.png
Normal file
|
After Width: | Height: | Size: 214 B |
BIN
dev/DWGViewer/icons/color.ad185a94.png
Normal file
|
After Width: | Height: | Size: 726 B |
BIN
dev/DWGViewer/icons/hemi.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
dev/DWGViewer/icons/ico-add-modelview.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
dev/DWGViewer/icons/ico-compare-paper-add.c996d2e0.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
dev/DWGViewer/icons/ico-compare-paper.14843256.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
dev/DWGViewer/icons/ico-font-bold.0f25103f.png
Normal file
|
After Width: | Height: | Size: 690 B |
BIN
dev/DWGViewer/icons/ico-font-italic.2905c201.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
dev/DWGViewer/icons/ico-radio-noselect.b46355af.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
dev/DWGViewer/icons/ico-radio-select.3bcbeffc.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
dev/DWGViewer/icons/ico-search.33e9e6a1.png
Normal file
|
After Width: | Height: | Size: 376 B |
BIN
dev/DWGViewer/icons/ico-set-color.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
dev/DWGViewer/icons/ico-set-hideMark.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
dev/DWGViewer/icons/ico-tree-check.7c4874a5.png
Normal file
|
After Width: | Height: | Size: 242 B |
BIN
dev/DWGViewer/icons/ico-tree-checked.905447ca.png
Normal file
|
After Width: | Height: | Size: 359 B |
BIN
dev/DWGViewer/icons/ico-tree-checking.bd34b24b.png
Normal file
|
After Width: | Height: | Size: 322 B |
BIN
dev/DWGViewer/icons/ico-voice-message-play.1c474ffb.gif
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
dev/DWGViewer/icons/ico-voice-message-white.3973a210.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
dev/DWGViewer/icons/icon-delete.7789869f.png
Normal file
|
After Width: | Height: | Size: 465 B |
BIN
dev/DWGViewer/icons/icon-edit.7b8623fb.png
Normal file
|
After Width: | Height: | Size: 427 B |
BIN
dev/DWGViewer/icons/icon-radio-n.4f7c47c7.png
Normal file
|
After Width: | Height: | Size: 197 B |
BIN
dev/DWGViewer/icons/icon-radio-s.73302fd7.png
Normal file
|
After Width: | Height: | Size: 245 B |
BIN
dev/DWGViewer/icons/lamp.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
dev/DWGViewer/icons/leftRotation.4801be73.png
Normal file
|
After Width: | Height: | Size: 821 B |
BIN
dev/DWGViewer/icons/line.de196a65.png
Normal file
|
After Width: | Height: | Size: 218 B |
BIN
dev/DWGViewer/icons/m-color.dd5de7f5.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
dev/DWGViewer/icons/m-lineWidth.f2009ee0.png
Normal file
|
After Width: | Height: | Size: 197 B |
BIN
dev/DWGViewer/icons/m-lineWidth1-s.e7866918.png
Normal file
|
After Width: | Height: | Size: 470 B |
BIN
dev/DWGViewer/icons/m-lineWidth2.7810074f.png
Normal file
|
After Width: | Height: | Size: 156 B |
BIN
dev/DWGViewer/icons/m-lineWidth3.06b1963a.png
Normal file
|
After Width: | Height: | Size: 160 B |
BIN
dev/DWGViewer/icons/measure-s-close.54f7147e.png
Normal file
|
After Width: | Height: | Size: 412 B |
BIN
dev/DWGViewer/icons/no-pic.f5cca8ad.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
dev/DWGViewer/icons/one-click-color.0a5c28c0.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
dev/DWGViewer/icons/pen.16c2a6dd.png
Normal file
|
After Width: | Height: | Size: 542 B |
BIN
dev/DWGViewer/icons/pic-default.1390d7c7.png
Normal file
|
After Width: | Height: | Size: 950 B |
BIN
dev/DWGViewer/icons/radio-check.818b2d54.png
Normal file
|
After Width: | Height: | Size: 415 B |
BIN
dev/DWGViewer/icons/radio-nocheck.07a1bc71.png
Normal file
|
After Width: | Height: | Size: 328 B |
BIN
dev/DWGViewer/icons/refreshScale.6e4404e3.png
Normal file
|
After Width: | Height: | Size: 627 B |
BIN
dev/DWGViewer/icons/return-icon.1cb36f98.png
Normal file
|
After Width: | Height: | Size: 332 B |
BIN
dev/DWGViewer/icons/rightRotation.d4d1fd16.png
Normal file
|
After Width: | Height: | Size: 841 B |
BIN
dev/DWGViewer/icons/spotlight.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
dev/DWGViewer/icons/sun.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
dev/DWGViewer/icons/tree-Part.e3fb53a4.png
Normal file
|
After Width: | Height: | Size: 643 B |
BIN
dev/DWGViewer/icons/tree-node-close.9d625f70.png
Normal file
|
After Width: | Height: | Size: 273 B |
BIN
dev/DWGViewer/icons/tree-node-open.f45d6681.png
Normal file
|
After Width: | Height: | Size: 298 B |
BIN
dev/DWGViewer/icons/tree-root.48094390.png
Normal file
|
After Width: | Height: | Size: 934 B |
BIN
dev/DWGViewer/icons/view-tool-Area.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
dev/DWGViewer/icons/view-tool-LinearMeasurement.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
dev/DWGViewer/icons/view-tool-angel.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
dev/DWGViewer/icons/view-tool-annoArrow.png
Normal file
|
After Width: | Height: | Size: 944 B |
BIN
dev/DWGViewer/icons/view-tool-annoLine.png
Normal file
|
After Width: | Height: | Size: 847 B |
BIN
dev/DWGViewer/icons/view-tool-annoText.png
Normal file
|
After Width: | Height: | Size: 926 B |
BIN
dev/DWGViewer/icons/view-tool-arcLength.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
dev/DWGViewer/icons/view-tool-attr.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
dev/DWGViewer/icons/view-tool-boundBox.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
dev/DWGViewer/icons/view-tool-centreOfCircle.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
dev/DWGViewer/icons/view-tool-circleAnno.png
Normal file
|
After Width: | Height: | Size: 968 B |
BIN
dev/DWGViewer/icons/view-tool-clear.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
dev/DWGViewer/icons/view-tool-cloudLine.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
dev/DWGViewer/icons/view-tool-comment.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
dev/DWGViewer/icons/view-tool-conceal.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
dev/DWGViewer/icons/view-tool-continuityLength.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
dev/DWGViewer/icons/view-tool-coordinate.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
dev/DWGViewer/icons/view-tool-distance.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
dev/DWGViewer/icons/view-tool-facades.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
dev/DWGViewer/icons/view-tool-freehandLine.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
dev/DWGViewer/icons/view-tool-graphic.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
dev/DWGViewer/icons/view-tool-hideOther.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
dev/DWGViewer/icons/view-tool-hideShow.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
dev/DWGViewer/icons/view-tool-lasso.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
dev/DWGViewer/icons/view-tool-layer.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
dev/DWGViewer/icons/view-tool-length.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
dev/DWGViewer/icons/view-tool-lineToLine.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
dev/DWGViewer/icons/view-tool-lineWeight.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
dev/DWGViewer/icons/view-tool-measure.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
dev/DWGViewer/icons/view-tool-newInstall.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
dev/DWGViewer/icons/view-tool-perimeter.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
dev/DWGViewer/icons/view-tool-pointToLine.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
dev/DWGViewer/icons/view-tool-pointToPoint.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
dev/DWGViewer/icons/view-tool-prominent.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
dev/DWGViewer/icons/view-tool-radius.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
dev/DWGViewer/icons/view-tool-rectangleAnno.png
Normal file
|
After Width: | Height: | Size: 843 B |
BIN
dev/DWGViewer/icons/view-tool-reset.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
dev/DWGViewer/icons/view-tool-scale.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |