Anaglyph stereo view mode for red/cyan 3D glasses
5 changed files+59−3
README.mdmodified+2−1View file
@@ -2,7 +2,8 @@
22
33 A static web app for converting meshes between formats: upload a mesh file,
44 inspect it in an interactive 3D view (shaded, wireframe, shaded + wireframe,
5-or points), and download it in a format of your choice. Formats differ in what they can store, and the UI shows exactly which
5+or points, with an optional anaglyph stereo mode for red/cyan 3D glasses),
6+and download it in a format of your choice. Formats differ in what they can store, and the UI shows exactly which
67 attributes (normals, colors) would be dropped by a lossy conversion.
78
89 Conversion is powered by [meshio](https://github.com/nschloe/meshio) running
src/App.cssmodified+5−0View file
@@ -283,6 +283,11 @@ input.share-url {
283283 border-left: 1px solid #3a4150;
284284 }
285285
286+/* separates the anaglyph toggle from the mutually-exclusive mode group */
287+.view-toolbar button.sep {
288+ border-left: 3px double #3a4150;
289+}
290+
286291 .view-toolbar button:hover {
287292 background: #2a2f39;
288293 color: #e2e5ea;
src/App.tsxmodified+10−1View file
@@ -61,6 +61,7 @@ function App() {
6161 const [busy, setBusy] = useState<'parsing' | 'exporting' | 'sizing' | null>(null)
6262 const [exportSizes, setExportSizes] = useState<Record<string, number> | null>(null)
6363 const [viewMode, setViewMode] = useState<ViewMode>('both')
64+ const [anaglyph, setAnaglyph] = useState(false)
6465 const [source, setSource] = useState<MeshSource | null>(null)
6566 const [shareStatus, setShareStatus] = useState<ShareStatus | null>(null)
6667 const fileInputRef = useRef<HTMLInputElement>(null)
@@ -116,6 +117,7 @@ function App() {
116117 if (VIEW_MODES.some((m) => m.id === payload.viewMode)) {
117118 setViewMode(payload.viewMode as ViewMode)
118119 }
120+ setAnaglyph(payload.anaglyph)
119121 if (payload.formatId === null) {
120122 setMesh(makeSampleMesh())
121123 setSource({ kind: 'sample' })
@@ -204,6 +206,7 @@ function App() {
204206 formatId: source.kind === 'file' ? source.formatId : null,
205207 exportFormatId,
206208 viewMode,
209+ anaglyph,
207210 bytes: source.kind === 'file' ? source.bytes : new Uint8Array(0),
208211 })
209212 if (url.length > MAX_SHARE_URL_CHARS) {
@@ -469,7 +472,13 @@ function App() {
469472
470473 <div className="viewport">
471474 {mesh ? (
472- <MeshView mesh={mesh} mode={viewMode} onModeChange={setViewMode} />
475+ <MeshView
476+ mesh={mesh}
477+ mode={viewMode}
478+ onModeChange={setViewMode}
479+ anaglyph={anaglyph}
480+ onAnaglyphChange={setAnaglyph}
481+ />
473482 ) : (
474483 <div className="empty-state">
475484 <p>No mesh loaded.</p>
src/MeshView.tsxmodified+36−1View file
@@ -1,7 +1,8 @@
11 import { useEffect, useMemo } from 'react'
2-import { Canvas } from '@react-three/fiber'
2+import { Canvas, useFrame, useThree } from '@react-three/fiber'
33 import { OrbitControls } from '@react-three/drei'
44 import * as THREE from 'three'
5+import { AnaglyphEffect } from 'three/addons/effects/AnaglyphEffect.js'
56 import type { MeshData } from './mesh/types'
67 import { VIEW_MODES } from './viewModes'
78 import type { ViewMode } from './viewModes'
@@ -80,14 +81,40 @@ function MeshObject({ mesh, mode }: { mesh: MeshData; mode: ViewMode }) {
8081 )
8182 }
8283
84+// While mounted, replaces the normal render with three's AnaglyphEffect
85+// (red/cyan stereo); a useFrame subscriber with priority > 0 suspends
86+// react-three-fiber's own render loop
87+function AnaglyphRenderer() {
88+ const gl = useThree((s) => s.gl)
89+ const size = useThree((s) => s.size)
90+ const effect = useMemo(() => new AnaglyphEffect(gl), [gl])
91+ useEffect(() => () => effect.dispose(), [effect])
92+ useEffect(() => {
93+ effect.setSize(size.width, size.height)
94+ }, [effect, size])
95+ useFrame(({ scene, camera, controls }) => {
96+ // Zero parallax at the orbit target, eye separation proportional to the
97+ // viewing distance, so stereo depth stays comfortable at any zoom
98+ const target = (controls as unknown as { target?: THREE.Vector3 } | null)?.target
99+ effect.planeDistance = target ? camera.position.distanceTo(target) : camera.position.length()
100+ effect.eyeSep = effect.planeDistance * 0.02
101+ effect.render(scene, camera)
102+ }, 1)
103+ return null
104+}
105+
83106 export function MeshView({
84107 mesh,
85108 mode,
86109 onModeChange,
110+ anaglyph,
111+ onAnaglyphChange,
87112 }: {
88113 mesh: MeshData
89114 mode: ViewMode
90115 onModeChange: (mode: ViewMode) => void
116+ anaglyph: boolean
117+ onAnaglyphChange: (on: boolean) => void
91118 }) {
92119 return (
93120 <>
@@ -101,6 +128,13 @@ export function MeshView({
101128 {m.label}
102129 </button>
103130 ))}
131+ <button
132+ className={`sep ${anaglyph ? 'active' : ''}`}
133+ onClick={() => onAnaglyphChange(!anaglyph)}
134+ title="Anaglyph stereo — view with red/cyan 3D glasses"
135+ >
136+ 3D
137+ </button>
104138 </div>
105139 <Canvas camera={{ position: [2.6, 1.8, 2.6], fov: 45 }}>
106140 <color attach="background" args={['#16181d']} />
@@ -109,6 +143,7 @@ export function MeshView({
109143 <directionalLight position={[-4, -3, -6]} intensity={0.4} />
110144 <MeshObject mesh={mesh} mode={mode} />
111145 <OrbitControls makeDefault />
146+ {anaglyph && <AnaglyphRenderer />}
112147 </Canvas>
113148 </>
114149 )
src/share.tsmodified+6−0View file
@@ -16,6 +16,8 @@ export interface SharePayload {
1616 formatId: string | null
1717 exportFormatId: string
1818 viewMode: string
19+ /** Anaglyph (red/cyan 3D glasses) rendering toggle */
20+ anaglyph: boolean
1921 /** Original file bytes (empty when formatId is null) */
2022 bytes: Uint8Array<ArrayBuffer>
2123 }
@@ -35,6 +37,8 @@ interface ShareHeader {
3537 format: string | null
3638 export: string
3739 view: string
40+ /** 1 when anaglyph mode is on; absent in links from older versions */
41+ ana?: number
3842 }
3943
4044 async function pipeThrough(
@@ -70,6 +74,7 @@ export async function encodeShare(payload: SharePayload): Promise<string> {
7074 format: payload.formatId,
7175 export: payload.exportFormatId,
7276 view: payload.viewMode,
77+ ana: payload.anaglyph ? 1 : 0,
7378 }
7479 const headerBytes = new TextEncoder().encode(JSON.stringify(header))
7580 const container = new Uint8Array(4 + headerBytes.length + payload.bytes.length)
@@ -106,6 +111,7 @@ export async function decodeShare(encoded: string): Promise<SharePayload> {
106111 formatId: header.format ?? null,
107112 exportFormatId: header.export,
108113 viewMode: header.view,
114+ anaglyph: header.ana === 1,
109115 bytes: container.slice(4 + headerLength),
110116 }
111117 }