首次提交: OnebotCatalog 项目代码与文档(含 NX 按需生成服务二期、后台、一键启动)

This commit is contained in:
wangruiguo
2026-09-03 17:55:45 +08:00
commit fafa86d3a6
241 changed files with 78656 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
# WebAssembly 必须带正确的 MIME否则 occt 引擎的 instantiateStreaming 会失败
AddType application/wasm .wasm
AddType text/javascript .js .mjs
# 禁止直接下载模型文件(.stp/.step/.enc 只能通过查看器令牌通道读取)
<FilesMatch "\.(stp|step|enc|STP|STEP)$">
Require all denied
</FilesMatch>

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

2250
viewer/STEPViewer/app.js Normal file

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

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

313
viewer/STEPViewer/style.css Normal file
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; }
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
&<26>E<EFBFBD><45>^YWh<57><68><EFBFBD><EFBFBD>#<23>߰<EFBFBD><DFB0>0?&*=؛F

View File

@@ -0,0 +1,95 @@
# STEP 3D 查看器部署说明
## 〇、群晖 NAS 专用步骤ftp.ruiguo.wang
1. **套件中心安装**Web Station + PHP 8.xDSM 7
2. **传文件**File Station → 找到网站根目录(对应 https://ftp.ruiguo.wang/3D/web/ 的目录)
→ 把 `STEPViewer` 整个文件夹上传到 `3D/web/`
3. **开 PHP**Web Station → 脚本语言设置 → 确认 PHP 8.x 已启用(默认启用;
如果用虚拟主机方式建站,需在该站点设置里选择 PHP 版本)
4. **改密钥**`api/upload.php``api/token.php``api/model.php` 里的 `$SECRET`
已经改成随机字符串(三处一致),如需自行更换请保持一致
5. **私有目录已预设nginx 下最省事)**
- `api/upload.php``api/model.php` 已预设
`$PRIVATE_DIR = '/volume1/stepviewer_private';`(路径不对就改成实际路径)
- File Station 在 Web 目录外建这个文件夹(右键 web 文件夹同级 → 新建),
并给 `http` 用户组读写权限
- 上传的加密模型直接存这里,**Web 目录里根本没有模型文件,谁都下载不到**
- 你现有的 `assets/models/*.stp` 也建议移到这个私有目录,
之后嵌入链接用 `?file=LZ01001001_H100_2mm.stp`(只写文件名即可)
- 留在 Web 目录里的文件(如 samples/ 样例)按原路径仍可打开(读取端自动退回站内),
但**它们仍可被直链下载**——要保护的文件必须移进私有目录
6. **可选——nginx 开 gzip大幅加速远程打开**
模型上传/下载已内置 gzipSTEP 文本可压到约 1/6自动生效
再做这一步可让 7.6MB 的 wasm 引擎也压缩传输(约 2.9MB。SSH 编辑
Web Station 站点配置,在 server 块加:
```
gzip on;
gzip_types application/wasm application/javascript text/css text/plain;
gzip_min_length 1024;
```
(查看器会自动判断:浏览器 fetch 对 gzip 响应透明解压,无需任何前端配置)
7. **验证**:见下方「验证清单」
> 群晖 Web Station 默认 nginx`.htaccess` 不生效——用上面的私有目录方案替代。
> 若坚持模型文件留在 Web 目录,需 SSH 加 nginx 规则:
> `location ~* \.(stp|step|enc)$ { return 403; }`(控制面板→终端机开 SSH
## 一、目录结构(整个文件夹上传,缺一不可)
## 一、目录结构(整个文件夹上传,缺一不可)
```
STEPViewer/
├── index.html / app.js / style.css ← 页面与逻辑
├── embed_demo.html ← 嵌入示例(可不传)
├── libs/ ← three.js + occt WASM 引擎(约 8MB
├── icons/ ← 界面图标
├── samples/ ← 测试模型(可不传)
├── uploads/ ← 上传的加密模型(自动创建)
├── api/ ← PHP 接口(虚拟主机用)
└── .htaccess ← 防直链Apache 用)
```
## 二、方案 A虚拟主机 / PHP 空间(最常见)
1. 用 FTP 客户端(如 FileZilla把整个 `STEPViewer` 文件夹上传到网站目录
例如:`https://ftp.ruiguo.wang/3D/web/STEPViewer/`
2. 确保主机支持 PHP 7+`api/` 里的三个接口就是 PHP 写的)
3. 修改 `api/upload.php`、`api/token.php`、`api/model.php` 里的 `$SECRET` 为随机长字符串
4. 确认主机是 Apache.htaccess 生效)→ 模型文件自动禁止直链;
如果是 Nginx需要在配置里加等价规则联系主机商或参考下方
5. 把模型文件放到查看器目录内(整个 STEPViewer 文件夹一起传,如 `STEPViewer/models/xxx.stp`
嵌入地址:`/STEPViewer/index.html?file=models/xxx.stp&embed=1`
⚠️ 启用防下载后(.htaccess 或 nginx 403 规则),`../` 这种逃出查看器目录的路径会被
令牌通道拒绝(防目录穿越),模型必须放在查看器目录内;用私有目录方案(§〇)时只写文件名。
**验证清单:**
- 浏览器打开 index.html 能加载模型 ✓
- 直接访问 .stp 文件 → 403/拒绝 ✓
- 拖入本地文件 → 状态栏显示「已上传」→ 嵌入链接可用 ✓
## 三、方案 B自己的服务器VPS/云主机,可跑 Python
1. 上传 `STEPViewer` 整个目录
2. 把开发用的 `nds_server_step.py` 传上去,修改:
- `SECRET` 为随机长字符串
- 监听 `0.0.0.0:8910`(对外服务)
3. 用 systemd / pm2 / nohup 常驻运行
4. Nginx 反代(可选,便于 80/443 端口):
```
location /3D/web/STEPViewer/ {
proxy_pass http://127.0.0.1:8910/;
proxy_set_header Host $host;
client_max_body_size 500m;
}
```
注意 Nginx 下需额外禁止 .stp/.step/.enc 直链Python 服务本身已 403 拦截 ✓)
## 四、常见问题
- **模型打不开**:检查 `libs/occt-import-js.wasm` 是否上传完整7.6MB),服务器 `.wasm` 的 MIME 应为 `application/wasm`;开了 nginx gzip 后不影响(浏览器自动解压)
- **打开慢**:模型传输已内置 gzip约省 84% 流量wasm 引擎首次加载需下载 7.6MB,之后走浏览器缓存;群晖上建议按第〇节第 6 步给 nginx 开 gzip
- **上传失败提示**说明接口没通PHP 环境缺失或路径不对),模型仍可本地查看
- **嵌入链接别人打不开**:确认用的是线上域名而非 127.0.0.1
- **跨域加载(?url=**:目标服务器必须允许 CORS否则请用同源 `?file=`

View File

@@ -0,0 +1,45 @@
新迪 STEPViewer 嵌入菜单精简补丁 (欧霓博目录专用)
====================================================
用途: 网页目录 iframe 里只保留 剖切/线框/透明/视角/测量 五个功能。
本地副本 OnebotCatalog\viewer\STEPViewer\ 已改好 (app.js + style.css)。
Z 盘原项目 (Z:\web\FTP_ObjectStorage\上海新迪3D\dev) 未动 —— 生产环境 (3d.ruiguo.wang)
由 Z 盘 FTP 同步, 需要你们自己在 Z 盘那套部署文件里应用同样的两处改动:
【改动 1】app.js, 约 2075 行 "URL 参数自动加载" 注释块处, 把:
const EMBED_MODE = new URLSearchParams(location.search).get('embed') === '1';
if (EMBED_MODE) document.body.classList.add('embed');
改成 (加两行):
const EMBED_MODE = new URLSearchParams(location.search).get('embed') === '1';
if (EMBED_MODE) document.body.classList.add('embed');
const MIN_MENU = new URLSearchParams(location.search).get('menu') === 'min';
if (MIN_MENU) document.body.classList.add('menu-min');
【改动 2】style.css, "body.embed #topbar" 规则之后追加 (含隐藏模型树):
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; }
【改动 3】index.html, <body> 之后紧接一段同步脚本 (首帧渲染前应用样式, 消除
加载瞬间完整界面闪烁; 2026-08-24 补丁):
<script>
(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>
【目录站侧】OnebotCatalog\web\app.js 的 iframe 地址已带 &menu=min (本地已改),
生产发布时同样带上即可。独立打开查看器 (不带 menu=min) 时菜单保持原样, 互不影响。