Files
2026-11-11 02:51:54 +01:00

23 KiB
Raw Permalink Blame History

Architecture

Last reviewed: 2026-11-05 · Last updated: 2026-11-05 · Change: evaluator and editor organization · Target: the professional node-based material workflow

MaterialCraft is a Cargo workspace of small crates in strict layers (cargo xtask layers enforces them). Everything below the UI is plain Rust with no UI toolkit and builds for wasm32-unknown-unknown.

L0  core      document model: Package → Graph → Node / Connection / Frame / Comment / Pin / ExposedParameter;
              Value (float, vectors, int, bool, color, enum, text, curve, gradient, function graph)
L0  image     Image: grayscale or RGBA f32 planes behind an Arc (copy-on-write, free to share);
              wrap/clamp sampling, bilinear, box/bilinear resampling, sRGB, 8-bit display
L0  geom      meshes, primitives (rounded cube, sphere, cylinder, plane, torus, material ball), BVH, camera
L1  nodes     30 main-graph entries (NodeDefinition), 85 function operations, four pattern node kinds;
              shared image operations and iterative function/pattern execution
L2  eval      cooking: topological order, output sizes (relative to input / parent, absolute, preview
              cap), parameter resolution (defaults, overrides, bindings to exposed inputs), the
              content-addressed LRU cache, graph instances (sub-graphs, recursion-guarded)
L2  formats   native .sbs packages, .mcraft ZIP packages, .mcmat portable materials, bitmap/SVG decode,
              texture encode (PNG/JPEG/TGA/BMP/WEBP/EXR/HDR), atomic writes
L3  engine    Session (open packages, active graph, selection, 2D/3D view state, snapshot undo, cache),
              the command registry, inspect, sample materials and graph templates
L3  render    the 3D view: BVH ray casts, the height output displacing a subdivided mesh, GGX PBR with
              prefiltered IBL + SH diffuse, alpha transparency (layered ray compositing) and an
              alpha channel view, ACES tone mapping; procedural environments
L4  automation MCP server (rmcp), JSON-lines RPC (stdio/TCP with token), bridge client, workspace sandbox
L5  ui-egui   the egui shell: docking (craft-ui), graph editor, library, properties, 2D/3D views,
              dialogs, menus, control channel with synthetic input
apps          materialcraft (desktop), materialcraft-cli, materialcraft-web

Cooking

A node's cook is a pure function fn(&CookContext) -> Result<Vec<Image>, String>: CookContext carries the output size, the seed, tiling, the inputs (by port order, None when unconnected), the resolved parameters, a resource decoder and the graph's exposed values. eval::cook walks the graph in dependency order. For each node it hashes the definition id, resolved parameters, size, seed, tiling, the keys of its input images, resource bytes and (for instances) the sub-graph: equal keys hit the cache, so an edit re-cooks only the nodes downstream of it, and undo finds earlier results again. A failing node shows a magenta image and its error; nodes after it still cook.

Each cook builds a borrowed core::GraphIndex for node and connection lookups. PreparedGraph resolves the needed nodes' ports once; graph_execution schedules them and node_execution handles a single node. Parameter resolution, graph instances, value execution, output collection and cache keys have separate modules. The index lives only for the cook, so it needs no invalidation protocol.

Function execution shares one interpreter. Pixel Processor borrows the caller's variables and keeps only assignments in its per-pixel state; ordinary function evaluation retains the full environment for callers such as FX-Map that read the resulting variables.

Sizes: generators take the graph ("parent") size; filters take their main input's size; both can be offset by powers of two or set absolutely. While editing, outputs are capped at the preview size (512 px by default), with pixel-based parameters scaled by w / 256 so previews and full-size exports look the same. Exports cook at full size through the same cache.

The 2D toolbar and the 3D view's Display menu expose the shared preview limit (256, 512, 1024, 2048 or 4096 px, or Full size). This is a cap, so a 4K preview requires a graph/node configured for 4096 px or larger. Both controls run view.previewSize and persist the choice in preferences; control-channel calls to the same command also retain their size across UI frames. The 3D view uses those cooked maps, while its rendered frame follows the panel's physical pixel dimensions. If the device supports smaller textures than the cooked image, the 2D view reduces only the uploaded texture to that limit; source pixels, readout and screenshots retain the cooked size.

The desktop UI cooks on a worker thread (ui-egui/src/cook.rs) from a snapshot of the package and keeps drawing the last results; the browser cooks on the UI thread.

Commands and automation

