1951 lines
72 KiB
HTML
1951 lines
72 KiB
HTML
<!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>
|