Skip to content

File Formats ​

CAD Preview supports two classes of 3D files: B-rep (boundary representation) formats that require tessellation, and mesh formats that are already triangulated.

Format Overview ​

FormatExtensionsPipelineGrouped by solid
STEP.step, .stpOCCT → BRepMesh✅
IGES.iges, .igsOCCT → BRepMesh✅
BREP.brepOCCT → BRepMesh✅
OpenSCAD CSG.csgCSG parse → OCCT build → BRepMesh✅
OpenSCAD Source.scadbundled OpenSCAD WASM → .csg → same as above (user-installed openscad binary as fallback)✅
STL.stlThree.js STLLoader—
OBJ.objThree.js OBJLoaderper-object
PLY.plyThree.js PLYLoader—
glTF.gltf, .glbThree.js GLTFLoaderper-mesh node
3MF.3mfThree.js ThreeMFLoader (display-only on the host)per-object
Collada.daeThree.js ColladaLoader (import-only; display-only on the host)per-node
AMF.amfThree.js AMFLoader (import-only; display-only on the host)per-object
USD.usd .usda .usdc .usdzThree.js USDLoader (import-only; display-only on the host)per-object
VRML.wrlThree.js VRMLLoader (import-only; display-only on the host)per-object
FBX.fbxThree.js FBXLoader (import-only; display-only on the host)per-object
VTK / VTU.vtk, .vtumeshio++ → STL boundary → Three.js STLLoader—
libMesh.xda, .xdr, .xda.gz, .xda.bz2meshio++ → STL boundary → Three.js STLLoader—
Kratos CAD JSON.cad.jsonmeshio++ → in-place linearization → Three.js STLLoader; iga_* tools read the exact NURBS—
Tecplot binary.pltmeshio++ → STL boundary → Three.js STLLoader—
FEBio plot.xpltmeshio++ → STL boundary → Three.js STLLoader—
INRIA Medit binary.meshbmeshio++ → STL boundary → Three.js STLLoader (shares the .mesh reader)—
MSC Marc post file.t19meshio++ → STL boundary → Three.js STLLoader—
Abaqus results.filmeshio++ → STL boundary → Three.js STLLoader—
Ansys MAPDL results.rstmeshio++ → STL boundary → Three.js STLLoader (ReStructuredText caveat on open)—
LS-DYNA d3plot.d3plotmeshio++ → STL boundary → Three.js STLLoader—
Nastran OP2 results.op2meshio++ → STL boundary → Three.js STLLoader—
MED.medmeshio++ → STL boundary → Three.js STLLoader—
CGNS.cgnsmeshio++ → STL boundary → Three.js STLLoader—
Exodus.exo, .emeshio++ → STL boundary → Three.js STLLoader—
XDMF.xdmf (+ its .h5 sibling, if any)meshio++ → STL boundary → Three.js STLLoader—
Kratos MDPA.mdpameshio++ → STL boundary → Three.js STLLoader—
OpenFOAM.foammeshio++ (case staging) → STL boundary → Three.js STLLoader—
Gmsh Mesh.msh, .msh2meshio++ → STL boundary → Three.js STLLoader—
Abaqus.inpmeshio++ → STL boundary → Three.js STLLoader—
I-DEAS Universal.unvmeshio++ → STL boundary → Three.js STLLoader—
SU2.su2meshio++ → STL boundary → Three.js STLLoader—
INRIA Medit.meshmeshio++ → STL boundary → Three.js STLLoader—
GiD Postprocess.post.msh (+ its .post.res sibling)meshio++ → STL boundary → Three.js STLLoader—
Nastran Bulk Data.bdfrouted as Nastran; a bulk-data deck this extension's own Gmsh export writes, and hand-authored fixed-, free- and large-field decks (tetrahedra, hexahedra, wedges and shell-only CQUAD4/CTRIA3 decks), open and mesh (see the caveat below); a shell-only deck displays but has no volume to mesh—

B-rep Formats (OCCT Pipeline) ​

B-rep files describe solid geometry analytically (surfaces, edges, vertices) rather than as triangles. CAD Preview tessellates them on-the-fly using OpenCascade.js.

Processing Steps ​

  1. Read — the appropriate OCCT reader parses the file bytes:

    • STEP: STEPControl_Reader_1
    • IGES: IGESControl_Reader_1
    • BREP: BRep_Builder + BRepTools::Read
  2. Tessellate — BRepMesh_IncrementalMesh_2 with:

    • Linear deflection: 0.1
    • Angular deflection: 0.5 (radians)

    A shape with no sub-shapes at all (an empty TopoDS_Compound) skips this step entirely and yields zero groups. That is not merely an optimization: handing an empty compound to BRepMesh_IncrementalMesh_2 aborts the whole OCCT WASM instance — a hard fault, not a catchable OCCT exception — so the guard is what makes an empty document (see below) and any op list that reduces a model to nothing degrade honestly instead of poisoning the kernel. A shape that has vertices/edges but no faces meshes fine and is deliberately left alone.

  3. Extract faces — TopExp_Explorer walks the TopoDS_Shape, visiting each TopoDS_Face. For each face, the triangulation (Poly_Triangulation) is pulled from the location-transformed face and converted to WebGL-ready Float32Array positions and Uint32Array indices. Each face gets a stable id (face-N, deterministic explorer order) and records its parent solid.

  4. Extract edges — extractEdges() walks every TopoDS_Edge, de-duplicating shared edges by HashCode + IsSame (this OCCT build does not bind TopTools_IndexedMapOfShape), and discretizes each unique edge to a polyline via BRepAdaptor_Curve + GCPnts_UniformDeflection. Each gets a stable id (edge-N).

  5. Group — faces are collected into SolidGroups, one per top-level solid (TopAbs_SOLID). In the webview each face becomes its own THREE.Mesh and each edge its own THREE.Line, parented under a per-solid THREE.Group — so faces, edges, and solids can all be picked and coloured independently.

  6. Encode — positions and indices are base64-encoded and posted to the webview as EncodedMesh (faces) and EncodedEdge (edges) objects.

Tessellation Quality ​

The linear deflection of 0.1 is a reasonable default for mechanical parts. Decrease it (e.g. 0.01) for smoother curved surfaces at the cost of more triangles. This value is hardcoded in src/meshExtract.ts; it is not currently user-configurable.

Solid Grouping ​

A SolidGroup maps to one THREE.Group child of the root, holding one THREE.Mesh per face. The userData.groupId (the solid id) links faces to the component tree panel for highlighting; each face mesh also carries userData.entityType = "surface" and userData.entityId = face-N. Each group's faceCount is the number of OCCT faces (not triangles) that were extracted.

STEP ​

STEP (ISO 10303) is the most common exchange format for CAD. CAD Preview reads AP203 and AP214 (and AP242 in practice). Shell assemblies, free surfaces, and wire edges are not rendered — only faces that yield a triangulation.

IGES ​

IGES (Initial Graphics Exchange Specification) is an older format. The OCCT reader handles most IGES entity types but edge cases (trimmed surfaces, complex NURBS) may result in incomplete geometry.

BREP ​

BREP is OpenCascade's native binary topology format. It reads fast and has no conversion artifacts. The BRepTools::Read function parses directly into a TopoDS_Shape.

OpenSCAD CSG ​

OpenSCAD's .csg export is the fully evaluated model — loops unrolled, modules inlined, variables literalised, every transform an explicit node — so there is no language to implement. src/csgImport.ts (pure, headless-tested) parses the text into an AST; src/csgModel.ts builds it kernel-side with live handles into an opaque base shape (like a STEP import — user edits layer on top via the sidecar, the parsed structure is not itself an edit history).

Built: cube, sphere, cylinder, polyhedron (sewn, closure-checked), union/difference/intersection, group, color (no geometry; its colour becomes Parts — see below), multmatrix/translate/rotate/scale/mirror (all through one gp_GTrsf path — shear included, no decomposition needed), linear_extrude/rotate_extrude of a single polygon/square/circle child (2D (x, y) read as (radius, height) for revolve; twist/scale and paths= holes are a later phase and skip with a warning; slices/convexity are preview-only and ignored). Skipped with a load_model warning (whole subtree dropped): hull/minkowski (no OCCT equivalent), text/import/surface (external files/fonts), standalone 2D (square/circle/polygon outside an extrude), multi/union 2D extrude children, and anything unknown.

color() becomes Parts. On the first open of a .csg/.scad whose color() nodes colour at least one solid, one Part is created per distinct colour ("Part red" for a basic named colour, else "Part #ff8800"), coloured with it and listing its solid-N ids — so Clash, BOM and per-Part meshing work with no manual assignment. The rules are the meshio region ones: only into a still-empty <model>.parts.json, written once (a reopen does not duplicate), and load_model says so. The colour of a solid is its nearest enclosing color() (the innermost wins, through any translate/rotate/…); a difference keeps its first child's colour; a union/intersection keeps a colour only if every operand shared it — a union of red and blue has none, and a warning says so rather than inventing a blend. Solids outside any color() get no Part. A .csg carries no module names, so the names cannot come from the source. Alpha is ignored, and the ids describe the base shape, so no Parts are created while edit ops are replayed over it (the load says why).

Two correctness rules worth knowing: OpenSCAD cylinders/spheres are faceted prisms, not analytic surfaces — $fn/$fa/$fs resolve per node and useMaxFN (default 16, FreeCAD's dial) decides real N-gon prism vs analytic import, with a warning stating the chord error whenever it approximates. And .csg prints at ~6 significant figures, a hard accuracy ceiling on anything reconstructed from it.

OpenSCAD Source ​

A .scad file is evaluated to .csg text — first by the bundled OpenSCAD WASM (@loumalouomega/openscad-wasm, Manifold backend by default, in the kernel worker via src/scadWasmService.ts), falling back to a user-installed openscad binary when the WASM path declines the model — then takes the exact path above — downstream only ever sees .csg bytes. Five things this implies, each deliberate:

  1. No install is needed for ordinary models. The WASM module is byte-identical to the binary on every committed fixture (minimal, parametric, colour-booleans, two-colour, include-main + -D overrides), so a stock install opens .scad with nothing on PATH. The binary remains as a fallback for constructs the WASM path declines (import()/surface() of a local mesh, a missing include, over the 20-file / 1 MB staging caps, or an unavailable WASM binary) and for the convert_scad tool's explicit use. When the fallback runs, configure it via the cadPreview.openscadBinary setting, else the OPENSCAD_BINARY environment variable (the headless escape hatch), else openscad on PATH. A model that needs neither path (pure .csg) never touches either.
  2. Includes resolve without a temp copy on either path. The binary conversion runs with cwd = the source directory on the real path, so relative use/include/import resolve exactly as a manual openscad -o model.csg model.scad invocation would (output to a temp dir that is always removed). The WASM path stages the source plus every use/include it names into MEMFS (<angle-bracket> targets via OPENSCADPATH /libraries, "quoted" targets relative to the source dir) and renders there — no binary, no cwd, same relative layout.
  3. The engine, the libraries and the parameters are configurable, and default to nothing. cadPreview.openscadBackend (auto / cgal / manifold; headless: OPENSCAD_BACKEND) passes --backend=<engine> — auto means the WASM default (manifold) and passes no flag to the binary, so a stock OpenSCAD that predates the switch keeps working until you opt in; Manifold evaluates large boolean trees far faster but needs a recent (2025+) OpenSCAD, and a build that rejects the flag gets a failure message that names it. cadPreview.openscadLibraryPaths prepends folders (BOSL2, MCAD, …) to OPENSCADPATH for the conversion only, so use <BOSL2/std.scad> resolves without copying a library beside every model (an inherited OPENSCADPATH already works headless). Customizer parameters — the top-level name = literal; assignments with their // [min:max] and // [a, b:Label] annotations — are read from the source text by the list_scad_parameters MCP tool (no binary needed), and convert_scad re-evaluates the file with -D name=value overrides into a new .csg that opens like any other document. Interactively the sidebar's Customizer section (.scad sources only) renders the same parameters as number/checkbox/select/text fields grouped by [Group]; Apply re-evaluates with the values as -D overrides and reloads the geometry, persisting them in <model>.scad-params.json (replayed on every open, included in the preprocess archive) — the .scad itself is never modified. Overrides are validated against the file's own parameters in both surfaces (an unknown name or wrong type is reported and dropped, never guessed).
  4. An unchanged model is not re-evaluated, and an edited include is never served stale. Every .scad consumer — opening, each edit's re-tessellation, load_model, mass properties, export, meshing — converts through one place, which keeps a small in-memory cache of the last 8 conversions. The binary is run with -d, and the dependency list it writes (every use/included file and import()ed mesh, Make format) is stored with the result; the WASM path records the staged files it read instead. Either way an entry is reused only while the source and each listed file still hash the same, under the same selector (WASM, or the binary path, mtime and size), backend, -D set and library paths. A result is never cached when the backend wrote no dependency list, or warned about an include/use it could not open (creating that file later would not be seen). An open document also watches each dependency and reloads when one changes, exactly as it does for the source itself. The cache is process-local and never written to disk.
  5. Runaway evaluation is bounded on either path — the binary by a 2-minute kill (SCAD_CONVERT_TIMEOUT_MS), the WASM by the kernel worker's watchdog (a recursion is declined in ~60 ms rather than hanging to the binary timeout). A .scad file is effectively evaluated code and can hang (runaway recursion, gigantic import), so the bound is the sandbox backstop, not just UX. openscad's own stderr is parsed into structured diagnostics (severity = echo / warning / deprecated / error, plus file and line where openscad prints them) and capped by severity — errors, then warnings, then deprecations, then echo() lines — so a flood of echo() output can never push out a real WARNING:; whatever does not fit is counted, not silently cut (droppedCount). Because an echo() is written by whoever wrote the .scad, every message that reaches a warning string is wrapped in ⟦openscad: …⟧ untrusted-text markers. A non-zero exit surfaces its (enveloped) stderr tail as the error.

AMF ​

.amf (plain XML or the compressed ZIP container) is loaded by three.js's AMFLoader in the webview — it needs DOMParser, so it lives only in meshLoaders.ts (loadAmf) and nothing the kernel worker imports. It is import-only (three has no AMF writer, so it is never an export target) and display-only on the host: there is no triangle parser, so Compare Models, Mesh Health, Promote, Repair, drawings, headless meshing/baking and save_model refuse it by name. It is never saved in place and never dirty.

  • Units. Unlike 3MF and Collada, AMFLoader already scales the geometry to millimetres from the root <amf unit="…"> (millimeter, inch, feet, meter, micron; a missing unit is millimeters), so the viewer applies no scale of its own — a second one would double it. src/amfUnits.ts's amfDeclaredUnit only mirrors that reading to select the Units dropdown (mm, in, ft, m; a micron, an unknown unit or no attribute leaves it alone); presentation only.
  • Colour. A <material> colour and a mesh-level <color> are native colours (convertAmfMaterials); alpha is dropped. The loader's own 0xaaaaff fallback for a volume with no material is not a file colour and gets the palette grey. Per-vertex colour, textures and constellations are not supported.
  • Parse failures. A document whose root is not <amf> is refused with a load error; a corrupt zip surfaces as a load error as well.

FBX ​

.fbx (ASCII or binary, FBX version 7000 or later) is loaded by three.js's FBXLoader in the webview (meshLoaders.ts's loadFbx). It is import-only (no FBX writer) and display-only on the host, exactly like VRML: Compare Models, Mesh Health, Promote, Repair, drawings, headless meshing/baking and save_model refuse it by name; it is never saved in place and never dirty.

  • Units and up-axis. The loader never applies GlobalSettings.UnitScaleFactor (centimetres per unit, default 1); loadFbx applies fbxMmPerUnit once and selects the Units dropdown for a non-default factor (mm, m, in, ft; the default is indistinguishable from undeclared). A Z-up file keeps its vertices as authored: the loader's compensating rotation is dropped because the viewer owns the root rotation.
  • Colour and content. A material's diffuse colour is a native colour (convertColladaMaterials); the loader's __DEFAULT fallback is not a file colour. Textures, lights, cameras, animation and skinning are ignored.