Session exposes the active package and keeps inactive editing state in OpenDocument. Switching packages retains their undo histories, selections and previews and invalidates pending cook results. Linked main and Function graph instances store a source path, graph name and cached connector interface. linked_packages publishes source snapshots and refreshes dependent packages; linked_files resolves saved references on native open. Source snapshots share a flat registry of Arc<Package> values whose own registries are empty, so cross-file references cannot create ownership cycles. The registry is runtime state; SBS serializes the file references and connectors instead. Function instances resolve to bounded runtime call bodies before cooking. Each compiled call owns its program and reuses separate evaluation storage, so source lookup and compilation never happen per pixel. Calls share the execution-step budget and are capped at eight levels and 4096 nodes. SBS Function nodes store the source URI in their instance data; a materialcraftInterface cache preserves unconnected input pins when a source file is unavailable.

Package folders are part of each graph's SBS identity. Exact paths take priority over leaf names; a leaf name is accepted only when it is unambiguous. Unconnected function-instance inputs use the source's declared defaults. Unknown function operations retain their connector names, data and output designation so an unavailable implementation does not erase the containing graph. Missing external resources retain their paths for repair on a later load.

Empty folders also persist as native SBS groups. Explorer renders these groups as a collapsible tree, including resources in their original folders. Folder creation, rename, removal, copying and moving use engine commands with snapshot undo. Renames and moves update local instance paths; copies retarget references between copied graphs. The Explorer clipboard survives document changes. Single clicks select Explorer rows; double clicks open their graph or input/output preview. The Explorer New submenu creates items immediately with numbered default names, advancing past name conflicts in the destination folder. Rename remains an explicit context-menu action.

sbs:// resolves beneath assets/sbs/. Native installations can place that directory beside the executable or set MATERIALCRAFT_SBS_DIR; packaged and browser builds also embed the standard library. Alias paths cannot escape that root, and saved packages retain the alias. The Function Library lists these source graphs and inserts references into Function and processor canvases. Library aliases are read-only save destinations; an edited library can be saved to a local path. Resource paths resolve beside their owning package: resources/tile.png in sbs://functions.sbs reads assets/sbs/resources/tile.png, also addressable as sbs://resources/tile.png. Nested packages use their own adjacent resources/ directory. Loaded resource bytes are embedded on save; unavailable files keep their reference and missing state.

Every session loads the whole library folder once per process (engine::node_library, cached until library.reload). Each package joins every session's dependencies, so its graphs can be instanced, and the Library lists them: function graphs under Functions, sorted into its sub-categories by their folder (Functions/Math/Pi → Constant), other graphs under their attribute category. A package whose titled frames name Library categories ("Filters - Blurs") can also lay the tree out (formats::sbs::read_layout): each node inside a frame becomes an entry there, listed as missing until a package in the folder provides it. Installed and browser builds embed functions.sbs.

Importing a user-supplied function package preserves its graph bodies, connections and positions; it does not replace them with the bundled library. Redundant legacy instance connectors are retained on save and evaluated only through the declared inputs. Conflicting obsolete connections remain visible errors. A shared typed system-variable catalog drives Get-node suggestions and execution. Ordinary functions expose size, log2 size, pixel ratio, tiling, physical size, UV tile, time and normal-format variables. Pixel Processor adds $pos; FX-Map adds $pos, $depth, $depthpow2 and $number. Reusable function instances inherit their caller's scope. Out-of-scope variables remain undefined even if a user input has the same name, and Set cannot overwrite system variables. Get properties offer a type-filtered dropdown and accept typed custom names.

Node tiling and pixel ratio resolve from absolute values, the first connected image input or the parent graph. SBS preserves these inheritance modes and the graph's physicalSize attribute. The host supplies time, UV tile and a raw normal-format integer through EvaluationEnvironment; the static authoring defaults are zero. These values propagate through nested graph instances without becoming constants in saved functions. Relevant variable changes invalidate cached cooks. The normal-format integer is passed through without assigning a convention label to an unverified code.

Numeric, Boolean, color, enum and text value parameters can own a Function graph, including inputs on material graph instances. The six base parameters store their functions separately from node-specific parameters so native identifiers cannot collide. Size and seed functions establish the evaluation context before other parameters run; downstream nodes inherit evaluated nominal sizes rather than capped previews. SBS writes these functions as native dynamic values, including incomplete graphs. Exposing a parameter creates a typed Get-node function. Properties offers the same function creation, editing and reset actions for base parameters and instance inputs. Only Specific Parameters offer exposure: a modal drafts the identifier, label, group, description, editor, default, limits, clamp and step before one undoable command creates the input and function. Inheritance buttons show distinct icons for input, parent and absolute modes.

