Files
nagi-bench/outputs/gpt-5-4/codex-xhigh/skeleton-watch.html
T
YinLing 2fb8499fa8 Add GPT-5.4 (Codex CLI · xhigh) skeleton-watch run (#22)
Adds the skeleton-watch artifact + run registration for the existing GPT-5.4 / Codex CLI · xhigh agent. Validated: both CI checks pass locally; artifact renders clean, no external calls beyond CDN three.js.
2026-06-21 11:19:43 +01:00

1730 lines
58 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Luxury Skeleton Watch Showcase</title>
<style>
:root {
color-scheme: dark;
--bg-0: #05070b;
--bg-1: #0b1220;
--bg-2: #111b2e;
--panel: rgba(10, 16, 27, 0.74);
--panel-border: rgba(180, 220, 255, 0.16);
--text: #f4f0e8;
--muted: rgba(230, 236, 244, 0.72);
--accent: #9ed6ff;
--accent-2: #77ffc5;
--gold: #d9c7a4;
--shadow: rgba(0, 0, 0, 0.55);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background:
radial-gradient(circle at 20% 18%, rgba(57, 105, 170, 0.22), transparent 32%),
radial-gradient(circle at 78% 14%, rgba(104, 170, 164, 0.12), transparent 26%),
radial-gradient(circle at 50% 82%, rgba(255, 213, 138, 0.08), transparent 36%),
linear-gradient(180deg, #0b1320 0%, #06080d 48%, #030407 100%);
color: var(--text);
font-family: "Segoe UI Variable Text", "PingFang SC", "Microsoft YaHei", sans-serif;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background:
linear-gradient(140deg, rgba(255, 255, 255, 0.06), transparent 18%, transparent 82%, rgba(255, 255, 255, 0.04)),
radial-gradient(circle at center, transparent 0 52%, rgba(255, 255, 255, 0.03) 73%, transparent 100%);
mix-blend-mode: screen;
opacity: 0.45;
}
#app {
position: fixed;
inset: 0;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
.hud {
position: fixed;
left: 32px;
top: 28px;
width: min(420px, calc(100vw - 64px));
padding: 22px 24px 18px;
border: 1px solid var(--panel-border);
border-radius: 22px;
background:
linear-gradient(180deg, rgba(18, 27, 40, 0.82), rgba(7, 11, 18, 0.72)),
rgba(8, 12, 18, 0.75);
backdrop-filter: blur(18px) saturate(135%);
box-shadow:
0 24px 70px rgba(0, 0, 0, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.07);
z-index: 20;
}
.eyebrow {
margin: 0 0 10px;
color: rgba(213, 222, 236, 0.72);
text-transform: uppercase;
letter-spacing: 0.34em;
font-size: 11px;
}
h1 {
margin: 0;
font-family: Baskerville, "Times New Roman", serif;
font-size: clamp(32px, 4vw, 48px);
line-height: 0.98;
font-weight: 600;
letter-spacing: 0.02em;
}
.subhead {
margin: 14px 0 18px;
color: var(--muted);
line-height: 1.6;
font-size: 14px;
}
.control-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
button {
border: 1px solid rgba(173, 218, 255, 0.22);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.04)),
rgba(16, 24, 38, 0.85);
color: var(--text);
padding: 11px 16px;
border-radius: 999px;
cursor: pointer;
font-size: 13px;
letter-spacing: 0.04em;
transition:
transform 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease,
background 180ms ease;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.07),
0 10px 24px rgba(0, 0, 0, 0.22);
}
button:hover {
transform: translateY(-1px);
border-color: rgba(175, 230, 255, 0.4);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 14px 32px rgba(0, 0, 0, 0.28);
}
button.active {
background:
linear-gradient(180deg, rgba(146, 214, 255, 0.24), rgba(75, 145, 184, 0.14)),
rgba(17, 30, 45, 0.92);
border-color: rgba(152, 216, 255, 0.54);
color: #eef8ff;
}
.status-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.status-card {
padding: 12px 12px 11px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.status-card .label {
display: block;
color: rgba(219, 228, 239, 0.64);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
margin-bottom: 6px;
}
.status-card strong {
display: block;
font-size: 15px;
font-weight: 600;
color: #f7f7f5;
}
.panel {
position: fixed;
right: 24px;
top: 24px;
bottom: 24px;
width: min(370px, calc(100vw - 48px));
padding: 22px 24px;
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.09);
background:
linear-gradient(180deg, rgba(9, 14, 23, 0.8), rgba(6, 10, 16, 0.88)),
rgba(8, 11, 18, 0.8);
backdrop-filter: blur(18px) saturate(135%);
box-shadow:
0 30px 80px rgba(0, 0, 0, 0.48),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
z-index: 18;
display: flex;
flex-direction: column;
gap: 16px;
}
.panel-head {
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.panel-kicker {
color: rgba(217, 230, 246, 0.62);
font-size: 11px;
letter-spacing: 0.24em;
text-transform: uppercase;
margin-bottom: 8px;
}
.panel h2 {
margin: 0 0 8px;
font-family: Baskerville, "Times New Roman", serif;
font-size: 32px;
font-weight: 600;
line-height: 1.04;
letter-spacing: 0.02em;
}
.panel p {
margin: 0;
line-height: 1.7;
color: var(--muted);
font-size: 14px;
}
.spec-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.spec-pill {
padding: 8px 11px;
border-radius: 999px;
border: 1px solid rgba(156, 209, 255, 0.16);
background: rgba(255, 255, 255, 0.04);
color: #eef6ff;
font-size: 12px;
letter-spacing: 0.03em;
}
.fact-block {
padding: 14px 0 2px;
border-top: 1px solid rgba(255, 255, 255, 0.07);
}
.fact-title {
color: #f9ebd3;
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 8px;
}
.fact-text {
color: rgba(228, 235, 244, 0.76);
line-height: 1.7;
font-size: 13px;
}
#partLabel {
position: fixed;
top: 0;
left: 0;
transform: translate(-50%, -50%);
padding: 10px 14px;
border-radius: 14px;
background: rgba(7, 11, 18, 0.86);
border: 1px solid rgba(161, 222, 255, 0.2);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.34);
color: #f4fbff;
font-size: 13px;
line-height: 1.4;
letter-spacing: 0.02em;
backdrop-filter: blur(14px);
pointer-events: none;
opacity: 0;
transition:
opacity 180ms ease,
transform 180ms ease;
z-index: 30;
white-space: nowrap;
}
#partLabel.visible {
opacity: 1;
transform: translate(-50%, calc(-100% - 14px));
}
.footer-note {
position: fixed;
left: 34px;
bottom: 28px;
width: min(410px, calc(100vw - 68px));
color: rgba(214, 224, 237, 0.56);
font-size: 12px;
letter-spacing: 0.04em;
line-height: 1.7;
z-index: 14;
}
.footer-note strong {
color: rgba(241, 248, 255, 0.88);
font-weight: 600;
}
@media (max-width: 1080px) {
.panel {
top: auto;
height: 280px;
width: calc(100vw - 24px);
left: 12px;
right: 12px;
bottom: 12px;
}
.hud {
top: 12px;
left: 12px;
right: 12px;
width: auto;
}
.footer-note {
display: none;
}
}
@media (max-width: 720px) {
.status-grid {
grid-template-columns: 1fr;
}
.panel {
height: 260px;
}
.panel h2 {
font-size: 28px;
}
}
</style>
</head>
<body>
<div id="app"></div>
<section class="hud">
<div class="eyebrow">Haute Horlogerie / Product Reveal</div>
<h1>镂空机械表</h1>
<p class="subhead">
拖拽旋转手表,悬停或点击零件查看说明。爆炸视图会分离蓝宝石水晶、夹板、轮系与表壳层级,便于观察复杂机芯结构。
</p>
<div class="control-row">
<button id="explodeBtn">切换爆炸视图</button>
<button id="spinBtn" class="active">自动展示开启</button>
<button id="resetBtn">重置视角</button>
</div>
<div class="status-grid">
<div class="status-card">
<span class="label">Power Reserve</span>
<strong>72 Hours</strong>
</div>
<div class="status-card">
<span class="label">Beat Rate</span>
<strong>28,800 vph</strong>
</div>
<div class="status-card">
<span class="label">Jewels</span>
<strong>21 Rubies</strong>
</div>
</div>
</section>
<aside class="panel" id="infoPanel">
<div class="panel-head">
<div class="panel-kicker" id="panelKicker">Movement Overview</div>
<h2 id="panelTitle">机械雕塑级展示</h2>
<p id="panelBody">
这枚表以镂空结构完整展示动力存储、轮系传动、擒纵和调速系统。金属表面采用多层反射与冷暖灯光塑形,夜光指针与刻度在深色背景中保持清晰可读。
</p>
</div>
<div class="spec-list" id="panelSpecs"></div>
<div class="fact-block">
<div class="fact-title">Interaction</div>
<div class="fact-text">
鼠标左键拖拽旋转,滚轮缩放。点击零件可锁定信息,点击空白区域可回到整体概览。
</div>
</div>
<div class="fact-block">
<div class="fact-title">Lighting</div>
<div class="fact-text">
使用多点摄影棚灯、环境反射与轻微辉光,让蓝宝石水晶、抛光边缘、蓝钢螺丝与红宝石轴承拥有更真实的高级制表质感。
</div>
</div>
</aside>
<div id="partLabel"></div>
<div class="footer-note">
<strong>提示:</strong> 爆炸视图最适合观察蓝宝石水晶、镂空夹板、发条盒、擒纵机构与摆轮游丝之间的空间层次。
</div>
<script>
window.__watchDebug = { errors: [] };
function showDebugOverlay(message) {
let overlay = document.getElementById("debugOverlay");
if (!overlay) {
overlay = document.createElement("pre");
overlay.id = "debugOverlay";
overlay.style.position = "fixed";
overlay.style.left = "16px";
overlay.style.right = "16px";
overlay.style.bottom = "16px";
overlay.style.zIndex = "999";
overlay.style.margin = "0";
overlay.style.padding = "14px 16px";
overlay.style.borderRadius = "14px";
overlay.style.background = "rgba(90, 10, 18, 0.92)";
overlay.style.border = "1px solid rgba(255, 148, 148, 0.45)";
overlay.style.color = "#fff2f2";
overlay.style.font = "12px/1.5 Consolas, monospace";
overlay.style.whiteSpace = "pre-wrap";
overlay.style.maxHeight = "32vh";
overlay.style.overflow = "auto";
document.body.appendChild(overlay);
}
overlay.textContent = window.__watchDebug.errors.join("\n\n");
}
window.addEventListener("error", (event) => {
const detail = `${event.message}\n${event.filename || ""}:${event.lineno || 0}:${event.colno || 0}`;
window.__watchDebug.errors.push(detail);
showDebugOverlay(detail);
});
window.addEventListener("unhandledrejection", (event) => {
const detail = `Unhandled rejection:\n${event.reason?.stack || event.reason || "unknown"}`;
window.__watchDebug.errors.push(detail);
showDebugOverlay(detail);
});
</script>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.165.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.165.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { Reflector } from "three/addons/objects/Reflector.js";
import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
const app = document.getElementById("app");
const explodeBtn = document.getElementById("explodeBtn");
const spinBtn = document.getElementById("spinBtn");
const resetBtn = document.getElementById("resetBtn");
const partLabel = document.getElementById("partLabel");
const panelKicker = document.getElementById("panelKicker");
const panelTitle = document.getElementById("panelTitle");
const panelBody = document.getElementById("panelBody");
const panelSpecs = document.getElementById("panelSpecs");
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.94;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
app.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x06090f);
scene.fog = new THREE.FogExp2(0x070a10, 0.038);
const camera = new THREE.PerspectiveCamera(36, window.innerWidth / window.innerHeight, 0.1, 120);
camera.position.set(0.3, 4.4, 21.2);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.target.set(0, 0.12, 0);
controls.minDistance = 9;
controls.maxDistance = 28;
controls.minPolarAngle = 0.4;
controls.maxPolarAngle = 1.48;
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.08,
0.58,
0.96
);
composer.addPass(bloomPass);
const pmremGenerator = new THREE.PMREMGenerator(renderer);
scene.environment = pmremGenerator.fromScene(new RoomEnvironment(renderer), 0.05).texture;
const keyLight = new THREE.SpotLight(0xe7f2ff, 46, 70, Math.PI / 5.8, 0.48, 1.5);
keyLight.position.set(-10, 14, 14);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(2048, 2048);
keyLight.shadow.bias = -0.00015;
keyLight.shadow.radius = 5;
scene.add(keyLight);
scene.add(keyLight.target);
keyLight.target.position.set(0, 0.4, 0);
const warmLight = new THREE.SpotLight(0xffe2b7, 16, 58, Math.PI / 4.8, 0.55, 1.4);
warmLight.position.set(9, 8, 11);
scene.add(warmLight);
scene.add(warmLight.target);
warmLight.target.position.set(0, -0.6, 0);
const rimLight = new THREE.PointLight(0x8ed7ff, 5, 36, 2.1);
rimLight.position.set(0, -1.5, -10);
scene.add(rimLight);
const hemi = new THREE.HemisphereLight(0x8fbce4, 0x05070b, 0.3);
scene.add(hemi);
const fillLight = new THREE.PointLight(0xd4e4ff, 6, 34, 2);
fillLight.position.set(-7, -2.5, 8);
scene.add(fillLight);
const floor = new Reflector(new THREE.CircleGeometry(20, 128), {
color: 0x070a10,
textureWidth: Math.floor(window.innerWidth * renderer.getPixelRatio()),
textureHeight: Math.floor(window.innerHeight * renderer.getPixelRatio())
});
floor.rotation.x = -Math.PI / 2;
floor.position.y = -6.35;
scene.add(floor);
const shadowCatcher = new THREE.Mesh(
new THREE.CircleGeometry(18, 128),
new THREE.ShadowMaterial({ opacity: 0.22 })
);
shadowCatcher.rotation.x = -Math.PI / 2;
shadowCatcher.position.y = -6.31;
shadowCatcher.receiveShadow = true;
scene.add(shadowCatcher);
const dustGeometry = new THREE.BufferGeometry();
const dustCount = 240;
const dustPositions = new Float32Array(dustCount * 3);
for (let i = 0; i < dustCount; i += 1) {
dustPositions[i * 3 + 0] = (Math.random() - 0.5) * 24;
dustPositions[i * 3 + 1] = (Math.random() - 0.5) * 14;
dustPositions[i * 3 + 2] = (Math.random() - 0.5) * 18;
}
dustGeometry.setAttribute("position", new THREE.BufferAttribute(dustPositions, 3));
const dustMaterial = new THREE.PointsMaterial({
color: 0xe8f4ff,
size: 0.045,
transparent: true,
opacity: 0.22,
depthWrite: false
});
const dust = new THREE.Points(dustGeometry, dustMaterial);
scene.add(dust);
function makeTexturePainter(draw) {
const size = 512;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
draw(ctx, size);
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
const brushedTexture = makeTexturePainter((ctx, size) => {
ctx.fillStyle = "#8e8e8e";
ctx.fillRect(0, 0, size, size);
for (let y = 0; y < size; y += 1) {
const v = 148 + Math.sin(y * 0.13) * 24 + (Math.random() - 0.5) * 26;
ctx.fillStyle = `rgb(${v}, ${v}, ${v})`;
ctx.fillRect(0, y, size, 1);
}
for (let i = 0; i < 1200; i += 1) {
const x = Math.random() * size;
const y = Math.random() * size;
const w = Math.random() * size * 0.25;
ctx.strokeStyle = `rgba(255,255,255,${Math.random() * 0.02})`;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + w, y);
ctx.stroke();
}
});
brushedTexture.repeat.set(2, 2);
const perlageTexture = makeTexturePainter((ctx, size) => {
ctx.fillStyle = "#5f666f";
ctx.fillRect(0, 0, size, size);
for (let y = 18; y < size + 18; y += 28) {
for (let x = 18; x < size + 18; x += 28) {
const r = 16 + Math.random() * 3;
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, "rgba(255,255,255,0.28)");
g.addColorStop(0.45, "rgba(205,205,205,0.12)");
g.addColorStop(1, "rgba(10,10,10,0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
}
});
perlageTexture.repeat.set(1.8, 1.8);
const lumeTexture = makeTexturePainter((ctx, size) => {
const g = ctx.createRadialGradient(size / 2, size / 2, 8, size / 2, size / 2, size / 2);
g.addColorStop(0, "#fbfff3");
g.addColorStop(0.22, "#e5ffd9");
g.addColorStop(0.7, "#9affd7");
g.addColorStop(1, "#1d8d70");
ctx.fillStyle = g;
ctx.fillRect(0, 0, size, size);
});
const highlightMaterialFactory = (color, opacity) =>
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity,
side: THREE.BackSide,
depthWrite: false,
blending: THREE.AdditiveBlending
});
const steelMaterial = new THREE.MeshPhysicalMaterial({
color: 0xd8dee8,
metalness: 1,
roughness: 0.22,
clearcoat: 0.9,
clearcoatRoughness: 0.18,
roughnessMap: brushedTexture,
envMapIntensity: 0.72
});
const satinSteelMaterial = new THREE.MeshPhysicalMaterial({
color: 0xaeb7c6,
metalness: 1,
roughness: 0.35,
clearcoat: 0.5,
clearcoatRoughness: 0.24,
roughnessMap: brushedTexture,
envMapIntensity: 0.55
});
const darkCaseMaterial = new THREE.MeshPhysicalMaterial({
color: 0x626d7e,
metalness: 1,
roughness: 0.26,
clearcoat: 0.85,
clearcoatRoughness: 0.18,
roughnessMap: brushedTexture,
envMapIntensity: 0.5
});
const goldBridgeMaterial = new THREE.MeshPhysicalMaterial({
color: 0xd0b783,
metalness: 1,
roughness: 0.24,
clearcoat: 0.7,
clearcoatRoughness: 0.16,
roughnessMap: brushedTexture,
envMapIntensity: 0.48
});
const darkPlateMaterial = new THREE.MeshPhysicalMaterial({
color: 0x3a414c,
metalness: 1,
roughness: 0.44,
clearcoat: 0.32,
roughnessMap: perlageTexture
});
const blueSteelMaterial = new THREE.MeshPhysicalMaterial({
color: 0x355a89,
metalness: 1,
roughness: 0.18,
clearcoat: 0.9,
clearcoatRoughness: 0.11
});
const rubyMaterial = new THREE.MeshPhysicalMaterial({
color: 0x9a1035,
transmission: 0.22,
thickness: 0.4,
ior: 1.6,
transparent: true,
opacity: 0.96,
roughness: 0.12,
metalness: 0,
clearcoat: 1,
clearcoatRoughness: 0.06,
envMapIntensity: 0.45
});
const lumeMaterial = new THREE.MeshPhysicalMaterial({
color: 0xcffff0,
emissive: 0x86ffd6,
emissiveIntensity: 1.5,
roughness: 0.18,
metalness: 0.1,
clearcoat: 0.16,
map: lumeTexture
});
const crystalMaterial = new THREE.MeshPhysicalMaterial({
color: 0xc7d8ff,
transmission: 0.82,
transparent: true,
opacity: 0.82,
thickness: 0.42,
ior: 1.52,
roughness: 0.07,
metalness: 0,
clearcoat: 1,
clearcoatRoughness: 0.02,
reflectivity: 0.78,
envMapIntensity: 0.32,
attenuationColor: 0xdbe7ff,
attenuationDistance: 2.6
});
const blackGlassMaterial = new THREE.MeshPhysicalMaterial({
color: 0x070a0f,
transmission: 0.08,
transparent: true,
opacity: 0.5,
roughness: 0.18,
metalness: 0.18,
clearcoat: 0.8
});
const matteShadowMaterial = new THREE.MeshStandardMaterial({
color: 0x0d131c,
roughness: 0.92,
metalness: 0.08
});
function circleHole(shape, x, y, r) {
const path = new THREE.Path();
path.absarc(x, y, r, 0, Math.PI * 2, true);
shape.holes.push(path);
}
function extrude(shape, depth, bevelSize = 0.025, bevelThickness = 0.025, curveSegments = 72) {
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
bevelEnabled: true,
bevelSegments: 2,
steps: 1,
bevelSize,
bevelThickness,
curveSegments
});
geometry.translate(0, 0, -depth / 2);
return geometry;
}
function createRing(innerRadius, outerRadius, depth, material, segments = 96) {
const shape = new THREE.Shape();
shape.absarc(0, 0, outerRadius, 0, Math.PI * 2, false);
circleHole(shape, 0, 0, innerRadius);
const mesh = new THREE.Mesh(extrude(shape, depth, 0.03, 0.03, segments), material);
mesh.castShadow = true;
mesh.receiveShadow = true;
return mesh;
}
function createGear(innerRadius, outerRadius, teeth, depth, material, hubRadius = 0.14) {
const shape = new THREE.Shape();
const toothDepth = (outerRadius - innerRadius) * 0.28;
const tipRadius = outerRadius;
const valleyRadius = outerRadius - toothDepth;
const points = [];
for (let i = 0; i < teeth * 2; i += 1) {
const angle = (i / (teeth * 2)) * Math.PI * 2;
const radius = i % 2 === 0 ? tipRadius : valleyRadius;
points.push(new THREE.Vector2(Math.cos(angle) * radius, Math.sin(angle) * radius));
}
shape.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i += 1) {
shape.lineTo(points[i].x, points[i].y);
}
shape.closePath();
circleHole(shape, 0, 0, hubRadius);
const windowCount = Math.max(3, Math.floor(teeth / 6));
const windowRadius = Math.max(0.08, innerRadius * 0.34);
const ringRadius = innerRadius * 0.86;
for (let i = 0; i < windowCount; i += 1) {
const angle = (i / windowCount) * Math.PI * 2 + Math.PI / windowCount;
circleHole(shape, Math.cos(angle) * ringRadius, Math.sin(angle) * ringRadius, windowRadius);
}
const group = new THREE.Group();
const gear = new THREE.Mesh(extrude(shape, depth, 0.012, 0.016, 96), material);
gear.castShadow = true;
gear.receiveShadow = true;
group.add(gear);
const hub = new THREE.Mesh(
new THREE.CylinderGeometry(hubRadius * 1.3, hubRadius * 1.3, depth + 0.08, 36),
satinSteelMaterial
);
hub.castShadow = true;
hub.receiveShadow = true;
group.add(hub);
return group;
}
function createCapsuleBridge(length, radius, depth, material) {
const shape = new THREE.Shape();
shape.absarc(-length / 2 + radius, 0, radius, Math.PI / 2, (Math.PI * 3) / 2, true);
shape.absarc(length / 2 - radius, 0, radius, (Math.PI * 3) / 2, Math.PI / 2, true);
const mesh = new THREE.Mesh(extrude(shape, depth, 0.02, 0.02, 48), material);
mesh.castShadow = true;
mesh.receiveShadow = true;
return mesh;
}
function connectPoints(a, b, radius, depth, material) {
const length = a.distanceTo(b);
const bridge = createCapsuleBridge(length, radius, depth, material);
bridge.position.copy(a).lerp(b, 0.5);
bridge.rotation.z = Math.atan2(b.y - a.y, b.x - a.x);
return bridge;
}
function createScrew(material = blueSteelMaterial, radius = 0.11, height = 0.08) {
const group = new THREE.Group();
const head = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, height, 24), material);
head.castShadow = true;
head.receiveShadow = true;
group.add(head);
const slot = new THREE.Mesh(
new THREE.BoxGeometry(radius * 1.2, radius * 0.18, height * 0.35),
matteShadowMaterial
);
slot.position.z = height * 0.24;
group.add(slot);
return group;
}
function createJewel(radius = 0.09, height = 0.1) {
const group = new THREE.Group();
const chaton = new THREE.Mesh(
new THREE.CylinderGeometry(radius * 1.95, radius * 1.95, height * 0.55, 28),
goldBridgeMaterial
);
chaton.castShadow = true;
chaton.receiveShadow = true;
group.add(chaton);
const jewel = new THREE.Mesh(
new THREE.CylinderGeometry(radius, radius * 1.1, height, 24),
rubyMaterial
);
jewel.position.z = 0.01;
jewel.castShadow = true;
group.add(jewel);
return group;
}
function createSpiralTube(turns, maxRadius, tubeRadius, colorMaterial) {
const points = [];
const total = 220;
for (let i = 0; i < total; i += 1) {
const t = i / (total - 1);
const angle = t * Math.PI * 2 * turns;
const radius = 0.08 + t * maxRadius;
points.push(new THREE.Vector3(Math.cos(angle) * radius, Math.sin(angle) * radius, 0));
}
const curve = new THREE.CatmullRomCurve3(points);
const mesh = new THREE.Mesh(new THREE.TubeGeometry(curve, 220, tubeRadius, 10, false), colorMaterial);
mesh.castShadow = true;
mesh.receiveShadow = true;
return mesh;
}
function createHand(length, width, tipInset, depth, frameMaterial, fillMaterial, counter = false) {
const group = new THREE.Group();
const frameShape = new THREE.Shape();
frameShape.moveTo(-0.18, -width * 0.52);
frameShape.lineTo(length - tipInset, -width * 0.32);
frameShape.lineTo(length, 0);
frameShape.lineTo(length - tipInset, width * 0.32);
frameShape.lineTo(-0.18, width * 0.52);
frameShape.absarc(0, 0, width * 0.68, Math.PI * 0.56, Math.PI * 1.44, true);
const innerShape = new THREE.Path();
innerShape.moveTo(0.22, -width * 0.17);
innerShape.lineTo(length - tipInset * 1.2, -width * 0.11);
innerShape.lineTo(length - tipInset * 0.65, 0);
innerShape.lineTo(length - tipInset * 1.2, width * 0.11);
innerShape.lineTo(0.22, width * 0.17);
innerShape.absarc(0.22, 0, width * 0.24, Math.PI * 0.5, Math.PI * 1.5, true);
frameShape.holes.push(innerShape);
const frame = new THREE.Mesh(extrude(frameShape, depth, 0.01, 0.01, 48), frameMaterial);
frame.castShadow = true;
frame.receiveShadow = true;
group.add(frame);
const lumeShape = new THREE.Shape();
lumeShape.moveTo(0.34, -width * 0.15);
lumeShape.lineTo(length - tipInset * 1.45, -width * 0.09);
lumeShape.lineTo(length - tipInset * 0.92, 0);
lumeShape.lineTo(length - tipInset * 1.45, width * 0.09);
lumeShape.lineTo(0.34, width * 0.15);
lumeShape.absarc(0.34, 0, width * 0.16, Math.PI * 0.5, Math.PI * 1.5, true);
const lumeFill = new THREE.Mesh(extrude(lumeShape, depth * 0.5, 0.005, 0.005, 32), fillMaterial);
lumeFill.position.z = depth * 0.18;
group.add(lumeFill);
const cap = new THREE.Mesh(new THREE.CylinderGeometry(width * 0.36, width * 0.36, depth * 1.8, 28), frameMaterial);
cap.castShadow = true;
cap.receiveShadow = true;
group.add(cap);
if (counter) {
const counterweight = new THREE.Mesh(
new THREE.BoxGeometry(0.55, width * 0.11, depth * 1.2),
frameMaterial
);
counterweight.position.x = -0.58;
group.add(counterweight);
const tail = new THREE.Mesh(new THREE.CylinderGeometry(width * 0.18, width * 0.18, depth * 1.6, 18), fillMaterial);
tail.rotation.z = Math.PI / 2;
tail.position.x = -0.95;
group.add(tail);
}
return group;
}
function createHourMarker(index) {
const group = new THREE.Group();
const frame = new THREE.Mesh(
new THREE.BoxGeometry(0.36, index % 3 === 0 ? 0.16 : 0.13, 0.08),
steelMaterial
);
frame.castShadow = true;
frame.receiveShadow = true;
group.add(frame);
const lume = new THREE.Mesh(
new THREE.BoxGeometry(0.22, index % 3 === 0 ? 0.08 : 0.07, 0.042),
lumeMaterial
);
lume.position.z = 0.023;
group.add(lume);
return group;
}
function createMinuteMarker(isFiveMinute) {
const material = isFiveMinute ? steelMaterial : satinSteelMaterial;
const width = isFiveMinute ? 0.18 : 0.09;
const height = isFiveMinute ? 0.05 : 0.03;
const tick = new THREE.Mesh(new THREE.BoxGeometry(width, height, 0.05), material);
tick.castShadow = true;
tick.receiveShadow = true;
return tick;
}
const interactiveRoots = [];
const pickables = [];
const layerTransforms = [];
function setLayerTransform(group, compact, exploded) {
group.userData.compact = compact.clone();
group.userData.exploded = exploded.clone();
group.position.copy(compact);
layerTransforms.push(group);
}
function registerPart(group, data, anchor = new THREE.Vector3()) {
const anchorObject = new THREE.Object3D();
anchorObject.position.copy(anchor);
group.add(anchorObject);
group.userData.meta = data;
group.userData.anchorObject = anchorObject;
group.userData.highlightMeshes = [];
group.traverse((child) => {
if (!child.isMesh || child.userData.isHighlightShell) return;
pickables.push(child);
child.userData.partRoot = group;
const shell = new THREE.Mesh(child.geometry, highlightMaterialFactory(0x74d7ff, 0.18));
shell.scale.setScalar(1.022);
shell.visible = false;
shell.userData.isHighlightShell = true;
shell.raycast = () => null;
child.add(shell);
group.userData.highlightMeshes.push(shell);
});
interactiveRoots.push(group);
return group;
}
const overallInfo = {
kicker: "Movement Overview",
title: "机械雕塑级展示",
body:
"这枚表以镂空结构完整展示动力存储、轮系传动、擒纵和调速系统。金属表面采用多层反射与冷暖灯光塑形,夜光指针与刻度在深色背景中保持清晰可读。",
specs: ["42 mm 表壳", "钛金属 / 蓝钢细节", "双面蓝宝石水晶", "72 小时动储"]
};
function updatePanel(meta) {
const source = meta || overallInfo;
panelKicker.textContent = source.kicker;
panelTitle.textContent = source.title;
panelBody.textContent = source.body;
panelSpecs.innerHTML = "";
source.specs.forEach((item) => {
const pill = document.createElement("div");
pill.className = "spec-pill";
pill.textContent = item;
panelSpecs.appendChild(pill);
});
}
updatePanel();
const rig = new THREE.Group();
rig.rotation.x = -0.42;
rig.rotation.z = 0.12;
scene.add(rig);
const watch = new THREE.Group();
watch.scale.setScalar(0.94);
rig.add(watch);
const layers = {
backCrystal: new THREE.Group(),
caseBack: new THREE.Group(),
base: new THREE.Group(),
gears: new THREE.Group(),
bridges: new THREE.Group(),
chapter: new THREE.Group(),
hands: new THREE.Group(),
caseMid: new THREE.Group(),
caseFront: new THREE.Group(),
frontCrystal: new THREE.Group()
};
Object.values(layers).forEach((layer) => watch.add(layer));
setLayerTransform(layers.backCrystal, new THREE.Vector3(0, 0, -0.34), new THREE.Vector3(0, 0, -1.9));
setLayerTransform(layers.caseBack, new THREE.Vector3(0, 0, -0.24), new THREE.Vector3(0, 0, -1.26));
setLayerTransform(layers.base, new THREE.Vector3(0, 0, -0.08), new THREE.Vector3(0, 0, -0.46));
setLayerTransform(layers.gears, new THREE.Vector3(0, 0, -0.01), new THREE.Vector3(0, 0, -0.1));
setLayerTransform(layers.bridges, new THREE.Vector3(0, 0, 0.08), new THREE.Vector3(0, 0, 0.46));
setLayerTransform(layers.chapter, new THREE.Vector3(0, 0, 0.16), new THREE.Vector3(0, 0, 0.88));
setLayerTransform(layers.hands, new THREE.Vector3(0, 0, 0.24), new THREE.Vector3(0, 0, 1.36));
setLayerTransform(layers.caseMid, new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 0));
setLayerTransform(layers.caseFront, new THREE.Vector3(0, 0, 0.32), new THREE.Vector3(0, 0, 1.06));
setLayerTransform(layers.frontCrystal, new THREE.Vector3(0, 0, 0.39), new THREE.Vector3(0, 0, 1.84));
const casePart = new THREE.Group();
layers.caseMid.add(casePart);
const caseBand = createRing(4.35, 5.3, 0.92, darkCaseMaterial);
casePart.add(caseBand);
const innerShadowRing = createRing(4.13, 4.3, 0.74, matteShadowMaterial);
innerShadowRing.position.z = 0.02;
casePart.add(innerShadowRing);
const crownGroup = new THREE.Group();
crownGroup.position.set(5.58, 0, 0);
const crownBody = new THREE.Mesh(new THREE.CylinderGeometry(0.48, 0.48, 0.66, 28), darkCaseMaterial);
crownBody.rotation.z = Math.PI / 2;
crownBody.castShadow = true;
crownBody.receiveShadow = true;
crownGroup.add(crownBody);
for (let i = 0; i < 18; i += 1) {
const groove = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.14, 0.12), matteShadowMaterial);
const angle = (i / 18) * Math.PI * 2;
groove.position.set(0, Math.cos(angle) * 0.48, Math.sin(angle) * 0.48);
crownGroup.add(groove);
}
casePart.add(crownGroup);
for (const sign of [-1, 1]) {
for (const side of [-1, 1]) {
const lug = new THREE.Mesh(new THREE.BoxGeometry(0.78, 1.22, 0.38), darkCaseMaterial);
lug.position.set(side * 1.4, sign * 5.34, 0);
lug.rotation.z = side * sign * 0.12;
lug.castShadow = true;
lug.receiveShadow = true;
casePart.add(lug);
}
const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 3.1, 20), steelMaterial);
bar.rotation.z = Math.PI / 2;
bar.position.set(0, sign * 5.32, 0);
bar.castShadow = true;
casePart.add(bar);
}
const bezelPart = new THREE.Group();
layers.caseFront.add(bezelPart);
const bezel = createRing(4.62, 5.24, 0.24, steelMaterial);
bezelPart.add(bezel);
const tensionRing = createRing(4.46, 4.56, 0.1, goldBridgeMaterial);
tensionRing.position.z = -0.09;
bezelPart.add(tensionRing);
for (let i = 0; i < 6; i += 1) {
const screw = createScrew(blueSteelMaterial, 0.11, 0.08);
const angle = (i / 6) * Math.PI * 2;
screw.position.set(Math.cos(angle) * 4.92, Math.sin(angle) * 4.92, 0.04);
bezelPart.add(screw);
}
const caseBackPart = new THREE.Group();
layers.caseBack.add(caseBackPart);
const backRing = createRing(4.45, 5.16, 0.2, satinSteelMaterial);
caseBackPart.add(backRing);
const backShadow = createRing(4.18, 4.42, 0.06, matteShadowMaterial);
backShadow.position.z = 0.06;
caseBackPart.add(backShadow);
const crystalPart = new THREE.Group();
layers.frontCrystal.add(crystalPart);
const frontCrystal = new THREE.Mesh(new THREE.CylinderGeometry(4.84, 4.9, 0.12, 80), crystalMaterial);
frontCrystal.castShadow = true;
crystalPart.add(frontCrystal);
const antiglare = createRing(4.7, 4.85, 0.03, blackGlassMaterial);
antiglare.position.z = -0.015;
crystalPart.add(antiglare);
const backCrystalPart = new THREE.Group();
layers.backCrystal.add(backCrystalPart);
const backCrystal = new THREE.Mesh(new THREE.CylinderGeometry(4.36, 4.42, 0.1, 80), crystalMaterial);
backCrystal.castShadow = true;
backCrystalPart.add(backCrystal);
const chapterPart = new THREE.Group();
layers.chapter.add(chapterPart);
const chapterRing = createRing(3.68, 4.08, 0.12, satinSteelMaterial);
chapterPart.add(chapterRing);
const chapterShadow = createRing(3.24, 3.62, 0.08, matteShadowMaterial);
chapterShadow.position.z = -0.08;
chapterPart.add(chapterShadow);
const minuteTicks = new THREE.Group();
for (let i = 0; i < 60; i += 1) {
const tick = createMinuteMarker(i % 5 === 0);
const angle = (i / 60) * Math.PI * 2;
const radius = i % 5 === 0 ? 3.82 : 3.88;
tick.position.set(Math.sin(angle) * radius, Math.cos(angle) * radius, 0.09);
tick.rotation.z = -angle;
minuteTicks.add(tick);
}
chapterPart.add(minuteTicks);
const hourMarkers = new THREE.Group();
for (let i = 0; i < 12; i += 1) {
const marker = createHourMarker(i);
const angle = (i / 12) * Math.PI * 2;
marker.position.set(Math.sin(angle) * 3.46, Math.cos(angle) * 3.46, 0.14);
marker.rotation.z = -angle;
hourMarkers.add(marker);
}
chapterPart.add(hourMarkers);
const platePart = new THREE.Group();
layers.base.add(platePart);
const plateShape = new THREE.Shape();
plateShape.absarc(0, 0, 4.05, 0, Math.PI * 2, false);
circleHole(plateShape, -1.82, 0.56, 1.02);
circleHole(plateShape, -0.1, 0.03, 0.98);
circleHole(plateShape, 1.22, 0.67, 0.52);
circleHole(plateShape, 1.9, 1.42, 0.42);
circleHole(plateShape, 2.42, 0.08, 0.4);
circleHole(plateShape, 2.36, -1.55, 1.06);
circleHole(plateShape, -0.92, -1.68, 0.8);
const plate = new THREE.Mesh(extrude(plateShape, 0.16, 0.03, 0.03, 96), darkPlateMaterial);
plate.castShadow = true;
plate.receiveShadow = true;
platePart.add(plate);
const plateRim = createRing(4.02, 4.16, 0.09, goldBridgeMaterial);
plateRim.position.z = 0.05;
platePart.add(plateRim);
const pillars = new THREE.Group();
[
new THREE.Vector3(-3.1, 2.55, 0),
new THREE.Vector3(3.2, 2.6, 0),
new THREE.Vector3(3.1, -2.65, 0),
new THREE.Vector3(-3.2, -2.6, 0)
].forEach((pos) => {
const pillar = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.18, 0.56, 24), goldBridgeMaterial);
pillar.position.copy(pos);
pillar.castShadow = true;
pillar.receiveShadow = true;
pillars.add(pillar);
const screw = createScrew(blueSteelMaterial, 0.1, 0.08);
screw.position.copy(pos).setZ(0.3);
pillars.add(screw);
});
platePart.add(pillars);
const jewelPart = new THREE.Group();
layers.bridges.add(jewelPart);
[
new THREE.Vector3(-1.82, 0.56, 0.16),
new THREE.Vector3(0, 0, 0.16),
new THREE.Vector3(1.22, 0.67, 0.16),
new THREE.Vector3(1.9, 1.42, 0.16),
new THREE.Vector3(2.42, 0.08, 0.16),
new THREE.Vector3(2.0, -0.72, 0.16),
new THREE.Vector3(2.36, -1.55, 0.16)
].forEach((pos) => {
const jewel = createJewel();
jewel.position.copy(pos);
jewelPart.add(jewel);
});
const gearPart = new THREE.Group();
layers.gears.add(gearPart);
const barrelGroup = new THREE.Group();
barrelGroup.position.set(-1.82, 0.56, 0);
const barrelShell = createRing(0.88, 1.18, 0.28, goldBridgeMaterial);
barrelGroup.add(barrelShell);
const ratchet = createGear(0.82, 1.25, 34, 0.1, steelMaterial, 0.16);
ratchet.position.z = 0.14;
barrelGroup.add(ratchet);
const mainspring = createSpiralTube(4.6, 0.78, 0.028, blueSteelMaterial);
mainspring.position.z = 0.02;
barrelGroup.add(mainspring);
const arbor = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 0.38, 20), steelMaterial);
arbor.position.z = 0.02;
barrelGroup.add(arbor);
gearPart.add(barrelGroup);
const centerWheelGroup = createGear(0.62, 0.96, 24, 0.11, steelMaterial, 0.12);
centerWheelGroup.position.set(0, 0, 0.02);
gearPart.add(centerWheelGroup);
const thirdWheelGroup = createGear(0.42, 0.68, 20, 0.11, goldBridgeMaterial, 0.1);
thirdWheelGroup.position.set(1.22, 0.67, 0.03);
gearPart.add(thirdWheelGroup);
const fourthWheelGroup = createGear(0.3, 0.48, 16, 0.1, steelMaterial, 0.08);
fourthWheelGroup.position.set(1.9, 1.42, 0.03);
gearPart.add(fourthWheelGroup);
const escapeWheelGroup = createGear(0.22, 0.42, 15, 0.08, blueSteelMaterial, 0.06);
escapeWheelGroup.position.set(2.42, 0.08, 0.04);
gearPart.add(escapeWheelGroup);
const transmissionPivots = new THREE.Group();
[
[-1.82, 0.56],
[0, 0],
[1.22, 0.67],
[1.9, 1.42],
[2.42, 0.08]
].forEach(([x, y]) => {
const pivot = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.34, 18), steelMaterial);
pivot.position.set(x, y, 0.02);
pivot.castShadow = true;
pivot.receiveShadow = true;
transmissionPivots.add(pivot);
});
gearPart.add(transmissionPivots);
const escapementPart = new THREE.Group();
layers.gears.add(escapementPart);
const palletForkPivot = new THREE.Group();
palletForkPivot.position.set(2.02, -0.72, 0.06);
const palletFork = new THREE.Group();
const forkBody = new THREE.Mesh(new THREE.BoxGeometry(0.72, 0.08, 0.08), steelMaterial);
forkBody.position.x = -0.08;
forkBody.castShadow = true;
forkBody.receiveShadow = true;
palletFork.add(forkBody);
const forkNeck = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 0.22, 18), steelMaterial);
forkNeck.castShadow = true;
forkNeck.receiveShadow = true;
palletFork.add(forkNeck);
const prongA = new THREE.Mesh(new THREE.BoxGeometry(0.13, 0.26, 0.08), steelMaterial);
prongA.position.set(0.34, 0.13, 0);
palletFork.add(prongA);
const prongB = new THREE.Mesh(new THREE.BoxGeometry(0.13, 0.26, 0.08), steelMaterial);
prongB.position.set(0.34, -0.13, 0);
palletFork.add(prongB);
const rubyA = createJewel(0.04, 0.06);
rubyA.scale.setScalar(0.7);
rubyA.position.set(0.28, 0.14, 0.04);
palletFork.add(rubyA);
const rubyB = createJewel(0.04, 0.06);
rubyB.scale.setScalar(0.7);
rubyB.position.set(0.28, -0.14, 0.04);
palletFork.add(rubyB);
palletForkPivot.add(palletFork);
escapementPart.add(palletForkPivot);
const balancePart = new THREE.Group();
layers.gears.add(balancePart);
balancePart.position.set(2.36, -1.55, 0.1);
const balanceWheel = new THREE.Group();
const rim = new THREE.Mesh(new THREE.TorusGeometry(0.88, 0.058, 18, 96), steelMaterial);
rim.castShadow = true;
rim.receiveShadow = true;
balanceWheel.add(rim);
const innerRim = new THREE.Mesh(new THREE.TorusGeometry(0.45, 0.022, 12, 64), goldBridgeMaterial);
innerRim.castShadow = true;
balanceWheel.add(innerRim);
for (let i = 0; i < 6; i += 1) {
const spoke = new THREE.Mesh(new THREE.BoxGeometry(0.78, 0.05, 0.045), goldBridgeMaterial);
spoke.rotation.z = (i / 6) * Math.PI * 2;
spoke.castShadow = true;
balanceWheel.add(spoke);
}
for (let i = 0; i < 8; i += 1) {
const screw = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.1, 16), blueSteelMaterial);
const angle = (i / 8) * Math.PI * 2;
screw.position.set(Math.cos(angle) * 0.88, Math.sin(angle) * 0.88, 0);
screw.castShadow = true;
balanceWheel.add(screw);
}
const balanceHub = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 0.24, 18), goldBridgeMaterial);
balanceHub.castShadow = true;
balanceWheel.add(balanceHub);
balancePart.add(balanceWheel);
const hairspring = createSpiralTube(5.3, 0.46, 0.014, blueSteelMaterial);
hairspring.position.z = 0.06;
balancePart.add(hairspring);
const balanceStud = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.1, 0.08), goldBridgeMaterial);
balanceStud.position.set(0.48, 0.64, 0.06);
balancePart.add(balanceStud);
const bridgePart = new THREE.Group();
layers.bridges.add(bridgePart);
const bridgeNodes = [
new THREE.Vector3(-1.82, 0.56, 0.16),
new THREE.Vector3(0, 0, 0.16),
new THREE.Vector3(1.22, 0.67, 0.16),
new THREE.Vector3(1.9, 1.42, 0.16),
new THREE.Vector3(2.42, 0.08, 0.16)
];
bridgeNodes.forEach((pos, index) => {
const ringRadius = index === 0 ? 0.58 : index === 1 ? 0.44 : 0.3;
const bridgeRing = createRing(0.16, ringRadius, 0.16, goldBridgeMaterial);
bridgeRing.position.copy(pos);
bridgePart.add(bridgeRing);
});
bridgePart.add(connectPoints(bridgeNodes[0], bridgeNodes[1], 0.16, 0.14, goldBridgeMaterial));
bridgePart.add(connectPoints(bridgeNodes[1], bridgeNodes[2], 0.14, 0.14, goldBridgeMaterial));
bridgePart.add(connectPoints(bridgeNodes[2], bridgeNodes[3], 0.11, 0.14, goldBridgeMaterial));
bridgePart.add(connectPoints(bridgeNodes[2], bridgeNodes[4], 0.1, 0.14, goldBridgeMaterial));
const balanceCock = new THREE.Group();
const balanceBridgeRing = createRing(0.16, 0.44, 0.14, goldBridgeMaterial);
balanceBridgeRing.position.set(2.36, -1.55, 0.16);
balanceCock.add(balanceBridgeRing);
const cockConnector = connectPoints(
new THREE.Vector3(1.74, -1.12, 0.16),
new THREE.Vector3(2.28, -1.45, 0.16),
0.16,
0.14,
goldBridgeMaterial
);
balanceCock.add(cockConnector);
const cockAnchor = createRing(0.12, 0.26, 0.14, goldBridgeMaterial);
cockAnchor.position.set(1.66, -1.06, 0.16);
balanceCock.add(cockAnchor);
bridgePart.add(balanceCock);
const bridgeScrews = new THREE.Group();
[
[-1.26, 1.08],
[0.36, 0.38],
[1.42, 1.02],
[2.18, 1.68],
[2.62, 0.34],
[1.64, -1.06],
[2.68, -1.24]
].forEach(([x, y]) => {
const screw = createScrew(blueSteelMaterial, 0.1, 0.08);
screw.position.set(x, y, 0.26);
bridgeScrews.add(screw);
});
bridgePart.add(bridgeScrews);
const handsPart = new THREE.Group();
layers.hands.add(handsPart);
const hourPivot = new THREE.Group();
const minutePivot = new THREE.Group();
const secondPivot = new THREE.Group();
handsPart.add(hourPivot, minutePivot, secondPivot);
const hourHand = createHand(2.05, 0.42, 0.42, 0.07, goldBridgeMaterial, lumeMaterial, false);
hourPivot.add(hourHand);
const minuteHand = createHand(3.2, 0.32, 0.56, 0.06, steelMaterial, lumeMaterial, false);
minutePivot.add(minuteHand);
const secondHand = createHand(3.48, 0.12, 0.24, 0.045, blueSteelMaterial, lumeMaterial, true);
secondPivot.add(secondHand);
const centralCap = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.18, 0.2, 28), steelMaterial);
centralCap.castShadow = true;
centralCap.receiveShadow = true;
handsPart.add(centralCap);
const shadowDial = new THREE.Mesh(new THREE.CircleGeometry(3.26, 96), blackGlassMaterial);
shadowDial.position.z = -0.22;
layers.chapter.add(shadowDial);
const rubyHighlight = new THREE.PointLight(0xff5e7a, 0.9, 5.5, 2.4);
rubyHighlight.position.set(1.8, -0.5, 1.1);
rig.add(rubyHighlight);
registerPart(casePart, {
kicker: "Case Architecture",
title: "表壳与表冠",
body:
"多段式钛金属表壳负责结构刚性与视觉框架。右侧滚花表冠暗示上链与校时功能,金属边缘利用高光与暗部反差强化雕塑感。",
specs: ["42 mm 直径", "钛金属质感", "滚花表冠", "多层抛磨边缘"],
label: "表壳与表冠"
}, new THREE.Vector3(5.2, 0, 0.2));
registerPart(bezelPart, {
kicker: "Front Assembly",
title: "抛光表圈",
body:
"表圈以冷色钢质反射包围蓝宝石水晶,螺丝与张力环提供技术感,也让前端视觉更像真正的高级产品摄影样机。",
specs: ["抛光钢圈", "蓝钢螺丝", "张力环", "高反射边缘"],
label: "抛光表圈"
}, new THREE.Vector3(0, 4.9, 0.08));
registerPart(crystalPart, {
kicker: "Optical Layer",
title: "蓝宝石水晶玻璃",
body:
"前后双面蓝宝石水晶以高透射率和轻微冷色反射保护机芯,同时保留极高的通透感与摄影级高光。",
specs: ["双面蓝宝石", "高透反射", "抗反射涂层", "立体折射"],
label: "蓝宝石水晶"
}, new THREE.Vector3(0, 0, 0.1));
registerPart(chapterPart, {
kicker: "Readable Skeleton Dial",
title: "夜光刻度圈",
body:
"外圈刻度承担读时任务,镂空中心则让机芯完全暴露。夜光块与金属刻度的组合在暗背景中依旧保持清晰识别。",
specs: ["60 分钟刻度", "12 组夜光时标", "悬浮式章节圈", "镂空表盘"],
label: "刻度圈"
}, new THREE.Vector3(0, 3.95, 0.08));
registerPart(handsPart, {
kicker: "Luminous Hands",
title: "夜光指针",
body:
"时针、分针采用镂空框架与夜光填充,秒针则使用蓝钢色调与配重尾端,让读时和机械感同时成立。",
specs: ["镂空时分针", "蓝钢秒针", "夜光填充", "中央同轴布局"],
label: "夜光指针"
}, new THREE.Vector3(1.6, 1.1, 0.1));
registerPart(barrelGroup, {
kicker: "Energy Storage",
title: "主发条盒",
body:
"主发条以螺旋方式储存势能,并通过发条盒向轮系输送稳定动力。镂空设计让你能够看到主发条盘绕在桶形外壳内。",
specs: ["72 小时动储", "可见主发条", "发条盒棘轮", "动力源头"],
label: "主发条盒"
}, new THREE.Vector3(0, 0, 0.18));
registerPart(gearPart, {
kicker: "Transmission",
title: "轮系齿轮",
body:
"中心轮、三轮、四轮与擒纵轮把主发条的慢速高扭矩输出转化为更高频、更稳定的细分运动,为擒纵和指针提供节奏。",
specs: ["中心轮", "三轮", "四轮", "擒纵轮"],
label: "轮系齿轮"
}, new THREE.Vector3(1.16, 0.7, 0.18));
registerPart(escapementPart, {
kicker: "Escapement",
title: "擒纵机构",
body:
"擒纵叉与擒纵轮负责把连续动力切分为精准脉冲,再把冲量传递给摆轮系统,是机械表走时的核心节拍器。",
specs: ["擒纵叉", "擒纵轮", "脉冲释放", "节拍控制"],
label: "擒纵机构"
}, new THREE.Vector3(0.35, 0, 0.12));
registerPart(balancePart, {
kicker: "Regulating Organ",
title: "摆轮与游丝",
body:
"摆轮以高频往复振荡,游丝提供回复力。它们共同决定走时稳定性,也是机械机芯最具生命感的部分。",
specs: ["4 Hz 摆频", "可见游丝", "调速螺丝", "高频摆动"],
label: "摆轮与游丝"
}, new THREE.Vector3(0, 0.9, 0.1));
registerPart(bridgePart, {
kicker: "Skeleton Bridges",
title: "镂空夹板",
body:
"镂空夹板负责固定轮系与摆轮上层轴点,并以尽量开放的方式展示内部运动。暖金色调与镜面倒角让结构更具高级制表感。",
specs: ["骨架化夹板", "镜面倒角感", "蓝钢固定螺丝", "层次分明"],
label: "镂空夹板"
}, new THREE.Vector3(0.9, 0.7, 0.22));
registerPart(jewelPart, {
kicker: "Low-Friction Bearings",
title: "红宝石轴承",
body:
"红宝石轴承减少齿轮轴心的摩擦与磨损,同时也成为高级机芯最醒目的色彩点缀之一。",
specs: ["21 枚宝石轴承", "低摩擦", "耐久支撑", "视觉焦点"],
label: "红宝石轴承"
}, new THREE.Vector3(1.22, 0.67, 0.22));
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2(2, 2);
const screenPosition = new THREE.Vector3();
const worldPosition = new THREE.Vector3();
const state = {
hovered: null,
selected: null,
exploded: false,
autoSpin: true,
explosion: 0
};
function setPointer(event) {
const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}
function applyHighlight() {
interactiveRoots.forEach((root) => {
const isSelected = root === state.selected;
const isHovered = root === state.hovered && root !== state.selected;
root.userData.highlightMeshes.forEach((shell) => {
shell.visible = isSelected || isHovered;
if (!shell.visible) return;
shell.material.color.set(isSelected ? 0xffd9a1 : 0x7fdfff);
shell.material.opacity = isSelected ? 0.22 : 0.16;
});
});
}
function activeMeta() {
if (state.selected) return state.selected.userData.meta;
if (state.hovered) return state.hovered.userData.meta;
return null;
}
function refreshPanelFromState() {
updatePanel(activeMeta());
}
function updateLabel() {
const active = state.hovered || state.selected;
if (!active) {
partLabel.classList.remove("visible");
return;
}
active.userData.anchorObject.getWorldPosition(worldPosition);
screenPosition.copy(worldPosition).project(camera);
if (screenPosition.z > 1) {
partLabel.classList.remove("visible");
return;
}
const x = (screenPosition.x * 0.5 + 0.5) * window.innerWidth;
const y = (-screenPosition.y * 0.5 + 0.5) * window.innerHeight;
partLabel.style.left = `${x}px`;
partLabel.style.top = `${y}px`;
partLabel.textContent = active.userData.meta.label;
partLabel.classList.add("visible");
}
renderer.domElement.addEventListener("pointermove", (event) => {
setPointer(event);
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(pickables, false)[0];
const next = hit ? hit.object.userData.partRoot : null;
if (state.hovered !== next) {
state.hovered = next;
applyHighlight();
if (!state.selected) refreshPanelFromState();
}
});
renderer.domElement.addEventListener("pointerleave", () => {
state.hovered = null;
applyHighlight();
if (!state.selected) refreshPanelFromState();
});
renderer.domElement.addEventListener("click", (event) => {
setPointer(event);
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(pickables, false)[0];
const next = hit ? hit.object.userData.partRoot : null;
state.selected = state.selected === next ? null : next;
applyHighlight();
refreshPanelFromState();
});
explodeBtn.addEventListener("click", () => {
state.exploded = !state.exploded;
explodeBtn.classList.toggle("active", state.exploded);
});
spinBtn.addEventListener("click", () => {
state.autoSpin = !state.autoSpin;
spinBtn.classList.toggle("active", state.autoSpin);
spinBtn.textContent = state.autoSpin ? "自动展示开启" : "自动展示关闭";
});
resetBtn.addEventListener("click", () => {
camera.position.set(0.3, 4.4, 21.2);
controls.target.set(0, 0.12, 0);
controls.update();
rig.rotation.set(-0.42, 0, 0.12);
});
const clock = new THREE.Clock();
function resize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
}
window.addEventListener("resize", resize);
function damp(current, target, lambda, dt) {
return THREE.MathUtils.damp(current, target, lambda, dt);
}
function animate() {
requestAnimationFrame(animate);
const dt = clock.getDelta();
const elapsed = clock.getElapsedTime();
controls.update();
state.explosion = damp(state.explosion, state.exploded ? 1 : 0, 4.6, dt);
layerTransforms.forEach((group) => {
group.position.lerpVectors(group.userData.compact, group.userData.exploded, state.explosion);
});
if (state.autoSpin) {
rig.rotation.y = Math.sin(elapsed * 0.28) * 0.34;
rig.rotation.z = 0.12 + Math.sin(elapsed * 0.19) * 0.024;
}
rig.position.y = Math.sin(elapsed * 0.72) * 0.08;
watch.rotation.z = Math.sin(elapsed * 0.17) * 0.035;
barrelGroup.rotation.z = elapsed * 0.09;
centerWheelGroup.rotation.z = -elapsed * 0.46;
thirdWheelGroup.rotation.z = elapsed * 0.92;
fourthWheelGroup.rotation.z = -elapsed * 1.72;
escapeWheelGroup.rotation.z = elapsed * 8.2;
palletForkPivot.rotation.z = -Math.sin(elapsed * 16.4) * 0.22;
balanceWheel.rotation.z = Math.sin(elapsed * 16.4) * 0.44;
hairspring.scale.setScalar(1 + Math.sin(elapsed * 16.4) * 0.018);
hourPivot.rotation.z = -elapsed * 0.06;
minutePivot.rotation.z = -elapsed * 0.6;
secondPivot.rotation.z = -elapsed * 3.6;
dust.rotation.z = elapsed * 0.01;
dust.rotation.y = elapsed * 0.015;
updateLabel();
composer.render();
}
applyHighlight();
animate();
</script>
</body>
</html>