/ concept-collection / mesh-studio
Sign in
concept-collection / mesh-studio
mesh-studio / src / App.tsx
376 lines · 13.1 KBBlameHistoryRaw
1import { useEffect, useRef, useState } from 'react'
2import { SurfaceView } from './render/SurfaceView'
3import type { ViewMode } from './render/SurfaceView'
4import type { NurbsPatch, SurfaceModel } from './model/types'
5import { nurbsCoverage, triangleCount, vertexCount } from './model/types'
6import { mergeTriMeshes } from './model/tessellate'
7import { loadOpenCascade } from './occ/loader'
8import type { OpenCascade, Shape } from './occ/types'
9import { buildModel, retessellate } from './occ/extract'
10import { importCadFile } from './occ/importCad'
11import { shapeToStep } from './occ/exportCad'
12import { primitives } from './sources'
13import { ABC_DATASET_URL, fetchRandomAbcStep } from './abcDataset'
14import { toOBJ, toPLY, toSTL } from './export/meshWriters'
15import { toNurbsJson } from './export/nurbsJson'
16import './index.css'
18type EngineState = 'idle' | 'loading' | 'ready' | 'error'
20const VIEW_MODES: { id: ViewMode; label: string }[] = [
21 { id: 'shaded', label: 'Shaded' },
22 { id: 'wire', label: 'Wireframe' },
23 { id: 'net', label: 'Control net' },
24 { id: 'iso', label: 'Isocurves' },
27const EXPORT_FORMATS = [
28 { id: 'obj', label: 'OBJ (triangles)', ext: '.obj' },
29 { id: 'ply', label: 'PLY (triangles)', ext: '.ply' },
30 { id: 'stl', label: 'STL (triangles)', ext: '.stl' },
31 { id: 'nurbs', label: 'NURBS patches (JSON)', ext: '.nurbs.json' },
32 { id: 'step', label: 'STEP (B-rep)', ext: '.step' },
33] as const
35type ExportId = (typeof EXPORT_FORMATS)[number]['id']
37function download(bytes: Uint8Array, filename: string) {
38 // copy into a fresh ArrayBuffer-backed view so it is a valid BlobPart
39 const blob = new Blob([new Uint8Array(bytes)], { type: 'application/octet-stream' })
40 const url = URL.createObjectURL(blob)
41 const a = document.createElement('a')
42 a.href = url
43 a.download = filename
44 a.click()
45 URL.revokeObjectURL(url)
48function App() {
49 const [engine, setEngine] = useState<{ state: EngineState; message: string }>({
50 state: 'idle',
51 message: 'CAD engine loads on first use (~30 MB).',
52 })
53 const [model, setModel] = useState<SurfaceModel | null>(null)
54 const [baseName, setBaseName] = useState('model')
55 const [quality, setQuality] = useState(0.5)
56 const [mode, setMode] = useState<ViewMode>('shaded')
57 const [anaglyph, setAnaglyph] = useState(false)
58 const [selectedFaceId, setSelectedFaceId] = useState<number | null>(null)
59 const [exportId, setExportId] = useState<ExportId>('obj')
60 const [busy, setBusy] = useState<string | null>(null)
61 const [error, setError] = useState<string | null>(null)
63 const ocRef = useRef<OpenCascade | null>(null)
64 const meshShapeRef = useRef<Shape | null>(null)
65 const fileInputRef = useRef<HTMLInputElement>(null)
67 async function ensureOc(): Promise<OpenCascade> {
68 if (ocRef.current) return ocRef.current
69 const oc = await loadOpenCascade((message) => setEngine({ state: 'loading', message }))
70 ocRef.current = oc
71 setEngine({ state: 'ready', message: 'CAD engine ready' })
72 return oc
73 }
75 async function load(
76 build: (oc: OpenCascade) => Shape,
77 source: SurfaceModel['source'],
78 name: string,
79 raw?: SurfaceModel['raw'],
80 ) {
81 setError(null)
82 setBusy('building')
83 setSelectedFaceId(null)
84 try {
85 const oc = await ensureOc()
86 const shape = build(oc)
87 const { model: built, meshShape } = buildModel(oc, shape, quality, source, raw)
88 meshShapeRef.current = meshShape
89 setModel(built)
90 setBaseName(name)
91 } catch (e) {
92 setError(e instanceof Error ? e.message : String(e))
93 setEngine((s) => (s.state === 'loading' ? { state: 'error', message: 'CAD engine failed to load.' } : s))
94 } finally {
95 setBusy(null)
96 }
97 }
99 const loadPrimitive = (id: string) => {
100 const src = primitives.find((p) => p.id === id)
101 if (!src) return
102 void load(src.build, { kind: 'primitive', label: src.label }, src.id)
103 }
105 const loadRandomAbc = async () => {
106 setError(null)
107 setBusy('downloading')
108 try {
109 const { name, bytes } = await fetchRandomAbcStep()
110 await load(
111 (oc) => importCadFile(oc, name, bytes).shape,
112 { kind: 'step', label: `${name} (ABC dataset)` },
113 name.replace(/\.[^.]+$/, ''),
114 { format: 'step', bytes },
115 )
116 } catch (e) {
117 setError(e instanceof Error ? e.message : String(e))
118 setBusy(null)
119 }
120 }
122 const openFile = async (file: File) => {
123 const bytes = new Uint8Array(await file.arrayBuffer())
124 const lower = file.name.toLowerCase()
125 const format: 'step' | 'iges' = lower.endsWith('.iges') || lower.endsWith('.igs') ? 'iges' : 'step'
126 void load(
127 (oc) => importCadFile(oc, file.name, bytes).shape,
128 { kind: format, label: file.name },
129 file.name.replace(/\.[^.]+$/, ''),
130 { format, bytes },
131 )
132 }
134 // Re-tessellate (debounced) when the resolution slider settles.
135 useEffect(() => {
136 const oc = ocRef.current
137 const meshShape = meshShapeRef.current
138 if (!oc || !meshShape || !model) return
139 const t = setTimeout(() => {
140 setBusy('meshing')
141 try {
142 const patches = retessellate(oc, meshShape, quality, model.patches)
143 setModel((m) => (m ? { ...m, patches } : m))
144 } catch (e) {
145 setError(e instanceof Error ? e.message : String(e))
146 } finally {
147 setBusy(null)
148 }
149 }, 150)
150 return () => clearTimeout(t)
151 // eslint-disable-next-line react-hooks/exhaustive-deps
152 }, [quality])
154 const doExport = () => {
155 if (!model) return
156 setError(null)
157 try {
158 const fmt = EXPORT_FORMATS.find((f) => f.id === exportId)!
159 let bytes: Uint8Array
160 if (exportId === 'nurbs') {
161 bytes = toNurbsJson(model)
162 } else if (exportId === 'step') {
163 if (model.raw) {
164 bytes = model.raw.bytes
165 } else if (ocRef.current && meshShapeRef.current) {
166 bytes = shapeToStep(ocRef.current, meshShapeRef.current)
167 } else {
168 throw new Error('STEP export unavailable for this model.')
169 }
170 } else {
171 const merged = mergeTriMeshes(model)
172 bytes = exportId === 'obj' ? toOBJ(merged) : exportId === 'ply' ? toPLY(merged) : toSTL(merged)
173 }
174 const base = baseName.replace(/[^\w-]+/g, '_').toLowerCase() || 'model'
175 download(bytes, base + fmt.ext)
176 } catch (e) {
177 setError(e instanceof Error ? e.message : String(e))
178 }
179 }
181 const selectedPatch =
182 selectedFaceId != null
183 ? (model?.patches.find((p) => p.id === selectedFaceId) as NurbsPatch | undefined)
184 : undefined
185 const coverage = model ? nurbsCoverage(model) : null
187 return (
188 <div className="app">
189 <div className="sidebar">
190 <h1>Mesh Studio</h1>
191 <p className="tagline">
192 Generate surface meshes with different tools and inspect them in 3D. First tool:{' '}
193 <a href="https://ocjs.org/">OpenCASCADE.js</a> — CAD B-rep faces are true NURBS surfaces
194 (polynomials on faces), extracted here alongside the triangulation.
195 </p>
196 <div className={`engine-status ${engine.state}`}>{engine.message}</div>
198 <section>
199 <h2>Sources</h2>
200 <div className="primitive-grid">
201 {primitives.map((p) => (
202 <button
203 key={p.id}
204 onClick={() => loadPrimitive(p.id)}
205 disabled={busy !== null}
206 title={p.blurb}
207 >
208 {p.label}
209 </button>
210 ))}
211 </div>
212 <div className="button-row">
213 <button onClick={() => fileInputRef.current?.click()} disabled={busy !== null}>
214 Open STEP/IGES…
215 </button>
216 </div>
217 <button
218 className="primary"
219 onClick={() => void loadRandomAbc()}
220 disabled={busy !== null}
221 title="Download a random CAD model from the first 1000 STEP files of the ABC dataset"
222 >
223 Random CAD model
224 </button>
225 <p className="footnote">
226 Random models are drawn from{' '}
227 <a href="https://concept-collection.github.io/abc-step-1000/">abc-step-1000</a>, a
228 rehosted slice of the <a href={ABC_DATASET_URL}>ABC dataset</a> of CAD models (Koch et
229 al., CVPR 2019).
230 </p>
231 <input
232 ref={fileInputRef}
233 type="file"
234 accept=".step,.stp,.iges,.igs"
235 hidden
236 onChange={(e) => {
237 const file = e.target.files?.[0]
238 if (file) void openFile(file)
239 e.target.value = ''
240 }}
241 />
242 {busy && (
243 <div className="busy">
244 {busy === 'building' ? 'Building model…' : busy === 'downloading' ? 'Downloading model…' : 'Re-meshing…'}
245 </div>
246 )}
247 {error && <div className="error">{error}</div>}
248 </section>
250 {model && (
251 <section>
252 <h2>Model</h2>
253 <div className="mesh-info">
254 <div className="source">{model.source.label}</div>
255 <div>
256 {model.patches.length} faces · {triangleCount(model).toLocaleString()} triangles ·{' '}
257 {vertexCount(model).toLocaleString()} vertices
258 </div>
259 {coverage && (
260 <div className={`chip ${coverage.withNurbs === coverage.total ? 'on' : ''}`}>
261 NURBS extracted on {coverage.withNurbs}/{coverage.total} faces
262 </div>
263 )}
264 </div>
265 </section>
266 )}
268 {model && (
269 <section>
270 <h2>View</h2>
271 <label className="slider">
272 <span>Mesh resolution</span>
273 <input
274 type="range"
275 min={0}
276 max={1}
277 step={0.01}
278 value={quality}
279 onChange={(e) => setQuality(Number(e.target.value))}
280 disabled={busy !== null}
281 />
282 </label>
283 <p className="footnote">
284 Coarse ↔ fine re-tessellates the same NURBS faces — drag to see the polynomial
285 surface go from faceted to smooth. Click a face to inspect it.
286 </p>
287 </section>
288 )}
290 {selectedPatch && (
291 <section>
292 <h2>Face #{selectedPatch.id}</h2>
293 {selectedPatch.nurbs ? (
294 <div className="mesh-info">
295 <div>
296 Degree (u, v): <strong>{selectedPatch.nurbs.uDegree}, {selectedPatch.nurbs.vDegree}</strong>
297 </div>
298 <div>
299 Control net: {selectedPatch.nurbs.nu} × {selectedPatch.nurbs.nv} poles
300 </div>
301 <div>{selectedPatch.nurbs.weights ? 'Rational (NURBS)' : 'Polynomial (non-rational)'}</div>
302 <div>
303 Knots: {selectedPatch.nurbs.uKnots.length} u, {selectedPatch.nurbs.vKnots.length} v
304 </div>
305 <div className="footnote">
306 {selectedPatch.tri.indices.length / 3} triangles at this resolution
307 </div>
308 </div>
309 ) : (
310 <div className="mesh-info">No NURBS data extracted for this face.</div>
311 )}
312 <button className="subtle" onClick={() => setSelectedFaceId(null)}>
313 Clear selection
314 </button>
315 </section>
316 )}
318 {model && (
319 <section>
320 <h2>Export</h2>
321 <select value={exportId} onChange={(e) => setExportId(e.target.value as ExportId)}>
322 {EXPORT_FORMATS.map((f) => (
323 <option key={f.id} value={f.id}>
324 {f.label}
325 </option>
326 ))}
327 </select>
328 <button className="primary" onClick={doExport} disabled={busy !== null}>
329 Download {EXPORT_FORMATS.find((f) => f.id === exportId)!.ext}
330 </button>
331 <p className="footnote">
332 Triangle formats export the current tessellation. NURBS JSON stores the exact
333 polynomial patches. STEP hands back the B-rep (original bytes for imported files).
334 </p>
335 </section>
336 )}
337 </div>
339 <div className="viewport">
340 <SurfaceView
341 model={model}
342 mode={mode}
343 anaglyph={anaglyph}
344 selectedFaceId={selectedFaceId}
345 onSelectFace={setSelectedFaceId}
346 />
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 )}
371 </div>
372 </div>
373 )
376export default App
moveopenescclose