{"id":1155,"date":"2026-03-28T21:42:31","date_gmt":"2026-03-28T21:42:31","guid":{"rendered":"https:\/\/juanitocoffee.com\/?page_id=1155"},"modified":"2026-03-31T12:22:30","modified_gmt":"2026-03-31T12:22:30","slug":"recorrido-virtual-finca-juanito-coffee","status":"publish","type":"page","link":"https:\/\/juanitocoffee.com\/es\/recorrido-virtual-finca-juanito-coffee\/","title":{"rendered":"Recorrido Virtual \u2014 Finca Juanito Coffee"},"content":{"rendered":"\t\t<div data-elementor-type=\"wp-page\" data-elementor-id=\"1155\" class=\"elementor elementor-1155\" data-elementor-post-type=\"page\">\n\t\t\t\t<div class=\"elementor-element elementor-element-67a19f0 e-flex e-con-boxed e-con e-parent\" data-id=\"67a19f0\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t\t\t<div class=\"elementor-element elementor-element-d002b9e elementor-widget elementor-widget-html\" data-id=\"d002b9e\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t\t<style>\n     \/* \u2500\u2500\u2500 JUANITO COFFEE \u00b7 FIRST-PERSON VIEWER v2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 *\/\n.jc-wrap{position:fixed;inset:0;z-index:9999;font-family:'Georgia',serif;background:#0a1208;overflow:hidden}\n.jc-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;outline:none;touch-action:none}\n\n\/* Sidebar *\/\n.jc-sidebar{position:absolute;top:0;left:0;bottom:0;width:220px;background:rgba(8,18,12,0.92);backdrop-filter:blur(20px);display:flex;flex-direction:column;padding:32px 24px;border-right:1px solid rgba(200,169,110,0.2);z-index:20;pointer-events:all;overflow-y:auto;max-height:100vh}\n.jc-logo{font-size:20px;color:#fff;font-weight:normal;margin:0 0 2px;letter-spacing:.5px}\n.jc-sub{font-size:9px;letter-spacing:4px;color:#C8A96E;text-transform:uppercase;font-family:Arial,sans-serif;margin:0 0 8px}\n.jc-sub2{font-size:9px;letter-spacing:2px;color:rgba(200,169,110,0.4);font-family:Arial,sans-serif;margin:0 0 36px}\n.jc-nav-lbl{font-size:9px;letter-spacing:3px;color:rgba(200,169,110,0.5);text-transform:uppercase;font-family:Arial,sans-serif;margin:0 0 10px}\n.jc-nav{list-style:none;padding:0;margin:0;flex:1}\n.jc-nav li{margin-bottom:2px}\n.jc-btn{background:transparent!important;border:none!important;color:rgba(255,255,255,0.6)!important;font-size:15px!important;font-family:'Georgia',serif!important;cursor:pointer;padding:10px 14px;border-radius:8px;width:100%;text-align:left;transition:all .25s;display:flex!important;align-items:center;gap:10px;text-transform:none!important}\n.jc-btn:hover,.jc-btn.act{background:rgba(200,169,110,0.15)!important;color:#fff!important}\n.jc-dot{width:7px;height:7px;border-radius:50%;background:#C8A96E;opacity:.4;flex-shrink:0;transition:opacity .25s}\n.jc-btn.act .jc-dot,.jc-btn:hover .jc-dot{opacity:1}\n.jc-hint{font-size:10px;color:rgba(255,255,255,0.25);line-height:2;font-family:Arial,sans-serif;margin-top:auto;border-top:1px solid rgba(255,255,255,0.06);padding-top:16px}\n.jc-hint span{display:block}\n\n\/* Loading *\/\n.jc-loading{position:absolute;inset:0;left:0;background:#0a1208;display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:30}\n.jc-loading-logo{font-size:24px;color:#C8A96E;letter-spacing:3px;margin-bottom:4px}\n.jc-loading-sub{font-size:9px;letter-spacing:4px;color:rgba(255,255,255,0.3);text-transform:uppercase;font-family:Arial,sans-serif;margin-bottom:50px}\n.jc-loading-track{width:260px;height:2px;background:rgba(255,255,255,0.08);border-radius:1px;overflow:hidden}\n.jc-loading-bar{height:100%;width:0%;background:linear-gradient(to right,#8a6930,#C8A96E);border-radius:1px;transition:width .15s ease}\n.jc-loading-pct{font-size:10px;letter-spacing:3px;color:rgba(255,255,255,0.25);font-family:Arial,sans-serif;margin-top:12px}\n.jc-loading-status{font-size:10px;color:rgba(255,255,255,0.2);font-family:Arial,sans-serif;margin-top:6px;letter-spacing:1px}\n.jc-loading-time{font-size:9px;color:rgba(255,255,255,0.15);font-family:Arial,sans-serif;margin-top:20px;letter-spacing:.5px}\n\n\/* Enter screen *\/\n.jc-enter{position:absolute;inset:0;left:220px;display:none;flex-direction:column;align-items:center;justify-content:center;background:rgba(0,0,0,0.55);backdrop-filter:blur(6px);z-index:18;cursor:pointer}\n.jc-enter-title{color:#fff;font-size:26px;letter-spacing:1px;margin-bottom:8px;text-shadow:0 2px 20px rgba(0,0,0,0.8)}\n.jc-enter-sub{color:rgba(200,169,110,0.8);font-size:11px;letter-spacing:3px;text-transform:uppercase;font-family:Arial,sans-serif;margin-bottom:28px}\n.jc-enter-keys{color:rgba(255,255,255,0.4);font-size:11px;font-family:Arial,sans-serif;letter-spacing:1px;text-align:center;line-height:2.2;margin-bottom:32px}\n.jc-enter-keys kbd{background:rgba(255,255,255,0.12);border:1px solid rgba(255,255,255,0.2);border-radius:4px;padding:2px 7px;font-family:monospace;font-size:11px}\n.jc-enter-btn{padding:13px 36px;border:1px solid rgba(200,169,110,0.7);color:#C8A96E;background:transparent;font-family:'Georgia',serif;font-size:15px;cursor:pointer;border-radius:4px;transition:all .3s;letter-spacing:1px}\n.jc-enter-btn:hover{background:rgba(200,169,110,0.18);color:#fff;border-color:#C8A96E}\n\n\/* Crosshair *\/\n.jc-crosshair{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);color:rgba(255,255,255,0.7);font-size:18px;font-weight:100;pointer-events:none;display:none;z-index:22;user-select:none;text-shadow:0 0 8px rgba(0,0,0,0.8)}\n\n\/* Location name *\/\n.jc-name{position:absolute;top:24px;right:28px;color:rgba(255,255,255,0.18);font-size:36px;pointer-events:none;z-index:21;display:none;letter-spacing:-1px;text-shadow:0 2px 20px rgba(0,0,0,0.5);transition:opacity .3s}\n\n\/* Hotspot label floating in 3D *\/\n.jc-label{position:absolute;background:rgba(8,18,12,0.88);border:1px solid rgba(200,169,110,0.5);color:#fff;font-size:12px;font-family:Arial,sans-serif;letter-spacing:1px;padding:6px 12px;border-radius:20px;pointer-events:auto;cursor:pointer;display:none;white-space:nowrap;transform:translate(-50%,-100%);z-index:22;transition:opacity .2s;backdrop-filter:blur(10px)}\n.jc-label:hover{background:rgba(200,169,110,0.25);border-color:#C8A96E;transform:translate(-50%,-110%)}\n.jc-label-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#C8A96E;margin-right:6px;vertical-align:middle}\n\n\/* Controls hint while locked *\/\n.jc-controls-hint{position:absolute;bottom:20px;right:28px;color:rgba(255,255,255,0.2);font-size:10px;font-family:Arial,sans-serif;letter-spacing:1px;pointer-events:none;display:none;line-height:1.8;text-align:right;z-index:21}\n\n\/* Mobile touch indicator *\/\n.jc-touch-hint{position:absolute;bottom:20px;left:28px;color:rgba(255,255,255,0.2);font-size:10px;font-family:Arial,sans-serif;letter-spacing:1px;pointer-events:none;display:none;line-height:1.8;z-index:21}\n\n\/* Smooth transitions on hotspot teleport *\/\n.jc-fade-transition{animation:jcFadeOut .2s ease-out}\n@keyframes jcFadeOut{from{opacity:1}to{opacity:0.8}}\n<\/style>\n\n<div class=\"jc-wrap\" id=\"jcWrap\">\n  <canvas class=\"jc-canvas\" id=\"jcCanvas\" tabindex=\"0\"><\/canvas>\n\n  <!-- Loading -->\n  <div class=\"jc-loading\" id=\"jcLoading\">\n    <div class=\"jc-loading-logo\">Juanito Coffee<\/div>\n    <div class=\"jc-loading-sub\">Recorrido Virtual \u00b7 Colombia<\/div>\n    <div class=\"jc-loading-track\"><div class=\"jc-loading-bar\" id=\"jcBar\"><\/div><\/div>\n    <div class=\"jc-loading-pct\" id=\"jcPct\">0%<\/div>\n    <div class=\"jc-loading-status\" id=\"jcStatus\">Cargando modelo 3D...<\/div>\n    <div class=\"jc-loading-time\" id=\"jcLoadTime\"><\/div>\n  <\/div>\n\n  <!-- Sidebar -->\n  <div class=\"jc-sidebar\">\n    <div class=\"jc-logo\">Juanito Coffee<\/div>\n    <div class=\"jc-sub\">Recorrido Virtual<\/div>\n    <div class=\"jc-sub2\">\u00b7 Colombia<\/div>\n    <div class=\"jc-nav-lbl\">Estaciones<\/div>\n    <ul class=\"jc-nav\">\n      <li><button class=\"jc-btn act\" id=\"btn-0\" onclick=\"jcGo(0)\"><span class=\"jc-dot\"><\/span>La Casa<\/button><\/li>\n      <li><button class=\"jc-btn\" id=\"btn-1\" onclick=\"jcGo(1)\"><span class=\"jc-dot\"><\/span>El Cafetal<\/button><\/li>\n      <li><button class=\"jc-btn\" id=\"btn-2\" onclick=\"jcGo(2)\"><span class=\"jc-dot\"><\/span>Beneficio<\/button><\/li>\n      <li><button class=\"jc-btn\" id=\"btn-3\" onclick=\"jcGo(3)\"><span class=\"jc-dot\"><\/span>Secado<\/button><\/li>\n    <\/ul>\n    <div class=\"jc-hint\">\n      <span>\ud83d\udc71 Clic para explorar<\/span>\n      <span><kbd style=\"font-family:monospace;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);border-radius:3px;padding:0 4px\">W A S D<\/kbd> Moverse<\/span>\n      <span>Mouse \u00b7 Mirar alrededor<\/span>\n      <span><kbd style=\"font-family:monospace;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);border-radius:3px;padding:0 4px\">Q<\/kbd> <kbd style=\"font-family:monospace;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);border-radius:3px;padding:0 4px\">E<\/kbd> Subir \/ Bajar<\/span>\n      <span>Shift \u00b7 Correr<\/span>\n      <span>ESC \u00b7 Pausar<\/span>\n    <\/div>\n  <\/div>\n\n  <!-- Enter first-person -->\n  <div class=\"jc-enter\" id=\"jcEnter\">\n    <div class=\"jc-enter-title\">Finca Juanito Coffee<\/div>\n    <div class=\"jc-enter-sub\">Recorrido en Primera Persona<\/div>\n    <div class=\"jc-enter-keys\">\n      <kbd>W<\/kbd><kbd>A<\/kbd><kbd>S<\/kbd><kbd>D<\/kbd> o <kbd>\u2191\u2193\u2190\u2192<\/kbd> para moverse<br>\n      <kbd>Q<\/kbd> subir \u00b7 <kbd>E<\/kbd> bajar<br>\n      Mueve el <strong>mouse<\/strong> para mirar<br>\n      <kbd>Shift<\/kbd> para correr \u00b7 <kbd>ESC<\/kbd> para pausar\n    <\/div>\n    <button class=\"jc-enter-btn\" id=\"jcEnterBtn\">\u25b6 Entrar al Recorrido<\/button>\n  <\/div>\n\n  <!-- Crosshair -->\n  <div class=\"jc-crosshair\" id=\"jcCrosshair\">\uff0b<\/div>\n\n  <!-- Location name -->\n  <div class=\"jc-name\" id=\"jcName\">La Casa<\/div>\n\n  <!-- Floating hotspot labels (positioned via JS) -->\n  <div class=\"jc-label\" id=\"jcL0\" onclick=\"jcGo(0)\"><span class=\"jc-label-dot\"><\/span>La Casa<\/div>\n  <div class=\"jc-label\" id=\"jcL1\" onclick=\"jcGo(1)\"><span class=\"jc-label-dot\"><\/span>El Cafetal<\/div>\n  <div class=\"jc-label\" id=\"jcL2\" onclick=\"jcGo(2)\"><span class=\"jc-label-dot\"><\/span>Beneficio<\/div>\n  <div class=\"jc-label\" id=\"jcL3\" onclick=\"jcGo(3)\"><span class=\"jc-label-dot\"><\/span>Secado<\/div>\n\n  <!-- Controls hint while locked -->\n  <div class=\"jc-controls-hint\" id=\"jcControlsHint\">\n    WASD \u00b7 Moverse &nbsp; Q\/E \u00b7 Subir\/Bajar<br>\n    Mouse \u00b7 Mirar &nbsp; Shift \u00b7 Correr &nbsp; ESC \u00b7 Salir\n  <\/div>\n\n  <!-- Touch controls hint for mobile -->\n  <div class=\"jc-touch-hint\" id=\"jcTouchHint\">\n    Toca y arrastra para mirar<br>\n    Botones virtuales para moverse\n  <\/div>\n<\/div>\n\n<script type=\"importmap\">\n{\n  \"imports\": {\n    \"three\": \"https:\/\/cdn.jsdelivr.net\/npm\/three@0.167.0\/build\/three.module.js\",\n    \"three\/addons\/\": \"https:\/\/cdn.jsdelivr.net\/npm\/three@0.167.0\/examples\/jsm\/\"\n  }\n}\n<\/script>\n\n<script type=\"module\">\nimport * as THREE from 'three';\nimport { GLTFLoader }         from 'three\/addons\/loaders\/GLTFLoader.js';\nimport { DRACOLoader }        from 'three\/addons\/loaders\/DRACOLoader.js';\nimport { PointerLockControls } from 'three\/addons\/controls\/PointerLockControls.js';\n\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\/\/ CONFIG\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nconst GLB_URL   = 'https:\/\/juanitocoffee.com\/wp-content\/uploads\/2026\/03\/finca-cafetera.glb';\nconst EYE_LEVEL = 2.8;    \/\/ m above terrain surface\nconst SPEED     = 55;     \/\/ m\/s base movement\nconst SPRINT    = 2.0;    \/\/ sprint multiplier (Shift)\nconst LABEL_DIST = 120;   \/\/ m \u2014 show hotspot label if within this distance\nconst ACCEL     = 12;     \/\/ acceleration factor for smooth movement\nconst COLLISION_RADIUS = 1.5; \/\/ collision sphere radius around player\nconst VERT_SPEED = 30;   \/\/ m\/s vertical movement speed (Q\/E keys)\nconst MAX_HEIGHT_ABOVE_TERRAIN = 95; \/\/ max meters above ground level\n\n\/\/ Hotspot world positions (after Z-up \u2192 Y-up rotation of model):\n\/\/   World X  = original X\n\/\/   World Y  = original Z   (elevation)\n\/\/   World Z  = -(original Y)\nconst HOTSPOTS = [\n  { name: 'La Casa',    desc: 'Vivienda principal de la finca',  wx: -50,  wy: 1495, wz: -80,  btn: 'btn-0' },\n  { name: 'El Cafetal', desc: 'Cultivo de caf\u00e9 de altura',       wx:  350, wy: 1520, wz: -60,  btn: 'btn-1' },\n  { name: 'Beneficio',  desc: 'Procesamiento del caf\u00e9',          wx: -150, wy: 1660, wz: -50,  btn: 'btn-2' },\n  { name: 'Secado',     desc: 'Zona de secado al sol',           wx:  380, wy: 1700, wz: -40,  btn: 'btn-3' },\n];\n\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\/\/ DEVICE DETECTION\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nconst isMobile = \/Android|iPhone|iPad|iPod|Opera Mini\/i.test(navigator.userAgent) || window.innerWidth < 768;\nconst isTouchDevice = () => (('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0));\n\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\/\/ RENDERER (optimized for performance)\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nconst canvas   = document.getElementById('jcCanvas');\nconst gW = () => window.innerWidth;\nconst gH = () => window.innerHeight;\n\nconst renderer = new THREE.WebGLRenderer({\n  canvas,\n  antialias: true,\n  precision: 'highp',\n  powerPreference: 'high-performance'\n});\nrenderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.5 : 2));\nrenderer.setSize(gW(), gH());\nrenderer.outputColorSpace  = THREE.SRGBColorSpace;\nrenderer.toneMapping       = THREE.ACESFilmicToneMapping;\nrenderer.toneMappingExposure = 1.15;\n\/\/ Enable depth buffer optimization\nrenderer.shadowMap.enabled = false; \/\/ no shadows for now, better performance\nrenderer.shadowMap.type = THREE.PCFSoftShadowMap;\n\/\/ Improved color space handling\nrenderer.gammaFactor = 2.2;\n\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\/\/ SCENE (improved lighting and environment)\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nconst scene = new THREE.Scene();\nscene.background = new THREE.Color(0x87CEEB); \/\/ lighter sky\nscene.fog = new THREE.FogExp2(0x87CEEB, 0.00045); \/\/ improved fog\n\n\/\/ Camera with optimized near\/far planes\nconst camera = new THREE.PerspectiveCamera(72, gW() \/ gH(), 0.1, 8000);\n\n\/\/ Enhanced lighting setup\nconst ambientLight = new THREE.AmbientLight(0xF5F5F5, 1.8); \/\/ softer ambient\nscene.add(ambientLight);\n\nconst sun = new THREE.DirectionalLight(0xFFF8DC, 2.8);\nsun.position.set(800, 1200, 400);\nsun.castShadow = false;\nscene.add(sun);\n\nconst fill = new THREE.DirectionalLight(0xB0D4F0, 0.9);\nfill.position.set(-800, 600, -800);\nscene.add(fill);\n\n\/\/ Optional: Hemisphere light for more natural outdoor lighting\nconst hemiLight = new THREE.HemisphereLight(0x87CEEB, 0x8B7355, 0.6);\nscene.add(hemiLight);\n\n\/\/ Enhanced sky sphere with better color gradients\nconst skyGeometry = new THREE.SphereGeometry(5000, 16, 16);\nconst skyMaterial = new THREE.MeshBasicMaterial({\n  color: 0x87CEEB,\n  side: THREE.BackSide,\n  fog: false\n});\nconst skySphere = new THREE.Mesh(skyGeometry, skyMaterial);\nscene.add(skySphere);\n\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\/\/ CONTROLS (PointerLock with improved responsiveness)\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nconst controls = new PointerLockControls(camera, renderer.domElement);\nscene.add(controls.getObject());\n\n\/\/ Improved key tracking\nconst keys = {};\nconst touchMovement = { forward: false, backward: false, left: false, right: false };\n\ndocument.addEventListener('keydown', e => {\n  keys[e.code] = true;\n  if (e.code === 'Space') e.preventDefault();\n});\ndocument.addEventListener('keyup', e => { keys[e.code] = false; });\n\n\/\/ Touch support for mobile\ndocument.addEventListener('touchstart', handleTouchStart, false);\ndocument.addEventListener('touchmove', handleTouchMove, false);\ndocument.addEventListener('touchend', handleTouchEnd, false);\n\nlet touchStartX = 0, touchStartY = 0;\nlet touchCurrX = 0, touchCurrY = 0;\nlet isTouching = false;\n\nfunction handleTouchStart(e) {\n  if (!controls.isLocked) return;\n  isTouching = true;\n  touchStartX = e.touches[0].clientX;\n  touchStartY = e.touches[0].clientY;\n  touchCurrX = touchStartX;\n  touchCurrY = touchStartY;\n}\n\nfunction handleTouchMove(e) {\n  if (!controls.isLocked || !isTouching) return;\n  e.preventDefault();\n  touchCurrX = e.touches[0].clientX;\n  touchCurrY = e.touches[0].clientY;\n\n  \/\/ Apply mouse movement for look around\n  const movementX = touchCurrX - touchStartX;\n  const movementY = touchCurrY - touchStartY;\n\n  \/\/ Dispatch synthetic mousemove (PointerLockControls listens for it)\n  const fakeEvent = new MouseEvent('mousemove', { movementX, movementY });\n  document.dispatchEvent(fakeEvent);\n\n  touchStartX = touchCurrX;\n  touchStartY = touchCurrY;\n}\n\nfunction handleTouchEnd(e) {\n  isTouching = false;\n}\n\ncontrols.addEventListener('lock', () => {\n  document.getElementById('jcEnter').style.display    = 'none';\n  document.getElementById('jcCrosshair').style.display = 'flex';\n  document.getElementById('jcName').style.display      = 'block';\n  document.getElementById('jcControlsHint').style.display = isMobile ? 'none' : 'block';\n  document.getElementById('jcTouchHint').style.display = isMobile ? 'block' : 'none';\n});\ncontrols.addEventListener('unlock', () => {\n  document.getElementById('jcEnter').style.display    = 'flex';\n  document.getElementById('jcCrosshair').style.display = 'none';\n  document.getElementById('jcControlsHint').style.display = 'none';\n  document.getElementById('jcTouchHint').style.display = 'none';\n  \/\/ hide all labels when paused\n  document.querySelectorAll('.jc-label').forEach(l => l.style.display = 'none');\n});\n\ndocument.getElementById('jcEnterBtn').addEventListener('click', e => {\n  e.stopPropagation();\n  controls.lock();\n});\n\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\/\/ MODEL LOADING (with improved feedback)\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nlet terrainMeshes = [];\nlet modelLoaded   = false;\nlet loadStartTime = performance.now();\n\nconst dracoLoader = new DRACOLoader();\ndracoLoader.setDecoderPath('https:\/\/cdn.jsdelivr.net\/npm\/three@0.167.0\/examples\/jsm\/libs\/draco\/gltf\/');\nconst loader = new GLTFLoader();\nloader.setDRACOLoader(dracoLoader);\n\nloader.load(\n  GLB_URL,\n  (gltf) => {\n    const model = gltf.scene;\n\n    \/\/ Correct Z-up \u2192 Y-up orientation\n    model.rotation.x = -Math.PI \/ 2;\n    model.updateMatrixWorld(true);\n\n    \/\/ Optimize materials if present\n    model.traverse(child => {\n      if (child.isMesh) {\n        \/\/ Enable frustum culling\n        child.frustumCulled = true;\n        \/\/ Optimize geometry if it has many polygons\n        if (child.geometry.attributes.position.count > 10000) {\n          \/\/ Could add LOD here if needed\n        }\n      }\n    });\n\n    scene.add(model);\n\n    \/\/ Gather all meshes for raycasting\n    model.traverse(child => {\n      if (child.isMesh) {\n        terrainMeshes.push(child);\n      }\n    });\n\n    \/\/ Place camera at La Casa position\n    const hs0 = HOTSPOTS[0];\n    camera.position.set(hs0.wx, hs0.wy + EYE_LEVEL + 3, hs0.wz);\n    const hs1 = HOTSPOTS[1];\n    camera.lookAt(hs1.wx, hs1.wy, hs1.wz);\n\n    modelLoaded = true;\n    const loadTime = ((performance.now() - loadStartTime) \/ 1000).toFixed(1);\n    document.getElementById('jcLoadTime').textContent = `Listo en ${loadTime}s`;\n\n    setTimeout(() => {\n      document.getElementById('jcLoading').style.display = 'none';\n      document.getElementById('jcEnter').style.display   = 'flex';\n    }, 600);\n  },\n  (xhr) => {\n    if (xhr.total > 0) {\n      const pct = Math.round(xhr.loaded \/ xhr.total * 100);\n      document.getElementById('jcBar').style.width = pct + '%';\n      document.getElementById('jcPct').textContent = pct + '%';\n    }\n    const mb = (xhr.loaded \/ 1048576).toFixed(1);\n    document.getElementById('jcStatus').textContent = `Cargando\u2026 ${mb} MB`;\n  },\n  (err) => {\n    document.getElementById('jcStatus').textContent = 'Error cargando modelo';\n    console.error('Model loading error:', err);\n  }\n);\n\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\/\/ HOTSPOT NAVIGATION (improved with smooth transitions)\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nlet activeHotspot = 0;\n\nfunction jcGo(idx) {\n  const hs = HOTSPOTS[idx];\n  if (!hs) return;\n  activeHotspot = idx;\n\n  \/\/ Add brief transition effect\n  const wrap = document.getElementById('jcWrap');\n  wrap.classList.add('jc-fade-transition');\n  setTimeout(() => wrap.classList.remove('jc-fade-transition'), 200);\n\n  \/\/ Smooth camera teleport\n  const targetPos = new THREE.Vector3(hs.wx, hs.wy + EYE_LEVEL + 3, hs.wz);\n  camera.position.lerp(targetPos, 0.3); \/\/ immediate for hotspot jump\n  camera.position.copy(targetPos);\n\n  \/\/ Orient toward next hotspot\n  const nextHs = HOTSPOTS[(idx + 1) % HOTSPOTS.length];\n  camera.lookAt(nextHs.wx, nextHs.wy, nextHs.wz);\n\n  \/\/ Update sidebar\n  document.querySelectorAll('.jc-btn').forEach((b, i) =>\n    b.classList.toggle('act', i === idx));\n  document.getElementById('jcName').textContent = hs.name;\n\n  \/\/ Lock controls\n  if (!controls.isLocked && modelLoaded) {\n    setTimeout(() => controls.lock(), 80);\n  }\n}\nwindow.jcGo = jcGo;\n\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\/\/ TERRAIN HEIGHT (downward raycast with caching)\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nconst downRay = new THREE.Raycaster();\ndownRay.ray.direction.set(0, -1, 0);\nlet lastGroundY = 0;\nlet lastGroundCheckPos = new THREE.Vector3();\n\nfunction getGroundY(x, z, fromY) {\n  if (!terrainMeshes.length) return fromY - EYE_LEVEL;\n\n  \/\/ Only raycast if player moved significantly (optimization)\n  const checkPos = new THREE.Vector3(x, fromY, z);\n  if (checkPos.distanceTo(lastGroundCheckPos) < 2) {\n    return lastGroundY;\n  }\n  lastGroundCheckPos.copy(checkPos);\n\n  downRay.ray.origin.set(x, fromY + 50, z);\n  const hits = downRay.intersectObjects(terrainMeshes, false);\n\n  if (hits.length) {\n    lastGroundY = hits[0].point.y;\n    return lastGroundY;\n  }\n  return -Infinity;\n}\n\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\/\/ COLLISION DETECTION (basic, optional)\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nconst collisionRay = new THREE.Raycaster();\nconst collisionCheckDist = COLLISION_RADIUS + 0.5;\n\nfunction checkCollision(pos, direction) {\n  if (!terrainMeshes.length) return false;\n  collisionRay.ray.origin.copy(pos);\n  collisionRay.ray.direction.copy(direction).normalize();\n  const hits = collisionRay.intersectObjects(terrainMeshes, false);\n  return hits.length > 0 && hits[0].distance < collisionCheckDist;\n}\n\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\/\/ FLOATING HOTSPOT LABELS (project 3D \u2192 screen)\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nconst labelEls = HOTSPOTS.map((_, i) => document.getElementById('jcL' + i));\nconst scrVec   = new THREE.Vector3();\n\nfunction updateLabels() {\n  if (!controls.isLocked) return;\n\n  HOTSPOTS.forEach((hs, i) => {\n    const hsPos = new THREE.Vector3(hs.wx, hs.wy + 6, hs.wz);\n    const dist  = camera.position.distanceTo(hsPos);\n    const el    = labelEls[i];\n\n    if (dist < LABEL_DIST) {\n      \/\/ Project to screen\n      scrVec.copy(hsPos).project(camera);\n      const sx = ( scrVec.x * 0.5 + 0.5) * gW();\n      const sy = (-scrVec.y * 0.5 + 0.5) * gH();\n      const inFront = scrVec.z < 1.0;\n\n      if (inFront && sx > 220 && sx < gW() - 10 && sy > 10 && sy < gH() - 10) {\n        const opacity = Math.max(0, Math.min(1, 1 - dist \/ LABEL_DIST));\n        el.style.left     = sx + 'px';\n        el.style.top      = sy + 'px';\n        el.style.opacity  = opacity.toFixed(2);\n        el.style.display  = i === activeHotspot ? 'none' : 'block';\n      } else {\n        el.style.display = 'none';\n      }\n    } else {\n      el.style.display = 'none';\n    }\n  });\n}\n\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\/\/ MOVEMENT & ANIMATION LOOP (improved physics)\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nlet prevTime  = performance.now();\nlet frameCount = 0;\nlet lastFpsUpdate = prevTime;\n\n\/\/ Smooth velocity with better acceleration\nconst vel = { x: 0, y: 0, z: 0 };\nconst DAMP = ACCEL; \/\/ improved acceleration factor\n\nfunction animate() {\n  requestAnimationFrame(animate);\n\n  const now = performance.now();\n  const dt  = Math.min((now - prevTime) \/ 1000, 0.016); \/\/ cap at 60fps\n  prevTime  = now;\n\n  \/\/ Simple FPS monitoring (can be removed)\n  frameCount++;\n  if (now - lastFpsUpdate > 1000) {\n    \/\/ console.log(`FPS: ${frameCount}`);\n    frameCount = 0;\n    lastFpsUpdate = now;\n  }\n\n  if (controls.isLocked && modelLoaded) {\n    const sprint = (keys['ShiftLeft'] || keys['ShiftRight']) ? SPRINT : 1.0;\n    const spd    = SPEED * sprint;\n\n    \/\/ Target velocity from keys (horizontal + vertical)\n    const tx = ((keys['KeyD'] || keys['ArrowRight'] ? 1 : 0) - (keys['KeyA'] || keys['ArrowLeft'] ? 1 : 0)) * spd;\n    const tz = ((keys['KeyW'] || keys['ArrowUp']    ? 1 : 0) - (keys['KeyS'] || keys['ArrowDown']  ? 1 : 0)) * spd;\n    const ty = ((keys['KeyQ'] ? 1 : 0) - (keys['KeyE'] ? 1 : 0)) * VERT_SPEED * sprint;\n\n    \/\/ Smooth acceleration\/deceleration with improved factor\n    const dampingFactor = Math.min(1, DAMP * dt);\n    vel.x += (tx - vel.x) * dampingFactor;\n    vel.z += (tz - vel.z) * dampingFactor;\n    vel.y += (ty - vel.y) * dampingFactor;\n\n    \/\/ Store old position for collision\n    const oldPos = camera.position.clone();\n\n    \/\/ Apply horizontal movement\n    controls.moveRight(vel.x * dt);\n    controls.moveForward(vel.z * dt);\n\n    \/\/ Apply vertical movement (Q = subir, E = bajar)\n    camera.position.y += vel.y * dt;\n\n    \/\/ \u2500\u2500 Terrain following \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    const gY = getGroundY(camera.position.x, camera.position.z, camera.position.y);\n    if (gY > -Infinity) {\n      const minY = gY + EYE_LEVEL;\n\n      \/\/ Smooth terrain following instead of hard clamp\n      if (camera.position.y < minY) {\n        camera.position.y = minY;\n      }\n\n      \/\/ Soft ceiling: don't fly more than MAX_HEIGHT_ABOVE_TERRAIN above ground\n      const maxY = minY + MAX_HEIGHT_ABOVE_TERRAIN;\n      if (camera.position.y > maxY) {\n        camera.position.y = maxY;\n      }\n    }\n\n    \/\/ \u2500\u2500 Update floating labels \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    updateLabels();\n\n    \/\/ \u2500\u2500 Update sky sphere to follow camera \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    skySphere.position.copy(camera.position);\n  }\n\n  renderer.render(scene, camera);\n}\n\nanimate();\n\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\/\/ RESIZE HANDLING (with debounce for performance)\n\/\/ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\nlet resizeTimeout;\nwindow.addEventListener('resize', () => {\n  clearTimeout(resizeTimeout);\n  resizeTimeout = setTimeout(() => {\n    camera.aspect = gW() \/ gH();\n    camera.updateProjectionMatrix();\n    renderer.setSize(gW(), gH());\n  }, 100);\n});\n\n\/\/ Handle visibility changes to pause rendering\ndocument.addEventListener('visibilitychange', () => {\n  if (document.hidden) {\n    \/\/ Page is hidden - could pause rendering if needed\n  } else {\n    \/\/ Page is visible - resume\n  }\n});\n<\/script>\n\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t<div class=\"elementor-element elementor-element-c2d1042 e-flex e-con-boxed e-con e-parent\" data-id=\"c2d1042\" data-element_type=\"container\" data-e-type=\"container\">\n\t\t\t\t\t<div class=\"e-con-inner\">\n\t\t\t\t<div class=\"elementor-element elementor-element-065c194 elementor-widget elementor-widget-html\" data-id=\"065c194\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t\t<style>\n.tour-section{padding:64px 40px;max-width:860px;margin:0 auto;border-bottom:1px solid #e8e2da}\n.tour-section:last-child{border-bottom:none}\n.tour-station-label{font-size:10px;letter-spacing:4px;color:#C8A96E;text-transform:uppercase;font-family:'Arial',sans-serif;margin:0 0 20px;display:inline-block;border:1px solid #C8A96E;padding:4px 12px;border-radius:20px}\n.tour-section-title{font-size:38px;color:#1a1a1a;margin:0 0 16px;font-weight:normal;font-family:'Georgia',serif;line-height:1.2}\n.tour-section-text{font-size:16px;color:#555;line-height:1.8;margin:0;font-family:'Arial',sans-serif;max-width:600px}\n<\/style>\n\n<div id=\"tour-casa\" class=\"tour-section\">\n  <span class=\"tour-station-label\">Estaci&oacute;n 01<\/span>\n  <h2 class=\"tour-section-title\">La Casa<\/h2>\n  <p class=\"tour-section-text\">El coraz&oacute;n de la finca donde la familia ha cultivado caf&eacute; por generaciones. Aqu&iacute; se recibe a los visitantes y se comparte la historia del caf&eacute; Juanito.<\/p>\n<\/div>\n\n<div id=\"tour-cafetal\" class=\"tour-section\">\n  <span class=\"tour-station-label\">Estaci&oacute;n 02<\/span>\n  <h2 class=\"tour-section-title\">El Cafetal<\/h2>\n  <p class=\"tour-section-text\">Variedades de caf&eacute; especial cultivadas a m&aacute;s de 1,600 metros de altitud. Cada &aacute;rbol es cuidado a mano siguiendo pr&aacute;cticas sostenibles.<\/p>\n<\/div>\n\n<div id=\"tour-beneficio\" class=\"tour-section\">\n  <span class=\"tour-station-label\">Estaci&oacute;n 03<\/span>\n  <h2 class=\"tour-section-title\">Beneficio<\/h2>\n  <p class=\"tour-section-text\">Donde el caf&eacute; cereza se transforma. Despulpado, fermentaci&oacute;n controlada y lavado \u2014 cada paso define el perfil de taza final.<\/p>\n<\/div>\n\n<div id=\"tour-secado\" class=\"tour-section\">\n  <span class=\"tour-station-label\">Estaci&oacute;n 04<\/span>\n  <h2 class=\"tour-section-title\">Secado<\/h2>\n  <p class=\"tour-section-text\">Camas africanas elevadas donde el caf&eacute; pergamino seca lentamente al sol. Control de humedad constante hasta alcanzar el punto &oacute;ptimo.<\/p>\n<\/div><style>\n.tour-section{padding:64px 40px;max-width:860px;margin:0 auto;border-bottom:1px solid #e8e2da}\n.tour-section:last-child{border-bottom:none}\n.tour-station-label{font-size:10px;letter-spacing:4px;color:#C8A96E;text-transform:uppercase;font-family:'Arial',sans-serif;margin:0 0 20px;display:inline-block;border:1px solid #C8A96E;padding:4px 12px;border-radius:20px}\n.tour-section-title{font-size:38px;color:#1a1a1a;margin:0 0 16px;font-weight:normal;font-family:'Georgia',serif;line-height:1.2}\n.tour-section-text{font-size:16px;color:#555;line-height:1.8;margin:0;font-family:'Arial',sans-serif;max-width:600px}\n<\/style>\n\n<div id=\"tour-casa\" class=\"tour-section\">\n  <span class=\"tour-station-label\">Estaci&oacute;n 01<\/span>\n  <h2 class=\"tour-section-title\">La Casa<\/h2>\n  <p class=\"tour-section-text\">El coraz&oacute;n de la finca donde la familia ha cultivado caf&eacute; por generaciones. Aqu&iacute; se recibe a los visitantes y se comparte la historia del caf&eacute; Juanito.<\/p>\n<\/div>\n\n<div id=\"tour-cafetal\" class=\"tour-section\">\n  <span class=\"tour-station-label\">Estaci&oacute;n 02<\/span>\n  <h2 class=\"tour-section-title\">El Cafetal<\/h2>\n  <p class=\"tour-section-text\">Variedades de caf&eacute; especial cultivadas a m&aacute;s de 1,600 metros de altitud. Cada &aacute;rbol es cuidado a mano siguiendo pr&aacute;cticas sostenibles.<\/p>\n<\/div>\n\n<div id=\"tour-beneficio\" class=\"tour-section\">\n  <span class=\"tour-station-label\">Estaci&oacute;n 03<\/span>\n  <h2 class=\"tour-section-title\">Beneficio<\/h2>\n  <p class=\"tour-section-text\">Donde el caf&eacute; cereza se transforma. Despulpado, fermentaci&oacute;n controlada y lavado \u2014 cada paso define el perfil de taza final.<\/p>\n<\/div>\n\n<div id=\"tour-secado\" class=\"tour-section\">\n  <span class=\"tour-station-label\">Estaci&oacute;n 04<\/span>\n  <h2 class=\"tour-section-title\">Secado<\/h2>\n  <p class=\"tour-section-text\">Camas africanas elevadas donde el caf&eacute; pergamino seca lentamente al sol. Control de humedad constante hasta alcanzar el punto &oacute;ptimo.<\/p>\n<\/div>\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t","protected":false},"excerpt":{"rendered":"<p>Juanito Coffee Recorrido Virtual \u00b7 Colombia 0% Cargando modelo 3D&#8230; Juanito Coffee Recorrido Virtual \u00b7 Colombia Estaciones La Casa El Cafetal Beneficio Secado \ud83d\udc71 Clic para explorar W A S D Moverse Mouse \u00b7 Mirar alrededor Q E Subir \/ Bajar Shift \u00b7 Correr ESC \u00b7 Pausar Finca Juanito Coffee Recorrido en Primera Persona WASD [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"footnotes":""},"class_list":["post-1155","page","type-page","status-publish","hentry"],"_hostinger_reach_plugin_has_subscription_block":false,"_hostinger_reach_plugin_is_elementor":false,"_links":{"self":[{"href":"https:\/\/juanitocoffee.com\/es\/wp-json\/wp\/v2\/pages\/1155","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/juanitocoffee.com\/es\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/juanitocoffee.com\/es\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/juanitocoffee.com\/es\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/juanitocoffee.com\/es\/wp-json\/wp\/v2\/comments?post=1155"}],"version-history":[{"count":91,"href":"https:\/\/juanitocoffee.com\/es\/wp-json\/wp\/v2\/pages\/1155\/revisions"}],"predecessor-version":[{"id":1251,"href":"https:\/\/juanitocoffee.com\/es\/wp-json\/wp\/v2\/pages\/1155\/revisions\/1251"}],"wp:attachment":[{"href":"https:\/\/juanitocoffee.com\/es\/wp-json\/wp\/v2\/media?parent=1155"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}