Thank you for the reply Lawrence. Here is the code, a bit long…
// Castle.jsx (condensed for review)
import { CameraControls, OrbitControls /, Bounds/ } from “@react-three/drei”
import AtmIframe from “./AtmIframe”
// Few more import statements
// -----------------------------
// Navigation helpers (kept minimal)
// -----------------------------
const NavigationSystem = {
positions: {},
navigationSources: {},
init() {
window.navigationSystem = {
storePosition: (id, pos, tgt) => {
NavigationSystem.positions[id] = { position: pos, target: tgt }
window.audioManager?.play(“transition”)
},
setNavigationSource: (id, src) => (NavigationSystem.navigationSources[id] = src),
getNavigationSource: (id) => NavigationSystem.navigationSources[id] || “direct”,
getPosition: (id) => NavigationSystem.positions[id],
clearPositions: () => { NavigationSystem.positions = {}; NavigationSystem.navigationSources = {} },
returnToPosition: (id, fallback) => {
const stored = NavigationSystem.positions[id]
const src = NavigationSystem.navigationSources[id] || “direct”
if (src === “pole” && window.globalNavigation?.navigateTo) {
window.globalNavigation.navigateTo(“nav”); return true
}
if (stored && src === “direct” && window.controls?.current) {
const cc = window.controls.current
const { position, target } = stored
cc.setLookAt(…position, …target, true).catch(()=>{})
return true
}
fallback(); return false
},
}
},
createElementHandlers(id, navigateTo, setActive, isActive) {
const handleClick = (e) => {
/* top-most / facing / occlusion checks omitted /
if (isActive) return
if (window.controls?.current) {
const cc = window.controls.current
const p = cc.getPosition(new THREE.Vector3())
const t = cc.getTarget(new THREE.Vector3())
window.navigationSystem.storePosition(id, [p.x,p.y,p.z], [t.x,t.y,t.z])
window.navigationSystem.setNavigationSource(id, “direct”)
}
navigateTo()
}
return { handleClick, pointerHandlers: { / hover handlers omitted */ } }
},
}
NavigationSystem.init()
// -----------------------------
// Camera presets (focus: “token”/ATM target)
// -----------------------------
const cameraConfig = {
default: [ -0.61918, 0.95, 100.2743, -0.21831, 1.04208, 0.86046 ],
sections: {
nav: [ -0.14842, 0.95658, 7.2, -0.21831, 1.04208, -0.1 ],
token: [ 1.76200, 1.19830, 0.89747, -0.12, 0.75, -0.04174 ], // ATM
/* about, aidatingcoach, roadmap… omitted */
},
}
// Only “nav” camX is aspect-adjusted; others are used as-is
const getCameraPosition = (section) => {
if (section === “default”) return […cameraConfig.default]
const base = […cameraConfig.sections[section]]
if (section !== “nav”) return base
const aspect = window.innerWidth / window.innerHeight
const MID = -0.48, MIN = -0.8, MAX = 0, RANGE = 2
const lerp = (a,b,t)=>a+(b-a)*t, clamp01=(x)=>Math.max(0,Math.min(1,x))
let camX = MID
if (aspect >= 1) camX = lerp(MID, MAX, clamp01((aspect-1)/(RANGE-1)))
else camX = lerp(MID, MIN, clamp01(((1/aspect)-1)/(RANGE-1)))
base[0] = camX
return base
}
// -----------------------------
// ATM sizing-related helpers (where the issue lives)
// -----------------------------
function smoothCameraReturn(position, target) {
if (!window.controls?.current) return
const cc = window.controls.current
cc.enabled = true
setTimeout(() => cc.setLookAt(…position, …target, true).catch(()=>{}), 50)
}
// -----------------------------
// CastleModel: meshes + ATM iframe mount
// -----------------------------
const CastleModel = ({
onCastleClick,
atmIframeActive,
mirrorIframeActive,
scrollIframeActive,
setAtmiframeActive,
setMirrorIframeActive,
setScrollIframeActive,
activeSection,
}) => {
const { /* nodes, scene, animations… / } = {} / loaded via useAsset in full file */
const atmHandlers = NavigationSystem.createElementHandlers(
“atm”,
() => { if (!atmIframeActive) onCastleClick(“token”) },
setAtmiframeActive,
atmIframeActive
)
/* most meshes/materials omitted */
return (
{/* …monument meshes omitted… /}
<mesh / the ATM 3D frame / onClick={atmHandlers.handleClick} {…atmHandlers.pointerHandlers} />
<AtmIframe
position={[1.675, 1.185, 0.86]}
rotation={[1.47, 0.194, -1.088]}
isActive={atmIframeActive}
onResolutionMeasured={({ width, height }) => {
// ATM plane’s current screen-pixel size, compared to window size
window.atmResolution = { width, height }
console.log([Castle] ATMIFrame resolution: ${width}×${height})
}}
onReturnToMain={(source) => {
setAtmiframeActive(false)
window.audioManager?.unduckAmbient?.(1000)
setTimeout(() => {
window.audioManager?.play(“transition”)
if (source === “pole”) {
onCastleClick(“nav”)
} else {
const stored = window.navigationSystem.getPosition(“atm”)
stored ? smoothCameraReturn(stored.position, stored.target) : onCastleClick(“nav”)
}
}, 100)
}}
/>
{/ MirrorIframe / ScrollIframe blocks omitted */}
)
}
// -----------------------------
// Castle: controls, transitions, and the zoom → ATM
// -----------------------------
const INITIAL_SMOOTH = 1.0
const INTERACTION_SMOOTH = 0.6
const Castle = ({ activeSection, isStarted=false }) => {
const controls = useRef()
const [atmIframeActive, setAtmiframeActive] = useState(false)
const [mirrorIframeActive, setMirrorIframeActive] = useState(false)
const [scrollIframeActive, setScrollIframeActive] = useState(false)
// expose controls globally (used by NavigationSystem & smoothCameraReturn)
useEffect(() => { window.controls = controls; return () => { delete window.controls } }, )
// Core transition: navigate to section, animate camera, then activate iframe
const playTransition = (sectionName, opts = {}) => {
if (!controls.current) return
const cc = controls.current
cc.smoothTime = opts.initial ? INITIAL_SMOOTH : INTERACTION_SMOOTH
setAtmiframeActive(false); setMirrorIframeActive(false); setScrollIframeActive(false)
if (["about","download","token","roadmap","aidatingcoach"].includes(sectionName)) {
window.audioManager?.duckAmbient?.(0.10, 1000)
}
const target = getCameraPosition(sectionName)
if (!target) return
cc.enabled = true
cc.setLookAt(...target, true)
.then(() => {
// after zoom: turn on the relevant iframe
switch (sectionName) {
case "aidatingcoach": setMirrorIframeActive(true); break
case "token": setAtmiframeActive(true); break // ATM
case "roadmap": setScrollIframeActive(true); break
default: break
}
// (Where I tried ATM fitting: compute window.atmResolution vs window size
// and adjust distance / or wrap in <Bounds>. Both approaches shown issues.)
})
.catch(()=>{})
.finally(() => {
cc.smoothTime = INTERACTION_SMOOTH
cc.enabled = sectionName === "nav"
})
}
// initial intro zoom to “nav”
useEffect(() => {
if (!controls.current || !isStarted) return
setTimeout(() => playTransition(“nav”, { initial: true }), 100)
}, [isStarted])
return (
)
}
export default React.memo(Castle)
CameraControls (drei) — main camera driver. Calls setLookAt(pos, target, smooth=true) to animate.
cameraConfig — preset poses for each section (nav, token/ATM, roadmap, etc.). Each is [camX, camY, camZ, targetX, targetY, targetZ].
getCameraPosition(section) — returns the pose for a section. It only tweaks camX for nav based on aspect ratio; every other section uses the preset unchanged.
NavigationSystem — remembers the user’s current camera position/target before jumping to an object so I can return later.