The local assets/sbs/functions.sbs is converted from a user-supplied package with cargo run -p materialcraft-formats --release --example standard_function_library -- <source.sbs> assets/sbs/functions.sbs. The converter preserves all 171 function bodies (2,334 nodes), folder paths, identifiers, inputs, defaults and internal references. It writes new interface-based descriptions, generates node positions and omits source parameter descriptions, annotations and icons. It rejects import warnings and functions that cannot compile instead of substituting empty graphs. The source graph bodies are retained; rewritten metadata does not establish redistribution rights. Local tests compare every converted body and evaluate all 171 functions against the supplied package with valid inputs and caller context. External runtime import and numerical compatibility still require validation. SBS's updaterVersion derives from the workspace's Cargo version; its format schema version is a separate constant.

Every user-visible action is a CommandSpec (engine/src/*_cmds.rs): id, label, menu, shortcut, params doc, enabled, run. The UI calls them through MaterialCraftApp::run; the CLI, the JSON control channel and MCP dispatch the same ids. Session::edit snapshots the package before running an edit and restores it on error; edit_merged folds repeated edits with the same key (a slider drag) into one undo step. See control-protocol.md.

The UI

MaterialCraftApp owns the session and UiState (serde, persisted by eframe, readable and writable through ui.inspect / ui.set). The dock (craft-ui) hosts eight panels around the protected graph panel. The graph editor lays nodes out in graph space, draws cards with thumbnails from the cook results (uploaded once per cache key), wires as cubic beziers between typed ports (hit-tested against the curve, so wires can be selected, deleted, swapped or split with a reroute dot), and turns gestures into commands only when they complete. See wires-and-alpha.md. The 3D view renders with materialcraft-render on the CPU: half resolution while the camera moves, 2×2 supersampled when it rests.

The 3D material has its own MaterialPreview assignment state. Automatic graph output usages and explicit MaterialSource overrides resolve to channels from core::MaterialChannel, shared with the renderer and menus. A source records its graph, node and named port; selection and 2D preview do not change it. PreviewCook publishes the editor cook and any other assigned graphs together through the existing worker/cache, so opening a Function Graph preserves the material. Render invalidation includes both source assignments and cooked content. Snapshot undo and graph deletion retain or adjust source ownership; package replacement clears old assignments.

core::ComponentUsage defines output metadata once for node properties, SBS and exports. Its indices stay stable as choices are added; renderable channels remain a separate capability. Explorer and canvas node labels share display formatting without rewriting identifiers. Explorer lists every graph input/output, and graph drops dispatch the existing material assignment command.

crates/ui-egui/tests/snapshot.rs renders the real UI offscreen with egui_kittest + wgpu for visual checks and README screenshots.

The regular and function canvases separate drawing, geometry, navigation and input handling. Shared modules own node cards, selection and annotation gestures. The regular canvas borrows the document while drawing, then dispatches edits. Graph Properties follows the same pattern; its property/ modules separate panel routing, node and graph settings, parameter controls, curves and gradients. Function and pattern Properties dispatch value edits without cloning graphs in the UI. Explorer inspection uses a document-and-graph target independent of the canvas. Shared graph attribute controls retain text drafts until focus changes, then dispatch targeted engine commands. Inactive packages record those edits in their own snapshot history. Both graph kinds share the SBS attribute codec; display labels are resolved separately from reference identifiers.

The properties panel builds every parameter editor from the node catalog; the Levels node adds a histogram editor with range handles on top of its sliders (levels-editor.md).

Shared graph editing

The graph panel renders one toolbar before dispatching to the material, Function Graph or FX-Map canvas. Framing, alignment, distribution and snapping occupy the same positions. Image inserts, parent texture size, annotations and parent-graph navigation appear only where the graph supports them. Alignment uses the same engine command for all three models, with explicit embedded node IDs and an explicit Function target or FX-Map owner ID, so overlapping IDs cannot move the parent material nodes. Pixel Processor, Value Processor and parameter functions use that same main panel and function node catalog, with one target-aware availability rule shared by node creation, Library and Add node. Both processor types accept Sample gray and Sample color to read their image inputs. A breadcrumb and parent action return from nested functions, FX-Maps and local graph instances while retaining the parent view and material preview.

ui-egui/graph/graph_camera.rs owns framing, pointer-centered scroll/pinch zoom and navigation shortcuts. node_selection.rs owns click and box selection; port_hit.rs owns nearest-port picking; wire_geometry.rs owns curve construction. Node movement uses the engine's graph_grid::translate for both previews and commits.

engine/graph_edit/ owns Function/FX-Map selection commands, clipboard remapping, deletion and connected-node traversal. Its small model adapters supply node fields, link direction, ID allocation and result/root handling. Material clipboard commands retain their named-port connections and cross-graph clipboard rules. Rendering and connection validation remain specific to each model; all document changes still pass through engine commands and snapshot undo. selection_commands.rs routes the existing selection and clipboard command IDs to these adapters, including headless calls. Read-only selection actions borrow the graph. Document, selection and clipboard updates are published only after validation succeeds; clipboard provenance prevents external links crossing graph targets. graph_validation.rs supplies the same checks to creation, legacy setters and targeted edits.

graph/editor_target.rs resolves command ownership for the material canvas, standalone Function Graphs, FX-Map canvases and embedded Function Graphs. Menus, keyboard shortcuts and ui.menu.invoke, Library and Properties use that resolver; ui.inspect reports the same editor and its canvas coordinates. Embedded editors record their owning graph and close when undo or another command makes that owner unavailable. Common context-menu entries live in graph/edit_menu.rs. Pointer gestures finish only when their initiating button is released; Escape or focus loss cancels pending canvas movement.

Function and FX-Map rendering borrow the document, prepare screen geometry, and dispatch edits after painting. Function wires reuse one node-layout index instead of scanning the graph for each endpoint. The engine's graph.function.edit and graph.pattern.edit commands own creation, movement, connections, values and result/root changes. Function targets identify standalone, parameter, dynamic-parameter or nested FX-Map functions explicitly. Value updates may request merge: true; edit.finish closes that undo group. The UI closes groups at gesture boundaries, while discrete edits always receive their own undo entry.

Files

SBS XML is the document representation. A native .sbs saves assets in an adjacent resources/ directory and references them with relative paths. A .mcraft is a ZIP containing the fixed root entry package.sbs and resources/, with no manifest or private graph payload. Both formats open, save, reload and save copies independently. The same clean-room SBS codec writes native filters, ports, parameters, function graphs, pattern graphs and resources. Unsupported imports produce warnings; unknown nodes remain placeholders.

formats::project::ProjectFiles builds either representation without changing the live package. Imports retain original bytes in snapshot history, including before the first save. Content-based asset filenames avoid overwriting resources shared by another SBS and allow the XML to be replaced last. Missing assets cause saving to fail. Existing unreferenced files are left in place. Archives are read in memory and validate paths, duplicate entries, symlinks and malformed data. Project I/O and resource imports impose no fixed file-size, resource-count or linked-package-count ceilings. Referenced packages are included under resources/packages/; unresolved installed-library aliases retain their sbs:// references. Autosaves use .mcraft.

Explorer shows its reserved Resources folder once it contains assets or subfolders. Its Import submenu accepts bitmaps or SVG vector graphics into the selected asset folder. Subfolders can be created, renamed and removed; graph creation and graph-folder moves into this tree are rejected. The root Resources name is reserved regardless of capitalization and cannot be renamed, copied, moved or removed. Import creates an asset; dragging it onto the canvas or choosing Add to graph creates the appropriate bitmap or SVG node. Drops from another open package copy the asset into the destination in one undo step. Imported native SBS groups retain their original resource placement for round-trip fidelity. Folder Properties exposes Identifier and Description; descriptions persist in native SBS group desc elements, including inside MCRAFT. The reserved root identifier is read-only.

Bitmap imports support DDS, WebP, GIF, HDR, EXR, JPEG, PGM, PNG, TIFF, Targa and BMP. DDS uses the MIT OR Apache-2.0 dds decoder; GIF and ASCII/binary PGM use image. GIF loads its first frame; DDS loads its first surface at the largest mip level. Each bitmap format has its own picker filter, and vector import offers SVG only. Both pickers include All formats, restricted to their supported extensions, and descriptive names with extension patterns. Open offers SBS XML Package and MaterialCraft Project plus All formats; Save As offers those two formats separately.

The browser downloads .mcraft directly. SBS downloads use a transport ZIP containing the named SBS and its adjacent resources; extract it to work with the native files. Both forms can be opened in the browser. A bare SBS can load embedded assets, but browser file pickers cannot automatically read adjacent files; use the SBS transport ZIP to include those assets.

The SBS read and write modules expose codec entry points. SbsReader and SbsWriter own serialization state; focused helpers handle values, functions, patterns and canvas annotations. Native value type codes come from core::ValueType rather than a second scalar type table.

See atomic-workflow.md for the tested scope and remaining limitations.

Other apps

A .mcmat portable material (formats/src/material/, engine/src/material_cmds.rs) holds a graph's cooked maps, its portable exposed inputs, presets and input nodes, and the source package. TextureCraft paints with it and re-cooks it by running materialcraft-cli run <package> file.exportMaterial {path, size, values, inputs}: the apps share the file format and the command line, never code. Graphs keep preview images for their input nodes (Graph::input_images, mesh maps from TextureCraft); eval::cook feeds them to the top-level graph, and a caller's images win.

Preview cooks publish the editor graph and assigned material graphs together. A synchronous cook invalidates older worker results. Displacement uses the assigned Height source even while another graph or an embedded function is open. Native image loading checks dimensions before decoding; bitmap resources and graph input previews can request linear data-map reads.