Fixtures: examples/FBX/cube.fbx (10 mm, UnitScaleFactor 0.1), cube-cm.fbx (1 unit at the default factor — must measure 10 mm), cube-zup.fbx (10 × 10 × 30 mm, Z-up) and cube-colour.fbx (red Lambert), regenerated by scripts/fbx-fixtures/make-fbx.mjs.

VRML ​

.wrl (VRML97 text) is loaded by three.js's VRMLLoader in the webview (meshLoaders.ts's loadWrl; the loader's chevrotain parser adds ~157 KB to the webview bundle). It is import-only (no VRML writer) and display-only on the host, exactly like USD: Compare Models, Mesh Health, Promote, Repair, drawings, headless meshing/baking and save_model refuse it by name; it is never saved in place and never dirty.

  • Units and up-axis. VRML declares no unit, so the geometry is shown as authored (millimetres, like STL/OBJ) with no scale and no change to the Units dropdown. The loader's own scale/rotation are reset because the viewer owns the root rotation.
  • Colour. Material.diffuseColor is a native colour (convertColladaMaterials); the loader's unnamed plain-white fallback is not a file colour and gets the palette grey. Textures, lights, animation and Inline are ignored; DEF/USE instancing and Transform nodes are honoured.

Fixtures: examples/VRML/cube.wrl (10 mm), cube-colour.wrl (red diffuse) and cube-twice.wrl (DEF/USE, two cubes 20 mm apart), regenerated by scripts/vrml-fixtures/make-wrl.mjs.

USD ​

.usd / .usda / .usdc / .usdz are loaded by three.js's USDLoader in the webview (meshLoaders.ts's loadUsd; the deprecated USDZLoader is only a stub over it). It is import-only (no USD writer) and display-only on the host, exactly like AMF: Compare Models, Mesh Health, Promote, Repair, drawings, headless meshing/baking and save_model refuse it by name; it is never saved in place and never dirty. All four extensions route to one usd format; the encoding (ASCII, binary crate, zipped package) is detected from the bytes.

  • Units and up-axis. USDLoader scales its root group by the stage's metersPerUnit (metres, no validation) and rotates a upAxis = "Z" stage to Y-up. The viewer works in millimetres and owns the root rotation, so loadUsd reads the loader's scale (which is metersPerUnit, for every encoding), resets the loader's scale and rotation, and applies usdMmPerUnit once; a non-positive or non-finite value falls back to metres. usdDeclaredUnit selects the Units dropdown (mm, cm, in, ft; a value of exactly 1 is indistinguishable from "not declared" and leaves it alone).
  • Colour. primvars:displayColor and a bound material's colour are native colours (convertColladaMaterials); the loader's unnamed plain-white fallback is not a file colour and gets the palette grey. Textures, animation and skinning are ignored.

Fixtures: examples/USD/cube.usda (10 mm, metersPerUnit = 0.001), cube-cm.usda (1 cm), cube-zup.usda (10 × 10 × 30 mm, Z-up), cube-colour.usda (red displayColor) and cube.usdz (the 10 mm cube packaged), regenerated by scripts/usd-fixtures/make-usd.mjs.

Fixtures: examples/AMF/cube.amf (10 mm), cube-inch.amf (1 inch), cube-colour.amf (red material) and cube-zip.amf (the 10 mm cube zipped), regenerated by scripts/amf-fixtures/make-amf.mjs.


Mesh Formats (Three.js Pipeline) ​

Mesh files contain pre-triangulated geometry. The extension host resolves the file to a vscode-webview:// URI via webviewPanel.webview.asWebviewUri() and posts it to the webview, which loads it directly with a Three.js loader.

STL ​

Binary and ASCII STL are both supported via STLLoader. The result is a single THREE.Mesh. Vertex normals are embedded in the STL format; they are used as-is.

Limitation: STL has no material, color, or hierarchy. A default grey MeshStandardMaterial is applied.

OBJ ​

OBJLoader produces a THREE.Group of meshes corresponding to the o / g groups in the OBJ file.

Materials. When the OBJ names a .mtl (mtllib), the viewer loads it from beside the model — the same plain-relative-path rule glTF external buffers follow (src/safeRelativePath.ts: no scheme, no absolute path, no ..), so only the model's own folder is read. The diffuse colour (Kd) and diffuse texture (map_Kd) of each usemtl are shown; opacity (d/Tr), specular and bump/other maps are ignored, and the viewer shades with its own lights. A missing, unreadable or unsafe .mtl falls back to the default material and says so on the status line — it never fails the open. A Part colour replaces the file colour on its faces (it is not multiplied into it), and removing the Part brings the file colour back; Flat mode keeps the colours.

Limitations.

  • Materials are display-only and webview-only. The host-side parsers and the headless mesh-edit bake read geometry only (the object hierarchy, and with it every node-N id, is identical either way), and saving an OBJ in place re-emits geometry — no mtllib/usemtl lines.
  • A mesh with more than one resolved material is split into facets per material (no facet straddles two materials), so a multi-material OBJ's node-N/face-K ids differ from what the same file produced before its .mtl was shown. Single-material and .mtl-less OBJs keep their ids.
  • A boolean or hole on a textured mesh keeps its material but the texture coordinates interpolate across the operands.

PLY ​

PLYLoader loads the file as a single THREE.BufferGeometry. If the PLY file lacks vertex normals, computeVertexNormals() is called automatically to generate smooth normals.

Vertex colours. A PLY with per-vertex red/green/blue (or per-face colours, which PLYLoader expands onto its vertices) renders in those colours, unmodified, in Shaded and Flat modes, and each facet keeps its colours through the facet split. A Part colour replaces them; removing the Part restores them.

Limitation: a boolean or hole on a vertex-coloured mesh cannot keep the colours (the CSG engine carries only position, uv and normal), so its result falls back to the default material. Alpha is ignored. Saving a PLY in place writes whatever colours the exporter emits for the displayed model.

glTF / GLB ​

GLTFLoader supports the full glTF 2.0 spec, including embedded textures, materials, and scene hierarchies. CAD Preview uses gltf.scene (the root THREE.Group) from the parsed result.

Limitations:

  • Animation playback is not supported. Only the bind pose (frame 0) is rendered.
  • The component tree is built from the Object3D name hierarchy, not from glTF extras.

Host-side parsing. Display goes through Three.js's GLTFLoader as described above, but a second, independent path exists for the features that need triangle geometry outside the webview: src/gltfParser.ts is a pure, hand-rolled glTF 2.0 / GLB parser (the fourth member of the stlParser.ts / objParser.ts / plyParser.ts family) that reads every mesh primitive's POSITION attribute and indices, transforms them by each node's world matrix, and welds the result into one shared index space. It is geometry-only — materials, textures, cameras, skins, animations, and morph targets are ignored entirely — and its unit tests cross-check every fixture against three.js's own GLTFLoader (src/gltfParser.crossvalidation.test.ts), which is what makes hand-rolling it defensible: a subtly-wrong parser producing plausible-but-wrong centroids and volumes would be worse than not supporting the format at all. This is what makes Compare Models, check_mesh_health, promote_mesh_to_brep, and silhouette SVG export work for .gltf/.glb sources.

Two host-side-parsing caveats, both surfaced as clear errors rather than silently-wrong geometry:

  • Draco and meshopt compression are decoded. A KHR_draco_mesh_compression primitive or an EXT_meshopt_compression bufferView is expanded by the kernel worker's own WebAssembly decoders (only for a file that actually uses them) into the position/index data the parser reads, so Compare Models, Mesh Health, Promote, drawings, headless meshing and the mesh-edit bake all accept the file; the 3D view decodes it in the webview. Only the geometry is decoded — POSITION and the triangle list — never colours, normals or UVs, exactly as for an uncompressed file. A file that requires one of these extensions and arrives without its decoded geometry (a caller that skipped the decode step) fails with an error naming the extension rather than parsing as empty. (Geometry-irrelevant extensions — KHR_materials_*, KHR_texture_transform, KHR_lights_punctual, … — are handled perfectly by ignoring them, and KHR_mesh_quantization is genuinely supported.)
  • External .bin buffers are read from beside the model. A .gltf whose buffers[].uri points at a sibling file works normally; a sibling that can't be read is a clear error, not a silently-empty mesh. A self-contained .glb, or a .gltf with base64 data URIs, needs no sibling at all.

Fixtures: examples/GLTF/cube.gltf, examples/GLTF/cube.glb, and examples/GLTF/two-boxes.gltf.

3MF ​

.3mf (the 3D-printing exchange format) is loaded by three.js's 3MFLoader in the webview — it needs DOMParser, so it lives only in meshLoaders.ts and nothing the kernel worker imports. The file's basematerials colours (and a colorgroup's vertex colours) are kept as native colours, the way an OBJ .mtl or a PLY's vertex colours are; everything else about its materials is replaced by the default material.

  • Units. 3MFLoader reads the model's unit attribute but never applies it, so src/threeMf.ts's threeMfMmPerUnit reads the declared unit (micron/millimeter/centimeter/inch/foot/meter; millimeter when absent) and the loader scales the whole model to the viewer's millimetres. A 1-inch cube declared unit="inch" measures 25.4 mm. The Units dropdown is also set to the declared unit (threeMfDeclaredUnit; in, ft, m, cm or mm — a micron or unknown unit leaves it on mm); this is presentation only, the geometry is already in millimetres.
  • Colour. 3MFLoader builds one mesh per distinct colour, and each file colour is shown as its displaycolor (alpha, specular and other material properties are dropped — the viewer composes opacity itself). A Part colour replaces the file colour, and removing the Part brings it back. Triangles with no colour take the normal palette colour. Colour is webview-only, like OBJ and PLY colour: the host has no 3MF parser.
  • Export. 3MF is an export target from every source (B-rep and mesh). build3mf hand-writes the package on fflate ([Content_Types].xml, _rels/.rels, 3D/3dmodel.model): one <object type="model"> per solid (meshes sharing a userData.groupId are one object), exactly-coincident vertices shared, triangle count never changed, output byte-stable. Colour: each mesh's resolved surface colour (a Part colour, else the file's own colour) is written per triangle through one <basematerials> resource (a uniformly coloured object carries pid/pindex once, a mixed one pid/p1 per triangle). The theme's default face colour is not a colour the model has, so an uncoloured model exports exactly as a geometry-only one; a Part deliberately coloured exactly the theme default is written colourless. A non-mm export unit scales the coordinates and writes the matching unit="…" label, so the file keeps its real-world size (the STEP/IGES rule), never a mislabelled-mm file.
  • Display-only on the host. There is no host-side triangle parser, so Compare Models, Mesh Health, Promote, Repair, drawings, headless meshing/baking and save_model refuse it by name. It is never saved in place and is never dirty. Materials beyond the display colour (textures, metallic properties), build-item transforms authored as component references, and the remaining three.js-loader formats (VRML, FBX, USD) are future work.

