x
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user