diff --git a/ROADMAP.md b/ROADMAP.md index d296b96..1b14cb3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,7 @@ end to end today (engine + UI + automation + tests), not what is planned. | Layer stack | 16 | 97% | Paint, fill, folder and **instance** layers; 32 per-channel blend modes and opacities; white/black/painted masks with **mask with height**; generator, fill, levels (with a histogram of its input), invert/threshold, color selection, compare, blur, sharpen and HSL effects on masks and layer content; anchor points; smart masks; mask view (Alt+click); Alt+click a material channel to keep it alone; duplicate, group, drag to reorder, rename, color tags, copy/cut/paste (also as instance) across sets, flatten, instantiate across sets, save layers as smart materials; **30 library filters** (adjustments incl. histogram scan, range and shift, blur/sharpen/high pass, bevel, glow, edge detect, warps, mosaic, baked lighting, material finishes); **geometry masks** (a layer on chosen mesh parts) | A few niche filters of the reference app's catalog | | Painting tools | 14 | 90% | Paint, eraser, projection with image stencils, polygon fill, **path** and **lasso fill**, smudge, clone, color picker, quick mask, Shift+click lines with preview, 2D view painting; size, flow, stroke opacity, spacing, hardness, angle, follow path, jitters, alignment, angle fade, backface culling, lazy mouse, X/Y/Z mirror and **radial symmetry**, pen pressure, procedural and image alphas, **text** in any installed font (as an alpha or a decal), **particle brush** | Pen tilt (not exposed by the windowing layer) | | Materials and assets | 8 | 90% | 63 procedural materials (incl. car paint, velvet, brushed steel, skin, wax, ice, snow, bark), 21 smart materials, 18 smart masks, **67 procedural brush alphas**, **18 brush presets** plus user presets, 23 procedurals, 6 environments plus imported HDR/EXR panoramas, image resources, a user smart material library, a tabbed shelf with search and **drag and drop** onto layers and the viewport | A smaller bundled library than the reference app's | -| Baking | 10 | 100% | AO, curvature (also from the normal map), thickness, world normal, position, ID, normal, height, **bent normals, opacity, color map and world-space direction**; **high-poly meshes**, cage, frontal/rear distances, average normals, **match by mesh name**; per-texture-set selection, common and per-map parameters, progress and cancel; high-poly base color textures embedded or in files beside a `.gltf`, FBX or USD layer | — | +| Baking | 10 | 100% | AO, curvature (also from the normal map), thickness, world normal, position, ID, normal, height, **bent normals, opacity, color map and world-space direction**; **high-poly meshes**, cage, frontal/rear distances, average normals, **match by mesh name**; per-texture-set selection, common and per-map parameters, progress and cancel; **Auto-rebake** (`bake.autoRebake`: one map bakes itself again in the background when its bake settings, the low poly or the high polys change); high-poly base color textures embedded or in files beside a `.gltf`, FBX or USD layer | — | | Viewport and display | 12 | 95% | PBR with image-based lighting, 6 procedural environments or imported HDR/EXR panoramas with exposure, rotation, blur, opacity, ACES/linear tone mapping, HDR pipeline with bloom, vignette, sharpen and grain, key-light shadows, 4× MSAA plus idle temporal accumulation, the baked normal map under the painted normal, channel / mesh-map / mask / UV checker views, 3D and 2D wireframes of the mesh's own polygons, brush cursor, axis gizmos, subsurface scattering blur, **depth of field**, saved cameras and cameras from glTF, FBX, COLLADA and USD files, displacement (also in shadows and the path tracers), **path-traced Rendering mode** (F10, on the GPU or the CPU) with environment importance sampling and an edge-aware **denoiser** | Hardware ray tracing | | Shader | 6 | 100% | Metallic/roughness and specular/glossiness PBR, **clear coat, sheen, anisotropy and subsurface scattering** (viewport and path tracer), height-to-normal, painted normals, parallax occlusion, emissive, opacity (dithered), AO intensity | — | | Export | 8 | 92% | 10 presets (incl. Godot 4 and **OpenPBR** with every shading lobe) plus **user output templates** (per-channel packing, converted maps, JSON import/export), PNG/JPEG/TGA/TIFF/BMP/WEBP/EXR/HDR, 8/16/32-bit, padding modes, size override, OpenGL/DirectX normals, **colour space** of the colour maps (sRGB, Linear Rec.709, ACEScg, ACES2065-1; data maps never converted), `$udim` tiles, **MaterialX** (`file.exportTextures` with `materialX`: a `.mtlx` OpenPBR material per set, `` aware), OBJ, textured GLB (with clearcoat/sheen) and USD (with clear coat) mesh export, zip download in the browser | Sheen and anisotropy in USD (UsdPreviewSurface has no inputs for them; GLB and OpenPBR maps carry them) | diff --git a/crates/engine/src/auto_rebake.rs b/crates/engine/src/auto_rebake.rs new file mode 100644 index 0000000..800eb51 --- /dev/null +++ b/crates/engine/src/auto_rebake.rs @@ -0,0 +1,176 @@ +//! Auto-rebake: one mesh map that bakes itself again when what it was baked from changes. +//! +//! The map is chosen with `bake.autoRebake`. From then on, [`Session::auto_rebake_due`] names the +//! texture sets whose bake inputs (the set's bake settings except the list of maps, the low poly, +//! the high polys and the cage) differ from those of their last bake of that map. +//! The apps poll it each frame and bake the due sets in the background; `bake.autoRebake.run` +//! does the same thing once, blocking, for agents and scripts. +//! +//! A set only takes part once it has the map baked at its current output size: Auto-rebake +//! keeps a bake up to date, it doesn't start one, and it never leaves a set's maps at two sizes. +//! The choice isn't part of the project. + +use std::collections::BTreeMap; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use serde_json::{Value, json}; +use texturecraft_bake::Progress; +use texturecraft_core::BakedMap; + +use crate::bake_cmds::BakeTask; +use crate::commands::{CommandSpec, always, bad, boolp}; +use crate::{Result, Session, cmd}; + +/// Which map rebakes itself, and what each texture set was last baked from. +#[derive(Clone, Debug, Default)] +pub struct AutoRebake { + pub map: Option, + /// Fingerprint of each set's bake inputs when it last baked [`AutoRebake::map`]. + seen: BTreeMap, + /// Fingerprint taken when a set's bake started, kept when that bake finishes. + pending: BTreeMap, +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "bake.autoRebake", + "Auto-rebake", + [], + None, + "{map?: map id (e.g. \"normal_base\", \"height\") to keep up to date, or null / off: true to turn it off; no params: just report} → {map, due: [texture sets that need a rebake]}", + always, + auto_rebake + ), + cmd!( + "bake.autoRebake.run", + "Run Auto-rebake", + [], + None, + "{cpu?: true (skip the GPU)} → {rebaked: [texture sets]}: bakes the Auto-rebake map of every set whose bake inputs changed since it was last baked, now", + always, + auto_rebake_run + ), + ] +} + +impl Session { + /// A fingerprint of everything a bake of texture set `ts` is made from, except which maps it + /// makes. + pub fn bake_inputs_key(&self, ts: usize) -> Option { + let set = self.doc.project.texture_sets.get(ts)?; + let mut settings = set.bake.clone(); + settings.maps.clear(); + settings.enabled = true; + let mut h = DefaultHasher::new(); + serde_json::to_string(&settings).ok()?.hash(&mut h); + (Arc::as_ptr(&self.mesh) as usize).hash(&mut h); + for m in &self.bake_meshes.high { + (Arc::as_ptr(&m.mesh) as usize).hash(&mut h); + } + self.bake_meshes.cage.as_ref().map(|c| Arc::as_ptr(&c.mesh) as usize).hash(&mut h); + Some(h.finish()) + } + + /// Texture sets whose Auto-rebake map is out of date. A set seen for the first time (a project + /// just opened, a bake from before Auto-rebake was on) is taken as up to date. + pub fn auto_rebake_due(&mut self) -> Vec { + let Some(map) = self.auto_rebake.map else { return Vec::new() }; + let mut due = Vec::new(); + for ts in 0..self.doc.project.texture_sets.len() { + let Some(set) = self.doc.project.texture_sets.get(ts).filter(|t| t.bake.enabled) else { continue }; + let columns = set.tile_grid().map_or(1, |g| g.cols); + let baked = self.doc.baked.get(&ts).is_some_and(|b| b.maps.contains_key(&map) && b.resolution == set.bake.resolution.saturating_mul(columns)); + let Some(key) = self.bake_inputs_key(ts).filter(|_| baked) else { continue }; + match self.auto_rebake.seen.get(&ts) { + None => { + self.auto_rebake.seen.insert(ts, key); + } + Some(seen) if *seen != key => due.push(ts), + Some(_) => {} + } + } + due + } + + /// Call right before building the bake task of `ts`: remembers what it is baked from, so a + /// finished bake of the Auto-rebake map marks the set up to date (and a change made while it + /// ran still triggers another). + pub fn begin_bake(&mut self, ts: usize) { + if let Some(key) = self.bake_inputs_key(ts) { + self.auto_rebake.pending.insert(ts, key); + } + } + + /// The bake task of `ts` restricted to one map (Auto-rebake's: the other maps stay as they are). + pub fn bake_task_for(&self, ts: usize, only: Option) -> Result { + let mut task = self.bake_task(ts)?; + if let Some(m) = only { + task.settings.maps = std::iter::once(m).collect(); + } + Ok(task) + } + + /// Records a finished bake: when it made the Auto-rebake map, the set is up to date. + pub(crate) fn note_baked(&mut self, ts: usize, mut made: impl Iterator) { + let key = self.auto_rebake.pending.remove(&ts); + if let (Some(key), Some(map)) = (key, self.auto_rebake.map) + && made.any(|m| m == map) + { + self.auto_rebake.seen.insert(ts, key); + } + } + + /// Records bakes of `sets` that were cancelled, failed or never started: Auto-rebake leaves + /// those sets alone until their bake inputs change again, instead of retrying every frame. + pub fn bake_abandoned(&mut self, sets: impl IntoIterator) { + for ts in sets { + if let Some(key) = self.auto_rebake.pending.remove(&ts).or_else(|| self.bake_inputs_key(ts)) { + self.auto_rebake.seen.insert(ts, key); + } + } + } + + /// Bakes every due set's Auto-rebake map now (blocking); returns the sets. + pub fn auto_rebake_now(&mut self, cpu: bool) -> Result> { + let due = self.auto_rebake_due(); + let map = self.auto_rebake.map; + for &ts in &due { + self.begin_bake(ts); + let mut task = self.bake_task_for(ts, map)?; + if cpu { + task.tracer = None; + } + let out = task.run(&Progress::new())?; + self.apply_bake(ts, out)?; + } + Ok(due) + } +} + +fn state(s: &mut Session) -> Value { + json!({ "map": s.auto_rebake.map.map(BakedMap::id), "due": s.auto_rebake_due() }) +} + +fn auto_rebake(s: &mut Session, p: &Value) -> Result { + if boolp(p, "off") == Some(true) || p.get("map").is_some_and(Value::is_null) { + s.auto_rebake = AutoRebake::default(); + } else if let Some(id) = p.get("map") { + let map = id.as_str().and_then(BakedMap::parse).ok_or_else(|| bad("bake.autoRebake", format!("unknown mesh map {id}")))?; + if s.auto_rebake.map != Some(map) { + s.auto_rebake = AutoRebake { map: Some(map), ..AutoRebake::default() }; + } + } + Ok(state(s)) +} + +fn auto_rebake_run(s: &mut Session, p: &Value) -> Result { + if s.auto_rebake.map.is_none() { + return Err(bad("bake.autoRebake.run", "no Auto-rebake map is set; run bake.autoRebake {map} first")); + } + let rebaked = s.auto_rebake_now(boolp(p, "cpu") == Some(true))?; + s.update(); + Ok(json!({ "rebaked": rebaked })) +} diff --git a/crates/engine/src/bake_cmds.rs b/crates/engine/src/bake_cmds.rs index d638763..57cdbc2 100644 --- a/crates/engine/src/bake_cmds.rs +++ b/crates/engine/src/bake_cmds.rs @@ -177,6 +177,7 @@ impl Session { /// Store a finished bake (one undo step). pub fn apply_bake(&mut self, ts: usize, out: BakeOutput) -> Result<()> { self.log.push(format!("Baked texture set {ts} ({})", out.tracer)); + let made: Vec = out.maps.keys().copied().collect(); self.edit("Bake mesh maps", |s| { let maps: BTreeSet = out.maps.keys().copied().collect(); let entry = s.doc.baked.entry(ts).or_default(); @@ -186,11 +187,14 @@ impl Session { } s.doc.project.texture_set_mut(ts)?.baked.extend(maps); Ok(()) - }) + })?; + self.note_baked(ts, made.into_iter()); + Ok(()) } /// Bake texture set `ts` now (blocking), on the CPU when `cpu`; returns where the rays ran. pub fn bake_now(&mut self, ts: usize, cpu: bool) -> Result { + self.begin_bake(ts); let mut task = self.bake_task(ts)?; if cpu { task.tracer = None; diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 695a933..98a9363 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -68,6 +68,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(crate::uv_tile_cmds::specs()); v.extend(crate::template_cmds::specs()); v.extend(crate::project_cmds::specs()); + v.extend(crate::auto_rebake::specs()); v.extend(crate::bake_cmds::specs()); v.extend(crate::materialx_cmds::specs()); v.extend(crate::file_cmds::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6077319..fe835eb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod auto_rebake; pub mod bake_cmds; pub mod brush_preset_cmds; pub mod camera_cmds; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 9b5acfa..1008457 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -320,6 +320,8 @@ pub struct Session { pub mesh_source: Option<(String, Vec)>, /// High-definition meshes and cage for baking (saved inside the project). pub bake_meshes: crate::BakeMeshes, + /// The map that bakes itself again when its inputs change (not saved). + pub auto_rebake: crate::auto_rebake::AutoRebake, pub caches: Vec, pub(crate) undo: Vec<(String, Document)>, pub(crate) redo: Vec<(String, Document)>, @@ -375,6 +377,7 @@ impl Session { bvh: Arc::new(bvh), mesh_source, bake_meshes: Default::default(), + auto_rebake: Default::default(), caches, undo: Vec::new(), redo: Vec::new(), diff --git a/crates/engine/tests/auto_rebake.rs b/crates/engine/tests/auto_rebake.rs new file mode 100644 index 0000000..9775c19 --- /dev/null +++ b/crates/engine/tests/auto_rebake.rs @@ -0,0 +1,119 @@ +//! Auto-rebake: one mesh map bakes itself again when its bake inputs change. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use texturecraft_engine::core::BakedMap; +use texturecraft_engine::geom::primitives; +use texturecraft_engine::{Session, execute}; + +/// A plane with a high poly just above it, baked once with height and opacity. +fn baked_session(tag: &str) -> (Session, std::path::PathBuf) { + let mesh = primitives::builtin("plane").unwrap(); + let mut obj = String::from("o Part_high\n"); + for p in &mesh.positions { + obj += &format!("v {} {} {}\n", p.x, p.y + 0.002, p.z); + } + for t in mesh.indices.chunks_exact(3) { + obj += &format!("f {} {} {}\n", t[0] + 1, t[1] + 1, t[2] + 1); + } + let dir = std::env::temp_dir().join(format!("tc_auto_rebake_{tag}_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("hp.obj"); + std::fs::write(&path, obj).unwrap(); + let mut s = Session::new_project("t", mesh, "builtin:plane", None, 256).unwrap(); + execute(&mut s, "bake.highPoly.add", &json!({ "path": path.display().to_string() })).unwrap(); + execute(&mut s, "bake.settings", &json!({ "resolution": 128, "dilation": 0, "maps": ["height", "opacity"] })).unwrap(); + execute(&mut s, "bake.run", &json!({ "cpu": true })).unwrap(); + (s, dir) +} + +fn due(s: &mut Session) -> Vec { + execute(s, "bake.autoRebake", &Value::Null).unwrap()["due"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() +} + +fn image(s: &Session, map: BakedMap) -> Arc> { + s.doc.baked[&0].maps[&map].clone() +} + +#[test] +fn a_changed_bake_input_rebakes_only_the_chosen_map() { + let (mut s, dir) = baked_session("rebake"); + // Off by default; nothing is due and the run command says what to do first. + assert_eq!(execute(&mut s, "bake.autoRebake", &Value::Null).unwrap()["map"], Value::Null); + assert!(execute(&mut s, "bake.autoRebake.run", &Value::Null).is_err()); + let r = execute(&mut s, "bake.autoRebake", &json!({ "map": "height" })).unwrap(); + assert_eq!(r["map"], "height"); + assert!(due(&mut s).is_empty(), "turning it on takes the current bake as up to date"); + + // Which maps are ticked is not a bake input; a setting is. + execute(&mut s, "bake.settings", &json!({ "maps": ["height", "opacity", "ambient_occlusion"] })).unwrap(); + assert!(due(&mut s).is_empty()); + execute(&mut s, "bake.settings", &json!({ "maxFrontal": 0.2, "maps": ["height", "opacity"] })).unwrap(); + assert_eq!(due(&mut s), [0]); + + let (height, opacity) = (image(&s, BakedMap::Height), image(&s, BakedMap::Opacity)); + let r = execute(&mut s, "bake.autoRebake.run", &json!({ "cpu": true })).unwrap(); + assert_eq!(r["rebaked"], json!([0])); + assert!(!Arc::ptr_eq(&height, &image(&s, BakedMap::Height)), "the Auto-rebake map was baked again"); + assert!(Arc::ptr_eq(&opacity, &image(&s, BakedMap::Opacity)), "the other maps were left alone"); + assert!(due(&mut s).is_empty(), "and the set is up to date"); + assert_eq!(execute(&mut s, "bake.autoRebake.run", &Value::Null).unwrap()["rebaked"], json!([])); + + // A manual bake of the map also counts; a changed high poly list is a change again. + execute(&mut s, "bake.settings", &json!({ "antialiasing": 2 })).unwrap(); + assert_eq!(due(&mut s), [0]); + s.begin_bake(0); + let out = s.bake_task_for(0, None).unwrap().run(&texturecraft_engine::bake::Progress::new()).unwrap(); + s.apply_bake(0, out).unwrap(); + assert!(due(&mut s).is_empty(), "a manual bake of the map marks it up to date"); + execute(&mut s, "bake.highPoly.remove", &json!({ "all": true })).unwrap(); + assert_eq!(due(&mut s), [0]); + + // A different output size is left to a manual bake: the maps would end up at two sizes. + execute(&mut s, "bake.settings", &json!({ "resolution": 256 })).unwrap(); + assert!(due(&mut s).is_empty()); + + // Hostile input, and turning it off. + for bad in [json!({ "map": "nonsense" }), json!({ "map": 3 })] { + assert!(execute(&mut s, "bake.autoRebake", &bad).is_err(), "{bad}"); + } + assert_eq!(execute(&mut s, "bake.autoRebake", &json!({ "map": null })).unwrap()["map"], Value::Null); + assert!(due(&mut s).is_empty()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_cancelled_or_failed_bake_is_not_queued_again_until_the_inputs_change() { + let (mut s, dir) = baked_session("cancel"); + execute(&mut s, "bake.autoRebake", &json!({ "map": "height" })).unwrap(); + assert!(due(&mut s).is_empty()); + execute(&mut s, "bake.settings", &json!({ "maxFrontal": 0.2 })).unwrap(); + assert_eq!(due(&mut s), [0]); + + // What the app does: start the bake, cancel it, record the abandoned bake. + s.begin_bake(0); + let mut task = s.bake_task_for(0, Some(BakedMap::Height)).unwrap(); + task.tracer = None; + let progress = texturecraft_engine::bake::Progress::new(); + progress.cancel(); + assert!(task.run(&progress).is_err(), "a cancelled bake fails"); + s.bake_abandoned([0]); + assert!(due(&mut s).is_empty(), "not queued again on the next frame"); + + // The next change makes it due again; a bake that never started (queued behind a failed + // one) is recorded the same way. + execute(&mut s, "bake.settings", &json!({ "maxFrontal": 0.3 })).unwrap(); + assert_eq!(due(&mut s), [0]); + s.bake_abandoned([0]); + assert!(due(&mut s).is_empty()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_set_without_the_map_baked_is_left_alone() { + let mut s = Session::new_project("t", primitives::builtin("plane").unwrap(), "builtin:plane", None, 128).unwrap(); + execute(&mut s, "bake.autoRebake", &json!({ "map": "height" })).unwrap(); + execute(&mut s, "bake.settings", &json!({ "maxFrontal": 0.3 })).unwrap(); + assert!(due(&mut s).is_empty(), "Auto-rebake keeps a bake up to date, it doesn't start one"); +} diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index c563685..b62b6ba 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -679,6 +679,11 @@ pub const TABLE: &[(&str, &str)] = &[ ("Restore every default shortcut", "استعادة كل الاختصارات الافتراضية"), ("Return to painting mode", "العودة إلى نمط الرسم"), ("Return to this step", "العودة إلى هذه الخطوة"), + ("Auto-rebake", "إعادة الخبز تلقائيًا"), + ( + "Bake this map again by itself when its bake settings, the low poly or the high polys change", + "يعيد خبز هذه الخريطة تلقائيًا عند تغيّر إعدادات الخبز أو المجسّم منخفض الدقة أو المجسّمات عالية الدقة", + ), ("Right-click a layer > Create smart material", "انقر بزر الفأرة الأيمن على طبقة > إنشاء مادة ذكية"), ("Rotation", "التدوير"), ("Rotation (degrees)", "التدوير (بالدرجات)"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 405cf24..45ea5c2 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -721,6 +721,11 @@ pub const TABLE: &[(&str, &str)] = &[ ("Restore every default shortcut", "Alle Standard-Tastenkürzel wiederherstellen"), ("Return to painting mode", "Zurück zum Malmodus"), ("Return to this step", "Zu diesem Schritt zurückkehren"), + ("Auto-rebake", "Automatisch neu backen"), + ( + "Bake this map again by itself when its bake settings, the low poly or the high polys change", + "Diese Map automatisch neu backen, wenn sich ihre Bake-Einstellungen, das Low-Poly- oder die High-Poly-Meshes ändern", + ), ("Right-click a layer > Create smart material", "Rechtsklick auf eine Ebene > Smart-Material erstellen"), ("Rotation", "Drehung"), ("Rotation (degrees)", "Drehung (Grad)"), diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 4d5ebc1..f5040e6 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -709,6 +709,11 @@ pub const TABLE: &[(&str, &str)] = &[ ("Restore every default shortcut", "Restaurar todos los atajos predeterminados"), ("Return to painting mode", "Volver al modo de pintura"), ("Return to this step", "Volver a este paso"), + ("Auto-rebake", "Rehornear automáticamente"), + ( + "Bake this map again by itself when its bake settings, the low poly or the high polys change", + "Vuelve a hornear este mapa automáticamente cuando cambian sus ajustes de horneado, la malla low poly o las high poly", + ), ("Right-click a layer > Create smart material", "Clic derecho en una capa > Crear material inteligente"), ("Rotation", "Rotación"), ("Rotation (degrees)", "Rotación (grados)"), diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 38e83cc..5a76af6 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -724,6 +724,11 @@ pub const TABLE: &[(&str, &str)] = &[ ("Restore every default shortcut", "Restaurer tous les raccourcis par défaut"), ("Return to painting mode", "Revenir au mode peinture"), ("Return to this step", "Revenir à cette étape"), + ("Auto-rebake", "Précalcul automatique"), + ( + "Bake this map again by itself when its bake settings, the low poly or the high polys change", + "Précalcule à nouveau cette carte automatiquement quand ses réglages de précalcul, le maillage low poly ou les maillages high poly changent", + ), ("Right-click a layer > Create smart material", "Clic droit sur un calque > Créer un matériau intelligent"), ("Rotation", "Rotation"), ("Rotation (degrees)", "Rotation (degrés)"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index ac6d865..011fbe1 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -718,6 +718,11 @@ pub const TABLE: &[(&str, &str)] = &[ ("Restore every default shortcut", "Ripristina tutte le scorciatoie predefinite"), ("Return to painting mode", "Torna alla modalità pittura"), ("Return to this step", "Torna a questo passaggio"), + ("Auto-rebake", "Bake automatico"), + ( + "Bake this map again by itself when its bake settings, the low poly or the high polys change", + "Esegue di nuovo il bake di questa mappa quando cambiano le sue impostazioni di bake, la mesh low poly o le mesh high poly", + ), ("Right-click a layer > Create smart material", "Clic destro su un livello > Crea materiale intelligente"), ("Rotation", "Rotazione"), ("Rotation (degrees)", "Rotazione (gradi)"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 7f04c5c..bf4e840 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -700,6 +700,11 @@ pub const TABLE: &[(&str, &str)] = &[ ("Restore every default shortcut", "すべてのショートカットをデフォルトに戻す"), ("Return to painting mode", "ペイントモードに戻る"), ("Return to this step", "このステップに戻る"), + ("Auto-rebake", "自動再ベイク"), + ( + "Bake this map again by itself when its bake settings, the low poly or the high polys change", + "ベイク設定、ローポリ、またはハイポリが変わると、このマップを自動的にベイクし直します", + ), ("Right-click a layer > Create smart material", "レイヤーを右クリック > スマートマテリアルを作成"), ("Rotation", "回転"), ("Rotation (degrees)", "回転 (度)"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 9b2c650..a9da933 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -691,6 +691,11 @@ pub const TABLE: &[(&str, &str)] = &[ ("Restore every default shortcut", "모든 기본 단축키를 복원합니다"), ("Return to painting mode", "페인팅 모드로 돌아가기"), ("Return to this step", "이 단계로 돌아가기"), + ("Auto-rebake", "자동 다시 베이크"), + ( + "Bake this map again by itself when its bake settings, the low poly or the high polys change", + "베이크 설정, 로우 폴리 또는 하이 폴리가 바뀌면 이 맵을 자동으로 다시 베이크합니다", + ), ("Right-click a layer > Create smart material", "레이어 마우스 오른쪽 클릭 > 스마트 머티리얼 만들기"), ("Rotation", "회전"), ("Rotation (degrees)", "회전 (도)"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index daaf9ff..2da863e 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -706,6 +706,11 @@ pub const TABLE: &[(&str, &str)] = &[ ("Restore every default shortcut", "Restaurar todos os atalhos padrão"), ("Return to painting mode", "Voltar ao modo de pintura"), ("Return to this step", "Voltar a esta etapa"), + ("Auto-rebake", "Refazer bake automaticamente"), + ( + "Bake this map again by itself when its bake settings, the low poly or the high polys change", + "Refaz o bake deste mapa automaticamente quando as configurações de bake, a malha low poly ou as high poly mudam", + ), ("Right-click a layer > Create smart material", "Clique com o botão direito em uma camada > Criar material inteligente"), ("Rotation", "Rotação"), ("Rotation (degrees)", "Rotação (graus)"), diff --git a/crates/ui-egui/src/i18n/zh_hans.rs b/crates/ui-egui/src/i18n/zh_hans.rs index 7701509..4727b3e 100644 --- a/crates/ui-egui/src/i18n/zh_hans.rs +++ b/crates/ui-egui/src/i18n/zh_hans.rs @@ -637,6 +637,8 @@ pub const TABLE: &[(&str, &str)] = &[ ("Restore every default shortcut", "恢复所有默认快捷键"), ("Return to painting mode", "返回绘画模式"), ("Return to this step", "返回到此步骤"), + ("Auto-rebake", "自动重新烘焙"), + ("Bake this map again by itself when its bake settings, the low poly or the high polys change", "当烘焙设置、低模或高模发生变化时,自动重新烘焙此贴图"), ("Right-click a layer > Create smart material", "右键单击图层 > 创建智能材质"), ("Rotation", "旋转"), ("Rotation (degrees)", "旋转(度)"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 300836e..db48ec4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -51,6 +51,8 @@ pub struct BakeJob { pub queue: Vec, pub current: Option<(usize, Arc, std::sync::mpsc::Receiver>)>, pub total: usize, + /// Bake only this map (an Auto-rebake); None bakes every ticked map. + pub only: Option, } /// The browser build's notice when WebGPU is unavailable and wgpu fell back to WebGL2. @@ -188,7 +190,7 @@ impl TextureCraftApp { return; } let total = sets.len(); - self.bake = Some(BakeJob { queue: sets, current: None, total }); + self.bake = Some(BakeJob { queue: sets, current: None, total, only: None }); } fn poll_bake(&mut self, ctx: &egui::Context) { @@ -200,19 +202,19 @@ impl TextureCraftApp { let ts = *ts; job.current = None; if let Err(e) = self.session.apply_bake(ts, out) { + self.session.bake_abandoned([ts]); self.toast(e.to_string(), true); } } Ok(Err(e)) => { - job.current = None; let msg = e.to_string(); - self.bake = None; + self.cancel_bake(); self.toast(msg, true); return; } Err(std::sync::mpsc::TryRecvError::Empty) => return, Err(std::sync::mpsc::TryRecvError::Disconnected) => { - self.bake = None; + self.cancel_bake(); self.toast(i18n::tr("The bake worker stopped"), true); return; } @@ -225,8 +227,9 @@ impl TextureCraftApp { self.toast(i18n::tr("Mesh maps baked"), false); return; } - let ts = job.queue.remove(0); - match self.session.bake_task(ts) { + let (ts, only) = (job.queue.remove(0), job.only); + self.session.begin_bake(ts); + match self.session.bake_task_for(ts, only) { Ok(task) => { let progress = Arc::new(Progress::new()); let p = progress.clone(); @@ -235,17 +238,35 @@ impl TextureCraftApp { job.current = Some((ts, progress, rx)); } } - Err(e) => self.toast(e.to_string(), true), + Err(e) => { + self.session.bake_abandoned([ts]); + self.toast(e.to_string(), true); + } } } } + /// Auto-rebake: when the chosen map's bake inputs changed, bake it again in the background + /// (one set after another, only that map), unless a bake or a stroke is running. + fn drive_auto_rebake(&mut self) { + if self.bake.is_some() || self.session.stroke_active() || self.session.auto_rebake.map.is_none() { + return; + } + let due = self.session.auto_rebake_due(); + if !due.is_empty() { + let total = due.len(); + self.bake = Some(BakeJob { queue: due, current: None, total, only: self.session.auto_rebake.map }); + } + } + + /// Stops the bake job (also after an error). The set being baked and the queued ones wait for + /// their next bake input change before Auto-rebake tries them again. pub fn cancel_bake(&mut self) { - if let Some(job) = self.bake.take() - && let Some((_, p, _)) = job.current - { + let Some(job) = self.bake.take() else { return }; + if let Some((_, p, _)) = &job.current { p.cancel.store(true, Ordering::Relaxed); } + self.session.bake_abandoned(job.current.map(|(ts, ..)| ts).into_iter().chain(job.queue)); } /// Resize texture set `ts` to `resolution` on a worker thread (the document changes when @@ -381,6 +402,7 @@ impl eframe::App for TextureCraftApp { web_files::dropped(self, ctx, f); } self.poll_bake(ctx); + self.drive_auto_rebake(); self.poll_resize(ctx); let scale = self.ui.prefs.ui_scale.clamp(0.5, 3.0); if (ctx.zoom_factor() - scale).abs() > 1e-3 { diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index e9d9570..516c0c2 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -723,7 +723,17 @@ fn bake_panel(app: &mut TextureCraftApp, ui: &mut Ui) { ui.separator(); match selected { None => common_params(app, ui, &mut b), - Some(m) => map_params(ui, m, &mut b), + Some(m) => { + let mut auto = app.session.auto_rebake.map == Some(m); + if ui + .checkbox(&mut auto, tr("Auto-rebake")) + .on_hover_text(tr("Bake this map again by itself when its bake settings, the low poly or the high polys change")) + .changed() + { + app.run("bake.autoRebake", if auto { json!({ "map": m.id() }) } else { json!({ "off": true }) }); + } + map_params(ui, m, &mut b) + } } if b.direction == [0.0; 3] { b.direction = before.direction;