Fixtures: examples/3MF/cube.3mf (10 mm), examples/3MF/cube-inch.3mf (1 inch) and examples/3MF/cube-colour.3mf (a 10 mm cube, six triangles red and six blue), regenerated by scripts/3mf-fixtures/make-3mf.mjs.

Collada ​

.dae is loaded by three.js's ColladaLoader in the webview — it needs DOMParser, so it lives only in meshLoaders.ts (loadDae) and nothing the kernel worker imports. It is import-only (three has no Collada writer, so it is never an export target) and display-only on the host: there is no triangle parser, so Compare Models, Mesh Health, Promote, Repair, drawings, headless meshing/baking and save_model refuse it by name. It is never saved in place and never dirty.

  • Units. The loader multiplies the scene by the raw <unit meter="…"> (metres per unit; NaN if malformed), but the viewer works in millimetres. loadDae resets that scale and applies src/colladaUnits.ts's colladaMmPerUnit instead: a millimetre file, the same cube authored in metres and a 1-inch cube all measure their real size, and a missing or unusable <unit> is the spec default (1 m, never NaN). The declared unit (mm/cm/m/in/ft) also selects the Units dropdown; presentation only.
  • Up axis. For Z_UP the loader rotates the scene to Y-up, but the viewer owns the root rotation (the Up axis setting). The loader's rotation is dropped, so a Z_UP and a Y_UP file are both shown as authored, like STL/OBJ/glTF.
  • Colour. A material's flat color (Phong/Lambert/Basic) is a native colour; a Part colour replaces it and removing the Part brings it back. The loader's magenta fallback for an unresolved binding and its invented plain white for a primitive with no material are not file colours and get the palette grey. Textures are ignored (no sibling image is fetched); animations, skinning and cameras are not used.

Fixtures: examples/Collada/cube.dae (10 mm, Z_UP, no material), cube-metre.dae (the same cube authored in metres, Y_UP), cube-inch.dae (1 inch) and cube-colour.dae (red Phong), regenerated by scripts/dae-fixtures/make-dae.mjs.


meshio++ Bridge Formats (VTK, MED, CGNS, Exodus, XDMF, Kratos MDPA, OpenFOAM, and more) ​

These formats — VTK/VTU, MED, CGNS, Exodus, XDMF, Kratos MDPA, OpenFOAM, Gmsh Mesh, Abaqus, I-DEAS Universal, SU2, INRIA Medit, and GiD Postprocess — have no native Three.js loader, so the extension host converts them to a triangulated boundary surface in STL form first — meshio++ (@meshioplusplus/wasm, a third host-side WASM module alongside OCCT and Gmsh) reads the source file and calls convertSurface (entirely inside its C++ core — a volume mesh becomes its boundary, everything else passes through), producing ASCII STL bytes. Those bytes are sent to the webview (loadMeshBytes protocol message, base64-over-postMessage) and fed through the exact same STL loader a native .stl open uses — see src/meshioService.ts's convertToStlBoundary() and doc/gmsh-integration.md's "The meshio++ bridge" section (which also covers the reverse direction: exporting a generated FE mesh to MED/CGNS/XDMF/VTU/and more, independent of this import path). Headlessly, src/meshioBoundarySide.ts welds that same extracted STL boundary back into the ordinary WeldedMesh the STL/OBJ/PLY/glTF facts tools use, so get_mass_properties, measure and compare_models can read a meshio-only source without a webview. Those results are marked surfaceDerived, carry the correlated region count and a boundary-closure flag, and withhold volume when the boundary is open; they describe the boundary surface, not the source's volume cells. Nastran (.bdf) is routed as a bulk-data deck: the header normalization adds the BEGIN BULK line the format requires when a writer omits it — which Gmsh's own writer does — and a deck written by this extension's Gmsh export then reads, converts to a boundary surface and meshes (60 facets spanning the source model's exact 3×4×5 extents; 376 nodes / 1255 elements). Hand-authored decks in all three Nastran field formats — fixed (8-column), free (comma-separated) and large-field GRID* — with 10-node CTETRA cards, + continuation lines, a full executive/case-control section and ignored solver cards also read to the correct geometry (a 2×3×4 box: exact extents and a summed tetrahedron volume of 24, not merely a non-empty mesh). What is still not verified is a deck emitted by a real Nastran-family tool: the committed hand-authored decks follow the card layout in the Nastran reference, but were not written by Nastran, so other cards (CQUAD4, CHEXA, shells and beams) and a real tool's idiosyncrasies are untested. Geometry only — solver cards are ignored and INCLUDE files are not followed. The reader's card coverage is tracked in the roadmap.

