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