{"id":4116,"date":"2025-09-03T20:50:44","date_gmt":"2025-09-03T20:50:44","guid":{"rendered":"https:\/\/nickcharlesworth.com\/?page_id=4116"},"modified":"2025-11-06T14:44:09","modified_gmt":"2025-11-06T14:44:09","slug":"3d-viewer","status":"publish","type":"page","link":"https:\/\/nickcharlesworth.com\/es\/3d-viewer\/","title":{"rendered":"3d Viewer"},"content":{"rendered":"\t\t<div data-elementor-type=\"wp-page\" data-elementor-id=\"4116\" class=\"elementor elementor-4116\" data-elementor-post-type=\"page\">\n\t\t\t\t\t\t<section class=\"elementor-section elementor-top-section elementor-element elementor-element-665b616 elementor-section-boxed elementor-section-height-default elementor-section-height-default\" data-id=\"665b616\" data-element_type=\"section\" data-e-type=\"section\">\n\t\t\t\t\t\t<div class=\"elementor-container elementor-column-gap-default\">\n\t\t\t\t\t<div class=\"elementor-column elementor-col-100 elementor-top-column elementor-element elementor-element-6b94f4c\" data-id=\"6b94f4c\" data-element_type=\"column\" data-e-type=\"column\">\n\t\t\t<div class=\"elementor-widget-wrap elementor-element-populated\">\n\t\t\t\t\t\t<div class=\"elementor-element elementor-element-c3892a7 elementor-widget elementor-widget-html\" data-id=\"c3892a7\" data-element_type=\"widget\" data-e-type=\"widget\" data-widget_type=\"html.default\">\n\t\t\t\t<div class=\"elementor-widget-container\">\n\t\t\t\t\t<!-- Full-bleed 360 pano (fills full screen width), footer below, smooth pan\/zoom -->\n<style>\n  \/* Allow vertical scroll, prevent accidental horizontal scroll from 100vw breakout *\/\n  html, body { height:100%; margin:0; overflow-x:hidden; }\n\n  \/* Your header can be normal\/sticky; no overlay *\/\n  header { position:relative; z-index:30; }\n\n  \/* Full-bleed wrapper: escapes centered theme containers to 100vw *\/\n  .pano-fullbleed{\n    position:relative;\n    width:100vw;\n    margin-left:calc(50% - 50vw);\n    margin-right:calc(50% - 50vw);\n  }\n\n  \/* The 360 viewer: one viewport tall in normal flow *\/\n  #pano360{\n    position:relative;           \/* NOT fixed *\/\n    width:100%;\n    height:120vh;                \/* full screen height *\/\n    background:#000;\n    cursor:grab; user-select:none; overflow:hidden;\n    z-index:1;\n  }\n  #pano360.grabbing{ cursor:grabbing; }\n\n  \/* Make sure theme CSS can\u2019t shrink\/darken the canvas *\/\n  #pano360 canvas{\n    width:100% !important; height:90% !important; display:block !important;\n    max-width:none !important; max-height:none !important;\n    filter:none !important; mix-blend-mode:normal !important; opacity:1 !important;\n  }\n\n  \/* Footer in normal flow (appears AFTER the viewer, not over it) *\/\n  footer, .site-footer{\n    position:static !important;\n    top:auto !important; bottom:auto !important; left:auto !important; right:auto !important;\n    z-index:auto !important;\n    clear:both;\n  }\n\n  \/* Loading overlay *\/\n  #pano360 .pano-loading{\n    position:absolute; inset:0; display:flex; align-items:center; justify-content:center;\n    color:#fff; font:500 14px\/1.4 system-ui,-apple-system,Segoe UI,Roboto,Arial;\n    background:radial-gradient(ellipse at center, rgba(0,0,0,.25), rgba(0,0,0,.85)); z-index:2;\n  }\n<\/style>\n\n<section class=\"pano-fullbleed\">\n  <div id=\"pano360\"><\/div>\n<\/section>\n\n<script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/three.js\/r128\/three.min.js\"><\/script>\n<script>\n(function(){\n  const el = document.getElementById('pano360');\n\n  const opts = {\n    src: \"https:\/\/nickcharlesworth.com\/wp-content\/uploads\/2025\/11\/ARIA_360_V3-scaled.jpg\",\n    fov: 95, minFov: 30, maxFov: 100,\n    headingDeg: 60, pitchDeg: 0,\n    startPitchOffsetPctOfFov: 0.00,    \/\/ set 0.20 to start \u201c20% up\u201d\n    speedX: 0.0014, speedY: 0.0016,\n    invertY: true,                     \/\/ drag up = look up\n    pitchMin: -89, pitchMax: 89,       \/\/ ~\u00b190\u00b0 vertical\n    lockStartPixels: 2, lockBias: 1.0, \/\/ strict axis lock\n    mirror: false,\n    backingScale: Math.min(2, (window.devicePixelRatio||1)) \/\/ crisp, not excessive\n  };\n\n  \/\/ ========= Smooth feel (tweak to taste) =========\n  const PAN_EASE  = 0.14;  \/\/ 0.10 = floatier, 0.20 = snappier\n  const ZOOM_EASE = 0.12;  \/\/ 0.08 = softer, 0.2 = snappier\n  \/\/ ================================================\n\n  \/\/ Loading overlay\n  const loadingEl = document.createElement('div');\n  loadingEl.className='pano-loading';\n  loadingEl.textContent='Loading panorama\u2026';\n  el.appendChild(loadingEl);\n\n  \/\/ Three.js\n  const scene    = new THREE.Scene();\n  const camera   = new THREE.PerspectiveCamera(opts.fov, 1, 0.1, 1100);\n\n  \/\/ Opaque canvas + sRGB = correct color, no dark\/contrast shift\n  const renderer = new THREE.WebGLRenderer({ antialias:true, alpha:false });\n  renderer.setClearColor(0x000000, 1);\n  renderer.outputEncoding = THREE.sRGBEncoding;\n  el.appendChild(renderer.domElement);\n\n  \/\/ Yaw\u2192Pitch rig (prevents roll\/diagonal tilt)\n  const yawObj = new THREE.Object3D();\n  const pitchObj = new THREE.Object3D();\n  yawObj.add(pitchObj); pitchObj.add(camera); scene.add(yawObj);\n\n  \/\/ Inside-out sphere\n  const sphereGeom = new THREE.SphereGeometry(500, 64, 48);\n  sphereGeom.scale(-1, 1, 1);\n  const sphereMat  = new THREE.MeshBasicMaterial({ color:0xffffff });\n  const sphere     = new THREE.Mesh(sphereGeom, sphereMat);\n  scene.add(sphere);\n\n  \/\/ State\n  const toRad = THREE.MathUtils.degToRad;\n  const PITCH_MIN = toRad(opts.pitchMin);\n  const PITCH_MAX = toRad(opts.pitchMax);\n\n  let yaw   = toRad(((opts.headingDeg % 360) + 360) % 360);\n  let pitch = toRad(opts.pitchDeg + (opts.fov * (opts.startPitchOffsetPctOfFov||0)));\n  let targetFov = opts.fov;\n\n  \/\/ Smoothed state used by the rig\n  let smoothYaw = yaw;\n  let smoothPitch = pitch;\n\n  function clampPitch(){ pitch = Math.max(PITCH_MIN, Math.min(PITCH_MAX, pitch)); }\n  function wrapPI(a){ return Math.atan2(Math.sin(a), Math.cos(a)); }\n  function lerpAngle(a, b, t){\n    const delta = Math.atan2(Math.sin(b - a), Math.cos(b - a)); \/\/ shortest path\n    return a + delta * t;\n  }\n\n  \/\/ Apply start\n  yawObj.rotation.y   = smoothYaw;\n  pitchObj.rotation.x = smoothPitch;\n\n  \/\/ Strict axis-locked drag (pure H or V per gesture)\n  let isDown=false, sx=0, sy=0, dragMode=null;\n  function decideDragMode(dx, dy, e){\n    if (e && e.shiftKey) return 'v';\n    if (e && e.altKey)   return 'h';\n    const adx=Math.abs(dx), ady=Math.abs(dy);\n    if (adx < opts.lockStartPixels && ady < opts.lockStartPixels) return null;\n    return (ady >= opts.lockBias * adx) ? 'v' : 'h';\n  }\n  function applyYaw(dx){\n    yaw = wrapPI(yaw - dx * opts.speedX);\n  }\n  function applyPitch(dy){\n    const sign = opts.invertY ? -1 : 1;\n    pitch += sign * (dy * opts.speedY);\n    clampPitch();\n  }\n\n  el.addEventListener('mousedown', e=>{\n    isDown=true; sx=e.clientX; sy=e.clientY; dragMode=null; el.classList.add('grabbing');\n  });\n  window.addEventListener('mouseup', ()=>{\n    isDown=false; dragMode=null; el.classList.remove('grabbing');\n  });\n  el.addEventListener('mousemove', e=>{\n    if(!isDown) return;\n    const dx=e.clientX - sx, dy=e.clientY - sy;\n    if (!dragMode){ dragMode = decideDragMode(dx, dy, e); sx=e.clientX; sy=e.clientY; return; }\n    sx=e.clientX; sy=e.clientY;\n    if (dragMode === 'v') applyPitch(dy); else applyYaw(dx);\n  });\n\n  \/\/ Touch\n  el.addEventListener('touchstart', e=>{\n    if(e.touches.length!==1) return;\n    isDown=true; sx=e.touches[0].clientX; sy=e.touches[0].clientY; dragMode=null;\n  }, {passive:true});\n  el.addEventListener('touchend', ()=>{ isDown=false; dragMode=null; }, {passive:true});\n  el.addEventListener('touchmove', e=>{\n    if(!isDown || e.touches.length!==1) return;\n    e.preventDefault();\n    const t=e.touches[0], dx=t.clientX - sx, dy=t.clientY - sy;\n    if (!dragMode){ dragMode = decideDragMode(dx, dy); sx=t.clientX; sy=t.clientY; return; }\n    sx=t.clientX; sy=t.clientY;\n    if (dragMode === 'v') applyPitch(dy); else applyYaw(dx);\n  }, {passive:false});\n\n  \/\/ Smooth wheel zoom (ease to target FOV)\n  el.addEventListener('wheel', e=>{\n    e.preventDefault();\n    targetFov = THREE.MathUtils.clamp(targetFov + e.deltaY*0.05, opts.minFov, opts.maxFov);\n  }, {passive:false});\n\n  \/\/ Double-click recenter\n  el.addEventListener('dblclick', ()=>{\n    yaw   = toRad(((opts.headingDeg % 360) + 360) % 360);\n    pitch = toRad(opts.pitchDeg + (opts.fov * (opts.startPitchOffsetPctOfFov||0)));\n    smoothYaw = yaw;\n    smoothPitch = pitch;\n    targetFov = opts.fov;\n  });\n\n  \/\/ Fit to element (100vh) and render at ~device DPR\n  function fit(){\n    const rect = el.getBoundingClientRect();\n    const cssW = Math.max(1, Math.round(rect.width));\n    const cssH = Math.max(1, Math.round(rect.height));\n    const scale = opts.backingScale;\n    renderer.setSize(cssW * scale, cssH * scale, false);\n    renderer.domElement.style.width  = cssW + 'px';\n    renderer.domElement.style.height = cssH + 'px';\n    camera.aspect = cssW \/ cssH; camera.updateProjectionMatrix();\n  }\n  fit();\n  new ResizeObserver(fit).observe(el);\n\n  \/\/ Texture\n  const loader = new THREE.TextureLoader(); loader.setCrossOrigin('anonymous');\n  loader.load(\n    opts.src,\n    tex=>{\n      tex.encoding   = THREE.sRGBEncoding;\n      tex.minFilter  = THREE.LinearMipmapLinearFilter;\n      tex.magFilter  = THREE.LinearFilter;\n      tex.wrapS      = THREE.RepeatWrapping;\n      tex.wrapT      = THREE.ClampToEdgeWrapping;\n      tex.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy() || 1);\n      if (opts.mirror){ tex.repeat.x = -1; tex.offset.x = 1; }\n      sphereMat.map = tex; sphereMat.needsUpdate = true;\n      loadingEl.remove();\n      animate();\n    },\n    undefined,\n    err=>{\n      loadingEl.textContent = 'Could not load image. If page and image are on different domains, upload the JPG to this site (CORS).';\n      console.error('Panorama load error:', err);\n      (function loop(){ requestAnimationFrame(loop); renderer.render(scene, camera); })();\n    }\n  );\n\n  function animate(){\n    requestAnimationFrame(animate);\n\n    \/\/ Ease angles toward latest drag values (shortest path for yaw)\n    smoothYaw   = lerpAngle(smoothYaw,   yaw,   PAN_EASE);\n    smoothPitch = smoothPitch + (pitch - smoothPitch) * PAN_EASE;\n    \/\/ Clamp smoothed pitch just in case\n    smoothPitch = Math.max(PITCH_MIN, Math.min(PITCH_MAX, smoothPitch));\n\n    yawObj.rotation.y   = smoothYaw;\n    pitchObj.rotation.x = smoothPitch;\n\n    \/\/ Ease zoom toward targetFov\n    camera.fov += (targetFov - camera.fov) * ZOOM_EASE;\n    camera.updateProjectionMatrix();\n\n    renderer.render(scene, camera);\n  }\n})();\n<\/script>\n\t\t\t\t<\/div>\n\t\t\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t<\/div>\n\t\t\t\t\t<\/div>\n\t\t<\/section>\n\t\t\t\t<\/div>\n\t\t","protected":false},"excerpt":{"rendered":"","protected":false},"author":1,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"elementor_header_footer","meta":{"site-sidebar-layout":"no-sidebar","site-content-layout":"page-builder","ast-site-content-layout":"full-width-container","site-content-style":"default","site-sidebar-style":"default","ast-global-header-display":"","ast-banner-title-visibility":"","ast-main-header-display":"","ast-hfb-above-header-display":"","ast-hfb-below-header-display":"","ast-hfb-mobile-header-display":"","site-post-title":"","ast-breadcrumbs-content":"","ast-featured-img":"disabled","footer-sml-layout":"","theme-transparent-header-meta":"","adv-header-id-meta":"","stick-header-meta":"","header-above-stick-meta":"","header-main-stick-meta":"","header-below-stick-meta":"","astra-migrate-meta-layouts":"set","ast-page-background-enabled":"default","ast-page-background-meta":{"desktop":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"ast-content-background-meta":{"desktop":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"footnotes":""},"class_list":["post-4116","page","type-page","status-publish","hentry"],"aioseo_notices":[],"_links":{"self":[{"href":"https:\/\/nickcharlesworth.com\/es\/wp-json\/wp\/v2\/pages\/4116","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/nickcharlesworth.com\/es\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/nickcharlesworth.com\/es\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/nickcharlesworth.com\/es\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/nickcharlesworth.com\/es\/wp-json\/wp\/v2\/comments?post=4116"}],"version-history":[{"count":262,"href":"https:\/\/nickcharlesworth.com\/es\/wp-json\/wp\/v2\/pages\/4116\/revisions"}],"predecessor-version":[{"id":4440,"href":"https:\/\/nickcharlesworth.com\/es\/wp-json\/wp\/v2\/pages\/4116\/revisions\/4440"}],"wp:attachment":[{"href":"https:\/\/nickcharlesworth.com\/es\/wp-json\/wp\/v2\/media?parent=4116"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}