GMSH Integration (FE Meshing)
CAD Preview can generate a finite-element mesh (nodes + tetrahedra/triangles, GMSH's native .msh format) from the model currently open in the editor, using Gmsh compiled to WebAssembly via @loumalouomega/gmsh-wasm. This is a distinct pipeline from the B-rep tessellation used for display (see Architecture) — meshing is opt-in, triggered from the FE Mesh panel, and its output is an overlay on top of the existing view, never a replacement for it.
Host-only execution, lazy WASM init
Like OpenCascade.js (see Architecture § Lazy Singleton Pattern), gmsh-wasm runs only in the Node extension host, never in the webview, and is never initialized eagerly. src/gmshService.ts holds a module-level _gmshPromise: Promise<GmshApi> | null; the first call to getGmsh(extensionPath) reads dist/gmsh-core.wasm from disk, passes it as wasmBinary to the raw Emscripten factory (mirroring the opencascade.wasm.wasm loading trick, not the zero-arg fetch-based wrapper), calls the module's own gmsh.initialize() exactly once, and memoizes the resolved promise:
export function getGmsh(extensionPath: string): Promise<GmshApi> {
if (!_gmshPromise) {
const wasmBinary = fs.readFileSync(path.join(extensionPath, "dist", "gmsh-core.wasm"));
_gmshPromise = initialize({ wasmBinary }).then((gmsh) => {
gmsh.initialize();
return gmsh;
});
}
return _gmshPromise;
}Opening a file never triggers this — only clicking ▶ Generate or 📤 Export in the FE Mesh panel does, exactly the same first-use trigger discipline as OCCT's getOcct. Subsequent mesh generations reuse the same singleton; per-generation state is reset with gmsh.clear() + gmsh.model.add(...) rather than a second gmsh.initialize() (see loadGeometryAndApplyOptions in src/gmshService.ts).
Two input paths
MeshGenerationInput is a discriminated union of exactly the two shapes the meshing pipeline can start from:
export type MeshGenerationInput =
| { kind: "brep"; stepBytes: Uint8Array }
| { kind: "stl"; stlBytes: Uint8Array };src/provider.ts's resolveMeshInput picks the input based on the document's FileRoute:
B-rep source (
.step/.stp/.iges/.igs/.brep) — the source file is re-exported to STEP bytes via the existingexportBRep()(so live, unsaved edits are baked in the same way normal Export does), and those bytes are staged to GMSH's in-memory filesystem (MEMFS) as/model.step, then loaded with:typescriptgmsh.model.occ.importShapes(tmpPath); gmsh.model.occ.synchronize();Immediately after
synchronize(), the B-rep path also threads the document'sPart[](read fresh from<model>.parts.jsonviareadParts()) throughapplyPartsToGmshModel(src/gmshPartsMap.ts) — see Parts → physical groups below. Mesh-source input never calls this;partsis always[]for the STL branch.Mesh source (
.stl/.obj/.ply/.gltf/.glb) — the host has no B-rep to re-export, so the webview serializes whateverTHREE.Object3Dis currently displayed to an in-memory STL (reusing the sameexportModel(..., "stl")mesh exporter Export already uses) and sends it up as a base64stlfield on themeshingGenerate/meshingExportmessage. The host writes those bytes to/model.stland remeshes it with the STL-specific call sequence:typescriptgmsh.merge(tmpPath); gmsh.model.mesh.classifySurfaces((options.stlAngle ?? 40) * (Math.PI / 180)); gmsh.model.mesh.createGeometry(); const surfaces = gmsh.model.getEntities(2).dimTags as number[]; // ...collect surface tags... const loopTag = gmsh.model.geo.addSurfaceLoop(surfaceTags); gmsh.model.geo.addVolume([loopTag]); gmsh.model.geo.synchronize();classifySurfacessplits the raw STL triangle soup into a set of parametric surfaces at sharp-angle boundaries (angle threshold =options.stlAngle, default 40°),createGeometryturns those into a real b-rep-like set ofgeosurfaces, and the surface loop + volume declare a solid so a 3D mesh can be generated from a format that otherwise has no volume topology at all.
Both paths converge on the shared options application in loadGeometryAndApplyOptions (Mesh.MeshSizeMin/Max, Mesh.Algorithm, Mesh.Algorithm3D, Mesh.ElementOrder, Mesh.RecombineAll, Mesh.SubdivisionAlgorithm, Mesh.Optimize, all via gmsh.option.setNumber), then generateMesh calls gmsh.model.mesh.generate(options.dimension) and reads the result back with gmsh.model.mesh.getNodes() / gmsh.model.mesh.getElements().
meshio++-imported documents (VTK/MED/CGNS/Exodus/XDMF/MDPA/OpenFOAM) still take the "Mesh source" {kind: "stl"} branch above, in the interactive extension — no third MeshGenerationInput kind was added. They're converted to an STL boundary surface once at import time (convertToStlBoundary, host-side — see "The meshio++ bridge" below) and displayed in the webview as an ordinary THREE.Object3D, indistinguishable from a native .stl open from that point on; when meshing runs, the webview re-serializes that already-displayed object to STL exactly like any other mesh source, following the same code path described above. The headless MCP server and the cad-preview.exportMesh command share a second STL-sourcing mechanism, src/meshSourceInput.ts's resolveMeshSourceInput (which mcpTools.ts's resolveMeshInputHeadless delegates to): it calls convertToStlBoundary directly for meshio++ formats, and parses .stl/.obj/.ply/.gltf with the pure parseToWeldedMesh, to turn the raw file bytes into {kind: "stl", stlBytes} with no webview involved. Pending mesh edits are baked in first by the kernel worker's bakeMeshEdits (headless mesh-edit replay — the viewer's own three.js loaders, engine and exporters), reported as Baked N of M… warnings; only if that bake fails is the raw file meshed, with a warning saying so.
Element shapes & order
Two options control the generated cell geometry:
elementOrder(1linear /2quadratic) →Mesh.ElementOrder. Quadratic meshes carry mid-side/-face nodes (tet → tet10, hex → hex27, triangle → tri6, quad → quad9). The display overlay renders corner nodes only (mid nodes are present in the position buffer but unreferenced by the triangulation — a quadratic mesh looks the same as its linear skeleton in the overlay).elementShape("simplex"/"subdivided") →Mesh.RecombineAll+Mesh.SubdivisionAlgorithm, via the shared, dimension-dependentgmshShapeOptions(shape, dimension)helper (src/meshOptions.ts), reused by both the live-mesh path and the.geogenerator so they can't drift:shape 2D 3D simplextriangles ( RecombineAll=0)tetrahedra subdividedquads ( RecombineAll=1, Blossom)hexahedra ( SubdivisionAlgorithm=2)The recipe is dimension-dependent by necessity, verified against the live gmsh-wasm build: 2D quads come out cleanest from Blossom recombination, but the 3D
RecombineAllpath throws "Cannot use frontal 3D algorithm with quadrangles on boundary" — all-hex 3D instead needs the subdivision algorithm.subdividedworks with both the Delaunay (Algorithm3D=1) and Frontal (4) 3D algorithms.A hex-dominant mixed mode (
elementShape: "hexDominant", 3D only —Mesh.Algorithm3D=9RTree +Mesh.Recombine3DAll=1) is also offered, producing tets + hexes stitched by an unmapped "trihedron" connector type Gmsh's own writers handle natively but Kratos MDPA export cannot represent — see Known limitations for the full verification trail and exactly what degrades where.
The single generic per-element-type table src/gmshElementTypes.ts (GMSH_ELEMENT_TYPES) is the source of truth for every supported gmsh type's stride, corner count, boundary-face decomposition, and Kratos mapping. Both the overlay builders and the MDPA extractor resolve types through it, so the overlay triangulation and MDPA connectivity can never diverge. Its node-order data and the gmsh→Kratos permutations were derived by coordinate-matching against the live WASM (getElementProperties().localNodeCoord), not from documentation.
Parts → physical groups (B-rep only)
Generated meshes preserve the user's parts (named face/edge/solid/point groups from the Parts panel, Part in src/protocol.ts) as Gmsh physical groups — so a .msh/.geo_unrolled export carries the same named regions a downstream FE solver expects, and the live overlay recolours per part. This is B-rep only, for the same reason feature-modeling/fillet/chamfer are B-rep only (see Geometry editing): Gmsh's STL path (classifySurfaces/createGeometry) produces brand-new surface/volume tags with zero correlation to a mesh-format document's original node-N/facet ids, so there is no reliable way to preserve part membership through it.
The correlation problem. face-N/solid-N/edge-N/point-N ids are assigned by CAD-Preview's own OCCT (opencascade.js) walking TopExp_Explorer order over the shape (src/meshExtract.ts/src/occtOperations.ts). Gmsh re-parses the exported STEP bytes with its own, separate OCCT build baked into gmsh-wasm — a different WASM module entirely, so no live shape object can be shared between the two, and there is no guarantee Gmsh's internal entity tags land in the same order (STEP import order is not a reliable cross-OCCT-build correspondence — importShapes's outDimTags is deliberately not relied on for this reason).
src/gmshPartsMap.ts's applyPartsToGmshModel resolves the correspondence geometrically instead: for every entity referenced by some part, it computes a bounding-box centre on both sides — bboxCenter (src/occtOperations.ts, already used elsewhere for explodeSolids) on CAD-Preview's own OCCT shape (re-read from the same STEP bytes already handed to Gmsh), and gmsh.model.getBoundingBox(dim, tag) on Gmsh's synchronized entities — then nearest-matches them within a tolerance relative to the whole model's bbox diagonal (1e-3 × diagonal), accepted only if unambiguous (the best match must be either the sole candidate within tolerance, or meaningfully closer than the runner-up). An unresolved or ambiguous entity is silently skipped — the same graceful-degradation convention every other unresolved-id path in this codebase follows (fillet/chamfer/mate/etc.).
Once ids resolve to Gmsh (dim, tag) pairs, one gmsh.model.addPhysicalGroup(dim, tags, -1, part.name) call is made per part per dimension it has entities in. This happens before mesh.generate()/gmsh.write(), so the .msh output's $PhysicalNames section carries the groups natively. .geo_unrolled output does NOT carry them as textual Physical Volume(...)/Physical Surface(...) statements — see Known limitations's XAO entry for why and how that export is actually made to round-trip the groups.
loadGeometryAndApplyOptions also always sets gmsh.option.setNumber("Mesh.SaveAll", 1), unconditionally (parts or no parts). This is required, not optional: Gmsh's own default (Mesh.SaveAll = 0) means gmsh.write() only serializes elements belonging to some physical group once any physical group exists in the model — so the instant a single part resolves a single entity, every other entity's elements would silently vanish from .msh/.geo_unrolled output (confirmed: a model with one part covering 1 of 15 surfaces wrote a .msh with exactly one entity block and 88 triangles, dropping the other 14 surfaces and the volume entirely). Physical groups are meant to be an additional tag layered on top of a full mesh in this feature, never a filter, hence the explicit override.
gmshService.ts's buildIndices also uses the returned tag→part maps to bucket generated triangles into contiguous per-part ranges (MeshElementGroup[] — see Protocol messages) for the live overlay's per-part colouring, scoping getElements(dim, tag) to one entity at a time instead of the original single global call. For dimension === 3, this runs per volume (getElements(3, tag) limits tets to that volume's own set before the tet-boundary-face-dedup algorithm runs) — correct even for two touching part-volumes, since Gmsh tags each tetrahedron by its single owning volume regardless of geometric adjacency, so a face shared between two touching volumes is independently each volume's own boundary.
Surface-scoped parts DO get their own colour range in a 3D overlay too (closed roadmap gap, re-verified against the live WASM): a tet-boundary triangle on its own only carries its owning volume's tag, with no link back to the B-rep surface it came from, so buildIndices3D cannot classify it by surfaceTagToPart directly. It resolves the correlation via the intermediate 2D surface mesh Gmsh generates as part of building the volume mesh — still live in the model after a 3D mesh.generate(), with the same node tags as the volume boundary it seeded, since the volume mesh is built directly from its own boundary surface mesh. Each surface-scoped part's getElements(2, tag) result is turned into a set of sorted-corner-tag keys (gmshElementTypes.ts's faceRingKey, the exact key boundaryFaceRings/boundaryTriangles already use to dedup interior tet/hex faces); every kept tet-boundary face is looked up by that same key, and routed to the matching surface part's group instead of its owning volume's when one matches — a surface-scoped part therefore wins over its own volume's volume-scoped part too, being the more specific assignment. gmshService.test.ts unit-tests the grouping/precedence logic directly against a fake GmshApi object (a real hex + a matching 2D quad face, no WASM needed); the correlation math (ring keys match regardless of winding order) is tested in gmshElementTypes.test.ts. 2D (dimension === 2) generates were always correct here (buildIndices2D groups directly by surfaceTagToPart via getElements(2, tag) per surface) and are unaffected by this change.
Per-part mesh size
A part can carry an optional meshSize?: number (a single target element size, not a min/max range) — set via the Parts panel's per-row numeric field or, equivalently, via the FE Mesh panel's mirrored "Part sizes" section (both edit the same Part.meshSize through the same PartsModel; blank = inherit the global size). For every part with meshSize set and at least one resolved entity, applyPartsToGmshModel creates a Gmsh Constant field (gmsh.model.mesh.field.add("Constant")) scoped to that part's resolved entities via PointsList/CurvesList/ SurfacesList/VolumesList (setNumbers) with VIn (setNumber) set to the part's meshSize. All parts' Constant fields are then combined via a Min field (FieldsList = every Constant field's tag) and set as the background mesh (setAsBackgroundMesh) — so the smallest requested size wins in any overlap, and regions outside every sized part keep the global Mesh.MeshSizeMin/Max sizing unaffected (Gmsh's own semantics: those remain clamps on the background field's output). This mechanism is implemented per the standard Gmsh Field API (documented in the Gmsh reference manual, not this WASM build specifically) and has been exercised end-to-end against the live WASM (examples/STP/angle1.stp, dimension 2 and 3, one part with meshSize: 0.5): the sized part's own region refines as expected, and every other surface/volume in the model still meshes fully — the Constant field's un-set VOut (Gmsh's own default, 1e22) only sizes the field's own output outside the part's entities, it does not blank out or fail to mesh unrelated entities, which was the failure mode worth specifically ruling out here.
STL/mesh-format sources get a narrower degrade, not the full mechanism above: since there is no per-entity correlation for STL input, src/meshOptions.ts's applyStlPartSizeOverride checks whether exactly one part in the document has meshSize set; if so, that value overrides sizeMin/sizeMax globally for that one generate/export call (never persisted to <model>.mesh.json). Zero or more than one part with meshSize set is ambiguous — which part's size should win for a single merged STL volume? — so it silently falls back to the panel's own options, unchanged.
Distance-graded sizing (roadmap "Boundary-layer and distance-threshold mesh sizing", Phase 1)
Part.meshSize (above) refines inside a part's own entities; it says nothing about the space around them. Part.meshGrading (src/meshOptions.ts's MeshGrading) closes that gap with a Distance + Threshold field pair, anchored on the same part, B-rep sources only (same gate as physical groups and meshSize — a mesh-format source has no per-entity correlation to anchor a field on at all):
export interface MeshGrading {
sizeAtWall: number; // element size within distNear of the part's entities
sizeFar: number; // element size at/beyond distFar (>= sizeAtWall)
distNear: number; // distance kept at sizeAtWall (>= 0)
distFar: number; // distance where size reaches sizeFar (> distNear)
}src/gmshSizingFields.ts's addDistanceThresholdField(gmsh, tags, grading) is the whole mechanism, pulled out of gmshPartsMap.ts's applyPartsToGmshModel into its own module specifically so the field-composition logic is unit-testable against a recording fake with no OCCT/entity-resolution involved (gmshPartsMap.ts stays the one place that resolves face-N/edge-N/solid-N/point-N ids to Gmsh tags; addConstantField, Part.meshSize's existing mechanism, moved into the same module verbatim, no behavior change):
const distTag = gmsh.model.mesh.field.add("Distance");
gmsh.model.mesh.field.setNumbers(distTag, "SurfacesList", surfaceTags); // + CurvesList/PointsList
gmsh.model.mesh.field.setNumber(distTag, "Sampling", 20);
const threshTag = gmsh.model.mesh.field.add("Threshold");
gmsh.model.mesh.field.setNumber(threshTag, "InField", distTag);
gmsh.model.mesh.field.setNumber(threshTag, "SizeMin", grading.sizeAtWall);
gmsh.model.mesh.field.setNumber(threshTag, "SizeMax", grading.sizeFar);
gmsh.model.mesh.field.setNumber(threshTag, "DistMin", grading.distNear);
gmsh.model.mesh.field.setNumber(threshTag, "DistMax", grading.distFar);Both this field's tag and every meshSize Constant field's tag land in the SAME FieldsList a single Min field combines and sets as the background mesh (setBackgroundMin, also in gmshSizingFields.ts) — Gmsh only supports one active background field, so a second setAsBackgroundMesh call would silently REPLACE, not compose with, the first; every sizing source for every part in the model must be gathered before that one call. No global Mesh.MeshSizeExtendFromBoundary/MeshSizeFromPoints/MeshSizeFromCurvature option is touched — grading is effective with Gmsh's own defaults, verified live (below), and touching those globals would change the density of every EXISTING mesh in this codebase, graded or not.
A Distance field cannot target a volume tag directly — only points, curves and surfaces — so a volume-scoped part's tags are first converted to their boundary surfaces via gmsh.model.getBoundary([3, t, ...], /* combined */ true, false, false), discarding the sign getBoundary returns (a SurfacesList tag is unsigned) and merging with any explicitly surface-scoped tags for the same part, deduplicated. Skipping this conversion is a REAL, silent-degradation risk, not a hypothetical: an empty SurfacesList is not an error to Gmsh, so a broken conversion doesn't throw — it degrades quietly to the model's ungraded baseline density, exactly the failure mode a live-WASM verification pass caught by bug-injecting the conversion out and confirming the resulting mesh matched the no-parts baseline exactly.
Verified against the live gmsh-wasm 0.3.0 build (examples/STP/block.stp, a real 3×4×5 box, through the real dist/mcp-server.js, not a synthetic fixture): a no-parts baseline (381 nodes) < a single face graded at {sizeAtWall: 0.15, sizeFar: 1, distNear: 0.3, distFar: 1.5} (3543 nodes) < the same size applied uniformly over the whole solid via meshSize (15940 nodes) — grading sits strictly between, as it should. Reading the exported .msh's own node coordinates back confirms the shape of the gradient, not just its existence: a slab within 0.3 units of the graded wall holds 2137 nodes against 55 in an equal-sized slab mid-domain (a ~39× density ratio), matching a direct hand-built Distance+Threshold probe against the same fixture almost exactly (2137/55, identical to the production-code result). Exporting the same model at unit:"in" (geometry, global size options, AND the part's grading band all rescaled by the same factor via scalePartsMeshSizeForUnit) still shows the gradient (~18× at the smaller absolute scale) — confirming the unit conversion and the field construction compose correctly. A volume-scoped part (the whole solid, exercising the getBoundary conversion) refines well beyond baseline as expected. An invalid band (sizeFar < sizeAtWall, distFar <= distNear, or a non-positive sizeAtWall/negative distNear) is rejected by validateMeshGrading (src/meshOptions.ts) with a named warning from set_part, leaving any existing valid band on that part untouched — never silently cleared. A mesh-format source's set_part call accepts the shape structurally but warns that meshGrading is ignored entirely, the same framing meshSize's STL degrade already uses for the adjacent case.
Interactive: the FE Mesh panel's existing "Part sizes" section (the one place per-part sizing already lives) gained a small Grade toggle per row, revealing four fields (Wall / Far / Near dist / Far dist) that commit through the same validateMeshGrading gate — an invalid band shows an inline error and is never sent to the host, rather than silently applying or clearing. PartsModel.setMeshGrading/clone() mirror the existing meshSize plumbing exactly (including the "a field added to clone() must be copied there too, or it silently unpersists" defect class this codebase has already hit once for a selector field). MCP: set_part gained a meshGrading parameter (object or null to clear); describe_capabilities' meshOptions.notes documents it.
Not separately re-verified: whether a Distance+Threshold+Min field composition survives the .geo_unrolled/XAO round trip the same way the existing Constant+Min composition already does. XAO round-tripping is Gmsh's own generic field-serialization mechanism (no per-field-type code lives in this codebase's own exportGeoUnrolled path — see the XAO entry above), so there is no code-level reason for a Threshold field to behave differently from a Constant one, but this was not independently checked live for this feature the way the node-density gradient itself was.
Options, sidecars, and the .geo script
The mesh generation options are a single flat, vscode-free, unit-tested bag defined in src/meshOptions.ts:
export interface MeshOptions {
dimension: 1 | 2 | 3;
sizeMin: number;
sizeMax: number;
algorithm2D: number; // Mesh.Algorithm
algorithm3D: number; // Mesh.Algorithm3D
elementOrder: 1 | 2;
elementShape: "simplex" | "subdivided"; // triangles/tets vs quads/hexes
optimize: boolean;
stlAngle: number; // classifySurfaces angle, degrees
}validateMeshOptions is the single tolerance gate: an individually invalid field (wrong type, out-of-range, or sizeMin > sizeMax) falls back to DEFAULT_MESH_OPTIONS for that field alone, rather than rejecting the whole options object — the same graceful-degradation philosophy EditOp/Part sidecars use elsewhere in this codebase.
sizeMax defaults to the 1e22 "unbounded" sentinel (SIZE_MAX_SENTINEL), and the webview seeds a real default over it. Once a model's bounding box is known, syncMeshSizeSeed() (src/webview/main.ts) replaces a still-sentinel sizeMax with diagonal / 20 (defaultTargetSize in src/webview/meshSizeHeuristics.ts) — via MeshingModel.load(), which does not fire onChange, so merely opening a file never posts meshingChanged and never creates .mesh.json/.geo sidecars; the seeded value only persists after a real user change. A persisted user-set sizeMax (≠ sentinel) always wins over the seed. The panel never displays the raw 1e+22: the Advanced "Size max" field shows an empty auto placeholder and the slider stays disabled until the seed resolves.
The panel's primary size control is a coarser→finer slider driving sizeMax, log-scaled between diagonal / 5 (coarsest) and diagonal / 200 (finest), with Coarse/Medium/Fine presets at diagonal / {10, 20, 50}. Its readout shows the size plus an element-count estimate, and estimates above ~1M elements raise an inline warning before Generate. All of that math lives in the pure, headless-tested src/webview/meshSizeHeuristics.ts — plain-number JS computed from the bounding box only (estimateElementCount is an order-of-magnitude heuristic that knowingly overestimates non-boxy models), never a gmsh/WASM call, so rendering the panel keeps the lazy-WASM-init invariant intact. Slider/preset commits that would drop sizeMax below the current sizeMin reset sizeMin to 0 in the same patch, so validateMeshOptions' pair rule can't silently revert both on reload.
Two files persist beside the source model, both generated by src/meshOptionsStore.ts (the vscode.workspace.fs I/O layer over the pure src/meshOptionsSidecar.ts parse/serialize functions):
<model>.mesh.json— theMeshOptionsthe panel was last set to, autosaved ~500 ms after each change (meshingChanged, on its own debounce timer, separate from parts/edits). Read back onreadyand used to hydrate the panel (meshingOptionsmessage).<model>.geo— an editable Gmsh script generated from the same options bygenerateGeoScript(src/meshOptionsSidecar.ts), written on the same debounce. It merges the source file and sets oneMesh.*option perMeshOptionsfield:Merge "model.stp"; Mesh.MeshSizeMin = 0; Mesh.MeshSizeMax = 1e22; Mesh.Algorithm = 6; Mesh.Algorithm3D = 1; Mesh.ElementOrder = 1; Mesh.RecombineAll = 0; Mesh.SubdivisionAlgorithm = 0; Mesh.Optimize = 1; Mesh 3;This is a one-way generation. See Known limitations below —
<model>.geois never parsed back by the extension.
Protocol messages
Six message types were added to src/protocol.ts for this feature (see Host ↔ Webview Protocol for the full message catalogue):
| Message | Direction | Purpose |
|---|---|---|
meshingOptions | host → webview | Hydrates the panel with the sidecar's (or default) MeshOptions on load. |
meshingResult | host → webview | Encoded boundary triangulation (positions/indices, base64) plus nodeCount/elementCount stats and elementGroups (MeshElementGroup[] — per-part contiguous triangle ranges, from physical-group resolution; see Parts → physical groups) after a successful generate. |
meshingError | host → webview | A human-readable failure message (bad geometry, GMSH exception, missing STL data) rendered in the panel's status line. |
meshingChanged | webview → host | A MeshOptions patch to persist (<model>.mesh.json + <model>.geo). |
meshingGenerate | webview → host | Request to run generateMesh now; carries the current options and, for mesh-format documents, a base64 stl snapshot. |
meshingExport | webview → host | Request to write the mesh (or, for "geoUnrolled", the unrolled geometry; or, for "mdpaElements"/"mdpaGeometries", hand-serialized Kratos MDPA — see Export formats) to disk in the format picked in the panel's export <select>, via a save dialog; target is a MeshExportFormatId, same options/stl payload as meshingGenerate. |
Export formats
The FE Mesh panel's export <select> is populated from MESH_EXPORT_FORMATS in src/meshExportFormats.ts — a single, vscode-free registry ({id, label, extension, filterLabel}[]) imported by both the host (gmshService.ts/ provider.ts, to pick the MEMFS write extension and the save-dialog filter) and the webview (meshingPanel.ts, to build the <option> list), the same "shared, kernel-agnostic" convention meshOptions.ts already established. This replaces the original one-button-per-format design (📤 .msh, 📤 .geo) — that pattern doesn't scale once more than two or three Gmsh output formats are offered.
gmsh.write(fileName) dispatches purely by the output path's extension; the registry's extension field doubles as both the MEMFS write extension (which Gmsh writer gets selected) and the save-dialog's default extension/filter. Since neither gmsh.d.ts nor the package README enumerate which formats a given WASM build actually supports, the full set Gmsh's writer-dispatch table recognizes was probed directly against the live gmsh-core.wasm (gmsh.write("/out.<ext>") for every extension in Gmsh's own Mesh.Format option-string enumeration, found via a strings scan of the .wasm binary):
| Result | Formats |
|---|---|
| Works | msh (v4.1, default), msh2 (legacy v2.2 — selected purely by writing to a .msh2 path, no Mesh.MshFileVersion option needed), geo_unrolled (existing, XAO-companion caveat below), vtk, unv (I-DEAS Universal), inp (Abaqus), bdf/nas (Nastran Bulk Data — same writer, only bdf is registered), su2, mesh (INRIA Medit), stl, diff (Diffpack), off, plus a few registered but not offered in the UI as redundant/niche for FE/CFD interchange: ply2, wrl, x3d, dat, m/matlab, ir3, celum |
| Compiled out | cgns, med — both extension-recognized (Gmsh's dispatch code path exists) but throw "This version of Gmsh was compiled without CGNS support" / "Gmsh must be compiled with MED support to write '...'"; both formats need HDF5-backed libraries (libCGNS, libMED) this WASM build doesn't statically link. Rebuilding @loumalouomega/gmsh-wasm with those libs linked in is the "fix Gmsh itself" path — not attempted; instead CAD-Preview bridges through a second, independent WASM module for these (and adds xdmf, which Gmsh's own writer table doesn't even recognize as an extension) — see "The meshio++ bridge" below. |
| Unusable for this pipeline | p3d, neu — wrote 0 bytes for a tri/tet mesh (structured-grid/quad-oriented formats); vtk_bin, tochnog, matlab (as a bare unrecognized extension distinct from .m) — not recognized as output extensions at all in this build. |
All working text formats are read back via gmsh.FS.readFile(path, { encoding: "utf8" }) and written UTF-8 as-is — none of the offered formats produced binary output in this build (Gmsh defaults to ASCII output unless Mesh.Binary is explicitly set, which this pipeline never does). src/gmshService.ts's exportMeshFormat() is the generic writer for every format except "msh" (which reuses generateMesh's mshText side product), "geoUnrolled" (which has its own XAO-companion handling, see above), and "mdpaElements"/"mdpaGeometries" (hand-serialized, see below — never a gmsh.write() call at all) — a thin loadGeometryAndApplyOptions → mesh.generate(options.dimension) → gmsh.write("/out.<extension>") → read-back-as-text, since none of the remaining formats need anything beyond a generated mesh.
Kratos MDPA (hand-written, not a gmsh.write() format)
Kratos Multiphysics' .mdpa format is not one of the formats in the probe table above — Gmsh has no MDPA writer at all, so it can't be reached through exportMeshFormat(). It's serialized entirely by hand: src/mdpaWriter.ts (pure, vscode/WASM-free, unit tests in mdpaWriter.test.ts) builds the ASCII text from a plain MdpaMesh ({nodes, tets, triangles, groups}); src/gmshService.ts's exportMdpa() + private extractMdpaMesh() pull that data off the live gmsh model after mesh.generate() (via getNodes()/per-entity-tag getElements(dim, tag) loops, the same pattern extractBoundaryFaces/appendTriangles2D already use for the display triangulation) and hand it to writeMdpa(). No MEMFS write/read-back round trip exists for this format.
Two mutually exclusive modes, each its own registry entry rather than a sub-toggle (mdpaElements/mdpaGeometries in meshExportFormats.ts, deliberately listed first so mdpaElements is the default-selected export format):
mdpaElements("Elements + Conditions") — the solver-ready shape. Volume cells →Begin Elements <ElementName>, surface cells →Begin Conditions <ConditionName>, both<id> <prop_id> <n1> ... <nk>withprop_idalways0under a singleBegin Properties 0block — this codebase has no material/property data of any kind, so there's never a second property id to reference.mdpaGeometries("Geometries") — volume cells →Begin Geometries <GeometryName>, surface cells →Begin Geometries <GeometryName>,<id> <n1> ... <nk>with no property id (the structural difference from the other mode) and noPropertiesblock. Kratos'sGeometriesis a single container, so all kinds share one id space — volume kinds get1..V, surface kinds continueV+1..V+S, not restarting at 1.
Supported cell kinds (linear + quadratic), each mapped by the shared gmshElementTypes.ts table to its Kratos block name and gmsh→Kratos node permutation:
| family | linear (geometry / element·condition) | quadratic |
|---|---|---|
| tetrahedron | Tetrahedra3D4 / Element3D4N | Tetrahedra3D10 / Element3D10N |
| hexahedron | Hexahedra3D8 / Element3D8N | Hexahedra3D27 / Element3D27N |
| prism | Prism3D6 / Element3D6N | Prism3D15 / Element3D15N |
| pyramid | Pyramid3D5 / Element3D5N | Pyramid3D13 / Element3D13N |
| triangle | Triangle3D3 / SurfaceCondition3D3N | Triangle3D6 / SurfaceCondition3D6N |
| quadrilateral | Quadrilateral3D4 / SurfaceCondition3D4N | Quadrilateral3D9 / SurfaceCondition3D9N |
Every name in this table — geometry as well as element/condition — is now confirmed against Kratos's own core C++ source (roadmap "Confirm Kratos MDPA block names", closed): kratos/sources/kratos_application.cpp in the KratosMultiphysics/Kratos GitHub repo unconditionally registers KRATOS_REGISTER_ELEMENT("Element3D4N", ...) through KRATOS_REGISTER_ELEMENT("Element3D27N", ...) and KRATOS_REGISTER_CONDITION("SurfaceCondition3D3N", ...) through KRATOS_REGISTER_CONDITION("SurfaceCondition3D9N", ...), each wrapping exactly the geometry class this table lists (e.g. mElement3D20N wraps Hexahedra3D20<NodeType>) — found via gh api search/code against the real repo, not assumed. These are CORE registrations, run by every Kratos installation regardless of which physics application is loaded — not an application-specific naming convention (a real Kratos .mdpa test fixture in the same repo uses an application-specific name instead, GeoTransientThermalElement3D20N, confirming the bare Element3DNN family is the genuine core fallback, not a fabricated placeholder). "elements" mode still runs a pre-flight that throws an actionable "export in Geometries mode instead" error if the generated mesh contains a kind whose element/condition name is unset — "geometries" mode is always safe — kept in place for any FUTURE unmapped kind (e.g. the hex-dominant "trihedron" connector), not because today's names are in doubt.
A complete order-2 prism (gmsh PRI18, 18 nodes) and pyramid (PYR14, 14) are truncated to Kratos's Prism3D15 / Pyramid3D13 — verified against the live WASM that PRI18's first 15 / PYR14's first 13 reference-node coordinates coincide with the shorter element's, so dropping the extra face nodes is exact. The dropped nodes remain in Begin Nodes (possibly unreferenced). This only matters for an order-2 mesh containing prism/pyramid elements — the RTree elementShape: "hexDominant" mode (see Known limitations) does NOT produce either kind (it produces tet + hex + an unmapped trihedron connector type only), so in practice this table row exists for future/manually-constructed prism/pyramid meshes, not anything the hex-dominant option itself generates.
Both a kind's root block and its SubModelPart* sub-block are omitted when empty — never an empty Begin/End pair. A genuinely unmapped element type throws an actionable error in extractMdpaMesh() (defensive backstop).
Node ordering. The gmsh→Kratos node permutation for every kind was derived by coordinate-matching getElementProperties().localNodeCoord against transcribed Kratos reference-element local coordinates (linear cells, tri6, and quad9 are identity; tet10, hex20, hex27, prism15, pyramid13 are non-trivial). Applied during extraction so mdpaWriter.ts always receives Kratos-ordered nodes. As a defensive backstop, orientCell() recomputes each cell's signed volume (divergence theorem over the kind's outward boundary faces) and, for a negative tetrahedron, applies the well-defined tet4/tet10 re-orientation swap; a negative hex/prism/pyramid (which gmsh shouldn't emit) is passed through unchanged with an onWarning callback rather than an unsafe reshuffle.
SubModelParts map 1:1 to Part[] (B-rep sources only — mesh/STL documents get parts: [] before reaching gmshService.ts, same as every other parts-preservation feature, so their MDPA export has root blocks only, no SubModelParts). Part[] has no nesting concept anywhere in this codebase, so SubModelParts are always flat — one top-level Begin SubModelPart <name> per part, never nested. extractMdpaMesh()'s private groupPartsAcrossDims() (the 4-map generalization of buildIndices's existing groupTagsByPart) reuses PartGroupMaps from applyPartsToGmshModel — already computed as a side effect of loadGeometryAndApplyOptions, never recomputed — to bucket each part's tets/triangles by owning volume/surface tag, plus resolve its lines/points selections to extra node ids via getNodes(1, curveTag, /*includeBoundary*/ true)/getNodes(0, pointTag) (a part's edges/points contribute only to SubModelPartNodes, never their own element/condition/ geometry entries — this exporter's cell scope is strictly linear tets and triangles). Each SubModelPart's SubModelPartNodes is the union of those explicit selections and every node its grouped cells reference — never just the explicit selection, so a reader never needs to backfill implied nodes. SubModelPart names are sanitized (anything but [A-Za-z0-9_] → _, a non-letter/underscore start gets a Part_ prefix) and de-duplicated among siblings with a _2, _3, … suffix. Output is fully deterministic: node ids are assigned by sorting on source tag ascending, and element/condition/ geometry ids by sorting each cell's own (already-renumbered) node-id tuple ascending — byte-identical output for the same geometry regardless of gmsh's internal (not contractually stable) enumeration order. Node coordinates are always written in scientific notation (x.toExponential(), e.g. 8e+0, 9.769962616701e-14) rather than plain decimal, at full round-trip precision (no fixed digit count — exactly as many digits as the double needs).
Verified end-to-end against the live WASM build on examples/STP/angle1.stp with a 2-part Part[] (one volume-scoped, one surface-scoped): 1899 tets / 988 boundary triangles generated; the volume-scoped SubModelPart's SubModelPartElements claimed all 1899 tets and none of the conditions; the surface-scoped one claimed the matching triangles and none of the elements; every connectivity line had the expected column count with no node id outside [1, nodeCount]; Mode B's triangle geometry ids all came out strictly after its tet geometry ids (confirming the shared id space).
Re-verified end-to-end after the element-shape/order extension, on angle1.stp across all four {simplex, subdivided} × {order 1, 2} combos (Mesh.Algorithm3D=1): every combo produced a watertight overlay (zero odd boundary edges — simplex → tet4/tri3 & tet10/tri6, subdivided → hex8/quad4 & hex27/quad9), no orientCell warnings, no negative tets, and both MDPA modes emitted every expected block name with all connectivity ids in range. Corner-only overlay display makes the boundary-triangle count identical between order 1 and order 2 of the same shape.
The meshio++ bridge (MED, CGNS, XDMF, GiD — not gmsh.write() formats either)
Like MDPA above, MED/CGNS/XDMF export never calls gmsh.write() — but unlike MDPA, they're not hand-serialized either. src/meshioService.ts's exportViaMeshio() re-encodes an already-generated mesh through a second, independent WASM module, @meshioplusplus/wasm (MIT-licensed; see the README's Licensing section), which this gmsh-wasm build simply doesn't have writers for at all.
Bridge mechanics. generateMesh()'s own mshText (modern MSH 4.1, Gmsh's default output version) is handed straight to exportViaMeshio(), which writes those bytes into meshio++'s own MEMFS and calls its convert()/readMesh()+writeMesh() (see below), bridging the two modules' independent virtual filesystems via a plain buffer round trip — no browser, no shared memory.
MSH 4.1 input requires @meshioplusplus/wasm ≥ 9.7.0 — before that, the bridge needed a legacy MSH 2.2 detour (kept here as the historical record, per this doc's convention). Against 9.4.1, feeding MSH 4.1 text into meshio++'s convert(..., {inFormat: "gmsh"}) threw "Gmsh $Entities not supported by the C++ reader" immediately — that build's C++ Gmsh reader only understood the older MSH 2.2 schema, so the bridge routed through exportMeshFormat(..., "msh2") first. 9.7.0 parses $Entities natively (ascii and binary), and — the genuinely valuable part — resolves physical-group membership from it: a 4.1 read now yields named regions (one per physical group, i.e. one per CAD-Preview part), which the 2.2 path never produced (re-verified: a 2.2 read of the same mesh yields no regions — $PhysicalNames alone isn't enough; 4.1's $Entities is where membership lives). The bridge input was switched back to generateMesh()'s own 4.1 mshText accordingly, dropping the extra gmsh.write() round trip.
Every format, MED included, is a single, uniform convert() call as of @meshioplusplus/wasm 9.8.0 — the MED-specific two-step below is now historical. Under 9.7.0, direct convert(..., {outFormat: "med"}) hit two independent obstacles: it threw "MED: gmsh physical groups handled by Python fallback" whenever cell_data carried gmsh's own "gmsh:physical"/ "gmsh:geometrical" tags (which readMesh(..., "gmsh") always attaches to a gmsh-sourced mesh), and MED separately rejected MSH 4.1's natural one-block-per-entity cell layout with "MED files cannot have two sections of the same cell type" (a meshed unit box arrives as 27 blocks: 8 vertex + 12 line + 6 triangle + 1 tetra). The workaround was: readMesh() the 4.1 text, run the result through merge([mesh]) to consolidate same-type blocks and remap region indices, then hand-build a brand-new plain object of only {points, dim, cells, regions} (dropping cell_data to dodge the Python-fallback throw) and writeMesh() that to MED.
9.8.0's write_med now bridges gmsh:physical to MED families and consolidates same-type blocks natively in C++ — both obstacles are gone, so that entire workaround was deleted from meshioService.ts. Re-verified end-to-end against the live 9.8.0 WASM with a real Gmsh-generated 4.1 file (box, tet-meshed, MyVolume/MySurface physical groups, Mesh.SaveAll forced on — the exact shape generateMesh() produces), not just a unit test: convert(inPath, "/out.med", {inFormat: "gmsh", outFormat: "med"}) followed by readMesh("/out.med", "med") returned both region names intact with no special-casing. CGNS/XDMF/VTK never needed this; plain convert() already worked directly on the 4.1 text.
The CGNS pure-surface read-back gap is also closed in 9.8.0 — previously verified broken on 9.4.1 and 9.7.0: exporting a pure-surface mesh (triangle/quad only, no volume cells — i.e. every 2D-dimension FE-mesh generate) produced a CGNS file this same WASM build's own reader couldn't read back ("HDF5: missing dataset ' data'"), because CGNS was a private, tetrahedra-only encoding whose writer emitted only the first tetra block it found — a mesh with no tetra block at all (every 2D generate) wrote an empty ElementRange/ElementConnectivity. 9.8.0 rewrote CGNS to a genuine CGNS/SIDS-compliant unstructured-mesh subset, one section per cell block rather than "first tetra block only". Re-verified against a real Gmsh 2D-dimension generate fed through the same convert() path exportViaMeshio() uses: the CGNS round-trips clean through this WASM's own reader. Volume (3D tet/hex) meshes were never affected by the old bug.
The bridge now also reaches 9 writers Gmsh has no writer for AT ALL — vtu, hmf (HDF Mesh Format), avsucd (AVS UCD), mphtxt (COMSOL), netgen, flac3d, wkt (Well-Known Text), flux, and gid (GiD postprocess) (meshExportFormats.ts's registry, via: "meshio") — a genuine widening of the bridge beyond its original MED/CGNS/XDMF motivating case, verified the same two-step way as every addition to this bridge: (1) confirm gmsh.write() has literally no writer for the extension (every one of these threw Gmsh's own "gmshWrite: Unknown output file format", not a shape-specific rejection — ruling out "this just duplicates an existing Gmsh path"), then (2) confirm meshio++'s OWN writer for the format accepts THIS pipeline's actual generateMesh()-shaped MSH 4.1 input (with Mesh.SaveAll forcing 0-D vertex cells for the model's geometric points into the output, not just the volume mesh) and the result round-trips back through meshio++'s own reader for that format with matching point/cell counts. A meaningful number of superficially-plausible candidates FAILED step (2) on this specific input shape and are deliberately excluded, not merely unconsidered — see meshExportFormats.ts's own doc comment for the full list and each one's actual failure message (vtp/vti's structural mismatch with a tet mesh; h5m/ugrid/tecplot/ansys/ansysinp/freefem all rejecting the vertex cells; dex/ip/mff/mfm losing all cell connectivity or failing outright on round-trip).
XDMF writes an HDF5 companion file, confirmed against the live WASM: convert(..., "/out.xdmf", ...) also writes /out.h5 (same MEMFS basename, swapped extension) and the .xdmf XML's <DataItem Format="HDF"> elements reference it by that bare filename (e.g. out.h5:/data0). exportViaMeshio() returns bytes/companion separately so the caller can write both under the user-chosen save filename's basename and rewrite the embedded reference to match — provider.ts's meshingExport handler (and mcpTools.ts's exportMeshTool) do this the same way they already rewrite .geo_unrolled's Merge "...xao" stub for B-rep sources.
GiD is the bridge's second companion-bearing format, and the reason companion handling became registry-driven (@meshioplusplus/wasm 10.18.0+). Its ascii writer emits a sibling pair — geometry in <stem>.post.msh, results in <stem>.post.res — where XDMF emits a referenced .h5. The two need genuinely different write paths, not just a different filename: XDMF's primary names its companion in its own content, so that reference is rewritten to whatever the user saved; GiD's is found by stem convention alone, so its primary must be written byte-for-byte untouched. Both export call sites used to carry their own hardcoded .h5/XDMF copy of this logic; both now read meshExportFormats.ts's companion: { extension, linkage } field, so a third companion-bearing format needs no dispatch-site change. companionSaveName() (same module, unit-tested) derives the sibling's name by stripping the format's full, possibly compound extension — a last-segment strip would turn a beam.post.msh save into beam.post.post.res.
GiD cleared the same two-step bar as every other addition to this bridge: fed this pipeline's actual Mesh.SaveAll-shaped MSH 4.1 input (vertex + line + triangle + tetra), it writes the pair and reads back through meshio++'s own reader with every block and count intact — as does XDMF with meshio++ 16.16.0. GiD is also an import format; see doc/file-formats.md.
Provenance tagging (@meshioplusplus/wasm 10.17.0+, WASM leak fixed in 10.20.1). exportViaMeshio() opens an explicit withProvenance(1, …) scope around the convert() call, recording the true source document (path + CadFormat) rather than the meaningless /in.msh intermediate. withProvenance is used in preference to the raw provenanceBegin/provenanceEnd pair because it closes the scope even if the body throws. Since Tier 1 closed, the scope also carries the conversion chain as Note [category]: detail lines (src/meshProvenanceNotes.ts's buildMeshProvenanceNotes, shared by export_mesh and the FE Mesh panel's Export so both record identically): the engine that actually ran, sizeMin/sizeMax (auto when still at the unbounded sentinel), dimension/shape/order, the export unit, and whether edits were baked — every entry a fact about the call, never a verdict.
Coverage is deliberately NOT universal, and this matters more than it sounds. A provenance block only lands where the container has a header slot meshio++ renders one into. Verified by inspecting raw output bytes — not just readProvenance(), which additionally cannot scan HDF5-backed slots even where one exists:
| Embeds provenance | Embeds nothing |
|---|---|
vtu, avsucd, mphtxt, netgen, flac3d, flux, gid | med, cgns, xdmf, hmf, wkt |
So this is a no-op for MED/CGNS/XDMF — the three formats this bridge originally existed for, and the ones most people reach for. Passing a source is harmless everywhere and useful for the other seven, but the split must not be described as blanket coverage. scripts/mcp-smoke/run.mjs pins both halves (present for vtu/gid, absent for med/cgns), so a future meshio++ release that closes the gap is noticed rather than silently assumed.
Note this codebase skipped the buggy window entirely: 10.14.0 predates default-on provenance (10.17.0), and 10.20.1 fixed a WASM leak where a note raised by an earlier scope-less call — extractSurface's "named regions dropped" warning, which this codebase's own import path raises constantly — could bleed into a later, unrelated write's header. Re-verified live after the bump: a write following extractSurface carries only the one-line credit, with no leaked note.
On the read side, the .h5 companion is staged back in automatically. meshio++ 16.16.0 also fixes the formerly independent Mixed-topology reader failure: this extension's own exports, including Gmsh Mesh.SaveAll point/line/triangle/tetra blocks, can now be reopened and re-meshed. Both the compatibility corpus and MCP smoke test cover that round trip. Missing HDF companions still prevent reading their referenced data.
Loading order / packaging. @meshioplusplus/wasm is ESM-only with no require condition at all (unlike gmsh-wasm, which is dual CJS/ESM) — meshioService.ts loads it via a dynamic await import(...), not a static top-of-file import, and it must stay external in esbuild.mjs for that reason (plus the same eager-pthread-worker-spawn risk gmsh-wasm's own comment documents, if it were ever bundled). It's always loaded with { variant: "seq" } explicitly — never "auto", since the package's own resolveVariant() picks the threaded build whenever typeof crossOriginIsolated === "undefined", which is unconditionally true under Node, so "auto" would always pick the eager-worker-spawning threaded build here. The build stages and .vscodeignore includes only the sequential variant's four files under dist/meshio/ (package.json, src/index.mjs, dist/meshioplusplus_wasm.mjs, dist/meshioplusplus_wasm.wasm) — the threaded variant's files are dead weight given "seq" is always forced.
Same module also powers document import for VTK/VTU/MED/CGNS/Exodus/XDMF/MDPA/OpenFOAM/Gmsh Mesh/Abaqus/I-DEAS Universal/SU2/INRIA Medit/GiD/Nastran — see doc/getting-started.md's Supported Formats note and meshioService.ts's convertToStlBoundary() (the reverse direction: source file → STL boundary surface, via convertSurface rather than convert, so multi-component data survives inside meshio++'s C++ core for as long as it's there — though the STL output format itself still can't carry it out).
Verified end-to-end against the live WASM build via npm run mcp:smoke: a hand-built tetrahedron .vtk file is loaded (load_model routes it through meshio, reports it as headlessly meshable — unlike .obj/.ply/ .gltf, which aren't), meshed (generate_mesh, real node/element counts), and exported to MED, CGNS (3D, since the 2D limitation above doesn't apply), and XDMF (confirming the .h5 companion is written and its embedded reference correctly rewritten to the chosen output filename); the exported .xdmf is then re-opened (load_model succeeds) and re-meshed (generate_mesh), which now succeeds with meshio++ 16.16.0 — all through the real dist/mcp-server.js process, not a mocked pipeline.
Webview: panel, model, and overlay display
src/webview/meshingModel.ts(MeshingModel) — a DOM-free store for the currentMeshOptions, mirroringEditsModel/PartsModel's pattern but simpler: since options are a flat bag rather than a list, there is no undo/redo, justload()(hydrate without firingonChange, used for the initial host→webview sync) andupdate()(patch + fireonChange, used for user edits).src/webview/meshingPanel.ts(MeshingPanel) — the DOM, top to bottom: a large-mesh warning strip (#meshing-warning, shown when the element-count estimate exceeds ~1M); the primary size control (Coarse/Medium/Fine preset buttons, the coarser→finer slider, and aSize: X · ~N elementsreadout — the slider refreshes the readout locally oninputand only commits the newsizeMaxonchange/release, so dragging never spamsmeshingChangedmessages); a "Part sizes" section (renderParts(parts), hidden while no parts exist) mirroring the Parts panel's per-partmeshSizeinputs; and a collapsed-by-default "Advanced settings" section holding the raw options form (dimension, size min/max, 2D/3D algorithm dropdowns, element order, optimize checkbox, and the STL angle field — disabled for B-rep documents viasetSourceKind, mirroringeditsPanel.setBRepOnly). Plus a Generate button, an export-format<select>(populated fromMESH_EXPORT_FORMATS, see Export formats above) with a single Export button, a Clear button, and a status line that shows eitherNodes: N · Elements: M · 3.2 s(the time ismeshingResult.elapsedMs, measured host-side around the generate call) or an error string. Pure DOM, no business logic (all size math delegates tomeshSizeHeuristics.ts), noprompt()/alert()(VS Code webviews block those — same constraint documented for the Parts/Edits panels).src/webview/meshSizeHeuristics.ts— the pure math behind the size control: bbox-derived default size, log-scale slider↔size mapping, preset divisors, and the order-of-magnitude element-count estimate (see Options, sidecars, and the.geoscript above). Plain numbers in/out — vscode-free, THREE-free, gmsh-free — and unit-tested headless. The panel gets the bounding box it needs viaViewer.getModelExtents(), pushed in bymain.tson each model load (setModelExtents).src/webview/geometryBuilder.ts'sbuildFEMesh(positionsB64, indicesB64, edgesB64, elementGroups)decodes the base64 buffers from ameshingResultmessage into aTHREE.Groupcontaining a shaded, multi-materialMeshBasicMaterialmesh (unlit — see the code comment for why) plus aLineSegmentswireframe, taggeduserData.entityType = "mesh". EachelementGroupsentry becomes onegeometry.addGroup(indexStart, indexCount, materialIndex)range and its own material —part.colorfor a resolved part, or the default blue0x4ea1fffor the trailing ungrouped range (or the single implicit range whenelementGroupsis empty, e.g. an STL source or a document with no parts) — so the overlay reads per-part just like the model's own faces do. The wireframe is built from the host'sedgesbuffer (true element edges), notTHREE.WireframeGeometryof the triangulated fill — otherwise a recombined hex mesh (quad faces split into 2 fill triangles) would show the quad-splitting diagonal and look identical to a tet mesh.gmshElementTypes.ts'sboundaryEdges/surfaceEdgesemit only polygon perimeters (deduplicated), so hexes draw as quads and tets as triangles. It's unaffected by grouping and stays a singleLineBasicMaterial.src/webview/viewer.ts'sViewer.setMeshOverlay(obj)adds/replaces that group as a sibling ofmodelin the scene (never a child) and disposes the previous overlay's geometries/materials before swapping — so toggling the FE Mesh overlay off leaves the original tessellated/loaded geometry completely untouched. It also toggles the model's shaded faces (entityType === "surface") invisible while an overlay is shown, and visible again once it's cleared — two overlapping opaque solids (the model's faces and the mesh overlay) are unreadable layered on top of each other; edges/points stay visible throughout as a feature-line reference. Display-only (Object3D.visible), never touches geometry.- Worst-quality-element highlighting closes the roadmap gap where bad tets are frequently interior and invisible in the boundary-only overlay above — without the tet→boundary-face correlation the roadmap flagged as the hard part.
gmshService.ts'scomputeQualityAndWorstElements(the functioncomputeMeshQualitywas folded into) selects, for a 3D generate only, every element scoring belowWORST_ELEMENT_QUALITY_THRESHOLD(0.2), sorted worst-first and capped atMAX_WORST_ELEMENTS(2000, never silently truncated —belowThresholdCountvsshownCountreports both), and triangulates each kept element's own full face set via the SAMEboundaryTriangles()used above — fed ONLY the worst elements, so a face shared between two adjacent bad elements dedups away (an interior seam within the cluster) while a face next to a good neighbor stays (the cluster's true outer surface).meshingResult's optionalworstElementsfield ({indices, threshold, shownCount, belowThresholdCount}) carries it to the webview.geometryBuilder.ts'sbuildWorstElementsHighlight(msg. positions, msg.worstElements.indices)builds aTHREE.Meshwith a bright0xff3b30MeshBasicMaterialsettransparent: true, depthTest: false, depthWrite: false— the SAME ghost technique the Hidden Lines display mode uses for occluded edges (seeCLAUDE.md's "Display modes" section) — so the highlight paints through occluding faces regardless of true 3D depth: the actual fix for "invisible when interior" is this webview-side styling, not a host-side projection onto the boundary.Viewer.setWorstElementsOverlay/setWorstElementsOverlayVisiblemirrorsetMeshOverlay/setMeshOverlayVisible's dispose/replace and show/hide-in-place pair, but are independent overlays —main.tsclears/sets both explicitly at every relevant call site rather than one implicitly driving the other. The panel's#meshing-worst-togglebutton auto-shows itself whenever a fresh generate hasworstElements(auto-hides otherwise) — a "surface a warning by default" framing, same as the large-mesh warning banner — with arenderQuality()readout line (⚠ N elements below quality 0.20 …) below the existing histogram. Unit-tested ingmshService.test.ts(dedup correctness, cap/priority) against a fakeGmshApi, and ingeometryBuilder.test.ts(the ghost material'sdepthTest/transparentflags) — the live-WASM generate path is exercised end-to-end bynpm run mcp:smoke, though the smoke fixture's mesh happens to be well-optimized enough thatworstElementsis usually absent there (a valid, common outcome the unit tests cover directly instead). src/webview/main.tswires the panel's callbacks topost()calls (meshingChanged/meshingGenerate/meshingExport), snapshots an STL viacurrentStlIfMeshSource()for mesh-source documents, and handles themeshingOptions/meshingResult/meshingErrormessages coming back from the host, callingviewer.setMeshOverlay(buildFEMesh(...))(plusviewer.setWorstElementsOverlay(...)when present) on a successful result andmeshingPanel.render(..., { error })on failure. The toolbar's 🔬 FE Mesh toggle (meshingToggle) shows/hides the panel and clears the overlay when switched off.- Generate feedback:
onGeneratecallsmeshingPanel.setBusy(true)before postingmeshingGenerate, and themeshingResult/meshingErrorhandlers callsetBusy(false).setBusydisables#meshing-generate(so the WASM call can't be re-triggered mid-flight) and shows an indeterminate progress bar (#meshing-progress, a CSS keyframe sweep) plus a"Generating…"status line — indeterminate becausegmsh.model.mesh.generate()is one opaque blocking call with no progress hook to report a real percentage from (GmshLoggeronly offers post-hoc wall/CPU time, not a streaming callback). 📤 Export (any format) is not wired tosetBusy; its save-dialog flow already surfaces completion/failure via the generic toolbar status bar.
Licensing
Bundling gmsh-wasm changes CAD-Preview's own license. See the README's Licensing section for the full statement; in short:
Gmsh statically links (and is itself linked with) OpenCASCADE, Netgen, METIS, and ParaView into a single WASM binary, and is distributed under the GPL-2.0-or-later (with a linking exception covering those dependencies) — on its own, this only required CAD-Preview to be
GPL-2.0-or-latertoo. CAD-Preview itself is distributed under the GPL-3.0-or-later — see LICENSE — a floor set separately by the committed-to "Build and bundle an OpenSCAD WASM port" roadmap item (a real OpenSCAD build genuinely links CGAL/Manifold, neither of which is GPLv2-compatible), landed ahead of that dependency actually shipping.
This is a strictly stronger copyleft than OCCT's own LGPL-2.1-with-exception (used directly for the B-rep read/export pipeline via opencascade.js, and indirectly a second time inside gmsh-wasm's bundled OCCT). The GPL obligation is triggered by gmsh-wasm's presence in the extension bundle, not by whether a given user ever opens the FE Mesh panel.
@meshioplusplus/wasm (the meshio++ bridge, see above) is MIT-licensed, including its compiled .wasm binary — bundling it doesn't change CAD-Preview's overall license (already GPL-3.0-or-later, see the README's Licensing section), it's simply an additional MIT dependency alongside @modelcontextprotocol/sdk/ zod/fflate. See the README's Licensing section for the full attribution list.
Known limitations
The meshio++ MED/CGNS export bridge had two narrow, verified gaps — TRUE for
@meshioplusplus/wasm≤ 9.7.0, CLOSED in 9.8.0 (see "The meshio++ bridge" above for the full write-up and re-verification). MED needed a strip-to-{points,dim,cells}-before-writing workaround (dropping any point/cell scalar field data, which this pipeline's generated meshes never carried anyway), and CGNS export of a pure-2D (surface-only) mesh produced a file this same WASM build's own reader couldn't read back (3D volume meshes were unaffected). Neither was a CAD-Preview bug — both were confirmed limitations of the bundled@meshioplusplus/wasmbuild itself, and both are gone as of 9.8.0: MED bridgesgmsh:physicaland consolidates same-type blocks natively now, and CGNS was rewritten to a genuine SIDS-compliant subset with no "first tetra block only" writer bug.No working 3D recombination (hex-dominant meshing) in the bundled WASM build — TRUE for
@loumalouomega/gmsh-wasm0.2.x, SUPERSEDED in 0.3.0 (see the update below). The all-hexsubdividedshape works (viaMesh.SubdivisionAlgorithm=2), but a hex-dominant mixed mesh (tets+prisms+pyramids+hexes viaMesh.Recombine3DAll) was completely non-functional in 0.2.x: every variant probed at the time —Recombine3DAll=1alone, combined withRecombineAll, withRecombine3DConformity/Recombine3DLevel, under Delaunay or Frontal — produced pure tetrahedra (no recombination at all) or threw "Cannot use frontal 3D algorithm with quadrangles on boundary". That probing pass never triedMesh.Algorithm3D=9(RTree) — the specific algorithm Gmsh's hex-tet hybrid recombiner is gated behind (confirmed by re-probing, see below) — so the 0.2.x "compiled without 3D recombination support" conclusion was itself incomplete, not just a since-fixed build limitation. SoelementShapewas restricted tosimplex/subdividedand no hex-dominant option was offered (validateMeshOptionsstill rejects"hexDominant", unchanged as of this writing — seeCLAUDE.md's "Meshing (GMSH-JS)" section for what adding it properly would need). ThegmshElementTypes.tstable still carries prism/pyramid rows (their permutations are verified) so the pipeline is ready if this is ever implemented — they remain unreachable today, by choice, not by WASM limitation.Mesh.Algorithm3D=10(HXT) was also broken in 0.2.x (empty mesh) — see the update below, same root cause as the 3D Delaunay bug two bullets down.Update,
@loumalouomega/gmsh-wasm0.3.0, verified against the live WASM onexamples/STP/block.stp: re-probing withMesh.Algorithm3D=9(RTree) +Mesh.Recombine3DAll=1— the exact combination the 0.2.x pass never tried — now produces a genuine hex-dominant mesh: element types[4, 5, 140](702 tetrahedra, 165 hexahedra, 366 type-140 "trihedron" connector elements stitching the tet/hex interface), completing in 482ms with no error. Gmsh's own upstream framing (relayed via GMSH-JS's README/docs) still calls the RTree path "experimental" and recommends Delaunay/HXT for production meshes.gmsh.model.mesh.getElementProperties(140)throws ("Size of basis incompatible with element type") — the coordinate-matching method every other element kind's Kratos node permutation ingmshElementTypes.tswas derived from doesn't extend to the trihedron connector, so that element's geometry remains unverifiable and deliberately has no table entry.Update, shipped as
elementShape: "hexDominant"(3D-only — seeCLAUDE.md's "Meshing (GMSH-JS)" section): rather than block on verifying type 140's geometry, every existing consumer ofgmshElementTypes.ts's lookup already treats an unmapped type as a graceful skip, not a throw (surfaceTriangles/boundaryTriangles/surfaceEdgesallcontinuepast it) — so the overlay/wireframe/quality pipeline needed ZERO changes and just silently omits type-140 elements' (non-existent, in practice — they're interior tet/hex transition connectors) contribution to the boundary surface. Re-verified end-to-end onexamples/STP/block.stpwith the real shipped code:generateMesh()withelementShape: "hexDominant"produced 446 nodes / 1289 elements with a POPULATED overlay (6384 triangle indices, 3348 edge-buffer values — confirming the boundary extraction produced real, non-empty output despite the unmapped type 140 mixed in) and a working quality summary (computeQualityAndWorstElements— min 0.163, mean 0.865, no crash on the mixed tet/hex/trihedron element-tag set); VTK export (Gmsh's own native writer, unaffected by our table at all) produced a valid 47.7KB file. Kratos MDPA export is the one path that genuinely cannot represent this mesh (noMdpaCellKindexists for a tet/hex transition connector) —gmshService.ts'scollectCellsgives type 140 a specific, actionable rejection message distinct from the generic "unsupported element type" one every other truly-unexpected type still gets; re-verified live thatexportMdpa()throws it correctly rather than producing silently-wrong Kratos output. HXT (Algorithm3D=10) on the same OCC-imported geometry completed correctly (37ms, 1254 tets, no hang/ empty-mesh) in the same 0.3.0 re-probe — see the 3D Delaunay bug entry below, which covers HXT's identical root cause and fix.
Per the original goal of this integration — flag anything GMSH-JS is missing so it can be reported upstream — five real gaps were found while building this feature (two of them only surfaced once parts-preserving meshing was exercised end-to-end, after the rest of this doc had already been written and manually verified — see the addendum at the end of this section), plus one explicit "everything else worked" confirmation:
No direct
.geowriter — only.geo_unrolled. GMSH-JS exposesgmsh.write(), which can produce a.geo_unrolledfile (Gmsh's fully-expanded, non-parametric script format) but has no API to emit a clean, hand-editable.geoscript from in-memory model state. CAD-Preview works around this by templating its own.geofile directly from theMeshOptionsJSON (generateGeoScriptinsrc/meshOptionsSidecar.ts) rather than asking GMSH-JS to produce one. The practical, user-facing consequence: hand-edits made to<model>.geoare never read back by the extension. The file is regenerated wholesale from<model>.mesh.jsonon every options change, so any manual changes to the.geotext are silently overwritten on the next edit in the FE Mesh panel. The generated file says as much in its own header comment (// Auto-generated by CAD-Preview. Edits here are not read back by the extension...), but this is worth stating plainly here too: it is a one-way generation, not a round-trip..geo_unrolledoutput for OCC-imported (B-rep) geometry is a dangling reference, not standalone text — a companion XAO file must be bundled with it. Confirmed against the live WASM: for every B-rep source (the STEP re-export every.step/.iges/.brepdocument goes through beforegmsh.model.occ.importShapes),gmsh.write("*.geo_unrolled")does not inline the shape as native GEO primitives — OCC B-rep geometry has no general textual GEO representation. It instead writes a single-line stub:Merge "/out.geo_unrolled.xao";referencing a companion XAO file (Gmsh's own OCC-preserving exchange format — it round-trips shapes, physical groups, and mesh-size fields) that
gmsh.write()additionally wrote to the same MEMFS directory as a side effect, under a path this code never originally read back. Saving only the.geo_unrolledtext to disk therefore produced a file that pointed at a MEMFS-only path with no corresponding file on the user's disk — reopening it in real Gmsh would fail outright. Fixed:gmshService.ts'sexportGeoUnrollednow also reads back the<outPath>.xaoMEMFS file (nullif absent — the STL/GEO-kernel path has no such companion, sinceaddSurfaceLoop/addVolumeunroll to real inline Point/Curve/Surface/Volume commands) and returns{ text, xao };provider.ts'smeshingExport"geo" branch writes the XAO bytes as a sibling of the user's chosen save path and rewrites the stub'sMergereference to that sibling's relative filename before writing the.geo_unrolledtext itself, so the pair is self-contained and actually reopens. Verified end-to-end: reopening the rewritten pair in a fresh Gmsh model (gmsh.open(...)) restored the exact same volume/surface counts, the same physical groups, and — after re-runningmesh.generate()— the exact same node count as the original generate that had a per-part sizing field active, confirming physical groups and mesh-size fields both survive the XAO round trip, not just the raw shape.Mesh.SaveAlldefaults to0, which silently drops every entity not in a physical group fromgmsh.write()output the instant any physical group exists. This is documented Gmsh behavior, not a WASM-build-specific gap, but it is an easy footgun for exactly this feature: once parts-preserving meshing started creatinggmsh.model.addPhysicalGroup(...)calls,.msh/.geo_unrolledexports for a document with even one part assigning even one face silently stopped containing the rest of the model. Confirmed against the live WASM (examples/STP/angle1.stp, one part covering 1 of 15 surfaces): the written.msh's$Elementssection contained exactly one entity block (that surface's 88 triangles) — the other 14 surfaces and the volume were entirely absent, while the live overlay (built fromgetNodes()/getElements()calls directly against Gmsh's in-memory model, not from re-reading the written file) was unaffected and showed the full, correct mesh, since those query APIs are not gated byMesh.SaveAll— onlygmsh.write()is. Fixed:loadGeometryAndApplyOptionsnow unconditionally setsgmsh.option.setNumber("Mesh.SaveAll", 1), since physical groups in this feature are always meant to be an additional tag on top of a full mesh, never a filter. Verified: the same model's.mshnow contains 92 entity blocks (the whole model) while still correctly listing the part in$PhysicalNames.gmsh.model.mesh.setSizeCallbackwas unsupported in the gmsh-wasm build this codebase originally bundled — no longer true of 0.3.0's binding manifest, see the correction below. Verified directly against the GMSH-JS source, not just the shipped.d.ts: the API definition (generated/gmsh-api.json) declaressetSizeCallbackwith acallbackargument of kindisizefun(a native function-pointer callback, invoked once per mesh vertex from inside the C++ meshing loop). The binding generator (scripts/gen_js.py) special-cased exactly this argument kind:pythonif kind in ("isizefun",): unsupported = True # function-pointer callback: skip wrapperand functions marked
unsupportedare excluded both from the emitted JS wrapper and from the generated.d.ts(if fn["unsupported"]: continue) — which is whysetSizeCallbackused to be entirely absent fromdist/gmsh.d.ts, while its siblingremoveSizeCallback(no callback argument) was present. In short, this was true of the gmsh-wasm build CAD-Preview bundled at the time — no mechanism to marshal a JavaScript function into the native mesh-sizing callback, only declarative, field-based sizing (Mesh.MeshSizeMin/Max, background mesh size fields, etc.).Correction (gmsh-wasm 0.3.0): the binding manifest no longer excludes it.
dist/gmsh.d.tsnow declaressetSizeCallback(callback: (dim, tag, x, y, z, lc) => number): void, and the generated descriptor (dist/gmsh-descriptor.mjs) marks its entry"unsupported": false— theisizefunspecial-case above must have been lifted upstream. Green in the manifest, per this doc's own standing rule, is necessary but not sufficient: nothing in this codebase has actually calledsetSizeCallbackagainst the live WASM, so whether the callback marshalling genuinely works (as opposed to compiling but throwing/aborting when invoked) is unconfirmed. This doesn't change what shipped: field-based sizing (Mesh.MeshSizeMin/Max, and theConstant/Distance+Threshold/Minfields — see "Per-part mesh size" and "Distance-graded sizing" below) is the deliberate choice regardless of whether the callback works, since it is declarative and round-trips through.geo_unrolled/XAO and this codebase's own JSON sidecars, where a JS closure could not. A future adaptive/per-region JS-computed sizing UI would need to probe this live before relying on it — that probe is now tracked in the roadmap as JS mesh-size callback.The WASM build had a known 3D Delaunay boundary-recovery failure on re-imported CAD geometry — FIXED upstream in
@loumalouomega/gmsh-wasm0.3.0; kept here as the historical record + verification trail, per this doc's convention of not deleting superseded findings. Documented in GMSH-JS's own README under "Known issues" at the time: the default 3D algorithm (Delaunay) could fail boundary recovery — producing zero tetrahedra, or hanging — specifically for geometry that had round-tripped through STEP/IGES import in this Emscripten target. Since every B-rep source CAD-Preview meshes has, by definition, just been imported viagmsh.model.occ.importShapes, this failure mode was directly in the feature's hot path — not a corner case. That is whyDEFAULT_MESH_OPTIONS.algorithm3Dinsrc/meshOptions.tswas set to4(Frontal) instead of Gmsh's own default, matching the workaround GMSH-JS's README recommended at the time (gmsh.option.setNumber('Mesh.Algorithm3D', 4)).Root cause and fix, per GMSH-JS's own 0.3.0 changelog/CLAUDE.md (
loumalouomega/GMSH-JScommit0cd8b24): not an algorithm-correctness bug at all — a wasm32 stack-overflow. Gmsh's tetgen-derived 3D boundary recovery (used by both the default Delaunay algorithm and HXT) recurses deeply; at-O3with no stack checks, overflowing Emscripten's 64 KiB default stack (shared by the main thread and every pthread) silently corrupted adjacent linear memory instead of trapping — surfacing as a hang or an empty mesh, reproducible even on small, non-degenerate geometry, not just pathological inputs. Fixed by raising-sSTACK_SIZE=4MB/-sDEFAULT_PTHREAD_STACK_SIZE=2MBin GMSH-JS's ownscripts/build-wasm.sh— a real fix to the actual defect, not a CAD-Preview-side workaround.Re-verified against the live 0.3.0 WASM (
examples/STP/block.stp, the exactgmsh.model.occ.importShapesre-import path CAD-Preview always uses):Algorithm3D=1(Delaunay) completed in 88ms producing 1282 tetrahedra — no hang, no empty mesh;Algorithm3D=10(HXT, sharing the same tetgen-derived recursion and therefore the same bug/fix) completed in 37ms producing 1254 tetrahedra. Both fully fixed, not merely improved.DEFAULT_MESH_OPTIONS.algorithm3Dwas updated from4back to1(Gmsh's own default) accordingly — existing documents are unaffected (they already have their own explicit value persisted in<model>.mesh.json; this only changes the seed for new documents that have never saved mesh options). Frontal (4) and HXT (10) both remain fully selectable from the 3D algorithm dropdown, and both still work correctly — there was never a reason to remove them, only to stop forcing one of them as the default.Parts → physical groups + per-part sizing fields: verified working against the live WASM (
examples/STP/angle1.stp, one volume-scoped part withmeshSize: 0.5and one surface-scoped part,dimension: 3): the standard GmshConstant/Minfield option name strings (PointsList/CurvesList/SurfacesList/VolumesList/VIn/FieldsList),addPhysicalGroup, and the bbox-centre correlation (bboxCentervs.getBoundingBox, tolerance1e-3 × model bbox diagonal) all resolved correctly — a no-parts baseline generate produced 499 nodes, the identical geometry with the sized part produced 235,088 nodes (clear, correctly-directed local refinement, not a silent no-op), and the resulting.msh's$PhysicalNamessection listed both parts by name at the right dimension (3 1 "PartA",2 2 "PartB"). This pass also originally surfaced a 3D-mode overlay-colouring gap for surface-scoped parts (physical groups/sizing were unaffected) — since closed; see Parts → physical groups above. This pass only checked live query results (getNodes/getElements) and the$PhysicalNamessection's presence — not whether the written.msh's$Elementssection actually contained every entity, or whether.geo_unrolledoutput was reopenable. Those two additional, narrower checks are what caught theMesh.SaveAlland XAO-companion gaps documented above; both are now fixed and covered by the end-to-end reopen-and-regenerate verification described there.Everything else this feature needed is present and worked correctly.
gmsh.model.occ.importShapes, the STL remesh sequence (merge→classifySurfaces→createGeometry→addSurfaceLoop→addVolume→synchronize),gmsh.model.mesh.generate, andgetNodes/getElementsall behaved exactly as documented against the live WASM build, with no binding gaps beyond the five points above. Every one of those five was a real default Gmsh option/output-format behavior CAD-Preview had to account for (Mesh.SaveAll, the XAO-stub.geo_unrolledoutput,setSizeCallback, the 3D Delaunay boundary-recovery bug, and the templated-not-generated.geo) — not a missing or broken binding — so there is no other GMSH-JS functionality gap to report upstream for this feature beyond what's already listed.Addendum: that statement is scoped to GMSH-JS's own API surface, and it still stands — but it shouldn't be read as "the whole feature shipped bug-free on the first try." Four integration issues surfaced after initial implementation and were fixed, all in CAD-Preview's own bundling/ integration code, not in GMSH-JS itself: (a) an esbuild ESM→CJS bundling quirk where gmsh-wasm's use of
import.meta.urlneeded abanner+defineshim to survive the CJS conversion (seeesbuild.mjs); (b) an undocumented MEMFS path-length limit in the OCCT WASM build that affectedexportBRep()'s generated temp file names (seesrc/occtService.ts); (c) the missingMesh.SaveAlloverride that silently dropped non-part geometry from.msh/.geo_unrolledwrites once any part existed; and (d) the unbundled XAO companion that left.geo_unrolledexports for B-rep sources pointing at a MEMFS-only path. (c) and (d) only surfaced once parts-preserving meshing was exercised against real multi-surface geometry with a resolved part — the earlier verification pass above predates that feature and didn't have physical groups active yet to trip either default.A second addendum, closing a real distinction the original statement blurred: "worked correctly" was verified against CLEAN input, and does not extend to ROBUSTNESS against dirty input. The STL remesh sequence's individual binding calls (
classifySurfaces/createGeometry/addSurfaceLoop/addVolume) are exactly as documented — butclassifySurfacesitself needs a watertight, manifold, well-oriented boundary to produce a meaningful result, and on a real-world dirty mesh (a hole, a self-intersection, a non-manifold edge) it does not throw — it silently returns a mesh with zero elements (verified live: a 10×10×10 STL cube with a single facet removed reportselementCount: 0, no exception at any layer). This is a genuine algorithmic robustness gap in theclassifySurfacesSTL-reclassification approach itself, not a binding defect — no amount of correct API usage avoids it, since the algorithm's own preconditions simply aren't met.MeshOptions.engine: "ftetwild"(roadmap "Robust volumetric meshing from a skin mesh", closed) is CAD-Preview's answer: an entirely separate WASM kernel (src/ftetwildService.ts, fTetWild) that tetrahedralizes the same dirty input directly, with noclassifySurfacesstep at all — seeCLAUDE.md's "fTetWild robust volume meshing" section for the full design, including how the resulting tets are handed back to Gmsh via a hand-written MSH 4.1gmsh.merge()so every downstream read-back path (buildIndices/buildEdges/gmsh.write()/MDPA export/quality) keeps working unchanged.
Planar simulation exports
With mesh dimension 2, MDPA Elements mode writes triangles/quadrilaterals as Element2D* and boundary curves as LineCondition2D*; named surface Parts become element SubModelParts and named curve Parts become condition SubModelParts. Use a planar XY face for a 2D Kratos analysis. Meshing a solid's entire skin in 2D does not turn that skin into a planar simulation domain. Geometries mode follows the same dimension and shares one ID space. MDPA export requires dimension 2 or 3.