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>
969 lines
47 KiB
HTML
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>
|