commit 0a86816b5544329af748b7ad945fe81d0b91706b Author: stary <834207172@qq.com> Date: Sat Jun 6 19:46:14 2026 +0800 单人单台扫描终端支持10分钟内完成≥50个牙列或牙体雕刻作品高速扫描采集 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fba09c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.npm-cache/ diff --git a/index.html b/index.html new file mode 100644 index 0000000..00e6342 --- /dev/null +++ b/index.html @@ -0,0 +1,244 @@ + + + + + + 口腔三维扫描采集与评分对比终端 + + + +
+
+
+

DENTAL 3D SCAN TERMINAL

+

口腔三维扫描采集与评分对比终端

+
+
+ + 单台扫描模式 + 7-10分钟 / 50个模型 +
+
+ +
+
+
+
+

HIGH-SPEED ACQUISITION

+

高速扫描采集

+
+
采集端 01
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+ 00 + / 50 +
+

7-10分钟随机总时长

+
+ +
+
+
+ 00:00 + 扫描用时 +
+
+ 0.0 + 模型/分钟 +
+
+ +
+
+ +
+ + +
+ +
+ + + +
+ +
+ 扫描进度 + 已完成 0 / 50 + 预计总时长 7-10分钟 +
+ +
+
+ +
+
+
+

MODEL SCORING COMPARISON

+

标准模型与学生评分模型对比

