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 @@
+
+
+
+
+
+ 口腔三维扫描采集与评分对比终端
+
+
+
+
+
+
+
+
+
+
+
HIGH-SPEED ACQUISITION
+
高速扫描采集
+
+
采集端 01
+
+
+
+
+
+
+
+ 00
+ / 50
+
+
7-10分钟随机总时长
+
+
+
+
+
+ 00:00
+ 扫描用时
+
+
+ 0.0
+ 模型/分钟
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 扫描进度
+ 已完成 0 / 50
+ 预计总时长 7-10分钟
+
+
+
+
+
+
+
+
+
MODEL SCORING COMPARISON
+
标准模型与学生评分模型对比
+
+
+ 综合评分
+ 92.6
+
+
+
+
+
+
+
+
+
+ 边缘密合
+ 94%
+
+
+ 轴面形态
+ 90%
+
+
+ 咬合偏差
+ 0.18mm
+
+
+ 邻接关系
+ 91%
+
+
+
+
+
+
+
+
+
+
请选择模型
+
评分前需要先选择标准模型和学生模型。
+
+
+
+
+
+
+
+
+
SCORING DETAIL
+
牙体雕刻评分表
+
+
+ 0.0
+
+
+
+
+
+
+
+ | 考核内容 |
+ 考核要点 |
+ 配分 |
+ 得分 |
+ 合计 |
+
+
+
+
+
+
+
+
+
+
+
+
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;
+ }
+}