@@ -254,6 +254,164 @@ class ThreeViewer {
254254 return this . renderer . domElement . toDataURL ( 'image/png' ) ;
255255 }
256256
257+ /**
258+ * Set camera to a named view preset
259+ * External views: camera outside arena looking in (like cube faces)
260+ * Internal views: camera at arena center looking out toward display
261+ * @param {string } preset - View preset name
262+ */
263+ setViewPreset ( preset ) {
264+ if ( ! this . arenaConfig || ! this . panelSpecs ) return ;
265+
266+ const arena = this . arenaConfig . arena ;
267+ const panelWidth = this . panelSpecs . panel_width_mm / 25.4 ;
268+ const alpha = ( 2 * Math . PI ) / arena . num_cols ;
269+ const cRadius = panelWidth / ( Math . tan ( alpha / 2 ) ) / 2 ;
270+ const viewDistance = cRadius * 3 ;
271+ const midlineY = 0 ;
272+
273+ switch ( preset ) {
274+ // External views (camera outside, looking in)
275+ case 'top-down' :
276+ this . camera . position . set ( 0 , viewDistance , 0.01 ) ;
277+ this . controls . target . set ( 0 , midlineY , 0 ) ;
278+ break ;
279+ case 'bottom-up' :
280+ this . camera . position . set ( 0 , - viewDistance , 0.01 ) ;
281+ this . controls . target . set ( 0 , midlineY , 0 ) ;
282+ break ;
283+ case 'from-north' :
284+ this . camera . position . set ( 0 , midlineY , - viewDistance ) ;
285+ this . controls . target . set ( 0 , midlineY , 0 ) ;
286+ break ;
287+ case 'from-east' :
288+ this . camera . position . set ( viewDistance , midlineY , 0 ) ;
289+ this . controls . target . set ( 0 , midlineY , 0 ) ;
290+ break ;
291+ case 'from-south' :
292+ this . camera . position . set ( 0 , midlineY , viewDistance ) ;
293+ this . controls . target . set ( 0 , midlineY , 0 ) ;
294+ break ;
295+ case 'from-west' :
296+ this . camera . position . set ( - viewDistance , midlineY , 0 ) ;
297+ this . controls . target . set ( 0 , midlineY , 0 ) ;
298+ break ;
299+
300+ // Internal views (camera at center, looking outward)
301+ case 'fly-north' :
302+ this . camera . position . set ( 0 , midlineY , 0 ) ;
303+ this . controls . target . set ( 0 , midlineY , - cRadius ) ;
304+ break ;
305+ case 'fly-east' :
306+ this . camera . position . set ( 0 , midlineY , 0 ) ;
307+ this . controls . target . set ( cRadius , midlineY , 0 ) ;
308+ break ;
309+ case 'fly-south' :
310+ this . camera . position . set ( 0 , midlineY , 0 ) ;
311+ this . controls . target . set ( 0 , midlineY , cRadius ) ;
312+ break ;
313+ case 'fly-west' :
314+ this . camera . position . set ( 0 , midlineY , 0 ) ;
315+ this . controls . target . set ( - cRadius , midlineY , 0 ) ;
316+ break ;
317+ }
318+ this . controls . update ( ) ;
319+ }
320+
321+ /**
322+ * Set camera field of view
323+ * @param {number } fov - FOV in degrees (30-120)
324+ * @param {boolean } compensateDistance - Adjust camera distance to maintain apparent size
325+ */
326+ setFOV ( fov , compensateDistance = false ) {
327+ const oldFOV = this . camera . fov ;
328+ this . camera . fov = fov ;
329+ this . camera . updateProjectionMatrix ( ) ;
330+
331+ if ( compensateDistance && oldFOV !== fov ) {
332+ const oldTan = Math . tan ( ( oldFOV / 2 ) * Math . PI / 180 ) ;
333+ const newTan = Math . tan ( ( fov / 2 ) * Math . PI / 180 ) ;
334+ const scale = oldTan / newTan ;
335+
336+ const direction = new THREE . Vector3 ( ) ;
337+ direction . subVectors ( this . camera . position , this . controls . target ) ;
338+ direction . multiplyScalar ( scale ) ;
339+ this . camera . position . copy ( this . controls . target ) . add ( direction ) ;
340+ this . controls . update ( ) ;
341+ }
342+ }
343+
344+ /**
345+ * Zoom in by moving camera closer to target
346+ */
347+ zoomIn ( ) {
348+ const direction = new THREE . Vector3 ( ) ;
349+ direction . subVectors ( this . camera . position , this . controls . target ) ;
350+ direction . multiplyScalar ( 0.8 ) ;
351+ this . camera . position . copy ( this . controls . target ) . add ( direction ) ;
352+ this . controls . update ( ) ;
353+ }
354+
355+ /**
356+ * Zoom out by moving camera farther from target
357+ */
358+ zoomOut ( ) {
359+ const direction = new THREE . Vector3 ( ) ;
360+ direction . subVectors ( this . camera . position , this . controls . target ) ;
361+ direction . multiplyScalar ( 1.25 ) ;
362+ this . camera . position . copy ( this . controls . target ) . add ( direction ) ;
363+ this . controls . update ( ) ;
364+ }
365+
366+ /**
367+ * Get arena statistics for display
368+ * @returns {Object|null } Stats object or null if no arena config
369+ */
370+ getArenaStats ( ) {
371+ if ( ! this . arenaConfig || ! this . panelSpecs ) return null ;
372+
373+ const arena = this . arenaConfig . arena ;
374+ const specs = this . panelSpecs ;
375+ const numCols = arena . num_cols ;
376+ const numRows = arena . num_rows ;
377+
378+ const panelWidth = specs . panel_width_mm / 25.4 ;
379+ const panelHeight = specs . panel_height_mm / 25.4 ;
380+ const alpha = ( 2 * Math . PI ) / numCols ;
381+ const cRadius = panelWidth / ( Math . tan ( alpha / 2 ) ) / 2 ;
382+ const arenaHeight = panelHeight * numRows ;
383+
384+ // Installed columns (for partial arenas)
385+ const installedCols = arena . columns_installed
386+ ? arena . columns_installed . length
387+ : numCols ;
388+ const totalPanels = installedCols * numRows ;
389+ const totalLEDs = totalPanels * specs . pixels_per_panel * specs . pixels_per_panel ;
390+
391+ const azimuthPixels = numCols * specs . pixels_per_panel ;
392+ const verticalPixels = numRows * specs . pixels_per_panel ;
393+ const azimuthRes = 360 / azimuthPixels ;
394+
395+ const halfAngleRad = Math . atan ( ( arenaHeight / 2 ) / cRadius ) ;
396+ const totalVerticalDegrees = 2 * halfAngleRad * ( 180 / Math . PI ) ;
397+ const verticalRes = totalVerticalDegrees / verticalPixels ;
398+
399+ const radiusMM = cRadius * 25.4 ;
400+ const heightMM = arenaHeight * 25.4 ;
401+
402+ return {
403+ columns : installedCols ,
404+ totalPanels,
405+ innerRadius : radiusMM ,
406+ arenaHeight : heightMM ,
407+ totalLEDs,
408+ azimuthRes,
409+ verticalRes,
410+ azimuthPixels,
411+ verticalPixels
412+ } ;
413+ }
414+
257415 /**
258416 * Clean up and destroy the viewer
259417 */
0 commit comments