/* ============================================================
NUMEX CLOCKWORK — Zamanın İçini Gör
Tamamen kodla üretilmiş 3D mekanik saat.
Hazır model / video / dış görsel YOK — her parça matematikle çizilir.
============================================================ */
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
/* ============================================================
0. YARDIMCILAR
============================================================ */
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const lerp = (a, b, t) => a + (b - a) * t;
const easeInOut = t => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
/* ============================================================
1. MALZEMELER — altın, bronz, çelik, cam
============================================================ */
const MAT = {};
/* ------------------------------------------------------------
Yüzey pürüz dokusu — kodla üretilir (harici dosya YOK).
Değer gürültüsü + ince tarama: metali "kesilmiş sac" görünümünden
çıkarıp torna/freze izli gerçek yüzeye yaklaştırır.
------------------------------------------------------------ */
function makeMetalRoughnessTexture(size = 256, base = 150, variance = 46) {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(size, size);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
// eşmerkezli tarama izleri + ince gürültü
const cx = x / size - 0.5;
const cy = y / size - 0.5;
const r = Math.sqrt(cx * cx + cy * cy) * 60;
const rings = Math.sin(r * Math.PI) * 0.5 + 0.5;
const noise = Math.random();
const v = clamp(base + (rings - 0.5) * variance * 1.4 + (noise - 0.5) * variance, 0, 255);
const i = (y * size + x) * 4;
img.data[i] = img.data[i + 1] = img.data[i + 2] = v;
img.data[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(2, 2);
tex.colorSpace = THREE.NoColorSpace;
tex.anisotropy = 4;
return tex;
}
function buildMaterials() {
const roughTex = makeMetalRoughnessTexture();
/* Altın: yüzeyde mikro pürüz (roughnessMap yerine yüksek roughness +
düşük emissive) — düz "sarı boya" görünümünü kırar. */
MAT.gold = new THREE.MeshPhysicalMaterial({
color: 0xc08c33, metalness: 1.0, roughness: 0.42,
roughnessMap: roughTex,
clearcoat: 0.35, clearcoatRoughness: 0.45,
emissive: 0x140c02, emissiveIntensity: 0.25,
});
MAT.goldPolished = new THREE.MeshPhysicalMaterial({
color: 0xe8bd6d, metalness: 1.0, roughness: 0.2,
roughnessMap: roughTex,
clearcoat: 0.85, clearcoatRoughness: 0.08,
});
MAT.bronze = new THREE.MeshPhysicalMaterial({
color: 0x6f4520, metalness: 1.0, roughness: 0.56,
roughnessMap: roughTex,
clearcoat: 0.2, clearcoatRoughness: 0.6,
emissive: 0x0e0702, emissiveIntensity: 0.22,
});
MAT.copper = new THREE.MeshPhysicalMaterial({
color: 0xb06a33, metalness: 1.0, roughness: 0.28,
});
MAT.steel = new THREE.MeshPhysicalMaterial({
color: 0x9aa1aa, metalness: 1.0, roughness: 0.24,
roughnessMap: roughTex,
clearcoat: 0.6,
});
MAT.dark = new THREE.MeshPhysicalMaterial({
color: 0x2a2118, metalness: 0.85, roughness: 0.5,
});
MAT.dialFace = new THREE.MeshPhysicalMaterial({
color: 0x0e0b08, metalness: 0.35, roughness: 0.62,
clearcoat: 0.4, clearcoatRoughness: 0.4,
});
MAT.enamel = new THREE.MeshPhysicalMaterial({
color: 0x14100c, metalness: 0.2, roughness: 0.55,
clearcoat: 0.9, clearcoatRoughness: 0.15,
});
/* Cam: yalnızca kenarlarda ince bir yansıma.
transmission + bloom beyaz leke üretiyordu; additive blending ise
tüm sahneyi sise boğuyordu. Normal saydamlık + çok düşük opaklık
sahneyi örtmeden cam hissi verir. */
MAT.glass = new THREE.MeshPhysicalMaterial({
color: 0xaec6d6, metalness: 0.0, roughness: 0.06,
transparent: true, opacity: 0.055,
clearcoat: 1.0, clearcoatRoughness: 0.02,
side: THREE.FrontSide, depthWrite: false,
envMapIntensity: 1.6,
});
MAT.glow = new THREE.MeshBasicMaterial({ color: 0xffd699 });
MAT.ruby = new THREE.MeshPhysicalMaterial({
color: 0x8e1020, metalness: 0.1, roughness: 0.15,
clearcoat: 1.0, emissive: 0x3a0510, emissiveIntensity: 0.6,
});
}
/* ============================================================
2. EVOLVENT DİŞLİ ÜRETECİ
Gerçek evolvent profil yerine yüksek kaliteli yaklaşım:
her diş bir trapez + yuvarlatılmış tepe; silindirik gövde.
============================================================ */
function gearShape(teeth, pitchRadius, { module_ = 0.083, hubRatio = 0.34, spokeCount = 0, holeRadius = 0 } = {}) {
const shape = new THREE.Shape();
/* DİŞ ÖLÇÜSÜ HATVEYE BAĞLIDIR, YARIÇAPA DEĞİL.
Eski sürüm toothDepth'ı pitchRadius'un yüzdesi olarak alıyordu; bu yüzden
44 dişli çarkta dişler iğne gibi ince, 9 dişlide kütük gibi kalıyor ve
komşu çarka hiç girmiyordu (ekranda "fırça" görünümü). Gerçek çarkta:
addendum = 1.00 × m (hatve dairesinden tepeye)
dedendum = 1.25 × m (hatve dairesinden köke)
Kök açıklığı bilerek tepe yüksekliğinden büyük: böylece dişler boşluğa
girer, dipte çakışma olmaz. */
const m = module_; // hatve (modül)
const rPitch = pitchRadius;
const rOuter = rPitch + m * 1.00; // diş üstü dairesi
const rRoot = rPitch - m * 1.25; // diş dibi dairesi
const step = TAU / teeth; // açısal hatve
const halfTop = step * 0.21; // tepe genişliği (hatvenin %42'si)
const halfRoot = step * 0.29; // kök genişliği (hatvenin %58'i)
/* EVOLVENT YAKLAŞIMI: diş yanağı iki nokta arasında düz çizgi değil,
kökten tepeye doğru DIŞA doğru kıvrılan bir eğri. Gerçek evolventin
ilk iki terimi (r·(1+t²/2)) yeterli: fark gözle görülür, maliyeti yok. */
const flank = (side, a, r, k) => {
// side: -1 sol yanak, +1 sağ yanak; k: 0→kök, 1→tepe
const t = k; // yanak boyunca 0..1
const ang = a + side * lerp(halfRoot, halfTop, t);
// evolvent düzeltmesi: yanağın ortası hafifçe dışa taşar
const bulge = 1 + m * 0.10 * Math.sin(Math.PI * t) / r;
return [Math.cos(ang) * r * bulge, Math.sin(ang) * r * bulge];
};
for (let i = 0; i < teeth; i++) {
const a = i * step;
const rootL = a - halfRoot;
const rootR = a + halfRoot;
const topL = a - halfTop;
const topR = a + halfTop;
// 1) kök yayının sonundan bu dişin sol köküne
if (i === 0) {
shape.moveTo(Math.cos(rootL) * rRoot, Math.sin(rootL) * rRoot);
} else {
shape.lineTo(Math.cos(rootL) * rRoot, Math.sin(rootL) * rRoot);
}
// 2) sol yanak — kökten tepeye eğri (3 ara nokta)
for (let s = 1; s <= 3; s++) {
const p = flank(-1, a, lerp(rRoot, rOuter, s / 4), s / 4);
shape.lineTo(p[0], p[1]);
}
shape.lineTo(Math.cos(topL) * rOuter, Math.sin(topL) * rOuter);
// 3) tepe yayı — bir miktar yuvarlatılmış (gerçek çarkta tepe düzdür,
// köşeler faso edilmiştir; burada 2 ara nokta ile taklit edilir)
shape.absarc(0, 0, rOuter, topL, topR, false);
// 4) sağ yanak — tepeden köke eğri
for (let s = 3; s >= 1; s--) {
const p = flank(1, a, lerp(rRoot, rOuter, s / 4), s / 4);
shape.lineTo(p[0], p[1]);
}
// 5) kök yayı — bir sonraki dişin sol köküne kadar
const nextRootL = a + step - halfRoot;
shape.absarc(0, 0, rRoot, rootR, nextRootL, false);
}
shape.closePath();
// merkez deliği
const hubR = pitchRadius * hubRatio;
const hole = new THREE.Path();
hole.absarc(0, 0, hubR, 0, TAU, true);
shape.holes.push(hole);
// hafifletme delikleri (büyük dişlilerde "spoke" görünümü)
if (spokeCount > 0) {
const holeR = pitchRadius * holeRadius;
const ringR = (hubR + rRoot) * 0.5;
for (let s = 0; s < spokeCount; s++) {
const ang = (s / spokeCount) * TAU + Math.PI / spokeCount;
const cx = Math.cos(ang) * ringR;
const cy = Math.sin(ang) * ringR;
const h = new THREE.Path();
h.absarc(cx, cy, holeR, 0, TAU, true);
shape.holes.push(h);
}
}
return shape;
}
function makeGear(teeth, module_, opts = {}) {
const pitchRadius = (module_ * teeth) / 2;
const thickness = opts.thickness ?? pitchRadius * 0.22;
const shape = gearShape(teeth, pitchRadius, { ...opts, module_ });
const geo = new THREE.ExtrudeGeometry(shape, {
depth: thickness,
bevelEnabled: true,
bevelThickness: thickness * 0.08,
bevelSize: pitchRadius * 0.012,
bevelSegments: 2,
curveSegments: Math.max(8, Math.min(30, Math.round(teeth / 2))),
});
geo.center();
geo.rotateX(0);
const mesh = new THREE.Mesh(geo, opts.material || MAT.gold);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData.pitchRadius = pitchRadius;
mesh.userData.teeth = teeth;
mesh.userData.thickness = thickness;
return mesh;
}
/* ------------------------------------------------------------
MİL, SOMUN, RONDELA
DÜZELTME (kök neden): Silindir geometrisinin ekseni ZATEN +Y'dir.
Eski kod `rotation.x = Math.PI/2` uyguluyordu → mil ve somun çark
düzlemine YATIYOR, çark ekseninde durmuyordu. Ayrıca geo.center()
sonrası çarkın ön yüzü +thickness/2'de kalırken somun thickness*0.72'ye
konuyordu → gövdenin İÇİNE gömülüyor, dışarıdan görünmüyordu.
Doğrusu: çark düzlemi XY, kalınlık Z boyunca. Mil ve somun Z ekseninde
olmalı → silindiri `rotateX(90°)` ile Y'den Z'ye çevirmek gerekir;
ama bunu mesh'e DEĞİL geometriye uygulayıp somun konumunu ön yüze taşırız.
------------------------------------------------------------ */
function addAxleAndNut(group, radius, thickness, mat = MAT.steel) {
/* Mil: çark kalınlığından taşar, iki yanda görünür */
const axleLen = thickness + radius * 0.34;
const axleGeo = new THREE.CylinderGeometry(radius * 0.085, radius * 0.085, axleLen, 18);
axleGeo.rotateX(Math.PI / 2); // Y ekseni → Z ekseni (çark ekseni)
const axle = new THREE.Mesh(axleGeo, mat);
axle.castShadow = true;
group.add(axle);
/* Rondela: somunun çarka oturduğu pul */
const washerGeo = new THREE.CylinderGeometry(radius * 0.19, radius * 0.19, thickness * 0.10, 24);
washerGeo.rotateX(Math.PI / 2);
/* Altıgen somun: çark ekseninde, çarkın ÖN yüzünün hemen dışında */
const nutGeo = new THREE.CylinderGeometry(radius * 0.145, radius * 0.145, thickness * 0.30, 6);
nutGeo.rotateX(Math.PI / 2);
const faceF = thickness / 2; // çarkın ön yüzü
const washerF = new THREE.Mesh(washerGeo, MAT.bronze);
washerF.position.z = faceF + thickness * 0.05;
group.add(washerF);
const nutF = new THREE.Mesh(nutGeo, MAT.goldPolished);
nutF.position.z = faceF + thickness * 0.10 + thickness * 0.15;
nutF.castShadow = true;
group.add(nutF);
/* Arka yüzde sadece rondela + küçük somun (kasaya bakan taraf) */
const washerB = new THREE.Mesh(washerGeo, MAT.bronze);
washerB.position.z = -faceF - thickness * 0.05;
group.add(washerB);
const nutB = new THREE.Mesh(nutGeo, MAT.goldPolished);
nutB.position.z = -faceF - thickness * 0.10 - thickness * 0.15;
group.add(nutB);
return group;
}
/* ============================================================
3. SAHNE KURULUMU
============================================================ */
const canvas = document.getElementById('stage');
const renderer = new THREE.WebGLRenderer({
canvas, antialias: true, alpha: false, powerPreference: 'high-performance',
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x060403);
scene.fog = new THREE.FogExp2(0x060403, 0.028);
const camera = new THREE.PerspectiveCamera(34, window.innerWidth / window.innerHeight, 0.1, 200);
const CAM_TARGET = new THREE.Vector3(0, 0.2, 0);
/* Ev kamerası EKRAN ORANINA göre hesaplanır: dar/dikey ekranda yatay
görüş açısı küçüldüğü için sabit mesafe kasayı kesiyordu. Kasa yarıçapı
CASE_R, hem dikey hem yatay eksende sığacak şekilde ölçeklenir. */
const CAM_HOME = new THREE.Vector3();
/* ★28.10 Market hazırlığı: alttaki kontrol paneli kasanın alt üçte birini (sarkaç
topuzu dahil) örtüyordu — kadraj tüm pencereye göre hesaplanıyordu. Artık SERBEST
alan (panelin üstü) ölçülür: kamera görüntüsü o alanın ortasına kaydırılır
(setViewOffset) ve sığdırma mesafesi o alanın yüksekliğine göre bulunur. */
function serbestAlan() {
const h = window.innerHeight;
const panel = document.getElementById('panel');
const alt = panel ? Math.max(0, h - panel.getBoundingClientRect().top + 6) : 0;
const ust = Math.min(56, h * 0.06);
return { h, alt, ust, oran: Math.max(0.35, (h - alt - ust) / h) };
}
function kadrajiKaydir() {
const w = window.innerWidth, { h, alt, ust } = serbestAlan();
camera.setViewOffset(w, h, 0, Math.round((alt - ust) / 2), w, h);
}
/* yarıçapı R olan daireyi serbest alana sığdıran kamera mesafesi */
function sigmaMesafesi(R, pay) {
const vFov = Math.atan(Math.tan((camera.fov * DEG) / 2) * serbestAlan().oran);
const hFov = Math.atan(Math.tan((camera.fov * DEG) / 2) * camera.aspect);
return (R / Math.tan(Math.min(vFov, hFov))) * pay; // dar olan eksen belirleyici
}
function computeHomeDistance() {
const CASE_R_HOME = 5.4; // kasa yarıçapı (CASE_R sabitinin ev değeri)
/* %26 nefes payı: %16'da en dıştaki çarklar kadraj kenarına dayanıyordu. */
return sigmaMesafesi(CASE_R_HOME, 1.26);
}
function resetCameraHome(immediate = false) {
kadrajiKaydir();
const d = computeHomeDistance();
CAM_HOME.set(0, 0.75 * (d / 15.2), d);
if (immediate) {
camera.position.copy(CAM_HOME);
camera.lookAt(CAM_TARGET);
}
return CAM_HOME;
}
resetCameraHome(true);
buildMaterials();
/* --- Çevre (metal yansımaları) --- */
const pmrem = new THREE.PMREMGenerator(renderer);
pmrem.compileEquirectangularShader();
const envRT = pmrem.fromScene(new RoomEnvironment(), 0.04);
scene.environment = envRT.texture;
/* ============================================================
4. IŞIK — sinematik
============================================================ */
const lights = new THREE.Group();
scene.add(lights);
const keyLight = new THREE.SpotLight(0xffe3b0, 190, 34, 0.62, 0.45, 1.6);
keyLight.position.set(5.2, 8.6, 7.0);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(2048, 2048);
keyLight.shadow.camera.near = 1;
keyLight.shadow.camera.far = 40;
keyLight.shadow.bias = -0.0009;
keyLight.target.position.set(0, 0, 0);
lights.add(keyLight, keyLight.target);
const rimLight = new THREE.SpotLight(0x7fb4ff, 120, 34, 0.8, 0.6, 1.5);
rimLight.position.set(-7.2, 4.0, -6.4);
rimLight.target.position.set(0, 0, 0);
lights.add(rimLight, rimLight.target);
const fillLight = new THREE.PointLight(0xff9b4d, 26, 16, 2);
fillLight.position.set(-3.4, -3.2, 5.4);
lights.add(fillLight);
/* Alt dolgu ışığı: sarkaç topuzuna değil, kasanın alt iç yüzüne vurur. */
const underGlow = new THREE.PointLight(0xb8762a, 5.5, 7, 2.4);
underGlow.position.set(0, -3.2, -0.4);
lights.add(underGlow);
const ambient = new THREE.AmbientLight(0x2a1f14, 0.9);
scene.add(ambient);
/* ============================================================
5. KASA — cam arkasında mekanizma
============================================================ */
const clockRoot = new THREE.Group();
scene.add(clockRoot);
const CASE_R = 5.4;
const caseGroup = new THREE.Group();
clockRoot.add(caseGroup);
/* arka plaka */
const backPlate = new THREE.Mesh(
new THREE.CylinderGeometry(CASE_R, CASE_R, 0.34, 96),
new THREE.MeshPhysicalMaterial({
color: 0x12100d, metalness: 0.9, roughness: 0.42,
clearcoat: 0.3,
})
);
backPlate.rotation.x = Math.PI / 2;
backPlate.position.z = -1.55;
backPlate.receiveShadow = true;
caseGroup.add(backPlate);
/* arka plakada dekoratif halka */
const plateRing = new THREE.Mesh(
new THREE.TorusGeometry(CASE_R * 0.82, 0.055, 12, 128),
MAT.bronze
);
plateRing.position.z = -1.36;
caseGroup.add(plateRing);
/* dış çember / kasa halkası */
const bezel = new THREE.Mesh(
new THREE.TorusGeometry(CASE_R, 0.36, 22, 128),
MAT.gold
);
bezel.castShadow = true;
caseGroup.add(bezel);
const bezelInner = new THREE.Mesh(
new THREE.TorusGeometry(CASE_R - 0.32, 0.09, 12, 128),
MAT.goldPolished
);
caseGroup.add(bezelInner);
/* cam kapak */
const glassDome = new THREE.Mesh(
new THREE.SphereGeometry(CASE_R * 0.995, 64, 32, 0, TAU, 0, Math.PI * 0.42),
MAT.glass
);
glassDome.rotation.x = -Math.PI / 2;
glassDome.position.z = 0.98;
glassDome.renderOrder = 3;
clockRoot.add(glassDome);
/* kasayı tutan ayaklar */
for (let i = 0; i < 4; i++) {
const a = (i / 4) * TAU + Math.PI / 4;
const leg = new THREE.Mesh(
new THREE.CylinderGeometry(0.09, 0.13, 1.5, 12),
MAT.bronze
);
leg.position.set(Math.cos(a) * CASE_R * 0.92, Math.sin(a) * CASE_R * 0.92, -1.2);
leg.rotation.x = Math.PI / 2;
caseGroup.add(leg);
const ball = new THREE.Mesh(new THREE.SphereGeometry(0.17, 20, 16), MAT.gold);
ball.position.set(Math.cos(a) * CASE_R * 0.92, Math.sin(a) * CASE_R * 0.92, -1.95);
caseGroup.add(ball);
}
/* ============================================================
6. DİŞLİ TRENİ — birbirine bağlı, hız oranları fiziksel
============================================================ */
/* Modül, kasa çapına göre TÜRETİLİR — sabit vermiyoruz ki dişli treni
her zaman kasanın içinde kalsın. En büyük çark 44 diş, hatve yarıçapı
MODULE*44/2 olur; bunun kasa yarıçapının %42'sini aşmaması istenir. */
const MAX_TEETH = 44;
const MODULE = (CASE_R * 0.34 * 2) / MAX_TEETH; // ≈ 0.083 — tren kasaya rahat sığar
const gearField = new THREE.Group();
gearField.position.z = -0.15;
caseGroup.add(gearField);
const gears = []; // her dişli: { group, mesh, teeth, pitchRadius, base, omega, explodeDir }
/* ------------------------------------------------------------
placeGear — bir dişliyi, kendisinden ÖNCE eklenmiş (ebeveyn)
bir dişliye kavrayacak biçimde yerleştirir.
Merkez uzaklığı = r1 + r2 (aynı modülde kavrama koşulu).
------------------------------------------------------------ */
let gearSeq = 0;
function placeGear(teeth, parentIndex, angleDeg, opts = {}) {
const pitchRadius = (MODULE * teeth) / 2;
const mesh = makeGear(teeth, MODULE, opts);
mesh.name = opts.name || `disli_${gearSeq++}`;
const group = new THREE.Group();
group.name = `mil_${mesh.name}`;
group.add(mesh);
addAxleAndNut(group, pitchRadius, mesh.userData.thickness, opts.axleMat || MAT.steel);
gearField.add(group);
const g = {
group, mesh, teeth, pitchRadius,
omega: 0, // rad/sn (işaretli)
base: new THREE.Vector3(),
explodeDir: new THREE.Vector3(),
parentIndex: parentIndex ?? null,
phase: 0,
};
if (parentIndex === null || parentIndex === undefined) {
g.base.set(0, 0, opts.z ?? 0);
} else {
const prev = gears[parentIndex];
/* Kavrama mesafesi: hatve yarıçapları toplamı. Aynı modülde zaten
r1+r2 = (z1+z2)/2 × m'dir — iki yazım eşdeğer; burada niyeti
açık etmek için hatve biçiminde yazıldı. Dişler artık gerçekten
birbirine GİRER (addendum = m, kök boşluğu 1.25m). */
const dist = (prev.teeth + teeth) / 2 * MODULE;
const a = angleDeg * DEG;
g.base.set(
prev.base.x + Math.cos(a) * dist,
prev.base.y + Math.sin(a) * dist,
opts.z ?? prev.base.z
);
}
group.position.copy(g.base);
gears.push(g);
return gears.length - 1;
}
/* ------------------------------------------------------------
DİŞLİ TRENİ
Merkez çark → 5 ara dişli → 3 ikinci kademe → 2 üçüncü kademe
Hepsi GERÇEK kavrama geometrisiyle (r1+r2 uzaklık) yerleşir.
------------------------------------------------------------ */
const g0 = placeGear(MAX_TEETH, null, 0, {
name: 'ana_cark',
thickness: 0.16, spokeCount: 5, hubRatio: 0.30, holeRadius: 0.105,
material: MAT.gold, z: 0,
});
const sats = [
{ teeth: 22, ang: 168, name: 'ara_a', opts: { material: MAT.bronze, z: 0.05, spokeCount: 4, holeRadius: 0.13, hubRatio: 0.32 } },
{ teeth: 30, ang: 74, name: 'ara_b', opts: { material: MAT.copper, z: 0.10, spokeCount: 5, holeRadius: 0.115, hubRatio: 0.30 } },
{ teeth: 16, ang: 123, name: 'ara_c', opts: { material: MAT.gold, z: 0.02, hubRatio: 0.34 } },
{ teeth: 26, ang: 330, name: 'ara_d', opts: { material: MAT.bronze, z: 0.08, spokeCount: 4, holeRadius: 0.13, hubRatio: 0.32 } },
{ teeth: 18, ang: 215, name: 'ara_e', opts: { material: MAT.steel, z: 0.04, hubRatio: 0.34 } },
];
const satIdx = sats.map(s => placeGear(s.teeth, g0, s.ang, { ...s.opts, name: s.name }));
const secondStage = [
{ parent: satIdx[0], teeth: 12, ang: 221, name: 'kademe2_a', opts: { material: MAT.goldPolished, z: 0.30, hubRatio: 0.32 } },
{ parent: satIdx[2], teeth: 13, ang: 132, name: 'kademe2_b', opts: { material: MAT.copper, z: 0.26, hubRatio: 0.32 } },
{ parent: satIdx[3], teeth: 17, ang: 50, name: 'kademe2_c', opts: { material: MAT.bronze, z: 0.32, spokeCount: 0, hubRatio: 0.32 } },
];
const secondIdx = secondStage.map(s => placeGear(s.teeth, s.parent, s.ang, { ...s.opts, name: s.name }));
/* üçüncü kademe — ebeveyni doğrudan ikinci kademedir */
const thirdStage = [
{ parent: secondIdx[0], teeth: 9, ang: 270, name: 'kademe3_a', opts: { material: MAT.steel, z: 0.46, hubRatio: 0.36 } },
{ parent: secondIdx[2], teeth: 11, ang: 85, name: 'kademe3_b', opts: { material: MAT.gold, z: 0.54, hubRatio: 0.36 } },
];
const thirdIdx = thirdStage.map(s => placeGear(s.teeth, s.parent, s.ang, { ...s.opts, name: s.name }));
/* ============================================================
6b. KAÇIŞ ÇARKI — ana çarka kavrar
★28.10 Market hazırlığı: eskiden 270°'de ara_d ile üst üste duruyor ve hızı elle
(-BASE_OMEGA × 7.5) veriliyordu → dişleri ana çarkın içinden geçiyordu. Artık
boş bir açıda ve hızı/fazı diğer çarklar gibi kavrama oranından gelir.
Açılar ölçülerek seçildi: bağlı olmayan iki çark arası en az ~0.27 birim,
hepsi kasanın 0.88R'si içinde, sarkaç koridoru boş.
============================================================ */
const escapeWheel = placeGear(15, g0, 25, {
name: 'escape_wheel', material: MAT.goldPolished, z: 0.44, hubRatio: 0.30,
});
/* ------------------------------------------------------------
Dönüş hızları — fiziksel kavrama oranı
w_çocuk = -w_ebeveyn * (z_ebeveyn / z_çocuk)
------------------------------------------------------------ */
const BASE_OMEGA = TAU / 72; // ana çark 72 saniyede bir tur
gears.forEach((g, i) => {
if (g.parentIndex === null) {
g.omega = BASE_OMEGA;
g.phase = 0;
return;
}
const parent = gears[g.parentIndex];
g.omega = -parent.omega * (parent.teeth / g.teeth);
/* Dişlerin birbirine geçmesi: çocuk çark, ebeveyne bakan yönde
yarım diş kaydırılır. Ebeveynin o kavrama açısındaki fazı hesaplanır. */
/* KAVRAMA FAZI — diş dişe geçsin diye.
Çocuk çarkın, ebeveyne bakan yönünde (contact açısı) bir DİŞ değil
bir BOŞLUK gelmelidir. Ebeveynin o açıdaki fazı ölçülüp çocuğun
fazı yarım hatve kaydırılır. Bu satır olmadan dişler üst üste biner
(ekranda "birbirine girmemiş" görüntünün ikinci nedeni). */
const dx = g.base.x - parent.base.x;
const dy = g.base.y - parent.base.y;
const contact = Math.atan2(dy, dx);
// ebeveynin kavrama noktasındaki diş fazı (0 = diş ortası, 1 = boşluk ortası)
const parentPhaseAtContact =
(parent.phase * parent.teeth / TAU) + (contact / (TAU / parent.teeth)) + 0.5;
// çocuk, ebeveynin dişinin karşısına BOŞLUK getirecek şekilde döner
// NUMEX-DÜZELTME (2026-10-27): teeth bu kapsamda tanımlı değil; çocuk çarkın diş sayısı g.teeth. ReferenceError tüm sahneyi düşürüyordu.
const childOffset = -(parentPhaseAtContact % 1) * (TAU / g.teeth);
g.phase = contact + Math.PI + childOffset;
});
/* ============================================================
7. KADRAN — akrep, yelkovan, saniye
============================================================ */
const dialGroup = new THREE.Group();
dialGroup.position.z = 0.92;
dialGroup.visible = false; // başlangıçta mekanizma modu
clockRoot.add(dialGroup);
const dialR = CASE_R * 0.86;
/* kadran yüzü */
const dialFace = new THREE.Mesh(
new THREE.CircleGeometry(dialR, 96),
new THREE.MeshPhysicalMaterial({
color: 0x0b0906, metalness: 0.25, roughness: 0.68,
clearcoat: 0.55, clearcoatRoughness: 0.3,
})
);
dialFace.position.z = 0.30;
dialGroup.add(dialFace);
/* dış ince halka */
const dialRing = new THREE.Mesh(new THREE.TorusGeometry(dialR * 0.985, 0.035, 10, 128), MAT.goldPolished);
dialRing.position.z = 0.34;
dialGroup.add(dialRing);
/* saat çizgileri (12 büyük, 60 ince) */
const tickMat = MAT.goldPolished;
for (let i = 0; i < 60; i++) {
const major = i % 5 === 0;
const len = major ? 0.34 : 0.15;
const w = major ? 0.055 : 0.022;
const tick = new THREE.Mesh(new THREE.BoxGeometry(w, len, 0.03), tickMat);
const a = (i / 60) * TAU;
const r = dialR * 0.9;
tick.position.set(Math.cos(a) * r, Math.sin(a) * r, 0.36);
tick.rotation.z = a - Math.PI / 2;
dialGroup.add(tick);
}
/* Saat rakamları — 12/3/6/9 için gerçek rakam geometrisi.
Her rakam, kalınlığı sabit parçalardan (segment) kurulur; böylece
yazı tipi yüklemeden, tamamen kodla okunur rakam elde ederiz. */
const numeralGroup = new THREE.Group();
numeralGroup.position.z = 0.375;
dialGroup.add(numeralGroup);
/* ------------------------------------------------------------
Saat rakamları — Canvas 2D ile yazı tipinden çizilip dokuya kaplanır.
Segment yaklaşımı (çubuklardan rakam) bırakıldı: "12" okunmuyor,
rakamlar perspektifte esniyordu. Burada rakam gerçek bir yazı
tipiyle, kendi tuvaline çizilir; harici dosya/doku YOK.
------------------------------------------------------------ */
function makeNumeralSprite(label, sizeUnits, opacity = 0.95) {
const S = 256; // tuval çözünürlüğü
const cv = document.createElement('canvas');
cv.width = cv.height = S;
const ctx = cv.getContext('2d');
ctx.clearRect(0, 0, S, S);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
/* altın gradyan dolgu + ince koyu kontur: metal kabartma hissi */
const grad = ctx.createLinearGradient(0, S * 0.2, 0, S * 0.85);
grad.addColorStop(0, '#f7e0ab');
grad.addColorStop(0.45, '#d9ae62');
grad.addColorStop(1, '#8a5f27');
ctx.fillStyle = grad;
ctx.strokeStyle = 'rgba(40,24,8,0.55)';
ctx.lineWidth = S * 0.018;
const twoDigits = String(label).length > 1;
ctx.font = `600 ${S * (twoDigits ? 0.62 : 0.72)}px Georgia, "Times New Roman", serif`;
ctx.strokeText(String(label), S / 2, S / 2);
ctx.fillText(String(label), S / 2, S / 2);
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 8;
const mat = new THREE.MeshBasicMaterial({
map: tex, transparent: true, depthWrite: false,
opacity,
});
const mesh = new THREE.Mesh(
new THREE.PlaneGeometry(sizeUnits, sizeUnits),
mat
);
return mesh;
}
/* Tüm 12 saat işaretli — 12/3/6/9 büyük ve altın, aradakiler küçük ve sönük.
Saat yönü (ekran düzlemi, +y yukarı):
12 → tepe = +π/2 · 3 → sağ = 0 · 6 → alt = -π/2 · 9 → sol = π */
const DIAL_HOURS = [
[12, 0, true], [1, 1, false], [2, 2, false], [3, 3, true],
[4, 4, false], [5, 5, false], [6, 6, true], [7, 7, false],
[8, 8, false], [9, 9, true], [10, 10, false], [11, 11, false],
];
DIAL_HOURS.forEach(([label, hour, major]) => {
const a = Math.PI / 2 - (hour / 12) * TAU;
const r = dialR * (major ? 0.745 : 0.775);
const size = major ? (label > 9 ? 0.64 : 0.46) : (label > 9 ? 0.34 : 0.26);
const num = makeNumeralSprite(label, size, major ? 0.95 : 0.55);
num.position.set(Math.cos(a) * r, Math.sin(a) * r, 0.02);
numeralGroup.add(num);
});
/* marka yazısı yeri: küçük altın plaka */
const plaque = new THREE.Mesh(
new THREE.CylinderGeometry(0.24, 0.24, 0.02, 32),
MAT.goldPolished
);
plaque.position.set(0, -dialR * 0.45, 0.37);
dialGroup.add(plaque);
/* --- Kollar --- */
function makeHand(length, width, tail, mat, thick = 0.055) {
const shape = new THREE.Shape();
// iğ biçimli kol: tabanda geniş, uçta sivri
shape.moveTo(-width, -tail);
shape.lineTo(width, -tail);
shape.lineTo(width * 0.55, length * 0.72);
shape.lineTo(0, length);
shape.lineTo(-width * 0.55, length * 0.72);
shape.closePath();
const geo = new THREE.ExtrudeGeometry(shape, {
depth: thick, bevelEnabled: true, bevelThickness: 0.008, bevelSize: 0.008, bevelSegments: 2,
});
const hand = new THREE.Mesh(geo, mat);
hand.castShadow = true;
return hand;
}
const hourHand = makeHand(dialR * 0.50, 0.075, 0.16, MAT.goldPolished, 0.06);
hourHand.position.z = 0.42;
dialGroup.add(hourHand);
const minuteHand = makeHand(dialR * 0.76, 0.055, 0.20, MAT.gold, 0.055);
minuteHand.position.z = 0.50;
dialGroup.add(minuteHand);
const secondHand = makeHand(dialR * 0.84, 0.018, 0.26, MAT.ruby, 0.035);
secondHand.position.z = 0.57;
dialGroup.add(secondHand);
/* merkez göbek */
const centerCap = new THREE.Mesh(new THREE.SphereGeometry(0.14, 24, 16), MAT.goldPolished);
centerCap.position.z = 0.62;
centerCap.scale.z = 0.6;
dialGroup.add(centerCap);
const centerGem = new THREE.Mesh(new THREE.SphereGeometry(0.06, 16, 12), MAT.ruby);
centerGem.position.z = 0.68;
dialGroup.add(centerGem);
/* ============================================================
8. SARKAÇ — ritmik hareket
============================================================ */
/* Sarkaç kasanın İÇİNDE ve camın arkasında durur — dışına taşmaz. */
/* DÜZELTME (kök neden): sarkaç z=-0.95'te, ana çark z=0'daydı → sarkaç
dişli treninin ARKASINDA kalıyor, kadrajda gölgeye gömülüyordu.
Gerçek saatte sarkaç mekanizmanın önünde, ayrı bir bölmede durur.
z=+0.62: tüm çarkların önü, kadranın (z=0.92) arkası. */
const pendulumPivot = new THREE.Group();
/* Sarkaç derinliği TEK kaynaktan: animasyon döngüsü de bu sabiti kullanır.
(Eskiden döngüde -1.1 sabit yazılıydı ve buradaki değeri eziyordu.) */
/* KONUM — sarkaç, dişli treninin TAMAMEN ALINDA olmalı.
Dişli treni yarıçapı: en dış çark merkezi 1.83 + yarıçapı 0.66 ≈ 2.5.
Sarkaç pivotu y=-1.55'te; çubuk 2.2 aşağı iner → topuz y≈-3.65.
Kasa yarıçapı 5.4 olduğuna göre topuz kasanın içinde, alt boşlukta,
hiçbir çarkın önünden geçmeden salınır. (Önceki denemede pivot 4.55'teyken
topuz y=-3.4'e iniyordu ama çubuk çarkların önünden geçiyordu.) */
const PENDULUM_Z = 0.74;
/* ★28.10: pivot ana çarkın hemen altına (-0.38R); altındaki koridorda hiçbir çark yok. */
pendulumPivot.position.set(0, -CASE_R * 0.38, PENDULUM_Z);
caseGroup.add(pendulumPivot);
/* Çubuk: eski hâli yarıçap 0.035 (14px'te ~1 piksel) — koyu zeminde kayboluyordu.
Gerçek saat sarkaç çubuğu ince ama PARLAK çelik; ölçüyü büyüttük ve
malzemeyi cilalı çeliğe çektik ki ışığı yakalayıp görünsün. */
/* Çubuk, pivotun üstündeki kısa boyunduruk + aşağı inen kol olarak kurulur.
2.1 birim: pivot -1.57'de, topuz -3.67'de → kasanın alt iç kenarına (-5.4)
rahatça sığar. */
const rodLen = CASE_R * 0.40;
const rod = new THREE.Mesh(
new THREE.CylinderGeometry(0.055, 0.055, rodLen, 16),
MAT.steel
);
rod.position.y = -rodLen / 2;
rod.castShadow = true;
pendulumPivot.add(rod);
/* Topuz: çap 0.34 → 0.46. Kasanın içinde kalır. */
const bobR = 0.46;
const bob = new THREE.Mesh(
new THREE.SphereGeometry(bobR, 36, 26),
MAT.goldPolished
);
bob.scale.set(1, 1, 0.52);
bob.position.y = -rodLen + 0.10;
bob.castShadow = true;
pendulumPivot.add(bob);
const bobRing = new THREE.Mesh(new THREE.TorusGeometry(bobR * 1.02, 0.05, 12, 56), MAT.bronze);
bobRing.position.copy(bob.position);
bobRing.castShadow = true;
pendulumPivot.add(bobRing);
const pivotPivot = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.1, 0.5, 16), MAT.steel);
pivotPivot.rotation.x = Math.PI / 2;
pendulumPivot.add(pivotPivot);
/* ============================================================
9. POST-PROCESS — bloom + vinyet
============================================================ */
const composer = new EffectComposer(renderer);
composer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
composer.setSize(window.innerWidth, window.innerHeight);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.22, // strength — kasa çerçevesi bloom'la erimesin
0.42, // radius
0.96 // threshold — yalnız en parlak noktalar taşar
);
composer.addPass(bloom);
const VignetteShader = {
uniforms: {
tDiffuse: { value: null },
offset: { value: 0.92 },
darkness: { value: 1.15 },
tint: { value: new THREE.Color(0x2a1a0c) },
tintAmount: { value: 0.12 },
},
vertexShader: /* glsl */`
varying vec2 vUv;
void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }
`,
fragmentShader: /* glsl */`
uniform sampler2D tDiffuse;
uniform float offset;
uniform float darkness;
uniform vec3 tint;
uniform float tintAmount;
varying vec2 vUv;
void main(){
vec4 c = texture2D(tDiffuse, vUv);
vec2 uv = (vUv - 0.5) * vec2(offset);
float d = dot(uv, uv);
c.rgb *= 1.0 - d * darkness * 0.85;
c.rgb = mix(c.rgb, tint, tintAmount * smoothstep(0.0, 0.6, d));
gl_FragColor = c;
}
`,
};
const vignette = new ShaderPass(VignetteShader);
composer.addPass(vignette);
composer.addPass(new OutputPass());
/* ============================================================
10. KAMERA KONTROLÜ
============================================================ */
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.copy(CAM_TARGET);
controls.enableDamping = true;
controls.dampingFactor = 0.055;
controls.rotateSpeed = 0.7;
controls.zoomSpeed = 0.75;
controls.minDistance = 4.6;
/* ★28.10: sabit 20, ev mesafesini kesip kasayı panelin altına itiyordu → ev mesafesine göre. */
controls.maxDistance = Math.max(20, CAM_HOME.length() * 1.5);
controls.maxPolarAngle = Math.PI * 0.86;
controls.minPolarAngle = Math.PI * 0.06;
controls.enablePan = false;
controls.autoRotate = false;
/* ============================================================
11. ZAMAN DURUMU
============================================================ */
const timeConfig = {
speed: 1, // 1, 0.15, 0, 6
targetSpeed: 1,
clockAngle: 0, // mekanizmanın toplam dönüş fazı (rad)
simTime: 0, // simülasyon saati (saniye)
};
/* gerçek saatten başlangıç fazı */
const now = new Date();
const startSeconds =
now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds() + now.getMilliseconds() / 1000;
/* ============================================================
12. PATLAT / BİRLEŞTİR
============================================================ */
const explodeState = { t: 0, target: 0, active: false };
/* her dişli için merkezden dışa doğru yön */
gears.forEach((g, i) => {
const dir = new THREE.Vector3(g.base.x, g.base.y, 0);
if (dir.lengthSq() < 0.01) dir.set(0, 0, 1);
dir.normalize();
// katmanına göre z derinliği
const layerZ = g.base.z * 1.6;
g.explodeDir.set(dir.x * 1.55, dir.y * 1.55, 0.35 + layerZ);
});
pendulumPivot.userData.explodeDir = new THREE.Vector3(-2.4, -1.0, -1.4);
dialGroup.userData.explodeDir = new THREE.Vector3(0, 0, 7.2);
glassDome.userData.explodeDir = new THREE.Vector3(0, 0, 9.6);
/* ============================================================
13. ARAYÜZ BAĞLANTISI
============================================================ */
const ui = {
segBtns: [...document.querySelectorAll('.seg-btn[data-speed]')],
explodeBtn: document.getElementById('explode-btn'),
explodeTxt: document.getElementById('explode-txt'),
viewMech: document.getElementById('view-mech'),
viewDial: document.getElementById('view-dial'),
resetCam: document.getElementById('reset-cam'),
tourBtn: document.getElementById('tour-btn'),
hh: document.getElementById('clock-hh'),
mm: document.getElementById('clock-mm'),
ss: document.getElementById('clock-ss'),
mode: document.getElementById('clock-mode'),
loader: document.getElementById('loader'),
fill: document.getElementById('loader-fill'),
hint: document.getElementById('loader-hint'),
};
function setSpeed(v, btn) {
timeConfig.targetSpeed = v;
ui.segBtns.forEach(b => {
const on = b === btn;
b.classList.toggle('is-active', on);
b.setAttribute('aria-pressed', String(on));
});
ui.mode.textContent =
v === 0 ? 'zaman durdu' :
v === 0.15 ? 'ağır çekim' :
v === 6 ? 'hızlandırılmış' : 'gerçek zaman';
}
ui.segBtns.forEach(btn => {
btn.addEventListener('click', () => setSpeed(Number(btn.dataset.speed), btn));
});
function toggleExplode() {
explodeState.active = !explodeState.active;
explodeState.target = explodeState.active ? 1 : 0;
ui.explodeBtn.classList.toggle('is-active', explodeState.active);
ui.explodeBtn.setAttribute('aria-pressed', String(explodeState.active));
ui.explodeTxt.textContent = explodeState.active ? 'Birleştir' : 'Parçalara Ayır';
/* Parçalanmış sahne kadraja sığmaz: kamera mekanizma mesafesine çekilir.
Kadran modundaysak ve kullanıcı parçalıyorsa kadran görünür kalır —
o durumda da geniş açı gerekir. */
const fitDist = sigmaMesafesi(CASE_R * 1.25, 1.05); // parçalar kasanın ~1.2R ötesine açılır
flyCamera(
new THREE.Vector3(CAM_HOME.x, CAM_HOME.y + 0.3, explodeState.active ? fitDist : CAM_HOME.z),
CAM_TARGET.clone(),
1.1
);
}
ui.explodeBtn.addEventListener('click', toggleExplode);
let viewMode = 'mech';
function setView(mode) {
viewMode = mode;
const isDial = mode === 'dial';
ui.viewMech.classList.toggle('is-active', !isDial);
ui.viewDial.classList.toggle('is-active', isDial);
ui.viewMech.setAttribute('aria-pressed', String(!isDial));
ui.viewDial.setAttribute('aria-pressed', String(isDial));
// kadran modunda sarkaç geriye çekilir, kamera kadrana döner
if (isDial) {
/* Kadran: tüm yüzü görecek mesafe — kasa yarıçapından türetilir,
sabit sayı vermiyoruz ki mercek/kasa değişince kadraj kaymasın.
mesafe = (yarıçap / tan(fov/2)) * 1.18 pay */
const dist = sigmaMesafesi(CASE_R, 1.18);
flyCamera(new THREE.Vector3(0, 0, dist), new THREE.Vector3(0, 0, 0.6));
bloom.strength = 0.20;
/* Dolgu ışığı kadranın önüne düşüp sol altta turuncu leke bırakıyordu. */
fillLight.intensity = 6;
} else {
flyCamera(CAM_HOME.clone(), CAM_TARGET.clone());
bloom.strength = 0.22;
fillLight.intensity = 26;
}
}
ui.viewMech.addEventListener('click', () => setView('mech'));
ui.viewDial.addEventListener('click', () => setView('dial'));
/* yumuşak kamera geçişi */
const camAnim = { active: false, t: 0, fromPos: new THREE.Vector3(), toPos: new THREE.Vector3(), fromTgt: new THREE.Vector3(), toTgt: new THREE.Vector3() };
function flyCamera(toPos, toTgt, dur = 1.4) {
camAnim.active = true;
camAnim.t = 0;
camAnim.dur = dur;
camAnim.fromPos.copy(camera.position);
camAnim.toPos.copy(toPos);
camAnim.fromTgt.copy(controls.target);
camAnim.toTgt.copy(toTgt);
}
ui.resetCam.addEventListener('click', () => {
tour.active = false;
flyCamera(CAM_HOME.clone(), CAM_TARGET.clone(), 1.2);
});
/* sinematik tur */
const tour = { active: false, t: 0 };
ui.tourBtn.addEventListener('click', () => {
tour.active = !tour.active;
tour.t = 0;
ui.tourBtn.classList.toggle('is-active', tour.active);
});
/* klavye */
window.addEventListener('keydown', (e) => {
if (e.code === 'Space') {
e.preventDefault();
const stopped = timeConfig.targetSpeed === 0;
setSpeed(stopped ? 1 : 0, ui.segBtns.find(b => Number(b.dataset.speed) === (stopped ? 1 : 0)));
} else if (e.key === 'e' || e.key === 'E') {
toggleExplode();
} else if (e.key === 'r' || e.key === 'R') {
ui.resetCam.click();
} else if (e.key === '1') {
setSpeed(1, ui.segBtns.find(b => Number(b.dataset.speed) === 1));
} else if (e.key === '2') {
setSpeed(0.15, ui.segBtns.find(b => Number(b.dataset.speed) === 0.15));
} else if (e.key === '3') {
setSpeed(6, ui.segBtns.find(b => Number(b.dataset.speed) === 6));
}
});
/* ============================================================
14. ANİMASYON DÖNGÜSÜ
============================================================ */
const clock = new THREE.Clock();
/* patlatma sırasında orijinal konumları koru */
const originalZ = gears.map(g => g.base.z);
function updateGears(dt) {
// yumuşak hız geçişi
timeConfig.speed = lerp(timeConfig.speed, timeConfig.targetSpeed, 1 - Math.pow(0.001, dt));
timeConfig.simTime += dt * timeConfig.speed;
const secs = startSeconds + timeConfig.simTime;
/* Dişliler: faz + hız * simülasyon zamanı.
Grup dönmez (mil/montaj sabit kalır), yalnız çark gövdesi döner. */
gears.forEach((g) => {
g.mesh.rotation.z = g.phase + g.omega * timeConfig.simTime;
});
// kadran kolları — gerçek zaman
const hAngle = ((secs / 43200) % 1) * TAU;
const mAngle = ((secs / 3600) % 1) * TAU;
const sAngle = ((secs / 60) % 1) * TAU;
/* Kol şekli +Y yönüne (12) uzanıyor; saat yönü için negatif.
★28.10: eskiden "+ Math.PI / 2" ekleniyordu → kollar 90° (3 saat / 15 dk) geride; dijital 00:39 iken
akrep 9'da, yelkovan 24. dakikadaydı. Rakamlar 12 tepede dizildiği için kaydırma gerekmez. */
hourHand.rotation.z = -hAngle;
minuteHand.rotation.z = -mAngle;
secondHand.rotation.z = -sAngle;
/* SARKAÇ — KAÇIŞ ÇARKINDAN TÜRETİLİR (bağımsız sinüs değil).
Gerçek saatte sarkaç kaçış çarkını kilitler; ikisi aynı fazdan beslenir.
Kaçış çarkı bir tam diş attığında sarkaç bir yarım salınım yapar.
Böylece mekanizma ile sarkaç gözle de aynı ritimde görünür. */
const escGear = gears[escapeWheel];
const escPhase = escGear.mesh.rotation.z;
/* Kaçış çarkının açısal konumu doğrudan salınım açısına çevrilir:
diş sayısı kadar adım → tam salınım. Genlik 22° (gerçek sarkaç ~±11°
ama görsel okunabilirlik için abartıldı).
★28.10: çapa (anchor) kaçışında bir tam salınımda bir diş geçer → çarpan
teeth. Kaçış çarkı artık gerçek oranla döndüğü için periyot ≈ 1,6 sn. */
const swing = Math.sin(escPhase * escGear.teeth) * 22 * DEG;
pendulumPivot.rotation.z = swing;
// dijital okuma
const hh = Math.floor((secs / 3600) % 24);
const mm = Math.floor((secs / 60) % 60);
const ss = Math.floor(secs % 60);
ui.hh.textContent = String(hh).padStart(2, '0');
ui.mm.textContent = String(mm).padStart(2, '0');
ui.ss.textContent = String(ss).padStart(2, '0');
}
function updateExplode(dt) {
if (Math.abs(explodeState.t - explodeState.target) < 0.0005) {
explodeState.t = explodeState.target;
} else {
explodeState.t = lerp(explodeState.t, explodeState.target, 1 - Math.pow(0.0009, dt * 1.6));
}
const t = easeInOut(clamp(explodeState.t, 0, 1));
gears.forEach((g, i) => {
g.group.position.x = g.base.x + g.explodeDir.x * t;
g.group.position.y = g.base.y + g.explodeDir.y * t;
g.group.position.z = originalZ[i] + g.explodeDir.z * t;
});
// NUMEX-DÜZELTME (2026-10-27): Her karede sarkaç z'si -1.1'e sabitleniyordu; kurulumda verilen 0.74 değeri eziliyor ve sarkaç görünmüyordu.
/* DÜZELTME (kök neden): bu satır her karede sarkacın z'sini -1.1'e
sabitliyordu — kurulumda verilen görünür derinlik (0.74) ilk karede
eziliyor, sarkaç dişli treninin en arkasına düşüyordu (ekranda yok).
Artık kurulumdaki z korunuyor, yalnız patlatma ofseti ekleniyor. */
pendulumPivot.position.z = PENDULUM_Z + pendulumPivot.userData.explodeDir.z * t;
pendulumPivot.position.x = pendulumPivot.userData.explodeDir.x * t;
dialGroup.position.z = 0.92 + dialGroup.userData.explodeDir.z * t;
glassDome.position.z = 0.98 + glassDome.userData.explodeDir.z * t;
glassDome.scale.setScalar(1 + t * 0.06);
/* Ayrıkken çarklar hafifçe kendi eksenlerinde yatar (canlı görünür). */
gears.forEach((g) => {
g.mesh.rotation.y = t * 0.22;
});
/* Kadran yalnızca kadran modunda görünür. Mekanizma modunda
parçalarken kadran ekranı kapatmasın — yoksa kullanıcı çarkları
kolların arkasından izlemek zorunda kalır.
Kadran modunda ise dişli tarlası tamamen gizlenir: çarklar kadran
yüzünü kirletiyor, rakamların okunurluğunu düşürüyordu. */
const dialMode = viewMode === 'dial';
dialGroup.visible = dialMode;
gearField.visible = !dialMode;
pendulumPivot.visible = !dialMode; // sarkaç topuzu kadranın önüne sızıyordu
gearField.position.z = -0.15;
}
function updateCamera(dt) {
/* ★28.10: sis yoğunluğu sabit 0.028 iken kamera 20 biriminden uzaklaşınca (dar ekran, parçalanmış görünüm)
sahne karanlığa gömülüyordu — yoğunluk kamera uzaklığıyla ölçeklenir, 20 birime kadar eskisiyle aynı. */
scene.fog.density = 0.028 * Math.min(1, 20 / camera.position.distanceTo(controls.target));
if (camAnim.active) {
camAnim.t += dt / camAnim.dur;
const t = easeInOut(clamp(camAnim.t, 0, 1));
camera.position.lerpVectors(camAnim.fromPos, camAnim.toPos, t);
controls.target.lerpVectors(camAnim.fromTgt, camAnim.toTgt, t);
if (camAnim.t >= 1) camAnim.active = false;
}
if (tour.active && !camAnim.active) {
tour.t += dt * 0.18;
const r = 13.0 + Math.sin(tour.t * 0.7) * 2.2;
const a = tour.t;
camera.position.set(
Math.cos(a) * r,
1.6 + Math.sin(tour.t * 0.9) * 2.6,
Math.sin(a) * r
);
controls.target.lerp(new THREE.Vector3(0, 0.2, 0), 0.05);
}
controls.update();
}
function animate() {
const dt = Math.min(clock.getDelta(), 0.05);
updateGears(dt);
updateExplode(dt);
updateCamera(dt);
composer.render();
requestAnimationFrame(animate);
}
/* ============================================================
15. BOYUTLANDIRMA
============================================================ */
function onResize() {
const w = window.innerWidth, h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
composer.setSize(w, h);
/* Ekran oranı değişti → ev kamerası mesafesi yeniden hesaplanır
(dikey ekranda kasa kesilmesin). Kullanıcı sahneyi döndürdüyse
kamerasına dokunulmaz, yalnız referans noktası güncellenir. */
const near = camera.position.distanceTo(controls.target);
const oldDist = CAM_HOME.length();
resetCameraHome(false);
const newDist = CAM_HOME.length();
controls.maxDistance = Math.max(20, newDist * 1.5);
if (Math.abs(near - oldDist) < 0.6 && !camAnim.active) {
const dir = camera.position.clone().sub(controls.target).normalize();
camera.position.copy(controls.target).addScaledVector(dir, newDist);
}
}
window.addEventListener('resize', onResize);
/* ============================================================
16. YÜKLEYİCİ
============================================================ */
function buildLoaderGears() {
const g1 = document.getElementById('lg1');
const g2 = document.getElementById('lg2');
const gearPath = (teeth, r) => {
let d = '';
for (let i = 0; i < teeth; i++) {
const a = (i / teeth) * TAU;
const step = TAU / teeth;
const pts = [
[a - step * 0.3, r * 0.86], [a - step * 0.16, r], [a + step * 0.16, r], [a + step * 0.3, r * 0.86],
];
pts.forEach(([ang, rad], k) => {
const x = 50 + Math.cos(ang) * rad;
const y = 50 + Math.sin(ang) * rad;
d += (i === 0 && k === 0 ? 'M' : 'L') + x.toFixed(2) + ' ' + y.toFixed(2) + ' ';
});
}
return d + 'Z';
};
if (g1) g1.innerHTML = ``;
if (g2) g2.innerHTML = ``;
}
const loaderSteps = [
[10, 'Mekanizma kuruluyor…'],
[35, 'Dişliler birbirine geçiriliyor…'],
[60, 'Kadran ve kollar yerleştiriliyor…'],
[82, 'Işıklar ayarlanıyor…'],
[100, 'Hazır'],
];
function runLoader(onDone) {
let i = 0;
const tick = () => {
if (i >= loaderSteps.length) {
setTimeout(() => {
ui.loader.classList.add('is-done');
onDone();
}, 380);
return;
}
const [pct, txt] = loaderSteps[i++];
ui.fill.style.width = pct + '%';
ui.hint.textContent = txt;
setTimeout(tick, 240);
};
tick();
}
/* ============================================================
17. BAŞLAT
============================================================ */
buildLoaderGears();
animate();
runLoader(() => {
// girişte sinematik yaklaşma: uzaktan sahneye
camera.position.set(0, 4.0, 24);
flyCamera(CAM_HOME.clone(), CAM_TARGET.clone(), 2.6);
});
/* vitrin bilgisi (Market meta) */
window.NUMEX_CLOCKWORK = {
name: 'NUMEX CLOCKWORK',
slogan: 'Zamanın İçini Gör',
gears: gears.length,
version: '1.0.0',
ready: true,
};