New model gpt-5-6-terra (Codex CLI, ultra): mythos-craft, pelican-cycling, skeleton-watch (one-shot, unmodified). gpt-5-6-sol-ultra gains alternate one-shot variants for existing cases: mythos-craft 'Block Era', two more pelican SVGs, and a cleanly completed skeleton-watch 'Calibre 01' kept alongside the interruption-recovered AURELIUS S-01. Extra variants attributed to Nagi-ovo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1609 lines
62 KiB
HTML
1609 lines
62 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<meta name="theme-color" content="#050706">
|
||
<title>Calibre 01 — 镂空机械腕表</title>
|
||
<style>
|
||
:root {
|
||
--ink: #f2f3ec;
|
||
--muted: #949b93;
|
||
--faint: rgba(225, 235, 226, .10);
|
||
--line: rgba(219, 233, 223, .16);
|
||
--panel: rgba(9, 13, 11, .68);
|
||
--accent: #c7ff8b;
|
||
--accent-2: #69f1c5;
|
||
--gold: #d8b878;
|
||
--danger: #ff5a60;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
html, body {
|
||
width: 100%;
|
||
height: 100%;
|
||
margin: 0;
|
||
overflow: hidden;
|
||
background: #050706;
|
||
color: var(--ink);
|
||
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||
}
|
||
|
||
body::before {
|
||
content: "";
|
||
position: fixed;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
z-index: 2;
|
||
background:
|
||
radial-gradient(circle at 50% 42%, transparent 0 20%, rgba(0,0,0,.18) 58%, rgba(0,0,0,.72) 100%),
|
||
linear-gradient(125deg, rgba(86, 122, 103, .07), transparent 35%, rgba(181, 143, 82, .035) 70%, transparent);
|
||
mix-blend-mode: screen;
|
||
}
|
||
|
||
body::after {
|
||
content: "";
|
||
position: fixed;
|
||
inset: -50%;
|
||
z-index: 3;
|
||
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='.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.75'/%3E%3C/svg%3E");
|
||
animation: grain .28s steps(2) infinite;
|
||
}
|
||
|
||
@keyframes grain {
|
||
0% { transform: translate3d(0, 0, 0); }
|
||
25% { transform: translate3d(2%, -1%, 0); }
|
||
50% { transform: translate3d(-1%, 2%, 0); }
|
||
75% { transform: translate3d(1%, 1%, 0); }
|
||
100% { transform: translate3d(-2%, -1%, 0); }
|
||
}
|
||
|
||
button { font: inherit; }
|
||
|
||
#stage {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1;
|
||
}
|
||
|
||
#stage canvas {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
cursor: grab;
|
||
outline: none;
|
||
}
|
||
|
||
#stage canvas:active { cursor: grabbing; }
|
||
|
||
.topbar {
|
||
position: fixed;
|
||
z-index: 10;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 92px;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
padding: 26px 30px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.brand-mark {
|
||
width: 36px;
|
||
height: 36px;
|
||
border: 1px solid rgba(231, 239, 230, .45);
|
||
border-radius: 50%;
|
||
display: grid;
|
||
place-items: center;
|
||
position: relative;
|
||
box-shadow: inset 0 0 0 4px rgba(255,255,255,.025), 0 0 24px rgba(154, 255, 198, .08);
|
||
}
|
||
|
||
.brand-mark::before,
|
||
.brand-mark::after {
|
||
content: "";
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 12px;
|
||
background: var(--accent);
|
||
transform-origin: 50% 100%;
|
||
bottom: 50%;
|
||
box-shadow: 0 0 8px rgba(199,255,139,.8);
|
||
}
|
||
|
||
.brand-mark::before { transform: rotate(42deg); }
|
||
.brand-mark::after { height: 8px; transform: rotate(-58deg); }
|
||
|
||
.brand-title {
|
||
font-family: Georgia, "Times New Roman", serif;
|
||
font-size: 18px;
|
||
letter-spacing: .16em;
|
||
line-height: 1;
|
||
}
|
||
|
||
.brand-sub {
|
||
margin-top: 7px;
|
||
color: var(--muted);
|
||
font-size: 9px;
|
||
letter-spacing: .28em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.scene-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
padding-top: 2px;
|
||
color: var(--muted);
|
||
font-size: 10px;
|
||
letter-spacing: .18em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.live-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--accent-2);
|
||
box-shadow: 0 0 0 4px rgba(105,241,197,.08), 0 0 14px rgba(105,241,197,.65);
|
||
animation: pulse 2s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes pulse { 50% { opacity: .45; transform: scale(.8); } }
|
||
|
||
.chapter {
|
||
position: fixed;
|
||
z-index: 8;
|
||
left: 30px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
writing-mode: vertical-rl;
|
||
color: rgba(220, 228, 220, .36);
|
||
font-size: 9px;
|
||
letter-spacing: .32em;
|
||
text-transform: uppercase;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.chapter::before {
|
||
content: "";
|
||
width: 1px;
|
||
height: 46px;
|
||
background: linear-gradient(transparent, rgba(225,235,226,.42), transparent);
|
||
}
|
||
|
||
.panel {
|
||
position: fixed;
|
||
z-index: 12;
|
||
top: 104px;
|
||
right: 26px;
|
||
width: min(340px, calc(100vw - 52px));
|
||
max-height: calc(100vh - 202px);
|
||
overflow: auto;
|
||
padding: 22px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 18px;
|
||
background: linear-gradient(145deg, rgba(17,22,19,.78), rgba(7,10,9,.56));
|
||
box-shadow: 0 22px 80px rgba(0,0,0,.34), inset 0 1px rgba(255,255,255,.04);
|
||
backdrop-filter: blur(20px) saturate(125%);
|
||
-webkit-backdrop-filter: blur(20px) saturate(125%);
|
||
transition: opacity .35s ease, transform .45s cubic-bezier(.2,.8,.2,1);
|
||
}
|
||
|
||
.panel.closed {
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transform: translateX(24px) scale(.97);
|
||
}
|
||
|
||
.panel-kicker {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
color: var(--accent);
|
||
font-size: 9px;
|
||
letter-spacing: .26em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.panel-close {
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
padding: 0;
|
||
width: 24px;
|
||
height: 24px;
|
||
cursor: pointer;
|
||
font-size: 19px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.panel h1 {
|
||
margin: 20px 0 8px;
|
||
max-width: 270px;
|
||
font-family: Georgia, "Times New Roman", serif;
|
||
font-weight: 400;
|
||
font-size: clamp(24px, 3vw, 34px);
|
||
letter-spacing: -.025em;
|
||
line-height: 1.04;
|
||
}
|
||
|
||
.panel-lead {
|
||
margin: 0;
|
||
color: #b7bdb6;
|
||
font-size: 12px;
|
||
line-height: 1.75;
|
||
}
|
||
|
||
.metric-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 1px;
|
||
margin: 21px 0;
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
background: var(--line);
|
||
}
|
||
|
||
.metric {
|
||
min-height: 68px;
|
||
padding: 12px 10px;
|
||
background: rgba(6,9,7,.76);
|
||
}
|
||
|
||
.metric-value {
|
||
color: var(--ink);
|
||
font-family: Georgia, "Times New Roman", serif;
|
||
font-size: 17px;
|
||
}
|
||
|
||
.metric-label {
|
||
margin-top: 6px;
|
||
color: #737a73;
|
||
font-size: 8px;
|
||
letter-spacing: .12em;
|
||
text-transform: uppercase;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.detail-list {
|
||
border-top: 1px solid var(--line);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.detail-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 11px 0;
|
||
border-bottom: 1px solid rgba(225,235,226,.075);
|
||
font-size: 10px;
|
||
}
|
||
|
||
.detail-row span:first-child { color: #747b74; }
|
||
.detail-row span:last-child { color: #d9ddd7; text-align: right; }
|
||
|
||
.focus-button {
|
||
width: 100%;
|
||
margin-top: 18px;
|
||
padding: 12px 16px;
|
||
border: 1px solid rgba(199,255,139,.27);
|
||
border-radius: 10px;
|
||
color: var(--accent);
|
||
background: linear-gradient(180deg, rgba(199,255,139,.09), rgba(199,255,139,.025));
|
||
font-size: 10px;
|
||
letter-spacing: .14em;
|
||
text-transform: uppercase;
|
||
cursor: pointer;
|
||
transition: background .25s, border-color .25s, transform .25s;
|
||
}
|
||
|
||
.focus-button:hover {
|
||
background: rgba(199,255,139,.13);
|
||
border-color: rgba(199,255,139,.5);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.toolbar-wrap {
|
||
position: fixed;
|
||
z-index: 15;
|
||
left: 50%;
|
||
bottom: 22px;
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 6px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 15px;
|
||
background: rgba(8,12,10,.72);
|
||
box-shadow: 0 16px 54px rgba(0,0,0,.36), inset 0 1px rgba(255,255,255,.04);
|
||
backdrop-filter: blur(18px);
|
||
-webkit-backdrop-filter: blur(18px);
|
||
}
|
||
|
||
.tool-button {
|
||
position: relative;
|
||
height: 42px;
|
||
min-width: 42px;
|
||
padding: 0 12px;
|
||
border: 1px solid transparent;
|
||
border-radius: 10px;
|
||
background: transparent;
|
||
color: #9da49d;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
transition: color .25s, border-color .25s, background .25s;
|
||
}
|
||
|
||
.tool-button svg {
|
||
width: 17px;
|
||
height: 17px;
|
||
fill: none;
|
||
stroke: currentColor;
|
||
stroke-width: 1.5;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.tool-button span {
|
||
font-size: 9px;
|
||
letter-spacing: .12em;
|
||
text-transform: uppercase;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.tool-button:hover,
|
||
.tool-button.active {
|
||
color: var(--accent);
|
||
border-color: rgba(199,255,139,.18);
|
||
background: rgba(199,255,139,.065);
|
||
}
|
||
|
||
.separator { width: 1px; height: 22px; background: var(--line); margin: 0 2px; }
|
||
|
||
.info-toggle {
|
||
width: 46px;
|
||
height: 46px;
|
||
flex: 0 0 auto;
|
||
border: 1px solid var(--line);
|
||
border-radius: 50%;
|
||
color: var(--ink);
|
||
background: rgba(8,12,10,.72);
|
||
cursor: pointer;
|
||
backdrop-filter: blur(16px);
|
||
font-family: Georgia, serif;
|
||
font-style: italic;
|
||
font-size: 16px;
|
||
transition: border-color .25s, color .25s;
|
||
}
|
||
|
||
.info-toggle:hover { color: var(--accent); border-color: rgba(199,255,139,.35); }
|
||
|
||
.hint {
|
||
position: fixed;
|
||
z-index: 8;
|
||
left: 50%;
|
||
bottom: 86px;
|
||
transform: translateX(-50%);
|
||
color: rgba(218,225,217,.44);
|
||
font-size: 9px;
|
||
letter-spacing: .13em;
|
||
white-space: nowrap;
|
||
pointer-events: none;
|
||
transition: opacity .5s;
|
||
}
|
||
|
||
.hint.hide { opacity: 0; }
|
||
|
||
#tooltip {
|
||
position: fixed;
|
||
z-index: 20;
|
||
left: 0;
|
||
top: 0;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transform: translate3d(-100px,-100px,0);
|
||
transition: opacity .15s;
|
||
}
|
||
|
||
#tooltip.visible { opacity: 1; }
|
||
|
||
.tooltip-box {
|
||
position: relative;
|
||
margin: 14px 0 0 14px;
|
||
min-width: 128px;
|
||
padding: 9px 12px 10px;
|
||
border: 1px solid rgba(199,255,139,.28);
|
||
border-radius: 8px;
|
||
background: rgba(7,10,8,.88);
|
||
box-shadow: 0 12px 36px rgba(0,0,0,.35);
|
||
backdrop-filter: blur(12px);
|
||
}
|
||
|
||
.tooltip-box::before {
|
||
content: "";
|
||
position: absolute;
|
||
width: 5px;
|
||
height: 5px;
|
||
top: -18px;
|
||
left: -18px;
|
||
border: 1px solid var(--accent);
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 10px rgba(199,255,139,.7);
|
||
}
|
||
|
||
.tooltip-id {
|
||
color: var(--accent);
|
||
font-size: 8px;
|
||
letter-spacing: .2em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.tooltip-name { margin-top: 4px; color: var(--ink); font-size: 11px; }
|
||
|
||
#hotspots {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 9;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.hotspot {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
color: rgba(227,234,226,.72);
|
||
font-size: 8px;
|
||
letter-spacing: .13em;
|
||
text-transform: uppercase;
|
||
pointer-events: auto;
|
||
cursor: pointer;
|
||
transform: translate(-4px,-4px);
|
||
transition: opacity .2s, color .2s;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.hotspot::before {
|
||
content: "";
|
||
width: 7px;
|
||
height: 7px;
|
||
flex: 0 0 auto;
|
||
border: 1px solid currentColor;
|
||
border-radius: 50%;
|
||
background: rgba(5,7,6,.7);
|
||
box-shadow: 0 0 0 3px rgba(199,255,139,.045);
|
||
}
|
||
|
||
.hotspot::after {
|
||
content: "";
|
||
position: absolute;
|
||
left: 7px;
|
||
top: 3px;
|
||
width: 22px;
|
||
height: 1px;
|
||
background: currentColor;
|
||
opacity: .28;
|
||
}
|
||
|
||
.hotspot span {
|
||
margin-left: 20px;
|
||
padding: 5px 7px;
|
||
border-radius: 5px;
|
||
background: rgba(5,8,6,.54);
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
|
||
.hotspot:hover { color: var(--accent); }
|
||
.hotspot.offscreen { opacity: 0; pointer-events: none; }
|
||
|
||
.loading {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 100;
|
||
display: grid;
|
||
place-items: center;
|
||
background: #050706;
|
||
transition: opacity .8s ease, visibility .8s;
|
||
}
|
||
|
||
.loading.done { opacity: 0; visibility: hidden; }
|
||
|
||
.loader {
|
||
width: min(360px, calc(100vw - 64px));
|
||
text-align: center;
|
||
}
|
||
|
||
.loader-ring {
|
||
position: relative;
|
||
width: 72px;
|
||
height: 72px;
|
||
margin: 0 auto 28px;
|
||
border: 1px solid rgba(225,235,226,.12);
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.loader-ring::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 7px;
|
||
border: 1px solid transparent;
|
||
border-top-color: var(--accent);
|
||
border-right-color: rgba(199,255,139,.24);
|
||
border-radius: 50%;
|
||
animation: spin 1.3s cubic-bezier(.6,.1,.4,.9) infinite;
|
||
}
|
||
|
||
.loader-ring::after {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 23px;
|
||
border: 1px solid rgba(225,235,226,.25);
|
||
border-radius: 50%;
|
||
}
|
||
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
.loader-title {
|
||
font-family: Georgia, serif;
|
||
font-size: 20px;
|
||
letter-spacing: .08em;
|
||
}
|
||
|
||
.loader-copy {
|
||
margin-top: 10px;
|
||
color: #747c74;
|
||
font-size: 9px;
|
||
letter-spacing: .2em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.loader-track {
|
||
height: 1px;
|
||
margin-top: 26px;
|
||
overflow: hidden;
|
||
background: rgba(225,235,226,.1);
|
||
}
|
||
|
||
.loader-track::before {
|
||
content: "";
|
||
display: block;
|
||
width: 42%;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||
animation: load 1.2s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes load { from { transform: translateX(-110%); } to { transform: translateX(330%); } }
|
||
|
||
.noscript {
|
||
position: fixed;
|
||
z-index: 200;
|
||
inset: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 40px;
|
||
color: #ddd;
|
||
background: #050706;
|
||
text-align: center;
|
||
}
|
||
|
||
@media (max-width: 820px) {
|
||
.topbar { height: 76px; padding: 18px; }
|
||
.scene-meta, .chapter { display: none; }
|
||
.brand-title { font-size: 15px; }
|
||
.panel {
|
||
top: auto;
|
||
right: 12px;
|
||
bottom: 82px;
|
||
width: calc(100vw - 24px);
|
||
max-height: min(55vh, 520px);
|
||
padding: 18px;
|
||
}
|
||
.panel.closed { transform: translateY(18px) scale(.98); }
|
||
.toolbar-wrap { bottom: 12px; width: calc(100vw - 24px); justify-content: center; }
|
||
.toolbar { flex: 1; justify-content: space-around; }
|
||
.tool-button { min-width: 38px; height: 40px; padding: 0 8px; }
|
||
.tool-button span { display: none; }
|
||
.info-toggle { width: 44px; height: 44px; }
|
||
.hint { bottom: 70px; font-size: 8px; }
|
||
.hotspot span { display: none; }
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
body::after, .live-dot, .loader-ring::before, .loader-track::before { animation: none !important; }
|
||
* { scroll-behavior: auto !important; }
|
||
}
|
||
</style>
|
||
<script type="importmap">
|
||
{
|
||
"imports": {
|
||
"three": "https://cdn.jsdelivr.net/npm/three@0.166.1/build/three.module.js",
|
||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.166.1/examples/jsm/"
|
||
}
|
||
}
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<noscript><div class="noscript">需要启用 JavaScript 才能查看 3D 机械腕表。</div></noscript>
|
||
|
||
<div id="stage" aria-label="可旋转的 3D 镂空机械腕表"></div>
|
||
|
||
<header class="topbar">
|
||
<div class="brand">
|
||
<div class="brand-mark" aria-hidden="true"></div>
|
||
<div>
|
||
<div class="brand-title">CALIBRE 01</div>
|
||
<div class="brand-sub">Openworked Manufacture</div>
|
||
</div>
|
||
</div>
|
||
<div class="scene-meta"><span class="live-dot"></span><span>机械运转中 · 28,800 vph</span></div>
|
||
</header>
|
||
|
||
<div class="chapter">01 · Anatomy of time</div>
|
||
|
||
<aside class="panel" id="panel" aria-live="polite">
|
||
<div class="panel-kicker">
|
||
<span id="panel-kicker">Manufacture Calibre</span>
|
||
<button class="panel-close" id="panel-close" aria-label="关闭信息面板">×</button>
|
||
</div>
|
||
<h1 id="panel-title">时间的机械建筑</h1>
|
||
<p class="panel-lead" id="panel-lead">由 248 枚零件组成的手动上链镂空机芯。拖动观察,悬停识别零件,点击锁定并探索其功能。</p>
|
||
<div class="metric-grid" id="metric-grid">
|
||
<div class="metric"><div class="metric-value">72 h</div><div class="metric-label">动力储存</div></div>
|
||
<div class="metric"><div class="metric-value">31</div><div class="metric-label">红宝石</div></div>
|
||
<div class="metric"><div class="metric-value">4 Hz</div><div class="metric-label">摆频</div></div>
|
||
</div>
|
||
<div class="detail-list" id="detail-list">
|
||
<div class="detail-row"><span>机芯直径</span><span>34.6 mm</span></div>
|
||
<div class="detail-row"><span>结构</span><span>手动上链 · 镂空</span></div>
|
||
<div class="detail-row"><span>表壳</span><span>钛金属 · 蓝宝石水晶</span></div>
|
||
<div class="detail-row"><span>显示</span><span>时 · 分 · 小秒</span></div>
|
||
</div>
|
||
<button class="focus-button" id="focus-button">聚焦当前部件</button>
|
||
</aside>
|
||
|
||
<div id="tooltip">
|
||
<div class="tooltip-box">
|
||
<div class="tooltip-id" id="tooltip-id">Part 01</div>
|
||
<div class="tooltip-name" id="tooltip-name">部件</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="hotspots" aria-hidden="true"></div>
|
||
<div class="hint" id="hint">拖动旋转 · 滚轮缩放 · 悬停识别 · 点击锁定</div>
|
||
|
||
<div class="toolbar-wrap">
|
||
<div class="toolbar" role="toolbar" aria-label="3D 腕表控制">
|
||
<button class="tool-button active" id="rotate-button" aria-pressed="true" title="自动旋转">
|
||
<svg viewBox="0 0 24 24"><path d="M20 7v5h-5"/><path d="M19 12a7 7 0 1 1-2.05-4.95L20 10"/></svg><span>旋转</span>
|
||
</button>
|
||
<button class="tool-button" id="explode-button" aria-pressed="false" title="爆炸视图">
|
||
<svg viewBox="0 0 24 24"><path d="m12 3 7 4-7 4-7-4 7-4Z"/><path d="m5 12 7 4 7-4"/><path d="m5 17 7 4 7-4"/></svg><span>爆炸</span>
|
||
</button>
|
||
<button class="tool-button" id="label-button" aria-pressed="false" title="部件标签">
|
||
<svg viewBox="0 0 24 24"><path d="M20 13 13 20l-9-9V4h7l9 9Z"/><path d="M8.5 8.5h.01"/></svg><span>标签</span>
|
||
</button>
|
||
<button class="tool-button" id="lume-button" aria-pressed="false" title="夜光模式">
|
||
<svg viewBox="0 0 24 24"><path d="M21 12.8A8.2 8.2 0 1 1 11.2 3 6.4 6.4 0 0 0 21 12.8Z"/></svg><span>夜光</span>
|
||
</button>
|
||
<div class="separator"></div>
|
||
<button class="tool-button" id="reset-button" title="重置视角">
|
||
<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/></svg><span>复位</span>
|
||
</button>
|
||
</div>
|
||
<button class="info-toggle" id="info-toggle" aria-label="显示机芯信息">i</button>
|
||
</div>
|
||
|
||
<div class="loading" id="loading">
|
||
<div class="loader">
|
||
<div class="loader-ring"></div>
|
||
<div class="loader-title">CALIBRE 01</div>
|
||
<div class="loader-copy">正在组装 248 枚精密零件</div>
|
||
<div class="loader-track"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script type="module">
|
||
import * as THREE from 'three';
|
||
import { OrbitControls } from 'three/addons/controls/OrbitControls.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';
|
||
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
||
|
||
const stage = document.getElementById('stage');
|
||
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
const isTouch = matchMedia('(pointer: coarse)').matches;
|
||
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x050706);
|
||
scene.fog = new THREE.FogExp2(0x050706, 0.022);
|
||
|
||
const camera = new THREE.PerspectiveCamera(32, innerWidth / innerHeight, 0.1, 80);
|
||
camera.position.set(0.2, 1.1, isTouch ? 25 : 22);
|
||
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' });
|
||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||
renderer.setSize(innerWidth, innerHeight);
|
||
renderer.shadowMap.enabled = true;
|
||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||
renderer.toneMappingExposure = 1.16;
|
||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||
stage.appendChild(renderer.domElement);
|
||
|
||
const pmrem = new THREE.PMREMGenerator(renderer);
|
||
const room = new RoomEnvironment();
|
||
scene.environment = pmrem.fromScene(room, 0.04).texture;
|
||
room.dispose();
|
||
pmrem.dispose();
|
||
|
||
const composer = new EffectComposer(renderer);
|
||
composer.addPass(new RenderPass(scene, camera));
|
||
const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.52, 0.75, 0.72);
|
||
composer.addPass(bloom);
|
||
composer.addPass(new OutputPass());
|
||
|
||
const controls = new OrbitControls(camera, renderer.domElement);
|
||
controls.enableDamping = true;
|
||
controls.dampingFactor = 0.055;
|
||
controls.minDistance = 12;
|
||
controls.maxDistance = 34;
|
||
controls.minPolarAngle = 0.35;
|
||
controls.maxPolarAngle = Math.PI - 0.35;
|
||
controls.autoRotate = !reduceMotion;
|
||
controls.autoRotateSpeed = 0.42;
|
||
controls.target.set(0, 0, 0.8);
|
||
|
||
const mat = {
|
||
titanium: new THREE.MeshPhysicalMaterial({ color: 0x5f6865, metalness: 1, roughness: 0.22, clearcoat: 0.48, clearcoatRoughness: 0.18 }),
|
||
darkTitanium: new THREE.MeshPhysicalMaterial({ color: 0x151b19, metalness: 0.93, roughness: 0.24, clearcoat: 0.34 }),
|
||
blackMetal: new THREE.MeshStandardMaterial({ color: 0x090c0b, metalness: 0.88, roughness: 0.3 }),
|
||
steel: new THREE.MeshPhysicalMaterial({ color: 0xb9c0ba, metalness: 1, roughness: 0.17, clearcoat: 0.35 }),
|
||
brushedSteel: new THREE.MeshStandardMaterial({ color: 0x89918c, metalness: 1, roughness: 0.38 }),
|
||
gold: new THREE.MeshPhysicalMaterial({ color: 0xb9904c, metalness: 1, roughness: 0.2, clearcoat: 0.45 }),
|
||
roseGold: new THREE.MeshPhysicalMaterial({ color: 0xb27255, metalness: 1, roughness: 0.22, clearcoat: 0.42 }),
|
||
brass: new THREE.MeshPhysicalMaterial({ color: 0x9e7737, metalness: 1, roughness: 0.3, clearcoat: 0.28 }),
|
||
blueSteel: new THREE.MeshPhysicalMaterial({ color: 0x153f75, metalness: 1, roughness: 0.17, clearcoat: 0.62 }),
|
||
ruby: new THREE.MeshPhysicalMaterial({ color: 0x9c061f, emissive: 0x2a0007, emissiveIntensity: 1.4, metalness: 0.05, roughness: 0.12, transmission: 0.22, thickness: 0.32, ior: 2.1, clearcoat: 1 }),
|
||
lume: new THREE.MeshStandardMaterial({ color: 0xc9ff9d, emissive: 0x79ffba, emissiveIntensity: 2.6, roughness: 0.28, metalness: 0.03 }),
|
||
glass: new THREE.MeshPhysicalMaterial({ color: 0xc9fff2, metalness: 0, roughness: 0.035, transmission: 0.96, thickness: 0.28, ior: 1.77, transparent: true, opacity: 0.24, clearcoat: 1, clearcoatRoughness: 0 }),
|
||
smokedGlass: new THREE.MeshPhysicalMaterial({ color: 0x24322d, metalness: 0.05, roughness: 0.2, transmission: 0.6, thickness: 0.38, transparent: true, opacity: 0.5, side: THREE.DoubleSide }),
|
||
strap: new THREE.MeshPhysicalMaterial({ color: 0x080b0a, roughness: 0.66, metalness: 0.05, clearcoat: 0.12, sheen: 0.35, sheenColor: new THREE.Color(0x56645d) })
|
||
};
|
||
|
||
const lights = [];
|
||
const key = new THREE.SpotLight(0xe7fff0, 1050, 45, Math.PI * 0.18, 0.55, 1.2);
|
||
key.position.set(-9, 12, 16);
|
||
key.target.position.set(0, 0, 0);
|
||
key.castShadow = true;
|
||
key.shadow.mapSize.set(2048, 2048);
|
||
key.shadow.bias = -0.00015;
|
||
scene.add(key, key.target); lights.push(key);
|
||
|
||
const warm = new THREE.SpotLight(0xffc987, 760, 40, Math.PI * 0.22, 0.65, 1.4);
|
||
warm.position.set(12, 5, 10);
|
||
warm.target.position.set(0, 0, 0);
|
||
scene.add(warm, warm.target); lights.push(warm);
|
||
|
||
const rim = new THREE.SpotLight(0x77ffd3, 920, 36, Math.PI * 0.2, 0.7, 1.25);
|
||
rim.position.set(-8, -8, 9);
|
||
rim.target.position.set(0, 0, 0);
|
||
scene.add(rim, rim.target); lights.push(rim);
|
||
|
||
const topLight = new THREE.PointLight(0xffffff, 75, 18, 2);
|
||
topLight.position.set(0, 7, 7);
|
||
scene.add(topLight); lights.push(topLight);
|
||
|
||
scene.add(new THREE.HemisphereLight(0x8eafa2, 0x17100b, 0.55));
|
||
|
||
const watch = new THREE.Group();
|
||
watch.rotation.set(-0.08, 0.02, -0.055);
|
||
scene.add(watch);
|
||
|
||
const caseGroup = new THREE.Group();
|
||
const movement = new THREE.Group();
|
||
const dial = new THREE.Group();
|
||
const display = new THREE.Group();
|
||
watch.add(caseGroup, movement, dial, display);
|
||
|
||
const interactives = [];
|
||
const parts = new Map();
|
||
const animatedGears = [];
|
||
const hotspotIds = ['barrel', 'gear-train', 'balance', 'escapement', 'bridges', 'jewels', 'sapphire', 'display'];
|
||
|
||
function cloneInteractiveMaterials(root) {
|
||
root.traverse(child => {
|
||
if (!child.isMesh) return;
|
||
if (Array.isArray(child.material)) child.material = child.material.map(m => m.clone());
|
||
else child.material = child.material.clone();
|
||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||
for (const m of materials) {
|
||
m.userData.baseEmissive = m.emissive ? m.emissive.clone() : null;
|
||
m.userData.baseEmissiveIntensity = m.emissiveIntensity ?? 1;
|
||
}
|
||
});
|
||
}
|
||
|
||
function registerPart(root, data, explode = 0) {
|
||
cloneInteractiveMaterials(root);
|
||
root.userData.basePosition = root.position.clone();
|
||
root.userData.explode = explode;
|
||
root.userData.partId = data.id;
|
||
root.traverse(child => {
|
||
if (child.isMesh) {
|
||
child.userData.partId = data.id;
|
||
interactives.push(child);
|
||
child.castShadow = true;
|
||
child.receiveShadow = true;
|
||
}
|
||
});
|
||
parts.set(data.id, { root, data });
|
||
return root;
|
||
}
|
||
|
||
function roundedRectShape(width, height, radius) {
|
||
const x = -width / 2, y = -height / 2;
|
||
const s = new THREE.Shape();
|
||
s.moveTo(x + radius, y);
|
||
s.lineTo(x + width - radius, y);
|
||
s.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||
s.lineTo(x + width, y + height - radius);
|
||
s.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||
s.lineTo(x + radius, y + height);
|
||
s.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||
s.lineTo(x, y + radius);
|
||
s.quadraticCurveTo(x, y, x + radius, y);
|
||
return s;
|
||
}
|
||
|
||
function extrudedShape(shape, depth, material, bevel = 0.05) {
|
||
const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: true, bevelSegments: 2, bevelSize: bevel, bevelThickness: bevel, curveSegments: 24 });
|
||
geo.center();
|
||
geo.computeVertexNormals();
|
||
return new THREE.Mesh(geo, material);
|
||
}
|
||
|
||
function cylinder(radius, depth, material, segments = 64) {
|
||
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, depth, segments, 1, false), material);
|
||
mesh.rotation.x = Math.PI / 2;
|
||
return mesh;
|
||
}
|
||
|
||
function ring(radius, tube, material, radial = 96) {
|
||
return new THREE.Mesh(new THREE.TorusGeometry(radius, tube, 12, radial), material);
|
||
}
|
||
|
||
function screw(radius = 0.15, depth = 0.12, blue = true) {
|
||
const g = new THREE.Group();
|
||
const head = cylinder(radius, depth, blue ? mat.blueSteel : mat.steel, 32);
|
||
g.add(head);
|
||
const slotMat = new THREE.MeshBasicMaterial({ color: blue ? 0x081325 : 0x262b29 });
|
||
const slot = new THREE.Mesh(new THREE.BoxGeometry(radius * 1.35, radius * .22, depth * .22), slotMat);
|
||
slot.position.z = depth * .55;
|
||
g.add(slot);
|
||
return g;
|
||
}
|
||
|
||
function jewel(radius = 0.18) {
|
||
const g = new THREE.Group();
|
||
const setting = ring(radius * 1.15, radius * .16, mat.gold, 36);
|
||
const stone = cylinder(radius, .11, mat.ruby, 32);
|
||
stone.position.z = .025;
|
||
g.add(setting, stone);
|
||
return g;
|
||
}
|
||
|
||
function gearShape(radius, teeth, toothDepth, holeRadius) {
|
||
const s = new THREE.Shape();
|
||
const count = teeth * 4;
|
||
for (let i = 0; i <= count; i++) {
|
||
const a = (i / count) * Math.PI * 2;
|
||
const phase = i % 4;
|
||
const r = phase === 1 || phase === 2 ? radius + toothDepth : radius;
|
||
const x = Math.cos(a) * r, y = Math.sin(a) * r;
|
||
i === 0 ? s.moveTo(x, y) : s.lineTo(x, y);
|
||
}
|
||
const hole = new THREE.Path();
|
||
hole.absarc(0, 0, holeRadius, 0, Math.PI * 2, true);
|
||
s.holes.push(hole);
|
||
return s;
|
||
}
|
||
|
||
function createGear({ radius, teeth, depth = .16, material = mat.brass, spokes = 5, hub = .2, toothDepth = .11 }) {
|
||
const g = new THREE.Group();
|
||
const wheel = extrudedShape(gearShape(radius, teeth, toothDepth, radius * .29), depth, material, .025);
|
||
g.add(wheel);
|
||
const spokeMaterial = material.clone();
|
||
spokeMaterial.roughness = Math.min(1, (material.roughness || .2) + .08);
|
||
for (let i = 0; i < spokes; i++) {
|
||
const a = i / spokes * Math.PI * 2;
|
||
const len = radius * .66;
|
||
const spoke = new THREE.Mesh(new THREE.BoxGeometry(len, Math.max(.055, radius * .075), depth * .72), spokeMaterial);
|
||
spoke.position.set(Math.cos(a) * radius * .33, Math.sin(a) * radius * .33, 0);
|
||
spoke.rotation.z = a;
|
||
g.add(spoke);
|
||
}
|
||
g.add(cylinder(hub, depth * 1.35, material, 32));
|
||
const pinion = cylinder(hub * .48, depth * 2.8, mat.steel, 24);
|
||
pinion.position.z = depth * .55;
|
||
g.add(pinion);
|
||
return g;
|
||
}
|
||
|
||
function spiralTube(inner, outer, turns, depth, material, thickness = .025, points = 220) {
|
||
const p = [];
|
||
for (let i = 0; i <= points; i++) {
|
||
const t = i / points;
|
||
const a = t * turns * Math.PI * 2;
|
||
const r = inner + (outer - inner) * t;
|
||
p.push(new THREE.Vector3(Math.cos(a) * r, Math.sin(a) * r, depth + Math.sin(t * Math.PI) * .015));
|
||
}
|
||
const curve = new THREE.CatmullRomCurve3(p);
|
||
return new THREE.Mesh(new THREE.TubeGeometry(curve, points, thickness, 5, false), material);
|
||
}
|
||
|
||
function bridge(width, height, angle, x, y, z, material, holes = []) {
|
||
const s = roundedRectShape(width, height, Math.min(height * .42, .24));
|
||
for (const h of holes) {
|
||
const cut = new THREE.Path();
|
||
cut.absarc(h.x, h.y, h.r, 0, Math.PI * 2, true);
|
||
s.holes.push(cut);
|
||
}
|
||
const mesh = extrudedShape(s, .2, material, .055);
|
||
mesh.position.set(x, y, z);
|
||
mesh.rotation.z = angle;
|
||
return mesh;
|
||
}
|
||
|
||
function canvasTexture(draw, width = 1024, height = 256) {
|
||
const c = document.createElement('canvas');
|
||
c.width = width; c.height = height;
|
||
const ctx = c.getContext('2d');
|
||
draw(ctx, width, height);
|
||
const t = new THREE.CanvasTexture(c);
|
||
t.colorSpace = THREE.SRGBColorSpace;
|
||
t.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
||
return t;
|
||
}
|
||
|
||
function addEngraving(text, x, y, z, width = 2.7, rotation = 0) {
|
||
const tex = canvasTexture((ctx, w, h) => {
|
||
ctx.clearRect(0,0,w,h);
|
||
ctx.font = '500 56px Georgia';
|
||
ctx.letterSpacing = '12px';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = 'rgba(233,221,186,.78)';
|
||
ctx.fillText(text, w/2, h/2);
|
||
});
|
||
const plane = new THREE.Mesh(new THREE.PlaneGeometry(width, width / 4), new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthWrite: false, opacity: .82, blending: THREE.AdditiveBlending }));
|
||
plane.position.set(x,y,z);
|
||
plane.rotation.z = rotation;
|
||
movement.add(plane);
|
||
}
|
||
|
||
// Strap and sculpted case
|
||
const strapTop = extrudedShape(roundedRectShape(4.35, 7.8, .72), .62, mat.strap, .11);
|
||
strapTop.position.set(0, 9.15, -1.0);
|
||
strapTop.rotation.x = -.045;
|
||
const strapBottom = strapTop.clone();
|
||
strapBottom.material = mat.strap.clone();
|
||
strapBottom.position.y = -9.15;
|
||
strapBottom.rotation.x = .045;
|
||
caseGroup.add(strapTop, strapBottom);
|
||
|
||
for (const sign of [-1, 1]) {
|
||
for (let i = -1; i <= 1; i++) {
|
||
const stitchTop = new THREE.Mesh(new THREE.BoxGeometry(.34, .035, .018), new THREE.MeshBasicMaterial({ color: 0x4d5550 }));
|
||
stitchTop.position.set(i * 1.25, sign > 0 ? 7.2 + sign * .25 : -7.2 + sign * .25, -.65);
|
||
caseGroup.add(stitchTop);
|
||
}
|
||
}
|
||
|
||
for (const y of [-5.8, 5.8]) {
|
||
for (const x of [-4.35, 4.35]) {
|
||
const lug = extrudedShape(roundedRectShape(1.05, 2.2, .36), .85, mat.titanium, .12);
|
||
lug.position.set(x, y, -.25);
|
||
lug.rotation.z = x * y > 0 ? -.19 : .19;
|
||
caseGroup.add(lug);
|
||
const lugInset = extrudedShape(roundedRectShape(.54, 1.65, .22), .91, mat.blackMetal, .06);
|
||
lugInset.position.copy(lug.position);
|
||
lugInset.position.z += .03;
|
||
lugInset.rotation.z = lug.rotation.z;
|
||
caseGroup.add(lugInset);
|
||
}
|
||
}
|
||
|
||
const caseBack = cylinder(6.28, .64, mat.darkTitanium, 128);
|
||
caseBack.position.z = -.43;
|
||
caseGroup.add(caseBack);
|
||
|
||
const exhibitionBack = cylinder(5.58, .68, mat.smokedGlass, 128);
|
||
exhibitionBack.position.z = -.45;
|
||
caseGroup.add(exhibitionBack);
|
||
|
||
const caseRing = ring(6.22, .52, mat.titanium, 160);
|
||
caseRing.position.z = .35;
|
||
caseGroup.add(caseRing);
|
||
|
||
const bezelOuter = ring(6.18, .23, mat.darkTitanium, 160);
|
||
bezelOuter.position.z = 2.62;
|
||
caseGroup.add(bezelOuter);
|
||
|
||
const bezelLip = ring(5.93, .08, mat.steel, 160);
|
||
bezelLip.position.z = 2.8;
|
||
caseGroup.add(bezelLip);
|
||
|
||
// Crown with knurling and crown guards
|
||
const crown = new THREE.Group();
|
||
crown.position.set(6.8, .12, .45);
|
||
const crownCore = cylinder(.62, .62, mat.titanium, 48);
|
||
crownCore.rotation.set(0,0,Math.PI/2);
|
||
crown.add(crownCore);
|
||
for (let i = 0; i < 24; i++) {
|
||
const a = i / 24 * Math.PI * 2;
|
||
const tooth = new THREE.Mesh(new THREE.BoxGeometry(.6, .045, .12), mat.steel);
|
||
tooth.position.set(0, Math.cos(a) * .61, Math.sin(a) * .61);
|
||
tooth.rotation.x = a;
|
||
crown.add(tooth);
|
||
}
|
||
const crownCap = cylinder(.45, .08, mat.darkTitanium, 48);
|
||
crownCap.rotation.set(0,0,Math.PI/2);
|
||
crownCap.position.x = .34;
|
||
crown.add(crownCap);
|
||
const crownMark = ring(.19, .035, mat.lume, 40);
|
||
crownMark.rotation.y = Math.PI/2;
|
||
crownMark.position.x = .39;
|
||
crown.add(crownMark);
|
||
caseGroup.add(crown);
|
||
|
||
for (const y of [-.78, .95]) {
|
||
const guard = extrudedShape(roundedRectShape(.74, 1.15, .26), .65, mat.titanium, .1);
|
||
guard.position.set(6.26, y, .2);
|
||
guard.rotation.z = .08 * Math.sign(y);
|
||
caseGroup.add(guard);
|
||
}
|
||
|
||
registerPart(caseGroup, {
|
||
id: 'case', code: 'Exterior 01', name: '钛金属表壳',
|
||
lead: '多层喷砂与镜面倒角构成的轻量化表壳,保护机芯并形成鲜明的光影边界。',
|
||
metrics: [['Grade 5','钛金属'],['43 mm','直径'],['10 ATM','防水']],
|
||
details: [['表面','拉丝 · 抛光倒角'],['结构','四体式表壳'],['表冠','螺旋防滑纹'],['重量','约 78 g']]
|
||
}, 0);
|
||
|
||
// Main plate and architectural rings
|
||
const mainPlate = new THREE.Group();
|
||
const plate = cylinder(5.48, .3, mat.smokedGlass, 128);
|
||
plate.position.z = .18;
|
||
mainPlate.add(plate);
|
||
const plateRing = ring(5.14, .14, mat.brushedSteel, 128);
|
||
plateRing.position.z = .36;
|
||
mainPlate.add(plateRing);
|
||
const innerRing = ring(4.15, .055, mat.gold, 128);
|
||
innerRing.position.z = .39;
|
||
mainPlate.add(innerRing);
|
||
for (let r = 1.15; r < 5; r += .64) {
|
||
const groove = ring(r, .009, new THREE.MeshBasicMaterial({ color: 0x6b746e, transparent: true, opacity: .24 }), 96);
|
||
groove.position.z = .38;
|
||
mainPlate.add(groove);
|
||
}
|
||
movement.add(mainPlate);
|
||
|
||
// Mainspring barrel
|
||
const barrel = new THREE.Group();
|
||
barrel.position.set(-2.55, 1.62, .76);
|
||
const barrelTeeth = createGear({ radius: 1.36, teeth: 46, depth: .2, material: mat.brass, spokes: 6, hub: .25, toothDepth: .09 });
|
||
barrel.add(barrelTeeth);
|
||
const barrelWall = ring(1.05, .09, mat.roseGold, 80);
|
||
barrelWall.position.z = .18;
|
||
barrel.add(barrelWall);
|
||
const barrelSpring = spiralTube(.12, .92, 7.2, .23, mat.blueSteel, .028, 260);
|
||
barrel.add(barrelSpring);
|
||
const barrelHub = cylinder(.26, .38, mat.steel, 40);
|
||
barrelHub.position.z = .16;
|
||
barrel.add(barrelHub);
|
||
const barrelScrew = screw(.13,.1,true);
|
||
barrelScrew.position.set(0,0,.39);
|
||
barrel.add(barrelScrew);
|
||
movement.add(registerPart(barrel, {
|
||
id:'barrel', code:'Energy 02', name:'开放式主发条盒',
|
||
lead:'可见的蓝钢主发条缓慢释放储存能量,经由发条盒齿圈传向走时轮系。',
|
||
metrics:[['72 h','动力储存'],['7.2','发条圈数'],['0.11 mm','簧片厚度']],
|
||
details:[['功能','储存与稳定释放能量'],['材质','Nivaflex 合金'],['上链','手动双向'],['安全结构','滑动末端']]
|
||
}, 3.2));
|
||
animatedGears.push({ object: barrelTeeth, speed: .018 });
|
||
|
||
// Gear train, including a deep background wheel for layered parallax
|
||
const gearTrain = new THREE.Group();
|
||
const gearSpecs = [
|
||
{ x:-.35, y:.55, r:1.05, teeth:38, color:mat.gold, speed:-.11, z:.82, spokes:5 },
|
||
{ x:1.62, y:1.45, r:.86, teeth:32, color:mat.brass, speed:.21, z:.76, spokes:5 },
|
||
{ x:2.82, y:-.05, r:.71, teeth:28, color:mat.roseGold, speed:-.36, z:.9, spokes:5 },
|
||
{ x:1.62, y:-1.47, r:.79, teeth:30, color:mat.gold, speed:.5, z:.73, spokes:6 },
|
||
{ x:-.3, y:-1.4, r:.68, teeth:26, color:mat.brass, speed:-.72, z:.62, spokes:5 },
|
||
{ x:3.63, y:1.8, r:.4, teeth:18, color:mat.steel, speed:-.72, z:.65, spokes:4 },
|
||
{ x:-3.65, y:-.5, r:.48, teeth:20, color:mat.steel, speed:.52, z:.6, spokes:4 }
|
||
];
|
||
gearSpecs.forEach((s, index) => {
|
||
const g = createGear({ radius:s.r, teeth:s.teeth, depth:.13 + index % 2 * .03, material:s.color, spokes:s.spokes, hub:s.r*.2, toothDepth:Math.max(.055,s.r*.095) });
|
||
g.position.set(s.x,s.y,s.z);
|
||
g.rotation.z = index * .31;
|
||
gearTrain.add(g);
|
||
animatedGears.push({ object:g, speed:s.speed });
|
||
});
|
||
// Pinion stacks communicate real depth and gearing
|
||
[[-.35,.55],[1.62,1.45],[2.82,-.05],[1.62,-1.47]].forEach(([x,y],i) => {
|
||
const pinion = createGear({ radius:.27, teeth:12, depth:.23, material: i%2 ? mat.steel : mat.blueSteel, spokes:4, hub:.08, toothDepth:.055 });
|
||
pinion.position.set(x,y,1.03);
|
||
gearTrain.add(pinion);
|
||
animatedGears.push({ object:pinion, speed:(i%2?-1:1)*(1.2+i*.3) });
|
||
});
|
||
movement.add(registerPart(gearTrain, {
|
||
id:'gear-train', code:'Transmission 03', name:'五级走时轮系',
|
||
lead:'不同齿数的轮与小齿轴逐级传递扭矩,把发条盒的缓慢转动转换成精准的秒脉冲。',
|
||
metrics:[['5','传动级'],['0.4 μm','齿面公差'],['14 K','金质轮']],
|
||
details:[['组成','中心轮 · 三轮 · 四轮'],['修饰','太阳纹与环形纹'],['轴榫','镜面抛光钢'],['啮合','渐开线齿形']]
|
||
}, 4.2));
|
||
|
||
// Balance wheel and breathing hairspring
|
||
const balance = new THREE.Group();
|
||
balance.position.set(-2.55,-2.66,1.02);
|
||
const balanceWheel = new THREE.Group();
|
||
const balanceRim = ring(1.19,.10,mat.roseGold,96);
|
||
balanceWheel.add(balanceRim);
|
||
for(let i=0;i<3;i++){
|
||
const a=i*Math.PI*2/3;
|
||
const spoke=new THREE.Mesh(new THREE.BoxGeometry(1.07,.085,.11),mat.roseGold);
|
||
spoke.position.set(Math.cos(a)*.53,Math.sin(a)*.53,0);
|
||
spoke.rotation.z=a;
|
||
balanceWheel.add(spoke);
|
||
}
|
||
for(let i=0;i<8;i++){
|
||
const a=i*Math.PI/4;
|
||
const weight=cylinder(.095,.12,mat.gold,24);
|
||
weight.position.set(Math.cos(a)*1.18,Math.sin(a)*1.18,.02);
|
||
balanceWheel.add(weight);
|
||
}
|
||
balanceWheel.add(cylinder(.18,.28,mat.steel,36));
|
||
balance.add(balanceWheel);
|
||
const hairspring = spiralTube(.08,.82,9.2,.09,mat.blueSteel,.018,300);
|
||
balance.add(hairspring);
|
||
const stud = cylinder(.08,.24,mat.gold,20); stud.position.set(.84,.04,.1); balance.add(stud);
|
||
balance.add(jewel(.18));
|
||
movement.add(registerPart(balance, {
|
||
id:'balance', code:'Regulation 04', name:'摆轮与游丝',
|
||
lead:'无卡度摆轮每秒往复八次;蓝钢游丝如同机芯的呼吸,决定腕表的时间基准。',
|
||
metrics:[['4 Hz','振动频率'],['28,800','每小时振次'],['8','微调砝码']],
|
||
details:[['摆轮','可变惯量三臂摆轮'],['游丝','平面式蓝钢合金'],['抗磁','约 15,000 高斯'],['调校','五方位调校']]
|
||
}, 5.5));
|
||
|
||
// Escapement wheel, pallet fork and impulse jewel
|
||
const escapement = new THREE.Group();
|
||
escapement.position.set(.15,-2.78,1.18);
|
||
const escapeWheel = createGear({radius:.58,teeth:20,depth:.11,material:mat.steel,spokes:5,hub:.11,toothDepth:.12});
|
||
escapeWheel.position.x=.47;
|
||
escapement.add(escapeWheel);
|
||
animatedGears.push({object:escapeWheel,speed:1.35,stepped:true});
|
||
const forkShape = new THREE.Shape();
|
||
forkShape.moveTo(-.72,-.16); forkShape.lineTo(-.2,-.08); forkShape.lineTo(.05,-.4);
|
||
forkShape.lineTo(.2,-.32); forkShape.lineTo(.05,.04); forkShape.lineTo(.21,.38);
|
||
forkShape.lineTo(.04,.44); forkShape.lineTo(-.22,.13); forkShape.lineTo(-.72,.2); forkShape.closePath();
|
||
const fork = extrudedShape(forkShape,.12,mat.roseGold,.02);
|
||
fork.position.x=-.3;
|
||
escapement.add(fork);
|
||
const pallet1 = new THREE.Mesh(new THREE.BoxGeometry(.24,.11,.14),mat.ruby); pallet1.position.set(.06,.37,.08); pallet1.rotation.z=-.35;
|
||
const pallet2 = pallet1.clone(); pallet2.material=mat.ruby.clone(); pallet2.position.set(.06,-.35,.08); pallet2.rotation.z=.35;
|
||
escapement.add(pallet1,pallet2);
|
||
const impulse = jewel(.11); impulse.position.set(-.76,0,.1); escapement.add(impulse);
|
||
movement.add(registerPart(escapement, {
|
||
id:'escapement', code:'Impulse 05', name:'瑞士杠杆擒纵',
|
||
lead:'擒纵轮与红宝石叉瓦交替锁止,将连续能量切分为稳定脉冲并维持摆轮振荡。',
|
||
metrics:[['20','擒纵齿'],['2','红宝石叉瓦'],['8 Hz','锁止脉冲']],
|
||
details:[['类型','瑞士杠杆式'],['擒纵轮','镍磷合金'],['叉瓦','合成红宝石'],['润滑','微量定点润滑']]
|
||
}, 6.2));
|
||
|
||
// Skeleton bridges with polished bevels, screws and engraving
|
||
const bridges = new THREE.Group();
|
||
const bridgePieces = [
|
||
bridge(3.3,.62,-.18,1.72,2.66,1.55,mat.roseGold,[{x:1.05,y:0,r:.17},{x:-1.08,y:0,r:.15}]),
|
||
bridge(3.85,.56,.46,.5,-.55,1.65,mat.brushedSteel,[{x:1.5,y:0,r:.16},{x:-1.5,y:0,r:.13}]),
|
||
bridge(2.2,.52,-.52,-3.15,-1.68,1.63,mat.roseGold,[{x:-.76,y:0,r:.14}]),
|
||
bridge(2.45,.45,.82,3.0,.8,1.5,mat.brushedSteel,[{x:.88,y:0,r:.12}])
|
||
];
|
||
bridgePieces.forEach(p=>bridges.add(p));
|
||
const bridgeBosses=[[-.06,2.39],[3.0,2.33],[-3.77,-1.14],[-2.44,-2.2],[1.8,.1],[3.76,1.55]];
|
||
bridgeBosses.forEach(([x,y],i)=>{
|
||
const boss=cylinder(.24,.2,i%2?mat.roseGold:mat.brushedSteel,36); boss.position.set(x,y,1.58); bridges.add(boss);
|
||
const s=screw(.115,.1,i%3!==0); s.position.set(x,y,1.72); bridges.add(s);
|
||
});
|
||
movement.add(registerPart(bridges, {
|
||
id:'bridges', code:'Architecture 06', name:'镂空夹板系统',
|
||
lead:'以最少覆盖支撑整套轮系。手工抛光内角、拉丝表面与镜面倒角在光线下清晰分层。',
|
||
metrics:[['4','独立夹板'],['42','手工倒角'],['0.2 mm','夹板厚度']],
|
||
details:[['材质','钛金属与玫瑰金'],['倒角','手工镜面抛光'],['表面','直纹拉丝'],['固定','热蓝钢螺钉']]
|
||
}, 7.0));
|
||
addEngraving('CAL. 01 · THIRTY-ONE JEWELS',.72,2.7,1.68,2.55,-.18);
|
||
|
||
// Ruby bearing array
|
||
const jewels = new THREE.Group();
|
||
const jewelPositions=[[-.35,.55,1.38],[1.62,1.45,1.32],[2.82,-.05,1.42],[1.62,-1.47,1.29],[-.3,-1.4,1.2],[-2.55,1.62,1.39],[-2.55,-2.66,1.26],[.63,-2.78,1.38],[3.62,1.8,1.1],[-3.64,-.5,1.08]];
|
||
jewelPositions.forEach(([x,y,z],i)=>{const j=jewel(i<8?.14:.11);j.position.set(x,y,z);jewels.add(j);});
|
||
movement.add(registerPart(jewels, {
|
||
id:'jewels', code:'Friction 07', name:'红宝石轴承',
|
||
lead:'合成红宝石以极低摩擦承托高速旋转的轮轴,同时呈现机芯标志性的深红光点。',
|
||
metrics:[['31','宝石总数'],['9 Mohs','硬度'],['0.02','摩擦系数']],
|
||
details:[['材料','单晶氧化铝'],['用途','降低轴榫摩擦'],['安装','金套筒压入'],['颜色','铬离子致色']]
|
||
}, 7.8));
|
||
|
||
// Fine screws and banking pins across the movement
|
||
[[-4.45,2.55],[4.38,2.7],[-4.2,-3.15],[4.3,-2.8],[-.2,4.42],[2.7,3.75],[-3.1,3.75],[3.75,-1.55]].forEach(([x,y],i)=>{
|
||
const s=screw(.12,.1,i%3!==1); s.position.set(x,y,.72); movement.add(s);
|
||
});
|
||
|
||
// Peripheral dial, minute track and luminous applied indices
|
||
const dialRing = new THREE.Mesh(new THREE.RingGeometry(5.35,5.86,128),mat.blackMetal);
|
||
dialRing.position.z=2.16;
|
||
dial.add(dialRing);
|
||
const dialEdge=ring(5.58,.08,mat.roseGold,128);dialEdge.position.z=2.23;dial.add(dialEdge);
|
||
const minuteRail=ring(5.15,.018,new THREE.MeshBasicMaterial({color:0x80928a,transparent:true,opacity:.45}),128);minuteRail.position.z=2.28;dial.add(minuteRail);
|
||
|
||
const indexGroup=new THREE.Group();
|
||
const lumeMeshes=[];
|
||
for(let i=0;i<60;i++){
|
||
const a=i/60*Math.PI*2;
|
||
const major=i%5===0;
|
||
const base=new THREE.Mesh(new THREE.BoxGeometry(major?(i%15===0?.16:.11):.022,major?.58:.16,.09),major?mat.roseGold:mat.brushedSteel);
|
||
base.position.set(Math.sin(a)*(major?5.22:5.34),Math.cos(a)*(major?5.22:5.34),2.3);
|
||
base.rotation.z=-a;
|
||
indexGroup.add(base);
|
||
if(major){
|
||
const lume=new THREE.Mesh(new THREE.BoxGeometry(i%15===0?.09:.055,.39,.055),mat.lume);
|
||
lume.position.set(Math.sin(a)*5.22,Math.cos(a)*5.22,2.37);
|
||
lume.rotation.z=-a;
|
||
indexGroup.add(lume);lumeMeshes.push(lume);
|
||
}
|
||
}
|
||
dial.add(indexGroup);
|
||
|
||
// Cardinal markers with doubled geometry at twelve
|
||
const twelveL=new THREE.Mesh(new THREE.BoxGeometry(.11,.62,.08),mat.lume);twelveL.position.set(-.13,5.17,2.4);
|
||
const twelveR=twelveL.clone();twelveR.material=mat.lume.clone();twelveR.position.x=.13;
|
||
dial.add(twelveL,twelveR);lumeMeshes.push(twelveL,twelveR);
|
||
|
||
// Small seconds register
|
||
const secondsRing=ring(1.08,.035,mat.roseGold,72); secondsRing.position.set(2.55,-2.72,2.42); dial.add(secondsRing);
|
||
for(let i=0;i<30;i++){
|
||
const a=i/30*Math.PI*2;
|
||
const tick=new THREE.Mesh(new THREE.BoxGeometry(i%5===0?.035:.018,i%5===0?.18:.10,.02),new THREE.MeshBasicMaterial({color:i%5===0?0xc5a36b:0x66716b}));
|
||
tick.position.set(2.55+Math.sin(a)*.92,-2.72+Math.cos(a)*.92,2.44);tick.rotation.z=-a;dial.add(tick);
|
||
}
|
||
registerPart(dial, {
|
||
id:'dial', code:'Reading 08', name:'悬浮式分钟轨道',
|
||
lead:'外围分钟圈悬浮于机芯上方,保留完整读时功能,同时不遮挡机械结构。',
|
||
metrics:[['60','分钟刻度'],['12','镶贴时标'],['1','小秒盘']],
|
||
details:[['底材','黑色钛金属'],['刻度','玫瑰金镶贴'],['分钟轨','铁路式'],['结构','外围悬浮']]
|
||
}, 8.8);
|
||
|
||
function handShape(length,width,tail=.36){
|
||
const s=new THREE.Shape();
|
||
s.moveTo(-width*.55,-tail);s.lineTo(-width*.9,.1);s.lineTo(-width*.38,length*.78);
|
||
s.lineTo(0,length);s.lineTo(width*.38,length*.78);s.lineTo(width*.9,.1);s.lineTo(width*.55,-tail);s.closePath();return s;
|
||
}
|
||
function createHand(length,width,angle){
|
||
const g=new THREE.Group();
|
||
const metal=extrudedShape(handShape(length,width),.075,mat.roseGold,.015);
|
||
g.add(metal);
|
||
const lumeShape=handShape(length*.74,width*.31,.05);
|
||
const lume=extrudedShape(lumeShape,.045,mat.lume,.009);
|
||
lume.position.y=length*.15;lume.position.z=.065;g.add(lume);lumeMeshes.push(lume);
|
||
g.rotation.z=angle;return g;
|
||
}
|
||
const hands=new THREE.Group();
|
||
hands.position.z=2.65;
|
||
const hourHand=createHand(2.72,.28,Math.PI/3);
|
||
const minuteHand=createHand(4.02,.21,-Math.PI/3);
|
||
minuteHand.position.z=.13;
|
||
hands.add(hourHand,minuteHand);
|
||
const canonPinion=cylinder(.24,.28,mat.roseGold,40);canonPinion.position.z=.18;hands.add(canonPinion);
|
||
const cap=cylinder(.11,.33,mat.lume,32);cap.position.z=.34;hands.add(cap);lumeMeshes.push(cap);
|
||
const secondsHand=new THREE.Group();secondsHand.position.set(2.55,-2.72,.02);
|
||
const secNeedle=new THREE.Mesh(new THREE.BoxGeometry(.027,1.5,.035),mat.lume);secNeedle.position.y=.46;secondsHand.add(secNeedle);lumeMeshes.push(secNeedle);
|
||
const secCounter=cylinder(.085,.08,mat.roseGold,24);secondsHand.add(secCounter);hands.add(secondsHand);
|
||
display.add(registerPart(hands, {
|
||
id:'display', code:'Luminous 09', name:'夜光镂空指针',
|
||
lead:'镂空玫瑰金指针填充高强度夜光材料,暗光中呈现悬浮于机芯之上的绿色轮廓。',
|
||
metrics:[['10 h','余辉时长'],['1600 K','烧结温度'],['3','显示指针']],
|
||
details:[['夜光材料','长余辉铝酸盐'],['指针','玫瑰金镂空'],['读时','小时 · 分钟 · 小秒'],['预设位置','经典 10:10']]
|
||
}, 10.4));
|
||
|
||
// Sapphire crystal: a thin transmitted disc plus polished edge
|
||
const sapphire=new THREE.Group();
|
||
const crystal=cylinder(5.92,.16,mat.glass,128);crystal.position.z=3.02;sapphire.add(crystal);
|
||
const crystalEdge=ring(5.91,.075,mat.glass,144);crystalEdge.position.z=3.04;sapphire.add(crystalEdge);
|
||
const glareTex=canvasTexture((ctx,w,h)=>{
|
||
const grad=ctx.createLinearGradient(0,0,w,h);
|
||
grad.addColorStop(0,'rgba(255,255,255,0)');grad.addColorStop(.42,'rgba(210,255,241,.05)');
|
||
grad.addColorStop(.5,'rgba(255,255,255,.2)');grad.addColorStop(.57,'rgba(190,244,228,.03)');grad.addColorStop(1,'rgba(255,255,255,0)');
|
||
ctx.fillStyle=grad;ctx.fillRect(0,0,w,h);
|
||
},512,512);
|
||
const glare=new THREE.Mesh(new THREE.CircleGeometry(5.68,96),new THREE.MeshBasicMaterial({map:glareTex,transparent:true,opacity:.34,depthWrite:false,blending:THREE.AdditiveBlending}));
|
||
glare.position.z=3.12;glare.rotation.z=-.32;sapphire.add(glare);
|
||
watch.add(registerPart(sapphire, {
|
||
id:'sapphire', code:'Crystal 10', name:'拱形蓝宝石水晶',
|
||
lead:'高折射率双面防眩蓝宝石形成近乎无形的保护层,并保留边缘冷色反光。',
|
||
metrics:[['1.77','折射率'],['9 Mohs','硬度'],['2','防眩镀层']],
|
||
details:[['材质','合成蓝宝石'],['轮廓','微拱形'],['镀膜','双面防反射'],['透光率','约 99%']]
|
||
}, 13.0));
|
||
|
||
// Ground reflection, architectural halos and restrained floating dust
|
||
const ground=new THREE.Mesh(new THREE.PlaneGeometry(60,60),new THREE.MeshPhysicalMaterial({color:0x050706,metalness:.35,roughness:.34,transparent:true,opacity:.72}));
|
||
ground.rotation.x=-Math.PI/2;ground.position.y=-7.4;ground.receiveShadow=true;scene.add(ground);
|
||
const haloMat=new THREE.MeshBasicMaterial({color:0x4ebf96,transparent:true,opacity:.045,side:THREE.DoubleSide,blending:THREE.AdditiveBlending,depthWrite:false});
|
||
for(let i=0;i<4;i++){
|
||
const halo=new THREE.Mesh(new THREE.TorusGeometry(8.7+i*1.55,.012,6,160),haloMat.clone());
|
||
halo.position.set(-.8+i*.55,.3-i*.2,-2.4-i*.7);halo.rotation.x=.18;scene.add(halo);
|
||
}
|
||
const dustGeo=new THREE.BufferGeometry();
|
||
const dustCount=isTouch?90:180;const dustPos=new Float32Array(dustCount*3);
|
||
for(let i=0;i<dustCount;i++){dustPos[i*3]=(Math.random()-.5)*30;dustPos[i*3+1]=(Math.random()-.5)*22;dustPos[i*3+2]=(Math.random()-.5)*18;}
|
||
dustGeo.setAttribute('position',new THREE.BufferAttribute(dustPos,3));
|
||
const dust=new THREE.Points(dustGeo,new THREE.PointsMaterial({color:0xb8d6ca,size:.018,transparent:true,opacity:.23,depthWrite:false}));scene.add(dust);
|
||
|
||
const raycaster=new THREE.Raycaster();
|
||
const pointer=new THREE.Vector2(-10,-10);
|
||
const tooltip=document.getElementById('tooltip');
|
||
const tooltipId=document.getElementById('tooltip-id');
|
||
const tooltipName=document.getElementById('tooltip-name');
|
||
let hoveredId=null,selectedId=null,pointerDown={x:0,y:0};
|
||
|
||
function setHighlight(id,on,strength=1){
|
||
const entry=parts.get(id);if(!entry)return;
|
||
entry.root.traverse(child=>{
|
||
if(!child.isMesh)return;
|
||
const materials=Array.isArray(child.material)?child.material:[child.material];
|
||
materials.forEach(m=>{
|
||
if(!m.emissive)return;
|
||
const base=m.userData.baseEmissive || new THREE.Color(0);
|
||
m.emissive.copy(base).lerp(new THREE.Color(0x5affb8),on?.38*strength:0);
|
||
m.emissiveIntensity=on?Math.max(m.userData.baseEmissiveIntensity||1,1.45):m.userData.baseEmissiveIntensity||1;
|
||
});
|
||
});
|
||
}
|
||
|
||
function updateHover(id,event){
|
||
if(hoveredId===id){
|
||
if(id)tooltip.style.transform=`translate3d(${event.clientX}px,${event.clientY}px,0)`;
|
||
return;
|
||
}
|
||
if(hoveredId&&hoveredId!==selectedId)setHighlight(hoveredId,false);
|
||
hoveredId=id;
|
||
if(id){
|
||
const data=parts.get(id).data;
|
||
setHighlight(id,true,.75);
|
||
tooltipId.textContent=data.code;
|
||
tooltipName.textContent=data.name;
|
||
tooltip.style.transform=`translate3d(${event.clientX}px,${event.clientY}px,0)`;
|
||
tooltip.classList.add('visible');
|
||
renderer.domElement.style.cursor='pointer';
|
||
}else{
|
||
tooltip.classList.remove('visible');
|
||
renderer.domElement.style.cursor=controls.state===-1?'grab':'grabbing';
|
||
}
|
||
}
|
||
|
||
function updatePanel(id){
|
||
const panel=document.getElementById('panel');
|
||
if(!id){
|
||
document.getElementById('panel-kicker').textContent='Manufacture Calibre';
|
||
document.getElementById('panel-title').textContent='时间的机械建筑';
|
||
document.getElementById('panel-lead').textContent='由 248 枚零件组成的手动上链镂空机芯。拖动观察,悬停识别零件,点击锁定并探索其功能。';
|
||
document.getElementById('metric-grid').innerHTML='<div class="metric"><div class="metric-value">72 h</div><div class="metric-label">动力储存</div></div><div class="metric"><div class="metric-value">31</div><div class="metric-label">红宝石</div></div><div class="metric"><div class="metric-value">4 Hz</div><div class="metric-label">摆频</div></div>';
|
||
document.getElementById('detail-list').innerHTML='<div class="detail-row"><span>机芯直径</span><span>34.6 mm</span></div><div class="detail-row"><span>结构</span><span>手动上链 · 镂空</span></div><div class="detail-row"><span>表壳</span><span>钛金属 · 蓝宝石水晶</span></div><div class="detail-row"><span>显示</span><span>时 · 分 · 小秒</span></div>';
|
||
return;
|
||
}
|
||
const d=parts.get(id).data;
|
||
document.getElementById('panel-kicker').textContent=d.code;
|
||
document.getElementById('panel-title').textContent=d.name;
|
||
document.getElementById('panel-lead').textContent=d.lead;
|
||
document.getElementById('metric-grid').innerHTML=d.metrics.map(m=>`<div class="metric"><div class="metric-value">${m[0]}</div><div class="metric-label">${m[1]}</div></div>`).join('');
|
||
document.getElementById('detail-list').innerHTML=d.details.map(row=>`<div class="detail-row"><span>${row[0]}</span><span>${row[1]}</span></div>`).join('');
|
||
panel.classList.remove('closed');
|
||
}
|
||
|
||
function selectPart(id){
|
||
if(selectedId)setHighlight(selectedId,false);
|
||
selectedId=id;
|
||
if(id)setHighlight(id,true,1);
|
||
updatePanel(id);
|
||
}
|
||
|
||
function hitTest(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;
|
||
raycaster.setFromCamera(pointer,camera);
|
||
const hit=raycaster.intersectObjects(interactives,false)[0];
|
||
return hit?.object.userData.partId || null;
|
||
}
|
||
|
||
renderer.domElement.addEventListener('pointermove',e=>{
|
||
if(isTouch)return;
|
||
updateHover(hitTest(e),e);
|
||
});
|
||
renderer.domElement.addEventListener('pointerleave',e=>updateHover(null,e));
|
||
renderer.domElement.addEventListener('pointerdown',e=>{pointerDown={x:e.clientX,y:e.clientY};document.getElementById('hint').classList.add('hide');});
|
||
renderer.domElement.addEventListener('pointerup',e=>{
|
||
const moved=Math.hypot(e.clientX-pointerDown.x,e.clientY-pointerDown.y);
|
||
if(moved<5)selectPart(hitTest(e));
|
||
});
|
||
|
||
// Persistent hotspot labels
|
||
const hotspotsRoot=document.getElementById('hotspots');
|
||
const hotspotElements=new Map();
|
||
hotspotIds.forEach(id=>{
|
||
const el=document.createElement('button');
|
||
el.className='hotspot';el.type='button';el.dataset.id=id;
|
||
el.innerHTML=`<span>${parts.get(id).data.name}</span>`;
|
||
el.addEventListener('click',()=>selectPart(id));
|
||
hotspotsRoot.appendChild(el);hotspotElements.set(id,el);
|
||
});
|
||
let labelsVisible=false;
|
||
|
||
function updateHotspots(){
|
||
if(!labelsVisible){hotspotsRoot.style.display='none';return;}
|
||
hotspotsRoot.style.display='block';
|
||
const w=innerWidth,h=innerHeight;
|
||
hotspotElements.forEach((el,id)=>{
|
||
const obj=parts.get(id).root;
|
||
const pos=new THREE.Vector3();obj.getWorldPosition(pos);pos.project(camera);
|
||
const visible=pos.z>-1&&pos.z<1&&Math.abs(pos.x)<1.08&&Math.abs(pos.y)<1.08;
|
||
el.classList.toggle('offscreen',!visible);
|
||
el.style.transform=`translate3d(${(pos.x*.5+.5)*w}px,${(-pos.y*.5+.5)*h}px,0) translate(-4px,-4px)`;
|
||
});
|
||
}
|
||
|
||
let explodeTarget=0,explodeProgress=0;
|
||
let lumeMode=false;
|
||
const explodeButton=document.getElementById('explode-button');
|
||
explodeButton.addEventListener('click',()=>{
|
||
explodeTarget=explodeTarget?0:1;
|
||
explodeButton.classList.toggle('active',!!explodeTarget);
|
||
explodeButton.setAttribute('aria-pressed',String(!!explodeTarget));
|
||
if(explodeTarget&&!labelsVisible)document.getElementById('label-button').click();
|
||
});
|
||
|
||
const rotateButton=document.getElementById('rotate-button');
|
||
rotateButton.addEventListener('click',()=>{
|
||
controls.autoRotate=!controls.autoRotate;
|
||
rotateButton.classList.toggle('active',controls.autoRotate);
|
||
rotateButton.setAttribute('aria-pressed',String(controls.autoRotate));
|
||
});
|
||
|
||
const labelButton=document.getElementById('label-button');
|
||
labelButton.addEventListener('click',()=>{
|
||
labelsVisible=!labelsVisible;
|
||
labelButton.classList.toggle('active',labelsVisible);
|
||
labelButton.setAttribute('aria-pressed',String(labelsVisible));
|
||
hotspotsRoot.setAttribute('aria-hidden',String(!labelsVisible));
|
||
});
|
||
|
||
document.getElementById('lume-button').addEventListener('click',e=>{
|
||
lumeMode=!lumeMode;
|
||
e.currentTarget.classList.toggle('active',lumeMode);
|
||
e.currentTarget.setAttribute('aria-pressed',String(lumeMode));
|
||
renderer.toneMappingExposure=lumeMode?.72:1.16;
|
||
bloom.strength=lumeMode?1.24:.52;
|
||
bloom.threshold=lumeMode?.42:.72;
|
||
mat.lume.emissiveIntensity=lumeMode?5.8:2.6;
|
||
lumeMeshes.forEach(mesh=>mesh.material.emissiveIntensity=lumeMode?5.8:2.6);
|
||
lights.forEach((light,i)=>light.intensity*=lumeMode?.55:1/.55);
|
||
});
|
||
|
||
let cameraGoal=null,targetGoal=null;
|
||
function resetView(){
|
||
cameraGoal=new THREE.Vector3(.2,1.1,isTouch?25:22);
|
||
targetGoal=new THREE.Vector3(0,0,.8);
|
||
selectPart(null);
|
||
}
|
||
document.getElementById('reset-button').addEventListener('click',resetView);
|
||
|
||
document.getElementById('focus-button').addEventListener('click',()=>{
|
||
if(!selectedId){cameraGoal=new THREE.Vector3(.2,1.1,17);targetGoal=new THREE.Vector3(0,0,1);return;}
|
||
const pos=new THREE.Vector3();parts.get(selectedId).root.getWorldPosition(pos);
|
||
targetGoal=pos.clone();
|
||
const direction=camera.position.clone().sub(controls.target).normalize();
|
||
cameraGoal=pos.clone().add(direction.multiplyScalar(9.2));
|
||
controls.autoRotate=false;rotateButton.classList.remove('active');rotateButton.setAttribute('aria-pressed','false');
|
||
});
|
||
|
||
const panel=document.getElementById('panel');
|
||
document.getElementById('panel-close').addEventListener('click',()=>panel.classList.add('closed'));
|
||
document.getElementById('info-toggle').addEventListener('click',()=>panel.classList.toggle('closed'));
|
||
|
||
let forkAngle=0;
|
||
const clock=new THREE.Clock();
|
||
function animate(){
|
||
requestAnimationFrame(animate);
|
||
const t=clock.getElapsedTime();
|
||
const dt=Math.min(clock.getDelta(),.05);
|
||
|
||
controls.update();
|
||
if(cameraGoal){
|
||
camera.position.lerp(cameraGoal,.055);
|
||
controls.target.lerp(targetGoal,.065);
|
||
if(camera.position.distanceTo(cameraGoal)<.03){cameraGoal=null;targetGoal=null;}
|
||
}
|
||
|
||
explodeProgress=THREE.MathUtils.damp(explodeProgress,explodeTarget,4.2,Math.max(dt,.016));
|
||
parts.forEach(({root})=>{
|
||
const base=root.userData.basePosition;
|
||
if(!base)return;
|
||
root.position.z=base.z+(root.userData.explode||0)*explodeProgress;
|
||
});
|
||
|
||
if(!reduceMotion){
|
||
animatedGears.forEach((g,i)=>{
|
||
if(g.stepped){
|
||
const steps=20;g.object.rotation.z=Math.floor(t*g.speed*steps)/steps;
|
||
}else g.object.rotation.z+=g.speed*.0026;
|
||
});
|
||
const beat=Math.sin(t*Math.PI*8);
|
||
balanceWheel.rotation.z=beat*.39;
|
||
hairspring.scale.setScalar(1+beat*.032);
|
||
forkAngle=beat*.12;fork.rotation.z=forkAngle;
|
||
secondsHand.rotation.z=-t*Math.PI/30;
|
||
crown.rotation.x=Math.sin(t*.22)*.025;
|
||
dust.rotation.y=t*.006;dust.position.y=Math.sin(t*.14)*.08;
|
||
glare.material.opacity=.24+Math.sin(t*.35)*.08;
|
||
}
|
||
|
||
updateHotspots();
|
||
composer.render();
|
||
}
|
||
|
||
controls.addEventListener('start',()=>document.getElementById('hint').classList.add('hide'));
|
||
window.addEventListener('resize',()=>{
|
||
camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();
|
||
renderer.setPixelRatio(Math.min(devicePixelRatio,2));renderer.setSize(innerWidth,innerHeight);composer.setSize(innerWidth,innerHeight);
|
||
bloom.resolution.set(innerWidth,innerHeight);
|
||
});
|
||
|
||
renderer.compile(scene,camera);
|
||
animate();
|
||
setTimeout(()=>document.getElementById('loading').classList.add('done'),720);
|
||
</script>
|
||
</body>
|
||
</html>
|