Skip to content

Development Guide ​

Prerequisites ​

  • Node.js 20 or later (LTS recommended)
  • npm (included with Node.js)
  • VS Code 1.144+ (the engines.vscode floor in package.json) for running the Extension Development Host
  • Git

Setup ​

bash
git clone https://github.com/loumalouomega/CAD-Preview.git
cd CAD-Preview
npm install

Build Commands ​

CommandDescription
npm run buildBundle extension host + MCP server + webview (esbuild) and type-check (tsc)
npm run watchRebuild incrementally on file changes
npm testRun unit tests with Vitest (headless, no display server needed)
npm run test:watchRun Vitest in watch mode
npm run packageProduce cad-preview.vsix for manual installation
npm run docs:devServe the VitePress documentation site locally
npm run docs:buildBuild the static documentation site to doc/.vitepress/dist/
npm run docs:previewPreview the built documentation site locally
npm run docs:screenshotsRegenerate every feature screenshot under doc/public/screenshots/
npm run mcpRun the standalone MCP server (dist/mcp-server.js; requires a prior build)
npm run mcp:smokeBuild, then run the real-WASM end-to-end MCP smoke test (see MCP Server)
npm run perfBuild, then benchmark load/mesh times against scripts/perf/baseline.json
npm run probe -- <entry.ts>Build, then run a TypeScript probe against the real WASM kernels (see Probing the WASM kernels)
npm run test:webviewPlaywright assertions over the real viewer bundle (needs a display server)
npm run test:integrationThe host-side suite inside a real VS Code (needs a display server)

Running the toolchain without node on PATH (Flatpak VS Code) ​

The Flatpak VS Code sandbox ships no standalone Node, and the host's /usr/bin/node is not loadable from inside it. VS Code's own Electron binary runs as Node, which is enough for the whole toolchain:

sh
ELECTRON_RUN_AS_NODE=1 /app/extra/vscode/code --version   # prints the Node version

There is no bundled npm, so invoke the local tools by path rather than through a script name — node_modules/typescript/bin/tsc --noEmit, node_modules/vitest/vitest.mjs run, esbuild.mjs, scripts/mcp-smoke/run.mjs. Anything that spawns a node child (the MCP smoke harness spawns dist/mcp-server.js) also needs a node shim on PATH:

sh
printf '#!/bin/sh\nexec env ELECTRON_RUN_AS_NODE=1 /app/extra/vscode/code "$@"\n' > /tmp/bin/node && chmod +x /tmp/bin/node
PATH=/tmp/bin:$PATH node scripts/mcp-smoke/run.mjs

Playwright looks for its browsers under XDG_CACHE_HOME, which the sandbox remaps, so point it at the real download instead: PLAYWRIGHT_BROWSERS_PATH=~/.cache/ms-playwright. With that set, vitest, esbuild.mjs, mcp:smoke, webview-test and docs:screenshots all run under this recipe.

test:integration is the one that does not. Its launcher spawns process.execPath — which under this recipe is the Electron binary, not Node — with ELECTRON_RUN_AS_NODE deliberately deleted (see the long comment in test/integration/run.mjs explaining why that deletion is required for the spawned VS Code). The launcher therefore starts as a GUI VS Code that treats its script argument as a file to open, and exits 0 having run nothing. Run that suite from a normal terminal with a real node on PATH.

Probing the WASM kernels ​

Many facts about this OCCT build can only be found by calling it: which overload suffix a constructor has, whether a manifest-green class actually computes, or what a method returns. npm run probe -- <entry.ts> bundles a TypeScript file with the same Node/CJS recipe as the shipped bundles and runs it against the real kernels, with the repo root as extensionPath:

sh
npm run probe -- scripts/probe/examples/bull-counts.ts   # prints 36 faces / 98 edges

Scratch probes go under scripts/probe/scratch/ (git-ignored). The harness also runs under the Flatpak recipe above (ELECTRON_RUN_AS_NODE=1 …/code scripts/probe/run.mjs …), because it passes the environment through unchanged. scripts/probe/README.md has the cleanup skeleton, the probe protocol and where a probe's result is recorded.

The shared recipe lives in scripts/nodeBundleConfig.mjs, which esbuild.mjs, the screenshot fixture generator and the probe runner all import. Add a new WASM package to its WASM_EXTERNALS list once, rather than to each script.

Regenerating Documentation Screenshots ​

The per-feature screenshots embedded in the docs are generated automatically — they are not hand-captured — so they stay in lockstep with the real UI:

bash
npm run docs:screenshots   # runs build → fixtures → capture