+
+
+ 综合评分 + 92.6 +
+
+ +
+
+ + + + +
+ + + +
+ +
+
+
+ 标准 3D 模型 + 标准模型.stl / .ply +
+
+
X/Y/Z
+ + +
+ +
+ +
+
+ 学生评分 3D 模型 + 学生作品.stl / .ply +
+
+
X/Y/Z
+ + +
+ +
+
+ +
+
+ 边缘密合 + 94% +
+
+ 轴面形态 + 90% +
+
+ 咬合偏差 + 0.18mm +
+
+ 邻接关系 + 91% +
+
+ +
+
+
+ + + + + + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..86fb7a8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,18 @@ +{ + "name": "point3D_demo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "three": "^0.184.0" + } + }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bcff64c --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "three": "^0.184.0" + } +} diff --git a/script.js b/script.js new file mode 100644 index 0000000..833b7d7 --- /dev/null +++ b/script.js @@ -0,0 +1,1210 @@ +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)); diff --git a/server.cjs b/server.cjs new file mode 100644 index 0000000..9bb85f4 --- /dev/null +++ b/server.cjs @@ -0,0 +1,40 @@ +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const root = __dirname; +const port = Number(process.env.PORT || 4173); +const types = { + ".html": "text/html;charset=utf-8", + ".css": "text/css;charset=utf-8", + ".js": "text/javascript;charset=utf-8", +}; + +const server = http.createServer((request, response) => { + const url = new URL(request.url, `http://localhost:${port}`); + const requestedPath = url.pathname === "/" ? "index.html" : url.pathname.slice(1); + const filePath = path.resolve(root, requestedPath); + + if (!filePath.startsWith(root)) { + response.writeHead(403); + response.end("Forbidden"); + return; + } + + fs.readFile(filePath, (error, buffer) => { + if (error) { + response.writeHead(404); + response.end("Not found"); + return; + } + + response.writeHead(200, { + "Content-Type": types[path.extname(filePath)] || "application/octet-stream", + }); + response.end(buffer); + }); +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`Dental 3D demo: http://localhost:${port}`); +}); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..f373dca --- /dev/null +++ b/styles.css @@ -0,0 +1,1165 @@ +:root { + --ink: #17201f; + --muted: #66736f; + --line: #d9e1dc; + --panel: rgba(255, 255, 255, 0.88); + --panel-strong: #f8fbf8; + --teal: #007d74; + --teal-dark: #07534f; + --mint: #b8e8dd; + --coral: #e7604d; + --amber: #d49b28; + --blue: #2f6fb1; + --shadow: 0 22px 70px rgba(18, 45, 41, 0.16); + --rx: -18deg; + --ry: 24deg; + --rz: 0deg; + --zoom: 1; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + color: var(--ink); + font-family: "Microsoft YaHei UI", "Noto Sans CJK SC", "Segoe UI", sans-serif; + background: + linear-gradient(120deg, rgba(0, 125, 116, 0.08), transparent 34%), + linear-gradient(240deg, rgba(231, 96, 77, 0.09), transparent 30%), + repeating-linear-gradient(90deg, rgba(23, 32, 31, 0.035) 0 1px, transparent 1px 82px), + #edf2ee; +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +.app-shell { + width: min(1500px, calc(100% - 40px)); + margin: 0 auto; + padding: 26px 0 32px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 82px; + margin-bottom: 18px; +} + +.eyebrow { + margin: 0 0 7px; + color: var(--teal-dark); + font-size: 12px; + font-weight: 800; + letter-spacing: 0; +} + +h1, +h2 { + margin: 0; + font-family: "Microsoft JhengHei UI", "Microsoft YaHei UI", sans-serif; + letter-spacing: 0; +} + +h1 { + font-size: clamp(28px, 4vw, 54px); + line-height: 1.05; +} + +h2 { + font-size: 25px; +} + +.status-strip { + display: flex; + align-items: center; + gap: 12px; + min-width: 330px; + padding: 14px 16px; + border: 1px solid rgba(0, 125, 116, 0.2); + border-radius: 8px; + background: rgba(255, 255, 255, 0.72); + box-shadow: 0 10px 36px rgba(18, 45, 41, 0.08); + color: var(--muted); +} + +.status-strip strong { + color: var(--teal-dark); +} + +.pulse { + width: 10px; + height: 10px; + border-radius: 50%; + background: #00a87d; + box-shadow: 0 0 0 8px rgba(0, 168, 125, 0.12); +} + +.workbench { + display: grid; + grid-template-columns: minmax(360px, 0.86fr) minmax(620px, 1.45fr); + gap: 18px; +} + +.panel { + min-width: 0; + border: 1px solid rgba(23, 32, 31, 0.1); + border-radius: 8px; + background: var(--panel); + box-shadow: var(--shadow); + overflow: hidden; +} + +.panel-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 18px; + padding: 22px 22px 16px; + border-bottom: 1px solid rgba(23, 32, 31, 0.08); +} + +.terminal-badge, +.score-pill { + flex: 0 0 auto; + border-radius: 8px; + border: 1px solid rgba(0, 125, 116, 0.16); + background: #eef8f5; + color: var(--teal-dark); +} + +.terminal-badge { + padding: 9px 11px; + font-weight: 800; +} + +.score-pill { + display: grid; + gap: 2px; + min-width: 112px; + padding: 9px 12px; + text-align: right; +} + +.score-pill span { + color: var(--muted); + font-size: 12px; +} + +.score-pill strong { + font-size: 26px; + line-height: 1; +} + +.scanner-panel { + display: flex; + flex-direction: column; +} + +.scan-hero { + display: grid; + grid-template-columns: 1.08fr 0.92fr; + gap: 14px; + padding: 18px 18px 12px; +} + +.scan-window { + position: relative; + min-height: 270px; + border-radius: 8px; + border: 1px solid #cbd8d2; + overflow: hidden; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(218, 232, 226, 0.9)), + repeating-linear-gradient(0deg, transparent 0 24px, rgba(0, 125, 116, 0.05) 24px 25px); +} + +.scanner-rail { + position: absolute; + left: 11%; + right: 11%; + top: 34px; + height: 9px; + border-radius: 99px; + background: linear-gradient(90deg, #17201f, #59716c 52%, #17201f); +} + +.scanner-head { + position: absolute; + left: 50%; + top: 48px; + width: 86px; + height: 52px; + border-radius: 8px; + transform: translateX(-50%); + background: linear-gradient(145deg, #1d2d2a, #3f5c56); + box-shadow: 0 18px 35px rgba(0, 0, 0, 0.18); + animation: scanHead 3.8s ease-in-out infinite; +} + +.scanner-head span { + position: absolute; + left: 50%; + bottom: -12px; + width: 32px; + height: 18px; + transform: translateX(-50%); + border-radius: 0 0 8px 8px; + background: var(--teal); +} + +.scan-beam { + position: absolute; + left: 50%; + top: 102px; + width: 170px; + height: 150px; + transform: translateX(-50%); + clip-path: polygon(47% 0, 53% 0, 100% 100%, 0 100%); + background: linear-gradient(180deg, rgba(37, 206, 184, 0.44), rgba(37, 206, 184, 0.03)); + mix-blend-mode: multiply; + animation: beam 1.6s ease-in-out infinite alternate; +} + +.turntable { + position: absolute; + left: 50%; + bottom: 26px; + width: 210px; + height: 112px; + transform: translateX(-50%); +} + +.plaster-base { + position: absolute; + inset: 50px 0 0; + border-radius: 50%; + background: radial-gradient(ellipse at center, #ffffff 0 36%, #d8e0db 64%, #aebdb8 100%); + box-shadow: 0 20px 30px rgba(15, 37, 34, 0.18); +} + +.mini-arch { + position: absolute; + left: 50%; + top: 0; + width: 150px; + height: 88px; + transform: translateX(-50%) rotateX(62deg); + transform-style: preserve-3d; +} + +.mini-arch i { + position: absolute; + width: 23px; + height: 42px; + border-radius: 50% 50% 42% 42%; + background: linear-gradient(145deg, #fff, #cdd8d2); + box-shadow: inset -5px -7px 10px rgba(72, 88, 84, 0.18); +} + +.mini-arch i:nth-child(1) { left: 5px; top: 44px; transform: rotate(-34deg); } +.mini-arch i:nth-child(2) { left: 25px; top: 23px; transform: rotate(-22deg); } +.mini-arch i:nth-child(3) { left: 55px; top: 11px; transform: rotate(-8deg); } +.mini-arch i:nth-child(4) { left: 88px; top: 11px; transform: rotate(8deg); } +.mini-arch i:nth-child(5) { left: 118px; top: 23px; transform: rotate(22deg); } +.mini-arch i:nth-child(6) { left: 138px; top: 44px; transform: rotate(34deg); } +.mini-arch i:nth-child(7) { left: 71px; top: 38px; width: 19px; height: 34px; } + +.scan-readout { + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + padding: 18px; + border-radius: 8px; + color: #eaf8f4; + background: + linear-gradient(145deg, rgba(7, 83, 79, 0.98), rgba(23, 32, 31, 0.95)), + repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.08) 0 1px, transparent 1px 28px); +} + +.readout-main { + display: flex; + align-items: flex-end; + gap: 6px; + font-family: "Consolas", "Microsoft YaHei UI", monospace; +} + +.readout-main span { + font-size: clamp(58px, 8vw, 106px); + line-height: 0.92; +} + +.readout-main small { + padding-bottom: 9px; + font-size: 22px; + color: #a9d6cc; +} + +.scan-readout p { + margin: 8px 0 18px; + color: #bde7de; +} + +.progress-track { + height: 10px; + border-radius: 99px; + overflow: hidden; + background: rgba(255, 255, 255, 0.16); +} + +.progress-track span { + display: block; + width: 0%; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--mint), #ffffff); + transition: width 0.35s ease; +} + +.readout-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 16px; +} + +.readout-grid div { + padding: 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); +} + +.readout-grid span, +.readout-grid small { + display: block; +} + +.readout-grid span { + font-size: 24px; + font-weight: 800; +} + +.readout-grid small { + color: #add9d0; + margin-top: 3px; +} + +.scan-speed-control { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 42px; + margin-top: 12px; + padding: 8px 10px 8px 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.1); + color: #bde7de; + font-weight: 800; +} + +.scan-speed-control select { + min-width: 82px; + min-height: 30px; + border: 1px solid rgba(189, 231, 222, 0.42); + border-radius: 6px; + color: #073f3c; + background: #eaf8f4; + font-weight: 900; +} + +.scan-speed-control span { + white-space: nowrap; +} + +.speed-tooltip { + position: absolute; + right: 0; + bottom: calc(100% + 8px); + width: max-content; + max-width: 220px; + padding: 8px 10px; + border-radius: 6px; + color: #17312e; + background: #fff; + box-shadow: 0 12px 30px rgba(15, 37, 34, 0.18); + font-style: normal; + font-size: 13px; + opacity: 0; + pointer-events: none; + transform: translateY(4px); + transition: opacity 0.16s ease, transform 0.16s ease; +} + +.speed-tooltip::after { + content: ""; + position: absolute; + right: 22px; + top: 100%; + border: 7px solid transparent; + border-top-color: #fff; +} + +.scan-speed-control:hover .speed-tooltip, +.scan-speed-control:focus-within .speed-tooltip { + opacity: 1; + transform: translateY(0); +} + +.control-row, +.segmented, +.file-zone, +.queue { + margin-inline: 18px; +} + +.control-row { + display: flex; + gap: 10px; + margin-top: 4px; +} + +.primary-btn, +.ghost-btn, +.angle-btn, +.segment { + min-height: 42px; + border-radius: 8px; + border: 1px solid transparent; + font-weight: 800; +} + +.primary-btn { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + color: white; + background: linear-gradient(135deg, var(--teal), #0f5f77); + box-shadow: 0 12px 24px rgba(0, 125, 116, 0.22); +} + +.icon { + font-size: 15px; +} + +.ghost-btn { + color: var(--teal-dark); + background: #fff; + border-color: var(--line); +} + +.icon-btn { + width: 48px; + font-size: 23px; +} + +.segmented { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + margin-top: 14px; + padding: 5px; + border: 1px solid var(--line); + border-radius: 8px; + background: #eef3ef; +} + +.segment { + padding: 0 8px; + color: var(--muted); + background: transparent; +} + +.segment.active { + color: white; + background: var(--teal-dark); +} + +.file-zone { + display: grid; + gap: 6px; + margin-top: 14px; + padding: 14px; + border: 1px dashed rgba(0, 125, 116, 0.45); + border-radius: 8px; + background: rgba(255, 255, 255, 0.7); + cursor: pointer; +} + +.progress-zone { + cursor: default; +} + +.file-zone input { + position: absolute; + inline-size: 1px; + block-size: 1px; + opacity: 0; + pointer-events: none; +} + +.file-zone span { + color: var(--muted); +} + +.file-zone strong { + color: var(--teal-dark); +} + +.file-zone small { + color: var(--muted); +} + +.queue { + display: grid; + grid-template-columns: repeat(10, 1fr); + gap: 5px; + margin-top: 14px; + margin-bottom: 18px; +} + +.queue span { + position: relative; + display: grid; + place-items: center; + aspect-ratio: 1; + border-radius: 4px; + background: #dce7e2; + border: 1px solid rgba(23, 32, 31, 0.06); + overflow: hidden; +} + +.queue span.done { + background: linear-gradient(145deg, #ffffff, var(--mint)); + border-color: rgba(0, 125, 116, 0.42); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5); +} + +.queue span.done::before { + content: ""; + width: 54%; + height: 68%; + border-radius: 46% 46% 34% 34%; + background: + radial-gradient(circle at 34% 22%, rgba(255, 255, 255, 0.95) 0 16%, transparent 18%), + linear-gradient(145deg, #fffefa, #d5e2dc 72%, #9eb3ac); + box-shadow: + inset -4px -6px 8px rgba(54, 88, 81, 0.18), + 0 5px 10px rgba(0, 125, 116, 0.16); + transform: rotate(-8deg); +} + +.queue span.done::after { + content: ""; + position: absolute; + width: 17%; + height: 17%; + top: 28%; + left: 43%; + border-radius: 50%; + background: rgba(77, 105, 99, 0.16); +} + +.compare-toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 16px 18px; + background: rgba(248, 251, 248, 0.76); +} + +.angle-group { + display: flex; + gap: 6px; + padding: 5px; + border: 1px solid var(--line); + border-radius: 8px; + background: #eef3ef; +} + +.angle-btn { + padding: 0 13px; + color: var(--muted); + background: transparent; +} + +.angle-btn.active { + color: white; + background: #1c2c2a; +} + +.zoom-control { + display: flex; + align-items: center; + gap: 9px; + min-height: 42px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid var(--line); + background: #fff; + color: var(--muted); + font-weight: 800; +} + +.zoom-control input { + width: 132px; + accent-color: var(--teal); +} + +.switch { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 42px; + color: var(--muted); + font-weight: 800; +} + +.switch input { + position: absolute; + opacity: 0; +} + +.switch span { + position: relative; + width: 48px; + height: 26px; + border-radius: 999px; + background: #cbd8d2; + transition: background 0.2s ease; +} + +.switch span::after { + content: ""; + position: absolute; + top: 4px; + left: 4px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + transition: transform 0.2s ease; +} + +.switch input:checked + span { + background: var(--coral); +} + +.switch input:checked + span::after { + transform: translateX(22px); +} + +.score-action { + min-height: 42px; + padding: 0 18px; + border: 1px solid rgba(0, 125, 116, 0.22); + border-radius: 8px; + color: white; + background: linear-gradient(135deg, var(--coral), var(--amber)); + font-weight: 900; + box-shadow: 0 12px 24px rgba(231, 96, 77, 0.2); +} + +.viewer { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + padding: 0 18px 16px; +} + +.model-card { + min-width: 0; + border: 1px solid rgba(23, 32, 31, 0.1); + border-radius: 8px; + background: #fff; + overflow: hidden; +} + +.card-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 13px 14px; + border-bottom: 1px solid rgba(23, 32, 31, 0.08); +} + +.card-title span { + font-weight: 900; +} + +.card-title small { + min-width: 0; + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; +} + +.model-space { + position: relative; + height: 390px; + display: grid; + place-items: center; + perspective: 900px; + overflow: hidden; + background: + radial-gradient(circle at 50% 58%, rgba(0, 125, 116, 0.1), transparent 42%), + linear-gradient(180deg, #f7faf8, #e3ebe6); + touch-action: none; +} + +.stl-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + transition: opacity 0.22s ease; +} + +.model-space.has-model .stl-canvas { + opacity: 1; +} + +.model-space.has-model { + background: + radial-gradient(circle at 50% 56%, rgba(0, 125, 116, 0.18), transparent 44%), + linear-gradient(180deg, #eef5f1, #d4e3dc); +} + +.model-space.has-model .dental-object { + opacity: 0; +} + +.axis-mark { + position: absolute; + z-index: 2; + left: 12px; + top: 12px; + padding: 6px 8px; + border-radius: 6px; + color: #60716c; + background: rgba(255, 255, 255, 0.7); + font-family: "Consolas", monospace; + font-size: 12px; + font-weight: 800; +} + +.dental-object { + position: relative; + width: 310px; + height: 260px; + transform-style: preserve-3d; + transform: rotateX(var(--rx)) rotateY(var(--ry)) rotateZ(var(--rz)) scale(var(--zoom)); + transition: transform 0.28s ease; +} + +.gum, +.tooth { + position: absolute; + transform-style: preserve-3d; +} + +.gum { + left: 50%; + top: 50%; + width: 260px; + height: 170px; + transform: translate(-50%, -42%) rotateX(70deg); + border: 18px solid rgba(214, 132, 119, 0.72); + border-top-width: 24px; + border-bottom-color: transparent; + border-radius: 50% 50% 42% 42%; + filter: drop-shadow(0 16px 22px rgba(78, 46, 43, 0.16)); +} + +.gum::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 132px; + height: 68px; + transform: translate(-50%, -44%); + border-radius: 50%; + background: rgba(255, 255, 255, 0.32); +} + +.tooth { + left: 50%; + top: 50%; + width: var(--tw); + height: var(--th); + border-radius: 48% 48% 36% 36%; + background: + radial-gradient(circle at 34% 24%, #ffffff 0 17%, transparent 18%), + linear-gradient(145deg, #fffefa, #d8e1dc 72%, #a7b6b0); + box-shadow: + inset -8px -11px 13px rgba(61, 81, 76, 0.14), + 0 12px 18px rgba(27, 54, 50, 0.16); + transform: + rotateZ(var(--a)) + translateY(var(--r)) + rotateZ(calc(var(--a) * -1)) + rotateX(16deg) + translateZ(var(--z)); +} + +.tooth::after { + content: ""; + position: absolute; + inset: 21% 24% auto; + height: 25%; + border-radius: 50%; + background: rgba(116, 132, 127, 0.14); +} + +.student-object .tooth:nth-child(4), +.student-object .tooth:nth-child(9), +.student-object .tooth:nth-child(13) { + filter: saturate(0.9); + transform: + rotateZ(var(--a)) + translateY(calc(var(--r) + var(--dr, 0px))) + rotateZ(calc(var(--a) * -1)) + rotateX(16deg) + rotateY(var(--tilt, 0deg)) + translateZ(var(--z)); +} + +.student-object .tooth:nth-child(4) { --dr: -8px; --tilt: 9deg; } +.student-object .tooth:nth-child(9) { --dr: 7px; --tilt: -8deg; } +.student-object .tooth:nth-child(13) { --dr: -5px; --tilt: 12deg; } + +.student-object.heat-on .tooth:nth-child(4)::before, +.student-object.heat-on .tooth:nth-child(9)::before, +.student-object.heat-on .tooth:nth-child(13)::before { + content: ""; + position: absolute; + inset: -5px; + border-radius: inherit; + background: radial-gradient(circle, rgba(231, 96, 77, 0.82), rgba(212, 155, 40, 0.36) 46%, transparent 67%); + mix-blend-mode: multiply; +} + +.mini-file { + position: relative; + z-index: 3; + display: flex; + align-items: center; + justify-content: center; + width: calc(100% - 24px); + min-height: 42px; + margin: 12px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--teal-dark); + background: #f7faf8; + font-weight: 800; + cursor: pointer; + overflow: hidden; +} + +.model-file-input { + position: absolute; + inset: 0; + inline-size: 100%; + block-size: 100%; + opacity: 0; + cursor: pointer; +} + +.metric-row { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + padding: 0 18px 18px; +} + +.metric { + padding: 13px; + border-radius: 8px; + border: 1px solid rgba(23, 32, 31, 0.09); + background: var(--panel-strong); +} + +.metric span, +.metric strong { + display: block; +} + +.metric span { + color: var(--muted); + font-size: 13px; +} + +.metric strong { + margin-top: 5px; + font-size: 22px; +} + +.score-report { + margin: 0 18px 18px; + border: 1px solid rgba(23, 32, 31, 0.09); + border-radius: 8px; + overflow: hidden; + background: #fff; +} + +.report-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px; + border-bottom: 1px solid rgba(23, 32, 31, 0.08); + background: #f7faf8; +} + +.report-head h3 { + margin: 0; + font-size: 22px; +} + +.report-head strong { + min-width: 96px; + padding: 10px 12px; + border-radius: 8px; + color: var(--teal-dark); + background: #eef8f5; + text-align: center; + font-size: 28px; +} + +.report-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.score-table-wrap { + max-height: 520px; + overflow: auto; +} + +.score-table { + width: 100%; + min-width: 760px; + border-collapse: separate; + border-spacing: 0; + font-size: 15px; +} + +.score-table th, +.score-table td { + padding: 12px 14px; + border-bottom: 1px solid rgba(23, 32, 31, 0.08); + text-align: center; +} + +.score-table th { + position: sticky; + top: 0; + z-index: 1; + color: #101817; + background: #fff; + font-weight: 900; +} + +.score-table td { + font-weight: 800; +} + +.score-table .category { + color: #101817; + background: #f7faf8; + font-size: 17px; +} + +.score-table .point { + text-align: left; +} + +.score-table .total { + color: var(--teal-dark); + background: #f3f8f6; + font-size: 18px; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 20; + display: grid; + place-items: center; + padding: 24px; + background: rgba(23, 32, 31, 0.34); + backdrop-filter: blur(5px); +} + +.modal-backdrop[hidden], +.score-report[hidden] { + display: none !important; +} + +.modal { + width: min(420px, 100%); + padding: 24px; + border-radius: 8px; + background: #fff; + box-shadow: 0 24px 80px rgba(23, 32, 31, 0.26); +} + +.score-report-dialog { + width: min(980px, calc(100vw - 36px)); + max-height: calc(100vh - 44px); + margin: 0; + box-shadow: 0 24px 80px rgba(23, 32, 31, 0.26); +} + +.score-report-dialog .score-table-wrap { + max-height: min(620px, calc(100vh - 180px)); +} + +.score-report-dialog .icon-btn { + flex: 0 0 auto; + width: 42px; + min-height: 42px; + font-size: 24px; + line-height: 1; +} + +.modal h3 { + margin: 0 0 10px; + font-size: 24px; +} + +.modal p { + margin: 0 0 18px; + color: var(--muted); + line-height: 1.6; +} + +.modal-close { + width: 100%; +} + +@keyframes scanHead { + 0%, 100% { transform: translateX(-85%); } + 50% { transform: translateX(-15%); } +} + +@keyframes beam { + from { opacity: 0.42; } + to { opacity: 0.78; } +} + +@media (max-width: 1120px) { + .workbench { + grid-template-columns: 1fr; + } + + .model-space { + height: 340px; + } +} + +@media (max-width: 760px) { + .app-shell { + width: min(100% - 24px, 1500px); + padding-top: 16px; + } + + .topbar, + .panel-head, + .scan-hero, + .viewer, + .metric-row { + grid-template-columns: 1fr; + } + + .topbar { + display: grid; + } + + .status-strip { + min-width: 0; + flex-wrap: wrap; + } + + .panel-head { + display: grid; + } + + .scan-hero { + display: grid; + } + + .viewer { + display: grid; + } + + .metric-row { + display: grid; + } + + .compare-toolbar { + align-items: stretch; + } + + .angle-group, + .zoom-control, + .switch, + .score-action { + width: 100%; + } + + .angle-group { + display: grid; + grid-template-columns: repeat(4, 1fr); + } + + .angle-btn { + padding: 0 6px; + } + + .segmented { + grid-template-columns: 1fr; + } + + .queue { + grid-template-columns: repeat(8, 1fr); + } + + .dental-object { + width: 260px; + height: 230px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + } +}