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' },
25]
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)
46}
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 [selectedFaceId, setSelectedFaceId] = useState<number | null>(null)
58 const [exportId, setExportId] = useState<ExportId>('obj')
59 const [busy, setBusy] = useState<string | null>(null)
60 const [error, setError] = useState<string | null>(null)
62 const ocRef = useRef<OpenCascade | null>(null)
63 const meshShapeRef = useRef<Shape | null>(null)
64 const fileInputRef = useRef<HTMLInputElement>(null)
66 async function ensureOc(): Promise<OpenCascade> {
67 if (ocRef.current) return ocRef.current
68 const oc = await loadOpenCascade((message) => setEngine({ state: 'loading', message }))
69 ocRef.current = oc
70 setEngine({ state: 'ready', message: 'CAD engine ready' })
71 return oc
72 }
74 async function load(
75 build: (oc: OpenCascade) => Shape,
76 source: SurfaceModel['source'],
77 name: string,
78 raw?: SurfaceModel['raw'],
79 ) {
80 setError(null)
81 setBusy('building')
82 setSelectedFaceId(null)
83 try {
84 const oc = await ensureOc()
85 const shape = build(oc)
86 const { model: built, meshShape } = buildModel(oc, shape, quality, source, raw)
87 meshShapeRef.current = meshShape
88 setModel(built)
89 setBaseName(name)
90 } catch (e) {
91 setError(e instanceof Error ? e.message : String(e))
92 setEngine((s) => (s.state === 'loading' ? { state: 'error', message: 'CAD engine failed to load.' } : s))
93 } finally {
94 setBusy(null)
95 }
96 }
98 const loadPrimitive = (id: string) => {
99 const src = primitives.find((p) => p.id === id)
100 if (!src) return
101 void load(src.build, { kind: 'primitive', label: src.label }, src.id)
102 }
104 const loadRandomAbc = async () => {
105 setError(null)
106 setBusy('downloading')
107 try {
108 const { name, bytes } = await fetchRandomAbcStep()
109 await load(
110 (oc) => importCadFile(oc, name, bytes).shape,
111 { kind: 'step', label: `${name} (ABC dataset)` },
112 name.replace(/\.[^.]+$/, ''),
113 { format: 'step', bytes },
114 )
115 } catch (e) {
116 setError(e instanceof Error ? e.message : String(e))
117 setBusy(null)
118 }
119 }
121 const openFile = async (file: File) => {
122 const bytes = new Uint8Array(await file.arrayBuffer())
123 const lower = file.name.toLowerCase()
124 const format: 'step' | 'iges' = lower.endsWith('.iges') || lower.endsWith('.igs') ? 'iges' : 'step'
125 void load(
126 (oc) => importCadFile(oc, file.name, bytes).shape,
127 { kind: format, label: file.name },
128 file.name.replace(/\.[^.]+$/, ''),
129 { format, bytes },
130 )
131 }
133 // Re-tessellate (debounced) when the resolution slider settles.
134 useEffect(() => {
135 const oc = ocRef.current
136 const meshShape = meshShapeRef.current
137 if (!oc || !meshShape || !model) return
138 const t = setTimeout(() => {
139 setBusy('meshing')
140 try {
141 const patches = retessellate(oc, meshShape, quality, model.patches)
142 setModel((m) => (m ? { ...m, patches } : m))
143 } catch (e) {
144 setError(e instanceof Error ? e.message : String(e))
145 } finally {
146 setBusy(null)
147 }
148 }, 150)
149 return () => clearTimeout(t)
150 // eslint-disable-next-line react-hooks/exhaustive-deps
151 }, [quality])
153 const doExport = () => {
154 if (!model) return
155 setError(null)
156 try {
157 const fmt = EXPORT_FORMATS.find((f) => f.id === exportId)!
158 let bytes: Uint8Array
159 if (exportId === 'nurbs') {
160 bytes = toNurbsJson(model)
161 } else if (exportId === 'step') {
162 if (model.raw) {
163 bytes = model.raw.bytes
164 } else if (ocRef.current && meshShapeRef.current) {
165 bytes = shapeToStep(ocRef.current, meshShapeRef.current)
166 } else {
167 throw new Error('STEP export unavailable for this model.')
168 }
169 } else {
170 const merged = mergeTriMeshes(model)
171 bytes = exportId === 'obj' ? toOBJ(merged) : exportId === 'ply' ? toPLY(merged) : toSTL(merged)
172 }
173 const base = baseName.replace(/[^\w-]+/g, '_').toLowerCase() || 'model'
174 download(bytes, base + fmt.ext)
175 } catch (e) {
176 setError(e instanceof Error ? e.message : String(e))
177 }
178 }
180 const selectedPatch =
181 selectedFaceId != null
182 ? (model?.patches.find((p) => p.id === selectedFaceId) as NurbsPatch | undefined)
183 : undefined
184 const coverage = model ? nurbsCoverage(model) : null
186 return (
187 <div className="app">
188 <div className="sidebar">
189 <h1>Mesh Studio</h1>
190 <p className="tagline">
191 Generate surface meshes with different tools and inspect them in 3D. First tool:{' '}
192 <a href="https://ocjs.org/">OpenCASCADE.js</a> — CAD B-rep faces are true NURBS surfaces
193 (polynomials on faces), extracted here alongside the triangulation.
194 </p>
195 <div className={`engine-status ${engine.state}`}>{engine.message}</div>
197 <section>
198 <h2>Sources</h2>
199 <button
200 className="primary"
201 onClick={() => void loadRandomAbc()}
202 disabled={busy !== null}
203 title="Download a random CAD model from the first 1000 STEP files of the ABC dataset"
204 >
205 🎲 Random CAD model
206 </button>
207 <p className="footnote">
208 Random models are drawn from{' '}
209 <a href="https://concept-collection.github.io/abc-step-1000/">abc-step-1000</a>, a
210 rehosted slice of the <a href={ABC_DATASET_URL}>ABC dataset</a> of CAD models (Koch et
211 al., CVPR 2019).
212 </p>
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 </div>
230 <input
231 ref={fileInputRef}
232 type="file"
233 accept=".step,.stp,.iges,.igs"
234 hidden
235 onChange={(e) => {
236 const file = e.target.files?.[0]
237 if (file) void openFile(file)
238 e.target.value = ''
239 }}
240 />
241 {busy && (
242 <div className="busy">
243 {busy === 'building' ? 'Building model…' : busy === 'downloading' ? 'Downloading model…' : 'Re-meshing…'}
244 </div>
245 )}
246 {error && <div className="error">{error}</div>}
247 </section>
249 {model && (
250 <section>
251 <h2>Model</h2>
252 <div className="mesh-info">
253 <div className="source">{model.source.label}</div>
254 <div>
255 {model.patches.length} faces · {triangleCount(model).toLocaleString()} triangles ·{' '}
256 {vertexCount(model).toLocaleString()} vertices
257 </div>
258 {coverage && (
259 <div className={`chip ${coverage.withNurbs === coverage.total ? 'on' : ''}`}>
260 NURBS extracted on {coverage.withNurbs}/{coverage.total} faces
261 </div>
262 )}
263 </div>
264 </section>
265 )}
267 {model && (
268 <section>
269 <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>
281 <label className="slider">
282 <span>Mesh resolution</span>
283 <input
284 type="range"
285 min={0}
286 max={1}
287 step={0.01}
288 value={quality}
289 onChange={(e) => setQuality(Number(e.target.value))}
290 disabled={busy !== null}
291 />
292 </label>
293 <p className="footnote">
294 Coarse ↔ fine re-tessellates the same NURBS faces — drag to see the polynomial
295 surface go from faceted to smooth. Click a face to inspect it.
296 </p>
297 </section>
298 )}
300 {selectedPatch && (
301 <section>
302 <h2>Face #{selectedPatch.id}</h2>
303 {selectedPatch.nurbs ? (
304 <div className="mesh-info">
305 <div>
306 Degree (u, v): <strong>{selectedPatch.nurbs.uDegree}, {selectedPatch.nurbs.vDegree}</strong>
307 </div>
308 <div>
309 Control net: {selectedPatch.nurbs.nu} Ă— {selectedPatch.nurbs.nv} poles
310 </div>
311 <div>{selectedPatch.nurbs.weights ? 'Rational (NURBS)' : 'Polynomial (non-rational)'}</div>
312 <div>
313 Knots: {selectedPatch.nurbs.uKnots.length} u, {selectedPatch.nurbs.vKnots.length} v
314 </div>
315 <div className="footnote">
316 {selectedPatch.tri.indices.length / 3} triangles at this resolution
317 </div>
318 </div>
319 ) : (
320 <div className="mesh-info">No NURBS data extracted for this face.</div>
321 )}
322 <button className="subtle" onClick={() => setSelectedFaceId(null)}>
323 Clear selection
324 </button>
325 </section>
326 )}
328 {model && (
329 <section>
330 <h2>Export</h2>
331 <select value={exportId} onChange={(e) => setExportId(e.target.value as ExportId)}>
332 {EXPORT_FORMATS.map((f) => (
333 <option key={f.id} value={f.id}>
334 {f.label}
335 </option>
336 ))}
337 </select>
338 <button className="primary" onClick={doExport} disabled={busy !== null}>
339 Download {EXPORT_FORMATS.find((f) => f.id === exportId)!.ext}
340 </button>
341 <p className="footnote">
342 Triangle formats export the current tessellation. NURBS JSON stores the exact
343 polynomial patches. STEP hands back the B-rep (original bytes for imported files).
344 </p>
345 </section>
346 )}
347 </div>
349 <div className="viewport">
350 <SurfaceView
351 model={model}
352 mode={mode}
353 selectedFaceId={selectedFaceId}
354 onSelectFace={setSelectedFaceId}
355 />
356 </div>
357 </div>
358 )
359}
361export default App