@@ -1346,6 +1346,10 @@ export function PaperRail() {
13461346 const scene = new THREE . Scene ( ) ;
13471347 const camera = new THREE . PerspectiveCamera ( 28 , W / H , 0.1 , 100 ) ;
13481348 camera . position . set ( 0 , 0 , 8.6 ) ;
1349+ // Hoisted projection constant: tan(fov/2) lets us convert camera-Z
1350+ // into the world-space half-height of the visible frustum at any
1351+ // depth, used in the loop to map cursor NDC to paper-local UV.
1352+ const tanHalfFov = Math . tan ( ( camera . fov * Math . PI ) / 360 ) ;
13491353
13501354 const paperGroup = new THREE . Group ( ) ;
13511355 scene . add ( paperGroup ) ;
@@ -1467,7 +1471,11 @@ export function PaperRail() {
14671471
14681472 const verlet = makeVerlet ( ) ;
14691473
1470- const keyLight = new THREE . DirectionalLight ( 0xfff8e8 , 1.4 ) ;
1474+ // Lighting: ambient was 0.7, which flooded the scene and crushed
1475+ // the self-shading on the bend layer (cursor bump, catenary sag,
1476+ // edge curl all looked flat). Lower ambient + slightly higher key
1477+ // restores normal-driven contrast so the geometry actually reads.
1478+ const keyLight = new THREE . DirectionalLight ( 0xfff8e8 , 1.55 ) ;
14711479 keyLight . position . set ( - 2 , 2.5 , 5 ) ;
14721480 scene . add ( keyLight ) ;
14731481 const fillLight = new THREE . DirectionalLight ( 0xffd9b8 , 0.55 ) ;
@@ -1476,7 +1484,7 @@ export function PaperRail() {
14761484 const rimLight = new THREE . DirectionalLight ( 0xe8503a , 0.25 ) ;
14771485 rimLight . position . set ( 0 , 0 , - 3 ) ;
14781486 scene . add ( rimLight ) ;
1479- const ambient = new THREE . AmbientLight ( 0xffffff , 0.7 ) ;
1487+ const ambient = new THREE . AmbientLight ( 0xffffff , 0.45 ) ;
14801488 scene . add ( ambient ) ;
14811489
14821490 const DUST_COUNT = 280 ;
@@ -2012,21 +2020,54 @@ export function PaperRail() {
20122020 const waveTime = waveStart > 0 ? ( now - waveStart ) / 600 : 1 ;
20132021 const wave = waveTime < 1 ? waveTime : 0 ;
20142022
2015- // Raw cursor UV in paper-screen space. Used as the spring target
2016- // for the trailing bump position.
2017- const cursorRawU = Math . max ( 0 , Math . min ( 1 , mouseRawX * 0.5 + 0.5 ) ) ;
2018- const cursorRawV = Math . max ( 0 , Math . min ( 1 , - mouseRawY * 0.5 + 0.5 ) ) ;
2023+ // Paper-relative cursor UV. Convert paper world-space centre and
2024+ // half-extents into NDC using the camera's frustum, then map the
2025+ // cursor's NDC offset from the paper centre into 0..1 UV space.
2026+ // Pre-existing code mapped the *whole viewport* to UV, which fired
2027+ // edge effects on offset stages (problem/how) where the paper
2028+ // doesn't fill the centre.
2029+ const halfHWorld = spCamZ . value * tanHalfFov ;
2030+ const halfWWorld = halfHWorld * camera . aspect ;
2031+ const paperRadiusNDCx = ( PAPER_W * 0.5 * stageScale ) / halfWWorld ;
2032+ const paperRadiusNDCy = ( PAPER_H * 0.5 * stageScale ) / halfHWorld ;
2033+ const paperCentreNDCx = spX . value / halfWWorld ;
2034+ const paperCentreNDCy = spY . value / halfHWorld ;
2035+ const cursorOffNDCx = mouseRawX - paperCentreNDCx ;
2036+ const cursorOffNDCy = mouseRawY - paperCentreNDCy ;
2037+ const cursorRawU = Math . max (
2038+ 0 ,
2039+ Math . min ( 1 , cursorOffNDCx / ( 2 * paperRadiusNDCx ) + 0.5 ) ,
2040+ ) ;
2041+ const cursorRawV = Math . max (
2042+ 0 ,
2043+ Math . min ( 1 , - cursorOffNDCy / ( 2 * paperRadiusNDCy ) + 0.5 ) ,
2044+ ) ;
20192045 // Cursor velocity in NDC. mouseX/Y are the lowpass-filtered cursor
20202046 // (pre-existing); the diff vs raw gives an instantaneous velocity
20212047 // estimate without needing per-frame state.
20222048 const cursorVelX = mouseRawX - mouseX ;
20232049 const cursorVelY = mouseRawY - mouseY ;
2024- // Hover lift: when the cursor is roughly over the paper's
2025- // viewport region, add a tiny persistent positive bias. Kept
2026- // small (0.004) so it reads as the paper noticing the cursor,
2027- // not lifting toward it.
2028- const overU = Math . max ( 0 , 1 - Math . max ( 0 , Math . abs ( mouseRawX ) - 0.35 ) * 5 ) ;
2029- const overV = Math . max ( 0 , 1 - Math . max ( 0 , Math . abs ( mouseRawY ) - 0.5 ) * 5 ) ;
2050+ // Hover gate: 1 inside the paper's actual NDC bounds, falling to
2051+ // 0 over a 50%-of-half-extent feathered band past each edge. Now
2052+ // tracks the paper's real screen position regardless of stage.
2053+ const fadeU = paperRadiusNDCx * 0.5 ;
2054+ const fadeV = paperRadiusNDCy * 0.5 ;
2055+ const overU = Math . max (
2056+ 0 ,
2057+ Math . min (
2058+ 1 ,
2059+ 1
2060+ - Math . max ( 0 , Math . abs ( cursorOffNDCx ) - paperRadiusNDCx ) / fadeU ,
2061+ ) ,
2062+ ) ;
2063+ const overV = Math . max (
2064+ 0 ,
2065+ Math . min (
2066+ 1 ,
2067+ 1
2068+ - Math . max ( 0 , Math . abs ( cursorOffNDCy ) - paperRadiusNDCy ) / fadeV ,
2069+ ) ,
2070+ ) ;
20302071 const hoverLift = overU * overV * 0.004 ;
20312072 // Press input: signed by horizontal motion (so swipes have
20322073 // direction), magnitude boosted by total speed (so vertical
0 commit comments