Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ATTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ images, materials, sample graphs, presets, environments) may ever be added.
| `docs/images/materialcraft-ground.png` | README screenshot: the mossy ground template | MaterialCraft contributors | Offscreen render of MaterialCraft's UI | MIT OR Apache-2.0 |
| `docs/images/materialcraft-new-graph.png` | README screenshot: the New graph dialog | MaterialCraft contributors | Offscreen render of MaterialCraft's UI | MIT OR Apache-2.0 |
| `docs/images/materialcraft-materials.png` | README render: eight sample materials on spheres | MaterialCraft contributors | `materialcraft-cli render --scene sphere --full --hq` of the templates, eight renders in a grid | MIT OR Apache-2.0 |
| `docs/images/materialcraft-levels-editor.png` | Docs screenshot: the Levels editor with the input histogram | MaterialCraft contributors | Offscreen render of MaterialCraft's UI (`crates/ui-egui/tests/snapshot.rs`) | MIT OR Apache-2.0 |
| `docs/brand/artcraft-*.svg`, `docs/brand/artcraft-*.png` | ArtCraft logo and mark | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms, not open source ([`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt)) |
| `crates/nodes/src/text.rs` (`GLYPHS`) | Single-stroke vector font of the Text node (A–Z, a–z, 0–9, punctuation) | MaterialCraft contributors | Original, designed as stroke data in code | MIT OR Apache-2.0 |

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ ray-traced ambient occlusion and a shadowed key light).

- **Familiar.** The layout, panels, menus and gestures material artists already know: a package
explorer, a library of nodes with previews, the node graph with live thumbnails, a 3D view, a 2D
view and a properties panel with base and specific parameters. Space opens the node search.
view and a properties panel with base and specific parameters (Levels is edited over a
histogram of its input, see [docs/levels-editor.md](docs/levels-editor.md)). Space opens the
node search.
- **Fast.** Every node cooks on every CPU core, and a content-addressed cache means an edit
re-cooks only what lies downstream: the brick wall sample cooks in about 40 ms at preview size.
Cooking runs on a worker thread, so the UI never waits.
Expand Down
31 changes: 31 additions & 0 deletions crates/nodes/src/atomic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,8 @@ fn hsl(ctx: &Ctx) -> CookResult {
pub fn levels_fn(v: f32, in_low: f32, in_mid: f32, in_high: f32, out_low: f32, out_high: f32) -> f32 {
let span = in_high - in_low;
let t = if span.abs() < 1e-6 { if v >= in_high { 1.0 } else { 0.0 } } else { ((v - in_low) / span).clamp(0.0, 1.0) };
// NaN (from an input or a hostile parameter) would otherwise spread through the graph.
let t = if t.is_finite() { t } else { 0.0 };
let mid = in_mid.clamp(0.001, 0.999);
let g = 0.5f32.ln() / mid.ln();
out_low + t.powf(g) * (out_high - out_low)
Expand Down Expand Up @@ -522,3 +524,32 @@ pub fn output_kind(def: &NodeDef, port: usize, inputs: &[Option<Image>]) -> Kind
_ => inputs.iter().flatten().next().map_or(Kind::Gray, |i| i.kind),
}
}

#[cfg(test)]
mod levels_tests {
use super::levels_fn;

#[test]
fn levels_remaps_the_input_range_midtones_and_output_range() {
let close = |a: f32, b: f32| (a - b).abs() < 1e-5;
// Identity by default.
for v in [0.0, 0.25, 0.5, 1.0] {
assert!(close(levels_fn(v, 0.0, 0.5, 1.0, 0.0, 1.0), v));
}
// The input range stretches and clips.
assert!(close(levels_fn(0.4, 0.2, 0.5, 0.6, 0.0, 1.0), 0.5));
assert!(close(levels_fn(0.1, 0.2, 0.5, 0.6, 0.0, 1.0), 0.0));
assert!(close(levels_fn(0.9, 0.2, 0.5, 0.6, 0.0, 1.0), 1.0));
// Mid is relative to the input range: the value there becomes 0.5.
assert!(close(levels_fn(0.25, 0.0, 0.25, 1.0, 0.0, 1.0), 0.5));
assert!(close(levels_fn(0.3, 0.2, 0.25, 0.6, 0.0, 1.0), 0.5));
// The output range, inverted when low > high.
assert!(close(levels_fn(0.5, 0.0, 0.5, 1.0, 0.2, 0.6), 0.4));
assert!(close(levels_fn(0.0, 0.0, 0.5, 1.0, 1.0, 0.0), 1.0));
// A collapsed input range is a threshold; NaN never escapes.
assert_eq!(levels_fn(0.49, 0.5, 0.5, 0.5, 0.0, 1.0), 0.0);
assert_eq!(levels_fn(0.5, 0.5, 0.5, 0.5, 0.0, 1.0), 1.0);
assert!(levels_fn(f32::NAN, 0.0, 0.5, 1.0, 0.0, 1.0).is_finite());
assert!(levels_fn(0.5, f32::NAN, 0.5, f32::NAN, 0.0, 1.0).is_finite());
}
}
243 changes: 243 additions & 0 deletions crates/ui-egui/src/levels_editor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
//! The Levels node's editor in the properties panel: a histogram of the node's input with the
//! levels transfer curve over it, the input range handles (low, mid, high) under it and the
//! output range handles (low, high) under a black-to-white bar.
//!
//! The mid handle sits where `Level in mid` really acts: relative to the input range, so it moves
//! with the low and high handles. The input low and high handles cannot cross (the low one stays
//! left of the high one); the output handles may, which inverts the result. Every drag is a
//! `graph.node.param` command, merged into one undo step per handle.

use egui::{Color32, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
use materialcraft_engine::core::{Node, NodeId, Value};
use materialcraft_engine::image::{Image, Kind};
use materialcraft_engine::nodes::atomic::levels_fn;
use serde_json::json;

use crate::MaterialCraftApp;
use crate::i18n::tr;
use crate::theme::Tokens;

/// Histogram bins (one per 8-bit level).
pub const BINS: usize = 256;

/// Smallest gap kept between the input low and high handles.
const MIN_SPAN: f32 = 0.004;

/// Histograms of an image's values in 0..1, clamped: one for a gray image, else R, G and B.
pub fn histogram(img: &Image) -> Vec<[u32; BINS]> {
let channels = if img.kind == Kind::Gray { 1 } else { 3 };
let stride = img.kind.channels();
let mut h = vec![[0u32; BINS]; channels];
for px in img.data.chunks(stride) {
for (c, bins) in h.iter_mut().enumerate() {
let v = px.get(c).copied().unwrap_or(0.0);
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
if let Some(b) = bins.get_mut(((v * (BINS - 1) as f32).round() as usize).min(BINS - 1)) {
*b = b.saturating_add(1);
}
}
}
h
}

/// The bar height scale: the tallest bin away from pure black and white (masks pile up there and
/// would flatten everything else), at least one.
fn scale(h: &[[u32; BINS]]) -> f32 {
let inner = h.iter().flat_map(|b| b.get(1..BINS - 1).unwrap_or(&[]).iter().copied()).max().unwrap_or(0);
let all = h.iter().flat_map(|b| b.iter().copied()).max().unwrap_or(0);
(if inner > 0 { inner } else { all }).max(1) as f32
}

/// The five levels values of `n`: in low, in mid (relative), in high, out low, out high.
pub fn values(n: &Node) -> [f32; 5] {
let get = |id: &str, d: f32| match n.params.get(id) {
Some(Value::Float(v)) if v.is_finite() => *v,
_ => d,
};
[get("in_low", 0.0), get("in_mid", 0.5), get("in_high", 1.0), get("out_low", 0.0), get("out_high", 1.0)]
}

/// Where the mid handle sits on the 0..1 axis.
pub fn mid_position(v: &[f32; 5]) -> f32 {
v[0] + v[1].clamp(0.0, 1.0) * (v[2] - v[0])
}

/// The new value of handle `k` (0 in low, 1 in mid, 2 in high, 3 out low, 4 out high) dragged to
/// `x` on the 0..1 axis: input low and high cannot cross, mid is stored relative to the range.
pub fn drag_value(v: &[f32; 5], k: usize, x: f32) -> f32 {
let x = if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 };
let (lo, hi) = (v[0].min(v[2]), v[0].max(v[2]));
match k {
0 => x.min(hi - MIN_SPAN).max(0.0),
2 => x.max(lo + MIN_SPAN).min(1.0),
1 => {
let span = v[2] - v[0];
if span.abs() < 1e-6 { 0.5 } else { ((x - v[0]) / span).clamp(0.01, 0.99) }
}
_ => x,
}
}

/// The histogram of what feeds the node's input, computed once per cooked result.
fn input_histogram(app: &MaterialCraftApp, ui: &Ui, node: NodeId) -> Option<std::sync::Arc<Vec<[u32; BINS]>>> {
let g = app.session.graph().ok()?;
let c = g.input_of(node, "input")?;
let port = app.session.output_ports(c.from).iter().position(|p| *p == c.from_port)?;
let r = app.session.cooked.nodes.get(&c.from)?;
let img = r.outputs.get(port)?;
let id = egui::Id::unique(("levels-histogram", r.key, port));
if let Some(h) = ui.ctx().data(|d| d.get_temp::<std::sync::Arc<Vec<[u32; BINS]>>>(id)) {
return Some(h);
}
let h = std::sync::Arc::new(histogram(img));
ui.ctx().data_mut(|d| d.insert_temp(id, h.clone()));
Some(h)
}

/// A handle: a small triangle pointing up at `x`, filled with `fill`.
fn handle(p: &egui::Painter, x: f32, top: f32, fill: Color32, active: bool, tk: &Tokens) {
let s = 6.0;
let pts = vec![pos2(x, top), pos2(x - s, top + s * 1.6), pos2(x + s, top + s * 1.6)];
p.add(Shape::convex_polygon(pts, fill, Stroke::new(if active { 1.5 } else { 1.0 }, if active { tk.accent } else { Color32::from_gray(0x10) })));
}

/// The editor for Levels node `n`.
pub fn show(app: &mut MaterialCraftApp, ui: &mut Ui, n: &Node) {
let tk = Tokens::get(ui.ctx());
let v = values(n);
// Handles of parameters driven by an input or a function are left alone.
let locked = |id: &str| n.bindings.contains_key(id) || n.functions.contains_key(id);
let ids = ["in_low", "in_mid", "in_high", "out_low", "out_high"];
let w = ui.available_width().max(120.0);
let pad = 7.0;
let (rect, _) = ui.allocate_exact_size(vec2(w, 96.0 + 16.0 + 12.0 + 16.0), Sense::hover());
let axis = |x: f32| rect.left() + pad + x * (rect.width() - 2.0 * pad);
let unaxis = |sx: f32| (sx - rect.left() - pad) / (rect.width() - 2.0 * pad).max(1.0);
let plot = Rect::from_min_max(pos2(axis(0.0), rect.top()), pos2(axis(1.0), rect.top() + 96.0));
let p = ui.painter_at(rect.expand(2.0));
p.rect_filled(plot, 2.0, Color32::from_gray(0x16));

// The input histogram.
match input_histogram(app, ui, n.id) {
Some(h) => {
let k = scale(&h);
let colors: &[Color32] = if h.len() == 1 {
&[Color32::from_gray(0xb8)]
} else {
&[
Color32::from_rgba_unmultiplied(0xe0, 0x50, 0x50, 110),
Color32::from_rgba_unmultiplied(0x50, 0xd0, 0x60, 110),
Color32::from_rgba_unmultiplied(0x50, 0x80, 0xf0, 110),
]
};
let bw = plot.width() / BINS as f32;
for (bins, col) in h.iter().zip(colors) {
for (i, &c) in bins.iter().enumerate() {
if c == 0 {
continue;
}
let hgt = (c as f32 / k).min(1.0) * (plot.height() - 2.0);
let x0 = plot.left() + i as f32 * bw;
p.rect_filled(Rect::from_min_max(pos2(x0, plot.bottom() - hgt), pos2(x0 + bw.max(1.0), plot.bottom())), 0.0, *col);
}
}
}
None => {
p.text(plot.center(), egui::Align2::CENTER_CENTER, tr("Connect an input to see its histogram"), egui::FontId::proportional(10.5), tk.text_dim);
}
}
// Shade what the input range clips, and draw the transfer curve.
let (lo, hi) = (v[0].min(v[2]), v[0].max(v[2]));
let shade = Color32::from_black_alpha(120);
p.rect_filled(Rect::from_min_max(plot.min, pos2(axis(lo), plot.bottom())), 0.0, shade);
p.rect_filled(Rect::from_min_max(pos2(axis(hi), plot.top()), plot.max), 0.0, shade);
let curve: Vec<Pos2> = (0..=96)
.map(|i| {
let x = i as f32 / 96.0;
let y = levels_fn(x, v[0], v[1], v[2], v[3], v[4]);
let y = if y.is_finite() { y.clamp(0.0, 1.0) } else { 0.0 };
pos2(axis(x), plot.bottom() - y * plot.height())
})
.collect();
p.add(Shape::line(curve, Stroke::new(1.5, tk.accent)));
p.rect_stroke(plot, 2.0, Stroke::new(1.0, Color32::from_gray(0x40)), StrokeKind::Inside);

// Input handles under the histogram, output handles under the gradient bar.
let in_row = plot.bottom() + 2.0;
let bar = Rect::from_min_max(pos2(plot.left(), in_row + 16.0), pos2(plot.right(), in_row + 26.0));
let mesh = {
let mut m = egui::Mesh::default();
m.colored_vertex(bar.left_top(), Color32::BLACK);
m.colored_vertex(bar.right_top(), Color32::WHITE);
m.colored_vertex(bar.right_bottom(), Color32::WHITE);
m.colored_vertex(bar.left_bottom(), Color32::BLACK);
m.add_triangle(0, 1, 2);
m.add_triangle(0, 2, 3);
m
};
p.add(Shape::mesh(mesh));
let out_row = bar.bottom() + 2.0;
let xs = [v[0], mid_position(&v), v[2], v[3], v[4]];
let rows = [in_row, in_row, in_row, out_row, out_row];
let fills = [Color32::from_gray(0x08), Color32::from_gray(0x80), Color32::WHITE, Color32::from_gray(0x08), Color32::WHITE];
let names = ["Level in low", "Level in mid", "Level in high", "Level out low", "Level out high"];
// Mid last, so it wins where it overlaps low or high.
for k in [0usize, 2, 3, 4, 1] {
let x = axis(xs[k].clamp(0.0, 1.0));
let hit = Rect::from_min_size(pos2(x - 7.0, rows[k] - 1.0), vec2(14.0, 13.0));
let resp = ui.interact(hit, ui.scope_id().with(("levels-handle", n.id.0, k)), if locked(ids[k]) { Sense::hover() } else { Sense::drag() });
crate::widgets::name(&resp, egui::Role::Slider, names[k]);
let active = resp.dragged() || resp.hovered();
handle(&p, x, rows[k], fills[k], active, &tk);
if resp.dragged()
&& let Some(ptr) = resp.interact_pointer_pos()
{
let nv = drag_value(&v, k, unaxis(ptr.x));
if (nv - v[k]).abs() > 1e-5 {
app.run("graph.node.param", json!({"id": n.id.0, "param": ids[k], "value": nv}));
}
}
resp.on_hover_text(format!("{}: {:.3}", tr(names[k]), v[k]));
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn histogram_counts_every_value_once_per_channel() {
let gray = Image::gray(4, 4, |x, _| x as f32 / 3.0);
let h = histogram(&gray);
assert_eq!(h.len(), 1);
assert_eq!(h[0].iter().sum::<u32>(), 16);
assert_eq!((h[0][0], h[0][85], h[0][170], h[0][255]), (4, 4, 4, 4));
let color = Image::filled(2, 2, Kind::Color, [2.0, f32::NAN, -1.0, 0.5]);
let h = histogram(&color);
assert_eq!(h.len(), 3);
// Out of range and NaN values land in the end bins.
assert_eq!((h[0][255], h[1][0], h[2][0]), (4, 4, 4));
// The scale ignores the end bins when there is anything in between.
assert_eq!(scale(&[[0; BINS]]), 1.0);
}

#[test]
fn input_handles_do_not_cross_and_mid_follows_the_range() {
let v = [0.2, 0.5, 0.6, 0.0, 1.0];
assert!((mid_position(&v) - 0.4).abs() < 1e-6);
// Low cannot pass high, high cannot pass low.
assert!(drag_value(&v, 0, 0.9) < 0.6);
assert!(drag_value(&v, 2, 0.1) > 0.2);
assert_eq!(drag_value(&v, 0, -1.0), 0.0);
assert_eq!(drag_value(&v, 2, 2.0), 1.0);
// Mid is stored relative to the range, kept inside it.
assert!((drag_value(&v, 1, 0.3) - 0.25).abs() < 1e-5);
assert_eq!(drag_value(&v, 1, 0.0), 0.01);
assert_eq!(drag_value(&v, 1, 1.0), 0.99);
// Output handles may cross (that inverts); NaN never escapes.
assert_eq!(drag_value(&v, 3, 0.8), 0.8);
assert_eq!(drag_value(&v, 4, f32::NAN), 0.0);
// A collapsed range keeps mid centered.
assert_eq!(drag_value(&[0.5, 0.3, 0.5, 0.0, 1.0], 1, 0.7), 0.5);
}
}
1 change: 1 addition & 0 deletions crates/ui-egui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod explorer;
pub mod function_editor;
pub mod graph_view;
pub mod i18n;
pub mod levels_editor;
pub mod library;
pub mod menus;
pub mod properties;
Expand Down
4 changes: 4 additions & 0 deletions crates/ui-egui/src/properties.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ fn node_props(app: &mut MaterialCraftApp, ui: &mut Ui, n: &materialcraft_engine:
return;
}
section(ui, "specific", tr("Specific parameters"), |ui| {
if n.def == "levels" {
crate::levels_editor::show(app, ui, n);
ui.add_space(6.0);
}
let exposed: Vec<(String, String)> =
app.session.graph().map(|g| g.exposed.iter().map(|e| (e.id.clone(), e.label.clone())).collect()).unwrap_or_default();
for p in &def.params {
Expand Down
18 changes: 18 additions & 0 deletions crates/ui-egui/tests/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,24 @@ fn snapshots() {
h.state_mut().ui.frame_all = true;
settle(&mut h, 40);
save(&mut h, &dir, "function-editor");
h.state_mut().fn_editor = None;

// The Levels editor: the input histogram under the transfer curve, with its handles.
h.state_mut().run("file.new", json!({"template": "stone_tiles", "size": 512}));
let levels = {
let g = h.state().session.graph().unwrap();
g.nodes.iter().find(|n| n.def == "levels" && g.connections.iter().any(|c| c.to == n.id)).map(|n| n.id.0)
};
if let Some(id) = levels {
h.state_mut().run("graph.select", json!({"ids": [id]}));
h.state_mut().run("graph.node.param", json!({"id": id, "param": "in_low", "value": 0.3}));
h.state_mut().run("graph.node.param", json!({"id": id, "param": "in_mid", "value": 0.35}));
h.state_mut().run("graph.node.param", json!({"id": id, "param": "in_high", "value": 0.85}));
h.state_mut().run("view.2d", json!({"node": id}));
}
h.state_mut().ui.frame_all = true;
settle(&mut h, 40);
save(&mut h, &dir, "levels-editor");
}

/// Steady-state UI build time per frame with each panel closed in turn (release):
Expand Down
3 changes: 3 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ on the CPU: half resolution while the camera moves, 2×2 supersampled when it re
`crates/ui-egui/tests/snapshot.rs` renders the real UI offscreen with egui_kittest + wgpu for visual
checks and README screenshots.

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](levels-editor.md)).

## Files

A `.mcraft` package is a zip: `package.json` (the serde `Package`, format version 1) and
Expand Down
Binary file added docs/images/materialcraft-levels-editor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading