Skip to content

Commit b93da82

Browse files
mbreiserclaude
andcommitted
feat: Add 3D viewer controls to Pattern Editor (#29)
Port view controls, FOV, zoom, screenshot, and arena statistics from the standalone 3D viewer into the Pattern Editor's 3D tab. - 10 view presets: 6 external (top/bottom/N/E/S/W) + 4 fly views - FOV slider (30-120°) with Normal (60°) and Wide (120°) presets - Zoom +/- buttons for camera distance control - Screenshot button (downloads PNG of 3D panel only) - Collapsible arena stats overlay (radius, height, LEDs, resolution) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ab111db commit b93da82

2 files changed

Lines changed: 436 additions & 3 deletions

File tree

js/pattern-editor/viewers/three-viewer.js

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)