完整备份: 纳入全部源文件/数据/文档(含 PDF/STEP/Excel/编译产物)

This commit is contained in:
wangruiguo
2026-09-03 18:04:37 +08:00
parent c286aed1d9
commit dd215b6679
4445 changed files with 1646144 additions and 39 deletions

View File

@@ -0,0 +1,4 @@
# 禁止直接访问加密模型文件双保险PHP 接口已校验令牌)
<FilesMatch "\.(stp|step|enc)$">
Require all denied
</FilesMatch>

View File

@@ -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;

View File

@@ -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]);

View File

@@ -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.json2D/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);
}

File diff suppressed because it is too large Load Diff

View 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">
嵌入方法:&lt;iframe src="查看器目录/index.html?file=模型路径.stp" width="100%" height="600" allowFullScreen&gt;&lt;/iframe&gt;<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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View 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>

View File

@@ -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 };

View File

@@ -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 };

File diff suppressed because it is too large Load Diff

View File

@@ -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 };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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; }
}