OpenFOAM is the one exception to the single-file shape: a .foam file is an (usually empty) marker — the ParaView convention — whose real mesh lives in sibling files under <marker's parent>/constant/polyMesh/. src/meshioService.ts's convertFoamCaseToStlBoundary(markerPath) takes the marker's filesystem path, stages the whole case into meshio++'s virtual filesystem itself, and hand-builds the STL by fan-triangulating every boundary face (meshio++'s own STL writer emits zero facets for the quad-only boundaries typical of hex-dominant CFD meshes). OpenFOAM import is geometry-only: patch names ride a C++ side-channel its JS binding doesn't expose, and field files in the case's time directories are not read at all — no Parts are auto-created and the colour-by-field selector stays empty.

Gmsh Mesh / Abaqus / I-DEAS Universal / SU2 / INRIA Medit close a real export/import asymmetry: the FE Mesh panel already wrote .msh/.inp/.unv/.su2/.mesh via Gmsh's own writer, but until now had no way to re-open any of them — this codebase's own exported files were not importable. Each was verified round-trippable end-to-end against the live WASM (export a real tetrahedralized model, then readMesh() the result back) before being added. .off, which Gmsh also writes, was tried and rejected after the same check ("Expected the first line to be 'OFF'" — Gmsh's output isn't shaped the way meshio++'s OFF reader expects), so it is not an import format. Nastran (.bdf) is verified for this extension's own Gmsh-written decks and for hand-authored fixed-, free- and large-field decks — the export/import asymmetry above is closed for it, and a generated deck re-opens, re-meshes and round-trips. It is not yet a verified import format for decks emitted by a real Nastran tool, because none is available to this project; that gap is tracked in the roadmap. .bdf is also the X11 bitmap-font extension, so opening one shows a one-line caveat, like .msh/.inp.

GiD Postprocess (.post.msh) is a sibling pair, and a compound extension. Its ascii flavour splits a model across <stem>.post.msh (geometry) and <stem>.post.res (results), the second discovered purely by swapping that final segment — nothing inside the .post.msh names it. CAD Preview routes only the .post.msh; the .post.res is not independently openable (it is not a mesh) but is staged alongside the primary by the same src/meshioCompanions.ts machinery that stages XDMF's .h5. Because .post.msh ends in .msh, which is registered to a different format (Gmsh), routeFile matches the longest registered suffix first — a last-dot-only lookup would silently resolve every GiD file to a Gmsh parse that then fails, which is precisely the bug meshio++ itself had to fix in its own resolve_format in 10.18.0. Only the ascii flavour is claimed: meshio++ also reads GiD's binary (.post.bin) and hdf5 (.post.h5) flavours, but this codebase has no fixture verifying either. GiD is also an export target — see doc/gmsh-integration.md's "The meshio++ bridge".

.msh and .inp are ambiguous extensions, each used by more than one format meshio++ can read (.msh: Gmsh, ANSYS, FreeFem; .inp: Abaqus, ANSYS APDL). CAD Preview always assumes the format it itself writes (Gmsh for .msh, Abaqus for .inp) and shows a one-line status caveat on open — an ANSYS-authored .msh or .inp file will not parse correctly. There is deliberately no content-sniffing disambiguation into the alternate formats: this codebase has no real ANSYS/FreeFem-authored fixture to verify such a read against, so claiming that support would be an unverified promise (see src/fileRouter.ts's AMBIGUOUS_MESHIO_EXTENSIONS).

XDMF's .h5 sibling is now staged automatically. Earlier versions of this bridge wrote only the primary file's bytes into meshio++'s MEMFS, so an XDMF using the (default) "HDF" heavy-data format could never be read back at all (HDF5: could not open file ...h5) — every attempt failed before meshio++ ever got as far as interpreting the mesh. Opening an .xdmf now scans its own <DataItem Format="HDF"> references (src/meshioCompanions.ts's extractXdmfHdfReferences, a plain regex scan — no XML parser in this vitest config) and stages the referenced .h5 file(s) alongside it under their exact referenced basename, if present beside the source. An XDMF using the "XML"/"Binary" data formats needs no companion and is unaffected either way.

Mixed-topology XDMF round-trips with meshio++ 16.16.0. The earlier XDMF: unknown mixed topology index reader defect is fixed upstream. Exports from this extension, including mixed point/line/triangle/tetra blocks, can be reopened and re-meshed when their referenced .h5 companion is present. This is covered by the compatibility corpus and MCP smoke test.

libMesh (.xda/.xdr) is both an import and an export format (meshio++ 17.2.0). libMesh is the equation-system output of the libMesh FEM library: .xda is its ASCII checkpoint, .xdr the XDR-binary one, and meshio++ also reads either compressed with gzip (.xda.gz/.xdr.gz — zlib, available in every artifact) or bzip2 (.xda.bz2/.xdr.bz2 — bzip2 is linked only into the FULL artifact, like every bzip2 path in this codebase). CAD Preview routes the four flavours it has fixtures for — .xda, .xdr, .xda.gz, .xda.bz2 — and deliberately not .xdr.gz/.xdr.bz2 (no fixture covers the binary+compressed combination; unclaimed-but-readable is this table's norm, same as the GiD flavours). Fixtures are meshio++'s own writer output (examples/libMesh/two-tets.*, two tets sharing a face → 8 boundary triangles; regenerate with scripts/gen-libmesh-fixtures.mjs, which also reads every flavour back by extension sniffing so the READ side is pinned at generation time), asserted by npm run mcp:smoke and compat rows load-libmesh-* (the .xda row also re-meshes the loaded geometry). libMesh is also an FE-mesh export target — plain ASCII .xda only, the flavours a solver consumes (see doc/gmsh-integration.md's "The meshio++ bridge"); writing the compressed pairings is possible in meshio++ but no export row claims them.

VTK XML block codecs: zlib, lz4 and (since the full artifact) zstd. The VTK XML family (.vtu, .vtp, and the structured siblings) stores binary data-array payloads in fixed 32 KiB blocks, each independently compressed. meshio++ supports three codecs for those blocks: zlib is always available (it is also this extension's own write default — nothing we write changes with 17.2.0); lz4 (vtkLZ4DataCompressor) is a real VTK/ParaView codec; zstd (vtkZSTDDataCompressor) is a meshio++ extension VTK does not ship, so a zstd file is readable by meshio++ (and this extension) but ParaView refuses it with a clean "unknown compressor". With @meshioplusplus/wasm 17.2.0's full artifact (the only one staged — see README's Licensing) linking zstd 1.5.7 and lz4 1.10.0, files compressed with either codec now open through the normal bridge; before, the lean artifact's zlib-only build failed the read with a message naming the codec's build flag. Fixtures: examples/VTKXML/cube-zstd.vtu/cube-lz4.vtu (meshio++'s own writer output; regenerate with scripts/gen-codec-fixtures.mjs), asserted by npm run mcp:smoke and the load-vtu-zstd/load-vtu-lz4 corpus rows. There is deliberately no codec option on this extension's own VTU exports — meshio++'s zlib default is the universally-readable choice, and writing zstd would produce files ParaView cannot open.

Kratos CAD JSON (.cad.json) holds an exact NURBS B-rep (meshio++ 17.2.0). The format Kratos's CadJsonInput reads: trimmed faces with loops, edges, vertices and the surface patches themselves. Import shows the file's own stored sampling, linearized in place — this is the one bridge format whose cells ARE the surface (sampled quads plus 1-D edge lines), so the usual convertSurface→extractSurface chain would end at the surface's free EDGES (probed: 1536 quads → 384 lines, an empty STL); convertToStlBoundary instead linearizes the cells directly (convertCells simplexify, dropping the edge lines). The exact model behind those cells is what the iga_sample / iga_refine / iga_evaluate MCP tools work on (see doc/mcp-server.md): iga_sample reports the B-rep inventory and re-samples at any density; iga_refine refines exactly — degree elevation + knot insertion, geometry unchanged — writing a new .cad.json; iga_evaluate evaluates one face at (u, v) with derivatives, normals and the inside-of-trimming-loops flag. A STEP/IGES read carries the same side channel in the full artifact, so the tools also accept B-rep sources (iga_refine always writes CAD JSON — the representation the refined model serializes to). Fixture: examples/CadJson/cube.cad.json — meshio++'s own fitCad solid of the STL cube serialized back (6 trimmed plane faces; regenerate with scripts/gen-cadjson-fixtures.mjs, which also prints the sampled/refined/evaluated facts the smoke and corpus rows pin). MFEM NURBS meshes have the same upstream operations but are not routed here.

The meshio++ 17.3.0 binary writers — six new two-way formats (meshio++ PR #163). meshio++ 17.3.0 ("write support for multiple binary formats") made six formats both read- and write-capable that this extension now routes as imports and FE-mesh export targets, each verified live before claiming (the writer ran on this codebase's own generateMesh() MSH 4.1 — vertex/line blocks included — and the output re-opened and re-meshed through the bridge; fixtures examples/{Tecplot,FEBio,Medit,Marc,AbaqusFIL,AnsysRST,LsDyna}/two-tets.* regenerate with scripts/gen-1730-fixtures.mjs):

FormatExtensionsNotes
Tecplot binary.pltThe #!TDV112 binary writer (upstream the .dat/.tec ASCII flavours too — only .plt is routed, content-sniffable as binary). Gmsh's own tecplot writer was previously excluded for rejecting the 0-D vertex cells Mesh.SaveAll adds; meshio++'s writer accepts them, so the meshio route reclaims the format.
FEBio plot.xpltFEBio's plot file (read since 16.x; written by both engines since 17.3.0).
INRIA Medit binary.meshbThe libMeshb binary variant under the same medit format name as the routed ASCII .mesh — one reader, binary by content. Only the extension is new here.
MSC Marc post file.t19The formatted post file (deck .dat remains unclaimed — upstream shares it with Tecplot).
Abaqus results.filThe binary/ASCII results file (model + one increment per step).
Ansys MAPDL results.rstBinary Fortran-record results; a one-line ReStructuredText caveat on open (.rst is a shared extension, treated like .msh/.inp/.bdf). .rth is not routed (no fixture).
LS-DYNA d3plot.d3plotThe result-database family (d3plot/d3plot01…); a single-file export only (the family split is a size cap this extension never exceeds).
Nastran OP2 results.op2meshio++ 17.4.0's result writer (PR #164, CVISC dampers #165), adopted with the 17.7.0 bump — the build's own OP2 reader reads the writer's output back, so an export re-opens (the earlier "no consumer here" triage predates that reader).

All routes share the usual bridge behaviour (STL boundary, auto-Parts from named cell regions, export, meshing) and every row round-trips in compat as export-… with reload+remesh. What the writers do with the bridge's own FE-mesh input is a stated layout fact per family: the .plt/.xplt/.meshb writers keep the per-region block layout but drop the 0-D vertex-only blocks, while the results-file writers (.t19/.fil/.rst/.d3plot/.op2) keep every cell class but merge all blocks of one cell type into one block (OP2's read also attaches nastran:card/eid/pid/c1/c2 cell-data columns).

Deliberately not adopted from 17.3.0, each with the reason it was rejected live: **Tecplot SZL (.szplt) needs TecIO and **DOLFINx VTX (.bp) needs ADIOS2 — this published build links neither, and convert refuses both by name (...this build has no TecIO/ADIOS2 support...; the fixture generator pins those refusals so a build change re-surfaces a fixture task); neuroglancer round-trips vertex points only, so an exported FE mesh would lose all connectivity; nastran_h5 is writable but its .h5 extension is a ubiquitous HDF5 name and would collide with the XDMF companion convention this table cannot content-sniff; and lzma-compressed .vtu files are refused by the published build's pipeline whitelist ('none', 'zlib', 'lz4', 'zstd' or 'lzf'), so no meshio++-authored fixture can exist for an unverified read — unclaimed-by-the-norm rather than adopted-on-promise. From 17.4.0's PR, only the lsdyna_binout writer stays unclaimed (no consumer workflow here); its Nastran OP2 writer is adopted above, and 17.6.0's marc_t19/xplt series additions are result-sequence features this single-increment exporter has no use for.

Processing Steps ​

  1. Companions — for a multi-file format (currently: XDMF's .h5), the host resolves and reads whatever sibling the source references, from beside the source file (provider.ts's resolveMeshioCompanionsFor / mcpTools.ts's resolveMeshioCompanions — the disk-I/O half; meshioCompanions.ts's meshioCompanionCandidates is the pure, shared "which basenames to look for" half). A missing companion is silently skipped, not an error — a self-contained source still opens.
  2. Read + convert — convertToStlBoundaryWithRegions(sourceBytes, format, sourceName, companions) stages the primary (and any companions) into meshio++'s own virtual filesystem under their real basenames, readMesh()s the full mesh, and — when the file declares ≥1 named kind: "cell" region and the boundary comes back pure triangles — correlates each boundary triangle to its region via extractSurface(mesh, recordParentIds=true)'s cell_data["surface:parent_cell"] provenance array. Falls back to the plain convertToStlBoundary() (convertSurface(inPath, outPath, {inFormat: format, outFormat: "stl"})) with no region data for anything else (quad/hex boundaries, no regions, correlation finding nothing).
  3. Transport — the resulting STL bytes are base64-encoded and posted as {type: "loadMeshBytes", sourceFormat, dataBase64, regionAssignment?}.
  4. Load — the webview base64-decodes them, wraps them in a blob: object URL, and calls the same loadMeshFromUrl(url, "stl") a native .stl open uses — from this point on, a meshio-imported document is indistinguishable from a native STL to every other feature (facet splitting, Parts, Edits, Export, Mass Properties, Measurement all work identically). When regionAssignment is present, the facet split is region-aware (see below) so a Part auto-created from a region references real, resolvable facet ids.

What's Preserved, What's Not ​

Only geometry (points + triangle connectivity) becomes actual geometry through this funnel. Named kind: "cell" regions now DO become real Parts, for the tetrahedral/triangular-boundary case: on first import (only when the parts sidecar is still empty — an existing Part is never overwritten), CAD-Preview auto-creates one selectable, colourable Part per region (src/meshioRegionParts.ts's buildPartsFromMeshioRegions), persisted to the usual <model>.parts.json sidecar exactly like a manually-created Part. This works identically whether the file is opened in VS Code or loaded headlessly via the MCP server's load_model (one shared implementation, not two that could drift).

Named point/cell scalar data arrays (temperatures, stresses, …) can now be visualized too — interactively, via the view-controls "Colour by field" selector (webview-only, no MCP tool). Picking a field fetches its values on demand (src/meshioService.ts's readMeshioFieldValues, same triangle-boundary correlation mechanism as region→Parts) and paints the model as a viridis colour ramp with a legend; picking "None" reverts. This is display-only — no new data is stored, exported, or made selectable/queryable, and a field's actual per-point/per-cell values are never available headlessly via the MCP server. Quad/hex volume boundaries, kind: "point"/"side" regions, and whole-mesh field_data (not spatially varying — nothing to colour by) are still not handled by any mechanism — their NAMES are surfaced informationally only (a one-line status message on open, and a load_model warning via the MCP server). See CLAUDE.md's "meshio++ integration" section for the full mechanism and its exact scope. If you need to actually inspect scalar field values numerically, use a dedicated FE post-processing tool (e.g. ParaView) for these formats; CAD-Preview's support here is for quick geometry/region/field previews alongside your CAD files, not full FE post-processing.

Kratos MDPA note ​

Unlike the MDPA export path (src/mdpaWriter.ts, hand-written — see doc/gmsh-integration.md's "Kratos MDPA" section), importing an .mdpa file as a document goes through meshio++'s own native MDPA reader (mesh-level blocks only — Nodes/Elements/Conditions/SubModelPart; Kratos Properties/Table/Geometries blocks are not represented in the WASM binding and throw if present, same as everywhere else meshio++ reads MDPA). Multi-point Constraints blocks are handled by CAD-Preview itself: meshio++ would throw on them, so they are stripped from the bytes it reads (src/mdpaConstraints.ts) and parsed back from the original text — the slave→master links of every LinearMasterSlaveConstraint are drawn from the FE Mesh panel's links button, the status line counts them (15 constraints on 5 slave nodes (DISPLACEMENT_X, …)), and the facets of a coarse/fine hanging-node interface are taken out of the displayed surface. Rows that do not fit the id constant [weights] slave masters… layout and other constraint kinds are counted and named, never guessed; the constraints are not applied to anything. Regression fixture: examples/MDPA/hanging-two-level.mdpa. The MDPA export writes the same blocks for a hanging-node octree (doc/gmsh-integration.md#octree-hanging). These are two entirely independent code paths that happen to share a file extension — one reads MDPA (via meshio++, for the Components view), the other writes MDPA (hand-rolled, for FE mesh export) — neither replaces the other. Non-sequential ("gapped") node ids — routine in real Kratos decks, since SubModelPart extraction, entity removal and deck merging all leave them — are supported (@meshioplusplus/wasm ≥ 9.13; regression-fixture: examples/MDPA/gapped-ids.mdpa, whose original ids surface as mdpa:id point/cell data names).


Parts Sidecar (<model>.parts.json) ​

User-defined parts (named groups of volumes / surfaces / lines) are stored in a JSON sidecar written next to the CAD file — e.g. bull.stp → bull.stp.parts.json. Part assignments never modify the CAD file itself — a Part edit writes only this sidecar. The sidecar is read on open (readParts()) and autosaved, debounced, on every edit (writeParts()), both in src/partsStore.ts. Parsing and serialization live in the vscode-free src/partsSidecar.ts so they are unit-tested.

json
{
  "version": 1,
  "source": "bull.stp",
  "parts": [
    {
      "name": "Inlet",
      "color": "#e6194b",
      "volumes": ["solid-0"],
      "surfaces": ["face-3", "face-7"],
      "lines": ["edge-12"]
    }
  ]
}

Entity ids are the stable topological ids assigned during extraction (solid-*, face-*, edge-*). For mesh formats (which have no stored B-rep topology), the whole object is a volume with a stable traversal-order id (node-*), and each connected near-coplanar facet detected on load is a surface (node-*/face-*); meshes have no assignable lines. Ids stay valid as long as the source file is unchanged. Parsing is tolerant: a missing or hand-corrupted sidecar yields an empty part list rather than blocking the model from opening.

A part may be rule-defined: a stored selector of {"version": 1, "source": {"kind": "scene", "filter": [...]}} is re-evaluated against the current model after every edit (instead of its ids being rebound), and its surfaces list is overwritten with the result. Besides the older leaves, a filter may use {"kind": "plane", "point": [-50, 0, 0], "normal": [1, 0, 0]} (planar faces on that plane, either orientation), {"kind": "box", "mode": "inside" | "intersects", "min": [-51, null, null], "max": [-49, null, null]} (face bounding box inside, or overlapping, the box; null = unbounded on that side) and {"kind": "surfaceType", "type": "cylinder", "radius": 5}. Tolerances default to 1e-3 × the model's bounding-box diagonal (the same figure the rebinder uses) and can be set with a leaf's own tol. A rule that selects nothing keeps the cached ids with a warning; a rule whose result changes reports "Part X: rule re-evaluated — N faces added, M lost". Faces only.

A part may carry an optional density (g/cm³, a positive finite number — anything else is dropped on read; the Parts panel's Material picker and set_part's material both store only this number, never the material name); generate_bom and get_mass_properties turn it into a mass in grams (volume mm³ / 1000 × density), and a part without one reports its mass as missing, never zero. A part may additionally carry a re-executable selector (a SelectorQuery, same shape resolve_selector takes) beside its raw surfaces cache, plus a selectorOpKind tag naming the producing op's kind at synthesis time. The host re-resolves the query against the current op list on open and after every op-list change, overwriting surfaces only on an oracle-clean result — otherwise the cache freezes with a warning (a stored op index that now addresses a different op kind freezes rather than repointing). A malformed selector is dropped while the part survives on its raw ids. set_part accepts the query and derives the kind tag server-side from the current op list, so write-time mismatch is impossible by construction.

Layers Sidecar (<model>.layers.json) ​

Named layers for presentation and drawing output (roadmap "Layers, distinct from Parts") — visibility, lock, a colour, and the drawing layer each entity lands on — are stored in a seventh JSON sidecar next to the CAD file — e.g. bull.stp → bull.stp.layers.json. Like the other six, this never modifies the CAD file. It is read on open (readLayers()) and autosaved, debounced (~500 ms, its own timer), on every create/rename/delete/visibility/lock/assign (writeLayers()), both in src/layersStore.ts; parse/serialize live in the vscode-free src/layersSidecar.ts so they are unit-tested. The headless MCP server has a byte-compatible counterpart in src/mcpSidecars.ts, written by the list_layers/set_layer/assign_layer tools and reported by get_state.

json
{
  "version": 1,
  "source": "bull.stp",
  "nextId": 2,
  "layers": [
    {
      "id": "layer-0",
      "name": "Default",
      "color": "#b8b8b8",
      "visible": true,
      "locked": false,
      "volumes": [],
      "surfaces": ["face-3"],
      "lines": [],
      "points": []
    }
  ]
}

Layers answer which entities are shown, locked or drawn together — never which form a finite-element sub-model. That is Parts' question, and the two stay apart everywhere it matters: a hidden layer is still meshed (meshing reads only Parts), layer membership reaches no solver output (no physical groups, no SubModelParts), and creating, renaming or deleting a layer leaves <model>.parts.json, every meshing output and every mass figure byte-identical. Each entity belongs to at most one layer; unassigned entities sit on the default layer, which cannot be deleted (deleting a layer returns its members to Default). Membership is an EntityIdBag, so it rebinds across topology-changing edits through the same remapPartEntityIds pass Parts use, with the same rebound/dropped reporting. A locked entity stays selectable for measurement and inspection but refuses as an edit operand and as a Transform Gizmo target, in the host and the webview alike, with a named diagnostic.

id is layer-N and is never reused — allocation runs off a persisted nextId counter, not the current max, so deleting a layer and adding another cannot resurrect the old id under a new meaning (which would silently retarget a drawing filter naming it). Parsing is tolerant like the other sidecars: a malformed entry drops that one layer, not the file. Included in the Preprocess Archive (below).

The drawing exports (export_svg_silhouette, export_technical_drawing, export_drawing_sheet) take an optional layers filter naming layers to restrict the drawing to — one output group per layer (SVG <g>, DXF LAYER table with each layer's name and colour). Absent = every layer. B-rep sources only; solid exports (STEP/IGES/BREP/STL/…) are never filtered.

Edits Sidecar (<model>.edits.json) ​

User-applied edit operations (transforms and booleans, and — in later milestones — feature modeling, assembly) are stored in a second JSON sidecar next to the CAD file — e.g. bull.stp → bull.stp.edits.json. Applying an edit itself never writes the CAD file — only this sidecar changes on every op. The sidecar holds an ordered, replayable op-list that is re-applied on every open, so the displayed model is base shape ∘ ops. It is read on open (readEdits()) and autosaved, debounced, on every change (writeEdits()), both in src/editsStore.ts; parse/serialize live in the vscode-free src/editsSidecar.ts so they are unit-tested.

The source file is written only by an explicit, confirmed action — Ctrl+S on a dirty tab (for STEP/IGES/BREP/STL/OBJ/PLY), an Export pick of the source's own format ("save in place"), or the headless save_model MCP tool — never as a side effect of applying or autosaving an edit. That save bakes the unbaked op tail into the file and records how far via a bakedThrough watermark: the file on disk already contains ops[0..bakedThrough], so replay starts after it and every kernel replay consumes only the tail. The field is omitted when 0, so a never-saved-in-place document's sidecar is byte-identical to before this feature existed; parsing clamps an out-of-range value instead of throwing. Undo cannot cross the save point, and remove_edit_op refuses an index inside the baked prefix headless.

Each op may carry three additive display/replay fields: suppressed: true (omitted when false — replay skips the op), label (a short row name) and folder (a history-folder name: consecutive ops with the same name show as one collapsible group in the Edits list; display only, replay never reads it, ≤80 characters, blank/non-string dropped). None changes the op's parameters, and a file without them parses unchanged and re-serialises byte-identically.

Recovering an interrupted save (<model>.save-journal.json) ​

A save-in-place (STEP/IGES/BREP or STL/OBJ/PLY) writes the geometry and then advances the bakedThrough watermark. A process that stops between those two writes leaves the file on disk baked while the sidecar still says otherwise — and reopening then replays the same edit over geometry that already contains it.

<model>.save-journal.json is a transaction marker written before the source write and deleted after the watermark write, so a crash at any point is repairable on the next open. It carries the intended bakedThrough, a saveId, and two SHA-256 hashes — preSaveSha256 and bakedSha256 — both computable at every boundary from bytes the save already holds. On open, the two hashes say which write landed, and one of three things happens:

  • The source holds the baked bytes → the watermark is advanced, completing the save. The file on disk is what you saved, and the pending edits are not applied again.
  • The source still holds its pre-save bytes → the write never landed, the existing watermark was already correct, and only the stale marker is removed.
  • The source matches neither (a torn write, or something else editing the file during the crash window) → nothing is guessed. A message reports it and the marker survives. Interactively you are offered Restore the pre-save backup — offered only when <model>.bak genuinely is this save's pre-save state, since the .bak is one-deep per session and on a second save it holds the pre-first-save bytes — or Keep the current file; either answer is recorded in the marker so the question is asked once. Headless (load_model) has no prompt, so it reports and changes nothing.

A leftover <base>.save-tmp.<ext> from a death between writing it and renaming it is swept, and a marker left after a save that had already completed is cleaned up silently. A recovered B-rep save warns that Part/annotation/layer highlights may still point at pre-save entity ids: re-emitting a B-rep renumbers entities, and the save-time rebind runs after the watermark, so the journal can complete the history without repeating that rebind.

This is deliberately not a seventh sidecar. It is per-save transaction state, not per-document state: it is not part of a document's history, list_workspace_models does not report it as a companion, and save_preprocess does not archive it. Its presence is reported by the recovery that acts on it, which is the only thing that needs to know. It is written only for the six formats with a same-format writer — STEP/IGES/BREP and STL/OBJ/PLY — so glTF, meshio and CAD-text documents are never a subject. See src/saveJournal.ts (the pure rules) and src/saveRecovery.ts (the shared I/O choreography).

json
{
  "version": 1,
  "source": "bull.stp",
  "variables": [
    { "name": "L", "expr": "20", "value": 20 },
    { "name": "W", "expr": "L/2", "value": 10 }
  ],
  "ops": [
    { "op": "translate", "targets": ["solid-0"], "vec": [10, 0, 0] },
    { "op": "rotate", "targets": ["solid-0"], "axisPoint": [0, 0, 0], "axisDir": [0, 0, 1], "angleDeg": 45 },
    { "op": "addBox", "center": [0, 0, 0], "size": [20, 10, 5], "exprs": { "size[0]": "L", "size[1]": "W" } }
  ]
}

Parametric variables ​

The optional top-level variables array holds the document's named parametric variables ({name, expr, value} plus optional comment and favourite), and any op may carry an optional exprs annotation mapping a numeric field path (length, size[1], points[2][0]) to an expression string over them. Editing a variable in the panel re-resolves every annotated op and rebuilds the geometry live. Rules:

  • Numeric fields are last-good caches. The addressed field always holds the most recent successful evaluation, so a consumer that ignores exprs (or an older extension version) still sees a fully-resolved op. Parsing re-resolves ops against the variables (parseEditsJson), so hand-editing a variable's expr in the sidecar takes effect on the next open.
  • Expressions support numbers, variable names, + - * / ^, parentheses, sqrt/abs/min/max/floor/ceil/round/sin/cos/tan (trig in degrees, matching the *Deg angle fields), and pi. A number may carry a bracketed unit — 2[in], 10[mm], 1.5[ft], 5[deg], 0.5[rad] (mm cm m in ft, deg rad) — converted to the pipeline's millimetres / degrees (2[in] is 50.8); a plain number keeps its meaning, and the suffix must touch the number. Evaluation is a small closed interpreter (src/paramExpr.ts) — never eval().
  • comment / favourite are additive and optional: both are absent when empty/false, so a sidecar written before they existed parses and re-serialises unchanged. favourite only pins the row in the panel's view; the array order is the evaluation order.
  • A variable may reference only variables defined above it in the list (derived values like W = L/2 work; cycles are unrepresentable). A variable whose expression fails keeps its cached value.
  • Failures freeze, never crash: an op whose expression references a deleted variable — or whose resolved values would violate a cross-field invariant (e.g. a torus with minorRadius ≥ majorRadius) — keeps its previous numbers and a warning is shown; replay continues.

A variable can carry a unit, which is how a value pasted from an inch drawing stays unambiguous (W resolves to 50.8 mm):

parametric
{
  "variables": [{ "name": "W", "expr": "2[in]" }],
  "steps": [
    { "op": { "op": "addBox", "center": [0, 0, 0], "size": [10, 10, 10], "exprs": { "size[0]": "W" } } }
  ]
}
  • Stored operand queries are the id-field counterpart of exprs: targetQueries maps an operand field (targets, a/b, edges, profile, faceA, …) to a SelectorQuery (same shape resolve_selector takes), with targetQueryKinds carrying one producing-op-kind tag per bucket query. The id arrays stay the last-good caches; resolution is host-side and replay-only (src/opQueryResolve.ts) — the sidecar keeps queries + caches untouched, and every freeze (forward reference, kind-tag mismatch, pattern producer, unresolved/empty) keeps the cache and surfaces a warning. Mesh sources never resolve queries (they replay on cached ids); queries are refused inside repeat bodies.
  • variables is omitted when empty, so pre-parametric sidecars are unchanged. The version stays 1. Note: an older extension version rewriting the sidecar drops variables and exprs (it serializes only what it knows).

Where ops are applied mirrors the read/export split:

Source pipelineEdit engineSupported ops (current)
B-rep (STEP/IGES/BREP)host, OCCT (applyEditsBRep, src/occtOperations.ts)every op kind: transforms, boolean, fillet/chamfer, extrude/revolve/sweep/loft (filled or thin-walled, optional region pick), shell/draft/defeature/unifySameDomain/splitByPlane/splitFace/section/drill, explode/mate, all primitives (incl. wedge) and holes, all 2D profile sketches, all curves, and the bottom-up wireframe/build ops
Mesh (STL/OBJ/PLY/glTF)webview, Three.js (applyEditsMesh, src/webview/meshEdits.ts)translate, rotate, scale, mirror, boolean (via manifold-3d — always closed and manifold — with three-bvh-csg as the fallback for an operand manifold refuses), explode, addBox, addSphere, addCylinder, addCone, addTorus, addPrism, and the hole family (addHole/addCounterboreHole/addCountersinkHole, via the same subtraction) — everything else (fillet/chamfer, feature-modeling, shell/draft/defeature/unifySameDomain/split/splitFace/section, mate, wedge, the 2D profile ops, curves, and the bottom-up wireframe ops) is B-rep only

Primitive-creation ops (addBox/addSphere/addCylinder/addCone/addTorus/ addPrism) are an op family that needs no existing operands — they build a new body from parameters alone and append it, on both pipelines, no B-rep-only restriction (addWedge is the exception: B-rep only). The hole ops (addHole/addCounterboreHole/addCountersinkHole) are the opposite of the other add* ops — subtractive: they cut a cylindrical (optionally counterbored/countersunk) hole into the selected target volumes, on both pipelines.

The 2D profile ops (addCircleProfile/addRectangleProfile/addPolygonProfile/ addEllipseProfile/addRoundedRectangleProfile/addSlotProfile/ addTrapezoidProfile) similarly need no operands, but build a flat face (no thickness) rather than a solid, and are B-rep only — their purpose is to be picked (Surf mode) as an extrude/revolve/sweep/loft profile afterward. A curve op's edges can be picked (Line mode) as a profile too, closed or open — see the sweep-family paragraph below. The curve ops (addPolyline/addThreePointArc/addSpline/addBezier/ addEllipseArc/addHelix) append standalone edges the same way the wireframe ops below do (B-rep only; addSpline is an approximating, endpoint-exact fit). The modify ops (shell/draft/defeature/unifySameDomain/splitByPlane/splitFace/section/drill, B-rep only) reshape existing solids: shell hollows the solids owning the selected opening faces, draft tapers faces (currently a kernel-limited honest skip — see doc/getting-started.md), defeature removes faces as recognized features via BRepAlgoAPI_Defeaturing with the solid healing behind them, unifySameDomain merges same-surface adjacent faces (the seam a union leaves) across the whole model via ShapeUpgrade_UnifySameDomain and leaves a model with nothing to merge unchanged, split cuts the targets by a plane keeping one or both sides, splitFace divides one face into two regions along a splitting edge on it (edge ids or two points — a boundary-condition region a Part can target; an edge that misses the face or stops short of its boundary is skipped with a diagnostic), section appends the planar cross-section as a sketch face without touching the solids, and drill cuts the picked regions of a profile face/wire through the targets (one prism per region down an explicit dir × length). They're grouped together under a "Sketches" pseudo-body in the Components tree/view, made visible by a "free-face" pass in the tessellation pipeline (see doc/extension-host-api.md). Extruding/ revolving/sweeping/lofting a sketch consumes it into the resulting solid — it doesn't leave a duplicate face behind. A thin-walled feature is the exception: it builds its own band face from the sketch's outline and consumes that instead, so the sketch survives in "Sketches" and can be reused.

The four sweep-family ops (extrude/revolve/sweep/loft) accept an optional thin — the total wall thickness — to build a thin-walled body instead of a filled one, plus thinOuter for how much of that wall sits outside the profile boundary (0, the default, grows it entirely inward; thinOuter === thin entirely outward; anything between is a band straddling the outline). The profile is offset into that band before the ordinary builder runs, so the result is an ordinary solid in every other respect. A profile that already has a hole is refused rather than silently losing it.

They also take their profile in either of two mutually exclusive forms — exactly one must be present. profile (profiles for loft) is a face-N, as before. profileEdges (profileEdgeSets for loft, one set per section) is a set of edge-N ids assembled into a single wire, which is how an open sketch is consumed: an addPolyline with closed: false contributes one edge-N per segment, and those edges are the profile. The edges may be listed in any order — they are joined by their shared vertices — and a genuinely disconnected set is skipped with a diagnostic. A closed edge set behaves exactly like the equivalent face, filled or thin. An open one encloses no area, so it requires thin: its wall is a band centred on the spine with semicircular ends, which is also why thinOuter is refused there (an open wire has no inside or outside). Every section of a loft must agree on closedness.

The single-profile three (extrude/revolve/sweep, not loft) also accept an optional region pick narrowing which enclosed regions of a multi-region profile are consumed: a face's wires enumerate to regions (0 = outer boundary, 1..N = inner loops in order), an edge profile exposes a single region. Omitted (or "outer") consumes the face as modeled, holes preserved; "all" — or any list containing 0 — consumes the outer boundary with every hole filled; a list without 0 builds each picked inner loop as a standalone island body. An out-of-range index skips with a diagnostic naming the loop count, and an explicit pick refuses thin (thin walls build from the outer boundary alone). drill cuts the same picked regions through target solids instead of appending them: one prism per region down an explicit dir × length, subtracted from the targets (no up-to-face variant).

Both fields are ordinary numeric op fields, so a parametric variable can drive them like any other — sketch a rectangle, then extrude it as a tube whose wall is a variable:

parametric
{
  "variables": [{ "name": "wall", "expr": "2" }],
  "steps": [
    { "op": { "op": "addRectangleProfile", "center": [0, 0, 0], "normal": [0, 0, 1], "up": [1, 0, 0], "width": 40, "height": 25 } },
    { "op": { "op": "extrude", "profile": "face-6", "dir": [0, 0, 1], "length": 30, "thin": 2, "exprs": { "thin": "wall" } } }
  ]
}

The bottom-up wireframe ops (addPoint/addLine/addArc/addSurfaceFromLines/ addVolumeFromSurfaces) let a shape be built up the traditional CAD way instead of from parametric primitives or profile extrusion: place standalone points, connect them with lines/arcs (endpoints typed, or bound to existing points — each coordinate slot of the eight curve ops can name a point-N, resolved against the model as of that op and re-anchored by its authored position if ids are renumbered), select a closed set of lines and Build → Surface to assemble a face, then select a closed set of surfaces and Build → Volume to sew them into a solid. addSurfaceFromLines also accepts SEVERAL disjoint closed loops in one selection, as long as they form exactly one outer boundary plus its holes (roadmap "3D text via outline import") — a letter with a counter, e.g. an imported "O"'s outer ring and inner ring, builds as one holed face rather than needing a manual boolean; a selection spanning more than one outer loop (two unrelated letters, say) is refused with a message naming the region count, since each region is its own op. Like the 2D profile ops, all five are B-rep only — meshes have no wire/sewing concept — and a point-select mode (📍 Point) shows every vertex in the model (original geometry's corners as well as added points), consistent with how Vol/Surf/Line already show everything. An addSurfaceFromLines/addVolumeFromSurfaces selection that doesn't actually close (open chain of lines, open shell of surfaces) is skipped gracefully — no error, no op applied.

Op order is preserved (replay depends on it). Parsing is tolerant: malformed ops are dropped via validateEditOp (src/editOps.ts) and a corrupt or missing sidecar yields an empty list rather than blocking the model. Export bakes the edits in — the export pipeline re-applies the same ops to the exported geometry.

Entity-id drift: topology-changing ops (booleans, fillet, feature modeling) re-tessellate into new face-*/edge-* ids, so a part assignment made before such an op may no longer resolve afterwards. The tolerant parts parser drops unresolved ids on reload, so this degrades gracefully rather than erroring.

Configurations (design table) ​

The optional top-level configurations array and activeConfiguration string hold named sets of variable values (an M6 and an M8 variant of one model). Both are additive and omitted when empty, so a sidecar written before they existed parses and re-serialises byte-for-byte unchanged.

json
{
  "variables": [{ "name": "D", "expr": "6", "value": 6 }],
  "configurations": [
    { "name": "M6", "overrides": {} },
    { "name": "M8", "overrides": { "D": "8" } }
  ],
  "activeConfiguration": "M8",
  "ops": [{ "op": "addBox", "center": [0, 0, 0], "size": [8, 8, 8], "exprs": { "size[0]": "D", "size[1]": "D", "size[2]": "D" } }]
}
  • Sparse overrides. A configuration maps a variable name to an expression that replaces that variable's own expr; a variable it does not mention keeps the base expression. A cell equal to the base expression is stored as no override.
  • Overlay before resolve, stored ops never change. The active configuration is overlaid onto the variables (on clones) before the existing resolve-on-read, so the ops' exprs, the base variables and their cached values are untouched; only the ops' cached numeric fields take the configuration's values (they are caches, healed on every read). activeConfiguration that names nothing falls back to the plain variables.
  • Tolerant parse. Entries with an empty, over-long (64), control-character or duplicate name are dropped; an override whose key is not a valid variable name, or whose value is not a bounded string, is dropped on its own. Up to 64 configurations.
  • Failures freeze. A cell that cannot evaluate keeps the variable's last-good cached value (and its dependents use it); the Config Table flags the cell. Nothing throws.
  • Suppressed ops. A configuration may also carry "suppressed": [1, 3] — sorted, unique, 0-based indices into the full op list that replay skips under that configuration (the field is omitted when empty, so a sidecar without it is byte-identical). It is overlaid at replay consumption (applySuppression in src/configurations.ts: clones carrying suppressed: true), never onto the stored ops, which are posted back and persisted and must not change; the user's own per-op suppressed toggle is independent of it. A row skipped this way reads as a configuration suppression in the History. Indices below bakedThrough are refused (those ops are already in the source), deleting an op shifts every configuration's later indices down, and a save-in-place is refused while the active configuration suppresses an unsaved op (it would bake the suppression into the file for every configuration).
  • Scope. Variables and, through suppressed, which ops replay: nothing else — not Parts, mesh options or layers. The sidecar writers in src/editsStore.ts and src/mcpSidecars.ts keep the configurations on disk when a caller does not pass them, so an older code path can never drop them.

Blank documents ​

File ▾ → New Blank Model… creates a .brep file holding an empty compound and opens it. Everything authored afterwards lives in this same .edits.json op-list, so a blank document is just the general "base shape ∘ ops" model with an empty base — no new concept, no new sidecar, and the CAD file stays read-only exactly as for an imported STEP.

BREP rather than STEP or IGES: a live probe confirmed all three writers accept an empty compound, so this is a preference rather than a constraint, but BREP is OCCT's native serialization, carries no unit header to declare for geometry that isn't there yet, and skips the STEP/IGES unit-detection read path. Two consequences worth knowing: the .brep on disk never grows (ship it together with its .edits.json, or Export/Save Preprocess to get something standalone), and since the export list always excludes a document's own format, a blank document's Export… offers STEP/IGES plus every mesh format but not BREP.

MMG remesh outputs ​

Remesh (MMG) / remesh_mesh writes a new .med mesh and a normal <output>.parts.json sidecar. Both paths must be unused; the source is never overwritten. Named cell regions, including boundary triangle groups and overlapping memberships, are carried through MMG integer references. Parts are rebuilt against the output's facet ids, not copied with stale face-N ids. Colours and flat mesh sizes survive by matching region names. Point/side groups, result fields and CAD selector/grading metadata do not transfer and are reported as warnings. Interior-only groups still exist in MED even without a selectable boundary Part. No new sidecar format is introduced; remesh settings are per-call, not added to .mesh.json. See MMG remeshing.

Mesh Options Sidecar (<model>.mesh.json) and Generated .geo Script ​

The FE Mesh panel's finite-element mesh generation settings (via Gmsh compiled to WebAssembly — see GMSH Integration) are stored in a third JSON sidecar next to the CAD file — e.g. bull.stp → bull.stp.mesh.json. Like parts and edits, this never modifies the CAD file. It is read on open (readMeshOptions()) and autosaved, debounced (~500 ms, its own timer), on every options change (writeMeshOptions()), both in src/meshOptionsStore.ts; parse/serialize live in the vscode-free src/meshOptionsSidecar.ts so they are unit-tested.

json
{
  "version": 1,
  "source": "bull.stp",
  "options": {
    "dimension": 3,
    "sizeMin": 0,
    "sizeMax": 1e22,
    "algorithm2D": 6,
    "algorithm3D": 1,
    "elementOrder": 1,
    "optimize": true,
    "stlAngle": 40
  }
}

An optional budgetElements (a positive integer) records the advisory element budget the FE Mesh panel and estimate_mesh_budget/generate_mesh warn against — it never blocks a generate and is omitted entirely when unset, so an untouched sidecar stays byte-stable. It is not a Gmsh option and never reaches the .geo script.

Optional mdpaRanks (an integer 2–64) and mdpaPartitionMethod ("kahip" or "sfc"; absent means auto) record the partitioned Kratos MDPA export request (Partitioned MDPA). Both are omitted unless set — a rank count of 1 and "auto" are never written — and neither is a Gmsh option or reaches the .geo script.

Drawing-sheet templates (cad-preview-sheet-templates.json beside the model, shared by every model in the folder; plus the bundled dist/sheet-templates/starter-templates.json) are { "version": 1, "templates": { "<name>": { "name", "description"?, "views"?, "projection"?, "paper"?, "scale"?, "title"?, "fields"?, "format"?, "sections"?, "viewSections"? } } } — settings only, never geometry. Parsing is tolerant: a malformed entry or field is dropped, never the file.

Mesh presets (cad-preview-mesh-presets.json, and the bundled dist/mesh-presets/starter-presets.json) may carry an optional per-preset stlExport block — { "targetCellSize": 1, "chordalFraction": 0.1, "angularDeg": 20 }, in the preset's own unit — the mesh-aware STL tolerance export_tessellated_stl's preset parameter applies. A malformed block is dropped without dropping the preset.

On the same debounce, writeGeoScript() also (re)generates an editable Gmsh .geo script beside the sidecar — e.g. bull.stp → bull.stp.geo — merging the source file and setting one Mesh.* option per MeshOptions field:

Merge "bull.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 .geo file is a one-way, generated output, not an input CAD-Preview reads back. It exists so the mesh can be regenerated or tweaked directly in Gmsh outside the editor. Hand-edits made to it are silently overwritten the next time an option changes in the FE Mesh panel — CAD-Preview never parses .geo files itself. Parsing the sidecar is tolerant: a missing or hand-corrupted <model>.mesh.json falls back to DEFAULT_MESH_OPTIONS rather than blocking the panel from working.

A sizeMax of 1e22 is Gmsh's "unbounded" sentinel: it means "no explicit target size was ever chosen", and the webview displays it as an empty auto field, seeding a bounding-box-derived default (model diagonal / 20) over it in memory once the model loads. That seed is display-only — neither sidecar file is created or rewritten until the user actually changes an option in the panel.

Exported mesh artifacts: the FE Mesh panel's export <select> + 📤 Export write further output files via a native Save dialog, in whichever format is picked — hand-written Kratos .mdpa (the default; either an Elements + Conditions or a Geometries layout, see GMSH Integration § Kratos MDPA), GMSH's native .msh mesh format (nodes + elements), the legacy .msh2 (v2.2) variant, Gmsh's fully-expanded .geo_unrolled script, or any of VTK/I-DEAS Universal (.unv)/Abaqus (.inp)/Nastran (.bdf)/SU2 (.su2)/INRIA Medit (.mesh)/STL/Diffpack (.diff)/OFF — see GMSH Integration § Export formats for the full registry and which formats this WASM build actually supports. Like every other Export target in this codebase, these are save-as artifacts the user places wherever they choose; they are not sidecars and are not read back by CAD-Preview.

View State Sidecar (<model>.view.json) ​

The persisted camera orientation, display mode, ortho/perspective toggle, and clip plane (roadmap "View-state persistence", closed) are stored in a fourth JSON sidecar next to the CAD file — e.g. bull.stp → bull.stp.view.json. Like the other three, this never modifies the CAD file. It is read on open (readViewState()) and autosaved, debounced (~500 ms, its own timer), on every user-facing view change — camera orbit/pan/zoom/dolly, Fit/Reset, the orientation gizmo, the Ortho/Persp toggle, a Display mode button, the clip axis/offset/toggle, or the split-view layout picker / any per-pane camera move (Phase 2), or collapsing/expanding a sidebar section, or moving the sidebar's resize handle — both in src/viewStateStore.ts; parse/serialize live in the vscode-free src/viewStateSidecar.ts so they are unit-tested.

json
{
  "version": 1,
  "source": "bull.stp",
  "view": {
    "viewDirection": [1, 0.8, 1],
    "cameraUp": [0, 1, 0],
    "orthographic": false,
    "displayMode": "shaded",
    "clip": null
  },
  "layout": "1x2",
  "panes": [
    { "viewDirection": [1, 0, 0], "cameraUp": [0, 1, 0], "orthographic": false },
    { "viewDirection": [0, 1, 0], "cameraUp": [0, 0, 1], "orthographic": true }
  ],
  "collapsedPanels": ["meshing-panel", "standard-parts-panel"],
  "sidebarWidth": 300,
  "ribbonTab": "inspect",
  "ribbonCompact": true,
  "editsGridOpen": true,
  "ribbonHidden": true,
  "formsDocked": true
}

layout ("1x1"|"1x2"|"2x1"|"2x2", Phase 2 — absent/"1x1" = single pane) and panes (one PaneViewState per pane of that layout, row-major) are optional siblings of view at the file's top level, never inside view itself; a 1×1 session writes neither, so existing single-pane sidecars stay byte-stable. view stays the focused/single-pane state, so an older build reading a new sidecar still restores sensibly, and vice versa — purely additive, no version bump. Only camera state (viewDirection/cameraUp/orthographic) is per-pane; display mode and clip stay global, matching Phase 1's scoping.

collapsedPanels is a third such optional top-level sibling: the sidebar sections currently collapsed to just their header, by panel id. Valid ids come only from COLLAPSIBLE_PANELS (src/webview/collapsiblePanels.ts), and a hand-edited or newer-build list is filtered against it on read, so an unknown id is dropped rather than reaching some other element. Absent or empty (the default) means every section is expanded, so an older sidecar restores exactly as before and an untouched one stays byte-stable — again purely additive, no version bump.

browserOpen is another optional top-level sibling: true when the sidebar Browser section is shown (View ▾ ▸ Browser). It starts hidden, so only true is ever written (additive, no version bump, no MCP surface); it is deliberately not part of collapsedPanels, whose absence means "every section expanded".

zebra is another optional top-level sibling: the Zebra display mode's stripe settings, { "count": 2..40, "axis": "x"|"y"|"z" }. Read through parseZebra (count rounded and clamped, anything malformed means "unpersisted"); the default (8 stripes, "y") writes nothing, so an untouched sidecar stays byte-stable — additive, no version bump, no MCP surface.

sidebarWidth (roadmap "Sidebar layout and keyboard usability") is a fourth such optional sibling: the resizable sidebar's width in px. Read values are clamped through clampSidebarWidth (src/webview/sidebarResizer.ts, range 176..420) — a hand-edited out-of-range number falls back to the clamp bounds, never the raw value, and a non-numeric value means "unpersisted". Absent or exactly 220 (the pre-resizer fixed width) writes nothing, so an unresized document's sidecar stays byte-stable. Same shape, same no-version-bump rule, same exclusion from the Preprocess Archive and from MCP as collapsedPanels.

ribbonTab, ribbonCompact and editsGridOpen (roadmap "Ribbon command strip") are three more optional top-level siblings: the command strip's active tab (view, model, inspect, mesh or export), whether its button labels are hidden, and whether the Edits panel's op button grid is expanded. Each is omitted at its default (tab model, labels shown, grid collapsed), so an untouched ribbon leaves the file byte-identical to the older shape; an unknown tab id or a non-boolean flag falls back to the default without discarding the rest of the record. Display preferences only: no MCP surface, no geometry.

ribbonHidden is one more optional top-level sibling (the whole ribbon strip is hidden); written only when true, ignored when not a boolean, no MCP surface. formsDocked is one more optional top-level sibling: the Edits panel's operation form is docked over the viewport (top right) instead of sitting in the sidebar. Written only when true; a non-boolean value is ignored without discarding the rest of the record. Display preference only: no MCP surface, no geometry.

viewDirection/cameraUp are a normalized direction (target → camera) and up vector, not a raw position/target/distance — Viewer.frame(direction) already re-derives both from the model's current bounding box, so this survives edits that change the model's extents. clip.offsetFrac is likewise a fraction of the model's bbox, not a raw plane constant — measured along the clip's active normal. clip may carry an optional explicit unit normal ({axis, offsetFrac, normal?}) which wins over axis when present; axis is still always written, set to that normal's dominant axis, so an older build restores a sensible neighbouring clip instead of none. A malformed normal drops only itself, leaving the clip as its axis form — unlike a malformed axis, which still drops the whole clip. Parsing is tolerant like the other three sidecars, with one stricter rule: a missing or degenerate (all-zero) viewDirection/cameraUp rejects the WHOLE record (falls back to no persisted view, i.e. the default isometric) rather than feeding NaN/zero into the camera — every other field falls back individually (an unrecognized displayMode → "shaded", a malformed clip → null, an unknown layout → ignored as "1x1", a bad per-pane entry → falls back to view's own direction/up/ortho for that pane, a short/long panes array → padded/truncated to paneCount(layout)).

Deliberately excludes explode-preview state — that's a session-only interaction preview by design (src/webview/explodePreview.ts); the committed explode edit op already persists correctly via .edits.json. Deliberately excluded from the Preprocess Archive (below) — unlike parts/edits/mesh options, view state is purely a display/session preference with no effect on computed geometry, mesh output, or anything an MCP agent would need; there is also no MCP tool surface for it at all (same "genuinely a display feature, no headless equivalent" scope this codebase already applies to Markup and interactive Measurement).

Annotations Sidecar (<model>.annotations.json) ​

User-pinned measurements (roadmap "Persisted, topology-anchored annotations", closed) — a "📌 Pin" action on a completed Measure-tool result — are stored in a fifth JSON sidecar next to the CAD file — e.g. bull.stp → bull.stp.annotations.json. Like the other four, this never modifies the CAD file. It is read on open (readAnnotations()) and autosaved, debounced (~500 ms, its own timer), on every pin/rename/delete (writeAnnotations()), both in src/annotationsStore.ts; parse/serialize live in the vscode-free src/annotationsSidecar.ts so they are unit-tested. The headless MCP server has a byte-compatible counterpart in src/mcpSidecars.ts.

json
{
  "version": 1,
  "source": "bull.stp",
  "annotations": [
    {
      "id": "ann-1234567890-1",
      "tool": "distance",
      "text": "12.5 mm",
      "anchorPoint": [5, 0, 0],
      "linePoints": [[0, 0, 0], [10, 0, 0]],
      "volumes": [],
      "surfaces": ["face-1", "face-4"],
      "lines": [],
      "points": [],
      "tolerance": { "nominal": 12, "plus": 0.1, "minus": 0.05, "measured": 12.5 }
    }
  ]
}

Entity ids in volumes/surfaces/lines/points are the same stable topological ids Part uses, and are rebound through the identical best-effort geometric matching a topology-changing edit already applies to Parts (src/entityFacts.ts's rebindPartsAcrossOps, extended to also rebind annotations via the same shape-diff pass at no extra cost) — an anchor that can't be confidently re-matched is dropped from these arrays, the same graceful-degradation contract unresolved Part ids already have. text/anchorPoint/linePoints are a frozen snapshot of the measurement result at pin time; they are never recomputed on reopen or after an edit — only whether the annotation is "detached" (none of its anchor ids currently resolve in the loaded model) is computed live, in the webview. Parsing is tolerant like the other four sidecars: a missing/malformed field drops that one annotation entry, not the whole file.

The optional tolerance object (roadmap "Tolerance-band fact checks on exact measurements") records a nominal-plus-band intent from the Measure panel's inline fields: nominal/plus/minus are the band (a symmetric ± when plus === minus; both allowances ≥ 0), and measured is the raw numeric value frozen at pin time so the in/out-of-band colour can be re-derived on redisplay without parsing formatted text back into a number. Facts only — nothing stores a verdict; src/toleranceBand.ts's shared evaluateToleranceBand computes it at render time (the same pure module the MCP check_tolerance tool uses). A malformed band drops the BAND only — the annotation survives as a plain untoleranced pin. A toleranced pin's label reads "<text> [nominal ±band]", and it appears decorated the same way in SVG/DXF silhouette-export dimension glyphs. Included in the Preprocess Archive (below), alongside parts/edits/mesh options (roadmap "Archive integrity", closed).

Construction Planes Sidecar (<model>.planes.json) ​

Named construction planes (roadmap "A named, persisted construction-plane entity", Phase 3 closed) — reusable datum planes saved from the current clip or entered numerically in the view-controls Planes group — are stored in a sixth JSON sidecar next to the CAD file — e.g. bull.stp → bull.stp.planes.json. Like the other five, this never modifies the CAD file. It is read on open (readPlanes()) and autosaved, debounced (~500 ms, its own timer), on every add/rename/delete (writePlanes()), both in src/planesStore.ts; parse/serialize live in the vscode-free src/planesSidecar.ts so they are unit-tested. The headless MCP server has a byte-compatible counterpart in src/mcpSidecars.ts, written by the set_plane tool and reported by get_state.

json
{
  "version": 1,
  "source": "bull.stp",
  "planes": [
    {
      "id": "plane-0",
      "name": "Top datum",
      "point": [0, 0, 10],
      "normal": [0, 0, 1],
      "derivedFrom": "face-12"
    }
  ]
}

Unlike every other sidecar that references geometry, a plane stores resolved vectors rather than entity ids — so it takes no part in entity-id rebinding at all. A topology-changing edit that renumbers face-N and rebinds Parts and annotations leaves this file byte-identical, which is the whole point of naming a plane: it stays where it was put. derivedFrom records where the plane came from ("face-12", "clip plane", "entered") for display only and is never resolved back to geometry.

id is plane-N and is never reused — the next is the highest existing N plus one — so deleting a plane and adding another cannot resurrect the old id under a new meaning. Parsing is tolerant like the other sidecars: a malformed entry drops that one plane, not the file; normal is normalized on read, so a hand-edited [0, 0, 10] still yields a unit vector; and a zero-length normal drops that plane, since it describes no plane at all. Included in the Preprocess Archive (below).

OpenSCAD Customizer Overrides Sidecar (<model>.scad-params.json) ​

OpenSCAD Customizer overrides (roadmap "OpenSCAD Customizer panel in the viewer") — the values the sidebar's Customizer section applies to a .scad source — are stored in a JSON sidecar next to the CAD file — e.g. model.scad → model.scad.scad-params.json. Like the other sidecars, this never modifies the CAD file. It is read on open and written immediately on every Apply (writeScadOverrides(), both in src/scadOverridesStore.ts); parse/serialize live in the vscode-free src/scadOverridesSidecar.ts so they are unit-tested. The headless MCP server has a byte-compatible read counterpart in src/mcpSidecars.ts (no tool writes it — convert_scad takes per-call parameters instead).

json
{
  "version": 1,
  "source": "model.scad",
  "overrides": {
    "width": 60,
    "chamfer": false
  }
}

Every conversion of the document — opening, every edit's re-tessellation, mass properties, BOM, clash, meshing and export — replays these overrides as -D defines through resolveEffectiveSource, validated with validateScadOverrides against the live source text (an unknown name or wrong type is reported and dropped, an out-of-range value applied with a note since OpenSCAD does not clamp). Parsing is tolerant like the other sidecars: a malformed entry drops that one override, not the file. Included in the Preprocess Archive (below).

Preprocess Archive (.zip) ​

File ▸ Save Preprocess… (Ctrl+Alt+S) packages the CAD source file plus whichever of its sidecars — <model>.parts.json, <model>.layers.json, <model>.annotations.json, <model>.planes.json, <model>.edits.json, <model>.mesh.json, <model>.scad-params.json — currently exist on disk into a single .zip, so the whole working state of a document can be shared, archived, or moved as one file. Which pieces are included is purely file-existence-driven: a document that never had meshing options set simply has no .mesh.json in the archive — this is normal, not an error. Pending debounced sidecar writes are flushed immediately before packaging (the same flush Save triggers), so the archive always reflects the current in-editor state, not a stale on-disk one. The generated .geo script is deliberately not packaged (see below).

The archive's internal layout (built by the pure, vscode-free — but Node-only, never imported by the webview — src/preprocessArchive.ts, shared by the extension and the MCP server):

manifest.json               { "version": 2, "minimumReaderVersion": 1, "source": "bull.stp",
                               "checksums": { "bull.stp": "<sha256 hex>", "bull.stp.parts.json": "<sha256 hex>", ... } }
bull.stp                    (the CAD source, byte-identical)
bull.stp.parts.json         (only if it exists)
bull.stp.layers.json        (only if it exists)
bull.stp.annotations.json   (only if it exists)
bull.stp.edits.json         (only if it exists)
bull.stp.mesh.json          (only if it exists)
bull.stp.scad-params.json   (only if it exists)

Archive integrity (roadmap "Archive integrity", closed). manifest.checksums records a SHA-256 hex digest for every entry the writer included — cryptographic tamper-evidence on top of zip's own CRC32, which only catches accidental corruption, not deliberate tampering. readPreprocessZip() recomputes and compares every present entry's checksum before returning anything, throwing a clear "failed its checksum" error on a mismatch. manifest.minimumReaderVersion is a forward-compatibility gate, not a backward one: it names the oldest reader version required to correctly interpret this archive, and a reader whose own capability is older refuses to open it (rather than silently misinterpreting fields it doesn't recognize) with a clear "requires a newer version of CAD Preview" error. Both fields are new in manifest version 2; a legacy v1 archive (written before this feature — no checksums, no minimumReaderVersion field at all) still opens exactly as before, with checksum verification simply skipped — reading tolerantly defaults a missing version/minimumReaderVersion to 1, never inventing the current version the way an earlier build's parseManifest used to (a real, if minor, gap this closed: nothing ever compared the invented value against anything, so a hypothetical future format bump would have loaded silently misinterpreted).

File ▸ Load Preprocess… (Ctrl+Alt+O) is the inverse: pick a .zip, then pick a destination path for the restored CAD file (defaulting to the manifest's source filename, beside the archive), and CAD-Preview writes the source bytes plus every sidecar the archive contains — named to match the chosen destination, not the archive's original filename — then opens the result. Mesh options (if present) are re-written through the normal writeMeshOptions/writeGeoScript path, which regenerates .geo fresh from them — the archive never packages a raw .geo text to restore verbatim in the first place, since neither reader ever did anything with one when it was still packaged (pure dead weight, closed alongside the integrity work above). The destination's file extension is checked against the archive's own source format (roadmap "Archive integrity", closed) — restoring a STEP archive to restored.stl now fails with a clear error instead of silently succeeding; a same-format alias (.stp/.step) still compares equal, since both route to the same FileRoute.format. Loading an archive never touches the CAD file it was originally saved from — it always creates a separate file at the chosen destination.

The headless MCP server exposes the same behavior as save_preprocess/ load_preprocess (see MCP Server), sharing the identical preprocessArchive.ts build/read logic — an archive saved from the extension loads via the MCP tool and vice versa.

Simulation Handoff Manifest (<mesh>.handoff.json) ​

Written beside an exported FE mesh when you ask for one (the FE Mesh panel's Handoff manifest box, or export_mesh's manifest: true). It is a receipt, not a document sidecar: nothing reads it back when the model opens, and deleting it changes nothing. check_handoff_manifest compares it with the current files.

json
{
  "version": 1,
  "kind": "cad-preview-handoff",
  "createdAt": "2026-09-23T10:00:00.000Z",
  "source": { "path": "/work/bracket.step", "format": "step", "sha256": "…" },
  "replay": { "opCount": 3, "bakedThrough": 0, "fingerprint": "…", "editsBaked": true },
  "unit": { "unit": "in", "scaleFactor": 0.03937007874015748 },
  "meshOptions": { "sizeMax": 1.5, "dimension": 3, "…": "…" },
  "engineUsed": "gmsh",
  "kernels": { "opencascade.js": "1.1.1", "@loumalouomega/gmsh-wasm": "0.3.0", "@meshioplusplus/wasm": "16.7.0", "float-tetwild-wasm": "0.2.0" },
  "outputs": [{ "path": "/work/bracket.mdpa", "format": "mdpaElements", "sha256": "…" }],
  "mesh": { "nodeCount": 812, "elementCount": 3120 },
  "boundaryLayers": [{ "part": "Wall", "plannedRows": 6, "achievedMin": 6, "achievedMedian": 6, "coverage": 1, "stations": 9 }],
  "parts": [
    { "name": "Inlet", "requested": { "volumes": 0, "surfaces": 1, "lines": 0, "points": 0 }, "status": "resolved",
      "groups": [{ "dim": 2, "physicalTag": 1, "entityCount": 1, "elementCount": 76 }],
      "subModelPart": { "nodeCount": 50, "volumeCellCount": 0, "surfaceCellCount": 76 } }
  ],
  "coverage": { "emptyParts": [], "unresolvedParts": [], "unassignedSurfaceCount": 5, "unassignedSurfaceTags": [2, 3, 4, 5, 6], "overlaps": [] },
  "notes": ["Geometry and mesh sizes were scaled by 0.0393… (mm → in) at export; meshOptions are recorded in native mm."]
}
  • createdAt is the export time and is absent from a manifest written with deterministic: true (see Reproducible exports), which also records "deterministic": true.
  • replay.fingerprint is a SHA-256 of the full edit history plus the save watermark, so adding, removing, editing or baking an op makes the manifest stale.
  • meshOptions are always the native-mm values; a unit conversion is recorded once, in unit and one note.
  • Coverage is reported, not judged. status is resolved (at least one physical group), unresolved (ids given, none landed in a group), or empty (no ids). unassignedSurfaceTags are Gmsh surface entities in no surface group; overlaps are entities in more than one group of the same dimension. Whether any of that matters is the solver setup's call.
  • Mesh-format sources carry no Parts into physical groups (there is no entity correlation) and never bake their edits; both are stated in notes.
  • subModelPart sizes are exactly what the Kratos MDPA writer emits (absent for hex-dominant meshes, which MDPA cannot represent).
  • boundaryLayers is present only for a 2-D generate that requested a Part.meshBoundaryLayer and was measured: each row names the wall Part, the planned rows, the achieved minimum/median over sampled wall stations and the fraction of stations covered. A shortfall is also a notes entry, so a manifest that travels alone still says the layer did not fully form.
  • The group and coverage facts come from one extra, deterministic meshing pass with the same input and options as the export.

Queue-managed exports may choose a project-owned path with handoffPath. The manifest keeps the fields above and also publishes the shared version-1 contract fields: exportId, an external source reference with revision, replayRevision, effective units and options, engine/engineVersion, revisioned artifacts, named groups, and boundaryCoverage. This lets a study manager validate and attach the export without discarding CAD Preview's richer Part and kernel details.

Export ​

The File ▸ Export… menu item (or Ctrl+E) converts the currently displayed model into a compatible format and saves it via a native VS Code save dialog. The available targets depend on the source file's pipeline (exportTargetsFor() in src/exportTargets.ts):

Source pipelineExport targets
B-rep (STEP/IGES/BREP/CSG/SCAD)its own format first (confirmed save-in-place), then the other B-rep formats, plus STL/OBJ/PLY/glTF/3MF (never Collada, AMF, USD, VRML or FBX — import-only)
Mesh (STL/OBJ/PLY)its own format first (confirmed save-in-place), then the other mesh formats
Mesh (3MF, Collada, AMF, USD, VRML, FBX)the other mesh formats only (display-only on the host; never saved in place; Collada, AMF, USD, VRML and FBX are never export targets)
Mesh (glTF)the other mesh formats only (glTF is never offered its own format — the exporter only emits binary .glb, so saving in place would change container)
meshio++ (VTK/MED/CGNS/Exodus/XDMF/MDPA/OpenFOAM)STL/OBJ/PLY/glTF — the displayed model is an ordinary THREE.Object3D by this point (see the meshio++ Bridge Formats section above), so it exports exactly like a native mesh source

Picking the source's own format above is a confirmed save-in-place (modal data-loss confirmation, temp sibling + rename, one-deep <model>.bak, bakedThrough watermark with history preserved), not an export — meshio++ and CAD-text sources never offer their own format (no same-format writer).

B-rep targets are written entirely in the extension host: the source file is re-parsed with the same OCCT reader used to open it, the current edit op-list is applied (applyEditsBRep), then the result is handed to the matching OCCT writer (STEPControl_Writer, IGESControl_Writer, or BRepTools::Write) in exportBRep() (src/occtService.ts). There is no path from a triangulated mesh back to a B-rep, so mesh-sourced documents never offer STEP/IGES/BREP as a target.

Mesh targets are written in the webview, reusing Three.js's bundled exporters (three/examples/jsm/exporters/) on the THREE.Object3D already displayed — regardless of whether it arrived via a native loader or OCCT tessellation. The serialized result is sent back to the host over the protocol described in Host ↔ Webview Protocol and written to disk there, since only the host can show file dialogs.

glTF export always produces a binary .glb file (not a text .gltf with embedded base64 buffers) — a single portable file, no separate buffer references to manage.

Reproducible exports ​

By default two exports of one model differ. Measured with scripts/probe/examples/export-determinism.ts: a STEP file carries the export time in FILE_NAME and a process-global counter OCCT bakes into product names (Open CASCADE STEP translator 7.4 <n>.…) and NEXT_ASSEMBLY_USAGE_OCCURRENCE ids, while IGES and BREP are already byte-identical and a 3MF uses a fixed zip timestamp. A drawing sheet's title block prints the export date (supplied by the caller, not the renderer).

The MCP tools export_brep, export_drawing_sheet, batch_export and export_mesh take an opt-in deterministic flag: STEP gets the timestamp fixed to 1970-01-01T00:00:00, the translator counter reset to its per-export position, and occurrence ids renumbered in file order (statements that carry one are also unwrapped, since OCCT's line wrapping depends on the counter's digit count); a sheet omits its date, or prints an explicit date. Geometry records are untouched. The interactive File menu keeps today's behaviour. A named-Part STEP (XCAF path) is normalised the same way but is not promised byte-identical beyond that, — see CLAUDE.md's "Reproducible exports".

For export_mesh, measured over every registered format written twice: the Gmsh .vtk and the .xao companion of .geo_unrolled carry a process-global model-<n> counter, and meshio++ stamps its export time into the text formats' provenance block (VTU, AVS UCD, COMSOL, Netgen, FLAC3D, Flux, GiD) and into the HDF5 containers (MED, CGNS, XDMF's .h5, HMF). With deterministic: true the counter is reset to model-1, the text timestamp becomes 1970-01-01T00:00:00Z, and the clock is pinned to the epoch while meshio++ converts so the HDF5 containers come out identical too — no binary patching. MSH 4.1/2, both Kratos MDPA modes, UNV, Abaqus, Nastran, SU2, Medit, STL, Diffpack, OFF and WKT were already byte-identical. A handoff manifest written with the flag has no createdAt, records "deterministic": true, and its portable exportId is a hash of the output hashes, edit-history fingerprint and format rather than a random UUID; the manifest's version stays 1 and createdAt is optional when reading.

Silhouette SVG/DXF Export ​

File ▸ Export Silhouette SVG… / File ▸ Export Silhouette DXF… (or the matching CAD Preview: Export Silhouette … commands) are a third export case, deliberately outside the Export… quick-pick above: they write a 2D outline drawing rather than a 3D model, so this is neither a B-rep target nor a mesh target and never appears in exportTargetsFor()'s list. The two menu items share one flow — a view quick-pick (Current view — the angle you are currently looking at — then Front/Back/Top/Bottom/Left/Right/Iso), then the same export-unit quick-pick every other export shows, then a save dialog — differing only in the serializer used (src/svgSilhouette.ts vs src/dxfSilhouette.ts, over the same segment list, so an SVG and a DXF of one view are geometrically consistent). Pressing Escape on the view pick cancels the export (it is the primary choice); Escape on the unit pick still exports at native mm, matching the existing convention.

It is an outline, not a dimensioned 2D technical drawing. There is no hidden-line removal. Back-facing geometry is not drawn, but neither are interior feature edges that do not lie on a silhouette. OCCT's HLRBRep_* hidden-line machinery is entirely unavailable in this WASM build, and HLRAppli_ReflectLines — the one surviving green alternative — was probed against the live kernel and produced a strictly worse drawing (missing the part's holes and interior cutout), so the outline is derived from triangle adjacency instead: an edge is kept where its two adjacent triangles disagree about facing the viewer. That choice is also why this works for mesh sources and not just B-rep. Treat the result as a review/illustration artifact; use the Measurement tools for any dimension you need to be sure of.

SourceGeometry the outline is derived from
STEP / IGES / BREPthe tessellation of the current model, edits baked in
STL / OBJ / PLY / glTFthe file with its pending mesh edits baked in by the headless mesh-edit replay (the viewer's own engine); the raw file bytes, with a warning, only if that bake fails
meshio++ (VTK/MED/CGNS/Exodus/XDMF/MDPA/OpenFOAM)rejected — those formats never expose a triangle array back to JS

The output is a single self-contained <path> — no <style>, no script, no external references — so it embeds anywhere. 1 SVG user unit = 1 model unit, with the document's physical width/height given in millimetres, so a drawing exported from a native (mm) model prints 1:1 in any vector tool. Choosing a non-mm unit applies the same real coordinate scale every other export in this codebase uses, before projection. The DXF variant is minimal model-space ENTITIES only: chained collinear runs become LWPOLYLINEs and unmatched singletons stay independent LINEs, at 1 DXF drawing unit = 1 model unit.

Limitation — triangle winding. The facing test depends on consistent triangle winding across the mesh. A mesh with mixed winding (some triangles clockwise, some counter-clockwise, as some exporters and hand-edited files produce) yields spurious interior lines, because the test flips with the winding. There is no cheap, reliable way to repair winding for an arbitrary open mesh, so this is documented rather than worked around.

The same capability is available headlessly as the MCP server's export_svg_silhouette tool (its format param selects "svg" or "dxf") — see MCP Server.

DXF Import ​

File ▸ Import DXF… reads a .dxf file's model-space ENTITIES section and converts each supported entity into an existing parametric profile/curve edit op — genuinely no new geometry kernel surface. Handled: LINE, LWPOLYLINE (bulge arcs sampled), POLYLINE/VERTEX, CIRCLE, ARC, and SPLINE (control points); everything else (blocks, INSERT, TEXT/MTEXT, DIMENSION, HATCH, paper space) is skipped. Like SVG import, it is B-rep sources only — mesh files have no sketch topology to receive the imported ops — and placement follows the same simple defaults: flat in the XY plane at z=0, 1 DXF drawing unit = 1 mm, Y-up native (DXF needs no flip, unlike SVG). A poorly-scaled source is adjusted afterward with the ordinary scale/translate/rotate ops.

File Size Guidance ​

FormatPractical limitNotes
STEP / IGES~50 MBTessellation is CPU-bound in the extension host process
BREP~100 MBFaster read than STEP/IGES; still tessellated
STL~200 MBBinary STL loads fast; ASCII STL is much slower for large files
OBJ~100 MBParsing is text-based; large files may be slow
PLY~200 MBBinary PLY is fast
glTF~100 MBDepends on embedded texture size

These are rough guidelines, not hard limits. Performance depends on vertex count after tessellation, not just file size.

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