Files
nagi-bench/outputs/claude-fable-5/claude-code-xhigh/skeleton-watch.html
T

1265 lines
57 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>AUREL &amp; CIE — Calibre AC-500 Squelette 镂空机械腕表</title>
<style>
:root{
--gold:#c9a165; --gold-dim:#8a7047;
--ink:#e9e5db; --ink-dim:#9aa0ab;
--bg:#06080c; --panel:rgba(12,15,21,.82); --line:rgba(201,161,101,.28);
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);
font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans SC",system-ui,sans-serif;
color:var(--ink);-webkit-font-smoothing:antialiased}
canvas{display:block;position:fixed;inset:0;z-index:0}
#vignette{position:fixed;inset:0;z-index:1;pointer-events:none;
background:radial-gradient(ellipse 72% 62% at 50% 42%, transparent 0%, transparent 55%, rgba(3,4,7,.55) 100%)}
.serif{font-family:Georgia,"Times New Roman","Songti SC","Noto Serif SC",serif}
#brand{position:fixed;top:28px;left:34px;z-index:5;pointer-events:none;user-select:none}
#brand h1{font-size:23px;letter-spacing:.42em;font-weight:600;color:var(--ink)}
#brand h1 em{font-style:normal;color:var(--gold)}
#brand p{margin-top:7px;font-size:11px;letter-spacing:.3em;color:var(--ink-dim)}
#brand .rule{width:52px;height:1px;background:linear-gradient(90deg,var(--gold),transparent);margin-top:12px}
/* ---------- control bar ---------- */
#bar{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);z-index:6;
display:flex;gap:9px;padding:9px 11px;border:1px solid var(--line);border-radius:999px;
background:var(--panel);backdrop-filter:blur(14px);box-shadow:0 12px 40px rgba(0,0,0,.5)}
#bar button{appearance:none;border:1px solid transparent;background:transparent;color:var(--ink-dim);
font:inherit;font-size:12px;letter-spacing:.14em;padding:8px 15px;border-radius:999px;cursor:pointer;
transition:all .25s ease;white-space:nowrap}
#bar button:hover{color:var(--ink)}
#bar button.on{color:var(--gold);border-color:var(--line);background:rgba(201,161,101,.08)}
/* ---------- info panel ---------- */
#panel{position:fixed;top:26px;right:26px;z-index:6;width:302px;max-height:calc(100vh - 130px);
border:1px solid var(--line);border-radius:14px;background:var(--panel);backdrop-filter:blur(16px);
box-shadow:0 18px 60px rgba(0,0,0,.55);overflow:hidden;display:flex;flex-direction:column;
transition:transform .45s cubic-bezier(.2,.8,.2,1),opacity .35s ease}
#panel.hidden{transform:translateX(30px);opacity:0;pointer-events:none}
#panel header{display:flex;align-items:center;justify-content:space-between;
padding:15px 18px 12px;border-bottom:1px solid rgba(201,161,101,.16)}
#panel header b{font-size:12px;letter-spacing:.3em;color:var(--gold);font-weight:600}
#panel header button{background:none;border:none;color:var(--ink-dim);font-size:15px;cursor:pointer;line-height:1}
#panel .body{overflow-y:auto;padding:14px 18px 18px;scrollbar-width:thin}
#specs{display:grid;grid-template-columns:auto 1fr;gap:7px 14px;font-size:12px}
#specs dt{color:var(--ink-dim);letter-spacing:.08em;white-space:nowrap}
#specs dd{color:var(--ink);text-align:right}
#panel .div{height:1px;background:linear-gradient(90deg,transparent,rgba(201,161,101,.35),transparent);margin:15px 0}
#partName{font-size:16px;letter-spacing:.12em;color:var(--gold)}
#partEn{font-size:10.5px;letter-spacing:.22em;color:var(--ink-dim);margin-top:3px;text-transform:uppercase}
#partDesc{font-size:12.5px;line-height:1.85;color:#cfd3da;margin-top:10px;text-align:justify}
#partSpec{font-size:11px;letter-spacing:.1em;color:var(--gold-dim);margin-top:10px}
#panelTab{position:fixed;top:26px;right:26px;z-index:5;border:1px solid var(--line);border-radius:999px;
background:var(--panel);backdrop-filter:blur(12px);color:var(--gold);font:inherit;font-size:11.5px;
letter-spacing:.2em;padding:9px 16px;cursor:pointer;opacity:0;pointer-events:none;transition:opacity .3s}
#panelTab.show{opacity:1;pointer-events:auto}
/* ---------- tooltip / hint ---------- */
#tip{position:fixed;z-index:8;pointer-events:none;padding:6px 12px;border:1px solid var(--line);
border-radius:8px;background:rgba(10,12,17,.92);backdrop-filter:blur(8px);font-size:12px;
letter-spacing:.12em;color:var(--ink);opacity:0;transition:opacity .15s;white-space:nowrap}
#tip small{display:block;font-size:9.5px;color:var(--ink-dim);letter-spacing:.18em;margin-top:2px}
#hint{position:fixed;left:34px;bottom:30px;z-index:5;font-size:11px;letter-spacing:.18em;
color:var(--ink-dim);pointer-events:none;transition:opacity 1.2s;user-select:none}
#hint b{color:var(--gold-dim);font-weight:500}
/* ---------- part labels ---------- */
#labels{position:fixed;inset:0;z-index:4;pointer-events:none;overflow:hidden}
.plabel{position:absolute;left:0;top:0;opacity:0;transition:opacity .4s}
.plabel.show{opacity:1}
.plabel .dot{position:absolute;width:5px;height:5px;border-radius:50%;background:var(--gold);
box-shadow:0 0 8px rgba(201,161,101,.9);transform:translate(-50%,-50%)}
.plabel .ln{position:absolute;height:1px;background:linear-gradient(90deg,var(--gold),rgba(201,161,101,.15));
transform-origin:0 50%}
.plabel .tx{position:absolute;transform:translate(-50%,-50%);font-size:10.5px;letter-spacing:.2em;
color:var(--ink);background:rgba(8,10,14,.78);border:1px solid rgba(201,161,101,.22);
padding:3px 9px;border-radius:6px;white-space:nowrap;backdrop-filter:blur(6px)}
/* ---------- loading ---------- */
#load{position:fixed;inset:0;z-index:20;background:var(--bg);display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:16px;transition:opacity .9s ease .15s}
#load.gone{opacity:0;pointer-events:none}
#load .lg{font-size:26px;letter-spacing:.5em;color:var(--ink)}
#load .lg em{font-style:normal;color:var(--gold)}
#load .sub{font-size:10px;letter-spacing:.4em;color:var(--ink-dim)}
#load .spin{width:44px;height:44px;border-radius:50%;border:1px solid rgba(201,161,101,.2);
border-top-color:var(--gold);animation:rot 1.1s linear infinite;margin-top:8px}
@keyframes rot{to{transform:rotate(360deg)}}
@media (max-width:860px){
#panel{display:none}
#panelTab{display:none}
#brand h1{font-size:17px}
#hint{display:none}
#bar{gap:4px;padding:7px 8px}
#bar button{padding:7px 10px;font-size:11px}
}
</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div id="vignette"></div>
<div id="brand">
<h1 class="serif">AUREL <em>&amp;</em> CIE</h1>
<p>CALIBRE AC-500 · SQUELETTE 镂空机芯</p>
<div class="rule"></div>
</div>
<div id="labels"></div>
<div id="panel">
<header><b>机 芯 资 料</b><button id="panelClose" title="收起">✕</button></header>
<div class="body">
<dl id="specs">
<dt>机芯型号</dt><dd class="serif">Calibre AC-500</dd>
<dt>类型</dt><dd>手动上链 · 全镂空</dd>
<dt>振频</dt><dd>28,800 vph · 4 Hz</dd>
<dt>宝石数</dt><dd>23 石</dd>
<dt>动力储存</dt><dd>约 72 小时</dd>
<dt>零件数</dt><dd>218 枚</dd>
<dt>表壳直径</dt><dd>42 mm</dd>
<dt>修饰</dt><dd>日内瓦纹 · 手工倒角</dd>
</dl>
<div class="div"></div>
<div id="partName">— 探索机芯 —</div>
<div id="partEn">Interactive Movement</div>
<div id="partDesc">将光标悬停于任意零件可查看其名称;点击零件可在此处阅读详细介绍。开启「爆炸视图」可逐层分解整枚机芯,开启「零件标注」可显示主要部件的标签。</div>
<div id="partSpec"></div>
</div>
</div>
<button id="panelTab">机芯资料</button>
<div id="bar">
<button id="bExplode">爆炸视图</button>
<button id="bLabels">零件标注</button>
<button id="bRotate" class="on">自动旋转</button>
<button id="bSlow">慢动作</button>
<button id="bReset">重置</button>
</div>
<div id="tip"></div>
<div id="hint"><b>拖动</b> 旋转视角 · <b>滚轮</b> 缩放 · <b>悬停</b> 识别零件 · <b>点击</b> 查看详情</div>
<div id="load">
<div class="lg serif">AUREL <em>&amp;</em> CIE</div>
<div class="sub">HAUTE HORLOGERIE · GENÈVE</div>
<div class="spin"></div>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { Reflector } from 'three/addons/objects/Reflector.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';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
/* ================================================================
基础:渲染器 / 场景 / 相机 / 灯光 / 环境 / 地台
================================================================ */
const DPR = Math.min(window.devicePixelRatio || 1, 2);
const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:'high-performance' });
renderer.setPixelRatio(DPR);
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.12;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x06080c);
const camera = new THREE.PerspectiveCamera(40, innerWidth/innerHeight, 0.1, 200);
const CAM_END = new THREE.Vector3(2.6, 6.6, 13.2);
const CAM_START = new THREE.Vector3(-6, 12, 24);
camera.position.copy(CAM_START);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 4.0, 0);
controls.enableDamping = true; controls.dampingFactor = 0.06;
controls.minDistance = 7; controls.maxDistance = 28;
controls.maxPolarAngle = 1.52; controls.minPolarAngle = 0.12;
controls.enablePan = false;
controls.autoRotate = true; controls.autoRotateSpeed = 0.8;
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
scene.environmentIntensity = 0.85;
/* 灯光 —— 腕表摄影布光:顶部主光 + 冷色轮廓光 + 暖色补光 */
const key = new THREE.SpotLight(0xfff4e2, 900, 0, 0.42, 0.65, 2);
key.position.set(7, 17, 9);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.camera.near = 4; key.shadow.camera.far = 45;
key.shadow.bias = -0.00015; key.shadow.normalBias = 0.02;
scene.add(key);
const keyTarget = new THREE.Object3D(); keyTarget.position.set(0, 4.2, 0);
scene.add(keyTarget); key.target = keyTarget;
const rim = new THREE.DirectionalLight(0xbfd4ff, 2.2); rim.position.set(-10, 7, -9); scene.add(rim);
const warm = new THREE.DirectionalLight(0xffe0bd, 0.8); warm.position.set(-6, 4, 11); scene.add(warm);
/* 反射地台:镜面 Reflector + 渐隐遮罩 + 接触阴影 */
const mirror = new Reflector(new THREE.CircleGeometry(30, 64), {
clipBias: 0.003,
textureWidth: innerWidth * DPR, textureHeight: innerHeight * DPR,
color: 0x2b3038
});
mirror.rotation.x = -Math.PI/2;
scene.add(mirror);
const fadeCv = document.createElement('canvas'); fadeCv.width = fadeCv.height = 512;
{
const c = fadeCv.getContext('2d');
const g = c.createRadialGradient(256,256,40, 256,256,256);
g.addColorStop(0.0, 'rgba(6,8,12,0.42)');
g.addColorStop(0.5, 'rgba(6,8,12,0.78)');
g.addColorStop(1.0, 'rgba(6,8,12,1.0)');
c.fillStyle = g; c.fillRect(0,0,512,512);
}
const fadeTex = new THREE.CanvasTexture(fadeCv);
const fadeMesh = new THREE.Mesh(
new THREE.CircleGeometry(30, 64),
new THREE.MeshBasicMaterial({ map: fadeTex, transparent: true, depthWrite: false })
);
fadeMesh.rotation.x = -Math.PI/2; fadeMesh.position.y = 0.02;
scene.add(fadeMesh);
const shadowCatcher = new THREE.Mesh(
new THREE.CircleGeometry(30, 48),
new THREE.ShadowMaterial({ opacity: 0.42 })
);
shadowCatcher.rotation.x = -Math.PI/2; shadowCatcher.position.y = 0.04;
shadowCatcher.receiveShadow = true;
scene.add(shadowCatcher);
/* 后期:HDR 渲染 + 微量辉光(夜光物料) */
const composer = new EffectComposer(renderer,
new THREE.WebGLRenderTarget(innerWidth, innerHeight, { type: THREE.HalfFloatType, samples: 4 }));
composer.setPixelRatio(DPR);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.42, 0.55, 0.88);
composer.addPass(bloom);
composer.addPass(new OutputPass());
/* ================================================================
程序化纹理:时标圈 / 日内瓦纹 / 珍珠纹
================================================================ */
function arcText(c, text, radiusPx, size, color, flip=false, spacing=1.18){
c.save();
c.translate(512, 512);
c.font = `600 ${size}px Georgia, "Times New Roman", serif`;
c.fillStyle = color; c.textAlign = 'center'; c.textBaseline = 'middle';
const widths = [...text].map(ch => c.measureText(ch).width * spacing);
const total = widths.reduce((a,b)=>a+b, 0);
const dir = flip ? -1 : 1;
let a = -dir * total / (2 * radiusPx);
[...text].forEach((ch, i) => {
a += dir * widths[i] / (2 * radiusPx);
c.save();
c.rotate(a);
if (flip){ c.translate(0, radiusPx); c.rotate(Math.PI); }
else { c.translate(0, -radiusPx); }
c.fillText(ch, 0, 0);
c.restore();
a += dir * widths[i] / (2 * radiusPx);
});
c.restore();
}
function makeChapterTexture(){
const cv = document.createElement('canvas'); cv.width = cv.height = 1024;
const c = cv.getContext('2d');
const K = 512 / 3.3; // 世界单位 → 像素
const g = c.createRadialGradient(512,512,300, 512,512,512);
g.addColorStop(0, '#191d24'); g.addColorStop(0.75, '#14171d'); g.addColorStop(1, '#101318');
c.fillStyle = g; c.fillRect(0,0,1024,1024);
for (let i = 0; i < 60; i++){ // 轨道式分钟刻度
const major = i % 5 === 0;
c.save(); c.translate(512,512); c.rotate(i / 60 * Math.PI * 2);
c.strokeStyle = major ? '#d6c49a' : 'rgba(205,211,222,0.55)';
c.lineWidth = major ? 5 : 2.2;
c.beginPath();
c.moveTo(0, -(major ? 2.97 : 3.02) * K);
c.lineTo(0, -(major ? 3.17 : 3.14) * K);
c.stroke(); c.restore();
}
c.save(); c.translate(512,512); // 轨道双线
c.strokeStyle = 'rgba(205,211,222,0.35)'; c.lineWidth = 1.4;
c.beginPath(); c.arc(0,0, 3.02*K, 0, Math.PI*2); c.stroke();
c.beginPath(); c.arc(0,0, 3.14*K, 0, Math.PI*2); c.stroke();
c.restore();
arcText(c, 'AUREL & CIE', 2.62*K, 46, '#ddd2b8');
arcText(c, '· GENÈVE · SWISS MADE ·', 2.62*K, 24, 'rgba(200,190,160,0.75)', true);
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = renderer.capabilities.getMaxAnisotropy();
return tex;
}
function makeStripeTexture(){ // 日内瓦条纹
const cv = document.createElement('canvas'); cv.width = cv.height = 256;
const c = cv.getContext('2d');
for (let x = 0; x < 256; x++){
const band = Math.floor(x / 32);
const t = (x % 32) / 32;
const v = 222 - 20 * Math.abs(t - 0.5) * 2 + (band % 2 ? -6 : 6);
c.fillStyle = `rgb(${v},${v},${v+2})`;
c.fillRect(x, 0, 1, 256);
}
const tex = new THREE.CanvasTexture(cv);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(0.42, 0.42); tex.center.set(0.5,0.5); tex.rotation = 0.6;
return tex;
}
function makePerlageTexture(){ // 珍珠圆纹
const cv = document.createElement('canvas'); cv.width = cv.height = 256;
const c = cv.getContext('2d');
c.fillStyle = '#d2d2d4'; c.fillRect(0,0,256,256);
for (let row = 0; row < 8; row++)
for (let col = 0; col < 8; col++){
const x = col*32 + (row%2 ? 16 : 0), y = row*32;
const rg = c.createRadialGradient(x,y,2, x,y,20);
rg.addColorStop(0,'rgba(255,255,255,0.5)');
rg.addColorStop(0.7,'rgba(160,160,165,0.35)');
rg.addColorStop(1,'rgba(160,160,165,0)');
c.fillStyle = rg; c.beginPath(); c.arc(x,y,20,0,Math.PI*2); c.fill();
}
const tex = new THREE.CanvasTexture(cv);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(0.8, 0.8);
return tex;
}
/* ================================================================
材质库
================================================================ */
const M = {
steel: new THREE.MeshPhysicalMaterial({ color:0xdfe3e8, metalness:1, roughness:0.16, side:THREE.DoubleSide }),
steelSat:new THREE.MeshPhysicalMaterial({ color:0xb6bcc5, metalness:1, roughness:0.4, side:THREE.DoubleSide }),
gold: new THREE.MeshPhysicalMaterial({ color:0xe3b57e, metalness:1, roughness:0.18 }),
goldSat: new THREE.MeshPhysicalMaterial({ color:0xd2a874, metalness:1, roughness:0.36 }),
gilt: new THREE.MeshPhysicalMaterial({ color:0xdcbc80, metalness:1, roughness:0.3 }),
blued: new THREE.MeshPhysicalMaterial({ color:0x2547d8, metalness:1, roughness:0.2 }),
rhodium: new THREE.MeshPhysicalMaterial({ color:0xa9b0bb, metalness:1, roughness:0.32, map:makeStripeTexture() }),
plate: new THREE.MeshPhysicalMaterial({ color:0x787f8b, metalness:1, roughness:0.44, map:makePerlageTexture(), side:THREE.DoubleSide }),
dark: new THREE.MeshStandardMaterial({ color:0x181c23, metalness:0.7, roughness:0.4 }),
ruby: new THREE.MeshPhysicalMaterial({ color:0xd81448, metalness:0.1, roughness:0.12, clearcoat:1,
emissive:0x550214, emissiveIntensity:0.35 }),
lume: new THREE.MeshStandardMaterial({ color:0xd8ffe8, emissive:0x86ffb4, emissiveIntensity:2.1,
metalness:0, roughness:0.5 }),
crystal: new THREE.MeshPhysicalMaterial({ color:0xffffff, metalness:0, roughness:0.03, transmission:1,
thickness:0.22, ior:1.76, clearcoat:1, clearcoatRoughness:0.02,
specularIntensity:1, envMapIntensity:1.4 }),
onyx: new THREE.MeshPhysicalMaterial({ color:0x0d1020, metalness:0.4, roughness:0.08, clearcoat:1 }),
spring: new THREE.MeshStandardMaterial({ color:0x8f96a2, metalness:1, roughness:0.34, side:THREE.DoubleSide }),
bluedDS: new THREE.MeshStandardMaterial({ color:0x2c50e0, metalness:1, roughness:0.22, side:THREE.DoubleSide }),
};
/* ================================================================
几何辅助函数
================================================================ */
function extrudeFlat(shape, depth, bevel=0.01, curveSegments=10){
const g = new THREE.ExtrudeGeometry(shape, {
depth, bevelEnabled:true, bevelThickness:bevel, bevelSize:bevel, bevelSegments:1, curveSegments
});
g.rotateX(-Math.PI/2); // 平面 XY → XZ,轴向 +Y
g.translate(0, -depth/2, 0);
return g;
}
/* 齿轮:梯形齿 + 轮辐镂空;escape=true 生成擒纵轮斜钩齿 */
function makeGear({ teeth=60, tipR=1, toothH=0.08, thickness=0.1, boreR=0.05,
hubR=0.22, spokes=5, spokeGap=0.28, mat=M.gilt,
escape=false, bevel=0.01, curveSegments=8 }){
const rootR = tipR - toothH;
const s = new THREE.Shape();
const N = teeth, pitch = Math.PI*2/N;
if (escape){
for (let i=0;i<N;i++){
const a = i*pitch;
if (i===0) s.moveTo(Math.cos(a)*tipR, Math.sin(a)*tipR);
else s.lineTo(Math.cos(a)*tipR, Math.sin(a)*tipR);
s.lineTo(Math.cos(a+pitch*0.20)*(rootR+toothH*0.3), Math.sin(a+pitch*0.20)*(rootR+toothH*0.3));
s.lineTo(Math.cos(a+pitch*0.72)*rootR, Math.sin(a+pitch*0.72)*rootR);
}
} else {
s.moveTo(rootR, 0);
for (let i=0;i<N;i++){
const a = i*pitch;
const a1=a+pitch*0.30, a2=a+pitch*0.40, a3=a+pitch*0.62, a4=a+pitch*0.72;
s.absarc(0,0,rootR, a, a1, false);
s.lineTo(Math.cos(a2)*tipR, Math.sin(a2)*tipR);
s.absarc(0,0,tipR, a2, a3, false);
s.lineTo(Math.cos(a4)*rootR, Math.sin(a4)*rootR);
}
}
s.closePath();
if (boreR > 0){
const b = new THREE.Path(); b.absarc(0,0,boreR, 0, Math.PI*2, true); s.holes.push(b);
}
if (spokes > 0){
const rimR = rootR - Math.max(0.1, toothH*1.2);
if (rimR > hubR + 0.06){
const step = Math.PI*2/spokes;
for (let k=0;k<spokes;k++){
const a0 = k*step + spokeGap/2, a1 = (k+1)*step - spokeGap/2;
const h = new THREE.Path();
h.absarc(0,0,hubR, a0, a1, false);
h.absarc(0,0,rimR, a1, a0, true);
h.closePath();
s.holes.push(h);
}
}
}
return new THREE.Mesh(extrudeFlat(s, thickness, bevel, curveSegments), mat);
}
/* 螺旋发条 / 游丝:阿基米德螺线立带 */
function makeSpiralRibbon(r0, r1, turns, height, mat, ease=1){
const segs = Math.max(48, Math.floor(turns*80));
const pos = [], nrm = [], idx = [];
for (let i=0; i<=segs; i++){
const t = i/segs;
const ang = t * turns * Math.PI*2;
const r = r0 + (r1-r0) * Math.pow(t, ease);
const x = Math.cos(ang)*r, z = Math.sin(ang)*r;
pos.push(x,-height/2,z, x,height/2,z);
nrm.push(Math.cos(ang),0,Math.sin(ang), Math.cos(ang),0,Math.sin(ang));
if (i<segs){ const a=i*2; idx.push(a,a+1,a+2, a+1,a+3,a+2); }
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(pos,3));
g.setAttribute('normal', new THREE.Float32BufferAttribute(nrm,3));
g.setIndex(idx);
const mesh = new THREE.Mesh(g, mat);
mesh.userData.endAngle = turns*Math.PI*2;
return mesh;
}
/* 夹板 / 摆轮夹板:圆端 + 渐开臂,含轴孔与装饰孔 */
function makeCock({ len=1, endR=0.35, holeR=0.08, wEnd=0.3, deco=[], mat=M.rhodium, th=0.07 }){
const s = new THREE.Shape();
s.absarc(0,0,endR, 0.6, Math.PI*2-0.6, false);
s.lineTo(len, -wEnd/2);
s.lineTo(len, wEnd/2);
s.closePath();
if (holeR>0){ const h=new THREE.Path(); h.absarc(0,0,holeR,0,Math.PI*2,true); s.holes.push(h); }
for (const d of deco){
const h=new THREE.Path(); h.absarc(d.x, d.y||0, d.r, 0, Math.PI*2, true); s.holes.push(h);
}
return new THREE.Mesh(extrudeFlat(s, th, 0.012, 24), mat);
}
function makeScrew(r=0.085, blued=true){
const g = new THREE.Group();
const head = new THREE.Mesh(new THREE.CylinderGeometry(r, r*1.05, 0.05, 20), blued?M.blued:M.gold);
const slot = new THREE.Mesh(new THREE.BoxGeometry(r*1.7, 0.02, r*0.32), M.dark);
slot.position.y = 0.022;
g.add(head, slot);
tag(g, 'screw');
return g;
}
function makeJewel(r=0.085, chaton=true){
const g = new THREE.Group();
const stone = new THREE.Mesh(new THREE.CylinderGeometry(r*0.8, r, 0.06, 20), M.ruby);
g.add(stone);
if (chaton){
const ring = new THREE.Mesh(new THREE.TorusGeometry(r+0.028, 0.022, 10, 28), M.gold);
ring.rotation.x = Math.PI/2; g.add(ring);
}
tag(g, 'jewel');
return g;
}
function tag(obj, id){
obj.traverse(o => { if (o.isMesh && !o.userData.partId) o.userData.partId = id; });
}
/* ================================================================
机芯布局常量(俯视 XZ 平面,-Z 为 12 点方向)
================================================================ */
const POS = {
B: [-1.00, -1.25], // 发条盒
C: [ 0.00, 0.00], // 中心轮
T: [ 1.12, -0.64], // 三轮
F: [ 1.74, 0.19], // 四轮
E: [ 1.45, 0.96], // 擒纵轮
P: [ 0.62, 1.42], // 擒纵叉
BA: [-0.40, 1.90], // 摆轮
};
const v2 = ([x,z]) => new THREE.Vector2(x,z);
const yawTo = (from, to) => Math.atan2(-(to[1]-from[1]), to[0]-from[0]);
/* ================================================================
构建腕表
================================================================ */
const watch = new THREE.Group();
watch.position.set(0, 4.45, 0);
watch.rotation.x = 0.9; // 表盘朝向镜头
scene.add(watch);
const EXPLODABLES = [];
function explodable(group, ox, oy, oz){
EXPLODABLES.push({ g:group, base:group.position.clone(), off:new THREE.Vector3(ox,oy,oz) });
}
/* ---------- 表壳(精钢)+ 表圈(玫瑰金)+ 表耳 ---------- */
const caseG = new THREE.Group(); watch.add(caseG);
{
const prof = [
[2.55,-0.80],[3.45,-0.80],[3.85,-0.52],[3.98,-0.05],[3.96,0.42],
[3.76,0.72],[3.44,0.88],[3.28,0.88],[3.28,-0.45],[2.55,-0.66],[2.55,-0.80]
].map(p => new THREE.Vector2(p[0], p[1]));
const band = new THREE.Mesh(new THREE.LatheGeometry(prof, 128), M.steel);
caseG.add(band);
const bezelProf = [
[3.26,0.84],[3.62,0.84],[3.74,0.94],[3.58,1.05],[3.32,1.07],[3.22,0.97],[3.26,0.84]
].map(p => new THREE.Vector2(p[0], p[1]));
const bezel = new THREE.Mesh(new THREE.LatheGeometry(bezelProf, 128), M.gold);
caseG.add(bezel);
for (const sz of [-1, 1]){ // 表耳与生耳
for (const sx of [-1, 1]){
const lug = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.34, 1.0), M.steel);
lug.position.set(sx*1.62, -0.08, sz*3.82);
lug.rotation.y = -sx*sz*0.22;
lug.rotation.x = sz*0.2;
caseG.add(lug);
}
const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.6, 12), M.steelSat);
bar.rotation.z = Math.PI/2;
bar.position.set(0, -0.16, sz*4.28);
caseG.add(bar);
}
tag(caseG, 'case');
}
/* ---------- 底盖(透明背透) ---------- */
const casebackG = new THREE.Group(); watch.add(casebackG);
{
const prof = [
[2.42,-0.80],[3.42,-0.80],[3.30,-0.98],[2.48,-0.98],[2.42,-0.80]
].map(p => new THREE.Vector2(p[0], p[1]));
const ring = new THREE.Mesh(new THREE.LatheGeometry(prof, 96), M.steel);
const glass = new THREE.Mesh(new THREE.CircleGeometry(2.44, 64), M.crystal.clone());
glass.rotation.x = Math.PI/2; glass.position.y = -0.87; glass.castShadow = false;
casebackG.add(ring, glass);
tag(ring, 'case'); tag(glass, 'crystal');
explodable(casebackG, 0, -1.45, 0);
}
/* ---------- 蓝宝石水晶(弧形穹面) ---------- */
const crystalG = new THREE.Group(); watch.add(crystalG);
{
const R = 8.5, half = Math.asin(3.32/R);
const dome = new THREE.Mesh(new THREE.SphereGeometry(R, 96, 32, 0, Math.PI*2, 0, half), M.crystal);
dome.position.y = -R*Math.cos(half) + 0.95;
dome.castShadow = false;
crystalG.add(dome);
tag(crystalG, 'crystal');
explodable(crystalG, 0, 3.3, 0);
}
/* ---------- 时标圈 + 夜光时标 ---------- */
const chapterG = new THREE.Group(); watch.add(chapterG);
{
const ring = new THREE.Mesh(new THREE.RingGeometry(2.36, 3.22, 128, 1),
new THREE.MeshStandardMaterial({ map:makeChapterTexture(), metalness:0.65, roughness:0.45 }));
ring.geometry.rotateX(-Math.PI/2);
ring.position.y = 0.60;
chapterG.add(ring);
const edge = new THREE.Mesh(new THREE.TorusGeometry(2.36, 0.022, 10, 96), M.gold);
edge.rotation.x = Math.PI/2; edge.position.y = 0.60;
chapterG.add(edge);
for (let i=0;i<12;i++){ // 夜光棒状时标(12 点为双柱)
const phi = i/12 * Math.PI*2;
const r = 2.72;
const px = Math.sin(phi)*r, pz = -Math.cos(phi)*r;
const make = dx => {
const frame = new THREE.Mesh(new THREE.BoxGeometry(0.13, 0.03, 0.44), M.gold);
const bat = new THREE.Mesh(new THREE.BoxGeometry(0.085, 0.055, 0.38), M.lume);
const g = new THREE.Group();
frame.position.y = 0.625; bat.position.y = 0.645;
frame.position.x = dx; bat.position.x = dx;
g.add(frame, bat);
g.position.set(px, 0, pz);
g.rotation.y = -phi;
chapterG.add(g);
};
if (i===0){ make(-0.1); make(0.1); } else make(0);
}
tag(chapterG, 'chapter');
explodable(chapterG, 0, 1.75, 0);
}
/* ---------- 主夹板(镂空骨架 + 珍珠纹 + 穴石) ---------- */
const plateG = new THREE.Group(); watch.add(plateG);
{
const s = new THREE.Shape();
s.absarc(0,0,3.06, 0, Math.PI*2, false);
const h = new THREE.Path(); h.absarc(0,0,2.58, 0, Math.PI*2, true); s.holes.push(h);
const rimRing = new THREE.Mesh(extrudeFlat(s, 0.12, 0.015, 64), M.plate);
rimRing.position.y = -0.40;
plateG.add(rimRing);
for (const key of Object.keys(POS)){ // 轮辐臂 + 轴穴 + 红宝石
const p = POS[key];
const dir = v2(p).clone().normalize();
const anchor = dir.clone().multiplyScalar(2.72);
const mid = anchor.clone().add(v2(p)).multiplyScalar(0.5);
const len = anchor.distanceTo(v2(p));
const arm = new THREE.Mesh(new THREE.BoxGeometry(Math.max(len,0.2), 0.1, 0.2), M.plate);
arm.position.set(mid.x, -0.40, mid.y);
arm.rotation.y = yawTo(p, [anchor.x, anchor.y]);
plateG.add(arm);
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.23, 0.26, 0.11, 24), M.plate);
hub.position.set(p[0], -0.40, p[1]);
plateG.add(hub);
const jw = makeJewel(0.08);
jw.position.set(p[0], -0.335, p[1]);
plateG.add(jw);
}
const hubC = new THREE.Mesh(new THREE.CylinderGeometry(0.34, 0.36, 0.11, 28), M.plate);
hubC.position.y = -0.40; plateG.add(hubC);
for (const a of [0.55, 2.25, 3.65, 5.35]){ // 主夹板固定螺丝
const sc = makeScrew(0.08);
sc.position.set(Math.cos(a)*2.82, -0.315, Math.sin(a)*2.82);
plateG.add(sc);
}
tag(plateG, 'plate');
explodable(plateG, 0, -0.85, 0);
}
/* ---------- 发条盒 + 主发条 + 大钢轮 ---------- */
const barrelG = new THREE.Group(); barrelG.position.set(POS.B[0], 0, POS.B[1]); watch.add(barrelG);
const barrelSpin = new THREE.Group(); barrelG.add(barrelSpin);
{
const gear = makeGear({ teeth:84, tipR:1.32, toothH:0.09, thickness:0.1, boreR:0.13,
hubR:0.5, spokes:6, spokeGap:0.24, mat:M.gilt });
gear.position.y = -0.26;
const wall = new THREE.Mesh(new THREE.CylinderGeometry(0.88, 0.88, 0.4, 64, 1, true), M.steelSat);
wall.position.y = 0.0;
const floor = new THREE.Mesh(new THREE.CircleGeometry(0.875, 64), M.steelSat);
floor.geometry.rotateX(-Math.PI/2); floor.position.y = -0.185;
const arbor = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 0.74, 20), M.steel);
arbor.position.y = 0.06;
barrelSpin.add(gear, wall, floor, arbor);
tag(barrelSpin, 'barrel');
}
explodable(barrelG, 0, 0.15, 0);
const springG = new THREE.Group(); springG.position.set(POS.B[0], 0, POS.B[1]); watch.add(springG);
{
const coil = makeSpiralRibbon(0.16, 0.8, 6.5, 0.3, M.spring, 1.3);
coil.position.y = -0.02;
springG.add(coil);
tag(springG, 'mainspring');
}
explodable(springG, 0, 0.55, 0);
const ratchetG = new THREE.Group(); ratchetG.position.set(POS.B[0], 0, POS.B[1]); watch.add(ratchetG);
{
const wheel = makeGear({ teeth:48, tipR:0.64, toothH:0.055, thickness:0.07, boreR:0.0,
spokes:0, mat:M.gold, bevel:0.008 });
wheel.position.y = 0.315;
const sc = makeScrew(0.13, true); sc.position.y = 0.36;
ratchetG.add(wheel, sc);
tag(wheel, 'ratchet');
}
explodable(ratchetG, 0, 1.0, 0);
/* ---------- 齿轮传动系 ---------- */
function makeWheelAssembly({ pos, wheelY, pinionY, wheel, pinion, arborH, id, phase }){
const outer = new THREE.Group(); outer.position.set(pos[0], 0, pos[1]); watch.add(outer);
const spin = new THREE.Group(); outer.add(spin);
spin.userData.phase = phase;
const w = makeGear(wheel); w.position.y = wheelY; spin.add(w);
if (pinion){ const p = makeGear(pinion); p.position.y = pinionY; spin.add(p); }
const arbor = new THREE.Mesh(new THREE.CylinderGeometry(0.045,0.045, arborH, 12), M.steel);
arbor.position.y = (wheelY + (pinion?pinionY:wheelY))/2;
spin.add(arbor);
tag(outer, id);
return { outer, spin };
}
const pinionBase = { teeth:10, toothH:0.08, thickness:0.14, boreR:0.03, spokes:0, bevel:0.005, curveSegments:5 };
const centerW = makeWheelAssembly({
pos:POS.C, wheelY:-0.06, pinionY:-0.24, id:'center', phase:0.35,
wheel:{ teeth:80, tipR:1.07, toothH:0.08, thickness:0.09, boreR:0.09, hubR:0.2, spokes:5, mat:M.gilt },
pinion:{ ...pinionBase, tipR:0.32, mat:M.steel }, arborH:0.44
});
{ // 中心轴延伸至指针
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.085, 0.085, 0.95, 16), M.gold);
post.position.y = 0.2;
centerW.spin.add(post);
tag(post, 'center');
}
explodable(centerW.outer, 0, 0.26, 0);
const thirdW = makeWheelAssembly({
pos:POS.T, wheelY:-0.20, pinionY:-0.06, id:'third', phase:1.4,
wheel:{ teeth:64, tipR:0.82, toothH:0.075, thickness:0.08, boreR:0.05, hubR:0.16, spokes:5, mat:M.gilt },
pinion:{ ...pinionBase, tipR:0.26, mat:M.steel }, arborH:0.42
});
explodable(thirdW.outer, 0, 0.38, 0);
const fourthW = makeWheelAssembly({
pos:POS.F, wheelY:-0.06, pinionY:-0.20, id:'fourth', phase:2.2,
wheel:{ teeth:60, tipR:0.74, toothH:0.07, thickness:0.08, boreR:0.05, hubR:0.15, spokes:5, mat:M.gilt },
pinion:{ ...pinionBase, tipR:0.26, mat:M.steel }, arborH:0.42
});
explodable(fourthW.outer, 0, 0.5, 0);
const escapeW = makeWheelAssembly({
pos:POS.E, wheelY:0.06, pinionY:-0.06, id:'escape', phase:0,
wheel:{ teeth:15, tipR:0.47, toothH:0.12, thickness:0.05, boreR:0.04, hubR:0.08,
spokes:4, spokeGap:0.35, mat:M.steel, escape:true, bevel:0.005 },
pinion:{ ...pinionBase, teeth:7, tipR:0.12, toothH:0.05, thickness:0.12, mat:M.steel }, arborH:0.36
});
explodable(escapeW.outer, 0, 0.62, 0);
/* ---------- 擒纵叉 ---------- */
const palletG = new THREE.Group(); palletG.position.set(POS.P[0], 0.06, POS.P[1]); watch.add(palletG);
const palletSpin = new THREE.Group(); palletG.add(palletSpin);
const palletYaw = yawTo(POS.P, POS.BA);
{
const body = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.045, 0.085), M.steel);
body.position.x = 0.42;
const hornA = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.05, 0.04), M.steel);
const hornB = hornA.clone();
hornA.position.set(0.95, 0, 0.05); hornB.position.set(0.95, 0, -0.05);
const cross = new THREE.Mesh(new THREE.BoxGeometry(0.085, 0.05, 0.8), M.steel);
cross.position.x = -0.28;
const rubyIn = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.085, 0.06), M.ruby);
const rubyOut = rubyIn.clone();
rubyIn.position.set(-0.28, 0, 0.37); rubyIn.rotation.y = 0.55;
rubyOut.position.set(-0.28, 0, -0.37); rubyOut.rotation.y = -0.55;
const pivot = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.3, 12), M.steel);
palletSpin.add(body, hornA, hornB, cross, rubyIn, rubyOut, pivot);
tag(palletG, 'pallet');
}
explodable(palletG, 0, 0.45, 0);
/* ---------- 摆轮 + 游丝 + 摆轮夹板 ---------- */
const balanceG = new THREE.Group(); balanceG.position.set(POS.BA[0], 0.34, POS.BA[1]); watch.add(balanceG);
const balanceSpin = new THREE.Group(); balanceG.add(balanceSpin);
{
const rimT = new THREE.Mesh(new THREE.TorusGeometry(0.92, 0.075, 20, 96), M.gold);
rimT.rotation.x = Math.PI/2;
const arm = new THREE.Mesh(new THREE.BoxGeometry(1.78, 0.05, 0.13), M.gold);
const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.92, 12), M.steel);
staff.position.y = -0.17;
const roller = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.16, 0.06, 20), M.steel);
roller.position.y = -0.24;
const impulse = new THREE.Mesh(new THREE.BoxGeometry(0.045, 0.07, 0.045), M.ruby);
impulse.position.set(0.13, -0.24, 0);
balanceSpin.add(rimT, arm, staff, roller, impulse);
for (let i=0;i<10;i++){ // 摆轮砝码螺丝
const a = i/10 * Math.PI*2;
const g = new THREE.Group(); g.rotation.y = a;
const w = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.11, 10), M.goldSat);
w.rotation.z = Math.PI/2; w.position.x = 1.0;
g.add(w); balanceSpin.add(g);
}
tag(balanceG, 'balance');
}
explodable(balanceG, 0, 0.8, 0);
const hairspringG = new THREE.Group(); hairspringG.position.set(POS.BA[0], 0.44, POS.BA[1]); watch.add(hairspringG);
const hairSpin = new THREE.Group(); hairspringG.add(hairSpin);
{
const coil = makeSpiralRibbon(0.09, 0.5, 6.75, 0.045, M.bluedDS);
const collet = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.07, 12), M.blued);
hairSpin.add(coil, collet);
tag(hairspringG, 'hairspring');
}
explodable(hairspringG, 0, 1.05, 0);
const cockG = new THREE.Group(); cockG.position.set(POS.BA[0], 0.52, POS.BA[1]); watch.add(cockG);
{
const dirBA = v2(POS.BA).clone().normalize();
const anchor = dirBA.clone().multiplyScalar(2.85);
const len = anchor.distanceTo(v2(POS.BA));
const cock = makeCock({ len, endR:0.5, holeR:0.1, wEnd:0.42,
deco:[{x:len*0.55, y:0, r:0.09}], mat:M.rhodium });
cock.rotation.y = yawTo(POS.BA, [anchor.x, anchor.y]);
cockG.add(cock);
const shock = new THREE.Mesh(new THREE.TorusGeometry(0.15, 0.035, 10, 28), M.gold); // 防震器
shock.rotation.x = Math.PI/2; shock.position.y = 0.05;
const capJewel = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 0.055, 16), M.ruby);
capJewel.position.y = 0.06;
const reg1 = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.022, 0.05), M.steel); // 快慢针
reg1.position.y = 0.05; reg1.rotation.y = 0.7;
const reg2 = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.022, 0.045), M.gold);
reg2.position.y = 0.052; reg2.rotation.y = -0.5;
cockG.add(shock, capJewel, reg1, reg2);
const sc = makeScrew(0.09);
const scPos = anchor.clone().sub(v2(POS.BA));
sc.position.set(scPos.x*0.94, 0.045, scPos.y*0.94);
cockG.add(sc);
const stud = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.14, 10), M.steel); // 游丝桩
const endA = 6.75*Math.PI*2;
stud.position.set(Math.cos(endA)*0.5, -0.055, Math.sin(endA)*0.5);
cockG.add(stud);
tag(cockG, 'cock');
}
explodable(cockG, 0, 1.35, 0);
/* ---------- 轮系夹板(指式镂空夹板 + 穴石 + 螺丝) ---------- */
const bridgesG = new THREE.Group(); watch.add(bridgesG);
{
const fingers = [
{ p:POS.T, endR:0.34, holeR:0.09, wEnd:0.3 },
{ p:POS.F, endR:0.32, holeR:0.09, wEnd:0.28 },
{ p:POS.E, endR:0.30, holeR:0.08, wEnd:0.26 },
{ p:POS.P, endR:0.28, holeR:0.07, wEnd:0.24 },
];
for (const f of fingers){
const dir = v2(f.p).clone().normalize();
const anchor = dir.clone().multiplyScalar(2.85);
const len = anchor.distanceTo(v2(f.p));
const cock = makeCock({ len, endR:f.endR, holeR:f.holeR, wEnd:f.wEnd, mat:M.rhodium });
cock.position.set(f.p[0], 0.24, f.p[1]);
cock.rotation.y = yawTo(f.p, [anchor.x, anchor.y]);
bridgesG.add(cock);
const jw = makeJewel(0.075);
jw.position.set(f.p[0], 0.30, f.p[1]);
bridgesG.add(jw);
const scP = v2(f.p).clone().add(dir.clone().multiplyScalar(len*0.92));
const sc = makeScrew(0.075);
sc.position.set(scP.x, 0.29, scP.y);
bridgesG.add(sc);
}
{ // 中心轮长夹板
const dir = new THREE.Vector2(-0.995, 0.1).normalize();
const anchor = dir.clone().multiplyScalar(2.85);
const len = anchor.length();
const cock = makeCock({ len, endR:0.34, holeR:0.1, wEnd:0.34,
deco:[{x:len*0.42, y:0, r:0.1},{x:len*0.72, y:0, r:0.08}], mat:M.rhodium });
cock.position.set(0, 0.24, 0);
cock.rotation.y = yawTo([0,0], [anchor.x, anchor.y]);
bridgesG.add(cock);
const jw = makeJewel(0.075); jw.position.y = 0.30; bridgesG.add(jw);
const sc = makeScrew(0.075);
sc.position.set(anchor.x*0.94, 0.29, anchor.y*0.94);
bridgesG.add(sc);
}
{ // 发条盒夹板
const dir = v2(POS.B).clone().normalize();
const anchor = dir.clone().multiplyScalar(2.85);
const len = anchor.distanceTo(v2(POS.B));
const cock = makeCock({ len, endR:0.52, holeR:0.13, wEnd:0.44,
deco:[{x:len*0.6, y:0, r:0.09}], mat:M.rhodium });
cock.position.set(POS.B[0], 0.24, POS.B[1]);
cock.rotation.y = yawTo(POS.B, [anchor.x, anchor.y]);
bridgesG.add(cock);
const sc = makeScrew(0.08);
const scP = v2(POS.B).clone().add(dir.clone().multiplyScalar(len*0.9));
sc.position.set(scP.x, 0.29, scP.y);
bridgesG.add(sc);
}
tag(bridgesG, 'bridge');
explodable(bridgesG, 0, 0.78, 0);
}
/* ---------- 表冠(玫瑰金滚花) ---------- */
const crownG = new THREE.Group(); crownG.position.set(4.3, 0.02, 0); watch.add(crownG);
{
const flute = makeGear({ teeth:16, tipR:0.5, toothH:0.07, thickness:0.44, boreR:0,
spokes:0, mat:M.gold, bevel:0.02, curveSegments:5 });
flute.geometry.rotateZ(Math.PI/2);
const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.42, 0.46, 0.1, 28), M.gold);
cap.rotation.z = Math.PI/2; cap.position.x = 0.27;
const cab = new THREE.Mesh(new THREE.SphereGeometry(0.17, 20, 14), M.onyx);
cab.scale.set(0.7, 1, 1); cab.position.x = 0.34;
const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.13, 0.9, 14), M.steelSat);
stem.rotation.z = Math.PI/2; stem.position.x = -0.6;
crownG.add(flute, cap, cab, stem);
tag(crownG, 'crown');
}
explodable(crownG, 1.9, 0, 0);
/* ---------- 指针(镂空金质 + 夜光 / 蓝钢秒针) ---------- */
function handShape(pts, holePts){
const s = new THREE.Shape();
s.moveTo(pts[0][0], pts[0][1]);
for (let i=1;i<pts.length;i++) s.lineTo(pts[i][0], pts[i][1]);
for (let i=pts.length-2;i>=0;i--) s.lineTo(pts[i][0], -pts[i][1]);
s.closePath();
if (holePts){
const h = new THREE.Path();
h.moveTo(holePts[0][0], holePts[0][1]);
for (let i=1;i<holePts.length;i++) h.lineTo(holePts[i][0], holePts[i][1]);
for (let i=holePts.length-2;i>=0;i--) h.lineTo(holePts[i][0], -holePts[i][1]);
h.closePath();
s.holes.push(h);
}
return s;
}
function makeLumeHand({ L, wBase, wMid, lumeAt, lumeLen, y, mat }){
const outer = new THREE.Group(); outer.position.y = y; watch.add(outer);
const inner = new THREE.Group(); inner.rotation.y = Math.PI/2; // +X → -Z(12 点方向)
outer.add(inner);
const pts = [[0.14, wBase],[L*0.32, wMid],[L*0.85, wMid*0.4],[L, 0.001]];
const hole = [[0.26, wBase*0.55],[L*0.33, wMid*0.62],[L*0.72, wMid*0.18],[L*0.78, 0.001]];
const body = new THREE.Mesh(extrudeFlat(handShape(pts, hole), 0.045, 0.006, 6), mat);
const lume = new THREE.Mesh(new THREE.BoxGeometry(lumeLen, 0.035, wMid*0.52), M.lume);
lume.position.set(lumeAt, 0.02, 0);
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.21, 0.08, 24), mat);
inner.add(body, lume, hub);
return { outer, inner };
}
const hourHand = makeLumeHand({ L:1.62, wBase:0.09, wMid:0.15, lumeAt:1.15, lumeLen:0.5, y:0.72, mat:M.gold });
const minuteHand = makeLumeHand({ L:2.32, wBase:0.08, wMid:0.12, lumeAt:1.75, lumeLen:0.62, y:0.82, mat:M.gold });
tag(hourHand.outer, 'hands'); tag(minuteHand.outer, 'hands');
explodable(hourHand.outer, 0, 2.25, 0);
explodable(minuteHand.outer, 0, 2.55, 0);
const secondHand = (() => {
const outer = new THREE.Group(); outer.position.y = 0.92; watch.add(outer);
const inner = new THREE.Group(); inner.rotation.y = Math.PI/2;
outer.add(inner);
const needle = new THREE.Mesh(new THREE.BoxGeometry(3.1, 0.028, 0.035), M.blued);
needle.position.x = 0.8;
const tail = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.03, 0.05), M.blued);
tail.position.x = -0.62;
const counter = new THREE.Mesh(new THREE.TorusGeometry(0.13, 0.04, 10, 24), M.blued);
counter.rotation.x = Math.PI/2; counter.position.x = -0.98;
const lumeDot = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.045, 12), M.lume);
lumeDot.position.set(2.1, 0.01, 0);
const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.085, 0.1, 0.07, 18), M.blued);
inner.add(needle, tail, counter, lumeDot, hub);
tag(outer, 'hands');
explodable(outer, 0, 2.85, 0);
return { outer, inner };
})();
/* ---------- 阴影 ---------- */
watch.traverse(o => {
if (o.isMesh){
o.castShadow = o.material !== M.crystal && o.userData.partId !== 'crystal';
o.receiveShadow = true;
}
});
/* ================================================================
零件资料 + 交互(悬停 / 点击 / 标签)
================================================================ */
const PART_INFO = {
case: { n:'表壳', en:'Case', d:'精钢与 18K 玫瑰金双色表壳,直径 42 毫米。侧面缎面拉丝与镜面抛光交替,表圈以玫瑰金收边。', s:'精钢 · 18K 玫瑰金 · 42 mm' },
crystal: { n:'蓝宝石水晶', en:'Sapphire Crystal', d:'莫氏硬度 9 的弧形蓝宝石玻璃,双面防眩光镀膜,几乎隐形。背透底盖同为蓝宝石,可欣赏机芯背面。', s:'弧形 · 双面 AR 镀膜' },
chapter: { n:'时标圈', en:'Chapter Ring', d:'轨道式分钟刻度环,棒状时标以金质框架镶嵌 Super-LumiNova 夜光物料,幽暗中泛出冷绿色辉光。', s:'Super-LumiNova C3' },
hands: { n:'指针', en:'Hands', d:'镂空金质时针与分针填充夜光物料;中央秒针为火焰蓝钢,尾端配平衡砝码,以每秒 8 步微跳扫过表盘。', s:'金质镂空 · 蓝钢秒针' },
crown: { n:'表冠', en:'Crown', d:'玫瑰金滚花表冠用于上链与调时,顶端镶嵌一粒缟玛瑙凸圆宝石。滚花纹路提供精准的指尖阻尼感。', s:'18K 玫瑰金 · 缟玛瑙' },
plate: { n:'主夹板', en:'Main Plate', d:'机芯的骨架基板。镂空工艺去除一切非结构材料,仅保留放射状轮辐,表面施以手工珍珠圆纹修饰。', s:'镂空 · 珍珠纹修饰' },
bridge: { n:'轮系夹板', en:'Train Bridges', d:'指式镂空夹板逐一固定各轮上轴承,饰以日内瓦条纹,边缘经手工倒角抛光,在光线下呈现连续的亮边。', s:'日内瓦纹 · 手工倒角' },
barrel: { n:'发条盒', en:'Mainspring Barrel', d:'容纳主发条的鼓形容器,外缘齿圈直接驱动中心轮。开面设计让盘绕的发条一览无余。', s:'单发条盒 · 约 72 h' },
mainspring:{ n:'主发条', en:'Mainspring', d:'盘绕于发条盒内的高弹性合金带,是整枚机芯唯一的动力来源。满链时可储存约 72 小时的能量。', s:'Nivaflex 合金 · 72 h' },
ratchet: { n:'大钢轮', en:'Ratchet Wheel', d:'旋合于发条轴之上的金质棘轮。经表冠上链时,大钢轮带动发条轴将主发条逐圈卷紧。', s:'太阳纹打磨' },
center: { n:'中心轮', en:'Center Wheel', d:'轮系的第二轮,每小时自转一周,经分轮驱动分针,并将扭矩继续传递给三轮。', s:'1 转 / 小时' },
third: { n:'三轮', en:'Third Wheel', d:'居于中心轮与四轮之间的过轮,负责变速与换向,让能量以正确的转速抵达秒轮。', s:'镀金黄铜' },
fourth: { n:'四轮', en:'Fourth Wheel', d:'即秒轮,每分钟自转一周,同时把动力送入擒纵轮齿轴,是轮系与擒纵机构的交界。', s:'1 转 / 分钟' },
escape: { n:'擒纵轮', en:'Escape Wheel', d:'轮系的终点。十五枚斜钩齿与擒纵叉宝石交替锁定、释放,每一次释放都给摆轮一记精确的冲量。', s:'15 齿 · 轻量化钢' },
pallet: { n:'擒纵叉', en:'Pallet Fork', d:'形似船锚的杠杆,两端镶嵌人造红宝石叉瓦,以每秒八次的节奏在进瓦与出瓦之间往复,发出机械表的心跳声。', s:'8 拍 / 秒 · 红宝石叉瓦' },
balance: { n:'摆轮', en:'Balance Wheel', d:'机芯的心脏。金质摆轮环携十枚砝码螺丝,以 4 赫兹的频率往复回转,其振荡周期决定了走时的快慢。', s:'4 Hz · 28,800 vph' },
hairspring:{ n:'游丝', en:'Hairspring', d:'比发丝更纤细的蓝钢螺旋弹簧,随摆轮同呼吸般收张。它的有效长度由快慢针微调,是精度的灵魂所在。', s:'蓝钢 · 平游丝' },
cock: { n:'摆轮夹板', en:'Balance Cock', d:'单臂悬挑固定摆轮上轴承,顶端为黄金套筒防震器与红宝石盖石,并承载调校快慢的微调机构。', s:'防震器 · 快慢针' },
jewel: { n:'红宝石轴承', en:'Jewel Bearing', d:'人造刚玉经镜面抛光制成的轴眼,硬度仅次于钻石,将金属轴尖的摩擦降至近乎为零。全表共 23 石。', s:'人造刚玉 · 23 石' },
screw: { n:'蓝钢螺丝', en:'Blued Screws', d:'螺丝经约 300°C 火焰烘烤,表面氧化层折射出深邃的孔雀蓝,既防锈蚀,亦是传统制表美学的签名。', s:'火焰回火 · 镜面头部' },
};
/* 可交互网格准备:克隆材质、记录基础自发光 */
const partMeshes = {};
watch.traverse(o => {
if (o.isMesh && o.userData.partId){
o.material = o.material.clone();
o.userData.baseEmi = o.material.emissive ? o.material.emissive.getHex() : 0;
o.userData.baseEI = o.material.emissiveIntensity ?? 1;
(partMeshes[o.userData.partId] ??= []).push(o);
}
});
let hoveredId = null, selectedId = null;
function paint(id, mode){
for (const m of (partMeshes[id] || [])){
if (mode === 'hover'){ m.material.emissive.setHex(0x9a7433); m.material.emissiveIntensity = 0.85; }
else if (mode === 'select'){ m.material.emissive.setHex(0x6a4e1e); m.material.emissiveIntensity = 0.55; }
else { m.material.emissive.setHex(m.userData.baseEmi); m.material.emissiveIntensity = m.userData.baseEI; }
}
}
function setHover(id){
if (id === hoveredId) return;
if (hoveredId) paint(hoveredId, hoveredId === selectedId ? 'select' : 'off');
hoveredId = id;
if (id) paint(id, 'hover');
renderer.domElement.style.cursor = id ? 'pointer' : 'grab';
}
function setSelect(id){
if (selectedId && selectedId !== id) paint(selectedId, 'off');
selectedId = id;
if (id){
paint(id, id === hoveredId ? 'hover' : 'select');
const p = PART_INFO[id];
document.getElementById('partName').textContent = p.n;
document.getElementById('partEn').textContent = p.en;
document.getElementById('partDesc').textContent = p.d;
document.getElementById('partSpec').textContent = p.s ? '— ' + p.s : '';
}
}
const raycaster = new THREE.Raycaster();
const mouseNDC = new THREE.Vector2();
const interactive = [];
watch.traverse(o => { if (o.isMesh && o.userData.partId) interactive.push(o); });
function pickPart(clientX, clientY){
mouseNDC.set(clientX/innerWidth*2-1, -(clientY/innerHeight)*2+1);
raycaster.setFromCamera(mouseNDC, camera);
const hits = raycaster.intersectObjects(interactive, false);
if (!hits.length) return null;
const solid = hits.find(h => h.object.userData.partId !== 'crystal');
return (solid || hits[0]).object.userData.partId;
}
const tip = document.getElementById('tip');
let pdX=0, pdY=0;
renderer.domElement.addEventListener('pointermove', e => {
if (e.pointerType === 'touch') return;
const id = pickPart(e.clientX, e.clientY);
setHover(id);
if (id){
tip.innerHTML = `${PART_INFO[id].n}<small>${PART_INFO[id].en} · 点击查看详情</small>`;
tip.style.opacity = 1;
tip.style.left = Math.min(e.clientX + 16, innerWidth-170) + 'px';
tip.style.top = (e.clientY + 14) + 'px';
} else tip.style.opacity = 0;
});
renderer.domElement.addEventListener('pointerdown', e => { pdX=e.clientX; pdY=e.clientY; introDone = true; });
renderer.domElement.addEventListener('pointerup', e => {
if (Math.hypot(e.clientX-pdX, e.clientY-pdY) > 6) return;
const id = pickPart(e.clientX, e.clientY);
setSelect(id);
if (!id){
document.getElementById('partName').textContent = '— 探索机芯 —';
document.getElementById('partEn').textContent = 'Interactive Movement';
document.getElementById('partDesc').textContent = '将光标悬停于任意零件可查看其名称;点击零件可在此处阅读详细介绍。';
document.getElementById('partSpec').textContent = '';
}
});
renderer.domElement.addEventListener('pointerleave', () => { setHover(null); tip.style.opacity = 0; });
/* ---------- 零件标注(投影标签 + 引线) ---------- */
const LABEL_DEFS = [
{ id:'balance', host:balanceG, off:[ 0.9, 0.1, 0.5] },
{ id:'hairspring', host:hairspringG, off:[-0.45, 0.05, -0.3] },
{ id:'pallet', host:palletG, off:[ 0.2, 0.05, 0.4] },
{ id:'escape', host:escapeW.outer, off:[ 0.4, 0.1, 0.2] },
{ id:'fourth', host:fourthW.outer, off:[ 0.5, 0.0, -0.2] },
{ id:'center', host:centerW.outer, off:[-0.3, 0.0, -0.6] },
{ id:'mainspring', host:springG, off:[-0.5, 0.15, -0.5] },
{ id:'cock', host:cockG, off:[-0.6, 0.1, 0.5] },
{ id:'crown', host:crownG, off:[ 0.3, 0.2, 0.0] },
{ id:'bridge', host:bridgesG, off:[ 2.4, 0.3, -0.6] },
];
const labelsRoot = document.getElementById('labels');
const labelObjs = LABEL_DEFS.map(def => {
const anchor = new THREE.Object3D();
anchor.position.set(...def.off);
def.host.add(anchor);
const el = document.createElement('div');
el.className = 'plabel';
el.innerHTML = `<div class="dot"></div><div class="ln"></div><div class="tx">${PART_INFO[def.id].n}</div>`;
labelsRoot.appendChild(el);
return { anchor, el, dot:el.children[0], ln:el.children[1], tx:el.children[2] };
});
let labelsOn = false;
const _wp = new THREE.Vector3(), _cp = new THREE.Vector3();
function updateLabels(){
if (!labelsOn){ for (const l of labelObjs) l.el.classList.remove('show'); return; }
watch.getWorldPosition(_cp); _cp.project(camera);
const cx = ( _cp.x*0.5+0.5)*innerWidth, cy = (-_cp.y*0.5+0.5)*innerHeight;
for (const l of labelObjs){
l.anchor.getWorldPosition(_wp); _wp.project(camera);
if (_wp.z > 1){ l.el.classList.remove('show'); continue; }
const sx = ( _wp.x*0.5+0.5)*innerWidth, sy = (-_wp.y*0.5+0.5)*innerHeight;
let dx = sx-cx, dy = sy-cy;
const dl = Math.hypot(dx,dy) || 1; dx/=dl; dy/=dl;
const ext = 62;
const ex = sx+dx*ext, ey = sy+dy*ext;
l.el.classList.add('show');
l.dot.style.transform = `translate(${sx-2.5}px,${sy-2.5}px)`;
l.ln.style.width = ext+'px';
l.ln.style.transform = `translate(${sx}px,${sy}px) rotate(${Math.atan2(dy,dx)}rad)`;
l.tx.style.transform = `translate(${ex}px,${ey}px) translate(${dx>0?28:-28}px,0) translate(-50%,-50%)`;
}
}
/* ================================================================
UI 控制
================================================================ */
let explodeTarget = 0, explodeF = 0;
let autoRot = true, tsTarget = 1, tsCur = 1;
let introDone = false;
const $ = id => document.getElementById(id);
$('bExplode').addEventListener('click', function(){
explodeTarget = explodeTarget ? 0 : 1;
this.classList.toggle('on', !!explodeTarget);
this.textContent = explodeTarget ? '组装视图' : '爆炸视图';
});
$('bLabels').addEventListener('click', function(){
labelsOn = !labelsOn;
this.classList.toggle('on', labelsOn);
});
$('bRotate').addEventListener('click', function(){
autoRot = !autoRot;
this.classList.toggle('on', autoRot);
});
$('bSlow').addEventListener('click', function(){
tsTarget = tsTarget === 1 ? 0.12 : 1;
this.classList.toggle('on', tsTarget !== 1);
});
$('bReset').addEventListener('click', () => {
explodeTarget = 0;
$('bExplode').classList.remove('on'); $('bExplode').textContent = '爆炸视图';
camera.position.copy(CAM_END);
controls.target.set(0, 4.0, 0);
setSelect(null);
});
$('panelClose').addEventListener('click', () => {
$('panel').classList.add('hidden'); $('panelTab').classList.add('show');
});
$('panelTab').addEventListener('click', () => {
$('panel').classList.remove('hidden'); $('panelTab').classList.remove('show');
});
setTimeout(() => { const h = $('hint'); if (h) h.style.opacity = 0.0; }, 9000);
/* ================================================================
动画:擒纵节拍 / 轮系 / 摆轮呼吸 / 走时
================================================================ */
const clock = new THREE.Clock();
let simT = 0;
const now0 = new Date();
const startSec = now0.getSeconds() + now0.getMilliseconds()/1000;
const startMin = now0.getMinutes();
const startHr = now0.getHours();
const HALF_STEP = Math.PI*2/15/2; // 擒纵轮每拍前进半齿
let escAngle = 0, fourthAngle = 0;
const easeIO = t => t<0.5 ? 4*t*t*t : 1-Math.pow(-2*t+2,3)/2;
let elapsed = 0;
function animate(){
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
elapsed += dt;
/* 慢动作平滑过渡 */
tsCur += (tsTarget-tsCur)*(1-Math.exp(-dt*4));
simT += dt*tsCur;
/* 摆轮 4 Hz 振荡,擒纵每秒 8 拍 */
const balAngle = 2.45*Math.sin(Math.PI*2*4*simT);
balanceSpin.rotation.y = balAngle;
hairSpin.rotation.y = balAngle*0.18;
const hs = 1 - balAngle*0.04;
hairSpin.scale.set(hs, 1, hs);
palletSpin.rotation.y = palletYaw + Math.tanh(Math.sin(Math.PI*2*4*simT)*8)*0.15;
const beats = Math.floor(simT*8);
escAngle += (-beats*HALF_STEP - escAngle)*(1-Math.exp(-dt*30));
escapeW.spin.rotation.y = escAngle;
const secTick = startSec + beats/8;
fourthAngle += (-Math.PI*2*secTick/60 - fourthAngle)*(1-Math.exp(-dt*22));
fourthW.spin.rotation.y = fourthAngle + fourthW.spin.userData.phase;
secondHand.outer.rotation.y = fourthAngle;
thirdW.spin.rotation.y = Math.PI*2*simT/200 + thirdW.spin.userData.phase;
centerW.spin.rotation.y = -Math.PI*2*simT/3600 + centerW.spin.userData.phase;
barrelSpin.rotation.y = Math.PI*2*simT/21600;
/* 时 / 分针走真实时间 */
const totSec = startSec + simT;
const mins = startMin + totSec/60;
const hrs = (startHr % 12) + mins/60;
minuteHand.outer.rotation.y = -Math.PI*2*(mins%60)/60;
hourHand.outer.rotation.y = -Math.PI*2*(hrs%12)/12;
/* 爆炸视图插值 */
explodeF += (explodeTarget-explodeF)*(1-Math.exp(-dt*3));
const ef = easeIO(Math.max(0, Math.min(1, explodeF)));
for (const e of EXPLODABLES)
e.g.position.copy(e.base).addScaledVector(e.off, ef);
/* 环境微旋转,金属高光缓慢流动 */
if (scene.environmentRotation) scene.environmentRotation.y = elapsed*0.045;
/* 入场镜头 */
if (!introDone){
const k = Math.min(1, elapsed/2.6);
camera.position.lerpVectors(CAM_START, CAM_END, 1-Math.pow(1-k,3));
if (k >= 1) introDone = true;
}
controls.autoRotate = autoRot;
controls.update();
composer.render();
updateLabels();
}
animate();
window.addEventListener('resize', () => {
camera.aspect = innerWidth/innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
composer.setSize(innerWidth, innerHeight);
});
/* 首帧就绪后揭幕 */
setTimeout(() => document.getElementById('load').classList.add('gone'), 500);
</script>
</body>
</html>