Architecture
Two-Process Model
VS Code extensions run in two separate JavaScript environments:
| Environment | Runtime | CAD Preview responsibilities |
|---|---|---|
| Extension host | Node.js (CJS) | File I/O, OCCT tessellation, geometry encoding, message dispatch |
| Webview | Chromium (browser IIFE) | Three.js scene, camera, rendering, UI |
The two processes communicate exclusively through VS Code's postMessage / onDidReceiveMessage API. They share no memory. See Host ↔ Webview Protocol for the message schema.
Extension host (Node) Webview (Chromium)
───────────────────────────────────── ─────────────────────────────────────────
read file bytes
OpenCascade.js parse + tessellate ───▶ buildGroupFromEncoded() → THREE.Group
→ base64 {positions, indices} Viewer.setModel() → render loop
── or ──
asWebviewUri(filePath) ───▶ loadMeshFromUrl() → THREE.Object3D
Viewer.setModel() → render loopWhy OCCT Runs in the Extension Host
- No CSP issues. The webview enforces a strict Content Security Policy (
default-src 'none'). Loading a 30 MB WASM binary from the filesystem inside the webview would require relaxingscript-srcandconnect-src, which opens XSS surface. The extension host is a trusted Node process with full filesystem access. - Lazy WASM, lazy cost. Opening an STL or glTF file never loads the WASM. The kernel is initialized only on the first B-rep open and memoized. Activation time stays instant regardless of what file the user opens first.
- Node 18+ has
Buffer/fs. The OCCT factory needsfs.readFileSyncto load the.wasmbinary. That API is not available in the webview at all.
File Routing
src/fileRouter.ts maps file extensions to one of two rendering strategies:
file extension
│
▼
routeFile()
│
├── .step / .stp / .iges / .igs / .brep → strategy: "occt"
│ format: "step" | "iges" | "brep"
│
└── .stl / .obj / .ply / .gltf / .glb → strategy: "three"
format: "stl" | "obj" | "ply" | "gltf"CadPreviewProvider.resolveCustomEditor() in src/provider.ts reads the returned FileRoute and dispatches accordingly.
Export
Export mirrors the same two-pipeline split, in reverse, driven by exportTargetsFor() in src/exportTargets.ts:
- B-rep targets (STEP/IGES/BREP) are written entirely in the extension host:
exportBRep()insrc/occtService.tsre-parses the source file with the existing OCCT reader and hands the liveTopoDS_Shapeto the matching OCCT writer. The webview is not involved. - Mesh targets (STL/OBJ/PLY/glTF) are written in the webview, since that's where the triangulated
THREE.Object3Dalready lives (for any source format — OCCT tessellation results and natively-loaded meshes are indistinguishable once they're in the Three.js scene).src/webview/meshExporters.tswraps Three.js's bundled exporters and posts the serialized result back to the host overpostMessage, since only the extension host can show native save dialogs.
OCCT in this build only includes B-rep writers — there are no STL/OBJ/PLY/glTF writers, and no reverse path from a triangle mesh to a B-rep. That asymmetry is why export targets are pipeline-dependent rather than a flat list of every supported format.
Silhouette SVG is a third case, outside that split entirely. File ▸ Export Silhouette SVG… writes a 2D outline drawing rather than a 3D model, so it is neither a B-rep target nor a mesh target and never appears in exportTargetsFor()'s list — deliberately, since an "svg" member of CadFormat would ripple all the way into package.json's customEditors.selector and make VS Code try to open .svg files in the 3D viewer. It is written in the host like a B-rep target (via src/svgSilhouetteHost.ts in the kernel worker), but it is not a CAD format and it is derived from either source family: a B-rep source's outline comes from its tessellation with edits baked in, a mesh source's from the raw file bytes through the same pure host-side parsers Compare Models uses. The outline itself is computed with no kernel involvement at all — src/silhouetteEdges.ts keeps the edges whose two adjacent triangles disagree about facing the viewer — which is precisely why one implementation covers both families. There is no hidden-line removal; see File Formats → Silhouette SVG Export.
WASM Loading — Critical Detail
Do not use initOpenCascade from opencascade.js/index.js. That wrapper takes zero arguments and ignores any options passed to it. In Node 18 the fallback code calls fetch(wasmPath), which fails for filesystem paths (TypeError: Failed to parse URL).
Instead, import the raw Emscripten factory and pass wasmBinary explicitly:
import openCascadeFactory from 'opencascade.js/dist/opencascade.wasm.js'
const wasmBinary = fs.readFileSync(
path.join(extensionPath, 'dist', 'opencascade.wasm.wasm')
)
const oc = await openCascadeFactory({ wasmBinary })This approach:
- Is deterministic — no
fetch, no URL resolution. - Works on all platforms (Windows paths, Remote/SSH, dev containers).
- Is the only approach that survives the esbuild bundle (see Bundling below).
Lazy Singleton Pattern
src/occtService.ts holds a module-level _ocPromise: Promise<any> | null. The first call to getOcct(extensionPath) initializes it; subsequent calls return the same promise. The singleton is never reset between file opens — only resetOcct() (used in tests) clears it.
// Simplified
let _ocPromise: Promise<any> | null = null
export function getOcct(extensionPath: string): Promise<any> {
if (!_ocPromise) {
_ocPromise = openCascadeFactory({ wasmBinary: fs.readFileSync(...) })
}
return _ocPromise
}Bundling
The project uses esbuild (not tsc) to produce four bundles:
| Bundle | Path | Format | Target |
|---|---|---|---|
| Extension host | dist/extension.js | CJS | Node 18 |
| MCP server | dist/mcp-server.js | CJS | Node 18 |
| Kernel worker | dist/kernel-worker.js | CJS | Node 18 |
| Webview | media/viewer.js | IIFE | ES2020 browser |
tsc --noEmit (via npm run compile) provides type-checking only — it emits nothing.
WASM Handling in esbuild
A custom esbuild plugin (wasmPathPlugin, defined in scripts/nodeBundleConfig.mjs and used by esbuild.mjs) intercepts the import of opencascade.js/dist/opencascade.wasm.wasm and replaces it with a CJS stub:
// Generated stub (not actual file content)
module.exports = require('path').join(__dirname, 'opencascade.wasm.wasm')After the bundle is written, esbuild.mjs copies node_modules/opencascade.js/dist/opencascade.wasm.wasm to dist/opencascade.wasm.wasm. At runtime, __dirname in the bundle resolves to dist/, so the path is correct.
opencascade.js is NOT marked external. It is fully bundled (ESM → CJS) into dist/extension.js along with its emscripten glue code.
Content Security Policy
The webview HTML sets a strict CSP:
default-src 'none';
script-src 'nonce-{nonce}';
style-src 'nonce-{nonce}';
img-src {webview-csp-source} data:;This means:
- No inline scripts without the nonce.
- No
eval/unsafe-eval(so no dynamic code). - Images are allowed only from
vscode-webview://URIs ordata:(used byCanvasTexture). - Fonts and external resources are blocked.
The orientation cube's face labels are drawn via CanvasTexture (HTML5 <canvas> → drawText) — this approach requires no external image assets and is fully CSP-safe.
Single WebGL Renderer
A second WebGL context is disallowed in some environments and degrades performance in all of them. The OrientationCube class therefore owns no WebGLRenderer.
Instead, Viewer.renderGizmo() draws the orientation cube into a corner of the single main renderer using a scissor viewport:
main canvas
┌──────────────────────────────────┐
│ │
│ Main Three.js scene │
│ (model + lights + grid) │
│ │
│ ┌──────┐ │
│ │ Gizmo│ ← scissor viewport │
│ │ cube │ (top-left corner) │
│ └──────┘ │
└──────────────────────────────────┘The render sequence per frame:
- Clear color + depth for the full canvas → render the main scene.
- Set scissor rect (top-left, 120×120 px) → clear depth only (scene colors preserved) → render the gizmo scene.
- Disable scissor.
Face clicks on the gizmo are handled by a capture-phase pointerdown listener on the canvas that calls stopImmediatePropagation(), preventing OrbitControls from also interpreting the click as a camera drag.
OCCT Memory Discipline
Every wrapped OCCT object (reader, shape, explorer, mesher, triangulation handle, face) is an Emscripten heap handle. These objects are not garbage-collected. The OCCT singleton is reused across file opens, so only per-file objects need to be freed.
The pattern in src/meshExtract.ts:
const cleanup: { delete(): void }[] = []
try {
const reader = new oc.STEPControl_Reader_1()
cleanup.push(reader)
// ... more objects
} finally {
for (let i = cleanup.length - 1; i >= 0; i--) {
cleanup[i].delete() // reverse order — inner objects before outer
}
}Violating this discipline causes the WASM heap to grow unboundedly across file opens. Measure it in the Extension Development Host by opening/closing large STEP files repeatedly and watching the extension host process memory.