A clean-room, open-source 3D modeling application written in Rust, targeting Windows, macOS, and Linux from a single codebase.
Provenance & license note. OpenForge is an original implementation. It was inspired by studying the architecture of a commercial plugin-based modeler (its node/scene-graph design, service/plugin split, and tool paradigms), but it copies no proprietary code, assets, or file-format secrets. Concepts and UI layouts are not copyrightable; the code here is written from scratch. It uses only standard, openly-documented interchange formats (OBJ, glTF 2.0, PLY, STL, COLLADA, OFF, FBX ASCII). It does not implement any proprietary/DRM-protected game formats and does not interact with any anti-tamper system.
The project is a Cargo workspace of thirteen crates; these are the main layers:
| Crate | Role | Notes |
|---|---|---|
openforge-core |
Scene graph, geometry, editing model | GUI- and renderer-agnostic. Nodes, meshes, selection, undo/redo, interactive tools, ray picking. |
openforge-io |
Import/export | OBJ, glTF/GLB, PLY, STL, COLLADA, OFF, FBX ASCII. Clean-room readers and writers. |
openforge-render |
GPU viewport backend | Camera/uniform/vertex plumbing plus a wgpu renderer behind the gpu feature: offscreen color + depth target (multi-sampled when the adapter supports it), the scene, grid, edge-overlay and axis-gizmo passes, a material texture array the scene pass samples per fragment — four map layers per vertex (diffuse, normal, specular, emissive) over reserved identity layers, with the material's specular colour and emissive colour on the same vertex — and an ID pass that resolves a click — or a whole rectangle — against exactly what the frame drew, down to single faces, edges and vertices. |
openforge-boolean |
Solid modelling | BSP CSG — union, subtract, intersect — run in normalized double precision, so the tolerances are relative to the model and the same geometry gives the same answer whether the scene is in millimetres or in kilometres. The result is a closed solid: shared vertices are welded by tolerance, slivers are kept rather than dropped, and the status bar says whether the result came out closed. The texture and the per-face materials come through the cut, so cutting a textured object keeps its texture. |
openforge-plugin |
Plugin host | Services, tools and format filters registered by name. The engine's own tools and formats are ordinary plugins here, so a replacement tool or a new format is a registration rather than an app change. |
openforge-project |
Project files | One versioned JSON document — the scene, the material library (textures as base64) and the animation clips — with its own document structs and a bridge to the engine types. Ids never reach the file: references are positions in a parents-first node list. |
openforge-app |
Desktop application | eframe/egui UI over winit/wgpu. Viewport, UV editor, outliner, properties, toolbar, hotkeys. The camera is either the free orbit camera or one of the six axis-aligned orthographic views, the transform gizmo draws axis arms, one square per coordinate plane and a screen-plane centre handle in three axis modes (world, the selection's own, the screen's) with a pivot a transform swings about, and the Properties panel edits a transform as numbers. |
Scene/Node— a hierarchy ofGroup,Dummy, andMeshnodes with TRS transforms and derived world matrices.Mesh— indexed n-gon polygon mesh with per-corner position/normal/UV attributes, so seams are preserved. Fan-triangulates for rendering and export.Selection— object/vertex/edge/face selection levels.UndoStack— snapshot-based undo/redo with labels.Tool— an interactive-tool trait. Ships withSelectTooland aTransformTool(move / scale / rotate) driven by viewport pointer rays.Camera— a perspective orbit camera with pan/zoom and NDC→ray picking.PluginHost— the seam plugins register into: a service registry, a tool registry (tools are factories, since a tool carries drag state) and a format filter registry that dispatches import/export on a file extension. Names are unique and a rejected plugin leaves the host exactly as it was.
Requires a stable Rust toolchain (rustup).
# Build everything
cargo build
# Run the application (host platform)
cargo run -p openforge-app --release
# Run all tests
cargo testcargo build produces a bare binary. To get a real, double-clickable app
bundle, use the packaging script:
./scripts/build-app.sh --release- macOS →
dist/OpenForge.app(ad-hoc signed). Install withcp -R dist/OpenForge.app /Applications/, or launch withopen dist/OpenForge.app. - Windows →
dist/OpenForge.exe(wrap in an installer such as Inno Setup or MSIX). - Linux →
dist/OpenForge(package as an AppImage or.deb).
The openforge-core, openforge-io and openforge-project crates are pure
Rust and cross-compile to every desktop target with no extra dependencies:
rustup target add x86_64-pc-windows-msvc x86_64-unknown-linux-gnu x86_64-apple-darwin
# Type-check the portable crates for each OS
cargo check -p openforge-core -p openforge-io -p openforge-project --target x86_64-pc-windows-msvc
cargo check -p openforge-core -p openforge-io -p openforge-project --target x86_64-unknown-linux-gnu
cargo check -p openforge-core -p openforge-io -p openforge-project --target x86_64-apple-darwinThe openforge-app GUI crate links against each platform's native windowing and
graphics stack (via winit/wgpu), so build it on (or with a full SDK/CI
runner for) the target OS:
- Windows —
cargo build --release --target x86_64-pc-windows-msvc(MSVC toolchain). - macOS —
cargo build --release(universal or--target aarch64-apple-darwin). - Linux —
cargo build --release --target x86_64-unknown-linux-gnu(needs X11/Wayland dev libs forwinit).
A sample GitHub Actions matrix is shown below.
- Left-drag selects (with the Select tool), or drags the selection through the view plane (with a transform tool).
- Q / W / E / R switch Select / Move / Scale / Rotate. With a transform tool active a transform gizmo sits on the selection: drag an axis arm to move or scale along that axis, a ring to rotate around it, the square in one of the coordinate planes to move or scale in two axes at once, or the square at the middle to work in the plane facing the camera (a uniform scale in Scale mode). The grabbed handle highlights and is labelled while the drag is live, and the whole gesture is a single undo step. Handles keep a constant size on screen whatever the camera distance and in whichever projection is showing. A rotation and a scale can also be typed into the Properties panel, per axis, as XYZ Euler degrees and multipliers.
- Axes Mode (the toolbar's Axes: row, or View → Axes Mode) turns the handles with the object or with the screen: World keeps them on the world axes, Local takes the selected object's own axes (a turned object then moves along the way it faces), and Screen holds them level and upright however the camera is orbited. View → Pivot puts the point a turn or a scale works about — Place at Cursor drops it where the cursor meets the ground grid, Place at Selection freezes it on the selection, Place at Origin uses the world origin, and Clear Pivot lets the gizmo follow the selection again — so a ring drag swings the whole selection round that point instead of spinning it in place. Snapping steps along whichever axes the handles are on.
- View → Layout splits the viewport area into Layout 1 (one viewport), Layout 2 (two side by side) or Layout 3 (four in a grid), and Maximize / Restore gives the active pane the whole area. Each pane has its own camera, so its projection is its own: a digit key or the View menu changes the pane the pointer is in and leaves the others alone.
- 0-6 choose the viewport projection:
0is the free perspective camera and1-6are the six axis-aligned orthographic views (Front, Back, Left, Right, Top, Bottom), also on View → Viewport with the current one ticked. An orthographic view looks along one world axis with the world's own axes right and up on screen,Fframes the scene in it, the gizmo and picking behave exactly as in perspective, and a middle-drag (or0) brings the free camera back. The corner label names whichever projection is showing. - Z cycles the edge overlay: off, then the selected meshes' edges as a selection outline, then every visible mesh's wireframe (View → Wireframe shows the current mode). Edges of the selected faces are highlighted, and edges seen only from behind are dropped so a closed mesh doesn't draw its own far side through the shading; at the Edge level an off overlay is drawn as the selected meshes' outline anyway, so the lines a pick can answer from are on screen while the setting itself stays where the user left it. On the wgpu backend the overlay is drawn inside the frame (depth-tested, so meshes occlude hidden edges) and the viewport is anti-aliased when the GPU supports it; the CPU fallback paints the edges on top.
- Vertex / Edge / Face level editing: select sub-objects and the gizmo moves to their centroid, where a drag moves only those vertices — the node's own transform is left alone. Edges are numbered by the mesh's own edge enumeration, one list every picker answers from, and a selected edge carries its endpoints into the drag like a corner carries a vertex, and is drawn as a bold highlight so what is selected reads in the picture, not just the status bar. Changing level clears the element selection, because a face index and a vertex index are not the same thing — and the viewport refuses to read one level's indices as another's, so a selected edge never tints a face that happens to share its number.
- Selection tools (the Select menu, the "Select:" toolbar row, or hotkeys): Ctrl+A takes everything at the current level — every visible node at the object level, every vertex/edge/face of the meshes being edited at a sub-object level — Ctrl+Shift+A clears it, Ctrl+I inverts it, and Ctrl+Plus / Ctrl+Minus grow or shrink it along the topology (faces through shared edges, edges through shared vertices, vertices through edges), the result counted in the status bar. L adds the edge loop through the selected edges and Shift+L the ring (Select menu or toolbar row): the loop carries straight on through vertices where exactly four edges meet, the ring crosses quads into the parallel band, and both add to the selection, so a second press changes nothing. H hides the selected nodes, Shift+H isolates them (everything else hidden) and U shows everything again; all three are undo steps, and hidden nodes drop out of the viewport and the picks at once — the outliner's per-node Visible box makes the same edit for one node, undo included.
- Select Through (View → Select Through, or P): the sub-object picks stop asking the depth-tested ID pass and answer from the projection instead, so a click takes the nearest element under the pointer and a rectangle everything it covers, whether the frame shows it or not. On a machine without wgpu the picks already answer that way, so the switch has nothing to change there.
- Animate ▸ Spin Selected (2 s) builds a one-turn clip over the selected nodes' current poses and opens the Timeline: the clip picker, Space to play/pause, Stop, Loop, and a scrub slider with the clip's key row under it. Key records the selected node's current transform at the playhead; clicking a key diamond scrubs to it; dragging one slides its position/rotation/scale column with a live preview, and the release is one undo step; Delete Key removes the picked column. Playback poses the scene on the frame clock; it is a view over the clip, not an edit, so nothing lands on the undo stack — Stop rewinds to the start.
- Game ▸ Set LOD 0–3 / Clear tags the selected nodes with a detail level
(0 = highest), Isolate LOD n shows only that level (one undo step, like
Isolate; U shows everything again), and Add Dummy drops an
attach-point dummy at the selection's centre. The Properties panel reports
the triangle budget per LOD for the selection and the whole scene, and the
outliner rows carry their
LOD ntag. - Game ▸ Generate LODs makes the levels, not just tags them:
cluster-decimated copies (
{name}_LOD1..3, one undo step) land tagged and hidden until Isolate LOD n previews them; a level that removed no face is skipped rather than duplicated. - Boolean CSG (the Game menu's Union / Subtract / Intersect, over the two selected meshes) comes out a closed solid: two boxes that share a face make one box, a box inside another subtracts to a shell, and the union of two overlapping spheres has no hole in it. The engine works in normalized double precision, so the same geometry gives the same answer whatever the scene's units are, and the status bar says whether the result came out closed. The cut keeps the texture — the UVs are interpolated across it — and the two meshes' materials come through as well.
- Rig (the toolbar's Rig row): Create Rig builds a bone chain along Y and auto-weights the active mesh; the Bone combo picks one by name and the Angle slider poses it live (Reset/Clear step back to rest). The Weights row binds the selected vertices to the active bone (Assign), moves a share of their influence onto it (Blend) and averages it over the mesh's edges (Smooth) — and Paint turns the cursor into a brush: a drag over the rigged mesh paints the active bone's weight onto the vertices under it, with a radius and a falloff, and Subtract takes it away instead. The whole stroke is one undo step. With a rig up, the Weights row edits the skin over the vertex selection: Assign binds to the active bone at full weight, Blend moves a share of every vertex's influence onto it, and Smooth averages over the mesh's edges — each one undo step, and Edit ▸ Undo puts the weights back.
- The Plugins panel lists each built-in plugin with a checkbox and what it brings (its formats, services and tools); unchecking one rebuilds the host without it, so its formats stop being importable/exportable and its tools leave the toolbar (and refuse their hotkey) in one step, and re-checking restores them.
- The UV editor's Tools row: Weld pulls the selected UVs closer than 0.005 to their cluster average, Split gives the viewport-selected faces their own UV corners, Mirror U/V reflects the selection about its bounds, and the Snap box makes drags land on a 0.05 grid — each one undo step, with the status saying what it did or why it did not.
- Import… and Export selected as… ask for the file's options before touching disk: its up-axis (Y up as-is, Z up, X up) and a uniform scale. The export writes the object where it stands — its world transform is baked into the file, so the object lands in the right place rather than at the origin — and the COLLADA and FBX importers bake the file's own node transforms in, so an imported model arrives where the file had it instead of stacked at the origin. Cancel reads and writes nothing, and dropped files take the defaults. The status names the options only when they were not the defaults.
- Picking matches the picture. In the wgpu viewport a Vertex-, Edge- or Face-level click resolves through an ID pass drawn with the same geometry, camera, depth test and back-face culling as the frame, so only what the image shows can be picked: a vertex hidden behind a surface stays unpicked, a face whose back is turned is skipped, and an edge wins the pixels it lies on without beating a face in front of it. The CPU raycast takes over when there is no GPU backend, or when nothing was under the cursor.
- Rectangle selection. With the Select tool, dragging draws a rectangle (the same overlay style as the gizmo) and on release everything inside it is selected — through the same ID pass, so only what the frame shows there is taken and occluded geometry is left out. A plain drag replaces the selection and Shift + drag adds to it; the status bar reads out the element count at Vertex, Edge and Face level. An edge counts as inside when either endpoint is or its line crosses the box. A rectangle dragged out and back with no area at all counts as the click it ended as. Off the wgpu backend the fallback projects the geometry instead — and, unlike the pass, it cannot tell what is hidden.
- Snapping (toolbar Snap:, or G to cycle Off → Grid → Vertex): a gizmo drag then lands on grid values — where the grabbed point ends up, not how far it travelled — with rotations and scales stepping too; in Vertex mode a Move drag lands the grabbed point on the vertex nearest the cursor (marked with a gold cross-hair while the drag is live). Hold Ctrl to invert snapping for as long as it is held, even mid-drag, and the status bar shows the value the drag is snapping to.
- Textures. A material carries up to four maps — diffuse, normal,
specular and emissive — and the Materials panel gives each one its own
row: a combo over the library (with a thumbnail of what it holds and a
None entry), Load Texture… imports a PNG or JPEG onto the material
being edited, and Checker generates a procedural checkerboard. A Checker
Test preset ships with a 256px checkerboard, so assigning it to a mesh shows
texturing without importing anything. Both viewports shade all four: the
diffuse texel multiplies the material colour, the normal map tilts the
shading through a tangent frame read back from the surface's own positions
and UVs (no tangent attribute), the specular map scales the material's own
highlight and the emissive map is added. The CPU fallback samples the maps at
a triangle's centre — its one per-pixel feature is the diffuse texel the egui
mesh interpolates — while the wgpu path samples every map per fragment. The
UV editor paints the diffuse map behind the UVs (toggle it in the
Materials panel) with
v = 0at the bottom of the tile. UVs come from the mesh: the sphere primitive carries spherical UVs, and imports bring their own. - The UV editor (bottom panel) picks, rubber-band selects and drags UVs, and
projects, packs and scales/rotates a selection. Island grows the selection
to every UV of the islands it touches — islands are welded across mesh edges
whose UVs match on both endpoints — and Pack Islands puts each island in
its own cell of the
grid × gridatlas with its aspect preserved (the other Pack groups by coincident positions instead). At the Face level the canvas and the viewport share one selection: the faces selected in the viewport light up in the canvas, a pick in the canvas selects those faces in the viewport, and the Material combo assigns a material to them. - Middle-drag orbits the camera; Shift + middle-drag pans; scroll zooms.
- Edit → Find and Replace… finds an object by name (a case-insensitive substring) and walks the matches: Find next and Find previous select them in turn, Replace current renames the one the search landed on (keeping the rest of the name — Wheel_Left with wheel replaced by Rim is Rim_Left) and Replace all does it for every match. Both renames are one undo step.
- Edit → Preferences… edits the theme, the ground grid (on/off, step,
reach) and the camera's orbit/pan/zoom speeds — closing the window files
the choices away in the platform's config directory (
settings.json), and the next start reads them back; Help → Keyboard Shortcuts… lists every global key and the four tool hotkeys, and Help → About OpenForge names the version and the licence. - Ctrl+Z undoes, Ctrl+Shift+Z redoes, Ctrl+D duplicates the selection, Delete removes, F frames all.
- The Properties panel renames a node (one undo step per rename) and the Materials panel lists the library: pick an entry to edit its colour, shininess, opacity and texture, Assign it to the selection, or New to duplicate it. Assigned meshes are shaded with that material in both viewports. The outliner's Visible box hides or shows one node — one undo step, the same edit H makes.
- File → Import/Export loads and saves OBJ, glTF/GLB, PLY, STL, COLLADA, OFF and FBX ASCII. Both go through the plugin host's filter registry, so a third-party filter for another extension needs no change to the app: the open dialog is grouped by format, Export selected as ▸ picks the format by name, and dropping files on the window imports them the same way. A path no filter knows is turned down with the list of extensions that would have worked, exporting is refused when the selection holds more than one mesh node rather than quietly writing one, and the statuses name the file and the format.
- File → Open Project… / Save Project / Save Project As… keeps the whole
document — the scene with its transforms, visibility, LOD tags and per-face
materials, the material library with its textures, the animation clips and
(since format version 2) the skinned rig with its pose, weights and bind
positions — in one versioned
.ofprojJSON file (Ctrl+O/Ctrl+S/Ctrl+Shift+S). Opening replaces the document and starts a fresh undo history; a file that is not JSON, not an OpenForge project, or from a newer build is refused with a sentence saying which one it was. - The window says what the document is: the title carries the file's name and a star while there are unsaved edits, File → Open Recent lists the last eight projects (with the full path in the tooltip and a clear-the-list item), and New, Open and Quit — the window's own close button included — stop at one unsaved changes modal (Discard / Cancel) instead of throwing work away silently.
- The Plugins section of the Properties panel lists the loaded plugins and the services, tools and formats they contributed.
- Create menu adds primitives (box, sphere, cylinder, cone, plane).
cargo test # unit + integration testsopenforge-core and openforge-io carry unit tests for geometry, the scene
graph, undo, picking, and every format. openforge-io/tests/roundtrip.rs writes
real files to disk and re-imports them for every format.
This is a deliberately solid foundation, not a feature-complete clone of a
mature commercial modeler. Sub-element editing, deformers, boolean CSG that
comes out closed, skeletal skinning, materials, texture mapping end to
end,
occlusion-exact sub-object picking — faces, edges and vertices, by click or
by rectangle —, the six orthographic viewports, the gizmo's axes modes
with a placeable pivot and a depth-tested
wgpu viewport are in; what remains is
mostly application surface. Natural next steps:
- Gizmo polish: the plane and centre handles, numeric rotation/scale entry, the world / local / screen axes modes and the placeable pivot are in; soft selection and a numeric readout of the measure a drag is carrying are still to come. Snapping is in for the grid and for vertices, confined to the axes the grabbed handle can move and taken in the frame those axes define; snapping to edges, faces and midpoints, and snapping during the free-form view-plane drag, are still to come.
- A deeper GPU viewport: shading depth on top of the ID pass that now resolves faces, edges and vertices — and rectangles of them — exactly as drawn (the select-through switch answers from the projection instead), with a picked edge already highlighted in the frame and the selection tools (all / none / invert, grow / shrink, edge loop / ring, hide / isolate) in place: the four material maps and a per-material highlight now reach the fragment stage, and the multi-view layout (several viewports side by side, each with its own camera and its own offscreen target) is in, leaving shadowing and a configurable shading mode as the honest gaps, then the materials node graph — mipmapped sampling, a per-map UV channel, opacity/blend and bump/environment maps on top of the four that sample today.
- A deeper UV editor: island weld/split/mirror, snapping, and the live sync with the 3D face selection are in (with a per-face material assignable from the canvas); what is left is a real unwrap, packing that respects a chosen scale, and baking a texture into the layout.
- A deeper rig: the bone chain, the pose, the weights (assign, blend, smooth and a brush) are in; what is left is a bone hierarchy drawn in the viewport, constraints and IK, and the weights on the GPU path.
- The app's settings persist across runs now (the platform's config directory) — including the workspace: which panels were open (View ▸ Outliner, Properties, UV Editor, Timeline) and how big the window was — and the project's own conveniences (the recent-projects list, the title's star and the unsaved-changes guard) are in; what remains here is an asset browser and the deeper panel/dialog surface.
- More standard formats (USDZ, binary FBX, glTF extensions) and a fuller
COLLADA and FBX (node transforms, effects, animation, the FBX
Materialssection) behind the sameopenforge-iotraits. - Dynamic plugin loading (
cdylibfilters) behind the host seam, which already mirrors the study architecture's service/tool/filter split.
MIT OR Apache-2.0.