Files
nagi-bench/outputs/claude-opus-4-7/claude-code-max/skeleton-watch.html
T
Jesse ZhangandClaude Opus 4.8 3d5bf6967c Normalize artifact filenames and register new runs (#17)
Rename all output artifacts to their canonical case-id names
(mythos-craft.html / pelican-cycling.svg / skeleton-watch.html, with
-2 suffixes for variants) and drop the now-redundant "file" overrides
from the corresponding models/*.json runs.

Register new Claude Code runs for Opus 4.6 and 4.7 (new model JSONs),
add the GPT-5.5 Pro skeleton-watch run, and regenerate the README
registry tables. Remove stray .DS_Store files from outputs/.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:36:38 +01:00

969 lines
47 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Cal. 1894 — Skeleton Mechanical Watch</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#06070a;color:#e8e8e8;font-family:'Helvetica Neue',Inter,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
#app{position:fixed;inset:0}
canvas{display:block}
.vignette{position:fixed;inset:0;pointer-events:none;z-index:2;background:radial-gradient(ellipse at 50% 45%,transparent 35%,rgba(0,0,0,.65) 100%)}
.grain{position:fixed;inset:0;pointer-events:none;z-index:3;opacity:.05;mix-blend-mode:overlay;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>")}
.ui{position:fixed;z-index:10;pointer-events:none}
.ui>*{pointer-events:auto}
.brand{top:30px;left:34px;font-family:'Times New Roman',serif;letter-spacing:.45em;font-size:10px;color:#7a7a7a;text-transform:uppercase}
.brand .crest{width:34px;height:1px;background:linear-gradient(90deg,transparent,#b48a4c,transparent);margin:14px 0 12px}
.brand .ttl{font-size:22px;letter-spacing:.32em;color:#ececec;font-weight:200;margin-top:2px}
.brand .sub{margin-top:8px;font-size:9px;color:#555;letter-spacing:.55em}
.info{top:30px;right:32px;width:300px;background:rgba(14,15,18,.72);backdrop-filter:blur(20px) saturate(1.1);-webkit-backdrop-filter:blur(20px) saturate(1.1);border:1px solid rgba(255,255,255,.06);padding:24px 26px;border-radius:2px;box-shadow:0 30px 60px rgba(0,0,0,.5),inset 0 1px 0 rgba(255,255,255,.04)}
.info h2{font-size:10px;letter-spacing:.42em;color:#888;text-transform:uppercase;font-weight:300;margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid rgba(180,140,80,.18);display:flex;justify-content:space-between;align-items:center}
.info h2 .mark{color:#c8a05a;font-family:'Times New Roman',serif;letter-spacing:.2em;font-size:9px}
.info .row{display:flex;justify-content:space-between;padding:6px 0;font-size:11.5px}
.info .lbl{color:#666;letter-spacing:.12em;text-transform:uppercase;font-size:10px}
.info .val{color:#d4d4d4;font-family:'Courier New',monospace;letter-spacing:.05em}
.controls{bottom:34px;left:34px;display:flex;gap:8px;flex-wrap:wrap;max-width:520px}
.btn{background:rgba(20,22,26,.72);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);color:#c8c8c8;border:1px solid rgba(255,255,255,.08);padding:11px 18px;cursor:pointer;font-size:9.5px;letter-spacing:.3em;text-transform:uppercase;transition:.28s cubic-bezier(.4,0,.2,1);border-radius:2px;font-family:inherit;position:relative;overflow:hidden}
.btn::before{content:'';position:absolute;inset:0;background:linear-gradient(135deg,transparent 40%,rgba(200,160,90,.08) 50%,transparent 60%);transform:translateX(-100%);transition:transform .6s}
.btn:hover{background:rgba(34,36,42,.85);color:#fff;border-color:rgba(255,255,255,.18);transform:translateY(-1px)}
.btn:hover::before{transform:translateX(100%)}
.btn.active{background:rgba(180,140,80,.16);border-color:rgba(200,160,90,.5);color:#e8c98c;box-shadow:0 0 22px rgba(200,160,90,.15)}
.part-panel{bottom:34px;right:32px;width:310px;background:rgba(14,15,18,.82);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border:1px solid rgba(200,160,90,.22);padding:22px 26px;border-radius:2px;opacity:0;transform:translateY(10px);transition:.35s cubic-bezier(.4,0,.2,1);pointer-events:none;box-shadow:0 25px 60px rgba(0,0,0,.55)}
.part-panel.show{opacity:1;transform:translateY(0);pointer-events:auto}
.part-panel .pcat{font-size:9px;letter-spacing:.45em;color:#c8a05a;margin-bottom:6px;text-transform:uppercase}
.part-panel .pname{font-size:18px;letter-spacing:.18em;color:#f0f0f0;text-transform:uppercase;margin-bottom:14px;font-weight:200;padding-bottom:12px;border-bottom:1px solid rgba(255,255,255,.07)}
.part-panel .pdesc{font-size:12.5px;line-height:1.75;color:#b4b4b4;font-weight:300}
.part-panel .pspec{margin-top:14px;display:grid;grid-template-columns:1fr 1fr;gap:6px 14px;font-size:10px}
.part-panel .pspec span:nth-child(odd){color:#666;letter-spacing:.1em;text-transform:uppercase}
.part-panel .pspec span:nth-child(even){color:#c5c5c5;font-family:'Courier New',monospace}
.hint{position:fixed;left:50%;bottom:12vh;transform:translateX(-50%);color:#3d3d3d;font-size:9.5px;letter-spacing:.55em;text-transform:uppercase;pointer-events:none;z-index:5;text-align:center;transition:opacity .8s;white-space:nowrap}
.tooltip{position:fixed;background:rgba(8,9,12,.94);border:1px solid rgba(200,160,90,.4);color:#e8c98c;padding:6px 13px;font-size:9.5px;letter-spacing:.28em;text-transform:uppercase;pointer-events:none;border-radius:1px;opacity:0;transition:opacity .15s,transform .15s;z-index:20;white-space:nowrap;font-weight:400;transform:translate(-50%,-100%) translateY(-12px)}
.tooltip.show{opacity:1;transform:translate(-50%,-100%) translateY(-18px)}
.tooltip::after{content:'';position:absolute;left:50%;bottom:-5px;transform:translateX(-50%) rotate(45deg);width:6px;height:6px;background:rgba(8,9,12,.94);border-right:1px solid rgba(200,160,90,.4);border-bottom:1px solid rgba(200,160,90,.4)}
.labels{position:fixed;inset:0;pointer-events:none;z-index:6}
.label{position:absolute;display:flex;align-items:center;font-size:9.5px;letter-spacing:.3em;color:#9c9c9c;text-transform:uppercase;white-space:nowrap;transition:opacity .35s;opacity:0;font-weight:300}
.label.show{opacity:1}
.label .ln{width:60px;height:1px;background:linear-gradient(90deg,rgba(200,160,90,.7),rgba(200,160,90,.1));margin-right:10px;position:relative}
.label .ln::before{content:'';position:absolute;left:0;top:50%;transform:translateY(-50%);width:5px;height:5px;border-radius:50%;background:#c8a05a;box-shadow:0 0 8px #c8a05a}
.label.right{flex-direction:row-reverse}
.label.right .ln{margin-right:0;margin-left:10px;background:linear-gradient(-90deg,rgba(200,160,90,.7),rgba(200,160,90,.1))}
.label.right .ln::before{left:auto;right:0}
.corners{position:fixed;inset:24px;pointer-events:none;z-index:4}
.corners span{position:absolute;width:18px;height:18px;border:1px solid rgba(200,160,90,.22)}
.corners span:nth-child(1){top:0;left:0;border-right:0;border-bottom:0}
.corners span:nth-child(2){top:0;right:0;border-left:0;border-bottom:0}
.corners span:nth-child(3){bottom:0;left:0;border-right:0;border-top:0}
.corners span:nth-child(4){bottom:0;right:0;border-left:0;border-top:0}
.loading{position:fixed;inset:0;background:#06070a;display:flex;align-items:center;justify-content:center;flex-direction:column;z-index:100;transition:opacity 1s}
.loading.hide{opacity:0;pointer-events:none}
.loading .lt{font-size:10px;letter-spacing:.55em;color:#888;margin-bottom:24px;text-transform:uppercase}
.loading .lb{width:200px;height:1px;background:rgba(255,255,255,.07);position:relative;overflow:hidden}
.loading .lb::after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,transparent,#c8a05a,transparent);animation:sw 1.5s infinite}
@keyframes sw{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}
</style>
</head>
<body>
<div id="app"></div>
<div class="vignette"></div>
<div class="grain"></div>
<div class="corners"><span></span><span></span><span></span><span></span></div>
<div class="loading" id="loading"><div class="lt">Calibrating Movement</div><div class="lb"></div></div>
<div class="ui brand">
<div>Maison Horologie</div>
<div class="crest"></div>
<div class="ttl">SKELETON · CAL. 1894</div>
<div class="sub">GENÈVE · SUISSE</div>
</div>
<div class="ui info">
<h2><span>Specification</span><span class="mark">N° 0027 / 88</span></h2>
<div class="row"><span class="lbl">Caliber</span><span class="val">1894-SK</span></div>
<div class="row"><span class="lbl">Movement</span><span class="val">Manual Wind</span></div>
<div class="row"><span class="lbl">Frequency</span><span class="val">28,800 vph</span></div>
<div class="row"><span class="lbl">Jewels</span><span class="val">29 Rubies</span></div>
<div class="row"><span class="lbl">Reserve</span><span class="val">72 Hours</span></div>
<div class="row"><span class="lbl">Case</span><span class="val">904L Steel</span></div>
<div class="row"><span class="lbl">Crystal</span><span class="val">Sapphire AR</span></div>
<div class="row"><span class="lbl">Diameter</span><span class="val">41.0 mm</span></div>
<div class="row"><span class="lbl">Finishing</span><span class="val">Côtes / Anglage</span></div>
</div>
<div class="ui controls">
<button class="btn active" id="btn-spin">Auto Rotate</button>
<button class="btn" id="btn-explode">Exploded View</button>
<button class="btn" id="btn-labels">Labels</button>
<button class="btn" id="btn-lume">Lume</button>
<button class="btn" id="btn-reset">Reset</button>
</div>
<div class="part-panel" id="partPanel">
<div class="pcat" id="pCat">Component</div>
<div class="pname" id="pName">—</div>
<div class="pdesc" id="pDesc"></div>
<div class="pspec" id="pSpec"></div>
</div>
<div class="labels" id="labelLayer"></div>
<div class="tooltip" id="tooltip"></div>
<div class="hint" id="hint">Drag to rotate · Scroll to zoom · Click parts</div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
const META = {
case:{cat:'Exterior',name:'Case',desc:'Mirror-polished 904L stainless steel mid-case with vertically brushed flanks. Hand-finished to a 40 µm tolerance over 24 hours of polishing.',spec:[['Material','904L Steel'],['Finish','Mirror Polished'],['Diameter','41.0 mm'],['Thickness','11.2 mm']]},
crystal:{cat:'Exterior',name:'Sapphire Crystal',desc:'Domed monocrystalline sapphire with a seven-layer anti-reflective coating on both surfaces. Hardness 9 on the Mohs scale, second only to diamond.',spec:[['Material','Sapphire'],['IOR','1.770'],['Coating','7-Layer AR'],['Hardness','Mohs 9']]},
bezel:{cat:'Exterior',name:'Polished Bezel',desc:'Fixed polished bezel with internal step. A diamond-cut chamfer at the inner edge catches the light along the dial perimeter.',spec:[['Profile','Stepped'],['Finish','Mirror']]},
caseback:{cat:'Exterior',name:'Caseback Ring',desc:'Screw-down caseback ring with sapphire display window, allowing a full view of the movement from both sides of the watch.',spec:[['Type','Screw-down'],['Seal','Triple Gasket']]},
crown:{cat:'Exterior',name:'Onion Crown',desc:'Fluted onion-style crown with embossed maison crest. Triple-gasket sealed and screw-locked for 100 m water resistance.',spec:[['Profile','Fluted Onion'],['Gaskets','3']]},
lug:{cat:'Exterior',name:'Strap Lugs',desc:'Integrated polished lugs with curved underside contour to match the wrist. Drilled through-holes for tool-free strap exchange.',spec:[['Span','20 mm'],['Spring Bar','1.8 mm']]},
mainspring:{cat:'Power',name:'Mainspring Barrel',desc:'Houses a 1,460 mm coiled flat spring storing 72 hours of energy. Releases torque linearly via a Geneva-stop mechanism.',spec:[['Coils','11.5'],['Reserve','72 h'],['Torque','9.4 g·cm']]},
centerWheel:{cat:'Gear Train',name:'Center Wheel',desc:'The second wheel after the barrel, mounted at the heart of the movement. Drives the minute hand at one rotation per hour.',spec:[['Teeth','64'],['Period','60 min'],['Pivots','2 Rubies']]},
thirdWheel:{cat:'Gear Train',name:'Third Wheel',desc:'Intermediate transmission wheel that steps down torque between the center wheel and the fourth wheel of the gear train.',spec:[['Teeth','60'],['Pinion','10 leaves']]},
fourthWheel:{cat:'Gear Train',name:'Fourth Wheel',desc:'Penultimate wheel before the escape wheel. Carries the small seconds hand at six o\'clock — one rotation per minute.',spec:[['Teeth','56'],['Period','60 s']]},
escapeWheel:{cat:'Escapement',name:'Escape Wheel',desc:'15-tooth lever-escapement wheel released one tooth at a time by the pallet fork. Each release is one tick of the watch.',spec:[['Teeth','15'],['Beats','8/s']]},
pallet:{cat:'Escapement',name:'Pallet Fork',desc:'Lever with two ruby pallet stones (entry and exit) that lock and release the escape wheel. The audible "tick-tock" originates here.',spec:[['Stones','2 Rubies'],['Travel','12°']]},
balance:{cat:'Regulator',name:'Balance Wheel',desc:'Glucydur free-sprung balance with four gold mean-time regulating screws. Oscillates at 4 Hz — the heart of the watch.',spec:[['Frequency','4 Hz'],['Inertia','11 mg·cm²'],['Material','Glucydur']]},
hairspring:{cat:'Regulator',name:'Hairspring',desc:'Phillips terminal-curve Nivarox-1 hairspring with Breguet overcoil. Returns the balance to neutral after each oscillation.',spec:[['Material','Nivarox-1'],['Coils','13']]},
bridge:{cat:'Architecture',name:'Skeleton Bridge',desc:'German silver bridge hand-engraved and beveled (anglage). Each interior angle is hand-polished to a 0.05 mm chamfer over six hours of work.',spec:[['Material','German Silver'],['Finish','Anglage']]},
jewel:{cat:'Bearings',name:'Ruby Jewel',desc:'Synthetic ruby pivot bearing. Reduces friction at high-rotation pivots to virtually nothing — a horological tradition since Fatio in 1704.',spec:[['Material','Synthetic Ruby'],['Count','29 total']]},
screw:{cat:'Hardware',name:'Blued Screw',desc:'Hand-heated steel screw, polished and chamfered. Heat-blued at 290 °C to a precise cornflower-blue tint.',spec:[['Treatment','290 °C Blued'],['Slot','Polished']]},
hands:{cat:'Display',name:'Skeleton Hands',desc:'Diamond-polished steel hands with cutaway skeleton design. Filled with Super-LumiNova X1 for night legibility up to 8 hours.',spec:[['Material','Steel'],['Lume','SLN X1']]},
indices:{cat:'Display',name:'Applied Indices',desc:'Solid steel indices machined from bar stock and individually polished. Each carries a luminescent SLN inlay.',spec:[['Count','12'],['Profile','Faceted']]},
mainPlate:{cat:'Architecture',name:'Main Plate',desc:'Base plate of the movement, hand-finished with perlage — a circular grain applied by spinning peg-wood at every pivot site.',spec:[['Material','German Silver'],['Finish','Perlage']]},
};
const APP = document.getElementById('app');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x06070a);
scene.fog = new THREE.Fog(0x06070a, 22, 55);
const camera = new THREE.PerspectiveCamera(34, innerWidth/innerHeight, 0.1, 100);
camera.position.set(2, 6.5, 11);
const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:'high-performance' });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
renderer.outputColorSpace = THREE.SRGBColorSpace;
APP.appendChild(renderer.domElement);
const pmrem = new THREE.PMREMGenerator(renderer);
pmrem.compileEquirectangularShader();
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.035).texture;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.07;
controls.minDistance = 5;
controls.maxDistance = 22;
controls.minPolarAngle = 0.05;
controls.maxPolarAngle = Math.PI * 0.52;
controls.target.set(0, 0.2, 0);
controls.autoRotate = true;
controls.autoRotateSpeed = 0.35;
const ambientLight = new THREE.AmbientLight(0x445566, 0.22);
scene.add(ambientLight);
const keyLight = new THREE.SpotLight(0xfff2d8, 14, 30, Math.PI/5.5, 0.55, 1.6);
keyLight.position.set(6.5, 11, 6);
keyLight.target.position.set(0, 0, 0); scene.add(keyLight.target);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(2048, 2048);
keyLight.shadow.bias = -0.00012;
keyLight.shadow.normalBias = 0.012;
keyLight.shadow.radius = 7;
keyLight.shadow.camera.near = 1;
keyLight.shadow.camera.far = 30;
scene.add(keyLight);
const rimLight = new THREE.SpotLight(0x88a8ff, 8, 28, Math.PI/4.5, 0.7, 1.2);
rimLight.position.set(-7.5, 7, -4);
rimLight.target.position.set(0, 0, 0); scene.add(rimLight.target);
scene.add(rimLight);
const fillLight = new THREE.DirectionalLight(0xfff0d0, 0.25);
fillLight.position.set(-2, 3, 6);
scene.add(fillLight);
const warmAccent = new THREE.PointLight(0xffaa55, 1.4, 12);
warmAccent.position.set(2.5, 3.5, 5);
scene.add(warmAccent);
const coolAccent = new THREE.PointLight(0x4080ff, 1.0, 12);
coolAccent.position.set(-3.5, 3, 4);
scene.add(coolAccent);
const floor = new THREE.Mesh(
new THREE.CircleGeometry(28, 96),
new THREE.MeshPhysicalMaterial({ color: 0x080a0d, metalness: 0.55, roughness: 0.22, clearcoat: 1.0, clearcoatRoughness: 0.06 })
);
floor.rotation.x = -Math.PI/2;
floor.position.y = -1.65;
floor.receiveShadow = true;
scene.add(floor);
const fadeRing = new THREE.Mesh(
new THREE.RingGeometry(3.5, 18, 64),
new THREE.MeshBasicMaterial({ color:0x06070a, transparent:true, opacity:0.45, side:THREE.DoubleSide })
);
fadeRing.rotation.x = -Math.PI/2;
fadeRing.position.y = -1.64;
scene.add(fadeRing);
const MAT = {
steelHi: new THREE.MeshPhysicalMaterial({ color:0xd5d8de, metalness:1.0, roughness:0.10, clearcoat:0.6, clearcoatRoughness:0.05, envMapIntensity:1.3 }),
steelMid: new THREE.MeshPhysicalMaterial({ color:0xb8bcc4, metalness:1.0, roughness:0.22, envMapIntensity:1.15 }),
steelBrushed: new THREE.MeshPhysicalMaterial({ color:0xa6abb3, metalness:1.0, roughness:0.42, envMapIntensity:1.0 }),
germanSilver: new THREE.MeshPhysicalMaterial({ color:0xd4cdb2, metalness:1.0, roughness:0.32, clearcoat:0.3, envMapIntensity:1.1 }),
brass: new THREE.MeshPhysicalMaterial({ color:0xd8a868, metalness:1.0, roughness:0.22, envMapIntensity:1.45 }),
gold: new THREE.MeshPhysicalMaterial({ color:0xf2c878, metalness:1.0, roughness:0.13, envMapIntensity:1.5 }),
blued: new THREE.MeshPhysicalMaterial({ color:0x1c3a82, metalness:1.0, roughness:0.18, clearcoat:0.85, clearcoatRoughness:0.04, envMapIntensity:1.4 }),
ruby: new THREE.MeshPhysicalMaterial({ color:0xb01828, metalness:0.0, roughness:0.04, transmission:0.72, ior:1.77, thickness:0.35, clearcoat:1.0, envMapIntensity:1.6 }),
sapphire: new THREE.MeshPhysicalMaterial({ color:0xffffff, metalness:0.0, roughness:0.02, transmission:1.0, ior:1.77, thickness:0.25, clearcoat:1.0, clearcoatRoughness:0.0, transparent:true, opacity:0.82, envMapIntensity:2.0, side:THREE.DoubleSide }),
lume: new THREE.MeshStandardMaterial({ color:0xc4f0c8, emissive:0x80ffa8, emissiveIntensity:0.55, roughness:0.55, metalness:0.05 }),
};
function gearShape(outerR, innerR, teeth, hubR, spokes, spokeWidthFrac){
const s = new THREE.Shape();
const seg = teeth * 8;
for (let i = 0; i <= seg; i++){
const a = i / seg * Math.PI * 2;
const ph = (a * teeth) % (Math.PI*2) / (Math.PI*2);
let r;
if (ph < 0.30) r = outerR;
else if (ph < 0.50) r = outerR - (ph-0.30)/0.20 * (outerR-innerR);
else if (ph < 0.80) r = innerR;
else r = innerR + (ph-0.80)/0.20 * (outerR-innerR);
const x = Math.cos(a)*r, y = Math.sin(a)*r;
if (i===0) s.moveTo(x,y); else s.lineTo(x,y);
}
const hp = new THREE.Path();
hp.absarc(0, 0, hubR, 0, Math.PI*2, true);
s.holes.push(hp);
if (spokes > 0){
const outR = innerR * 0.83;
const inR = hubR * 1.7;
const slot = (Math.PI*2 / spokes) * (1 - spokeWidthFrac);
for (let k = 0; k < spokes; k++){
const c = (k + 0.5) * Math.PI*2 / spokes;
const a1 = c - slot/2, a2 = c + slot/2;
const p = new THREE.Path();
p.moveTo(Math.cos(a1)*inR, Math.sin(a1)*inR);
p.lineTo(Math.cos(a1)*outR, Math.sin(a1)*outR);
p.absarc(0, 0, outR, a1, a2, false);
p.lineTo(Math.cos(a2)*inR, Math.sin(a2)*inR);
p.absarc(0, 0, inR, a2, a1, true);
s.holes.push(p);
}
}
return s;
}
function makeWheel({ outerR, innerR, teeth, thickness=0.07, hubR=0.10, spokes=4, spokeW=0.22, mat=MAT.brass, withPinion=true }){
const g = new THREE.Group();
const shape = gearShape(outerR, innerR, teeth, hubR, spokes, spokeW);
const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: true, bevelThickness: thickness*0.28, bevelSize: thickness*0.20, bevelSegments: 2, curveSegments: 1, steps: 1 });
geo.translate(0, 0, -thickness/2);
geo.rotateX(Math.PI/2);
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = true; mesh.receiveShadow = true;
g.add(mesh);
if (withPinion){
const pinShape = gearShape(hubR*1.85, hubR*1.45, 8, hubR*0.55, 0, 0);
const pinGeo = new THREE.ExtrudeGeometry(pinShape, { depth: thickness*1.4, bevelEnabled: true, bevelThickness: thickness*0.18, bevelSize: thickness*0.12, bevelSegments: 1, curveSegments:1, steps:1 });
pinGeo.translate(0, 0, -thickness*0.7);
pinGeo.rotateX(Math.PI/2);
const pin = new THREE.Mesh(pinGeo, MAT.steelHi);
pin.position.y = -thickness*1.0;
pin.castShadow = true;
g.add(pin);
}
const arbor = new THREE.Mesh(new THREE.CylinderGeometry(hubR*0.55, hubR*0.55, thickness*3.5, 14), MAT.steelHi);
g.add(arbor);
const cap = new THREE.Mesh(new THREE.CylinderGeometry(hubR*1.1, hubR*1.1, 0.025, 16), MAT.ruby);
cap.position.y = thickness*0.6;
g.add(cap);
return g;
}
function makeRoundedRectShape(w, h, r){
const s = new THREE.Shape();
s.moveTo(-w/2+r, -h/2);
s.lineTo(w/2-r, -h/2);
s.quadraticCurveTo(w/2, -h/2, w/2, -h/2+r);
s.lineTo(w/2, h/2-r);
s.quadraticCurveTo(w/2, h/2, w/2-r, h/2);
s.lineTo(-w/2+r, h/2);
s.quadraticCurveTo(-w/2, h/2, -w/2, h/2-r);
s.lineTo(-w/2, -h/2+r);
s.quadraticCurveTo(-w/2, -h/2, -w/2+r, -h/2);
return s;
}
function makeBridge({ w, h, thickness, r=0.18, holes=[], mat=MAT.germanSilver }){
const shape = makeRoundedRectShape(w, h, r);
for (const ho of holes){
const hp = new THREE.Path();
hp.absarc(ho.x, ho.y, ho.r, 0, Math.PI*2, true);
shape.holes.push(hp);
}
const geo = new THREE.ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: true, bevelThickness: thickness*0.35, bevelSize: thickness*0.30, bevelSegments: 3, curveSegments: 6, steps: 1 });
geo.translate(0, 0, -thickness/2);
geo.rotateX(Math.PI/2);
const m = new THREE.Mesh(geo, mat);
m.castShadow = true; m.receiveShadow = true;
return m;
}
function makeHairspring({ innerR=0.22, outerR=0.62, turns=13, segments=420 }){
const pts = [];
for (let i = 0; i <= segments; i++){
const t = i / segments;
const a = t * Math.PI * 2 * turns;
const r = innerR + (outerR - innerR) * t;
pts.push(new THREE.Vector3(Math.cos(a)*r, 0, Math.sin(a)*r));
}
const last = pts[pts.length-1];
pts.push(new THREE.Vector3(last.x + 0.06, 0, last.z + 0.04));
const curve = new THREE.CatmullRomCurve3(pts);
const geo = new THREE.TubeGeometry(curve, segments, 0.012, 6, false);
const m = new THREE.Mesh(geo, MAT.blued);
m.castShadow = true;
return m;
}
function makeScrew(r=0.085, h=0.10, mat=MAT.blued){
const g = new THREE.Group();
const head = new THREE.Mesh(new THREE.CylinderGeometry(r, r*0.92, h, 18, 1), mat);
head.castShadow = true;
g.add(head);
const slot = new THREE.Mesh(new THREE.BoxGeometry(r*1.7, h*0.18, r*0.22), new THREE.MeshStandardMaterial({ color:0x000000, roughness:0.6, metalness:0.2 }));
slot.position.y = h/2 + 0.001;
g.add(slot);
return g;
}
function makeJewel(r=0.075){
const g = new THREE.Group();
const bezelMesh = new THREE.Mesh(new THREE.CylinderGeometry(r*1.55, r*1.55, 0.04, 18), MAT.gold);
g.add(bezelMesh);
const stone = new THREE.Mesh(new THREE.CylinderGeometry(r, r, 0.05, 18), MAT.ruby);
stone.position.y = 0.012;
g.add(stone);
const hole = new THREE.Mesh(new THREE.CylinderGeometry(r*0.18, r*0.18, 0.06, 12), new THREE.MeshBasicMaterial({ color:0x000000 }));
hole.position.y = 0.04;
g.add(hole);
return g;
}
const parts = [];
function registerPart(obj, key, explodeOffset=0){
obj.userData.partKey = key;
obj.traverse(c => {
if (c.isMesh){
c.userData.partKey = key;
if (c.material) c.userData._origMat = c.material;
}
});
parts.push({ obj, key, basePos: obj.position.clone(), explodeOffset });
}
const watch = new THREE.Group();
watch.rotation.x = -0.08;
scene.add(watch);
const caseGroup = new THREE.Group();
watch.add(caseGroup);
const caseBodyShape = new THREE.Shape();
caseBodyShape.absarc(0, 0, 4.0, 0, Math.PI*2, false);
const caseBodyHole = new THREE.Path();
caseBodyHole.absarc(0, 0, 3.55, 0, Math.PI*2, true);
caseBodyShape.holes.push(caseBodyHole);
const caseBody = new THREE.Mesh(
new THREE.ExtrudeGeometry(caseBodyShape, { depth: 0.95, bevelEnabled: true, bevelThickness: 0.10, bevelSize: 0.10, bevelSegments: 6, curveSegments: 80, steps:1 }),
MAT.steelHi
);
caseBody.geometry.translate(0, 0, -0.475);
caseBody.geometry.rotateX(Math.PI/2);
caseBody.position.y = -0.05;
caseBody.castShadow = true;
caseBody.receiveShadow = true;
caseGroup.add(caseBody);
registerPart(caseBody, 'case');
const bezelShape = new THREE.Shape();
bezelShape.absarc(0, 0, 4.05, 0, Math.PI*2, false);
const bezelHole = new THREE.Path();
bezelHole.absarc(0, 0, 3.60, 0, Math.PI*2, true);
bezelShape.holes.push(bezelHole);
const bezel = new THREE.Mesh(
new THREE.ExtrudeGeometry(bezelShape, { depth: 0.18, bevelEnabled: true, bevelThickness: 0.06, bevelSize: 0.07, bevelSegments: 4, curveSegments: 80, steps:1 }),
MAT.steelHi
);
bezel.geometry.rotateX(Math.PI/2);
bezel.position.y = 0.52;
bezel.castShadow = true;
bezel.receiveShadow = true;
caseGroup.add(bezel);
registerPart(bezel, 'bezel', 1.2);
const backShape = new THREE.Shape();
backShape.absarc(0, 0, 3.95, 0, Math.PI*2, false);
const backHole = new THREE.Path();
backHole.absarc(0, 0, 3.55, 0, Math.PI*2, true);
backShape.holes.push(backHole);
const caseback = new THREE.Mesh(
new THREE.ExtrudeGeometry(backShape, { depth: 0.15, bevelEnabled: true, bevelThickness: 0.05, bevelSize: 0.05, bevelSegments: 3, curveSegments: 64, steps:1 }),
MAT.steelBrushed
);
caseback.geometry.rotateX(Math.PI/2);
caseback.position.y = -0.58;
caseGroup.add(caseback);
registerPart(caseback, 'caseback', -1.2);
for (const sign of [1, -1]){
const lugGrp = new THREE.Group();
lugGrp.position.z = sign * 3.85;
for (const side of [-1, 1]){
const lug = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.55, 1.0), MAT.steelHi);
lug.position.set(side * 0.95, -0.10, sign * 0.45);
lug.castShadow = true;
lug.receiveShadow = true;
lug.rotation.x = sign * 0.18;
lugGrp.add(lug);
registerPart(lug, 'lug');
}
caseGroup.add(lugGrp);
}
const crownGrp = new THREE.Group();
crownGrp.position.set(4.25, -0.05, 0);
const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 0.30, 14), MAT.steelHi);
stem.rotation.z = Math.PI/2;
stem.position.x = -0.18;
crownGrp.add(stem);
const crownBody = new THREE.Group();
const crownCore = new THREE.Mesh(new THREE.CylinderGeometry(0.30, 0.32, 0.42, 28), MAT.steelHi);
crownCore.rotation.z = Math.PI/2;
crownBody.add(crownCore);
for (let i = 0; i < 22; i++){
const a = i / 22 * Math.PI * 2;
const flute = new THREE.Mesh(new THREE.BoxGeometry(0.40, 0.06, 0.05), MAT.steelMid);
flute.position.set(0, Math.sin(a)*0.31, Math.cos(a)*0.31);
flute.rotation.x = a;
crownBody.add(flute);
}
const crownCap = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.18, 0.05, 20), MAT.gold);
crownCap.rotation.z = Math.PI/2;
crownCap.position.x = 0.22;
crownBody.add(crownCap);
crownGrp.add(crownBody);
caseGroup.add(crownGrp);
registerPart(crownGrp, 'crown', 0.6);
const mainPlateShape = new THREE.Shape();
mainPlateShape.absarc(0, 0, 3.45, 0, Math.PI*2, false);
const mpHole = new THREE.Path();
mpHole.absarc(0, 0, 0.18, 0, Math.PI*2, true);
mainPlateShape.holes.push(mpHole);
const skelWindows = [
{ x: 0, y: 1.9, r: 1.05 },
{ x: -1.8, y: -0.8, r: 0.85 },
{ x: 1.7, y: -1.0, r: 0.75 },
{ x: 0, y: -1.85, r: 1.0 }
];
for (const w of skelWindows){
const p = new THREE.Path();
p.absarc(w.x, w.y, w.r, 0, Math.PI*2, true);
mainPlateShape.holes.push(p);
}
const mainPlate = new THREE.Mesh(
new THREE.ExtrudeGeometry(mainPlateShape, { depth: 0.10, bevelEnabled: true, bevelThickness: 0.04, bevelSize: 0.05, bevelSegments: 3, curveSegments: 64, steps:1 }),
MAT.germanSilver
);
mainPlate.geometry.translate(0, 0, -0.05);
mainPlate.geometry.rotateX(Math.PI/2);
mainPlate.position.y = -0.30;
mainPlate.castShadow = true;
mainPlate.receiveShadow = true;
watch.add(mainPlate);
registerPart(mainPlate, 'mainPlate', -0.8);
const movement = new THREE.Group();
watch.add(movement);
const mainBarrelGrp = new THREE.Group();
mainBarrelGrp.position.set(-1.55, 0.05, -1.55);
const barrelOuter = new THREE.Mesh(new THREE.CylinderGeometry(1.10, 1.10, 0.38, 56, 1, true), MAT.steelHi);
mainBarrelGrp.add(barrelOuter);
const barrelTopShape = new THREE.Shape();
barrelTopShape.absarc(0, 0, 1.10, 0, Math.PI*2, false);
const barrelTopHole = new THREE.Path();
barrelTopHole.absarc(0, 0, 0.12, 0, Math.PI*2, true);
barrelTopShape.holes.push(barrelTopHole);
const barrelTop = new THREE.Mesh(new THREE.ExtrudeGeometry(barrelTopShape, { depth: 0.03, bevelEnabled: false, curveSegments: 60, steps: 1 }), MAT.steelMid);
barrelTop.geometry.rotateX(Math.PI/2);
barrelTop.position.y = 0.19;
mainBarrelGrp.add(barrelTop);
for (let i = 0; i < 11; i++){
const r = 0.20 + i * 0.082;
const coil = new THREE.Mesh(new THREE.TorusGeometry(r, 0.014, 6, 80), MAT.blued);
coil.rotation.x = Math.PI/2;
coil.position.y = 0.205;
mainBarrelGrp.add(coil);
}
const barrelTeethGeo = new THREE.ExtrudeGeometry(gearShape(1.18, 1.10, 80, 1.05, 0, 0), { depth: 0.10, bevelEnabled: true, bevelThickness: 0.02, bevelSize: 0.02, bevelSegments: 1, curveSegments: 1, steps: 1 });
barrelTeethGeo.translate(0, 0, -0.05);
barrelTeethGeo.rotateX(Math.PI/2);
const barrelTeeth = new THREE.Mesh(barrelTeethGeo, MAT.brass);
barrelTeeth.castShadow = true;
mainBarrelGrp.add(barrelTeeth);
const barrelPivot = new THREE.Mesh(new THREE.CylinderGeometry(0.10, 0.10, 0.55, 16), MAT.steelHi);
mainBarrelGrp.add(barrelPivot);
movement.add(mainBarrelGrp);
registerPart(mainBarrelGrp, 'mainspring', 1.6);
const wheelDefs = [
{ key:'centerWheel', pos:[0.40, 0.05, 0.10], outerR:0.78, innerR:0.65, teeth:64, spokes:4, mat:MAT.brass },
{ key:'thirdWheel', pos:[1.55, 0.08, 0.35], outerR:0.55, innerR:0.45, teeth:54, spokes:4, mat:MAT.brass },
{ key:'fourthWheel', pos:[0.55, 0.10, 1.55], outerR:0.48, innerR:0.39, teeth:48, spokes:3, mat:MAT.brass },
{ key:'escapeWheel', pos:[1.55, 0.10, 1.60], outerR:0.40, innerR:0.30, teeth:15, spokes:3, mat:MAT.steelHi },
];
const wheels = {};
for (const d of wheelDefs){
const w = makeWheel({ outerR: d.outerR, innerR: d.innerR, teeth: d.teeth, thickness: 0.07, hubR: 0.08, spokes: d.spokes, spokeW: 0.22, mat: d.mat });
w.position.set(...d.pos);
movement.add(w);
registerPart(w, d.key, 0.7 + (d.key === 'escapeWheel' ? 0.2 : 0));
wheels[d.key] = w;
}
const palletGrp = new THREE.Group();
palletGrp.position.set(0.55, 0.12, -1.10);
const palletShape = new THREE.Shape();
palletShape.moveTo(-0.45, -0.06);
palletShape.lineTo(0.45, -0.06);
palletShape.quadraticCurveTo(0.50, 0, 0.45, 0.06);
palletShape.lineTo(-0.45, 0.06);
palletShape.quadraticCurveTo(-0.50, 0, -0.45, -0.06);
const palletGeo = new THREE.ExtrudeGeometry(palletShape, { depth: 0.05, bevelEnabled: true, bevelThickness: 0.012, bevelSize: 0.012, bevelSegments: 2 });
palletGeo.translate(0, 0, -0.025);
palletGeo.rotateX(Math.PI/2);
const palletArm = new THREE.Mesh(palletGeo, MAT.steelHi);
palletArm.castShadow = true;
palletGrp.add(palletArm);
const tineShape = new THREE.Shape();
tineShape.moveTo(-0.04, -0.30);
tineShape.lineTo(0.04, -0.30);
tineShape.lineTo(0.06, 0.30);
tineShape.quadraticCurveTo(0, 0.34, -0.06, 0.30);
const tineGeo = new THREE.ExtrudeGeometry(tineShape, { depth: 0.05, bevelEnabled: true, bevelThickness: 0.01, bevelSize: 0.01, bevelSegments: 1 });
tineGeo.translate(0, 0, -0.025);
tineGeo.rotateX(Math.PI/2);
const tine = new THREE.Mesh(tineGeo, MAT.steelHi);
tine.position.x = 0.42;
tine.castShadow = true;
palletGrp.add(tine);
for (const x of [-0.15, 0.15]){
const ps = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.08, 0.05), MAT.ruby);
ps.position.set(x, 0.05, 0);
palletGrp.add(ps);
}
const palletPivot = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.35, 12), MAT.steelHi);
palletGrp.add(palletPivot);
movement.add(palletGrp);
registerPart(palletGrp, 'pallet', 0.9);
const balanceGrp = new THREE.Group();
balanceGrp.position.set(-0.45, 0.20, -1.95);
const balanceRim = new THREE.Mesh(new THREE.TorusGeometry(0.78, 0.06, 14, 80), MAT.gold);
balanceRim.rotation.x = Math.PI/2;
balanceRim.castShadow = true;
balanceGrp.add(balanceRim);
for (let i = 0; i < 3; i++){
const a = i * Math.PI * 2 / 3;
const spk = new THREE.Mesh(new THREE.BoxGeometry(1.50, 0.04, 0.07), MAT.gold);
spk.rotation.y = a;
spk.castShadow = true;
balanceGrp.add(spk);
}
for (let i = 0; i < 4; i++){
const a = i * Math.PI / 2 + Math.PI/4;
const sc = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 0.14, 12), MAT.gold);
sc.position.set(Math.cos(a)*0.78, 0.02, Math.sin(a)*0.78);
sc.rotation.x = Math.PI/2;
sc.rotation.z = a;
balanceGrp.add(sc);
}
const balHub = new THREE.Mesh(new THREE.CylinderGeometry(0.10, 0.10, 0.12, 18), MAT.steelHi);
balanceGrp.add(balHub);
const balArbor = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.50, 12), MAT.steelHi);
balanceGrp.add(balArbor);
movement.add(balanceGrp);
registerPart(balanceGrp, 'balance', 1.0);
const hairGrp = new THREE.Group();
hairGrp.position.copy(balanceGrp.position);
hairGrp.position.y += 0.18;
const hair = makeHairspring({ innerR: 0.14, outerR: 0.62, turns: 13, segments: 480 });
hairGrp.add(hair);
const stud = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.05, 0.08), MAT.gold);
stud.position.set(0.66, 0.02, 0.05);
hairGrp.add(stud);
movement.add(hairGrp);
registerPart(hairGrp, 'hairspring', 1.25);
const bridge1 = makeBridge({ w: 4.6, h: 1.2, thickness: 0.10, r: 0.45, holes: [ { x: -1.6, y: 0, r: 0.78 }, { x: 0.45, y: 0.10, r: 0.55 }, { x: 1.55, y: -0.05, r: 0.40 } ] });
bridge1.rotation.y = -0.22;
bridge1.position.set(-0.2, 0.30, 0.0);
movement.add(bridge1);
registerPart(bridge1, 'bridge', 0.9);
const bridge2 = makeBridge({ w: 2.8, h: 1.0, thickness: 0.10, r: 0.35, holes: [ { x: -0.5, y: 0, r: 0.35 }, { x: 0.55, y: 0, r: 0.30 } ] });
bridge2.position.set(1.05, 0.30, 1.55);
bridge2.rotation.y = 0.15;
movement.add(bridge2);
registerPart(bridge2, 'bridge', 0.9);
const balanceCock = makeBridge({ w: 1.7, h: 0.55, thickness: 0.08, r: 0.22, holes: [{ x: -0.35, y: 0, r: 0.13 }] });
balanceCock.position.set(-0.20, 0.46, -1.95);
balanceCock.rotation.y = -0.35;
movement.add(balanceCock);
registerPart(balanceCock, 'bridge', 0.9);
const palletCock = makeBridge({ w: 1.1, h: 0.35, thickness: 0.07, r: 0.15, holes: [] });
palletCock.position.set(0.55, 0.36, -1.10);
palletCock.rotation.y = 0.2;
movement.add(palletCock);
registerPart(palletCock, 'bridge', 0.9);
const jewelPositions = [
[-1.55, 0.41, -1.55], [ 0.45, 0.41, 0.10], [ 1.55, 0.41, 0.35],
[ 0.55, 0.41, 1.55], [ 1.55, 0.41, 1.60],
[-0.45, 0.52, -1.95], [ 0.55, 0.45, -1.10],
];
for (const [x,y,z] of jewelPositions){
const j = makeJewel(0.075);
j.position.set(x, y, z);
movement.add(j);
registerPart(j, 'jewel', 1.1);
}
const screwSpots = [
[-2.2, 0.42, -0.5], [1.55, 0.42, -0.55], [1.45, 0.42, 0.65], [-2.15, 0.42, 0.5],
[0.05, 0.42, 1.50], [2.05, 0.42, 1.60],
[0.50, 0.55, -1.85], [-0.95, 0.55, -2.05],
[1.05, 0.45, -1.05]
];
for (const [x,y,z] of screwSpots){
const sc = makeScrew(0.085, 0.10);
sc.position.set(x, y, z);
movement.add(sc);
registerPart(sc, 'screw', 1.2);
}
const dialGrp = new THREE.Group();
dialGrp.position.y = 0.50;
watch.add(dialGrp);
const chapterRing = new THREE.Mesh(new THREE.RingGeometry(3.10, 3.45, 96), MAT.steelMid);
chapterRing.rotation.x = -Math.PI/2;
chapterRing.position.y = 0.001;
dialGrp.add(chapterRing);
for (let i = 0; i < 12; i++){
const a = i / 12 * Math.PI * 2 - Math.PI/2;
const isQuarter = i % 3 === 0;
const len = isQuarter ? 0.34 : 0.22;
const w = isQuarter ? 0.08 : 0.05;
const idx = new THREE.Mesh(new THREE.BoxGeometry(w, 0.06, len), MAT.steelHi);
const r = 3.22;
idx.position.set(Math.cos(a)*r, 0.04, Math.sin(a)*r);
idx.rotation.y = -a + Math.PI/2;
idx.castShadow = true;
dialGrp.add(idx);
registerPart(idx, 'indices', 0.5);
const lumeInset = new THREE.Mesh(new THREE.BoxGeometry(w*0.55, 0.04, len*0.7), MAT.lume);
lumeInset.position.set(Math.cos(a)*r, 0.085, Math.sin(a)*r);
lumeInset.rotation.y = -a + Math.PI/2;
dialGrp.add(lumeInset);
registerPart(lumeInset, 'indices', 0.5);
}
const triShape = new THREE.Shape();
triShape.moveTo(-0.13, -0.10);
triShape.lineTo(0.13, -0.10);
triShape.lineTo(0, 0.18);
const triGeo = new THREE.ExtrudeGeometry(triShape, { depth: 0.04, bevelEnabled: true, bevelThickness: 0.01, bevelSize: 0.01, bevelSegments: 1 });
triGeo.translate(0, 0, -0.02);
triGeo.rotateX(Math.PI/2);
const triMarker = new THREE.Mesh(triGeo, MAT.lume);
triMarker.position.set(0, 0.10, -3.20);
dialGrp.add(triMarker);
registerPart(triMarker, 'indices', 0.5);
const handsGrp = new THREE.Group();
handsGrp.position.y = 0.60;
watch.add(handsGrp);
function makeHand({ length, width, color=MAT.steelHi, height=0.04 }){
const grp = new THREE.Group();
const sh = new THREE.Shape();
sh.moveTo(-width/2, -0.1);
sh.lineTo(width/2, -0.1);
sh.lineTo(width*0.15, length*0.92);
sh.quadraticCurveTo(0, length, -width*0.15, length*0.92);
const cut = new THREE.Path();
cut.moveTo(-width*0.25, length*0.15);
cut.lineTo(width*0.25, length*0.15);
cut.lineTo(width*0.06, length*0.70);
cut.quadraticCurveTo(0, length*0.75, -width*0.06, length*0.70);
sh.holes.push(cut);
const geo = new THREE.ExtrudeGeometry(sh, { depth: height, bevelEnabled: true, bevelThickness: height*0.4, bevelSize: height*0.4, bevelSegments: 2 });
geo.translate(0, 0, -height/2);
geo.rotateX(Math.PI/2);
const m = new THREE.Mesh(geo, color);
m.castShadow = true;
grp.add(m);
const lumeSh = new THREE.Shape();
lumeSh.moveTo(-width*0.20, length*0.18);
lumeSh.lineTo(width*0.20, length*0.18);
lumeSh.lineTo(width*0.05, length*0.68);
lumeSh.quadraticCurveTo(0, length*0.72, -width*0.05, length*0.68);
const lumeGeo = new THREE.ExtrudeGeometry(lumeSh, { depth: height*0.4, bevelEnabled: false });
lumeGeo.rotateX(Math.PI/2);
const lm = new THREE.Mesh(lumeGeo, MAT.lume);
lm.position.y = height*0.5;
grp.add(lm);
return grp;
}
const hourHand = makeHand({ length: 1.55, width: 0.20, color: MAT.steelHi, height: 0.045 });
handsGrp.add(hourHand);
registerPart(hourHand, 'hands', 0.4);
const minHand = makeHand({ length: 2.25, width: 0.16, color: MAT.steelHi, height: 0.04 });
minHand.position.y = 0.05;
handsGrp.add(minHand);
registerPart(minHand, 'hands', 0.5);
const secHand = new THREE.Group();
const secNeedle = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.025, 2.50), MAT.blued);
secNeedle.position.z = 1.07;
secHand.add(secNeedle);
const secCounter = new THREE.Mesh(new THREE.CylinderGeometry(0.10, 0.10, 0.04, 16), MAT.blued);
secCounter.position.z = -0.18;
secHand.add(secCounter);
secHand.position.y = 0.10;
handsGrp.add(secHand);
registerPart(secHand, 'hands', 0.6);
const centralCap = new THREE.Mesh(new THREE.SphereGeometry(0.16, 24, 12, 0, Math.PI*2, 0, Math.PI/2), MAT.steelHi);
centralCap.position.y = 0.15;
handsGrp.add(centralCap);
const crystalRadius = 3.55;
const crystalGeo = new THREE.SphereGeometry(crystalRadius * 2.1, 64, 32, 0, Math.PI*2, 0, Math.PI/6);
const crystal = new THREE.Mesh(crystalGeo, MAT.sapphire);
crystal.position.y = -5.4;
watch.add(crystal);
registerPart(crystal, 'crystal', 2.2);
const crystalRing = new THREE.Mesh(new THREE.TorusGeometry(3.58, 0.06, 12, 80), MAT.steelHi);
crystalRing.rotation.x = Math.PI/2;
crystalRing.position.y = 0.66;
watch.add(crystalRing);
const labelLayer = document.getElementById('labelLayer');
const labelDefs = [
{ key:'mainspring', side:'left', worldOffset:[ -1.55, 1.2, -1.55 ] },
{ key:'balance', side:'left', worldOffset:[ -0.45, 1.0, -1.95 ] },
{ key:'hairspring', side:'left', worldOffset:[ -1.05, 1.4, -2.15 ] },
{ key:'pallet', side:'right', worldOffset:[ 0.55, 0.95, -1.10 ] },
{ key:'escapeWheel', side:'right', worldOffset:[ 1.55, 0.90, 1.60 ] },
{ key:'centerWheel', side:'right', worldOffset:[ 0.45, 0.85, 0.10 ] },
{ key:'crown', side:'right', worldOffset:[ 4.55, 0.20, 0.0 ] },
{ key:'crystal', side:'left', worldOffset:[ -2.4, 0.95, -1.4 ] }
];
const labelEls = [];
for (const ld of labelDefs){
const el = document.createElement('div');
el.className = 'label ' + ld.side;
el.innerHTML = `<div class="ln"></div><span>${META[ld.key].name}</span>`;
labelLayer.appendChild(el);
labelEls.push({ el, def: ld });
}
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let hoveredKey = null;
const tooltip = document.getElementById('tooltip');
const partPanel = document.getElementById('partPanel');
const pName = document.getElementById('pName');
const pCat = document.getElementById('pCat');
const pDesc = document.getElementById('pDesc');
const pSpec = document.getElementById('pSpec');
function pickPart(){
raycaster.setFromCamera(pointer, camera);
const objs = [];
parts.forEach(p => p.obj.traverse(c => { if (c.isMesh) objs.push(c); }));
const hits = raycaster.intersectObjects(objs, false);
for (const h of hits){
let o = h.object;
while (o && !o.userData.partKey) o = o.parent;
if (o && o.userData.partKey) return { obj: o, key: o.userData.partKey };
}
return null;
}
function clearHighlight(key){
parts.filter(p => p.key === key).forEach(p => {
p.obj.traverse(c => { if (c.isMesh && c.userData._origMat) c.material = c.userData._origMat; });
});
}
function applyHighlight(key){
parts.filter(p => p.key === key).forEach(p => {
p.obj.traverse(c => {
if (c.isMesh && c.userData._origMat){
const cloned = c.userData._origMat.clone();
if ('emissive' in cloned){ cloned.emissive = new THREE.Color(0xffa550); cloned.emissiveIntensity = 0.42; }
c.material = cloned;
}
});
});
}
addEventListener('pointermove', e => {
pointer.x = (e.clientX / innerWidth) * 2 - 1;
pointer.y = -(e.clientY / innerHeight) * 2 + 1;
const pick = pickPart();
const nowKey = pick ? pick.key : null;
if (nowKey !== hoveredKey){
if (hoveredKey) clearHighlight(hoveredKey);
hoveredKey = nowKey;
if (hoveredKey && META[hoveredKey]){
applyHighlight(hoveredKey);
tooltip.textContent = META[hoveredKey].name;
tooltip.classList.add('show');
document.body.style.cursor = 'pointer';
} else {
tooltip.classList.remove('show');
document.body.style.cursor = '';
}
}
if (hoveredKey){
tooltip.style.left = e.clientX + 'px';
tooltip.style.top = e.clientY + 'px';
}
});
addEventListener('pointerdown', e => {
if (e.target !== renderer.domElement) return;
const pick = pickPart();
if (pick && META[pick.key]) showPartPanel(pick.key);
else partPanel.classList.remove('show');
});
function showPartPanel(key){
const m = META[key];
pCat.textContent = m.cat;
pName.textContent = m.name;
pDesc.textContent = m.desc;
pSpec.innerHTML = '';
for (const [lbl, val] of (m.spec || [])){
const a = document.createElement('span'); a.textContent = lbl;
const b = document.createElement('span'); b.textContent = val;
pSpec.appendChild(a); pSpec.appendChild(b);
}
partPanel.classList.add('show');
}
const btnSpin = document.getElementById('btn-spin');
const btnExplode = document.getElementById('btn-explode');
const btnLabels = document.getElementById('btn-labels');
const btnLume = document.getElementById('btn-lume');
const btnReset = document.getElementById('btn-reset');
let explodeAmount = 0;
let explodeTarget = 0;
let labelsVisible = false;
let lumeMode = false;
btnSpin.addEventListener('click', () => { controls.autoRotate = !controls.autoRotate; btnSpin.classList.toggle('active', controls.autoRotate); });
btnExplode.addEventListener('click', () => { const exploded = explodeTarget > 0.5; explodeTarget = exploded ? 0 : 1; btnExplode.classList.toggle('active', !exploded); });
btnLabels.addEventListener('click', () => { labelsVisible = !labelsVisible; btnLabels.classList.toggle('active', labelsVisible); labelEls.forEach(({el}) => el.classList.toggle('show', labelsVisible)); });
btnLume.addEventListener('click', () => {
lumeMode = !lumeMode;
btnLume.classList.toggle('active', lumeMode);
if (lumeMode){
renderer.toneMappingExposure = 0.30;
scene.background = new THREE.Color(0x010204);
floor.material.color.setHex(0x010206);
MAT.lume.emissiveIntensity = 3.2;
keyLight.intensity = 1.2;
rimLight.intensity = 0.5;
warmAccent.intensity = 0.25;
coolAccent.intensity = 0.4;
ambientLight.intensity = 0.05;
} else {
renderer.toneMappingExposure = 1.05;
scene.background = new THREE.Color(0x06070a);
floor.material.color.setHex(0x080a0d);
MAT.lume.emissiveIntensity = 0.55;
keyLight.intensity = 14;
rimLight.intensity = 8;
warmAccent.intensity = 1.4;
coolAccent.intensity = 1.0;
ambientLight.intensity = 0.22;
}
});
btnReset.addEventListener('click', () => { camera.position.set(2, 6.5, 11); controls.target.set(0, 0.2, 0); controls.update(); });
const clock = new THREE.Clock();
function updateLabels(){
if (!labelsVisible) return;
for (const { el, def } of labelEls){
const wp = new THREE.Vector3(...def.worldOffset);
wp.applyMatrix4(watch.matrixWorld);
const proj = wp.clone().project(camera);
if (proj.z > 1 || proj.z < -1){ el.style.display = 'none'; continue; }
el.style.display = '';
const x = (proj.x * 0.5 + 0.5) * innerWidth;
const y = (-proj.y * 0.5 + 0.5) * innerHeight;
const ox = def.side === 'right' ? 90 : -90;
el.style.left = (x + ox) + 'px';
el.style.top = y + 'px';
el.style.transform = def.side === 'right' ? 'translate(0,-50%)' : 'translate(-100%,-50%)';
}
}
function animate(){
const t = clock.getElapsedTime();
const dt = Math.min(clock.getDelta(), 0.05);
const beat = Math.sin(t * Math.PI * 8) * Math.PI * 0.35;
balanceGrp.rotation.y = beat;
hairGrp.rotation.y = beat * 0.92;
palletGrp.rotation.y = -Math.sin(t * Math.PI * 8 + 0.4) * 0.10;
const beatN = Math.floor(t * 8);
const beatPhase = (t * 8) % 1;
const tick = beatN + Math.min(1, beatPhase * 4);
wheels.escapeWheel.rotation.y = -tick * (Math.PI * 2 / 15);
wheels.fourthWheel.rotation.y = -t * 0.42;
wheels.thirdWheel.rotation.y = t * 0.19;
wheels.centerWheel.rotation.y = -t * 0.06;
mainBarrelGrp.rotation.y = t * 0.015;
secHand.rotation.y = -t * 0.42;
minHand.rotation.y = -t * 0.042;
hourHand.rotation.y = -t * 0.0035;
explodeAmount += (explodeTarget - explodeAmount) * Math.min(1, dt * 4);
for (const p of parts) p.obj.position.y = p.basePos.y + p.explodeOffset * explodeAmount;
crystal.position.y = (-5.4) + 2.2 * explodeAmount;
crystalRing.position.y = 0.66 + 0.7 * explodeAmount;
if (!lumeMode) warmAccent.intensity = 1.4 * (1 + Math.sin(t * 1.6) * 0.05);
controls.update();
renderer.render(scene, camera);
updateLabels();
requestAnimationFrame(animate);
}
addEventListener('resize', () => { camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight); });
animate();
setTimeout(() => {
document.getElementById('loading').classList.add('hide');
setTimeout(() => { const h = document.getElementById('hint'); if (h) h.style.opacity = '0'; }, 6500);
}, 700);
</script>
</body>
</html>