Files
nagi-bench/outputs/gpt-5-6-sol/codex-cli-ultra/skeleton-watch.html
T

1951 lines
72 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#05070a" />
<title>AURELIUS S-01 — 交互式镂空机械表</title>
<style>
:root {
--bg: #05070a;
--panel: rgba(12, 15, 19, 0.76);
--panel-solid: #0d1014;
--line: rgba(255, 255, 255, 0.13);
--line-strong: rgba(255, 255, 255, 0.22);
--ink: #f2f1eb;
--muted: #9ba3a8;
--dim: #686f73;
--lume: #bfffd6;
--gold: #d7b47d;
--ruby: #ef385e;
--ease: cubic-bezier(.2, .75, .25, 1);
}
* { box-sizing: border-box; }
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: var(--bg);
color: var(--ink);
font-family: "Helvetica Neue", "Segoe UI", Arial, sans-serif;
font-weight: 400;
}
body {
background:
radial-gradient(circle at 50% 38%, #172028 0%, #0a0d11 30%, #05070a 66%, #030406 100%);
}
button,
select { font: inherit; }
button { color: inherit; }
button:focus-visible,
select:focus-visible {
outline: 2px solid var(--lume);
outline-offset: 3px;
}
#app {
position: relative;
width: 100%;
height: 100dvh;
min-height: 560px;
isolation: isolate;
overflow: hidden;
}
#app::before {
content: "";
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
background:
linear-gradient(90deg, rgba(1, 2, 3, .5), transparent 22%, transparent 76%, rgba(1, 2, 3, .4)),
radial-gradient(circle at 50% 45%, transparent 32%, rgba(0, 0, 0, .48) 100%);
}
#app::after {
content: "";
position: absolute;
inset: -80%;
z-index: 5;
pointer-events: none;
opacity: .035;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.95' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.9'/%3E%3C/svg%3E");
animation: grain .28s steps(2) infinite;
}
@keyframes grain {
0% { transform: translate3d(-2%, 1%, 0); }
25% { transform: translate3d(2%, -1%, 0); }
50% { transform: translate3d(1%, 2%, 0); }
75% { transform: translate3d(-1%, -2%, 0); }
100% { transform: translate3d(-2%, 1%, 0); }
}
#stage,
#stage canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
#stage { z-index: 1; }
#stage canvas {
display: block;
touch-action: none;
cursor: grab;
}
#stage canvas:active { cursor: grabbing; }
.chrome {
position: absolute;
z-index: 10;
pointer-events: none;
}
.chrome button,
.chrome select,
.part-panel { pointer-events: auto; }
.topbar {
top: 0;
left: 0;
right: 0;
height: 92px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: max(22px, env(safe-area-inset-top)) 34px 12px;
border-bottom: 1px solid rgba(255, 255, 255, .07);
background: linear-gradient(180deg, rgba(3, 4, 6, .72), transparent);
}
.brand {
display: flex;
align-items: center;
gap: 13px;
min-width: 220px;
}
.brand-mark {
position: relative;
width: 29px;
height: 29px;
border: 1px solid rgba(215, 180, 125, .72);
border-radius: 50%;
}
.brand-mark::before,
.brand-mark::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
background: var(--gold);
transform: translate(-50%, -50%) rotate(45deg);
}
.brand-mark::before { width: 12px; height: 1px; }
.brand-mark::after { width: 1px; height: 12px; }
.brand-name {
font-family: "Arial Narrow", "Helvetica Neue", sans-serif;
font-size: 15px;
font-weight: 600;
letter-spacing: .24em;
}
.brand-sub {
margin-top: 4px;
color: var(--dim);
font-size: 8px;
font-weight: 600;
letter-spacing: .28em;
text-transform: uppercase;
}
.calibre-id {
justify-self: center;
color: #d5d8d8;
font-family: "Arial Narrow", sans-serif;
font-size: 10px;
letter-spacing: .34em;
text-transform: uppercase;
}
.top-actions {
justify-self: end;
display: flex;
align-items: center;
gap: 8px;
}
.icon-button,
.text-button {
height: 40px;
border: 1px solid var(--line);
background: rgba(9, 12, 15, .5);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
transition: border-color .3s, background .3s, color .3s, transform .3s;
cursor: pointer;
}
.icon-button:hover,
.text-button:hover {
border-color: var(--line-strong);
background: rgba(25, 29, 33, .72);
}
.icon-button:active,
.text-button:active { transform: scale(.97); }
.icon-button {
display: grid;
place-items: center;
width: 40px;
border-radius: 50%;
}
.icon-button svg { width: 15px; height: 15px; }
.text-button {
padding: 0 16px;
border-radius: 999px;
font-size: 9px;
font-weight: 600;
letter-spacing: .15em;
text-transform: uppercase;
}
.text-button .state-dot {
display: inline-block;
width: 5px;
height: 5px;
margin-right: 8px;
border-radius: 50%;
background: var(--dim);
box-shadow: 0 0 0 3px rgba(255,255,255,.04);
vertical-align: 1px;
transition: background .3s, box-shadow .3s;
}
.text-button[aria-pressed="true"] {
color: var(--lume);
border-color: rgba(191, 255, 214, .3);
background: rgba(20, 40, 30, .45);
}
.text-button[aria-pressed="true"] .state-dot {
background: var(--lume);
box-shadow: 0 0 9px rgba(191, 255, 214, .8);
}
.hero-copy {
left: 34px;
bottom: 104px;
width: min(305px, 24vw);
}
.kicker {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 13px;
color: var(--gold);
font-size: 9px;
font-weight: 600;
letter-spacing: .28em;
text-transform: uppercase;
}
.kicker::before {
content: "";
width: 26px;
height: 1px;
background: currentColor;
}
.hero-copy h1 {
margin: 0;
font-family: "Arial Narrow", "Helvetica Neue", sans-serif;
font-size: clamp(36px, 4vw, 67px);
font-weight: 200;
line-height: .92;
letter-spacing: -.045em;
text-transform: uppercase;
}
.hero-copy h1 span {
display: block;
color: transparent;
-webkit-text-stroke: 1px rgba(242, 241, 235, .45);
}
.hero-copy p {
width: 250px;
margin: 19px 0 0 39px;
color: var(--muted);
font-size: 11px;
line-height: 1.75;
letter-spacing: .04em;
}
.spec-rail {
left: 34px;
top: 118px;
display: flex;
flex-direction: column;
gap: 7px;
}
.spec-chip {
display: flex;
align-items: center;
gap: 9px;
color: #7e878c;
font-size: 8px;
font-weight: 600;
letter-spacing: .17em;
text-transform: uppercase;
}
.spec-chip i {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--gold);
box-shadow: 0 0 7px rgba(215,180,125,.4);
}
.side-index {
right: 26px;
top: 50%;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
transform: translateY(-50%);
}
.side-index span {
color: #5b6266;
font-size: 7px;
letter-spacing: .16em;
writing-mode: vertical-rl;
text-transform: uppercase;
}
.side-index::before,
.side-index::after {
content: "";
width: 1px;
height: 50px;
background: linear-gradient(transparent, rgba(255,255,255,.2));
}
.side-index::after { transform: rotate(180deg); }
.part-panel {
right: 34px;
bottom: 98px;
width: 330px;
padding: 19px 20px 18px;
border: 1px solid var(--line);
border-radius: 2px;
background: var(--panel);
box-shadow: 0 24px 80px rgba(0,0,0,.35), inset 0 1px 0 rgba(255,255,255,.04);
backdrop-filter: blur(22px) saturate(1.15);
-webkit-backdrop-filter: blur(22px) saturate(1.15);
transition: opacity .4s var(--ease), transform .5s var(--ease);
}
.part-panel.is-minimized {
opacity: 0;
transform: translateX(25px);
pointer-events: none;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.panel-seq {
color: var(--gold);
font-family: "Arial Narrow", sans-serif;
font-size: 9px;
letter-spacing: .22em;
text-transform: uppercase;
}
.panel-close {
display: grid;
place-items: center;
width: 27px;
height: 27px;
margin: -5px -6px 0 0;
padding: 0;
border: 0;
border-radius: 50%;
color: #858d91;
background: transparent;
cursor: pointer;
}
.panel-close:hover { color: white; background: rgba(255,255,255,.06); }
.part-panel h2 {
margin: 10px 0 3px;
font-family: "Arial Narrow", "Helvetica Neue", sans-serif;
font-size: 25px;
font-weight: 300;
letter-spacing: -.025em;
text-transform: uppercase;
}
.part-cn {
color: var(--muted);
font-size: 10px;
letter-spacing: .1em;
}
.part-rule {
position: relative;
height: 1px;
margin: 16px 0 14px;
background: rgba(255,255,255,.1);
}
.part-rule::before {
content: "";
position: absolute;
left: 0;
top: -1px;
width: 38px;
height: 2px;
background: var(--gold);
}
.part-description {
min-height: 45px;
margin: 0;
color: #aab0b3;
font-size: 10px;
line-height: 1.65;
}
.metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
margin-top: 15px;
background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.08);
}
.metric {
min-width: 0;
padding: 11px 8px 10px;
background: rgba(8,10,13,.82);
}
.metric-label {
display: block;
margin-bottom: 6px;
overflow: hidden;
color: #646c71;
font-size: 7px;
letter-spacing: .12em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.metric-value {
display: block;
overflow: hidden;
color: #dfe2e1;
font-family: "Arial Narrow", sans-serif;
font-size: 10px;
letter-spacing: .06em;
text-overflow: ellipsis;
white-space: nowrap;
}
.part-select-wrap {
display: flex;
align-items: center;
gap: 10px;
margin-top: 14px;
}
.part-select-wrap label {
flex: none;
color: #60686d;
font-size: 7px;
letter-spacing: .14em;
text-transform: uppercase;
}
#part-select {
width: 100%;
min-width: 0;
height: 33px;
padding: 0 27px 0 10px;
border: 1px solid rgba(255,255,255,.1);
border-radius: 0;
color: #cdd1d1;
background: #0d1014;
font-size: 9px;
letter-spacing: .05em;
cursor: pointer;
}
.bottom-dock {
left: 50%;
bottom: max(24px, env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: 3px;
padding: 5px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(9, 11, 14, .72);
box-shadow: 0 12px 45px rgba(0,0,0,.36);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
transform: translateX(-50%);
}
.dock-button {
display: flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 14px;
border: 0;
border-radius: 999px;
color: #9da4a8;
background: transparent;
font-size: 8px;
font-weight: 600;
letter-spacing: .12em;
text-transform: uppercase;
transition: color .25s, background .25s;
cursor: pointer;
}
.dock-button:hover { color: #eef0ef; background: rgba(255,255,255,.06); }
.dock-button[aria-pressed="true"] {
color: var(--lume);
background: rgba(191,255,214,.08);
}
.dock-button svg { width: 14px; height: 14px; flex: none; }
.dock-divider { width: 1px; height: 19px; background: rgba(255,255,255,.1); }
.interaction-hint {
left: 50%;
bottom: 80px;
display: flex;
align-items: center;
gap: 8px;
color: rgba(189, 196, 198, .55);
font-size: 7px;
letter-spacing: .14em;
text-transform: uppercase;
transform: translateX(-50%);
transition: opacity .5s;
}
.interaction-hint svg { width: 13px; height: 13px; }
.interaction-hint.hidden { opacity: 0; }
#cursor-label {
position: absolute;
z-index: 30;
display: none;
min-width: 132px;
padding: 9px 12px 9px 14px;
border: 1px solid rgba(255,255,255,.16);
color: #f5f5f0;
background: rgba(9, 12, 15, .88);
box-shadow: 0 8px 30px rgba(0,0,0,.3);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
pointer-events: none;
transform: translate3d(16px, 16px, 0);
}
#cursor-label::before {
content: "";
position: absolute;
top: 12px;
left: -5px;
width: 8px;
height: 8px;
border-left: 1px solid rgba(255,255,255,.18);
border-bottom: 1px solid rgba(255,255,255,.18);
background: rgba(9,12,15,.88);
transform: rotate(45deg);
}
#cursor-label .cursor-kind {
display: block;
margin-bottom: 4px;
color: var(--gold);
font-size: 7px;
letter-spacing: .18em;
text-transform: uppercase;
}
#cursor-label .cursor-name {
display: block;
font-family: "Arial Narrow", sans-serif;
font-size: 11px;
letter-spacing: .08em;
text-transform: uppercase;
}
#active-label {
position: absolute;
z-index: 20;
display: none;
align-items: center;
gap: 9px;
color: #dce1df;
pointer-events: none;
transform: translate3d(-7px, -50%, 0);
}
#active-label .pin {
position: relative;
width: 7px;
height: 7px;
border: 1px solid var(--lume);
border-radius: 50%;
box-shadow: 0 0 12px rgba(191,255,214,.65);
}
#active-label .pin::before {
content: "";
position: absolute;
top: 50%;
left: 6px;
width: 29px;
height: 1px;
background: linear-gradient(90deg, rgba(191,255,214,.85), rgba(255,255,255,.18));
}
#active-label .active-name {
margin-left: 29px;
padding: 5px 8px;
border-left: 1px solid rgba(255,255,255,.24);
color: rgba(232,235,232,.84);
background: rgba(5,7,9,.38);
font-size: 7px;
font-weight: 600;
letter-spacing: .16em;
text-transform: uppercase;
white-space: nowrap;
}
#loading {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
background: #05070a;
transition: opacity .9s var(--ease), visibility .9s;
}
#loading.done { opacity: 0; visibility: hidden; pointer-events: none; }
.loader-inner { width: min(310px, 72vw); text-align: center; }
.loader-emblem {
position: relative;
width: 54px;
height: 54px;
margin: 0 auto 25px;
border: 1px solid rgba(215,180,125,.22);
border-radius: 50%;
}
.loader-emblem::before {
content: "";
position: absolute;
inset: 6px;
border: 1px dashed rgba(215,180,125,.55);
border-radius: 50%;
animation: spin 4s linear infinite;
}
.loader-emblem::after {
content: "A";
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--gold);
font-family: Georgia, serif;
font-size: 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loader-title {
margin-bottom: 17px;
color: #d9dcdb;
font-size: 9px;
letter-spacing: .3em;
text-transform: uppercase;
}
.loader-track { height: 1px; background: rgba(255,255,255,.09); overflow: hidden; }
.loader-bar { width: 0; height: 100%; background: var(--gold); box-shadow: 0 0 9px var(--gold); transition: width .3s; }
.loader-status {
display: flex;
justify-content: space-between;
margin-top: 9px;
color: #50575b;
font-size: 7px;
letter-spacing: .14em;
text-transform: uppercase;
}
#error-message {
position: fixed;
inset: 0;
z-index: 110;
display: none;
place-items: center;
padding: 28px;
background: #06080a;
text-align: center;
}
#error-message div { max-width: 500px; }
#error-message h2 { margin: 0 0 12px; font-weight: 300; letter-spacing: .08em; }
#error-message p { color: var(--muted); font-size: 13px; line-height: 1.7; }
@media (max-width: 1120px) {
.hero-copy { width: 220px; }
.hero-copy p { display: none; }
.part-panel { width: 292px; }
.side-index { display: none; }
}
@media (max-width: 820px) {
#app { min-height: 500px; }
.topbar { height: 70px; padding: max(14px, env(safe-area-inset-top)) 16px 8px; }
.brand { min-width: 0; }
.brand-sub, .calibre-id, #info-toggle span, #auto-toggle { display: none; }
.text-button { padding: 0 12px; }
.spec-rail, .hero-copy, .side-index { display: none; }
.part-panel {
right: 12px;
bottom: 82px;
left: 12px;
width: auto;
max-height: 38vh;
padding: 15px 16px;
overflow: auto;
transform-origin: bottom center;
}
.part-panel.is-minimized { transform: translateY(18px); }
.part-panel h2 { margin-top: 6px; font-size: 20px; }
.part-description { min-height: 0; }
.metrics { margin-top: 11px; }
.part-select-wrap { margin-top: 10px; }
.bottom-dock { bottom: max(16px, env(safe-area-inset-bottom)); }
.dock-button { padding: 0 11px; }
.dock-button span { display: none; }
.interaction-hint { bottom: 70px; }
#active-label { display: none !important; }
}
@media (max-width: 450px) {
.brand-name { font-size: 12px; letter-spacing: .17em; }
.brand-mark { width: 25px; height: 25px; }
.top-actions { gap: 5px; }
.icon-button, .text-button { height: 36px; }
.icon-button { width: 36px; }
.part-panel { max-height: 42vh; }
.metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.metric { padding-left: 6px; padding-right: 6px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
}
}
</style>
</head>
<body>
<main id="app">
<div id="stage" role="img" aria-label="可旋转、缩放并探索内部机械零件的三维镂空腕表"></div>
<header class="topbar chrome">
<div class="brand" aria-label="Aurelius Manufacture">
<div class="brand-mark" aria-hidden="true"></div>
<div>
<div class="brand-name">AURELIUS</div>
<div class="brand-sub">Haute Horlogerie · Genève</div>
</div>
</div>
<div class="calibre-id">Calibre S–01 / No. 008</div>
<div class="top-actions">
<button class="text-button" id="auto-toggle" type="button" aria-pressed="true" title="切换自动旋转">
<i class="state-dot"></i>Auto orbit
</button>
<button class="icon-button" id="info-toggle" type="button" aria-label="打开机芯信息" aria-pressed="true" title="机芯信息">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 10.7v5.8M12 7.4v.1"/></svg>
<span hidden>Info</span>
</button>
</div>
</header>
<aside class="spec-rail chrome" aria-label="机芯主要参数">
<div class="spec-chip"><i></i>Hand-wound calibre</div>
<div class="spec-chip"><i></i>41 jewels</div>
<div class="spec-chip"><i></i>72 hour reserve</div>
</aside>
<section class="hero-copy chrome" aria-label="作品介绍">
<div class="kicker">Sculpted mechanics</div>
<h1>The art of <span>motion</span></h1>
<p>让光穿过每一枚齿轮与夹板。S–01 以 248 枚零件,呈现时间最纯粹的机械形态。</p>
</section>
<div class="side-index chrome" aria-hidden="true"><span>Interactive calibre study · 01</span></div>
<aside class="part-panel chrome" id="part-panel" aria-live="polite" aria-label="机芯零件信息">
<div class="panel-head">
<div class="panel-seq" id="part-seq">00 / Calibre</div>
<button class="panel-close" id="panel-close" type="button" aria-label="关闭信息面板">×</button>
</div>
<h2 id="part-name">Calibre S–01</h2>
<div class="part-cn" id="part-cn">手动上链镂空机芯</div>
<div class="part-rule"></div>
<p class="part-description" id="part-description">完全开放的双夹板结构,让发条盒、轮系、擒纵和摆轮在同一视野内协同运转。点击任意高亮零件查看其机械职责。</p>
<div class="metrics">
<div class="metric"><span class="metric-label" id="metric-label-1">Frequency</span><span class="metric-value" id="metric-value-1">28,800 vph</span></div>
<div class="metric"><span class="metric-label" id="metric-label-2">Components</span><span class="metric-value" id="metric-value-2">248</span></div>
<div class="metric"><span class="metric-label" id="metric-label-3">Finish</span><span class="metric-value" id="metric-value-3">Hand bevel</span></div>
</div>
<div class="part-select-wrap">
<label for="part-select">Explore</label>
<select id="part-select" aria-label="选择机芯零件">
<option value="">选择机芯零件…</option>
</select>
</div>
</aside>
<div class="interaction-hint chrome" id="interaction-hint" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M8 11V7a2 2 0 0 1 4 0v3-5a2 2 0 0 1 4 0v5-2a2 2 0 0 1 4 0v6c0 4-3 7-7 7h-1c-3 0-5-2-6-5l-2-4a2 2 0 0 1 4-1Z"/></svg>
Drag to orbit · Scroll to inspect
</div>
<nav class="bottom-dock chrome" aria-label="三维展示控制">
<button class="dock-button" id="explode-toggle" type="button" aria-pressed="false" title="爆炸视图 (E)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><path d="m8 8-5-5m0 0v4m0-4h4M16 8l5-5m0 0v4m0-4h-4M8 16l-5 5m0 0v-4m0 4h4M16 16l5 5m0 0v-4m0 4h-4"/><rect x="8" y="8" width="8" height="8" rx="1"/></svg>
<span>Exploded</span>
</button>
<div class="dock-divider"></div>
<button class="dock-button" id="motion-toggle" type="button" aria-pressed="false" title="暂停机芯动画 (空格)">
<svg class="motion-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M8 5v14M16 5v14"/></svg>
<span>Pause</span>
</button>
<div class="dock-divider"></div>
<button class="dock-button" id="reset-view" type="button" title="重置视角 (R)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M4.9 7A9 9 0 1 1 3 12M4.9 7H9M4.9 7V3"/></svg>
<span>Reset</span>
</button>
</nav>
<div id="cursor-label" aria-hidden="true"><span class="cursor-kind"></span><span class="cursor-name"></span></div>
<div id="active-label" aria-hidden="true"><span class="pin"></span><span class="active-name"></span></div>
<div id="loading" role="status" aria-live="polite">
<div class="loader-inner">
<div class="loader-emblem"></div>
<div class="loader-title">Assembling calibre S–01</div>
<div class="loader-track"><div class="loader-bar" id="loader-bar"></div></div>
<div class="loader-status"><span id="loader-step">Preparing atelier</span><span id="loader-percent">0%</span></div>
</div>
</div>
<div id="error-message">
<div><h2>无法启动 3D 展示</h2><p>此体验需要支持 WebGL 的现代浏览器,并需联网载入 Three.js。请尝试使用最新版 Chrome、Safari 或 Firefox。</p></div>
</div>
</main>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.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';
import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
const $ = (selector) => document.querySelector(selector);
const stage = $('#stage');
const loading = $('#loading');
const loaderBar = $('#loader-bar');
const loaderPercent = $('#loader-percent');
const loaderStep = $('#loader-step');
const errorMessage = $('#error-message');
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const isCompact = () => window.innerWidth < 821;
const state = {
exploded: false,
explodeFactor: 0,
motionPaused: false,
autoRotate: !reducedMotion,
hovered: null,
selected: null,
pointerDirty: false,
dragging: false,
pointerDown: new THREE.Vector2(),
intro: reducedMotion ? 1 : 0,
lastInteraction: 0,
visible: true
};
const parts = [];
const partById = new Map();
const pickables = [];
const explodables = [];
const rotors = [];
const oscillators = {};
const pointer = new THREE.Vector2(10, 10);
const raycaster = new THREE.Raycaster();
const tempV3 = new THREE.Vector3();
const tempV3b = new THREE.Vector3();
const tempBox = new THREE.Box3();
function progress(percent, label) {
loaderBar.style.width = `${percent}%`;
loaderPercent.textContent = `${percent}%`;
loaderStep.textContent = label;
}
progress(8, 'Calibrating optics');
let renderer, scene, camera, controls, composer, outlinePass, bloomPass;
let presentationRoot, watchRoot;
try {
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isCompact() ? 1.35 : 1.8));
renderer.setSize(stage.clientWidth, stage.clientHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.08;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.domElement.setAttribute('aria-hidden', 'true');
stage.appendChild(renderer.domElement);
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x05070a, 0.022);
camera = new THREE.PerspectiveCamera(31, stage.clientWidth / stage.clientHeight, 0.1, 80);
camera.position.set(0.5, 1.15, isCompact() ? 17.8 : 15.7);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.055;
controls.rotateSpeed = 0.58;
controls.zoomSpeed = 0.62;
controls.minDistance = 9.5;
controls.maxDistance = 24;
controls.minPolarAngle = 0.16;
controls.maxPolarAngle = Math.PI - 0.16;
controls.target.set(0, 0, 0);
controls.autoRotate = state.autoRotate;
controls.autoRotateSpeed = 0.48;
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
pmrem.dispose();
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
bloomPass = new UnrealBloomPass(new THREE.Vector2(stage.clientWidth, stage.clientHeight), isCompact() ? 0.28 : 0.42, 0.35, 0.86);
composer.addPass(bloomPass);
outlinePass = new OutlinePass(new THREE.Vector2(stage.clientWidth, stage.clientHeight), scene, camera);
outlinePass.edgeStrength = 3.1;
outlinePass.edgeGlow = 0.3;
outlinePass.edgeThickness = 0.85;
outlinePass.pulsePeriod = 0;
outlinePass.visibleEdgeColor.set(0xbfffd6);
outlinePass.hiddenEdgeColor.set(0x342d25);
composer.addPass(outlinePass);
composer.addPass(new OutputPass());
} catch (error) {
console.error(error);
loading.style.display = 'none';
errorMessage.style.display = 'grid';
throw error;
}
progress(18, 'Mixing noble metals');
const MAT = {
caseDark: new THREE.MeshPhysicalMaterial({ color: 0x1a1f23, metalness: 1, roughness: 0.2, clearcoat: 0.72, clearcoatRoughness: 0.12, envMapIntensity: 1.5 }),
caseBrush: new THREE.MeshPhysicalMaterial({ color: 0x242a2e, metalness: 1, roughness: 0.34, clearcoat: 0.25, envMapIntensity: 1.3 }),
black: new THREE.MeshStandardMaterial({ color: 0x080b0d, metalness: 0.82, roughness: 0.38, envMapIntensity: 0.8 }),
ruthenium: new THREE.MeshPhysicalMaterial({ color: 0x343a3d, metalness: 0.94, roughness: 0.25, clearcoat: 0.35, envMapIntensity: 1.35 }),
rhodium: new THREE.MeshPhysicalMaterial({ color: 0xaeb6b8, metalness: 1, roughness: 0.21, clearcoat: 0.45, clearcoatRoughness: 0.1, envMapIntensity: 1.65 }),
chamfer: new THREE.MeshPhysicalMaterial({ color: 0xe0dbcc, metalness: 1, roughness: 0.09, clearcoat: 0.75, envMapIntensity: 2.1 }),
gold: new THREE.MeshPhysicalMaterial({ color: 0xb68b48, metalness: 1, roughness: 0.2, clearcoat: 0.48, envMapIntensity: 1.65 }),
roseGold: new THREE.MeshPhysicalMaterial({ color: 0xc48a61, metalness: 1, roughness: 0.18, clearcoat: 0.55, envMapIntensity: 1.75 }),
steel: new THREE.MeshPhysicalMaterial({ color: 0x87949b, metalness: 1, roughness: 0.18, clearcoat: 0.4, envMapIntensity: 1.7 }),
blueSteel: new THREE.MeshPhysicalMaterial({ color: 0x12365e, metalness: 1, roughness: 0.16, clearcoat: 0.65, envMapIntensity: 1.6 }),
spring: new THREE.MeshPhysicalMaterial({ color: 0x8ea7b3, metalness: 1, roughness: 0.12, clearcoat: 0.5, envMapIntensity: 1.7 }),
jewel: new THREE.MeshPhysicalMaterial({ color: 0x970d2d, emissive: 0x2b0008, emissiveIntensity: 0.75, metalness: 0.05, roughness: 0.08, transmission: 0.3, thickness: 0.5, ior: 1.76, clearcoat: 1, envMapIntensity: 1.8 }),
lume: new THREE.MeshStandardMaterial({ color: 0xc7ffe0, emissive: 0x7cffaf, emissiveIntensity: 3.2, metalness: 0.05, roughness: 0.35 }),
glass: new THREE.MeshPhysicalMaterial({ color: 0xc8e5ed, metalness: 0, roughness: 0.025, transmission: 1, thickness: 0.45, ior: 1.5, transparent: true, opacity: 0.34, clearcoat: 1, clearcoatRoughness: 0.02, envMapIntensity: 1.7, depthWrite: false, side: THREE.DoubleSide }),
red: new THREE.MeshStandardMaterial({ color: 0x8f0e2a, metalness: 0.35, roughness: 0.27, emissive: 0x180004, emissiveIntensity: 0.6 })
};
const metalMats = Object.values(MAT);
metalMats.forEach((material) => { material.userData.shared = true; });
function finish(mesh, cast = true, receive = true) {
mesh.castShadow = cast;
mesh.receiveShadow = receive;
return mesh;
}
function makeDisc(radius, depth, material, segments = 72) {
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, depth, segments, 1, false), material);
mesh.rotation.x = Math.PI / 2;
return finish(mesh);
}
function makeRing(outer, inner, depth, material, segments = 128) {
const shape = new THREE.Shape();
shape.absarc(0, 0, outer, 0, Math.PI * 2, false);
const hole = new THREE.Path();
hole.absarc(0, 0, inner, 0, Math.PI * 2, true);
shape.holes.push(hole);
const geometry = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: true, bevelSegments: 2, bevelSize: Math.min(depth * 0.16, 0.035), bevelThickness: Math.min(depth * 0.13, 0.028), curveSegments: segments });
geometry.translate(0, 0, -depth / 2);
return finish(new THREE.Mesh(geometry, material));
}
function makeTorus(radius, tube, material, radial = 12, tubular = 96) {
return finish(new THREE.Mesh(new THREE.TorusGeometry(radius, tube, radial, tubular), material));
}
function radialShape(teeth, rootRadius, tipRadius, toothWidth = 0.44) {
const shape = new THREE.Shape();
const points = [];
for (let i = 0; i < teeth; i++) {
const center = (i / teeth) * Math.PI * 2;
const half = Math.PI / teeth;
points.push(
new THREE.Vector2(Math.cos(center - half) * rootRadius, Math.sin(center - half) * rootRadius),
new THREE.Vector2(Math.cos(center - half * toothWidth) * tipRadius, Math.sin(center - half * toothWidth) * tipRadius),
new THREE.Vector2(Math.cos(center + half * toothWidth) * tipRadius, Math.sin(center + half * toothWidth) * tipRadius),
new THREE.Vector2(Math.cos(center + half) * rootRadius, Math.sin(center + half) * rootRadius)
);
}
shape.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) shape.lineTo(points[i].x, points[i].y);
shape.closePath();
return shape;
}
function extrudedShape(shape, depth, material, bevel = 0.018) {
const geometry = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: bevel > 0, bevelSegments: 1, bevelSize: bevel, bevelThickness: bevel, curveSegments: 48 });
geometry.translate(0, 0, -depth / 2);
return finish(new THREE.Mesh(geometry, material));
}
function createGear({ radius, teeth = 48, spokes = 5, depth = 0.12, material = MAT.gold, hubMaterial = MAT.chamfer, inner = 0.22, tooth = 0.075, spokeWidth = 0.07, escape = false }) {
const group = new THREE.Group();
const outerShape = radialShape(teeth, radius - tooth, radius, escape ? 0.22 : 0.5);
const centerHole = new THREE.Path();
centerHole.absarc(0, 0, radius * 0.69, 0, Math.PI * 2, true);
outerShape.holes.push(centerHole);
const rim = extrudedShape(outerShape, depth, material, 0.009);
group.add(rim);
const hub = makeRing(inner + 0.1, inner, depth * 1.25, hubMaterial, 48);
group.add(hub);
const pinion = makeDisc(inner * 0.48, depth * 2.2, MAT.steel, 32);
group.add(pinion);
const spokeLength = radius * 0.69 - (inner + 0.07);
for (let i = 0; i < spokes; i++) {
const angle = (i / spokes) * Math.PI * 2;
const geometry = new RoundedBoxGeometry(spokeLength, spokeWidth, depth * 0.82, 2, Math.min(spokeWidth * 0.45, 0.035));
const spoke = finish(new THREE.Mesh(geometry, material));
spoke.position.set(Math.cos(angle) * (inner + 0.07 + spokeLength / 2), Math.sin(angle) * (inner + 0.07 + spokeLength / 2), 0);
spoke.rotation.z = angle;
group.add(spoke);
}
const pivot = makeDisc(inner * 0.22, depth * 2.8, MAT.chamfer, 24);
pivot.position.z = depth * 0.4;
group.add(pivot);
return group;
}
function createSpiral(turns, innerRadius, outerRadius, z, tubeRadius, material, segments = 260) {
const points = [];
const total = turns * Math.PI * 2;
for (let i = 0; i <= segments; i++) {
const p = i / segments;
const a = p * total;
const r = innerRadius + (outerRadius - innerRadius) * p;
points.push(new THREE.Vector3(Math.cos(a) * r, Math.sin(a) * r, z));
}
const curve = new THREE.CatmullRomCurve3(points);
const mesh = new THREE.Mesh(new THREE.TubeGeometry(curve, segments, tubeRadius, 6, false), material);
mesh.castShadow = false;
return mesh;
}
function createScrew(radius = 0.105, depth = 0.1, material = MAT.blueSteel) {
const group = new THREE.Group();
const head = makeDisc(radius, depth, material, 32);
const slot = new THREE.Mesh(new RoundedBoxGeometry(radius * 1.32, radius * 0.17, depth * 0.12, 1, 0.004), MAT.black);
slot.position.z = depth * 0.52;
group.add(head, slot);
return group;
}
function createJewel(radius = 0.11, depth = 0.07) {
const group = new THREE.Group();
const setting = makeRing(radius * 1.48, radius * 1.07, depth * 0.72, MAT.chamfer, 40);
const gem = makeDisc(radius, depth, MAT.jewel, 36);
gem.position.z = depth * 0.15;
group.add(setting, gem);
return group;
}
function createCapsule(length, width, depth, material) {
return finish(new THREE.Mesh(new RoundedBoxGeometry(length, width, depth, 3, Math.min(width * 0.45, depth * 0.4)), material));
}
function createBridge(points, radius, material, z = 0, tubularSegments = 70) {
const curvePoints = points.map(([x, y]) => new THREE.Vector3(x, y, z));
const curve = new THREE.CatmullRomCurve3(curvePoints);
const mesh = new THREE.Mesh(new THREE.TubeGeometry(curve, tubularSegments, radius, 9, false), material);
return finish(mesh);
}
function createHand(length, width, material, lume = true, counter = 0.48) {
const group = new THREE.Group();
const shape = new THREE.Shape();
shape.moveTo(-width * .36, -counter);
shape.lineTo(width * .36, -counter);
shape.lineTo(width * .52, length * .12);
shape.lineTo(width * .2, length * .78);
shape.lineTo(0, length);
shape.lineTo(-width * .2, length * .78);
shape.lineTo(-width * .52, length * .12);
shape.closePath();
const body = extrudedShape(shape, 0.075, material, 0.012);
group.add(body);
if (lume) {
const insert = createCapsule(width * .3, length * .54, 0.038, MAT.lume);
insert.position.set(0, length * .47, 0.055);
insert.rotation.z = Math.PI / 2;
group.add(insert);
}
return group;
}
function addPart(root, meta, explode = new THREE.Vector3()) {
root.userData.part = meta;
root.userData.anchor = new THREE.Vector3(...(meta.anchor || [0, 0, 0]));
root.userData.home = root.position.clone();
root.userData.explode = explode.clone();
parts.push(root);
partById.set(meta.id, root);
explodables.push(root);
root.traverse((child) => {
if (child.isMesh && child.material !== MAT.glass) {
child.userData.partRoot = root;
pickables.push(child);
}
});
return root;
}
function addExplodable(root, explode) {
root.userData.home = root.position.clone();
root.userData.explode = explode.clone();
explodables.push(root);
return root;
}
function partMeta(id, name, cn, category, description, metrics, anchor = [0, 0, 0]) {
return { id, name, cn, category, description, metrics, anchor };
}
progress(27, 'Machining the case');
presentationRoot = new THREE.Group();
presentationRoot.rotation.set(reducedMotion ? 0.045 : 0.34, reducedMotion ? -0.06 : -0.42, reducedMotion ? -0.01 : 0.06);
presentationRoot.scale.setScalar(reducedMotion ? 1 : 0.84);
scene.add(presentationRoot);
watchRoot = new THREE.Group();
presentationRoot.add(watchRoot);
const backAssembly = new THREE.Group();
backAssembly.position.z = -0.73;
const backRim = makeRing(4.08, 3.46, 0.46, MAT.caseBrush);
const backBevel = makeTorus(3.49, 0.075, MAT.chamfer, 10, 128);
backBevel.position.z = 0.24;
const backCrystal = new THREE.Mesh(new THREE.CircleGeometry(3.43, 128), MAT.glass);
backCrystal.position.z = -0.24;
backCrystal.renderOrder = 0;
backCrystal.castShadow = false;
backAssembly.add(backRim, backBevel, backCrystal);
watchRoot.add(backAssembly);
addExplodable(backAssembly, new THREE.Vector3(0, 0, -1.8));
const midCase = new THREE.Group();
midCase.position.z = -0.08;
const caseRing = makeRing(4.18, 3.55, 0.82, MAT.caseDark);
caseRing.scale.z = 1.08;
midCase.add(caseRing);
const caseHighlight = makeTorus(3.78, 0.33, MAT.caseBrush, 16, 160);
caseHighlight.scale.z = 0.75;
midCase.add(caseHighlight);
const lugPositions = [
[-2.36, 4.18, -0.18], [2.36, 4.18, -0.18],
[-2.36, -4.18, -0.18], [2.36, -4.18, -0.18]
];
lugPositions.forEach(([x, y, z], i) => {
const lug = finish(new THREE.Mesh(new RoundedBoxGeometry(1.18, 1.66, 0.75, 5, 0.18), MAT.caseBrush));
lug.position.set(x, y, z);
lug.rotation.z = (x > 0 ? -1 : 1) * (y > 0 ? 0.11 : -0.11);
midCase.add(lug);
const cap = createCapsule(0.85, 0.16, 0.79, MAT.chamfer);
cap.position.set(x, y + (y > 0 ? 0.18 : -0.18), z + 0.02);
cap.rotation.z = lug.rotation.z;
midCase.add(cap);
});
const strapMat = new THREE.MeshPhysicalMaterial({ color: 0x0b0d0f, roughness: 0.68, metalness: 0.05, clearcoat: 0.18, clearcoatRoughness: 0.7 });
[-1, 1].forEach((sign) => {
const strap = finish(new THREE.Mesh(new RoundedBoxGeometry(4.2, 2.4, 0.48, 8, 0.28), strapMat));
strap.position.set(0, sign * 5.65, -0.25);
strap.rotation.z = sign * 0.015;
midCase.add(strap);
for (let x = -1.3; x <= 1.3; x += 0.26) {
const stitch = createCapsule(0.11, 0.025, 0.495, MAT.caseBrush);
stitch.position.set(x, sign * 5.24, -0.24);
midCase.add(stitch);
}
});
const crownRoot = new THREE.Group();
crownRoot.position.set(4.62, 0, 0.02);
const crown = makeDisc(0.48, 0.62, MAT.caseBrush, 48);
crown.rotation.set(0, 0, Math.PI / 2);
crownRoot.add(crown);
const crownCap = makeDisc(0.33, 0.64, MAT.chamfer, 48);
crownCap.rotation.set(0, 0, Math.PI / 2);
crownRoot.add(crownCap);
for (let i = 0; i < 30; i++) {
const a = i / 30 * Math.PI * 2;
const ridge = createCapsule(0.58, 0.018, 0.06, MAT.chamfer);
ridge.position.set(0, Math.cos(a) * 0.45, Math.sin(a) * 0.45);
ridge.rotation.x = a;
crownRoot.add(ridge);
}
midCase.add(crownRoot);
watchRoot.add(midCase);
addPart(midCase, partMeta('case', 'Titanium Case', '钛金属表壳', 'Architecture', '多层拉丝黑钛中层与手工抛光倒角构成 42 毫米表壳;四枚雕塑式表耳把透明机芯悬置于腕间。', [['Diameter', '42.0 mm'], ['Material', 'Grade 5 Ti'], ['Water resist.', '50 m']], [3.72, 0, 0.25]), new THREE.Vector3(0, 0, -0.65));
const frontAssembly = new THREE.Group();
frontAssembly.position.z = 0.57;
const bezel = makeRing(4.04, 3.59, 0.35, MAT.caseDark);
const bezelEdge = makeTorus(3.76, 0.115, MAT.chamfer, 12, 160);
bezelEdge.position.z = 0.17;
const crystal = new THREE.Mesh(new THREE.CircleGeometry(3.58, 144), MAT.glass);
crystal.position.z = 0.27;
crystal.renderOrder = 20;
crystal.castShadow = false;
const crystalEdge = makeTorus(3.57, 0.038, MAT.glass, 8, 128);
crystalEdge.position.z = 0.29;
crystalEdge.castShadow = false;
frontAssembly.add(bezel, bezelEdge, crystal, crystalEdge);
watchRoot.add(frontAssembly);
frontAssembly.userData.home = frontAssembly.position.clone();
frontAssembly.userData.explode = new THREE.Vector3(0, 0, 2.7);
explodables.push(frontAssembly);
const sapphireProxy = new THREE.Group();
sapphireProxy.position.set(0, 0, 0.3);
const sapphirePickRing = makeRing(3.96, 3.65, 0.08, MAT.caseDark);
sapphirePickRing.visible = false;
sapphireProxy.add(sapphirePickRing);
frontAssembly.add(sapphireProxy);
progress(40, 'Skeletonising the mainplate');
const mainplate = new THREE.Group();
mainplate.position.z = -0.42;
const movementRim = makeRing(3.48, 3.14, 0.2, MAT.ruthenium);
mainplate.add(movementRim);
const innerBevel = makeTorus(3.13, 0.055, MAT.chamfer, 8, 128);
innerBevel.position.z = 0.11;
mainplate.add(innerBevel);
const platePaths = [
[[-3.05, 1.52], [-2.24, 1.84], [-1.35, 1.62], [-0.48, 1.18], [0.2, .72]],
[[-2.8, -1.7], [-2.1, -1.2], [-1.3, -1.0], [-0.55, -.56], [0.1, .08]],
[[2.96, 1.42], [2.34, 1.1], [1.65, .65], [1.22, -.05]],
[[2.85, -1.65], [2.32, -1.05], [1.86, -.46], [1.42, .22]]
];
platePaths.forEach((points, i) => {
const arm = createBridge(points, i % 2 ? 0.17 : 0.2, i % 2 ? MAT.ruthenium : MAT.caseBrush, 0, 62);
mainplate.add(arm);
});
const supportNodes = [
[-3.02,1.52],[-.5,1.17],[-2.78,-1.7],[.08,.08],[2.96,1.42],[1.42,.22],[2.83,-1.65]
];
supportNodes.forEach(([x,y], i) => {
const node = makeDisc(i % 3 === 0 ? .23 : .18, .18, MAT.ruthenium, 40);
node.position.set(x,y,.02);
mainplate.add(node);
});
watchRoot.add(mainplate);
addPart(mainplate, partMeta('mainplate', 'Skeleton Mainplate', '镂空主夹板', 'Foundation', '钌色主夹板仅保留承受扭矩所必需的骨架。手工倒角沿每一条开放边缘捕捉光线。', [['Finish', 'Micro-blast'], ['Bevel', 'Hand polish'], ['Rigidity', '3 bridges']], [0.05, 0.08, 0.1]), new THREE.Vector3(-0.18, -0.12, -0.85));
const chapter = new THREE.Group();
chapter.position.z = 0.5;
const chapterRing = makeRing(3.52, 3.20, 0.12, MAT.black);
chapter.add(chapterRing);
const minuteRail = makeTorus(3.34, 0.018, MAT.chamfer, 6, 128);
minuteRail.position.z = 0.07;
chapter.add(minuteRail);
const markerGeometry = new RoundedBoxGeometry(0.055, 0.2, 0.06, 2, 0.018);
const lumeMarkerGeometry = new RoundedBoxGeometry(0.10, 0.47, 0.075, 3, 0.035);
for (let i = 0; i < 60; i++) {
const a = i / 60 * Math.PI * 2;
const major = i % 5 === 0;
const radius = major ? 3.27 : 3.35;
const marker = finish(new THREE.Mesh(major ? lumeMarkerGeometry : markerGeometry, major ? MAT.lume : MAT.rhodium), false, false);
marker.position.set(Math.sin(a) * radius, Math.cos(a) * radius, major ? 0.11 : 0.08);
marker.rotation.z = -a;
if (major) marker.scale.y = (i % 15 === 0) ? 1.22 : 1;
chapter.add(marker);
}
watchRoot.add(chapter);
addPart(chapter, partMeta('chapter', 'Luminous Chapter Ring', '夜光分钟圈', 'Time display', '十二枚立体 Super-LumiNova 时标悬浮在镂空机芯之上,微光环境下仍保持清晰读时。', [['Lume', 'BGW9'], ['Markers', '60'], ['Glow', 'Blue-green']], [0, 3.28, 0.15]), new THREE.Vector3(0, 0, 1.15));
progress(52, 'Cutting the gear train');
const barrel = new THREE.Group();
barrel.position.set(-1.56, 1.22, -0.08);
const barrelGear = createGear({ radius: 1.08, teeth: 64, spokes: 6, depth: 0.13, material: MAT.gold, inner: .23, tooth: .075, spokeWidth: .085 });
barrel.add(barrelGear);
const barrelWall = makeRing(0.87, 0.78, 0.26, MAT.ruthenium, 96);
barrelWall.position.z = 0.02;
barrel.add(barrelWall);
const mainspring = createSpiral(8.3, 0.13, 0.73, 0.11, 0.018, MAT.spring, 290);
barrel.add(mainspring);
const barrelCross = new THREE.Group();
for (let i=0; i<3; i++) {
const spoke = createCapsule(1.42, .075, .075, MAT.ruthenium);
spoke.rotation.z = i * Math.PI / 3;
spoke.position.z = .15;
barrelCross.add(spoke);
}
barrel.add(barrelCross);
const barrelJewel = createJewel(.105,.08);
barrelJewel.position.z = .23;
barrel.add(barrelJewel);
watchRoot.add(barrel);
addPart(barrel, partMeta('barrel', 'Openworked Barrel', '镂空发条盒', 'Energy source', '发条在开放式鼓轮内储存 72 小时动力;鼓轮每次缓慢转动都会把扭矩送入中心轮。', [['Reserve', '72 hours'], ['Teeth', '64'], ['Spring', 'Nivaflex']], [0, 0, .18]), new THREE.Vector3(-0.38, 0.34, 0.35));
rotors.push({ object: barrelGear, speed: 0.055, phase: 0.1 });
oscillators.mainspring = mainspring;
const train = new THREE.Group();
train.position.z = -0.02;
const gearSpecs = [
{ id: 'centre-wheel', name: 'Centre Wheel', cn: '中心轮', x: -.35, y: .28, r: .76, teeth: 48, spokes: 5, speed: -.11, color: MAT.gold, seq: 1 },
{ id: 'third-wheel', name: 'Third Wheel', cn: '三番轮', x: .72, y: .45, r: .55, teeth: 36, spokes: 5, speed: .147, color: MAT.roseGold, seq: 2 },
{ id: 'fourth-wheel', name: 'Fourth Wheel', cn: '四番轮', x: .42, y: -.62, r: .47, teeth: 30, spokes: 5, speed: -.176, color: MAT.gold, seq: 3 }
];
const gearRoots = [];
gearSpecs.forEach((spec, index) => {
const g = createGear({ radius: spec.r, teeth: spec.teeth, spokes: spec.spokes, depth: .105, material: spec.color, inner: .12, tooth: .052, spokeWidth: .055 });
g.position.set(spec.x, spec.y, index * .055);
train.add(g);
gearRoots.push(g);
rotors.push({ object: g, speed: spec.speed, phase: index * .4 });
const jewel = createJewel(.09,.065);
jewel.position.set(spec.x, spec.y, index * .055 + .13);
train.add(jewel);
});
watchRoot.add(train);
addPart(train, partMeta('gear-train', 'Going Train', '传动齿轮系', 'Transmission', '中心轮、三番轮与四番轮以严格齿数比交替啮合,将发条盒的缓慢扭矩转换为稳定秒脉冲。', [['Ratio', '1 : 60'], ['Wheels', '3 + pinions'], ['Material', 'Brass']], [.2, .15, .1]), new THREE.Vector3(.1, .18, .55));
const winding = new THREE.Group();
winding.position.z = -0.02;
const wind1 = createGear({ radius: .48, teeth: 28, spokes: 4, depth: .11, material: MAT.steel, inner: .11, tooth: .05, spokeWidth: .05 });
wind1.position.set(2.85, .3, 0);
const wind2 = createGear({ radius: .38, teeth: 24, spokes: 4, depth: .11, material: MAT.rhodium, inner: .1, tooth: .045, spokeWidth: .045 });
wind2.position.set(2.2, .72, .04);
const stem = createCapsule(1.72, .10, .10, MAT.steel);
stem.position.set(3.48, 0, -.02);
winding.add(wind1, wind2, stem);
watchRoot.add(winding);
addPart(winding, partMeta('winding', 'Winding Works', '上链机构', 'Control', '表冠驱动精钢离合轮与上链轮,把指尖的旋转传到发条棘轮,并在满链时保护轮系。', [['Actuation', 'Manual'], ['Gears', '2'], ['Steel', 'Tempered']], [2.65, .28, .1]), new THREE.Vector3(.6, .1, .28));
rotors.push({ object: wind1, speed: -.12, phase: 0 }, { object: wind2, speed: .17, phase: .3 });
progress(64, 'Poising the escapement');
const escapement = new THREE.Group();
escapement.position.set(1.45, -0.47, 0.12);
const escapeWheel = createGear({ radius: .49, teeth: 15, spokes: 5, depth: .08, material: MAT.roseGold, hubMaterial: MAT.steel, inner: .09, tooth: .105, spokeWidth: .045, escape: true });
escapeWheel.position.set(0, .1, 0);
escapement.add(escapeWheel);
const forkRoot = new THREE.Group();
forkRoot.position.set(.15, -.56, .09);
const forkStem = createCapsule(.72, .09, .085, MAT.steel);
forkStem.position.y = -.16;
forkStem.rotation.z = Math.PI / 2;
forkRoot.add(forkStem);
const forkArm1 = createCapsule(.42, .085, .085, MAT.steel);
forkArm1.position.set(-.17, .16, 0);
forkArm1.rotation.z = .64;
const forkArm2 = createCapsule(.42, .085, .085, MAT.steel);
forkArm2.position.set(.17, .16, 0);
forkArm2.rotation.z = -.64;
forkRoot.add(forkArm1, forkArm2);
[-1,1].forEach((sign) => {
const pallet = createCapsule(.19, .09, .075, MAT.jewel);
pallet.position.set(sign * .29, .33, .03);
pallet.rotation.z = sign * -.45;
forkRoot.add(pallet);
});
const forkJewel = createJewel(.085,.065);
forkJewel.position.set(0,-.5,.08);
forkRoot.add(forkJewel);
escapement.add(forkRoot);
watchRoot.add(escapement);
addPart(escapement, partMeta('escapement', 'Swiss Lever Escapement', '瑞士杠杆擒纵', 'Regulation', '十五齿擒纵轮以每秒八次的脉冲释放能量;红宝石叉瓦在锁止与冲击之间交替切换。', [['Rate', '8 impulses/s'], ['Pallets', '2 ruby'], ['Wheel', '15 teeth']], [0, -.12, .1]), new THREE.Vector3(.42, -.24, .7));
oscillators.escapeWheel = escapeWheel;
oscillators.fork = forkRoot;
const balance = new THREE.Group();
balance.position.set(1.65, -1.73, 0.05);
const balanceMotion = new THREE.Group();
const balanceRim = makeTorus(1.08, .085, MAT.roseGold, 12, 112);
balanceRim.position.z = .08;
balanceMotion.add(balanceRim);
for (let i=0; i<4; i++) {
const spoke = createCapsule(1.82, .085, .085, MAT.roseGold);
spoke.rotation.z = i * Math.PI / 4;
spoke.position.z = .08;
balanceMotion.add(spoke);
}
for (let i=0; i<12; i++) {
const a = i / 12 * Math.PI * 2;
const weight = makeDisc(.09,.11,MAT.chamfer,24);
weight.position.set(Math.cos(a)*1.06,Math.sin(a)*1.06,.09);
balanceMotion.add(weight);
}
const balanceJewel = createJewel(.13,.09);
balanceJewel.position.z = .18;
balanceMotion.add(balanceJewel);
balance.add(balanceMotion);
const hairspring = createSpiral(9.6, .11, .91, .17, .012, MAT.blueSteel, 320);
balance.add(hairspring);
const balanceBridge = createBridge([[-1.1,.05],[-.62,.56],[.03,.66],[.7,.49],[1.13,.08]], .105, MAT.rhodium, .29, 68);
balance.add(balanceBridge);
const bridgeFootL = makeDisc(.18,.15,MAT.ruthenium,40);
bridgeFootL.position.set(-1.08,.05,.28);
const bridgeFootR = makeDisc(.18,.15,MAT.ruthenium,40);
bridgeFootR.position.set(1.08,.05,.28);
const regulator = createCapsule(.48,.055,.055,MAT.blueSteel);
regulator.position.set(.05,.7,.32);
regulator.rotation.z = -.26;
balance.add(bridgeFootL,bridgeFootR,regulator);
watchRoot.add(balance);
addPart(balance, partMeta('balance', 'Free-sprung Balance', '自由摆轮与游丝', 'Oscillator', '无卡度摆轮以 4 Hz 往复振荡;蓝钢游丝在每次摆动中呼吸,将时间切分为均匀的八个半振频率。', [['Frequency', '4 Hz'], ['Amplitude', '± 270°'], ['Spring', 'Flat spiral']], [0, 0, .2]), new THREE.Vector3(.38, -.4, 1.0));
oscillators.balance = balanceMotion;
oscillators.hairspring = hairspring;
progress(75, 'Setting the jewels');
const upperBridges = new THREE.Group();
upperBridges.position.z = 0.2;
const bridgeCurves = [
{ pts: [[-2.72,2.05],[-1.9,2.48],[-.92,2.15],[-.46,1.52]], r:.15 },
{ pts: [[-.5,1.47],[.08,1.15],[.62,.87],[1.1,.39]], r:.12 },
{ pts: [[2.75,1.65],[2.43,1.15],[2.08,.7],[1.85,.18]], r:.145 },
{ pts: [[-2.72,-2.03],[-2.18,-1.58],[-1.65,-1.36],[-1.15,-1.15]], r:.14 }
];
bridgeCurves.forEach(({pts,r}, i) => {
const bridge = createBridge(pts,r,i === 1 ? MAT.chamfer : MAT.rhodium,0,70);
upperBridges.add(bridge);
[pts[0],pts[pts.length-1]].forEach(([x,y], n) => {
const foot = makeDisc(r*1.5,.15,MAT.rhodium,36);
foot.position.set(x,y,0);
upperBridges.add(foot);
if ((i+n)%2===0) {
const screw=createScrew(.085,.06);
screw.position.set(x,y,.11);
screw.rotation.z=(i*1.1+n*.6);
upperBridges.add(screw);
}
});
});
const bridgeJewels = [[-.48,1.46],[1.09,.39],[-1.15,-1.15],[2.07,.68]];
bridgeJewels.forEach(([x,y]) => {
const jewel=createJewel(.1,.075);
jewel.position.set(x,y,.13);
upperBridges.add(jewel);
});
watchRoot.add(upperBridges);
addPart(upperBridges, partMeta('bridges', 'Rhodium Bridges', '镀铑机芯夹板', 'Architecture', '四条手工修饰夹板跨越轮系,在最少遮挡下固定每一枚枢轴。镜面倒角与细腻喷砂形成冷暖反差。', [['Material', 'German silver'], ['Coating', 'Rhodium'], ['Edges', 'Mirror bevel']], [-.5,1.5,.15]), new THREE.Vector3(-.2,.18,1.0));
const jewelCluster = new THREE.Group();
jewelCluster.position.z = .18;
const jewelPositions = [[-2.65,.1],[-2.28,-.55],[-.82,-1.75],[.1,1.78],[2.55,-.32],[.82,1.86]];
jewelPositions.forEach(([x,y],i) => {
const jewel=createJewel(i%3===0?.115:.09,.07);
jewel.position.set(x,y,0);
jewelCluster.add(jewel);
});
watchRoot.add(jewelCluster);
addPart(jewelCluster, partMeta('jewels', 'Ruby Bearings', '红宝石轴承', 'Friction control', '合成红宝石轴承以极低摩擦支撑高速枢轴,并以金属沉孔精确定位。整枚机芯共使用 41 颗宝石。', [['Jewels', '41'], ['Hardness', '9 Mohs'], ['Setting', 'Pressed']], [-2.65,.1,.05]), new THREE.Vector3(-.16,.06,.72));
const screwCluster = new THREE.Group();
screwCluster.position.z = .23;
const screwPositions = [[-3.02,.86,.2],[3.0,.92,-.1],[-2.92,-.9,.7],[2.88,-1.04,-.4],[-.05,2.94,.35],[.25,-2.95,-.8]];
screwPositions.forEach(([x,y,r]) => {
const screw=createScrew(.09,.07);
screw.position.set(x,y,0);
screw.rotation.z=r;
screwCluster.add(screw);
});
watchRoot.add(screwCluster);
addPart(screwCluster, partMeta('screws', 'Tempered Blue Screws', '火焰蓝钢螺丝', 'Finishing', '精钢螺丝经抛光后以火焰回火呈现深蓝色;每道槽口均以手工倒角并独立校正。', [['Colour', 'Flame blue'], ['Process', 'Heat temper'], ['Finish', 'Polished']], [-3.02,.86,.05]), new THREE.Vector3(.12,.08,.9));
progress(84, 'Mounting luminous hands');
const hands = new THREE.Group();
hands.position.z = .79;
const hourHand = createHand(1.78,.28,MAT.rhodium,true,.38);
const minuteHand = createHand(2.55,.22,MAT.rhodium,true,.48);
minuteHand.position.z = .105;
const secondsShape = new THREE.Shape();
secondsShape.moveTo(-.026,-.72); secondsShape.lineTo(.026,-.72); secondsShape.lineTo(.018,2.82); secondsShape.lineTo(0,3.05); secondsShape.lineTo(-.018,2.82); secondsShape.closePath();
const secondsHand = extrudedShape(secondsShape,.035,MAT.roseGold,.005);
secondsHand.position.z=.2;
const secondsCounter = makeRing(.18,.11,.04,MAT.roseGold,40);
secondsCounter.position.set(0,-.55,.21);
secondsHand.add(secondsCounter);
const handCap = makeDisc(.18,.14,MAT.chamfer,48);
handCap.position.z=.31;
hands.add(hourHand,minuteHand,secondsHand,handCap);
watchRoot.add(hands);
addPart(hands, partMeta('hands', 'Luminous Hands', '镂空夜光指针', 'Time display', '镀铑镂空指针嵌入蓝绿色夜光材料;独立玫瑰金秒针沿外围分钟轨道精确扫过。', [['Display', 'H / M / S'], ['Lume', 'BGW9'], ['Seconds', 'Direct drive']], [0,2.25,.18]), new THREE.Vector3(0,0,1.78));
oscillators.hourHand=hourHand;
oscillators.minuteHand=minuteHand;
oscillators.secondsHand=secondsHand;
const dialLogo = new THREE.Group();
dialLogo.position.set(0,2.64,.7);
const logoArc = makeTorus(.21,.014,MAT.chamfer,6,48);
logoArc.scale.y=.5;
const logoStem=createCapsule(.18,.018,.026,MAT.chamfer);
logoStem.position.y=-.02;
logoStem.rotation.z=Math.PI/2;
dialLogo.add(logoArc,logoStem);
watchRoot.add(dialLogo);
progress(91, 'Lighting the atelier');
const hemi = new THREE.HemisphereLight(0xb7d3e5, 0x100b08, 0.65);
scene.add(hemi);
const keyLight = new THREE.SpotLight(0xffe3bd, 230, 34, Math.PI * .19, .58, 1.4);
keyLight.position.set(-6.5, 8.8, 11);
keyLight.target.position.set(-.4,.2,0);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(isCompact()?1024:2048,isCompact()?1024:2048);
keyLight.shadow.bias=-0.00018;
scene.add(keyLight,keyLight.target);
const rimLight = new THREE.SpotLight(0x8ecbff, 170, 30, Math.PI*.22,.6,1.5);
rimLight.position.set(7,-1,-8);
rimLight.target.position.set(0,0,0);
scene.add(rimLight,rimLight.target);
const goldLight = new THREE.PointLight(0xffb36b, 38, 15, 2);
goldLight.position.set(-4,-5,4);
scene.add(goldLight);
const topStrip = new THREE.RectAreaLight(0xeaf3ff, 18, 7, 1.4);
topStrip.position.set(0,6,7);
topStrip.lookAt(0,0,0);
scene.add(topStrip);
const floor = new THREE.Mesh(new THREE.PlaneGeometry(36,28),new THREE.MeshPhysicalMaterial({color:0x080a0c,metalness:.45,roughness:.21,clearcoat:.55,clearcoatRoughness:.25,transparent:true,opacity:.82}));
floor.rotation.x=-Math.PI/2;
floor.position.y=-5.2;
floor.position.z=-1.2;
floor.receiveShadow=true;
scene.add(floor);
const shadowTextureCanvas=document.createElement('canvas');
shadowTextureCanvas.width=256; shadowTextureCanvas.height=128;
const shadowCtx=shadowTextureCanvas.getContext('2d');
const shadowGrad=shadowCtx.createRadialGradient(128,64,0,128,64,118);
shadowGrad.addColorStop(0,'rgba(0,0,0,.72)'); shadowGrad.addColorStop(.55,'rgba(0,0,0,.25)'); shadowGrad.addColorStop(1,'rgba(0,0,0,0)');
shadowCtx.fillStyle=shadowGrad; shadowCtx.fillRect(0,0,256,128);
const contactShadow=new THREE.Mesh(new THREE.PlaneGeometry(9.5,4.8),new THREE.MeshBasicMaterial({map:new THREE.CanvasTexture(shadowTextureCanvas),transparent:true,depthWrite:false,opacity:.7}));
contactShadow.rotation.x=-Math.PI/2;
contactShadow.position.set(0,-5.16,.1);
scene.add(contactShadow);
progress(96, 'Indexing complications');
const select = $('#part-select');
parts.forEach((part,index) => {
part.userData.part.index=index+1;
const option=document.createElement('option');
option.value=part.userData.part.id;
option.textContent=`${String(index+1).padStart(2,'0')} · ${part.userData.part.cn}`;
select.appendChild(option);
});
const defaultInfo = {
index:0,
name:'Calibre S–01',
cn:'手动上链镂空机芯',
category:'Calibre',
description:'完全开放的双夹板结构,让发条盒、轮系、擒纵和摆轮在同一视野内协同运转。点击任意高亮零件查看其机械职责。',
metrics:[['Frequency','28,800 vph'],['Components','248'],['Finish','Hand bevel']]
};
function updatePanel(part) {
const meta=part ? part.userData.part : defaultInfo;
$('#part-seq').textContent=`${String(meta.index||0).padStart(2,'0')} / ${meta.category}`;
$('#part-name').textContent=meta.name;
$('#part-cn').textContent=meta.cn;
$('#part-description').textContent=meta.description;
meta.metrics.forEach((metric,i)=>{
$(`#metric-label-${i+1}`).textContent=metric[0];
$(`#metric-value-${i+1}`).textContent=metric[1];
});
select.value=part ? meta.id : '';
$('#part-panel').classList.remove('is-minimized');
$('#info-toggle').setAttribute('aria-pressed','true');
}
function setSelected(part) {
state.selected=part;
updatePanel(part);
updateOutline();
if (part) {
$('#active-label .active-name').textContent=part.userData.part.name;
} else {
$('#active-label').style.display='none';
}
}
function updateOutline() {
const target=state.hovered || state.selected;
outlinePass.selectedObjects=target ? [target] : [];
outlinePass.visibleEdgeColor.set(state.hovered ? 0xbfffd6 : 0xd7b47d);
outlinePass.edgeStrength=state.hovered ? 3.5 : 2.6;
}
function setHovered(part) {
if (state.hovered===part) return;
state.hovered=part;
updateOutline();
const label=$('#cursor-label');
if (part) {
label.querySelector('.cursor-kind').textContent=part.userData.part.category;
label.querySelector('.cursor-name').textContent=part.userData.part.name;
label.style.display='block';
renderer.domElement.style.cursor='pointer';
} else {
label.style.display='none';
renderer.domElement.style.cursor=state.dragging?'grabbing':'grab';
}
}
function performRaycast() {
state.pointerDirty=false;
raycaster.setFromCamera(pointer,camera);
const hits=raycaster.intersectObjects(pickables,false);
let part=null;
for (const hit of hits) {
const candidate=hit.object.userData.partRoot;
if (candidate && candidate.visible) { part=candidate; break; }
}
setHovered(part);
}
function updatePointer(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;
state.pointerDirty=true;
const label=$('#cursor-label');
const maxX=window.innerWidth-180;
const maxY=window.innerHeight-72;
label.style.left=`${Math.max(0,Math.min(event.clientX,maxX))}px`;
label.style.top=`${Math.max(0,Math.min(event.clientY,maxY))}px`;
}
renderer.domElement.addEventListener('pointermove',(event)=>{
updatePointer(event);
if (state.pointerDown.distanceTo(new THREE.Vector2(event.clientX,event.clientY))>5) state.dragging=true;
});
renderer.domElement.addEventListener('pointerdown',(event)=>{
state.pointerDown.set(event.clientX,event.clientY);
state.dragging=false;
state.lastInteraction=performance.now();
$('#interaction-hint').classList.add('hidden');
});
renderer.domElement.addEventListener('pointerup',(event)=>{
const moved=state.pointerDown.distanceTo(new THREE.Vector2(event.clientX,event.clientY));
if (moved<5) {
updatePointer(event);
performRaycast();
setSelected(state.hovered);
}
state.dragging=false;
});
renderer.domElement.addEventListener('pointerleave',()=>{
pointer.set(10,10);
state.pointerDirty=false;
state.dragging=false;
setHovered(null);
});
renderer.domElement.addEventListener('dblclick',resetView);
controls.addEventListener('start',()=>{ state.lastInteraction=performance.now(); });
select.addEventListener('change',()=>setSelected(partById.get(select.value)||null));
function toggleExplode(force) {
state.exploded=typeof force==='boolean'?force:!state.exploded;
$('#explode-toggle').setAttribute('aria-pressed',String(state.exploded));
$('#explode-toggle span').textContent=state.exploded?'Assemble':'Exploded';
}
function toggleMotion(force) {
state.motionPaused=typeof force==='boolean'?force:!state.motionPaused;
const button=$('#motion-toggle');
button.setAttribute('aria-pressed',String(state.motionPaused));
button.querySelector('span').textContent=state.motionPaused?'Play':'Pause';
button.querySelector('svg').innerHTML=state.motionPaused?'<path d="m8 5 11 7-11 7Z"/>':'<path d="M8 5v14M16 5v14"/>';
}
function toggleAuto(force) {
state.autoRotate=typeof force==='boolean'?force:!state.autoRotate;
controls.autoRotate=state.autoRotate;
$('#auto-toggle').setAttribute('aria-pressed',String(state.autoRotate));
}
function resetView() {
toggleExplode(false);
state.selected=null;
state.hovered=null;
updatePanel(null);
updateOutline();
camera.position.set(.5,1.15,isCompact()?17.8:15.7);
controls.target.set(0,0,0);
controls.update();
toggleAuto(!reducedMotion);
presentationRoot.rotation.set(.045,-.06,-.01);
presentationRoot.scale.setScalar(1);
}
$('#explode-toggle').addEventListener('click',()=>toggleExplode());
$('#motion-toggle').addEventListener('click',()=>toggleMotion());
$('#auto-toggle').addEventListener('click',()=>toggleAuto());
$('#reset-view').addEventListener('click',resetView);
$('#panel-close').addEventListener('click',()=>{
$('#part-panel').classList.add('is-minimized');
$('#info-toggle').setAttribute('aria-pressed','false');
});
$('#info-toggle').addEventListener('click',()=>{
const panel=$('#part-panel');
const show=panel.classList.contains('is-minimized');
panel.classList.toggle('is-minimized',!show);
$('#info-toggle').setAttribute('aria-pressed',String(show));
});
document.addEventListener('keydown',(event)=>{
if (event.target.matches('select, input, textarea, button')) return;
if (event.key.toLowerCase()==='e') toggleExplode();
if (event.key.toLowerCase()==='r') resetView();
if (event.code==='Space') { event.preventDefault(); toggleMotion(); }
});
document.addEventListener('visibilitychange',()=>{
state.visible=!document.hidden;
if (state.visible) clock.start();
else clock.stop();
});
function onResize() {
const width=stage.clientWidth;
const height=stage.clientHeight;
renderer.setPixelRatio(Math.min(window.devicePixelRatio,isCompact()?1.35:1.8));
renderer.setSize(width,height,false);
composer.setSize(width,height);
camera.aspect=width/height;
camera.updateProjectionMatrix();
bloomPass.strength=isCompact()?.28:.42;
}
new ResizeObserver(onResize).observe(stage);
function updateExplode(delta) {
const target=state.exploded?1:0;
state.explodeFactor=THREE.MathUtils.damp(state.explodeFactor,target,5.4,delta);
const f=state.explodeFactor*state.explodeFactor*(3-2*state.explodeFactor);
explodables.forEach((object)=>{
if (!object.userData.home || !object.userData.explode) return;
object.position.copy(object.userData.home).addScaledVector(object.userData.explode,f);
});
}
let mechanicalTime=0;
function updateMechanics(delta) {
if (!state.motionPaused) mechanicalTime+=delta;
const t=mechanicalTime;
rotors.forEach(({object,speed,phase})=>{ object.rotation.z=phase+t*speed; });
const beat=t*8*Math.PI;
const balanceAngle=Math.sin(beat)*.34;
oscillators.balance.rotation.z=balanceAngle;
oscillators.hairspring.scale.setScalar(1-Math.sin(beat)*.025);
oscillators.fork.rotation.z=-Math.sin(beat)*.12;
const steps=t*8;
const whole=Math.floor(steps);
const advance=Math.min(1,(steps-whole)*5);
const eased=advance*advance*(3-2*advance);
oscillators.escapeWheel.rotation.z=-(whole+eased)*(Math.PI*2/15);
oscillators.mainspring.scale.setScalar(1+Math.sin(t*.55)*.006);
const now=new Date();
const sec=now.getSeconds()+now.getMilliseconds()/1000;
const min=now.getMinutes()+sec/60;
const hour=(now.getHours()%12)+min/60;
oscillators.secondsHand.rotation.z=-sec/60*Math.PI*2;
oscillators.minuteHand.rotation.z=-min/60*Math.PI*2;
oscillators.hourHand.rotation.z=-hour/12*Math.PI*2;
}
function updateIntro(delta) {
if (state.intro>=1) return;
state.intro=Math.min(1,state.intro+delta*.58);
const f=1-Math.pow(1-state.intro,3);
presentationRoot.rotation.x=THREE.MathUtils.lerp(.34,.045,f);
presentationRoot.rotation.y=THREE.MathUtils.lerp(-.42,-.06,f);
presentationRoot.rotation.z=THREE.MathUtils.lerp(.06,-.01,f);
presentationRoot.scale.setScalar(THREE.MathUtils.lerp(.84,1,f));
}
function updateActiveLabel() {
const label=$('#active-label');
if (!state.selected || isCompact()) { label.style.display='none'; return; }
const part=state.selected;
part.localToWorld(tempV3.copy(part.userData.anchor));
tempV3.project(camera);
if (tempV3.z<-1 || tempV3.z>1 || Math.abs(tempV3.x)>1.1 || Math.abs(tempV3.y)>1.1) {
label.style.display='none'; return;
}
const x=(tempV3.x*.5+.5)*stage.clientWidth;
const y=(-tempV3.y*.5+.5)*stage.clientHeight;
label.style.display='flex';
label.style.left=`${x}px`;
label.style.top=`${y}px`;
}
const clock=new THREE.Clock();
let frameCount=0;
function animate() {
requestAnimationFrame(animate);
if (!state.visible) return;
const delta=Math.min(clock.getDelta(),.05);
updateIntro(delta);
updateExplode(delta);
updateMechanics(delta);
if (state.pointerDirty && !state.dragging) performRaycast();
controls.autoRotate=state.autoRotate && performance.now()-state.lastInteraction>2600;
controls.update();
updateActiveLabel();
composer.render();
frameCount++;
}
progress(100, 'Calibre ready');
updatePanel(null);
toggleAuto(state.autoRotate);
animate();
requestAnimationFrame(()=>requestAnimationFrame(()=>{
loading.classList.add('done');
setTimeout(()=>loading.remove(),1100);
}));
</script>
</body>
</html>