Roadmap
Candidate features for future CAD-Preview releases, prioritized by value versus effort given what the extension already ships: an OCCT kernel, a Gmsh kernel, and a meshio++ kernel live in the extension host, a full picking/selection pipeline in the webview, a sidecar persistence model, and an MCP server mirroring the pipeline headless. Many high-value features are cheap precisely because that infrastructure exists.
This page is aspirational, not a commitment — items may be re-ordered, re-scoped, or dropped. Effort is a rough order of magnitude: S (a day or two), M (roughly a week), L (multi-week).
Everything previously shipped is tracked in CHANGELOG.md, and CLAUDE.md has a per-feature section with the verified implementation details for anything currently in the codebase — this page is for what's not built yet, only.
How this file works
- Tiers are ordered, and the order is the recommendation. Each tier states an admission criterion; an item that doesn't meet it belongs in a different tier or in Non-goals, not at the top because it sounds exciting. Unlike an earlier version of this convention, an empty tier is removed from the file entirely, not kept as a placeholder — if a new item shows up later that needs a tier no longer listed here (e.g. a defect-shaped correctness/robustness finding, which is always top priority regardless of effort size), recreate that tier at the top rather than burying it under whatever tier happens to be listed first at the time.
- A closed item is removed from this list entirely, not struck through — its write-up moves to
CLAUDE.md(a per-feature section with the verified implementation details) and its history stays in git. Numbering is renumbered to stay consecutive whenever an item closes, so a reference like "item 5" always means the file's current 5th item, not a fossil of one that shipped. - Items marked needs live-WASM verification are listed on the strength of the binding manifest alone. Green in
node_modules/opencascade.js/dist/Supported APIs.mdis necessary but not sufficient — both the STEP-unit and the IGES-writer findings started green and only resolved (one negative, one positive) under a real probe. No such item may be estimated until it has been probed against the live build; each one names its probe below.
Several items below were identified by comparing against SketchForge-3D, a browser-based direct-manipulation CAD editor over the same OCCT kernel. Its capability gaps transferred well; most of its interaction model deliberately did not — see Non-goals.
An item that corresponds to a GitHub issue names it inline (e.g. issue #36). The issue is the request and the discussion thread; this file is the scoping — what already exists, what the real blocker is, and what the phasing should be. Where the two disagree, this file is the one that was checked against the code.
Queued
Tier 1 — Correctness and prerequisite gaps
Admission: a defect-shaped finding — something already shipped that is inconsistent, incomplete, or blocks another item — regardless of how small. Per the convention above, this tier is recreated at the top whenever such a finding appears, rather than being filed behind a feature.
Two small plane-handling gaps in already-shipped code (S for both). Found while scoping arbitrary construction planes (item 7); the second is a hard prerequisite for that item's Phase 2, which is why they are listed rather than folded into it.
mirroraccepts a zeroplaneNormal, unlike every other plane-bearing op.validateEditOp(src/editOps.ts) reads it withasVec3, wheresplitByPlaneandsectionboth useasNonZeroVec3. A[0,0,0]normal therefore passes validation and reachesgp_Dir_4(0,0,0), where OCCT throws — caught by the op's graceful-skiptry/catch, so it degrades rather than crashes. Not a crash, but a genuine hole in the single tolerance gate: the webview compensates with its own guard for split/section only (main.ts's"Plane normal must be non-zero.") and never for mirror, and an MCP caller bypasses the UI entirely. One-word fix, plus aeditOps.test.tscase alongside the existing split/section ones.inspectcomputes a face's full plane and then throws half of it away.entityFacts.tscallsfacePlane(oc, handle, cleanup)— which returns{pt, nl}, a complete plane — and keeps onlynormal = plane?.nl ?? null. The plane's own origin (gp_Pln.Location()) is discarded. A caller wanting{planePoint, planeNormal}for asection/splitByPlane/mirrorop must therefore substituteEntityFacts.center, which is the bounding-box centre — coplanar with a planar face, so usable, but a different point than OCCT computed, undocumented as a substitute, and not guaranteed to lie on an annular or concave face. AddingplaneOrigin: Vec3 | nullbeside the existingnormalis purely additive (same tolerant-consumer convention as every other optional field), costs no extra OCCT call, and is what lets Phase 2 of item 7 derive a real plane from a picked face.
Tier 2 — Dependency currency
Admission: a bundled dependency has drifted far enough that shipped behavior is affected. The work is a version bump plus re-verification against this codebase's own recorded invariants — not new design — so it ranks above every feature item regardless of how modest it looks.
Update
@meshioplusplus/wasmto the latest published release (S). The extension pins^9.9.0(and has 9.9.0 installed); npm'slatestis 9.22.0, with 9.23.0 already tagged upstream but not yet published — 13 published minor versions of drift.This ranks first because it is defect-shaped, not because dependencies should be current for its own sake. v9.13.0 fixed the C++ MDPA reader, which until then threw (
"MDPA: non-sequential node ids are not supported by the C++ reader") on any deck whose node ids were not exactly1..nin file order — and gapped ids are routine in real Kratos decks, since SubModelPart extraction, entity removal and deck merging all leave them.mdpais in this extension'sMESHIO_FORMATS, and the WASM path has no Python fallback, so a genuine production Kratos deck currently fails to open in CAD-Preview. That is a broken capability the extension already claims to have, not a missing one. v9.14.0 then addedmdpa:idpreservation on write, which matters less here — MDPA export goes through this codebase's own hand-writtenmdpaWriter.ts, not meshio++.The bump itself is expected to be a version number plus verification, because the API surface CAD-Preview actually calls is unchanged. Checked against upstream's own
src/wasm/index.d.tsrather than assumed:extractSurface(mesh, recordParentIds?),readMetadata,convertSurface,loadMeshioPlusPlus({ variant })and thecell_data["surface:parent_cell"]field name that the whole region→Parts correlation hangs on are all still present with the same shapes. No JS-facing breaking change exists in the range — the two**Breaking:**callouts between 9.9.0 and 9.23.0 are C++ ABI only (OpenFoamInfogaining a member at v9.20.0,MESHIOPLUSPLUS_ABI_VERSION5 → 6). The one behavioral change worth a glance is v9.16.0's: the signed volume of a cell with non-planar faces changed, affectingstats,cell_measureandclean— none of which this codebase calls, since it computes mesh volume itself inmeshComponents.ts.So the real work is re-verifying this codebase's own meshio++ invariants, every one of which is a documented, previously-hit failure mode:
- Still ESM-only with no
requirecondition, so the loader must stay a dynamicimport()— a static import cannot be represented in the CJS bundle and fails the build outright. variant: "seq"still forced. The package'sresolveVariant()picks the threaded build whenevercrossOriginIsolatedisundefined, which is unconditionally true under Node — so"auto"would eagerly spawn a worker pool in the extension host.- The
.vscodeignorecarve-out still names the right files. It lists exactly four (package.json,src/index.mjs,dist/meshioplusplus_wasm.mjs,dist/meshioplusplus_wasm.wasm); a renamed or relocated artifact breaks packaging silently — it keeps working in development fromnode_modules/and is simply absent from the shipped.vsix. - The loader still self-locates its
.wasmviaimport.meta.url, which is why this is the one WASM dependency deliberately not copied intodist/(unlike OCCT and gmsh, which are handed an explicitwasmBinary). It must stayexternalin esbuild and stay put undernode_modules/. - Console output must still route through
console.log/console.error. The MCP server's stdout is the JSON-RPC channel, and gmsh-wasm 0.2.0's switch to rawfs.writeSync(1, …)is the precedent for exactly this class of regression — it corrupted the protocol stream the instant a model meshed, and noconsole.*rebinding could intercept it. - Measure the packaged size. The sequential artifact is 6.07 MB today; v9.22.0 adds a third pinned, SHA256-checked cross-compiled dependency (cgnslib 4.5.2, beside HDF5 1.14.6 and netcdf-c 4.9.3). It ships inside the
.vsix, so this is a real number to check before and after, not a formality.
Verification is mostly already written.
npm run mcp:smokedrives the meshio bridge end to end against the live WASM (a hand-built.vtktetrahedron loaded, meshed, and exported to MED, CGNS and XDMF including the.h5companion and its rewritten reference), andexamples/MED/two-material-tets.medis the permanent region→Parts fixture. Both must pass unchanged. The import and colour-by-field panels still need an F5 pass, per the standing webview caveat.What the bump unlocks is deliberately out of scope here — each of these is its own future item, listed so the opportunity is on record rather than rediscovered:
convertCells(mesh, mode, recordParentIds?)("linearize"/"simplexify"/"elevate") is the first credible route to closingconvertToStlBoundaryWithRegions' own documented gate. Today, any extracted boundary that is not plain 3-nodetriangleblocks — quads off a hexahedral volume, or a higher-order block — falls back to geometry-only with no region→Parts correlation at all."linearize"drops higher-order nodes;"simplexify"decomposes same-dimension cells into simplices, i.e. boundary quads into triangles. Both acceptrecordParentIds, so the provenance could in principle be chained (triangle → boundary quad → original volume cell) rather than lost, which is exactly what the correlation needs. To be verified, not assumed: that the two provenance arrays genuinely compose, and that the documented"simplexify"throw on a polyhedron block is handled rather than left to surface as an opaque failure.decimate(quadric-error-metric edge collapse) is a candidate answer tomeshHeal.ts'sMAX_HEALABLE_TRIANGLESceiling of 50 000, which today simply refuses an oversized mesh — a real limit for glTF, whose files routinely carry 100k–1M triangles.- CGNS coverage widened twice: v9.21.0 hand-rolled
NGON_n/NFACE_npolyhedral cells into the default and WASM builds, and v9.22.0 cross-compiled cgnslib itself for WebAssembly, reaching ADF-backed containers and CGNS 3.x section layouts that are unreachable from the hand-rolled HDF5 path by construction. - OpenFOAM polyMesh gained a writer in v9.20.0 (the last read-only format upstream), making it a candidate addition to
MESHIO_FORMATS. gradient(v9.10.0) and multi-file / transient datasets (v9.12.0) are plausible feeds for the colour-by-scalar-field overlay — derived quantities such as|grad T|, and time-series scrubbing — though neither has a designed UI here yet.
- Still ESM-only with no
Tier 3 — Webview-only interaction depth
Admission: closes an interaction gap using capabilities the pipeline already has, with no new kernel surface — no OCCT/Gmsh/meshio++ call, no new persisted geometry, no new MCP tool. The cost is concentrated in the webview's own single-view assumptions, not in a WASM binding, so nothing here is gated on a live-WASM probe.
Split view — multiple synchronized viewports over one document (issue #37) (L overall; M for Phase 1 alone). A layout picker (1×1 as today, 1×2 horizontal/vertical, 2×2 quad) that draws the same scene from several cameras at once — the classic CAD quad view: Top / Front / Right / Iso. Orbiting one pane leaves the others alone; the model, edits, parts, selection, and every overlay are shared, because there is only ever one scene.
- This is deliberately not "open two models side by side" — that already works today, with zero code.
provider.ts'sresolveCustomEditorhas no per-provider document singleton (every document gets its own closure and its own webview page;activeSessionis only a "which tab has focus" router for keybindings), and VS Code's own split-editor UI already puts two CAD tabs next to each other. This was investigated and recorded when Model comparison shipped — seeCLAUDE.md's "Model comparison" section. What does not exist is more than one camera onto one scene, which is what this item adds. - The one genuinely-new cross-document variant, if it's wanted, is a separate and much smaller sub-feature: linked cameras across two tabs. Two webviews cannot talk to each other, so it needs a host relay — and
provider.tscurrently tracks onlyactiveSession(a single field, assigned ononDidChangeViewState), not a registry of every open session, so the relay's real cost is that registry plus a decision about what "linked" means when the two documents have different extents. Worth listing as Phase 3 below, not folding into the main item.
The non-negotiable constraint, already on record: exactly one
WebGLRendererand one canvas.orientationCube.ts's own doc comment records that a second WebGL context fails in some environments — that is an existing finding, not a fresh worry, and it is why the gizmo owns no renderer of its own. So panes must besetViewport/setScissor/setScissorTestregions of the single renderer. This technique is already proven in-repo:Viewer.renderGizmo()does exactly that today, including theautoClear = false+clearDepth()discipline needed to overlay a second render into a sub-rect without wiping the frame. Multi-viewport is a generalization of a loop that already runs every frame, not a new rendering strategy.Where the effort actually is:
Viewer's single-camera and single-rect assumptions, spread across a ~1500-line class. Each of these is a specific, known site, not a guess:- Cameras.
activeCamerais one field (over thecamera/orthoCamerapair the Persp/Ortho toggle swaps between), andframe()/resetView()/setViewDirection()/orbit/pan/dollyall read it. Per-pane means N of those pairs, plus a definition of "the focused pane" for every API that still takes no pane argument. - Orbit input.
OrbitControlsis constructed againstrenderer.domElementand cannot tell which pane the pointer is in — it would drive every pane at once. Two viable routes: NOrbitControlsinstances withenabledgated by hit-testing the pointer against the pane rect onpointerdown, or per-pane use of the already-existing, already-unit-tested purecameraControls.ts(orbit/pan/dolly/setDirection), which the stepped view-control buttons already delegate to. The second route is more code but sidesteps N sets of damping state each callingcontrols.update()every frame. - Picking.
onSelectPointerUpbuilds NDC fromrenderer.domElement.getBoundingClientRect()and raycasts withactiveCamera; both must become pane-relative. Same foronGizmoPointerDown's corner hit-test — decide whether every pane gets its own orientation cube or only the focused one. - The transform gizmo.
TransformControlsis likewise constructed againstactiveCamera+domElement. Its.camerais a genuinely reassignable accessor (verified against the installed three.js source when the gizmo shipped — seeCLAUDE.md's "Transform gizmo" section), so retargeting it to the focused pane is cheap; what needs a decision is a drag begun in one pane while the pointer crosses into another. - Resize.
onResizesets one aspect on both cameras fromcontainer.clientWidth/Heightand callsrenderer.setSize. Per-pane aspect comes from the pane's own rect, and the orthographicleft/rightderive fromorthoHalfHeight * aspect, so each pane's ortho frustum is independent. - Per-frame camera-derived scaling. The measurement label is rescaled every frame from
activeCamera(distance under perspective, frustum height under ortho) to hold a constant on-screen size — with N cameras there is no single correct scale for one shared sprite.pickThreshold/pointSpriteScaleare model-radius-derived inframe()and are camera-independent, so they are unaffected.
Highest-risk unknown, worth probing before committing to the estimate: the clip cap and the stencil buffer.
clipCap.tsmarks the cut face by rendering boundary geometry into the stencil buffer, and the renderer is constructed withstencil: trueprecisely for it. Per-pane rendering undersetScissorTest(true)needs the stencil cleared per pane, andrenderGizmo's existing overlay pattern clears depth only. This matters more than the usual unknown because the documented failure mode is silent — get the stencil state wrong and the model simply renders uncapped/hollow with no error anywhere. Probe: a clipped model in a 2×2 layout, checked visually in all four panes.Two shared-surface decisions that are scoping choices, not bugs:
- The markup overlay is one
<canvas>absolutely positioned over the whole#app, and its strokes are screen-space pixels with no pane awareness. Treating markup as whole-canvas (spanning panes) is the simpler and arguably more correct reading of a screen-space review annotation; per-pane markup would need the stroke model to carry a pane id. - Screenshots capture the whole canvas.
captureScreenshotBase64readsrenderer.domElement, so a split-mode screenshot is the whole grid — fine, and probably desirable, interactively. ButrenderService.ts's headless harness drives the sameViewerthroughrenderViewRequestand expects one framed view per call, so single-pane must remain the default state of a freshly-created viewer; the harness posts no layout message and must keep getting 1×1.
Persistence needs an additive
.view.jsonextension, not a rewrite.ViewStateis exactly one camera's state (viewDirection/cameraUp/orthographic/displayMode/clip) atVIEW_STATE_SIDECAR_VERSION1. The natural shape keepsviewas the focused/single-pane state — so a sidecar written by a split-aware build still restores sensibly in a build without it, and vice versa — and adds an optionallayoutplus a per-pane array beside it. Per this codebase's tolerant-parse convention, purely additive optional fields need no version bump; changing whatviewmeans would. The five per-document file watchers already reconcile.view.jsonon external change, and that path has to keep working.The strongest scoping recommendation, and the thing that keeps this L rather than XL: only camera state is per-pane. There are roughly a hundred
viewer.*references inmain.ts, essentially all written against a single implicit "the view". The large majority are display or document state — display mode, clip plane, colours, part visibility, the FE-mesh/worst-element/colour-field overlays, selection highlight — which are genuinely global to the document and should stay global. Only direction/up/ortho/zoom become per-pane in v1. Making display mode or clipping per-pane multiplies the surface for little gain and should be an explicit follow-up, if ever.Suggested phasing:
- Phase 1 (M) — pane layout, N cameras, per-pane orbit/pan/zoom, per-pane resize/aspect, picking routed to the pane under the cursor, focused-pane concept. Ship 1×1 and 2×2 only; no persistence, no new UI chrome beyond a temporary toggle.
- Phase 2 (S–M) — the
.view.jsonextension above, plus a real layout picker in the View ▾ menu. Note two standing costs this triggers: the picker's icons go through the generated TikZ→SVG pipeline (icons/tikz-ui/, rebuilt per-id, never a baremake ts), and any change to the sharedviewerDom.tsmarkup means re-runningnpm run docs:screenshotsand visually inspecting the result, perCLAUDE.md's screenshot rule. - Phase 3 (S, optional) — linked cameras across two tabs via the host relay + session registry described above.
No kernel work and no MCP tool. This is a display/interaction feature with no headless equivalent, matching the precedent already applied to Display modes, Markup, Measurement, and SVG import. The agent-facing need it resembles is already met:
render_snapshot'sDEFAULT_VIEWSpacket is two opposed isometrics plus TOP and FRONT — quad view is, in effect, the interactive counterpart of a view packet the codebase already chose once.Verification will be mostly F5-only, the standing caveat for every webview-only feature here. The one genuinely unit-testable piece is the pure layout math — pane rects from a container size, and the inverse mapping from a pointer position back to a pane — which should be extracted into its own DOM-free module, following the
clipping.ts/cameraControls.tsprecedent of keeping the math testable and leaving only the three.js application inviewer.ts.- This is deliberately not "open two models side by side" — that already works today, with zero code.
Tier 4 — MCP agent-workflow ergonomics
Admission: closes a friction point documented in real-world agentic CAD usage elsewhere, using capabilities the MCP pipeline already exposes — persistence or tooling additions on top of mcpTools.ts/the sidecar-store pattern, not a new kernel capability. None of these needs a live-WASM probe; each composes existing, already-verified primitives (parametricScript.ts's compiler, measureExact, fileRouter.ts's routeFile, mcpSidecars.ts's store convention).
Prompted by a MakeUseOf write-up of a FreeCAD-via-Claude-Code workflow (source). FreeCAD's own MCP integration is architecturally different from this one — a persistent, session-scoped document inside a running FreeCAD process, driven over its Python API, versus this codebase's stateless-per-call design where every tool call is explicit-path-in/sidecar-as-state — so most of the piece's specifics (its CAM-toolpath forks, its --only-text-feedback screenshot-budget flag) don't transfer directly; the latter in particular is already this codebase's own default posture (inspect/measure/measure_exact are fact-only with no image cost at all, and render_snapshot's images are opt-in and explicitly framed as "diagnostic, not authoritative" per describeCapabilities()'s verdictConventions — see CLAUDE.md's "Agent feedback tools" section). Three gaps the piece named do transfer, reframed for this codebase's own architecture rather than copied verbatim.
Saved, named, parameterized scripts — a persistent macro library (issue #14) (M headless; +S–M for the interactive half). The article's sharpest complaint: FreeCAD-MCP agents have "no persistent skill library across sessions" and must "re-derive the same bolt pattern logic every time" — a real cost for exactly the repetitive-but-parametric jobs (mounting plates with bolt patterns, brackets with fixed hole spacings) it also calls out as the workflow's strongest fit. Issue #14 asks for the same capability from the other side of the tool: macros to automate repeated work interactively. Both land on one mechanism, which is why they are one item.
src/parametricScript.tsalready compiles a declarative{variables?, steps}document with arepeatloop and a full expression evaluator (paramExpr.ts) — it is missing only a name and a place to persist it across calls. That is the whole gap: there is no macro language to design, and deliberately so. Recording a macro is likewise not a new capture mechanism —<model>.edits.jsonis already an ordered, replayable op list, so "record" is a selection over ops the user has already applied, and "play" is the compile-and-append path that already exists.- A script's own
variablesblock is already its parameter list — no new schema needed.ScriptLibraryEntry {name, description, script: ParametricScript}is the whole shape; calling a saved script with overrides is just merging caller-supplied{name: value}pairs onto the savedvariablesarray by name before compiling (an unknown override name degrades to awarningsentry, never a hard failure — this codebase's standing per-field-tolerant convention, not a new one). - Storage is one explicit JSON file, not a hidden per-workspace convention. This codebase has no notion of a workspace root inside
mcpTools.tstoday (every path is caller-supplied), and a script isn't tied to one CAD document the way.edits.jsonis tied to one source file — so the natural fit is a singlelibraryPathJSON file the caller names explicitly (e.g. checked into a project alongside its models), holding{version, scripts: {name: entry}}, parsed with the same tolerant-drop-malformed-entries discipline every other sidecar in this codebase uses. A newsrc/scriptLibrary.ts(pure, vscode-free, unit-tested) owns parse/serialize;mcpSidecars.tsgains the Node-fs read/write, mirroring the established pure-module/impure-store split. - Three new tools, all thin wrappers over existing machinery:
save_parametric_script(libraryPath, name, description, script)compiles the script against its own declared variable defaults first (a dry run, nomodelPath/geometry needed) and refuses to save one that doesn't compile — a broken macro should never make it into the library silently.list_parametric_scripts(libraryPath)returns each entry's name/description/parameter names+defaults, so an agent can discover what's available without reading the raw JSON.run_saved_script(libraryPath, name, modelPath, parameterOverrides?, ops-and-dryRun params identical to run_parametric_script)loads the entry, resolves overrides, and hands the merged script straight to the exact same compile-and-apply pathrun_parametric_scriptalready uses — no second B-rep-only gate, no second entity-rebinding call site, just a different source for the script document. - The interactive half (issue #14's own framing) is a panel over the same library file, not a second mechanism. A "Macros" sidebar section listing each saved entry with its parameters, a value field per parameter, and a Run button that pushes the compiled ops onto the existing
EditsModelstack — so a macro is undoable, inspectable in the history, and removable op-by-op exactly like any hand-applied edit, with no special "macro" state for undo/redo to reason about. Record is a selection over the current op list (check the ops to keep, name it, save) rather than a live capture session, which avoids inventing a recording mode and makes "record" work retroactively on edits already applied. The one genuine design decision: whether recording should offer to promote a literal to a parameter (turn a hard-coded20into aLvariable). Recommendation — yes, but as an explicit per-field opt-in in the save dialog, never inferred, since guessing which numbers are meant to be parametric is exactly the kind of silent wrong assumption this codebase avoids elsewhere. - Deliberately NOT a new interactive persistence convention: the panel reads and writes the same caller-named library JSON the MCP tools use, so a macro recorded by a human is directly runnable by an agent and vice versa — the same interoperability property the parts/edits/mesh sidecars already give the two surfaces.
- Verification: unit tests for
scriptLibrary.ts's parse/serialize/merge-overrides (pure, no WASM), plus anmcp:smokeaddition mirroring the existing bolt-circle script check — save a parameterized bolt-circle macro, list it back, run it twice with differentradius/countoverrides against a fresh copy ofbull.stpeach time, and confirm the two results differ by exactly the parameter change (same(R·cos, R·sin)position check the existingrun_parametric_scriptsmoke coverage already does, run through the saved-script path instead of an inline script).
- A script's own
Tolerance-band fact checks on exact measurements (S–M). The article separately names "close tolerance relationships and GD&T annotations" as still hard to specify. Full GD&T (datums, flatness/position/profile callouts, ASME Y14.5 semantics) is modeling-application scope this project has already rejected elsewhere (see Non-goals' "Interactive sketching with geometric constraints") — but a much narrower slice, a nominal value plus a tolerance band checked against an already-exact measurement, is pure arithmetic on top of the closed
measure_exacttool and needs no new kernel surface at all.check_tolerance(kind, entityIdA, entityIdB?, nominal, tolerancePlus, toleranceMinus?)(toleranceMinusdefaults totolerancePlus, i.e. symmetric ± when only one is given) calls the existingmeasureExactpipeline function unchanged, then computesdeviation = measured − nominalandwithinTolerance = deviation ≤ tolerancePlus && deviation ≥ −toleranceMinusas an additional fact field alongside the raw measured value — never phrased as a pass/fail verdict in the tool description, matchingcheckInterference'shasOverlap-as-fact precedent and this codebase'sverdictConventions(tools report facts; the agent — or, transitively, the person it's answering to — renders the judgment).- Phase 2, optional and webview-only: extend the already-persisted
Annotationshape (the closed "Persisted, topology-anchored annotations" feature) with an optionaltolerance?: {nominal, plus, minus}, surfaced as a small inline field next to the Measure panel's existing 📌 Pin button — so a human reviewing the model can record the same nominal-plus-band intent interactively, with the annotation's existing detached/struck-through handling covering a tolerance-carrying pin whose anchor entity is later lost exactly the same way it already covers a plain one. This half needs the standing F5-only verification everyprovider.ts/webview change in this codebase needs; the MCP tool alone (phase 1) is fully headless-testable viamcp:smoke.
list_workspace_models— headless CAD-file and sidecar discovery (S). FreeCAD's MCP integration is session-scoped — a running FreeCAD process holds "several open documents" as real, statement-carrying state, and the article names losing track of them as a friction point. This codebase's MCP tools are already stateless and path-explicit per call, so there is no analogous "open documents" state to lose — but a long agent conversation spanning many files in a project still pays a real, avoidable cost re-deriving CAD-Preview's own format/sidecar rules by hand (shelling out tofind, then re-implementing whatrouteFile()already knows about which extensions are openable and what a.edits.json/.parts.json/.mesh.json/.view.json/.annotations.jsonsidecar means).- Given a folder path, walks it (depth-capped, file-count-capped — any cap that's actually hit is reported via
log/warnings, per this codebase's no-silent-truncation convention, never a quietly-partial list) and returns every filerouteFile()recognizes, each with its detected format/strategy (occt/ mesh / meshio-only) and which of its five possible sidecars currently exist next to it. - Deliberately not a session/state feature — every other MCP tool call remains fully self-contained and explicit-path-in, exactly as today; this is pure, stateless discovery layered on top of
routeFile()+mcpSidecars.ts's existing sidecar-path derivation, with no new state anywhere and no interaction with the kernel-worker child process at all. - Verification: unit-testable almost entirely (a fake/temp directory tree,
routeFile()already unit-tested, sidecar-path derivation already pure) plus onemcp:smokecheck against the fixture directories this codebase already ships (examples/STP/,examples/STL/, …) confirming the returned format/sidecar-presence set matches reality.
- Given a folder path, walks it (depth-capped, file-count-capped — any cap that's actually hit is reported via
Tier 5 — New model-layer concepts
Admission: adds a genuinely new concept to the document model — a new persisted entity, or a new class of derived information — rather than closing a gap in an existing one. Both items here are phased so that the cheap, independently-useful phase can ship alone, and both have a phase that needs a live-WASM probe before it can be estimated.
Arbitrary and reusable construction planes (issue #36) (L overall; S for Phase 1 alone).
First, the correction that changes the shape of this item: arbitrary planes already exist throughout the kernel and the op model. Every plane-bearing edit op takes an explicit
Vec3pair, not an axis enum —mirror,splitByPlaneandsectionall take{planePoint, planeNormal}, and every 2D profile (addCircleProfile,addRectangleProfile,addPolygonProfile,addEllipseProfile,addRoundedRectangleProfile,addSlotProfile,addTrapezoidProfile,addArc,addEllipseArc) takesnormalplus, where phase matters,up.alignis the only op with an axis enum. So "add arbitrary planes to the edit ops" would be wrong work. The three real gaps are elsewhere:- (a) The display clip plane is axis-locked — and only there.
planeForAxis(axis, offsetFrac, box)derives the normal from an"x" | "y" | "z"literal,ViewState.clippersists{axis, offsetFrac},viewStateSidecar.tsvalidates against the three literals, and the UI is threedata-axisbuttons. - (b) There is no way to derive a plane from picked geometry.
facePlaneexists but is host-side only; no protocol message carries a plane to the webview, and the webview constructs exactly oneTHREE.Planein the whole codebase (inplaneForAxis). Three-point plane construction exists nowhere, host or webview. - (c) There is no named, reusable, persisted plane. No sidecar stores one; every op carries its own inline, independently-typed copy. Sketching six profiles on one plane means typing the same
normal+upsix times. The only available indirection is per-scalar parametric variables, which have no plane semantics and no coherence check.
Phase 1 (S) — arbitrary clip normals. By far the best cost/benefit, and almost entirely a type-widening exercise: everything below
Viewer.setClippingPlane(plane: THREE.Plane | null)is already orientation-agnostic, includingcapCenterAndSize(plane, box)(which already takes a genericTHREE.Plane, not aClipAxis) and the entire stencil clip-cap. WidenViewState.clipto carry an optional explicit normal beside the existingaxis, keeping the axis form as the legacy shape so an older.view.jsonstill loads — purely additive optional fields need no version bump under this codebase's tolerant-parse convention. The one thing to probe rather than assume is the stencil clip cap under a non-axis-aligned normal:clipCap.tsis not unit-tested (verified F5 + throwaway-Playwright only), and its documented failure mode is silent — get the stencil state wrong and the model simply renders uncapped, with no error anywhere. Same class of risk as the split-view item's own stencil note, and worth probing once for both.Phase 2 (S–M) — derive a plane from picked geometry, the piece that makes Phase 1 usable rather than merely more general (typing a normal vector by hand is not what "clip along this face" means). Two sources, and they differ sharply in cost:
- From a picked planar face — nearly free once item 1's second bullet restores the plane origin
inspectcurrently discards.facePlanealready returns exactly{pt, nl}. - From three picked vertices — needs something that genuinely does not exist: point selection currently feeds nothing but Parts and Annotations. Every op-draft consumer filters for
volume/surface/lineonly, and no op resolves apoint-Nid to a coordinate on either side of the boundary. The mechanism is closer than that sounds, though —main.ts'scollectSnapPoints()already harvests live world-space positions frompoint-Nsprites for the snapping feature; it collects every point rather than the selected ones. Filtering that by the current selection plusTHREE.Plane.setFromCoplanarPointsis the whole implementation.
Phase 3 (M) — a named, persisted plane entity, which is what makes this a Tier 5 item rather than a Tier 3 one. A
plane-Nid namespace and a sixth sidecar (<model>.planes.json, following the five existing ones' tolerant-parse shape), with plane-bearing ops able to reference a plane id instead of inline vectors. The design question to settle first, because it determines whether this interacts with entity-id rebinding at all: store the resolved vectors, or a live reference to the face the plane came from? Recommendation: store resolved vectors, with the provenance (which face it was derived from) recorded as optional metadata only — matching the conventionalign'stocoordinate already established deliberately ("store resolved values, not live references"), and keeping planes entirely out ofentityRebind.ts's id-drift machinery. A live face reference would need rebinding on every topology-changing op, for a feature whose whole value is stability.This does not reopen the sketching non-goal. A construction plane is a placement for the parametric profile ops that already exist — it removes retyping and enables "clip along this face", and every op it feeds still takes numeric, expression-capable fields. It adds no click-to-draw geometry and no constraint solver, which is what Non-goals rejects and why that rejection stands.
Two smaller findings worth recording here so they aren't rediscovered: what AutoCAD calls a UCS is a plane plus an in-plane frame, and this codebase already has both conventions —
inPlaneBasis(normal, up)(deterministic, user-anchored) andplaneBasis(axis)(arbitrary perpendicular).sectionSolidscurrently uses the arbitrary-phase one, which is fine for a trimmed boolean result but would needinPlaneBasissemantics for any work plane meant to carry a stable 2D coordinate frame. Andgp_Plnis used nowhere in this codebase — every face is built via theBRepBuilderAPI_MakeFace_15(wire, true)wire overload, so the plane overload is unprobed should a true infinite-plane primitive ever be wanted.- (a) The display clip plane is axis-locked — and only there.
Decompose imported geometry into parametric primitives (issue #34) (L overall; S–M for Phase 1 alone). Feature recognition — take an imported STEP whose geometry is opaque, identify that a solid is a box or a cylinder, and re-express it as a primitive whose side length or radius is a named variable the user can then edit.
The parametric half is already complete end to end — variables, per-op
exprs,resolveEditOps, andset_variables(which already writes ops and variables together) all exist and would need no change. Emitting{op: "addBox", size: [20,10,5], exprs: {"size[0]": "L"}}alongside aParamVariable{name: "L", …}is already a supported shape. What is missing is (a) deriving primitive parameters from a B-rep at all, and (b) any way for ops to replace the source geometry rather than add to it.Phase 1 (S–M, independently valuable) — return the surface parameters
inspectalready computes and discards.entityFacts.tsreadsBRepAdaptor_Surface_2(face, true).GetType(), maps it to aSurfaceTypelabel, and deletes the handle — sosurfaceType: "cylinder"tells a caller a face is cylindrical and nothing else: no radius, no axis direction, no axis location, no cone half-angle, no torus major/minor. Adding them is one accessor call per face on a handle that is already in scope. This needs a live-WASM probe before it can be estimated, per this file's own rule:.Cylinder(),.Sphere(),.Cone()and.Torus()are called nowhere in this codebase, so their overload names, return-handle shapes, and sub-accessor names are entirely unrecorded.BRepAdaptor_Surface,gp_Cylinder,gp_Cone,gp_Sphere,gp_Torusandgp_Plnare all green in the manifest, which — as ever — is necessary but not sufficient. The probe and its verification bar both already have a precedent to copy:facePlane's.Plane()→Location()/Axis().Direction()is the shape (note every sub-accessor returns its own handle needing.delete()), andmeasure_exact's radius path sets the bar — not "the calls don't throw" but a round trip against known parameters:addCylinder(radius: 3, axis: A)→inspectits lateral face → assert the radius is exactly3and the axis matchesA. Worth probingBRepAlgoAPI_Defeaturingin the same pass — green, completely unused here, and exactly the "remove a recognized feature" primitive a later phase would want.Phase 2 (M) — a read-only recognition report, facts only, emitting no ops. Per solid: the face inventory by surface type, a candidate primitive fit, and the fit residual (maximum deviation between the fitted primitive and the real faces). The residual is what keeps this a fact rather than a verdict, per
describeCapabilities()'sverdictConventions— a caller decides whether 0.002 mm of deviation means "this is a cylinder". This phasing is not a general preference, it is the same rule the closed "Mesh → B-rep promotion" item was held to, for the same reason: a mis-recognized primitive that silently replaced real geometry would feedget_mass_properties/measure_exactconfidently-wrong numbers — the same misleading-false-result failure mode that gated mesh promotion behind a report, and thatcompare_modelsavoids by publishing its match residuals instead of a verdict. Natural home: extendcollectAllEntitySignatures, which already performs exactly the right deterministic per-entity walk in the right id order but records only{id, kind, centre, measure}.Phase 3 (L) — actually emit ops and variables. Two structural blockers, neither of them a matter of effort:
AddBoxOphas no orientation at all — it is{center, size}, full stop. A recognized box that is not axis-aligned cannot be expressed as one op; it needsaddBox+ a follow-onrotate, or a new oriented-box op. Related trap:centerdoes not mean the same thing across primitives — box/sphere/torus take the geometric centre, while cylinder/cone/prism/wedge take the base centre, so a fitted cylinder's axis location must be converted rather than passed through.- Nothing in the op model can replace the base shape.
applyEditsBRepfolds ops overreadShape(sourceFile); all 44 op kinds either append or operate on solids already present; there is no "empty base" concept, sincerouteFile()is a pure function of file extension computed once per document-open. Two viable shapes, both new: a newTOPOLOGY_CHANGINGop kind that suppresses base solids (which then has to interact withentityRebind.ts), or thepromote_mesh_to_brepmodel — analyze, and write a brand-new file whose content is the recognized primitives. The export model is the recommended v1, and for exactly the reason recorded when mesh promotion made the same call: it sidesteps in-place reclassification entirely, and the written file is an ordinary document from the moment it exists. - Worth noting: no code path anywhere in this repo currently produces an expression string from geometry.
exprsis only ever authored by a human typing in the panel, by an agent's raw JSON, or by a hand-edited sidecar — andparametricScript.tsdeliberately does the opposite, baking and strippingexprsafter compiling. The plumbing needs no change, but this would be its first programmatic producer.
Tier 6 — Externally gated
Admission: the work is understood and scoped, but is blocked on a decision or an event outside this repository — a license choice, an upstream merge, or a dependency that does not yet exist. Listed so the blocker is on record and re-scoping is unnecessary when it clears; not estimable until it does.
Open OpenSCAD (
.scad/.csg) files (issue #35) (M for the recommended path; L and license-affecting for the maximal one).Three genuinely distinct approaches, in ascending cost. The recommendation is (a) plus (b); (c) should not be chosen by default.
- (a) Parse
.csgonly — no new dependency, no license change. OpenSCAD's.csgexport is the fully evaluated model: loops unrolled, modules inlined, variables literalised,ifbranches collapsed, every transform emitted as an explicit 4×4multmatrix. There is therefore no language to implement — FreeCAD's own.csglexer has 33 reserved words and no arithmetic, noif/for, no variables, because none survive into the format. Most nodes (cube,sphere,cylinder,polyhedron,union,difference,intersection,group,multmatrix) map directly onto primitives and booleans this codebase already has, with graceful degradation on the rest — the same convention every unresolvable operand in this codebase already follows. - (b) Shell out to a user-installed
openscadbinary for.scad→.csg, then (a). Still zero bundled megabytes and no license propagation — a separate process is mere aggregation, not linking. Degrades to a clearsupported: falsewhen the binary is absent, structurally identical to howrender_snapshotalready handles a missing Playwright. This is precisely FreeCAD's own architecture. - (c) Bundle
openscad-wasm. Technically attractive: it is under the officialopenscadGitHub org, actively committed to, ships a dedicated Node build, needs noSharedArrayBuffer, and drives through an Emscripten MEMFS plus a literalcallMain(argv)— structurally identical to how this codebase already drives Gmsh. But it costs roughly 8–14 MB uncompressed (plus ~8 MB more iftext()is ever to work), and it permanently upgrades the shipped.vsixto GPL-3.0-or-later. CGAL is GPLv3+/LGPLv3+ with no GPLv2 option, and Manifold is Apache-2.0, which the FSF holds GPLv2-incompatible; OpenSCAD's own playground documents shipping its build as GPLv3 for exactly these reasons. This is not a conflict for a GPL-2.0-or-later project — the "or later" grant is precisely the escape hatch — but it is a one-way door, and it would be the first bundled dependency to force it (gmsh-wasm is GPL-2.0-or-later and OCCT is LGPL-2.1-with-exception; both are GPLv2-compatible today). See item 10, which converges on the same decision from a different direction.
One correctness decision must be settled before any option, and getting it wrong reproduces the same misleading-false-result failure mode item 8 describes. OpenSCAD's
cylinderis a faceted prism, not an analytic cylinder.$fn/$fa/$fssurvive into.csg, and the common default$fn = 0means unresolved — the consumer must reimplement OpenSCAD's own segment-count rule from$fa/$fs. Mapping a$fn = 10cylinder onto an exactBRepPrimAPI_MakeCylinderyields a solid that silently disagrees with OpenSCAD's own STL by the chord error, andget_mass_properties/measure_exactwould then report confidently-wrong numbers. FreeCAD's answer is an explicit dial — auseMaxFNpreference (default 16: below it build the real polygon, above it treat as circular) — worth copying, though note its default silently favours exactness over fidelity.Other limits to record now rather than discover later:
.csgprints at default stream precision (~6 significant figures), a hard accuracy ceiling on anything reconstructed from it, and rigid transforms come back with float noise (rotate([90,0,0])→2.22045e-16), so exact axis alignment must be recovered with a tolerance. Amultmatrixis not guaranteed rigid, sincescale/mirrorcollapse into it. Module names are destroyed — every user module becomes an anonymousgroup().hullandminkowskihave no OCCT equivalent at all (FreeCAD round-trips them through the binary and accepts triangles).text()carries a font name, not outlines — and the official WASM build ships with "no support for fonts".import/surfacereference external files, so.csgis evaluated but not self-contained. And the format is documented poorly, versioned inconsistently, and framed by its own man page as "mainly used for debugging and testing" — treat it as a de-facto format with no stability guarantee.Ruled out, with evidence: writing a JavaScript OpenSCAD evaluator. Every prior attempt is dead — the JSCAD translator is deprecated and its own designated successor is also deprecated — the one live from-scratch effort is Rust with acknowledged silent divergences from the reference implementation, and OpenSCAD's
$-variable dynamic scoping alongside ordinary lexical scoping is a documented design landmine that upstream contributors themselves call one of the language's worst mistakes. Upstream also closed its own bytecode-interpreter rewrite as not planned. This is the same "shipping the weak two-thirds of the feature" reasoning that rejected a constraint solver in Non-goals.- (a) Parse
Robust volumetric meshing from a skin mesh (issue #6) (M for Phase 1; L and blocked for Phase 2).
The framing correction that matters most: this already works today for clean input.
generate_meshon an STL/OBJ/PLY/glTF source already produces a tetrahedral volume mesh —gmshService.tsrunsclassifySurfaces(stlAngle)→createGeometry()→addSurfaceLoop→addVolumeand meshes it in 3D. The gap is robustness, not capability: that pipeline needs a watertight, manifold, reasonably clean boundary, and it fails or produces garbage on the meshes people actually have — scans, sculpts and print-ready STLs with holes, self-intersections, non-manifold edges and duplicated geometry. The diagnostic half is already done too:check_mesh_healthreports free edges, non-manifold edges, degenerate faces, and whether an OCCT sewing ladder closes. What is missing is the repair path between the diagnosis and the mesher.Status of the route named in the issue, verified against the checkout at
/home/vicente/src/meshioplusplus:- SDF is complete and JS-reachable — but on an unmerged branch.
computeSdf,sampleDistance,distanceToSurface,surfaceWatertightCheck,gridandvoxelizeare all insrc/wasm/index.d.tswith full options (voxel and octree structure, unsigned / pseudonormal / winding-number signing, narrow band), and v9.25.0's changelog calls it "Signed distance fields, completed." It lives on local branchsdf, 38 commits ahead of master and not merged. So this item is gated on that merge and on item 2's version bump — which is why it is last, not because the work is unclear. - MMG is entirely absent from meshio++. No dependency, no WASM target, no build-system reference, no TODO, no roadmap entry. The only two occurrences in the whole repository are an unrelated analogy in the changelog and a 2021 comment inherited from Python meshio about medit keywords. There is no WASM MMG to call. Cross-compiling it would follow meshio++'s own pinned-dependency pattern (HDF5, netcdf-c, cgnslib) but is upstream work in a different repository, not work here.
- meshio++ has no surface → volume operation of any kind, and says so plainly in its own roadmap: "Every operation transforms a mesh you already have; nothing creates one."
simplexifynever raises topological dimension;extractSurfacegoes the other way. - Licence check needed before anything is bundled: MMG is (to be confirmed) LGPL-3.0-family, which — exactly like item 9's option (c) — would be GPLv3-compatible but not GPLv2, forcing the same one-way
.vsixlicense upgrade. Two independent roadmap items converge on the same decision; it should be made once, deliberately, rather than twice by accident.
Phase 1 (M, gated only on the SDF merge + item 2) — repair, then reuse the existing mesher. No MMG required, and this is the part worth doing first. An SDF is watertight and manifold by construction, regardless of how broken the input was: holes close, self-intersections resolve, and the winding-number signing mode handles the genuinely ugly cases. So
computeSdf→isosurfaceat level 0 already yields a clean closed triangle surface — which is exactly what the existingclassifySurfaces/addSurfaceLoop/addVolumepath needs. The whole feature is:check_mesh_healthsays the boundary is not closed → offer an opt-in repair pass → hand the repaired surface to the pipeline that already works. Opt-in and clearly reported, never automatic, because the cost is real: an SDF resamples at a grid resolution, so sharp edges round off and the result is an approximation of the input, not the input — which must be stated in the output rather than silently applied.Phase 2 (L, blocked on MMG existing as WASM at all) — true level-set / anisotropic remeshing for genuine control over element quality and grading, rather than accepting whatever the isosurface resolution produced.
A cheaper alternative already reachable at merge time, worth recording:
voxelize(fill="inside"), orcomputeSdf+crop, yields a hexahedral voxel occupancy mesh directly — a staircase approximation rather than a conforming mesh, but a legitimate answer for some FEM workflows, and it needs nothing beyond the merge.- SDF is complete and JS-reachable — but on an unmerged branch.
Non-goals / known constraints
Writing the CAD source file — never. The read-only invariant (sidecar persistence, export-only baking) is architectural, not a missing feature.
OCCT in the webview — the kernel stays in the extension host; the webview runs only Three.js (architecture invariant).
G-code preview — considered (text-to-cad ships a strong implementation: layer scrubbing, feature-type coloring, adaptive decimation) and rejected: 3D-printing-oriented, outside this extension's CAD/FEM domain.
URDF/SDF robotics rendering — considered (joint articulation, FK, MoveIt2 integration in text-to-cad) and rejected for the same reason.
Interactive sketching with geometric constraints — considered and rejected, not deferred. It is the single clearest "this is a modeling application now" feature, and CAD-Preview is a preview/inspect/prepare tool. More concretely: the numeric profile and curve forms are not a degraded mouse — they accept parametric variable expressions (
L*2,R*cos(i*360/N)) that a click-to-place tool cannot express, so replacing them with drawing would trade away a distinguishing capability for a familiar one. Worth noting that SketchForge, a dedicated sketch application, still has no constraint solver either — building this would mean shipping the weak two-thirds of the feature.Reference-image tracing underlay — rejected. Its value is almost entirely to sketching, which is a non-goal above; and it would punch a hole in a real design property: every texture in the webview is a procedurally-drawn
CanvasTexture(geometryBuilder.ts'sdotTexture(),labelOverlay.ts, the generated-SVG icon pipeline), a deliberately asset-free design driven by the webview's CSP. Injecting a user-supplied data-URL image trades that away for a workflow the tool doesn't have.2D vector technical drawings via hidden-line removal — kernel-blocked, not deprioritized. Every
HLRBRep_*class is red in this OCCT WASM build:HLRBRep_Algo,HLRBRep_PolyAlgo,HLRBRep_HLRToShape,HLRBRep_PolyHLRToShape.HLRAlgo_Projectoris green but is a low-level internal only reachable through them. Same class of finding asMessage_ProgressRange_1andInterface_Static.CVal— recorded here so it isn't re-proposed.The narrow survivor,
HLRAppli_ReflectLines, has now been probed against the live WASM, and it is not the way in either. The binding genuinely works — the unsuffixed constructor takes aTopoDS_Shape, andSetAxes/Perform/GetResultare all bound and functional (249 ms onbull.stp, returning a non-null compound this codebase's ownenumerateEdgesreads as 25 edges). It was rejected on the drawing itself, which is only visible by looking at it: rendered side by side against a tessellation-derived silhouette of the same view,GetResult()produced the outer boundary and a few fragments while missing the part's circular holes and interior cutout entirely.GetResult()returns reflect lines only; sharp feature edges live behindGetCompoundOf3dEdges(type, …), whosetypeargument is anHLRBRep_TypeOfResultingEdge— from the entirely-red family above — so calling it throws. The one filter that would make the kernel path competitive is unreachable. SVG silhouette export shipped anyway, built on triangle adjacency instead (silhouetteEdges.ts), which additionally works for STL/OBJ/PLY/glTF sources thatHLRAppli_ReflectLinescould never have handled — seeCLAUDE.md's "SVG silhouette export" section. Hidden-line drawings remain out of reach; an outline no longer is.3D text, engraving, and embossing — kernel-blocked.
Font_BRepFontandFont_BRepTextBuilderare both red in this build, so there is no path from a glyph to aTopoDS_Shape.Parametric part generators (gears, threads, springs) — rejected. Standard parts are something this tool should source, not author:
search_standard_parts/download_standard_partalready fetch real, verified geometry from step.parts as ordinary STEP files the existing pipeline opens (and, since the closed "Standard-parts browse and insert panel" item, so does the interactive sidebar). Authoring involute gear profiles is modeling-application scope.