Documentation site and CI workflows

An mdBook under docs/book: a user guide (getting started, UI tour, sketching, features,
parameters, components and joints, sheet metal, plastic, files, shortcuts, MCP) and a developer
guide (CONTRIBUTING, architecture, command engine, kernel boundary, persistent naming, test
harnesses, and the existing docs/*.md pages). `cargo xtask book` generates the command reference
from `solvecraft-cli commands` and renders the screenshots with `solvecraft-cli snapshot`, then
runs mdbook. .github/workflows/pages.yml publishes the book at /solvecraft/ and the web app at
/solvecraft/app/ (skipped while the repository is private). .github/workflows/ci.yml runs
`cargo xtask ci` on pull requests. README and Help → Documentation link to the book.

Fixes #28
Fixes #8

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-09 13:25:38 +00:00
1 parent 2c3fec087e
commit 0d7e456bc1
35 files changed
+792 -5

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
# Every pull request must pass `cargo xtask ci`: fmt, clippy -D warnings, tests in release mode,
# the asset and layer checks, and the wasm build check.
name: CI
on:
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo xtask ci
+65
View File
@@ -0,0 +1,65 @@
# The documentation site on GitHub Pages: the book (docs/book) at /solvecraft/ and the web app
# at /solvecraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
# repository is private.
name: Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
MDBOOK_VERSION: v0.5.4
TRUNK_VERSION: v0.21.14
jobs:
build:
if: ${{ !github.event.repository.private }}
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
- name: Install mdbook and trunk
run: |
mkdir -p "$HOME/.local/bin"
curl -sSL "https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
curl -sSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Build the book
run: cargo xtask book
- name: Build the web app
working-directory: apps/solvecraft-web
run: trunk build --release --public-url /solvecraft/app/
- name: Assemble the site
run: |
mkdir -p site
cp -r target/book/. site/
cp -r dist/web site/app
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: site
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
+5
View File
@@ -18,6 +18,11 @@ Generated-in-code assets are original and have no file to list:
| File | Author | Source | Licence |
|---|---|---|---|
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
| docs/book/src/generated/sample-plate.png | SolveCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/bracket.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/enclosure.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/revolve.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/sheet-metal.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
| crates/sketch/fonts/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/fonts/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
## Code adapted from sibling projects
+6
View File
@@ -8,6 +8,10 @@ storytold "craft" family.
![SolveCraft with the sample plate](docs/screenshots/sample-plate.png)
**Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide,
developer guide and command reference; sources in [docs/book](docs/book)), and the
[web app](https://bherbruck.github.io/solvecraft/app/) to try it in the browser.
**Status: early but usable for simple parts.** 76 of 76 reference parts built in Fusion
rebuild in SolveCraft with the same volume, area and topology ([docs/oracle.md](docs/oracle.md));
command parity with Fusion's Design workspace is tracked in [docs/parity.md](docs/parity.md).
@@ -90,6 +94,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
## Architecture
<!-- ANCHOR: architecture -->
| Layer | Crates | Role |
|---|---|---|
| L0 | `geom` | vectors, planes, profiles, meshes and their measures |
@@ -99,6 +104,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
| L4 | `engine` | the session and the command registry (everything is a command) |
| L5 | `ui-egui`, `mcp` | the swappable desktop front end; the MCP server (headless or bridged to the app) |
| apps | `solvecraft`, `solvecraft-cli` | desktop app, headless CLI |
<!-- ANCHOR_END: architecture -->
## Contributing
+10 -5
View File
@@ -1,6 +1,6 @@
//! The Help menu (the ? button on the application bar): About SolveCraft (version, commit, build
//! date, licences, attributions, fonts), the keyboard shortcuts (read only), and Report an Issue,
//! which copies diagnostic information to the clipboard.
//! The Help menu (the ? button on the application bar): Documentation (the online book), About
//! SolveCraft (version, commit, build date, licences, attributions, fonts), the keyboard shortcuts
//! (read only), and Report an Issue, which copies diagnostic information to the clipboard.
use egui::{RichText, vec2};
@@ -10,6 +10,8 @@ use crate::theme::Tokens;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const COMMIT: &str = env!("SOLVECRAFT_COMMIT");
pub const BUILD_DATE: &str = env!("SOLVECRAFT_BUILD_DATE");
/// The user and developer guide (docs/book, published by .github/workflows/pages.yml).
pub const DOCS_URL: &str = "https://bherbruck.github.io/solvecraft/";
const ATTRIBUTION: &str = include_str!("../../../ATTRIBUTION.md");
const LICENSE_MIT: &str = include_str!("../../../LICENSE-MIT");
const LICENSE_APACHE: &str = include_str!("../../../LICENSE-APACHE");
@@ -42,10 +44,11 @@ pub fn diagnostics(app: &SolveApp) -> String {
)
}
/// Run a Help menu item: `about`, `shortcuts`, `report`.
/// Run a Help menu item: `docs`, `about`, `shortcuts`, `report`.
pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) {
app.help.menu = false;
match item {
"docs" => ctx.open_url(egui::OpenUrl::new_tab(DOCS_URL)),
"about" => app.help.about = true,
"shortcuts" => {
app.keymap.open = true;
@@ -65,7 +68,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context, anchor: egui::Pos2) {
let mut pick: Option<&str> = None;
let resp = egui::Area::new(egui::Id::new("sc_help_menu")).fixed_pos(anchor).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| {
crate::context_menu::menu_frame(ui, |ui| {
for (id, label) in [("about", "About SolveCraft"), ("shortcuts", "Keyboard Shortcuts"), ("report", "Report an Issue…")] {
for (id, label) in
[("docs", "Documentation"), ("about", "About SolveCraft"), ("shortcuts", "Keyboard Shortcuts"), ("report", "Report an Issue…")]
{
if crate::context_menu::list_row(ui, &crate::context_menu::Item::action(id, label, "")).is_some() {
pick = Some(id);
}
+2
View File
@@ -0,0 +1,2 @@
/src/generated/
/src/reference/commands.md
+29
View File
@@ -0,0 +1,29 @@
[book]
title = "SolveCraft"
description = "An open-source parametric 3D CAD application in pure Rust: user guide and developer guide."
authors = ["SolveCraft contributors"]
language = "en"
src = "src"
[build]
build-dir = "../../target/book"
create-missing = false
[output.html]
git-repository-url = "https://github.com/bherbruck/solvecraft"
edit-url-template = "https://github.com/bherbruck/solvecraft/edit/main/docs/book/{path}"
site-url = "/solvecraft/"
no-section-label = false
[output.html.search]
enable = true
[output.html.redirect]
# Pages included from the repository (CONTRIBUTING.md, docs/*.md) keep their repository-relative
# links; these send them to the right place.
"/dev/mcp.html" = "../guide/mcp.html"
"/dev/docs/oracle.html" = "../oracle.html"
"/dev/README.html" = "https://github.com/bherbruck/solvecraft/blob/main/README.md"
"/dev/ATTRIBUTION.html" = "https://github.com/bherbruck/solvecraft/blob/main/ATTRIBUTION.md"
"/dev/AGENTS.html" = "https://github.com/bherbruck/solvecraft/blob/main/AGENTS.md"
"/dev/ROADMAP.html" = "https://github.com/bherbruck/solvecraft/blob/main/ROADMAP.md"
+7
View File
@@ -0,0 +1,7 @@
{"commands": [
{"command": "solid.box", "params": {"length": 70, "width": 45, "height": 25, "body_name": "Enclosure"}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 12], [70, 0, 12], [70, 45, 12], [0, 45, 12]], "radius": 6}},
{"command": "solid.shell", "params": {"faces": [[35, 22, 25]], "thickness": 2}},
{"command": "plastic.boss", "params": {"position": [12, 12, 2], "diameter": 7, "height": 14, "hole_diameter": 2.5}},
{"command": "plastic.boss", "params": {"position": [58, 33, 2], "diameter": 7, "height": 14, "hole_diameter": 2.5}}
]}
+11
View File
@@ -0,0 +1,11 @@
{"commands": [
{"command": "sketch.create", "params": {"plane": "XZ", "name": "Section"}},
{"command": "sketch.line", "params": {"points": [[6, 0], [30, 0], [30, 4], [12, 4], [12, 20], [6, 20]], "closed": true}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.revolve", "params": {"axis": "Z", "body_name": "Hub"}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "Bolt holes"}},
{"command": "sketch.circle.center", "params": {"center": [22, 0], "diameter": 5}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 4, "operation": "cut", "name": "Bolt hole"}},
{"command": "solid.pattern.circular", "params": {"features": ["Bolt hole"], "axis": "Z", "count": 6}}
]}
+9
View File
@@ -0,0 +1,9 @@
{"commands": [
{"command": "sheet.manage_rules", "params": {"name": "Steel 1.5", "thickness": "1.5 mm", "bend_radius": "1.5 mm", "k_factor": 0.44, "active": true}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [80, 50]}},
{"command": "sketch.finish", "params": {}},
{"command": "sheet.flange", "params": {"type": "base"}},
{"command": "sheet.flange", "params": {"edges": [[40, 0, 1.5]], "height": 20}},
{"command": "sheet.flange", "params": {"edges": [[40, 50, 1.5]], "height": 20}}
]}
+34
View File
@@ -0,0 +1,34 @@
# Summary
[Introduction](introduction.md)
# User guide
- [Getting started](guide/getting-started.md)
- [A tour of the window](guide/ui-tour.md)
- [Sketching and constraints](guide/sketching.md)
- [Solid features](guide/features.md)
- [Parameters and expressions](guide/parameters.md)
- [Components and joints](guide/assemblies.md)
- [Sheet metal](guide/sheet-metal.md)
- [Plastic parts](guide/plastic.md)
- [Import and export](guide/files.md)
- [Keyboard shortcuts](guide/shortcuts.md)
- [Driving SolveCraft with an AI agent (MCP)](guide/mcp.md)
# Developer guide
- [Contributing](dev/contributing.md)
- [Architecture](dev/architecture.md)
- [The command engine](dev/commands.md)
- [The kernel boundary and truck](dev/kernel.md)
- [Persistent naming](dev/naming.md)
- [Test harnesses](dev/testing.md)
- [Control protocol](dev/control-protocol.md)
- [Fusion oracle](dev/oracle.md)
- [Command parity](dev/parity.md)
- [STEP import](dev/step-import.md)
# Reference
- [Command reference](reference/commands.md)
+22
View File
@@ -0,0 +1,22 @@
# Architecture
SolveCraft is a Cargo workspace in layers. A crate only depends on crates in lower layers, and
`cargo xtask layers` fails the build otherwise.
{{#include ../../../../README.md:architecture}}
The web app, `apps/solvecraft-web`, is the desktop front end compiled to wasm with
[trunk](https://trunkrs.dev). `xtask` holds the repository's own tooling: `cargo xtask ci`,
`layers`, `assets`, `oracle`, `parity`, `step-corpus` and `book`.
A few rules follow from the layering:
- **Only `crates/kernel` touches truck.** Everything above it sees SolveCraft's own types:
bodies, faces, edges, meshes and measures. See [The kernel boundary](kernel.md).
- **The document knows nothing about commands or the UI.** `crates/doc` holds the parameters,
the feature timeline and the component tree, and evaluates them into a model.
- **Everything the user can do is a command** in `crates/engine`. See
[The command engine](commands.md).
- **The front end is swappable.** Nothing below `ui-egui` depends on egui. The CLI and the MCP
server run the same engine without a window, and `crates/render` has a CPU rasterizer so
headless screenshots work anywhere.
+38
View File
@@ -0,0 +1,38 @@
# The command engine
Every user-visible action is a **command**: a `CommandSpec` registered in
`crates/engine/src/cmd/`, with a dotted, lower-case id (`solid.extrude`, `sketch.create`,
`timeline.rollback`). The toolbar, dialogs, marking menu, S box, scripts, the CLI, the control
channel and MCP all run commands through `Session::execute(id, params)`. Commands take JSON
parameters and never open dialogs; a dialog is a front-end form that builds the parameters.
```rust
CommandSpec::new("solid.fillet", "Fillet", fillet)
.at("SOLID", "MODIFY") // toolbar tab and panel ("" = not on the toolbar)
.icon("fillet") // drawn in code, crates/ui-egui/src/icons.rs
.key("F") // default shortcut
.params("edges: [[x,y,z] | {body, index}]; radius: expr; …"),
```
`run` is `fn(&mut Session, &Value) -> Result<Value>`, and `enabled` is
`fn(&Session) -> Result<(), String>`; the error string is the disabled-button tooltip. The
`params` string is the one-line documentation shown by `solvecraft-cli commands`, MCP's
`list_commands` and the [command reference](../reference/commands.md).
A command that changes the design is undoable by default: the session snapshots the document
before it runs, and a failing command leaves the document unchanged. Feature commands add a
feature to the timeline and let the document evaluate it; they validate their inputs first
(units, ranges, references) and return `BadParams` instead of building a broken feature.
Ids used before the 2026-10 rename still resolve through `crates/engine/src/legacy_ids.rs`, so
old scripts, recipes, saved shortcuts and toolbar layouts keep working.
## Adding a command
1. Add a `CommandSpec` to the right file in `crates/engine/src/cmd/` and implement `run`.
2. Write engine tests that measure the result: volume, area, and face/edge/vertex counts
against values you can derive by hand.
3. The hostile-input test (`hostile_params_never_panic` in `crates/engine/src/tests.rs`) runs
your command automatically with every documented parameter set to junk. It must return
errors, never panic.
4. For UI work, add a scenario (see [Test harnesses](testing.md)).
+1
View File
@@ -0,0 +1 @@
{{#include ../../../../CONTRIBUTING.md}}
+1
View File
@@ -0,0 +1 @@
{{#include ../../../control-protocol.md}}
+37
View File
@@ -0,0 +1,37 @@
# The kernel boundary and truck
`crates/kernel` is SolveCraft's B-rep kernel. It wraps
[truck](https://github.com/ricosjp/truck) (Apache-2.0) for topology, NURBS geometry, booleans,
tessellation and STEP parsing, and it is the only crate allowed to use truck. Callers get
SolveCraft types (`Body`, face and edge indices, meshes, measures) and a
`KernelError` instead of truck types and panics.
## `guard`
truck can panic on geometry it doesn't handle. Every call into it goes through
`solvecraft_kernel::guard`, which catches the panic and turns it into
`KernelError::Internal`, leaving the model unchanged:
```rust
pub fn guard<T>(what: &str, f: impl FnOnce() -> Result<T>) -> Result<T>
```
A failing feature shows an error on the timeline; it never takes the app down.
## What is our own
truck has no fillets, chamfers, shells or face edits, so these are SolveCraft code in the
kernel: constant, chord and variable-radius fillets with corner blends, chamfers, shell and
offset, Delete Face, Split Face, Replace Face, sewing, STEP and IGES reading and writing beyond
truck's parser, and face provenance (which input face each output face came from, for
[persistent naming](naming.md)).
## Vendored truck crates
Patched copies of `truck-shapeops`, `truck-stepio` and `truck-meshalgo` live in `vendor/`,
wired in with `[patch.crates-io]`. The patches fix robustness bugs found by our tests and the
STEP corpus. `vendor/` is excluded from our fmt and clippy rules, and each patch should go
upstream when it can.
Some truck assertions only run in debug builds and can fail on valid geometry, so tests and CI
run with `--release`.
+22
View File
@@ -0,0 +1,22 @@
# Persistent naming
A fillet stores the edges it rounds, and a sketch stores the face it sits on. If those were face
and edge *indices*, an upstream edit that adds a face would move the fillet to the wrong edge.
SolveCraft stores **names** instead (`crates/doc/src/naming.rs`):
- A face of an evaluated body is named after how it was made. A face lying on a face of the
feature's input keeps that face's name; a face new to the feature is named by its role:
`F<feature>:side:<sketch curve>`, `F<feature>:start` and `:end`, `F<feature>:blend:<k>` and
so on.
- Pieces of a split face get `#1`, `#2`, … in order along the longest axis.
- An edge is named by the two faces it separates.
- Pattern and mirror copies are named after their sources plus the instance.
Names are computed lazily, the first time something asks, from the body, the feature that made
it and the model just before that feature. Face provenance from the kernel (which input face an
output face came from) does most of the work.
When a feature evaluates, each stored reference is resolved by name. If no face has the name
any more, the reference falls back to the geometry it recorded (a point on the face, a normal),
and the feature shows a warning so the user can check it. A reference that matches nothing is
an error, never a guess.
+1
View File
@@ -0,0 +1 @@
{{#include ../../../oracle.md}}
+1
View File
@@ -0,0 +1 @@
{{#include ../../../parity.md}}
+1
View File
@@ -0,0 +1 @@
{{#include ../../../step-import.md}}
+40
View File
@@ -0,0 +1,40 @@
# Test harnesses
`cargo xtask ci` runs everything a pull request must pass: fmt, clippy with `-D warnings`, the
tests in release mode, `xtask layers`, `xtask assets` and the wasm build check.
| Harness | Where | What it catches |
|---|---|---|
| Unit and engine tests | next to the code, `*_tests.rs` | measured results: volume, area, topology counts against derived values |
| Hostile input | `hostile_params_never_panic`, `crates/engine/src/tests.rs` | every command × every documented parameter × junk values: errors, never panics |
| Sketch fuzz | `crates/engine/src/cmd/sketch_fuzz_tests.rs` | random sketches through the solver and profile finder |
| UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | the real app, headless: clicks, keys, drags, assertions |
| Layout tests | `crates/ui-egui/tests/ui_layout.rs`, `window_widths.rs` | panels and dialogs at every window width |
| Design-file corpus | `apps/solvecraft-cli/tests/corpus` | every saved file version still opens and evaluates the same |
| Kill mid-save | `apps/solvecraft-cli/tests/kill_mid_save.rs` | a save killed at any moment leaves a readable file |
| Fusion oracle | `cargo xtask oracle` → [docs/oracle.md](oracle.md) | parts rebuilt from recipes, compared with Fusion's measurements |
| STEP corpus | `cargo xtask step-corpus` → [docs/step-import.md](step-import.md) | STEP files from Fusion opened and measured |
## UI scenarios
A scenario is a JSON list of steps run against the real app with a headless egui context:
```json
[
{"start": "sketch.create"},
{"click": {"plane": "XY"}},
{"start": "sketch.rectangle.two_point"},
{"click": {"sketch": [0, 0]}},
{"click": {"sketch": [40, 30]}},
{"expect": {"errors": 0}}
]
```
A new file in `crates/ui-egui/tests/scenarios/` is picked up automatically. To record a bug
before it's fixed, add a `{"pending": "why"}` step; its test is then ignored with that reason, and
the fix removes the step.
## Release mode
CI runs the tests with `--release`, because some truck assertions only exist in debug builds
and fail on valid geometry. If a test fails only in debug, say so in the PR.
+22
View File
@@ -0,0 +1,22 @@
# Components and joints
A design is a tree of **components**. **New Component** (`component.create`) adds one, and
**Create Components from Bodies** (`component.from_bodies`) turns bodies into components. Each
component has its own origin, bodies, sketches and timeline entries. The **active component**
(the radio button in the browser) receives new sketches and features; the others are drawn
faded.
A component is placed by its **occurrences**. Paste creates another instance of the same
component, and **Paste New** creates an independent copy. Move an occurrence with the move
triad, then **Capture Position** to keep it or **Revert** to put it back. SolveCraft asks before
the next command if a move wasn't captured. **Ground** fixes an occurrence in place.
**Joints** (`joint.create`, J) connect two components by joint origins that snap to faces, edges,
circles and vertices: rigid, revolute, slider, cylindrical, pin-slot, planar and ball, with
offsets, flip and limits. **As-built Joint** keeps the current position, **Rigid Group** locks
components together, **Drive Joints** moves a joint through its values, and **Motion Link**
couples two joints. Contact sets stop motion where bodies would pass through each other,
**Motion Study** keys joint values along a timeline, and **Interference** shows overlapping
volumes in red.
STEP export writes the component tree as an assembly.
+24
View File
@@ -0,0 +1,24 @@
# Solid features
Features live on the **timeline** and rebuild incrementally from the first one that changed.
Each one can be edited (double-click), suppressed, renamed, reordered (if nothing it depends on
moves after it), rolled back to, and deleted. Deleting asks first when other features depend on
it.
| Panel | Features |
|---|---|
| Create | extrude (one side, two sides, symmetric, taper, start offset, to a distance or through all; new body, join, cut, intersect), revolve, sweep, loft, box, cylinder, sphere, torus, coil, pipe, rib, web, emboss, hole (simple, counterbore, countersink, tapped; on faces or at sketch points), thread, rectangular/circular/path patterns, mirror |
| Modify | press pull, fillet, chamfer, shell, draft, scale, combine, split body, offset face, replace face, move/copy, align, remove |
| Construct | offset plane, plane at angle |
| Inspect | measure, section analysis, interference, curvature comb and map, zebra, draft, minimum radius, isocurves, centre of mass |
Fillets and chamfers handle straight edges between planar faces, whole smooth loops, runs of
neighbouring edges with mitred or spherical corners, and closed chains of curved edges (a rolling
ball). See the roadmap's known limitations for the rest.
Feature references to faces and edges are stored by name, so an upstream edit that moves geometry
keeps them on the right face (see [Persistent naming](../dev/naming.md)). When a reference can't
be found, the feature warns instead of silently picking another face.
Press **Delete** on anything selected (bodies, faces, sketches, features, planes, components,
canvases). It runs as one undoable step and lists the features that would go with it or fail.
+19
View File
@@ -0,0 +1,19 @@
# Import and export
| Format | Read | Write |
|---|---|---|
| `.solvecraft` (JSON design, versioned) | yes | yes |
| STEP (AP203, AP214, AP242) | solids, assemblies, units, names, colours | AP242 with names, colours and the component tree |
| IGES 5.3 | manifold solids, or trimmed surfaces sewn into solids | manifold solids with names and colours |
| 3MF, STL, OBJ | mesh bodies | yes |
| DXF, SVG | into a sketch | sketches and flat patterns as DXF |
| PNG, JPEG | canvases and decals | |
Opening a STEP or IGES file creates a design with the file's bodies as a base feature, and
inserting one adds them to the current design. Later features build on them. Mesh bodies render,
measure, move and export, but solid features need B-rep bodies.
Saves are atomic: a crash at any moment leaves either the old file or the new one, and the
replaced version is kept as `<file>.bak`. Unsaved changes are autosaved to a recovery folder,
and the next launch offers to recover them. Older design files are upgraded on open, and files
from a newer version are refused with a message.
+56
View File
@@ -0,0 +1,56 @@
# Getting started
## Desktop
Download the latest build from the
[releases page](https://github.com/bherbruck/solvecraft/releases), or build it yourself with
stable Rust (1.90 or newer):
```sh
git clone https://github.com/bherbruck/solvecraft && cd solvecraft
cargo run --release -p solvecraft -- --sample # opens with a sample part
```
`solvecraft part.step` opens a STEP, IGES, 3MF, STL or OBJ file as a new design, and
`solvecraft design.solvecraft` opens a saved design. Windows builds cross-compile from Linux with
`cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
## Web
The same app runs in the browser: [open SolveCraft on the web](../app/) (`?sample` opens the sample
part, `?webgl` forces WebGL2 where WebGPU is missing). Designs are saved in the browser's private
file storage, which needs HTTPS or localhost. To build it yourself:
```sh
rustup target add wasm32-unknown-unknown
cd apps/solvecraft-web && trunk serve --release
```
## CLI
`solvecraft-cli` runs the same commands without a window:
```sh
cargo run --release -p solvecraft-cli -- commands # every command, as JSON
cargo run --release -p solvecraft-cli -- run examples/bracket.json --out bracket.step
cargo run --release -p solvecraft-cli -- eval examples/bracket.json # volume, area, faces…
cargo run --release -p solvecraft-cli -- snapshot examples/bracket.json --out bracket.png
cargo run --release -p solvecraft-cli -- exec solid.box '{"length": 20, "width": 10, "height": 5}'
```
A script is a list of commands:
```json
{"commands": [
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 20}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}}
]}
```
![The bracket example, rendered by solvecraft-cli snapshot](../generated/bracket.png)
Units are millimetres and degrees unless an expression says otherwise (`"1 in"`, `"30 deg"`),
and Z is up.
+1
View File
@@ -0,0 +1 @@
{{#include ../../../mcp.md}}
+21
View File
@@ -0,0 +1,21 @@
# Parameters and expressions
Every dimension and feature input is a **model parameter** (`d1`, `d2`, …). **User parameters**
are ones you add yourself, with a unit, an expression and an optional comment. Change them in
**Change Parameters** (`parameters.change`). The timeline rebuilds from the first feature that
uses them.
Expressions are unit-aware: `2 * width + 5 mm`, `angle / 2`, `sqrt(area)`, `1 in + 3 mm`, using
`+ - * / ^`, parentheses, `sin cos tan asin acos atan atan2 sqrt abs min max floor ceil round mod
trunc` and unit conversion. A length where an angle is expected (or the other way round) is an
error, not a silent conversion. Cycles are reported with their path (`a → b → a`), renaming a
parameter updates every expression, dimension and feature input that uses it, and a parameter
in use can't be deleted (the error lists its users).
The design's units (Document Settings in the browser: mm, cm, m, in or ft) set how a bare number
typed into a feature is read. In an inch design `2` means 2 in. Changing the units never moves
existing geometry.
Parameters export to and import from CSV or JSON (`parameters.export`, `parameters.import`).
**Configurations** (`config.*`) keep a table of rows, each with its own parameter values and
suppressed features, switched with `config.activate`.
+13
View File
@@ -0,0 +1,13 @@
# Plastic parts
The PLASTIC tab has a small enclosure subset:
- **Boss** (`plastic.boss`): screw bosses with a hole, fillets and optional ribs.
- **Lip / Groove** (`plastic.lip`): the mating lip and groove along a shell's opening.
- **Snap Fit** (`plastic.snap_fit`): a cantilever snap hook and its catch.
- **Rest** (`plastic.rest`): a pad for a part to rest on.
- **Plastic Rules** (`plastic.manage_rules`, `plastic.assign_rule`): wall thickness, draft and
clearance per material, used as the features' defaults.
Rib and web (on the SOLID tab) complete the set. These features are checked against
hand-computed geometry rather than a Fusion oracle.
+15
View File
@@ -0,0 +1,15 @@
# Sheet metal
The SHEET METAL tab builds parts from a sheet of constant thickness under a **sheet metal rule**
(thickness, bend radius, K-factor), set in **Sheet Metal Rules** (`sheet.manage_rules`).
- **Flange** (`sheet.flange`): a base flange from a sketch profile, a contour flange from an open
sketch, or an edge flange from a part edge.
- **Hem** (`sheet.hem`) and **Fold** (`sheet.fold`) along a sketch line.
- **Unfold / Refold** (`sheet.unfold`, `sheet.refold`) for working on the flat part.
- **Convert** (`sheet.convert`) turns a solid of constant thickness into sheet metal.
- **Create Flat Pattern** (`sheet.flat_pattern`) shows the flat part with its bend lines and a
bend table, and exports it as DXF.
Bends follow the rule's K-factor, so flat lengths match what a press brake will make. The
oracle checks these against parts built in Fusion.
+29
View File
@@ -0,0 +1,29 @@
# Keyboard shortcuts
Command shortcuts can be changed in File › Keyboard Shortcuts; these are the defaults.
| Key | Command | Key | Command |
|---|---|---|---|
| L | Line | E | Extrude |
| R | Two-Point Rectangle | Q | Press Pull |
| C | Center Diameter Circle | F | Fillet |
| D | Sketch Dimension | H | Hole |
| X | Construction | M | Move/Copy |
| O | Offset | J | Joint |
| T | Trim | I | Measure |
| P | Project | A | Appearance |
| Ctrl+Z | Undo | Ctrl+Y | Redo |
| Ctrl+N | New Design | Ctrl+O | Open |
| Ctrl+S | Save | Delete | Delete the selection |
Keys handled by the window itself:
| Key | Action |
|---|---|
| S | the S box: search every command at the cursor |
| V | show or hide the selected bodies, sketches and planes |
| F2 | rename the selected browser or timeline item |
| F6 | fit the view to the model |
| Esc | cancel the running command, or clear the selection |
Help › Keyboard Shortcuts in the app lists the current bindings, including any you changed.
+34
View File
@@ -0,0 +1,34 @@
# Sketching and constraints
A sketch lies on a plane: an origin plane, a construction plane or a planar face. Start one with
**Create Sketch** (`sketch.create`) and click a plane, or pick a face first. **Finish Sketch**
returns to the model.
## Geometry
Lines and polylines (L), rectangles (two-point R, three-point, centre), circles (centre C,
two-point, three-point, tangent), arcs (three-point, centre, tangent), polygons, slots, ellipses,
fit-point and control-point splines, conics, points and text. **Construction** (X) turns curves
into reference geometry that doesn't form profiles. Modify tools include trim (T), extend, break,
fillet, chamfer, offset (O), mirror, patterns, move/copy and scale.
**Project** (P) and **Intersect** bring model edges into the sketch as linked geometry that
follows later changes. Sketching on a face projects its edges automatically.
## Constraints and dimensions
The solver is SolveCraft's own (damped Gauss–Newton with degree-of-freedom analysis). Constraints:
coincident, horizontal/vertical, parallel, perpendicular, tangent, equal, concentric, collinear,
midpoint, symmetry, fix, curvature (G2) and polygon. **Sketch Dimension** (D) adds linear,
aligned, horizontal, vertical, radius, diameter and angle dimensions. Each is a parameter
(`d1`, `d2`, …), so it can hold an expression.
Under-constrained geometry is blue and fully constrained geometry turns dark, and the status
shows the remaining degrees of freedom. A constraint or dimension that would over-constrain the
sketch is refused rather than applied. Drag a point to move it; the solver keeps every
constraint while you drag.
## Profiles
Closed loops become profiles automatically, including regions split by crossing lines and
islands inside outlines. Extrude, revolve, sweep and loft pick profiles.
+29
View File
@@ -0,0 +1,29 @@
# A tour of the window
![The SolveCraft window](../generated/sample-plate.png)
- **Title bar.** On Windows and Linux the application bar is the window's title bar: File, save,
undo and redo, the start page, the theme, Help, and one tab per open design (each with its own
undo history, selection and camera).
- **Toolbar.** The workspace switcher (DESIGN) sits on the left. The tabs (SOLID, SURFACE,
SHEET METAL, PLASTIC, UTILITIES, and SKETCH while you sketch) hold panels of commands. Click a
panel's name for all its commands.
- **Browser** (left). The design as a tree: document settings and units, named views, the
origin, bodies, sketches, construction planes, canvases, joints and components. The eye shows or
hides an item, a component's radio button makes it the active component, and right-clicking any
row gives that item's menu. Rows drag into groups and other components.
- **Viewport.** Click to select, Shift or Ctrl to add, drag for a window or crossing box. The
wheel zooms at the cursor, the middle button pans and Shift+middle orbits (the mouse preset in
Preferences switches to SolidWorks or Inventor style). The view cube and the navigation bar
change the view. Right-click for the marking menu: eight commands around the cursor, plus a
menu for whatever is under it.
- **Dialogs** dock on the right with a live preview. Most features also show drag handles and a
value box in the viewport.
- **Timeline** (bottom). Every feature in order. Double-click to edit, drag the marker to roll
back, and drag features to reorder them.
- **S box.** Press S anywhere for a command search at the cursor. Pin the commands you use most
to its top.
Preferences (File › Preferences) hold the theme, the units of new designs, the mouse preset, the
visual style and autosave. Help › Keyboard Shortcuts lists every key, and File › Keyboard
Shortcuts changes them.
+32
View File
@@ -0,0 +1,32 @@
# SolveCraft
SolveCraft is an open-source parametric 3D CAD application written in pure Rust. It works the way
Fusion-style modellers do: sketch on a plane, constrain and dimension the sketch, turn profiles
into solids with features on a timeline, and change a parameter to watch the whole timeline
rebuild.
![The sample plate in SolveCraft](generated/sample-plate.png)
It runs as:
- a **desktop app** for Windows and Linux (egui + wgpu);
- a **web app** in the browser (wasm, WebGPU or WebGL2), [try it here](app/);
- a **headless CLI**, `solvecraft-cli`, that runs command scripts, measures and exports;
- an **MCP server** that AI agents model with, headless or driving the running app.
Everything the user can do is a *command* with JSON parameters. The toolbar, the dialogs, scripts,
the CLI, the control channel and MCP all run the same commands, so anything you can click you can
also script, test or hand to an agent.
SolveCraft is early. Sketching, solid modelling, parameters, components with joints, sheet metal
and a plastic-enclosure subset work; surfaces, drawings, CAM and simulation are deferred. The
[roadmap](https://github.com/bherbruck/solvecraft/blob/main/ROADMAP.md) has the details, and
[command parity](dev/parity.md) and the [Fusion oracle](dev/oracle.md) measure how far along it
is.
This book has two halves: the **user guide** for people modelling with SolveCraft, and the
**developer guide** for people working on it. The [command reference](reference/commands.md) is
generated from the code on every build.
SolveCraft is licensed MIT OR Apache-2.0. It is a clean-room implementation and is not affiliated
with Autodesk; Fusion is a trademark of Autodesk, Inc.
+123
View File
@@ -0,0 +1,123 @@
//! `cargo xtask book`: generate the book's command reference and screenshots, then build it.
//!
//! The command reference (`docs/book/src/reference/commands.md`) comes from
//! `solvecraft-cli commands`, so it can't drift from the registry. The screenshots in
//! `docs/book/src/generated/` are CPU renders by `solvecraft-cli snapshot` of `examples/*.json`
//! and `docs/book/scripts/*.json`, plus the app screenshot from `docs/screenshots/`. Both are
//! gitignored and attributed in ATTRIBUTION.md. The book itself is built by `mdbook` (a separate
//! tool, not a dependency) into `target/book`.
use std::path::Path;
use serde_json::Value;
use crate::cargo;
/// Toolbar tabs in toolbar order; commands not on the toolbar come last, grouped by id prefix.
const TABS: &[&str] = &["SOLID", "SKETCH", "SURFACE", "SHEET METAL", "PLASTIC"];
const SNAPSHOT_SIZE: (&str, &str) = ("960", "600");
pub fn run(root: &Path, build: bool) -> Result<(), String> {
let book = root.join("docs/book");
let mut c = cargo();
c.args(["build", "-q", "--release", "-p", "solvecraft-cli"]);
crate::run(c, "cargo build -p solvecraft-cli")?;
let out = cli().arg("commands").output().map_err(|e| format!("solvecraft-cli: {e}"))?;
if !out.status.success() {
return Err(format!("solvecraft-cli commands failed:\n{}", String::from_utf8_lossy(&out.stderr)));
}
let cmds: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("commands JSON: {e}"))?;
let md = reference(cmds.as_array().map(Vec::as_slice).unwrap_or_default());
write(&book.join("src/reference/commands.md"), &md)?;
let gen_dir = book.join("src/generated");
std::fs::create_dir_all(&gen_dir).map_err(|e| format!("{}: {e}", gen_dir.display()))?;
let mut scripts = vec![root.join("examples/bracket.json")];
scripts.extend(json_files(&book.join("scripts"))?);
for s in &scripts {
let stem = s.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let png = gen_dir.join(format!("{stem}.png"));
let mut c = cli();
c.arg("snapshot").arg(s).arg("--out").arg(&png).args(["--width", SNAPSHOT_SIZE.0, "--height", SNAPSHOT_SIZE.1]);
let o = c.output().map_err(|e| format!("solvecraft-cli snapshot: {e}"))?;
if !o.status.success() {
return Err(format!("snapshot of {} failed:\n{}", s.display(), String::from_utf8_lossy(&o.stderr)));
}
println!("book: {}", png.strip_prefix(root).unwrap_or(&png).display());
}
let shot = "sample-plate.png";
std::fs::copy(root.join("docs/screenshots").join(shot), gen_dir.join(shot)).map_err(|e| format!("{shot}: {e}"))?;
if build {
let mut c = std::process::Command::new("mdbook");
c.arg("build").arg(&book);
crate::run(c, "mdbook build docs/book").map_err(|e| format!("{e}\n(install mdbook with `cargo install mdbook`)"))?;
println!("book: built into target/book");
}
Ok(())
}
/// The release solvecraft-cli just built.
fn cli() -> std::process::Command {
let mut c = cargo();
c.args(["run", "-q", "--release", "-p", "solvecraft-cli", "--"]);
c
}
fn write(path: &Path, text: &str) -> Result<(), String> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
std::fs::write(path, text).map_err(|e| format!("{}: {e}", path.display()))
}
fn json_files(dir: &Path) -> Result<Vec<std::path::PathBuf>, String> {
let rd = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let mut v: Vec<_> = rd.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "json")).collect();
v.sort();
Ok(v)
}
/// The command reference page: one section per toolbar tab and panel, in registry order.
fn reference(cmds: &[Value]) -> String {
let s = |c: &Value, k: &str| c[k].as_str().unwrap_or_default().to_string();
let mut md = format!(
"# Command reference\n\nGenerated by `cargo xtask book` from `solvecraft-cli commands` ({} commands). Every command runs the same \
way from the toolbar, a script, `solvecraft-cli exec <id> '<json>'`, the control channel and MCP. Parameters marked `?` are \
optional; `expr` is a number or an expression with units.\n",
cmds.len()
);
let mut sections: Vec<(String, Vec<&Value>)> = Vec::new();
for tab in TABS {
for c in cmds.iter().filter(|c| s(c, "tab") == *tab) {
let title = format!("{tab} › {}", s(c, "panel"));
add(&mut sections, title, c);
}
}
for c in cmds.iter().filter(|c| !TABS.contains(&s(c, "tab").as_str())) {
let id = s(c, "id");
let prefix = id.split('.').next().unwrap_or_default().to_string();
add(&mut sections, format!("Other: {prefix}.*"), c);
}
for (title, list) in sections {
md.push_str(&format!("\n## {title}\n\n"));
for c in list {
let key = c["shortcut"].as_str().map(|k| format!(" ({k})")).unwrap_or_default();
md.push_str(&format!("- `{}` **{}**{key}", s(c, "id"), s(c, "label")));
let p = s(c, "params");
if !p.is_empty() {
md.push_str(&format!("<br>``{}``", p.replace('\n', " ")));
}
md.push('\n');
}
}
md
}
fn add<'a>(sections: &mut Vec<(String, Vec<&'a Value>)>, title: String, c: &'a Value) {
match sections.iter_mut().find(|(t, _)| *t == title) {
Some((_, v)) => v.push(c),
None => sections.push((title, vec![c])),
}
}
+5
View File
@@ -4,6 +4,7 @@
//! `std::process::Command`.
mod assets;
mod book;
mod layers;
mod parity;
@@ -24,6 +25,9 @@ commands:
step-corpus [DIR]
import every DIR/*/part.step (default plan/fusion/oracle) and compare
with measure.json; writes docs/step-import.md
book [--no-build]
generate docs/book's command reference and screenshots, then mdbook build
into target/book
wasm cargo check every library crate and the web app for wasm32-unknown-unknown
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
";
@@ -37,6 +41,7 @@ fn main() -> ExitCode {
Some("parity") => parity::run(&root(), rest.contains(&"--refresh")),
Some("oracle") => cmd_oracle(),
Some("step-corpus") => cmd_step_corpus(rest.first().copied()),
Some("book") => book::run(&root(), !rest.contains(&"--no-build")),
Some("ci") => cmd_ci(),
Some("wasm") => cmd_wasm(),
Some("-h" | "--help" | "help") | None => {