This commit is contained in:
2026-05-11 11:35:15 +08:00
parent 5e1d475a22
commit a68b9ca57f
22 changed files with 432 additions and 11341 deletions
-831
View File
@@ -1,831 +0,0 @@
# utils/html_generator.py
from pathlib import Path
from typing import Dict, Any, Optional
from datetime import datetime
from utils.logger import get_logger
logger = get_logger(__name__)
try:
import orjson
def _json_dumps(obj: Any) -> bytes:
return orjson.dumps(obj, option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS)
def _json_dumps_str(obj: Any) -> str:
return orjson.dumps(obj, option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS).decode("utf-8")
_JSON_FAST = True
except ImportError:
import json
def _json_dumps(obj: Any) -> bytes:
return json.dumps(obj, ensure_ascii=False).encode("utf-8")
def _json_dumps_str(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False)
_JSON_FAST = False
class HTMLGenerator:
"""HTML文件生成器 — 数据分离架构,Three.js 0.170 + PBR渲染"""
def __init__(self, output_dir: str = "./html_output"):
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
def generate_3d_viewer_html(self, stp_filename: str, data_filename: str) -> str:
"""生成3D可视化HTML页面 — 通过fetch异步加载companion JSON数据"""
cavity_html = self._build_cavity_info_panel_template()
html_content = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D模具几何可视化 - {stp_filename}</title>
<script type="importmap">
{{
"imports": {{
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}}
}}
</script>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ overflow: hidden; font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f0f2f5; }}
#container {{ position: relative; width: 100vw; height: 100vh; }}
#canvas {{ display: block; }}
#loading-overlay {{
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; background: rgba(240,242,245,0.95);
z-index: 100; transition: opacity 0.4s;
}}
#loading-overlay.hidden {{ opacity: 0; pointer-events: none; }}
.spinner {{
width: 48px; height: 48px; border: 3px solid rgba(0,0,0,0.1);
border-top-color: #4CAF50; border-radius: 50%; animation: spin 0.8s linear infinite;
}}
@keyframes spin {{ to {{ transform: rotate(360deg); }} }}
.loading-text {{ color: #666; margin-top: 16px; font-size: 14px; }}
#info-panel {{
position: absolute; top: 10px; left: 10px; background: rgba(255,255,255,0.92);
color: #333; padding: 12px 16px; border-radius: 10px; font-size: 13px;
max-width: 340px; backdrop-filter: blur(10px); border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}}
#info-panel h3 {{ margin: 0 0 6px 0; font-size: 14px; color: #2E7D32; }}
#info-panel .info-row {{ display: flex; justify-content: space-between; padding: 2px 0; }}
#info-panel .info-label {{ color: #888; }}
#info-panel .info-value {{ color: #111; font-weight: 500; }}
#cavity-info-panel {{
position: absolute; top: 10px; right: 10px; background: rgba(255,255,255,0.92);
color: #333; padding: 15px; border-radius: 10px; font-size: 12px;
max-width: 310px; backdrop-filter: blur(10px); border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
display: none;
}}
#cavity-info-panel h3 {{ margin: 0 0 10px 0; font-size: 14px; color: #E65100; }}
#cavity-info-panel .metric {{ display: flex; justify-content: space-between; padding: 3px 0; }}
#cavity-info-panel .metric-label {{ color: #888; }}
#cavity-info-panel .metric-value {{ color: #111; font-weight: 500; }}
#toolbar {{
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
display: flex; gap: 6px; background: rgba(255,255,255,0.92); padding: 8px 12px;
border-radius: 24px; backdrop-filter: blur(10px); border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
flex-wrap: wrap; justify-content: center;
}}
#toolbar button {{
background: rgba(0,0,0,0.04); color: #555; border: 1px solid rgba(0,0,0,0.1);
padding: 6px 14px; border-radius: 18px; cursor: pointer; font-size: 12px;
transition: all 0.2s; white-space: nowrap;
}}
#toolbar button:hover {{ background: rgba(0,0,0,0.1); color: #222; }}
#toolbar button.active {{ background: rgba(76,175,80,0.18); border-color: #4CAF50; color: #2E7D32; }}
#toolbar button.accent {{
background: rgba(255,87,34,0.15); border-color: #FF5722; color: #D84315;
font-weight: bold;
}}
#toolbar button.accent:hover {{ background: rgba(255,87,34,0.28); }}
#view-presets {{
position: absolute; bottom: 75px; left: 50%; transform: translateX(-50%);
display: flex; gap: 4px; background: rgba(255,255,255,0.88); padding: 6px 8px;
border-radius: 20px; backdrop-filter: blur(8px); border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 1px 8px rgba(0,0,0,0.06);
}}
#view-presets button {{
background: rgba(0,0,0,0.03); color: #777; border: none;
padding: 5px 10px; border-radius: 14px; cursor: pointer; font-size: 11px;
transition: all 0.2s;
}}
#view-presets button:hover {{ background: rgba(0,0,0,0.1); color: #222; }}
@media (max-width: 768px) {{
#info-panel {{ max-width: 240px; font-size: 11px; padding: 8px 12px; }}
#cavity-info-panel {{ max-width: 220px; font-size: 10px; padding: 10px; }}
#toolbar {{ gap: 3px; padding: 6px 8px; }}
#toolbar button {{ padding: 5px 10px; font-size: 10px; }}
#view-presets {{ bottom: 68px; }}
}}
</style>
</head>
<body>
<div id="container">
<canvas id="canvas"></canvas>
<div id="loading-overlay">
<div class="spinner"></div>
<div class="loading-text" id="loading-status">加载几何数据...</div>
</div>
<div id="info-panel">
<h3>📐 {stp_filename}</h3>
<div class="info-row"><span class="info-label">顶点</span><span class="info-value" id="info-verts">-</span></div>
<div class="info-row"><span class="info-label">三角面</span><span class="info-value" id="info-faces">-</span></div>
<div class="info-row"><span class="info-label">点云</span><span class="info-value" id="info-points">-</span></div>
<div class="info-row"><span class="info-label">体积</span><span class="info-value" id="info-vol">-</span></div>
</div>
{cavity_html}
<div id="view-presets">
<button onclick="setView('front')" title="前视">前</button>
<button onclick="setView('back')" title="后视">后</button>
<button onclick="setView('left')" title="左视">左</button>
<button onclick="setView('right')" title="右视">右</button>
<button onclick="setView('top')" title="俯视">俯</button>
<button onclick="setView('bottom')" title="仰视">仰</button>
<button onclick="setView('iso')" title="等轴测" style="font-weight:bold;color:#FF9800;">3D</button>
</div>
<div id="toolbar">
<button id="btn-product" class="active" onclick="toggleProduct()">产品</button>
<button id="btn-mold" class="active" onclick="toggleMold()">模具</button>
<button id="btn-parting" class="active" onclick="toggleParting()">分型面</button>
<button id="btn-pointcloud" onclick="togglePointcloud()">点云</button>
<button onclick="toggleWireframe()">线框</button>
<button onclick="resetView()">重置</button>
<button id="splitBtn" class="accent" onclick="splitMold()">分模拆分</button>
</div>
</div>
<script type="module">
import * as THREE from 'three';
import {{ OrbitControls }} from 'three/addons/controls/OrbitControls.js';
const DATA_URL = '{data_filename}';
let productMesh, cavityMesh, coreMesh, partingMesh, pointcloudMesh;
let productVisible = true, moldVisible = true, partingVisible = true, pointcloudVisible = false;
let isSplit = false, splitAnimId = null;
let sceneBox = null;
let cavityDataGlobal = null;
const renderer = new THREE.WebGLRenderer({{ canvas: document.getElementById('canvas'), antialias: true, alpha: true }});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf0f2f5);
scene.fog = new THREE.Fog(0xf0f2f5, 500, 3000);
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 10000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.12;
controls.minDistance = 1;
controls.maxDistance = 5000;
controls.target.set(0, 0, 0);
function setupLighting() {{
const ambient = new THREE.AmbientLight(0xccccdd, 4);
scene.add(ambient);
const keyLight = new THREE.DirectionalLight(0xffffff, 7);
keyLight.position.set(1, 1.2, 0.8);
keyLight.castShadow = true;
keyLight.shadow.mapSize.width = 2048;
keyLight.shadow.mapSize.height = 2048;
keyLight.shadow.camera.near = 0.5;
keyLight.shadow.camera.far = 500;
keyLight.shadow.bias = -0.0001;
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0xccddff, 3);
fillLight.position.set(-0.6, 0.3, -0.4);
scene.add(fillLight);
const rimLight = new THREE.DirectionalLight(0xffffff, 4);
rimLight.position.set(0, -0.3, -1);
scene.add(rimLight);
const bottomLight = new THREE.DirectionalLight(0x8899cc, 1.5);
bottomLight.position.set(0, -1, 0.2);
scene.add(bottomLight);
const pmremGenerator = new THREE.PMREMGenerator(renderer);
pmremGenerator.compileEquirectangularShader();
const envScene = new THREE.Scene();
envScene.background = new THREE.Color(0xddeeff);
const envMap = pmremGenerator.fromScene(envScene).texture;
scene.environment = envMap;
scene.background = new THREE.Color(0xf0f2f5);
}}
setupLighting();
const axesHelper = new THREE.AxesHelper(50);
scene.add(axesHelper);
const gridHelper = new THREE.GridHelper(400, 40, 0xccccdd, 0xe8e8f0);
scene.add(gridHelper);
function toFlatArray(data) {{
if (!Array.isArray(data)) return [];
if (data.length === 0) return [];
return Array.isArray(data[0]) ? data.flat(Infinity) : data;
}}
function normalizePositions(rawPositions, center) {{
const flat = toFlatArray(rawPositions);
if (!flat.length) return [];
const cx = Number(center[0] || 0), cy = Number(center[1] || 0), cz = Number(center[2] || 0);
const normalized = [];
for (let i = 0; i + 2 < flat.length; i += 3) {{
const x = Number(flat[i]), y = Number(flat[i + 1]), z = Number(flat[i + 2]);
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {{
normalized.push(x - cx, y - cy, z - cz);
}}
}}
return normalized;
}}
function computeBounds(rawPositionsList) {{
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
let hasPoint = false;
for (const raw of rawPositionsList) {{
const flat = toFlatArray(raw);
for (let i = 0; i + 2 < flat.length; i += 3) {{
const x = Number(flat[i]), y = Number(flat[i + 1]), z = Number(flat[i + 2]);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) continue;
hasPoint = true;
minX = Math.min(minX, x); minY = Math.min(minY, y); minZ = Math.min(minZ, z);
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); maxZ = Math.max(maxZ, z);
}}
}}
if (!hasPoint) return null;
return {{
center: [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2],
dimensions: [Math.max(maxX - minX, 1), Math.max(maxY - minY, 1), Math.max(maxZ - minZ, 1)],
}};
}}
function isValidIndexedGeometry(positions, indices) {{
if (!positions || !indices) return false;
if (positions.length < 9 || indices.length < 3) return false;
if (positions.length % 3 !== 0 || indices.length % 3 !== 0) return false;
const vertexCount = positions.length / 3;
for (let i = 0; i < indices.length; i++) {{
const idx = Number(indices[i]);
if (!Number.isFinite(idx) || idx < 0 || idx >= vertexCount) return false;
}}
return true;
}}
function createPBRMaterial(colorHex, opts = {{}}) {{
return new THREE.MeshStandardMaterial({{
color: new THREE.Color(colorHex),
metalness: opts.metalness ?? 0.05,
roughness: opts.roughness ?? 0.35,
transparent: true,
opacity: opts.opacity ?? 0.55,
side: THREE.DoubleSide,
depthWrite: opts.depthWrite ?? true,
}});
}}
function registerInitialPose(mesh) {{
if (!mesh) return;
mesh.userData.initialPosition = mesh.position.clone();
mesh.userData.initialVisible = mesh.visible;
}}
function fitCameraToScene() {{
const objects = [productMesh, cavityMesh, coreMesh, partingMesh, pointcloudMesh].filter(Boolean);
if (!objects.length) return;
sceneBox = new THREE.Box3();
objects.forEach(obj => sceneBox.expandByObject(obj));
if (sceneBox.isEmpty()) return;
const center = new THREE.Vector3();
const size = new THREE.Vector3();
sceneBox.getCenter(center);
sceneBox.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z) || 100;
const distance = Math.max(maxDim * 1.6, 30);
camera.near = Math.max(maxDim / 2000, 0.01);
camera.far = Math.max(maxDim * 200, 10000);
camera.updateProjectionMatrix();
camera.position.set(center.x + distance * 0.7, center.y + distance * 0.7, center.z + distance * 0.8);
controls.target.copy(center);
controls.minDistance = Math.max(maxDim * 0.03, 0.5);
controls.maxDistance = Math.max(maxDim * 30, 5000);
controls.update();
}}
function setView(direction) {{
if (!sceneBox) return;
const center = new THREE.Vector3();
const size = new THREE.Vector3();
sceneBox.getCenter(center);
sceneBox.getSize(size);
const dist = Math.max(size.x, size.y, size.z) * 1.5;
const positions = {{
front: [0, 0, dist],
back: [0, 0, -dist],
left: [-dist, 0, 0],
right: [dist, 0, 0],
top: [0, dist, 0],
bottom: [0, -dist, 0],
iso: [dist * 0.7, dist * 0.7, dist * 0.8],
}};
const pos = positions[direction] || positions.iso;
camera.position.set(center.x + pos[0], center.y + pos[1], center.z + pos[2]);
controls.target.copy(center);
controls.update();
}}
window.setView = setView;
function buildScene(geometryData, cavityData, pointcloudData) {{
cavityDataGlobal = cavityData;
const bbox = geometryData.bounding_box || computeBounds([
pointcloudData?.points,
cavityData?.mold_cavities?.cavity?.vertices,
cavityData?.mold_cavities?.core?.vertices
]) || {{ center: [0, 0, 0], dimensions: [100, 100, 100] }};
const centerOffset = bbox.center || [0, 0, 0];
const width = (bbox.dimensions && bbox.dimensions[0]) || 100;
const height = (bbox.dimensions && bbox.dimensions[1]) || 100;
const depth = (bbox.dimensions && bbox.dimensions[2]) || 100;
const coreRequired = cavityData?.metadata?.core_required !== false;
const maxDim = Math.max(width, height, depth) || 100;
const lods = pointcloudData?.lods;
if (lods && lods["0"] && lods["0"].vertices && lods["0"].faces) {{
const lodGroup = new THREE.LOD();
const lodKeys = Object.keys(lods).sort((a, b) => Number(a) - Number(b));
for (const key of lodKeys) {{
const entry = lods[key];
if (!entry.vertices || !entry.faces || entry.vertices.length === 0 || entry.faces.length === 0) continue;
const productVerts = normalizePositions(entry.vertices, centerOffset);
const productFaces = toFlatArray(entry.faces);
if (productVerts.length < 9 || productFaces.length < 3) continue;
const lodGeo = new THREE.BufferGeometry();
lodGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(productVerts), 3));
lodGeo.setIndex(new THREE.BufferAttribute(new Uint32Array(productFaces), 1));
lodGeo.computeVertexNormals();
const lodMat = createPBRMaterial(0xFFFFFF, {{ metalness: 0.0, roughness: 0.20, opacity: 0.55 }});
const lodMesh = new THREE.Mesh(lodGeo, lodMat);
lodMesh.castShadow = true;
lodMesh.receiveShadow = true;
const dist = key === "0" ? 0 : key === "1" ? maxDim * 3 : maxDim * 8;
lodGroup.addLevel(lodMesh, dist);
}}
productMesh = lodGroup;
scene.add(productMesh);
registerInitialPose(productMesh);
}} else if (pointcloudData && pointcloudData.vertices && pointcloudData.faces && pointcloudData.vertices.length > 0 && pointcloudData.faces.length > 0) {{
const productVerts = normalizePositions(pointcloudData.vertices, centerOffset);
const productFaces = toFlatArray(pointcloudData.faces);
if (productVerts.length >= 9 && productFaces.length >= 3) {{
const productGeometry = new THREE.BufferGeometry();
productGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(productVerts), 3));
productGeometry.setIndex(new THREE.BufferAttribute(new Uint32Array(productFaces), 1));
productGeometry.computeVertexNormals();
const productMaterial = createPBRMaterial(0xFFFFFF, {{ metalness: 0.0, roughness: 0.20, opacity: 0.55 }});
productMesh = new THREE.Mesh(productGeometry, productMaterial);
productMesh.castShadow = true;
productMesh.receiveShadow = true;
scene.add(productMesh);
registerInitialPose(productMesh);
}}
}}
if (!productMesh && pointcloudData && pointcloudData.points && pointcloudData.points.length > 0) {{
const pointPositions = normalizePositions(pointcloudData.points, centerOffset);
if (pointPositions.length >= 3) {{
const ptGeometry = new THREE.BufferGeometry();
ptGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pointPositions), 3));
if (pointcloudData.normals && pointcloudData.normals.length > 0) {{
const normals = new Float32Array(toFlatArray(pointcloudData.normals));
if (normals.length === pointPositions.length) {{
ptGeometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
}}
}}
const ptMaterial = new THREE.PointsMaterial({{
color: 0xFFFFFF, size: 0.5, sizeAttenuation: true,
transparent: true, opacity: 0.90, blending: THREE.NormalBlending,
depthWrite: false,
}});
pointcloudMesh = new THREE.Points(ptGeometry, ptMaterial);
pointcloudMesh.visible = pointcloudVisible;
scene.add(pointcloudMesh);
registerInitialPose(pointcloudMesh);
}}
}}
if (!productMesh && !pointcloudMesh) {{
const productGeometry = new THREE.BoxGeometry(width * 0.9, height * 0.9, depth * 0.9);
const productMaterial = createPBRMaterial(0xFFFFFF, {{ metalness: 0.0, roughness: 0.25, opacity: 0.65 }});
productMesh = new THREE.Mesh(productGeometry, productMaterial);
productMesh.position.set(0, 0, 0);
scene.add(productMesh);
registerInitialPose(productMesh);
}}
if (cavityData && cavityData.mold_cavities && cavityData.mold_cavities.cavity) {{
const cd = cavityData.mold_cavities.cavity;
if (cd.vertices && cd.faces && cd.vertices.length > 0 && cd.faces.length > 0) {{
const cavityGeometry = new THREE.BufferGeometry();
const positions = new Float32Array(normalizePositions(cd.vertices, centerOffset));
const indices = new Uint32Array(toFlatArray(cd.faces));
if (isValidIndexedGeometry(positions, indices)) {{
cavityGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
cavityGeometry.setIndex(new THREE.BufferAttribute(indices, 1));
cavityGeometry.computeVertexNormals();
const cavityMaterial = createPBRMaterial(0x4488cc, {{ metalness: 0.7, roughness: 0.3, opacity: 0.45, depthWrite: false }});
cavityMesh = new THREE.Mesh(cavityGeometry, cavityMaterial);
cavityMesh.renderOrder = 1;
scene.add(cavityMesh);
registerInitialPose(cavityMesh);
addWireframe(cavityMesh, cavityGeometry, 0x3388bb);
}}
}}
}}
if (!cavityMesh) createSimpleCavity(width, height, depth, centerOffset);
if (coreRequired && cavityData && cavityData.mold_cavities && cavityData.mold_cavities.core) {{
const cd = cavityData.mold_cavities.core;
if (cd.vertices && cd.faces && cd.vertices.length > 0 && cd.faces.length > 0) {{
const coreGeometry = new THREE.BufferGeometry();
const positions = new Float32Array(normalizePositions(cd.vertices, centerOffset));
const indices = new Uint32Array(toFlatArray(cd.faces));
if (isValidIndexedGeometry(positions, indices)) {{
coreGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
coreGeometry.setIndex(new THREE.BufferAttribute(indices, 1));
coreGeometry.computeVertexNormals();
const coreMaterial = createPBRMaterial(0xdd8822, {{ metalness: 0.7, roughness: 0.3, opacity: 0.45, depthWrite: false }});
coreMesh = new THREE.Mesh(coreGeometry, coreMaterial);
coreMesh.renderOrder = 1;
scene.add(coreMesh);
registerInitialPose(coreMesh);
addWireframe(coreMesh, coreGeometry, 0xcc6600);
}}
}}
}}
if (!coreMesh && coreRequired) createSimpleCore(width, height, depth, centerOffset);
const partingGeometry = new THREE.PlaneGeometry(width * 1.2, height * 1.2);
const partingMaterial = new THREE.MeshBasicMaterial({{
color: 0xF44336, transparent: true, opacity: 0.25, side: THREE.DoubleSide, depthWrite: false,
}});
partingMesh = new THREE.Mesh(partingGeometry, partingMaterial);
partingMesh.position.set(centerOffset[0], centerOffset[1], centerOffset[2]);
partingMesh.renderOrder = 2;
scene.add(partingMesh);
registerInitialPose(partingMesh);
if (productMesh) {{
if (productMesh.isLOD) {{
productMesh.traverse(child => {{
if (child.isMesh && child.geometry) {{
addWireframe(child, child.geometry, 0xCCCCCC);
}}
}});
}} else {{
addWireframe(productMesh, productMesh.geometry, 0xCCCCCC);
}}
}}
updateInfoPanel(pointcloudData, cavityData);
fitCameraToScene();
}}
function addWireframe(parent, geometry, colorHex) {{
const wf = new THREE.WireframeGeometry(geometry);
const line = new THREE.LineSegments(wf, new THREE.LineBasicMaterial({{
color: colorHex, transparent: true, opacity: 0.25, depthTest: true, depthWrite: false,
}}));
line.renderOrder = 3;
parent.add(line);
}}
function createSimpleCavity(width, height, depth, center) {{
const halfDepth = depth / 2;
const cGeo = new THREE.BoxGeometry(width * 1.2, height * 1.2, halfDepth + 10);
const cMat = createPBRMaterial(0x4488cc, {{ metalness: 0.6, roughness: 0.35, opacity: 0.35, depthWrite: false }});
cavityMesh = new THREE.Mesh(cGeo, cMat);
cavityMesh.position.set(center[0], center[1], center[2] + halfDepth / 2 + 5);
cavityMesh.renderOrder = 1;
scene.add(cavityMesh);
registerInitialPose(cavityMesh);
addWireframe(cavityMesh, cGeo, 0x3388bb);
}}
function createSimpleCore(width, height, depth, center) {{
const halfDepth = depth / 2;
const cGeo = new THREE.BoxGeometry(width * 1.2, height * 1.2, halfDepth + 10);
const cMat = createPBRMaterial(0xdd8822, {{ metalness: 0.6, roughness: 0.35, opacity: 0.35, depthWrite: false }});
coreMesh = new THREE.Mesh(cGeo, cMat);
coreMesh.position.set(center[0], center[1], center[2] - halfDepth / 2 - 5);
coreMesh.renderOrder = 1;
scene.add(coreMesh);
registerInitialPose(coreMesh);
addWireframe(coreMesh, cGeo, 0xcc6600);
}}
function updateInfoPanel(pointcloudData, cavityData) {{
const verts = pointcloudData?.vertex_count || cavityData?.mold_cavities?.cavity?.vertex_count || '-';
const faces = pointcloudData?.face_count || cavityData?.mold_cavities?.cavity?.face_count || '-';
const pts = pointcloudData?.point_count || '-';
const vol = cavityData?.mold_cavities?.cavity_key_info?.geometric_characteristics?.product_volume || '-';
document.getElementById('info-verts').textContent = typeof verts === 'number' ? verts.toLocaleString() : verts;
document.getElementById('info-faces').textContent = typeof faces === 'number' ? faces.toLocaleString() : faces;
document.getElementById('info-points').textContent = typeof pts === 'number' ? pts.toLocaleString() : pts;
document.getElementById('info-vol').textContent = vol;
const panel = document.getElementById('cavity-info-panel');
if (panel && cavityData) {{
panel.style.display = 'block';
const meta = cavityData.metadata || {{}};
const mfg = cavityData.manufacturing_info || {{}};
const geo = cavityData.mold_cavities?.cavity_key_info?.geometric_characteristics || {{}};
const setVal = (id, val) => {{ const el = document.getElementById(id); if (el) el.textContent = val || 'N/A'; }};
setVal('cp-shrink', meta.shrinkage_rate);
setVal('cp-draft', meta.draft_angle != null ? meta.draft_angle + '°' : null);
setVal('cp-parting', mfg.parting_line_length);
setVal('cp-vol', geo.product_volume);
setVal('cp-weight', geo.product_weight);
setVal('cp-wall', geo.wall_thickness_range);
setVal('cp-material', mfg.mold_material);
setVal('cp-hardness', mfg.mold_hardness);
setVal('cp-finish', mfg.surface_finish);
setVal('cp-cycle', mfg.estimated_cycle_time);
}}
}}
async function loadData() {{
const statusEl = document.getElementById('loading-status');
try {{
statusEl.textContent = '正在加载几何数据...';
const resp = await fetch(DATA_URL);
if (!resp.ok) throw new Error(`HTTP ${{resp.status}}`);
const data = await resp.json();
statusEl.textContent = '正在构建3D场景...';
await new Promise(r => setTimeout(r, 30));
buildScene(
data.geometry || {{}},
data.cavity || null,
data.pointcloud || null
);
statusEl.textContent = '完成';
document.getElementById('loading-overlay').classList.add('hidden');
}} catch (err) {{
console.error('数据加载失败:', err);
statusEl.textContent = '加载失败: ' + err.message;
statusEl.style.color = '#F44336';
}}
}}
function animate() {{
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}}
loadData().then(() => animate());
window.addEventListener('resize', () => {{
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}});
window.resetView = function() {{
if (splitAnimId) {{ cancelAnimationFrame(splitAnimId); splitAnimId = null; }}
isSplit = false;
const btn = document.getElementById('splitBtn');
if (btn) btn.textContent = '分模拆分';
[productMesh, cavityMesh, coreMesh, partingMesh, pointcloudMesh].forEach(mesh => {{
if (!mesh) return;
if (mesh.userData.initialPosition) mesh.position.copy(mesh.userData.initialPosition);
else mesh.position.set(0, 0, 0);
mesh.visible = mesh.userData.initialVisible !== false;
}});
if (partingMesh && partingMesh.material) {{ partingMesh.material.opacity = 0.25; partingMesh.visible = true; }}
productVisible = true; moldVisible = true; partingVisible = true;
document.getElementById('btn-product').classList.add('active');
document.getElementById('btn-mold').classList.add('active');
document.getElementById('btn-parting').classList.add('active');
fitCameraToScene();
}};
window.toggleWireframe = function() {{
scene.traverse(child => {{ if (child.isMesh) child.material.wireframe = !child.material.wireframe; }});
}};
window.toggleProduct = function() {{
if (!productMesh && !pointcloudMesh) return;
productVisible = !productVisible;
if (productMesh) productMesh.visible = productVisible;
document.getElementById('btn-product').classList.toggle('active', productVisible);
}};
window.toggleMold = function() {{
moldVisible = !moldVisible;
if (cavityMesh) cavityMesh.visible = moldVisible;
if (coreMesh) coreMesh.visible = moldVisible;
document.getElementById('btn-mold').classList.toggle('active', moldVisible);
}};
window.toggleParting = function() {{
partingVisible = !partingVisible;
if (partingMesh) partingMesh.visible = partingVisible;
document.getElementById('btn-parting').classList.toggle('active', partingVisible);
}};
window.togglePointcloud = function() {{
pointcloudVisible = !pointcloudVisible;
if (pointcloudMesh) pointcloudMesh.visible = pointcloudVisible;
document.getElementById('btn-pointcloud').classList.toggle('active', pointcloudVisible);
}};
function getPartingDirection() {{
if (cavityDataGlobal?.metadata?.parting_direction) return cavityDataGlobal.metadata.parting_direction;
if (cavityDataGlobal?.manufacturing_info?.parting_direction) return cavityDataGlobal.manufacturing_info.parting_direction;
if (cavityDataGlobal?.metadata?.is_foam) return 'Z';
return 'Z';
}}
window.splitMold = function() {{
if (!cavityMesh && !coreMesh) return;
isSplit = !isSplit;
const btn = document.getElementById('splitBtn');
btn.textContent = isSplit ? '合模' : '分模拆分';
const dir = getPartingDirection();
let splitDist, axis;
if (dir === 'Z') {{ splitDist = (sceneBox ? sceneBox.getSize(new THREE.Vector3()).z : 100) * 0.4; axis = 'z'; }}
else if (dir === 'Y') {{ splitDist = (sceneBox ? sceneBox.getSize(new THREE.Vector3()).y : 100) * 0.4; axis = 'y'; }}
else {{ splitDist = (sceneBox ? sceneBox.getSize(new THREE.Vector3()).x : 100) * 0.4; axis = 'x'; }}
const partingTargetOpacity = isSplit ? 0 : 0.25;
const cavityStart = cavityMesh ? cavityMesh.position[axis] : 0;
const coreStart = coreMesh ? coreMesh.position[axis] : 0;
const partingStartOpacity = partingMesh ? partingMesh.material.opacity : 0.25;
const cavityTarget = isSplit ? splitDist : 0;
const coreTarget = isSplit ? -splitDist : 0;
const duration = 900;
const startTime = performance.now();
if (splitAnimId) cancelAnimationFrame(splitAnimId);
function animateSplit(now) {{
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
if (cavityMesh) cavityMesh.position[axis] = cavityStart + (cavityTarget - cavityStart) * ease;
if (coreMesh) coreMesh.position[axis] = coreStart + (coreTarget - coreStart) * ease;
if (partingMesh) {{
partingMesh.material.opacity = partingStartOpacity + (partingTargetOpacity - partingStartOpacity) * ease;
partingMesh.visible = !(isSplit && t >= 1);
}}
if (t < 1) splitAnimId = requestAnimationFrame(animateSplit);
else splitAnimId = null;
}}
splitAnimId = requestAnimationFrame(animateSplit);
}};
</script>
</body>
</html>"""
return html_content
def _build_cavity_info_panel_template(self) -> str:
"""构建型腔信息面板 — 由JS动态填充,这里放置容器"""
return """
<div id="cavity-info-panel">
<h3>🔧 关键工艺参数</h3>
<div style="margin: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">
<strong style="color: #FF9800;">模具参数</strong>
</div>
<div class="metric"><span class="metric-label">收缩率</span><span class="metric-value" id="cp-shrink">-</span></div>
<div class="metric"><span class="metric-label">拔模角</span><span class="metric-value" id="cp-draft">-</span></div>
<div class="metric"><span class="metric-label">分型线长度</span><span class="metric-value" id="cp-parting">-</span></div>
<div style="margin: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">
<strong style="color: #FF9800;">几何特性</strong>
</div>
<div class="metric"><span class="metric-label">产品体积</span><span class="metric-value" id="cp-vol">-</span></div>
<div class="metric"><span class="metric-label">产品重量</span><span class="metric-value" id="cp-weight">-</span></div>
<div class="metric"><span class="metric-label">壁厚范围</span><span class="metric-value" id="cp-wall">-</span></div>
<div style="margin: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">
<strong style="color: #FF9800;">制造要求</strong>
</div>
<div class="metric"><span class="metric-label">模仁材料</span><span class="metric-value" id="cp-material">-</span></div>
<div class="metric"><span class="metric-label">硬度</span><span class="metric-value" id="cp-hardness">-</span></div>
<div class="metric"><span class="metric-label">表面光洁度</span><span class="metric-value" id="cp-finish">-</span></div>
<div class="metric"><span class="metric-label">预估周期</span><span class="metric-value" id="cp-cycle">-</span></div>
</div>
"""
def generate_3d_viewer_data(
self,
geometry_data: Dict[str, Any],
cavity_data: Optional[Dict[str, Any]] = None,
pointcloud_data: Optional[Dict[str, Any]] = None,
lod_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""生成companion JSON数据文件内容,支持多级LOD"""
pc = dict(pointcloud_data) if pointcloud_data else {}
if lod_data and lod_data.get("lods"):
pc["lods"] = lod_data["lods"]
data = {
"version": "4.0.0",
"generated_at": datetime.now().isoformat(),
"geometry": geometry_data,
"cavity": cavity_data,
"pointcloud": pc if pc else pointcloud_data,
}
return data
def save_html_file(self, html_content: str, filename: str) -> str:
"""保存HTML文件到磁盘"""
try:
file_path = self.output_dir / filename
file_path.write_text(html_content, encoding='utf-8')
logger.info(f"HTML文件保存成功: {file_path}")
return str(file_path)
except Exception as e:
logger.error(f"保存HTML文件失败: {e}")
raise
def save_data_file(self, data_content: Dict[str, Any], filename: str) -> str:
"""保存JSON数据文件到磁盘 — 使用orjson高速序列化"""
try:
file_path = self.output_dir / filename
file_path.write_bytes(_json_dumps(data_content))
logger.info(f"数据文件保存成功: {file_path} (orjson={_JSON_FAST})")
return str(file_path)
except Exception as e:
logger.error(f"保存数据文件失败: {e}")
raise
def generate_and_save_visualization(
self,
geometry_data: Dict[str, Any],
stp_filename: str,
cavity_data: Optional[Dict[str, Any]] = None,
pointcloud_data: Optional[Dict[str, Any]] = None,
suffix: Optional[str] = None,
lod_data: Optional[Dict[str, Any]] = None,
) -> str:
"""生成并保存可视化HTML + companion JSON数据文件。返回HTML文件路径(向后兼容)"""
try:
base_stem = Path(stp_filename).stem.replace(" ", "_")
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
suffix_part = f"_{suffix}" if suffix else ""
base_name = f"mold_{base_stem}{suffix_part}_{ts}"
html_filename = f"{base_name}.html"
data_filename = f"{base_name}_data.json"
data_content = self.generate_3d_viewer_data(
geometry_data, cavity_data, pointcloud_data, lod_data=lod_data
)
self.save_data_file(data_content, data_filename)
html_content = self.generate_3d_viewer_html(stp_filename, data_filename)
html_file_path = self.save_html_file(html_content, html_filename)
return html_file_path
except Exception as e:
logger.error(f"生成可视化文件失败: {e}")
raise