import * as THREE from "three"; import { STLLoader } from "three/addons/loaders/STLLoader.js"; import { PLYLoader } from "three/addons/loaders/PLYLoader.js"; const root = document.documentElement; const queue = document.querySelector("#scanQueue"); const countEl = document.querySelector("#scanCount"); const progressEl = document.querySelector("#scanProgress"); const timerEl = document.querySelector("#scanTimer"); const rateEl = document.querySelector("#scanRate"); const startButton = document.querySelector("#scanStart"); const resetButton = document.querySelector("#scanReset"); const scanProgressText = document.querySelector("#scanProgressText"); const scanDurationText = document.querySelector("#scanDurationText"); const scanSpeedSelect = document.querySelector("#scanSpeedSelect"); const zoomRange = document.querySelector("#zoomRange"); const heatToggle = document.querySelector("#heatToggle"); const scoreValue = document.querySelector("#scoreValue"); const standardCanvas = document.querySelector("#standardCanvas"); const studentCanvas = document.querySelector("#studentCanvas"); const metricValues = document.querySelectorAll(".metric strong"); const scoreButton = document.querySelector("#scoreButton"); const scoreModal = document.querySelector("#scoreModal"); const scoreReport = document.querySelector("#scoreReport"); const scoreTableBody = document.querySelector("#scoreTableBody"); const reportTotal = document.querySelector("#reportTotal"); const scoreModalClose = document.querySelector("#scoreModalClose"); const modelAlert = document.querySelector("#modelAlert"); const modelAlertText = document.querySelector("#modelAlertText"); const modelAlertClose = document.querySelector("#modelAlertClose"); const stlModels = { standard: null, student: null, }; const stlLoader = new STLLoader(); const plyLoader = new PLYLoader(); const renderLimits = { triangles: 14000, previewTriangles: 3500, points: 42000, previewPoints: 9000, }; let renderQueued = false; let previewRender = false; let zoomSettleTimer = null; const scoringGroups = [ { category: "唇面", items: [ ["唇面梯形形态", 3], ["近远中缘突度是否正确", 3], ["近、远中切角角度", 3], ["发育沟深度及与牙体的衔接", 3], ["外形高点", 3], ], }, { category: "舌面", items: [ ["舌面梯形形态", 3], ["边缘嵴的宽度", 3], ["近中边缘嵴平直、锐利", 3], ["远中边缘嵴圆钝", 3], ["边缘嵴与邻面的衔接", 3], ["舌隆突的位置、大小", 3], ["舌隆突与舌窝、牙根的协调度", 6], ], }, { category: "邻面", items: [ ["邻面与唇面的衔接", 3], ["邻面的冠根连接", 3], ["邻面凹陷的位置、深度", 3], ["邻面外形高点", 6], ], }, { category: "切缘", items: [ ["切缘的位置与走行", 3], ["切缘厚度", 3], ["切缘结节", 3], ["与四面的衔接", 3], ["近中锐利、远中圆钝", 6], ], }, { category: "牙颈线", items: [ ["牙颈线曲度", 3], ["牙颈线位置", 3], ["牙颈线突度", 3], ], }, { category: "牙根", items: [ ["冠根连接处的宽度、厚度", 6], ["牙根形态", 4], ["牙根宽度、厚度", 6], ["根尖形态", 3], ], }, ]; class ThreeModelViewer { constructor(canvas, kind) { this.canvas = canvas; this.kind = kind; this.scene = new THREE.Scene(); this.camera = new THREE.PerspectiveCamera(38, 1, 0.01, 100); this.camera.position.set(0, 0.2, 4.2); this.group = new THREE.Group(); this.scene.add(this.group); this.renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true, powerPreference: "high-performance", }); this.renderer.setClearColor(0x000000, 0); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75)); const keyLight = new THREE.DirectionalLight(0xffffff, 2.2); keyLight.position.set(2.5, 3.5, 4); this.scene.add(keyLight); const fillLight = new THREE.DirectionalLight(0x9dd8ce, 1.15); fillLight.position.set(-3, 1.5, 2.5); this.scene.add(fillLight); const ambient = new THREE.HemisphereLight(0xffffff, 0x87a9a0, 1.7); this.scene.add(ambient); } disposeObject() { while (this.group.children.length) { const child = this.group.children[0]; this.group.remove(child); if (child.geometry) child.geometry.dispose(); if (Array.isArray(child.material)) { child.material.forEach((material) => material.dispose()); } else if (child.material) { child.material.dispose(); } } } setGeometry(geometry, options) { this.disposeObject(); geometry.computeBoundingBox(); const box = geometry.boundingBox; const center = new THREE.Vector3(); const size = new THREE.Vector3(); box.getCenter(center); box.getSize(size); const extent = Math.max(size.x, size.y, size.z) || 1; geometry.translate(-center.x, -center.y, -center.z); geometry.scale(2.35 / extent, 2.35 / extent, 2.35 / extent); let modelObject; if (options.isPointCloud) { const material = new THREE.PointsMaterial({ color: this.kind === "standard" ? 0x2f7d70 : 0x4b8879, size: 0.022, sizeAttenuation: true, vertexColors: geometry.hasAttribute("color"), }); modelObject = new THREE.Points(geometry, material); } else { if (!geometry.hasAttribute("normal")) geometry.computeVertexNormals(); const material = new THREE.MeshStandardMaterial({ color: this.kind === "standard" ? 0xbfdad2 : 0xb8d4cd, roughness: 0.58, metalness: 0.02, side: THREE.DoubleSide, vertexColors: geometry.hasAttribute("color"), }); modelObject = new THREE.Mesh(geometry, material); } modelObject.rotation.x = -Math.PI / 2; this.group.add(modelObject); this.render(); } resize() { const rect = this.canvas.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.75); const targetWidth = Math.floor(width * pixelRatio); const targetHeight = Math.floor(height * pixelRatio); if (this.canvas.width !== targetWidth || this.canvas.height !== targetHeight) { this.renderer.setSize(width, height, false); this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); } } render() { this.resize(); this.group.rotation.x = THREE.MathUtils.degToRad(rotation.rx); this.group.rotation.y = THREE.MathUtils.degToRad(rotation.ry); this.group.rotation.z = THREE.MathUtils.degToRad(rotation.rz); const scale = Number(zoomRange.value) / 100; this.group.scale.setScalar(scale); this.renderer.render(this.scene, this.camera); } } const scanState = { running: false, count: 0, elapsedMs: 0, startedAt: 0, baseDurationMs: 0, targetDurationMs: 0, segmentStartCount: 0, segmentStartElapsedMs: 0, segmentRemainingCount: 50, segmentDurationMs: 0, interval: null, }; const SCAN_TARGET = 50; const BASE_SCAN_MIN_MS = 7 * 60 * 1000; const BASE_SCAN_MAX_MS = 10 * 60 * 1000; const viewAngles = { front: { rx: -18, ry: 24, rz: 0 }, top: { rx: -72, ry: 0, rz: 0 }, left: { rx: -12, ry: -64, rz: 0 }, right: { rx: -12, ry: 64, rz: 0 }, }; let rotation = { ...viewAngles.front }; let dragging = false; let lastPointer = { x: 0, y: 0 }; const viewers = { standard: new ThreeModelViewer(standardCanvas, "standard"), student: new ThreeModelViewer(studentCanvas, "student"), }; function pad(value) { return String(value).padStart(2, "0"); } function formatTime(seconds) { const minutes = Math.floor(seconds / 60); const rest = seconds % 60; return `${pad(minutes)}:${pad(rest)}`; } function formatMs(milliseconds) { return formatTime(Math.floor(milliseconds / 1000)); } function currentScanSpeed() { return Number(scanSpeedSelect.value) || 1; } function randomBaseScanDuration() { return Math.round(BASE_SCAN_MIN_MS + Math.random() * (BASE_SCAN_MAX_MS - BASE_SCAN_MIN_MS)); } function updateTargetDuration() { if (!scanState.baseDurationMs) scanState.baseDurationMs = randomBaseScanDuration(); const remainingCount = Math.max(SCAN_TARGET - scanState.count, 0); const perModelMs = scanState.baseDurationMs / SCAN_TARGET; scanState.targetDurationMs = scanState.elapsedMs + (remainingCount * perModelMs) / currentScanSpeed(); } function updateElapsedFromClock() { if (!scanState.running) return; scanState.elapsedMs = Date.now() - scanState.startedAt; } function beginScanSegment() { updateTargetDuration(); scanState.segmentStartCount = scanState.count; scanState.segmentStartElapsedMs = scanState.elapsedMs; scanState.segmentRemainingCount = Math.max(SCAN_TARGET - scanState.count, 0); scanState.segmentDurationMs = Math.max(1, scanState.targetDurationMs - scanState.elapsedMs); } function updateRotation() { root.style.setProperty("--rx", `${rotation.rx}deg`); root.style.setProperty("--ry", `${rotation.ry}deg`); root.style.setProperty("--rz", `${rotation.rz}deg`); scheduleRenderAll(); } function updateScanUI() { updateElapsedFromClock(); if (!scanState.targetDurationMs) updateTargetDuration(); if (scanState.running) { const segmentElapsedMs = Math.max(scanState.elapsedMs - scanState.segmentStartElapsedMs, 0); const segmentProgress = scanState.segmentDurationMs ? Math.min(segmentElapsedMs / scanState.segmentDurationMs, 1) : 1; const segmentCount = Math.floor(segmentProgress * scanState.segmentRemainingCount); scanState.count = Math.min(scanState.segmentStartCount + segmentCount, SCAN_TARGET); } const displayedCount = Math.min(scanState.count, SCAN_TARGET); countEl.textContent = pad(displayedCount); timerEl.textContent = formatMs(scanState.elapsedMs); progressEl.style.width = `${Math.min(displayedCount / SCAN_TARGET, 1) * 100}%`; scanProgressText.textContent = `已完成 ${displayedCount} / ${SCAN_TARGET}`; updateTargetDuration(); scanDurationText.textContent = `预计完成用时 ${formatMs(scanState.targetDurationMs)} · ${currentScanSpeed()}倍`; const elapsedMinutes = Math.max(scanState.elapsedMs / 60000, 1 / 60); rateEl.textContent = (displayedCount / elapsedMinutes).toFixed(1); queue.querySelectorAll("span").forEach((item, index) => { item.classList.toggle("done", index < displayedCount); item.setAttribute("aria-label", index < displayedCount ? `模型 ${index + 1} 扫描完成` : `模型 ${index + 1} 等待扫描`); }); } function stopScan(label = "继续采集") { updateElapsedFromClock(); clearInterval(scanState.interval); scanState.interval = null; scanState.running = false; startButton.innerHTML = `${label}`; } function tickScan() { updateScanUI(); if (scanState.count >= SCAN_TARGET) { scoreValue.textContent = "96.8"; stopScan("重新采集"); scanState.elapsedMs = scanState.targetDurationMs; updateScanUI(); return; } } function resetScan() { stopScan("开始采集"); scanState.count = 0; scanState.elapsedMs = 0; scanState.startedAt = 0; scanState.baseDurationMs = randomBaseScanDuration(); scanState.segmentStartCount = 0; scanState.segmentStartElapsedMs = 0; scanState.segmentRemainingCount = SCAN_TARGET; scanState.segmentDurationMs = 0; updateTargetDuration(); scoreValue.textContent = "92.6"; updateScanUI(); } function createQueue() { const fragment = document.createDocumentFragment(); for (let i = 0; i < SCAN_TARGET; i += 1) { const item = document.createElement("span"); item.title = `模型 ${i + 1}`; item.setAttribute("role", "img"); fragment.appendChild(item); } queue.appendChild(fragment); } function createDentalObject(object) { const gum = document.createElement("div"); gum.className = "gum"; object.appendChild(gum); const angles = [-72, -58, -43, -27, -10, 10, 27, 43, 58, 72, 104, 128, 152, -152, -128, -104]; angles.forEach((angle, index) => { const tooth = document.createElement("span"); const isMolar = index < 2 || index > 13 || (index > 8 && index < 13); tooth.className = "tooth"; tooth.style.setProperty("--a", `${angle}deg`); tooth.style.setProperty("--r", index < 10 ? "-82px" : "-58px"); tooth.style.setProperty("--z", index < 10 ? "18px" : "-16px"); tooth.style.setProperty("--tw", isMolar ? "34px" : "28px"); tooth.style.setProperty("--th", isMolar ? "54px" : "61px"); object.appendChild(tooth); }); } function setActiveButton(buttons, activeButton) { buttons.forEach((button) => button.classList.toggle("active", button === activeButton)); } function bindFile(inputId, labelId) { const input = document.querySelector(inputId); const label = document.querySelector(labelId); input.addEventListener("change", () => { const file = input.files && input.files[0]; if (file) { label.textContent = file.name; } }); } function isLikelyBinaryStl(buffer) { if (buffer.byteLength < 84) return false; const triangleCount = new DataView(buffer).getUint32(80, true); return 84 + triangleCount * 50 === buffer.byteLength; } function parseAsciiStl(text) { const numbers = []; const vertexPattern = /vertex\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)/g; let match = vertexPattern.exec(text); while (match) { numbers.push([Number(match[1]), Number(match[2]), Number(match[3])]); match = vertexPattern.exec(text); } const triangles = []; for (let i = 0; i + 2 < numbers.length; i += 3) { triangles.push([numbers[i], numbers[i + 1], numbers[i + 2]]); } return triangles; } function parseBinaryStl(buffer) { const view = new DataView(buffer); const triangleCount = view.getUint32(80, true); const triangles = []; let offset = 84; for (let i = 0; i < triangleCount; i += 1) { offset += 12; const triangle = []; for (let vertex = 0; vertex < 3; vertex += 1) { triangle.push([ view.getFloat32(offset, true), view.getFloat32(offset + 4, true), view.getFloat32(offset + 8, true), ]); offset += 12; } triangles.push(triangle); offset += 2; } return triangles; } function sampleArray(items, limit) { if (!items || items.length <= limit) return items || []; const sampled = []; const step = items.length / limit; for (let i = 0; i < limit; i += 1) { sampled.push(items[Math.floor(i * step)]); } return sampled; } function normalizeGeometry(triangles, points = null) { const min = [Infinity, Infinity, Infinity]; const max = [-Infinity, -Infinity, -Infinity]; const visitVertex = (vertex) => { for (let axis = 0; axis < 3; axis += 1) { min[axis] = Math.min(min[axis], vertex[axis]); max[axis] = Math.max(max[axis], vertex[axis]); } }; if (points && points.length) { points.forEach(visitVertex); } else { triangles.forEach((triangle) => triangle.forEach(visitVertex)); } const center = min.map((value, axis) => (value + max[axis]) / 2); const size = max.map((value, axis) => value - min[axis]); const extent = Math.max(...size) || 1; const normalized = triangles.map((triangle) => triangle.map((vertex) => vertex.map((value, axis) => (value - center[axis]) / extent)) ); const normalizedPoints = points ? points.map((vertex) => vertex.map((value, axis) => (value - center[axis]) / extent)) : []; return { triangles: sampleArray(normalized, renderLimits.triangles), previewTriangles: sampleArray(normalized, renderLimits.previewTriangles), points: sampleArray(normalizedPoints, renderLimits.points), previewPoints: sampleArray(normalizedPoints, renderLimits.previewPoints), triangleCount: triangles.length, pointCount: points ? points.length : triangles.length * 3, sampled: normalized.length > renderLimits.triangles || normalizedPoints.length > renderLimits.points, bounds: { min, max, size, extent }, }; } function parseStl(buffer) { const triangles = isLikelyBinaryStl(buffer) ? parseBinaryStl(buffer) : parseAsciiStl(new TextDecoder("utf-8", { fatal: false }).decode(buffer)); if (!triangles.length) { throw new Error("STL文件未解析到有效三角面"); } return normalizeGeometry(triangles); } function findPlyHeader(buffer) { const bytes = new Uint8Array(buffer); const marker = new TextEncoder().encode("end_header"); for (let i = 0; i <= bytes.length - marker.length; i += 1) { let found = true; for (let j = 0; j < marker.length; j += 1) { if (bytes[i + j] !== marker[j]) { found = false; break; } } if (found) { let dataOffset = i + marker.length; if (bytes[dataOffset] === 13) dataOffset += 1; if (bytes[dataOffset] === 10) dataOffset += 1; return { text: new TextDecoder("utf-8", { fatal: false }).decode(buffer.slice(0, i + marker.length)), dataOffset, }; } } throw new Error("PLY header missing end_header"); } function parsePlyHeader(text) { const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); const elements = []; let format = "ascii"; let current = null; lines.forEach((line) => { const parts = line.split(/\s+/); if (parts[0] === "format") { format = parts[1]; return; } if (parts[0] === "element") { current = { name: parts[1], count: Number(parts[2]), properties: [] }; elements.push(current); return; } if (parts[0] === "property" && current) { if (parts[1] === "list") { current.properties.push({ kind: "list", countType: parts[2], itemType: parts[3], name: parts[4], }); } else { current.properties.push({ kind: "scalar", type: parts[1], name: parts[2], }); } } }); return { format, elements }; } function triangulateFace(indices, vertices, triangles) { if (indices.length < 3) return; for (let i = 1; i < indices.length - 1; i += 1) { const triangle = [vertices[indices[0]], vertices[indices[i]], vertices[indices[i + 1]]]; if (triangle.every(Boolean)) triangles.push(triangle); } } function parseAsciiPly(buffer, header) { const body = new TextDecoder("utf-8", { fatal: false }).decode(buffer.slice(header.dataOffset)); const lines = body.split(/\r?\n/).filter((line) => line.trim()); const vertices = []; const triangles = []; let cursor = 0; header.elements.forEach((element) => { for (let row = 0; row < element.count && cursor < lines.length; row += 1) { const values = lines[cursor].trim().split(/\s+/).map(Number); cursor += 1; if (element.name === "vertex") { const xIndex = element.properties.findIndex((property) => property.name === "x"); const yIndex = element.properties.findIndex((property) => property.name === "y"); const zIndex = element.properties.findIndex((property) => property.name === "z"); const vertex = [values[xIndex], values[yIndex], values[zIndex]]; if (vertex.every(Number.isFinite)) vertices.push(vertex); } else if (element.name === "face") { const record = {}; let valueCursor = 0; element.properties.forEach((property) => { if (property.kind === "list") { const count = values[valueCursor]; record[property.name] = values.slice(valueCursor + 1, valueCursor + 1 + count); valueCursor += 1 + count; } else { record[property.name] = values[valueCursor]; valueCursor += 1; } }); const indices = record.vertex_indices || record.vertex_index || record.indices || []; triangulateFace(indices, vertices, triangles); } } }); return normalizeGeometry(triangles, vertices); } const plyScalarReaders = { char: { size: 1, read: (view, offset) => view.getInt8(offset) }, int8: { size: 1, read: (view, offset) => view.getInt8(offset) }, uchar: { size: 1, read: (view, offset) => view.getUint8(offset) }, uint8: { size: 1, read: (view, offset) => view.getUint8(offset) }, short: { size: 2, read: (view, offset, little) => view.getInt16(offset, little) }, int16: { size: 2, read: (view, offset, little) => view.getInt16(offset, little) }, ushort: { size: 2, read: (view, offset, little) => view.getUint16(offset, little) }, uint16: { size: 2, read: (view, offset, little) => view.getUint16(offset, little) }, int: { size: 4, read: (view, offset, little) => view.getInt32(offset, little) }, int32: { size: 4, read: (view, offset, little) => view.getInt32(offset, little) }, uint: { size: 4, read: (view, offset, little) => view.getUint32(offset, little) }, uint32: { size: 4, read: (view, offset, little) => view.getUint32(offset, little) }, float: { size: 4, read: (view, offset, little) => view.getFloat32(offset, little) }, float32: { size: 4, read: (view, offset, little) => view.getFloat32(offset, little) }, double: { size: 8, read: (view, offset, little) => view.getFloat64(offset, little) }, float64: { size: 8, read: (view, offset, little) => view.getFloat64(offset, little) }, }; function readPlyScalar(view, offset, type, littleEndian) { const reader = plyScalarReaders[type]; if (!reader) throw new Error(`Unsupported PLY property type: ${type}`); return { value: reader.read(view, offset, littleEndian), offset: offset + reader.size, }; } function parseBinaryPly(buffer, header) { const littleEndian = header.meta.format === "binary_little_endian"; const view = new DataView(buffer); const vertices = []; const triangles = []; let offset = header.dataOffset; header.meta.elements.forEach((element) => { for (let row = 0; row < element.count; row += 1) { const record = {}; element.properties.forEach((property) => { if (property.kind === "list") { const countRead = readPlyScalar(view, offset, property.countType, littleEndian); offset = countRead.offset; const items = []; for (let i = 0; i < countRead.value; i += 1) { const itemRead = readPlyScalar(view, offset, property.itemType, littleEndian); offset = itemRead.offset; items.push(itemRead.value); } record[property.name] = items; } else { const scalarRead = readPlyScalar(view, offset, property.type, littleEndian); offset = scalarRead.offset; record[property.name] = scalarRead.value; } }); if (element.name === "vertex") { const vertex = [record.x, record.y, record.z]; if (vertex.every(Number.isFinite)) vertices.push(vertex); } else if (element.name === "face") { const indices = record.vertex_indices || record.vertex_index || record.indices || []; triangulateFace(indices, vertices, triangles); } } }); return normalizeGeometry(triangles, vertices); } function parsePly(buffer) { const header = findPlyHeader(buffer); const meta = parsePlyHeader(header.text); const model = meta.format === "ascii" ? parseAsciiPly(buffer, { ...header, elements: meta.elements }) : parseBinaryPly(buffer, { ...header, meta }); if (!model.triangleCount && !model.pointCount) { throw new Error("PLY file has no readable vertices or faces"); } return model; } function getPlyFaceCount(buffer) { try { const header = findPlyHeader(buffer).text; const match = header.match(/element\s+face\s+(\d+)/i); return match ? Number(match[1]) : 0; } catch { return 0; } } function getThreeGeometryStats(geometry, fileName, faceCount = 0) { geometry.computeBoundingBox(); const box = geometry.boundingBox; const size = new THREE.Vector3(); box.getSize(size); const pointCount = geometry.getAttribute("position")?.count || 0; const isStl = fileName.toLowerCase().endsWith(".stl"); const triangleCount = isStl ? Math.floor(pointCount / 3) : faceCount || (geometry.index ? Math.floor(geometry.index.count / 3) : 0); return { triangleCount, pointCount, bounds: { min: [box.min.x, box.min.y, box.min.z], max: [box.max.x, box.max.y, box.max.z], size: [size.x, size.y, size.z], extent: Math.max(size.x, size.y, size.z) || 1, }, }; } function parseModelFile(buffer, fileName) { const name = fileName.toLowerCase(); let geometry; let faceCount = 0; let isPointCloud = false; if (name.endsWith(".stl")) { geometry = stlLoader.parse(buffer); } else if (name.endsWith(".ply")) { faceCount = getPlyFaceCount(buffer); geometry = plyLoader.parse(buffer); isPointCloud = faceCount === 0; } else { throw new Error("Unsupported model file type"); } const stats = getThreeGeometryStats(geometry, fileName, faceCount); return { ...stats, geometry, isPointCloud, sampled: stats.triangleCount > 50000 || stats.pointCount > 100000, }; } function rotatePoint(point) { const rx = (rotation.rx * Math.PI) / 180; const ry = (rotation.ry * Math.PI) / 180; const rz = (rotation.rz * Math.PI) / 180; let [x, y, z] = point; let y1 = y * Math.cos(rx) - z * Math.sin(rx); let z1 = y * Math.sin(rx) + z * Math.cos(rx); y = y1; z = z1; let x1 = x * Math.cos(ry) + z * Math.sin(ry); z1 = -x * Math.sin(ry) + z * Math.cos(ry); x = x1; z = z1; x1 = x * Math.cos(rz) - y * Math.sin(rz); y1 = x * Math.sin(rz) + y * Math.cos(rz); return [x1, y1, z]; } function normalOf(points) { const [a, b, c] = points; const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; const v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; const normal = [ u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0], ]; const length = Math.hypot(...normal) || 1; return normal.map((value) => value / length); } function colorForTriangle(kind, shade, heat) { if (kind === "student" && heatToggle.checked && heat > 0.08) { const warm = Math.min(1, heat * 5); return `rgba(${220 + warm * 25}, ${168 - warm * 92}, ${72 - warm * 34}, 0.88)`; } if (kind === "standard") { const tone = Math.round(150 + shade * 82); return `rgb(${tone}, ${Math.round(tone + 18)}, ${Math.round(tone + 12)})`; } const tone = Math.round(142 + shade * 78); return `rgb(${tone}, ${Math.round(tone + 22)}, ${Math.round(tone + 16)})`; } function projectPoint(point, rect, scale) { const [x, y, z] = rotatePoint(point); const perspective = 1 / (2.6 - z * 0.42); return { x: rect.width / 2 + x * scale * perspective, y: rect.height / 2 + y * scale * perspective, z, }; } function drawModel(canvas, model, kind) { if (!canvas || !model) return; const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; const targetWidth = Math.max(1, Math.floor(rect.width * dpr)); const targetHeight = Math.max(1, Math.floor(rect.height * dpr)); if (canvas.width !== targetWidth || canvas.height !== targetHeight) { canvas.width = targetWidth; canvas.height = targetHeight; } const context = canvas.getContext("2d"); context.clearRect(0, 0, canvas.width, canvas.height); context.save(); context.scale(dpr, dpr); const scale = Math.min(rect.width, rect.height) * 0.88 * (Number(zoomRange.value) / 100); const light = [-0.35, -0.48, 0.8]; const source = kind === "student" ? stlModels.standard : null; const modelTriangles = previewRender ? model.previewTriangles : model.triangles; const sourceTriangles = source ? (previewRender ? source.previewTriangles : source.triangles) : null; const triangles = modelTriangles.map((triangle, index) => { const rotated = triangle.map(rotatePoint); const projected = rotated.map(([x, y, z]) => { const perspective = 1 / (2.6 - z * 0.42); return { x: rect.width / 2 + x * scale * perspective, y: rect.height / 2 + y * scale * perspective, z, }; }); const normal = normalOf(rotated); const shade = Math.max(0, normal[0] * light[0] + normal[1] * light[1] + normal[2] * light[2]); let heat = 0; if (sourceTriangles && sourceTriangles[index]) { const centroid = triangle.reduce((sum, vertex) => sum.map((value, axis) => value + vertex[axis] / 3), [0, 0, 0]); const target = sourceTriangles[index].reduce((sum, vertex) => sum.map((value, axis) => value + vertex[axis] / 3), [0, 0, 0]); heat = Math.hypot(centroid[0] - target[0], centroid[1] - target[1], centroid[2] - target[2]); } return { projected, shade, heat, depth: rotated.reduce((sum, point) => sum + point[2], 0) / 3, }; }); if (triangles.length) { triangles.sort((a, b) => a.depth - b.depth); context.lineJoin = "round"; triangles.forEach((triangle) => { context.beginPath(); context.moveTo(triangle.projected[0].x, triangle.projected[0].y); context.lineTo(triangle.projected[1].x, triangle.projected[1].y); context.lineTo(triangle.projected[2].x, triangle.projected[2].y); context.closePath(); context.fillStyle = colorForTriangle(kind, triangle.shade, triangle.heat); context.strokeStyle = "rgba(48, 72, 68, 0.08)"; context.lineWidth = 0.6; context.fill(); if (!previewRender) context.stroke(); }); } else { const modelPoints = previewRender ? model.previewPoints : model.points; const sourcePoints = source ? (previewRender ? source.previewPoints : source.points) : null; const projectedPoints = modelPoints.map((point, index) => { const projected = projectPoint(point, rect, scale); let heat = 0; if (sourcePoints && sourcePoints[index]) { heat = Math.hypot( point[0] - sourcePoints[index][0], point[1] - sourcePoints[index][1], point[2] - sourcePoints[index][2] ); } return { ...projected, heat }; }).sort((a, b) => a.z - b.z); context.shadowColor = "rgba(25, 58, 53, 0.18)"; context.shadowBlur = previewRender ? 0 : 2; projectedPoints.forEach((point) => { const warm = kind === "student" && heatToggle.checked ? Math.min(1, point.heat * 7) : 0; const depth = Math.max(0, Math.min(1, (point.z + 0.55) / 1.1)); const radius = (previewRender ? 2.2 : 2.75) + depth * 0.8; context.beginPath(); context.arc(point.x, point.y, radius, 0, Math.PI * 2); context.fillStyle = warm ? `rgba(${230 + warm * 25}, ${180 - warm * 105}, ${82 - warm * 38}, 0.82)` : kind === "standard" ? `rgba(${82 + depth * 78}, ${126 + depth * 78}, ${116 + depth * 74}, 0.92)` : `rgba(${72 + depth * 84}, ${118 + depth * 82}, ${109 + depth * 76}, 0.92)`; context.fill(); if (!previewRender) { context.strokeStyle = "rgba(255, 255, 255, 0.26)"; context.lineWidth = 0.55; context.stroke(); } }); context.shadowBlur = 0; } context.restore(); } function renderAllStl() { viewers.standard.render(); viewers.student.render(); } function scheduleRenderAll(preview = previewRender) { previewRender = preview; if (renderQueued) return; renderQueued = true; requestAnimationFrame(() => { renderQueued = false; renderAllStl(); }); } function compareLoadedStlModels() { const standard = stlModels.standard; const student = stlModels.student; if (!standard || !student) return; const sizeDiff = standard.bounds.size.reduce((total, value, axis) => { const base = Math.max(value, 0.0001); return total + Math.abs(student.bounds.size[axis] - value) / base; }, 0) / 3; const standardCount = standard.triangleCount || standard.pointCount; const studentCount = student.triangleCount || student.pointCount; const geometryDiff = Math.abs(studentCount - standardCount) / Math.max(standardCount, 1); const score = Math.max(60, Math.min(99.8, 100 - sizeDiff * 42 - geometryDiff * 16)); scoreValue.textContent = score.toFixed(1); metricValues[0].textContent = `${Math.max(65, 100 - sizeDiff * 72).toFixed(0)}%`; metricValues[1].textContent = `${Math.max(62, 100 - geometryDiff * 45).toFixed(0)}%`; metricValues[2].textContent = `${(Math.abs(student.bounds.extent - standard.bounds.extent) / Math.max(standard.bounds.extent, 1) * 2.2).toFixed(2)}mm`; metricValues[3].textContent = `${Math.max(60, 100 - (sizeDiff + geometryDiff) * 35).toFixed(0)}%`; } function scoreItem(points) { const delta = Math.random() * 0.5; return Math.max(0, points - delta); } function showModelAlert(message) { modelAlertText.textContent = message; modelAlert.hidden = false; } function hideModelAlert() { modelAlert.hidden = true; } function hideScoreModal() { scoreModal.hidden = true; } function renderScoreTable() { if (!stlModels.standard || !stlModels.student) { const missing = [ !stlModels.standard ? "标准模型" : "", !stlModels.student ? "学生模型" : "", ].filter(Boolean).join("和"); showModelAlert(`评分前请先选择${missing}。`); return; } scoreTableBody.innerHTML = ""; let grandTotal = 0; scoringGroups.forEach((group) => { let groupTotal = 0; group.items.forEach(([point, allocation], index) => { const row = document.createElement("tr"); const score = scoreItem(allocation); groupTotal += score; grandTotal += score; if (index === 0) { const categoryCell = document.createElement("td"); categoryCell.className = "category"; categoryCell.rowSpan = group.items.length; categoryCell.textContent = group.category; row.appendChild(categoryCell); } const pointCell = document.createElement("td"); pointCell.className = "point"; pointCell.textContent = point; row.appendChild(pointCell); const allocationCell = document.createElement("td"); allocationCell.textContent = allocation; row.appendChild(allocationCell); const scoreCell = document.createElement("td"); scoreCell.textContent = score.toFixed(1); row.appendChild(scoreCell); if (index === 0) { const totalCell = document.createElement("td"); totalCell.className = "total"; totalCell.rowSpan = group.items.length; totalCell.dataset.group = group.category; row.appendChild(totalCell); } scoreTableBody.appendChild(row); }); const totalCell = scoreTableBody.querySelector(`[data-group="${group.category}"]`); totalCell.textContent = groupTotal.toFixed(1); }); reportTotal.textContent = grandTotal.toFixed(1); scoreValue.textContent = grandTotal.toFixed(1); scoreModal.hidden = false; } async function bindModelFile(inputId, labelId, kind, canvas) { const input = document.querySelector(inputId); const label = document.querySelector(labelId); const picker = input.closest(".mini-file"); picker.addEventListener("pointerdown", (event) => { event.stopPropagation(); }); input.addEventListener("click", (event) => { event.stopPropagation(); }); input.addEventListener("change", async () => { const file = input.files && input.files[0]; if (!file) return; const fileName = file.name.toLowerCase(); if (!fileName.endsWith(".stl") && !fileName.endsWith(".ply")) { label.textContent = "请选择 .stl 或 .ply 文件"; return; } label.textContent = `${file.name} 读取中...`; try { stlModels[kind] = parseModelFile(await file.arrayBuffer(), file.name); const countLabel = stlModels[kind].triangleCount ? `${stlModels[kind].triangleCount} 面` : `${stlModels[kind].pointCount} 点`; label.textContent = `${file.name} · ${countLabel} · WebGL`; canvas.closest(".model-space").classList.add("has-model"); viewers[kind].setGeometry(stlModels[kind].geometry, { isPointCloud: stlModels[kind].isPointCloud, }); scheduleRenderAll(false); compareLoadedStlModels(); } catch (error) { stlModels[kind] = null; canvas.closest(".model-space").classList.remove("has-model"); label.textContent = "模型解析失败,请重新选择"; console.error(error); } }); } createQueue(); document.querySelectorAll(".dental-object").forEach(createDentalObject); document.querySelector(".student-object").classList.toggle("heat-on", heatToggle.checked); updateRotation(); updateScanUI(); startButton.addEventListener("click", () => { if (scanState.running) { updateScanUI(); stopScan("继续采集"); return; } if (scanState.count >= SCAN_TARGET) { resetScan(); } if (!scanState.baseDurationMs) { scanState.baseDurationMs = randomBaseScanDuration(); } updateTargetDuration(); scanState.startedAt = Date.now() - scanState.elapsedMs; scanState.running = true; beginScanSegment(); startButton.innerHTML = `暂停采集`; scanState.interval = setInterval(tickScan, 250); updateScanUI(); }); resetButton.addEventListener("click", resetScan); scanSpeedSelect.addEventListener("change", () => { updateElapsedFromClock(); updateScanUI(); beginScanSegment(); if (scanState.running) { scanState.startedAt = Date.now() - scanState.elapsedMs; } updateScanUI(); if (scanState.running && scanState.count >= SCAN_TARGET) { scoreValue.textContent = "96.8"; stopScan("重新采集"); scanState.elapsedMs = scanState.targetDurationMs; updateScanUI(); } }); document.querySelectorAll(".segment").forEach((button) => { button.addEventListener("click", () => { setActiveButton(document.querySelectorAll(".segment"), button); }); }); document.querySelectorAll(".angle-btn").forEach((button) => { button.addEventListener("click", () => { setActiveButton(document.querySelectorAll(".angle-btn"), button); rotation = { ...viewAngles[button.dataset.view] }; updateRotation(); }); }); zoomRange.addEventListener("input", () => { root.style.setProperty("--zoom", Number(zoomRange.value) / 100); scheduleRenderAll(true); clearTimeout(zoomSettleTimer); zoomSettleTimer = setTimeout(() => scheduleRenderAll(false), 120); }); heatToggle.addEventListener("change", () => { document.querySelector(".student-object").classList.toggle("heat-on", heatToggle.checked); scheduleRenderAll(false); }); scoreButton.addEventListener("click", renderScoreTable); scoreModalClose.addEventListener("click", hideScoreModal); scoreModal.addEventListener("click", (event) => { if (event.target === scoreModal) hideScoreModal(); }); modelAlertClose.addEventListener("click", hideModelAlert); modelAlert.addEventListener("click", (event) => { if (event.target === modelAlert) hideModelAlert(); }); document.querySelectorAll(".model-space").forEach((space) => { space.addEventListener("pointerdown", (event) => { dragging = true; previewRender = true; lastPointer = { x: event.clientX, y: event.clientY }; space.setPointerCapture(event.pointerId); }); space.addEventListener("pointermove", (event) => { if (!dragging) return; const dx = event.clientX - lastPointer.x; const dy = event.clientY - lastPointer.y; rotation.ry += dx * 0.42; rotation.rx -= dy * 0.32; rotation.rx = Math.max(-82, Math.min(58, rotation.rx)); lastPointer = { x: event.clientX, y: event.clientY }; updateRotation(); }); ["pointerup", "pointercancel", "pointerleave"].forEach((eventName) => { space.addEventListener(eventName, () => { const wasDragging = dragging; dragging = false; if (wasDragging) scheduleRenderAll(false); }); }); }); bindModelFile("#standardFile", "#standardName", "standard", standardCanvas); bindModelFile("#studentFile", "#studentName", "student", studentCanvas); window.addEventListener("resize", () => scheduleRenderAll(false));