The pipeline lives in scripts/screenshots/:

  1. make-fixtures.mjs runs the real extension-host geometry pipeline in plain Node — OpenCascade tessellation + a Gmsh mesh of examples/STP/bull.stp — and writes the exact geometry/tree/meshingResult/parts/edits message payloads (plus the shared viewer DOM) to scripts/screenshots/fixtures/ (git-ignored).
  2. capture.mjs loads the shipped webview bundle (media/viewer.js) into a headless Chromium via Playwright, stubs acquireVsCodeApi, posts those fixtures so the UI shows genuine geometry (WebGL renders through SwiftShader — no display server needed), drives each panel, and writes one PNG per feature to doc/public/screenshots/. It also refreshes the two README hero images.

The webview DOM is shared with the real extension via src/viewerDom.ts (viewerBodyHtml()), which provider.ts also uses, so a UI change can never leave the screenshots showing stale markup. First run needs the Playwright browser: npx playwright install chromium.

Running in the Extension Development Host ​

Press F5 in VS Code (with the launch.json already configured) to open the Extension Development Host. This is the recommended way to test the extension end-to-end.

Test fixtures ​

FixtureFormatWhat to test
examples/STP/bull.stpSTEP (B-rep)OCCT pipeline, multi-solid tree panel
examples/STL/cube.stlSTLThree.js pipeline, basic geometry
examples/OBJ/cube.objOBJOBJ loader, default material
examples/PLY/cube.plyPLYPLY loader, normal computation
examples/GLTF/cube.gltfglTFGLTFLoader, scene hierarchy
examples/MED/two-material-tets.medMEDMMG volume remesh, material regions/Parts and dropped-field warning
examples/STL/large-sphere-100k.stlSTLMMG surface probe and sampled vertex-to-sphere error

Manual checklist ​

After any non-trivial change, run through:

  1. Open examples/STP/bull.stp — model renders, component tree visible.
  2. Open examples/STL/cube.stl — renders without loading the WASM.
  3. Orbit, pan, zoom with mouse — smooth movement with damping.
  4. Click each toolbar button: Fit, Tree, FE Mesh, and open each of the View ▾ / Select ▾ / Measure ▾ / Markup ▾ dropdowns. Use File ▸ Export… (or Ctrl+E) to export.
  5. Use the view-controls panel: step rotate (15°/45°/90°), pan, zoom, Fit, Ctr (reset).
  6. Collapse and expand the view-controls panel with ⌄/⌃.
  7. Click all six faces of the orientation cube — view snaps to ±X/Y/Z.
  8. Click a row in the component tree — solid highlights, others dim. Click again to deselect.
  9. Click Export on bull.stp — quick-pick offers IGES/BREP/STL/OBJ/PLY/glTF (not STEP); export to each and reopen the output to confirm it round-trips. On cube.stl, confirm only OBJ/PLY/glTF are offered.
  10. Open and close the same file several times — extension host memory stays flat (no OCCT heap leak). Repeat with export/cancel cycles.
  11. Open two-material-tets.med, use FE Mesh → Remesh (MMG)… with Max edge 0.6, and save to an unused MED path. Open the output: both material Parts remain selectable. Repeat on block.stp in generated mode with a face Part. Refuse the original/existing output paths; dismiss the save dialog and use Cancel during work without changing the source.

Project Structure ​

CAD-Preview/
├── src/
│   ├── extension.ts          # Extension entry point
│   ├── provider.ts           # Custom editor provider
│   ├── fileRouter.ts         # File extension → strategy routing
│   ├── exportTargets.ts      # Compatible export formats per route
│   ├── protocol.ts           # Host↔webview message types
│   ├── occtService.ts        # WASM singleton + B-rep loading + export
│   ├── meshExtract.ts        # OCCT geometry extraction
│   └── webview/
│       ├── main.ts           # Webview entry point
│       ├── viewer.ts         # Three.js scene controller
│       ├── cameraControls.ts # Pure camera math
│       ├── orientationCube.ts# Orientation gizmo
│       ├── geometryBuilder.ts# Decode buffers → THREE.Group
│       ├── meshLoaders.ts    # Three.js loader dispatch
│       ├── meshExporters.ts  # Three.js exporter dispatch
│       └── treePanel.ts      # Component tree DOM panel
├── media/                    # Runtime webview assets (built)
│   ├── viewer.js             # Compiled webview IIFE bundle
│   └── viewer.css            # Webview styles
├── dist/                     # Extension host build output
│   ├── extension.js          # Compiled extension CJS bundle
│   └── opencascade.wasm.wasm # WASM binary (copied from node_modules)
├── doc/                      # Documentation source (VitePress)
│   └── .vitepress/
│       └── config.ts         # VitePress configuration
├── examples/                 # Sample CAD/mesh fixtures
├── esbuild.mjs               # Build configuration
├── tsconfig.json             # TypeScript configuration (noEmit)
└── .github/
    ├── dependabot.yml            # Automated dependency-update PRs (npm + GitHub Actions)
    └── workflows/
        ├── ci.yml                # Build + test + release CI
        ├── docs.yml              # Docs build + GitHub Pages deploy
        └── dependency-review.yml # Blocks PRs introducing vulnerable/risky dependencies

