完整备份: 纳入全部源文件/数据/文档(含 PDF/STEP/Excel/编译产物)
@@ -0,0 +1,4 @@
|
||||
# 禁止直接访问加密模型文件(双保险:PHP 接口已校验令牌)
|
||||
<FilesMatch "\.(stp|step|enc)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
/**
|
||||
* STEP 查看器 - 模型读取接口(PHP 版)
|
||||
* 校验令牌 → .enc 解密 / .stp 原样 → 流式返回
|
||||
*/
|
||||
$SECRET = '7175a8598ff7fa1c182f57f0c750e4f8';
|
||||
// 与 upload.php 保持一致的私有目录配置:模型放 Web 目录外,web 里根本没有文件可下载
|
||||
$PRIVATE_DIR = '/volume1/stepviewer_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('/\.(stp|step|enc)$/i', $f)) json_out(400, ['error' => 'bad params']);
|
||||
|
||||
$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 || !$full) json_out(404, ['error' => 'not found']);
|
||||
|
||||
$data = file_get_contents($full);
|
||||
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)];
|
||||
}
|
||||
$data = $out;
|
||||
}
|
||||
|
||||
// 传输层 gzip:新格式文件解密后即以 gzip 魔数开头,原样传输;
|
||||
// 旧格式明文文件在线压缩。浏览器 fetch 自动解压(无需前端改动)
|
||||
$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');
|
||||
echo $send;
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
/**
|
||||
* STEP 查看器 - 令牌接口(PHP 版)
|
||||
* 同源校验 + HMAC 短时令牌(5 分钟)
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$SECRET = '7175a8598ff7fa1c182f57f0c750e4f8';
|
||||
$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('/\.(stp|step|enc)$/i', $f)) json_out(400, ['error' => 'bad file']);
|
||||
|
||||
// 只允许本站页面来取令牌,挡掉直接爬取。
|
||||
// 优先看 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 (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]);
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/**
|
||||
* STEP 查看器 - 上传接口(PHP 版,虚拟主机可用)
|
||||
* 接收原始字节流 + X-Filename 头,加密存储为 uploads/xxx.enc
|
||||
* 生产环境请修改 SECRET!
|
||||
*/
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$SECRET = '7175a8598ff7fa1c182f57f0c750e4f8';
|
||||
$MAX_SIZE = 500 * 1024 * 1024;
|
||||
// 私有目录:Web 目录外的绝对路径,模型只存这里,web 目录里根本没有模型文件可下载。
|
||||
// 群晖 File Station 在 web 目录外建 /volume1/stepviewer_private 并给 http 用户组读写权限;
|
||||
// 路径不同就改成你的实际路径。
|
||||
$PRIVATE_DIR = '/volume1/stepviewer_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']);
|
||||
|
||||
$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 500MB)']);
|
||||
|
||||
$raw = urldecode($_SERVER['HTTP_X_FILENAME'] ?? 'model.step');
|
||||
$base = basename($raw);
|
||||
$safe = preg_replace('/[^\w.\-\x{4e00}-\x{9fff}]/u', '_', $base);
|
||||
if (!$safe || !preg_match('/\.(step|stp)$/i', $safe)) $safe .= '.stp';
|
||||
$stem = pathinfo($safe, PATHINFO_FILENAME);
|
||||
$ext = pathinfo($safe, PATHINFO_EXTENSION);
|
||||
$name = $stem . '_' . substr(bin2hex(random_bytes(4)), 0, 8) . '.' . $ext;
|
||||
|
||||
$data = file_get_contents('php://input');
|
||||
if ($data === false || strlen($data) !== $len) json_out(400, ['error' => 'incomplete upload']);
|
||||
|
||||
// 客户端可选 gzip 压缩上传(STEP 文本约可压到 1/6,节省带宽)
|
||||
if (($_SERVER['HTTP_X_ENCODING'] ?? '') === 'gzip') {
|
||||
$dec = gzdecode($data);
|
||||
if ($dec === false) json_out(400, ['error' => 'bad gzip body']);
|
||||
if (strlen($dec) > $MAX_SIZE) json_out(413, ['error' => 'file too large (max 500MB)']);
|
||||
$data = $dec;
|
||||
}
|
||||
|
||||
// 存储层也压缩:先 gzip 再加密,磁盘占用约 1/6(读取端靠 gzip 魔数自动识别)
|
||||
$rawSize = strlen($data);
|
||||
$data = gzencode($data, 6);
|
||||
if ($data === false) json_out(500, ['error' => 'gzip failed']);
|
||||
|
||||
$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)];
|
||||
}
|
||||
|
||||
file_put_contents($dir . '/' . $name . '.enc', $nonce . $enc);
|
||||
// 私有目录模式下返回裸文件名:嵌入链接就是 ?file=xxx.enc,读取端按 basename 在私有目录找
|
||||
$entryPath = ($PRIVATE_DIR !== '' ? '' : 'uploads/') . $name . '.enc';
|
||||
|
||||
// 记入首页数模库(入口目录的 library.json,2D/3D 共享);库写失败不影响上传本身
|
||||
try {
|
||||
add_to_library($entryPath, $safe, '3d');
|
||||
} catch (Throwable $e) { /* 忽略 */ }
|
||||
|
||||
json_out(200, ['path' => $entryPath, '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);
|
||||
}
|
||||
2250
release/OnebotCatalog_2026.08/viewer/STEPViewer/app.js
Normal file
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>STEP 查看器嵌入示例</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: "Microsoft YaHei", sans-serif; background: #f5f6f7; }
|
||||
.header {
|
||||
background: #fff; padding: 14px 24px; border-bottom: 1px solid #e3e6e8;
|
||||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||
}
|
||||
.header h1 { font-size: 18px; margin: 0; color: #2b9ae8; }
|
||||
.model-btn {
|
||||
border: 1px solid #ddd; background: #fff; border-radius: 4px;
|
||||
padding: 8px 16px; cursor: pointer; font-size: 13px; color: #444;
|
||||
}
|
||||
.model-btn:hover { border-color: #2b9ae8; color: #2b9ae8; }
|
||||
.content { padding: 16px; }
|
||||
.frame-box {
|
||||
background: #fff; border: 1px solid #e3e6e8; border-radius: 6px;
|
||||
overflow: hidden; height: calc(100vh - 150px);
|
||||
}
|
||||
iframe { width: 100%; height: 100%; border: none; display: block; }
|
||||
.tip { color: #888; font-size: 12px; margin-top: 8px; line-height: 1.8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>STEP 3D 查看器 · 嵌入示例</h1>
|
||||
<button class="model-btn" data-model="samples/user/LZ01001001_H100_2mm.stp">LZ01001001_H100</button>
|
||||
<button class="model-btn" data-model="samples/user/LZ01001005_H500_2mm.stp">LZ01001005_H500</button>
|
||||
<button class="model-btn" data-model="samples/as1-oc-214.stp">as1 装配体</button>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="frame-box">
|
||||
<!-- 嵌入方式:iframe + ?file= 参数自动加载指定模型 -->
|
||||
<iframe id="viewerFrame" src="./index.html?file=samples/user/LZ01001001_H100_2mm.stp" allowFullScreen></iframe>
|
||||
</div>
|
||||
<div class="tip">
|
||||
嵌入方法:<iframe src="查看器目录/index.html?file=模型路径.stp" width="100%" height="600" allowFullScreen></iframe><br>
|
||||
部署:把整个 STEPViewer 目录(index.html、app.js、style.css、libs、icons)上传到你的网站,iframe 指向它即可。<br>
|
||||
注意:模型文件要与查看器同源(同一域名下),或用 ?url= 加载远程模型(远程服务器需允许 CORS)。
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('.model-btn').forEach((b) => {
|
||||
b.addEventListener('click', () => {
|
||||
document.getElementById('viewerFrame').src = './index.html?file=' + encodeURIComponent(b.dataset.model);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
release/OnebotCatalog_2026.08/viewer/STEPViewer/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 914 B |
|
After Width: | Height: | Size: 340 B |
|
After Width: | Height: | Size: 214 B |
|
After Width: | Height: | Size: 726 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 376 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 242 B |
|
After Width: | Height: | Size: 359 B |
|
After Width: | Height: | Size: 322 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 643 B |
|
After Width: | Height: | Size: 548 B |
|
After Width: | Height: | Size: 273 B |
|
After Width: | Height: | Size: 298 B |
|
After Width: | Height: | Size: 934 B |
|
After Width: | Height: | Size: 443 B |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
209
release/OnebotCatalog_2026.08/viewer/STEPViewer/index.html
Normal file
@@ -0,0 +1,209 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>STEP 模型查看器</title>
|
||||
<link rel="icon" href="favicon.ico">
|
||||
<link rel="stylesheet" href="./style.css?v=5">
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "./libs/three.module.min.js",
|
||||
"three/addons/": "./libs/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="./libs/occt-import-js.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
// 首帧渲染前应用 embed/menu-min 样式, 消除加载瞬间完整界面(顶栏/模型树/完整菜单)闪烁 (欧霓博目录专用补丁 2026-08-24)
|
||||
(function () {
|
||||
var q = new URLSearchParams(location.search);
|
||||
if (q.get('embed') === '1') document.body.classList.add('embed');
|
||||
if (q.get('menu') === 'min') document.body.classList.add('menu-min');
|
||||
})();
|
||||
</script>
|
||||
<!-- 顶栏 -->
|
||||
<div id="topbar">
|
||||
<div class="logo">
|
||||
<span class="logo-text">STEP 3D 查看器</span>
|
||||
</div>
|
||||
<button id="btnOpen" class="btn-primary" title="打开 STEP 文件">打开模型</button>
|
||||
<label class="quality-label" title="网格细分精度(越高越精细,转换时间越长)">精度
|
||||
<select id="qualitySelect">
|
||||
<option value="low">低</option>
|
||||
<option value="medium">中</option>
|
||||
<option value="high" selected>高</option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="btnEmbed" class="btn-secondary" title="生成嵌入链接">嵌入</button>
|
||||
<span id="fileName" class="file-name">未加载模型(支持拖拽 .step/.stp 文件到页面)</span>
|
||||
<div class="top-right">
|
||||
<button id="btnFullscreen" class="icon-btn" title="全屏"><img src="./icons/all-screen.png" alt="全屏"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="main">
|
||||
<!-- 左侧模型树 -->
|
||||
<div id="treePanel">
|
||||
<div class="tree-header">
|
||||
<span class="tree-title">模型树</span>
|
||||
<button id="btnTreeClose" class="tree-close" title="关闭面板">✕</button>
|
||||
<div class="tree-search">
|
||||
<img src="./icons/ico-search.33e9e6a1.png" alt="搜索">
|
||||
<input id="treeSearch" type="text" placeholder="搜索零件...">
|
||||
</div>
|
||||
</div>
|
||||
<div id="tree" class="tree-body"><div class="tree-empty">加载模型后显示零件树</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 视口 -->
|
||||
<div id="viewport">
|
||||
<div id="canvasWrap"></div>
|
||||
|
||||
<!-- 底部工具栏 -->
|
||||
<div id="toolbar">
|
||||
<button class="tool-btn active" data-tool="select" title="选择"><img src="./icons/view-tool-select.png" alt="选择"><span>选择</span></button>
|
||||
<button class="tool-btn" data-tool="measure" title="测量"><img src="./icons/view-tool-distance.png" alt="测量"><span>测量</span></button>
|
||||
<button class="tool-btn" data-tool="drag" title="拖动零件"><img src="./icons/view-tool-drag.png" alt="拖动"><span>拖动</span></button>
|
||||
<button class="tool-btn" data-tool="singalDrag" title="单件拖动"><img src="./icons/view-tool-singalDrag.png" alt="单件拖动"><span>单件</span></button>
|
||||
<button class="tool-btn" data-tool="explode" title="爆炸"><img src="./icons/view-tool-blast.png" alt="爆炸"><span>爆炸</span></button>
|
||||
<button class="tool-btn" data-tool="section" title="剖切"><img src="./icons/view-tool-slice.png" alt="剖切"><span>剖切</span></button>
|
||||
<div class="tool-sep"></div>
|
||||
<button class="tool-btn" id="btnTree" title="结构树显示/隐藏"><img src="./icons/view-tool-tree.png" alt="结构树"><span>结构树</span></button>
|
||||
<button class="tool-btn" id="btnWireframe" title="线框(显示/隐藏轮廓线)"><img src="./icons/ico-set-wireframe.png" alt="线框"><span>线框</span></button>
|
||||
<button class="tool-btn" id="btnColorPanel" title="上色"><img src="./icons/ico-set-color.png" alt="上色"><span>上色</span></button>
|
||||
<button class="tool-btn" id="btnShowAll" title="全部显示"><img src="./icons/view-tool-hideShow.png" alt="全部显示"><span>显示</span></button>
|
||||
<button class="tool-btn" id="btnTransparent" title="透明(选中零件)"><img src="./icons/view-tool-transparent.png" alt="透明"><span>透明</span></button>
|
||||
<button class="tool-btn" id="btnBbox" title="包围盒"><img src="./icons/view-tool-coutBox.png" alt="包围盒"><span>包围盒</span></button>
|
||||
<button class="tool-btn" id="btnClear" title="清除测量"><img src="./icons/view-tool-clear.png" alt="清除"><span>清除</span></button>
|
||||
<button class="tool-btn" id="btnSingleReset" title="单件复位"><img src="./icons/view-tool-singalReset.png" alt="单件复位"><span>复位</span></button>
|
||||
<button class="tool-btn" id="btnAllReset" title="全部复位"><img src="./icons/view-tool-allReset.png" alt="全部复位"><span>全复位</span></button>
|
||||
<div class="tool-sep"></div>
|
||||
<button class="tool-btn" id="btnVport" title="切换视角"><img src="./icons/view-tool-vport.png" alt="视角"><span>视角</span></button>
|
||||
<button class="tool-btn" id="btnAxes" title="坐标系"><img src="./icons/view-tool-coordinate.png" alt="坐标系"><span>坐标</span></button>
|
||||
<button class="tool-btn" id="btnFull" title="全屏"><img src="./icons/all-screen.png" alt="全屏"><span>全屏</span></button>
|
||||
</div>
|
||||
|
||||
<!-- 爆炸面板 -->
|
||||
<div id="explodePanel" class="float-panel hidden">
|
||||
<div class="panel-title">爆炸 <img src="./icons/close.0fb8dc6e.png" class="panel-close" data-panel="explode" alt="关闭"></div>
|
||||
<div class="panel-row">
|
||||
<span>程度</span>
|
||||
<input type="range" id="explodeSlider" min="0" max="100" value="0">
|
||||
<span id="explodeVal" class="panel-val">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 剖切面板 -->
|
||||
<div id="sectionPanel" class="float-panel hidden">
|
||||
<div class="panel-title">剖切 <img src="./icons/close.0fb8dc6e.png" class="panel-close" data-panel="section" alt="关闭"></div>
|
||||
<div class="panel-row">
|
||||
<button class="axis-btn" data-axis="x">X</button>
|
||||
<button class="axis-btn" data-axis="y">Y</button>
|
||||
<button class="axis-btn active" data-axis="z">Z</button>
|
||||
<button id="btnSectionFlip" class="small-btn">翻转</button>
|
||||
<button id="btnSectionToggle" class="small-btn on">开启</button>
|
||||
</div>
|
||||
<div class="panel-row">
|
||||
<span>位置</span>
|
||||
<input type="range" id="sectionSlider" min="-100" max="100" value="0">
|
||||
<span id="sectionVal" class="panel-val">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测量子面板(水平单行,激活时隐藏底部工具栏) -->
|
||||
<div id="measurePanel" class="float-panel measure-panel hidden">
|
||||
<div id="measureModes"></div>
|
||||
<div class="measure-actions">
|
||||
<button id="btnStats" class="measure-act" title="结果统计"><img src="./icons/view-tool-statisticalResult.png" alt="统计"></button>
|
||||
<button id="btnMeasureClear" class="measure-act" title="清空"><img src="./icons/view-tool-clear.png" alt="清空"></button>
|
||||
</div>
|
||||
<button id="btnMeasureClose" class="measure-act" title="关闭测量,恢复工具栏"><img src="./icons/close.0fb8dc6e.png" alt="关闭"></button>
|
||||
</div>
|
||||
|
||||
<!-- 结果统计面板 -->
|
||||
<div id="statsPanel" class="float-panel hidden">
|
||||
<div class="panel-title">结果统计 <img src="./icons/close.0fb8dc6e.png" class="panel-close" data-panel="stats" alt="关闭"></div>
|
||||
<div id="statsList"><div class="stats-empty">暂无测量结果</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 上色面板 -->
|
||||
<div id="colorPanel" class="float-panel hidden">
|
||||
<div class="panel-title">上色 <img src="./icons/close.0fb8dc6e.png" class="panel-close" data-panel="color" alt="关闭"></div>
|
||||
<div class="panel-row">
|
||||
<input type="color" id="colorPicker" value="#2b9ae8" title="选择颜色">
|
||||
<button id="btnColorApply" class="small-btn">应用到选中</button>
|
||||
</div>
|
||||
<div class="panel-row">
|
||||
<button id="btnColorOneClick" class="small-btn" title="按零件自动分配颜色"><img src="./icons/one-click-color.0a5c28c0.png" alt="一键上色">一键上色</button>
|
||||
<button id="btnColorReset" class="small-btn">恢复原色</button>
|
||||
</div>
|
||||
<div class="panel-row">
|
||||
<span>亮度</span>
|
||||
<input type="range" id="brightnessSlider" min="50" max="200" value="100">
|
||||
<span id="brightnessVal" class="panel-val">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 嵌入链接面板 -->
|
||||
<div id="embedPanel" class="float-panel hidden">
|
||||
<div class="panel-title">生成嵌入链接 <img src="./icons/close.0fb8dc6e.png" class="panel-close" data-panel="embed" alt="关闭"></div>
|
||||
<div class="panel-row">
|
||||
<span>模型路径</span>
|
||||
<input type="text" id="embedPath" placeholder="samples/xxx.stp 或 https://...">
|
||||
</div>
|
||||
<div class="panel-row">
|
||||
<span>嵌入地址</span>
|
||||
<input type="text" id="embedUrl" readonly>
|
||||
</div>
|
||||
<div class="panel-row">
|
||||
<span>iframe 代码</span>
|
||||
<textarea id="embedCode" rows="3" readonly></textarea>
|
||||
</div>
|
||||
<div class="panel-row embed-actions">
|
||||
<button id="btnUploadModel" class="small-btn">上传当前模型</button>
|
||||
<button id="btnCopyUrl" class="small-btn">复制链接</button>
|
||||
<button id="btnCopyCode" class="small-btn">复制代码</button>
|
||||
</div>
|
||||
<div id="embedHint" class="embed-hint"></div>
|
||||
</div>
|
||||
|
||||
<!-- 状态栏 -->
|
||||
<div id="statusbar">
|
||||
<span id="statusText">就绪 | 左键旋转 · 右键平移 · 滚轮缩放</span>
|
||||
<span id="modelInfo" class="status-right"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 零件右键菜单 -->
|
||||
<div id="ctxMenu" class="hidden">
|
||||
<div class="ctx-title" id="ctxMenuTitle"></div>
|
||||
<div class="ctx-item" data-act="hide"><img src="./icons/view-tool-hideShow.png" alt=""><span>隐藏</span></div>
|
||||
<div class="ctx-item" data-act="isolate"><img src="./icons/view-tool-conceal.png" alt=""><span>显示其他</span></div>
|
||||
<div class="ctx-item" data-act="showAll"><img src="./icons/view-tool-hideShow.png" alt=""><span>全部显示</span></div>
|
||||
<div class="ctx-sep"></div>
|
||||
<div class="ctx-item" data-act="color"><img src="./icons/ico-set-color.png" alt=""><span>更改颜色</span></div>
|
||||
<div class="ctx-item" data-act="transparent"><img src="./icons/view-tool-transparent.png" alt=""><span>透明</span></div>
|
||||
<div class="ctx-sep"></div>
|
||||
<div class="ctx-item" data-act="drag"><img src="./icons/view-tool-drag.png" alt=""><span>拖动</span></div>
|
||||
<div class="ctx-item" data-act="reset"><img src="./icons/view-tool-singalReset.png" alt=""><span>复位</span></div>
|
||||
</div>
|
||||
|
||||
<input type="file" id="fileInput" accept=".step,.stp,.STEP,.STP" style="display:none">
|
||||
|
||||
<!-- 加载遮罩(新迪同款弹跳圆点) -->
|
||||
<div id="loading" class="hidden">
|
||||
<div class="waiter"><div class="bounce1"></div><div class="bounce2"></div><div class="bounce3"></div></div>
|
||||
<div id="loadingText">正在转换 STEP 模型...</div>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽提示 -->
|
||||
<div id="dropOverlay" class="hidden"><div class="drop-box">松开鼠标加载 STEP 文件</div></div>
|
||||
|
||||
<script type="module" src="./app.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
Matrix4,
|
||||
Object3D,
|
||||
Vector2,
|
||||
Vector3
|
||||
} from 'three';
|
||||
|
||||
class CSS2DObject extends Object3D {
|
||||
|
||||
constructor( element = document.createElement( 'div' ) ) {
|
||||
|
||||
super();
|
||||
|
||||
this.isCSS2DObject = true;
|
||||
|
||||
this.element = element;
|
||||
|
||||
this.element.style.position = 'absolute';
|
||||
this.element.style.userSelect = 'none';
|
||||
|
||||
this.element.setAttribute( 'draggable', false );
|
||||
|
||||
this.center = new Vector2( 0.5, 0.5 ); // ( 0, 0 ) is the lower left; ( 1, 1 ) is the top right
|
||||
|
||||
this.addEventListener( 'removed', function () {
|
||||
|
||||
this.traverse( function ( object ) {
|
||||
|
||||
if ( object.element instanceof Element && object.element.parentNode !== null ) {
|
||||
|
||||
object.element.parentNode.removeChild( object.element );
|
||||
|
||||
}
|
||||
|
||||
} );
|
||||
|
||||
} );
|
||||
|
||||
}
|
||||
|
||||
copy( source, recursive ) {
|
||||
|
||||
super.copy( source, recursive );
|
||||
|
||||
this.element = source.element.cloneNode( true );
|
||||
|
||||
this.center = source.center;
|
||||
|
||||
return this;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
const _vector = new Vector3();
|
||||
const _viewMatrix = new Matrix4();
|
||||
const _viewProjectionMatrix = new Matrix4();
|
||||
const _a = new Vector3();
|
||||
const _b = new Vector3();
|
||||
|
||||
class CSS2DRenderer {
|
||||
|
||||
constructor( parameters = {} ) {
|
||||
|
||||
const _this = this;
|
||||
|
||||
let _width, _height;
|
||||
let _widthHalf, _heightHalf;
|
||||
|
||||
const cache = {
|
||||
objects: new WeakMap()
|
||||
};
|
||||
|
||||
const domElement = parameters.element !== undefined ? parameters.element : document.createElement( 'div' );
|
||||
|
||||
domElement.style.overflow = 'hidden';
|
||||
|
||||
this.domElement = domElement;
|
||||
|
||||
this.getSize = function () {
|
||||
|
||||
return {
|
||||
width: _width,
|
||||
height: _height
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
this.render = function ( scene, camera ) {
|
||||
|
||||
if ( scene.matrixWorldAutoUpdate === true ) scene.updateMatrixWorld();
|
||||
if ( camera.parent === null && camera.matrixWorldAutoUpdate === true ) camera.updateMatrixWorld();
|
||||
|
||||
_viewMatrix.copy( camera.matrixWorldInverse );
|
||||
_viewProjectionMatrix.multiplyMatrices( camera.projectionMatrix, _viewMatrix );
|
||||
|
||||
renderObject( scene, scene, camera );
|
||||
zOrder( scene );
|
||||
|
||||
};
|
||||
|
||||
this.setSize = function ( width, height ) {
|
||||
|
||||
_width = width;
|
||||
_height = height;
|
||||
|
||||
_widthHalf = _width / 2;
|
||||
_heightHalf = _height / 2;
|
||||
|
||||
domElement.style.width = width + 'px';
|
||||
domElement.style.height = height + 'px';
|
||||
|
||||
};
|
||||
|
||||
function renderObject( object, scene, camera ) {
|
||||
|
||||
if ( object.isCSS2DObject ) {
|
||||
|
||||
_vector.setFromMatrixPosition( object.matrixWorld );
|
||||
_vector.applyMatrix4( _viewProjectionMatrix );
|
||||
|
||||
const visible = ( object.visible === true ) && ( _vector.z >= - 1 && _vector.z <= 1 ) && ( object.layers.test( camera.layers ) === true );
|
||||
object.element.style.display = ( visible === true ) ? '' : 'none';
|
||||
|
||||
if ( visible === true ) {
|
||||
|
||||
object.onBeforeRender( _this, scene, camera );
|
||||
|
||||
const element = object.element;
|
||||
|
||||
element.style.transform = 'translate(' + ( - 100 * object.center.x ) + '%,' + ( - 100 * object.center.y ) + '%)' + 'translate(' + ( _vector.x * _widthHalf + _widthHalf ) + 'px,' + ( - _vector.y * _heightHalf + _heightHalf ) + 'px)';
|
||||
|
||||
if ( element.parentNode !== domElement ) {
|
||||
|
||||
domElement.appendChild( element );
|
||||
|
||||
}
|
||||
|
||||
object.onAfterRender( _this, scene, camera );
|
||||
|
||||
}
|
||||
|
||||
const objectData = {
|
||||
distanceToCameraSquared: getDistanceToSquared( camera, object )
|
||||
};
|
||||
|
||||
cache.objects.set( object, objectData );
|
||||
|
||||
}
|
||||
|
||||
for ( let i = 0, l = object.children.length; i < l; i ++ ) {
|
||||
|
||||
renderObject( object.children[ i ], scene, camera );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function getDistanceToSquared( object1, object2 ) {
|
||||
|
||||
_a.setFromMatrixPosition( object1.matrixWorld );
|
||||
_b.setFromMatrixPosition( object2.matrixWorld );
|
||||
|
||||
return _a.distanceToSquared( _b );
|
||||
|
||||
}
|
||||
|
||||
function filterAndFlatten( scene ) {
|
||||
|
||||
const result = [];
|
||||
|
||||
scene.traverse( function ( object ) {
|
||||
|
||||
if ( object.isCSS2DObject ) result.push( object );
|
||||
|
||||
} );
|
||||
|
||||
return result;
|
||||
|
||||
}
|
||||
|
||||
function zOrder( scene ) {
|
||||
|
||||
const sorted = filterAndFlatten( scene ).sort( function ( a, b ) {
|
||||
|
||||
if ( a.renderOrder !== b.renderOrder ) {
|
||||
|
||||
return b.renderOrder - a.renderOrder;
|
||||
|
||||
}
|
||||
|
||||
const distanceA = cache.objects.get( a ).distanceToCameraSquared;
|
||||
const distanceB = cache.objects.get( b ).distanceToCameraSquared;
|
||||
|
||||
return distanceA - distanceB;
|
||||
|
||||
} );
|
||||
|
||||
const zMax = sorted.length;
|
||||
|
||||
for ( let i = 0, l = sorted.length; i < l; i ++ ) {
|
||||
|
||||
sorted[ i ].element.style.zIndex = zMax - i;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { CSS2DObject, CSS2DRenderer };
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
EventDispatcher,
|
||||
Matrix4,
|
||||
Plane,
|
||||
Raycaster,
|
||||
Vector2,
|
||||
Vector3
|
||||
} from 'three';
|
||||
|
||||
const _plane = new Plane();
|
||||
const _raycaster = new Raycaster();
|
||||
|
||||
const _pointer = new Vector2();
|
||||
const _offset = new Vector3();
|
||||
const _intersection = new Vector3();
|
||||
const _worldPosition = new Vector3();
|
||||
const _inverseMatrix = new Matrix4();
|
||||
|
||||
class DragControls extends EventDispatcher {
|
||||
|
||||
constructor( _objects, _camera, _domElement ) {
|
||||
|
||||
super();
|
||||
|
||||
_domElement.style.touchAction = 'none'; // disable touch scroll
|
||||
|
||||
let _selected = null, _hovered = null;
|
||||
|
||||
const _intersections = [];
|
||||
|
||||
//
|
||||
|
||||
const scope = this;
|
||||
|
||||
function activate() {
|
||||
|
||||
_domElement.addEventListener( 'pointermove', onPointerMove );
|
||||
_domElement.addEventListener( 'pointerdown', onPointerDown );
|
||||
_domElement.addEventListener( 'pointerup', onPointerCancel );
|
||||
_domElement.addEventListener( 'pointerleave', onPointerCancel );
|
||||
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
|
||||
_domElement.removeEventListener( 'pointermove', onPointerMove );
|
||||
_domElement.removeEventListener( 'pointerdown', onPointerDown );
|
||||
_domElement.removeEventListener( 'pointerup', onPointerCancel );
|
||||
_domElement.removeEventListener( 'pointerleave', onPointerCancel );
|
||||
|
||||
_domElement.style.cursor = '';
|
||||
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
|
||||
deactivate();
|
||||
|
||||
}
|
||||
|
||||
function getObjects() {
|
||||
|
||||
return _objects;
|
||||
|
||||
}
|
||||
|
||||
function getRaycaster() {
|
||||
|
||||
return _raycaster;
|
||||
|
||||
}
|
||||
|
||||
function onPointerMove( event ) {
|
||||
|
||||
if ( scope.enabled === false ) return;
|
||||
|
||||
updatePointer( event );
|
||||
|
||||
_raycaster.setFromCamera( _pointer, _camera );
|
||||
|
||||
if ( _selected ) {
|
||||
|
||||
if ( _raycaster.ray.intersectPlane( _plane, _intersection ) ) {
|
||||
|
||||
_selected.position.copy( _intersection.sub( _offset ).applyMatrix4( _inverseMatrix ) );
|
||||
|
||||
}
|
||||
|
||||
scope.dispatchEvent( { type: 'drag', object: _selected } );
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
// hover support
|
||||
|
||||
if ( event.pointerType === 'mouse' || event.pointerType === 'pen' ) {
|
||||
|
||||
_intersections.length = 0;
|
||||
|
||||
_raycaster.setFromCamera( _pointer, _camera );
|
||||
_raycaster.intersectObjects( _objects, scope.recursive, _intersections );
|
||||
|
||||
if ( _intersections.length > 0 ) {
|
||||
|
||||
const object = _intersections[ 0 ].object;
|
||||
|
||||
_plane.setFromNormalAndCoplanarPoint( _camera.getWorldDirection( _plane.normal ), _worldPosition.setFromMatrixPosition( object.matrixWorld ) );
|
||||
|
||||
if ( _hovered !== object && _hovered !== null ) {
|
||||
|
||||
scope.dispatchEvent( { type: 'hoveroff', object: _hovered } );
|
||||
|
||||
_domElement.style.cursor = 'auto';
|
||||
_hovered = null;
|
||||
|
||||
}
|
||||
|
||||
if ( _hovered !== object ) {
|
||||
|
||||
scope.dispatchEvent( { type: 'hoveron', object: object } );
|
||||
|
||||
_domElement.style.cursor = 'pointer';
|
||||
_hovered = object;
|
||||
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
if ( _hovered !== null ) {
|
||||
|
||||
scope.dispatchEvent( { type: 'hoveroff', object: _hovered } );
|
||||
|
||||
_domElement.style.cursor = 'auto';
|
||||
_hovered = null;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function onPointerDown( event ) {
|
||||
|
||||
if ( scope.enabled === false ) return;
|
||||
|
||||
updatePointer( event );
|
||||
|
||||
_intersections.length = 0;
|
||||
|
||||
_raycaster.setFromCamera( _pointer, _camera );
|
||||
_raycaster.intersectObjects( _objects, scope.recursive, _intersections );
|
||||
|
||||
if ( _intersections.length > 0 ) {
|
||||
|
||||
_selected = ( scope.transformGroup === true ) ? _objects[ 0 ] : _intersections[ 0 ].object;
|
||||
|
||||
_plane.setFromNormalAndCoplanarPoint( _camera.getWorldDirection( _plane.normal ), _worldPosition.setFromMatrixPosition( _selected.matrixWorld ) );
|
||||
|
||||
if ( _raycaster.ray.intersectPlane( _plane, _intersection ) ) {
|
||||
|
||||
_inverseMatrix.copy( _selected.parent.matrixWorld ).invert();
|
||||
_offset.copy( _intersection ).sub( _worldPosition.setFromMatrixPosition( _selected.matrixWorld ) );
|
||||
|
||||
}
|
||||
|
||||
_domElement.style.cursor = 'move';
|
||||
|
||||
scope.dispatchEvent( { type: 'dragstart', object: _selected } );
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
function onPointerCancel() {
|
||||
|
||||
if ( scope.enabled === false ) return;
|
||||
|
||||
if ( _selected ) {
|
||||
|
||||
scope.dispatchEvent( { type: 'dragend', object: _selected } );
|
||||
|
||||
_selected = null;
|
||||
|
||||
}
|
||||
|
||||
_domElement.style.cursor = _hovered ? 'pointer' : 'auto';
|
||||
|
||||
}
|
||||
|
||||
function updatePointer( event ) {
|
||||
|
||||
const rect = _domElement.getBoundingClientRect();
|
||||
|
||||
_pointer.x = ( event.clientX - rect.left ) / rect.width * 2 - 1;
|
||||
_pointer.y = - ( event.clientY - rect.top ) / rect.height * 2 + 1;
|
||||
|
||||
}
|
||||
|
||||
activate();
|
||||
|
||||
// API
|
||||
|
||||
this.enabled = true;
|
||||
this.recursive = true;
|
||||
this.transformGroup = false;
|
||||
|
||||
this.activate = activate;
|
||||
this.deactivate = deactivate;
|
||||
this.dispose = dispose;
|
||||
this.getObjects = getObjects;
|
||||
this.getRaycaster = getRaycaster;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { DragControls };
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* https://github.com/google/model-viewer/blob/master/packages/model-viewer/src/three-components/EnvironmentScene.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
BackSide,
|
||||
BoxGeometry,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
MeshStandardMaterial,
|
||||
PointLight,
|
||||
Scene,
|
||||
} from 'three';
|
||||
|
||||
class RoomEnvironment extends Scene {
|
||||
|
||||
constructor( renderer = null ) {
|
||||
|
||||
super();
|
||||
|
||||
const geometry = new BoxGeometry();
|
||||
geometry.deleteAttribute( 'uv' );
|
||||
|
||||
const roomMaterial = new MeshStandardMaterial( { side: BackSide } );
|
||||
const boxMaterial = new MeshStandardMaterial();
|
||||
|
||||
let intensity = 5;
|
||||
|
||||
if ( renderer !== null && renderer._useLegacyLights === false ) intensity = 900;
|
||||
|
||||
const mainLight = new PointLight( 0xffffff, intensity, 28, 2 );
|
||||
mainLight.position.set( 0.418, 16.199, 0.300 );
|
||||
this.add( mainLight );
|
||||
|
||||
const room = new Mesh( geometry, roomMaterial );
|
||||
room.position.set( - 0.757, 13.219, 0.717 );
|
||||
room.scale.set( 31.713, 28.305, 28.591 );
|
||||
this.add( room );
|
||||
|
||||
const box1 = new Mesh( geometry, boxMaterial );
|
||||
box1.position.set( - 10.906, 2.009, 1.846 );
|
||||
box1.rotation.set( 0, - 0.195, 0 );
|
||||
box1.scale.set( 2.328, 7.905, 4.651 );
|
||||
this.add( box1 );
|
||||
|
||||
const box2 = new Mesh( geometry, boxMaterial );
|
||||
box2.position.set( - 5.607, - 0.754, - 0.758 );
|
||||
box2.rotation.set( 0, 0.994, 0 );
|
||||
box2.scale.set( 1.970, 1.534, 3.955 );
|
||||
this.add( box2 );
|
||||
|
||||
const box3 = new Mesh( geometry, boxMaterial );
|
||||
box3.position.set( 6.167, 0.857, 7.803 );
|
||||
box3.rotation.set( 0, 0.561, 0 );
|
||||
box3.scale.set( 3.927, 6.285, 3.687 );
|
||||
this.add( box3 );
|
||||
|
||||
const box4 = new Mesh( geometry, boxMaterial );
|
||||
box4.position.set( - 2.017, 0.018, 6.124 );
|
||||
box4.rotation.set( 0, 0.333, 0 );
|
||||
box4.scale.set( 2.002, 4.566, 2.064 );
|
||||
this.add( box4 );
|
||||
|
||||
const box5 = new Mesh( geometry, boxMaterial );
|
||||
box5.position.set( 2.291, - 0.756, - 2.621 );
|
||||
box5.rotation.set( 0, - 0.286, 0 );
|
||||
box5.scale.set( 1.546, 1.552, 1.496 );
|
||||
this.add( box5 );
|
||||
|
||||
const box6 = new Mesh( geometry, boxMaterial );
|
||||
box6.position.set( - 2.193, - 0.369, - 5.547 );
|
||||
box6.rotation.set( 0, 0.516, 0 );
|
||||
box6.scale.set( 3.875, 3.487, 2.986 );
|
||||
this.add( box6 );
|
||||
|
||||
|
||||
// -x right
|
||||
const light1 = new Mesh( geometry, createAreaLightMaterial( 50 ) );
|
||||
light1.position.set( - 16.116, 14.37, 8.208 );
|
||||
light1.scale.set( 0.1, 2.428, 2.739 );
|
||||
this.add( light1 );
|
||||
|
||||
// -x left
|
||||
const light2 = new Mesh( geometry, createAreaLightMaterial( 50 ) );
|
||||
light2.position.set( - 16.109, 18.021, - 8.207 );
|
||||
light2.scale.set( 0.1, 2.425, 2.751 );
|
||||
this.add( light2 );
|
||||
|
||||
// +x
|
||||
const light3 = new Mesh( geometry, createAreaLightMaterial( 17 ) );
|
||||
light3.position.set( 14.904, 12.198, - 1.832 );
|
||||
light3.scale.set( 0.15, 4.265, 6.331 );
|
||||
this.add( light3 );
|
||||
|
||||
// +z
|
||||
const light4 = new Mesh( geometry, createAreaLightMaterial( 43 ) );
|
||||
light4.position.set( - 0.462, 8.89, 14.520 );
|
||||
light4.scale.set( 4.38, 5.441, 0.088 );
|
||||
this.add( light4 );
|
||||
|
||||
// -z
|
||||
const light5 = new Mesh( geometry, createAreaLightMaterial( 20 ) );
|
||||
light5.position.set( 3.235, 11.486, - 12.541 );
|
||||
light5.scale.set( 2.5, 2.0, 0.1 );
|
||||
this.add( light5 );
|
||||
|
||||
// +y
|
||||
const light6 = new Mesh( geometry, createAreaLightMaterial( 100 ) );
|
||||
light6.position.set( 0.0, 20.0, 0.0 );
|
||||
light6.scale.set( 1.0, 0.1, 1.0 );
|
||||
this.add( light6 );
|
||||
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
||||
const resources = new Set();
|
||||
|
||||
this.traverse( ( object ) => {
|
||||
|
||||
if ( object.isMesh ) {
|
||||
|
||||
resources.add( object.geometry );
|
||||
resources.add( object.material );
|
||||
|
||||
}
|
||||
|
||||
} );
|
||||
|
||||
for ( const resource of resources ) {
|
||||
|
||||
resource.dispose();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function createAreaLightMaterial( intensity ) {
|
||||
|
||||
const material = new MeshBasicMaterial();
|
||||
material.color.setScalar( intensity );
|
||||
return material;
|
||||
|
||||
}
|
||||
|
||||
export { RoomEnvironment };
|
||||
6
release/OnebotCatalog_2026.08/viewer/STEPViewer/libs/three.module.min.js
vendored
Normal file
313
release/OnebotCatalog_2026.08/viewer/STEPViewer/style.css
Normal file
@@ -0,0 +1,313 @@
|
||||
/* ===== 全局 ===== */
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0; height: 100%; overflow: hidden;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
|
||||
font-size: 13px; color: #333;
|
||||
}
|
||||
/* 嵌入模式:隐藏顶部菜单栏 */
|
||||
body.embed #topbar { display: none; }
|
||||
body.embed #main { top: 0; }
|
||||
/* 精简菜单 (menu=min, 欧霓博目录嵌入专用): 只保留 剖切/线框/透明/视角/测量, 并隐藏模型树 */
|
||||
body.menu-min #treePanel { display: none; }
|
||||
body.menu-min #viewport { left: 0; }
|
||||
body.menu-min .tool-btn[data-tool="select"],
|
||||
body.menu-min .tool-btn[data-tool="drag"],
|
||||
body.menu-min .tool-btn[data-tool="singalDrag"],
|
||||
body.menu-min .tool-btn[data-tool="explode"],
|
||||
body.menu-min #btnTree,
|
||||
body.menu-min #btnColorPanel,
|
||||
body.menu-min #btnShowAll,
|
||||
body.menu-min #btnBbox,
|
||||
body.menu-min #btnClear,
|
||||
body.menu-min #btnSingleReset,
|
||||
body.menu-min #btnAllReset,
|
||||
body.menu-min #btnAxes,
|
||||
body.menu-min #btnFull { display: none; }
|
||||
.hidden { display: none !important; }
|
||||
img { -webkit-user-drag: none; user-select: none; }
|
||||
|
||||
/* ===== 顶栏 ===== */
|
||||
#topbar {
|
||||
position: absolute; top: 0; left: 0; right: 0; height: 50px;
|
||||
background: #fff; border-bottom: 1px solid #e3e6e8;
|
||||
display: flex; align-items: center; gap: 14px; padding: 0 14px; z-index: 50;
|
||||
}
|
||||
.logo { display: flex; align-items: center; gap: 8px; }
|
||||
.logo-text { font-size: 16px; font-weight: 600; color: #2b9ae8; letter-spacing: 1px; }
|
||||
.btn-primary {
|
||||
background: #2b9ae8; color: #fff; border: none; border-radius: 4px;
|
||||
padding: 7px 18px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover { background: #2389cf; }
|
||||
.btn-secondary {
|
||||
background: #fff; color: #2b9ae8; border: 1px solid #2b9ae8; border-radius: 4px;
|
||||
padding: 6px 16px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover { background: #f0f7fd; }
|
||||
.file-name { color: #888; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.quality-label { display: flex; align-items: center; gap: 6px; color: #666; font-size: 12px; flex: none; }
|
||||
.quality-label select {
|
||||
border: 1px solid #ddd; border-radius: 4px; padding: 4px 6px; font-size: 12px;
|
||||
color: #333; background: #fff; cursor: pointer;
|
||||
}
|
||||
.top-right { display: flex; gap: 6px; }
|
||||
.icon-btn {
|
||||
width: 32px; height: 32px; border: 1px solid #ddd; border-radius: 4px;
|
||||
background: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0;
|
||||
}
|
||||
.icon-btn:hover { border-color: #2b9ae8; }
|
||||
.icon-btn img { width: 18px; height: 18px; }
|
||||
|
||||
/* ===== 主区域 ===== */
|
||||
#main { position: absolute; top: 50px; left: 0; right: 0; bottom: 0; }
|
||||
|
||||
/* ===== 左侧模型树 ===== */
|
||||
#treePanel {
|
||||
position: absolute; top: 0; left: 0; bottom: 0; width: 280px;
|
||||
background: #fff; border-right: 1px solid #e3e6e8; z-index: 40;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.tree-header { padding: 10px 12px 8px; border-bottom: 1px solid #f0f2f3; }
|
||||
.tree-title { font-weight: 600; font-size: 14px; }
|
||||
.tree-search {
|
||||
margin-top: 8px; display: flex; align-items: center; gap: 6px;
|
||||
border: 1px solid #ddd; border-radius: 4px; padding: 4px 8px;
|
||||
}
|
||||
.tree-search img { width: 14px; height: 14px; opacity: .6; }
|
||||
.tree-search input { border: none; outline: none; flex: 1; font-size: 12px; background: transparent; }
|
||||
.tree-body { flex: 1; overflow: auto; padding: 4px 0 20px; }
|
||||
.tree-empty { color: #aaa; text-align: center; padding: 40px 10px; font-size: 12px; }
|
||||
|
||||
.tree-row {
|
||||
display: flex; align-items: center; gap: 4px; height: 26px; padding: 0 6px;
|
||||
cursor: pointer; white-space: nowrap; user-select: none;
|
||||
}
|
||||
.tree-row:hover { background: #f0f7fd; }
|
||||
.tree-row.highlight { background: #dceefb; }
|
||||
.tree-expander { width: 14px; height: 14px; display: inline-flex; align-items: center; justify-content: center; flex: none; }
|
||||
.tree-expander img { width: 10px; height: 10px; }
|
||||
.tree-spacer { width: 14px; flex: none; }
|
||||
.tree-icon { width: 16px; height: 16px; flex: none; }
|
||||
.tree-check { width: 14px; height: 14px; flex: none; cursor: pointer; }
|
||||
.tree-label { overflow: hidden; text-overflow: ellipsis; font-size: 12px; }
|
||||
.tree-row.matched .tree-label { color: #2b9ae8; font-weight: 600; }
|
||||
.tree-children { display: block; }
|
||||
|
||||
/* ===== 视口 ===== */
|
||||
#viewport { position: absolute; top: 0; left: 280px; right: 0; bottom: 0; }
|
||||
#canvasWrap { position: absolute; inset: 0; }
|
||||
#canvasWrap canvas { display: block; }
|
||||
|
||||
/* ===== 底部工具栏 ===== */
|
||||
#toolbar {
|
||||
position: absolute; left: 50%; bottom: 36px; transform: translateX(-50%);
|
||||
display: flex; flex-direction: row; gap: 2px; z-index: 45;
|
||||
background: #fff; border: 1px solid #e3e6e8; border-radius: 6px;
|
||||
padding: 4px 8px; box-shadow: 0 2px 10px rgba(0,0,0,.08);
|
||||
}
|
||||
.tool-btn {
|
||||
width: 72px; height: 72px; border: none; background: transparent; border-radius: 4px;
|
||||
cursor: pointer; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 4px; color: #666; padding: 0;
|
||||
}
|
||||
.tool-btn img { width: 40px; height: 40px; }
|
||||
.tool-btn span { font-size: 12px; line-height: 1; }
|
||||
.tool-btn:hover { background: #f0f7fd; color: #2b9ae8; }
|
||||
.tool-btn.active { background: #2b9ae8; color: #fff; }
|
||||
.tool-btn.active img { filter: brightness(0) invert(1); }
|
||||
.tool-sep { height: 1px; background: #eee; margin: 2px 4px; }
|
||||
|
||||
/* ===== 浮动面板 ===== */
|
||||
.float-panel {
|
||||
position: absolute; z-index: 46; background: #fff; border: 1px solid #e3e6e8;
|
||||
border-radius: 6px; box-shadow: 0 2px 12px rgba(0,0,0,.12); padding: 10px 12px; width: 240px;
|
||||
}
|
||||
#explodePanel { right: 16px; top: 20px; }
|
||||
#sectionPanel { right: 16px; top: 120px; }
|
||||
.panel-title {
|
||||
font-weight: 600; font-size: 13px; margin-bottom: 10px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.panel-close { width: 14px; height: 14px; cursor: pointer; opacity: .6; }
|
||||
.panel-close:hover { opacity: 1; }
|
||||
.panel-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.panel-row:last-child { margin-bottom: 0; }
|
||||
.panel-row span { font-size: 12px; color: #666; flex: none; }
|
||||
.panel-row input[type="range"] { flex: 1; accent-color: #2b9ae8; }
|
||||
.panel-val { width: 44px; text-align: right; color: #2b9ae8; font-weight: 600; }
|
||||
.axis-btn {
|
||||
width: 28px; height: 24px; border: 1px solid #ccc; background: #fff; border-radius: 3px;
|
||||
cursor: pointer; font-weight: 700; color: #555;
|
||||
}
|
||||
.axis-btn.active { background: #2b9ae8; border-color: #2b9ae8; color: #fff; }
|
||||
.small-btn {
|
||||
border: 1px solid #ccc; background: #fff; border-radius: 3px; cursor: pointer;
|
||||
font-size: 11px; padding: 4px 8px; color: #555;
|
||||
}
|
||||
.small-btn:hover { border-color: #2b9ae8; color: #2b9ae8; }
|
||||
.small-btn.on { background: #2b9ae8; border-color: #2b9ae8; color: #fff; }
|
||||
|
||||
/* ===== 测量面板(水平单行) ===== */
|
||||
.measure-panel {
|
||||
left: 50%; bottom: 36px; transform: translateX(-50%);
|
||||
display: flex; align-items: center; gap: 2px; padding: 6px 8px;
|
||||
width: auto; max-width: 96vw; overflow-x: auto;
|
||||
}
|
||||
#measureModes { display: flex; gap: 2px; flex-wrap: nowrap; }
|
||||
.measure-btn {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
width: 72px; height: 72px; padding: 0; border: 1px solid transparent; border-radius: 4px;
|
||||
background: transparent; cursor: pointer; color: #555; flex: none;
|
||||
}
|
||||
.measure-btn img { width: 40px; height: 40px; }
|
||||
.measure-btn span { display: none; }
|
||||
.measure-btn:hover { background: #f0f7fd; }
|
||||
.measure-btn.active { background: #2b9ae8; color: #fff; }
|
||||
.measure-btn.active img { filter: brightness(0) invert(1); }
|
||||
.measure-actions {
|
||||
display: flex; gap: 2px; margin-left: 6px; padding-left: 6px;
|
||||
border-left: 1px solid #eee; flex: none;
|
||||
}
|
||||
.measure-act {
|
||||
width: 72px; height: 72px; border: none; background: transparent; border-radius: 4px;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center; flex: none;
|
||||
}
|
||||
.measure-act:hover { background: #f0f7fd; }
|
||||
.measure-act img { width: 40px; height: 40px; }
|
||||
|
||||
/* ===== 结果统计面板 ===== */
|
||||
#statsPanel { right: 16px; top: 220px; width: 260px; max-height: 320px; display: flex; flex-direction: column; }
|
||||
#statsPanel .panel-title { flex: none; }
|
||||
#statsList { overflow: auto; flex: 1; }
|
||||
.stats-empty { color: #aaa; text-align: center; padding: 16px 0; font-size: 12px; }
|
||||
.stats-row {
|
||||
display: flex; align-items: center; gap: 6px; padding: 5px 2px;
|
||||
border-bottom: 1px solid #f5f6f7; font-size: 12px;
|
||||
}
|
||||
.stats-row:last-child { border-bottom: none; }
|
||||
.stats-type { color: #2b9ae8; flex: none; font-weight: 600; }
|
||||
.stats-value { flex: 1; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.stats-del { cursor: pointer; color: #bbb; flex: none; padding: 0 4px; }
|
||||
.stats-del:hover { color: #e74c3c; }
|
||||
|
||||
/* ===== 上色面板 ===== */
|
||||
#colorPanel { right: 16px; top: 20px; width: 230px; }
|
||||
#colorPicker { width: 36px; height: 28px; border: 1px solid #ddd; border-radius: 4px; padding: 1px; cursor: pointer; }
|
||||
#colorPanel .small-btn { display: flex; align-items: center; gap: 4px; }
|
||||
#colorPanel .small-btn img { width: 14px; height: 14px; }
|
||||
|
||||
/* ===== 嵌入链接面板 ===== */
|
||||
#embedPanel { right: 16px; top: 20px; width: 340px; }
|
||||
#embedPanel .panel-row { align-items: flex-start; flex-wrap: wrap; }
|
||||
#embedPanel .panel-row > span { padding-top: 6px; }
|
||||
#embedPanel input[type="text"], #embedPanel textarea {
|
||||
flex: 1; border: 1px solid #ddd; border-radius: 4px; padding: 5px 8px;
|
||||
font-size: 11px; color: #444; min-width: 200px;
|
||||
}
|
||||
#embedPanel textarea { resize: vertical; font-family: Consolas, monospace; }
|
||||
.embed-actions { justify-content: flex-end; }
|
||||
.embed-hint { font-size: 11px; color: #999; line-height: 1.6; }
|
||||
|
||||
/* ===== 零件右键菜单 ===== */
|
||||
#ctxMenu {
|
||||
position: fixed; z-index: 500; background: #fff; border: 1px solid #e3e6e8;
|
||||
border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,.15); padding: 6px 0;
|
||||
min-width: 140px;
|
||||
}
|
||||
.ctx-title {
|
||||
padding: 6px 14px; font-weight: 600; font-size: 12px; color: #2b9ae8;
|
||||
border-bottom: 1px solid #f0f2f3; margin-bottom: 4px;
|
||||
max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.ctx-item {
|
||||
display: flex; align-items: center; gap: 8px; padding: 7px 14px;
|
||||
cursor: pointer; font-size: 12px; color: #444;
|
||||
}
|
||||
.ctx-item:hover { background: #f0f7fd; color: #2b9ae8; }
|
||||
.ctx-item img { width: 18px; height: 18px; }
|
||||
.ctx-sep { height: 1px; background: #f0f2f3; margin: 4px 0; }
|
||||
|
||||
/* ===== 状态栏 ===== */
|
||||
#statusbar {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; height: 26px;
|
||||
background: rgba(255,255,255,.92); border-top: 1px solid #e3e6e8;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 12px; font-size: 11px; color: #888; z-index: 44;
|
||||
}
|
||||
.status-right { color: #2b9ae8; }
|
||||
|
||||
/* ===== 加载遮罩(新迪同款弹跳圆点) ===== */
|
||||
#loading {
|
||||
position: fixed; inset: 0; background: rgba(255,255,255,.9); z-index: 200;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px;
|
||||
}
|
||||
.waiter { display: flex; gap: 12px; }
|
||||
.waiter > div {
|
||||
width: 18px; height: 18px; background-color: #2b9ae8; border-radius: 100%;
|
||||
animation: bouncedelay 1.4s infinite ease-in-out; animation-fill-mode: both;
|
||||
}
|
||||
.waiter .bounce1 { animation-delay: -0.75s; }
|
||||
.waiter .bounce2 { animation-delay: -0.5s; }
|
||||
.waiter .bounce3 { animation-delay: -0.25s; }
|
||||
@keyframes bouncedelay {
|
||||
0% { transform: scale(0); }
|
||||
50% { transform: scale(1); }
|
||||
100% { transform: scale(0); }
|
||||
}
|
||||
#loadingText { color: #666; font-size: 14px; }
|
||||
|
||||
/* ===== 拖拽遮罩 ===== */
|
||||
#dropOverlay {
|
||||
position: fixed; inset: 0; background: rgba(43,154,232,.08); z-index: 300;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.drop-box {
|
||||
border: 2px dashed #2b9ae8; border-radius: 12px; background: #fff;
|
||||
padding: 50px 80px; font-size: 18px; color: #2b9ae8; pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== 测量标注 ===== */
|
||||
.measure-label {
|
||||
background: #2b9ae8; color: #fff; font-size: 12px; font-weight: 600;
|
||||
padding: 3px 8px; border-radius: 3px; white-space: nowrap; pointer-events: none;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.3);
|
||||
}
|
||||
.measure-label::after {
|
||||
content: ''; position: absolute; left: 50%; top: 100%; margin-left: -4px;
|
||||
border: 4px solid transparent; border-top-color: #2b9ae8;
|
||||
}
|
||||
|
||||
/* ===== 手机适配(≤768px) ===== */
|
||||
.tree-close { display: none; }
|
||||
@media (max-width: 768px) {
|
||||
.tree-close {
|
||||
display: block; position: absolute; top: 12px; right: 10px;
|
||||
border: none; background: #f2f7fc; color: #6b8498; font-size: 14px;
|
||||
border-radius: 4px; padding: 2px 8px; cursor: pointer;
|
||||
}
|
||||
.tree-close:hover { background: #2b9ae8; color: #fff; }
|
||||
#topbar { height: 44px; gap: 8px; padding: 0 8px; }
|
||||
#topbar .logo-text { display: none; }
|
||||
#topbar .file-name { display: none; }
|
||||
#topbar .btn-primary, #topbar .btn-secondary { padding: 5px 10px; font-size: 12px; }
|
||||
#main { top: 44px; }
|
||||
body.embed #main { top: 0; }
|
||||
|
||||
/* 模型树抽屉:默认隐藏,点「结构树」按钮弹出 */
|
||||
#treePanel {
|
||||
position: fixed; top: 44px; left: 0; bottom: 0; width: min(280px, 85vw); z-index: 60;
|
||||
transform: translateX(-100%); transition: transform .2s ease; box-shadow: 2px 0 12px rgba(0,0,0,.15);
|
||||
}
|
||||
body.embed #treePanel { top: 0; }
|
||||
body.tree-open #treePanel { transform: translateX(0); }
|
||||
#treePanel.hidden { display: flex; } /* 手机上抽屉由 tree-open 控制 */
|
||||
#viewport { left: 0; }
|
||||
|
||||
#toolbar { left: 0; right: 0; transform: none; bottom: 8px;
|
||||
border-radius: 0; border-left: none; border-right: none; padding: 2px 4px; overflow-x: auto; }
|
||||
.tool-btn { width: 56px; height: 60px; flex: none; }
|
||||
.tool-btn img { width: 32px; height: 32px; }
|
||||
.tool-btn span { font-size: 11px; }
|
||||
.float-panel { max-width: 92vw; max-height: 70vh; overflow: auto; }
|
||||
}
|
||||