11b606bd39
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
1176 lines
39 KiB
JavaScript
1176 lines
39 KiB
JavaScript
/**
|
||
* 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');
|
||
}
|
||
|
||
// ═══════════════════════════════════════
|
||
// 尺寸标注 Overlay(CSS + 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;
|
||
}
|
||
}
|
||
}
|