Build System Details ​

esbuild ​

esbuild.mjs produces four bundles — three Node/CJS entry points plus the browser webview:

Extension host (dist/extension.js):

  • Format: cjs (Node requires CommonJS)
  • Platform: node
  • Target: es2020
  • vscode is marked external (provided by VS Code at runtime)
  • opencascade.js is bundled (not external) — ESM is converted to CJS

MCP server (dist/mcp-server.js):

  • Same Node/CJS recipe as the extension host, but its own standalone entry (src/mcpServer.ts) — it is not part of the extension bundle and is run directly by an MCP client (node dist/mcp-server.js)
  • Additionally bundles @modelcontextprotocol/sdk and zod, which never reach dist/extension.js
  • See MCP Server

Kernel worker (dist/kernel-worker.js):

  • Same Node/CJS recipe again, entry src/kernelWorker.ts
  • The forked child process both the extension host and the MCP server route every OCCT/Gmsh/meshio++ call through, so a hung or crashed WASM call can be killed and respawned without taking the parent process down
  • Leaner than mcp-server.js — it needs the raw pipeline functions, none of the MCP SDK or tool-registration logic

Webview (media/viewer.js):

  • Format: iife (immediately-invoked, consistent with webview CSP)
  • Platform: browser
  • Target: es2020
  • Three.js is bundled

wasmPathPlugin: A custom esbuild plugin intercepts *.wasm imports and emits a require('path').join(__dirname, '<name>') CJS stub. After the bundle is written, esbuild.mjs copies the actual .wasm binary from node_modules/ to dist/. This ensures the WASM is always co-located with the extension bundle.

The three Node/CJS configs take the plugin, the shared external list, the import.meta.url shim and the stamped kernel versions from scripts/nodeBundleConfig.mjs. The screenshot fixture generator and the probe runner import the same module, so a script-side bundle cannot fall behind the shipped ones.

TypeScript ​

tsconfig.json uses "noEmit": true — type-checking only. esbuild handles the actual compilation. Run npm run compile (or tsc --noEmit) to check types without building.

Test Suite ​

Unit tests use Vitest. They run headlessly — no display server or VS Code host needed.

Every test lives beside the module it covers, as <module>.test.ts (currently ~110 files under src/), so the file list is not duplicated here — it would only rot. The convention is that anything pure gets a unit test: the sidecar parsers, editOps.ts's validation gate, the geometry/mesh math, the webview's DOM-free models. Modules that need the OCCT/Gmsh/meshio WASM or a live DOM are verified by npm run mcp:smoke and npm run test:webview instead.

Run all tests: npm test

Run a specific test file: npx vitest run src/fileRouter.test.ts

The verification-gap gate ​

Features that were checked only by hand are recorded in scripts/verification-gaps/registry.json — the single list of unverified claims; do not write prose "verification gap" paragraphs in CLAUDE.md. npm test keeps that debt from growing silently (src/verificationGaps.test.ts):

  • A new unverified claim is a new registry entry, which raises the unresolved count and so fails the suite until the baseline is raised deliberately in the same change. If a prose gap note does appear in CLAUDE.md anyway, it must end in a [vg:<id>] marker naming a registry entry.
  • An entry becomes closed only by listing closedBy — exact test("…") titles from scripts/webview-test/run.mjs or test/integration/suite/index.ts. Rewording a note cannot close it, and renaming or deleting the covering test re-opens it. test-written means a test exists but has not yet been seen green, and still counts as unresolved.
  • scripts/verification-gaps/baseline.json caps the unresolved count. Closing an entry means lowering it by one in the same change; the gate fails if the count falls without the baseline following.

To close a gap: write the harness case, bug-inject it (confirm the fault turns it red), set the entry's status and closedBy, and lower the baseline.

Rewrite the claim before closing it. An entry's text often bundles a decidable claim with a look-and-feel one ("the panel's click-through has not been exercised" + "how it reads in the light theme"). Narrow the text to the part a harness can decide, close the entry by a test that decides exactly that part, and leave the residual in the entry's note — where it stays visible instead of disappearing. An entry closed by rewording, with no test behind it, is the failure this gate exists to prevent.

