1211 lines
39 KiB
JavaScript
1211 lines
39 KiB
JavaScript
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 = `<span class="icon">▶</span>${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 = `<span class="icon">Ⅱ</span>暂停采集`;
|
|
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));
|