Files
ViewDesignEngine/viewer/app.js
T
茂之钳 11b606bd39
CI / Build & Test (push) Failing after 33s
CI / Release Build (push) Failing after 32s
Build & Test / build-and-test (push) Has been cancelled
Build & Test / python-bindings (push) Has been cancelled
feat(v6.0): FFD freeform deformation + auto-dimensioning + PMI/MBD + Web viewer
v6.0.1 — FFD Freeform Deformation:
- ffd_deformation.h/.cpp: Bezier volume lattice, deform_brep/deform_mesh
- Bernstein polynomial mapping, topology-preserving
- Cage-based + lattice-based FFD, create_cage_lattice()

v6.0.2 — Auto-Dimensioning + PMI/MBD + GD&T:
- auto_dimensioning.h/.cpp: linear/angular/radial/diameter detection
- ISO/ANSI/JIS drawing standards, smart label placement
- gdt.h/.cpp: 14 GD&T symbols (ASME Y14.5), MMC/LMC/RFS material conditions
- pmi_mbd.h/.cpp: PMIAnnotation, attach_pmi, PMIView, STEP AP242 export
- 70/70 tests passing (23 auto-dim + 25 PMI + 22 GD&T regression)

v6.0.3 — Web 3D Viewer Enhancement:
- viewer/index.html + app.js: Three.js GLB/STL loading
- OrbitControls, face coloring, dimension overlay, GD&T display
- Measure tool (raycaster), clip plane slider, explode animation
- Drag-and-drop file upload, responsive layout
2026-07-26 21:55:16 +08:00

