@@ -18,6 +18,8 @@ import {
1818 selectTarget , deselectTarget , setSetFromClickMode ,
1919 setProjectContext , setCalibrationReviewed , getNextPhotoId , getPrevPhotoId ,
2020 setNearbyPhotos , isTargetHidden , refreshTargets ,
21+ setMeshRotationX , setMeshRotationY , setMeshRotationZ ,
22+ setTargetHidden as stateSetTargetHidden ,
2123} from './state.js' ;
2224import {
2325 initViewer , loadProgressive , setMeshRotationY as viewerSetMeshRotationY ,
@@ -34,9 +36,11 @@ import {
3436 initMinimap , updateCamera , updateTargets , setSelectedTarget ,
3537 updateNearbyPhotos ,
3638} from './minimap.js' ;
37- import { initPanel , showToast , setGridToggleState , clearNearbyPreview , getNearbyPreviewState } from './calibration-panel.js' ;
39+ import { initPanel , showToast , setSphericalGridToggleState , clearNearbyPreview , getNearbyPreviewState } from './calibration-panel.js' ;
3840import {
3941 initPreviewViewer , showPreview , hidePreview , showAddButton ,
42+ showRearView , updateRearViewRotation , showTargetActions , updateHideButtonState ,
43+ syncRearViewCamera , setRearViewTargets ,
4044} from './preview-viewer.js' ;
4145
4246// ============================================================================
@@ -71,6 +75,8 @@ document.addEventListener('DOMContentLoaded', () => {
7175
7276 // Keyboard shortcuts
7377 document . addEventListener ( 'keydown' , onKeyDown ) ;
78+ document . addEventListener ( 'keyup' , onKeyUp ) ;
79+ window . addEventListener ( 'blur' , onWindowBlur ) ;
7480
7581 // Prevent data loss on tab close
7682 window . addEventListener ( 'beforeunload' , ( e ) => {
@@ -200,6 +206,7 @@ async function startCalibration(photoId) {
200206 // Configure navigator
201207 setCameraConfig ( metadata . camera ) ;
202208 setTargets ( metadata . targets ) ;
209+ setRearViewTargets ( metadata . targets , metadata . camera ) ;
203210
204211 // Reset camera to look straight at the image center (lon=0).
205212 // The navigator adds imageHeading when computing world yaw,
@@ -216,6 +223,14 @@ async function startCalibration(photoId) {
216223 const fullUrl = getPhotoImageUrl ( photoId , 'full' ) ;
217224 await loadProgressive ( previewUrl , fullUrl ) ;
218225
226+ // Show rear view in preview viewer
227+ showRearView (
228+ photoId ,
229+ metadata . camera ?. mesh_rotation_y ?? 180 ,
230+ metadata . camera ?. mesh_rotation_x ?? 0 ,
231+ metadata . camera ?. mesh_rotation_z ?? 0 ,
232+ ) ;
233+
219234 // Update minimap
220235 updateCamera ( metadata . camera ) ;
221236 updateTargets ( metadata . targets ) ;
@@ -264,15 +279,24 @@ function initializeSubsystems() {
264279 initPanel ( panelContainer , {
265280 onSave : handleSave ,
266281 onDiscard : handleDiscard ,
267- onMeshRotationPreview : ( degrees ) => viewerSetMeshRotationY ( degrees ) ,
282+ onMeshRotationPreview : ( degrees ) => {
283+ viewerSetMeshRotationY ( degrees ) ;
284+ updateRearViewRotation ( degrees , state . editedMeshRotationX ?? 0 , state . editedMeshRotationZ ?? 0 ) ;
285+ } ,
268286 onCameraHeightPreview : ( height ) => {
269287 // Update navigator camera config live for ground-plane projection preview
270288 if ( state . currentMetadata ?. camera ) {
271289 setCameraConfig ( { ...state . currentMetadata . camera , height, distance_scale : state . editedDistanceScale , marker_scale : state . editedMarkerScale } ) ;
272290 }
273291 } ,
274- onMeshRotationXPreview : ( degrees ) => viewerSetMeshRotationX ( degrees ) ,
275- onMeshRotationZPreview : ( degrees ) => viewerSetMeshRotationZ ( degrees ) ,
292+ onMeshRotationXPreview : ( degrees ) => {
293+ viewerSetMeshRotationX ( degrees ) ;
294+ updateRearViewRotation ( state . editedMeshRotationY ?? 180 , degrees , state . editedMeshRotationZ ?? 0 ) ;
295+ } ,
296+ onMeshRotationZPreview : ( degrees ) => {
297+ viewerSetMeshRotationZ ( degrees ) ;
298+ updateRearViewRotation ( state . editedMeshRotationY ?? 180 , state . editedMeshRotationX ?? 0 , degrees ) ;
299+ } ,
276300 onDistanceScalePreview : ( scale ) => {
277301 // Update navigator camera config with new distance_scale
278302 if ( state . currentMetadata ?. camera ) {
@@ -290,10 +314,8 @@ function initializeSubsystems() {
290314 onNextPhoto : handleNextPhoto ,
291315 onPrevPhoto : handlePrevPhoto ,
292316 onBackToProjects : ( ) => showProjectSelector ( ) ,
293- onGridToggle : ( visible ) => {
294- setGridVisible ( visible ) ;
295- setGroundGridVisible ( visible ) ;
296- } ,
317+ onSphericalGridToggle : ( visible ) => setGridVisible ( visible ) ,
318+ onGroundGridToggle : ( visible ) => setGroundGridVisible ( visible ) ,
297319 onAddTarget : handleAddTarget ,
298320 onDeleteTarget : handleDeleteTarget ,
299321 onNearbyPreviewToggle : handleNearbyPreviewToggle ,
@@ -329,26 +351,53 @@ function initializeSubsystems() {
329351 clearNearbyPreview ( ) ;
330352 showAddButton ( false ) ;
331353
332- // Fetch target photo metadata to get its mesh_rotation_y
354+ // Fetch target photo metadata to get its mesh_rotation_y/x/z
333355 fetchPhotoMetadata ( target . id ) . then ( meta => {
334356 // Only show if still the same target
335357 if ( state . selectedTargetId === target . id ) {
336358 showPreview (
337359 target . id ,
338360 target . display_name || target . id . slice ( 0 , 8 ) ,
339- meta . camera ?. mesh_rotation_y ?? 180
361+ meta . camera ?. mesh_rotation_y ?? 180 ,
362+ meta . camera ?. mesh_rotation_x ?? 0 ,
363+ meta . camera ?. mesh_rotation_z ?? 0 ,
340364 ) ;
365+ showTargetActions ( true , {
366+ onHide : ( ) => {
367+ const hidden = isTargetHidden ( state . selectedTargetId ) ;
368+ stateSetTargetHidden ( state . selectedTargetId , ! hidden ) ;
369+ updateHideButtonState ( ! hidden ) ;
370+ } ,
371+ onSetFromClick : ( ) => {
372+ setSetFromClickMode ( ! state . setFromClickMode ) ;
373+ refreshCursor ( ) ;
374+ } ,
375+ isHidden : isTargetHidden ( target . id ) ,
376+ } ) ;
341377 }
342378 } ) . catch ( ( ) => {
343379 // Still show without correct mesh rotation
344380 if ( state . selectedTargetId === target . id ) {
345381 showPreview ( target . id , target . display_name || target . id . slice ( 0 , 8 ) ) ;
382+ showTargetActions ( true , {
383+ onHide : ( ) => {
384+ const hidden = isTargetHidden ( state . selectedTargetId ) ;
385+ stateSetTargetHidden ( state . selectedTargetId , ! hidden ) ;
386+ updateHideButtonState ( ! hidden ) ;
387+ } ,
388+ onSetFromClick : ( ) => {
389+ setSetFromClickMode ( ! state . setFromClickMode ) ;
390+ refreshCursor ( ) ;
391+ } ,
392+ isHidden : isTargetHidden ( target . id ) ,
393+ } ) ;
346394 }
347395 } ) ;
348396 }
349397 } else if ( ! s . selectedTargetId ) {
350398 lastPreviewTargetId = null ;
351- // Only hide preview if no nearby preview is active
399+ showTargetActions ( false ) ;
400+ // Switch back to rear view (or hide if no nearby preview)
352401 const { previewingId } = getNearbyPreviewState ( ) ;
353402 if ( ! previewingId ) {
354403 hidePreview ( ) ;
@@ -365,6 +414,11 @@ function onViewerRender(cameraState) {
365414 // Update navigator projection each frame
366415 updateCameraState ( cameraState ) ;
367416 updateNavigator ( cameraState ) ;
417+
418+ // Sync rear view camera direction with main viewer (opposite direction)
419+ const lonDeg = ( cameraState . yaw * 180 ) / Math . PI ;
420+ const latDeg = ( cameraState . pitch * 180 ) / Math . PI ;
421+ syncRearViewCamera ( lonDeg , latDeg , cameraState . fov ) ;
368422}
369423
370424// ============================================================================
@@ -736,6 +790,7 @@ async function refreshTargetsAndNearby() {
736790 // Update navigator and minimap with new targets
737791 setCameraConfig ( metadata . camera ) ;
738792 setTargets ( metadata . targets ) ;
793+ setRearViewTargets ( metadata . targets , metadata . camera ) ;
739794 updateTargets ( metadata . targets ) ;
740795
741796 // Re-fetch nearby photos
@@ -803,16 +858,21 @@ function handleNearbySelect(nearbyPhoto) {
803858// KEYBOARD SHORTCUTS
804859// ============================================================================
805860
861+ // ── Held-key state for smooth WASD rotation ──
862+ const heldKeys = new Set ( ) ;
863+ let heldKeysAnimId = null ;
864+ let lastHeldKeyTime = 0 ;
865+ const ROTATION_RATE = 20 ; // degrees per second
866+
806867function onKeyDown ( e ) {
807868 // Don't handle shortcuts when typing in inputs
808869 if ( e . target . tagName === 'INPUT' || e . target . tagName === 'TEXTAREA' ) return ;
809870
810871 // Ctrl+S / Cmd+S = Save
811872 if ( ( e . ctrlKey || e . metaKey ) && e . key === 's' ) {
812873 e . preventDefault ( ) ;
813- if ( isDirty ( ) ) {
814- handleSave ( ) ;
815- }
874+ if ( isDirty ( ) ) handleSave ( ) ;
875+ return ;
816876 }
817877
818878 // Escape = Cancel modes / Deselect target
@@ -823,38 +883,147 @@ function onKeyDown(e) {
823883 } else if ( state . selectedTargetId ) {
824884 deselectTarget ( ) ;
825885 }
886+ return ;
826887 }
827888
828- // Review workflow shortcuts
829- // R = Toggle reviewed
830- if ( e . key === 'r' && ! e . ctrlKey && ! e . metaKey ) {
831- handleMarkReviewed ( ! state . calibrationReviewed ) ;
889+ // ── Smooth rotation keys (WASD) — held for continuous rotation ──
890+ if ( [ 'w' , 'a' , 's' , 'd' ] . includes ( e . key ) ) {
891+ e . preventDefault ( ) ;
892+ if ( ! heldKeys . has ( e . key ) ) {
893+ heldKeys . add ( e . key ) ;
894+ if ( ! heldKeysAnimId ) startHeldKeysLoop ( ) ;
895+ }
896+ return ;
832897 }
833898
834- // N or ] = Next photo
835- if ( ( e . key === 'n' || e . key === ']' ) && ! e . ctrlKey && ! e . metaKey ) {
836- handleNextPhoto ( ) ;
837- }
899+ // ── Instant action shortcuts ──
838900
839- // P or [ or Q = Previous photo
840- if ( ( e . key === 'p' || e . key === '[' || e . key === 'q' ) && ! e . ctrlKey && ! e . metaKey ) {
841- handlePrevPhoto ( ) ;
901+ // Q = Previous photo
902+ if ( e . key === 'q' ) handlePrevPhoto ( ) ;
903+
904+ // E = Save + mark reviewed + next
905+ if ( e . key === 'e' ) handleMarkReviewedAndNext ( ) ;
906+
907+ // R = Toggle hide selected marker (requires selected target)
908+ if ( e . key === 'r' && state . selectedTargetId ) {
909+ stateSetTargetHidden ( state . selectedTargetId , ! isTargetHidden ( state . selectedTargetId ) ) ;
842910 }
843911
844- // E = Mark reviewed + go to next (efficient workflow)
845- if ( e . key === 'e' && ! e . ctrlKey && ! e . metaKey ) {
846- handleMarkReviewedAndNext ( ) ;
912+ // F = Toggle set-from-click mode (requires selected target)
913+ if ( e . key === 'f' && state . selectedTargetId ) {
914+ setSetFromClickMode ( ! state . setFromClickMode ) ;
915+ refreshCursor ( ) ;
847916 }
848917
849- // G = Toggle perspective grid
850- if ( e . key === 'g' && ! e . ctrlKey && ! e . metaKey ) {
918+ // G = Toggle spherical grid
919+ if ( e . key === 'g' ) {
851920 const newState = ! isGridVisible ( ) ;
852921 setGridVisible ( newState ) ;
853- setGroundGridVisible ( newState ) ;
854- setGridToggleState ( newState ) ;
922+ setSphericalGridToggleState ( newState ) ;
923+ }
924+
925+ // Z = Reset mesh_rotation_z to 0
926+ if ( e . key === 'z' ) {
927+ resetMeshRotationZ ( ) ;
928+ }
929+
930+ // X = Reset mesh_rotation_x to 0
931+ if ( e . key === 'x' ) {
932+ resetMeshRotationX ( ) ;
855933 }
856934}
857935
936+ function onKeyUp ( e ) {
937+ if ( [ 'w' , 'a' , 's' , 'd' ] . includes ( e . key ) ) {
938+ heldKeys . delete ( e . key ) ;
939+ if ( heldKeys . size === 0 ) {
940+ stopHeldKeysLoop ( ) ;
941+ flushHeldKeyState ( ) ;
942+ }
943+ }
944+ }
945+
946+ function onWindowBlur ( ) {
947+ if ( heldKeys . size > 0 ) {
948+ heldKeys . clear ( ) ;
949+ stopHeldKeysLoop ( ) ;
950+ flushHeldKeyState ( ) ;
951+ }
952+ }
953+
954+ function startHeldKeysLoop ( ) {
955+ lastHeldKeyTime = 0 ;
956+ function tick ( timestamp ) {
957+ if ( heldKeys . size === 0 ) {
958+ heldKeysAnimId = null ;
959+ return ;
960+ }
961+ if ( ! lastHeldKeyTime ) lastHeldKeyTime = timestamp ;
962+ const dt = Math . min ( ( timestamp - lastHeldKeyTime ) / 1000 , 0.1 ) ;
963+ lastHeldKeyTime = timestamp ;
964+ const delta = ROTATION_RATE * dt ;
965+
966+ if ( heldKeys . has ( 'w' ) ) adjustMeshRotationZ ( + delta , true ) ;
967+ if ( heldKeys . has ( 's' ) ) adjustMeshRotationZ ( - delta , true ) ;
968+ if ( heldKeys . has ( 'a' ) ) adjustMeshRotationX ( + delta , true ) ;
969+ if ( heldKeys . has ( 'd' ) ) adjustMeshRotationX ( - delta , true ) ;
970+
971+ heldKeysAnimId = requestAnimationFrame ( tick ) ;
972+ }
973+ heldKeysAnimId = requestAnimationFrame ( tick ) ;
974+ }
975+
976+ function stopHeldKeysLoop ( ) {
977+ if ( heldKeysAnimId ) {
978+ cancelAnimationFrame ( heldKeysAnimId ) ;
979+ heldKeysAnimId = null ;
980+ }
981+ lastHeldKeyTime = 0 ;
982+ }
983+
984+ /** Triggers panel re-render after held-key rotation ends. */
985+ function flushHeldKeyState ( ) {
986+ const x = state . editedMeshRotationX ;
987+ if ( x !== null && x !== undefined ) {
988+ setMeshRotationX ( x ) ; // non-silent → triggers notify/re-render
989+ } else {
990+ const z = state . editedMeshRotationZ ;
991+ if ( z !== null && z !== undefined ) {
992+ setMeshRotationZ ( z ) ;
993+ }
994+ }
995+ }
996+
997+ function adjustMeshRotationX ( delta , silent = false ) {
998+ const current = state . editedMeshRotationX ?? 0 ;
999+ const newVal = Math . max ( - 30 , Math . min ( 30 , current + delta ) ) ;
1000+ setMeshRotationX ( newVal , silent ) ;
1001+ viewerSetMeshRotationX ( newVal ) ;
1002+ updateRearViewRotation ( state . editedMeshRotationY ?? 180 , newVal , state . editedMeshRotationZ ?? 0 ) ;
1003+ }
1004+
1005+ function adjustMeshRotationZ ( delta , silent = false ) {
1006+ const current = state . editedMeshRotationZ ?? 0 ;
1007+ const newVal = Math . max ( - 30 , Math . min ( 30 , current + delta ) ) ;
1008+ setMeshRotationZ ( newVal , silent ) ;
1009+ viewerSetMeshRotationZ ( newVal ) ;
1010+ updateRearViewRotation ( state . editedMeshRotationY ?? 180 , state . editedMeshRotationX ?? 0 , newVal ) ;
1011+ }
1012+
1013+ function resetMeshRotationX ( ) {
1014+ const original = state . originalMeshRotationX ?? 0 ;
1015+ setMeshRotationX ( original ) ;
1016+ viewerSetMeshRotationX ( original ) ;
1017+ updateRearViewRotation ( state . editedMeshRotationY ?? 180 , original , state . editedMeshRotationZ ?? 0 ) ;
1018+ }
1019+
1020+ function resetMeshRotationZ ( ) {
1021+ const original = state . originalMeshRotationZ ?? 0 ;
1022+ setMeshRotationZ ( original ) ;
1023+ viewerSetMeshRotationZ ( original ) ;
1024+ updateRearViewRotation ( state . editedMeshRotationY ?? 180 , state . editedMeshRotationX ?? 0 , original ) ;
1025+ }
1026+
8581027async function handleMarkReviewedAndNext ( ) {
8591028 if ( isDirty ( ) ) {
8601029 await handleSave ( ) ;
0 commit comments