WEBVIEW_TEST_ONLY="<substring>" node scripts/webview-test/run.mjs runs only the matching test:webview cases, which is the fast loop for writing one. If Playwright wants a newer Chromium than the one installed and you cannot download one, point its expected path at the installed headless shell instead of reinstalling.

/tmp/cad-preview-test-only does the same for the integration suite: one case-name substring per line, and the launcher reads the file (it cannot forward an env var into the test VS Code). It is how the second host-side increment was written — seven new cases, iterated in minutes instead of in the ~12 minutes the whole suite takes.

VS Code Remote / SSH ​

When using VS Code Remote or SSH, the running extension is the installed copy in ~/.vscode-server/extensions/, not the dist/ directory in the workspace. Rebuilding alone won't show your changes.

To update the running extension after code changes:

  1. Bump the version in package.json.
  2. npm run package — produces cad-preview-<version>.vsix.
  3. code --install-extension cad-preview-<version>.vsix (or install via the Extensions view).
  4. Reload the VS Code window (Developer: Reload Window).

Also watch out for stale duplicate entries in ~/.vscode-server/extensions/ if you have installed both a published version and a local .vsix.

CI Pipeline ​

See .github/workflows/ci.yml. Two jobs:

build-and-test (every push/PR to master):

  1. Checkout + Node 20 setup
  2. npm ci — clean install
  3. npm run build — bundle + type-check
  4. npm test — unit tests (including the doc/** example-compile, op-coverage, and no-ordinal-roadmap-citation gates)
  5. npm run docs:build — VitePress build, which is also the only dead-link check over doc/**
    • Then npm run ui:drift (scripts/ui-drift-check.mjs): informational — it fetches VSCode-MDPA-Preview's webview/design-system.css and src/toolbarIcons.ts and prints the --ui-* token and icon-id differences against this repository. It exits 0 on drift and prints "skipped" when the sibling is unreachable; the icon sets differ by design (each extension has its own surfaces), so read it for the shared tokens.
  6. npm run test:webview — Playwright assertions over the real viewer bundle (under xvfb-run)
  7. npm run test:integration — the host-side suite in a real VS Code (under xvfb-run, retried on network flakes)
  8. npm run package — produce .vsix
  9. npm run compat:vsix — assert the packaged archive holds every runtime file the kernel loaders resolve (derived from .vscodeignore's carve-outs) and no dev/test file
  10. Upload .vsix as a workflow artifact

release (only on v* tags):

  1. Same steps as build-and-test
  2. npx vsce package --out cad-preview-<tag>.vsix
  3. Create a GitHub Release with auto-generated release notes
  4. Attach the .vsix as a release asset

Both jobs check out a commit, not a ref. The ref a run was triggered for is a moving target: v3.27.0's tag was force-moved to a later commit four minutes after it was pushed, and the run that was still queued died in its checkout step with "The ref 'refs/tags/v3.27.0' does not point to the expected commit" — before a single test ran. Both jobs therefore check out github.sha, the commit the run was triggered for, so a run always tests exactly one commit. The release job additionally re-reads the tag from the remote and publishes nothing when it no longer points at that commit: the move triggered a newer run, and that run owns the release. (A pull_request run keeps the default merge-ref checkout — github.sha is the PR's merge commit, which the merge ref already pins.)

Compatibility corpus ​

npm run compat (scripts/compat/) is a table-driven sibling of mcp:smoke: each row of corpus.json opens a committed fixture, writes a mesh export from examples/STP/block.stp and re-opens it, or round-trips a B-rep export through get_mass_properties — every import format, every meshio/Gmsh export writer, compound extensions and mixed cells, against the real kernels. Known upstream limitations are rows too: they assert the current failure text and report FIXED-UPSTREAM (a finding to record, never a failure) when it stops failing. --only <substring> filters rows; the last run's table is written to scripts/compat/last-run.json (git-ignored). Timing stays in npm run perf.

It is not in CI (it runs the WASM kernels for minutes); run it before and after any kernel dependency change and diff the tables.

Release checklist, before tagging:

  1. npm outdated @meshioplusplus/wasm @loumalouomega/gmsh-wasm opencascade.js float-tetwild-wasm — kernel drift is caught here, not at the next incident.
  2. npm ci so the checkout matches the lockfile (a stale node_modules silently tests a different artifact).
  3. npm run compat — update corpus.json's verifiedAt when versions changed, and record any FIXED-UPSTREAM row in CLAUDE.md.
  4. npm run package -- --out cad-preview.vsix && npm run compat:vsix -- cad-preview.vsix.
  5. Never move a tag that has been pushed. A tag push is the release trigger: CI builds it, publishes it to the Marketplace and creates the GitHub Release. A tag that moves afterwards leaves the in-flight run standing down (see the CI section) and the release is whatever the new tag points at — so amend before tagging, or cut a new version after tagging.

Dependency Hygiene ​

Two mechanisms keep dependencies current and non-malicious, configured in .github/:

  • dependabot.yml opens weekly PRs for outdated npm packages (dev-dependency minor/patch bumps grouped into one PR) and GitHub Actions versions. It also drives GitHub's native Dependabot security alerts/PRs for the npm ecosystem regardless of the update schedule.
  • dependency-review.yml runs actions/dependency-review-action on every PR to master and fails the check (fail-on-severity: moderate) if the diff introduces a package with a known moderate-or-worse vulnerability, posting a summary comment on the PR.

Before adding any new bundled dependency (see the License section in CLAUDE.md — anything that ends up in the packaged .vsix, not just a dev/build-time tool), check its license for GPL compatibility first regardless of what these two checks report, since they scan for vulnerabilities/version currency, not license terms.

docs (see .github/workflows/docs.yml, every push to master):

  1. Checkout + Node 20 setup
  2. npm ci
  3. npm run docs:build — VitePress build
  4. Deploy to GitHub Pages via actions/upload-pages-artifact + actions/deploy-pages

Embedding the kernel runtime ​

MMG is an additional external CJS runtime: copy the carved-back node_modules/@loumalouomega/mmg-wasm/{package.json,LICENSE,dist/*.cjs} files and dist/mmg-core.wasm into the consuming extension layout. It is loaded only by a remesh, through createRequire (not an inlined ESM factory). Preserve the import-meta shim, the LGPL notice, and replaceability of the library. The VSIX checker has an independent required MMG file list as well as carve-outs. MMG 0.1.0 is pinned in production dependencies, not probe-only/dev-only.

The build stages meshio++ under dist/meshio/ and fTetWild under dist/ftetwild/. Copy these directories intact beside a consuming CJS bundle; they contain package metadata, ESM glue and self-located WASM binaries. The shared inventory is scripts/runtimeAssets.mjs, used by the build and VSIX checker. meshio++ ships only its sequential variant; fTetWild retains its existing serial and threaded runtime files but always runs serially.

Both loaders first resolve an installed package relative to the bundle, then try meshio/ or ftetwild/ beside it, then the same directories two levels above it (KKSS's out/cad-runtime/dist/ layout). Native dynamic import avoids CJS conversion of ESM/top-level await. Keep the existing import.meta.url shim from scripts/nodeBundleConfig.mjs when bundling these sources into CJS. Import and initialization errors from a selected package are surfaced rather than hidden by a fallback. Failed initialization can be retried.

For KKSS's separate migration: update its CAD submodule; copy cad/dist/meshio/ and cad/dist/ftetwild/ into its runtime layout; remove the cadMeshioLoader and cadFtetwildLoader aliases and shim files from both worker builds; retain the existing Gmsh alias and import-meta shim. Its two consumers should use the same tested meshio++ version. Run KKSS's packaged geometry and MCP tests before shipping. This change does not modify KKSS.

Dependency watch ​

For MMG packaging, also run node scripts/compat/mmg-vsix.mjs /path/to/cad-preview.vsix. It extracts the archive into an isolated /tmp/opencode directory (no repository package fallback), invokes its real MCP tool, remeshes and reopens both material Parts, checks a planar surface remesh and freshly generated translated CAD with a coloured/sized boundary Part, and verifies refusal/recovery paths. The generated MED's coordinates are read through the archive's own meshio runtime to prove pending edits landed. It checks dropped-field warnings/source preservation and parses every MCP stdout line as JSON-RPC.

node scripts/dependency-watch.mjs reports installed-versus-latest versions without writing to GitHub. The Monday workflow also supports manual dispatch and uses --publish to create or update one tracking issue, or to close it with a dated comment when every watched package is current. It monitors the five WASM packages, Three.js and the MCP SDK, including releases beyond caret ranges. Unchanged reports do not generate repeated updates. Registry errors fail the job.

After updating kernels, run npm test, npm run compat, npm run mcp:smoke, then package a fresh VSIX and run npm run compat:vsix. The tests include real CJS loading and geometry operations from an isolated directory with no repository node_modules, for both adjacent and nested runtime layouts.

Released under the GPL-3.0-or-later License.