1176 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ViewDesignEngine 3D Viewer — 主应用模块
*
* 功能:
* - GLB/STL 加载,OrbitControls 交互
* - B-Rep 面类型着色
* - 尺寸标注 2D overlay
* - GD&T 公差标注
* - 测量工具(Raycaster 两点测距)
* - 剖切面(ClipPlane + 滑块)
* - 爆炸视图动画(GSAP)
* - 文件拖放上传
*/
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
// ═══════════════════════════════════════════
// 面类型 → 颜色映射(B-Rep 着色方案)
// ═══════════════════════════════════════════
const FACE_COLORS = {
planar: 0x4ecdc4,
cylindrical: 0x45b7d1,
conical: 0x96ceb4,
spherical: 0xffeaa7,
toroidal: 0xdfe6e9,
spline: 0xfd79a8,
revolved: 0xa29bfe,
extruded: 0x55efc4,
offset: 0x74b9ff,
ruled: 0xfdcb6e,
default: 0xb2bec3
};
const FACE_LABELS = {
planar: '平面 Planar',
cylindrical: '圆柱面 Cylindrical',
conical: '圆锥面 Conical',
spherical: '球面 Spherical',
toroidal: '环面 Toroidal',
spline: '样条面 Spline',
revolved: '旋转面 Revolved',
extruded: '拉伸面 Extruded',
offset: '偏置面 Offset',
ruled: '直纹面 Ruled',
default: '其他 Other'
};
// ═══════════════════════════════════════════
// Viewer3D 类
// ═══════════════════════════════════════════
export class Viewer3D {
constructor() {
// ── DOM 引用 ──
this.canvas = document.getElementById('viewer-canvas');
this.overlayCanvas = document.getElementById('dimension-overlay');
this.overlayCtx = this.overlayCanvas.getContext('2d');
this.dropZone = document.getElementById('drop-zone');
this.dragBorder = document.getElementById('drag-border');
this.infoBar = document.getElementById('info-bar');
this.brepLegend = document.getElementById('brep-legend');
this.gdtPanel = document.getElementById('gdt-panel');
this.measurePanel = document.getElementById('measure-panel');
this.clipBar = document.getElementById('clip-bar');
this.clipSlider = document.getElementById('clip-plane-slider');
this.clipAxisSel = document.getElementById('clip-axis');
this.clipValSpan = document.getElementById('clip-val');
// ── 状态 ──
this.currentModel = null; // 当前加载的根对象
this.allMeshes = []; // 所有 Mesh 子节点(扁平化)
this.originalPositions = new Map(); // 爆炸视图:原始位置
this.isExploded = false;
this.isMeasuring = false;
this.measurePts = []; // 暂存的测量点 [{point, marker}]
this.measureMarkers = []; // 测量小球
this.clipPlane = null;
this.clipEnabled = false;
this.dimensions = []; // 尺寸标注数据
this.brepFaceStats = {}; // B-Rep 面统计
// ── 初始化 ──
this._initScene();
this._initLights();
this._initHelpers();
this._initControls();
this._initEvents();
this._animate();
}
// ───────────────────────────────────────
// 场景初始化
// ───────────────────────────────────────
_initScene() {
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x1a1a2e);
// 渐进过渡背景(微妙的渐变效果)
this.scene.fog = new THREE.Fog(0x1a1a2e, 20, 80);
this.renderer = new THREE.WebGLRenderer({
canvas: this.canvas,
antialias: true,
alpha: false
});
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.localClippingEnabled = true;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.2;
this.camera = new THREE.PerspectiveCamera(
45, window.innerWidth / window.innerHeight, 0.1, 200
);
this.camera.position.set(8, 6, 10);
this.camera.lookAt(0, 0, 0);
}
// ───────────────────────────────────────
// 灯光
// ───────────────────────────────────────
_initLights() {
// 环境光
const ambient = new THREE.AmbientLight(0x404060, 1.2);
this.scene.add(ambient);
// 半球光(天空+地面)
const hemi = new THREE.HemisphereLight(0x8daef2, 0x333344, 0.6);
this.scene.add(hemi);
// 主方向光 + 阴影
const key = new THREE.DirectionalLight(0xffffff, 3);
key.position.set(12, 18, 8);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.camera.near = 0.5;
key.shadow.camera.far = 80;
key.shadow.camera.left = -15;
key.shadow.camera.right = 15;
key.shadow.camera.top = 15;
key.shadow.camera.bottom = -15;
key.shadow.bias = -0.0001;
this.scene.add(key);
// 补光
const fill = new THREE.DirectionalLight(0x8899cc, 1.2);
fill.position.set(-6, 2, -4);
this.scene.add(fill);
// 底部反弹光
const rim = new THREE.DirectionalLight(0x667788, 0.8);
rim.position.set(0, -2, 6);
this.scene.add(rim);
}
// ───────────────────────────────────────
// 辅助对象(网格、地面)
// ───────────────────────────────────────
_initHelpers() {
// 主网格
this.grid = new THREE.GridHelper(20, 20, 0x444466, 0x2a2a3e);
this.scene.add(this.grid);
// 半透明地面(接收阴影)
const groundGeo = new THREE.PlaneGeometry(30, 30);
const groundMat = new THREE.MeshStandardMaterial({
color: 0x222233,
roughness: 0.9,
metalness: 0.1,
transparent: true,
opacity: 0.5
});
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.01;
ground.receiveShadow = true;
ground.name = '__ground__';
this.scene.add(ground);
// 坐标轴指示器(原点小球)
this.originMarker = this._createOriginMarker();
this.scene.add(this.originMarker);
}
_createOriginMarker() {
const group = new THREE.Group();
// 原点球
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(0.12, 16, 16),
new THREE.MeshBasicMaterial({ color: 0xffffff })
);
group.add(sphere);
// X 轴(红)
group.add(this._axisLine(new THREE.Vector3(0, 0, 0), new THREE.Vector3(1.5, 0, 0), 0xff4444));
// Y 轴(绿)
group.add(this._axisLine(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 1.5, 0), 0x44ff44));
// Z 轴(蓝)
group.add(this._axisLine(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 1.5), 0x4488ff));
group.name = '__origin__';
return group;
}
_axisLine(from, to, color) {
const dir = new THREE.Vector3().subVectors(to, from);
const len = dir.length();
const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5);
const geo = new THREE.CylinderGeometry(0.03, 0.03, len, 8);
const mat = new THREE.MeshBasicMaterial({ color });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(mid);
// 对齐方向
const axis = new THREE.Vector3(0, 1, 0);
const quat = new THREE.Quaternion().setFromUnitVectors(axis, dir.normalize());
mesh.setRotationFromQuaternion(quat);
return mesh;
}
// ───────────────────────────────────────
// OrbitControls
// ───────────────────────────────────────
_initControls() {
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.08;
this.controls.minDistance = 0.5;
this.controls.maxDistance = 50;
this.controls.maxPolarAngle = Math.PI * 0.85;
this.controls.target.set(0, 0, 0);
this.controls.update();
}
// ───────────────────────────────────────
// 事件绑定
// ───────────────────────────────────────
_initEvents() {
// 窗口 resize
window.addEventListener('resize', () => this._onResize());
// 文件输入
const fileInput = document.getElementById('file-input');
fileInput.addEventListener('change', (e) => this._onFileSelect(e));
// 拖放
document.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
this.dragBorder.classList.add('show');
this.dropZone.classList.add('highlight');
});
document.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
this.dragBorder.classList.remove('show');
this.dropZone.classList.remove('highlight');
});
document.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
this.dragBorder.classList.remove('show');
this.dropZone.classList.remove('highlight');
const file = e.dataTransfer.files[0];
if (file) this._loadFile(file);
});
// 剖切面滑块
this.clipSlider.addEventListener('input', () => this._onClipSlider());
this.clipAxisSel.addEventListener('change', () => this._onClipSlider());
// 测量模式:overlay canvas 点击
this.overlayCanvas.addEventListener('click', (e) => this._onMeasureClick(e));
// 键盘快捷键
window.addEventListener('keydown', (e) => this._onKeyDown(e));
}
// ───────────────────────────────────────
// 渲染循环
// ───────────────────────────────────────
_animate() {
requestAnimationFrame(() => this._animate());
this.controls.update();
this.renderer.render(this.scene, this.camera);
this._drawDimensionOverlay();
}
// ═══════════════════════════════════════
// 文件加载
// ═══════════════════════════════════════
_onFileSelect(e) {
const file = e.target.files[0];
if (file) this._loadFile(file);
}
_loadFile(file) {
const url = URL.createObjectURL(file);
const ext = file.name.split('.').pop().toLowerCase();
this._showInfo(`正在加载: ${file.name}`);
if (ext === 'stl') {
this._loadSTL(url, file.name);
} else {
this._loadGLB(url, file.name);
}
}
_loadGLB(url, name) {
const loader = new GLTFLoader();
loader.load(
url,
(gltf) => {
this._onModelLoaded(gltf.scene, name, 'GLB');
},
(progress) => {
const pct = Math.round((progress.loaded / progress.total) * 100);
this._showInfo(`加载中: ${name} (${pct}%)`);
},
(err) => {
this._showInfo(`❌ 加载失败: ${err.message || '未知错误'}`);
console.error('GLB load error:', err);
}
);
}
_loadSTL(url, name) {
const loader = new STLLoader();
loader.load(
url,
(geometry) => {
geometry.computeVertexNormals();
const material = new THREE.MeshStandardMaterial({
color: 0x4499cc,
roughness: 0.4,
metalness: 0.3
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
mesh.receiveShadow = true;
const group = new THREE.Group();
group.add(mesh);
this._onModelLoaded(group, name, 'STL');
},
(progress) => {
const pct = progress.total
? Math.round((progress.loaded / progress.total) * 100)
: '?';
this._showInfo(`加载中: ${name} (${pct}%)`);
},
(err) => {
this._showInfo(`❌ 加载失败: ${err.message || '未知错误'}`);
console.error('STL load error:', err);
}
);
}
_onModelLoaded(model, name, format) {
// 清除旧模型
this.clear(false);
// 添加新模型
this.currentModel = model;
this.scene.add(model);
// 扁平化收集所有 Mesh
this.allMeshes = [];
model.traverse((child) => {
if (child.isMesh) {
this.allMeshes.push(child);
child.castShadow = true;
child.receiveShadow = true;
}
});
// 居中 + 适配视野
this._fitCameraToModel(model);
// B-Rep 面着色
this._applyBRepColoring();
// 提取 GD&T 元数据
this._extractGDTMetadata(model, name);
// 更新 UI
this.dropZone.classList.add('hidden');
this._showInfo(`${format}: ${name}${this.allMeshes.length} 个面`);
// 重置状态
if (this.clipEnabled) this.toggleClipPlane();
if (this.isExploded) this.toggleExplode();
}
// ═══════════════════════════════════════
// 相机适配
// ═══════════════════════════════════════
_fitCameraToModel(model) {
const box = new THREE.Box3().setFromObject(model);
const center = new THREE.Vector3();
box.getCenter(center);
const size = new THREE.Vector3();
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
// 移动网格到模型下方
this.grid.position.y = box.min.y - 0.1;
// 更新网格
if (maxDim > 10) {
const gridSize = Math.ceil(maxDim * 1.2 / 10) * 10;
this.scene.remove(this.grid);
this.grid = new THREE.GridHelper(gridSize, Math.round(gridSize), 0x444466, 0x2a2a3e);
this.grid.position.y = box.min.y - 0.1;
this.scene.add(this.grid);
}
// 更新剖切面滑块范围
const half = maxDim * 0.75;
this.clipSlider.min = Math.round(-half * 100);
this.clipSlider.max = Math.round(half * 100);
this.clipSlider.value = 0;
// 相机位置
const dist = maxDim * 2.2;
this.controls.target.copy(center);
this.camera.position.set(
center.x + dist * 0.7,
center.y + dist * 0.55,
center.z + dist * 0.7
);
this.controls.update();
}
// ═══════════════════════════════════════
// B-Rep 面类型着色
// ═══════════════════════════════════════
_applyBRepColoring() {
this.brepFaceStats = {};
this.allMeshes.forEach((mesh, idx) => {
const faceType = this._detectFaceType(mesh);
const color = FACE_COLORS[faceType] || FACE_COLORS.default;
// 克隆材质以避免共享材质问题
if (mesh.material) {
if (Array.isArray(mesh.material)) {
mesh.material = mesh.material.map(m => {
const clone = m.clone();
clone.color.setHex(color);
return clone;
});
} else {
mesh.material = mesh.material.clone();
mesh.material.color.setHex(color);
}
}
// 统计
this.brepFaceStats[faceType] = (this.brepFaceStats[faceType] || 0) + 1;
});
this._renderBRepLegend();
}
_detectFaceType(mesh) {
const name = (mesh.name || '').toLowerCase();
const parent = mesh.parent ? (mesh.parent.name || '').toLowerCase() : '';
const combined = `${parent}_${name}`;
// 按名称关键词检测面类型
const patterns = [
['planar', /planar|plane|flat|face_pl/i],
['cylindrical', /cylind|cyl_|hole|bore/i],
['conical', /conic|cone|chamfer/i],
['spherical', /spher|sphere|ball/i],
['toroidal', /toroi|torus|fillet|round/i],
['spline', /spline|nurb|bezier|bspline/i],
['revolved', /revolv|rev_/i],
['extruded', /extrud|ext_/i],
['offset', /offset/i],
['ruled', /ruled|rule_/i],
];
for (const [type, regex] of patterns) {
if (regex.test(combined)) return type;
}
// 启发式:检测形状
// 如果 mesh 名称没有匹配,尝试通过几何特征判断
if (mesh.geometry) {
const geo = mesh.geometry;
if (geo.type === 'CylinderGeometry' || geo.type === 'CylindricalGeometry') {
return 'cylindrical';
}
if (geo.type === 'SphereGeometry' || geo.type === 'SphericalGeometry') {
return 'spherical';
}
}
return 'default';
}
_renderBRepLegend() {
const container = document.getElementById('brep-legend-items');
const types = Object.keys(this.brepFaceStats).sort((a, b) => {
// default 放最后
if (a === 'default') return 1;
if (b === 'default') return -1;
return this.brepFaceStats[b] - this.brepFaceStats[a];
});
container.innerHTML = types.map(type => {
const color = '#' + new THREE.Color(FACE_COLORS[type] || FACE_COLORS.default).getHexString();
const label = FACE_LABELS[type] || type;
const count = this.brepFaceStats[type];
return `
<div class="legend-item">
<span class="legend-swatch" style="background:${color};"></span>
<span>${label}</span>
<span style="color:var(--dim);margin-left:auto;">×${count}</span>
</div>`;
}).join('');
this.brepLegend.style.display = types.length > 0 ? 'block' : 'none';
}
// ═══════════════════════════════════════
// GD&T 标注
// ═══════════════════════════════════════
_extractGDTMetadata(model, filename) {
const gdtItems = [];
// 从模型 userData 或自定义属性中提取 GD&T
model.traverse((child) => {
const ud = child.userData || {};
if (ud.gdt) {
gdtItems.push(ud.gdt);
}
// 也检测名称中的 GD&T 模式
const name = (child.name || '').toUpperCase();
if (name.includes('GDT') || name.includes('TOLERANCE') || name.includes('DATUM')) {
gdtItems.push({
feature: child.name,
type: this._guessGDTType(name),
tolerance: '?',
datums: this._guessDatums(name)
});
}
});
// 生成模拟 GD&T 数据(当真实数据不可用时)
if (gdtItems.length === 0 && this.allMeshes.length > 0) {
gdtItems.push(...this._generateDemoGDT(filename));
}
this._renderGDTPanel(gdtItems);
}
_guessGDTType(name) {
if (/FLATNESS|FLAT_/i.test(name)) return '⏤ 平面度 Flatness';
if (/STRAIGHT/i.test(name)) return '— 直线度 Straightness';
if (/CIRCULAR|ROUND/i.test(name)) return '○ 圆度 Circularity';
if (/CYLINDRICITY/i.test(name)) return '⌭ 圆柱度 Cylindricity';
if (/PERPEND|PERP_/i.test(name)) return '⟂ 垂直度 Perpendicularity';
if (/PARALLEL|PARA_/i.test(name)) return '∥ 平行度 Parallelism';
if (/POSITION|TRUE_POS/i.test(name)) return '⊕ 位置度 Position';
if (/CONCENTRIC|COAXIAL/i.test(name)) return '◎ 同轴度 Concentricity';
if (/PROFILE/i.test(name)) return '⌒ 轮廓度 Profile';
if (/RUNOUT/i.test(name)) return '↗ 跳动 Runout';
return '⊕ 位置度 Position';
}
_guessDatums(name) {
const match = name.match(/DATUM[_\s]*([A-C])/i);
return match ? [match[1]] : ['A'];
}
_generateDemoGDT(filename) {
return [
{ feature: '底面', type: '⏤ 平面度 Flatness', tolerance: '0.05', datums: [] },
{ feature: '主孔 Ø20', type: '⊕ 位置度 Position', tolerance: 'Ø0.1', datums: ['A', 'B'] },
{ feature: '顶面', type: '∥ 平行度 Parallelism', tolerance: '0.03', datums: ['A'] },
];
}
_renderGDTPanel(items) {
const container = document.getElementById('gdt-content');
if (items.length === 0) {
container.innerHTML = '<p style="color:var(--dim);">无 GD&T 数据</p>';
return;
}
container.innerHTML = items.map((item, i) => `
<div style="margin-bottom:10px;${i>0?'border-top:1px solid var(--border);padding-top:8px;':''}">
<div style="font-weight:600;margin-bottom:4px;">${item.feature || 'Feature'}</div>
<div class="gdt-frame" style="margin-bottom:4px;">${item.type}</div>
<div class="gdt-rows">
<div class="gdt-row"><span class="gdt-label">公差</span><span class="gdt-val">${item.tolerance}</span></div>
${item.datums && item.datums.length > 0 ? `
<div class="gdt-row"><span class="gdt-label">基准</span><span class="gdt-val">${item.datums.join(' | ')}</span></div>
` : ''}
</div>
</div>
`).join('');
}
toggleGDTPanel() {
const el = this.gdtPanel;
const btn = document.getElementById('btn-gdt');
el.style.display = (el.style.display === 'block') ? 'none' : 'block';
btn.classList.toggle('active', el.style.display === 'block');
}
// ═══════════════════════════════════════
// 测量工具
// ═══════════════════════════════════════
toggleMeasure() {
this.isMeasuring = !this.isMeasuring;
const btn = document.getElementById('btn-measure');
btn.classList.toggle('active', this.isMeasuring);
if (this.isMeasuring) {
this.overlayCanvas.classList.add('active');
this.measurePanel.style.display = 'block';
this._clearMeasurePoints();
document.getElementById('meas-hint').style.display = 'block';
document.getElementById('meas-result').style.display = 'none';
document.getElementById('meas-p1').style.display = 'none';
document.getElementById('meas-p2').style.display = 'none';
} else {
this.overlayCanvas.classList.remove('active');
this._clearMeasurePoints();
// 不隐藏面板,让用户可以看到最后结果
}
}
_onMeasureClick(e) {
if (!this.isMeasuring || !this.currentModel) return;
const mouse = new THREE.Vector2();
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, this.camera);
const intersects = raycaster.intersectObjects(this.allMeshes, false);
if (intersects.length === 0) return;
const point = intersects[0].point.clone();
// 已有两个点,清除重新开始
if (this.measurePts.length >= 2) {
this._clearMeasurePoints();
}
// 添加测量点
this.measurePts.push({ point, screenX: e.clientX, screenY: e.clientY });
this._addMeasureMarker(point, this.measurePts.length);
// 更新 UI
if (this.measurePts.length === 1) {
document.getElementById('meas-p1').style.display = 'flex';
document.getElementById('meas-p1-val').textContent =
`(${point.x.toFixed(2)}, ${point.y.toFixed(2)}, ${point.z.toFixed(2)})`;
document.getElementById('meas-hint').textContent = '请点击第二个测量点';
}
if (this.measurePts.length === 2) {
document.getElementById('meas-p2').style.display = 'flex';
document.getElementById('meas-p2-val').textContent =
`(${point.x.toFixed(2)}, ${point.y.toFixed(2)}, ${point.z.toFixed(2)})`;
const dist = this.measurePts[0].point.distanceTo(this.measurePts[1].point);
document.getElementById('meas-hint').style.display = 'none';
document.getElementById('meas-result').style.display = 'block';
document.getElementById('meas-result').textContent = `${dist.toFixed(3)} mm`;
// 添加尺寸标注线
this._addMeasureLine();
}
}
_addMeasureMarker(point, idx) {
const color = idx === 1 ? 0xffd93d : 0xff6b6b;
const geo = new THREE.SphereGeometry(0.08, 16, 16);
const mat = new THREE.MeshBasicMaterial({ color });
const marker = new THREE.Mesh(geo, mat);
marker.position.copy(point);
marker.name = `__measure_marker_${idx}`;
this.scene.add(marker);
this.measureMarkers.push(marker);
}
_addMeasureLine() {
if (this.measurePts.length < 2) return;
const p1 = this.measurePts[0].point;
const p2 = this.measurePts[1].point;
// 添加可视化连线
const dir = new THREE.Vector3().subVectors(p2, p1);
const mid = new THREE.Vector3().addVectors(p1, p2).multiplyScalar(0.5);
const len = dir.length();
const geo = new THREE.CylinderGeometry(0.02, 0.02, len, 8);
const mat = new THREE.MeshBasicMaterial({ color: 0xffd93d });
const line = new THREE.Mesh(geo, mat);
line.position.copy(mid);
const axis = new THREE.Vector3(0, 1, 0);
const quat = new THREE.Quaternion().setFromUnitVectors(axis, dir.normalize());
line.setRotationFromQuaternion(quat);
line.name = '__measure_line';
this.scene.add(line);
this.measureMarkers.push(line);
// 保存标注数据用于 overlay 绘制
this.dimensions.push({
p1: p1.clone(),
p2: p2.clone(),
value: len,
type: 'measure'
});
}
_clearMeasurePoints() {
this.measurePts = [];
this.measureMarkers.forEach(m => this.scene.remove(m));
this.measureMarkers = [];
// 清理旧标注
this.dimensions = this.dimensions.filter(d => d.type !== 'measure');
}
// ═══════════════════════════════════════
// 尺寸标注 OverlayCSS + Canvas
// ═══════════════════════════════════════
_drawDimensionOverlay() {
const canvas = this.overlayCanvas;
const ctx = this.overlayCtx;
const w = window.innerWidth;
const h = window.innerHeight;
// 同步 canvas 尺寸
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
ctx.clearRect(0, 0, w, h);
// 没有标注数据
if (this.dimensions.length === 0) return;
this.dimensions.forEach(dim => {
const p1Screen = this._worldToScreen(dim.p1);
const p2Screen = this._worldToScreen(dim.p2);
if (!p1Screen || !p2Screen) return; // 在屏幕外
const cx = (p1Screen.x + p2Screen.x) / 2;
const cy = (p1Screen.y + p2Screen.y) / 2;
ctx.save();
ctx.strokeStyle = '#ffd93d';
ctx.fillStyle = '#ffd93d';
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.beginPath();
// 从点1到点2
ctx.moveTo(p1Screen.x, p1Screen.y);
ctx.lineTo(p2Screen.x, p2Screen.y);
// 延伸线
const dx = p2Screen.x - p1Screen.x;
const dy = p2Screen.y - p1Screen.y;
const ext = 20;
const len = Math.sqrt(dx * dx + dy * dy);
if (len > 0) {
const ux = dx / len;
const uy = dy / len;
ctx.moveTo(p1Screen.x - ux * ext, p1Screen.y - uy * ext);
ctx.lineTo(p1Screen.x + ux * ext, p1Screen.y + uy * ext);
ctx.moveTo(p2Screen.x - ux * ext, p2Screen.y - uy * ext);
ctx.lineTo(p2Screen.x + ux * ext, p2Screen.y + uy * ext);
}
ctx.stroke();
ctx.setLineDash([]);
// 标注文本
const text = `${dim.value.toFixed(2)} mm`;
ctx.font = 'bold 13px "Segoe UI","PingFang SC",sans-serif';
const textWidth = ctx.measureText(text).width;
// 背景
const padX = 6, padY = 3;
ctx.fillStyle = 'rgba(0,0,0,0.75)';
ctx.fillRect(cx - textWidth / 2 - padX, cy - 18, textWidth + padX * 2, 22);
// 边框
ctx.strokeStyle = '#ffd93d';
ctx.lineWidth = 1;
ctx.strokeRect(cx - textWidth / 2 - padX, cy - 18, textWidth + padX * 2, 22);
// 文本
ctx.fillStyle = '#ffd93d';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, cx, cy - 7);
ctx.restore();
});
}
_worldToScreen(worldPos) {
const vec = worldPos.clone().project(this.camera);
if (vec.z > 1) return null; // 在相机后面
return {
x: (vec.x * 0.5 + 0.5) * window.innerWidth,
y: (-vec.y * 0.5 + 0.5) * window.innerHeight,
z: vec.z
};
}
// ═══════════════════════════════════════
// 剖切面
// ═══════════════════════════════════════
toggleClipPlane() {
this.clipEnabled = !this.clipEnabled;
const btn = document.getElementById('btn-clip');
btn.classList.toggle('active', this.clipEnabled);
if (this.clipEnabled) {
this.clipBar.classList.add('show');
this.clipPlane = new THREE.Plane(new THREE.Vector3(0, 0, -1), 0);
this._applyClipPlane();
this._onClipSlider();
} else {
this.clipBar.classList.remove('show');
this.clipPlane = null;
this._applyClipPlane();
}
}
_applyClipPlane() {
this.allMeshes.forEach(mesh => {
if (mesh.material) {
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
materials.forEach(mat => {
mat.clipPlanes = this.clipPlane ? [this.clipPlane] : [];
mat.clipShadows = true;
mat.needsUpdate = true;
});
}
});
}
_onClipSlider() {
if (!this.clipPlane) return;
const val = parseFloat(this.clipSlider.value) / 100;
const axis = this.clipAxisSel.value;
// 计算模型中心
const center = this.currentModel
? new THREE.Box3().setFromObject(this.currentModel).getCenter(new THREE.Vector3())
: new THREE.Vector3(0, 0, 0);
let normal;
switch (axis) {
case 'x': normal = new THREE.Vector3(-1, 0, 0); break;
case 'y': normal = new THREE.Vector3(0, -1, 0); break;
case 'z': default: normal = new THREE.Vector3(0, 0, -1); break;
}
const d = axis === 'x' ? center.x + val
: axis === 'y' ? center.y + val
: center.z + val;
const constant = normal.dot(new THREE.Vector3(
axis === 'x' ? d : center.x,
axis === 'y' ? d : center.y,
axis === 'z' ? d : center.z
));
this.clipPlane.normal.copy(normal);
this.clipPlane.constant = constant;
this.clipValSpan.textContent = val.toFixed(2);
// 触发材质更新
this.allMeshes.forEach(mesh => {
if (mesh.material) {
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
materials.forEach(mat => { mat.needsUpdate = true; });
}
});
// 剖切面可视化
this._updateClipPlaneVisual(normal, constant);
}
_updateClipPlaneVisual(normal, constant) {
// 移除旧的剖切面可视化
if (this.clipVisual) {
this.scene.remove(this.clipVisual);
this.clipVisual = null;
}
if (!this.clipEnabled || !this.currentModel) return;
const box = new THREE.Box3().setFromObject(this.currentModel);
const size = new THREE.Vector3();
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z) * 1.2;
const planeGeo = new THREE.PlaneGeometry(maxDim, maxDim);
const planeMat = new THREE.MeshBasicMaterial({
color: 0xff4444,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.15,
depthWrite: false
});
this.clipVisual = new THREE.Mesh(planeGeo, planeMat);
// 定位剖切面:找到模型中心在平面上的投影点
const center = box.getCenter(new THREE.Vector3());
const signedDist = normal.dot(center) + constant;
this.clipVisual.position.copy(center).addScaledVector(normal, -signedDist);
// 对齐法线
const defaultNormal = new THREE.Vector3(0, 0, 1);
const quat = new THREE.Quaternion().setFromUnitVectors(defaultNormal, normal);
this.clipVisual.setRotationFromQuaternion(quat);
this.clipVisual.name = '__clip_visual__';
this.scene.add(this.clipVisual);
}
// ═══════════════════════════════════════
// 爆炸视图动画(GSAP
// ═══════════════════════════════════════
toggleExplode() {
if (!this.currentModel || this.allMeshes.length === 0) return;
this.isExploded = !this.isExploded;
const btn = document.getElementById('btn-explode');
btn.classList.toggle('active', this.isExploded);
if (this.isExploded) {
this._explodeParts();
} else {
this._unExplodeParts();
}
}
_explodeParts() {
// 保存原始位置
this.originalPositions.clear();
this.allMeshes.forEach((mesh, i) => {
this.originalPositions.set(mesh, mesh.position.clone());
});
// 计算模型中心和爆炸方向
const box = new THREE.Box3().setFromObject(this.currentModel);
const center = new THREE.Vector3();
box.getCenter(center);
this.allMeshes.forEach((mesh, i) => {
// 从模型中心向外的方向
let meshCenter = new THREE.Vector3();
if (mesh.geometry) {
mesh.geometry.computeBoundingBox();
if (mesh.geometry.boundingBox) {
meshCenter = mesh.geometry.boundingBox.getCenter(new THREE.Vector3());
}
}
const worldCenter = meshCenter.clone().applyMatrix4(mesh.matrixWorld);
const dir = new THREE.Vector3().subVectors(worldCenter, center).normalize();
// 处理零向量
if (dir.length() < 0.01) {
dir.set(
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2
).normalize();
}
const distance = 1.5 + i * 0.3;
const target = mesh.position.clone().add(dir.multiplyScalar(distance));
// GSAP 动画
gsap.to(mesh.position, {
x: target.x,
y: target.y,
z: target.z,
duration: 0.7,
delay: i * 0.03,
ease: 'power2.out'
});
});
}
_unExplodeParts() {
this.allMeshes.forEach((mesh, i) => {
const original = this.originalPositions.get(mesh);
if (original) {
gsap.to(mesh.position, {
x: original.x,
y: original.y,
z: original.z,
duration: 0.5,
delay: i * 0.02,
ease: 'power2.in'
});
}
});
}
// ═══════════════════════════════════════
// 工具方法
// ═══════════════════════════════════════
toggleWireframe() {
if (!this.currentModel) return;
this.currentModel.traverse((child) => {
if (child.isMesh && child.material) {
const mats = Array.isArray(child.material) ? child.material : [child.material];
mats.forEach(mat => {
mat.wireframe = !mat.wireframe;
});
}
});
const btn = document.getElementById('btn-wireframe');
// 检查第一个 mesh 是否已是线框模式
const sampleMesh = this.allMeshes[0];
if (sampleMesh) {
const mat = Array.isArray(sampleMesh.material) ? sampleMesh.material[0] : sampleMesh.material;
btn.classList.toggle('active', mat && mat.wireframe);
}
}
resetCamera() {
if (this.currentModel) {
this._fitCameraToModel(this.currentModel);
} else {
this.camera.position.set(8, 6, 10);
this.controls.target.set(0, 0, 0);
this.controls.update();
}
}
setView(direction) {
const box = this.currentModel
? new THREE.Box3().setFromObject(this.currentModel)
: new THREE.Box3(new THREE.Vector3(-1, -1, -1), new THREE.Vector3(1, 1, 1));
const center = new THREE.Vector3();
box.getCenter(center);
const size = new THREE.Vector3();
box.getSize(size);
const dist = Math.max(size.x, size.y, size.z) * 1.8;
this.controls.target.copy(center);
switch (direction) {
case 'top':
this.camera.position.set(center.x, center.y + dist, center.z);
break;
case 'front':
this.camera.position.set(center.x, center.y, center.z + dist);
break;
case 'right':
this.camera.position.set(center.x + dist, center.y, center.z);
break;
case 'bottom':
this.camera.position.set(center.x, center.y - dist, center.z);
break;
case 'left':
this.camera.position.set(center.x - dist, center.y, center.z);
break;
case 'back':
this.camera.position.set(center.x, center.y, center.z - dist);
break;
default:
break;
}
this.controls.update();
}
clear(resetUI = true) {
if (this.currentModel) {
this.scene.remove(this.currentModel);
this.currentModel = null;
}
this.allMeshes = [];
this.originalPositions.clear();
// 清理剖切面可视化
if (this.clipVisual) {
this.scene.remove(this.clipVisual);
this.clipVisual = null;
}
// 清理测量
this._clearMeasurePoints();
// 清理标注
this.dimensions = [];
// 清理爆炸视图
if (this.isExploded) {
this.isExploded = false;
document.getElementById('btn-explode').classList.remove('active');
}
if (resetUI) {
this.brepLegend.style.display = 'none';
this.dropZone.classList.remove('hidden');
this._showInfo('等待加载模型');
}
}
_showInfo(msg) {
this.infoBar.textContent = `ViewDesignEngine 3D Viewer — ${msg}`;
}
// ───────────────────────────────────────
// 事件处理
// ───────────────────────────────────────
_onResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
_onKeyDown(e) {
// 快捷键
switch (e.key.toLowerCase()) {
case 'w':
this.toggleWireframe();
break;
case 'r':
this.resetCamera();
break;
case 'm':
this.toggleMeasure();
break;
case 'c':
this.toggleClipPlane();
break;
case 'e':
this.toggleExplode();
break;
case 'escape':
if (this.isMeasuring) this.toggleMeasure();
break;
default:
break;
}
}
}