concept-collection / mesh-studio
Move view controls into viewport overlay; add anaglyph 3D toggle
View mode buttons now sit in the upper right of the 3D view, next to a 3D toggle that renders red/cyan anaglyph via AnaglyphEffect. The effect's convergence plane and eye separation track the orbit distance (the defaults assume a much closer scene and double the image).
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 7f0745dcf9f7 parent 4b6e89d Browse files
5 changed files+111−16
.claude/skills/verify/SKILL.mdadded+39−0View file
@@ -0,0 +1,39 @@
1+---
2+name: verify
3+description: Build, launch, and drive mesh-studio headlessly to verify a change at the browser surface (WebGL renders fine under headless Chrome + SwiftShader).
4+---
5+
6+# Verifying mesh-studio
7+
8+`npm run build` only gates TS + bundling. The real surface is the browser
9+(OCCT WASM + three.js WebGL), and it works headlessly:
10+
11+1. `npm run dev -- --port 5199 --strictPort` (background).
12+2. Drive with playwright-core (install it in the scratchpad, not the repo)
13+ using system Chrome — WebGL needs the SwiftShader flags:
14+
15+ ```js
16+ chromium.launch({
17+ executablePath: '/usr/bin/google-chrome',
18+ headless: true,
19+ args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
20+ })
21+ ```
22+
23+3. Cheapest end-to-end flow (no network): click the "Box" primitive button.
24+ That triggers the ~50 MB OCCT WASM load (local, a few seconds) — wait for
25+ `.view-overlay` to appear and `.busy` to disappear, then screenshot.
26+ "Random CAD model" additionally exercises the network import path.
27+
28+## Gotchas
29+
30+- Rendering is deterministic — byte-identical screenshots across runs means
31+ your change did NOT take effect, not that it's stable.
32+- To pixel-check the canvas, screenshot the `.viewport canvas` locator and
33+ analyze in-page via a 2D canvas; `gl.readPixels` returns blanks because the
34+ renderer doesn't preserve the drawing buffer.
35+- Face picking: click the canvas center and look for a sidebar `h2` matching
36+ `Face #`.
37+- `node_modules` reads are permission-denied here; inspect library behavior at
38+ runtime (temporary `console.log` in app code + a page that captures console)
39+ instead of reading vendored source.
CLAUDE.mdmodified+5−2View file
@@ -19,7 +19,9 @@ src/model/ types.ts: SurfaceModel = list of Patch (discriminated union).
1919 v1 emits `nurbs` patches. tessellate.ts: pure geometry helpers
2020 (modelBounds, controlNet, mergeTriMeshes) — no three, no OCCT.
2121 src/render/ SurfaceView.tsx: plain three.js, one mesh per patch (for picking),
22- modes shaded/wire/net/iso. palette.ts: per-face colors.
22+ modes shaded/wire/net/iso, optional red/cyan anaglyph
23+ (AnaglyphEffect; planeDistance/eyeSep tracked to orbit distance —
24+ the defaults double the image). palette.ts: per-face colors.
2325 src/export/ meshWriters.ts (OBJ/PLY/STL, dependency-free), nurbsJson.ts.
2426 src/App.tsx sidebar + viewport; keeps the OpenCascade instance and the
2527 (NURBS-converted) mesh shape in refs for re-tessellation.
@@ -58,7 +60,8 @@ src/abcDataset.ts "Random CAD model" source: fetches index.json from
5860
5961 Browser is the real verification surface (WASM). `npm run build` confirms TS +
6062 bundling (including the OCCT wasm asset). Then `npm run dev` and:
61-- load each primitive; toggle shaded / wireframe / control-net / isocurves;
63+- load each primitive; toggle shaded / wireframe / control-net / isocurves and
64+ the 3D (anaglyph) button — all in the overlay at the viewport's upper right;
6265 - drag the resolution slider (faceting should visibly change);
6366 - click a face → the inspector shows degree / poles / knots;
6467 - "Random CAD model" downloads from abc-step-1000 and renders (needs the
src/App.tsxmodified+26−11View file
@@ -54,6 +54,7 @@ function App() {
5454 const [baseName, setBaseName] = useState('model')
5555 const [quality, setQuality] = useState(0.5)
5656 const [mode, setMode] = useState<ViewMode>('shaded')
57+ const [anaglyph, setAnaglyph] = useState(false)
5758 const [selectedFaceId, setSelectedFaceId] = useState<number | null>(null)
5859 const [exportId, setExportId] = useState<ExportId>('obj')
5960 const [busy, setBusy] = useState<string | null>(null)
@@ -267,17 +268,6 @@ function App() {
267268 {model && (
268269 <section>
269270 <h2>View</h2>
270- <div className="view-toolbar">
271- {VIEW_MODES.map((m) => (
272- <button
273- key={m.id}
274- className={mode === m.id ? 'active' : ''}
275- onClick={() => setMode(m.id)}
276- >
277- {m.label}
278- </button>
279- ))}
280- </div>
281271 <label className="slider">
282272 <span>Mesh resolution</span>
283273 <input
@@ -350,9 +340,34 @@ function App() {
350340 <SurfaceView
351341 model={model}
352342 mode={mode}
343+ anaglyph={anaglyph}
353344 selectedFaceId={selectedFaceId}
354345 onSelectFace={setSelectedFaceId}
355346 />
347+ {model && (
348+ <div className="view-overlay">
349+ <div className="view-toolbar">
350+ {VIEW_MODES.map((m) => (
351+ <button
352+ key={m.id}
353+ className={mode === m.id ? 'active' : ''}
354+ onClick={() => setMode(m.id)}
355+ >
356+ {m.label}
357+ </button>
358+ ))}
359+ </div>
360+ <div className="view-toolbar">
361+ <button
362+ className={anaglyph ? 'active' : ''}
363+ onClick={() => setAnaglyph((a) => !a)}
364+ title="Red/cyan anaglyph stereo (needs 3D glasses)"
365+ >
366+ 3D
367+ </button>
368+ </div>
369+ </div>
370+ )}
356371 </div>
357372 </div>
358373 )
src/index.cssmodified+18−0View file
@@ -195,6 +195,24 @@ select {
195195 overflow: hidden;
196196 }
197197
198+.view-overlay {
199+ position: absolute;
200+ top: 12px;
201+ right: 12px;
202+ display: flex;
203+ gap: 8px;
204+}
205+
206+.view-overlay .view-toolbar {
207+ flex-wrap: nowrap;
208+ background: rgba(30, 33, 40, 0.88);
209+}
210+
211+.view-overlay .view-toolbar button {
212+ flex: 0 0 auto;
213+ white-space: nowrap;
214+}
215+
198216 .view-toolbar button {
199217 flex: 1 1 auto;
200218 border: none;
src/render/SurfaceView.tsxmodified+23−3View file
@@ -7,6 +7,7 @@
77 import { useEffect, useRef } from 'react'
88 import * as THREE from 'three'
99 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
10+import { AnaglyphEffect } from 'three/examples/jsm/effects/AnaglyphEffect.js'
1011 import type { Patch, SurfaceModel } from '../model/types'
1112 import { controlNet, modelBounds } from '../model/tessellate'
1213 import { faceColor, ISO_COLOR, NET_COLOR, POLE_COLOR, SELECTED_COLOR } from './palette'
@@ -15,6 +16,7 @@ export type ViewMode = 'shaded' | 'wire' | 'net' | 'iso'
1516
1617 interface SceneState {
1718 renderer: THREE.WebGLRenderer
19+ effect: AnaglyphEffect
1820 scene: THREE.Scene
1921 camera: THREE.PerspectiveCamera
2022 controls: OrbitControls
@@ -126,11 +128,13 @@ function buildContent(
126128 export function SurfaceView({
127129 model,
128130 mode,
131+ anaglyph,
129132 selectedFaceId,
130133 onSelectFace,
131134 }: {
132135 model: SurfaceModel | null
133136 mode: ViewMode
137+ anaglyph: boolean
134138 selectedFaceId: number | null
135139 onSelectFace: (id: number | null) => void
136140 }) {
@@ -138,6 +142,8 @@ export function SurfaceView({
138142 const stateRef = useRef<SceneState | null>(null)
139143 const onSelectRef = useRef(onSelectFace)
140144 onSelectRef.current = onSelectFace
145+ const anaglyphRef = useRef(anaglyph)
146+ anaglyphRef.current = anaglyph
141147
142148 // set up the scene once
143149 useEffect(() => {
@@ -149,6 +155,8 @@ export function SurfaceView({
149155 renderer.setClearColor(0x161a22)
150156 container.appendChild(renderer.domElement)
151157
158+ const effect = new AnaglyphEffect(renderer)
159+
152160 const scene = new THREE.Scene()
153161 const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100)
154162 camera.position.set(2.4, 1.8, 2.6)
@@ -171,10 +179,20 @@ export function SurfaceView({
171179
172180 const animId = requestAnimationFrame(function loop() {
173181 controls.update()
174- renderer.render(scene, camera)
182+ if (anaglyphRef.current) {
183+ // converge the stereo eyes on the orbit target and scale the eye
184+ // separation with viewing distance (the defaults — planeDistance 0.5,
185+ // eyeSep 0.064 — assume a much closer scene and double the image)
186+ const dist = camera.position.distanceTo(controls.target)
187+ effect.planeDistance = dist
188+ effect.eyeSep = dist / 30
189+ effect.render(scene, camera)
190+ } else {
191+ renderer.render(scene, camera)
192+ }
175193 if (stateRef.current) stateRef.current.animId = requestAnimationFrame(loop)
176194 })
177- stateRef.current = { renderer, scene, camera, controls, content, raycaster, animId }
195+ stateRef.current = { renderer, effect, scene, camera, controls, content, raycaster, animId }
178196
179197 const onPointerDown = (ev: PointerEvent) => {
180198 const st = stateRef.current
@@ -195,7 +213,8 @@ export function SurfaceView({
195213 const observer = new ResizeObserver(() => {
196214 const rect = container.getBoundingClientRect()
197215 if (rect.width === 0 || rect.height === 0) return
198- renderer.setSize(rect.width, rect.height)
216+ // effect.setSize sizes the renderer plus the stereo render targets
217+ effect.setSize(rect.width, rect.height)
199218 camera.aspect = rect.width / rect.height
200219 camera.updateProjectionMatrix()
201220 })
@@ -206,6 +225,7 @@ export function SurfaceView({
206225 renderer.domElement.removeEventListener('pointerdown', onPointerDown)
207226 cancelAnimationFrame(stateRef.current?.animId ?? animId)
208227 controls.dispose()
228+ effect.dispose()
209229 renderer.dispose()
210230 container.removeChild(renderer.domElement)
211231 stateRef.current = null