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));